From c252c03401881fd7dbf7fab984285c402eb31d5f Mon Sep 17 00:00:00 2001 From: Luke Bennett <lukeeeebennettplus@gmail.com> Date: Sun, 9 Oct 2016 23:40:58 +0100 Subject: [PATCH 001/363] Added raven and raven-vue plugin, updated gon_helper with data needed for raven and created raven_config, required by application.js Added is_production to define sentry environment Removed as much jQuery as possible Added public_sentry_dsn application_settings helper method Use URI module instead of regex for public dsn Removed raven-vue and load raven on if sentry is enabled Add load_script spec added raven_config spec added class_spec_helper and tests added sentry_helper spec added feature spec --- app/assets/javascripts/application.js | 1 + .../javascripts/lib/utils/load_script.js.es6 | 26 + app/assets/javascripts/raven_config.js.es6 | 66 + app/helpers/sentry_helper.rb | 8 + config/application.rb | 1 + lib/gitlab/gon_helper.rb | 6 + spec/features/raven_js_spec.rb | 24 + spec/helpers/sentry_helper_spec.rb | 15 + spec/javascripts/class_spec_helper.js.es6 | 10 + .../javascripts/class_spec_helper_spec.js.es6 | 35 + .../lib/utils/load_script_spec.js.es6 | 95 + spec/javascripts/raven_config_spec.js.es6 | 142 + spec/javascripts/spec_helper.js | 1 + vendor/assets/javascripts/raven.js | 2547 +++++++++++++++++ 14 files changed, 2977 insertions(+) create mode 100644 app/assets/javascripts/lib/utils/load_script.js.es6 create mode 100644 app/assets/javascripts/raven_config.js.es6 create mode 100644 spec/features/raven_js_spec.rb create mode 100644 spec/helpers/sentry_helper_spec.rb create mode 100644 spec/javascripts/class_spec_helper.js.es6 create mode 100644 spec/javascripts/class_spec_helper_spec.js.es6 create mode 100644 spec/javascripts/lib/utils/load_script_spec.js.es6 create mode 100644 spec/javascripts/raven_config_spec.js.es6 create mode 100644 vendor/assets/javascripts/raven.js diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index f0615481ed2cd..94902e560a8b4 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -62,6 +62,7 @@ /*= require_directory . */ /*= require fuzzaldrin-plus */ /*= require es6-promise.auto */ +/*= require raven_config */ (function () { document.addEventListener('page:fetch', function () { diff --git a/app/assets/javascripts/lib/utils/load_script.js.es6 b/app/assets/javascripts/lib/utils/load_script.js.es6 new file mode 100644 index 0000000000000..351d96530eddf --- /dev/null +++ b/app/assets/javascripts/lib/utils/load_script.js.es6 @@ -0,0 +1,26 @@ +(() => { + const global = window.gl || (window.gl = {}); + + class LoadScript { + static load(source, id = '') { + if (!source) return Promise.reject('source url must be defined'); + if (id && document.querySelector(`#${id}`)) return Promise.reject('script id already exists'); + return new Promise((resolve, reject) => this.appendScript(source, id, resolve, reject)); + } + + static appendScript(source, id, resolve, reject) { + const scriptElement = document.createElement('script'); + scriptElement.type = 'text/javascript'; + if (id) scriptElement.id = id; + scriptElement.onload = resolve; + scriptElement.onerror = reject; + scriptElement.src = source; + + document.body.appendChild(scriptElement); + } + } + + global.LoadScript = LoadScript; + + return global.LoadScript; +})(); diff --git a/app/assets/javascripts/raven_config.js.es6 b/app/assets/javascripts/raven_config.js.es6 new file mode 100644 index 0000000000000..e15eeb9f9cd75 --- /dev/null +++ b/app/assets/javascripts/raven_config.js.es6 @@ -0,0 +1,66 @@ +/* global Raven */ + +/*= require lib/utils/load_script */ + +(() => { + const global = window.gl || (window.gl = {}); + + class RavenConfig { + static init(options = {}) { + this.options = options; + if (!this.options.sentryDsn || !this.options.ravenAssetUrl) return Promise.reject('sentry dsn and raven asset url is required'); + return global.LoadScript.load(this.options.ravenAssetUrl, 'raven-js') + .then(() => { + this.configure(); + this.bindRavenErrors(); + if (this.options.currentUserId) this.setUser(); + }); + } + + static configure() { + Raven.config(this.options.sentryDsn, { + whitelistUrls: this.options.whitelistUrls, + environment: this.options.isProduction ? 'production' : 'development', + }).install(); + } + + static setUser() { + Raven.setUserContext({ + id: this.options.currentUserId, + }); + } + + static bindRavenErrors() { + $(document).on('ajaxError.raven', this.handleRavenErrors); + } + + static handleRavenErrors(event, req, config, err) { + const error = err || req.statusText; + Raven.captureMessage(error, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: req.responseText.substring(0, 100), + error, + event, + }, + }); + } + } + + global.RavenConfig = RavenConfig; + + document.addEventListener('DOMContentLoaded', () => { + if (!window.gon) return; + + global.RavenConfig.init({ + sentryDsn: gon.sentry_dsn, + ravenAssetUrl: gon.raven_asset_url, + currentUserId: gon.current_user_id, + whitelistUrls: [gon.gitlab_url], + isProduction: gon.is_production, + }).catch($.noop); + }); +})(); diff --git a/app/helpers/sentry_helper.rb b/app/helpers/sentry_helper.rb index 3d255df66a0c3..19de38ac52db6 100644 --- a/app/helpers/sentry_helper.rb +++ b/app/helpers/sentry_helper.rb @@ -6,4 +6,12 @@ def sentry_enabled? def sentry_context Gitlab::Sentry.context(current_user) end + + def sentry_dsn_public + sentry_dsn = ApplicationSetting.current.sentry_dsn + return unless sentry_dsn + uri = URI.parse(sentry_dsn) + uri.password = nil + uri.to_s + end end diff --git a/config/application.rb b/config/application.rb index f00e58a36ca89..8af6eccf3fe0c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -92,6 +92,7 @@ class Application < Rails::Application config.assets.precompile << "katex.css" config.assets.precompile << "katex.js" config.assets.precompile << "xterm/xterm.css" + config.assets.precompile << "raven.js" config.assets.precompile << "graphs/graphs_bundle.js" config.assets.precompile << "users/users_bundle.js" config.assets.precompile << "network/network_bundle.js" diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index b8a5ac907a44f..70ffb68c9abc7 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -1,3 +1,5 @@ +include SentryHelper + module Gitlab module GonHelper def add_gon_variables @@ -10,6 +12,10 @@ def add_gon_variables gon.award_menu_url = emojis_path gon.katex_css_url = ActionController::Base.helpers.asset_path('katex.css') gon.katex_js_url = ActionController::Base.helpers.asset_path('katex.js') + gon.sentry_dsn = sentry_dsn_public if sentry_enabled? + gon.raven_asset_url = ActionController::Base.helpers.asset_path('raven.js') if sentry_enabled? + gon.gitlab_url = Gitlab.config.gitlab.url + gon.is_production = Rails.env.production? if current_user gon.current_user_id = current_user.id diff --git a/spec/features/raven_js_spec.rb b/spec/features/raven_js_spec.rb new file mode 100644 index 0000000000000..e64da1e3a974f --- /dev/null +++ b/spec/features/raven_js_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +feature 'RavenJS', feature: true, js: true do + let(:raven_path) { '/raven.js' } + + it 'should not load raven if sentry is disabled' do + visit new_user_session_path + + expect(has_requested_raven).to eq(false) + end + + it 'should load raven if sentry is enabled' do + allow_any_instance_of(ApplicationController).to receive_messages(sentry_dsn_public: 'https://mock:sentry@dsn/path', + sentry_enabled?: true) + + visit new_user_session_path + + expect(has_requested_raven).to eq(true) + end + + def has_requested_raven + page.driver.network_traffic.one? {|request| request.url.end_with?(raven_path)} + end +end diff --git a/spec/helpers/sentry_helper_spec.rb b/spec/helpers/sentry_helper_spec.rb new file mode 100644 index 0000000000000..35ecf9355e165 --- /dev/null +++ b/spec/helpers/sentry_helper_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' + +describe SentryHelper do + describe '#sentry_dsn_public' do + it 'returns nil if no sentry_dsn is set' do + allow(ApplicationSetting.current).to receive(:sentry_dsn).and_return(nil) + expect(helper.sentry_dsn_public).to eq(nil) + end + + it 'returns the uri string with no password if sentry_dsn is set' do + allow(ApplicationSetting.current).to receive(:sentry_dsn).and_return('https://test:dsn@host/path') + expect(helper.sentry_dsn_public).to eq('https://test@host/path') + end + end +end diff --git a/spec/javascripts/class_spec_helper.js.es6 b/spec/javascripts/class_spec_helper.js.es6 new file mode 100644 index 0000000000000..3a04e170924d5 --- /dev/null +++ b/spec/javascripts/class_spec_helper.js.es6 @@ -0,0 +1,10 @@ +/* eslint-disable no-unused-vars */ + +class ClassSpecHelper { + static itShouldBeAStaticMethod(base, method) { + return it('should be a static method', () => { + expect(base[method]).toBeDefined(); + expect(base.prototype[method]).toBeUndefined(); + }); + } +} diff --git a/spec/javascripts/class_spec_helper_spec.js.es6 b/spec/javascripts/class_spec_helper_spec.js.es6 new file mode 100644 index 0000000000000..d1155f1bd1edd --- /dev/null +++ b/spec/javascripts/class_spec_helper_spec.js.es6 @@ -0,0 +1,35 @@ +/* global ClassSpecHelper */ +//= require ./class_spec_helper + +describe('ClassSpecHelper', () => { + describe('.itShouldBeAStaticMethod', function () { + beforeEach(() => { + class TestClass { + instanceMethod() { this.prop = 'val'; } + static staticMethod() {} + } + + this.TestClass = TestClass; + }); + + ClassSpecHelper.itShouldBeAStaticMethod(ClassSpecHelper, 'itShouldBeAStaticMethod'); + + it('should have a defined spec', () => { + expect(ClassSpecHelper.itShouldBeAStaticMethod(this.TestClass, 'staticMethod').description).toBe('should be a static method'); + }); + + it('should pass for a static method', () => { + const spec = ClassSpecHelper.itShouldBeAStaticMethod(this.TestClass, 'staticMethod'); + expect(spec.status()).toBe('passed'); + }); + + it('should fail for an instance method', (done) => { + const spec = ClassSpecHelper.itShouldBeAStaticMethod(this.TestClass, 'instanceMethod'); + spec.resultCallback = (result) => { + expect(result.status).toBe('failed'); + done(); + }; + spec.execute(); + }); + }); +}); diff --git a/spec/javascripts/lib/utils/load_script_spec.js.es6 b/spec/javascripts/lib/utils/load_script_spec.js.es6 new file mode 100644 index 0000000000000..52c53327695c7 --- /dev/null +++ b/spec/javascripts/lib/utils/load_script_spec.js.es6 @@ -0,0 +1,95 @@ +/* global ClassSpecHelper */ + +/*= require lib/utils/load_script */ +/*= require class_spec_helper */ + +describe('LoadScript', () => { + const global = window.gl || (window.gl = {}); + const LoadScript = global.LoadScript; + + it('should be defined in the global scope', () => { + expect(LoadScript).toBeDefined(); + }); + + describe('.load', () => { + ClassSpecHelper.itShouldBeAStaticMethod(LoadScript, 'load'); + + it('should reject if no source argument is provided', () => { + spyOn(Promise, 'reject'); + LoadScript.load(); + expect(Promise.reject).toHaveBeenCalledWith('source url must be defined'); + }); + + it('should reject if the script id already exists', () => { + spyOn(Promise, 'reject'); + spyOn(document, 'querySelector').and.returnValue({}); + LoadScript.load('src.js', 'src-id'); + + expect(Promise.reject).toHaveBeenCalledWith('script id already exists'); + }); + + it('should return a promise on completion', () => { + expect(LoadScript.load('src.js')).toEqual(jasmine.any(Promise)); + }); + + it('should call appendScript when the promise is constructed', () => { + spyOn(LoadScript, 'appendScript'); + LoadScript.load('src.js', 'src-id'); + + expect(LoadScript.appendScript).toHaveBeenCalledWith('src.js', 'src-id', jasmine.any(Promise.resolve.constructor), jasmine.any(Promise.reject.constructor)); + }); + }); + + describe('.appendScript', () => { + beforeEach(() => { + spyOn(document.body, 'appendChild'); + }); + + ClassSpecHelper.itShouldBeAStaticMethod(LoadScript, 'appendScript'); + + describe('when called', () => { + let mockScriptTag; + + beforeEach(() => { + mockScriptTag = {}; + spyOn(document, 'createElement').and.returnValue(mockScriptTag); + LoadScript.appendScript('src.js', 'src-id', () => {}, () => {}); + }); + + it('should create a script tag', () => { + expect(document.createElement).toHaveBeenCalledWith('script'); + }); + + it('should set the MIME type', () => { + expect(mockScriptTag.type).toBe('text/javascript'); + }); + + it('should set the script id', () => { + expect(mockScriptTag.id).toBe('src-id'); + }); + + it('should set an onload handler', () => { + expect(mockScriptTag.onload).toEqual(jasmine.any(Function)); + }); + + it('should set an onerror handler', () => { + expect(mockScriptTag.onerror).toEqual(jasmine.any(Function)); + }); + + it('should set the src attribute', () => { + expect(mockScriptTag.src).toBe('src.js'); + }); + + it('should append the script tag to the body element', () => { + expect(document.body.appendChild).toHaveBeenCalledWith(mockScriptTag); + }); + }); + + it('should not set the script id if no id is provided', () => { + const mockScriptTag = {}; + spyOn(document, 'createElement').and.returnValue(mockScriptTag); + LoadScript.appendScript('src.js', undefined); + expect(mockScriptTag.id).toBeUndefined(); + }); + }); +}); diff --git a/spec/javascripts/raven_config_spec.js.es6 b/spec/javascripts/raven_config_spec.js.es6 new file mode 100644 index 0000000000000..25df2cec75f03 --- /dev/null +++ b/spec/javascripts/raven_config_spec.js.es6 @@ -0,0 +1,142 @@ +/* global ClassSpecHelper */ + +/*= require raven */ +/*= require lib/utils/load_script */ +/*= require raven_config */ +/*= require class_spec_helper */ + +describe('RavenConfig', () => { + const global = window.gl || (window.gl = {}); + const RavenConfig = global.RavenConfig; + + it('should be defined in the global scope', () => { + expect(RavenConfig).toBeDefined(); + }); + + describe('.init', () => { + beforeEach(() => { + spyOn(global.LoadScript, 'load').and.callThrough(); + spyOn(document, 'querySelector').and.returnValue(undefined); + spyOn(RavenConfig, 'configure'); + spyOn(RavenConfig, 'bindRavenErrors'); + spyOn(RavenConfig, 'setUser'); + spyOn(Promise, 'reject'); + }); + + ClassSpecHelper.itShouldBeAStaticMethod(RavenConfig, 'init'); + + describe('when called', () => { + let options; + let initPromise; + + beforeEach(() => { + options = { + sentryDsn: '//sentryDsn', + ravenAssetUrl: '//ravenAssetUrl', + currentUserId: 1, + whitelistUrls: ['//gitlabUrl'], + isProduction: true, + }; + initPromise = RavenConfig.init(options); + }); + + it('should set the options property', () => { + expect(RavenConfig.options).toEqual(options); + }); + + it('should load a #raven-js script with the raven asset URL', () => { + expect(global.LoadScript.load).toHaveBeenCalledWith(options.ravenAssetUrl, 'raven-js'); + }); + + it('should return a promise', () => { + expect(initPromise).toEqual(jasmine.any(Promise)); + }); + + it('should call the configure method', () => { + initPromise.then(() => { + expect(RavenConfig.configure).toHaveBeenCalled(); + }); + }); + + it('should call the error bindings method', () => { + initPromise.then(() => { + expect(RavenConfig.bindRavenErrors).toHaveBeenCalled(); + }); + }); + + it('should call setUser', () => { + initPromise.then(() => { + expect(RavenConfig.setUser).toHaveBeenCalled(); + }); + }); + }); + + it('should not call setUser if there is no current user ID', () => { + RavenConfig.init({ + sentryDsn: '//sentryDsn', + ravenAssetUrl: '//ravenAssetUrl', + currentUserId: undefined, + whitelistUrls: ['//gitlabUrl'], + isProduction: true, + }); + + expect(RavenConfig.setUser).not.toHaveBeenCalled(); + }); + + it('should reject if there is no Sentry DSN', () => { + RavenConfig.init({ + sentryDsn: undefined, + ravenAssetUrl: '//ravenAssetUrl', + currentUserId: 1, + whitelistUrls: ['//gitlabUrl'], + isProduction: true, + }); + + expect(Promise.reject).toHaveBeenCalledWith('sentry dsn and raven asset url is required'); + }); + + it('should reject if there is no Raven asset URL', () => { + RavenConfig.init({ + sentryDsn: '//sentryDsn', + ravenAssetUrl: undefined, + currentUserId: 1, + whitelistUrls: ['//gitlabUrl'], + isProduction: true, + }); + + expect(Promise.reject).toHaveBeenCalledWith('sentry dsn and raven asset url is required'); + }); + }); + + describe('.configure', () => { + ClassSpecHelper.itShouldBeAStaticMethod(RavenConfig, 'configure'); + + describe('when called', () => { + beforeEach(() => {}); + }); + }); + + describe('.setUser', () => { + ClassSpecHelper.itShouldBeAStaticMethod(RavenConfig, 'setUser'); + + describe('when called', () => { + beforeEach(() => {}); + }); + }); + + describe('.bindRavenErrors', () => { + ClassSpecHelper.itShouldBeAStaticMethod(RavenConfig, 'bindRavenErrors'); + + describe('when called', () => { + beforeEach(() => {}); + }); + }); + + describe('.handleRavenErrors', () => { + ClassSpecHelper.itShouldBeAStaticMethod(RavenConfig, 'handleRavenErrors'); + + describe('when called', () => { + beforeEach(() => {}); + }); + }); +}); diff --git a/spec/javascripts/spec_helper.js b/spec/javascripts/spec_helper.js index f8e3aca29fa52..ee6e6279ac9fc 100644 --- a/spec/javascripts/spec_helper.js +++ b/spec/javascripts/spec_helper.js @@ -11,6 +11,7 @@ /*= require jquery.turbolinks */ /*= require bootstrap */ /*= require underscore */ +/*= require es6-promise.auto */ // Teaspoon includes some support files, but you can use anything from your own // support path too. diff --git a/vendor/assets/javascripts/raven.js b/vendor/assets/javascripts/raven.js new file mode 100644 index 0000000000000..ba416b26be14d --- /dev/null +++ b/vendor/assets/javascripts/raven.js @@ -0,0 +1,2547 @@ +/*! Raven.js 3.9.1 (7bbae7d) | github.com/getsentry/raven-js */ + +/* + * Includes TraceKit + * https://github.com/getsentry/TraceKit + * + * Copyright 2016 Matt Robenolt and other contributors + * Released under the BSD license + * https://github.com/getsentry/raven-js/blob/master/LICENSE + * + */ + +(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.Raven = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(_dereq_,module,exports){ +exports = module.exports = stringify +exports.getSerialize = serializer + +function stringify(obj, replacer, spaces, cycleReplacer) { + return JSON.stringify(obj, serializer(replacer, cycleReplacer), spaces) +} + +function serializer(replacer, cycleReplacer) { + var stack = [], keys = [] + + if (cycleReplacer == null) cycleReplacer = function(key, value) { + if (stack[0] === value) return "[Circular ~]" + return "[Circular ~." + keys.slice(0, stack.indexOf(value)).join(".") + "]" + } + + return function(key, value) { + if (stack.length > 0) { + var thisPos = stack.indexOf(this) + ~thisPos ? stack.splice(thisPos + 1) : stack.push(this) + ~thisPos ? keys.splice(thisPos, Infinity, key) : keys.push(key) + if (~stack.indexOf(value)) value = cycleReplacer.call(this, key, value) + } + else stack.push(value) + + return replacer == null ? value : replacer.call(this, key, value) + } +} + +},{}],2:[function(_dereq_,module,exports){ +'use strict'; + +function RavenConfigError(message) { + this.name = 'RavenConfigError'; + this.message = message; +} +RavenConfigError.prototype = new Error(); +RavenConfigError.prototype.constructor = RavenConfigError; + +module.exports = RavenConfigError; + +},{}],3:[function(_dereq_,module,exports){ +'use strict'; + +var wrapMethod = function(console, level, callback) { + var originalConsoleLevel = console[level]; + var originalConsole = console; + + if (!(level in console)) { + return; + } + + var sentryLevel = level === 'warn' + ? 'warning' + : level; + + console[level] = function () { + var args = [].slice.call(arguments); + + var msg = '' + args.join(' '); + var data = {level: sentryLevel, logger: 'console', extra: {'arguments': args}}; + callback && callback(msg, data); + + // this fails for some browsers. :( + if (originalConsoleLevel) { + // IE9 doesn't allow calling apply on console functions directly + // See: https://stackoverflow.com/questions/5472938/does-ie9-support-console-log-and-is-it-a-real-function#answer-5473193 + Function.prototype.apply.call( + originalConsoleLevel, + originalConsole, + args + ); + } + }; +}; + +module.exports = { + wrapMethod: wrapMethod +}; + +},{}],4:[function(_dereq_,module,exports){ +(function (global){ +/*global XDomainRequest:false, __DEV__:false*/ +'use strict'; + +var TraceKit = _dereq_(6); +var RavenConfigError = _dereq_(2); +var stringify = _dereq_(1); + +var wrapConsoleMethod = _dereq_(3).wrapMethod; + +var dsnKeys = 'source protocol user pass host port path'.split(' '), + dsnPattern = /^(?:(\w+):)?\/\/(?:(\w+)(:\w+)?@)?([\w\.-]+)(?::(\d+))?(\/.*)/; + +function now() { + return +new Date(); +} + +// This is to be defensive in environments where window does not exist (see https://github.com/getsentry/raven-js/pull/785) +var _window = typeof window !== 'undefined' ? window + : typeof global !== 'undefined' ? global + : typeof self !== 'undefined' ? self + : {}; +var _document = _window.document; + +// First, check for JSON support +// If there is no JSON, we no-op the core features of Raven +// since JSON is required to encode the payload +function Raven() { + this._hasJSON = !!(typeof JSON === 'object' && JSON.stringify); + // Raven can run in contexts where there's no document (react-native) + this._hasDocument = !isUndefined(_document); + this._lastCapturedException = null; + this._lastEventId = null; + this._globalServer = null; + this._globalKey = null; + this._globalProject = null; + this._globalContext = {}; + this._globalOptions = { + logger: 'javascript', + ignoreErrors: [], + ignoreUrls: [], + whitelistUrls: [], + includePaths: [], + crossOrigin: 'anonymous', + collectWindowErrors: true, + maxMessageLength: 0, + stackTraceLimit: 50, + autoBreadcrumbs: true + }; + this._ignoreOnError = 0; + this._isRavenInstalled = false; + this._originalErrorStackTraceLimit = Error.stackTraceLimit; + // capture references to window.console *and* all its methods first + // before the console plugin has a chance to monkey patch + this._originalConsole = _window.console || {}; + this._originalConsoleMethods = {}; + this._plugins = []; + this._startTime = now(); + this._wrappedBuiltIns = []; + this._breadcrumbs = []; + this._lastCapturedEvent = null; + this._keypressTimeout; + this._location = _window.location; + this._lastHref = this._location && this._location.href; + + for (var method in this._originalConsole) { // eslint-disable-line guard-for-in + this._originalConsoleMethods[method] = this._originalConsole[method]; + } +} + +/* + * The core Raven singleton + * + * @this {Raven} + */ + +Raven.prototype = { + // Hardcode version string so that raven source can be loaded directly via + // webpack (using a build step causes webpack #1617). Grunt verifies that + // this value matches package.json during build. + // See: https://github.com/getsentry/raven-js/issues/465 + VERSION: '3.9.1', + + debug: false, + + TraceKit: TraceKit, // alias to TraceKit + + /* + * Configure Raven with a DSN and extra options + * + * @param {string} dsn The public Sentry DSN + * @param {object} options Optional set of of global options [optional] + * @return {Raven} + */ + config: function(dsn, options) { + var self = this; + + if (self._globalServer) { + this._logDebug('error', 'Error: Raven has already been configured'); + return self; + } + if (!dsn) return self; + + var globalOptions = self._globalOptions; + + // merge in options + if (options) { + each(options, function(key, value){ + // tags and extra are special and need to be put into context + if (key === 'tags' || key === 'extra' || key === 'user') { + self._globalContext[key] = value; + } else { + globalOptions[key] = value; + } + }); + } + + self.setDSN(dsn); + + // "Script error." is hard coded into browsers for errors that it can't read. + // this is the result of a script being pulled in from an external domain and CORS. + globalOptions.ignoreErrors.push(/^Script error\.?$/); + globalOptions.ignoreErrors.push(/^Javascript error: Script error\.? on line 0$/); + + // join regexp rules into one big rule + globalOptions.ignoreErrors = joinRegExp(globalOptions.ignoreErrors); + globalOptions.ignoreUrls = globalOptions.ignoreUrls.length ? joinRegExp(globalOptions.ignoreUrls) : false; + globalOptions.whitelistUrls = globalOptions.whitelistUrls.length ? joinRegExp(globalOptions.whitelistUrls) : false; + globalOptions.includePaths = joinRegExp(globalOptions.includePaths); + globalOptions.maxBreadcrumbs = Math.max(0, Math.min(globalOptions.maxBreadcrumbs || 100, 100)); // default and hard limit is 100 + + var autoBreadcrumbDefaults = { + xhr: true, + console: true, + dom: true, + location: true + }; + + var autoBreadcrumbs = globalOptions.autoBreadcrumbs; + if ({}.toString.call(autoBreadcrumbs) === '[object Object]') { + autoBreadcrumbs = objectMerge(autoBreadcrumbDefaults, autoBreadcrumbs); + } else if (autoBreadcrumbs !== false) { + autoBreadcrumbs = autoBreadcrumbDefaults; + } + globalOptions.autoBreadcrumbs = autoBreadcrumbs; + + TraceKit.collectWindowErrors = !!globalOptions.collectWindowErrors; + + // return for chaining + return self; + }, + + /* + * Installs a global window.onerror error handler + * to capture and report uncaught exceptions. + * At this point, install() is required to be called due + * to the way TraceKit is set up. + * + * @return {Raven} + */ + install: function() { + var self = this; + if (self.isSetup() && !self._isRavenInstalled) { + TraceKit.report.subscribe(function () { + self._handleOnErrorStackInfo.apply(self, arguments); + }); + self._instrumentTryCatch(); + if (self._globalOptions.autoBreadcrumbs) + self._instrumentBreadcrumbs(); + + // Install all of the plugins + self._drainPlugins(); + + self._isRavenInstalled = true; + } + + Error.stackTraceLimit = self._globalOptions.stackTraceLimit; + return this; + }, + + /* + * Set the DSN (can be called multiple time unlike config) + * + * @param {string} dsn The public Sentry DSN + */ + setDSN: function(dsn) { + var self = this, + uri = self._parseDSN(dsn), + lastSlash = uri.path.lastIndexOf('/'), + path = uri.path.substr(1, lastSlash); + + self._dsn = dsn; + self._globalKey = uri.user; + self._globalSecret = uri.pass && uri.pass.substr(1); + self._globalProject = uri.path.substr(lastSlash + 1); + + self._globalServer = self._getGlobalServer(uri); + + self._globalEndpoint = self._globalServer + + '/' + path + 'api/' + self._globalProject + '/store/'; + }, + + /* + * Wrap code within a context so Raven can capture errors + * reliably across domains that is executed immediately. + * + * @param {object} options A specific set of options for this context [optional] + * @param {function} func The callback to be immediately executed within the context + * @param {array} args An array of arguments to be called with the callback [optional] + */ + context: function(options, func, args) { + if (isFunction(options)) { + args = func || []; + func = options; + options = undefined; + } + + return this.wrap(options, func).apply(this, args); + }, + + /* + * Wrap code within a context and returns back a new function to be executed + * + * @param {object} options A specific set of options for this context [optional] + * @param {function} func The function to be wrapped in a new context + * @param {function} func A function to call before the try/catch wrapper [optional, private] + * @return {function} The newly wrapped functions with a context + */ + wrap: function(options, func, _before) { + var self = this; + // 1 argument has been passed, and it's not a function + // so just return it + if (isUndefined(func) && !isFunction(options)) { + return options; + } + + // options is optional + if (isFunction(options)) { + func = options; + options = undefined; + } + + // At this point, we've passed along 2 arguments, and the second one + // is not a function either, so we'll just return the second argument. + if (!isFunction(func)) { + return func; + } + + // We don't wanna wrap it twice! + try { + if (func.__raven__) { + return func; + } + + // If this has already been wrapped in the past, return that + if (func.__raven_wrapper__ ){ + return func.__raven_wrapper__ ; + } + } catch (e) { + // Just accessing custom props in some Selenium environments + // can cause a "Permission denied" exception (see raven-js#495). + // Bail on wrapping and return the function as-is (defers to window.onerror). + return func; + } + + function wrapped() { + var args = [], i = arguments.length, + deep = !options || options && options.deep !== false; + + if (_before && isFunction(_before)) { + _before.apply(this, arguments); + } + + // Recursively wrap all of a function's arguments that are + // functions themselves. + while(i--) args[i] = deep ? self.wrap(options, arguments[i]) : arguments[i]; + + try { + return func.apply(this, args); + } catch(e) { + self._ignoreNextOnError(); + self.captureException(e, options); + throw e; + } + } + + // copy over properties of the old function + for (var property in func) { + if (hasKey(func, property)) { + wrapped[property] = func[property]; + } + } + wrapped.prototype = func.prototype; + + func.__raven_wrapper__ = wrapped; + // Signal that this function has been wrapped already + // for both debugging and to prevent it to being wrapped twice + wrapped.__raven__ = true; + wrapped.__inner__ = func; + + return wrapped; + }, + + /* + * Uninstalls the global error handler. + * + * @return {Raven} + */ + uninstall: function() { + TraceKit.report.uninstall(); + + this._restoreBuiltIns(); + + Error.stackTraceLimit = this._originalErrorStackTraceLimit; + this._isRavenInstalled = false; + + return this; + }, + + /* + * Manually capture an exception and send it over to Sentry + * + * @param {error} ex An exception to be logged + * @param {object} options A specific set of options for this error [optional] + * @return {Raven} + */ + captureException: function(ex, options) { + // If not an Error is passed through, recall as a message instead + if (!isError(ex)) { + return this.captureMessage(ex, objectMerge({ + trimHeadFrames: 1, + stacktrace: true // if we fall back to captureMessage, default to attempting a new trace + }, options)); + } + + // Store the raw exception object for potential debugging and introspection + this._lastCapturedException = ex; + + // TraceKit.report will re-raise any exception passed to it, + // which means you have to wrap it in try/catch. Instead, we + // can wrap it here and only re-raise if TraceKit.report + // raises an exception different from the one we asked to + // report on. + try { + var stack = TraceKit.computeStackTrace(ex); + this._handleStackInfo(stack, options); + } catch(ex1) { + if(ex !== ex1) { + throw ex1; + } + } + + return this; + }, + + /* + * Manually send a message to Sentry + * + * @param {string} msg A plain message to be captured in Sentry + * @param {object} options A specific set of options for this message [optional] + * @return {Raven} + */ + captureMessage: function(msg, options) { + // config() automagically converts ignoreErrors from a list to a RegExp so we need to test for an + // early call; we'll error on the side of logging anything called before configuration since it's + // probably something you should see: + if (!!this._globalOptions.ignoreErrors.test && this._globalOptions.ignoreErrors.test(msg)) { + return; + } + + options = options || {}; + + var data = objectMerge({ + message: msg + '' // Make sure it's actually a string + }, options); + + if (this._globalOptions.stacktrace || (options && options.stacktrace)) { + var ex; + // create a stack trace from this point; just trim + // off extra frames so they don't include this function call (or + // earlier Raven.js library fn calls) + try { + throw new Error(msg); + } catch (ex1) { + ex = ex1; + } + + // null exception name so `Error` isn't prefixed to msg + ex.name = null; + + options = objectMerge({ + // fingerprint on msg, not stack trace (legacy behavior, could be + // revisited) + fingerprint: msg, + trimHeadFrames: (options.trimHeadFrames || 0) + 1 + }, options); + + var stack = TraceKit.computeStackTrace(ex); + var frames = this._prepareFrames(stack, options); + data.stacktrace = { + // Sentry expects frames oldest to newest + frames: frames.reverse() + } + } + + // Fire away! + this._send(data); + + return this; + }, + + captureBreadcrumb: function (obj) { + var crumb = objectMerge({ + timestamp: now() / 1000 + }, obj); + + if (isFunction(this._globalOptions.breadcrumbCallback)) { + var result = this._globalOptions.breadcrumbCallback(crumb); + + if (isObject(result) && !isEmptyObject(result)) { + crumb = result; + } else if (result === false) { + return this; + } + } + + this._breadcrumbs.push(crumb); + if (this._breadcrumbs.length > this._globalOptions.maxBreadcrumbs) { + this._breadcrumbs.shift(); + } + return this; + }, + + addPlugin: function(plugin /*arg1, arg2, ... argN*/) { + var pluginArgs = [].slice.call(arguments, 1); + + this._plugins.push([plugin, pluginArgs]); + if (this._isRavenInstalled) { + this._drainPlugins(); + } + + return this; + }, + + /* + * Set/clear a user to be sent along with the payload. + * + * @param {object} user An object representing user data [optional] + * @return {Raven} + */ + setUserContext: function(user) { + // Intentionally do not merge here since that's an unexpected behavior. + this._globalContext.user = user; + + return this; + }, + + /* + * Merge extra attributes to be sent along with the payload. + * + * @param {object} extra An object representing extra data [optional] + * @return {Raven} + */ + setExtraContext: function(extra) { + this._mergeContext('extra', extra); + + return this; + }, + + /* + * Merge tags to be sent along with the payload. + * + * @param {object} tags An object representing tags [optional] + * @return {Raven} + */ + setTagsContext: function(tags) { + this._mergeContext('tags', tags); + + return this; + }, + + /* + * Clear all of the context. + * + * @return {Raven} + */ + clearContext: function() { + this._globalContext = {}; + + return this; + }, + + /* + * Get a copy of the current context. This cannot be mutated. + * + * @return {object} copy of context + */ + getContext: function() { + // lol javascript + return JSON.parse(stringify(this._globalContext)); + }, + + + /* + * Set environment of application + * + * @param {string} environment Typically something like 'production'. + * @return {Raven} + */ + setEnvironment: function(environment) { + this._globalOptions.environment = environment; + + return this; + }, + + /* + * Set release version of application + * + * @param {string} release Typically something like a git SHA to identify version + * @return {Raven} + */ + setRelease: function(release) { + this._globalOptions.release = release; + + return this; + }, + + /* + * Set the dataCallback option + * + * @param {function} callback The callback to run which allows the + * data blob to be mutated before sending + * @return {Raven} + */ + setDataCallback: function(callback) { + var original = this._globalOptions.dataCallback; + this._globalOptions.dataCallback = isFunction(callback) + ? function (data) { return callback(data, original); } + : callback; + + return this; + }, + + /* + * Set the breadcrumbCallback option + * + * @param {function} callback The callback to run which allows filtering + * or mutating breadcrumbs + * @return {Raven} + */ + setBreadcrumbCallback: function(callback) { + var original = this._globalOptions.breadcrumbCallback; + this._globalOptions.breadcrumbCallback = isFunction(callback) + ? function (data) { return callback(data, original); } + : callback; + + return this; + }, + + /* + * Set the shouldSendCallback option + * + * @param {function} callback The callback to run which allows + * introspecting the blob before sending + * @return {Raven} + */ + setShouldSendCallback: function(callback) { + var original = this._globalOptions.shouldSendCallback; + this._globalOptions.shouldSendCallback = isFunction(callback) + ? function (data) { return callback(data, original); } + : callback; + + return this; + }, + + /** + * Override the default HTTP transport mechanism that transmits data + * to the Sentry server. + * + * @param {function} transport Function invoked instead of the default + * `makeRequest` handler. + * + * @return {Raven} + */ + setTransport: function(transport) { + this._globalOptions.transport = transport; + + return this; + }, + + /* + * Get the latest raw exception that was captured by Raven. + * + * @return {error} + */ + lastException: function() { + return this._lastCapturedException; + }, + + /* + * Get the last event id + * + * @return {string} + */ + lastEventId: function() { + return this._lastEventId; + }, + + /* + * Determine if Raven is setup and ready to go. + * + * @return {boolean} + */ + isSetup: function() { + if (!this._hasJSON) return false; // needs JSON support + if (!this._globalServer) { + if (!this.ravenNotConfiguredError) { + this.ravenNotConfiguredError = true; + this._logDebug('error', 'Error: Raven has not been configured.'); + } + return false; + } + return true; + }, + + afterLoad: function () { + // TODO: remove window dependence? + + // Attempt to initialize Raven on load + var RavenConfig = _window.RavenConfig; + if (RavenConfig) { + this.config(RavenConfig.dsn, RavenConfig.config).install(); + } + }, + + showReportDialog: function (options) { + if (!_document) // doesn't work without a document (React native) + return; + + options = options || {}; + + var lastEventId = options.eventId || this.lastEventId(); + if (!lastEventId) { + throw new RavenConfigError('Missing eventId'); + } + + var dsn = options.dsn || this._dsn; + if (!dsn) { + throw new RavenConfigError('Missing DSN'); + } + + var encode = encodeURIComponent; + var qs = ''; + qs += '?eventId=' + encode(lastEventId); + qs += '&dsn=' + encode(dsn); + + var user = options.user || this._globalContext.user; + if (user) { + if (user.name) qs += '&name=' + encode(user.name); + if (user.email) qs += '&email=' + encode(user.email); + } + + var globalServer = this._getGlobalServer(this._parseDSN(dsn)); + + var script = _document.createElement('script'); + script.async = true; + script.src = globalServer + '/api/embed/error-page/' + qs; + (_document.head || _document.body).appendChild(script); + }, + + /**** Private functions ****/ + _ignoreNextOnError: function () { + var self = this; + this._ignoreOnError += 1; + setTimeout(function () { + // onerror should trigger before setTimeout + self._ignoreOnError -= 1; + }); + }, + + _triggerEvent: function(eventType, options) { + // NOTE: `event` is a native browser thing, so let's avoid conflicting wiht it + var evt, key; + + if (!this._hasDocument) + return; + + options = options || {}; + + eventType = 'raven' + eventType.substr(0,1).toUpperCase() + eventType.substr(1); + + if (_document.createEvent) { + evt = _document.createEvent('HTMLEvents'); + evt.initEvent(eventType, true, true); + } else { + evt = _document.createEventObject(); + evt.eventType = eventType; + } + + for (key in options) if (hasKey(options, key)) { + evt[key] = options[key]; + } + + if (_document.createEvent) { + // IE9 if standards + _document.dispatchEvent(evt); + } else { + // IE8 regardless of Quirks or Standards + // IE9 if quirks + try { + _document.fireEvent('on' + evt.eventType.toLowerCase(), evt); + } catch(e) { + // Do nothing + } + } + }, + + /** + * Wraps addEventListener to capture UI breadcrumbs + * @param evtName the event name (e.g. "click") + * @returns {Function} + * @private + */ + _breadcrumbEventHandler: function(evtName) { + var self = this; + return function (evt) { + // reset keypress timeout; e.g. triggering a 'click' after + // a 'keypress' will reset the keypress debounce so that a new + // set of keypresses can be recorded + self._keypressTimeout = null; + + // It's possible this handler might trigger multiple times for the same + // event (e.g. event propagation through node ancestors). Ignore if we've + // already captured the event. + if (self._lastCapturedEvent === evt) + return; + + self._lastCapturedEvent = evt; + var elem = evt.target; + + var target; + + // try/catch htmlTreeAsString because it's particularly complicated, and + // just accessing the DOM incorrectly can throw an exception in some circumstances. + try { + target = htmlTreeAsString(elem); + } catch (e) { + target = '<unknown>'; + } + + self.captureBreadcrumb({ + category: 'ui.' + evtName, // e.g. ui.click, ui.input + message: target + }); + }; + }, + + /** + * Wraps addEventListener to capture keypress UI events + * @returns {Function} + * @private + */ + _keypressEventHandler: function() { + var self = this, + debounceDuration = 1000; // milliseconds + + // TODO: if somehow user switches keypress target before + // debounce timeout is triggered, we will only capture + // a single breadcrumb from the FIRST target (acceptable?) + return function (evt) { + var target = evt.target, + tagName = target && target.tagName; + + // only consider keypress events on actual input elements + // this will disregard keypresses targeting body (e.g. tabbing + // through elements, hotkeys, etc) + if (!tagName || tagName !== 'INPUT' && tagName !== 'TEXTAREA' && !target.isContentEditable) + return; + + // record first keypress in a series, but ignore subsequent + // keypresses until debounce clears + var timeout = self._keypressTimeout; + if (!timeout) { + self._breadcrumbEventHandler('input')(evt); + } + clearTimeout(timeout); + self._keypressTimeout = setTimeout(function () { + self._keypressTimeout = null; + }, debounceDuration); + }; + }, + + /** + * Captures a breadcrumb of type "navigation", normalizing input URLs + * @param to the originating URL + * @param from the target URL + * @private + */ + _captureUrlChange: function(from, to) { + var parsedLoc = parseUrl(this._location.href); + var parsedTo = parseUrl(to); + var parsedFrom = parseUrl(from); + + // because onpopstate only tells you the "new" (to) value of location.href, and + // not the previous (from) value, we need to track the value of the current URL + // state ourselves + this._lastHref = to; + + // Use only the path component of the URL if the URL matches the current + // document (almost all the time when using pushState) + if (parsedLoc.protocol === parsedTo.protocol && parsedLoc.host === parsedTo.host) + to = parsedTo.relative; + if (parsedLoc.protocol === parsedFrom.protocol && parsedLoc.host === parsedFrom.host) + from = parsedFrom.relative; + + this.captureBreadcrumb({ + category: 'navigation', + data: { + to: to, + from: from + } + }); + }, + + /** + * Install any queued plugins + */ + _instrumentTryCatch: function() { + var self = this; + + var wrappedBuiltIns = self._wrappedBuiltIns; + + function wrapTimeFn(orig) { + return function (fn, t) { // preserve arity + // Make a copy of the arguments to prevent deoptimization + // https://github.com/petkaantonov/bluebird/wiki/Optimization-killers#32-leaking-arguments + var args = new Array(arguments.length); + for(var i = 0; i < args.length; ++i) { + args[i] = arguments[i]; + } + var originalCallback = args[0]; + if (isFunction(originalCallback)) { + args[0] = self.wrap(originalCallback); + } + + // IE < 9 doesn't support .call/.apply on setInterval/setTimeout, but it + // also supports only two arguments and doesn't care what this is, so we + // can just call the original function directly. + if (orig.apply) { + return orig.apply(this, args); + } else { + return orig(args[0], args[1]); + } + }; + } + + var autoBreadcrumbs = this._globalOptions.autoBreadcrumbs; + + function wrapEventTarget(global) { + var proto = _window[global] && _window[global].prototype; + if (proto && proto.hasOwnProperty && proto.hasOwnProperty('addEventListener')) { + fill(proto, 'addEventListener', function(orig) { + return function (evtName, fn, capture, secure) { // preserve arity + try { + if (fn && fn.handleEvent) { + fn.handleEvent = self.wrap(fn.handleEvent); + } + } catch (err) { + // can sometimes get 'Permission denied to access property "handle Event' + } + + // More breadcrumb DOM capture ... done here and not in `_instrumentBreadcrumbs` + // so that we don't have more than one wrapper function + var before, + clickHandler, + keypressHandler; + + if (autoBreadcrumbs && autoBreadcrumbs.dom && (global === 'EventTarget' || global === 'Node')) { + // NOTE: generating multiple handlers per addEventListener invocation, should + // revisit and verify we can just use one (almost certainly) + clickHandler = self._breadcrumbEventHandler('click'); + keypressHandler = self._keypressEventHandler(); + before = function (evt) { + // need to intercept every DOM event in `before` argument, in case that + // same wrapped method is re-used for different events (e.g. mousemove THEN click) + // see #724 + if (!evt) return; + + if (evt.type === 'click') + return clickHandler(evt); + else if (evt.type === 'keypress') + return keypressHandler(evt); + }; + } + return orig.call(this, evtName, self.wrap(fn, undefined, before), capture, secure); + }; + }, wrappedBuiltIns); + fill(proto, 'removeEventListener', function (orig) { + return function (evt, fn, capture, secure) { + try { + fn = fn && (fn.__raven_wrapper__ ? fn.__raven_wrapper__ : fn); + } catch (e) { + // ignore, accessing __raven_wrapper__ will throw in some Selenium environments + } + return orig.call(this, evt, fn, capture, secure); + }; + }, wrappedBuiltIns); + } + } + + fill(_window, 'setTimeout', wrapTimeFn, wrappedBuiltIns); + fill(_window, 'setInterval', wrapTimeFn, wrappedBuiltIns); + if (_window.requestAnimationFrame) { + fill(_window, 'requestAnimationFrame', function (orig) { + return function (cb) { + return orig(self.wrap(cb)); + }; + }, wrappedBuiltIns); + } + + // event targets borrowed from bugsnag-js: + // https://github.com/bugsnag/bugsnag-js/blob/master/src/bugsnag.js#L666 + var eventTargets = ['EventTarget', 'Window', 'Node', 'ApplicationCache', 'AudioTrackList', 'ChannelMergerNode', 'CryptoOperation', 'EventSource', 'FileReader', 'HTMLUnknownElement', 'IDBDatabase', 'IDBRequest', 'IDBTransaction', 'KeyOperation', 'MediaController', 'MessagePort', 'ModalWindow', 'Notification', 'SVGElementInstance', 'Screen', 'TextTrack', 'TextTrackCue', 'TextTrackList', 'WebSocket', 'WebSocketWorker', 'Worker', 'XMLHttpRequest', 'XMLHttpRequestEventTarget', 'XMLHttpRequestUpload']; + for (var i = 0; i < eventTargets.length; i++) { + wrapEventTarget(eventTargets[i]); + } + + var $ = _window.jQuery || _window.$; + if ($ && $.fn && $.fn.ready) { + fill($.fn, 'ready', function (orig) { + return function (fn) { + return orig.call(this, self.wrap(fn)); + }; + }, wrappedBuiltIns); + } + }, + + + /** + * Instrument browser built-ins w/ breadcrumb capturing + * - XMLHttpRequests + * - DOM interactions (click/typing) + * - window.location changes + * - console + * + * Can be disabled or individually configured via the `autoBreadcrumbs` config option + */ + _instrumentBreadcrumbs: function () { + var self = this; + var autoBreadcrumbs = this._globalOptions.autoBreadcrumbs; + + var wrappedBuiltIns = self._wrappedBuiltIns; + + function wrapProp(prop, xhr) { + if (prop in xhr && isFunction(xhr[prop])) { + fill(xhr, prop, function (orig) { + return self.wrap(orig); + }); // intentionally don't track filled methods on XHR instances + } + } + + if (autoBreadcrumbs.xhr && 'XMLHttpRequest' in _window) { + var xhrproto = XMLHttpRequest.prototype; + fill(xhrproto, 'open', function(origOpen) { + return function (method, url) { // preserve arity + + // if Sentry key appears in URL, don't capture + if (isString(url) && url.indexOf(self._globalKey) === -1) { + this.__raven_xhr = { + method: method, + url: url, + status_code: null + }; + } + + return origOpen.apply(this, arguments); + }; + }, wrappedBuiltIns); + + fill(xhrproto, 'send', function(origSend) { + return function (data) { // preserve arity + var xhr = this; + + function onreadystatechangeHandler() { + if (xhr.__raven_xhr && (xhr.readyState === 1 || xhr.readyState === 4)) { + try { + // touching statusCode in some platforms throws + // an exception + xhr.__raven_xhr.status_code = xhr.status; + } catch (e) { /* do nothing */ } + self.captureBreadcrumb({ + type: 'http', + category: 'xhr', + data: xhr.__raven_xhr + }); + } + } + + var props = ['onload', 'onerror', 'onprogress']; + for (var j = 0; j < props.length; j++) { + wrapProp(props[j], xhr); + } + + if ('onreadystatechange' in xhr && isFunction(xhr.onreadystatechange)) { + fill(xhr, 'onreadystatechange', function (orig) { + return self.wrap(orig, undefined, onreadystatechangeHandler); + } /* intentionally don't track this instrumentation */); + } else { + // if onreadystatechange wasn't actually set by the page on this xhr, we + // are free to set our own and capture the breadcrumb + xhr.onreadystatechange = onreadystatechangeHandler; + } + + return origSend.apply(this, arguments); + }; + }, wrappedBuiltIns); + } + + if (autoBreadcrumbs.xhr && 'fetch' in _window) { + fill(_window, 'fetch', function(origFetch) { + return function (fn, t) { // preserve arity + // Make a copy of the arguments to prevent deoptimization + // https://github.com/petkaantonov/bluebird/wiki/Optimization-killers#32-leaking-arguments + var args = new Array(arguments.length); + for(var i = 0; i < args.length; ++i) { + args[i] = arguments[i]; + } + + var method = 'GET'; + + if (args[1] && args[1].method) { + method = args[1].method; + } + + var fetchData = { + method: method, + url: args[0], + status_code: null + }; + + self.captureBreadcrumb({ + type: 'http', + category: 'fetch', + data: fetchData + }); + + return origFetch.apply(this, args).then(function (response) { + fetchData.status_code = response.status; + + return response; + }); + }; + }, wrappedBuiltIns); + } + + // Capture breadcrumbs from any click that is unhandled / bubbled up all the way + // to the document. Do this before we instrument addEventListener. + if (autoBreadcrumbs.dom && this._hasDocument) { + if (_document.addEventListener) { + _document.addEventListener('click', self._breadcrumbEventHandler('click'), false); + _document.addEventListener('keypress', self._keypressEventHandler(), false); + } + else { + // IE8 Compatibility + _document.attachEvent('onclick', self._breadcrumbEventHandler('click')); + _document.attachEvent('onkeypress', self._keypressEventHandler()); + } + } + + // record navigation (URL) changes + // NOTE: in Chrome App environment, touching history.pushState, *even inside + // a try/catch block*, will cause Chrome to output an error to console.error + // borrowed from: https://github.com/angular/angular.js/pull/13945/files + var chrome = _window.chrome; + var isChromePackagedApp = chrome && chrome.app && chrome.app.runtime; + var hasPushState = !isChromePackagedApp && _window.history && history.pushState; + if (autoBreadcrumbs.location && hasPushState) { + // TODO: remove onpopstate handler on uninstall() + var oldOnPopState = _window.onpopstate; + _window.onpopstate = function () { + var currentHref = self._location.href; + self._captureUrlChange(self._lastHref, currentHref); + + if (oldOnPopState) { + return oldOnPopState.apply(this, arguments); + } + }; + + fill(history, 'pushState', function (origPushState) { + // note history.pushState.length is 0; intentionally not declaring + // params to preserve 0 arity + return function (/* state, title, url */) { + var url = arguments.length > 2 ? arguments[2] : undefined; + + // url argument is optional + if (url) { + // coerce to string (this is what pushState does) + self._captureUrlChange(self._lastHref, url + ''); + } + + return origPushState.apply(this, arguments); + }; + }, wrappedBuiltIns); + } + + if (autoBreadcrumbs.console && 'console' in _window && console.log) { + // console + var consoleMethodCallback = function (msg, data) { + self.captureBreadcrumb({ + message: msg, + level: data.level, + category: 'console' + }); + }; + + each(['debug', 'info', 'warn', 'error', 'log'], function (_, level) { + wrapConsoleMethod(console, level, consoleMethodCallback); + }); + } + + }, + + _restoreBuiltIns: function () { + // restore any wrapped builtins + var builtin; + while (this._wrappedBuiltIns.length) { + builtin = this._wrappedBuiltIns.shift(); + + var obj = builtin[0], + name = builtin[1], + orig = builtin[2]; + + obj[name] = orig; + } + }, + + _drainPlugins: function() { + var self = this; + + // FIX ME TODO + each(this._plugins, function(_, plugin) { + var installer = plugin[0]; + var args = plugin[1]; + installer.apply(self, [self].concat(args)); + }); + }, + + _parseDSN: function(str) { + var m = dsnPattern.exec(str), + dsn = {}, + i = 7; + + try { + while (i--) dsn[dsnKeys[i]] = m[i] || ''; + } catch(e) { + throw new RavenConfigError('Invalid DSN: ' + str); + } + + if (dsn.pass && !this._globalOptions.allowSecretKey) { + throw new RavenConfigError('Do not specify your secret key in the DSN. See: http://bit.ly/raven-secret-key'); + } + + return dsn; + }, + + _getGlobalServer: function(uri) { + // assemble the endpoint from the uri pieces + var globalServer = '//' + uri.host + + (uri.port ? ':' + uri.port : ''); + + if (uri.protocol) { + globalServer = uri.protocol + ':' + globalServer; + } + return globalServer; + }, + + _handleOnErrorStackInfo: function() { + // if we are intentionally ignoring errors via onerror, bail out + if (!this._ignoreOnError) { + this._handleStackInfo.apply(this, arguments); + } + }, + + _handleStackInfo: function(stackInfo, options) { + var frames = this._prepareFrames(stackInfo, options); + + this._triggerEvent('handle', { + stackInfo: stackInfo, + options: options + }); + + this._processException( + stackInfo.name, + stackInfo.message, + stackInfo.url, + stackInfo.lineno, + frames, + options + ); + }, + + _prepareFrames: function(stackInfo, options) { + var self = this; + var frames = []; + if (stackInfo.stack && stackInfo.stack.length) { + each(stackInfo.stack, function(i, stack) { + var frame = self._normalizeFrame(stack); + if (frame) { + frames.push(frame); + } + }); + + // e.g. frames captured via captureMessage throw + if (options && options.trimHeadFrames) { + for (var j = 0; j < options.trimHeadFrames && j < frames.length; j++) { + frames[j].in_app = false; + } + } + } + frames = frames.slice(0, this._globalOptions.stackTraceLimit); + return frames; + }, + + + _normalizeFrame: function(frame) { + if (!frame.url) return; + + // normalize the frames data + var normalized = { + filename: frame.url, + lineno: frame.line, + colno: frame.column, + 'function': frame.func || '?' + }; + + normalized.in_app = !( // determine if an exception came from outside of our app + // first we check the global includePaths list. + !!this._globalOptions.includePaths.test && !this._globalOptions.includePaths.test(normalized.filename) || + // Now we check for fun, if the function name is Raven or TraceKit + /(Raven|TraceKit)\./.test(normalized['function']) || + // finally, we do a last ditch effort and check for raven.min.js + /raven\.(min\.)?js$/.test(normalized.filename) + ); + + return normalized; + }, + + _processException: function(type, message, fileurl, lineno, frames, options) { + var stacktrace; + if (!!this._globalOptions.ignoreErrors.test && this._globalOptions.ignoreErrors.test(message)) return; + + message += ''; + + if (frames && frames.length) { + fileurl = frames[0].filename || fileurl; + // Sentry expects frames oldest to newest + // and JS sends them as newest to oldest + frames.reverse(); + stacktrace = {frames: frames}; + } else if (fileurl) { + stacktrace = { + frames: [{ + filename: fileurl, + lineno: lineno, + in_app: true + }] + }; + } + + if (!!this._globalOptions.ignoreUrls.test && this._globalOptions.ignoreUrls.test(fileurl)) return; + if (!!this._globalOptions.whitelistUrls.test && !this._globalOptions.whitelistUrls.test(fileurl)) return; + + var data = objectMerge({ + // sentry.interfaces.Exception + exception: { + values: [{ + type: type, + value: message, + stacktrace: stacktrace + }] + }, + culprit: fileurl + }, options); + + // Fire away! + this._send(data); + }, + + _trimPacket: function(data) { + // For now, we only want to truncate the two different messages + // but this could/should be expanded to just trim everything + var max = this._globalOptions.maxMessageLength; + if (data.message) { + data.message = truncate(data.message, max); + } + if (data.exception) { + var exception = data.exception.values[0]; + exception.value = truncate(exception.value, max); + } + + return data; + }, + + _getHttpData: function() { + if (!this._hasDocument || !_document.location || !_document.location.href) { + return; + } + + var httpData = { + headers: { + 'User-Agent': navigator.userAgent + } + }; + + httpData.url = _document.location.href; + + if (_document.referrer) { + httpData.headers.Referer = _document.referrer; + } + + return httpData; + }, + + + _send: function(data) { + var globalOptions = this._globalOptions; + + var baseData = { + project: this._globalProject, + logger: globalOptions.logger, + platform: 'javascript' + }, httpData = this._getHttpData(); + + if (httpData) { + baseData.request = httpData; + } + + // HACK: delete `trimHeadFrames` to prevent from appearing in outbound payload + if (data.trimHeadFrames) delete data.trimHeadFrames; + + data = objectMerge(baseData, data); + + // Merge in the tags and extra separately since objectMerge doesn't handle a deep merge + data.tags = objectMerge(objectMerge({}, this._globalContext.tags), data.tags); + data.extra = objectMerge(objectMerge({}, this._globalContext.extra), data.extra); + + // Send along our own collected metadata with extra + data.extra['session:duration'] = now() - this._startTime; + + if (this._breadcrumbs && this._breadcrumbs.length > 0) { + // intentionally make shallow copy so that additions + // to breadcrumbs aren't accidentally sent in this request + data.breadcrumbs = { + values: [].slice.call(this._breadcrumbs, 0) + }; + } + + // If there are no tags/extra, strip the key from the payload alltogther. + if (isEmptyObject(data.tags)) delete data.tags; + + if (this._globalContext.user) { + // sentry.interfaces.User + data.user = this._globalContext.user; + } + + // Include the environment if it's defined in globalOptions + if (globalOptions.environment) data.environment = globalOptions.environment; + + // Include the release if it's defined in globalOptions + if (globalOptions.release) data.release = globalOptions.release; + + // Include server_name if it's defined in globalOptions + if (globalOptions.serverName) data.server_name = globalOptions.serverName; + + if (isFunction(globalOptions.dataCallback)) { + data = globalOptions.dataCallback(data) || data; + } + + // Why?????????? + if (!data || isEmptyObject(data)) { + return; + } + + // Check if the request should be filtered or not + if (isFunction(globalOptions.shouldSendCallback) && !globalOptions.shouldSendCallback(data)) { + return; + } + + this._sendProcessedPayload(data); + }, + + _getUuid: function () { + return uuid4(); + }, + + _sendProcessedPayload: function(data, callback) { + var self = this; + var globalOptions = this._globalOptions; + + // Send along an event_id if not explicitly passed. + // This event_id can be used to reference the error within Sentry itself. + // Set lastEventId after we know the error should actually be sent + this._lastEventId = data.event_id || (data.event_id = this._getUuid()); + + // Try and clean up the packet before sending by truncating long values + data = this._trimPacket(data); + + this._logDebug('debug', 'Raven about to send:', data); + + if (!this.isSetup()) return; + + var auth = { + sentry_version: '7', + sentry_client: 'raven-js/' + this.VERSION, + sentry_key: this._globalKey + }; + if (this._globalSecret) { + auth.sentry_secret = this._globalSecret; + } + + var exception = data.exception && data.exception.values[0]; + this.captureBreadcrumb({ + category: 'sentry', + message: exception + ? (exception.type ? exception.type + ': ' : '') + exception.value + : data.message, + event_id: data.event_id, + level: data.level || 'error' // presume error unless specified + }); + + var url = this._globalEndpoint; + (globalOptions.transport || this._makeRequest).call(this, { + url: url, + auth: auth, + data: data, + options: globalOptions, + onSuccess: function success() { + self._triggerEvent('success', { + data: data, + src: url + }); + callback && callback(); + }, + onError: function failure(error) { + self._triggerEvent('failure', { + data: data, + src: url + }); + error = error || new Error('Raven send failed (no additional details provided)'); + callback && callback(error); + } + }); + }, + + _makeRequest: function(opts) { + var request = new XMLHttpRequest(); + + // if browser doesn't support CORS (e.g. IE7), we are out of luck + var hasCORS = + 'withCredentials' in request || + typeof XDomainRequest !== 'undefined'; + + if (!hasCORS) return; + + var url = opts.url; + function handler() { + if (request.status === 200) { + if (opts.onSuccess) { + opts.onSuccess(); + } + } else if (opts.onError) { + opts.onError(new Error('Sentry error code: ' + request.status)); + } + } + + if ('withCredentials' in request) { + request.onreadystatechange = function () { + if (request.readyState !== 4) { + return; + } + handler(); + }; + } else { + request = new XDomainRequest(); + // xdomainrequest cannot go http -> https (or vice versa), + // so always use protocol relative + url = url.replace(/^https?:/, ''); + + // onreadystatechange not supported by XDomainRequest + request.onload = handler; + } + + // NOTE: auth is intentionally sent as part of query string (NOT as custom + // HTTP header) so as to avoid preflight CORS requests + request.open('POST', url + '?' + urlencode(opts.auth)); + request.send(stringify(opts.data)); + }, + + _logDebug: function(level) { + if (this._originalConsoleMethods[level] && this.debug) { + // In IE<10 console methods do not have their own 'apply' method + Function.prototype.apply.call( + this._originalConsoleMethods[level], + this._originalConsole, + [].slice.call(arguments, 1) + ); + } + }, + + _mergeContext: function(key, context) { + if (isUndefined(context)) { + delete this._globalContext[key]; + } else { + this._globalContext[key] = objectMerge(this._globalContext[key] || {}, context); + } + } +}; + +/*------------------------------------------------ + * utils + * + * conditionally exported for test via Raven.utils + ================================================= + */ +var objectPrototype = Object.prototype; + +function isUndefined(what) { + return what === void 0; +} + +function isFunction(what) { + return typeof what === 'function'; +} + +function isString(what) { + return objectPrototype.toString.call(what) === '[object String]'; +} + +function isObject(what) { + return typeof what === 'object' && what !== null; +} + +function isEmptyObject(what) { + for (var _ in what) return false; // eslint-disable-line guard-for-in, no-unused-vars + return true; +} + +// Sorta yanked from https://github.com/joyent/node/blob/aa3b4b4/lib/util.js#L560 +// with some tiny modifications +function isError(what) { + var toString = objectPrototype.toString.call(what); + return isObject(what) && + toString === '[object Error]' || + toString === '[object Exception]' || // Firefox NS_ERROR_FAILURE Exceptions + what instanceof Error; +} + +function each(obj, callback) { + var i, j; + + if (isUndefined(obj.length)) { + for (i in obj) { + if (hasKey(obj, i)) { + callback.call(null, i, obj[i]); + } + } + } else { + j = obj.length; + if (j) { + for (i = 0; i < j; i++) { + callback.call(null, i, obj[i]); + } + } + } +} + +function objectMerge(obj1, obj2) { + if (!obj2) { + return obj1; + } + each(obj2, function(key, value){ + obj1[key] = value; + }); + return obj1; +} + +function truncate(str, max) { + return !max || str.length <= max ? str : str.substr(0, max) + '\u2026'; +} + +/** + * hasKey, a better form of hasOwnProperty + * Example: hasKey(MainHostObject, property) === true/false + * + * @param {Object} host object to check property + * @param {string} key to check + */ +function hasKey(object, key) { + return objectPrototype.hasOwnProperty.call(object, key); +} + +function joinRegExp(patterns) { + // Combine an array of regular expressions and strings into one large regexp + // Be mad. + var sources = [], + i = 0, len = patterns.length, + pattern; + + for (; i < len; i++) { + pattern = patterns[i]; + if (isString(pattern)) { + // If it's a string, we need to escape it + // Taken from: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions + sources.push(pattern.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, '\\$1')); + } else if (pattern && pattern.source) { + // If it's a regexp already, we want to extract the source + sources.push(pattern.source); + } + // Intentionally skip other cases + } + return new RegExp(sources.join('|'), 'i'); +} + +function urlencode(o) { + var pairs = []; + each(o, function(key, value) { + pairs.push(encodeURIComponent(key) + '=' + encodeURIComponent(value)); + }); + return pairs.join('&'); +} + +// borrowed from https://tools.ietf.org/html/rfc3986#appendix-B +// intentionally using regex and not <a/> href parsing trick because React Native and other +// environments where DOM might not be available +function parseUrl(url) { + var match = url.match(/^(([^:\/?#]+):)?(\/\/([^\/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?$/); + if (!match) return {}; + + // coerce to undefined values to empty string so we don't get 'undefined' + var query = match[6] || ''; + var fragment = match[8] || ''; + return { + protocol: match[2], + host: match[4], + path: match[5], + relative: match[5] + query + fragment // everything minus origin + }; +} +function uuid4() { + var crypto = _window.crypto || _window.msCrypto; + + if (!isUndefined(crypto) && crypto.getRandomValues) { + // Use window.crypto API if available + var arr = new Uint16Array(8); + crypto.getRandomValues(arr); + + // set 4 in byte 7 + arr[3] = arr[3] & 0xFFF | 0x4000; + // set 2 most significant bits of byte 9 to '10' + arr[4] = arr[4] & 0x3FFF | 0x8000; + + var pad = function(num) { + var v = num.toString(16); + while (v.length < 4) { + v = '0' + v; + } + return v; + }; + + return pad(arr[0]) + pad(arr[1]) + pad(arr[2]) + pad(arr[3]) + pad(arr[4]) + + pad(arr[5]) + pad(arr[6]) + pad(arr[7]); + } else { + // http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/2117523#2117523 + return 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + var r = Math.random()*16|0, + v = c === 'x' ? r : r&0x3|0x8; + return v.toString(16); + }); + } +} + +/** + * Given a child DOM element, returns a query-selector statement describing that + * and its ancestors + * e.g. [HTMLElement] => body > div > input#foo.btn[name=baz] + * @param elem + * @returns {string} + */ +function htmlTreeAsString(elem) { + /* eslint no-extra-parens:0*/ + var MAX_TRAVERSE_HEIGHT = 5, + MAX_OUTPUT_LEN = 80, + out = [], + height = 0, + len = 0, + separator = ' > ', + sepLength = separator.length, + nextStr; + + while (elem && height++ < MAX_TRAVERSE_HEIGHT) { + + nextStr = htmlElementAsString(elem); + // bail out if + // - nextStr is the 'html' element + // - the length of the string that would be created exceeds MAX_OUTPUT_LEN + // (ignore this limit if we are on the first iteration) + if (nextStr === 'html' || height > 1 && len + (out.length * sepLength) + nextStr.length >= MAX_OUTPUT_LEN) { + break; + } + + out.push(nextStr); + + len += nextStr.length; + elem = elem.parentNode; + } + + return out.reverse().join(separator); +} + +/** + * Returns a simple, query-selector representation of a DOM element + * e.g. [HTMLElement] => input#foo.btn[name=baz] + * @param HTMLElement + * @returns {string} + */ +function htmlElementAsString(elem) { + var out = [], + className, + classes, + key, + attr, + i; + + if (!elem || !elem.tagName) { + return ''; + } + + out.push(elem.tagName.toLowerCase()); + if (elem.id) { + out.push('#' + elem.id); + } + + className = elem.className; + if (className && isString(className)) { + classes = className.split(' '); + for (i = 0; i < classes.length; i++) { + out.push('.' + classes[i]); + } + } + var attrWhitelist = ['type', 'name', 'title', 'alt']; + for (i = 0; i < attrWhitelist.length; i++) { + key = attrWhitelist[i]; + attr = elem.getAttribute(key); + if (attr) { + out.push('[' + key + '="' + attr + '"]'); + } + } + return out.join(''); +} + +/** + * Polyfill a method + * @param obj object e.g. `document` + * @param name method name present on object e.g. `addEventListener` + * @param replacement replacement function + * @param track {optional} record instrumentation to an array + */ +function fill(obj, name, replacement, track) { + var orig = obj[name]; + obj[name] = replacement(orig); + if (track) { + track.push([obj, name, orig]); + } +} + +if (typeof __DEV__ !== 'undefined' && __DEV__) { + Raven.utils = { + isUndefined: isUndefined, + isFunction: isFunction, + isString: isString, + isObject: isObject, + isEmptyObject: isEmptyObject, + isError: isError, + each: each, + objectMerge: objectMerge, + truncate: truncate, + hasKey: hasKey, + joinRegExp: joinRegExp, + urlencode: urlencode, + uuid4: uuid4, + htmlTreeAsString: htmlTreeAsString, + htmlElementAsString: htmlElementAsString, + parseUrl: parseUrl, + fill: fill + }; +}; + +// Deprecations +Raven.prototype.setUser = Raven.prototype.setUserContext; +Raven.prototype.setReleaseContext = Raven.prototype.setRelease; + +module.exports = Raven; + +}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) +},{"1":1,"2":2,"3":3,"6":6}],5:[function(_dereq_,module,exports){ +(function (global){ +/** + * Enforces a single instance of the Raven client, and the + * main entry point for Raven. If you are a consumer of the + * Raven library, you SHOULD load this file (vs raven.js). + **/ + +'use strict'; + +var RavenConstructor = _dereq_(4); + +// This is to be defensive in environments where window does not exist (see https://github.com/getsentry/raven-js/pull/785) +var _window = typeof window !== 'undefined' ? window + : typeof global !== 'undefined' ? global + : typeof self !== 'undefined' ? self + : {}; +var _Raven = _window.Raven; + +var Raven = new RavenConstructor(); + +/* + * Allow multiple versions of Raven to be installed. + * Strip Raven from the global context and returns the instance. + * + * @return {Raven} + */ +Raven.noConflict = function () { + _window.Raven = _Raven; + return Raven; +}; + +Raven.afterLoad(); + +module.exports = Raven; + +}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) +},{"4":4}],6:[function(_dereq_,module,exports){ +(function (global){ +'use strict'; + +/* + TraceKit - Cross brower stack traces - github.com/occ/TraceKit + MIT license +*/ + +var TraceKit = { + collectWindowErrors: true, + debug: false +}; + +// This is to be defensive in environments where window does not exist (see https://github.com/getsentry/raven-js/pull/785) +var _window = typeof window !== 'undefined' ? window + : typeof global !== 'undefined' ? global + : typeof self !== 'undefined' ? self + : {}; + +// global reference to slice +var _slice = [].slice; +var UNKNOWN_FUNCTION = '?'; + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#Error_types +var ERROR_TYPES_RE = /^(?:Uncaught (?:exception: )?)?((?:Eval|Internal|Range|Reference|Syntax|Type|URI)Error): ?(.*)$/; + +function getLocationHref() { + if (typeof document === 'undefined') + return ''; + + return document.location.href; +} + +/** + * TraceKit.report: cross-browser processing of unhandled exceptions + * + * Syntax: + * TraceKit.report.subscribe(function(stackInfo) { ... }) + * TraceKit.report.unsubscribe(function(stackInfo) { ... }) + * TraceKit.report(exception) + * try { ...code... } catch(ex) { TraceKit.report(ex); } + * + * Supports: + * - Firefox: full stack trace with line numbers, plus column number + * on top frame; column number is not guaranteed + * - Opera: full stack trace with line and column numbers + * - Chrome: full stack trace with line and column numbers + * - Safari: line and column number for the top frame only; some frames + * may be missing, and column number is not guaranteed + * - IE: line and column number for the top frame only; some frames + * may be missing, and column number is not guaranteed + * + * In theory, TraceKit should work on all of the following versions: + * - IE5.5+ (only 8.0 tested) + * - Firefox 0.9+ (only 3.5+ tested) + * - Opera 7+ (only 10.50 tested; versions 9 and earlier may require + * Exceptions Have Stacktrace to be enabled in opera:config) + * - Safari 3+ (only 4+ tested) + * - Chrome 1+ (only 5+ tested) + * - Konqueror 3.5+ (untested) + * + * Requires TraceKit.computeStackTrace. + * + * Tries to catch all unhandled exceptions and report them to the + * subscribed handlers. Please note that TraceKit.report will rethrow the + * exception. This is REQUIRED in order to get a useful stack trace in IE. + * If the exception does not reach the top of the browser, you will only + * get a stack trace from the point where TraceKit.report was called. + * + * Handlers receive a stackInfo object as described in the + * TraceKit.computeStackTrace docs. + */ +TraceKit.report = (function reportModuleWrapper() { + var handlers = [], + lastArgs = null, + lastException = null, + lastExceptionStack = null; + + /** + * Add a crash handler. + * @param {Function} handler + */ + function subscribe(handler) { + installGlobalHandler(); + handlers.push(handler); + } + + /** + * Remove a crash handler. + * @param {Function} handler + */ + function unsubscribe(handler) { + for (var i = handlers.length - 1; i >= 0; --i) { + if (handlers[i] === handler) { + handlers.splice(i, 1); + } + } + } + + /** + * Remove all crash handlers. + */ + function unsubscribeAll() { + uninstallGlobalHandler(); + handlers = []; + } + + /** + * Dispatch stack information to all handlers. + * @param {Object.<string, *>} stack + */ + function notifyHandlers(stack, isWindowError) { + var exception = null; + if (isWindowError && !TraceKit.collectWindowErrors) { + return; + } + for (var i in handlers) { + if (handlers.hasOwnProperty(i)) { + try { + handlers[i].apply(null, [stack].concat(_slice.call(arguments, 2))); + } catch (inner) { + exception = inner; + } + } + } + + if (exception) { + throw exception; + } + } + + var _oldOnerrorHandler, _onErrorHandlerInstalled; + + /** + * Ensures all global unhandled exceptions are recorded. + * Supported by Gecko and IE. + * @param {string} message Error message. + * @param {string} url URL of script that generated the exception. + * @param {(number|string)} lineNo The line number at which the error + * occurred. + * @param {?(number|string)} colNo The column number at which the error + * occurred. + * @param {?Error} ex The actual Error object. + */ + function traceKitWindowOnError(message, url, lineNo, colNo, ex) { + var stack = null; + + if (lastExceptionStack) { + TraceKit.computeStackTrace.augmentStackTraceWithInitialElement(lastExceptionStack, url, lineNo, message); + processLastException(); + } else if (ex) { + // New chrome and blink send along a real error object + // Let's just report that like a normal error. + // See: https://mikewest.org/2013/08/debugging-runtime-errors-with-window-onerror + stack = TraceKit.computeStackTrace(ex); + notifyHandlers(stack, true); + } else { + var location = { + 'url': url, + 'line': lineNo, + 'column': colNo + }; + + var name = undefined; + var msg = message; // must be new var or will modify original `arguments` + var groups; + if ({}.toString.call(message) === '[object String]') { + var groups = message.match(ERROR_TYPES_RE); + if (groups) { + name = groups[1]; + msg = groups[2]; + } + } + + location.func = UNKNOWN_FUNCTION; + + stack = { + 'name': name, + 'message': msg, + 'url': getLocationHref(), + 'stack': [location] + }; + notifyHandlers(stack, true); + } + + if (_oldOnerrorHandler) { + return _oldOnerrorHandler.apply(this, arguments); + } + + return false; + } + + function installGlobalHandler () + { + if (_onErrorHandlerInstalled) { + return; + } + _oldOnerrorHandler = _window.onerror; + _window.onerror = traceKitWindowOnError; + _onErrorHandlerInstalled = true; + } + + function uninstallGlobalHandler () + { + if (!_onErrorHandlerInstalled) { + return; + } + _window.onerror = _oldOnerrorHandler; + _onErrorHandlerInstalled = false; + _oldOnerrorHandler = undefined; + } + + function processLastException() { + var _lastExceptionStack = lastExceptionStack, + _lastArgs = lastArgs; + lastArgs = null; + lastExceptionStack = null; + lastException = null; + notifyHandlers.apply(null, [_lastExceptionStack, false].concat(_lastArgs)); + } + + /** + * Reports an unhandled Error to TraceKit. + * @param {Error} ex + * @param {?boolean} rethrow If false, do not re-throw the exception. + * Only used for window.onerror to not cause an infinite loop of + * rethrowing. + */ + function report(ex, rethrow) { + var args = _slice.call(arguments, 1); + if (lastExceptionStack) { + if (lastException === ex) { + return; // already caught by an inner catch block, ignore + } else { + processLastException(); + } + } + + var stack = TraceKit.computeStackTrace(ex); + lastExceptionStack = stack; + lastException = ex; + lastArgs = args; + + // If the stack trace is incomplete, wait for 2 seconds for + // slow slow IE to see if onerror occurs or not before reporting + // this exception; otherwise, we will end up with an incomplete + // stack trace + setTimeout(function () { + if (lastException === ex) { + processLastException(); + } + }, (stack.incomplete ? 2000 : 0)); + + if (rethrow !== false) { + throw ex; // re-throw to propagate to the top level (and cause window.onerror) + } + } + + report.subscribe = subscribe; + report.unsubscribe = unsubscribe; + report.uninstall = unsubscribeAll; + return report; +}()); + +/** + * TraceKit.computeStackTrace: cross-browser stack traces in JavaScript + * + * Syntax: + * s = TraceKit.computeStackTrace(exception) // consider using TraceKit.report instead (see below) + * Returns: + * s.name - exception name + * s.message - exception message + * s.stack[i].url - JavaScript or HTML file URL + * s.stack[i].func - function name, or empty for anonymous functions (if guessing did not work) + * s.stack[i].args - arguments passed to the function, if known + * s.stack[i].line - line number, if known + * s.stack[i].column - column number, if known + * + * Supports: + * - Firefox: full stack trace with line numbers and unreliable column + * number on top frame + * - Opera 10: full stack trace with line and column numbers + * - Opera 9-: full stack trace with line numbers + * - Chrome: full stack trace with line and column numbers + * - Safari: line and column number for the topmost stacktrace element + * only + * - IE: no line numbers whatsoever + * + * Tries to guess names of anonymous functions by looking for assignments + * in the source code. In IE and Safari, we have to guess source file names + * by searching for function bodies inside all page scripts. This will not + * work for scripts that are loaded cross-domain. + * Here be dragons: some function names may be guessed incorrectly, and + * duplicate functions may be mismatched. + * + * TraceKit.computeStackTrace should only be used for tracing purposes. + * Logging of unhandled exceptions should be done with TraceKit.report, + * which builds on top of TraceKit.computeStackTrace and provides better + * IE support by utilizing the window.onerror event to retrieve information + * about the top of the stack. + * + * Note: In IE and Safari, no stack trace is recorded on the Error object, + * so computeStackTrace instead walks its *own* chain of callers. + * This means that: + * * in Safari, some methods may be missing from the stack trace; + * * in IE, the topmost function in the stack trace will always be the + * caller of computeStackTrace. + * + * This is okay for tracing (because you are likely to be calling + * computeStackTrace from the function you want to be the topmost element + * of the stack trace anyway), but not okay for logging unhandled + * exceptions (because your catch block will likely be far away from the + * inner function that actually caused the exception). + * + */ +TraceKit.computeStackTrace = (function computeStackTraceWrapper() { + /** + * Escapes special characters, except for whitespace, in a string to be + * used inside a regular expression as a string literal. + * @param {string} text The string. + * @return {string} The escaped string literal. + */ + function escapeRegExp(text) { + return text.replace(/[\-\[\]{}()*+?.,\\\^$|#]/g, '\\$&'); + } + + /** + * Escapes special characters in a string to be used inside a regular + * expression as a string literal. Also ensures that HTML entities will + * be matched the same as their literal friends. + * @param {string} body The string. + * @return {string} The escaped string. + */ + function escapeCodeAsRegExpForMatchingInsideHTML(body) { + return escapeRegExp(body).replace('<', '(?:<|<)').replace('>', '(?:>|>)').replace('&', '(?:&|&)').replace('"', '(?:"|")').replace(/\s+/g, '\\s+'); + } + + // Contents of Exception in various browsers. + // + // SAFARI: + // ex.message = Can't find variable: qq + // ex.line = 59 + // ex.sourceId = 580238192 + // ex.sourceURL = http://... + // ex.expressionBeginOffset = 96 + // ex.expressionCaretOffset = 98 + // ex.expressionEndOffset = 98 + // ex.name = ReferenceError + // + // FIREFOX: + // ex.message = qq is not defined + // ex.fileName = http://... + // ex.lineNumber = 59 + // ex.columnNumber = 69 + // ex.stack = ...stack trace... (see the example below) + // ex.name = ReferenceError + // + // CHROME: + // ex.message = qq is not defined + // ex.name = ReferenceError + // ex.type = not_defined + // ex.arguments = ['aa'] + // ex.stack = ...stack trace... + // + // INTERNET EXPLORER: + // ex.message = ... + // ex.name = ReferenceError + // + // OPERA: + // ex.message = ...message... (see the example below) + // ex.name = ReferenceError + // ex.opera#sourceloc = 11 (pretty much useless, duplicates the info in ex.message) + // ex.stacktrace = n/a; see 'opera:config#UserPrefs|Exceptions Have Stacktrace' + + /** + * Computes stack trace information from the stack property. + * Chrome and Gecko use this property. + * @param {Error} ex + * @return {?Object.<string, *>} Stack trace information. + */ + function computeStackTraceFromStackProp(ex) { + if (typeof ex.stack === 'undefined' || !ex.stack) return; + + var chrome = /^\s*at (.*?) ?\(((?:file|https?|blob|chrome-extension|native|eval|<anonymous>).*?)(?::(\d+))?(?::(\d+))?\)?\s*$/i, + gecko = /^\s*(.*?)(?:\((.*?)\))?(?:^|@)((?:file|https?|blob|chrome|\[native).*?)(?::(\d+))?(?::(\d+))?\s*$/i, + winjs = /^\s*at (?:((?:\[object object\])?.+) )?\(?((?:file|ms-appx|https?|blob):.*?):(\d+)(?::(\d+))?\)?\s*$/i, + lines = ex.stack.split('\n'), + stack = [], + parts, + element, + reference = /^(.*) is undefined$/.exec(ex.message); + + for (var i = 0, j = lines.length; i < j; ++i) { + if ((parts = chrome.exec(lines[i]))) { + var isNative = parts[2] && parts[2].indexOf('native') !== -1; + element = { + 'url': !isNative ? parts[2] : null, + 'func': parts[1] || UNKNOWN_FUNCTION, + 'args': isNative ? [parts[2]] : [], + 'line': parts[3] ? +parts[3] : null, + 'column': parts[4] ? +parts[4] : null + }; + } else if ( parts = winjs.exec(lines[i]) ) { + element = { + 'url': parts[2], + 'func': parts[1] || UNKNOWN_FUNCTION, + 'args': [], + 'line': +parts[3], + 'column': parts[4] ? +parts[4] : null + }; + } else if ((parts = gecko.exec(lines[i]))) { + element = { + 'url': parts[3], + 'func': parts[1] || UNKNOWN_FUNCTION, + 'args': parts[2] ? parts[2].split(',') : [], + 'line': parts[4] ? +parts[4] : null, + 'column': parts[5] ? +parts[5] : null + }; + } else { + continue; + } + + if (!element.func && element.line) { + element.func = UNKNOWN_FUNCTION; + } + + stack.push(element); + } + + if (!stack.length) { + return null; + } + + if (!stack[0].column && typeof ex.columnNumber !== 'undefined') { + // FireFox uses this awesome columnNumber property for its top frame + // Also note, Firefox's column number is 0-based and everything else expects 1-based, + // so adding 1 + stack[0].column = ex.columnNumber + 1; + } + + return { + 'name': ex.name, + 'message': ex.message, + 'url': getLocationHref(), + 'stack': stack + }; + } + + /** + * Adds information about the first frame to incomplete stack traces. + * Safari and IE require this to get complete data on the first frame. + * @param {Object.<string, *>} stackInfo Stack trace information from + * one of the compute* methods. + * @param {string} url The URL of the script that caused an error. + * @param {(number|string)} lineNo The line number of the script that + * caused an error. + * @param {string=} message The error generated by the browser, which + * hopefully contains the name of the object that caused the error. + * @return {boolean} Whether or not the stack information was + * augmented. + */ + function augmentStackTraceWithInitialElement(stackInfo, url, lineNo, message) { + var initial = { + 'url': url, + 'line': lineNo + }; + + if (initial.url && initial.line) { + stackInfo.incomplete = false; + + if (!initial.func) { + initial.func = UNKNOWN_FUNCTION; + } + + if (stackInfo.stack.length > 0) { + if (stackInfo.stack[0].url === initial.url) { + if (stackInfo.stack[0].line === initial.line) { + return false; // already in stack trace + } else if (!stackInfo.stack[0].line && stackInfo.stack[0].func === initial.func) { + stackInfo.stack[0].line = initial.line; + return false; + } + } + } + + stackInfo.stack.unshift(initial); + stackInfo.partial = true; + return true; + } else { + stackInfo.incomplete = true; + } + + return false; + } + + /** + * Computes stack trace information by walking the arguments.caller + * chain at the time the exception occurred. This will cause earlier + * frames to be missed but is the only way to get any stack trace in + * Safari and IE. The top frame is restored by + * {@link augmentStackTraceWithInitialElement}. + * @param {Error} ex + * @return {?Object.<string, *>} Stack trace information. + */ + function computeStackTraceByWalkingCallerChain(ex, depth) { + var functionName = /function\s+([_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*)?\s*\(/i, + stack = [], + funcs = {}, + recursion = false, + parts, + item, + source; + + for (var curr = computeStackTraceByWalkingCallerChain.caller; curr && !recursion; curr = curr.caller) { + if (curr === computeStackTrace || curr === TraceKit.report) { + // console.log('skipping internal function'); + continue; + } + + item = { + 'url': null, + 'func': UNKNOWN_FUNCTION, + 'line': null, + 'column': null + }; + + if (curr.name) { + item.func = curr.name; + } else if ((parts = functionName.exec(curr.toString()))) { + item.func = parts[1]; + } + + if (typeof item.func === 'undefined') { + try { + item.func = parts.input.substring(0, parts.input.indexOf('{')); + } catch (e) { } + } + + if (funcs['' + curr]) { + recursion = true; + }else{ + funcs['' + curr] = true; + } + + stack.push(item); + } + + if (depth) { + // console.log('depth is ' + depth); + // console.log('stack is ' + stack.length); + stack.splice(0, depth); + } + + var result = { + 'name': ex.name, + 'message': ex.message, + 'url': getLocationHref(), + 'stack': stack + }; + augmentStackTraceWithInitialElement(result, ex.sourceURL || ex.fileName, ex.line || ex.lineNumber, ex.message || ex.description); + return result; + } + + /** + * Computes a stack trace for an exception. + * @param {Error} ex + * @param {(string|number)=} depth + */ + function computeStackTrace(ex, depth) { + var stack = null; + depth = (depth == null ? 0 : +depth); + + try { + stack = computeStackTraceFromStackProp(ex); + if (stack) { + return stack; + } + } catch (e) { + if (TraceKit.debug) { + throw e; + } + } + + try { + stack = computeStackTraceByWalkingCallerChain(ex, depth + 1); + if (stack) { + return stack; + } + } catch (e) { + if (TraceKit.debug) { + throw e; + } + } + + return { + 'name': ex.name, + 'message': ex.message, + 'url': getLocationHref() + }; + } + + computeStackTrace.augmentStackTraceWithInitialElement = augmentStackTraceWithInitialElement; + computeStackTrace.computeStackTraceFromStackProp = computeStackTraceFromStackProp; + + return computeStackTrace; +}()); + +module.exports = TraceKit; + +}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) +},{}]},{},[5])(5) +}); -- GitLab From e0dc73527a478188cfa28b456b64798639aa73c9 Mon Sep 17 00:00:00 2001 From: Jarka Kadlecova <jarka@gitlab.com> Date: Fri, 24 Mar 2017 11:11:36 +0100 Subject: [PATCH 002/363] Project deploy keys json end point --- .../projects/deploy_keys_controller.rb | 9 ++- .../settings/deploy_keys_presenter.rb | 11 ++++ app/serializers/deploy_key_entity.rb | 12 ++++ app/serializers/deploy_key_serializer.rb | 3 + app/serializers/project_entity.rb | 12 ++++ changelogs/unreleased/29667-deploy-keys.yml | 4 ++ .../projects/deploy_keys_controller_spec.rb | 66 +++++++++++++++++++ spec/serializers/deploy_key_entity_spec.rb | 29 ++++++++ 8 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 app/serializers/deploy_key_entity.rb create mode 100644 app/serializers/deploy_key_serializer.rb create mode 100644 app/serializers/project_entity.rb create mode 100644 changelogs/unreleased/29667-deploy-keys.yml create mode 100644 spec/controllers/projects/deploy_keys_controller_spec.rb create mode 100644 spec/serializers/deploy_key_entity_spec.rb diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb index d0c44e297e3b4..a47e15a192bff 100644 --- a/app/controllers/projects/deploy_keys_controller.rb +++ b/app/controllers/projects/deploy_keys_controller.rb @@ -8,7 +8,12 @@ class Projects::DeployKeysController < Projects::ApplicationController layout "project_settings" def index - redirect_to_repository_settings(@project) + respond_to do |format| + format.html { redirect_to_repository_settings(@project) } + format.json do + render json: Projects::Settings::DeployKeysPresenter.new(@project, current_user: current_user).as_json + end + end end def new @@ -19,7 +24,7 @@ def create @key = DeployKey.new(deploy_key_params.merge(user: current_user)) unless @key.valid? && @project.deploy_keys << @key - flash[:alert] = @key.errors.full_messages.join(', ').html_safe + flash[:alert] = @key.errors.full_messages.join(', ').html_safe end redirect_to_repository_settings(@project) end diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb index 86ac513b3c07e..070b0c35e3673 100644 --- a/app/presenters/projects/settings/deploy_keys_presenter.rb +++ b/app/presenters/projects/settings/deploy_keys_presenter.rb @@ -48,6 +48,17 @@ def any_available_public_keys_enabled? available_public_keys.any? end + def as_json + serializer = DeployKeySerializer.new + opts = { user: current_user } + + { + enabled_keys: serializer.represent(enabled_keys, opts), + available_project_keys: serializer.represent(available_project_keys, opts), + public_keys: serializer.represent(available_public_keys, opts) + } + end + def to_partial_path 'projects/deploy_keys/index' end diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb new file mode 100644 index 0000000000000..cdedc2c7bd018 --- /dev/null +++ b/app/serializers/deploy_key_entity.rb @@ -0,0 +1,12 @@ +class DeployKeyEntity < Grape::Entity + expose :id + expose :user_id + expose :title + expose :fingerprint + expose :can_push + expose :created_at + expose :updated_at + expose :projects, using: ProjectEntity do |deploy_key| + deploy_key.projects.select { |project| options[:user].can?(:read_project, project) } + end +end diff --git a/app/serializers/deploy_key_serializer.rb b/app/serializers/deploy_key_serializer.rb new file mode 100644 index 0000000000000..8f849eb88b717 --- /dev/null +++ b/app/serializers/deploy_key_serializer.rb @@ -0,0 +1,3 @@ +class DeployKeySerializer < BaseSerializer + entity DeployKeyEntity +end diff --git a/app/serializers/project_entity.rb b/app/serializers/project_entity.rb new file mode 100644 index 0000000000000..6f8061f753095 --- /dev/null +++ b/app/serializers/project_entity.rb @@ -0,0 +1,12 @@ +class ProjectEntity < Grape::Entity + expose :id + expose :name + + expose :full_path do |project| + project.full_path + end + + expose :full_name do |project| + project.full_name + end +end diff --git a/changelogs/unreleased/29667-deploy-keys.yml b/changelogs/unreleased/29667-deploy-keys.yml new file mode 100644 index 0000000000000..0f202ebf1ee56 --- /dev/null +++ b/changelogs/unreleased/29667-deploy-keys.yml @@ -0,0 +1,4 @@ +--- +title: Project deploy keys json end point +merge_request: +author: diff --git a/spec/controllers/projects/deploy_keys_controller_spec.rb b/spec/controllers/projects/deploy_keys_controller_spec.rb new file mode 100644 index 0000000000000..efe1a78415b7d --- /dev/null +++ b/spec/controllers/projects/deploy_keys_controller_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' + +describe Projects::DeployKeysController do + let(:project) { create(:project, :repository) } + let(:user) { create(:user) } + + before do + project.team << [user, :master] + + sign_in(user) + end + + describe 'GET index' do + let(:params) do + { namespace_id: project.namespace, project_id: project } + end + + context 'when html requested' do + it 'redirects to blob' do + get :index, params + + expect(response).to redirect_to(namespace_project_settings_repository_path(params)) + end + end + + context 'when json requested' do + let(:project2) { create(:empty_project, :internal)} + let(:project_private) { create(:empty_project, :private)} + + let(:deploy_key_internal) do + create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCdMHEHyhRjbhEZVddFn6lTWdgEy5Q6Bz4nwGB76xWZI5YT/1WJOMEW+sL5zYd31kk7sd3FJ5L9ft8zWMWrr/iWXQikC2cqZK24H1xy+ZUmrRuJD4qGAaIVoyyzBL+avL+lF8J5lg6YSw8gwJY/lX64/vnJHUlWw2n5BF8IFOWhiw== dummy@gitlab.com') + end + let(:deploy_key_actual) do + create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDNd/UJWhPrpb+b/G5oL109y57yKuCxE+WUGJGYaj7WQKsYRJmLYh1mgjrl+KVyfsWpq4ylOxIfFSnN9xBBFN8mlb0Fma5DC7YsSsibJr3MZ19ZNBprwNcdogET7aW9I0In7Wu5f2KqI6e5W/spJHCy4JVxzVMUvk6Myab0LnJ2iQ== dummy@gitlab.com') + end + let!(:deploy_key_public) { create(:deploy_key, public: true) } + + let!(:deploy_keys_project_internal) do + create(:deploy_keys_project, project: project2, deploy_key: deploy_key_internal) + end + + let!(:deploy_keys_actual_project) do + create(:deploy_keys_project, project: project, deploy_key: deploy_key_actual) + end + + let!(:deploy_keys_project_private) do + create(:deploy_keys_project, project: project_private, deploy_key: create(:another_deploy_key)) + end + + before do + project2.team << [user, :developer] + end + + it 'returns json in a correct format' do + get :index, params.merge(format: :json) + + json = JSON.parse(response.body) + + expect(json.keys).to match_array(%w(enabled_keys available_project_keys public_keys)) + expect(json['enabled_keys'].count).to eq(1) + expect(json['available_project_keys'].count).to eq(1) + expect(json['public_keys'].count).to eq(1) + end + end + end +end diff --git a/spec/serializers/deploy_key_entity_spec.rb b/spec/serializers/deploy_key_entity_spec.rb new file mode 100644 index 0000000000000..da1d33b42ac80 --- /dev/null +++ b/spec/serializers/deploy_key_entity_spec.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +describe DeployKeyEntity do + let(:user) { create(:user) } + let(:project) { create(:empty_project, :internal)} + let(:project_private) { create(:empty_project, :private)} + let(:deploy_key) { create(:deploy_key) } + let!(:deploy_key_internal) { create(:deploy_keys_project, project: project, deploy_key: deploy_key) } + let!(:deploy_key_private) { create(:deploy_keys_project, project: project_private, deploy_key: deploy_key) } + + let(:entity) { described_class.new(deploy_key, user: user) } + + it 'returns deploy keys with projects a user can read' do + expected_result = { + id: deploy_key.id, + user_id: deploy_key.user_id, + title: deploy_key.title, + fingerprint: deploy_key.fingerprint, + can_push: deploy_key.can_push, + created_at: deploy_key.created_at, + updated_at: deploy_key.updated_at, + projects: [ + { id: project.id, name: project.name, full_path: project.full_path, full_name: project.full_name } + ] + } + + expect(entity.as_json).to eq(expected_result) + end +end -- GitLab From ee592f0df36aba995d04a969697fb7a064b6ef3c Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Thu, 6 Apr 2017 11:37:23 +0200 Subject: [PATCH 003/363] Improve specs related to CI/CD job environment --- spec/models/ci/build_spec.rb | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 8dbcf50ee0c5c..ae390afbd80e9 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -902,22 +902,26 @@ def create_mr(build, pipeline, factory: :merge_request, created_at: Time.now) end describe '#persisted_environment' do - before do - @environment = create(:environment, project: project, name: "foo-#{project.default_branch}") + let!(:environment) do + create(:environment, project: project, name: "foo-#{project.default_branch}") end subject { build.persisted_environment } - context 'referenced literally' do - let(:build) { create(:ci_build, pipeline: pipeline, environment: "foo-#{project.default_branch}") } + context 'when referenced literally' do + let(:build) do + create(:ci_build, pipeline: pipeline, environment: "foo-#{project.default_branch}") + end - it { is_expected.to eq(@environment) } + it { is_expected.to eq(environment) } end - context 'referenced with a variable' do - let(:build) { create(:ci_build, pipeline: pipeline, environment: "foo-$CI_COMMIT_REF_NAME") } + context 'when referenced with a variable' do + let(:build) do + create(:ci_build, pipeline: pipeline, environment: "foo-$CI_COMMIT_REF_NAME") + end - it { is_expected.to eq(@environment) } + it { is_expected.to eq(environment) } end end -- GitLab From 5525db8b33bf1f76c4a8023ea4fb571d73e41b0f Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Thu, 6 Apr 2017 11:52:37 +0200 Subject: [PATCH 004/363] Check branch access when user triggers manual action --- app/models/ci/build.rb | 10 ++++++ spec/models/ci/build_spec.rb | 65 +++++++++++++++++++++++++++++------- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 8431c5f228cb1..159b3b2e10153 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -115,7 +115,17 @@ def has_commands? commands.present? end + def can_play?(current_user) + ::Gitlab::UserAccess + .new(current_user, project: project) + .can_push_to_branch?(ref) + end + def play(current_user) + unless can_play?(current_user) + raise Gitlab::Access::AccessDeniedError + end + # Try to queue a current build if self.enqueue self.update(user: current_user) diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index ae390afbd80e9..8124d263fd417 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -925,6 +925,33 @@ def create_mr(build, pipeline, factory: :merge_request, created_at: Time.now) end end + describe '#can_play?' do + before do + project.add_developer(user) + end + + let(:build) do + create(:ci_build, ref: 'some-ref', pipeline: pipeline) + end + + context 'when branch build is running for is protected' do + before do + create(:protected_branch, :no_one_can_push, + name: 'some-ref', project: project) + end + + it 'indicates that user can not trigger an action' do + expect(build.can_play?(user)).to be_falsey + end + end + + context 'when branch build is running for is not protected' do + it 'indicates that user can trigger an action' do + expect(build.can_play?(user)).to be_truthy + end + end + end + describe '#play' do let(:build) { create(:ci_build, :manual, pipeline: pipeline) } @@ -932,25 +959,39 @@ def create_mr(build, pipeline, factory: :merge_request, created_at: Time.now) project.add_developer(user) end - context 'when build is manual' do - it 'enqueues a build' do - new_build = build.play(user) + context 'when user does not have ability to trigger action' do + before do + create(:protected_branch, :no_one_can_push, + name: build.ref, project: project) + end - expect(new_build).to be_pending - expect(new_build).to eq(build) + it 'raises an error' do + expect { build.play(user) } + .to raise_error Gitlab::Access::AccessDeniedError end end - context 'when build is passed' do - before do - build.update(status: 'success') + context 'when user has ability to trigger manual action' do + context 'when build is manual' do + it 'enqueues a build' do + new_build = build.play(user) + + expect(new_build).to be_pending + expect(new_build).to eq(build) + end end - it 'creates a new build' do - new_build = build.play(user) + context 'when build is not manual' do + before do + build.update(status: 'success') + end + + it 'creates a new build' do + new_build = build.play(user) - expect(new_build).to be_pending - expect(new_build).not_to eq(build) + expect(new_build).to be_pending + expect(new_build).not_to eq(build) + end end end end -- GitLab From 3dbef510c187606ef12eb9f2aaf51f30ddc3e30d Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Thu, 6 Apr 2017 12:58:13 +0200 Subject: [PATCH 005/363] Expose manual action abilities from a build entity --- app/serializers/build_entity.rb | 10 +++++++--- spec/serializers/build_entity_spec.rb | 28 +++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/app/serializers/build_entity.rb b/app/serializers/build_entity.rb index b804d6d0e8a7b..401a277dadc53 100644 --- a/app/serializers/build_entity.rb +++ b/app/serializers/build_entity.rb @@ -12,7 +12,7 @@ class BuildEntity < Grape::Entity path_to(:retry_namespace_project_build, build) end - expose :play_path, if: ->(build, _) { build.playable? } do |build| + expose :play_path, if: proc { playable? } do |build| path_to(:play_namespace_project_build, build) end @@ -25,11 +25,15 @@ class BuildEntity < Grape::Entity alias_method :build, :object - def path_to(route, build) - send("#{route}_path", build.project.namespace, build.project, build) + def playable? + build.playable? && build.can_play?(request.user) end def detailed_status build.detailed_status(request.user) end + + def path_to(route, build) + send("#{route}_path", build.project.namespace, build.project, build) + end end diff --git a/spec/serializers/build_entity_spec.rb b/spec/serializers/build_entity_spec.rb index f76a5cf72d1a9..897a28b7305a8 100644 --- a/spec/serializers/build_entity_spec.rb +++ b/spec/serializers/build_entity_spec.rb @@ -41,13 +41,37 @@ it 'does not contain path to play action' do expect(subject).not_to include(:play_path) end + + it 'is not a playable job' do + expect(subject[:playable]).to be false + end end context 'when build is a manual action' do let(:build) { create(:ci_build, :manual) } - it 'contains path to play action' do - expect(subject).to include(:play_path) + context 'when user is allowed to trigger action' do + before do + build.project.add_master(user) + end + + it 'contains path to play action' do + expect(subject).to include(:play_path) + end + + it 'is a playable action' do + expect(subject[:playable]).to be true + end + end + + context 'when user is not allowed to trigger action' do + it 'does not contain path to play action' do + expect(subject).not_to include(:play_path) + end + + it 'is not a playable action' do + expect(subject[:playable]).to be false + end end end end -- GitLab From 5b6202cce1cf7ad5da1fcbfc4e089194311f205b Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Thu, 6 Apr 2017 13:08:45 +0200 Subject: [PATCH 006/363] Do not show play action if user not allowed to run it --- lib/gitlab/ci/status/build/play.rb | 2 +- spec/lib/gitlab/ci/status/build/play_spec.rb | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/gitlab/ci/status/build/play.rb b/lib/gitlab/ci/status/build/play.rb index 3495b8d0448b9..29d0558a265c5 100644 --- a/lib/gitlab/ci/status/build/play.rb +++ b/lib/gitlab/ci/status/build/play.rb @@ -10,7 +10,7 @@ def label end def has_action? - can?(user, :update_build, subject) + can?(user, :update_build, subject) && subject.can_play?(user) end def action_icon diff --git a/spec/lib/gitlab/ci/status/build/play_spec.rb b/spec/lib/gitlab/ci/status/build/play_spec.rb index 6c97a4fe5cad4..48aeb89e11f44 100644 --- a/spec/lib/gitlab/ci/status/build/play_spec.rb +++ b/spec/lib/gitlab/ci/status/build/play_spec.rb @@ -17,9 +17,17 @@ describe '#has_action?' do context 'when user is allowed to update build' do - before { build.project.team << [user, :developer] } + context 'when user can push to branch' do + before { build.project.add_master(user) } - it { is_expected.to have_action } + it { is_expected.to have_action } + end + + context 'when user can not push to the branch' do + before { build.project.add_developer(user) } + + it { is_expected.not_to have_action } + end end context 'when user is not allowed to update build' do -- GitLab From f647ad8f6fd076c6adcc3876b5e4a521a49c0ca8 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Thu, 6 Apr 2017 13:40:04 +0200 Subject: [PATCH 007/363] Fix chatops specs with incorrectly defined project --- spec/lib/gitlab/chat_commands/command_spec.rb | 14 ++++++++++---- spec/lib/gitlab/chat_commands/deploy_spec.rb | 16 ++++++++++------ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/spec/lib/gitlab/chat_commands/command_spec.rb b/spec/lib/gitlab/chat_commands/command_spec.rb index b6e924d67bebc..eb4f06b371c43 100644 --- a/spec/lib/gitlab/chat_commands/command_spec.rb +++ b/spec/lib/gitlab/chat_commands/command_spec.rb @@ -40,11 +40,15 @@ context 'when trying to do deployment' do let(:params) { { text: 'deploy staging to production' } } - let!(:build) { create(:ci_build, project: project) } + let!(:build) { create(:ci_build, pipeline: pipeline) } + let!(:pipeline) { create(:ci_pipeline, project: project) } let!(:staging) { create(:environment, name: 'staging', project: project) } let!(:deployment) { create(:deployment, environment: staging, deployable: build) } + let!(:manual) do - create(:ci_build, :manual, project: project, pipeline: build.pipeline, name: 'first', environment: 'production') + create(:ci_build, :manual, pipeline: pipeline, + name: 'first', + environment: 'production') end context 'and user can not create deployment' do @@ -56,7 +60,7 @@ context 'and user does have deployment permission' do before do - project.team << [user, :developer] + build.project.add_master(user) end it 'returns action' do @@ -66,7 +70,9 @@ context 'when duplicate action exists' do let!(:manual2) do - create(:ci_build, :manual, project: project, pipeline: build.pipeline, name: 'second', environment: 'production') + create(:ci_build, :manual, pipeline: pipeline, + name: 'second', + environment: 'production') end it 'returns error' do diff --git a/spec/lib/gitlab/chat_commands/deploy_spec.rb b/spec/lib/gitlab/chat_commands/deploy_spec.rb index b3358a321618d..b33389d959e30 100644 --- a/spec/lib/gitlab/chat_commands/deploy_spec.rb +++ b/spec/lib/gitlab/chat_commands/deploy_spec.rb @@ -7,7 +7,7 @@ let(:regex_match) { described_class.match('deploy staging to production') } before do - project.team << [user, :master] + project.add_master(user) end subject do @@ -23,7 +23,8 @@ context 'with environment' do let!(:staging) { create(:environment, name: 'staging', project: project) } - let!(:build) { create(:ci_build, project: project) } + let!(:pipeline) { create(:ci_pipeline, project: project) } + let!(:build) { create(:ci_build, pipeline: pipeline) } let!(:deployment) { create(:deployment, environment: staging, deployable: build) } context 'without actions' do @@ -35,7 +36,9 @@ context 'with action' do let!(:manual1) do - create(:ci_build, :manual, project: project, pipeline: build.pipeline, name: 'first', environment: 'production') + create(:ci_build, :manual, pipeline: pipeline, + name: 'first', + environment: 'production') end it 'returns success result' do @@ -45,7 +48,9 @@ context 'when duplicate action exists' do let!(:manual2) do - create(:ci_build, :manual, project: project, pipeline: build.pipeline, name: 'second', environment: 'production') + create(:ci_build, :manual, pipeline: pipeline, + name: 'second', + environment: 'production') end it 'returns error' do @@ -57,8 +62,7 @@ context 'when teardown action exists' do let!(:teardown) do create(:ci_build, :manual, :teardown_environment, - project: project, pipeline: build.pipeline, - name: 'teardown', environment: 'production') + pipeline: pipeline, name: 'teardown', environment: 'production') end it 'returns the success message' do -- GitLab From e533d43a8c03a9b47a7016f3fea01a00ca797778 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Thu, 6 Apr 2017 14:28:40 +0200 Subject: [PATCH 008/363] Fix specs related to new manual actions permissions --- spec/factories/environments.rb | 4 +- .../projects/environments/environment_spec.rb | 4 ++ spec/models/environment_spec.rb | 53 ++++++++++++++----- .../ci/process_pipeline_service_spec.rb | 7 +++ 4 files changed, 54 insertions(+), 14 deletions(-) diff --git a/spec/factories/environments.rb b/spec/factories/environments.rb index 3fbf24b5c7d29..f6595751d1e27 100644 --- a/spec/factories/environments.rb +++ b/spec/factories/environments.rb @@ -18,6 +18,8 @@ # interconnected objects to simulate a review app. # after(:create) do |environment, evaluator| + pipeline = create(:ci_pipeline, project: environment.project) + deployment = create(:deployment, environment: environment, project: environment.project, @@ -26,7 +28,7 @@ teardown_build = create(:ci_build, :manual, name: "#{deployment.environment.name}:teardown", - pipeline: deployment.deployable.pipeline) + pipeline: pipeline) deployment.update_column(:on_stop, teardown_build.name) environment.update_attribute(:deployments, [deployment]) diff --git a/spec/features/projects/environments/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb index acc3efe04e672..2b7f67eee3265 100644 --- a/spec/features/projects/environments/environment_spec.rb +++ b/spec/features/projects/environments/environment_spec.rb @@ -62,6 +62,8 @@ name: 'deploy to production') end + given(:role) { :master } + scenario 'does show a play button' do expect(page).to have_link(action.name.humanize) end @@ -132,6 +134,8 @@ on_stop: 'close_app') end + given(:role) { :master } + scenario 'does allow to stop environment' do click_link('Stop') diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 9f0e7fbbe268c..9e00f2247e8b1 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -191,25 +191,52 @@ end context 'when matching action is defined' do - let(:build) { create(:ci_build) } - let!(:deployment) { create(:deployment, environment: environment, deployable: build, on_stop: 'close_app') } + let(:pipeline) { create(:ci_pipeline, project: project) } + let(:build) { create(:ci_build, pipeline: pipeline) } + + let!(:deployment) do + create(:deployment, environment: environment, + deployable: build, + on_stop: 'close_app') + end - context 'when action did not yet finish' do - let!(:close_action) { create(:ci_build, :manual, pipeline: build.pipeline, name: 'close_app') } + context 'when user is not allowed to stop environment' do + let!(:close_action) do + create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') + end - it 'returns the same action' do - expect(subject).to eq(close_action) - expect(subject.user).to eq(user) + it 'raises an exception' do + expect { subject }.to raise_error(Gitlab::Access::AccessDeniedError) end end - context 'if action did finish' do - let!(:close_action) { create(:ci_build, :manual, :success, pipeline: build.pipeline, name: 'close_app') } + context 'when user is allowed to stop environment' do + before do + project.add_master(user) + end + + context 'when action did not yet finish' do + let!(:close_action) do + create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') + end + + it 'returns the same action' do + expect(subject).to eq(close_action) + expect(subject.user).to eq(user) + end + end - it 'returns a new action of the same type' do - is_expected.to be_persisted - expect(subject.name).to eq(close_action.name) - expect(subject.user).to eq(user) + context 'if action did finish' do + let!(:close_action) do + create(:ci_build, :manual, :success, + pipeline: pipeline, name: 'close_app') + end + + it 'returns a new action of the same type' do + expect(subject).to be_persisted + expect(subject.name).to eq(close_action.name) + expect(subject.user).to eq(user) + end end end end diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb index bb98fb37a9018..7df6a81b0aba0 100644 --- a/spec/services/ci/process_pipeline_service_spec.rb +++ b/spec/services/ci/process_pipeline_service_spec.rb @@ -314,6 +314,13 @@ end context 'when pipeline is promoted sequentially up to the end' do + before do + # We are using create(:empty_project), and users has to be master in + # order to execute manual action when repository does not exist. + # + project.add_master(user) + end + it 'properly processes entire pipeline' do process_pipeline -- GitLab From 6c0fc62ef5c4fa4535174a9f187b9853f0fb90ac Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Thu, 6 Apr 2017 15:19:52 +0200 Subject: [PATCH 009/363] Take branch access into account when stopping environment --- app/models/deployment.rb | 4 +-- app/models/environment.rb | 6 +++++ app/services/ci/stop_environments_service.rb | 8 +++--- spec/factories/environments.rb | 6 ++++- spec/models/environment_spec.rb | 25 +++++++++++++++++++ .../ci/stop_environments_service_spec.rb | 16 +++++++++++- 6 files changed, 58 insertions(+), 7 deletions(-) diff --git a/app/models/deployment.rb b/app/models/deployment.rb index afad001d50f91..37adfb4de7342 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -85,8 +85,8 @@ def previous_deployment end def stop_action - return nil unless on_stop.present? - return nil unless manual_actions + return unless on_stop.present? + return unless manual_actions @stop_action ||= manual_actions.find_by(name: on_stop) end diff --git a/app/models/environment.rb b/app/models/environment.rb index bf33010fd21f2..f8b9a21c08ea3 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -122,6 +122,12 @@ def stop_action? available? && stop_action.present? end + def can_trigger_stop_action?(current_user) + return false unless stop_action? + + stop_action.can_play?(current_user) + end + def stop_with_action!(current_user) return unless available? diff --git a/app/services/ci/stop_environments_service.rb b/app/services/ci/stop_environments_service.rb index 42c72aba7ddfc..d1e341bc9b509 100644 --- a/app/services/ci/stop_environments_service.rb +++ b/app/services/ci/stop_environments_service.rb @@ -6,9 +6,10 @@ def execute(branch_name) @ref = branch_name return unless has_ref? + return unless can?(current_user, :create_deployment, project) environments.each do |environment| - next unless can?(current_user, :create_deployment, project) + next unless environment.can_trigger_stop_action?(current_user) environment.stop_with_action!(current_user) end @@ -21,8 +22,9 @@ def has_ref? end def environments - @environments ||= - EnvironmentsFinder.new(project, current_user, ref: @ref, recently_updated: true).execute + @environments ||= EnvironmentsFinder + .new(project, current_user, ref: @ref, recently_updated: true) + .execute end end end diff --git a/spec/factories/environments.rb b/spec/factories/environments.rb index f6595751d1e27..d8d699fb3aace 100644 --- a/spec/factories/environments.rb +++ b/spec/factories/environments.rb @@ -20,14 +20,18 @@ after(:create) do |environment, evaluator| pipeline = create(:ci_pipeline, project: environment.project) + deployable = create(:ci_build, name: "#{environment.name}:deploy", + pipeline: pipeline) + deployment = create(:deployment, environment: environment, project: environment.project, + deployable: deployable, ref: evaluator.ref, sha: environment.project.commit(evaluator.ref).id) teardown_build = create(:ci_build, :manual, - name: "#{deployment.environment.name}:teardown", + name: "#{environment.name}:teardown", pipeline: pipeline) deployment.update_column(:on_stop, teardown_build.name) diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 9e00f2247e8b1..fb7cee47ae8e2 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -155,6 +155,31 @@ end end + describe '#can_trigger_stop_action?' do + let(:user) { create(:user) } + let(:project) { create(:project) } + + let(:environment) do + create(:environment, :with_review_app, project: project) + end + + context 'when user can trigger stop action' do + before do + project.add_developer(user) + end + + it 'returns value that evaluates to true' do + expect(environment.can_trigger_stop_action?(user)).to be_truthy + end + end + + context 'when user is not allowed to trigger stop action' do + it 'returns value that evaluates to false' do + expect(environment.can_trigger_stop_action?(user)).to be_falsey + end + end + end + describe '#stop_with_action!' do let(:user) { create(:admin) } diff --git a/spec/services/ci/stop_environments_service_spec.rb b/spec/services/ci/stop_environments_service_spec.rb index 32c72a9cf5ef0..98044ad232ed9 100644 --- a/spec/services/ci/stop_environments_service_spec.rb +++ b/spec/services/ci/stop_environments_service_spec.rb @@ -55,8 +55,22 @@ end context 'when user does not have permission to stop environment' do + context 'when user has no access to manage deployments' do + before do + project.team << [user, :guest] + end + + it 'does not stop environment' do + expect_environment_not_stopped_on('master') + end + end + end + + context 'when branch for stop action is protected' do before do - project.team << [user, :guest] + project.add_developer(user) + create(:protected_branch, :no_one_can_push, + name: 'master', project: project) end it 'does not stop environment' do -- GitLab From d96e90734783a83ca4d87b961699072712077d9f Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Thu, 6 Apr 2017 15:24:48 +0200 Subject: [PATCH 010/363] Fix specs for build status factory and manual status --- spec/lib/gitlab/ci/status/build/factory_spec.rb | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/spec/lib/gitlab/ci/status/build/factory_spec.rb b/spec/lib/gitlab/ci/status/build/factory_spec.rb index e648a3ac3a27c..2ab67127b1edd 100644 --- a/spec/lib/gitlab/ci/status/build/factory_spec.rb +++ b/spec/lib/gitlab/ci/status/build/factory_spec.rb @@ -218,9 +218,24 @@ expect(status.favicon).to eq 'favicon_status_manual' expect(status.label).to eq 'manual play action' expect(status).to have_details - expect(status).to have_action expect(status.action_path).to include 'play' end + + context 'when user has ability to play action' do + before do + build.project.add_master(user) + end + + it 'fabricates status that has action' do + expect(status).to have_action + end + end + + context 'when user does not have ability to play action' do + it 'fabricates status that has no action' do + expect(status).not_to have_action + end + end end context 'when build is an environment stop action' do -- GitLab From 3e55e0742218fe20b269dfbc4f44d3798e4b0eb6 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Thu, 6 Apr 2017 15:32:23 +0200 Subject: [PATCH 011/363] Check if user can trigger manual action in the UI --- app/views/projects/ci/builds/_build.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index aeed293a72427..ceec36e24407c 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -101,7 +101,7 @@ = link_to cancel_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do = icon('remove', class: 'cred') - elsif allow_retry - - if build.playable? && !admin + - if build.playable? && !admin && build.can_play?(current_user) = link_to play_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do = custom_icon('icon_play') - elsif build.retryable? -- GitLab From 7bcca2284b09e18438e6163c6ead72e10fdd2f57 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Thu, 6 Apr 2017 17:16:19 +0200 Subject: [PATCH 012/363] Check branch permission in manual action entity --- app/serializers/build_action_entity.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/serializers/build_action_entity.rb b/app/serializers/build_action_entity.rb index 184b4b7a6813e..3d12c64b88aa4 100644 --- a/app/serializers/build_action_entity.rb +++ b/app/serializers/build_action_entity.rb @@ -13,4 +13,12 @@ class BuildActionEntity < Grape::Entity end expose :playable?, as: :playable + + private + + alias_method :build, :object + + def playable? + build.playable? && build.can_play?(request.user) + end end -- GitLab From 703df2881bb137a79284baafe2cc12ff32ab9ff5 Mon Sep 17 00:00:00 2001 From: Jarka Kadlecova <jarka@gitlab.com> Date: Fri, 7 Apr 2017 13:34:39 +0200 Subject: [PATCH 013/363] expose additional values in deploy key entity --- app/serializers/deploy_key_entity.rb | 2 ++ spec/serializers/deploy_key_entity_spec.rb | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb index cdedc2c7bd018..d75a83d0fa529 100644 --- a/app/serializers/deploy_key_entity.rb +++ b/app/serializers/deploy_key_entity.rb @@ -4,6 +4,8 @@ class DeployKeyEntity < Grape::Entity expose :title expose :fingerprint expose :can_push + expose :destroyed_when_orphaned?, as: :destroyed_when_orphaned + expose :almost_orphaned?, as: :almost_orphaned expose :created_at expose :updated_at expose :projects, using: ProjectEntity do |deploy_key| diff --git a/spec/serializers/deploy_key_entity_spec.rb b/spec/serializers/deploy_key_entity_spec.rb index da1d33b42ac80..cc3fb193f1be2 100644 --- a/spec/serializers/deploy_key_entity_spec.rb +++ b/spec/serializers/deploy_key_entity_spec.rb @@ -17,6 +17,8 @@ title: deploy_key.title, fingerprint: deploy_key.fingerprint, can_push: deploy_key.can_push, + destroyed_when_orphaned: true, + almost_orphaned: false, created_at: deploy_key.created_at, updated_at: deploy_key.updated_at, projects: [ -- GitLab From acea881bb012cce0b59f3d5874a630b16d0caaef Mon Sep 17 00:00:00 2001 From: Jacob Schatz <jschatz1@gmail.com> Date: Fri, 7 Apr 2017 23:38:43 -0400 Subject: [PATCH 014/363] Initial balsamiq support --- app/models/blob.rb | 6 ++++++ app/views/projects/blob/_bmpr.html.haml | 5 +++++ app/views/projects/blob/balsamiq_viewer.js | 3 +++ config/webpack.config.js | 1 + db/schema.rb | 2 +- 5 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 app/views/projects/blob/_bmpr.html.haml create mode 100644 app/views/projects/blob/balsamiq_viewer.js diff --git a/app/models/blob.rb b/app/models/blob.rb index 801d344280367..0c1730f6801bc 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -58,6 +58,10 @@ def sketch? binary? && extname.downcase.delete('.') == 'sketch' end + def balsamiq? + binary? && extname.downcase.delete('.') == 'bmpr' + end + def stl? extname.downcase.delete('.') == 'stl' end @@ -87,6 +91,8 @@ def to_partial_path(project) 'sketch' elsif stl? 'stl' + elsif balsamiq? + 'bmpr' elsif text? 'text' else diff --git a/app/views/projects/blob/_bmpr.html.haml b/app/views/projects/blob/_bmpr.html.haml new file mode 100644 index 0000000000000..048bdf9dcb5a6 --- /dev/null +++ b/app/views/projects/blob/_bmpr.html.haml @@ -0,0 +1,5 @@ +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('balsamiq_viewer') + +.file-content#js-balsamiq-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } } \ No newline at end of file diff --git a/app/views/projects/blob/balsamiq_viewer.js b/app/views/projects/blob/balsamiq_viewer.js new file mode 100644 index 0000000000000..b60cfe165a4e9 --- /dev/null +++ b/app/views/projects/blob/balsamiq_viewer.js @@ -0,0 +1,3 @@ +import renderBalsamiq from './balsamiq'; + +document.addEventListener('DOMContentLoaded', renderBalsamiq); \ No newline at end of file diff --git a/config/webpack.config.js b/config/webpack.config.js index e3bc939d57847..42638d47cfadd 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -39,6 +39,7 @@ var config = { notebook_viewer: './blob/notebook_viewer.js', sketch_viewer: './blob/sketch_viewer.js', pdf_viewer: './blob/pdf_viewer.js', + balsamiq_viewer: './blob/balsamiq_viewer.js', profile: './profile/profile_bundle.js', protected_branches: './protected_branches/protected_branches_bundle.js', protected_tags: './protected_tags', diff --git a/db/schema.rb b/db/schema.rb index 3422847d72985..ceae051db0c60 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -950,9 +950,9 @@ t.boolean "lfs_enabled" t.text "description_html" t.boolean "only_allow_merge_if_all_discussions_are_resolved" - t.integer "auto_cancel_pending_pipelines", default: 0, null: false t.boolean "printing_merge_request_link_enabled", default: true, null: false t.string "import_jid" + t.integer "auto_cancel_pending_pipelines", default: 0, null: false end add_index "projects", ["ci_id"], name: "index_projects_on_ci_id", using: :btree -- GitLab From ef07200cd0f059a2e0493779263aa526a2ade2e3 Mon Sep 17 00:00:00 2001 From: Jacob Schatz <jschatz1@gmail.com> Date: Sat, 8 Apr 2017 00:18:37 -0400 Subject: [PATCH 015/363] Get initial sql values back from file which is database --- app/assets/javascripts/blob/balsamiq/index.js | 27 +++++++++++++++++++ .../javascripts/blob/balsamiq_viewer.js | 5 ++++ app/views/projects/blob/balsamiq_viewer.js | 3 --- config/webpack.config.js | 5 ++++ package.json | 1 + yarn.lock | 4 +++ 6 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 app/assets/javascripts/blob/balsamiq/index.js create mode 100644 app/assets/javascripts/blob/balsamiq_viewer.js delete mode 100644 app/views/projects/blob/balsamiq_viewer.js diff --git a/app/assets/javascripts/blob/balsamiq/index.js b/app/assets/javascripts/blob/balsamiq/index.js new file mode 100644 index 0000000000000..042013668974b --- /dev/null +++ b/app/assets/javascripts/blob/balsamiq/index.js @@ -0,0 +1,27 @@ +import Vue from 'vue'; +import sqljs from 'sql.js'; + +export default class BalsamiqViewer { + constructor(el) { + this.el = el; + this.loadSqlFile(); + } + + + + loadSqlFile() { + var xhr = new XMLHttpRequest(); + console.log(this.el) + xhr.open('GET', this.el.dataset.endpoint, true); + xhr.responseType = 'arraybuffer'; + + xhr.onload = function(e) { + var uInt8Array = new Uint8Array(this.response); + var db = new SQL.Database(uInt8Array); + var contents = db.exec("SELECT * FROM thumbnails"); + console.log(contents) + // contents is now [{columns:['col1','col2',...], values:[[first row], [second row], ...]}] + }; + xhr.send(); + } +} \ No newline at end of file diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js new file mode 100644 index 0000000000000..b14933980998d --- /dev/null +++ b/app/assets/javascripts/blob/balsamiq_viewer.js @@ -0,0 +1,5 @@ +import BalsamiqViewer from './balsamiq'; + +document.addEventListener('DOMContentLoaded', () => { + new BalsamiqViewer(document.getElementById('js-balsamiq-viewer')); +}); \ No newline at end of file diff --git a/app/views/projects/blob/balsamiq_viewer.js b/app/views/projects/blob/balsamiq_viewer.js deleted file mode 100644 index b60cfe165a4e9..0000000000000 --- a/app/views/projects/blob/balsamiq_viewer.js +++ /dev/null @@ -1,3 +0,0 @@ -import renderBalsamiq from './balsamiq'; - -document.addEventListener('DOMContentLoaded', renderBalsamiq); \ No newline at end of file diff --git a/config/webpack.config.js b/config/webpack.config.js index 42638d47cfadd..bc4126bce02d4 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -15,6 +15,10 @@ var DEV_SERVER_LIVERELOAD = process.env.DEV_SERVER_LIVERELOAD !== 'false'; var WEBPACK_REPORT = process.env.WEBPACK_REPORT; var config = { + // because sqljs requires fs. + node: { + fs: "empty" + }, context: path.join(ROOT_PATH, 'app/assets/javascripts'), entry: { common: './commons/index.js', @@ -118,6 +122,7 @@ var config = { 'merge_conflicts', 'notebook_viewer', 'pdf_viewer', + 'balsamiq_viewer', 'vue_pipelines', ], minChunks: function(module, count) { diff --git a/package.json b/package.json index 6d4f99e33b30c..eb680b0d76c94 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "raphael": "^2.2.7", "raw-loader": "^0.5.1", "select2": "3.5.2-browserify", + "sql.js": "^0.4.0", "stats-webpack-plugin": "^0.4.3", "three": "^0.84.0", "three-orbit-controls": "^82.1.0", diff --git a/yarn.lock b/yarn.lock index 2434b3a8a48a6..d0175ca9f986e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4154,6 +4154,10 @@ sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" +sql.js@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/sql.js/-/sql.js-0.4.0.tgz#23be9635520eb0ff43a741e7e830397266e88445" + sshpk@^1.7.0: version "1.10.2" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.10.2.tgz#d5a804ce22695515638e798dbe23273de070a5fa" -- GitLab From e1b0ed391fcbd1c622b6e3c866674b85ccd0edea Mon Sep 17 00:00:00 2001 From: Jacob Schatz <jschatz1@gmail.com> Date: Sat, 8 Apr 2017 10:36:42 -0400 Subject: [PATCH 016/363] Show thumbnails and their titles. --- app/assets/javascripts/blob/balsamiq/index.js | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/blob/balsamiq/index.js b/app/assets/javascripts/blob/balsamiq/index.js index 042013668974b..61f4631b423fe 100644 --- a/app/assets/javascripts/blob/balsamiq/index.js +++ b/app/assets/javascripts/blob/balsamiq/index.js @@ -8,19 +8,33 @@ export default class BalsamiqViewer { } - loadSqlFile() { var xhr = new XMLHttpRequest(); - console.log(this.el) + var self = this; xhr.open('GET', this.el.dataset.endpoint, true); xhr.responseType = 'arraybuffer'; xhr.onload = function(e) { + var list = document.createElement('ul'); var uInt8Array = new Uint8Array(this.response); var db = new SQL.Database(uInt8Array); var contents = db.exec("SELECT * FROM thumbnails"); - console.log(contents) - // contents is now [{columns:['col1','col2',...], values:[[first row], [second row], ...]}] + var previews = contents[0].values.map((i)=>{return JSON.parse(i[1])}); + previews.forEach((prev) => { + var li = document.createElement('li'); + var title = db.exec(`select * from resources where id = '${prev.resourceID}'`) + var template = + `<div class="panel panel-default"> + <div class="panel-heading">${JSON.parse(title[0].values[0][2]).name}</div> + <div class="panel-body"> + <img class="img-thumbnail" src="data:image/png;base64,${prev.image}"/> + </div> + </div>`; + li.innerHTML = template; + list.appendChild(li); + }); + list.classList += 'list-inline'; + self.el.appendChild(list); }; xhr.send(); } -- GitLab From 440ff838f7646ac7ea7a660280b51740ebeed70f Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Mon, 10 Apr 2017 15:48:23 +0100 Subject: [PATCH 017/363] Tidy balsamiq viewer and remove unused Vue --- .../blob/balsamiq/balsamiq_viewer.js | 89 +++++++++++++++++++ app/assets/javascripts/blob/balsamiq/index.js | 41 --------- .../javascripts/blob/balsamiq_viewer.js | 7 +- app/views/projects/blob/_bmpr.html.haml | 3 +- 4 files changed, 94 insertions(+), 46 deletions(-) create mode 100644 app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js delete mode 100644 app/assets/javascripts/blob/balsamiq/index.js diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js new file mode 100644 index 0000000000000..e1c837cb09efc --- /dev/null +++ b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js @@ -0,0 +1,89 @@ +/* global Flash */ + +import sqljs from 'sql.js'; + +class BalsamiqViewer { + constructor(viewer) { + this.viewer = viewer; + this.endpoint = this.viewer.dataset.endpoint; + } + + loadFile() { + const xhr = new XMLHttpRequest(); + + xhr.open('GET', this.endpoint, true); + xhr.responseType = 'arraybuffer'; + + xhr.onload = this.renderFile.bind(this); + xhr.onerror = BalsamiqViewer.onError; + + xhr.send(); + } + + renderFile(loadEvent) { + const container = document.createElement('ul'); + + this.initDatabase(loadEvent.target.response); + + const previews = this.getPreviews(); + const renderedPreviews = previews.map(preview => this.renderPreview(preview, container)); + + container.innerHTML = renderedPreviews.join(''); + container.classList.add('list-inline'); + + this.viewer.appendChild(container); + } + + initDatabase(data) { + const previewBinary = new Uint8Array(data); + + this.database = new sqljs.Database(previewBinary); + } + + getPreviews() { + const thumnails = this.database.exec('SELECT * FROM thumbnails'); + + return thumnails[0].values.map(BalsamiqViewer.parsePreview); + } + + renderPreview(preview) { + const previewElement = document.createElement('li'); + + previewElement.innerHTML = this.renderTemplate(preview); + + return previewElement.outerHTML; + } + + renderTemplate(preview) { + let template = BalsamiqViewer.PREVIEW_TEMPLATE; + + const title = this.database.exec(`SELECT * FROM resources WHERE id = '${preview.resourceID}'`); + const name = JSON.parse(title[0].values[0][2]).name; + const image = preview.image; + + template = template.replace(/{{name}}/, name).replace(/{{image}}/, image); + + return template; + } + + static parsePreview(preview) { + return JSON.parse(preview[1]); + } + + static onError() { + const flash = new Flash('Balsamiq file could not be loaded.'); + + return flash; + } +} + +BalsamiqViewer.PREVIEW_TEMPLATE = ` + <div class="panel panel-default"> + <div class="panel-heading">{{name}}</div> + <div class="panel-body"> + <img class="img-thumbnail" src="data:image/png;base64,{{image}}"/> + </div> + </div> +`; + +export default BalsamiqViewer; diff --git a/app/assets/javascripts/blob/balsamiq/index.js b/app/assets/javascripts/blob/balsamiq/index.js deleted file mode 100644 index 61f4631b423fe..0000000000000 --- a/app/assets/javascripts/blob/balsamiq/index.js +++ /dev/null @@ -1,41 +0,0 @@ -import Vue from 'vue'; -import sqljs from 'sql.js'; - -export default class BalsamiqViewer { - constructor(el) { - this.el = el; - this.loadSqlFile(); - } - - - loadSqlFile() { - var xhr = new XMLHttpRequest(); - var self = this; - xhr.open('GET', this.el.dataset.endpoint, true); - xhr.responseType = 'arraybuffer'; - - xhr.onload = function(e) { - var list = document.createElement('ul'); - var uInt8Array = new Uint8Array(this.response); - var db = new SQL.Database(uInt8Array); - var contents = db.exec("SELECT * FROM thumbnails"); - var previews = contents[0].values.map((i)=>{return JSON.parse(i[1])}); - previews.forEach((prev) => { - var li = document.createElement('li'); - var title = db.exec(`select * from resources where id = '${prev.resourceID}'`) - var template = - `<div class="panel panel-default"> - <div class="panel-heading">${JSON.parse(title[0].values[0][2]).name}</div> - <div class="panel-body"> - <img class="img-thumbnail" src="data:image/png;base64,${prev.image}"/> - </div> - </div>`; - li.innerHTML = template; - list.appendChild(li); - }); - list.classList += 'list-inline'; - self.el.appendChild(list); - }; - xhr.send(); - } -} \ No newline at end of file diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js index b14933980998d..1dacf84470f3f 100644 --- a/app/assets/javascripts/blob/balsamiq_viewer.js +++ b/app/assets/javascripts/blob/balsamiq_viewer.js @@ -1,5 +1,6 @@ -import BalsamiqViewer from './balsamiq'; +import BalsamiqViewer from './balsamiq/balsamiq_viewer'; document.addEventListener('DOMContentLoaded', () => { - new BalsamiqViewer(document.getElementById('js-balsamiq-viewer')); -}); \ No newline at end of file + const balsamiqViewer = new BalsamiqViewer(document.getElementById('js-balsamiq-viewer')); + balsamiqViewer.loadFile(); +}); diff --git a/app/views/projects/blob/_bmpr.html.haml b/app/views/projects/blob/_bmpr.html.haml index 048bdf9dcb5a6..36ec0cbcce8bb 100644 --- a/app/views/projects/blob/_bmpr.html.haml +++ b/app/views/projects/blob/_bmpr.html.haml @@ -1,5 +1,4 @@ - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('balsamiq_viewer') -.file-content#js-balsamiq-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } } \ No newline at end of file +.file-content#js-balsamiq-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } } -- GitLab From 80745bc81da089af088d99574088dc6bf8959e4d Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Mon, 10 Apr 2017 16:16:09 +0100 Subject: [PATCH 018/363] Revert schema.db --- db/schema.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/schema.rb b/db/schema.rb index ceae051db0c60..3422847d72985 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -950,9 +950,9 @@ t.boolean "lfs_enabled" t.text "description_html" t.boolean "only_allow_merge_if_all_discussions_are_resolved" + t.integer "auto_cancel_pending_pipelines", default: 0, null: false t.boolean "printing_merge_request_link_enabled", default: true, null: false t.string "import_jid" - t.integer "auto_cancel_pending_pipelines", default: 0, null: false end add_index "projects", ["ci_id"], name: "index_projects_on_ci_id", using: :btree -- GitLab From 75d3ed9a43eceda6a375e1538945a20c97925841 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Mon, 10 Apr 2017 16:44:49 +0100 Subject: [PATCH 019/363] Added Spinner class --- .../blob/balsamiq/balsamiq_viewer.js | 13 +++++++-- app/assets/javascripts/spinner.js | 28 +++++++++++++++++++ app/assets/stylesheets/framework/files.scss | 11 ++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 app/assets/javascripts/spinner.js diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js index e1c837cb09efc..0065584cec5e5 100644 --- a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js +++ b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js @@ -1,11 +1,13 @@ /* global Flash */ +import Spinner from '../../spinner'; import sqljs from 'sql.js'; class BalsamiqViewer { constructor(viewer) { this.viewer = viewer; this.endpoint = this.viewer.dataset.endpoint; + this.spinner = new Spinner(this.viewer); } loadFile() { @@ -17,10 +19,14 @@ class BalsamiqViewer { xhr.onload = this.renderFile.bind(this); xhr.onerror = BalsamiqViewer.onError; + this.spinner.start(); + xhr.send(); } renderFile(loadEvent) { + this.spinner.stop(); + const container = document.createElement('ul'); this.initDatabase(loadEvent.target.response); @@ -29,7 +35,7 @@ class BalsamiqViewer { const renderedPreviews = previews.map(preview => this.renderPreview(preview, container)); container.innerHTML = renderedPreviews.join(''); - container.classList.add('list-inline'); + container.classList.add('list-inline', 'previews'); this.viewer.appendChild(container); } @@ -41,14 +47,15 @@ class BalsamiqViewer { } getPreviews() { - const thumnails = this.database.exec('SELECT * FROM thumbnails'); + const thumbnails = this.database.exec('SELECT * FROM thumbnails'); - return thumnails[0].values.map(BalsamiqViewer.parsePreview); + return thumbnails[0].values.map(BalsamiqViewer.parsePreview); } renderPreview(preview) { const previewElement = document.createElement('li'); + previewElement.classList.add('preview'); previewElement.innerHTML = this.renderTemplate(preview); return previewElement.outerHTML; diff --git a/app/assets/javascripts/spinner.js b/app/assets/javascripts/spinner.js new file mode 100644 index 0000000000000..b7bfe1a2572de --- /dev/null +++ b/app/assets/javascripts/spinner.js @@ -0,0 +1,28 @@ +class Spinner { + constructor(renderable) { + this.renderable = renderable; + + this.container = Spinner.createContainer(); + } + + start() { + this.renderable.prepend(this.container); + } + + stop() { + this.container.remove(); + } + + static createContainer() { + const container = document.createElement('div'); + container.classList.add('loading'); + + container.innerHTML = Spinner.TEMPLATE; + + return container; + } +} + +Spinner.TEMPLATE = '<i class="fa fa-spinner fa-spin"></i>'; + +export default Spinner; diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index a5a8522739e3d..b8dab538feef8 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -168,6 +168,17 @@ &.code { padding: 0; } + + .list-inline.previews { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + padding: $gl-padding; + + .preview { + flex-shrink: 0; + } + } } } -- GitLab From 40e2be25c74aef14a94cf9dff4e80554958fd347 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Mon, 10 Apr 2017 18:20:54 +0100 Subject: [PATCH 020/363] Added unit tests --- .../blob/balsamiq/balsamiq_viewer.js | 6 +- .../blob/balsamiq/balsamiq_viewer_spec.js | 357 ++++++++++++++++++ spec/javascripts/spinner_spec.js | 97 +++++ 3 files changed, 457 insertions(+), 3 deletions(-) create mode 100644 spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js create mode 100644 spec/javascripts/spinner_spec.js diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js index 0065584cec5e5..d0c161f2aad55 100644 --- a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js +++ b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js @@ -1,7 +1,7 @@ /* global Flash */ -import Spinner from '../../spinner'; import sqljs from 'sql.js'; +import Spinner from '../../spinner'; class BalsamiqViewer { constructor(viewer) { @@ -32,7 +32,7 @@ class BalsamiqViewer { this.initDatabase(loadEvent.target.response); const previews = this.getPreviews(); - const renderedPreviews = previews.map(preview => this.renderPreview(preview, container)); + const renderedPreviews = previews.map(preview => this.renderPreview(preview)); container.innerHTML = renderedPreviews.join(''); container.classList.add('list-inline', 'previews'); @@ -68,7 +68,7 @@ class BalsamiqViewer { const name = JSON.parse(title[0].values[0][2]).name; const image = preview.image; - template = template.replace(/{{name}}/, name).replace(/{{image}}/, image); + template = template.replace(/{{name}}/g, name).replace(/{{image}}/g, image); return template; } diff --git a/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js b/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js new file mode 100644 index 0000000000000..10db4175ca4e3 --- /dev/null +++ b/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js @@ -0,0 +1,357 @@ +import sqljs from 'sql.js'; +import BalsamiqViewer from '~/blob/balsamiq/balsamiq_viewer'; +import * as spinnerSrc from '~/spinner'; +import ClassSpecHelper from '../../helpers/class_spec_helper'; + +describe('BalsamiqViewer', () => { + let balsamiqViewer; + let endpoint; + let viewer; + + describe('class constructor', () => { + beforeEach(() => { + endpoint = 'endpoint'; + viewer = { + dataset: { + endpoint, + }, + }; + + spyOn(spinnerSrc, 'default'); + + balsamiqViewer = new BalsamiqViewer(viewer); + }); + + it('should set .viewer', () => { + expect(balsamiqViewer.viewer).toBe(viewer); + }); + + it('should set .endpoint', () => { + expect(balsamiqViewer.endpoint).toBe(endpoint); + }); + + it('should instantiate Spinner', () => { + expect(spinnerSrc.default).toHaveBeenCalledWith(viewer); + }); + + it('should set .spinner', () => { + expect(balsamiqViewer.spinner).toEqual(jasmine.any(spinnerSrc.default)); + }); + }); + + describe('loadFile', () => { + let xhr; + let spinner; + + beforeEach(() => { + endpoint = 'endpoint'; + xhr = jasmine.createSpyObj('xhr', ['open', 'send']); + spinner = jasmine.createSpyObj('spinner', ['start']); + + balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['renderFile']); + balsamiqViewer.endpoint = endpoint; + balsamiqViewer.spinner = spinner; + + spyOn(window, 'XMLHttpRequest').and.returnValue(xhr); + + BalsamiqViewer.prototype.loadFile.call(balsamiqViewer); + }); + + it('should instantiate XMLHttpRequest', () => { + expect(window.XMLHttpRequest).toHaveBeenCalled(); + }); + + it('should call .open', () => { + expect(xhr.open).toHaveBeenCalledWith('GET', endpoint, true); + }); + + it('should set .responseType', () => { + expect(xhr.responseType).toBe('arraybuffer'); + }); + + it('should set .onload', () => { + expect(xhr.onload).toEqual(jasmine.any(Function)); + }); + + it('should set .onerror', () => { + expect(xhr.onerror).toBe(BalsamiqViewer.onError); + }); + + it('should call spinner.start', () => { + expect(spinner.start).toHaveBeenCalled(); + }); + + it('should call .send', () => { + expect(xhr.send).toHaveBeenCalled(); + }); + }); + + describe('renderFile', () => { + let spinner; + let container; + let loadEvent; + let previews; + + beforeEach(() => { + loadEvent = { target: { response: {} } }; + viewer = jasmine.createSpyObj('viewer', ['appendChild']); + spinner = jasmine.createSpyObj('spinner', ['stop']); + previews = [0, 1, 2]; + + balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['initDatabase', 'getPreviews', 'renderPreview']); + balsamiqViewer.viewer = viewer; + balsamiqViewer.spinner = spinner; + + balsamiqViewer.getPreviews.and.returnValue(previews); + balsamiqViewer.renderPreview.and.callFake(preview => preview); + viewer.appendChild.and.callFake((containerElement) => { + container = containerElement; + }); + + BalsamiqViewer.prototype.renderFile.call(balsamiqViewer, loadEvent); + }); + + it('should call spinner.stop', () => { + expect(spinner.stop).toHaveBeenCalled(); + }); + + it('should call .initDatabase', () => { + expect(balsamiqViewer.initDatabase).toHaveBeenCalledWith(loadEvent.target.response); + }); + + it('should call .getPreviews', () => { + expect(balsamiqViewer.getPreviews).toHaveBeenCalled(); + }); + + it('should call .renderPreview for each preview', () => { + const allArgs = balsamiqViewer.renderPreview.calls.allArgs(); + + expect(allArgs.length).toBe(3); + + previews.forEach((preview, i) => { + expect(allArgs[i][0]).toBe(preview); + }); + }); + + it('should set .innerHTML', () => { + expect(container.innerHTML).toBe('012'); + }); + + it('should add inline preview classes', () => { + expect(container.classList[0]).toBe('list-inline'); + expect(container.classList[1]).toBe('previews'); + }); + + it('should call viewer.appendChild', () => { + expect(viewer.appendChild).toHaveBeenCalledWith(container); + }); + }); + + describe('initDatabase', () => { + let database; + let uint8Array; + let data; + + beforeEach(() => { + uint8Array = {}; + database = {}; + data = 'data'; + + balsamiqViewer = {}; + + spyOn(window, 'Uint8Array').and.returnValue(uint8Array); + spyOn(sqljs, 'Database').and.returnValue(database); + + BalsamiqViewer.prototype.initDatabase.call(balsamiqViewer, data); + }); + + it('should instantiate Uint8Array', () => { + expect(window.Uint8Array).toHaveBeenCalledWith(data); + }); + + it('should call sqljs.Database', () => { + expect(sqljs.Database).toHaveBeenCalledWith(uint8Array); + }); + + it('should set .database', () => { + expect(balsamiqViewer.database).toBe(database); + }); + }); + + describe('getPreviews', () => { + let database; + let thumbnails; + let getPreviews; + + beforeEach(() => { + database = jasmine.createSpyObj('database', ['exec']); + thumbnails = [{ values: [0, 1, 2] }]; + + balsamiqViewer = { + database, + }; + + spyOn(BalsamiqViewer, 'parsePreview').and.callFake(preview => preview.toString()); + database.exec.and.returnValue(thumbnails); + + getPreviews = BalsamiqViewer.prototype.getPreviews.call(balsamiqViewer); + }); + + it('should call database.exec', () => { + expect(database.exec).toHaveBeenCalledWith('SELECT * FROM thumbnails'); + }); + + it('should call .parsePreview for each value', () => { + const allArgs = BalsamiqViewer.parsePreview.calls.allArgs(); + + expect(allArgs.length).toBe(3); + + thumbnails[0].values.forEach((value, i) => { + expect(allArgs[i][0]).toBe(value); + }); + }); + + it('should return an array of parsed values', () => { + expect(getPreviews).toEqual(['0', '1', '2']); + }); + }); + + describe('renderPreview', () => { + let previewElement; + let innerHTML; + let preview; + let renderPreview; + + beforeEach(() => { + innerHTML = '<a>innerHTML</a>'; + previewElement = { + outerHTML: '<p>outerHTML</p>', + classList: jasmine.createSpyObj('classList', ['add']), + }; + preview = {}; + + balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['renderTemplate']); + + spyOn(document, 'createElement').and.returnValue(previewElement); + balsamiqViewer.renderTemplate.and.returnValue(innerHTML); + + renderPreview = BalsamiqViewer.prototype.renderPreview.call(balsamiqViewer, preview); + }); + + it('should call document.createElement', () => { + expect(document.createElement).toHaveBeenCalledWith('li'); + }); + + it('should call classList.add', () => { + expect(previewElement.classList.add).toHaveBeenCalledWith('preview'); + }); + + it('should call .renderTemplate', () => { + expect(balsamiqViewer.renderTemplate).toHaveBeenCalledWith(preview); + }); + + it('should set .innerHTML', () => { + expect(previewElement.innerHTML).toBe(innerHTML); + }); + + it('should return .outerHTML', () => { + expect(renderPreview).toBe(previewElement.outerHTML); + }); + }); + + describe('renderTemplate', () => { + let preview; + let database; + let title; + let renderTemplate; + + beforeEach(() => { + preview = { reosourceID: 1, image: 'image' }; + title = [{ values: [['{}', '{}', '{ "name": "name" }']] }]; + database = jasmine.createSpyObj('database', ['exec']); + + database.exec.and.returnValue(title); + + balsamiqViewer = { + database, + }; + + spyOn(JSON, 'parse').and.callThrough(); + spyOn(String.prototype, 'replace').and.callThrough(); + + renderTemplate = BalsamiqViewer.prototype.renderTemplate.call(balsamiqViewer, preview); + }); + + it('should call database.exec', () => { + expect(database.exec).toHaveBeenCalledWith(`SELECT * FROM resources WHERE id = '${preview.resourceID}'`); + }); + + it('should call JSON.parse', () => { + expect(JSON.parse).toHaveBeenCalledWith(title[0].values[0][2]); + }); + + it('should call String.prototype.replace', () => { + const allArgs = String.prototype.replace.calls.allArgs(); + + expect(allArgs.length).toBe(2); + expect(allArgs[0]).toEqual([/{{name}}/g, 'name']); + expect(allArgs[1]).toEqual([/{{image}}/g, 'image']); + }); + + it('should return the template string', function () { + const template = ` + <div class="panel panel-default"> + <div class="panel-heading">name</div> + <div class="panel-body"> + <img class="img-thumbnail" src="data:image/png;base64,image"/> + </div> + </div> + `; + + expect(renderTemplate.replace(/\s/g, '')).toEqual(template.replace(/\s/g, '')); + }); + }); + + describe('parsePreview', () => { + let preview; + let parsePreview; + + beforeEach(() => { + preview = ['{}', '{ "id": 1 }']; + + spyOn(JSON, 'parse').and.callThrough(); + + parsePreview = BalsamiqViewer.parsePreview(preview); + }); + + ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview'); + + it('should call JSON.parse', () => { + expect(JSON.parse).toHaveBeenCalledWith(preview[1]); + }); + + it('should return the parsed JSON', () => { + expect(parsePreview).toEqual(JSON.parse('{ "id": 1 }')); + }); + }); + + describe('onError', () => { + let onError; + + beforeEach(() => { + spyOn(window, 'Flash'); + + onError = BalsamiqViewer.onError(); + }); + + ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'onError'); + + it('should instantiate Flash', () => { + expect(window.Flash).toHaveBeenCalledWith('Balsamiq file could not be loaded.'); + }); + + it('should return Flash', () => { + expect(onError).toEqual(jasmine.any(window.Flash)); + }); + }); +}); diff --git a/spec/javascripts/spinner_spec.js b/spec/javascripts/spinner_spec.js new file mode 100644 index 0000000000000..ec62503659c20 --- /dev/null +++ b/spec/javascripts/spinner_spec.js @@ -0,0 +1,97 @@ +import Spinner from '~/spinner'; +import ClassSpecHelper from './helpers/class_spec_helper'; + +describe('Spinner', () => { + let renderable; + let container; + let spinner; + + describe('class constructor', () => { + beforeEach(() => { + renderable = {}; + container = {}; + + spyOn(Spinner, 'createContainer').and.returnValue(container); + + spinner = new Spinner(renderable); + }); + + it('should set .renderable', () => { + expect(spinner.renderable).toBe(renderable); + }); + + it('should call Spinner.createContainer', () => { + expect(Spinner.createContainer).toHaveBeenCalled(); + }); + + it('should set .container', () => { + expect(spinner.container).toBe(container); + }); + }); + + describe('start', () => { + beforeEach(() => { + renderable = jasmine.createSpyObj('renderable', ['prepend']); + container = {}; + + spinner = { + renderable, + container, + }; + + Spinner.prototype.start.call(spinner); + }); + + it('should call .prepend', () => { + expect(renderable.prepend).toHaveBeenCalledWith(container); + }); + }); + + describe('stop', () => { + beforeEach(() => { + container = jasmine.createSpyObj('container', ['remove']); + + spinner = { + container, + }; + + Spinner.prototype.stop.call(spinner); + }); + + it('should call .remove', () => { + expect(container.remove).toHaveBeenCalled(); + }); + }); + + describe('createContainer', () => { + let createContainer; + + beforeEach(() => { + container = { + classList: jasmine.createSpyObj('classList', ['add']), + }; + + spyOn(document, 'createElement').and.returnValue(container); + + createContainer = Spinner.createContainer(); + }); + + ClassSpecHelper.itShouldBeAStaticMethod(Spinner, 'createContainer'); + + it('should call document.createElement', () => { + expect(document.createElement).toHaveBeenCalledWith('div'); + }); + + it('should call classList.add', () => { + expect(container.classList.add).toHaveBeenCalledWith('loading'); + }); + + it('should return the container element', () => { + expect(createContainer).toBe(container); + }); + + it('should set the container .innerHTML to Spinner.TEMPLATE', () => { + expect(container.innerHTML).toBe(Spinner.TEMPLATE); + }); + }); +}); -- GitLab From b72e18b04fb6a054368a065c4c612505e842b526 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Tue, 11 Apr 2017 10:09:47 +0100 Subject: [PATCH 021/363] Added feature test --- .../projects/blobs/balsamiq_preview_spec.rb | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 spec/features/projects/blobs/balsamiq_preview_spec.rb diff --git a/spec/features/projects/blobs/balsamiq_preview_spec.rb b/spec/features/projects/blobs/balsamiq_preview_spec.rb new file mode 100644 index 0000000000000..856ec3271a0f0 --- /dev/null +++ b/spec/features/projects/blobs/balsamiq_preview_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +feature 'Balsamiq preview', :feature, :js do + include TreeHelper + + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:branch) { 'add-balsamiq-file' } + let(:path) { 'files/images/balsamiq.bmpr' } + + before do + project.add_master(user) + + login_as user + + p namespace_project_blob_path(project.namespace, project, tree_join(branch, path)) + + visit namespace_project_blob_path(project.namespace, project, tree_join(branch, path)) + end + + it 'should' do + screenshot_and_open_image + end +end -- GitLab From 897d8d547c5888bc63d3c3ecc0d0dd971d70e6c0 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Tue, 11 Apr 2017 11:28:17 +0100 Subject: [PATCH 022/363] Further review changes --- .../blob/balsamiq/balsamiq_viewer.js | 28 +++-- .../blob/balsamiq/balsamiq_viewer_spec.js | 110 +++++++++++++----- 2 files changed, 98 insertions(+), 40 deletions(-) diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js index d0c161f2aad55..3885b0f43b2bb 100644 --- a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js +++ b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js @@ -1,6 +1,7 @@ /* global Flash */ import sqljs from 'sql.js'; +import _ from 'underscore'; import Spinner from '../../spinner'; class BalsamiqViewer { @@ -52,6 +53,10 @@ class BalsamiqViewer { return thumbnails[0].values.map(BalsamiqViewer.parsePreview); } + getTitle(resourceID) { + return this.database.exec(`SELECT * FROM resources WHERE id = '${resourceID}'`); + } + renderPreview(preview) { const previewElement = document.createElement('li'); @@ -62,13 +67,14 @@ class BalsamiqViewer { } renderTemplate(preview) { - let template = BalsamiqViewer.PREVIEW_TEMPLATE; - - const title = this.database.exec(`SELECT * FROM resources WHERE id = '${preview.resourceID}'`); - const name = JSON.parse(title[0].values[0][2]).name; + const title = this.getTitle(preview.resourceID); + const name = BalsamiqViewer.parseTitle(title); const image = preview.image; - template = template.replace(/{{name}}/g, name).replace(/{{image}}/g, image); + const template = BalsamiqViewer.PREVIEW_TEMPLATE({ + name, + image, + }); return template; } @@ -77,6 +83,10 @@ class BalsamiqViewer { return JSON.parse(preview[1]); } + static parseTitle(title) { + return JSON.parse(title[0].values[0][2]).name; + } + static onError() { const flash = new Flash('Balsamiq file could not be loaded.'); @@ -84,13 +94,13 @@ class BalsamiqViewer { } } -BalsamiqViewer.PREVIEW_TEMPLATE = ` +BalsamiqViewer.PREVIEW_TEMPLATE = _.template(` <div class="panel panel-default"> - <div class="panel-heading">{{name}}</div> + <div class="panel-heading"><%- name %></div> <div class="panel-body"> - <img class="img-thumbnail" src="data:image/png;base64,{{image}}"/> + <img class="img-thumbnail" src="data:image/png;base64,<%- image %>"/> </div> </div> -`; +`); export default BalsamiqViewer; diff --git a/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js b/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js index 10db4175ca4e3..557eb721a2b8c 100644 --- a/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js +++ b/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js @@ -216,6 +216,35 @@ describe('BalsamiqViewer', () => { }); }); + describe('getTitle', () => { + let database; + let resourceID; + let resource; + let getTitle; + + beforeEach(() => { + database = jasmine.createSpyObj('database', ['exec']); + resourceID = 4; + resource = 'resource'; + + balsamiqViewer = { + database, + }; + + database.exec.and.returnValue(resource); + + getTitle = BalsamiqViewer.prototype.getTitle.call(balsamiqViewer, resourceID); + }); + + it('should call database.exec', () => { + expect(database.exec).toHaveBeenCalledWith(`SELECT * FROM resources WHERE id = '${resourceID}'`); + }); + + it('should return the selected resource', () => { + expect(getTitle).toBe(resource); + }); + }); + describe('renderPreview', () => { let previewElement; let innerHTML; @@ -261,54 +290,50 @@ describe('BalsamiqViewer', () => { describe('renderTemplate', () => { let preview; - let database; + let name; let title; + let template; let renderTemplate; beforeEach(() => { - preview = { reosourceID: 1, image: 'image' }; - title = [{ values: [['{}', '{}', '{ "name": "name" }']] }]; - database = jasmine.createSpyObj('database', ['exec']); - - database.exec.and.returnValue(title); + preview = { resourceID: 1, image: 'image' }; + name = 'name'; + title = 'title'; + template = ` + <div class="panel panel-default"> + <div class="panel-heading">name</div> + <div class="panel-body"> + <img class="img-thumbnail" src="data:image/png;base64,image"/> + </div> + </div> + `; - balsamiqViewer = { - database, - }; + balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['getTitle']); - spyOn(JSON, 'parse').and.callThrough(); - spyOn(String.prototype, 'replace').and.callThrough(); + spyOn(BalsamiqViewer, 'parseTitle').and.returnValue(name); + spyOn(BalsamiqViewer, 'PREVIEW_TEMPLATE').and.returnValue(template); + balsamiqViewer.getTitle.and.returnValue(title); renderTemplate = BalsamiqViewer.prototype.renderTemplate.call(balsamiqViewer, preview); }); - it('should call database.exec', () => { - expect(database.exec).toHaveBeenCalledWith(`SELECT * FROM resources WHERE id = '${preview.resourceID}'`); + it('should call .getTitle', () => { + expect(balsamiqViewer.getTitle).toHaveBeenCalledWith(preview.resourceID); }); - it('should call JSON.parse', () => { - expect(JSON.parse).toHaveBeenCalledWith(title[0].values[0][2]); + it('should call .parseTitle', () => { + expect(BalsamiqViewer.parseTitle).toHaveBeenCalledWith(title); }); - it('should call String.prototype.replace', () => { - const allArgs = String.prototype.replace.calls.allArgs(); - - expect(allArgs.length).toBe(2); - expect(allArgs[0]).toEqual([/{{name}}/g, 'name']); - expect(allArgs[1]).toEqual([/{{image}}/g, 'image']); + it('should call .PREVIEW_TEMPLATE', () => { + expect(BalsamiqViewer.PREVIEW_TEMPLATE).toHaveBeenCalledWith({ + name, + image: preview.image, + }); }); it('should return the template string', function () { - const template = ` - <div class="panel panel-default"> - <div class="panel-heading">name</div> - <div class="panel-body"> - <img class="img-thumbnail" src="data:image/png;base64,image"/> - </div> - </div> - `; - - expect(renderTemplate.replace(/\s/g, '')).toEqual(template.replace(/\s/g, '')); + expect(renderTemplate.trim()).toBe(template.trim()); }); }); @@ -335,6 +360,29 @@ describe('BalsamiqViewer', () => { }); }); + describe('parseTitle', () => { + let title; + let parseTitle; + + beforeEach(() => { + title = [{ values: [['{}', '{}', '{"name":"name"}']] }]; + + spyOn(JSON, 'parse').and.callThrough(); + + parseTitle = BalsamiqViewer.parseTitle(title); + }); + + ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview'); + + it('should call JSON.parse', () => { + expect(JSON.parse).toHaveBeenCalledWith(title[0].values[0][2]); + }); + + it('should return the name value', () => { + expect(parseTitle).toBe('name'); + }); + }); + describe('onError', () => { let onError; -- GitLab From fbed2909091b98f614ae51c5d6503cdd40a74eb5 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Tue, 11 Apr 2017 11:42:47 +0100 Subject: [PATCH 023/363] Removed prepend in favour of clean and appendChild --- app/assets/javascripts/spinner.js | 3 ++- spec/javascripts/spinner_spec.js | 10 +++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/spinner.js b/app/assets/javascripts/spinner.js index b7bfe1a2572de..6b5ac89a57641 100644 --- a/app/assets/javascripts/spinner.js +++ b/app/assets/javascripts/spinner.js @@ -6,7 +6,8 @@ class Spinner { } start() { - this.renderable.prepend(this.container); + this.renderable.innerHTML = ''; + this.renderable.appendChild(this.container); } stop() { diff --git a/spec/javascripts/spinner_spec.js b/spec/javascripts/spinner_spec.js index ec62503659c20..f550285e0f76d 100644 --- a/spec/javascripts/spinner_spec.js +++ b/spec/javascripts/spinner_spec.js @@ -31,7 +31,7 @@ describe('Spinner', () => { describe('start', () => { beforeEach(() => { - renderable = jasmine.createSpyObj('renderable', ['prepend']); + renderable = jasmine.createSpyObj('renderable', ['appendChild']); container = {}; spinner = { @@ -42,8 +42,12 @@ describe('Spinner', () => { Spinner.prototype.start.call(spinner); }); - it('should call .prepend', () => { - expect(renderable.prepend).toHaveBeenCalledWith(container); + it('should set .innerHTML to an empty string', () => { + expect(renderable.innerHTML).toEqual(''); + }); + + it('should call .appendChild', () => { + expect(renderable.appendChild).toHaveBeenCalledWith(container); }); }); -- GitLab From 18751fb1356935f38bf1ff0f00350b276d77169d Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Tue, 11 Apr 2017 13:06:32 +0100 Subject: [PATCH 024/363] Only import template function from underscore --- app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js index 3885b0f43b2bb..5bcd7d5eccc07 100644 --- a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js +++ b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js @@ -1,7 +1,7 @@ /* global Flash */ import sqljs from 'sql.js'; -import _ from 'underscore'; +import { template as _template } from 'underscore'; import Spinner from '../../spinner'; class BalsamiqViewer { @@ -94,7 +94,7 @@ class BalsamiqViewer { } } -BalsamiqViewer.PREVIEW_TEMPLATE = _.template(` +BalsamiqViewer.PREVIEW_TEMPLATE = _template(` <div class="panel panel-default"> <div class="panel-heading"><%- name %></div> <div class="panel-body"> -- GitLab From cf678945044f657111997a92330420a0110d03fc Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Tue, 11 Apr 2017 13:06:45 +0100 Subject: [PATCH 025/363] Finished feature specs --- app/views/projects/blob/_bmpr.html.haml | 2 +- spec/features/projects/blobs/balsamiq_preview_spec.rb | 11 +++++++---- spec/support/test_env.rb | 1 + 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/app/views/projects/blob/_bmpr.html.haml b/app/views/projects/blob/_bmpr.html.haml index 36ec0cbcce8bb..573b24ae44f7f 100644 --- a/app/views/projects/blob/_bmpr.html.haml +++ b/app/views/projects/blob/_bmpr.html.haml @@ -1,4 +1,4 @@ - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('balsamiq_viewer') -.file-content#js-balsamiq-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } } +.file-content.balsamiq-viewer#js-balsamiq-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } } diff --git a/spec/features/projects/blobs/balsamiq_preview_spec.rb b/spec/features/projects/blobs/balsamiq_preview_spec.rb index 856ec3271a0f0..503f1f94915f6 100644 --- a/spec/features/projects/blobs/balsamiq_preview_spec.rb +++ b/spec/features/projects/blobs/balsamiq_preview_spec.rb @@ -7,18 +7,21 @@ let(:project) { create(:project) } let(:branch) { 'add-balsamiq-file' } let(:path) { 'files/images/balsamiq.bmpr' } + let(:file_content) { find('.file-content') } before do project.add_master(user) login_as user - p namespace_project_blob_path(project.namespace, project, tree_join(branch, path)) - visit namespace_project_blob_path(project.namespace, project, tree_join(branch, path)) end - it 'should' do - screenshot_and_open_image + it 'should show a loading icon' do + expect(file_content).to have_selector('.loading') + end + + it 'should show a viewer container' do + expect(page).to have_selector('.balsamiq-viewer') end end diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index 1b5cb71a6b052..5765a31ca18e0 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -27,6 +27,7 @@ module TestEnv 'expand-collapse-files' => '025db92', 'expand-collapse-lines' => '238e82d', 'video' => '8879059', + 'add-balsamiq-file' => 'b89b56d', 'crlf-diff' => '5938907', 'conflict-start' => '824be60', 'conflict-resolvable' => '1450cd6', -- GitLab From ccba2f445dc0efd670a492cef954169a117eb166 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Wed, 12 Apr 2017 07:22:52 +0100 Subject: [PATCH 026/363] Spec review changes --- spec/features/projects/blobs/balsamiq_preview_spec.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/spec/features/projects/blobs/balsamiq_preview_spec.rb b/spec/features/projects/blobs/balsamiq_preview_spec.rb index 503f1f94915f6..551d2045744c3 100644 --- a/spec/features/projects/blobs/balsamiq_preview_spec.rb +++ b/spec/features/projects/blobs/balsamiq_preview_spec.rb @@ -11,9 +11,7 @@ before do project.add_master(user) - login_as user - visit namespace_project_blob_path(project.namespace, project, tree_join(branch, path)) end -- GitLab From 04eaed8088a398aee0954935752a99ac7721bb4f Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Wed, 12 Apr 2017 08:11:12 +0100 Subject: [PATCH 027/363] Used underscore to template list children to utilize their simple escaped interpolation --- app/assets/javascripts/droplab/constants.js | 2 ++ app/assets/javascripts/droplab/drop_down.js | 2 +- app/assets/javascripts/droplab/utils.js | 16 ++++++++-------- .../javascripts/filtered_search/dropdown_hint.js | 2 +- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/app/assets/javascripts/droplab/constants.js b/app/assets/javascripts/droplab/constants.js index a23d914772a89..34b1aee73c7a9 100644 --- a/app/assets/javascripts/droplab/constants.js +++ b/app/assets/javascripts/droplab/constants.js @@ -2,10 +2,12 @@ const DATA_TRIGGER = 'data-dropdown-trigger'; const DATA_DROPDOWN = 'data-dropdown'; const SELECTED_CLASS = 'droplab-item-selected'; const ACTIVE_CLASS = 'droplab-item-active'; +const TEMPLATE_REGEX = /\{\{(.+?)\}\}/g; export { DATA_TRIGGER, DATA_DROPDOWN, SELECTED_CLASS, ACTIVE_CLASS, + TEMPLATE_REGEX, }; diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js index 9588921ebcd8d..084d57e2e1f3d 100644 --- a/app/assets/javascripts/droplab/drop_down.js +++ b/app/assets/javascripts/droplab/drop_down.js @@ -93,7 +93,7 @@ Object.assign(DropDown.prototype, { }, renderChildren: function(data) { - var html = utils.t(this.templateString, data); + var html = utils.template(this.templateString, data); var template = document.createElement('div'); template.innerHTML = html; diff --git a/app/assets/javascripts/droplab/utils.js b/app/assets/javascripts/droplab/utils.js index c149a33a1e9ed..4da7344604eda 100644 --- a/app/assets/javascripts/droplab/utils.js +++ b/app/assets/javascripts/droplab/utils.js @@ -1,19 +1,19 @@ /* eslint-disable */ -import { DATA_TRIGGER, DATA_DROPDOWN } from './constants'; +import { template as _template } from 'underscore'; +import { DATA_TRIGGER, DATA_DROPDOWN, TEMPLATE_REGEX } from './constants'; const utils = { toCamelCase(attr) { return this.camelize(attr.split('-').slice(1).join(' ')); }, - t(s, d) { - for (const p in d) { - if (Object.prototype.hasOwnProperty.call(d, p)) { - s = s.replace(new RegExp(`{{${p}}}`, 'g'), d[p]); - } - } - return s; + template(templateString, data) { + const template = _template(templateString, { + escape: TEMPLATE_REGEX, + }); + + return template(data); }, camelize(str) { diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js index 381c40c03d802..5b7b059666a3b 100644 --- a/app/assets/javascripts/filtered_search/dropdown_hint.js +++ b/app/assets/javascripts/filtered_search/dropdown_hint.js @@ -63,7 +63,7 @@ require('./filtered_search_dropdown'); Object.assign({ icon: `fa-${icon}`, hint, - tag: `<${tag}>`, + tag: `<${tag}>`, }, type && { type }), ); } -- GitLab From 606275da6f1245e7e2dd790a2679653aea6b9a11 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Wed, 12 Apr 2017 09:18:22 +0100 Subject: [PATCH 028/363] Update specs --- spec/javascripts/droplab/constants_spec.js | 6 ++++++ spec/javascripts/droplab/drop_down_spec.js | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/spec/javascripts/droplab/constants_spec.js b/spec/javascripts/droplab/constants_spec.js index 35239e4fb8eff..c526e98748ebd 100644 --- a/spec/javascripts/droplab/constants_spec.js +++ b/spec/javascripts/droplab/constants_spec.js @@ -26,4 +26,10 @@ describe('constants', function () { expect(constants.ACTIVE_CLASS).toBe('droplab-item-active'); }); }); + + describe('TEMPLATE_REGEX', function () { + it('should be a handlebars templating syntax regex', function() { + expect(constants.TEMPLATE_REGEX).toEqual(/\{\{(.+?)\}\}/g); + }); + }); }); diff --git a/spec/javascripts/droplab/drop_down_spec.js b/spec/javascripts/droplab/drop_down_spec.js index 802e2435672ea..1b658aa670125 100644 --- a/spec/javascripts/droplab/drop_down_spec.js +++ b/spec/javascripts/droplab/drop_down_spec.js @@ -429,7 +429,7 @@ describe('DropDown', function () { this.html = 'html'; this.template = { firstChild: { outerHTML: 'outerHTML', style: {} } }; - spyOn(utils, 't').and.returnValue(this.html); + spyOn(utils, 'template').and.returnValue(this.html); spyOn(document, 'createElement').and.returnValue(this.template); spyOn(this.dropdown, 'setImagesSrc'); @@ -437,7 +437,7 @@ describe('DropDown', function () { }); it('should call utils.t with .templateString and data', function () { - expect(utils.t).toHaveBeenCalledWith(this.templateString, this.data); + expect(utils.template).toHaveBeenCalledWith(this.templateString, this.data); }); it('should call document.createElement', function () { -- GitLab From 0453d6d7ae72f0179d02ff5190122a86e304e1bb Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Wed, 12 Apr 2017 09:50:54 +0100 Subject: [PATCH 029/363] BE review changes --- app/models/blob.rb | 2 +- spec/features/projects/blobs/balsamiq_preview_spec.rb | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/models/blob.rb b/app/models/blob.rb index 91cf171a6880b..82333b6f3696c 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -63,7 +63,7 @@ def sketch? end def balsamiq? - binary? && extname.downcase.delete('.') == 'bmpr' + binary? && extension == 'bmpr' end def stl? diff --git a/spec/features/projects/blobs/balsamiq_preview_spec.rb b/spec/features/projects/blobs/balsamiq_preview_spec.rb index 551d2045744c3..59475525b813f 100644 --- a/spec/features/projects/blobs/balsamiq_preview_spec.rb +++ b/spec/features/projects/blobs/balsamiq_preview_spec.rb @@ -7,7 +7,6 @@ let(:project) { create(:project) } let(:branch) { 'add-balsamiq-file' } let(:path) { 'files/images/balsamiq.bmpr' } - let(:file_content) { find('.file-content') } before do project.add_master(user) @@ -16,7 +15,7 @@ end it 'should show a loading icon' do - expect(file_content).to have_selector('.loading') + expect(find('.file-content')).to have_selector('.loading') end it 'should show a viewer container' do -- GitLab From b09465f38d66d7ff6074843177bcdb7d72caf07f Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Wed, 12 Apr 2017 11:26:18 +0200 Subject: [PATCH 030/363] Implement new rule for manual actions in policies --- app/policies/ci/build_policy.rb | 14 +++++++ spec/policies/ci/build_policy_spec.rb | 53 +++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index 8b25332b73cee..0522cbdb33142 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -8,6 +8,20 @@ def rules %w[read create update admin].each do |rule| cannot! :"#{rule}_commit_status" unless can? :"#{rule}_build" end + + can! :play_build if can_play_action? + end + + private + + alias_method :build, :subject + + def can_play_action? + return false unless build.playable? + + ::Gitlab::UserAccess + .new(user, project: build.project) + .can_push_to_branch?(build.ref) end end end diff --git a/spec/policies/ci/build_policy_spec.rb b/spec/policies/ci/build_policy_spec.rb index 0f280f32eac91..e4693cdcef0e0 100644 --- a/spec/policies/ci/build_policy_spec.rb +++ b/spec/policies/ci/build_policy_spec.rb @@ -89,5 +89,58 @@ end end end + + describe 'rules for manual actions' do + let(:project) { create(:project) } + + before do + project.add_developer(user) + end + + context 'when branch build is assigned to is protected' do + before do + create(:protected_branch, :no_one_can_push, + name: 'some-ref', project: project) + end + + context 'when build is a manual action' do + let(:build) do + create(:ci_build, :manual, ref: 'some-ref', pipeline: pipeline) + end + + it 'does not include ability to play build' do + expect(policies).not_to include :play_build + end + end + + context 'when build is not a manual action' do + let(:build) do + create(:ci_build, ref: 'some-ref', pipeline: pipeline) + end + + it 'does not include ability to play build' do + expect(policies).not_to include :play_build + end + end + end + + context 'when branch build is assigned to is not protected' do + context 'when build is a manual action' do + let(:build) { create(:ci_build, :manual, pipeline: pipeline) } + + it 'includes ability to play build' do + expect(policies).to include :play_build + end + end + + context 'when build is not a manual action' do + let(:build) { create(:ci_build, pipeline: pipeline) } + + it 'does not include ability to play build' do + expect(policies).not_to include :play_build + end + end + end + end end end -- GitLab From 6c6bc400d1d8a96f6e443788cd0b2c14addd88e3 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Wed, 12 Apr 2017 11:46:24 +0200 Subject: [PATCH 031/363] Move code for playing an action to separate service --- app/models/ci/build.rb | 15 +++------------ app/policies/ci/build_policy.rb | 2 +- app/services/ci/play_build_service.rb | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 13 deletions(-) create mode 100644 app/services/ci/play_build_service.rb diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 159b3b2e10153..9edc4cd96b9a8 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -122,18 +122,9 @@ def can_play?(current_user) end def play(current_user) - unless can_play?(current_user) - raise Gitlab::Access::AccessDeniedError - end - - # Try to queue a current build - if self.enqueue - self.update(user: current_user) - self - else - # Otherwise we need to create a duplicate - Ci::Build.retry(self, current_user) - end + Ci::PlayBuildService + .new(project, current_user) + .execute(self) end def cancelable? diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index 0522cbdb33142..2c39d31488f05 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -17,7 +17,7 @@ def rules alias_method :build, :subject def can_play_action? - return false unless build.playable? + return false unless build.action? ::Gitlab::UserAccess .new(user, project: build.project) diff --git a/app/services/ci/play_build_service.rb b/app/services/ci/play_build_service.rb new file mode 100644 index 0000000000000..c9ed45408f223 --- /dev/null +++ b/app/services/ci/play_build_service.rb @@ -0,0 +1,17 @@ +module Ci + class PlayBuildService < ::BaseService + def execute(build) + unless can?(current_user, :play_build, build) + raise Gitlab::Access::AccessDeniedError + end + + # Try to enqueue thebuild, otherwise create a duplicate. + # + if build.enqueue + build.tap { |action| action.update(user: current_user) } + else + Ci::Build.retry(build, current_user) + end + end + end +end -- GitLab From 7fc6b5b6ff23e2faba7f06a1362ada31f6f3436a Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Wed, 12 Apr 2017 12:19:39 +0200 Subject: [PATCH 032/363] Do not inherit build policy in pipeline policy --- app/policies/base_policy.rb | 4 ++++ app/policies/ci/pipeline_policy.rb | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index 8890409d0565e..623424c63e087 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -97,6 +97,10 @@ def anonymous_rules rules end + def rules + raise NotImplementedError + end + def delegate!(new_subject) @rule_set.merge(Ability.allowed(@user, new_subject)) end diff --git a/app/policies/ci/pipeline_policy.rb b/app/policies/ci/pipeline_policy.rb index 3d2eef1c50cf9..10aa2d3e72a5d 100644 --- a/app/policies/ci/pipeline_policy.rb +++ b/app/policies/ci/pipeline_policy.rb @@ -1,4 +1,7 @@ module Ci - class PipelinePolicy < BuildPolicy + class PipelinePolicy < BasePolicy + def rules + delegate! @subject.project + end end end -- GitLab From 55aa727eff50a9472405b302645abb54f28bdba0 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Wed, 12 Apr 2017 13:36:35 +0200 Subject: [PATCH 033/363] Extract build play specs and extend test examples --- spec/models/ci/build_spec.rb | 36 +------ spec/services/ci/play_build_service_spec.rb | 105 ++++++++++++++++++++ 2 files changed, 107 insertions(+), 34 deletions(-) create mode 100644 spec/services/ci/play_build_service_spec.rb diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 8124d263fd417..09e9f5bd8ba77 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -959,40 +959,8 @@ def create_mr(build, pipeline, factory: :merge_request, created_at: Time.now) project.add_developer(user) end - context 'when user does not have ability to trigger action' do - before do - create(:protected_branch, :no_one_can_push, - name: build.ref, project: project) - end - - it 'raises an error' do - expect { build.play(user) } - .to raise_error Gitlab::Access::AccessDeniedError - end - end - - context 'when user has ability to trigger manual action' do - context 'when build is manual' do - it 'enqueues a build' do - new_build = build.play(user) - - expect(new_build).to be_pending - expect(new_build).to eq(build) - end - end - - context 'when build is not manual' do - before do - build.update(status: 'success') - end - - it 'creates a new build' do - new_build = build.play(user) - - expect(new_build).to be_pending - expect(new_build).not_to eq(build) - end - end + it 'enqueues the build' do + expect(build.play(user)).to be_pending end end diff --git a/spec/services/ci/play_build_service_spec.rb b/spec/services/ci/play_build_service_spec.rb new file mode 100644 index 0000000000000..d6f9fa42045b3 --- /dev/null +++ b/spec/services/ci/play_build_service_spec.rb @@ -0,0 +1,105 @@ +require 'spec_helper' + +describe Ci::PlayBuildService, '#execute', :services do + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + let(:pipeline) { create(:ci_pipeline, project: project) } + let(:build) { create(:ci_build, :manual, pipeline: pipeline) } + + let(:service) do + described_class.new(project, user) + end + + context 'when project does not have repository yet' do + let(:project) { create(:empty_project) } + + it 'allows user with master role to play build' do + project.add_master(user) + + service.execute(build) + + expect(build.reload).to be_pending + end + + it 'does not allow user with developer role to play build' do + project.add_developer(user) + + expect { service.execute(build) } + .to raise_error Gitlab::Access::AccessDeniedError + end + end + + context 'when project has repository' do + let(:project) { create(:project) } + + it 'allows user with developer role to play a build' do + project.add_developer(user) + + service.execute(build) + + expect(build.reload).to be_pending + end + end + + context 'when build is a playable manual action' do + let(:build) { create(:ci_build, :manual, pipeline: pipeline) } + + before do + project.add_master(user) + end + + it 'enqueues the build' do + expect(service.execute(build)).to eq build + expect(build.reload).to be_pending + end + + it 'reassignes build user correctly' do + service.execute(build) + + expect(build.reload.user).to eq user + end + end + + context 'when build is not a playable manual action' do + let(:build) { create(:ci_build, when: :manual, pipeline: pipeline) } + + before do + project.add_master(user) + end + + it 'duplicates the build' do + duplicate = service.execute(build) + + expect(duplicate).not_to eq build + expect(duplicate).to be_pending + end + + it 'assigns users correctly' do + duplicate = service.execute(build) + + expect(build.user).not_to eq user + expect(duplicate.user).to eq user + end + end + + context 'when build is not action' do + let(:build) { create(:ci_build, :success, pipeline: pipeline) } + + it 'raises an error' do + expect { service.execute(build) } + .to raise_error Gitlab::Access::AccessDeniedError + end + end + + context 'when user does not have ability to trigger action' do + before do + create(:protected_branch, :no_one_can_push, + name: build.ref, project: project) + end + + it 'raises an error' do + expect { service.execute(build) } + .to raise_error Gitlab::Access::AccessDeniedError + end + end +end -- GitLab From 2aa211fa69ffd02ba11757e06e19d34f6ca51865 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Wed, 12 Apr 2017 13:48:43 +0200 Subject: [PATCH 034/363] Use build policy to determine if user can play build --- app/models/ci/build.rb | 6 ----- app/models/environment.rb | 6 ----- app/serializers/build_action_entity.rb | 2 +- app/serializers/build_entity.rb | 2 +- app/services/ci/stop_environments_service.rb | 3 ++- app/views/projects/ci/builds/_build.html.haml | 2 +- lib/gitlab/ci/status/build/play.rb | 2 +- spec/models/ci/build_spec.rb | 27 ------------------- spec/models/environment_spec.rb | 25 ----------------- 9 files changed, 6 insertions(+), 69 deletions(-) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 9edc4cd96b9a8..b3acb25b9ce01 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -115,12 +115,6 @@ def has_commands? commands.present? end - def can_play?(current_user) - ::Gitlab::UserAccess - .new(current_user, project: project) - .can_push_to_branch?(ref) - end - def play(current_user) Ci::PlayBuildService .new(project, current_user) diff --git a/app/models/environment.rb b/app/models/environment.rb index f8b9a21c08ea3..bf33010fd21f2 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -122,12 +122,6 @@ def stop_action? available? && stop_action.present? end - def can_trigger_stop_action?(current_user) - return false unless stop_action? - - stop_action.can_play?(current_user) - end - def stop_with_action!(current_user) return unless available? diff --git a/app/serializers/build_action_entity.rb b/app/serializers/build_action_entity.rb index 3d12c64b88aa4..0bb7e5610731f 100644 --- a/app/serializers/build_action_entity.rb +++ b/app/serializers/build_action_entity.rb @@ -19,6 +19,6 @@ class BuildActionEntity < Grape::Entity alias_method :build, :object def playable? - build.playable? && build.can_play?(request.user) + can?(request.user, :play_build, build) && build.playable? end end diff --git a/app/serializers/build_entity.rb b/app/serializers/build_entity.rb index 401a277dadc53..f301900c43c43 100644 --- a/app/serializers/build_entity.rb +++ b/app/serializers/build_entity.rb @@ -26,7 +26,7 @@ class BuildEntity < Grape::Entity alias_method :build, :object def playable? - build.playable? && build.can_play?(request.user) + can?(request.user, :play_build, build) && build.playable? end def detailed_status diff --git a/app/services/ci/stop_environments_service.rb b/app/services/ci/stop_environments_service.rb index d1e341bc9b509..bd9735fc0accf 100644 --- a/app/services/ci/stop_environments_service.rb +++ b/app/services/ci/stop_environments_service.rb @@ -9,7 +9,8 @@ def execute(branch_name) return unless can?(current_user, :create_deployment, project) environments.each do |environment| - next unless environment.can_trigger_stop_action?(current_user) + next unless environment.stop_action? + next unless can?(current_user, :play_build, environment.stop_action) environment.stop_with_action!(current_user) end diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index ceec36e24407c..769640c484279 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -101,7 +101,7 @@ = link_to cancel_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do = icon('remove', class: 'cred') - elsif allow_retry - - if build.playable? && !admin && build.can_play?(current_user) + - if build.playable? && !admin && can?(current_user, :play_build, build) = link_to play_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do = custom_icon('icon_play') - elsif build.retryable? diff --git a/lib/gitlab/ci/status/build/play.rb b/lib/gitlab/ci/status/build/play.rb index 29d0558a265c5..4c893f62925e3 100644 --- a/lib/gitlab/ci/status/build/play.rb +++ b/lib/gitlab/ci/status/build/play.rb @@ -10,7 +10,7 @@ def label end def has_action? - can?(user, :update_build, subject) && subject.can_play?(user) + can?(user, :play_build, subject) end def action_icon diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 09e9f5bd8ba77..cdceca975e5a9 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -925,33 +925,6 @@ def create_mr(build, pipeline, factory: :merge_request, created_at: Time.now) end end - describe '#can_play?' do - before do - project.add_developer(user) - end - - let(:build) do - create(:ci_build, ref: 'some-ref', pipeline: pipeline) - end - - context 'when branch build is running for is protected' do - before do - create(:protected_branch, :no_one_can_push, - name: 'some-ref', project: project) - end - - it 'indicates that user can not trigger an action' do - expect(build.can_play?(user)).to be_falsey - end - end - - context 'when branch build is running for is not protected' do - it 'indicates that user can trigger an action' do - expect(build.can_play?(user)).to be_truthy - end - end - end - describe '#play' do let(:build) { create(:ci_build, :manual, pipeline: pipeline) } diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index fb7cee47ae8e2..9e00f2247e8b1 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -155,31 +155,6 @@ end end - describe '#can_trigger_stop_action?' do - let(:user) { create(:user) } - let(:project) { create(:project) } - - let(:environment) do - create(:environment, :with_review_app, project: project) - end - - context 'when user can trigger stop action' do - before do - project.add_developer(user) - end - - it 'returns value that evaluates to true' do - expect(environment.can_trigger_stop_action?(user)).to be_truthy - end - end - - context 'when user is not allowed to trigger stop action' do - it 'returns value that evaluates to false' do - expect(environment.can_trigger_stop_action?(user)).to be_falsey - end - end - end - describe '#stop_with_action!' do let(:user) { create(:admin) } -- GitLab From 0b75541559c0550bd3e195ca5aca55016fa614ef Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Wed, 12 Apr 2017 15:22:32 +0200 Subject: [PATCH 035/363] Fix manual action entity specs --- spec/serializers/build_action_entity_spec.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/serializers/build_action_entity_spec.rb b/spec/serializers/build_action_entity_spec.rb index 54ac17447b13d..059deba54163d 100644 --- a/spec/serializers/build_action_entity_spec.rb +++ b/spec/serializers/build_action_entity_spec.rb @@ -2,9 +2,10 @@ describe BuildActionEntity do let(:build) { create(:ci_build, name: 'test_build') } + let(:request) { double('request') } let(:entity) do - described_class.new(build, request: double) + described_class.new(build, request: spy('request')) end describe '#as_json' do -- GitLab From 1ebcc96e3a14c5bee675df918cdecbe1a7d04b63 Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Wed, 12 Apr 2017 22:41:46 -0500 Subject: [PATCH 036/363] Setup gettext libraries --- Gemfile | 4 ++++ Gemfile.lock | 12 +++++++++++- locale/de/gitlab.edit.po | 19 +++++++++++++++++++ locale/de/gitlab.po | 18 ++++++++++++++++++ locale/de/gitlab.po.time_stamp | 0 locale/en/gitlab.edit.po | 19 +++++++++++++++++++ locale/en/gitlab.po | 18 ++++++++++++++++++ locale/en/gitlab.po.time_stamp | 0 locale/es/gitlab.edit.po | 19 +++++++++++++++++++ locale/es/gitlab.po | 18 ++++++++++++++++++ locale/es/gitlab.po.time_stamp | 0 locale/gitlab.pot | 19 +++++++++++++++++++ 12 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 locale/de/gitlab.edit.po create mode 100644 locale/de/gitlab.po create mode 100644 locale/de/gitlab.po.time_stamp create mode 100644 locale/en/gitlab.edit.po create mode 100644 locale/en/gitlab.po create mode 100644 locale/en/gitlab.po.time_stamp create mode 100644 locale/es/gitlab.edit.po create mode 100644 locale/es/gitlab.po create mode 100644 locale/es/gitlab.po.time_stamp create mode 100644 locale/gitlab.pot diff --git a/Gemfile b/Gemfile index d4b2ade424382..e98c127d2f57b 100644 --- a/Gemfile +++ b/Gemfile @@ -251,6 +251,10 @@ gem 'sentry-raven', '~> 2.4.0' gem 'premailer-rails', '~> 1.9.0' +# I18n +gem 'gettext_i18n_rails', '~> 1.8.0' +gem 'gettext', '~> 3.2.2', require: false, group: :development + # Metrics group :metrics do gem 'allocations', '~> 1.0', require: false, platform: :mri diff --git a/Gemfile.lock b/Gemfile.lock index 965c888ca7927..eeec4b67764fb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -200,6 +200,7 @@ GEM faraday_middleware-multi_json (0.0.6) faraday_middleware multi_json + fast_gettext (1.4.0) ffaker (2.4.0) ffi (1.9.10) flay (2.8.1) @@ -253,6 +254,11 @@ GEM gemojione (3.0.1) json get_process_mem (0.2.0) + gettext (3.2.2) + locale (>= 2.0.5) + text (>= 1.3.0) + gettext_i18n_rails (1.8.0) + fast_gettext (>= 0.9.0) gherkin-ruby (0.3.2) gitaly (0.5.0) google-protobuf (~> 3.1) @@ -422,6 +428,7 @@ GEM licensee (8.7.0) rugged (~> 0.24) little-plugger (1.1.4) + locale (2.1.2) logging (2.1.0) little-plugger (~> 1.1) multi_json (~> 1.10) @@ -776,6 +783,7 @@ GEM temple (0.7.7) test_after_commit (1.1.0) activerecord (>= 3.2) + text (1.3.1) thin (1.7.0) daemons (~> 1.0, >= 1.0.9) eventmachine (~> 1.0, >= 1.0.4) @@ -902,6 +910,8 @@ DEPENDENCIES fuubar (~> 2.0.0) gemnasium-gitlab-service (~> 0.2) gemojione (~> 3.0) + gettext (~> 3.2.2) + gettext_i18n_rails (~> 1.8.0) gitaly (~> 0.5.0) github-linguist (~> 4.7.0) gitlab-flowdock-git-hook (~> 1.0.1) @@ -1035,4 +1045,4 @@ DEPENDENCIES wikicloth (= 0.8.1) BUNDLED WITH - 1.14.5 + 1.14.6 diff --git a/locale/de/gitlab.edit.po b/locale/de/gitlab.edit.po new file mode 100644 index 0000000000000..70ac9a35be79f --- /dev/null +++ b/locale/de/gitlab.edit.po @@ -0,0 +1,19 @@ +# German translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-04-12 22:36-0500\n" +"PO-Revision-Date: 2017-04-12 22:37-0500\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: German\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"\n" diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po new file mode 100644 index 0000000000000..5eb4a86d528e1 --- /dev/null +++ b/locale/de/gitlab.po @@ -0,0 +1,18 @@ +# German translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2017-04-12 22:37-0500\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: German\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"\n" diff --git a/locale/de/gitlab.po.time_stamp b/locale/de/gitlab.po.time_stamp new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/locale/en/gitlab.edit.po b/locale/en/gitlab.edit.po new file mode 100644 index 0000000000000..f9e8c3e67094c --- /dev/null +++ b/locale/en/gitlab.edit.po @@ -0,0 +1,19 @@ +# English translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-04-12 22:36-0500\n" +"PO-Revision-Date: 2017-04-12 22:36-0500\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: English\n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"\n" diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po new file mode 100644 index 0000000000000..5dcd2983f30c2 --- /dev/null +++ b/locale/en/gitlab.po @@ -0,0 +1,18 @@ +# English translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2017-04-12 22:36-0500\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: English\n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"\n" diff --git a/locale/en/gitlab.po.time_stamp b/locale/en/gitlab.po.time_stamp new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/locale/es/gitlab.edit.po b/locale/es/gitlab.edit.po new file mode 100644 index 0000000000000..38a2d10f6e845 --- /dev/null +++ b/locale/es/gitlab.edit.po @@ -0,0 +1,19 @@ +# Spanish translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-04-12 22:36-0500\n" +"PO-Revision-Date: 2017-04-12 22:37-0500\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: Spanish\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"\n" diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po new file mode 100644 index 0000000000000..893eb7e15a3e3 --- /dev/null +++ b/locale/es/gitlab.po @@ -0,0 +1,18 @@ +# Spanish translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2017-04-12 22:37-0500\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: Spanish\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"\n" diff --git a/locale/es/gitlab.po.time_stamp b/locale/es/gitlab.po.time_stamp new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/locale/gitlab.pot b/locale/gitlab.pot new file mode 100644 index 0000000000000..7c4f303a5d035 --- /dev/null +++ b/locale/gitlab.pot @@ -0,0 +1,19 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-04-12 22:36-0500\n" +"PO-Revision-Date: 2017-04-12 22:36-0500\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" -- GitLab From acc807cd30a4b0005b552d67583b20538474a1f2 Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Wed, 12 Apr 2017 23:04:26 -0500 Subject: [PATCH 037/363] Add preferred_language column to users --- ...170413035209_add_preferred_language_to_users.rb | 14 ++++++++++++++ db/schema.rb | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20170413035209_add_preferred_language_to_users.rb diff --git a/db/migrate/20170413035209_add_preferred_language_to_users.rb b/db/migrate/20170413035209_add_preferred_language_to_users.rb new file mode 100644 index 0000000000000..5dc128dbaea0a --- /dev/null +++ b/db/migrate/20170413035209_add_preferred_language_to_users.rb @@ -0,0 +1,14 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddPreferredLanguageToUsers < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def change + add_column_with_default :users, :preferred_language, :string, default: 'en' + end +end diff --git a/db/schema.rb b/db/schema.rb index 5689f7331dcfd..03ce12570885a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170408033905) do +ActiveRecord::Schema.define(version: 20170413035209) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -1304,6 +1304,7 @@ t.boolean "notified_of_own_activity" t.boolean "require_two_factor_authentication_from_group", default: false, null: false t.integer "two_factor_grace_period", default: 48, null: false + t.string "preferred_language" end add_index "users", ["admin"], name: "index_users_on_admin", using: :btree -- GitLab From bd86796dd0edae7e5db2bfbb887d3196498ebd49 Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Wed, 12 Apr 2017 23:23:02 -0500 Subject: [PATCH 038/363] Add support to change language in profile form --- app/controllers/profiles_controller.rb | 3 ++- app/views/profiles/show.html.haml | 3 +++ lib/gitlab/i18n.rb | 9 +++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 lib/gitlab/i18n.rb diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 987b95e89b9e6..57e23cea00eb5 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -85,7 +85,8 @@ def user_params :twitter, :username, :website_url, - :organization + :organization, + :preferred_language ) end end diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index c74b3249a1305..dc71a04cbf048 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -72,6 +72,9 @@ = f.label :public_email, class: "label-light" = f.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email), { include_blank: 'Do not show on profile' }, class: "select2" %span.help-block This email will be displayed on your public profile. + .form-group + = f.label :preferred_language, class: "label-light" + = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES, {}, class: "select2" .form-group = f.label :skype, class: "label-light" = f.text_field :skype, class: "form-control" diff --git a/lib/gitlab/i18n.rb b/lib/gitlab/i18n.rb new file mode 100644 index 0000000000000..8c578aa0b4650 --- /dev/null +++ b/lib/gitlab/i18n.rb @@ -0,0 +1,9 @@ +module Gitlab + module I18n + AVAILABLE_LANGUAGES = [ + [_('English'), 'en'], + [_('Spanish'), 'es'], + [_('Deutsch'), 'de'] + ] + end +end -- GitLab From 020e12a6ae99e9b9dfaa3cfbd640ecdf31d7424d Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Wed, 12 Apr 2017 23:24:29 -0500 Subject: [PATCH 039/363] Add missing initializer for FastGettext --- config/initializers/fast_gettext.rb | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 config/initializers/fast_gettext.rb diff --git a/config/initializers/fast_gettext.rb b/config/initializers/fast_gettext.rb new file mode 100644 index 0000000000000..cfc3427b5ae69 --- /dev/null +++ b/config/initializers/fast_gettext.rb @@ -0,0 +1,3 @@ +FastGettext.add_text_domain 'gitlab', path: 'locale', type: :po +FastGettext.default_available_locales = ['en', 'es','de'] +FastGettext.default_text_domain = 'gitlab' -- GitLab From 73d0730d09b5f9a9b68f158cc72ad30c7a2b35d0 Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Thu, 13 Apr 2017 01:03:47 -0500 Subject: [PATCH 040/363] Set locale through controller filter --- app/controllers/application_controller.rb | 6 ++++++ app/controllers/profiles_controller.rb | 1 + app/views/profiles/show.html.haml | 3 ++- lib/gitlab/i18n.rb | 6 +++--- locale/de/gitlab.edit.po | 11 ++++++++++- locale/de/gitlab.po | 9 +++++++++ locale/en/gitlab.edit.po | 11 ++++++++++- locale/en/gitlab.po | 9 +++++++++ locale/es/gitlab.edit.po | 17 +++++++++++++---- locale/es/gitlab.po | 15 ++++++++++++--- locale/gitlab.pot | 13 +++++++++++-- 11 files changed, 86 insertions(+), 15 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e77094fe2a826..5a3bd4040cc03 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -265,4 +265,10 @@ def gitlab_project_import_enabled? def u2f_app_id request.base_url end + + def set_locale + requested_locale = current_user&.preferred_language || request.env['HTTP_ACCEPT_LANGUAGE'] || I18n.default_locale + locale = FastGettext.set_locale(requested_locale) + I18n.locale = locale + end end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 57e23cea00eb5..0f01bf7e7067a 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -3,6 +3,7 @@ class ProfilesController < Profiles::ApplicationController before_action :user before_action :authorize_change_username!, only: :update_username + before_action :set_locale, only: :show skip_before_action :require_email, only: [:show, :update] def show diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index dc71a04cbf048..d8ef64ceb7288 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -74,7 +74,8 @@ %span.help-block This email will be displayed on your public profile. .form-group = f.label :preferred_language, class: "label-light" - = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES, {}, class: "select2" + = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |lang| [_(lang[0]), lang[1]] }, + {}, class: "select2" .form-group = f.label :skype, class: "label-light" = f.text_field :skype, class: "form-control" diff --git a/lib/gitlab/i18n.rb b/lib/gitlab/i18n.rb index 8c578aa0b4650..85e527afd7148 100644 --- a/lib/gitlab/i18n.rb +++ b/lib/gitlab/i18n.rb @@ -1,9 +1,9 @@ module Gitlab module I18n AVAILABLE_LANGUAGES = [ - [_('English'), 'en'], - [_('Spanish'), 'es'], - [_('Deutsch'), 'de'] + ['English', 'en'], + ['Spanish', 'es'], + ['Deutsch', 'de'] ] end end diff --git a/locale/de/gitlab.edit.po b/locale/de/gitlab.edit.po index 70ac9a35be79f..e4c8caef1647e 100644 --- a/locale/de/gitlab.edit.po +++ b/locale/de/gitlab.edit.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-04-12 22:36-0500\n" +"POT-Creation-Date: 2017-04-13 00:01-0500\n" "PO-Revision-Date: 2017-04-12 22:37-0500\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: German\n" @@ -17,3 +17,12 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" "\n" + +msgid "Deutsch" +msgstr "" + +msgid "English" +msgstr "" + +msgid "Spanish" +msgstr "" diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po index 5eb4a86d528e1..7f32771b80f74 100644 --- a/locale/de/gitlab.po +++ b/locale/de/gitlab.po @@ -16,3 +16,12 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" "\n" + +msgid "Deutsch" +msgstr "" + +msgid "English" +msgstr "" + +msgid "Spanish" +msgstr "" diff --git a/locale/en/gitlab.edit.po b/locale/en/gitlab.edit.po index f9e8c3e67094c..048f531de4b0d 100644 --- a/locale/en/gitlab.edit.po +++ b/locale/en/gitlab.edit.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-04-12 22:36-0500\n" +"POT-Creation-Date: 2017-04-13 00:01-0500\n" "PO-Revision-Date: 2017-04-12 22:36-0500\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: English\n" @@ -17,3 +17,12 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" "\n" + +msgid "Deutsch" +msgstr "" + +msgid "English" +msgstr "" + +msgid "Spanish" +msgstr "" diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po index 5dcd2983f30c2..f93f438b42416 100644 --- a/locale/en/gitlab.po +++ b/locale/en/gitlab.po @@ -16,3 +16,12 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" "\n" + +msgid "Deutsch" +msgstr "" + +msgid "English" +msgstr "" + +msgid "Spanish" +msgstr "" diff --git a/locale/es/gitlab.edit.po b/locale/es/gitlab.edit.po index 38a2d10f6e845..b5b6c0a671f0d 100644 --- a/locale/es/gitlab.edit.po +++ b/locale/es/gitlab.edit.po @@ -7,13 +7,22 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-04-12 22:36-0500\n" -"PO-Revision-Date: 2017-04-12 22:37-0500\n" -"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"PO-Revision-Date: 2017-04-13 00:07-0500\n" "Language-Team: Spanish\n" "Language: es\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"\n" +"POT-Creation-Date: 2017-04-13 00:01-0500\n" +"Last-Translator: \n" +"X-Generator: Poedit 2.0.1\n" + +msgid "Deutsch" +msgstr "Alemán" + +msgid "English" +msgstr "Inglés" + +msgid "Spanish" +msgstr "Español" diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po index 893eb7e15a3e3..e2d03d8834774 100644 --- a/locale/es/gitlab.po +++ b/locale/es/gitlab.po @@ -7,12 +7,21 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2017-04-12 22:37-0500\n" -"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"PO-Revision-Date: 2017-04-13 00:07-0500\n" "Language-Team: Spanish\n" "Language: es\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"\n" +"Last-Translator: \n" +"X-Generator: Poedit 2.0.1\n" + +msgid "Deutsch" +msgstr "Alemán" + +msgid "English" +msgstr "Inglés" + +msgid "Spanish" +msgstr "Español" diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 7c4f303a5d035..a90fc69d94bf0 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-04-12 22:36-0500\n" -"PO-Revision-Date: 2017-04-12 22:36-0500\n" +"POT-Creation-Date: 2017-04-13 00:01-0500\n" +"PO-Revision-Date: 2017-04-13 00:01-0500\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" "Language: \n" @@ -17,3 +17,12 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" + +msgid "Deutsch" +msgstr "" + +msgid "English" +msgstr "" + +msgid "Spanish" +msgstr "" -- GitLab From 72fefba4c18e2464725231b3426ec2e3b0eda9c4 Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Thu, 13 Apr 2017 01:08:44 -0500 Subject: [PATCH 041/363] Ignore some files required to the translation process --- .gitignore | 3 +++ locale/de/gitlab.edit.po | 28 ---------------------------- locale/en/gitlab.edit.po | 28 ---------------------------- locale/es/gitlab.edit.po | 28 ---------------------------- 4 files changed, 3 insertions(+), 84 deletions(-) delete mode 100644 locale/de/gitlab.edit.po delete mode 100644 locale/en/gitlab.edit.po delete mode 100644 locale/es/gitlab.edit.po diff --git a/.gitignore b/.gitignore index 51b4d06b01b2a..40948f2e89c72 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ *.log *.swp +*.mo +*.edit.po .DS_Store .bundle .chef @@ -53,3 +55,4 @@ eslint-report.html /shared/* /.gitlab_workhorse_secret /webpack-report/ +/locale/**/LC_MESSAGES diff --git a/locale/de/gitlab.edit.po b/locale/de/gitlab.edit.po deleted file mode 100644 index e4c8caef1647e..0000000000000 --- a/locale/de/gitlab.edit.po +++ /dev/null @@ -1,28 +0,0 @@ -# German translations for gitlab package. -# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the gitlab package. -# FIRST AUTHOR <EMAIL@ADDRESS>, 2017. -# -msgid "" -msgstr "" -"Project-Id-Version: gitlab 1.0.0\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-04-13 00:01-0500\n" -"PO-Revision-Date: 2017-04-12 22:37-0500\n" -"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" -"Language-Team: German\n" -"Language: de\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=n != 1;\n" -"\n" - -msgid "Deutsch" -msgstr "" - -msgid "English" -msgstr "" - -msgid "Spanish" -msgstr "" diff --git a/locale/en/gitlab.edit.po b/locale/en/gitlab.edit.po deleted file mode 100644 index 048f531de4b0d..0000000000000 --- a/locale/en/gitlab.edit.po +++ /dev/null @@ -1,28 +0,0 @@ -# English translations for gitlab package. -# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the gitlab package. -# FIRST AUTHOR <EMAIL@ADDRESS>, 2017. -# -msgid "" -msgstr "" -"Project-Id-Version: gitlab 1.0.0\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-04-13 00:01-0500\n" -"PO-Revision-Date: 2017-04-12 22:36-0500\n" -"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" -"Language-Team: English\n" -"Language: en\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=n != 1;\n" -"\n" - -msgid "Deutsch" -msgstr "" - -msgid "English" -msgstr "" - -msgid "Spanish" -msgstr "" diff --git a/locale/es/gitlab.edit.po b/locale/es/gitlab.edit.po deleted file mode 100644 index b5b6c0a671f0d..0000000000000 --- a/locale/es/gitlab.edit.po +++ /dev/null @@ -1,28 +0,0 @@ -# Spanish translations for gitlab package. -# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the gitlab package. -# FIRST AUTHOR <EMAIL@ADDRESS>, 2017. -# -msgid "" -msgstr "" -"Project-Id-Version: gitlab 1.0.0\n" -"Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2017-04-13 00:07-0500\n" -"Language-Team: Spanish\n" -"Language: es\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=n != 1;\n" -"POT-Creation-Date: 2017-04-13 00:01-0500\n" -"Last-Translator: \n" -"X-Generator: Poedit 2.0.1\n" - -msgid "Deutsch" -msgstr "Alemán" - -msgid "English" -msgstr "Inglés" - -msgid "Spanish" -msgstr "Español" -- GitLab From 57f8f2d7ff851fc4f5d1c81a28a023855f1985b7 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Thu, 13 Apr 2017 15:06:45 +0200 Subject: [PATCH 042/363] Fix detailed build status specs for manual actions --- spec/lib/gitlab/ci/status/build/play_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/lib/gitlab/ci/status/build/play_spec.rb b/spec/lib/gitlab/ci/status/build/play_spec.rb index 48aeb89e11f44..95f1388a9b971 100644 --- a/spec/lib/gitlab/ci/status/build/play_spec.rb +++ b/spec/lib/gitlab/ci/status/build/play_spec.rb @@ -12,7 +12,7 @@ describe 'action details' do let(:user) { create(:user) } - let(:build) { create(:ci_build) } + let(:build) { create(:ci_build, :manual) } let(:status) { Gitlab::Ci::Status::Core.new(build, user) } describe '#has_action?' do -- GitLab From cfd3d0fd377c3438c6ce8bc2f20b11f86b43a785 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Tue, 4 Apr 2017 14:58:45 +0100 Subject: [PATCH 043/363] [ci skip] Remove loadscript class in favour of backend conditional --- app/assets/javascripts/application.js | 266 -- .../javascripts/lib/utils/load_script.js.es6 | 26 - app/assets/javascripts/raven/index.js | 10 + app/assets/javascripts/raven/raven_config.js | 46 + app/assets/javascripts/raven_config.js.es6 | 66 - app/helpers/sentry_helper.rb | 2 + app/views/layouts/application.html.haml | 2 + app/views/layouts/devise.html.haml | 2 + app/views/layouts/devise_empty.html.haml | 2 + config/webpack.config.js | 1 + lib/gitlab/gon_helper.rb | 1 - package.json | 1 + spec/features/raven_js_spec.rb | 8 +- spec/javascripts/class_spec_helper.js.es6 | 10 - .../javascripts/class_spec_helper_spec.js.es6 | 35 - .../lib/utils/load_script_spec.js.es6 | 95 - spec/javascripts/raven/index_spec.js | 11 + spec/javascripts/raven/raven_config_spec.js | 137 + spec/javascripts/raven_config_spec.js.es6 | 142 - vendor/assets/javascripts/raven.js | 2547 ----------------- yarn.lock | 8 +- 21 files changed, 225 insertions(+), 3193 deletions(-) delete mode 100644 app/assets/javascripts/application.js delete mode 100644 app/assets/javascripts/lib/utils/load_script.js.es6 create mode 100644 app/assets/javascripts/raven/index.js create mode 100644 app/assets/javascripts/raven/raven_config.js delete mode 100644 app/assets/javascripts/raven_config.js.es6 delete mode 100644 spec/javascripts/class_spec_helper.js.es6 delete mode 100644 spec/javascripts/class_spec_helper_spec.js.es6 delete mode 100644 spec/javascripts/lib/utils/load_script_spec.js.es6 create mode 100644 spec/javascripts/raven/index_spec.js create mode 100644 spec/javascripts/raven/raven_config_spec.js delete mode 100644 spec/javascripts/raven_config_spec.js.es6 delete mode 100644 vendor/assets/javascripts/raven.js diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js deleted file mode 100644 index 94902e560a8b4..0000000000000 --- a/app/assets/javascripts/application.js +++ /dev/null @@ -1,266 +0,0 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, quotes, consistent-return, prefer-arrow-callback, comma-dangle, object-shorthand, no-new, max-len */ -/* global bp */ -/* global Cookies */ -/* global Flash */ -/* global ConfirmDangerModal */ -/* global AwardsHandler */ -/* global Aside */ - -// This is a manifest file that'll be compiled into including all the files listed below. -// Add new JavaScript code in separate files in this directory and they'll automatically -// be included in the compiled file accessible from http://example.com/assets/application.js -// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the -// the compiled file. -// -/*= require jquery2 */ -/*= require jquery-ui/autocomplete */ -/*= require jquery-ui/datepicker */ -/*= require jquery-ui/draggable */ -/*= require jquery-ui/effect-highlight */ -/*= require jquery-ui/sortable */ -/*= require jquery_ujs */ -/*= require jquery.endless-scroll */ -/*= require jquery.highlight */ -/*= require jquery.waitforimages */ -/*= require jquery.atwho */ -/*= require jquery.scrollTo */ -/*= require jquery.turbolinks */ -/*= require js.cookie */ -/*= require turbolinks */ -/*= require autosave */ -/*= require bootstrap/affix */ -/*= require bootstrap/alert */ -/*= require bootstrap/button */ -/*= require bootstrap/collapse */ -/*= require bootstrap/dropdown */ -/*= require bootstrap/modal */ -/*= require bootstrap/scrollspy */ -/*= require bootstrap/tab */ -/*= require bootstrap/transition */ -/*= require bootstrap/tooltip */ -/*= require bootstrap/popover */ -/*= require select2 */ -/*= require underscore */ -/*= require dropzone */ -/*= require mousetrap */ -/*= require mousetrap/pause */ -/*= require shortcuts */ -/*= require shortcuts_navigation */ -/*= require shortcuts_dashboard_navigation */ -/*= require shortcuts_issuable */ -/*= require shortcuts_network */ -/*= require jquery.nicescroll */ -/*= require date.format */ -/*= require_directory ./behaviors */ -/*= require_directory ./blob */ -/*= require_directory ./templates */ -/*= require_directory ./commit */ -/*= require_directory ./extensions */ -/*= require_directory ./lib/utils */ -/*= require_directory ./u2f */ -/*= require_directory ./droplab */ -/*= require_directory . */ -/*= require fuzzaldrin-plus */ -/*= require es6-promise.auto */ -/*= require raven_config */ - -(function () { - document.addEventListener('page:fetch', function () { - // Unbind scroll events - $(document).off('scroll'); - // Close any open tooltips - $('.has-tooltip, [data-toggle="tooltip"]').tooltip('destroy'); - }); - - window.addEventListener('hashchange', gl.utils.handleLocationHash); - window.addEventListener('load', function onLoad() { - window.removeEventListener('load', onLoad, false); - gl.utils.handleLocationHash(); - }, false); - - $(function () { - var $body = $('body'); - var $document = $(document); - var $window = $(window); - var $sidebarGutterToggle = $('.js-sidebar-toggle'); - var $flash = $('.flash-container'); - var bootstrapBreakpoint = bp.getBreakpointSize(); - var checkInitialSidebarSize; - var fitSidebarForSize; - - // Set the default path for all cookies to GitLab's root directory - Cookies.defaults.path = gon.relative_url_root || '/'; - - // `hashchange` is not triggered when link target is already in window.location - $body.on('click', 'a[href^="#"]', function() { - var href = this.getAttribute('href'); - if (href.substr(1) === gl.utils.getLocationHash()) { - setTimeout(gl.utils.handleLocationHash, 1); - } - }); - - // prevent default action for disabled buttons - $('.btn').click(function(e) { - if ($(this).hasClass('disabled')) { - e.preventDefault(); - e.stopImmediatePropagation(); - return false; - } - }); - - $('.nav-sidebar').niceScroll({ - cursoropacitymax: '0.4', - cursorcolor: '#FFF', - cursorborder: '1px solid #FFF' - }); - $('.js-select-on-focus').on('focusin', function () { - return $(this).select().one('mouseup', function (e) { - return e.preventDefault(); - }); - // Click a .js-select-on-focus field, select the contents - // Prevent a mouseup event from deselecting the input - }); - $('.remove-row').bind('ajax:success', function () { - $(this).tooltip('destroy') - .closest('li') - .fadeOut(); - }); - $('.js-remove-tr').bind('ajax:before', function () { - return $(this).hide(); - }); - $('.js-remove-tr').bind('ajax:success', function () { - return $(this).closest('tr').fadeOut(); - }); - $('select.select2').select2({ - width: 'resolve', - // Initialize select2 selects - dropdownAutoWidth: true - }); - $('.js-select2').bind('select2-close', function () { - return setTimeout((function () { - $('.select2-container-active').removeClass('select2-container-active'); - return $(':focus').blur(); - }), 1); - // Close select2 on escape - }); - // Initialize tooltips - $.fn.tooltip.Constructor.DEFAULTS.trigger = 'hover'; - $body.tooltip({ - selector: '.has-tooltip, [data-toggle="tooltip"]', - placement: function (_, el) { - return $(el).data('placement') || 'bottom'; - } - }); - $('.trigger-submit').on('change', function () { - return $(this).parents('form').submit(); - // Form submitter - }); - gl.utils.localTimeAgo($('abbr.timeago, .js-timeago'), true); - // Flash - if ($flash.length > 0) { - $flash.click(function () { - return $(this).fadeOut(); - }); - $flash.show(); - } - // Disable form buttons while a form is submitting - $body.on('ajax:complete, ajax:beforeSend, submit', 'form', function (e) { - var buttons; - buttons = $('[type="submit"]', this); - switch (e.type) { - case 'ajax:beforeSend': - case 'submit': - return buttons.disable(); - default: - return buttons.enable(); - } - }); - $(document).ajaxError(function (e, xhrObj) { - var ref = xhrObj.status; - if (xhrObj.status === 401) { - return new Flash('You need to be logged in.', 'alert'); - } else if (ref === 404 || ref === 500) { - return new Flash('Something went wrong on our end.', 'alert'); - } - }); - $('.account-box').hover(function () { - // Show/Hide the profile menu when hovering the account box - return $(this).toggleClass('hover'); - }); - $document.on('click', '.diff-content .js-show-suppressed-diff', function () { - var $container; - $container = $(this).parent(); - $container.next('table').show(); - return $container.remove(); - // Commit show suppressed diff - }); - $('.navbar-toggle').on('click', function () { - $('.header-content .title').toggle(); - $('.header-content .header-logo').toggle(); - $('.header-content .navbar-collapse').toggle(); - return $('.navbar-toggle').toggleClass('active'); - }); - // Show/hide comments on diff - $body.on('click', '.js-toggle-diff-comments', function (e) { - var $this = $(this); - var notesHolders = $this.closest('.diff-file').find('.notes_holder'); - $this.toggleClass('active'); - if ($this.hasClass('active')) { - notesHolders.show().find('.hide').show(); - } else { - notesHolders.hide(); - } - $this.trigger('blur'); - return e.preventDefault(); - }); - $document.off('click', '.js-confirm-danger'); - $document.on('click', '.js-confirm-danger', function (e) { - var btn = $(e.target); - var form = btn.closest('form'); - var text = btn.data('confirm-danger-message'); - e.preventDefault(); - return new ConfirmDangerModal(form, text); - }); - $('input[type="search"]').each(function () { - var $this = $(this); - $this.attr('value', $this.val()); - }); - $document.off('keyup', 'input[type="search"]').on('keyup', 'input[type="search"]', function () { - var $this; - $this = $(this); - return $this.attr('value', $this.val()); - }); - $document.off('breakpoint:change').on('breakpoint:change', function (e, breakpoint) { - var $gutterIcon; - if (breakpoint === 'sm' || breakpoint === 'xs') { - $gutterIcon = $sidebarGutterToggle.find('i'); - if ($gutterIcon.hasClass('fa-angle-double-right')) { - return $sidebarGutterToggle.trigger('click'); - } - } - }); - fitSidebarForSize = function () { - var oldBootstrapBreakpoint; - oldBootstrapBreakpoint = bootstrapBreakpoint; - bootstrapBreakpoint = bp.getBreakpointSize(); - if (bootstrapBreakpoint !== oldBootstrapBreakpoint) { - return $document.trigger('breakpoint:change', [bootstrapBreakpoint]); - } - }; - checkInitialSidebarSize = function () { - bootstrapBreakpoint = bp.getBreakpointSize(); - if (bootstrapBreakpoint === 'xs' || 'sm') { - return $document.trigger('breakpoint:change', [bootstrapBreakpoint]); - } - }; - $window.off('resize.app').on('resize.app', function () { - return fitSidebarForSize(); - }); - gl.awardsHandler = new AwardsHandler(); - checkInitialSidebarSize(); - new Aside(); - - // bind sidebar events - new gl.Sidebar(); - }); -}).call(this); diff --git a/app/assets/javascripts/lib/utils/load_script.js.es6 b/app/assets/javascripts/lib/utils/load_script.js.es6 deleted file mode 100644 index 351d96530eddf..0000000000000 --- a/app/assets/javascripts/lib/utils/load_script.js.es6 +++ /dev/null @@ -1,26 +0,0 @@ -(() => { - const global = window.gl || (window.gl = {}); - - class LoadScript { - static load(source, id = '') { - if (!source) return Promise.reject('source url must be defined'); - if (id && document.querySelector(`#${id}`)) return Promise.reject('script id already exists'); - return new Promise((resolve, reject) => this.appendScript(source, id, resolve, reject)); - } - - static appendScript(source, id, resolve, reject) { - const scriptElement = document.createElement('script'); - scriptElement.type = 'text/javascript'; - if (id) scriptElement.id = id; - scriptElement.onload = resolve; - scriptElement.onerror = reject; - scriptElement.src = source; - - document.body.appendChild(scriptElement); - } - } - - global.LoadScript = LoadScript; - - return global.LoadScript; -})(); diff --git a/app/assets/javascripts/raven/index.js b/app/assets/javascripts/raven/index.js new file mode 100644 index 0000000000000..6cc81248e6b23 --- /dev/null +++ b/app/assets/javascripts/raven/index.js @@ -0,0 +1,10 @@ +import RavenConfig from './raven_config'; + +RavenConfig.init({ + sentryDsn: gon.sentry_dsn, + currentUserId: gon.current_user_id, + whitelistUrls: [gon.gitlab_url], + isProduction: gon.is_production, +}); + +export default RavenConfig; diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/raven/raven_config.js new file mode 100644 index 0000000000000..5510dd2752d58 --- /dev/null +++ b/app/assets/javascripts/raven/raven_config.js @@ -0,0 +1,46 @@ +import Raven from 'raven-js'; + +class RavenConfig { + static init(options = {}) { + this.options = options; + + this.configure(); + this.bindRavenErrors(); + if (this.options.currentUserId) this.setUser(); + } + + static configure() { + Raven.config(this.options.sentryDsn, { + whitelistUrls: this.options.whitelistUrls, + environment: this.options.isProduction ? 'production' : 'development', + }).install(); + } + + static setUser() { + Raven.setUserContext({ + id: this.options.currentUserId, + }); + } + + static bindRavenErrors() { + $(document).on('ajaxError.raven', this.handleRavenErrors); + } + + static handleRavenErrors(event, req, config, err) { + const error = err || req.statusText; + + Raven.captureMessage(error, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: req.responseText.substring(0, 100), + error, + event, + }, + }); + } +} + +export default RavenConfig; diff --git a/app/assets/javascripts/raven_config.js.es6 b/app/assets/javascripts/raven_config.js.es6 deleted file mode 100644 index e15eeb9f9cd75..0000000000000 --- a/app/assets/javascripts/raven_config.js.es6 +++ /dev/null @@ -1,66 +0,0 @@ -/* global Raven */ - -/*= require lib/utils/load_script */ - -(() => { - const global = window.gl || (window.gl = {}); - - class RavenConfig { - static init(options = {}) { - this.options = options; - if (!this.options.sentryDsn || !this.options.ravenAssetUrl) return Promise.reject('sentry dsn and raven asset url is required'); - return global.LoadScript.load(this.options.ravenAssetUrl, 'raven-js') - .then(() => { - this.configure(); - this.bindRavenErrors(); - if (this.options.currentUserId) this.setUser(); - }); - } - - static configure() { - Raven.config(this.options.sentryDsn, { - whitelistUrls: this.options.whitelistUrls, - environment: this.options.isProduction ? 'production' : 'development', - }).install(); - } - - static setUser() { - Raven.setUserContext({ - id: this.options.currentUserId, - }); - } - - static bindRavenErrors() { - $(document).on('ajaxError.raven', this.handleRavenErrors); - } - - static handleRavenErrors(event, req, config, err) { - const error = err || req.statusText; - Raven.captureMessage(error, { - extra: { - type: config.type, - url: config.url, - data: config.data, - status: req.status, - response: req.responseText.substring(0, 100), - error, - event, - }, - }); - } - } - - global.RavenConfig = RavenConfig; - - document.addEventListener('DOMContentLoaded', () => { - if (!window.gon) return; - - global.RavenConfig.init({ - sentryDsn: gon.sentry_dsn, - ravenAssetUrl: gon.raven_asset_url, - currentUserId: gon.current_user_id, - whitelistUrls: [gon.gitlab_url], - isProduction: gon.is_production, - }).catch($.noop); - }); -})(); diff --git a/app/helpers/sentry_helper.rb b/app/helpers/sentry_helper.rb index 19de38ac52db6..4b07f71bcea02 100644 --- a/app/helpers/sentry_helper.rb +++ b/app/helpers/sentry_helper.rb @@ -9,7 +9,9 @@ def sentry_context def sentry_dsn_public sentry_dsn = ApplicationSetting.current.sentry_dsn + return unless sentry_dsn + uri = URI.parse(sentry_dsn) uri.password = nil uri.to_s diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 36543edc040f9..cfd9481e4b23a 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -9,3 +9,5 @@ = yield :scripts_body = render "layouts/init_auto_complete" if @gfm_form + + = javascript_include_tag(*webpack_asset_paths("raven")) if sentry_enabled? diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 3368a9beb2964..6274f6340ab86 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -35,3 +35,5 @@ = link_to "Explore", explore_root_path = link_to "Help", help_path = link_to "About GitLab", "https://about.gitlab.com/" + + = javascript_include_tag(*webpack_asset_paths("raven")) if sentry_enabled? diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml index 7466423a934ec..120f7299fc96c 100644 --- a/app/views/layouts/devise_empty.html.haml +++ b/app/views/layouts/devise_empty.html.haml @@ -16,3 +16,5 @@ = link_to "Explore", explore_root_path = link_to "Help", help_path = link_to "About GitLab", "https://about.gitlab.com/" + + = javascript_include_tag(*webpack_asset_paths("raven")) if sentry_enabled? diff --git a/config/webpack.config.js b/config/webpack.config.js index 70d98b022c1ff..62118522606a0 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -45,6 +45,7 @@ var config = { u2f: ['vendor/u2f'], users: './users/users_bundle.js', vue_pipelines: './vue_pipelines_index/index.js', + raven: './raven/index.js', }, output: { diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index 956e80b4a09cf..4de504e9bf984 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -13,7 +13,6 @@ def add_gon_variables gon.katex_css_url = ActionController::Base.helpers.asset_path('katex.css') gon.katex_js_url = ActionController::Base.helpers.asset_path('katex.js') gon.sentry_dsn = sentry_dsn_public if sentry_enabled? - gon.raven_asset_url = ActionController::Base.helpers.asset_path('raven.js') if sentry_enabled? gon.gitlab_url = Gitlab.config.gitlab.url gon.is_production = Rails.env.production? diff --git a/package.json b/package.json index 7b6c4556e2c4a..0b24c5b8b0434 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "mousetrap": "^1.4.6", "pikaday": "^1.5.1", "raphael": "^2.2.7", + "raven-js": "^3.14.0", "raw-loader": "^0.5.1", "select2": "3.5.2-browserify", "stats-webpack-plugin": "^0.4.3", diff --git a/spec/features/raven_js_spec.rb b/spec/features/raven_js_spec.rb index e64da1e3a974f..74df52d80a73e 100644 --- a/spec/features/raven_js_spec.rb +++ b/spec/features/raven_js_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' -feature 'RavenJS', feature: true, js: true do - let(:raven_path) { '/raven.js' } +feature 'RavenJS', :feature, :js do + let(:raven_path) { '/raven.bundle.js' } it 'should not load raven if sentry is disabled' do visit new_user_session_path @@ -10,8 +10,8 @@ end it 'should load raven if sentry is enabled' do - allow_any_instance_of(ApplicationController).to receive_messages(sentry_dsn_public: 'https://mock:sentry@dsn/path', - sentry_enabled?: true) + allow_any_instance_of(SentryHelper).to receive_messages(sentry_dsn_public: 'https://key@domain.com/id', + sentry_enabled?: true) visit new_user_session_path diff --git a/spec/javascripts/class_spec_helper.js.es6 b/spec/javascripts/class_spec_helper.js.es6 deleted file mode 100644 index 3a04e170924d5..0000000000000 --- a/spec/javascripts/class_spec_helper.js.es6 +++ /dev/null @@ -1,10 +0,0 @@ -/* eslint-disable no-unused-vars */ - -class ClassSpecHelper { - static itShouldBeAStaticMethod(base, method) { - return it('should be a static method', () => { - expect(base[method]).toBeDefined(); - expect(base.prototype[method]).toBeUndefined(); - }); - } -} diff --git a/spec/javascripts/class_spec_helper_spec.js.es6 b/spec/javascripts/class_spec_helper_spec.js.es6 deleted file mode 100644 index d1155f1bd1edd..0000000000000 --- a/spec/javascripts/class_spec_helper_spec.js.es6 +++ /dev/null @@ -1,35 +0,0 @@ -/* global ClassSpecHelper */ -//= require ./class_spec_helper - -describe('ClassSpecHelper', () => { - describe('.itShouldBeAStaticMethod', function () { - beforeEach(() => { - class TestClass { - instanceMethod() { this.prop = 'val'; } - static staticMethod() {} - } - - this.TestClass = TestClass; - }); - - ClassSpecHelper.itShouldBeAStaticMethod(ClassSpecHelper, 'itShouldBeAStaticMethod'); - - it('should have a defined spec', () => { - expect(ClassSpecHelper.itShouldBeAStaticMethod(this.TestClass, 'staticMethod').description).toBe('should be a static method'); - }); - - it('should pass for a static method', () => { - const spec = ClassSpecHelper.itShouldBeAStaticMethod(this.TestClass, 'staticMethod'); - expect(spec.status()).toBe('passed'); - }); - - it('should fail for an instance method', (done) => { - const spec = ClassSpecHelper.itShouldBeAStaticMethod(this.TestClass, 'instanceMethod'); - spec.resultCallback = (result) => { - expect(result.status).toBe('failed'); - done(); - }; - spec.execute(); - }); - }); -}); diff --git a/spec/javascripts/lib/utils/load_script_spec.js.es6 b/spec/javascripts/lib/utils/load_script_spec.js.es6 deleted file mode 100644 index 52c53327695c7..0000000000000 --- a/spec/javascripts/lib/utils/load_script_spec.js.es6 +++ /dev/null @@ -1,95 +0,0 @@ -/* global ClassSpecHelper */ - -/*= require lib/utils/load_script */ -/*= require class_spec_helper */ - -describe('LoadScript', () => { - const global = window.gl || (window.gl = {}); - const LoadScript = global.LoadScript; - - it('should be defined in the global scope', () => { - expect(LoadScript).toBeDefined(); - }); - - describe('.load', () => { - ClassSpecHelper.itShouldBeAStaticMethod(LoadScript, 'load'); - - it('should reject if no source argument is provided', () => { - spyOn(Promise, 'reject'); - LoadScript.load(); - expect(Promise.reject).toHaveBeenCalledWith('source url must be defined'); - }); - - it('should reject if the script id already exists', () => { - spyOn(Promise, 'reject'); - spyOn(document, 'querySelector').and.returnValue({}); - LoadScript.load('src.js', 'src-id'); - - expect(Promise.reject).toHaveBeenCalledWith('script id already exists'); - }); - - it('should return a promise on completion', () => { - expect(LoadScript.load('src.js')).toEqual(jasmine.any(Promise)); - }); - - it('should call appendScript when the promise is constructed', () => { - spyOn(LoadScript, 'appendScript'); - LoadScript.load('src.js', 'src-id'); - - expect(LoadScript.appendScript).toHaveBeenCalledWith('src.js', 'src-id', jasmine.any(Promise.resolve.constructor), jasmine.any(Promise.reject.constructor)); - }); - }); - - describe('.appendScript', () => { - beforeEach(() => { - spyOn(document.body, 'appendChild'); - }); - - ClassSpecHelper.itShouldBeAStaticMethod(LoadScript, 'appendScript'); - - describe('when called', () => { - let mockScriptTag; - - beforeEach(() => { - mockScriptTag = {}; - spyOn(document, 'createElement').and.returnValue(mockScriptTag); - LoadScript.appendScript('src.js', 'src-id', () => {}, () => {}); - }); - - it('should create a script tag', () => { - expect(document.createElement).toHaveBeenCalledWith('script'); - }); - - it('should set the MIME type', () => { - expect(mockScriptTag.type).toBe('text/javascript'); - }); - - it('should set the script id', () => { - expect(mockScriptTag.id).toBe('src-id'); - }); - - it('should set an onload handler', () => { - expect(mockScriptTag.onload).toEqual(jasmine.any(Function)); - }); - - it('should set an onerror handler', () => { - expect(mockScriptTag.onerror).toEqual(jasmine.any(Function)); - }); - - it('should set the src attribute', () => { - expect(mockScriptTag.src).toBe('src.js'); - }); - - it('should append the script tag to the body element', () => { - expect(document.body.appendChild).toHaveBeenCalledWith(mockScriptTag); - }); - }); - - it('should not set the script id if no id is provided', () => { - const mockScriptTag = {}; - spyOn(document, 'createElement').and.returnValue(mockScriptTag); - LoadScript.appendScript('src.js', undefined); - expect(mockScriptTag.id).toBeUndefined(); - }); - }); -}); diff --git a/spec/javascripts/raven/index_spec.js b/spec/javascripts/raven/index_spec.js new file mode 100644 index 0000000000000..51e84a6dbeea4 --- /dev/null +++ b/spec/javascripts/raven/index_spec.js @@ -0,0 +1,11 @@ +import RavenConfig from '~/raven/index'; + +describe('RavenConfig options', () => { + it('should set sentryDsn'); + + it('should set currentUserId'); + + it('should set whitelistUrls'); + + it('should set isProduction'); +}); diff --git a/spec/javascripts/raven/raven_config_spec.js b/spec/javascripts/raven/raven_config_spec.js new file mode 100644 index 0000000000000..2b63f56d71904 --- /dev/null +++ b/spec/javascripts/raven/raven_config_spec.js @@ -0,0 +1,137 @@ +import Raven from 'raven-js'; +import RavenConfig from '~/raven/raven_config'; +import ClassSpecHelper from '../helpers/class_spec_helper'; + +fdescribe('RavenConfig', () => { + describe('init', () => { + beforeEach(() => { + spyOn(RavenConfig, 'configure'); + spyOn(RavenConfig, 'bindRavenErrors'); + spyOn(RavenConfig, 'setUser'); + }); + + ClassSpecHelper.itShouldBeAStaticMethod(RavenConfig, 'init'); + + describe('when called', () => { + let options; + + beforeEach(() => { + options = { + sentryDsn: '//sentryDsn', + ravenAssetUrl: '//ravenAssetUrl', + currentUserId: 1, + whitelistUrls: ['//gitlabUrl'], + isProduction: true, + }; + + RavenConfig.init(options); + }); + + it('should set the options property', () => { + expect(RavenConfig.options).toEqual(options); + }); + + it('should call the configure method', () => { + expect(RavenConfig.configure).toHaveBeenCalled(); + }); + + it('should call the error bindings method', () => { + expect(RavenConfig.bindRavenErrors).toHaveBeenCalled(); + }); + + it('should call setUser', () => { + expect(RavenConfig.setUser).toHaveBeenCalled(); + }); + }); + + it('should not call setUser if there is no current user ID', () => { + RavenConfig.init({ + sentryDsn: '//sentryDsn', + ravenAssetUrl: '//ravenAssetUrl', + currentUserId: undefined, + whitelistUrls: ['//gitlabUrl'], + isProduction: true, + }); + + expect(RavenConfig.setUser).not.toHaveBeenCalled(); + }); + }); + + describe('configure', () => { + ClassSpecHelper.itShouldBeAStaticMethod(RavenConfig, 'configure'); + + describe('when called', () => { + let options; + let raven; + + beforeEach(() => { + options = { + sentryDsn: '//sentryDsn', + whitelistUrls: ['//gitlabUrl'], + isProduction: true, + }; + + raven = jasmine.createSpyObj('raven', ['install']); + + spyOn(Raven, 'config').and.returnValue(raven); + spyOn(Raven, 'install'); + + RavenConfig.configure.call({ + options, + }); + }); + + it('should call Raven.config', () => { + expect(Raven.config).toHaveBeenCalledWith(options.sentryDsn, { + whitelistUrls: options.whitelistUrls, + environment: 'production', + }); + }); + + it('should call Raven.install', () => { + expect(Raven.install).toHaveBeenCalled(); + }); + + describe('if isProduction is false', () => { + beforeEach(() => { + options.isProduction = false; + + RavenConfig.configure.call({ + options, + }); + }); + + it('should set .environment to development', () => { + expect(Raven.config).toHaveBeenCalledWith(options.sentryDsn, { + whitelistUrls: options.whitelistUrls, + environment: 'development', + }); + }); + }); + }); + }); + + describe('setUser', () => { + ClassSpecHelper.itShouldBeAStaticMethod(RavenConfig, 'setUser'); + + describe('when called', () => { + beforeEach(() => {}); + }); + }); + + describe('bindRavenErrors', () => { + ClassSpecHelper.itShouldBeAStaticMethod(RavenConfig, 'bindRavenErrors'); + + describe('when called', () => { + beforeEach(() => {}); + }); + }); + + describe('handleRavenErrors', () => { + ClassSpecHelper.itShouldBeAStaticMethod(RavenConfig, 'handleRavenErrors'); + + describe('when called', () => { + beforeEach(() => {}); + }); + }); +}); diff --git a/spec/javascripts/raven_config_spec.js.es6 b/spec/javascripts/raven_config_spec.js.es6 deleted file mode 100644 index 25df2cec75f03..0000000000000 --- a/spec/javascripts/raven_config_spec.js.es6 +++ /dev/null @@ -1,142 +0,0 @@ -/* global ClassSpecHelper */ - -/*= require raven */ -/*= require lib/utils/load_script */ -/*= require raven_config */ -/*= require class_spec_helper */ - -describe('RavenConfig', () => { - const global = window.gl || (window.gl = {}); - const RavenConfig = global.RavenConfig; - - it('should be defined in the global scope', () => { - expect(RavenConfig).toBeDefined(); - }); - - describe('.init', () => { - beforeEach(() => { - spyOn(global.LoadScript, 'load').and.callThrough(); - spyOn(document, 'querySelector').and.returnValue(undefined); - spyOn(RavenConfig, 'configure'); - spyOn(RavenConfig, 'bindRavenErrors'); - spyOn(RavenConfig, 'setUser'); - spyOn(Promise, 'reject'); - }); - - ClassSpecHelper.itShouldBeAStaticMethod(RavenConfig, 'init'); - - describe('when called', () => { - let options; - let initPromise; - - beforeEach(() => { - options = { - sentryDsn: '//sentryDsn', - ravenAssetUrl: '//ravenAssetUrl', - currentUserId: 1, - whitelistUrls: ['//gitlabUrl'], - isProduction: true, - }; - initPromise = RavenConfig.init(options); - }); - - it('should set the options property', () => { - expect(RavenConfig.options).toEqual(options); - }); - - it('should load a #raven-js script with the raven asset URL', () => { - expect(global.LoadScript.load).toHaveBeenCalledWith(options.ravenAssetUrl, 'raven-js'); - }); - - it('should return a promise', () => { - expect(initPromise).toEqual(jasmine.any(Promise)); - }); - - it('should call the configure method', () => { - initPromise.then(() => { - expect(RavenConfig.configure).toHaveBeenCalled(); - }); - }); - - it('should call the error bindings method', () => { - initPromise.then(() => { - expect(RavenConfig.bindRavenErrors).toHaveBeenCalled(); - }); - }); - - it('should call setUser', () => { - initPromise.then(() => { - expect(RavenConfig.setUser).toHaveBeenCalled(); - }); - }); - }); - - it('should not call setUser if there is no current user ID', () => { - RavenConfig.init({ - sentryDsn: '//sentryDsn', - ravenAssetUrl: '//ravenAssetUrl', - currentUserId: undefined, - whitelistUrls: ['//gitlabUrl'], - isProduction: true, - }); - - expect(RavenConfig.setUser).not.toHaveBeenCalled(); - }); - - it('should reject if there is no Sentry DSN', () => { - RavenConfig.init({ - sentryDsn: undefined, - ravenAssetUrl: '//ravenAssetUrl', - currentUserId: 1, - whitelistUrls: ['//gitlabUrl'], - isProduction: true, - }); - - expect(Promise.reject).toHaveBeenCalledWith('sentry dsn and raven asset url is required'); - }); - - it('should reject if there is no Raven asset URL', () => { - RavenConfig.init({ - sentryDsn: '//sentryDsn', - ravenAssetUrl: undefined, - currentUserId: 1, - whitelistUrls: ['//gitlabUrl'], - isProduction: true, - }); - - expect(Promise.reject).toHaveBeenCalledWith('sentry dsn and raven asset url is required'); - }); - }); - - describe('.configure', () => { - ClassSpecHelper.itShouldBeAStaticMethod(RavenConfig, 'configure'); - - describe('when called', () => { - beforeEach(() => {}); - }); - }); - - describe('.setUser', () => { - ClassSpecHelper.itShouldBeAStaticMethod(RavenConfig, 'setUser'); - - describe('when called', () => { - beforeEach(() => {}); - }); - }); - - describe('.bindRavenErrors', () => { - ClassSpecHelper.itShouldBeAStaticMethod(RavenConfig, 'bindRavenErrors'); - - describe('when called', () => { - beforeEach(() => {}); - }); - }); - - describe('.handleRavenErrors', () => { - ClassSpecHelper.itShouldBeAStaticMethod(RavenConfig, 'handleRavenErrors'); - - describe('when called', () => { - beforeEach(() => {}); - }); - }); -}); diff --git a/vendor/assets/javascripts/raven.js b/vendor/assets/javascripts/raven.js deleted file mode 100644 index ba416b26be14d..0000000000000 --- a/vendor/assets/javascripts/raven.js +++ /dev/null @@ -1,2547 +0,0 @@ -/*! Raven.js 3.9.1 (7bbae7d) | github.com/getsentry/raven-js */ - -/* - * Includes TraceKit - * https://github.com/getsentry/TraceKit - * - * Copyright 2016 Matt Robenolt and other contributors - * Released under the BSD license - * https://github.com/getsentry/raven-js/blob/master/LICENSE - * - */ - -(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.Raven = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(_dereq_,module,exports){ -exports = module.exports = stringify -exports.getSerialize = serializer - -function stringify(obj, replacer, spaces, cycleReplacer) { - return JSON.stringify(obj, serializer(replacer, cycleReplacer), spaces) -} - -function serializer(replacer, cycleReplacer) { - var stack = [], keys = [] - - if (cycleReplacer == null) cycleReplacer = function(key, value) { - if (stack[0] === value) return "[Circular ~]" - return "[Circular ~." + keys.slice(0, stack.indexOf(value)).join(".") + "]" - } - - return function(key, value) { - if (stack.length > 0) { - var thisPos = stack.indexOf(this) - ~thisPos ? stack.splice(thisPos + 1) : stack.push(this) - ~thisPos ? keys.splice(thisPos, Infinity, key) : keys.push(key) - if (~stack.indexOf(value)) value = cycleReplacer.call(this, key, value) - } - else stack.push(value) - - return replacer == null ? value : replacer.call(this, key, value) - } -} - -},{}],2:[function(_dereq_,module,exports){ -'use strict'; - -function RavenConfigError(message) { - this.name = 'RavenConfigError'; - this.message = message; -} -RavenConfigError.prototype = new Error(); -RavenConfigError.prototype.constructor = RavenConfigError; - -module.exports = RavenConfigError; - -},{}],3:[function(_dereq_,module,exports){ -'use strict'; - -var wrapMethod = function(console, level, callback) { - var originalConsoleLevel = console[level]; - var originalConsole = console; - - if (!(level in console)) { - return; - } - - var sentryLevel = level === 'warn' - ? 'warning' - : level; - - console[level] = function () { - var args = [].slice.call(arguments); - - var msg = '' + args.join(' '); - var data = {level: sentryLevel, logger: 'console', extra: {'arguments': args}}; - callback && callback(msg, data); - - // this fails for some browsers. :( - if (originalConsoleLevel) { - // IE9 doesn't allow calling apply on console functions directly - // See: https://stackoverflow.com/questions/5472938/does-ie9-support-console-log-and-is-it-a-real-function#answer-5473193 - Function.prototype.apply.call( - originalConsoleLevel, - originalConsole, - args - ); - } - }; -}; - -module.exports = { - wrapMethod: wrapMethod -}; - -},{}],4:[function(_dereq_,module,exports){ -(function (global){ -/*global XDomainRequest:false, __DEV__:false*/ -'use strict'; - -var TraceKit = _dereq_(6); -var RavenConfigError = _dereq_(2); -var stringify = _dereq_(1); - -var wrapConsoleMethod = _dereq_(3).wrapMethod; - -var dsnKeys = 'source protocol user pass host port path'.split(' '), - dsnPattern = /^(?:(\w+):)?\/\/(?:(\w+)(:\w+)?@)?([\w\.-]+)(?::(\d+))?(\/.*)/; - -function now() { - return +new Date(); -} - -// This is to be defensive in environments where window does not exist (see https://github.com/getsentry/raven-js/pull/785) -var _window = typeof window !== 'undefined' ? window - : typeof global !== 'undefined' ? global - : typeof self !== 'undefined' ? self - : {}; -var _document = _window.document; - -// First, check for JSON support -// If there is no JSON, we no-op the core features of Raven -// since JSON is required to encode the payload -function Raven() { - this._hasJSON = !!(typeof JSON === 'object' && JSON.stringify); - // Raven can run in contexts where there's no document (react-native) - this._hasDocument = !isUndefined(_document); - this._lastCapturedException = null; - this._lastEventId = null; - this._globalServer = null; - this._globalKey = null; - this._globalProject = null; - this._globalContext = {}; - this._globalOptions = { - logger: 'javascript', - ignoreErrors: [], - ignoreUrls: [], - whitelistUrls: [], - includePaths: [], - crossOrigin: 'anonymous', - collectWindowErrors: true, - maxMessageLength: 0, - stackTraceLimit: 50, - autoBreadcrumbs: true - }; - this._ignoreOnError = 0; - this._isRavenInstalled = false; - this._originalErrorStackTraceLimit = Error.stackTraceLimit; - // capture references to window.console *and* all its methods first - // before the console plugin has a chance to monkey patch - this._originalConsole = _window.console || {}; - this._originalConsoleMethods = {}; - this._plugins = []; - this._startTime = now(); - this._wrappedBuiltIns = []; - this._breadcrumbs = []; - this._lastCapturedEvent = null; - this._keypressTimeout; - this._location = _window.location; - this._lastHref = this._location && this._location.href; - - for (var method in this._originalConsole) { // eslint-disable-line guard-for-in - this._originalConsoleMethods[method] = this._originalConsole[method]; - } -} - -/* - * The core Raven singleton - * - * @this {Raven} - */ - -Raven.prototype = { - // Hardcode version string so that raven source can be loaded directly via - // webpack (using a build step causes webpack #1617). Grunt verifies that - // this value matches package.json during build. - // See: https://github.com/getsentry/raven-js/issues/465 - VERSION: '3.9.1', - - debug: false, - - TraceKit: TraceKit, // alias to TraceKit - - /* - * Configure Raven with a DSN and extra options - * - * @param {string} dsn The public Sentry DSN - * @param {object} options Optional set of of global options [optional] - * @return {Raven} - */ - config: function(dsn, options) { - var self = this; - - if (self._globalServer) { - this._logDebug('error', 'Error: Raven has already been configured'); - return self; - } - if (!dsn) return self; - - var globalOptions = self._globalOptions; - - // merge in options - if (options) { - each(options, function(key, value){ - // tags and extra are special and need to be put into context - if (key === 'tags' || key === 'extra' || key === 'user') { - self._globalContext[key] = value; - } else { - globalOptions[key] = value; - } - }); - } - - self.setDSN(dsn); - - // "Script error." is hard coded into browsers for errors that it can't read. - // this is the result of a script being pulled in from an external domain and CORS. - globalOptions.ignoreErrors.push(/^Script error\.?$/); - globalOptions.ignoreErrors.push(/^Javascript error: Script error\.? on line 0$/); - - // join regexp rules into one big rule - globalOptions.ignoreErrors = joinRegExp(globalOptions.ignoreErrors); - globalOptions.ignoreUrls = globalOptions.ignoreUrls.length ? joinRegExp(globalOptions.ignoreUrls) : false; - globalOptions.whitelistUrls = globalOptions.whitelistUrls.length ? joinRegExp(globalOptions.whitelistUrls) : false; - globalOptions.includePaths = joinRegExp(globalOptions.includePaths); - globalOptions.maxBreadcrumbs = Math.max(0, Math.min(globalOptions.maxBreadcrumbs || 100, 100)); // default and hard limit is 100 - - var autoBreadcrumbDefaults = { - xhr: true, - console: true, - dom: true, - location: true - }; - - var autoBreadcrumbs = globalOptions.autoBreadcrumbs; - if ({}.toString.call(autoBreadcrumbs) === '[object Object]') { - autoBreadcrumbs = objectMerge(autoBreadcrumbDefaults, autoBreadcrumbs); - } else if (autoBreadcrumbs !== false) { - autoBreadcrumbs = autoBreadcrumbDefaults; - } - globalOptions.autoBreadcrumbs = autoBreadcrumbs; - - TraceKit.collectWindowErrors = !!globalOptions.collectWindowErrors; - - // return for chaining - return self; - }, - - /* - * Installs a global window.onerror error handler - * to capture and report uncaught exceptions. - * At this point, install() is required to be called due - * to the way TraceKit is set up. - * - * @return {Raven} - */ - install: function() { - var self = this; - if (self.isSetup() && !self._isRavenInstalled) { - TraceKit.report.subscribe(function () { - self._handleOnErrorStackInfo.apply(self, arguments); - }); - self._instrumentTryCatch(); - if (self._globalOptions.autoBreadcrumbs) - self._instrumentBreadcrumbs(); - - // Install all of the plugins - self._drainPlugins(); - - self._isRavenInstalled = true; - } - - Error.stackTraceLimit = self._globalOptions.stackTraceLimit; - return this; - }, - - /* - * Set the DSN (can be called multiple time unlike config) - * - * @param {string} dsn The public Sentry DSN - */ - setDSN: function(dsn) { - var self = this, - uri = self._parseDSN(dsn), - lastSlash = uri.path.lastIndexOf('/'), - path = uri.path.substr(1, lastSlash); - - self._dsn = dsn; - self._globalKey = uri.user; - self._globalSecret = uri.pass && uri.pass.substr(1); - self._globalProject = uri.path.substr(lastSlash + 1); - - self._globalServer = self._getGlobalServer(uri); - - self._globalEndpoint = self._globalServer + - '/' + path + 'api/' + self._globalProject + '/store/'; - }, - - /* - * Wrap code within a context so Raven can capture errors - * reliably across domains that is executed immediately. - * - * @param {object} options A specific set of options for this context [optional] - * @param {function} func The callback to be immediately executed within the context - * @param {array} args An array of arguments to be called with the callback [optional] - */ - context: function(options, func, args) { - if (isFunction(options)) { - args = func || []; - func = options; - options = undefined; - } - - return this.wrap(options, func).apply(this, args); - }, - - /* - * Wrap code within a context and returns back a new function to be executed - * - * @param {object} options A specific set of options for this context [optional] - * @param {function} func The function to be wrapped in a new context - * @param {function} func A function to call before the try/catch wrapper [optional, private] - * @return {function} The newly wrapped functions with a context - */ - wrap: function(options, func, _before) { - var self = this; - // 1 argument has been passed, and it's not a function - // so just return it - if (isUndefined(func) && !isFunction(options)) { - return options; - } - - // options is optional - if (isFunction(options)) { - func = options; - options = undefined; - } - - // At this point, we've passed along 2 arguments, and the second one - // is not a function either, so we'll just return the second argument. - if (!isFunction(func)) { - return func; - } - - // We don't wanna wrap it twice! - try { - if (func.__raven__) { - return func; - } - - // If this has already been wrapped in the past, return that - if (func.__raven_wrapper__ ){ - return func.__raven_wrapper__ ; - } - } catch (e) { - // Just accessing custom props in some Selenium environments - // can cause a "Permission denied" exception (see raven-js#495). - // Bail on wrapping and return the function as-is (defers to window.onerror). - return func; - } - - function wrapped() { - var args = [], i = arguments.length, - deep = !options || options && options.deep !== false; - - if (_before && isFunction(_before)) { - _before.apply(this, arguments); - } - - // Recursively wrap all of a function's arguments that are - // functions themselves. - while(i--) args[i] = deep ? self.wrap(options, arguments[i]) : arguments[i]; - - try { - return func.apply(this, args); - } catch(e) { - self._ignoreNextOnError(); - self.captureException(e, options); - throw e; - } - } - - // copy over properties of the old function - for (var property in func) { - if (hasKey(func, property)) { - wrapped[property] = func[property]; - } - } - wrapped.prototype = func.prototype; - - func.__raven_wrapper__ = wrapped; - // Signal that this function has been wrapped already - // for both debugging and to prevent it to being wrapped twice - wrapped.__raven__ = true; - wrapped.__inner__ = func; - - return wrapped; - }, - - /* - * Uninstalls the global error handler. - * - * @return {Raven} - */ - uninstall: function() { - TraceKit.report.uninstall(); - - this._restoreBuiltIns(); - - Error.stackTraceLimit = this._originalErrorStackTraceLimit; - this._isRavenInstalled = false; - - return this; - }, - - /* - * Manually capture an exception and send it over to Sentry - * - * @param {error} ex An exception to be logged - * @param {object} options A specific set of options for this error [optional] - * @return {Raven} - */ - captureException: function(ex, options) { - // If not an Error is passed through, recall as a message instead - if (!isError(ex)) { - return this.captureMessage(ex, objectMerge({ - trimHeadFrames: 1, - stacktrace: true // if we fall back to captureMessage, default to attempting a new trace - }, options)); - } - - // Store the raw exception object for potential debugging and introspection - this._lastCapturedException = ex; - - // TraceKit.report will re-raise any exception passed to it, - // which means you have to wrap it in try/catch. Instead, we - // can wrap it here and only re-raise if TraceKit.report - // raises an exception different from the one we asked to - // report on. - try { - var stack = TraceKit.computeStackTrace(ex); - this._handleStackInfo(stack, options); - } catch(ex1) { - if(ex !== ex1) { - throw ex1; - } - } - - return this; - }, - - /* - * Manually send a message to Sentry - * - * @param {string} msg A plain message to be captured in Sentry - * @param {object} options A specific set of options for this message [optional] - * @return {Raven} - */ - captureMessage: function(msg, options) { - // config() automagically converts ignoreErrors from a list to a RegExp so we need to test for an - // early call; we'll error on the side of logging anything called before configuration since it's - // probably something you should see: - if (!!this._globalOptions.ignoreErrors.test && this._globalOptions.ignoreErrors.test(msg)) { - return; - } - - options = options || {}; - - var data = objectMerge({ - message: msg + '' // Make sure it's actually a string - }, options); - - if (this._globalOptions.stacktrace || (options && options.stacktrace)) { - var ex; - // create a stack trace from this point; just trim - // off extra frames so they don't include this function call (or - // earlier Raven.js library fn calls) - try { - throw new Error(msg); - } catch (ex1) { - ex = ex1; - } - - // null exception name so `Error` isn't prefixed to msg - ex.name = null; - - options = objectMerge({ - // fingerprint on msg, not stack trace (legacy behavior, could be - // revisited) - fingerprint: msg, - trimHeadFrames: (options.trimHeadFrames || 0) + 1 - }, options); - - var stack = TraceKit.computeStackTrace(ex); - var frames = this._prepareFrames(stack, options); - data.stacktrace = { - // Sentry expects frames oldest to newest - frames: frames.reverse() - } - } - - // Fire away! - this._send(data); - - return this; - }, - - captureBreadcrumb: function (obj) { - var crumb = objectMerge({ - timestamp: now() / 1000 - }, obj); - - if (isFunction(this._globalOptions.breadcrumbCallback)) { - var result = this._globalOptions.breadcrumbCallback(crumb); - - if (isObject(result) && !isEmptyObject(result)) { - crumb = result; - } else if (result === false) { - return this; - } - } - - this._breadcrumbs.push(crumb); - if (this._breadcrumbs.length > this._globalOptions.maxBreadcrumbs) { - this._breadcrumbs.shift(); - } - return this; - }, - - addPlugin: function(plugin /*arg1, arg2, ... argN*/) { - var pluginArgs = [].slice.call(arguments, 1); - - this._plugins.push([plugin, pluginArgs]); - if (this._isRavenInstalled) { - this._drainPlugins(); - } - - return this; - }, - - /* - * Set/clear a user to be sent along with the payload. - * - * @param {object} user An object representing user data [optional] - * @return {Raven} - */ - setUserContext: function(user) { - // Intentionally do not merge here since that's an unexpected behavior. - this._globalContext.user = user; - - return this; - }, - - /* - * Merge extra attributes to be sent along with the payload. - * - * @param {object} extra An object representing extra data [optional] - * @return {Raven} - */ - setExtraContext: function(extra) { - this._mergeContext('extra', extra); - - return this; - }, - - /* - * Merge tags to be sent along with the payload. - * - * @param {object} tags An object representing tags [optional] - * @return {Raven} - */ - setTagsContext: function(tags) { - this._mergeContext('tags', tags); - - return this; - }, - - /* - * Clear all of the context. - * - * @return {Raven} - */ - clearContext: function() { - this._globalContext = {}; - - return this; - }, - - /* - * Get a copy of the current context. This cannot be mutated. - * - * @return {object} copy of context - */ - getContext: function() { - // lol javascript - return JSON.parse(stringify(this._globalContext)); - }, - - - /* - * Set environment of application - * - * @param {string} environment Typically something like 'production'. - * @return {Raven} - */ - setEnvironment: function(environment) { - this._globalOptions.environment = environment; - - return this; - }, - - /* - * Set release version of application - * - * @param {string} release Typically something like a git SHA to identify version - * @return {Raven} - */ - setRelease: function(release) { - this._globalOptions.release = release; - - return this; - }, - - /* - * Set the dataCallback option - * - * @param {function} callback The callback to run which allows the - * data blob to be mutated before sending - * @return {Raven} - */ - setDataCallback: function(callback) { - var original = this._globalOptions.dataCallback; - this._globalOptions.dataCallback = isFunction(callback) - ? function (data) { return callback(data, original); } - : callback; - - return this; - }, - - /* - * Set the breadcrumbCallback option - * - * @param {function} callback The callback to run which allows filtering - * or mutating breadcrumbs - * @return {Raven} - */ - setBreadcrumbCallback: function(callback) { - var original = this._globalOptions.breadcrumbCallback; - this._globalOptions.breadcrumbCallback = isFunction(callback) - ? function (data) { return callback(data, original); } - : callback; - - return this; - }, - - /* - * Set the shouldSendCallback option - * - * @param {function} callback The callback to run which allows - * introspecting the blob before sending - * @return {Raven} - */ - setShouldSendCallback: function(callback) { - var original = this._globalOptions.shouldSendCallback; - this._globalOptions.shouldSendCallback = isFunction(callback) - ? function (data) { return callback(data, original); } - : callback; - - return this; - }, - - /** - * Override the default HTTP transport mechanism that transmits data - * to the Sentry server. - * - * @param {function} transport Function invoked instead of the default - * `makeRequest` handler. - * - * @return {Raven} - */ - setTransport: function(transport) { - this._globalOptions.transport = transport; - - return this; - }, - - /* - * Get the latest raw exception that was captured by Raven. - * - * @return {error} - */ - lastException: function() { - return this._lastCapturedException; - }, - - /* - * Get the last event id - * - * @return {string} - */ - lastEventId: function() { - return this._lastEventId; - }, - - /* - * Determine if Raven is setup and ready to go. - * - * @return {boolean} - */ - isSetup: function() { - if (!this._hasJSON) return false; // needs JSON support - if (!this._globalServer) { - if (!this.ravenNotConfiguredError) { - this.ravenNotConfiguredError = true; - this._logDebug('error', 'Error: Raven has not been configured.'); - } - return false; - } - return true; - }, - - afterLoad: function () { - // TODO: remove window dependence? - - // Attempt to initialize Raven on load - var RavenConfig = _window.RavenConfig; - if (RavenConfig) { - this.config(RavenConfig.dsn, RavenConfig.config).install(); - } - }, - - showReportDialog: function (options) { - if (!_document) // doesn't work without a document (React native) - return; - - options = options || {}; - - var lastEventId = options.eventId || this.lastEventId(); - if (!lastEventId) { - throw new RavenConfigError('Missing eventId'); - } - - var dsn = options.dsn || this._dsn; - if (!dsn) { - throw new RavenConfigError('Missing DSN'); - } - - var encode = encodeURIComponent; - var qs = ''; - qs += '?eventId=' + encode(lastEventId); - qs += '&dsn=' + encode(dsn); - - var user = options.user || this._globalContext.user; - if (user) { - if (user.name) qs += '&name=' + encode(user.name); - if (user.email) qs += '&email=' + encode(user.email); - } - - var globalServer = this._getGlobalServer(this._parseDSN(dsn)); - - var script = _document.createElement('script'); - script.async = true; - script.src = globalServer + '/api/embed/error-page/' + qs; - (_document.head || _document.body).appendChild(script); - }, - - /**** Private functions ****/ - _ignoreNextOnError: function () { - var self = this; - this._ignoreOnError += 1; - setTimeout(function () { - // onerror should trigger before setTimeout - self._ignoreOnError -= 1; - }); - }, - - _triggerEvent: function(eventType, options) { - // NOTE: `event` is a native browser thing, so let's avoid conflicting wiht it - var evt, key; - - if (!this._hasDocument) - return; - - options = options || {}; - - eventType = 'raven' + eventType.substr(0,1).toUpperCase() + eventType.substr(1); - - if (_document.createEvent) { - evt = _document.createEvent('HTMLEvents'); - evt.initEvent(eventType, true, true); - } else { - evt = _document.createEventObject(); - evt.eventType = eventType; - } - - for (key in options) if (hasKey(options, key)) { - evt[key] = options[key]; - } - - if (_document.createEvent) { - // IE9 if standards - _document.dispatchEvent(evt); - } else { - // IE8 regardless of Quirks or Standards - // IE9 if quirks - try { - _document.fireEvent('on' + evt.eventType.toLowerCase(), evt); - } catch(e) { - // Do nothing - } - } - }, - - /** - * Wraps addEventListener to capture UI breadcrumbs - * @param evtName the event name (e.g. "click") - * @returns {Function} - * @private - */ - _breadcrumbEventHandler: function(evtName) { - var self = this; - return function (evt) { - // reset keypress timeout; e.g. triggering a 'click' after - // a 'keypress' will reset the keypress debounce so that a new - // set of keypresses can be recorded - self._keypressTimeout = null; - - // It's possible this handler might trigger multiple times for the same - // event (e.g. event propagation through node ancestors). Ignore if we've - // already captured the event. - if (self._lastCapturedEvent === evt) - return; - - self._lastCapturedEvent = evt; - var elem = evt.target; - - var target; - - // try/catch htmlTreeAsString because it's particularly complicated, and - // just accessing the DOM incorrectly can throw an exception in some circumstances. - try { - target = htmlTreeAsString(elem); - } catch (e) { - target = '<unknown>'; - } - - self.captureBreadcrumb({ - category: 'ui.' + evtName, // e.g. ui.click, ui.input - message: target - }); - }; - }, - - /** - * Wraps addEventListener to capture keypress UI events - * @returns {Function} - * @private - */ - _keypressEventHandler: function() { - var self = this, - debounceDuration = 1000; // milliseconds - - // TODO: if somehow user switches keypress target before - // debounce timeout is triggered, we will only capture - // a single breadcrumb from the FIRST target (acceptable?) - return function (evt) { - var target = evt.target, - tagName = target && target.tagName; - - // only consider keypress events on actual input elements - // this will disregard keypresses targeting body (e.g. tabbing - // through elements, hotkeys, etc) - if (!tagName || tagName !== 'INPUT' && tagName !== 'TEXTAREA' && !target.isContentEditable) - return; - - // record first keypress in a series, but ignore subsequent - // keypresses until debounce clears - var timeout = self._keypressTimeout; - if (!timeout) { - self._breadcrumbEventHandler('input')(evt); - } - clearTimeout(timeout); - self._keypressTimeout = setTimeout(function () { - self._keypressTimeout = null; - }, debounceDuration); - }; - }, - - /** - * Captures a breadcrumb of type "navigation", normalizing input URLs - * @param to the originating URL - * @param from the target URL - * @private - */ - _captureUrlChange: function(from, to) { - var parsedLoc = parseUrl(this._location.href); - var parsedTo = parseUrl(to); - var parsedFrom = parseUrl(from); - - // because onpopstate only tells you the "new" (to) value of location.href, and - // not the previous (from) value, we need to track the value of the current URL - // state ourselves - this._lastHref = to; - - // Use only the path component of the URL if the URL matches the current - // document (almost all the time when using pushState) - if (parsedLoc.protocol === parsedTo.protocol && parsedLoc.host === parsedTo.host) - to = parsedTo.relative; - if (parsedLoc.protocol === parsedFrom.protocol && parsedLoc.host === parsedFrom.host) - from = parsedFrom.relative; - - this.captureBreadcrumb({ - category: 'navigation', - data: { - to: to, - from: from - } - }); - }, - - /** - * Install any queued plugins - */ - _instrumentTryCatch: function() { - var self = this; - - var wrappedBuiltIns = self._wrappedBuiltIns; - - function wrapTimeFn(orig) { - return function (fn, t) { // preserve arity - // Make a copy of the arguments to prevent deoptimization - // https://github.com/petkaantonov/bluebird/wiki/Optimization-killers#32-leaking-arguments - var args = new Array(arguments.length); - for(var i = 0; i < args.length; ++i) { - args[i] = arguments[i]; - } - var originalCallback = args[0]; - if (isFunction(originalCallback)) { - args[0] = self.wrap(originalCallback); - } - - // IE < 9 doesn't support .call/.apply on setInterval/setTimeout, but it - // also supports only two arguments and doesn't care what this is, so we - // can just call the original function directly. - if (orig.apply) { - return orig.apply(this, args); - } else { - return orig(args[0], args[1]); - } - }; - } - - var autoBreadcrumbs = this._globalOptions.autoBreadcrumbs; - - function wrapEventTarget(global) { - var proto = _window[global] && _window[global].prototype; - if (proto && proto.hasOwnProperty && proto.hasOwnProperty('addEventListener')) { - fill(proto, 'addEventListener', function(orig) { - return function (evtName, fn, capture, secure) { // preserve arity - try { - if (fn && fn.handleEvent) { - fn.handleEvent = self.wrap(fn.handleEvent); - } - } catch (err) { - // can sometimes get 'Permission denied to access property "handle Event' - } - - // More breadcrumb DOM capture ... done here and not in `_instrumentBreadcrumbs` - // so that we don't have more than one wrapper function - var before, - clickHandler, - keypressHandler; - - if (autoBreadcrumbs && autoBreadcrumbs.dom && (global === 'EventTarget' || global === 'Node')) { - // NOTE: generating multiple handlers per addEventListener invocation, should - // revisit and verify we can just use one (almost certainly) - clickHandler = self._breadcrumbEventHandler('click'); - keypressHandler = self._keypressEventHandler(); - before = function (evt) { - // need to intercept every DOM event in `before` argument, in case that - // same wrapped method is re-used for different events (e.g. mousemove THEN click) - // see #724 - if (!evt) return; - - if (evt.type === 'click') - return clickHandler(evt); - else if (evt.type === 'keypress') - return keypressHandler(evt); - }; - } - return orig.call(this, evtName, self.wrap(fn, undefined, before), capture, secure); - }; - }, wrappedBuiltIns); - fill(proto, 'removeEventListener', function (orig) { - return function (evt, fn, capture, secure) { - try { - fn = fn && (fn.__raven_wrapper__ ? fn.__raven_wrapper__ : fn); - } catch (e) { - // ignore, accessing __raven_wrapper__ will throw in some Selenium environments - } - return orig.call(this, evt, fn, capture, secure); - }; - }, wrappedBuiltIns); - } - } - - fill(_window, 'setTimeout', wrapTimeFn, wrappedBuiltIns); - fill(_window, 'setInterval', wrapTimeFn, wrappedBuiltIns); - if (_window.requestAnimationFrame) { - fill(_window, 'requestAnimationFrame', function (orig) { - return function (cb) { - return orig(self.wrap(cb)); - }; - }, wrappedBuiltIns); - } - - // event targets borrowed from bugsnag-js: - // https://github.com/bugsnag/bugsnag-js/blob/master/src/bugsnag.js#L666 - var eventTargets = ['EventTarget', 'Window', 'Node', 'ApplicationCache', 'AudioTrackList', 'ChannelMergerNode', 'CryptoOperation', 'EventSource', 'FileReader', 'HTMLUnknownElement', 'IDBDatabase', 'IDBRequest', 'IDBTransaction', 'KeyOperation', 'MediaController', 'MessagePort', 'ModalWindow', 'Notification', 'SVGElementInstance', 'Screen', 'TextTrack', 'TextTrackCue', 'TextTrackList', 'WebSocket', 'WebSocketWorker', 'Worker', 'XMLHttpRequest', 'XMLHttpRequestEventTarget', 'XMLHttpRequestUpload']; - for (var i = 0; i < eventTargets.length; i++) { - wrapEventTarget(eventTargets[i]); - } - - var $ = _window.jQuery || _window.$; - if ($ && $.fn && $.fn.ready) { - fill($.fn, 'ready', function (orig) { - return function (fn) { - return orig.call(this, self.wrap(fn)); - }; - }, wrappedBuiltIns); - } - }, - - - /** - * Instrument browser built-ins w/ breadcrumb capturing - * - XMLHttpRequests - * - DOM interactions (click/typing) - * - window.location changes - * - console - * - * Can be disabled or individually configured via the `autoBreadcrumbs` config option - */ - _instrumentBreadcrumbs: function () { - var self = this; - var autoBreadcrumbs = this._globalOptions.autoBreadcrumbs; - - var wrappedBuiltIns = self._wrappedBuiltIns; - - function wrapProp(prop, xhr) { - if (prop in xhr && isFunction(xhr[prop])) { - fill(xhr, prop, function (orig) { - return self.wrap(orig); - }); // intentionally don't track filled methods on XHR instances - } - } - - if (autoBreadcrumbs.xhr && 'XMLHttpRequest' in _window) { - var xhrproto = XMLHttpRequest.prototype; - fill(xhrproto, 'open', function(origOpen) { - return function (method, url) { // preserve arity - - // if Sentry key appears in URL, don't capture - if (isString(url) && url.indexOf(self._globalKey) === -1) { - this.__raven_xhr = { - method: method, - url: url, - status_code: null - }; - } - - return origOpen.apply(this, arguments); - }; - }, wrappedBuiltIns); - - fill(xhrproto, 'send', function(origSend) { - return function (data) { // preserve arity - var xhr = this; - - function onreadystatechangeHandler() { - if (xhr.__raven_xhr && (xhr.readyState === 1 || xhr.readyState === 4)) { - try { - // touching statusCode in some platforms throws - // an exception - xhr.__raven_xhr.status_code = xhr.status; - } catch (e) { /* do nothing */ } - self.captureBreadcrumb({ - type: 'http', - category: 'xhr', - data: xhr.__raven_xhr - }); - } - } - - var props = ['onload', 'onerror', 'onprogress']; - for (var j = 0; j < props.length; j++) { - wrapProp(props[j], xhr); - } - - if ('onreadystatechange' in xhr && isFunction(xhr.onreadystatechange)) { - fill(xhr, 'onreadystatechange', function (orig) { - return self.wrap(orig, undefined, onreadystatechangeHandler); - } /* intentionally don't track this instrumentation */); - } else { - // if onreadystatechange wasn't actually set by the page on this xhr, we - // are free to set our own and capture the breadcrumb - xhr.onreadystatechange = onreadystatechangeHandler; - } - - return origSend.apply(this, arguments); - }; - }, wrappedBuiltIns); - } - - if (autoBreadcrumbs.xhr && 'fetch' in _window) { - fill(_window, 'fetch', function(origFetch) { - return function (fn, t) { // preserve arity - // Make a copy of the arguments to prevent deoptimization - // https://github.com/petkaantonov/bluebird/wiki/Optimization-killers#32-leaking-arguments - var args = new Array(arguments.length); - for(var i = 0; i < args.length; ++i) { - args[i] = arguments[i]; - } - - var method = 'GET'; - - if (args[1] && args[1].method) { - method = args[1].method; - } - - var fetchData = { - method: method, - url: args[0], - status_code: null - }; - - self.captureBreadcrumb({ - type: 'http', - category: 'fetch', - data: fetchData - }); - - return origFetch.apply(this, args).then(function (response) { - fetchData.status_code = response.status; - - return response; - }); - }; - }, wrappedBuiltIns); - } - - // Capture breadcrumbs from any click that is unhandled / bubbled up all the way - // to the document. Do this before we instrument addEventListener. - if (autoBreadcrumbs.dom && this._hasDocument) { - if (_document.addEventListener) { - _document.addEventListener('click', self._breadcrumbEventHandler('click'), false); - _document.addEventListener('keypress', self._keypressEventHandler(), false); - } - else { - // IE8 Compatibility - _document.attachEvent('onclick', self._breadcrumbEventHandler('click')); - _document.attachEvent('onkeypress', self._keypressEventHandler()); - } - } - - // record navigation (URL) changes - // NOTE: in Chrome App environment, touching history.pushState, *even inside - // a try/catch block*, will cause Chrome to output an error to console.error - // borrowed from: https://github.com/angular/angular.js/pull/13945/files - var chrome = _window.chrome; - var isChromePackagedApp = chrome && chrome.app && chrome.app.runtime; - var hasPushState = !isChromePackagedApp && _window.history && history.pushState; - if (autoBreadcrumbs.location && hasPushState) { - // TODO: remove onpopstate handler on uninstall() - var oldOnPopState = _window.onpopstate; - _window.onpopstate = function () { - var currentHref = self._location.href; - self._captureUrlChange(self._lastHref, currentHref); - - if (oldOnPopState) { - return oldOnPopState.apply(this, arguments); - } - }; - - fill(history, 'pushState', function (origPushState) { - // note history.pushState.length is 0; intentionally not declaring - // params to preserve 0 arity - return function (/* state, title, url */) { - var url = arguments.length > 2 ? arguments[2] : undefined; - - // url argument is optional - if (url) { - // coerce to string (this is what pushState does) - self._captureUrlChange(self._lastHref, url + ''); - } - - return origPushState.apply(this, arguments); - }; - }, wrappedBuiltIns); - } - - if (autoBreadcrumbs.console && 'console' in _window && console.log) { - // console - var consoleMethodCallback = function (msg, data) { - self.captureBreadcrumb({ - message: msg, - level: data.level, - category: 'console' - }); - }; - - each(['debug', 'info', 'warn', 'error', 'log'], function (_, level) { - wrapConsoleMethod(console, level, consoleMethodCallback); - }); - } - - }, - - _restoreBuiltIns: function () { - // restore any wrapped builtins - var builtin; - while (this._wrappedBuiltIns.length) { - builtin = this._wrappedBuiltIns.shift(); - - var obj = builtin[0], - name = builtin[1], - orig = builtin[2]; - - obj[name] = orig; - } - }, - - _drainPlugins: function() { - var self = this; - - // FIX ME TODO - each(this._plugins, function(_, plugin) { - var installer = plugin[0]; - var args = plugin[1]; - installer.apply(self, [self].concat(args)); - }); - }, - - _parseDSN: function(str) { - var m = dsnPattern.exec(str), - dsn = {}, - i = 7; - - try { - while (i--) dsn[dsnKeys[i]] = m[i] || ''; - } catch(e) { - throw new RavenConfigError('Invalid DSN: ' + str); - } - - if (dsn.pass && !this._globalOptions.allowSecretKey) { - throw new RavenConfigError('Do not specify your secret key in the DSN. See: http://bit.ly/raven-secret-key'); - } - - return dsn; - }, - - _getGlobalServer: function(uri) { - // assemble the endpoint from the uri pieces - var globalServer = '//' + uri.host + - (uri.port ? ':' + uri.port : ''); - - if (uri.protocol) { - globalServer = uri.protocol + ':' + globalServer; - } - return globalServer; - }, - - _handleOnErrorStackInfo: function() { - // if we are intentionally ignoring errors via onerror, bail out - if (!this._ignoreOnError) { - this._handleStackInfo.apply(this, arguments); - } - }, - - _handleStackInfo: function(stackInfo, options) { - var frames = this._prepareFrames(stackInfo, options); - - this._triggerEvent('handle', { - stackInfo: stackInfo, - options: options - }); - - this._processException( - stackInfo.name, - stackInfo.message, - stackInfo.url, - stackInfo.lineno, - frames, - options - ); - }, - - _prepareFrames: function(stackInfo, options) { - var self = this; - var frames = []; - if (stackInfo.stack && stackInfo.stack.length) { - each(stackInfo.stack, function(i, stack) { - var frame = self._normalizeFrame(stack); - if (frame) { - frames.push(frame); - } - }); - - // e.g. frames captured via captureMessage throw - if (options && options.trimHeadFrames) { - for (var j = 0; j < options.trimHeadFrames && j < frames.length; j++) { - frames[j].in_app = false; - } - } - } - frames = frames.slice(0, this._globalOptions.stackTraceLimit); - return frames; - }, - - - _normalizeFrame: function(frame) { - if (!frame.url) return; - - // normalize the frames data - var normalized = { - filename: frame.url, - lineno: frame.line, - colno: frame.column, - 'function': frame.func || '?' - }; - - normalized.in_app = !( // determine if an exception came from outside of our app - // first we check the global includePaths list. - !!this._globalOptions.includePaths.test && !this._globalOptions.includePaths.test(normalized.filename) || - // Now we check for fun, if the function name is Raven or TraceKit - /(Raven|TraceKit)\./.test(normalized['function']) || - // finally, we do a last ditch effort and check for raven.min.js - /raven\.(min\.)?js$/.test(normalized.filename) - ); - - return normalized; - }, - - _processException: function(type, message, fileurl, lineno, frames, options) { - var stacktrace; - if (!!this._globalOptions.ignoreErrors.test && this._globalOptions.ignoreErrors.test(message)) return; - - message += ''; - - if (frames && frames.length) { - fileurl = frames[0].filename || fileurl; - // Sentry expects frames oldest to newest - // and JS sends them as newest to oldest - frames.reverse(); - stacktrace = {frames: frames}; - } else if (fileurl) { - stacktrace = { - frames: [{ - filename: fileurl, - lineno: lineno, - in_app: true - }] - }; - } - - if (!!this._globalOptions.ignoreUrls.test && this._globalOptions.ignoreUrls.test(fileurl)) return; - if (!!this._globalOptions.whitelistUrls.test && !this._globalOptions.whitelistUrls.test(fileurl)) return; - - var data = objectMerge({ - // sentry.interfaces.Exception - exception: { - values: [{ - type: type, - value: message, - stacktrace: stacktrace - }] - }, - culprit: fileurl - }, options); - - // Fire away! - this._send(data); - }, - - _trimPacket: function(data) { - // For now, we only want to truncate the two different messages - // but this could/should be expanded to just trim everything - var max = this._globalOptions.maxMessageLength; - if (data.message) { - data.message = truncate(data.message, max); - } - if (data.exception) { - var exception = data.exception.values[0]; - exception.value = truncate(exception.value, max); - } - - return data; - }, - - _getHttpData: function() { - if (!this._hasDocument || !_document.location || !_document.location.href) { - return; - } - - var httpData = { - headers: { - 'User-Agent': navigator.userAgent - } - }; - - httpData.url = _document.location.href; - - if (_document.referrer) { - httpData.headers.Referer = _document.referrer; - } - - return httpData; - }, - - - _send: function(data) { - var globalOptions = this._globalOptions; - - var baseData = { - project: this._globalProject, - logger: globalOptions.logger, - platform: 'javascript' - }, httpData = this._getHttpData(); - - if (httpData) { - baseData.request = httpData; - } - - // HACK: delete `trimHeadFrames` to prevent from appearing in outbound payload - if (data.trimHeadFrames) delete data.trimHeadFrames; - - data = objectMerge(baseData, data); - - // Merge in the tags and extra separately since objectMerge doesn't handle a deep merge - data.tags = objectMerge(objectMerge({}, this._globalContext.tags), data.tags); - data.extra = objectMerge(objectMerge({}, this._globalContext.extra), data.extra); - - // Send along our own collected metadata with extra - data.extra['session:duration'] = now() - this._startTime; - - if (this._breadcrumbs && this._breadcrumbs.length > 0) { - // intentionally make shallow copy so that additions - // to breadcrumbs aren't accidentally sent in this request - data.breadcrumbs = { - values: [].slice.call(this._breadcrumbs, 0) - }; - } - - // If there are no tags/extra, strip the key from the payload alltogther. - if (isEmptyObject(data.tags)) delete data.tags; - - if (this._globalContext.user) { - // sentry.interfaces.User - data.user = this._globalContext.user; - } - - // Include the environment if it's defined in globalOptions - if (globalOptions.environment) data.environment = globalOptions.environment; - - // Include the release if it's defined in globalOptions - if (globalOptions.release) data.release = globalOptions.release; - - // Include server_name if it's defined in globalOptions - if (globalOptions.serverName) data.server_name = globalOptions.serverName; - - if (isFunction(globalOptions.dataCallback)) { - data = globalOptions.dataCallback(data) || data; - } - - // Why?????????? - if (!data || isEmptyObject(data)) { - return; - } - - // Check if the request should be filtered or not - if (isFunction(globalOptions.shouldSendCallback) && !globalOptions.shouldSendCallback(data)) { - return; - } - - this._sendProcessedPayload(data); - }, - - _getUuid: function () { - return uuid4(); - }, - - _sendProcessedPayload: function(data, callback) { - var self = this; - var globalOptions = this._globalOptions; - - // Send along an event_id if not explicitly passed. - // This event_id can be used to reference the error within Sentry itself. - // Set lastEventId after we know the error should actually be sent - this._lastEventId = data.event_id || (data.event_id = this._getUuid()); - - // Try and clean up the packet before sending by truncating long values - data = this._trimPacket(data); - - this._logDebug('debug', 'Raven about to send:', data); - - if (!this.isSetup()) return; - - var auth = { - sentry_version: '7', - sentry_client: 'raven-js/' + this.VERSION, - sentry_key: this._globalKey - }; - if (this._globalSecret) { - auth.sentry_secret = this._globalSecret; - } - - var exception = data.exception && data.exception.values[0]; - this.captureBreadcrumb({ - category: 'sentry', - message: exception - ? (exception.type ? exception.type + ': ' : '') + exception.value - : data.message, - event_id: data.event_id, - level: data.level || 'error' // presume error unless specified - }); - - var url = this._globalEndpoint; - (globalOptions.transport || this._makeRequest).call(this, { - url: url, - auth: auth, - data: data, - options: globalOptions, - onSuccess: function success() { - self._triggerEvent('success', { - data: data, - src: url - }); - callback && callback(); - }, - onError: function failure(error) { - self._triggerEvent('failure', { - data: data, - src: url - }); - error = error || new Error('Raven send failed (no additional details provided)'); - callback && callback(error); - } - }); - }, - - _makeRequest: function(opts) { - var request = new XMLHttpRequest(); - - // if browser doesn't support CORS (e.g. IE7), we are out of luck - var hasCORS = - 'withCredentials' in request || - typeof XDomainRequest !== 'undefined'; - - if (!hasCORS) return; - - var url = opts.url; - function handler() { - if (request.status === 200) { - if (opts.onSuccess) { - opts.onSuccess(); - } - } else if (opts.onError) { - opts.onError(new Error('Sentry error code: ' + request.status)); - } - } - - if ('withCredentials' in request) { - request.onreadystatechange = function () { - if (request.readyState !== 4) { - return; - } - handler(); - }; - } else { - request = new XDomainRequest(); - // xdomainrequest cannot go http -> https (or vice versa), - // so always use protocol relative - url = url.replace(/^https?:/, ''); - - // onreadystatechange not supported by XDomainRequest - request.onload = handler; - } - - // NOTE: auth is intentionally sent as part of query string (NOT as custom - // HTTP header) so as to avoid preflight CORS requests - request.open('POST', url + '?' + urlencode(opts.auth)); - request.send(stringify(opts.data)); - }, - - _logDebug: function(level) { - if (this._originalConsoleMethods[level] && this.debug) { - // In IE<10 console methods do not have their own 'apply' method - Function.prototype.apply.call( - this._originalConsoleMethods[level], - this._originalConsole, - [].slice.call(arguments, 1) - ); - } - }, - - _mergeContext: function(key, context) { - if (isUndefined(context)) { - delete this._globalContext[key]; - } else { - this._globalContext[key] = objectMerge(this._globalContext[key] || {}, context); - } - } -}; - -/*------------------------------------------------ - * utils - * - * conditionally exported for test via Raven.utils - ================================================= - */ -var objectPrototype = Object.prototype; - -function isUndefined(what) { - return what === void 0; -} - -function isFunction(what) { - return typeof what === 'function'; -} - -function isString(what) { - return objectPrototype.toString.call(what) === '[object String]'; -} - -function isObject(what) { - return typeof what === 'object' && what !== null; -} - -function isEmptyObject(what) { - for (var _ in what) return false; // eslint-disable-line guard-for-in, no-unused-vars - return true; -} - -// Sorta yanked from https://github.com/joyent/node/blob/aa3b4b4/lib/util.js#L560 -// with some tiny modifications -function isError(what) { - var toString = objectPrototype.toString.call(what); - return isObject(what) && - toString === '[object Error]' || - toString === '[object Exception]' || // Firefox NS_ERROR_FAILURE Exceptions - what instanceof Error; -} - -function each(obj, callback) { - var i, j; - - if (isUndefined(obj.length)) { - for (i in obj) { - if (hasKey(obj, i)) { - callback.call(null, i, obj[i]); - } - } - } else { - j = obj.length; - if (j) { - for (i = 0; i < j; i++) { - callback.call(null, i, obj[i]); - } - } - } -} - -function objectMerge(obj1, obj2) { - if (!obj2) { - return obj1; - } - each(obj2, function(key, value){ - obj1[key] = value; - }); - return obj1; -} - -function truncate(str, max) { - return !max || str.length <= max ? str : str.substr(0, max) + '\u2026'; -} - -/** - * hasKey, a better form of hasOwnProperty - * Example: hasKey(MainHostObject, property) === true/false - * - * @param {Object} host object to check property - * @param {string} key to check - */ -function hasKey(object, key) { - return objectPrototype.hasOwnProperty.call(object, key); -} - -function joinRegExp(patterns) { - // Combine an array of regular expressions and strings into one large regexp - // Be mad. - var sources = [], - i = 0, len = patterns.length, - pattern; - - for (; i < len; i++) { - pattern = patterns[i]; - if (isString(pattern)) { - // If it's a string, we need to escape it - // Taken from: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions - sources.push(pattern.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, '\\$1')); - } else if (pattern && pattern.source) { - // If it's a regexp already, we want to extract the source - sources.push(pattern.source); - } - // Intentionally skip other cases - } - return new RegExp(sources.join('|'), 'i'); -} - -function urlencode(o) { - var pairs = []; - each(o, function(key, value) { - pairs.push(encodeURIComponent(key) + '=' + encodeURIComponent(value)); - }); - return pairs.join('&'); -} - -// borrowed from https://tools.ietf.org/html/rfc3986#appendix-B -// intentionally using regex and not <a/> href parsing trick because React Native and other -// environments where DOM might not be available -function parseUrl(url) { - var match = url.match(/^(([^:\/?#]+):)?(\/\/([^\/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?$/); - if (!match) return {}; - - // coerce to undefined values to empty string so we don't get 'undefined' - var query = match[6] || ''; - var fragment = match[8] || ''; - return { - protocol: match[2], - host: match[4], - path: match[5], - relative: match[5] + query + fragment // everything minus origin - }; -} -function uuid4() { - var crypto = _window.crypto || _window.msCrypto; - - if (!isUndefined(crypto) && crypto.getRandomValues) { - // Use window.crypto API if available - var arr = new Uint16Array(8); - crypto.getRandomValues(arr); - - // set 4 in byte 7 - arr[3] = arr[3] & 0xFFF | 0x4000; - // set 2 most significant bits of byte 9 to '10' - arr[4] = arr[4] & 0x3FFF | 0x8000; - - var pad = function(num) { - var v = num.toString(16); - while (v.length < 4) { - v = '0' + v; - } - return v; - }; - - return pad(arr[0]) + pad(arr[1]) + pad(arr[2]) + pad(arr[3]) + pad(arr[4]) + - pad(arr[5]) + pad(arr[6]) + pad(arr[7]); - } else { - // http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/2117523#2117523 - return 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - var r = Math.random()*16|0, - v = c === 'x' ? r : r&0x3|0x8; - return v.toString(16); - }); - } -} - -/** - * Given a child DOM element, returns a query-selector statement describing that - * and its ancestors - * e.g. [HTMLElement] => body > div > input#foo.btn[name=baz] - * @param elem - * @returns {string} - */ -function htmlTreeAsString(elem) { - /* eslint no-extra-parens:0*/ - var MAX_TRAVERSE_HEIGHT = 5, - MAX_OUTPUT_LEN = 80, - out = [], - height = 0, - len = 0, - separator = ' > ', - sepLength = separator.length, - nextStr; - - while (elem && height++ < MAX_TRAVERSE_HEIGHT) { - - nextStr = htmlElementAsString(elem); - // bail out if - // - nextStr is the 'html' element - // - the length of the string that would be created exceeds MAX_OUTPUT_LEN - // (ignore this limit if we are on the first iteration) - if (nextStr === 'html' || height > 1 && len + (out.length * sepLength) + nextStr.length >= MAX_OUTPUT_LEN) { - break; - } - - out.push(nextStr); - - len += nextStr.length; - elem = elem.parentNode; - } - - return out.reverse().join(separator); -} - -/** - * Returns a simple, query-selector representation of a DOM element - * e.g. [HTMLElement] => input#foo.btn[name=baz] - * @param HTMLElement - * @returns {string} - */ -function htmlElementAsString(elem) { - var out = [], - className, - classes, - key, - attr, - i; - - if (!elem || !elem.tagName) { - return ''; - } - - out.push(elem.tagName.toLowerCase()); - if (elem.id) { - out.push('#' + elem.id); - } - - className = elem.className; - if (className && isString(className)) { - classes = className.split(' '); - for (i = 0; i < classes.length; i++) { - out.push('.' + classes[i]); - } - } - var attrWhitelist = ['type', 'name', 'title', 'alt']; - for (i = 0; i < attrWhitelist.length; i++) { - key = attrWhitelist[i]; - attr = elem.getAttribute(key); - if (attr) { - out.push('[' + key + '="' + attr + '"]'); - } - } - return out.join(''); -} - -/** - * Polyfill a method - * @param obj object e.g. `document` - * @param name method name present on object e.g. `addEventListener` - * @param replacement replacement function - * @param track {optional} record instrumentation to an array - */ -function fill(obj, name, replacement, track) { - var orig = obj[name]; - obj[name] = replacement(orig); - if (track) { - track.push([obj, name, orig]); - } -} - -if (typeof __DEV__ !== 'undefined' && __DEV__) { - Raven.utils = { - isUndefined: isUndefined, - isFunction: isFunction, - isString: isString, - isObject: isObject, - isEmptyObject: isEmptyObject, - isError: isError, - each: each, - objectMerge: objectMerge, - truncate: truncate, - hasKey: hasKey, - joinRegExp: joinRegExp, - urlencode: urlencode, - uuid4: uuid4, - htmlTreeAsString: htmlTreeAsString, - htmlElementAsString: htmlElementAsString, - parseUrl: parseUrl, - fill: fill - }; -}; - -// Deprecations -Raven.prototype.setUser = Raven.prototype.setUserContext; -Raven.prototype.setReleaseContext = Raven.prototype.setRelease; - -module.exports = Raven; - -}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) -},{"1":1,"2":2,"3":3,"6":6}],5:[function(_dereq_,module,exports){ -(function (global){ -/** - * Enforces a single instance of the Raven client, and the - * main entry point for Raven. If you are a consumer of the - * Raven library, you SHOULD load this file (vs raven.js). - **/ - -'use strict'; - -var RavenConstructor = _dereq_(4); - -// This is to be defensive in environments where window does not exist (see https://github.com/getsentry/raven-js/pull/785) -var _window = typeof window !== 'undefined' ? window - : typeof global !== 'undefined' ? global - : typeof self !== 'undefined' ? self - : {}; -var _Raven = _window.Raven; - -var Raven = new RavenConstructor(); - -/* - * Allow multiple versions of Raven to be installed. - * Strip Raven from the global context and returns the instance. - * - * @return {Raven} - */ -Raven.noConflict = function () { - _window.Raven = _Raven; - return Raven; -}; - -Raven.afterLoad(); - -module.exports = Raven; - -}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) -},{"4":4}],6:[function(_dereq_,module,exports){ -(function (global){ -'use strict'; - -/* - TraceKit - Cross brower stack traces - github.com/occ/TraceKit - MIT license -*/ - -var TraceKit = { - collectWindowErrors: true, - debug: false -}; - -// This is to be defensive in environments where window does not exist (see https://github.com/getsentry/raven-js/pull/785) -var _window = typeof window !== 'undefined' ? window - : typeof global !== 'undefined' ? global - : typeof self !== 'undefined' ? self - : {}; - -// global reference to slice -var _slice = [].slice; -var UNKNOWN_FUNCTION = '?'; - -// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#Error_types -var ERROR_TYPES_RE = /^(?:Uncaught (?:exception: )?)?((?:Eval|Internal|Range|Reference|Syntax|Type|URI)Error): ?(.*)$/; - -function getLocationHref() { - if (typeof document === 'undefined') - return ''; - - return document.location.href; -} - -/** - * TraceKit.report: cross-browser processing of unhandled exceptions - * - * Syntax: - * TraceKit.report.subscribe(function(stackInfo) { ... }) - * TraceKit.report.unsubscribe(function(stackInfo) { ... }) - * TraceKit.report(exception) - * try { ...code... } catch(ex) { TraceKit.report(ex); } - * - * Supports: - * - Firefox: full stack trace with line numbers, plus column number - * on top frame; column number is not guaranteed - * - Opera: full stack trace with line and column numbers - * - Chrome: full stack trace with line and column numbers - * - Safari: line and column number for the top frame only; some frames - * may be missing, and column number is not guaranteed - * - IE: line and column number for the top frame only; some frames - * may be missing, and column number is not guaranteed - * - * In theory, TraceKit should work on all of the following versions: - * - IE5.5+ (only 8.0 tested) - * - Firefox 0.9+ (only 3.5+ tested) - * - Opera 7+ (only 10.50 tested; versions 9 and earlier may require - * Exceptions Have Stacktrace to be enabled in opera:config) - * - Safari 3+ (only 4+ tested) - * - Chrome 1+ (only 5+ tested) - * - Konqueror 3.5+ (untested) - * - * Requires TraceKit.computeStackTrace. - * - * Tries to catch all unhandled exceptions and report them to the - * subscribed handlers. Please note that TraceKit.report will rethrow the - * exception. This is REQUIRED in order to get a useful stack trace in IE. - * If the exception does not reach the top of the browser, you will only - * get a stack trace from the point where TraceKit.report was called. - * - * Handlers receive a stackInfo object as described in the - * TraceKit.computeStackTrace docs. - */ -TraceKit.report = (function reportModuleWrapper() { - var handlers = [], - lastArgs = null, - lastException = null, - lastExceptionStack = null; - - /** - * Add a crash handler. - * @param {Function} handler - */ - function subscribe(handler) { - installGlobalHandler(); - handlers.push(handler); - } - - /** - * Remove a crash handler. - * @param {Function} handler - */ - function unsubscribe(handler) { - for (var i = handlers.length - 1; i >= 0; --i) { - if (handlers[i] === handler) { - handlers.splice(i, 1); - } - } - } - - /** - * Remove all crash handlers. - */ - function unsubscribeAll() { - uninstallGlobalHandler(); - handlers = []; - } - - /** - * Dispatch stack information to all handlers. - * @param {Object.<string, *>} stack - */ - function notifyHandlers(stack, isWindowError) { - var exception = null; - if (isWindowError && !TraceKit.collectWindowErrors) { - return; - } - for (var i in handlers) { - if (handlers.hasOwnProperty(i)) { - try { - handlers[i].apply(null, [stack].concat(_slice.call(arguments, 2))); - } catch (inner) { - exception = inner; - } - } - } - - if (exception) { - throw exception; - } - } - - var _oldOnerrorHandler, _onErrorHandlerInstalled; - - /** - * Ensures all global unhandled exceptions are recorded. - * Supported by Gecko and IE. - * @param {string} message Error message. - * @param {string} url URL of script that generated the exception. - * @param {(number|string)} lineNo The line number at which the error - * occurred. - * @param {?(number|string)} colNo The column number at which the error - * occurred. - * @param {?Error} ex The actual Error object. - */ - function traceKitWindowOnError(message, url, lineNo, colNo, ex) { - var stack = null; - - if (lastExceptionStack) { - TraceKit.computeStackTrace.augmentStackTraceWithInitialElement(lastExceptionStack, url, lineNo, message); - processLastException(); - } else if (ex) { - // New chrome and blink send along a real error object - // Let's just report that like a normal error. - // See: https://mikewest.org/2013/08/debugging-runtime-errors-with-window-onerror - stack = TraceKit.computeStackTrace(ex); - notifyHandlers(stack, true); - } else { - var location = { - 'url': url, - 'line': lineNo, - 'column': colNo - }; - - var name = undefined; - var msg = message; // must be new var or will modify original `arguments` - var groups; - if ({}.toString.call(message) === '[object String]') { - var groups = message.match(ERROR_TYPES_RE); - if (groups) { - name = groups[1]; - msg = groups[2]; - } - } - - location.func = UNKNOWN_FUNCTION; - - stack = { - 'name': name, - 'message': msg, - 'url': getLocationHref(), - 'stack': [location] - }; - notifyHandlers(stack, true); - } - - if (_oldOnerrorHandler) { - return _oldOnerrorHandler.apply(this, arguments); - } - - return false; - } - - function installGlobalHandler () - { - if (_onErrorHandlerInstalled) { - return; - } - _oldOnerrorHandler = _window.onerror; - _window.onerror = traceKitWindowOnError; - _onErrorHandlerInstalled = true; - } - - function uninstallGlobalHandler () - { - if (!_onErrorHandlerInstalled) { - return; - } - _window.onerror = _oldOnerrorHandler; - _onErrorHandlerInstalled = false; - _oldOnerrorHandler = undefined; - } - - function processLastException() { - var _lastExceptionStack = lastExceptionStack, - _lastArgs = lastArgs; - lastArgs = null; - lastExceptionStack = null; - lastException = null; - notifyHandlers.apply(null, [_lastExceptionStack, false].concat(_lastArgs)); - } - - /** - * Reports an unhandled Error to TraceKit. - * @param {Error} ex - * @param {?boolean} rethrow If false, do not re-throw the exception. - * Only used for window.onerror to not cause an infinite loop of - * rethrowing. - */ - function report(ex, rethrow) { - var args = _slice.call(arguments, 1); - if (lastExceptionStack) { - if (lastException === ex) { - return; // already caught by an inner catch block, ignore - } else { - processLastException(); - } - } - - var stack = TraceKit.computeStackTrace(ex); - lastExceptionStack = stack; - lastException = ex; - lastArgs = args; - - // If the stack trace is incomplete, wait for 2 seconds for - // slow slow IE to see if onerror occurs or not before reporting - // this exception; otherwise, we will end up with an incomplete - // stack trace - setTimeout(function () { - if (lastException === ex) { - processLastException(); - } - }, (stack.incomplete ? 2000 : 0)); - - if (rethrow !== false) { - throw ex; // re-throw to propagate to the top level (and cause window.onerror) - } - } - - report.subscribe = subscribe; - report.unsubscribe = unsubscribe; - report.uninstall = unsubscribeAll; - return report; -}()); - -/** - * TraceKit.computeStackTrace: cross-browser stack traces in JavaScript - * - * Syntax: - * s = TraceKit.computeStackTrace(exception) // consider using TraceKit.report instead (see below) - * Returns: - * s.name - exception name - * s.message - exception message - * s.stack[i].url - JavaScript or HTML file URL - * s.stack[i].func - function name, or empty for anonymous functions (if guessing did not work) - * s.stack[i].args - arguments passed to the function, if known - * s.stack[i].line - line number, if known - * s.stack[i].column - column number, if known - * - * Supports: - * - Firefox: full stack trace with line numbers and unreliable column - * number on top frame - * - Opera 10: full stack trace with line and column numbers - * - Opera 9-: full stack trace with line numbers - * - Chrome: full stack trace with line and column numbers - * - Safari: line and column number for the topmost stacktrace element - * only - * - IE: no line numbers whatsoever - * - * Tries to guess names of anonymous functions by looking for assignments - * in the source code. In IE and Safari, we have to guess source file names - * by searching for function bodies inside all page scripts. This will not - * work for scripts that are loaded cross-domain. - * Here be dragons: some function names may be guessed incorrectly, and - * duplicate functions may be mismatched. - * - * TraceKit.computeStackTrace should only be used for tracing purposes. - * Logging of unhandled exceptions should be done with TraceKit.report, - * which builds on top of TraceKit.computeStackTrace and provides better - * IE support by utilizing the window.onerror event to retrieve information - * about the top of the stack. - * - * Note: In IE and Safari, no stack trace is recorded on the Error object, - * so computeStackTrace instead walks its *own* chain of callers. - * This means that: - * * in Safari, some methods may be missing from the stack trace; - * * in IE, the topmost function in the stack trace will always be the - * caller of computeStackTrace. - * - * This is okay for tracing (because you are likely to be calling - * computeStackTrace from the function you want to be the topmost element - * of the stack trace anyway), but not okay for logging unhandled - * exceptions (because your catch block will likely be far away from the - * inner function that actually caused the exception). - * - */ -TraceKit.computeStackTrace = (function computeStackTraceWrapper() { - /** - * Escapes special characters, except for whitespace, in a string to be - * used inside a regular expression as a string literal. - * @param {string} text The string. - * @return {string} The escaped string literal. - */ - function escapeRegExp(text) { - return text.replace(/[\-\[\]{}()*+?.,\\\^$|#]/g, '\\$&'); - } - - /** - * Escapes special characters in a string to be used inside a regular - * expression as a string literal. Also ensures that HTML entities will - * be matched the same as their literal friends. - * @param {string} body The string. - * @return {string} The escaped string. - */ - function escapeCodeAsRegExpForMatchingInsideHTML(body) { - return escapeRegExp(body).replace('<', '(?:<|<)').replace('>', '(?:>|>)').replace('&', '(?:&|&)').replace('"', '(?:"|")').replace(/\s+/g, '\\s+'); - } - - // Contents of Exception in various browsers. - // - // SAFARI: - // ex.message = Can't find variable: qq - // ex.line = 59 - // ex.sourceId = 580238192 - // ex.sourceURL = http://... - // ex.expressionBeginOffset = 96 - // ex.expressionCaretOffset = 98 - // ex.expressionEndOffset = 98 - // ex.name = ReferenceError - // - // FIREFOX: - // ex.message = qq is not defined - // ex.fileName = http://... - // ex.lineNumber = 59 - // ex.columnNumber = 69 - // ex.stack = ...stack trace... (see the example below) - // ex.name = ReferenceError - // - // CHROME: - // ex.message = qq is not defined - // ex.name = ReferenceError - // ex.type = not_defined - // ex.arguments = ['aa'] - // ex.stack = ...stack trace... - // - // INTERNET EXPLORER: - // ex.message = ... - // ex.name = ReferenceError - // - // OPERA: - // ex.message = ...message... (see the example below) - // ex.name = ReferenceError - // ex.opera#sourceloc = 11 (pretty much useless, duplicates the info in ex.message) - // ex.stacktrace = n/a; see 'opera:config#UserPrefs|Exceptions Have Stacktrace' - - /** - * Computes stack trace information from the stack property. - * Chrome and Gecko use this property. - * @param {Error} ex - * @return {?Object.<string, *>} Stack trace information. - */ - function computeStackTraceFromStackProp(ex) { - if (typeof ex.stack === 'undefined' || !ex.stack) return; - - var chrome = /^\s*at (.*?) ?\(((?:file|https?|blob|chrome-extension|native|eval|<anonymous>).*?)(?::(\d+))?(?::(\d+))?\)?\s*$/i, - gecko = /^\s*(.*?)(?:\((.*?)\))?(?:^|@)((?:file|https?|blob|chrome|\[native).*?)(?::(\d+))?(?::(\d+))?\s*$/i, - winjs = /^\s*at (?:((?:\[object object\])?.+) )?\(?((?:file|ms-appx|https?|blob):.*?):(\d+)(?::(\d+))?\)?\s*$/i, - lines = ex.stack.split('\n'), - stack = [], - parts, - element, - reference = /^(.*) is undefined$/.exec(ex.message); - - for (var i = 0, j = lines.length; i < j; ++i) { - if ((parts = chrome.exec(lines[i]))) { - var isNative = parts[2] && parts[2].indexOf('native') !== -1; - element = { - 'url': !isNative ? parts[2] : null, - 'func': parts[1] || UNKNOWN_FUNCTION, - 'args': isNative ? [parts[2]] : [], - 'line': parts[3] ? +parts[3] : null, - 'column': parts[4] ? +parts[4] : null - }; - } else if ( parts = winjs.exec(lines[i]) ) { - element = { - 'url': parts[2], - 'func': parts[1] || UNKNOWN_FUNCTION, - 'args': [], - 'line': +parts[3], - 'column': parts[4] ? +parts[4] : null - }; - } else if ((parts = gecko.exec(lines[i]))) { - element = { - 'url': parts[3], - 'func': parts[1] || UNKNOWN_FUNCTION, - 'args': parts[2] ? parts[2].split(',') : [], - 'line': parts[4] ? +parts[4] : null, - 'column': parts[5] ? +parts[5] : null - }; - } else { - continue; - } - - if (!element.func && element.line) { - element.func = UNKNOWN_FUNCTION; - } - - stack.push(element); - } - - if (!stack.length) { - return null; - } - - if (!stack[0].column && typeof ex.columnNumber !== 'undefined') { - // FireFox uses this awesome columnNumber property for its top frame - // Also note, Firefox's column number is 0-based and everything else expects 1-based, - // so adding 1 - stack[0].column = ex.columnNumber + 1; - } - - return { - 'name': ex.name, - 'message': ex.message, - 'url': getLocationHref(), - 'stack': stack - }; - } - - /** - * Adds information about the first frame to incomplete stack traces. - * Safari and IE require this to get complete data on the first frame. - * @param {Object.<string, *>} stackInfo Stack trace information from - * one of the compute* methods. - * @param {string} url The URL of the script that caused an error. - * @param {(number|string)} lineNo The line number of the script that - * caused an error. - * @param {string=} message The error generated by the browser, which - * hopefully contains the name of the object that caused the error. - * @return {boolean} Whether or not the stack information was - * augmented. - */ - function augmentStackTraceWithInitialElement(stackInfo, url, lineNo, message) { - var initial = { - 'url': url, - 'line': lineNo - }; - - if (initial.url && initial.line) { - stackInfo.incomplete = false; - - if (!initial.func) { - initial.func = UNKNOWN_FUNCTION; - } - - if (stackInfo.stack.length > 0) { - if (stackInfo.stack[0].url === initial.url) { - if (stackInfo.stack[0].line === initial.line) { - return false; // already in stack trace - } else if (!stackInfo.stack[0].line && stackInfo.stack[0].func === initial.func) { - stackInfo.stack[0].line = initial.line; - return false; - } - } - } - - stackInfo.stack.unshift(initial); - stackInfo.partial = true; - return true; - } else { - stackInfo.incomplete = true; - } - - return false; - } - - /** - * Computes stack trace information by walking the arguments.caller - * chain at the time the exception occurred. This will cause earlier - * frames to be missed but is the only way to get any stack trace in - * Safari and IE. The top frame is restored by - * {@link augmentStackTraceWithInitialElement}. - * @param {Error} ex - * @return {?Object.<string, *>} Stack trace information. - */ - function computeStackTraceByWalkingCallerChain(ex, depth) { - var functionName = /function\s+([_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*)?\s*\(/i, - stack = [], - funcs = {}, - recursion = false, - parts, - item, - source; - - for (var curr = computeStackTraceByWalkingCallerChain.caller; curr && !recursion; curr = curr.caller) { - if (curr === computeStackTrace || curr === TraceKit.report) { - // console.log('skipping internal function'); - continue; - } - - item = { - 'url': null, - 'func': UNKNOWN_FUNCTION, - 'line': null, - 'column': null - }; - - if (curr.name) { - item.func = curr.name; - } else if ((parts = functionName.exec(curr.toString()))) { - item.func = parts[1]; - } - - if (typeof item.func === 'undefined') { - try { - item.func = parts.input.substring(0, parts.input.indexOf('{')); - } catch (e) { } - } - - if (funcs['' + curr]) { - recursion = true; - }else{ - funcs['' + curr] = true; - } - - stack.push(item); - } - - if (depth) { - // console.log('depth is ' + depth); - // console.log('stack is ' + stack.length); - stack.splice(0, depth); - } - - var result = { - 'name': ex.name, - 'message': ex.message, - 'url': getLocationHref(), - 'stack': stack - }; - augmentStackTraceWithInitialElement(result, ex.sourceURL || ex.fileName, ex.line || ex.lineNumber, ex.message || ex.description); - return result; - } - - /** - * Computes a stack trace for an exception. - * @param {Error} ex - * @param {(string|number)=} depth - */ - function computeStackTrace(ex, depth) { - var stack = null; - depth = (depth == null ? 0 : +depth); - - try { - stack = computeStackTraceFromStackProp(ex); - if (stack) { - return stack; - } - } catch (e) { - if (TraceKit.debug) { - throw e; - } - } - - try { - stack = computeStackTraceByWalkingCallerChain(ex, depth + 1); - if (stack) { - return stack; - } - } catch (e) { - if (TraceKit.debug) { - throw e; - } - } - - return { - 'name': ex.name, - 'message': ex.message, - 'url': getLocationHref() - }; - } - - computeStackTrace.augmentStackTraceWithInitialElement = augmentStackTraceWithInitialElement; - computeStackTrace.computeStackTraceFromStackProp = computeStackTraceFromStackProp; - - return computeStackTrace; -}()); - -module.exports = TraceKit; - -}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) -},{}]},{},[5])(5) -}); diff --git a/yarn.lock b/yarn.lock index f254668646c54..46f528f0bec5b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2730,7 +2730,7 @@ json-stable-stringify@^1.0.0, json-stable-stringify@^1.0.1: dependencies: jsonify "~0.0.0" -json-stringify-safe@~5.0.1: +json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" @@ -3604,6 +3604,12 @@ raphael@^2.2.7: dependencies: eve-raphael "0.5.0" +raven-js@^3.14.0: + version "3.14.0" + resolved "https://registry.yarnpkg.com/raven-js/-/raven-js-3.14.0.tgz#94dda81d975fdc4a42f193db437cf70021d654e0" + dependencies: + json-stringify-safe "^5.0.1" + raw-body@~2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.2.0.tgz#994976cf6a5096a41162840492f0bdc5d6e7fb96" -- GitLab From 13b60eb75b99abb23f41c0d899d6e40eefa641cc Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Thu, 13 Apr 2017 17:17:41 +0100 Subject: [PATCH 044/363] [ci skip] Index and singleton improvements with some more unit, more to come --- app/assets/javascripts/raven/index.js | 6 ++- app/assets/javascripts/raven/raven_config.js | 25 +++++----- spec/helpers/sentry_helper_spec.rb | 15 ++++-- spec/javascripts/raven/index_spec.js | 42 ++++++++++++++--- spec/javascripts/raven/raven_config_spec.js | 13 +----- spec/javascripts/spec_helper.js | 49 -------------------- 6 files changed, 66 insertions(+), 84 deletions(-) delete mode 100644 spec/javascripts/spec_helper.js diff --git a/app/assets/javascripts/raven/index.js b/app/assets/javascripts/raven/index.js index 6cc81248e6b23..373f9f29c7980 100644 --- a/app/assets/javascripts/raven/index.js +++ b/app/assets/javascripts/raven/index.js @@ -1,10 +1,12 @@ import RavenConfig from './raven_config'; -RavenConfig.init({ +const index = RavenConfig.init.bind(RavenConfig, { sentryDsn: gon.sentry_dsn, currentUserId: gon.current_user_id, whitelistUrls: [gon.gitlab_url], isProduction: gon.is_production, }); -export default RavenConfig; +index(); + +export default index; diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/raven/raven_config.js index 5510dd2752d58..1157a10d96f45 100644 --- a/app/assets/javascripts/raven/raven_config.js +++ b/app/assets/javascripts/raven/raven_config.js @@ -1,32 +1,35 @@ import Raven from 'raven-js'; +import $ from 'jquery'; -class RavenConfig { - static init(options = {}) { +const RavenConfig = { + init(options = {}) { this.options = options; this.configure(); this.bindRavenErrors(); if (this.options.currentUserId) this.setUser(); - } - static configure() { + return this; + }, + + configure() { Raven.config(this.options.sentryDsn, { whitelistUrls: this.options.whitelistUrls, environment: this.options.isProduction ? 'production' : 'development', }).install(); - } + }, - static setUser() { + setUser() { Raven.setUserContext({ id: this.options.currentUserId, }); - } + }, - static bindRavenErrors() { + bindRavenErrors() { $(document).on('ajaxError.raven', this.handleRavenErrors); - } + }, - static handleRavenErrors(event, req, config, err) { + handleRavenErrors(event, req, config, err) { const error = err || req.statusText; Raven.captureMessage(error, { @@ -40,7 +43,7 @@ class RavenConfig { event, }, }); - } + }, } export default RavenConfig; diff --git a/spec/helpers/sentry_helper_spec.rb b/spec/helpers/sentry_helper_spec.rb index 35ecf9355e165..ff218235cd1d3 100644 --- a/spec/helpers/sentry_helper_spec.rb +++ b/spec/helpers/sentry_helper_spec.rb @@ -3,13 +3,20 @@ describe SentryHelper do describe '#sentry_dsn_public' do it 'returns nil if no sentry_dsn is set' do - allow(ApplicationSetting.current).to receive(:sentry_dsn).and_return(nil) - expect(helper.sentry_dsn_public).to eq(nil) + mock_sentry_dsn(nil) + + expect(helper.sentry_dsn_public).to eq nil end it 'returns the uri string with no password if sentry_dsn is set' do - allow(ApplicationSetting.current).to receive(:sentry_dsn).and_return('https://test:dsn@host/path') - expect(helper.sentry_dsn_public).to eq('https://test@host/path') + mock_sentry_dsn('https://test:dsn@host/path') + + expect(helper.sentry_dsn_public).to eq 'https://test@host/path' end end + + def mock_sentry_dsn(value) + allow_message_expectations_on_nil + allow(ApplicationSetting.current).to receive(:sentry_dsn).and_return(value) + end end diff --git a/spec/javascripts/raven/index_spec.js b/spec/javascripts/raven/index_spec.js index 51e84a6dbeea4..efde37b1f8ee6 100644 --- a/spec/javascripts/raven/index_spec.js +++ b/spec/javascripts/raven/index_spec.js @@ -1,11 +1,41 @@ -import RavenConfig from '~/raven/index'; +import RavenConfig from '~/raven/raven_config'; +import index from '~/raven/index'; -describe('RavenConfig options', () => { - it('should set sentryDsn'); +fdescribe('RavenConfig options', () => { + let sentryDsn; + let currentUserId; + let gitlabUrl; + let isProduction; + let indexReturnValue; - it('should set currentUserId'); + beforeEach(() => { + sentryDsn = 'sentryDsn'; + currentUserId = 'currentUserId'; + gitlabUrl = 'gitlabUrl'; + isProduction = 'isProduction'; - it('should set whitelistUrls'); + window.gon = { + sentry_dsn: sentryDsn, + current_user_id: currentUserId, + gitlab_url: gitlabUrl, + is_production: isProduction, + }; - it('should set isProduction'); + spyOn(RavenConfig.init, 'bind'); + + indexReturnValue = index(); + }); + + it('should init with .sentryDsn, .currentUserId, .whitelistUrls and .isProduction', () => { + expect(RavenConfig.init.bind).toHaveBeenCalledWith(RavenConfig, { + sentryDsn, + currentUserId, + whitelistUrls: [gitlabUrl], + isProduction, + }); + }); + + it('should return RavenConfig', () => { + expect(indexReturnValue).toBe(RavenConfig); + }); }); diff --git a/spec/javascripts/raven/raven_config_spec.js b/spec/javascripts/raven/raven_config_spec.js index 2b63f56d71904..64cf63702bc2c 100644 --- a/spec/javascripts/raven/raven_config_spec.js +++ b/spec/javascripts/raven/raven_config_spec.js @@ -1,8 +1,7 @@ import Raven from 'raven-js'; import RavenConfig from '~/raven/raven_config'; -import ClassSpecHelper from '../helpers/class_spec_helper'; -fdescribe('RavenConfig', () => { +describe('RavenConfig', () => { describe('init', () => { beforeEach(() => { spyOn(RavenConfig, 'configure'); @@ -10,8 +9,6 @@ fdescribe('RavenConfig', () => { spyOn(RavenConfig, 'setUser'); }); - ClassSpecHelper.itShouldBeAStaticMethod(RavenConfig, 'init'); - describe('when called', () => { let options; @@ -58,8 +55,6 @@ fdescribe('RavenConfig', () => { }); describe('configure', () => { - ClassSpecHelper.itShouldBeAStaticMethod(RavenConfig, 'configure'); - describe('when called', () => { let options; let raven; @@ -112,24 +107,18 @@ fdescribe('RavenConfig', () => { }); describe('setUser', () => { - ClassSpecHelper.itShouldBeAStaticMethod(RavenConfig, 'setUser'); - describe('when called', () => { beforeEach(() => {}); }); }); describe('bindRavenErrors', () => { - ClassSpecHelper.itShouldBeAStaticMethod(RavenConfig, 'bindRavenErrors'); - describe('when called', () => { beforeEach(() => {}); }); }); describe('handleRavenErrors', () => { - ClassSpecHelper.itShouldBeAStaticMethod(RavenConfig, 'handleRavenErrors'); - describe('when called', () => { beforeEach(() => {}); }); diff --git a/spec/javascripts/spec_helper.js b/spec/javascripts/spec_helper.js deleted file mode 100644 index ee6e6279ac9fc..0000000000000 --- a/spec/javascripts/spec_helper.js +++ /dev/null @@ -1,49 +0,0 @@ -/* eslint-disable space-before-function-paren */ -// PhantomJS (Teaspoons default driver) doesn't have support for -// Function.prototype.bind, which has caused confusion. Use this polyfill to -// avoid the confusion. -/*= require support/bind-poly */ - -// You can require your own javascript files here. By default this will include -// everything in application, however you may get better load performance if you -// require the specific files that are being used in the spec that tests them. -/*= require jquery */ -/*= require jquery.turbolinks */ -/*= require bootstrap */ -/*= require underscore */ -/*= require es6-promise.auto */ - -// Teaspoon includes some support files, but you can use anything from your own -// support path too. -// require support/jasmine-jquery-1.7.0 -// require support/jasmine-jquery-2.0.0 -/*= require support/jasmine-jquery-2.1.0 */ - -// require support/sinon -// require support/your-support-file -// Deferring execution -// If you're using CommonJS, RequireJS or some other asynchronous library you can -// defer execution. Call Teaspoon.execute() after everything has been loaded. -// Simple example of a timeout: -// Teaspoon.defer = true -// setTimeout(Teaspoon.execute, 1000) -// Matching files -// By default Teaspoon will look for files that match -// _spec.{js,js.es6}. Add a filename_spec.js file in your spec path -// and it'll be included in the default suite automatically. If you want to -// customize suites, check out the configuration in teaspoon_env.rb -// Manifest -// If you'd rather require your spec files manually (to control order for -// instance) you can disable the suite matcher in the configuration and use this -// file as a manifest. -// For more information: http://github.com/modeset/teaspoon - -// set our fixtures path -jasmine.getFixtures().fixturesPath = '/teaspoon/fixtures'; -jasmine.getJSONFixtures().fixturesPath = '/teaspoon/fixtures'; - -// defined in ActionDispatch::TestRequest -// see https://github.com/rails/rails/blob/v4.2.7.1/actionpack/lib/action_dispatch/testing/test_request.rb#L7 -window.gl = window.gl || {}; -window.gl.TEST_HOST = 'http://test.host'; -window.gon = window.gon || {}; -- GitLab From 067361327bba0cb7a8b28190485699da7deb22bb Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 14 Apr 2017 21:09:16 +0100 Subject: [PATCH 045/363] Updated units --- app/assets/javascripts/raven/index.js | 16 +- app/assets/javascripts/raven/raven_config.js | 4 +- spec/javascripts/raven/index_spec.js | 6 +- spec/javascripts/raven/raven_config_spec.js | 154 ++++++++++--------- 4 files changed, 96 insertions(+), 84 deletions(-) diff --git a/app/assets/javascripts/raven/index.js b/app/assets/javascripts/raven/index.js index 373f9f29c7980..3c5656040b9e1 100644 --- a/app/assets/javascripts/raven/index.js +++ b/app/assets/javascripts/raven/index.js @@ -1,11 +1,15 @@ import RavenConfig from './raven_config'; -const index = RavenConfig.init.bind(RavenConfig, { - sentryDsn: gon.sentry_dsn, - currentUserId: gon.current_user_id, - whitelistUrls: [gon.gitlab_url], - isProduction: gon.is_production, -}); +const index = function index() { + RavenConfig.init({ + sentryDsn: gon.sentry_dsn, + currentUserId: gon.current_user_id, + whitelistUrls: [gon.gitlab_url], + isProduction: gon.is_production, + }); + + return RavenConfig; +}; index(); diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/raven/raven_config.js index 1157a10d96f45..bb9be7cb196fb 100644 --- a/app/assets/javascripts/raven/raven_config.js +++ b/app/assets/javascripts/raven/raven_config.js @@ -8,8 +8,6 @@ const RavenConfig = { this.configure(); this.bindRavenErrors(); if (this.options.currentUserId) this.setUser(); - - return this; }, configure() { @@ -44,6 +42,6 @@ const RavenConfig = { }, }); }, -} +}; export default RavenConfig; diff --git a/spec/javascripts/raven/index_spec.js b/spec/javascripts/raven/index_spec.js index efde37b1f8ee6..85ec1de4e4efa 100644 --- a/spec/javascripts/raven/index_spec.js +++ b/spec/javascripts/raven/index_spec.js @@ -1,7 +1,7 @@ import RavenConfig from '~/raven/raven_config'; import index from '~/raven/index'; -fdescribe('RavenConfig options', () => { +describe('RavenConfig options', () => { let sentryDsn; let currentUserId; let gitlabUrl; @@ -21,13 +21,13 @@ fdescribe('RavenConfig options', () => { is_production: isProduction, }; - spyOn(RavenConfig.init, 'bind'); + spyOn(RavenConfig, 'init'); indexReturnValue = index(); }); it('should init with .sentryDsn, .currentUserId, .whitelistUrls and .isProduction', () => { - expect(RavenConfig.init.bind).toHaveBeenCalledWith(RavenConfig, { + expect(RavenConfig.init).toHaveBeenCalledWith({ sentryDsn, currentUserId, whitelistUrls: [gitlabUrl], diff --git a/spec/javascripts/raven/raven_config_spec.js b/spec/javascripts/raven/raven_config_spec.js index 64cf63702bc2c..3885cfde6bf80 100644 --- a/spec/javascripts/raven/raven_config_spec.js +++ b/spec/javascripts/raven/raven_config_spec.js @@ -1,47 +1,46 @@ +import $ from 'jquery'; import Raven from 'raven-js'; import RavenConfig from '~/raven/raven_config'; -describe('RavenConfig', () => { +fdescribe('RavenConfig', () => { describe('init', () => { + let options; + beforeEach(() => { + options = { + sentryDsn: '//sentryDsn', + ravenAssetUrl: '//ravenAssetUrl', + currentUserId: 1, + whitelistUrls: ['//gitlabUrl'], + isProduction: true, + }; + spyOn(RavenConfig, 'configure'); spyOn(RavenConfig, 'bindRavenErrors'); spyOn(RavenConfig, 'setUser'); - }); - - describe('when called', () => { - let options; - beforeEach(() => { - options = { - sentryDsn: '//sentryDsn', - ravenAssetUrl: '//ravenAssetUrl', - currentUserId: 1, - whitelistUrls: ['//gitlabUrl'], - isProduction: true, - }; - - RavenConfig.init(options); - }); + RavenConfig.init(options); + }); - it('should set the options property', () => { - expect(RavenConfig.options).toEqual(options); - }); + it('should set the options property', () => { + expect(RavenConfig.options).toEqual(options); + }); - it('should call the configure method', () => { - expect(RavenConfig.configure).toHaveBeenCalled(); - }); + it('should call the configure method', () => { + expect(RavenConfig.configure).toHaveBeenCalled(); + }); - it('should call the error bindings method', () => { - expect(RavenConfig.bindRavenErrors).toHaveBeenCalled(); - }); + it('should call the error bindings method', () => { + expect(RavenConfig.bindRavenErrors).toHaveBeenCalled(); + }); - it('should call setUser', () => { - expect(RavenConfig.setUser).toHaveBeenCalled(); - }); + it('should call setUser', () => { + expect(RavenConfig.setUser).toHaveBeenCalled(); }); it('should not call setUser if there is no current user ID', () => { + RavenConfig.setUser.calls.reset(); + RavenConfig.init({ sentryDsn: '//sentryDsn', ravenAssetUrl: '//ravenAssetUrl', @@ -55,72 +54,83 @@ describe('RavenConfig', () => { }); describe('configure', () => { - describe('when called', () => { - let options; - let raven; + let options; + let raven; - beforeEach(() => { - options = { - sentryDsn: '//sentryDsn', - whitelistUrls: ['//gitlabUrl'], - isProduction: true, - }; + beforeEach(() => { + options = { + sentryDsn: '//sentryDsn', + whitelistUrls: ['//gitlabUrl'], + isProduction: true, + }; - raven = jasmine.createSpyObj('raven', ['install']); + raven = jasmine.createSpyObj('raven', ['install']); - spyOn(Raven, 'config').and.returnValue(raven); - spyOn(Raven, 'install'); + spyOn(Raven, 'config').and.returnValue(raven); - RavenConfig.configure.call({ - options, - }); + RavenConfig.configure.call({ + options, }); + }); - it('should call Raven.config', () => { - expect(Raven.config).toHaveBeenCalledWith(options.sentryDsn, { - whitelistUrls: options.whitelistUrls, - environment: 'production', - }); + it('should call Raven.config', () => { + expect(Raven.config).toHaveBeenCalledWith(options.sentryDsn, { + whitelistUrls: options.whitelistUrls, + environment: 'production', }); + }); - it('should call Raven.install', () => { - expect(Raven.install).toHaveBeenCalled(); + it('should call Raven.install', () => { + expect(raven.install).toHaveBeenCalled(); + }); + + it('should set .environment to development if isProduction is false', () => { + options.isProduction = false; + + RavenConfig.configure.call({ + options, }); - describe('if isProduction is false', () => { - beforeEach(() => { - options.isProduction = false; - - RavenConfig.configure.call({ - options, - }); - }); - - it('should set .environment to development', () => { - expect(Raven.config).toHaveBeenCalledWith(options.sentryDsn, { - whitelistUrls: options.whitelistUrls, - environment: 'development', - }); - }); + expect(Raven.config).toHaveBeenCalledWith(options.sentryDsn, { + whitelistUrls: options.whitelistUrls, + environment: 'development', }); }); }); describe('setUser', () => { - describe('when called', () => { - beforeEach(() => {}); + let ravenConfig; + + beforeEach(() => { + ravenConfig = { options: { currentUserId: 1 } }; + spyOn(Raven, 'setUserContext'); + + RavenConfig.setUser.call(ravenConfig); + }); + + it('should call .setUserContext', function () { + expect(Raven.setUserContext).toHaveBeenCalledWith({ + id: ravenConfig.options.currentUserId, + }); }); }); describe('bindRavenErrors', () => { - describe('when called', () => { - beforeEach(() => {}); + beforeEach(() => { + RavenConfig.bindRavenErrors(); + }); + + it('should query for document using jquery', () => { + console.log($, 'or', $.fn); + // expect($).toHaveBeenCalledWith() + }); + + it('should call .on', function () { + // expect($document.on).toHaveBeenCalledWith('ajaxError.raven', RavenConfig.handleRavenErrors); }); }); describe('handleRavenErrors', () => { - describe('when called', () => { - beforeEach(() => {}); - }); + beforeEach(() => {}); }); }); -- GitLab From e22bd9650d7bebfea0909fdaf5be7dcf746b53f7 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Sat, 15 Apr 2017 03:38:59 +0100 Subject: [PATCH 046/363] Updated specs, added rewire, updated layouts to move conditional raven and gon to head --- app/views/layouts/_head.html.haml | 3 + app/views/layouts/application.html.haml | 4 -- app/views/layouts/devise.html.haml | 3 - app/views/layouts/devise_empty.html.haml | 3 - config/webpack.config.js | 2 +- package.json | 1 + spec/javascripts/.eslintrc | 15 ++++- spec/javascripts/raven/raven_config_spec.js | 70 ++++++++++++++++++--- yarn.lock | 4 ++ 9 files changed, 86 insertions(+), 19 deletions(-) diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index f6d8bb08a646a..00de3b506b401 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -28,9 +28,12 @@ = stylesheet_link_tag "application", media: "all" = stylesheet_link_tag "print", media: "print" + = Gon::Base.render_data + = javascript_include_tag(*webpack_asset_paths("runtime")) = javascript_include_tag(*webpack_asset_paths("common")) = javascript_include_tag(*webpack_asset_paths("main")) + = javascript_include_tag(*webpack_asset_paths("raven")) if sentry_enabled? - if content_for?(:page_specific_javascripts) = yield :page_specific_javascripts diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index cfd9481e4b23a..4c7f0b57d162e 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -2,12 +2,8 @@ %html{ lang: "en", class: "#{page_class}" } = render "layouts/head" %body{ class: @body_class, data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}" } } - = Gon::Base.render_data - = render "layouts/header/default", title: header_title = render 'layouts/page', sidebar: sidebar, nav: nav = yield :scripts_body = render "layouts/init_auto_complete" if @gfm_form - - = javascript_include_tag(*webpack_asset_paths("raven")) if sentry_enabled? diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 6274f6340ab86..52fb46eb8c93f 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -3,7 +3,6 @@ = render "layouts/head" %body.ui_charcoal.login-page.application.navless{ data: { page: body_data_page } } .page-wrap - = Gon::Base.render_data = render "layouts/header/empty" = render "layouts/broadcast" .container.navless-container @@ -35,5 +34,3 @@ = link_to "Explore", explore_root_path = link_to "Help", help_path = link_to "About GitLab", "https://about.gitlab.com/" - - = javascript_include_tag(*webpack_asset_paths("raven")) if sentry_enabled? diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml index 120f7299fc96c..ed6731bde9540 100644 --- a/app/views/layouts/devise_empty.html.haml +++ b/app/views/layouts/devise_empty.html.haml @@ -2,7 +2,6 @@ %html{ lang: "en" } = render "layouts/head" %body.ui_charcoal.login-page.application.navless - = Gon::Base.render_data = render "layouts/header/empty" = render "layouts/broadcast" .container.navless-container @@ -16,5 +15,3 @@ = link_to "Explore", explore_root_path = link_to "Help", help_path = link_to "About GitLab", "https://about.gitlab.com/" - - = javascript_include_tag(*webpack_asset_paths("raven")) if sentry_enabled? diff --git a/config/webpack.config.js b/config/webpack.config.js index 62118522606a0..77d703e008a67 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -61,7 +61,7 @@ var config = { { test: /\.js$/, exclude: /(node_modules|vendor\/assets)/, - loader: 'babel-loader' + loader: 'babel-loader?plugins=rewire' }, { test: /\.svg$/, diff --git a/package.json b/package.json index 0b24c5b8b0434..d05ae8f7658dc 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ }, "devDependencies": { "babel-plugin-istanbul": "^4.0.0", + "babel-plugin-rewire": "^1.1.0", "eslint": "^3.10.1", "eslint-config-airbnb-base": "^10.0.1", "eslint-import-resolver-webpack": "^0.8.1", diff --git a/spec/javascripts/.eslintrc b/spec/javascripts/.eslintrc index 3d922021978aa..a3a8bb050c95e 100644 --- a/spec/javascripts/.eslintrc +++ b/spec/javascripts/.eslintrc @@ -27,6 +27,19 @@ "jasmine/no-suite-dupes": [1, "branch"], "jasmine/no-spec-dupes": [1, "branch"], "no-console": 0, - "prefer-arrow-callback": 0 + "prefer-arrow-callback": 0, + "no-underscore-dangle": [ + 2, + { + "allow": [ + "__GetDependency__", + "__Rewire__", + "__ResetDependency__", + "__get__", + "__set__", + "__RewireAPI__" + ] + } + ] } } diff --git a/spec/javascripts/raven/raven_config_spec.js b/spec/javascripts/raven/raven_config_spec.js index 3885cfde6bf80..b8bb558d22ea2 100644 --- a/spec/javascripts/raven/raven_config_spec.js +++ b/spec/javascripts/raven/raven_config_spec.js @@ -1,8 +1,7 @@ -import $ from 'jquery'; import Raven from 'raven-js'; -import RavenConfig from '~/raven/raven_config'; +import RavenConfig, { __RewireAPI__ as RavenConfigRewire } from '~/raven/raven_config'; -fdescribe('RavenConfig', () => { +describe('RavenConfig', () => { describe('init', () => { let options; @@ -116,21 +115,78 @@ fdescribe('RavenConfig', () => { }); describe('bindRavenErrors', () => { + let $document; + let $; + beforeEach(() => { + $document = jasmine.createSpyObj('$document', ['on']); + $ = jasmine.createSpy('$').and.returnValue($document); + + RavenConfigRewire.__set__('$', $); + RavenConfig.bindRavenErrors(); }); it('should query for document using jquery', () => { - console.log($, 'or', $.fn); - // expect($).toHaveBeenCalledWith() + expect($).toHaveBeenCalledWith(document); }); it('should call .on', function () { - // expect($document.on).toHaveBeenCalledWith('ajaxError.raven', RavenConfig.handleRavenErrors); + expect($document.on).toHaveBeenCalledWith('ajaxError.raven', RavenConfig.handleRavenErrors); }); }); describe('handleRavenErrors', () => { - beforeEach(() => {}); + let event; + let req; + let config; + let err; + + beforeEach(() => { + event = {}; + req = { status: 'status', responseText: 'responseText', statusText: 'statusText' }; + config = { type: 'type', url: 'url', data: 'data' }; + err = {}; + + spyOn(Raven, 'captureMessage'); + + RavenConfig.handleRavenErrors(event, req, config, err); + }); + + it('should call Raven.captureMessage', () => { + expect(Raven.captureMessage).toHaveBeenCalledWith(err, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: req.responseText.substring(0, 100), + error: err, + event, + }, + }); + }); + + describe('if no err is provided', () => { + beforeEach(() => { + Raven.captureMessage.calls.reset(); + + RavenConfig.handleRavenErrors(event, req, config); + }); + + it('should use req.statusText as the error value', () => { + expect(Raven.captureMessage).toHaveBeenCalledWith(req.statusText, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: req.responseText.substring(0, 100), + error: req.statusText, + event, + }, + }); + }); + }); }); }); diff --git a/yarn.lock b/yarn.lock index 46f528f0bec5b..6e9b9bd9cec6b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -406,6 +406,10 @@ babel-plugin-istanbul@^4.0.0: istanbul-lib-instrument "^1.4.2" test-exclude "^4.0.0" +babel-plugin-rewire@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/babel-plugin-rewire/-/babel-plugin-rewire-1.1.0.tgz#a6b966d9d8c06c03d95dcda2eec4e2521519549b" + babel-plugin-syntax-async-functions@^6.8.0: version "6.13.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz#cad9cad1191b5ad634bf30ae0872391e0647be95" -- GitLab From 69e49a095ded719ccfb1287b6100646663de3f03 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Sat, 15 Apr 2017 11:52:37 +0100 Subject: [PATCH 047/363] Only rewire in dev --- config/webpack.config.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/config/webpack.config.js b/config/webpack.config.js index f034a8ae27d92..79c1d66a24214 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -15,6 +15,7 @@ var DEV_SERVER_HOST = process.env.DEV_SERVER_HOST || 'localhost'; var DEV_SERVER_PORT = parseInt(process.env.DEV_SERVER_PORT, 10) || 3808; var DEV_SERVER_LIVERELOAD = process.env.DEV_SERVER_LIVERELOAD !== 'false'; var WEBPACK_REPORT = process.env.WEBPACK_REPORT; +var rewirePlugin = IS_PRODUCTION ? '?plugins=rewire' : ''; var config = { context: path.join(ROOT_PATH, 'app/assets/javascripts'), @@ -52,7 +53,7 @@ var config = { vue_pipelines: './vue_pipelines_index/index.js', raven: './raven/index.js', issue_show: './issue_show/index.js', - group: './group.js' + group: './group.js', }, output: { @@ -68,7 +69,7 @@ var config = { { test: /\.js$/, exclude: /(node_modules|vendor\/assets)/, - loader: 'babel-loader?plugins=rewire', + loader: `babel-loader${rewirePlugin}`, }, { test: /\.vue$/, -- GitLab From 86b4f49c8c76a322d2f12f61e5a2d27d3c5c4671 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Sat, 15 Apr 2017 12:29:46 +0100 Subject: [PATCH 048/363] Removed rewire and fixed tests --- app/assets/javascripts/raven/raven_config.js | 6 ++-- config/webpack.config.js | 3 +- package.json | 1 - spec/javascripts/.eslintrc | 15 +--------- spec/javascripts/raven/raven_config_spec.js | 30 ++++++++++++++++++-- yarn.lock | 4 --- 6 files changed, 32 insertions(+), 27 deletions(-) diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/raven/raven_config.js index bb9be7cb196fb..9c756a4130b74 100644 --- a/app/assets/javascripts/raven/raven_config.js +++ b/app/assets/javascripts/raven/raven_config.js @@ -1,5 +1,4 @@ import Raven from 'raven-js'; -import $ from 'jquery'; const RavenConfig = { init(options = {}) { @@ -24,11 +23,12 @@ const RavenConfig = { }, bindRavenErrors() { - $(document).on('ajaxError.raven', this.handleRavenErrors); + window.$(document).on('ajaxError.raven', this.handleRavenErrors); }, handleRavenErrors(event, req, config, err) { const error = err || req.statusText; + const responseText = req.responseText || 'Unknown response text'; Raven.captureMessage(error, { extra: { @@ -36,7 +36,7 @@ const RavenConfig = { url: config.url, data: config.data, status: req.status, - response: req.responseText.substring(0, 100), + response: responseText.substring(0, 100), error, event, }, diff --git a/config/webpack.config.js b/config/webpack.config.js index 79c1d66a24214..2153c8bbaf281 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -15,7 +15,6 @@ var DEV_SERVER_HOST = process.env.DEV_SERVER_HOST || 'localhost'; var DEV_SERVER_PORT = parseInt(process.env.DEV_SERVER_PORT, 10) || 3808; var DEV_SERVER_LIVERELOAD = process.env.DEV_SERVER_LIVERELOAD !== 'false'; var WEBPACK_REPORT = process.env.WEBPACK_REPORT; -var rewirePlugin = IS_PRODUCTION ? '?plugins=rewire' : ''; var config = { context: path.join(ROOT_PATH, 'app/assets/javascripts'), @@ -69,7 +68,7 @@ var config = { { test: /\.js$/, exclude: /(node_modules|vendor\/assets)/, - loader: `babel-loader${rewirePlugin}`, + loader: 'babel-loader', }, { test: /\.vue$/, diff --git a/package.json b/package.json index cf66d7ec58e0a..4ac1526bba184 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,6 @@ }, "devDependencies": { "babel-plugin-istanbul": "^4.0.0", - "babel-plugin-rewire": "^1.1.0", "eslint": "^3.10.1", "eslint-config-airbnb-base": "^10.0.1", "eslint-import-resolver-webpack": "^0.8.1", diff --git a/spec/javascripts/.eslintrc b/spec/javascripts/.eslintrc index a3a8bb050c95e..3d922021978aa 100644 --- a/spec/javascripts/.eslintrc +++ b/spec/javascripts/.eslintrc @@ -27,19 +27,6 @@ "jasmine/no-suite-dupes": [1, "branch"], "jasmine/no-spec-dupes": [1, "branch"], "no-console": 0, - "prefer-arrow-callback": 0, - "no-underscore-dangle": [ - 2, - { - "allow": [ - "__GetDependency__", - "__Rewire__", - "__ResetDependency__", - "__get__", - "__set__", - "__RewireAPI__" - ] - } - ] + "prefer-arrow-callback": 0 } } diff --git a/spec/javascripts/raven/raven_config_spec.js b/spec/javascripts/raven/raven_config_spec.js index b8bb558d22ea2..55f3949c74008 100644 --- a/spec/javascripts/raven/raven_config_spec.js +++ b/spec/javascripts/raven/raven_config_spec.js @@ -1,5 +1,5 @@ import Raven from 'raven-js'; -import RavenConfig, { __RewireAPI__ as RavenConfigRewire } from '~/raven/raven_config'; +import RavenConfig from '~/raven/raven_config'; describe('RavenConfig', () => { describe('init', () => { @@ -122,13 +122,13 @@ describe('RavenConfig', () => { $document = jasmine.createSpyObj('$document', ['on']); $ = jasmine.createSpy('$').and.returnValue($document); - RavenConfigRewire.__set__('$', $); + window.$ = $; RavenConfig.bindRavenErrors(); }); it('should query for document using jquery', () => { - expect($).toHaveBeenCalledWith(document); + expect(window.$).toHaveBeenCalledWith(document); }); it('should call .on', function () { @@ -188,5 +188,29 @@ describe('RavenConfig', () => { }); }); }); + + describe('if no req.responseText is provided', () => { + beforeEach(() => { + req.responseText = undefined; + + Raven.captureMessage.calls.reset(); + + RavenConfig.handleRavenErrors(event, req, config, err); + }); + + it('should use `Unknown response text` as the response', () => { + expect(Raven.captureMessage).toHaveBeenCalledWith(err, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: 'Unknown response text', + error: err, + event, + }, + }); + }); + }); }); }); diff --git a/yarn.lock b/yarn.lock index e41f737c88c92..dd169cf28ba81 100644 --- a/yarn.lock +++ b/yarn.lock @@ -425,10 +425,6 @@ babel-plugin-istanbul@^4.0.0: istanbul-lib-instrument "^1.4.2" test-exclude "^4.0.0" -babel-plugin-rewire@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/babel-plugin-rewire/-/babel-plugin-rewire-1.1.0.tgz#a6b966d9d8c06c03d95dcda2eec4e2521519549b" - babel-plugin-syntax-async-functions@^6.8.0: version "6.13.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz#cad9cad1191b5ad634bf30ae0872391e0647be95" -- GitLab From 90ba69d224eb7ef3d91332f1b7944c68ad16affd Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Sat, 15 Apr 2017 16:07:31 +0100 Subject: [PATCH 049/363] Started internationalising cycyle analytics --- .eslintignore | 1 + Gemfile | 1 + Gemfile.lock | 8 ++++++++ app/assets/javascripts/locale/de/app.js | 1 + app/assets/javascripts/locale/en/app.js | 1 + app/assets/javascripts/locale/es/app.js | 1 + app/assets/javascripts/locale/index.js | 15 +++++++++++++++ app/views/layouts/_head.html.haml | 1 + app/views/layouts/application.html.haml | 2 +- config/webpack.config.js | 5 +++++ package.json | 2 ++ yarn.lock | 19 +++++++++++++++---- 12 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 app/assets/javascripts/locale/de/app.js create mode 100644 app/assets/javascripts/locale/en/app.js create mode 100644 app/assets/javascripts/locale/es/app.js create mode 100644 app/assets/javascripts/locale/index.js diff --git a/.eslintignore b/.eslintignore index c742b08c00540..1605e483e9e63 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,3 +7,4 @@ /vendor/ karma.config.js webpack.config.js +/app/assets/javascripts/locale/**/*.js diff --git a/Gemfile b/Gemfile index 6c1f634607154..736e3e3b57849 100644 --- a/Gemfile +++ b/Gemfile @@ -256,6 +256,7 @@ gem 'premailer-rails', '~> 1.9.0' # I18n gem 'gettext_i18n_rails', '~> 1.8.0' +gem 'gettext_i18n_rails_js', '~> 1.2.0' gem 'gettext', '~> 3.2.2', require: false, group: :development # Metrics diff --git a/Gemfile.lock b/Gemfile.lock index ca4084e18a232..94727810ac9e2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -259,6 +259,11 @@ GEM text (>= 1.3.0) gettext_i18n_rails (1.8.0) fast_gettext (>= 0.9.0) + gettext_i18n_rails_js (1.2.0) + gettext (>= 3.0.2) + gettext_i18n_rails (>= 0.7.1) + po_to_json (>= 1.0.0) + rails (>= 3.2.0) gherkin-ruby (0.3.2) gitaly (0.5.0) google-protobuf (~> 3.1) @@ -534,6 +539,8 @@ GEM ast (~> 2.2) path_expander (1.0.1) pg (0.18.4) + po_to_json (1.0.1) + json (>= 1.6.0) poltergeist (1.9.0) capybara (~> 2.1) cliver (~> 0.3.1) @@ -914,6 +921,7 @@ DEPENDENCIES gemojione (~> 3.0) gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) + gettext_i18n_rails_js (~> 1.2.0) gitaly (~> 0.5.0) github-linguist (~> 4.7.0) gitlab-flowdock-git-hook (~> 1.0.1) diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js new file mode 100644 index 0000000000000..643e82a90a073 --- /dev/null +++ b/app/assets/javascripts/locale/de/app.js @@ -0,0 +1 @@ +var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Deutsch":[""],"English":[""],"Spanish":[""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/en/app.js b/app/assets/javascripts/locale/en/app.js new file mode 100644 index 0000000000000..9070b519ff330 --- /dev/null +++ b/app/assets/javascripts/locale/en/app.js @@ -0,0 +1 @@ +var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Deutsch":[""],"English":[""],"Spanish":[""]}}}; diff --git a/app/assets/javascripts/locale/es/app.js b/app/assets/javascripts/locale/es/app.js new file mode 100644 index 0000000000000..41f6ddef5b868 --- /dev/null +++ b/app/assets/javascripts/locale/es/app.js @@ -0,0 +1 @@ +var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-13 00:07-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Deutsch":["Alemán"],"English":["Inglés"],"Spanish":["Español"]}}}; diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js new file mode 100644 index 0000000000000..0feb9eb5d43a9 --- /dev/null +++ b/app/assets/javascripts/locale/index.js @@ -0,0 +1,15 @@ +import Jed from 'jed'; +import de from './de/app'; +import es from './es/app'; +import en from './en/app'; + +const locales = { + de, + es, + en, +}; + +const lang = document.querySelector('html').getAttribute('lang'); + +export { lang }; +export default new Jed(locales[lang]); diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 19473b6ab276d..7e1986794893f 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -31,6 +31,7 @@ = webpack_bundle_tag "runtime" = webpack_bundle_tag "common" = webpack_bundle_tag "main" + = webpack_bundle_tag "locale" - if content_for?(:page_specific_javascripts) = yield :page_specific_javascripts diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 36543edc040f9..dc926a615c76b 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,5 +1,5 @@ !!! 5 -%html{ lang: "en", class: "#{page_class}" } +%html{ lang: I18n.locale, class: "#{page_class}" } = render "layouts/head" %body{ class: @body_class, data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}" } } = Gon::Base.render_data diff --git a/config/webpack.config.js b/config/webpack.config.js index ffb161900933d..df7b43cf053e9 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -52,6 +52,7 @@ var config = { vue_pipelines: './vue_pipelines_index/index.js', issue_show: './issue_show/index.js', group: './group.js', + locale: './locale/index.js', }, output: { @@ -82,6 +83,10 @@ var config = { exclude: /node_modules/, loader: 'file-loader', }, + { + test: /locale\/[a-z]+\/(.*)\.js$/, + loader: 'exports-loader?locales', + }, ] }, diff --git a/package.json b/package.json index a17399ddb8f61..5cb07ddc3b75c 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,9 @@ "dropzone": "^4.2.0", "emoji-unicode-version": "^0.2.1", "eslint-plugin-html": "^2.0.1", + "exports-loader": "^0.6.4", "file-loader": "^0.11.1", + "jed": "^1.1.1", "jquery": "^2.2.1", "jquery-ujs": "^1.2.1", "js-cookie": "^2.1.3", diff --git a/yarn.lock b/yarn.lock index e16cd9c367301..b95df2d23838f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2143,6 +2143,13 @@ expand-range@^1.8.1: dependencies: fill-range "^2.1.0" +exports-loader@^0.6.4: + version "0.6.4" + resolved "https://registry.yarnpkg.com/exports-loader/-/exports-loader-0.6.4.tgz#d70fc6121975b35fc12830cf52754be2740fc886" + dependencies: + loader-utils "^1.0.2" + source-map "0.5.x" + express@^4.13.3, express@^4.14.1: version "4.14.1" resolved "https://registry.yarnpkg.com/express/-/express-4.14.1.tgz#646c237f766f148c2120aff073817b9e4d7e0d33" @@ -3080,6 +3087,10 @@ jasmine-jquery@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/jasmine-jquery/-/jasmine-jquery-2.1.1.tgz#d4095e646944a26763235769ab018d9f30f0d47b" +jed@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/jed/-/jed-1.1.1.tgz#7a549bbd9ffe1585b0cd0a191e203055bee574b4" + jodid25519@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/jodid25519/-/jodid25519-1.0.2.tgz#06d4912255093419477d425633606e0e90782967" @@ -5071,6 +5082,10 @@ source-map-support@^0.4.2: dependencies: source-map "^0.5.3" +source-map@0.5.x, source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6, source-map@~0.5.1, source-map@~0.5.3: + version "0.5.6" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" + source-map@^0.1.41: version "0.1.43" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.1.43.tgz#c24bc146ca517c1471f5dacbe2571b2b7f9e3346" @@ -5083,10 +5098,6 @@ source-map@^0.4.4: dependencies: amdefine ">=0.0.4" -source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6, source-map@~0.5.1, source-map@~0.5.3: - version "0.5.6" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" - source-map@~0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.2.0.tgz#dab73fbcfc2ba819b4de03bd6f6eaa48164b3f9d" -- GitLab From 7963c2c25114e871eb42c0859b6d24fc37437a8a Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Tue, 18 Apr 2017 14:55:09 +0100 Subject: [PATCH 050/363] Added Vue filters & directives for translating --- .../components/limit_warning_component.js | 2 +- .../components/stage_code_component.js | 4 ++-- .../components/stage_issue_component.js | 4 ++-- .../components/stage_plan_component.js | 4 ++-- .../components/stage_production_component.js | 4 ++-- .../components/stage_review_component.js | 4 ++-- .../components/stage_staging_component.js | 2 +- .../components/total_time_component.js | 6 ++--- .../cycle_analytics/cycle_analytics_bundle.js | 3 +++ .../cycle_analytics/cycle_analytics_store.js | 16 ++++++++------ .../javascripts/vue_shared/translate.js | 14 ++++++++++++ .../cycle_analytics/_empty_stage.html.haml | 2 +- .../cycle_analytics/_no_access.html.haml | 4 ++-- .../projects/cycle_analytics/show.html.haml | 22 +++++++++---------- config/webpack.config.js | 8 +++++++ 15 files changed, 63 insertions(+), 36 deletions(-) create mode 100644 app/assets/javascripts/vue_shared/translate.js diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js index abe48572347b0..a7b187a0a36ba 100644 --- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js +++ b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js @@ -11,7 +11,7 @@ export default { aria-hidden="true" title="Limited to showing 50 events at most" data-placement="top"></i> - Showing 50 events + {{ 'Showing 50 events' | translate }} </span> `, }; diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js index 80bd2df6f42ea..f72882872cdf1 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js @@ -28,11 +28,11 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({ <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> · <span> - Opened + {{ 'Opened' | translate }} <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> </span> <span> - by + {{ 'by' | translate }} <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> </span> </div> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js index 20a43798fbedc..bb265c8316feb 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js @@ -28,11 +28,11 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({ <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a> · <span> - Opened + {{ 'Opened' | translate }} <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> </span> <span> - by + {{ 'by' | translate }} <a :href="issue.author.webUrl" class="issue-author-link"> {{ issue.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js index f33cac3da8248..32b685faece6a 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js @@ -31,10 +31,10 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({ </a> </h5> <span> - First + {{ 'First' | translate }} <span class="commit-icon">${iconCommit}</span> <a :href="commit.commitUrl" class="commit-hash-link monospace">{{ commit.shortSha }}</a> - pushed by + {{ 'pushed by' | translate }} <a :href="commit.author.webUrl" class="commit-author-link"> {{ commit.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js index 657f538537433..5c9186a2e497c 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js @@ -28,11 +28,11 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({ <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a> · <span> - Opened + {{ 'Opened' | translate }} <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> </span> <span> - by + {{ 'by' | translate }} <a :href="issue.author.webUrl" class="issue-author-link"> {{ issue.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js index 8a801300647aa..a047573548df0 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js @@ -28,11 +28,11 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({ <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> · <span> - Opened + {{ 'Opened' | translate }} <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> </span> <span> - by + {{ 'by' | translate }} <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> </span> <template v-if="mergeRequest.state === 'closed'"> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js index 4a28637958848..e8dfaf4294e19 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js @@ -32,7 +32,7 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({ </h5> <span> <a :href="build.url" class="build-date">{{ build.date }}</a> - by + {{ 'by' | translate }} <a :href="build.author.webUrl" class="issue-author-link"> {{ build.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.js b/app/assets/javascripts/cycle_analytics/components/total_time_component.js index 77edcb7627323..e07963b1b45c6 100644 --- a/app/assets/javascripts/cycle_analytics/components/total_time_component.js +++ b/app/assets/javascripts/cycle_analytics/components/total_time_component.js @@ -12,9 +12,9 @@ global.cycleAnalytics.TotalTimeComponent = Vue.extend({ template: ` <span class="total-time"> <template v-if="Object.keys(time).length"> - <template v-if="time.days">{{ time.days }} <span>{{ time.days === 1 ? 'day' : 'days' }}</span></template> - <template v-if="time.hours">{{ time.hours }} <span>hr</span></template> - <template v-if="time.mins && !time.days">{{ time.mins }} <span>mins</span></template> + <template v-if="time.days">{{ time.days }} <span>{{ time.days === 1 ? 'day' : 'days' | translate }}</span></template> + <template v-if="time.hours">{{ time.hours }} <span v-translate>hr</span></template> + <template v-if="time.mins && !time.days">{{ time.mins }} <span v-translate>mins</span></template> <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>s</span></template> </template> <template v-else> diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index b099b39e58f20..95257e58e6b0c 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import Cookies from 'js-cookie'; +import Translate from '../vue_shared/translate'; import LimitWarningComponent from './components/limit_warning_component'; require('./components/stage_code_component'); @@ -16,6 +17,8 @@ require('./cycle_analytics_service'); require('./cycle_analytics_store'); require('./default_event_objects'); +Vue.use(Translate); + $(() => { const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed'; const cycleAnalyticsEl = document.querySelector('#cycle-analytics'); diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js index 6536a8fd7fa28..25d5092a1fd8e 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js @@ -1,5 +1,7 @@ /* eslint-disable no-param-reassign */ +import locale from '~/locale'; + require('../lib/utils/text_utility'); const DEFAULT_EVENT_OBJECTS = require('./default_event_objects'); @@ -7,13 +9,13 @@ const global = window.gl || (window.gl = {}); global.cycleAnalytics = global.cycleAnalytics || {}; const EMPTY_STAGE_TEXTS = { - issue: 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.', - plan: 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.', - code: 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.', - test: 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.', - review: 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.', - staging: 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.', - production: 'The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.', + issue: locale.gettext('The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.'), + plan: locale.gettext('The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.'), + code: locale.gettext('The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.'), + test: locale.gettext('The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.'), + review: locale.gettext('The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.'), + staging: locale.gettext('The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.'), + production: locale.gettext('The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.'), }; global.cycleAnalytics.CycleAnalyticsStore = { diff --git a/app/assets/javascripts/vue_shared/translate.js b/app/assets/javascripts/vue_shared/translate.js new file mode 100644 index 0000000000000..88b7c0bb95436 --- /dev/null +++ b/app/assets/javascripts/vue_shared/translate.js @@ -0,0 +1,14 @@ +import locale from '../locale'; + +export default (Vue) => { + Vue.filter('translate', text => locale.gettext(text)); + + Vue.directive('translate', { + bind(el) { + const $el = el; + const text = $el.textContent.trim(); + + $el.textContent = locale.gettext(text); + }, + }); +}; diff --git a/app/views/projects/cycle_analytics/_empty_stage.html.haml b/app/views/projects/cycle_analytics/_empty_stage.html.haml index c3f95860e9237..27190785fff2d 100644 --- a/app/views/projects/cycle_analytics/_empty_stage.html.haml +++ b/app/views/projects/cycle_analytics/_empty_stage.html.haml @@ -2,6 +2,6 @@ .empty-stage .icon-no-data = custom_icon ('icon_no_data') - %h4 We don't have enough data to show this stage. + %h4 {{ 'We don\'t have enough data to show this stage.' | translate }} %p {{currentStage.emptyStageText}} diff --git a/app/views/projects/cycle_analytics/_no_access.html.haml b/app/views/projects/cycle_analytics/_no_access.html.haml index 0ffc79b318122..474d0f410a704 100644 --- a/app/views/projects/cycle_analytics/_no_access.html.haml +++ b/app/views/projects/cycle_analytics/_no_access.html.haml @@ -2,6 +2,6 @@ .no-access-stage .icon-lock = custom_icon ('icon_lock') - %h4 You need permission. + %h4 {{ 'You need permission.' | translate }} %p - Want to see the data? Please ask administrator for access. + {{ 'Want to see the data? Please ask administrator for access.' | translate }} diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index dd3fa814716ea..47fde758ffcff 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -15,9 +15,9 @@ = custom_icon('icon_cycle_analytics_splash') .col-sm-8.col-xs-12.inner-content %h4 - Introducing Cycle Analytics + {{ 'Introducing Cycle Analytics' | translate }} %p - Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project. + {{ 'Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.' | translate }} = link_to "Read more", help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn' = icon("spinner spin", "v-show" => "isLoading") @@ -34,15 +34,15 @@ .col-sm-3.col-xs-12.column .dropdown.inline.js-ca-dropdown %button.dropdown-menu-toggle{ "data-toggle" => "dropdown", :type => "button" } - %span.dropdown-label Last 30 days + %span.dropdown-label {{ 'Last 30 days' | translate }} %i.fa.fa-chevron-down %ul.dropdown-menu.dropdown-menu-align-right %li %a{ "href" => "#", "data-value" => "30" } - Last 30 days + {{ 'Last 30 days' | translate }} %li %a{ "href" => "#", "data-value" => "90" } - Last 90 days + {{ 'Last 30 days' | translate }} .stage-panel-container .panel.panel-default.stage-panel .panel-heading @@ -50,19 +50,19 @@ %ul %li.stage-header %span.stage-name - Stage + {{ 'Stage' | translate }} %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The phase of the development lifecycle.", "aria-hidden" => "true" } %li.median-header %span.stage-name - Median + {{ 'Median' | translate }} %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.", "aria-hidden" => "true" } %li.event-header %span.stage-name - {{ currentStage ? currentStage.legend : 'Related Issues' }} + {{ currentStage ? currentStage.legend : 'Related Issues' | translate }} %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The collection of events added to the data gathered for that stage.", "aria-hidden" => "true" } %li.total-time-header %span.stage-name - Total Time + {{ 'Total Time' | translate }} %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The time taken by each data entry gathered by that stage.", "aria-hidden" => "true" } .stage-panel-body %nav.stage-nav @@ -75,10 +75,10 @@ %span{ "v-if" => "stage.value" } {{ stage.value }} %span.stage-empty{ "v-else" => true } - Not enough data + {{ 'Not enough data' | translate }} %template{ "v-else" => true } %span.not-available - Not available + {{ 'Not available' | translate }} .section.stage-events %template{ "v-if" => "isLoadingStage" } = icon("spinner spin") diff --git a/config/webpack.config.js b/config/webpack.config.js index df7b43cf053e9..92ecba45d6e86 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -150,6 +150,14 @@ var config = { new webpack.optimize.CommonsChunkPlugin({ names: ['main', 'common', 'runtime'], }), + + // locale common library + new webpack.optimize.CommonsChunkPlugin({ + name: 'locale', + chunks: [ + 'cycle_analytics', + ], + }), ], resolve: { -- GitLab From b0df1ed4884a16c5c45800031667e0dd3251c978 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Tue, 18 Apr 2017 15:11:32 +0100 Subject: [PATCH 051/363] Make the locale file page specific --- app/views/layouts/_head.html.haml | 1 - app/views/projects/cycle_analytics/show.html.haml | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 7e1986794893f..19473b6ab276d 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -31,7 +31,6 @@ = webpack_bundle_tag "runtime" = webpack_bundle_tag "common" = webpack_bundle_tag "main" - = webpack_bundle_tag "locale" - if content_for?(:page_specific_javascripts) = yield :page_specific_javascripts diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index 47fde758ffcff..a0a629eaa4108 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -2,6 +2,7 @@ - page_title "Cycle Analytics" - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('locale') = page_specific_javascript_bundle_tag('cycle_analytics') = render "projects/head" @@ -19,7 +20,8 @@ %p {{ 'Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.' | translate }} - = link_to "Read more", help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn' + = link_to help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn' do + {{ 'Read more' | translate }} = icon("spinner spin", "v-show" => "isLoading") .wrapper{ "v-show" => "!isLoading && !hasError" } .panel.panel-default -- GitLab From 372841975feace348ffdcc14819b9c9a9da8b181 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Tue, 18 Apr 2017 16:29:22 +0100 Subject: [PATCH 052/363] Default the language to en - more useful for specs --- app/assets/javascripts/locale/index.js | 2 +- .../cycle_analytics/limit_warning_component_spec.js | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js index 0feb9eb5d43a9..56791968e5375 100644 --- a/app/assets/javascripts/locale/index.js +++ b/app/assets/javascripts/locale/index.js @@ -9,7 +9,7 @@ const locales = { en, }; -const lang = document.querySelector('html').getAttribute('lang'); +const lang = document.querySelector('html').getAttribute('lang') || 'en'; export { lang }; export default new Jed(locales[lang]); diff --git a/spec/javascripts/cycle_analytics/limit_warning_component_spec.js b/spec/javascripts/cycle_analytics/limit_warning_component_spec.js index 50000c5a5f560..2fb9eb0ca8561 100644 --- a/spec/javascripts/cycle_analytics/limit_warning_component_spec.js +++ b/spec/javascripts/cycle_analytics/limit_warning_component_spec.js @@ -1,6 +1,9 @@ import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; import limitWarningComp from '~/cycle_analytics/components/limit_warning_component'; +Vue.use(Translate); + describe('Limit warning component', () => { let component; let LimitWarningComponent; -- GitLab From a3506a228723d7e31fb37580dcd3b30245436f22 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Tue, 18 Apr 2017 17:00:44 +0100 Subject: [PATCH 053/363] Fixed missing text Fixed duplicated time in dropdown --- app/views/projects/cycle_analytics/show.html.haml | 4 ++-- config/webpack.config.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index a0a629eaa4108..a7783b6958803 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -26,7 +26,7 @@ .wrapper{ "v-show" => "!isLoading && !hasError" } .panel.panel-default .panel-heading - Pipeline Health + {{ 'Pipeline Health' | translate }} .content-block .container-fluid .row @@ -44,7 +44,7 @@ {{ 'Last 30 days' | translate }} %li %a{ "href" => "#", "data-value" => "90" } - {{ 'Last 30 days' | translate }} + {{ 'Last 90 days' | translate }} .stage-panel-container .panel.panel-default.stage-panel .panel-heading diff --git a/config/webpack.config.js b/config/webpack.config.js index 92ecba45d6e86..f93f4d6cd5e4c 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -34,6 +34,7 @@ var config = { graphs: './graphs/graphs_bundle.js', groups_list: './groups_list.js', issuable: './issuable/issuable_bundle.js', + locale: './locale/index.js', merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js', merge_request_widget: './merge_request_widget/ci_bundle.js', monitoring: './monitoring/monitoring_bundle.js', @@ -52,7 +53,6 @@ var config = { vue_pipelines: './vue_pipelines_index/index.js', issue_show: './issue_show/index.js', group: './group.js', - locale: './locale/index.js', }, output: { -- GitLab From af6e3e57e4c0ee861e26310c65be2e5e8547ca68 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Tue, 18 Apr 2017 18:58:03 +0100 Subject: [PATCH 054/363] Added sampling function and blacklisted common urls and error messages --- app/assets/javascripts/raven/raven_config.js | 53 +++++++++++++++++ spec/javascripts/raven/raven_config_spec.js | 62 ++++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/raven/raven_config.js index 9c756a4130b74..6c20cd9aabdb7 100644 --- a/app/assets/javascripts/raven/raven_config.js +++ b/app/assets/javascripts/raven/raven_config.js @@ -1,6 +1,52 @@ import Raven from 'raven-js'; +const IGNORE_ERRORS = [ + // Random plugins/extensions + 'top.GLOBALS', + // See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error. html + 'originalCreateNotification', + 'canvas.contentDocument', + 'MyApp_RemoveAllHighlights', + 'http://tt.epicplay.com', + 'Can\'t find variable: ZiteReader', + 'jigsaw is not defined', + 'ComboSearch is not defined', + 'http://loading.retry.widdit.com/', + 'atomicFindClose', + // Facebook borked + 'fb_xd_fragment', + // ISP "optimizing" proxy - `Cache-Control: no-transform` seems to + // reduce this. (thanks @acdha) + // See http://stackoverflow.com/questions/4113268 + 'bmi_SafeAddOnload', + 'EBCallBackMessageReceived', + // See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx + 'conduitPage', +]; + +const IGNORE_URLS = [ + // Facebook flakiness + /graph\.facebook\.com/i, + // Facebook blocked + /connect\.facebook\.net\/en_US\/all\.js/i, + // Woopra flakiness + /eatdifferent\.com\.woopra-ns\.com/i, + /static\.woopra\.com\/js\/woopra\.js/i, + // Chrome extensions + /extensions\//i, + /^chrome:\/\//i, + // Other plugins + /127\.0\.0\.1:4001\/isrunning/i, // Cacaoweb + /webappstoolbarba\.texthelp\.com\//i, + /metrics\.itunes\.apple\.com\.edgesuite\.net\//i, +]; + +const SAMPLE_RATE = 95; + const RavenConfig = { + IGNORE_ERRORS, + IGNORE_URLS, + SAMPLE_RATE, init(options = {}) { this.options = options; @@ -13,6 +59,9 @@ const RavenConfig = { Raven.config(this.options.sentryDsn, { whitelistUrls: this.options.whitelistUrls, environment: this.options.isProduction ? 'production' : 'development', + ignoreErrors: this.IGNORE_ERRORS, + ignoreUrls: this.IGNORE_URLS, + shouldSendCallback: this.shouldSendSample, }).install(); }, @@ -42,6 +91,10 @@ const RavenConfig = { }, }); }, + + shouldSendSample() { + return Math.random() * 100 <= this.SAMPLE_RATE; + }, }; export default RavenConfig; diff --git a/spec/javascripts/raven/raven_config_spec.js b/spec/javascripts/raven/raven_config_spec.js index 55f3949c74008..78251b4f61b6c 100644 --- a/spec/javascripts/raven/raven_config_spec.js +++ b/spec/javascripts/raven/raven_config_spec.js @@ -2,6 +2,28 @@ import Raven from 'raven-js'; import RavenConfig from '~/raven/raven_config'; describe('RavenConfig', () => { + describe('IGNORE_ERRORS', () => { + it('should be an array of strings', () => { + const areStrings = RavenConfig.IGNORE_ERRORS.every(error => typeof error === 'string'); + + expect(areStrings).toBe(true); + }); + }); + + describe('IGNORE_URLS', () => { + it('should be an array of regexps', () => { + const areRegExps = RavenConfig.IGNORE_URLS.every(url => url instanceof RegExp); + + expect(areRegExps).toBe(true); + }); + }); + + describe('SAMPLE_RATE', () => { + it('should be a finite number', () => { + expect(typeof RavenConfig.SAMPLE_RATE).toEqual('number'); + }); + }); + describe('init', () => { let options; @@ -76,6 +98,9 @@ describe('RavenConfig', () => { expect(Raven.config).toHaveBeenCalledWith(options.sentryDsn, { whitelistUrls: options.whitelistUrls, environment: 'production', + ignoreErrors: Raven.IGNORE_ERRORS, + ignoreUrls: Raven.IGNORE_URLS, + shouldSendCallback: Raven.shouldSendSample, }); }); @@ -93,6 +118,9 @@ describe('RavenConfig', () => { expect(Raven.config).toHaveBeenCalledWith(options.sentryDsn, { whitelistUrls: options.whitelistUrls, environment: 'development', + ignoreErrors: Raven.IGNORE_ERRORS, + ignoreUrls: Raven.IGNORE_URLS, + shouldSendCallback: Raven.shouldSendSample, }); }); }); @@ -213,4 +241,38 @@ describe('RavenConfig', () => { }); }); }); + + describe('shouldSendSample', () => { + let randomNumber; + + beforeEach(() => { + RavenConfig.SAMPLE_RATE = 50; + + spyOn(Math, 'random').and.callFake(() => randomNumber); + }); + + it('should call Math.random', () => { + RavenConfig.shouldSendSample(); + + expect(Math.random).toHaveBeenCalled(); + }); + + it('should return true if the sample rate is greater than the random number * 100', () => { + randomNumber = 0.1; + + expect(RavenConfig.shouldSendSample()).toBe(true); + }); + + it('should return false if the sample rate is less than the random number * 100', () => { + randomNumber = 0.9; + + expect(RavenConfig.shouldSendSample()).toBe(false); + }); + + it('should return true if the sample rate is equal to the random number * 100', () => { + randomNumber = 0.5; + + expect(RavenConfig.shouldSendSample()).toBe(true); + }); + }); }); -- GitLab From 7d16537cac7eb808d8d18cd0b89475db4e4eeaaa Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 19 Apr 2017 16:53:06 +0100 Subject: [PATCH 055/363] Created a plural filter Added tests for the filter [ci skip] --- .../components/limit_warning_component.js | 2 +- .../components/total_time_component.js | 4 +- .../javascripts/vue_shared/translate.js | 3 + spec/javascripts/vue_shared/translate_spec.js | 90 +++++++++++++++++++ 4 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 spec/javascripts/vue_shared/translate_spec.js diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js index a7b187a0a36ba..63e20478e9494 100644 --- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js +++ b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js @@ -11,7 +11,7 @@ export default { aria-hidden="true" title="Limited to showing 50 events at most" data-placement="top"></i> - {{ 'Showing 50 events' | translate }} + {{ 'Showing %d event' | translate-plural('Showing %d events', 50) }} </span> `, }; diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.js b/app/assets/javascripts/cycle_analytics/components/total_time_component.js index e07963b1b45c6..a0d735f159ce0 100644 --- a/app/assets/javascripts/cycle_analytics/components/total_time_component.js +++ b/app/assets/javascripts/cycle_analytics/components/total_time_component.js @@ -12,9 +12,9 @@ global.cycleAnalytics.TotalTimeComponent = Vue.extend({ template: ` <span class="total-time"> <template v-if="Object.keys(time).length"> - <template v-if="time.days">{{ time.days }} <span>{{ time.days === 1 ? 'day' : 'days' | translate }}</span></template> + <template v-if="time.days">{{ time.days }} <span>{{ 'day' | translate-plural('days', time.days) }}</span></template> <template v-if="time.hours">{{ time.hours }} <span v-translate>hr</span></template> - <template v-if="time.mins && !time.days">{{ time.mins }} <span v-translate>mins</span></template> + <template v-if="time.mins && !time.days">{{ time.mins }} <span>{{ 'min' | translate-plural('mins', time.mins) }}</span></template> <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>s</span></template> </template> <template v-else> diff --git a/app/assets/javascripts/vue_shared/translate.js b/app/assets/javascripts/vue_shared/translate.js index 88b7c0bb95436..072828b310ece 100644 --- a/app/assets/javascripts/vue_shared/translate.js +++ b/app/assets/javascripts/vue_shared/translate.js @@ -3,6 +3,9 @@ import locale from '../locale'; export default (Vue) => { Vue.filter('translate', text => locale.gettext(text)); + Vue.filter('translate-plural', (text, pluralText, count) => + locale.ngettext(text, pluralText, count).replace(/%d/g, count)); + Vue.directive('translate', { bind(el) { const $el = el; diff --git a/spec/javascripts/vue_shared/translate_spec.js b/spec/javascripts/vue_shared/translate_spec.js new file mode 100644 index 0000000000000..74bd4ff86b1bb --- /dev/null +++ b/spec/javascripts/vue_shared/translate_spec.js @@ -0,0 +1,90 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; + +Vue.use(Translate); + +describe('Vue translate filter', () => { + let el; + + beforeEach(() => { + el = document.createElement('div'); + + document.body.appendChild(el); + }); + + it('translate single text', (done) => { + const comp = new Vue({ + el, + template: ` + <span> + {{ 'testing' | translate }} + </span> + `, + }).$mount(); + + Vue.nextTick(() => { + expect( + comp.$el.textContent.trim(), + ).toBe('testing'); + + done(); + }); + }); + + it('translate plural text with single count', (done) => { + const comp = new Vue({ + el, + template: ` + <span> + {{ '%d day' | translate-plural('%d days', 1) }} + </span> + `, + }).$mount(); + + Vue.nextTick(() => { + expect( + comp.$el.textContent.trim(), + ).toBe('1 day'); + + done(); + }); + }); + + it('translate plural text with multiple count', (done) => { + const comp = new Vue({ + el, + template: ` + <span> + {{ '%d day' | translate-plural('%d days', 2) }} + </span> + `, + }).$mount(); + + Vue.nextTick(() => { + expect( + comp.$el.textContent.trim(), + ).toBe('2 days'); + + done(); + }); + }); + + it('translate plural without replacing any text', (done) => { + const comp = new Vue({ + el, + template: ` + <span> + {{ 'day' | translate-plural('days', 2) }} + </span> + `, + }).$mount(); + + Vue.nextTick(() => { + expect( + comp.$el.textContent.trim(), + ).toBe('days'); + + done(); + }); + }); +}); -- GitLab From 1de135bc0408a871e3bfa8b0ba5aa81a7936bd01 Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Wed, 19 Apr 2017 23:19:24 -0500 Subject: [PATCH 056/363] Fix Rubocop complains plus some small refactor --- app/controllers/application_controller.rb | 1 + app/views/profiles/show.html.haml | 2 +- config/initializers/fast_gettext.rb | 2 +- .../20170413035209_add_preferred_language_to_users.rb | 6 +++++- lib/gitlab/i18n.rb | 10 +++++----- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 5a3bd4040cc03..0af05992cceb5 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -269,6 +269,7 @@ def u2f_app_id def set_locale requested_locale = current_user&.preferred_language || request.env['HTTP_ACCEPT_LANGUAGE'] || I18n.default_locale locale = FastGettext.set_locale(requested_locale) + I18n.locale = locale end end diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index d8ef64ceb7288..9846f01603fd2 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -74,7 +74,7 @@ %span.help-block This email will be displayed on your public profile. .form-group = f.label :preferred_language, class: "label-light" - = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |lang| [_(lang[0]), lang[1]] }, + = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [_(label), value] }, {}, class: "select2" .form-group = f.label :skype, class: "label-light" diff --git a/config/initializers/fast_gettext.rb b/config/initializers/fast_gettext.rb index cfc3427b5ae69..54b0049033b7e 100644 --- a/config/initializers/fast_gettext.rb +++ b/config/initializers/fast_gettext.rb @@ -1,3 +1,3 @@ FastGettext.add_text_domain 'gitlab', path: 'locale', type: :po -FastGettext.default_available_locales = ['en', 'es','de'] +FastGettext.default_available_locales = Gitlab::I18n::AVAILABLE_LANGUAGES.keys FastGettext.default_text_domain = 'gitlab' diff --git a/db/migrate/20170413035209_add_preferred_language_to_users.rb b/db/migrate/20170413035209_add_preferred_language_to_users.rb index 5dc128dbaea0a..6fe91656eeba5 100644 --- a/db/migrate/20170413035209_add_preferred_language_to_users.rb +++ b/db/migrate/20170413035209_add_preferred_language_to_users.rb @@ -8,7 +8,11 @@ class AddPreferredLanguageToUsers < ActiveRecord::Migration disable_ddl_transaction! - def change + def up add_column_with_default :users, :preferred_language, :string, default: 'en' end + + def down + remove_column :users, :preferred_language + end end diff --git a/lib/gitlab/i18n.rb b/lib/gitlab/i18n.rb index 85e527afd7148..0459def651727 100644 --- a/lib/gitlab/i18n.rb +++ b/lib/gitlab/i18n.rb @@ -1,9 +1,9 @@ module Gitlab module I18n - AVAILABLE_LANGUAGES = [ - ['English', 'en'], - ['Spanish', 'es'], - ['Deutsch', 'de'] - ] + AVAILABLE_LANGUAGES = { + 'en' => 'English', + 'es' => 'Spanish', + 'de' => 'Deutsch' + }.freeze end end -- GitLab From aa29bd43451f18e79eacaf92af6859a08da8b5a4 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Thu, 20 Apr 2017 07:38:28 +0000 Subject: [PATCH 057/363] Include droplab-item-ignore in droplab docs --- doc/development/fe_guide/droplab/droplab.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/development/fe_guide/droplab/droplab.md b/doc/development/fe_guide/droplab/droplab.md index 8f0b6b21953a0..112ff3419d975 100644 --- a/doc/development/fe_guide/droplab/droplab.md +++ b/doc/development/fe_guide/droplab/droplab.md @@ -183,6 +183,8 @@ For example, either by a mouse click or by enter key selection. * The `droplab-item-active` css class is added to items that have been selected using arrow key navigation. +* You can add the `droplab-item-ignore` css class to any item that you do not want to be selectable. For example, +an `<li class="divider"></li>` list divider element that should not be interactive. ## Internal events -- GitLab From 4471d7b84fc783a08909473ef0243e6e07d2342a Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Thu, 20 Apr 2017 18:09:13 -0500 Subject: [PATCH 058/363] Some small fixes for the current I18n implementation --- app/controllers/projects/cycle_analytics_controller.rb | 1 + config/initializers/fast_gettext.rb | 2 +- lib/gitlab/i18n.rb | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb index 88ac3ad046b57..7ef8872a90b5b 100644 --- a/app/controllers/projects/cycle_analytics_controller.rb +++ b/app/controllers/projects/cycle_analytics_controller.rb @@ -4,6 +4,7 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController include CycleAnalyticsParams before_action :authorize_read_cycle_analytics! + before_action :set_locale, only: :show def show @cycle_analytics = ::CycleAnalytics.new(@project, options(cycle_analytics_params)) diff --git a/config/initializers/fast_gettext.rb b/config/initializers/fast_gettext.rb index 54b0049033b7e..10a3ee02b8510 100644 --- a/config/initializers/fast_gettext.rb +++ b/config/initializers/fast_gettext.rb @@ -1,3 +1,3 @@ FastGettext.add_text_domain 'gitlab', path: 'locale', type: :po -FastGettext.default_available_locales = Gitlab::I18n::AVAILABLE_LANGUAGES.keys FastGettext.default_text_domain = 'gitlab' +FastGettext.default_available_locales = Gitlab::I18n::AVAILABLE_LANGUAGES.keys diff --git a/lib/gitlab/i18n.rb b/lib/gitlab/i18n.rb index 0459def651727..64a86b55c7f5f 100644 --- a/lib/gitlab/i18n.rb +++ b/lib/gitlab/i18n.rb @@ -1,9 +1,9 @@ module Gitlab module I18n AVAILABLE_LANGUAGES = { - 'en' => 'English', - 'es' => 'Spanish', - 'de' => 'Deutsch' + 'en' => N_('English'), + 'es' => N_('Spanish'), + 'de' => N_('Deutsch') }.freeze end end -- GitLab From 6fbc6befa1768cc68471d17091ef08b2e73fec82 Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Thu, 20 Apr 2017 22:47:31 -0500 Subject: [PATCH 059/363] Monkey patch gettext_i18n_rails so it can parse content in Mustache format --- config/initializers/gettext_rails_i18n_patch.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 config/initializers/gettext_rails_i18n_patch.rb diff --git a/config/initializers/gettext_rails_i18n_patch.rb b/config/initializers/gettext_rails_i18n_patch.rb new file mode 100644 index 0000000000000..0413330c6087a --- /dev/null +++ b/config/initializers/gettext_rails_i18n_patch.rb @@ -0,0 +1,17 @@ +module GettextI18nRails + class HamlParser + singleton_class.send(:alias_method, :old_convert_to_code, :convert_to_code) + + # We need to convert text in Mustache format + # to a format that can be parsed by Gettext scripts. + # If we found a content like "{{ 'Stage' | translate }}" + # in a HAML file we convert it to "= _('Stage')", that way + # it can be processed by the "rake gettext:find" script. + def self.convert_to_code(text) + text.gsub!(/{{ (.*)( \| translate) }}/, "= _(\\1)") + + old_convert_to_code(text) + end + end +end + -- GitLab From 4f31d5963f44b1803fa54d0732cae86b0ec45e06 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Fri, 21 Apr 2017 09:16:50 +0100 Subject: [PATCH 060/363] Revert line being commented out --- .../initializers/gettext_rails_i18n_patch.rb | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/config/initializers/gettext_rails_i18n_patch.rb b/config/initializers/gettext_rails_i18n_patch.rb index 744e983a094a7..c6b3795d0c430 100644 --- a/config/initializers/gettext_rails_i18n_patch.rb +++ b/config/initializers/gettext_rails_i18n_patch.rb @@ -1,16 +1,16 @@ module GettextI18nRails class HamlParser - # singleton_class.send(:alias_method, :old_convert_to_code, :convert_to_code) - # - # # We need to convert text in Mustache format - # # to a format that can be parsed by Gettext scripts. - # # If we found a content like "{{ 'Stage' | translate }}" - # # in a HAML file we convert it to "= _('Stage')", that way - # # it can be processed by the "rake gettext:find" script. - # def self.convert_to_code(text) - # text.gsub!(/{{ (.*)( \| translate) }}/, "= _(\\1)") - # - # old_convert_to_code(text) - # end + singleton_class.send(:alias_method, :old_convert_to_code, :convert_to_code) + + # We need to convert text in Mustache format + # to a format that can be parsed by Gettext scripts. + # If we found a content like "{{ 'Stage' | translate }}" + # in a HAML file we convert it to "= _('Stage')", that way + # it can be processed by the "rake gettext:find" script. + def self.convert_to_code(text) + text.gsub!(/{{ (.*)( \| translate) }}/, "= _(\\1)") + + old_convert_to_code(text) + end end end -- GitLab From 76cdb8ee127942a3e5b88eb7e8a9c199bdf3363e Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Fri, 21 Apr 2017 14:34:11 -0600 Subject: [PATCH 061/363] render description - tasks work - gfm is juicy --- app/assets/javascripts/issue_show/index.js | 6 +- .../javascripts/issue_show/issue_title.vue | 80 ----------- .../issue_show/issue_title_description.vue | 128 ++++++++++++++++++ app/assets/stylesheets/pages/issues.scss | 9 ++ app/controllers/projects/issues_controller.rb | 6 +- app/views/projects/issues/show.html.haml | 12 +- ...pec.js => issue_title_description_spec.js} | 2 +- 7 files changed, 149 insertions(+), 94 deletions(-) delete mode 100644 app/assets/javascripts/issue_show/issue_title.vue create mode 100644 app/assets/javascripts/issue_show/issue_title_description.vue rename spec/javascripts/issue_show/{issue_title_spec.js => issue_title_description_spec.js} (88%) diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index 4d491e70d8379..db1cdb6d498a6 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -1,16 +1,16 @@ import Vue from 'vue'; -import IssueTitle from './issue_title.vue'; +import IssueTitle from './issue_title_description.vue'; import '../vue_shared/vue_resource_interceptor'; (() => { const issueTitleData = document.querySelector('.issue-title-data').dataset; - const { initialTitle, endpoint } = issueTitleData; + const { candescription, endpoint } = issueTitleData; const vm = new Vue({ el: '.issue-title-entrypoint', render: createElement => createElement(IssueTitle, { props: { - initialTitle, + candescription, endpoint, }, }), diff --git a/app/assets/javascripts/issue_show/issue_title.vue b/app/assets/javascripts/issue_show/issue_title.vue deleted file mode 100644 index 00b0e56030ae0..0000000000000 --- a/app/assets/javascripts/issue_show/issue_title.vue +++ /dev/null @@ -1,80 +0,0 @@ -<script> -import Visibility from 'visibilityjs'; -import Poll from './../lib/utils/poll'; -import Service from './services/index'; - -export default { - props: { - initialTitle: { required: true, type: String }, - endpoint: { required: true, type: String }, - }, - data() { - const resource = new Service(this.$http, this.endpoint); - - const poll = new Poll({ - resource, - method: 'getTitle', - successCallback: (res) => { - this.renderResponse(res); - }, - errorCallback: (err) => { - if (process.env.NODE_ENV !== 'production') { - // eslint-disable-next-line no-console - console.error('ISSUE SHOW TITLE REALTIME ERROR', err); - } else { - throw new Error(err); - } - }, - }); - - return { - poll, - timeoutId: null, - title: this.initialTitle, - }; - }, - methods: { - renderResponse(res) { - const body = JSON.parse(res.body); - this.triggerAnimation(body); - }, - triggerAnimation(body) { - const { title } = body; - - /** - * since opacity is changed, even if there is no diff for Vue to update - * we must check the title even on a 304 to ensure no visual change - */ - if (this.title === title) return; - - this.$el.style.opacity = 0; - - this.timeoutId = setTimeout(() => { - this.title = title; - - this.$el.style.transition = 'opacity 0.2s ease'; - this.$el.style.opacity = 1; - - clearTimeout(this.timeoutId); - }, 100); - }, - }, - created() { - if (!Visibility.hidden()) { - this.poll.makeRequest(); - } - - Visibility.change(() => { - if (!Visibility.hidden()) { - this.poll.restart(); - } else { - this.poll.stop(); - } - }); - }, -}; -</script> - -<template> - <h2 class="title" v-html="title"></h2> -</template> diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue new file mode 100644 index 0000000000000..4605fdadf8d75 --- /dev/null +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -0,0 +1,128 @@ +<script> +import Visibility from 'visibilityjs'; +import Poll from './../lib/utils/poll'; +import Service from './services/index'; + +export default { + props: { + endpoint: { required: true, type: String }, + candescription: { required: true, type: String }, + }, + data() { + const resource = new Service(this.$http, this.endpoint); + + const poll = new Poll({ + resource, + method: 'getTitle', + successCallback: (res) => { + this.renderResponse(res); + }, + errorCallback: (err) => { + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.error('ISSUE SHOW TITLE REALTIME ERROR', err); + } else { + throw new Error(err); + } + }, + }); + + return { + poll, + timeoutId: null, + title: null, + description: null, + }; + }, + methods: { + renderResponse(res) { + const body = JSON.parse(res.body); + this.triggerAnimation(body); + }, + triggerAnimation(body) { + const { title, description } = body; + + this.descriptionText = body.description_text; + + /** + * since opacity is changed, even if there is no diff for Vue to update + * we must check the title even on a 304 to ensure no visual change + */ + const noTitleChange = this.title === title; + const noDescriptionChange = this.description === description; + + if (noTitleChange && noDescriptionChange) return; + + const elementsToVisualize = []; + + if (!noTitleChange) { + elementsToVisualize.push(this.$el.querySelector('.title')); + } else if (!noDescriptionChange) { + elementsToVisualize.push(this.$el.querySelector('.wiki')); + } + + elementsToVisualize.forEach((element) => { + element.classList.remove('issue-realtime-trigger-pulse'); + element.classList.add('issue-realtime-pre-pulse'); + }); + + this.timeoutId = setTimeout(() => { + this.title = title; + this.description = description; + + elementsToVisualize.forEach((element) => { + element.classList.remove('issue-realtime-pre-pulse'); + element.classList.add('issue-realtime-trigger-pulse'); + }); + + clearTimeout(this.timeoutId); + }, 100); + }, + }, + computed: { + descriptionClass() { + return `description ${this.candescription} is-task-list-enabled`; + }, + }, + created() { + if (!Visibility.hidden()) { + this.poll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + this.poll.restart(); + } else { + this.poll.stop(); + } + }); + }, + updated() { + new gl.TaskList({ + dataType: 'issue', + fieldName: 'description', + selector: '.detail-page-description', + }).init(); + + $(this.$refs['issue-content-container-gfm-entry']).renderGFM(); + }, +}; +</script> + +<template> + <div> + <h2 class="title issue-realtime-trigger-pulse" v-html="title"></h2> + <div + :class="descriptionClass" + v-if="description" + > + <div + class="wiki issue-realtime-trigger-pulse" + v-html="description" + ref="issue-content-container-gfm-entry" + > + </div> + <textarea class="hidden js-task-list-field" v-if="descriptionText">{{descriptionText}}</textarea> + </div> + </div> +</template> diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index b2f45625a2a46..1311291661547 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -18,6 +18,15 @@ } } +.issue-realtime-pre-pulse { + opacity: 0; +} + +.issue-realtime-trigger-pulse { + transition: opacity 0.2s ease; + opacity: 1; +} + .check-all-holder { line-height: 36px; float: left; diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index cbf6713726178..b1df50ba84902 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -198,7 +198,11 @@ def can_create_branch def rendered_title Gitlab::PollingInterval.set_header(response, interval: 3_000) - render json: { title: view_context.markdown_field(@issue, :title) } + render json: { + title: view_context.markdown_field(@issue, :title), + description: view_context.markdown_field(@issue, :description), + description_text: @issue.description, + } end protected diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index fcbd88295958e..bf98efbdfdfee 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -51,17 +51,11 @@ .issue-details.issuable-details .detail-page-description.content-block{ class: ('hide-bottom-border' unless @issue.description.present? ) } - .issue-title-data.hidden{ "data" => { "initial-title" => markdown_field(@issue, :title), - "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue), + .issue-title-data.hidden{ "data" => { "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue), + "canDescription" => can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '', } } .issue-title-entrypoint - - if @issue.description.present? - .description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' } - .wiki - = preserve do - = markdown_field(@issue, :description) - %textarea.hidden.js-task-list-field - = @issue.description + = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue_edited_ago') #merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } } diff --git a/spec/javascripts/issue_show/issue_title_spec.js b/spec/javascripts/issue_show/issue_title_description_spec.js similarity index 88% rename from spec/javascripts/issue_show/issue_title_spec.js rename to spec/javascripts/issue_show/issue_title_description_spec.js index 03edbf9f94742..f351ca7d8e657 100644 --- a/spec/javascripts/issue_show/issue_title_spec.js +++ b/spec/javascripts/issue_show/issue_title_description_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import issueTitle from '~/issue_show/issue_title.vue'; +import issueTitle from '~/issue_show/issue_title_description.vue'; describe('Issue Title', () => { let IssueTitleComponent; -- GitLab From 52bfc0efa95f991f17618aa049f799f4e44e13ac Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Sat, 22 Apr 2017 21:02:31 +0200 Subject: [PATCH 062/363] Fix typo in CI/CD build partial view --- app/views/projects/ci/builds/_build.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 3e1c8f25deac3..2227d36eed22d 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -102,7 +102,7 @@ = link_to cancel_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do = icon('remove', class: 'cred') - elsif allow_retry - - if job.playable? && !admin && can?(current_user, :play_build, jop) + - if job.playable? && !admin && can?(current_user, :play_build, job) = link_to play_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do = custom_icon('icon_play') - elsif job.retryable? -- GitLab From b34534b60a593f2416fd7854f5fa08a84af9cedf Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Mon, 24 Apr 2017 13:37:14 +0100 Subject: [PATCH 063/363] Fixed dodgy merge --- README.md | 2 +- app/assets/stylesheets/framework/blocks.scss | 1 + app/assets/stylesheets/framework/nav.scss | 2 +- .../users/migrate_to_ghost_user_service.rb | 34 +++----- ...01-add-slash-slack-commands-to-api-doc.yml | 5 -- ...tion-while-moving-issues-to-ghost-user.yml | 4 - changelogs/unreleased/fix_link_in_readme.yml | 4 - doc/administration/integration/plantuml.md | 2 +- doc/api/services.md | 79 +++---------------- doc/development/fe_guide/testing.md | 6 +- .../migrate_to_ghost_user_service_spec.rb | 18 ----- ...e_to_ghost_user_service_shared_examples.rb | 52 ------------ 12 files changed, 28 insertions(+), 181 deletions(-) delete mode 100644 changelogs/unreleased/29801-add-slash-slack-commands-to-api-doc.yml delete mode 100644 changelogs/unreleased/30306-transaction-while-moving-issues-to-ghost-user.yml delete mode 100644 changelogs/unreleased/fix_link_in_readme.yml diff --git a/README.md b/README.md index 10d69efdc6bf3..f0e3b52ef6f06 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ One small thing you also have to do when installing it yourself is to copy the e cp config/unicorn.rb.example.development config/unicorn.rb -Instructions on how to start GitLab and how to run the tests can be found in the [getting started section of the GitLab Development Kit](https://gitlab.com/gitlab-org/gitlab-development-kit#getting-started). +Instructions on how to start GitLab and how to run the tests can be found in the [development section of the GitLab Development Kit](https://gitlab.com/gitlab-org/gitlab-development-kit#development). ## Software stack diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index f3e2a5db0a64d..524252629254e 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -230,6 +230,7 @@ float: right; margin-top: 8px; padding-bottom: 8px; + border-bottom: 1px solid $border-color; } } diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index b6cf5101d6094..e6d808717f37f 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -110,7 +110,7 @@ .top-area { @include clearfix; - border-bottom: 1px solid $border-color; + border-bottom: 1px solid $white-normal; .nav-text { padding-top: 16px; diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb index 4628c4c6f6e7d..1e1ed1791ec7e 100644 --- a/app/services/users/migrate_to_ghost_user_service.rb +++ b/app/services/users/migrate_to_ghost_user_service.rb @@ -15,39 +15,27 @@ def initialize(user) end def execute - transition = user.block_transition + # Block the user before moving records to prevent a data race. + # For example, if the user creates an issue after `migrate_issues` + # runs and before the user is destroyed, the destroy will fail with + # an exception. + user.block user.transaction do - # Block the user before moving records to prevent a data race. - # For example, if the user creates an issue after `migrate_issues` - # runs and before the user is destroyed, the destroy will fail with - # an exception. - user.block - - # Reverse the user block if record migration fails - if !migrate_records && transition - transition.rollback - user.save! - end - end - - user.reload - end - - private - - def migrate_records - user.transaction(requires_new: true) do @ghost_user = User.ghost migrate_issues migrate_merge_requests migrate_notes migrate_abuse_reports - migrate_award_emojis + migrate_award_emoji end + + user.reload end + private + def migrate_issues user.issues.update_all(author_id: ghost_user.id) end @@ -64,7 +52,7 @@ def migrate_abuse_reports user.reported_abuse_reports.update_all(reporter_id: ghost_user.id) end - def migrate_award_emojis + def migrate_award_emoji user.award_emoji.update_all(user_id: ghost_user.id) end end diff --git a/changelogs/unreleased/29801-add-slash-slack-commands-to-api-doc.yml b/changelogs/unreleased/29801-add-slash-slack-commands-to-api-doc.yml deleted file mode 100644 index 9c5df690085c8..0000000000000 --- a/changelogs/unreleased/29801-add-slash-slack-commands-to-api-doc.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Add Slack slash command api to services documentation and rearrange order and - cases -merge_request: 10757 -author: TM Lee diff --git a/changelogs/unreleased/30306-transaction-while-moving-issues-to-ghost-user.yml b/changelogs/unreleased/30306-transaction-while-moving-issues-to-ghost-user.yml deleted file mode 100644 index 5fc57e44be617..0000000000000 --- a/changelogs/unreleased/30306-transaction-while-moving-issues-to-ghost-user.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Add a transaction around move_issues_to_ghost_user -merge_request: 10465 -author: diff --git a/changelogs/unreleased/fix_link_in_readme.yml b/changelogs/unreleased/fix_link_in_readme.yml deleted file mode 100644 index be5ceac86563e..0000000000000 --- a/changelogs/unreleased/fix_link_in_readme.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Fix dead link to GDK on the README page -merge_request: -author: Dino Maric diff --git a/doc/administration/integration/plantuml.md b/doc/administration/integration/plantuml.md index b21817c1fd31a..5c85683503913 100644 --- a/doc/administration/integration/plantuml.md +++ b/doc/administration/integration/plantuml.md @@ -28,7 +28,7 @@ using Tomcat: sudo apt-get install tomcat7 sudo cp target/plantuml.war /var/lib/tomcat7/webapps/plantuml.war sudo chown tomcat7:tomcat7 /var/lib/tomcat7/webapps/plantuml.war -sudo service tomcat7 restart +sudo service restart tomcat7 ``` Once the Tomcat service restarts the PlantUML service will be ready and diff --git a/doc/api/services.md b/doc/api/services.md index 0f42c2560992f..7d4779f113763 100644 --- a/doc/api/services.md +++ b/doc/api/services.md @@ -490,98 +490,41 @@ Remove all previously JIRA settings from a project. DELETE /projects/:id/services/jira ``` -## Slack slash commands +## Mattermost Slash Commands -Ability to receive slash commands from a Slack chat instance. - -### Get Slack slash command service settings - -Get Slack slash command service settings for a project. - -``` -GET /projects/:id/services/slack-slash-commands -``` - -Example response: - -```json -{ - "id": 4, - "title": "Slack slash commands", - "created_at": "2017-06-27T05:51:39-07:00", - "updated_at": "2017-06-27T05:51:39-07:00", - "active": true, - "push_events": true, - "issues_events": true, - "merge_requests_events": true, - "tag_push_events": true, - "note_events": true, - "build_events": true, - "pipeline_events": true, - "properties": { - "token": "9koXpg98eAheJpvBs5tK" - } -} -``` +Ability to receive slash commands from a Mattermost chat instance. -### Create/Edit Slack slash command service +### Create/Edit Mattermost Slash Command service -Set Slack slash command for a project. +Set Mattermost Slash Command for a project. ``` -PUT /projects/:id/services/slack-slash-commands +PUT /projects/:id/services/mattermost-slash-commands ``` Parameters: | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `token` | string | yes | The Slack token | +| `token` | string | yes | The Mattermost token | -### Delete Slack slash command service +### Delete Mattermost Slash Command service -Delete Slack slash command service for a project. +Delete Mattermost Slash Command service for a project. ``` -DELETE /projects/:id/services/slack-slash-commands +DELETE /projects/:id/services/mattermost-slash-commands ``` -## Mattermost slash commands - -Ability to receive slash commands from a Mattermost chat instance. - -### Get Mattermost slash command service settings +### Get Mattermost Slash Command service settings -Get Mattermost slash command service settings for a project. +Get Mattermost Slash Command service settings for a project. ``` GET /projects/:id/services/mattermost-slash-commands ``` -### Create/Edit Mattermost slash command service - -Set Mattermost slash command for a project. - -``` -PUT /projects/:id/services/mattermost-slash-commands -``` - -Parameters: - -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `token` | string | yes | The Mattermost token | - - -### Delete Mattermost slash command service - -Delete Mattermost slash command service for a project. - -``` -DELETE /projects/:id/services/mattermost-slash-commands -``` - ## Pipeline-Emails Get emails for GitLab CI pipelines. diff --git a/doc/development/fe_guide/testing.md b/doc/development/fe_guide/testing.md index 157c13352ca07..66afbf4db4d64 100644 --- a/doc/development/fe_guide/testing.md +++ b/doc/development/fe_guide/testing.md @@ -14,10 +14,8 @@ for more information on general testing practices at GitLab. GitLab uses the [Karma][karma] test runner with [Jasmine][jasmine] as its test framework for our JavaScript unit tests. For tests that rely on DOM -manipulation, we generate HTML files using RSpec suites (see `spec/javascripts/fixtures/*.rb` for examples). -Some fixtures are still HAML templates that are translated to HTML files using the same mechanism (see `static_fixtures.rb`). -Those will be migrated over time. -Fixtures are served during testing by the [jasmine-jquery][jasmine-jquery] plugin. +manipulation we use fixtures which are pre-compiled from HAML source files and +served during testing by the [jasmine-jquery][jasmine-jquery] plugin. JavaScript tests live in `spec/javascripts/`, matching the folder structure of `app/assets/javascripts/`: `app/assets/javascripts/behaviors/autosize.js` diff --git a/spec/services/users/migrate_to_ghost_user_service_spec.rb b/spec/services/users/migrate_to_ghost_user_service_spec.rb index 9e1edf1ac30d8..8c5b7e41c1550 100644 --- a/spec/services/users/migrate_to_ghost_user_service_spec.rb +++ b/spec/services/users/migrate_to_ghost_user_service_spec.rb @@ -60,23 +60,5 @@ end end end - - context "when record migration fails with a rollback exception" do - before do - expect_any_instance_of(MergeRequest::ActiveRecord_Associations_CollectionProxy) - .to receive(:update_all).and_raise(ActiveRecord::Rollback) - end - - context "for records that were already migrated" do - let!(:issue) { create(:issue, project: project, author: user) } - let!(:merge_request) { create(:merge_request, source_project: project, author: user, target_branch: "first") } - - it "reverses the migration" do - service.execute - - expect(issue.reload.author).to eq(user) - end - end - end end end diff --git a/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb b/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb index dcc562c684b0f..0eac587e9730a 100644 --- a/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb +++ b/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb @@ -35,57 +35,5 @@ expect(user).to be_blocked end - - context "race conditions" do - context "when #{record_class_name} migration fails and is rolled back" do - before do - expect_any_instance_of(record_class::ActiveRecord_Associations_CollectionProxy) - .to receive(:update_all).and_raise(ActiveRecord::Rollback) - end - - it 'rolls back the user block' do - service.execute - - expect(user.reload).not_to be_blocked - end - - it "doesn't unblock an previously-blocked user" do - user.block - - service.execute - - expect(user.reload).to be_blocked - end - end - - context "when #{record_class_name} migration fails with a non-rollback exception" do - before do - expect_any_instance_of(record_class::ActiveRecord_Associations_CollectionProxy) - .to receive(:update_all).and_raise(ArgumentError) - end - - it 'rolls back the user block' do - service.execute rescue nil - - expect(user.reload).not_to be_blocked - end - - it "doesn't unblock an previously-blocked user" do - user.block - - service.execute rescue nil - - expect(user.reload).to be_blocked - end - end - - it "blocks the user before #{record_class_name} migration begins" do - expect(service).to receive("migrate_#{record_class_name.parameterize('_')}s".to_sym) do - expect(user.reload).to be_blocked - end - - service.execute - end - end end end -- GitLab From 3417e97c18bcf28fbe9f7061e9d9036c29931969 Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Mon, 24 Apr 2017 15:03:58 -0600 Subject: [PATCH 064/363] fix a few tests (not all) - update title in tab with new issue --- .../issue_show/issue_title_description.vue | 8 +++++-- spec/features/task_lists_spec.rb | 24 ++++++++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index 4605fdadf8d75..77562dac74c87 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -68,6 +68,8 @@ export default { this.timeoutId = setTimeout(() => { this.title = title; + document.querySelector('title').innerText = title; + this.description = description; elementsToVisualize.forEach((element) => { @@ -98,13 +100,15 @@ export default { }); }, updated() { - new gl.TaskList({ + const tl = new gl.TaskList({ dataType: 'issue', fieldName: 'description', selector: '.detail-page-description', - }).init(); + }); $(this.$refs['issue-content-container-gfm-entry']).renderGFM(); + + return tl; }, }; </script> diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb index a5d14aa19f160..b8b4fb75dd9e9 100644 --- a/spec/features/task_lists_spec.rb +++ b/spec/features/task_lists_spec.rb @@ -63,12 +63,16 @@ def visit_issue(project, issue) end describe 'for Issues' do - describe 'multiple tasks' do + include WaitForVueResource + + describe 'multiple tasks', js: true do let!(:issue) { create(:issue, description: markdown, author: user, project: project) } it 'renders' do visit_issue(project, issue) + wait_for_vue_resource + expect(page).to have_selector('ul.task-list', count: 1) expect(page).to have_selector('li.task-list-item', count: 6) expect(page).to have_selector('ul input[checked]', count: 2) @@ -79,6 +83,8 @@ def visit_issue(project, issue) container = '.detail-page-description .description.js-task-list-container' + wait_for_vue_resource + expect(page).to have_selector(container) expect(page).to have_selector("#{container} .wiki .task-list .task-list-item .task-list-item-checkbox") expect(page).to have_selector("#{container} .js-task-list-field") @@ -88,12 +94,17 @@ def visit_issue(project, issue) it 'is only editable by author' do visit_issue(project, issue) + + wait_for_vue_resource + expect(page).to have_selector('.js-task-list-container') logout(:user) login_as(user2) visit current_path + + wait_for_vue_resource expect(page).not_to have_selector('.js-task-list-container') end @@ -109,6 +120,8 @@ def visit_issue(project, issue) it 'renders' do visit_issue(project, issue) + wait_for_vue_resource + expect(page).to have_selector('ul.task-list', count: 1) expect(page).to have_selector('li.task-list-item', count: 1) expect(page).to have_selector('ul input[checked]', count: 0) @@ -126,6 +139,8 @@ def visit_issue(project, issue) it 'renders' do visit_issue(project, issue) + wait_for_vue_resource + expect(page).to have_selector('ul.task-list', count: 1) expect(page).to have_selector('li.task-list-item', count: 1) expect(page).to have_selector('ul input[checked]', count: 1) @@ -143,6 +158,9 @@ def visit_issue(project, issue) before { visit_issue(project, issue) } it 'renders' do + + wait_for_vue_resource + expect(page).to have_selector('ul.task-list', count: 2) expect(page).to have_selector('li.task-list-item', count: 7) expect(page).to have_selector('ul input[checked]', count: 1) @@ -152,6 +170,8 @@ def visit_issue(project, issue) it 'solves tasks' do expect(page).to have_content("2 of 7 tasks completed") + wait_for_vue_resource + page.find('li.task-list-item', text: 'Task b').find('input').click page.find('li.task-list-item ul li.task-list-item', text: 'Task a.2').find('input').click page.find('li.task-list-item ol li.task-list-item', text: 'Task 1.1').find('input').click @@ -160,6 +180,8 @@ def visit_issue(project, issue) visit_issue(project, issue) # reload to see new system notes + wait_for_vue_resource + expect(page).to have_content('marked the task Task b as complete') expect(page).to have_content('marked the task Task a.2 as complete') expect(page).to have_content('marked the task Task 1.1 as complete') -- GitLab From 77d9e3f9b49821af3ec9b4fbac574c75b02d0351 Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Mon, 24 Apr 2017 22:36:14 -0500 Subject: [PATCH 065/363] Monkey patch gettext_i18n_rails so it can parse content in Mustache format --- .../initializers/gettext_rails_i18n_patch.rb | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 config/initializers/gettext_rails_i18n_patch.rb diff --git a/config/initializers/gettext_rails_i18n_patch.rb b/config/initializers/gettext_rails_i18n_patch.rb new file mode 100644 index 0000000000000..c6b841aacd61f --- /dev/null +++ b/config/initializers/gettext_rails_i18n_patch.rb @@ -0,0 +1,19 @@ +require 'gettext_i18n_rails/haml_parser' + +module GettextI18nRails + class HamlParser + singleton_class.send(:alias_method, :old_convert_to_code, :convert_to_code) + + # We need to convert text in Mustache format + # to a format that can be parsed by Gettext scripts. + # If we found a content like "{{ 'Stage' | translate }}" + # in a HAML file we convert it to "= _('Stage')", that way + # it can be processed by the "rake gettext:find" script. + def self.convert_to_code(text) + text.gsub!(/{{ (.*)( \| translate) }}/, "= _(\\1)") + + old_convert_to_code(text) + end + end +end + -- GitLab From 80d7f4166fec5476c07ca7afe78e3e8a50071f2b Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Mon, 24 Apr 2017 23:03:38 -0500 Subject: [PATCH 066/363] Parse the translate-plural filter in HAML views --- config/initializers/gettext_rails_i18n_patch.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/initializers/gettext_rails_i18n_patch.rb b/config/initializers/gettext_rails_i18n_patch.rb index a50be2ea27914..3b50501c3daea 100644 --- a/config/initializers/gettext_rails_i18n_patch.rb +++ b/config/initializers/gettext_rails_i18n_patch.rb @@ -10,8 +10,12 @@ class HamlParser # in a HAML file we convert it to "= _('Stage')", that way # it can be processed by the "rake gettext:find" script. def self.convert_to_code(text) + # {{ 'Stage' | translate }} => = _('Stage') text.gsub!(/{{ (.*)( \| translate) }}/, "= _(\\1)") + # {{ 'user' | translate-plural('users', users.size) }} => = n_('user', 'users', users.size) + text.gsub!(/{{ (.*)( \| translate-plural\((.*), (.*)\)) }}/, "= n_(\\1, \\3, \\4)") + old_convert_to_code(text) end end -- GitLab From 422c48baa7eeaca025a423c4eb48256c786514ed Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Tue, 25 Apr 2017 00:01:59 -0500 Subject: [PATCH 067/363] Parse translate filters from JS files. --- .../initializers/gettext_rails_i18n_patch.rb | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/config/initializers/gettext_rails_i18n_patch.rb b/config/initializers/gettext_rails_i18n_patch.rb index 3b50501c3daea..5570b7e31e9e4 100644 --- a/config/initializers/gettext_rails_i18n_patch.rb +++ b/config/initializers/gettext_rails_i18n_patch.rb @@ -1,4 +1,8 @@ require 'gettext_i18n_rails/haml_parser' +require 'gettext_i18n_rails_js/parser/javascript' + +VUE_TRANSLATE_REGEX = /{{ ([^{]*)( \| translate) }}/ +VUE_TRANSLATE_PLURAL_REGEX = /{{ ([^{]*)( \| translate-plural\((.*), (.*)\)) }}/ module GettextI18nRails class HamlParser @@ -9,14 +13,37 @@ class HamlParser # If we found a content like "{{ 'Stage' | translate }}" # in a HAML file we convert it to "= _('Stage')", that way # it can be processed by the "rake gettext:find" script. + # Overwrites: https://github.com/grosser/gettext_i18n_rails/blob/8396387a431e0f8ead72fc1cd425cad2fa4992f2/lib/gettext_i18n_rails/haml_parser.rb#L9 def self.convert_to_code(text) # {{ 'Stage' | translate }} => = _('Stage') - text.gsub!(/{{ (.*)( \| translate) }}/, "= _(\\1)") + text.gsub!(VUE_TRANSLATE_REGEX, "= _(\\1)") # {{ 'user' | translate-plural('users', users.size) }} => = n_('user', 'users', users.size) - text.gsub!(/{{ (.*)( \| translate-plural\((.*), (.*)\)) }}/, "= n_(\\1, \\3, \\4)") + text.gsub!(VUE_TRANSLATE_PLURAL_REGEX, "= n_(\\1, \\3, \\4)") old_convert_to_code(text) end end end + +module GettextI18nRailsJs + module Parser + module Javascript + protected + + # Overwrites: https://github.com/webhippie/gettext_i18n_rails_js/blob/46c58db6d2053a4f5f36a0eb024ea706ff5707cb/lib/gettext_i18n_rails_js/parser/javascript.rb#L46 + def collect_for(value) + ::File.open(value) do |f| + f.each_line.each_with_index.collect do |line, idx| + line.gsub!(VUE_TRANSLATE_REGEX, "__(\\1)") + line.gsub!(VUE_TRANSLATE_PLURAL_REGEX, "n__(\\1, \\3, \\4)") + + line.scan(invoke_regex).collect do |function, arguments| + yield(function, arguments, idx + 1) + end + end.inject([], :+).compact + end + end + end + end +end -- GitLab From b4e2f9f1a711b5c4103d56752f7d968d009b51a5 Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Tue, 25 Apr 2017 10:31:15 -0600 Subject: [PATCH 068/363] test async nature of issue show --- .../issue_show/issue_title_description.vue | 2 +- spec/features/task_lists_spec.rb | 9 +--- .../issue_title_description_spec.js | 41 +++++++++++++++---- spec/javascripts/issue_show/mock_data.js | 5 +++ 4 files changed, 41 insertions(+), 16 deletions(-) create mode 100644 spec/javascripts/issue_show/mock_data.js diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index 77562dac74c87..5ef0037e48837 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -20,7 +20,7 @@ export default { errorCallback: (err) => { if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line no-console - console.error('ISSUE SHOW TITLE REALTIME ERROR', err); + console.error('ISSUE SHOW REALTIME ERROR', err); } else { throw new Error(err); } diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb index b8b4fb75dd9e9..3c341fef25399 100644 --- a/spec/features/task_lists_spec.rb +++ b/spec/features/task_lists_spec.rb @@ -70,7 +70,6 @@ def visit_issue(project, issue) it 'renders' do visit_issue(project, issue) - wait_for_vue_resource expect(page).to have_selector('ul.task-list', count: 1) @@ -80,11 +79,10 @@ def visit_issue(project, issue) it 'contains the required selectors' do visit_issue(project, issue) + wait_for_vue_resource container = '.detail-page-description .description.js-task-list-container' - wait_for_vue_resource - expect(page).to have_selector(container) expect(page).to have_selector("#{container} .wiki .task-list .task-list-item .task-list-item-checkbox") expect(page).to have_selector("#{container} .js-task-list-field") @@ -94,16 +92,13 @@ def visit_issue(project, issue) it 'is only editable by author' do visit_issue(project, issue) - wait_for_vue_resource expect(page).to have_selector('.js-task-list-container') logout(:user) - login_as(user2) visit current_path - wait_for_vue_resource expect(page).not_to have_selector('.js-task-list-container') end @@ -119,7 +114,6 @@ def visit_issue(project, issue) it 'renders' do visit_issue(project, issue) - wait_for_vue_resource expect(page).to have_selector('ul.task-list', count: 1) @@ -138,7 +132,6 @@ def visit_issue(project, issue) it 'renders' do visit_issue(project, issue) - wait_for_vue_resource expect(page).to have_selector('ul.task-list', count: 1) diff --git a/spec/javascripts/issue_show/issue_title_description_spec.js b/spec/javascripts/issue_show/issue_title_description_spec.js index f351ca7d8e657..2f81660aeb11a 100644 --- a/spec/javascripts/issue_show/issue_title_description_spec.js +++ b/spec/javascripts/issue_show/issue_title_description_spec.js @@ -1,22 +1,49 @@ import Vue from 'vue'; +import $ from 'jquery'; +import '~/render_math'; +import '~/render_gfm'; import issueTitle from '~/issue_show/issue_title_description.vue'; +import issueShowData from './mock_data'; + +window.$ = $; + +const issueShowInterceptor = (request, next) => { + console.log(issueShowData); + next(request.respondWith(JSON.stringify(issueShowData), { + status: 200, + })); +}; describe('Issue Title', () => { - let IssueTitleComponent; + const comps = { + IssueTitleComponent: {}, + }; beforeEach(() => { - IssueTitleComponent = Vue.extend(issueTitle); + comps.IssueTitleComponent = Vue.extend(issueTitle); + Vue.http.interceptors.push(issueShowInterceptor); + }); + + afterEach(() => { + Vue.http.interceptors = _.without( + Vue.http.interceptors, issueShowInterceptor, + ); }); - it('should render a title', () => { - const component = new IssueTitleComponent({ + it('should render a title', (done) => { + const issueShowComponent = new comps.IssueTitleComponent({ propsData: { - initialTitle: 'wow', + candescription: '.css-stuff', endpoint: '/gitlab-org/gitlab-shell/issues/9/rendered_title', }, }).$mount(); - expect(component.$el.classList).toContain('title'); - expect(component.$el.innerHTML).toContain('wow'); + // need setTimeout because actual setTimeout in code :P + setTimeout(() => { + expect(issueShowComponent.$el.querySelector('.title').innerHTML) + .toContain('this is a title'); + done(); + }, 300); + // 300 is just three times the Vue comps setTimeout to ensure pass }); }); diff --git a/spec/javascripts/issue_show/mock_data.js b/spec/javascripts/issue_show/mock_data.js new file mode 100644 index 0000000000000..37e86cfe33d5b --- /dev/null +++ b/spec/javascripts/issue_show/mock_data.js @@ -0,0 +1,5 @@ +export default { + title: '<p>this is a title</p>', + description: '<p>this is a description!</p>', + description_text: 'this is a description', +}; -- GitLab From 6176ca37b8f6cd44c17c2f3e015d18e0b892d4be Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Tue, 25 Apr 2017 11:06:43 -0600 Subject: [PATCH 069/363] change comp timeout to 0ms and test timeout to 10ms --- .../javascripts/issue_show/issue_title_description.vue | 2 +- spec/javascripts/issue_show/issue_title_description_spec.js | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index 5ef0037e48837..c31b980dfdec7 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -78,7 +78,7 @@ export default { }); clearTimeout(this.timeoutId); - }, 100); + }, 0); }, }, computed: { diff --git a/spec/javascripts/issue_show/issue_title_description_spec.js b/spec/javascripts/issue_show/issue_title_description_spec.js index 2f81660aeb11a..a06865fa39a06 100644 --- a/spec/javascripts/issue_show/issue_title_description_spec.js +++ b/spec/javascripts/issue_show/issue_title_description_spec.js @@ -8,7 +8,6 @@ import issueShowData from './mock_data'; window.$ = $; const issueShowInterceptor = (request, next) => { - console.log(issueShowData); next(request.respondWith(JSON.stringify(issueShowData), { status: 200, })); @@ -43,7 +42,7 @@ describe('Issue Title', () => { expect(issueShowComponent.$el.querySelector('.title').innerHTML) .toContain('this is a title'); done(); - }, 300); - // 300 is just three times the Vue comps setTimeout to ensure pass + }, 10); + // 10ms is just long enough for the update hook to fire }); }); -- GitLab From a39a5cafecb476460c23b62029064590731940f9 Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Tue, 25 Apr 2017 11:10:24 -0600 Subject: [PATCH 070/363] update comment explaining visual change check --- app/assets/javascripts/issue_show/issue_title_description.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index c31b980dfdec7..af2fe61af2bb1 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -46,7 +46,7 @@ export default { /** * since opacity is changed, even if there is no diff for Vue to update - * we must check the title even on a 304 to ensure no visual change + * we must check the title/description even on a 304 to ensure no visual change */ const noTitleChange = this.title === title; const noDescriptionChange = this.description === description; -- GitLab From 9b09ff098cc1efd4f90bbe54fa3cbcd5d4f8cef9 Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Tue, 25 Apr 2017 11:45:11 -0600 Subject: [PATCH 071/363] add more assertions --- .../issue_show/issue_title_description_spec.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/spec/javascripts/issue_show/issue_title_description_spec.js b/spec/javascripts/issue_show/issue_title_description_spec.js index a06865fa39a06..b81e5d74f7ec3 100644 --- a/spec/javascripts/issue_show/issue_title_description_spec.js +++ b/spec/javascripts/issue_show/issue_title_description_spec.js @@ -13,7 +13,7 @@ const issueShowInterceptor = (request, next) => { })); }; -describe('Issue Title', () => { +fdescribe('Issue Title', () => { const comps = { IssueTitleComponent: {}, }; @@ -39,8 +39,14 @@ describe('Issue Title', () => { // need setTimeout because actual setTimeout in code :P setTimeout(() => { - expect(issueShowComponent.$el.querySelector('.title').innerHTML) + expect(document.querySelector('title').innerText) .toContain('this is a title'); + expect(issueShowComponent.$el.querySelector('.title').innerHTML) + .toContain('<p>this is a title</p>'); + expect(issueShowComponent.$el.querySelector('.wiki').innerHTML) + .toContain('<p>this is a description!</p>'); + expect(issueShowComponent.$el.querySelector('.js-task-list-field').innerText) + .toContain('this is a description'); done(); }, 10); // 10ms is just long enough for the update hook to fire -- GitLab From b5e58c3e082c0b611b1527e72f9ae7a64f7a865a Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Tue, 25 Apr 2017 11:51:27 -0600 Subject: [PATCH 072/363] remove fdescribe :( --- spec/javascripts/issue_show/issue_title_description_spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/javascripts/issue_show/issue_title_description_spec.js b/spec/javascripts/issue_show/issue_title_description_spec.js index b81e5d74f7ec3..9148f16714b64 100644 --- a/spec/javascripts/issue_show/issue_title_description_spec.js +++ b/spec/javascripts/issue_show/issue_title_description_spec.js @@ -13,7 +13,7 @@ const issueShowInterceptor = (request, next) => { })); }; -fdescribe('Issue Title', () => { +describe('Issue Title', () => { const comps = { IssueTitleComponent: {}, }; -- GitLab From ab7e59122437cbf64d731b6a27304d0f96dd2b2c Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Tue, 25 Apr 2017 12:48:00 -0600 Subject: [PATCH 073/363] formatting in test --- .../issue_show/issue_title_description_spec.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/spec/javascripts/issue_show/issue_title_description_spec.js b/spec/javascripts/issue_show/issue_title_description_spec.js index 9148f16714b64..756eda54f798c 100644 --- a/spec/javascripts/issue_show/issue_title_description_spec.js +++ b/spec/javascripts/issue_show/issue_title_description_spec.js @@ -41,12 +41,19 @@ describe('Issue Title', () => { setTimeout(() => { expect(document.querySelector('title').innerText) .toContain('this is a title'); + expect(issueShowComponent.$el.querySelector('.title').innerHTML) .toContain('<p>this is a title</p>'); + expect(issueShowComponent.$el.querySelector('.wiki').innerHTML) .toContain('<p>this is a description!</p>'); - expect(issueShowComponent.$el.querySelector('.js-task-list-field').innerText) + + const hiddenText = issueShowComponent.$el + .querySelector('.js-task-list-field').innerText; + + expect(hiddenText) .toContain('this is a description'); + done(); }, 10); // 10ms is just long enough for the update hook to fire -- GitLab From d8f767445933f41a7629a7637ba962bdbc461c9a Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Tue, 25 Apr 2017 21:47:49 -0500 Subject: [PATCH 074/363] Add ability to parse *.vue files through the `ruby gettext:find` script --- .../initializers/gettext_rails_i18n_patch.rb | 15 +++++++++ lib/tasks/gettext.rake | 33 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 lib/tasks/gettext.rake diff --git a/config/initializers/gettext_rails_i18n_patch.rb b/config/initializers/gettext_rails_i18n_patch.rb index 5570b7e31e9e4..d74c1f597fefc 100644 --- a/config/initializers/gettext_rails_i18n_patch.rb +++ b/config/initializers/gettext_rails_i18n_patch.rb @@ -13,6 +13,7 @@ class HamlParser # If we found a content like "{{ 'Stage' | translate }}" # in a HAML file we convert it to "= _('Stage')", that way # it can be processed by the "rake gettext:find" script. + # # Overwrites: https://github.com/grosser/gettext_i18n_rails/blob/8396387a431e0f8ead72fc1cd425cad2fa4992f2/lib/gettext_i18n_rails/haml_parser.rb#L9 def self.convert_to_code(text) # {{ 'Stage' | translate }} => = _('Stage') @@ -29,6 +30,20 @@ def self.convert_to_code(text) module GettextI18nRailsJs module Parser module Javascript + + # This is required to tell the `rake gettext:find` script to use the Javascript + # parser for *.vue files. + # + # Overwrites: https://github.com/webhippie/gettext_i18n_rails_js/blob/46c58db6d2053a4f5f36a0eb024ea706ff5707cb/lib/gettext_i18n_rails_js/parser/javascript.rb#L36 + def target?(file) + [ + ".js", + ".jsx", + ".coffee", + ".vue" + ].include? ::File.extname(file) + end + protected # Overwrites: https://github.com/webhippie/gettext_i18n_rails_js/blob/46c58db6d2053a4f5f36a0eb024ea706ff5707cb/lib/gettext_i18n_rails_js/parser/javascript.rb#L46 diff --git a/lib/tasks/gettext.rake b/lib/tasks/gettext.rake new file mode 100644 index 0000000000000..cc18616d2a785 --- /dev/null +++ b/lib/tasks/gettext.rake @@ -0,0 +1,33 @@ +require "gettext_i18n_rails/tasks" + +namespace :gettext do + # Customize list of translatable files + # See: https://github.com/grosser/gettext_i18n_rails#customizing-list-of-translatable-files + def files_to_translate + folders = [ + "app", + "lib", + "config", + locale_path + ].join(",") + + exts = [ + "rb", + "erb", + "haml", + "slim", + "rhtml", + "js", + "jsx", + "vue", + "coffee", + "handlebars", + "hbs", + "mustache" + ].join(",") + + Dir.glob( + "{#{folders}}/**/*.{#{exts}}" + ) + end +end -- GitLab From 597c5c762ff5c86f8fc8c2c3c3aed2af270c2a71 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Wed, 26 Apr 2017 11:17:53 +0100 Subject: [PATCH 075/363] Fixed being behind --- README.md | 2 +- app/assets/stylesheets/framework/blocks.scss | 1 - app/assets/stylesheets/framework/nav.scss | 2 +- .../users/migrate_to_ghost_user_service.rb | 34 +++++--- ...01-add-slash-slack-commands-to-api-doc.yml | 5 ++ ...tion-while-moving-issues-to-ghost-user.yml | 4 + changelogs/unreleased/fix_link_in_readme.yml | 4 + doc/administration/integration/plantuml.md | 2 +- doc/api/services.md | 79 ++++++++++++++++--- doc/development/fe_guide/testing.md | 6 +- .../migrate_to_ghost_user_service_spec.rb | 18 +++++ ...e_to_ghost_user_service_shared_examples.rb | 52 ++++++++++++ 12 files changed, 181 insertions(+), 28 deletions(-) create mode 100644 changelogs/unreleased/29801-add-slash-slack-commands-to-api-doc.yml create mode 100644 changelogs/unreleased/30306-transaction-while-moving-issues-to-ghost-user.yml create mode 100644 changelogs/unreleased/fix_link_in_readme.yml diff --git a/README.md b/README.md index f0e3b52ef6f06..10d69efdc6bf3 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ One small thing you also have to do when installing it yourself is to copy the e cp config/unicorn.rb.example.development config/unicorn.rb -Instructions on how to start GitLab and how to run the tests can be found in the [development section of the GitLab Development Kit](https://gitlab.com/gitlab-org/gitlab-development-kit#development). +Instructions on how to start GitLab and how to run the tests can be found in the [getting started section of the GitLab Development Kit](https://gitlab.com/gitlab-org/gitlab-development-kit#getting-started). ## Software stack diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index 524252629254e..f3e2a5db0a64d 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -230,7 +230,6 @@ float: right; margin-top: 8px; padding-bottom: 8px; - border-bottom: 1px solid $border-color; } } diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index e6d808717f37f..b6cf5101d6094 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -110,7 +110,7 @@ .top-area { @include clearfix; - border-bottom: 1px solid $white-normal; + border-bottom: 1px solid $border-color; .nav-text { padding-top: 16px; diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb index 1e1ed1791ec7e..4628c4c6f6e7d 100644 --- a/app/services/users/migrate_to_ghost_user_service.rb +++ b/app/services/users/migrate_to_ghost_user_service.rb @@ -15,27 +15,39 @@ def initialize(user) end def execute - # Block the user before moving records to prevent a data race. - # For example, if the user creates an issue after `migrate_issues` - # runs and before the user is destroyed, the destroy will fail with - # an exception. - user.block + transition = user.block_transition user.transaction do + # Block the user before moving records to prevent a data race. + # For example, if the user creates an issue after `migrate_issues` + # runs and before the user is destroyed, the destroy will fail with + # an exception. + user.block + + # Reverse the user block if record migration fails + if !migrate_records && transition + transition.rollback + user.save! + end + end + + user.reload + end + + private + + def migrate_records + user.transaction(requires_new: true) do @ghost_user = User.ghost migrate_issues migrate_merge_requests migrate_notes migrate_abuse_reports - migrate_award_emoji + migrate_award_emojis end - - user.reload end - private - def migrate_issues user.issues.update_all(author_id: ghost_user.id) end @@ -52,7 +64,7 @@ def migrate_abuse_reports user.reported_abuse_reports.update_all(reporter_id: ghost_user.id) end - def migrate_award_emoji + def migrate_award_emojis user.award_emoji.update_all(user_id: ghost_user.id) end end diff --git a/changelogs/unreleased/29801-add-slash-slack-commands-to-api-doc.yml b/changelogs/unreleased/29801-add-slash-slack-commands-to-api-doc.yml new file mode 100644 index 0000000000000..9c5df690085c8 --- /dev/null +++ b/changelogs/unreleased/29801-add-slash-slack-commands-to-api-doc.yml @@ -0,0 +1,5 @@ +--- +title: Add Slack slash command api to services documentation and rearrange order and + cases +merge_request: 10757 +author: TM Lee diff --git a/changelogs/unreleased/30306-transaction-while-moving-issues-to-ghost-user.yml b/changelogs/unreleased/30306-transaction-while-moving-issues-to-ghost-user.yml new file mode 100644 index 0000000000000..5fc57e44be617 --- /dev/null +++ b/changelogs/unreleased/30306-transaction-while-moving-issues-to-ghost-user.yml @@ -0,0 +1,4 @@ +--- +title: Add a transaction around move_issues_to_ghost_user +merge_request: 10465 +author: diff --git a/changelogs/unreleased/fix_link_in_readme.yml b/changelogs/unreleased/fix_link_in_readme.yml new file mode 100644 index 0000000000000..be5ceac86563e --- /dev/null +++ b/changelogs/unreleased/fix_link_in_readme.yml @@ -0,0 +1,4 @@ +--- +title: Fix dead link to GDK on the README page +merge_request: +author: Dino Maric diff --git a/doc/administration/integration/plantuml.md b/doc/administration/integration/plantuml.md index 5c85683503913..b21817c1fd31a 100644 --- a/doc/administration/integration/plantuml.md +++ b/doc/administration/integration/plantuml.md @@ -28,7 +28,7 @@ using Tomcat: sudo apt-get install tomcat7 sudo cp target/plantuml.war /var/lib/tomcat7/webapps/plantuml.war sudo chown tomcat7:tomcat7 /var/lib/tomcat7/webapps/plantuml.war -sudo service restart tomcat7 +sudo service tomcat7 restart ``` Once the Tomcat service restarts the PlantUML service will be ready and diff --git a/doc/api/services.md b/doc/api/services.md index 7d4779f113763..0f42c2560992f 100644 --- a/doc/api/services.md +++ b/doc/api/services.md @@ -490,41 +490,98 @@ Remove all previously JIRA settings from a project. DELETE /projects/:id/services/jira ``` -## Mattermost Slash Commands +## Slack slash commands -Ability to receive slash commands from a Mattermost chat instance. +Ability to receive slash commands from a Slack chat instance. -### Create/Edit Mattermost Slash Command service +### Get Slack slash command service settings -Set Mattermost Slash Command for a project. +Get Slack slash command service settings for a project. ``` -PUT /projects/:id/services/mattermost-slash-commands +GET /projects/:id/services/slack-slash-commands +``` + +Example response: + +```json +{ + "id": 4, + "title": "Slack slash commands", + "created_at": "2017-06-27T05:51:39-07:00", + "updated_at": "2017-06-27T05:51:39-07:00", + "active": true, + "push_events": true, + "issues_events": true, + "merge_requests_events": true, + "tag_push_events": true, + "note_events": true, + "build_events": true, + "pipeline_events": true, + "properties": { + "token": "9koXpg98eAheJpvBs5tK" + } +} +``` + +### Create/Edit Slack slash command service + +Set Slack slash command for a project. + +``` +PUT /projects/:id/services/slack-slash-commands ``` Parameters: | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `token` | string | yes | The Mattermost token | +| `token` | string | yes | The Slack token | -### Delete Mattermost Slash Command service +### Delete Slack slash command service -Delete Mattermost Slash Command service for a project. +Delete Slack slash command service for a project. ``` -DELETE /projects/:id/services/mattermost-slash-commands +DELETE /projects/:id/services/slack-slash-commands ``` -### Get Mattermost Slash Command service settings +## Mattermost slash commands + +Ability to receive slash commands from a Mattermost chat instance. + +### Get Mattermost slash command service settings -Get Mattermost Slash Command service settings for a project. +Get Mattermost slash command service settings for a project. ``` GET /projects/:id/services/mattermost-slash-commands ``` +### Create/Edit Mattermost slash command service + +Set Mattermost slash command for a project. + +``` +PUT /projects/:id/services/mattermost-slash-commands +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `token` | string | yes | The Mattermost token | + + +### Delete Mattermost slash command service + +Delete Mattermost slash command service for a project. + +``` +DELETE /projects/:id/services/mattermost-slash-commands +``` + ## Pipeline-Emails Get emails for GitLab CI pipelines. diff --git a/doc/development/fe_guide/testing.md b/doc/development/fe_guide/testing.md index 66afbf4db4d64..157c13352ca07 100644 --- a/doc/development/fe_guide/testing.md +++ b/doc/development/fe_guide/testing.md @@ -14,8 +14,10 @@ for more information on general testing practices at GitLab. GitLab uses the [Karma][karma] test runner with [Jasmine][jasmine] as its test framework for our JavaScript unit tests. For tests that rely on DOM -manipulation we use fixtures which are pre-compiled from HAML source files and -served during testing by the [jasmine-jquery][jasmine-jquery] plugin. +manipulation, we generate HTML files using RSpec suites (see `spec/javascripts/fixtures/*.rb` for examples). +Some fixtures are still HAML templates that are translated to HTML files using the same mechanism (see `static_fixtures.rb`). +Those will be migrated over time. +Fixtures are served during testing by the [jasmine-jquery][jasmine-jquery] plugin. JavaScript tests live in `spec/javascripts/`, matching the folder structure of `app/assets/javascripts/`: `app/assets/javascripts/behaviors/autosize.js` diff --git a/spec/services/users/migrate_to_ghost_user_service_spec.rb b/spec/services/users/migrate_to_ghost_user_service_spec.rb index 8c5b7e41c1550..9e1edf1ac30d8 100644 --- a/spec/services/users/migrate_to_ghost_user_service_spec.rb +++ b/spec/services/users/migrate_to_ghost_user_service_spec.rb @@ -60,5 +60,23 @@ end end end + + context "when record migration fails with a rollback exception" do + before do + expect_any_instance_of(MergeRequest::ActiveRecord_Associations_CollectionProxy) + .to receive(:update_all).and_raise(ActiveRecord::Rollback) + end + + context "for records that were already migrated" do + let!(:issue) { create(:issue, project: project, author: user) } + let!(:merge_request) { create(:merge_request, source_project: project, author: user, target_branch: "first") } + + it "reverses the migration" do + service.execute + + expect(issue.reload.author).to eq(user) + end + end + end end end diff --git a/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb b/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb index 0eac587e9730a..dcc562c684b0f 100644 --- a/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb +++ b/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb @@ -35,5 +35,57 @@ expect(user).to be_blocked end + + context "race conditions" do + context "when #{record_class_name} migration fails and is rolled back" do + before do + expect_any_instance_of(record_class::ActiveRecord_Associations_CollectionProxy) + .to receive(:update_all).and_raise(ActiveRecord::Rollback) + end + + it 'rolls back the user block' do + service.execute + + expect(user.reload).not_to be_blocked + end + + it "doesn't unblock an previously-blocked user" do + user.block + + service.execute + + expect(user.reload).to be_blocked + end + end + + context "when #{record_class_name} migration fails with a non-rollback exception" do + before do + expect_any_instance_of(record_class::ActiveRecord_Associations_CollectionProxy) + .to receive(:update_all).and_raise(ArgumentError) + end + + it 'rolls back the user block' do + service.execute rescue nil + + expect(user.reload).not_to be_blocked + end + + it "doesn't unblock an previously-blocked user" do + user.block + + service.execute rescue nil + + expect(user.reload).to be_blocked + end + end + + it "blocks the user before #{record_class_name} migration begins" do + expect(service).to receive("migrate_#{record_class_name.parameterize('_')}s".to_sym) do + expect(user.reload).to be_blocked + end + + service.execute + end + end end end -- GitLab From cc248f3e7be6ae3cfd0d9f24eae92500e14b9e98 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Wed, 26 Apr 2017 14:05:13 +0100 Subject: [PATCH 076/363] Swapped flex out for grid system --- app/assets/stylesheets/framework/files.scss | 28 +++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 099187a519383..3737ae3e653f6 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -162,13 +162,31 @@ } .list-inline.previews { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - padding: $gl-padding; + display: inline-block; .preview { - flex-shrink: 0; + display: inline-block; + height: 280px; + min-width: 260px; + margin: 0; + padding: 16px; + @include make-xs-column(3); + } + + .panel { + margin: 0; + height: 100%; + + .panel-body { + padding: $gl-padding 0 0; + text-align: center; + } + } + + .img-thumbnail { + max-height: 195px; + max-width: 195px; + padding: 0; } } } -- GitLab From 0242c4dfc92da0c59583b0ac9f1d3f2e6dcd11b8 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 26 Apr 2017 17:03:06 +0100 Subject: [PATCH 077/363] Changed JS translation functions Added context function --- .../components/limit_warning_component.js | 2 +- .../components/stage_code_component.js | 4 +- .../components/stage_issue_component.js | 4 +- .../components/stage_plan_component.js | 4 +- .../components/stage_production_component.js | 4 +- .../components/stage_review_component.js | 4 +- .../components/stage_staging_component.js | 2 +- .../components/total_time_component.js | 6 +-- .../cycle_analytics/cycle_analytics_store.js | 17 +++--- app/assets/javascripts/locale/index.js | 9 +++- .../javascripts/vue_shared/translate.js | 19 +++++-- .../cycle_analytics/_empty_stage.html.haml | 2 +- .../cycle_analytics/_no_access.html.haml | 4 +- .../projects/cycle_analytics/show.html.haml | 29 ++++++----- locale/de/gitlab.po | 48 +++++++++++------ locale/en/gitlab.po | 48 +++++++++++------ locale/es/gitlab.po | 50 ++++++++++++------ locale/gitlab.pot | 52 +++++++++++++------ spec/javascripts/vue_shared/translate_spec.js | 8 +-- 19 files changed, 202 insertions(+), 114 deletions(-) diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js index 63e20478e9494..72ada943fea0f 100644 --- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js +++ b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js @@ -11,7 +11,7 @@ export default { aria-hidden="true" title="Limited to showing 50 events at most" data-placement="top"></i> - {{ 'Showing %d event' | translate-plural('Showing %d events', 50) }} + {{ n__('Showing %d event', 'Showing %d events', 50) }} </span> `, }; diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js index f72882872cdf1..0bd15ee773d34 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js @@ -28,11 +28,11 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({ <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> · <span> - {{ 'Opened' | translate }} + {{ __('Opened') }} <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> </span> <span> - {{ 'by' | translate }} + {{ __('by') }} <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> </span> </div> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js index bb265c8316feb..b6bf9a2572e8f 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js @@ -28,11 +28,11 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({ <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a> · <span> - {{ 'Opened' | translate }} + {{ __('Opened') }} <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> </span> <span> - {{ 'by' | translate }} + {{ __('by') }} <a :href="issue.author.webUrl" class="issue-author-link"> {{ issue.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js index 32b685faece6a..116c118b82413 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js @@ -31,10 +31,10 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({ </a> </h5> <span> - {{ 'First' | translate }} + {{ __('First') }} <span class="commit-icon">${iconCommit}</span> <a :href="commit.commitUrl" class="commit-hash-link monospace">{{ commit.shortSha }}</a> - {{ 'pushed by' | translate }} + {{ __('pushed by') }} <a :href="commit.author.webUrl" class="commit-author-link"> {{ commit.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js index 5c9186a2e497c..18fb09d6d2f95 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js @@ -28,11 +28,11 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({ <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a> · <span> - {{ 'Opened' | translate }} + {{ __('Opened') }} <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> </span> <span> - {{ 'by' | translate }} + {{ __('by') }} <a :href="issue.author.webUrl" class="issue-author-link"> {{ issue.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js index a047573548df0..af5f1b42ea2b7 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js @@ -28,11 +28,11 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({ <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> · <span> - {{ 'Opened' | translate }} + {{ __('Opened') }} <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> </span> <span> - {{ 'by' | translate }} + {{ __('by') }} <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> </span> <template v-if="mergeRequest.state === 'closed'"> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js index e8dfaf4294e19..a0bd1bab96e55 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js @@ -32,7 +32,7 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({ </h5> <span> <a :href="build.url" class="build-date">{{ build.date }}</a> - {{ 'by' | translate }} + {{ __('by') }} <a :href="build.author.webUrl" class="issue-author-link"> {{ build.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.js b/app/assets/javascripts/cycle_analytics/components/total_time_component.js index 47d82bdda90d5..48cc0a5c58dbb 100644 --- a/app/assets/javascripts/cycle_analytics/components/total_time_component.js +++ b/app/assets/javascripts/cycle_analytics/components/total_time_component.js @@ -12,9 +12,9 @@ global.cycleAnalytics.TotalTimeComponent = Vue.extend({ template: ` <span class="total-time"> <template v-if="Object.keys(time).length"> - <template v-if="time.days">{{ time.days }} <span>{{ 'day' | translate-plural('days', time.days) }}</span></template> - <template v-if="time.hours">{{ time.hours }} <span>{{ 'hr' | translate }}</span></template> - <template v-if="time.mins && !time.days">{{ time.mins }} <span>{{ 'min' | translate-plural('mins', time.mins) }}</span></template> + <template v-if="time.days">{{ time.days }} <span>{{ n__('day', 'days', time.days) }}</span></template> + <template v-if="time.hours">{{ time.hours }} <span>{{ __('hr') }}</span></template> + <template v-if="time.mins && !time.days">{{ time.mins }} <span>{{ n__('min', 'mins', time.mins) }}</span></template> <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>s</span></template> </template> <template v-else> diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js index 25d5092a1fd8e..1c57495121141 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js @@ -1,6 +1,5 @@ /* eslint-disable no-param-reassign */ - -import locale from '~/locale'; +import { __ } from '../locale'; require('../lib/utils/text_utility'); const DEFAULT_EVENT_OBJECTS = require('./default_event_objects'); @@ -9,13 +8,13 @@ const global = window.gl || (window.gl = {}); global.cycleAnalytics = global.cycleAnalytics || {}; const EMPTY_STAGE_TEXTS = { - issue: locale.gettext('The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.'), - plan: locale.gettext('The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.'), - code: locale.gettext('The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.'), - test: locale.gettext('The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.'), - review: locale.gettext('The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.'), - staging: locale.gettext('The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.'), - production: locale.gettext('The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.'), + issue: __('The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.'), + plan: __('The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.'), + code: __('The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.'), + test: __('The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.'), + review: __('The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.'), + staging: __('The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.'), + production: __('The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.'), }; global.cycleAnalytics.CycleAnalyticsStore = { diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js index 9f81024ff68c2..3907b0e2aba34 100644 --- a/app/assets/javascripts/locale/index.js +++ b/app/assets/javascripts/locale/index.js @@ -10,6 +10,13 @@ const locales = { }; const lang = document.querySelector('html').getAttribute('lang') || 'en'; +const locale = new Jed(locales[lang]); +const gettext = locale.gettext.bind(locale); +const ngettext = locale.ngettext.bind(locale); +const pgettext = locale.pgettext.bind(locale); export { lang }; -export default new Jed(locales[lang]); +export { gettext as __ }; +export { ngettext as n__ }; +export { pgettext as s__ }; +export default locale; diff --git a/app/assets/javascripts/vue_shared/translate.js b/app/assets/javascripts/vue_shared/translate.js index 7fc6f1ce38af5..c2c20ea0853d3 100644 --- a/app/assets/javascripts/vue_shared/translate.js +++ b/app/assets/javascripts/vue_shared/translate.js @@ -1,8 +1,17 @@ -import locale from '../locale'; +import { + __, + n__, + s__, +} from '../locale'; export default (Vue) => { - Vue.filter('translate', text => locale.gettext(text)); - - Vue.filter('translate-plural', (text, pluralText, count) => - locale.ngettext(text, pluralText, count).replace(/%d/g, count)); + Vue.mixin({ + methods: { + __(text) { return __(text); }, + n__(text, pluralText, count) { + return n__(text, pluralText, count).replace(/%d/g, count); + }, + s__(context, key) { return s__(context, key); }, + }, + }); }; diff --git a/app/views/projects/cycle_analytics/_empty_stage.html.haml b/app/views/projects/cycle_analytics/_empty_stage.html.haml index 27190785fff2d..57e95c4c14f13 100644 --- a/app/views/projects/cycle_analytics/_empty_stage.html.haml +++ b/app/views/projects/cycle_analytics/_empty_stage.html.haml @@ -2,6 +2,6 @@ .empty-stage .icon-no-data = custom_icon ('icon_no_data') - %h4 {{ 'We don\'t have enough data to show this stage.' | translate }} + %h4 {{ 'We don\'t have enough data to show this stage.' | t }} %p {{currentStage.emptyStageText}} diff --git a/app/views/projects/cycle_analytics/_no_access.html.haml b/app/views/projects/cycle_analytics/_no_access.html.haml index 474d0f410a704..25109bd61cd34 100644 --- a/app/views/projects/cycle_analytics/_no_access.html.haml +++ b/app/views/projects/cycle_analytics/_no_access.html.haml @@ -2,6 +2,6 @@ .no-access-stage .icon-lock = custom_icon ('icon_lock') - %h4 {{ 'You need permission.' | translate }} + %h4 {{ 'You need permission.' | t }} %p - {{ 'Want to see the data? Please ask administrator for access.' | translate }} + {{ 'Want to see the data? Please ask administrator for access.' | t }} diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index a7783b6958803..2e0e4753e6888 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -8,7 +8,8 @@ = render "projects/head" #cycle-analytics{ class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } } - - if @cycle_analytics_no_data + - if @cycle_analytics_no_data || true + {{ s__('context', 'key') }} .bordered-box.landing.content-block{ "v-if" => "!isOverviewDialogDismissed" } = icon("times", class: "dismiss-icon", "@click" => "dismissOverviewDialog()") .row @@ -16,17 +17,17 @@ = custom_icon('icon_cycle_analytics_splash') .col-sm-8.col-xs-12.inner-content %h4 - {{ 'Introducing Cycle Analytics' | translate }} + {{ __('Introducing Cycle Analytics') }} %p - {{ 'Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.' | translate }} + {{ __('Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.') }} = link_to help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn' do - {{ 'Read more' | translate }} + {{ __('Read more') }} = icon("spinner spin", "v-show" => "isLoading") .wrapper{ "v-show" => "!isLoading && !hasError" } .panel.panel-default .panel-heading - {{ 'Pipeline Health' | translate }} + {{ __('Pipeline Health') }} .content-block .container-fluid .row @@ -36,15 +37,15 @@ .col-sm-3.col-xs-12.column .dropdown.inline.js-ca-dropdown %button.dropdown-menu-toggle{ "data-toggle" => "dropdown", :type => "button" } - %span.dropdown-label {{ 'Last 30 days' | translate }} + %span.dropdown-label {{ __('Last 30 days') }} %i.fa.fa-chevron-down %ul.dropdown-menu.dropdown-menu-align-right %li %a{ "href" => "#", "data-value" => "30" } - {{ 'Last 30 days' | translate }} + {{ __('Last 30 days') }} %li %a{ "href" => "#", "data-value" => "90" } - {{ 'Last 90 days' | translate }} + {{ __('Last 90 days') }} .stage-panel-container .panel.panel-default.stage-panel .panel-heading @@ -52,19 +53,19 @@ %ul %li.stage-header %span.stage-name - {{ 'Stage' | translate }} + {{ __('Stage') }} %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The phase of the development lifecycle.", "aria-hidden" => "true" } %li.median-header %span.stage-name - {{ 'Median' | translate }} + {{ __('Median') }} %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.", "aria-hidden" => "true" } %li.event-header %span.stage-name - {{ currentStage ? currentStage.legend : 'Related Issues' | translate }} + {{ __(currentStage ? currentStage.legend : 'Related Issues') }} %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The collection of events added to the data gathered for that stage.", "aria-hidden" => "true" } %li.total-time-header %span.stage-name - {{ 'Total Time' | translate }} + {{ __('Total Time') }} %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The time taken by each data entry gathered by that stage.", "aria-hidden" => "true" } .stage-panel-body %nav.stage-nav @@ -77,10 +78,10 @@ %span{ "v-if" => "stage.value" } {{ stage.value }} %span.stage-empty{ "v-else" => true } - {{ 'Not enough data' | translate }} + {{ __('Not enough data') }} %template{ "v-else" => true } %span.not-available - {{ 'Not available' | translate }} + {{ __('Not available') }} .section.stage-events %template{ "v-if" => "isLoadingStage" } = icon("spinner spin") diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po index f3b6cb6e90590..c291b6adab698 100644 --- a/locale/de/gitlab.po +++ b/locale/de/gitlab.po @@ -17,47 +17,65 @@ msgstr "" "Plural-Forms: nplurals=2; plural=n != 1;\n" "\n" -msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." -msgstr "" - msgid "Deutsch" msgstr "" msgid "English" msgstr "" -msgid "Introducing Cycle Analytics" +msgid "First" msgstr "" -msgid "Last 30 days" +msgid "Opened" msgstr "" -msgid "Last 90 days" -msgstr "" +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "" +msgstr[1] "" -msgid "Median" +msgid "Spanish" msgstr "" -msgid "Not available" +msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." msgstr "" -msgid "Not enough data" +msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." msgstr "" -msgid "Pipeline Health" +msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." msgstr "" -msgid "Read more" +msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." msgstr "" -msgid "Spanish" +msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." msgstr "" -msgid "Stage" +msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time." msgstr "" -msgid "Total Time" +msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." msgstr "" msgid "Want to see the data? Please ask administrator for access." msgstr "" + +msgid "by" +msgstr "" + +msgid "day" +msgid_plural "days" +msgstr[0] "" +msgstr[1] "" + +msgid "hr" +msgstr "" + +msgid "min" +msgid_plural "mins" +msgstr[0] "" +msgstr[1] "" + +msgid "pushed by" +msgstr "" diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po index 9302986848695..7e13ddb1c1878 100644 --- a/locale/en/gitlab.po +++ b/locale/en/gitlab.po @@ -17,47 +17,65 @@ msgstr "" "Plural-Forms: nplurals=2; plural=n != 1;\n" "\n" -msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." -msgstr "" - msgid "Deutsch" msgstr "" msgid "English" msgstr "" -msgid "Introducing Cycle Analytics" +msgid "First" msgstr "" -msgid "Last 30 days" +msgid "Opened" msgstr "" -msgid "Last 90 days" -msgstr "" +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "" +msgstr[1] "" -msgid "Median" +msgid "Spanish" msgstr "" -msgid "Not available" +msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." msgstr "" -msgid "Not enough data" +msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." msgstr "" -msgid "Pipeline Health" +msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." msgstr "" -msgid "Read more" +msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." msgstr "" -msgid "Spanish" +msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." msgstr "" -msgid "Stage" +msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time." msgstr "" -msgid "Total Time" +msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." msgstr "" msgid "Want to see the data? Please ask administrator for access." msgstr "" + +msgid "by" +msgstr "" + +msgid "day" +msgid_plural "days" +msgstr[0] "" +msgstr[1] "" + +msgid "hr" +msgstr "" + +msgid "min" +msgid_plural "mins" +msgstr[0] "" +msgstr[1] "" + +msgid "pushed by" +msgstr "" diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po index 23863cbdb92e7..f5bf45e2cd31f 100644 --- a/locale/es/gitlab.po +++ b/locale/es/gitlab.po @@ -17,47 +17,65 @@ msgstr "" "Last-Translator: \n" "X-Generator: Poedit 2.0.1\n" -msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." -msgstr "" - msgid "Deutsch" msgstr "Alemán" msgid "English" msgstr "Inglés" -msgid "Introducing Cycle Analytics" +msgid "First" msgstr "" -msgid "Last 30 days" +msgid "Opened" msgstr "" -msgid "Last 90 days" +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "" +msgstr[1] "" + +msgid "Spanish" +msgstr "Español" + +msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." msgstr "" -msgid "Median" +msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." msgstr "" -msgid "Not available" +msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." msgstr "" -msgid "Not enough data" +msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." msgstr "" -msgid "Pipeline Health" +msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." msgstr "" -msgid "Read more" +msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time." msgstr "" -msgid "Spanish" -msgstr "Español" +msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." +msgstr "" -msgid "Stage" +msgid "Want to see the data? Please ask administrator for access." msgstr "" -msgid "Total Time" +msgid "by" msgstr "" -msgid "Want to see the data? Please ask administrator for access." +msgid "day" +msgid_plural "days" +msgstr[0] "" +msgstr[1] "" + +msgid "hr" +msgstr "" + +msgid "min" +msgid_plural "mins" +msgstr[0] "" +msgstr[1] "" + +msgid "pushed by" msgstr "" diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ada103157d987..372ab3cd3ccda 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-04-21 09:01+0100\n" -"PO-Revision-Date: 2017-04-21 09:01+0100\n" +"POT-Creation-Date: 2017-04-26 16:26+0100\n" +"PO-Revision-Date: 2017-04-26 16:26+0100\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" "Language: \n" @@ -18,47 +18,65 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" -msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." -msgstr "" - msgid "Deutsch" msgstr "" msgid "English" msgstr "" -msgid "Introducing Cycle Analytics" +msgid "First" msgstr "" -msgid "Last 30 days" +msgid "Opened" msgstr "" -msgid "Last 90 days" -msgstr "" +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "" +msgstr[1] "" -msgid "Median" +msgid "Spanish" msgstr "" -msgid "Not available" +msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." msgstr "" -msgid "Not enough data" +msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." msgstr "" -msgid "Pipeline Health" +msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." msgstr "" -msgid "Read more" +msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." msgstr "" -msgid "Spanish" +msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." msgstr "" -msgid "Stage" +msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time." msgstr "" -msgid "Total Time" +msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." msgstr "" msgid "Want to see the data? Please ask administrator for access." msgstr "" + +msgid "by" +msgstr "" + +msgid "day" +msgid_plural "days" +msgstr[0] "" +msgstr[1] "" + +msgid "hr" +msgstr "" + +msgid "min" +msgid_plural "mins" +msgstr[0] "" +msgstr[1] "" + +msgid "pushed by" +msgstr "" diff --git a/spec/javascripts/vue_shared/translate_spec.js b/spec/javascripts/vue_shared/translate_spec.js index 74bd4ff86b1bb..5dc0be5011647 100644 --- a/spec/javascripts/vue_shared/translate_spec.js +++ b/spec/javascripts/vue_shared/translate_spec.js @@ -17,7 +17,7 @@ describe('Vue translate filter', () => { el, template: ` <span> - {{ 'testing' | translate }} + {{ 'testing' | t }} </span> `, }).$mount(); @@ -36,7 +36,7 @@ describe('Vue translate filter', () => { el, template: ` <span> - {{ '%d day' | translate-plural('%d days', 1) }} + {{ '%d day' | nt('%d days', 1) }} </span> `, }).$mount(); @@ -55,7 +55,7 @@ describe('Vue translate filter', () => { el, template: ` <span> - {{ '%d day' | translate-plural('%d days', 2) }} + {{ '%d day' | nt('%d days', 2) }} </span> `, }).$mount(); @@ -74,7 +74,7 @@ describe('Vue translate filter', () => { el, template: ` <span> - {{ 'day' | translate-plural('days', 2) }} + {{ 'day' | nt('days', 2) }} </span> `, }).$mount(); -- GitLab From cb06f09e982248ff6dc8218ffa1f431141fd1ad8 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 26 Apr 2017 17:20:13 +0100 Subject: [PATCH 078/363] Removed t filter from other places --- app/assets/javascripts/locale/de/app.js | 2 +- app/assets/javascripts/locale/en/app.js | 2 +- app/assets/javascripts/locale/es/app.js | 2 +- .../cycle_analytics/_empty_stage.html.haml | 2 +- .../cycle_analytics/_no_access.html.haml | 4 +- locale/de/gitlab.po | 51 +++++++++-------- locale/en/gitlab.po | 51 +++++++++-------- locale/es/gitlab.po | 51 +++++++++-------- locale/gitlab.pot | 55 ++++++++++--------- spec/javascripts/vue_shared/translate_spec.js | 8 +-- 10 files changed, 120 insertions(+), 108 deletions(-) diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js index 036572c5568e1..76f01f1affec2 100644 --- a/app/assets/javascripts/locale/de/app.js +++ b/app/assets/javascripts/locale/de/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deutsch":[""],"English":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Median":[""],"Not available":[""],"Not enough data":[""],"Pipeline Health":[""],"Read more":[""],"Spanish":[""],"Stage":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""]}}}; \ No newline at end of file +var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deutsch":[""],"English":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Median":[""],"Not available":[""],"Not enough data":[""],"Pipeline Health":[""],"Read more":[""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"Total Time":[""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/en/app.js b/app/assets/javascripts/locale/en/app.js index dfab3c8f0ded3..9a2d27c9f24b2 100644 --- a/app/assets/javascripts/locale/en/app.js +++ b/app/assets/javascripts/locale/en/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deutsch":[""],"English":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Median":[""],"Not available":[""],"Not enough data":[""],"Pipeline Health":[""],"Read more":[""],"Spanish":[""],"Stage":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""]}}}; \ No newline at end of file +var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deutsch":[""],"English":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Median":[""],"Not available":[""],"Not enough data":[""],"Pipeline Health":[""],"Read more":[""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"Total Time":[""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/es/app.js b/app/assets/javascripts/locale/es/app.js index a12129bef1356..1518d8852334a 100644 --- a/app/assets/javascripts/locale/es/app.js +++ b/app/assets/javascripts/locale/es/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-13 00:07-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deutsch":["Alemán"],"English":["Inglés"],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Median":[""],"Not available":[""],"Not enough data":[""],"Pipeline Health":[""],"Read more":[""],"Spanish":["Español"],"Stage":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""]}}}; \ No newline at end of file +var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-13 00:07-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deutsch":["Alemán"],"English":["Inglés"],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Median":[""],"Not available":[""],"Not enough data":[""],"Pipeline Health":[""],"Read more":[""],"Spanish":["Español"],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"Total Time":[""]}}}; \ No newline at end of file diff --git a/app/views/projects/cycle_analytics/_empty_stage.html.haml b/app/views/projects/cycle_analytics/_empty_stage.html.haml index 57e95c4c14f13..cdad0bc723188 100644 --- a/app/views/projects/cycle_analytics/_empty_stage.html.haml +++ b/app/views/projects/cycle_analytics/_empty_stage.html.haml @@ -2,6 +2,6 @@ .empty-stage .icon-no-data = custom_icon ('icon_no_data') - %h4 {{ 'We don\'t have enough data to show this stage.' | t }} + %h4 {{ __('We don\'t have enough data to show this stage.') }} %p {{currentStage.emptyStageText}} diff --git a/app/views/projects/cycle_analytics/_no_access.html.haml b/app/views/projects/cycle_analytics/_no_access.html.haml index 25109bd61cd34..dcfd1b7afc09d 100644 --- a/app/views/projects/cycle_analytics/_no_access.html.haml +++ b/app/views/projects/cycle_analytics/_no_access.html.haml @@ -2,6 +2,6 @@ .no-access-stage .icon-lock = custom_icon ('icon_lock') - %h4 {{ 'You need permission.' | t }} + %h4 {{ __('You need permission.') }} %p - {{ 'Want to see the data? Please ask administrator for access.' | t }} + {{ __('Want to see the data? Please ask administrator for access.') }} diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po index c291b6adab698..aeee577c1444e 100644 --- a/locale/de/gitlab.po +++ b/locale/de/gitlab.po @@ -17,26 +17,45 @@ msgstr "" "Plural-Forms: nplurals=2; plural=n != 1;\n" "\n" +msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." +msgstr "" + msgid "Deutsch" msgstr "" msgid "English" msgstr "" -msgid "First" +msgid "Introducing Cycle Analytics" +msgstr "" + +msgid "Last 30 days" +msgstr "" + +msgid "Last 90 days" msgstr "" -msgid "Opened" +msgid "Median" msgstr "" -msgid "Showing %d event" -msgid_plural "Showing %d events" -msgstr[0] "" -msgstr[1] "" +msgid "Not available" +msgstr "" + +msgid "Not enough data" +msgstr "" + +msgid "Pipeline Health" +msgstr "" + +msgid "Read more" +msgstr "" msgid "Spanish" msgstr "" +msgid "Stage" +msgstr "" + msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." msgstr "" @@ -58,24 +77,8 @@ msgstr "" msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." msgstr "" -msgid "Want to see the data? Please ask administrator for access." -msgstr "" - -msgid "by" +msgid "Total Time" msgstr "" -msgid "day" -msgid_plural "days" -msgstr[0] "" -msgstr[1] "" - -msgid "hr" -msgstr "" - -msgid "min" -msgid_plural "mins" -msgstr[0] "" -msgstr[1] "" - -msgid "pushed by" +msgid "Want to see the data? Please ask administrator for access." msgstr "" diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po index 7e13ddb1c1878..9d32f6c9f8f25 100644 --- a/locale/en/gitlab.po +++ b/locale/en/gitlab.po @@ -17,26 +17,45 @@ msgstr "" "Plural-Forms: nplurals=2; plural=n != 1;\n" "\n" +msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." +msgstr "" + msgid "Deutsch" msgstr "" msgid "English" msgstr "" -msgid "First" +msgid "Introducing Cycle Analytics" +msgstr "" + +msgid "Last 30 days" +msgstr "" + +msgid "Last 90 days" msgstr "" -msgid "Opened" +msgid "Median" msgstr "" -msgid "Showing %d event" -msgid_plural "Showing %d events" -msgstr[0] "" -msgstr[1] "" +msgid "Not available" +msgstr "" + +msgid "Not enough data" +msgstr "" + +msgid "Pipeline Health" +msgstr "" + +msgid "Read more" +msgstr "" msgid "Spanish" msgstr "" +msgid "Stage" +msgstr "" + msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." msgstr "" @@ -58,24 +77,8 @@ msgstr "" msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." msgstr "" -msgid "Want to see the data? Please ask administrator for access." -msgstr "" - -msgid "by" +msgid "Total Time" msgstr "" -msgid "day" -msgid_plural "days" -msgstr[0] "" -msgstr[1] "" - -msgid "hr" -msgstr "" - -msgid "min" -msgid_plural "mins" -msgstr[0] "" -msgstr[1] "" - -msgid "pushed by" +msgid "Want to see the data? Please ask administrator for access." msgstr "" diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po index f5bf45e2cd31f..af7e127bc3a7d 100644 --- a/locale/es/gitlab.po +++ b/locale/es/gitlab.po @@ -17,26 +17,45 @@ msgstr "" "Last-Translator: \n" "X-Generator: Poedit 2.0.1\n" +msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." +msgstr "" + msgid "Deutsch" msgstr "Alemán" msgid "English" msgstr "Inglés" -msgid "First" +msgid "Introducing Cycle Analytics" +msgstr "" + +msgid "Last 30 days" +msgstr "" + +msgid "Last 90 days" msgstr "" -msgid "Opened" +msgid "Median" msgstr "" -msgid "Showing %d event" -msgid_plural "Showing %d events" -msgstr[0] "" -msgstr[1] "" +msgid "Not available" +msgstr "" + +msgid "Not enough data" +msgstr "" + +msgid "Pipeline Health" +msgstr "" + +msgid "Read more" +msgstr "" msgid "Spanish" msgstr "Español" +msgid "Stage" +msgstr "" + msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." msgstr "" @@ -58,24 +77,8 @@ msgstr "" msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." msgstr "" -msgid "Want to see the data? Please ask administrator for access." -msgstr "" - -msgid "by" +msgid "Total Time" msgstr "" -msgid "day" -msgid_plural "days" -msgstr[0] "" -msgstr[1] "" - -msgid "hr" -msgstr "" - -msgid "min" -msgid_plural "mins" -msgstr[0] "" -msgstr[1] "" - -msgid "pushed by" +msgid "Want to see the data? Please ask administrator for access." msgstr "" diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 372ab3cd3ccda..b99506788c72f 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-04-26 16:26+0100\n" -"PO-Revision-Date: 2017-04-26 16:26+0100\n" +"POT-Creation-Date: 2017-04-26 17:18+0100\n" +"PO-Revision-Date: 2017-04-26 17:18+0100\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" "Language: \n" @@ -18,26 +18,45 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" +msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." +msgstr "" + msgid "Deutsch" msgstr "" msgid "English" msgstr "" -msgid "First" +msgid "Introducing Cycle Analytics" +msgstr "" + +msgid "Last 30 days" +msgstr "" + +msgid "Last 90 days" msgstr "" -msgid "Opened" +msgid "Median" msgstr "" -msgid "Showing %d event" -msgid_plural "Showing %d events" -msgstr[0] "" -msgstr[1] "" +msgid "Not available" +msgstr "" + +msgid "Not enough data" +msgstr "" + +msgid "Pipeline Health" +msgstr "" + +msgid "Read more" +msgstr "" msgid "Spanish" msgstr "" +msgid "Stage" +msgstr "" + msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." msgstr "" @@ -59,24 +78,8 @@ msgstr "" msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." msgstr "" -msgid "Want to see the data? Please ask administrator for access." -msgstr "" - -msgid "by" +msgid "Total Time" msgstr "" -msgid "day" -msgid_plural "days" -msgstr[0] "" -msgstr[1] "" - -msgid "hr" -msgstr "" - -msgid "min" -msgid_plural "mins" -msgstr[0] "" -msgstr[1] "" - -msgid "pushed by" +msgid "Want to see the data? Please ask administrator for access." msgstr "" diff --git a/spec/javascripts/vue_shared/translate_spec.js b/spec/javascripts/vue_shared/translate_spec.js index 5dc0be5011647..cbb3cbdff4675 100644 --- a/spec/javascripts/vue_shared/translate_spec.js +++ b/spec/javascripts/vue_shared/translate_spec.js @@ -17,7 +17,7 @@ describe('Vue translate filter', () => { el, template: ` <span> - {{ 'testing' | t }} + {{ __('testing') }} </span> `, }).$mount(); @@ -36,7 +36,7 @@ describe('Vue translate filter', () => { el, template: ` <span> - {{ '%d day' | nt('%d days', 1) }} + {{ n__('%d day', '%d days', 1) }} </span> `, }).$mount(); @@ -55,7 +55,7 @@ describe('Vue translate filter', () => { el, template: ` <span> - {{ '%d day' | nt('%d days', 2) }} + {{ n__('%d day', '%d days', 2) }} </span> `, }).$mount(); @@ -74,7 +74,7 @@ describe('Vue translate filter', () => { el, template: ` <span> - {{ 'day' | nt('days', 2) }} + {{ n__('day', 'days', 2) }} </span> `, }).$mount(); -- GitLab From c159727894d399152bc04914ef7b936417d6e1bb Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 26 Apr 2017 17:26:07 +0100 Subject: [PATCH 079/363] Removed random test code --- app/views/projects/cycle_analytics/show.html.haml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index 2e0e4753e6888..65afe41fe9e53 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -8,8 +8,7 @@ = render "projects/head" #cycle-analytics{ class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } } - - if @cycle_analytics_no_data || true - {{ s__('context', 'key') }} + - if @cycle_analytics_no_data .bordered-box.landing.content-block{ "v-if" => "!isOverviewDialogDismissed" } = icon("times", class: "dismiss-icon", "@click" => "dismissOverviewDialog()") .row -- GitLab From 82e967bc3ebc6b884651ddcadff2142332c365a5 Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Wed, 26 Apr 2017 13:02:07 -0500 Subject: [PATCH 080/363] Adjust the patch for the gettext_i18n_rails and gettext_i18n_rails_js gems. This is more simple now given we're using the recommended code to annotate content to be translated. --- .../initializers/gettext_rails_i18n_patch.rb | 27 +++---------------- 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/config/initializers/gettext_rails_i18n_patch.rb b/config/initializers/gettext_rails_i18n_patch.rb index d74c1f597fefc..6785f361eda77 100644 --- a/config/initializers/gettext_rails_i18n_patch.rb +++ b/config/initializers/gettext_rails_i18n_patch.rb @@ -1,8 +1,7 @@ require 'gettext_i18n_rails/haml_parser' require 'gettext_i18n_rails_js/parser/javascript' -VUE_TRANSLATE_REGEX = /{{ ([^{]*)( \| translate) }}/ -VUE_TRANSLATE_PLURAL_REGEX = /{{ ([^{]*)( \| translate-plural\((.*), (.*)\)) }}/ +VUE_TRANSLATE_REGEX = /{{ (N|n|s)?__\((.*)\) }}/ module GettextI18nRails class HamlParser @@ -10,17 +9,13 @@ class HamlParser # We need to convert text in Mustache format # to a format that can be parsed by Gettext scripts. - # If we found a content like "{{ 'Stage' | translate }}" + # If we found a content like "{{ __('Stage') }}" # in a HAML file we convert it to "= _('Stage')", that way # it can be processed by the "rake gettext:find" script. # # Overwrites: https://github.com/grosser/gettext_i18n_rails/blob/8396387a431e0f8ead72fc1cd425cad2fa4992f2/lib/gettext_i18n_rails/haml_parser.rb#L9 def self.convert_to_code(text) - # {{ 'Stage' | translate }} => = _('Stage') - text.gsub!(VUE_TRANSLATE_REGEX, "= _(\\1)") - - # {{ 'user' | translate-plural('users', users.size) }} => = n_('user', 'users', users.size) - text.gsub!(VUE_TRANSLATE_PLURAL_REGEX, "= n_(\\1, \\3, \\4)") + text.gsub!(VUE_TRANSLATE_REGEX, "= \\1_(\\2)") old_convert_to_code(text) end @@ -43,22 +38,6 @@ def target?(file) ".vue" ].include? ::File.extname(file) end - - protected - - # Overwrites: https://github.com/webhippie/gettext_i18n_rails_js/blob/46c58db6d2053a4f5f36a0eb024ea706ff5707cb/lib/gettext_i18n_rails_js/parser/javascript.rb#L46 - def collect_for(value) - ::File.open(value) do |f| - f.each_line.each_with_index.collect do |line, idx| - line.gsub!(VUE_TRANSLATE_REGEX, "__(\\1)") - line.gsub!(VUE_TRANSLATE_PLURAL_REGEX, "n__(\\1, \\3, \\4)") - - line.scan(invoke_regex).collect do |function, arguments| - yield(function, arguments, idx + 1) - end - end.inject([], :+).compact - end - end end end end -- GitLab From 1f8226e1837eaaaffa8bb2efc498d3058049fa9c Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Wed, 26 Apr 2017 15:17:07 -0600 Subject: [PATCH 081/363] check if description changed prior to binding TaskLists and calling renderGFM --- .../issue_show/issue_title_description.vue | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index af2fe61af2bb1..ef7ac83f336a0 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -30,8 +30,9 @@ export default { return { poll, timeoutId: null, - title: null, - description: null, + title: '<span></span>', + description: '<span></span>', + descriptionChange: false, }; }, methods: { @@ -40,6 +41,9 @@ export default { this.triggerAnimation(body); }, triggerAnimation(body) { + // always reset to false before checking the change + this.descriptionChange = false; + const { title, description } = body; this.descriptionText = body.description_text; @@ -57,7 +61,12 @@ export default { if (!noTitleChange) { elementsToVisualize.push(this.$el.querySelector('.title')); - } else if (!noDescriptionChange) { + } + + if (!noDescriptionChange) { + // only change to true when we need to bind TaskLists the html of description + this.descriptionChange = true; + elementsToVisualize.push(this.$el.querySelector('.wiki')); } @@ -100,15 +109,19 @@ export default { }); }, updated() { - const tl = new gl.TaskList({ - dataType: 'issue', - fieldName: 'description', - selector: '.detail-page-description', - }); + // if new html is injected (description changed) - bind TaskList and call renderGFM + if (this.descriptionChange) { + const tl = new gl.TaskList({ + dataType: 'issue', + fieldName: 'description', + selector: '.detail-page-description', + }); - $(this.$refs['issue-content-container-gfm-entry']).renderGFM(); + $(this.$refs['issue-content-container-gfm-entry']).renderGFM(); - return tl; + return tl; + } + return null; }, }; </script> -- GitLab From a4267d2d0f9b414aeeb1e42c6e0eca8a53af72e8 Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Wed, 26 Apr 2017 16:19:06 -0500 Subject: [PATCH 082/363] Add some translations for Spanish. --- app/assets/javascripts/locale/de/app.js | 2 +- app/assets/javascripts/locale/en/app.js | 2 +- app/assets/javascripts/locale/es/app.js | 2 +- locale/de/gitlab.po | 30 +++++++++++ locale/en/gitlab.po | 30 +++++++++++ locale/es/gitlab.po | 69 ++++++++++++++++++------- locale/gitlab.pot | 34 +++++++++++- 7 files changed, 145 insertions(+), 24 deletions(-) diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js index 76f01f1affec2..d74d36711d360 100644 --- a/app/assets/javascripts/locale/de/app.js +++ b/app/assets/javascripts/locale/de/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deutsch":[""],"English":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Median":[""],"Not available":[""],"Not enough data":[""],"Pipeline Health":[""],"Read more":[""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"Total Time":[""]}}}; \ No newline at end of file +var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deutsch":[""],"English":[""],"First":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Median":[""],"Not available":[""],"Not enough data":[""],"Opened":[""],"Pipeline Health":[""],"Read more":[""],"Showing %d event":["",""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"by":[""],"day":["",""],"hr":[""],"min":["",""],"pushed by":[""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/en/app.js b/app/assets/javascripts/locale/en/app.js index 9a2d27c9f24b2..05ddc42e33386 100644 --- a/app/assets/javascripts/locale/en/app.js +++ b/app/assets/javascripts/locale/en/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deutsch":[""],"English":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Median":[""],"Not available":[""],"Not enough data":[""],"Pipeline Health":[""],"Read more":[""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"Total Time":[""]}}}; \ No newline at end of file +var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deutsch":[""],"English":[""],"First":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Median":[""],"Not available":[""],"Not enough data":[""],"Opened":[""],"Pipeline Health":[""],"Read more":[""],"Showing %d event":["",""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"by":[""],"day":["",""],"hr":[""],"min":["",""],"pushed by":[""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/es/app.js b/app/assets/javascripts/locale/es/app.js index 1518d8852334a..bb5976c572a6c 100644 --- a/app/assets/javascripts/locale/es/app.js +++ b/app/assets/javascripts/locale/es/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-13 00:07-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deutsch":["Alemán"],"English":["Inglés"],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Median":[""],"Not available":[""],"Not enough data":[""],"Pipeline Health":[""],"Read more":[""],"Spanish":["Español"],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"Total Time":[""]}}}; \ No newline at end of file +var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-26 16:07-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"Deutsch":["Alemán"],"English":["Inglés"],"First":["Primer"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last 30 days":["Últimos 30 dÃas"],"Last 90 days":["Últimos 90 dÃas"],"Median":["Mediana"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"Opened":["Abiertos"],"Pipeline Health":["Estado del Pipeline"],"Read more":["Leer más"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"Spanish":["Español"],"Stage":["Etapa"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde la primera confirmación hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de temas muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primera confirmación."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de un tema y el despliegue del código a producción. Los datos se añadirá automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirá automáticamente una vez que se implementa en producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirá automáticamente luego de que el primer pipeline termine de ejecutarse."],"Total Time":["Tiempo total"],"Want to see the data? Please ask administrator for access.":["¿Quieres ver los datos? Por favor pedir acceso al administrador."],"by":["por"],"day":["dÃa","dÃas"],"hr":[""],"min":["",""],"pushed by":["enviado por"]}}}; \ No newline at end of file diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po index aeee577c1444e..747d446ebc41f 100644 --- a/locale/de/gitlab.po +++ b/locale/de/gitlab.po @@ -26,6 +26,9 @@ msgstr "" msgid "English" msgstr "" +msgid "First" +msgstr "" + msgid "Introducing Cycle Analytics" msgstr "" @@ -44,12 +47,20 @@ msgstr "" msgid "Not enough data" msgstr "" +msgid "Opened" +msgstr "" + msgid "Pipeline Health" msgstr "" msgid "Read more" msgstr "" +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "" +msgstr[1] "" + msgid "Spanish" msgstr "" @@ -82,3 +93,22 @@ msgstr "" msgid "Want to see the data? Please ask administrator for access." msgstr "" + +msgid "by" +msgstr "" + +msgid "day" +msgid_plural "days" +msgstr[0] "" +msgstr[1] "" + +msgid "hr" +msgstr "" + +msgid "min" +msgid_plural "mins" +msgstr[0] "" +msgstr[1] "" + +msgid "pushed by" +msgstr "" diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po index 9d32f6c9f8f25..d2ecc6cd73aff 100644 --- a/locale/en/gitlab.po +++ b/locale/en/gitlab.po @@ -26,6 +26,9 @@ msgstr "" msgid "English" msgstr "" +msgid "First" +msgstr "" + msgid "Introducing Cycle Analytics" msgstr "" @@ -44,12 +47,20 @@ msgstr "" msgid "Not enough data" msgstr "" +msgid "Opened" +msgstr "" + msgid "Pipeline Health" msgstr "" msgid "Read more" msgstr "" +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "" +msgstr[1] "" + msgid "Spanish" msgstr "" @@ -82,3 +93,22 @@ msgstr "" msgid "Want to see the data? Please ask administrator for access." msgstr "" + +msgid "by" +msgstr "" + +msgid "day" +msgid_plural "days" +msgstr[0] "" +msgstr[1] "" + +msgid "hr" +msgstr "" + +msgid "min" +msgid_plural "mins" +msgstr[0] "" +msgstr[1] "" + +msgid "pushed by" +msgstr "" diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po index af7e127bc3a7d..404ab89c7cd94 100644 --- a/locale/es/gitlab.po +++ b/locale/es/gitlab.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2017-04-13 00:07-0500\n" +"PO-Revision-Date: 2017-04-26 16:07-0500\n" "Language-Team: Spanish\n" "Language: es\n" "MIME-Version: 1.0\n" @@ -18,7 +18,7 @@ msgstr "" "X-Generator: Poedit 2.0.1\n" msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." -msgstr "" +msgstr "Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto." msgid "Deutsch" msgstr "Alemán" @@ -26,59 +26,90 @@ msgstr "Alemán" msgid "English" msgstr "Inglés" +msgid "First" +msgstr "Primer" + msgid "Introducing Cycle Analytics" -msgstr "" +msgstr "Introducción a Cycle Analytics" msgid "Last 30 days" -msgstr "" +msgstr "Últimos 30 dÃas" msgid "Last 90 days" -msgstr "" +msgstr "Últimos 90 dÃas" msgid "Median" -msgstr "" +msgstr "Mediana" msgid "Not available" -msgstr "" +msgstr "No disponible" msgid "Not enough data" -msgstr "" +msgstr "No hay suficientes datos" + +msgid "Opened" +msgstr "Abiertos" msgid "Pipeline Health" -msgstr "" +msgstr "Estado del Pipeline" msgid "Read more" -msgstr "" +msgstr "Leer más" + +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "Mostrando %d evento" +msgstr[1] "Mostrando %d eventos" msgid "Spanish" msgstr "Español" msgid "Stage" -msgstr "" +msgstr "Etapa" msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." -msgstr "" +msgstr "La etapa de codificación muestra el tiempo desde la primera confirmación hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión." msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." -msgstr "" +msgstr "La etapa de temas muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa." msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." -msgstr "" +msgstr "La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primera confirmación." msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." -msgstr "" +msgstr "La etapa de producción muestra el tiempo total que tarda entre la creación de un tema y el despliegue del código a producción. Los datos se añadirá automáticamente una vez haya finalizado por completo el ciclo de idea a producción." msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." -msgstr "" +msgstr "La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión." msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time." -msgstr "" +msgstr "La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirá automáticamente una vez que se implementa en producción por primera vez." +#, fuzzy msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." -msgstr "" +msgstr "La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirá automáticamente luego de que el primer pipeline termine de ejecutarse." msgid "Total Time" -msgstr "" +msgstr "Tiempo total" msgid "Want to see the data? Please ask administrator for access." +msgstr "¿Quieres ver los datos? Por favor pedir acceso al administrador." + +msgid "by" +msgstr "por" + +msgid "day" +msgid_plural "days" +msgstr[0] "dÃa" +msgstr[1] "dÃas" + +msgid "hr" msgstr "" + +msgid "min" +msgid_plural "mins" +msgstr[0] "" +msgstr[1] "" + +msgid "pushed by" +msgstr "enviado por" diff --git a/locale/gitlab.pot b/locale/gitlab.pot index b99506788c72f..ab9ab88b24e4e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-04-26 17:18+0100\n" -"PO-Revision-Date: 2017-04-26 17:18+0100\n" +"POT-Creation-Date: 2017-04-26 12:42-0500\n" +"PO-Revision-Date: 2017-04-26 12:42-0500\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" "Language: \n" @@ -27,6 +27,9 @@ msgstr "" msgid "English" msgstr "" +msgid "First" +msgstr "" + msgid "Introducing Cycle Analytics" msgstr "" @@ -45,12 +48,20 @@ msgstr "" msgid "Not enough data" msgstr "" +msgid "Opened" +msgstr "" + msgid "Pipeline Health" msgstr "" msgid "Read more" msgstr "" +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "" +msgstr[1] "" + msgid "Spanish" msgstr "" @@ -83,3 +94,22 @@ msgstr "" msgid "Want to see the data? Please ask administrator for access." msgstr "" + +msgid "by" +msgstr "" + +msgid "day" +msgid_plural "days" +msgstr[0] "" +msgstr[1] "" + +msgid "hr" +msgstr "" + +msgid "min" +msgid_plural "mins" +msgstr[0] "" +msgstr[1] "" + +msgid "pushed by" +msgstr "" -- GitLab From edbf9880f95c4bcdd71bf4414f287d4d3f3cc290 Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Wed, 26 Apr 2017 17:31:09 -0600 Subject: [PATCH 083/363] update task_list n/n --- .../issue_show/issue_title_description.vue | 63 +++++++++++-------- app/controllers/projects/issues_controller.rb | 2 + 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index ef7ac83f336a0..d13e02b8cc0e0 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -32,7 +32,9 @@ export default { timeoutId: null, title: '<span></span>', description: '<span></span>', + descriptionText: '', descriptionChange: false, + taskStatus: '', }; }, methods: { @@ -40,46 +42,35 @@ export default { const body = JSON.parse(res.body); this.triggerAnimation(body); }, - triggerAnimation(body) { - // always reset to false before checking the change - this.descriptionChange = false; - - const { title, description } = body; - - this.descriptionText = body.description_text; - - /** - * since opacity is changed, even if there is no diff for Vue to update - * we must check the title/description even on a 304 to ensure no visual change - */ - const noTitleChange = this.title === title; - const noDescriptionChange = this.description === description; - - if (noTitleChange && noDescriptionChange) return; - - const elementsToVisualize = []; + updateTaskHTML(body) { + this.taskStatus = body.task_status; + document.querySelector('#task_status').innerText = this.taskStatus; + }, + elementsToVisualize(noTitleChange, noDescriptionChange) { + const elementStack = []; if (!noTitleChange) { - elementsToVisualize.push(this.$el.querySelector('.title')); + elementStack.push(this.$el.querySelector('.title')); } if (!noDescriptionChange) { // only change to true when we need to bind TaskLists the html of description this.descriptionChange = true; - - elementsToVisualize.push(this.$el.querySelector('.wiki')); + elementStack.push(this.$el.querySelector('.wiki')); } - elementsToVisualize.forEach((element) => { + elementStack.forEach((element) => { element.classList.remove('issue-realtime-trigger-pulse'); element.classList.add('issue-realtime-pre-pulse'); }); + return elementStack; + }, + animate(title, description, elementsToVisualize) { this.timeoutId = setTimeout(() => { this.title = title; - document.querySelector('title').innerText = title; - this.description = description; + document.querySelector('title').innerText = title; elementsToVisualize.forEach((element) => { element.classList.remove('issue-realtime-pre-pulse'); @@ -89,6 +80,29 @@ export default { clearTimeout(this.timeoutId); }, 0); }, + triggerAnimation(body) { + // always reset to false before checking the change + this.descriptionChange = false; + + const { title, description } = body; + this.descriptionText = body.description_text; + this.updateTaskHTML(body); + /** + * since opacity is changed, even if there is no diff for Vue to update + * we must check the title/description even on a 304 to ensure no visual change + */ + const noTitleChange = this.title === title; + const noDescriptionChange = this.description === description; + + if (noTitleChange && noDescriptionChange) return; + + const elementsToVisualize = this.elementsToVisualize( + noTitleChange, + noDescriptionChange, + ); + + this.animate(title, description, elementsToVisualize); + }, }, computed: { descriptionClass() { @@ -118,7 +132,6 @@ export default { }); $(this.$refs['issue-content-container-gfm-entry']).renderGFM(); - return tl; } return null; diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index b1df50ba84902..e5c1505ece65d 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -198,10 +198,12 @@ def can_create_branch def rendered_title Gitlab::PollingInterval.set_header(response, interval: 3_000) + render json: { title: view_context.markdown_field(@issue, :title), description: view_context.markdown_field(@issue, :description), description_text: @issue.description, + task_status: @issue.task_status, } end -- GitLab From 4c9e68cb90740ce7967dd748e44c3792e21cf87c Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Wed, 26 Apr 2017 17:34:17 -0600 Subject: [PATCH 084/363] change 'body' to 'data' after 'JSON.parse' --- .../issue_show/issue_title_description.vue | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index d13e02b8cc0e0..e21667f2ac709 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -39,11 +39,11 @@ export default { }, methods: { renderResponse(res) { - const body = JSON.parse(res.body); - this.triggerAnimation(body); + const data = JSON.parse(res.body); + this.triggerAnimation(data); }, - updateTaskHTML(body) { - this.taskStatus = body.task_status; + updateTaskHTML(data) { + this.taskStatus = data.task_status; document.querySelector('#task_status').innerText = this.taskStatus; }, elementsToVisualize(noTitleChange, noDescriptionChange) { @@ -80,13 +80,13 @@ export default { clearTimeout(this.timeoutId); }, 0); }, - triggerAnimation(body) { + triggerAnimation(data) { // always reset to false before checking the change this.descriptionChange = false; - const { title, description } = body; - this.descriptionText = body.description_text; - this.updateTaskHTML(body); + const { title, description } = data; + this.descriptionText = data.description_text; + this.updateTaskHTML(data); /** * since opacity is changed, even if there is no diff for Vue to update * we must check the title/description even on a 304 to ensure no visual change -- GitLab From 0f1273fa44a9122bffbd5cecbaea99b1db781d7e Mon Sep 17 00:00:00 2001 From: Chris Wilson <chris@chrisjwilson.com> Date: Fri, 14 Apr 2017 11:53:30 +1000 Subject: [PATCH 085/363] Add configurable timeout for git fetch and clone operations GitLab uses the import_project method in GitLab Shell, This method uses a timeout for the operation, hardcoded to 800 seconds. With this MR the timeout is now configurable in the gitlab_shell settings. --- .../mrchrisw-import-shell-timeout.yml | 4 ++ config/gitlab.yml.example | 3 ++ config/initializers/1_settings.rb | 1 + lib/gitlab/shell.rb | 4 +- spec/lib/gitlab/backend/shell_spec.rb | 41 +++++++++++++++++++ 5 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 changelogs/unreleased/mrchrisw-import-shell-timeout.yml diff --git a/changelogs/unreleased/mrchrisw-import-shell-timeout.yml b/changelogs/unreleased/mrchrisw-import-shell-timeout.yml new file mode 100644 index 0000000000000..e43409109d6c5 --- /dev/null +++ b/changelogs/unreleased/mrchrisw-import-shell-timeout.yml @@ -0,0 +1,4 @@ +--- +title: Add configurable timeout for git fetch and clone operations +merge_request: 10697 +author: diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 06c9f734c2ade..ce477001b6fb8 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -502,6 +502,9 @@ production: &base upload_pack: true receive_pack: true + # Git import/fetch timeout + # git_timeout: 800 + # If you use non-standard ssh port you need to specify it # ssh_port: 22 diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 87bf48a3dcdd6..16bc08cf48f21 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -386,6 +386,7 @@ def cron_random_weekly_time Settings.gitlab_shell['ssh_user'] ||= Settings.gitlab.user Settings.gitlab_shell['owner_group'] ||= Settings.gitlab.user Settings.gitlab_shell['ssh_path_prefix'] ||= Settings.__send__(:build_gitlab_shell_ssh_path_prefix) +Settings.gitlab_shell['git_timeout'] ||= 800 # # Repositories diff --git a/lib/gitlab/shell.rb b/lib/gitlab/shell.rb index 36a871e5bbc00..b1d6ea665b70c 100644 --- a/lib/gitlab/shell.rb +++ b/lib/gitlab/shell.rb @@ -83,7 +83,7 @@ def import_repository(storage, name, url) # Timeout should be less than 900 ideally, to prevent the memory killer # to silently kill the process without knowing we are timing out here. output, status = Popen.popen([gitlab_shell_projects_path, 'import-project', - storage, "#{name}.git", url, '800']) + storage, "#{name}.git", url, "#{Gitlab.config.gitlab_shell.git_timeout}"]) raise Error, output unless status.zero? true end @@ -99,7 +99,7 @@ def import_repository(storage, name, url) # fetch_remote("gitlab/gitlab-ci", "upstream") # def fetch_remote(storage, name, remote, forced: false, no_tags: false) - args = [gitlab_shell_projects_path, 'fetch-remote', storage, "#{name}.git", remote, '800'] + args = [gitlab_shell_projects_path, 'fetch-remote', storage, "#{name}.git", remote, "#{Gitlab.config.gitlab_shell.git_timeout}"] args << '--force' if forced args << '--no-tags' if no_tags diff --git a/spec/lib/gitlab/backend/shell_spec.rb b/spec/lib/gitlab/backend/shell_spec.rb index 6675d26734e2e..a97a0f8452bd7 100644 --- a/spec/lib/gitlab/backend/shell_spec.rb +++ b/spec/lib/gitlab/backend/shell_spec.rb @@ -91,4 +91,45 @@ end end end + + describe 'projects commands' do + let(:projects_path) { 'tmp/tests/shell-projects-test/bin/gitlab-projects' } + + before do + allow(Gitlab.config.gitlab_shell).to receive(:path).and_return('tmp/tests/shell-projects-test') + allow(Gitlab.config.gitlab_shell).to receive(:git_timeout).and_return(800) + end + + describe '#fetch_remote' do + it 'returns true when the command succeeds' do + expect(Gitlab::Popen).to receive(:popen) + .with([projects_path, 'fetch-remote', 'current/storage', 'project/path.git', 'new/storage', '800']).and_return([nil, 0]) + + expect(gitlab_shell.fetch_remote('current/storage', 'project/path', 'new/storage')).to be true + end + + it 'raises an exception when the command fails' do + expect(Gitlab::Popen).to receive(:popen) + .with([projects_path, 'fetch-remote', 'current/storage', 'project/path.git', 'new/storage', '800']).and_return(["error", 1]) + + expect { gitlab_shell.fetch_remote('current/storage', 'project/path', 'new/storage') }.to raise_error(Gitlab::Shell::Error, "error") + end + end + + describe '#import_repository' do + it 'returns true when the command succeeds' do + expect(Gitlab::Popen).to receive(:popen) + .with([projects_path, 'import-project', 'current/storage', 'project/path.git', 'https://gitlab.com/gitlab-org/gitlab-ce.git', "800"]).and_return([nil, 0]) + + expect(gitlab_shell.import_repository('current/storage', 'project/path', 'https://gitlab.com/gitlab-org/gitlab-ce.git')).to be true + end + + it 'raises an exception when the command fails' do + expect(Gitlab::Popen).to receive(:popen) + .with([projects_path, 'import-project', 'current/storage', 'project/path.git', 'https://gitlab.com/gitlab-org/gitlab-ce.git', "800"]).and_return(["error", 1]) + + expect { gitlab_shell.import_repository('current/storage', 'project/path', 'https://gitlab.com/gitlab-org/gitlab-ce.git') }.to raise_error(Gitlab::Shell::Error, "error") + end + end + end end -- GitLab From e4526e061cff78ce6dafc0bd5e3555c634161d3d Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Wed, 26 Apr 2017 20:04:33 -0500 Subject: [PATCH 086/363] Add more translation for Spanish. --- app/assets/javascripts/locale/de/app.js | 2 +- app/assets/javascripts/locale/en/app.js | 2 +- app/assets/javascripts/locale/es/app.js | 2 +- .../projects/cycle_analytics/show.html.haml | 4 +- lib/gitlab/cycle_analytics/code_stage.rb | 2 +- lib/gitlab/cycle_analytics/issue_stage.rb | 2 +- lib/gitlab/cycle_analytics/plan_stage.rb | 2 +- .../cycle_analytics/production_stage.rb | 2 +- lib/gitlab/cycle_analytics/review_stage.rb | 2 +- lib/gitlab/cycle_analytics/staging_stage.rb | 2 +- lib/gitlab/cycle_analytics/test_stage.rb | 2 +- locale/de/gitlab.po | 27 ++++++++++++ locale/en/gitlab.po | 27 ++++++++++++ locale/es/gitlab.po | 44 +++++++++++++++---- locale/gitlab.pot | 31 ++++++++++++- locale/unfound_translations.rb | 3 ++ 16 files changed, 134 insertions(+), 22 deletions(-) create mode 100644 locale/unfound_translations.rb diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js index d74d36711d360..ba6adc7334578 100644 --- a/app/assets/javascripts/locale/de/app.js +++ b/app/assets/javascripts/locale/de/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deutsch":[""],"English":[""],"First":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Median":[""],"Not available":[""],"Not enough data":[""],"Opened":[""],"Pipeline Health":[""],"Read more":[""],"Showing %d event":["",""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"by":[""],"day":["",""],"hr":[""],"min":["",""],"pushed by":[""]}}}; \ No newline at end of file +var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deploys":[""],"Deutsch":[""],"English":[""],"First":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"Opened":[""],"Pipeline Health":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"by":[""],"day":["",""],"hr":[""],"min":["",""],"pushed by":[""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/en/app.js b/app/assets/javascripts/locale/en/app.js index 05ddc42e33386..d5d5b00505898 100644 --- a/app/assets/javascripts/locale/en/app.js +++ b/app/assets/javascripts/locale/en/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deutsch":[""],"English":[""],"First":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Median":[""],"Not available":[""],"Not enough data":[""],"Opened":[""],"Pipeline Health":[""],"Read more":[""],"Showing %d event":["",""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"by":[""],"day":["",""],"hr":[""],"min":["",""],"pushed by":[""]}}}; \ No newline at end of file +var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deploys":[""],"Deutsch":[""],"English":[""],"First":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"Opened":[""],"Pipeline Health":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"by":[""],"day":["",""],"hr":[""],"min":["",""],"pushed by":[""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/es/app.js b/app/assets/javascripts/locale/es/app.js index bb5976c572a6c..c47a743e2107a 100644 --- a/app/assets/javascripts/locale/es/app.js +++ b/app/assets/javascripts/locale/es/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-26 16:07-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"Deutsch":["Alemán"],"English":["Inglés"],"First":["Primer"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last 30 days":["Últimos 30 dÃas"],"Last 90 days":["Últimos 90 dÃas"],"Median":["Mediana"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"Opened":["Abiertos"],"Pipeline Health":["Estado del Pipeline"],"Read more":["Leer más"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"Spanish":["Español"],"Stage":["Etapa"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde la primera confirmación hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de temas muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primera confirmación."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de un tema y el despliegue del código a producción. Los datos se añadirá automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirá automáticamente una vez que se implementa en producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirá automáticamente luego de que el primer pipeline termine de ejecutarse."],"Total Time":["Tiempo total"],"Want to see the data? Please ask administrator for access.":["¿Quieres ver los datos? Por favor pedir acceso al administrador."],"by":["por"],"day":["dÃa","dÃas"],"hr":[""],"min":["",""],"pushed by":["enviado por"]}}}; \ No newline at end of file +var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-26 20:01-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":["Cambios"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"Deploys":["Despliegues"],"Deutsch":["Alemán"],"English":["Inglés"],"First":["Primer"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last 30 days":["Últimos 30 dÃas"],"Last 90 days":["Últimos 90 dÃas"],"Median":["Mediana"],"New Issues":["Nuevos temas"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"Opened":["Abiertos"],"Pipeline Health":["Estado del Pipeline"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Issues":["Temas Relacionados"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Relative Builds Trigger by Commits":["Builds Relacionadas Originadas por Cambios"],"Relative Deployed Builds":["Builds Desplegadas Relacionadas"],"Relative Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"Spanish":["Español"],"Stage":["Etapa"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de temas muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de un tema y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"Total Time":["Tiempo Total"],"Want to see the data? Please ask administrator for access.":["¿Quieres ver los datos? Por favor pedir acceso al administrador."],"by":["por"],"day":["dÃa","dÃas"],"hr":[""],"min":["",""],"pushed by":["enviado por"]}}}; \ No newline at end of file diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index 65afe41fe9e53..9710c7a090597 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -32,7 +32,7 @@ .row .col-sm-3.col-xs-12.column{ "v-for" => "item in state.summary" } %h3.header {{ item.value }} - %p.text {{ item.title }} + %p.text {{ __(item.title) }} .col-sm-3.col-xs-12.column .dropdown.inline.js-ca-dropdown %button.dropdown-menu-toggle{ "data-toggle" => "dropdown", :type => "button" } @@ -60,7 +60,7 @@ %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.", "aria-hidden" => "true" } %li.event-header %span.stage-name - {{ __(currentStage ? currentStage.legend : 'Related Issues') }} + {{ __(currentStage ? __(currentStage.legend) : __('Related Issues')) }} %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The collection of events added to the data gathered for that stage.", "aria-hidden" => "true" } %li.total-time-header %span.stage-name diff --git a/lib/gitlab/cycle_analytics/code_stage.rb b/lib/gitlab/cycle_analytics/code_stage.rb index 1e52b6614a152..afb8196484775 100644 --- a/lib/gitlab/cycle_analytics/code_stage.rb +++ b/lib/gitlab/cycle_analytics/code_stage.rb @@ -14,7 +14,7 @@ def name end def legend - "Related Merge Requests" + N_("Related Merge Requests") end def description diff --git a/lib/gitlab/cycle_analytics/issue_stage.rb b/lib/gitlab/cycle_analytics/issue_stage.rb index 213994988a5a5..d36967d9497f8 100644 --- a/lib/gitlab/cycle_analytics/issue_stage.rb +++ b/lib/gitlab/cycle_analytics/issue_stage.rb @@ -15,7 +15,7 @@ def name end def legend - "Related Issues" + N_("Related Issues") end def description diff --git a/lib/gitlab/cycle_analytics/plan_stage.rb b/lib/gitlab/cycle_analytics/plan_stage.rb index 45d51d30ccc6b..7231c45378874 100644 --- a/lib/gitlab/cycle_analytics/plan_stage.rb +++ b/lib/gitlab/cycle_analytics/plan_stage.rb @@ -15,7 +15,7 @@ def name end def legend - "Related Commits" + N_("Related Commits") end def description diff --git a/lib/gitlab/cycle_analytics/production_stage.rb b/lib/gitlab/cycle_analytics/production_stage.rb index 9f387a0294533..451720c81214f 100644 --- a/lib/gitlab/cycle_analytics/production_stage.rb +++ b/lib/gitlab/cycle_analytics/production_stage.rb @@ -16,7 +16,7 @@ def name end def legend - "Related Issues" + N_("Related Issues") end def description diff --git a/lib/gitlab/cycle_analytics/review_stage.rb b/lib/gitlab/cycle_analytics/review_stage.rb index 4744be834de27..08e895acad628 100644 --- a/lib/gitlab/cycle_analytics/review_stage.rb +++ b/lib/gitlab/cycle_analytics/review_stage.rb @@ -14,7 +14,7 @@ def name end def legend - "Relative Merged Requests" + N_("Relative Merged Requests") end def description diff --git a/lib/gitlab/cycle_analytics/staging_stage.rb b/lib/gitlab/cycle_analytics/staging_stage.rb index 3cdbe04fbaf1d..706a5d14d3f35 100644 --- a/lib/gitlab/cycle_analytics/staging_stage.rb +++ b/lib/gitlab/cycle_analytics/staging_stage.rb @@ -15,7 +15,7 @@ def name end def legend - "Relative Deployed Builds" + N_("Relative Deployed Builds") end def description diff --git a/lib/gitlab/cycle_analytics/test_stage.rb b/lib/gitlab/cycle_analytics/test_stage.rb index e96943833bc46..887cf3e09416f 100644 --- a/lib/gitlab/cycle_analytics/test_stage.rb +++ b/lib/gitlab/cycle_analytics/test_stage.rb @@ -14,7 +14,7 @@ def name end def legend - "Relative Builds Trigger by Commits" + N_("Relative Builds Trigger by Commits") end def description diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po index 747d446ebc41f..0a5db8ab1e5e7 100644 --- a/locale/de/gitlab.po +++ b/locale/de/gitlab.po @@ -17,9 +17,15 @@ msgstr "" "Plural-Forms: nplurals=2; plural=n != 1;\n" "\n" +msgid "Commits" +msgstr "" + msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "" +msgid "Deploys" +msgstr "" + msgid "Deutsch" msgstr "" @@ -41,6 +47,9 @@ msgstr "" msgid "Median" msgstr "" +msgid "New Issues" +msgstr "" + msgid "Not available" msgstr "" @@ -56,6 +65,24 @@ msgstr "" msgid "Read more" msgstr "" +msgid "Related Commits" +msgstr "" + +msgid "Related Issues" +msgstr "" + +msgid "Related Merge Requests" +msgstr "" + +msgid "Relative Builds Trigger by Commits" +msgstr "" + +msgid "Relative Deployed Builds" +msgstr "" + +msgid "Relative Merged Requests" +msgstr "" + msgid "Showing %d event" msgid_plural "Showing %d events" msgstr[0] "" diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po index d2ecc6cd73aff..fac5c3ddb0273 100644 --- a/locale/en/gitlab.po +++ b/locale/en/gitlab.po @@ -17,9 +17,15 @@ msgstr "" "Plural-Forms: nplurals=2; plural=n != 1;\n" "\n" +msgid "Commits" +msgstr "" + msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "" +msgid "Deploys" +msgstr "" + msgid "Deutsch" msgstr "" @@ -41,6 +47,9 @@ msgstr "" msgid "Median" msgstr "" +msgid "New Issues" +msgstr "" + msgid "Not available" msgstr "" @@ -56,6 +65,24 @@ msgstr "" msgid "Read more" msgstr "" +msgid "Related Commits" +msgstr "" + +msgid "Related Issues" +msgstr "" + +msgid "Related Merge Requests" +msgstr "" + +msgid "Relative Builds Trigger by Commits" +msgstr "" + +msgid "Relative Deployed Builds" +msgstr "" + +msgid "Relative Merged Requests" +msgstr "" + msgid "Showing %d event" msgid_plural "Showing %d events" msgstr[0] "" diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po index 404ab89c7cd94..8bb4176dbf45a 100644 --- a/locale/es/gitlab.po +++ b/locale/es/gitlab.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2017-04-26 16:07-0500\n" +"PO-Revision-Date: 2017-04-26 20:01-0500\n" "Language-Team: Spanish\n" "Language: es\n" "MIME-Version: 1.0\n" @@ -17,9 +17,15 @@ msgstr "" "Last-Translator: \n" "X-Generator: Poedit 2.0.1\n" +msgid "Commits" +msgstr "Cambios" + msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto." +msgid "Deploys" +msgstr "Despliegues" + msgid "Deutsch" msgstr "Alemán" @@ -41,6 +47,9 @@ msgstr "Últimos 90 dÃas" msgid "Median" msgstr "Mediana" +msgid "New Issues" +msgstr "Nuevos temas" + msgid "Not available" msgstr "No disponible" @@ -56,6 +65,26 @@ msgstr "Estado del Pipeline" msgid "Read more" msgstr "Leer más" +msgid "Related Commits" +msgstr "Cambios Relacionados" + +msgid "Related Issues" +msgstr "Temas Relacionados" + +msgid "Related Merge Requests" +msgstr "Solicitudes de fusión Relacionadas" + +#, fuzzy +msgid "Relative Builds Trigger by Commits" +msgstr "Builds Relacionadas Originadas por Cambios" + +#, fuzzy +msgid "Relative Deployed Builds" +msgstr "Builds Desplegadas Relacionadas" + +msgid "Relative Merged Requests" +msgstr "Solicitudes de fusión Relacionadas" + msgid "Showing %d event" msgid_plural "Showing %d events" msgstr[0] "Mostrando %d evento" @@ -68,29 +97,28 @@ msgid "Stage" msgstr "Etapa" msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." -msgstr "La etapa de codificación muestra el tiempo desde la primera confirmación hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión." +msgstr "La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión." msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." msgstr "La etapa de temas muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa." msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." -msgstr "La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primera confirmación." +msgstr "La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primer cambio." msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." -msgstr "La etapa de producción muestra el tiempo total que tarda entre la creación de un tema y el despliegue del código a producción. Los datos se añadirá automáticamente una vez haya finalizado por completo el ciclo de idea a producción." +msgstr "La etapa de producción muestra el tiempo total que tarda entre la creación de un tema y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción." msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." msgstr "La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión." msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time." -msgstr "La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirá automáticamente una vez que se implementa en producción por primera vez." +msgstr "La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez." -#, fuzzy msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." -msgstr "La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirá automáticamente luego de que el primer pipeline termine de ejecutarse." +msgstr "La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse." msgid "Total Time" -msgstr "Tiempo total" +msgstr "Tiempo Total" msgid "Want to see the data? Please ask administrator for access." msgstr "¿Quieres ver los datos? Por favor pedir acceso al administrador." diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ab9ab88b24e4e..9e2614cb8ba8f 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-04-26 12:42-0500\n" -"PO-Revision-Date: 2017-04-26 12:42-0500\n" +"POT-Creation-Date: 2017-04-26 19:52-0500\n" +"PO-Revision-Date: 2017-04-26 19:52-0500\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" "Language: \n" @@ -18,9 +18,15 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" +msgid "Commits" +msgstr "" + msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "" +msgid "Deploys" +msgstr "" + msgid "Deutsch" msgstr "" @@ -42,6 +48,9 @@ msgstr "" msgid "Median" msgstr "" +msgid "New Issues" +msgstr "" + msgid "Not available" msgstr "" @@ -57,6 +66,24 @@ msgstr "" msgid "Read more" msgstr "" +msgid "Related Commits" +msgstr "" + +msgid "Related Issues" +msgstr "" + +msgid "Related Merge Requests" +msgstr "" + +msgid "Relative Builds Trigger by Commits" +msgstr "" + +msgid "Relative Deployed Builds" +msgstr "" + +msgid "Relative Merged Requests" +msgstr "" + msgid "Showing %d event" msgid_plural "Showing %d events" msgstr[0] "" diff --git a/locale/unfound_translations.rb b/locale/unfound_translations.rb new file mode 100644 index 0000000000000..a88300ba43f4b --- /dev/null +++ b/locale/unfound_translations.rb @@ -0,0 +1,3 @@ +N_('New Issues') +N_('Commits') +N_('Deploys') -- GitLab From b3c80094e0db4d390522daef7a845ff2c5744bf3 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Thu, 27 Apr 2017 14:47:00 +0100 Subject: [PATCH 087/363] Added some missing text that needs translating --- .../components/limit_warning_component.js | 4 ++-- app/assets/javascripts/locale/de/app.js | 2 +- app/assets/javascripts/locale/en/app.js | 2 +- app/assets/javascripts/locale/es/app.js | 2 +- .../projects/cycle_analytics/show.html.haml | 10 +++++----- locale/de/gitlab.po | 15 +++++++++++++++ locale/en/gitlab.po | 15 +++++++++++++++ locale/es/gitlab.po | 16 ++++++++++++++++ locale/gitlab.pot | 19 +++++++++++++++++-- 9 files changed, 73 insertions(+), 12 deletions(-) diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js index 72ada943fea0f..6e07560040c95 100644 --- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js +++ b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js @@ -6,10 +6,10 @@ export default { }, }, template: ` - <span v-if="count === 50" class="events-info pull-right"> + <span v-if="count === 50 || true" class="events-info pull-right"> <i class="fa fa-warning has-tooltip" aria-hidden="true" - title="Limited to showing 50 events at most" + :title="__('Limited to showing 50 events at most')" data-placement="top"></i> {{ n__('Showing %d event', 'Showing %d events', 50) }} </span> diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js index ba6adc7334578..f59109f247f99 100644 --- a/app/assets/javascripts/locale/de/app.js +++ b/app/assets/javascripts/locale/de/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deploys":[""],"Deutsch":[""],"English":[""],"First":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"Opened":[""],"Pipeline Health":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"by":[""],"day":["",""],"hr":[""],"min":["",""],"pushed by":[""]}}}; \ No newline at end of file +var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deploys":[""],"Deutsch":[""],"English":[""],"First":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Limited to showing 50 events at most":[""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"Opened":[""],"Pipeline Health":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"by":[""],"day":["",""],"hr":[""],"min":["",""],"pushed by":[""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/en/app.js b/app/assets/javascripts/locale/en/app.js index d5d5b00505898..ec64122d1a95f 100644 --- a/app/assets/javascripts/locale/en/app.js +++ b/app/assets/javascripts/locale/en/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deploys":[""],"Deutsch":[""],"English":[""],"First":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"Opened":[""],"Pipeline Health":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"by":[""],"day":["",""],"hr":[""],"min":["",""],"pushed by":[""]}}}; \ No newline at end of file +var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deploys":[""],"Deutsch":[""],"English":[""],"First":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Limited to showing 50 events at most":[""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"Opened":[""],"Pipeline Health":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"by":[""],"day":["",""],"hr":[""],"min":["",""],"pushed by":[""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/es/app.js b/app/assets/javascripts/locale/es/app.js index c47a743e2107a..64a4449c260e0 100644 --- a/app/assets/javascripts/locale/es/app.js +++ b/app/assets/javascripts/locale/es/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-26 20:01-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":["Cambios"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"Deploys":["Despliegues"],"Deutsch":["Alemán"],"English":["Inglés"],"First":["Primer"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last 30 days":["Últimos 30 dÃas"],"Last 90 days":["Últimos 90 dÃas"],"Median":["Mediana"],"New Issues":["Nuevos temas"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"Opened":["Abiertos"],"Pipeline Health":["Estado del Pipeline"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Issues":["Temas Relacionados"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Relative Builds Trigger by Commits":["Builds Relacionadas Originadas por Cambios"],"Relative Deployed Builds":["Builds Desplegadas Relacionadas"],"Relative Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"Spanish":["Español"],"Stage":["Etapa"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de temas muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de un tema y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"Total Time":["Tiempo Total"],"Want to see the data? Please ask administrator for access.":["¿Quieres ver los datos? Por favor pedir acceso al administrador."],"by":["por"],"day":["dÃa","dÃas"],"hr":[""],"min":["",""],"pushed by":["enviado por"]}}}; \ No newline at end of file +var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-26 20:01-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":["Cambios"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"Deploys":["Despliegues"],"Deutsch":["Alemán"],"English":["Inglés"],"First":["Primer"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last 30 days":["Últimos 30 dÃas"],"Last 90 days":["Últimos 90 dÃas"],"Limited to showing 50 events at most":[""],"Median":["Mediana"],"New Issues":["Nuevos temas"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"Opened":["Abiertos"],"Pipeline Health":["Estado del Pipeline"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Issues":["Temas Relacionados"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Relative Builds Trigger by Commits":["Builds Relacionadas Originadas por Cambios"],"Relative Deployed Builds":["Builds Desplegadas Relacionadas"],"Relative Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"Spanish":["Español"],"Stage":["Etapa"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de temas muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de un tema y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["por"],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Total Time":["Tiempo Total"],"Want to see the data? Please ask administrator for access.":["¿Quieres ver los datos? Por favor pedir acceso al administrador."],"by":["por"],"day":["dÃa","dÃas"],"hr":[""],"min":["",""],"pushed by":["enviado por"]}}}; \ No newline at end of file diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index 9710c7a090597..b088dfe344158 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -53,19 +53,19 @@ %li.stage-header %span.stage-name {{ __('Stage') }} - %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The phase of the development lifecycle.", "aria-hidden" => "true" } + %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The phase of the development lifecycle."), "aria-hidden" => "true" } %li.median-header %span.stage-name {{ __('Median') }} - %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.", "aria-hidden" => "true" } + %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."), "aria-hidden" => "true" } %li.event-header %span.stage-name - {{ __(currentStage ? __(currentStage.legend) : __('Related Issues')) }} - %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The collection of events added to the data gathered for that stage.", "aria-hidden" => "true" } + {{ currentStage ? currentStage.legend : __('Related Issues') }} + %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The collection of events added to the data gathered for that stage."), "aria-hidden" => "true" } %li.total-time-header %span.stage-name {{ __('Total Time') }} - %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The time taken by each data entry gathered by that stage.", "aria-hidden" => "true" } + %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The time taken by each data entry gathered by that stage."), "aria-hidden" => "true" } .stage-panel-body %nav.stage-nav %ul diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po index 0a5db8ab1e5e7..bbdd4e4a0be80 100644 --- a/locale/de/gitlab.po +++ b/locale/de/gitlab.po @@ -44,6 +44,9 @@ msgstr "" msgid "Last 90 days" msgstr "" +msgid "Limited to showing 50 events at most" +msgstr "" + msgid "Median" msgstr "" @@ -97,9 +100,15 @@ msgstr "" msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." msgstr "" +msgid "The collection of events added to the data gathered for that stage." +msgstr "" + msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." msgstr "" +msgid "The phase of the development lifecycle." +msgstr "" + msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." msgstr "" @@ -115,6 +124,12 @@ msgstr "" msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." msgstr "" +msgid "The time taken by each data entry gathered by that stage." +msgstr "" + +msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." +msgstr "" + msgid "Total Time" msgstr "" diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po index fac5c3ddb0273..4942149dd1fe4 100644 --- a/locale/en/gitlab.po +++ b/locale/en/gitlab.po @@ -44,6 +44,9 @@ msgstr "" msgid "Last 90 days" msgstr "" +msgid "Limited to showing 50 events at most" +msgstr "" + msgid "Median" msgstr "" @@ -97,9 +100,15 @@ msgstr "" msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." msgstr "" +msgid "The collection of events added to the data gathered for that stage." +msgstr "" + msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." msgstr "" +msgid "The phase of the development lifecycle." +msgstr "" + msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." msgstr "" @@ -115,6 +124,12 @@ msgstr "" msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." msgstr "" +msgid "The time taken by each data entry gathered by that stage." +msgstr "" + +msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." +msgstr "" + msgid "Total Time" msgstr "" diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po index 8bb4176dbf45a..bad2b6b72a25f 100644 --- a/locale/es/gitlab.po +++ b/locale/es/gitlab.po @@ -44,6 +44,9 @@ msgstr "Últimos 30 dÃas" msgid "Last 90 days" msgstr "Últimos 90 dÃas" +msgid "Limited to showing 50 events at most" +msgstr "" + msgid "Median" msgstr "Mediana" @@ -99,9 +102,15 @@ msgstr "Etapa" msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." msgstr "La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión." +msgid "The collection of events added to the data gathered for that stage." +msgstr "" + msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." msgstr "La etapa de temas muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa." +msgid "The phase of the development lifecycle." +msgstr "" + msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." msgstr "La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primer cambio." @@ -117,6 +126,13 @@ msgstr "La etapa de puesta en escena muestra el tiempo entre la fusión y el de msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." msgstr "La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse." +#, fuzzy +msgid "The time taken by each data entry gathered by that stage." +msgstr "por" + +msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." +msgstr "" + msgid "Total Time" msgstr "Tiempo Total" diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9e2614cb8ba8f..faac5f13aedec 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-04-26 19:52-0500\n" -"PO-Revision-Date: 2017-04-26 19:52-0500\n" +"POT-Creation-Date: 2017-04-27 14:41+0100\n" +"PO-Revision-Date: 2017-04-27 14:41+0100\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" "Language: \n" @@ -45,6 +45,9 @@ msgstr "" msgid "Last 90 days" msgstr "" +msgid "Limited to showing 50 events at most" +msgstr "" + msgid "Median" msgstr "" @@ -98,9 +101,15 @@ msgstr "" msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." msgstr "" +msgid "The collection of events added to the data gathered for that stage." +msgstr "" + msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." msgstr "" +msgid "The phase of the development lifecycle." +msgstr "" + msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." msgstr "" @@ -116,6 +125,12 @@ msgstr "" msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." msgstr "" +msgid "The time taken by each data entry gathered by that stage." +msgstr "" + +msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." +msgstr "" + msgid "Total Time" msgstr "" -- GitLab From abde62b53ed993b4ceec778d4fb839fa0132c165 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Thu, 27 Apr 2017 17:06:55 +0100 Subject: [PATCH 088/363] [ci skip] Add comment to regex --- app/assets/javascripts/droplab/constants.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/droplab/constants.js b/app/assets/javascripts/droplab/constants.js index 0579b5de45938..868d47e91b3e4 100644 --- a/app/assets/javascripts/droplab/constants.js +++ b/app/assets/javascripts/droplab/constants.js @@ -2,8 +2,9 @@ const DATA_TRIGGER = 'data-dropdown-trigger'; const DATA_DROPDOWN = 'data-dropdown'; const SELECTED_CLASS = 'droplab-item-selected'; const ACTIVE_CLASS = 'droplab-item-active'; -const TEMPLATE_REGEX = /\{\{(.+?)\}\}/g; const IGNORE_CLASS = 'droplab-item-ignore'; +// Matches `{{anything}}` and `{{ everything }}`. +const TEMPLATE_REGEX = /\{\{(.+?)\}\}/g; export { DATA_TRIGGER, -- GitLab From 22ecf73e823f01e155b2db2cb072120f80b9d234 Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Thu, 27 Apr 2017 11:51:04 -0500 Subject: [PATCH 089/363] Adjust regex used in order to parse inline content. e.g: %h4 {{ __('foo') }} Problem was it was being parsed as: %h4 = _('foo') And the correct content should be: %h4= _('foo') --- app/assets/javascripts/locale/de/app.js | 2 +- app/assets/javascripts/locale/en/app.js | 2 +- app/assets/javascripts/locale/es/app.js | 2 +- config/initializers/gettext_rails_i18n_patch.rb | 4 ++-- locale/de/gitlab.po | 6 ++++++ locale/en/gitlab.po | 6 ++++++ locale/es/gitlab.po | 8 +++++++- locale/gitlab.pot | 6 ++++++ 8 files changed, 30 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js index f59109f247f99..9fd02783bdb97 100644 --- a/app/assets/javascripts/locale/de/app.js +++ b/app/assets/javascripts/locale/de/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deploys":[""],"Deutsch":[""],"English":[""],"First":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Limited to showing 50 events at most":[""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"Opened":[""],"Pipeline Health":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"by":[""],"day":["",""],"hr":[""],"min":["",""],"pushed by":[""]}}}; \ No newline at end of file +var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deploys":[""],"Deutsch":[""],"English":[""],"First":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Limited to showing 50 events at most":[""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"Opened":[""],"Pipeline Health":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"by":[""],"day":["",""],"hr":[""],"min":["",""],"pushed by":[""]}}}; diff --git a/app/assets/javascripts/locale/en/app.js b/app/assets/javascripts/locale/en/app.js index ec64122d1a95f..8bc82f121714a 100644 --- a/app/assets/javascripts/locale/en/app.js +++ b/app/assets/javascripts/locale/en/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deploys":[""],"Deutsch":[""],"English":[""],"First":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Limited to showing 50 events at most":[""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"Opened":[""],"Pipeline Health":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"by":[""],"day":["",""],"hr":[""],"min":["",""],"pushed by":[""]}}}; \ No newline at end of file +var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deploys":[""],"Deutsch":[""],"English":[""],"First":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Limited to showing 50 events at most":[""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"Opened":[""],"Pipeline Health":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"by":[""],"day":["",""],"hr":[""],"min":["",""],"pushed by":[""]}}}; diff --git a/app/assets/javascripts/locale/es/app.js b/app/assets/javascripts/locale/es/app.js index 64a4449c260e0..dfe33cb6eb92c 100644 --- a/app/assets/javascripts/locale/es/app.js +++ b/app/assets/javascripts/locale/es/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-26 20:01-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":["Cambios"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"Deploys":["Despliegues"],"Deutsch":["Alemán"],"English":["Inglés"],"First":["Primer"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last 30 days":["Últimos 30 dÃas"],"Last 90 days":["Últimos 90 dÃas"],"Limited to showing 50 events at most":[""],"Median":["Mediana"],"New Issues":["Nuevos temas"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"Opened":["Abiertos"],"Pipeline Health":["Estado del Pipeline"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Issues":["Temas Relacionados"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Relative Builds Trigger by Commits":["Builds Relacionadas Originadas por Cambios"],"Relative Deployed Builds":["Builds Desplegadas Relacionadas"],"Relative Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"Spanish":["Español"],"Stage":["Etapa"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de temas muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de un tema y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["por"],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Total Time":["Tiempo Total"],"Want to see the data? Please ask administrator for access.":["¿Quieres ver los datos? Por favor pedir acceso al administrador."],"by":["por"],"day":["dÃa","dÃas"],"hr":[""],"min":["",""],"pushed by":["enviado por"]}}}; \ No newline at end of file +var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-27 11:45-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":["Cambios"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"Deploys":["Despliegues"],"Deutsch":["Alemán"],"English":["Inglés"],"First":["Primer"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last 30 days":["Últimos 30 dÃas"],"Last 90 days":["Últimos 90 dÃas"],"Limited to showing 50 events at most":[""],"Median":["Mediana"],"New Issues":["Nuevos temas"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"Opened":["Abiertos"],"Pipeline Health":["Estado del Pipeline"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Issues":["Temas Relacionados"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Relative Builds Trigger by Commits":["Builds Relacionadas Originadas por Cambios"],"Relative Deployed Builds":["Builds Desplegadas Relacionadas"],"Relative Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"Spanish":["Español"],"Stage":["Etapa"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de temas muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de un tema y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["por"],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Total Time":["Tiempo Total"],"Want to see the data? Please ask administrator for access.":["¿Quieres ver los datos? Por favor pedir acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"You need permission.":["Necesitas permisos."],"by":["por"],"day":["dÃa","dÃas"],"hr":[""],"min":["",""],"pushed by":["enviado por"]}}}; diff --git a/config/initializers/gettext_rails_i18n_patch.rb b/config/initializers/gettext_rails_i18n_patch.rb index 6785f361eda77..616f6c45b0add 100644 --- a/config/initializers/gettext_rails_i18n_patch.rb +++ b/config/initializers/gettext_rails_i18n_patch.rb @@ -1,7 +1,7 @@ require 'gettext_i18n_rails/haml_parser' require 'gettext_i18n_rails_js/parser/javascript' -VUE_TRANSLATE_REGEX = /{{ (N|n|s)?__\((.*)\) }}/ +VUE_TRANSLATE_REGEX = /((%[\w.-]+)(?:\s))?{{ (N|n|s)?__\((.*)\) }}/ module GettextI18nRails class HamlParser @@ -15,7 +15,7 @@ class HamlParser # # Overwrites: https://github.com/grosser/gettext_i18n_rails/blob/8396387a431e0f8ead72fc1cd425cad2fa4992f2/lib/gettext_i18n_rails/haml_parser.rb#L9 def self.convert_to_code(text) - text.gsub!(VUE_TRANSLATE_REGEX, "= \\1_(\\2)") + text.gsub!(VUE_TRANSLATE_REGEX, "\\2= \\3_(\\4)") old_convert_to_code(text) end diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po index bbdd4e4a0be80..108273898d03c 100644 --- a/locale/de/gitlab.po +++ b/locale/de/gitlab.po @@ -136,6 +136,12 @@ msgstr "" msgid "Want to see the data? Please ask administrator for access." msgstr "" +msgid "We don't have enough data to show this stage." +msgstr "" + +msgid "You need permission." +msgstr "" + msgid "by" msgstr "" diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po index 4942149dd1fe4..b33e941a4f339 100644 --- a/locale/en/gitlab.po +++ b/locale/en/gitlab.po @@ -136,6 +136,12 @@ msgstr "" msgid "Want to see the data? Please ask administrator for access." msgstr "" +msgid "We don't have enough data to show this stage." +msgstr "" + +msgid "You need permission." +msgstr "" + msgid "by" msgstr "" diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po index bad2b6b72a25f..f3d77cffecdf3 100644 --- a/locale/es/gitlab.po +++ b/locale/es/gitlab.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2017-04-26 20:01-0500\n" +"PO-Revision-Date: 2017-04-27 11:45-0500\n" "Language-Team: Spanish\n" "Language: es\n" "MIME-Version: 1.0\n" @@ -139,6 +139,12 @@ msgstr "Tiempo Total" msgid "Want to see the data? Please ask administrator for access." msgstr "¿Quieres ver los datos? Por favor pedir acceso al administrador." +msgid "We don't have enough data to show this stage." +msgstr "No hay suficientes datos para mostrar en esta etapa." + +msgid "You need permission." +msgstr "Necesitas permisos." + msgid "by" msgstr "por" diff --git a/locale/gitlab.pot b/locale/gitlab.pot index faac5f13aedec..9d3be20e13224 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -137,6 +137,12 @@ msgstr "" msgid "Want to see the data? Please ask administrator for access." msgstr "" +msgid "We don't have enough data to show this stage." +msgstr "" + +msgid "You need permission." +msgstr "" + msgid "by" msgstr "" -- GitLab From f74d0ba195079260d3b3217cd72b2fd9b0483c00 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Thu, 27 Apr 2017 17:58:51 +0100 Subject: [PATCH 090/363] Fix webpack config conflicts bad resolution --- config/webpack.config.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/webpack.config.js b/config/webpack.config.js index f61e65c0e5bb0..8b5354809e746 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -46,6 +46,7 @@ var config = { network: './network/network_bundle.js', notebook_viewer: './blob/notebook_viewer.js', pdf_viewer: './blob/pdf_viewer.js', + pipelines: './pipelines/index.js', balsamiq_viewer: './blob/balsamiq_viewer.js', profile: './profile/profile_bundle.js', protected_branches: './protected_branches/protected_branches_bundle.js', @@ -129,6 +130,7 @@ var config = { 'merge_conflicts', 'notebook_viewer', 'pdf_viewer', + 'pipelines', 'balsamiq_viewer', ], minChunks: function(module, count) { -- GitLab From 657664f9801c90ded9ede327a7c8b11016809a29 Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Thu, 27 Apr 2017 17:56:21 -0500 Subject: [PATCH 091/363] Add more spanish translations. --- app/assets/javascripts/locale/de/app.js | 2 +- app/assets/javascripts/locale/en/app.js | 2 +- app/assets/javascripts/locale/es/app.js | 2 +- .../projects/cycle_analytics/show.html.haml | 4 +- locale/de/gitlab.po | 18 +++++++++ locale/en/gitlab.po | 18 +++++++++ locale/es/gitlab.po | 37 +++++++++++++++---- locale/gitlab.pot | 22 ++++++++++- locale/unfound_translations.rb | 8 +++- 9 files changed, 98 insertions(+), 15 deletions(-) diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js index 9fd02783bdb97..1b10c19c48e78 100644 --- a/app/assets/javascripts/locale/de/app.js +++ b/app/assets/javascripts/locale/de/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deploys":[""],"Deutsch":[""],"English":[""],"First":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Limited to showing 50 events at most":[""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"Opened":[""],"Pipeline Health":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"by":[""],"day":["",""],"hr":[""],"min":["",""],"pushed by":[""]}}}; +var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Cycle|Code":[""],"Cycle|Issue":[""],"Cycle|Plan":[""],"Cycle|Review":[""],"Cycle|Staging":[""],"Cycle|Test":[""],"Deploys":[""],"Deutsch":[""],"English":[""],"First":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Limited to showing 50 events at most":[""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"Opened":[""],"Pipeline Health":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"by":[""],"day":["",""],"hr":[""],"min":["",""],"pushed by":[""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/en/app.js b/app/assets/javascripts/locale/en/app.js index 8bc82f121714a..2754e177653b0 100644 --- a/app/assets/javascripts/locale/en/app.js +++ b/app/assets/javascripts/locale/en/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Deploys":[""],"Deutsch":[""],"English":[""],"First":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Limited to showing 50 events at most":[""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"Opened":[""],"Pipeline Health":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"by":[""],"day":["",""],"hr":[""],"min":["",""],"pushed by":[""]}}}; +var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Cycle|Code":[""],"Cycle|Issue":[""],"Cycle|Plan":[""],"Cycle|Review":[""],"Cycle|Staging":[""],"Cycle|Test":[""],"Deploys":[""],"Deutsch":[""],"English":[""],"First":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Limited to showing 50 events at most":[""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"Opened":[""],"Pipeline Health":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"by":[""],"day":["",""],"hr":[""],"min":["",""],"pushed by":[""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/es/app.js b/app/assets/javascripts/locale/es/app.js index dfe33cb6eb92c..5d1cfddf76abf 100644 --- a/app/assets/javascripts/locale/es/app.js +++ b/app/assets/javascripts/locale/es/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-27 11:45-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":["Cambios"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"Deploys":["Despliegues"],"Deutsch":["Alemán"],"English":["Inglés"],"First":["Primer"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last 30 days":["Últimos 30 dÃas"],"Last 90 days":["Últimos 90 dÃas"],"Limited to showing 50 events at most":[""],"Median":["Mediana"],"New Issues":["Nuevos temas"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"Opened":["Abiertos"],"Pipeline Health":["Estado del Pipeline"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Issues":["Temas Relacionados"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Relative Builds Trigger by Commits":["Builds Relacionadas Originadas por Cambios"],"Relative Deployed Builds":["Builds Desplegadas Relacionadas"],"Relative Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"Spanish":["Español"],"Stage":["Etapa"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de temas muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de un tema y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["por"],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Total Time":["Tiempo Total"],"Want to see the data? Please ask administrator for access.":["¿Quieres ver los datos? Por favor pedir acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"You need permission.":["Necesitas permisos."],"by":["por"],"day":["dÃa","dÃas"],"hr":[""],"min":["",""],"pushed by":["enviado por"]}}}; +var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-27 19:15-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":["Cambios"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"Cycle|Code":["Código"],"Cycle|Issue":["Tema"],"Cycle|Plan":["Planificación"],"Cycle|Review":["Revisión"],"Cycle|Staging":["Etapa"],"Cycle|Test":["Pruebas"],"Deploys":["Despliegues"],"Deutsch":["Alemán"],"English":["Inglés"],"First":["Primer"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last 30 days":["Últimos 30 dÃas"],"Last 90 days":["Últimos 90 dÃas"],"Limited to showing 50 events at most":["Limitado a mostrar máximo 50 eventos"],"Median":["Mediana"],"New Issues":["Nuevos temas"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"Opened":["Abiertos"],"Pipeline Health":["Estado del Pipeline"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Issues":["Temas Relacionados"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Relative Builds Trigger by Commits":["Builds Relacionadas Originadas por Cambios"],"Relative Deployed Builds":["Builds Desplegadas Relacionadas"],"Relative Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"Spanish":["Español"],"Stage":["Etapa"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":["La colección de eventos agregados a los datos recopilados para esa etapa."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de temas muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":["La etapa del ciclo de vida del desarrollo."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de un tema y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["El tiempo utilizado por cada entrada de datos obtenido por esa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."],"Total Time":["Tiempo Total"],"Want to see the data? Please ask administrator for access.":["¿Quieres ver los datos? Por favor pedir acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"You need permission.":["Necesitas permisos."],"by":["por"],"day":["dÃa","dÃas"],"hr":[""],"min":["",""],"pushed by":["enviado por"]}}}; \ No newline at end of file diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index b088dfe344158..a1a227d2fe9d0 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -60,7 +60,7 @@ %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."), "aria-hidden" => "true" } %li.event-header %span.stage-name - {{ currentStage ? currentStage.legend : __('Related Issues') }} + {{ currentStage ? __(currentStage.legend) : __('Related Issues') }} %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The collection of events added to the data gathered for that stage."), "aria-hidden" => "true" } %li.total-time-header %span.stage-name @@ -71,7 +71,7 @@ %ul %li.stage-nav-item{ ':class' => '{ active: stage.active }', '@click' => 'selectStage(stage)', "v-for" => "stage in state.stages" } .stage-nav-item-cell.stage-name - {{ stage.title }} + {{ s__('Cycle', stage.title) }} .stage-nav-item-cell.stage-median %template{ "v-if" => "stage.isUserAllowed" } %span{ "v-if" => "stage.value" } diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po index 108273898d03c..843648c25f1fe 100644 --- a/locale/de/gitlab.po +++ b/locale/de/gitlab.po @@ -23,6 +23,24 @@ msgstr "" msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "" +msgid "Cycle|Code" +msgstr "" + +msgid "Cycle|Issue" +msgstr "" + +msgid "Cycle|Plan" +msgstr "" + +msgid "Cycle|Review" +msgstr "" + +msgid "Cycle|Staging" +msgstr "" + +msgid "Cycle|Test" +msgstr "" + msgid "Deploys" msgstr "" diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po index b33e941a4f339..e98931685d753 100644 --- a/locale/en/gitlab.po +++ b/locale/en/gitlab.po @@ -23,6 +23,24 @@ msgstr "" msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "" +msgid "Cycle|Code" +msgstr "" + +msgid "Cycle|Issue" +msgstr "" + +msgid "Cycle|Plan" +msgstr "" + +msgid "Cycle|Review" +msgstr "" + +msgid "Cycle|Staging" +msgstr "" + +msgid "Cycle|Test" +msgstr "" + msgid "Deploys" msgstr "" diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po index f3d77cffecdf3..644007f514928 100644 --- a/locale/es/gitlab.po +++ b/locale/es/gitlab.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2017-04-27 11:45-0500\n" +"PO-Revision-Date: 2017-04-27 19:15-0500\n" "Language-Team: Spanish\n" "Language: es\n" "MIME-Version: 1.0\n" @@ -23,6 +23,30 @@ msgstr "Cambios" msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto." +#, fuzzy +msgid "Cycle|Code" +msgstr "Código" + +#, fuzzy +msgid "Cycle|Issue" +msgstr "Tema" + +#, fuzzy +msgid "Cycle|Plan" +msgstr "Planificación" + +#, fuzzy +msgid "Cycle|Review" +msgstr "Revisión" + +#, fuzzy +msgid "Cycle|Staging" +msgstr "Etapa" + +#, fuzzy +msgid "Cycle|Test" +msgstr "Pruebas" + msgid "Deploys" msgstr "Despliegues" @@ -45,7 +69,7 @@ msgid "Last 90 days" msgstr "Últimos 90 dÃas" msgid "Limited to showing 50 events at most" -msgstr "" +msgstr "Limitado a mostrar máximo 50 eventos" msgid "Median" msgstr "Mediana" @@ -103,13 +127,13 @@ msgid "The coding stage shows the time from the first commit to creating the mer msgstr "La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión." msgid "The collection of events added to the data gathered for that stage." -msgstr "" +msgstr "La colección de eventos agregados a los datos recopilados para esa etapa." msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." msgstr "La etapa de temas muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa." msgid "The phase of the development lifecycle." -msgstr "" +msgstr "La etapa del ciclo de vida del desarrollo." msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." msgstr "La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primer cambio." @@ -126,12 +150,11 @@ msgstr "La etapa de puesta en escena muestra el tiempo entre la fusión y el de msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." msgstr "La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse." -#, fuzzy msgid "The time taken by each data entry gathered by that stage." -msgstr "por" +msgstr "El tiempo utilizado por cada entrada de datos obtenido por esa etapa." msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." -msgstr "" +msgstr "El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6." msgid "Total Time" msgstr "Tiempo Total" diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9d3be20e13224..1b60f9dad7a57 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-04-27 14:41+0100\n" -"PO-Revision-Date: 2017-04-27 14:41+0100\n" +"POT-Creation-Date: 2017-04-27 19:13-0500\n" +"PO-Revision-Date: 2017-04-27 19:13-0500\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" "Language: \n" @@ -24,6 +24,24 @@ msgstr "" msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "" +msgid "Cycle|Code" +msgstr "" + +msgid "Cycle|Issue" +msgstr "" + +msgid "Cycle|Plan" +msgstr "" + +msgid "Cycle|Review" +msgstr "" + +msgid "Cycle|Staging" +msgstr "" + +msgid "Cycle|Test" +msgstr "" + msgid "Deploys" msgstr "" diff --git a/locale/unfound_translations.rb b/locale/unfound_translations.rb index a88300ba43f4b..e38b49b48a17c 100644 --- a/locale/unfound_translations.rb +++ b/locale/unfound_translations.rb @@ -1,3 +1,9 @@ -N_('New Issues') N_('Commits') +N_('Cycle|Code') +N_('Cycle|Issue') +N_('Cycle|Plan') +N_('Cycle|Review') +N_('Cycle|Staging') +N_('Cycle|Test') N_('Deploys') +N_('New Issues') -- GitLab From a922d90414fea5b05152717a72f169c0d9ef7d09 Mon Sep 17 00:00:00 2001 From: Jacob Schatz <jschatz1@gmail.com> Date: Thu, 27 Apr 2017 22:47:50 -0400 Subject: [PATCH 092/363] Add Vue with async deploy keys --- app/assets/javascripts/dispatcher.js | 3 + .../settings/settings_repository.js | 92 +++++++++++++++++++ .../projects/deploy_keys/_index.html.haml | 33 +++---- 3 files changed, 107 insertions(+), 21 deletions(-) create mode 100644 app/assets/javascripts/settings/settings_repository.js diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index b20673cd03c8d..2300daf07274a 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -48,6 +48,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; import UserCallout from './user_callout'; import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags'; import ShortcutsWiki from './shortcuts_wiki'; +import SettingsDeployKeys from './settings/settings_repository'; const ShortcutsBlob = require('./shortcuts_blob'); @@ -343,6 +344,8 @@ const ShortcutsBlob = require('./shortcuts_blob'); // Initialize Protected Tag Settings new ProtectedTagCreate(); new ProtectedTagEditList(); + // Initialize deploy key ajax call + new SettingsDeployKeys(); break; case 'projects:ci_cd:show': new gl.ProjectVariables(); diff --git a/app/assets/javascripts/settings/settings_repository.js b/app/assets/javascripts/settings/settings_repository.js new file mode 100644 index 0000000000000..4c6f769f53301 --- /dev/null +++ b/app/assets/javascripts/settings/settings_repository.js @@ -0,0 +1,92 @@ +/* eslint-disable no-new */ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class SettingsDeployKeys { + constructor() { + this.initVue(); + } + + deployKeyRowTemplate() { + return ` + <li> + <div class="pull-left append-right-10 hidden-xs"> + <i aria-hidden="true" class="fa fa-key key-icon"></i> + </div> + <div class="deploy-key-content key-list-item-info"> + <strong class="title"> + {{deployKey.title}} + </strong> + <div class="description"> + {{deployKey.fingerprint}} + </div> + </div> + <div class="deploy-key-content prepend-left-default deploy-key-projects"> + <a class="label deploy-project-label" :href="project.full_path" v-for="project in deployKey.projects">{{project.full_name}}</a> + </div> + <div class="deploy-key-content"> + <span class="key-created-at"> + created {{deployKey.created_at}} + </span> + <div class="visible-xs-block visible-sm-block"></div> + <a v-if="!enabled" class="btn btn-sm prepend-left-10" rel="nofollow" data-method="put" href="enableURL">Enable +</a> + <a v-else-if="deployKey.destroyed_when_orphaned && deployKey.almost_orphaned" class="btn btn-warning btn-sm prepend-left-10" rel="nofollow" data-method="put" :href="removeURL">Remove</a> + <a v-else class="btn btn-warning btn-sm prepend-left-10" rel="nofollow" data-method="put" :href="disableURL">Disable</a> + </div> + </li>` + } + + deployKeyRowComponent() { + const self = this; + return { + props: { + deployKey: Object, + enabled: Boolean + }, + + computed: { + disableURL() { + return self.disableEndpoint.replace(':id', this.deployKey.id); + }, + + enableURL() { + return self.enableEndpoint.replace(':id', this.deployKey.id); + } + }, + + template: this.deployKeyRowTemplate() + } + } + + initVue() { + const self = this; + const el = document.getElementById('js-deploy-keys'); + const endpoint = el.dataset.endpoint; + this.jsonEndpoint = `${endpoint}.json`; + this.disableEndpoint = `${endpoint}/:id/disable`; + this.enableEndpoint = `${endpoint}/:id/enable`; + new Vue({ + el: el, + components: { + deployKeyRow: self.deployKeyRowComponent() + }, + data () { + return { + enabledKeys: [], + availableKeys: [] + } + }, + created () { + this.$http.get(self.jsonEndpoint) + .then((res) => { + const keys = JSON.parse(res.body); + this.enabledKeys = keys.enabled_keys; + this.availableKeys = keys.available_project_keys; + }); + } + }) + } +} \ No newline at end of file diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml index 4cfbd9add009d..b35cd356aa507 100644 --- a/app/views/projects/deploy_keys/_index.html.haml +++ b/app/views/projects/deploy_keys/_index.html.haml @@ -10,25 +10,16 @@ = render @deploy_keys.form_partial_path .col-lg-9.col-lg-offset-3 %hr - .col-lg-9.col-lg-offset-3.append-bottom-default.deploy-keys + #js-deploy-keys.col-lg-9.col-lg-offset-3.append-bottom-default{data:{endpoint: namespace_project_deploy_keys_path}} %h5.prepend-top-0 - Enabled deploy keys for this project (#{@deploy_keys.enabled_keys_size}) - - if @deploy_keys.any_keys_enabled? - %ul.well-list - = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.enabled_keys, as: :deploy_key - - else - .settings-message.text-center - No deploy keys found. Create one with the form above. - %h5.prepend-top-default - Deploy keys from projects you have access to (#{@deploy_keys.available_project_keys_size}) - - if @deploy_keys.any_available_project_keys_enabled? - %ul.well-list - = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.available_project_keys, as: :deploy_key - - else - .settings-message.text-center - No deploy keys from your projects could be found. Create one with the form above or add existing one below. - - if @deploy_keys.any_available_public_keys_enabled? - %h5.prepend-top-default - Public deploy keys available to any project (#{@deploy_keys.available_public_keys_size}) - %ul.well-list - = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.available_public_keys, as: :deploy_key + Enabled deploy keys for this project ({{enabledKeys.length}}) + %ul.well-list{"v-if" => "enabledKeys.length"} + %deploy-key-row{"v-for" => "deployKey in enabledKeys", ":deploy-key" => "deployKey", ":enabled" =>"true"} + .settings-message.text-center{"v-else" => true} + No deploy keys found. Create one with the form above. + %h5.prepend-top-0 + Deploy keys from projects you have access to ({{availableKeys.length}}) + %ul.well-list{"v-if" => "availableKeys.length"} + %deploy-key-row{"v-for" => "deployKey in availableKeys", ":deploy-key" => "deployKey", ":enabled" =>"false"} + .settings-message.text-center{"v-else" => true} + No deploy keys found. Create one with the form above. -- GitLab From b5b33200ccef223e089edf6ad8cdd0ecc3e737da Mon Sep 17 00:00:00 2001 From: Jacob Schatz <jschatz1@gmail.com> Date: Thu, 27 Apr 2017 23:05:22 -0400 Subject: [PATCH 093/363] Add timeago --- app/assets/javascripts/settings/settings_repository.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/settings/settings_repository.js b/app/assets/javascripts/settings/settings_repository.js index 4c6f769f53301..9d6a60baa3ad4 100644 --- a/app/assets/javascripts/settings/settings_repository.js +++ b/app/assets/javascripts/settings/settings_repository.js @@ -28,12 +28,12 @@ export default class SettingsDeployKeys { </div> <div class="deploy-key-content"> <span class="key-created-at"> - created {{deployKey.created_at}} + created {{timeagoDate}} </span> <div class="visible-xs-block visible-sm-block"></div> <a v-if="!enabled" class="btn btn-sm prepend-left-10" rel="nofollow" data-method="put" href="enableURL">Enable </a> - <a v-else-if="deployKey.destroyed_when_orphaned && deployKey.almost_orphaned" class="btn btn-warning btn-sm prepend-left-10" rel="nofollow" data-method="put" :href="removeURL">Remove</a> + <a v-else-if="deployKey.destroyed_when_orphaned && deployKey.almost_orphaned" class="btn btn-warning btn-sm prepend-left-10" rel="nofollow" data-method="put" :href="disableURL">Remove</a> <a v-else class="btn btn-warning btn-sm prepend-left-10" rel="nofollow" data-method="put" :href="disableURL">Disable</a> </div> </li>` @@ -48,6 +48,10 @@ export default class SettingsDeployKeys { }, computed: { + timeagoDate() { + return gl.utils.getTimeago().format(this.deployKey.createdAt, 'gl_en'); + }, + disableURL() { return self.disableEndpoint.replace(':id', this.deployKey.id); }, -- GitLab From d14397ff956e6f56bedf7737f51f8da154a9da64 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Fri, 28 Apr 2017 10:49:28 +0100 Subject: [PATCH 094/363] Fixed context translation Fixes CSS widths when translating --- .../components/limit_warning_component.js | 2 +- app/assets/javascripts/locale/index.js | 5 ++++- app/assets/stylesheets/pages/cycle_analytics.scss | 12 ++++-------- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js index 6e07560040c95..c43a45067553b 100644 --- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js +++ b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js @@ -6,7 +6,7 @@ export default { }, }, template: ` - <span v-if="count === 50 || true" class="events-info pull-right"> + <span v-if="count === 50" class="events-info pull-right"> <i class="fa fa-warning has-tooltip" aria-hidden="true" :title="__('Limited to showing 50 events at most')" diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js index 3907b0e2aba34..ca3ed69fbb3ba 100644 --- a/app/assets/javascripts/locale/index.js +++ b/app/assets/javascripts/locale/index.js @@ -13,7 +13,10 @@ const lang = document.querySelector('html').getAttribute('lang') || 'en'; const locale = new Jed(locales[lang]); const gettext = locale.gettext.bind(locale); const ngettext = locale.ngettext.bind(locale); -const pgettext = locale.pgettext.bind(locale); +const pgettext = (context, key) => { + const joinedKey = [context, key].join('|'); + return gettext(joinedKey).split('|').pop(); +}; export { lang }; export { gettext as __ }; diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index ad3dbc7ac4832..182909627bb48 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -213,7 +213,7 @@ } .stage-nav-item { - display: block; + display: flex; line-height: 65px; border-top: 1px solid transparent; border-bottom: 1px solid transparent; @@ -247,14 +247,10 @@ } .stage-nav-item-cell { - float: left; - - &.stage-name { - width: 65%; - } - &.stage-median { - width: 35%; + margin-left: auto; + margin-right: $gl-padding; + min-width: calc(35% - #{$gl-padding}); } } -- GitLab From 79d50538c4c633dd35b6182ba4d08d4eba7576fc Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Fri, 28 Apr 2017 11:27:52 +0100 Subject: [PATCH 095/363] Fixed avatar not displaying in issue boards This happens when Gravatar is disabled in the admin settings, the avatar is returned as null & then frontend didn't do anything about it. Closes #31428 --- app/assets/javascripts/boards/models/user.js | 3 ++- app/assets/javascripts/boards/utils/default_avatar.js | 1 + app/helpers/boards_helper.rb | 1 + changelogs/unreleased/issue-boards-no-avatar.yml | 4 ++++ 4 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 app/assets/javascripts/boards/utils/default_avatar.js create mode 100644 changelogs/unreleased/issue-boards-no-avatar.yml diff --git a/app/assets/javascripts/boards/models/user.js b/app/assets/javascripts/boards/models/user.js index 8e9de4d4cbb69..875c21a37d362 100644 --- a/app/assets/javascripts/boards/models/user.js +++ b/app/assets/javascripts/boards/models/user.js @@ -1,11 +1,12 @@ /* eslint-disable no-unused-vars */ +import defaultAvatar from '../utils/default_avatar'; class ListUser { constructor(user) { this.id = user.id; this.name = user.name; this.username = user.username; - this.avatar = user.avatar_url; + this.avatar = user.avatar_url || defaultAvatar(); } } diff --git a/app/assets/javascripts/boards/utils/default_avatar.js b/app/assets/javascripts/boards/utils/default_avatar.js new file mode 100644 index 0000000000000..062ffec6dce0d --- /dev/null +++ b/app/assets/javascripts/boards/utils/default_avatar.js @@ -0,0 +1 @@ +export default () => document.getElementById('board-app').dataset.defaultAvatar; diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index f43827da446d0..e2df52e383323 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -9,6 +9,7 @@ def board_data issue_link_base: namespace_project_issues_path(@project.namespace, @project), root_path: root_path, bulk_update_path: bulk_update_namespace_project_issues_path(@project.namespace, @project), + default_avatar: image_path(default_avatar) } end end diff --git a/changelogs/unreleased/issue-boards-no-avatar.yml b/changelogs/unreleased/issue-boards-no-avatar.yml new file mode 100644 index 0000000000000..a2dd53b3f2fe8 --- /dev/null +++ b/changelogs/unreleased/issue-boards-no-avatar.yml @@ -0,0 +1,4 @@ +--- +title: Fixed avatar not display on issue boards when Gravatar is disabled +merge_request: +author: -- GitLab From 817aa1b1160bcc1f3be828ad3c0c66fe403e2557 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 28 Apr 2017 11:32:56 +0100 Subject: [PATCH 096/363] added balsamiq blobviewer class --- app/models/blob_viewer/balsamiq.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 app/models/blob_viewer/balsamiq.rb diff --git a/app/models/blob_viewer/balsamiq.rb b/app/models/blob_viewer/balsamiq.rb new file mode 100644 index 0000000000000..8b0c0c049a124 --- /dev/null +++ b/app/models/blob_viewer/balsamiq.rb @@ -0,0 +1,12 @@ +module BalsamiqViewer + class Balsamiq < Base + include Rich + include ClientSide + + self.partial_name = 'balsamiq' + self.extensions = %w(bmpr) + self.binary = true + self.switcher_icon = 'file-image-o' + self.switcher_title = 'preview' + end +end -- GitLab From a67a648b0055334dbcfe0b20e01cd7b1401d2a0a Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 28 Apr 2017 11:42:30 +0100 Subject: [PATCH 097/363] Moved partial --- app/models/blob_viewer/balsamiq.rb | 2 +- .../blob/{_bmpr.html.haml => viewers/_balsamiq.html.haml} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename app/views/projects/blob/{_bmpr.html.haml => viewers/_balsamiq.html.haml} (100%) diff --git a/app/models/blob_viewer/balsamiq.rb b/app/models/blob_viewer/balsamiq.rb index 8b0c0c049a124..f982521db9973 100644 --- a/app/models/blob_viewer/balsamiq.rb +++ b/app/models/blob_viewer/balsamiq.rb @@ -1,4 +1,4 @@ -module BalsamiqViewer +module BlobViewer class Balsamiq < Base include Rich include ClientSide diff --git a/app/views/projects/blob/_bmpr.html.haml b/app/views/projects/blob/viewers/_balsamiq.html.haml similarity index 100% rename from app/views/projects/blob/_bmpr.html.haml rename to app/views/projects/blob/viewers/_balsamiq.html.haml -- GitLab From fc954749eace61d448fe8cc04c7fc0126bdbcdee Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 28 Apr 2017 12:15:07 +0100 Subject: [PATCH 098/363] Corrected styling, less hacky --- app/assets/stylesheets/framework/files.scss | 29 +++++---------------- 1 file changed, 6 insertions(+), 23 deletions(-) diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 3737ae3e653f6..a95ce4cc963e9 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -162,31 +162,14 @@ } .list-inline.previews { - display: inline-block; + display: flex; + flex-wrap: wrap; + justify-content: center; + align-content: flex-start; + align-items: baseline; .preview { - display: inline-block; - height: 280px; - min-width: 260px; - margin: 0; - padding: 16px; - @include make-xs-column(3); - } - - .panel { - margin: 0; - height: 100%; - - .panel-body { - padding: $gl-padding 0 0; - text-align: center; - } - } - - .img-thumbnail { - max-height: 195px; - max-width: 195px; - padding: 0; + padding: $gl-padding; } } } -- GitLab From 284d4f76fee9f593cb67f3f2978ad4f49ef03c13 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 28 Apr 2017 13:23:34 +0100 Subject: [PATCH 099/363] Attempted adding separate clientside_sentry settings --- .../admin/application_settings_controller.rb | 2 ++ app/helpers/sentry_helper.rb | 12 ++++-------- app/models/application_setting.rb | 4 ++++ .../admin/application_settings/_form.html.haml | 15 +++++++++++++++ app/views/layouts/_head.html.haml | 2 +- config/application.rb | 2 ++ db/schema.rb | 13 ++++++------- lib/api/settings.rb | 5 +++++ lib/api/v3/settings.rb | 6 +++++- lib/gitlab/gon_helper.rb | 2 +- 10 files changed, 45 insertions(+), 18 deletions(-) diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 643993d035e43..152d7baad4967 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -133,6 +133,8 @@ def application_setting_params_ce :signup_enabled, :sentry_dsn, :sentry_enabled, + :clientside_sentry_dsn, + :clientside_sentry_enabled, :send_user_confirmation_email, :shared_runners_enabled, :shared_runners_text, diff --git a/app/helpers/sentry_helper.rb b/app/helpers/sentry_helper.rb index 4b07f71bcea02..8e80f83ae6147 100644 --- a/app/helpers/sentry_helper.rb +++ b/app/helpers/sentry_helper.rb @@ -7,13 +7,9 @@ def sentry_context Gitlab::Sentry.context(current_user) end - def sentry_dsn_public - sentry_dsn = ApplicationSetting.current.sentry_dsn - - return unless sentry_dsn - - uri = URI.parse(sentry_dsn) - uri.password = nil - uri.to_s + def clientside_sentry_enabled? + current_application_settings.clientside_sentry_enabled end + + delegate :clientside_sentry_dsn, to: :current_application_settings end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index dd1a692296883..4a1e7d6e59eef 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -60,6 +60,10 @@ class ApplicationSetting < ActiveRecord::Base presence: true, if: :sentry_enabled + validates :clientside_sentry_dsn, + presence: true, + if: :clientside_sentry_enabled + validates :akismet_api_key, presence: true, if: :akismet_enabled diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 0dc1103eecef5..5cc99e0833fcb 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -411,6 +411,21 @@ .col-sm-10 = f.text_field :sentry_dsn, class: 'form-control' + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :clientside_sentry_enabled do + = f.check_box :clientside_sentry_enabled + Enable Clientside Sentry + .help-block + Sentry can also be used for reporting and logging clientside exceptions. + %a{ href: 'https://sentry.io/for/javascript/', target: '_blank', rel: 'noopener noreferrer' } https://sentry.io/for/javascript/ + + .form-group + = f.label :sentry_dsn, 'Sentry DSN', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :sentry_dsn, class: 'form-control' + %fieldset %legend Repository Storage .form-group diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 8aef5cbdc044f..1579afa646174 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -33,7 +33,7 @@ = webpack_bundle_tag "runtime" = webpack_bundle_tag "common" = webpack_bundle_tag "main" - = webpack_bundle_tag "raven" if sentry_enabled? + = webpack_bundle_tag "raven" if clientside_sentry_enabled? - if content_for?(:page_specific_javascripts) = yield :page_specific_javascripts diff --git a/config/application.rb b/config/application.rb index f2ecc4ce77c7e..0ab8a24a5dff8 100644 --- a/config/application.rb +++ b/config/application.rb @@ -55,6 +55,7 @@ class Application < Rails::Application # - Webhook URLs (:hook) # - GitLab-shell secret token (:secret_token) # - Sentry DSN (:sentry_dsn) + # - Clientside Sentry DSN (:clientside_sentry_dsn) # - Deploy keys (:key) config.filter_parameters += %i( authentication_token @@ -71,6 +72,7 @@ class Application < Rails::Application runners_token secret_token sentry_dsn + clientside_sentry_dsn variables ) diff --git a/db/schema.rb b/db/schema.rb index ff00951d5f6bb..592f1c253eaa2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170424142900) do +ActiveRecord::Schema.define(version: 20170426175636) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -63,7 +63,6 @@ t.boolean "shared_runners_enabled", default: true, null: false t.integer "max_artifacts_size", default: 100, null: false t.string "runners_registration_token" - t.integer "max_pages_size", default: 100, null: false t.boolean "require_two_factor_authentication", default: false t.integer "two_factor_grace_period", default: 48 t.boolean "metrics_enabled", default: false @@ -112,15 +111,16 @@ t.boolean "html_emails_enabled", default: true t.string "plantuml_url" t.boolean "plantuml_enabled" + t.integer "max_pages_size", default: 100, null: false t.integer "terminal_max_session_time", default: 0, null: false t.string "default_artifacts_expire_in", default: "0", null: false t.integer "unique_ips_limit_per_user" t.integer "unique_ips_limit_time_window" t.boolean "unique_ips_limit_enabled", default: false, null: false t.decimal "polling_interval_multiplier", default: 1.0, null: false - t.integer "cached_markdown_version" t.boolean "usage_ping_enabled", default: true, null: false t.string "uuid" + t.integer "cached_markdown_version" end create_table "audit_events", force: :cascade do |t| @@ -731,8 +731,8 @@ t.integer "visibility_level", default: 20, null: false t.boolean "request_access_enabled", default: false, null: false t.datetime "deleted_at" - t.text "description_html" t.boolean "lfs_enabled" + t.text "description_html" t.integer "parent_id" t.boolean "require_two_factor_authentication", default: false, null: false t.integer "two_factor_grace_period", default: 48, null: false @@ -965,9 +965,9 @@ t.boolean "lfs_enabled" t.text "description_html" t.boolean "only_allow_merge_if_all_discussions_are_resolved" - t.integer "auto_cancel_pending_pipelines", default: 0, null: false t.boolean "printing_merge_request_link_enabled", default: true, null: false t.string "import_jid" + t.integer "auto_cancel_pending_pipelines", default: 0, null: false t.integer "cached_markdown_version" end @@ -1070,7 +1070,6 @@ t.string "line_code" t.string "note_type" t.text "position" - t.string "in_reply_to_discussion_id" end add_index "sent_notifications", ["reply_key"], name: "index_sent_notifications_on_reply_key", unique: true, using: :btree @@ -1320,10 +1319,10 @@ t.string "organization" t.boolean "authorized_projects_populated" t.boolean "ghost" - t.date "last_activity_on" t.boolean "notified_of_own_activity" t.boolean "require_two_factor_authentication_from_group", default: false, null: false t.integer "two_factor_grace_period", default: 48, null: false + t.date "last_activity_on" end add_index "users", ["admin"], name: "index_users_on_admin", using: :btree diff --git a/lib/api/settings.rb b/lib/api/settings.rb index d01c7f2703b96..82f513c984ec1 100644 --- a/lib/api/settings.rb +++ b/lib/api/settings.rb @@ -58,6 +58,7 @@ def current_settings :restricted_visibility_levels, :send_user_confirmation_email, :sentry_enabled, + :clientside_sentry_enabled, :session_expire_delay, :shared_runners_enabled, :sidekiq_throttling_enabled, @@ -138,6 +139,10 @@ def current_settings given sentry_enabled: ->(val) { val } do requires :sentry_dsn, type: String, desc: 'Sentry Data Source Name' end + optional :clientside_sentry_enabled, type: Boolean, desc: 'Sentry can also be used for reporting and logging clientside exceptions. https://sentry.io/for/javascript/' + given clientside_sentry_enabled: ->(val) { val } do + requires :clientside_sentry_dsn, type: String, desc: 'Clientside Sentry Data Source Name' + end optional :repository_storage, type: String, desc: 'Storage paths for new projects' optional :repository_checks_enabled, type: Boolean, desc: "GitLab will periodically run 'git fsck' in all project and wiki repositories to look for silent disk corruption issues." optional :koding_enabled, type: Boolean, desc: 'Enable Koding' diff --git a/lib/api/v3/settings.rb b/lib/api/v3/settings.rb index 748d6b97d4f94..67a85e13d8c48 100644 --- a/lib/api/v3/settings.rb +++ b/lib/api/v3/settings.rb @@ -89,6 +89,10 @@ def current_settings given sentry_enabled: ->(val) { val } do requires :sentry_dsn, type: String, desc: 'Sentry Data Source Name' end + optional :clientside_sentry_enabled, type: Boolean, desc: 'Sentry can also be used for reporting and logging clientside exceptions. https://sentry.io/for/javascript/' + given clientside_sentry_enabled: ->(val) { val } do + requires :clientside_sentry_dsn, type: String, desc: 'Clientside Sentry Data Source Name' + end optional :repository_storage, type: String, desc: 'Storage paths for new projects' optional :repository_checks_enabled, type: Boolean, desc: "GitLab will periodically run 'git fsck' in all project and wiki repositories to look for silent disk corruption issues." optional :koding_enabled, type: Boolean, desc: 'Enable Koding' @@ -120,7 +124,7 @@ def current_settings :home_page_url, :after_sign_out_path, :sign_in_text, :help_page_text, :shared_runners_enabled, :max_artifacts_size, :max_pages_size, :container_registry_token_expire_delay, :metrics_enabled, :sidekiq_throttling_enabled, :recaptcha_enabled, - :akismet_enabled, :admin_notification_email, :sentry_enabled, + :akismet_enabled, :admin_notification_email, :sentry_enabled, :clientside_sentry_enabled, :repository_storage, :repository_checks_enabled, :koding_enabled, :plantuml_enabled, :version_check_enabled, :email_author_in_body, :html_emails_enabled, :housekeeping_enabled, :terminal_max_session_time diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index 4de504e9bf984..a8a2715e8480c 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -12,7 +12,7 @@ def add_gon_variables gon.user_color_scheme = Gitlab::ColorSchemes.for_user(current_user).css_class gon.katex_css_url = ActionController::Base.helpers.asset_path('katex.css') gon.katex_js_url = ActionController::Base.helpers.asset_path('katex.js') - gon.sentry_dsn = sentry_dsn_public if sentry_enabled? + gon.sentry_dsn = Gitlab.config.clientside_sentry_dsn if clientside_sentry_enabled? gon.gitlab_url = Gitlab.config.gitlab.url gon.is_production = Rails.env.production? -- GitLab From cb4b2e31a75e0f31e49f739c9008451d53908a0e Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 28 Apr 2017 13:42:26 +0100 Subject: [PATCH 100/363] Added migration --- ...3910_add_clientside_sentry_to_application_settings.rb | 9 +++++++++ db/schema.rb | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20170428123910_add_clientside_sentry_to_application_settings.rb diff --git a/db/migrate/20170428123910_add_clientside_sentry_to_application_settings.rb b/db/migrate/20170428123910_add_clientside_sentry_to_application_settings.rb new file mode 100644 index 0000000000000..2b085b889eb87 --- /dev/null +++ b/db/migrate/20170428123910_add_clientside_sentry_to_application_settings.rb @@ -0,0 +1,9 @@ +# rubocop:disable all +class AddClientsideSentryToApplicationSettings < ActiveRecord::Migration + def change + change_table :application_settings do |t| + t.boolean :clientside_sentry_enabled, default: false + t.string :clientside_sentry_dsn + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 592f1c253eaa2..a9940be22ffe8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170426175636) do +ActiveRecord::Schema.define(version: 20170428123910) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -121,6 +121,8 @@ t.boolean "usage_ping_enabled", default: true, null: false t.string "uuid" t.integer "cached_markdown_version" + t.boolean "clientside_sentry_enabled", default: false + t.string "clientside_sentry_dsn" end create_table "audit_events", force: :cascade do |t| @@ -296,6 +298,7 @@ t.boolean "locked", default: false, null: false end + add_index "ci_runners", ["contacted_at"], name: "index_ci_runners_on_contacted_at", using: :btree add_index "ci_runners", ["is_shared"], name: "index_ci_runners_on_is_shared", using: :btree add_index "ci_runners", ["locked"], name: "index_ci_runners_on_locked", using: :btree add_index "ci_runners", ["token"], name: "index_ci_runners_on_token", using: :btree -- GitLab From 8e8be5d1710f246bbb43c6c0bae82dead1139fde Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 28 Apr 2017 13:51:45 +0100 Subject: [PATCH 101/363] Fixed view to correct property --- app/helpers/sentry_helper.rb | 6 ----- .../application_settings/_form.html.haml | 4 ++-- app/views/layouts/_head.html.haml | 2 +- lib/gitlab/gon_helper.rb | 2 +- spec/helpers/sentry_helper_spec.rb | 22 ------------------- 5 files changed, 4 insertions(+), 32 deletions(-) delete mode 100644 spec/helpers/sentry_helper_spec.rb diff --git a/app/helpers/sentry_helper.rb b/app/helpers/sentry_helper.rb index 8e80f83ae6147..3d255df66a0c3 100644 --- a/app/helpers/sentry_helper.rb +++ b/app/helpers/sentry_helper.rb @@ -6,10 +6,4 @@ def sentry_enabled? def sentry_context Gitlab::Sentry.context(current_user) end - - def clientside_sentry_enabled? - current_application_settings.clientside_sentry_enabled - end - - delegate :clientside_sentry_dsn, to: :current_application_settings end diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 5cc99e0833fcb..6030c8b1dfa25 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -422,9 +422,9 @@ %a{ href: 'https://sentry.io/for/javascript/', target: '_blank', rel: 'noopener noreferrer' } https://sentry.io/for/javascript/ .form-group - = f.label :sentry_dsn, 'Sentry DSN', class: 'control-label col-sm-2' + = f.label :clientside_sentry_dsn, 'Clientside Sentry DSN', class: 'control-label col-sm-2' .col-sm-10 - = f.text_field :sentry_dsn, class: 'form-control' + = f.text_field :clientside_sentry_dsn, class: 'form-control' %fieldset %legend Repository Storage diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 1579afa646174..b768ac5a36cfd 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -33,7 +33,7 @@ = webpack_bundle_tag "runtime" = webpack_bundle_tag "common" = webpack_bundle_tag "main" - = webpack_bundle_tag "raven" if clientside_sentry_enabled? + = webpack_bundle_tag "raven" if Gitlab.config.clientside_sentry_enabled - if content_for?(:page_specific_javascripts) = yield :page_specific_javascripts diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index a8a2715e8480c..e7ae1d1669861 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -12,7 +12,7 @@ def add_gon_variables gon.user_color_scheme = Gitlab::ColorSchemes.for_user(current_user).css_class gon.katex_css_url = ActionController::Base.helpers.asset_path('katex.css') gon.katex_js_url = ActionController::Base.helpers.asset_path('katex.js') - gon.sentry_dsn = Gitlab.config.clientside_sentry_dsn if clientside_sentry_enabled? + gon.sentry_dsn = Gitlab.config.clientside_sentry_dsn if Gitlab.config.clientside_sentry_enabled gon.gitlab_url = Gitlab.config.gitlab.url gon.is_production = Rails.env.production? diff --git a/spec/helpers/sentry_helper_spec.rb b/spec/helpers/sentry_helper_spec.rb deleted file mode 100644 index ff218235cd1d3..0000000000000 --- a/spec/helpers/sentry_helper_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -require 'spec_helper' - -describe SentryHelper do - describe '#sentry_dsn_public' do - it 'returns nil if no sentry_dsn is set' do - mock_sentry_dsn(nil) - - expect(helper.sentry_dsn_public).to eq nil - end - - it 'returns the uri string with no password if sentry_dsn is set' do - mock_sentry_dsn('https://test:dsn@host/path') - - expect(helper.sentry_dsn_public).to eq 'https://test@host/path' - end - end - - def mock_sentry_dsn(value) - allow_message_expectations_on_nil - allow(ApplicationSetting.current).to receive(:sentry_dsn).and_return(value) - end -end -- GitLab From 0e8afe1343497165bf609a840306ce26c6136a7b Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 28 Apr 2017 14:00:20 +0100 Subject: [PATCH 102/363] Remove unneeded helper include --- lib/gitlab/gon_helper.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index e7ae1d1669861..34f74232db62e 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -1,5 +1,3 @@ -include SentryHelper - module Gitlab module GonHelper def add_gon_variables -- GitLab From 97633d561681a5c0f55472c1372bedca5fe3bffe Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 28 Apr 2017 16:01:22 +0100 Subject: [PATCH 103/363] Fixed specs --- app/assets/javascripts/raven/raven_config.js | 2 +- app/views/layouts/_head.html.haml | 5 +++- config/application.rb | 1 + lib/gitlab/gon_helper.rb | 2 +- spec/features/raven_js_spec.rb | 4 +-- spec/javascripts/raven/raven_config_spec.js | 28 +++++++++++--------- 6 files changed, 24 insertions(+), 18 deletions(-) diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/raven/raven_config.js index 6c20cd9aabdb7..43e55bc541512 100644 --- a/app/assets/javascripts/raven/raven_config.js +++ b/app/assets/javascripts/raven/raven_config.js @@ -61,7 +61,7 @@ const RavenConfig = { environment: this.options.isProduction ? 'production' : 'development', ignoreErrors: this.IGNORE_ERRORS, ignoreUrls: this.IGNORE_URLS, - shouldSendCallback: this.shouldSendSample, + shouldSendCallback: this.shouldSendSample.bind(this), }).install(); }, diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index b768ac5a36cfd..6123440b6e58f 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -33,7 +33,10 @@ = webpack_bundle_tag "runtime" = webpack_bundle_tag "common" = webpack_bundle_tag "main" - = webpack_bundle_tag "raven" if Gitlab.config.clientside_sentry_enabled + = webpack_bundle_tag "raven" if current_application_settings.clientside_sentry_enabled + + = 'LUKETEST' + = current_application_settings.clientside_sentry_enabled - if content_for?(:page_specific_javascripts) = yield :page_specific_javascripts diff --git a/config/application.rb b/config/application.rb index 0ab8a24a5dff8..46652637c1f36 100644 --- a/config/application.rb +++ b/config/application.rb @@ -72,6 +72,7 @@ class Application < Rails::Application runners_token secret_token sentry_dsn + clientside_sentry_enabled clientside_sentry_dsn variables ) diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index 34f74232db62e..848b3352c63b7 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -10,7 +10,7 @@ def add_gon_variables gon.user_color_scheme = Gitlab::ColorSchemes.for_user(current_user).css_class gon.katex_css_url = ActionController::Base.helpers.asset_path('katex.css') gon.katex_js_url = ActionController::Base.helpers.asset_path('katex.js') - gon.sentry_dsn = Gitlab.config.clientside_sentry_dsn if Gitlab.config.clientside_sentry_enabled + gon.sentry_dsn = current_application_settings.clientside_sentry_dsn if current_application_settings.clientside_sentry_enabled gon.gitlab_url = Gitlab.config.gitlab.url gon.is_production = Rails.env.production? diff --git a/spec/features/raven_js_spec.rb b/spec/features/raven_js_spec.rb index 74df52d80a73e..0fe1c28bf8d3d 100644 --- a/spec/features/raven_js_spec.rb +++ b/spec/features/raven_js_spec.rb @@ -10,8 +10,7 @@ end it 'should load raven if sentry is enabled' do - allow_any_instance_of(SentryHelper).to receive_messages(sentry_dsn_public: 'https://key@domain.com/id', - sentry_enabled?: true) + stub_application_setting(clientside_sentry_dsn: 'https://key@domain.com/id', clientside_sentry_enabled: true) visit new_user_session_path @@ -19,6 +18,7 @@ end def has_requested_raven + p page.driver.network_traffic page.driver.network_traffic.one? {|request| request.url.end_with?(raven_path)} end end diff --git a/spec/javascripts/raven/raven_config_spec.js b/spec/javascripts/raven/raven_config_spec.js index 78251b4f61b6c..b4c35420f9094 100644 --- a/spec/javascripts/raven/raven_config_spec.js +++ b/spec/javascripts/raven/raven_config_spec.js @@ -77,6 +77,7 @@ describe('RavenConfig', () => { describe('configure', () => { let options; let raven; + let ravenConfig; beforeEach(() => { options = { @@ -85,22 +86,25 @@ describe('RavenConfig', () => { isProduction: true, }; + ravenConfig = jasmine.createSpyObj('ravenConfig', ['shouldSendSample']); raven = jasmine.createSpyObj('raven', ['install']); spyOn(Raven, 'config').and.returnValue(raven); - RavenConfig.configure.call({ - options, - }); + ravenConfig.options = options; + ravenConfig.IGNORE_ERRORS = 'ignore_errors'; + ravenConfig.IGNORE_URLS = 'ignore_urls'; + + RavenConfig.configure.call(ravenConfig); }); it('should call Raven.config', () => { expect(Raven.config).toHaveBeenCalledWith(options.sentryDsn, { whitelistUrls: options.whitelistUrls, environment: 'production', - ignoreErrors: Raven.IGNORE_ERRORS, - ignoreUrls: Raven.IGNORE_URLS, - shouldSendCallback: Raven.shouldSendSample, + ignoreErrors: ravenConfig.IGNORE_ERRORS, + ignoreUrls: ravenConfig.IGNORE_URLS, + shouldSendCallback: jasmine.any(Function), }); }); @@ -109,18 +113,16 @@ describe('RavenConfig', () => { }); it('should set .environment to development if isProduction is false', () => { - options.isProduction = false; + ravenConfig.options.isProduction = false; - RavenConfig.configure.call({ - options, - }); + RavenConfig.configure.call(ravenConfig); expect(Raven.config).toHaveBeenCalledWith(options.sentryDsn, { whitelistUrls: options.whitelistUrls, environment: 'development', - ignoreErrors: Raven.IGNORE_ERRORS, - ignoreUrls: Raven.IGNORE_URLS, - shouldSendCallback: Raven.shouldSendSample, + ignoreErrors: ravenConfig.IGNORE_ERRORS, + ignoreUrls: ravenConfig.IGNORE_URLS, + shouldSendCallback: jasmine.any(Function), }); }); }); -- GitLab From 89eaeb11d2a29b28b04f6494545ee1368462c237 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 28 Apr 2017 16:04:07 +0100 Subject: [PATCH 104/363] Revert schema.db to master --- db/schema.rb | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index 81cd32e2d8302..b938657a18655 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,11 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -<<<<<<< HEAD -ActiveRecord::Schema.define(version: 20170428123910) do -======= ActiveRecord::Schema.define(version: 20170426181740) do ->>>>>>> origin/master # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -67,6 +63,7 @@ t.boolean "shared_runners_enabled", default: true, null: false t.integer "max_artifacts_size", default: 100, null: false t.string "runners_registration_token" + t.integer "max_pages_size", default: 100, null: false t.boolean "require_two_factor_authentication", default: false t.integer "two_factor_grace_period", default: 48 t.boolean "metrics_enabled", default: false @@ -115,18 +112,15 @@ t.boolean "html_emails_enabled", default: true t.string "plantuml_url" t.boolean "plantuml_enabled" - t.integer "max_pages_size", default: 100, null: false t.integer "terminal_max_session_time", default: 0, null: false t.string "default_artifacts_expire_in", default: "0", null: false t.integer "unique_ips_limit_per_user" t.integer "unique_ips_limit_time_window" t.boolean "unique_ips_limit_enabled", default: false, null: false t.decimal "polling_interval_multiplier", default: 1.0, null: false + t.integer "cached_markdown_version" t.boolean "usage_ping_enabled", default: true, null: false t.string "uuid" - t.integer "cached_markdown_version" - t.boolean "clientside_sentry_enabled", default: false - t.string "clientside_sentry_dsn" end create_table "audit_events", force: :cascade do |t| @@ -738,8 +732,8 @@ t.integer "visibility_level", default: 20, null: false t.boolean "request_access_enabled", default: false, null: false t.datetime "deleted_at" - t.boolean "lfs_enabled" t.text "description_html" + t.boolean "lfs_enabled" t.integer "parent_id" t.boolean "require_two_factor_authentication", default: false, null: false t.integer "two_factor_grace_period", default: 48, null: false @@ -972,9 +966,9 @@ t.boolean "lfs_enabled" t.text "description_html" t.boolean "only_allow_merge_if_all_discussions_are_resolved" + t.integer "auto_cancel_pending_pipelines", default: 0, null: false t.boolean "printing_merge_request_link_enabled", default: true, null: false t.string "import_jid" - t.integer "auto_cancel_pending_pipelines", default: 0, null: false t.integer "cached_markdown_version" end @@ -1077,6 +1071,7 @@ t.string "line_code" t.string "note_type" t.text "position" + t.string "in_reply_to_discussion_id" end add_index "sent_notifications", ["reply_key"], name: "index_sent_notifications_on_reply_key", unique: true, using: :btree @@ -1326,10 +1321,10 @@ t.string "organization" t.boolean "authorized_projects_populated" t.boolean "ghost" + t.date "last_activity_on" t.boolean "notified_of_own_activity" t.boolean "require_two_factor_authentication_from_group", default: false, null: false t.integer "two_factor_grace_period", default: 48, null: false - t.date "last_activity_on" end add_index "users", ["admin"], name: "index_users_on_admin", using: :btree -- GitLab From eacf8b74c5f57bd594485b9fe20e91c933d8b9bb Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 28 Apr 2017 16:07:59 +0100 Subject: [PATCH 105/363] Applied schema update with patch --- db/schema.rb | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index b938657a18655..a9940be22ffe8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170426181740) do +ActiveRecord::Schema.define(version: 20170428123910) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -63,7 +63,6 @@ t.boolean "shared_runners_enabled", default: true, null: false t.integer "max_artifacts_size", default: 100, null: false t.string "runners_registration_token" - t.integer "max_pages_size", default: 100, null: false t.boolean "require_two_factor_authentication", default: false t.integer "two_factor_grace_period", default: 48 t.boolean "metrics_enabled", default: false @@ -112,15 +111,18 @@ t.boolean "html_emails_enabled", default: true t.string "plantuml_url" t.boolean "plantuml_enabled" + t.integer "max_pages_size", default: 100, null: false t.integer "terminal_max_session_time", default: 0, null: false t.string "default_artifacts_expire_in", default: "0", null: false t.integer "unique_ips_limit_per_user" t.integer "unique_ips_limit_time_window" t.boolean "unique_ips_limit_enabled", default: false, null: false t.decimal "polling_interval_multiplier", default: 1.0, null: false - t.integer "cached_markdown_version" t.boolean "usage_ping_enabled", default: true, null: false t.string "uuid" + t.integer "cached_markdown_version" + t.boolean "clientside_sentry_enabled", default: false + t.string "clientside_sentry_dsn" end create_table "audit_events", force: :cascade do |t| @@ -732,8 +734,8 @@ t.integer "visibility_level", default: 20, null: false t.boolean "request_access_enabled", default: false, null: false t.datetime "deleted_at" - t.text "description_html" t.boolean "lfs_enabled" + t.text "description_html" t.integer "parent_id" t.boolean "require_two_factor_authentication", default: false, null: false t.integer "two_factor_grace_period", default: 48, null: false @@ -966,9 +968,9 @@ t.boolean "lfs_enabled" t.text "description_html" t.boolean "only_allow_merge_if_all_discussions_are_resolved" - t.integer "auto_cancel_pending_pipelines", default: 0, null: false t.boolean "printing_merge_request_link_enabled", default: true, null: false t.string "import_jid" + t.integer "auto_cancel_pending_pipelines", default: 0, null: false t.integer "cached_markdown_version" end @@ -1071,7 +1073,6 @@ t.string "line_code" t.string "note_type" t.text "position" - t.string "in_reply_to_discussion_id" end add_index "sent_notifications", ["reply_key"], name: "index_sent_notifications_on_reply_key", unique: true, using: :btree @@ -1321,10 +1322,10 @@ t.string "organization" t.boolean "authorized_projects_populated" t.boolean "ghost" - t.date "last_activity_on" t.boolean "notified_of_own_activity" t.boolean "require_two_factor_authentication_from_group", default: false, null: false t.integer "two_factor_grace_period", default: 48, null: false + t.date "last_activity_on" end add_index "users", ["admin"], name: "index_users_on_admin", using: :btree -- GitLab From f6ebb90a7bcc984a958f2e5cf01f8f1716c7f1de Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 28 Apr 2017 16:16:41 +0100 Subject: [PATCH 106/363] Deleted spinner and spec --- app/assets/javascripts/spinner.js | 29 --------- spec/javascripts/spinner_spec.js | 101 ------------------------------ 2 files changed, 130 deletions(-) delete mode 100644 app/assets/javascripts/spinner.js delete mode 100644 spec/javascripts/spinner_spec.js diff --git a/app/assets/javascripts/spinner.js b/app/assets/javascripts/spinner.js deleted file mode 100644 index 6b5ac89a57641..0000000000000 --- a/app/assets/javascripts/spinner.js +++ /dev/null @@ -1,29 +0,0 @@ -class Spinner { - constructor(renderable) { - this.renderable = renderable; - - this.container = Spinner.createContainer(); - } - - start() { - this.renderable.innerHTML = ''; - this.renderable.appendChild(this.container); - } - - stop() { - this.container.remove(); - } - - static createContainer() { - const container = document.createElement('div'); - container.classList.add('loading'); - - container.innerHTML = Spinner.TEMPLATE; - - return container; - } -} - -Spinner.TEMPLATE = '<i class="fa fa-spinner fa-spin"></i>'; - -export default Spinner; diff --git a/spec/javascripts/spinner_spec.js b/spec/javascripts/spinner_spec.js deleted file mode 100644 index f550285e0f76d..0000000000000 --- a/spec/javascripts/spinner_spec.js +++ /dev/null @@ -1,101 +0,0 @@ -import Spinner from '~/spinner'; -import ClassSpecHelper from './helpers/class_spec_helper'; - -describe('Spinner', () => { - let renderable; - let container; - let spinner; - - describe('class constructor', () => { - beforeEach(() => { - renderable = {}; - container = {}; - - spyOn(Spinner, 'createContainer').and.returnValue(container); - - spinner = new Spinner(renderable); - }); - - it('should set .renderable', () => { - expect(spinner.renderable).toBe(renderable); - }); - - it('should call Spinner.createContainer', () => { - expect(Spinner.createContainer).toHaveBeenCalled(); - }); - - it('should set .container', () => { - expect(spinner.container).toBe(container); - }); - }); - - describe('start', () => { - beforeEach(() => { - renderable = jasmine.createSpyObj('renderable', ['appendChild']); - container = {}; - - spinner = { - renderable, - container, - }; - - Spinner.prototype.start.call(spinner); - }); - - it('should set .innerHTML to an empty string', () => { - expect(renderable.innerHTML).toEqual(''); - }); - - it('should call .appendChild', () => { - expect(renderable.appendChild).toHaveBeenCalledWith(container); - }); - }); - - describe('stop', () => { - beforeEach(() => { - container = jasmine.createSpyObj('container', ['remove']); - - spinner = { - container, - }; - - Spinner.prototype.stop.call(spinner); - }); - - it('should call .remove', () => { - expect(container.remove).toHaveBeenCalled(); - }); - }); - - describe('createContainer', () => { - let createContainer; - - beforeEach(() => { - container = { - classList: jasmine.createSpyObj('classList', ['add']), - }; - - spyOn(document, 'createElement').and.returnValue(container); - - createContainer = Spinner.createContainer(); - }); - - ClassSpecHelper.itShouldBeAStaticMethod(Spinner, 'createContainer'); - - it('should call document.createElement', () => { - expect(document.createElement).toHaveBeenCalledWith('div'); - }); - - it('should call classList.add', () => { - expect(container.classList.add).toHaveBeenCalledWith('loading'); - }); - - it('should return the container element', () => { - expect(createContainer).toBe(container); - }); - - it('should set the container .innerHTML to Spinner.TEMPLATE', () => { - expect(container.innerHTML).toBe(Spinner.TEMPLATE); - }); - }); -}); -- GitLab From f13a6882d06d2a17c0f67b40cbf8f30edd289610 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 28 Apr 2017 16:18:08 +0100 Subject: [PATCH 107/363] Removed puts --- spec/features/raven_js_spec.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/spec/features/raven_js_spec.rb b/spec/features/raven_js_spec.rb index 0fe1c28bf8d3d..e8fa49c18cbd5 100644 --- a/spec/features/raven_js_spec.rb +++ b/spec/features/raven_js_spec.rb @@ -18,7 +18,6 @@ end def has_requested_raven - p page.driver.network_traffic page.driver.network_traffic.one? {|request| request.url.end_with?(raven_path)} end end -- GitLab From 1e2246e73ed45de4161c47a842dd68616373898a Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 28 Apr 2017 16:21:50 +0100 Subject: [PATCH 108/363] Removed Spinner usage --- app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js index 5bcd7d5eccc07..1778f9cba5171 100644 --- a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js +++ b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js @@ -2,13 +2,11 @@ import sqljs from 'sql.js'; import { template as _template } from 'underscore'; -import Spinner from '../../spinner'; class BalsamiqViewer { constructor(viewer) { this.viewer = viewer; this.endpoint = this.viewer.dataset.endpoint; - this.spinner = new Spinner(this.viewer); } loadFile() { -- GitLab From e11a702afceee7f5edeb8c93a4192fa05209b091 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Fri, 28 Apr 2017 17:06:19 +0100 Subject: [PATCH 109/363] Re-wrote to match our docs - still not 100% sure but closer than it was --- .../deploy_keys/components/action_btn.vue | 53 +++++++++ .../deploy_keys/components/app.vue | 105 ++++++++++++++++++ .../deploy_keys/components/key.vue | 85 ++++++++++++++ .../deploy_keys/components/keys.vue | 52 +++++++++ .../javascripts/deploy_keys/eventhub.js | 3 + app/assets/javascripts/deploy_keys/index.js | 21 ++++ .../javascripts/deploy_keys/service/index.js | 34 ++++++ .../javascripts/deploy_keys/store/index.js | 13 +++ app/assets/javascripts/dispatcher.js | 3 - .../settings/settings_repository.js | 96 ---------------- .../projects/deploy_keys_controller.rb | 11 +- app/serializers/project_entity.rb | 4 +- .../projects/deploy_keys/_index.html.haml | 14 +-- .../settings/repository/show.html.haml | 4 + config/webpack.config.js | 1 + 15 files changed, 384 insertions(+), 115 deletions(-) create mode 100644 app/assets/javascripts/deploy_keys/components/action_btn.vue create mode 100644 app/assets/javascripts/deploy_keys/components/app.vue create mode 100644 app/assets/javascripts/deploy_keys/components/key.vue create mode 100644 app/assets/javascripts/deploy_keys/components/keys.vue create mode 100644 app/assets/javascripts/deploy_keys/eventhub.js create mode 100644 app/assets/javascripts/deploy_keys/index.js create mode 100644 app/assets/javascripts/deploy_keys/service/index.js create mode 100644 app/assets/javascripts/deploy_keys/store/index.js delete mode 100644 app/assets/javascripts/settings/settings_repository.js diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue new file mode 100644 index 0000000000000..7da2915a45e38 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue @@ -0,0 +1,53 @@ +<script> + import eventHub from '../eventhub'; + + export default { + data() { + return { + isLoading: false, + }; + }, + props: { + deployKey: { + type: Object, + required: true, + }, + type: { + type: String, + required: true, + }, + btnCssClass: { + type: String, + required: false, + default: 'btn-default', + }, + }, + methods: { + doAction() { + this.isLoading = true; + + eventHub.$emit(`${this.type}.key`, this.deployKey); + }, + }, + computed: { + text() { + return `${this.type.charAt(0).toUpperCase()}${this.type.slice(1)}`; + }, + }, + }; +</script> + +<template> + <button + class="btn btn-sm prepend-left-10" + :class="[{ disabled: isLoading }, btnCssClass]" + :disabled="isLoading" + @click="doAction"> + {{ text }} + <i + v-if="isLoading" + class="fa fa-spinner fa-spin" + aria-hidden="true"> + </i> + </button> +</template> diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue new file mode 100644 index 0000000000000..ff54a0f241a2f --- /dev/null +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -0,0 +1,105 @@ +<script> + /* global Flash */ + import eventHub from '../eventhub'; + import DeployKeysService from '../service'; + import DeployKeysStore from '../store'; + import keysPanel from './keys.vue'; + + export default { + data() { + const store = new DeployKeysStore(); + + return { + isLoading: false, + store, + }; + }, + props: { + endpoint: { + type: String, + required: true, + }, + }, + computed: { + hasKeys() { + return Object.keys(this.keys).length; + }, + keys() { + return this.store.keys; + }, + }, + components: { + keysPanel, + }, + methods: { + fetchKeys() { + this.isLoading = true; + this.store.keys = {}; + + this.service.getKeys() + .then((data) => { + this.isLoading = false; + this.store.keys = data; + }) + .catch(() => new Flash('Error getting deploy keys')); + }, + enableKey(deployKey) { + this.service.enableKey(deployKey.id) + .then(() => this.fetchKeys()) + .catch(() => new Flash('Error enabling deploy key')); + }, + removeKey(deployKey) { + this.disableKey(deployKey); + }, + disableKey(deployKey) { + // eslint-disable-next-line no-alert + if (confirm('You are going to remove this deploy key. Are you sure?')) { + this.service.disableKey(deployKey.id) + .then(() => this.fetchKeys()) + .catch(() => new Flash('Error removing deploy key')); + } + }, + }, + created() { + this.service = new DeployKeysService(this.endpoint); + + eventHub.$on('enable.key', this.enableKey); + eventHub.$on('remove.key', this.removeKey); + eventHub.$on('disable.key', this.disableKey); + }, + mounted() { + this.fetchKeys(); + }, + beforeDestroy() { + eventHub.$off('enable.key', this.enableKey); + eventHub.$off('remove.key', this.removeKey); + eventHub.$off('disable.key', this.disableKey); + }, + }; +</script> + +<template> + <div class="col-lg-9 col-lg-offset-3 append-bottom-default"> + <div + class="text-center" + v-if="isLoading && !hasKeys"> + <i + class="fa fa-spinner fa-spin fa-2x"> + </i> + </div> + <div v-else-if="hasKeys"> + <keys-panel + title="Enabled deploy keys for this project" + :keys="keys.enabled_keys" + :store="store" /> + <keys-panel + title="Deploy keys from projects you have access to" + :keys="keys.available_project_keys" + :store="store" /> + <keys-panel + title="Public deploy keys available to any project" + :keys="keys.public_keys" + :store="store" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue new file mode 100644 index 0000000000000..af842a3bb391c --- /dev/null +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -0,0 +1,85 @@ +<script> + import actionBtn from './action_btn.vue'; + + export default { + props: { + deployKey: { + type: Object, + required: true, + }, + enabled: { + type: Boolean, + required: false, + default: true, + }, + store: { + type: Object, + required: true, + }, + }, + components: { + actionBtn, + }, + computed: { + timeagoDate() { + return gl.utils.getTimeago().format(this.deployKey.created_at); + }, + }, + methods: { + isEnabled(id) { + return this.store.findEnabledKey(id) !== undefined; + }, + }, + }; +</script> + +<template> + <div> + <div class="pull-left append-right-10 hidden-xs"> + <i + aria-hidden="true" + class="fa fa-key key-icon"> + </i> + </div> + <div class="deploy-key-content key-list-item-info"> + <strong class="title"> + {{ deployKey.title }} + </strong> + <div class="description"> + {{ deployKey.fingerprint }} + </div> + <div + v-if="deployKey.can_push" + class="write-access-allowed"> + Write access allowed + </div> + </div> + <div class="deploy-key-content prepend-left-default deploy-key-projects"> + <a + class="label deploy-project-label" + :href="project.full_path" + v-for="project in deployKey.projects"> + {{ project.full_name }} + </a> + </div> + <div class="deploy-key-content"> + <span class="key-created-at"> + created {{ timeagoDate }} + </span> + <action-btn + v-if="!isEnabled(deployKey.id)" + :deploy-key="deployKey" + type="enable"/> + <action-btn + v-else-if="deployKey.destroyed_when_orphaned && deployKey.almost_orphaned" + :deploy-key="deployKey" + btn-css-class="btn-warning" + type="remove" /> + <action-btn + v-else + :deploy-key="deployKey" + btn-css-class="btn-warning" + type="disable" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/deploy_keys/components/keys.vue b/app/assets/javascripts/deploy_keys/components/keys.vue new file mode 100644 index 0000000000000..2470831eddfef --- /dev/null +++ b/app/assets/javascripts/deploy_keys/components/keys.vue @@ -0,0 +1,52 @@ +<script> + import key from './key.vue'; + + export default { + props: { + title: { + type: String, + required: true, + }, + keys: { + type: Array, + required: true, + }, + showHelpBox: { + type: Boolean, + required: false, + default: true, + }, + store: { + type: Object, + required: true, + }, + }, + components: { + key, + }, + }; +</script> + +<template> + <div> + <h5> + {{ title }} + ({{ keys.length }}) + </h5> + <ul class="well-list" + v-if="keys.length"> + <li + v-for="deployKey in keys" + key="deployKey.id"> + <key + :deploy-key="deployKey" + :store="store" /> + </li> + </ul> + <div + class="settings-message text-center" + v-else-if="showHelpBox"> + No deploy keys found. Create one with the form above. + </div> + </div> +</template> diff --git a/app/assets/javascripts/deploy_keys/eventhub.js b/app/assets/javascripts/deploy_keys/eventhub.js new file mode 100644 index 0000000000000..0948c2e53524a --- /dev/null +++ b/app/assets/javascripts/deploy_keys/eventhub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/deploy_keys/index.js b/app/assets/javascripts/deploy_keys/index.js new file mode 100644 index 0000000000000..a5f232f950a61 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/index.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import deployKeysApp from './components/app.vue'; + +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: document.getElementById('js-deploy-keys'), + data() { + return { + endpoint: this.$options.el.dataset.endpoint, + }; + }, + components: { + deployKeysApp, + }, + render(createElement) { + return createElement('deploy-keys-app', { + props: { + endpoint: this.endpoint, + }, + }); + }, +})); diff --git a/app/assets/javascripts/deploy_keys/service/index.js b/app/assets/javascripts/deploy_keys/service/index.js new file mode 100644 index 0000000000000..fe6dbaa949809 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/service/index.js @@ -0,0 +1,34 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class DeployKeysService { + constructor(endpoint) { + this.endpoint = endpoint; + + this.resource = Vue.resource(`${this.endpoint}{/id}`, {}, { + enable: { + method: 'PUT', + url: `${this.endpoint}{/id}/enable`, + }, + disable: { + method: 'PUT', + url: `${this.endpoint}{/id}/disable`, + }, + }); + } + + getKeys() { + return this.resource.get() + .then(response => response.json()); + } + + enableKey(id) { + return this.resource.enable({ id }, {}); + } + + disableKey(id) { + return this.resource.disable({ id }, {}); + } +} diff --git a/app/assets/javascripts/deploy_keys/store/index.js b/app/assets/javascripts/deploy_keys/store/index.js new file mode 100644 index 0000000000000..7177d9bed7fbc --- /dev/null +++ b/app/assets/javascripts/deploy_keys/store/index.js @@ -0,0 +1,13 @@ +export default class DeployKeysStore { + constructor() { + this.keys = {}; + } + + findEnabledKey(id) { + return this.keys.enabled_keys.find(key => key.id === id); + } + + removeKeyForType(deployKey, type) { + this.keys[type] = this.keys[type].filter(key => key.id !== deployKey.id); + } +} diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index f7402792c59eb..d3d75c4bf4a15 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -48,7 +48,6 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; import UserCallout from './user_callout'; import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags'; import ShortcutsWiki from './shortcuts_wiki'; -import SettingsDeployKeys from './settings/settings_repository'; import BlobViewer from './blob/viewer/index'; const ShortcutsBlob = require('./shortcuts_blob'); @@ -346,8 +345,6 @@ const ShortcutsBlob = require('./shortcuts_blob'); // Initialize Protected Tag Settings new ProtectedTagCreate(); new ProtectedTagEditList(); - // Initialize deploy key ajax call - new SettingsDeployKeys(); break; case 'projects:ci_cd:show': new gl.ProjectVariables(); diff --git a/app/assets/javascripts/settings/settings_repository.js b/app/assets/javascripts/settings/settings_repository.js deleted file mode 100644 index 9d6a60baa3ad4..0000000000000 --- a/app/assets/javascripts/settings/settings_repository.js +++ /dev/null @@ -1,96 +0,0 @@ -/* eslint-disable no-new */ -import Vue from 'vue'; -import VueResource from 'vue-resource'; - -Vue.use(VueResource); - -export default class SettingsDeployKeys { - constructor() { - this.initVue(); - } - - deployKeyRowTemplate() { - return ` - <li> - <div class="pull-left append-right-10 hidden-xs"> - <i aria-hidden="true" class="fa fa-key key-icon"></i> - </div> - <div class="deploy-key-content key-list-item-info"> - <strong class="title"> - {{deployKey.title}} - </strong> - <div class="description"> - {{deployKey.fingerprint}} - </div> - </div> - <div class="deploy-key-content prepend-left-default deploy-key-projects"> - <a class="label deploy-project-label" :href="project.full_path" v-for="project in deployKey.projects">{{project.full_name}}</a> - </div> - <div class="deploy-key-content"> - <span class="key-created-at"> - created {{timeagoDate}} - </span> - <div class="visible-xs-block visible-sm-block"></div> - <a v-if="!enabled" class="btn btn-sm prepend-left-10" rel="nofollow" data-method="put" href="enableURL">Enable -</a> - <a v-else-if="deployKey.destroyed_when_orphaned && deployKey.almost_orphaned" class="btn btn-warning btn-sm prepend-left-10" rel="nofollow" data-method="put" :href="disableURL">Remove</a> - <a v-else class="btn btn-warning btn-sm prepend-left-10" rel="nofollow" data-method="put" :href="disableURL">Disable</a> - </div> - </li>` - } - - deployKeyRowComponent() { - const self = this; - return { - props: { - deployKey: Object, - enabled: Boolean - }, - - computed: { - timeagoDate() { - return gl.utils.getTimeago().format(this.deployKey.createdAt, 'gl_en'); - }, - - disableURL() { - return self.disableEndpoint.replace(':id', this.deployKey.id); - }, - - enableURL() { - return self.enableEndpoint.replace(':id', this.deployKey.id); - } - }, - - template: this.deployKeyRowTemplate() - } - } - - initVue() { - const self = this; - const el = document.getElementById('js-deploy-keys'); - const endpoint = el.dataset.endpoint; - this.jsonEndpoint = `${endpoint}.json`; - this.disableEndpoint = `${endpoint}/:id/disable`; - this.enableEndpoint = `${endpoint}/:id/enable`; - new Vue({ - el: el, - components: { - deployKeyRow: self.deployKeyRowComponent() - }, - data () { - return { - enabledKeys: [], - availableKeys: [] - } - }, - created () { - this.$http.get(self.jsonEndpoint) - .then((res) => { - const keys = JSON.parse(res.body); - this.enabledKeys = keys.enabled_keys; - this.availableKeys = keys.available_project_keys; - }); - } - }) - } -} \ No newline at end of file diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb index a47e15a192bff..f27089b8590d0 100644 --- a/app/controllers/projects/deploy_keys_controller.rb +++ b/app/controllers/projects/deploy_keys_controller.rb @@ -32,7 +32,10 @@ def create def enable Projects::EnableDeployKeyService.new(@project, current_user, params).execute - redirect_to_repository_settings(@project) + respond_to do |format| + format.html { redirect_to_repository_settings(@project) } + format.json { head :ok } + end end def disable @@ -40,7 +43,11 @@ def disable return render_404 unless deploy_key_project deploy_key_project.destroy! - redirect_to_repository_settings(@project) + + respond_to do |format| + format.html { redirect_to_repository_settings(@project) } + format.json { head :ok } + end end protected diff --git a/app/serializers/project_entity.rb b/app/serializers/project_entity.rb index 6f8061f753095..a471a7e6a8825 100644 --- a/app/serializers/project_entity.rb +++ b/app/serializers/project_entity.rb @@ -1,9 +1,11 @@ class ProjectEntity < Grape::Entity + include RequestAwareEntity + expose :id expose :name expose :full_path do |project| - project.full_path + namespace_project_path(project.namespace, project) end expose :full_name do |project| diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml index b35cd356aa507..74756b58439a0 100644 --- a/app/views/projects/deploy_keys/_index.html.haml +++ b/app/views/projects/deploy_keys/_index.html.haml @@ -10,16 +10,4 @@ = render @deploy_keys.form_partial_path .col-lg-9.col-lg-offset-3 %hr - #js-deploy-keys.col-lg-9.col-lg-offset-3.append-bottom-default{data:{endpoint: namespace_project_deploy_keys_path}} - %h5.prepend-top-0 - Enabled deploy keys for this project ({{enabledKeys.length}}) - %ul.well-list{"v-if" => "enabledKeys.length"} - %deploy-key-row{"v-for" => "deployKey in enabledKeys", ":deploy-key" => "deployKey", ":enabled" =>"true"} - .settings-message.text-center{"v-else" => true} - No deploy keys found. Create one with the form above. - %h5.prepend-top-0 - Deploy keys from projects you have access to ({{availableKeys.length}}) - %ul.well-list{"v-if" => "availableKeys.length"} - %deploy-key-row{"v-for" => "deployKey in availableKeys", ":deploy-key" => "deployKey", ":enabled" =>"false"} - .settings-message.text-center{"v-else" => true} - No deploy keys found. Create one with the form above. + #js-deploy-keys{ data: { endpoint: namespace_project_deploy_keys_path } } diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index 5402320cb66d0..4e59033c4a34e 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -1,6 +1,10 @@ - page_title "Repository" = render "projects/settings/head" +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('deploy_keys') + = render @deploy_keys = render "projects/protected_branches/index" = render "projects/protected_tags/index" diff --git a/config/webpack.config.js b/config/webpack.config.js index cb0a57a3a410f..1721d275685fb 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -26,6 +26,7 @@ var config = { common_d3: ['d3'], cycle_analytics: './cycle_analytics/cycle_analytics_bundle.js', commit_pipelines: './commit/pipelines/pipelines_bundle.js', + deploy_keys: './deploy_keys/index.js', diff_notes: './diff_notes/diff_notes_bundle.js', environments: './environments/environments_bundle.js', environments_folder: './environments/folder/environments_folder_bundle.js', -- GitLab From fde9732a27065467f4fab0ba0a6bd1cfed9b092b Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Fri, 28 Apr 2017 17:11:46 +0100 Subject: [PATCH 110/363] Fixed public keys always rendering even when empty --- app/assets/javascripts/deploy_keys/components/app.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index ff54a0f241a2f..e0443e2e0df63 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -97,6 +97,7 @@ :keys="keys.available_project_keys" :store="store" /> <keys-panel + v-if="keys.public_keys.length" title="Public deploy keys available to any project" :keys="keys.public_keys" :store="store" /> -- GitLab From 0b7824d433ec5029da17114389f425f816b7be74 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Fri, 28 Apr 2017 17:22:57 +0100 Subject: [PATCH 111/363] Updated some Vue specific JS --- .../javascripts/deploy_keys/components/action_btn.vue | 3 ++- app/assets/javascripts/deploy_keys/components/app.vue | 10 +++++----- app/assets/javascripts/deploy_keys/components/key.vue | 4 ++-- .../components/{keys.vue => keys_panel.vue} | 0 app/assets/javascripts/deploy_keys/store/index.js | 4 ---- changelogs/unreleased/29667-deploy-keys.yml | 4 ---- changelogs/unreleased/deploy-keys-load-async.yml | 4 ++++ 7 files changed, 13 insertions(+), 16 deletions(-) rename app/assets/javascripts/deploy_keys/components/{keys.vue => keys_panel.vue} (100%) delete mode 100644 changelogs/unreleased/29667-deploy-keys.yml create mode 100644 changelogs/unreleased/deploy-keys-load-async.yml diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue index 7da2915a45e38..3ff3a9d977e77 100644 --- a/app/assets/javascripts/deploy_keys/components/action_btn.vue +++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue @@ -47,7 +47,8 @@ <i v-if="isLoading" class="fa fa-spinner fa-spin" - aria-hidden="true"> + aria-hidden="true" + aria-label="Loading"> </i> </button> </template> diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index e0443e2e0df63..ee2f85bde5388 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -3,15 +3,13 @@ import eventHub from '../eventhub'; import DeployKeysService from '../service'; import DeployKeysStore from '../store'; - import keysPanel from './keys.vue'; + import keysPanel from './keys_panel.vue'; export default { data() { - const store = new DeployKeysStore(); - return { isLoading: false, - store, + store: new DeployKeysStore(), }; }, props: { @@ -84,7 +82,9 @@ class="text-center" v-if="isLoading && !hasKeys"> <i - class="fa fa-spinner fa-spin fa-2x"> + class="fa fa-spinner fa-spin fa-2x" + aria-hidden="true" + aria-label="Loading deploy keys"> </i> </div> <div v-else-if="hasKeys"> diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue index af842a3bb391c..1b47cceba78a1 100644 --- a/app/assets/javascripts/deploy_keys/components/key.vue +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -56,9 +56,9 @@ </div> <div class="deploy-key-content prepend-left-default deploy-key-projects"> <a + v-for="project in deployKey.projects" class="label deploy-project-label" - :href="project.full_path" - v-for="project in deployKey.projects"> + :href="project.full_path"> {{ project.full_name }} </a> </div> diff --git a/app/assets/javascripts/deploy_keys/components/keys.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue similarity index 100% rename from app/assets/javascripts/deploy_keys/components/keys.vue rename to app/assets/javascripts/deploy_keys/components/keys_panel.vue diff --git a/app/assets/javascripts/deploy_keys/store/index.js b/app/assets/javascripts/deploy_keys/store/index.js index 7177d9bed7fbc..6210361af26e8 100644 --- a/app/assets/javascripts/deploy_keys/store/index.js +++ b/app/assets/javascripts/deploy_keys/store/index.js @@ -6,8 +6,4 @@ export default class DeployKeysStore { findEnabledKey(id) { return this.keys.enabled_keys.find(key => key.id === id); } - - removeKeyForType(deployKey, type) { - this.keys[type] = this.keys[type].filter(key => key.id !== deployKey.id); - } } diff --git a/changelogs/unreleased/29667-deploy-keys.yml b/changelogs/unreleased/29667-deploy-keys.yml deleted file mode 100644 index 0f202ebf1ee56..0000000000000 --- a/changelogs/unreleased/29667-deploy-keys.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Project deploy keys json end point -merge_request: -author: diff --git a/changelogs/unreleased/deploy-keys-load-async.yml b/changelogs/unreleased/deploy-keys-load-async.yml new file mode 100644 index 0000000000000..e90910278e807 --- /dev/null +++ b/changelogs/unreleased/deploy-keys-load-async.yml @@ -0,0 +1,4 @@ +--- +title: Deploy keys load are loaded async +merge_request: +author: -- GitLab From 2ef7c6f070e52f68b10cba09d10a4db9ab8b2537 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 28 Apr 2017 17:32:50 +0100 Subject: [PATCH 112/363] Fixed specs --- .../blob/balsamiq/balsamiq_viewer.js | 58 ++++++---- .../projects/blob/viewers/_balsamiq.html.haml | 2 +- .../blob/balsamiq/balsamiq_viewer_spec.js | 105 ++++-------------- 3 files changed, 56 insertions(+), 109 deletions(-) diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js index 1778f9cba5171..cdbfe36ca1cc3 100644 --- a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js +++ b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js @@ -3,6 +3,15 @@ import sqljs from 'sql.js'; import { template as _template } from 'underscore'; +const PREVIEW_TEMPLATE = _template(` + <div class="panel panel-default"> + <div class="panel-heading"><%- name %></div> + <div class="panel-body"> + <img class="img-thumbnail" src="data:image/png;base64,<%- image %>"/> + </div> + </div> +`); + class BalsamiqViewer { constructor(viewer) { this.viewer = viewer; @@ -18,23 +27,23 @@ class BalsamiqViewer { xhr.onload = this.renderFile.bind(this); xhr.onerror = BalsamiqViewer.onError; - this.spinner.start(); - xhr.send(); } renderFile(loadEvent) { - this.spinner.stop(); - const container = document.createElement('ul'); this.initDatabase(loadEvent.target.response); const previews = this.getPreviews(); - const renderedPreviews = previews.map(preview => this.renderPreview(preview)); + previews.forEach((preview) => { + const renderedPreview = this.renderPreview(preview); - container.innerHTML = renderedPreviews.join(''); - container.classList.add('list-inline', 'previews'); + container.appendChild(renderedPreview); + }); + + container.classList.add('list-inline'); + container.classList.add('previews'); this.viewer.appendChild(container); } @@ -51,8 +60,10 @@ class BalsamiqViewer { return thumbnails[0].values.map(BalsamiqViewer.parsePreview); } - getTitle(resourceID) { - return this.database.exec(`SELECT * FROM resources WHERE id = '${resourceID}'`); + getResource(resourceID) { + const resources = this.database.exec(`SELECT * FROM resources WHERE id = '${resourceID}'`); + + return resources[0]; } renderPreview(preview) { @@ -61,15 +72,15 @@ class BalsamiqViewer { previewElement.classList.add('preview'); previewElement.innerHTML = this.renderTemplate(preview); - return previewElement.outerHTML; + return previewElement; } renderTemplate(preview) { - const title = this.getTitle(preview.resourceID); - const name = BalsamiqViewer.parseTitle(title); + const resource = this.getResource(preview.resourceID); + const name = BalsamiqViewer.parseTitle(resource); const image = preview.image; - const template = BalsamiqViewer.PREVIEW_TEMPLATE({ + const template = PREVIEW_TEMPLATE({ name, image, }); @@ -81,8 +92,16 @@ class BalsamiqViewer { return JSON.parse(preview[1]); } - static parseTitle(title) { - return JSON.parse(title[0].values[0][2]).name; + /* + * resource = { + * columns: ['ID', 'BRANCHID', 'ATTRIBUTES', 'DATA'], + * values: [['id', 'branchId', 'attributes', 'data']], + * } + * + * 'attributes' being a JSON string containing the `name` property. + */ + static parseTitle(resource) { + return JSON.parse(resource.values[0][2]).name; } static onError() { @@ -92,13 +111,4 @@ class BalsamiqViewer { } } -BalsamiqViewer.PREVIEW_TEMPLATE = _template(` - <div class="panel panel-default"> - <div class="panel-heading"><%- name %></div> - <div class="panel-body"> - <img class="img-thumbnail" src="data:image/png;base64,<%- image %>"/> - </div> - </div> -`); - export default BalsamiqViewer; diff --git a/app/views/projects/blob/viewers/_balsamiq.html.haml b/app/views/projects/blob/viewers/_balsamiq.html.haml index 573b24ae44f7f..28670e7de9755 100644 --- a/app/views/projects/blob/viewers/_balsamiq.html.haml +++ b/app/views/projects/blob/viewers/_balsamiq.html.haml @@ -1,4 +1,4 @@ - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('balsamiq_viewer') -.file-content.balsamiq-viewer#js-balsamiq-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } } +.file-content.balsamiq-viewer#js-balsamiq-viewer{ data: { endpoint: blob_raw_url } } diff --git a/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js b/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js index 557eb721a2b8c..85816ee1f1161 100644 --- a/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js +++ b/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js @@ -1,6 +1,5 @@ import sqljs from 'sql.js'; import BalsamiqViewer from '~/blob/balsamiq/balsamiq_viewer'; -import * as spinnerSrc from '~/spinner'; import ClassSpecHelper from '../../helpers/class_spec_helper'; describe('BalsamiqViewer', () => { @@ -17,8 +16,6 @@ describe('BalsamiqViewer', () => { }, }; - spyOn(spinnerSrc, 'default'); - balsamiqViewer = new BalsamiqViewer(viewer); }); @@ -29,38 +26,23 @@ describe('BalsamiqViewer', () => { it('should set .endpoint', () => { expect(balsamiqViewer.endpoint).toBe(endpoint); }); - - it('should instantiate Spinner', () => { - expect(spinnerSrc.default).toHaveBeenCalledWith(viewer); - }); - - it('should set .spinner', () => { - expect(balsamiqViewer.spinner).toEqual(jasmine.any(spinnerSrc.default)); - }); }); describe('loadFile', () => { let xhr; - let spinner; beforeEach(() => { endpoint = 'endpoint'; xhr = jasmine.createSpyObj('xhr', ['open', 'send']); - spinner = jasmine.createSpyObj('spinner', ['start']); balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['renderFile']); balsamiqViewer.endpoint = endpoint; - balsamiqViewer.spinner = spinner; spyOn(window, 'XMLHttpRequest').and.returnValue(xhr); BalsamiqViewer.prototype.loadFile.call(balsamiqViewer); }); - it('should instantiate XMLHttpRequest', () => { - expect(window.XMLHttpRequest).toHaveBeenCalled(); - }); - it('should call .open', () => { expect(xhr.open).toHaveBeenCalledWith('GET', endpoint, true); }); @@ -69,25 +51,12 @@ describe('BalsamiqViewer', () => { expect(xhr.responseType).toBe('arraybuffer'); }); - it('should set .onload', () => { - expect(xhr.onload).toEqual(jasmine.any(Function)); - }); - - it('should set .onerror', () => { - expect(xhr.onerror).toBe(BalsamiqViewer.onError); - }); - - it('should call spinner.start', () => { - expect(spinner.start).toHaveBeenCalled(); - }); - it('should call .send', () => { expect(xhr.send).toHaveBeenCalled(); }); }); describe('renderFile', () => { - let spinner; let container; let loadEvent; let previews; @@ -95,12 +64,10 @@ describe('BalsamiqViewer', () => { beforeEach(() => { loadEvent = { target: { response: {} } }; viewer = jasmine.createSpyObj('viewer', ['appendChild']); - spinner = jasmine.createSpyObj('spinner', ['stop']); - previews = [0, 1, 2]; + previews = [document.createElement('ul'), document.createElement('ul')]; balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['initDatabase', 'getPreviews', 'renderPreview']); balsamiqViewer.viewer = viewer; - balsamiqViewer.spinner = spinner; balsamiqViewer.getPreviews.and.returnValue(previews); balsamiqViewer.renderPreview.and.callFake(preview => preview); @@ -111,10 +78,6 @@ describe('BalsamiqViewer', () => { BalsamiqViewer.prototype.renderFile.call(balsamiqViewer, loadEvent); }); - it('should call spinner.stop', () => { - expect(spinner.stop).toHaveBeenCalled(); - }); - it('should call .initDatabase', () => { expect(balsamiqViewer.initDatabase).toHaveBeenCalledWith(loadEvent.target.response); }); @@ -126,15 +89,15 @@ describe('BalsamiqViewer', () => { it('should call .renderPreview for each preview', () => { const allArgs = balsamiqViewer.renderPreview.calls.allArgs(); - expect(allArgs.length).toBe(3); + expect(allArgs.length).toBe(2); previews.forEach((preview, i) => { expect(allArgs[i][0]).toBe(preview); }); }); - it('should set .innerHTML', () => { - expect(container.innerHTML).toBe('012'); + it('should set the container HTML', () => { + expect(container.innerHTML).toBe('<ul></ul><ul></ul>'); }); it('should add inline preview classes', () => { @@ -216,16 +179,16 @@ describe('BalsamiqViewer', () => { }); }); - describe('getTitle', () => { + describe('getResource', () => { let database; let resourceID; let resource; - let getTitle; + let getResource; beforeEach(() => { database = jasmine.createSpyObj('database', ['exec']); resourceID = 4; - resource = 'resource'; + resource = ['resource']; balsamiqViewer = { database, @@ -233,7 +196,7 @@ describe('BalsamiqViewer', () => { database.exec.and.returnValue(resource); - getTitle = BalsamiqViewer.prototype.getTitle.call(balsamiqViewer, resourceID); + getResource = BalsamiqViewer.prototype.getResource.call(balsamiqViewer, resourceID); }); it('should call database.exec', () => { @@ -241,7 +204,7 @@ describe('BalsamiqViewer', () => { }); it('should return the selected resource', () => { - expect(getTitle).toBe(resource); + expect(getResource).toBe(resource[0]); }); }); @@ -267,10 +230,6 @@ describe('BalsamiqViewer', () => { renderPreview = BalsamiqViewer.prototype.renderPreview.call(balsamiqViewer, preview); }); - it('should call document.createElement', () => { - expect(document.createElement).toHaveBeenCalledWith('li'); - }); - it('should call classList.add', () => { expect(previewElement.classList.add).toHaveBeenCalledWith('preview'); }); @@ -283,22 +242,22 @@ describe('BalsamiqViewer', () => { expect(previewElement.innerHTML).toBe(innerHTML); }); - it('should return .outerHTML', () => { - expect(renderPreview).toBe(previewElement.outerHTML); + it('should return element', () => { + expect(renderPreview).toBe(previewElement); }); }); describe('renderTemplate', () => { let preview; let name; - let title; + let resource; let template; let renderTemplate; beforeEach(() => { preview = { resourceID: 1, image: 'image' }; name = 'name'; - title = 'title'; + resource = 'resource'; template = ` <div class="panel panel-default"> <div class="panel-heading">name</div> @@ -308,32 +267,24 @@ describe('BalsamiqViewer', () => { </div> `; - balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['getTitle']); + balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['getResource']); spyOn(BalsamiqViewer, 'parseTitle').and.returnValue(name); - spyOn(BalsamiqViewer, 'PREVIEW_TEMPLATE').and.returnValue(template); - balsamiqViewer.getTitle.and.returnValue(title); + balsamiqViewer.getResource.and.returnValue(resource); renderTemplate = BalsamiqViewer.prototype.renderTemplate.call(balsamiqViewer, preview); }); - it('should call .getTitle', () => { - expect(balsamiqViewer.getTitle).toHaveBeenCalledWith(preview.resourceID); + it('should call .getResource', () => { + expect(balsamiqViewer.getResource).toHaveBeenCalledWith(preview.resourceID); }); it('should call .parseTitle', () => { - expect(BalsamiqViewer.parseTitle).toHaveBeenCalledWith(title); - }); - - it('should call .PREVIEW_TEMPLATE', () => { - expect(BalsamiqViewer.PREVIEW_TEMPLATE).toHaveBeenCalledWith({ - name, - image: preview.image, - }); + expect(BalsamiqViewer.parseTitle).toHaveBeenCalledWith(resource); }); it('should return the template string', function () { - expect(renderTemplate.trim()).toBe(template.trim()); + expect(renderTemplate.replace(/\s/g, '')).toEqual(template.replace(/\s/g, '')); }); }); @@ -351,10 +302,6 @@ describe('BalsamiqViewer', () => { ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview'); - it('should call JSON.parse', () => { - expect(JSON.parse).toHaveBeenCalledWith(preview[1]); - }); - it('should return the parsed JSON', () => { expect(parsePreview).toEqual(JSON.parse('{ "id": 1 }')); }); @@ -365,7 +312,7 @@ describe('BalsamiqViewer', () => { let parseTitle; beforeEach(() => { - title = [{ values: [['{}', '{}', '{"name":"name"}']] }]; + title = { values: [['{}', '{}', '{"name":"name"}']] }; spyOn(JSON, 'parse').and.callThrough(); @@ -374,22 +321,16 @@ describe('BalsamiqViewer', () => { ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview'); - it('should call JSON.parse', () => { - expect(JSON.parse).toHaveBeenCalledWith(title[0].values[0][2]); - }); - it('should return the name value', () => { expect(parseTitle).toBe('name'); }); }); describe('onError', () => { - let onError; - beforeEach(() => { spyOn(window, 'Flash'); - onError = BalsamiqViewer.onError(); + BalsamiqViewer.onError(); }); ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'onError'); @@ -397,9 +338,5 @@ describe('BalsamiqViewer', () => { it('should instantiate Flash', () => { expect(window.Flash).toHaveBeenCalledWith('Balsamiq file could not be loaded.'); }); - - it('should return Flash', () => { - expect(onError).toEqual(jasmine.any(window.Flash)); - }); }); }); -- GitLab From 6b6296e1cde034ed6a335890a44a2f7bc87d782d Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 28 Apr 2017 17:50:41 +0100 Subject: [PATCH 113/363] Reverted false schema diffs --- db/schema.rb | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index a9940be22ffe8..06f1b079dbfc6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -63,6 +63,7 @@ t.boolean "shared_runners_enabled", default: true, null: false t.integer "max_artifacts_size", default: 100, null: false t.string "runners_registration_token" + t.integer "max_pages_size", default: 100, null: false t.boolean "require_two_factor_authentication", default: false t.integer "two_factor_grace_period", default: 48 t.boolean "metrics_enabled", default: false @@ -111,16 +112,15 @@ t.boolean "html_emails_enabled", default: true t.string "plantuml_url" t.boolean "plantuml_enabled" - t.integer "max_pages_size", default: 100, null: false t.integer "terminal_max_session_time", default: 0, null: false t.string "default_artifacts_expire_in", default: "0", null: false t.integer "unique_ips_limit_per_user" t.integer "unique_ips_limit_time_window" t.boolean "unique_ips_limit_enabled", default: false, null: false t.decimal "polling_interval_multiplier", default: 1.0, null: false + t.integer "cached_markdown_version" t.boolean "usage_ping_enabled", default: true, null: false t.string "uuid" - t.integer "cached_markdown_version" t.boolean "clientside_sentry_enabled", default: false t.string "clientside_sentry_dsn" end @@ -734,8 +734,8 @@ t.integer "visibility_level", default: 20, null: false t.boolean "request_access_enabled", default: false, null: false t.datetime "deleted_at" - t.boolean "lfs_enabled" t.text "description_html" + t.boolean "lfs_enabled" t.integer "parent_id" t.boolean "require_two_factor_authentication", default: false, null: false t.integer "two_factor_grace_period", default: 48, null: false @@ -968,9 +968,9 @@ t.boolean "lfs_enabled" t.text "description_html" t.boolean "only_allow_merge_if_all_discussions_are_resolved" + t.integer "auto_cancel_pending_pipelines", default: 0, null: false t.boolean "printing_merge_request_link_enabled", default: true, null: false t.string "import_jid" - t.integer "auto_cancel_pending_pipelines", default: 0, null: false t.integer "cached_markdown_version" end @@ -1073,6 +1073,7 @@ t.string "line_code" t.string "note_type" t.text "position" + t.string "in_reply_to_discussion_id" end add_index "sent_notifications", ["reply_key"], name: "index_sent_notifications_on_reply_key", unique: true, using: :btree @@ -1322,10 +1323,10 @@ t.string "organization" t.boolean "authorized_projects_populated" t.boolean "ghost" + t.date "last_activity_on" t.boolean "notified_of_own_activity" t.boolean "require_two_factor_authentication_from_group", default: false, null: false t.integer "two_factor_grace_period", default: 48, null: false - t.date "last_activity_on" end add_index "users", ["admin"], name: "index_users_on_admin", using: :btree -- GitLab From ca17e258a2233e18912529f33646db27edc58e25 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 28 Apr 2017 17:52:18 +0100 Subject: [PATCH 114/363] Removed debug message --- app/views/layouts/_head.html.haml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 6123440b6e58f..fa4d760a20eb1 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -35,7 +35,6 @@ = webpack_bundle_tag "main" = webpack_bundle_tag "raven" if current_application_settings.clientside_sentry_enabled - = 'LUKETEST' = current_application_settings.clientside_sentry_enabled - if content_for?(:page_specific_javascripts) -- GitLab From 637ed8a21e9f9457d1b194f9c591a0813c20cc3e Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 28 Apr 2017 18:09:55 +0100 Subject: [PATCH 115/363] Added migration downtime tag --- ...0428123910_add_clientside_sentry_to_application_settings.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/db/migrate/20170428123910_add_clientside_sentry_to_application_settings.rb b/db/migrate/20170428123910_add_clientside_sentry_to_application_settings.rb index 2b085b889eb87..380060b18b391 100644 --- a/db/migrate/20170428123910_add_clientside_sentry_to_application_settings.rb +++ b/db/migrate/20170428123910_add_clientside_sentry_to_application_settings.rb @@ -1,5 +1,8 @@ # rubocop:disable all class AddClientsideSentryToApplicationSettings < ActiveRecord::Migration + DOWNTIME = true + DOWNTIME_REASON = 'This migration requires downtime because we must add 2 new columns, 1 of which has a default value.' + def change change_table :application_settings do |t| t.boolean :clientside_sentry_enabled, default: false -- GitLab From adff7923ca3088df9e07ccd60d6f9fa24fbdbfc5 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 28 Apr 2017 19:08:39 +0100 Subject: [PATCH 116/363] Removed unneeded spec --- .../projects/blobs/balsamiq_preview_spec.rb | 24 ------------------- 1 file changed, 24 deletions(-) delete mode 100644 spec/features/projects/blobs/balsamiq_preview_spec.rb diff --git a/spec/features/projects/blobs/balsamiq_preview_spec.rb b/spec/features/projects/blobs/balsamiq_preview_spec.rb deleted file mode 100644 index 59475525b813f..0000000000000 --- a/spec/features/projects/blobs/balsamiq_preview_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -require 'spec_helper' - -feature 'Balsamiq preview', :feature, :js do - include TreeHelper - - let(:user) { create(:user) } - let(:project) { create(:project) } - let(:branch) { 'add-balsamiq-file' } - let(:path) { 'files/images/balsamiq.bmpr' } - - before do - project.add_master(user) - login_as user - visit namespace_project_blob_path(project.namespace, project, tree_join(branch, path)) - end - - it 'should show a loading icon' do - expect(find('.file-content')).to have_selector('.loading') - end - - it 'should show a viewer container' do - expect(page).to have_selector('.balsamiq-viewer') - end -end -- GitLab From 357ab10da34102499a32ad569d9b58c8d5b97d17 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Fri, 28 Apr 2017 20:59:20 +0100 Subject: [PATCH 117/363] Fixed some spinach & rspec tests --- .../javascripts/deploy_keys/components/app.vue | 2 +- features/project/deploy_keys.feature | 6 ++++++ features/steps/project/deploy_keys.rb | 16 +++++++++------- spec/features/projects/deploy_keys_spec.rb | 12 ++++++++---- spec/serializers/deploy_key_entity_spec.rb | 9 ++++++++- 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index ee2f85bde5388..2ba5001cf56a0 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -77,7 +77,7 @@ </script> <template> - <div class="col-lg-9 col-lg-offset-3 append-bottom-default"> + <div class="col-lg-9 col-lg-offset-3 append-bottom-default deploy-keys"> <div class="text-center" v-if="isLoading && !hasKeys"> diff --git a/features/project/deploy_keys.feature b/features/project/deploy_keys.feature index 960b4100ee506..6f1ed9ff5b6b6 100644 --- a/features/project/deploy_keys.feature +++ b/features/project/deploy_keys.feature @@ -3,28 +3,33 @@ Feature: Project Deploy Keys Given I sign in as a user And I own project "Shop" + @javascript Scenario: I should see deploy keys list Given project has deploy key When I visit project deploy keys page Then I should see project deploy key + @javascript Scenario: I should see project deploy keys Given other projects have deploy keys When I visit project deploy keys page Then I should see other project deploy key And I should only see the same deploy key once + @javascript Scenario: I should see public deploy keys Given public deploy key exists When I visit project deploy keys page Then I should see public deploy key + @javascript Scenario: I add new deploy key Given I visit project deploy keys page And I submit new deploy key Then I should be on deploy keys page And I should see newly created deploy key + @javascript Scenario: I attach other project deploy key to project Given other projects have deploy keys And I visit project deploy keys page @@ -32,6 +37,7 @@ Feature: Project Deploy Keys Then I should be on deploy keys page And I should see newly created deploy key + @javascript Scenario: I attach public deploy key to project Given public deploy key exists And I visit project deploy keys page diff --git a/features/steps/project/deploy_keys.rb b/features/steps/project/deploy_keys.rb index ec59a2c094e79..8ad9d4a47419c 100644 --- a/features/steps/project/deploy_keys.rb +++ b/features/steps/project/deploy_keys.rb @@ -8,19 +8,19 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps end step 'I should see project deploy key' do - page.within '.deploy-keys' do + page.within(find('.deploy-keys')) do expect(page).to have_content deploy_key.title end end step 'I should see other project deploy key' do - page.within '.deploy-keys' do + page.within(find('.deploy-keys')) do expect(page).to have_content other_deploy_key.title end end step 'I should see public deploy key' do - page.within '.deploy-keys' do + page.within(find('.deploy-keys')) do expect(page).to have_content public_deploy_key.title end end @@ -40,7 +40,8 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps end step 'I should see newly created deploy key' do - page.within '.deploy-keys' do + @project.reload + page.within(find('.deploy-keys')) do expect(page).to have_content(deploy_key.title) end end @@ -56,7 +57,7 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps end step 'I should only see the same deploy key once' do - page.within '.deploy-keys' do + page.within(find('.deploy-keys')) do expect(page).to have_selector('ul li', count: 1) end end @@ -66,8 +67,9 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps end step 'I click attach deploy key' do - page.within '.deploy-keys' do - click_link 'Enable' + page.within(find('.deploy-keys')) do + click_button 'Enable' + expect(page).not_to have_selector('.fa-spinner') end end diff --git a/spec/features/projects/deploy_keys_spec.rb b/spec/features/projects/deploy_keys_spec.rb index 0b997f130ea6a..06abfbbc86b78 100644 --- a/spec/features/projects/deploy_keys_spec.rb +++ b/spec/features/projects/deploy_keys_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'Project deploy keys', feature: true do +describe 'Project deploy keys', :js, :feature do let(:user) { create(:user) } let(:project) { create(:project_empty_repo) } @@ -17,9 +17,13 @@ it 'removes association between project and deploy key' do visit namespace_project_settings_repository_path(project.namespace, project) - page.within '.deploy-keys' do - expect { click_on 'Remove' } - .to change { project.deploy_keys.count }.by(-1) + page.within(find('.deploy-keys')) do + expect(page).to have_selector('.deploy-keys li', count: 1) + + click_on 'Remove' + + expect(page).not_to have_selector('.fa-spinner', count: 0) + expect(page).to have_selector('.deploy-keys li', count: 0) end end end diff --git a/spec/serializers/deploy_key_entity_spec.rb b/spec/serializers/deploy_key_entity_spec.rb index cc3fb193f1be2..e73fbe190ca18 100644 --- a/spec/serializers/deploy_key_entity_spec.rb +++ b/spec/serializers/deploy_key_entity_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe DeployKeyEntity do + include RequestAwareEntity + let(:user) { create(:user) } let(:project) { create(:empty_project, :internal)} let(:project_private) { create(:empty_project, :private)} @@ -22,7 +24,12 @@ created_at: deploy_key.created_at, updated_at: deploy_key.updated_at, projects: [ - { id: project.id, name: project.name, full_path: project.full_path, full_name: project.full_name } + { + id: project.id, + name: project.name, + full_path: namespace_project_path(project.namespace, project), + full_name: project.full_name + } ] } -- GitLab From dc3d778fac4f9b0d4fbcb574f9ef3b0cd11ad640 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Sun, 30 Apr 2017 12:02:17 +0100 Subject: [PATCH 118/363] Fix failing specs --- spec/features/projects/commit/cherry_pick_spec.rb | 2 +- .../projects/environments/environment_spec.rb | 2 +- spec/features/projects/merge_request_button_spec.rb | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/spec/features/projects/commit/cherry_pick_spec.rb b/spec/features/projects/commit/cherry_pick_spec.rb index 5d64d42fd6158..8c86a6742c069 100644 --- a/spec/features/projects/commit/cherry_pick_spec.rb +++ b/spec/features/projects/commit/cherry_pick_spec.rb @@ -75,7 +75,7 @@ wait_for_ajax page.within('#modal-cherry-pick-commit .dropdown-menu .dropdown-content') do - click_link 'feature' + click_link "'test'" end page.within('#modal-cherry-pick-commit') do diff --git a/spec/features/projects/environments/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb index acc3efe04e672..49a4944e8740a 100644 --- a/spec/features/projects/environments/environment_spec.rb +++ b/spec/features/projects/environments/environment_spec.rb @@ -200,7 +200,7 @@ end scenario 'user deletes the branch with running environment' do - visit namespace_project_branches_path(project.namespace, project) + visit namespace_project_branches_path(project.namespace, project, page: 2) remove_branch_with_hooks(project, user, 'feature') do page.within('.js-branch-feature') { find('a.btn-remove').click } diff --git a/spec/features/projects/merge_request_button_spec.rb b/spec/features/projects/merge_request_button_spec.rb index 05f3162f13cd9..2c5642f9a8c4c 100644 --- a/spec/features/projects/merge_request_button_spec.rb +++ b/spec/features/projects/merge_request_button_spec.rb @@ -25,7 +25,7 @@ it 'shows Create merge request button' do href = new_namespace_project_merge_request_path(project.namespace, project, - merge_request: { source_branch: 'feature', + merge_request: { source_branch: "'test'", target_branch: 'master' }) visit url @@ -69,7 +69,7 @@ it 'shows Create merge request button' do href = new_namespace_project_merge_request_path(forked_project.namespace, forked_project, - merge_request: { source_branch: 'feature', + merge_request: { source_branch: "'test'", target_branch: 'master' }) visit fork_url @@ -93,16 +93,16 @@ context 'on compare page' do it_behaves_like 'Merge request button only shown when allowed' do let(:label) { 'Create merge request' } - let(:url) { namespace_project_compare_path(project.namespace, project, from: 'master', to: 'feature') } - let(:fork_url) { namespace_project_compare_path(forked_project.namespace, forked_project, from: 'master', to: 'feature') } + let(:url) { namespace_project_compare_path(project.namespace, project, from: 'master', to: "'test'") } + let(:fork_url) { namespace_project_compare_path(forked_project.namespace, forked_project, from: 'master', to: "'test'") } end end context 'on commits page' do it_behaves_like 'Merge request button only shown when allowed' do let(:label) { 'Create merge request' } - let(:url) { namespace_project_commits_path(project.namespace, project, 'feature') } - let(:fork_url) { namespace_project_commits_path(forked_project.namespace, forked_project, 'feature') } + let(:url) { namespace_project_commits_path(project.namespace, project, "'test'") } + let(:fork_url) { namespace_project_commits_path(forked_project.namespace, forked_project, "'test'") } end end end -- GitLab From 6baaa8a98e10ff93ba3f481052bb68fdafb6e2c1 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Mon, 1 May 2017 13:38:57 +0200 Subject: [PATCH 119/363] Add new ability check for stopping environment --- app/policies/environment_policy.rb | 13 ++++- app/services/ci/stop_environments_service.rb | 9 +--- spec/policies/environment_policy_spec.rb | 57 ++++++++++++++++++++ 3 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 spec/policies/environment_policy_spec.rb diff --git a/app/policies/environment_policy.rb b/app/policies/environment_policy.rb index f4219569161e9..0b976e5664d04 100644 --- a/app/policies/environment_policy.rb +++ b/app/policies/environment_policy.rb @@ -1,5 +1,16 @@ class EnvironmentPolicy < BasePolicy + + alias_method :environment, :subject + def rules - delegate! @subject.project + delegate! environment.project + + if environment.stop_action? + delegate! environment.stop_action + end + + if can?(:create_deployment) && can?(:play_build) + can! :stop_environment + end end end diff --git a/app/services/ci/stop_environments_service.rb b/app/services/ci/stop_environments_service.rb index bd9735fc0accf..43c9a065fcf75 100644 --- a/app/services/ci/stop_environments_service.rb +++ b/app/services/ci/stop_environments_service.rb @@ -5,12 +5,11 @@ class StopEnvironmentsService < BaseService def execute(branch_name) @ref = branch_name - return unless has_ref? - return unless can?(current_user, :create_deployment, project) + return unless @ref.present? environments.each do |environment| next unless environment.stop_action? - next unless can?(current_user, :play_build, environment.stop_action) + next unless can?(current_user, :stop_environment, environment) environment.stop_with_action!(current_user) end @@ -18,10 +17,6 @@ def execute(branch_name) private - def has_ref? - @ref.present? - end - def environments @environments ||= EnvironmentsFinder .new(project, current_user, ref: @ref, recently_updated: true) diff --git a/spec/policies/environment_policy_spec.rb b/spec/policies/environment_policy_spec.rb new file mode 100644 index 0000000000000..f43caffa94633 --- /dev/null +++ b/spec/policies/environment_policy_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' + +describe Ci::EnvironmentPolicy do + let(:user) { create(:user) } + let(:project) { create(:project) } + + let(:environment) do + create(:environment, :with_review_app, project: project) + end + + let(:policies) do + described_class.abilities(user, environment).to_set + end + + describe '#rules' do + context 'when user does not have access to the project' do + let(:project) { create(:project, :private) } + + it 'does not include ability to stop environment' do + expect(policies).not_to include :stop_environment + end + end + + context 'when anonymous user has access to the project' do + let(:project) { create(:project, :public) } + + it 'does not include ability to stop environment' do + expect(policies).not_to include :stop_environment + end + end + + context 'when team member has access to the project' do + let(:project) { create(:project, :public) } + + before do + project.add_master(user) + end + + context 'when team member has ability to stop environment' do + it 'does includes ability to stop environment' do + expect(policies).to include :stop_environment + end + end + + context 'when team member has no ability to stop environment' do + before do + create(:protected_branch, :no_one_can_push, + name: 'master', project: project) + end + + it 'does not include ability to stop environment' do + expect(policies).not_to include :stop_environment + end + end + end + end +end -- GitLab From 4f2cc5951f3d2862a44fe124ef9a879162028e62 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Mon, 1 May 2017 14:29:20 +0200 Subject: [PATCH 120/363] Extend action tooltop to show info about abilities --- lib/gitlab/ci/status/build/play.rb | 6 +- .../gitlab/ci/status/build/factory_spec.rb | 2 +- spec/lib/gitlab/ci/status/build/play_spec.rb | 65 ++++++++++--------- 3 files changed, 42 insertions(+), 31 deletions(-) diff --git a/lib/gitlab/ci/status/build/play.rb b/lib/gitlab/ci/status/build/play.rb index 4c893f62925e3..8130c7d1c90ef 100644 --- a/lib/gitlab/ci/status/build/play.rb +++ b/lib/gitlab/ci/status/build/play.rb @@ -6,7 +6,11 @@ class Play < SimpleDelegator include Status::Extended def label - 'manual play action' + if has_action? + 'manual play action' + else + 'manual play action (not allowed)' + end end def has_action? diff --git a/spec/lib/gitlab/ci/status/build/factory_spec.rb b/spec/lib/gitlab/ci/status/build/factory_spec.rb index 2ab67127b1edd..2de00c289450c 100644 --- a/spec/lib/gitlab/ci/status/build/factory_spec.rb +++ b/spec/lib/gitlab/ci/status/build/factory_spec.rb @@ -216,7 +216,7 @@ expect(status.group).to eq 'manual' expect(status.icon).to eq 'icon_status_manual' expect(status.favicon).to eq 'favicon_status_manual' - expect(status.label).to eq 'manual play action' + expect(status.label).to include 'manual play action' expect(status).to have_details expect(status.action_path).to include 'play' end diff --git a/spec/lib/gitlab/ci/status/build/play_spec.rb b/spec/lib/gitlab/ci/status/build/play_spec.rb index 95f1388a9b971..abefdbe7c66ca 100644 --- a/spec/lib/gitlab/ci/status/build/play_spec.rb +++ b/spec/lib/gitlab/ci/status/build/play_spec.rb @@ -1,51 +1,58 @@ require 'spec_helper' describe Gitlab::Ci::Status::Build::Play do - let(:status) { double('core') } - let(:user) { double('user') } + let(:user) { create(:user) } + let(:build) { create(:ci_build, :manual) } + let(:status) { Gitlab::Ci::Status::Core.new(build, user) } subject { described_class.new(status) } - describe '#label' do - it { expect(subject.label).to eq 'manual play action' } - end - - describe 'action details' do - let(:user) { create(:user) } - let(:build) { create(:ci_build, :manual) } - let(:status) { Gitlab::Ci::Status::Core.new(build, user) } + context 'when user is allowed to update build' do + context 'when user can push to branch' do + before { build.project.add_master(user) } - describe '#has_action?' do - context 'when user is allowed to update build' do - context 'when user can push to branch' do - before { build.project.add_master(user) } + describe '#has_action?' do + it { is_expected.to have_action } + end - it { is_expected.to have_action } + describe '#label' do + it 'has a label that says it is a manual action' do + expect(subject.label).to eq 'manual play action' end + end + end - context 'when user can not push to the branch' do - before { build.project.add_developer(user) } + context 'when user can not push to the branch' do + before { build.project.add_developer(user) } - it { is_expected.not_to have_action } - end + describe 'has_action?' do + it { is_expected.not_to have_action } end - context 'when user is not allowed to update build' do - it { is_expected.not_to have_action } + describe '#label' do + it 'has a label that says user is not allowed to play it' do + expect(subject.label).to eq 'manual play action (not allowed)' + end end end + end - describe '#action_path' do - it { expect(subject.action_path).to include "#{build.id}/play" } + context 'when user is not allowed to update build' do + describe '#has_action?' do + it { is_expected.not_to have_action } end + end - describe '#action_icon' do - it { expect(subject.action_icon).to eq 'icon_action_play' } - end + describe '#action_path' do + it { expect(subject.action_path).to include "#{build.id}/play" } + end - describe '#action_title' do - it { expect(subject.action_title).to eq 'Play' } - end + describe '#action_icon' do + it { expect(subject.action_icon).to eq 'icon_action_play' } + end + + describe '#action_title' do + it { expect(subject.action_title).to eq 'Play' } end describe '.matches?' do -- GitLab From e5df86f54d6165e0b3871678dd987956dddfa061 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Mon, 1 May 2017 14:30:50 +0200 Subject: [PATCH 121/363] Fix Rubocop offense in environments policy class --- app/policies/environment_policy.rb | 1 - spec/models/ci/build_spec.rb | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/policies/environment_policy.rb b/app/policies/environment_policy.rb index 0b976e5664d04..c0c9bbf2d9e9d 100644 --- a/app/policies/environment_policy.rb +++ b/app/policies/environment_policy.rb @@ -1,5 +1,4 @@ class EnvironmentPolicy < BasePolicy - alias_method :environment, :subject def rules diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index b2f9a61f7f3a2..5231ce28c9df1 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -911,7 +911,7 @@ def create_mr(build, pipeline, factory: :merge_request, created_at: Time.now) it { is_expected.to eq(environment) } end - context 'when referenced with a variable' do + context 'when referenced with a variable' do let(:build) do create(:ci_build, pipeline: pipeline, environment: "foo-$CI_COMMIT_REF_NAME") end -- GitLab From 3584e74eacfc101c64fe1305bbf97515e86ebc88 Mon Sep 17 00:00:00 2001 From: Eric Eastwood <contact@ericeastwood.com> Date: Tue, 25 Apr 2017 04:34:46 -0500 Subject: [PATCH 122/363] Fix MR target branch selector dropdown placement cut-off Fix https://gitlab.com/gitlab-org/gitlab-ce/issues/31271 When the dropdown items are too wide, constrain the dropdown to the offsetParent. Otherwise, let it naturally flow as Select2 wants. --- app/assets/javascripts/dispatcher.js | 2 + .../issuable/auto_width_dropdown_select.js | 38 +++++++++++++++++++ .../stylesheets/pages/merge_requests.scss | 4 ++ .../issuable/form/_branch_chooser.html.haml | 10 +++-- 4 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 app/assets/javascripts/issuable/auto_width_dropdown_select.js diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 15fe87f21ea7a..c0f11c0140575 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -49,6 +49,7 @@ import UserCallout from './user_callout'; import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags'; import ShortcutsWiki from './shortcuts_wiki'; import BlobViewer from './blob/viewer/index'; +import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; const ShortcutsBlob = require('./shortcuts_blob'); @@ -186,6 +187,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); new LabelsSelect(); new MilestoneSelect(); new gl.IssuableTemplateSelectors(); + new AutoWidthDropdownSelect($('.js-target-branch-select')).init(); break; case 'projects:tags:new': new ZenMode(); diff --git a/app/assets/javascripts/issuable/auto_width_dropdown_select.js b/app/assets/javascripts/issuable/auto_width_dropdown_select.js new file mode 100644 index 0000000000000..2203a56315e86 --- /dev/null +++ b/app/assets/javascripts/issuable/auto_width_dropdown_select.js @@ -0,0 +1,38 @@ +let instanceCount = 0; + +class AutoWidthDropdownSelect { + constructor(selectElement) { + this.$selectElement = $(selectElement); + this.dropdownClass = `js-auto-width-select-dropdown-${instanceCount}`; + instanceCount += 1; + } + + init() { + const dropdownClass = this.dropdownClass; + this.$selectElement.select2({ + dropdownCssClass: dropdownClass, + dropdownCss() { + let resultantWidth = 'auto'; + const $dropdown = $(`.${dropdownClass}`); + + // We have to look at the parent because + // `offsetParent` on a `display: none;` is `null` + const offsetParentWidth = $(this).parent().offsetParent().width(); + // Reset any width to let it naturally flow + $dropdown.css('width', 'auto'); + if ($dropdown.outerWidth(false) > offsetParentWidth) { + resultantWidth = offsetParentWidth; + } + + return { + width: resultantWidth, + maxWidth: offsetParentWidth, + }; + }, + }); + + return this; + } +} + +export default AutoWidthDropdownSelect; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 6a419384a3472..05e7e565177f5 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -482,6 +482,10 @@ } } +.target-branch-select-dropdown-container { + position: relative; +} + .assign-to-me-link { padding-left: 12px; white-space: nowrap; diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml index 2793e7bcff430..f57b4d899ce98 100644 --- a/app/views/shared/issuable/form/_branch_chooser.html.haml +++ b/app/views/shared/issuable/form/_branch_chooser.html.haml @@ -10,12 +10,16 @@ = form.label :source_branch, class: 'control-label' .col-sm-10 .issuable-form-select-holder - = form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2 span2', disabled: true }) + = form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2', disabled: true }) .form-group = form.label :target_branch, class: 'control-label' - .col-sm-10 + .col-sm-10.target-branch-select-dropdown-container .issuable-form-select-holder - = form.select(:target_branch, issuable.target_branches, { include_blank: true }, { class: 'target_branch select2 span2', disabled: issuable.new_record?, data: { placeholder: "Select branch" }}) + = form.select(:target_branch, issuable.target_branches, + { include_blank: true }, + { class: 'target_branch js-target-branch-select', + disabled: issuable.new_record?, + data: { placeholder: "Select branch" }}) - if issuable.new_record? = link_to 'Change branches', mr_change_branches_path(issuable) -- GitLab From 78a8be969f47af2fca2bff8b177f47238f8e7516 Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Mon, 1 May 2017 15:45:50 -0600 Subject: [PATCH 123/363] make tab title legit --- .../issue_show/issue_title_description.vue | 14 ++++++++++++-- app/controllers/projects/issues_controller.rb | 2 ++ .../issue_show/issue_title_description_spec.js | 6 ++++-- spec/javascripts/issue_show/mock_data.js | 3 +++ 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index e21667f2ac709..6fdf56fc93830 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -31,6 +31,7 @@ export default { poll, timeoutId: null, title: '<span></span>', + titleText: '', description: '<span></span>', descriptionText: '', descriptionChange: false, @@ -40,16 +41,18 @@ export default { methods: { renderResponse(res) { const data = JSON.parse(res.body); + this.issueIID = data.issue_number; this.triggerAnimation(data); }, updateTaskHTML(data) { this.taskStatus = data.task_status; document.querySelector('#task_status').innerText = this.taskStatus; }, - elementsToVisualize(noTitleChange, noDescriptionChange) { + elementsToVisualize(noTitleChange, noDescriptionChange, data) { const elementStack = []; if (!noTitleChange) { + this.titleText = data.title_text; elementStack.push(this.$el.querySelector('.title')); } @@ -66,11 +69,17 @@ export default { return elementStack; }, + setTabTitle() { + const currentTabTitle = document.querySelector('title'); + const currentTabTitleScope = currentTabTitle.innerText.split('·'); + currentTabTitleScope[0] = `${this.titleText} (#${this.issueIID}) `; + currentTabTitle.innerText = currentTabTitleScope.join('·'); + }, animate(title, description, elementsToVisualize) { this.timeoutId = setTimeout(() => { this.title = title; this.description = description; - document.querySelector('title').innerText = title; + this.setTabTitle(); elementsToVisualize.forEach((element) => { element.classList.remove('issue-realtime-pre-pulse'); @@ -99,6 +108,7 @@ export default { const elementsToVisualize = this.elementsToVisualize( noTitleChange, noDescriptionChange, + data, ); this.animate(title, description, elementsToVisualize); diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index e5c1505ece65d..7932b92c00549 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -201,9 +201,11 @@ def rendered_title render json: { title: view_context.markdown_field(@issue, :title), + title_text: @issue.title, description: view_context.markdown_field(@issue, :description), description_text: @issue.description, task_status: @issue.task_status, + issue_number: @issue.iid, } end diff --git a/spec/javascripts/issue_show/issue_title_description_spec.js b/spec/javascripts/issue_show/issue_title_description_spec.js index 756eda54f798c..30daa3e7eda07 100644 --- a/spec/javascripts/issue_show/issue_title_description_spec.js +++ b/spec/javascripts/issue_show/issue_title_description_spec.js @@ -13,7 +13,9 @@ const issueShowInterceptor = (request, next) => { })); }; -describe('Issue Title', () => { +fdescribe('Issue Title', () => { + document.body.innerHTML = '<span id="task_status"></span>'; + const comps = { IssueTitleComponent: {}, }; @@ -40,7 +42,7 @@ describe('Issue Title', () => { // need setTimeout because actual setTimeout in code :P setTimeout(() => { expect(document.querySelector('title').innerText) - .toContain('this is a title'); + .toContain('this is a title (#1)'); expect(issueShowComponent.$el.querySelector('.title').innerHTML) .toContain('<p>this is a title</p>'); diff --git a/spec/javascripts/issue_show/mock_data.js b/spec/javascripts/issue_show/mock_data.js index 37e86cfe33d5b..b7b01391a18a8 100644 --- a/spec/javascripts/issue_show/mock_data.js +++ b/spec/javascripts/issue_show/mock_data.js @@ -1,5 +1,8 @@ export default { title: '<p>this is a title</p>', + title_text: 'this is a title', description: '<p>this is a description!</p>', description_text: 'this is a description', + issue_number: 1, + task_status: '2/4 completed', }; -- GitLab From a194e874e2bcd61b9ca5f87a6918a69eb8833aa9 Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Mon, 1 May 2017 16:39:39 -0600 Subject: [PATCH 124/363] previous and current description --- .../issue_show/issue_title_description.vue | 74 +++++++++++++------ .../issue_title_description_spec.js | 2 +- 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index 6fdf56fc93830..c7adec878a31f 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -29,36 +29,43 @@ export default { return { poll, + data: {}, + current: true, timeoutId: null, title: '<span></span>', titleText: '', description: '<span></span>', descriptionText: '', descriptionChange: false, + previousDescription: null, taskStatus: '', }; }, methods: { renderResponse(res) { const data = JSON.parse(res.body); - this.issueIID = data.issue_number; + this.data = data; + this.issueIID = this.data.issue_number; this.triggerAnimation(data); }, - updateTaskHTML(data) { - this.taskStatus = data.task_status; + updateTaskHTML() { + this.taskStatus = this.data.task_status; document.querySelector('#task_status').innerText = this.taskStatus; }, - elementsToVisualize(noTitleChange, noDescriptionChange, data) { + elementsToVisualize(noTitleChange, noDescriptionChange) { const elementStack = []; if (!noTitleChange) { - this.titleText = data.title_text; + this.titleText = this.data.title_text; elementStack.push(this.$el.querySelector('.title')); } if (!noDescriptionChange) { // only change to true when we need to bind TaskLists the html of description this.descriptionChange = true; + if (this.description !== '<span></span>') { + this.previousDescription = this.description; + } elementStack.push(this.$el.querySelector('.wiki')); } @@ -89,35 +96,49 @@ export default { clearTimeout(this.timeoutId); }, 0); }, - triggerAnimation(data) { + triggerAnimation() { // always reset to false before checking the change this.descriptionChange = false; - const { title, description } = data; - this.descriptionText = data.description_text; - this.updateTaskHTML(data); + const { title, description } = this.data; + this.descriptionText = this.data.description_text; + + this.updateTaskHTML(); + + const noTitleChange = this.title === title; + const noDescriptionChange = this.description === description; + /** * since opacity is changed, even if there is no diff for Vue to update * we must check the title/description even on a 304 to ensure no visual change */ - const noTitleChange = this.title === title; - const noDescriptionChange = this.description === description; - if (noTitleChange && noDescriptionChange) return; const elementsToVisualize = this.elementsToVisualize( noTitleChange, noDescriptionChange, - data, ); this.animate(title, description, elementsToVisualize); }, + handleCurrentOrPrevious() { + this.descriptionChange = true; + this.current = !this.current; + }, }, computed: { descriptionClass() { return `description ${this.candescription} is-task-list-enabled`; }, + showDescription() { + return this.current ? this.description : this.previousDescription; + }, + previousOrCurrentButtonText() { + return this.current ? '<< Show Previous Decription' : 'Show Current Description >>'; + }, + prevCurrBtnClass() { + return this.current ? 'btn btn-sm btn-default' : 'btn btn-sm btn-primary'; + }, }, created() { if (!Visibility.hidden()) { @@ -135,14 +156,17 @@ export default { updated() { // if new html is injected (description changed) - bind TaskList and call renderGFM if (this.descriptionChange) { - const tl = new gl.TaskList({ - dataType: 'issue', - fieldName: 'description', - selector: '.detail-page-description', - }); - $(this.$refs['issue-content-container-gfm-entry']).renderGFM(); - return tl; + + if (this.current) { + const tl = new gl.TaskList({ + dataType: 'issue', + fieldName: 'description', + selector: '.detail-page-description', + }); + + return tl; + } } return null; }, @@ -156,9 +180,17 @@ export default { :class="descriptionClass" v-if="description" > + <div v-if="previousDescription"> + <button + :class="prevCurrBtnClass" + @click="handleCurrentOrPrevious" + > + {{ previousOrCurrentButtonText }} + </button> + </div><br> <div class="wiki issue-realtime-trigger-pulse" - v-html="description" + v-html="showDescription" ref="issue-content-container-gfm-entry" > </div> diff --git a/spec/javascripts/issue_show/issue_title_description_spec.js b/spec/javascripts/issue_show/issue_title_description_spec.js index 30daa3e7eda07..4c7d4748693dd 100644 --- a/spec/javascripts/issue_show/issue_title_description_spec.js +++ b/spec/javascripts/issue_show/issue_title_description_spec.js @@ -13,7 +13,7 @@ const issueShowInterceptor = (request, next) => { })); }; -fdescribe('Issue Title', () => { +describe('Issue Title', () => { document.body.innerHTML = '<span id="task_status"></span>'; const comps = { -- GitLab From dd9f848653d9635e7f17e608e8ff6cceca608068 Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Mon, 1 May 2017 18:59:32 -0600 Subject: [PATCH 125/363] handle tasks and fix some specs --- .../issue_show/issue_title_description.vue | 36 ++++++++++++------- spec/features/issues/award_spec.rb | 6 ++++ ...issuable_slash_commands_shared_examples.rb | 2 ++ 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index c7adec878a31f..dd8794188d783 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -20,7 +20,7 @@ export default { errorCallback: (err) => { if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line no-console - console.error('ISSUE SHOW REALTIME ERROR', err); + console.error('ISSUE SHOW REALTIME ERROR', err, err.stack); } else { throw new Error(err); } @@ -29,7 +29,7 @@ export default { return { poll, - data: {}, + apiData: {}, current: true, timeoutId: null, title: '<span></span>', @@ -43,29 +43,41 @@ export default { }, methods: { renderResponse(res) { - const data = JSON.parse(res.body); - this.data = data; - this.issueIID = this.data.issue_number; - this.triggerAnimation(data); + this.apiData = JSON.parse(res.body); + this.issueIID = this.apiData.issue_number; + this.triggerAnimation(); }, updateTaskHTML() { - this.taskStatus = this.data.task_status; - document.querySelector('#task_status').innerText = this.taskStatus; + const tasks = document.querySelector('#task_status_short'); + const zeroTasks = this.apiData.task_status.includes('0 of 0'); + + if (tasks && !zeroTasks) { + tasks.innerText = this.apiData.task_status; + } else if (this.apiData.task_status.includes('0 of 0')) { + $('#task_status_short').remove(); + } else if (!tasks && !zeroTasks) { + $('.issuable-header').append(` + <span id="task_status_short" class="hidden-md hidden-lg">${this.apiData.task_status}</span> + `); + } }, elementsToVisualize(noTitleChange, noDescriptionChange) { const elementStack = []; if (!noTitleChange) { - this.titleText = this.data.title_text; + this.titleText = this.apiData.title_text; elementStack.push(this.$el.querySelector('.title')); } if (!noDescriptionChange) { // only change to true when we need to bind TaskLists the html of description this.descriptionChange = true; + this.updateTaskHTML(); + if (this.description !== '<span></span>') { this.previousDescription = this.description; } + elementStack.push(this.$el.querySelector('.wiki')); } @@ -100,10 +112,8 @@ export default { // always reset to false before checking the change this.descriptionChange = false; - const { title, description } = this.data; - this.descriptionText = this.data.description_text; - - this.updateTaskHTML(); + const { title, description } = this.apiData; + this.descriptionText = this.apiData.description_text; const noTitleChange = this.title === title; const noDescriptionChange = this.description === description; diff --git a/spec/features/issues/award_spec.rb b/spec/features/issues/award_spec.rb index 401e1ea2b893e..08e3f99e29f0d 100644 --- a/spec/features/issues/award_spec.rb +++ b/spec/features/issues/award_spec.rb @@ -6,9 +6,12 @@ let(:issue) { create(:issue, project: project) } describe 'logged in' do + include WaitForVueResource + before do login_as(user) visit namespace_project_issue_path(project.namespace, project, issue) + wait_for_vue_resource end it 'adds award to issue' do @@ -38,8 +41,11 @@ end describe 'logged out' do + include WaitForVueResource + before do visit namespace_project_issue_path(project.namespace, project, issue) + wait_for_vue_resource end it 'does not see award menu button' do diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb index 5bbe36d9b7fb8..5e7eca1d987d4 100644 --- a/spec/support/features/issuable_slash_commands_shared_examples.rb +++ b/spec/support/features/issuable_slash_commands_shared_examples.rb @@ -3,6 +3,7 @@ shared_examples 'issuable record that supports slash commands in its description and notes' do |issuable_type| include SlashCommandsHelpers + include WaitForVueResource let(:master) { create(:user) } let(:assignee) { create(:user, username: 'bob') } @@ -18,6 +19,7 @@ project.team << [assignee, :developer] project.team << [guest, :guest] login_with(master) + wait_for_vue_resource end after do -- GitLab From 60b27b1b3e76b4b848b044c1f64a6d27a67b9424 Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Mon, 1 May 2017 19:05:07 -0600 Subject: [PATCH 126/363] formatting --- .../javascripts/issue_show/issue_title_description.vue | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index dd8794188d783..97f22754393a1 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -38,7 +38,6 @@ export default { descriptionText: '', descriptionChange: false, previousDescription: null, - taskStatus: '', }; }, methods: { @@ -53,12 +52,12 @@ export default { if (tasks && !zeroTasks) { tasks.innerText = this.apiData.task_status; - } else if (this.apiData.task_status.includes('0 of 0')) { - $('#task_status_short').remove(); } else if (!tasks && !zeroTasks) { $('.issuable-header').append(` <span id="task_status_short" class="hidden-md hidden-lg">${this.apiData.task_status}</span> `); + } else if (zeroTasks) { + $('#task_status_short').remove(); } }, elementsToVisualize(noTitleChange, noDescriptionChange) { -- GitLab From 7021e867e41f86343fa8fe9cef75f89d466b2ef6 Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Mon, 1 May 2017 19:48:25 -0600 Subject: [PATCH 127/363] use indexOf instead of includes --- app/assets/javascripts/issue_show/issue_title_description.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index 97f22754393a1..48b649cd56509 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -48,7 +48,7 @@ export default { }, updateTaskHTML() { const tasks = document.querySelector('#task_status_short'); - const zeroTasks = this.apiData.task_status.includes('0 of 0'); + const zeroTasks = this.apiData.task_status.indexOf('0 of 0') >= 0; if (tasks && !zeroTasks) { tasks.innerText = this.apiData.task_status; -- GitLab From cfc03dab245843b924c353e50e15e2e59733048e Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Mon, 1 May 2017 20:56:04 -0600 Subject: [PATCH 128/363] fix rubocop offenses --- .../issue_show/issue_title_description.vue | 3 +-- spec/features/task_lists_spec.rb | 23 +++++++------------ 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index 48b649cd56509..7862923c00442 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -43,7 +43,6 @@ export default { methods: { renderResponse(res) { this.apiData = JSON.parse(res.body); - this.issueIID = this.apiData.issue_number; this.triggerAnimation(); }, updateTaskHTML() { @@ -90,7 +89,7 @@ export default { setTabTitle() { const currentTabTitle = document.querySelector('title'); const currentTabTitleScope = currentTabTitle.innerText.split('·'); - currentTabTitleScope[0] = `${this.titleText} (#${this.issueIID}) `; + currentTabTitleScope[0] = `${this.titleText} (#${this.apiData.issue_number}) `; currentTabTitle.innerText = currentTabTitleScope.join('·'); }, animate(title, description, elementsToVisualize) { diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb index 3858f4d89596f..38437b4250aca 100644 --- a/spec/features/task_lists_spec.rb +++ b/spec/features/task_lists_spec.rb @@ -62,15 +62,16 @@ def visit_issue(project, issue) visit namespace_project_issue_path(project.namespace, project, issue) end - describe 'for Issues' do + describe 'for Issues', js: true do include WaitForVueResource + before { wait_for_vue_resource } + describe 'multiple tasks', js: true do let!(:issue) { create(:issue, description: markdown, author: user, project: project) } it 'renders' do visit_issue(project, issue) - wait_for_vue_resource expect(page).to have_selector('ul.task-list', count: 1) expect(page).to have_selector('li.task-list-item', count: 6) @@ -79,7 +80,6 @@ def visit_issue(project, issue) it 'contains the required selectors' do visit_issue(project, issue) - wait_for_vue_resource container = '.detail-page-description .description.js-task-list-container' @@ -92,14 +92,12 @@ def visit_issue(project, issue) it 'is only editable by author' do visit_issue(project, issue) - wait_for_vue_resource expect(page).to have_selector('.js-task-list-container') logout(:user) login_as(user2) visit current_path - wait_for_vue_resource expect(page).not_to have_selector('.js-task-list-container') end @@ -109,12 +107,15 @@ def visit_issue(project, issue) end end - describe 'single incomplete task' do + describe 'single incomplete task', js: true do + include WaitForVueResource + let!(:issue) { create(:issue, description: singleIncompleteMarkdown, author: user, project: project) } + before { wait_for_vue_resource } + it 'renders' do visit_issue(project, issue) - wait_for_vue_resource expect(page).to have_selector('ul.task-list', count: 1) expect(page).to have_selector('li.task-list-item', count: 1) @@ -132,7 +133,6 @@ def visit_issue(project, issue) it 'renders' do visit_issue(project, issue) - wait_for_vue_resource expect(page).to have_selector('ul.task-list', count: 1) expect(page).to have_selector('li.task-list-item', count: 1) @@ -151,9 +151,6 @@ def visit_issue(project, issue) before { visit_issue(project, issue) } it 'renders' do - - wait_for_vue_resource - expect(page).to have_selector('ul.task-list', count: 2) expect(page).to have_selector('li.task-list-item', count: 7) expect(page).to have_selector('ul input[checked]', count: 1) @@ -163,8 +160,6 @@ def visit_issue(project, issue) it 'solves tasks' do expect(page).to have_content("2 of 7 tasks completed") - wait_for_vue_resource - page.find('li.task-list-item', text: 'Task b').find('input').click page.find('li.task-list-item ul li.task-list-item', text: 'Task a.2').find('input').click page.find('li.task-list-item ol li.task-list-item', text: 'Task 1.1').find('input').click @@ -173,8 +168,6 @@ def visit_issue(project, issue) visit_issue(project, issue) # reload to see new system notes - wait_for_vue_resource - expect(page).to have_content('marked the task Task b as complete') expect(page).to have_content('marked the task Task a.2 as complete') expect(page).to have_content('marked the task Task 1.1 as complete') -- GitLab From e2bc67c91fc3ccbcbe5ed5b391d805b5da2f4d02 Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Mon, 1 May 2017 20:59:52 -0600 Subject: [PATCH 129/363] fix task_list spec - not ocmpletely but progress --- spec/features/task_lists_spec.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb index 38437b4250aca..f55c5aaf646be 100644 --- a/spec/features/task_lists_spec.rb +++ b/spec/features/task_lists_spec.rb @@ -62,12 +62,12 @@ def visit_issue(project, issue) visit namespace_project_issue_path(project.namespace, project, issue) end - describe 'for Issues', js: true do - include WaitForVueResource - - before { wait_for_vue_resource } - + describe 'for Issues', feature: true do describe 'multiple tasks', js: true do + include WaitForVueResource + + before { wait_for_vue_resource } + let!(:issue) { create(:issue, description: markdown, author: user, project: project) } it 'renders' do -- GitLab From 96ea22208ffced8a8a82d182fe29172d0b60ce29 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Tue, 2 May 2017 08:29:39 +0100 Subject: [PATCH 130/363] Started specs [ci skip] --- .../deploy_keys/components/keys_panel.vue | 2 +- .../deploy_keys/components/app_spec.js | 129 ++++++++++++++++++ spec/javascripts/fixtures/deploy_keys.rb | 36 +++++ 3 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 spec/javascripts/deploy_keys/components/app_spec.js create mode 100644 spec/javascripts/fixtures/deploy_keys.rb diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue index 2470831eddfef..e5113fe5245ce 100644 --- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue +++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue @@ -28,7 +28,7 @@ </script> <template> - <div> + <div class="deploy-keys-panel"> <h5> {{ title }} ({{ keys.length }}) diff --git a/spec/javascripts/deploy_keys/components/app_spec.js b/spec/javascripts/deploy_keys/components/app_spec.js new file mode 100644 index 0000000000000..e061992b2bda8 --- /dev/null +++ b/spec/javascripts/deploy_keys/components/app_spec.js @@ -0,0 +1,129 @@ +import Vue from 'vue'; +import eventHub from '~/deploy_keys/eventhub'; +import deployKeysApp from '~/deploy_keys/components/app.vue'; + +describe('Deploy keys app component', () => { + const data = getJSONFixture('deploy_keys/keys.json'); + let vm; + + const deployKeysResponse = (request, next) => { + next(request.respondWith(JSON.stringify(data), { + status: 200, + })); + }; + + beforeEach((done) => { + const Component = Vue.extend(deployKeysApp); + + Vue.http.interceptors.push(deployKeysResponse); + + vm = new Component({ + propsData: { + endpoint: '/test', + }, + }).$mount(); + + setTimeout(done); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, deployKeysResponse); + }); + + it('renders loading icon', (done) => { + vm.store.keys = {}; + vm.isLoading = false; + + Vue.nextTick(() => { + expect( + vm.$el.querySelectorAll('.deploy-keys-panel').length, + ).toBe(0); + + expect( + vm.$el.querySelector('.fa-spinner'), + ).toBeDefined(); + + done(); + }); + }); + + it('renders keys panels', () => { + expect( + vm.$el.querySelectorAll('.deploy-keys-panel').length, + ).toBe(3); + }); + + it('does not render key panels when keys object is empty', (done) => { + vm.store.keys = {}; + + Vue.nextTick(() => { + expect( + vm.$el.querySelectorAll('.deploy-keys-panel').length, + ).toBe(0); + + done(); + }); + }); + + it('does not render public panel when empty', (done) => { + vm.store.keys.public_keys = []; + + Vue.nextTick(() => { + expect( + vm.$el.querySelectorAll('.deploy-keys-panel').length, + ).toBe(2); + + done(); + }); + }); + + it('re-fetches deploy keys when enabling a key', (done) => { + const key = data.public_keys[0]; + + spyOn(vm.service, 'getKeys'); + spyOn(vm.service, 'enableKey').and.callFake(() => new Promise((resolve) => { + resolve(); + + setTimeout(() => { + expect(vm.service.getKeys).toHaveBeenCalled(); + + done(); + }); + })); + + eventHub.$emit('enable.key', key); + + expect(vm.service.enableKey).toHaveBeenCalledWith(key.id); + }); + + it('re-fetches deploy keys when disabling a key', (done) => { + const key = data.public_keys[0]; + + spyOn(window, 'confirm').and.returnValue(true); + spyOn(vm.service, 'getKeys'); + spyOn(vm.service, 'disableKey').and.callFake(() => new Promise((resolve) => { + resolve(); + + setTimeout(() => { + expect(vm.service.getKeys).toHaveBeenCalled(); + + done(); + }); + })); + + eventHub.$emit('disable.key', key); + + expect(vm.service.disableKey).toHaveBeenCalledWith(key.id); + }); + + it('calls disableKey when removing a key', () => { + const key = data.public_keys[0]; + + spyOn(window, 'confirm').and.returnValue(true); + spyOn(vm, 'disableKey'); + + eventHub.$emit('remove.key', key); + + expect(vm.disableKey).toHaveBeenCalledWith(key); + }); +}); diff --git a/spec/javascripts/fixtures/deploy_keys.rb b/spec/javascripts/fixtures/deploy_keys.rb new file mode 100644 index 0000000000000..16e598a4b290c --- /dev/null +++ b/spec/javascripts/fixtures/deploy_keys.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :controller do + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin) } + let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} + let(:project) { create(:project_empty_repo, namespace: namespace, path: 'todos-project') } + let(:project2) { create(:empty_project, :internal)} + + before(:all) do + clean_frontend_fixtures('deploy_keys/') + end + + before(:each) do + sign_in(admin) + end + + render_views + + it 'deploy_keys/keys.json' do |example| + create(:deploy_key, public: true) + project_key = create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCdMHEHyhRjbhEZVddFn6lTWdgEy5Q6Bz4nwGB76xWZI5YT/1WJOMEW+sL5zYd31kk7sd3FJ5L9ft8zWMWrr/iWXQikC2cqZK24H1xy+ZUmrRuJD4qGAaIVoyyzBL+avL+lF8J5lg6YSw8gwJY/lX64/vnJHUlWw2n5BF8IFOWhiw== dummy@gitlab.com') + internal_key = create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDNd/UJWhPrpb+b/G5oL109y57yKuCxE+WUGJGYaj7WQKsYRJmLYh1mgjrl+KVyfsWpq4ylOxIfFSnN9xBBFN8mlb0Fma5DC7YsSsibJr3MZ19ZNBprwNcdogET7aW9I0In7Wu5f2KqI6e5W/spJHCy4JVxzVMUvk6Myab0LnJ2iQ== dummy@gitlab.com') + create(:deploy_keys_project, project: project, deploy_key: project_key) + create(:deploy_keys_project, project: project2, deploy_key: internal_key) + + get :index, + namespace_id: project.namespace.to_param, + project_id: project, + format: :json + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end +end -- GitLab From 02fc1846832593b2e803a83446690183715f5df1 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Tue, 2 May 2017 10:29:09 +0200 Subject: [PATCH 131/363] Improve specs for jobs API regarding manual actions --- spec/requests/api/jobs_spec.rb | 67 +++++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 17 deletions(-) diff --git a/spec/requests/api/jobs_spec.rb b/spec/requests/api/jobs_spec.rb index d8a56c02a638a..e5e5872dc1ff6 100644 --- a/spec/requests/api/jobs_spec.rb +++ b/spec/requests/api/jobs_spec.rb @@ -1,16 +1,26 @@ require 'spec_helper' -describe API::Jobs, api: true do - include ApiHelpers +describe API::Jobs, :api do + let!(:project) do + create(:project, :repository, public_builds: false) + end + + let!(:pipeline) do + create(:ci_empty_pipeline, project: project, + sha: project.commit.id, + ref: project.default_branch) + end + + let!(:build) { create(:ci_build, pipeline: pipeline) } let(:user) { create(:user) } let(:api_user) { user } - let!(:project) { create(:project, :repository, creator: user, public_builds: false) } - let!(:developer) { create(:project_member, :developer, user: user, project: project) } - let(:reporter) { create(:project_member, :reporter, project: project) } - let(:guest) { create(:project_member, :guest, project: project) } - let!(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.id, ref: project.default_branch) } - let!(:build) { create(:ci_build, pipeline: pipeline) } + let(:reporter) { create(:project_member, :reporter, project: project).user } + let(:guest) { create(:project_member, :guest, project: project).user } + + before do + project.add_developer(user) + end describe 'GET /projects/:id/jobs' do let(:query) { Hash.new } @@ -213,7 +223,7 @@ end describe 'GET /projects/:id/artifacts/:ref_name/download?job=name' do - let(:api_user) { reporter.user } + let(:api_user) { reporter } let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) } before do @@ -237,7 +247,7 @@ def get_for_ref(ref = pipeline.ref, job = build.name) end context 'when logging as guest' do - let(:api_user) { guest.user } + let(:api_user) { guest } before do get_for_ref @@ -347,7 +357,7 @@ def get_for_ref(ref = pipeline.ref, job = build.name) end context 'user without :update_build permission' do - let(:api_user) { reporter.user } + let(:api_user) { reporter } it 'does not cancel job' do expect(response).to have_http_status(403) @@ -381,7 +391,7 @@ def get_for_ref(ref = pipeline.ref, job = build.name) end context 'user without :update_build permission' do - let(:api_user) { reporter.user } + let(:api_user) { reporter } it 'does not retry job' do expect(response).to have_http_status(403) @@ -457,16 +467,39 @@ def get_for_ref(ref = pipeline.ref, job = build.name) describe 'POST /projects/:id/jobs/:job_id/play' do before do - post api("/projects/#{project.id}/jobs/#{build.id}/play", user) + post api("/projects/#{project.id}/jobs/#{build.id}/play", api_user) end context 'on an playable job' do let(:build) { create(:ci_build, :manual, project: project, pipeline: pipeline) } - it 'plays the job' do - expect(response).to have_http_status(200) - expect(json_response['user']['id']).to eq(user.id) - expect(json_response['id']).to eq(build.id) + context 'when user is authorized to trigger a manual action' do + it 'plays the job' do + expect(response).to have_http_status(200) + expect(json_response['user']['id']).to eq(user.id) + expect(json_response['id']).to eq(build.id) + expect(build.reload).to be_pending + end + end + + context 'when user is not authorized to trigger a manual action' do + context 'when user does not have access to the project' do + let(:api_user) { create(:user) } + + it 'does not trigger a manual action' do + expect(build.reload).to be_manual + expect(response).to have_http_status(404) + end + end + + context 'when user is not allowed to trigger the manual action' do + let(:api_user) { reporter } + + it 'does not trigger a manual action' do + expect(build.reload).to be_manual + expect(response).to have_http_status(403) + end + end end end -- GitLab From f5441c93203cd5c8ccd208c00bee59432facae7f Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Tue, 2 May 2017 10:35:51 +0200 Subject: [PATCH 132/363] Document protected manual actions feature --- doc/ci/yaml/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index ad3ebd144dfe4..0cab3270d94dc 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -553,6 +553,8 @@ The above script will: #### Manual actions > Introduced in GitLab 8.10. +> Blocking manual actions were introduced in GitLab 9.0 +> Protected actions were introduced in GitLab 9.2 Manual actions are a special type of job that are not executed automatically; they need to be explicitly started by a user. Manual actions can be started @@ -578,7 +580,9 @@ Optional manual actions have `allow_failure: true` set by default. **Statuses of optional actions do not contribute to overall pipeline status.** -> Blocking manual actions were introduced in GitLab 9.0 +**Manual actions do inherit permissions of protected branches. In other words, +in order to trigger a manual action assigned to a branch that the pipeline is +running for, user needs to have ability to push to this branch.** ### environment -- GitLab From c9def85844531ffdd2984707a1bc8cbca18f6742 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Tue, 2 May 2017 10:37:50 +0200 Subject: [PATCH 133/363] Add Changelog entry for protected manual actions --- ...ature-gb-manual-actions-protected-branches-permissions.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelogs/unreleased/feature-gb-manual-actions-protected-branches-permissions.yml diff --git a/changelogs/unreleased/feature-gb-manual-actions-protected-branches-permissions.yml b/changelogs/unreleased/feature-gb-manual-actions-protected-branches-permissions.yml new file mode 100644 index 0000000000000..6f8e80e7d648a --- /dev/null +++ b/changelogs/unreleased/feature-gb-manual-actions-protected-branches-permissions.yml @@ -0,0 +1,4 @@ +--- +title: Implement protected manual actions +merge_request: 10494 +author: -- GitLab From 4ddce55315167b362056616151244f137c81945f Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Tue, 2 May 2017 11:27:10 +0200 Subject: [PATCH 134/363] Fix environment policy class name in specs --- spec/policies/environment_policy_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/policies/environment_policy_spec.rb b/spec/policies/environment_policy_spec.rb index f43caffa94633..0e15beaa5e8c1 100644 --- a/spec/policies/environment_policy_spec.rb +++ b/spec/policies/environment_policy_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Ci::EnvironmentPolicy do +describe EnvironmentPolicy do let(:user) { create(:user) } let(:project) { create(:project) } -- GitLab From ccb8e6936c9e85743e4cb42c3580b297efa889ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Thu, 27 Apr 2017 18:55:15 +0200 Subject: [PATCH 135/363] Document labels in CONTRIBUTING.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable <remy@rymai.me> --- CONTRIBUTING.md | 180 +++++++++++++++++++++++++++++++++++++++--------- PROCESS.md | 67 ++++++++++-------- 2 files changed, 183 insertions(+), 64 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 73c8a77364b67..c8b4d0250ff77 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,27 +13,30 @@ _This notice should stay as the first item in the CONTRIBUTING.MD file._ <!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE --> **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* -- [Contributor license agreement](#contributor-license-agreement) - [Contribute to GitLab](#contribute-to-gitlab) - [Security vulnerability disclosure](#security-vulnerability-disclosure) - [Closing policy for issues and merge requests](#closing-policy-for-issues-and-merge-requests) - [Helping others](#helping-others) - [I want to contribute!](#i-want-to-contribute) -- [Implement design & UI elements](#implement-design-ui-elements) -- [Release retrospective and kickoff](#release-retrospective-and-kickoff) - - [Retrospective](#retrospective) - - [Kickoff](#kickoff) +- [Workflow labels](#workflow-labels) + - [Type labels (~"feature proposal", ~bug, ~customer, etc.)](#type-labels-feature-proposal-bug-customer-etc) + - [Subject labels (~wiki, ~"container registry", ~ldap, ~api, etc.)](#subject-labels-wiki-container-registry-ldap-api-etc) + - [Team labels (~CI, ~Discussion, ~Edge, ~Frontend, ~Platform, etc.)](#team-labels-ci-discussion-edge-frontend-platform-etc) + - [Priority labels (`Deliverable` and `Stretch`)](#priority-labels-deliverable-and-stretch) + - [Label for community contributors (~"Accepting Merge Requests")](#label-for-community-contributors-accepting-merge-requests) +- [Implement design & UI elements](#implement-design-&-ui-elements) - [Issue tracker](#issue-tracker) - - [Feature proposals](#feature-proposals) - - [Issue tracker guidelines](#issue-tracker-guidelines) - - [Issue weight](#issue-weight) - - [Regression issues](#regression-issues) - - [Technical debt](#technical-debt) - - [Stewardship](#stewardship) + - [Issue triaging](#issue-triaging) + - [Feature proposals](#feature-proposals) + - [Issue tracker guidelines](#issue-tracker-guidelines) + - [Issue weight](#issue-weight) + - [Regression issues](#regression-issues) + - [Technical debt](#technical-debt) + - [Stewardship](#stewardship) - [Merge requests](#merge-requests) - - [Merge request guidelines](#merge-request-guidelines) - - [Contribution acceptance criteria](#contribution-acceptance-criteria) -- [Changes for Stable Releases](#changes-for-stable-releases) + - [Merge request guidelines](#merge-request-guidelines) + - [Getting your merge request reviewed, approved, and merged](#getting-your-merge-request-reviewed-approved-and-merged) + - [Contribution acceptance criteria](#contribution-acceptance-criteria) - [Definition of done](#definition-of-done) - [Style guides](#style-guides) - [Code of conduct](#code-of-conduct) @@ -103,34 +106,125 @@ contributing to GitLab. ## Workflow labels -Labelling issues is described in the [GitLab Inc engineering workflow]. +To allow for asynchronous issue handling, we use [milestones][milestones-page] +and [labels][labels-page]. Leads and product managers handle most of the +scheduling into milestones. Labelling is a task for everyone. -## Implement design & UI elements +Most issues will have labels for at least one of the following: -Please see the [UX Guide for GitLab]. +- Type: ~"feature proposal", ~bug, ~customer, etc. +- Subject: ~wiki, ~"container registry", ~ldap, ~api, etc. +- Team: ~CI, ~Discussion, ~Edge, ~Frontend, ~Platform, etc. +- Priority: ~Deliverable, ~Stretch + +All labels, their meaning and priority are defined on the +[labels page][labels-page]. + +If you come across an issue that has none of these, and you're allowed to set +labels, you can _always_ add the team and type, and often also the subject. + +[milestones-page]: https://gitlab.com/gitlab-org/gitlab-ce/milestones +[labels-page]: https://gitlab.com/gitlab-org/gitlab-ce/labels + +### Type labels (~"feature proposal", ~bug, ~customer, etc.) + +Type labels are very important. They define what kind of issue this is. Every +issue should have one or more. + +Examples of type labels are ~"feature proposal", ~bug, ~customer, ~security, +and ~"direction". + +A number of type labels have a priority assigned to them, which automatically +makes them float to the top, depending on their importance. + +Type labels are always lowercase, but can have any color, besides blue (which is +already reserved for subject labels). + +The descriptions on the [labels page][labels-page] explain what falls under each type label. + +### Subject labels (~wiki, ~"container registry", ~ldap, ~api, etc.) + +Subject labels are labels that define what area or feature of GitLab this issue +hits. They are not always necessary, but very convenient. + +If you are an expert in a particular area, it makes it easier to find issues to +work on. You can also subscribe to those labels to receive an email each time an +issue is labelled with a subject label corresponding to your expertise. + +Examples of subject labels are ~wiki, ~"container registry", ~ldap, ~api, +~issues, ~"merge requests", ~labels, and ~"container registry". + +Subject labels are always colored blue and all-lowercase. + +### Team labels (~CI, ~Discussion, ~Edge, ~Frontend, ~Platform, etc.) + +Team labels specify what team is responsible for this issue. +Assigning a team label makes sure issues get the attention of the appropriate +people. -## Release retrospective and kickoff +The current team labels are ~Build, ~CI, ~Discussion, ~Documentation, ~Edge, +~Frontend, ~Gitaly, ~Platform, ~Prometheus, ~Release, and ~"UX". -### Retrospective +The descriptions on the [labels page][labels-page] explain what falls under the +responsibility of each team. -After each release, we have a retrospective call where we discuss what went well, -what went wrong, and what we can improve for the next release. The -[retrospective notes] are public and you are invited to comment on them. -If you're interested, you can even join the -[retrospective call][retro-kickoff-call], on the first working day after the -22nd at 6pm CET / 9am PST. +Team labels are always colored aqua, and are capitalized so that they show up as +the first label for any issue. -### Kickoff +### Priority labels (~Deliverable and ~Stretch) -Before working on the next release, we have a -kickoff call to explain what we expect to ship in the next release. The -[kickoff notes] are public and you are invited to comment on them. -If you're interested, you can even join the [kickoff call][retro-kickoff-call], -on the first working day after the 7th at 6pm CET / 9am PST.. +Priority labels help us clearly communicate expectations of the work for the +release. There are two levels of priority labels: -[retrospective notes]: https://docs.google.com/document/d/1nEkM_7Dj4bT21GJy0Ut3By76FZqCfLBmFQNVThmW2TY/edit?usp=sharing -[kickoff notes]: https://docs.google.com/document/d/1ElPkZ90A8ey_iOkTvUs_ByMlwKK6NAB2VOK5835wYK0/edit?usp=sharing -[retro-kickoff-call]: https://gitlab.zoom.us/j/918821206 +- ~Deliverable: Issues that are expected to be delivered in the current + milestone. +- ~Stretch: Issues that are a stretch goal for delivering in the current + milestone. If these issues are not done in the current release, they will + strongly be considered for the next release. + +### Label for community contributors (~"Accepting Merge Requests") + +Issues that are beneficial to our users, 'nice to haves', that we currently do +not have the capacity for or want to give the priority to, are labeled as +~"Accepting Merge Requests", so the community can make a contribution. + +Community contributors can submit merge requests for any issue they want, but +the ~"Accepting Merge Requests" label has a special meaning. It points to +changes that: + +1. We already agreed on, +1. Are well-defined, +1. Are likely to get accepted by a maintainer. + +We want to avoid a situation when a contributor picks an +~"Accepting Merge Requests" issue and then their merge request gets closed, +because we realize that it does not fit our vision, or we want to solve it in a +different way. + +We add the ~"Accepting Merge Requests" label to: + +- Low priority ~bug issues (i.e. we do not add it to the bugs that we want to +solve in the ~"Next Patch Release") +- Small ~"feature proposal" that do not need ~UX / ~"Product work", or for which +the ~UX / ~"Product work" is already done +- Small ~"technical debt" issues + +After adding the ~"Accepting Merge Requests" label, we try to estimate the +[weight](#issue-weight) of the issue. We use issue weight to let contributors +know how difficult the issue is. Additionally: + +- We advertise [~"Accepting Merge Requests" issues with weight < 5][up-for-grabs] + as suitable for people that have never contributed to GitLab before on the + [Up For Grabs campaign](http://up-for-grabs.net) +- We encourage people that have never contributed to any open source project to + look for [~"Accepting Merge Requests" issues with a weight of 1][firt-timers] + +[up-for-grabs]: https://gitlab.com/gitlab-org/gitlab-ce/issues?label_name=Accepting+Merge+Requests&scope=all&sort=weight_asc&state=opened +[firt-timers]: https://gitlab.com/gitlab-org/gitlab-ce/issues?label_name%5B%5D=Accepting+Merge+Requests&scope=all&sort=upvotes_desc&state=opened&weight=1 + +## Implement design & UI elements + +Please see the [UX Guide for GitLab]. ## Issue tracker @@ -154,6 +248,24 @@ If it happens that you know the solution to an existing bug, please first open the issue in order to keep track of it and then open the relevant merge request that potentially fixes it. +### Issue triaging + +Our issue triage policies are [described in our handbook]. You are very welcome +to help the GitLab team triage issues. We also organize [issue bash events] once +every quarter. + +The most important thing is making sure valid issues receive feedback from the +development team. Therefore the priority is mentioning developers that can help +on those issues. Please select someone with relevant experience from +[GitLab team][team]. If there is nobody mentioned with that expertise +look in the commit history for the affected files to find someone. Avoid +mentioning the lead developer, this is the person that is least likely to give a +timely response. If the involvement of the lead developer is needed the other +core team members will mention this person. + +[described in our handbook]: https://about.gitlab.com/handbook/engineering/issues/issue-triage-policies/ +[issue bash events]: https://gitlab.com/gitlab-org/gitlab-ce/issues/17815 + ### Feature proposals To create a feature proposal for CE, open an issue on the diff --git a/PROCESS.md b/PROCESS.md index fac3c22e09fc8..735120a369ebe 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -1,35 +1,48 @@ -# GitLab Contributing Process +<!-- START doctoc generated TOC please keep comment here to allow auto update --> +<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE --> +**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* + +- [GitLab Contributing Process](#gitlab-contributing-process) + - [Purpose of describing the contributing process](#purpose-of-describing-the-contributing-process) + - [Common actions](#common-actions) + - [Merge request coaching](#merge-request-coaching) + - [Workflow labels](#workflow-labels) + - [Assigning issues](#assigning-issues) + - [Be kind](#be-kind) + - [Feature freeze on the 7th for the release on the 22nd](#feature-freeze-on-the-7th-for-the-release-on-the-22nd) + - [Between the 1st and the 7th](#between-the-1st-and-the-7th) + - [On the 7th](#on-the-7th) + - [After the 7th](#after-the-7th) + - [Copy & paste responses](#copy-&-paste-responses) + - [Improperly formatted issue](#improperly-formatted-issue) + - [Issue report for old version](#issue-report-for-old-version) + - [Support requests and configuration questions](#support-requests-and-configuration-questions) + - [Code format](#code-format) + - [Issue fixed in newer version](#issue-fixed-in-newer-version) + - [Improperly formatted merge request](#improperly-formatted-merge-request) + - [Inactivity close of an issue](#inactivity-close-of-an-issue) + - [Inactivity close of a merge request](#inactivity-close-of-a-merge-request) + - [Accepting merge requests](#accepting-merge-requests) + - [Only accepting merge requests with green tests](#only-accepting-merge-requests-with-green-tests) + - [Closing down the issue tracker on GitHub](#closing-down-the-issue-tracker-on-github) + +<!-- END doctoc generated TOC please keep comment here to allow auto update --> + +# GitLab Core Team Contributing Process ## Purpose of describing the contributing process -Below we describe the contributing process to GitLab for two reasons. So that -contributors know what to expect from maintainers (possible responses, friendly -treatment, etc.). And so that maintainers know what to expect from contributors -(use the latest version, ensure that the issue is addressed, friendly treatment, -etc.). +Below we describe the contributing process to GitLab for two reasons: + +1. Contributors know what to expect from maintainers (possible responses, friendly + treatment, etc.) +1. Maintainers know what to expect from contributors (use the latest version, + ensure that the issue is addressed, friendly treatment, etc.). - [GitLab Inc engineers should refer to the engineering workflow document](https://about.gitlab.com/handbook/engineering/workflow/) ## Common actions -### Issue triaging - -Our issue triage policies are [described in our handbook]. You are very welcome -to help the GitLab team triage issues. We also organize [issue bash events] once -every quarter. - -The most important thing is making sure valid issues receive feedback from the -development team. Therefore the priority is mentioning developers that can help -on those issues. Please select someone with relevant experience from -[GitLab team][team]. If there is nobody mentioned with that expertise -look in the commit history for the affected files to find someone. Avoid -mentioning the lead developer, this is the person that is least likely to give a -timely response. If the involvement of the lead developer is needed the other -core team members will mention this person. - -[described in our handbook]: https://about.gitlab.com/handbook/engineering/issues/issue-triage-policies/ -[issue bash events]: https://gitlab.com/gitlab-org/gitlab-ce/issues/17815 - ### Merge request coaching Several people from the [GitLab team][team] are helping community members to get @@ -37,12 +50,6 @@ their contributions accepted by meeting our [Definition of done][done]. What you can expect from them is described at https://about.gitlab.com/jobs/merge-request-coach/. -## Workflow labels - -Labelling issues is described in the [GitLab Inc engineering workflow]. - -[GitLab Inc engineering workflow]: https://about.gitlab.com/handbook/engineering/workflow/#labelling-issues - ## Assigning issues If an issue is complex and needs the attention of a specific person, assignment is a good option but assigning issues might discourage other people from contributing to that issue. We need all the contributions we can get so this should never be discouraged. Also, an assigned person might not have time for a few weeks, so others should feel free to takeover. -- GitLab From eb45582f55cae42acc41fa228f4797b4067c01dc Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Tue, 2 May 2017 12:25:30 +0200 Subject: [PATCH 136/363] Fix builds controller spec related to protected actions --- spec/controllers/projects/builds_controller_spec.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/spec/controllers/projects/builds_controller_spec.rb b/spec/controllers/projects/builds_controller_spec.rb index 22193eac6722f..3bf03c88ff55a 100644 --- a/spec/controllers/projects/builds_controller_spec.rb +++ b/spec/controllers/projects/builds_controller_spec.rb @@ -260,6 +260,8 @@ def post_retry end describe 'POST play' do + let(:project) { create(:project, :public) } + before do project.add_developer(user) sign_in(user) -- GitLab From 4134d700623404948f163349882caf4a6d940cf3 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Tue, 2 May 2017 12:03:58 +0100 Subject: [PATCH 137/363] Component specs --- .../deploy_keys/components/key.vue | 5 - .../deploy_keys/components/keys_panel.vue | 2 +- .../deploy_keys/components/action_btn_spec.js | 70 ++++++++++++++ .../deploy_keys/components/app_spec.js | 4 + .../deploy_keys/components/key_spec.js | 92 +++++++++++++++++++ .../deploy_keys/components/keys_panel_spec.js | 70 ++++++++++++++ 6 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 spec/javascripts/deploy_keys/components/action_btn_spec.js create mode 100644 spec/javascripts/deploy_keys/components/key_spec.js create mode 100644 spec/javascripts/deploy_keys/components/keys_panel_spec.js diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue index 1b47cceba78a1..0a06a481b9686 100644 --- a/app/assets/javascripts/deploy_keys/components/key.vue +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -7,11 +7,6 @@ type: Object, required: true, }, - enabled: { - type: Boolean, - required: false, - default: true, - }, store: { type: Object, required: true, diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue index e5113fe5245ce..eccc470578b6e 100644 --- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue +++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue @@ -37,7 +37,7 @@ v-if="keys.length"> <li v-for="deployKey in keys" - key="deployKey.id"> + :key="deployKey.id"> <key :deploy-key="deployKey" :store="store" /> diff --git a/spec/javascripts/deploy_keys/components/action_btn_spec.js b/spec/javascripts/deploy_keys/components/action_btn_spec.js new file mode 100644 index 0000000000000..5b93fbc5575d7 --- /dev/null +++ b/spec/javascripts/deploy_keys/components/action_btn_spec.js @@ -0,0 +1,70 @@ +import Vue from 'vue'; +import eventHub from '~/deploy_keys/eventhub'; +import actionBtn from '~/deploy_keys/components/action_btn.vue'; + +describe('Deploy keys action btn', () => { + const data = getJSONFixture('deploy_keys/keys.json'); + const deployKey = data.enabled_keys[0]; + let vm; + + beforeEach((done) => { + const ActionBtnComponent = Vue.extend(actionBtn); + + vm = new ActionBtnComponent({ + propsData: { + deployKey, + type: 'enable', + }, + }).$mount(); + + setTimeout(done); + }); + + it('renders the type as uppercase', () => { + expect( + vm.$el.textContent.trim(), + ).toBe('Enable'); + }); + + it('sends eventHub event with btn type', (done) => { + spyOn(eventHub, '$emit'); + + vm.$el.click(); + + setTimeout(() => { + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('enable.key', deployKey); + + done(); + }); + }); + + it('shows loading spinner after click', (done) => { + vm.$el.click(); + + setTimeout(() => { + expect( + vm.$el.querySelector('.fa'), + ).toBeDefined(); + + done(); + }); + }); + + it('disables button after click', (done) => { + vm.$el.click(); + + setTimeout(() => { + expect( + vm.$el.classList.contains('disabled'), + ).toBeTruthy(); + + expect( + vm.$el.getAttribute('disabled'), + ).toBe('disabled'); + + done(); + }); + }); +}); diff --git a/spec/javascripts/deploy_keys/components/app_spec.js b/spec/javascripts/deploy_keys/components/app_spec.js index e061992b2bda8..43b8f71850883 100644 --- a/spec/javascripts/deploy_keys/components/app_spec.js +++ b/spec/javascripts/deploy_keys/components/app_spec.js @@ -126,4 +126,8 @@ describe('Deploy keys app component', () => { expect(vm.disableKey).toHaveBeenCalledWith(key); }); + + it('hasKeys returns true when there are keys', () => { + expect(vm.hasKeys).toEqual(3); + }); }); diff --git a/spec/javascripts/deploy_keys/components/key_spec.js b/spec/javascripts/deploy_keys/components/key_spec.js new file mode 100644 index 0000000000000..793ab8c451d85 --- /dev/null +++ b/spec/javascripts/deploy_keys/components/key_spec.js @@ -0,0 +1,92 @@ +import Vue from 'vue'; +import DeployKeysStore from '~/deploy_keys/store'; +import key from '~/deploy_keys/components/key.vue'; + +describe('Deploy keys key', () => { + let vm; + const KeyComponent = Vue.extend(key); + const data = getJSONFixture('deploy_keys/keys.json'); + const createComponent = (deployKey) => { + const store = new DeployKeysStore(); + store.keys = data; + + vm = new KeyComponent({ + propsData: { + deployKey, + store, + }, + }).$mount(); + }; + + describe('enabled key', () => { + const deployKey = data.enabled_keys[0]; + + beforeEach((done) => { + createComponent(deployKey); + + setTimeout(done); + }); + + it('renders the keys title', () => { + expect( + vm.$el.querySelector('.title').textContent.trim(), + ).toContain('My title'); + }); + + it('renders human friendly formatted created date', () => { + expect( + vm.$el.querySelector('.key-created-at').textContent.trim(), + ).toBe(`created ${gl.utils.getTimeago().format(deployKey.created_at)}`); + }); + + it('shows remove button', () => { + expect( + vm.$el.querySelector('.btn').textContent.trim(), + ).toBe('Remove'); + }); + + it('shows write access text when key has write access', (done) => { + vm.deployKey.can_push = true; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.write-access-allowed'), + ).not.toBeNull(); + + expect( + vm.$el.querySelector('.write-access-allowed').textContent.trim(), + ).toBe('Write access allowed'); + + done(); + }); + }); + }); + + describe('public keys', () => { + const deployKey = data.public_keys[0]; + + beforeEach((done) => { + createComponent(deployKey); + + setTimeout(done); + }); + + it('shows enable button', () => { + expect( + vm.$el.querySelector('.btn').textContent.trim(), + ).toBe('Enable'); + }); + + it('shows disable button when key is enabled', (done) => { + vm.store.keys.enabled_keys.push(deployKey); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn').textContent.trim(), + ).toBe('Disable'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/deploy_keys/components/keys_panel_spec.js b/spec/javascripts/deploy_keys/components/keys_panel_spec.js new file mode 100644 index 0000000000000..416b80525fc2d --- /dev/null +++ b/spec/javascripts/deploy_keys/components/keys_panel_spec.js @@ -0,0 +1,70 @@ +import Vue from 'vue'; +import DeployKeysStore from '~/deploy_keys/store'; +import deployKeysPanel from '~/deploy_keys/components/keys_panel.vue'; + +describe('Deploy keys panel', () => { + const data = getJSONFixture('deploy_keys/keys.json'); + let vm; + + beforeEach((done) => { + const DeployKeysPanelComponent = Vue.extend(deployKeysPanel); + const store = new DeployKeysStore(); + store.keys = data; + + vm = new DeployKeysPanelComponent({ + propsData: { + title: 'test', + keys: data.enabled_keys, + showHelpBox: true, + store, + }, + }).$mount(); + + setTimeout(done); + }); + + it('renders the title with keys count', () => { + expect( + vm.$el.querySelector('h5').textContent.trim(), + ).toContain('test'); + + expect( + vm.$el.querySelector('h5').textContent.trim(), + ).toContain('(1)'); + }); + + it('renders list of keys', () => { + expect( + vm.$el.querySelectorAll('li').length, + ).toBe(1); + }); + + it('renders help box if keys are empty', (done) => { + vm.keys = []; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.settings-message'), + ).toBeDefined(); + + expect( + vm.$el.querySelector('.settings-message').textContent.trim(), + ).toBe('No deploy keys found. Create one with the form above.'); + + done(); + }); + }); + + it('does not render help box if keys are empty & showHelpBox is false', (done) => { + vm.keys = []; + vm.showHelpBox = false; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.settings-message'), + ).toBeNull(); + + done(); + }); + }); +}); -- GitLab From 48bb8921240387ce7d8f9401c98e13a9b3d0100f Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Tue, 2 May 2017 13:21:06 +0200 Subject: [PATCH 138/363] Hide environment external URL button if not defined --- .../projects/environments/terminal.html.haml | 5 +-- .../environments/terminal.html.haml_spec.rb | 32 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 spec/views/projects/environments/terminal.html.haml_spec.rb diff --git a/app/views/projects/environments/terminal.html.haml b/app/views/projects/environments/terminal.html.haml index c8363087d6aa1..4c4aa0baff3fa 100644 --- a/app/views/projects/environments/terminal.html.haml +++ b/app/views/projects/environments/terminal.html.haml @@ -16,8 +16,9 @@ .col-sm-6 .nav-controls - = link_to @environment.external_url, class: 'btn btn-default', target: '_blank', rel: 'noopener noreferrer nofollow' do - = icon('external-link') + - if @environment.external_url.present? + = link_to @environment.external_url, class: 'btn btn-default', target: '_blank', rel: 'noopener noreferrer nofollow' do + = icon('external-link') = render 'projects/deployments/actions', deployment: @environment.last_deployment .terminal-container{ class: container_class } diff --git a/spec/views/projects/environments/terminal.html.haml_spec.rb b/spec/views/projects/environments/terminal.html.haml_spec.rb new file mode 100644 index 0000000000000..d2e4722522651 --- /dev/null +++ b/spec/views/projects/environments/terminal.html.haml_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' + +describe 'projects/environments/terminal' do + let!(:environment) { create(:environment, :with_review_app) } + + before do + assign(:environment, environment) + assign(:project, environment.project) + + allow(view).to receive(:can?).and_return(true) + end + + context 'when environment has external URL' do + it 'shows external URL button' do + environment.update_attribute(:external_url, 'https://gitlab.com') + + render + + expect(rendered).to have_link(nil, href: 'https://gitlab.com') + end + end + + context 'when environment does not have external URL' do + it 'shows external URL button' do + environment.update_attribute(:external_url, nil) + + render + + expect(rendered).not_to have_link(nil, href: 'https://gitlab.com') + end + end +end -- GitLab From 3aa92cb5cbe8456c845e16d14489591dd81dbcb3 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Tue, 2 May 2017 13:26:53 +0200 Subject: [PATCH 139/363] Add changelog entry for external env URL btn fix --- ...gb-hide-environment-external-url-btn-when-not-provided.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelogs/unreleased/fix-gb-hide-environment-external-url-btn-when-not-provided.yml diff --git a/changelogs/unreleased/fix-gb-hide-environment-external-url-btn-when-not-provided.yml b/changelogs/unreleased/fix-gb-hide-environment-external-url-btn-when-not-provided.yml new file mode 100644 index 0000000000000..66158e337fdf0 --- /dev/null +++ b/changelogs/unreleased/fix-gb-hide-environment-external-url-btn-when-not-provided.yml @@ -0,0 +1,4 @@ +--- +title: Hide external environment URL button on terminal page if URL is not defined +merge_request: 11029 +author: -- GitLab From 702b291f81b576c08f8e194d87fb779b4ce0fd6c Mon Sep 17 00:00:00 2001 From: James Lopez <james@jameslopez.es> Date: Tue, 2 May 2017 14:55:32 +0200 Subject: [PATCH 140/363] remove gl_project_id for I/E version update --- app/models/commit_status.rb | 6 ------ lib/gitlab/import_export/import_export.yml | 1 - 2 files changed, 7 deletions(-) diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 2c4033146bff9..75d04fd2b08c3 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -142,12 +142,6 @@ def auto_canceled? canceled? && auto_canceled_by_id? end - # Added in 9.0 to keep backward compatibility for projects exported in 8.17 - # and prior. - def gl_project_id - 'dummy' - end - def detailed_status(current_user) Gitlab::Ci::Status::Factory .new(self, current_user) diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index 899a656776812..70d2ef7103cf2 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -87,7 +87,6 @@ methods: - :type statuses: - :type - - :gl_project_id services: - :type merge_request_diff: -- GitLab From 5ed2465c1af700bd32242d0f1f04a1968ee65039 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Tue, 2 May 2017 17:00:57 +0100 Subject: [PATCH 141/363] Fixed tests --- spec/features/projects/merge_request_button_spec.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/spec/features/projects/merge_request_button_spec.rb b/spec/features/projects/merge_request_button_spec.rb index 1a4d613ef9417..1370ab1c5217b 100644 --- a/spec/features/projects/merge_request_button_spec.rb +++ b/spec/features/projects/merge_request_button_spec.rb @@ -25,7 +25,7 @@ it 'shows Create merge request button' do href = new_namespace_project_merge_request_path(project.namespace, project, - merge_request: { source_branch: "'test'", + merge_request: { source_branch: 'feature', target_branch: 'master' }) visit url @@ -69,7 +69,7 @@ it 'shows Create merge request button' do href = new_namespace_project_merge_request_path(forked_project.namespace, forked_project, - merge_request: { source_branch: "'test'", + merge_request: { source_branch: 'feature', target_branch: 'master' }) visit fork_url @@ -93,16 +93,16 @@ context 'on compare page' do it_behaves_like 'Merge request button only shown when allowed' do let(:label) { 'Create merge request' } - let(:url) { namespace_project_compare_path(project.namespace, project, from: 'master', to: "'test'") } - let(:fork_url) { namespace_project_compare_path(forked_project.namespace, forked_project, from: 'master', to: "'test'") } + let(:url) { namespace_project_compare_path(project.namespace, project, from: 'master', to: 'feature') } + let(:fork_url) { namespace_project_compare_path(forked_project.namespace, forked_project, from: 'master', to: 'feature') } end end context 'on commits page' do it_behaves_like 'Merge request button only shown when allowed' do let(:label) { 'Create merge request' } - let(:url) { namespace_project_commits_path(project.namespace, project, "'test'") } - let(:fork_url) { namespace_project_commits_path(forked_project.namespace, forked_project, "'test'") } + let(:url) { namespace_project_commits_path(project.namespace, project, 'feature') } + let(:fork_url) { namespace_project_commits_path(forked_project.namespace, forked_project, 'feature') } end end end -- GitLab From 3b82444eb7791e58e3e0ba2f08b8ccde48e3d4c6 Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Tue, 2 May 2017 13:13:28 -0500 Subject: [PATCH 142/363] Fix Rubocop complains. --- .../initializers/gettext_rails_i18n_patch.rb | 1 - lib/tasks/gettext.rake | 23 ++----------------- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/config/initializers/gettext_rails_i18n_patch.rb b/config/initializers/gettext_rails_i18n_patch.rb index 616f6c45b0add..69118f464caa0 100644 --- a/config/initializers/gettext_rails_i18n_patch.rb +++ b/config/initializers/gettext_rails_i18n_patch.rb @@ -25,7 +25,6 @@ def self.convert_to_code(text) module GettextI18nRailsJs module Parser module Javascript - # This is required to tell the `rake gettext:find` script to use the Javascript # parser for *.vue files. # diff --git a/lib/tasks/gettext.rake b/lib/tasks/gettext.rake index cc18616d2a785..0aa21a4bd13ba 100644 --- a/lib/tasks/gettext.rake +++ b/lib/tasks/gettext.rake @@ -4,27 +4,8 @@ namespace :gettext do # Customize list of translatable files # See: https://github.com/grosser/gettext_i18n_rails#customizing-list-of-translatable-files def files_to_translate - folders = [ - "app", - "lib", - "config", - locale_path - ].join(",") - - exts = [ - "rb", - "erb", - "haml", - "slim", - "rhtml", - "js", - "jsx", - "vue", - "coffee", - "handlebars", - "hbs", - "mustache" - ].join(",") + folders = %W(app lib config #{locale_path}).join(',') + exts = %w(rb erb haml slim rhtml js jsx vue coffee handlebars hbs mustache).join(',') Dir.glob( "{#{folders}}/**/*.{#{exts}}" -- GitLab From 290cba5b23118bd40d64e5c6187afc820a6a0e38 Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Tue, 2 May 2017 12:53:03 -0600 Subject: [PATCH 143/363] remove curr and previous states - extract tasks logic to tasks action --- .../javascripts/issue_show/actions/tasks.js | 27 ++++++++ .../issue_show/issue_title_description.vue | 68 ++++++------------- app/controllers/projects/issues_controller.rb | 1 + app/views/projects/issues/_issue.html.haml | 4 -- 4 files changed, 48 insertions(+), 52 deletions(-) create mode 100644 app/assets/javascripts/issue_show/actions/tasks.js diff --git a/app/assets/javascripts/issue_show/actions/tasks.js b/app/assets/javascripts/issue_show/actions/tasks.js new file mode 100644 index 0000000000000..0f6e71ce7ac97 --- /dev/null +++ b/app/assets/javascripts/issue_show/actions/tasks.js @@ -0,0 +1,27 @@ +export default (apiData, tasks) => { + const $tasks = $('#task_status'); + const $tasksShort = $('#task_status_short'); + const $issueableHeader = $('.issuable-header'); + const zeroData = { api: null, tasks: null }; + + if ($tasks.length === 0) { + if (!(apiData.task_status.indexOf('0 of 0') >= 0)) { + $issueableHeader.append(`<span id="task_status">${apiData.task_status}</span>`); + } else { + $issueableHeader.append('<span id="task_status"></span>'); + } + } else { + zeroData.api = apiData.task_status.indexOf('0 of 0') >= 0; + zeroData.tasks = tasks.indexOf('0 of 0') >= 0; + } + + if ($tasks && !zeroData.api) { + $tasks.text(apiData.task_status); + $tasksShort.text(apiData.task_status); + } else if (zeroData.tasks) { + $issueableHeader.append(`<span id="task_status">${apiData.task_status}</span>`); + } else if (zeroData.api) { + $tasks.remove(); + $tasksShort.remove(); + } +}; diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index 7862923c00442..f6c3308388cd5 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -2,6 +2,7 @@ import Visibility from 'visibilityjs'; import Poll from './../lib/utils/poll'; import Service from './services/index'; +import tasks from './actions/tasks'; export default { props: { @@ -30,14 +31,13 @@ export default { return { poll, apiData: {}, - current: true, timeoutId: null, title: '<span></span>', titleText: '', description: '<span></span>', descriptionText: '', descriptionChange: false, - previousDescription: null, + tasks: '0 of 0', }; }, methods: { @@ -46,18 +46,7 @@ export default { this.triggerAnimation(); }, updateTaskHTML() { - const tasks = document.querySelector('#task_status_short'); - const zeroTasks = this.apiData.task_status.indexOf('0 of 0') >= 0; - - if (tasks && !zeroTasks) { - tasks.innerText = this.apiData.task_status; - } else if (!tasks && !zeroTasks) { - $('.issuable-header').append(` - <span id="task_status_short" class="hidden-md hidden-lg">${this.apiData.task_status}</span> - `); - } else if (zeroTasks) { - $('#task_status_short').remove(); - } + tasks(this.apiData, this.tasks); }, elementsToVisualize(noTitleChange, noDescriptionChange) { const elementStack = []; @@ -71,11 +60,7 @@ export default { // only change to true when we need to bind TaskLists the html of description this.descriptionChange = true; this.updateTaskHTML(); - - if (this.description !== '<span></span>') { - this.previousDescription = this.description; - } - + this.tasks = this.apiData.task_status; elementStack.push(this.$el.querySelector('.wiki')); } @@ -129,24 +114,18 @@ export default { this.animate(title, description, elementsToVisualize); }, - handleCurrentOrPrevious() { - this.descriptionChange = true; - this.current = !this.current; + updateEditedTimeAgo() { + const toolTipTime = gl.utils.formatDate(this.apiData.updated_at); + const $timeAgoNode = $('.issue_edited_ago'); + + $timeAgoNode.attr('datetime', this.apiData.updated_at); + $timeAgoNode.attr('data-original-title', toolTipTime); }, }, computed: { descriptionClass() { return `description ${this.candescription} is-task-list-enabled`; }, - showDescription() { - return this.current ? this.description : this.previousDescription; - }, - previousOrCurrentButtonText() { - return this.current ? '<< Show Previous Decription' : 'Show Current Description >>'; - }, - prevCurrBtnClass() { - return this.current ? 'btn btn-sm btn-default' : 'btn btn-sm btn-primary'; - }, }, created() { if (!Visibility.hidden()) { @@ -164,18 +143,19 @@ export default { updated() { // if new html is injected (description changed) - bind TaskList and call renderGFM if (this.descriptionChange) { + this.updateEditedTimeAgo(); + $(this.$refs['issue-content-container-gfm-entry']).renderGFM(); - if (this.current) { - const tl = new gl.TaskList({ - dataType: 'issue', - fieldName: 'description', - selector: '.detail-page-description', - }); + const tl = new gl.TaskList({ + dataType: 'issue', + fieldName: 'description', + selector: '.detail-page-description', + }); - return tl; - } + return tl && null; } + return null; }, }; @@ -188,17 +168,9 @@ export default { :class="descriptionClass" v-if="description" > - <div v-if="previousDescription"> - <button - :class="prevCurrBtnClass" - @click="handleCurrentOrPrevious" - > - {{ previousOrCurrentButtonText }} - </button> - </div><br> <div class="wiki issue-realtime-trigger-pulse" - v-html="showDescription" + v-html="description" ref="issue-content-container-gfm-entry" > </div> diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 7932b92c00549..87370dec3ca79 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -206,6 +206,7 @@ def rendered_title description_text: @issue.description, task_status: @issue.task_status, issue_number: @issue.iid, + updated_at: @issue.updated_at, } end diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 0e3902c066ad2..6199569a20a9e 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -37,10 +37,6 @@ - issue.labels.each do |label| = link_to_label(label, subject: issue.project, css_class: 'label-link') - - if issue.tasks? - - %span.task-status - = issue.task_status .pull-right.issue-updated-at %span updated #{time_ago_with_tooltip(issue.updated_at, placement: 'bottom', html_class: 'issue_update_ago')} -- GitLab From 165bcec30506380efa912b3dba25815aeea9c92f Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Tue, 2 May 2017 20:17:45 +0100 Subject: [PATCH 144/363] [ci skip] added changelog entry --- changelogs/unreleased/balsalmiq-support.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelogs/unreleased/balsalmiq-support.yml diff --git a/changelogs/unreleased/balsalmiq-support.yml b/changelogs/unreleased/balsalmiq-support.yml new file mode 100644 index 0000000000000..56a0b4c83fae6 --- /dev/null +++ b/changelogs/unreleased/balsalmiq-support.yml @@ -0,0 +1,4 @@ +--- +title: Added balsamiq file viewer +merge_request: 10564 +author: -- GitLab From f6df135717a09b7598f274a1c9d877ffcb59be26 Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Tue, 2 May 2017 15:01:23 -0600 Subject: [PATCH 145/363] test realtime changes and hit more branches --- .../issue_title_description_spec.js | 41 ++++++++++--------- spec/javascripts/issue_show/mock_data.js | 22 +++++++--- 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/spec/javascripts/issue_show/issue_title_description_spec.js b/spec/javascripts/issue_show/issue_title_description_spec.js index 4c7d4748693dd..e1d0f51f9cb12 100644 --- a/spec/javascripts/issue_show/issue_title_description_spec.js +++ b/spec/javascripts/issue_show/issue_title_description_spec.js @@ -7,9 +7,12 @@ import issueShowData from './mock_data'; window.$ = $; -const issueShowInterceptor = (request, next) => { - next(request.respondWith(JSON.stringify(issueShowData), { +const issueShowInterceptor = data => (request, next) => { + next(request.respondWith(JSON.stringify(data), { status: 200, + headers: { + 'POLL-INTERVAL': 1, + }, })); }; @@ -22,16 +25,15 @@ describe('Issue Title', () => { beforeEach(() => { comps.IssueTitleComponent = Vue.extend(issueTitle); - Vue.http.interceptors.push(issueShowInterceptor); }); afterEach(() => { - Vue.http.interceptors = _.without( - Vue.http.interceptors, issueShowInterceptor, - ); + Vue.http.interceptors = _.without(Vue.http.interceptors, issueShowInterceptor); }); - it('should render a title', (done) => { + it('should render a title/description and update title/description on update', (done) => { + Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest)); + const issueShowComponent = new comps.IssueTitleComponent({ propsData: { candescription: '.css-stuff', @@ -41,22 +43,21 @@ describe('Issue Title', () => { // need setTimeout because actual setTimeout in code :P setTimeout(() => { - expect(document.querySelector('title').innerText) - .toContain('this is a title (#1)'); - - expect(issueShowComponent.$el.querySelector('.title').innerHTML) - .toContain('<p>this is a title</p>'); - - expect(issueShowComponent.$el.querySelector('.wiki').innerHTML) - .toContain('<p>this is a description!</p>'); + expect(document.querySelector('title').innerText).toContain('this is a title (#1)'); + expect(issueShowComponent.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>'); + expect(issueShowComponent.$el.querySelector('.wiki').innerHTML).toContain('<p>this is a description!</p>'); + expect(issueShowComponent.$el.querySelector('.js-task-list-field').innerText).toContain('this is a description'); - const hiddenText = issueShowComponent.$el - .querySelector('.js-task-list-field').innerText; + Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest)); - expect(hiddenText) - .toContain('this is a description'); + setTimeout(() => { + expect(document.querySelector('title').innerText).toContain('2 (#1)'); + expect(issueShowComponent.$el.querySelector('.title').innerHTML).toContain('<p>2</p>'); + expect(issueShowComponent.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>'); + expect(issueShowComponent.$el.querySelector('.js-task-list-field').innerText).toContain('42'); - done(); + done(); + }, 10); }, 10); // 10ms is just long enough for the update hook to fire }); diff --git a/spec/javascripts/issue_show/mock_data.js b/spec/javascripts/issue_show/mock_data.js index b7b01391a18a8..5d234ebf25425 100644 --- a/spec/javascripts/issue_show/mock_data.js +++ b/spec/javascripts/issue_show/mock_data.js @@ -1,8 +1,18 @@ export default { - title: '<p>this is a title</p>', - title_text: 'this is a title', - description: '<p>this is a description!</p>', - description_text: 'this is a description', - issue_number: 1, - task_status: '2/4 completed', + initialRequest: { + title: '<p>this is a title</p>', + title_text: 'this is a title', + description: '<p>this is a description!</p>', + description_text: 'this is a description', + issue_number: 1, + task_status: '2 of 4 completed', + }, + secondRequest: { + title: '<p>2</p>', + title_text: '2', + description: '<p>42</p>', + description_text: '42', + issue_number: 1, + task_status: '0 of 0 completed', + }, }; -- GitLab From c50c5e92e9619cbb2a0ae7cc49697af5df46988c Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Tue, 2 May 2017 20:16:39 -0600 Subject: [PATCH 146/363] issue_spec - getting closer --- .../issue_title_description_spec.js | 6 +-- spec/javascripts/issue_show/mock_data.js | 8 +++ spec/javascripts/issue_spec.js | 52 +++++++++++++++++-- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/spec/javascripts/issue_show/issue_title_description_spec.js b/spec/javascripts/issue_show/issue_title_description_spec.js index e1d0f51f9cb12..3877ed2164b90 100644 --- a/spec/javascripts/issue_show/issue_title_description_spec.js +++ b/spec/javascripts/issue_show/issue_title_description_spec.js @@ -11,14 +11,12 @@ const issueShowInterceptor = data => (request, next) => { next(request.respondWith(JSON.stringify(data), { status: 200, headers: { - 'POLL-INTERVAL': 1, + 'POLL-INTERVAL': 10, }, })); }; describe('Issue Title', () => { - document.body.innerHTML = '<span id="task_status"></span>'; - const comps = { IssueTitleComponent: {}, }; @@ -57,7 +55,7 @@ describe('Issue Title', () => { expect(issueShowComponent.$el.querySelector('.js-task-list-field').innerText).toContain('42'); done(); - }, 10); + }, 20); }, 10); // 10ms is just long enough for the update hook to fire }); diff --git a/spec/javascripts/issue_show/mock_data.js b/spec/javascripts/issue_show/mock_data.js index 5d234ebf25425..ad5a7b63470ad 100644 --- a/spec/javascripts/issue_show/mock_data.js +++ b/spec/javascripts/issue_show/mock_data.js @@ -15,4 +15,12 @@ export default { issue_number: 1, task_status: '0 of 0 completed', }, + issueSpecRequest: { + title: '<p>this is a title</p>', + title_text: 'this is a title', + description: '<li class="task-list-item enabled"><input type="checkbox" class="task-list-item-checkbox">Task List Item</li>', + description_text: '- [ ] Task List Item', + issue_number: 1, + task_status: '0 of 1 completed', + }, }; diff --git a/spec/javascripts/issue_spec.js b/spec/javascripts/issue_spec.js index 9a2570ef7e93c..e2e9cc01424d4 100644 --- a/spec/javascripts/issue_spec.js +++ b/spec/javascripts/issue_spec.js @@ -1,5 +1,10 @@ /* eslint-disable space-before-function-paren, one-var, one-var-declaration-per-line, no-use-before-define, comma-dangle, max-len */ import Issue from '~/issue'; +import Vue from 'vue'; +import '~/render_math'; +import '~/render_gfm'; +import IssueTitle from '~/issue_show/issue_title_description.vue'; +import issueShowData from './issue_show/mock_data'; require('~/lib/utils/text_utility'); @@ -75,16 +80,53 @@ describe('Issue', function() { expect($btnReopen).toHaveText('Reopen issue'); } - describe('task lists', function() { + fdescribe('task lists', function() { + const issueShowInterceptor = data => (request, next) => { + next(request.respondWith(JSON.stringify(data), { + status: 200, + })); + }; + beforeEach(function() { loadFixtures('issues/issue-with-task-list.html.raw'); this.issue = new Issue(); + Vue.http.interceptors.push(issueShowInterceptor(issueShowData.issueSpecRequest)); + }); + + afterEach(function() { + Vue.http.interceptors = _.without(Vue.http.interceptors, issueShowInterceptor); }); - it('modifies the Markdown field', function() { - spyOn(jQuery, 'ajax').and.stub(); - $('input[type=checkbox]').attr('checked', true).trigger('change'); - expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); + it('modifies the Markdown field', function(done) { + // gotta actually render it for jquery to find elements + const vm = new Vue({ + el: document.querySelector('.issue-title-entrypoint'), + components: { + IssueTitle, + }, + render: createElement => createElement(IssueTitle, { + props: { + candescription: '.js-task-list-container', + endpoint: '/gitlab-org/gitlab-shell/issues/9/rendered_title', + }, + }), + }); + + setTimeout(() => { + spyOn(jQuery, 'ajax').and.stub(); + + const description = '<li class="task-list-item enabled"><input type="checkbox" class="task-list-item-checkbox"> Task List Item</li>'; + + expect(document.querySelector('title').innerText).toContain('this is a title (#1)'); + expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>'); + expect(vm.$el.querySelector('.wiki').innerHTML).toContain(description); + expect(vm.$el.querySelector('.js-task-list-field').value).toContain('- [ ] Task List Item'); + + // somehow the dom does not have a closest `.js-task-list.field` to the `.task-list-item-checkbox` + $('input[type=checkbox]').attr('checked', true).trigger('change'); + expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); + done(); + }, 10); }); it('submits an ajax request on tasklist:changed', function() { -- GitLab From b29f91b06cc90f48c8322175ad92087579f96bdd Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Tue, 2 May 2017 20:17:10 -0600 Subject: [PATCH 147/363] remove fdescribe [ci skip] --- spec/javascripts/issue_spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/javascripts/issue_spec.js b/spec/javascripts/issue_spec.js index e2e9cc01424d4..788d7ccc5e42c 100644 --- a/spec/javascripts/issue_spec.js +++ b/spec/javascripts/issue_spec.js @@ -80,7 +80,7 @@ describe('Issue', function() { expect($btnReopen).toHaveText('Reopen issue'); } - fdescribe('task lists', function() { + describe('task lists', function() { const issueShowInterceptor = data => (request, next) => { next(request.respondWith(JSON.stringify(data), { status: 200, -- GitLab From 6d5364cfb0e39f49afac9b465f37bd19185c3755 Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Tue, 2 May 2017 23:36:36 -0500 Subject: [PATCH 148/363] First round of updates from the code review. --- .../components/stage_code_component.js | 2 +- .../components/stage_issue_component.js | 2 +- .../components/stage_plan_component.js | 2 +- .../components/stage_production_component.js | 2 +- .../components/stage_review_component.js | 2 +- .../components/stage_staging_component.js | 2 +- .../components/total_time_component.js | 6 +- app/assets/javascripts/locale/de/app.js | 2 +- app/assets/javascripts/locale/en/app.js | 2 +- app/assets/javascripts/locale/es/app.js | 2 +- app/controllers/application_controller.rb | 15 +++-- app/controllers/profiles_controller.rb | 1 - .../projects/cycle_analytics_controller.rb | 1 - app/models/user.rb | 1 + app/views/profiles/show.html.haml | 2 +- .../projects/cycle_analytics/show.html.haml | 2 +- config/initializers/fast_gettext.rb | 4 +- ...3035209_add_preferred_language_to_users.rb | 4 +- lib/gitlab/i18n.rb | 12 +++- locale/de/gitlab.po | 51 +++++++++-------- locale/en/gitlab.po | 51 +++++++++-------- locale/es/gitlab.po | 54 +++++++++--------- locale/gitlab.pot | 55 +++++++++---------- 23 files changed, 143 insertions(+), 134 deletions(-) diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js index 0bd15ee773d34..f22299898a5c4 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js @@ -32,7 +32,7 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({ <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> </span> <span> - {{ __('by') }} + {{ __('Author|by') }} <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> </span> </div> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js index b6bf9a2572e8f..f400fd2fb142c 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js @@ -32,7 +32,7 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({ <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> </span> <span> - {{ __('by') }} + {{ __('Author|by') }} <a :href="issue.author.webUrl" class="issue-author-link"> {{ issue.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js index 116c118b82413..b85b8902fc7c5 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js @@ -31,7 +31,7 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({ </a> </h5> <span> - {{ __('First') }} + {{ __('OfFirstTime|First') }} <span class="commit-icon">${iconCommit}</span> <a :href="commit.commitUrl" class="commit-hash-link monospace">{{ commit.shortSha }}</a> {{ __('pushed by') }} diff --git a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js index 18fb09d6d2f95..18c30f490fd76 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js @@ -32,7 +32,7 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({ <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> </span> <span> - {{ __('by') }} + {{ __('Author|by') }} <a :href="issue.author.webUrl" class="issue-author-link"> {{ issue.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js index af5f1b42ea2b7..988067601284b 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js @@ -32,7 +32,7 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({ <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> </span> <span> - {{ __('by') }} + {{ __('Author|by') }} <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> </span> <template v-if="mergeRequest.state === 'closed'"> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js index a0bd1bab96e55..f13294c7af707 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js @@ -32,7 +32,7 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({ </h5> <span> <a :href="build.url" class="build-date">{{ build.date }}</a> - {{ __('by') }} + {{ __('Author|by') }} <a :href="build.author.webUrl" class="issue-author-link"> {{ build.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.js b/app/assets/javascripts/cycle_analytics/components/total_time_component.js index 48cc0a5c58dbb..1eef7d9a8738d 100644 --- a/app/assets/javascripts/cycle_analytics/components/total_time_component.js +++ b/app/assets/javascripts/cycle_analytics/components/total_time_component.js @@ -13,9 +13,9 @@ global.cycleAnalytics.TotalTimeComponent = Vue.extend({ <span class="total-time"> <template v-if="Object.keys(time).length"> <template v-if="time.days">{{ time.days }} <span>{{ n__('day', 'days', time.days) }}</span></template> - <template v-if="time.hours">{{ time.hours }} <span>{{ __('hr') }}</span></template> - <template v-if="time.mins && !time.days">{{ time.mins }} <span>{{ n__('min', 'mins', time.mins) }}</span></template> - <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>s</span></template> + <template v-if="time.hours">{{ time.hours }} <span>{{ n__('Time|hr', 'Time|hrs', time.hours) }}</span></template> + <template v-if="time.mins && !time.days">{{ time.mins }} <span>{{ n__('Time|min', 'Time|mins', time.mins) }}</span></template> + <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>{{ __('Time|s') }}</span></template> </template> <template v-else> -- diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js index 1b10c19c48e78..51c5ad512fc7a 100644 --- a/app/assets/javascripts/locale/de/app.js +++ b/app/assets/javascripts/locale/de/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Cycle|Code":[""],"Cycle|Issue":[""],"Cycle|Plan":[""],"Cycle|Review":[""],"Cycle|Staging":[""],"Cycle|Test":[""],"Deploys":[""],"Deutsch":[""],"English":[""],"First":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Limited to showing 50 events at most":[""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"Opened":[""],"Pipeline Health":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"by":[""],"day":["",""],"hr":[""],"min":["",""],"pushed by":[""]}}}; \ No newline at end of file +var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Author|by":[""],"Commits":[""],"Cycle":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Cycle|Code":[""],"Cycle|Issue":[""],"Cycle|Plan":[""],"Cycle|Review":[""],"Cycle|Staging":[""],"Cycle|Test":[""],"Deploys":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Limited to showing 50 events at most":[""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"OfFirstTime|First":[""],"Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""],"pushed by":[""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/en/app.js b/app/assets/javascripts/locale/en/app.js index 2754e177653b0..68beb0afda016 100644 --- a/app/assets/javascripts/locale/en/app.js +++ b/app/assets/javascripts/locale/en/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Cycle|Code":[""],"Cycle|Issue":[""],"Cycle|Plan":[""],"Cycle|Review":[""],"Cycle|Staging":[""],"Cycle|Test":[""],"Deploys":[""],"Deutsch":[""],"English":[""],"First":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Limited to showing 50 events at most":[""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"Opened":[""],"Pipeline Health":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"Spanish":[""],"Stage":[""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"by":[""],"day":["",""],"hr":[""],"min":["",""],"pushed by":[""]}}}; \ No newline at end of file +var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Author|by":[""],"Commits":[""],"Cycle":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Cycle|Code":[""],"Cycle|Issue":[""],"Cycle|Plan":[""],"Cycle|Review":[""],"Cycle|Staging":[""],"Cycle|Test":[""],"Deploys":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Limited to showing 50 events at most":[""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"OfFirstTime|First":[""],"Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""],"pushed by":[""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/es/app.js b/app/assets/javascripts/locale/es/app.js index 5d1cfddf76abf..511c70b30a6ca 100644 --- a/app/assets/javascripts/locale/es/app.js +++ b/app/assets/javascripts/locale/es/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-27 19:15-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Commits":["Cambios"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"Cycle|Code":["Código"],"Cycle|Issue":["Tema"],"Cycle|Plan":["Planificación"],"Cycle|Review":["Revisión"],"Cycle|Staging":["Etapa"],"Cycle|Test":["Pruebas"],"Deploys":["Despliegues"],"Deutsch":["Alemán"],"English":["Inglés"],"First":["Primer"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last 30 days":["Últimos 30 dÃas"],"Last 90 days":["Últimos 90 dÃas"],"Limited to showing 50 events at most":["Limitado a mostrar máximo 50 eventos"],"Median":["Mediana"],"New Issues":["Nuevos temas"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"Opened":["Abiertos"],"Pipeline Health":["Estado del Pipeline"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Issues":["Temas Relacionados"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Relative Builds Trigger by Commits":["Builds Relacionadas Originadas por Cambios"],"Relative Deployed Builds":["Builds Desplegadas Relacionadas"],"Relative Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"Spanish":["Español"],"Stage":["Etapa"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":["La colección de eventos agregados a los datos recopilados para esa etapa."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de temas muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":["La etapa del ciclo de vida del desarrollo."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de un tema y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["El tiempo utilizado por cada entrada de datos obtenido por esa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."],"Total Time":["Tiempo Total"],"Want to see the data? Please ask administrator for access.":["¿Quieres ver los datos? Por favor pedir acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"You need permission.":["Necesitas permisos."],"by":["por"],"day":["dÃa","dÃas"],"hr":[""],"min":["",""],"pushed by":["enviado por"]}}}; \ No newline at end of file +var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-02 23:17-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Author|by":["por"],"Commits":["Cambios"],"Cycle":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"Cycle|Code":["Código"],"Cycle|Issue":["Tema"],"Cycle|Plan":["Planificación"],"Cycle|Review":["Revisión"],"Cycle|Staging":["Etapa"],"Cycle|Test":["Pruebas"],"Deploys":["Despliegues"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last 30 days":["Últimos 30 dÃas"],"Last 90 days":["Últimos 90 dÃas"],"Limited to showing 50 events at most":["Limitado a mostrar máximo 50 eventos"],"Median":["Mediana"],"New Issues":["Nuevos temas"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"OfFirstTime|First":["Primer"],"Opened":["Abiertos"],"Pipeline Health":["Estado del Pipeline"],"ProjectLifecycle|Stage":["Etapa"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Issues":["Temas Relacionados"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Relative Builds Trigger by Commits":["Builds Relacionadas Originadas por Cambios"],"Relative Deployed Builds":["Builds Desplegadas Relacionadas"],"Relative Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":["La colección de eventos agregados a los datos recopilados para esa etapa."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de temas muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":["La etapa del ciclo de vida del desarrollo."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de un tema y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["El tiempo utilizado por cada entrada de datos obtenido por esa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":["Tiempo Total"],"Want to see the data? Please ask administrator for access.":["¿Quieres ver los datos? Por favor pedir acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"You need permission.":["Necesitas permisos."],"day":["dÃa","dÃas"],"pushed by":["enviado por"]}}}; \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f2997d0b0c421..24017e8ea40ae 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -21,6 +21,8 @@ class ApplicationController < ActionController::Base before_action :configure_permitted_parameters, if: :devise_controller? before_action :require_email, unless: :devise_controller? + around_action :set_locale + protect_from_forgery with: :exception helper_method :can?, :current_application_settings @@ -271,9 +273,14 @@ def u2f_app_id end def set_locale - requested_locale = current_user&.preferred_language || request.env['HTTP_ACCEPT_LANGUAGE'] || I18n.default_locale - locale = FastGettext.set_locale(requested_locale) - - I18n.locale = locale + begin + requested_locale = current_user&.preferred_language || I18n.default_locale + locale = FastGettext.set_locale(requested_locale) + I18n.locale = locale + + yield + ensure + I18n.locale = I18n.default_locale + end end end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 0f01bf7e7067a..57e23cea00eb5 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -3,7 +3,6 @@ class ProfilesController < Profiles::ApplicationController before_action :user before_action :authorize_change_username!, only: :update_username - before_action :set_locale, only: :show skip_before_action :require_email, only: [:show, :update] def show diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb index 7ef8872a90b5b..88ac3ad046b57 100644 --- a/app/controllers/projects/cycle_analytics_controller.rb +++ b/app/controllers/projects/cycle_analytics_controller.rb @@ -4,7 +4,6 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController include CycleAnalyticsParams before_action :authorize_read_cycle_analytics! - before_action :set_locale, only: :show def show @cycle_analytics = ::CycleAnalytics.new(@project, options(cycle_analytics_params)) diff --git a/app/models/user.rb b/app/models/user.rb index 2b7ebe6c1a71f..43c5fdc038d47 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -23,6 +23,7 @@ class User < ActiveRecord::Base default_value_for :hide_no_password, false default_value_for :project_view, :files default_value_for :notified_of_own_activity, false + default_value_for :preferred_language, I18n.default_locale attr_encrypted :otp_secret, key: Gitlab::Application.secrets.otp_key_base, diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 9846f01603fd2..6e14be62e32e3 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -74,7 +74,7 @@ %span.help-block This email will be displayed on your public profile. .form-group = f.label :preferred_language, class: "label-light" - = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [_(label), value] }, + = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] }, {}, class: "select2" .form-group = f.label :skype, class: "label-light" diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index a1a227d2fe9d0..a8ce021e05f7a 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -52,7 +52,7 @@ %ul %li.stage-header %span.stage-name - {{ __('Stage') }} + {{ __('ProjectLifecycle|Stage') }} %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The phase of the development lifecycle."), "aria-hidden" => "true" } %li.median-header %span.stage-name diff --git a/config/initializers/fast_gettext.rb b/config/initializers/fast_gettext.rb index 10a3ee02b8510..bea85b43b9547 100644 --- a/config/initializers/fast_gettext.rb +++ b/config/initializers/fast_gettext.rb @@ -1,3 +1,5 @@ FastGettext.add_text_domain 'gitlab', path: 'locale', type: :po FastGettext.default_text_domain = 'gitlab' -FastGettext.default_available_locales = Gitlab::I18n::AVAILABLE_LANGUAGES.keys +FastGettext.default_available_locales = Gitlab::I18n.available_locales + +I18n.available_locales = Gitlab::I18n.available_locales diff --git a/db/migrate/20170413035209_add_preferred_language_to_users.rb b/db/migrate/20170413035209_add_preferred_language_to_users.rb index 6fe91656eeba5..92f1d6f243633 100644 --- a/db/migrate/20170413035209_add_preferred_language_to_users.rb +++ b/db/migrate/20170413035209_add_preferred_language_to_users.rb @@ -6,10 +6,8 @@ class AddPreferredLanguageToUsers < ActiveRecord::Migration DOWNTIME = false - disable_ddl_transaction! - def up - add_column_with_default :users, :preferred_language, :string, default: 'en' + add_column :users, :preferred_language, :string end def down diff --git a/lib/gitlab/i18n.rb b/lib/gitlab/i18n.rb index 64a86b55c7f5f..a7addee0dcd15 100644 --- a/lib/gitlab/i18n.rb +++ b/lib/gitlab/i18n.rb @@ -1,9 +1,15 @@ module Gitlab module I18n + extend self + AVAILABLE_LANGUAGES = { - 'en' => N_('English'), - 'es' => N_('Spanish'), - 'de' => N_('Deutsch') + en: 'English', + es: 'Español', + de: 'Deutsch' }.freeze + + def available_locales + AVAILABLE_LANGUAGES.keys + end end end diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po index 843648c25f1fe..beeca35b879dc 100644 --- a/locale/de/gitlab.po +++ b/locale/de/gitlab.po @@ -17,9 +17,15 @@ msgstr "" "Plural-Forms: nplurals=2; plural=n != 1;\n" "\n" +msgid "Author|by" +msgstr "" + msgid "Commits" msgstr "" +msgid "Cycle" +msgstr "" + msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "" @@ -44,15 +50,6 @@ msgstr "" msgid "Deploys" msgstr "" -msgid "Deutsch" -msgstr "" - -msgid "English" -msgstr "" - -msgid "First" -msgstr "" - msgid "Introducing Cycle Analytics" msgstr "" @@ -77,12 +74,18 @@ msgstr "" msgid "Not enough data" msgstr "" +msgid "OfFirstTime|First" +msgstr "" + msgid "Opened" msgstr "" msgid "Pipeline Health" msgstr "" +msgid "ProjectLifecycle|Stage" +msgstr "" + msgid "Read more" msgstr "" @@ -109,12 +112,6 @@ msgid_plural "Showing %d events" msgstr[0] "" msgstr[1] "" -msgid "Spanish" -msgstr "" - -msgid "Stage" -msgstr "" - msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." msgstr "" @@ -148,6 +145,19 @@ msgstr "" msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." msgstr "" +msgid "Time|hr" +msgid_plural "Time|hrs" +msgstr[0] "" +msgstr[1] "" + +msgid "Time|min" +msgid_plural "Time|mins" +msgstr[0] "" +msgstr[1] "" + +msgid "Time|s" +msgstr "" + msgid "Total Time" msgstr "" @@ -160,21 +170,10 @@ msgstr "" msgid "You need permission." msgstr "" -msgid "by" -msgstr "" - msgid "day" msgid_plural "days" msgstr[0] "" msgstr[1] "" -msgid "hr" -msgstr "" - -msgid "min" -msgid_plural "mins" -msgstr[0] "" -msgstr[1] "" - msgid "pushed by" msgstr "" diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po index e98931685d753..9596531fdbd0c 100644 --- a/locale/en/gitlab.po +++ b/locale/en/gitlab.po @@ -17,9 +17,15 @@ msgstr "" "Plural-Forms: nplurals=2; plural=n != 1;\n" "\n" +msgid "Author|by" +msgstr "" + msgid "Commits" msgstr "" +msgid "Cycle" +msgstr "" + msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "" @@ -44,15 +50,6 @@ msgstr "" msgid "Deploys" msgstr "" -msgid "Deutsch" -msgstr "" - -msgid "English" -msgstr "" - -msgid "First" -msgstr "" - msgid "Introducing Cycle Analytics" msgstr "" @@ -77,12 +74,18 @@ msgstr "" msgid "Not enough data" msgstr "" +msgid "OfFirstTime|First" +msgstr "" + msgid "Opened" msgstr "" msgid "Pipeline Health" msgstr "" +msgid "ProjectLifecycle|Stage" +msgstr "" + msgid "Read more" msgstr "" @@ -109,12 +112,6 @@ msgid_plural "Showing %d events" msgstr[0] "" msgstr[1] "" -msgid "Spanish" -msgstr "" - -msgid "Stage" -msgstr "" - msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." msgstr "" @@ -148,6 +145,19 @@ msgstr "" msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." msgstr "" +msgid "Time|hr" +msgid_plural "Time|hrs" +msgstr[0] "" +msgstr[1] "" + +msgid "Time|min" +msgid_plural "Time|mins" +msgstr[0] "" +msgstr[1] "" + +msgid "Time|s" +msgstr "" + msgid "Total Time" msgstr "" @@ -160,21 +170,10 @@ msgstr "" msgid "You need permission." msgstr "" -msgid "by" -msgstr "" - msgid "day" msgid_plural "days" msgstr[0] "" msgstr[1] "" -msgid "hr" -msgstr "" - -msgid "min" -msgid_plural "mins" -msgstr[0] "" -msgstr[1] "" - msgid "pushed by" msgstr "" diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po index 644007f514928..56f889c7141b4 100644 --- a/locale/es/gitlab.po +++ b/locale/es/gitlab.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2017-04-27 19:15-0500\n" +"PO-Revision-Date: 2017-05-02 23:17-0500\n" "Language-Team: Spanish\n" "Language: es\n" "MIME-Version: 1.0\n" @@ -17,9 +17,15 @@ msgstr "" "Last-Translator: \n" "X-Generator: Poedit 2.0.1\n" +msgid "Author|by" +msgstr "por" + msgid "Commits" msgstr "Cambios" +msgid "Cycle" +msgstr "" + msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto." @@ -50,15 +56,6 @@ msgstr "Pruebas" msgid "Deploys" msgstr "Despliegues" -msgid "Deutsch" -msgstr "Alemán" - -msgid "English" -msgstr "Inglés" - -msgid "First" -msgstr "Primer" - msgid "Introducing Cycle Analytics" msgstr "Introducción a Cycle Analytics" @@ -83,12 +80,19 @@ msgstr "No disponible" msgid "Not enough data" msgstr "No hay suficientes datos" +#, fuzzy +msgid "OfFirstTime|First" +msgstr "Primer" + msgid "Opened" msgstr "Abiertos" msgid "Pipeline Health" msgstr "Estado del Pipeline" +msgid "ProjectLifecycle|Stage" +msgstr "Etapa" + msgid "Read more" msgstr "Leer más" @@ -117,12 +121,6 @@ msgid_plural "Showing %d events" msgstr[0] "Mostrando %d evento" msgstr[1] "Mostrando %d eventos" -msgid "Spanish" -msgstr "Español" - -msgid "Stage" -msgstr "Etapa" - msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." msgstr "La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión." @@ -156,6 +154,19 @@ msgstr "El tiempo utilizado por cada entrada de datos obtenido por esa etapa." msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." msgstr "El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6." +msgid "Time|hr" +msgid_plural "Time|hrs" +msgstr[0] "" +msgstr[1] "" + +msgid "Time|min" +msgid_plural "Time|mins" +msgstr[0] "" +msgstr[1] "" + +msgid "Time|s" +msgstr "" + msgid "Total Time" msgstr "Tiempo Total" @@ -168,21 +179,10 @@ msgstr "No hay suficientes datos para mostrar en esta etapa." msgid "You need permission." msgstr "Necesitas permisos." -msgid "by" -msgstr "por" - msgid "day" msgid_plural "days" msgstr[0] "dÃa" msgstr[1] "dÃas" -msgid "hr" -msgstr "" - -msgid "min" -msgid_plural "mins" -msgstr[0] "" -msgstr[1] "" - msgid "pushed by" msgstr "enviado por" diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 1b60f9dad7a57..956e05926e10b 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-04-27 19:13-0500\n" -"PO-Revision-Date: 2017-04-27 19:13-0500\n" +"POT-Creation-Date: 2017-05-02 23:33-0500\n" +"PO-Revision-Date: 2017-05-02 23:33-0500\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" "Language: \n" @@ -18,9 +18,15 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" +msgid "Author|by" +msgstr "" + msgid "Commits" msgstr "" +msgid "Cycle" +msgstr "" + msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "" @@ -45,15 +51,6 @@ msgstr "" msgid "Deploys" msgstr "" -msgid "Deutsch" -msgstr "" - -msgid "English" -msgstr "" - -msgid "First" -msgstr "" - msgid "Introducing Cycle Analytics" msgstr "" @@ -78,12 +75,18 @@ msgstr "" msgid "Not enough data" msgstr "" +msgid "OfFirstTime|First" +msgstr "" + msgid "Opened" msgstr "" msgid "Pipeline Health" msgstr "" +msgid "ProjectLifecycle|Stage" +msgstr "" + msgid "Read more" msgstr "" @@ -110,12 +113,6 @@ msgid_plural "Showing %d events" msgstr[0] "" msgstr[1] "" -msgid "Spanish" -msgstr "" - -msgid "Stage" -msgstr "" - msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." msgstr "" @@ -149,6 +146,19 @@ msgstr "" msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." msgstr "" +msgid "Time|hr" +msgid_plural "Time|hrs" +msgstr[0] "" +msgstr[1] "" + +msgid "Time|min" +msgid_plural "Time|mins" +msgstr[0] "" +msgstr[1] "" + +msgid "Time|s" +msgstr "" + msgid "Total Time" msgstr "" @@ -161,21 +171,10 @@ msgstr "" msgid "You need permission." msgstr "" -msgid "by" -msgstr "" - msgid "day" msgid_plural "days" msgstr[0] "" msgstr[1] "" -msgid "hr" -msgstr "" - -msgid "min" -msgid_plural "mins" -msgstr[0] "" -msgstr[1] "" - msgid "pushed by" msgstr "" -- GitLab From 9ee274c03166b5773d433e3947b5d566fda02e53 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 3 May 2017 09:06:28 +0100 Subject: [PATCH 149/363] Fixed tags sort dropdown being empty Closes #31618 --- app/helpers/sorting_helper.rb | 8 ++++++++ app/views/projects/tags/index.html.haml | 16 +++++++--------- changelogs/unreleased/tags-sort-default.yml | 4 ++++ spec/features/projects/tags/sort_spec.rb | 15 +++++++++++++++ 4 files changed, 34 insertions(+), 9 deletions(-) create mode 100644 changelogs/unreleased/tags-sort-default.yml create mode 100644 spec/features/projects/tags/sort_spec.rb diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 2fda98cae9040..4882d9b71d2b4 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -70,6 +70,14 @@ def branches_sort_options_hash } end + def tags_sort_options_hash + { + sort_value_name => sort_title_name, + sort_value_recently_updated => sort_title_recently_updated, + sort_value_oldest_updated => sort_title_oldest_updated + } + end + def sort_title_priority 'Priority' end diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index 7f9a44e565f61..c14bbf4f05f72 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -14,16 +14,14 @@ .dropdown %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown'} } %span.light - = projects_sort_options_hash[@sort] + = tags_sort_options_hash[@sort] = icon('chevron-down') - %ul.dropdown-menu.dropdown-menu-align-right - %li - = link_to filter_tags_path(sort: sort_value_name) do - = sort_title_name - = link_to filter_tags_path(sort: sort_value_recently_updated) do - = sort_title_recently_updated - = link_to filter_tags_path(sort: sort_value_oldest_updated) do - = sort_title_oldest_updated + %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable + %li.dropdown-header + Sort by + - tags_sort_options_hash.each do |value, title| + %li + = link_to title, filter_tags_path(sort: value), class: ("is-active" if @sort == value) - if can?(current_user, :push_code, @project) = link_to new_namespace_project_tag_path(@project.namespace, @project), class: 'btn btn-create new-tag-btn' do New tag diff --git a/changelogs/unreleased/tags-sort-default.yml b/changelogs/unreleased/tags-sort-default.yml new file mode 100644 index 0000000000000..265b765d5400c --- /dev/null +++ b/changelogs/unreleased/tags-sort-default.yml @@ -0,0 +1,4 @@ +--- +title: Fixed tags sort from defaulting to empty +merge_request: +author: diff --git a/spec/features/projects/tags/sort_spec.rb b/spec/features/projects/tags/sort_spec.rb new file mode 100644 index 0000000000000..835cd1507fbfc --- /dev/null +++ b/spec/features/projects/tags/sort_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' + +feature 'Tags sort dropdown', :feature do + let(:project) { create(:project) } + + before do + login_as(:admin) + + visit namespace_project_tags_path(project.namespace, project) + end + + it 'defaults sort dropdown to last updated' do + expect(page).to have_button('Last updated') + end +end -- GitLab From 72307940bb04d29dc751adb5e76330d2ce45e9ce Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Wed, 3 May 2017 12:32:39 +0200 Subject: [PATCH 150/363] Improve code style related to protected actions --- app/serializers/build_action_entity.rb | 2 +- app/serializers/build_entity.rb | 4 ++-- app/serializers/pipeline_entity.rb | 6 +++--- app/services/ci/play_build_service.rb | 2 +- spec/controllers/projects/builds_controller_spec.rb | 4 +--- 5 files changed, 8 insertions(+), 10 deletions(-) diff --git a/app/serializers/build_action_entity.rb b/app/serializers/build_action_entity.rb index 0bb7e5610731f..3eff724ae9149 100644 --- a/app/serializers/build_action_entity.rb +++ b/app/serializers/build_action_entity.rb @@ -19,6 +19,6 @@ class BuildActionEntity < Grape::Entity alias_method :build, :object def playable? - can?(request.user, :play_build, build) && build.playable? + build.playable? && can?(request.user, :play_build, build) end end diff --git a/app/serializers/build_entity.rb b/app/serializers/build_entity.rb index f301900c43c43..10ba7c90c10c2 100644 --- a/app/serializers/build_entity.rb +++ b/app/serializers/build_entity.rb @@ -12,7 +12,7 @@ class BuildEntity < Grape::Entity path_to(:retry_namespace_project_build, build) end - expose :play_path, if: proc { playable? } do |build| + expose :play_path, if: -> (*) { playable? } do |build| path_to(:play_namespace_project_build, build) end @@ -26,7 +26,7 @@ class BuildEntity < Grape::Entity alias_method :build, :object def playable? - can?(request.user, :play_build, build) && build.playable? + build.playable? && can?(request.user, :play_build, build) end def detailed_status diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index ad8b4d43e8f07..7eb7aac72eb5a 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/pipeline_entity.rb @@ -48,15 +48,15 @@ class PipelineEntity < Grape::Entity end expose :commit, using: CommitEntity - expose :yaml_errors, if: ->(pipeline, _) { pipeline.has_yaml_errors? } + expose :yaml_errors, if: -> (pipeline, _) { pipeline.has_yaml_errors? } - expose :retry_path, if: proc { can_retry? } do |pipeline| + expose :retry_path, if: -> (*) { can_retry? } do |pipeline| retry_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id) end - expose :cancel_path, if: proc { can_cancel? } do |pipeline| + expose :cancel_path, if: -> (*) { can_cancel? } do |pipeline| cancel_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id) diff --git a/app/services/ci/play_build_service.rb b/app/services/ci/play_build_service.rb index c9ed45408f223..5b3ff1ced3855 100644 --- a/app/services/ci/play_build_service.rb +++ b/app/services/ci/play_build_service.rb @@ -5,7 +5,7 @@ def execute(build) raise Gitlab::Access::AccessDeniedError end - # Try to enqueue thebuild, otherwise create a duplicate. + # Try to enqueue the build, otherwise create a duplicate. # if build.enqueue build.tap { |action| action.update(user: current_user) } diff --git a/spec/controllers/projects/builds_controller_spec.rb b/spec/controllers/projects/builds_controller_spec.rb index 3bf03c88ff55a..3ce23c17cdc68 100644 --- a/spec/controllers/projects/builds_controller_spec.rb +++ b/spec/controllers/projects/builds_controller_spec.rb @@ -260,10 +260,8 @@ def post_retry end describe 'POST play' do - let(:project) { create(:project, :public) } - before do - project.add_developer(user) + project.add_master(user) sign_in(user) post_play -- GitLab From 88ace4a7ad0809d64f688dc1ee6fc8ea6cda9045 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Wed, 3 May 2017 12:35:13 +0200 Subject: [PATCH 151/363] Rephrase documentation for protected actions feature --- doc/ci/yaml/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 0cab3270d94dc..16308a957cb31 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -580,9 +580,10 @@ Optional manual actions have `allow_failure: true` set by default. **Statuses of optional actions do not contribute to overall pipeline status.** -**Manual actions do inherit permissions of protected branches. In other words, -in order to trigger a manual action assigned to a branch that the pipeline is -running for, user needs to have ability to push to this branch.** +**Manual actions are considered to be write actions, so permissions for +protected branches are used when user wants to trigger an action. In other +words, in order to trigger a manual action assigned to a branch that the +pipeline is running for, user needs to have ability to push to this branch.** ### environment -- GitLab From 93f27958d02389b3550d25470ccd29b304005e96 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 3 May 2017 12:02:26 +0100 Subject: [PATCH 152/363] Updated some JS translate methods to correctly take in context --- .../components/limit_warning_component.js | 2 +- .../components/total_time_component.js | 2 +- app/assets/javascripts/locale/de/app.js | 2 +- app/assets/javascripts/locale/en/app.js | 2 +- app/assets/javascripts/locale/es/app.js | 2 +- app/assets/javascripts/locale/index.js | 21 +++++++++++-------- .../javascripts/vue_shared/translate.js | 12 ++++++++--- .../cycle_analytics/_no_access.html.haml | 2 +- .../projects/cycle_analytics/show.html.haml | 13 ++++++------ locale/de/gitlab.po | 17 ++++++++------- locale/en/gitlab.po | 17 ++++++++------- locale/es/gitlab.po | 18 +++++++++------- locale/gitlab.pot | 21 ++++++++++--------- 13 files changed, 72 insertions(+), 59 deletions(-) diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js index c43a45067553b..8d3d34f836f5e 100644 --- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js +++ b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js @@ -9,7 +9,7 @@ export default { <span v-if="count === 50" class="events-info pull-right"> <i class="fa fa-warning has-tooltip" aria-hidden="true" - :title="__('Limited to showing 50 events at most')" + :title="n__('Limited to showing %d event at most', 'Limited to showing %d events at most', 50)" data-placement="top"></i> {{ n__('Showing %d event', 'Showing %d events', 50) }} </span> diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.js b/app/assets/javascripts/cycle_analytics/components/total_time_component.js index 1eef7d9a8738d..d5e6167b2a82f 100644 --- a/app/assets/javascripts/cycle_analytics/components/total_time_component.js +++ b/app/assets/javascripts/cycle_analytics/components/total_time_component.js @@ -15,7 +15,7 @@ global.cycleAnalytics.TotalTimeComponent = Vue.extend({ <template v-if="time.days">{{ time.days }} <span>{{ n__('day', 'days', time.days) }}</span></template> <template v-if="time.hours">{{ time.hours }} <span>{{ n__('Time|hr', 'Time|hrs', time.hours) }}</span></template> <template v-if="time.mins && !time.days">{{ time.mins }} <span>{{ n__('Time|min', 'Time|mins', time.mins) }}</span></template> - <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>{{ __('Time|s') }}</span></template> + <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>{{ s__('Time|s') }}</span></template> </template> <template v-else> -- diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js index 51c5ad512fc7a..a7922e911bdec 100644 --- a/app/assets/javascripts/locale/de/app.js +++ b/app/assets/javascripts/locale/de/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Author|by":[""],"Commits":[""],"Cycle":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Cycle|Code":[""],"Cycle|Issue":[""],"Cycle|Plan":[""],"Cycle|Review":[""],"Cycle|Staging":[""],"Cycle|Test":[""],"Deploys":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Limited to showing 50 events at most":[""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"OfFirstTime|First":[""],"Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""],"pushed by":[""]}}}; \ No newline at end of file +var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Author|by":[""],"Commits":[""],"Cycle":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Cycle|Code":[""],"Cycle|Issue":[""],"Cycle|Plan":[""],"Cycle|Review":[""],"Cycle|Staging":[""],"Cycle|Test":[""],"Deploys":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"OfFirstTime|First":[""],"Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""],"pushed by":[""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/en/app.js b/app/assets/javascripts/locale/en/app.js index 68beb0afda016..3ed43559e74ee 100644 --- a/app/assets/javascripts/locale/en/app.js +++ b/app/assets/javascripts/locale/en/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Author|by":[""],"Commits":[""],"Cycle":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Cycle|Code":[""],"Cycle|Issue":[""],"Cycle|Plan":[""],"Cycle|Review":[""],"Cycle|Staging":[""],"Cycle|Test":[""],"Deploys":[""],"Introducing Cycle Analytics":[""],"Last 30 days":[""],"Last 90 days":[""],"Limited to showing 50 events at most":[""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"OfFirstTime|First":[""],"Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Want to see the data? Please ask administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""],"pushed by":[""]}}}; \ No newline at end of file +var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Author|by":[""],"Commits":[""],"Cycle":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Cycle|Code":[""],"Cycle|Issue":[""],"Cycle|Plan":[""],"Cycle|Review":[""],"Cycle|Staging":[""],"Cycle|Test":[""],"Deploys":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"OfFirstTime|First":[""],"Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""],"pushed by":[""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/es/app.js b/app/assets/javascripts/locale/es/app.js index 511c70b30a6ca..b91dcbd1ff36c 100644 --- a/app/assets/javascripts/locale/es/app.js +++ b/app/assets/javascripts/locale/es/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-02 23:17-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Author|by":["por"],"Commits":["Cambios"],"Cycle":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"Cycle|Code":["Código"],"Cycle|Issue":["Tema"],"Cycle|Plan":["Planificación"],"Cycle|Review":["Revisión"],"Cycle|Staging":["Etapa"],"Cycle|Test":["Pruebas"],"Deploys":["Despliegues"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last 30 days":["Últimos 30 dÃas"],"Last 90 days":["Últimos 90 dÃas"],"Limited to showing 50 events at most":["Limitado a mostrar máximo 50 eventos"],"Median":["Mediana"],"New Issues":["Nuevos temas"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"OfFirstTime|First":["Primer"],"Opened":["Abiertos"],"Pipeline Health":["Estado del Pipeline"],"ProjectLifecycle|Stage":["Etapa"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Issues":["Temas Relacionados"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Relative Builds Trigger by Commits":["Builds Relacionadas Originadas por Cambios"],"Relative Deployed Builds":["Builds Desplegadas Relacionadas"],"Relative Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":["La colección de eventos agregados a los datos recopilados para esa etapa."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de temas muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":["La etapa del ciclo de vida del desarrollo."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de un tema y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["El tiempo utilizado por cada entrada de datos obtenido por esa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":["Tiempo Total"],"Want to see the data? Please ask administrator for access.":["¿Quieres ver los datos? Por favor pedir acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"You need permission.":["Necesitas permisos."],"day":["dÃa","dÃas"],"pushed by":["enviado por"]}}}; \ No newline at end of file +var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-02 23:17-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Author|by":["por"],"Commits":["Cambios"],"Cycle":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"Cycle|Code":["Código"],"Cycle|Issue":["Tema"],"Cycle|Plan":["Planificación"],"Cycle|Review":["Revisión"],"Cycle|Staging":["Etapa"],"Cycle|Test":["Pruebas"],"Deploys":["Despliegues"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last %d day":["Últimos %d dÃas"],"Limited to showing %d event at most":["Limitado a mostrar máximo %d eventos"],"Median":["Mediana"],"New Issues":["Nuevos temas"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"OfFirstTime|First":["Primer"],"Opened":["Abiertos"],"Pipeline Health":["Estado del Pipeline"],"ProjectLifecycle|Stage":["Etapa"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Issues":["Temas Relacionados"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Relative Builds Trigger by Commits":["Builds Relacionadas Originadas por Cambios"],"Relative Deployed Builds":["Builds Desplegadas Relacionadas"],"Relative Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":["La colección de eventos agregados a los datos recopilados para esa etapa."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de temas muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":["La etapa del ciclo de vida del desarrollo."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de un tema y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["El tiempo utilizado por cada entrada de datos obtenido por esa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":["Tiempo Total"],"Want to see the data? Please ask an administrator for access.":["¿Quieres ver los datos? Por favor pedir acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"You need permission.":["Necesitas permisos."],"day":["dÃa","dÃas"],"pushed by":["enviado por"]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js index ca3ed69fbb3ba..3b23e3a69d491 100644 --- a/app/assets/javascripts/locale/index.js +++ b/app/assets/javascripts/locale/index.js @@ -1,15 +1,18 @@ import Jed from 'jed'; -import { de } from './de/app'; -import { es } from './es/app'; -import { en } from './en/app'; -const locales = { - de, - es, - en, -}; +function requireAll(requireContext) { return requireContext.keys().map(requireContext); } + +const allLocales = requireAll(require.context('./', true, /^(?!.*(?:index.js$)).*\.js$/)); +const locales = allLocales.reduce((d, obj) => { + const data = d; + const localeKey = Object.keys(obj)[0]; + + data[localeKey] = obj[localeKey]; + + return data; +}, {}); -const lang = document.querySelector('html').getAttribute('lang') || 'en'; +const lang = document.querySelector('html').getAttribute('lang').replace(/-/g, '_') || 'en'; const locale = new Jed(locales[lang]); const gettext = locale.gettext.bind(locale); const ngettext = locale.ngettext.bind(locale); diff --git a/app/assets/javascripts/vue_shared/translate.js b/app/assets/javascripts/vue_shared/translate.js index c2c20ea0853d3..07ef00c10d06a 100644 --- a/app/assets/javascripts/vue_shared/translate.js +++ b/app/assets/javascripts/vue_shared/translate.js @@ -1,7 +1,6 @@ import { __, n__, - s__, } from '../locale'; export default (Vue) => { @@ -9,9 +8,16 @@ export default (Vue) => { methods: { __(text) { return __(text); }, n__(text, pluralText, count) { - return n__(text, pluralText, count).replace(/%d/g, count); + const translated = n__(text, pluralText, count).replace(/%d/g, count).split('|'); + return translated[translated.length - 1]; + }, + s__(keyOrContext, key) { + const normalizedKey = key ? `${keyOrContext}|${key}` : keyOrContext; + // eslint-disable-next-line no-underscore-dangle + const translated = this.__(normalizedKey).split('|'); + + return translated[translated.length - 1]; }, - s__(context, key) { return s__(context, key); }, }, }); }; diff --git a/app/views/projects/cycle_analytics/_no_access.html.haml b/app/views/projects/cycle_analytics/_no_access.html.haml index dcfd1b7afc09d..c3eda39823422 100644 --- a/app/views/projects/cycle_analytics/_no_access.html.haml +++ b/app/views/projects/cycle_analytics/_no_access.html.haml @@ -4,4 +4,4 @@ = custom_icon ('icon_lock') %h4 {{ __('You need permission.') }} %p - {{ __('Want to see the data? Please ask administrator for access.') }} + {{ __('Want to see the data? Please ask an administrator for access.') }} diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index a8ce021e05f7a..376b1369405c5 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -20,8 +20,7 @@ %p {{ __('Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.') }} - = link_to help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn' do - {{ __('Read more') }} + = link_to _('Read more'), help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn' = icon("spinner spin", "v-show" => "isLoading") .wrapper{ "v-show" => "!isLoading && !hasError" } .panel.panel-default @@ -32,19 +31,19 @@ .row .col-sm-3.col-xs-12.column{ "v-for" => "item in state.summary" } %h3.header {{ item.value }} - %p.text {{ __(item.title) }} + %p.text {{ s__('Cycle', item.title) }} .col-sm-3.col-xs-12.column .dropdown.inline.js-ca-dropdown %button.dropdown-menu-toggle{ "data-toggle" => "dropdown", :type => "button" } - %span.dropdown-label {{ __('Last 30 days') }} + %span.dropdown-label {{ n__('Last %d day', 'Last %d days', 30) }} %i.fa.fa-chevron-down %ul.dropdown-menu.dropdown-menu-align-right %li %a{ "href" => "#", "data-value" => "30" } - {{ __('Last 30 days') }} + {{ n__('Last %d day', 'Last %d days', 30) }} %li %a{ "href" => "#", "data-value" => "90" } - {{ __('Last 90 days') }} + {{ n__('Last %d day', 'Last %d days', 90) }} .stage-panel-container .panel.panel-default.stage-panel .panel-heading @@ -52,7 +51,7 @@ %ul %li.stage-header %span.stage-name - {{ __('ProjectLifecycle|Stage') }} + {{ s__('ProjectLifecycle|Stage') }} %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The phase of the development lifecycle."), "aria-hidden" => "true" } %li.median-header %span.stage-name diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po index beeca35b879dc..d2ea50e5a7835 100644 --- a/locale/de/gitlab.po +++ b/locale/de/gitlab.po @@ -53,14 +53,15 @@ msgstr "" msgid "Introducing Cycle Analytics" msgstr "" -msgid "Last 30 days" -msgstr "" - -msgid "Last 90 days" -msgstr "" +msgid "Last %d day" +msgid_plural "Last %d days" +msgstr[0] "" +msgstr[1] "" -msgid "Limited to showing 50 events at most" -msgstr "" +msgid "Limited to showing %d event at most" +msgid_plural "Limited to showing %d events at most" +msgstr[0] "" +msgstr[1] "" msgid "Median" msgstr "" @@ -161,7 +162,7 @@ msgstr "" msgid "Total Time" msgstr "" -msgid "Want to see the data? Please ask administrator for access." +msgid "Want to see the data? Please ask an administrator for access." msgstr "" msgid "We don't have enough data to show this stage." diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po index 9596531fdbd0c..f473ce2e0dcd4 100644 --- a/locale/en/gitlab.po +++ b/locale/en/gitlab.po @@ -53,14 +53,15 @@ msgstr "" msgid "Introducing Cycle Analytics" msgstr "" -msgid "Last 30 days" -msgstr "" - -msgid "Last 90 days" -msgstr "" +msgid "Last %d day" +msgid_plural "Last %d days" +msgstr[0] "" +msgstr[1] "" -msgid "Limited to showing 50 events at most" -msgstr "" +msgid "Limited to showing %d event at most" +msgid_plural "Limited to showing %d events at most" +msgstr[0] "" +msgstr[1] "" msgid "Median" msgstr "" @@ -161,7 +162,7 @@ msgstr "" msgid "Total Time" msgstr "" -msgid "Want to see the data? Please ask administrator for access." +msgid "Want to see the data? Please ask an administrator for access." msgstr "" msgid "We don't have enough data to show this stage." diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po index 56f889c7141b4..91135a983f653 100644 --- a/locale/es/gitlab.po +++ b/locale/es/gitlab.po @@ -59,14 +59,15 @@ msgstr "Despliegues" msgid "Introducing Cycle Analytics" msgstr "Introducción a Cycle Analytics" -msgid "Last 30 days" -msgstr "Últimos 30 dÃas" - -msgid "Last 90 days" -msgstr "Últimos 90 dÃas" +#, fuzzy +msgid "Last %d day" +msgid_plural "Last %d days" +msgstr[0] "Últimos %d dÃas" -msgid "Limited to showing 50 events at most" -msgstr "Limitado a mostrar máximo 50 eventos" +#, fuzzy +msgid "Limited to showing %d event at most" +msgid_plural "Limited to showing %d events at most" +msgstr[0] "Limitado a mostrar máximo %d eventos" msgid "Median" msgstr "Mediana" @@ -170,7 +171,8 @@ msgstr "" msgid "Total Time" msgstr "Tiempo Total" -msgid "Want to see the data? Please ask administrator for access." +#, fuzzy +msgid "Want to see the data? Please ask an administrator for access." msgstr "¿Quieres ver los datos? Por favor pedir acceso al administrador." msgid "We don't have enough data to show this stage." diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 956e05926e10b..bc16b7a0968c4 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-05-02 23:33-0500\n" -"PO-Revision-Date: 2017-05-02 23:33-0500\n" +"POT-Creation-Date: 2017-05-03 12:00+0100\n" +"PO-Revision-Date: 2017-05-03 12:00+0100\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" "Language: \n" @@ -54,14 +54,15 @@ msgstr "" msgid "Introducing Cycle Analytics" msgstr "" -msgid "Last 30 days" -msgstr "" - -msgid "Last 90 days" -msgstr "" +msgid "Last %d day" +msgid_plural "Last %d days" +msgstr[0] "" +msgstr[1] "" -msgid "Limited to showing 50 events at most" -msgstr "" +msgid "Limited to showing %d event at most" +msgid_plural "Limited to showing %d events at most" +msgstr[0] "" +msgstr[1] "" msgid "Median" msgstr "" @@ -162,7 +163,7 @@ msgstr "" msgid "Total Time" msgstr "" -msgid "Want to see the data? Please ask administrator for access." +msgid "Want to see the data? Please ask an administrator for access." msgstr "" msgid "We don't have enough data to show this stage." -- GitLab From 9c78b17a113c593d5d1d2370688ac814a4d0f3a2 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 3 May 2017 13:33:12 +0100 Subject: [PATCH 153/363] Fixed karma failure --- app/assets/javascripts/locale/index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js index 3b23e3a69d491..c9df5f3723af7 100644 --- a/app/assets/javascripts/locale/index.js +++ b/app/assets/javascripts/locale/index.js @@ -12,7 +12,9 @@ const locales = allLocales.reduce((d, obj) => { return data; }, {}); -const lang = document.querySelector('html').getAttribute('lang').replace(/-/g, '_') || 'en'; +let lang = document.querySelector('html').getAttribute('lang') || 'en'; +lang = lang.replace(/-/g, '_'); + const locale = new Jed(locales[lang]); const gettext = locale.gettext.bind(locale); const ngettext = locale.ngettext.bind(locale); -- GitLab From 7ac89d05dbf6c932e9cb6e87cc64c46e015a22d5 Mon Sep 17 00:00:00 2001 From: James Lopez <james@jameslopez.es> Date: Wed, 3 May 2017 14:53:54 +0200 Subject: [PATCH 154/363] add service spec --- spec/models/service_spec.rb | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb index 134882648b9b4..e9acbb81ad99e 100644 --- a/spec/models/service_spec.rb +++ b/spec/models/service_spec.rb @@ -254,4 +254,30 @@ end end end + + describe "#update_and_propagate" do + let!(:service) do + RedmineService.new( + project: project, + active: false, + properties: { + project_url: 'http://redmine/projects/project_name_in_redmine', + issues_url: "http://redmine/#{project.id}/project_name_in_redmine/:id", + new_issue_url: 'http://redmine/projects/project_name_in_redmine/issues/new' + } + ) + end + + it 'updates the service params successfully and calls the propagation worker' do + expect(PropagateProjectServiceWorker).to receve(:perform_async) + + expect(service.update_and_propagate(active: true)).to be true + end + + it 'updates the service params successfully' do + expect(PropagateProjectServiceWorker).not_to receve(:perform_asyncs) + + expect(service.update_and_propagate(properties: {})).to be true + end + end end -- GitLab From 36c6447f9de74039f374a49f9c29ce370fb45ee2 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Wed, 3 May 2017 14:28:42 +0100 Subject: [PATCH 155/363] [ci skip] remove accidental debug print --- app/views/layouts/_head.html.haml | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index fa4d760a20eb1..afcc2b6e4f31f 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -35,8 +35,6 @@ = webpack_bundle_tag "main" = webpack_bundle_tag "raven" if current_application_settings.clientside_sentry_enabled - = current_application_settings.clientside_sentry_enabled - - if content_for?(:page_specific_javascripts) = yield :page_specific_javascripts -- GitLab From 83154f21542ec04076d20ce9c4a8997d55fc5f43 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Wed, 3 May 2017 15:42:29 +0200 Subject: [PATCH 156/363] Improve environment policy class --- app/policies/environment_policy.rb | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/policies/environment_policy.rb b/app/policies/environment_policy.rb index c0c9bbf2d9e9d..cc94d4a7e058e 100644 --- a/app/policies/environment_policy.rb +++ b/app/policies/environment_policy.rb @@ -4,12 +4,14 @@ class EnvironmentPolicy < BasePolicy def rules delegate! environment.project - if environment.stop_action? - delegate! environment.stop_action + if can?(:create_deployment) && environment.stop_action? + can! :stop_environment if can_play_stop_action? end + end - if can?(:create_deployment) && can?(:play_build) - can! :stop_environment - end + private + + def can_play_stop_action? + Ability.allowed?(user, :play_build, environment.stop_action) end end -- GitLab From e81ea165ba738fedab07d5e20423856e004e2594 Mon Sep 17 00:00:00 2001 From: James Lopez <james@jameslopez.es> Date: Wed, 3 May 2017 15:43:26 +0200 Subject: [PATCH 157/363] added worker spec --- .../propagate_project_service_worker_spec.rb | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 spec/workers/propagate_project_service_worker_spec.rb diff --git a/spec/workers/propagate_project_service_worker_spec.rb b/spec/workers/propagate_project_service_worker_spec.rb new file mode 100644 index 0000000000000..ce01a663a8fa6 --- /dev/null +++ b/spec/workers/propagate_project_service_worker_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe PruneOldEventsWorker do + describe '#perform' do + let!(:service_template) do + PushoverService.create( + template: true, + properties: { + device: 'MyDevice', + sound: 'mic', + priority: 4, + api_key: '123456789' + }) + end + + let!(:project) { create(:empty_project) } + + it 'creates services for projects' do + expect { subject.perform }.to change { Service.count }.by(1) + end + + it 'does not create the service if it exists already' do + Service.build_from_template(project.id, service_template).save! + + expect { subject.perform }.not_to change { Service.count } + end + + it 'creates the service containing the template attributes' do + subject.perform + + service = Service.find_by(service_template.merge(project_id: project.id, template: false)) + + expect(service).not_to be_nil + end + end +end -- GitLab From b0dfc741c499ec72c5cad53fc1bb1ed65ee38e70 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Wed, 3 May 2017 14:47:21 +0100 Subject: [PATCH 158/363] [ci skip] Removed substring --- app/assets/javascripts/raven/raven_config.js | 2 +- spec/javascripts/raven/raven_config_spec.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/raven/raven_config.js index 43e55bc541512..c7fe1cacf49ea 100644 --- a/app/assets/javascripts/raven/raven_config.js +++ b/app/assets/javascripts/raven/raven_config.js @@ -85,7 +85,7 @@ const RavenConfig = { url: config.url, data: config.data, status: req.status, - response: responseText.substring(0, 100), + response: responseText, error, event, }, diff --git a/spec/javascripts/raven/raven_config_spec.js b/spec/javascripts/raven/raven_config_spec.js index b4c35420f9094..7b3bef3162d39 100644 --- a/spec/javascripts/raven/raven_config_spec.js +++ b/spec/javascripts/raven/raven_config_spec.js @@ -1,7 +1,7 @@ import Raven from 'raven-js'; import RavenConfig from '~/raven/raven_config'; -describe('RavenConfig', () => { +fdescribe('RavenConfig', () => { describe('IGNORE_ERRORS', () => { it('should be an array of strings', () => { const areStrings = RavenConfig.IGNORE_ERRORS.every(error => typeof error === 'string'); @@ -190,7 +190,7 @@ describe('RavenConfig', () => { url: config.url, data: config.data, status: req.status, - response: req.responseText.substring(0, 100), + response: req.responseText, error: err, event, }, @@ -211,7 +211,7 @@ describe('RavenConfig', () => { url: config.url, data: config.data, status: req.status, - response: req.responseText.substring(0, 100), + response: req.responseText, error: req.statusText, event, }, -- GitLab From 4fed7036aaf25dcf4fb4ff9660215e397654fe4c Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Wed, 3 May 2017 14:48:10 +0100 Subject: [PATCH 159/363] [ci skip] Moved the required restart message to only backend sentry field --- app/views/admin/application_settings/_form.html.haml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 6030c8b1dfa25..4b6628169ef55 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -394,8 +394,6 @@ %fieldset %legend Error Reporting and Logging - %p - These settings require a restart to take effect. .form-group .col-sm-offset-2.col-sm-10 .checkbox @@ -403,6 +401,7 @@ = f.check_box :sentry_enabled Enable Sentry .help-block + %p This setting requires a restart to take effect. Sentry is an error reporting and logging tool which is currently not shipped with GitLab, get it here: %a{ href: 'https://getsentry.com', target: '_blank', rel: 'noopener noreferrer' } https://getsentry.com -- GitLab From f3732e167b3d1ab58cbd49261ef7624959387cab Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Wed, 3 May 2017 14:51:53 +0100 Subject: [PATCH 160/363] [ci skip] Update migration to be reversible and use add_column_with_default --- ...ientside_sentry_to_application_settings.rb | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/db/migrate/20170428123910_add_clientside_sentry_to_application_settings.rb b/db/migrate/20170428123910_add_clientside_sentry_to_application_settings.rb index 380060b18b391..f3e08aed81988 100644 --- a/db/migrate/20170428123910_add_clientside_sentry_to_application_settings.rb +++ b/db/migrate/20170428123910_add_clientside_sentry_to_application_settings.rb @@ -1,12 +1,31 @@ -# rubocop:disable all class AddClientsideSentryToApplicationSettings < ActiveRecord::Migration - DOWNTIME = true - DOWNTIME_REASON = 'This migration requires downtime because we must add 2 new columns, 1 of which has a default value.' - - def change - change_table :application_settings do |t| - t.boolean :clientside_sentry_enabled, default: false - t.string :clientside_sentry_dsn - end + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + disable_ddl_transaction! + + def up + add_column_with_default :application_settings, :clientside_sentry_enabled, :boolean, default: false + add_column :application_settings, :clientside_sentry_dsn, :string + end + + def down + remove_columns :application_settings, :clientside_sentry_enabled, :clientside_sentry_dsn end end -- GitLab From 555a3cf5bbfb78d697c70339bc65047d90e26d95 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Wed, 3 May 2017 14:53:46 +0100 Subject: [PATCH 161/363] Updated schema.rb with patch mode --- db/schema.rb | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index c3fbb74e878c9..ea303678ac02e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -63,7 +63,6 @@ t.boolean "shared_runners_enabled", default: true, null: false t.integer "max_artifacts_size", default: 100, null: false t.string "runners_registration_token" - t.integer "max_pages_size", default: 100, null: false t.boolean "require_two_factor_authentication", default: false t.integer "two_factor_grace_period", default: 48 t.boolean "metrics_enabled", default: false @@ -112,16 +111,17 @@ t.boolean "html_emails_enabled", default: true t.string "plantuml_url" t.boolean "plantuml_enabled" + t.integer "max_pages_size", default: 100, null: false t.integer "terminal_max_session_time", default: 0, null: false t.string "default_artifacts_expire_in", default: "0", null: false t.integer "unique_ips_limit_per_user" t.integer "unique_ips_limit_time_window" t.boolean "unique_ips_limit_enabled", default: false, null: false t.decimal "polling_interval_multiplier", default: 1.0, null: false - t.integer "cached_markdown_version" t.boolean "usage_ping_enabled", default: true, null: false t.string "uuid" - t.boolean "clientside_sentry_enabled", default: false + t.integer "cached_markdown_version" + t.boolean "clientside_sentry_enabled", default: false, null: false t.string "clientside_sentry_dsn" end @@ -735,8 +735,8 @@ t.integer "visibility_level", default: 20, null: false t.boolean "request_access_enabled", default: false, null: false t.datetime "deleted_at" - t.text "description_html" t.boolean "lfs_enabled" + t.text "description_html" t.integer "parent_id" t.boolean "require_two_factor_authentication", default: false, null: false t.integer "two_factor_grace_period", default: 48, null: false @@ -969,9 +969,9 @@ t.boolean "lfs_enabled" t.text "description_html" t.boolean "only_allow_merge_if_all_discussions_are_resolved" - t.integer "auto_cancel_pending_pipelines", default: 0, null: false t.boolean "printing_merge_request_link_enabled", default: true, null: false t.string "import_jid" + t.integer "auto_cancel_pending_pipelines", default: 0, null: false t.integer "cached_markdown_version" end @@ -1074,7 +1074,6 @@ t.string "line_code" t.string "note_type" t.text "position" - t.string "in_reply_to_discussion_id" end add_index "sent_notifications", ["reply_key"], name: "index_sent_notifications_on_reply_key", unique: true, using: :btree @@ -1324,10 +1323,10 @@ t.string "organization" t.boolean "authorized_projects_populated" t.boolean "ghost" - t.date "last_activity_on" t.boolean "notified_of_own_activity" t.boolean "require_two_factor_authentication_from_group", default: false, null: false t.integer "two_factor_grace_period", default: 48, null: false + t.date "last_activity_on" end add_index "users", ["admin"], name: "index_users_on_admin", using: :btree -- GitLab From 264bf229277caf1c1eaca4e83921ca1b305d5401 Mon Sep 17 00:00:00 2001 From: James Lopez <james@jameslopez.es> Date: Wed, 3 May 2017 17:20:12 +0200 Subject: [PATCH 162/363] add propagate service worker and updated spec and controller --- app/controllers/admin/services_controller.rb | 2 +- app/models/service.rb | 10 ++++ .../propagate_project_service_worker.rb | 49 +++++++++++++++++++ .../propagate_project_service_worker_spec.rb | 19 ++++--- 4 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 app/workers/propagate_project_service_worker.rb diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb index 37a1a23178eb7..2b6f335cb2b1c 100644 --- a/app/controllers/admin/services_controller.rb +++ b/app/controllers/admin/services_controller.rb @@ -15,7 +15,7 @@ def edit end def update - if service.update_attributes(service_params[:service]) + if service.update_and_propagate(service_params[:service]) redirect_to admin_application_settings_services_path, notice: 'Application settings saved successfully' else diff --git a/app/models/service.rb b/app/models/service.rb index c71a7d169eca7..dea22fd96a7aa 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -254,6 +254,16 @@ def self.build_from_template(project_id, template) service end + def update_and_propagate(service_params) + return false unless update_attributes(service_params) + + if service_params[:active] == 1 + PropagateProjectServiceWorker.perform_async(service_params[:id]) + end + + true + end + private def cache_project_has_external_issue_tracker diff --git a/app/workers/propagate_project_service_worker.rb b/app/workers/propagate_project_service_worker.rb new file mode 100644 index 0000000000000..535517709682f --- /dev/null +++ b/app/workers/propagate_project_service_worker.rb @@ -0,0 +1,49 @@ +# Worker for updating any project specific caches. +class PropagateProjectServiceWorker + include Sidekiq::Worker + include DedicatedSidekiqQueue + + LEASE_TIMEOUT = 30.minutes.to_i + + def perform(template_id) + template = Service.find_by(id: template_id) + + return unless template&.active + return unless try_obtain_lease_for(template.id) + + Rails.logger.info("Propagating services for template #{template.id}") + + project_ids_for_template(template) do |project_id| + Service.build_from_template(project_id, template).save! + end + end + + private + + def project_ids_for_template(template) + limit = 100 + offset = 0 + + loop do + batch = project_ids_batch(limit, offset, template.type) + + batch.each { |project_id| yield(project_id) } + + break if batch.count < limit + + offset += limit + end + end + + def project_ids_batch(limit, offset, template_type) + Project.joins('LEFT JOIN services ON services.project_id = projects.id'). + where('services.type != ? OR services.id IS NULL', template_type). + limit(limit).offset(offset).pluck(:id) + end + + def try_obtain_lease_for(template_id) + Gitlab::ExclusiveLease. + new("propagate_project_service_worker:#{template_id}", timeout: LEASE_TIMEOUT). + try_obtain + end +end diff --git a/spec/workers/propagate_project_service_worker_spec.rb b/spec/workers/propagate_project_service_worker_spec.rb index ce01a663a8fa6..d525a8b4a236b 100644 --- a/spec/workers/propagate_project_service_worker_spec.rb +++ b/spec/workers/propagate_project_service_worker_spec.rb @@ -1,36 +1,43 @@ require 'spec_helper' -describe PruneOldEventsWorker do +describe PropagateProjectServiceWorker do describe '#perform' do let!(:service_template) do PushoverService.create( template: true, + active: true, properties: { device: 'MyDevice', sound: 'mic', priority: 4, + user_key: 'asdf', api_key: '123456789' }) end let!(:project) { create(:empty_project) } + before do + allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain). + and_return(true) + end + it 'creates services for projects' do - expect { subject.perform }.to change { Service.count }.by(1) + expect { subject.perform(service_template.id) }.to change { Service.count }.by(1) end it 'does not create the service if it exists already' do Service.build_from_template(project.id, service_template).save! - expect { subject.perform }.not_to change { Service.count } + expect { subject.perform(service_template.id) }.not_to change { Service.count } end it 'creates the service containing the template attributes' do - subject.perform + subject.perform(service_template.id) - service = Service.find_by(service_template.merge(project_id: project.id, template: false)) + service = Service.find_by(type: service_template.type, template: false) - expect(service).not_to be_nil + expect(service.properties).to eq(service_template.properties) end end end -- GitLab From 1faf398c5ddae576ef6b1aa63b7fed063c17d4f4 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Wed, 3 May 2017 16:46:18 +0100 Subject: [PATCH 163/363] Fixed schema diffs --- db/schema.rb | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index ea303678ac02e..97282e8f97088 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -63,6 +63,7 @@ t.boolean "shared_runners_enabled", default: true, null: false t.integer "max_artifacts_size", default: 100, null: false t.string "runners_registration_token" + t.integer "max_pages_size", default: 100, null: false t.boolean "require_two_factor_authentication", default: false t.integer "two_factor_grace_period", default: 48 t.boolean "metrics_enabled", default: false @@ -111,16 +112,15 @@ t.boolean "html_emails_enabled", default: true t.string "plantuml_url" t.boolean "plantuml_enabled" - t.integer "max_pages_size", default: 100, null: false t.integer "terminal_max_session_time", default: 0, null: false t.string "default_artifacts_expire_in", default: "0", null: false t.integer "unique_ips_limit_per_user" t.integer "unique_ips_limit_time_window" t.boolean "unique_ips_limit_enabled", default: false, null: false t.decimal "polling_interval_multiplier", default: 1.0, null: false + t.integer "cached_markdown_version" t.boolean "usage_ping_enabled", default: true, null: false t.string "uuid" - t.integer "cached_markdown_version" t.boolean "clientside_sentry_enabled", default: false, null: false t.string "clientside_sentry_dsn" end @@ -735,8 +735,8 @@ t.integer "visibility_level", default: 20, null: false t.boolean "request_access_enabled", default: false, null: false t.datetime "deleted_at" - t.boolean "lfs_enabled" t.text "description_html" + t.boolean "lfs_enabled" t.integer "parent_id" t.boolean "require_two_factor_authentication", default: false, null: false t.integer "two_factor_grace_period", default: 48, null: false @@ -969,9 +969,9 @@ t.boolean "lfs_enabled" t.text "description_html" t.boolean "only_allow_merge_if_all_discussions_are_resolved" + t.integer "auto_cancel_pending_pipelines", default: 0, null: false t.boolean "printing_merge_request_link_enabled", default: true, null: false t.string "import_jid" - t.integer "auto_cancel_pending_pipelines", default: 0, null: false t.integer "cached_markdown_version" end @@ -1074,6 +1074,7 @@ t.string "line_code" t.string "note_type" t.text "position" + t.string "in_reply_to_discussion_id" end add_index "sent_notifications", ["reply_key"], name: "index_sent_notifications_on_reply_key", unique: true, using: :btree @@ -1323,10 +1324,10 @@ t.string "organization" t.boolean "authorized_projects_populated" t.boolean "ghost" + t.date "last_activity_on" t.boolean "notified_of_own_activity" t.boolean "require_two_factor_authentication_from_group", default: false, null: false t.integer "two_factor_grace_period", default: 48, null: false - t.date "last_activity_on" end add_index "users", ["admin"], name: "index_users_on_admin", using: :btree -- GitLab From e6829ae88eccfdb87b004762dd35f26ab9d212cb Mon Sep 17 00:00:00 2001 From: Eric Eastwood <contact@ericeastwood.com> Date: Wed, 3 May 2017 10:47:59 -0500 Subject: [PATCH 164/363] Add enableMap to gl.GfmAutoComplete for partial re-use EE backport of https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/1800 --- app/assets/javascripts/gfm_auto_complete.js | 169 ++++++++++++-------- 1 file changed, 102 insertions(+), 67 deletions(-) diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 687a462a0d41a..f1b99023c723c 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -101,9 +101,17 @@ window.gl.GfmAutoComplete = { } } }, - setup: function(input) { + setup: function(input, enableMap = { + emojis: true, + members: true, + issues: true, + milestones: true, + mergeRequests: true, + labels: true + }) { // Add GFM auto-completion to all input fields, that accept GFM input. this.input = input || $('.js-gfm-input'); + this.enableMap = enableMap; this.setupLifecycle(); }, setupLifecycle() { @@ -115,7 +123,84 @@ window.gl.GfmAutoComplete = { $input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup')); }); }, + setupAtWho: function($input) { + if (this.enableMap.emojis) this.setupEmoji($input); + if (this.enableMap.members) this.setupMembers($input); + if (this.enableMap.issues) this.setupIssues($input); + if (this.enableMap.milestones) this.setupMilestones($input); + if (this.enableMap.mergeRequests) this.setupMergeRequests($input); + if (this.enableMap.labels) this.setupLabels($input); + + // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms + $input.filter('[data-supports-slash-commands="true"]').atwho({ + at: '/', + alias: 'commands', + searchKey: 'search', + skipSpecialCharacterTest: true, + data: this.defaultLoadingData, + displayTpl: function(value) { + if (this.isLoading(value)) return this.Loading.template; + var tpl = '<li>/${name}'; + if (value.aliases.length > 0) { + tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>'; + } + if (value.params.length > 0) { + tpl += ' <small><%- params.join(" ") %></small>'; + } + if (value.description !== '') { + tpl += '<small class="description"><i><%- description %></i></small>'; + } + tpl += '</li>'; + return _.template(tpl)(value); + }.bind(this), + insertTpl: function(value) { + var tpl = "/${name} "; + var reference_prefix = null; + if (value.params.length > 0) { + reference_prefix = value.params[0][0]; + if (/^[@%~]/.test(reference_prefix)) { + tpl += '<%- reference_prefix %>'; + } + } + return _.template(tpl)({ reference_prefix: reference_prefix }); + }, + suffix: '', + callbacks: { + sorter: this.DefaultOptions.sorter, + filter: this.DefaultOptions.filter, + beforeInsert: this.DefaultOptions.beforeInsert, + beforeSave: function(commands) { + if (gl.GfmAutoComplete.isLoading(commands)) return commands; + return $.map(commands, function(c) { + var search = c.name; + if (c.aliases.length > 0) { + search = search + " " + c.aliases.join(" "); + } + return { + name: c.name, + aliases: c.aliases, + params: c.params, + description: c.description, + search: search + }; + }); + }, + matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) { + var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi; + var match = regexp.exec(subtext); + if (match) { + return match[1]; + } else { + return null; + } + } + } + }); + return; + }, + + setupEmoji($input) { // Emoji $input.atwho({ at: ':', @@ -139,6 +224,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupMembers($input) { // Team Members $input.atwho({ at: '@', @@ -180,6 +268,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupIssues($input) { $input.atwho({ at: '#', alias: 'issues', @@ -208,6 +299,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupMilestones($input) { $input.atwho({ at: '%', alias: 'milestones', @@ -236,6 +330,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupMergeRequests($input) { $input.atwho({ at: '!', alias: 'mergerequests', @@ -264,6 +361,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupLabels($input) { $input.atwho({ at: '~', alias: 'labels', @@ -298,73 +398,8 @@ window.gl.GfmAutoComplete = { } } }); - // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms - $input.filter('[data-supports-slash-commands="true"]').atwho({ - at: '/', - alias: 'commands', - searchKey: 'search', - skipSpecialCharacterTest: true, - data: this.defaultLoadingData, - displayTpl: function(value) { - if (this.isLoading(value)) return this.Loading.template; - var tpl = '<li>/${name}'; - if (value.aliases.length > 0) { - tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>'; - } - if (value.params.length > 0) { - tpl += ' <small><%- params.join(" ") %></small>'; - } - if (value.description !== '') { - tpl += '<small class="description"><i><%- description %></i></small>'; - } - tpl += '</li>'; - return _.template(tpl)(value); - }.bind(this), - insertTpl: function(value) { - var tpl = "/${name} "; - var reference_prefix = null; - if (value.params.length > 0) { - reference_prefix = value.params[0][0]; - if (/^[@%~]/.test(reference_prefix)) { - tpl += '<%- reference_prefix %>'; - } - } - return _.template(tpl)({ reference_prefix: reference_prefix }); - }, - suffix: '', - callbacks: { - sorter: this.DefaultOptions.sorter, - filter: this.DefaultOptions.filter, - beforeInsert: this.DefaultOptions.beforeInsert, - beforeSave: function(commands) { - if (gl.GfmAutoComplete.isLoading(commands)) return commands; - return $.map(commands, function(c) { - var search = c.name; - if (c.aliases.length > 0) { - search = search + " " + c.aliases.join(" "); - } - return { - name: c.name, - aliases: c.aliases, - params: c.params, - description: c.description, - search: search - }; - }); - }, - matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) { - var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi; - var match = regexp.exec(subtext); - if (match) { - return match[1]; - } else { - return null; - } - } - } - }); - return; }, + fetchData: function($input, at) { if (this.isLoadingData[at]) return; this.isLoadingData[at] = true; -- GitLab From 5c91113c5b6edc4fa1d63bc161b791c7e84e644d Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 3 May 2017 17:13:02 +0100 Subject: [PATCH 165/363] Fixed karma specs --- spec/javascripts/deploy_keys/components/keys_panel_spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/javascripts/deploy_keys/components/keys_panel_spec.js b/spec/javascripts/deploy_keys/components/keys_panel_spec.js index 416b80525fc2d..a69b39c35c4f1 100644 --- a/spec/javascripts/deploy_keys/components/keys_panel_spec.js +++ b/spec/javascripts/deploy_keys/components/keys_panel_spec.js @@ -30,13 +30,13 @@ describe('Deploy keys panel', () => { expect( vm.$el.querySelector('h5').textContent.trim(), - ).toContain('(1)'); + ).toContain(`(${vm.keys.length})`); }); it('renders list of keys', () => { expect( vm.$el.querySelectorAll('li').length, - ).toBe(1); + ).toBe(vm.keys.length); }); it('renders help box if keys are empty', (done) => { -- GitLab From 11661d1d65b3b5b9ea71d91e56a7c263a3a4eec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Thu, 27 Apr 2017 18:55:31 +0200 Subject: [PATCH 166/363] Move retro/kickoff doc to PROCESS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable <remy@rymai.me> --- CONTRIBUTING.md | 13 ------------- PROCESS.md | 35 ++++++++++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8b4d0250ff77..8cdf314ca4a5c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -540,19 +540,6 @@ There are a few rules to get your merge request accepted: See the instructions in that document for help if your MR fails the "license-finder" test with a "Dependencies that need approval" error. -## Changes for Stable Releases - -Sometimes certain changes have to be added to an existing stable release. -Two examples are bug fixes and performance improvements. In these cases the -corresponding merge request should be updated to have the following: - -1. A milestone indicating what release the merge request should be merged into. -1. The label "Pick into Stable" - -This makes it easier for release managers to keep track of what still has to be -merged and where changes have to be merged into. -Like all merge requests the target should be master so all bugfixes are in master. - ## Definition of done If you contribute to GitLab please know that changes involve more than just diff --git a/PROCESS.md b/PROCESS.md index 735120a369ebe..e5f6ab2992776 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -1,18 +1,24 @@ +## GitLab Core Team & GitLab Inc. Team Contributing Process + +--- + <!-- START doctoc generated TOC please keep comment here to allow auto update --> <!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE --> **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* -- [GitLab Contributing Process](#gitlab-contributing-process) +- [GitLab Core Team Contributing Process](#gitlab-core-team-contributing-process) - [Purpose of describing the contributing process](#purpose-of-describing-the-contributing-process) - [Common actions](#common-actions) - [Merge request coaching](#merge-request-coaching) - - [Workflow labels](#workflow-labels) - [Assigning issues](#assigning-issues) - [Be kind](#be-kind) - [Feature freeze on the 7th for the release on the 22nd](#feature-freeze-on-the-7th-for-the-release-on-the-22nd) - [Between the 1st and the 7th](#between-the-1st-and-the-7th) - [On the 7th](#on-the-7th) - [After the 7th](#after-the-7th) + - [Release retrospective and kickoff](#release-retrospective-and-kickoff) + - [Retrospective](#retrospective) + - [Kickoff](#kickoff) - [Copy & paste responses](#copy-&-paste-responses) - [Improperly formatted issue](#improperly-formatted-issue) - [Issue report for old version](#issue-report-for-old-version) @@ -28,7 +34,7 @@ <!-- END doctoc generated TOC please keep comment here to allow auto update --> -# GitLab Core Team Contributing Process +--- ## Purpose of describing the contributing process @@ -153,6 +159,29 @@ release should have the correct milestone assigned _and_ have the label Merge requests without a milestone and this label will not be merged into any stable branches. +## Release retrospective and kickoff + +### Retrospective + +After each release, we have a retrospective call where we discuss what went well, +what went wrong, and what we can improve for the next release. The +[retrospective notes] are public and you are invited to comment on them. +If you're interested, you can even join the +[retrospective call][retro-kickoff-call], on the first working day after the +22nd at 6pm CET / 9am PST. + +### Kickoff + +Before working on the next release, we have a +kickoff call to explain what we expect to ship in the next release. The +[kickoff notes] are public and you are invited to comment on them. +If you're interested, you can even join the [kickoff call][retro-kickoff-call], +on the first working day after the 7th at 6pm CET / 9am PST.. + +[retrospective notes]: https://docs.google.com/document/d/1nEkM_7Dj4bT21GJy0Ut3By76FZqCfLBmFQNVThmW2TY/edit?usp=sharing +[kickoff notes]: https://docs.google.com/document/d/1ElPkZ90A8ey_iOkTvUs_ByMlwKK6NAB2VOK5835wYK0/edit?usp=sharing +[retro-kickoff-call]: https://gitlab.zoom.us/j/918821206 + ## Copy & paste responses ### Improperly formatted issue -- GitLab From 0374e22a1d5e8a9555106e60804ca6bb72843443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Fri, 28 Apr 2017 20:03:18 +0200 Subject: [PATCH 167/363] Improve the merge request guidelines and DoD in CONTRIBUTING.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable <remy@rymai.me> --- CONTRIBUTING.md | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8cdf314ca4a5c..bd1e0d7f2f512 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -439,13 +439,17 @@ request is as follows: "Description" field. 1. If you are contributing documentation, choose `Documentation` from the "Choose a template" menu and fill in the template. + 1. Mention the issue(s) your merge request solves, using the `Solves #XXX` or + `Closes #XXX` syntax to auto-close the issue(s) once the merge request will + be merged. +1. If you're allowed to, set a relevant milestone and labels 1. If the MR changes the UI it should include *Before* and *After* screenshots 1. If the MR changes CSS classes please include the list of affected pages, `grep css-class ./app -R` -1. Link any relevant [issues][ce-tracker] in the merge request description and - leave a comment on them with a link back to the MR 1. Be prepared to answer questions and incorporate feedback even if requests for this arrive weeks or months after your MR submission + 1. If a discussion has been addressed, select the "Resolve discussion" button + beneath it to mark it resolved. 1. If your MR touches code that executes shell commands, reads or opens files or handles paths to files on disk, make sure it adheres to the [shell command guidelines](doc/development/shell_commands.md) @@ -528,8 +532,7 @@ There are a few rules to get your merge request accepted: 1. If you need polling to support real-time features, please use [polling with ETag caching][polling-etag]. 1. Changes after submitting the merge request should be in separate commits - (no squashing). If necessary, you will be asked to squash when the review is - over, before merging. + (no squashing). 1. It conforms to the [style guides](#style-guides) and the following: - If your change touches a line that does not follow the style, modify the entire line to follow it. This prevents linting tools from generating warnings. @@ -548,16 +551,16 @@ the feature you contribute through all of these steps. 1. Description explaining the relevancy (see following item) 1. Working and clean code that is commented where needed -1. Unit and integration tests that pass on the CI server +1. [Unit and system tests][testing] that pass on the CI server 1. Performance/scalability implications have been considered, addressed, and tested -1. [Documented][doc-styleguide] in the /doc directory -1. Changelog entry added +1. [Documented][doc-styleguide] in the `/doc` directory +1. [Changelog entry added][changelog] 1. Reviewed and any concerns are addressed -1. Merged by the project lead -1. Added to the release blog article +1. Merged by a project maintainer +1. Added to the release blog article if relevant 1. Added to [the website](https://gitlab.com/gitlab-com/www-gitlab-com/) if relevant 1. Community questions answered -1. Answers to questions radiated (in docs/wiki/etc.) +1. Answers to questions radiated (in docs/wiki/support etc.) If you add a dependency in GitLab (such as an operating system package) please consider updating the following and note the applicability of each in your @@ -580,7 +583,7 @@ merge request: - string literal quoting style **Option A**: single quoted by default 1. [Rails](https://github.com/bbatsov/rails-style-guide) 1. [Newlines styleguide][newlines-styleguide] -1. [Testing](doc/development/testing.md) +1. [Testing][testing] 1. [JavaScript styleguide][js-styleguide] 1. [SCSS styleguide][scss-styleguide] 1. [Shell commands](doc/development/shell_commands.md) created by GitLab @@ -657,6 +660,7 @@ available at [http://contributor-covenant.org/version/1/1/0/](http://contributor [license-finder-doc]: doc/development/licensing.md [GitLab Inc engineering workflow]: https://about.gitlab.com/handbook/engineering/workflow/#labelling-issues [polling-etag]: https://docs.gitlab.com/ce/development/polling.html +[testing]: doc/development/testing.md [^1]: Please note that specs other than JavaScript specs are considered backend code. -- GitLab From f644b8c80a51a97e39d2f9422930efaa2b8bc334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Fri, 28 Apr 2017 20:03:47 +0200 Subject: [PATCH 168/363] Improve the Code review guidelines documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable <remy@rymai.me> --- CONTRIBUTING.md | 18 -------------- doc/development/code_review.md | 44 ++++++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd1e0d7f2f512..b8df8047a3269 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -485,24 +485,6 @@ Please ensure that your merge request meets the contribution acceptance criteria When having your code reviewed and when reviewing merge requests please take the [code review guidelines](doc/development/code_review.md) into account. -### Getting your merge request reviewed, approved, and merged - -There are a few rules to get your merge request accepted: - -1. Your merge request should only be **merged by a [maintainer][team]**. - 1. If your merge request includes only backend changes [^1], it must be - **approved by a [backend maintainer][team]**. - 1. If your merge request includes only frontend changes [^1], it must be - **approved by a [frontend maintainer][team]**. - 1. If your merge request includes frontend and backend changes [^1], it must - be **approved by a [frontend and a backend maintainer][team]**. -1. To lower the amount of merge requests maintainers need to review, you can - ask or assign any [reviewers][team] for a first review. - 1. If you need some guidance (e.g. it's your first merge request), feel free - to ask one of the [Merge request coaches][team]. - 1. The reviewer will assign the merge request to a maintainer once the - reviewer is satisfied with the state of the merge request. - ### Contribution acceptance criteria 1. The change is as small as possible diff --git a/doc/development/code_review.md b/doc/development/code_review.md index 819578404b6fb..138817f5440f4 100644 --- a/doc/development/code_review.md +++ b/doc/development/code_review.md @@ -1,5 +1,25 @@ # Code Review Guidelines +## Getting your merge request reviewed, approved, and merged + +There are a few rules to get your merge request accepted: + +1. Your merge request should only be **merged by a [maintainer][team]**. + 1. If your merge request includes only backend changes [^1], it must be + **approved by a [backend maintainer][team]**. + 1. If your merge request includes only frontend changes [^1], it must be + **approved by a [frontend maintainer][team]**. + 1. If your merge request includes frontend and backend changes [^1], it must + be **approved by a [frontend and a backend maintainer][team]**. +1. To lower the amount of merge requests maintainers need to review, you can + ask or assign any [reviewers][team] for a first review. + 1. If you need some guidance (e.g. it's your first merge request), feel free + to ask one of the [Merge request coaches][team]. + 1. The reviewer will assign the merge request to a maintainer once the + reviewer is satisfied with the state of the merge request. + +## Best practices + This guide contains advice and best practices for performing code review, and having your code reviewed. @@ -12,7 +32,7 @@ of colleagues and contributors. However, the final decision to accept a merge request is up to one the project's maintainers, denoted on the [team page](https://about.gitlab.com/team). -## Everyone +### Everyone - Accept that many programming decisions are opinions. Discuss tradeoffs, which you prefer, and reach a resolution quickly. @@ -31,8 +51,11 @@ request is up to one the project's maintainers, denoted on the - Consider one-on-one chats or video calls if there are too many "I didn't understand" or "Alternative solution:" comments. Post a follow-up comment summarizing one-on-one discussion. +- If you ask a question to a specific person, always start the comment by + mentioning them; this will ensure they see it if their notification level is + set to "mentioned" and other people will understand they don't have to respond. -## Having your code reviewed +### Having your code reviewed Please keep in mind that code review is a process that can take multiple iterations, and reviewers may spot things later that they may not have seen the @@ -50,11 +73,12 @@ first time. - Extract unrelated changes and refactorings into future merge requests/issues. - Seek to understand the reviewer's perspective. - Try to respond to every comment. +- Let the reviewer select the "Resolve discussion" buttons. - Push commits based on earlier rounds of feedback as isolated commits to the branch. Do not squash until the branch is ready to merge. Reviewers should be able to read individual updates based on their earlier feedback. -## Reviewing code +### Reviewing code Understand why the change is necessary (fixes a bug, improves the user experience, refactors the existing code). Then: @@ -69,12 +93,22 @@ experience, refactors the existing code). Then: someone else would be confused by it as well. - After a round of line notes, it can be helpful to post a summary note such as "LGTM :thumbsup:", or "Just a couple things to address." +- Assign the merge request to the author if changes are required following your + review. +- You should try to resolve merge conflicts yourself, using the [merge conflict + resolution][conflict-resolution] tool. +- Set the milestone before merging a merge request. - Avoid accepting a merge request before the job succeeds. Of course, "Merge When Pipeline Succeeds" (MWPS) is fine. - If you set the MR to "Merge When Pipeline Succeeds", you should take over subsequent revisions for anything that would be spotted after that. +- Consider using the [Squash and + merge][squash-and-merge] feature when the merge request has a lot of commits. + +[conflict-resolution]: https://docs.gitlab.com/ce/user/project/merge_requests/resolve_conflicts.html#merge-conflict-resolution +[squash-and-merge]: https://docs.gitlab.com/ee/user/project/merge_requests/squash_and_merge.html#squash-and-merge -## The right balance +### The right balance One of the most difficult things during code review is finding the right balance in how deep the reviewer can interfere with the code created by a @@ -100,7 +134,7 @@ reviewee. tomorrow. When you are not able to find the right balance, ask other people about their opinion. -## Credits +### Credits Largely based on the [thoughtbot code review guide]. -- GitLab From cb87903c6eec14e42fc23e0488f6c0769ba627d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Wed, 3 May 2017 17:59:59 +0200 Subject: [PATCH 169/363] Update ToC of CONTRIBUTING.md and PROCESS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable <remy@rymai.me> --- CONTRIBUTING.md | 5 ++--- PROCESS.md | 49 ++++++++++++++++++++++++------------------------- 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b8df8047a3269..f27efb0ae85b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,9 +22,9 @@ _This notice should stay as the first item in the CONTRIBUTING.MD file._ - [Type labels (~"feature proposal", ~bug, ~customer, etc.)](#type-labels-feature-proposal-bug-customer-etc) - [Subject labels (~wiki, ~"container registry", ~ldap, ~api, etc.)](#subject-labels-wiki-container-registry-ldap-api-etc) - [Team labels (~CI, ~Discussion, ~Edge, ~Frontend, ~Platform, etc.)](#team-labels-ci-discussion-edge-frontend-platform-etc) - - [Priority labels (`Deliverable` and `Stretch`)](#priority-labels-deliverable-and-stretch) + - [Priority labels (~Deliverable and ~Stretch)](#priority-labels-deliverable-and-stretch) - [Label for community contributors (~"Accepting Merge Requests")](#label-for-community-contributors-accepting-merge-requests) -- [Implement design & UI elements](#implement-design-&-ui-elements) +- [Implement design & UI elements](#implement-design--ui-elements) - [Issue tracker](#issue-tracker) - [Issue triaging](#issue-triaging) - [Feature proposals](#feature-proposals) @@ -35,7 +35,6 @@ _This notice should stay as the first item in the CONTRIBUTING.MD file._ - [Stewardship](#stewardship) - [Merge requests](#merge-requests) - [Merge request guidelines](#merge-request-guidelines) - - [Getting your merge request reviewed, approved, and merged](#getting-your-merge-request-reviewed-approved-and-merged) - [Contribution acceptance criteria](#contribution-acceptance-criteria) - [Definition of done](#definition-of-done) - [Style guides](#style-guides) diff --git a/PROCESS.md b/PROCESS.md index e5f6ab2992776..6d7d155ca6cf1 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -6,31 +6,30 @@ <!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE --> **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* -- [GitLab Core Team Contributing Process](#gitlab-core-team-contributing-process) - - [Purpose of describing the contributing process](#purpose-of-describing-the-contributing-process) - - [Common actions](#common-actions) - - [Merge request coaching](#merge-request-coaching) - - [Assigning issues](#assigning-issues) - - [Be kind](#be-kind) - - [Feature freeze on the 7th for the release on the 22nd](#feature-freeze-on-the-7th-for-the-release-on-the-22nd) - - [Between the 1st and the 7th](#between-the-1st-and-the-7th) - - [On the 7th](#on-the-7th) - - [After the 7th](#after-the-7th) - - [Release retrospective and kickoff](#release-retrospective-and-kickoff) - - [Retrospective](#retrospective) - - [Kickoff](#kickoff) - - [Copy & paste responses](#copy-&-paste-responses) - - [Improperly formatted issue](#improperly-formatted-issue) - - [Issue report for old version](#issue-report-for-old-version) - - [Support requests and configuration questions](#support-requests-and-configuration-questions) - - [Code format](#code-format) - - [Issue fixed in newer version](#issue-fixed-in-newer-version) - - [Improperly formatted merge request](#improperly-formatted-merge-request) - - [Inactivity close of an issue](#inactivity-close-of-an-issue) - - [Inactivity close of a merge request](#inactivity-close-of-a-merge-request) - - [Accepting merge requests](#accepting-merge-requests) - - [Only accepting merge requests with green tests](#only-accepting-merge-requests-with-green-tests) - - [Closing down the issue tracker on GitHub](#closing-down-the-issue-tracker-on-github) +- [Purpose of describing the contributing process](#purpose-of-describing-the-contributing-process) +- [Common actions](#common-actions) + - [Merge request coaching](#merge-request-coaching) +- [Assigning issues](#assigning-issues) +- [Be kind](#be-kind) +- [Feature freeze on the 7th for the release on the 22nd](#feature-freeze-on-the-7th-for-the-release-on-the-22nd) + - [Between the 1st and the 7th](#between-the-1st-and-the-7th) + - [On the 7th](#on-the-7th) + - [After the 7th](#after-the-7th) +- [Release retrospective and kickoff](#release-retrospective-and-kickoff) + - [Retrospective](#retrospective) + - [Kickoff](#kickoff) +- [Copy & paste responses](#copy--paste-responses) + - [Improperly formatted issue](#improperly-formatted-issue) + - [Issue report for old version](#issue-report-for-old-version) + - [Support requests and configuration questions](#support-requests-and-configuration-questions) + - [Code format](#code-format) + - [Issue fixed in newer version](#issue-fixed-in-newer-version) + - [Improperly formatted merge request](#improperly-formatted-merge-request) + - [Inactivity close of an issue](#inactivity-close-of-an-issue) + - [Inactivity close of a merge request](#inactivity-close-of-a-merge-request) + - [Accepting merge requests](#accepting-merge-requests) + - [Only accepting merge requests with green tests](#only-accepting-merge-requests-with-green-tests) + - [Closing down the issue tracker on GitHub](#closing-down-the-issue-tracker-on-github) <!-- END doctoc generated TOC please keep comment here to allow auto update --> -- GitLab From b6a1658e535ca0c97e9a06a355b5bff90b01bb18 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Wed, 3 May 2017 17:37:52 +0000 Subject: [PATCH 170/363] Remove focused kamra test --- spec/javascripts/raven/raven_config_spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/javascripts/raven/raven_config_spec.js b/spec/javascripts/raven/raven_config_spec.js index 7b3bef3162d39..2a2b91fe9480c 100644 --- a/spec/javascripts/raven/raven_config_spec.js +++ b/spec/javascripts/raven/raven_config_spec.js @@ -1,7 +1,7 @@ import Raven from 'raven-js'; import RavenConfig from '~/raven/raven_config'; -fdescribe('RavenConfig', () => { +describe('RavenConfig', () => { describe('IGNORE_ERRORS', () => { it('should be an array of strings', () => { const areStrings = RavenConfig.IGNORE_ERRORS.every(error => typeof error === 'string'); -- GitLab From ee65de48d8285ec86b83ad6bb0bf95f23d53c6ce Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Wed, 3 May 2017 12:40:21 -0500 Subject: [PATCH 171/363] More translations updates. --- app/assets/javascripts/locale/de/app.js | 2 +- app/assets/javascripts/locale/en/app.js | 2 +- app/assets/javascripts/locale/es/app.js | 2 +- .../projects/cycle_analytics/show.html.haml | 4 +- lib/gitlab/cycle_analytics/code_stage.rb | 2 +- lib/gitlab/cycle_analytics/issue_stage.rb | 2 +- lib/gitlab/cycle_analytics/plan_stage.rb | 2 +- .../cycle_analytics/production_stage.rb | 2 +- lib/gitlab/cycle_analytics/review_stage.rb | 2 +- lib/gitlab/cycle_analytics/staging_stage.rb | 2 +- lib/gitlab/cycle_analytics/test_stage.rb | 2 +- locale/de/gitlab.po | 19 ++++--- locale/en/gitlab.po | 19 ++++--- locale/es/gitlab.po | 56 +++++++++---------- locale/gitlab.pot | 23 ++++---- locale/unfound_translations.rb | 13 +++-- 16 files changed, 79 insertions(+), 75 deletions(-) diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js index a7922e911bdec..e1e4a2b7138f9 100644 --- a/app/assets/javascripts/locale/de/app.js +++ b/app/assets/javascripts/locale/de/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Author|by":[""],"Commits":[""],"Cycle":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Cycle|Code":[""],"Cycle|Issue":[""],"Cycle|Plan":[""],"Cycle|Review":[""],"Cycle|Staging":[""],"Cycle|Test":[""],"Deploys":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"OfFirstTime|First":[""],"Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""],"pushed by":[""]}}}; \ No newline at end of file +var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Author|by":[""],"Commits":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploys":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"OfFirstTime|First":[""],"Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""],"pushed by":[""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/en/app.js b/app/assets/javascripts/locale/en/app.js index 3ed43559e74ee..c14e7b41ad137 100644 --- a/app/assets/javascripts/locale/en/app.js +++ b/app/assets/javascripts/locale/en/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Author|by":[""],"Commits":[""],"Cycle":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"Cycle|Code":[""],"Cycle|Issue":[""],"Cycle|Plan":[""],"Cycle|Review":[""],"Cycle|Staging":[""],"Cycle|Test":[""],"Deploys":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"OfFirstTime|First":[""],"Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""],"pushed by":[""]}}}; \ No newline at end of file +var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Author|by":[""],"Commits":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploys":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"OfFirstTime|First":[""],"Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""],"pushed by":[""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/es/app.js b/app/assets/javascripts/locale/es/app.js index b91dcbd1ff36c..9e09a28c0e55c 100644 --- a/app/assets/javascripts/locale/es/app.js +++ b/app/assets/javascripts/locale/es/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-02 23:17-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Author|by":["por"],"Commits":["Cambios"],"Cycle":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"Cycle|Code":["Código"],"Cycle|Issue":["Tema"],"Cycle|Plan":["Planificación"],"Cycle|Review":["Revisión"],"Cycle|Staging":["Etapa"],"Cycle|Test":["Pruebas"],"Deploys":["Despliegues"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last %d day":["Últimos %d dÃas"],"Limited to showing %d event at most":["Limitado a mostrar máximo %d eventos"],"Median":["Mediana"],"New Issues":["Nuevos temas"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"OfFirstTime|First":["Primer"],"Opened":["Abiertos"],"Pipeline Health":["Estado del Pipeline"],"ProjectLifecycle|Stage":["Etapa"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Issues":["Temas Relacionados"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Relative Builds Trigger by Commits":["Builds Relacionadas Originadas por Cambios"],"Relative Deployed Builds":["Builds Desplegadas Relacionadas"],"Relative Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":["La colección de eventos agregados a los datos recopilados para esa etapa."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de temas muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":["La etapa del ciclo de vida del desarrollo."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de un tema y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["El tiempo utilizado por cada entrada de datos obtenido por esa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":["Tiempo Total"],"Want to see the data? Please ask an administrator for access.":["¿Quieres ver los datos? Por favor pedir acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"You need permission.":["Necesitas permisos."],"day":["dÃa","dÃas"],"pushed by":["enviado por"]}}}; \ No newline at end of file +var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-03 12:28-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Author|by":["por"],"Commits":["Cambios"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"CycleAnalyticsStage":[""],"CycleAnalyticsStage|Code":["Código"],"CycleAnalyticsStage|Issue":["Incidencia"],"CycleAnalyticsStage|Plan":["Planificación"],"CycleAnalyticsStage|Production":["Producción"],"CycleAnalyticsStage|Review":["Revisión"],"CycleAnalyticsStage|Staging":["Puesta en escena"],"CycleAnalyticsStage|Test":["Pruebas"],"Deploys":["Despliegues"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last %d day":["Último %d dÃa","Últimos %d dÃas"],"Limited to showing %d event at most":["Limitado a mostrar máximo %d evento","Limitado a mostrar máximo %d eventos"],"Median":["Mediana"],"New Issues":["Nuevas incidencias"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"OfFirstTime|First":["Primer"],"Opened":["Abiertos"],"Pipeline Health":["Estado del Pipeline"],"ProjectLifecycle|Stage":["Etapa"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Issues":["Incidencias Relacionadas"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Relative Builds Trigger by Commits":["Builds Relacionados Originados por Cambios"],"Relative Deployed Builds":["Builds Desplegados Relacionados"],"Relative Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":["La colección de eventos agregados a los datos recopilados para esa etapa."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":["La etapa del ciclo de vida del desarrollo."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["El tiempo utilizado por cada entrada de datos obtenido por esa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":["Tiempo Total"],"Want to see the data? Please ask an administrator for access.":["¿Quieres ver los datos? Por favor pedir acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"You need permission.":["Necesitas permisos."],"day":["dÃa","dÃas"],"pushed by":["enviado por"]}}}; \ No newline at end of file diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index 376b1369405c5..5c1426e49953b 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -31,7 +31,7 @@ .row .col-sm-3.col-xs-12.column{ "v-for" => "item in state.summary" } %h3.header {{ item.value }} - %p.text {{ s__('Cycle', item.title) }} + %p.text {{ __(item.title) }} .col-sm-3.col-xs-12.column .dropdown.inline.js-ca-dropdown %button.dropdown-menu-toggle{ "data-toggle" => "dropdown", :type => "button" } @@ -70,7 +70,7 @@ %ul %li.stage-nav-item{ ':class' => '{ active: stage.active }', '@click' => 'selectStage(stage)', "v-for" => "stage in state.stages" } .stage-nav-item-cell.stage-name - {{ s__('Cycle', stage.title) }} + {{ s__('CycleAnalyticsStage', stage.title) }} .stage-nav-item-cell.stage-median %template{ "v-if" => "stage.isUserAllowed" } %span{ "v-if" => "stage.value" } diff --git a/lib/gitlab/cycle_analytics/code_stage.rb b/lib/gitlab/cycle_analytics/code_stage.rb index afb8196484775..182d4c43fbda9 100644 --- a/lib/gitlab/cycle_analytics/code_stage.rb +++ b/lib/gitlab/cycle_analytics/code_stage.rb @@ -14,7 +14,7 @@ def name end def legend - N_("Related Merge Requests") + _("Related Merge Requests") end def description diff --git a/lib/gitlab/cycle_analytics/issue_stage.rb b/lib/gitlab/cycle_analytics/issue_stage.rb index d36967d9497f8..08e249116a44b 100644 --- a/lib/gitlab/cycle_analytics/issue_stage.rb +++ b/lib/gitlab/cycle_analytics/issue_stage.rb @@ -15,7 +15,7 @@ def name end def legend - N_("Related Issues") + _("Related Issues") end def description diff --git a/lib/gitlab/cycle_analytics/plan_stage.rb b/lib/gitlab/cycle_analytics/plan_stage.rb index 7231c45378874..c5b2809209ced 100644 --- a/lib/gitlab/cycle_analytics/plan_stage.rb +++ b/lib/gitlab/cycle_analytics/plan_stage.rb @@ -15,7 +15,7 @@ def name end def legend - N_("Related Commits") + _("Related Commits") end def description diff --git a/lib/gitlab/cycle_analytics/production_stage.rb b/lib/gitlab/cycle_analytics/production_stage.rb index 451720c81214f..8f05c840db195 100644 --- a/lib/gitlab/cycle_analytics/production_stage.rb +++ b/lib/gitlab/cycle_analytics/production_stage.rb @@ -16,7 +16,7 @@ def name end def legend - N_("Related Issues") + _("Related Issues") end def description diff --git a/lib/gitlab/cycle_analytics/review_stage.rb b/lib/gitlab/cycle_analytics/review_stage.rb index 08e895acad628..4f4c94f4020d4 100644 --- a/lib/gitlab/cycle_analytics/review_stage.rb +++ b/lib/gitlab/cycle_analytics/review_stage.rb @@ -14,7 +14,7 @@ def name end def legend - N_("Relative Merged Requests") + _("Relative Merged Requests") end def description diff --git a/lib/gitlab/cycle_analytics/staging_stage.rb b/lib/gitlab/cycle_analytics/staging_stage.rb index 706a5d14d3f35..da8eb8c55de74 100644 --- a/lib/gitlab/cycle_analytics/staging_stage.rb +++ b/lib/gitlab/cycle_analytics/staging_stage.rb @@ -15,7 +15,7 @@ def name end def legend - N_("Relative Deployed Builds") + _("Relative Deployed Builds") end def description diff --git a/lib/gitlab/cycle_analytics/test_stage.rb b/lib/gitlab/cycle_analytics/test_stage.rb index 887cf3e09416f..3ef7132bcc398 100644 --- a/lib/gitlab/cycle_analytics/test_stage.rb +++ b/lib/gitlab/cycle_analytics/test_stage.rb @@ -14,7 +14,7 @@ def name end def legend - N_("Relative Builds Trigger by Commits") + _("Relative Builds Trigger by Commits") end def description diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po index d2ea50e5a7835..ea19349bac10e 100644 --- a/locale/de/gitlab.po +++ b/locale/de/gitlab.po @@ -23,28 +23,31 @@ msgstr "" msgid "Commits" msgstr "" -msgid "Cycle" +msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "" -msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." +msgid "CycleAnalyticsStage" +msgstr "" + +msgid "CycleAnalyticsStage|Code" msgstr "" -msgid "Cycle|Code" +msgid "CycleAnalyticsStage|Issue" msgstr "" -msgid "Cycle|Issue" +msgid "CycleAnalyticsStage|Plan" msgstr "" -msgid "Cycle|Plan" +msgid "CycleAnalyticsStage|Production" msgstr "" -msgid "Cycle|Review" +msgid "CycleAnalyticsStage|Review" msgstr "" -msgid "Cycle|Staging" +msgid "CycleAnalyticsStage|Staging" msgstr "" -msgid "Cycle|Test" +msgid "CycleAnalyticsStage|Test" msgstr "" msgid "Deploys" diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po index f473ce2e0dcd4..2fed2aa1f57e9 100644 --- a/locale/en/gitlab.po +++ b/locale/en/gitlab.po @@ -23,28 +23,31 @@ msgstr "" msgid "Commits" msgstr "" -msgid "Cycle" +msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "" -msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." +msgid "CycleAnalyticsStage" +msgstr "" + +msgid "CycleAnalyticsStage|Code" msgstr "" -msgid "Cycle|Code" +msgid "CycleAnalyticsStage|Issue" msgstr "" -msgid "Cycle|Issue" +msgid "CycleAnalyticsStage|Plan" msgstr "" -msgid "Cycle|Plan" +msgid "CycleAnalyticsStage|Production" msgstr "" -msgid "Cycle|Review" +msgid "CycleAnalyticsStage|Review" msgstr "" -msgid "Cycle|Staging" +msgid "CycleAnalyticsStage|Staging" msgstr "" -msgid "Cycle|Test" +msgid "CycleAnalyticsStage|Test" msgstr "" msgid "Deploys" diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po index 91135a983f653..254b7ad18020d 100644 --- a/locale/es/gitlab.po +++ b/locale/es/gitlab.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2017-05-02 23:17-0500\n" +"PO-Revision-Date: 2017-05-03 12:28-0500\n" "Language-Team: Spanish\n" "Language: es\n" "MIME-Version: 1.0\n" @@ -23,34 +23,32 @@ msgstr "por" msgid "Commits" msgstr "Cambios" -msgid "Cycle" -msgstr "" - msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto." -#, fuzzy -msgid "Cycle|Code" +msgid "CycleAnalyticsStage" +msgstr "" + +msgid "CycleAnalyticsStage|Code" msgstr "Código" -#, fuzzy -msgid "Cycle|Issue" -msgstr "Tema" +msgid "CycleAnalyticsStage|Issue" +msgstr "Incidencia" -#, fuzzy -msgid "Cycle|Plan" +msgid "CycleAnalyticsStage|Plan" msgstr "Planificación" -#, fuzzy -msgid "Cycle|Review" +msgid "CycleAnalyticsStage|Production" +msgstr "Producción" + +msgid "CycleAnalyticsStage|Review" msgstr "Revisión" #, fuzzy -msgid "Cycle|Staging" -msgstr "Etapa" +msgid "CycleAnalyticsStage|Staging" +msgstr "Puesta en escena" -#, fuzzy -msgid "Cycle|Test" +msgid "CycleAnalyticsStage|Test" msgstr "Pruebas" msgid "Deploys" @@ -59,21 +57,21 @@ msgstr "Despliegues" msgid "Introducing Cycle Analytics" msgstr "Introducción a Cycle Analytics" -#, fuzzy msgid "Last %d day" msgid_plural "Last %d days" -msgstr[0] "Últimos %d dÃas" +msgstr[0] "Último %d dÃa" +msgstr[1] "Últimos %d dÃas" -#, fuzzy msgid "Limited to showing %d event at most" msgid_plural "Limited to showing %d events at most" -msgstr[0] "Limitado a mostrar máximo %d eventos" +msgstr[0] "Limitado a mostrar máximo %d evento" +msgstr[1] "Limitado a mostrar máximo %d eventos" msgid "Median" msgstr "Mediana" msgid "New Issues" -msgstr "Nuevos temas" +msgstr "Nuevas incidencias" msgid "Not available" msgstr "No disponible" @@ -81,7 +79,6 @@ msgstr "No disponible" msgid "Not enough data" msgstr "No hay suficientes datos" -#, fuzzy msgid "OfFirstTime|First" msgstr "Primer" @@ -101,18 +98,16 @@ msgid "Related Commits" msgstr "Cambios Relacionados" msgid "Related Issues" -msgstr "Temas Relacionados" +msgstr "Incidencias Relacionadas" msgid "Related Merge Requests" msgstr "Solicitudes de fusión Relacionadas" -#, fuzzy msgid "Relative Builds Trigger by Commits" -msgstr "Builds Relacionadas Originadas por Cambios" +msgstr "Builds Relacionados Originados por Cambios" -#, fuzzy msgid "Relative Deployed Builds" -msgstr "Builds Desplegadas Relacionadas" +msgstr "Builds Desplegados Relacionados" msgid "Relative Merged Requests" msgstr "Solicitudes de fusión Relacionadas" @@ -129,7 +124,7 @@ msgid "The collection of events added to the data gathered for that stage." msgstr "La colección de eventos agregados a los datos recopilados para esa etapa." msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." -msgstr "La etapa de temas muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa." +msgstr "La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa." msgid "The phase of the development lifecycle." msgstr "La etapa del ciclo de vida del desarrollo." @@ -138,7 +133,7 @@ msgid "The planning stage shows the time from the previous step to pushing your msgstr "La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primer cambio." msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." -msgstr "La etapa de producción muestra el tiempo total que tarda entre la creación de un tema y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción." +msgstr "La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción." msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." msgstr "La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión." @@ -171,7 +166,6 @@ msgstr "" msgid "Total Time" msgstr "Tiempo Total" -#, fuzzy msgid "Want to see the data? Please ask an administrator for access." msgstr "¿Quieres ver los datos? Por favor pedir acceso al administrador." diff --git a/locale/gitlab.pot b/locale/gitlab.pot index bc16b7a0968c4..8f4dc30b8c861 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-05-03 12:00+0100\n" -"PO-Revision-Date: 2017-05-03 12:00+0100\n" +"POT-Creation-Date: 2017-05-03 12:32-0500\n" +"PO-Revision-Date: 2017-05-03 12:32-0500\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" "Language: \n" @@ -24,28 +24,31 @@ msgstr "" msgid "Commits" msgstr "" -msgid "Cycle" +msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "" -msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." +msgid "CycleAnalyticsStage" +msgstr "" + +msgid "CycleAnalyticsStage|Code" msgstr "" -msgid "Cycle|Code" +msgid "CycleAnalyticsStage|Issue" msgstr "" -msgid "Cycle|Issue" +msgid "CycleAnalyticsStage|Plan" msgstr "" -msgid "Cycle|Plan" +msgid "CycleAnalyticsStage|Production" msgstr "" -msgid "Cycle|Review" +msgid "CycleAnalyticsStage|Review" msgstr "" -msgid "Cycle|Staging" +msgid "CycleAnalyticsStage|Staging" msgstr "" -msgid "Cycle|Test" +msgid "CycleAnalyticsStage|Test" msgstr "" msgid "Deploys" diff --git a/locale/unfound_translations.rb b/locale/unfound_translations.rb index e38b49b48a17c..e66211c161923 100644 --- a/locale/unfound_translations.rb +++ b/locale/unfound_translations.rb @@ -1,9 +1,10 @@ N_('Commits') -N_('Cycle|Code') -N_('Cycle|Issue') -N_('Cycle|Plan') -N_('Cycle|Review') -N_('Cycle|Staging') -N_('Cycle|Test') +N_('CycleAnalyticsStage|Code') +N_('CycleAnalyticsStage|Issue') +N_('CycleAnalyticsStage|Plan') +N_('CycleAnalyticsStage|Production') +N_('CycleAnalyticsStage|Review') +N_('CycleAnalyticsStage|Staging') +N_('CycleAnalyticsStage|Test') N_('Deploys') N_('New Issues') -- GitLab From b7f01f2b18940eb8dc0fa30e8ebbf1784c864304 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 3 May 2017 20:52:18 +0100 Subject: [PATCH 172/363] Moved all the text translation manipulation into the locale index file Commented the translation methods --- app/assets/javascripts/locale/index.js | 48 +++++++++++++++++-- .../javascripts/vue_shared/translate.js | 39 +++++++++++---- 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js index c9df5f3723af7..7ba676d6d20aa 100644 --- a/app/assets/javascripts/locale/index.js +++ b/app/assets/javascripts/locale/index.js @@ -1,5 +1,9 @@ import Jed from 'jed'; +/** + This is required to require all the translation folders in the current directory + this saves us having to do this manually & keep up to date with new languages +**/ function requireAll(requireContext) { return requireContext.keys().map(requireContext); } const allLocales = requireAll(require.context('./', true, /^(?!.*(?:index.js$)).*\.js$/)); @@ -16,11 +20,47 @@ let lang = document.querySelector('html').getAttribute('lang') || 'en'; lang = lang.replace(/-/g, '_'); const locale = new Jed(locales[lang]); + +/** + Translates `text` + + @param text The text to be translated + @returns {String} The translated text +**/ const gettext = locale.gettext.bind(locale); -const ngettext = locale.ngettext.bind(locale); -const pgettext = (context, key) => { - const joinedKey = [context, key].join('|'); - return gettext(joinedKey).split('|').pop(); + +/** + Translate the text with a number + if the number is more than 1 it will use the `pluralText` translation. + This method allows for contexts, see below re. contexts + + @param text Singular text to translate (eg. '%d day') + @param pluralText Plural text to translate (eg. '%d days') + @param count Number to decide which translation to use (eg. 2) + @returns {String} Translated text with the number replaced (eg. '2 days') +**/ +const ngettext = (text, pluralText, count) => { + const translated = locale.ngettext(text, pluralText, count).replace(/%d/g, count).split('|'); + + return translated[translated.length - 1]; +}; + +/** + Translate context based text + Either pass in the context translation like `Context|Text to translate` + or allow for dynamic text by doing passing in the context first & then the text to translate + + @param keyOrContext Can be either the key to translate including the context + (eg. 'Context|Text') or just the context for the translation + (eg. 'Context') + @param key Is the dynamic variable you want to be translated + @returns {String} Translated context based text +**/ +const pgettext = (keyOrContext, key) => { + const normalizedKey = key ? `${keyOrContext}|${key}` : keyOrContext; + const translated = gettext(normalizedKey).split('|'); + + return translated[translated.length - 1]; }; export { lang }; diff --git a/app/assets/javascripts/vue_shared/translate.js b/app/assets/javascripts/vue_shared/translate.js index 07ef00c10d06a..ba9656d4e71e8 100644 --- a/app/assets/javascripts/vue_shared/translate.js +++ b/app/assets/javascripts/vue_shared/translate.js @@ -1,23 +1,42 @@ import { __, n__, + s__, } from '../locale'; export default (Vue) => { Vue.mixin({ methods: { + /** + Translates `text` + + @param text The text to be translated + @returns {String} The translated text + **/ __(text) { return __(text); }, - n__(text, pluralText, count) { - const translated = n__(text, pluralText, count).replace(/%d/g, count).split('|'); - return translated[translated.length - 1]; - }, - s__(keyOrContext, key) { - const normalizedKey = key ? `${keyOrContext}|${key}` : keyOrContext; - // eslint-disable-next-line no-underscore-dangle - const translated = this.__(normalizedKey).split('|'); + /** + Translate the text with a number + if the number is more than 1 it will use the `pluralText` translation. + This method allows for contexts, see below re. contexts + + @param text Singular text to translate (eg. '%d day') + @param pluralText Plural text to translate (eg. '%d days') + @param count Number to decide which translation to use (eg. 2) + @returns {String} Translated text with the number replaced (eg. '2 days') + **/ + n__(text, pluralText, count) { return n__(text, pluralText, count); }, + /** + Translate context based text + Either pass in the context translation like `Context|Text to translate` + or allow for dynamic text by doing passing in the context first & then the text to translate - return translated[translated.length - 1]; - }, + @param keyOrContext Can be either the key to translate including the context + (eg. 'Context|Text') or just the context for the translation + (eg. 'Context') + @param key Is the dynamic variable you want to be translated + @returns {String} Translated context based text + **/ + s__(keyOrContext, key) { return s__(keyOrContext, key); }, }, }); }; -- GitLab From 93a20ea87261c05e34db708f33441c36d2dc68bd Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Wed, 3 May 2017 14:35:54 -0600 Subject: [PATCH 173/363] fix tasks list spec in rspec --- app/views/projects/issues/_issue.html.haml | 8 +++++++ spec/features/task_lists_spec.rb | 27 +++++++++++----------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 6199569a20a9e..064a7bd1f2606 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -16,6 +16,10 @@ - if issue.assignee %li = link_to_member(@project, issue.assignee, name: false, title: "Assigned to :name") + - if issue.tasks? + + %span.task-status + = issue.task_status = render 'shared/issuable_meta_data', issuable: issue @@ -37,6 +41,10 @@ - issue.labels.each do |label| = link_to_label(label, subject: issue.project, css_class: 'label-link') + - if issue.tasks? + + %span.task-status + = issue.task_status .pull-right.issue-updated-at %span updated #{time_ago_with_tooltip(issue.updated_at, placement: 'bottom', html_class: 'issue_update_ago')} diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb index f55c5aaf646be..8bd13caf2b0be 100644 --- a/spec/features/task_lists_spec.rb +++ b/spec/features/task_lists_spec.rb @@ -65,13 +65,12 @@ def visit_issue(project, issue) describe 'for Issues', feature: true do describe 'multiple tasks', js: true do include WaitForVueResource - - before { wait_for_vue_resource } let!(:issue) { create(:issue, description: markdown, author: user, project: project) } it 'renders' do visit_issue(project, issue) + wait_for_vue_resource expect(page).to have_selector('ul.task-list', count: 1) expect(page).to have_selector('li.task-list-item', count: 6) @@ -80,25 +79,24 @@ def visit_issue(project, issue) it 'contains the required selectors' do visit_issue(project, issue) + wait_for_vue_resource - container = '.detail-page-description .description.js-task-list-container' - - expect(page).to have_selector(container) - expect(page).to have_selector("#{container} .wiki .task-list .task-list-item .task-list-item-checkbox") - expect(page).to have_selector("#{container} .js-task-list-field") - expect(page).to have_selector('form.js-issuable-update') + expect(page).to have_selector(".wiki .task-list .task-list-item .task-list-item-checkbox") expect(page).to have_selector('a.btn-close') end it 'is only editable by author' do visit_issue(project, issue) + wait_for_vue_resource - expect(page).to have_selector('.js-task-list-container') + expect(page).to have_selector(".wiki .task-list .task-list-item .task-list-item-checkbox") logout(:user) login_as(user2) visit current_path - expect(page).not_to have_selector('.js-task-list-container') + wait_for_vue_resource + + expect(page).to have_selector(".wiki .task-list .task-list-item .task-list-item-checkbox") end it 'provides a summary on Issues#index' do @@ -112,10 +110,9 @@ def visit_issue(project, issue) let!(:issue) { create(:issue, description: singleIncompleteMarkdown, author: user, project: project) } - before { wait_for_vue_resource } - it 'renders' do visit_issue(project, issue) + wait_for_vue_resource expect(page).to have_selector('ul.task-list', count: 1) expect(page).to have_selector('li.task-list-item', count: 1) @@ -124,15 +121,18 @@ def visit_issue(project, issue) it 'provides a summary on Issues#index' do visit namespace_project_issues_path(project.namespace, project) + expect(page).to have_content("0 of 1 task completed") end end - describe 'single complete task' do + describe 'single complete task', js: true do + include WaitForVueResource let!(:issue) { create(:issue, description: singleCompleteMarkdown, author: user, project: project) } it 'renders' do visit_issue(project, issue) + wait_for_vue_resource expect(page).to have_selector('ul.task-list', count: 1) expect(page).to have_selector('li.task-list-item', count: 1) @@ -141,6 +141,7 @@ def visit_issue(project, issue) it 'provides a summary on Issues#index' do visit namespace_project_issues_path(project.namespace, project) + expect(page).to have_content("1 of 1 task completed") end end -- GitLab From c45341c816d78d51aee84a6068d778b9cbc502c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Rodr=C3=ADguez?= <alejorro70@gmail.com> Date: Fri, 28 Apr 2017 13:52:09 -0300 Subject: [PATCH 174/363] Generate and handle a gl_repository param to pass around components This new param allows us to share project information between components that don't share or don't have access to the same filesystem mountpoints, for example between Gitaly and Rails or between Rails and Gitlab-Shell hooks. The previous parameters are still supported, but if found, gl_repository is prefered. The old parameters should be deprecated once all components support the new format. --- ...onger-send-absolute-paths-to-gitlab-ce.yml | 4 + lib/api/helpers/internal_helpers.rb | 52 ++++--------- lib/api/internal.rb | 10 ++- lib/gitlab/gl_repository.rb | 16 ++++ lib/gitlab/repo_path.rb | 29 ++++--- spec/lib/gitlab/gl_repository_spec.rb | 19 +++++ spec/lib/gitlab/repo_path_spec.rb | 24 ++++++ .../api/helpers/internal_helpers_spec.rb | 32 -------- spec/requests/api/internal_spec.rb | 77 ++++++++++++++++++- spec/support/matchers/gitlab_git_matchers.rb | 6 ++ 10 files changed, 184 insertions(+), 85 deletions(-) create mode 100644 changelogs/unreleased/29925-gitlab-shell-hooks-can-no-longer-send-absolute-paths-to-gitlab-ce.yml create mode 100644 lib/gitlab/gl_repository.rb create mode 100644 spec/lib/gitlab/gl_repository_spec.rb delete mode 100644 spec/requests/api/helpers/internal_helpers_spec.rb create mode 100644 spec/support/matchers/gitlab_git_matchers.rb diff --git a/changelogs/unreleased/29925-gitlab-shell-hooks-can-no-longer-send-absolute-paths-to-gitlab-ce.yml b/changelogs/unreleased/29925-gitlab-shell-hooks-can-no-longer-send-absolute-paths-to-gitlab-ce.yml new file mode 100644 index 0000000000000..1df8f695ef1ac --- /dev/null +++ b/changelogs/unreleased/29925-gitlab-shell-hooks-can-no-longer-send-absolute-paths-to-gitlab-ce.yml @@ -0,0 +1,4 @@ +--- +title: Generate and handle a gl_repository param to pass around components +merge_request: 10992 +author: diff --git a/lib/api/helpers/internal_helpers.rb b/lib/api/helpers/internal_helpers.rb index 718f936a1fc5a..264df7271a3b0 100644 --- a/lib/api/helpers/internal_helpers.rb +++ b/lib/api/helpers/internal_helpers.rb @@ -1,48 +1,14 @@ module API module Helpers module InternalHelpers - # Project paths may be any of the following: - # * /repository/storage/path/namespace/project - # * /namespace/project - # * namespace/project - # - # In addition, they may have a '.git' extension and multiple namespaces - # - # Transform all these cases to 'namespace/project' - def clean_project_path(project_path, storages = Gitlab.config.repositories.storages.values) - project_path = project_path.sub(/\.git\z/, '') - - storages.each do |storage| - storage_path = File.expand_path(storage['path']) - - if project_path.start_with?(storage_path) - project_path = project_path.sub(storage_path, '') - break - end - end - - project_path.sub(/\A\//, '') - end - - def project_path - @project_path ||= clean_project_path(params[:project]) - end - def wiki? - @wiki ||= project_path.end_with?('.wiki') && - !Project.find_by_full_path(project_path) + set_project unless defined?(@wiki) + @wiki end def project - @project ||= begin - # Check for *.wiki repositories. - # Strip out the .wiki from the pathname before finding the - # project. This applies the correct project permissions to - # the wiki repository as well. - project_path.chomp!('.wiki') if wiki? - - Project.find_by_full_path(project_path) - end + set_project unless defined?(@project) + @project end def ssh_authentication_abilities @@ -66,6 +32,16 @@ def log_user_activity(actor) ::Users::ActivityService.new(actor, 'Git SSH').execute if commands.include?(params[:action]) end + + private + + def set_project + if params[:gl_repository] + @project, @wiki = Gitlab::GlRepository.parse(params[:gl_repository]) + else + @project, @wiki = Gitlab::RepoPath.parse(params[:project]) + end + end end end end diff --git a/lib/api/internal.rb b/lib/api/internal.rb index ebed26dd17866..ddb2047f68621 100644 --- a/lib/api/internal.rb +++ b/lib/api/internal.rb @@ -42,6 +42,10 @@ class Internal < Grape::API if access_status.status log_user_activity(actor) + # Project id to pass between components that don't share/don't have + # access to the same filesystem mounts + response[:gl_repository] = "#{wiki? ? 'wiki' : 'project'}-#{project.id}" + # Return the repository full path so that gitlab-shell has it when # handling ssh commands response[:repository_path] = @@ -134,11 +138,9 @@ class Internal < Grape::API return unless Gitlab::GitalyClient.enabled? - relative_path = Gitlab::RepoPath.strip_storage_path(params[:repo_path]) - project = Project.find_by_full_path(relative_path.sub(/\.(git|wiki)\z/, '')) - begin - Gitlab::GitalyClient::Notifications.new(project.repository).post_receive + repository = wiki? ? project.wiki.repository : project.repository + Gitlab::GitalyClient::Notifications.new(repository.raw_repository).post_receive rescue GRPC::Unavailable => e render_api_error!(e, 500) end diff --git a/lib/gitlab/gl_repository.rb b/lib/gitlab/gl_repository.rb new file mode 100644 index 0000000000000..6997546049ebb --- /dev/null +++ b/lib/gitlab/gl_repository.rb @@ -0,0 +1,16 @@ +module Gitlab + module GlRepository + def self.parse(gl_repository) + match_data = /\A(project|wiki)-([1-9][0-9]*)\z/.match(gl_repository) + unless match_data + raise ArgumentError, "Invalid GL Repository \"#{gl_repository}\"" + end + + type, id = match_data.captures + project = Project.find_by(id: id) + wiki = type == 'wiki' + + [project, wiki] + end + end +end diff --git a/lib/gitlab/repo_path.rb b/lib/gitlab/repo_path.rb index 4b1d828c45ca3..878e03f61d70d 100644 --- a/lib/gitlab/repo_path.rb +++ b/lib/gitlab/repo_path.rb @@ -2,18 +2,29 @@ module Gitlab module RepoPath NotFoundError = Class.new(StandardError) - def self.strip_storage_path(repo_path) - result = nil + def self.parse(repo_path) + project_path = strip_storage_path(repo_path.sub(/\.git\z/, ''), fail_on_not_found: false) + project = Project.find_by_full_path(project_path) + if project_path.end_with?('.wiki') && !project + project = Project.find_by_full_path(project_path.chomp('.wiki')) + wiki = true + else + wiki = false + end + + [project, wiki] + end + + def self.strip_storage_path(repo_path, fail_on_not_found: true) + result = repo_path - Gitlab.config.repositories.storages.values.each do |params| - storage_path = params['path'] - if repo_path.start_with?(storage_path) - result = repo_path.sub(storage_path, '') - break - end + storage = Gitlab.config.repositories.storages.values.find do |params| + repo_path.start_with?(params['path']) end - if result.nil? + if storage + result = result.sub(storage['path'], '') + elsif fail_on_not_found raise NotFoundError.new("No known storage path matches #{repo_path.inspect}") end diff --git a/spec/lib/gitlab/gl_repository_spec.rb b/spec/lib/gitlab/gl_repository_spec.rb new file mode 100644 index 0000000000000..ac3558ab38685 --- /dev/null +++ b/spec/lib/gitlab/gl_repository_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe ::Gitlab::GlRepository do + describe '.parse' do + set(:project) { create(:project) } + + it 'parses a project gl_repository' do + expect(described_class.parse("project-#{project.id}")).to eq([project, false]) + end + + it 'parses a wiki gl_repository' do + expect(described_class.parse("wiki-#{project.id}")).to eq([project, true]) + end + + it 'throws an argument error on an invalid gl_repository' do + expect { described_class.parse("badformat-#{project.id}") }.to raise_error(ArgumentError) + end + end +end diff --git a/spec/lib/gitlab/repo_path_spec.rb b/spec/lib/gitlab/repo_path_spec.rb index 0fb5d7646f220..f94c9c2e3155f 100644 --- a/spec/lib/gitlab/repo_path_spec.rb +++ b/spec/lib/gitlab/repo_path_spec.rb @@ -1,6 +1,30 @@ require 'spec_helper' describe ::Gitlab::RepoPath do + describe '.parse' do + set(:project) { create(:project) } + + it 'parses a full repository path' do + expect(described_class.parse(project.repository.path)).to eq([project, false]) + end + + it 'parses a full wiki path' do + expect(described_class.parse(project.wiki.repository.path)).to eq([project, true]) + end + + it 'parses a relative repository path' do + expect(described_class.parse(project.full_path + '.git')).to eq([project, false]) + end + + it 'parses a relative wiki path' do + expect(described_class.parse(project.full_path + '.wiki.git')).to eq([project, true]) + end + + it 'parses a relative path starting with /' do + expect(described_class.parse('/' + project.full_path + '.git')).to eq([project, false]) + end + end + describe '.strip_storage_path' do before do allow(Gitlab.config.repositories).to receive(:storages).and_return({ diff --git a/spec/requests/api/helpers/internal_helpers_spec.rb b/spec/requests/api/helpers/internal_helpers_spec.rb deleted file mode 100644 index db716b340f1c3..0000000000000 --- a/spec/requests/api/helpers/internal_helpers_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -require 'spec_helper' - -describe ::API::Helpers::InternalHelpers do - include described_class - - describe '.clean_project_path' do - project = 'namespace/project' - namespaced = File.join('namespace2', project) - - { - File.join(Dir.pwd, project) => project, - File.join(Dir.pwd, namespaced) => namespaced, - project => project, - namespaced => namespaced, - project + '.git' => project, - namespaced + '.git' => namespaced, - "/" + project => project, - "/" + namespaced => namespaced, - }.each do |project_path, expected| - context project_path do - # Relative and absolute storage paths, with and without trailing / - ['.', './', Dir.pwd, Dir.pwd + '/'].each do |storage_path| - context "storage path is #{storage_path}" do - subject { clean_project_path(project_path, [{ 'path' => storage_path }]) } - - it { is_expected.to eq(expected) } - end - end - end - end - end -end diff --git a/spec/requests/api/internal_spec.rb b/spec/requests/api/internal_spec.rb index 429f1a4e375c6..2ceb4648ece67 100644 --- a/spec/requests/api/internal_spec.rb +++ b/spec/requests/api/internal_spec.rb @@ -180,6 +180,7 @@ expect(response).to have_http_status(200) expect(json_response["status"]).to be_truthy expect(json_response["repository_path"]).to eq(project.wiki.repository.path_to_repo) + expect(json_response["gl_repository"]).to eq("wiki-#{project.id}") expect(user).not_to have_an_activity_record end end @@ -191,6 +192,7 @@ expect(response).to have_http_status(200) expect(json_response["status"]).to be_truthy expect(json_response["repository_path"]).to eq(project.wiki.repository.path_to_repo) + expect(json_response["gl_repository"]).to eq("wiki-#{project.id}") expect(user).to have_an_activity_record end end @@ -202,6 +204,7 @@ expect(response).to have_http_status(200) expect(json_response["status"]).to be_truthy expect(json_response["repository_path"]).to eq(project.repository.path_to_repo) + expect(json_response["gl_repository"]).to eq("project-#{project.id}") expect(user).to have_an_activity_record end end @@ -213,6 +216,7 @@ expect(response).to have_http_status(200) expect(json_response["status"]).to be_truthy expect(json_response["repository_path"]).to eq(project.repository.path_to_repo) + expect(json_response["gl_repository"]).to eq("project-#{project.id}") expect(user).not_to have_an_activity_record end @@ -223,6 +227,7 @@ expect(response).to have_http_status(200) expect(json_response["status"]).to be_truthy expect(json_response["repository_path"]).to eq(project.repository.path_to_repo) + expect(json_response["gl_repository"]).to eq("project-#{project.id}") end end @@ -233,6 +238,7 @@ expect(response).to have_http_status(200) expect(json_response["status"]).to be_truthy expect(json_response["repository_path"]).to eq(project.repository.path_to_repo) + expect(json_response["gl_repository"]).to eq("project-#{project.id}") end end end @@ -444,18 +450,39 @@ expect(json_response).to eq([]) end + + context 'with a gl_repository parameter' do + let(:gl_repository) { "project-#{project.id}" } + + it 'returns link to create new merge request' do + get api("/internal/merge_request_urls?gl_repository=#{gl_repository}&changes=#{changes}"), secret_token: secret_token + + expect(json_response).to match [{ + "branch_name" => "new_branch", + "url" => "http://#{Gitlab.config.gitlab.host}/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=new_branch", + "new_merge_request" => true + }] + end + end end describe 'POST /notify_post_receive' do let(:valid_params) do - { repo_path: project.repository.path, secret_token: secret_token } + { project: project.repository.path, secret_token: secret_token } + end + + let(:valid_wiki_params) do + { project: project.wiki.repository.path, secret_token: secret_token } end before do allow(Gitlab.config.gitaly).to receive(:enabled).and_return(true) end - it "calls the Gitaly client if it's enabled" do + it "calls the Gitaly client with the project's repository" do + expect(Gitlab::GitalyClient::Notifications). + to receive(:new).with(gitlab_git_repository_with(path: project.repository.path)). + and_call_original expect_any_instance_of(Gitlab::GitalyClient::Notifications). to receive(:post_receive) @@ -464,6 +491,18 @@ expect(response).to have_http_status(200) end + it "calls the Gitaly client with the wiki's repository if it's a wiki" do + expect(Gitlab::GitalyClient::Notifications). + to receive(:new).with(gitlab_git_repository_with(path: project.wiki.repository.path)). + and_call_original + expect_any_instance_of(Gitlab::GitalyClient::Notifications). + to receive(:post_receive) + + post api("/internal/notify_post_receive"), valid_wiki_params + + expect(response).to have_http_status(200) + end + it "returns 500 if the gitaly call fails" do expect_any_instance_of(Gitlab::GitalyClient::Notifications). to receive(:post_receive).and_raise(GRPC::Unavailable) @@ -472,6 +511,40 @@ expect(response).to have_http_status(500) end + + context 'with a gl_repository parameter' do + let(:valid_params) do + { gl_repository: "project-#{project.id}", secret_token: secret_token } + end + + let(:valid_wiki_params) do + { gl_repository: "wiki-#{project.id}", secret_token: secret_token } + end + + it "calls the Gitaly client with the project's repository" do + expect(Gitlab::GitalyClient::Notifications). + to receive(:new).with(gitlab_git_repository_with(path: project.repository.path)). + and_call_original + expect_any_instance_of(Gitlab::GitalyClient::Notifications). + to receive(:post_receive) + + post api("/internal/notify_post_receive"), valid_params + + expect(response).to have_http_status(200) + end + + it "calls the Gitaly client with the wiki's repository if it's a wiki" do + expect(Gitlab::GitalyClient::Notifications). + to receive(:new).with(gitlab_git_repository_with(path: project.wiki.repository.path)). + and_call_original + expect_any_instance_of(Gitlab::GitalyClient::Notifications). + to receive(:post_receive) + + post api("/internal/notify_post_receive"), valid_wiki_params + + expect(response).to have_http_status(200) + end + end end def project_with_repo_path(path) diff --git a/spec/support/matchers/gitlab_git_matchers.rb b/spec/support/matchers/gitlab_git_matchers.rb new file mode 100644 index 0000000000000..c840cd4bf2d95 --- /dev/null +++ b/spec/support/matchers/gitlab_git_matchers.rb @@ -0,0 +1,6 @@ +RSpec::Matchers.define :gitlab_git_repository_with do |values| + match do |actual| + actual.is_a?(Gitlab::Git::Repository) && + values.all? { |k, v| actual.send(k) == v } + end +end -- GitLab From 9773b5a3d4a736d933fc8ff3b6652981507b2cbd Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Wed, 3 May 2017 14:51:28 -0600 Subject: [PATCH 175/363] make js true --- .../support/features/issuable_slash_commands_shared_examples.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb index 5e7eca1d987d4..4de61966daa5b 100644 --- a/spec/support/features/issuable_slash_commands_shared_examples.rb +++ b/spec/support/features/issuable_slash_commands_shared_examples.rb @@ -27,7 +27,7 @@ wait_for_ajax end - describe "new #{issuable_type}" do + describe "new #{issuable_type}", js: true do context 'with commands in the description' do it "creates the #{issuable_type} and interpret commands accordingly" do visit public_send("new_namespace_project_#{issuable_type}_path", project.namespace, project, new_url_opts) -- GitLab From 0a725c80ea6a0ac0544188c743ee3755190334d8 Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Wed, 3 May 2017 15:17:25 -0600 Subject: [PATCH 176/363] pass failed spinach spec --- features/project/issues/issues.feature | 1 + 1 file changed, 1 insertion(+) diff --git a/features/project/issues/issues.feature b/features/project/issues/issues.feature index 4dee0cd23dc20..1b00d8a32a0c2 100644 --- a/features/project/issues/issues.feature +++ b/features/project/issues/issues.feature @@ -82,6 +82,7 @@ Feature: Project Issues # Markdown + @javascript Scenario: Headers inside the description should have ids generated for them. Given I visit issue page "Release 0.4" Then Header "Description header" should have correct id and link -- GitLab From 7822e4a480cd74f5ba744e936901d710239a1eeb Mon Sep 17 00:00:00 2001 From: Annabel Dunstone Gray <annabel.dunstone@gmail.com> Date: Wed, 3 May 2017 16:30:59 -0500 Subject: [PATCH 177/363] Add tooltips to note action buttons --- app/views/projects/notes/_actions.html.haml | 6 +++--- app/views/snippets/notes/_actions.html.haml | 6 +++--- .../unreleased/31760-add-tooltips-to-note-actions.yml | 4 ++++ 3 files changed, 10 insertions(+), 6 deletions(-) create mode 100644 changelogs/unreleased/31760-add-tooltips-to-note-actions.yml diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml index 718b52dd82e81..d70ec8a6062a5 100644 --- a/app/views/projects/notes/_actions.html.haml +++ b/app/views/projects/notes/_actions.html.haml @@ -31,14 +31,14 @@ - if current_user - if note.emoji_awardable? - user_authored = note.user_authored?(current_user) - = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored}", data: { position: 'right' } do + = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do = icon('spinner spin') %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile') - if note_editable - = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do + = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip' do = icon('pencil', class: 'link-highlight') - = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger' do + = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger has-tooltip' do = icon('trash-o', class: 'danger-highlight') diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml index dace11e547461..679a5e934da0e 100644 --- a/app/views/snippets/notes/_actions.html.haml +++ b/app/views/snippets/notes/_actions.html.haml @@ -1,13 +1,13 @@ - if current_user - if note.emoji_awardable? - user_authored = note.user_authored?(current_user) - = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored}", data: { position: 'right' } do + = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do = icon('spinner spin') %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile') - if note_editable - = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do + = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip' do = icon('pencil', class: 'link-highlight') - = link_to snippet_note_path(note.noteable, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger' do + = link_to snippet_note_path(note.noteable, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger has-tooltip' do = icon('trash-o', class: 'danger-highlight') diff --git a/changelogs/unreleased/31760-add-tooltips-to-note-actions.yml b/changelogs/unreleased/31760-add-tooltips-to-note-actions.yml new file mode 100644 index 0000000000000..9bbf43d652e37 --- /dev/null +++ b/changelogs/unreleased/31760-add-tooltips-to-note-actions.yml @@ -0,0 +1,4 @@ +--- +title: Add tooltips to note action buttons +merge_request: +author: -- GitLab From 4fd874219318b4ac195264f390c1b04912aff7e1 Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Wed, 3 May 2017 17:09:30 -0600 Subject: [PATCH 178/363] fix failing slash command specs --- .../features/issuable_slash_commands_shared_examples.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb index 4de61966daa5b..a67940e447a0b 100644 --- a/spec/support/features/issuable_slash_commands_shared_examples.rb +++ b/spec/support/features/issuable_slash_commands_shared_examples.rb @@ -3,7 +3,6 @@ shared_examples 'issuable record that supports slash commands in its description and notes' do |issuable_type| include SlashCommandsHelpers - include WaitForVueResource let(:master) { create(:user) } let(:assignee) { create(:user, username: 'bob') } @@ -19,7 +18,6 @@ project.team << [assignee, :developer] project.team << [guest, :guest] login_with(master) - wait_for_vue_resource end after do @@ -46,7 +44,7 @@ end end - describe "note on #{issuable_type}" do + describe "note on #{issuable_type}", js: true do before do visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) end -- GitLab From 323596f68efe95b479a0dc29832b8033a1eddef0 Mon Sep 17 00:00:00 2001 From: blackst0ne <blackst0ne.ru@gmail.com> Date: Sat, 29 Apr 2017 10:54:37 +1100 Subject: [PATCH 179/363] Add system note on description change of issue/merge request --- app/helpers/system_note_helper.rb | 1 + app/models/system_note_metadata.rb | 2 +- app/services/issuable_base_service.rb | 8 ++++++++ app/services/system_note_service.rb | 17 +++++++++++++++++ app/views/projects/issues/show.html.haml | 2 +- .../merge_requests/show/_mr_box.html.haml | 2 +- .../add_system_note_for_editing_issuable.yml | 4 ++++ spec/services/system_note_service_spec.rb | 14 ++++++++++++++ 8 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 changelogs/unreleased/add_system_note_for_editing_issuable.yml diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index 1ea60e3938644..d889d1411018f 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -1,6 +1,7 @@ module SystemNoteHelper ICON_NAMES_BY_ACTION = { 'commit' => 'icon_commit', + 'description' => 'icon_edit', 'merge' => 'icon_merge', 'merged' => 'icon_merged', 'opened' => 'icon_status_open', diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 1e6fc837a758c..b44f4fe000c73 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -1,6 +1,6 @@ class SystemNoteMetadata < ActiveRecord::Base ICON_TYPES = %w[ - commit merge confidential visible label assignee cross_reference + commit description merge confidential visible label assignee cross_reference title time_tracking branch milestone discussion task moved opened closed merged ].freeze diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index b071a3984811e..7072d78b28da1 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -24,6 +24,10 @@ def create_title_change_note(issuable, old_title) issuable, issuable.project, current_user, old_title) end + def create_description_change_note(issuable) + SystemNoteService.change_description(issuable, issuable.project, current_user) + end + def create_branch_change_note(issuable, branch_type, old_branch, new_branch) SystemNoteService.change_branch( issuable, issuable.project, current_user, branch_type, @@ -289,6 +293,10 @@ def handle_common_system_notes(issuable, old_labels: []) create_title_change_note(issuable, issuable.previous_changes['title'].first) end + if issuable.previous_changes.include?('description') + create_description_change_note(issuable) + end + if issuable.previous_changes.include?('description') && issuable.tasks? create_task_status_note(issuable) end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index c9e25c7aaa20e..82694305a9263 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -261,6 +261,23 @@ def change_title(noteable, project, author, old_title) create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) end + # Called when the description of a Noteable is changed + # + # noteable - Noteable object that responds to `description` + # project - Project owning noteable + # author - User performing the change + # + # Example Note text: + # + # "changed the description" + # + # Returns the created Note object + def change_description(noteable, project, author) + body = "changed the description" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'description')) + end + # Called when the confidentiality changes # # issue - Issue object diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 2a871966aa832..9bddc3ac44d58 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -61,7 +61,7 @@ = markdown_field(@issue, :description) %textarea.hidden.js-task-list-field = @issue.description - = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue_edited_ago') + = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue_edited_ago', include_author: true) #merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } } // This element is filled in using JavaScript. diff --git a/app/views/projects/merge_requests/show/_mr_box.html.haml b/app/views/projects/merge_requests/show/_mr_box.html.haml index 8a390cf870044..3b2cbb12a8509 100644 --- a/app/views/projects/merge_requests/show/_mr_box.html.haml +++ b/app/views/projects/merge_requests/show/_mr_box.html.haml @@ -10,4 +10,4 @@ %textarea.hidden.js-task-list-field = @merge_request.description - = edited_time_ago_with_tooltip(@merge_request, placement: 'bottom') + = edited_time_ago_with_tooltip(@merge_request, placement: 'bottom', include_author: true) diff --git a/changelogs/unreleased/add_system_note_for_editing_issuable.yml b/changelogs/unreleased/add_system_note_for_editing_issuable.yml new file mode 100644 index 0000000000000..3cbc7f91bf007 --- /dev/null +++ b/changelogs/unreleased/add_system_note_for_editing_issuable.yml @@ -0,0 +1,4 @@ +--- +title: Add system note on description change of issue/merge request +merge_request: 10392 +author: blackst0ne diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 75d7caf2508cd..5e85c3c162186 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -292,6 +292,20 @@ end end + describe '.change_description' do + subject { described_class.change_description(noteable, project, author) } + + context 'when noteable responds to `description`' do + it_behaves_like 'a system note' do + let(:action) { 'description' } + end + + it 'sets the note text' do + expect(subject.note).to eq 'changed the description' + end + end + end + describe '.change_issue_confidentiality' do subject { described_class.change_issue_confidentiality(noteable, project, author) } -- GitLab From b82870afc0031cb830976fb36cd1237c6059397f Mon Sep 17 00:00:00 2001 From: blackst0ne <blackst0ne.ru@gmail.com> Date: Tue, 2 May 2017 09:49:30 +1100 Subject: [PATCH 180/363] Add author to 'Edited time ago by ...' message --- app/helpers/application_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index fff57472a4f1d..b9d375d9b64d1 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -187,7 +187,7 @@ def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago output = content_tag(:span, "Edited ") output << time_ago_with_tooltip(object.updated_at, placement: placement, html_class: html_class) - if include_author && object.updated_by && object.updated_by != object.author + if include_author && object.updated_by output << content_tag(:span, " by ") output << link_to_member(object.project, object.updated_by, avatar: false, author_class: nil) end -- GitLab From 7ad5a1b371f5d319369a6b9d6609c40dea6292c2 Mon Sep 17 00:00:00 2001 From: blackst0ne <blackst0ne.ru@gmail.com> Date: Wed, 3 May 2017 16:32:21 +1100 Subject: [PATCH 181/363] Add last_edited_at and last_edited_by attributes --- app/helpers/application_helper.rb | 14 +++++++------- app/models/concerns/issuable.rb | 1 + app/models/note.rb | 1 + app/services/issuable_base_service.rb | 11 +++++++++++ app/services/notes/update_service.rb | 4 ++++ ...st_edited_at_and_last_edited_by_id_to_issues.rb | 14 ++++++++++++++ ...ast_edited_at_and_last_edited_by_id_to_notes.rb | 14 ++++++++++++++ ...d_at_and_last_edited_by_id_to_merge_requests.rb | 14 ++++++++++++++ db/schema.rb | 8 +++++++- 9 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 db/migrate/20170503021915_add_last_edited_at_and_last_edited_by_id_to_issues.rb create mode 100644 db/migrate/20170503022512_add_last_edited_at_and_last_edited_by_id_to_notes.rb create mode 100644 db/migrate/20170503022548_add_last_edited_at_and_last_edited_by_id_to_merge_requests.rb diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index b9d375d9b64d1..0ff6ab488084d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -181,15 +181,15 @@ def time_ago_with_tooltip(time, placement: 'top', html_class: '', short_format: end def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', include_author: false) - return if object.updated_at == object.created_at + return if object.last_edited_at == object.created_at || object.last_edited_at.blank? - content_tag :small, class: "edited-text" do - output = content_tag(:span, "Edited ") - output << time_ago_with_tooltip(object.updated_at, placement: placement, html_class: html_class) + content_tag :small, class: 'edited-text' do + output = content_tag(:span, 'Edited ') + output << time_ago_with_tooltip(object.last_edited_at, placement: placement, html_class: html_class) - if include_author && object.updated_by - output << content_tag(:span, " by ") - output << link_to_member(object.project, object.updated_by, avatar: false, author_class: nil) + if include_author && object.last_edited_by + output << content_tag(:span, ' by ') + output << link_to_member(object.project, object.last_edited_by, avatar: false, author_class: nil) end output diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 26dbf4d95708f..de1ad840daa2e 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -28,6 +28,7 @@ module Issuable belongs_to :author, class_name: "User" belongs_to :assignee, class_name: "User" belongs_to :updated_by, class_name: "User" + belongs_to :last_edited_by, class_name: 'User' belongs_to :milestone has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do def authors_loaded? diff --git a/app/models/note.rb b/app/models/note.rb index b06985b4a6f81..943211ca991e4 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -38,6 +38,7 @@ class Note < ActiveRecord::Base belongs_to :noteable, polymorphic: true, touch: true belongs_to :author, class_name: "User" belongs_to :updated_by, class_name: "User" + belongs_to :last_edited_by, class_name: 'User' has_many :todos, dependent: :destroy has_many :events, as: :target, dependent: :destroy diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 7072d78b28da1..889e450875827 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -218,6 +218,13 @@ def update(issuable) if issuable.changed? || params.present? issuable.assign_attributes(params.merge(updated_by: current_user)) + if has_title_or_description_changed?(issuable) + issuable.assign_attributes(params.merge( + last_edited_at: Time.now, + last_edited_by: current_user + )) + end + before_update(issuable) if issuable.with_transaction_returning_status { issuable.save } @@ -240,6 +247,10 @@ def labels_changing?(old_label_ids, new_label_ids) old_label_ids.sort != new_label_ids.sort end + def has_title_or_description_changed?(issuable) + issuable.title_changed? || issuable.description_changed? + end + def change_state(issuable) case params.delete(:state_event) when 'reopen' diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb index 75fd08ea0a95c..4b4f383abf2f0 100644 --- a/app/services/notes/update_service.rb +++ b/app/services/notes/update_service.rb @@ -5,7 +5,11 @@ def execute(note) old_mentioned_users = note.mentioned_users.to_a + note.assign_attributes(params) + params.merge!(last_edited_at: Time.now, last_edited_by: current_user) if note.note_changed? + note.update_attributes(params.merge(updated_by: current_user)) + note.create_new_cross_references!(current_user) if note.previous_changes.include?('note') diff --git a/db/migrate/20170503021915_add_last_edited_at_and_last_edited_by_id_to_issues.rb b/db/migrate/20170503021915_add_last_edited_at_and_last_edited_by_id_to_issues.rb new file mode 100644 index 0000000000000..6ac10723c82f9 --- /dev/null +++ b/db/migrate/20170503021915_add_last_edited_at_and_last_edited_by_id_to_issues.rb @@ -0,0 +1,14 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddLastEditedAtAndLastEditedByIdToIssues < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + add_column :issues, :last_edited_at, :timestamp + add_column :issues, :last_edited_by_id, :integer + end +end diff --git a/db/migrate/20170503022512_add_last_edited_at_and_last_edited_by_id_to_notes.rb b/db/migrate/20170503022512_add_last_edited_at_and_last_edited_by_id_to_notes.rb new file mode 100644 index 0000000000000..a44a1feab16c4 --- /dev/null +++ b/db/migrate/20170503022512_add_last_edited_at_and_last_edited_by_id_to_notes.rb @@ -0,0 +1,14 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddLastEditedAtAndLastEditedByIdToNotes < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + add_column :notes, :last_edited_at, :timestamp + add_column :notes, :last_edited_by_id, :integer + end +end diff --git a/db/migrate/20170503022548_add_last_edited_at_and_last_edited_by_id_to_merge_requests.rb b/db/migrate/20170503022548_add_last_edited_at_and_last_edited_by_id_to_merge_requests.rb new file mode 100644 index 0000000000000..7a1acdcbf69ef --- /dev/null +++ b/db/migrate/20170503022548_add_last_edited_at_and_last_edited_by_id_to_merge_requests.rb @@ -0,0 +1,14 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddLastEditedAtAndLastEditedByIdToMergeRequests < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + add_column :merge_requests, :last_edited_at, :timestamp + add_column :merge_requests, :last_edited_by_id, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index be6684f3a6b4e..b173b467abf86 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170426181740) do +ActiveRecord::Schema.define(version: 20170503022548) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -488,6 +488,8 @@ t.integer "relative_position" t.datetime "closed_at" t.integer "cached_markdown_version" + t.datetime "last_edited_at" + t.integer "last_edited_by_id" end add_index "issues", ["assignee_id"], name: "index_issues_on_assignee_id", using: :btree @@ -674,6 +676,8 @@ t.text "description_html" t.integer "time_estimate" t.integer "cached_markdown_version" + t.datetime "last_edited_at" + t.integer "last_edited_by_id" end add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree @@ -774,6 +778,8 @@ t.string "discussion_id" t.text "note_html" t.integer "cached_markdown_version" + t.datetime "last_edited_at" + t.integer "last_edited_by_id" end add_index "notes", ["author_id"], name: "index_notes_on_author_id", using: :btree -- GitLab From 571c832d31690ba549d936c6cd921309eadad519 Mon Sep 17 00:00:00 2001 From: blackst0ne <blackst0ne.ru@gmail.com> Date: Wed, 3 May 2017 16:47:14 +1100 Subject: [PATCH 182/363] Simplified assign_attributes for issuable --- app/services/issuable_base_service.rb | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 889e450875827..e77799e548c5d 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -216,15 +216,12 @@ def update(issuable) params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids) if issuable.changed? || params.present? - issuable.assign_attributes(params.merge(updated_by: current_user)) - if has_title_or_description_changed?(issuable) - issuable.assign_attributes(params.merge( - last_edited_at: Time.now, - last_edited_by: current_user - )) + issuable.assign_attributes(last_edited_at: Time.now, last_edited_by: current_user) end + issuable.assign_attributes(params.merge(updated_by: current_user)) + before_update(issuable) if issuable.with_transaction_returning_status { issuable.save } -- GitLab From d9f48e45a124530211e0a65d25f5d41db5842c62 Mon Sep 17 00:00:00 2001 From: blackst0ne <blackst0ne.ru@gmail.com> Date: Wed, 3 May 2017 16:49:00 +1100 Subject: [PATCH 183/363] Prefer single quotes --- app/services/system_note_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 82694305a9263..7db634fbca181 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -273,7 +273,7 @@ def change_title(noteable, project, author, old_title) # # Returns the created Note object def change_description(noteable, project, author) - body = "changed the description" + body = 'changed the description' create_note(NoteSummary.new(noteable, project, author, body, action: 'description')) end -- GitLab From 35160a97f2dc781209515af4cdce6ff33e039cc9 Mon Sep 17 00:00:00 2001 From: blackst0ne <blackst0ne.ru@gmail.com> Date: Wed, 3 May 2017 17:54:00 +1100 Subject: [PATCH 184/363] Add specs for issue and note changes --- app/services/issuable_base_service.rb | 4 ++-- spec/features/issues_spec.rb | 22 ++++++++++++++++++++++ spec/services/notes/update_service_spec.rb | 7 +++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index e77799e548c5d..d0805fd6eb471 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -216,12 +216,12 @@ def update(issuable) params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids) if issuable.changed? || params.present? + issuable.assign_attributes(params.merge(updated_by: current_user)) + if has_title_or_description_changed?(issuable) issuable.assign_attributes(last_edited_at: Time.now, last_edited_by: current_user) end - issuable.assign_attributes(params.merge(updated_by: current_user)) - before_update(issuable) if issuable.with_transaction_returning_status { issuable.save } diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index 81cc8513454ef..93b7880b96f97 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -714,4 +714,26 @@ expect(page).to have_text("updated title") end end + + describe '"edited by" message', js: true do + let(:issue) { create(:issue, project: project, author: @user) } + + context 'when issue is updated' do + before { visit edit_namespace_project_issue_path(project.namespace, project, issue) } + + it 'shows "edited by" mesage on title update' do + fill_in 'issue_title', with: 'hello world' + click_button 'Save changes' + + expect(page).to have_content("Edited less than a minute ago by #{@user.name}") + end + + it 'shows "edited by" mesage on description update' do + fill_in 'issue_description', with: 'hello world' + click_button 'Save changes' + + expect(page).to have_content("Edited less than a minute ago by #{@user.name}") + end + end + end end diff --git a/spec/services/notes/update_service_spec.rb b/spec/services/notes/update_service_spec.rb index 905e2f46bde41..8c3e4607f3e42 100644 --- a/spec/services/notes/update_service_spec.rb +++ b/spec/services/notes/update_service_spec.rb @@ -20,6 +20,13 @@ def update_note(opts) @note.reload end + it 'updates last_edited_at and last_edited_by attributes' do + update_note({ note: 'Hello world!' }) + + expect(@note.last_edited_at).not_to be_nil + expect(@note.last_edited_by).not_to be_nil + end + context 'todos' do let!(:todo) { create(:todo, :assigned, user: user, project: project, target: issue, author: user2) } -- GitLab From c2b869a119f7400d748a8a97e4abc3137bc7c51d Mon Sep 17 00:00:00 2001 From: blackst0ne <blackst0ne.ru@gmail.com> Date: Wed, 3 May 2017 18:07:53 +1100 Subject: [PATCH 185/363] Add specs for merge requests --- spec/features/merge_requests/edit_mr_spec.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/spec/features/merge_requests/edit_mr_spec.rb b/spec/features/merge_requests/edit_mr_spec.rb index cb3bc3929031a..b0855af95247f 100644 --- a/spec/features/merge_requests/edit_mr_spec.rb +++ b/spec/features/merge_requests/edit_mr_spec.rb @@ -67,5 +67,23 @@ def get_textarea_height page.evaluate_script('document.getElementById("merge_request_description").offsetHeight') end + + describe '"edited by" message', js: true do + context 'when merge request is updated' do + it 'shows "edited by" mesage on title update' do + fill_in 'merge_request_title', with: 'hello world' + click_button 'Save changes' + + expect(page).to have_content("Edited less than a minute ago by #{user.name}") + end + + it 'shows "edited by" mesage on description update' do + fill_in 'merge_request_description', with: 'hello world' + click_button 'Save changes' + + expect(page).to have_content("Edited less than a minute ago by #{user.name}") + end + end + end end end -- GitLab From 62be3355b1cc74e085a7a046e7aca05f59a1f97a Mon Sep 17 00:00:00 2001 From: blackst0ne <blackst0ne.ru@gmail.com> Date: Wed, 3 May 2017 22:11:19 +1100 Subject: [PATCH 186/363] Add alias_attributes for notes --- app/models/note.rb | 3 +++ app/services/notes/update_service.rb | 4 ---- ...ast_edited_at_and_last_edited_by_id_to_notes.rb | 14 -------------- db/schema.rb | 2 -- spec/services/notes/update_service_spec.rb | 7 ------- 5 files changed, 3 insertions(+), 27 deletions(-) delete mode 100644 db/migrate/20170503022512_add_last_edited_at_and_last_edited_by_id_to_notes.rb diff --git a/app/models/note.rb b/app/models/note.rb index 943211ca991e4..002a1565d540f 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -18,6 +18,9 @@ class Note < ActiveRecord::Base cache_markdown_field :note, pipeline: :note, issuable_state_filter_enabled: true + alias_attribute :last_edited_at, :updated_at + alias_attribute :last_edited_by, :updated_by + # Attribute containing rendered and redacted Markdown as generated by # Banzai::ObjectRenderer. attr_accessor :redacted_note_html diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb index 4b4f383abf2f0..75fd08ea0a95c 100644 --- a/app/services/notes/update_service.rb +++ b/app/services/notes/update_service.rb @@ -5,11 +5,7 @@ def execute(note) old_mentioned_users = note.mentioned_users.to_a - note.assign_attributes(params) - params.merge!(last_edited_at: Time.now, last_edited_by: current_user) if note.note_changed? - note.update_attributes(params.merge(updated_by: current_user)) - note.create_new_cross_references!(current_user) if note.previous_changes.include?('note') diff --git a/db/migrate/20170503022512_add_last_edited_at_and_last_edited_by_id_to_notes.rb b/db/migrate/20170503022512_add_last_edited_at_and_last_edited_by_id_to_notes.rb deleted file mode 100644 index a44a1feab16c4..0000000000000 --- a/db/migrate/20170503022512_add_last_edited_at_and_last_edited_by_id_to_notes.rb +++ /dev/null @@ -1,14 +0,0 @@ -# See http://doc.gitlab.com/ce/development/migration_style_guide.html -# for more information on how to write migrations for GitLab. - -class AddLastEditedAtAndLastEditedByIdToNotes < ActiveRecord::Migration - include Gitlab::Database::MigrationHelpers - - # Set this constant to true if this migration requires downtime. - DOWNTIME = false - - def change - add_column :notes, :last_edited_at, :timestamp - add_column :notes, :last_edited_by_id, :integer - end -end diff --git a/db/schema.rb b/db/schema.rb index b173b467abf86..20127cbed32c0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -778,8 +778,6 @@ t.string "discussion_id" t.text "note_html" t.integer "cached_markdown_version" - t.datetime "last_edited_at" - t.integer "last_edited_by_id" end add_index "notes", ["author_id"], name: "index_notes_on_author_id", using: :btree diff --git a/spec/services/notes/update_service_spec.rb b/spec/services/notes/update_service_spec.rb index 8c3e4607f3e42..905e2f46bde41 100644 --- a/spec/services/notes/update_service_spec.rb +++ b/spec/services/notes/update_service_spec.rb @@ -20,13 +20,6 @@ def update_note(opts) @note.reload end - it 'updates last_edited_at and last_edited_by attributes' do - update_note({ note: 'Hello world!' }) - - expect(@note.last_edited_at).not_to be_nil - expect(@note.last_edited_by).not_to be_nil - end - context 'todos' do let!(:todo) { create(:todo, :assigned, user: user, project: project, target: issue, author: user2) } -- GitLab From aaa70a62b05f846a677a4b4dc5ccc83ca82fba32 Mon Sep 17 00:00:00 2001 From: blackst0ne <blackst0ne.ru@gmail.com> Date: Wed, 3 May 2017 22:16:36 +1100 Subject: [PATCH 187/363] Add comment to notes aliases --- app/models/note.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/models/note.rb b/app/models/note.rb index 002a1565d540f..46d0a4f159f87 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -18,6 +18,8 @@ class Note < ActiveRecord::Base cache_markdown_field :note, pipeline: :note, issuable_state_filter_enabled: true + # Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with notes. + # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10392/diffs#note_28719102 alias_attribute :last_edited_at, :updated_at alias_attribute :last_edited_by, :updated_by -- GitLab From ac99441de9774333da4422eb9072777c09a2ad7a Mon Sep 17 00:00:00 2001 From: blackst0ne <blackst0ne.ru@gmail.com> Date: Wed, 3 May 2017 22:46:10 +1100 Subject: [PATCH 188/363] Change 'exclude_author' param --- app/helpers/application_helper.rb | 4 ++-- app/views/projects/issues/show.html.haml | 2 +- app/views/projects/merge_requests/show/_mr_box.html.haml | 2 +- app/views/shared/notes/_note.html.haml | 2 +- app/views/shared/snippets/_header.html.haml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 0ff6ab488084d..6d6bcbaf88a18 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -180,14 +180,14 @@ def time_ago_with_tooltip(time, placement: 'top', html_class: '', short_format: element end - def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', include_author: false) + def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', exclude_author: false) return if object.last_edited_at == object.created_at || object.last_edited_at.blank? content_tag :small, class: 'edited-text' do output = content_tag(:span, 'Edited ') output << time_ago_with_tooltip(object.last_edited_at, placement: placement, html_class: html_class) - if include_author && object.last_edited_by + if !exclude_author && object.last_edited_by output << content_tag(:span, ' by ') output << link_to_member(object.project, object.last_edited_by, avatar: false, author_class: nil) end diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 9bddc3ac44d58..2a871966aa832 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -61,7 +61,7 @@ = markdown_field(@issue, :description) %textarea.hidden.js-task-list-field = @issue.description - = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue_edited_ago', include_author: true) + = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue_edited_ago') #merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } } // This element is filled in using JavaScript. diff --git a/app/views/projects/merge_requests/show/_mr_box.html.haml b/app/views/projects/merge_requests/show/_mr_box.html.haml index 3b2cbb12a8509..8a390cf870044 100644 --- a/app/views/projects/merge_requests/show/_mr_box.html.haml +++ b/app/views/projects/merge_requests/show/_mr_box.html.haml @@ -10,4 +10,4 @@ %textarea.hidden.js-task-list-field = @merge_request.description - = edited_time_ago_with_tooltip(@merge_request, placement: 'bottom', include_author: true) + = edited_time_ago_with_tooltip(@merge_request, placement: 'bottom') diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index 9657b4eea82f0..6868f63199a19 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -40,7 +40,7 @@ .note-body{ class: note_editable ? 'js-task-list-container' : '' } .note-text.md = note.redacted_note_html - = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago', include_author: true) + = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago') - if note_editable - if note.for_personal_snippet? = render 'snippets/notes/edit', note: note diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml index d084f5e968416..501c09d71d510 100644 --- a/app/views/shared/snippets/_header.html.haml +++ b/app/views/shared/snippets/_header.html.haml @@ -21,4 +21,4 @@ = markdown_field(@snippet, :title) - if @snippet.updated_at != @snippet.created_at - = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago') + = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago', exclude_author: true) -- GitLab From b2e578803dce182bd8d871fa7bcac573e05c4b95 Mon Sep 17 00:00:00 2001 From: blackst0ne <blackst0ne.ru@gmail.com> Date: Thu, 4 May 2017 12:36:12 +1100 Subject: [PATCH 189/363] Add feature spec for system notes --- spec/features/issuables/system_notes_spec.rb | 30 ++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 spec/features/issuables/system_notes_spec.rb diff --git a/spec/features/issuables/system_notes_spec.rb b/spec/features/issuables/system_notes_spec.rb new file mode 100644 index 0000000000000..e12d81e76cbaf --- /dev/null +++ b/spec/features/issuables/system_notes_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +describe 'issuable system notes', feature: true do + let(:issue) { create(:issue, project: project, author: user) } + let(:merge_request) { create(:merge_request, :simple, source_project: project) } + let(:project) { create(:project, :public) } + let(:user) { create(:user) } + + before do + project.add_user(user, :master) + login_as(user) + end + + [:issue, :merge_request].each do |issuable_type| + context "when #{issuable_type}" do + before do + issuable = issuable_type == :issue ? issue : merge_request + + visit(edit_polymorphic_path([project.namespace.becomes(Namespace), project, issuable])) + end + + it 'adds system note "description changed"' do + fill_in("#{issuable_type}_description", with: 'hello world') + click_button('Save changes') + + expect(page).to have_content("#{user.name} #{user.to_reference} changed the description") + end + end + end +end -- GitLab From dbd1bdaeed596f14af89d662e73030bb02571cfd Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Wed, 3 May 2017 21:05:38 -0500 Subject: [PATCH 190/363] More updates for translations plus some refactoring. --- .../components/stage_code_component.js | 4 +- .../components/stage_issue_component.js | 2 +- .../components/stage_plan_component.js | 4 +- .../components/stage_production_component.js | 4 +- .../components/stage_review_component.js | 4 +- .../components/stage_staging_component.js | 2 +- .../cycle_analytics_service.js | 2 +- .../cycle_analytics/cycle_analytics_store.js | 2 +- app/assets/javascripts/locale/de/app.js | 2 +- app/assets/javascripts/locale/en/app.js | 2 +- app/assets/javascripts/locale/es/app.js | 2 +- app/controllers/application_controller.rb | 14 ++--- app/serializers/analytics_stage_entity.rb | 1 + app/serializers/analytics_summary_entity.rb | 5 +- .../projects/cycle_analytics/show.html.haml | 4 +- db/fixtures/development/17_cycle_analytics.rb | 2 +- lib/api/api.rb | 3 + lib/gitlab/cycle_analytics/base_stage.rb | 2 +- lib/gitlab/cycle_analytics/code_stage.rb | 6 +- lib/gitlab/cycle_analytics/issue_stage.rb | 6 +- lib/gitlab/cycle_analytics/plan_stage.rb | 6 +- .../cycle_analytics/production_stage.rb | 6 +- lib/gitlab/cycle_analytics/review_stage.rb | 6 +- lib/gitlab/cycle_analytics/staging_stage.rb | 6 +- lib/gitlab/cycle_analytics/summary/base.rb | 2 +- lib/gitlab/cycle_analytics/summary/commit.rb | 4 ++ lib/gitlab/cycle_analytics/summary/deploy.rb | 4 ++ lib/gitlab/cycle_analytics/summary/issue.rb | 2 +- lib/gitlab/cycle_analytics/test_stage.rb | 6 +- lib/gitlab/i18n.rb | 10 +++ locale/de/gitlab.po | 55 ++++++++++++----- locale/en/gitlab.po | 55 ++++++++++++----- locale/es/gitlab.po | 61 +++++++++++++------ locale/gitlab.pot | 59 +++++++++++++----- locale/unfound_translations.rb | 10 --- 35 files changed, 253 insertions(+), 112 deletions(-) delete mode 100644 locale/unfound_translations.rb diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js index f22299898a5c4..0d9ad197abf18 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js @@ -28,11 +28,11 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({ <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> · <span> - {{ __('Opened') }} + {{ __('OpenedNDaysAgo|Opened') }} <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> </span> <span> - {{ __('Author|by') }} + {{ __('ByAuthor|by') }} <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> </span> </div> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js index f400fd2fb142c..c4018c8fc36e5 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js @@ -32,7 +32,7 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({ <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> </span> <span> - {{ __('Author|by') }} + {{ __('ByAuthor|by') }} <a :href="issue.author.webUrl" class="issue-author-link"> {{ issue.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js index b85b8902fc7c5..222084deee94c 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js @@ -31,10 +31,10 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({ </a> </h5> <span> - {{ __('OfFirstTime|First') }} + {{ __('FirstPushedBy|First') }} <span class="commit-icon">${iconCommit}</span> <a :href="commit.commitUrl" class="commit-hash-link monospace">{{ commit.shortSha }}</a> - {{ __('pushed by') }} + {{ __('FirstPushedBy|pushed by') }} <a :href="commit.author.webUrl" class="commit-author-link"> {{ commit.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js index 18c30f490fd76..a14ebc3ece942 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js @@ -28,11 +28,11 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({ <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a> · <span> - {{ __('Opened') }} + {{ __('OpenedNDaysAgo|Opened') }} <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> </span> <span> - {{ __('Author|by') }} + {{ __('ByAuthor|by') }} <a :href="issue.author.webUrl" class="issue-author-link"> {{ issue.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js index 988067601284b..1a5bf9bc0b511 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js @@ -28,11 +28,11 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({ <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> · <span> - {{ __('Opened') }} + {{ __('OpenedNDaysAgo|Opened') }} <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> </span> <span> - {{ __('Author|by') }} + {{ __('ByAuthor|by') }} <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> </span> <template v-if="mergeRequest.state === 'closed'"> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js index f13294c7af707..b1e9362434f1f 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js @@ -32,7 +32,7 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({ </h5> <span> <a :href="build.url" class="build-date">{{ build.date }}</a> - {{ __('Author|by') }} + {{ __('ByAuthor|by') }} <a :href="build.author.webUrl" class="issue-author-link"> {{ build.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js index 681d6eef56577..c176376e8cf89 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js @@ -30,7 +30,7 @@ class CycleAnalyticsService { startDate, } = options; - return $.get(`${this.requestPath}/events/${stage.title.toLowerCase()}.json`, { + return $.get(`${this.requestPath}/events/${stage.name.toLowerCase()}.json`, { cycle_analytics: { start_date: startDate, }, diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js index 1c57495121141..50bd394e90e0a 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js @@ -39,7 +39,7 @@ global.cycleAnalytics.CycleAnalyticsStore = { }); newData.stages.forEach((item) => { - const stageSlug = gl.text.dasherize(item.title.toLowerCase()); + const stageSlug = gl.text.dasherize(item.name.toLowerCase()); item.active = false; item.isUserAllowed = data.permissions[stageSlug]; item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug]; diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js index e1e4a2b7138f9..aee25c3439249 100644 --- a/app/assets/javascripts/locale/de/app.js +++ b/app/assets/javascripts/locale/de/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Author|by":[""],"Commits":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploys":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"OfFirstTime|First":[""],"Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""],"pushed by":[""]}}}; \ No newline at end of file +var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"Opened":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/en/app.js b/app/assets/javascripts/locale/en/app.js index c14e7b41ad137..ed32f7e49c0ea 100644 --- a/app/assets/javascripts/locale/en/app.js +++ b/app/assets/javascripts/locale/en/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Author|by":[""],"Commits":[""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploys":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issues":[""],"Not available":[""],"Not enough data":[""],"OfFirstTime|First":[""],"Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""],"pushed by":[""]}}}; \ No newline at end of file +var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"Opened":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/es/app.js b/app/assets/javascripts/locale/es/app.js index 9e09a28c0e55c..0d8152bb8635e 100644 --- a/app/assets/javascripts/locale/es/app.js +++ b/app/assets/javascripts/locale/es/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-03 12:28-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"Author|by":["por"],"Commits":["Cambios"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"CycleAnalyticsStage":[""],"CycleAnalyticsStage|Code":["Código"],"CycleAnalyticsStage|Issue":["Incidencia"],"CycleAnalyticsStage|Plan":["Planificación"],"CycleAnalyticsStage|Production":["Producción"],"CycleAnalyticsStage|Review":["Revisión"],"CycleAnalyticsStage|Staging":["Puesta en escena"],"CycleAnalyticsStage|Test":["Pruebas"],"Deploys":["Despliegues"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last %d day":["Último %d dÃa","Últimos %d dÃas"],"Limited to showing %d event at most":["Limitado a mostrar máximo %d evento","Limitado a mostrar máximo %d eventos"],"Median":["Mediana"],"New Issues":["Nuevas incidencias"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"OfFirstTime|First":["Primer"],"Opened":["Abiertos"],"Pipeline Health":["Estado del Pipeline"],"ProjectLifecycle|Stage":["Etapa"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Issues":["Incidencias Relacionadas"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Relative Builds Trigger by Commits":["Builds Relacionados Originados por Cambios"],"Relative Deployed Builds":["Builds Desplegados Relacionados"],"Relative Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":["La colección de eventos agregados a los datos recopilados para esa etapa."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":["La etapa del ciclo de vida del desarrollo."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["El tiempo utilizado por cada entrada de datos obtenido por esa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":["Tiempo Total"],"Want to see the data? Please ask an administrator for access.":["¿Quieres ver los datos? Por favor pedir acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"You need permission.":["Necesitas permisos."],"day":["dÃa","dÃas"],"pushed by":["enviado por"]}}}; \ No newline at end of file +var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-03 21:03-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":["por"],"Commit":["Cambio","Cambios"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"CycleAnalyticsStage|Code":["Código"],"CycleAnalyticsStage|Issue":["Incidencia"],"CycleAnalyticsStage|Plan":["Planificación"],"CycleAnalyticsStage|Production":["Producción"],"CycleAnalyticsStage|Review":["Revisión"],"CycleAnalyticsStage|Staging":["Puesta en escena"],"CycleAnalyticsStage|Test":["Pruebas"],"Deploy":["Despliegue","Despliegues"],"FirstPushedBy|First":["Primer"],"FirstPushedBy|pushed by":["enviado por"],"From issue creation until deploy to production":["Desde la creación de la incidencia hasta el despliegue a producción"],"From merge request merge until deploy to production":["Desde la integración de la solicitud de fusión hasta el despliegue a producción"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last %d day":["Último %d dÃa","Últimos %d dÃas"],"Limited to showing %d event at most":["Limitado a mostrar máximo %d evento","Limitado a mostrar máximo %d eventos"],"Median":["Mediana"],"New Issue":["Nueva incidencia","Nuevas incidencias"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"Opened":["Abiertos"],"OpenedNDaysAgo|Opened":["Abierto"],"Pipeline Health":["Estado del Pipeline"],"ProjectLifecycle|Stage":["Etapa"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Issues":["Incidencias Relacionadas"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Relative Builds Trigger by Commits":["Builds Relacionados Originados por Cambios"],"Relative Deployed Builds":["Builds Desplegados Relacionados"],"Relative Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":["La colección de eventos agregados a los datos recopilados para esa etapa."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":["La etapa del ciclo de vida del desarrollo."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["El tiempo utilizado por cada entrada de datos obtenido por esa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."],"Time before an issue gets scheduled":["Tiempo antes de que una incidencia sea programada"],"Time before an issue starts implementation":["Tiempo antes de que empieze la implementación de una incidencia"],"Time between merge request creation and merge/close":["Tiempo entre la creación de la solicitud de fusión y la integración o cierre de esta"],"Time until first merge request":["Tiempo hasta la primera solicitud de fusión"],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":["Tiempo Total"],"Total test time for all commits/merges":["Tiempo total de pruebas para todos los cambios o integraciones"],"Want to see the data? Please ask an administrator for access.":["¿Quieres ver los datos? Por favor pedir acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"You need permission.":["Necesitas permisos."],"day":["dÃa","dÃas"]}}}; \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 24017e8ea40ae..d2c13da691741 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -273,14 +273,10 @@ def u2f_app_id end def set_locale - begin - requested_locale = current_user&.preferred_language || I18n.default_locale - locale = FastGettext.set_locale(requested_locale) - I18n.locale = locale - - yield - ensure - I18n.locale = I18n.default_locale - end + Gitlab::I18n.set_locale(current_user) + + yield + ensure + Gitlab::I18n.reset_locale end end diff --git a/app/serializers/analytics_stage_entity.rb b/app/serializers/analytics_stage_entity.rb index 69bf693de8d8e..564612202b5ec 100644 --- a/app/serializers/analytics_stage_entity.rb +++ b/app/serializers/analytics_stage_entity.rb @@ -2,6 +2,7 @@ class AnalyticsStageEntity < Grape::Entity include EntityDateHelper expose :title + expose :name expose :legend expose :description diff --git a/app/serializers/analytics_summary_entity.rb b/app/serializers/analytics_summary_entity.rb index 91803ec07f5ff..9c37afd53e1ac 100644 --- a/app/serializers/analytics_summary_entity.rb +++ b/app/serializers/analytics_summary_entity.rb @@ -1,7 +1,4 @@ class AnalyticsSummaryEntity < Grape::Entity expose :value, safe: true - - expose :title do |object| - object.title.pluralize(object.value) - end + expose :title end diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index 5c1426e49953b..eadaff70e0469 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -51,7 +51,7 @@ %ul %li.stage-header %span.stage-name - {{ s__('ProjectLifecycle|Stage') }} + {{ __('ProjectLifecycle|Stage') }} %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The phase of the development lifecycle."), "aria-hidden" => "true" } %li.median-header %span.stage-name @@ -70,7 +70,7 @@ %ul %li.stage-nav-item{ ':class' => '{ active: stage.active }', '@click' => 'selectStage(stage)', "v-for" => "stage in state.stages" } .stage-nav-item-cell.stage-name - {{ s__('CycleAnalyticsStage', stage.title) }} + {{ stage.title }} .stage-nav-item-cell.stage-median %template{ "v-if" => "stage.isUserAllowed" } %span{ "v-if" => "stage.value" } diff --git a/db/fixtures/development/17_cycle_analytics.rb b/db/fixtures/development/17_cycle_analytics.rb index 0d7eb1a7c9336..040505ad52c54 100644 --- a/db/fixtures/development/17_cycle_analytics.rb +++ b/db/fixtures/development/17_cycle_analytics.rb @@ -227,7 +227,7 @@ def deploy_to_production(merge_requests) if ENV[flag] Project.all.each do |project| - seeder = Gitlab::Seeder::CycleAnalytics.new(project) + seeder = Gitlab::Seeder::CycleAnalytics.new(pro) seeder.seed! end elsif ENV['CYCLE_ANALYTICS_PERF_TEST'] diff --git a/lib/api/api.rb b/lib/api/api.rb index 1bf20f76ad6a4..302ccd3e5327d 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -44,6 +44,9 @@ class API < Grape::API end before { allow_access_with_scope :api } + before { Gitlab::I18n.set_locale(current_user) } + + after { Gitlab::I18n.reset_locale } rescue_from Gitlab::Access::AccessDeniedError do rack_response({ 'message' => '403 Forbidden' }.to_json, 403) diff --git a/lib/gitlab/cycle_analytics/base_stage.rb b/lib/gitlab/cycle_analytics/base_stage.rb index 559e3939da641..cac31ea8cff42 100644 --- a/lib/gitlab/cycle_analytics/base_stage.rb +++ b/lib/gitlab/cycle_analytics/base_stage.rb @@ -17,7 +17,7 @@ def as_json end def title - name.to_s.capitalize + raise NotImplementedError.new("Expected #{self.name} to implement title") end def median diff --git a/lib/gitlab/cycle_analytics/code_stage.rb b/lib/gitlab/cycle_analytics/code_stage.rb index 182d4c43fbda9..5f9dc9a430376 100644 --- a/lib/gitlab/cycle_analytics/code_stage.rb +++ b/lib/gitlab/cycle_analytics/code_stage.rb @@ -13,12 +13,16 @@ def name :code end + def title + s_('CycleAnalyticsStage|Code') + end + def legend _("Related Merge Requests") end def description - "Time until first merge request" + _("Time until first merge request") end end end diff --git a/lib/gitlab/cycle_analytics/issue_stage.rb b/lib/gitlab/cycle_analytics/issue_stage.rb index 08e249116a44b..7b03811efb21e 100644 --- a/lib/gitlab/cycle_analytics/issue_stage.rb +++ b/lib/gitlab/cycle_analytics/issue_stage.rb @@ -14,12 +14,16 @@ def name :issue end + def title + s_('CycleAnalyticsStage|Issue') + end + def legend _("Related Issues") end def description - "Time before an issue gets scheduled" + _("Time before an issue gets scheduled") end end end diff --git a/lib/gitlab/cycle_analytics/plan_stage.rb b/lib/gitlab/cycle_analytics/plan_stage.rb index c5b2809209ced..1a0afb56b4fb0 100644 --- a/lib/gitlab/cycle_analytics/plan_stage.rb +++ b/lib/gitlab/cycle_analytics/plan_stage.rb @@ -14,12 +14,16 @@ def name :plan end + def title + s_('CycleAnalyticsStage|Plan') + end + def legend _("Related Commits") end def description - "Time before an issue starts implementation" + _("Time before an issue starts implementation") end end end diff --git a/lib/gitlab/cycle_analytics/production_stage.rb b/lib/gitlab/cycle_analytics/production_stage.rb index 8f05c840db195..0fa8a65cb9911 100644 --- a/lib/gitlab/cycle_analytics/production_stage.rb +++ b/lib/gitlab/cycle_analytics/production_stage.rb @@ -15,12 +15,16 @@ def name :production end + def title + s_('CycleAnalyticsStage|Production') + end + def legend _("Related Issues") end def description - "From issue creation until deploy to production" + _("From issue creation until deploy to production") end def query diff --git a/lib/gitlab/cycle_analytics/review_stage.rb b/lib/gitlab/cycle_analytics/review_stage.rb index 4f4c94f4020d4..2cab363ba9d4e 100644 --- a/lib/gitlab/cycle_analytics/review_stage.rb +++ b/lib/gitlab/cycle_analytics/review_stage.rb @@ -13,12 +13,16 @@ def name :review end + def title + s_('CycleAnalyticsStage|Review') + end + def legend _("Relative Merged Requests") end def description - "Time between merge request creation and merge/close" + _("Time between merge request creation and merge/close") end end end diff --git a/lib/gitlab/cycle_analytics/staging_stage.rb b/lib/gitlab/cycle_analytics/staging_stage.rb index da8eb8c55de74..c1753340842b0 100644 --- a/lib/gitlab/cycle_analytics/staging_stage.rb +++ b/lib/gitlab/cycle_analytics/staging_stage.rb @@ -14,12 +14,16 @@ def name :staging end + def title + s_('CycleAnalyticsStage|Staging') + end + def legend _("Relative Deployed Builds") end def description - "From merge request merge until deploy to production" + _("From merge request merge until deploy to production") end end end diff --git a/lib/gitlab/cycle_analytics/summary/base.rb b/lib/gitlab/cycle_analytics/summary/base.rb index 43fa3795e5ca9..a917ddccac732 100644 --- a/lib/gitlab/cycle_analytics/summary/base.rb +++ b/lib/gitlab/cycle_analytics/summary/base.rb @@ -8,7 +8,7 @@ def initialize(project:, from:) end def title - self.class.name.demodulize + raise NotImplementedError.new("Expected #{self.name} to implement title") end def value diff --git a/lib/gitlab/cycle_analytics/summary/commit.rb b/lib/gitlab/cycle_analytics/summary/commit.rb index 7b8faa4d854ed..bea7886275718 100644 --- a/lib/gitlab/cycle_analytics/summary/commit.rb +++ b/lib/gitlab/cycle_analytics/summary/commit.rb @@ -2,6 +2,10 @@ module Gitlab module CycleAnalytics module Summary class Commit < Base + def title + n_('Commit', 'Commits', value) + end + def value @value ||= count_commits end diff --git a/lib/gitlab/cycle_analytics/summary/deploy.rb b/lib/gitlab/cycle_analytics/summary/deploy.rb index 06032e9200edf..099d798aac691 100644 --- a/lib/gitlab/cycle_analytics/summary/deploy.rb +++ b/lib/gitlab/cycle_analytics/summary/deploy.rb @@ -2,6 +2,10 @@ module Gitlab module CycleAnalytics module Summary class Deploy < Base + def title + n_('Deploy', 'Deploys', value) + end + def value @value ||= @project.deployments.where("created_at > ?", @from).count end diff --git a/lib/gitlab/cycle_analytics/summary/issue.rb b/lib/gitlab/cycle_analytics/summary/issue.rb index 008468f24b90a..9bbf7a2685f52 100644 --- a/lib/gitlab/cycle_analytics/summary/issue.rb +++ b/lib/gitlab/cycle_analytics/summary/issue.rb @@ -9,7 +9,7 @@ def initialize(project:, from:, current_user:) end def title - 'New Issue' + n_('New Issue', 'New Issues', value) end def value diff --git a/lib/gitlab/cycle_analytics/test_stage.rb b/lib/gitlab/cycle_analytics/test_stage.rb index 3ef7132bcc398..10402a1d7cde2 100644 --- a/lib/gitlab/cycle_analytics/test_stage.rb +++ b/lib/gitlab/cycle_analytics/test_stage.rb @@ -13,12 +13,16 @@ def name :test end + def title + s_('CycleAnalyticsStage|Test') + end + def legend _("Relative Builds Trigger by Commits") end def description - "Total test time for all commits/merges" + _("Total test time for all commits/merges") end def stage_query diff --git a/lib/gitlab/i18n.rb b/lib/gitlab/i18n.rb index a7addee0dcd15..9081ced0238ee 100644 --- a/lib/gitlab/i18n.rb +++ b/lib/gitlab/i18n.rb @@ -11,5 +11,15 @@ module I18n def available_locales AVAILABLE_LANGUAGES.keys end + + def set_locale(current_user) + requested_locale = current_user&.preferred_language || ::I18n.default_locale + locale = FastGettext.set_locale(requested_locale) + ::I18n.locale = locale + end + + def reset_locale + ::I18n.locale = ::I18n.default_locale + end end end diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po index ea19349bac10e..3e7bcf2c90fb5 100644 --- a/locale/de/gitlab.po +++ b/locale/de/gitlab.po @@ -17,18 +17,17 @@ msgstr "" "Plural-Forms: nplurals=2; plural=n != 1;\n" "\n" -msgid "Author|by" +msgid "ByAuthor|by" msgstr "" -msgid "Commits" -msgstr "" +msgid "Commit" +msgid_plural "Commits" +msgstr[0] "" +msgstr[1] "" msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "" -msgid "CycleAnalyticsStage" -msgstr "" - msgid "CycleAnalyticsStage|Code" msgstr "" @@ -50,7 +49,21 @@ msgstr "" msgid "CycleAnalyticsStage|Test" msgstr "" -msgid "Deploys" +msgid "Deploy" +msgid_plural "Deploys" +msgstr[0] "" +msgstr[1] "" + +msgid "FirstPushedBy|First" +msgstr "" + +msgid "FirstPushedBy|pushed by" +msgstr "" + +msgid "From issue creation until deploy to production" +msgstr "" + +msgid "From merge request merge until deploy to production" msgstr "" msgid "Introducing Cycle Analytics" @@ -69,8 +82,10 @@ msgstr[1] "" msgid "Median" msgstr "" -msgid "New Issues" -msgstr "" +msgid "New Issue" +msgid_plural "New Issues" +msgstr[0] "" +msgstr[1] "" msgid "Not available" msgstr "" @@ -78,10 +93,10 @@ msgstr "" msgid "Not enough data" msgstr "" -msgid "OfFirstTime|First" +msgid "Opened" msgstr "" -msgid "Opened" +msgid "OpenedNDaysAgo|Opened" msgstr "" msgid "Pipeline Health" @@ -149,6 +164,18 @@ msgstr "" msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." msgstr "" +msgid "Time before an issue gets scheduled" +msgstr "" + +msgid "Time before an issue starts implementation" +msgstr "" + +msgid "Time between merge request creation and merge/close" +msgstr "" + +msgid "Time until first merge request" +msgstr "" + msgid "Time|hr" msgid_plural "Time|hrs" msgstr[0] "" @@ -165,6 +192,9 @@ msgstr "" msgid "Total Time" msgstr "" +msgid "Total test time for all commits/merges" +msgstr "" + msgid "Want to see the data? Please ask an administrator for access." msgstr "" @@ -178,6 +208,3 @@ msgid "day" msgid_plural "days" msgstr[0] "" msgstr[1] "" - -msgid "pushed by" -msgstr "" diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po index 2fed2aa1f57e9..9f75b5641a4ae 100644 --- a/locale/en/gitlab.po +++ b/locale/en/gitlab.po @@ -17,18 +17,17 @@ msgstr "" "Plural-Forms: nplurals=2; plural=n != 1;\n" "\n" -msgid "Author|by" +msgid "ByAuthor|by" msgstr "" -msgid "Commits" -msgstr "" +msgid "Commit" +msgid_plural "Commits" +msgstr[0] "" +msgstr[1] "" msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "" -msgid "CycleAnalyticsStage" -msgstr "" - msgid "CycleAnalyticsStage|Code" msgstr "" @@ -50,7 +49,21 @@ msgstr "" msgid "CycleAnalyticsStage|Test" msgstr "" -msgid "Deploys" +msgid "Deploy" +msgid_plural "Deploys" +msgstr[0] "" +msgstr[1] "" + +msgid "FirstPushedBy|First" +msgstr "" + +msgid "FirstPushedBy|pushed by" +msgstr "" + +msgid "From issue creation until deploy to production" +msgstr "" + +msgid "From merge request merge until deploy to production" msgstr "" msgid "Introducing Cycle Analytics" @@ -69,8 +82,10 @@ msgstr[1] "" msgid "Median" msgstr "" -msgid "New Issues" -msgstr "" +msgid "New Issue" +msgid_plural "New Issues" +msgstr[0] "" +msgstr[1] "" msgid "Not available" msgstr "" @@ -78,10 +93,10 @@ msgstr "" msgid "Not enough data" msgstr "" -msgid "OfFirstTime|First" +msgid "Opened" msgstr "" -msgid "Opened" +msgid "OpenedNDaysAgo|Opened" msgstr "" msgid "Pipeline Health" @@ -149,6 +164,18 @@ msgstr "" msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." msgstr "" +msgid "Time before an issue gets scheduled" +msgstr "" + +msgid "Time before an issue starts implementation" +msgstr "" + +msgid "Time between merge request creation and merge/close" +msgstr "" + +msgid "Time until first merge request" +msgstr "" + msgid "Time|hr" msgid_plural "Time|hrs" msgstr[0] "" @@ -165,6 +192,9 @@ msgstr "" msgid "Total Time" msgstr "" +msgid "Total test time for all commits/merges" +msgstr "" + msgid "Want to see the data? Please ask an administrator for access." msgstr "" @@ -178,6 +208,3 @@ msgid "day" msgid_plural "days" msgstr[0] "" msgstr[1] "" - -msgid "pushed by" -msgstr "" diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po index 254b7ad18020d..6806bf216774e 100644 --- a/locale/es/gitlab.po +++ b/locale/es/gitlab.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2017-05-03 12:28-0500\n" +"PO-Revision-Date: 2017-05-03 21:03-0500\n" "Language-Team: Spanish\n" "Language: es\n" "MIME-Version: 1.0\n" @@ -17,18 +17,17 @@ msgstr "" "Last-Translator: \n" "X-Generator: Poedit 2.0.1\n" -msgid "Author|by" +msgid "ByAuthor|by" msgstr "por" -msgid "Commits" -msgstr "Cambios" +msgid "Commit" +msgid_plural "Commits" +msgstr[0] "Cambio" +msgstr[1] "Cambios" msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto." -msgid "CycleAnalyticsStage" -msgstr "" - msgid "CycleAnalyticsStage|Code" msgstr "Código" @@ -51,8 +50,22 @@ msgstr "Puesta en escena" msgid "CycleAnalyticsStage|Test" msgstr "Pruebas" -msgid "Deploys" -msgstr "Despliegues" +msgid "Deploy" +msgid_plural "Deploys" +msgstr[0] "Despliegue" +msgstr[1] "Despliegues" + +msgid "FirstPushedBy|First" +msgstr "Primer" + +msgid "FirstPushedBy|pushed by" +msgstr "enviado por" + +msgid "From issue creation until deploy to production" +msgstr "Desde la creación de la incidencia hasta el despliegue a producción" + +msgid "From merge request merge until deploy to production" +msgstr "Desde la integración de la solicitud de fusión hasta el despliegue a producción" msgid "Introducing Cycle Analytics" msgstr "Introducción a Cycle Analytics" @@ -70,8 +83,10 @@ msgstr[1] "Limitado a mostrar máximo %d eventos" msgid "Median" msgstr "Mediana" -msgid "New Issues" -msgstr "Nuevas incidencias" +msgid "New Issue" +msgid_plural "New Issues" +msgstr[0] "Nueva incidencia" +msgstr[1] "Nuevas incidencias" msgid "Not available" msgstr "No disponible" @@ -79,12 +94,12 @@ msgstr "No disponible" msgid "Not enough data" msgstr "No hay suficientes datos" -msgid "OfFirstTime|First" -msgstr "Primer" - msgid "Opened" msgstr "Abiertos" +msgid "OpenedNDaysAgo|Opened" +msgstr "Abierto" + msgid "Pipeline Health" msgstr "Estado del Pipeline" @@ -150,6 +165,18 @@ msgstr "El tiempo utilizado por cada entrada de datos obtenido por esa etapa." msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." msgstr "El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6." +msgid "Time before an issue gets scheduled" +msgstr "Tiempo antes de que una incidencia sea programada" + +msgid "Time before an issue starts implementation" +msgstr "Tiempo antes de que empieze la implementación de una incidencia" + +msgid "Time between merge request creation and merge/close" +msgstr "Tiempo entre la creación de la solicitud de fusión y la integración o cierre de esta" + +msgid "Time until first merge request" +msgstr "Tiempo hasta la primera solicitud de fusión" + msgid "Time|hr" msgid_plural "Time|hrs" msgstr[0] "" @@ -166,6 +193,9 @@ msgstr "" msgid "Total Time" msgstr "Tiempo Total" +msgid "Total test time for all commits/merges" +msgstr "Tiempo total de pruebas para todos los cambios o integraciones" + msgid "Want to see the data? Please ask an administrator for access." msgstr "¿Quieres ver los datos? Por favor pedir acceso al administrador." @@ -179,6 +209,3 @@ msgid "day" msgid_plural "days" msgstr[0] "dÃa" msgstr[1] "dÃas" - -msgid "pushed by" -msgstr "enviado por" diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 8f4dc30b8c861..e1796d519351f 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-05-03 12:32-0500\n" -"PO-Revision-Date: 2017-05-03 12:32-0500\n" +"POT-Creation-Date: 2017-05-03 20:53-0500\n" +"PO-Revision-Date: 2017-05-03 20:53-0500\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" "Language: \n" @@ -18,18 +18,17 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" -msgid "Author|by" +msgid "ByAuthor|by" msgstr "" -msgid "Commits" -msgstr "" +msgid "Commit" +msgid_plural "Commits" +msgstr[0] "" +msgstr[1] "" msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." msgstr "" -msgid "CycleAnalyticsStage" -msgstr "" - msgid "CycleAnalyticsStage|Code" msgstr "" @@ -51,7 +50,21 @@ msgstr "" msgid "CycleAnalyticsStage|Test" msgstr "" -msgid "Deploys" +msgid "Deploy" +msgid_plural "Deploys" +msgstr[0] "" +msgstr[1] "" + +msgid "FirstPushedBy|First" +msgstr "" + +msgid "FirstPushedBy|pushed by" +msgstr "" + +msgid "From issue creation until deploy to production" +msgstr "" + +msgid "From merge request merge until deploy to production" msgstr "" msgid "Introducing Cycle Analytics" @@ -70,8 +83,10 @@ msgstr[1] "" msgid "Median" msgstr "" -msgid "New Issues" -msgstr "" +msgid "New Issue" +msgid_plural "New Issues" +msgstr[0] "" +msgstr[1] "" msgid "Not available" msgstr "" @@ -79,10 +94,10 @@ msgstr "" msgid "Not enough data" msgstr "" -msgid "OfFirstTime|First" +msgid "Opened" msgstr "" -msgid "Opened" +msgid "OpenedNDaysAgo|Opened" msgstr "" msgid "Pipeline Health" @@ -150,6 +165,18 @@ msgstr "" msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." msgstr "" +msgid "Time before an issue gets scheduled" +msgstr "" + +msgid "Time before an issue starts implementation" +msgstr "" + +msgid "Time between merge request creation and merge/close" +msgstr "" + +msgid "Time until first merge request" +msgstr "" + msgid "Time|hr" msgid_plural "Time|hrs" msgstr[0] "" @@ -166,6 +193,9 @@ msgstr "" msgid "Total Time" msgstr "" +msgid "Total test time for all commits/merges" +msgstr "" + msgid "Want to see the data? Please ask an administrator for access." msgstr "" @@ -179,6 +209,3 @@ msgid "day" msgid_plural "days" msgstr[0] "" msgstr[1] "" - -msgid "pushed by" -msgstr "" diff --git a/locale/unfound_translations.rb b/locale/unfound_translations.rb deleted file mode 100644 index e66211c161923..0000000000000 --- a/locale/unfound_translations.rb +++ /dev/null @@ -1,10 +0,0 @@ -N_('Commits') -N_('CycleAnalyticsStage|Code') -N_('CycleAnalyticsStage|Issue') -N_('CycleAnalyticsStage|Plan') -N_('CycleAnalyticsStage|Production') -N_('CycleAnalyticsStage|Review') -N_('CycleAnalyticsStage|Staging') -N_('CycleAnalyticsStage|Test') -N_('Deploys') -N_('New Issues') -- GitLab From 154610c92bec0f94067ab88f35c5afdf6f10791d Mon Sep 17 00:00:00 2001 From: blackst0ne <blackst0ne.ru@gmail.com> Date: Thu, 4 May 2017 14:54:25 +1100 Subject: [PATCH 191/363] Fix feature tests --- app/models/snippet.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/models/snippet.rb b/app/models/snippet.rb index d8860718cb581..abfbefdf9a0fb 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -12,6 +12,11 @@ class Snippet < ActiveRecord::Base cache_markdown_field :title, pipeline: :single_line cache_markdown_field :content + # Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with snippets. + # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10392/diffs#note_28719102 + alias_attribute :last_edited_at, :updated_at + alias_attribute :last_edited_by, :updated_by + # If file_name changes, it invalidates content alias_method :default_content_html_invalidator, :content_html_invalidated? def content_html_invalidated? -- GitLab From b44eaf8e0772d4ab076bd0df10b13085483e5e66 Mon Sep 17 00:00:00 2001 From: Timothy Andrew <mail@timothyandrew.net> Date: Wed, 3 May 2017 09:29:49 +0000 Subject: [PATCH 192/363] Sort the network graph both by commit date and topographically. - Previously, we sorted commits by date, which seemed to work okay. - The one edge case where this failed was when multiple commits have the same commit date (for example: when a range of commits are cherry picked with a single command, they all have the same commit date [and different author dates]). - Commits with the same commit date would be sorted arbitrarily, and usually break the network graph. - This commit solves the problem by both sorting by date, and by sorting topographically (parents aren't displayed until all their children are displayed) - Include review comments from @adamniedzielski A more detailed explanation is present here: https://gitlab.com/gitlab-org/gitlab-ce/issues/30973#note_28706230 --- ...-network-graph-sorted-by-date-and-topo.yml | 4 ++++ lib/gitlab/git/repository.rb | 17 ++++++++------- spec/lib/gitlab/git/repository_spec.rb | 2 +- spec/models/network/graph_spec.rb | 21 ++++++++++++++++--- 4 files changed, 33 insertions(+), 11 deletions(-) create mode 100644 changelogs/unreleased/30973-network-graph-sorted-by-date-and-topo.yml diff --git a/changelogs/unreleased/30973-network-graph-sorted-by-date-and-topo.yml b/changelogs/unreleased/30973-network-graph-sorted-by-date-and-topo.yml new file mode 100644 index 0000000000000..42426c1865e72 --- /dev/null +++ b/changelogs/unreleased/30973-network-graph-sorted-by-date-and-topo.yml @@ -0,0 +1,4 @@ +--- +title: Sort the network graph both by commit date and topographically +merge_request: 11057 +author: diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index acd0037ee4f3b..684b2e7287533 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -499,8 +499,9 @@ def ref_name_for_sha(ref_path, sha) # :contains is the commit contained by the refs from which to begin (SHA1 or name) # :max_count is the maximum number of commits to fetch # :skip is the number of commits to skip - # :order is the commits order and allowed value is :none (default), :date, or :topo - # commit ordering types are documented here: + # :order is the commits order and allowed value is :none (default), :date, + # :topo, or any combination of them (in an array). Commit ordering types + # are documented here: # http://www.rubydoc.info/github/libgit2/rugged/Rugged#SORT_NONE-constant) # def find_commits(options = {}) @@ -1290,16 +1291,18 @@ def gitaly_migrate(method, &block) raise CommandError.new(e) end - # Returns the `Rugged` sorting type constant for a given - # sort type key. Valid keys are `:none`, `:topo`, and `:date` - def rugged_sort_type(key) + # Returns the `Rugged` sorting type constant for one or more given + # sort types. Valid keys are `:none`, `:topo`, and `:date`, or an array + # containing more than one of them. `:date` uses a combination of date and + # topological sorting to closer mimic git's native ordering. + def rugged_sort_type(sort_type) @rugged_sort_types ||= { none: Rugged::SORT_NONE, topo: Rugged::SORT_TOPO, - date: Rugged::SORT_DATE + date: Rugged::SORT_DATE | Rugged::SORT_TOPO } - @rugged_sort_types.fetch(key, Rugged::SORT_NONE) + @rugged_sort_types.fetch(sort_type, Rugged::SORT_NONE) end end end diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index ddedb7c34438b..fea186fd4f457 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -1062,7 +1062,7 @@ def commit_files(commit) end it "allows ordering by date" do - expect_any_instance_of(Rugged::Walker).to receive(:sorting).with(Rugged::SORT_DATE) + expect_any_instance_of(Rugged::Walker).to receive(:sorting).with(Rugged::SORT_DATE | Rugged::SORT_TOPO) repository.find_commits(order: :date) end diff --git a/spec/models/network/graph_spec.rb b/spec/models/network/graph_spec.rb index 46b36e11c230f..0fe8a591a4557 100644 --- a/spec/models/network/graph_spec.rb +++ b/spec/models/network/graph_spec.rb @@ -10,17 +10,17 @@ expect(graph.notes).to eq( { note_on_commit.commit_id => 1 } ) end - describe "#commits" do + describe '#commits' do let(:graph) { described_class.new(project, 'refs/heads/master', project.repository.commit, nil) } - it "returns a list of commits" do + it 'returns a list of commits' do commits = graph.commits expect(commits).not_to be_empty expect(commits).to all( be_kind_of(Network::Commit) ) end - it "sorts the commits by commit date (descending)" do + it 'it the commits by commit date (descending)' do # Remove duplicate timestamps because they make it harder to # assert that the commits are sorted as expected. commits = graph.commits.uniq(&:date) @@ -29,5 +29,20 @@ expect(commits).not_to be_empty expect(commits.map(&:id)).to eq(sorted_commits.map(&:id)) end + + it 'sorts children before parents for commits with the same timestamp' do + commits_by_time = graph.commits.group_by(&:date) + + commits_by_time.each do |time, commits| + commit_ids = commits.map(&:id) + + commits.each_with_index do |commit, index| + parent_indexes = commit.parent_ids.map { |parent_id| commit_ids.find_index(parent_id) }.compact + + # All parents of the current commit should appear after it + expect(parent_indexes).to all( be > index ) + end + end + end end end -- GitLab From 628d641b067fd0c0ba16fc7f6d68bcd241a7a105 Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Thu, 4 May 2017 01:58:57 -0500 Subject: [PATCH 193/363] Disable FastGettext from translating AR attrs. It isn't working fine when using POROs in forms like WikiPage, the following error is being raised: undefined method `abstract_class?' for Object:Class --- config/application.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/application.rb b/config/application.rb index f2ecc4ce77c7e..32ad239364876 100644 --- a/config/application.rb +++ b/config/application.rb @@ -40,6 +40,9 @@ class Application < Rails::Application # config.i18n.default_locale = :de config.i18n.enforce_available_locales = false + # Translation for AR attrs is not working well for POROs like WikiPage + config.gettext_i18n_rails.use_for_active_record_attributes = false + # Configure the default encoding used in templates for Ruby 1.9. config.encoding = "utf-8" -- GitLab From 38c29f8775e393164e04fb1faf6c6b05fd105df6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Thu, 4 May 2017 09:02:39 +0200 Subject: [PATCH 194/363] Improving copy of CONTRIBUTING.md, PROCESS.md, and code_review.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable <remy@rymai.me> --- CONTRIBUTING.md | 23 ++++++++++------------- PROCESS.md | 2 +- doc/development/code_review.md | 3 --- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f27efb0ae85b2..600dad563a6a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -136,7 +136,7 @@ and ~"direction". A number of type labels have a priority assigned to them, which automatically makes them float to the top, depending on their importance. -Type labels are always lowercase, but can have any color, besides blue (which is +Type labels are always lowercase, and can have any color, besides blue (which is already reserved for subject labels). The descriptions on the [labels page][labels-page] explain what falls under each type label. @@ -153,7 +153,7 @@ issue is labelled with a subject label corresponding to your expertise. Examples of subject labels are ~wiki, ~"container registry", ~ldap, ~api, ~issues, ~"merge requests", ~labels, and ~"container registry". -Subject labels are always colored blue and all-lowercase. +Subject labels are always all-lowercase. ### Team labels (~CI, ~Discussion, ~Edge, ~Frontend, ~Platform, etc.) @@ -167,8 +167,8 @@ The current team labels are ~Build, ~CI, ~Discussion, ~Documentation, ~Edge, The descriptions on the [labels page][labels-page] explain what falls under the responsibility of each team. -Team labels are always colored aqua, and are capitalized so that they show up as -the first label for any issue. +Team labels are always capitalized so that they show up as the first label for +any issue. ### Priority labels (~Deliverable and ~Stretch) @@ -255,12 +255,9 @@ every quarter. The most important thing is making sure valid issues receive feedback from the development team. Therefore the priority is mentioning developers that can help -on those issues. Please select someone with relevant experience from -[GitLab team][team]. If there is nobody mentioned with that expertise -look in the commit history for the affected files to find someone. Avoid -mentioning the lead developer, this is the person that is least likely to give a -timely response. If the involvement of the lead developer is needed the other -core team members will mention this person. +on those issues. Please select someone with relevant experience from the +[GitLab team][team]. If there is nobody mentioned with that expertise look in +the commit history for the affected files to find someone. [described in our handbook]: https://about.gitlab.com/handbook/engineering/issues/issue-triage-policies/ [issue bash events]: https://gitlab.com/gitlab-org/gitlab-ce/issues/17815 @@ -535,11 +532,11 @@ the feature you contribute through all of these steps. 1. [Unit and system tests][testing] that pass on the CI server 1. Performance/scalability implications have been considered, addressed, and tested 1. [Documented][doc-styleguide] in the `/doc` directory -1. [Changelog entry added][changelog] +1. [Changelog entry added][changelog], if necessary 1. Reviewed and any concerns are addressed 1. Merged by a project maintainer -1. Added to the release blog article if relevant -1. Added to [the website](https://gitlab.com/gitlab-com/www-gitlab-com/) if relevant +1. Added to the release blog article, if relevant +1. Added to [the website](https://gitlab.com/gitlab-com/www-gitlab-com/), if relevant 1. Community questions answered 1. Answers to questions radiated (in docs/wiki/support etc.) diff --git a/PROCESS.md b/PROCESS.md index 6d7d155ca6cf1..3b97a4e8c75fc 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -1,4 +1,4 @@ -## GitLab Core Team & GitLab Inc. Team Contributing Process +## GitLab Core Team & GitLab Inc. Contribution Process --- diff --git a/doc/development/code_review.md b/doc/development/code_review.md index 138817f5440f4..be3dd1e2cc69b 100644 --- a/doc/development/code_review.md +++ b/doc/development/code_review.md @@ -95,8 +95,6 @@ experience, refactors the existing code). Then: "LGTM :thumbsup:", or "Just a couple things to address." - Assign the merge request to the author if changes are required following your review. -- You should try to resolve merge conflicts yourself, using the [merge conflict - resolution][conflict-resolution] tool. - Set the milestone before merging a merge request. - Avoid accepting a merge request before the job succeeds. Of course, "Merge When Pipeline Succeeds" (MWPS) is fine. @@ -105,7 +103,6 @@ experience, refactors the existing code). Then: - Consider using the [Squash and merge][squash-and-merge] feature when the merge request has a lot of commits. -[conflict-resolution]: https://docs.gitlab.com/ce/user/project/merge_requests/resolve_conflicts.html#merge-conflict-resolution [squash-and-merge]: https://docs.gitlab.com/ee/user/project/merge_requests/squash_and_merge.html#squash-and-merge ### The right balance -- GitLab From fdacc4ee6a3341b2c44ddd85a41f2a04d0d417ad Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Thu, 4 May 2017 08:54:42 +0100 Subject: [PATCH 195/363] Moved to a view spec --- app/views/projects/tags/index.html.haml | 1 + spec/features/projects/tags/sort_spec.rb | 15 -------------- .../projects/tags/index.html.haml_spec.rb | 20 +++++++++++++++++++ 3 files changed, 21 insertions(+), 15 deletions(-) delete mode 100644 spec/features/projects/tags/sort_spec.rb create mode 100644 spec/views/projects/tags/index.html.haml_spec.rb diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index c14bbf4f05f72..56656ea3d8600 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -1,4 +1,5 @@ - @no_container = true +- @sort ||= sort_value_recently_updated - page_title "Tags" = render "projects/commits/head" diff --git a/spec/features/projects/tags/sort_spec.rb b/spec/features/projects/tags/sort_spec.rb deleted file mode 100644 index 835cd1507fbfc..0000000000000 --- a/spec/features/projects/tags/sort_spec.rb +++ /dev/null @@ -1,15 +0,0 @@ -require 'spec_helper' - -feature 'Tags sort dropdown', :feature do - let(:project) { create(:project) } - - before do - login_as(:admin) - - visit namespace_project_tags_path(project.namespace, project) - end - - it 'defaults sort dropdown to last updated' do - expect(page).to have_button('Last updated') - end -end diff --git a/spec/views/projects/tags/index.html.haml_spec.rb b/spec/views/projects/tags/index.html.haml_spec.rb new file mode 100644 index 0000000000000..33122365e9ab7 --- /dev/null +++ b/spec/views/projects/tags/index.html.haml_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe 'projects/tags/index', :view do + let(:project) { create(:project) } + + before do + assign(:project, project) + assign(:repository, project.repository) + assign(:tags, []) + + allow(view).to receive(:current_ref).and_return('master') + allow(view).to receive(:can?).and_return(false) + end + + it 'defaults sort dropdown toggle to last updated' do + render + + expect(rendered).to have_button('Last updated') + end +end -- GitLab From 2f7f1ce4e66db847414e2fc3de09556e75c51eb4 Mon Sep 17 00:00:00 2001 From: James Lopez <james@jameslopez.es> Date: Thu, 4 May 2017 11:32:39 +0200 Subject: [PATCH 196/363] fix sidekiq spec, add changelog --- changelogs/unreleased/fix-admin-integrations.yml | 4 ++++ config/sidekiq_queues.yml | 1 + 2 files changed, 5 insertions(+) create mode 100644 changelogs/unreleased/fix-admin-integrations.yml diff --git a/changelogs/unreleased/fix-admin-integrations.yml b/changelogs/unreleased/fix-admin-integrations.yml new file mode 100644 index 0000000000000..7689623501ffc --- /dev/null +++ b/changelogs/unreleased/fix-admin-integrations.yml @@ -0,0 +1,4 @@ +--- +title: Fix new admin integrations not taking effect on existing projects +merge_request: +author: diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index c3bd73533d079..91ea1c0f779ba 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -53,3 +53,4 @@ - [pages, 1] - [system_hook_push, 1] - [update_user_activity, 1] + - [propagate_project_service, 1] -- GitLab From f81cf84035213002ce7931af6c3ffa917fe7fcbd Mon Sep 17 00:00:00 2001 From: James Lopez <james@jameslopez.es> Date: Thu, 4 May 2017 12:13:33 +0200 Subject: [PATCH 197/363] refactor worker into service --- app/services/projects/propagate_service.rb | 47 +++++++++++++++++ .../propagate_project_service_worker.rb | 34 ++---------- .../projects/propagate_service_spec.rb | 40 ++++++++++++++ .../propagate_project_service_worker_spec.rb | 52 +++++++------------ 4 files changed, 110 insertions(+), 63 deletions(-) create mode 100644 app/services/projects/propagate_service.rb create mode 100644 spec/services/projects/propagate_service_spec.rb diff --git a/app/services/projects/propagate_service.rb b/app/services/projects/propagate_service.rb new file mode 100644 index 0000000000000..3c05dcce07c7a --- /dev/null +++ b/app/services/projects/propagate_service.rb @@ -0,0 +1,47 @@ +module Projects + class PropagateService + BATCH_SIZE = 100 + + def self.propagate!(*args) + new(*args).propagate! + end + + def initialize(template) + @template = template + end + + def propagate! + return unless @template&.active + + Rails.logger.info("Propagating services for template #{@template.id}") + + propagate_projects_with_template + end + + private + + def propagate_projects_with_template + offset = 0 + + loop do + batch = project_ids_batch(offset) + + batch.each { |project_id| create_from_template(project_id) } + + break if batch.count < BATCH_SIZE + + offset += BATCH_SIZE + end + end + + def create_from_template(project_id) + Service.build_from_template(project_id, @template).save! + end + + def project_ids_batch(offset) + Project.joins('LEFT JOIN services ON services.project_id = projects.id'). + where('services.type != ? OR services.id IS NULL', @template.type). + limit(BATCH_SIZE).offset(offset).pluck(:id) + end + end +end diff --git a/app/workers/propagate_project_service_worker.rb b/app/workers/propagate_project_service_worker.rb index 535517709682f..ab2b7738f9ad9 100644 --- a/app/workers/propagate_project_service_worker.rb +++ b/app/workers/propagate_project_service_worker.rb @@ -3,44 +3,18 @@ class PropagateProjectServiceWorker include Sidekiq::Worker include DedicatedSidekiqQueue + sidekiq_options retry: 3 + LEASE_TIMEOUT = 30.minutes.to_i def perform(template_id) - template = Service.find_by(id: template_id) - - return unless template&.active - return unless try_obtain_lease_for(template.id) - - Rails.logger.info("Propagating services for template #{template.id}") + return unless try_obtain_lease_for(template_id) - project_ids_for_template(template) do |project_id| - Service.build_from_template(project_id, template).save! - end + Projects::PropagateService.propagate!(Service.find_by(id: template_id)) end private - def project_ids_for_template(template) - limit = 100 - offset = 0 - - loop do - batch = project_ids_batch(limit, offset, template.type) - - batch.each { |project_id| yield(project_id) } - - break if batch.count < limit - - offset += limit - end - end - - def project_ids_batch(limit, offset, template_type) - Project.joins('LEFT JOIN services ON services.project_id = projects.id'). - where('services.type != ? OR services.id IS NULL', template_type). - limit(limit).offset(offset).pluck(:id) - end - def try_obtain_lease_for(template_id) Gitlab::ExclusiveLease. new("propagate_project_service_worker:#{template_id}", timeout: LEASE_TIMEOUT). diff --git a/spec/services/projects/propagate_service_spec.rb b/spec/services/projects/propagate_service_spec.rb new file mode 100644 index 0000000000000..ee40a7ecbabce --- /dev/null +++ b/spec/services/projects/propagate_service_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe Projects::PropagateService, services: true do + describe '.propagate!' do + let!(:service_template) do + PushoverService.create( + template: true, + active: true, + properties: { + device: 'MyDevice', + sound: 'mic', + priority: 4, + user_key: 'asdf', + api_key: '123456789' + }) + end + + let!(:project) { create(:empty_project) } + + it 'creates services for projects' do + expect { described_class.propagate!(service_template) }. + to change { Service.count }.by(1) + end + + it 'does not create the service if it exists already' do + Service.build_from_template(project.id, service_template).save! + + expect { described_class.propagate!(service_template) }. + not_to change { Service.count } + end + + it 'creates the service containing the template attributes' do + described_class.propagate!(service_template) + + service = Service.find_by(type: service_template.type, template: false) + + expect(service.properties).to eq(service_template.properties) + end + end +end diff --git a/spec/workers/propagate_project_service_worker_spec.rb b/spec/workers/propagate_project_service_worker_spec.rb index d525a8b4a236b..c16e95bd49b6e 100644 --- a/spec/workers/propagate_project_service_worker_spec.rb +++ b/spec/workers/propagate_project_service_worker_spec.rb @@ -1,43 +1,29 @@ require 'spec_helper' describe PropagateProjectServiceWorker do - describe '#perform' do - let!(:service_template) do - PushoverService.create( - template: true, - active: true, - properties: { - device: 'MyDevice', - sound: 'mic', - priority: 4, - user_key: 'asdf', - api_key: '123456789' - }) - end - - let!(:project) { create(:empty_project) } - - before do - allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain). - and_return(true) - end - - it 'creates services for projects' do - expect { subject.perform(service_template.id) }.to change { Service.count }.by(1) - end + let!(:service_template) do + PushoverService.create( + template: true, + active: true, + properties: { + device: 'MyDevice', + sound: 'mic', + priority: 4, + user_key: 'asdf', + api_key: '123456789' + }) + end - it 'does not create the service if it exists already' do - Service.build_from_template(project.id, service_template).save! + before do + allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain). + and_return(true) + end - expect { subject.perform(service_template.id) }.not_to change { Service.count } - end + describe '#perform' do + it 'calls the propagate service with the template' do + expect(Projects::PropagateService).to receive(:propagate!).with(service_template) - it 'creates the service containing the template attributes' do subject.perform(service_template.id) - - service = Service.find_by(type: service_template.type, template: false) - - expect(service.properties).to eq(service_template.properties) end end end -- GitLab From 77bbc1c4af087d009fdfa3d7d4c9a720b7cc9c5b Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Thu, 4 May 2017 10:47:44 +0100 Subject: [PATCH 198/363] Reset schema and migration --- ...ientside_sentry_to_application_settings.rb | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 db/migrate/20170504094058_add_clientside_sentry_to_application_settings.rb diff --git a/db/migrate/20170504094058_add_clientside_sentry_to_application_settings.rb b/db/migrate/20170504094058_add_clientside_sentry_to_application_settings.rb new file mode 100644 index 0000000000000..9462d85833412 --- /dev/null +++ b/db/migrate/20170504094058_add_clientside_sentry_to_application_settings.rb @@ -0,0 +1,65 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddClientsideSentryToApplicationSettings < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index", "remove_concurrent_index" or + # "add_column_with_default" you must disable the use of transactions + # as these methods can not run in an existing transaction. + # When using "add_concurrent_index" or "remove_concurrent_index" methods make sure + # that either of them is the _only_ method called in the migration, + # any other changes should go in a separate migration. + # This ensures that upon failure _only_ the index creation or removing fails + # and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + disable_ddl_transaction! + + # Reversability was tested when I made an update the originally irreversible + # migration after I ran it, so I could test that the down method worked and then + # the up method worked. + + def up + add_column_with_default :application_settings, :clientside_sentry_enabled, :boolean, default: false + add_column :application_settings, :clientside_sentry_dsn, :string + end + + def down + remove_columns :application_settings, :clientside_sentry_enabled, :clientside_sentry_dsn + end +end + +class AddClientsideSentryToApplicationSettings < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + disable_ddl_transaction! + + +end -- GitLab From 3d807dc81b27c6366390b2355e40a5c65bbf02c2 Mon Sep 17 00:00:00 2001 From: James Lopez <james@jameslopez.es> Date: Thu, 4 May 2017 12:21:35 +0200 Subject: [PATCH 199/363] update lease timeout --- app/workers/propagate_project_service_worker.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/workers/propagate_project_service_worker.rb b/app/workers/propagate_project_service_worker.rb index ab2b7738f9ad9..6cc3fe846356a 100644 --- a/app/workers/propagate_project_service_worker.rb +++ b/app/workers/propagate_project_service_worker.rb @@ -5,7 +5,7 @@ class PropagateProjectServiceWorker sidekiq_options retry: 3 - LEASE_TIMEOUT = 30.minutes.to_i + LEASE_TIMEOUT = 4.hours.to_i def perform(template_id) return unless try_obtain_lease_for(template_id) -- GitLab From 1a8dc206a49a3812ce920ad94deda1ef57929c1b Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Thu, 4 May 2017 11:28:12 +0100 Subject: [PATCH 200/363] Reset migration to head --- ...ientside_sentry_to_application_settings.rb | 31 --------- ...ientside_sentry_to_application_settings.rb | 65 ------------------- db/schema.rb | 2 - 3 files changed, 98 deletions(-) delete mode 100644 db/migrate/20170428123910_add_clientside_sentry_to_application_settings.rb delete mode 100644 db/migrate/20170504094058_add_clientside_sentry_to_application_settings.rb diff --git a/db/migrate/20170428123910_add_clientside_sentry_to_application_settings.rb b/db/migrate/20170428123910_add_clientside_sentry_to_application_settings.rb deleted file mode 100644 index f3e08aed81988..0000000000000 --- a/db/migrate/20170428123910_add_clientside_sentry_to_application_settings.rb +++ /dev/null @@ -1,31 +0,0 @@ -class AddClientsideSentryToApplicationSettings < ActiveRecord::Migration - include Gitlab::Database::MigrationHelpers - - # Set this constant to true if this migration requires downtime. - DOWNTIME = false - - # When a migration requires downtime you **must** uncomment the following - # constant and define a short and easy to understand explanation as to why the - # migration requires downtime. - # DOWNTIME_REASON = '' - - # When using the methods "add_concurrent_index" or "add_column_with_default" - # you must disable the use of transactions as these methods can not run in an - # existing transaction. When using "add_concurrent_index" make sure that this - # method is the _only_ method called in the migration, any other changes - # should go in a separate migration. This ensures that upon failure _only_ the - # index creation fails and can be retried or reverted easily. - # - # To disable transactions uncomment the following line and remove these - # comments: - disable_ddl_transaction! - - def up - add_column_with_default :application_settings, :clientside_sentry_enabled, :boolean, default: false - add_column :application_settings, :clientside_sentry_dsn, :string - end - - def down - remove_columns :application_settings, :clientside_sentry_enabled, :clientside_sentry_dsn - end -end diff --git a/db/migrate/20170504094058_add_clientside_sentry_to_application_settings.rb b/db/migrate/20170504094058_add_clientside_sentry_to_application_settings.rb deleted file mode 100644 index 9462d85833412..0000000000000 --- a/db/migrate/20170504094058_add_clientside_sentry_to_application_settings.rb +++ /dev/null @@ -1,65 +0,0 @@ -# See http://doc.gitlab.com/ce/development/migration_style_guide.html -# for more information on how to write migrations for GitLab. - -class AddClientsideSentryToApplicationSettings < ActiveRecord::Migration - include Gitlab::Database::MigrationHelpers - - # Set this constant to true if this migration requires downtime. - DOWNTIME = false - - # When a migration requires downtime you **must** uncomment the following - # constant and define a short and easy to understand explanation as to why the - # migration requires downtime. - # DOWNTIME_REASON = '' - - # When using the methods "add_concurrent_index", "remove_concurrent_index" or - # "add_column_with_default" you must disable the use of transactions - # as these methods can not run in an existing transaction. - # When using "add_concurrent_index" or "remove_concurrent_index" methods make sure - # that either of them is the _only_ method called in the migration, - # any other changes should go in a separate migration. - # This ensures that upon failure _only_ the index creation or removing fails - # and can be retried or reverted easily. - # - # To disable transactions uncomment the following line and remove these - # comments: - disable_ddl_transaction! - - # Reversability was tested when I made an update the originally irreversible - # migration after I ran it, so I could test that the down method worked and then - # the up method worked. - - def up - add_column_with_default :application_settings, :clientside_sentry_enabled, :boolean, default: false - add_column :application_settings, :clientside_sentry_dsn, :string - end - - def down - remove_columns :application_settings, :clientside_sentry_enabled, :clientside_sentry_dsn - end -end - -class AddClientsideSentryToApplicationSettings < ActiveRecord::Migration - include Gitlab::Database::MigrationHelpers - - # Set this constant to true if this migration requires downtime. - DOWNTIME = false - - # When a migration requires downtime you **must** uncomment the following - # constant and define a short and easy to understand explanation as to why the - # migration requires downtime. - # DOWNTIME_REASON = '' - - # When using the methods "add_concurrent_index" or "add_column_with_default" - # you must disable the use of transactions as these methods can not run in an - # existing transaction. When using "add_concurrent_index" make sure that this - # method is the _only_ method called in the migration, any other changes - # should go in a separate migration. This ensures that upon failure _only_ the - # index creation fails and can be retried or reverted easily. - # - # To disable transactions uncomment the following line and remove these - # comments: - disable_ddl_transaction! - - -end diff --git a/db/schema.rb b/db/schema.rb index 519a444833b3a..01c0f00c92496 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -121,8 +121,6 @@ t.integer "cached_markdown_version" t.boolean "usage_ping_enabled", default: true, null: false t.string "uuid" - t.boolean "clientside_sentry_enabled", default: false, null: false - t.string "clientside_sentry_dsn" end create_table "audit_events", force: :cascade do |t| -- GitLab From 944ed8da556a067746ac6e98e2e81781748eab48 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Thu, 4 May 2017 11:32:28 +0100 Subject: [PATCH 201/363] clientside_sentry migration and schema changes commit --- ...ientside_sentry_to_application_settings.rb | 33 +++++++++++++++++++ db/schema.rb | 4 ++- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20170504102911_add_clientside_sentry_to_application_settings.rb diff --git a/db/migrate/20170504102911_add_clientside_sentry_to_application_settings.rb b/db/migrate/20170504102911_add_clientside_sentry_to_application_settings.rb new file mode 100644 index 0000000000000..141112f8b5084 --- /dev/null +++ b/db/migrate/20170504102911_add_clientside_sentry_to_application_settings.rb @@ -0,0 +1,33 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddClientsideSentryToApplicationSettings < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using "add_concurrent_index" or "remove_concurrent_index" methods make sure + # that either of them is the _only_ method called in the migration, + # any other changes should go in a separate migration. + # This ensures that upon failure _only_ the index creation or removing fails + # and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + disable_ddl_transaction! + + def up + add_column_with_default :application_settings, :clientside_sentry_enabled, :boolean, default: false + add_column :application_settings, :clientside_sentry_dsn, :string + end + + def down + remove_columns :application_settings, :clientside_sentry_enabled, :clientside_sentry_dsn + end +end diff --git a/db/schema.rb b/db/schema.rb index 01c0f00c92496..44148a9bc318d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170502091007) do +ActiveRecord::Schema.define(version: 20170504102911) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -121,6 +121,8 @@ t.integer "cached_markdown_version" t.boolean "usage_ping_enabled", default: true, null: false t.string "uuid" + t.boolean "clientside_sentry_enabled", default: false, null: false + t.string "clientside_sentry_dsn" end create_table "audit_events", force: :cascade do |t| -- GitLab From 3bff8da8c1e3223e81bccd5343902b840f005fcf Mon Sep 17 00:00:00 2001 From: James Lopez <james@jameslopez.es> Date: Thu, 4 May 2017 12:37:54 +0200 Subject: [PATCH 202/363] fix service spec --- app/models/service.rb | 2 +- spec/models/service_spec.rb | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/models/service.rb b/app/models/service.rb index dea22fd96a7aa..18c046aff5418 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -257,7 +257,7 @@ def self.build_from_template(project_id, template) def update_and_propagate(service_params) return false unless update_attributes(service_params) - if service_params[:active] == 1 + if service_params[:active] PropagateProjectServiceWorker.perform_async(service_params[:id]) end diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb index e9acbb81ad99e..7a5fb509bf5d3 100644 --- a/spec/models/service_spec.rb +++ b/spec/models/service_spec.rb @@ -256,26 +256,27 @@ end describe "#update_and_propagate" do + let(:project) { create(:empty_project) } let!(:service) do RedmineService.new( project: project, active: false, properties: { - project_url: 'http://redmine/projects/project_name_in_redmine', - issues_url: "http://redmine/#{project.id}/project_name_in_redmine/:id", - new_issue_url: 'http://redmine/projects/project_name_in_redmine/issues/new' + project_url: 'http://abc', + issues_url: 'http://abc', + new_issue_url: 'http://abc' } ) end it 'updates the service params successfully and calls the propagation worker' do - expect(PropagateProjectServiceWorker).to receve(:perform_async) + expect(PropagateProjectServiceWorker).to receive(:perform_async) expect(service.update_and_propagate(active: true)).to be true end it 'updates the service params successfully' do - expect(PropagateProjectServiceWorker).not_to receve(:perform_asyncs) + expect(PropagateProjectServiceWorker).not_to receive(:perform_async) expect(service.update_and_propagate(properties: {})).to be true end -- GitLab From b871564383cbade7fff312b8f045cee6c871f1e0 Mon Sep 17 00:00:00 2001 From: James Lopez <james@jameslopez.es> Date: Thu, 4 May 2017 12:46:03 +0200 Subject: [PATCH 203/363] fix service spec --- app/models/service.rb | 2 +- spec/models/service_spec.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models/service.rb b/app/models/service.rb index 18c046aff5418..f85343877035e 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -258,7 +258,7 @@ def update_and_propagate(service_params) return false unless update_attributes(service_params) if service_params[:active] - PropagateProjectServiceWorker.perform_async(service_params[:id]) + PropagateProjectServiceWorker.perform_async(id) end true diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb index 7a5fb509bf5d3..3a7d8b729938b 100644 --- a/spec/models/service_spec.rb +++ b/spec/models/service_spec.rb @@ -258,7 +258,7 @@ describe "#update_and_propagate" do let(:project) { create(:empty_project) } let!(:service) do - RedmineService.new( + RedmineService.create( project: project, active: false, properties: { @@ -270,7 +270,7 @@ end it 'updates the service params successfully and calls the propagation worker' do - expect(PropagateProjectServiceWorker).to receive(:perform_async) + expect(PropagateProjectServiceWorker).to receive(:perform_async).with(service.id) expect(service.update_and_propagate(active: true)).to be true end -- GitLab From 7a37ada12f8c2dc033594fecddf63345f1addf37 Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Thu, 4 May 2017 06:07:36 -0600 Subject: [PATCH 204/363] use refs when possible - style changes - better initial title and description data types --- .../issue_show/issue_title_description.vue | 52 ++++++++++++------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index f6c3308388cd5..8d2ef39040b36 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -6,8 +6,14 @@ import tasks from './actions/tasks'; export default { props: { - endpoint: { required: true, type: String }, - candescription: { required: true, type: String }, + endpoint: { + required: true, + type: String, + }, + candescription: { + required: true, + type: String, + }, }, data() { const resource = new Service(this.$http, this.endpoint); @@ -32,17 +38,18 @@ export default { poll, apiData: {}, timeoutId: null, - title: '<span></span>', + title: null, titleText: '', - description: '<span></span>', + description: null, descriptionText: '', descriptionChange: false, tasks: '0 of 0', + titleEl: document.querySelector('title'), }; }, methods: { renderResponse(res) { - this.apiData = JSON.parse(res.body); + this.apiData = res.json(); this.triggerAnimation(); }, updateTaskHTML() { @@ -53,7 +60,7 @@ export default { if (!noTitleChange) { this.titleText = this.apiData.title_text; - elementStack.push(this.$el.querySelector('.title')); + elementStack.push(this.$refs['issue-title']); } if (!noDescriptionChange) { @@ -61,21 +68,22 @@ export default { this.descriptionChange = true; this.updateTaskHTML(); this.tasks = this.apiData.task_status; - elementStack.push(this.$el.querySelector('.wiki')); + elementStack.push(this.$refs['issue-content-container-gfm-entry']); } elementStack.forEach((element) => { - element.classList.remove('issue-realtime-trigger-pulse'); - element.classList.add('issue-realtime-pre-pulse'); + if (element) { + element.classList.remove('issue-realtime-trigger-pulse'); + element.classList.add('issue-realtime-pre-pulse'); + } }); return elementStack; }, setTabTitle() { - const currentTabTitle = document.querySelector('title'); - const currentTabTitleScope = currentTabTitle.innerText.split('·'); + const currentTabTitleScope = this.titleEl.innerText.split('·'); currentTabTitleScope[0] = `${this.titleText} (#${this.apiData.issue_number}) `; - currentTabTitle.innerText = currentTabTitleScope.join('·'); + this.titleEl.innerText = currentTabTitleScope.join('·'); }, animate(title, description, elementsToVisualize) { this.timeoutId = setTimeout(() => { @@ -84,11 +92,11 @@ export default { this.setTabTitle(); elementsToVisualize.forEach((element) => { - element.classList.remove('issue-realtime-pre-pulse'); - element.classList.add('issue-realtime-trigger-pulse'); + if (element) { + element.classList.remove('issue-realtime-pre-pulse'); + element.classList.add('issue-realtime-trigger-pulse'); + } }); - - clearTimeout(this.timeoutId); }, 0); }, triggerAnimation() { @@ -163,7 +171,12 @@ export default { <template> <div> - <h2 class="title issue-realtime-trigger-pulse" v-html="title"></h2> + <h2 + class="title issue-realtime-trigger-pulse" + ref="issue-title" + v-html="title" + > + </h2> <div :class="descriptionClass" v-if="description" @@ -174,7 +187,10 @@ export default { ref="issue-content-container-gfm-entry" > </div> - <textarea class="hidden js-task-list-field" v-if="descriptionText">{{descriptionText}}</textarea> + <textarea + class="hidden js-task-list-field" + v-if="descriptionText" + >{{descriptionText}}</textarea> </div> </div> </template> -- GitLab From 05c409d28eaacde527a9260a1828fcd2b720a93c Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Thu, 4 May 2017 06:24:50 -0600 Subject: [PATCH 205/363] use nextTick and cache timeAgoEl --- .../issue_show/issue_title_description.vue | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index 8d2ef39040b36..0593d95cf40e3 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -1,4 +1,5 @@ <script> +import Vue from 'vue'; import Visibility from 'visibilityjs'; import Poll from './../lib/utils/poll'; import Service from './services/index'; @@ -37,13 +38,13 @@ export default { return { poll, apiData: {}, - timeoutId: null, title: null, titleText: '', + tasks: '0 of 0', description: null, descriptionText: '', descriptionChange: false, - tasks: '0 of 0', + timeAgoEl: $('.issue_edited_ago'), titleEl: document.querySelector('title'), }; }, @@ -86,18 +87,18 @@ export default { this.titleEl.innerText = currentTabTitleScope.join('·'); }, animate(title, description, elementsToVisualize) { - this.timeoutId = setTimeout(() => { - this.title = title; - this.description = description; - this.setTabTitle(); + this.title = title; + this.description = description; + this.setTabTitle(); + Vue.nextTick(() => { elementsToVisualize.forEach((element) => { if (element) { element.classList.remove('issue-realtime-pre-pulse'); element.classList.add('issue-realtime-trigger-pulse'); } }); - }, 0); + }); }, triggerAnimation() { // always reset to false before checking the change @@ -124,10 +125,9 @@ export default { }, updateEditedTimeAgo() { const toolTipTime = gl.utils.formatDate(this.apiData.updated_at); - const $timeAgoNode = $('.issue_edited_ago'); - $timeAgoNode.attr('datetime', this.apiData.updated_at); - $timeAgoNode.attr('data-original-title', toolTipTime); + this.timeAgoEl.attr('datetime', this.apiData.updated_at); + this.timeAgoEl.attr('data-original-title', toolTipTime); }, }, computed: { -- GitLab From 57731bbc6063f781a07511a3acb434a3eadf6afc Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Thu, 4 May 2017 06:37:27 -0600 Subject: [PATCH 206/363] better name for zeroData - use insteaf importing Vue --- app/assets/javascripts/issue_show/actions/tasks.js | 14 +++++++------- .../issue_show/issue_title_description.vue | 3 +-- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/app/assets/javascripts/issue_show/actions/tasks.js b/app/assets/javascripts/issue_show/actions/tasks.js index 0f6e71ce7ac97..654d6d64f0fb9 100644 --- a/app/assets/javascripts/issue_show/actions/tasks.js +++ b/app/assets/javascripts/issue_show/actions/tasks.js @@ -2,25 +2,25 @@ export default (apiData, tasks) => { const $tasks = $('#task_status'); const $tasksShort = $('#task_status_short'); const $issueableHeader = $('.issuable-header'); - const zeroData = { api: null, tasks: null }; + const tasksStates = { api: null, tasks: null }; if ($tasks.length === 0) { - if (!(apiData.task_status.indexOf('0 of 0') >= 0)) { + if (!(apiData.task_status.indexOf('0 of 0') === 0)) { $issueableHeader.append(`<span id="task_status">${apiData.task_status}</span>`); } else { $issueableHeader.append('<span id="task_status"></span>'); } } else { - zeroData.api = apiData.task_status.indexOf('0 of 0') >= 0; - zeroData.tasks = tasks.indexOf('0 of 0') >= 0; + tasksStates.api = apiData.task_status.indexOf('0 of 0') === 0; + tasksStates.tasks = tasks.indexOf('0 of 0') === 0; } - if ($tasks && !zeroData.api) { + if ($tasks && !tasksStates.api) { $tasks.text(apiData.task_status); $tasksShort.text(apiData.task_status); - } else if (zeroData.tasks) { + } else if (tasksStates.tasks) { $issueableHeader.append(`<span id="task_status">${apiData.task_status}</span>`); - } else if (zeroData.api) { + } else if (tasksStates.api) { $tasks.remove(); $tasksShort.remove(); } diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index 0593d95cf40e3..7aa019ac41d9a 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -1,5 +1,4 @@ <script> -import Vue from 'vue'; import Visibility from 'visibilityjs'; import Poll from './../lib/utils/poll'; import Service from './services/index'; @@ -91,7 +90,7 @@ export default { this.description = description; this.setTabTitle(); - Vue.nextTick(() => { + this.$nextTick(() => { elementsToVisualize.forEach((element) => { if (element) { element.classList.remove('issue-realtime-pre-pulse'); -- GitLab From 3af533f7b62c6b94f230f66e9cbd3bbd775d969f Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Thu, 4 May 2017 06:41:28 -0600 Subject: [PATCH 207/363] rename candescription to canUpdateIssue --- app/assets/javascripts/issue_show/index.js | 4 ++-- app/assets/javascripts/issue_show/issue_title_description.vue | 4 ++-- app/views/projects/issues/show.html.haml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index db1cdb6d498a6..2de072c9778d5 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -4,13 +4,13 @@ import '../vue_shared/vue_resource_interceptor'; (() => { const issueTitleData = document.querySelector('.issue-title-data').dataset; - const { candescription, endpoint } = issueTitleData; + const { canupdateissue, endpoint } = issueTitleData; const vm = new Vue({ el: '.issue-title-entrypoint', render: createElement => createElement(IssueTitle, { props: { - candescription, + canUpdateIssue: canupdateissue, endpoint, }, }), diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index 7aa019ac41d9a..424e8b88bd7b5 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -10,7 +10,7 @@ export default { required: true, type: String, }, - candescription: { + canUpdateIssue: { required: true, type: String, }, @@ -131,7 +131,7 @@ export default { }, computed: { descriptionClass() { - return `description ${this.candescription} is-task-list-enabled`; + return `description ${this.canUpdateIssue} is-task-list-enabled`; }, }, created() { diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 3971ea44ef33a..aa024be1c55d3 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -52,7 +52,7 @@ .issue-details.issuable-details .detail-page-description.content-block .issue-title-data.hidden{ "data" => { "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue), - "canDescription" => can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '', + "canUpdateIssue" => can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '', } } .issue-title-entrypoint -- GitLab From 082b868ecda0b30193a93b32d9614aef1f204484 Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Thu, 4 May 2017 07:24:47 -0600 Subject: [PATCH 208/363] fix js specs --- .../javascripts/issue_show/actions/tasks.js | 24 +++++----- .../issue_title_description_spec.js | 22 ++++----- spec/javascripts/issue_spec.js | 48 ------------------- 3 files changed, 23 insertions(+), 71 deletions(-) diff --git a/app/assets/javascripts/issue_show/actions/tasks.js b/app/assets/javascripts/issue_show/actions/tasks.js index 654d6d64f0fb9..719b604a86581 100644 --- a/app/assets/javascripts/issue_show/actions/tasks.js +++ b/app/assets/javascripts/issue_show/actions/tasks.js @@ -1,26 +1,26 @@ -export default (apiData, tasks) => { +export default (newStateData, tasks) => { const $tasks = $('#task_status'); const $tasksShort = $('#task_status_short'); const $issueableHeader = $('.issuable-header'); - const tasksStates = { api: null, tasks: null }; + const tasksStates = { newState: null, currentState: null }; if ($tasks.length === 0) { - if (!(apiData.task_status.indexOf('0 of 0') === 0)) { - $issueableHeader.append(`<span id="task_status">${apiData.task_status}</span>`); + if (!(newStateData.task_status.indexOf('0 of 0') === 0)) { + $issueableHeader.append(`<span id="task_status">${newStateData.task_status}</span>`); } else { $issueableHeader.append('<span id="task_status"></span>'); } } else { - tasksStates.api = apiData.task_status.indexOf('0 of 0') === 0; - tasksStates.tasks = tasks.indexOf('0 of 0') === 0; + tasksStates.newState = newStateData.task_status.indexOf('0 of 0') === 0; + tasksStates.currentState = tasks.indexOf('0 of 0') === 0; } - if ($tasks && !tasksStates.api) { - $tasks.text(apiData.task_status); - $tasksShort.text(apiData.task_status); - } else if (tasksStates.tasks) { - $issueableHeader.append(`<span id="task_status">${apiData.task_status}</span>`); - } else if (tasksStates.api) { + if ($tasks && !tasksStates.newState) { + $tasks.text(newStateData.task_status); + $tasksShort.text(newStateData.task_status); + } else if (tasksStates.currentState) { + $issueableHeader.append(`<span id="task_status">${newStateData.task_status}</span>`); + } else if (tasksStates.newState) { $tasks.remove(); $tasksShort.remove(); } diff --git a/spec/javascripts/issue_show/issue_title_description_spec.js b/spec/javascripts/issue_show/issue_title_description_spec.js index 3877ed2164b90..30455663e5097 100644 --- a/spec/javascripts/issue_show/issue_title_description_spec.js +++ b/spec/javascripts/issue_show/issue_title_description_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import $ from 'jquery'; import '~/render_math'; import '~/render_gfm'; -import issueTitle from '~/issue_show/issue_title_description.vue'; +import issueTitleDescription from '~/issue_show/issue_title_description.vue'; import issueShowData from './mock_data'; window.$ = $; @@ -11,18 +11,18 @@ const issueShowInterceptor = data => (request, next) => { next(request.respondWith(JSON.stringify(data), { status: 200, headers: { - 'POLL-INTERVAL': 10, + 'POLL-INTERVAL': 1, }, })); }; describe('Issue Title', () => { - const comps = { - IssueTitleComponent: {}, - }; + document.body.innerHTML = '<span id="task_status"></span>'; + + let IssueTitleDescriptionComponent; beforeEach(() => { - comps.IssueTitleComponent = Vue.extend(issueTitle); + IssueTitleDescriptionComponent = Vue.extend(issueTitleDescription); }); afterEach(() => { @@ -32,14 +32,14 @@ describe('Issue Title', () => { it('should render a title/description and update title/description on update', (done) => { Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest)); - const issueShowComponent = new comps.IssueTitleComponent({ + const issueShowComponent = new IssueTitleDescriptionComponent({ propsData: { - candescription: '.css-stuff', + canUpdateIssue: '.css-stuff', endpoint: '/gitlab-org/gitlab-shell/issues/9/rendered_title', }, }).$mount(); - // need setTimeout because actual setTimeout in code :P + // need setTimeout because of api call/v-html setTimeout(() => { expect(document.querySelector('title').innerText).toContain('this is a title (#1)'); expect(issueShowComponent.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>'); @@ -55,8 +55,8 @@ describe('Issue Title', () => { expect(issueShowComponent.$el.querySelector('.js-task-list-field').innerText).toContain('42'); done(); - }, 20); - }, 10); + }); + }); // 10ms is just long enough for the update hook to fire }); }); diff --git a/spec/javascripts/issue_spec.js b/spec/javascripts/issue_spec.js index 788d7ccc5e42c..445ecea8e643c 100644 --- a/spec/javascripts/issue_spec.js +++ b/spec/javascripts/issue_spec.js @@ -1,10 +1,5 @@ /* eslint-disable space-before-function-paren, one-var, one-var-declaration-per-line, no-use-before-define, comma-dangle, max-len */ import Issue from '~/issue'; -import Vue from 'vue'; -import '~/render_math'; -import '~/render_gfm'; -import IssueTitle from '~/issue_show/issue_title_description.vue'; -import issueShowData from './issue_show/mock_data'; require('~/lib/utils/text_utility'); @@ -81,52 +76,9 @@ describe('Issue', function() { } describe('task lists', function() { - const issueShowInterceptor = data => (request, next) => { - next(request.respondWith(JSON.stringify(data), { - status: 200, - })); - }; - beforeEach(function() { loadFixtures('issues/issue-with-task-list.html.raw'); this.issue = new Issue(); - Vue.http.interceptors.push(issueShowInterceptor(issueShowData.issueSpecRequest)); - }); - - afterEach(function() { - Vue.http.interceptors = _.without(Vue.http.interceptors, issueShowInterceptor); - }); - - it('modifies the Markdown field', function(done) { - // gotta actually render it for jquery to find elements - const vm = new Vue({ - el: document.querySelector('.issue-title-entrypoint'), - components: { - IssueTitle, - }, - render: createElement => createElement(IssueTitle, { - props: { - candescription: '.js-task-list-container', - endpoint: '/gitlab-org/gitlab-shell/issues/9/rendered_title', - }, - }), - }); - - setTimeout(() => { - spyOn(jQuery, 'ajax').and.stub(); - - const description = '<li class="task-list-item enabled"><input type="checkbox" class="task-list-item-checkbox"> Task List Item</li>'; - - expect(document.querySelector('title').innerText).toContain('this is a title (#1)'); - expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>'); - expect(vm.$el.querySelector('.wiki').innerHTML).toContain(description); - expect(vm.$el.querySelector('.js-task-list-field').value).toContain('- [ ] Task List Item'); - - // somehow the dom does not have a closest `.js-task-list.field` to the `.task-list-item-checkbox` - $('input[type=checkbox]').attr('checked', true).trigger('change'); - expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); - done(); - }, 10); }); it('submits an ajax request on tasklist:changed', function() { -- GitLab From c59f80ff2d50fccb31625eb617be1374a7ad1264 Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Thu, 4 May 2017 07:32:23 -0600 Subject: [PATCH 209/363] change error handling for api callback - check length is not zero on jquery el --- app/assets/javascripts/issue_show/actions/tasks.js | 2 +- .../javascripts/issue_show/issue_title_description.vue | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/issue_show/actions/tasks.js b/app/assets/javascripts/issue_show/actions/tasks.js index 719b604a86581..0740a9f559c26 100644 --- a/app/assets/javascripts/issue_show/actions/tasks.js +++ b/app/assets/javascripts/issue_show/actions/tasks.js @@ -15,7 +15,7 @@ export default (newStateData, tasks) => { tasksStates.currentState = tasks.indexOf('0 of 0') === 0; } - if ($tasks && !tasksStates.newState) { + if ($tasks.length !== 0 && !tasksStates.newState) { $tasks.text(newStateData.task_status); $tasksShort.text(newStateData.task_status); } else if (tasksStates.currentState) { diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index 424e8b88bd7b5..d74d59531ddd7 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -25,12 +25,7 @@ export default { this.renderResponse(res); }, errorCallback: (err) => { - if (process.env.NODE_ENV !== 'production') { - // eslint-disable-next-line no-console - console.error('ISSUE SHOW REALTIME ERROR', err, err.stack); - } else { - throw new Error(err); - } + throw new Error(err); }, }); -- GitLab From 0f58eb6bde35009b69ef871534d9ff80fc38bbf7 Mon Sep 17 00:00:00 2001 From: Douwe Maan <douwe@selenight.nl> Date: Tue, 2 May 2017 17:42:37 -0500 Subject: [PATCH 210/363] Add artifact file page that uses the blob viewer --- app/assets/javascripts/dispatcher.js | 3 + .../projects/artifacts_controller.rb | 28 ++- app/helpers/blob_helper.rb | 6 +- app/helpers/gitlab_routing_helper.rb | 2 + app/models/ci/artifact_blob.rb | 35 ++++ .../projects/artifacts/_tree_file.html.haml | 9 +- app/views/projects/artifacts/file.html.haml | 33 +++ .../unreleased/dm-artifact-blob-viewer.yml | 4 + config/routes/project.rb | 1 + features/project/builds/artifacts.feature | 3 +- features/steps/project/builds/artifacts.rb | 15 +- .../ci/build/artifacts/metadata/entry.rb | 6 + .../projects/artifacts_controller_spec.rb | 188 ++++++++++++++++++ spec/features/projects/artifacts/file_spec.rb | 59 ++++++ .../ci/build/artifacts/metadata/entry_spec.rb | 11 + spec/models/ci/artifact_blob_spec.rb | 44 ++++ .../projects/artifacts_controller_spec.rb | 117 ----------- 17 files changed, 425 insertions(+), 139 deletions(-) create mode 100644 app/models/ci/artifact_blob.rb create mode 100644 app/views/projects/artifacts/file.html.haml create mode 100644 changelogs/unreleased/dm-artifact-blob-viewer.yml create mode 100644 spec/controllers/projects/artifacts_controller_spec.rb create mode 100644 spec/features/projects/artifacts/file_spec.rb create mode 100644 spec/models/ci/artifact_blob_spec.rb delete mode 100644 spec/requests/projects/artifacts_controller_spec.rb diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 0bdce52cc89c1..7e46915310682 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -344,6 +344,9 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'projects:artifacts:browse': new BuildArtifacts(); break; + case 'projects:artifacts:file': + new BlobViewer(); + break; case 'help:index': gl.VersionCheckImage.bindErrorEvent($('img.js-version-status-badge')); break; diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index a13588b4218c2..1224e9503c93d 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -1,11 +1,13 @@ class Projects::ArtifactsController < Projects::ApplicationController include ExtractsPath + include RendersBlob layout 'project' before_action :authorize_read_build! before_action :authorize_update_build!, only: [:keep] before_action :extract_ref_name_and_path before_action :validate_artifacts! + before_action :set_path_and_entry, only: [:file, :raw] def download if artifacts_file.file_storage? @@ -24,15 +26,24 @@ def browse end def file - entry = build.artifacts_metadata_entry(params[:path]) + blob = @entry.blob + override_max_blob_size(blob) - if entry.exists? - send_artifacts_entry(build, entry) - else - render_404 + respond_to do |format| + format.html do + render 'file' + end + + format.json do + render_blob_json(blob) + end end end + def raw + send_artifacts_entry(build, @entry) + end + def keep build.keep_artifacts! redirect_to namespace_project_build_path(project.namespace, project, build) @@ -81,4 +92,11 @@ def build_from_ref def artifacts_file @artifacts_file ||= build.artifacts_file end + + def set_path_and_entry + @path = params[:path] + @entry = build.artifacts_metadata_entry(@path) + + render_404 unless @entry.exists? + end end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 37b6f4ad5cc6b..af430270ae424 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -119,7 +119,9 @@ def blob_icon(mode, name) end def blob_raw_url - if @snippet + if @build && @entry + raw_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: @entry.path) + elsif @snippet if @snippet.project_id raw_namespace_project_snippet_path(@project.namespace, @project, @snippet) else @@ -250,6 +252,8 @@ def blob_render_error_reason(viewer) case viewer.blob.external_storage when :lfs 'it is stored in LFS' + when :build_artifact + 'it is stored as a job artifact' else 'it is stored externally' end diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index e9b7cbbad6a5e..1336c676134eb 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -208,6 +208,8 @@ def artifacts_action_path(path, project, build) browse_namespace_project_build_artifacts_path(*args) when 'file' file_namespace_project_build_artifacts_path(*args) + when 'raw' + raw_namespace_project_build_artifacts_path(*args) end end diff --git a/app/models/ci/artifact_blob.rb b/app/models/ci/artifact_blob.rb new file mode 100644 index 0000000000000..b35febc9ac5a5 --- /dev/null +++ b/app/models/ci/artifact_blob.rb @@ -0,0 +1,35 @@ +module Ci + class ArtifactBlob + include BlobLike + + attr_reader :entry + + def initialize(entry) + @entry = entry + end + + delegate :name, :path, to: :entry + + def id + Digest::SHA1.hexdigest(path) + end + + def size + entry.metadata[:size] + end + + def data + "Build artifact #{path}" + end + + def mode + entry.metadata[:mode] + end + + def external_storage + :build_artifact + end + + alias_method :external_size, :size + end +end diff --git a/app/views/projects/artifacts/_tree_file.html.haml b/app/views/projects/artifacts/_tree_file.html.haml index 36fb4c998c96b..ce7e25d774b62 100644 --- a/app/views/projects/artifacts/_tree_file.html.haml +++ b/app/views/projects/artifacts/_tree_file.html.haml @@ -1,9 +1,10 @@ - path_to_file = file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: file.path) %tr.tree-item{ 'data-link' => path_to_file } + - blob = file.blob %td.tree-item-file-name - = tree_icon('file', '664', file.name) - %span.str-truncated - = link_to file.name, path_to_file + = tree_icon('file', blob.mode, blob.name) + = link_to path_to_file do + %span.str-truncated= blob.name %td - = number_to_human_size(file.metadata[:size], precision: 2) + = number_to_human_size(blob.size, precision: 2) diff --git a/app/views/projects/artifacts/file.html.haml b/app/views/projects/artifacts/file.html.haml new file mode 100644 index 0000000000000..d8da83b9a80ba --- /dev/null +++ b/app/views/projects/artifacts/file.html.haml @@ -0,0 +1,33 @@ +- page_title @path, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' += render "projects/pipelines/head" + += render "projects/builds/header", show_controls: false + +#tree-holder.tree-holder + .nav-block + %ul.breadcrumb.repo-breadcrumb + %li + = link_to 'Artifacts', browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build) + - path_breadcrumbs do |title, path| + - title = truncate(title, length: 40) + %li + - if path == @path + = link_to file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) do + %strong= title + - else + = link_to title, browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) + + + %article.file-holder + - blob = @entry.blob + .js-file-title.file-title-flex-parent + = render 'projects/blob/header_content', blob: blob + + .file-actions.hidden-xs + = render 'projects/blob/viewer_switcher', blob: blob + + .btn-group{ role: "group" }< + = copy_blob_source_button(blob) + = open_raw_blob_button(blob) + + = render 'projects/blob/content', blob: blob diff --git a/changelogs/unreleased/dm-artifact-blob-viewer.yml b/changelogs/unreleased/dm-artifact-blob-viewer.yml new file mode 100644 index 0000000000000..38f5cbb73e18e --- /dev/null +++ b/changelogs/unreleased/dm-artifact-blob-viewer.yml @@ -0,0 +1,4 @@ +--- +title: Add artifact file page that uses the blob viewer +merge_request: +author: diff --git a/config/routes/project.rb b/config/routes/project.rb index a15e365cc2fb8..5aba469b6e4d8 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -183,6 +183,7 @@ get :download get :browse, path: 'browse(/*path)', format: false get :file, path: 'file/*path', format: false + get :raw, path: 'raw/*path', format: false post :keep end end diff --git a/features/project/builds/artifacts.feature b/features/project/builds/artifacts.feature index 09094d638c966..5abc24949cf5c 100644 --- a/features/project/builds/artifacts.feature +++ b/features/project/builds/artifacts.feature @@ -46,13 +46,14 @@ Feature: Project Builds Artifacts And I navigate to parent directory of directory with invalid name Then I should not see directory with invalid name on the list + @javascript Scenario: I download a single file from build artifacts Given recent build has artifacts available And recent build has artifacts metadata available When I visit recent build details page And I click artifacts browse button And I click a link to file within build artifacts - Then download of a file extracted from build artifacts should start + Then I see a download link @javascript Scenario: I click on a row in an artifacts table diff --git a/features/steps/project/builds/artifacts.rb b/features/steps/project/builds/artifacts.rb index 3ec5c8e2f4ffc..eec375b0532fc 100644 --- a/features/steps/project/builds/artifacts.rb +++ b/features/steps/project/builds/artifacts.rb @@ -3,6 +3,7 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps include SharedProject include SharedBuilds include RepoHelpers + include WaitForAjax step 'I click artifacts download button' do click_link 'Download' @@ -78,19 +79,11 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps step 'I click a link to file within build artifacts' do page.within('.tree-table') { find_link('ci_artifacts.txt').click } + wait_for_ajax end - step 'download of a file extracted from build artifacts should start' do - send_data = response_headers[Gitlab::Workhorse::SEND_DATA_HEADER] - - expect(send_data).to start_with('artifacts-entry:') - - base64_params = send_data.sub(/\Aartifacts\-entry:/, '') - params = JSON.parse(Base64.urlsafe_decode64(base64_params)) - - expect(params.keys).to eq(%w(Archive Entry)) - expect(params['Archive']).to end_with('build_artifacts.zip') - expect(params['Entry']).to eq(Base64.encode64('ci_artifacts.txt')) + step 'I see a download link' do + expect(page).to have_link 'download it' end step 'I click a first row within build artifacts table' do diff --git a/lib/gitlab/ci/build/artifacts/metadata/entry.rb b/lib/gitlab/ci/build/artifacts/metadata/entry.rb index 6f799c2f031b0..2e073334abca9 100644 --- a/lib/gitlab/ci/build/artifacts/metadata/entry.rb +++ b/lib/gitlab/ci/build/artifacts/metadata/entry.rb @@ -37,6 +37,12 @@ def file? !directory? end + def blob + return unless file? + + @blob ||= Blob.decorate(::Ci::ArtifactBlob.new(self), nil) + end + def has_parent? nodes > 0 end diff --git a/spec/controllers/projects/artifacts_controller_spec.rb b/spec/controllers/projects/artifacts_controller_spec.rb new file mode 100644 index 0000000000000..eff9fab8da2a5 --- /dev/null +++ b/spec/controllers/projects/artifacts_controller_spec.rb @@ -0,0 +1,188 @@ +require 'spec_helper' + +describe Projects::ArtifactsController do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + + let(:pipeline) do + create(:ci_pipeline, + project: project, + sha: project.commit.sha, + ref: project.default_branch, + status: 'success') + end + + let(:build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) } + + before do + project.team << [user, :developer] + + sign_in(user) + end + + describe 'GET download' do + it 'sends the artifacts file' do + expect(controller).to receive(:send_file).with(build.artifacts_file.path, disposition: 'attachment').and_call_original + + get :download, namespace_id: project.namespace, project_id: project, build_id: build + end + end + + describe 'GET browse' do + context 'when the directory exists' do + it 'renders the browse view' do + get :browse, namespace_id: project.namespace, project_id: project, build_id: build, path: 'other_artifacts_0.1.2' + + expect(response).to render_template('projects/artifacts/browse') + end + end + + context 'when the directory does not exist' do + it 'responds Not Found' do + get :browse, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + + expect(response).to be_not_found + end + end + end + + describe 'GET file' do + context 'when the file exists' do + it 'renders the file view' do + get :file, namespace_id: project.namespace, project_id: project, build_id: build, path: 'ci_artifacts.txt' + + expect(response).to render_template('projects/artifacts/file') + end + end + + context 'when the file does not exist' do + it 'responds Not Found' do + get :file, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + + expect(response).to be_not_found + end + end + end + + describe 'GET raw' do + context 'when the file exists' do + it 'serves the file using workhorse' do + get :raw, namespace_id: project.namespace, project_id: project, build_id: build, path: 'ci_artifacts.txt' + + send_data = response.headers[Gitlab::Workhorse::SEND_DATA_HEADER] + + expect(send_data).to start_with('artifacts-entry:') + + base64_params = send_data.sub(/\Aartifacts\-entry:/, '') + params = JSON.parse(Base64.urlsafe_decode64(base64_params)) + + expect(params.keys).to eq(%w(Archive Entry)) + expect(params['Archive']).to end_with('build_artifacts.zip') + expect(params['Entry']).to eq(Base64.encode64('ci_artifacts.txt')) + end + end + + context 'when the file does not exist' do + it 'responds Not Found' do + get :raw, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + + expect(response).to be_not_found + end + end + end + + describe 'GET latest_succeeded' do + def params_from_ref(ref = pipeline.ref, job = build.name, path = 'browse') + { + namespace_id: project.namespace, + project_id: project, + ref_name_and_path: File.join(ref, path), + job: job + } + end + + context 'cannot find the build' do + shared_examples 'not found' do + it { expect(response).to have_http_status(:not_found) } + end + + context 'has no such ref' do + before do + get :latest_succeeded, params_from_ref('TAIL', build.name) + end + + it_behaves_like 'not found' + end + + context 'has no such build' do + before do + get :latest_succeeded, params_from_ref(pipeline.ref, 'NOBUILD') + end + + it_behaves_like 'not found' + end + + context 'has no path' do + before do + get :latest_succeeded, params_from_ref(pipeline.sha, build.name, '') + end + + it_behaves_like 'not found' + end + end + + context 'found the build and redirect' do + shared_examples 'redirect to the build' do + it 'redirects' do + path = browse_namespace_project_build_artifacts_path( + project.namespace, + project, + build) + + expect(response).to redirect_to(path) + end + end + + context 'with regular branch' do + before do + pipeline.update(ref: 'master', + sha: project.commit('master').sha) + + get :latest_succeeded, params_from_ref('master') + end + + it_behaves_like 'redirect to the build' + end + + context 'with branch name containing slash' do + before do + pipeline.update(ref: 'improve/awesome', + sha: project.commit('improve/awesome').sha) + + get :latest_succeeded, params_from_ref('improve/awesome') + end + + it_behaves_like 'redirect to the build' + end + + context 'with branch name and path containing slashes' do + before do + pipeline.update(ref: 'improve/awesome', + sha: project.commit('improve/awesome').sha) + + get :latest_succeeded, params_from_ref('improve/awesome', build.name, 'file/README.md') + end + + it 'redirects' do + path = file_namespace_project_build_artifacts_path( + project.namespace, + project, + build, + 'README.md') + + expect(response).to redirect_to(path) + end + end + end + end +end diff --git a/spec/features/projects/artifacts/file_spec.rb b/spec/features/projects/artifacts/file_spec.rb new file mode 100644 index 0000000000000..74308a7e8dd39 --- /dev/null +++ b/spec/features/projects/artifacts/file_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +feature 'Artifact file', :js, feature: true do + let(:project) { create(:project, :public) } + let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.sha, ref: 'master') } + let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) } + + def visit_file(path) + visit file_namespace_project_build_artifacts_path(project.namespace, project, build, path) + end + + context 'Text file' do + before do + visit_file('other_artifacts_0.1.2/doc_sample.txt') + + wait_for_ajax + end + + it 'displays an error' do + aggregate_failures do + # shows an error message + expect(page).to have_content('The source could not be displayed because it is stored as a job artifact. You can download it instead.') + + # does not show a viewer switcher + expect(page).not_to have_selector('.js-blob-viewer-switcher') + + # does not show a copy button + expect(page).not_to have_selector('.js-copy-blob-source-btn') + + # shows a download button + expect(page).to have_link('Download') + end + end + end + + context 'JPG file' do + before do + visit_file('rails_sample.jpg') + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # shows rendered image + expect(page).to have_selector('.image_file img') + + # does not show a viewer switcher + expect(page).not_to have_selector('.js-blob-viewer-switcher') + + # does not show a copy button + expect(page).not_to have_selector('.js-copy-blob-source-btn') + + # shows a download button + expect(page).to have_link('Download') + end + end + end +end diff --git a/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb b/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb index abc93e1b44a92..3b9056114674f 100644 --- a/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb +++ b/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb @@ -135,6 +135,17 @@ def entry(path) subject { |example| path(example).nodes } it { is_expected.to eq 4 } end + + describe '#blob' do + let(:file_entry) { |example| path(example) } + subject { file_entry.blob } + + it 'returns a blob representing the entry data' do + expect(subject).to be_a(Blob) + expect(subject.path).to eq(file_entry.path) + expect(subject.size).to eq(file_entry.metadata[:size]) + end + end end describe 'non-existent/', path: 'non-existent/' do diff --git a/spec/models/ci/artifact_blob_spec.rb b/spec/models/ci/artifact_blob_spec.rb new file mode 100644 index 0000000000000..968593d7e9bef --- /dev/null +++ b/spec/models/ci/artifact_blob_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +describe Ci::ArtifactBlob, models: true do + let(:build) { create(:ci_build, :artifacts) } + let(:entry) { build.artifacts_metadata_entry('other_artifacts_0.1.2/another-subdirectory/banana_sample.gif') } + + subject { described_class.new(entry) } + + describe '#id' do + it 'returns a hash of the path' do + expect(subject.id).to eq(Digest::SHA1.hexdigest(entry.path)) + end + end + + describe '#name' do + it 'returns the entry name' do + expect(subject.name).to eq(entry.name) + end + end + + describe '#path' do + it 'returns the entry path' do + expect(subject.path).to eq(entry.path) + end + end + + describe '#size' do + it 'returns the entry size' do + expect(subject.size).to eq(entry.metadata[:size]) + end + end + + describe '#mode' do + it 'returns the entry mode' do + expect(subject.mode).to eq(entry.metadata[:mode]) + end + end + + describe '#external_storage' do + it 'returns :build_artifact' do + expect(subject.external_storage).to eq(:build_artifact) + end + end +end diff --git a/spec/requests/projects/artifacts_controller_spec.rb b/spec/requests/projects/artifacts_controller_spec.rb deleted file mode 100644 index d20866c0d44d4..0000000000000 --- a/spec/requests/projects/artifacts_controller_spec.rb +++ /dev/null @@ -1,117 +0,0 @@ -require 'spec_helper' - -describe Projects::ArtifactsController do - let(:user) { create(:user) } - let(:project) { create(:project, :repository) } - - let(:pipeline) do - create(:ci_pipeline, - project: project, - sha: project.commit.sha, - ref: project.default_branch, - status: 'success') - end - - let(:build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) } - - describe 'GET /:project/builds/artifacts/:ref_name/browse?job=name' do - before do - project.team << [user, :developer] - - login_as(user) - end - - def path_from_ref( - ref = pipeline.ref, job = build.name, path = 'browse') - latest_succeeded_namespace_project_artifacts_path( - project.namespace, - project, - [ref, path].join('/'), - job: job) - end - - context 'cannot find the build' do - shared_examples 'not found' do - it { expect(response).to have_http_status(:not_found) } - end - - context 'has no such ref' do - before do - get path_from_ref('TAIL', build.name) - end - - it_behaves_like 'not found' - end - - context 'has no such build' do - before do - get path_from_ref(pipeline.ref, 'NOBUILD') - end - - it_behaves_like 'not found' - end - - context 'has no path' do - before do - get path_from_ref(pipeline.sha, build.name, '') - end - - it_behaves_like 'not found' - end - end - - context 'found the build and redirect' do - shared_examples 'redirect to the build' do - it 'redirects' do - path = browse_namespace_project_build_artifacts_path( - project.namespace, - project, - build) - - expect(response).to redirect_to(path) - end - end - - context 'with regular branch' do - before do - pipeline.update(ref: 'master', - sha: project.commit('master').sha) - - get path_from_ref('master') - end - - it_behaves_like 'redirect to the build' - end - - context 'with branch name containing slash' do - before do - pipeline.update(ref: 'improve/awesome', - sha: project.commit('improve/awesome').sha) - - get path_from_ref('improve/awesome') - end - - it_behaves_like 'redirect to the build' - end - - context 'with branch name and path containing slashes' do - before do - pipeline.update(ref: 'improve/awesome', - sha: project.commit('improve/awesome').sha) - - get path_from_ref('improve/awesome', build.name, 'file/README.md') - end - - it 'redirects' do - path = file_namespace_project_build_artifacts_path( - project.namespace, - project, - build, - 'README.md') - - expect(response).to redirect_to(path) - end - end - end - end -end -- GitLab From f68f0cd1eacbd9137b014c486c6f6cd6b6378e58 Mon Sep 17 00:00:00 2001 From: Filipa Lacerda <filipa@gitlab.com> Date: Thu, 4 May 2017 14:53:00 +0100 Subject: [PATCH 211/363] Adds numbered lists to easily point to documentation --- doc/development/fe_guide/style_guide_js.md | 544 ++++++++++----------- doc/development/fe_guide/vue.md | 15 + 2 files changed, 277 insertions(+), 282 deletions(-) diff --git a/doc/development/fe_guide/style_guide_js.md b/doc/development/fe_guide/style_guide_js.md index 1d2b055894830..c8a77f6760708 100644 --- a/doc/development/fe_guide/style_guide_js.md +++ b/doc/development/fe_guide/style_guide_js.md @@ -11,207 +11,197 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns. #### ESlint -- **Never** disable eslint rules unless you have a good reason. You may see a lot of legacy files with `/* eslint-disable some-rule, some-other-rule */` at the top, but legacy files are a special case. Any time you develop a new feature or refactor an existing one, you should abide by the eslint rules. - -- **Never Ever EVER** disable eslint globally for a file +1. **Never** disable eslint rules unless you have a good reason. You may see a lot of legacy files with `/* eslint-disable some-rule, some-other-rule */` at the top, but legacy files are a special case. Any time you develop a new feature or refactor an existing one, you should abide by the eslint rules. +1. **Never Ever EVER** disable eslint globally for a file ```javascript - // bad - /* eslint-disable */ + // bad + /* eslint-disable */ - // better - /* eslint-disable some-rule, some-other-rule */ + // better + /* eslint-disable some-rule, some-other-rule */ - // best - // nothing :) + // best + // nothing :) ``` -- If you do need to disable a rule for a single violation, try to do it as locally as possible - +1. If you do need to disable a rule for a single violation, try to do it as locally as possible ```javascript - // bad - /* eslint-disable no-new */ + // bad + /* eslint-disable no-new */ - import Foo from 'foo'; + import Foo from 'foo'; - new Foo(); + new Foo(); - // better - import Foo from 'foo'; + // better + import Foo from 'foo'; - // eslint-disable-next-line no-new - new Foo(); + // eslint-disable-next-line no-new + new Foo(); ``` +1. There are few rules that we need to disable due to technical debt. Which are: + 1. [no-new][eslint-new] + 1. [class-methods-use-this][eslint-this] -- When they are needed _always_ place ESlint directive comment blocks on the first line of a script, followed by any global declarations, then a blank newline prior to any imports or code. - +1. When they are needed _always_ place ESlint directive comment blocks on the first line of a script, followed by any global declarations, then a blank newline prior to any imports or code. ```javascript - // bad - /* global Foo */ - /* eslint-disable no-new */ - import Bar from './bar'; + // bad + /* global Foo */ + /* eslint-disable no-new */ + import Bar from './bar'; - // good - /* eslint-disable no-new */ - /* global Foo */ + // good + /* eslint-disable no-new */ + /* global Foo */ - import Bar from './bar'; + import Bar from './bar'; ``` -- **Never** disable the `no-undef` rule. Declare globals with `/* global Foo */` instead. - -- When declaring multiple globals, always use one `/* global [name] */` line per variable. +1. **Never** disable the `no-undef` rule. Declare globals with `/* global Foo */` instead. +1. When declaring multiple globals, always use one `/* global [name] */` line per variable. ```javascript - // bad - /* globals Flash, Cookies, jQuery */ + // bad + /* globals Flash, Cookies, jQuery */ - // good - /* global Flash */ - /* global Cookies */ - /* global jQuery */ + // good + /* global Flash */ + /* global Cookies */ + /* global jQuery */ ``` - -- Use up to 3 parameters for a function or class. If you need more accept an Object instead. +1. Use up to 3 parameters for a function or class. If you need more accept an Object instead. ```javascript - // bad - fn(p1, p2, p3, p4) {} + // bad + fn(p1, p2, p3, p4) {} - // good - fn(options) {} + // good + fn(options) {} ``` #### Modules, Imports, and Exports -- Use ES module syntax to import modules - +1. Use ES module syntax to import modules ```javascript - // bad - require('foo'); + // bad + require('foo'); - // good - import Foo from 'foo'; + // good + import Foo from 'foo'; - // bad - module.exports = Foo; + // bad + module.exports = Foo; - // good - export default Foo; + // good + export default Foo; ``` -- Relative paths +1. Relative paths: Unless you are writing a test, always reference other scripts using relative paths instead of `~` + * In **app/assets/javascripts**: - Unless you are writing a test, always reference other scripts using relative paths instead of `~` + ```javascript + // bad + import Foo from '~/foo' - In **app/assets/javascripts**: - ```javascript - // bad - import Foo from '~/foo' + // good + import Foo from '../foo'; + ``` + * In **spec/javascripts**: - // good - import Foo from '../foo'; - ``` - - In **spec/javascripts**: - ```javascript - // bad - import Foo from '../../app/assets/javascripts/foo' - - // good - import Foo from '~/foo'; - ``` + ```javascript + // bad + import Foo from '../../app/assets/javascripts/foo' -- Avoid using IIFE. Although we have a lot of examples of files which wrap their contents in IIFEs (immediately-invoked function expressions), this is no longer necessary after the transition from Sprockets to webpack. Do not use them anymore and feel free to remove them when refactoring legacy code. + // good + import Foo from '~/foo'; + ``` -- Avoid adding to the global namespace. +1. Avoid using IIFE. Although we have a lot of examples of files which wrap their contents in IIFEs (immediately-invoked function expressions), this is no longer necessary after the transition from Sprockets to webpack. Do not use them anymore and feel free to remove them when refactoring legacy code. +1. Avoid adding to the global namespace. ```javascript - // bad - window.MyClass = class { /* ... */ }; + // bad + window.MyClass = class { /* ... */ }; - // good - export default class MyClass { /* ... */ } + // good + export default class MyClass { /* ... */ } ``` -- Side effects are forbidden in any script which contains exports - +1. Side effects are forbidden in any script which contains exports ```javascript - // bad - export default class MyClass { /* ... */ } + // bad + export default class MyClass { /* ... */ } - document.addEventListener("DOMContentLoaded", function(event) { - new MyClass(); - } + document.addEventListener("DOMContentLoaded", function(event) { + new MyClass(); + } ``` #### Data Mutation and Pure functions -- Strive to write many small pure functions, and minimize where mutations occur. - +1. Strive to write many small pure functions, and minimize where mutations occur. ```javascript - // bad - const values = {foo: 1}; + // bad + const values = {foo: 1}; - function impureFunction(items) { - const bar = 1; + function impureFunction(items) { + const bar = 1; - items.foo = items.a * bar + 2; + items.foo = items.a * bar + 2; - return items.a; - } + return items.a; + } - const c = impureFunction(values); + const c = impureFunction(values); - // good - var values = {foo: 1}; + // good + var values = {foo: 1}; - function pureFunction (foo) { - var bar = 1; + function pureFunction (foo) { + var bar = 1; - foo = foo * bar + 2; + foo = foo * bar + 2; - return foo; - } + return foo; + } - var c = pureFunction(values.foo); + var c = pureFunction(values.foo); ``` -- Avoid constructors with side-effects +1. Avoid constructors with side-effects -- Prefer `.map`, `.reduce` or `.filter` over `.forEach` +1. Prefer `.map`, `.reduce` or `.filter` over `.forEach` A forEach will cause side effects, it will be mutating the array being iterated. Prefer using `.map`, `.reduce` or `.filter` - ```javascript - const users = [ { name: 'Foo' }, { name: 'Bar' } ]; + const users = [ { name: 'Foo' }, { name: 'Bar' } ]; - // bad - users.forEach((user, index) => { - user.id = index; - }); + // bad + users.forEach((user, index) => { + user.id = index; + }); - // good - const usersWithId = users.map((user, index) => { - return Object.assign({}, user, { id: index }); - }); + // good + const usersWithId = users.map((user, index) => { + return Object.assign({}, user, { id: index }); + }); ``` #### Parse Strings into Numbers -- `parseInt()` is preferable over `Number()` or `+` - +1. `parseInt()` is preferable over `Number()` or `+` ```javascript - // bad - +'10' // 10 + // bad + +'10' // 10 - // good - Number('10') // 10 + // good + Number('10') // 10 - // better - parseInt('10', 10); + // better + parseInt('10', 10); ``` #### CSS classes used for JavaScript -- If the class is being used in Javascript it needs to be prepend with `js-` +1. If the class is being used in Javascript it needs to be prepend with `js-` ```html // bad <button class="add-user"> @@ -226,234 +216,222 @@ A forEach will cause side effects, it will be mutating the array being iterated. ### Vue.js - #### Basic Rules -- Only include one Vue.js component per file. -- Export components as plain objects: - +1. Only include one Vue.js component per file. +1. Export components as plain objects: ```javascript - export default { - template: `<h1>I'm a component</h1> - } + export default { + template: `<h1>I'm a component</h1> + } ``` +1. #### Naming -- **Extensions**: Use `.vue` extension for Vue components. -- **Reference Naming**: Use PascalCase for Vue components and camelCase for their instances: - +1. **Extensions**: Use `.vue` extension for Vue components. +1. **Reference Naming**: Use PascalCase for Vue components and camelCase for their instances: ```javascript - // bad - import cardBoard from 'cardBoard'; + // bad + import cardBoard from 'cardBoard'; - // good - import CardBoard from 'cardBoard' + // good + import CardBoard from 'cardBoard' - // bad - components: { - CardBoard: CardBoard - }; + // bad + components: { + CardBoard: CardBoard + }; - // good - components: { - cardBoard: CardBoard - }; + // good + components: { + cardBoard: CardBoard + }; ``` -- **Props Naming:** -- Avoid using DOM component prop names. -- Use kebab-case instead of camelCase to provide props in templates. - +1. **Props Naming:** +1. Avoid using DOM component prop names. +1. Use kebab-case instead of camelCase to provide props in templates. ```javascript - // bad - <component class="btn"> + // bad + <component class="btn"> - // good - <component css-class="btn"> + // good + <component css-class="btn"> - // bad - <component myProp="prop" /> + // bad + <component myProp="prop" /> - // good - <component my-prop="prop" /> -``` + // good + <component my-prop="prop" /> + ``` #### Alignment -- Follow these alignment styles for the template method: - +1. Follow these alignment styles for the template method: ```javascript - // bad - <component v-if="bar" - param="baz" /> + // bad + <component v-if="bar" + param="baz" /> - <button class="btn">Click me</button> + <button class="btn">Click me</button> - // good - <component - v-if="bar" - param="baz" - /> + // good + <component + v-if="bar" + param="baz" + /> - <button class="btn"> - Click me - </button> + <button class="btn"> + Click me + </button> - // if props fit in one line then keep it on the same line - <component bar="bar" /> + // if props fit in one line then keep it on the same line + <component bar="bar" /> ``` #### Quotes -- Always use double quotes `"` inside templates and single quotes `'` for all other JS. - +1. Always use double quotes `"` inside templates and single quotes `'` for all other JS. ```javascript - // bad - template: ` - <button :class='style'>Button</button> - ` - - // good - template: ` - <button :class="style">Button</button> - ` + // bad + template: ` + <button :class='style'>Button</button> + ` + + // good + template: ` + <button :class="style">Button</button> + ` ``` #### Props -- Props should be declared as an object - +1. Props should be declared as an object ```javascript - // bad - props: ['foo'] - - // good - props: { - foo: { - type: String, - required: false, - default: 'bar' + // bad + props: ['foo'] + + // good + props: { + foo: { + type: String, + required: false, + default: 'bar' + } } - } ``` -- Required key should always be provided when declaring a prop - +1. Required key should always be provided when declaring a prop ```javascript - // bad - props: { - foo: { - type: String, + // bad + props: { + foo: { + type: String, + } } - } - - // good - props: { - foo: { - type: String, - required: false, - default: 'bar' + + // good + props: { + foo: { + type: String, + required: false, + default: 'bar' + } } - } ``` -- Default key should always be provided if the prop is not required: - +1. Default key should always be provided if the prop is not required: ```javascript - // bad - props: { - foo: { - type: String, - required: false, + // bad + props: { + foo: { + type: String, + required: false, + } } - } - - // good - props: { - foo: { - type: String, - required: false, - default: 'bar' + + // good + props: { + foo: { + type: String, + required: false, + default: 'bar' + } } - } - // good - props: { - foo: { - type: String, - required: true + // good + props: { + foo: { + type: String, + required: true + } } - } ``` #### Data -- `data` method should always be a function +1. `data` method should always be a function ```javascript - // bad - data: { - foo: 'foo' - } - - // good - data() { - return { + // bad + data: { foo: 'foo' - }; - } + } + + // good + data() { + return { + foo: 'foo' + }; + } ``` #### Directives -- Shorthand `@` is preferable over `v-on` - +1. Shorthand `@` is preferable over `v-on` ```javascript - // bad - <component v-on:click="eventHandler"/> + // bad + <component v-on:click="eventHandler"/> - // good - <component @click="eventHandler"/> + // good + <component @click="eventHandler"/> ``` -- Shorthand `:` is preferable over `v-bind` - +1. Shorthand `:` is preferable over `v-bind` ```javascript - // bad - <component v-bind:class="btn"/> + // bad + <component v-bind:class="btn"/> - // good - <component :class="btn"/> + // good + <component :class="btn"/> ``` #### Closing tags -- Prefer self closing component tags - +1. Prefer self closing component tags ```javascript - // bad - <component></component> + // bad + <component></component> - // good - <component /> + // good + <component /> ``` #### Ordering -- Order for a Vue Component: +1. Order for a Vue Component: 1. `name` - 2. `props` - 3. `data` - 4. `components` - 5. `computedProps` - 6. `methods` - 7. lifecycle methods - 1. `beforeCreate` - 2. `created` - 3. `beforeMount` - 4. `mounted` - 5. `beforeUpdate` - 6. `updated` - 7. `activated` - 8. `deactivated` - 9. `beforeDestroy` - 10. `destroyed` - 8. `template` + 1. `props` + 1. `mixins` + 1. `data` + 1. `components` + 1. `computedProps` + 1. `methods` + 1. `beforeCreate` + 1. `created` + 1. `beforeMount` + 1. `mounted` + 1. `beforeUpdate` + 1. `updated` + 1. `activated` + 1. `deactivated` + 1. `beforeDestroy` + 1. `destroyed` ## SCSS @@ -461,3 +439,5 @@ A forEach will cause side effects, it will be mutating the array being iterated. [airbnb-js-style-guide]: https://github.com/airbnb/javascript [eslintrc]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.eslintrc +[eslint-this]: http://eslint.org/docs/rules/class-methods-use-this +[eslint-new]: http://eslint.org/docs/rules/no-new diff --git a/doc/development/fe_guide/vue.md b/doc/development/fe_guide/vue.md index 73d2ffc1bdc7b..a984bb6c94ce6 100644 --- a/doc/development/fe_guide/vue.md +++ b/doc/development/fe_guide/vue.md @@ -387,6 +387,10 @@ describe('Todos App', () => { }); }); ``` +#### Test the component's output +The main return value of a Vue component is the rendered output. In order to test the component we +need to test the rendered output. [Vue][vue-test] guide's to unit test show us exactly that: + ### Stubbing API responses [Vue Resource Interceptors][vue-resource-interceptor] allow us to add a interceptor with @@ -419,6 +423,16 @@ the response we need: }); ``` +1. Use `$.mount()` to mount the component +```javascript + // bad + new Component({ + el: document.createElement('div') + }); + + // good + new Component().$mount(); +``` [vue-docs]: http://vuejs.org/guide/index.html [issue-boards]: https://gitlab.com/gitlab-org/gitlab-ce/tree/master/app/assets/javascripts/boards @@ -429,5 +443,6 @@ the response we need: [one-way-data-flow]: https://vuejs.org/v2/guide/components.html#One-Way-Data-Flow [vue-resource-repo]: https://github.com/pagekit/vue-resource [vue-resource-interceptor]: https://github.com/pagekit/vue-resource/blob/develop/docs/http.md#interceptors +[vue-test]: https://vuejs.org/v2/guide/unit-testing.html [issue-boards-service]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/assets/javascripts/boards/services/board_service.js.es6 [flux]: https://facebook.github.io/flux -- GitLab From 387c4b2c21a44360386a9b8ce6849e7f1b8a3de9 Mon Sep 17 00:00:00 2001 From: Valery Sizov <valery@gitlab.com> Date: Thu, 4 May 2017 15:11:15 +0300 Subject: [PATCH 212/363] Backport of multiple_assignees_feature [ci skip] --- .../blob/target_branch_dropdown.js | 4 +- .../javascripts/blob/template_selector.js | 7 +- .../javascripts/boards/boards_bundle.js | 2 +- .../boards/components/board_new_issue.js | 1 + .../boards/components/board_sidebar.js | 61 ++- .../boards/components/issue_card_inner.js | 104 +++-- .../boards/components/new_list_dropdown.js | 4 +- .../boards/models/{user.js => assignee.js} | 6 +- app/assets/javascripts/boards/models/issue.js | 32 +- app/assets/javascripts/gl_dropdown.js | 56 ++- .../javascripts/issuable/issuable_bundle.js | 1 - .../components/no_tracking_pane.js | 12 - .../time_tracking/time_tracking_bundle.js | 66 --- app/assets/javascripts/issue_status_select.js | 4 +- .../javascripts/issues_bulk_assignment.js | 3 + app/assets/javascripts/labels_select.js | 7 +- app/assets/javascripts/main.js | 1 - app/assets/javascripts/members.js | 19 + app/assets/javascripts/milestone_select.js | 23 ++ app/assets/javascripts/namespace_select.js | 3 +- app/assets/javascripts/project.js | 3 +- .../protected_branch_access_dropdown.js | 5 +- .../protected_branch_dropdown.js | 3 +- .../components/assignees/assignee_title.js | 41 ++ .../sidebar/components/assignees/assignees.js | 224 +++++++++++ .../components/assignees/sidebar_assignees.js | 84 ++++ .../time_tracking/collapsed_state.js | 97 +++++ .../time_tracking/comparison_pane.js | 98 +++++ .../time_tracking/estimate_only_pane.js | 17 + .../components/time_tracking/help_state.js | 44 ++ .../time_tracking/no_tracking_pane.js | 10 + .../time_tracking/sidebar_time_tracking.js | 45 +++ .../time_tracking/spent_only_pane.js | 15 + .../components/time_tracking/time_tracker.js | 163 ++++++++ app/assets/javascripts/sidebar/event_hub.js | 3 + .../sidebar/services/sidebar_service.js | 28 ++ .../javascripts/sidebar/sidebar_bundle.js | 21 + .../javascripts/sidebar/sidebar_mediator.js | 38 ++ .../sidebar/stores/sidebar_store.js | 52 +++ app/assets/javascripts/subbable_resource.js | 51 --- app/assets/javascripts/subscription_select.js | 4 +- app/assets/javascripts/users_select.js | 377 ++++++++++++++---- app/assets/stylesheets/framework/avatar.scss | 11 + .../stylesheets/framework/dropdowns.scss | 12 +- app/assets/stylesheets/framework/lists.scss | 1 + app/assets/stylesheets/pages/boards.scss | 74 +++- app/assets/stylesheets/pages/diff.scss | 9 +- app/assets/stylesheets/pages/issuable.scss | 125 +++++- app/controllers/concerns/issuable_actions.rb | 1 + .../concerns/issuable_collections.rb | 2 +- .../projects/boards/issues_controller.rb | 2 +- app/controllers/projects/issues_controller.rb | 6 +- app/finders/issuable_finder.rb | 2 +- app/finders/issues_finder.rb | 19 +- app/helpers/form_helper.rb | 33 ++ app/helpers/issuables_helper.rb | 10 + app/mailers/emails/issues.rb | 6 +- app/models/concerns/issuable.rb | 27 +- app/models/concerns/milestoneish.rb | 2 +- app/models/global_milestone.rb | 6 +- app/models/issue.rb | 35 +- app/models/issue_assignee.rb | 29 ++ app/models/merge_request.rb | 32 ++ app/models/milestone.rb | 5 +- app/models/user.rb | 5 + app/serializers/issuable_entity.rb | 1 - app/serializers/issue_entity.rb | 1 + app/serializers/merge_request_entity.rb | 1 + app/services/issuable/bulk_update_service.rb | 6 +- app/services/issuable_base_service.rb | 23 +- app/services/issues/base_service.rb | 19 + app/services/issues/update_service.rb | 14 +- .../merge_requests/assign_issues_service.rb | 4 +- app/services/merge_requests/base_service.rb | 5 + app/services/merge_requests/update_service.rb | 5 +- .../notification_recipient_service.rb | 7 +- app/services/notification_service.rb | 27 +- .../slash_commands/interpret_service.rb | 23 +- app/services/system_note_service.rb | 38 ++ app/services/todo_service.rb | 4 +- app/views/issues/_issue.atom.builder | 10 +- .../_reassigned_issuable_email.text.erb | 6 - app/views/notify/new_issue_email.html.haml | 4 +- app/views/notify/new_issue_email.text.erb | 2 +- .../new_mention_in_issue_email.text.erb | 2 +- .../notify/reassigned_issue_email.html.haml | 11 +- .../notify/reassigned_issue_email.text.erb | 7 +- .../reassigned_merge_request_email.html.haml | 10 +- .../reassigned_merge_request_email.text.erb | 7 +- .../components/sidebar/_assignee.html.haml | 48 +-- app/views/projects/issues/_issue.html.haml | 4 +- .../shared/issuable/_assignees.html.haml | 15 + .../shared/issuable/_participants.html.haml | 8 +- .../shared/issuable/_search_bar.html.haml | 9 + app/views/shared/issuable/_sidebar.html.haml | 95 +++-- .../issuable/form/_issue_assignee.html.haml | 30 ++ .../form/_merge_request_assignee.html.haml | 31 ++ .../shared/issuable/form/_metadata.html.haml | 25 +- .../shared/milestones/_issuable.html.haml | 8 +- .../update-issue-board-cards-design.yml | 4 + config/webpack.config.js | 7 +- db/fixtures/development/09_issues.rb | 2 +- ...0320171632_create_issue_assignees_table.rb | 40 ++ .../20170320173259_migrate_assignees.rb | 52 +++ db/schema.rb | 10 + doc/api/issues.md | 84 +++- doc/user/project/integrations/webhooks.md | 12 + features/steps/dashboard/dashboard.rb | 2 +- features/steps/dashboard/todos.rb | 2 +- features/steps/group/milestones.rb | 4 +- features/steps/groups.rb | 4 +- lib/api/api.rb | 2 + lib/api/entities.rb | 6 +- lib/api/helpers/common_helpers.rb | 13 + lib/api/issues.rb | 9 +- lib/api/v3/entities.rb | 7 + lib/api/v3/issues.rb | 33 +- lib/api/v3/merge_requests.rb | 2 +- lib/api/v3/milestones.rb | 4 +- lib/banzai/reference_parser/issue_parser.rb | 2 +- .../chat_commands/presenters/issue_base.rb | 2 +- lib/gitlab/fogbugz_import/importer.rb | 18 +- lib/gitlab/github_import/issue_formatter.rb | 2 +- lib/gitlab/google_code_import/importer.rb | 14 +- .../dashboard/todos_controller_spec.rb | 2 +- .../projects/boards/issues_controller_spec.rb | 2 +- .../projects/issues_controller_spec.rb | 8 +- .../merge_requests_controller_spec.rb | 2 +- spec/features/atom/dashboard_issues_spec.rb | 8 +- spec/features/atom/issues_spec.rb | 6 +- spec/features/boards/boards_spec.rb | 2 +- spec/features/boards/modal_filter_spec.rb | 2 +- spec/features/boards/sidebar_spec.rb | 35 +- .../dashboard/issuables_counter_spec.rb | 4 +- spec/features/dashboard/issues_spec.rb | 7 +- spec/features/dashboard_issues_spec.rb | 4 +- .../features/gitlab_flavored_markdown_spec.rb | 4 +- spec/features/issues/award_emoji_spec.rb | 2 +- .../filtered_search/filter_issues_spec.rb | 11 +- spec/features/issues/form_spec.rb | 54 ++- spec/features/issues/issue_sidebar_spec.rb | 15 + spec/features/issues/update_issues_spec.rb | 2 +- spec/features/issues_spec.rb | 40 +- .../merge_requests/assign_issues_spec.rb | 2 +- spec/features/milestones/show_spec.rb | 2 +- .../projects/issuable_templates_spec.rb | 4 +- spec/features/search_spec.rb | 2 +- spec/features/unsubscribe_links_spec.rb | 2 +- spec/finders/issues_finder_spec.rb | 26 +- spec/fixtures/api/schemas/issue.json | 20 + .../api/schemas/public_api/v4/issues.json | 17 +- spec/helpers/issuables_helper_spec.rb | 17 + spec/javascripts/boards/boards_store_spec.js | 19 +- spec/javascripts/boards/issue_card_spec.js | 110 ++++- spec/javascripts/boards/issue_spec.js | 76 +++- spec/javascripts/boards/list_spec.js | 19 +- spec/javascripts/boards/mock_data.js | 3 +- spec/javascripts/boards/modal_store_spec.js | 12 +- .../javascripts/issuable_time_tracker_spec.js | 250 ++++++------ spec/javascripts/subbable_resource_spec.js | 63 --- .../lib/banzai/filter/redactor_filter_spec.rb | 2 +- .../github_import/issue_formatter_spec.rb | 10 +- .../google_code_import/importer_spec.rb | 2 +- spec/lib/gitlab/import_export/all_models.yml | 3 +- .../import_export/project_tree_saver_spec.rb | 2 +- .../lib/gitlab/project_search_results_spec.rb | 2 +- spec/lib/gitlab/search_results_spec.rb | 4 +- spec/mailers/notify_spec.rb | 10 +- spec/models/concerns/issuable_spec.rb | 109 +---- spec/models/concerns/milestoneish_spec.rb | 6 +- spec/models/event_spec.rb | 4 +- spec/models/issue_collection_spec.rb | 2 +- spec/models/issue_spec.rb | 91 ++++- spec/models/merge_request_spec.rb | 91 ++++- spec/requests/api/issues_spec.rb | 76 +++- spec/requests/api/v3/issues_spec.rb | 72 +++- .../issuable/bulk_update_service_spec.rb | 62 ++- spec/services/issues/close_service_spec.rb | 2 +- spec/services/issues/create_service_spec.rb | 86 +++- spec/services/issues/update_service_spec.rb | 59 ++- .../assign_issues_service_spec.rb | 10 +- .../merge_requests/create_service_spec.rb | 82 +++- .../merge_requests/update_service_spec.rb | 48 +++ .../notes/slash_commands_service_spec.rb | 4 +- spec/services/notification_service_spec.rb | 80 ++-- .../projects/autocomplete_service_spec.rb | 2 +- .../slash_commands/interpret_service_spec.rb | 70 +++- spec/services/system_note_service_spec.rb | 45 +++ spec/services/todo_service_spec.rb | 26 +- ...issuable_slash_commands_shared_examples.rb | 4 +- .../import_export/export_file_helper.rb | 2 +- ...issuable_create_service_shared_examples.rb | 52 --- ..._service_slash_commands_shared_examples.rb | 18 +- ...issuable_update_service_shared_examples.rb | 48 --- spec/support/time_tracking_shared_examples.rb | 10 +- 195 files changed, 3966 insertions(+), 1224 deletions(-) rename app/assets/javascripts/boards/models/{user.js => assignee.js} (61%) delete mode 100644 app/assets/javascripts/issuable/issuable_bundle.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js create mode 100644 app/assets/javascripts/sidebar/components/assignees/assignee_title.js create mode 100644 app/assets/javascripts/sidebar/components/assignees/assignees.js create mode 100644 app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js create mode 100644 app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js create mode 100644 app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js create mode 100644 app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js create mode 100644 app/assets/javascripts/sidebar/components/time_tracking/help_state.js create mode 100644 app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js create mode 100644 app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js create mode 100644 app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js create mode 100644 app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js create mode 100644 app/assets/javascripts/sidebar/event_hub.js create mode 100644 app/assets/javascripts/sidebar/services/sidebar_service.js create mode 100644 app/assets/javascripts/sidebar/sidebar_bundle.js create mode 100644 app/assets/javascripts/sidebar/sidebar_mediator.js create mode 100644 app/assets/javascripts/sidebar/stores/sidebar_store.js delete mode 100644 app/assets/javascripts/subbable_resource.js create mode 100644 app/models/issue_assignee.rb delete mode 100644 app/views/notify/_reassigned_issuable_email.text.erb create mode 100644 app/views/shared/issuable/_assignees.html.haml create mode 100644 app/views/shared/issuable/form/_issue_assignee.html.haml create mode 100644 app/views/shared/issuable/form/_merge_request_assignee.html.haml create mode 100644 changelogs/unreleased/update-issue-board-cards-design.yml create mode 100644 db/migrate/20170320171632_create_issue_assignees_table.rb create mode 100644 db/migrate/20170320173259_migrate_assignees.rb create mode 100644 lib/api/helpers/common_helpers.rb delete mode 100644 spec/javascripts/subbable_resource_spec.js delete mode 100644 spec/support/services/issuable_create_service_shared_examples.rb diff --git a/app/assets/javascripts/blob/target_branch_dropdown.js b/app/assets/javascripts/blob/target_branch_dropdown.js index 216f069ef71cd..d52d69b127488 100644 --- a/app/assets/javascripts/blob/target_branch_dropdown.js +++ b/app/assets/javascripts/blob/target_branch_dropdown.js @@ -37,8 +37,8 @@ class TargetBranchDropDown { } return SELECT_ITEM_MSG; }, - clicked(item, el, e) { - e.preventDefault(); + clicked(options) { + options.e.preventDefault(); self.onClick.call(self); }, fieldName: self.fieldName, diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js index d7c1c32efbd40..888883163c5b2 100644 --- a/app/assets/javascripts/blob/template_selector.js +++ b/app/assets/javascripts/blob/template_selector.js @@ -24,7 +24,7 @@ export default class TemplateSelector { search: { fields: ['name'], }, - clicked: (item, el, e) => this.fetchFileTemplate(item, el, e), + clicked: options => this.fetchFileTemplate(options), text: item => item.name, }); } @@ -51,7 +51,10 @@ export default class TemplateSelector { return this.$dropdownContainer.removeClass('hidden'); } - fetchFileTemplate(item, el, e) { + fetchFileTemplate(options) { + const { e } = options; + const item = options.selectedObj; + e.preventDefault(); return this.requestFile(item); } diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js index b6dee8177d276..dbbfb6de3ea33 100644 --- a/app/assets/javascripts/boards/boards_bundle.js +++ b/app/assets/javascripts/boards/boards_bundle.js @@ -11,7 +11,7 @@ require('./models/issue'); require('./models/label'); require('./models/list'); require('./models/milestone'); -require('./models/user'); +require('./models/assignee'); require('./stores/boards_store'); require('./stores/modal_store'); require('./services/board_service'); diff --git a/app/assets/javascripts/boards/components/board_new_issue.js b/app/assets/javascripts/boards/components/board_new_issue.js index 0fa85b6fe1406..1ce95b62138d9 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.js +++ b/app/assets/javascripts/boards/components/board_new_issue.js @@ -26,6 +26,7 @@ export default { title: this.title, labels, subscribed: true, + assignees: [], }); this.list.newIssue(issue) diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index f0066d4ec5d72..317cef9f22750 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -3,8 +3,13 @@ /* global MilestoneSelect */ /* global LabelsSelect */ /* global Sidebar */ +/* global Flash */ import Vue from 'vue'; +import eventHub from '../../sidebar/event_hub'; + +import AssigneeTitle from '../../sidebar/components/assignees/assignee_title'; +import Assignees from '../../sidebar/components/assignees/assignees'; require('./sidebar/remove_issue'); @@ -22,6 +27,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({ detail: Store.detail, issue: {}, list: {}, + loadingAssignees: false, }; }, computed: { @@ -43,6 +49,10 @@ gl.issueBoards.BoardSidebar = Vue.extend({ this.issue = this.detail.issue; this.list = this.detail.list; + + this.$nextTick(() => { + this.endpoint = this.$refs.assigneeDropdown.dataset.issueUpdate; + }); }, deep: true }, @@ -53,12 +63,57 @@ gl.issueBoards.BoardSidebar = Vue.extend({ $('.right-sidebar').getNiceScroll().resize(); }); } - } + + this.issue = this.detail.issue; + this.list = this.detail.list; + }, + deep: true }, methods: { closeSidebar () { this.detail.issue = {}; - } + }, + assignSelf () { + // Notify gl dropdown that we are now assigning to current user + this.$refs.assigneeBlock.dispatchEvent(new Event('assignYourself')); + + this.addAssignee(this.currentUser); + this.saveAssignees(); + }, + removeAssignee (a) { + gl.issueBoards.BoardsStore.detail.issue.removeAssignee(a); + }, + addAssignee (a) { + gl.issueBoards.BoardsStore.detail.issue.addAssignee(a); + }, + removeAllAssignees () { + gl.issueBoards.BoardsStore.detail.issue.removeAllAssignees(); + }, + saveAssignees () { + this.loadingAssignees = true; + + gl.issueBoards.BoardsStore.detail.issue.update(this.endpoint) + .then(() => { + this.loadingAssignees = false; + }) + .catch(() => { + this.loadingAssignees = false; + return new Flash('An error occurred while saving assignees'); + }); + }, + }, + created () { + // Get events from glDropdown + eventHub.$on('sidebar.removeAssignee', this.removeAssignee); + eventHub.$on('sidebar.addAssignee', this.addAssignee); + eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$on('sidebar.saveAssignees', this.saveAssignees); + }, + beforeDestroy() { + eventHub.$off('sidebar.removeAssignee', this.removeAssignee); + eventHub.$off('sidebar.addAssignee', this.addAssignee); + eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$off('sidebar.saveAssignees', this.saveAssignees); }, mounted () { new IssuableContext(this.currentUser); @@ -70,5 +125,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({ }, components: { removeBtn: gl.issueBoards.RemoveIssueBtn, + 'assignee-title': AssigneeTitle, + assignees: Assignees, }, }); diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js index fc154ee7b8b03..2f06d186c508b 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.js +++ b/app/assets/javascripts/boards/components/issue_card_inner.js @@ -31,18 +31,36 @@ gl.issueBoards.IssueCardInner = Vue.extend({ default: false, }, }, + data() { + return { + limitBeforeCounter: 3, + maxRender: 4, + maxCounter: 99, + }; + }, computed: { - cardUrl() { - return `${this.issueLinkBase}/${this.issue.id}`; + numberOverLimit() { + return this.issue.assignees.length - this.limitBeforeCounter; }, - assigneeUrl() { - return `${this.rootPath}${this.issue.assignee.username}`; + assigneeCounterTooltip() { + return `${this.assigneeCounterLabel} more`; + }, + assigneeCounterLabel() { + if (this.numberOverLimit > this.maxCounter) { + return `${this.maxCounter}+`; + } + + return `+${this.numberOverLimit}`; }, - assigneeUrlTitle() { - return `Assigned to ${this.issue.assignee.name}`; + shouldRenderCounter() { + if (this.issue.assignees.length <= this.maxRender) { + return false; + } + + return this.issue.assignees.length > this.numberOverLimit; }, - avatarUrlTitle() { - return `Avatar for ${this.issue.assignee.name}`; + cardUrl() { + return `${this.issueLinkBase}/${this.issue.id}`; }, issueId() { return `#${this.issue.id}`; @@ -52,6 +70,28 @@ gl.issueBoards.IssueCardInner = Vue.extend({ }, }, methods: { + isIndexLessThanlimit(index) { + return index < this.limitBeforeCounter; + }, + shouldRenderAssignee(index) { + // Eg. maxRender is 4, + // Render up to all 4 assignees if there are only 4 assigness + // Otherwise render up to the limitBeforeCounter + if (this.issue.assignees.length <= this.maxRender) { + return index < this.maxRender; + } + + return index < this.limitBeforeCounter; + }, + assigneeUrl(assignee) { + return `${this.rootPath}${assignee.username}`; + }, + assigneeUrlTitle(assignee) { + return `Assigned to ${assignee.name}`; + }, + avatarUrlTitle(assignee) { + return `Avatar for ${assignee.name}`; + }, showLabel(label) { if (!this.list) return true; @@ -105,25 +145,39 @@ gl.issueBoards.IssueCardInner = Vue.extend({ {{ issueId }} </span> </h4> - <a - class="card-assignee has-tooltip js-no-trigger" - :href="assigneeUrl" - :title="assigneeUrlTitle" - v-if="issue.assignee" - data-container="body" - > - <img - class="avatar avatar-inline s20 js-no-trigger" - :src="issue.assignee.avatar" - width="20" - height="20" - :alt="avatarUrlTitle" - /> - </a> + <div class="card-assignee"> + <a + class="has-tooltip js-no-trigger" + :href="assigneeUrl(assignee)" + :title="assigneeUrlTitle(assignee)" + v-for="(assignee, index) in issue.assignees" + v-if="shouldRenderAssignee(index)" + data-container="body" + data-placement="bottom" + > + <img + class="avatar avatar-inline s20" + :src="assignee.avatarUrl" + width="20" + height="20" + :alt="avatarUrlTitle(assignee)" + /> + </a> + <span + class="avatar-counter has-tooltip" + :title="assigneeCounterTooltip" + v-if="shouldRenderCounter" + > + {{ assigneeCounterLabel }} + </span> + </div> </div> - <div class="card-footer" v-if="showLabelFooter"> + <div + class="card-footer" + v-if="showLabelFooter" + > <button - class="label color-label has-tooltip js-no-trigger" + class="label color-label has-tooltip" v-for="label in issue.labels" type="button" v-if="showLabel(label)" diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index 7e3bb79af1d4a..f29b6caa1acec 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -52,7 +52,9 @@ gl.issueBoards.newListDropdownInit = () => { filterable: true, selectable: true, multiSelect: true, - clicked (label, $el, e) { + clicked (options) { + const { e } = options; + const label = options.selectedObj; e.preventDefault(); if (!Store.findList('title', label.title)) { diff --git a/app/assets/javascripts/boards/models/user.js b/app/assets/javascripts/boards/models/assignee.js similarity index 61% rename from app/assets/javascripts/boards/models/user.js rename to app/assets/javascripts/boards/models/assignee.js index 8e9de4d4cbb69..ee0f4e608c943 100644 --- a/app/assets/javascripts/boards/models/user.js +++ b/app/assets/javascripts/boards/models/assignee.js @@ -1,12 +1,12 @@ /* eslint-disable no-unused-vars */ -class ListUser { +class ListAssignee { constructor(user) { this.id = user.id; this.name = user.name; this.username = user.username; - this.avatar = user.avatar_url; + this.avatarUrl = user.avatar_url; } } -window.ListUser = ListUser; +window.ListAssignee = ListAssignee; diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index d6175069e370e..76ebd4c9eab2d 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -1,7 +1,7 @@ /* eslint-disable no-unused-vars, space-before-function-paren, arrow-body-style, arrow-parens, comma-dangle, max-len */ /* global ListLabel */ /* global ListMilestone */ -/* global ListUser */ +/* global ListAssignee */ import Vue from 'vue'; @@ -14,14 +14,10 @@ class ListIssue { this.dueDate = obj.due_date; this.subscribed = obj.subscribed; this.labels = []; + this.assignees = []; this.selected = false; - this.assignee = false; this.position = obj.relative_position || Infinity; - if (obj.assignee) { - this.assignee = new ListUser(obj.assignee); - } - if (obj.milestone) { this.milestone = new ListMilestone(obj.milestone); } @@ -29,6 +25,8 @@ class ListIssue { obj.labels.forEach((label) => { this.labels.push(new ListLabel(label)); }); + + this.assignees = obj.assignees.map(a => new ListAssignee(a)); } addLabel (label) { @@ -51,6 +49,26 @@ class ListIssue { labels.forEach(this.removeLabel.bind(this)); } + addAssignee (assignee) { + if (!this.findAssignee(assignee)) { + this.assignees.push(new ListAssignee(assignee)); + } + } + + findAssignee (findAssignee) { + return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0]; + } + + removeAssignee (removeAssignee) { + if (removeAssignee) { + this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id); + } + } + + removeAllAssignees () { + this.assignees = []; + } + getLists () { return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id)); } @@ -60,7 +78,7 @@ class ListIssue { issue: { milestone_id: this.milestone ? this.milestone.id : null, due_date: this.dueDate, - assignee_id: this.assignee ? this.assignee.id : null, + assignee_ids: this.assignees.length > 0 ? this.assignees.map((u) => u.id) : [0], label_ids: this.labels.map((label) => label.id) } }; diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index a03f1202a6d4c..0c9eb84f0ebe6 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -255,7 +255,8 @@ GitLabDropdown = (function() { } }; // Remote data - })(this) + })(this), + instance: this, }); } } @@ -269,6 +270,7 @@ GitLabDropdown = (function() { remote: this.options.filterRemote, query: this.options.data, keys: searchFields, + instance: this, elements: (function(_this) { return function() { selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')'; @@ -343,21 +345,26 @@ GitLabDropdown = (function() { } this.dropdown.on("click", selector, function(e) { var $el, selected, selectedObj, isMarking; - $el = $(this); + $el = $(e.currentTarget); selected = self.rowClicked($el); selectedObj = selected ? selected[0] : null; isMarking = selected ? selected[1] : null; - if (self.options.clicked) { - self.options.clicked(selectedObj, $el, e, isMarking); + if (this.options.clicked) { + this.options.clicked.call(this, { + selectedObj, + $el, + e, + isMarking, + }); } // Update label right after all modifications in dropdown has been done - if (self.options.toggleLabel) { - self.updateLabel(selectedObj, $el, self); + if (this.options.toggleLabel) { + this.updateLabel(selectedObj, $el, this); } $el.trigger('blur'); - }); + }.bind(this)); } } @@ -439,15 +446,34 @@ GitLabDropdown = (function() { } }; + GitLabDropdown.prototype.filteredFullData = function() { + return this.fullData.filter(r => typeof r === 'object' + && !Object.prototype.hasOwnProperty.call(r, 'beforeDivider') + && !Object.prototype.hasOwnProperty.call(r, 'header') + ); + }; + GitLabDropdown.prototype.opened = function(e) { var contentHtml; this.resetRows(); this.addArrowKeyEvent(); + const dropdownToggle = this.dropdown.find('.dropdown-menu-toggle'); + const hasFilterBulkUpdate = dropdownToggle.hasClass('js-filter-bulk-update'); + const hasMultiSelect = dropdownToggle.hasClass('js-multiselect'); + // Makes indeterminate items effective - if (this.fullData && this.dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update')) { + if (this.fullData && hasFilterBulkUpdate) { this.parseData(this.fullData); } + + // Process the data to make sure rendered data + // matches the correct layout + if (this.fullData && hasMultiSelect && this.options.processData) { + const inputValue = this.filterInput.val(); + this.options.processData.call(this.options, inputValue, this.filteredFullData(), this.parseData.bind(this)); + } + contentHtml = $('.dropdown-content', this.dropdown).html(); if (this.remote && contentHtml === "") { this.remote.execute(); @@ -709,6 +735,11 @@ GitLabDropdown = (function() { if (this.options.inputId != null) { $input.attr('id', this.options.inputId); } + + if (this.options.inputMeta) { + $input.attr('data-meta', selectedObject[this.options.inputMeta]); + } + return this.dropdown.before($input); }; @@ -829,7 +860,14 @@ GitLabDropdown = (function() { if (instance == null) { instance = null; } - return $(this.el).find(".dropdown-toggle-text").text(this.options.toggleLabel(selected, el, instance)); + + let toggleText = this.options.toggleLabel(selected, el, instance); + if (this.options.updateLabel) { + // Option to override the dropdown label text + toggleText = this.options.updateLabel; + } + + return $(this.el).find(".dropdown-toggle-text").text(toggleText); }; GitLabDropdown.prototype.clearField = function(field, isInput) { diff --git a/app/assets/javascripts/issuable/issuable_bundle.js b/app/assets/javascripts/issuable/issuable_bundle.js deleted file mode 100644 index e927cc0077c5b..0000000000000 --- a/app/assets/javascripts/issuable/issuable_bundle.js +++ /dev/null @@ -1 +0,0 @@ -require('./time_tracking/time_tracking_bundle'); diff --git a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js b/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js deleted file mode 100644 index b081adf5e643b..0000000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js +++ /dev/null @@ -1,12 +0,0 @@ -import Vue from 'vue'; - -(() => { - Vue.component('time-tracking-no-tracking-pane', { - name: 'time-tracking-no-tracking-pane', - template: ` - <div class='time-tracking-no-tracking-pane'> - <span class='no-value'>No estimate or time spent</span> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js b/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js deleted file mode 100644 index 1689a69e1ed94..0000000000000 --- a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js +++ /dev/null @@ -1,66 +0,0 @@ -import Vue from 'vue'; -import VueResource from 'vue-resource'; - -require('./components/time_tracker'); -require('../../smart_interval'); -require('../../subbable_resource'); - -Vue.use(VueResource); - -(() => { - /* This Vue instance represents what will become the parent instance for the - * sidebar. It will be responsible for managing `issuable` state and propagating - * changes to sidebar components. We will want to create a separate service to - * interface with the server at that point. - */ - - class IssuableTimeTracking { - constructor(issuableJSON) { - const parsedIssuable = JSON.parse(issuableJSON); - return this.initComponent(parsedIssuable); - } - - initComponent(parsedIssuable) { - this.parentInstance = new Vue({ - el: '#issuable-time-tracker', - data: { - issuable: parsedIssuable, - }, - methods: { - fetchIssuable() { - return gl.IssuableResource.get.call(gl.IssuableResource, { - type: 'GET', - url: gl.IssuableResource.endpoint, - }); - }, - updateState(data) { - this.issuable = data; - }, - subscribeToUpdates() { - gl.IssuableResource.subscribe(data => this.updateState(data)); - }, - listenForSlashCommands() { - $(document).on('ajax:success', '.gfm-form', (e, data) => { - const subscribedCommands = ['spend_time', 'time_estimate']; - const changedCommands = data.commands_changes - ? Object.keys(data.commands_changes) - : []; - if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) { - this.fetchIssuable(); - } - }); - }, - }, - created() { - this.fetchIssuable(); - }, - mounted() { - this.subscribeToUpdates(); - this.listenForSlashCommands(); - }, - }); - } - } - - gl.IssuableTimeTracking = IssuableTimeTracking; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/issue_status_select.js b/app/assets/javascripts/issue_status_select.js index b2cfd3ef2a3cb..56cb536dcde7d 100644 --- a/app/assets/javascripts/issue_status_select.js +++ b/app/assets/javascripts/issue_status_select.js @@ -19,8 +19,8 @@ return label; }; })(this), - clicked: function(item, $el, e) { - return e.preventDefault(); + clicked: function(options) { + return options.e.preventDefault(); }, id: function(obj, el) { return $(el).data("id"); diff --git a/app/assets/javascripts/issues_bulk_assignment.js b/app/assets/javascripts/issues_bulk_assignment.js index e0ebd36a65ce8..fee3429e2b846 100644 --- a/app/assets/javascripts/issues_bulk_assignment.js +++ b/app/assets/javascripts/issues_bulk_assignment.js @@ -88,7 +88,10 @@ const formData = { update: { state_event: this.form.find('input[name="update[state_event]"]').val(), + // For Merge Requests assignee_id: this.form.find('input[name="update[assignee_id]"]').val(), + // For Issues + assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()], milestone_id: this.form.find('input[name="update[milestone_id]"]').val(), issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(), subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 9a60f5464df95..ac5ce84e31b1f 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -330,7 +330,10 @@ }, multiSelect: $dropdown.hasClass('js-multiselect'), vue: $dropdown.hasClass('js-issue-board-sidebar'), - clicked: function(label, $el, e, isMarking) { + clicked: function(options) { + const { $el, e, isMarking } = options; + const label = options.selectedObj; + var isIssueIndex, isMRIndex, page, boardsModel; var fadeOutLoader = () => { $loading.fadeOut(); @@ -352,7 +355,7 @@ if ($dropdown.hasClass('js-filter-bulk-update')) { _this.enableBulkLabelDropdown(); - _this.setDropdownData($dropdown, isMarking, this.id(label)); + _this.setDropdownData($dropdown, isMarking, label.id); return; } diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index be3c2c9fbb102..1b0d5fc92e38b 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -158,7 +158,6 @@ import './single_file_diff'; import './smart_interval'; import './snippets_list'; import './star'; -import './subbable_resource'; import './subscription'; import './subscription_select'; import './syntax_highlight'; diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js index e3f367a11eb94..bfc4e55116834 100644 --- a/app/assets/javascripts/members.js +++ b/app/assets/javascripts/members.js @@ -31,8 +31,27 @@ toggleLabel(selected, $el) { return $el.text(); }, +<<<<<<< HEAD clicked: (selected, $link) => { this.formSubmit(null, $link); +======= + clicked: (options) => { + const $link = options.$el; + + if (!$link.data('revert')) { + this.formSubmit(null, $link); + } else { + const { $memberListItem, $toggle, $dateInput } = this.getMemberListItems($link); + + $toggle.disable(); + $dateInput.disable(); + + this.overrideLdap($memberListItem, $link.data('endpoint'), false).fail(() => { + $toggle.enable(); + $dateInput.enable(); + }); + } +>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master }, }); }); diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index bebd0aa357e0e..0ca7aedb5bd4b 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -121,7 +121,30 @@ return $value.css('display', ''); }, vue: $dropdown.hasClass('js-issue-board-sidebar'), +<<<<<<< HEAD clicked: function(selected, $el, e) { +======= + hideRow: function(milestone) { + if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar') && + !$dropdown.closest('.add-issues-modal').length && gl.issueBoards.BoardsStore.state.currentBoard.milestone) { + return milestone !== gl.issueBoards.BoardsStore.state.currentBoard.milestone.title; + } + + return false; + }, + isSelectable: function() { + if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar') && + !$dropdown.closest('.add-issues-modal').length && gl.issueBoards.BoardsStore.state.currentBoard.milestone_id) { + return false; + } + + return true; + }, + clicked: function(options) { + const { $el, e } = options; + let selected = options.selectedObj; + +>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master var data, isIssueIndex, isMRIndex, page, boardsStore; page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js index b98e6121967a7..36bc1257cefd2 100644 --- a/app/assets/javascripts/namespace_select.js +++ b/app/assets/javascripts/namespace_select.js @@ -58,7 +58,8 @@ }); } - NamespaceSelect.prototype.onSelectItem = function(item, el, e) { + NamespaceSelect.prototype.onSelectItem = function(options) { + const { e } = options; return e.preventDefault(); }; diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js index f944fcc5a58c4..738e710deb9ab 100644 --- a/app/assets/javascripts/project.js +++ b/app/assets/javascripts/project.js @@ -112,7 +112,8 @@ import Cookies from 'js-cookie'; toggleLabel: function(obj, $el) { return $el.text().trim(); }, - clicked: function(selected, $el, e) { + clicked: function(options) { + const { e } = options; e.preventDefault(); if ($('input[name="ref"]').length) { var $form = $dropdown.closest('form'); diff --git a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js index e7fff57ff452c..1266d70f073aa 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js +++ b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js @@ -19,7 +19,10 @@ return 'Select'; } }, - clicked(item, $el, e) { + clicked(opts) { + const { $el, e } = opts; + const item = opts.selectedObj; + e.preventDefault(); onSelect(); } diff --git a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_dropdown.js index 1d4bb8a13d677..bc6110fcd4e4f 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js +++ b/app/assets/javascripts/protected_branches/protected_branch_dropdown.js @@ -35,7 +35,8 @@ class ProtectedBranchDropdown { return _.escape(protectedBranch.id); }, onFilter: this.toggleCreateNewButton.bind(this), - clicked: (item, $el, e) => { + clicked: (options) => { + const { $el, e } = options; e.preventDefault(); this.onSelect(); } diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.js b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js new file mode 100644 index 0000000000000..a9ad3708514b7 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js @@ -0,0 +1,41 @@ +export default { + name: 'AssigneeTitle', + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + numberOfAssignees: { + type: Number, + required: true, + }, + editable: { + type: Boolean, + required: true, + }, + }, + computed: { + assigneeTitle() { + const assignees = this.numberOfAssignees; + return assignees > 1 ? `${assignees} Assignees` : 'Assignee'; + }, + }, + template: ` + <div class="title hide-collapsed"> + {{assigneeTitle}} + <i + v-if="loading" + aria-hidden="true" + class="fa fa-spinner fa-spin block-loading" + /> + <a + v-if="editable" + class="edit-link pull-right" + href="#" + > + Edit + </a> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.js b/app/assets/javascripts/sidebar/components/assignees/assignees.js new file mode 100644 index 0000000000000..88d7650f40ac8 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.js @@ -0,0 +1,224 @@ +export default { + name: 'Assignees', + data() { + return { + defaultRenderCount: 5, + defaultMaxCounter: 99, + showLess: true, + }; + }, + props: { + rootPath: { + type: String, + required: true, + }, + users: { + type: Array, + required: true, + }, + editable: { + type: Boolean, + required: true, + }, + }, + computed: { + firstUser() { + return this.users[0]; + }, + hasMoreThanTwoAssignees() { + return this.users.length > 2; + }, + hasMoreThanOneAssignee() { + return this.users.length > 1; + }, + hasAssignees() { + return this.users.length > 0; + }, + hasNoUsers() { + return !this.users.length; + }, + hasOneUser() { + return this.users.length === 1; + }, + renderShowMoreSection() { + return this.users.length > this.defaultRenderCount; + }, + numberOfHiddenAssignees() { + return this.users.length - this.defaultRenderCount; + }, + isHiddenAssignees() { + return this.numberOfHiddenAssignees > 0; + }, + hiddenAssigneesLabel() { + return `+ ${this.numberOfHiddenAssignees} more`; + }, + collapsedTooltipTitle() { + const maxRender = Math.min(this.defaultRenderCount, this.users.length); + const renderUsers = this.users.slice(0, maxRender); + const names = renderUsers.map(u => u.name); + + if (this.users.length > maxRender) { + names.push(`+ ${this.users.length - maxRender} more`); + } + + return names.join(', '); + }, + sidebarAvatarCounter() { + let counter = `+${this.users.length - 1}`; + + if (this.users.length > this.defaultMaxCounter) { + counter = `${this.defaultMaxCounter}+`; + } + + return counter; + }, + }, + methods: { + assignSelf() { + this.$emit('assign-self'); + }, + toggleShowLess() { + this.showLess = !this.showLess; + }, + renderAssignee(index) { + return !this.showLess || (index < this.defaultRenderCount && this.showLess); + }, + avatarUrl(user) { + return user.avatarUrl || user.avatar_url; + }, + assigneeUrl(user) { + return `${this.rootPath}${user.username}`; + }, + assigneeAlt(user) { + return `${user.name}'s avatar`; + }, + assigneeUsername(user) { + return `@${user.username}`; + }, + shouldRenderCollapsedAssignee(index) { + const firstTwo = this.users.length <= 2 && index <= 2; + + return index === 0 || firstTwo; + }, + }, + template: ` + <div> + <div + class="sidebar-collapsed-icon sidebar-collapsed-user" + :class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }" + data-container="body" + data-placement="left" + :title="collapsedTooltipTitle" + > + <i + v-if="hasNoUsers" + aria-label="No Assignee" + class="fa fa-user" + /> + <button + type="button" + class="btn-link" + v-for="(user, index) in users" + v-if="shouldRenderCollapsedAssignee(index)" + > + <img + width="24" + class="avatar avatar-inline s24" + :alt="assigneeAlt(user)" + :src="avatarUrl(user)" + /> + <span class="author"> + {{ user.name }} + </span> + </button> + <button + v-if="hasMoreThanTwoAssignees" + class="btn-link" + type="button" + > + <span + class="avatar-counter sidebar-avatar-counter" + > + {{ sidebarAvatarCounter }} + </span> + </button> + </div> + <div class="value hide-collapsed"> + <template v-if="hasNoUsers"> + <span class="assign-yourself no-value"> + No assignee + <template v-if="editable"> + - + <button + type="button" + class="btn-link" + @click="assignSelf" + > + assign yourself + </button> + </template> + </span> + </template> + <template v-else-if="hasOneUser"> + <a + class="author_link bold" + :href="assigneeUrl(firstUser)" + > + <img + width="32" + class="avatar avatar-inline s32" + :alt="assigneeAlt(firstUser)" + :src="avatarUrl(firstUser)" + /> + <span class="author"> + {{ firstUser.name }} + </span> + <span class="username"> + {{ assigneeUsername(firstUser) }} + </span> + </a> + </template> + <template v-else> + <div class="user-list"> + <div + class="user-item" + v-for="(user, index) in users" + v-if="renderAssignee(index)" + > + <a + class="user-link has-tooltip" + data-placement="bottom" + :href="assigneeUrl(user)" + :data-title="user.name" + > + <img + width="32" + class="avatar avatar-inline s32" + :alt="assigneeAlt(user)" + :src="avatarUrl(user)" + /> + </a> + </div> + </div> + <div + v-if="renderShowMoreSection" + class="user-list-more" + > + <button + type="button" + class="btn-link" + @click="toggleShowLess" + > + <template v-if="showLess"> + {{ hiddenAssigneesLabel }} + </template> + <template v-else> + - show less + </template> + </button> + </div> + </template> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js new file mode 100644 index 0000000000000..1488a66c695ad --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js @@ -0,0 +1,84 @@ +/* global Flash */ + +import AssigneeTitle from './assignee_title'; +import Assignees from './assignees'; + +import Store from '../../stores/sidebar_store'; +import Mediator from '../../sidebar_mediator'; + +import eventHub from '../../event_hub'; + +export default { + name: 'SidebarAssignees', + data() { + return { + mediator: new Mediator(), + store: new Store(), + loading: false, + field: '', + }; + }, + components: { + 'assignee-title': AssigneeTitle, + assignees: Assignees, + }, + methods: { + assignSelf() { + // Notify gl dropdown that we are now assigning to current user + this.$el.parentElement.dispatchEvent(new Event('assignYourself')); + + this.mediator.assignYourself(); + this.saveAssignees(); + }, + saveAssignees() { + this.loading = true; + + function setLoadingFalse() { + this.loading = false; + } + + this.mediator.saveAssignees(this.field) + .then(setLoadingFalse.bind(this)) + .catch(() => { + setLoadingFalse(); + return new Flash('Error occurred when saving assignees'); + }); + }, + }, + created() { + this.removeAssignee = this.store.removeAssignee.bind(this.store); + this.addAssignee = this.store.addAssignee.bind(this.store); + this.removeAllAssignees = this.store.removeAllAssignees.bind(this.store); + + // Get events from glDropdown + eventHub.$on('sidebar.removeAssignee', this.removeAssignee); + eventHub.$on('sidebar.addAssignee', this.addAssignee); + eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$on('sidebar.saveAssignees', this.saveAssignees); + }, + beforeDestroy() { + eventHub.$off('sidebar.removeAssignee', this.removeAssignee); + eventHub.$off('sidebar.addAssignee', this.addAssignee); + eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$off('sidebar.saveAssignees', this.saveAssignees); + }, + beforeMount() { + this.field = this.$el.dataset.field; + }, + template: ` + <div> + <assignee-title + :number-of-assignees="store.assignees.length" + :loading="loading" + :editable="store.editable" + /> + <assignees + class="value" + :root-path="store.rootPath" + :users="store.assignees" + :editable="store.editable" + @assign-self="assignSelf" + /> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js new file mode 100644 index 0000000000000..0da265053bd0e --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js @@ -0,0 +1,97 @@ +import stopwatchSvg from 'icons/_icon_stopwatch.svg'; + +import '../../../lib/utils/pretty_time'; + +export default { + name: 'time-tracking-collapsed-state', + props: { + showComparisonState: { + type: Boolean, + required: true, + }, + showSpentOnlyState: { + type: Boolean, + required: true, + }, + showEstimateOnlyState: { + type: Boolean, + required: true, + }, + showNoTimeTrackingState: { + type: Boolean, + required: true, + }, + timeSpentHumanReadable: { + type: String, + required: false, + default: '', + }, + timeEstimateHumanReadable: { + type: String, + required: false, + default: '', + }, + }, + computed: { + timeSpent() { + return this.abbreviateTime(this.timeSpentHumanReadable); + }, + timeEstimate() { + return this.abbreviateTime(this.timeEstimateHumanReadable); + }, + divClass() { + if (this.showComparisonState) { + return 'compare'; + } else if (this.showEstimateOnlyState) { + return 'estimate-only'; + } else if (this.showSpentOnlyState) { + return 'spend-only'; + } else if (this.showNoTimeTrackingState) { + return 'no-tracking'; + } + + return ''; + }, + spanClass() { + if (this.showComparisonState) { + return ''; + } else if (this.showEstimateOnlyState || this.showSpentOnlyState) { + return 'bold'; + } else if (this.showNoTimeTrackingState) { + return 'no-value'; + } + + return ''; + }, + text() { + if (this.showComparisonState) { + return `${this.timeSpent} / ${this.timeEstimate}`; + } else if (this.showEstimateOnlyState) { + return `-- / ${this.timeEstimate}`; + } else if (this.showSpentOnlyState) { + return `${this.timeSpent} / --`; + } else if (this.showNoTimeTrackingState) { + return 'None'; + } + + return ''; + }, + }, + methods: { + abbreviateTime(timeStr) { + return gl.utils.prettyTime.abbreviateTime(timeStr); + }, + }, + template: ` + <div class="sidebar-collapsed-icon"> + ${stopwatchSvg} + <div class="time-tracking-collapsed-summary"> + <div :class="divClass"> + <span :class="spanClass"> + {{ text }} + </span> + </div> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js new file mode 100644 index 0000000000000..40f5c89c5bbcf --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js @@ -0,0 +1,98 @@ +import '../../../lib/utils/pretty_time'; + +const prettyTime = gl.utils.prettyTime; + +export default { + name: 'time-tracking-comparison-pane', + props: { + timeSpent: { + type: Number, + required: true, + }, + timeEstimate: { + type: Number, + required: true, + }, + timeSpentHumanReadable: { + type: String, + required: true, + }, + timeEstimateHumanReadable: { + type: String, + required: true, + }, + }, + computed: { + parsedRemaining() { + const diffSeconds = this.timeEstimate - this.timeSpent; + return prettyTime.parseSeconds(diffSeconds); + }, + timeRemainingHumanReadable() { + return prettyTime.stringifyTime(this.parsedRemaining); + }, + timeRemainingTooltip() { + const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:'; + return `${prefix} ${this.timeRemainingHumanReadable}`; + }, + /* Diff values for comparison meter */ + timeRemainingMinutes() { + return this.timeEstimate - this.timeSpent; + }, + timeRemainingPercent() { + return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`; + }, + timeRemainingStatusClass() { + return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate'; + }, + /* Parsed time values */ + parsedEstimate() { + return prettyTime.parseSeconds(this.timeEstimate); + }, + parsedSpent() { + return prettyTime.parseSeconds(this.timeSpent); + }, + }, + template: ` + <div class="time-tracking-comparison-pane"> + <div + class="compare-meter" + data-toggle="tooltip" + data-placement="top" + role="timeRemainingDisplay" + :aria-valuenow="timeRemainingTooltip" + :title="timeRemainingTooltip" + :data-original-title="timeRemainingTooltip" + :class="timeRemainingStatusClass" + > + <div + class="meter-container" + role="timeSpentPercent" + :aria-valuenow="timeRemainingPercent" + > + <div + :style="{ width: timeRemainingPercent }" + class="meter-fill" + /> + </div> + <div class="compare-display-container"> + <div class="compare-display pull-left"> + <span class="compare-label"> + Spent + </span> + <span class="compare-value spent"> + {{ timeSpentHumanReadable }} + </span> + </div> + <div class="compare-display estimated pull-right"> + <span class="compare-label"> + Est + </span> + <span class="compare-value"> + {{ timeEstimateHumanReadable }} + </span> + </div> + </div> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js new file mode 100644 index 0000000000000..ad1b9179db017 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js @@ -0,0 +1,17 @@ +export default { + name: 'time-tracking-estimate-only-pane', + props: { + timeEstimateHumanReadable: { + type: String, + required: true, + }, + }, + template: ` + <div class="time-tracking-estimate-only-pane"> + <span class="bold"> + Estimated: + </span> + {{ timeEstimateHumanReadable }} + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js new file mode 100644 index 0000000000000..b2a77462fe0d4 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js @@ -0,0 +1,44 @@ +export default { + name: 'time-tracking-help-state', + props: { + rootPath: { + type: String, + required: true, + }, + }, + computed: { + href() { + return `${this.rootPath}help/workflow/time_tracking.md`; + }, + }, + template: ` + <div class="time-tracking-help-state"> + <div class="time-tracking-info"> + <h4> + Track time with slash commands + </h4> + <p> + Slash commands can be used in the issues description and comment boxes. + </p> + <p> + <code> + /estimate + </code> + will update the estimated time with the latest command. + </p> + <p> + <code> + /spend + </code> + will update the sum of the time spent. + </p> + <a + class="btn btn-default learn-more-button" + :href="href" + > + Learn more + </a> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js new file mode 100644 index 0000000000000..d1dd1dcdd277d --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js @@ -0,0 +1,10 @@ +export default { + name: 'time-tracking-no-tracking-pane', + template: ` + <div class="time-tracking-no-tracking-pane"> + <span class="no-value"> + No estimate or time spent + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js new file mode 100644 index 0000000000000..e2dba1fb0c2e7 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js @@ -0,0 +1,45 @@ +import '~/smart_interval'; + +import timeTracker from './time_tracker'; + +import Store from '../../stores/sidebar_store'; +import Mediator from '../../sidebar_mediator'; + +export default { + data() { + return { + mediator: new Mediator(), + store: new Store(), + }; + }, + components: { + 'issuable-time-tracker': timeTracker, + }, + methods: { + listenForSlashCommands() { + $(document).on('ajax:success', '.gfm-form', (e, data) => { + const subscribedCommands = ['spend_time', 'time_estimate']; + const changedCommands = data.commands_changes + ? Object.keys(data.commands_changes) + : []; + if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) { + this.mediator.fetch(); + } + }); + }, + }, + mounted() { + this.listenForSlashCommands(); + }, + template: ` + <div class="block"> + <issuable-time-tracker + :time_estimate="store.timeEstimate" + :time_spent="store.totalTimeSpent" + :human_time_estimate="store.humanTimeEstimate" + :human_time_spent="store.humanTotalTimeSpent" + :rootPath="store.rootPath" + /> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js new file mode 100644 index 0000000000000..bf9875626475e --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js @@ -0,0 +1,15 @@ +export default { + name: 'time-tracking-spent-only-pane', + props: { + timeSpentHumanReadable: { + type: String, + required: true, + }, + }, + template: ` + <div class="time-tracking-spend-only-pane"> + <span class="bold">Spent:</span> + {{ timeSpentHumanReadable }} + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js new file mode 100644 index 0000000000000..ed0d71a4f797d --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js @@ -0,0 +1,163 @@ +import timeTrackingHelpState from './help_state'; +import timeTrackingCollapsedState from './collapsed_state'; +import timeTrackingSpentOnlyPane from './spent_only_pane'; +import timeTrackingNoTrackingPane from './no_tracking_pane'; +import timeTrackingEstimateOnlyPane from './estimate_only_pane'; +import timeTrackingComparisonPane from './comparison_pane'; + +import eventHub from '../../event_hub'; + +export default { + name: 'issuable-time-tracker', + props: { + time_estimate: { + type: Number, + required: true, + }, + time_spent: { + type: Number, + required: true, + }, + human_time_estimate: { + type: String, + required: false, + default: '', + }, + human_time_spent: { + type: String, + required: false, + default: '', + }, + rootPath: { + type: String, + required: true, + }, + }, + data() { + return { + showHelp: false, + }; + }, + components: { + 'time-tracking-collapsed-state': timeTrackingCollapsedState, + 'time-tracking-estimate-only-pane': timeTrackingEstimateOnlyPane, + 'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane, + 'time-tracking-no-tracking-pane': timeTrackingNoTrackingPane, + 'time-tracking-comparison-pane': timeTrackingComparisonPane, + 'time-tracking-help-state': timeTrackingHelpState, + }, + computed: { + timeSpent() { + return this.time_spent; + }, + timeEstimate() { + return this.time_estimate; + }, + timeEstimateHumanReadable() { + return this.human_time_estimate; + }, + timeSpentHumanReadable() { + return this.human_time_spent; + }, + hasTimeSpent() { + return !!this.timeSpent; + }, + hasTimeEstimate() { + return !!this.timeEstimate; + }, + showComparisonState() { + return this.hasTimeEstimate && this.hasTimeSpent; + }, + showEstimateOnlyState() { + return this.hasTimeEstimate && !this.hasTimeSpent; + }, + showSpentOnlyState() { + return this.hasTimeSpent && !this.hasTimeEstimate; + }, + showNoTimeTrackingState() { + return !this.hasTimeEstimate && !this.hasTimeSpent; + }, + showHelpState() { + return !!this.showHelp; + }, + }, + methods: { + toggleHelpState(show) { + this.showHelp = show; + }, + update(data) { + this.time_estimate = data.time_estimate; + this.time_spent = data.time_spent; + this.human_time_estimate = data.human_time_estimate; + this.human_time_spent = data.human_time_spent; + }, + }, + created() { + eventHub.$on('timeTracker:updateData', this.update); + }, + template: ` + <div + class="time_tracker time-tracking-component-wrap" + v-cloak + > + <time-tracking-collapsed-state + :show-comparison-state="showComparisonState" + :show-no-time-tracking-state="showNoTimeTrackingState" + :show-help-state="showHelpState" + :show-spent-only-state="showSpentOnlyState" + :show-estimate-only-state="showEstimateOnlyState" + :time-spent-human-readable="timeSpentHumanReadable" + :time-estimate-human-readable="timeEstimateHumanReadable" + /> + <div class="title hide-collapsed"> + Time tracking + <div + class="help-button pull-right" + v-if="!showHelpState" + @click="toggleHelpState(true)" + > + <i + class="fa fa-question-circle" + aria-hidden="true" + /> + </div> + <div + class="close-help-button pull-right" + v-if="showHelpState" + @click="toggleHelpState(false)" + > + <i + class="fa fa-close" + aria-hidden="true" + /> + </div> + </div> + <div class="time-tracking-content hide-collapsed"> + <time-tracking-estimate-only-pane + v-if="showEstimateOnlyState" + :time-estimate-human-readable="timeEstimateHumanReadable" + /> + <time-tracking-spent-only-pane + v-if="showSpentOnlyState" + :time-spent-human-readable="timeSpentHumanReadable" + /> + <time-tracking-no-tracking-pane + v-if="showNoTimeTrackingState" + /> + <time-tracking-comparison-pane + v-if="showComparisonState" + :time-estimate="timeEstimate" + :time-spent="timeSpent" + :time-spent-human-readable="timeSpentHumanReadable" + :time-estimate-human-readable="timeEstimateHumanReadable" + /> + <transition name="help-state-toggle"> + <time-tracking-help-state + v-if="showHelpState" + :rootPath="rootPath" + /> + </transition> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/event_hub.js b/app/assets/javascripts/sidebar/event_hub.js new file mode 100644 index 0000000000000..0948c2e53524a --- /dev/null +++ b/app/assets/javascripts/sidebar/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js new file mode 100644 index 0000000000000..5a82d01dc41e7 --- /dev/null +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -0,0 +1,28 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class SidebarService { + constructor(endpoint) { + if (!SidebarService.singleton) { + this.endpoint = endpoint; + + SidebarService.singleton = this; + } + + return SidebarService.singleton; + } + + get() { + return Vue.http.get(this.endpoint); + } + + update(key, data) { + return Vue.http.put(this.endpoint, { + [key]: data, + }, { + emulateJSON: true, + }); + } +} diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js new file mode 100644 index 0000000000000..2ce53c2ed30d5 --- /dev/null +++ b/app/assets/javascripts/sidebar/sidebar_bundle.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking'; +import sidebarAssignees from './components/assignees/sidebar_assignees'; + +import Mediator from './sidebar_mediator'; + +document.addEventListener('DOMContentLoaded', () => { + const mediator = new Mediator(gl.sidebarOptions); + mediator.fetch(); + + const sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees'); + + // Only create the sidebarAssignees vue app if it is found in the DOM + // We currently do not use sidebarAssignees for the MR page + if (sidebarAssigneesEl) { + new Vue(sidebarAssignees).$mount(sidebarAssigneesEl); + } + + new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker'); +}); + diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js new file mode 100644 index 0000000000000..c13f3391f0d0e --- /dev/null +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -0,0 +1,38 @@ +/* global Flash */ + +import Service from './services/sidebar_service'; +import Store from './stores/sidebar_store'; + +export default class SidebarMediator { + constructor(options) { + if (!SidebarMediator.singleton) { + this.store = new Store(options); + this.service = new Service(options.endpoint); + SidebarMediator.singleton = this; + } + + return SidebarMediator.singleton; + } + + assignYourself() { + this.store.addAssignee(this.store.currentUser); + } + + saveAssignees(field) { + const selected = this.store.assignees.map(u => u.id); + + // If there are no ids, that means we have to unassign (which is id = 0) + // And it only accepts an array, hence [0] + return this.service.update(field, selected.length === 0 ? [0] : selected); + } + + fetch() { + this.service.get() + .then((response) => { + const data = response.json(); + this.store.processAssigneeData(data); + this.store.processTimeTrackingData(data); + }) + .catch(() => new Flash('Error occured when fetching sidebar data')); + } +} diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js new file mode 100644 index 0000000000000..94408c4d71547 --- /dev/null +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -0,0 +1,52 @@ +export default class SidebarStore { + constructor(store) { + if (!SidebarStore.singleton) { + const { currentUser, rootPath, editable } = store; + this.currentUser = currentUser; + this.rootPath = rootPath; + this.editable = editable; + this.timeEstimate = 0; + this.totalTimeSpent = 0; + this.humanTimeEstimate = ''; + this.humanTimeSpent = ''; + this.assignees = []; + + SidebarStore.singleton = this; + } + + return SidebarStore.singleton; + } + + processAssigneeData(data) { + if (data.assignees) { + this.assignees = data.assignees; + } + } + + processTimeTrackingData(data) { + this.timeEstimate = data.time_estimate; + this.totalTimeSpent = data.total_time_spent; + this.humanTimeEstimate = data.human_time_estimate; + this.humanTotalTimeSpent = data.human_total_time_spent; + } + + addAssignee(assignee) { + if (!this.findAssignee(assignee)) { + this.assignees.push(assignee); + } + } + + findAssignee(findAssignee) { + return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0]; + } + + removeAssignee(removeAssignee) { + if (removeAssignee) { + this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id); + } + } + + removeAllAssignees() { + this.assignees = []; + } +} diff --git a/app/assets/javascripts/subbable_resource.js b/app/assets/javascripts/subbable_resource.js deleted file mode 100644 index d819160512849..0000000000000 --- a/app/assets/javascripts/subbable_resource.js +++ /dev/null @@ -1,51 +0,0 @@ -(() => { -/* -* SubbableResource can be extended to provide a pubsub-style service for one-off REST -* calls. Subscribe by passing a callback or render method you will use to handle responses. - * -* */ - - class SubbableResource { - constructor(resourcePath) { - this.endpoint = resourcePath; - - // TODO: Switch to axios.create - this.resource = $.ajax; - this.subscribers = []; - } - - subscribe(callback) { - this.subscribers.push(callback); - } - - publish(newResponse) { - const responseCopy = _.extend({}, newResponse); - this.subscribers.forEach((fn) => { - fn(responseCopy); - }); - return newResponse; - } - - get(payload) { - return this.resource(payload) - .then(data => this.publish(data)); - } - - post(payload) { - return this.resource(payload) - .then(data => this.publish(data)); - } - - put(payload) { - return this.resource(payload) - .then(data => this.publish(data)); - } - - delete(payload) { - return this.resource(payload) - .then(data => this.publish(data)); - } - } - - gl.SubbableResource = SubbableResource; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/subscription_select.js b/app/assets/javascripts/subscription_select.js index 8b25f43ffc745..0cd591c732086 100644 --- a/app/assets/javascripts/subscription_select.js +++ b/app/assets/javascripts/subscription_select.js @@ -19,8 +19,8 @@ return label; }; })(this), - clicked: function(item, $el, e) { - return e.preventDefault(); + clicked: function(options) { + return options.e.preventDefault(); }, id: function(obj, el) { return $(el).data("id"); diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 68cf9ced3efbf..7828bf86f6028 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -1,6 +1,7 @@ /* eslint-disable func-names, space-before-function-paren, one-var, no-var, prefer-rest-params, wrap-iife, quotes, max-len, one-var-declaration-per-line, vars-on-top, prefer-arrow-callback, consistent-return, comma-dangle, object-shorthand, no-shadow, no-unused-vars, no-else-return, no-self-compare, prefer-template, no-unused-expressions, no-lonely-if, yoda, prefer-spread, no-void, camelcase, no-param-reassign */ /* global Issuable */ -/* global ListUser */ + +import eventHub from './sidebar/event_hub'; (function() { var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }, @@ -54,42 +55,92 @@ selectedIdDefault = (defaultNullUser && showNullUser) ? 0 : null; selectedId = $dropdown.data('selected') || selectedIdDefault; - var updateIssueBoardsIssue = function () { - $loading.removeClass('hidden').fadeIn(); - gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update')) - .then(function () { - $loading.fadeOut(); - }) - .catch(function () { - $loading.fadeOut(); - }); + const assignYourself = function () { + const unassignedSelected = $dropdown.closest('.selectbox') + .find(`input[name='${$dropdown.data('field-name')}'][value=0]`); + + if (unassignedSelected) { + unassignedSelected.remove(); + } + + // Save current selected user to the DOM + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = $dropdown.data('field-name'); + + const currentUserInfo = $dropdown.data('currentUserInfo'); + + if (currentUserInfo) { + input.value = currentUserInfo.id; + input.dataset.meta = currentUserInfo.name; + } else if (_this.currentUser) { + input.value = _this.currentUser.id; + } + + $dropdown.before(input); + }; + + if ($block[0]) { + $block[0].addEventListener('assignYourself', assignYourself); + } + + const getSelectedUserInputs = function() { + return $selectbox + .find(`input[name="${$dropdown.data('field-name')}"]`); + }; + + const getSelected = function() { + return getSelectedUserInputs() + .map((index, input) => parseInt(input.value, 10)) + .get(); + }; + + const getMultiSelectDropdownTitle = function(selectedUser, isSelected) { + const selectedUsers = getSelected() + .filter(u => u !== 0); + + const firstUser = getSelectedUserInputs() + .map((index, input) => ({ + name: input.dataset.meta, + value: parseInt(input.value, 10), + })) + .filter(u => u.id !== 0) + .get(0); + + if (selectedUsers.length === 0) { + return 'Unassigned'; + } else if (selectedUsers.length === 1) { + return firstUser.name; + } else if (isSelected) { + const otherSelected = selectedUsers.filter(s => s !== selectedUser.id); + return `${selectedUser.name} + ${otherSelected.length} more`; + } else { + return `${firstUser.name} + ${selectedUsers.length - 1} more`; + } }; $('.assign-to-me-link').on('click', (e) => { e.preventDefault(); $(e.currentTarget).hide(); - const $input = $(`input[name="${$dropdown.data('field-name')}"]`); - $input.val(gon.current_user_id); - selectedId = $input.val(); - $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default'); - }); - - $block.on('click', '.js-assign-yourself', function(e) { - e.preventDefault(); - if ($dropdown.hasClass('js-issue-board-sidebar')) { - gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({ - id: _this.currentUser.id, - username: _this.currentUser.username, - name: _this.currentUser.name, - avatar_url: _this.currentUser.avatar_url - })); + if ($dropdown.data('multiSelect')) { + assignYourself(); - updateIssueBoardsIssue(); + const currentUserInfo = $dropdown.data('currentUserInfo'); + $dropdown.find('.dropdown-toggle-text').text(getMultiSelectDropdownTitle(currentUserInfo)).removeClass('is-default'); } else { - return assignTo(_this.currentUser.id); + const $input = $(`input[name="${$dropdown.data('field-name')}"]`); + $input.val(gon.current_user_id); + selectedId = $input.val(); + $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default'); } }); + + $block.on('click', '.js-assign-yourself', (e) => { + e.preventDefault(); + return assignTo(_this.currentUser.id); + }); + assignTo = function(selected) { var data; data = {}; @@ -97,6 +148,7 @@ data[abilityName].assignee_id = selected != null ? selected : null; $loading.removeClass('hidden').fadeIn(); $dropdown.trigger('loading.gl.dropdown'); + return $.ajax({ type: 'PUT', dataType: 'json', @@ -106,7 +158,6 @@ var user; $dropdown.trigger('loaded.gl.dropdown'); $loading.fadeOut(); - $selectbox.hide(); if (data.assignee) { user = { name: data.assignee.name, @@ -133,51 +184,90 @@ var isAuthorFilter; isAuthorFilter = $('.js-author-search'); return _this.users(term, options, function(users) { - var anyUser, index, j, len, name, obj, showDivider; - if (term.length === 0) { - showDivider = 0; - if (firstUser) { - // Move current user to the front of the list - for (index = j = 0, len = users.length; j < len; index = (j += 1)) { - obj = users[index]; - if (obj.username === firstUser) { - users.splice(index, 1); - users.unshift(obj); - break; - } + // GitLabDropdownFilter returns this.instance + // GitLabDropdownRemote returns this.options.instance + const glDropdown = this.instance || this.options.instance; + glDropdown.options.processData(term, users, callback); + }.bind(this)); + }, + processData: function(term, users, callback) { + let anyUser; + let index; + let j; + let len; + let name; + let obj; + let showDivider; + if (term.length === 0) { + showDivider = 0; + if (firstUser) { + // Move current user to the front of the list + for (index = j = 0, len = users.length; j < len; index = (j += 1)) { + obj = users[index]; + if (obj.username === firstUser) { + users.splice(index, 1); + users.unshift(obj); + break; } } - if (showNullUser) { - showDivider += 1; - users.unshift({ - beforeDivider: true, - name: 'Unassigned', - id: 0 - }); - } - if (showAnyUser) { - showDivider += 1; - name = showAnyUser; - if (name === true) { - name = 'Any User'; - } - anyUser = { - beforeDivider: true, - name: name, - id: null - }; - users.unshift(anyUser); + } + if (showNullUser) { + showDivider += 1; + users.unshift({ + beforeDivider: true, + name: 'Unassigned', + id: 0 + }); + } + if (showAnyUser) { + showDivider += 1; + name = showAnyUser; + if (name === true) { + name = 'Any User'; } + anyUser = { + beforeDivider: true, + name: name, + id: null + }; + users.unshift(anyUser); } + if (showDivider) { - users.splice(showDivider, 0, "divider"); + users.splice(showDivider, 0, 'divider'); } - callback(users); - if (showMenuAbove) { - $dropdown.data('glDropdown').positionMenuAbove(); + if ($dropdown.hasClass('js-multiselect')) { + const selected = getSelected().filter(i => i !== 0); + + if (selected.length > 0) { + if ($dropdown.data('dropdown-header')) { + showDivider += 1; + users.splice(showDivider, 0, { + header: $dropdown.data('dropdown-header'), + }); + } + + const selectedUsers = users + .filter(u => selected.indexOf(u.id) !== -1) + .sort((a, b) => a.name > b.name); + + users = users.filter(u => selected.indexOf(u.id) === -1); + + selectedUsers.forEach((selectedUser) => { + showDivider += 1; + users.splice(showDivider, 0, selectedUser); + }); + + users.splice(showDivider + 1, 0, 'divider'); + } } - }); + } + + callback(users); + if (showMenuAbove) { + $dropdown.data('glDropdown').positionMenuAbove(); + } }, filterable: true, filterRemote: true, @@ -186,7 +276,22 @@ }, selectable: true, fieldName: $dropdown.data('field-name'), - toggleLabel: function(selected, el) { + toggleLabel: function(selected, el, glDropdown) { + const inputValue = glDropdown.filterInput.val(); + + if (this.multiSelect && inputValue === '') { + // Remove non-users from the fullData array + const users = glDropdown.filteredFullData(); + const callback = glDropdown.parseData.bind(glDropdown); + + // Update the data model + this.processData(inputValue, users, callback); + } + + if (this.multiSelect) { + return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active')); + } + if (selected && 'id' in selected && $(el).hasClass('is-active')) { $dropdown.find('.dropdown-toggle-text').removeClass('is-default'); if (selected.text) { @@ -200,15 +305,92 @@ } }, defaultLabel: defaultLabel, - inputId: 'issue_assignee_id', hidden: function(e) { - $selectbox.hide(); - // display:block overrides the hide-collapse rule - return $value.css('display', ''); + if ($dropdown.hasClass('js-multiselect')) { + eventHub.$emit('sidebar.saveAssignees'); + } + + if (!$dropdown.data('always-show-selectbox')) { + $selectbox.hide(); + + // Recalculate where .value is because vue might have changed it + $block = $selectbox.closest('.block'); + $value = $block.find('.value'); + // display:block overrides the hide-collapse rule + $value.css('display', ''); + } }, +<<<<<<< HEAD vue: $dropdown.hasClass('js-issue-board-sidebar'), clicked: function(user, $el, e) { var isIssueIndex, isMRIndex, page, selected, isSelecting; +======= + multiSelect: $dropdown.hasClass('js-multiselect'), + inputMeta: $dropdown.data('input-meta'), + clicked: function(options) { + const { $el, e, isMarking } = options; + const user = options.selectedObj; + + if ($dropdown.hasClass('js-multiselect')) { + const isActive = $el.hasClass('is-active'); + const previouslySelected = $dropdown.closest('.selectbox') + .find("input[name='" + ($dropdown.data('field-name')) + "'][value!=0]"); + + // Enables support for limiting the number of users selected + // Automatically removes the first on the list if more users are selected + const maxSelect = $dropdown.data('max-select'); + if (maxSelect) { + const selected = getSelected(); + + if (selected.length > maxSelect) { + const firstSelectedId = selected[0]; + const firstSelected = $dropdown.closest('.selectbox') + .find(`input[name='${$dropdown.data('field-name')}'][value=${firstSelectedId}]`); + + firstSelected.remove(); + eventHub.$emit('sidebar.removeAssignee', { + id: firstSelectedId, + }); + } + } + + if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') { + // Unassigned selected + previouslySelected.each((index, element) => { + const id = parseInt(element.value, 10); + element.remove(); + }); + eventHub.$emit('sidebar.removeAllAssignees'); + } else if (isActive) { + // user selected + eventHub.$emit('sidebar.addAssignee', user); + + // Remove unassigned selection (if it was previously selected) + const unassignedSelected = $dropdown.closest('.selectbox') + .find("input[name='" + ($dropdown.data('field-name')) + "'][value=0]"); + + if (unassignedSelected) { + unassignedSelected.remove(); + } + } else { + if (previouslySelected.length === 0) { + // Select unassigned because there is no more selected users + this.addInput($dropdown.data('field-name'), 0, {}); + } + + // User unselected + eventHub.$emit('sidebar.removeAssignee', user); + } + + if (getSelected().find(u => u === gon.current_user_id)) { + $('.assign-to-me-link').hide(); + } else { + $('.assign-to-me-link').show(); + } + } + + var isIssueIndex, isMRIndex, page, selected; +>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; isMRIndex = (page === page && page === 'projects:merge_requests:index'); @@ -229,6 +411,7 @@ return Issuable.filterResults($dropdown.closest('form')); } else if ($dropdown.hasClass('js-filter-submit')) { return $dropdown.closest('form').submit(); +<<<<<<< HEAD } else if ($dropdown.hasClass('js-issue-board-sidebar')) { if (user.id && isSelecting) { gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({ @@ -243,6 +426,9 @@ updateIssueBoardsIssue(); } else { +======= + } else if (!$dropdown.hasClass('js-multiselect')) { +>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val(); return assignTo(selected); } @@ -256,29 +442,54 @@ selectedId = parseInt($dropdown[0].dataset.selected, 10) || selectedIdDefault; } $el.find('.is-active').removeClass('is-active'); - $el.find(`li[data-user-id="${selectedId}"] .dropdown-menu-user-link`).addClass('is-active'); + + function highlightSelected(id) { + $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active'); + } + + if ($selectbox[0]) { + getSelected().forEach(selectedId => highlightSelected(selectedId)); + } else { + highlightSelected(selectedId); + } }, + updateLabel: $dropdown.data('dropdown-title'), renderRow: function(user) { - var avatar, img, listClosingTags, listWithName, listWithUserName, selected, username; + var avatar, img, listClosingTags, listWithName, listWithUserName, username; username = user.username ? "@" + user.username : ""; avatar = user.avatar_url ? user.avatar_url : false; - selected = user.id === parseInt(selectedId, 10) ? "is-active" : ""; + + let selected = user.id === parseInt(selectedId, 10); + + if (this.multiSelect) { + const fieldName = this.fieldName; + const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']"); + + if (field.length) { + selected = true; + } + } + img = ""; if (user.beforeDivider != null) { - "<li> <a href='#' class='" + selected + "'> " + user.name + " </a> </li>"; + `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`; } else { if (avatar) { - img = "<img src='" + avatar + "' class='avatar avatar-inline' width='30' />"; + img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />"; } } - // split into three parts so we can remove the username section if nessesary - listWithName = "<li data-user-id=" + user.id + "> <a href='#' class='dropdown-menu-user-link " + selected + "'> " + img + " <strong class='dropdown-menu-user-full-name'> " + user.name + " </strong>"; - listWithUserName = "<span class='dropdown-menu-user-username'> " + username + " </span>"; - listClosingTags = "</a> </li>"; - if (username === '') { - listWithUserName = ''; - } - return listWithName + listWithUserName + listClosingTags; + + return ` + <li data-user-id=${user.id}> + <a href='#' class='dropdown-menu-user-link ${selected === true ? 'is-active' : ''}'> + ${img} + <strong class='dropdown-menu-user-full-name'> + ${user.name} + </strong> + ${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''} + </a> + </li> + `; } }); }; diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss index 3f5b78ed44536..91c1ebd5a7de3 100644 --- a/app/assets/stylesheets/framework/avatar.scss +++ b/app/assets/stylesheets/framework/avatar.scss @@ -93,3 +93,14 @@ align-self: center; } } + +.avatar-counter { + background-color: $gray-darkest; + color: $white-light; + border: 1px solid $border-color; + border-radius: 1em; + font-family: $regular_font; + font-size: 9px; + line-height: 16px; + text-align: center; +} diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 73ded9f30d470..856989bccf11f 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -251,11 +251,9 @@ } .dropdown-header { - color: $gl-text-color; + color: $gl-text-color-secondary; font-size: 13px; - font-weight: 600; line-height: 22px; - text-transform: capitalize; padding: 0 16px; } @@ -337,8 +335,8 @@ .dropdown-menu-user { .avatar { float: left; - width: 30px; - height: 30px; + width: 2 * $gl-padding; + height: 2 * $gl-padding; margin: 0 10px 0 0; } } @@ -381,6 +379,7 @@ .dropdown-menu-selectable { a { padding-left: 26px; + position: relative; &.is-indeterminate, &.is-active { @@ -406,6 +405,9 @@ &.is-active::before { content: "\f00c"; + position: absolute; + top: 50%; + transform: translateY(-50%); } } } diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index 15dc0aa6a5288..c9a25946ffd5f 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -255,6 +255,7 @@ ul.controls { .avatar-inline { margin-left: 0; margin-right: 0; + margin-bottom: 0; } } } diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 0be1c21595923..68d7ab4bf8411 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -207,8 +207,13 @@ margin-bottom: 5px; } - &.is-active { + &.is-active, + &.is-active .card-assignee:hover a { background-color: $row-hover; + + &:first-child:not(:only-child) { + box-shadow: -10px 0 10px 1px $row-hover; + } } .label { @@ -224,7 +229,7 @@ } .card-title { - margin: 0; + margin: 0 30px 0 0; font-size: 1em; line-height: inherit; @@ -240,10 +245,69 @@ min-height: 20px; .card-assignee { - margin-left: auto; - margin-right: 5px; - padding-left: 10px; + display: flex; + justify-content: flex-end; + position: absolute; + right: 15px; height: 20px; + width: 20px; + + .avatar-counter { + display: none; + vertical-align: middle; + min-width: 20px; + line-height: 19px; + height: 20px; + padding-left: 2px; + padding-right: 2px; + border-radius: 2em; + } + + img { + vertical-align: top; + } + + a { + position: relative; + margin-left: -15px; + } + + a:nth-child(1) { + z-index: 3; + } + + a:nth-child(2) { + z-index: 2; + } + + a:nth-child(3) { + z-index: 1; + } + + a:nth-child(4) { + display: none; + } + + &:hover { + .avatar-counter { + display: inline-block; + } + + a { + position: static; + background-color: $white-light; + transition: background-color 0s; + margin-left: auto; + + &:nth-child(4) { + display: block; + } + + &:first-child:not(:only-child) { + box-shadow: -10px 0 10px 1px $white-light; + } + } + } } .avatar { diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index feefaad8a15c6..77f2638683a1d 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -570,14 +570,7 @@ .diff-comments-more-count, .diff-notes-collapse { - background-color: $gray-darkest; - color: $white-light; - border: 1px solid $white-light; - border-radius: 1em; - font-family: $regular_font; - font-size: 9px; - line-height: 17px; - text-align: center; + @extend .avatar-counter; } .diff-notes-collapse { diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index ad6eb9f6fe040..485ea369f3d17 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -95,10 +95,15 @@ } .right-sidebar { - a { + a, + .btn-link { color: inherit; } + .btn-link { + outline: none; + } + .issuable-header-text { margin-top: 7px; } @@ -215,6 +220,10 @@ } } + .assign-yourself .btn-link { + padding-left: 0; + } + .light { font-weight: normal; } @@ -239,6 +248,10 @@ margin-left: 0; } + .assignee .user-list .avatar { + margin: 0; + } + .username { display: block; margin-top: 4px; @@ -301,6 +314,10 @@ margin-top: 0; } + .sidebar-avatar-counter { + padding-top: 2px; + } + .todo-undone { color: $gl-link-color; } @@ -309,10 +326,15 @@ display: none; } - .avatar:hover { + .avatar:hover, + .avatar-counter:hover { border-color: $issuable-sidebar-color; } + .avatar-counter:hover { + color: $issuable-sidebar-color; + } + .btn-clipboard { border: none; color: $issuable-sidebar-color; @@ -322,6 +344,17 @@ color: $gl-text-color; } } + + &.multiple-users { + display: flex; + justify-content: center; + } + } + + .sidebar-avatar-counter { + width: 24px; + height: 24px; + border-radius: 12px; } .sidebar-collapsed-user { @@ -332,6 +365,37 @@ .issuable-header-btn { display: none; } + + .multiple-users { + height: 24px; + margin-bottom: 17px; + margin-top: 4px; + padding-bottom: 4px; + + .btn-link { + padding: 0; + border: 0; + + .avatar { + margin: 0; + } + } + + .btn-link:first-child { + position: absolute; + left: 10px; + z-index: 1; + } + + .btn-link:last-child { + position: absolute; + right: 10px; + + &:hover { + text-decoration: none; + } + } + } } a { @@ -380,17 +444,21 @@ } .participants-list { + display: flex; + flex-wrap: wrap; + justify-content: space-between; margin: -5px; } +.user-list { + display: flex; + flex-wrap: wrap; +} + .participants-author { - display: inline-block; + flex-basis: 14%; padding: 5px; - &:nth-of-type(7n) { - padding-right: 0; - } - .author_link { display: block; } @@ -400,13 +468,39 @@ } } -.participants-more { +.user-item { + display: inline-block; + padding: 5px; + flex-basis: 20%; + + .user-link { + display: inline-block; + } +} + +.participants-more, +.user-list-more { margin-top: 5px; margin-left: 5px; - a { + a, + .btn-link { color: $gl-text-color-secondary; } + + .btn-link { + outline: none; + padding: 0; + } + + .btn-link:hover { + @extend a:hover; + text-decoration: none; + } + + .btn-link:focus { + text-decoration: none; + } } .issuable-form-padding-top { @@ -499,6 +593,19 @@ } } +.issuable-list li, +.issue-info-container .controls { + .avatar-counter { + display: inline-block; + vertical-align: middle; + min-width: 16px; + line-height: 14px; + height: 16px; + padding-left: 2px; + padding-right: 2px; + } +} + .time_tracker { padding-bottom: 0; border-bottom: 0; diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 3ccf2a9ce33e1..b199f18da1e6d 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -66,6 +66,7 @@ def bulk_update_params :milestone_id, :state_event, :subscription_event, + assignee_ids: [], label_ids: [], add_label_ids: [], remove_label_ids: [] diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index c8a501d73195a..6df2c06874566 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -43,7 +43,7 @@ def issuable_meta_data(issuable_collection, collection_type) end def issues_collection - issues_finder.execute.preload(:project, :author, :assignee, :labels, :milestone, project: :namespace) + issues_finder.execute.preload(:project, :author, :assignees, :labels, :milestone, project: :namespace) end def merge_requests_collection diff --git a/app/controllers/projects/boards/issues_controller.rb b/app/controllers/projects/boards/issues_controller.rb index 28c9646910d2c..da9b789d6171f 100644 --- a/app/controllers/projects/boards/issues_controller.rb +++ b/app/controllers/projects/boards/issues_controller.rb @@ -82,7 +82,7 @@ def serialize_as_json(resource) labels: true, only: [:id, :iid, :title, :confidential, :due_date, :relative_position], include: { - assignee: { only: [:id, :name, :username], methods: [:avatar_url] }, + assignees: { only: [:id, :name, :username], methods: [:avatar_url] }, milestone: { only: [:id, :title] } }, user: current_user diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index af9157bfbb598..2cb38fd953dfc 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -67,7 +67,7 @@ def index def new params[:issue] ||= ActionController::Parameters.new( - assignee_id: "" + assignee_ids: "" ) build_params = issue_params.merge( merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], @@ -150,7 +150,7 @@ def update if @issue.valid? render json: @issue.to_json(methods: [:task_status, :task_status_short], include: { milestone: {}, - assignee: { only: [:name, :username], methods: [:avatar_url] }, + assignees: { only: [:id, :name, :username], methods: [:avatar_url] }, labels: { methods: :text_color } }) else render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity @@ -275,7 +275,7 @@ def redirect_old def issue_params params.require(:issue).permit( :title, :assignee_id, :position, :description, :confidential, - :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [] + :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [], assignee_ids: [], ) end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 4cc42b88a2a6a..957ad87585837 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -231,7 +231,7 @@ def by_scope(items) when 'created-by-me', 'authored' items.where(author_id: current_user.id) when 'assigned-to-me' - items.where(assignee_id: current_user.id) + items.assigned_to(current_user) else items end diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index 76715e5970dac..b4c074bc69c94 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -26,17 +26,28 @@ def init_collection IssuesFinder.not_restricted_by_confidentiality(current_user) end + def by_assignee(items) + if assignee + items.assigned_to(assignee) + elsif no_assignee? + items.unassigned + elsif assignee_id? || assignee_username? # assignee not found + items.none + else + items + end + end + def self.not_restricted_by_confidentiality(user) - return Issue.where('issues.confidential IS NULL OR issues.confidential IS FALSE') if user.blank? + return Issue.where('issues.confidential IS NOT TRUE') if user.blank? return Issue.all if user.admin? Issue.where(' - issues.confidential IS NULL - OR issues.confidential IS FALSE + issues.confidential IS NOT TRUE OR (issues.confidential = TRUE AND (issues.author_id = :user_id - OR issues.assignee_id = :user_id + OR EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = :user_id AND issue_id = issues.id) OR issues.project_id IN(:project_ids)))', user_id: user.id, project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id)) diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index 1182939f65695..639a720b024f7 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -15,4 +15,37 @@ def form_errors(model) end end end + + def issue_dropdown_options(issuable, has_multiple_assignees = true) + options = { + toggle_class: 'js-user-search js-assignee-search', + title: 'Select assignee', + filter: true, + dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee', + placeholder: 'Search users', + data: { + first_user: current_user&.username, + null_user: true, + current_user: true, + project_id: issuable.project.try(:id), + field_name: "#{issuable.class.model_name.param_key}[assignee_ids][]", + default_label: 'Assignee', + 'max-select': 1, + 'dropdown-header': 'Assignee', + } + } + + if has_multiple_assignees + options[:toggle_class] += ' js-multiselect js-save-user-data' + options[:title] = 'Select assignee(s)' + options[:data][:multi_select] = true + options[:data][:'input-meta'] = 'name' + options[:data][:'always-show-selectbox'] = true + options[:data][:current_user_info] = current_user.to_json(only: [:id, :name]) + options[:data][:'dropdown-header'] = 'Assignee(s)' + options[:data].delete(:'max-select') + end + + options + end end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 0b13dbf5f8d26..7656929efe7ad 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -63,6 +63,16 @@ def template_dropdown_tag(issuable, &block) end end + def users_dropdown_label(selected_users) + if selected_users.length == 0 + "Unassigned" + elsif selected_users.length == 1 + selected_users[0].name + else + "#{selected_users[0].name} + #{selected_users.length - 1} more" + end + end + def user_dropdown_label(user_id, default_label) return default_label if user_id.nil? return "Unassigned" if user_id == "0" diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index d64e48f774b92..0f84784129511 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -11,10 +11,12 @@ def new_mention_in_issue_email(recipient_id, issue_id, updated_by_user_id) mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) end - def reassigned_issue_email(recipient_id, issue_id, previous_assignee_id, updated_by_user_id) + def reassigned_issue_email(recipient_id, issue_id, previous_assignee_ids, updated_by_user_id) setup_issue_mail(issue_id, recipient_id) - @previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id + @previous_assignees = [] + @previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any? + mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 26dbf4d95708f..edf4e9e5d780f 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -26,7 +26,6 @@ module Issuable cache_markdown_field :description, issuable_state_filter_enabled: true belongs_to :author, class_name: "User" - belongs_to :assignee, class_name: "User" belongs_to :updated_by, class_name: "User" belongs_to :milestone has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do @@ -65,11 +64,8 @@ def award_emojis_loaded? validates :title, presence: true, length: { maximum: 255 } scope :authored, ->(user) { where(author_id: user) } - scope :assigned_to, ->(u) { where(assignee_id: u.id)} scope :recent, -> { reorder(id: :desc) } scope :order_position_asc, -> { reorder(position: :asc) } - scope :assigned, -> { where("assignee_id IS NOT NULL") } - scope :unassigned, -> { where("assignee_id IS NULL") } scope :of_projects, ->(ids) { where(project_id: ids) } scope :of_milestones, ->(ids) { where(milestone_id: ids) } scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) } @@ -92,7 +88,6 @@ def award_emojis_loaded? attr_mentionable :description participant :author - participant :assignee participant :notes_with_associations strip_attributes :title @@ -102,13 +97,6 @@ def award_emojis_loaded? after_save :update_assignee_cache_counts, if: :assignee_id_changed? after_save :record_metrics, unless: :imported? - def update_assignee_cache_counts - # make sure we flush the cache for both the old *and* new assignees(if they exist) - previous_assignee = User.find_by_id(assignee_id_was) if assignee_id_was - previous_assignee&.update_cache_counts - assignee&.update_cache_counts - end - # We want to use optimistic lock for cases when only title or description are involved # http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html def locking_enabled? @@ -237,10 +225,6 @@ def new? today? && created_at == updated_at end - def is_being_reassigned? - assignee_id_changed? - end - def open? opened? || reopened? end @@ -269,7 +253,11 @@ def to_hook_data(user) # DEPRECATED repository: project.hook_attrs.slice(:name, :url, :description, :homepage) } - hook_data[:assignee] = assignee.hook_attrs if assignee + if self.is_a?(Issue) + hook_data[:assignees] = assignees.map(&:hook_attrs) if assignees.any? + else + hook_data[:assignee] = assignee.hook_attrs if assignee + end hook_data end @@ -331,11 +319,6 @@ def can_move?(*) false end - def assignee_or_author?(user) - # We're comparing IDs here so we don't need to load any associations. - author_id == user.id || assignee_id == user.id - end - def record_metrics metrics = self.metrics || create_metrics metrics.record! diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index f449229864d44..a3472af5c55c9 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -40,7 +40,7 @@ def elapsed_days def issues_visible_to_user(user) memoize_per_user(user, :issues_visible_to_user) do IssuesFinder.new(user, issues_finder_params) - .execute.where(milestone_id: milestoneish_ids) + .execute.includes(:assignees).where(milestone_id: milestoneish_ids) end end diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb index 0afbca2cb325e..538615130a762 100644 --- a/app/models/global_milestone.rb +++ b/app/models/global_milestone.rb @@ -36,7 +36,7 @@ def self.states_count(projects) closed = count_by_state(milestones_by_state_and_title, 'closed') all = milestones_by_state_and_title.map { |(_, title), _| title }.uniq.count - { + { opened: opened, closed: closed, all: all @@ -86,7 +86,7 @@ def closed? end def issues - @issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignee, :labels) + @issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignees, :labels) end def merge_requests @@ -94,7 +94,7 @@ def merge_requests end def participants - @participants ||= milestones.includes(:participants).map(&:participants).flatten.compact.uniq + @participants ||= milestones.map(&:participants).flatten.uniq end def labels diff --git a/app/models/issue.rb b/app/models/issue.rb index 78bde6820da97..27e3ed9bc7f54 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -24,10 +24,17 @@ class Issue < ActiveRecord::Base has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all + has_many :issue_assignees + has_many :assignees, class_name: "User", through: :issue_assignees + validates :project, presence: true scope :in_projects, ->(project_ids) { where(project_id: project_ids) } + scope :assigned, -> { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') } + scope :unassigned, -> { where('NOT EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') } + scope :assigned_to, ->(u) { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = ? AND issue_id = issues.id)', u.id)} + scope :without_due_date, -> { where(due_date: nil) } scope :due_before, ->(date) { where('issues.due_date < ?', date) } scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) } @@ -37,13 +44,15 @@ class Issue < ActiveRecord::Base scope :created_after, -> (datetime) { where("created_at >= ?", datetime) } - scope :include_associations, -> { includes(:assignee, :labels, project: :namespace) } + scope :include_associations, -> { includes(:labels, project: :namespace) } after_save :expire_etag_cache attr_spammable :title, spam_title: true attr_spammable :description, spam_description: true + participant :assignees + state_machine :state, initial: :opened do event :close do transition [:reopened, :opened] => :closed @@ -63,10 +72,14 @@ class Issue < ActiveRecord::Base end def hook_attrs + assignee_ids = self.assignee_ids + attrs = { total_time_spent: total_time_spent, human_total_time_spent: human_total_time_spent, - human_time_estimate: human_time_estimate + human_time_estimate: human_time_estimate, + assignee_ids: assignee_ids, + assignee_id: assignee_ids.first # This key is deprecated } attributes.merge!(attrs) @@ -114,6 +127,22 @@ def self.order_by_position_and_priority "id DESC") end + # Returns a Hash of attributes to be used for Twitter card metadata + def card_attributes + { + 'Author' => author.try(:name), + 'Assignee' => assignee_list + } + end + + def assignee_or_author?(user) + author_id == user.id || assignees.exists?(user.id) + end + + def assignee_list + assignees.map(&:name).to_sentence + end + # `from` argument can be a Namespace or Project. def to_reference(from = nil, full: false) reference = "#{self.class.reference_prefix}#{iid}" @@ -248,7 +277,7 @@ def readable_by?(user) true elsif confidential? author == user || - assignee == user || + assignees.include?(user) || project.team.member?(user, Gitlab::Access::REPORTER) else project.public? || diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb new file mode 100644 index 0000000000000..b94b55bb1d1ec --- /dev/null +++ b/app/models/issue_assignee.rb @@ -0,0 +1,29 @@ +class IssueAssignee < ActiveRecord::Base + extend Gitlab::CurrentSettings + + belongs_to :issue + belongs_to :assignee, class_name: "User", foreign_key: :user_id + + after_create :update_assignee_cache_counts + after_destroy :update_assignee_cache_counts + + # EE-specific + after_create :update_elasticsearch_index + after_destroy :update_elasticsearch_index + # EE-specific + + def update_assignee_cache_counts + assignee&.update_cache_counts + end + + def update_elasticsearch_index + if current_application_settings.elasticsearch_indexing? + ElasticIndexerWorker.perform_async( + :update, + 'Issue', + issue.id, + changed_fields: ['assignee_ids'] + ) + end + end +end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 12c5481cd6d24..35231bab12ee1 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -17,6 +17,8 @@ class MergeRequest < ActiveRecord::Base has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all + belongs_to :assignee, class_name: "User" + serialize :merge_params, Hash after_create :ensure_merge_request_diff, unless: :importing? @@ -114,8 +116,14 @@ class MergeRequest < ActiveRecord::Base scope :join_project, -> { joins(:target_project) } scope :references_project, -> { references(:target_project) } + scope :assigned, -> { where("assignee_id IS NOT NULL") } + scope :unassigned, -> { where("assignee_id IS NULL") } + scope :assigned_to, ->(u) { where(assignee_id: u.id)} + + participant :assignee after_save :keep_around_commit + after_save :update_assignee_cache_counts, if: :assignee_id_changed? def self.reference_prefix '!' @@ -177,6 +185,30 @@ def self.wip_title(title) work_in_progress?(title) ? title : "WIP: #{title}" end + def update_assignee_cache_counts + # make sure we flush the cache for both the old *and* new assignees(if they exist) + previous_assignee = User.find_by_id(assignee_id_was) if assignee_id_was + previous_assignee&.update_cache_counts + assignee&.update_cache_counts + end + + # Returns a Hash of attributes to be used for Twitter card metadata + def card_attributes + { + 'Author' => author.try(:name), + 'Assignee' => assignee.try(:name) + } + end + + # This method is needed for compatibility with issues to not mess view and other code + def assignees + Array(assignee) + end + + def assignee_or_author?(user) + author_id == user.id || assignee_id == user.id + end + # `from` argument can be a Namespace or Project. def to_reference(from = nil, full: false) reference = "#{self.class.reference_prefix}#{iid}" diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 652b15519285a..c06bfe0ccdd77 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -21,7 +21,6 @@ class Milestone < ActiveRecord::Base has_many :issues has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues has_many :merge_requests - has_many :participants, -> { distinct.reorder('users.name') }, through: :issues, source: :assignee has_many :events, as: :target, dependent: :destroy scope :active, -> { with_state(:active) } @@ -107,6 +106,10 @@ def self.upcoming_ids_by_projects(projects) end end + def participants + User.joins(assigned_issues: :milestone).where("milestones.id = ?", id) + end + def self.sort(method) case method.to_s when 'due_date_asc' diff --git a/app/models/user.rb b/app/models/user.rb index 2b7ebe6c1a71f..a3126cbb644a4 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -89,6 +89,7 @@ class User < ActiveRecord::Base has_many :subscriptions, dependent: :destroy has_many :recent_events, -> { order "id DESC" }, foreign_key: :author_id, class_name: "Event" has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy + has_one :abuse_report, dependent: :destroy, foreign_key: :user_id has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport" has_many :spam_logs, dependent: :destroy @@ -99,6 +100,10 @@ class User < ActiveRecord::Base has_many :award_emoji, dependent: :destroy has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id + has_many :issue_assignees + has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue + has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" + # Issues that a user owns are expected to be moved to the "ghost" user before # the user is destroyed. If the user owns any issues during deletion, this # should be treated as an exceptional condition. diff --git a/app/serializers/issuable_entity.rb b/app/serializers/issuable_entity.rb index 29aecb50849fc..65b204d4dd27b 100644 --- a/app/serializers/issuable_entity.rb +++ b/app/serializers/issuable_entity.rb @@ -1,7 +1,6 @@ class IssuableEntity < Grape::Entity expose :id expose :iid - expose :assignee_id expose :author_id expose :description expose :lock_version diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index 6429159ebe152..bc4f68710b20d 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -1,6 +1,7 @@ class IssueEntity < IssuableEntity expose :branch_name expose :confidential + expose :assignees, using: API::Entities::UserBasic expose :due_date expose :moved_to_id expose :project_id diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_entity.rb index 5f80ab397a9ba..453ba52b892a0 100644 --- a/app/serializers/merge_request_entity.rb +++ b/app/serializers/merge_request_entity.rb @@ -1,4 +1,5 @@ class MergeRequestEntity < IssuableEntity + expose :assignee_id expose :in_progress_merge_commit_sha expose :locked_at expose :merge_commit_sha diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb index 60891cbb255fb..40ff9b8b8679a 100644 --- a/app/services/issuable/bulk_update_service.rb +++ b/app/services/issuable/bulk_update_service.rb @@ -7,10 +7,14 @@ def execute(type) ids = params.delete(:issuable_ids).split(",") items = model_class.where(id: ids) - %i(state_event milestone_id assignee_id add_label_ids remove_label_ids subscription_event).each do |key| + %i(state_event milestone_id assignee_id assignee_ids add_label_ids remove_label_ids subscription_event).each do |key| params.delete(key) unless params[key].present? end + if params[:assignee_ids] == [IssuableFinder::NONE.to_s] + params[:assignee_ids] = [] + end + items.each do |issuable| next unless can?(current_user, :"update_#{type}", issuable) diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index b071a3984811e..6c2777a8d2d2e 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -1,11 +1,6 @@ class IssuableBaseService < BaseService private - def create_assignee_note(issuable) - SystemNoteService.change_assignee( - issuable, issuable.project, current_user, issuable.assignee) - end - def create_milestone_note(issuable) SystemNoteService.change_milestone( issuable, issuable.project, current_user, issuable.milestone) @@ -53,6 +48,7 @@ def filter_params(issuable) params.delete(:add_label_ids) params.delete(:remove_label_ids) params.delete(:label_ids) + params.delete(:assignee_ids) params.delete(:assignee_id) params.delete(:due_date) end @@ -77,7 +73,7 @@ def filter_assignee(issuable) def assignee_can_read?(issuable, assignee_id) new_assignee = User.find_by_id(assignee_id) - return false unless new_assignee.present? + return false unless new_assignee ability_name = :"read_#{issuable.to_ability_name}" resource = issuable.persisted? ? issuable : project @@ -207,6 +203,7 @@ def update(issuable) filter_params(issuable) old_labels = issuable.labels.to_a old_mentioned_users = issuable.mentioned_users.to_a + old_assignees = issuable.assignees.to_a label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids) params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids) @@ -222,7 +219,13 @@ def update(issuable) handle_common_system_notes(issuable, old_labels: old_labels) end - handle_changes(issuable, old_labels: old_labels, old_mentioned_users: old_mentioned_users) + handle_changes( + issuable, + old_labels: old_labels, + old_mentioned_users: old_mentioned_users, + old_assignees: old_assignees + ) + after_update(issuable) issuable.create_new_cross_references!(current_user) execute_hooks(issuable, 'update') @@ -272,7 +275,7 @@ def toggle_award(issuable) end end - def has_changes?(issuable, old_labels: []) + def has_changes?(issuable, old_labels: [], old_assignees: []) valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch] attrs_changed = valid_attrs.any? do |attr| @@ -281,7 +284,9 @@ def has_changes?(issuable, old_labels: []) labels_changed = issuable.labels != old_labels - attrs_changed || labels_changed + assignees_changed = issuable.assignees != old_assignees + + attrs_changed || labels_changed || assignees_changed end def handle_common_system_notes(issuable, old_labels: []) diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index ee1b40db71891..eedbfa724ff66 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -9,11 +9,30 @@ def hook_data(issue, action) private + def create_assignee_note(issue, old_assignees) + SystemNoteService.change_issue_assignees( + issue, issue.project, current_user, old_assignees) + end + def execute_hooks(issue, action = 'open') issue_data = hook_data(issue, action) hooks_scope = issue.confidential? ? :confidential_issue_hooks : :issue_hooks issue.project.execute_hooks(issue_data, hooks_scope) issue.project.execute_services(issue_data, hooks_scope) end + + def filter_assignee(issuable) + return if params[:assignee_ids].blank? + + assignee_ids = params[:assignee_ids].select { |assignee_id| assignee_can_read?(issuable, assignee_id) } + + if params[:assignee_ids].map(&:to_s) == [IssuableFinder::NONE] + params[:assignee_ids] = [] + elsif assignee_ids.any? + params[:assignee_ids] = assignee_ids + else + params.delete(:assignee_ids) + end + end end end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index b7fe5cb168b97..cd9f9a4a16e02 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -12,8 +12,12 @@ def before_update(issue) spam_check(issue, current_user) end - def handle_changes(issue, old_labels: [], old_mentioned_users: []) - if has_changes?(issue, old_labels: old_labels) + def handle_changes(issue, options) + old_labels = options[:old_labels] || [] + old_mentioned_users = options[:old_mentioned_users] || [] + old_assignees = options[:old_assignees] || [] + + if has_changes?(issue, old_labels: old_labels, old_assignees: old_assignees) todo_service.mark_pending_todos_as_done(issue, current_user) end @@ -26,9 +30,9 @@ def handle_changes(issue, old_labels: [], old_mentioned_users: []) create_milestone_note(issue) end - if issue.previous_changes.include?('assignee_id') - create_assignee_note(issue) - notification_service.reassigned_issue(issue, current_user) + if issue.assignees != old_assignees + create_assignee_note(issue, old_assignees) + notification_service.reassigned_issue(issue, current_user, old_assignees) todo_service.reassigned_issue(issue, current_user) end diff --git a/app/services/merge_requests/assign_issues_service.rb b/app/services/merge_requests/assign_issues_service.rb index 066efa1acc3dd..8c6c484102039 100644 --- a/app/services/merge_requests/assign_issues_service.rb +++ b/app/services/merge_requests/assign_issues_service.rb @@ -4,7 +4,7 @@ def assignable_issues @assignable_issues ||= begin if current_user == merge_request.author closes_issues.select do |issue| - !issue.is_a?(ExternalIssue) && !issue.assignee_id? && can?(current_user, :admin_issue, issue) + !issue.is_a?(ExternalIssue) && !issue.assignees.present? && can?(current_user, :admin_issue, issue) end else [] @@ -14,7 +14,7 @@ def assignable_issues def execute assignable_issues.each do |issue| - Issues::UpdateService.new(issue.project, current_user, assignee_id: current_user.id).execute(issue) + Issues::UpdateService.new(issue.project, current_user, assignee_ids: [current_user.id]).execute(issue) end { diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 582d5c47b6603..3542a41ac831b 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -38,6 +38,11 @@ def execute_hooks(merge_request, action = 'open', oldrev = nil) private + def create_assignee_note(merge_request) + SystemNoteService.change_assignee( + merge_request, merge_request.project, current_user, merge_request.assignee) + end + # Returns all origin and fork merge requests from `@project` satisfying passed arguments. def merge_requests_for(source_branch, mr_states: [:opened, :reopened]) MergeRequest diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index ab7fcf3b6e2b7..5c843a258fb3b 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -21,7 +21,10 @@ def execute(merge_request) update(merge_request) end - def handle_changes(merge_request, old_labels: [], old_mentioned_users: []) + def handle_changes(merge_request, options) + old_labels = options[:old_labels] || [] + old_mentioned_users = options[:old_mentioned_users] || [] + if has_changes?(merge_request, old_labels: old_labels) todo_service.mark_pending_todos_as_done(merge_request, current_user) end diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb index 8bb995158de7e..988bd0a7cdbe9 100644 --- a/app/services/notification_recipient_service.rb +++ b/app/services/notification_recipient_service.rb @@ -19,9 +19,14 @@ def build_recipients(target, current_user, action: nil, previous_assignee: nil, # Re-assign is considered as a mention of the new assignee so we add the # new assignee to the list of recipients after we rejected users with # the "on mention" notification level - if [:reassign_merge_request, :reassign_issue].include?(custom_action) + case custom_action + when :reassign_merge_request recipients << previous_assignee if previous_assignee recipients << target.assignee + when :reassign_issue + previous_assignees = Array(previous_assignee) + recipients.concat(previous_assignees) + recipients.concat(target.assignees) end recipients = reject_muted_users(recipients) diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 6b186263bd103..fe9f5ae2b3304 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -66,8 +66,23 @@ def close_issue(issue, current_user) # * issue new assignee if their notification level is not Disabled # * users with custom level checked with "reassign issue" # - def reassigned_issue(issue, current_user) - reassign_resource_email(issue, issue.project, current_user, :reassigned_issue_email) + def reassigned_issue(issue, current_user, previous_assignees = []) + recipients = NotificationRecipientService.new(issue.project).build_recipients( + issue, + current_user, + action: "reassign", + previous_assignee: previous_assignees + ) + + recipients.each do |recipient| + mailer.send( + :reassigned_issue_email, + recipient.id, + issue.id, + previous_assignees.map(&:id), + current_user.id + ).deliver_later + end end # When we add labels to an issue we should send an email to: @@ -367,10 +382,10 @@ def mailer end def previous_record(object, attribute) - if object && attribute - if object.previous_changes.include?(attribute) - object.previous_changes[attribute].first - end + return unless object && attribute + + if object.previous_changes.include?(attribute) + object.previous_changes[attribute].first end end end diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index 6aeebc26685ae..dc2ee695384e0 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -88,20 +88,33 @@ def extractor current_user.can?(:"admin_#{issuable.to_ability_name}", project) end command :assign do |assignee_param| - user = extract_references(assignee_param, :user).first - user ||= User.find_by(username: assignee_param) + user_ids = extract_references(assignee_param, :user).map(&:id) - @updates[:assignee_id] = user.id if user + if user_ids.empty? + user_ids = User.where(username: assignee_param.split(' ').map(&:strip)).pluck(:id) + end + + next if user_ids.empty? + + if issuable.is_a?(Issue) + @updates[:assignee_ids] = user_ids + else + @updates[:assignee_id] = user_ids.last + end end desc 'Remove assignee' condition do issuable.persisted? && - issuable.assignee_id? && + issuable.assignees.any? && current_user.can?(:"admin_#{issuable.to_ability_name}", project) end command :unassign do - @updates[:assignee_id] = nil + if issuable.is_a?(Issue) + @updates[:assignee_ids] = [] + else + @updates[:assignee_id] = nil + end end desc 'Set milestone' diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index c9e25c7aaa20e..898c69b3f8c2c 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -49,6 +49,44 @@ def change_assignee(noteable, project, author, assignee) create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee')) end + # Called when the assignees of an Issue is changed or removed + # + # issue - Issue object + # project - Project owning noteable + # author - User performing the change + # assignees - Users being assigned, or nil + # + # Example Note text: + # + # "removed all assignees" + # + # "assigned to @user1 additionally to @user2" + # + # "assigned to @user1, @user2 and @user3 and unassigned from @user4 and @user5" + # + # "assigned to @user1 and @user2" + # + # Returns the created Note object + def change_issue_assignees(issue, project, author, old_assignees) + body = + if issue.assignees.any? && old_assignees.any? + unassigned_users = old_assignees - issue.assignees + added_users = issue.assignees.to_a - old_assignees + + text_parts = [] + text_parts << "assigned to #{added_users.map(&:to_reference).to_sentence}" if added_users.any? + text_parts << "unassigned #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any? + + text_parts.join(' and ') + elsif old_assignees.any? + "removed all assignees" + elsif issue.assignees.any? + "assigned to #{issue.assignees.map(&:to_reference).to_sentence}" + end + + create_note(noteable: issue, project: project, author: author, note: body) + end + # Called when one or more labels on a Noteable are added and/or removed # # noteable - Noteable object diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 8ae61694b503e..322c62863655a 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -251,9 +251,9 @@ def handle_note(note, author, skip_users = []) end def create_assignment_todo(issuable, author) - if issuable.assignee + if issuable.assignees.any? attributes = attributes_for_todo(issuable.project, issuable, author, Todo::ASSIGNED) - create_todos(issuable.assignee, attributes) + create_todos(issuable.assignees, attributes) end end diff --git a/app/views/issues/_issue.atom.builder b/app/views/issues/_issue.atom.builder index 23a884480552a..9ec765c45f927 100644 --- a/app/views/issues/_issue.atom.builder +++ b/app/views/issues/_issue.atom.builder @@ -23,10 +23,12 @@ xml.entry do end end - if issue.assignee - xml.assignee do - xml.name issue.assignee.name - xml.email issue.assignee_public_email + if issue.assignees.any? + xml.assignees do + issue.assignees.each do |assignee| + xml.name assignee.name + xml.email assignee.public_email + end end end end diff --git a/app/views/notify/_reassigned_issuable_email.text.erb b/app/views/notify/_reassigned_issuable_email.text.erb deleted file mode 100644 index daf20a226dd7b..0000000000000 --- a/app/views/notify/_reassigned_issuable_email.text.erb +++ /dev/null @@ -1,6 +0,0 @@ -Reassigned <%= issuable.class.model_name.human.titleize %> <%= issuable.iid %> - -<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, { only_path: false }]) %> - -Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%> - to <%= "#{issuable.assignee_id ? issuable.assignee_name : 'Unassigned'}" %> diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml index c762578971a5f..eb5157ccac920 100644 --- a/app/views/notify/new_issue_email.html.haml +++ b/app/views/notify/new_issue_email.html.haml @@ -2,9 +2,9 @@ %p.details #{link_to @issue.author_name, user_url(@issue.author)} created an issue: -- if @issue.assignee_id.present? +- if @issue.assignees.any? %p - Assignee: #{@issue.assignee_name} + Assignee: #{@issue.assignee_list} - if @issue.description %div diff --git a/app/views/notify/new_issue_email.text.erb b/app/views/notify/new_issue_email.text.erb index ca5c2f2688c0c..13f1ac08e9454 100644 --- a/app/views/notify/new_issue_email.text.erb +++ b/app/views/notify/new_issue_email.text.erb @@ -2,6 +2,6 @@ New Issue was created. Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %> Author: <%= @issue.author_name %> -Assignee: <%= @issue.assignee_name %> +Assignee: <%= @issue.assignee_list %> <%= @issue.description %> diff --git a/app/views/notify/new_mention_in_issue_email.text.erb b/app/views/notify/new_mention_in_issue_email.text.erb index 457e94b48001b..f19ac3adfc7aa 100644 --- a/app/views/notify/new_mention_in_issue_email.text.erb +++ b/app/views/notify/new_mention_in_issue_email.text.erb @@ -2,6 +2,6 @@ You have been mentioned in an issue. Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %> Author: <%= @issue.author_name %> -Assignee: <%= @issue.assignee_name %> +Assignee: <%= @issue.assignee_list %> <%= @issue.description %> diff --git a/app/views/notify/reassigned_issue_email.html.haml b/app/views/notify/reassigned_issue_email.html.haml index 498ba8b83651b..ee2f40e1683a4 100644 --- a/app/views/notify/reassigned_issue_email.html.haml +++ b/app/views/notify/reassigned_issue_email.html.haml @@ -1 +1,10 @@ -= render 'reassigned_issuable_email', issuable: @issue +%p + Assignee changed + - if @previous_assignees.any? + from + %strong= @previous_assignees.map(&:name).to_sentence + to + - if @issue.assignees.any? + %strong= @issue.assignee_list + - else + %strong Unassigned diff --git a/app/views/notify/reassigned_issue_email.text.erb b/app/views/notify/reassigned_issue_email.text.erb index 710253be98429..6c357f1074a48 100644 --- a/app/views/notify/reassigned_issue_email.text.erb +++ b/app/views/notify/reassigned_issue_email.text.erb @@ -1 +1,6 @@ -<%= render 'reassigned_issuable_email', issuable: @issue %> +Reassigned Issue <%= @issue.iid %> + +<%= url_for([@issue.project.namespace.becomes(Namespace), @issue.project, @issue, { only_path: false }]) %> + +Assignee changed <%= "from #{@previous_assignees.map(&:name).to_sentence}" if @previous_assignees.any? -%> + to <%= "#{@issue.assignees.any? ? @issue.assignee_list : 'Unassigned'}" %> diff --git a/app/views/notify/reassigned_merge_request_email.html.haml b/app/views/notify/reassigned_merge_request_email.html.haml index 2a650130f59ce..841df872857fc 100644 --- a/app/views/notify/reassigned_merge_request_email.html.haml +++ b/app/views/notify/reassigned_merge_request_email.html.haml @@ -1 +1,9 @@ -= render 'reassigned_issuable_email', issuable: @merge_request +Reassigned Merge Request #{ @merge_request.iid } + += url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }]) + +Assignee changed +- if @previous_assignee + from #{@previous_assignee.name} +to += @merge_request.assignee_id ? @merge_request.assignee_name : 'Unassigned' diff --git a/app/views/notify/reassigned_merge_request_email.text.erb b/app/views/notify/reassigned_merge_request_email.text.erb index b5b4f1ff99a6b..998a40fefdeb3 100644 --- a/app/views/notify/reassigned_merge_request_email.text.erb +++ b/app/views/notify/reassigned_merge_request_email.text.erb @@ -1 +1,6 @@ -<%= render 'reassigned_issuable_email', issuable: @merge_request %> +Reassigned Merge Request <%= @merge_request.iid %> + +<%= url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }]) %> + +Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%> + to <%= "#{@merge_request.assignee_id ? @merge_request.assignee_name : 'Unassigned'}" %> diff --git a/app/views/projects/boards/components/sidebar/_assignee.html.haml b/app/views/projects/boards/components/sidebar/_assignee.html.haml index 0f42433452134..96f7f12b1d74a 100644 --- a/app/views/projects/boards/components/sidebar/_assignee.html.haml +++ b/app/views/projects/boards/components/sidebar/_assignee.html.haml @@ -1,40 +1,32 @@ -.block.assignee - .title.hide-collapsed - Assignee - - if can?(current_user, :admin_issue, @project) - = icon("spinner spin", class: "block-loading") - = link_to "Edit", "#", class: "edit-link pull-right" - .value.hide-collapsed - %span.assign-yourself.no-value{ "v-if" => "!issue.assignee" } - No assignee - - if can?(current_user, :admin_issue, @project) - \- - %a.js-assign-yourself{ href: "#" } - assign yourself - %a.author_link.bold{ ":href" => "'#{root_url}' + issue.assignee.username", - "v-if" => "issue.assignee" } - %img.avatar.avatar-inline.s32{ ":src" => "issue.assignee.avatar", - width: "32", alt: "Avatar" } - %span.author - {{ issue.assignee.name }} - %span.username - = precede "@" do - {{ issue.assignee.username }} +.block.assignee{ ref: "assigneeBlock" } + %template{ "v-if" => "issue.assignees" } + %assignee-title{ ":number-of-assignees" => "issue.assignees.length", + ":loading" => "loadingAssignees", + ":editable" => can?(current_user, :admin_issue, @project) } + %assignees.value{ "root-path" => "#{root_url}", + ":users" => "issue.assignees", + ":editable" => can?(current_user, :admin_issue, @project), + "@assign-self" => "assignSelf" } + - if can?(current_user, :admin_issue, @project) .selectbox.hide-collapsed %input{ type: "hidden", - name: "issue[assignee_id]", - id: "issue_assignee_id", - ":value" => "issue.assignee.id", - "v-if" => "issue.assignee" } + name: "issue[assignee_ids][]", + ":value" => "assignee.id", + "v-if" => "issue.assignees", + "v-for" => "assignee in issue.assignees" } .dropdown +<<<<<<< HEAD %button.dropdown-menu-toggle.js-user-search.js-author-search.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", field_name: "issue[assignee_id]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true", null_user_default: "true" }, +======= + %button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: "button", ref: "assigneeDropdown", data: { toggle: "dropdown", field_name: "issue[assignee_ids][]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true", multi_select: "true", dropdown: { header: 'Assignee(s)'} }, +>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master ":data-issuable-id" => "issue.id", ":data-selected" => "assigneeId", ":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" } - Select assignee + Select assignee(s) = icon("chevron-down") - .dropdown-menu.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author + .dropdown-menu.dropdown-select.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author = dropdown_title("Assign to") = dropdown_filter("Search users") = dropdown_content diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 0e3902c066ad2..c184e0e00224b 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -13,9 +13,9 @@ %li CLOSED - - if issue.assignee + - if issue.assignees.any? %li - = link_to_member(@project, issue.assignee, name: false, title: "Assigned to :name") + = render 'shared/issuable/assignees', project: @project, issue: issue = render 'shared/issuable_meta_data', issuable: issue diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml new file mode 100644 index 0000000000000..36bbb1148d49b --- /dev/null +++ b/app/views/shared/issuable/_assignees.html.haml @@ -0,0 +1,15 @@ +- max_render = 3 +- max = [max_render, issue.assignees.length].min + +- issue.assignees.each_with_index do |assignee, index| + - if index < max + = link_to_member(@project, assignee, name: false, title: "Assigned to :name") + +- if issue.assignees.length > max_render + - counter = issue.assignees.length - max_render + + %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', 'original-title' => "+#{counter} more assignees" } } + - if counter < 99 + = "+#{counter}" + - else + 99+ diff --git a/app/views/shared/issuable/_participants.html.haml b/app/views/shared/issuable/_participants.html.haml index 171da89993736..db407363a0929 100644 --- a/app/views/shared/issuable/_participants.html.haml +++ b/app/views/shared/issuable/_participants.html.haml @@ -12,9 +12,9 @@ - participants.each do |participant| .participants-author.js-participants-author = link_to_member(@project, participant, name: false, size: 24) - - if participants_extra > 0 - .participants-more - %a.js-participants-more{ href: "#", data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } } - + #{participants_extra} more + - if participants_extra > 0 + .hide-collapsed.participants-more + %a.js-participants-more{ href: "#", data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } } + + #{participants_extra} more :javascript IssuableContext.prototype.PARTICIPANTS_ROW_COUNT = #{participants_row}; diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index b6fce5e3cd439..dd727f1abfb54 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -124,8 +124,17 @@ %li %a{ href: "#", data: { id: "close" } } Closed .filter-item.inline + - if type == :issues + - field_name = "update[assignee_ids][]" + - else + - field_name = "update[assignee_id]" + = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", +<<<<<<< HEAD placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]", default_label: "Assignee" } }) +======= + placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } }) +>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master .filter-item.inline = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true, default_label: "Milestone" } }) .filter-item.inline.labels-filter diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index bc638e994f3c4..9bf73c87c88aa 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -1,10 +1,10 @@ - todo = issuable_todo(issuable) - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('common_vue') - = page_specific_javascript_bundle_tag('issuable') + = page_specific_javascript_bundle_tag('sidebar') %aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => "102", "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } - .issuable-sidebar + .issuable-sidebar{ data: { endpoint: "#{issuable_json_path(issuable)}" } } - can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project) .block.issuable-sidebar-header - if current_user @@ -20,36 +20,59 @@ .block.todo.hide-expanded = render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable, is_collapsed: true .block.assignee - .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } - - if issuable.assignee - = link_to_member(@project, issuable.assignee, size: 24) - - else - = icon('user', 'aria-hidden': 'true') - .title.hide-collapsed - Assignee - = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - - if can_edit_issuable - = link_to 'Edit', '#', class: 'edit-link pull-right' - .value.hide-collapsed - - if issuable.assignee - = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do - - if issuable.instance_of?(MergeRequest) && !issuable.can_be_merged_by?(issuable.assignee) - %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } - = icon('exclamation-triangle', 'aria-hidden': 'true') - %span.username - = issuable.assignee.to_reference - - else - %span.assign-yourself.no-value - No assignee - - if can_edit_issuable - \- - %a.js-assign-yourself{ href: '#' } - assign yourself + - if issuable.instance_of?(Issue) + #js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]" } } + - else + .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } + - if issuable.assignee + = link_to_member(@project, issuable.assignee, size: 24) + - else + = icon('user', 'aria-hidden': 'true') + .title.hide-collapsed + Assignee + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') + - if can_edit_issuable + = link_to 'Edit', '#', class: 'edit-link pull-right' + .value.hide-collapsed + - if issuable.assignee + = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do + - if issuable.instance_of?(MergeRequest) && !issuable.can_be_merged_by?(issuable.assignee) + %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } + = icon('exclamation-triangle', 'aria-hidden': 'true') + %span.username + = issuable.assignee.to_reference + - else + %span.assign-yourself.no-value + No assignee + - if can_edit_issuable + \- + %a.js-assign-yourself{ href: '#' } + assign yourself .selectbox.hide-collapsed +<<<<<<< HEAD = f.hidden_field 'assignee_id', value: issuable.assignee_id, id: 'issue_assignee_id' = dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true, null_user_default: true, selected: issuable.assignee_id } }) +======= + - issuable.assignees.each do |assignee| + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil +>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master + - options = { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } } + + - if issuable.instance_of?(Issue) + - if issuable.assignees.length == 0 + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil + - title = 'Select assignee(s)' + - options[:toggle_class] += ' js-multiselect js-save-user-data' + - options[:data][:field_name] = "#{issuable.to_ability_name}[assignee_ids][]" + - options[:data][:multi_select] = true + - options[:data]['dropdown-title'] = title + - options[:data]['dropdown-header'] = 'Assignee(s)' + - else + - title = 'Select assignee' + + = dropdown_tag(title, options: options) .block.milestone .sidebar-collapsed-icon = icon('clock-o', 'aria-hidden': 'true') @@ -75,11 +98,10 @@ = dropdown_tag('Milestone', options: { title: 'Assign milestone', toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: 'Search milestones', data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true }}) - if issuable.has_attribute?(:time_estimate) #issuable-time-tracker.block - %issuable-time-tracker{ ':time_estimate' => 'issuable.time_estimate', ':time_spent' => 'issuable.total_time_spent', ':human_time_estimate' => 'issuable.human_time_estimate', ':human_time_spent' => 'issuable.human_total_time_spent', 'docs-url' => help_page_path('workflow/time_tracking.md') } - // Fallback while content is loading - .title.hide-collapsed - Time tracking - = icon('spinner spin', 'aria-hidden': 'true') + // Fallback while content is loading + .title.hide-collapsed + Time tracking + = icon('spinner spin', 'aria-hidden': 'true') - if issuable.has_attribute?(:due_date) .block.due_date .sidebar-collapsed-icon @@ -169,8 +191,13 @@ = clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left") :javascript - gl.IssuableResource = new gl.SubbableResource('#{issuable_json_path(issuable)}'); - new gl.IssuableTimeTracking("#{escape_javascript(serialize_issuable(issuable))}"); + gl.sidebarOptions = { + endpoint: "#{issuable_json_path(issuable)}", + editable: #{can_edit_issuable ? true : false}, + currentUser: #{current_user.to_json(only: [:username, :id, :name], methods: :avatar_url)}, + rootPath: "#{root_path}" + }; + new MilestoneSelect('{"full_path":"#{@project.full_path}"}'); new LabelsSelect(); new IssuableContext('#{escape_javascript(current_user.to_json(only: [:username, :id, :name]))}'); diff --git a/app/views/shared/issuable/form/_issue_assignee.html.haml b/app/views/shared/issuable/form/_issue_assignee.html.haml new file mode 100644 index 0000000000000..c33474ac3b4e7 --- /dev/null +++ b/app/views/shared/issuable/form/_issue_assignee.html.haml @@ -0,0 +1,30 @@ +- issue = issuable +.block.assignee + .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee_list) } + - if issue.assignees.any? + - issue.assignees.each do |assignee| + = link_to_member(@project, assignee, size: 24) + - else + = icon('user', 'aria-hidden': 'true') + .title.hide-collapsed + Assignee + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') + - if can_edit_issuable + = link_to 'Edit', '#', class: 'edit-link pull-right' + .value.hide-collapsed + - if issue.assignees.any? + - issue.assignees.each do |assignee| + = link_to_member(@project, assignee, size: 32, extra_class: 'bold') do + %span.username + = assignee.to_reference + - else + %span.assign-yourself.no-value + No assignee + - if can_edit_issuable + \- + %a.js-assign-yourself{ href: '#' } + assign yourself + + .selectbox.hide-collapsed + = f.hidden_field 'assignee_ids', value: issuable.assignee_ids, id: 'issue_assignee_ids' + = dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } }) diff --git a/app/views/shared/issuable/form/_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_merge_request_assignee.html.haml new file mode 100644 index 0000000000000..18011d528a0be --- /dev/null +++ b/app/views/shared/issuable/form/_merge_request_assignee.html.haml @@ -0,0 +1,31 @@ +- merge_request = issuable +.block.assignee + .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (merge_request.assignee.name if merge_request.assignee) } + - if merge_request.assignee + = link_to_member(@project, merge_request.assignee, size: 24) + - else + = icon('user', 'aria-hidden': 'true') + .title.hide-collapsed + Assignee + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') + - if can_edit_issuable + = link_to 'Edit', '#', class: 'edit-link pull-right' + .value.hide-collapsed + - if merge_request.assignee + = link_to_member(@project, merge_request.assignee, size: 32, extra_class: 'bold') do + - unless merge_request.can_be_merged_by?(merge_request.assignee) + %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } + = icon('exclamation-triangle', 'aria-hidden': 'true') + %span.username + = merge_request.assignee.to_reference + - else + %span.assign-yourself.no-value + No assignee + - if can_edit_issuable + \- + %a.js-assign-yourself{ href: '#' } + assign yourself + + .selectbox.hide-collapsed + = f.hidden_field 'assignee_id', value: merge_request.assignee_id, id: 'issue_assignee_id' + = dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: @project&.id, author_id: merge_request.author_id, field_name: 'merge_request[assignee_id]', issue_update: issuable_json_path(merge_request), ability_name: 'merge_request', null_user: true } }) diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml index 9dbfedb84f1a2..411cb717fc766 100644 --- a/app/views/shared/issuable/form/_metadata.html.haml +++ b/app/views/shared/issuable/form/_metadata.html.haml @@ -10,13 +10,24 @@ .row %div{ class: (has_due_date ? "col-lg-6" : "col-sm-12") } .form-group.issue-assignee - = form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" - .col-sm-10{ class: ("col-lg-8" if has_due_date) } - .issuable-form-select-holder - = form.hidden_field :assignee_id - = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", - placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} }) - = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}" + - if issuable.is_a?(Issue) + = form.label :assignee_ids, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" + .col-sm-10{ class: ("col-lg-8" if has_due_date) } + .issuable-form-select-holder.selectbox + - issuable.assignees.each do |assignee| + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { meta: assignee.name } + + = dropdown_tag(users_dropdown_label(issuable.assignees), options: issue_dropdown_options(issuable, true)) + = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}" + - else + = form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" + .col-sm-10{ class: ("col-lg-8" if has_due_date) } + .issuable-form-select-holder + = form.hidden_field :assignee_id + + = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", + placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} }) + = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}" .form-group.issue-milestone = form.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}" .col-sm-10{ class: ("col-lg-8" if has_due_date) } diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml index 5247d6a51e642..22547a30cdfb1 100644 --- a/app/views/shared/milestones/_issuable.html.haml +++ b/app/views/shared/milestones/_issuable.html.haml @@ -1,7 +1,7 @@ -# @project is present when viewing Project's milestone - project = @project || issuable.project - namespace = @project_namespace || project.namespace.becomes(Namespace) -- assignee = issuable.assignee +- assignees = issuable.assignees - issuable_type = issuable.class.table_name - base_url_args = [namespace, project] - issuable_type_args = base_url_args + [issuable_type] @@ -26,7 +26,7 @@ - render_colored_label(label) %span.assignee-icon - - if assignee - = link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, assignee_id: issuable.assignee_id, state: 'all' }), + - assignees.each do |assignee| + = link_to polymorphic_path(base_url_args, { milestone_title: @milestone.title, assignee_id: assignee.id, state: 'all' }), class: 'has-tooltip', title: "Assigned to #{assignee.name}", data: { container: 'body' } do - - image_tag(avatar_icon(issuable.assignee, 16), class: "avatar s16", alt: '') + - image_tag(avatar_icon(assignee, 16), class: "avatar s16", alt: '') diff --git a/changelogs/unreleased/update-issue-board-cards-design.yml b/changelogs/unreleased/update-issue-board-cards-design.yml new file mode 100644 index 0000000000000..5ef94a74e8a92 --- /dev/null +++ b/changelogs/unreleased/update-issue-board-cards-design.yml @@ -0,0 +1,4 @@ +--- +title: Update issue board cards design +merge_request: 10353 +author: diff --git a/config/webpack.config.js b/config/webpack.config.js index 0ec9e48845edc..8cacafd611f64 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -33,7 +33,11 @@ var config = { graphs: './graphs/graphs_bundle.js', group: './group.js', groups_list: './groups_list.js', +<<<<<<< HEAD issuable: './issuable/issuable_bundle.js', +======= + issues: './issues/issues_bundle.js', +>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master issue_show: './issue_show/index.js', main: './main.js', merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js', @@ -46,6 +50,7 @@ var config = { profile: './profile/profile_bundle.js', protected_branches: './protected_branches/protected_branches_bundle.js', protected_tags: './protected_tags', + sidebar: './sidebar/sidebar_bundle.js', snippet: './snippet/snippet_bundle.js', sketch_viewer: './blob/sketch_viewer.js', stl_viewer: './blob/stl_viewer.js', @@ -125,7 +130,7 @@ var config = { 'diff_notes', 'environments', 'environments_folder', - 'issuable', + 'sidebar', 'issue_show', 'merge_conflicts', 'notebook_viewer', diff --git a/db/fixtures/development/09_issues.rb b/db/fixtures/development/09_issues.rb index d93d133d15767..0b32a461d56b1 100644 --- a/db/fixtures/development/09_issues.rb +++ b/db/fixtures/development/09_issues.rb @@ -8,7 +8,7 @@ description: FFaker::Lorem.sentence, state: ['opened', 'closed'].sample, milestone: project.milestones.sample, - assignee: project.team.users.sample + assignees: [project.team.users.sample] } Issues::CreateService.new(project, project.team.users.sample, issue_params).execute diff --git a/db/migrate/20170320171632_create_issue_assignees_table.rb b/db/migrate/20170320171632_create_issue_assignees_table.rb new file mode 100644 index 0000000000000..72b70baa8d916 --- /dev/null +++ b/db/migrate/20170320171632_create_issue_assignees_table.rb @@ -0,0 +1,40 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class CreateIssueAssigneesTable < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + INDEX_NAME = 'index_issue_assignees_on_issue_id_and_user_id' + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def up + create_table :issue_assignees, id: false do |t| + t.references :user, foreign_key: { on_delete: :cascade }, index: true, null: false + t.references :issue, foreign_key: { on_delete: :cascade }, null: false + end + + add_index :issue_assignees, [:issue_id, :user_id], unique: true, name: INDEX_NAME + end + + def down + drop_table :issue_assignees + end +end diff --git a/db/migrate/20170320173259_migrate_assignees.rb b/db/migrate/20170320173259_migrate_assignees.rb new file mode 100644 index 0000000000000..ba8edbd7d32e2 --- /dev/null +++ b/db/migrate/20170320173259_migrate_assignees.rb @@ -0,0 +1,52 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class MigrateAssignees < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + disable_ddl_transaction! + + def up + # Optimisation: this accounts for most of the invalid assignee IDs on GitLab.com + update_column_in_batches(:issues, :assignee_id, nil) do |table, query| + query.where(table[:assignee_id].eq(0)) + end + + users = Arel::Table.new(:users) + + update_column_in_batches(:issues, :assignee_id, nil) do |table, query| + query.where(table[:assignee_id].not_eq(nil)\ + .and( + users.project("true").where(users[:id].eq(table[:assignee_id])).exists.not + )) + end + + execute <<-EOF + INSERT INTO issue_assignees(issue_id, user_id) + SELECT id, assignee_id FROM issues WHERE assignee_id IS NOT NULL + EOF + end + + def down + execute <<-EOF + DELETE FROM issue_assignees + EOF + end +end diff --git a/db/schema.rb b/db/schema.rb index 01c0f00c92496..340d4064c0245 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -452,6 +452,14 @@ add_index "identities", ["user_id"], name: "index_identities_on_user_id", using: :btree + create_table "issue_assignees", id: false, force: :cascade do |t| + t.integer "user_id", null: false + t.integer "issue_id", null: false + end + + add_index "issue_assignees", ["issue_id", "user_id"], name: "index_issue_assignees_on_issue_id_and_user_id", unique: true, using: :btree + add_index "issue_assignees", ["user_id"], name: "index_issue_assignees_on_user_id", using: :btree + create_table "issue_metrics", force: :cascade do |t| t.integer "issue_id", null: false t.datetime "first_mentioned_in_commit_at" @@ -1383,6 +1391,8 @@ add_foreign_key "ci_trigger_requests", "ci_triggers", column: "trigger_id", name: "fk_b8ec8b7245", on_delete: :cascade add_foreign_key "ci_trigger_schedules", "ci_triggers", column: "trigger_id", name: "fk_90a406cc94", on_delete: :cascade add_foreign_key "ci_triggers", "users", column: "owner_id", name: "fk_e8e10d1964", on_delete: :cascade + add_foreign_key "issue_assignees", "issues", on_delete: :cascade + add_foreign_key "issue_assignees", "users", on_delete: :cascade add_foreign_key "container_repositories", "projects" add_foreign_key "issue_metrics", "issues", on_delete: :cascade add_foreign_key "label_priorities", "labels", on_delete: :cascade diff --git a/doc/api/issues.md b/doc/api/issues.md index 6c10b5ab0e71a..1d43b1298b9b5 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -70,6 +70,14 @@ Example response: "updated_at" : "2016-01-04T15:31:39.996Z" }, "project_id" : 1, + "assignees" : [{ + "state" : "active", + "id" : 1, + "name" : "Administrator", + "web_url" : "https://gitlab.example.com/root", + "avatar_url" : null, + "username" : "root" + }], "assignee" : { "state" : "active", "id" : 1, @@ -92,6 +100,8 @@ Example response: ] ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## List group issues Get a list of a group's issues. @@ -153,6 +163,14 @@ Example response: "description" : "Omnis vero earum sunt corporis dolor et placeat.", "state" : "closed", "iid" : 1, + "assignees" : [{ + "avatar_url" : null, + "web_url" : "https://gitlab.example.com/lennie", + "state" : "active", + "username" : "lennie", + "id" : 9, + "name" : "Dr. Luella Kovacek" + }], "assignee" : { "avatar_url" : null, "web_url" : "https://gitlab.example.com/lennie", @@ -174,6 +192,8 @@ Example response: ] ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## List project issues Get a list of a project's issues. @@ -235,6 +255,14 @@ Example response: "description" : "Omnis vero earum sunt corporis dolor et placeat.", "state" : "closed", "iid" : 1, + "assignees" : [{ + "avatar_url" : null, + "web_url" : "https://gitlab.example.com/lennie", + "state" : "active", + "username" : "lennie", + "id" : 9, + "name" : "Dr. Luella Kovacek" + }], "assignee" : { "avatar_url" : null, "web_url" : "https://gitlab.example.com/lennie", @@ -256,6 +284,8 @@ Example response: ] ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Single issue Get a single project issue. @@ -300,6 +330,14 @@ Example response: "description" : "Omnis vero earum sunt corporis dolor et placeat.", "state" : "closed", "iid" : 1, + "assignees" : [{ + "avatar_url" : null, + "web_url" : "https://gitlab.example.com/lennie", + "state" : "active", + "username" : "lennie", + "id" : 9, + "name" : "Dr. Luella Kovacek" + }], "assignee" : { "avatar_url" : null, "web_url" : "https://gitlab.example.com/lennie", @@ -321,6 +359,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## New issue Creates a new project issue. @@ -329,13 +369,13 @@ Creates a new project issue. POST /projects/:id/issues ``` -| Attribute | Type | Required | Description | -|-------------------------------------------|---------|----------|--------------| +| Attribute | Type | Required | Description | +|-------------------------------------------|----------------|----------|--------------| | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | | `title` | string | yes | The title of an issue | | `description` | string | no | The description of an issue | | `confidential` | boolean | no | Set an issue to be confidential. Default is `false`. | -| `assignee_id` | integer | no | The ID of a user to assign issue | +| `assignee_ids` | Array[integer] | no | The ID of a user to assign issue | | `milestone_id` | integer | no | The ID of a milestone to assign issue | | `labels` | string | no | Comma-separated label names for an issue | | `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. `2016-03-11T03:45:40Z` (requires admin or project owner rights) | @@ -357,6 +397,7 @@ Example response: "iid" : 14, "title" : "Issues with auth", "state" : "opened", + "assignees" : [], "assignee" : null, "labels" : [ "bug" @@ -380,6 +421,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Edit issue Updates an existing project issue. This call is also used to mark an issue as @@ -396,7 +439,7 @@ PUT /projects/:id/issues/:issue_iid | `title` | string | no | The title of an issue | | `description` | string | no | The description of an issue | | `confidential` | boolean | no | Updates an issue to be confidential | -| `assignee_id` | integer | no | The ID of a user to assign the issue to | +| `assignee_ids` | Array[integer] | no | The ID of a user to assign the issue to | | `milestone_id` | integer | no | The ID of a milestone to assign the issue to | | `labels` | string | no | Comma-separated label names for an issue | | `state_event` | string | no | The state event of an issue. Set `close` to close the issue and `reopen` to reopen it | @@ -430,6 +473,7 @@ Example response: "bug" ], "id" : 85, + "assignees" : [], "assignee" : null, "milestone" : null, "subscribed" : true, @@ -440,6 +484,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Delete an issue Only for admins and project owners. Soft deletes the issue in question. @@ -494,6 +540,14 @@ Example response: "updated_at": "2016-04-07T12:20:17.596Z", "labels": [], "milestone": null, + "assignees": [{ + "name": "Miss Monserrate Beier", + "username": "axel.block", + "id": 12, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon", + "web_url": "https://gitlab.example.com/axel.block" + }], "assignee": { "name": "Miss Monserrate Beier", "username": "axel.block", @@ -516,6 +570,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Subscribe to an issue Subscribes the authenticated user to an issue to receive notifications. @@ -549,6 +605,14 @@ Example response: "updated_at": "2016-04-07T12:20:17.596Z", "labels": [], "milestone": null, + "assignees": [{ + "name": "Miss Monserrate Beier", + "username": "axel.block", + "id": 12, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon", + "web_url": "https://gitlab.example.com/axel.block" + }], "assignee": { "name": "Miss Monserrate Beier", "username": "axel.block", @@ -571,6 +635,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Unsubscribe from an issue Unsubscribes the authenticated user from the issue to not receive notifications @@ -652,6 +718,14 @@ Example response: "updated_at": "2016-06-17T07:47:33.832Z", "due_date": null }, + "assignees": [{ + "name": "Jarret O'Keefe", + "username": "francisca", + "id": 14, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/a7fa515d53450023c83d62986d0658a8?s=80&d=identicon", + "web_url": "https://gitlab.example.com/francisca" + }], "assignee": { "name": "Jarret O'Keefe", "username": "francisca", @@ -683,6 +757,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Set a time estimate for an issue Sets an estimated time of work for this issue. diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md index dbdc93a77a8eb..e15daa2feae1b 100644 --- a/doc/user/project/integrations/webhooks.md +++ b/doc/user/project/integrations/webhooks.md @@ -232,6 +232,7 @@ X-Gitlab-Event: Issue Hook "object_attributes": { "id": 301, "title": "New API: create/update/delete file", + "assignee_ids": [51], "assignee_id": 51, "author_id": 51, "project_id": 14, @@ -246,6 +247,11 @@ X-Gitlab-Event: Issue Hook "url": "http://example.com/diaspora/issues/23", "action": "open" }, + "assignees": [{ + "name": "User1", + "username": "user1", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + }], "assignee": { "name": "User1", "username": "user1", @@ -265,6 +271,9 @@ X-Gitlab-Event: Issue Hook }] } ``` + +**Note**: `assignee` and `assignee_id` keys are deprecated and now show the first assignee only. + ### Comment events Triggered when a new comment is made on commits, merge requests, issues, and code snippets. @@ -544,6 +553,7 @@ X-Gitlab-Event: Note Hook "issue": { "id": 92, "title": "test", + "assignee_ids": [], "assignee_id": null, "author_id": 1, "project_id": 5, @@ -559,6 +569,8 @@ X-Gitlab-Event: Note Hook } ``` +**Note**: `assignee_id` field is deprecated and now shows the first assignee only. + #### Comment on code snippet **Request header**: diff --git a/features/steps/dashboard/dashboard.rb b/features/steps/dashboard/dashboard.rb index c715c85c43c01..bf09d7b7114be 100644 --- a/features/steps/dashboard/dashboard.rb +++ b/features/steps/dashboard/dashboard.rb @@ -77,7 +77,7 @@ class Spinach::Features::Dashboard < Spinach::FeatureSteps step 'project "Shop" has issue "Bugfix1" with label "feature"' do project = Project.find_by(name: "Shop") - issue = create(:issue, title: "Bugfix1", project: project, assignee: current_user) + issue = create(:issue, title: "Bugfix1", project: project, assignees: [current_user]) issue.labels << project.labels.find_by(title: 'feature') end end diff --git a/features/steps/dashboard/todos.rb b/features/steps/dashboard/todos.rb index 3225e19995bbe..b56558ba0d2ae 100644 --- a/features/steps/dashboard/todos.rb +++ b/features/steps/dashboard/todos.rb @@ -182,7 +182,7 @@ def enterprise end def issue - @issue ||= create(:issue, assignee: current_user, project: project) + @issue ||= create(:issue, assignees: [current_user], project: project) end def merge_request diff --git a/features/steps/group/milestones.rb b/features/steps/group/milestones.rb index 49fcd6f120143..0b0983f0d0653 100644 --- a/features/steps/group/milestones.rb +++ b/features/steps/group/milestones.rb @@ -113,7 +113,7 @@ def group_milestone create :issue, project: project, - assignee: current_user, + assignees: [current_user], author: current_user, milestone: milestone @@ -125,7 +125,7 @@ def group_milestone issue = create :issue, project: project, - assignee: current_user, + assignees: [current_user], author: current_user, milestone: milestone diff --git a/features/steps/groups.rb b/features/steps/groups.rb index 4dc87dc4d9c29..83d8abbab1fae 100644 --- a/features/steps/groups.rb +++ b/features/steps/groups.rb @@ -61,7 +61,7 @@ class Spinach::Features::Groups < Spinach::FeatureSteps step 'project from group "Owned" has issues assigned to me' do create :issue, project: project, - assignee: current_user, + assignees: [current_user], author: current_user end @@ -123,7 +123,7 @@ class Spinach::Features::Groups < Spinach::FeatureSteps step 'the archived project have some issues' do create :issue, project: @archived_project, - assignee: current_user, + assignees: [current_user], author: current_user end diff --git a/lib/api/api.rb b/lib/api/api.rb index 1bf20f76ad6a4..842c7301a6611 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -6,6 +6,7 @@ class API < Grape::API version 'v3', using: :path do helpers ::API::V3::Helpers + helpers ::API::Helpers::CommonHelpers mount ::API::V3::AwardEmoji mount ::API::V3::Boards @@ -77,6 +78,7 @@ class API < Grape::API # Ensure the namespace is right, otherwise we might load Grape::API::Helpers helpers ::SentryHelper helpers ::API::Helpers + helpers ::API::Helpers::CommonHelpers # Keep in alphabetical order mount ::API::AccessRequests diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 6d6ccefe8776c..f8f5548d23da9 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -256,7 +256,11 @@ class Milestone < ProjectEntity class IssueBasic < ProjectEntity expose :label_names, as: :labels expose :milestone, using: Entities::Milestone - expose :assignee, :author, using: Entities::UserBasic + expose :assignees, :author, using: Entities::UserBasic + + expose :assignee, using: ::API::Entities::UserBasic do |issue, options| + issue.assignees.first + end expose :user_notes_count expose :upvotes, :downvotes diff --git a/lib/api/helpers/common_helpers.rb b/lib/api/helpers/common_helpers.rb new file mode 100644 index 0000000000000..6236fdd43ca1e --- /dev/null +++ b/lib/api/helpers/common_helpers.rb @@ -0,0 +1,13 @@ +module API + module Helpers + module CommonHelpers + def convert_parameters_from_legacy_format(params) + if params[:assignee_id].present? + params[:assignee_ids] = [params.delete(:assignee_id)] + end + + params + end + end + end +end diff --git a/lib/api/issues.rb b/lib/api/issues.rb index 522f0f3be9291..78db960ae28fa 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -32,7 +32,8 @@ def find_issues(args = {}) params :issue_params_ce do optional :description, type: String, desc: 'The description of an issue' - optional :assignee_id, type: Integer, desc: 'The ID of a user to assign issue' + optional :assignee_ids, type: Array[Integer], desc: 'The array of user IDs to assign issue' + optional :assignee_id, type: Integer, desc: '[Deprecated] The ID of a user to assign issue' optional :milestone_id, type: Integer, desc: 'The ID of a milestone to assign issue' optional :labels, type: String, desc: 'Comma-separated list of label names' optional :due_date, type: String, desc: 'Date string in the format YEAR-MONTH-DAY' @@ -135,6 +136,8 @@ def find_issues(args = {}) issue_params = declared_params(include_missing: false) + issue_params = convert_parameters_from_legacy_format(issue_params) + issue = ::Issues::CreateService.new(user_project, current_user, issue_params.merge(request: request, api: true)).execute @@ -159,7 +162,7 @@ def find_issues(args = {}) desc: 'Date time when the issue was updated. Available only for admins and project owners.' optional :state_event, type: String, values: %w[reopen close], desc: 'State of the issue' use :issue_params - at_least_one_of :title, :description, :assignee_id, :milestone_id, + at_least_one_of :title, :description, :assignee_ids, :assignee_id, :milestone_id, :labels, :created_at, :due_date, :confidential, :state_event end put ':id/issues/:issue_iid' do @@ -173,6 +176,8 @@ def find_issues(args = {}) update_params = declared_params(include_missing: false).merge(request: request, api: true) + update_params = convert_parameters_from_legacy_format(update_params) + issue = ::Issues::UpdateService.new(user_project, current_user, update_params).execute(issue) diff --git a/lib/api/v3/entities.rb b/lib/api/v3/entities.rb index 832b4bdeb4fef..7c8be7e51db56 100644 --- a/lib/api/v3/entities.rb +++ b/lib/api/v3/entities.rb @@ -248,6 +248,13 @@ class ProjectHook < ::API::Entities::Hook expose :project_id, :issues_events, :merge_requests_events expose :note_events, :build_events, :pipeline_events, :wiki_page_events end + + class Issue < ::API::Entities::Issue + unexpose :assignees + expose :assignee do |issue, options| + ::API::Entities::UserBasic.represent(issue.assignees.first, options) + end + end end end end diff --git a/lib/api/v3/issues.rb b/lib/api/v3/issues.rb index 715083fc4f80f..cedbeb0ded0f7 100644 --- a/lib/api/v3/issues.rb +++ b/lib/api/v3/issues.rb @@ -8,6 +8,7 @@ class Issues < Grape::API helpers do def find_issues(args = {}) args = params.merge(args) + args = convert_parameters_from_legacy_format(args) args.delete(:id) args[:milestone_title] = args.delete(:milestone) @@ -51,7 +52,7 @@ def find_issues(args = {}) resource :issues do desc "Get currently authenticated user's issues" do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do optional :state, type: String, values: %w[opened closed all], default: 'all', @@ -60,8 +61,12 @@ def find_issues(args = {}) end get do issues = find_issues(scope: 'authored') +<<<<<<< HEAD present paginate(issues), with: ::API::Entities::Issue, current_user: current_user +======= + present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user +>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master end end @@ -70,7 +75,7 @@ def find_issues(args = {}) end resource :groups, requirements: { id: %r{[^/]+} } do desc 'Get a list of group issues' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do optional :state, type: String, values: %w[opened closed all], default: 'all', @@ -82,7 +87,7 @@ def find_issues(args = {}) issues = find_issues(group_id: group.id, match_all_labels: true) - present paginate(issues), with: ::API::Entities::Issue, current_user: current_user + present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user end end @@ -94,7 +99,7 @@ def find_issues(args = {}) desc 'Get a list of project issues' do detail 'iid filter is deprecated have been removed on V4' - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do optional :state, type: String, values: %w[opened closed all], default: 'all', @@ -107,22 +112,22 @@ def find_issues(args = {}) issues = find_issues(project_id: project.id) - present paginate(issues), with: ::API::Entities::Issue, current_user: current_user, project: user_project + present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project end desc 'Get a single project issue' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do requires :issue_id, type: Integer, desc: 'The ID of a project issue' end get ":id/issues/:issue_id" do issue = find_project_issue(params[:issue_id]) - present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project + present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project end desc 'Create a new project issue' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do requires :title, type: String, desc: 'The title of an issue' @@ -140,6 +145,7 @@ def find_issues(args = {}) issue_params = declared_params(include_missing: false) issue_params = issue_params.merge(merge_request_to_resolve_discussions_of: issue_params.delete(:merge_request_for_resolving_discussions)) + issue_params = convert_parameters_from_legacy_format(issue_params) issue = ::Issues::CreateService.new(user_project, current_user, @@ -147,14 +153,14 @@ def find_issues(args = {}) render_spam_error! if issue.spam? if issue.valid? - present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project + present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project else render_validation_error!(issue) end end desc 'Update an existing issue' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do requires :issue_id, type: Integer, desc: 'The ID of a project issue' @@ -176,6 +182,7 @@ def find_issues(args = {}) end update_params = declared_params(include_missing: false).merge(request: request, api: true) + update_params = convert_parameters_from_legacy_format(update_params) issue = ::Issues::UpdateService.new(user_project, current_user, @@ -184,14 +191,14 @@ def find_issues(args = {}) render_spam_error! if issue.spam? if issue.valid? - present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project + present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project else render_validation_error!(issue) end end desc 'Move an existing issue' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do requires :issue_id, type: Integer, desc: 'The ID of a project issue' @@ -206,7 +213,7 @@ def find_issues(args = {}) begin issue = ::Issues::MoveService.new(user_project, current_user).execute(issue, new_project) - present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project + present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project rescue ::Issues::MoveService::MoveError => error render_api_error!(error.message, 400) end diff --git a/lib/api/v3/merge_requests.rb b/lib/api/v3/merge_requests.rb index 1616142a619db..b6b7254ae2956 100644 --- a/lib/api/v3/merge_requests.rb +++ b/lib/api/v3/merge_requests.rb @@ -34,7 +34,7 @@ def issue_entity(project) if project.has_external_issue_tracker? ::API::Entities::ExternalIssue else - ::API::Entities::Issue + ::API::V3::Entities::Issue end end diff --git a/lib/api/v3/milestones.rb b/lib/api/v3/milestones.rb index be90cec4afcbc..4c7061d493948 100644 --- a/lib/api/v3/milestones.rb +++ b/lib/api/v3/milestones.rb @@ -39,7 +39,7 @@ def filter_milestones_state(milestones, state) end desc 'Get all issues for a single project milestone' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do requires :milestone_id, type: Integer, desc: 'The ID of a project milestone' @@ -56,7 +56,7 @@ def filter_milestones_state(milestones, state) } issues = IssuesFinder.new(current_user, finder_params).execute - present paginate(issues), with: ::API::Entities::Issue, current_user: current_user, project: user_project + present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project end end end diff --git a/lib/banzai/reference_parser/issue_parser.rb b/lib/banzai/reference_parser/issue_parser.rb index e02b360924ae2..89ec715ddf6d9 100644 --- a/lib/banzai/reference_parser/issue_parser.rb +++ b/lib/banzai/reference_parser/issue_parser.rb @@ -28,7 +28,7 @@ def issues_for_nodes(nodes) nodes, Issue.all.includes( :author, - :assignee, + :assignees, { # These associations are primarily used for checking permissions. # Eager loading these ensures we don't end up running dozens of diff --git a/lib/gitlab/chat_commands/presenters/issue_base.rb b/lib/gitlab/chat_commands/presenters/issue_base.rb index 054f7f4be0ce6..25bc82994baa5 100644 --- a/lib/gitlab/chat_commands/presenters/issue_base.rb +++ b/lib/gitlab/chat_commands/presenters/issue_base.rb @@ -22,7 +22,7 @@ def fields [ { title: "Assignee", - value: @resource.assignee ? @resource.assignee.name : "_None_", + value: @resource.assignees.any? ? @resource.assignees.first.name : "_None_", short: true }, { diff --git a/lib/gitlab/fogbugz_import/importer.rb b/lib/gitlab/fogbugz_import/importer.rb index 222bcdcbf9c8a..3dcee681c72f4 100644 --- a/lib/gitlab/fogbugz_import/importer.rb +++ b/lib/gitlab/fogbugz_import/importer.rb @@ -122,15 +122,15 @@ def import_cases author_id = user_info(bug['ixPersonOpenedBy'])[:gitlab_id] || project.creator_id issue = Issue.create!( - iid: bug['ixBug'], - project_id: project.id, - title: bug['sTitle'], - description: body, - author_id: author_id, - assignee_id: assignee_id, - state: bug['fOpen'] == 'true' ? 'opened' : 'closed', - created_at: date, - updated_at: DateTime.parse(bug['dtLastUpdated']) + iid: bug['ixBug'], + project_id: project.id, + title: bug['sTitle'], + description: body, + author_id: author_id, + assignee_ids: [assignee_id], + state: bug['fOpen'] == 'true' ? 'opened' : 'closed', + created_at: date, + updated_at: DateTime.parse(bug['dtLastUpdated']) ) issue_labels = ::LabelsFinder.new(nil, project_id: project.id, title: labels).execute(skip_authorization: true) diff --git a/lib/gitlab/github_import/issue_formatter.rb b/lib/gitlab/github_import/issue_formatter.rb index 6f5ac4dac0d38..977cd0423ba53 100644 --- a/lib/gitlab/github_import/issue_formatter.rb +++ b/lib/gitlab/github_import/issue_formatter.rb @@ -10,7 +10,7 @@ def attributes description: description, state: state, author_id: author_id, - assignee_id: assignee_id, + assignee_ids: Array(assignee_id), created_at: raw_data.created_at, updated_at: raw_data.updated_at } diff --git a/lib/gitlab/google_code_import/importer.rb b/lib/gitlab/google_code_import/importer.rb index 5ca3e6a95cab8..1b43440673c11 100644 --- a/lib/gitlab/google_code_import/importer.rb +++ b/lib/gitlab/google_code_import/importer.rb @@ -108,13 +108,13 @@ def import_issues end issue = Issue.create!( - iid: raw_issue['id'], - project_id: project.id, - title: raw_issue['title'], - description: body, - author_id: project.creator_id, - assignee_id: assignee_id, - state: raw_issue['state'] == 'closed' ? 'closed' : 'opened' + iid: raw_issue['id'], + project_id: project.id, + title: raw_issue['title'], + description: body, + author_id: project.creator_id, + assignee_ids: [assignee_id], + state: raw_issue['state'] == 'closed' ? 'closed' : 'opened' ) issue_labels = ::LabelsFinder.new(nil, project_id: project.id, title: labels).execute(skip_authorization: true) diff --git a/spec/controllers/dashboard/todos_controller_spec.rb b/spec/controllers/dashboard/todos_controller_spec.rb index 762e90f4a1672..085f3fd8543d5 100644 --- a/spec/controllers/dashboard/todos_controller_spec.rb +++ b/spec/controllers/dashboard/todos_controller_spec.rb @@ -14,7 +14,7 @@ describe 'GET #index' do context 'when using pagination' do let(:last_page) { user.todos.page.total_pages } - let!(:issues) { create_list(:issue, 2, project: project, assignee: user) } + let!(:issues) { create_list(:issue, 2, project: project, assignees: [user]) } before do issues.each { |issue| todo_service.new_issue(issue, user) } diff --git a/spec/controllers/projects/boards/issues_controller_spec.rb b/spec/controllers/projects/boards/issues_controller_spec.rb index 15667e8d4b116..dc3b72c6de4e2 100644 --- a/spec/controllers/projects/boards/issues_controller_spec.rb +++ b/spec/controllers/projects/boards/issues_controller_spec.rb @@ -34,7 +34,7 @@ issue = create(:labeled_issue, project: project, labels: [planning]) create(:labeled_issue, project: project, labels: [planning]) create(:labeled_issue, project: project, labels: [development], due_date: Date.tomorrow) - create(:labeled_issue, project: project, labels: [development], assignee: johndoe) + create(:labeled_issue, project: project, labels: [development], assignees: [johndoe]) issue.subscribe(johndoe, project) list_issues user: user, board: board, list: list2 diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 5f1f892821a05..1f79e72495ac7 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -173,12 +173,12 @@ namespace_id: project.namespace.to_param, project_id: project, id: issue.iid, - issue: { assignee_id: assignee.id }, + issue: { assignee_ids: [assignee.id] }, format: :json body = JSON.parse(response.body) - expect(body['assignee'].keys) - .to match_array(%w(name username avatar_url)) + expect(body['assignees'].first.keys) + .to match_array(%w(id name username avatar_url)) end end @@ -348,7 +348,7 @@ def move_issue let(:admin) { create(:admin) } let!(:issue) { create(:issue, project: project) } let!(:unescaped_parameter_value) { create(:issue, :confidential, project: project, author: author) } - let!(:request_forgery_timing_attack) { create(:issue, :confidential, project: project, assignee: assignee) } + let!(:request_forgery_timing_attack) { create(:issue, :confidential, project: project, assignees: [assignee]) } describe 'GET #index' do it 'does not list confidential issues for guests' do diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index a793da4162a8d..0483c6b787954 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -1067,7 +1067,7 @@ def post_assign_issues end it 'correctly pluralizes flash message on success' do - issue2.update!(assignee: user) + issue2.assignees = [user] post_assign_issues diff --git a/spec/features/atom/dashboard_issues_spec.rb b/spec/features/atom/dashboard_issues_spec.rb index 58b14e09740fa..9ea325ab41b05 100644 --- a/spec/features/atom/dashboard_issues_spec.rb +++ b/spec/features/atom/dashboard_issues_spec.rb @@ -32,7 +32,7 @@ end context "issue with basic fields" do - let!(:issue2) { create(:issue, author: user, assignee: assignee, project: project2, description: 'test desc') } + let!(:issue2) { create(:issue, author: user, assignees: [assignee], project: project2, description: 'test desc') } it "renders issue fields" do visit issues_dashboard_path(:atom, private_token: user.private_token) @@ -41,7 +41,7 @@ expect(entry).to be_present expect(entry).to have_selector('author email', text: issue2.author_public_email) - expect(entry).to have_selector('assignee email', text: issue2.assignee_public_email) + expect(entry).to have_selector('assignees email', text: assignee.public_email) expect(entry).not_to have_selector('labels') expect(entry).not_to have_selector('milestone') expect(entry).to have_selector('description', text: issue2.description) @@ -51,7 +51,7 @@ context "issue with label and milestone" do let!(:milestone1) { create(:milestone, project: project1, title: 'v1') } let!(:label1) { create(:label, project: project1, title: 'label1') } - let!(:issue1) { create(:issue, author: user, assignee: assignee, project: project1, milestone: milestone1) } + let!(:issue1) { create(:issue, author: user, assignees: [assignee], project: project1, milestone: milestone1) } before do issue1.labels << label1 @@ -64,7 +64,7 @@ expect(entry).to be_present expect(entry).to have_selector('author email', text: issue1.author_public_email) - expect(entry).to have_selector('assignee email', text: issue1.assignee_public_email) + expect(entry).to have_selector('assignees email', text: assignee.public_email) expect(entry).to have_selector('labels label', text: label1.title) expect(entry).to have_selector('milestone', text: milestone1.title) expect(entry).not_to have_selector('description') diff --git a/spec/features/atom/issues_spec.rb b/spec/features/atom/issues_spec.rb index b3903ec2faf92..78f8f46a04eb2 100644 --- a/spec/features/atom/issues_spec.rb +++ b/spec/features/atom/issues_spec.rb @@ -6,7 +6,7 @@ let!(:assignee) { create(:user, email: 'private2@example.com', public_email: 'public2@example.com') } let!(:group) { create(:group) } let!(:project) { create(:project) } - let!(:issue) { create(:issue, author: user, assignee: assignee, project: project) } + let!(:issue) { create(:issue, author: user, assignees: [assignee], project: project) } before do project.team << [user, :developer] @@ -22,7 +22,7 @@ to have_content('application/atom+xml') expect(body).to have_selector('title', text: "#{project.name} issues") expect(body).to have_selector('author email', text: issue.author_public_email) - expect(body).to have_selector('assignee email', text: issue.author_public_email) + expect(body).to have_selector('assignees email', text: issue.author_public_email) expect(body).to have_selector('entry summary', text: issue.title) end end @@ -36,7 +36,7 @@ to have_content('application/atom+xml') expect(body).to have_selector('title', text: "#{project.name} issues") expect(body).to have_selector('author email', text: issue.author_public_email) - expect(body).to have_selector('assignee email', text: issue.author_public_email) + expect(body).to have_selector('assignees email', text: issue.author_public_email) expect(body).to have_selector('entry summary', text: issue.title) end end diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index a172ce1e8c022..18585488e2660 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -71,7 +71,7 @@ let!(:list2) { create(:list, board: board, label: development, position: 1) } let!(:confidential_issue) { create(:labeled_issue, :confidential, project: project, author: user, labels: [planning], relative_position: 9) } - let!(:issue1) { create(:labeled_issue, project: project, assignee: user, labels: [planning], relative_position: 8) } + let!(:issue1) { create(:labeled_issue, project: project, assignees: [user], labels: [planning], relative_position: 8) } let!(:issue2) { create(:labeled_issue, project: project, author: user2, labels: [planning], relative_position: 7) } let!(:issue3) { create(:labeled_issue, project: project, labels: [planning], relative_position: 6) } let!(:issue4) { create(:labeled_issue, project: project, labels: [planning], relative_position: 5) } diff --git a/spec/features/boards/modal_filter_spec.rb b/spec/features/boards/modal_filter_spec.rb index 4a4c13e79c8a9..e1367c675e58b 100644 --- a/spec/features/boards/modal_filter_spec.rb +++ b/spec/features/boards/modal_filter_spec.rb @@ -98,7 +98,7 @@ end context 'assignee' do - let!(:issue) { create(:issue, project: project, assignee: user2) } + let!(:issue) { create(:issue, project: project, assignees: [user2]) } before do project.team << [user2, :developer] diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb index bafa4f0593710..02b6b5dc88865 100644 --- a/spec/features/boards/sidebar_spec.rb +++ b/spec/features/boards/sidebar_spec.rb @@ -4,13 +4,14 @@ include WaitForVueResource let(:user) { create(:user) } + let(:user2) { create(:user) } let(:project) { create(:empty_project, :public) } let!(:milestone) { create(:milestone, project: project) } let!(:development) { create(:label, project: project, name: 'Development') } let!(:bug) { create(:label, project: project, name: 'Bug') } let!(:regression) { create(:label, project: project, name: 'Regression') } let!(:stretch) { create(:label, project: project, name: 'Stretch') } - let!(:issue1) { create(:labeled_issue, project: project, assignee: user, milestone: milestone, labels: [development], relative_position: 2) } + let!(:issue1) { create(:labeled_issue, project: project, assignees: [user], milestone: milestone, labels: [development], relative_position: 2) } let!(:issue2) { create(:labeled_issue, project: project, labels: [development, stretch], relative_position: 1) } let(:board) { create(:board, project: project) } let!(:list) { create(:list, board: board, label: development, position: 0) } @@ -20,6 +21,7 @@ Timecop.freeze project.team << [user, :master] + project.team.add_developer(user2) login_as(user) @@ -101,6 +103,26 @@ expect(card).to have_selector('.avatar') end + it 'adds multiple assignees' do + click_card(card) + + page.within('.assignee') do + click_link 'Edit' + + wait_for_ajax + + page.within('.dropdown-menu-user') do + click_link user.name + click_link user2.name + end + + expect(page).to have_content(user.name) + expect(page).to have_content(user2.name) + end + + expect(card.all('.avatar').length).to eq(2) + end + it 'removes the assignee' do card_two = first('.board').find('.card:nth-child(2)') click_card(card_two) @@ -112,10 +134,11 @@ page.within('.dropdown-menu-user') do click_link 'Unassigned' - - wait_for_vue_resource end + find('.dropdown-menu-toggle').click + wait_for_vue_resource + expect(page).to have_content('No assignee') end @@ -128,7 +151,7 @@ page.within(find('.assignee')) do expect(page).to have_content('No assignee') - click_link 'assign yourself' + click_button 'assign yourself' wait_for_vue_resource @@ -138,7 +161,7 @@ expect(card).to have_selector('.avatar') end - it 'resets assignee dropdown' do + it 'updates assignee dropdown' do click_card(card) page.within('.assignee') do @@ -162,7 +185,7 @@ page.within('.assignee') do click_link 'Edit' - expect(page).not_to have_selector('.is-active') + expect(page).to have_selector('.is-active') end end end diff --git a/spec/features/dashboard/issuables_counter_spec.rb b/spec/features/dashboard/issuables_counter_spec.rb index 4fca7577e7431..3d536c5ba4025 100644 --- a/spec/features/dashboard/issuables_counter_spec.rb +++ b/spec/features/dashboard/issuables_counter_spec.rb @@ -7,7 +7,7 @@ let(:merge_request) { create(:merge_request, source_project: project) } before do - issue.update(assignee: user) + issue.assignees = [user] merge_request.update(assignee: user) login_as(user) end @@ -17,7 +17,7 @@ expect_counters('issues', '1') - issue.update(assignee: nil) + issue.assignees = [] Timecop.travel(3.minutes.from_now) do visit issues_path diff --git a/spec/features/dashboard/issues_spec.rb b/spec/features/dashboard/issues_spec.rb index f4420814c3a1b..86c7954e60cf0 100644 --- a/spec/features/dashboard/issues_spec.rb +++ b/spec/features/dashboard/issues_spec.rb @@ -11,7 +11,7 @@ let!(:authored_issue) { create :issue, author: current_user, project: project } let!(:authored_issue_on_public_project) { create :issue, author: current_user, project: public_project } - let!(:assigned_issue) { create :issue, assignee: current_user, project: project } + let!(:assigned_issue) { create :issue, assignees: [current_user], project: project } let!(:other_issue) { create :issue, project: project } before do @@ -30,6 +30,11 @@ find('#assignee_id', visible: false).set('') find('.js-author-search', match: :first).click find('.dropdown-menu-author li a', match: :first, text: current_user.to_reference).click + find('.js-author-search', match: :first).click + + page.within '.dropdown-menu-user' do + expect(find('.dropdown-menu-author li a.is-active', match: :first, text: current_user.to_reference)).to be_visible + end expect(page).to have_content(authored_issue.title) expect(page).to have_content(authored_issue_on_public_project.title) diff --git a/spec/features/dashboard_issues_spec.rb b/spec/features/dashboard_issues_spec.rb index b6b879052315c..ad60fb2c74f51 100644 --- a/spec/features/dashboard_issues_spec.rb +++ b/spec/features/dashboard_issues_spec.rb @@ -10,8 +10,8 @@ project.team << [user, :master] login_as(user) - create(:issue, project: project, author: user, assignee: user) - create(:issue, project: project, author: user, assignee: user, milestone: milestone) + create(:issue, project: project, author: user, assignees: [user]) + create(:issue, project: project, author: user, assignees: [user], milestone: milestone) visit_issues end diff --git a/spec/features/gitlab_flavored_markdown_spec.rb b/spec/features/gitlab_flavored_markdown_spec.rb index f5b54463df8ff..005a029a39319 100644 --- a/spec/features/gitlab_flavored_markdown_spec.rb +++ b/spec/features/gitlab_flavored_markdown_spec.rb @@ -54,11 +54,11 @@ before do @other_issue = create(:issue, author: @user, - assignee: @user, + assignees: [@user], project: project) @issue = create(:issue, author: @user, - assignee: @user, + assignees: [@user], project: project, title: "fix #{@other_issue.to_reference}", description: "ask #{fred.to_reference} for details") diff --git a/spec/features/issues/award_emoji_spec.rb b/spec/features/issues/award_emoji_spec.rb index 71df3c949db4b..853632614c447 100644 --- a/spec/features/issues/award_emoji_spec.rb +++ b/spec/features/issues/award_emoji_spec.rb @@ -7,7 +7,7 @@ let!(:user) { create(:user) } let(:issue) do create(:issue, - assignee: @user, + assignees: [user], project: project) end diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb index c824aa6a41450..ece62c8da4131 100644 --- a/spec/features/issues/filtered_search/filter_issues_spec.rb +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -51,15 +51,16 @@ def select_search_at_index(pos) create(:issue, project: project, title: "issue with 'single quotes'") create(:issue, project: project, title: "issue with \"double quotes\"") create(:issue, project: project, title: "issue with !@\#{$%^&*()-+") - create(:issue, project: project, title: "issue by assignee", milestone: milestone, author: user, assignee: user) - create(:issue, project: project, title: "issue by assignee with searchTerm", milestone: milestone, author: user, assignee: user) + create(:issue, project: project, title: "issue by assignee", milestone: milestone, author: user, assignees: [user]) + create(:issue, project: project, title: "issue by assignee with searchTerm", milestone: milestone, author: user, assignees: [user]) + issue = create(:issue, title: "Bug 2", project: project, milestone: milestone, author: user, - assignee: user) + assignees: [user]) issue.labels << bug_label issue_with_caps_label = create(:issue, @@ -67,7 +68,7 @@ def select_search_at_index(pos) project: project, milestone: milestone, author: user, - assignee: user) + assignees: [user]) issue_with_caps_label.labels << caps_sensitive_label issue_with_everything = create(:issue, @@ -75,7 +76,7 @@ def select_search_at_index(pos) project: project, milestone: milestone, author: user, - assignee: user) + assignees: [user]) issue_with_everything.labels << bug_label issue_with_everything.labels << caps_sensitive_label diff --git a/spec/features/issues/form_spec.rb b/spec/features/issues/form_spec.rb index 21b8cf3add5c5..5798292033b14 100644 --- a/spec/features/issues/form_spec.rb +++ b/spec/features/issues/form_spec.rb @@ -10,7 +10,7 @@ let!(:milestone) { create(:milestone, project: project) } let!(:label) { create(:label, project: project) } let!(:label2) { create(:label, project: project) } - let!(:issue) { create(:issue, project: project, assignee: user, milestone: milestone) } + let!(:issue) { create(:issue, project: project, assignees: [user], milestone: milestone) } before do project.team << [user, :master] @@ -23,25 +23,65 @@ visit new_namespace_project_issue_path(project.namespace, project) end + describe 'multiple assignees' do + before do + click_button 'Unassigned' + end + + it 'unselects other assignees when unassigned is selected' do + page.within '.dropdown-menu-user' do + click_link user2.name + end + + page.within '.dropdown-menu-user' do + click_link 'Unassigned' + end + + page.within '.js-assignee-search' do + expect(page).to have_content 'Unassigned' + end + + expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match('0') + end + + it 'toggles assign to me when current user is selected and unselected' do + page.within '.dropdown-menu-user' do + click_link user.name + end + + expect(find('a', text: 'Assign to me', visible: false)).not_to be_visible + + page.within '.dropdown-menu-user' do + click_link user.name + end + + expect(find('a', text: 'Assign to me')).to be_visible + end + end + it 'allows user to create new issue' do fill_in 'issue_title', with: 'title' fill_in 'issue_description', with: 'title' expect(find('a', text: 'Assign to me')).to be_visible - click_button 'Assignee' + click_button 'Unassigned' page.within '.dropdown-menu-user' do click_link user2.name end - expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user2.id.to_s) + expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user2.id.to_s) page.within '.js-assignee-search' do expect(page).to have_content user2.name end expect(find('a', text: 'Assign to me')).to be_visible click_link 'Assign to me' - expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user.id.to_s) + assignee_ids = page.all('input[name="issue[assignee_ids][]"]', visible: false) + + expect(assignee_ids[0].value).to match(user2.id.to_s) + expect(assignee_ids[1].value).to match(user.id.to_s) + page.within '.js-assignee-search' do - expect(page).to have_content user.name + expect(page).to have_content "#{user2.name} + 1 more" end expect(find('a', text: 'Assign to me', visible: false)).not_to be_visible @@ -69,7 +109,7 @@ page.within '.issuable-sidebar' do page.within '.assignee' do - expect(page).to have_content user.name + expect(page).to have_content "2 Assignees" end page.within '.milestone' do @@ -141,7 +181,7 @@ end it 'allows user to update issue' do - expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user.id.to_s) + expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user.id.to_s) expect(find('input[name="issue[milestone_id]"]', visible: false).value).to match(milestone.id.to_s) expect(find('a', text: 'Assign to me', visible: false)).not_to be_visible diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb index 82b80a69bed5f..e9a05f56543b0 100644 --- a/spec/features/issues/issue_sidebar_spec.rb +++ b/spec/features/issues/issue_sidebar_spec.rb @@ -42,6 +42,21 @@ expect(page).to have_content(user2.name) end end + + it 'assigns yourself' do + find('.block.assignee .dropdown-menu-toggle').click + + click_button 'assign yourself' + + wait_for_ajax + + find('.block.assignee .edit-link').click + + page.within '.dropdown-menu-user' do + expect(page.find('.dropdown-header')).to be_visible + expect(page.find('.dropdown-menu-user-link.is-active')).to have_content(user.name) + end + end end context 'as a allowed user' do diff --git a/spec/features/issues/update_issues_spec.rb b/spec/features/issues/update_issues_spec.rb index 7fa83c1fcf764..b250fa2ed3c80 100644 --- a/spec/features/issues/update_issues_spec.rb +++ b/spec/features/issues/update_issues_spec.rb @@ -99,7 +99,7 @@ def create_closed end def create_assigned - create(:issue, project: project, assignee: user) + create(:issue, project: project, assignees: [user]) end def create_with_milestone diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index 81cc8513454ef..cc81303f03292 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -18,7 +18,7 @@ let!(:issue) do create(:issue, author: @user, - assignee: @user, + assignees: [@user], project: project) end @@ -43,7 +43,7 @@ let!(:issue) do create(:issue, author: @user, - assignee: @user, + assignees: [@user], project: project) end @@ -61,7 +61,7 @@ expect(page).to have_content 'No assignee - assign yourself' end - expect(issue.reload.assignee).to be_nil + expect(issue.reload.assignees).to be_empty end end @@ -138,7 +138,7 @@ describe 'Issue info' do it 'excludes award_emoji from comment count' do - issue = create(:issue, author: @user, assignee: @user, project: project, title: 'foobar') + issue = create(:issue, author: @user, assignees: [@user], project: project, title: 'foobar') create(:award_emoji, awardable: issue) visit namespace_project_issues_path(project.namespace, project, assignee_id: @user.id) @@ -153,14 +153,14 @@ %w(foobar barbaz gitlab).each do |title| create(:issue, author: @user, - assignee: @user, + assignees: [@user], project: project, title: title) end @issue = Issue.find_by(title: 'foobar') @issue.milestone = create(:milestone, project: project) - @issue.assignee = nil + @issue.assignees = [] @issue.save end @@ -351,9 +351,9 @@ let(:user2) { create(:user) } before do - foo.assignee = user2 + foo.assignees << user2 foo.save - bar.assignee = user2 + bar.assignees << user2 bar.save end @@ -396,7 +396,7 @@ end describe 'update labels from issue#show', js: true do - let(:issue) { create(:issue, project: project, author: @user, assignee: @user) } + let(:issue) { create(:issue, project: project, author: @user, assignees: [@user]) } let!(:label) { create(:label, project: project) } before do @@ -415,7 +415,7 @@ end describe 'update assignee from issue#show' do - let(:issue) { create(:issue, project: project, author: @user, assignee: @user) } + let(:issue) { create(:issue, project: project, author: @user, assignees: [@user]) } context 'by authorized user' do it 'allows user to select unassigned', js: true do @@ -426,10 +426,14 @@ click_link 'Edit' click_link 'Unassigned' + first('.title').click expect(page).to have_content 'No assignee' end - expect(issue.reload.assignee).to be_nil + # wait_for_ajax does not work with vue-resource at the moment + sleep 1 + + expect(issue.reload.assignees).to be_empty end it 'allows user to select an assignee', js: true do @@ -461,14 +465,18 @@ click_link 'Edit' click_link @user.name - page.within '.value' do + find('.dropdown-menu-toggle').click + + page.within '.value .author' do expect(page).to have_content @user.name end click_link 'Edit' click_link @user.name - page.within '.value' do + find('.dropdown-menu-toggle').click + + page.within '.value .assign-yourself' do expect(page).to have_content "No assignee" end end @@ -487,7 +495,7 @@ login_with guest visit namespace_project_issue_path(project.namespace, project, issue) - expect(page).to have_content issue.assignee.name + expect(page).to have_content issue.assignees.first.name end end end @@ -558,7 +566,7 @@ let(:user2) { create(:user) } before do - issue.assignee = user2 + issue.assignees << user2 issue.save end end @@ -655,7 +663,7 @@ describe 'due date' do context 'update due on issue#show', js: true do - let(:issue) { create(:issue, project: project, author: @user, assignee: @user) } + let(:issue) { create(:issue, project: project, author: @user, assignees: [@user]) } before do visit namespace_project_issue_path(project.namespace, project, issue) diff --git a/spec/features/merge_requests/assign_issues_spec.rb b/spec/features/merge_requests/assign_issues_spec.rb index 43cc6f2a2a7d7..ec49003772b82 100644 --- a/spec/features/merge_requests/assign_issues_spec.rb +++ b/spec/features/merge_requests/assign_issues_spec.rb @@ -33,7 +33,7 @@ def visit_merge_request(current_user = nil) end it "doesn't display if related issues are already assigned" do - [issue1, issue2].each { |issue| issue.update!(assignee: user) } + [issue1, issue2].each { |issue| issue.update!(assignees: [user]) } visit_merge_request diff --git a/spec/features/milestones/show_spec.rb b/spec/features/milestones/show_spec.rb index 40b4dc6369734..227eb04ba7282 100644 --- a/spec/features/milestones/show_spec.rb +++ b/spec/features/milestones/show_spec.rb @@ -5,7 +5,7 @@ let(:project) { create(:empty_project) } let(:milestone) { create(:milestone, project: project) } let(:labels) { create_list(:label, 2, project: project) } - let(:issue_params) { { project: project, assignee: user, author: user, milestone: milestone, labels: labels } } + let(:issue_params) { { project: project, assignees: [user], author: user, milestone: milestone, labels: labels } } before do project.add_user(user, :developer) diff --git a/spec/features/projects/issuable_templates_spec.rb b/spec/features/projects/issuable_templates_spec.rb index d28a853bbc2fc..fa5e30075e305 100644 --- a/spec/features/projects/issuable_templates_spec.rb +++ b/spec/features/projects/issuable_templates_spec.rb @@ -12,7 +12,7 @@ context 'user creates an issue using templates' do let(:template_content) { 'this is a test "bug" template' } let(:longtemplate_content) { %Q(this\n\n\n\n\nis\n\n\n\n\na\n\n\n\n\nbug\n\n\n\n\ntemplate) } - let(:issue) { create(:issue, author: user, assignee: user, project: project) } + let(:issue) { create(:issue, author: user, assignees: [user], project: project) } let(:description_addition) { ' appending to description' } background do @@ -72,7 +72,7 @@ context 'user creates an issue using templates, with a prior description' do let(:prior_description) { 'test issue description' } let(:template_content) { 'this is a test "bug" template' } - let(:issue) { create(:issue, author: user, assignee: user, project: project) } + let(:issue) { create(:issue, author: user, assignees: [user], project: project) } background do project.repository.create_file( diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb index da6388dcdf20d..498a4a5cba04c 100644 --- a/spec/features/search_spec.rb +++ b/spec/features/search_spec.rb @@ -5,7 +5,7 @@ let(:user) { create(:user) } let(:project) { create(:empty_project, namespace: user.namespace) } - let!(:issue) { create(:issue, project: project, assignee: user) } + let!(:issue) { create(:issue, project: project, assignees: [user]) } let!(:issue2) { create(:issue, project: project, author: user) } before do diff --git a/spec/features/unsubscribe_links_spec.rb b/spec/features/unsubscribe_links_spec.rb index e2d9cfdd0b037..a23c4ca2b92f6 100644 --- a/spec/features/unsubscribe_links_spec.rb +++ b/spec/features/unsubscribe_links_spec.rb @@ -6,7 +6,7 @@ let(:recipient) { create(:user) } let(:author) { create(:user) } let(:project) { create(:empty_project, :public) } - let(:params) { { title: 'A bug!', description: 'Fix it!', assignee: recipient } } + let(:params) { { title: 'A bug!', description: 'Fix it!', assignees: [recipient] } } let(:issue) { Issues::CreateService.new(project, author, params).execute } let(:mail) { ActionMailer::Base.deliveries.last } diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index a5f717e623389..e6bf77aee6a61 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -1,19 +1,19 @@ require 'spec_helper' describe IssuesFinder do - set(:user) { create(:user) } - set(:user2) { create(:user) } - set(:project1) { create(:empty_project) } - set(:project2) { create(:empty_project) } - set(:milestone) { create(:milestone, project: project1) } - set(:label) { create(:label, project: project2) } - set(:issue1) { create(:issue, author: user, assignee: user, project: project1, milestone: milestone, title: 'gitlab') } - set(:issue2) { create(:issue, author: user, assignee: user, project: project2, description: 'gitlab') } - set(:issue3) { create(:issue, author: user2, assignee: user2, project: project2, title: 'tanuki', description: 'tanuki') } + let(:user) { create(:user) } + let(:user2) { create(:user) } + let(:project1) { create(:empty_project) } + let(:project2) { create(:empty_project) } + let(:milestone) { create(:milestone, project: project1) } + let(:label) { create(:label, project: project2) } + let(:issue1) { create(:issue, author: user, assignees: [user], project: project1, milestone: milestone, title: 'gitlab') } + let(:issue2) { create(:issue, author: user, assignees: [user], project: project2, description: 'gitlab') } + let(:issue3) { create(:issue, author: user2, assignees: [user2], project: project2, title: 'tanuki', description: 'tanuki') } describe '#execute' do - set(:closed_issue) { create(:issue, author: user2, assignee: user2, project: project2, state: 'closed') } - set(:label_link) { create(:label_link, label: label, target: issue2) } + let(:closed_issue) { create(:issue, author: user2, assignees: [user2], project: project2, state: 'closed') } + let!(:label_link) { create(:label_link, label: label, target: issue2) } let(:search_user) { user } let(:params) { {} } let(:issues) { described_class.new(search_user, params.reverse_merge(scope: scope, state: 'opened')).execute } @@ -91,7 +91,7 @@ before do milestones.each do |milestone| - create(:issue, project: milestone.project, milestone: milestone, author: user, assignee: user) + create(:issue, project: milestone.project, milestone: milestone, author: user, assignees: [user]) end end @@ -126,7 +126,7 @@ before do milestones.each do |milestone| - create(:issue, project: milestone.project, milestone: milestone, author: user, assignee: user) + create(:issue, project: milestone.project, milestone: milestone, author: user, assignees: [user]) end end diff --git a/spec/fixtures/api/schemas/issue.json b/spec/fixtures/api/schemas/issue.json index 21c078e0f44ec..983beb838b736 100644 --- a/spec/fixtures/api/schemas/issue.json +++ b/spec/fixtures/api/schemas/issue.json @@ -40,11 +40,31 @@ "additionalProperties": false } }, +<<<<<<< HEAD "assignee": { "id": { "type": "integet" }, "name": { "type": "string" }, "username": { "type": "string" }, "avatar_url": { "type": "uri" } +======= + "assignees": { + "type": "array", + "items": { + "type": ["object", "null"], + "required": [ + "id", + "name", + "username", + "avatar_url" + ], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "username": { "type": "string" }, + "avatar_url": { "type": "uri" } + } + } +>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master }, "subscribed": { "type": ["boolean", "null"] } }, diff --git a/spec/fixtures/api/schemas/public_api/v4/issues.json b/spec/fixtures/api/schemas/public_api/v4/issues.json index 52199e7573420..2d1c84ee93d21 100644 --- a/spec/fixtures/api/schemas/public_api/v4/issues.json +++ b/spec/fixtures/api/schemas/public_api/v4/issues.json @@ -33,6 +33,21 @@ }, "additionalProperties": false }, + "assignees": { + "type": "array", + "items": { + "type": ["object", "null"], + "properties": { + "name": { "type": "string" }, + "username": { "type": "string" }, + "id": { "type": "integer" }, + "state": { "type": "string" }, + "avatar_url": { "type": "uri" }, + "web_url": { "type": "uri" } + }, + "additionalProperties": false + } + }, "assignee": { "type": ["object", "null"], "properties": { @@ -67,7 +82,7 @@ "required": [ "id", "iid", "project_id", "title", "description", "state", "created_at", "updated_at", "labels", - "milestone", "assignee", "author", "user_notes_count", + "milestone", "assignees", "author", "user_notes_count", "upvotes", "downvotes", "due_date", "confidential", "web_url" ], diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index 93bb711f29ab3..c1ecb46aece84 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -4,6 +4,23 @@ let(:label) { build_stubbed(:label) } let(:label2) { build_stubbed(:label) } + describe '#users_dropdown_label' do + let(:user) { build_stubbed(:user) } + let(:user2) { build_stubbed(:user) } + + it 'returns unassigned' do + expect(users_dropdown_label([])).to eq('Unassigned') + end + + it 'returns selected user\'s name' do + expect(users_dropdown_label([user])).to eq(user.name) + end + + it 'returns selected user\'s name and counter' do + expect(users_dropdown_label([user, user2])).to eq("#{user.name} + 1 more") + end + end + describe '#issuable_labels_tooltip' do it 'returns label text' do expect(issuable_labels_tooltip([label])).to eq(label.title) diff --git a/spec/javascripts/boards/boards_store_spec.js b/spec/javascripts/boards/boards_store_spec.js index b55ff2f473a23..5ea160b7790c4 100644 --- a/spec/javascripts/boards/boards_store_spec.js +++ b/spec/javascripts/boards/boards_store_spec.js @@ -8,14 +8,14 @@ import Vue from 'vue'; import Cookies from 'js-cookie'; -require('~/lib/utils/url_utility'); -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/services/board_service'); -require('~/boards/stores/boards_store'); -require('./mock_data'); +import '~/lib/utils/url_utility'; +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/services/board_service'; +import '~/boards/stores/boards_store'; +import './mock_data'; describe('Store', () => { beforeEach(() => { @@ -212,7 +212,8 @@ describe('Store', () => { title: 'Testing', iid: 2, confidential: false, - labels: [] + labels: [], + assignees: [], }); const list = gl.issueBoards.BoardsStore.addList(listObj); diff --git a/spec/javascripts/boards/issue_card_spec.js b/spec/javascripts/boards/issue_card_spec.js index 1a5e9e9fd0794..b1907ac3070b4 100644 --- a/spec/javascripts/boards/issue_card_spec.js +++ b/spec/javascripts/boards/issue_card_spec.js @@ -1,20 +1,20 @@ -/* global ListUser */ +/* global ListAssignee */ /* global ListLabel */ /* global listObj */ /* global ListIssue */ import Vue from 'vue'; -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/stores/boards_store'); -require('~/boards/components/issue_card_inner'); -require('./mock_data'); +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/stores/boards_store'; +import '~/boards/components/issue_card_inner'; +import './mock_data'; describe('Issue card component', () => { - const user = new ListUser({ + const user = new ListAssignee({ id: 1, name: 'testing 123', username: 'test', @@ -40,6 +40,7 @@ describe('Issue card component', () => { iid: 1, confidential: false, labels: [list.label], + assignees: [], }); component = new Vue({ @@ -92,12 +93,12 @@ describe('Issue card component', () => { it('renders confidential icon', (done) => { component.issue.confidential = true; - setTimeout(() => { + Vue.nextTick(() => { expect( component.$el.querySelector('.confidential-icon'), ).not.toBeNull(); done(); - }, 0); + }); }); it('renders issue ID with #', () => { @@ -109,34 +110,32 @@ describe('Issue card component', () => { describe('assignee', () => { it('does not render assignee', () => { expect( - component.$el.querySelector('.card-assignee'), + component.$el.querySelector('.card-assignee .avatar'), ).toBeNull(); }); describe('exists', () => { beforeEach((done) => { - component.issue.assignee = user; + component.issue.assignees = [user]; - setTimeout(() => { - done(); - }, 0); + Vue.nextTick(() => done()); }); it('renders assignee', () => { expect( - component.$el.querySelector('.card-assignee'), + component.$el.querySelector('.card-assignee .avatar'), ).not.toBeNull(); }); it('sets title', () => { expect( - component.$el.querySelector('.card-assignee').getAttribute('title'), + component.$el.querySelector('.card-assignee a').getAttribute('title'), ).toContain(`Assigned to ${user.name}`); }); it('sets users path', () => { expect( - component.$el.querySelector('.card-assignee').getAttribute('href'), + component.$el.querySelector('.card-assignee a').getAttribute('href'), ).toBe('/test'); }); @@ -148,6 +147,75 @@ describe('Issue card component', () => { }); }); + describe('multiple assignees', () => { + beforeEach((done) => { + component.issue.assignees = [ + user, + new ListAssignee({ + id: 2, + name: 'user2', + username: 'user2', + avatar: 'test_image', + }), + new ListAssignee({ + id: 3, + name: 'user3', + username: 'user3', + avatar: 'test_image', + }), + new ListAssignee({ + id: 4, + name: 'user4', + username: 'user4', + avatar: 'test_image', + })]; + + Vue.nextTick(() => done()); + }); + + it('renders all four assignees', () => { + expect(component.$el.querySelectorAll('.card-assignee .avatar').length).toEqual(4); + }); + + describe('more than four assignees', () => { + beforeEach((done) => { + component.issue.assignees.push(new ListAssignee({ + id: 5, + name: 'user5', + username: 'user5', + avatar: 'test_image', + })); + + Vue.nextTick(() => done()); + }); + + it('renders more avatar counter', () => { + expect(component.$el.querySelector('.card-assignee .avatar-counter').innerText).toEqual('+2'); + }); + + it('renders three assignees', () => { + expect(component.$el.querySelectorAll('.card-assignee .avatar').length).toEqual(3); + }); + + it('renders 99+ avatar counter', (done) => { + for (let i = 5; i < 104; i += 1) { + const u = new ListAssignee({ + id: i, + name: 'name', + username: 'username', + avatar: 'test_image', + }); + component.issue.assignees.push(u); + } + + Vue.nextTick(() => { + expect(component.$el.querySelector('.card-assignee .avatar-counter').innerText).toEqual('99+'); + done(); + }); + }); + }); + }); + describe('labels', () => { it('does not render any', () => { expect( @@ -159,9 +227,7 @@ describe('Issue card component', () => { beforeEach((done) => { component.issue.addLabel(label1); - setTimeout(() => { - done(); - }, 0); + Vue.nextTick(() => done()); }); it('does not render list label', () => { diff --git a/spec/javascripts/boards/issue_spec.js b/spec/javascripts/boards/issue_spec.js index c96dfe94a4a5d..cd1497bc5e615 100644 --- a/spec/javascripts/boards/issue_spec.js +++ b/spec/javascripts/boards/issue_spec.js @@ -2,14 +2,15 @@ /* global BoardService */ /* global ListIssue */ -require('~/lib/utils/url_utility'); -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/services/board_service'); -require('~/boards/stores/boards_store'); -require('./mock_data'); +import Vue from 'vue'; +import '~/lib/utils/url_utility'; +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/services/board_service'; +import '~/boards/stores/boards_store'; +import './mock_data'; describe('Issue model', () => { let issue; @@ -27,7 +28,13 @@ describe('Issue model', () => { title: 'test', color: 'red', description: 'testing' - }] + }], + assignees: [{ + id: 1, + name: 'name', + username: 'username', + avatar_url: 'http://avatar_url', + }], }); }); @@ -80,6 +87,33 @@ describe('Issue model', () => { expect(issue.labels.length).toBe(0); }); + it('adds assignee', () => { + issue.addAssignee({ + id: 2, + name: 'Bruce Wayne', + username: 'batman', + avatar_url: 'http://batman', + }); + + expect(issue.assignees.length).toBe(2); + }); + + it('finds assignee', () => { + const assignee = issue.findAssignee(issue.assignees[0]); + expect(assignee).toBeDefined(); + }); + + it('removes assignee', () => { + const assignee = issue.findAssignee(issue.assignees[0]); + issue.removeAssignee(assignee); + expect(issue.assignees.length).toBe(0); + }); + + it('removes all assignees', () => { + issue.removeAllAssignees(); + expect(issue.assignees.length).toBe(0); + }); + it('sets position to infinity if no position is stored', () => { expect(issue.position).toBe(Infinity); }); @@ -90,9 +124,31 @@ describe('Issue model', () => { iid: 1, confidential: false, relative_position: 1, - labels: [] + labels: [], + assignees: [], }); expect(relativePositionIssue.position).toBe(1); }); + + describe('update', () => { + it('passes assignee ids when there are assignees', (done) => { + spyOn(Vue.http, 'patch').and.callFake((url, data) => { + expect(data.issue.assignee_ids).toEqual([1]); + done(); + }); + + issue.update('url'); + }); + + it('passes assignee ids of [0] when there are no assignees', (done) => { + spyOn(Vue.http, 'patch').and.callFake((url, data) => { + expect(data.issue.assignee_ids).toEqual([0]); + done(); + }); + + issue.removeAllAssignees(); + issue.update('url'); + }); + }); }); diff --git a/spec/javascripts/boards/list_spec.js b/spec/javascripts/boards/list_spec.js index 24a2da9f6b629..88a0e6855f4fc 100644 --- a/spec/javascripts/boards/list_spec.js +++ b/spec/javascripts/boards/list_spec.js @@ -8,14 +8,14 @@ import Vue from 'vue'; -require('~/lib/utils/url_utility'); -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/services/board_service'); -require('~/boards/stores/boards_store'); -require('./mock_data'); +import '~/lib/utils/url_utility'; +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/services/board_service'; +import '~/boards/stores/boards_store'; +import './mock_data'; describe('List model', () => { let list; @@ -94,7 +94,8 @@ describe('List model', () => { title: 'Testing', iid: _.random(10000), confidential: false, - labels: [list.label, listDup.label] + labels: [list.label, listDup.label], + assignees: [], }); list.issues.push(issue); diff --git a/spec/javascripts/boards/mock_data.js b/spec/javascripts/boards/mock_data.js index a4fa694eebec2..a64c3964ee3e3 100644 --- a/spec/javascripts/boards/mock_data.js +++ b/spec/javascripts/boards/mock_data.js @@ -33,7 +33,8 @@ const BoardsMockData = { title: 'Testing', iid: 1, confidential: false, - labels: [] + labels: [], + assignees: [], }], size: 1 } diff --git a/spec/javascripts/boards/modal_store_spec.js b/spec/javascripts/boards/modal_store_spec.js index 80db816aff852..32e6d04df9fed 100644 --- a/spec/javascripts/boards/modal_store_spec.js +++ b/spec/javascripts/boards/modal_store_spec.js @@ -1,10 +1,10 @@ /* global ListIssue */ -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/stores/modal_store'); +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/stores/modal_store'; describe('Modal store', () => { let issue; @@ -21,12 +21,14 @@ describe('Modal store', () => { iid: 1, confidential: false, labels: [], + assignees: [], }); issue2 = new ListIssue({ title: 'Testing', iid: 2, confidential: false, labels: [], + assignees: [], }); Store.store.issues.push(issue); Store.store.issues.push(issue2); diff --git a/spec/javascripts/issuable_time_tracker_spec.js b/spec/javascripts/issuable_time_tracker_spec.js index 0a830f25e29ed..8ff93c4f918d2 100644 --- a/spec/javascripts/issuable_time_tracker_spec.js +++ b/spec/javascripts/issuable_time_tracker_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; -require('~/issuable/time_tracking/components/time_tracker'); +import timeTracker from '~/sidebar/components/time_tracking/time_tracker'; function initTimeTrackingComponent(opts) { setFixtures(` @@ -16,187 +16,185 @@ function initTimeTrackingComponent(opts) { time_spent: opts.timeSpent, human_time_estimate: opts.timeEstimateHumanReadable, human_time_spent: opts.timeSpentHumanReadable, - docsUrl: '/help/workflow/time_tracking.md', + rootPath: '/', }; - const TimeTrackingComponent = Vue.component('issuable-time-tracker'); + const TimeTrackingComponent = Vue.extend(timeTracker); this.timeTracker = new TimeTrackingComponent({ el: '#mock-container', propsData: this.initialData, }); } -((gl) => { - describe('Issuable Time Tracker', function() { - describe('Initialization', function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' }); - }); +describe('Issuable Time Tracker', function() { + describe('Initialization', function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' }); + }); - it('should return something defined', function() { - expect(this.timeTracker).toBeDefined(); - }); + it('should return something defined', function() { + expect(this.timeTracker).toBeDefined(); + }); - it ('should correctly set timeEstimate', function(done) { - Vue.nextTick(() => { - expect(this.timeTracker.timeEstimate).toBe(this.initialData.time_estimate); - done(); - }); + it ('should correctly set timeEstimate', function(done) { + Vue.nextTick(() => { + expect(this.timeTracker.timeEstimate).toBe(this.initialData.time_estimate); + done(); }); - it ('should correctly set time_spent', function(done) { - Vue.nextTick(() => { - expect(this.timeTracker.timeSpent).toBe(this.initialData.time_spent); - done(); - }); + }); + it ('should correctly set time_spent', function(done) { + Vue.nextTick(() => { + expect(this.timeTracker.timeSpent).toBe(this.initialData.time_spent); + done(); }); }); + }); - describe('Content Display', function() { - describe('Panes', function() { - describe('Comparison pane', function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '', timeSpentHumanReadable: '' }); + describe('Content Display', function() { + describe('Panes', function() { + describe('Comparison pane', function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '', timeSpentHumanReadable: '' }); + }); + + it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', function(done) { + Vue.nextTick(() => { + const $comparisonPane = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane'); + expect(this.timeTracker.showComparisonState).toBe(true); + done(); }); + }); - it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', function(done) { + describe('Remaining meter', function() { + it('should display the remaining meter with the correct width', function(done) { Vue.nextTick(() => { - const $comparisonPane = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane'); - expect(this.timeTracker.showComparisonState).toBe(true); + const meterWidth = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane .meter-fill').style.width; + const correctWidth = '5%'; + + expect(meterWidth).toBe(correctWidth); done(); - }); + }) }); - describe('Remaining meter', function() { - it('should display the remaining meter with the correct width', function(done) { - Vue.nextTick(() => { - const meterWidth = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane .meter-fill').style.width; - const correctWidth = '5%'; - - expect(meterWidth).toBe(correctWidth); - done(); - }) - }); - - it('should display the remaining meter with the correct background color when within estimate', function(done) { - Vue.nextTick(() => { - const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .within_estimate .meter-fill'); - expect(styledMeter.length).toBe(1); - done() - }); + it('should display the remaining meter with the correct background color when within estimate', function(done) { + Vue.nextTick(() => { + const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .within_estimate .meter-fill'); + expect(styledMeter.length).toBe(1); + done() }); + }); - it('should display the remaining meter with the correct background color when over estimate', function(done) { - this.timeTracker.time_estimate = 100000; - this.timeTracker.time_spent = 20000000; - Vue.nextTick(() => { - const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .over_estimate .meter-fill'); - expect(styledMeter.length).toBe(1); - done(); - }); + it('should display the remaining meter with the correct background color when over estimate', function(done) { + this.timeTracker.time_estimate = 100000; + this.timeTracker.time_spent = 20000000; + Vue.nextTick(() => { + const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .over_estimate .meter-fill'); + expect(styledMeter.length).toBe(1); + done(); }); }); }); + }); - describe("Estimate only pane", function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 0, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '' }); - }); + describe("Estimate only pane", function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 0, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '' }); + }); - it('should display the human readable version of time estimated', function(done) { - Vue.nextTick(() => { - const estimateText = this.timeTracker.$el.querySelector('.time-tracking-estimate-only-pane').innerText; - const correctText = 'Estimated: 2h 46m'; + it('should display the human readable version of time estimated', function(done) { + Vue.nextTick(() => { + const estimateText = this.timeTracker.$el.querySelector('.time-tracking-estimate-only-pane').innerText; + const correctText = 'Estimated: 2h 46m'; - expect(estimateText).toBe(correctText); - done(); - }); + expect(estimateText).toBe(correctText); + done(); }); }); + }); - describe('Spent only pane', function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' }); - }); + describe('Spent only pane', function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' }); + }); - it('should display the human readable version of time spent', function(done) { - Vue.nextTick(() => { - const spentText = this.timeTracker.$el.querySelector('.time-tracking-spend-only-pane').innerText; - const correctText = 'Spent: 1h 23m'; + it('should display the human readable version of time spent', function(done) { + Vue.nextTick(() => { + const spentText = this.timeTracker.$el.querySelector('.time-tracking-spend-only-pane').innerText; + const correctText = 'Spent: 1h 23m'; - expect(spentText).toBe(correctText); - done(); - }); + expect(spentText).toBe(correctText); + done(); }); }); + }); - describe('No time tracking pane', function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0, timeEstimateHumanReadable: 0, timeSpentHumanReadable: 0 }); - }); + describe('No time tracking pane', function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0, timeEstimateHumanReadable: '', timeSpentHumanReadable: '' }); + }); - it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', function(done) { - Vue.nextTick(() => { - const $noTrackingPane = this.timeTracker.$el.querySelector('.time-tracking-no-tracking-pane'); - const noTrackingText =$noTrackingPane.innerText; - const correctText = 'No estimate or time spent'; + it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', function(done) { + Vue.nextTick(() => { + const $noTrackingPane = this.timeTracker.$el.querySelector('.time-tracking-no-tracking-pane'); + const noTrackingText =$noTrackingPane.innerText; + const correctText = 'No estimate or time spent'; - expect(this.timeTracker.showNoTimeTrackingState).toBe(true); - expect($noTrackingPane).toBeVisible(); - expect(noTrackingText).toBe(correctText); - done(); - }); + expect(this.timeTracker.showNoTimeTrackingState).toBe(true); + expect($noTrackingPane).toBeVisible(); + expect(noTrackingText).toBe(correctText); + done(); }); }); + }); - describe("Help pane", function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0 }); - }); + describe("Help pane", function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0 }); + }); - it('should not show the "Help" pane by default', function(done) { - Vue.nextTick(() => { - const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); + it('should not show the "Help" pane by default', function(done) { + Vue.nextTick(() => { + const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); - expect(this.timeTracker.showHelpState).toBe(false); - expect($helpPane).toBeNull(); - done(); - }); + expect(this.timeTracker.showHelpState).toBe(false); + expect($helpPane).toBeNull(); + done(); }); + }); - it('should show the "Help" pane when help button is clicked', function(done) { - Vue.nextTick(() => { - $(this.timeTracker.$el).find('.help-button').click(); + it('should show the "Help" pane when help button is clicked', function(done) { + Vue.nextTick(() => { + $(this.timeTracker.$el).find('.help-button').click(); - setTimeout(() => { - const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); - expect(this.timeTracker.showHelpState).toBe(true); - expect($helpPane).toBeVisible(); - done(); - }, 10); - }); + setTimeout(() => { + const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); + expect(this.timeTracker.showHelpState).toBe(true); + expect($helpPane).toBeVisible(); + done(); + }, 10); }); + }); - it('should not show the "Help" pane when help button is clicked and then closed', function(done) { - Vue.nextTick(() => { - $(this.timeTracker.$el).find('.help-button').click(); + it('should not show the "Help" pane when help button is clicked and then closed', function(done) { + Vue.nextTick(() => { + $(this.timeTracker.$el).find('.help-button').click(); - setTimeout(() => { + setTimeout(() => { - $(this.timeTracker.$el).find('.close-help-button').click(); + $(this.timeTracker.$el).find('.close-help-button').click(); - setTimeout(() => { - const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); + setTimeout(() => { + const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); - expect(this.timeTracker.showHelpState).toBe(false); - expect($helpPane).toBeNull(); + expect(this.timeTracker.showHelpState).toBe(false); + expect($helpPane).toBeNull(); - done(); - }, 1000); + done(); }, 1000); - }); + }, 1000); }); }); }); }); }); -})(window.gl || (window.gl = {})); +}); diff --git a/spec/javascripts/subbable_resource_spec.js b/spec/javascripts/subbable_resource_spec.js deleted file mode 100644 index 454386697f54f..0000000000000 --- a/spec/javascripts/subbable_resource_spec.js +++ /dev/null @@ -1,63 +0,0 @@ -/* eslint-disable max-len, arrow-parens, comma-dangle */ - -require('~/subbable_resource'); - -/* -* Test that each rest verb calls the publish and subscribe function and passes the correct value back -* -* -* */ -((global) => { - describe('Subbable Resource', function () { - describe('PubSub', function () { - beforeEach(function () { - this.MockResource = new global.SubbableResource('https://example.com'); - }); - it('should successfully add a single subscriber', function () { - const callback = () => {}; - this.MockResource.subscribe(callback); - - expect(this.MockResource.subscribers.length).toBe(1); - expect(this.MockResource.subscribers[0]).toBe(callback); - }); - - it('should successfully add multiple subscribers', function () { - const callbackOne = () => {}; - const callbackTwo = () => {}; - const callbackThree = () => {}; - - this.MockResource.subscribe(callbackOne); - this.MockResource.subscribe(callbackTwo); - this.MockResource.subscribe(callbackThree); - - expect(this.MockResource.subscribers.length).toBe(3); - }); - - it('should successfully publish an update to a single subscriber', function () { - const state = { myprop: 1 }; - - const callbacks = { - one: (data) => expect(data.myprop).toBe(2), - two: (data) => expect(data.myprop).toBe(2), - three: (data) => expect(data.myprop).toBe(2) - }; - - const spyOne = spyOn(callbacks, 'one'); - const spyTwo = spyOn(callbacks, 'two'); - const spyThree = spyOn(callbacks, 'three'); - - this.MockResource.subscribe(callbacks.one); - this.MockResource.subscribe(callbacks.two); - this.MockResource.subscribe(callbacks.three); - - state.myprop += 1; - - this.MockResource.publish(state); - - expect(spyOne).toHaveBeenCalled(); - expect(spyTwo).toHaveBeenCalled(); - expect(spyThree).toHaveBeenCalled(); - }); - }); - }); -})(window.gl || (window.gl = {})); diff --git a/spec/lib/banzai/filter/redactor_filter_spec.rb b/spec/lib/banzai/filter/redactor_filter_spec.rb index 8a6fe1ad6a39d..7c4a0f32c7b73 100644 --- a/spec/lib/banzai/filter/redactor_filter_spec.rb +++ b/spec/lib/banzai/filter/redactor_filter_spec.rb @@ -113,7 +113,7 @@ def reference_link(data) it 'allows references for assignee' do assignee = create(:user) project = create(:empty_project, :public) - issue = create(:issue, :confidential, project: project, assignee: assignee) + issue = create(:issue, :confidential, project: project, assignees: [assignee]) link = reference_link(project: project.id, issue: issue.id, reference_type: 'issue') doc = filter(link, current_user: assignee) diff --git a/spec/lib/gitlab/github_import/issue_formatter_spec.rb b/spec/lib/gitlab/github_import/issue_formatter_spec.rb index f34d09f2c1d9a..a4089592cf215 100644 --- a/spec/lib/gitlab/github_import/issue_formatter_spec.rb +++ b/spec/lib/gitlab/github_import/issue_formatter_spec.rb @@ -43,7 +43,7 @@ description: "*Created by: octocat*\n\nI'm having a problem with this.", state: 'opened', author_id: project.creator_id, - assignee_id: nil, + assignee_ids: [], created_at: created_at, updated_at: updated_at } @@ -64,7 +64,7 @@ description: "*Created by: octocat*\n\nI'm having a problem with this.", state: 'closed', author_id: project.creator_id, - assignee_id: nil, + assignee_ids: [], created_at: created_at, updated_at: updated_at } @@ -77,19 +77,19 @@ let(:raw_data) { double(base_data.merge(assignee: octocat)) } it 'returns nil as assignee_id when is not a GitLab user' do - expect(issue.attributes.fetch(:assignee_id)).to be_nil + expect(issue.attributes.fetch(:assignee_ids)).to be_empty end it 'returns GitLab user id associated with GitHub id as assignee_id' do gl_user = create(:omniauth_user, extern_uid: octocat.id, provider: 'github') - expect(issue.attributes.fetch(:assignee_id)).to eq gl_user.id + expect(issue.attributes.fetch(:assignee_ids)).to eq [gl_user.id] end it 'returns GitLab user id associated with GitHub email as assignee_id' do gl_user = create(:user, email: octocat.email) - expect(issue.attributes.fetch(:assignee_id)).to eq gl_user.id + expect(issue.attributes.fetch(:assignee_ids)).to eq [gl_user.id] end end diff --git a/spec/lib/gitlab/google_code_import/importer_spec.rb b/spec/lib/gitlab/google_code_import/importer_spec.rb index ccaa88a5c798f..622a0f513f43a 100644 --- a/spec/lib/gitlab/google_code_import/importer_spec.rb +++ b/spec/lib/gitlab/google_code_import/importer_spec.rb @@ -49,7 +49,7 @@ expect(issue).not_to be_nil expect(issue.iid).to eq(169) expect(issue.author).to eq(project.creator) - expect(issue.assignee).to eq(mapped_user) + expect(issue.assignees).to eq([mapped_user]) expect(issue.state).to eq("closed") expect(issue.label_names).to include("Priority: Medium") expect(issue.label_names).to include("Status: Fixed") diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 0abf89d060cec..cd2fa27bb9f88 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -3,7 +3,7 @@ issues: - subscriptions - award_emoji - author -- assignee +- assignees - updated_by - milestone - notes @@ -16,6 +16,7 @@ issues: - merge_requests_closing_issues - metrics - timelogs +- issue_assignees events: - author - project diff --git a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb index 1035428b2e7d3..5aeb29b7fecb7 100644 --- a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb @@ -203,7 +203,7 @@ end def setup_project - issue = create(:issue, assignee: user) + issue = create(:issue, assignees: [user]) snippet = create(:project_snippet) release = create(:release) group = create(:group) diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index e0ebea63eb40e..a7c8e7f1f5708 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -89,7 +89,7 @@ let(:project) { create(:empty_project, :internal) } let!(:issue) { create(:issue, project: project, title: 'Issue 1') } let!(:security_issue_1) { create(:issue, :confidential, project: project, title: 'Security issue 1', author: author) } - let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignee: assignee) } + let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignees: [assignee]) } it 'does not list project confidential issues for non project members' do results = described_class.new(non_member, project, query) diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb index 847fb97740015..31c3cd4d53c8e 100644 --- a/spec/lib/gitlab/search_results_spec.rb +++ b/spec/lib/gitlab/search_results_spec.rb @@ -72,9 +72,9 @@ let(:admin) { create(:admin) } let!(:issue) { create(:issue, project: project_1, title: 'Issue 1') } let!(:security_issue_1) { create(:issue, :confidential, project: project_1, title: 'Security issue 1', author: author) } - let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project_1, assignee: assignee) } + let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project_1, assignees: [assignee]) } let!(:security_issue_3) { create(:issue, :confidential, project: project_2, title: 'Security issue 3', author: author) } - let!(:security_issue_4) { create(:issue, :confidential, project: project_3, title: 'Security issue 4', assignee: assignee) } + let!(:security_issue_4) { create(:issue, :confidential, project: project_3, title: 'Security issue 4', assignees: [assignee]) } let!(:security_issue_5) { create(:issue, :confidential, project: project_4, title: 'Security issue 5') } it 'does not list confidential issues for non project members' do diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 9f12e40d808e5..1e6260270fe31 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -36,11 +36,11 @@ def have_referable_subject(referable, reply: false) end context 'for issues' do - let(:issue) { create(:issue, author: current_user, assignee: assignee, project: project) } - let(:issue_with_description) { create(:issue, author: current_user, assignee: assignee, project: project, description: 'My awesome description') } + let(:issue) { create(:issue, author: current_user, assignees: [assignee], project: project) } + let(:issue_with_description) { create(:issue, author: current_user, assignees: [assignee], project: project, description: 'My awesome description') } describe 'that are new' do - subject { described_class.new_issue_email(issue.assignee_id, issue.id) } + subject { described_class.new_issue_email(issue.assignees.first.id, issue.id) } it_behaves_like 'an assignee email' it_behaves_like 'an email starting a new thread with reply-by-email enabled' do @@ -69,7 +69,7 @@ def have_referable_subject(referable, reply: false) end describe 'that are new with a description' do - subject { described_class.new_issue_email(issue_with_description.assignee_id, issue_with_description.id) } + subject { described_class.new_issue_email(issue_with_description.assignees.first.id, issue_with_description.id) } it_behaves_like 'it should show Gmail Actions View Issue link' @@ -79,7 +79,7 @@ def have_referable_subject(referable, reply: false) end describe 'that have been reassigned' do - subject { described_class.reassigned_issue_email(recipient.id, issue.id, previous_assignee.id, current_user.id) } + subject { described_class.reassigned_issue_email(recipient.id, issue.id, [previous_assignee.id], current_user.id) } it_behaves_like 'a multiple recipients email' it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index 3ecba2e96870b..27890e33b49d8 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -10,7 +10,6 @@ it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:author) } - it { is_expected.to belong_to(:assignee) } it { is_expected.to have_many(:notes).dependent(:destroy) } it { is_expected.to have_many(:todos).dependent(:destroy) } @@ -66,60 +65,6 @@ end end - describe 'assignee_name' do - it 'is delegated to assignee' do - issue.update!(assignee: create(:user)) - - expect(issue.assignee_name).to eq issue.assignee.name - end - - it 'returns nil when assignee is nil' do - issue.assignee_id = nil - issue.save(validate: false) - - expect(issue.assignee_name).to eq nil - end - end - - describe "before_save" do - describe "#update_cache_counts" do - context "when previous assignee exists" do - before do - assignee = create(:user) - issue.project.team << [assignee, :developer] - issue.update(assignee: assignee) - end - - it "updates cache counts for new assignee" do - user = create(:user) - - expect(user).to receive(:update_cache_counts) - - issue.update(assignee: user) - end - - it "updates cache counts for previous assignee" do - old_assignee = issue.assignee - allow(User).to receive(:find_by_id).with(old_assignee.id).and_return(old_assignee) - - expect(old_assignee).to receive(:update_cache_counts) - - issue.update(assignee: nil) - end - end - - context "when previous assignee does not exist" do - before{ issue.update(assignee: nil) } - - it "updates cache count for the new assignee" do - expect_any_instance_of(User).to receive(:update_cache_counts) - - issue.update(assignee: user) - end - end - end - end - describe ".search" do let!(:searchable_issue) { create(:issue, title: "Searchable issue") } @@ -307,7 +252,20 @@ end context "issue is assigned" do - before { issue.update_attribute(:assignee, user) } + before { issue.assignees << user } + + it "returns correct hook data" do + expect(data[:assignees].first).to eq(user.hook_attrs) + end + end + + context "merge_request is assigned" do + let(:merge_request) { create(:merge_request) } + let(:data) { merge_request.to_hook_data(user) } + + before do + merge_request.update_attribute(:assignee, user) + end it "returns correct hook data" do expect(data[:object_attributes]['assignee_id']).to eq(user.id) @@ -329,24 +287,6 @@ include_examples 'deprecated repository hook data' end - describe '#card_attributes' do - it 'includes the author name' do - allow(issue).to receive(:author).and_return(double(name: 'Robert')) - allow(issue).to receive(:assignee).and_return(nil) - - expect(issue.card_attributes). - to eq({ 'Author' => 'Robert', 'Assignee' => nil }) - end - - it 'includes the assignee name' do - allow(issue).to receive(:author).and_return(double(name: 'Robert')) - allow(issue).to receive(:assignee).and_return(double(name: 'Douwe')) - - expect(issue.card_attributes). - to eq({ 'Author' => 'Robert', 'Assignee' => 'Douwe' }) - end - end - describe '#labels_array' do let(:project) { create(:empty_project) } let(:bug) { create(:label, project: project, title: 'bug') } @@ -475,27 +415,6 @@ def create_issue(milestone, labels) end end - describe '#assignee_or_author?' do - let(:user) { build(:user, id: 1) } - let(:issue) { build(:issue) } - - it 'returns true for a user that is assigned to an issue' do - issue.assignee = user - - expect(issue.assignee_or_author?(user)).to eq(true) - end - - it 'returns true for a user that is the author of an issue' do - issue.author = user - - expect(issue.assignee_or_author?(user)).to eq(true) - end - - it 'returns false for a user that is not the assignee or author' do - expect(issue.assignee_or_author?(user)).to eq(false) - end - end - describe '#spend_time' do let(:user) { create(:user) } let(:issue) { create(:issue) } diff --git a/spec/models/concerns/milestoneish_spec.rb b/spec/models/concerns/milestoneish_spec.rb index 68e4c0a522bc8..675b730c5575a 100644 --- a/spec/models/concerns/milestoneish_spec.rb +++ b/spec/models/concerns/milestoneish_spec.rb @@ -11,13 +11,13 @@ let(:milestone) { create(:milestone, project: project) } let!(:issue) { create(:issue, project: project, milestone: milestone) } let!(:security_issue_1) { create(:issue, :confidential, project: project, author: author, milestone: milestone) } - let!(:security_issue_2) { create(:issue, :confidential, project: project, assignee: assignee, milestone: milestone) } + let!(:security_issue_2) { create(:issue, :confidential, project: project, assignees: [assignee], milestone: milestone) } let!(:closed_issue_1) { create(:issue, :closed, project: project, milestone: milestone) } let!(:closed_issue_2) { create(:issue, :closed, project: project, milestone: milestone) } let!(:closed_security_issue_1) { create(:issue, :confidential, :closed, project: project, author: author, milestone: milestone) } - let!(:closed_security_issue_2) { create(:issue, :confidential, :closed, project: project, assignee: assignee, milestone: milestone) } + let!(:closed_security_issue_2) { create(:issue, :confidential, :closed, project: project, assignees: [assignee], milestone: milestone) } let!(:closed_security_issue_3) { create(:issue, :confidential, :closed, project: project, author: author, milestone: milestone) } - let!(:closed_security_issue_4) { create(:issue, :confidential, :closed, project: project, assignee: assignee, milestone: milestone) } + let!(:closed_security_issue_4) { create(:issue, :confidential, :closed, project: project, assignees: [assignee], milestone: milestone) } let!(:merge_request) { create(:merge_request, source_project: project, target_project: project, milestone: milestone) } before do diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index 8c90a538f5723..e5954d80f00aa 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -92,8 +92,8 @@ let(:author) { create(:author) } let(:assignee) { create(:user) } let(:admin) { create(:admin) } - let(:issue) { create(:issue, project: project, author: author, assignee: assignee) } - let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee) } + let(:issue) { create(:issue, project: project, author: author, assignees: [assignee]) } + let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee]) } let(:note_on_commit) { create(:note_on_commit, project: project) } let(:note_on_issue) { create(:note_on_issue, noteable: issue, project: project) } let(:note_on_confidential_issue) { create(:note_on_issue, noteable: confidential_issue, project: project) } diff --git a/spec/models/issue_collection_spec.rb b/spec/models/issue_collection_spec.rb index d8aed25c041f5..93c2c538e1056 100644 --- a/spec/models/issue_collection_spec.rb +++ b/spec/models/issue_collection_spec.rb @@ -28,7 +28,7 @@ end it 'returns the issues the user is assigned to' do - issue1.assignee = user + issue1.assignees << user expect(collection.updatable_by_user(user)).to eq([issue1]) end diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 8748b98a4e357..725f5c2311fd3 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -3,6 +3,7 @@ describe Issue, models: true do describe "Associations" do it { is_expected.to belong_to(:milestone) } + it { is_expected.to have_many(:assignees) } end describe 'modules' do @@ -37,6 +38,64 @@ end end + describe "before_save" do + describe "#update_cache_counts when an issue is reassigned" do + let(:issue) { create(:issue) } + let(:assignee) { create(:user) } + + context "when previous assignee exists" do + before do + issue.project.team << [assignee, :developer] + issue.assignees << assignee + end + + it "updates cache counts for new assignee" do + user = create(:user) + + expect(user).to receive(:update_cache_counts) + + issue.assignees << user + end + + it "updates cache counts for previous assignee" do + issue.assignees.first + + expect_any_instance_of(User).to receive(:update_cache_counts) + + issue.assignees.destroy_all + end + end + + context "when previous assignee does not exist" do + it "updates cache count for the new assignee" do + issue.assignees = [] + + expect_any_instance_of(User).to receive(:update_cache_counts) + + issue.assignees << assignee + end + end + end + end + + describe '#card_attributes' do + it 'includes the author name' do + allow(subject).to receive(:author).and_return(double(name: 'Robert')) + allow(subject).to receive(:assignees).and_return([]) + + expect(subject.card_attributes). + to eq({ 'Author' => 'Robert', 'Assignee' => '' }) + end + + it 'includes the assignee name' do + allow(subject).to receive(:author).and_return(double(name: 'Robert')) + allow(subject).to receive(:assignees).and_return([double(name: 'Douwe')]) + + expect(subject.card_attributes). + to eq({ 'Author' => 'Robert', 'Assignee' => 'Douwe' }) + end + end + describe '#closed_at' do after do Timecop.return @@ -124,13 +183,24 @@ end end - describe '#is_being_reassigned?' do - it 'returns true if the issue assignee has changed' do - subject.assignee = create(:user) - expect(subject.is_being_reassigned?).to be_truthy + describe '#assignee_or_author?' do + let(:user) { create(:user) } + let(:issue) { create(:issue) } + + it 'returns true for a user that is assigned to an issue' do + issue.assignees << user + + expect(issue.assignee_or_author?(user)).to be_truthy end - it 'returns false if the issue assignee has not changed' do - expect(subject.is_being_reassigned?).to be_falsey + + it 'returns true for a user that is the author of an issue' do + issue.update(author: user) + + expect(issue.assignee_or_author?(user)).to be_truthy + end + + it 'returns false for a user that is not the assignee or author' do + expect(issue.assignee_or_author?(user)).to be_falsey end end @@ -383,14 +453,14 @@ user1 = create(:user) user2 = create(:user) project = create(:empty_project) - issue = create(:issue, assignee: user1, project: project) + issue = create(:issue, assignees: [user1], project: project) project.add_developer(user1) project.add_developer(user2) expect(user1.assigned_open_issues_count).to eq(1) expect(user2.assigned_open_issues_count).to eq(0) - issue.assignee = user2 + issue.assignees = [user2] issue.save expect(user1.assigned_open_issues_count).to eq(0) @@ -676,6 +746,11 @@ expect(attrs_hash).to include(:human_total_time_spent) expect(attrs_hash).to include('time_estimate') end + + it 'includes assignee_ids and deprecated assignee_id' do + expect(attrs_hash).to include(:assignee_id) + expect(attrs_hash).to include(:assignee_ids) + end end describe '#check_for_spam' do diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 8b72125dd5d1c..6cf3dd30ead92 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -9,6 +9,7 @@ it { is_expected.to belong_to(:target_project).class_name('Project') } it { is_expected.to belong_to(:source_project).class_name('Project') } it { is_expected.to belong_to(:merge_user).class_name("User") } + it { is_expected.to belong_to(:assignee) } it { is_expected.to have_many(:merge_request_diffs).dependent(:destroy) } end @@ -86,6 +87,86 @@ end end + describe "before_save" do + describe "#update_cache_counts when a merge request is reassigned" do + let(:project) { create :project } + let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } + let(:assignee) { create :user } + + context "when previous assignee exists" do + before do + project.team << [assignee, :developer] + merge_request.update(assignee: assignee) + end + + it "updates cache counts for new assignee" do + user = create(:user) + + expect(user).to receive(:update_cache_counts) + + merge_request.update(assignee: user) + end + + it "updates cache counts for previous assignee" do + old_assignee = merge_request.assignee + allow(User).to receive(:find_by_id).with(old_assignee.id).and_return(old_assignee) + + expect(old_assignee).to receive(:update_cache_counts) + + merge_request.update(assignee: nil) + end + end + + context "when previous assignee does not exist" do + it "updates cache count for the new assignee" do + merge_request.update(assignee: nil) + + expect_any_instance_of(User).to receive(:update_cache_counts) + + merge_request.update(assignee: assignee) + end + end + end + end + + describe '#card_attributes' do + it 'includes the author name' do + allow(subject).to receive(:author).and_return(double(name: 'Robert')) + allow(subject).to receive(:assignee).and_return(nil) + + expect(subject.card_attributes). + to eq({ 'Author' => 'Robert', 'Assignee' => nil }) + end + + it 'includes the assignee name' do + allow(subject).to receive(:author).and_return(double(name: 'Robert')) + allow(subject).to receive(:assignee).and_return(double(name: 'Douwe')) + + expect(subject.card_attributes). + to eq({ 'Author' => 'Robert', 'Assignee' => 'Douwe' }) + end + end + + describe '#assignee_or_author?' do + let(:user) { create(:user) } + + it 'returns true for a user that is assigned to a merge request' do + subject.assignee = user + + expect(subject.assignee_or_author?(user)).to eq(true) + end + + it 'returns true for a user that is the author of a merge request' do + subject.author = user + + expect(subject.assignee_or_author?(user)).to eq(true) + end + + it 'returns false for a user that is not the assignee or author' do + expect(subject.assignee_or_author?(user)).to eq(false) + end + end + describe '#cache_merge_request_closes_issues!' do before do subject.project.team << [subject.author, :developer] @@ -295,16 +376,6 @@ def set_compare(merge_request) end end - describe '#is_being_reassigned?' do - it 'returns true if the merge_request assignee has changed' do - subject.assignee = create(:user) - expect(subject.is_being_reassigned?).to be_truthy - end - it 'returns false if the merge request assignee has not changed' do - expect(subject.is_being_reassigned?).to be_falsey - end - end - describe '#for_fork?' do it 'returns true if the merge request is for a fork' do subject.source_project = build_stubbed(:empty_project, namespace: create(:group)) diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 3ca13111acb0b..f7c317021dc76 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -19,7 +19,7 @@ let!(:closed_issue) do create :closed_issue, author: user, - assignee: user, + assignees: [user], project: project, state: :closed, milestone: milestone, @@ -31,14 +31,14 @@ :confidential, project: project, author: author, - assignee: assignee, + assignees: [assignee], created_at: generate(:past_time), updated_at: 2.hours.ago end let!(:issue) do create :issue, author: user, - assignee: user, + assignees: [user], project: project, milestone: milestone, created_at: generate(:past_time), @@ -265,7 +265,7 @@ let!(:group_closed_issue) do create :closed_issue, author: user, - assignee: user, + assignees: [user], project: group_project, state: :closed, milestone: group_milestone, @@ -276,13 +276,13 @@ :confidential, project: group_project, author: author, - assignee: assignee, + assignees: [assignee], updated_at: 2.hours.ago end let!(:group_issue) do create :issue, author: user, - assignee: user, + assignees: [user], project: group_project, milestone: group_milestone, updated_at: 1.hour.ago, @@ -687,6 +687,7 @@ expect(json_response['updated_at']).to be_present expect(json_response['labels']).to eq(issue.label_names) expect(json_response['milestone']).to be_a Hash + expect(json_response['assignees']).to be_a Array expect(json_response['assignee']).to be_a Hash expect(json_response['author']).to be_a Hash expect(json_response['confidential']).to be_falsy @@ -759,15 +760,38 @@ end describe "POST /projects/:id/issues" do + context 'support for deprecated assignee_id' do + it 'creates a new project issue' do + post api("/projects/#{project.id}/issues", user), + title: 'new issue', assignee_id: user2.id + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['assignee']['name']).to eq(user2.name) + expect(json_response['assignees'].first['name']).to eq(user2.name) + end + end + it 'creates a new project issue' do post api("/projects/#{project.id}/issues", user), +<<<<<<< HEAD title: 'new issue', labels: 'label, label2' +======= + title: 'new issue', labels: 'label, label2', weight: 3, + assignee_ids: [user2.id] +>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master expect(response).to have_http_status(201) expect(json_response['title']).to eq('new issue') expect(json_response['description']).to be_nil expect(json_response['labels']).to eq(%w(label label2)) expect(json_response['confidential']).to be_falsy +<<<<<<< HEAD +======= + expect(json_response['weight']).to eq(3) + expect(json_response['assignee']['name']).to eq(user2.name) + expect(json_response['assignees'].first['name']).to eq(user2.name) +>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master end it 'creates a new confidential project issue' do @@ -1057,6 +1081,46 @@ end end + describe 'PUT /projects/:id/issues/:issue_iid to update assignee' do + context 'support for deprecated assignee_id' do + it 'removes assignee' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + assignee_id: 0 + + expect(response).to have_http_status(200) + + expect(json_response['assignee']).to be_nil + end + + it 'updates an issue with new assignee' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + assignee_id: user2.id + + expect(response).to have_http_status(200) + + expect(json_response['assignee']['name']).to eq(user2.name) + end + end + + it 'removes assignee' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + assignee_ids: [0] + + expect(response).to have_http_status(200) + + expect(json_response['assignees']).to be_empty + end + + it 'updates an issue with new assignee' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + assignee_ids: [user2.id] + + expect(response).to have_http_status(200) + + expect(json_response['assignees'].first['name']).to eq(user2.name) + end + end + describe 'PUT /projects/:id/issues/:issue_iid to update labels' do let!(:label) { create(:label, title: 'dummy', project: project) } let!(:label_link) { create(:label_link, label: label, target: issue) } diff --git a/spec/requests/api/v3/issues_spec.rb b/spec/requests/api/v3/issues_spec.rb index ef5b10a161569..a1124aebcec9f 100644 --- a/spec/requests/api/v3/issues_spec.rb +++ b/spec/requests/api/v3/issues_spec.rb @@ -14,7 +14,7 @@ let!(:closed_issue) do create :closed_issue, author: user, - assignee: user, + assignees: [user], project: project, state: :closed, milestone: milestone, @@ -26,14 +26,14 @@ :confidential, project: project, author: author, - assignee: assignee, + assignees: [assignee], created_at: generate(:past_time), updated_at: 2.hours.ago end let!(:issue) do create :issue, author: user, - assignee: user, + assignees: [user], project: project, milestone: milestone, created_at: generate(:past_time), @@ -247,7 +247,7 @@ let!(:group_closed_issue) do create :closed_issue, author: user, - assignee: user, + assignees: [user], project: group_project, state: :closed, milestone: group_milestone, @@ -258,13 +258,13 @@ :confidential, project: group_project, author: author, - assignee: assignee, + assignees: [assignee], updated_at: 2.hours.ago end let!(:group_issue) do create :issue, author: user, - assignee: user, + assignees: [user], project: group_project, milestone: group_milestone, updated_at: 1.hour.ago @@ -737,13 +737,22 @@ describe "POST /projects/:id/issues" do it 'creates a new project issue' do post v3_api("/projects/#{project.id}/issues", user), +<<<<<<< HEAD title: 'new issue', labels: 'label, label2' +======= + title: 'new issue', labels: 'label, label2', weight: 3, assignee_id: assignee.id +>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master expect(response).to have_http_status(201) expect(json_response['title']).to eq('new issue') expect(json_response['description']).to be_nil expect(json_response['labels']).to eq(%w(label label2)) expect(json_response['confidential']).to be_falsy +<<<<<<< HEAD +======= + expect(json_response['weight']).to eq(3) + expect(json_response['assignee']['name']).to eq(assignee.name) +>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master end it 'creates a new confidential project issue' do @@ -1140,6 +1149,57 @@ end end +<<<<<<< HEAD +======= + describe 'PUT /projects/:id/issues/:issue_id to update assignee' do + it 'updates an issue with no assignee' do + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), assignee_id: 0 + + expect(response).to have_http_status(200) + expect(json_response['assignee']).to eq(nil) + end + + it 'updates an issue with assignee' do + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), assignee_id: user2.id + + expect(response).to have_http_status(200) + expect(json_response['assignee']['name']).to eq(user2.name) + end + end + + describe 'PUT /projects/:id/issues/:issue_id to update weight' do + it 'updates an issue with no weight' do + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), weight: 5 + + expect(response).to have_http_status(200) + expect(json_response['weight']).to eq(5) + end + + it 'removes a weight from an issue' do + weighted_issue = create(:issue, project: project, weight: 2) + + put v3_api("/projects/#{project.id}/issues/#{weighted_issue.id}", user), weight: nil + + expect(response).to have_http_status(200) + expect(json_response['weight']).to be_nil + end + + it 'returns 400 if weight is less than minimum weight' do + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), weight: -1 + + expect(response).to have_http_status(400) + expect(json_response['error']).to eq('weight does not have a valid value') + end + + it 'returns 400 if weight is more than maximum weight' do + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), weight: 10 + + expect(response).to have_http_status(400) + expect(json_response['error']).to eq('weight does not have a valid value') + end + end + +>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master describe "DELETE /projects/:id/issues/:issue_id" do it "rejects a non member from deleting an issue" do delete v3_api("/projects/#{project.id}/issues/#{issue.id}", non_member) diff --git a/spec/services/issuable/bulk_update_service_spec.rb b/spec/services/issuable/bulk_update_service_spec.rb index 7a1ac02731075..5b1639ca0d6e5 100644 --- a/spec/services/issuable/bulk_update_service_spec.rb +++ b/spec/services/issuable/bulk_update_service_spec.rb @@ -4,11 +4,12 @@ let(:user) { create(:user) } let(:project) { create(:empty_project, namespace: user.namespace) } - def bulk_update(issues, extra_params = {}) + def bulk_update(issuables, extra_params = {}) bulk_update_params = extra_params - .reverse_merge(issuable_ids: Array(issues).map(&:id).join(',')) + .reverse_merge(issuable_ids: Array(issuables).map(&:id).join(',')) - Issuable::BulkUpdateService.new(project, user, bulk_update_params).execute('issue') + type = Array(issuables).first.model_name.param_key + Issuable::BulkUpdateService.new(project, user, bulk_update_params).execute(type) end describe 'close issues' do @@ -47,15 +48,15 @@ def bulk_update(issues, extra_params = {}) end end - describe 'updating assignee' do - let(:issue) { create(:issue, project: project, assignee: user) } + describe 'updating merge request assignee' do + let(:merge_request) { create(:merge_request, target_project: project, source_project: project, assignee: user) } context 'when the new assignee ID is a valid user' do it 'succeeds' do new_assignee = create(:user) project.team << [new_assignee, :developer] - result = bulk_update(issue, assignee_id: new_assignee.id) + result = bulk_update(merge_request, assignee_id: new_assignee.id) expect(result[:success]).to be_truthy expect(result[:count]).to eq(1) @@ -65,22 +66,59 @@ def bulk_update(issues, extra_params = {}) assignee = create(:user) project.team << [assignee, :developer] - expect { bulk_update(issue, assignee_id: assignee.id) } - .to change { issue.reload.assignee }.from(user).to(assignee) + expect { bulk_update(merge_request, assignee_id: assignee.id) } + .to change { merge_request.reload.assignee }.from(user).to(assignee) end end context "when the new assignee ID is #{IssuableFinder::NONE}" do it "unassigns the issues" do - expect { bulk_update(issue, assignee_id: IssuableFinder::NONE) } - .to change { issue.reload.assignee }.to(nil) + expect { bulk_update(merge_request, assignee_id: IssuableFinder::NONE) } + .to change { merge_request.reload.assignee }.to(nil) end end context 'when the new assignee ID is not present' do it 'does not unassign' do - expect { bulk_update(issue, assignee_id: nil) } - .not_to change { issue.reload.assignee } + expect { bulk_update(merge_request, assignee_id: nil) } + .not_to change { merge_request.reload.assignee } + end + end + end + + describe 'updating issue assignee' do + let(:issue) { create(:issue, project: project, assignees: [user]) } + + context 'when the new assignee ID is a valid user' do + it 'succeeds' do + new_assignee = create(:user) + project.team << [new_assignee, :developer] + + result = bulk_update(issue, assignee_ids: [new_assignee.id]) + + expect(result[:success]).to be_truthy + expect(result[:count]).to eq(1) + end + + it 'updates the assignee to the use ID passed' do + assignee = create(:user) + project.team << [assignee, :developer] + expect { bulk_update(issue, assignee_ids: [assignee.id]) } + .to change { issue.reload.assignees.first }.from(user).to(assignee) + end + end + + context "when the new assignee ID is #{IssuableFinder::NONE}" do + it "unassigns the issues" do + expect { bulk_update(issue, assignee_ids: [IssuableFinder::NONE.to_s]) } + .to change { issue.reload.assignees.count }.from(1).to(0) + end + end + + context 'when the new assignee ID is not present' do + it 'does not unassign' do + expect { bulk_update(issue, assignee_ids: []) } + .not_to change{ issue.reload.assignees } end end end diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb index 7a54373963e54..5184053171186 100644 --- a/spec/services/issues/close_service_spec.rb +++ b/spec/services/issues/close_service_spec.rb @@ -4,7 +4,7 @@ let(:user) { create(:user) } let(:user2) { create(:user) } let(:guest) { create(:user) } - let(:issue) { create(:issue, assignee: user2) } + let(:issue) { create(:issue, assignees: [user2]) } let(:project) { issue.project } let!(:todo) { create(:todo, :assigned, user: user, project: project, target: issue, author: user2) } diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb index 80bfb7315505a..01edc46496d97 100644 --- a/spec/services/issues/create_service_spec.rb +++ b/spec/services/issues/create_service_spec.rb @@ -6,10 +6,10 @@ describe '#execute' do let(:issue) { described_class.new(project, user, opts).execute } + let(:assignee) { create(:user) } + let(:milestone) { create(:milestone, project: project) } context 'when params are valid' do - let(:assignee) { create(:user) } - let(:milestone) { create(:milestone, project: project) } let(:labels) { create_pair(:label, project: project) } before do @@ -20,7 +20,7 @@ let(:opts) do { title: 'Awesome issue', description: 'please fix', - assignee_id: assignee.id, + assignee_ids: [assignee.id], label_ids: labels.map(&:id), milestone_id: milestone.id, due_date: Date.tomorrow } @@ -29,7 +29,7 @@ it 'creates the issue with the given params' do expect(issue).to be_persisted expect(issue.title).to eq('Awesome issue') - expect(issue.assignee).to eq assignee + expect(issue.assignees).to eq [assignee] expect(issue.labels).to match_array labels expect(issue.milestone).to eq milestone expect(issue.due_date).to eq Date.tomorrow @@ -37,6 +37,7 @@ context 'when current user cannot admin issues in the project' do let(:guest) { create(:user) } + before do project.team << [guest, :guest] end @@ -47,7 +48,7 @@ expect(issue).to be_persisted expect(issue.title).to eq('Awesome issue') expect(issue.description).to eq('please fix') - expect(issue.assignee).to be_nil + expect(issue.assignees).to be_empty expect(issue.labels).to be_empty expect(issue.milestone).to be_nil expect(issue.due_date).to be_nil @@ -136,10 +137,83 @@ end end - it_behaves_like 'issuable create service' + context 'issue create service' do + context 'assignees' do + before { project.team << [user, :master] } + + it 'removes assignee when user id is invalid' do + opts = { title: 'Title', description: 'Description', assignee_ids: [-1] } + + issue = described_class.new(project, user, opts).execute + + expect(issue.assignees).to be_empty + end + + it 'removes assignee when user id is 0' do + opts = { title: 'Title', description: 'Description', assignee_ids: [0] } + + issue = described_class.new(project, user, opts).execute + + expect(issue.assignees).to be_empty + end + + it 'saves assignee when user id is valid' do + project.team << [assignee, :master] + opts = { title: 'Title', description: 'Description', assignee_ids: [assignee.id] } + + issue = described_class.new(project, user, opts).execute + + expect(issue.assignees).to eq([assignee]) + end + + context "when issuable feature is private" do + before do + project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE, + merge_requests_access_level: ProjectFeature::PRIVATE) + end + + levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] + + levels.each do |level| + it "removes not authorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do + project.update(visibility_level: level) + opts = { title: 'Title', description: 'Description', assignee_ids: [assignee.id] } + + issue = described_class.new(project, user, opts).execute + + expect(issue.assignees).to be_empty + end + end + end + end + end it_behaves_like 'new issuable record that supports slash commands' + context 'Slash commands' do + context 'with assignee and milestone in params and command' do + let(:opts) do + { + assignee_ids: [create(:user).id], + milestone_id: 1, + title: 'Title', + description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}") + } + end + + before do + project.team << [user, :master] + project.team << [assignee, :master] + end + + it 'assigns and sets milestone to issuable from command' do + expect(issue).to be_persisted + expect(issue.assignees).to eq([assignee]) + expect(issue.milestone).to eq(milestone) + end + end + end + context 'resolving discussions' do let(:discussion) { create(:diff_note_on_merge_request).to_discussion } let(:merge_request) { discussion.noteable } diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index 5b324f3c706d6..1797a23ee8afd 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -14,7 +14,7 @@ let(:issue) do create(:issue, title: 'Old title', description: "for #{user2.to_reference}", - assignee_id: user3.id, + assignee_ids: [user3.id], project: project) end @@ -40,7 +40,7 @@ def update_issue(opts) { title: 'New title', description: 'Also please fix', - assignee_id: user2.id, + assignee_ids: [user2.id, user3.id], state_event: 'close', label_ids: [label.id], due_date: Date.tomorrow @@ -53,15 +53,15 @@ def update_issue(opts) expect(issue).to be_valid expect(issue.title).to eq 'New title' expect(issue.description).to eq 'Also please fix' - expect(issue.assignee).to eq user2 + expect(issue.assignees).to match_array([user2, user3]) expect(issue).to be_closed expect(issue.labels).to match_array [label] expect(issue.due_date).to eq Date.tomorrow end it 'sorts issues as specified by parameters' do - issue1 = create(:issue, project: project, assignee_id: user3.id) - issue2 = create(:issue, project: project, assignee_id: user3.id) + issue1 = create(:issue, project: project, assignees: [user3]) + issue2 = create(:issue, project: project, assignees: [user3]) [issue, issue1, issue2].each do |issue| issue.move_to_end @@ -87,7 +87,7 @@ def update_issue(opts) expect(issue).to be_valid expect(issue.title).to eq 'New title' expect(issue.description).to eq 'Also please fix' - expect(issue.assignee).to eq user3 + expect(issue.assignees).to match_array [user3] expect(issue.labels).to be_empty expect(issue.milestone).to be_nil expect(issue.due_date).to be_nil @@ -137,7 +137,7 @@ def update_issue(opts) { title: 'New title', description: 'Also please fix', - assignee_id: user2.id, + assignee_ids: [user2], state_event: 'close', label_ids: [label.id], confidential: true @@ -163,12 +163,12 @@ def update_issue(opts) it 'does not update assignee_id with unauthorized users' do project.update(visibility_level: Gitlab::VisibilityLevel::PUBLIC) update_issue(confidential: true) - non_member = create(:user) - original_assignee = issue.assignee + non_member = create(:user) + original_assignees = issue.assignees - update_issue(assignee_id: non_member.id) + update_issue(assignee_ids: [non_member.id]) - expect(issue.reload.assignee_id).to eq(original_assignee.id) + expect(issue.reload.assignees).to eq(original_assignees) end end @@ -205,7 +205,7 @@ def update_issue(opts) context 'when is reassigned' do before do - update_issue(assignee: user2) + update_issue(assignees: [user2]) end it 'marks previous assignee todos as done' do @@ -408,6 +408,41 @@ def update_issue(opts) end end + context 'updating asssignee_id' do + it 'does not update assignee when assignee_id is invalid' do + update_issue(assignee_ids: [-1]) + + expect(issue.reload.assignees).to eq([user3]) + end + + it 'unassigns assignee when user id is 0' do + update_issue(assignee_ids: [0]) + + expect(issue.reload.assignees).to be_empty + end + + it 'does not update assignee_id when user cannot read issue' do + update_issue(assignee_ids: [create(:user).id]) + + expect(issue.reload.assignees).to eq([user3]) + end + + context "when issuable feature is private" do + levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] + + levels.each do |level| + it "does not update with unauthorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do + assignee = create(:user) + project.update(visibility_level: level) + feature_visibility_attr = :"#{issue.model_name.plural}_access_level" + project.project_feature.update_attribute(feature_visibility_attr, ProjectFeature::PRIVATE) + + expect{ update_issue(assignee_ids: [assignee.id]) }.not_to change{ issue.assignees } + end + end + end + end + context 'updating mentions' do let(:mentionable) { issue } include_examples 'updating mentions', Issues::UpdateService diff --git a/spec/services/merge_requests/assign_issues_service_spec.rb b/spec/services/merge_requests/assign_issues_service_spec.rb index fe75757dd29c4..d3556020d4d06 100644 --- a/spec/services/merge_requests/assign_issues_service_spec.rb +++ b/spec/services/merge_requests/assign_issues_service_spec.rb @@ -15,14 +15,14 @@ expect(service.assignable_issues.map(&:id)).to include(issue.id) end - it 'ignores issues already assigned to any user' do - issue.update!(assignee: create(:user)) + it 'ignores issues the user cannot update assignee on' do + project.team.truncate expect(service.assignable_issues).to be_empty end - it 'ignores issues the user cannot update assignee on' do - project.team.truncate + it 'ignores issues already assigned to any user' do + issue.assignees = [create(:user)] expect(service.assignable_issues).to be_empty end @@ -44,7 +44,7 @@ end it 'assigns these to the merge request owner' do - expect { service.execute }.to change { issue.reload.assignee }.to(user) + expect { service.execute }.to change { issue.assignees.first }.to(user) end it 'ignores external issues' do diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb index 0e16c7cc94bbd..ace82380cc999 100644 --- a/spec/services/merge_requests/create_service_spec.rb +++ b/spec/services/merge_requests/create_service_spec.rb @@ -84,7 +84,87 @@ end end - it_behaves_like 'issuable create service' + context 'Slash commands' do + context 'with assignee and milestone in params and command' do + let(:merge_request) { described_class.new(project, user, opts).execute } + let(:milestone) { create(:milestone, project: project) } + + let(:opts) do + { + assignee_id: create(:user).id, + milestone_id: 1, + title: 'Title', + description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}"), + source_branch: 'feature', + target_branch: 'master' + } + end + + before do + project.team << [user, :master] + project.team << [assignee, :master] + end + + it 'assigns and sets milestone to issuable from command' do + expect(merge_request).to be_persisted + expect(merge_request.assignee).to eq(assignee) + expect(merge_request.milestone).to eq(milestone) + end + end + end + + context 'merge request create service' do + context 'asssignee_id' do + let(:assignee) { create(:user) } + + before { project.team << [user, :master] } + + it 'removes assignee_id when user id is invalid' do + opts = { title: 'Title', description: 'Description', assignee_id: -1 } + + merge_request = described_class.new(project, user, opts).execute + + expect(merge_request.assignee_id).to be_nil + end + + it 'removes assignee_id when user id is 0' do + opts = { title: 'Title', description: 'Description', assignee_id: 0 } + + merge_request = described_class.new(project, user, opts).execute + + expect(merge_request.assignee_id).to be_nil + end + + it 'saves assignee when user id is valid' do + project.team << [assignee, :master] + opts = { title: 'Title', description: 'Description', assignee_id: assignee.id } + + merge_request = described_class.new(project, user, opts).execute + + expect(merge_request.assignee).to eq(assignee) + end + + context "when issuable feature is private" do + before do + project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE, + merge_requests_access_level: ProjectFeature::PRIVATE) + end + + levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] + + levels.each do |level| + it "removes not authorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do + project.update(visibility_level: level) + opts = { title: 'Title', description: 'Description', assignee_id: assignee.id } + + merge_request = described_class.new(project, user, opts).execute + + expect(merge_request.assignee_id).to be_nil + end + end + end + end + end context 'while saving references to issues that the created merge request closes' do let(:first_issue) { create(:issue, project: project) } diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index f2ca1e6fcbd80..694ec3a579f85 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -423,6 +423,54 @@ def update_merge_request(opts) end end + context 'updating asssignee_id' do + it 'does not update assignee when assignee_id is invalid' do + merge_request.update(assignee_id: user.id) + + update_merge_request(assignee_id: -1) + + expect(merge_request.reload.assignee).to eq(user) + end + + it 'unassigns assignee when user id is 0' do + merge_request.update(assignee_id: user.id) + + update_merge_request(assignee_id: 0) + + expect(merge_request.assignee_id).to be_nil + end + + it 'saves assignee when user id is valid' do + update_merge_request(assignee_id: user.id) + + expect(merge_request.assignee_id).to eq(user.id) + end + + it 'does not update assignee_id when user cannot read issue' do + non_member = create(:user) + original_assignee = merge_request.assignee + + update_merge_request(assignee_id: non_member.id) + + expect(merge_request.assignee_id).to eq(original_assignee.id) + end + + context "when issuable feature is private" do + levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] + + levels.each do |level| + it "does not update with unauthorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do + assignee = create(:user) + project.update(visibility_level: level) + feature_visibility_attr = :"#{merge_request.model_name.plural}_access_level" + project.project_feature.update_attribute(feature_visibility_attr, ProjectFeature::PRIVATE) + + expect{ update_merge_request(assignee_id: assignee) }.not_to change{ merge_request.assignee } + end + end + end + end + include_examples 'issuable update service' do let(:open_issuable) { merge_request } let(:closed_issuable) { create(:closed_merge_request, source_project: project) } diff --git a/spec/services/notes/slash_commands_service_spec.rb b/spec/services/notes/slash_commands_service_spec.rb index 1a64c8bbf00df..0edcc50ed7b22 100644 --- a/spec/services/notes/slash_commands_service_spec.rb +++ b/spec/services/notes/slash_commands_service_spec.rb @@ -66,7 +66,7 @@ expect(content).to eq '' expect(note.noteable).to be_closed expect(note.noteable.labels).to match_array(labels) - expect(note.noteable.assignee).to eq(assignee) + expect(note.noteable.assignees).to eq([assignee]) expect(note.noteable.milestone).to eq(milestone) end end @@ -113,7 +113,7 @@ expect(content).to eq "HELLO\nWORLD" expect(note.noteable).to be_closed expect(note.noteable.labels).to match_array(labels) - expect(note.noteable.assignee).to eq(assignee) + expect(note.noteable.assignees).to eq([assignee]) expect(note.noteable.milestone).to eq(milestone) end end diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 989fd90cda9d9..74f96b9790988 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -4,6 +4,7 @@ include EmailHelpers let(:notification) { NotificationService.new } + let(:assignee) { create(:user) } around(:each) do |example| perform_enqueued_jobs do @@ -52,7 +53,11 @@ def send_notifications(*new_mentions) shared_examples 'participating by assignee notification' do it 'emails the participant' do - issuable.update_attribute(:assignee, participant) + if issuable.is_a?(Issue) + issuable.assignees << participant + else + issuable.update_attribute(:assignee, participant) + end notification_trigger @@ -103,14 +108,14 @@ def send_notifications(*new_mentions) describe 'Notes' do context 'issue note' do let(:project) { create(:empty_project, :private) } - let(:issue) { create(:issue, project: project, assignee: create(:user)) } - let(:mentioned_issue) { create(:issue, assignee: issue.assignee) } + let(:issue) { create(:issue, project: project, assignees: [assignee]) } + let(:mentioned_issue) { create(:issue, assignees: issue.assignees) } let(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@mention referenced, @outsider also') } before do build_team(note.project) project.add_master(issue.author) - project.add_master(issue.assignee) + project.add_master(assignee) project.add_master(note.author) create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@subscribed_participant cc this guy') update_custom_notification(:new_note, @u_guest_custom, resource: project) @@ -130,7 +135,7 @@ def send_notifications(*new_mentions) should_email(@u_watcher) should_email(note.noteable.author) - should_email(note.noteable.assignee) + should_email(note.noteable.assignees.first) should_email(@u_custom_global) should_email(@u_mentioned) should_email(@subscriber) @@ -196,7 +201,7 @@ def send_notifications(*new_mentions) notification.new_note(note) should_email(note.noteable.author) - should_email(note.noteable.assignee) + should_email(note.noteable.assignees.first) should_email(@u_mentioned) should_email(@u_custom_global) should_not_email(@u_guest_custom) @@ -218,7 +223,7 @@ def send_notifications(*new_mentions) let(:member) { create(:user) } let(:guest) { create(:user) } let(:admin) { create(:admin) } - let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee) } + let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee]) } let(:note) { create(:note_on_issue, noteable: confidential_issue, project: project, note: "#{author.to_reference} #{assignee.to_reference} #{non_member.to_reference} #{member.to_reference} #{admin.to_reference}") } let(:guest_watcher) { create_user_with_notification(:watch, "guest-watcher-confidential") } @@ -244,8 +249,8 @@ def send_notifications(*new_mentions) context 'issue note mention' do let(:project) { create(:empty_project, :public) } - let(:issue) { create(:issue, project: project, assignee: create(:user)) } - let(:mentioned_issue) { create(:issue, assignee: issue.assignee) } + let(:issue) { create(:issue, project: project, assignees: [assignee]) } + let(:mentioned_issue) { create(:issue, assignees: issue.assignees) } let(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@all mentioned') } before do @@ -269,7 +274,7 @@ def send_notifications(*new_mentions) should_email(@u_guest_watcher) should_email(note.noteable.author) - should_email(note.noteable.assignee) + should_email(note.noteable.assignees.first) should_not_email(note.author) should_email(@u_mentioned) should_not_email(@u_disabled) @@ -449,7 +454,7 @@ def send_notifications(*new_mentions) let(:group) { create(:group) } let(:project) { create(:empty_project, :public, namespace: group) } let(:another_project) { create(:empty_project, :public, namespace: group) } - let(:issue) { create :issue, project: project, assignee: create(:user), description: 'cc @participant' } + let(:issue) { create :issue, project: project, assignees: [assignee], description: 'cc @participant' } before do build_team(issue.project) @@ -465,7 +470,7 @@ def send_notifications(*new_mentions) it do notification.new_issue(issue, @u_disabled) - should_email(issue.assignee) + should_email(assignee) should_email(@u_watcher) should_email(@u_guest_watcher) should_email(@u_guest_custom) @@ -480,10 +485,10 @@ def send_notifications(*new_mentions) end it do - create_global_setting_for(issue.assignee, :mention) + create_global_setting_for(issue.assignees.first, :mention) notification.new_issue(issue, @u_disabled) - should_not_email(issue.assignee) + should_not_email(issue.assignees.first) end it "emails the author if they've opted into notifications about their activity" do @@ -528,7 +533,7 @@ def send_notifications(*new_mentions) let(:member) { create(:user) } let(:guest) { create(:user) } let(:admin) { create(:admin) } - let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignee: assignee) } + let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignees: [assignee]) } it "emails subscribers of the issue's labels that can read the issue" do project.add_developer(member) @@ -572,9 +577,9 @@ def send_notifications(*new_mentions) end it 'emails new assignee' do - notification.reassigned_issue(issue, @u_disabled) + notification.reassigned_issue(issue, @u_disabled, [assignee]) - should_email(issue.assignee) + should_email(issue.assignees.first) should_email(@u_watcher) should_email(@u_guest_watcher) should_email(@u_guest_custom) @@ -588,9 +593,8 @@ def send_notifications(*new_mentions) end it 'emails previous assignee even if he has the "on mention" notif level' do - issue.update_attribute(:assignee, @u_mentioned) - issue.update_attributes(assignee: @u_watcher) - notification.reassigned_issue(issue, @u_disabled) + issue.assignees = [@u_mentioned] + notification.reassigned_issue(issue, @u_disabled, [@u_watcher]) should_email(@u_mentioned) should_email(@u_watcher) @@ -606,11 +610,11 @@ def send_notifications(*new_mentions) end it 'emails new assignee even if he has the "on mention" notif level' do - issue.update_attributes(assignee: @u_mentioned) - notification.reassigned_issue(issue, @u_disabled) + issue.assignees = [@u_mentioned] + notification.reassigned_issue(issue, @u_disabled, [@u_mentioned]) - expect(issue.assignee).to be @u_mentioned - should_email(issue.assignee) + expect(issue.assignees.first).to be @u_mentioned + should_email(issue.assignees.first) should_email(@u_watcher) should_email(@u_guest_watcher) should_email(@u_guest_custom) @@ -624,11 +628,11 @@ def send_notifications(*new_mentions) end it 'emails new assignee' do - issue.update_attribute(:assignee, @u_mentioned) - notification.reassigned_issue(issue, @u_disabled) + issue.assignees = [@u_mentioned] + notification.reassigned_issue(issue, @u_disabled, [@u_mentioned]) - expect(issue.assignee).to be @u_mentioned - should_email(issue.assignee) + expect(issue.assignees.first).to be @u_mentioned + should_email(issue.assignees.first) should_email(@u_watcher) should_email(@u_guest_watcher) should_email(@u_guest_custom) @@ -642,17 +646,17 @@ def send_notifications(*new_mentions) end it 'does not email new assignee if they are the current user' do - issue.update_attribute(:assignee, @u_mentioned) - notification.reassigned_issue(issue, @u_mentioned) + issue.assignees = [@u_mentioned] + notification.reassigned_issue(issue, @u_mentioned, [@u_mentioned]) - expect(issue.assignee).to be @u_mentioned + expect(issue.assignees.first).to be @u_mentioned should_email(@u_watcher) should_email(@u_guest_watcher) should_email(@u_guest_custom) should_email(@u_participant_mentioned) should_email(@subscriber) should_email(@u_custom_global) - should_not_email(issue.assignee) + should_not_email(issue.assignees.first) should_not_email(@unsubscriber) should_not_email(@u_participating) should_not_email(@u_disabled) @@ -662,7 +666,7 @@ def send_notifications(*new_mentions) it_behaves_like 'participating notifications' do let(:participant) { create(:user, username: 'user-participant') } let(:issuable) { issue } - let(:notification_trigger) { notification.reassigned_issue(issue, @u_disabled) } + let(:notification_trigger) { notification.reassigned_issue(issue, @u_disabled, [assignee]) } end end @@ -705,7 +709,7 @@ def send_notifications(*new_mentions) it "doesn't send email to anyone but subscribers of the given labels" do notification.relabeled_issue(issue, [group_label_2, label_2], @u_disabled) - should_not_email(issue.assignee) + should_not_email(issue.assignees.first) should_not_email(issue.author) should_not_email(@u_watcher) should_not_email(@u_guest_watcher) @@ -729,7 +733,7 @@ def send_notifications(*new_mentions) let(:member) { create(:user) } let(:guest) { create(:user) } let(:admin) { create(:admin) } - let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignee: assignee) } + let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignees: [assignee]) } let!(:label_1) { create(:label, project: project, issues: [confidential_issue]) } let!(:label_2) { create(:label, project: project) } @@ -767,7 +771,7 @@ def send_notifications(*new_mentions) it 'sends email to issue assignee and issue author' do notification.close_issue(issue, @u_disabled) - should_email(issue.assignee) + should_email(issue.assignees.first) should_email(issue.author) should_email(@u_watcher) should_email(@u_guest_watcher) @@ -798,7 +802,7 @@ def send_notifications(*new_mentions) it 'sends email to issue notification recipients' do notification.reopen_issue(issue, @u_disabled) - should_email(issue.assignee) + should_email(issue.assignees.first) should_email(issue.author) should_email(@u_watcher) should_email(@u_guest_watcher) @@ -826,7 +830,7 @@ def send_notifications(*new_mentions) it 'sends email to issue notification recipients' do notification.issue_moved(issue, new_issue, @u_disabled) - should_email(issue.assignee) + should_email(issue.assignees.first) should_email(issue.author) should_email(@u_watcher) should_email(@u_guest_watcher) diff --git a/spec/services/projects/autocomplete_service_spec.rb b/spec/services/projects/autocomplete_service_spec.rb index 7916c2d957cc0..c198c3eedfca2 100644 --- a/spec/services/projects/autocomplete_service_spec.rb +++ b/spec/services/projects/autocomplete_service_spec.rb @@ -11,7 +11,7 @@ let(:project) { create(:empty_project, :public) } let!(:issue) { create(:issue, project: project, title: 'Issue 1') } let!(:security_issue_1) { create(:issue, :confidential, project: project, title: 'Security issue 1', author: author) } - let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignee: assignee) } + let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignees: [assignee]) } it 'does not list project confidential issues for guests' do autocomplete = described_class.new(project, nil) diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb index 29e65fe7ce6da..865a7c698759e 100644 --- a/spec/services/slash_commands/interpret_service_spec.rb +++ b/spec/services/slash_commands/interpret_service_spec.rb @@ -3,6 +3,7 @@ describe SlashCommands::InterpretService, services: true do let(:project) { create(:empty_project, :public) } let(:developer) { create(:user) } + let(:developer2) { create(:user) } let(:issue) { create(:issue, project: project) } let(:milestone) { create(:milestone, project: project, title: '9.10') } let(:inprogress) { create(:label, project: project, title: 'In Progress') } @@ -42,6 +43,7 @@ end end +<<<<<<< HEAD shared_examples 'assign command' do it 'fetches assignee and populates assignee_id if content contains /assign' do _, updates = service.execute(content, issuable) @@ -59,6 +61,8 @@ end end +======= +>>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master shared_examples 'milestone command' do it 'fetches milestone and populates milestone_id if content contains /milestone' do milestone # populate the milestone @@ -371,14 +375,46 @@ let(:issuable) { issue } end - it_behaves_like 'assign command' do + context 'assign command' do let(:content) { "/assign @#{developer.username}" } - let(:issuable) { issue } + + context 'Issue' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, issue) + + expect(updates).to eq(assignee_ids: [developer.id]) + end + end + + context 'Merge Request' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, merge_request) + + expect(updates).to eq(assignee_id: developer.id) + end + end end - it_behaves_like 'assign command' do - let(:content) { "/assign @#{developer.username}" } - let(:issuable) { merge_request } + context 'assign command with multiple assignees' do + let(:content) { "/assign @#{developer.username} @#{developer2.username}" } + + before{ project.team << [developer2, :developer] } + + context 'Issue' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, issue) + + expect(updates[:assignee_ids]).to match_array([developer.id, developer2.id]) + end + end + + context 'Merge Request' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, merge_request) + + expect(updates).to eq(assignee_id: developer.id) + end + end end it_behaves_like 'empty command' do @@ -391,14 +427,26 @@ let(:issuable) { issue } end - it_behaves_like 'unassign command' do + context 'unassign command' do let(:content) { '/unassign' } - let(:issuable) { issue } - end - it_behaves_like 'unassign command' do - let(:content) { '/unassign' } - let(:issuable) { merge_request } + context 'Issue' do + it 'populates assignee_ids: [] if content contains /unassign' do + issue.update(assignee_ids: [developer.id]) + _, updates = service.execute(content, issue) + + expect(updates).to eq(assignee_ids: []) + end + end + + context 'Merge Request' do + it 'populates assignee_id: nil if content contains /unassign' do + merge_request.update(assignee_id: developer.id) + _, updates = service.execute(content, merge_request) + + expect(updates).to eq(assignee_id: nil) + end + end end it_behaves_like 'milestone command' do diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 75d7caf2508cd..5f1b82e835568 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -6,6 +6,7 @@ let(:project) { create(:empty_project) } let(:author) { create(:user) } let(:noteable) { create(:issue, project: project) } + let(:issue) { noteable } shared_examples_for 'a system note' do let(:expected_noteable) { noteable } @@ -155,6 +156,50 @@ end end + describe '.change_issue_assignees' do + subject { described_class.change_issue_assignees(noteable, project, author, [assignee]) } + + let(:assignee) { create(:user) } + let(:assignee1) { create(:user) } + let(:assignee2) { create(:user) } + let(:assignee3) { create(:user) } + + it_behaves_like 'a system note' + + def build_note(old_assignees, new_assignees) + issue.assignees = new_assignees + described_class.change_issue_assignees(issue, project, author, old_assignees).note + end + + it 'builds a correct phrase when an assignee is added to a non-assigned issue' do + expect(build_note([], [assignee1])).to eq "assigned to @#{assignee1.username}" + end + + it 'builds a correct phrase when assignee removed' do + expect(build_note([assignee1], [])).to eq 'removed all assignees' + end + + it 'builds a correct phrase when assignees changed' do + expect(build_note([assignee1], [assignee2])).to eq \ + "assigned to @#{assignee2.username} and unassigned @#{assignee1.username}" + end + + it 'builds a correct phrase when three assignees removed and one added' do + expect(build_note([assignee, assignee1, assignee2], [assignee3])).to eq \ + "assigned to @#{assignee3.username} and unassigned @#{assignee.username}, @#{assignee1.username}, and @#{assignee2.username}" + end + + it 'builds a correct phrase when one assignee changed from a set' do + expect(build_note([assignee, assignee1], [assignee, assignee2])).to eq \ + "assigned to @#{assignee2.username} and unassigned @#{assignee1.username}" + end + + it 'builds a correct phrase when one assignee removed from a set' do + expect(build_note([assignee, assignee1, assignee2], [assignee, assignee1])).to eq \ + "unassigned @#{assignee2.username}" + end + end + describe '.change_label' do subject { described_class.change_label(noteable, project, author, added, removed) } diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb index 89b3b6aad103c..175a42a32d9b7 100644 --- a/spec/services/todo_service_spec.rb +++ b/spec/services/todo_service_spec.rb @@ -25,11 +25,11 @@ end describe 'Issues' do - let(:issue) { create(:issue, project: project, assignee: john_doe, author: author, description: "- [ ] Task 1\n- [ ] Task 2 #{mentions}") } - let(:addressed_issue) { create(:issue, project: project, assignee: john_doe, author: author, description: "#{directly_addressed}\n- [ ] Task 1\n- [ ] Task 2") } - let(:unassigned_issue) { create(:issue, project: project, assignee: nil) } - let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee, description: mentions) } - let(:addressed_confident_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee, description: directly_addressed) } + let(:issue) { create(:issue, project: project, assignees: [john_doe], author: author, description: "- [ ] Task 1\n- [ ] Task 2 #{mentions}") } + let(:addressed_issue) { create(:issue, project: project, assignees: [john_doe], author: author, description: "#{directly_addressed}\n- [ ] Task 1\n- [ ] Task 2") } + let(:unassigned_issue) { create(:issue, project: project, assignees: []) } + let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee], description: mentions) } + let(:addressed_confident_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee], description: directly_addressed) } describe '#new_issue' do it 'creates a todo if assigned' do @@ -43,7 +43,7 @@ end it 'creates a todo if assignee is the current user' do - unassigned_issue.update_attribute(:assignee, john_doe) + unassigned_issue.assignees = [john_doe] service.new_issue(unassigned_issue, john_doe) should_create_todo(user: john_doe, target: unassigned_issue, author: john_doe, action: Todo::ASSIGNED) @@ -258,20 +258,20 @@ describe '#reassigned_issue' do it 'creates a pending todo for new assignee' do - unassigned_issue.update_attribute(:assignee, john_doe) + unassigned_issue.assignees << john_doe service.reassigned_issue(unassigned_issue, author) should_create_todo(user: john_doe, target: unassigned_issue, action: Todo::ASSIGNED) end it 'does not create a todo if unassigned' do - issue.update_attribute(:assignee, nil) + issue.assignees.destroy_all should_not_create_any_todo { service.reassigned_issue(issue, author) } end it 'creates a todo if new assignee is the current user' do - unassigned_issue.update_attribute(:assignee, john_doe) + unassigned_issue.assignees << john_doe service.reassigned_issue(unassigned_issue, john_doe) should_create_todo(user: john_doe, target: unassigned_issue, author: john_doe, action: Todo::ASSIGNED) @@ -361,7 +361,7 @@ describe '#new_note' do let!(:first_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) } let!(:second_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) } - let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee) } + let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee]) } let(:note) { create(:note, project: project, noteable: issue, author: john_doe, note: mentions) } let(:addressed_note) { create(:note, project: project, noteable: issue, author: john_doe, note: directly_addressed) } let(:note_on_commit) { create(:note_on_commit, project: project, author: john_doe, note: mentions) } @@ -854,7 +854,7 @@ end it 'updates cached counts when a todo is created' do - issue = create(:issue, project: project, assignee: john_doe, author: author, description: mentions) + issue = create(:issue, project: project, assignees: [john_doe], author: author, description: mentions) expect(john_doe.todos_pending_count).to eq(0) expect(john_doe).to receive(:update_todos_count_cache).and_call_original @@ -866,8 +866,8 @@ end describe '#mark_todos_as_done' do - let(:issue) { create(:issue, project: project, author: author, assignee: john_doe) } - let(:another_issue) { create(:issue, project: project, author: author, assignee: john_doe) } + let(:issue) { create(:issue, project: project, author: author, assignees: [john_doe]) } + let(:another_issue) { create(:issue, project: project, author: author, assignees: [john_doe]) } it 'marks a relation of todos as done' do create(:todo, :mentioned, user: john_doe, target: issue, project: project) diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb index 5bbe36d9b7fb8..f7499ede09cbd 100644 --- a/spec/support/features/issuable_slash_commands_shared_examples.rb +++ b/spec/support/features/issuable_slash_commands_shared_examples.rb @@ -62,7 +62,7 @@ note = issuable.notes.user.first expect(note.note).to eq "Awesome!" - expect(issuable.assignee).to eq assignee + expect(issuable.assignees).to eq [assignee] expect(issuable.labels).to eq [label_bug] expect(issuable.milestone).to eq milestone end @@ -80,7 +80,7 @@ issuable.reload expect(issuable.notes.user).to be_empty - expect(issuable.assignee).to eq assignee + expect(issuable.assignees).to eq [assignee] expect(issuable.labels).to eq [label_bug] expect(issuable.milestone).to eq milestone end diff --git a/spec/support/import_export/export_file_helper.rb b/spec/support/import_export/export_file_helper.rb index 944ea30656fc9..57b6abe12b7ca 100644 --- a/spec/support/import_export/export_file_helper.rb +++ b/spec/support/import_export/export_file_helper.rb @@ -10,7 +10,7 @@ def setup_project create(:release, project: project) - issue = create(:issue, assignee: user, project: project) + issue = create(:issue, assignees: [user], project: project) snippet = create(:project_snippet, project: project) label = create(:label, project: project) milestone = create(:milestone, project: project) diff --git a/spec/support/services/issuable_create_service_shared_examples.rb b/spec/support/services/issuable_create_service_shared_examples.rb deleted file mode 100644 index 4f0c745b7ee48..0000000000000 --- a/spec/support/services/issuable_create_service_shared_examples.rb +++ /dev/null @@ -1,52 +0,0 @@ -shared_examples 'issuable create service' do - context 'asssignee_id' do - let(:assignee) { create(:user) } - - before { project.team << [user, :master] } - - it 'removes assignee_id when user id is invalid' do - opts = { title: 'Title', description: 'Description', assignee_id: -1 } - - issuable = described_class.new(project, user, opts).execute - - expect(issuable.assignee_id).to be_nil - end - - it 'removes assignee_id when user id is 0' do - opts = { title: 'Title', description: 'Description', assignee_id: 0 } - - issuable = described_class.new(project, user, opts).execute - - expect(issuable.assignee_id).to be_nil - end - - it 'saves assignee when user id is valid' do - project.team << [assignee, :master] - opts = { title: 'Title', description: 'Description', assignee_id: assignee.id } - - issuable = described_class.new(project, user, opts).execute - - expect(issuable.assignee_id).to eq(assignee.id) - end - - context "when issuable feature is private" do - before do - project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE, - merge_requests_access_level: ProjectFeature::PRIVATE) - end - - levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] - - levels.each do |level| - it "removes not authorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do - project.update(visibility_level: level) - opts = { title: 'Title', description: 'Description', assignee_id: assignee.id } - - issuable = described_class.new(project, user, opts).execute - - expect(issuable.assignee_id).to be_nil - end - end - end - end -end diff --git a/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb b/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb index 9e9cdf3e48b49..1dd3663b944fd 100644 --- a/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb +++ b/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb @@ -49,23 +49,7 @@ it 'assigns and sets milestone to issuable' do expect(issuable).to be_persisted - expect(issuable.assignee).to eq(assignee) - expect(issuable.milestone).to eq(milestone) - end - end - - context 'with assignee and milestone in params and command' do - let(:example_params) do - { - assignee: create(:user), - milestone_id: 1, - description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}") - } - end - - it 'assigns and sets milestone to issuable from command' do - expect(issuable).to be_persisted - expect(issuable.assignee).to eq(assignee) + expect(issuable.assignees).to eq([assignee]) expect(issuable.milestone).to eq(milestone) end end diff --git a/spec/support/services/issuable_update_service_shared_examples.rb b/spec/support/services/issuable_update_service_shared_examples.rb index 49cea1e608cd9..8947f20562f8b 100644 --- a/spec/support/services/issuable_update_service_shared_examples.rb +++ b/spec/support/services/issuable_update_service_shared_examples.rb @@ -18,52 +18,4 @@ def update_issuable(opts) end end end - - context 'asssignee_id' do - it 'does not update assignee when assignee_id is invalid' do - open_issuable.update(assignee_id: user.id) - - update_issuable(assignee_id: -1) - - expect(open_issuable.reload.assignee).to eq(user) - end - - it 'unassigns assignee when user id is 0' do - open_issuable.update(assignee_id: user.id) - - update_issuable(assignee_id: 0) - - expect(open_issuable.assignee_id).to be_nil - end - - it 'saves assignee when user id is valid' do - update_issuable(assignee_id: user.id) - - expect(open_issuable.assignee_id).to eq(user.id) - end - - it 'does not update assignee_id when user cannot read issue' do - non_member = create(:user) - original_assignee = open_issuable.assignee - - update_issuable(assignee_id: non_member.id) - - expect(open_issuable.assignee_id).to eq(original_assignee.id) - end - - context "when issuable feature is private" do - levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] - - levels.each do |level| - it "does not update with unauthorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do - assignee = create(:user) - project.update(visibility_level: level) - feature_visibility_attr = :"#{open_issuable.model_name.plural}_access_level" - project.project_feature.update_attribute(feature_visibility_attr, ProjectFeature::PRIVATE) - - expect{ update_issuable(assignee_id: assignee) }.not_to change{ open_issuable.assignee } - end - end - end - end end diff --git a/spec/support/time_tracking_shared_examples.rb b/spec/support/time_tracking_shared_examples.rb index 01bc80f957e24..bd5d71f964abe 100644 --- a/spec/support/time_tracking_shared_examples.rb +++ b/spec/support/time_tracking_shared_examples.rb @@ -34,7 +34,7 @@ submit_time('/estimate 3w 1d 1h') submit_time('/remove_estimate') - page.within '#issuable-time-tracker' do + page.within '.time-tracking-component-wrap' do expect(page).to have_content 'No estimate or time spent' end end @@ -43,13 +43,13 @@ submit_time('/spend 3w 1d 1h') submit_time('/remove_time_spent') - page.within '#issuable-time-tracker' do + page.within '.time-tracking-component-wrap' do expect(page).to have_content 'No estimate or time spent' end end it 'shows the help state when icon is clicked' do - page.within '#issuable-time-tracker' do + page.within '.time-tracking-component-wrap' do find('.help-button').click expect(page).to have_content 'Track time with slash commands' expect(page).to have_content 'Learn more' @@ -57,7 +57,7 @@ end it 'hides the help state when close icon is clicked' do - page.within '#issuable-time-tracker' do + page.within '.time-tracking-component-wrap' do find('.help-button').click find('.close-help-button').click @@ -67,7 +67,7 @@ end it 'displays the correct help url' do - page.within '#issuable-time-tracker' do + page.within '.time-tracking-component-wrap' do find('.help-button').click expect(find_link('Learn more')[:href]).to have_content('/help/workflow/time_tracking.md') -- GitLab From 1848ddf6562f1c0a7a53bd65c79e251f0726a532 Mon Sep 17 00:00:00 2001 From: Filipa Lacerda <filipa@gitlab.com> Date: Thu, 4 May 2017 15:13:52 +0100 Subject: [PATCH 213/363] Add missing points --- doc/development/fe_guide/style_guide_js.md | 78 +++++++++++++++++++--- 1 file changed, 70 insertions(+), 8 deletions(-) diff --git a/doc/development/fe_guide/style_guide_js.md b/doc/development/fe_guide/style_guide_js.md index c8a77f6760708..a3f806c171382 100644 --- a/doc/development/fe_guide/style_guide_js.md +++ b/doc/development/fe_guide/style_guide_js.md @@ -11,7 +11,10 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns. #### ESlint -1. **Never** disable eslint rules unless you have a good reason. You may see a lot of legacy files with `/* eslint-disable some-rule, some-other-rule */` at the top, but legacy files are a special case. Any time you develop a new feature or refactor an existing one, you should abide by the eslint rules. +1. **Never** disable eslint rules unless you have a good reason. +You may see a lot of legacy files with `/* eslint-disable some-rule, some-other-rule */` +at the top, but legacy files are a special case. Any time you develop a new feature or +refactor an existing one, you should abide by the eslint rules. 1. **Never Ever EVER** disable eslint globally for a file ```javascript @@ -44,7 +47,8 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns. 1. [no-new][eslint-new] 1. [class-methods-use-this][eslint-this] -1. When they are needed _always_ place ESlint directive comment blocks on the first line of a script, followed by any global declarations, then a blank newline prior to any imports or code. +1. When they are needed _always_ place ESlint directive comment blocks on the first line of a script, +followed by any global declarations, then a blank newline prior to any imports or code. ```javascript // bad /* global Foo */ @@ -96,7 +100,8 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns. export default Foo; ``` -1. Relative paths: Unless you are writing a test, always reference other scripts using relative paths instead of `~` +1. Relative paths: Unless you are writing a test, always reference other scripts using +relative paths instead of `~` * In **app/assets/javascripts**: ```javascript @@ -116,7 +121,10 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns. import Foo from '~/foo'; ``` -1. Avoid using IIFE. Although we have a lot of examples of files which wrap their contents in IIFEs (immediately-invoked function expressions), this is no longer necessary after the transition from Sprockets to webpack. Do not use them anymore and feel free to remove them when refactoring legacy code. +1. Avoid using IIFE. Although we have a lot of examples of files which wrap their +contents in IIFEs (immediately-invoked function expressions), +this is no longer necessary after the transition from Sprockets to webpack. +Do not use them anymore and feel free to remove them when refactoring legacy code. 1. Avoid adding to the global namespace. ```javascript @@ -224,7 +232,45 @@ A forEach will cause side effects, it will be mutating the array being iterated. template: `<h1>I'm a component</h1> } ``` -1. +1. The service has it's own file +1. The store has it's own file +1. Use a function in the bundle file to instantiate the Vue component: + ```javascript + // bad + class { + init() { + new Component({}) + } + } + + // good + document.addEventListener('DOMContentLoaded', () => new Vue({ + el: '#element', + components: { + componentName + }, + render: createElement => createElement('component-name'), + })); + ``` + +1. Don not use a singleton for the service or the store + ```javascript + // bad + class Store { + constructor() { + if (!this.prototype.singleton) { + // do something + } + } + } + + // good + class Store { + constructor() { + // do something + } + } + ``` #### Naming 1. **Extensions**: Use `.vue` extension for Vue components. @@ -247,9 +293,8 @@ A forEach will cause side effects, it will be mutating the array being iterated. }; ``` -1. **Props Naming:** -1. Avoid using DOM component prop names. -1. Use kebab-case instead of camelCase to provide props in templates. +1. **Props Naming:** Avoid using DOM component prop names. +1. **Props Naming:** Use kebab-case instead of camelCase to provide props in templates. ```javascript // bad <component class="btn"> @@ -433,6 +478,23 @@ A forEach will cause side effects, it will be mutating the array being iterated. 1. `beforeDestroy` 1. `destroyed` +#### Vue and Boostrap +1. Tooltips: Do not rely on `has-tooltip` class name for vue components + ```javascript + // bad + <span class="has-tooltip"> + Text + </span> + + // good + <span data-toggle="tooltip"> + Text + </span> + ``` + +1. Tooltips: When using a tooltip, include the tooltip mixin + +1. Don't change `data-original-title`. ## SCSS - [SCSS](style_guide_scss.md) -- GitLab From b0a9a7cf5ee670ae07c4a9751d9c6e8bed1063b7 Mon Sep 17 00:00:00 2001 From: Valery Sizov <valery@gitlab.com> Date: Thu, 4 May 2017 17:22:24 +0300 Subject: [PATCH 214/363] [Multiple issue assignee]: reslving some conflicts --- lib/api/v3/issues.rb | 4 ---- spec/fixtures/api/schemas/issue.json | 4 +--- spec/requests/api/issues_spec.rb | 8 -------- spec/requests/api/v3/issues_spec.rb | 13 +----------- .../slash_commands/interpret_service_spec.rb | 20 ------------------- 5 files changed, 2 insertions(+), 47 deletions(-) diff --git a/lib/api/v3/issues.rb b/lib/api/v3/issues.rb index cedbeb0ded0f7..cb371fdbab8f7 100644 --- a/lib/api/v3/issues.rb +++ b/lib/api/v3/issues.rb @@ -61,12 +61,8 @@ def find_issues(args = {}) end get do issues = find_issues(scope: 'authored') -<<<<<<< HEAD - present paginate(issues), with: ::API::Entities::Issue, current_user: current_user -======= present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user ->>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master end end diff --git a/spec/fixtures/api/schemas/issue.json b/spec/fixtures/api/schemas/issue.json index 983beb838b736..ff86437fdd5d4 100644 --- a/spec/fixtures/api/schemas/issue.json +++ b/spec/fixtures/api/schemas/issue.json @@ -40,13 +40,12 @@ "additionalProperties": false } }, -<<<<<<< HEAD "assignee": { "id": { "type": "integet" }, "name": { "type": "string" }, "username": { "type": "string" }, "avatar_url": { "type": "uri" } -======= + }, "assignees": { "type": "array", "items": { @@ -64,7 +63,6 @@ "avatar_url": { "type": "uri" } } } ->>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master }, "subscribed": { "type": ["boolean", "null"] } }, diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index f7c317021dc76..9b7b5798b71e2 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -774,24 +774,16 @@ it 'creates a new project issue' do post api("/projects/#{project.id}/issues", user), -<<<<<<< HEAD - title: 'new issue', labels: 'label, label2' -======= title: 'new issue', labels: 'label, label2', weight: 3, assignee_ids: [user2.id] ->>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master expect(response).to have_http_status(201) expect(json_response['title']).to eq('new issue') expect(json_response['description']).to be_nil expect(json_response['labels']).to eq(%w(label label2)) expect(json_response['confidential']).to be_falsy -<<<<<<< HEAD -======= - expect(json_response['weight']).to eq(3) expect(json_response['assignee']['name']).to eq(user2.name) expect(json_response['assignees'].first['name']).to eq(user2.name) ->>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master end it 'creates a new confidential project issue' do diff --git a/spec/requests/api/v3/issues_spec.rb b/spec/requests/api/v3/issues_spec.rb index a1124aebcec9f..49191b2eb4e72 100644 --- a/spec/requests/api/v3/issues_spec.rb +++ b/spec/requests/api/v3/issues_spec.rb @@ -737,22 +737,14 @@ describe "POST /projects/:id/issues" do it 'creates a new project issue' do post v3_api("/projects/#{project.id}/issues", user), -<<<<<<< HEAD - title: 'new issue', labels: 'label, label2' -======= - title: 'new issue', labels: 'label, label2', weight: 3, assignee_id: assignee.id ->>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master + title: 'new issue', labels: 'label, label2', assignee_id: assignee.id expect(response).to have_http_status(201) expect(json_response['title']).to eq('new issue') expect(json_response['description']).to be_nil expect(json_response['labels']).to eq(%w(label label2)) expect(json_response['confidential']).to be_falsy -<<<<<<< HEAD -======= - expect(json_response['weight']).to eq(3) expect(json_response['assignee']['name']).to eq(assignee.name) ->>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master end it 'creates a new confidential project issue' do @@ -1149,8 +1141,6 @@ end end -<<<<<<< HEAD -======= describe 'PUT /projects/:id/issues/:issue_id to update assignee' do it 'updates an issue with no assignee' do put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), assignee_id: 0 @@ -1199,7 +1189,6 @@ end end ->>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master describe "DELETE /projects/:id/issues/:issue_id" do it "rejects a non member from deleting an issue" do delete v3_api("/projects/#{project.id}/issues/#{issue.id}", non_member) diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb index 865a7c698759e..9c16421fef03a 100644 --- a/spec/services/slash_commands/interpret_service_spec.rb +++ b/spec/services/slash_commands/interpret_service_spec.rb @@ -43,26 +43,6 @@ end end -<<<<<<< HEAD - shared_examples 'assign command' do - it 'fetches assignee and populates assignee_id if content contains /assign' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(assignee_id: developer.id) - end - end - - shared_examples 'unassign command' do - it 'populates assignee_id: nil if content contains /unassign' do - issuable.update!(assignee_id: developer.id) - _, updates = service.execute(content, issuable) - - expect(updates).to eq(assignee_id: nil) - end - end - -======= ->>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master shared_examples 'milestone command' do it 'fetches milestone and populates milestone_id if content contains /milestone' do milestone # populate the milestone -- GitLab From 4d08ea6c8c27d6d52c2bc324c5c7ab800e05f297 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzegorz@gitlab.com> Date: Thu, 4 May 2017 14:28:28 +0000 Subject: [PATCH 215/363] Document serializers --- app/serializers/README.md | 325 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 app/serializers/README.md diff --git a/app/serializers/README.md b/app/serializers/README.md new file mode 100644 index 0000000000000..0337f88db5f65 --- /dev/null +++ b/app/serializers/README.md @@ -0,0 +1,325 @@ +# Serializers + +This is a documentation for classes located in `app/serializers` directory. + +In GitLab, we use [grape-entities][grape-entity-project], accompanied by a +serializer, to convert a Ruby object to its JSON representation. + +Serializers are typically used in controllers to build a JSON response +that is usually consumed by a frontend code. + +## Why using a serializer is important? + +Using serializers, instead of `to_json` method, has several benefits: + +* it helps to prevent exposure of a sensitive data stored in the database +* it makes it easier to test what should and should not be exposed +* it makes it easier to reuse serialization entities that are building blocks +* it makes it easier to move complexity from controllers to easily testable + classes +* it encourages hiding complexity behind intentions-revealing interfaces +* it makes it easier to take care about serialization performance concerns +* it makes it easier to reduce merge conflicts between CE -> EE +* it makes it easier to benefit from domain driven development techniques + +## What is a serializer? + +A serializer is a class that encapsulates all business rules for building a +JSON response using serialization entities. + +It is designed to be testable and to support passing additional context from +the controller. + +## What is a serialization entity? + +Entities are lightweight structures that allow to represent domain models +in a consistent and abstracted way, and reuse them as building blocks to +create a payload. + +Entities located in `app/serializers` are usually derived from a +[`Grape::Entity`][grape-entity-class] class. + +Serialization entities that do require to have a knowledge about specific +elements of the request, need to mix `RequestAwareEntity` in. + +A serialization entity usually maps a domain model class into its JSON +representation. It rarely happens that a serialization entity exists without +a corresponding domain model class. As an example, we have an `Issue` class and +a corresponding `IssueSerializer`. + +Serialization entites are designed to reuse other serialization entities, which +is a convenient way to create a multi-level JSON representation of a piece of +a domain model you want to serialize. + +See [documentation for Grape Entites][grape-entity-readme] for more details. + +## How to implement a serializer? + +### Base implementation + +In order to effectively implement a serializer it is necessary to create a new +class in `app/serializers`. See existing serializers as an example. + +A new serializer should inherit from a `BaseSerializer` class. It is necessary +to specify which serialization entity will be used to serialize a resource. + +```ruby +class MyResourceSerializer < BaseSerialize + entity MyResourceEntity +end +``` + +The example above shows how a most simple serializer can look like. + +Given that the entity `MyResourceEntity` exists, you can now use +`MyResourceSerializer` in the controller by creating an instance of it, and +calling `MyResourceSerializer#represent(resource)` method. + +Note that a `resource` can be either a single object, an array of objects or an +`ActiveRecord::Relation` object. A serialization entity should be smart enough +to accurately represent each of these. + +It should not be necessary to use `Enumerable#map`, and it should be avoided +from the performance reasons. + +### Choosing what gets serialized + +It often happens that you might want to use the same serializer in many places, +but sometimes the intention is to only expose a small subset of object's +attributes in one place, and a different subset in another. + +`BaseSerializer#represent(resource, opts = {})` method can take an additional +hash argument, `opts`, that defines what is going to be serialized. + +`BaseSerializer` will pass these options to a serialization entity. See +how it is [documented in the upstream project][grape-entity-only]. + +With this approach you can extend the serializer to respond to methods that will +create a JSON response according to your needs. + +```ruby +class PipelineSerializer < BaseSerializer + entity PipelineEntity + + def represent_details(resource) + represent(resource, only: [:details]) + end + + def represent_status(resource) + represent(resource, only: [:status]) + end +end +``` + +It is possible to use `only` and `except` keywords. Both keywords do support +nested attributes, like `except: [:id, { user: [:id] }]`. + +Passing `only` and `except` to the `represent` method from a controller is +possible, but it defies principles of encapsulation and testability, and it is +better to avoid it, and to add a specific method to the serializer instead. + +### Reusing serialization entities from the API + +Public API in GitLab is implemented using [Grape][grape-project]. + +Under the hood it also uses [`Grape::Entity`][grape-entity-class] classes. +This means that it is possible to reuse these classes to implement internal +serializers. + +You can either use such entity directly: + +```ruby +class MyResourceSerializer < BaseSerializer + entity API::Entities::SomeEntity +end +``` + +Or derive a new serialization entity class from it: + +```ruby +class MyEntity < API::Entities::SomeEntity + include RequestAwareEntity + + unexpose :something +end +``` + +It might be a good idea to write specs for entities that do inherit from +the API, because when API payloads are changed / extended, it is easy to forget +about the impact on the internal API through a serializer that reuses API +entities. + +It is usually safe to do that, because API entities rarely break backward +compatibility, but additional exposure may have a performance impact when API +gets extended significantly. Write tests that check if only necessary data is +exposed. + +## How to write tests for a serializer? + +Like every other class in the project, creating a serializer warrants writing +tests for it. + +It is usually a good idea to test each public method in the serializer against +a valid payload. `BaseSerializer#represent` returns a hash, so it is possible +to use usual RSpec matchers like `include`. + +Sometimes, when the payload is large, it makes sense to validate it entirely +using `match_response_schema` matcher along with a new fixture that can be +stored in `spec/fixtures/api/schemas/`. This matcher is using a `json-schema` +gem, which is quite flexible, see a [documentation][json-schema-gem] for it. + +## How to use a serializer in a controller? + +Once a new serializer is implemented, it is possible to use it in a controller. + +Create an instance of the serializer and render the response. + +```ruby +def index + format.json do + render json: MyResourceSerializer + .new(current_user: @current_user) + .represent_details(@project.resources) + nd +end +``` + +If it is necessary to include additional information in the payload, it is +possible to extend what is going to be rendered, the usual way: + +```ruby +def index + format.json do + render json: { + resources: MyResourceSerializer + .new(current_user: @current_user) + .represent_details(@project.resources), + count: @project.resources.count + } + nd +end +``` + +Note that in these examples an additional context is being passed to the +serializer (`current_user: @current_user`). + +## How to pass an additional context from the controller? + +It is possible to pass an additional context from a controller to a +serializer and each serialization entity that is used in the process. + +Serialization entities that do require an additional context have +`RequestAwareEntity` concern mixed in. This piece of the code exposes a method +called `request` in every serialization entity that is instantiated during +serialization. + +An object returned by this method is an instance of `EntityRequest`, which +behaves like an `OpenStruct` object, with the difference that it will raise +an error if an unknown method is called. + +In other words, in the previous example, `request` method will return an +instance of `EntityRequest` that responds to `current_user` method. It will be +available in every serialization entity instantiated by `MyResourceSerializer`. + +`EntityRequest` is a workaround for [#20045][issue-20045] and is meant to be +refactored soon. Please avoid passing an additional context that is not +required by a serialization entity. + +At the moment, the context that is passed to entities most often is +`current_user` and `project`. + +## How is this related to using presenters? + +Payload created by a serializer is usually a representation of the backed code, +combined with the current request data. Therefore, technically, serializers +are presenters that create payload consumed by a frontend code, usually Vue +components. + +In GitLab, it is possible to use [presenters][presenters-readme], but +`BaseSerializer` still needs to learn how to use it, see [#30898][issue-30898]. + +It is possible to use presenters when serializer is used to represent only +a single object. It is not supported when `ActiveRecord::Relation` is being +serialized. + +```ruby +MyObjectSerializer.new.represent(object.present) +``` + +## Best practices + +1. Do not invoke a serializer from within a serialization entity. + + If you need to use a serializer from within a serialization entity, it is + possible that you are missing a class for an important domain concept. + + Consider creating a new domain class and a corresponding serialization + entity for it. + +1. Use only one approach to switch behavior of the serializer. + + It is possible to use a few approaches to switch a behavior of the + serializer. Most common are using a [Fluent Interface][fluent-interface] + and creating a separate `represent_something` methods. + + Whatever you choose, it might be better to use only one approach at a time. + +1. Do not forget about creating specs for serialization entities. + + Writing tests for the serializer indeed does cover testing a behavior of + serialization entities that the serializer instantiates. However it might + be a good idea to write separate tests for entities as well, because these + are meant to be reused in different serializers, and a serializer can + change a behavior of a serialization entity. + +1. Use `ActiveRecord::Relation` where possible + + Using an `ActiveRecord::Relation` might help from the performance perspective. + +1. Be diligent about passing an additional context from the controller. + + Using `EntityRequest` and `RequestAwareEntity` is a workaround for the lack + of high-level mechanism. It is meant to be refactored, and current + implementation is error prone. Imagine the situation that one serialization + entity requires `request.user` attribute, but the second one wants + `request.current_user`. When it happens that these two entities are used in + the same serialization request, you might need to pass both parameters to + the serializer, which is obviously not a perfect situation. + + When in doubt, pass only `current_user` and `project` if these are required. + +1. Keep performance concerns in mind + + Using a serializer incorrectly can have significant impact on the + performance. + + Because serializers are technically presenters, it is often necessary + to calculate, for example, paths to various controller-actions. + Since using URL helpers usually involve passing `project` and `namespace` + adding `includes(project: :namespace)` in the serializer, can help to avoid + N+1 queries. + + Also, try to avoid using `Enumerable#map` or other methods that will + execute a database query eagerly. + +1. Avoid passing `only` and `except` from the controller. +1. Write tests checking for N+1 queries. +1. Write controller tests for actions / formats using serializers. +1. Write tests that check if only necessary data is exposed. +1. Write tests that check if no sensitive data is exposed. + +## Future + +* [Next iteration of serializers][issue-27569] + +[grape-project]: http://www.ruby-grape.org +[grape-entity-project]: https://github.com/ruby-grape/grape-entity +[grape-entity-readme]: https://github.com/ruby-grape/grape-entity/blob/master/README.md +[grape-entity-class]: https://github.com/ruby-grape/grape-entity/blob/master/lib/grape_entity/entity.rb +[grape-entity-only]: https://github.com/ruby-grape/grape-entity/blob/master/README.md#returning-only-the-fields-you-want +[presenters-readme]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/presenters/README.md +[fluent-interface]: https://en.wikipedia.org/wiki/Fluent_interface +[json-schema-gem]: https://github.com/ruby-json-schema/json-schema +[issue-20045]: https://gitlab.com/gitlab-org/gitlab-ce/issues/20045 +[issue-30898]: https://gitlab.com/gitlab-org/gitlab-ce/issues/30898 +[issue-27569]: https://gitlab.com/gitlab-org/gitlab-ce/issues/27569 -- GitLab From 221dccab97766be810d87c6eb1d120145b41b364 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Thu, 4 May 2017 16:34:52 +0200 Subject: [PATCH 216/363] Include the bundler:audit job into the static-analysis job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable <remy@rymai.me> --- .gitlab-ci.yml | 12 ------------ scripts/static-analysis | 1 + 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7f665f191326f..44620d390ad33 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -412,18 +412,6 @@ rake karma: paths: - coverage-javascript/ -bundler:audit: - stage: test - <<: *ruby-static-analysis - <<: *dedicated-runner - only: - - master@gitlab-org/gitlab-ce - - master@gitlab-org/gitlab-ee - - master@gitlab/gitlabhq - - master@gitlab/gitlab-ee - script: - - "bundle exec bundle-audit check --update --ignore CVE-2016-4658" - .migration-paths: &migration-paths stage: test <<: *dedicated-runner diff --git a/scripts/static-analysis b/scripts/static-analysis index 1bd6b33983090..7dc8f67903640 100755 --- a/scripts/static-analysis +++ b/scripts/static-analysis @@ -3,6 +3,7 @@ require ::File.expand_path('../lib/gitlab/popen', __dir__) tasks = [ + %w[bundle exec bundle-audit check --update --ignore CVE-2016-4658], %w[bundle exec rake config_lint], %w[bundle exec rake flay], %w[bundle exec rake haml_lint], -- GitLab From 78d059141b5f3345d797992e03680654ce762c76 Mon Sep 17 00:00:00 2001 From: James Lopez <james@jameslopez.es> Date: Thu, 4 May 2017 16:41:07 +0200 Subject: [PATCH 217/363] add more examples for testing SQL --- .../projects/propagate_service_spec.rb | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/spec/services/projects/propagate_service_spec.rb b/spec/services/projects/propagate_service_spec.rb index ee40a7ecbabce..409c14655cd33 100644 --- a/spec/services/projects/propagate_service_spec.rb +++ b/spec/services/projects/propagate_service_spec.rb @@ -22,8 +22,38 @@ to change { Service.count }.by(1) end + it 'creates services for a project that has another service' do + other_service = BambooService.create( + template: true, + active: true, + properties: { + bamboo_url: 'http://gitlab.com', + username: 'mic', + password: "password", + build_key: 'build' + } + ) + + Service.build_from_template(project.id, other_service).save! + + expect { described_class.propagate!(service_template) }. + to change { Service.count }.by(1) + end + it 'does not create the service if it exists already' do + other_service = BambooService.create( + template: true, + active: true, + properties: { + bamboo_url: 'http://gitlab.com', + username: 'mic', + password: "password", + build_key: 'build' + } + ) + Service.build_from_template(project.id, service_template).save! + Service.build_from_template(project.id, other_service).save! expect { described_class.propagate!(service_template) }. not_to change { Service.count } -- GitLab From 9904c12746e744c4e271fe454b04a54dfb9b668e Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Thu, 4 May 2017 15:57:08 +0100 Subject: [PATCH 218/363] Updated webpack config --- app/assets/javascripts/deploy_keys/components/app.vue | 6 +----- config/webpack.config.js | 1 + 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index 2ba5001cf56a0..a6552125e6692 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -32,7 +32,6 @@ methods: { fetchKeys() { this.isLoading = true; - this.store.keys = {}; this.service.getKeys() .then((data) => { @@ -46,9 +45,6 @@ .then(() => this.fetchKeys()) .catch(() => new Flash('Error enabling deploy key')); }, - removeKey(deployKey) { - this.disableKey(deployKey); - }, disableKey(deployKey) { // eslint-disable-next-line no-alert if (confirm('You are going to remove this deploy key. Are you sure?')) { @@ -62,7 +58,7 @@ this.service = new DeployKeysService(this.endpoint); eventHub.$on('enable.key', this.enableKey); - eventHub.$on('remove.key', this.removeKey); + eventHub.$on('remove.key', this.disableKey); eventHub.$on('disable.key', this.disableKey); }, mounted() { diff --git a/config/webpack.config.js b/config/webpack.config.js index 742d22d0c1fec..239bb5ec43620 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -123,6 +123,7 @@ var config = { 'boards', 'commit_pipelines', 'cycle_analytics', + 'deploy_keys', 'diff_notes', 'environments', 'environments_folder', -- GitLab From 45e4c665653cd511b0d96119d3973652c43238bb Mon Sep 17 00:00:00 2001 From: Rares Sfirlogea <rrr.junior@gmail.com> Date: Wed, 16 Nov 2016 12:09:09 +0100 Subject: [PATCH 219/363] Display slash commands outcome when previewing Markdown Remove slash commands from Markdown preview and display their outcome next to the text field. Introduce new "explanation" block to our slash commands DSL. Introduce optional "parse_params" block to slash commands DSL that allows to process a parameter before it is passed to "explanation" or "command" blocks. Pass path for previewing Markdown as "data" attribute instead of setting a variable on "window". --- app/assets/javascripts/preview_markdown.js | 48 +++- app/controllers/concerns/markdown_preview.rb | 19 -- app/controllers/projects/wikis_controller.rb | 13 +- app/controllers/projects_controller.rb | 11 +- app/controllers/snippets_controller.rb | 10 +- app/helpers/gitlab_routing_helper.rb | 4 + app/services/preview_markdown_service.rb | 45 ++++ .../slash_commands/interpret_service.rb | 212 ++++++++++++++---- app/views/groups/milestones/new.html.haml | 2 +- app/views/layouts/project.html.haml | 5 - app/views/projects/_md_preview.html.haml | 7 +- app/views/projects/milestones/_form.html.haml | 2 +- app/views/projects/notes/_edit_form.html.haml | 2 +- app/views/projects/notes/_form.html.haml | 6 +- .../projects/notes/_notes_with_form.html.haml | 2 +- app/views/projects/releases/edit.html.haml | 2 +- app/views/projects/tags/new.html.haml | 2 +- app/views/projects/wikis/_form.html.haml | 2 +- app/views/shared/issuable/_form.html.haml | 2 +- .../issuable/form/_description.html.haml | 13 +- .../preview-separate-slash-commands.yml | 4 + .../slash_commands/command_definition.rb | 46 +++- lib/gitlab/slash_commands/dsl.rb | 52 ++++- .../slash_commands/command_definition_spec.rb | 52 +++++ spec/lib/gitlab/slash_commands/dsl_spec.rb | 66 ++++-- .../services/preview_markdown_service_spec.rb | 67 ++++++ .../slash_commands/interpret_service_spec.rb | 207 +++++++++++++++++ ...issuable_slash_commands_shared_examples.rb | 15 ++ 28 files changed, 785 insertions(+), 133 deletions(-) delete mode 100644 app/controllers/concerns/markdown_preview.rb create mode 100644 app/services/preview_markdown_service.rb create mode 100644 changelogs/unreleased/preview-separate-slash-commands.yml create mode 100644 spec/services/preview_markdown_service_spec.rb diff --git a/app/assets/javascripts/preview_markdown.js b/app/assets/javascripts/preview_markdown.js index 07eea98e737b1..4a3df2fd465c8 100644 --- a/app/assets/javascripts/preview_markdown.js +++ b/app/assets/javascripts/preview_markdown.js @@ -2,8 +2,9 @@ // MarkdownPreview // -// Handles toggling the "Write" and "Preview" tab clicks, rendering the preview, -// and showing a warning when more than `x` users are referenced. +// Handles toggling the "Write" and "Preview" tab clicks, rendering the preview +// (including the explanation of slash commands), and showing a warning when +// more than `x` users are referenced. // (function () { var lastTextareaPreviewed; @@ -17,32 +18,45 @@ // Minimum number of users referenced before triggering a warning MarkdownPreview.prototype.referenceThreshold = 10; + MarkdownPreview.prototype.emptyMessage = 'Nothing to preview.'; MarkdownPreview.prototype.ajaxCache = {}; MarkdownPreview.prototype.showPreview = function ($form) { var mdText; var preview = $form.find('.js-md-preview'); + var url = preview.data('url'); if (preview.hasClass('md-preview-loading')) { return; } mdText = $form.find('textarea.markdown-area').val(); if (mdText.trim().length === 0) { - preview.text('Nothing to preview.'); + preview.text(this.emptyMessage); this.hideReferencedUsers($form); } else { preview.addClass('md-preview-loading').text('Loading...'); - this.fetchMarkdownPreview(mdText, (function (response) { - preview.removeClass('md-preview-loading').html(response.body); + this.fetchMarkdownPreview(mdText, url, (function (response) { + var body; + if (response.body.length > 0) { + body = response.body; + } else { + body = this.emptyMessage; + } + + preview.removeClass('md-preview-loading').html(body); preview.renderGFM(); this.renderReferencedUsers(response.references.users, $form); + + if (response.references.commands) { + this.renderReferencedCommands(response.references.commands, $form); + } }).bind(this)); } }; - MarkdownPreview.prototype.fetchMarkdownPreview = function (text, success) { - if (!window.preview_markdown_path) { + MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) { + if (!url) { return; } if (text === this.ajaxCache.text) { @@ -51,7 +65,7 @@ } $.ajax({ type: 'POST', - url: window.preview_markdown_path, + url: url, data: { text: text }, @@ -83,6 +97,22 @@ } }; + MarkdownPreview.prototype.hideReferencedCommands = function ($form) { + $form.find('.referenced-commands').hide(); + }; + + MarkdownPreview.prototype.renderReferencedCommands = function (commands, $form) { + var referencedCommands; + referencedCommands = $form.find('.referenced-commands'); + if (commands.length > 0) { + referencedCommands.html(commands); + referencedCommands.show(); + } else { + referencedCommands.html(''); + referencedCommands.hide(); + } + }; + return MarkdownPreview; }()); @@ -137,6 +167,8 @@ $form.find('.md-write-holder').show(); $form.find('textarea.markdown-area').focus(); $form.find('.md-preview-holder').hide(); + + markdownPreview.hideReferencedCommands($form); }); $(document).on('markdown-preview:toggle', function (e, keyboardEvent) { diff --git a/app/controllers/concerns/markdown_preview.rb b/app/controllers/concerns/markdown_preview.rb deleted file mode 100644 index 40eff267348fa..0000000000000 --- a/app/controllers/concerns/markdown_preview.rb +++ /dev/null @@ -1,19 +0,0 @@ -module MarkdownPreview - private - - def render_markdown_preview(text, markdown_context = {}) - render json: { - body: view_context.markdown(text, markdown_context), - references: { - users: preview_referenced_users(text) - } - } - end - - def preview_referenced_users(text) - extractor = Gitlab::ReferenceExtractor.new(@project, current_user) - extractor.analyze(text, author: current_user) - - extractor.users.map(&:username) - end -end diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index 96125684da0d0..887d18dbec3ce 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -1,6 +1,4 @@ class Projects::WikisController < Projects::ApplicationController - include MarkdownPreview - before_action :authorize_read_wiki! before_action :authorize_create_wiki!, only: [:edit, :create, :history] before_action :authorize_admin_wiki!, only: :destroy @@ -97,9 +95,14 @@ def git_access end def preview_markdown - context = { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] } - - render_markdown_preview(params[:text], context) + result = PreviewMarkdownService.new(@project, current_user, params).execute + + render json: { + body: view_context.markdown(result[:text], pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id]), + references: { + users: result[:users] + } + } end private diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 9f6ee4826e6ee..69310b26e76cc 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -1,7 +1,6 @@ class ProjectsController < Projects::ApplicationController include IssuableCollections include ExtractsPath - include MarkdownPreview before_action :authenticate_user!, except: [:index, :show, :activity, :refs] before_action :project, except: [:index, :new, :create] @@ -240,7 +239,15 @@ def refs end def preview_markdown - render_markdown_preview(params[:text]) + result = PreviewMarkdownService.new(@project, current_user, params).execute + + render json: { + body: view_context.markdown(result[:text]), + references: { + users: result[:users], + commands: view_context.markdown(result[:commands]) + } + } end private diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb index da1ae9a34d980..afed27a41d1fb 100644 --- a/app/controllers/snippets_controller.rb +++ b/app/controllers/snippets_controller.rb @@ -3,7 +3,6 @@ class SnippetsController < ApplicationController include ToggleAwardEmoji include SpammableActions include SnippetsActions - include MarkdownPreview include RendersBlob before_action :snippet, only: [:show, :edit, :destroy, :update, :raw] @@ -90,7 +89,14 @@ def destroy end def preview_markdown - render_markdown_preview(params[:text], skip_project_check: true) + result = PreviewMarkdownService.new(@project, current_user, params).execute + + render json: { + body: view_context.markdown(result[:text], skip_project_check: true), + references: { + users: result[:users] + } + } end protected diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index e9b7cbbad6a5e..d3af241dd0e60 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -122,6 +122,10 @@ def project_snippet_url(entity, *args) namespace_project_snippet_url(entity.project.namespace, entity.project, entity, *args) end + def preview_markdown_path(project, *args) + preview_markdown_namespace_project_path(project.namespace, project, *args) + end + def toggle_subscription_path(entity, *args) if entity.is_a?(Issue) toggle_subscription_namespace_project_issue_path(entity.project.namespace, entity.project, entity) diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb new file mode 100644 index 0000000000000..10d45bbf73c20 --- /dev/null +++ b/app/services/preview_markdown_service.rb @@ -0,0 +1,45 @@ +class PreviewMarkdownService < BaseService + def execute + text, commands = explain_slash_commands(params[:text]) + users = find_user_references(text) + + success( + text: text, + users: users, + commands: commands.join(' ') + ) + end + + private + + def explain_slash_commands(text) + return text, [] unless %w(Issue MergeRequest).include?(commands_target_type) + + slash_commands_service = SlashCommands::InterpretService.new(project, current_user) + slash_commands_service.explain(text, find_commands_target) + end + + def find_user_references(text) + extractor = Gitlab::ReferenceExtractor.new(project, current_user) + extractor.analyze(text, author: current_user) + extractor.users.map(&:username) + end + + def find_commands_target + if commands_target_id.present? + finder = commands_target_type == 'Issue' ? IssuesFinder : MergeRequestsFinder + finder.new(current_user, project_id: project.id).find(commands_target_id) + else + collection = commands_target_type == 'Issue' ? project.issues : project.merge_requests + collection.build + end + end + + def commands_target_type + params[:slash_commands_target_type] + end + + def commands_target_id + params[:slash_commands_target_id] + end +end diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index 6aeebc26685ae..f1bbc7032d566 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -2,7 +2,7 @@ module SlashCommands class InterpretService < BaseService include Gitlab::SlashCommands::Dsl - attr_reader :issuable, :options + attr_reader :issuable # Takes a text and interprets the commands that are extracted from it. # Returns the content without commands, and hash of changes to be applied to a record. @@ -12,23 +12,21 @@ def execute(content, issuable) @issuable = issuable @updates = {} - opts = { - issuable: issuable, - current_user: current_user, - project: project, - params: params - } - - content, commands = extractor.extract_commands(content, opts) + content, commands = extractor.extract_commands(content, context) + extract_updates(commands, context) + [content, @updates] + end - commands.each do |name, arg| - definition = self.class.command_definitions_by_name[name.to_sym] - next unless definition + # Takes a text and interprets the commands that are extracted from it. + # Returns the content without commands, and array of changes explained. + def explain(content, issuable) + return [content, []] unless current_user.can?(:use_slash_commands) - definition.execute(self, opts, arg) - end + @issuable = issuable - [content, @updates] + content, commands = extractor.extract_commands(content, context) + commands = explain_commands(commands, context) + [content, commands] end private @@ -40,6 +38,9 @@ def extractor desc do "Close this #{issuable.to_ability_name.humanize(capitalize: false)}" end + explanation do + "Closes this #{issuable.to_ability_name.humanize(capitalize: false)}." + end condition do issuable.persisted? && issuable.open? && @@ -52,6 +53,9 @@ def extractor desc do "Reopen this #{issuable.to_ability_name.humanize(capitalize: false)}" end + explanation do + "Reopens this #{issuable.to_ability_name.humanize(capitalize: false)}." + end condition do issuable.persisted? && issuable.closed? && @@ -62,6 +66,7 @@ def extractor end desc 'Merge (when the pipeline succeeds)' + explanation 'Merges this merge request when the pipeline succeeds.' condition do last_diff_sha = params && params[:merge_request_diff_head_sha] issuable.is_a?(MergeRequest) && @@ -73,6 +78,9 @@ def extractor end desc 'Change title' + explanation do |title_param| + "Changes the title to \"#{title_param}\"." + end params '<New title>' condition do issuable.persisted? && @@ -83,18 +91,25 @@ def extractor end desc 'Assign' + explanation do |user| + "Assigns #{user.to_reference}." if user + end params '@user' condition do current_user.can?(:"admin_#{issuable.to_ability_name}", project) end - command :assign do |assignee_param| - user = extract_references(assignee_param, :user).first - user ||= User.find_by(username: assignee_param) - + parse_params do |assignee_param| + extract_references(assignee_param, :user).first || + User.find_by(username: assignee_param) + end + command :assign do |user| @updates[:assignee_id] = user.id if user end desc 'Remove assignee' + explanation do + "Removes assignee #{issuable.assignee.to_reference}." + end condition do issuable.persisted? && issuable.assignee_id? && @@ -105,19 +120,26 @@ def extractor end desc 'Set milestone' + explanation do |milestone| + "Sets the milestone to #{milestone.to_reference}." if milestone + end params '%"milestone"' condition do current_user.can?(:"admin_#{issuable.to_ability_name}", project) && project.milestones.active.any? end - command :milestone do |milestone_param| - milestone = extract_references(milestone_param, :milestone).first - milestone ||= project.milestones.find_by(title: milestone_param.strip) - + parse_params do |milestone_param| + extract_references(milestone_param, :milestone).first || + project.milestones.find_by(title: milestone_param.strip) + end + command :milestone do |milestone| @updates[:milestone_id] = milestone.id if milestone end desc 'Remove milestone' + explanation do + "Removes #{issuable.milestone.to_reference(format: :name)} milestone." + end condition do issuable.persisted? && issuable.milestone_id? && @@ -128,6 +150,11 @@ def extractor end desc 'Add label(s)' + explanation do |labels_param| + labels = find_label_references(labels_param) + + "Adds #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any? + end params '~label1 ~"label 2"' condition do available_labels = LabelsFinder.new(current_user, project_id: project.id).execute @@ -147,6 +174,14 @@ def extractor end desc 'Remove all or specific label(s)' + explanation do |labels_param = nil| + if labels_param.present? + labels = find_label_references(labels_param) + "Removes #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any? + else + 'Removes all labels.' + end + end params '~label1 ~"label 2"' condition do issuable.persisted? && @@ -169,6 +204,10 @@ def extractor end desc 'Replace all label(s)' + explanation do |labels_param| + labels = find_label_references(labels_param) + "Replaces all labels with #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any? + end params '~label1 ~"label 2"' condition do issuable.persisted? && @@ -187,6 +226,7 @@ def extractor end desc 'Add a todo' + explanation 'Adds a todo.' condition do issuable.persisted? && !TodoService.new.todo_exist?(issuable, current_user) @@ -196,6 +236,7 @@ def extractor end desc 'Mark todo as done' + explanation 'Marks todo as done.' condition do issuable.persisted? && TodoService.new.todo_exist?(issuable, current_user) @@ -205,6 +246,9 @@ def extractor end desc 'Subscribe' + explanation do + "Subscribes to this #{issuable.to_ability_name.humanize(capitalize: false)}." + end condition do issuable.persisted? && !issuable.subscribed?(current_user, project) @@ -214,6 +258,9 @@ def extractor end desc 'Unsubscribe' + explanation do + "Unsubscribes from this #{issuable.to_ability_name.humanize(capitalize: false)}." + end condition do issuable.persisted? && issuable.subscribed?(current_user, project) @@ -223,18 +270,23 @@ def extractor end desc 'Set due date' + explanation do |due_date| + "Sets the due date to #{due_date.to_s(:medium)}." if due_date + end params '<in 2 days | this Friday | December 31st>' condition do issuable.respond_to?(:due_date) && current_user.can?(:"admin_#{issuable.to_ability_name}", project) end - command :due do |due_date_param| - due_date = Chronic.parse(due_date_param).try(:to_date) - + parse_params do |due_date_param| + Chronic.parse(due_date_param).try(:to_date) + end + command :due do |due_date| @updates[:due_date] = due_date if due_date end desc 'Remove due date' + explanation 'Removes the due date.' condition do issuable.persisted? && issuable.respond_to?(:due_date) && @@ -245,8 +297,11 @@ def extractor @updates[:due_date] = nil end - desc do - "Toggle the Work In Progress status" + desc 'Toggle the Work In Progress status' + explanation do + verb = issuable.work_in_progress? ? 'Unmarks' : 'Marks' + noun = issuable.to_ability_name.humanize(capitalize: false) + "#{verb} this #{noun} as Work In Progress." end condition do issuable.persisted? && @@ -257,45 +312,72 @@ def extractor @updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip' end - desc 'Toggle emoji reward' + desc 'Toggle emoji award' + explanation do |name| + "Toggles :#{name}: emoji award." if name + end params ':emoji:' condition do issuable.persisted? end - command :award do |emoji| - name = award_emoji_name(emoji) + parse_params do |emoji_param| + match = emoji_param.match(Banzai::Filter::EmojiFilter.emoji_pattern) + match[1] if match + end + command :award do |name| if name && issuable.user_can_award?(current_user, name) @updates[:emoji_award] = name end end desc 'Set time estimate' + explanation do |time_estimate| + time_estimate = Gitlab::TimeTrackingFormatter.output(time_estimate) + + "Sets time estimate to #{time_estimate}." if time_estimate + end params '<1w 3d 2h 14m>' condition do current_user.can?(:"admin_#{issuable.to_ability_name}", project) end - command :estimate do |raw_duration| - time_estimate = Gitlab::TimeTrackingFormatter.parse(raw_duration) - + parse_params do |raw_duration| + Gitlab::TimeTrackingFormatter.parse(raw_duration) + end + command :estimate do |time_estimate| if time_estimate @updates[:time_estimate] = time_estimate end end desc 'Add or substract spent time' + explanation do |time_spent| + if time_spent + if time_spent > 0 + verb = 'Adds' + value = time_spent + else + verb = 'Substracts' + value = -time_spent + end + + "#{verb} #{Gitlab::TimeTrackingFormatter.output(value)} spent time." + end + end params '<1h 30m | -1h 30m>' condition do current_user.can?(:"admin_#{issuable.to_ability_name}", issuable) end - command :spend do |raw_duration| - time_spent = Gitlab::TimeTrackingFormatter.parse(raw_duration) - + parse_params do |raw_duration| + Gitlab::TimeTrackingFormatter.parse(raw_duration) + end + command :spend do |time_spent| if time_spent @updates[:spend_time] = { duration: time_spent, user: current_user } end end desc 'Remove time estimate' + explanation 'Removes time estimate.' condition do issuable.persisted? && current_user.can?(:"admin_#{issuable.to_ability_name}", project) @@ -305,6 +387,7 @@ def extractor end desc 'Remove spent time' + explanation 'Removes spent time.' condition do issuable.persisted? && current_user.can?(:"admin_#{issuable.to_ability_name}", project) @@ -318,19 +401,28 @@ def extractor params '@user' command :cc - desc 'Defines target branch for MR' + desc 'Define target branch for MR' + explanation do |branch_name| + "Sets target branch to #{branch_name}." + end params '<Local branch name>' condition do issuable.respond_to?(:target_branch) && (current_user.can?(:"update_#{issuable.to_ability_name}", issuable) || issuable.new_record?) end - command :target_branch do |target_branch_param| - branch_name = target_branch_param.strip + parse_params do |target_branch_param| + target_branch_param.strip + end + command :target_branch do |branch_name| @updates[:target_branch] = branch_name if project.repository.branch_names.include?(branch_name) end desc 'Move issue from one column of the board to another' + explanation do |target_list_name| + label = find_label_references(target_list_name).first + "Moves issue to #{label} column in the board." if label + end params '~"Target column"' condition do issuable.is_a?(Issue) && @@ -352,11 +444,35 @@ def extractor end end + def find_labels(labels_param) + extract_references(labels_param, :label) | + LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute + end + + def find_label_references(labels_param) + find_labels(labels_param).map(&:to_reference) + end + def find_label_ids(labels_param) - label_ids_by_reference = extract_references(labels_param, :label).map(&:id) - labels_ids_by_name = LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute.select(:id) + find_labels(labels_param).map(&:id) + end - label_ids_by_reference | labels_ids_by_name + def explain_commands(commands, opts) + commands.map do |name, arg| + definition = self.class.definition_by_name(name) + next unless definition + + definition.explain(self, opts, arg) + end.compact + end + + def extract_updates(commands, opts) + commands.each do |name, arg| + definition = self.class.definition_by_name(name) + next unless definition + + definition.execute(self, opts, arg) + end end def extract_references(arg, type) @@ -366,9 +482,13 @@ def extract_references(arg, type) ext.references(type) end - def award_emoji_name(emoji) - match = emoji.match(Banzai::Filter::EmojiFilter.emoji_pattern) - match[1] if match + def context + { + issuable: issuable, + current_user: current_user, + project: project, + params: params + } end end end diff --git a/app/views/groups/milestones/new.html.haml b/app/views/groups/milestones/new.html.haml index 8d3aa4d1a74d2..7c7573862d02c 100644 --- a/app/views/groups/milestones/new.html.haml +++ b/app/views/groups/milestones/new.html.haml @@ -26,7 +26,7 @@ .form-group.milestone-description = f.label :description, "Description", class: "control-label" .col-sm-10 - = render layout: 'projects/md_preview', locals: { preview_class: "md-preview" } do + = render layout: 'projects/md_preview', locals: { url: '' } do = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...' .clearfix .error-alert diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml index e9e06e5c8e388..3f5b0c54e5001 100644 --- a/app/views/layouts/project.html.haml +++ b/app/views/layouts/project.html.haml @@ -5,14 +5,9 @@ - content_for :project_javascripts do - project = @target_project || @project - - if @project_wiki && @page - - preview_markdown_path = namespace_project_wiki_preview_markdown_path(project.namespace, project, @page.slug) - - else - - preview_markdown_path = preview_markdown_namespace_project_path(project.namespace, project) - if current_user :javascript window.uploads_path = "#{namespace_project_uploads_path project.namespace,project}"; - window.preview_markdown_path = "#{preview_markdown_path}"; - content_for :header_content do .js-dropdown-menu-projects diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml index 23e27c1105c7d..d0698285f8491 100644 --- a/app/views/projects/_md_preview.html.haml +++ b/app/views/projects/_md_preview.html.haml @@ -1,3 +1,5 @@ +- referenced_users = local_assigns.fetch(:referenced_users, nil) + .md-area .md-header %ul.nav-links.clearfix @@ -28,9 +30,10 @@ .md-write-holder = yield - .md.md-preview-holder.js-md-preview.hide{ class: (preview_class if defined?(preview_class)) } + .md.md-preview-holder.js-md-preview.hide.md-preview{ data: { url: url } } + .referenced-commands.hide - - if defined?(referenced_users) && referenced_users + - if referenced_users .referenced-users.hide %span = icon("exclamation-triangle") diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index 0f4a850875155..2e978fda62422 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -9,7 +9,7 @@ .form-group.milestone-description = f.label :description, "Description", class: "control-label" .col-sm-10 - = render layout: 'projects/md_preview', locals: { preview_class: "md-preview" } do + = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project) } do = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...' = render 'projects/notes/hints' .clearfix diff --git a/app/views/projects/notes/_edit_form.html.haml b/app/views/projects/notes/_edit_form.html.haml index a1efc0b051a88..00230b0bdf822 100644 --- a/app/views/projects/notes/_edit_form.html.haml +++ b/app/views/projects/notes/_edit_form.html.haml @@ -2,7 +2,7 @@ = form_tag '#', method: :put, class: 'edit-note common-note-form js-quick-submit' do = hidden_field_tag :target_id, '', class: 'js-form-target-id' = hidden_field_tag :target_type, '', class: 'js-form-target-type' - = render layout: 'projects/md_preview', locals: { preview_class: 'md-preview', referenced_users: true } do + = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(project), referenced_users: true } do = render 'projects/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', placeholder: "Write a comment or drag your files here..." = render 'projects/notes/hints' diff --git a/app/views/projects/notes/_form.html.haml b/app/views/projects/notes/_form.html.haml index 0d835a9e949dd..46f785fefcafe 100644 --- a/app/views/projects/notes/_form.html.haml +++ b/app/views/projects/notes/_form.html.haml @@ -1,4 +1,8 @@ - supports_slash_commands = note_supports_slash_commands?(@note) +- if supports_slash_commands + - preview_url = preview_markdown_path(@project, slash_commands_target_type: @note.noteable_type, slash_commands_target_id: @note.noteable_id) +- else + - preview_url = preview_markdown_path(@project) = form_for [@project.namespace.becomes(Namespace), @project, @note], remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f| = hidden_field_tag :view, diff_view @@ -18,7 +22,7 @@ -# DiffNote = f.hidden_field :position - = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do + = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do = render 'projects/zen', f: f, attr: :note, classes: 'note-textarea js-note-text', diff --git a/app/views/projects/notes/_notes_with_form.html.haml b/app/views/projects/notes/_notes_with_form.html.haml index 555228623cc6b..2a66addb08a1d 100644 --- a/app/views/projects/notes/_notes_with_form.html.haml +++ b/app/views/projects/notes/_notes_with_form.html.haml @@ -1,7 +1,7 @@ %ul#notes-list.notes.main-notes-list.timeline = render "shared/notes/notes" -= render 'projects/notes/edit_form' += render 'projects/notes/edit_form', project: @project %ul.notes.notes-form.timeline %li.timeline-entry diff --git a/app/views/projects/releases/edit.html.haml b/app/views/projects/releases/edit.html.haml index 79d8d721aa9b7..faa24a3c88e21 100644 --- a/app/views/projects/releases/edit.html.haml +++ b/app/views/projects/releases/edit.html.haml @@ -11,7 +11,7 @@ = form_for(@release, method: :put, url: namespace_project_tag_release_path(@project.namespace, @project, @tag.name), html: { class: 'form-horizontal common-note-form release-form js-quick-submit' }) do |f| - = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do + = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here..." = render 'projects/notes/hints' .error-alert diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index 160d4c7a22372..a6894b9adc001 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -28,7 +28,7 @@ .form-group = label_tag :release_description, 'Release notes', class: 'control-label' .col-sm-10 - = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do + = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do = render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here..." = render 'projects/notes/hints' .help-block Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page. diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml index 0d2cd4a7476f9..00869aff27bf4 100644 --- a/app/views/projects/wikis/_form.html.haml +++ b/app/views/projects/wikis/_form.html.haml @@ -12,7 +12,7 @@ .form-group = f.label :content, class: 'control-label' .col-sm-10 - = render layout: 'projects/md_preview', locals: { preview_class: "md-preview" } do + = render layout: 'projects/md_preview', locals: { url: namespace_project_wiki_preview_markdown_path(@project.namespace, @project, @page.slug) } do = render 'projects/zen', f: f, attr: :content, classes: 'note-textarea', placeholder: 'Write your content or drag files here...' = render 'projects/notes/hints' diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 17107f55a2d61..7748351b333f6 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -17,7 +17,7 @@ = render 'shared/issuable/form/template_selector', issuable: issuable = render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?) -= render 'shared/issuable/form/description', issuable: issuable, form: form += render 'shared/issuable/form/description', issuable: issuable, form: form, project: project - if issuable.respond_to?(:confidential) .form-group diff --git a/app/views/shared/issuable/form/_description.html.haml b/app/views/shared/issuable/form/_description.html.haml index dbace9ce401de..cbc7125c0d56a 100644 --- a/app/views/shared/issuable/form/_description.html.haml +++ b/app/views/shared/issuable/form/_description.html.haml @@ -1,15 +1,22 @@ +- project = local_assigns.fetch(:project) - issuable = local_assigns.fetch(:issuable) - form = local_assigns.fetch(:form) +- supports_slash_commands = issuable.new_record? + +- if supports_slash_commands + - preview_url = preview_markdown_path(project, slash_commands_target_type: issuable.class.name) +- else + - preview_url = preview_markdown_path(project) .form-group.detail-page-description = form.label :description, 'Description', class: 'control-label' .col-sm-10 - = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do + = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do = render 'projects/zen', f: form, attr: :description, classes: 'note-textarea', placeholder: "Write a comment or drag your files here...", - supports_slash_commands: !issuable.persisted? - = render 'projects/notes/hints', supports_slash_commands: !issuable.persisted? + supports_slash_commands: supports_slash_commands + = render 'projects/notes/hints', supports_slash_commands: supports_slash_commands .clearfix .error-alert diff --git a/changelogs/unreleased/preview-separate-slash-commands.yml b/changelogs/unreleased/preview-separate-slash-commands.yml new file mode 100644 index 0000000000000..6240ccc957c80 --- /dev/null +++ b/changelogs/unreleased/preview-separate-slash-commands.yml @@ -0,0 +1,4 @@ +--- +title: Display slash commands outcome when previewing Markdown +merge_request: 10054 +author: Rares Sfirlogea diff --git a/lib/gitlab/slash_commands/command_definition.rb b/lib/gitlab/slash_commands/command_definition.rb index 60d35be259912..12a385f90fdf5 100644 --- a/lib/gitlab/slash_commands/command_definition.rb +++ b/lib/gitlab/slash_commands/command_definition.rb @@ -1,16 +1,19 @@ module Gitlab module SlashCommands class CommandDefinition - attr_accessor :name, :aliases, :description, :params, :condition_block, :action_block + attr_accessor :name, :aliases, :description, :explanation, :params, + :condition_block, :parse_params_block, :action_block def initialize(name, attributes = {}) @name = name - @aliases = attributes[:aliases] || [] - @description = attributes[:description] || '' - @params = attributes[:params] || [] + @aliases = attributes[:aliases] || [] + @description = attributes[:description] || '' + @explanation = attributes[:explanation] || '' + @params = attributes[:params] || [] @condition_block = attributes[:condition_block] - @action_block = attributes[:action_block] + @parse_params_block = attributes[:parse_params_block] + @action_block = attributes[:action_block] end def all_names @@ -28,14 +31,20 @@ def available?(opts) context.instance_exec(&condition_block) end + def explain(context, opts, arg) + return unless available?(opts) + + if explanation.respond_to?(:call) + execute_block(explanation, context, arg) + else + explanation + end + end + def execute(context, opts, arg) return if noop? || !available?(opts) - if arg.present? - context.instance_exec(arg, &action_block) - elsif action_block.arity == 0 - context.instance_exec(&action_block) - end + execute_block(action_block, context, arg) end def to_h(opts) @@ -52,6 +61,23 @@ def to_h(opts) params: params } end + + private + + def execute_block(block, context, arg) + if arg.present? + parsed = parse_params(arg, context) + context.instance_exec(parsed, &block) + elsif block.arity == 0 + context.instance_exec(&block) + end + end + + def parse_params(arg, context) + return arg unless parse_params_block + + context.instance_exec(arg, &parse_params_block) + end end end end diff --git a/lib/gitlab/slash_commands/dsl.rb b/lib/gitlab/slash_commands/dsl.rb index 50b0937d267cd..614bafbe1b213 100644 --- a/lib/gitlab/slash_commands/dsl.rb +++ b/lib/gitlab/slash_commands/dsl.rb @@ -44,6 +44,22 @@ def params(*params) @params = params end + # Allows to give an explanation of what the command will do when + # executed. This explanation is shown when rendering the Markdown + # preview. + # + # Example: + # + # explanation do |arguments| + # "Adds label(s) #{arguments.join(' ')}" + # end + # command :command_key do |arguments| + # # Awesome code block + # end + def explanation(text = '', &block) + @explanation = block_given? ? block : text + end + # Allows to define conditions that must be met in order for the command # to be returned by `.command_names` & `.command_definitions`. # It accepts a block that will be evaluated with the context given to @@ -61,6 +77,24 @@ def condition(&block) @condition_block = block end + # Allows to perform initial parsing of parameters. The result is passed + # both to `command` and `explanation` blocks, instead of the raw + # parameters. + # It accepts a block that will be evaluated with the context given to + # `CommandDefintion#to_h`. + # + # Example: + # + # parse_params do |raw| + # raw.strip + # end + # command :command_key do |parsed| + # # Awesome code block + # end + def parse_params(&block) + @parse_params_block = block + end + # Registers a new command which is recognizeable from body of email or # comment. # It accepts aliases and takes a block. @@ -75,11 +109,13 @@ def command(*command_names, &block) definition = CommandDefinition.new( name, - aliases: aliases, - description: @description, - params: @params, - condition_block: @condition_block, - action_block: block + aliases: aliases, + description: @description, + explanation: @explanation, + params: @params, + condition_block: @condition_block, + parse_params_block: @parse_params_block, + action_block: block ) self.command_definitions << definition @@ -89,8 +125,14 @@ def command(*command_names, &block) end @description = nil + @explanation = nil @params = nil @condition_block = nil + @parse_params_block = nil + end + + def definition_by_name(name) + command_definitions_by_name[name.to_sym] end end end diff --git a/spec/lib/gitlab/slash_commands/command_definition_spec.rb b/spec/lib/gitlab/slash_commands/command_definition_spec.rb index c9c2f314e576d..5b9173d3d3f51 100644 --- a/spec/lib/gitlab/slash_commands/command_definition_spec.rb +++ b/spec/lib/gitlab/slash_commands/command_definition_spec.rb @@ -167,6 +167,58 @@ end end end + + context 'when the command defines parse_params block' do + before do + subject.parse_params_block = ->(raw) { raw.strip } + subject.action_block = ->(parsed) { self.received_arg = parsed } + end + + it 'executes the command passing the parsed param' do + subject.execute(context, {}, 'something ') + + expect(context.received_arg).to eq('something') + end + end + end + end + end + + describe '#explain' do + context 'when the command is not available' do + before do + subject.condition_block = proc { false } + subject.explanation = 'Explanation' + end + + it 'returns nil' do + result = subject.explain({}, {}, nil) + + expect(result).to be_nil + end + end + + context 'when the explanation is a static string' do + before do + subject.explanation = 'Explanation' + end + + it 'returns this static string' do + result = subject.explain({}, {}, nil) + + expect(result).to eq 'Explanation' + end + end + + context 'when the explanation is dynamic' do + before do + subject.explanation = proc { |arg| "Dynamic #{arg}" } + end + + it 'invokes the proc' do + result = subject.explain({}, {}, 'explanation') + + expect(result).to eq 'Dynamic explanation' end end end diff --git a/spec/lib/gitlab/slash_commands/dsl_spec.rb b/spec/lib/gitlab/slash_commands/dsl_spec.rb index 2763d95071627..33b49a5ddf98b 100644 --- a/spec/lib/gitlab/slash_commands/dsl_spec.rb +++ b/spec/lib/gitlab/slash_commands/dsl_spec.rb @@ -11,67 +11,99 @@ end params 'The first argument' - command :one_arg, :once, :first do |arg1| - arg1 + explanation 'Static explanation' + command :explanation_with_aliases, :once, :first do |arg| + arg end desc do "A dynamic description for #{noteable.upcase}" end params 'The first argument', 'The second argument' - command :two_args do |arg1, arg2| - [arg1, arg2] + command :dynamic_description do |args| + args.split end command :cc + explanation do |arg| + "Action does something with #{arg}" + end condition do project == 'foo' end command :cond_action do |arg| arg end + + parse_params do |raw_arg| + raw_arg.strip + end + command :with_params_parsing do |parsed| + parsed + end end end describe '.command_definitions' do it 'returns an array with commands definitions' do - no_args_def, one_arg_def, two_args_def, cc_def, cond_action_def = DummyClass.command_definitions + no_args_def, explanation_with_aliases_def, dynamic_description_def, + cc_def, cond_action_def, with_params_parsing_def = + DummyClass.command_definitions expect(no_args_def.name).to eq(:no_args) expect(no_args_def.aliases).to eq([:none]) expect(no_args_def.description).to eq('A command with no args') + expect(no_args_def.explanation).to eq('') expect(no_args_def.params).to eq([]) expect(no_args_def.condition_block).to be_nil expect(no_args_def.action_block).to be_a_kind_of(Proc) + expect(no_args_def.parse_params_block).to be_nil - expect(one_arg_def.name).to eq(:one_arg) - expect(one_arg_def.aliases).to eq([:once, :first]) - expect(one_arg_def.description).to eq('') - expect(one_arg_def.params).to eq(['The first argument']) - expect(one_arg_def.condition_block).to be_nil - expect(one_arg_def.action_block).to be_a_kind_of(Proc) + expect(explanation_with_aliases_def.name).to eq(:explanation_with_aliases) + expect(explanation_with_aliases_def.aliases).to eq([:once, :first]) + expect(explanation_with_aliases_def.description).to eq('') + expect(explanation_with_aliases_def.explanation).to eq('Static explanation') + expect(explanation_with_aliases_def.params).to eq(['The first argument']) + expect(explanation_with_aliases_def.condition_block).to be_nil + expect(explanation_with_aliases_def.action_block).to be_a_kind_of(Proc) + expect(explanation_with_aliases_def.parse_params_block).to be_nil - expect(two_args_def.name).to eq(:two_args) - expect(two_args_def.aliases).to eq([]) - expect(two_args_def.to_h(noteable: "issue")[:description]).to eq('A dynamic description for ISSUE') - expect(two_args_def.params).to eq(['The first argument', 'The second argument']) - expect(two_args_def.condition_block).to be_nil - expect(two_args_def.action_block).to be_a_kind_of(Proc) + expect(dynamic_description_def.name).to eq(:dynamic_description) + expect(dynamic_description_def.aliases).to eq([]) + expect(dynamic_description_def.to_h(noteable: 'issue')[:description]).to eq('A dynamic description for ISSUE') + expect(dynamic_description_def.explanation).to eq('') + expect(dynamic_description_def.params).to eq(['The first argument', 'The second argument']) + expect(dynamic_description_def.condition_block).to be_nil + expect(dynamic_description_def.action_block).to be_a_kind_of(Proc) + expect(dynamic_description_def.parse_params_block).to be_nil expect(cc_def.name).to eq(:cc) expect(cc_def.aliases).to eq([]) expect(cc_def.description).to eq('') + expect(cc_def.explanation).to eq('') expect(cc_def.params).to eq([]) expect(cc_def.condition_block).to be_nil expect(cc_def.action_block).to be_nil + expect(cc_def.parse_params_block).to be_nil expect(cond_action_def.name).to eq(:cond_action) expect(cond_action_def.aliases).to eq([]) expect(cond_action_def.description).to eq('') + expect(cond_action_def.explanation).to be_a_kind_of(Proc) expect(cond_action_def.params).to eq([]) expect(cond_action_def.condition_block).to be_a_kind_of(Proc) expect(cond_action_def.action_block).to be_a_kind_of(Proc) + expect(cond_action_def.parse_params_block).to be_nil + + expect(with_params_parsing_def.name).to eq(:with_params_parsing) + expect(with_params_parsing_def.aliases).to eq([]) + expect(with_params_parsing_def.description).to eq('') + expect(with_params_parsing_def.explanation).to eq('') + expect(with_params_parsing_def.params).to eq([]) + expect(with_params_parsing_def.condition_block).to be_nil + expect(with_params_parsing_def.action_block).to be_a_kind_of(Proc) + expect(with_params_parsing_def.parse_params_block).to be_a_kind_of(Proc) end end end diff --git a/spec/services/preview_markdown_service_spec.rb b/spec/services/preview_markdown_service_spec.rb new file mode 100644 index 0000000000000..b2fb5c91313c0 --- /dev/null +++ b/spec/services/preview_markdown_service_spec.rb @@ -0,0 +1,67 @@ +require 'spec_helper' + +describe PreviewMarkdownService do + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + + before do + project.add_developer(user) + end + + describe 'user references' do + let(:params) { { text: "Take a look #{user.to_reference}" } } + let(:service) { described_class.new(project, user, params) } + + it 'returns users referenced in text' do + result = service.execute + + expect(result[:users]).to eq [user.username] + end + end + + context 'new note with slash commands' do + let(:issue) { create(:issue, project: project) } + let(:params) do + { + text: "Please do it\n/assign #{user.to_reference}", + slash_commands_target_type: 'Issue', + slash_commands_target_id: issue.id + } + end + let(:service) { described_class.new(project, user, params) } + + it 'removes slash commands from text' do + result = service.execute + + expect(result[:text]).to eq 'Please do it' + end + + it 'explains slash commands effect' do + result = service.execute + + expect(result[:commands]).to eq "Assigns #{user.to_reference}." + end + end + + context 'merge request description' do + let(:params) do + { + text: "My work\n/estimate 2y", + slash_commands_target_type: 'MergeRequest' + } + end + let(:service) { described_class.new(project, user, params) } + + it 'removes slash commands from text' do + result = service.execute + + expect(result[:text]).to eq 'My work' + end + + it 'explains slash commands effect' do + result = service.execute + + expect(result[:commands]).to eq 'Sets time estimate to 2y.' + end + end +end diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb index 29e65fe7ce6da..46cfac4a128fe 100644 --- a/spec/services/slash_commands/interpret_service_spec.rb +++ b/spec/services/slash_commands/interpret_service_spec.rb @@ -798,4 +798,211 @@ end end end + + describe '#explain' do + let(:service) { described_class.new(project, developer) } + let(:merge_request) { create(:merge_request, source_project: project) } + + describe 'close command' do + let(:content) { '/close' } + + it 'includes issuable name' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(['Closes this issue.']) + end + end + + describe 'reopen command' do + let(:content) { '/reopen' } + let(:merge_request) { create(:merge_request, :closed, source_project: project) } + + it 'includes issuable name' do + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(['Reopens this merge request.']) + end + end + + describe 'title command' do + let(:content) { '/title This is new title' } + + it 'includes new title' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(['Changes the title to "This is new title".']) + end + end + + describe 'assign command' do + let(:content) { "/assign @#{developer.username} do it!" } + + it 'includes only the user reference' do + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(["Assigns @#{developer.username}."]) + end + end + + describe 'unassign command' do + let(:content) { '/unassign' } + let(:issue) { create(:issue, project: project, assignee: developer) } + + it 'includes current assignee reference' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(["Removes assignee @#{developer.username}."]) + end + end + + describe 'milestone command' do + let(:content) { '/milestone %wrong-milestone' } + let!(:milestone) { create(:milestone, project: project, title: '9.10') } + + it 'is empty when milestone reference is wrong' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq([]) + end + end + + describe 'remove milestone command' do + let(:content) { '/remove_milestone' } + let(:merge_request) { create(:merge_request, source_project: project, milestone: milestone) } + + it 'includes current milestone name' do + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(['Removes %"9.10" milestone.']) + end + end + + describe 'label command' do + let(:content) { '/label ~missing' } + let!(:label) { create(:label, project: project) } + + it 'is empty when there are no correct labels' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq([]) + end + end + + describe 'unlabel command' do + let(:content) { '/unlabel' } + + it 'says all labels if no parameter provided' do + merge_request.update!(label_ids: [bug.id]) + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(['Removes all labels.']) + end + end + + describe 'relabel command' do + let(:content) { '/relabel Bug' } + let!(:bug) { create(:label, project: project, title: 'Bug') } + let(:feature) { create(:label, project: project, title: 'Feature') } + + it 'includes label name' do + issue.update!(label_ids: [feature.id]) + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(["Replaces all labels with ~#{bug.id} label."]) + end + end + + describe 'subscribe command' do + let(:content) { '/subscribe' } + + it 'includes issuable name' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(['Subscribes to this issue.']) + end + end + + describe 'unsubscribe command' do + let(:content) { '/unsubscribe' } + + it 'includes issuable name' do + merge_request.subscribe(developer, project) + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(['Unsubscribes from this merge request.']) + end + end + + describe 'due command' do + let(:content) { '/due April 1st 2016' } + + it 'includes the date' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(['Sets the due date to Apr 1, 2016.']) + end + end + + describe 'wip command' do + let(:content) { '/wip' } + + it 'includes the new status' do + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(['Marks this merge request as Work In Progress.']) + end + end + + describe 'award command' do + let(:content) { '/award :confetti_ball: ' } + + it 'includes the emoji' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(['Toggles :confetti_ball: emoji award.']) + end + end + + describe 'estimate command' do + let(:content) { '/estimate 79d' } + + it 'includes the formatted duration' do + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(['Sets time estimate to 3mo 3w 4d.']) + end + end + + describe 'spend command' do + let(:content) { '/spend -120m' } + + it 'includes the formatted duration and proper verb' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(['Substracts 2h spent time.']) + end + end + + describe 'target branch command' do + let(:content) { '/target_branch my-feature ' } + + it 'includes the branch name' do + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(['Sets target branch to my-feature.']) + end + end + + describe 'board move command' do + let(:content) { '/board_move ~bug' } + let!(:bug) { create(:label, project: project, title: 'bug') } + let!(:board) { create(:board, project: project) } + + it 'includes the label name' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(["Moves issue to ~#{bug.id} column in the board."]) + end + end + end end diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb index 5bbe36d9b7fb8..6efcdd7a1ded9 100644 --- a/spec/support/features/issuable_slash_commands_shared_examples.rb +++ b/spec/support/features/issuable_slash_commands_shared_examples.rb @@ -257,4 +257,19 @@ end end end + + describe "preview of note on #{issuable_type}" do + it 'removes slash commands from note and explains them' do + visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) + + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "Awesome!\n/assign @bob " + click_on 'Preview' + + expect(page).to have_content 'Awesome!' + expect(page).not_to have_content '/assign @bob' + expect(page).to have_content 'Assigns @bob.' + end + end + end end -- GitLab From f1ace97f8bdc69edc481b545b779721322cec95c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Wed, 3 May 2017 17:25:31 +0200 Subject: [PATCH 220/363] Backport avatar-related spec changes from gitlab-org/gitlab-ee@4b464eaaee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable <remy@rymai.me> --- spec/factories/groups.rb | 4 ++++ spec/factories/users.rb | 4 ++++ spec/models/group_spec.rb | 16 ++++++++++++++++ spec/models/project_spec.rb | 11 +++-------- spec/models/user_spec.rb | 11 +++++++++++ 5 files changed, 38 insertions(+), 8 deletions(-) diff --git a/spec/factories/groups.rb b/spec/factories/groups.rb index 86f51ffca997f..52f76b094a399 100644 --- a/spec/factories/groups.rb +++ b/spec/factories/groups.rb @@ -17,6 +17,10 @@ visibility_level Gitlab::VisibilityLevel::PRIVATE end + trait :with_avatar do + avatar { File.open(Rails.root.join('spec/fixtures/dk.png')) } + end + trait :access_requestable do request_access_enabled true end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index e1ae94a08e461..33fa80772ffcd 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -29,6 +29,10 @@ after(:build) { |user, _| user.block! } end + trait :with_avatar do + avatar { File.open(Rails.root.join('spec/fixtures/dk.png')) } + end + trait :two_factor_via_otp do before(:create) do |user| user.otp_required_for_login = true diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index a11805926cc53..3d60e52f23f4d 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -175,6 +175,22 @@ end end + describe '#avatar_url' do + let!(:group) { create(:group, :access_requestable, :with_avatar) } + let(:user) { create(:user) } + subject { group.avatar_url } + + context 'when avatar file is uploaded' do + before do + group.add_master(user) + end + + let(:avatar_path) { "/uploads/group/avatar/#{group.id}/dk.png" } + + it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } + end + end + describe '.search' do it 'returns groups with a matching name' do expect(described_class.search(group.name)).to eq([group]) diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 36ce3070a6eb0..c3eeff6c43a04 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -811,12 +811,9 @@ context 'when avatar file is uploaded' do let(:project) { create(:empty_project, :with_avatar) } + let(:avatar_path) { "/uploads/project/avatar/#{project.id}/dk.png" } - it 'creates a correct avatar path' do - avatar_path = "/uploads/project/avatar/#{project.id}/dk.png" - - expect(project.avatar_url).to eq("http://#{Gitlab.config.gitlab.host}#{avatar_path}") - end + it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } end context 'When avatar file in git' do @@ -824,9 +821,7 @@ allow(project).to receive(:avatar_in_git) { true } end - let(:avatar_path) do - "/#{project.full_path}/avatar" - end + let(:avatar_path) { "/#{project.full_path}/avatar" } it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 1c2df4c9d9741..5322b7178e3ef 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -874,6 +874,17 @@ end end + describe '#avatar_url' do + let(:user) { create(:user, :with_avatar) } + subject { user.avatar_url } + + context 'when avatar file is uploaded' do + let(:avatar_path) { "/uploads/user/avatar/#{user.id}/dk.png" } + + it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } + end + end + describe '#requires_ldap_check?' do let(:user) { User.new } -- GitLab From dde9b6698bb67dc34650a588ad3e644843067800 Mon Sep 17 00:00:00 2001 From: Clement Ho <ClemMakesApps@gmail.com> Date: Thu, 4 May 2017 10:08:55 -0500 Subject: [PATCH 221/363] Fix webpack config conflict --- config/webpack.config.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/config/webpack.config.js b/config/webpack.config.js index 8cacafd611f64..97597397b75e7 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -33,11 +33,6 @@ var config = { graphs: './graphs/graphs_bundle.js', group: './group.js', groups_list: './groups_list.js', -<<<<<<< HEAD - issuable: './issuable/issuable_bundle.js', -======= - issues: './issues/issues_bundle.js', ->>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master issue_show: './issue_show/index.js', main: './main.js', merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js', -- GitLab From e1d22a054e5ebac2846da1c2f5fbdf2bc22a7117 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Thu, 4 May 2017 16:16:00 +0100 Subject: [PATCH 222/363] Updated JS object for Vue methods --- app/assets/javascripts/vue_shared/translate.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/vue_shared/translate.js b/app/assets/javascripts/vue_shared/translate.js index ba9656d4e71e8..f83c4b00761d2 100644 --- a/app/assets/javascripts/vue_shared/translate.js +++ b/app/assets/javascripts/vue_shared/translate.js @@ -13,7 +13,7 @@ export default (Vue) => { @param text The text to be translated @returns {String} The translated text **/ - __(text) { return __(text); }, + __, /** Translate the text with a number if the number is more than 1 it will use the `pluralText` translation. @@ -24,7 +24,7 @@ export default (Vue) => { @param count Number to decide which translation to use (eg. 2) @returns {String} Translated text with the number replaced (eg. '2 days') **/ - n__(text, pluralText, count) { return n__(text, pluralText, count); }, + n__, /** Translate context based text Either pass in the context translation like `Context|Text to translate` @@ -36,7 +36,7 @@ export default (Vue) => { @param key Is the dynamic variable you want to be translated @returns {String} Translated context based text **/ - s__(keyOrContext, key) { return s__(keyOrContext, key); }, + s__, }, }); }; -- GitLab From dd146a59c32de94a01dafb82279199310337ced8 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Thu, 4 May 2017 16:33:25 +0100 Subject: [PATCH 223/363] Changed how the default avatar is set --- .../javascripts/boards/boards_bundle.js | 5 +++-- app/assets/javascripts/boards/models/issue.js | 4 ++-- app/assets/javascripts/boards/models/list.js | 5 +++-- app/assets/javascripts/boards/models/user.js | 7 ++----- .../javascripts/boards/stores/boards_store.js | 4 ++-- .../boards/utils/default_avatar.js | 1 - spec/javascripts/boards/issue_card_spec.js | 21 +++++++++++++++++++ 7 files changed, 33 insertions(+), 14 deletions(-) delete mode 100644 app/assets/javascripts/boards/utils/default_avatar.js diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js index b6dee8177d276..8c08b2d4db38f 100644 --- a/app/assets/javascripts/boards/boards_bundle.js +++ b/app/assets/javascripts/boards/boards_bundle.js @@ -59,7 +59,8 @@ $(() => { issueLinkBase: $boardApp.dataset.issueLinkBase, rootPath: $boardApp.dataset.rootPath, bulkUpdatePath: $boardApp.dataset.bulkUpdatePath, - detailIssue: Store.detail + detailIssue: Store.detail, + defaultAvatar: $boardApp.dataset.defaultAvatar, }, computed: { detailIssueVisible () { @@ -82,7 +83,7 @@ $(() => { gl.boardService.all() .then((resp) => { resp.json().forEach((board) => { - const list = Store.addList(board); + const list = Store.addList(board, this.defaultAvatar); if (list.type === 'closed') { list.position = Infinity; diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index d6175069e370e..db783467f8772 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -6,7 +6,7 @@ import Vue from 'vue'; class ListIssue { - constructor (obj) { + constructor (obj, defaultAvatar) { this.globalId = obj.id; this.id = obj.iid; this.title = obj.title; @@ -19,7 +19,7 @@ class ListIssue { this.position = obj.relative_position || Infinity; if (obj.assignee) { - this.assignee = new ListUser(obj.assignee); + this.assignee = new ListUser(obj.assignee, defaultAvatar); } if (obj.milestone) { diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index f2b79a88a4ad4..bd2f62bcc1aab 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -6,7 +6,7 @@ import queryData from '../utils/query_data'; const PER_PAGE = 20; class List { - constructor (obj) { + constructor (obj, defaultAvatar) { this.id = obj.id; this._uid = this.guid(); this.position = obj.position; @@ -18,6 +18,7 @@ class List { this.loadingMore = false; this.issues = []; this.issuesSize = 0; + this.defaultAvatar = defaultAvatar; if (obj.label) { this.label = new ListLabel(obj.label); @@ -106,7 +107,7 @@ class List { createIssues (data) { data.forEach((issueObj) => { - this.addIssue(new ListIssue(issueObj)); + this.addIssue(new ListIssue(issueObj, this.defaultAvatar)); }); } diff --git a/app/assets/javascripts/boards/models/user.js b/app/assets/javascripts/boards/models/user.js index 875c21a37d362..2af583c3279f2 100644 --- a/app/assets/javascripts/boards/models/user.js +++ b/app/assets/javascripts/boards/models/user.js @@ -1,12 +1,9 @@ -/* eslint-disable no-unused-vars */ -import defaultAvatar from '../utils/default_avatar'; - class ListUser { - constructor(user) { + constructor(user, defaultAvatar) { this.id = user.id; this.name = user.name; this.username = user.username; - this.avatar = user.avatar_url || defaultAvatar(); + this.avatar = user.avatar_url || defaultAvatar; } } diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index ccb000992155f..ad9997ac33436 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -23,8 +23,8 @@ gl.issueBoards.BoardsStore = { this.state.lists = []; this.filter.path = gl.utils.getUrlParamsArray().join('&'); }, - addList (listObj) { - const list = new List(listObj); + addList (listObj, defaultAvatar) { + const list = new List(listObj, defaultAvatar); this.state.lists.push(list); return list; diff --git a/app/assets/javascripts/boards/utils/default_avatar.js b/app/assets/javascripts/boards/utils/default_avatar.js deleted file mode 100644 index 062ffec6dce0d..0000000000000 --- a/app/assets/javascripts/boards/utils/default_avatar.js +++ /dev/null @@ -1 +0,0 @@ -export default () => document.getElementById('board-app').dataset.defaultAvatar; diff --git a/spec/javascripts/boards/issue_card_spec.js b/spec/javascripts/boards/issue_card_spec.js index 1a5e9e9fd0794..ef567635d48c1 100644 --- a/spec/javascripts/boards/issue_card_spec.js +++ b/spec/javascripts/boards/issue_card_spec.js @@ -146,6 +146,27 @@ describe('Issue card component', () => { ).not.toBeNull(); }); }); + + describe('assignee default avatar', () => { + beforeEach((done) => { + component.issue.assignee = new ListUser({ + id: 1, + name: 'testing 123', + username: 'test', + }, 'default_avatar'); + + Vue.nextTick(done); + }); + + it('displays defaults avatar if users avatar is null', () => { + expect( + component.$el.querySelector('.card-assignee img'), + ).not.toBeNull(); + expect( + component.$el.querySelector('.card-assignee img').getAttribute('src'), + ).toBe('default_avatar'); + }); + }); }); describe('labels', () => { -- GitLab From cf002738e766f977bdb0e857759f548a5c65c9bd Mon Sep 17 00:00:00 2001 From: James Lopez <james@jameslopez.es> Date: Thu, 4 May 2017 18:11:28 +0200 Subject: [PATCH 224/363] refactor a few things based on feedback --- app/controllers/admin/services_controller.rb | 4 ++- app/models/service.rb | 10 ------ app/services/projects/propagate_service.rb | 8 ++--- .../propagate_project_service_worker.rb | 2 +- .../admin/services_controller_spec.rb | 32 +++++++++++++++++++ spec/models/service_spec.rb | 27 ---------------- .../projects/propagate_service_spec.rb | 8 ++--- .../propagate_project_service_worker_spec.rb | 2 +- 8 files changed, 45 insertions(+), 48 deletions(-) diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb index 2b6f335cb2b1c..e335fbfffedfd 100644 --- a/app/controllers/admin/services_controller.rb +++ b/app/controllers/admin/services_controller.rb @@ -15,7 +15,9 @@ def edit end def update - if service.update_and_propagate(service_params[:service]) + if service.update_attributes(service_params[:service]) + PropagateProjectServiceWorker.perform_async(service.id) if service.active? + redirect_to admin_application_settings_services_path, notice: 'Application settings saved successfully' else diff --git a/app/models/service.rb b/app/models/service.rb index f85343877035e..c71a7d169eca7 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -254,16 +254,6 @@ def self.build_from_template(project_id, template) service end - def update_and_propagate(service_params) - return false unless update_attributes(service_params) - - if service_params[:active] - PropagateProjectServiceWorker.perform_async(id) - end - - true - end - private def cache_project_has_external_issue_tracker diff --git a/app/services/projects/propagate_service.rb b/app/services/projects/propagate_service.rb index 3c05dcce07c7a..6e24a67d8b0b7 100644 --- a/app/services/projects/propagate_service.rb +++ b/app/services/projects/propagate_service.rb @@ -2,15 +2,15 @@ module Projects class PropagateService BATCH_SIZE = 100 - def self.propagate!(*args) - new(*args).propagate! + def self.propagate(*args) + new(*args).propagate end def initialize(template) @template = template end - def propagate! + def propagate return unless @template&.active Rails.logger.info("Propagating services for template #{@template.id}") @@ -28,7 +28,7 @@ def propagate_projects_with_template batch.each { |project_id| create_from_template(project_id) } - break if batch.count < BATCH_SIZE + break if batch.size < BATCH_SIZE offset += BATCH_SIZE end diff --git a/app/workers/propagate_project_service_worker.rb b/app/workers/propagate_project_service_worker.rb index 6cc3fe846356a..5eabe4eaecdbc 100644 --- a/app/workers/propagate_project_service_worker.rb +++ b/app/workers/propagate_project_service_worker.rb @@ -10,7 +10,7 @@ class PropagateProjectServiceWorker def perform(template_id) return unless try_obtain_lease_for(template_id) - Projects::PropagateService.propagate!(Service.find_by(id: template_id)) + Projects::PropagateService.propagate(Service.find_by(id: template_id)) end private diff --git a/spec/controllers/admin/services_controller_spec.rb b/spec/controllers/admin/services_controller_spec.rb index e5cdd52307ee4..808c98edb7f13 100644 --- a/spec/controllers/admin/services_controller_spec.rb +++ b/spec/controllers/admin/services_controller_spec.rb @@ -23,4 +23,36 @@ end end end + + describe "#update" do + let(:project) { create(:empty_project) } + let!(:service) do + RedmineService.create( + project: project, + active: false, + template: true, + properties: { + project_url: 'http://abc', + issues_url: 'http://abc', + new_issue_url: 'http://abc' + } + ) + end + + it 'updates the service params successfully and calls the propagation worker' do + expect(PropagateProjectServiceWorker).to receive(:perform_async).with(service.id) + + put :update, id: service.id, service: { active: true } + + expect(response).to have_http_status(302) + end + + it 'updates the service params successfully' do + expect(PropagateProjectServiceWorker).not_to receive(:perform_async) + + put :update, id: service.id, service: { properties: {} } + + expect(response).to have_http_status(302) + end + end end diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb index 3a7d8b729938b..134882648b9b4 100644 --- a/spec/models/service_spec.rb +++ b/spec/models/service_spec.rb @@ -254,31 +254,4 @@ end end end - - describe "#update_and_propagate" do - let(:project) { create(:empty_project) } - let!(:service) do - RedmineService.create( - project: project, - active: false, - properties: { - project_url: 'http://abc', - issues_url: 'http://abc', - new_issue_url: 'http://abc' - } - ) - end - - it 'updates the service params successfully and calls the propagation worker' do - expect(PropagateProjectServiceWorker).to receive(:perform_async).with(service.id) - - expect(service.update_and_propagate(active: true)).to be true - end - - it 'updates the service params successfully' do - expect(PropagateProjectServiceWorker).not_to receive(:perform_async) - - expect(service.update_and_propagate(properties: {})).to be true - end - end end diff --git a/spec/services/projects/propagate_service_spec.rb b/spec/services/projects/propagate_service_spec.rb index 409c14655cd33..d4ec7c0b357bc 100644 --- a/spec/services/projects/propagate_service_spec.rb +++ b/spec/services/projects/propagate_service_spec.rb @@ -18,7 +18,7 @@ let!(:project) { create(:empty_project) } it 'creates services for projects' do - expect { described_class.propagate!(service_template) }. + expect { described_class.propagate(service_template) }. to change { Service.count }.by(1) end @@ -36,7 +36,7 @@ Service.build_from_template(project.id, other_service).save! - expect { described_class.propagate!(service_template) }. + expect { described_class.propagate(service_template) }. to change { Service.count }.by(1) end @@ -55,12 +55,12 @@ Service.build_from_template(project.id, service_template).save! Service.build_from_template(project.id, other_service).save! - expect { described_class.propagate!(service_template) }. + expect { described_class.propagate(service_template) }. not_to change { Service.count } end it 'creates the service containing the template attributes' do - described_class.propagate!(service_template) + described_class.propagate(service_template) service = Service.find_by(type: service_template.type, template: false) diff --git a/spec/workers/propagate_project_service_worker_spec.rb b/spec/workers/propagate_project_service_worker_spec.rb index c16e95bd49b6e..4c7edbfcd3e4a 100644 --- a/spec/workers/propagate_project_service_worker_spec.rb +++ b/spec/workers/propagate_project_service_worker_spec.rb @@ -21,7 +21,7 @@ describe '#perform' do it 'calls the propagate service with the template' do - expect(Projects::PropagateService).to receive(:propagate!).with(service_template) + expect(Projects::PropagateService).to receive(:propagate).with(service_template) subject.perform(service_template.id) end -- GitLab From 4b9eab02b8cc8bd443a802d1d73da26e5b3178d9 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt <bob@gitlab.com> Date: Thu, 4 May 2017 18:11:31 +0200 Subject: [PATCH 225/363] Reject EE reserved namespace paths in CE as well --- app/validators/dynamic_path_validator.rb | 7 +++++++ .../20170412174900_rename_reserved_dynamic_paths.rb | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/app/validators/dynamic_path_validator.rb b/app/validators/dynamic_path_validator.rb index 226eb6b313c1f..d992b0c3725ac 100644 --- a/app/validators/dynamic_path_validator.rb +++ b/app/validators/dynamic_path_validator.rb @@ -115,13 +115,20 @@ class DynamicPathValidator < ActiveModel::EachValidator # this would map to the activity-page of it's parent. GROUP_ROUTES = %w[ activity + analytics + audit_events avatar edit group_members + hooks issues labels + ldap + ldap_group_links merge_requests milestones + notification_setting + pipeline_quota projects subgroups ].freeze diff --git a/db/post_migrate/20170412174900_rename_reserved_dynamic_paths.rb b/db/post_migrate/20170412174900_rename_reserved_dynamic_paths.rb index a23f83205f148..08cf366f0a197 100644 --- a/db/post_migrate/20170412174900_rename_reserved_dynamic_paths.rb +++ b/db/post_migrate/20170412174900_rename_reserved_dynamic_paths.rb @@ -36,10 +36,17 @@ class RenameReservedDynamicPaths < ActiveRecord::Migration DISSALLOWED_GROUP_PATHS = %w[ activity + analytics + audit_events avatar group_members + hooks labels + ldap + ldap_group_links milestones + notification_setting + pipeline_quota subgroups ] -- GitLab From 58e36044397b7fad093dfaea0dc385e3d9ce2f06 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Thu, 4 May 2017 17:19:41 +0100 Subject: [PATCH 226/363] Fixed bug where merge request JSON would show Closes #28909 --- app/views/projects/merge_requests/_show.html.haml | 2 +- changelogs/unreleased/merge-request-poll-json-endpoint.yml | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 changelogs/unreleased/merge-request-poll-json-endpoint.yml diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index 881ee9fd59656..9e306d4543ce9 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -6,7 +6,7 @@ = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('diff_notes') -.merge-request{ 'data-url' => merge_request_path(@merge_request), 'data-project-path' => project_path(@merge_request.project) } +.merge-request{ 'data-url' => merge_request_path(@merge_request, format: :json), 'data-project-path' => project_path(@merge_request.project) } = render "projects/merge_requests/show/mr_title" .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } } diff --git a/changelogs/unreleased/merge-request-poll-json-endpoint.yml b/changelogs/unreleased/merge-request-poll-json-endpoint.yml new file mode 100644 index 0000000000000..6c41984e9b702 --- /dev/null +++ b/changelogs/unreleased/merge-request-poll-json-endpoint.yml @@ -0,0 +1,4 @@ +--- +title: Fixed bug where merge request JSON would be displayed +merge_request: +author: -- GitLab From dac3b6a4164c77bb142ec1644ed7c697c5c69459 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Thu, 4 May 2017 17:21:27 +0100 Subject: [PATCH 227/363] Review changes, removed api/v3 updates as its frozen, removed the clientside_sentry properties from the sensitive data filter as they\'re both available publically --- config/application.rb | 3 --- lib/api/v3/settings.rb | 4 ---- 2 files changed, 7 deletions(-) diff --git a/config/application.rb b/config/application.rb index 46652637c1f36..f2ecc4ce77c7e 100644 --- a/config/application.rb +++ b/config/application.rb @@ -55,7 +55,6 @@ class Application < Rails::Application # - Webhook URLs (:hook) # - GitLab-shell secret token (:secret_token) # - Sentry DSN (:sentry_dsn) - # - Clientside Sentry DSN (:clientside_sentry_dsn) # - Deploy keys (:key) config.filter_parameters += %i( authentication_token @@ -72,8 +71,6 @@ class Application < Rails::Application runners_token secret_token sentry_dsn - clientside_sentry_enabled - clientside_sentry_dsn variables ) diff --git a/lib/api/v3/settings.rb b/lib/api/v3/settings.rb index 67a85e13d8c48..aa31f01b62cc6 100644 --- a/lib/api/v3/settings.rb +++ b/lib/api/v3/settings.rb @@ -89,10 +89,6 @@ def current_settings given sentry_enabled: ->(val) { val } do requires :sentry_dsn, type: String, desc: 'Sentry Data Source Name' end - optional :clientside_sentry_enabled, type: Boolean, desc: 'Sentry can also be used for reporting and logging clientside exceptions. https://sentry.io/for/javascript/' - given clientside_sentry_enabled: ->(val) { val } do - requires :clientside_sentry_dsn, type: String, desc: 'Clientside Sentry Data Source Name' - end optional :repository_storage, type: String, desc: 'Storage paths for new projects' optional :repository_checks_enabled, type: Boolean, desc: "GitLab will periodically run 'git fsck' in all project and wiki repositories to look for silent disk corruption issues." optional :koding_enabled, type: Boolean, desc: 'Enable Koding' -- GitLab From 136baeda508ddf46f6d91c03d4128b2ee890d205 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Thu, 4 May 2017 17:31:59 +0100 Subject: [PATCH 228/363] Fixed Karma spec --- .../javascripts/deploy_keys/components/app.vue | 2 +- .../deploy_keys/components/app_spec.js | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index a6552125e6692..7315a9e11cb02 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -66,7 +66,7 @@ }, beforeDestroy() { eventHub.$off('enable.key', this.enableKey); - eventHub.$off('remove.key', this.removeKey); + eventHub.$off('remove.key', this.disableKey); eventHub.$off('disable.key', this.disableKey); }, }; diff --git a/spec/javascripts/deploy_keys/components/app_spec.js b/spec/javascripts/deploy_keys/components/app_spec.js index 43b8f71850883..700897f50b0ee 100644 --- a/spec/javascripts/deploy_keys/components/app_spec.js +++ b/spec/javascripts/deploy_keys/components/app_spec.js @@ -116,15 +116,24 @@ describe('Deploy keys app component', () => { expect(vm.service.disableKey).toHaveBeenCalledWith(key.id); }); - it('calls disableKey when removing a key', () => { + it('calls disableKey when removing a key', (done) => { const key = data.public_keys[0]; spyOn(window, 'confirm').and.returnValue(true); - spyOn(vm, 'disableKey'); + spyOn(vm.service, 'getKeys'); + spyOn(vm.service, 'disableKey').and.callFake(() => new Promise((resolve) => { + resolve(); + + setTimeout(() => { + expect(vm.service.getKeys).toHaveBeenCalled(); + + done(); + }); + })); eventHub.$emit('remove.key', key); - expect(vm.disableKey).toHaveBeenCalledWith(key); + expect(vm.service.disableKey).toHaveBeenCalledWith(key.id); }); it('hasKeys returns true when there are keys', () => { -- GitLab From 4b1d580a84016397bc1dfd0d089b8ed7ace9228d Mon Sep 17 00:00:00 2001 From: Clement Ho <ClemMakesApps@gmail.com> Date: Thu, 4 May 2017 11:43:45 -0500 Subject: [PATCH 229/363] Fix FE conflicts --- .../components/collapsed_state.js | 42 ------- .../components/comparison_pane.js | 70 ----------- .../components/estimate_only_pane.js | 14 --- .../time_tracking/components/help_state.js | 25 ---- .../components/spent_only_pane.js | 14 --- .../time_tracking/components/time_tracker.js | 117 ------------------ app/assets/javascripts/members.js | 21 +--- app/assets/javascripts/milestone_select.js | 20 --- app/assets/javascripts/users_select.js | 23 ---- .../components/sidebar/_assignee.html.haml | 4 - .../shared/issuable/_search_bar.html.haml | 4 - app/views/shared/issuable/_sidebar.html.haml | 5 - 12 files changed, 1 insertion(+), 358 deletions(-) delete mode 100644 app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/components/help_state.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/components/time_tracker.js diff --git a/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js b/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js deleted file mode 100644 index aec13e78f42ac..0000000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js +++ /dev/null @@ -1,42 +0,0 @@ -import Vue from 'vue'; -import stopwatchSvg from 'icons/_icon_stopwatch.svg'; - -require('../../../lib/utils/pretty_time'); - -(() => { - Vue.component('time-tracking-collapsed-state', { - name: 'time-tracking-collapsed-state', - props: [ - 'showComparisonState', - 'showSpentOnlyState', - 'showEstimateOnlyState', - 'showNoTimeTrackingState', - 'timeSpentHumanReadable', - 'timeEstimateHumanReadable', - ], - methods: { - abbreviateTime(timeStr) { - return gl.utils.prettyTime.abbreviateTime(timeStr); - }, - }, - template: ` - <div class='sidebar-collapsed-icon'> - ${stopwatchSvg} - <div class='time-tracking-collapsed-summary'> - <div class='compare' v-if='showComparisonState'> - <span>{{ abbreviateTime(timeSpentHumanReadable) }} / {{ abbreviateTime(timeEstimateHumanReadable) }}</span> - </div> - <div class='estimate-only' v-if='showEstimateOnlyState'> - <span class='bold'>-- / {{ abbreviateTime(timeEstimateHumanReadable) }}</span> - </div> - <div class='spend-only' v-if='showSpentOnlyState'> - <span class='bold'>{{ abbreviateTime(timeSpentHumanReadable) }} / --</span> - </div> - <div class='no-tracking' v-if='showNoTimeTrackingState'> - <span class='no-value'>None</span> - </div> - </div> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js b/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js deleted file mode 100644 index c55e263f6f475..0000000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js +++ /dev/null @@ -1,70 +0,0 @@ -import Vue from 'vue'; - -require('../../../lib/utils/pretty_time'); - -(() => { - const prettyTime = gl.utils.prettyTime; - - Vue.component('time-tracking-comparison-pane', { - name: 'time-tracking-comparison-pane', - props: [ - 'timeSpent', - 'timeEstimate', - 'timeSpentHumanReadable', - 'timeEstimateHumanReadable', - ], - computed: { - parsedRemaining() { - const diffSeconds = this.timeEstimate - this.timeSpent; - return prettyTime.parseSeconds(diffSeconds); - }, - timeRemainingHumanReadable() { - return prettyTime.stringifyTime(this.parsedRemaining); - }, - timeRemainingTooltip() { - const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:'; - return `${prefix} ${this.timeRemainingHumanReadable}`; - }, - /* Diff values for comparison meter */ - timeRemainingMinutes() { - return this.timeEstimate - this.timeSpent; - }, - timeRemainingPercent() { - return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`; - }, - timeRemainingStatusClass() { - return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate'; - }, - /* Parsed time values */ - parsedEstimate() { - return prettyTime.parseSeconds(this.timeEstimate); - }, - parsedSpent() { - return prettyTime.parseSeconds(this.timeSpent); - }, - }, - template: ` - <div class='time-tracking-comparison-pane'> - <div class='compare-meter' data-toggle='tooltip' data-placement='top' role='timeRemainingDisplay' - :aria-valuenow='timeRemainingTooltip' - :title='timeRemainingTooltip' - :data-original-title='timeRemainingTooltip' - :class='timeRemainingStatusClass'> - <div class='meter-container' role='timeSpentPercent' :aria-valuenow='timeRemainingPercent'> - <div :style='{ width: timeRemainingPercent }' class='meter-fill'></div> - </div> - <div class='compare-display-container'> - <div class='compare-display pull-left'> - <span class='compare-label'>Spent</span> - <span class='compare-value spent'>{{ timeSpentHumanReadable }}</span> - </div> - <div class='compare-display estimated pull-right'> - <span class='compare-label'>Est</span> - <span class='compare-value'>{{ timeEstimateHumanReadable }}</span> - </div> - </div> - </div> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js b/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js deleted file mode 100644 index a7fbd704c40d3..0000000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js +++ /dev/null @@ -1,14 +0,0 @@ -import Vue from 'vue'; - -(() => { - Vue.component('time-tracking-estimate-only-pane', { - name: 'time-tracking-estimate-only-pane', - props: ['timeEstimateHumanReadable'], - template: ` - <div class='time-tracking-estimate-only-pane'> - <span class='bold'>Estimated:</span> - {{ timeEstimateHumanReadable }} - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/help_state.js b/app/assets/javascripts/issuable/time_tracking/components/help_state.js deleted file mode 100644 index 344b29ebea4d4..0000000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/help_state.js +++ /dev/null @@ -1,25 +0,0 @@ -import Vue from 'vue'; - -(() => { - Vue.component('time-tracking-help-state', { - name: 'time-tracking-help-state', - props: ['docsUrl'], - template: ` - <div class='time-tracking-help-state'> - <div class='time-tracking-info'> - <h4>Track time with slash commands</h4> - <p>Slash commands can be used in the issues description and comment boxes.</p> - <p> - <code>/estimate</code> - will update the estimated time with the latest command. - </p> - <p> - <code>/spend</code> - will update the sum of the time spent. - </p> - <a class='btn btn-default learn-more-button' :href='docsUrl'>Learn more</a> - </div> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js b/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js deleted file mode 100644 index edb9169112ff6..0000000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js +++ /dev/null @@ -1,14 +0,0 @@ -import Vue from 'vue'; - -(() => { - Vue.component('time-tracking-spent-only-pane', { - name: 'time-tracking-spent-only-pane', - props: ['timeSpentHumanReadable'], - template: ` - <div class='time-tracking-spend-only-pane'> - <span class='bold'>Spent:</span> - {{ timeSpentHumanReadable }} - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js b/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js deleted file mode 100644 index 0213522f5519b..0000000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js +++ /dev/null @@ -1,117 +0,0 @@ -import Vue from 'vue'; - -require('./help_state'); -require('./collapsed_state'); -require('./spent_only_pane'); -require('./no_tracking_pane'); -require('./estimate_only_pane'); -require('./comparison_pane'); - -(() => { - Vue.component('issuable-time-tracker', { - name: 'issuable-time-tracker', - props: [ - 'time_estimate', - 'time_spent', - 'human_time_estimate', - 'human_time_spent', - 'docsUrl', - ], - data() { - return { - showHelp: false, - }; - }, - computed: { - timeSpent() { - return this.time_spent; - }, - timeEstimate() { - return this.time_estimate; - }, - timeEstimateHumanReadable() { - return this.human_time_estimate; - }, - timeSpentHumanReadable() { - return this.human_time_spent; - }, - hasTimeSpent() { - return !!this.timeSpent; - }, - hasTimeEstimate() { - return !!this.timeEstimate; - }, - showComparisonState() { - return this.hasTimeEstimate && this.hasTimeSpent; - }, - showEstimateOnlyState() { - return this.hasTimeEstimate && !this.hasTimeSpent; - }, - showSpentOnlyState() { - return this.hasTimeSpent && !this.hasTimeEstimate; - }, - showNoTimeTrackingState() { - return !this.hasTimeEstimate && !this.hasTimeSpent; - }, - showHelpState() { - return !!this.showHelp; - }, - }, - methods: { - toggleHelpState(show) { - this.showHelp = show; - }, - }, - template: ` - <div class='time_tracker time-tracking-component-wrap' v-cloak> - <time-tracking-collapsed-state - :show-comparison-state='showComparisonState' - :show-help-state='showHelpState' - :show-spent-only-state='showSpentOnlyState' - :show-estimate-only-state='showEstimateOnlyState' - :time-spent-human-readable='timeSpentHumanReadable' - :time-estimate-human-readable='timeEstimateHumanReadable'> - </time-tracking-collapsed-state> - <div class='title hide-collapsed'> - Time tracking - <div class='help-button pull-right' - v-if='!showHelpState' - @click='toggleHelpState(true)'> - <i class='fa fa-question-circle' aria-hidden='true'></i> - </div> - <div class='close-help-button pull-right' - v-if='showHelpState' - @click='toggleHelpState(false)'> - <i class='fa fa-close' aria-hidden='true'></i> - </div> - </div> - <div class='time-tracking-content hide-collapsed'> - <time-tracking-estimate-only-pane - v-if='showEstimateOnlyState' - :time-estimate-human-readable='timeEstimateHumanReadable'> - </time-tracking-estimate-only-pane> - <time-tracking-spent-only-pane - v-if='showSpentOnlyState' - :time-spent-human-readable='timeSpentHumanReadable'> - </time-tracking-spent-only-pane> - <time-tracking-no-tracking-pane - v-if='showNoTimeTrackingState'> - </time-tracking-no-tracking-pane> - <time-tracking-comparison-pane - v-if='showComparisonState' - :time-estimate='timeEstimate' - :time-spent='timeSpent' - :time-spent-human-readable='timeSpentHumanReadable' - :time-estimate-human-readable='timeEstimateHumanReadable'> - </time-tracking-comparison-pane> - <transition name='help-state-toggle'> - <time-tracking-help-state - v-if='showHelpState' - :docs-url='docsUrl'> - </time-tracking-help-state> - </transition> - </div> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js index bfc4e55116834..8291b8c4a709d 100644 --- a/app/assets/javascripts/members.js +++ b/app/assets/javascripts/members.js @@ -31,27 +31,8 @@ toggleLabel(selected, $el) { return $el.text(); }, -<<<<<<< HEAD - clicked: (selected, $link) => { - this.formSubmit(null, $link); -======= clicked: (options) => { - const $link = options.$el; - - if (!$link.data('revert')) { - this.formSubmit(null, $link); - } else { - const { $memberListItem, $toggle, $dateInput } = this.getMemberListItems($link); - - $toggle.disable(); - $dateInput.disable(); - - this.overrideLdap($memberListItem, $link.data('endpoint'), false).fail(() => { - $toggle.enable(); - $dateInput.enable(); - }); - } ->>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master + this.formSubmit(null, options.$el); }, }); }); diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index 0ca7aedb5bd4b..11e68c0a3be80 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -121,30 +121,10 @@ return $value.css('display', ''); }, vue: $dropdown.hasClass('js-issue-board-sidebar'), -<<<<<<< HEAD - clicked: function(selected, $el, e) { -======= - hideRow: function(milestone) { - if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar') && - !$dropdown.closest('.add-issues-modal').length && gl.issueBoards.BoardsStore.state.currentBoard.milestone) { - return milestone !== gl.issueBoards.BoardsStore.state.currentBoard.milestone.title; - } - - return false; - }, - isSelectable: function() { - if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar') && - !$dropdown.closest('.add-issues-modal').length && gl.issueBoards.BoardsStore.state.currentBoard.milestone_id) { - return false; - } - - return true; - }, clicked: function(options) { const { $el, e } = options; let selected = options.selectedObj; ->>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master var data, isIssueIndex, isMRIndex, page, boardsStore; page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 7828bf86f6028..cd1484fa857e1 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -320,11 +320,6 @@ import eventHub from './sidebar/event_hub'; $value.css('display', ''); } }, -<<<<<<< HEAD - vue: $dropdown.hasClass('js-issue-board-sidebar'), - clicked: function(user, $el, e) { - var isIssueIndex, isMRIndex, page, selected, isSelecting; -======= multiSelect: $dropdown.hasClass('js-multiselect'), inputMeta: $dropdown.data('input-meta'), clicked: function(options) { @@ -390,7 +385,6 @@ import eventHub from './sidebar/event_hub'; } var isIssueIndex, isMRIndex, page, selected; ->>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; isMRIndex = (page === page && page === 'projects:merge_requests:index'); @@ -411,24 +405,7 @@ import eventHub from './sidebar/event_hub'; return Issuable.filterResults($dropdown.closest('form')); } else if ($dropdown.hasClass('js-filter-submit')) { return $dropdown.closest('form').submit(); -<<<<<<< HEAD - } else if ($dropdown.hasClass('js-issue-board-sidebar')) { - if (user.id && isSelecting) { - gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({ - id: user.id, - username: user.username, - name: user.name, - avatar_url: user.avatar_url - })); - } else { - gl.issueBoards.boardStoreIssueDelete('assignee'); - } - - updateIssueBoardsIssue(); - } else { -======= } else if (!$dropdown.hasClass('js-multiselect')) { ->>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val(); return assignTo(selected); } diff --git a/app/views/projects/boards/components/sidebar/_assignee.html.haml b/app/views/projects/boards/components/sidebar/_assignee.html.haml index 96f7f12b1d74a..0c09e71feeed5 100644 --- a/app/views/projects/boards/components/sidebar/_assignee.html.haml +++ b/app/views/projects/boards/components/sidebar/_assignee.html.haml @@ -16,11 +16,7 @@ "v-if" => "issue.assignees", "v-for" => "assignee in issue.assignees" } .dropdown -<<<<<<< HEAD - %button.dropdown-menu-toggle.js-user-search.js-author-search.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", field_name: "issue[assignee_id]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true", null_user_default: "true" }, -======= %button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: "button", ref: "assigneeDropdown", data: { toggle: "dropdown", field_name: "issue[assignee_ids][]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true", multi_select: "true", dropdown: { header: 'Assignee(s)'} }, ->>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master ":data-issuable-id" => "issue.id", ":data-selected" => "assigneeId", ":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" } diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index dd727f1abfb54..f7b87171573b0 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -130,11 +130,7 @@ - field_name = "update[assignee_id]" = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", -<<<<<<< HEAD - placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]", default_label: "Assignee" } }) -======= placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } }) ->>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master .filter-item.inline = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true, default_label: "Milestone" } }) .filter-item.inline.labels-filter diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 9bf73c87c88aa..11b844476338d 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -50,13 +50,8 @@ assign yourself .selectbox.hide-collapsed -<<<<<<< HEAD - = f.hidden_field 'assignee_id', value: issuable.assignee_id, id: 'issue_assignee_id' - = dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true, null_user_default: true, selected: issuable.assignee_id } }) -======= - issuable.assignees.each do |assignee| = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil ->>>>>>> b0a2435... Merge branch 'multiple_assignees_review_upstream' into ee_master - options = { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } } -- GitLab From 6f040230d2307d980dc3ebd134caebb1945a3903 Mon Sep 17 00:00:00 2001 From: Filipa Lacerda <filipa@gitlab.com> Date: Thu, 4 May 2017 17:56:46 +0100 Subject: [PATCH 230/363] Changes after review --- doc/development/fe_guide/style_guide_js.md | 32 +++++++++------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/doc/development/fe_guide/style_guide_js.md b/doc/development/fe_guide/style_guide_js.md index a3f806c171382..d2d895172410f 100644 --- a/doc/development/fe_guide/style_guide_js.md +++ b/doc/development/fe_guide/style_guide_js.md @@ -225,13 +225,6 @@ A forEach will cause side effects, it will be mutating the array being iterated. ### Vue.js #### Basic Rules -1. Only include one Vue.js component per file. -1. Export components as plain objects: - ```javascript - export default { - template: `<h1>I'm a component</h1> - } - ``` 1. The service has it's own file 1. The store has it's own file 1. Use a function in the bundle file to instantiate the Vue component: @@ -274,22 +267,13 @@ A forEach will cause side effects, it will be mutating the array being iterated. #### Naming 1. **Extensions**: Use `.vue` extension for Vue components. -1. **Reference Naming**: Use PascalCase for Vue components and camelCase for their instances: +1. **Reference Naming**: Use camelCase for their instances: ```javascript - // bad - import cardBoard from 'cardBoard'; - // good - import CardBoard from 'cardBoard' + import cardBoard from 'cardBoard' - // bad components: { - CardBoard: CardBoard - }; - - // good - components: { - cardBoard: CardBoard + cardBoard: }; ``` @@ -495,6 +479,16 @@ A forEach will cause side effects, it will be mutating the array being iterated. 1. Tooltips: When using a tooltip, include the tooltip mixin 1. Don't change `data-original-title`. + ```javascript + // bad + <span data-original-title="tooltip text">Foo</span> + + // good + <span title="tooltip text">Foo</span> + + $('span').tooltip('fixTitle'); + ``` + ## SCSS - [SCSS](style_guide_scss.md) -- GitLab From 0f2a9681a318e5d27ef4e45195f2ac4b75f351dc Mon Sep 17 00:00:00 2001 From: Valery Sizov <valery@gitlab.com> Date: Thu, 4 May 2017 18:52:01 +0300 Subject: [PATCH 231/363] [Multiple issue assignees] Resolving conflicts --- app/models/issue_assignee.rb | 16 ---------------- app/models/user.rb | 1 - app/services/notification_service.rb | 4 +++- 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb index b94b55bb1d1ec..0663d3aaef8b4 100644 --- a/app/models/issue_assignee.rb +++ b/app/models/issue_assignee.rb @@ -7,23 +7,7 @@ class IssueAssignee < ActiveRecord::Base after_create :update_assignee_cache_counts after_destroy :update_assignee_cache_counts - # EE-specific - after_create :update_elasticsearch_index - after_destroy :update_elasticsearch_index - # EE-specific - def update_assignee_cache_counts assignee&.update_cache_counts end - - def update_elasticsearch_index - if current_application_settings.elasticsearch_indexing? - ElasticIndexerWorker.perform_async( - :update, - 'Issue', - issue.id, - changed_fields: ['assignee_ids'] - ) - end - end end diff --git a/app/models/user.rb b/app/models/user.rb index a3126cbb644a4..6015643b56507 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -89,7 +89,6 @@ class User < ActiveRecord::Base has_many :subscriptions, dependent: :destroy has_many :recent_events, -> { order "id DESC" }, foreign_key: :author_id, class_name: "Event" has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy - has_one :abuse_report, dependent: :destroy, foreign_key: :user_id has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport" has_many :spam_logs, dependent: :destroy diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index fe9f5ae2b3304..c65c66d715040 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -74,12 +74,14 @@ def reassigned_issue(issue, current_user, previous_assignees = []) previous_assignee: previous_assignees ) + previous_assignee_ids = previous_assignees.map(&:id) + recipients.each do |recipient| mailer.send( :reassigned_issue_email, recipient.id, issue.id, - previous_assignees.map(&:id), + previous_assignee_ids, current_user.id ).deliver_later end -- GitLab From fc464f1ff27734df81f7f85cbc23e93902c97cda Mon Sep 17 00:00:00 2001 From: Valery Sizov <valery@gitlab.com> Date: Thu, 4 May 2017 20:22:03 +0300 Subject: [PATCH 232/363] Multiple issue assignee: fixed services/issues/update_service by using new NoteSummary --- app/services/system_note_service.rb | 2 +- spec/features/boards/sidebar_spec.rb | 21 --------------------- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 898c69b3f8c2c..1dee791cfd615 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -84,7 +84,7 @@ def change_issue_assignees(issue, project, author, old_assignees) "assigned to #{issue.assignees.map(&:to_reference).to_sentence}" end - create_note(noteable: issue, project: project, author: author, note: body) + NoteSummary.new(issue, project, author, body, action: 'assignee') end # Called when one or more labels on a Noteable are added and/or removed diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb index 02b6b5dc88865..7c53d2b47d9c8 100644 --- a/spec/features/boards/sidebar_spec.rb +++ b/spec/features/boards/sidebar_spec.rb @@ -21,7 +21,6 @@ Timecop.freeze project.team << [user, :master] - project.team.add_developer(user2) login_as(user) @@ -103,26 +102,6 @@ expect(card).to have_selector('.avatar') end - it 'adds multiple assignees' do - click_card(card) - - page.within('.assignee') do - click_link 'Edit' - - wait_for_ajax - - page.within('.dropdown-menu-user') do - click_link user.name - click_link user2.name - end - - expect(page).to have_content(user.name) - expect(page).to have_content(user2.name) - end - - expect(card.all('.avatar').length).to eq(2) - end - it 'removes the assignee' do card_two = first('.board').find('.card:nth-child(2)') click_card(card_two) -- GitLab From cdcbe99aa749d4516f47ca9d2e7d510dc29d1c91 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Wed, 3 May 2017 22:36:02 -0300 Subject: [PATCH 233/363] Add last_repository_updated_at to projects --- ...503004125_add_last_repository_updated_at_to_projects.rb | 7 +++++++ db/schema.rb | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20170503004125_add_last_repository_updated_at_to_projects.rb diff --git a/db/migrate/20170503004125_add_last_repository_updated_at_to_projects.rb b/db/migrate/20170503004125_add_last_repository_updated_at_to_projects.rb new file mode 100644 index 0000000000000..00c685cf342fe --- /dev/null +++ b/db/migrate/20170503004125_add_last_repository_updated_at_to_projects.rb @@ -0,0 +1,7 @@ +class AddLastRepositoryUpdatedAtToProjects < ActiveRecord::Migration + DOWNTIME = false + + def change + add_column :projects, :last_repository_updated_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 01c0f00c92496..e12c6e30d8e0d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170502091007) do +ActiveRecord::Schema.define(version: 20170503004125) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -971,6 +971,7 @@ t.boolean "printing_merge_request_link_enabled", default: true, null: false t.string "import_jid" t.integer "cached_markdown_version" + t.datetime "last_repository_updated_at" end add_index "projects", ["ci_id"], name: "index_projects_on_ci_id", using: :btree -- GitLab From 341964da45460d5c6593d0e08178ecf61e726e91 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Wed, 3 May 2017 22:37:43 -0300 Subject: [PATCH 234/363] Add index to last_repository_updated_at on projects --- ...x_to_last_repository_updated_at_on_projects.rb | 15 +++++++++++++++ db/schema.rb | 3 ++- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20170503004425_add_index_to_last_repository_updated_at_on_projects.rb diff --git a/db/migrate/20170503004425_add_index_to_last_repository_updated_at_on_projects.rb b/db/migrate/20170503004425_add_index_to_last_repository_updated_at_on_projects.rb new file mode 100644 index 0000000000000..6144d74745c8b --- /dev/null +++ b/db/migrate/20170503004425_add_index_to_last_repository_updated_at_on_projects.rb @@ -0,0 +1,15 @@ +class AddIndexToLastRepositoryUpdatedAtOnProjects < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index(:projects, :last_repository_updated_at) + end + + def down + remove_concurrent_index(:projects, :last_repository_updated_at) if index_exists?(:projects, :last_repository_updated_at) + end +end diff --git a/db/schema.rb b/db/schema.rb index e12c6e30d8e0d..5d3e22d612304 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170503004125) do +ActiveRecord::Schema.define(version: 20170503004425) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -980,6 +980,7 @@ add_index "projects", ["description"], name: "index_projects_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} add_index "projects", ["last_activity_at"], name: "index_projects_on_last_activity_at", using: :btree add_index "projects", ["last_repository_check_failed"], name: "index_projects_on_last_repository_check_failed", using: :btree + add_index "projects", ["last_repository_updated_at"], name: "index_projects_on_last_repository_updated_at", using: :btree add_index "projects", ["name"], name: "index_projects_on_name_trigram", using: :gin, opclasses: {"name"=>"gin_trgm_ops"} add_index "projects", ["namespace_id"], name: "index_projects_on_namespace_id", using: :btree add_index "projects", ["path"], name: "index_projects_on_path", using: :btree -- GitLab From 56db54d3e53f5bac4aa5190d3189f015e3fdbfa7 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Wed, 3 May 2017 22:39:41 -0300 Subject: [PATCH 235/363] Set last_repository_updated_at to created_at upon project creation --- app/models/project.rb | 5 +++++ spec/models/project_spec.rb | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/app/models/project.rb b/app/models/project.rb index 025db89ebfd82..edbca3b537b33 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -53,6 +53,11 @@ def set_last_activity_at update_column(:last_activity_at, self.created_at) end + after_create :set_last_repository_updated_at + def set_last_repository_updated_at + update_column(:last_repository_updated_at, self.created_at) + end + after_destroy :remove_pages # update visibility_level of forks diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 36ce3070a6eb0..316ece87faac5 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -1925,4 +1925,12 @@ def enable_lfs not_to raise_error end end + + describe '#last_repository_updated_at' do + it 'sets to created_at upon creation' do + project = create(:empty_project, created_at: 2.hours.ago) + + expect(project.last_repository_updated_at.to_i).to eq(project.created_at.to_i) + end + end end -- GitLab From 91b5aaf770836b7101414527a4650db5fa669ce2 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Wed, 3 May 2017 22:44:39 -0300 Subject: [PATCH 236/363] Update last_repository_updated_at when a push event is created --- app/models/event.rb | 6 ++++++ spec/models/event_spec.rb | 42 +++++++++++++++++++++++++++++++-------- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/app/models/event.rb b/app/models/event.rb index b780c1faf81d9..e6fad46077aa1 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -30,6 +30,7 @@ class Event < ActiveRecord::Base # Callbacks after_create :reset_project_activity + after_create :set_last_repository_updated_at, if: :push? # Scopes scope :recent, -> { reorder(id: :desc) } @@ -357,4 +358,9 @@ def authored_by?(user) def recent_update? project.last_activity_at > RESET_PROJECT_ACTIVITY_INTERVAL.ago end + + def set_last_repository_updated_at + Project.unscoped.where(id: project_id). + update_all(last_repository_updated_at: created_at) + end end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index 8c90a538f5723..a9c5b604268cd 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -15,13 +15,39 @@ end describe 'Callbacks' do - describe 'after_create :reset_project_activity' do - let(:project) { create(:empty_project) } + let(:project) { create(:empty_project) } + describe 'after_create :reset_project_activity' do it 'calls the reset_project_activity method' do expect_any_instance_of(described_class).to receive(:reset_project_activity) - create_event(project, project.owner) + create_push_event(project, project.owner) + end + end + + describe 'after_create :set_last_repository_updated_at' do + context 'with a push event' do + it 'updates the project last_repository_updated_at' do + project.update(last_repository_updated_at: 1.year.ago) + + create_push_event(project, project.owner) + + project.reload + + expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now) + end + end + + context 'without a push event' do + it 'does not update the project last_repository_updated_at' do + project.update(last_repository_updated_at: 1.year.ago) + + create(:closed_issue_event, project: project, author: project.owner) + + project.reload + + expect(project.last_repository_updated_at).to be_within(1.minute).of(1.year.ago) + end end end end @@ -29,7 +55,7 @@ describe "Push event" do let(:project) { create(:empty_project, :private) } let(:user) { project.owner } - let(:event) { create_event(project, user) } + let(:event) { create_push_event(project, user) } it do expect(event.push?).to be_truthy @@ -243,7 +269,7 @@ expect(project).not_to receive(:update_column). with(:last_activity_at, a_kind_of(Time)) - create_event(project, project.owner) + create_push_event(project, project.owner) end end @@ -251,11 +277,11 @@ it 'updates the project' do project.update(last_activity_at: 1.year.ago) - create_event(project, project.owner) + create_push_event(project, project.owner) project.reload - project.last_activity_at <= 1.minute.ago + expect(project.last_activity_at).to be_within(1.minute).of(Time.now) end end end @@ -278,7 +304,7 @@ end end - def create_event(project, user, attrs = {}) + def create_push_event(project, user, attrs = {}) data = { before: Gitlab::Git::BLANK_SHA, after: "0220c11b9a3e6c69dc8fd35321254ca9a7b98f7e", -- GitLab From dbb50ed890e9a81dbba288fac033dec7f0b3efeb Mon Sep 17 00:00:00 2001 From: Clement Ho <ClemMakesApps@gmail.com> Date: Thu, 4 May 2017 13:36:18 -0500 Subject: [PATCH 237/363] Restrict FE assignees to 1 --- app/assets/javascripts/users_select.js | 6 ++++-- .../projects/boards/components/sidebar/_assignee.html.haml | 4 ++-- app/views/shared/issuable/_sidebar.html.haml | 7 ++++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index cd1484fa857e1..64171e491e576 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -388,10 +388,12 @@ import eventHub from './sidebar/event_hub'; page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; isMRIndex = (page === page && page === 'projects:merge_requests:index'); - isSelecting = (user.id !== selectedId); - selectedId = isSelecting ? user.id : selectedIdDefault; if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { e.preventDefault(); + + let isSelecting = (user.id !== selectedId); + selectedId = isSelecting ? user.id : selectedIdDefault; + if (selectedId === gon.current_user_id) { $('.assign-to-me-link').hide(); } else { diff --git a/app/views/projects/boards/components/sidebar/_assignee.html.haml b/app/views/projects/boards/components/sidebar/_assignee.html.haml index 0c09e71feeed5..642da679f97be 100644 --- a/app/views/projects/boards/components/sidebar/_assignee.html.haml +++ b/app/views/projects/boards/components/sidebar/_assignee.html.haml @@ -16,11 +16,11 @@ "v-if" => "issue.assignees", "v-for" => "assignee in issue.assignees" } .dropdown - %button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: "button", ref: "assigneeDropdown", data: { toggle: "dropdown", field_name: "issue[assignee_ids][]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true", multi_select: "true", dropdown: { header: 'Assignee(s)'} }, + %button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: "button", ref: "assigneeDropdown", data: { toggle: "dropdown", field_name: "issue[assignee_ids][]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true", multi_select: "true", 'max-select' => 1, dropdown: { header: 'Assignee' } }, ":data-issuable-id" => "issue.id", ":data-selected" => "assigneeId", ":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" } - Select assignee(s) + Select assignee = icon("chevron-down") .dropdown-menu.dropdown-select.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author = dropdown_title("Assign to") diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 11b844476338d..44e624c15a78d 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -53,17 +53,18 @@ - issuable.assignees.each do |assignee| = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil - - options = { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } } + - options = { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_ids][]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } } - if issuable.instance_of?(Issue) - if issuable.assignees.length == 0 = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil - - title = 'Select assignee(s)' + - title = 'Select assignee' - options[:toggle_class] += ' js-multiselect js-save-user-data' - options[:data][:field_name] = "#{issuable.to_ability_name}[assignee_ids][]" - options[:data][:multi_select] = true - options[:data]['dropdown-title'] = title - - options[:data]['dropdown-header'] = 'Assignee(s)' + - options[:data]['dropdown-header'] = 'Assignee' + - options[:data]['max-select'] = 1 - else - title = 'Select assignee' -- GitLab From 9f2edaa84642113d33fee37eee1a8daca2be54c0 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Wed, 3 May 2017 22:47:10 -0300 Subject: [PATCH 238/363] Update last_repository_updated_at when the wiki is updated --- app/models/project_wiki.rb | 2 +- spec/models/project_wiki_spec.rb | 21 +++++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index 70eef359cdd09..189c106b70b67 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -183,6 +183,6 @@ def path_to_repo end def update_project_activity - @project.touch(:last_activity_at) + @project.touch(:last_activity_at, :last_repository_updated_at) end end diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb index b5b9cd024b056..969e9f7a130de 100644 --- a/spec/models/project_wiki_spec.rb +++ b/spec/models/project_wiki_spec.rb @@ -213,9 +213,12 @@ end it 'updates project activity' do - expect(subject).to receive(:update_project_activity) - subject.create_page('Test Page', 'This is content') + + project.reload + + expect(project.last_activity_at).to be_within(1.minute).of(Time.now) + expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now) end end @@ -240,9 +243,12 @@ end it 'updates project activity' do - expect(subject).to receive(:update_project_activity) - subject.update_page(@gollum_page, 'Yet more content', :markdown, 'Updated page again') + + project.reload + + expect(project.last_activity_at).to be_within(1.minute).of(Time.now) + expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now) end end @@ -258,9 +264,12 @@ end it 'updates project activity' do - expect(subject).to receive(:update_project_activity) - subject.delete_page(@page) + + project.reload + + expect(project.last_activity_at).to be_within(1.minute).of(Time.now) + expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now) end end -- GitLab From 41d0f23108ddce3f9285898de75508564edd80b3 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Thu, 4 May 2017 18:49:56 +0000 Subject: [PATCH 239/363] Resolve "Web GUI: Unable to create new branch from commit SHA" --- app/assets/javascripts/new_branch_form.js | 16 +++++++ app/views/projects/branches/new.html.haml | 12 ++--- app/views/projects/compare/_form.html.haml | 4 +- .../_ref_dropdown.html.haml | 4 +- .../branches/new_branch_ref_dropdown_spec.rb | 48 +++++++++++++++++++ 5 files changed, 75 insertions(+), 9 deletions(-) rename app/views/{projects/compare => shared}/_ref_dropdown.html.haml (50%) create mode 100644 spec/features/projects/branches/new_branch_ref_dropdown_spec.rb diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js index 5828f460a235a..67046d52a653c 100644 --- a/app/assets/javascripts/new_branch_form.js +++ b/app/assets/javascripts/new_branch_form.js @@ -34,6 +34,7 @@ filterByText: true, remote: false, fieldName: $branchSelect.data('field-name'), + filterInput: 'input[type="search"]', selectable: true, isSelectable: function(branch, $el) { return !$el.hasClass('is-active'); @@ -50,6 +51,21 @@ } } }); + + const $dropdownContainer = $branchSelect.closest('.dropdown'); + const $fieldInput = $(`input[name="${$branchSelect.data('field-name')}"]`, $dropdownContainer); + const $filterInput = $('input[type="search"]', $dropdownContainer); + + $filterInput.on('keyup', (e) => { + const keyCode = e.keyCode || e.which; + if (keyCode !== 13) return; + + const text = $filterInput.val(); + $fieldInput.val(text); + $('.dropdown-toggle-text', $branchSelect).text(text); + + $dropdownContainer.removeClass('open'); + }); }; NewBranchForm.prototype.setupRestrictions = function() { diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml index d3c3e40d5185a..796ecdfd0145a 100644 --- a/app/views/projects/branches/new.html.haml +++ b/app/views/projects/branches/new.html.haml @@ -1,4 +1,5 @@ - page_title "New Branch" +- default_ref = params[:ref] || @project.default_branch - if @error .alert.alert-danger @@ -16,12 +17,11 @@ .help-block.text-danger.js-branch-name-error .form-group = label_tag :ref, 'Create from', class: 'control-label' - .col-sm-10 - = hidden_field_tag :ref, params[:ref] || @project.default_branch - = dropdown_tag(params[:ref] || @project.default_branch, - options: { toggle_class: 'js-branch-select wide', - filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search branches", - data: { selected: params[:ref] || @project.default_branch, field_name: 'ref' } }) + .col-sm-10.dropdown.create-from + = hidden_field_tag :ref, default_ref + = button_tag type: 'button', title: default_ref, class: 'dropdown-toggle form-control js-branch-select', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do + .text-left.dropdown-toggle-text= default_ref + = render 'shared/ref_dropdown', dropdown_class: 'wide' .help-block Existing branch name, tag, or commit SHA .form-actions = button_tag 'Create branch', class: 'btn btn-create', tabindex: 3 diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml index 0f080b6aceefb..1f4c9fac54c73 100644 --- a/app/views/projects/compare/_form.html.haml +++ b/app/views/projects/compare/_form.html.haml @@ -9,7 +9,7 @@ = hidden_field_tag :from, params[:from] = button_tag type: 'button', title: params[:from], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do .dropdown-toggle-text.str-truncated= params[:from] || 'Select branch/tag' - = render "ref_dropdown" + = render 'shared/ref_dropdown' .compare-ellipsis.inline ... .form-group.dropdown.compare-form-group.to.js-compare-to-dropdown .input-group.inline-input-group @@ -17,7 +17,7 @@ = hidden_field_tag :to, params[:to] = button_tag type: 'button', title: params[:to], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do .dropdown-toggle-text.str-truncated= params[:to] || 'Select branch/tag' - = render "ref_dropdown" + = render 'shared/ref_dropdown' = button_tag "Compare", class: "btn btn-create commits-compare-btn" - if @merge_request.present? diff --git a/app/views/projects/compare/_ref_dropdown.html.haml b/app/views/shared/_ref_dropdown.html.haml similarity index 50% rename from app/views/projects/compare/_ref_dropdown.html.haml rename to app/views/shared/_ref_dropdown.html.haml index 05fb37cdc0f52..96f68c80c48cd 100644 --- a/app/views/projects/compare/_ref_dropdown.html.haml +++ b/app/views/shared/_ref_dropdown.html.haml @@ -1,4 +1,6 @@ -.dropdown-menu.dropdown-menu-selectable +- dropdown_class = local_assigns.fetch(:dropdown_class, '') + +.dropdown-menu.dropdown-menu-selectable{ class: dropdown_class } = dropdown_title "Select Git revision" = dropdown_filter "Filter by Git revision" = dropdown_content diff --git a/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb b/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb new file mode 100644 index 0000000000000..cfc782c98ad42 --- /dev/null +++ b/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +describe 'New Branch Ref Dropdown', :js, :feature do + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + let(:toggle) { find('.create-from .dropdown-toggle') } + + before do + project.add_master(user) + + login_as(user) + visit new_namespace_project_branch_path(project.namespace, project) + end + + it 'filters a list of branches and tags' do + toggle.click + + filter_by('v1.0.0') + + expect(items_count).to be(1) + + filter_by('video') + + expect(items_count).to be(1) + + find('.create-from .dropdown-content li').click + + expect(toggle).to have_content 'video' + end + + it 'accepts a manually entered commit SHA' do + toggle.click + + filter_by('somecommitsha') + + find('.create-from input[type=search]').send_keys(:enter) + + expect(toggle).to have_content 'somecommitsha' + end + + def items_count + all('.create-from .dropdown-content li').length + end + + def filter_by(filter_text) + fill_in 'Filter by Git revision', with: filter_text + end +end -- GitLab From 9c4ee8ee64dcba297de13d453f6eea18304d50db Mon Sep 17 00:00:00 2001 From: Clement Ho <ClemMakesApps@gmail.com> Date: Thu, 4 May 2017 13:55:13 -0500 Subject: [PATCH 240/363] Fix eslint and rubocop --- .../protected_branches/protected_branch_access_dropdown.js | 3 +-- app/assets/javascripts/users_select.js | 2 +- spec/features/issues/filtered_search/filter_issues_spec.rb | 1 - spec/javascripts/boards/board_card_spec.js | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js index 1266d70f073aa..42993a252c33d 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js +++ b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js @@ -20,8 +20,7 @@ } }, clicked(opts) { - const { $el, e } = opts; - const item = opts.selectedObj; + const { e } = opts; e.preventDefault(); onSelect(); diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 64171e491e576..2dcaa24e93a1f 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -391,7 +391,7 @@ import eventHub from './sidebar/event_hub'; if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { e.preventDefault(); - let isSelecting = (user.id !== selectedId); + const isSelecting = (user.id !== selectedId); selectedId = isSelecting ? user.id : selectedIdDefault; if (selectedId === gon.current_user_id) { diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb index ece62c8da4131..a8f4e2d7e10ad 100644 --- a/spec/features/issues/filtered_search/filter_issues_spec.rb +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -54,7 +54,6 @@ def select_search_at_index(pos) create(:issue, project: project, title: "issue by assignee", milestone: milestone, author: user, assignees: [user]) create(:issue, project: project, title: "issue by assignee with searchTerm", milestone: milestone, author: user, assignees: [user]) - issue = create(:issue, title: "Bug 2", project: project, diff --git a/spec/javascripts/boards/board_card_spec.js b/spec/javascripts/boards/board_card_spec.js index de072e7e470bf..2064ca2632bbf 100644 --- a/spec/javascripts/boards/board_card_spec.js +++ b/spec/javascripts/boards/board_card_spec.js @@ -6,7 +6,7 @@ /* global BoardService */ import Vue from 'vue'; -import '~/boards/models/user'; +import '~/boards/models/assignee'; require('~/boards/models/list'); require('~/boards/models/label'); -- GitLab From ad6ac17c5434f7eb87005dc3603b4ae9409c333f Mon Sep 17 00:00:00 2001 From: Jose Ivan Vargas <jvargas@gitlab.com> Date: Fri, 28 Apr 2017 15:20:58 -0500 Subject: [PATCH 241/363] Added rescue block for the test method for the prometheus service --- .../unreleased/prometheus-integration-test-setting-fix.yml | 4 ++++ lib/gitlab/prometheus.rb | 6 +++++- spec/models/project_services/prometheus_service_spec.rb | 5 +++-- 3 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 changelogs/unreleased/prometheus-integration-test-setting-fix.yml diff --git a/changelogs/unreleased/prometheus-integration-test-setting-fix.yml b/changelogs/unreleased/prometheus-integration-test-setting-fix.yml new file mode 100644 index 0000000000000..c65c682a11442 --- /dev/null +++ b/changelogs/unreleased/prometheus-integration-test-setting-fix.yml @@ -0,0 +1,4 @@ +--- +title: Added rescue block for the test method +merge_request: 10994 +author: diff --git a/lib/gitlab/prometheus.rb b/lib/gitlab/prometheus.rb index 62239779454f6..37afec5e7df4c 100644 --- a/lib/gitlab/prometheus.rb +++ b/lib/gitlab/prometheus.rb @@ -49,7 +49,11 @@ def join_api_url(type, args = {}) end def get(url) - handle_response(HTTParty.get(url)) + begin + handle_response(HTTParty.get(url)) + rescue SocketError + raise PrometheusError, "Can't connect to #{url}" + end end def handle_response(response) diff --git a/spec/models/project_services/prometheus_service_spec.rb b/spec/models/project_services/prometheus_service_spec.rb index d15079b686be9..5ef1b53be1570 100644 --- a/spec/models/project_services/prometheus_service_spec.rb +++ b/spec/models/project_services/prometheus_service_spec.rb @@ -93,11 +93,12 @@ [404, 500].each do |status| context "when Prometheus responds with #{status}" do + body_response = 'QUERY_FAILED' before do - stub_all_prometheus_requests(environment.slug, status: status, body: 'QUERY FAILED!') + stub_all_prometheus_requests(environment.slug, status: status, body: body_response) end - it { is_expected.to eq(success: false, result: %(#{status} - "QUERY FAILED!")) } + it { is_expected.to eq(success: false, result: %(#{status} - \"#{body_response}\")) } end end end -- GitLab From 63a5d98a7c921289e9b43f4b54c03614427f7eda Mon Sep 17 00:00:00 2001 From: Jose Ivan Vargas <jvargas@gitlab.com> Date: Tue, 2 May 2017 11:16:59 -0500 Subject: [PATCH 242/363] Added specs --- lib/gitlab/prometheus.rb | 10 +++++----- spec/lib/gitlab/prometheus_spec.rb | 18 ++++++++++++++++++ spec/support/prometheus_helpers.rb | 10 ++++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/lib/gitlab/prometheus.rb b/lib/gitlab/prometheus.rb index 37afec5e7df4c..94541009100a4 100644 --- a/lib/gitlab/prometheus.rb +++ b/lib/gitlab/prometheus.rb @@ -49,11 +49,11 @@ def join_api_url(type, args = {}) end def get(url) - begin - handle_response(HTTParty.get(url)) - rescue SocketError - raise PrometheusError, "Can't connect to #{url}" - end + handle_response(HTTParty.get(url)) + rescue SocketError + raise PrometheusError, "Can't connect to #{url}" + rescue OpenSSL::SSL::SSLError + raise PrometheusError, "#{url} contains invalid SSL data" end def handle_response(response) diff --git a/spec/lib/gitlab/prometheus_spec.rb b/spec/lib/gitlab/prometheus_spec.rb index 280264188e2e2..d868366951829 100644 --- a/spec/lib/gitlab/prometheus_spec.rb +++ b/spec/lib/gitlab/prometheus_spec.rb @@ -49,6 +49,24 @@ end end + describe 'failure to reach a prometheus url' do + prometheus_invalid_url = 'https://prometheus.invalid.example.com' + + it 'raises a Gitlab::PrometheusError error when a SocketError is rescued' do + req_stub = stub_prometheus_request_with_socket_exception(prometheus_invalid_url) + + expect { subject.send(:get, prometheus_invalid_url) }.to raise_error(Gitlab::PrometheusError, "Can't connect to #{prometheus_invalid_url}") + expect(req_stub).to have_been_requested + end + + it 'raises a Gitlab::PrometheusError error when a SSLError is rescued' do + req_stub = stub_prometheus_request_with_ssl_exception(prometheus_invalid_url) + + expect { subject.send(:get, prometheus_invalid_url) }.to raise_error(Gitlab::PrometheusError, "#{prometheus_invalid_url} contains invalid SSL data") + expect(req_stub).to have_been_requested + end + end + describe '#query' do let(:prometheus_query) { prometheus_cpu_query('env-slug') } let(:query_url) { prometheus_query_url(prometheus_query) } diff --git a/spec/support/prometheus_helpers.rb b/spec/support/prometheus_helpers.rb index cc79b11616aca..625a40e1154c8 100644 --- a/spec/support/prometheus_helpers.rb +++ b/spec/support/prometheus_helpers.rb @@ -33,6 +33,16 @@ def stub_prometheus_request(url, body: {}, status: 200) }) end + def stub_prometheus_request_with_socket_exception(url) + WebMock.stub_request(:get, url) + .to_raise(SocketError) + end + + def stub_prometheus_request_with_ssl_exception(url) + WebMock.stub_request(:get, url) + .to_raise(OpenSSL::SSL::SSLError) + end + def stub_all_prometheus_requests(environment_slug, body: nil, status: 200) stub_prometheus_request( prometheus_query_url(prometheus_memory_query(environment_slug)), -- GitLab From 64e811957795293b647bda7aef23ffbd92082614 Mon Sep 17 00:00:00 2001 From: Jose Ivan Vargas <jvargas@gitlab.com> Date: Tue, 2 May 2017 16:53:49 -0500 Subject: [PATCH 243/363] Improved code styling and added a HTTParty rescue block --- lib/gitlab/prometheus.rb | 2 ++ spec/lib/gitlab/prometheus_spec.rb | 34 +++++++++++++------ .../prometheus_service_spec.rb | 5 ++- spec/support/prometheus_helpers.rb | 10 ++---- 4 files changed, 29 insertions(+), 22 deletions(-) diff --git a/lib/gitlab/prometheus.rb b/lib/gitlab/prometheus.rb index 94541009100a4..aa9810c5c4171 100644 --- a/lib/gitlab/prometheus.rb +++ b/lib/gitlab/prometheus.rb @@ -54,6 +54,8 @@ def get(url) raise PrometheusError, "Can't connect to #{url}" rescue OpenSSL::SSL::SSLError raise PrometheusError, "#{url} contains invalid SSL data" + rescue HTTParty::Error + raise PrometheusError, "An error has ocurred" end def handle_response(response) diff --git a/spec/lib/gitlab/prometheus_spec.rb b/spec/lib/gitlab/prometheus_spec.rb index d868366951829..8e187df6ca4fc 100644 --- a/spec/lib/gitlab/prometheus_spec.rb +++ b/spec/lib/gitlab/prometheus_spec.rb @@ -49,21 +49,33 @@ end end - describe 'failure to reach a prometheus url' do - prometheus_invalid_url = 'https://prometheus.invalid.example.com' + describe 'failure to reach a provided prometheus url' do + let(:prometheus_url) {"https://prometheus.invalid.example.com"} - it 'raises a Gitlab::PrometheusError error when a SocketError is rescued' do - req_stub = stub_prometheus_request_with_socket_exception(prometheus_invalid_url) + context 'exceptions are raised' do + it 'raises a Gitlab::PrometheusError error when a SocketError is rescued' do + req_stub = stub_prometheus_request_with_exception(prometheus_url, SocketError) - expect { subject.send(:get, prometheus_invalid_url) }.to raise_error(Gitlab::PrometheusError, "Can't connect to #{prometheus_invalid_url}") - expect(req_stub).to have_been_requested - end + expect { subject.send(:get, prometheus_url) } + .to raise_error(Gitlab::PrometheusError, "Can't connect to #{prometheus_url}") + expect(req_stub).to have_been_requested + end - it 'raises a Gitlab::PrometheusError error when a SSLError is rescued' do - req_stub = stub_prometheus_request_with_ssl_exception(prometheus_invalid_url) + it 'raises a Gitlab::PrometheusError error when a SSLError is rescued' do + req_stub = stub_prometheus_request_with_exception(prometheus_url, OpenSSL::SSL::SSLError) - expect { subject.send(:get, prometheus_invalid_url) }.to raise_error(Gitlab::PrometheusError, "#{prometheus_invalid_url} contains invalid SSL data") - expect(req_stub).to have_been_requested + expect { subject.send(:get, prometheus_url) } + .to raise_error(Gitlab::PrometheusError, "#{prometheus_url} contains invalid SSL data") + expect(req_stub).to have_been_requested + end + + it 'raises a Gitlab::PrometheusError error when a HTTParty::Error is rescued' do + req_stub = stub_prometheus_request_with_exception(prometheus_url, HTTParty::Error) + + expect { subject.send(:get, prometheus_url) } + .to raise_error(Gitlab::PrometheusError, "An error has ocurred") + expect(req_stub).to have_been_requested + end end end diff --git a/spec/models/project_services/prometheus_service_spec.rb b/spec/models/project_services/prometheus_service_spec.rb index 5ef1b53be1570..f3126bc1e570b 100644 --- a/spec/models/project_services/prometheus_service_spec.rb +++ b/spec/models/project_services/prometheus_service_spec.rb @@ -93,12 +93,11 @@ [404, 500].each do |status| context "when Prometheus responds with #{status}" do - body_response = 'QUERY_FAILED' before do - stub_all_prometheus_requests(environment.slug, status: status, body: body_response) + stub_all_prometheus_requests(environment.slug, status: status, body: "QUERY FAILED!") end - it { is_expected.to eq(success: false, result: %(#{status} - \"#{body_response}\")) } + it { is_expected.to eq(success: false, result: %(#{status} - "QUERY FAILED!")) } end end end diff --git a/spec/support/prometheus_helpers.rb b/spec/support/prometheus_helpers.rb index 625a40e1154c8..a204365431b1e 100644 --- a/spec/support/prometheus_helpers.rb +++ b/spec/support/prometheus_helpers.rb @@ -33,14 +33,8 @@ def stub_prometheus_request(url, body: {}, status: 200) }) end - def stub_prometheus_request_with_socket_exception(url) - WebMock.stub_request(:get, url) - .to_raise(SocketError) - end - - def stub_prometheus_request_with_ssl_exception(url) - WebMock.stub_request(:get, url) - .to_raise(OpenSSL::SSL::SSLError) + def stub_prometheus_request_with_exception(url, exception_type) + WebMock.stub_request(:get, url).to_raise(exception_type) end def stub_all_prometheus_requests(environment_slug, body: nil, status: 200) -- GitLab From 3f6477fd1f6fc83e5ef8d03e7c2fe960294c8a98 Mon Sep 17 00:00:00 2001 From: Clement Ho <ClemMakesApps@gmail.com> Date: Thu, 4 May 2017 14:35:02 -0500 Subject: [PATCH 244/363] Fix karma --- spec/javascripts/boards/board_card_spec.js | 6 +++--- spec/javascripts/boards/board_list_spec.js | 1 + spec/javascripts/boards/list_spec.js | 6 ++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/spec/javascripts/boards/board_card_spec.js b/spec/javascripts/boards/board_card_spec.js index 2064ca2632bbf..376e706d1db82 100644 --- a/spec/javascripts/boards/board_card_spec.js +++ b/spec/javascripts/boards/board_card_spec.js @@ -1,5 +1,5 @@ /* global List */ -/* global ListUser */ +/* global ListAssignee */ /* global ListLabel */ /* global listObj */ /* global boardsMockInterceptor */ @@ -133,12 +133,12 @@ describe('Issue card', () => { }); it('does not set detail issue if img is clicked', (done) => { - vm.issue.assignee = new ListUser({ + vm.issue.assignees = [new ListAssignee({ id: 1, name: 'testing 123', username: 'test', avatar: 'test_image', - }); + })]; Vue.nextTick(() => { triggerEvent('mouseup', vm.$el.querySelector('img')); diff --git a/spec/javascripts/boards/board_list_spec.js b/spec/javascripts/boards/board_list_spec.js index 3f598887603af..a89be91166700 100644 --- a/spec/javascripts/boards/board_list_spec.js +++ b/spec/javascripts/boards/board_list_spec.js @@ -35,6 +35,7 @@ describe('Board list component', () => { iid: 1, confidential: false, labels: [], + assignees: [], }); list.issuesSize = 1; list.issues.push(issue); diff --git a/spec/javascripts/boards/list_spec.js b/spec/javascripts/boards/list_spec.js index 88a0e6855f4fc..8e3d9fd77a073 100644 --- a/spec/javascripts/boards/list_spec.js +++ b/spec/javascripts/boards/list_spec.js @@ -120,7 +120,8 @@ describe('List model', () => { title: 'Testing', iid: _.random(10000) + i, confidential: false, - labels: [list.label] + labels: [list.label], + assignees: [], })); } list.issuesSize = 50; @@ -138,7 +139,8 @@ describe('List model', () => { title: 'Testing', iid: _.random(10000), confidential: false, - labels: [list.label] + labels: [list.label], + assignees: [], })); list.issuesSize = 2; -- GitLab From 0c46b3b8ad1bd251125773649138231482397614 Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Thu, 4 May 2017 14:20:23 -0600 Subject: [PATCH 245/363] use computed css class for animations --- .../issue_show/issue_title_description.vue | 58 ++++++++++--------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index d74d59531ddd7..838dccd6a79eb 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -29,15 +29,22 @@ export default { }, }); + const defaultFlags = { + pre: true, + pulse: false, + }; + return { poll, apiData: {}, + tasks: '0 of 0', title: null, titleText: '', - tasks: '0 of 0', + titleFlag: defaultFlags, description: null, descriptionText: '', descriptionChange: false, + descriptionFlag: defaultFlags, timeAgoEl: $('.issue_edited_ago'), titleEl: document.querySelector('title'), }; @@ -51,11 +58,9 @@ export default { tasks(this.apiData, this.tasks); }, elementsToVisualize(noTitleChange, noDescriptionChange) { - const elementStack = []; - if (!noTitleChange) { this.titleText = this.apiData.title_text; - elementStack.push(this.$refs['issue-title']); + this.titleFlag = { pre: true, pulse: false }; } if (!noDescriptionChange) { @@ -63,35 +68,24 @@ export default { this.descriptionChange = true; this.updateTaskHTML(); this.tasks = this.apiData.task_status; - elementStack.push(this.$refs['issue-content-container-gfm-entry']); + this.descriptionFlag = { pre: true, pulse: false }; } - elementStack.forEach((element) => { - if (element) { - element.classList.remove('issue-realtime-trigger-pulse'); - element.classList.add('issue-realtime-pre-pulse'); - } - }); - - return elementStack; + return { noTitleChange, noDescriptionChange }; }, setTabTitle() { const currentTabTitleScope = this.titleEl.innerText.split('·'); currentTabTitleScope[0] = `${this.titleText} (#${this.apiData.issue_number}) `; this.titleEl.innerText = currentTabTitleScope.join('·'); }, - animate(title, description, elementsToVisualize) { + animate(title, description) { this.title = title; this.description = description; this.setTabTitle(); this.$nextTick(() => { - elementsToVisualize.forEach((element) => { - if (element) { - element.classList.remove('issue-realtime-pre-pulse'); - element.classList.add('issue-realtime-trigger-pulse'); - } - }); + this.titleFlag = { pre: false, pulse: true }; + this.descriptionFlag = { pre: false, pulse: true }; }); }, triggerAnimation() { @@ -110,12 +104,8 @@ export default { */ if (noTitleChange && noDescriptionChange) return; - const elementsToVisualize = this.elementsToVisualize( - noTitleChange, - noDescriptionChange, - ); - - this.animate(title, description, elementsToVisualize); + this.elementsToVisualize(noTitleChange, noDescriptionChange); + this.animate(title, description); }, updateEditedTimeAgo() { const toolTipTime = gl.utils.formatDate(this.apiData.updated_at); @@ -128,6 +118,18 @@ export default { descriptionClass() { return `description ${this.canUpdateIssue} is-task-list-enabled`; }, + titleAnimationCss() { + return { + 'title issue-realtime-pre-pulse': this.titleFlag.pre, + 'title issue-realtime-trigger-pulse': this.titleFlag.pulse, + }; + }, + descriptionAnimationCss() { + return { + 'wiki issue-realtime-pre-pulse': this.descriptionFlag.pre, + 'wiki issue-realtime-trigger-pulse': this.descriptionFlag.pulse, + }; + }, }, created() { if (!Visibility.hidden()) { @@ -166,7 +168,7 @@ export default { <template> <div> <h2 - class="title issue-realtime-trigger-pulse" + :class="titleAnimationCss" ref="issue-title" v-html="title" > @@ -176,7 +178,7 @@ export default { v-if="description" > <div - class="wiki issue-realtime-trigger-pulse" + :class="descriptionAnimationCss" v-html="description" ref="issue-content-container-gfm-entry" > -- GitLab From 6911baa13a082fbf6adf3695f16d859d6758a03c Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Thu, 4 May 2017 17:27:46 -0300 Subject: [PATCH 246/363] Fix Import/Export configuration spec --- spec/lib/gitlab/import_export/safe_model_attributes.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index ebfaab4eacd78..59c8b48a2beed 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -351,6 +351,7 @@ Project: - auto_cancel_pending_pipelines - printing_merge_request_link_enabled - build_allow_git_fetch +- last_repository_updated_at Author: - name ProjectFeature: -- GitLab From 1bf694fcb95c978e8cf32664b3ced186e7a7d850 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Thu, 4 May 2017 20:49:39 +0000 Subject: [PATCH 247/363] Removed clientside_sentry ref in settings.rb --- lib/api/v3/settings.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/api/v3/settings.rb b/lib/api/v3/settings.rb index aa31f01b62cc6..748d6b97d4f94 100644 --- a/lib/api/v3/settings.rb +++ b/lib/api/v3/settings.rb @@ -120,7 +120,7 @@ def current_settings :home_page_url, :after_sign_out_path, :sign_in_text, :help_page_text, :shared_runners_enabled, :max_artifacts_size, :max_pages_size, :container_registry_token_expire_delay, :metrics_enabled, :sidekiq_throttling_enabled, :recaptcha_enabled, - :akismet_enabled, :admin_notification_email, :sentry_enabled, :clientside_sentry_enabled, + :akismet_enabled, :admin_notification_email, :sentry_enabled, :repository_storage, :repository_checks_enabled, :koding_enabled, :plantuml_enabled, :version_check_enabled, :email_author_in_body, :html_emails_enabled, :housekeeping_enabled, :terminal_max_session_time -- GitLab From 5fb98734d5b91ee6eb57b5fbe098e401d490d946 Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Thu, 4 May 2017 15:18:59 -0600 Subject: [PATCH 248/363] use more subtle datetime and title change for timeagoEl - use SCSS variable for timing on animation --- .../javascripts/issue_show/issue_title_description.vue | 5 +---- app/assets/stylesheets/pages/issues.scss | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index 838dccd6a79eb..e88ab69455b54 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -70,8 +70,6 @@ export default { this.tasks = this.apiData.task_status; this.descriptionFlag = { pre: true, pulse: false }; } - - return { noTitleChange, noDescriptionChange }; }, setTabTitle() { const currentTabTitleScope = this.titleEl.innerText.split('·'); @@ -109,9 +107,8 @@ export default { }, updateEditedTimeAgo() { const toolTipTime = gl.utils.formatDate(this.apiData.updated_at); - this.timeAgoEl.attr('datetime', this.apiData.updated_at); - this.timeAgoEl.attr('data-original-title', toolTipTime); + this.timeAgoEl.attr('title', toolTipTime).tooltip('fixTitle'); }, }, computed: { diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index f109a01cfa0e3..eda1f96838837 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -23,7 +23,7 @@ } .issue-realtime-trigger-pulse { - transition: opacity 0.2s ease; + transition: opacity $fade-in-duration linear; opacity: 1; } -- GitLab From f7b7a520920204d1a2864af6d911ef9dc8c2df6b Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Thu, 4 May 2017 17:05:14 -0500 Subject: [PATCH 249/363] More updates for translations plus small tweaks. --- .../components/stage_issue_component.js | 2 +- .../cycle_analytics_service.js | 2 +- app/assets/javascripts/locale/de/app.js | 2 +- app/assets/javascripts/locale/en/app.js | 2 +- app/assets/javascripts/locale/es/app.js | 2 +- app/views/profiles/show.html.haml | 1 + .../projects/cycle_analytics/show.html.haml | 2 +- config/locales/es.yml | 217 ++++++++++++++++++ db/fixtures/development/17_cycle_analytics.rb | 2 +- locale/de/gitlab.po | 3 - locale/en/gitlab.po | 3 - locale/es/gitlab.po | 31 ++- locale/gitlab.pot | 7 +- 13 files changed, 241 insertions(+), 35 deletions(-) create mode 100644 config/locales/es.yml diff --git a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js index c4018c8fc36e5..ad285874643fd 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js @@ -28,7 +28,7 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({ <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a> · <span> - {{ __('Opened') }} + {{ __('OpenedNDaysAgo|Opened') }} <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> </span> <span> diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js index c176376e8cf89..6504d7db2f2ae 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js @@ -30,7 +30,7 @@ class CycleAnalyticsService { startDate, } = options; - return $.get(`${this.requestPath}/events/${stage.name.toLowerCase()}.json`, { + return $.get(`${this.requestPath}/events/${stage.name}.json`, { cycle_analytics: { start_date: startDate, }, diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js index aee25c3439249..7dfa38dab8c90 100644 --- a/app/assets/javascripts/locale/de/app.js +++ b/app/assets/javascripts/locale/de/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"Opened":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}}; \ No newline at end of file +var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/en/app.js b/app/assets/javascripts/locale/en/app.js index ed32f7e49c0ea..d6dcf739aadc1 100644 --- a/app/assets/javascripts/locale/en/app.js +++ b/app/assets/javascripts/locale/en/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"Opened":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}}; \ No newline at end of file +var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/es/app.js b/app/assets/javascripts/locale/es/app.js index 0d8152bb8635e..2bb5145e67b65 100644 --- a/app/assets/javascripts/locale/es/app.js +++ b/app/assets/javascripts/locale/es/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-03 21:03-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":["por"],"Commit":["Cambio","Cambios"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"CycleAnalyticsStage|Code":["Código"],"CycleAnalyticsStage|Issue":["Incidencia"],"CycleAnalyticsStage|Plan":["Planificación"],"CycleAnalyticsStage|Production":["Producción"],"CycleAnalyticsStage|Review":["Revisión"],"CycleAnalyticsStage|Staging":["Puesta en escena"],"CycleAnalyticsStage|Test":["Pruebas"],"Deploy":["Despliegue","Despliegues"],"FirstPushedBy|First":["Primer"],"FirstPushedBy|pushed by":["enviado por"],"From issue creation until deploy to production":["Desde la creación de la incidencia hasta el despliegue a producción"],"From merge request merge until deploy to production":["Desde la integración de la solicitud de fusión hasta el despliegue a producción"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last %d day":["Último %d dÃa","Últimos %d dÃas"],"Limited to showing %d event at most":["Limitado a mostrar máximo %d evento","Limitado a mostrar máximo %d eventos"],"Median":["Mediana"],"New Issue":["Nueva incidencia","Nuevas incidencias"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"Opened":["Abiertos"],"OpenedNDaysAgo|Opened":["Abierto"],"Pipeline Health":["Estado del Pipeline"],"ProjectLifecycle|Stage":["Etapa"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Issues":["Incidencias Relacionadas"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Relative Builds Trigger by Commits":["Builds Relacionados Originados por Cambios"],"Relative Deployed Builds":["Builds Desplegados Relacionados"],"Relative Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":["La colección de eventos agregados a los datos recopilados para esa etapa."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":["La etapa del ciclo de vida del desarrollo."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["El tiempo utilizado por cada entrada de datos obtenido por esa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."],"Time before an issue gets scheduled":["Tiempo antes de que una incidencia sea programada"],"Time before an issue starts implementation":["Tiempo antes de que empieze la implementación de una incidencia"],"Time between merge request creation and merge/close":["Tiempo entre la creación de la solicitud de fusión y la integración o cierre de esta"],"Time until first merge request":["Tiempo hasta la primera solicitud de fusión"],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":["Tiempo Total"],"Total test time for all commits/merges":["Tiempo total de pruebas para todos los cambios o integraciones"],"Want to see the data? Please ask an administrator for access.":["¿Quieres ver los datos? Por favor pedir acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"You need permission.":["Necesitas permisos."],"day":["dÃa","dÃas"]}}}; \ No newline at end of file +var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-04 14:52-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":["por"],"Commit":["Cambio","Cambios"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"CycleAnalyticsStage|Code":["Código"],"CycleAnalyticsStage|Issue":["Incidencia"],"CycleAnalyticsStage|Plan":["Planificación"],"CycleAnalyticsStage|Production":["Producción"],"CycleAnalyticsStage|Review":["Revisión"],"CycleAnalyticsStage|Staging":["Puesta en escena"],"CycleAnalyticsStage|Test":["Pruebas"],"Deploy":["Despliegue","Despliegues"],"FirstPushedBy|First":["Primer"],"FirstPushedBy|pushed by":["enviado por"],"From issue creation until deploy to production":["Desde la creación de la incidencia hasta el despliegue a producción"],"From merge request merge until deploy to production":["Desde la integración de la solicitud de fusión hasta el despliegue a producción"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last %d day":["Último %d dÃa","Últimos %d dÃas"],"Limited to showing %d event at most":["Limitado a mostrar máximo %d evento","Limitado a mostrar máximo %d eventos"],"Median":["Mediana"],"New Issue":["Nueva incidencia","Nuevas incidencias"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"OpenedNDaysAgo|Opened":["Abierto"],"Pipeline Health":["Estado del Pipeline"],"ProjectLifecycle|Stage":["Etapa"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Issues":["Incidencias Relacionadas"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Relative Builds Trigger by Commits":["Builds Relativos Originados por Cambios"],"Relative Deployed Builds":["Builds Desplegados Relativos"],"Relative Merged Requests":["Solicitudes de fusión Relativas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":["La colección de eventos agregados a los datos recopilados para esa etapa."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":["La etapa del ciclo de vida de desarrollo."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe el primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["El tiempo utilizado por cada entrada de datos obtenido por esa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."],"Time before an issue gets scheduled":["Tiempo antes de que una incidencia sea programada"],"Time before an issue starts implementation":["Tiempo antes de que empieze la implementación de una incidencia"],"Time between merge request creation and merge/close":["Tiempo entre la creación de la solicitud de fusión y la integración o cierre de ésta"],"Time until first merge request":["Tiempo hasta la primera solicitud de fusión"],"Time|hr":["hr","hrs"],"Time|min":["min","mins"],"Time|s":["s"],"Total Time":["Tiempo Total"],"Total test time for all commits/merges":["Tiempo total de pruebas para todos los cambios o integraciones"],"Want to see the data? Please ask an administrator for access.":["¿Quieres ver los datos? Por favor pide acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"You need permission.":["Necesitas permisos."],"day":["dÃa","dÃas"]}}}; \ No newline at end of file diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 6e14be62e32e3..4a1438aa68e92 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -76,6 +76,7 @@ = f.label :preferred_language, class: "label-light" = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] }, {}, class: "select2" + %span.help-block This feature is experimental and translations are not complete yet. .form-group = f.label :skype, class: "label-light" = f.text_field :skype, class: "form-control" diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index eadaff70e0469..819f29d3ca594 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -31,7 +31,7 @@ .row .col-sm-3.col-xs-12.column{ "v-for" => "item in state.summary" } %h3.header {{ item.value }} - %p.text {{ __(item.title) }} + %p.text {{ item.title }} .col-sm-3.col-xs-12.column .dropdown.inline.js-ca-dropdown %button.dropdown-menu-toggle{ "data-toggle" => "dropdown", :type => "button" } diff --git a/config/locales/es.yml b/config/locales/es.yml new file mode 100644 index 0000000000000..87e79beee7460 --- /dev/null +++ b/config/locales/es.yml @@ -0,0 +1,217 @@ +--- +es: + activerecord: + errors: + messages: + record_invalid: "La validación falló: %{errors}" + restrict_dependent_destroy: + has_one: No se puede eliminar el registro porque existe un %{record} dependiente + has_many: No se puede eliminar el registro porque existen %{record} dependientes + date: + abbr_day_names: + - dom + - lun + - mar + - mié + - jue + - vie + - sáb + abbr_month_names: + - + - ene + - feb + - mar + - abr + - may + - jun + - jul + - ago + - sep + - oct + - nov + - dic + day_names: + - domingo + - lunes + - martes + - miércoles + - jueves + - viernes + - sábado + formats: + default: "%d/%m/%Y" + long: "%d de %B de %Y" + short: "%d de %b" + month_names: + - + - enero + - febrero + - marzo + - abril + - mayo + - junio + - julio + - agosto + - septiembre + - octubre + - noviembre + - diciembre + order: + - :day + - :month + - :year + datetime: + distance_in_words: + about_x_hours: + one: alrededor de 1 hora + other: alrededor de %{count} horas + about_x_months: + one: alrededor de 1 mes + other: alrededor de %{count} meses + about_x_years: + one: alrededor de 1 año + other: alrededor de %{count} años + almost_x_years: + one: casi 1 año + other: casi %{count} años + half_a_minute: medio minuto + less_than_x_minutes: + one: menos de 1 minuto + other: menos de %{count} minutos + less_than_x_seconds: + one: menos de 1 segundo + other: menos de %{count} segundos + over_x_years: + one: más de 1 año + other: más de %{count} años + x_days: + one: 1 dÃa + other: "%{count} dÃas" + x_minutes: + one: 1 minuto + other: "%{count} minutos" + x_months: + one: 1 mes + other: "%{count} meses" + x_years: + one: 1 año + other: "%{count} años" + x_seconds: + one: 1 segundo + other: "%{count} segundos" + prompts: + day: DÃa + hour: Hora + minute: Minutos + month: Mes + second: Segundos + year: Año + errors: + format: "%{attribute} %{message}" + messages: + accepted: debe ser aceptado + blank: no puede estar en blanco + present: debe estar en blanco + confirmation: no coincide + empty: no puede estar vacÃo + equal_to: debe ser igual a %{count} + even: debe ser par + exclusion: está reservado + greater_than: debe ser mayor que %{count} + greater_than_or_equal_to: debe ser mayor que o igual a %{count} + inclusion: no está incluido en la lista + invalid: no es válido + less_than: debe ser menor que %{count} + less_than_or_equal_to: debe ser menor que o igual a %{count} + model_invalid: "La validación falló: %{errors}" + not_a_number: no es un número + not_an_integer: debe ser un entero + odd: debe ser impar + required: debe existir + taken: ya está en uso + too_long: + one: "es demasiado largo (1 carácter máximo)" + other: "es demasiado largo (%{count} caracteres máximo)" + too_short: + one: "es demasiado corto (1 carácter mÃnimo)" + other: "es demasiado corto (%{count} caracteres mÃnimo)" + wrong_length: + one: "no tiene la longitud correcta (1 carácter exactos)" + other: "no tiene la longitud correcta (%{count} caracteres exactos)" + other_than: debe ser distinto de %{count} + template: + body: 'Se encontraron problemas con los siguientes campos:' + header: + one: No se pudo guardar este/a %{model} porque se encontró 1 error + other: No se pudo guardar este/a %{model} porque se encontraron %{count} errores + helpers: + select: + prompt: Por favor seleccione + submit: + create: Crear %{model} + submit: Guardar %{model} + update: Actualizar %{model} + number: + currency: + format: + delimiter: "." + format: "%n %u" + precision: 2 + separator: "," + significant: false + strip_insignificant_zeros: false + unit: "€" + format: + delimiter: "." + precision: 3 + separator: "," + significant: false + strip_insignificant_zeros: false + human: + decimal_units: + format: "%n %u" + units: + billion: mil millones + million: + one: millón + other: millones + quadrillion: mil billones + thousand: mil + trillion: + one: billón + other: billones + unit: '' + format: + delimiter: '' + precision: 1 + significant: true + strip_insignificant_zeros: true + storage_units: + format: "%n %u" + units: + byte: + one: Byte + other: Bytes + gb: GB + kb: KB + mb: MB + tb: TB + percentage: + format: + delimiter: '' + format: "%n %" + precision: + format: + delimiter: '' + support: + array: + last_word_connector: " y " + two_words_connector: " y " + words_connector: ", " + time: + am: am + formats: + default: "%A, %d de %B de %Y %H:%M:%S %z" + long: "%d de %B de %Y %H:%M" + short: "%d de %b %H:%M" + pm: pm diff --git a/db/fixtures/development/17_cycle_analytics.rb b/db/fixtures/development/17_cycle_analytics.rb index 040505ad52c54..0d7eb1a7c9336 100644 --- a/db/fixtures/development/17_cycle_analytics.rb +++ b/db/fixtures/development/17_cycle_analytics.rb @@ -227,7 +227,7 @@ def deploy_to_production(merge_requests) if ENV[flag] Project.all.each do |project| - seeder = Gitlab::Seeder::CycleAnalytics.new(pro) + seeder = Gitlab::Seeder::CycleAnalytics.new(project) seeder.seed! end elsif ENV['CYCLE_ANALYTICS_PERF_TEST'] diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po index 3e7bcf2c90fb5..5a13000434ea5 100644 --- a/locale/de/gitlab.po +++ b/locale/de/gitlab.po @@ -93,9 +93,6 @@ msgstr "" msgid "Not enough data" msgstr "" -msgid "Opened" -msgstr "" - msgid "OpenedNDaysAgo|Opened" msgstr "" diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po index 9f75b5641a4ae..93ecefec762e9 100644 --- a/locale/en/gitlab.po +++ b/locale/en/gitlab.po @@ -93,9 +93,6 @@ msgstr "" msgid "Not enough data" msgstr "" -msgid "Opened" -msgstr "" - msgid "OpenedNDaysAgo|Opened" msgstr "" diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po index 6806bf216774e..2074912c1477a 100644 --- a/locale/es/gitlab.po +++ b/locale/es/gitlab.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2017-05-03 21:03-0500\n" +"PO-Revision-Date: 2017-05-04 14:52-0500\n" "Language-Team: Spanish\n" "Language: es\n" "MIME-Version: 1.0\n" @@ -94,9 +94,6 @@ msgstr "No disponible" msgid "Not enough data" msgstr "No hay suficientes datos" -msgid "Opened" -msgstr "Abiertos" - msgid "OpenedNDaysAgo|Opened" msgstr "Abierto" @@ -119,13 +116,13 @@ msgid "Related Merge Requests" msgstr "Solicitudes de fusión Relacionadas" msgid "Relative Builds Trigger by Commits" -msgstr "Builds Relacionados Originados por Cambios" +msgstr "Builds Relativos Originados por Cambios" msgid "Relative Deployed Builds" -msgstr "Builds Desplegados Relacionados" +msgstr "Builds Desplegados Relativos" msgid "Relative Merged Requests" -msgstr "Solicitudes de fusión Relacionadas" +msgstr "Solicitudes de fusión Relativas" msgid "Showing %d event" msgid_plural "Showing %d events" @@ -142,10 +139,10 @@ msgid "The issue stage shows the time it takes from creating an issue to assigni msgstr "La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa." msgid "The phase of the development lifecycle." -msgstr "La etapa del ciclo de vida del desarrollo." +msgstr "La etapa del ciclo de vida de desarrollo." msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." -msgstr "La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe su primer cambio." +msgstr "La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe el primer cambio." msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." msgstr "La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción." @@ -154,7 +151,7 @@ msgid "The review stage shows the time from creating the merge request to mergin msgstr "La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión." msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time." -msgstr "La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez." +msgstr "La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez." msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." msgstr "La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse." @@ -172,23 +169,23 @@ msgid "Time before an issue starts implementation" msgstr "Tiempo antes de que empieze la implementación de una incidencia" msgid "Time between merge request creation and merge/close" -msgstr "Tiempo entre la creación de la solicitud de fusión y la integración o cierre de esta" +msgstr "Tiempo entre la creación de la solicitud de fusión y la integración o cierre de ésta" msgid "Time until first merge request" msgstr "Tiempo hasta la primera solicitud de fusión" msgid "Time|hr" msgid_plural "Time|hrs" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "hr" +msgstr[1] "hrs" msgid "Time|min" msgid_plural "Time|mins" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "min" +msgstr[1] "mins" msgid "Time|s" -msgstr "" +msgstr "s" msgid "Total Time" msgstr "Tiempo Total" @@ -197,7 +194,7 @@ msgid "Total test time for all commits/merges" msgstr "Tiempo total de pruebas para todos los cambios o integraciones" msgid "Want to see the data? Please ask an administrator for access." -msgstr "¿Quieres ver los datos? Por favor pedir acceso al administrador." +msgstr "¿Quieres ver los datos? Por favor pide acceso al administrador." msgid "We don't have enough data to show this stage." msgstr "No hay suficientes datos para mostrar en esta etapa." diff --git a/locale/gitlab.pot b/locale/gitlab.pot index e1796d519351f..4473f5715a2f9 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-05-03 20:53-0500\n" -"PO-Revision-Date: 2017-05-03 20:53-0500\n" +"POT-Creation-Date: 2017-05-04 14:55-0500\n" +"PO-Revision-Date: 2017-05-04 14:55-0500\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" "Language: \n" @@ -94,9 +94,6 @@ msgstr "" msgid "Not enough data" msgstr "" -msgid "Opened" -msgstr "" - msgid "OpenedNDaysAgo|Opened" msgstr "" -- GitLab From 024427bff6536457e1365709bd15098ddab0e0e8 Mon Sep 17 00:00:00 2001 From: tauriedavis <taurie@gitlab.com> Date: Thu, 4 May 2017 15:17:16 -0700 Subject: [PATCH 250/363] 31689 Add default margin-top to user request table on project members page --- app/views/projects/group_links/_index.html.haml | 4 +--- app/views/projects/project_members/_index.html.haml | 2 +- app/views/shared/members/_requests.html.haml | 2 +- changelogs/unreleased/31689-request-access-spacing.yml | 4 ++++ 4 files changed, 7 insertions(+), 5 deletions(-) create mode 100644 changelogs/unreleased/31689-request-access-spacing.yml diff --git a/app/views/projects/group_links/_index.html.haml b/app/views/projects/group_links/_index.html.haml index b6116dbec414a..debb0214d068c 100644 --- a/app/views/projects/group_links/_index.html.haml +++ b/app/views/projects/group_links/_index.html.haml @@ -6,11 +6,9 @@ %p Projects can be stored in only one group at once. However you can share a project with other groups here. .col-lg-9 - %h5.prepend-top-0 - Set a group to share = form_tag namespace_project_group_links_path(@project.namespace, @project), class: 'js-requires-input', method: :post do .form-group - = label_tag :link_group_id, "Group", class: "label-light" + = label_tag :link_group_id, "Select a group to share with", class: "label-light" = groups_select_tag(:link_group_id, data: { skip_groups: @skip_groups }, required: true) .form-group = label_tag :link_group_access, "Max access level", class: "label-light" diff --git a/app/views/projects/project_members/_index.html.haml b/app/views/projects/project_members/_index.html.haml index f83521052ed58..d080b6c83d472 100644 --- a/app/views/projects/project_members/_index.html.haml +++ b/app/views/projects/project_members/_index.html.haml @@ -18,7 +18,7 @@ = render "projects/project_members/new_project_member" = render 'shared/members/requests', membership_source: @project, requesters: @requesters - .append-bottom-default.clearfix + .clearfix %h5.member.existing-title Existing members and groups - if @group_links.any? diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml index 10050adfda5a4..92f6e7428ae2e 100644 --- a/app/views/shared/members/_requests.html.haml +++ b/app/views/shared/members/_requests.html.haml @@ -1,5 +1,5 @@ - if requesters.any? - .panel.panel-default + .panel.panel-default.prepend-top-default .panel-heading Users requesting access to %strong= membership_source.name diff --git a/changelogs/unreleased/31689-request-access-spacing.yml b/changelogs/unreleased/31689-request-access-spacing.yml new file mode 100644 index 0000000000000..66076b44f466a --- /dev/null +++ b/changelogs/unreleased/31689-request-access-spacing.yml @@ -0,0 +1,4 @@ +--- +title: Add default margin-top to user request table on project members page +merge_request: +author: -- GitLab From 46d7f0a3a1c5587cca53a930b5d0610db2e59f89 Mon Sep 17 00:00:00 2001 From: tauriedavis <taurie@gitlab.com> Date: Thu, 4 May 2017 15:36:43 -0700 Subject: [PATCH 251/363] 30007 Add transparent top-border to the hover state of done todos --- app/assets/stylesheets/pages/todos.scss | 3 ++- changelogs/unreleased/30007-done-todo-hover-state.yml | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 changelogs/unreleased/30007-done-todo-hover-state.yml diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss index a39815319f377..de652a7936988 100644 --- a/app/assets/stylesheets/pages/todos.scss +++ b/app/assets/stylesheets/pages/todos.scss @@ -54,8 +54,9 @@ background-color: $white-light; &:hover { - border-color: $white-dark; + border-color: $white-normal; background-color: $gray-light; + border-top: 1px solid transparent; .todo-avatar, .todo-item { diff --git a/changelogs/unreleased/30007-done-todo-hover-state.yml b/changelogs/unreleased/30007-done-todo-hover-state.yml new file mode 100644 index 0000000000000..bfbde7a49c825 --- /dev/null +++ b/changelogs/unreleased/30007-done-todo-hover-state.yml @@ -0,0 +1,4 @@ +--- +title: Add transparent top-border to the hover state of done todos +merge_request: +author: -- GitLab From 897f98cc9df483ce00a91a273195da1da52ed3da Mon Sep 17 00:00:00 2001 From: Douwe Maan <douwe@selenight.nl> Date: Thu, 4 May 2017 18:12:49 -0500 Subject: [PATCH 252/363] Fix wording of Cycle Analytics stage legends --- lib/gitlab/cycle_analytics/review_stage.rb | 2 +- lib/gitlab/cycle_analytics/staging_stage.rb | 2 +- lib/gitlab/cycle_analytics/test_stage.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/gitlab/cycle_analytics/review_stage.rb b/lib/gitlab/cycle_analytics/review_stage.rb index 4744be834de27..80e5b538c6cec 100644 --- a/lib/gitlab/cycle_analytics/review_stage.rb +++ b/lib/gitlab/cycle_analytics/review_stage.rb @@ -14,7 +14,7 @@ def name end def legend - "Relative Merged Requests" + "Related Merged Requests" end def description diff --git a/lib/gitlab/cycle_analytics/staging_stage.rb b/lib/gitlab/cycle_analytics/staging_stage.rb index 3cdbe04fbaf1d..eae0e8ee7b067 100644 --- a/lib/gitlab/cycle_analytics/staging_stage.rb +++ b/lib/gitlab/cycle_analytics/staging_stage.rb @@ -15,7 +15,7 @@ def name end def legend - "Relative Deployed Builds" + "Related Deployed Jobs" end def description diff --git a/lib/gitlab/cycle_analytics/test_stage.rb b/lib/gitlab/cycle_analytics/test_stage.rb index e96943833bc46..de9a36d34f176 100644 --- a/lib/gitlab/cycle_analytics/test_stage.rb +++ b/lib/gitlab/cycle_analytics/test_stage.rb @@ -14,7 +14,7 @@ def name end def legend - "Relative Builds Trigger by Commits" + "Related Jobs" end def description -- GitLab From 6eb9e981c61969a904ccbae2c5f52f562b02d199 Mon Sep 17 00:00:00 2001 From: Jose Ivan Vargas <jvargas@gitlab.com> Date: Thu, 4 May 2017 09:16:24 -0500 Subject: [PATCH 253/363] Improved changelog entry, also changed error message for HTTParty error --- .../unreleased/prometheus-integration-test-setting-fix.yml | 2 +- lib/gitlab/prometheus.rb | 2 +- spec/lib/gitlab/prometheus_spec.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/changelogs/unreleased/prometheus-integration-test-setting-fix.yml b/changelogs/unreleased/prometheus-integration-test-setting-fix.yml index c65c682a11442..45b7c2263e694 100644 --- a/changelogs/unreleased/prometheus-integration-test-setting-fix.yml +++ b/changelogs/unreleased/prometheus-integration-test-setting-fix.yml @@ -1,4 +1,4 @@ --- -title: Added rescue block for the test method +title: Prevent 500 errors caused by testing the Prometheus service merge_request: 10994 author: diff --git a/lib/gitlab/prometheus.rb b/lib/gitlab/prometheus.rb index aa9810c5c4171..8827507955dd7 100644 --- a/lib/gitlab/prometheus.rb +++ b/lib/gitlab/prometheus.rb @@ -55,7 +55,7 @@ def get(url) rescue OpenSSL::SSL::SSLError raise PrometheusError, "#{url} contains invalid SSL data" rescue HTTParty::Error - raise PrometheusError, "An error has ocurred" + raise PrometheusError, "Network connection error" end def handle_response(response) diff --git a/spec/lib/gitlab/prometheus_spec.rb b/spec/lib/gitlab/prometheus_spec.rb index 8e187df6ca4fc..fc453a2704b36 100644 --- a/spec/lib/gitlab/prometheus_spec.rb +++ b/spec/lib/gitlab/prometheus_spec.rb @@ -73,7 +73,7 @@ req_stub = stub_prometheus_request_with_exception(prometheus_url, HTTParty::Error) expect { subject.send(:get, prometheus_url) } - .to raise_error(Gitlab::PrometheusError, "An error has ocurred") + .to raise_error(Gitlab::PrometheusError, "Network connection error") expect(req_stub).to have_been_requested end end -- GitLab From b396668ec55fb866ef578e5f0e60df8b13519613 Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Thu, 4 May 2017 18:44:19 -0500 Subject: [PATCH 254/363] Add CHANGELOG entry, some specs and locale file for German. --- .../unreleased/implement-i18n-support.yml | 4 + config/locales/de.yml | 219 ++++++++++++++++++ lib/gitlab/i18n.rb | 7 +- spec/features/cycle_analytics_spec.rb | 19 ++ spec/lib/gitlab/i18n_spec.rb | 27 +++ spec/models/user_spec.rb | 8 + 6 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 changelogs/unreleased/implement-i18n-support.yml create mode 100644 config/locales/de.yml create mode 100644 spec/lib/gitlab/i18n_spec.rb diff --git a/changelogs/unreleased/implement-i18n-support.yml b/changelogs/unreleased/implement-i18n-support.yml new file mode 100644 index 0000000000000..d304fbecf9088 --- /dev/null +++ b/changelogs/unreleased/implement-i18n-support.yml @@ -0,0 +1,4 @@ +--- +title: Add support for i18n on Cycle Analytics page +merge_request: 10669 +author: diff --git a/config/locales/de.yml b/config/locales/de.yml new file mode 100644 index 0000000000000..533663a270495 --- /dev/null +++ b/config/locales/de.yml @@ -0,0 +1,219 @@ +--- +de: + activerecord: + errors: + messages: + record_invalid: 'Gültigkeitsprüfung ist fehlgeschlagen: %{errors}' + restrict_dependent_destroy: + has_one: Datensatz kann nicht gelöscht werden, da ein abhängiger %{record}-Datensatz + existiert. + has_many: Datensatz kann nicht gelöscht werden, da abhängige %{record} existieren. + date: + abbr_day_names: + - So + - Mo + - Di + - Mi + - Do + - Fr + - Sa + abbr_month_names: + - + - Jan + - Feb + - Mär + - Apr + - Mai + - Jun + - Jul + - Aug + - Sep + - Okt + - Nov + - Dez + day_names: + - Sonntag + - Montag + - Dienstag + - Mittwoch + - Donnerstag + - Freitag + - Samstag + formats: + default: "%d.%m.%Y" + long: "%e. %B %Y" + short: "%e. %b" + month_names: + - + - Januar + - Februar + - März + - April + - Mai + - Juni + - Juli + - August + - September + - Oktober + - November + - Dezember + order: + - :day + - :month + - :year + datetime: + distance_in_words: + about_x_hours: + one: etwa eine Stunde + other: etwa %{count} Stunden + about_x_months: + one: etwa ein Monat + other: etwa %{count} Monate + about_x_years: + one: etwa ein Jahr + other: etwa %{count} Jahre + almost_x_years: + one: fast ein Jahr + other: fast %{count} Jahre + half_a_minute: eine halbe Minute + less_than_x_minutes: + one: weniger als eine Minute + other: weniger als %{count} Minuten + less_than_x_seconds: + one: weniger als eine Sekunde + other: weniger als %{count} Sekunden + over_x_years: + one: mehr als ein Jahr + other: mehr als %{count} Jahre + x_days: + one: ein Tag + other: "%{count} Tage" + x_minutes: + one: eine Minute + other: "%{count} Minuten" + x_months: + one: ein Monat + other: "%{count} Monate" + x_seconds: + one: eine Sekunde + other: "%{count} Sekunden" + prompts: + day: Tag + hour: Stunden + minute: Minute + month: Monat + second: Sekunde + year: Jahr + errors: + format: "%{attribute} %{message}" + messages: + accepted: muss akzeptiert werden + blank: muss ausgefüllt werden + present: darf nicht ausgefüllt werden + confirmation: stimmt nicht mit %{attribute} überein + empty: muss ausgefüllt werden + equal_to: muss genau %{count} sein + even: muss gerade sein + exclusion: ist nicht verfügbar + greater_than: muss größer als %{count} sein + greater_than_or_equal_to: muss größer oder gleich %{count} sein + inclusion: ist kein gültiger Wert + invalid: ist nicht gültig + less_than: muss kleiner als %{count} sein + less_than_or_equal_to: muss kleiner oder gleich %{count} sein + model_invalid: 'Gültigkeitsprüfung ist fehlgeschlagen: %{errors}' + not_a_number: ist keine Zahl + not_an_integer: muss ganzzahlig sein + odd: muss ungerade sein + required: muss ausgefüllt werden + taken: ist bereits vergeben + too_long: + one: ist zu lang (mehr als 1 Zeichen) + other: ist zu lang (mehr als %{count} Zeichen) + too_short: + one: ist zu kurz (weniger als 1 Zeichen) + other: ist zu kurz (weniger als %{count} Zeichen) + wrong_length: + one: hat die falsche Länge (muss genau 1 Zeichen haben) + other: hat die falsche Länge (muss genau %{count} Zeichen haben) + other_than: darf nicht gleich %{count} sein + template: + body: 'Bitte überprüfen Sie die folgenden Felder:' + header: + one: 'Konnte %{model} nicht speichern: ein Fehler.' + other: 'Konnte %{model} nicht speichern: %{count} Fehler.' + helpers: + select: + prompt: Bitte wählen + submit: + create: "%{model} erstellen" + submit: "%{model} speichern" + update: "%{model} aktualisieren" + number: + currency: + format: + delimiter: "." + format: "%n %u" + precision: 2 + separator: "," + significant: false + strip_insignificant_zeros: false + unit: "€" + format: + delimiter: "." + precision: 2 + separator: "," + significant: false + strip_insignificant_zeros: false + human: + decimal_units: + format: "%n %u" + units: + billion: + one: Milliarde + other: Milliarden + million: + one: Million + other: Millionen + quadrillion: + one: Billiarde + other: Billiarden + thousand: Tausend + trillion: + one: Billion + other: Billionen + unit: '' + format: + delimiter: '' + precision: 3 + significant: true + strip_insignificant_zeros: true + storage_units: + format: "%n %u" + units: + byte: + one: Byte + other: Bytes + gb: GB + kb: KB + mb: MB + tb: TB + percentage: + format: + delimiter: '' + format: "%n %" + precision: + format: + delimiter: '' + support: + array: + last_word_connector: " und " + two_words_connector: " und " + words_connector: ", " + time: + am: vormittags + formats: + default: "%A, %d. %B %Y, %H:%M Uhr" + long: "%A, %d. %B %Y, %H:%M Uhr" + short: "%d. %B, %H:%M Uhr" + pm: nachmittags diff --git a/lib/gitlab/i18n.rb b/lib/gitlab/i18n.rb index 9081ced0238ee..3411516319f88 100644 --- a/lib/gitlab/i18n.rb +++ b/lib/gitlab/i18n.rb @@ -3,9 +3,9 @@ module I18n extend self AVAILABLE_LANGUAGES = { - en: 'English', - es: 'Español', - de: 'Deutsch' + 'en' => 'English', + 'es' => 'Español', + 'de' => 'Deutsch' }.freeze def available_locales @@ -19,6 +19,7 @@ def set_locale(current_user) end def reset_locale + FastGettext.set_locale(::I18n.default_locale) ::I18n.locale = ::I18n.default_locale end end diff --git a/spec/features/cycle_analytics_spec.rb b/spec/features/cycle_analytics_spec.rb index b93275c330bbc..7c9d522273bea 100644 --- a/spec/features/cycle_analytics_spec.rb +++ b/spec/features/cycle_analytics_spec.rb @@ -62,6 +62,25 @@ expect_issue_to_be_present end end + + context "when my preferred language is Spanish" do + before do + user.update_attribute(:preferred_language, 'es') + + project.team << [user, :master] + login_as(user) + visit namespace_project_cycle_analytics_path(project.namespace, project) + wait_for_ajax + end + + it 'shows the content in Spanish' do + expect(page).to have_content('Estado del Pipeline') + end + + it 'resets the language to English' do + expect(I18n.locale).to eq(:en) + end + end end context "as a guest" do diff --git a/spec/lib/gitlab/i18n_spec.rb b/spec/lib/gitlab/i18n_spec.rb new file mode 100644 index 0000000000000..52f2614d5cab6 --- /dev/null +++ b/spec/lib/gitlab/i18n_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +module Gitlab + describe I18n, lib: true do + let(:user) { create(:user, preferred_language: 'es') } + + describe '.set_locale' do + it 'sets the locale based on current user preferred language' do + Gitlab::I18n.set_locale(user) + + expect(FastGettext.locale).to eq('es') + expect(::I18n.locale).to eq(:es) + end + end + + describe '.reset_locale' do + it 'resets the locale to the default language' do + Gitlab::I18n.set_locale(user) + + Gitlab::I18n.reset_locale + + expect(FastGettext.locale).to eq('en') + expect(::I18n.locale).to eq(:en) + end + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 1c2df4c9d9741..1317917495649 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1663,4 +1663,12 @@ def add_user(access) expect(User.active.count).to eq(1) end end + + describe 'preferred language' do + it 'is English by default' do + user = create(:user) + + expect(user.preferred_language).to eq('en') + end + end end -- GitLab From 7f24b87b3beb142cadc7644db3ab4c49fdd04839 Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Thu, 4 May 2017 19:27:17 -0500 Subject: [PATCH 255/363] Some small updates for Spanish translations. --- app/assets/javascripts/locale/de/app.js | 2 +- app/assets/javascripts/locale/en/app.js | 2 +- app/assets/javascripts/locale/es/app.js | 2 +- lib/gitlab/cycle_analytics/review_stage.rb | 2 +- lib/gitlab/cycle_analytics/staging_stage.rb | 2 +- lib/gitlab/cycle_analytics/test_stage.rb | 2 +- locale/de/gitlab.po | 10 +++++----- locale/en/gitlab.po | 10 +++++----- locale/es/gitlab.po | 18 +++++++++--------- locale/gitlab.pot | 14 +++++++------- 10 files changed, 32 insertions(+), 32 deletions(-) diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js index 7dfa38dab8c90..e96090da80e41 100644 --- a/app/assets/javascripts/locale/de/app.js +++ b/app/assets/javascripts/locale/de/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}}; \ No newline at end of file +var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Deployed Jobs":[""],"Related Issues":[""],"Related Jobs":[""],"Related Merge Requests":[""],"Related Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/en/app.js b/app/assets/javascripts/locale/en/app.js index d6dcf739aadc1..ade9b667b3c0f 100644 --- a/app/assets/javascripts/locale/en/app.js +++ b/app/assets/javascripts/locale/en/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Issues":[""],"Related Merge Requests":[""],"Relative Builds Trigger by Commits":[""],"Relative Deployed Builds":[""],"Relative Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}}; \ No newline at end of file +var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Deployed Jobs":[""],"Related Issues":[""],"Related Jobs":[""],"Related Merge Requests":[""],"Related Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/es/app.js b/app/assets/javascripts/locale/es/app.js index 2bb5145e67b65..3dafa21f2359a 100644 --- a/app/assets/javascripts/locale/es/app.js +++ b/app/assets/javascripts/locale/es/app.js @@ -1 +1 @@ -var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-04 14:52-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":["por"],"Commit":["Cambio","Cambios"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"CycleAnalyticsStage|Code":["Código"],"CycleAnalyticsStage|Issue":["Incidencia"],"CycleAnalyticsStage|Plan":["Planificación"],"CycleAnalyticsStage|Production":["Producción"],"CycleAnalyticsStage|Review":["Revisión"],"CycleAnalyticsStage|Staging":["Puesta en escena"],"CycleAnalyticsStage|Test":["Pruebas"],"Deploy":["Despliegue","Despliegues"],"FirstPushedBy|First":["Primer"],"FirstPushedBy|pushed by":["enviado por"],"From issue creation until deploy to production":["Desde la creación de la incidencia hasta el despliegue a producción"],"From merge request merge until deploy to production":["Desde la integración de la solicitud de fusión hasta el despliegue a producción"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last %d day":["Último %d dÃa","Últimos %d dÃas"],"Limited to showing %d event at most":["Limitado a mostrar máximo %d evento","Limitado a mostrar máximo %d eventos"],"Median":["Mediana"],"New Issue":["Nueva incidencia","Nuevas incidencias"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"OpenedNDaysAgo|Opened":["Abierto"],"Pipeline Health":["Estado del Pipeline"],"ProjectLifecycle|Stage":["Etapa"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Issues":["Incidencias Relacionadas"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Relative Builds Trigger by Commits":["Builds Relativos Originados por Cambios"],"Relative Deployed Builds":["Builds Desplegados Relativos"],"Relative Merged Requests":["Solicitudes de fusión Relativas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":["La colección de eventos agregados a los datos recopilados para esa etapa."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":["La etapa del ciclo de vida de desarrollo."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe el primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["El tiempo utilizado por cada entrada de datos obtenido por esa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."],"Time before an issue gets scheduled":["Tiempo antes de que una incidencia sea programada"],"Time before an issue starts implementation":["Tiempo antes de que empieze la implementación de una incidencia"],"Time between merge request creation and merge/close":["Tiempo entre la creación de la solicitud de fusión y la integración o cierre de ésta"],"Time until first merge request":["Tiempo hasta la primera solicitud de fusión"],"Time|hr":["hr","hrs"],"Time|min":["min","mins"],"Time|s":["s"],"Total Time":["Tiempo Total"],"Total test time for all commits/merges":["Tiempo total de pruebas para todos los cambios o integraciones"],"Want to see the data? Please ask an administrator for access.":["¿Quieres ver los datos? Por favor pide acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"You need permission.":["Necesitas permisos."],"day":["dÃa","dÃas"]}}}; \ No newline at end of file +var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-04 19:24-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":["por"],"Commit":["Cambio","Cambios"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"CycleAnalyticsStage|Code":["Código"],"CycleAnalyticsStage|Issue":["Incidencia"],"CycleAnalyticsStage|Plan":["Planificación"],"CycleAnalyticsStage|Production":["Producción"],"CycleAnalyticsStage|Review":["Revisión"],"CycleAnalyticsStage|Staging":["Puesta en escena"],"CycleAnalyticsStage|Test":["Pruebas"],"Deploy":["Despliegue","Despliegues"],"FirstPushedBy|First":["Primer"],"FirstPushedBy|pushed by":["enviado por"],"From issue creation until deploy to production":["Desde la creación de la incidencia hasta el despliegue a producción"],"From merge request merge until deploy to production":["Desde la integración de la solicitud de fusión hasta el despliegue a producción"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last %d day":["Último %d dÃa","Últimos %d dÃas"],"Limited to showing %d event at most":["Limitado a mostrar máximo %d evento","Limitado a mostrar máximo %d eventos"],"Median":["Mediana"],"New Issue":["Nueva incidencia","Nuevas incidencias"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"OpenedNDaysAgo|Opened":["Abierto"],"Pipeline Health":["Estado del Pipeline"],"ProjectLifecycle|Stage":["Etapa"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Deployed Jobs":["Trabajos Desplegados Relacionados"],"Related Issues":["Incidencias Relacionadas"],"Related Jobs":["Trabajos Relacionados"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Related Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":["La colección de eventos agregados a los datos recopilados para esa etapa."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":["La etapa del ciclo de vida de desarrollo."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe el primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["El tiempo utilizado por cada entrada de datos obtenido por esa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."],"Time before an issue gets scheduled":["Tiempo antes de que una incidencia sea programada"],"Time before an issue starts implementation":["Tiempo antes de que empieze la implementación de una incidencia"],"Time between merge request creation and merge/close":["Tiempo entre la creación de la solicitud de fusión y la integración o cierre de ésta"],"Time until first merge request":["Tiempo hasta la primera solicitud de fusión"],"Time|hr":["hr","hrs"],"Time|min":["min","mins"],"Time|s":["s"],"Total Time":["Tiempo Total"],"Total test time for all commits/merges":["Tiempo total de pruebas para todos los cambios o integraciones"],"Want to see the data? Please ask an administrator for access.":["¿Quieres ver los datos? Por favor pide acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"You need permission.":["Necesitas permisos."],"day":["dÃa","dÃas"]}}}; \ No newline at end of file diff --git a/lib/gitlab/cycle_analytics/review_stage.rb b/lib/gitlab/cycle_analytics/review_stage.rb index 0642e1ce32fa9..cfbbdc43fd93c 100644 --- a/lib/gitlab/cycle_analytics/review_stage.rb +++ b/lib/gitlab/cycle_analytics/review_stage.rb @@ -18,7 +18,7 @@ def title end def legend - "Related Merged Requests" + _("Related Merged Requests") end def description diff --git a/lib/gitlab/cycle_analytics/staging_stage.rb b/lib/gitlab/cycle_analytics/staging_stage.rb index d5c7d3f915a5a..d5684bb920178 100644 --- a/lib/gitlab/cycle_analytics/staging_stage.rb +++ b/lib/gitlab/cycle_analytics/staging_stage.rb @@ -19,7 +19,7 @@ def title end def legend - "Related Deployed Jobs" + _("Related Deployed Jobs") end def description diff --git a/lib/gitlab/cycle_analytics/test_stage.rb b/lib/gitlab/cycle_analytics/test_stage.rb index 956ee9b8b74a6..2b5f72bef8930 100644 --- a/lib/gitlab/cycle_analytics/test_stage.rb +++ b/lib/gitlab/cycle_analytics/test_stage.rb @@ -18,7 +18,7 @@ def title end def legend - "Related Jobs" + _("Related Jobs") end def description diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po index 5a13000434ea5..b804dc0436f4f 100644 --- a/locale/de/gitlab.po +++ b/locale/de/gitlab.po @@ -108,19 +108,19 @@ msgstr "" msgid "Related Commits" msgstr "" -msgid "Related Issues" +msgid "Related Deployed Jobs" msgstr "" -msgid "Related Merge Requests" +msgid "Related Issues" msgstr "" -msgid "Relative Builds Trigger by Commits" +msgid "Related Jobs" msgstr "" -msgid "Relative Deployed Builds" +msgid "Related Merge Requests" msgstr "" -msgid "Relative Merged Requests" +msgid "Related Merged Requests" msgstr "" msgid "Showing %d event" diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po index 93ecefec762e9..a43bafbbe2872 100644 --- a/locale/en/gitlab.po +++ b/locale/en/gitlab.po @@ -108,19 +108,19 @@ msgstr "" msgid "Related Commits" msgstr "" -msgid "Related Issues" +msgid "Related Deployed Jobs" msgstr "" -msgid "Related Merge Requests" +msgid "Related Issues" msgstr "" -msgid "Relative Builds Trigger by Commits" +msgid "Related Jobs" msgstr "" -msgid "Relative Deployed Builds" +msgid "Related Merge Requests" msgstr "" -msgid "Relative Merged Requests" +msgid "Related Merged Requests" msgstr "" msgid "Showing %d event" diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po index 2074912c1477a..c14ddd3b94c6c 100644 --- a/locale/es/gitlab.po +++ b/locale/es/gitlab.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2017-05-04 14:52-0500\n" +"PO-Revision-Date: 2017-05-04 19:24-0500\n" "Language-Team: Spanish\n" "Language: es\n" "MIME-Version: 1.0\n" @@ -109,20 +109,20 @@ msgstr "Leer más" msgid "Related Commits" msgstr "Cambios Relacionados" +msgid "Related Deployed Jobs" +msgstr "Trabajos Desplegados Relacionados" + msgid "Related Issues" msgstr "Incidencias Relacionadas" +msgid "Related Jobs" +msgstr "Trabajos Relacionados" + msgid "Related Merge Requests" msgstr "Solicitudes de fusión Relacionadas" -msgid "Relative Builds Trigger by Commits" -msgstr "Builds Relativos Originados por Cambios" - -msgid "Relative Deployed Builds" -msgstr "Builds Desplegados Relativos" - -msgid "Relative Merged Requests" -msgstr "Solicitudes de fusión Relativas" +msgid "Related Merged Requests" +msgstr "Solicitudes de fusión Relacionadas" msgid "Showing %d event" msgid_plural "Showing %d events" diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 4473f5715a2f9..3967d40ea9e50 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-05-04 14:55-0500\n" -"PO-Revision-Date: 2017-05-04 14:55-0500\n" +"POT-Creation-Date: 2017-05-04 19:24-0500\n" +"PO-Revision-Date: 2017-05-04 19:24-0500\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" "Language: \n" @@ -109,19 +109,19 @@ msgstr "" msgid "Related Commits" msgstr "" -msgid "Related Issues" +msgid "Related Deployed Jobs" msgstr "" -msgid "Related Merge Requests" +msgid "Related Issues" msgstr "" -msgid "Relative Builds Trigger by Commits" +msgid "Related Jobs" msgstr "" -msgid "Relative Deployed Builds" +msgid "Related Merge Requests" msgstr "" -msgid "Relative Merged Requests" +msgid "Related Merged Requests" msgstr "" msgid "Showing %d event" -- GitLab From c4094b7ec4699811f928699ad67c90e1686da6e2 Mon Sep 17 00:00:00 2001 From: Clement Ho <ClemMakesApps@gmail.com> Date: Thu, 4 May 2017 15:55:36 -0500 Subject: [PATCH 256/363] Fix specs --- .../blob/file_template_mediator.js | 6 ++- .../blob/file_template_selector.js | 10 ++++- .../template_selectors/ci_yaml_selector.js | 2 +- .../template_selectors/dockerfile_selector.js | 2 +- .../template_selectors/gitignore_selector.js | 2 +- .../template_selectors/license_selector.js | 13 +++++- .../blob/template_selectors/type_selector.js | 2 +- .../protected_tag_access_dropdown.js | 4 +- .../protected_tags/protected_tag_dropdown.js | 4 +- app/assets/javascripts/users_select.js | 41 +++++++++++-------- .../stylesheets/framework/dropdowns.scss | 4 ++ app/helpers/form_helper.rb | 11 +++-- .../_create_protected_branch.html.haml | 4 +- .../_update_protected_branch.html.haml | 4 +- .../protected_tags/_dropdown.html.haml | 2 +- .../protected_tags/_update_protected_tag.haml | 2 +- .../shared/issuable/form/_metadata.html.haml | 5 ++- spec/features/issues/form_spec.rb | 13 +++--- spec/features/issues_spec.rb | 2 +- spec/policies/issue_policy_spec.rb | 8 ++-- .../authorized_destroy_service_spec.rb | 6 +-- spec/services/users/destroy_service_spec.rb | 4 +- 22 files changed, 93 insertions(+), 58 deletions(-) diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index 3062cd51ee370..a20c6ca7a21ab 100644 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -99,7 +99,7 @@ export default class FileTemplateMediator { }); } - selectTemplateType(item, el, e) { + selectTemplateType(item, e) { if (e) { e.preventDefault(); } @@ -117,6 +117,10 @@ export default class FileTemplateMediator { this.cacheToggleText(); } + selectTemplateTypeOptions(options) { + this.selectTemplateType(options.selectedObj, options.e); + } + selectTemplateFile(selector, query, data) { selector.renderLoading(); // in case undo menu is already already there diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js index 31dd45fac89ad..ab5b3751c4e2f 100644 --- a/app/assets/javascripts/blob/file_template_selector.js +++ b/app/assets/javascripts/blob/file_template_selector.js @@ -52,9 +52,17 @@ export default class FileTemplateSelector { .removeClass('fa-spinner fa-spin'); } - reportSelection(query, el, e, data) { + reportSelection(options) { + const { query, e, data } = options; e.preventDefault(); return this.mediator.selectTemplateFile(this, query, data); } + + reportSelectionName(options) { + const opts = options; + opts.query = options.selectedObj.name; + + this.reportSelection(opts); + } } diff --git a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js index 935df07677cb2..f2f81af137b4b 100644 --- a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js +++ b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js @@ -25,7 +25,7 @@ export default class BlobCiYamlSelector extends FileTemplateSelector { search: { fields: ['name'], }, - clicked: (query, el, e) => this.reportSelection(query.name, el, e), + clicked: options => this.reportSelectionName(options), text: item => item.name, }); } diff --git a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js index b4b4d09c315b3..3cb7b960aaa7c 100644 --- a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js +++ b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js @@ -25,7 +25,7 @@ export default class DockerfileSelector extends FileTemplateSelector { search: { fields: ['name'], }, - clicked: (query, el, e) => this.reportSelection(query.name, el, e), + clicked: options => this.reportSelectionName(options), text: item => item.name, }); } diff --git a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js index aefae54ae71a4..7efda8e7f50d8 100644 --- a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js +++ b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js @@ -24,7 +24,7 @@ export default class BlobGitignoreSelector extends FileTemplateSelector { search: { fields: ['name'], }, - clicked: (query, el, e) => this.reportSelection(query.name, el, e), + clicked: options => this.reportSelectionName(options), text: item => item.name, }); } diff --git a/app/assets/javascripts/blob/template_selectors/license_selector.js b/app/assets/javascripts/blob/template_selectors/license_selector.js index c8abd689ab4ef..1d757332f6c9f 100644 --- a/app/assets/javascripts/blob/template_selectors/license_selector.js +++ b/app/assets/javascripts/blob/template_selectors/license_selector.js @@ -24,13 +24,22 @@ export default class BlobLicenseSelector extends FileTemplateSelector { search: { fields: ['name'], }, - clicked: (query, el, e) => { + clicked: (options) => { + const { e } = options; + const el = options.$el; + const query = options.selectedObj; + const data = { project: this.$dropdown.data('project'), fullname: this.$dropdown.data('fullname'), }; - this.reportSelection(query.id, el, e, data); + this.reportSelection({ + query: query.id, + el, + e, + data, + }); }, text: item => item.name, }); diff --git a/app/assets/javascripts/blob/template_selectors/type_selector.js b/app/assets/javascripts/blob/template_selectors/type_selector.js index 56f23ef05687a..a09381014a754 100644 --- a/app/assets/javascripts/blob/template_selectors/type_selector.js +++ b/app/assets/javascripts/blob/template_selectors/type_selector.js @@ -17,7 +17,7 @@ export default class FileTemplateTypeSelector extends FileTemplateSelector { filterable: false, selectable: true, toggleLabel: item => item.name, - clicked: (item, el, e) => this.mediator.selectTemplateType(item, el, e), + clicked: options => this.mediator.selectTemplateTypeOptions(options), text: item => item.name, }); } diff --git a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js index fff83f3af3bb3..d4c9a91a74a42 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js +++ b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js @@ -17,8 +17,8 @@ export default class ProtectedTagAccessDropdown { } return 'Select'; }, - clicked(item, $el, e) { - e.preventDefault(); + clicked(options) { + options.e.preventDefault(); onSelect(); }, }); diff --git a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js index 5ff4e4432622b..068e9698e1d02 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js +++ b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js @@ -39,8 +39,8 @@ export default class ProtectedTagDropdown { return _.escape(protectedTag.id); }, onFilter: this.toggleCreateNewButton.bind(this), - clicked: (item, $el, e) => { - e.preventDefault(); + clicked: (options) => { + options.e.preventDefault(); this.onSelect(); }, }); diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 2dcaa24e93a1f..be29b08c34345 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -77,7 +77,11 @@ import eventHub from './sidebar/event_hub'; input.value = _this.currentUser.id; } - $dropdown.before(input); + if ($selectbox) { + $dropdown.parent().before(input); + } else { + $dropdown.after(input); + } }; if ($block[0]) { @@ -95,6 +99,24 @@ import eventHub from './sidebar/event_hub'; .get(); }; + const checkMaxSelect = function() { + const maxSelect = $dropdown.data('max-select'); + if (maxSelect) { + const selected = getSelected(); + + if (selected.length > maxSelect) { + const firstSelectedId = selected[0]; + const firstSelected = $dropdown.closest('.selectbox') + .find(`input[name='${$dropdown.data('field-name')}'][value=${firstSelectedId}]`); + + firstSelected.remove(); + eventHub.$emit('sidebar.removeAssignee', { + id: firstSelectedId, + }); + } + } + }; + const getMultiSelectDropdownTitle = function(selectedUser, isSelected) { const selectedUsers = getSelected() .filter(u => u !== 0); @@ -125,6 +147,7 @@ import eventHub from './sidebar/event_hub'; if ($dropdown.data('multiSelect')) { assignYourself(); + checkMaxSelect(); const currentUserInfo = $dropdown.data('currentUserInfo'); $dropdown.find('.dropdown-toggle-text').text(getMultiSelectDropdownTitle(currentUserInfo)).removeClass('is-default'); @@ -333,21 +356,7 @@ import eventHub from './sidebar/event_hub'; // Enables support for limiting the number of users selected // Automatically removes the first on the list if more users are selected - const maxSelect = $dropdown.data('max-select'); - if (maxSelect) { - const selected = getSelected(); - - if (selected.length > maxSelect) { - const firstSelectedId = selected[0]; - const firstSelected = $dropdown.closest('.selectbox') - .find(`input[name='${$dropdown.data('field-name')}'][value=${firstSelectedId}]`); - - firstSelected.remove(); - eventHub.$emit('sidebar.removeAssignee', { - id: firstSelectedId, - }); - } - } + checkMaxSelect(); if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') { // Unassigned selected diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 856989bccf11f..5c9b71a452cc0 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -257,6 +257,10 @@ padding: 0 16px; } + &.capitalize-header .dropdown-header { + text-transform: capitalize; + } + .separator + .dropdown-header { padding-top: 2px; } diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index 639a720b024f7..53962b846185b 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -18,7 +18,7 @@ def form_errors(model) def issue_dropdown_options(issuable, has_multiple_assignees = true) options = { - toggle_class: 'js-user-search js-assignee-search', + toggle_class: 'js-user-search js-assignee-search js-multiselect js-save-user-data', title: 'Select assignee', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee', @@ -32,16 +32,15 @@ def issue_dropdown_options(issuable, has_multiple_assignees = true) default_label: 'Assignee', 'max-select': 1, 'dropdown-header': 'Assignee', + multi_select: true, + 'input-meta': 'name', + 'always-show-selectbox': true, + current_user_info: current_user.to_json(only: [:id, :name]) } } if has_multiple_assignees - options[:toggle_class] += ' js-multiselect js-save-user-data' options[:title] = 'Select assignee(s)' - options[:data][:multi_select] = true - options[:data][:'input-meta'] = 'name' - options[:data][:'always-show-selectbox'] = true - options[:data][:current_user_info] = current_user.to_json(only: [:id, :name]) options[:data][:'dropdown-header'] = 'Assignee(s)' options[:data].delete(:'max-select') end diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml index b8e885b4d9a7a..99bc25163660b 100644 --- a/app/views/projects/protected_branches/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml @@ -25,7 +25,7 @@ .merge_access_levels-container = dropdown_tag('Select', options: { toggle_class: 'js-allowed-to-merge wide', - dropdown_class: 'dropdown-menu-selectable', + dropdown_class: 'dropdown-menu-selectable capitalize-header', data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes' }}) .form-group %label.col-md-2.text-right{ for: 'push_access_levels_attributes' } @@ -34,7 +34,7 @@ .push_access_levels-container = dropdown_tag('Select', options: { toggle_class: 'js-allowed-to-push wide', - dropdown_class: 'dropdown-menu-selectable', + dropdown_class: 'dropdown-menu-selectable capitalize-header', data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }}) .panel-footer diff --git a/app/views/projects/protected_branches/_update_protected_branch.html.haml b/app/views/projects/protected_branches/_update_protected_branch.html.haml index d6044aacaec85..c61b2951e1e89 100644 --- a/app/views/projects/protected_branches/_update_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_update_protected_branch.html.haml @@ -1,10 +1,10 @@ %td = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_levels.first.access_level = dropdown_tag( (protected_branch.merge_access_levels.first.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container', + options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container capitalize-header', data: { field_name: "allowed_to_merge_#{protected_branch.id}", access_level_id: protected_branch.merge_access_levels.first.id }}) %td = hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_levels.first.access_level = dropdown_tag( (protected_branch.push_access_levels.first.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container', + options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container capitalize-header', data: { field_name: "allowed_to_push_#{protected_branch.id}", access_level_id: protected_branch.push_access_levels.first.id }}) diff --git a/app/views/projects/protected_tags/_dropdown.html.haml b/app/views/projects/protected_tags/_dropdown.html.haml index 748515190779f..c50515cfe06e9 100644 --- a/app/views/projects/protected_tags/_dropdown.html.haml +++ b/app/views/projects/protected_tags/_dropdown.html.haml @@ -2,7 +2,7 @@ = dropdown_tag('Select tag or create wildcard', options: { toggle_class: 'js-protected-tag-select js-filter-submit wide', - filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search protected tag", + filter: true, dropdown_class: "dropdown-menu-selectable capitalize-header", placeholder: "Search protected tag", footer_content: true, data: { show_no: true, show_any: true, show_upcoming: true, selected: params[:protected_tag_name], diff --git a/app/views/projects/protected_tags/_update_protected_tag.haml b/app/views/projects/protected_tags/_update_protected_tag.haml index 62823bee46e1f..cc80bd04dd067 100644 --- a/app/views/projects/protected_tags/_update_protected_tag.haml +++ b/app/views/projects/protected_tags/_update_protected_tag.haml @@ -1,5 +1,5 @@ %td = hidden_field_tag "allowed_to_create_#{protected_tag.id}", protected_tag.create_access_levels.first.access_level = dropdown_tag( (protected_tag.create_access_levels.first.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-create', dropdown_class: 'dropdown-menu-selectable js-allowed-to-create-container', + options: { toggle_class: 'js-allowed-to-create', dropdown_class: 'dropdown-menu-selectable capitalize-header js-allowed-to-create-container', data: { field_name: "allowed_to_create_#{protected_tag.id}", access_level_id: protected_tag.create_access_levels.first.id }}) diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml index 411cb717fc766..9281a51574444 100644 --- a/app/views/shared/issuable/form/_metadata.html.haml +++ b/app/views/shared/issuable/form/_metadata.html.haml @@ -17,7 +17,10 @@ - issuable.assignees.each do |assignee| = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { meta: assignee.name } - = dropdown_tag(users_dropdown_label(issuable.assignees), options: issue_dropdown_options(issuable, true)) + - if issuable.assignees.length === 0 + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' } + + = dropdown_tag(users_dropdown_label(issuable.assignees), options: issue_dropdown_options(issuable,false)) = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}" - else = form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" diff --git a/spec/features/issues/form_spec.rb b/spec/features/issues/form_spec.rb index 5798292033b14..87adce3cdddf4 100644 --- a/spec/features/issues/form_spec.rb +++ b/spec/features/issues/form_spec.rb @@ -77,11 +77,10 @@ click_link 'Assign to me' assignee_ids = page.all('input[name="issue[assignee_ids][]"]', visible: false) - expect(assignee_ids[0].value).to match(user2.id.to_s) - expect(assignee_ids[1].value).to match(user.id.to_s) + expect(assignee_ids[0].value).to match(user.id.to_s) page.within '.js-assignee-search' do - expect(page).to have_content "#{user2.name} + 1 more" + expect(page).to have_content user.name end expect(find('a', text: 'Assign to me', visible: false)).not_to be_visible @@ -109,7 +108,7 @@ page.within '.issuable-sidebar' do page.within '.assignee' do - expect(page).to have_content "2 Assignees" + expect(page).to have_content "Assignee" end page.within '.milestone' do @@ -148,12 +147,12 @@ end it 'correctly updates the selected user when changing assignee' do - click_button 'Assignee' + click_button 'Unassigned' page.within '.dropdown-menu-user' do click_link user.name end - expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user.id.to_s) + expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user.id.to_s) click_button user.name @@ -167,7 +166,7 @@ click_link user2.name end - expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user2.id.to_s) + expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user2.id.to_s) click_button user2.name diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index cc81303f03292..5285dda361b4e 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -710,7 +710,7 @@ include WaitForVueResource it 'updates the title', js: true do - issue = create(:issue, author: @user, assignee: @user, project: project, title: 'new title') + issue = create(:issue, author: @user, assignees: [@user], project: project, title: 'new title') visit namespace_project_issue_path(project.namespace, project, issue) diff --git a/spec/policies/issue_policy_spec.rb b/spec/policies/issue_policy_spec.rb index 9a870b7fda12f..4a07c864428c6 100644 --- a/spec/policies/issue_policy_spec.rb +++ b/spec/policies/issue_policy_spec.rb @@ -15,7 +15,7 @@ def permissions(user, issue) context 'a private project' do let(:non_member) { create(:user) } let(:project) { create(:empty_project, :private) } - let(:issue) { create(:issue, project: project, assignee: assignee, author: author) } + let(:issue) { create(:issue, project: project, assignees: [assignee], author: author) } let(:issue_no_assignee) { create(:issue, project: project) } before do @@ -69,7 +69,7 @@ def permissions(user, issue) end context 'with confidential issues' do - let(:confidential_issue) { create(:issue, :confidential, project: project, assignee: assignee, author: author) } + let(:confidential_issue) { create(:issue, :confidential, project: project, assignees: [assignee], author: author) } let(:confidential_issue_no_assignee) { create(:issue, :confidential, project: project) } it 'does not allow non-members to read confidential issues' do @@ -110,7 +110,7 @@ def permissions(user, issue) context 'a public project' do let(:project) { create(:empty_project, :public) } - let(:issue) { create(:issue, project: project, assignee: assignee, author: author) } + let(:issue) { create(:issue, project: project, assignees: [assignee], author: author) } let(:issue_no_assignee) { create(:issue, project: project) } before do @@ -157,7 +157,7 @@ def permissions(user, issue) end context 'with confidential issues' do - let(:confidential_issue) { create(:issue, :confidential, project: project, assignee: assignee, author: author) } + let(:confidential_issue) { create(:issue, :confidential, project: project, assignees: [assignee], author: author) } let(:confidential_issue_no_assignee) { create(:issue, :confidential, project: project) } it 'does not allow guests to read confidential issues' do diff --git a/spec/services/members/authorized_destroy_service_spec.rb b/spec/services/members/authorized_destroy_service_spec.rb index 3b35a3b8e3a3a..ab440d18e9f68 100644 --- a/spec/services/members/authorized_destroy_service_spec.rb +++ b/spec/services/members/authorized_destroy_service_spec.rb @@ -14,8 +14,8 @@ def number_of_assigned_issuables(user) it "unassigns issues and merge requests" do group.add_developer(member_user) - issue = create :issue, project: group_project, assignee: member_user - create :issue, assignee: member_user + issue = create :issue, project: group_project, assignees: [member_user] + create :issue, assignees: [member_user] merge_request = create :merge_request, target_project: group_project, source_project: group_project, assignee: member_user create :merge_request, target_project: project, source_project: project, assignee: member_user @@ -33,7 +33,7 @@ def number_of_assigned_issuables(user) it "unassigns issues and merge requests" do project.team << [member_user, :developer] - create :issue, project: project, assignee: member_user + create :issue, project: project, assignees: [member_user] create :merge_request, target_project: project, source_project: project, assignee: member_user member = project.members.find_by(user_id: member_user.id) diff --git a/spec/services/users/destroy_service_spec.rb b/spec/services/users/destroy_service_spec.rb index 4bc30018ebd5d..8aa900d1a75fa 100644 --- a/spec/services/users/destroy_service_spec.rb +++ b/spec/services/users/destroy_service_spec.rb @@ -47,7 +47,7 @@ end context "for an issue the user was assigned to" do - let!(:issue) { create(:issue, project: project, assignee: user) } + let!(:issue) { create(:issue, project: project, assignees: [user]) } before do service.execute(user) @@ -60,7 +60,7 @@ it 'migrates the issue so that it is "Unassigned"' do migrated_issue = Issue.find_by_id(issue.id) - expect(migrated_issue.assignee).to be_nil + expect(migrated_issue.assignees).to be_nil end end end -- GitLab From cca09bbb1864bf300157d4d9c5500b83acf9f5c7 Mon Sep 17 00:00:00 2001 From: blackst0ne <blackst0ne.ru@gmail.com> Date: Fri, 5 May 2017 16:39:26 +1100 Subject: [PATCH 257/363] Update Import/Export files --- lib/gitlab/import_export/relation_factory.rb | 2 +- spec/lib/gitlab/import_export/all_models.yml | 3 +++ spec/lib/gitlab/import_export/safe_model_attributes.yml | 4 ++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb index 4a54e7ef2e736..956763fa39927 100644 --- a/lib/gitlab/import_export/relation_factory.rb +++ b/lib/gitlab/import_export/relation_factory.rb @@ -15,7 +15,7 @@ class RelationFactory priorities: :label_priorities, label: :project_label }.freeze - USER_REFERENCES = %w[author_id assignee_id updated_by_id user_id created_by_id merge_user_id resolved_by_id].freeze + USER_REFERENCES = %w[author_id assignee_id updated_by_id user_id created_by_id last_edited_by_id merge_user_id resolved_by_id].freeze PROJECT_REFERENCES = %w[project_id source_project_id target_project_id].freeze diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 0abf89d060cec..9ae294ae8d1fa 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -9,6 +9,7 @@ issues: - notes - label_links - labels +- last_edited_by - todos - user_agent_detail - moved_to @@ -26,6 +27,7 @@ notes: - noteable - author - updated_by +- last_edited_by - resolved_by - todos - events @@ -71,6 +73,7 @@ merge_requests: - notes - label_links - labels +- last_edited_by - todos - target_project - source_project diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index ebfaab4eacd78..5fdc30207c3f1 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -23,6 +23,8 @@ Issue: - weight - time_estimate - relative_position +- last_edited_at +- last_edited_by_id Event: - id - target_type @@ -154,6 +156,8 @@ MergeRequest: - approvals_before_merge - rebase_commit_sha - time_estimate +- last_edited_at +- last_edited_by_id MergeRequestDiff: - id - state -- GitLab From a1debf5cf029edc550d7d46eff7ef4ab1b51a2b2 Mon Sep 17 00:00:00 2001 From: Filipa Lacerda <filipa@gitlab.com> Date: Mon, 10 Apr 2017 11:30:06 +0100 Subject: [PATCH 258/363] Retry and cancel endpoints send 204 json response. Request is made with type json --- .../pipelines/services/pipelines_service.js | 2 +- .../projects/pipelines_controller.rb | 20 +++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js index 255cd513490e4..b21f84b454529 100644 --- a/app/assets/javascripts/pipelines/services/pipelines_service.js +++ b/app/assets/javascripts/pipelines/services/pipelines_service.js @@ -40,6 +40,6 @@ export default class PipelinesService { * @return {Promise} */ postAction(endpoint) { - return Vue.http.post(endpoint, {}, { emulateJSON: true }); + return Vue.http.post(`${endpoint}.json`); } } diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 454b8ee17af2b..6d77b9c38fa2d 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -92,13 +92,29 @@ def stage def retry pipeline.retry_failed(current_user) - redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project) + respond_to do |format| + format.html do + redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project) + end + + format.json do + render status: 204 + end + end end def cancel pipeline.cancel_running - redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project) + respond_to do |format| + format.html do + redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project) + end + + format.json do + render status: 204 + end + end end def charts -- GitLab From b8960354fecdaf0a19a370f00f7caad147c57e62 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Wed, 12 Apr 2017 09:44:16 +0200 Subject: [PATCH 259/363] Respond with no content for pipeline JSON actions --- app/controllers/projects/pipelines_controller.rb | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 6d77b9c38fa2d..95478da019e1c 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -97,9 +97,7 @@ def retry redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project) end - format.json do - render status: 204 - end + format.json { head :no_content } end end @@ -111,9 +109,7 @@ def cancel redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project) end - format.json do - render status: 204 - end + format.json { head :no_content } end end -- GitLab From c68bf4327b3e4811e001adeca84ad0f88c421455 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Mon, 24 Apr 2017 16:34:53 +0200 Subject: [PATCH 260/363] Use wrap_parameters in pipelines controller This makes it possible to workaround a bug in `safe_constantize` which caused a `LoadError` exception when doing ``` "Pipeline".safe_constantize LoadError: Unable to autoload constant Pipeline, expected /home/grzesiek/gdk/gitlab/app/models/ci/pipeline.rb to define it ``` See https://github.com/rails/rails/issues/28854 for more details. --- app/controllers/projects/pipelines_controller.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 95478da019e1c..2908036607a30 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -6,6 +6,8 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action :builds_enabled, only: :charts + wrap_parameters Ci::Pipeline + def index @scope = params[:scope] @pipelines = PipelinesFinder -- GitLab From 4f3dc19aafdd71aedb6b086da5707315a9a51ace Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Thu, 4 May 2017 21:01:15 +0200 Subject: [PATCH 261/363] Add specs for new pipeline action for JSON format --- .../projects/pipelines_controller_spec.rb | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb index b9bacc5a64a25..1b47d163c0b3d 100644 --- a/spec/controllers/projects/pipelines_controller_spec.rb +++ b/spec/controllers/projects/pipelines_controller_spec.rb @@ -5,6 +5,8 @@ let(:project) { create(:empty_project, :public) } before do + project.add_developer(user) + sign_in(user) end @@ -87,4 +89,38 @@ def get_stage(name) expect(json_response['favicon']).to eq "/assets/ci_favicons/#{status.favicon}.ico" end end + + describe 'POST retry.json' do + let!(:pipeline) { create(:ci_pipeline, :failed, project: project) } + let!(:build) { create(:ci_build, :failed, pipeline: pipeline) } + + before do + post :retry, namespace_id: project.namespace, + project_id: project, + id: pipeline.id, + format: :json + end + + it 'retries a pipeline without returning any content' do + expect(response).to have_http_status(:no_content) + expect(build.reload).to be_retried + end + end + + describe 'POST cancel.json' do + let!(:pipeline) { create(:ci_pipeline, project: project) } + let!(:build) { create(:ci_build, :running, pipeline: pipeline) } + + before do + post :cancel, namespace_id: project.namespace, + project_id: project, + id: pipeline.id, + format: :json + end + + it 'cancels a pipeline without returning any content' do + expect(response).to have_http_status(:no_content) + expect(pipeline.reload).to be_canceled + end + end end -- GitLab From f7a5ce923a3c0db9fc34f611b1a655c472503941 Mon Sep 17 00:00:00 2001 From: Filipa Lacerda <filipa@gitlab.com> Date: Fri, 5 May 2017 07:32:36 +0000 Subject: [PATCH 262/363] Remove # char for commit --- app/assets/javascripts/merge_request_widget.js | 2 +- changelogs/unreleased/31810-commit-link.yml | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 changelogs/unreleased/31810-commit-link.yml diff --git a/app/assets/javascripts/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js index 42ecf0d6cb2f5..6f6ae9bde926c 100644 --- a/app/assets/javascripts/merge_request_widget.js +++ b/app/assets/javascripts/merge_request_widget.js @@ -291,7 +291,7 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; MergeRequestWidget.prototype.updateCommitUrls = function(id) { const commitsUrl = this.opts.commits_path; - $('.js-commit-link').text(`#${id}`).attr('href', [commitsUrl, id].join('/')); + $('.js-commit-link').text(id).attr('href', [commitsUrl, id].join('/')); }; MergeRequestWidget.prototype.initMiniPipelineGraph = function() { diff --git a/changelogs/unreleased/31810-commit-link.yml b/changelogs/unreleased/31810-commit-link.yml new file mode 100644 index 0000000000000..857c9cb95c595 --- /dev/null +++ b/changelogs/unreleased/31810-commit-link.yml @@ -0,0 +1,4 @@ +--- +title: Remove `#` being added on commit sha in MR widget +merge_request: +author: -- GitLab From 1fe8b7f646603239f530b1a18427f4f5bc0e2060 Mon Sep 17 00:00:00 2001 From: James Lopez <james@jameslopez.es> Date: Fri, 5 May 2017 09:40:44 +0200 Subject: [PATCH 263/363] refactor propagate service to use batch inserts and subquery instead of left join --- app/services/projects/propagate_service.rb | 32 ++++++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/app/services/projects/propagate_service.rb b/app/services/projects/propagate_service.rb index 6e24a67d8b0b7..b067fc2cd6492 100644 --- a/app/services/projects/propagate_service.rb +++ b/app/services/projects/propagate_service.rb @@ -26,7 +26,7 @@ def propagate_projects_with_template loop do batch = project_ids_batch(offset) - batch.each { |project_id| create_from_template(project_id) } + bulk_create_from_template(batch) break if batch.size < BATCH_SIZE @@ -34,14 +34,34 @@ def propagate_projects_with_template end end - def create_from_template(project_id) - Service.build_from_template(project_id, @template).save! + def bulk_create_from_template(batch) + service_hash_list = batch.map do |project_id| + service_hash.merge('project_id' => project_id) + end + + Project.transaction do + Service.create!(service_hash_list) + end end def project_ids_batch(offset) - Project.joins('LEFT JOIN services ON services.project_id = projects.id'). - where('services.type != ? OR services.id IS NULL', @template.type). - limit(BATCH_SIZE).offset(offset).pluck(:id) + Project.connection.execute( + <<-SQL + SELECT id + FROM projects + WHERE NOT EXISTS ( + SELECT true + FROM services + WHERE services.project_id = projects.id + AND services.type = '#{@template.type}' + ) + LIMIT #{BATCH_SIZE} OFFSET #{offset} + SQL + ).to_a.flatten + end + + def service_hash + @service_hash ||= @template.as_json(methods: :type).except('id', 'template') end end end -- GitLab From 225662a708314947a970c9be6f969ada6c636c23 Mon Sep 17 00:00:00 2001 From: Annabel Dunstone Gray <annabel.dunstone@gmail.com> Date: Fri, 5 May 2017 08:33:04 +0000 Subject: [PATCH 264/363] Update design of auth error page --- .../omniauth_callbacks_controller.rb | 2 +- app/views/errors/omniauth_error.html.haml | 21 ++- app/views/layouts/oauth_error.html.haml | 127 ++++++++++++++++++ app/views/shared/errors/_graphic_422.svg | 1 + changelogs/unreleased/29145-oauth-422.yml | 4 + 5 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 app/views/layouts/oauth_error.html.haml create mode 100644 app/views/shared/errors/_graphic_422.svg create mode 100644 changelogs/unreleased/29145-oauth-422.yml diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 58d50ad647bf1..2a8c8ca4bad6a 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -67,7 +67,7 @@ def saml def omniauth_error @provider = params[:provider] @error = params[:error] - render 'errors/omniauth_error', layout: "errors", status: 422 + render 'errors/omniauth_error', layout: "oauth_error", status: 422 end def cas3 diff --git a/app/views/errors/omniauth_error.html.haml b/app/views/errors/omniauth_error.html.haml index 72508b9113411..20b7fa471a0a3 100644 --- a/app/views/errors/omniauth_error.html.haml +++ b/app/views/errors/omniauth_error.html.haml @@ -1,16 +1,15 @@ - content_for(:title, 'Auth Error') -%img{ :alt => "GitLab Logo", :src => image_path('logo.svg') } - %h1 - 422 + .container + = render "shared/errors/graphic_422.svg" %h3 Sign-in using #{@provider} auth failed - %hr - %p Sign-in failed because #{@error}. - %p There are couple of steps you can take: -%ul - %li Try logging in using your email - %li Try logging in using your username - %li If you have forgotten your password, try recovering it using #{ link_to "Password recovery", new_password_path(resource_name) } + %p.light.subtitle Sign-in failed because #{@error}. + + %p Try logging in using your username or email. If you have forgotten your password, try recovering it -%p If none of the options work, try contacting the GitLab administrator. + = link_to "Sign in", new_session_path(:user), class: 'btn primary' + = link_to "Recover password", new_password_path(resource_name), class: 'btn secondary' + + %hr + %p.light If none of the options work, try contacting a GitLab administrator. diff --git a/app/views/layouts/oauth_error.html.haml b/app/views/layouts/oauth_error.html.haml new file mode 100644 index 0000000000000..34bcd2a8b3a60 --- /dev/null +++ b/app/views/layouts/oauth_error.html.haml @@ -0,0 +1,127 @@ +!!! 5 +%html{ lang: "en" } + %head + %meta{ :content => "width=device-width, initial-scale=1, maximum-scale=1", :name => "viewport" } + %title= yield(:title) + :css + body { + color: #666; + text-align: center; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + margin: auto; + font-size: 16px; + } + + .container { + margin: auto 20px; + } + + h3 { + color: #456; + font-size: 22px; + font-weight: bold; + margin-bottom: 6px; + } + + p { + max-width: 470px; + margin: 16px auto; + } + + .subtitle { + margin: 0 auto 20px; + } + + svg { + width: 280px; + height: 280px; + display: block; + margin: 40px auto; + } + + .tv-screen path { + animation: move-lines 1s linear infinite; + } + + + @keyframes move-lines { + 0% {transform: translateY(0)} + 50% {transform: translateY(-10px)} + 100% {transform: translateY(-20px)} + } + + .tv-screen path:nth-child(1) { + animation-delay: .2s + } + + .tv-screen path:nth-child(2) { + animation-delay: .4s + } + + .tv-screen path:nth-child(3) { + animation-delay: .6s + } + + .tv-screen path:nth-child(4) { + animation-delay: .8s + } + + .tv-screen path:nth-child(5) { + animation-delay: 2s + } + + .text-422 { + animation: flicker 1s infinite; + } + + @keyframes flicker { + 0% {opacity: 0.3;} + 10% {opacity: 1;} + 15% {opacity: .3;} + 20% {opacity: .5;} + 25% {opacity: 1;} + } + + .light { + color: #8D8D8D; + } + + hr { + max-width: 600px; + margin: 18px auto; + border: 0; + border-top: 1px solid #EEE; + } + + .btn { + padding: 8px 14px; + border-radius: 3px; + border: 1px solid; + display: inline-block; + text-decoration: none; + margin: 4px 8px; + font-size: 14px; + } + + .primary { + color: #fff; + background-color: #1aaa55; + border-color: #168f48; + } + + .primary:hover { + background-color: #168f48; + } + + .secondary { + color: #1aaa55; + background-color: #fff; + border-color: #1aaa55; + } + + .secondary:hover { + background-color: #f3fff8; + } + +%body + = yield diff --git a/app/views/shared/errors/_graphic_422.svg b/app/views/shared/errors/_graphic_422.svg new file mode 100644 index 0000000000000..87128ecd69d54 --- /dev/null +++ b/app/views/shared/errors/_graphic_422.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 246" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="0" width="178" height="136" rx="10"/><mask id="1" width="178" height="136" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask></defs><g fill="none" fill-rule="evenodd"><g fill="#e5e5e5" fill-rule="nonzero"><path d="m109.88 37.634c5.587-3.567 12.225-5.634 19.345-5.634 7.445 0 14.363 2.26 20.1 6.132l21.435-37.13c.554-.959 1.771-1.292 2.734-.736.957.552 1.284 1.777.73 2.736l-21.496 37.23c-.065.112-.138.215-.219.309 3.686 3.13 6.733 6.988 8.919 11.353l-3.393.002c-5.775-10.322-16.705-16.901-28.814-16.901-12.12 0-23.06 6.594-28.833 16.935l-3.393.002c2.32-4.646 5.616-8.72 9.618-11.954l-21.349-36.977c-.554-.959-.227-2.184.73-2.736.963-.556 2.181-.223 2.734.736l21.15 36.629"/><path d="m3 70v134c0 9.389 7.611 17 16.997 17h220.01c9.389 0 16.997-7.611 16.997-17v-134c0-9.389-7.611-17-16.997-17h-220.01c-9.389 0-16.997 7.611-16.997 17m-3 0c0-11.05 8.95-20 19.997-20h220.01c11.04 0 19.997 8.958 19.997 20v134c0 11.05-8.95 20-19.997 20h-220.01c-11.04 0-19.997-8.958-19.997-20v-134"/></g><ellipse cx="129" cy="241.5" fill="#f9f9f9" rx="89" ry="4.5"/><g fill-rule="nonzero" transform="translate(210 70)"><path fill="#eaeaea" d="m16 29c7.18 0 13-5.82 13-13 0-7.18-5.82-13-13-13-7.18 0-13 5.82-13 13 0 7.18 5.82 13 13 13m0 3c-8.837 0-16-7.163-16-16 0-8.837 7.163-16 16-16 8.837 0 16 7.163 16 16 0 8.837-7.163 16-16 16" id="2"/><path fill="#6b4fbb" d="m16 21c2.761 0 5-2.239 5-5 0-2.761-2.239-5-5-5-2.761 0-5 2.239-5 5 0 2.761 2.239 5 5 5m0 3c-4.418 0-8-3.582-8-8 0-4.418 3.582-8 8-8 4.418 0 8 3.582 8 8 0 4.418-3.582 8-8 8" id="3"/></g><g fill-rule="nonzero" transform="translate(210 109)"><use xlink:href="#2"/><use xlink:href="#3"/></g><g transform="translate(210 147)"><path fill="#e5e5e5" fill-rule="nonzero" d="m3 5.992v45.02c0 1.647 1.346 2.992 3 2.992h20c1.657 0 3-1.341 3-2.992v-45.02c0-1.647-1.346-2.992-3-2.992h-20c-1.657 0-3 1.341-3 2.992m-3 0c0-3.309 2.687-5.992 6-5.992h20c3.314 0 6 2.692 6 5.992v45.02c0 3.309-2.687 5.992-6 5.992h-20c-3.314 0-6-2.692-6-5.992v-45.02"/><rect width="16" height="4" x="8" y="27" fill="#fdb692" rx="2"/><rect width="16" height="4" x="8" y="19" fill="#fc9867" rx="2"/><rect width="16" height="4" x="8" y="11" fill="#fc6d26" rx="2"/><rect width="16" height="4" x="8" y="35" fill="#fed3bd" rx="2"/><rect width="16" height="4" x="8" y="43" fill="#fef0e9" rx="2"/></g><g transform="translate(16 69)"><use fill="#6b4fbb" fill-opacity=".1" stroke="#e5e5e5" stroke-width="6" mask="url(#1)" xlink:href="#0"/><g class="tv-screen" fill="#fff"><path opacity=".4" mix-blend-mode="overlay" d="m3 17h172v16h-172z"/><path opacity=".6" mix-blend-mode="overlay" d="m3 70h172v24h-172z"/><path opacity=".3" mix-blend-mode="overlay" d="m3 107h172v16h-172z"/><path opacity=".4" mix-blend-mode="overlay" d="m3 40h172v8h-172z"/><path opacity=".3" mix-blend-mode="overlay" d="m3 55h172v8h-172z"/></g></g><path class="text-422" d="m.693 19h5.808c.277 0 .498-.224.498-.5 0-.268-.223-.5-.498-.5h-5.808v-2.094l3.777-5.906h3.916l-4.124 6.454h6.259v-6.454h.978c.273 0 .5-.224.5-.5 0-.268-.224-.5-.5-.5h-.978v-2h4.698v6h-2.721c-.277 0-.498.224-.498.5 0 .268.223.5.498.5h2.721v2.454h2.723v4.2h-2.723v5.346h-4.698v-5.346h-9.828v-1.654m4.417-10l1.279-2h3.914l-1.278 2h-3.916m1.919-3l1.279-2h4.192c.27 0 .5-.224.5-.5 0-.268-.224-.5-.5-.5h-3.552l1.142-1.786h5.13v4.786h-8.191m31.09 19v1h-15.738v-2h5.118c.271 0 .503-.224.503-.5 0-.268-.225-.5-.503-.5h-5.118v-1.184l2.656-2.822c.682-.725 1.306-1.39 1.872-1.994h5.428c-.389.394-.808.815-1.256 1.264-1.428 1.428-2.562 2.568-3.403 3.42h10.442v2.316h-4.614c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h4.614m-6.674-13c.493-.631.87-1.208 1.129-1.73.365-.736.548-1.464.548-2.183 0-1.107-.335-1.962-1-2.565-.67-.603-1.619-.905-2.847-.905-.874 0-1.857.174-2.947.523-1.09.349-2.227.855-3.412 1.519v-2.659h3.589c.27 0 .5-.224.5-.5 0-.268-.224-.5-.5-.5h-3.589v-.906c1.184-.432 2.344-.761 3.478-.988 1.134-.227 2.222-.34 3.262-.34 2.623 0 4.684.611 6.184 1.834.157.128.307.262.448.4h-2.782c-.27 0-.5.224-.5.5 0 .268.224.5.5.5h3.602c.654 1.01.981 2.209.981 3.605 0 .974-.163 1.887-.49 2.739-.326.852-.888 1.798-1.685 2.839-.397.509-1.261 1.448-2.594 2.816h-5.474c1.34-1.436 2.261-2.436 2.763-3h4.396c.271 0 .499-.224.499-.5 0-.268-.223-.5-.499-.5h-3.557m28.14 12v2h-15.738v-4.184l2.651-2.816h5.313c-1.087 1.089-1.976 1.983-2.668 2.684h10.442v1.316h-4.083c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h4.083m-2.069-11c-.045.061-.092.122-.139.184-.567.727-2.089 2.333-4.568 4.816h-5.372c2.601-2.77 4.204-4.503 4.81-5.198.83-.952 1.428-1.796 1.793-2.532.365-.736.548-1.464.548-2.183 0-1.107-.335-1.962-1-2.565-.67-.603-1.619-.905-2.847-.905-.874 0-1.857.174-2.947.523-1.09.349-2.227.855-3.412 1.519v-2.659h3.117c.271 0 .503-.224.503-.5 0-.268-.225-.5-.503-.5h-3.117v-.906c1.184-.432 2.344-.761 3.478-.988 1.134-.227 2.222-.34 3.262-.34 2.623 0 4.684.611 6.184 1.834.157.128.307.262.448.4h-1.248c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h2.069c.654 1.01.981 2.209.981 3.605 0 .844-.123 1.642-.368 2.395h-2.683c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h2.272c-.159.321-.347.655-.566 1h-3.706c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h3.01" transform="translate(75 124)" fill="#5c5c5c"/></g></svg> diff --git a/changelogs/unreleased/29145-oauth-422.yml b/changelogs/unreleased/29145-oauth-422.yml new file mode 100644 index 0000000000000..94e4cd84ad195 --- /dev/null +++ b/changelogs/unreleased/29145-oauth-422.yml @@ -0,0 +1,4 @@ +--- +title: Redesign auth 422 page +merge_request: +author: -- GitLab From b667fba826e02bec2c94ee4922edf348b84f8075 Mon Sep 17 00:00:00 2001 From: Balasankar C <balasankar@gitlab.com> Date: Fri, 5 May 2017 08:41:16 +0000 Subject: [PATCH 265/363] Add a manual job to trigger package build in omnibus --- .gitlab-ci.yml | 23 ++++++++++++++++++ doc/development/README.md | 1 + doc/development/build_test_package.md | 35 +++++++++++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 doc/development/build_test_package.md diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 44620d390ad33..588f255eff8bb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -23,6 +23,7 @@ before_script: - source scripts/prepare_build.sh stages: +- build - prepare - test - post-test @@ -137,6 +138,28 @@ stages: <<: *only-master-and-ee-or-mysql <<: *except-docs +# Trigger a package build on omnibus-gitlab repository + +build-package: + services: [] + variables: + SETUP_DB: "false" + USE_BUNDLE_INSTALL: "false" + stage: build + when: manual + script: + # If no branch in omnibus is specified, trigger pipeline against master + - if [ -z "$OMNIBUS_BRANCH" ] ; then export OMNIBUS_BRANCH=master ;fi + - echo "token=${BUILD_TRIGGER_TOKEN}" > version_details + - echo "ref=${OMNIBUS_BRANCH}" >> version_details + - echo "variables[ALTERNATIVE_SOURCES]=true" >> version_details + - echo "variables[GITLAB_VERSION]=${CI_COMMIT_SHA}" >> version_details + # Collect version details of all components + - for f in *_VERSION; do echo "variables[$f]=$(cat $f)" >> version_details; done + # Trigger the API and pass values collected above as parameters to it + - cat version_details | tr '\n' '&' | curl -X POST https://gitlab.com/api/v4/projects/20699/trigger/pipeline --data-binary @- + - rm version_details + # Prepare and merge knapsack tests knapsack: <<: *knapsack-state diff --git a/doc/development/README.md b/doc/development/README.md index 77bb0263374f3..d04380e5b3323 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -41,6 +41,7 @@ - [Shell commands](shell_commands.md) in the GitLab codebase - [Sidekiq debugging](sidekiq_debugging.md) - [Object state models](object_state_models.md) +- [Building a package for testing purposes](build_test_package.md) ## Databases diff --git a/doc/development/build_test_package.md b/doc/development/build_test_package.md new file mode 100644 index 0000000000000..2bc1a7008444d --- /dev/null +++ b/doc/development/build_test_package.md @@ -0,0 +1,35 @@ +# Building a package for testing + +While developing a new feature or modifying an existing one, it is helpful if an +installable package (or a docker image) containing those changes is available +for testing. For this very purpose, a manual job is provided in the GitLab CI/CD +pipeline that can be used to trigger a pipeline in the omnibus-gitlab repository +that will create +1. A deb package for Ubuntu 16.04, available as a build artifact, and +2. A docker image, which is pushed to [Omnibus GitLab's container +registry](https://gitlab.com/gitlab-org/omnibus-gitlab/container_registry) +(images titled `gitlab-ce` and `gitlab-ee` respectively and image tag is the +commit which triggered the pipeline). + +When you push a commit to either the gitlab-ce or gitlab-ee project, the +pipeline for that commit will have a `build-package` manual action you can +trigger. + +## Specifying versions of components + +If you want to create a package from a specific branch, commit or tag of any of +the GitLab components (like GitLab Workhorse, Gitaly, GitLab Pages, etc.), you +can specify the branch name, commit sha or tag in the component's respective +`*_VERSION` file. For example, if you want to build a package that uses the +branch `0-1-stable`, modify the content of `GITALY_SERVER_VERSION` to +`0-1-stable` and push the commit. This will create a manual job that can be +used to trigger the build. + +## Specifying the branch in omnibus-gitlab repository + +In scenarios where a configuration change is to be introduced and omnibus-gitlab +repository already has the necessary changes in a specific branch, you can build +a package against that branch through an environment variable named +`OMNIBUS_BRANCH`. To do this, specify that environment variable with the name of +the branch as value in `.gitlab-ci.yml` and push a commit. This will create a +manual job that can be used to trigger the build. -- GitLab From adcff298f8f3041faa29b75ee3711fb4ce1cbb69 Mon Sep 17 00:00:00 2001 From: James Lopez <james@jameslopez.es> Date: Fri, 5 May 2017 10:43:56 +0200 Subject: [PATCH 266/363] fixed all issues - not doing bulk create. --- app/services/projects/propagate_service.rb | 10 +++------- spec/services/projects/propagate_service_spec.rb | 12 ++++++++++++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/app/services/projects/propagate_service.rb b/app/services/projects/propagate_service.rb index b067fc2cd6492..716a620953719 100644 --- a/app/services/projects/propagate_service.rb +++ b/app/services/projects/propagate_service.rb @@ -21,16 +21,12 @@ def propagate private def propagate_projects_with_template - offset = 0 - loop do - batch = project_ids_batch(offset) + batch = project_ids_batch bulk_create_from_template(batch) break if batch.size < BATCH_SIZE - - offset += BATCH_SIZE end end @@ -44,7 +40,7 @@ def bulk_create_from_template(batch) end end - def project_ids_batch(offset) + def project_ids_batch Project.connection.execute( <<-SQL SELECT id @@ -55,7 +51,7 @@ def project_ids_batch(offset) WHERE services.project_id = projects.id AND services.type = '#{@template.type}' ) - LIMIT #{BATCH_SIZE} OFFSET #{offset} + LIMIT #{BATCH_SIZE} SQL ).to_a.flatten end diff --git a/spec/services/projects/propagate_service_spec.rb b/spec/services/projects/propagate_service_spec.rb index d4ec7c0b357bc..ac25c8b3d561b 100644 --- a/spec/services/projects/propagate_service_spec.rb +++ b/spec/services/projects/propagate_service_spec.rb @@ -66,5 +66,17 @@ expect(service.properties).to eq(service_template.properties) end + + describe 'bulk update' do + it 'creates services for all projects' do + project_total = 5 + stub_const 'Projects::PropagateService::BATCH_SIZE', 3 + + project_total.times { create(:empty_project) } + + expect { described_class.propagate(service_template) }. + to change { Service.count }.by(project_total + 1) + end + end end end -- GitLab From 9ec39568c5284f5a3a17a342d12f87befb6cfb4c Mon Sep 17 00:00:00 2001 From: James Lopez <james@jameslopez.es> Date: Fri, 5 May 2017 10:51:25 +0200 Subject: [PATCH 267/363] use select_values --- app/services/projects/propagate_service.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/services/projects/propagate_service.rb b/app/services/projects/propagate_service.rb index 716a620953719..f952b5108bb1f 100644 --- a/app/services/projects/propagate_service.rb +++ b/app/services/projects/propagate_service.rb @@ -41,7 +41,7 @@ def bulk_create_from_template(batch) end def project_ids_batch - Project.connection.execute( + Project.connection.select_values( <<-SQL SELECT id FROM projects @@ -53,7 +53,7 @@ def project_ids_batch ) LIMIT #{BATCH_SIZE} SQL - ).to_a.flatten + ) end def service_hash -- GitLab From 6f7933b34947984bf46b6a1f4e9e878199bf48d3 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Fri, 5 May 2017 10:22:59 +0100 Subject: [PATCH 268/363] Fixed cycle analytics intro box styling regression Closes #31844 --- .../stylesheets/pages/cycle_analytics.scss | 19 ++++++++++++++++ .../projects/cycle_analytics/show.html.haml | 22 +++++++++---------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index 52bbb753af337..d29944207c5ba 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -3,6 +3,25 @@ margin: 24px auto 0; position: relative; + .landing { + margin-top: 10px; + + .inner-content { + white-space: normal; + + h4, + p { + margin: 7px 0 0; + max-width: 480px; + padding: 0 $gl-padding; + + @media (max-width: $screen-sm-min) { + margin: 0 auto; + } + } + } + } + .col-headers { ul { margin: 0; diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index 819f29d3ca594..b158a81471cc4 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -9,17 +9,17 @@ #cycle-analytics{ class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } } - if @cycle_analytics_no_data - .bordered-box.landing.content-block{ "v-if" => "!isOverviewDialogDismissed" } - = icon("times", class: "dismiss-icon", "@click" => "dismissOverviewDialog()") - .row - .col-sm-3.col-xs-12.svg-container - = custom_icon('icon_cycle_analytics_splash') - .col-sm-8.col-xs-12.inner-content - %h4 - {{ __('Introducing Cycle Analytics') }} - %p - {{ __('Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.') }} - + .landing.content-block{ "v-if" => "!isOverviewDialogDismissed" } + %button.dismiss-button{ type: 'button', 'aria-label': 'Dismiss Cycle Analytics introduction box' } + = icon("times", "@click" => "dismissOverviewDialog()") + .svg-container + = custom_icon('icon_cycle_analytics_splash') + .inner-content + %h4 + {{ __('Introducing Cycle Analytics') }} + %p + {{ __('Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.') }} + %p = link_to _('Read more'), help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn' = icon("spinner spin", "v-show" => "isLoading") .wrapper{ "v-show" => "!isLoading && !hasError" } -- GitLab From cba965eba49811e1e97909dbbbb16adf04c4a37d Mon Sep 17 00:00:00 2001 From: Pawel Chojnacki <pawel@chojnacki.ws> Date: Fri, 5 May 2017 11:34:01 +0200 Subject: [PATCH 269/363] Set minimum latency to be non-negative number. Sometimes the tests run so fast latency is calculated as 0. This causes transient failures in our CI. --- .../lib/gitlab/health_checks/fs_shards_check_spec.rb | 12 ++++++------ spec/lib/gitlab/health_checks/simple_check_shared.rb | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb b/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb index 4cd8cf313a54a..45ccd3d6459d6 100644 --- a/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb +++ b/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb @@ -82,9 +82,9 @@ it { is_expected.to include(metric_class.new(:filesystem_readable, 0, shard: :default)) } it { is_expected.to include(metric_class.new(:filesystem_writable, 0, shard: :default)) } - it { is_expected.to include(have_attributes(name: :filesystem_access_latency, value: be > 0, labels: { shard: :default })) } - it { is_expected.to include(have_attributes(name: :filesystem_read_latency, value: be > 0, labels: { shard: :default })) } - it { is_expected.to include(have_attributes(name: :filesystem_write_latency, value: be > 0, labels: { shard: :default })) } + it { is_expected.to include(have_attributes(name: :filesystem_access_latency, value: be >= 0, labels: { shard: :default })) } + it { is_expected.to include(have_attributes(name: :filesystem_read_latency, value: be >= 0, labels: { shard: :default })) } + it { is_expected.to include(have_attributes(name: :filesystem_write_latency, value: be >= 0, labels: { shard: :default })) } end context 'storage points to directory that has both read and write rights' do @@ -96,9 +96,9 @@ it { is_expected.to include(metric_class.new(:filesystem_readable, 1, shard: :default)) } it { is_expected.to include(metric_class.new(:filesystem_writable, 1, shard: :default)) } - it { is_expected.to include(have_attributes(name: :filesystem_access_latency, value: be > 0, labels: { shard: :default })) } - it { is_expected.to include(have_attributes(name: :filesystem_read_latency, value: be > 0, labels: { shard: :default })) } - it { is_expected.to include(have_attributes(name: :filesystem_write_latency, value: be > 0, labels: { shard: :default })) } + it { is_expected.to include(have_attributes(name: :filesystem_access_latency, value: be >= 0, labels: { shard: :default })) } + it { is_expected.to include(have_attributes(name: :filesystem_read_latency, value: be >= 0, labels: { shard: :default })) } + it { is_expected.to include(have_attributes(name: :filesystem_write_latency, value: be >= 0, labels: { shard: :default })) } end end end diff --git a/spec/lib/gitlab/health_checks/simple_check_shared.rb b/spec/lib/gitlab/health_checks/simple_check_shared.rb index 1fa6d0faef983..3f871d6603442 100644 --- a/spec/lib/gitlab/health_checks/simple_check_shared.rb +++ b/spec/lib/gitlab/health_checks/simple_check_shared.rb @@ -8,7 +8,7 @@ it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_success", value: 1)) } it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_timeout", value: 0)) } - it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be > 0)) } + it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be >= 0)) } end context 'Check is misbehaving' do @@ -18,7 +18,7 @@ it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_success", value: 0)) } it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_timeout", value: 0)) } - it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be > 0)) } + it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be >= 0)) } end context 'Check is timeouting' do @@ -28,7 +28,7 @@ it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_success", value: 0)) } it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_timeout", value: 1)) } - it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be > 0)) } + it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be >= 0)) } end end -- GitLab From 7af4c0c4f7dc66f7293a121d1c0e2caa8a5050c6 Mon Sep 17 00:00:00 2001 From: Sean McGivern <sean@gitlab.com> Date: Fri, 5 May 2017 10:33:41 +0100 Subject: [PATCH 270/363] Fix email receiver metrics events `Project#inspect` isn't very useful for examining usage of these features. --- lib/gitlab/email/handler/create_issue_handler.rb | 2 +- lib/gitlab/email/handler/create_note_handler.rb | 2 +- lib/gitlab/email/handler/unsubscribe_handler.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/gitlab/email/handler/create_issue_handler.rb b/lib/gitlab/email/handler/create_issue_handler.rb index e7f91607e7e13..a616a80e8f5c3 100644 --- a/lib/gitlab/email/handler/create_issue_handler.rb +++ b/lib/gitlab/email/handler/create_issue_handler.rb @@ -37,7 +37,7 @@ def project end def metrics_params - super.merge(project: project) + super.merge(project: project&.full_path) end private diff --git a/lib/gitlab/email/handler/create_note_handler.rb b/lib/gitlab/email/handler/create_note_handler.rb index 31bb775c35789..31579e94a871d 100644 --- a/lib/gitlab/email/handler/create_note_handler.rb +++ b/lib/gitlab/email/handler/create_note_handler.rb @@ -29,7 +29,7 @@ def execute end def metrics_params - super.merge(project: project) + super.merge(project: project&.full_path) end private diff --git a/lib/gitlab/email/handler/unsubscribe_handler.rb b/lib/gitlab/email/handler/unsubscribe_handler.rb index df70a0633304a..5894384da5d44 100644 --- a/lib/gitlab/email/handler/unsubscribe_handler.rb +++ b/lib/gitlab/email/handler/unsubscribe_handler.rb @@ -20,7 +20,7 @@ def execute end def metrics_params - super.merge(project: project) + super.merge(project: project&.full_path) end private -- GitLab From f1f883501aa0035bbc744c7485a9517cb4561893 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin <godfat@godfat.org> Date: Fri, 5 May 2017 10:09:54 +0000 Subject: [PATCH 271/363] Update CHANGELOG.md for 9.1.3 [ci skip] --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2686d778b098e..188aa73d16a0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 9.1.3 (2017-05-05) + +- Enforce project features when searching blobs and wikis. +- Fixed branches dropdown rendering branch names as HTML. +- Make Asciidoc & other markup go through pipeline to prevent XSS. +- Validate URLs in markdown using URI to detect the host correctly. +- Fix for XSS in project import view caused by Hamlit filter usage. +- Sanitize submodule URLs before linking to them in the file tree view. +- Refactor snippets finder & dont return internal snippets for external users. +- Fix snippets visibility for show action - external users can not see internal snippets. + ## 9.1.2 (2017-05-01) - Add index on ci_runners.contacted_at. !10876 (blackst0ne) -- GitLab From 444df931e7a0a7101c9f16cc7a20ea11094335d7 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin <godfat@godfat.org> Date: Fri, 5 May 2017 10:16:44 +0000 Subject: [PATCH 272/363] Update CHANGELOG.md for 9.0.7 [ci skip] --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 188aa73d16a0a..e05b025ce2d6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -287,6 +287,18 @@ entry. - Only send chat notifications for the default branch. - Don't fill in the default kubernetes namespace. +## 9.0.7 (2017-05-05) + +- Enforce project features when searching blobs and wikis. +- Fixed branches dropdown rendering branch names as HTML. +- Make Asciidoc & other markup go through pipeline to prevent XSS. +- Validate URLs in markdown using URI to detect the host correctly. +- Fix for XSS in project import view caused by Hamlit filter usage. +- Sanitize submodule URLs before linking to them in the file tree view. +- Refactor snippets finder & dont return internal snippets for external users. +- Fix snippets visibility for show action - external users can not see internal snippets. +- Do not show private groups on subgroups page if user doesn't have access to. + ## 9.0.6 (2017-04-21) - Bugfix: POST /projects/:id/hooks and PUT /projects/:id/hook/:hook_id no longer ignore the the job_events param in the V4 API. !10586 -- GitLab From 936367538043854c7b093b71ca315b8e469c55a4 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Fri, 5 May 2017 12:25:24 +0200 Subject: [PATCH 273/363] Use update build policy instead of new play policy --- app/policies/ci/build_policy.rb | 12 +++++++----- app/policies/environment_policy.rb | 2 +- app/serializers/build_action_entity.rb | 2 +- app/serializers/build_entity.rb | 2 +- app/services/ci/play_build_service.rb | 2 +- app/views/projects/ci/builds/_build.html.haml | 2 +- lib/gitlab/ci/status/build/play.rb | 2 +- spec/policies/ci/build_policy_spec.rb | 18 +++++++++--------- 8 files changed, 22 insertions(+), 20 deletions(-) diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index 2c39d31488f05..d4af449060862 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -1,5 +1,7 @@ module Ci class BuildPolicy < CommitStatusPolicy + alias_method :build, :subject + def rules super @@ -9,17 +11,17 @@ def rules cannot! :"#{rule}_commit_status" unless can? :"#{rule}_build" end - can! :play_build if can_play_action? + if can?(:update_build) && protected_action? + cannot! :update_build + end end private - alias_method :build, :subject - - def can_play_action? + def protected_action? return false unless build.action? - ::Gitlab::UserAccess + !::Gitlab::UserAccess .new(user, project: build.project) .can_push_to_branch?(build.ref) end diff --git a/app/policies/environment_policy.rb b/app/policies/environment_policy.rb index cc94d4a7e058e..2fa15e645629b 100644 --- a/app/policies/environment_policy.rb +++ b/app/policies/environment_policy.rb @@ -12,6 +12,6 @@ def rules private def can_play_stop_action? - Ability.allowed?(user, :play_build, environment.stop_action) + Ability.allowed?(user, :update_build, environment.stop_action) end end diff --git a/app/serializers/build_action_entity.rb b/app/serializers/build_action_entity.rb index 3eff724ae9149..75dda1af709b1 100644 --- a/app/serializers/build_action_entity.rb +++ b/app/serializers/build_action_entity.rb @@ -19,6 +19,6 @@ class BuildActionEntity < Grape::Entity alias_method :build, :object def playable? - build.playable? && can?(request.user, :play_build, build) + build.playable? && can?(request.user, :update_build, build) end end diff --git a/app/serializers/build_entity.rb b/app/serializers/build_entity.rb index 10ba7c90c10c2..1380b347d8e96 100644 --- a/app/serializers/build_entity.rb +++ b/app/serializers/build_entity.rb @@ -26,7 +26,7 @@ class BuildEntity < Grape::Entity alias_method :build, :object def playable? - build.playable? && can?(request.user, :play_build, build) + build.playable? && can?(request.user, :update_build, build) end def detailed_status diff --git a/app/services/ci/play_build_service.rb b/app/services/ci/play_build_service.rb index 5b3ff1ced3855..e24f48c2d1654 100644 --- a/app/services/ci/play_build_service.rb +++ b/app/services/ci/play_build_service.rb @@ -1,7 +1,7 @@ module Ci class PlayBuildService < ::BaseService def execute(build) - unless can?(current_user, :play_build, build) + unless can?(current_user, :update_build, build) raise Gitlab::Access::AccessDeniedError end diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 2227d36eed22d..c001999617665 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -102,7 +102,7 @@ = link_to cancel_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do = icon('remove', class: 'cred') - elsif allow_retry - - if job.playable? && !admin && can?(current_user, :play_build, job) + - if job.playable? && !admin && can?(current_user, :update_build, job) = link_to play_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do = custom_icon('icon_play') - elsif job.retryable? diff --git a/lib/gitlab/ci/status/build/play.rb b/lib/gitlab/ci/status/build/play.rb index 8130c7d1c90ef..fae34a2f9276a 100644 --- a/lib/gitlab/ci/status/build/play.rb +++ b/lib/gitlab/ci/status/build/play.rb @@ -14,7 +14,7 @@ def label end def has_action? - can?(user, :play_build, subject) + can?(user, :update_build, subject) end def action_icon diff --git a/spec/policies/ci/build_policy_spec.rb b/spec/policies/ci/build_policy_spec.rb index e4693cdcef0e0..3f4ce222b60fb 100644 --- a/spec/policies/ci/build_policy_spec.rb +++ b/spec/policies/ci/build_policy_spec.rb @@ -108,8 +108,8 @@ create(:ci_build, :manual, ref: 'some-ref', pipeline: pipeline) end - it 'does not include ability to play build' do - expect(policies).not_to include :play_build + it 'does not include ability to update build' do + expect(policies).not_to include :update_build end end @@ -118,8 +118,8 @@ create(:ci_build, ref: 'some-ref', pipeline: pipeline) end - it 'does not include ability to play build' do - expect(policies).not_to include :play_build + it 'includes ability to update build' do + expect(policies).to include :update_build end end end @@ -128,16 +128,16 @@ context 'when build is a manual action' do let(:build) { create(:ci_build, :manual, pipeline: pipeline) } - it 'includes ability to play build' do - expect(policies).to include :play_build + it 'includes ability to update build' do + expect(policies).to include :update_build end end context 'when build is not a manual action' do - let(:build) { create(:ci_build, pipeline: pipeline) } + let(:build) { create(:ci_build, pipeline: pipeline) } - it 'does not include ability to play build' do - expect(policies).not_to include :play_build + it 'includes ability to update build' do + expect(policies).to include :update_build end end end -- GitLab From 645593e5af1fe9e7fa345788aeaa90d2313d6486 Mon Sep 17 00:00:00 2001 From: Kushal Pandya <kushalspandya@gmail.com> Date: Fri, 5 May 2017 10:57:29 +0000 Subject: [PATCH 274/363] Add instant comments support --- .../javascripts/behaviors/quick_submit.js | 2 +- .../javascripts/lib/utils/common_utils.js | 8 + app/assets/javascripts/notes.js | 345 +++++++++++++++--- .../stylesheets/framework/animations.scss | 28 ++ app/assets/stylesheets/pages/notes.scss | 23 ++ app/views/discussions/_notes.html.haml | 1 + app/views/projects/notes/_edit_form.html.haml | 2 +- .../unreleased/27614-instant-comments.yml | 4 + features/steps/project/merge_requests.rb | 7 + features/steps/shared/note.rb | 4 + .../merge_requests/user_posts_notes_spec.rb | 1 + .../user_uses_slash_commands_spec.rb | 1 + .../lib/utils/common_utils_spec.js | 11 + spec/javascripts/notes_spec.js | 229 +++++++++++- ...issuable_slash_commands_shared_examples.rb | 1 + spec/support/time_tracking_shared_examples.rb | 5 + 16 files changed, 607 insertions(+), 65 deletions(-) create mode 100644 changelogs/unreleased/27614-instant-comments.yml diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js index 3d162b244135c..1f9e044808451 100644 --- a/app/assets/javascripts/behaviors/quick_submit.js +++ b/app/assets/javascripts/behaviors/quick_submit.js @@ -43,8 +43,8 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => { const $submitButton = $form.find('input[type=submit], button[type=submit]'); if (!$submitButton.attr('disabled')) { + $submitButton.trigger('click', [e]); $submitButton.disable(); - $form.submit(); } }); diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 8058672eaa90e..2f682fbd2fbf8 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -35,6 +35,14 @@ }); }; + w.gl.utils.ajaxPost = function(url, data) { + return $.ajax({ + type: 'POST', + url: url, + data: data, + }); + }; + w.gl.utils.extractLast = function(term) { return this.split(term).pop(); }; diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 87f03a40eba3f..72709f68070d9 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -26,12 +26,13 @@ const normalizeNewlines = function(str) { this.Notes = (function() { const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; + const REGEX_SLASH_COMMANDS = /\/\w+/g; Notes.interval = null; function Notes(notes_url, note_ids, last_fetched_at, view) { this.updateTargetButtons = bind(this.updateTargetButtons, this); - this.updateCloseButton = bind(this.updateCloseButton, this); + this.updateComment = bind(this.updateComment, this); this.visibilityChange = bind(this.visibilityChange, this); this.cancelDiscussionForm = bind(this.cancelDiscussionForm, this); this.addDiffNote = bind(this.addDiffNote, this); @@ -47,6 +48,7 @@ const normalizeNewlines = function(str) { this.refresh = bind(this.refresh, this); this.keydownNoteText = bind(this.keydownNoteText, this); this.toggleCommitList = bind(this.toggleCommitList, this); + this.postComment = bind(this.postComment, this); this.notes_url = notes_url; this.note_ids = note_ids; @@ -82,28 +84,19 @@ const normalizeNewlines = function(str) { }; Notes.prototype.addBinding = function() { - // add note to UI after creation - $(document).on("ajax:success", ".js-main-target-form", this.addNote); - $(document).on("ajax:success", ".js-discussion-note-form", this.addDiscussionNote); - // catch note ajax errors - $(document).on("ajax:error", ".js-main-target-form", this.addNoteError); - // change note in UI after update - $(document).on("ajax:success", "form.edit-note", this.updateNote); // Edit note link $(document).on("click", ".js-note-edit", this.showEditForm.bind(this)); $(document).on("click", ".note-edit-cancel", this.cancelEdit); // Reopen and close actions for Issue/MR combined with note form submit - $(document).on("click", ".js-comment-button", this.updateCloseButton); + $(document).on("click", ".js-comment-submit-button", this.postComment); + $(document).on("click", ".js-comment-save-button", this.updateComment); $(document).on("keyup input", ".js-note-text", this.updateTargetButtons); // resolve a discussion - $(document).on('click', '.js-comment-resolve-button', this.resolveDiscussion); + $(document).on('click', '.js-comment-resolve-button', this.postComment); // remove a note (in general) $(document).on("click", ".js-note-delete", this.removeNote); // delete note attachment $(document).on("click", ".js-note-attachment-delete", this.removeAttachment); - // reset main target form after submit - $(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton); - $(document).on("ajax:success", ".js-main-target-form", this.resetMainTargetForm); // reset main target form when clicking discard $(document).on("click", ".js-note-discard", this.resetMainTargetForm); // update the file name when an attachment is selected @@ -120,20 +113,20 @@ const normalizeNewlines = function(str) { $(document).on("visibilitychange", this.visibilityChange); // when issue status changes, we need to refresh data $(document).on("issuable:change", this.refresh); + // ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs. + $(document).on("ajax:success", ".js-main-target-form", this.addNote); + $(document).on("ajax:success", ".js-discussion-note-form", this.addDiscussionNote); + $(document).on("ajax:success", ".js-main-target-form", this.resetMainTargetForm); + $(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton); // when a key is clicked on the notes return $(document).on("keydown", ".js-note-text", this.keydownNoteText); }; Notes.prototype.cleanBinding = function() { - $(document).off("ajax:success", ".js-main-target-form"); - $(document).off("ajax:success", ".js-discussion-note-form"); - $(document).off("ajax:success", "form.edit-note"); $(document).off("click", ".js-note-edit"); $(document).off("click", ".note-edit-cancel"); $(document).off("click", ".js-note-delete"); $(document).off("click", ".js-note-attachment-delete"); - $(document).off("ajax:complete", ".js-main-target-form"); - $(document).off("ajax:success", ".js-main-target-form"); $(document).off("click", ".js-discussion-reply-button"); $(document).off("click", ".js-add-diff-note-button"); $(document).off("visibilitychange"); @@ -144,6 +137,9 @@ const normalizeNewlines = function(str) { $(document).off("keydown", ".js-note-text"); $(document).off('click', '.js-comment-resolve-button'); $(document).off("click", '.system-note-commit-list-toggler'); + $(document).off("ajax:success", ".js-main-target-form"); + $(document).off("ajax:success", ".js-discussion-note-form"); + $(document).off("ajax:complete", ".js-main-target-form"); }; Notes.initCommentTypeToggle = function (form) { @@ -276,12 +272,8 @@ const normalizeNewlines = function(str) { return this.initRefresh(); }; - Notes.prototype.handleCreateChanges = function(noteEntity) { + Notes.prototype.handleSlashCommands = function(noteEntity) { var votesBlock; - if (typeof noteEntity === 'undefined') { - return; - } - if (noteEntity.commands_changes) { if ('merge' in noteEntity.commands_changes) { $.get(mrRefreshWidgetUrl); @@ -556,24 +548,29 @@ const normalizeNewlines = function(str) { Adds new note to list. */ - Notes.prototype.addNote = function(xhr, note, status) { - this.handleCreateChanges(note); + Notes.prototype.addNote = function($form, note) { return this.renderNote(note); }; - Notes.prototype.addNoteError = function(xhr, note, status) { - return new Flash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', this.parentTimeline); + Notes.prototype.addNoteError = ($form) => { + let formParentTimeline; + if ($form.hasClass('js-main-target-form')) { + formParentTimeline = $form.parents('.timeline'); + } else if ($form.hasClass('js-discussion-note-form')) { + formParentTimeline = $form.closest('.discussion-notes').find('.notes'); + } + return new Flash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', formParentTimeline); }; + Notes.prototype.updateNoteError = $parentTimeline => new Flash('Your comment could not be updated! Please check your network connection and try again.'); + /* Called in response to the new note form being submitted Adds new note to list. */ - Notes.prototype.addDiscussionNote = function(xhr, note, status) { - var $form = $(xhr.target); - + Notes.prototype.addDiscussionNote = function($form, note, isNewDiffComment) { if ($form.attr('data-resolve-all') != null) { var projectPath = $form.data('project-path'); var discussionId = $form.data('discussion-id'); @@ -586,7 +583,9 @@ const normalizeNewlines = function(str) { this.renderNote(note, $form); // cleanup after successfully creating a diff/discussion note - this.removeDiscussionNoteForm($form); + if (isNewDiffComment) { + this.removeDiscussionNoteForm($form); + } }; /* @@ -596,17 +595,18 @@ const normalizeNewlines = function(str) { */ Notes.prototype.updateNote = function(_xhr, noteEntity, _status) { - var $html, $note_li; + var $noteEntityEl, $note_li; // Convert returned HTML to a jQuery object so we can modify it further - $html = $(noteEntity.html); + $noteEntityEl = $(noteEntity.html); + $noteEntityEl.addClass('fade-in-full'); this.revertNoteEditForm(); - gl.utils.localTimeAgo($('.js-timeago', $html)); - $html.renderGFM(); - $html.find('.js-task-list-container').taskList('enable'); + gl.utils.localTimeAgo($('.js-timeago', $noteEntityEl)); + $noteEntityEl.renderGFM(); + $noteEntityEl.find('.js-task-list-container').taskList('enable'); // Find the note's `li` element by ID and replace it with the updated HTML $note_li = $('.note-row-' + noteEntity.id); - $note_li.replaceWith($html); + $note_li.replaceWith($noteEntityEl); if (typeof gl.diffNotesCompileComponents !== 'undefined') { gl.diffNotesCompileComponents(); @@ -698,7 +698,7 @@ const normalizeNewlines = function(str) { var $editForm = $(selector); $editForm.insertBefore('.notes-form'); - $editForm.find('.js-comment-button').enable(); + $editForm.find('.js-comment-save-button').enable(); $editForm.find('.js-finish-edit-warning').hide(); }; @@ -982,14 +982,6 @@ const normalizeNewlines = function(str) { return this.refresh(); }; - Notes.prototype.updateCloseButton = function(e) { - var closebtn, form, textarea; - textarea = $(e.target); - form = textarea.parents('form'); - closebtn = form.find('.js-note-target-close'); - return closebtn.text(closebtn.data('original-text')); - }; - Notes.prototype.updateTargetButtons = function(e) { var closebtn, closetext, discardbtn, form, reopenbtn, reopentext, textarea; textarea = $(e.target); @@ -1078,17 +1070,6 @@ const normalizeNewlines = function(str) { return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount); }; - Notes.prototype.resolveDiscussion = function() { - var $this = $(this); - var discussionId = $this.attr('data-discussion-id'); - - $this - .closest('form') - .attr('data-discussion-id', discussionId) - .attr('data-resolve-all', 'true') - .attr('data-project-path', $this.attr('data-project-path')); - }; - Notes.prototype.toggleCommitList = function(e) { const $element = $(e.currentTarget); const $closestSystemCommitList = $element.siblings('.system-note-commit-list'); @@ -1137,7 +1118,7 @@ const normalizeNewlines = function(str) { Notes.animateAppendNote = function(noteHtml, $notesList) { const $note = $(noteHtml); - $note.addClass('fade-in').renderGFM(); + $note.addClass('fade-in-full').renderGFM(); $notesList.append($note); return $note; }; @@ -1150,6 +1131,254 @@ const normalizeNewlines = function(str) { return $updatedNote; }; + /** + * Get data from Form attributes to use for saving/submitting comment. + */ + Notes.prototype.getFormData = function($form) { + return { + formData: $form.serialize(), + formContent: $form.find('.js-note-text').val(), + formAction: $form.attr('action'), + }; + }; + + /** + * Identify if comment has any slash commands + */ + Notes.prototype.hasSlashCommands = function(formContent) { + return REGEX_SLASH_COMMANDS.test(formContent); + }; + + /** + * Remove slash commands and leave comment with pure message + */ + Notes.prototype.stripSlashCommands = function(formContent) { + return formContent.replace(REGEX_SLASH_COMMANDS, '').trim(); + }; + + /** + * Create placeholder note DOM element populated with comment body + * that we will show while comment is being posted. + * Once comment is _actually_ posted on server, we will have final element + * in response that we will show in place of this temporary element. + */ + Notes.prototype.createPlaceholderNote = function({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname }) { + const discussionClass = isDiscussionNote ? 'discussion' : ''; + const $tempNote = $( + `<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry"> + <div class="timeline-entry-inner"> + <div class="timeline-icon"> + <a href="/${currentUsername}"><span class="dummy-avatar"></span></a> + </div> + <div class="timeline-content ${discussionClass}"> + <div class="note-header"> + <div class="note-header-info"> + <a href="/${currentUsername}"> + <span class="hidden-xs">${currentUserFullname}</span> + <span class="note-headline-light">@${currentUsername}</span> + </a> + <span class="note-headline-light"> + <i class="fa fa-spinner fa-spin" aria-label="Comment is being posted" aria-hidden="true"></i> + </span> + </div> + </div> + <div class="note-body"> + <div class="note-text"> + <p>${formContent}</p> + </div> + </div> + </div> + </div> + </li>` + ); + + return $tempNote; + }; + + /** + * This method does following tasks step-by-step whenever a new comment + * is submitted by user (both main thread comments as well as discussion comments). + * + * 1) Get Form metadata + * 2) Identify comment type; a) Main thread b) Discussion thread c) Discussion resolve + * 3) Build temporary placeholder element (using `createPlaceholderNote`) + * 4) Show placeholder note on UI + * 5) Perform network request to submit the note using `gl.utils.ajaxPost` + * a) If request is successfully completed + * 1. Remove placeholder element + * 2. Show submitted Note element + * 3. Perform post-submit errands + * a. Mark discussion as resolved if comment submission was for resolve. + * b. Reset comment form to original state. + * b) If request failed + * 1. Remove placeholder element + * 2. Show error Flash message about failure + */ + Notes.prototype.postComment = function(e) { + e.preventDefault(); + + // Get Form metadata + const $submitBtn = $(e.target); + let $form = $submitBtn.parents('form'); + const $closeBtn = $form.find('.js-note-target-close'); + const isDiscussionNote = $submitBtn.parent().find('li.droplab-item-selected').attr('id') === 'discussion'; + const isMainForm = $form.hasClass('js-main-target-form'); + const isDiscussionForm = $form.hasClass('js-discussion-note-form'); + const isDiscussionResolve = $submitBtn.hasClass('js-comment-resolve-button'); + const { formData, formContent, formAction } = this.getFormData($form); + const uniqueId = _.uniqueId('tempNote_'); + let $notesContainer; + let tempFormContent; + + // Get reference to notes container based on type of comment + if (isDiscussionForm) { + $notesContainer = $form.parent('.discussion-notes').find('.notes'); + } else if (isMainForm) { + $notesContainer = $('ul.main-notes-list'); + } + + // If comment is to resolve discussion, disable submit buttons while + // comment posting is finished. + if (isDiscussionResolve) { + $submitBtn.disable(); + $form.find('.js-comment-submit-button').disable(); + } + + tempFormContent = formContent; + if (this.hasSlashCommands(formContent)) { + tempFormContent = this.stripSlashCommands(formContent); + } + + if (tempFormContent) { + // Show placeholder note + $notesContainer.append(this.createPlaceholderNote({ + formContent: tempFormContent, + uniqueId, + isDiscussionNote, + currentUsername: gon.current_username, + currentUserFullname: gon.current_user_fullname, + })); + } + + // Clear the form textarea + if ($notesContainer.length) { + if (isMainForm) { + this.resetMainTargetForm(e); + } else if (isDiscussionForm) { + this.removeDiscussionNoteForm($form); + } + } + + /* eslint-disable promise/catch-or-return */ + // Make request to submit comment on server + gl.utils.ajaxPost(formAction, formData) + .then((note) => { + // Submission successful! remove placeholder + $notesContainer.find(`#${uniqueId}`).remove(); + + // Check if this was discussion comment + if (isDiscussionForm) { + // Remove flash-container + $notesContainer.find('.flash-container').remove(); + + // If comment intends to resolve discussion, do the same. + if (isDiscussionResolve) { + $form + .attr('data-discussion-id', $submitBtn.data('discussion-id')) + .attr('data-resolve-all', 'true') + .attr('data-project-path', $submitBtn.data('project-path')); + } + + // Show final note element on UI + this.addDiscussionNote($form, note, $notesContainer.length === 0); + + // append flash-container to the Notes list + if ($notesContainer.length) { + $notesContainer.append('<div class="flash-container" style="display: none;"></div>'); + } + } else if (isMainForm) { // Check if this was main thread comment + // Show final note element on UI and perform form and action buttons cleanup + this.addNote($form, note); + this.reenableTargetFormSubmitButton(e); + } + + if (note.commands_changes) { + this.handleSlashCommands(note); + } + + $form.trigger('ajax:success', [note]); + }).fail(() => { + // Submission failed, remove placeholder note and show Flash error message + $notesContainer.find(`#${uniqueId}`).remove(); + + // Show form again on UI on failure + if (isDiscussionForm && $notesContainer.length) { + const replyButton = $notesContainer.parent().find('.js-discussion-reply-button'); + $.proxy(this.replyToDiscussionNote, replyButton[0], { target: replyButton[0] }).call(); + $form = $notesContainer.parent().find('form'); + } + + $form.find('.js-note-text').val(formContent); + this.reenableTargetFormSubmitButton(e); + this.addNoteError($form); + }); + + return $closeBtn.text($closeBtn.data('original-text')); + }; + + /** + * This method does following tasks step-by-step whenever an existing comment + * is updated by user (both main thread comments as well as discussion comments). + * + * 1) Get Form metadata + * 2) Update note element with new content + * 3) Perform network request to submit the updated note using `gl.utils.ajaxPost` + * a) If request is successfully completed + * 1. Show submitted Note element + * b) If request failed + * 1. Revert Note element to original content + * 2. Show error Flash message about failure + */ + Notes.prototype.updateComment = function(e) { + e.preventDefault(); + + // Get Form metadata + const $submitBtn = $(e.target); + const $form = $submitBtn.parents('form'); + const $closeBtn = $form.find('.js-note-target-close'); + const $editingNote = $form.parents('.note.is-editing'); + const $noteBody = $editingNote.find('.js-task-list-container'); + const $noteBodyText = $noteBody.find('.note-text'); + const { formData, formContent, formAction } = this.getFormData($form); + + // Cache original comment content + const cachedNoteBodyText = $noteBodyText.html(); + + // Show updated comment content temporarily + $noteBodyText.html(formContent); + $editingNote.removeClass('is-editing').addClass('being-posted fade-in-half'); + $editingNote.find('.note-headline-meta a').html('<i class="fa fa-spinner fa-spin" aria-label="Comment is being updated" aria-hidden="true"></i>'); + + /* eslint-disable promise/catch-or-return */ + // Make request to update comment on server + gl.utils.ajaxPost(formAction, formData) + .then((note) => { + // Submission successful! render final note element + this.updateNote(null, note, null); + }) + .fail(() => { + // Submission failed, revert back to original note + $noteBodyText.html(cachedNoteBodyText); + $editingNote.removeClass('being-posted fade-in'); + $editingNote.find('.fa.fa-spinner').remove(); + + // Show Flash message about failure + this.updateNoteError(); + }); + + return $closeBtn.text($closeBtn.data('original-text')); + }; + return Notes; })(); }).call(window); diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 7c50b80fd2bb5..3cd7f81da4719 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -159,3 +159,31 @@ a { .fade-in { animation: fadeIn $fade-in-duration 1; } + +@keyframes fadeInHalf { + 0% { + opacity: 0; + } + + 100% { + opacity: 0.5; + } +} + +.fade-in-half { + animation: fadeInHalf $fade-in-duration 1; +} + +@keyframes fadeInFull { + 0% { + opacity: 0.5; + } + + 100% { + opacity: 1; + } +} + +.fade-in-full { + animation: fadeInFull $fade-in-duration 1; +} diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index f89150ebead7e..cfea52c6e5742 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -57,6 +57,25 @@ ul.notes { position: relative; border-bottom: 1px solid $white-normal; + &.being-posted { + pointer-events: none; + opacity: 0.5; + + .dummy-avatar { + display: inline-block; + height: 40px; + width: 40px; + border-radius: 50%; + background-color: $kdb-border; + border: 1px solid darken($kdb-border, 25%); + } + + .note-headline-light, + .fa-spinner { + margin-left: 3px; + } + } + &.note-discussion { &.timeline-entry { padding: 14px 10px; @@ -687,6 +706,10 @@ ul.notes { } } +.discussion-notes .flash-container { + margin-bottom: 0; +} + // Merge request notes in diffs .diff-file { // Diff is side by side diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml index 964473ee3e08c..7ba3f3f6c42a5 100644 --- a/app/views/discussions/_notes.html.haml +++ b/app/views/discussions/_notes.html.haml @@ -1,6 +1,7 @@ .discussion-notes %ul.notes{ data: { discussion_id: discussion.id } } = render partial: "shared/notes/note", collection: discussion.notes, as: :note + .flash-container - if current_user .discussion-reply-holder diff --git a/app/views/projects/notes/_edit_form.html.haml b/app/views/projects/notes/_edit_form.html.haml index 00230b0bdf822..3867072225f88 100644 --- a/app/views/projects/notes/_edit_form.html.haml +++ b/app/views/projects/notes/_edit_form.html.haml @@ -9,6 +9,6 @@ .note-form-actions.clearfix .settings-message.note-edit-warning.js-finish-edit-warning Finish editing this message first! - = submit_tag 'Save comment', class: 'btn btn-nr btn-save js-comment-button' + = submit_tag 'Save comment', class: 'btn btn-nr btn-save js-comment-save-button' %button.btn.btn-nr.btn-cancel.note-edit-cancel{ type: 'button' } Cancel diff --git a/changelogs/unreleased/27614-instant-comments.yml b/changelogs/unreleased/27614-instant-comments.yml new file mode 100644 index 0000000000000..7b2592f46ede2 --- /dev/null +++ b/changelogs/unreleased/27614-instant-comments.yml @@ -0,0 +1,4 @@ +--- +title: Add support for instantly updating comments +merge_request: 10760 +author: diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index a06b2f2911f11..4b7d6cd840b14 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -458,6 +458,8 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps click_button "Comment" end + wait_for_ajax + page.within ".files>div:nth-child(2) .note-body > .note-text" do expect(page).to have_content "Line is correct" end @@ -470,6 +472,8 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps fill_in "note_note", with: "Line is wrong on here" click_button "Comment" end + + wait_for_ajax end step 'I should still see a comment like "Line is correct" in the second file' do @@ -574,6 +578,9 @@ def leave_comment(message) fill_in "note_note", with: message click_button "Comment" end + + wait_for_ajax + page.within(".notes_holder", visible: true) do expect(page).to have_content message end diff --git a/features/steps/shared/note.rb b/features/steps/shared/note.rb index 7885cc7ab7729..7d260025052cf 100644 --- a/features/steps/shared/note.rb +++ b/features/steps/shared/note.rb @@ -24,6 +24,8 @@ module SharedNote fill_in "note[note]", with: "XML attached" click_button "Comment" end + + wait_for_ajax end step 'I preview a comment text like "Bug fixed :smile:"' do @@ -37,6 +39,8 @@ module SharedNote page.within(".js-main-target-form") do click_button "Comment" end + + wait_for_ajax end step 'I write a comment like ":+1: Nice"' do diff --git a/spec/features/merge_requests/user_posts_notes_spec.rb b/spec/features/merge_requests/user_posts_notes_spec.rb index c7cc4d6bc724e..7fc0e2ce6eca4 100644 --- a/spec/features/merge_requests/user_posts_notes_spec.rb +++ b/spec/features/merge_requests/user_posts_notes_spec.rb @@ -98,6 +98,7 @@ find('.btn-save').click end + wait_for_ajax find('.note').hover find('.js-note-edit').click diff --git a/spec/features/merge_requests/user_uses_slash_commands_spec.rb b/spec/features/merge_requests/user_uses_slash_commands_spec.rb index 1c0f21e5616b5..f0ad57eb92fd8 100644 --- a/spec/features/merge_requests/user_uses_slash_commands_spec.rb +++ b/spec/features/merge_requests/user_uses_slash_commands_spec.rb @@ -160,6 +160,7 @@ it 'changes target branch from a note' do write_note("message start \n/target_branch merge-test\n message end.") + wait_for_ajax expect(page).not_to have_content('/target_branch') expect(page).to have_content('message start') expect(page).to have_content('message end.') diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js index a00efa10119ce..5eb147ed88806 100644 --- a/spec/javascripts/lib/utils/common_utils_spec.js +++ b/spec/javascripts/lib/utils/common_utils_spec.js @@ -362,5 +362,16 @@ require('~/lib/utils/common_utils'); gl.utils.setCiStatusFavicon(BUILD_URL); }); }); + + describe('gl.utils.ajaxPost', () => { + it('should perform `$.ajax` call and do `POST` request', () => { + const requestURL = '/some/random/api'; + const data = { keyname: 'value' }; + const ajaxSpy = spyOn($, 'ajax').and.callFake(() => {}); + + gl.utils.ajaxPost(requestURL, data); + expect(ajaxSpy.calls.allArgs()[0][0].type).toEqual('POST'); + }); + }); }); })(); diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index cdc5c4510ffcd..7bffa90ab145e 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -26,7 +26,7 @@ import '~/notes'; describe('task lists', function() { beforeEach(function() { - $('form').on('submit', function(e) { + $('.js-comment-button').on('click', function(e) { e.preventDefault(); }); this.notes = new Notes(); @@ -60,9 +60,12 @@ import '~/notes'; reset: function() {} }); - $('form').on('submit', function(e) { + $('.js-comment-button').on('click', (e) => { + const $form = $(this); e.preventDefault(); - $('.js-main-target-form').trigger('ajax:success'); + this.notes.addNote($form); + this.notes.reenableTargetFormSubmitButton(e); + this.notes.resetMainTargetForm(e); }); }); @@ -238,8 +241,8 @@ import '~/notes'; $resultantNote = Notes.animateAppendNote(noteHTML, $notesList); }); - it('should have `fade-in` class', () => { - expect($resultantNote.hasClass('fade-in')).toEqual(true); + it('should have `fade-in-full` class', () => { + expect($resultantNote.hasClass('fade-in-full')).toEqual(true); }); it('should append note to the notes list', () => { @@ -269,5 +272,221 @@ import '~/notes'; expect($note.replaceWith).toHaveBeenCalledWith($updatedNote); }); }); + + describe('getFormData', () => { + it('should return form metadata object from form reference', () => { + this.notes = new Notes(); + + const $form = $('form'); + const sampleComment = 'foobar'; + $form.find('textarea.js-note-text').val(sampleComment); + const { formData, formContent, formAction } = this.notes.getFormData($form); + + expect(formData.indexOf(sampleComment) > -1).toBe(true); + expect(formContent).toEqual(sampleComment); + expect(formAction).toEqual($form.attr('action')); + }); + }); + + describe('hasSlashCommands', () => { + beforeEach(() => { + this.notes = new Notes(); + }); + + it('should return true when comment has slash commands', () => { + const sampleComment = '/wip /milestone %1.0 /merge /unassign Merging this'; + const hasSlashCommands = this.notes.hasSlashCommands(sampleComment); + + expect(hasSlashCommands).toBeTruthy(); + }); + + it('should return false when comment does NOT have any slash commands', () => { + const sampleComment = 'Looking good, Awesome!'; + const hasSlashCommands = this.notes.hasSlashCommands(sampleComment); + + expect(hasSlashCommands).toBeFalsy(); + }); + }); + + describe('stripSlashCommands', () => { + const REGEX_SLASH_COMMANDS = /\/\w+/g; + + it('should strip slash commands from the comment', () => { + this.notes = new Notes(); + const sampleComment = '/wip /milestone %1.0 /merge /unassign Merging this'; + const stripedComment = this.notes.stripSlashCommands(sampleComment); + + expect(REGEX_SLASH_COMMANDS.test(stripedComment)).toBeFalsy(); + }); + }); + + describe('createPlaceholderNote', () => { + const sampleComment = 'foobar'; + const uniqueId = 'b1234-a4567'; + const currentUsername = 'root'; + const currentUserFullname = 'Administrator'; + + beforeEach(() => { + this.notes = new Notes(); + }); + + it('should return constructed placeholder element for regular note based on form contents', () => { + const $tempNote = this.notes.createPlaceholderNote({ + formContent: sampleComment, + uniqueId, + isDiscussionNote: false, + currentUsername, + currentUserFullname + }); + const $tempNoteHeader = $tempNote.find('.note-header'); + + expect($tempNote.prop('nodeName')).toEqual('LI'); + expect($tempNote.attr('id')).toEqual(uniqueId); + $tempNote.find('.timeline-icon > a, .note-header-info > a').each(function() { + expect($(this).attr('href')).toEqual(`/${currentUsername}`); + }); + expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeFalsy(); + expect($tempNoteHeader.find('.hidden-xs').text().trim()).toEqual(currentUserFullname); + expect($tempNoteHeader.find('.note-headline-light').text().trim()).toEqual(`@${currentUsername}`); + expect($tempNote.find('.note-body .note-text').text().trim()).toEqual(sampleComment); + }); + + it('should return constructed placeholder element for discussion note based on form contents', () => { + const $tempNote = this.notes.createPlaceholderNote({ + formContent: sampleComment, + uniqueId, + isDiscussionNote: true, + currentUsername, + currentUserFullname + }); + + expect($tempNote.prop('nodeName')).toEqual('LI'); + expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeTruthy(); + }); + }); + + describe('postComment & updateComment', () => { + const sampleComment = 'foo'; + const updatedComment = 'bar'; + const note = { + id: 1234, + html: `<li class="note note-row-1234 timeline-entry" id="note_1234"> + <div class="note-text">${sampleComment}</div> + </li>`, + note: sampleComment, + valid: true + }; + let $form; + let $notesContainer; + + beforeEach(() => { + this.notes = new Notes(); + window.gon.current_username = 'root'; + window.gon.current_user_fullname = 'Administrator'; + $form = $('form'); + $notesContainer = $('ul.main-notes-list'); + $form.find('textarea.js-note-text').val(sampleComment); + $('.js-comment-button').click(); + }); + + it('should show placeholder note while new comment is being posted', () => { + expect($notesContainer.find('.note.being-posted').length > 0).toEqual(true); + }); + + it('should remove placeholder note when new comment is done posting', () => { + spyOn($, 'ajax').and.callFake((options) => { + options.success(note); + expect($notesContainer.find('.note.being-posted').length).toEqual(0); + }); + }); + + it('should show actual note element when new comment is done posting', () => { + spyOn($, 'ajax').and.callFake((options) => { + options.success(note); + expect($notesContainer.find(`#${note.id}`).length > 0).toEqual(true); + }); + }); + + it('should reset Form when new comment is done posting', () => { + spyOn($, 'ajax').and.callFake((options) => { + options.success(note); + expect($form.find('textarea.js-note-text')).toEqual(''); + }); + }); + + it('should trigger ajax:success event on Form when new comment is done posting', () => { + spyOn($, 'ajax').and.callFake((options) => { + options.success(note); + spyOn($form, 'trigger'); + expect($form.trigger).toHaveBeenCalledWith('ajax:success', [note]); + }); + }); + + it('should show flash error message when new comment failed to be posted', () => { + spyOn($, 'ajax').and.callFake((options) => { + options.error(); + expect($notesContainer.parent().find('.flash-container .flash-text').is(':visible')).toEqual(true); + }); + }); + + it('should refill form textarea with original comment content when new comment failed to be posted', () => { + spyOn($, 'ajax').and.callFake((options) => { + options.error(); + expect($form.find('textarea.js-note-text')).toEqual(sampleComment); + }); + }); + + it('should show updated comment as _actively being posted_ while comment being updated', () => { + spyOn($, 'ajax').and.callFake((options) => { + options.success(note); + const $noteEl = $notesContainer.find(`#note_${note.id}`); + $noteEl.find('.js-note-edit').click(); + $noteEl.find('textarea.js-note-text').val(updatedComment); + $noteEl.find('.js-comment-save-button').click(); + expect($noteEl.hasClass('.being-posted')).toEqual(true); + expect($noteEl.find('.note-text').text()).toEqual(updatedComment); + }); + }); + + it('should show updated comment when comment update is done posting', () => { + spyOn($, 'ajax').and.callFake((options) => { + options.success(note); + const $noteEl = $notesContainer.find(`#note_${note.id}`); + $noteEl.find('.js-note-edit').click(); + $noteEl.find('textarea.js-note-text').val(updatedComment); + $noteEl.find('.js-comment-save-button').click(); + + spyOn($, 'ajax').and.callFake((updateOptions) => { + const updatedNote = Object.assign({}, note); + updatedNote.note = updatedComment; + updatedNote.html = `<li class="note note-row-1234 timeline-entry" id="note_1234"> + <div class="note-text">${updatedComment}</div> + </li>`; + updateOptions.success(updatedNote); + const $updatedNoteEl = $notesContainer.find(`#note_${updatedNote.id}`); + expect($updatedNoteEl.hasClass('.being-posted')).toEqual(false); // Remove being-posted visuals + expect($updatedNoteEl.find('note-text').text().trim()).toEqual(updatedComment); // Verify if comment text updated + }); + }); + }); + + it('should show flash error message when comment failed to be updated', () => { + spyOn($, 'ajax').and.callFake((options) => { + options.success(note); + const $noteEl = $notesContainer.find(`#note_${note.id}`); + $noteEl.find('.js-note-edit').click(); + $noteEl.find('textarea.js-note-text').val(updatedComment); + $noteEl.find('.js-comment-save-button').click(); + + spyOn($, 'ajax').and.callFake((updateOptions) => { + updateOptions.error(); + const $updatedNoteEl = $notesContainer.find(`#note_${note.id}`); + expect($updatedNoteEl.hasClass('.being-posted')).toEqual(false); // Remove being-posted visuals + expect($updatedNoteEl.find('note-text').text().trim()).toEqual(sampleComment); // See if comment reverted back to original + expect($notesContainer.parent().find('.flash-container .flash-text').is(':visible')).toEqual(true); // Flash error message shown + }); + }); + }); + }); }); }).call(window); diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb index 6efcdd7a1ded9..610decdcddbaf 100644 --- a/spec/support/features/issuable_slash_commands_shared_examples.rb +++ b/spec/support/features/issuable_slash_commands_shared_examples.rb @@ -58,6 +58,7 @@ expect(page).not_to have_content '/label ~bug' expect(page).not_to have_content '/milestone %"ASAP"' + wait_for_ajax issuable.reload note = issuable.notes.user.first diff --git a/spec/support/time_tracking_shared_examples.rb b/spec/support/time_tracking_shared_examples.rb index 01bc80f957e24..e94e17da7e588 100644 --- a/spec/support/time_tracking_shared_examples.rb +++ b/spec/support/time_tracking_shared_examples.rb @@ -8,6 +8,7 @@ it 'updates the sidebar component when estimate is added' do submit_time('/estimate 3w 1d 1h') + wait_for_ajax page.within '.time-tracking-estimate-only-pane' do expect(page).to have_content '3w 1d 1h' end @@ -16,6 +17,7 @@ it 'updates the sidebar component when spent is added' do submit_time('/spend 3w 1d 1h') + wait_for_ajax page.within '.time-tracking-spend-only-pane' do expect(page).to have_content '3w 1d 1h' end @@ -25,6 +27,7 @@ submit_time('/estimate 3w 1d 1h') submit_time('/spend 3w 1d 1h') + wait_for_ajax page.within '.time-tracking-comparison-pane' do expect(page).to have_content '3w 1d 1h' end @@ -34,6 +37,7 @@ submit_time('/estimate 3w 1d 1h') submit_time('/remove_estimate') + wait_for_ajax page.within '#issuable-time-tracker' do expect(page).to have_content 'No estimate or time spent' end @@ -43,6 +47,7 @@ submit_time('/spend 3w 1d 1h') submit_time('/remove_time_spent') + wait_for_ajax page.within '#issuable-time-tracker' do expect(page).to have_content 'No estimate or time spent' end -- GitLab From 8c4c40d09b6947f4ac652dd76cc422fea2a6443d Mon Sep 17 00:00:00 2001 From: blackst0ne <blackst0ne.ru@gmail.com> Date: Fri, 5 May 2017 23:01:50 +1100 Subject: [PATCH 275/363] Updated specs --- spec/features/issuables/system_notes_spec.rb | 30 ------------------- spec/features/issues_spec.rb | 22 -------------- spec/features/merge_requests/edit_mr_spec.rb | 18 ----------- spec/services/issues/update_service_spec.rb | 11 +++++++ .../merge_requests/update_service_spec.rb | 7 +++++ spec/services/system_note_service_spec.rb | 2 +- 6 files changed, 19 insertions(+), 71 deletions(-) delete mode 100644 spec/features/issuables/system_notes_spec.rb diff --git a/spec/features/issuables/system_notes_spec.rb b/spec/features/issuables/system_notes_spec.rb deleted file mode 100644 index e12d81e76cbaf..0000000000000 --- a/spec/features/issuables/system_notes_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -require 'spec_helper' - -describe 'issuable system notes', feature: true do - let(:issue) { create(:issue, project: project, author: user) } - let(:merge_request) { create(:merge_request, :simple, source_project: project) } - let(:project) { create(:project, :public) } - let(:user) { create(:user) } - - before do - project.add_user(user, :master) - login_as(user) - end - - [:issue, :merge_request].each do |issuable_type| - context "when #{issuable_type}" do - before do - issuable = issuable_type == :issue ? issue : merge_request - - visit(edit_polymorphic_path([project.namespace.becomes(Namespace), project, issuable])) - end - - it 'adds system note "description changed"' do - fill_in("#{issuable_type}_description", with: 'hello world') - click_button('Save changes') - - expect(page).to have_content("#{user.name} #{user.to_reference} changed the description") - end - end - end -end diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index 93b7880b96f97..81cc8513454ef 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -714,26 +714,4 @@ expect(page).to have_text("updated title") end end - - describe '"edited by" message', js: true do - let(:issue) { create(:issue, project: project, author: @user) } - - context 'when issue is updated' do - before { visit edit_namespace_project_issue_path(project.namespace, project, issue) } - - it 'shows "edited by" mesage on title update' do - fill_in 'issue_title', with: 'hello world' - click_button 'Save changes' - - expect(page).to have_content("Edited less than a minute ago by #{@user.name}") - end - - it 'shows "edited by" mesage on description update' do - fill_in 'issue_description', with: 'hello world' - click_button 'Save changes' - - expect(page).to have_content("Edited less than a minute ago by #{@user.name}") - end - end - end end diff --git a/spec/features/merge_requests/edit_mr_spec.rb b/spec/features/merge_requests/edit_mr_spec.rb index b0855af95247f..cb3bc3929031a 100644 --- a/spec/features/merge_requests/edit_mr_spec.rb +++ b/spec/features/merge_requests/edit_mr_spec.rb @@ -67,23 +67,5 @@ def get_textarea_height page.evaluate_script('document.getElementById("merge_request_description").offsetHeight') end - - describe '"edited by" message', js: true do - context 'when merge request is updated' do - it 'shows "edited by" mesage on title update' do - fill_in 'merge_request_title', with: 'hello world' - click_button 'Save changes' - - expect(page).to have_content("Edited less than a minute ago by #{user.name}") - end - - it 'shows "edited by" mesage on description update' do - fill_in 'merge_request_description', with: 'hello world' - click_button 'Save changes' - - expect(page).to have_content("Edited less than a minute ago by #{user.name}") - end - end - end end end diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index 5b324f3c706d6..ee01b3871f3ac 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -132,6 +132,17 @@ def update_issue(opts) end end + context 'when description changed' do + it 'creates system note about description change' do + update_issue(description: 'Changed description') + + note = find_note('changed the description') + + expect(note).not_to be_nil + expect(note.note).to eq('changed the description') + end + end + context 'when issue turns confidential' do let(:opts) do { diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index f2ca1e6fcbd80..98fc41f2ac07d 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -102,6 +102,13 @@ def update_merge_request(opts) expect(note.note).to eq 'changed title from **{-Old-} title** to **{+New+} title**' end + it 'creates system note about description change' do + note = find_note('changed the description') + + expect(note).not_to be_nil + expect(note.note).to eq('changed the description') + end + it 'creates system note about branch change' do note = find_note('changed target') diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 5e85c3c162186..5775874ebe0d9 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -301,7 +301,7 @@ end it 'sets the note text' do - expect(subject.note).to eq 'changed the description' + expect(subject.note).to eq('changed the description') end end end -- GitLab From 61dd92aaff822759941bb224de9f45bfc5f7cc9b Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Fri, 5 May 2017 13:24:07 +0200 Subject: [PATCH 276/363] Authorize build update on per object basis --- .../projects/application_controller.rb | 8 ++++--- app/controllers/projects/builds_controller.rb | 23 +++++++++++++++---- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index 89f1128ec36ad..afed0ac05a088 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -55,13 +55,15 @@ def can_collaborate_with_project?(project = nil) (current_user && current_user.already_forked?(project)) end - def authorize_project!(action) - return access_denied! unless can?(current_user, action, project) + def authorize_action!(action) + unless can?(current_user, action, project) + return access_denied! + end end def method_missing(method_sym, *arguments, &block) if method_sym.to_s =~ /\Aauthorize_(.*)!\z/ - authorize_project!($1.to_sym) + authorize_action!($1.to_sym) else super end diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb index e24fc45d16660..d97bc93f8dcd9 100644 --- a/app/controllers/projects/builds_controller.rb +++ b/app/controllers/projects/builds_controller.rb @@ -1,7 +1,11 @@ class Projects::BuildsController < Projects::ApplicationController before_action :build, except: [:index, :cancel_all] - before_action :authorize_read_build!, only: [:index, :show, :status, :raw, :trace] - before_action :authorize_update_build!, except: [:index, :show, :status, :raw, :trace] + + before_action :authorize_read_build!, + only: [:index, :show, :status, :raw, :trace] + before_action :authorize_update_build!, + except: [:index, :show, :status, :raw, :trace, :cancel_all] + layout 'project' def index @@ -28,7 +32,12 @@ def index end def cancel_all - @project.builds.running_or_pending.each(&:cancel) + return access_denied! unless can?(current_user, :update_build, project) + + @project.builds.running_or_pending.each do |build| + build.cancel if can?(current_user, :update_build, build) + end + redirect_to namespace_project_builds_path(project.namespace, project) end @@ -107,8 +116,14 @@ def raw private + def authorize_update_build! + return access_denied! unless can?(current_user, :update_build, build) + end + def build - @build ||= project.builds.find_by!(id: params[:id]).present(current_user: current_user) + @build ||= project.builds + .find_by!(id: params[:id]) + .present(current_user: current_user) end def build_path(build) -- GitLab From 3264e09c6fbe07831db74b83d6a6620d9f8f47d9 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Fri, 5 May 2017 13:25:48 +0200 Subject: [PATCH 277/363] Require build to be present in the controller --- app/controllers/projects/builds_controller.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb index d97bc93f8dcd9..0fd35bcb790c9 100644 --- a/app/controllers/projects/builds_controller.rb +++ b/app/controllers/projects/builds_controller.rb @@ -121,8 +121,7 @@ def authorize_update_build! end def build - @build ||= project.builds - .find_by!(id: params[:id]) + @build ||= project.builds.find(params[:id]) .present(current_user: current_user) end -- GitLab From 53219857dd9f97516c6f24f6efb4f405998d9ff2 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Fri, 5 May 2017 13:56:07 +0200 Subject: [PATCH 278/363] Check ability to update build on the API resource --- lib/api/jobs.rb | 9 +++++++-- lib/api/v3/builds.rb | 10 +++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/lib/api/jobs.rb b/lib/api/jobs.rb index 288b03d940ce5..0223957fde11d 100644 --- a/lib/api/jobs.rb +++ b/lib/api/jobs.rb @@ -132,6 +132,7 @@ class Jobs < Grape::API authorize_update_builds! build = get_build!(params[:job_id]) + authorize!(:update_build, build) build.cancel @@ -148,6 +149,7 @@ class Jobs < Grape::API authorize_update_builds! build = get_build!(params[:job_id]) + authorize!(:update_build, build) return forbidden!('Job is not retryable') unless build.retryable? build = Ci::Build.retry(build, current_user) @@ -165,6 +167,7 @@ class Jobs < Grape::API authorize_update_builds! build = get_build!(params[:job_id]) + authorize!(:update_build, build) return forbidden!('Job is not erasable!') unless build.erasable? build.erase(erased_by: current_user) @@ -181,6 +184,7 @@ class Jobs < Grape::API authorize_update_builds! build = get_build!(params[:job_id]) + authorize!(:update_build, build) return not_found!(build) unless build.artifacts? build.keep_artifacts! @@ -201,6 +205,7 @@ class Jobs < Grape::API build = get_build!(params[:job_id]) + authorize!(:update_build, build) bad_request!("Unplayable Job") unless build.playable? build.play(current_user) @@ -211,12 +216,12 @@ class Jobs < Grape::API end helpers do - def get_build(id) + def find_build(id) user_project.builds.find_by(id: id.to_i) end def get_build!(id) - get_build(id) || not_found! + find_build(id) || not_found! end def present_artifacts!(artifacts_file) diff --git a/lib/api/v3/builds.rb b/lib/api/v3/builds.rb index 4dd03cdf24bc9..219359224148e 100644 --- a/lib/api/v3/builds.rb +++ b/lib/api/v3/builds.rb @@ -134,6 +134,7 @@ class Builds < Grape::API authorize_update_builds! build = get_build!(params[:build_id]) + authorize!(:update_build, build) build.cancel @@ -150,6 +151,7 @@ class Builds < Grape::API authorize_update_builds! build = get_build!(params[:build_id]) + authorize!(:update_build, build) return forbidden!('Build is not retryable') unless build.retryable? build = Ci::Build.retry(build, current_user) @@ -167,6 +169,7 @@ class Builds < Grape::API authorize_update_builds! build = get_build!(params[:build_id]) + authorize!(:update_build, build) return forbidden!('Build is not erasable!') unless build.erasable? build.erase(erased_by: current_user) @@ -183,6 +186,7 @@ class Builds < Grape::API authorize_update_builds! build = get_build!(params[:build_id]) + authorize!(:update_build, build) return not_found!(build) unless build.artifacts? build.keep_artifacts! @@ -202,7 +206,7 @@ class Builds < Grape::API authorize_read_builds! build = get_build!(params[:build_id]) - + authorize!(:update_build, build) bad_request!("Unplayable Job") unless build.playable? build.play(current_user) @@ -213,12 +217,12 @@ class Builds < Grape::API end helpers do - def get_build(id) + def find_build(id) user_project.builds.find_by(id: id.to_i) end def get_build!(id) - get_build(id) || not_found! + find_build(id) || not_found! end def present_artifacts!(artifacts_file) -- GitLab From 34be1835af2913c86bc468131e6bcbd530daf48d Mon Sep 17 00:00:00 2001 From: Valery Sizov <valery@gitlab.com> Date: Fri, 5 May 2017 13:41:35 +0300 Subject: [PATCH 279/363] [Multiple issue assignee] Fix a number of specs --- app/models/concerns/issuable.rb | 1 - .../members/authorized_destroy_service.rb | 15 ++++++--- app/services/system_note_service.rb | 2 +- ...0320171632_create_issue_assignees_table.rb | 2 +- db/schema.rb | 2 +- lib/github/import.rb | 2 +- .../dashboard/issuables_counter_spec.rb | 2 ++ spec/finders/issues_finder_spec.rb | 22 ++++++------- spec/requests/api/v3/issues_spec.rb | 32 ------------------- spec/services/system_note_service_spec.rb | 4 ++- spec/services/users/destroy_service_spec.rb | 2 +- 11 files changed, 32 insertions(+), 54 deletions(-) diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index edf4e9e5d780f..16f04305a4345 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -94,7 +94,6 @@ def award_emojis_loaded? acts_as_paranoid - after_save :update_assignee_cache_counts, if: :assignee_id_changed? after_save :record_metrics, unless: :imported? # We want to use optimistic lock for cases when only title or description are involved diff --git a/app/services/members/authorized_destroy_service.rb b/app/services/members/authorized_destroy_service.rb index 1711be7211c6f..a85b9465c8483 100644 --- a/app/services/members/authorized_destroy_service.rb +++ b/app/services/members/authorized_destroy_service.rb @@ -26,15 +26,22 @@ def execute def unassign_issues_and_merge_requests(member) if member.is_a?(GroupMember) - IssuesFinder.new(user, group_id: member.source_id, assignee_id: member.user_id). - execute. - update_all(assignee_id: nil) + issue_ids = IssuesFinder.new(user, group_id: member.source_id, assignee_id: member.user_id). + execute.pluck(:id) + + IssueAssignee.destroy_all(issue_id: issue_ids, user_id: member.user_id) + MergeRequestsFinder.new(user, group_id: member.source_id, assignee_id: member.user_id). execute. update_all(assignee_id: nil) else project = member.source - project.issues.opened.assigned_to(member.user).update_all(assignee_id: nil) + + IssueAssignee.destroy_all( + user_id: member.user_id, + issue_id: project.issues.opened.assigned_to(member.user).select(:id) + ) + project.merge_requests.opened.assigned_to(member.user).update_all(assignee_id: nil) member.user.update_cache_counts end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 1dee791cfd615..fb1f56c9cc6a9 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -84,7 +84,7 @@ def change_issue_assignees(issue, project, author, old_assignees) "assigned to #{issue.assignees.map(&:to_reference).to_sentence}" end - NoteSummary.new(issue, project, author, body, action: 'assignee') + create_note(NoteSummary.new(issue, project, author, body, action: 'assignee')) end # Called when one or more labels on a Noteable are added and/or removed diff --git a/db/migrate/20170320171632_create_issue_assignees_table.rb b/db/migrate/20170320171632_create_issue_assignees_table.rb index 72b70baa8d916..23b8da37b6d75 100644 --- a/db/migrate/20170320171632_create_issue_assignees_table.rb +++ b/db/migrate/20170320171632_create_issue_assignees_table.rb @@ -26,7 +26,7 @@ class CreateIssueAssigneesTable < ActiveRecord::Migration # disable_ddl_transaction! def up - create_table :issue_assignees, id: false do |t| + create_table :issue_assignees do |t| t.references :user, foreign_key: { on_delete: :cascade }, index: true, null: false t.references :issue, foreign_key: { on_delete: :cascade }, null: false end diff --git a/db/schema.rb b/db/schema.rb index 340d4064c0245..7b960f79c1fb0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -452,7 +452,7 @@ add_index "identities", ["user_id"], name: "index_identities_on_user_id", using: :btree - create_table "issue_assignees", id: false, force: :cascade do |t| + create_table "issue_assignees", force: :cascade do |t| t.integer "user_id", null: false t.integer "issue_id", null: false end diff --git a/lib/github/import.rb b/lib/github/import.rb index d49761fd6c608..06beb607a3eca 100644 --- a/lib/github/import.rb +++ b/lib/github/import.rb @@ -245,7 +245,7 @@ def fetch_issues issue.label_ids = label_ids(representation.labels) issue.milestone_id = milestone_id(representation.milestone) issue.author_id = author_id - issue.assignee_id = user_id(representation.assignee) + issue.assignee_ids = [user_id(representation.assignee)] issue.created_at = representation.created_at issue.updated_at = representation.updated_at issue.save!(validate: false) diff --git a/spec/features/dashboard/issuables_counter_spec.rb b/spec/features/dashboard/issuables_counter_spec.rb index 3d536c5ba4025..6f7bf0eba6ebf 100644 --- a/spec/features/dashboard/issuables_counter_spec.rb +++ b/spec/features/dashboard/issuables_counter_spec.rb @@ -19,6 +19,8 @@ issue.assignees = [] + user.update_cache_counts + Timecop.travel(3.minutes.from_now) do visit issues_path diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index e6bf77aee6a61..9615168935989 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -1,19 +1,19 @@ require 'spec_helper' describe IssuesFinder do - let(:user) { create(:user) } - let(:user2) { create(:user) } - let(:project1) { create(:empty_project) } - let(:project2) { create(:empty_project) } - let(:milestone) { create(:milestone, project: project1) } - let(:label) { create(:label, project: project2) } - let(:issue1) { create(:issue, author: user, assignees: [user], project: project1, milestone: milestone, title: 'gitlab') } - let(:issue2) { create(:issue, author: user, assignees: [user], project: project2, description: 'gitlab') } - let(:issue3) { create(:issue, author: user2, assignees: [user2], project: project2, title: 'tanuki', description: 'tanuki') } + set(:user) { create(:user) } + set(:user2) { create(:user) } + set(:project1) { create(:empty_project) } + set(:project2) { create(:empty_project) } + set(:milestone) { create(:milestone, project: project1) } + set(:label) { create(:label, project: project2) } + set(:issue1) { create(:issue, author: user, assignees: [user], project: project1, milestone: milestone, title: 'gitlab') } + set(:issue2) { create(:issue, author: user, assignees: [user], project: project2, description: 'gitlab') } + set(:issue3) { create(:issue, author: user2, assignees: [user2], project: project2, title: 'tanuki', description: 'tanuki') } describe '#execute' do - let(:closed_issue) { create(:issue, author: user2, assignees: [user2], project: project2, state: 'closed') } - let!(:label_link) { create(:label_link, label: label, target: issue2) } + set(:closed_issue) { create(:issue, author: user2, assignees: [user2], project: project2, state: 'closed') } + set(:label_link) { create(:label_link, label: label, target: issue2) } let(:search_user) { user } let(:params) { {} } let(:issues) { described_class.new(search_user, params.reverse_merge(scope: scope, state: 'opened')).execute } diff --git a/spec/requests/api/v3/issues_spec.rb b/spec/requests/api/v3/issues_spec.rb index 49191b2eb4e72..cc81922697ae6 100644 --- a/spec/requests/api/v3/issues_spec.rb +++ b/spec/requests/api/v3/issues_spec.rb @@ -1157,38 +1157,6 @@ end end - describe 'PUT /projects/:id/issues/:issue_id to update weight' do - it 'updates an issue with no weight' do - put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), weight: 5 - - expect(response).to have_http_status(200) - expect(json_response['weight']).to eq(5) - end - - it 'removes a weight from an issue' do - weighted_issue = create(:issue, project: project, weight: 2) - - put v3_api("/projects/#{project.id}/issues/#{weighted_issue.id}", user), weight: nil - - expect(response).to have_http_status(200) - expect(json_response['weight']).to be_nil - end - - it 'returns 400 if weight is less than minimum weight' do - put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), weight: -1 - - expect(response).to have_http_status(400) - expect(json_response['error']).to eq('weight does not have a valid value') - end - - it 'returns 400 if weight is more than maximum weight' do - put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), weight: 10 - - expect(response).to have_http_status(400) - expect(json_response['error']).to eq('weight does not have a valid value') - end - end - describe "DELETE /projects/:id/issues/:issue_id" do it "rejects a non member from deleting an issue" do delete v3_api("/projects/#{project.id}/issues/#{issue.id}", non_member) diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 5f1b82e835568..68816bf36b833 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -164,7 +164,9 @@ let(:assignee2) { create(:user) } let(:assignee3) { create(:user) } - it_behaves_like 'a system note' + it_behaves_like 'a system note' do + let(:action) { 'assignee' } + end def build_note(old_assignees, new_assignees) issue.assignees = new_assignees diff --git a/spec/services/users/destroy_service_spec.rb b/spec/services/users/destroy_service_spec.rb index 8aa900d1a75fa..de37a61e38880 100644 --- a/spec/services/users/destroy_service_spec.rb +++ b/spec/services/users/destroy_service_spec.rb @@ -60,7 +60,7 @@ it 'migrates the issue so that it is "Unassigned"' do migrated_issue = Issue.find_by_id(issue.id) - expect(migrated_issue.assignees).to be_nil + expect(migrated_issue.assignees).to be_empty end end end -- GitLab From 606584c115d5f7a22f3b5c7e0ac6803b96fe999e Mon Sep 17 00:00:00 2001 From: James Lopez <james@jameslopez.es> Date: Fri, 5 May 2017 14:43:22 +0200 Subject: [PATCH 280/363] bulk insert FTW - This would introduce more complexity, but should be faster --- app/services/projects/propagate_service.rb | 27 ++++++++++++++++------ lib/gitlab/sql/bulk_insert.rb | 21 +++++++++++++++++ 2 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 lib/gitlab/sql/bulk_insert.rb diff --git a/app/services/projects/propagate_service.rb b/app/services/projects/propagate_service.rb index f952b5108bb1f..c420f24fe028b 100644 --- a/app/services/projects/propagate_service.rb +++ b/app/services/projects/propagate_service.rb @@ -24,20 +24,23 @@ def propagate_projects_with_template loop do batch = project_ids_batch - bulk_create_from_template(batch) + bulk_create_from_template(batch) unless batch.empty? break if batch.size < BATCH_SIZE end end def bulk_create_from_template(batch) - service_hash_list = batch.map do |project_id| - service_hash.merge('project_id' => project_id) + service_list = batch.map do |project_id| + service_hash.merge('project_id' => project_id).values end - Project.transaction do - Service.create!(service_hash_list) - end + # Project.transaction do + # Service.create!(service_hash_list) + # end + Gitlab::SQL::BulkInsert.new(service_hash.keys + ['project_id'], + service_list, + 'services').execute end def project_ids_batch @@ -57,7 +60,17 @@ def project_ids_batch end def service_hash - @service_hash ||= @template.as_json(methods: :type).except('id', 'template') + @service_hash ||= + begin + template_hash = @template.as_json(methods: :type).except('id', 'template', 'project_id') + + template_hash.each_with_object({}) do |(key, value), service_hash| + value = value.is_a?(Hash) ? value.to_json : value + key = Gitlab::Database.postgresql? ? "\"#{key}\"" : "`#{key}`" + + service_hash[key] = ActiveRecord::Base.sanitize(value) + end + end end end end diff --git a/lib/gitlab/sql/bulk_insert.rb b/lib/gitlab/sql/bulk_insert.rb new file mode 100644 index 0000000000000..097f9ff237b1e --- /dev/null +++ b/lib/gitlab/sql/bulk_insert.rb @@ -0,0 +1,21 @@ +module Gitlab + module SQL + # Class for building SQL bulk inserts + class BulkInsert + def initialize(columns, values_array, table) + @columns = columns + @values_array = values_array + @table = table + end + + def execute + ActiveRecord::Base.connection.execute( + <<-SQL.strip_heredoc + INSERT INTO #{@table} (#{@columns.join(', ')}) + VALUES #{@values_array.map { |tuple| "(#{tuple.join(', ')})" }.join(', ')} + SQL + ) + end + end + end +end -- GitLab From 715cdc1afdf59cde4bd4a6183b81e2e19ef3ab78 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin <godfat@godfat.org> Date: Fri, 5 May 2017 12:53:44 +0000 Subject: [PATCH 281/363] Update CHANGELOG.md for 8.17.6 [ci skip] --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e05b025ce2d6a..c9de0113e2452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -643,6 +643,17 @@ entry. - Change development tanuki favicon colors to match logo color order. - API issues - support filtering by iids. +## 8.17.6 (2017-05-05) + +- Enforce project features when searching blobs and wikis. +- Fixed branches dropdown rendering branch names as HTML. +- Make Asciidoc & other markup go through pipeline to prevent XSS. +- Validate URLs in markdown using URI to detect the host correctly. +- Fix for XSS in project import view caused by Hamlit filter usage. +- Sanitize submodule URLs before linking to them in the file tree view. +- Refactor snippets finder & dont return internal snippets for external users. +- Fix snippets visibility for show action - external users can not see internal snippets. + ## 8.17.5 (2017-04-05) - Don’t show source project name when user does not have access. -- GitLab From 2cc8f43e54d2b653a4f2e80c57339acb11dcba86 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Fri, 5 May 2017 15:13:58 +0200 Subject: [PATCH 282/363] Introduce generic manual action extended status class --- lib/gitlab/ci/status/build/action.rb | 23 ++++++++++++ lib/gitlab/ci/status/build/factory.rb | 3 +- lib/gitlab/ci/status/build/play.rb | 6 +--- .../gitlab/ci/status/build/factory_spec.rb | 32 +++++++++-------- spec/lib/gitlab/ci/status/build/play_spec.rb | 36 +++++++------------ 5 files changed, 57 insertions(+), 43 deletions(-) create mode 100644 lib/gitlab/ci/status/build/action.rb diff --git a/lib/gitlab/ci/status/build/action.rb b/lib/gitlab/ci/status/build/action.rb new file mode 100644 index 0000000000000..1397c35145a72 --- /dev/null +++ b/lib/gitlab/ci/status/build/action.rb @@ -0,0 +1,23 @@ +module Gitlab + module Ci + module Status + module Build + class Action < SimpleDelegator + include Status::Extended + + def label + if has_action? + __getobj__.label + else + "#{__getobj__.label} (not allowed)" + end + end + + def self.matches?(build, user) + build.action? + end + end + end + end + end +end diff --git a/lib/gitlab/ci/status/build/factory.rb b/lib/gitlab/ci/status/build/factory.rb index 38ac6edc9f188..c852d6073736d 100644 --- a/lib/gitlab/ci/status/build/factory.rb +++ b/lib/gitlab/ci/status/build/factory.rb @@ -8,7 +8,8 @@ def self.extended_statuses Status::Build::Retryable], [Status::Build::FailedAllowed, Status::Build::Play, - Status::Build::Stop]] + Status::Build::Stop], + [Status::Build::Action]] end def self.common_helpers diff --git a/lib/gitlab/ci/status/build/play.rb b/lib/gitlab/ci/status/build/play.rb index fae34a2f9276a..3495b8d0448b9 100644 --- a/lib/gitlab/ci/status/build/play.rb +++ b/lib/gitlab/ci/status/build/play.rb @@ -6,11 +6,7 @@ class Play < SimpleDelegator include Status::Extended def label - if has_action? - 'manual play action' - else - 'manual play action (not allowed)' - end + 'manual play action' end def has_action? diff --git a/spec/lib/gitlab/ci/status/build/factory_spec.rb b/spec/lib/gitlab/ci/status/build/factory_spec.rb index 2de00c289450c..39c5e3658d910 100644 --- a/spec/lib/gitlab/ci/status/build/factory_spec.rb +++ b/spec/lib/gitlab/ci/status/build/factory_spec.rb @@ -204,11 +204,12 @@ it 'matches correct extended statuses' do expect(factory.extended_statuses) - .to eq [Gitlab::Ci::Status::Build::Play] + .to eq [Gitlab::Ci::Status::Build::Play, + Gitlab::Ci::Status::Build::Action] end - it 'fabricates a play detailed status' do - expect(status).to be_a Gitlab::Ci::Status::Build::Play + it 'fabricates action detailed status' do + expect(status).to be_a Gitlab::Ci::Status::Build::Action end it 'fabricates status with correct details' do @@ -247,21 +248,24 @@ it 'matches correct extended statuses' do expect(factory.extended_statuses) - .to eq [Gitlab::Ci::Status::Build::Stop] + .to eq [Gitlab::Ci::Status::Build::Stop, + Gitlab::Ci::Status::Build::Action] end - it 'fabricates a stop detailed status' do - expect(status).to be_a Gitlab::Ci::Status::Build::Stop + it 'fabricates action detailed status' do + expect(status).to be_a Gitlab::Ci::Status::Build::Action end - it 'fabricates status with correct details' do - expect(status.text).to eq 'manual' - expect(status.group).to eq 'manual' - expect(status.icon).to eq 'icon_status_manual' - expect(status.favicon).to eq 'favicon_status_manual' - expect(status.label).to eq 'manual stop action' - expect(status).to have_details - expect(status).to have_action + context 'when user is not allowed to execute manual action' do + it 'fabricates status with correct details' do + expect(status.text).to eq 'manual' + expect(status.group).to eq 'manual' + expect(status.icon).to eq 'icon_status_manual' + expect(status.favicon).to eq 'favicon_status_manual' + expect(status.label).to eq 'manual stop action (not allowed)' + expect(status).to have_details + expect(status).not_to have_action + end end end end diff --git a/spec/lib/gitlab/ci/status/build/play_spec.rb b/spec/lib/gitlab/ci/status/build/play_spec.rb index abefdbe7c66ca..f5d0f977768fc 100644 --- a/spec/lib/gitlab/ci/status/build/play_spec.rb +++ b/spec/lib/gitlab/ci/status/build/play_spec.rb @@ -7,38 +7,28 @@ subject { described_class.new(status) } - context 'when user is allowed to update build' do - context 'when user can push to branch' do - before { build.project.add_master(user) } + describe '#label' do + it 'has a label that says it is a manual action' do + expect(subject.label).to eq 'manual play action' + end + end + + describe '#has_action?' do + context 'when user is allowed to update build' do + context 'when user can push to branch' do + before { build.project.add_master(user) } - describe '#has_action?' do it { is_expected.to have_action } end - describe '#label' do - it 'has a label that says it is a manual action' do - expect(subject.label).to eq 'manual play action' - end - end - end + context 'when user can not push to the branch' do + before { build.project.add_developer(user) } - context 'when user can not push to the branch' do - before { build.project.add_developer(user) } - - describe 'has_action?' do it { is_expected.not_to have_action } end - - describe '#label' do - it 'has a label that says user is not allowed to play it' do - expect(subject.label).to eq 'manual play action (not allowed)' - end - end end - end - context 'when user is not allowed to update build' do - describe '#has_action?' do + context 'when user is not allowed to update build' do it { is_expected.not_to have_action } end end -- GitLab From 55cec2177cd97a5c0939ab34b01aa408de887d1a Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Fri, 5 May 2017 15:21:06 +0200 Subject: [PATCH 283/363] Refine inheritance model of extended CI/CD statuses --- lib/gitlab/ci/status/build/action.rb | 8 +++----- lib/gitlab/ci/status/build/cancelable.rb | 4 +--- lib/gitlab/ci/status/build/failed_allowed.rb | 4 +--- lib/gitlab/ci/status/build/play.rb | 4 +--- lib/gitlab/ci/status/build/retryable.rb | 4 +--- lib/gitlab/ci/status/build/stop.rb | 4 +--- lib/gitlab/ci/status/extended.rb | 12 ++++++------ lib/gitlab/ci/status/pipeline/blocked.rb | 4 +--- lib/gitlab/ci/status/success_warning.rb | 4 +--- spec/lib/gitlab/ci/status/build/factory_spec.rb | 2 +- spec/lib/gitlab/ci/status/extended_spec.rb | 6 +----- 11 files changed, 18 insertions(+), 38 deletions(-) diff --git a/lib/gitlab/ci/status/build/action.rb b/lib/gitlab/ci/status/build/action.rb index 1397c35145a72..45fd0d4aa0739 100644 --- a/lib/gitlab/ci/status/build/action.rb +++ b/lib/gitlab/ci/status/build/action.rb @@ -2,14 +2,12 @@ module Gitlab module Ci module Status module Build - class Action < SimpleDelegator - include Status::Extended - + class Action < Status::Extended def label if has_action? - __getobj__.label + @status.label else - "#{__getobj__.label} (not allowed)" + "#{@status.label} (not allowed)" end end diff --git a/lib/gitlab/ci/status/build/cancelable.rb b/lib/gitlab/ci/status/build/cancelable.rb index 67bbc3c484932..57b533bad99c6 100644 --- a/lib/gitlab/ci/status/build/cancelable.rb +++ b/lib/gitlab/ci/status/build/cancelable.rb @@ -2,9 +2,7 @@ module Gitlab module Ci module Status module Build - class Cancelable < SimpleDelegator - include Status::Extended - + class Cancelable < Status::Extended def has_action? can?(user, :update_build, subject) end diff --git a/lib/gitlab/ci/status/build/failed_allowed.rb b/lib/gitlab/ci/status/build/failed_allowed.rb index 807afe24bd576..e42d3574357cb 100644 --- a/lib/gitlab/ci/status/build/failed_allowed.rb +++ b/lib/gitlab/ci/status/build/failed_allowed.rb @@ -2,9 +2,7 @@ module Gitlab module Ci module Status module Build - class FailedAllowed < SimpleDelegator - include Status::Extended - + class FailedAllowed < Status::Extended def label 'failed (allowed to fail)' end diff --git a/lib/gitlab/ci/status/build/play.rb b/lib/gitlab/ci/status/build/play.rb index 3495b8d0448b9..c6139f1b71647 100644 --- a/lib/gitlab/ci/status/build/play.rb +++ b/lib/gitlab/ci/status/build/play.rb @@ -2,9 +2,7 @@ module Gitlab module Ci module Status module Build - class Play < SimpleDelegator - include Status::Extended - + class Play < Status::Extended def label 'manual play action' end diff --git a/lib/gitlab/ci/status/build/retryable.rb b/lib/gitlab/ci/status/build/retryable.rb index 6b362af76343e..505f80848b250 100644 --- a/lib/gitlab/ci/status/build/retryable.rb +++ b/lib/gitlab/ci/status/build/retryable.rb @@ -2,9 +2,7 @@ module Gitlab module Ci module Status module Build - class Retryable < SimpleDelegator - include Status::Extended - + class Retryable < Status::Extended def has_action? can?(user, :update_build, subject) end diff --git a/lib/gitlab/ci/status/build/stop.rb b/lib/gitlab/ci/status/build/stop.rb index e8530f2aaaed4..0b5199e5483a7 100644 --- a/lib/gitlab/ci/status/build/stop.rb +++ b/lib/gitlab/ci/status/build/stop.rb @@ -2,9 +2,7 @@ module Gitlab module Ci module Status module Build - class Stop < SimpleDelegator - include Status::Extended - + class Stop < Status::Extended def label 'manual stop action' end diff --git a/lib/gitlab/ci/status/extended.rb b/lib/gitlab/ci/status/extended.rb index d367c9bda69ff..1e8101f894950 100644 --- a/lib/gitlab/ci/status/extended.rb +++ b/lib/gitlab/ci/status/extended.rb @@ -1,13 +1,13 @@ module Gitlab module Ci module Status - module Extended - extend ActiveSupport::Concern + class Extended < SimpleDelegator + def initialize(status) + super(@status = status) + end - class_methods do - def matches?(_subject, _user) - raise NotImplementedError - end + def self.matches?(_subject, _user) + raise NotImplementedError end end end diff --git a/lib/gitlab/ci/status/pipeline/blocked.rb b/lib/gitlab/ci/status/pipeline/blocked.rb index a250c3fcb419d..37dfe43fb621a 100644 --- a/lib/gitlab/ci/status/pipeline/blocked.rb +++ b/lib/gitlab/ci/status/pipeline/blocked.rb @@ -2,9 +2,7 @@ module Gitlab module Ci module Status module Pipeline - class Blocked < SimpleDelegator - include Status::Extended - + class Blocked < Status::Extended def text 'blocked' end diff --git a/lib/gitlab/ci/status/success_warning.rb b/lib/gitlab/ci/status/success_warning.rb index d4cdab6957a00..df6e76b015183 100644 --- a/lib/gitlab/ci/status/success_warning.rb +++ b/lib/gitlab/ci/status/success_warning.rb @@ -5,9 +5,7 @@ module Status # Extended status used when pipeline or stage passed conditionally. # This means that failed jobs that are allowed to fail were present. # - class SuccessWarning < SimpleDelegator - include Status::Extended - + class SuccessWarning < Status::Extended def text 'passed' end diff --git a/spec/lib/gitlab/ci/status/build/factory_spec.rb b/spec/lib/gitlab/ci/status/build/factory_spec.rb index 39c5e3658d910..185bb9098dac0 100644 --- a/spec/lib/gitlab/ci/status/build/factory_spec.rb +++ b/spec/lib/gitlab/ci/status/build/factory_spec.rb @@ -205,7 +205,7 @@ it 'matches correct extended statuses' do expect(factory.extended_statuses) .to eq [Gitlab::Ci::Status::Build::Play, - Gitlab::Ci::Status::Build::Action] + Gitlab::Ci::Status::Build::Action] end it 'fabricates action detailed status' do diff --git a/spec/lib/gitlab/ci/status/extended_spec.rb b/spec/lib/gitlab/ci/status/extended_spec.rb index c2d74ca5cde0c..6eacb07078bf9 100644 --- a/spec/lib/gitlab/ci/status/extended_spec.rb +++ b/spec/lib/gitlab/ci/status/extended_spec.rb @@ -1,12 +1,8 @@ require 'spec_helper' describe Gitlab::Ci::Status::Extended do - subject do - Class.new.include(described_class) - end - it 'requires subclass to implement matcher' do - expect { subject.matches?(double, double) } + expect { described_class.matches?(double, double) } .to raise_error(NotImplementedError) end end -- GitLab From e5f24c5490294a4e990103da24e9861f21d7bfd9 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Fri, 5 May 2017 15:31:00 +0200 Subject: [PATCH 284/363] Add specs for extended status for manual actions --- .../lib/gitlab/ci/status/build/action_spec.rb | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 spec/lib/gitlab/ci/status/build/action_spec.rb diff --git a/spec/lib/gitlab/ci/status/build/action_spec.rb b/spec/lib/gitlab/ci/status/build/action_spec.rb new file mode 100644 index 0000000000000..8c25f72804b68 --- /dev/null +++ b/spec/lib/gitlab/ci/status/build/action_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +describe Gitlab::Ci::Status::Build::Action do + let(:status) { double('core status') } + let(:user) { double('user') } + + subject do + described_class.new(status) + end + + describe '#label' do + before do + allow(status).to receive(:label).and_return('label') + end + + context 'when status has action' do + before do + allow(status).to receive(:has_action?).and_return(true) + end + + it 'does not append text' do + expect(subject.label).to eq 'label' + end + end + + context 'when status does not have action' do + before do + allow(status).to receive(:has_action?).and_return(false) + end + + it 'appends text about action not allowed' do + expect(subject.label).to eq 'label (not allowed)' + end + end + end + + describe '.matches?' do + subject { described_class.matches?(build, user) } + + context 'when build is an action' do + let(:build) { create(:ci_build, :manual) } + + it 'is a correct match' do + expect(subject).to be true + end + end + + context 'when build is not manual' do + let(:build) { create(:ci_build) } + + it 'does not match' do + expect(subject).to be false + end + end + end +end -- GitLab From e4f7b87ddb4ba83456871eb83b841192b1b56799 Mon Sep 17 00:00:00 2001 From: Jarka Kadlecova <jarka@gitlab.com> Date: Wed, 3 May 2017 10:48:01 +0200 Subject: [PATCH 285/363] Support comments for personal snippets --- app/controllers/concerns/notes_actions.rb | 44 +++++++++++ app/controllers/projects/notes_controller.rb | 44 ----------- app/controllers/snippets/notes_controller.rb | 9 --- app/controllers/snippets_controller.rb | 1 + app/helpers/gitlab_routing_helper.rb | 6 +- app/helpers/notes_helper.rb | 43 +++++++++++ app/services/notes/build_service.rb | 18 ++++- app/views/layouts/snippets.html.haml | 6 ++ app/views/projects/commit/show.html.haml | 2 +- .../projects/issues/_discussion.html.haml | 2 +- .../merge_requests/_discussion.html.haml | 2 +- app/views/projects/milestones/_form.html.haml | 2 +- app/views/projects/notes/_edit.html.haml | 3 - app/views/projects/releases/edit.html.haml | 2 +- app/views/projects/snippets/show.html.haml | 2 +- app/views/projects/tags/new.html.haml | 2 +- app/views/projects/wikis/_form.html.haml | 2 +- .../issuable/form/_description.html.haml | 2 +- .../notes/_comment_button.html.haml | 0 app/views/shared/notes/_edit.html.haml | 3 + .../notes/_edit_form.html.haml | 2 +- .../notes/_form.html.haml | 6 +- .../notes/_hints.html.haml | 0 app/views/shared/notes/_note.html.haml | 5 +- .../notes/_notes_with_form.html.haml | 8 +- app/views/snippets/notes/_edit.html.haml | 0 app/views/snippets/notes/_notes.html.haml | 2 - app/views/snippets/show.html.haml | 12 +-- .../12910-personal-snippets-notes.yml | 4 + spec/factories/notes.rb | 2 + .../notes_on_personal_snippets_spec.rb | 64 +++++++++++++++- spec/helpers/notes_helper_spec.rb | 75 +++++++++++++++++++ spec/services/notes/build_service_spec.rb | 74 +++++++++++++++++- .../notes/_form.html.haml_spec.rb | 2 +- 34 files changed, 359 insertions(+), 92 deletions(-) delete mode 100644 app/views/projects/notes/_edit.html.haml rename app/views/{projects => shared}/notes/_comment_button.html.haml (100%) create mode 100644 app/views/shared/notes/_edit.html.haml rename app/views/{projects => shared}/notes/_edit_form.html.haml (95%) rename app/views/{projects => shared}/notes/_form.html.haml (74%) rename app/views/{projects => shared}/notes/_hints.html.haml (100%) rename app/views/{projects => shared}/notes/_notes_with_form.html.haml (64%) delete mode 100644 app/views/snippets/notes/_edit.html.haml delete mode 100644 app/views/snippets/notes/_notes.html.haml create mode 100644 changelogs/unreleased/12910-personal-snippets-notes.yml rename spec/views/{projects => shared}/notes/_form.html.haml_spec.rb (96%) diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index c32038d07bfaa..a57d9e6e6c0ec 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -65,6 +65,15 @@ def destroy private + def note_html(note) + render_to_string( + "shared/notes/_note", + layout: false, + formats: [:html], + locals: { note: note } + ) + end + def note_json(note) attrs = { commands_changes: note.commands_changes @@ -98,6 +107,41 @@ def note_json(note) attrs end + def diff_discussion_html(discussion) + return unless discussion.diff_discussion? + + if params[:view] == 'parallel' + template = "discussions/_parallel_diff_discussion" + locals = + if params[:line_type] == 'old' + { discussions_left: [discussion], discussions_right: nil } + else + { discussions_left: nil, discussions_right: [discussion] } + end + else + template = "discussions/_diff_discussion" + locals = { discussions: [discussion] } + end + + render_to_string( + template, + layout: false, + formats: [:html], + locals: locals + ) + end + + def discussion_html(discussion) + return if discussion.individual_note? + + render_to_string( + "discussions/_discussion", + layout: false, + formats: [:html], + locals: { discussion: discussion } + ) + end + def authorize_admin_note! return access_denied! unless can?(current_user, :admin_note, note) end diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index 37f51b2ebe3ba..41a13f6f5778e 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -62,50 +62,6 @@ def note end alias_method :awardable, :note - def note_html(note) - render_to_string( - "shared/notes/_note", - layout: false, - formats: [:html], - locals: { note: note } - ) - end - - def discussion_html(discussion) - return if discussion.individual_note? - - render_to_string( - "discussions/_discussion", - layout: false, - formats: [:html], - locals: { discussion: discussion } - ) - end - - def diff_discussion_html(discussion) - return unless discussion.diff_discussion? - - if params[:view] == 'parallel' - template = "discussions/_parallel_diff_discussion" - locals = - if params[:line_type] == 'old' - { discussions_left: [discussion], discussions_right: nil } - else - { discussions_left: nil, discussions_right: [discussion] } - end - else - template = "discussions/_diff_discussion" - locals = { discussions: [discussion] } - end - - render_to_string( - template, - layout: false, - formats: [:html], - locals: locals - ) - end - def finder_params params.merge(last_fetched_at: last_fetched_at) end diff --git a/app/controllers/snippets/notes_controller.rb b/app/controllers/snippets/notes_controller.rb index 3c4ddc1680de0..f9496787b15f5 100644 --- a/app/controllers/snippets/notes_controller.rb +++ b/app/controllers/snippets/notes_controller.rb @@ -13,15 +13,6 @@ def note end alias_method :awardable, :note - def note_html(note) - render_to_string( - "shared/notes/_note", - layout: false, - formats: [:html], - locals: { note: note } - ) - end - def project nil end diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb index afed27a41d1fb..19e07e3ab86f6 100644 --- a/app/controllers/snippets_controller.rb +++ b/app/controllers/snippets_controller.rb @@ -64,6 +64,7 @@ def show blob = @snippet.blob override_max_blob_size(blob) + @note = Note.new(noteable: @snippet) @noteable = @snippet @discussions = @snippet.discussions diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index 88f9a691a17a3..3769830de2a1f 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -123,7 +123,11 @@ def project_snippet_url(entity, *args) end def preview_markdown_path(project, *args) - preview_markdown_namespace_project_path(project.namespace, project, *args) + if @snippet.is_a?(PersonalSnippet) + preview_markdown_snippet_path(@snippet) + else + preview_markdown_namespace_project_path(project.namespace, project, *args) + end end def toggle_subscription_path(entity, *args) diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 08180883eb9e0..52403640c053e 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -76,4 +76,47 @@ def discussion_path(discussion) namespace_project_commit_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: anchor) end end + + def notes_url + if @snippet.is_a?(PersonalSnippet) + snippet_notes_path(@snippet) + else + namespace_project_noteable_notes_path( + namespace_id: @project.namespace, + project_id: @project, + target_id: @noteable.id, + target_type: @noteable.class.name.underscore + ) + end + end + + def note_url(note) + if note.noteable.is_a?(PersonalSnippet) + snippet_note_path(note.noteable, note) + else + namespace_project_note_path(@project.namespace, @project, note) + end + end + + def form_resources + if @snippet.is_a?(PersonalSnippet) + [@note] + else + [@project.namespace.becomes(Namespace), @project, @note] + end + end + + def new_form_url + return nil unless @snippet.is_a?(PersonalSnippet) + + snippet_notes_path(@snippet) + end + + def can_create_note? + if @snippet.is_a?(PersonalSnippet) + can?(current_user, :comment_personal_snippet, @snippet) + else + can?(current_user, :create_note, @project) + end + end end diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb index ea7cacc956c1c..abf25bb778bba 100644 --- a/app/services/notes/build_service.rb +++ b/app/services/notes/build_service.rb @@ -3,8 +3,8 @@ class BuildService < ::BaseService def execute in_reply_to_discussion_id = params.delete(:in_reply_to_discussion_id) - if project && in_reply_to_discussion_id.present? - discussion = project.notes.find_discussion(in_reply_to_discussion_id) + if in_reply_to_discussion_id.present? + discussion = find_discussion(in_reply_to_discussion_id) unless discussion note = Note.new @@ -21,5 +21,19 @@ def execute note end + + def find_discussion(discussion_id) + if project + project.notes.find_discussion(discussion_id) + else + # only PersonalSnippets can have discussions without project association + discussion = Note.find_discussion(discussion_id) + noteable = discussion.noteable + + return nil unless noteable.is_a?(PersonalSnippet) && can?(current_user, :comment_personal_snippet, noteable) + + discussion + end + end end end diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml index 02ca3ee7a2875..98b75cea03fd7 100644 --- a/app/views/layouts/snippets.html.haml +++ b/app/views/layouts/snippets.html.haml @@ -1,3 +1,9 @@ - header_title "Snippets", snippets_path +- content_for :page_specific_javascripts do + - if @snippet&.persisted? && current_user + :javascript + window.uploads_path = "#{upload_path('personal_snippet', @snippet)}"; + window.preview_markdown_path = "#{preview_markdown_snippet_path(@snippet)}"; + = render template: "layouts/application" diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml index 16d2646cb4e08..6051ea2f1ce0d 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -13,7 +13,7 @@ .block-connector = render "projects/diffs/diffs", diffs: @diffs, environment: @environment - = render "projects/notes/notes_with_form" + = render "shared/notes/notes_with_form" - if can_collaborate_with_project? - %w(revert cherry-pick).each do |type| = render "projects/commit/change", type: type, commit: @commit, title: @commit.title diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 5d4e593e4ef21..4dfda54feb554 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -4,4 +4,4 @@ = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {no_turbolink: true, original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' #notes - = render 'projects/notes/notes_with_form' + = render 'shared/notes/notes_with_form' diff --git a/app/views/projects/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml index 15b5a51c1d0c4..2e6420db21223 100644 --- a/app/views/projects/merge_requests/_discussion.html.haml +++ b/app/views/projects/merge_requests/_discussion.html.haml @@ -8,4 +8,4 @@ %button.btn.btn-nr.btn-default.append-right-10.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { project_path: "#{project_path(@merge_request.project)}" } } {{ buttonText }} -#notes= render "projects/notes/notes_with_form" +#notes= render "shared/notes/notes_with_form" diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index 2e978fda62422..9a95b2a82ffd7 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -11,7 +11,7 @@ .col-sm-10 = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project) } do = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...' - = render 'projects/notes/hints' + = render 'shared/notes/hints' .clearfix .error-alert = render "shared/milestones/form_dates", f: f diff --git a/app/views/projects/notes/_edit.html.haml b/app/views/projects/notes/_edit.html.haml deleted file mode 100644 index f1e251d65b75b..0000000000000 --- a/app/views/projects/notes/_edit.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -.original-note-content.hidden{ data: { post_url: namespace_project_note_path(@project.namespace, @project, note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } } - #{note.note} -%textarea.hidden.js-task-list-field.original-task-list{ data: {update_url: namespace_project_note_path(@project.namespace, @project, note) } }= note.note diff --git a/app/views/projects/releases/edit.html.haml b/app/views/projects/releases/edit.html.haml index faa24a3c88e21..93ee9382a6e09 100644 --- a/app/views/projects/releases/edit.html.haml +++ b/app/views/projects/releases/edit.html.haml @@ -13,7 +13,7 @@ = form_for(@release, method: :put, url: namespace_project_tag_release_path(@project.namespace, @project, @tag.name), html: { class: 'form-horizontal common-note-form release-form js-quick-submit' }) do |f| = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here..." - = render 'projects/notes/hints' + = render 'shared/notes/hints' .error-alert .prepend-top-default = f.submit 'Save changes', class: 'btn btn-save' diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml index 7a175f63eeb06..aab1c043e66ea 100644 --- a/app/views/projects/snippets/show.html.haml +++ b/app/views/projects/snippets/show.html.haml @@ -9,4 +9,4 @@ .row-content-block.top-block.content-component-block = render 'award_emoji/awards_block', awardable: @snippet, inline: true - #notes= render "projects/notes/notes_with_form" + #notes= render "shared/notes/notes_with_form" diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index a6894b9adc001..7c607d2956bf3 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -30,7 +30,7 @@ .col-sm-10 = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do = render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here..." - = render 'projects/notes/hints' + = render 'shared/notes/hints' .help-block Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page. .form-actions = button_tag 'Create tag', class: 'btn btn-create', tabindex: 3 diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml index 00869aff27bf4..6cb7c1e9c4d0f 100644 --- a/app/views/projects/wikis/_form.html.haml +++ b/app/views/projects/wikis/_form.html.haml @@ -14,7 +14,7 @@ .col-sm-10 = render layout: 'projects/md_preview', locals: { url: namespace_project_wiki_preview_markdown_path(@project.namespace, @project, @page.slug) } do = render 'projects/zen', f: f, attr: :content, classes: 'note-textarea', placeholder: 'Write your content or drag files here...' - = render 'projects/notes/hints' + = render 'shared/notes/hints' .clearfix .error-alert diff --git a/app/views/shared/issuable/form/_description.html.haml b/app/views/shared/issuable/form/_description.html.haml index cbc7125c0d56a..7ef0ae96be287 100644 --- a/app/views/shared/issuable/form/_description.html.haml +++ b/app/views/shared/issuable/form/_description.html.haml @@ -17,6 +17,6 @@ classes: 'note-textarea', placeholder: "Write a comment or drag your files here...", supports_slash_commands: supports_slash_commands - = render 'projects/notes/hints', supports_slash_commands: supports_slash_commands + = render 'shared/notes/hints', supports_slash_commands: supports_slash_commands .clearfix .error-alert diff --git a/app/views/projects/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml similarity index 100% rename from app/views/projects/notes/_comment_button.html.haml rename to app/views/shared/notes/_comment_button.html.haml diff --git a/app/views/shared/notes/_edit.html.haml b/app/views/shared/notes/_edit.html.haml new file mode 100644 index 0000000000000..4a020865828d2 --- /dev/null +++ b/app/views/shared/notes/_edit.html.haml @@ -0,0 +1,3 @@ +.original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } } + #{note.note} +%textarea.hidden.js-task-list-field.original-task-list{ data: {update_url: note_url(note) } }= note.note diff --git a/app/views/projects/notes/_edit_form.html.haml b/app/views/shared/notes/_edit_form.html.haml similarity index 95% rename from app/views/projects/notes/_edit_form.html.haml rename to app/views/shared/notes/_edit_form.html.haml index 3867072225f88..8923e5602a41f 100644 --- a/app/views/projects/notes/_edit_form.html.haml +++ b/app/views/shared/notes/_edit_form.html.haml @@ -4,7 +4,7 @@ = hidden_field_tag :target_type, '', class: 'js-form-target-type' = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(project), referenced_users: true } do = render 'projects/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', placeholder: "Write a comment or drag your files here..." - = render 'projects/notes/hints' + = render 'shared/notes/hints' .note-form-actions.clearfix .settings-message.note-edit-warning.js-finish-edit-warning diff --git a/app/views/projects/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml similarity index 74% rename from app/views/projects/notes/_form.html.haml rename to app/views/shared/notes/_form.html.haml index 46f785fefcafe..eaf50bc2115aa 100644 --- a/app/views/projects/notes/_form.html.haml +++ b/app/views/shared/notes/_form.html.haml @@ -4,7 +4,7 @@ - else - preview_url = preview_markdown_path(@project) -= form_for [@project.namespace.becomes(Namespace), @project, @note], remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f| += form_for form_resources, url: new_form_url, remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f| = hidden_field_tag :view, diff_view = hidden_field_tag :line_type = hidden_field_tag :merge_request_diff_head_sha, @note.noteable.try(:diff_head_sha) @@ -28,11 +28,11 @@ classes: 'note-textarea js-note-text', placeholder: "Write a comment or drag your files here...", supports_slash_commands: supports_slash_commands - = render 'projects/notes/hints', supports_slash_commands: supports_slash_commands + = render 'shared/notes/hints', supports_slash_commands: supports_slash_commands .error-alert .note-form-actions.clearfix - = render partial: 'projects/notes/comment_button' + = render partial: 'shared/notes/comment_button' = yield(:note_actions) diff --git a/app/views/projects/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml similarity index 100% rename from app/views/projects/notes/_hints.html.haml rename to app/views/shared/notes/_hints.html.haml diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index 9657b4eea82f0..071c48fa2e494 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -42,10 +42,7 @@ = note.redacted_note_html = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago', include_author: true) - if note_editable - - if note.for_personal_snippet? - = render 'snippets/notes/edit', note: note - - else - = render 'projects/notes/edit', note: note + = render 'shared/notes/edit', note: note .note-awards = render 'award_emoji/awards_block', awardable: note, inline: false - if note.system diff --git a/app/views/projects/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml similarity index 64% rename from app/views/projects/notes/_notes_with_form.html.haml rename to app/views/shared/notes/_notes_with_form.html.haml index 2a66addb08a1d..9930cbd96d7fd 100644 --- a/app/views/projects/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -1,18 +1,18 @@ %ul#notes-list.notes.main-notes-list.timeline = render "shared/notes/notes" -= render 'projects/notes/edit_form', project: @project += render 'shared/notes/edit_form', project: @project %ul.notes.notes-form.timeline %li.timeline-entry .flash-container.timeline-content - - if can? current_user, :create_note, @project + - if can_create_note? .timeline-icon.hidden-xs.hidden-sm %a.author_link{ href: user_path(current_user) } = image_tag avatar_icon(current_user), alt: current_user.to_reference, class: 'avatar s40' .timeline-content.timeline-content-form - = render "projects/notes/form", view: diff_view + = render "shared/notes/form", view: diff_view - elsif !current_user .disabled-comment.text-center .disabled-comment-text.inline @@ -23,4 +23,4 @@ to post a comment :javascript - var notes = new Notes("#{namespace_project_noteable_notes_path(namespace_id: @project.namespace, project_id: @project, target_id: @noteable.id, target_type: @noteable.class.name.underscore)}", #{@notes.map(&:id).to_json}, #{Time.now.to_i}, "#{diff_view}") + var notes = new Notes("#{notes_url}", #{@notes.map(&:id).to_json}, #{Time.now.to_i}, "#{diff_view}") diff --git a/app/views/snippets/notes/_edit.html.haml b/app/views/snippets/notes/_edit.html.haml deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/app/views/snippets/notes/_notes.html.haml b/app/views/snippets/notes/_notes.html.haml deleted file mode 100644 index f07d6b8c1266b..0000000000000 --- a/app/views/snippets/notes/_notes.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -%ul#notes-list.notes.main-notes-list.timeline - = render "projects/notes/notes" diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml index 98287cba5b47c..51dbbc32cc97a 100644 --- a/app/views/snippets/show.html.haml +++ b/app/views/snippets/show.html.haml @@ -2,11 +2,11 @@ = render 'shared/snippets/header' -%article.file-holder.snippet-file-content - = render 'shared/snippets/blob' +.personal-snippets + %article.file-holder.snippet-file-content + = render 'shared/snippets/blob' -.row-content-block.top-block.content-component-block - = render 'award_emoji/awards_block', awardable: @snippet, inline: true + .row-content-block.top-block.content-component-block + = render 'award_emoji/awards_block', awardable: @snippet, inline: true -%ul#notes-list.notes.main-notes-list.timeline - #notes= render 'shared/notes/notes' + #notes= render "shared/notes/notes_with_form" diff --git a/changelogs/unreleased/12910-personal-snippets-notes.yml b/changelogs/unreleased/12910-personal-snippets-notes.yml new file mode 100644 index 0000000000000..7f1576c351342 --- /dev/null +++ b/changelogs/unreleased/12910-personal-snippets-notes.yml @@ -0,0 +1,4 @@ +--- +title: Support comments for personal snippets +merge_request: +author: diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb index 44c3186d8138d..046974dcd6e8a 100644 --- a/spec/factories/notes.rb +++ b/spec/factories/notes.rb @@ -29,6 +29,8 @@ factory :discussion_note_on_commit, traits: [:on_commit], class: DiscussionNote + factory :discussion_note_on_personal_snippet, traits: [:on_personal_snippet], class: DiscussionNote + factory :legacy_diff_note_on_commit, traits: [:on_commit, :legacy_diff_note], class: LegacyDiffNote factory :legacy_diff_note_on_merge_request, traits: [:on_merge_request, :legacy_diff_note], class: LegacyDiffNote do diff --git a/spec/features/snippets/notes_on_personal_snippets_spec.rb b/spec/features/snippets/notes_on_personal_snippets_spec.rb index c646039e0b145..957baac02eb00 100644 --- a/spec/features/snippets/notes_on_personal_snippets_spec.rb +++ b/spec/features/snippets/notes_on_personal_snippets_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'Comments on personal snippets', feature: true do +describe 'Comments on personal snippets', :js, feature: true do let!(:user) { create(:user) } let!(:snippet) { create(:personal_snippet, :public) } let!(:snippet_notes) do @@ -18,7 +18,7 @@ subject { page } - context 'viewing the snippet detail page' do + context 'when viewing the snippet detail page' do it 'contains notes for a snippet with correct action icons' do expect(page).to have_selector('#notes-list li', count: 2) @@ -36,4 +36,64 @@ end end end + + context 'when submitting a note' do + it 'shows a valid form' do + is_expected.to have_css('.js-main-target-form', visible: true, count: 1) + expect(find('.js-main-target-form .js-comment-button').value). + to eq('Comment') + + page.within('.js-main-target-form') do + expect(page).not_to have_link('Cancel') + end + end + + it 'previews a note' do + fill_in 'note[note]', with: 'This is **awesome**!' + find('.js-md-preview-button').click + + page.within('.new-note .md-preview') do + expect(page).to have_content('This is awesome!') + expect(page).to have_selector('strong') + end + end + + it 'creates a note' do + fill_in 'note[note]', with: 'This is **awesome**!' + click_button 'Comment' + + expect(find('div#notes')).to have_content('This is awesome!') + end + end + + context 'when editing a note' do + it 'changes the text' do + page.within("#notes-list li#note_#{snippet_notes[0].id}") do + click_on 'Edit comment' + end + + page.within('.current-note-edit-form') do + fill_in 'note[note]', with: 'new content' + find('.btn-save').click + end + + page.within("#notes-list li#note_#{snippet_notes[0].id}") do + expect(page).to have_css('.note_edited_ago') + expect(page).to have_content('new content') + expect(find('.note_edited_ago').text).to match(/less than a minute ago/) + end + end + end + + context 'when deleting a note' do + it 'removes the note from the snippet detail page' do + page.within("#notes-list li#note_#{snippet_notes[0].id}") do + click_on 'Remove comment' + end + + wait_for_ajax + + expect(page).not_to have_selector("#notes-list li#note_#{snippet_notes[0].id}") + end + end end diff --git a/spec/helpers/notes_helper_spec.rb b/spec/helpers/notes_helper_spec.rb index 6c990f9417508..099146678ae2d 100644 --- a/spec/helpers/notes_helper_spec.rb +++ b/spec/helpers/notes_helper_spec.rb @@ -175,4 +175,79 @@ end end end + + describe '#notes_url' do + it 'return snippet notes path for personal snippet' do + @snippet = create(:personal_snippet) + + expect(helper.notes_url).to eq("/snippets/#{@snippet.id}/notes") + end + + it 'return project notes path for project snippet' do + namespace = create(:namespace, path: 'nm') + @project = create(:empty_project, path: 'test', namespace: namespace) + @snippet = create(:project_snippet, project: @project) + @noteable = @snippet + + expect(helper.notes_url).to eq("/nm/test/noteable/project_snippet/#{@noteable.id}/notes") + end + + it 'return project notes path for other noteables' do + namespace = create(:namespace, path: 'nm') + @project = create(:empty_project, path: 'test', namespace: namespace) + @noteable = create(:issue, project: @project) + + expect(helper.notes_url).to eq("/nm/test/noteable/issue/#{@noteable.id}/notes") + end + end + + describe '#note_url' do + it 'return snippet notes path for personal snippet' do + note = create(:note_on_personal_snippet) + + expect(helper.note_url(note)).to eq("/snippets/#{note.noteable.id}/notes/#{note.id}") + end + + it 'return project notes path for project snippet' do + namespace = create(:namespace, path: 'nm') + @project = create(:empty_project, path: 'test', namespace: namespace) + note = create(:note_on_project_snippet, project: @project) + + expect(helper.note_url(note)).to eq("/nm/test/notes/#{note.id}") + end + + it 'return project notes path for other noteables' do + namespace = create(:namespace, path: 'nm') + @project = create(:empty_project, path: 'test', namespace: namespace) + note = create(:note_on_issue, project: @project) + + expect(helper.note_url(note)).to eq("/nm/test/notes/#{note.id}") + end + end + + describe '#form_resurces' do + it 'returns note for personal snippet' do + @snippet = create(:personal_snippet) + @note = create(:note_on_personal_snippet) + + expect(helper.form_resources).to eq([@note]) + end + + it 'returns namespace, project and note for project snippet' do + namespace = create(:namespace, path: 'nm') + @project = create(:empty_project, path: 'test', namespace: namespace) + @snippet = create(:project_snippet, project: @project) + @note = create(:note_on_personal_snippet) + + expect(helper.form_resources).to eq([@project.namespace, @project, @note]) + end + + it 'returns namespace, project and note path for other noteables' do + namespace = create(:namespace, path: 'nm') + @project = create(:empty_project, path: 'test', namespace: namespace) + @note = create(:note_on_issue, project: @project) + + expect(helper.form_resources).to eq([@project.namespace, @project, @note]) + end + end end diff --git a/spec/services/notes/build_service_spec.rb b/spec/services/notes/build_service_spec.rb index f9dd5541b10b2..133175769ca49 100644 --- a/spec/services/notes/build_service_spec.rb +++ b/spec/services/notes/build_service_spec.rb @@ -29,10 +29,82 @@ expect(new_note.errors[:base]).to include('Discussion to reply to cannot be found') end end + + context 'personal snippet note' do + def reply(note, user = nil) + user ||= create(:user) + + described_class.new(nil, + user, + note: 'Test', + in_reply_to_discussion_id: note.discussion_id).execute + end + + let(:snippet_author) { create(:user) } + + context 'when a snippet is public' do + it 'creates a reply note' do + snippet = create(:personal_snippet, :public) + note = create(:discussion_note_on_personal_snippet, noteable: snippet) + + new_note = reply(note) + + expect(new_note).to be_valid + expect(new_note.in_reply_to?(note)).to be_truthy + end + end + + context 'when a snippet is private' do + let(:snippet) { create(:personal_snippet, :private, author: snippet_author) } + let(:note) { create(:discussion_note_on_personal_snippet, noteable: snippet) } + + it 'creates a reply note when the author replies' do + new_note = reply(note, snippet_author) + + expect(new_note).to be_valid + expect(new_note.in_reply_to?(note)).to be_truthy + end + + it 'sets an error when another user replies' do + new_note = reply(note) + + expect(new_note.errors[:base]).to include('Discussion to reply to cannot be found') + end + end + + context 'when a snippet is internal' do + let(:snippet) { create(:personal_snippet, :internal, author: snippet_author) } + let(:note) { create(:discussion_note_on_personal_snippet, noteable: snippet) } + + it 'creates a reply note when the author replies' do + new_note = reply(note, snippet_author) + + expect(new_note).to be_valid + expect(new_note.in_reply_to?(note)).to be_truthy + end + + it 'creates a reply note when a regular user replies' do + new_note = reply(note) + + expect(new_note).to be_valid + expect(new_note.in_reply_to?(note)).to be_truthy + end + + it 'sets an error when an external user replies' do + new_note = reply(note, create(:user, :external)) + + expect(new_note.errors[:base]).to include('Discussion to reply to cannot be found') + end + end + end end it 'builds a note without saving it' do - new_note = described_class.new(project, author, noteable_type: note.noteable_type, noteable_id: note.noteable_id, note: 'Test').execute + new_note = described_class.new(project, + author, + noteable_type: note.noteable_type, + noteable_id: note.noteable_id, + note: 'Test').execute expect(new_note).to be_valid expect(new_note).not_to be_persisted end diff --git a/spec/views/projects/notes/_form.html.haml_spec.rb b/spec/views/shared/notes/_form.html.haml_spec.rb similarity index 96% rename from spec/views/projects/notes/_form.html.haml_spec.rb rename to spec/views/shared/notes/_form.html.haml_spec.rb index a364f9bce92b0..d7d0a5bf56a9b 100644 --- a/spec/views/projects/notes/_form.html.haml_spec.rb +++ b/spec/views/shared/notes/_form.html.haml_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'projects/notes/_form' do +describe 'shared/notes/_form' do include Devise::Test::ControllerHelpers let(:user) { create(:user) } -- GitLab From 5249157552bbf4cbf279b1decbd4a0e90e056077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Rodr=C3=ADguez?= <alejorro70@gmail.com> Date: Tue, 2 May 2017 18:15:12 -0300 Subject: [PATCH 286/363] Allow gl-repository strings as project identifiers in PostReceive worker --- app/workers/post_receive.rb | 31 ++++++++++++---------- lib/gitlab/git_post_receive.rb | 29 +++------------------ spec/workers/post_receive_spec.rb | 43 +++++++++++++++++++++++-------- 3 files changed, 53 insertions(+), 50 deletions(-) diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 015a41b6e82e9..39f03983821fd 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -2,27 +2,24 @@ class PostReceive include Sidekiq::Worker include DedicatedSidekiqQueue - def perform(repo_path, identifier, changes) - repo_relative_path = Gitlab::RepoPath.strip_storage_path(repo_path) + def perform(project_identifier, identifier, changes) + project, is_wiki = parse_project_identifier(project_identifier) + + if project.nil? + log("Triggered hook for non-existing project with identifier \"#{project_identifier}\"") + return false + end changes = Base64.decode64(changes) unless changes.include?(' ') # Use Sidekiq.logger so arguments can be correlated with execution # time and thread ID's. Sidekiq.logger.info "changes: #{changes.inspect}" if ENV['SIDEKIQ_LOG_ARGUMENTS'] - post_received = Gitlab::GitPostReceive.new(repo_relative_path, identifier, changes) - - if post_received.project.nil? - log("Triggered hook for non-existing project with full path \"#{repo_relative_path}\"") - return false - end + post_received = Gitlab::GitPostReceive.new(project, identifier, changes) - if post_received.wiki? + if is_wiki # Nothing defined here yet. - elsif post_received.regular_project? - process_project_changes(post_received) else - log("Triggered hook for unidentifiable repository type with full path \"#{repo_relative_path}\"") - false + process_project_changes(post_received) end end @@ -47,6 +44,14 @@ def process_project_changes(post_received) private + def parse_project_identifier(project_identifier) + if project_identifier.start_with?('/') + Gitlab::RepoPath.parse(project_identifier) + else + Gitlab::GlRepository.parse(project_identifier) + end + end + def log(message) Gitlab::GitLogger.error("POST-RECEIVE: #{message}") end diff --git a/lib/gitlab/git_post_receive.rb b/lib/gitlab/git_post_receive.rb index 6babea144c790..0e14253ab4e87 100644 --- a/lib/gitlab/git_post_receive.rb +++ b/lib/gitlab/git_post_receive.rb @@ -1,25 +1,12 @@ module Gitlab class GitPostReceive include Gitlab::Identifier - attr_reader :repo_path, :identifier, :changes, :project + attr_reader :project, :identifier, :changes - def initialize(repo_path, identifier, changes) - repo_path.gsub!(/\.git\z/, '') - repo_path.gsub!(/\A\//, '') - - @repo_path = repo_path + def initialize(project, identifier, changes) + @project = project @identifier = identifier @changes = deserialize_changes(changes) - - retrieve_project_and_type - end - - def wiki? - @type == :wiki - end - - def regular_project? - @type == :project end def identify(revision) @@ -28,16 +15,6 @@ def identify(revision) private - def retrieve_project_and_type - @type = :project - @project = Project.find_by_full_path(@repo_path) - - if @repo_path.end_with?('.wiki') && !@project - @type = :wiki - @project = Project.find_by_full_path(@repo_path.gsub(/\.wiki\z/, '')) - end - end - def deserialize_changes(changes) changes = utf8_encode_changes(changes) changes.lines diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb index 5ab3c4a0e341b..0260416dbe2e5 100644 --- a/spec/workers/post_receive_spec.rb +++ b/spec/workers/post_receive_spec.rb @@ -5,6 +5,7 @@ let(:wrongly_encoded_changes) { changes.encode("ISO-8859-1").force_encoding("UTF-8") } let(:base64_changes) { Base64.encode64(wrongly_encoded_changes) } let(:project) { create(:project, :repository) } + let(:project_identifier) { "project-#{project.id}" } let(:key) { create(:key, user: project.owner) } let(:key_id) { key.shell_id } @@ -14,6 +15,26 @@ end end + context 'with a non-existing project' do + let(:project_identifier) { "project-123456789" } + let(:error_message) do + "Triggered hook for non-existing project with identifier \"#{project_identifier}\"" + end + + it "returns false and logs an error" do + expect(Gitlab::GitLogger).to receive(:error).with("POST-RECEIVE: #{error_message}") + expect(described_class.new.perform(project_identifier, key_id, base64_changes)).to be(false) + end + end + + context "with an absolute path as the project identifier" do + it "searches the project by full path" do + expect(Project).to receive(:find_by_full_path).with(project.full_path).and_call_original + + described_class.new.perform(pwd(project), key_id, base64_changes) + end + end + describe "#process_project_changes" do before do allow_any_instance_of(Gitlab::GitPostReceive).to receive(:identify).and_return(project.owner) @@ -25,7 +46,7 @@ it "calls GitTagPushService" do expect_any_instance_of(GitPushService).to receive(:execute).and_return(true) expect_any_instance_of(GitTagPushService).not_to receive(:execute) - described_class.new.perform(pwd(project), key_id, base64_changes) + described_class.new.perform(project_identifier, key_id, base64_changes) end end @@ -35,7 +56,7 @@ it "calls GitTagPushService" do expect_any_instance_of(GitPushService).not_to receive(:execute) expect_any_instance_of(GitTagPushService).to receive(:execute).and_return(true) - described_class.new.perform(pwd(project), key_id, base64_changes) + described_class.new.perform(project_identifier, key_id, base64_changes) end end @@ -45,12 +66,12 @@ it "does not call any of the services" do expect_any_instance_of(GitPushService).not_to receive(:execute) expect_any_instance_of(GitTagPushService).not_to receive(:execute) - described_class.new.perform(pwd(project), key_id, base64_changes) + described_class.new.perform(project_identifier, key_id, base64_changes) end end context "gitlab-ci.yml" do - subject { described_class.new.perform(pwd(project), key_id, base64_changes) } + subject { described_class.new.perform(project_identifier, key_id, base64_changes) } context "creates a Ci::Pipeline for every change" do before do @@ -74,8 +95,8 @@ context "webhook" do it "fetches the correct project" do - expect(Project).to receive(:find_by_full_path).with(project.path_with_namespace).and_return(project) - described_class.new.perform(pwd(project), key_id, base64_changes) + expect(Project).to receive(:find_by).with(id: project.id.to_s) + described_class.new.perform(project_identifier, key_id, base64_changes) end it "does not run if the author is not in the project" do @@ -85,22 +106,22 @@ expect(project).not_to receive(:execute_hooks) - expect(described_class.new.perform(pwd(project), key_id, base64_changes)).to be_falsey + expect(described_class.new.perform(project_identifier, key_id, base64_changes)).to be_falsey end it "asks the project to trigger all hooks" do - allow(Project).to receive(:find_by_full_path).and_return(project) + allow(Project).to receive(:find_by).and_return(project) expect(project).to receive(:execute_hooks).twice expect(project).to receive(:execute_services).twice - described_class.new.perform(pwd(project), key_id, base64_changes) + described_class.new.perform(project_identifier, key_id, base64_changes) end it "enqueues a UpdateMergeRequestsWorker job" do - allow(Project).to receive(:find_by_full_path).and_return(project) + allow(Project).to receive(:find_by).and_return(project) expect(UpdateMergeRequestsWorker).to receive(:perform_async).with(project.id, project.owner.id, any_args) - described_class.new.perform(pwd(project), key_id, base64_changes) + described_class.new.perform(project_identifier, key_id, base64_changes) end end -- GitLab From 8bc381db90c92bca6ba868d1588af1ad1a41073b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Rodr=C3=ADguez?= <alejorro70@gmail.com> Date: Wed, 3 May 2017 18:07:54 -0300 Subject: [PATCH 287/363] Pass GL_REPOSITORY in Workhorse responses --- app/controllers/projects/git_http_controller.rb | 2 +- app/workers/post_receive.rb | 7 +++++++ lib/api/internal.rb | 2 +- lib/gitlab/gl_repository.rb | 4 ++++ lib/gitlab/workhorse.rb | 6 ++++-- spec/lib/gitlab/workhorse_spec.rb | 17 +++++++++++++++-- 6 files changed, 32 insertions(+), 6 deletions(-) diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb index 10adddb463669..9e4edcae10107 100644 --- a/app/controllers/projects/git_http_controller.rb +++ b/app/controllers/projects/git_http_controller.rb @@ -59,7 +59,7 @@ def git_command def render_ok set_workhorse_internal_api_content_type - render json: Gitlab::Workhorse.git_http_ok(repository, user, action_name) + render json: Gitlab::Workhorse.git_http_ok(repository, wiki?, user, action_name) end def render_http_not_allowed diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 39f03983821fd..127d8dfbb6171 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -44,6 +44,13 @@ def process_project_changes(post_received) private + # To maintain backwards compatibility, we accept both gl_repository or + # repository paths as project identifiers. Our plan is to migrate to + # gl_repository only with the following plan: + # 9.2: Handle both possible values. Keep Gitlab-Shell sending only repo paths + # 9.3 (or patch release): Make GitLab Shell pass gl_repository if present + # 9.4 (or patch release): Make GitLab Shell always pass gl_repository + # 9.5 (or patch release): Handle only gl_repository as project identifier on this method def parse_project_identifier(project_identifier) if project_identifier.start_with?('/') Gitlab::RepoPath.parse(project_identifier) diff --git a/lib/api/internal.rb b/lib/api/internal.rb index ddb2047f68621..2a11790b215d4 100644 --- a/lib/api/internal.rb +++ b/lib/api/internal.rb @@ -44,7 +44,7 @@ class Internal < Grape::API # Project id to pass between components that don't share/don't have # access to the same filesystem mounts - response[:gl_repository] = "#{wiki? ? 'wiki' : 'project'}-#{project.id}" + response[:gl_repository] = Gitlab::GlRepository.gl_repository(project, wiki?) # Return the repository full path so that gitlab-shell has it when # handling ssh commands diff --git a/lib/gitlab/gl_repository.rb b/lib/gitlab/gl_repository.rb index 6997546049ebb..07c0abcce235d 100644 --- a/lib/gitlab/gl_repository.rb +++ b/lib/gitlab/gl_repository.rb @@ -1,5 +1,9 @@ module Gitlab module GlRepository + def self.gl_repository(project, is_wiki) + "#{is_wiki ? 'wiki' : 'project'}-#{project.id}" + end + def self.parse(gl_repository) match_data = /\A(project|wiki)-([1-9][0-9]*)\z/.match(gl_repository) unless match_data diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb index c551f939df19a..8c5ad01e8c2ee 100644 --- a/lib/gitlab/workhorse.rb +++ b/lib/gitlab/workhorse.rb @@ -16,15 +16,17 @@ class Workhorse SECRET_LENGTH = 32 class << self - def git_http_ok(repository, user, action) + def git_http_ok(repository, is_wiki, user, action) + project = repository.project repo_path = repository.path_to_repo params = { GL_ID: Gitlab::GlId.gl_id(user), + GL_REPOSITORY: Gitlab::GlRepository.gl_repository(project, is_wiki), RepoPath: repo_path, } if Gitlab.config.gitaly.enabled - address = Gitlab::GitalyClient.get_address(repository.project.repository_storage) + address = Gitlab::GitalyClient.get_address(project.repository_storage) params[:Repository] = repository.gitaly_repository.to_h feature_enabled = case action.to_s diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb index b703e9808a8c3..beb1791a42968 100644 --- a/spec/lib/gitlab/workhorse_spec.rb +++ b/spec/lib/gitlab/workhorse_spec.rb @@ -181,10 +181,23 @@ def call_verify(headers) let(:user) { create(:user) } let(:repo_path) { repository.path_to_repo } let(:action) { 'info_refs' } + let(:params) do + { GL_ID: "user-#{user.id}", GL_REPOSITORY: "project-#{project.id}", RepoPath: repo_path } + end + + subject { described_class.git_http_ok(repository, false, user, action) } + + it { expect(subject).to include(params) } - subject { described_class.git_http_ok(repository, user, action) } + context 'when is_wiki' do + let(:params) do + { GL_ID: "user-#{user.id}", GL_REPOSITORY: "wiki-#{project.id}", RepoPath: repo_path } + end + + subject { described_class.git_http_ok(repository, true, user, action) } - it { expect(subject).to include({ GL_ID: "user-#{user.id}", RepoPath: repo_path }) } + it { expect(subject).to include(params) } + end context 'when Gitaly is enabled' do let(:gitaly_params) do -- GitLab From 79b8323d11cc06911b996f327c6e06fd29cafea4 Mon Sep 17 00:00:00 2001 From: Valery Sizov <valery@gitlab.com> Date: Fri, 5 May 2017 16:57:27 +0300 Subject: [PATCH 288/363] [Multiple issue assignees] Fix issue atom feed --- app/views/issues/_issue.atom.builder | 11 +++++++++-- spec/features/atom/issues_spec.rb | 6 ++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/app/views/issues/_issue.atom.builder b/app/views/issues/_issue.atom.builder index 9ec765c45f927..2ed78bb3b6586 100644 --- a/app/views/issues/_issue.atom.builder +++ b/app/views/issues/_issue.atom.builder @@ -26,9 +26,16 @@ xml.entry do if issue.assignees.any? xml.assignees do issue.assignees.each do |assignee| - xml.name assignee.name - xml.email assignee.public_email + xml.assignee do + xml.name assignee.name + xml.email assignee.public_email + end end end + + xml.assignee do + xml.name issue.assignees.first.name + xml.email issue.assignees.first.public_email + end end end diff --git a/spec/features/atom/issues_spec.rb b/spec/features/atom/issues_spec.rb index 78f8f46a04eb2..4f6754ad54105 100644 --- a/spec/features/atom/issues_spec.rb +++ b/spec/features/atom/issues_spec.rb @@ -22,7 +22,8 @@ to have_content('application/atom+xml') expect(body).to have_selector('title', text: "#{project.name} issues") expect(body).to have_selector('author email', text: issue.author_public_email) - expect(body).to have_selector('assignees email', text: issue.author_public_email) + expect(body).to have_selector('assignees assignee email', text: issue.assignees.first.public_email) + expect(body).to have_selector('assignee email', text: issue.assignees.first.public_email) expect(body).to have_selector('entry summary', text: issue.title) end end @@ -36,7 +37,8 @@ to have_content('application/atom+xml') expect(body).to have_selector('title', text: "#{project.name} issues") expect(body).to have_selector('author email', text: issue.author_public_email) - expect(body).to have_selector('assignees email', text: issue.author_public_email) + expect(body).to have_selector('assignees assignee email', text: issue.assignees.first.public_email) + expect(body).to have_selector('assignee email', text: issue.assignees.first.public_email) expect(body).to have_selector('entry summary', text: issue.title) end end -- GitLab From 4c0adb9ee9ee6631b7cda6562d0e20b7daab8a9e Mon Sep 17 00:00:00 2001 From: James Edwards-Jones <jedwardsjones@gitlab.com> Date: Sun, 16 Apr 2017 02:33:01 +0100 Subject: [PATCH 289/363] Build failures summary page for pipelines --- app/assets/javascripts/dispatcher.js | 1 + app/assets/stylesheets/pages/pipelines.scss | 26 +++++++++++++ .../projects/pipelines_controller.rb | 20 +++++++--- app/helpers/builds_helper.rb | 8 ++++ .../projects/pipelines/_with_tabs.html.haml | 16 +++++++- .../24883-build-failure-summary-page.yml | 4 ++ config/routes/project.rb | 1 + .../projects/pipelines/pipeline_spec.rb | 38 +++++++++++++++++++ 8 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 changelogs/unreleased/24883-build-failure-summary-page.yml diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index f277e1dddc75a..32cc6623c7ede 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -232,6 +232,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); } break; case 'projects:pipelines:builds': + case 'projects:pipelines:failures': case 'projects:pipelines:show': const { controllerAction } = document.querySelector('.js-pipeline-container').dataset; const pipelineStatusUrl = `${document.querySelector('.js-pipeline-tab-link a').getAttribute('href')}/status.json`; diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index a4fe652b52fa6..22500698af3aa 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -316,6 +316,32 @@ } } +.build-failures { + .build-state { + padding: 20px 2px; + + .build-name { + float: right; + font-weight: 500; + } + + .ci-status-icon-failed svg { + vertical-align: middle; + } + + .stage { + color: $gl-text-color-secondary; + font-weight: 500; + vertical-align: middle; + } + } + + .build-log { + border: none; + line-height: initial; + } +} + // Pipeline graph .pipeline-graph { width: 100%; diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 1780cc0233c6c..915f0bc63f768 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -1,6 +1,6 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :pipeline, except: [:index, :new, :create, :charts] - before_action :commit, only: [:show, :builds] + before_action :commit, only: [:show, :builds, :failures] before_action :authorize_read_pipeline! before_action :authorize_create_pipeline!, only: [:new, :create] before_action :authorize_update_pipeline!, only: [:retry, :cancel] @@ -67,11 +67,11 @@ def show end def builds - respond_to do |format| - format.html do - render 'show' - end - end + render_show + end + + def failures + render_show end def status @@ -111,6 +111,14 @@ def charts private + def render_show + respond_to do |format| + format.html do + render 'show' + end + end + end + def create_params params.require(:pipeline).permit(:ref) end diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb index 2fcb7a59fc32a..0145029fb6005 100644 --- a/app/helpers/builds_helper.rb +++ b/app/helpers/builds_helper.rb @@ -1,4 +1,12 @@ module BuildsHelper + def build_summary(build) + if build.has_trace? + build.trace.html(last_lines: 10).html_safe + else + "No job trace" + end + end + def sidebar_build_class(build, current_build) build_class = '' build_class += ' active' if build.id === current_build.id diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index d7cefb8613e4a..76eb8533cc3b6 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -7,8 +7,10 @@ = link_to builds_namespace_project_pipeline_path(@project.namespace, @project, @pipeline), data: {target: 'div#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do Jobs %span.badge.js-builds-counter= pipeline.statuses.count - - + %li.js-failures-tab-link + = link_to failures_namespace_project_pipeline_path(@project.namespace, @project, @pipeline), data: {target: 'div#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do + Failures + %span.badge.js-failures-counter= pipeline.statuses.latest.failed.count .tab-content #js-tab-pipeline.tab-pane @@ -39,3 +41,13 @@ %th Coverage %th = render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage + #js-tab-failures.build-failures.tab-pane + - failed = @pipeline.statuses.latest.failed + - failed.each do |build| + .build-state + %span.ci-status-icon-failed= custom_icon('icon_status_failed') + %span.stage + = build.stage.titleize + %span.build-name + = render "notify/links/#{build.to_partial_path}", pipeline: @pipeline, build: build + %pre.build-log= build_summary(build) diff --git a/changelogs/unreleased/24883-build-failure-summary-page.yml b/changelogs/unreleased/24883-build-failure-summary-page.yml new file mode 100644 index 0000000000000..214cd3e2bc71f --- /dev/null +++ b/changelogs/unreleased/24883-build-failure-summary-page.yml @@ -0,0 +1,4 @@ +--- +title: Added build failures summary page for pipelines +merge_request: 10719 +author: diff --git a/config/routes/project.rb b/config/routes/project.rb index f5009186344eb..5f5b41afbf4c9 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -155,6 +155,7 @@ post :cancel post :retry get :builds + get :failures get :status end end diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb index 5a53e48f5f81d..ab7c002704d0e 100644 --- a/spec/features/projects/pipelines/pipeline_spec.rb +++ b/spec/features/projects/pipelines/pipeline_spec.rb @@ -254,4 +254,42 @@ it { expect(build_manual.reload).to be_pending } end end + + describe 'GET /:project/pipelines/:id/failures' do + let(:project) { create(:project) } + let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) } + let(:pipeline_failures_page) { failures_namespace_project_pipeline_path(project.namespace, project, pipeline) } + let!(:failed_build) { create(:ci_build, :failed, pipeline: pipeline) } + + context 'with failed build' do + before do + failed_build.trace.set('4 examples, 1 failure') + + visit pipeline_failures_page + end + + it 'shows jobs tab pane as active' do + expect(page).to have_css('#js-tab-failures.active') + end + + it 'lists failed builds' do + expect(page).to have_content(failed_build.name) + expect(page).to have_content(failed_build.stage) + end + + it 'shows build failure logs' do + expect(page).to have_content('4 examples, 1 failure') + end + end + + context 'when missing build logs' do + before do + visit pipeline_failures_page + end + + it 'includes failed jobs' do + expect(page).to have_content('No job trace') + end + end + end end -- GitLab From ce418036c763219df8239632f71ef0e9782be7ea Mon Sep 17 00:00:00 2001 From: James Lopez <james@jameslopez.es> Date: Fri, 5 May 2017 16:16:02 +0200 Subject: [PATCH 290/363] add callbacks in bulk --- app/services/projects/propagate_service.rb | 30 +++++++++++++++---- .../projects/propagate_service_spec.rb | 18 +++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/app/services/projects/propagate_service.rb b/app/services/projects/propagate_service.rb index c420f24fe028b..f4fae478609f1 100644 --- a/app/services/projects/propagate_service.rb +++ b/app/services/projects/propagate_service.rb @@ -35,12 +35,12 @@ def bulk_create_from_template(batch) service_hash.merge('project_id' => project_id).values end - # Project.transaction do - # Service.create!(service_hash_list) - # end - Gitlab::SQL::BulkInsert.new(service_hash.keys + ['project_id'], - service_list, - 'services').execute + Project.transaction do + Gitlab::SQL::BulkInsert.new(service_hash.keys + ['project_id'], + service_list, + 'services').execute + run_callbacks(batch) + end end def project_ids_batch @@ -72,5 +72,23 @@ def service_hash end end end + + def run_callbacks(batch) + if active_external_issue_tracker? + Project.where(id: batch).update_all(has_external_issue_tracker: true) + end + + if active_external_wiki? + Project.where(id: batch).update_all(has_external_wiki: true) + end + end + + def active_external_issue_tracker? + @template['category'] == 'issue_tracker' && @template['active'] && !@template['default'] + end + + def active_external_wiki? + @template['type'] == 'ExternalWikiService' && @template['active'] + end end end diff --git a/spec/services/projects/propagate_service_spec.rb b/spec/services/projects/propagate_service_spec.rb index ac25c8b3d561b..b8aa4de5bd17a 100644 --- a/spec/services/projects/propagate_service_spec.rb +++ b/spec/services/projects/propagate_service_spec.rb @@ -78,5 +78,23 @@ to change { Service.count }.by(project_total + 1) end end + + describe 'external tracker' do + it 'updates the project external tracker' do + service_template.update(category: 'issue_tracker', default: false) + + expect { described_class.propagate(service_template) }. + to change { project.reload.has_external_issue_tracker }.to(true) + end + end + + describe 'external wiki' do + it 'updates the project external tracker' do + service_template.update(type: 'ExternalWikiService') + + expect { described_class.propagate(service_template) }. + to change { project.reload.has_external_wiki }.to(true) + end + end end end -- GitLab From 40f51c8e8172933ec511dd828a589abb52d83c45 Mon Sep 17 00:00:00 2001 From: Clement Ho <ClemMakesApps@gmail.com> Date: Fri, 5 May 2017 09:19:58 -0500 Subject: [PATCH 291/363] [skip ci] Fix FE conflicts with master --- .../javascripts/boards/components/issue_card_inner.js | 2 +- app/assets/javascripts/boards/models/assignee.js | 9 --------- app/assets/javascripts/boards/models/issue.js | 9 +-------- .../sidebar/components/assignees/assignees.js | 2 +- config/webpack.config.js | 5 ----- spec/javascripts/boards/issue_card_spec.js | 4 ++-- spec/support/time_tracking_shared_examples.rb | 10 ---------- 7 files changed, 5 insertions(+), 36 deletions(-) diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js index 2f06d186c508b..710207db0c741 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.js +++ b/app/assets/javascripts/boards/components/issue_card_inner.js @@ -157,7 +157,7 @@ gl.issueBoards.IssueCardInner = Vue.extend({ > <img class="avatar avatar-inline s20" - :src="assignee.avatarUrl" + :src="assignee.avatar" width="20" height="20" :alt="avatarUrlTitle(assignee)" diff --git a/app/assets/javascripts/boards/models/assignee.js b/app/assets/javascripts/boards/models/assignee.js index c07a840ec3787..05dd449e4fd8d 100644 --- a/app/assets/javascripts/boards/models/assignee.js +++ b/app/assets/javascripts/boards/models/assignee.js @@ -1,20 +1,11 @@ -<<<<<<< HEAD:app/assets/javascripts/boards/models/assignee.js /* eslint-disable no-unused-vars */ class ListAssignee { - constructor(user) { - this.id = user.id; - this.name = user.name; - this.username = user.username; - this.avatarUrl = user.avatar_url; -======= -class ListUser { constructor(user, defaultAvatar) { this.id = user.id; this.name = user.name; this.username = user.username; this.avatar = user.avatar_url || defaultAvatar; ->>>>>>> 10c1bf2d77fd0ab21309d0b136cbc0ac11f56c77:app/assets/javascripts/boards/models/user.js } } diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index 33c2fab99c725..6c2d8a3781b38 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -18,13 +18,6 @@ class ListIssue { this.selected = false; this.position = obj.relative_position || Infinity; -<<<<<<< HEAD -======= - if (obj.assignee) { - this.assignee = new ListUser(obj.assignee, defaultAvatar); - } - ->>>>>>> 10c1bf2d77fd0ab21309d0b136cbc0ac11f56c77 if (obj.milestone) { this.milestone = new ListMilestone(obj.milestone); } @@ -33,7 +26,7 @@ class ListIssue { this.labels.push(new ListLabel(label)); }); - this.assignees = obj.assignees.map(a => new ListAssignee(a)); + this.assignees = obj.assignees.map(a => new ListAssignee(a, defaultAvatar)); } addLabel (label) { diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.js b/app/assets/javascripts/sidebar/components/assignees/assignees.js index 88d7650f40ac8..7e5feac622c7a 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees.js +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.js @@ -84,7 +84,7 @@ export default { return !this.showLess || (index < this.defaultRenderCount && this.showLess); }, avatarUrl(user) { - return user.avatarUrl || user.avatar_url; + return user.avatar || user.avatar_url; }, assigneeUrl(user) { return `${this.rootPath}${user.username}`; diff --git a/config/webpack.config.js b/config/webpack.config.js index a539dc511fa1a..bc2aa1ebf0cca 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -34,11 +34,6 @@ var config = { graphs: './graphs/graphs_bundle.js', group: './group.js', groups_list: './groups_list.js', -<<<<<<< HEAD -======= - issuable: './issuable/issuable_bundle.js', - locale: './locale/index.js', ->>>>>>> 10c1bf2d77fd0ab21309d0b136cbc0ac11f56c77 issue_show: './issue_show/index.js', locale: './locale/index.js', main: './main.js', diff --git a/spec/javascripts/boards/issue_card_spec.js b/spec/javascripts/boards/issue_card_spec.js index f872ca1040b30..fddde799d01ac 100644 --- a/spec/javascripts/boards/issue_card_spec.js +++ b/spec/javascripts/boards/issue_card_spec.js @@ -148,11 +148,11 @@ describe('Issue card component', () => { describe('assignee default avatar', () => { beforeEach((done) => { - component.issue.assignee = new ListUser({ + component.issue.assignees = [new ListAssignee({ id: 1, name: 'testing 123', username: 'test', - }, 'default_avatar'); + }, 'default_avatar')]; Vue.nextTick(done); }); diff --git a/spec/support/time_tracking_shared_examples.rb b/spec/support/time_tracking_shared_examples.rb index c8e7af19f892e..84ef46ffa27bf 100644 --- a/spec/support/time_tracking_shared_examples.rb +++ b/spec/support/time_tracking_shared_examples.rb @@ -37,12 +37,7 @@ submit_time('/estimate 3w 1d 1h') submit_time('/remove_estimate') -<<<<<<< HEAD page.within '.time-tracking-component-wrap' do -======= - wait_for_ajax - page.within '#issuable-time-tracker' do ->>>>>>> 10c1bf2d77fd0ab21309d0b136cbc0ac11f56c77 expect(page).to have_content 'No estimate or time spent' end end @@ -51,12 +46,7 @@ submit_time('/spend 3w 1d 1h') submit_time('/remove_time_spent') -<<<<<<< HEAD page.within '.time-tracking-component-wrap' do -======= - wait_for_ajax - page.within '#issuable-time-tracker' do ->>>>>>> 10c1bf2d77fd0ab21309d0b136cbc0ac11f56c77 expect(page).to have_content 'No estimate or time spent' end end -- GitLab From 4a681bb19f672e2266f0863db73706977727523a Mon Sep 17 00:00:00 2001 From: Sean McGivern <sean@gitlab.com> Date: Fri, 5 May 2017 15:31:14 +0100 Subject: [PATCH 292/363] Remove FE team label --- CONTRIBUTING.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 600dad563a6a3..8b6c87ae51870 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ _This notice should stay as the first item in the CONTRIBUTING.MD file._ - [Workflow labels](#workflow-labels) - [Type labels (~"feature proposal", ~bug, ~customer, etc.)](#type-labels-feature-proposal-bug-customer-etc) - [Subject labels (~wiki, ~"container registry", ~ldap, ~api, etc.)](#subject-labels-wiki-container-registry-ldap-api-etc) - - [Team labels (~CI, ~Discussion, ~Edge, ~Frontend, ~Platform, etc.)](#team-labels-ci-discussion-edge-frontend-platform-etc) + - [Team labels (~CI, ~Discussion, ~Edge, ~Platform, etc.)](#team-labels-ci-discussion-edge-platform-etc) - [Priority labels (~Deliverable and ~Stretch)](#priority-labels-deliverable-and-stretch) - [Label for community contributors (~"Accepting Merge Requests")](#label-for-community-contributors-accepting-merge-requests) - [Implement design & UI elements](#implement-design--ui-elements) @@ -155,18 +155,21 @@ Examples of subject labels are ~wiki, ~"container registry", ~ldap, ~api, Subject labels are always all-lowercase. -### Team labels (~CI, ~Discussion, ~Edge, ~Frontend, ~Platform, etc.) +### Team labels (~CI, ~Discussion, ~Edge, ~Platform, etc.) Team labels specify what team is responsible for this issue. Assigning a team label makes sure issues get the attention of the appropriate people. The current team labels are ~Build, ~CI, ~Discussion, ~Documentation, ~Edge, -~Frontend, ~Gitaly, ~Platform, ~Prometheus, ~Release, and ~"UX". +~Gitaly, ~Platform, ~Prometheus, ~Release, and ~"UX". The descriptions on the [labels page][labels-page] explain what falls under the responsibility of each team. +Within those team labels, we also have the ~backend and ~frontend labels to +indicate if an issue needs backend work, frontend work, or both. + Team labels are always capitalized so that they show up as the first label for any issue. -- GitLab From acd9cd0906046f3d11d95aa9dc9d1af0498ef2ef Mon Sep 17 00:00:00 2001 From: James Edwards-Jones <jedwardsjones@gitlab.com> Date: Thu, 4 May 2017 17:50:09 +0100 Subject: [PATCH 293/363] =?UTF-8?q?Hides=20pipeline=20=E2=80=98Failed=20Jo?= =?UTF-8?q?bs=E2=80=99=20tab=20when=20no=20jobs=20have=20failed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../projects/pipelines_controller.rb | 6 +++- .../projects/pipelines/_with_tabs.html.haml | 31 ++++++++++--------- .../projects/pipelines/pipeline_spec.rb | 15 +++++++++ 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 915f0bc63f768..3a56be2735adf 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -71,7 +71,11 @@ def builds end def failures - render_show + if @pipeline.statuses.latest.failed.present? + render_show + else + redirect_to pipeline_path(@pipeline) + end end def status diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 76eb8533cc3b6..ba2c71cfd887d 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -1,3 +1,5 @@ +- failed_builds = @pipeline.statuses.latest.failed + .tabs-holder %ul.pipelines-tabs.nav-links.no-top.no-bottom %li.js-pipeline-tab-link @@ -7,10 +9,11 @@ = link_to builds_namespace_project_pipeline_path(@project.namespace, @project, @pipeline), data: {target: 'div#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do Jobs %span.badge.js-builds-counter= pipeline.statuses.count - %li.js-failures-tab-link - = link_to failures_namespace_project_pipeline_path(@project.namespace, @project, @pipeline), data: {target: 'div#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do - Failures - %span.badge.js-failures-counter= pipeline.statuses.latest.failed.count + - if failed_builds.present? + %li.js-failures-tab-link + = link_to failures_namespace_project_pipeline_path(@project.namespace, @project, @pipeline), data: {target: 'div#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do + Failed Jobs + %span.badge.js-failures-counter= failed_builds.count .tab-content #js-tab-pipeline.tab-pane @@ -41,13 +44,13 @@ %th Coverage %th = render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage - #js-tab-failures.build-failures.tab-pane - - failed = @pipeline.statuses.latest.failed - - failed.each do |build| - .build-state - %span.ci-status-icon-failed= custom_icon('icon_status_failed') - %span.stage - = build.stage.titleize - %span.build-name - = render "notify/links/#{build.to_partial_path}", pipeline: @pipeline, build: build - %pre.build-log= build_summary(build) + - if failed_builds.present? + #js-tab-failures.build-failures.tab-pane + - failed_builds.each do |build| + .build-state + %span.ci-status-icon-failed= custom_icon('icon_status_failed') + %span.stage + = build.stage.titleize + %span.build-name + = render "notify/links/#{build.to_partial_path}", pipeline: @pipeline, build: build + %pre.build-log= build_summary(build) diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb index ab7c002704d0e..cfac54ef259ad 100644 --- a/spec/features/projects/pipelines/pipeline_spec.rb +++ b/spec/features/projects/pipelines/pipeline_spec.rb @@ -269,6 +269,7 @@ end it 'shows jobs tab pane as active' do + expect(page).to have_content('Failed Jobs') expect(page).to have_css('#js-tab-failures.active') end @@ -291,5 +292,19 @@ expect(page).to have_content('No job trace') end end + + context 'without failures' do + before do + failed_build.update!(status: :success) + + visit pipeline_failures_page + end + + it 'displays the pipeline graph' do + expect(current_path).to eq(pipeline_path(pipeline)) + expect(page).not_to have_content('Failed Jobs') + expect(page).to have_selector('.pipeline-visualization') + end + end end end -- GitLab From 9f3f22c8959b430811102fb790895e7edd61d3f9 Mon Sep 17 00:00:00 2001 From: James Edwards-Jones <jedwardsjones@gitlab.com> Date: Thu, 4 May 2017 19:30:13 +0100 Subject: [PATCH 294/363] Failed Jobs tab only shows 10 job traces for performance --- app/helpers/builds_helper.rb | 8 ++++++-- app/views/projects/pipelines/_with_tabs.html.haml | 6 +++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb index 0145029fb6005..2eb2c6c738945 100644 --- a/app/helpers/builds_helper.rb +++ b/app/helpers/builds_helper.rb @@ -1,7 +1,11 @@ module BuildsHelper - def build_summary(build) + def build_summary(build, skip: false) if build.has_trace? - build.trace.html(last_lines: 10).html_safe + if skip + link_to "View job trace", pipeline_build_url(build.pipeline, build) + else + build.trace.html(last_lines: 10).html_safe + end else "No job trace" end diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index ba2c71cfd887d..1aa48bf98131b 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -46,11 +46,11 @@ = render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage - if failed_builds.present? #js-tab-failures.build-failures.tab-pane - - failed_builds.each do |build| + - failed_builds.each_with_index do |build, index| .build-state %span.ci-status-icon-failed= custom_icon('icon_status_failed') %span.stage = build.stage.titleize %span.build-name - = render "notify/links/#{build.to_partial_path}", pipeline: @pipeline, build: build - %pre.build-log= build_summary(build) + = link_to build.name, pipeline_build_url(pipeline, build) + %pre.build-log= build_summary(build, skip: index >= 10) -- GitLab From 505e883fc09f344a326437a0f457b1be6106a2b5 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Fri, 5 May 2017 15:42:01 +0100 Subject: [PATCH 295/363] Fixed labels layout on labels milestone tab --- app/assets/stylesheets/pages/labels.scss | 2 +- app/views/shared/milestones/_labels_tab.html.haml | 14 ++++++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index e1ef0b029a59a..c10588ac58e6a 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -116,7 +116,7 @@ } .manage-labels-list { - > li:not(.empty-message) { + > li:not(.empty-message):not(.is-not-draggable) { background-color: $white-light; cursor: move; cursor: -webkit-grab; diff --git a/app/views/shared/milestones/_labels_tab.html.haml b/app/views/shared/milestones/_labels_tab.html.haml index 33f93dccd3c48..a26b3b8009e96 100644 --- a/app/views/shared/milestones/_labels_tab.html.haml +++ b/app/views/shared/milestones/_labels_tab.html.haml @@ -2,7 +2,7 @@ - labels.each do |label| - options = { milestone_title: @milestone.title, label_name: label.title } - %li + %li.is-not-draggable %span.label-row %span.label-name = link_to milestones_label_path(options) do @@ -10,10 +10,8 @@ %span.prepend-description-left = markdown_field(label, :description) - .pull-info-right - %span.append-right-20 - = link_to milestones_label_path(options.merge(state: 'opened')) do - - pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), 'open issue' - %span.append-right-20 - = link_to milestones_label_path(options.merge(state: 'closed')) do - - pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), 'closed issue' + .pull-right.hidden-xs.hidden-sm.hidden-md + = link_to milestones_label_path(options.merge(state: 'opened')), class: 'btn btn-transparent btn-action' do + - pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), 'open issue' + = link_to milestones_label_path(options.merge(state: 'closed')), class: 'btn btn-transparent btn-action' do + - pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), 'closed issue' -- GitLab From cc826c0469202930be835861eeabe0c43390db47 Mon Sep 17 00:00:00 2001 From: James Lopez <james@jameslopez.es> Date: Fri, 5 May 2017 16:45:46 +0200 Subject: [PATCH 296/363] workaround spec failure for mySQL invalid date issue --- lib/gitlab/import_export/import_export.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index 3aac731e84448..5f757f99fb301 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -85,6 +85,8 @@ excluded_attributes: - :id - :star_count - :last_activity_at + - :last_repository_updated_at + - :last_repository_check_at snippets: - :expired_at merge_request_diff: -- GitLab From 513b96f4eedd864b5f56e63fd5342deba53cc870 Mon Sep 17 00:00:00 2001 From: Sam Rose <samrose3@gmail.com> Date: Fri, 5 May 2017 14:47:02 +0000 Subject: [PATCH 297/363] Wrap note actions below timestamp on discussions --- app/assets/stylesheets/pages/notes.scss | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index cfea52c6e5742..69c328d09ffab 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -130,12 +130,6 @@ ul.notes { } .note-header { - padding-bottom: 8px; - padding-right: 20px; - - @media (min-width: $screen-sm-min) { - padding-right: 0; - } @media (max-width: $screen-xs-min) { .inline { @@ -384,10 +378,15 @@ ul.notes { .note-header { display: flex; justify-content: space-between; + + @media (max-width: $screen-xs-max) { + flex-flow: row wrap; + } } .note-header-info { min-width: 0; + padding-bottom: 5px; } .note-headline-light { @@ -435,6 +434,11 @@ ul.notes { margin-left: 10px; color: $gray-darkest; + @media (max-width: $screen-xs-max) { + float: none; + margin-left: 0; + } + .note-action-button { margin-left: 8px; } -- GitLab From e5a7ed3ac36aaa1045353e589dae98a29ca72f1e Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 5 May 2017 15:55:55 +0100 Subject: [PATCH 298/363] Review changes --- app/assets/javascripts/raven/index.js | 2 +- lib/gitlab/gon_helper.rb | 1 - spec/javascripts/raven/index_spec.js | 3 ++- spec/javascripts/raven/raven_config_spec.js | 4 ---- 4 files changed, 3 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/raven/index.js b/app/assets/javascripts/raven/index.js index 3c5656040b9e1..5325e495815f7 100644 --- a/app/assets/javascripts/raven/index.js +++ b/app/assets/javascripts/raven/index.js @@ -5,7 +5,7 @@ const index = function index() { sentryDsn: gon.sentry_dsn, currentUserId: gon.current_user_id, whitelistUrls: [gon.gitlab_url], - isProduction: gon.is_production, + isProduction: process.env.NODE_ENV, }); return RavenConfig; diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index 848b3352c63b7..26473f99bc351 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -12,7 +12,6 @@ def add_gon_variables gon.katex_js_url = ActionController::Base.helpers.asset_path('katex.js') gon.sentry_dsn = current_application_settings.clientside_sentry_dsn if current_application_settings.clientside_sentry_enabled gon.gitlab_url = Gitlab.config.gitlab.url - gon.is_production = Rails.env.production? if current_user gon.current_user_id = current_user.id diff --git a/spec/javascripts/raven/index_spec.js b/spec/javascripts/raven/index_spec.js index 85ec1de4e4efa..b5662cd0331c7 100644 --- a/spec/javascripts/raven/index_spec.js +++ b/spec/javascripts/raven/index_spec.js @@ -18,9 +18,10 @@ describe('RavenConfig options', () => { sentry_dsn: sentryDsn, current_user_id: currentUserId, gitlab_url: gitlabUrl, - is_production: isProduction, }; + process.env.NODE_ENV = isProduction; + spyOn(RavenConfig, 'init'); indexReturnValue = index(); diff --git a/spec/javascripts/raven/raven_config_spec.js b/spec/javascripts/raven/raven_config_spec.js index 2a2b91fe9480c..a2d720760fce6 100644 --- a/spec/javascripts/raven/raven_config_spec.js +++ b/spec/javascripts/raven/raven_config_spec.js @@ -157,10 +157,6 @@ describe('RavenConfig', () => { RavenConfig.bindRavenErrors(); }); - it('should query for document using jquery', () => { - expect(window.$).toHaveBeenCalledWith(document); - }); - it('should call .on', function () { expect($document.on).toHaveBeenCalledWith('ajaxError.raven', RavenConfig.handleRavenErrors); }); -- GitLab From b09460db7c5428ceaafb6d263bb134b4cbfb46a1 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin <godfat@godfat.org> Date: Fri, 5 May 2017 15:13:17 +0000 Subject: [PATCH 299/363] Update CHANGELOG.md for 9.1.3 [ci skip] --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9de0113e2452..e625278a7963a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ entry. ## 9.1.3 (2017-05-05) +- Do not show private groups on subgroups page if user doesn't have access to. - Enforce project features when searching blobs and wikis. - Fixed branches dropdown rendering branch names as HTML. - Make Asciidoc & other markup go through pipeline to prevent XSS. -- GitLab From 9c0f2485b5d7fed5b538313e5bf867110273c004 Mon Sep 17 00:00:00 2001 From: Valery Sizov <valery@gitlab.com> Date: Fri, 5 May 2017 18:22:54 +0300 Subject: [PATCH 300/363] Multiple issue assignee: resolve conflicts after merging upstream --- .../slash_commands/interpret_service.rb | 32 ++++++++----------- .../slash_commands/interpret_service_spec.rb | 2 +- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index 78a6e072f26c5..a7e13648b540f 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -91,41 +91,35 @@ def extractor end desc 'Assign' - explanation do |user| - "Assigns #{user.to_reference}." if user + explanation do |users| + "Assigns #{users.map(&:to_reference).to_sentence}." if users.any? end params '@user' condition do current_user.can?(:"admin_#{issuable.to_ability_name}", project) end -<<<<<<< HEAD - command :assign do |assignee_param| - user_ids = extract_references(assignee_param, :user).map(&:id) + parse_params do |assignee_param| + users = extract_references(assignee_param, :user) - if user_ids.empty? - user_ids = User.where(username: assignee_param.split(' ').map(&:strip)).pluck(:id) + if users.empty? + users = User.where(username: assignee_param.split(' ').map(&:strip)) end - next if user_ids.empty? + users + end + command :assign do |users| + next if users.empty? if issuable.is_a?(Issue) - @updates[:assignee_ids] = user_ids + @updates[:assignee_ids] = users.map(&:id) else - @updates[:assignee_id] = user_ids.last + @updates[:assignee_id] = users.last.id end -======= - parse_params do |assignee_param| - extract_references(assignee_param, :user).first || - User.find_by(username: assignee_param) - end - command :assign do |user| - @updates[:assignee_id] = user.id if user ->>>>>>> 10c1bf2d77fd0ab21309d0b136cbc0ac11f56c77 end desc 'Remove assignee' explanation do - "Removes assignee #{issuable.assignee.to_reference}." + "Removes assignee #{issuable.assignees.first.to_reference}." end condition do issuable.persisted? && diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb index 24f2c1407aa54..e5e400ee281f6 100644 --- a/spec/services/slash_commands/interpret_service_spec.rb +++ b/spec/services/slash_commands/interpret_service_spec.rb @@ -874,7 +874,7 @@ describe 'unassign command' do let(:content) { '/unassign' } - let(:issue) { create(:issue, project: project, assignee: developer) } + let(:issue) { create(:issue, project: project, assignees: [developer]) } it 'includes current assignee reference' do _, explanations = service.explain(content, issue) -- GitLab From 58b560fae0f75f5f5de0960d055791c471d58af7 Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Fri, 5 May 2017 09:33:39 -0600 Subject: [PATCH 301/363] resolve discussion --- app/assets/javascripts/issue_show/index.js | 4 ++-- .../issue_show/issue_title_description.vue | 18 +++++++++--------- app/views/projects/issues/_issue.html.haml | 4 ---- app/views/projects/issues/show.html.haml | 2 +- .../issue_show/issue_title_description_spec.js | 2 -- 5 files changed, 12 insertions(+), 18 deletions(-) diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index 2de072c9778d5..eb20a597bb5a5 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -4,13 +4,13 @@ import '../vue_shared/vue_resource_interceptor'; (() => { const issueTitleData = document.querySelector('.issue-title-data').dataset; - const { canupdateissue, endpoint } = issueTitleData; + const { canUpdateTasksClass, endpoint } = issueTitleData; const vm = new Vue({ el: '.issue-title-entrypoint', render: createElement => createElement(IssueTitle, { props: { - canUpdateIssue: canupdateissue, + canUpdateTasksClass, endpoint, }, }), diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index e88ab69455b54..f3437e2ef8b0e 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -10,7 +10,7 @@ export default { required: true, type: String, }, - canUpdateIssue: { + canUpdateTasksClass: { required: true, type: String, }, @@ -112,19 +112,16 @@ export default { }, }, computed: { - descriptionClass() { - return `description ${this.canUpdateIssue} is-task-list-enabled`; - }, titleAnimationCss() { return { - 'title issue-realtime-pre-pulse': this.titleFlag.pre, - 'title issue-realtime-trigger-pulse': this.titleFlag.pulse, + 'issue-realtime-pre-pulse': this.titleFlag.pre, + 'issue-realtime-trigger-pulse': this.titleFlag.pulse, }; }, descriptionAnimationCss() { return { - 'wiki issue-realtime-pre-pulse': this.descriptionFlag.pre, - 'wiki issue-realtime-trigger-pulse': this.descriptionFlag.pulse, + 'issue-realtime-pre-pulse': this.descriptionFlag.pre, + 'issue-realtime-trigger-pulse': this.descriptionFlag.pulse, }; }, }, @@ -165,16 +162,19 @@ export default { <template> <div> <h2 + class="title" :class="titleAnimationCss" ref="issue-title" v-html="title" > </h2> <div - :class="descriptionClass" + class="description is-task-list-enabled" + :class="canUpdateTasksClass" v-if="description" > <div + class="wiki" :class="descriptionAnimationCss" v-html="description" ref="issue-content-container-gfm-entry" diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 064a7bd1f2606..0e3902c066ad2 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -16,10 +16,6 @@ - if issue.assignee %li = link_to_member(@project, issue.assignee, name: false, title: "Assigned to :name") - - if issue.tasks? - - %span.task-status - = issue.task_status = render 'shared/issuable_meta_data', issuable: issue diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index aa024be1c55d3..a7aefa08aa01b 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -52,7 +52,7 @@ .issue-details.issuable-details .detail-page-description.content-block .issue-title-data.hidden{ "data" => { "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue), - "canUpdateIssue" => can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '', + "can-update-tasks-class" => can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '', } } .issue-title-entrypoint diff --git a/spec/javascripts/issue_show/issue_title_description_spec.js b/spec/javascripts/issue_show/issue_title_description_spec.js index 30455663e5097..1ec4fe58b08b9 100644 --- a/spec/javascripts/issue_show/issue_title_description_spec.js +++ b/spec/javascripts/issue_show/issue_title_description_spec.js @@ -39,7 +39,6 @@ describe('Issue Title', () => { }, }).$mount(); - // need setTimeout because of api call/v-html setTimeout(() => { expect(document.querySelector('title').innerText).toContain('this is a title (#1)'); expect(issueShowComponent.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>'); @@ -57,6 +56,5 @@ describe('Issue Title', () => { done(); }); }); - // 10ms is just long enough for the update hook to fire }); }); -- GitLab From de59bab80989eba44c38a129a235900cf71ff0b5 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 5 May 2017 16:42:56 +0100 Subject: [PATCH 302/363] Another attempt at access_control_ce_spec --- spec/features/protected_tags/access_control_ce_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/features/protected_tags/access_control_ce_spec.rb b/spec/features/protected_tags/access_control_ce_spec.rb index a04fbcdd15fe1..12622cd548a55 100644 --- a/spec/features/protected_tags/access_control_ce_spec.rb +++ b/spec/features/protected_tags/access_control_ce_spec.rb @@ -9,7 +9,7 @@ allowed_to_create_button = find(".js-allowed-to-create") unless allowed_to_create_button.text == access_type_name - allowed_to_create_button.click + allowed_to_create_button.trigger('click') find('.create_access_levels-container .dropdown-menu li', match: :first) within('.create_access_levels-container .dropdown-menu') { click_on access_type_name } end -- GitLab From 0cfe35f7279e7b6618c10be91908c29cd7ab110a Mon Sep 17 00:00:00 2001 From: Valery Sizov <valery@gitlab.com> Date: Fri, 5 May 2017 18:55:16 +0300 Subject: [PATCH 303/363] Multiple issue assignee: CE restriction for multiple assignees --- app/services/issues/base_service.rb | 3 +++ spec/requests/api/issues_spec.rb | 22 +++++++++++++++ .../notes/slash_commands_service_spec.rb | 27 +++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index eedbfa724ff66..bcd196bfa21b7 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -24,6 +24,9 @@ def execute_hooks(issue, action = 'open') def filter_assignee(issuable) return if params[:assignee_ids].blank? + # The number of assignees is limited by one for GitLab CE + params[:assignee_ids].slice!(0, 1) + assignee_ids = params[:assignee_ids].select { |assignee_id| assignee_can_read?(issuable, assignee_id) } if params[:assignee_ids].map(&:to_s) == [IssuableFinder::NONE] diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 9b7b5798b71e2..da2b56c040bc7 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -772,6 +772,17 @@ end end + context 'CE restrictions' do + it 'creates a new project issue with no more than one assignee' do + post api("/projects/#{project.id}/issues", user), + title: 'new issue', assignee_ids: [user2.id, guest.id] + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['assignees'].count).to eq(1) + end + end + it 'creates a new project issue' do post api("/projects/#{project.id}/issues", user), title: 'new issue', labels: 'label, label2', weight: 3, @@ -1111,6 +1122,17 @@ expect(json_response['assignees'].first['name']).to eq(user2.name) end + + context 'CE restrictions' do + it 'updates an issue with several assignee but only one has been applied' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + assignee_ids: [user2.id, guest.id] + + expect(response).to have_http_status(200) + + expect(json_response['assignees'].size).to eq(1) + end + end end describe 'PUT /projects/:id/issues/:issue_iid to update labels' do diff --git a/spec/services/notes/slash_commands_service_spec.rb b/spec/services/notes/slash_commands_service_spec.rb index 0edcc50ed7b22..b99f01162ee34 100644 --- a/spec/services/notes/slash_commands_service_spec.rb +++ b/spec/services/notes/slash_commands_service_spec.rb @@ -220,4 +220,31 @@ let(:note) { build(:note_on_commit, project: project) } end end + + context 'CE restriction for issue assignees' do + describe '/assign' do + let(:project) { create(:empty_project) } + let(:master) { create(:user).tap { |u| project.team << [u, :master] } } + let(:assignee) { create(:user) } + let(:master) { create(:user) } + let(:service) { described_class.new(project, master) } + let(:note) { create(:note_on_issue, note: note_text, project: project) } + + let(:note_text) do + %(/assign @#{assignee.username} @#{master.username}\n") + end + + before do + project.team << [master, :master] + project.team << [assignee, :master] + end + + it 'adds only one assignee from the list' do + content, command_params = service.extract_commands(note) + service.execute(command_params, note) + + expect(note.noteable.assignees.count).to eq(1) + end + end + end end -- GitLab From 6ecf16b8f70335e1e6868b7c918cd031f5eb2f8d Mon Sep 17 00:00:00 2001 From: James Lopez <james@jameslopez.es> Date: Fri, 5 May 2017 18:01:33 +0200 Subject: [PATCH 304/363] refactor code based on feedback --- app/controllers/admin/services_controller.rb | 2 +- ...rvice.rb => propagate_service_template.rb} | 21 +++++++++++++------ ...b => propagate_service_template_worker.rb} | 6 +++--- config/sidekiq_queues.yml | 2 +- lib/gitlab/sql/bulk_insert.rb | 21 ------------------- .../admin/services_controller_spec.rb | 8 +++---- ....rb => propagate_service_template_spec.rb} | 17 +++++++-------- ...propagate_service_template_worker_spec.rb} | 4 ++-- 8 files changed, 34 insertions(+), 47 deletions(-) rename app/services/projects/{propagate_service.rb => propagate_service_template.rb} (77%) rename app/workers/{propagate_project_service_worker.rb => propagate_service_template_worker.rb} (63%) delete mode 100644 lib/gitlab/sql/bulk_insert.rb rename spec/services/projects/{propagate_service_spec.rb => propagate_service_template_spec.rb} (83%) rename spec/workers/{propagate_project_service_worker_spec.rb => propagate_service_template_worker_spec.rb} (79%) diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb index e335fbfffedfd..a40ce3c24182b 100644 --- a/app/controllers/admin/services_controller.rb +++ b/app/controllers/admin/services_controller.rb @@ -16,7 +16,7 @@ def edit def update if service.update_attributes(service_params[:service]) - PropagateProjectServiceWorker.perform_async(service.id) if service.active? + PropagateServiceTemplateWorker.perform_async(service.id) if service.active? redirect_to admin_application_settings_services_path, notice: 'Application settings saved successfully' diff --git a/app/services/projects/propagate_service.rb b/app/services/projects/propagate_service_template.rb similarity index 77% rename from app/services/projects/propagate_service.rb rename to app/services/projects/propagate_service_template.rb index f4fae478609f1..32ad68673acd9 100644 --- a/app/services/projects/propagate_service.rb +++ b/app/services/projects/propagate_service_template.rb @@ -1,5 +1,5 @@ module Projects - class PropagateService + class PropagateServiceTemplate BATCH_SIZE = 100 def self.propagate(*args) @@ -36,9 +36,7 @@ def bulk_create_from_template(batch) end Project.transaction do - Gitlab::SQL::BulkInsert.new(service_hash.keys + ['project_id'], - service_list, - 'services').execute + bulk_insert_services(service_hash.keys + ['project_id'], service_list) run_callbacks(batch) end end @@ -54,11 +52,22 @@ def project_ids_batch WHERE services.project_id = projects.id AND services.type = '#{@template.type}' ) + AND projects.pending_delete = false + AND projects.archived = false LIMIT #{BATCH_SIZE} SQL ) end + def bulk_insert_services(columns, values_array) + ActiveRecord::Base.connection.execute( + <<-SQL.strip_heredoc + INSERT INTO services (#{columns.join(', ')}) + VALUES #{values_array.map { |tuple| "(#{tuple.join(', ')})" }.join(', ')} + SQL + ) + end + def service_hash @service_hash ||= begin @@ -84,11 +93,11 @@ def run_callbacks(batch) end def active_external_issue_tracker? - @template['category'] == 'issue_tracker' && @template['active'] && !@template['default'] + @template.category == :issue_tracker && !@template.default end def active_external_wiki? - @template['type'] == 'ExternalWikiService' && @template['active'] + @template.type == 'ExternalWikiService' end end end diff --git a/app/workers/propagate_project_service_worker.rb b/app/workers/propagate_service_template_worker.rb similarity index 63% rename from app/workers/propagate_project_service_worker.rb rename to app/workers/propagate_service_template_worker.rb index 5eabe4eaecdbc..f1fc7ccb955bf 100644 --- a/app/workers/propagate_project_service_worker.rb +++ b/app/workers/propagate_service_template_worker.rb @@ -1,5 +1,5 @@ # Worker for updating any project specific caches. -class PropagateProjectServiceWorker +class PropagateServiceTemplateWorker include Sidekiq::Worker include DedicatedSidekiqQueue @@ -10,14 +10,14 @@ class PropagateProjectServiceWorker def perform(template_id) return unless try_obtain_lease_for(template_id) - Projects::PropagateService.propagate(Service.find_by(id: template_id)) + Projects::PropagateServiceTemplate.propagate(Service.find_by(id: template_id)) end private def try_obtain_lease_for(template_id) Gitlab::ExclusiveLease. - new("propagate_project_service_worker:#{template_id}", timeout: LEASE_TIMEOUT). + new("propagate_service_template_worker:#{template_id}", timeout: LEASE_TIMEOUT). try_obtain end end diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 91ea1c0f779ba..433381e79d3d5 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -53,4 +53,4 @@ - [pages, 1] - [system_hook_push, 1] - [update_user_activity, 1] - - [propagate_project_service, 1] + - [propagate_service_template, 1] diff --git a/lib/gitlab/sql/bulk_insert.rb b/lib/gitlab/sql/bulk_insert.rb deleted file mode 100644 index 097f9ff237b1e..0000000000000 --- a/lib/gitlab/sql/bulk_insert.rb +++ /dev/null @@ -1,21 +0,0 @@ -module Gitlab - module SQL - # Class for building SQL bulk inserts - class BulkInsert - def initialize(columns, values_array, table) - @columns = columns - @values_array = values_array - @table = table - end - - def execute - ActiveRecord::Base.connection.execute( - <<-SQL.strip_heredoc - INSERT INTO #{@table} (#{@columns.join(', ')}) - VALUES #{@values_array.map { |tuple| "(#{tuple.join(', ')})" }.join(', ')} - SQL - ) - end - end - end -end diff --git a/spec/controllers/admin/services_controller_spec.rb b/spec/controllers/admin/services_controller_spec.rb index 808c98edb7f13..c94616d8508f4 100644 --- a/spec/controllers/admin/services_controller_spec.rb +++ b/spec/controllers/admin/services_controller_spec.rb @@ -39,16 +39,16 @@ ) end - it 'updates the service params successfully and calls the propagation worker' do - expect(PropagateProjectServiceWorker).to receive(:perform_async).with(service.id) + it 'calls the propagation worker when service is active' do + expect(PropagateServiceTemplateWorker).to receive(:perform_async).with(service.id) put :update, id: service.id, service: { active: true } expect(response).to have_http_status(302) end - it 'updates the service params successfully' do - expect(PropagateProjectServiceWorker).not_to receive(:perform_async) + it 'does not call the propagation worker when service is not active' do + expect(PropagateServiceTemplateWorker).not_to receive(:perform_async) put :update, id: service.id, service: { properties: {} } diff --git a/spec/services/projects/propagate_service_spec.rb b/spec/services/projects/propagate_service_template_spec.rb similarity index 83% rename from spec/services/projects/propagate_service_spec.rb rename to spec/services/projects/propagate_service_template_spec.rb index b8aa4de5bd17a..331fb3c5ac53e 100644 --- a/spec/services/projects/propagate_service_spec.rb +++ b/spec/services/projects/propagate_service_template_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' -describe Projects::PropagateService, services: true do - describe '.propagate!' do +describe Projects::PropagateServiceTemplate, services: true do + describe '.propagate' do let!(:service_template) do PushoverService.create( template: true, @@ -23,9 +23,10 @@ end it 'creates services for a project that has another service' do - other_service = BambooService.create( + BambooService.create( template: true, active: true, + project: project, properties: { bamboo_url: 'http://gitlab.com', username: 'mic', @@ -34,8 +35,6 @@ } ) - Service.build_from_template(project.id, other_service).save! - expect { described_class.propagate(service_template) }. to change { Service.count }.by(1) end @@ -62,7 +61,7 @@ it 'creates the service containing the template attributes' do described_class.propagate(service_template) - service = Service.find_by(type: service_template.type, template: false) + service = Service.find_by!(type: service_template.type, template: false) expect(service.properties).to eq(service_template.properties) end @@ -70,7 +69,7 @@ describe 'bulk update' do it 'creates services for all projects' do project_total = 5 - stub_const 'Projects::PropagateService::BATCH_SIZE', 3 + stub_const 'Projects::PropagateServiceTemplate::BATCH_SIZE', 3 project_total.times { create(:empty_project) } @@ -81,7 +80,7 @@ describe 'external tracker' do it 'updates the project external tracker' do - service_template.update(category: 'issue_tracker', default: false) + service_template.update!(category: 'issue_tracker', default: false) expect { described_class.propagate(service_template) }. to change { project.reload.has_external_issue_tracker }.to(true) @@ -90,7 +89,7 @@ describe 'external wiki' do it 'updates the project external tracker' do - service_template.update(type: 'ExternalWikiService') + service_template.update!(type: 'ExternalWikiService') expect { described_class.propagate(service_template) }. to change { project.reload.has_external_wiki }.to(true) diff --git a/spec/workers/propagate_project_service_worker_spec.rb b/spec/workers/propagate_service_template_worker_spec.rb similarity index 79% rename from spec/workers/propagate_project_service_worker_spec.rb rename to spec/workers/propagate_service_template_worker_spec.rb index 4c7edbfcd3e4a..7040d5ef81c97 100644 --- a/spec/workers/propagate_project_service_worker_spec.rb +++ b/spec/workers/propagate_service_template_worker_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe PropagateProjectServiceWorker do +describe PropagateServiceTemplateWorker do let!(:service_template) do PushoverService.create( template: true, @@ -21,7 +21,7 @@ describe '#perform' do it 'calls the propagate service with the template' do - expect(Projects::PropagateService).to receive(:propagate).with(service_template) + expect(Projects::PropagateServiceTemplate).to receive(:propagate).with(service_template) subject.perform(service_template.id) end -- GitLab From 7389bb98167551a850be52e6683ecc93c4734a05 Mon Sep 17 00:00:00 2001 From: Clement Ho <ClemMakesApps@gmail.com> Date: Fri, 5 May 2017 11:02:40 -0500 Subject: [PATCH 305/363] Revert participants style change --- app/assets/stylesheets/pages/issuable.scss | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 485ea369f3d17..c4210ffd8230c 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -444,21 +444,23 @@ } .participants-list { - display: flex; - flex-wrap: wrap; - justify-content: space-between; margin: -5px; } + .user-list { display: flex; flex-wrap: wrap; } .participants-author { - flex-basis: 14%; + display: inline-block; padding: 5px; + &:nth-of-type(7n) { + padding-right: 0; + } + .author_link { display: block; } -- GitLab From f15466bd5bd2ce5390e392785d7c750c176acbec Mon Sep 17 00:00:00 2001 From: James Lopez <james@jameslopez.es> Date: Fri, 5 May 2017 18:18:39 +0200 Subject: [PATCH 306/363] refactor code based on feedback --- app/controllers/admin/services_controller.rb | 2 +- app/services/projects/propagate_service_template.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb index a40ce3c24182b..4c3d336b3aff8 100644 --- a/app/controllers/admin/services_controller.rb +++ b/app/controllers/admin/services_controller.rb @@ -16,7 +16,7 @@ def edit def update if service.update_attributes(service_params[:service]) - PropagateServiceTemplateWorker.perform_async(service.id) if service.active? + PropagateServiceTemplateWorker.perform_async(service.id) if service.active? redirect_to admin_application_settings_services_path, notice: 'Application settings saved successfully' diff --git a/app/services/projects/propagate_service_template.rb b/app/services/projects/propagate_service_template.rb index 32ad68673acd9..2999e1af38505 100644 --- a/app/services/projects/propagate_service_template.rb +++ b/app/services/projects/propagate_service_template.rb @@ -11,7 +11,7 @@ def initialize(template) end def propagate - return unless @template&.active + return unless @template&.active? Rails.logger.info("Propagating services for template #{@template.id}") -- GitLab From 62f7b206b936bbd58d3f18021df37920a5c34ddd Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Fri, 5 May 2017 10:27:31 -0600 Subject: [PATCH 307/363] object in css not computed --- .../issue_show/issue_title_description.vue | 45 ++++++++----------- 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index f3437e2ef8b0e..9dc02bbee7f68 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -29,22 +29,23 @@ export default { }, }); - const defaultFlags = { - pre: true, - pulse: false, - }; - return { poll, apiData: {}, tasks: '0 of 0', title: null, titleText: '', - titleFlag: defaultFlags, + titleFlag: { + pre: true, + pulse: false, + }, description: null, descriptionText: '', descriptionChange: false, - descriptionFlag: defaultFlags, + descriptionFlag: { + pre: true, + pulse: false, + }, timeAgoEl: $('.issue_edited_ago'), titleEl: document.querySelector('title'), }; @@ -60,7 +61,8 @@ export default { elementsToVisualize(noTitleChange, noDescriptionChange) { if (!noTitleChange) { this.titleText = this.apiData.title_text; - this.titleFlag = { pre: true, pulse: false }; + this.titleFlag.pre = true; + this.titleFlag.pulse = false; } if (!noDescriptionChange) { @@ -68,7 +70,8 @@ export default { this.descriptionChange = true; this.updateTaskHTML(); this.tasks = this.apiData.task_status; - this.descriptionFlag = { pre: true, pulse: false }; + this.descriptionFlag.pre = true; + this.descriptionFlag.pulse = false; } }, setTabTitle() { @@ -82,8 +85,10 @@ export default { this.setTabTitle(); this.$nextTick(() => { - this.titleFlag = { pre: false, pulse: true }; - this.descriptionFlag = { pre: false, pulse: true }; + this.titleFlag.pre = false; + this.titleFlag.pulse = true; + this.descriptionFlag.pre = false; + this.descriptionFlag.pulse = true; }); }, triggerAnimation() { @@ -111,20 +116,6 @@ export default { this.timeAgoEl.attr('title', toolTipTime).tooltip('fixTitle'); }, }, - computed: { - titleAnimationCss() { - return { - 'issue-realtime-pre-pulse': this.titleFlag.pre, - 'issue-realtime-trigger-pulse': this.titleFlag.pulse, - }; - }, - descriptionAnimationCss() { - return { - 'issue-realtime-pre-pulse': this.descriptionFlag.pre, - 'issue-realtime-trigger-pulse': this.descriptionFlag.pulse, - }; - }, - }, created() { if (!Visibility.hidden()) { this.poll.makeRequest(); @@ -163,7 +154,7 @@ export default { <div> <h2 class="title" - :class="titleAnimationCss" + :class="{ 'issue-realtime-pre-pulse': titleFlag.pre, 'issue-realtime-trigger-pulse': titleFlag.pulse }" ref="issue-title" v-html="title" > @@ -175,7 +166,7 @@ export default { > <div class="wiki" - :class="descriptionAnimationCss" + :class="{ 'issue-realtime-pre-pulse': descriptionFlag.pre, 'issue-realtime-trigger-pulse': descriptionFlag.pulse }" v-html="description" ref="issue-content-container-gfm-entry" > -- GitLab From 166e3cc5b012cc564707ab9c8a69c6b357dd5567 Mon Sep 17 00:00:00 2001 From: tauriedavis <taurie@gitlab.com> Date: Thu, 4 May 2017 15:58:36 -0700 Subject: [PATCH 308/363] 30903 Vertically align mini pipeline stage container --- app/assets/stylesheets/pages/pipelines.scss | 1 + .../unreleased/30903-vertically-align-mini-pipeline.yml | 4 ++++ 2 files changed, 5 insertions(+) create mode 100644 changelogs/unreleased/30903-vertically-align-mini-pipeline.yml diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 9115d26c77960..f514fdafc7b16 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -273,6 +273,7 @@ .stage-container { display: inline-block; position: relative; + vertical-align: middle; height: 22px; margin: 3px 6px 3px 0; diff --git a/changelogs/unreleased/30903-vertically-align-mini-pipeline.yml b/changelogs/unreleased/30903-vertically-align-mini-pipeline.yml new file mode 100644 index 0000000000000..af87e5ce39f17 --- /dev/null +++ b/changelogs/unreleased/30903-vertically-align-mini-pipeline.yml @@ -0,0 +1,4 @@ +--- +title: Vertically align mini pipeline stage container +merge_request: +author: -- GitLab From 17730a6e1fde0643719c03218e8e582a52e7ad94 Mon Sep 17 00:00:00 2001 From: Annabel Dunstone Gray <annabel.dunstone@gmail.com> Date: Fri, 5 May 2017 08:49:19 -0500 Subject: [PATCH 309/363] Change text of inline issue creation tooltip --- app/views/projects/boards/components/_board.html.haml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/projects/boards/components/_board.html.haml b/app/views/projects/boards/components/_board.html.haml index 5a4eaf92b1613..bc5c727bf0df2 100644 --- a/app/views/projects/boards/components/_board.html.haml +++ b/app/views/projects/boards/components/_board.html.haml @@ -13,8 +13,8 @@ %button.btn.btn-small.btn-default.pull-right.has-tooltip{ type: "button", "@click" => "showNewIssueForm", "v-if" => 'list.type !== "closed"', - "aria-label" => "Add an issue", - "title" => "Add an issue", + "aria-label" => "New issue", + "title" => "New issue", data: { placement: "top", container: "body" } } = icon("plus") - if can?(current_user, :admin_list, @project) -- GitLab From d32e0b31e01b653c3dc2f5fe307119fd4fbff6f4 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 5 May 2017 17:51:18 +0100 Subject: [PATCH 310/363] Trigger click instead of actual click to make sure the right button is always hit --- spec/features/issues/issue_sidebar_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb index 82b80a69bed5f..e980e78f9950a 100644 --- a/spec/features/issues/issue_sidebar_spec.rb +++ b/spec/features/issues/issue_sidebar_spec.rb @@ -152,7 +152,7 @@ def visit_issue(project, issue) end def open_issue_sidebar - find('aside.right-sidebar.right-sidebar-collapsed .js-sidebar-toggle').click + find('aside.right-sidebar.right-sidebar-collapsed .js-sidebar-toggle').trigger('click') find('aside.right-sidebar.right-sidebar-expanded') end end -- GitLab From 856a511b4804a0b78294a29bbba86ac111d960f8 Mon Sep 17 00:00:00 2001 From: James Lopez <james@jameslopez.es> Date: Fri, 5 May 2017 18:57:52 +0200 Subject: [PATCH 311/363] refactor code based on feedback --- .../projects/propagate_service_template.rb | 12 ++++++------ .../propagate_service_template_worker.rb | 2 -- .../propagate_service_template_spec.rb | 18 +++++++++++------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/app/services/projects/propagate_service_template.rb b/app/services/projects/propagate_service_template.rb index 2999e1af38505..a8ef2108492e7 100644 --- a/app/services/projects/propagate_service_template.rb +++ b/app/services/projects/propagate_service_template.rb @@ -11,7 +11,7 @@ def initialize(template) end def propagate - return unless @template&.active? + return unless @template.active? Rails.logger.info("Propagating services for template #{@template.id}") @@ -32,11 +32,11 @@ def propagate_projects_with_template def bulk_create_from_template(batch) service_list = batch.map do |project_id| - service_hash.merge('project_id' => project_id).values + service_hash.values << project_id end Project.transaction do - bulk_insert_services(service_hash.keys + ['project_id'], service_list) + bulk_insert_services(service_hash.keys << 'project_id', service_list) run_callbacks(batch) end end @@ -75,9 +75,9 @@ def service_hash template_hash.each_with_object({}) do |(key, value), service_hash| value = value.is_a?(Hash) ? value.to_json : value - key = Gitlab::Database.postgresql? ? "\"#{key}\"" : "`#{key}`" - service_hash[key] = ActiveRecord::Base.sanitize(value) + service_hash[ActiveRecord::Base.connection.quote_column_name(key)] = + ActiveRecord::Base.sanitize(value) end end end @@ -93,7 +93,7 @@ def run_callbacks(batch) end def active_external_issue_tracker? - @template.category == :issue_tracker && !@template.default + @template.issue_tracker? && !@template.default end def active_external_wiki? diff --git a/app/workers/propagate_service_template_worker.rb b/app/workers/propagate_service_template_worker.rb index f1fc7ccb955bf..5ce0e0405d0ac 100644 --- a/app/workers/propagate_service_template_worker.rb +++ b/app/workers/propagate_service_template_worker.rb @@ -3,8 +3,6 @@ class PropagateServiceTemplateWorker include Sidekiq::Worker include DedicatedSidekiqQueue - sidekiq_options retry: 3 - LEASE_TIMEOUT = 4.hours.to_i def perform(template_id) diff --git a/spec/services/projects/propagate_service_template_spec.rb b/spec/services/projects/propagate_service_template_spec.rb index 331fb3c5ac53e..90eff3bbc1e0b 100644 --- a/spec/services/projects/propagate_service_template_spec.rb +++ b/spec/services/projects/propagate_service_template_spec.rb @@ -18,8 +18,11 @@ let!(:project) { create(:empty_project) } it 'creates services for projects' do - expect { described_class.propagate(service_template) }. - to change { Service.count }.by(1) + expect(project.pushover_service).to be_nil + + described_class.propagate(service_template) + + expect(project.reload.pushover_service).to be_present end it 'creates services for a project that has another service' do @@ -35,8 +38,11 @@ } ) - expect { described_class.propagate(service_template) }. - to change { Service.count }.by(1) + expect(project.pushover_service).to be_nil + + described_class.propagate(service_template) + + expect(project.reload.pushover_service).to be_present end it 'does not create the service if it exists already' do @@ -61,9 +67,7 @@ it 'creates the service containing the template attributes' do described_class.propagate(service_template) - service = Service.find_by!(type: service_template.type, template: false) - - expect(service.properties).to eq(service_template.properties) + expect(project.pushover_service.properties).to eq(service_template.properties) end describe 'bulk update' do -- GitLab From 541c8da0103d008471b8c6389451e6370a3992f3 Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Fri, 5 May 2017 11:07:19 -0600 Subject: [PATCH 312/363] make toggle switch for flags --- .../issue_show/issue_title_description.vue | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue index 9dc02bbee7f68..dc3ba2550c5a5 100644 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ b/app/assets/javascripts/issue_show/issue_title_description.vue @@ -51,6 +51,10 @@ export default { }; }, methods: { + updateFlag(key, toggle) { + this[key].pre = toggle; + this[key].pulse = !toggle; + }, renderResponse(res) { this.apiData = res.json(); this.triggerAnimation(); @@ -61,8 +65,7 @@ export default { elementsToVisualize(noTitleChange, noDescriptionChange) { if (!noTitleChange) { this.titleText = this.apiData.title_text; - this.titleFlag.pre = true; - this.titleFlag.pulse = false; + this.updateFlag('titleFlag', true); } if (!noDescriptionChange) { @@ -70,8 +73,7 @@ export default { this.descriptionChange = true; this.updateTaskHTML(); this.tasks = this.apiData.task_status; - this.descriptionFlag.pre = true; - this.descriptionFlag.pulse = false; + this.updateFlag('descriptionFlag', true); } }, setTabTitle() { @@ -85,10 +87,8 @@ export default { this.setTabTitle(); this.$nextTick(() => { - this.titleFlag.pre = false; - this.titleFlag.pulse = true; - this.descriptionFlag.pre = false; - this.descriptionFlag.pulse = true; + this.updateFlag('titleFlag', false); + this.updateFlag('descriptionFlag', false); }); }, triggerAnimation() { -- GitLab From 0b7aabe302195348e06cea68937457c0c905de6c Mon Sep 17 00:00:00 2001 From: Valery Sizov <valery@gitlab.com> Date: Fri, 5 May 2017 20:06:22 +0300 Subject: [PATCH 313/363] Multiple issue assignee: fix for CE restrictions --- app/services/issues/base_service.rb | 2 +- spec/services/issues/update_service_spec.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index bcd196bfa21b7..34199eb5d1373 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -25,7 +25,7 @@ def filter_assignee(issuable) return if params[:assignee_ids].blank? # The number of assignees is limited by one for GitLab CE - params[:assignee_ids].slice!(0, 1) + params[:assignee_ids] = params[:assignee_ids][0, 1] assignee_ids = params[:assignee_ids].select { |assignee_id| assignee_can_read?(issuable, assignee_id) } diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index 1797a23ee8afd..6633ac10236e4 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -40,7 +40,7 @@ def update_issue(opts) { title: 'New title', description: 'Also please fix', - assignee_ids: [user2.id, user3.id], + assignee_ids: [user2.id], state_event: 'close', label_ids: [label.id], due_date: Date.tomorrow @@ -53,7 +53,7 @@ def update_issue(opts) expect(issue).to be_valid expect(issue.title).to eq 'New title' expect(issue.description).to eq 'Also please fix' - expect(issue.assignees).to match_array([user2, user3]) + expect(issue.assignees).to match_array([user2]) expect(issue).to be_closed expect(issue.labels).to match_array [label] expect(issue.due_date).to eq Date.tomorrow -- GitLab From fc121cca5ba87abd24afbc8da2f76e14e386e4c8 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Fri, 5 May 2017 19:35:32 +0200 Subject: [PATCH 314/363] Do not reprocess actions when user retries pipeline User who is not allowed to trigger manual actions should not be allowed to reprocess / retrigger / retry these actions. --- app/services/ci/retry_pipeline_service.rb | 2 + .../ci/retry_pipeline_service_spec.rb | 44 ++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb index ecc6173a96a55..5b2071573453e 100644 --- a/app/services/ci/retry_pipeline_service.rb +++ b/app/services/ci/retry_pipeline_service.rb @@ -8,6 +8,8 @@ def execute(pipeline) end pipeline.retryable_builds.find_each do |build| + next unless can?(current_user, :update_build, build) + Ci::RetryBuildService.new(project, current_user) .reprocess(build) end diff --git a/spec/services/ci/retry_pipeline_service_spec.rb b/spec/services/ci/retry_pipeline_service_spec.rb index f1b2d3a47985a..40e151545c995 100644 --- a/spec/services/ci/retry_pipeline_service_spec.rb +++ b/spec/services/ci/retry_pipeline_service_spec.rb @@ -7,7 +7,9 @@ let(:service) { described_class.new(project, user) } context 'when user has ability to modify pipeline' do - let(:user) { create(:admin) } + before do + project.add_master(user) + end context 'when there are already retried jobs present' do before do @@ -227,6 +229,46 @@ end end + context 'when user is not allowed to trigger manual action' do + before do + project.add_developer(user) + end + + context 'when there is a failed manual action present' do + before do + create_build('test', :failed, 0) + create_build('deploy', :failed, 0, when: :manual) + create_build('verify', :canceled, 1) + end + + it 'does not reprocess manual action' do + service.execute(pipeline) + + expect(build('test')).to be_pending + expect(build('deploy')).to be_failed + expect(build('verify')).to be_created + expect(pipeline.reload).to be_running + end + end + + context 'when there is a failed manual action in later stage' do + before do + create_build('test', :failed, 0) + create_build('deploy', :failed, 1, when: :manual) + create_build('verify', :canceled, 2) + end + + it 'does not reprocess manual action' do + service.execute(pipeline) + + expect(build('test')).to be_pending + expect(build('deploy')).to be_failed + expect(build('verify')).to be_created + expect(pipeline.reload).to be_running + end + end + end + def statuses pipeline.reload.statuses end -- GitLab From 8a5eaee6e51fa176f53bb1fdb1ecfe3f92e781ba Mon Sep 17 00:00:00 2001 From: Valery Sizov <valery@gitlab.com> Date: Fri, 5 May 2017 20:39:03 +0300 Subject: [PATCH 315/363] Remove wrong changelog --- changelogs/unreleased/update-issue-board-cards-design.yml | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 changelogs/unreleased/update-issue-board-cards-design.yml diff --git a/changelogs/unreleased/update-issue-board-cards-design.yml b/changelogs/unreleased/update-issue-board-cards-design.yml deleted file mode 100644 index 5ef94a74e8a92..0000000000000 --- a/changelogs/unreleased/update-issue-board-cards-design.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Update issue board cards design -merge_request: 10353 -author: -- GitLab From 933447e0da19f9d0be8574185500cabb5d7ab012 Mon Sep 17 00:00:00 2001 From: Valery Sizov <valery@gitlab.com> Date: Fri, 5 May 2017 20:44:19 +0300 Subject: [PATCH 316/363] Address static analyzer warning --- spec/services/notes/slash_commands_service_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/services/notes/slash_commands_service_spec.rb b/spec/services/notes/slash_commands_service_spec.rb index b99f01162ee34..c9954dc360351 100644 --- a/spec/services/notes/slash_commands_service_spec.rb +++ b/spec/services/notes/slash_commands_service_spec.rb @@ -240,7 +240,7 @@ end it 'adds only one assignee from the list' do - content, command_params = service.extract_commands(note) + _, command_params = service.extract_commands(note) service.execute(command_params, note) expect(note.noteable.assignees.count).to eq(1) -- GitLab From bef42d9a36bf465878e0edc19c9e154f73f12b13 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Fri, 5 May 2017 17:59:41 +0000 Subject: [PATCH 317/363] Fallback localstorage cases --- app/assets/javascripts/autosave.js | 44 +++--- .../behaviors/gl_emoji/unicode_support_map.js | 17 ++- .../recent_searches_dropdown_content.js | 12 +- .../filtered_search_manager.js | 11 +- .../filtered_search_visual_tokens.js | 3 + .../filtered_search/recent_searches_root.js | 7 +- .../services/recent_searches_service.js | 14 ++ .../services/recent_searches_service_error.js | 11 ++ app/assets/javascripts/lib/utils/accessor.js | 47 ++++++ .../javascripts/signin_tabs_memoizer.js | 12 +- spec/javascripts/autosave_spec.js | 134 ++++++++++++++++++ .../gl_emoji/unicode_support_map_spec.js | 47 ++++++ .../recent_searches_dropdown_content_spec.js | 20 +++ .../filtered_search_manager_spec.js | 34 +++++ .../recent_searches_root_spec.js | 31 ++++ .../recent_searches_service_error_spec.js | 18 +++ .../services/recent_searches_service_spec.js | 95 ++++++++++++- spec/javascripts/lib/utils/accessor_spec.js | 78 ++++++++++ spec/javascripts/signin_tabs_memoizer_spec.js | 90 ++++++++++++ 19 files changed, 684 insertions(+), 41 deletions(-) create mode 100644 app/assets/javascripts/filtered_search/services/recent_searches_service_error.js create mode 100644 app/assets/javascripts/lib/utils/accessor.js create mode 100644 spec/javascripts/autosave_spec.js create mode 100644 spec/javascripts/behaviors/gl_emoji/unicode_support_map_spec.js create mode 100644 spec/javascripts/filtered_search/recent_searches_root_spec.js create mode 100644 spec/javascripts/filtered_search/services/recent_searches_service_error_spec.js create mode 100644 spec/javascripts/lib/utils/accessor_spec.js diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js index 8630b18a73f96..cfab6c40b34f7 100644 --- a/app/assets/javascripts/autosave.js +++ b/app/assets/javascripts/autosave.js @@ -1,8 +1,11 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-param-reassign, quotes, prefer-template, no-var, one-var, no-unused-vars, one-var-declaration-per-line, no-void, consistent-return, no-empty, max-len */ +import AccessorUtilities from './lib/utils/accessor'; window.Autosave = (function() { function Autosave(field, key) { this.field = field; + this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); + if (key.join != null) { key = key.join("/"); } @@ -17,16 +20,12 @@ window.Autosave = (function() { } Autosave.prototype.restore = function() { - var e, text; - if (window.localStorage == null) { - return; - } - try { - text = window.localStorage.getItem(this.key); - } catch (error) { - e = error; - return; - } + var text; + + if (!this.isLocalStorageAvailable) return; + + text = window.localStorage.getItem(this.key); + if ((text != null ? text.length : void 0) > 0) { this.field.val(text); } @@ -35,27 +34,22 @@ window.Autosave = (function() { Autosave.prototype.save = function() { var text; - if (window.localStorage == null) { - return; - } text = this.field.val(); - if ((text != null ? text.length : void 0) > 0) { - try { - return window.localStorage.setItem(this.key, text); - } catch (error) {} - } else { - return this.reset(); + + if (this.isLocalStorageAvailable && (text != null ? text.length : void 0) > 0) { + return window.localStorage.setItem(this.key, text); } + + return this.reset(); }; Autosave.prototype.reset = function() { - if (window.localStorage == null) { - return; - } - try { - return window.localStorage.removeItem(this.key); - } catch (error) {} + if (!this.isLocalStorageAvailable) return; + + return window.localStorage.removeItem(this.key); }; return Autosave; })(); + +export default window.Autosave; diff --git a/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js b/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js index aa522e20c3603..257df55e54fb4 100644 --- a/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js +++ b/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js @@ -1,3 +1,5 @@ +import AccessorUtilities from '../../lib/utils/accessor'; + const unicodeSupportTestMap = { // man, student (emojione does not have any of these yet), http://emojipedia.org/emoji-zwj-sequences/ // occupationZwj: '\u{1F468}\u{200D}\u{1F393}', @@ -140,16 +142,25 @@ function generateUnicodeSupportMap(testMap) { function getUnicodeSupportMap() { let unicodeSupportMap; - const userAgentFromCache = window.localStorage.getItem('gl-emoji-user-agent'); + let userAgentFromCache; + + const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); + + if (isLocalStorageAvailable) userAgentFromCache = window.localStorage.getItem('gl-emoji-user-agent'); + try { unicodeSupportMap = JSON.parse(window.localStorage.getItem('gl-emoji-unicode-support-map')); } catch (err) { // swallow } + if (!unicodeSupportMap || userAgentFromCache !== navigator.userAgent) { unicodeSupportMap = generateUnicodeSupportMap(unicodeSupportTestMap); - window.localStorage.setItem('gl-emoji-user-agent', navigator.userAgent); - window.localStorage.setItem('gl-emoji-unicode-support-map', JSON.stringify(unicodeSupportMap)); + + if (isLocalStorageAvailable) { + window.localStorage.setItem('gl-emoji-user-agent', navigator.userAgent); + window.localStorage.setItem('gl-emoji-unicode-support-map', JSON.stringify(unicodeSupportMap)); + } } return unicodeSupportMap; diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js index 9126422b33547..15052dbd362f5 100644 --- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js +++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js @@ -8,6 +8,11 @@ export default { type: Array, required: true, }, + isLocalStorageAvailable: { + type: Boolean, + required: false, + default: true, + }, }, computed: { @@ -47,7 +52,12 @@ export default { template: ` <div> - <ul v-if="hasItems"> + <div + v-if="!isLocalStorageAvailable" + class="dropdown-info-note"> + This feature requires local storage to be enabled + </div> + <ul v-else-if="hasItems"> <li v-for="(item, index) in processedItems" :key="index"> diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 36af0674ac668..9fea563370f77 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -1,5 +1,3 @@ -/* global Flash */ - import FilteredSearchContainer from './container'; import RecentSearchesRoot from './recent_searches_root'; import RecentSearchesStore from './stores/recent_searches_store'; @@ -15,7 +13,9 @@ class FilteredSearchManager { this.tokensContainer = this.container.querySelector('.tokens-container'); this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; - this.recentSearchesStore = new RecentSearchesStore(); + this.recentSearchesStore = new RecentSearchesStore({ + isLocalStorageAvailable: RecentSearchesService.isAvailable(), + }); let recentSearchesKey = 'issue-recent-searches'; if (page === 'merge_requests') { recentSearchesKey = 'merge-request-recent-searches'; @@ -24,9 +24,10 @@ class FilteredSearchManager { // Fetch recent searches from localStorage this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch() - .catch(() => { + .catch((error) => { + if (error.name === 'RecentSearchesServiceError') return undefined; // eslint-disable-next-line no-new - new Flash('An error occured while parsing recent searches'); + new window.Flash('An error occured while parsing recent searches'); // Gracefully fail to empty array return []; }) diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index 453ecccc6fc8d..59ce0587e1e40 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -183,6 +183,9 @@ class FilteredSearchVisualTokens { static moveInputToTheRight() { const input = FilteredSearchContainer.container.querySelector('.filtered-search'); + + if (!input) return; + const inputLi = input.parentElement; const tokenContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); diff --git a/app/assets/javascripts/filtered_search/recent_searches_root.js b/app/assets/javascripts/filtered_search/recent_searches_root.js index 4e38409e12a5a..b2e6f63aacf34 100644 --- a/app/assets/javascripts/filtered_search/recent_searches_root.js +++ b/app/assets/javascripts/filtered_search/recent_searches_root.js @@ -29,12 +29,15 @@ class RecentSearchesRoot { } render() { + const state = this.store.state; this.vm = new Vue({ el: this.wrapperElement, - data: this.store.state, + data() { return state; }, template: ` <recent-searches-dropdown-content - :items="recentSearches" /> + :items="recentSearches" + :is-local-storage-available="isLocalStorageAvailable" + /> `, components: { 'recent-searches-dropdown-content': RecentSearchesDropdownContent, diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service.js b/app/assets/javascripts/filtered_search/services/recent_searches_service.js index 3e402d5aed006..a056dea928dcc 100644 --- a/app/assets/javascripts/filtered_search/services/recent_searches_service.js +++ b/app/assets/javascripts/filtered_search/services/recent_searches_service.js @@ -1,9 +1,17 @@ +import RecentSearchesServiceError from './recent_searches_service_error'; +import AccessorUtilities from '../../lib/utils/accessor'; + class RecentSearchesService { constructor(localStorageKey = 'issuable-recent-searches') { this.localStorageKey = localStorageKey; } fetch() { + if (!RecentSearchesService.isAvailable()) { + const error = new RecentSearchesServiceError(); + return Promise.reject(error); + } + const input = window.localStorage.getItem(this.localStorageKey); let searches = []; @@ -19,8 +27,14 @@ class RecentSearchesService { } save(searches = []) { + if (!RecentSearchesService.isAvailable()) return; + window.localStorage.setItem(this.localStorageKey, JSON.stringify(searches)); } + + static isAvailable() { + return AccessorUtilities.isLocalStorageAccessSafe(); + } } export default RecentSearchesService; diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js new file mode 100644 index 0000000000000..5917b223d63fd --- /dev/null +++ b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js @@ -0,0 +1,11 @@ +class RecentSearchesServiceError { + constructor(message) { + this.name = 'RecentSearchesServiceError'; + this.message = message || 'Recent Searches Service is unavailable'; + } +} + +// Can't use `extends` for builtin prototypes and get true inheritance yet +RecentSearchesServiceError.prototype = Error.prototype; + +export default RecentSearchesServiceError; diff --git a/app/assets/javascripts/lib/utils/accessor.js b/app/assets/javascripts/lib/utils/accessor.js new file mode 100644 index 0000000000000..1d18992af6325 --- /dev/null +++ b/app/assets/javascripts/lib/utils/accessor.js @@ -0,0 +1,47 @@ +function isPropertyAccessSafe(base, property) { + let safe; + + try { + safe = !!base[property]; + } catch (error) { + safe = false; + } + + return safe; +} + +function isFunctionCallSafe(base, functionName, ...args) { + let safe = true; + + try { + base[functionName](...args); + } catch (error) { + safe = false; + } + + return safe; +} + +function isLocalStorageAccessSafe() { + let safe; + + const TEST_KEY = 'isLocalStorageAccessSafe'; + const TEST_VALUE = 'true'; + + safe = isPropertyAccessSafe(window, 'localStorage'); + if (!safe) return safe; + + safe = isFunctionCallSafe(window.localStorage, 'setItem', TEST_KEY, TEST_VALUE); + + if (safe) window.localStorage.removeItem(TEST_KEY); + + return safe; +} + +const AccessorUtilities = { + isPropertyAccessSafe, + isFunctionCallSafe, + isLocalStorageAccessSafe, +}; + +export default AccessorUtilities; diff --git a/app/assets/javascripts/signin_tabs_memoizer.js b/app/assets/javascripts/signin_tabs_memoizer.js index d811d1cd53abf..2587facc58225 100644 --- a/app/assets/javascripts/signin_tabs_memoizer.js +++ b/app/assets/javascripts/signin_tabs_memoizer.js @@ -1,5 +1,7 @@ /* eslint no-param-reassign: ["error", { "props": false }]*/ /* eslint no-new: "off" */ +import AccessorUtilities from './lib/utils/accessor'; + ((global) => { /** * Memorize the last selected tab after reloading a page. @@ -9,6 +11,8 @@ constructor({ currentTabKey = 'current_signin_tab', tabSelector = 'ul.nav-tabs' } = {}) { this.currentTabKey = currentTabKey; this.tabSelector = tabSelector; + this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); + this.bootstrap(); } @@ -37,11 +41,15 @@ } saveData(val) { - localStorage.setItem(this.currentTabKey, val); + if (!this.isLocalStorageAvailable) return undefined; + + return window.localStorage.setItem(this.currentTabKey, val); } readData() { - return localStorage.getItem(this.currentTabKey); + if (!this.isLocalStorageAvailable) return null; + + return window.localStorage.getItem(this.currentTabKey); } } diff --git a/spec/javascripts/autosave_spec.js b/spec/javascripts/autosave_spec.js new file mode 100644 index 0000000000000..9f9acc392c286 --- /dev/null +++ b/spec/javascripts/autosave_spec.js @@ -0,0 +1,134 @@ +import Autosave from '~/autosave'; +import AccessorUtilities from '~/lib/utils/accessor'; + +describe('Autosave', () => { + let autosave; + + describe('class constructor', () => { + const key = 'key'; + const field = jasmine.createSpyObj('field', ['data', 'on']); + + beforeEach(() => { + spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.returnValue(true); + spyOn(Autosave.prototype, 'restore'); + + autosave = new Autosave(field, key); + }); + + it('should set .isLocalStorageAvailable', () => { + expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + expect(autosave.isLocalStorageAvailable).toBe(true); + }); + }); + + describe('restore', () => { + const key = 'key'; + const field = jasmine.createSpyObj('field', ['trigger']); + + beforeEach(() => { + autosave = { + field, + key, + }; + + spyOn(window.localStorage, 'getItem'); + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = false; + + Autosave.prototype.restore.call(autosave); + }); + + it('should not call .getItem', () => { + expect(window.localStorage.getItem).not.toHaveBeenCalled(); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = true; + + Autosave.prototype.restore.call(autosave); + }); + + it('should call .getItem', () => { + expect(window.localStorage.getItem).toHaveBeenCalledWith(key); + }); + }); + }); + + describe('save', () => { + const field = jasmine.createSpyObj('field', ['val']); + + beforeEach(() => { + autosave = jasmine.createSpyObj('autosave', ['reset']); + autosave.field = field; + + field.val.and.returnValue('value'); + + spyOn(window.localStorage, 'setItem'); + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = false; + + Autosave.prototype.save.call(autosave); + }); + + it('should not call .setItem', () => { + expect(window.localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = true; + + Autosave.prototype.save.call(autosave); + }); + + it('should call .setItem', () => { + expect(window.localStorage.setItem).toHaveBeenCalled(); + }); + }); + }); + + describe('reset', () => { + const key = 'key'; + + beforeEach(() => { + autosave = { + key, + }; + + spyOn(window.localStorage, 'removeItem'); + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = false; + + Autosave.prototype.reset.call(autosave); + }); + + it('should not call .removeItem', () => { + expect(window.localStorage.removeItem).not.toHaveBeenCalled(); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = true; + + Autosave.prototype.reset.call(autosave); + }); + + it('should call .removeItem', () => { + expect(window.localStorage.removeItem).toHaveBeenCalledWith(key); + }); + }); + }); +}); diff --git a/spec/javascripts/behaviors/gl_emoji/unicode_support_map_spec.js b/spec/javascripts/behaviors/gl_emoji/unicode_support_map_spec.js new file mode 100644 index 0000000000000..1ed96a6747813 --- /dev/null +++ b/spec/javascripts/behaviors/gl_emoji/unicode_support_map_spec.js @@ -0,0 +1,47 @@ +import { getUnicodeSupportMap } from '~/behaviors/gl_emoji/unicode_support_map'; +import AccessorUtilities from '~/lib/utils/accessor'; + +describe('Unicode Support Map', () => { + describe('getUnicodeSupportMap', () => { + const stringSupportMap = 'stringSupportMap'; + + beforeEach(() => { + spyOn(AccessorUtilities, 'isLocalStorageAccessSafe'); + spyOn(window.localStorage, 'getItem'); + spyOn(window.localStorage, 'setItem'); + spyOn(JSON, 'parse'); + spyOn(JSON, 'stringify').and.returnValue(stringSupportMap); + }); + + describe('if isLocalStorageAvailable is `true`', function () { + beforeEach(() => { + AccessorUtilities.isLocalStorageAccessSafe.and.returnValue(true); + + getUnicodeSupportMap(); + }); + + it('should call .getItem and .setItem', () => { + const allArgs = window.localStorage.setItem.calls.allArgs(); + + expect(window.localStorage.getItem).toHaveBeenCalledWith('gl-emoji-user-agent'); + expect(allArgs[0][0]).toBe('gl-emoji-user-agent'); + expect(allArgs[0][1]).toBe(navigator.userAgent); + expect(allArgs[1][0]).toBe('gl-emoji-unicode-support-map'); + expect(allArgs[1][1]).toBe(stringSupportMap); + }); + }); + + describe('if isLocalStorageAvailable is `false`', function () { + beforeEach(() => { + AccessorUtilities.isLocalStorageAccessSafe.and.returnValue(false); + + getUnicodeSupportMap(); + }); + + it('should not call .getItem or .setItem', () => { + expect(window.localStorage.getItem.calls.count()).toBe(1); + expect(window.localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js b/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js index 2722882375f44..d0f09a561d5ab 100644 --- a/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js +++ b/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js @@ -76,6 +76,26 @@ describe('RecentSearchesDropdownContent', () => { }); }); + describe('if isLocalStorageAvailable is `false`', () => { + let el; + + beforeEach(() => { + const props = Object.assign({ isLocalStorageAvailable: false }, propsDataWithItems); + + vm = createComponent(props); + el = vm.$el; + }); + + it('should render an info note', () => { + const note = el.querySelector('.dropdown-info-note'); + const items = el.querySelectorAll('.filtered-search-history-dropdown-item'); + + expect(note).toBeDefined(); + expect(note.innerText.trim()).toBe('This feature requires local storage to be enabled'); + expect(items.length).toEqual(propsDataWithoutItems.items.length); + }); + }); + describe('computed', () => { describe('processedItems', () => { it('with items', () => { diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_manager_spec.js index e747aa497c20b..063d547d00c93 100644 --- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_manager_spec.js @@ -1,3 +1,7 @@ +import * as recentSearchesStoreSrc from '~/filtered_search/stores/recent_searches_store'; +import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; +import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error'; + require('~/lib/utils/url_utility'); require('~/lib/utils/common_utils'); require('~/filtered_search/filtered_search_token_keys'); @@ -60,6 +64,36 @@ describe('Filtered Search Manager', () => { manager.cleanup(); }); + describe('class constructor', () => { + const isLocalStorageAvailable = 'isLocalStorageAvailable'; + let filteredSearchManager; + + beforeEach(() => { + spyOn(RecentSearchesService, 'isAvailable').and.returnValue(isLocalStorageAvailable); + spyOn(recentSearchesStoreSrc, 'default'); + + filteredSearchManager = new gl.FilteredSearchManager(); + + return filteredSearchManager; + }); + + it('should instantiate RecentSearchesStore with isLocalStorageAvailable', () => { + expect(RecentSearchesService.isAvailable).toHaveBeenCalled(); + expect(recentSearchesStoreSrc.default).toHaveBeenCalledWith({ + isLocalStorageAvailable, + }); + }); + + it('should not instantiate Flash if an RecentSearchesServiceError is caught', () => { + spyOn(RecentSearchesService.prototype, 'fetch').and.callFake(() => Promise.reject(new RecentSearchesServiceError())); + spyOn(window, 'Flash'); + + filteredSearchManager = new gl.FilteredSearchManager(); + + expect(window.Flash).not.toHaveBeenCalled(); + }); + }); + describe('search', () => { const defaultParams = '?scope=all&utf8=%E2%9C%93&state=opened'; diff --git a/spec/javascripts/filtered_search/recent_searches_root_spec.js b/spec/javascripts/filtered_search/recent_searches_root_spec.js new file mode 100644 index 0000000000000..d8ba6de5f45d9 --- /dev/null +++ b/spec/javascripts/filtered_search/recent_searches_root_spec.js @@ -0,0 +1,31 @@ +import RecentSearchesRoot from '~/filtered_search/recent_searches_root'; +import * as vueSrc from 'vue'; + +describe('RecentSearchesRoot', () => { + describe('render', () => { + let recentSearchesRoot; + let data; + let template; + + beforeEach(() => { + recentSearchesRoot = { + store: { + state: 'state', + }, + }; + + spyOn(vueSrc, 'default').and.callFake((options) => { + data = options.data; + template = options.template; + }); + + RecentSearchesRoot.prototype.render.call(recentSearchesRoot); + }); + + it('should instantiate Vue', () => { + expect(vueSrc.default).toHaveBeenCalled(); + expect(data()).toBe(recentSearchesRoot.store.state); + expect(template).toContain(':is-local-storage-available="isLocalStorageAvailable"'); + }); + }); +}); diff --git a/spec/javascripts/filtered_search/services/recent_searches_service_error_spec.js b/spec/javascripts/filtered_search/services/recent_searches_service_error_spec.js new file mode 100644 index 0000000000000..ea7c146fa4f01 --- /dev/null +++ b/spec/javascripts/filtered_search/services/recent_searches_service_error_spec.js @@ -0,0 +1,18 @@ +import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error'; + +describe('RecentSearchesServiceError', () => { + let recentSearchesServiceError; + + beforeEach(() => { + recentSearchesServiceError = new RecentSearchesServiceError(); + }); + + it('instantiates an instance of RecentSearchesServiceError and not an Error', () => { + expect(recentSearchesServiceError).toEqual(jasmine.any(RecentSearchesServiceError)); + expect(recentSearchesServiceError.name).toBe('RecentSearchesServiceError'); + }); + + it('should set a default message', () => { + expect(recentSearchesServiceError.message).toBe('Recent Searches Service is unavailable'); + }); +}); diff --git a/spec/javascripts/filtered_search/services/recent_searches_service_spec.js b/spec/javascripts/filtered_search/services/recent_searches_service_spec.js index c255bf7c93996..31fa478804af9 100644 --- a/spec/javascripts/filtered_search/services/recent_searches_service_spec.js +++ b/spec/javascripts/filtered_search/services/recent_searches_service_spec.js @@ -1,6 +1,7 @@ /* eslint-disable promise/catch-or-return */ import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; +import AccessorUtilities from '~/lib/utils/accessor'; describe('RecentSearchesService', () => { let service; @@ -11,6 +12,10 @@ describe('RecentSearchesService', () => { }); describe('fetch', () => { + beforeEach(() => { + spyOn(RecentSearchesService, 'isAvailable').and.returnValue(true); + }); + it('should default to empty array', (done) => { const fetchItemsPromise = service.fetch(); @@ -29,11 +34,21 @@ describe('RecentSearchesService', () => { const fetchItemsPromise = service.fetch(); fetchItemsPromise - .catch(() => { + .catch((error) => { + expect(error).toEqual(jasmine.any(SyntaxError)); done(); }); }); + it('should reject when service is unavailable', (done) => { + RecentSearchesService.isAvailable.and.returnValue(false); + + service.fetch().catch((error) => { + expect(error).toEqual(jasmine.any(Error)); + done(); + }); + }); + it('should return items from localStorage', (done) => { window.localStorage.setItem(service.localStorageKey, '["foo", "bar"]'); const fetchItemsPromise = service.fetch(); @@ -44,15 +59,89 @@ describe('RecentSearchesService', () => { done(); }); }); + + describe('if .isAvailable returns `false`', () => { + beforeEach(() => { + RecentSearchesService.isAvailable.and.returnValue(false); + + spyOn(window.localStorage, 'getItem'); + + RecentSearchesService.prototype.fetch(); + }); + + it('should not call .getItem', () => { + expect(window.localStorage.getItem).not.toHaveBeenCalled(); + }); + }); }); describe('setRecentSearches', () => { + beforeEach(() => { + spyOn(RecentSearchesService, 'isAvailable').and.returnValue(true); + }); + it('should save things in localStorage', () => { const items = ['foo', 'bar']; service.save(items); - const newLocalStorageValue = - window.localStorage.getItem(service.localStorageKey); + const newLocalStorageValue = window.localStorage.getItem(service.localStorageKey); expect(JSON.parse(newLocalStorageValue)).toEqual(items); }); }); + + describe('save', () => { + beforeEach(() => { + spyOn(window.localStorage, 'setItem'); + spyOn(RecentSearchesService, 'isAvailable'); + }); + + describe('if .isAvailable returns `true`', () => { + const searchesString = 'searchesString'; + const localStorageKey = 'localStorageKey'; + const recentSearchesService = { + localStorageKey, + }; + + beforeEach(() => { + RecentSearchesService.isAvailable.and.returnValue(true); + + spyOn(JSON, 'stringify').and.returnValue(searchesString); + + RecentSearchesService.prototype.save.call(recentSearchesService); + }); + + it('should call .setItem', () => { + expect(window.localStorage.setItem).toHaveBeenCalledWith(localStorageKey, searchesString); + }); + }); + + describe('if .isAvailable returns `false`', () => { + beforeEach(() => { + RecentSearchesService.isAvailable.and.returnValue(false); + + RecentSearchesService.prototype.save(); + }); + + it('should not call .setItem', () => { + expect(window.localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + }); + + describe('isAvailable', () => { + let isAvailable; + + beforeEach(() => { + spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.callThrough(); + + isAvailable = RecentSearchesService.isAvailable(); + }); + + it('should call .isLocalStorageAccessSafe', () => { + expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + }); + + it('should return a boolean', () => { + expect(typeof isAvailable).toBe('boolean'); + }); + }); }); diff --git a/spec/javascripts/lib/utils/accessor_spec.js b/spec/javascripts/lib/utils/accessor_spec.js new file mode 100644 index 0000000000000..b768d6f2a68b4 --- /dev/null +++ b/spec/javascripts/lib/utils/accessor_spec.js @@ -0,0 +1,78 @@ +import AccessorUtilities from '~/lib/utils/accessor'; + +describe('AccessorUtilities', () => { + const testError = new Error('test error'); + + describe('isPropertyAccessSafe', () => { + let base; + + it('should return `true` if access is safe', () => { + base = { testProp: 'testProp' }; + + expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(true); + }); + + it('should return `false` if access throws an error', () => { + base = { get testProp() { throw testError; } }; + + expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(false); + }); + + it('should return `false` if property is undefined', () => { + base = {}; + + expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(false); + }); + }); + + describe('isFunctionCallSafe', () => { + const base = {}; + + it('should return `true` if calling is safe', () => { + base.func = () => {}; + + expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(true); + }); + + it('should return `false` if calling throws an error', () => { + base.func = () => { throw new Error('test error'); }; + + expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(false); + }); + + it('should return `false` if function is undefined', () => { + base.func = undefined; + + expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(false); + }); + }); + + describe('isLocalStorageAccessSafe', () => { + beforeEach(() => { + spyOn(window.localStorage, 'setItem'); + spyOn(window.localStorage, 'removeItem'); + }); + + it('should return `true` if access is safe', () => { + expect(AccessorUtilities.isLocalStorageAccessSafe()).toBe(true); + }); + + it('should return `false` if access to .setItem isnt safe', () => { + window.localStorage.setItem.and.callFake(() => { throw testError; }); + + expect(AccessorUtilities.isLocalStorageAccessSafe()).toBe(false); + }); + + it('should set a test item if access is safe', () => { + AccessorUtilities.isLocalStorageAccessSafe(); + + expect(window.localStorage.setItem).toHaveBeenCalledWith('isLocalStorageAccessSafe', 'true'); + }); + + it('should remove the test item if access is safe', () => { + AccessorUtilities.isLocalStorageAccessSafe(); + + expect(window.localStorage.removeItem).toHaveBeenCalledWith('isLocalStorageAccessSafe'); + }); + }); +}); diff --git a/spec/javascripts/signin_tabs_memoizer_spec.js b/spec/javascripts/signin_tabs_memoizer_spec.js index d83d9a57b4246..5b4f5933b34de 100644 --- a/spec/javascripts/signin_tabs_memoizer_spec.js +++ b/spec/javascripts/signin_tabs_memoizer_spec.js @@ -1,3 +1,5 @@ +import AccessorUtilities from '~/lib/utils/accessor'; + require('~/signin_tabs_memoizer'); ((global) => { @@ -19,6 +21,8 @@ require('~/signin_tabs_memoizer'); beforeEach(() => { loadFixtures(fixtureTemplate); + + spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.returnValue(true); }); it('does nothing if no tab was previously selected', () => { @@ -49,5 +53,91 @@ require('~/signin_tabs_memoizer'); expect(memo.readData()).toEqual('#standard'); }); + + describe('class constructor', () => { + beforeEach(() => { + memo = createMemoizer(); + }); + + it('should set .isLocalStorageAvailable', () => { + expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + expect(memo.isLocalStorageAvailable).toBe(true); + }); + }); + + describe('saveData', () => { + beforeEach(() => { + memo = { + currentTabKey, + }; + + spyOn(localStorage, 'setItem'); + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(function () { + memo.isLocalStorageAvailable = false; + + global.ActiveTabMemoizer.prototype.saveData.call(memo); + }); + + it('should not call .setItem', () => { + expect(localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + const value = 'value'; + + beforeEach(function () { + memo.isLocalStorageAvailable = true; + + global.ActiveTabMemoizer.prototype.saveData.call(memo, value); + }); + + it('should call .setItem', () => { + expect(localStorage.setItem).toHaveBeenCalledWith(currentTabKey, value); + }); + }); + }); + + describe('readData', () => { + const itemValue = 'itemValue'; + let readData; + + beforeEach(() => { + memo = { + currentTabKey, + }; + + spyOn(localStorage, 'getItem').and.returnValue(itemValue); + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(function () { + memo.isLocalStorageAvailable = false; + + readData = global.ActiveTabMemoizer.prototype.readData.call(memo); + }); + + it('should not call .getItem and should return `null`', () => { + expect(localStorage.getItem).not.toHaveBeenCalled(); + expect(readData).toBe(null); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + beforeEach(function () { + memo.isLocalStorageAvailable = true; + + readData = global.ActiveTabMemoizer.prototype.readData.call(memo); + }); + + it('should call .getItem and return the localStorage value', () => { + expect(window.localStorage.getItem).toHaveBeenCalledWith(currentTabKey); + expect(readData).toBe(itemValue); + }); + }); + }); }); })(window); -- GitLab From 4b4fc943a3f4e6379cd5f59d2a3879b8d1ebb703 Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Fri, 5 May 2017 13:10:46 -0500 Subject: [PATCH 318/363] Add ruby_parser gem for all environments. It was only available for test and development environments and we need it to run the `rake gettext:pack` command in omnibus which doesn't have the test/dev gems installed. --- Gemfile | 1 + Gemfile.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/Gemfile b/Gemfile index 9426a55861283..5dbb0bdfddb5d 100644 --- a/Gemfile +++ b/Gemfile @@ -257,6 +257,7 @@ gem 'sentry-raven', '~> 2.4.0' gem 'premailer-rails', '~> 1.9.0' # I18n +gem 'ruby_parser', '~> 3.8.4', require: false gem 'gettext_i18n_rails', '~> 1.8.0' gem 'gettext_i18n_rails_js', '~> 1.2.0' gem 'gettext', '~> 3.2.2', require: false, group: :development diff --git a/Gemfile.lock b/Gemfile.lock index c7e3f9935dad2..01c35a935f225 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1012,6 +1012,7 @@ DEPENDENCIES rubocop-rspec (~> 1.15.0) ruby-fogbugz (~> 0.2.1) ruby-prof (~> 0.16.2) + ruby_parser (~> 3.8.4) rufus-scheduler (~> 3.1.10) rugged (~> 0.25.1.1) sanitize (~> 2.0) -- GitLab From c6d8a517dfa1da2f9180073c6aebcdb05ae2553b Mon Sep 17 00:00:00 2001 From: Nick Thomas <nick@gitlab.com> Date: Fri, 5 May 2017 19:16:04 +0100 Subject: [PATCH 319/363] Use GitLab Pages v0.4.2 --- GITLAB_PAGES_VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION index 267577d47e497..2b7c5ae01848a 100644 --- a/GITLAB_PAGES_VERSION +++ b/GITLAB_PAGES_VERSION @@ -1 +1 @@ -0.4.1 +0.4.2 -- GitLab From 48e4991907aa9046c25323af53afc3b7eaa895c4 Mon Sep 17 00:00:00 2001 From: Clement Ho <ClemMakesApps@gmail.com> Date: Fri, 5 May 2017 13:23:31 -0500 Subject: [PATCH 320/363] Add sidebar specs --- .../time_tracking/sidebar_time_tracking.js | 20 +- .../javascripts/sidebar/sidebar_bundle.js | 7 +- .../javascripts/sidebar/sidebar_mediator.js | 4 +- .../sidebar/stores/sidebar_store.js | 4 +- .../sidebar/assignee_title_spec.js | 80 ++++++ spec/javascripts/sidebar/assignees_spec.js | 272 ++++++++++++++++++ spec/javascripts/sidebar/mock_data.js | 109 +++++++ .../sidebar/sidebar_assignees_spec.js | 45 +++ .../sidebar/sidebar_bundle_spec.js | 42 +++ .../sidebar/sidebar_mediator_spec.js | 38 +++ .../sidebar/sidebar_service_spec.js | 28 ++ .../javascripts/sidebar/sidebar_store_spec.js | 80 ++++++ spec/javascripts/sidebar/user_mock_data.js | 16 ++ 13 files changed, 732 insertions(+), 13 deletions(-) create mode 100644 spec/javascripts/sidebar/assignee_title_spec.js create mode 100644 spec/javascripts/sidebar/assignees_spec.js create mode 100644 spec/javascripts/sidebar/mock_data.js create mode 100644 spec/javascripts/sidebar/sidebar_assignees_spec.js create mode 100644 spec/javascripts/sidebar/sidebar_bundle_spec.js create mode 100644 spec/javascripts/sidebar/sidebar_mediator_spec.js create mode 100644 spec/javascripts/sidebar/sidebar_service_spec.js create mode 100644 spec/javascripts/sidebar/sidebar_store_spec.js create mode 100644 spec/javascripts/sidebar/user_mock_data.js diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js index e2dba1fb0c2e7..244b67b3ad926 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js @@ -17,15 +17,21 @@ export default { }, methods: { listenForSlashCommands() { - $(document).on('ajax:success', '.gfm-form', (e, data) => { - const subscribedCommands = ['spend_time', 'time_estimate']; - const changedCommands = data.commands_changes + $(document).on('ajax:success', '.gfm-form', this.slashCommandListened); + }, + slashCommandListened(e, data) { + const subscribedCommands = ['spend_time', 'time_estimate']; + let changedCommands; + if (data !== undefined) { + changedCommands = data.commands_changes ? Object.keys(data.commands_changes) : []; - if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) { - this.mediator.fetch(); - } - }); + } else { + changedCommands = []; + } + if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) { + this.mediator.fetch(); + } }, }, mounted() { diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js index 2ce53c2ed30d5..2b02af87d8ac4 100644 --- a/app/assets/javascripts/sidebar/sidebar_bundle.js +++ b/app/assets/javascripts/sidebar/sidebar_bundle.js @@ -4,7 +4,7 @@ import sidebarAssignees from './components/assignees/sidebar_assignees'; import Mediator from './sidebar_mediator'; -document.addEventListener('DOMContentLoaded', () => { +function domContentLoaded() { const mediator = new Mediator(gl.sidebarOptions); mediator.fetch(); @@ -17,5 +17,8 @@ document.addEventListener('DOMContentLoaded', () => { } new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker'); -}); +} +document.addEventListener('DOMContentLoaded', domContentLoaded); + +export default domContentLoaded; diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index c13f3391f0d0e..5ccfb4ee9c1d3 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -30,8 +30,8 @@ export default class SidebarMediator { this.service.get() .then((response) => { const data = response.json(); - this.store.processAssigneeData(data); - this.store.processTimeTrackingData(data); + this.store.setAssigneeData(data); + this.store.setTimeTrackingData(data); }) .catch(() => new Flash('Error occured when fetching sidebar data')); } diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index 94408c4d71547..2d44c05bb8d24 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -17,13 +17,13 @@ export default class SidebarStore { return SidebarStore.singleton; } - processAssigneeData(data) { + setAssigneeData(data) { if (data.assignees) { this.assignees = data.assignees; } } - processTimeTrackingData(data) { + setTimeTrackingData(data) { this.timeEstimate = data.time_estimate; this.totalTimeSpent = data.total_time_spent; this.humanTimeEstimate = data.human_time_estimate; diff --git a/spec/javascripts/sidebar/assignee_title_spec.js b/spec/javascripts/sidebar/assignee_title_spec.js new file mode 100644 index 0000000000000..5b5b1bf414010 --- /dev/null +++ b/spec/javascripts/sidebar/assignee_title_spec.js @@ -0,0 +1,80 @@ +import Vue from 'vue'; +import AssigneeTitle from '~/sidebar/components/assignees/assignee_title'; + +describe('AssigneeTitle component', () => { + let component; + let AssigneeTitleComponent; + + beforeEach(() => { + AssigneeTitleComponent = Vue.extend(AssigneeTitle); + }); + + describe('assignee title', () => { + it('renders assignee', () => { + component = new AssigneeTitleComponent({ + propsData: { + numberOfAssignees: 1, + editable: false, + }, + }).$mount(); + + expect(component.$el.innerText.trim()).toEqual('Assignee'); + }); + + it('renders 2 assignees', () => { + component = new AssigneeTitleComponent({ + propsData: { + numberOfAssignees: 2, + editable: false, + }, + }).$mount(); + + expect(component.$el.innerText.trim()).toEqual('2 Assignees'); + }); + }); + + it('does not render spinner by default', () => { + component = new AssigneeTitleComponent({ + propsData: { + numberOfAssignees: 0, + editable: false, + }, + }).$mount(); + + expect(component.$el.querySelector('.fa')).toBeNull(); + }); + + it('renders spinner when loading', () => { + component = new AssigneeTitleComponent({ + propsData: { + loading: true, + numberOfAssignees: 0, + editable: false, + }, + }).$mount(); + + expect(component.$el.querySelector('.fa')).not.toBeNull(); + }); + + it('does not render edit link when not editable', () => { + component = new AssigneeTitleComponent({ + propsData: { + numberOfAssignees: 0, + editable: false, + }, + }).$mount(); + + expect(component.$el.querySelector('.edit-link')).toBeNull(); + }); + + it('renders edit link when editable', () => { + component = new AssigneeTitleComponent({ + propsData: { + numberOfAssignees: 0, + editable: true, + }, + }).$mount(); + + expect(component.$el.querySelector('.edit-link')).not.toBeNull(); + }); +}); diff --git a/spec/javascripts/sidebar/assignees_spec.js b/spec/javascripts/sidebar/assignees_spec.js new file mode 100644 index 0000000000000..cf47940ef1c18 --- /dev/null +++ b/spec/javascripts/sidebar/assignees_spec.js @@ -0,0 +1,272 @@ +import Vue from 'vue'; +import Assignee from '~/sidebar/components/assignees/assignees'; +import UsersMock from './mock_data'; +import UsersMockHelper from '../test_helpers/user_mock_data'; + +describe('Assignee component', () => { + let component; + let AssigneeComponent; + + beforeEach(() => { + AssigneeComponent = Vue.extend(Assignee); + }); + + describe('No assignees/users', () => { + it('displays no assignee icon when collapsed', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users: [], + editable: false, + }, + }).$mount(); + + const collapsed = component.$el.querySelector('.sidebar-collapsed-icon'); + expect(collapsed.childElementCount).toEqual(1); + expect(collapsed.children[0].getAttribute('aria-label')).toEqual('No Assignee'); + expect(collapsed.children[0].classList.contains('fa')).toEqual(true); + expect(collapsed.children[0].classList.contains('fa-user')).toEqual(true); + }); + + it('displays only "No assignee" when no users are assigned and the issue is read-only', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users: [], + editable: false, + }, + }).$mount(); + const componentTextNoUsers = component.$el.querySelector('.assign-yourself').innerText.trim(); + + expect(componentTextNoUsers).toBe('No assignee'); + expect(componentTextNoUsers.indexOf('assign yourself')).toEqual(-1); + }); + + it('displays only "No assignee" when no users are assigned and the issue can be edited', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users: [], + editable: true, + }, + }).$mount(); + const componentTextNoUsers = component.$el.querySelector('.assign-yourself').innerText.trim(); + + expect(componentTextNoUsers.indexOf('No assignee')).toEqual(0); + expect(componentTextNoUsers.indexOf('assign yourself')).toBeGreaterThan(0); + }); + + it('emits the assign-self event when "assign yourself" is clicked', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users: [], + editable: true, + }, + }).$mount(); + + spyOn(component, '$emit'); + component.$el.querySelector('.assign-yourself .btn-link').click(); + expect(component.$emit).toHaveBeenCalledWith('assign-self'); + }); + }); + + describe('One assignee/user', () => { + it('displays one assignee icon when collapsed', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users: [ + UsersMock.user, + ], + editable: false, + }, + }).$mount(); + + const collapsed = component.$el.querySelector('.sidebar-collapsed-icon'); + const assignee = collapsed.children[0]; + expect(collapsed.childElementCount).toEqual(1); + expect(assignee.querySelector('.avatar').getAttribute('src')).toEqual(UsersMock.user.avatarUrl); + expect(assignee.querySelector('.avatar').getAttribute('alt')).toEqual(`${UsersMock.user.name}'s avatar`); + expect(assignee.querySelector('.author').innerText.trim()).toEqual(UsersMock.user.name); + }); + + it('Shows one user with avatar, username and author name', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000/', + users: [ + UsersMock.user, + ], + editable: true, + }, + }).$mount(); + + expect(component.$el.querySelector('.author_link')).not.toBeNull(); + // The image + expect(component.$el.querySelector('.author_link img').getAttribute('src')).toEqual(UsersMock.user.avatarUrl); + // Author name + expect(component.$el.querySelector('.author_link .author').innerText.trim()).toEqual(UsersMock.user.name); + // Username + expect(component.$el.querySelector('.author_link .username').innerText.trim()).toEqual(`@${UsersMock.user.username}`); + }); + + it('has the root url present in the assigneeUrl method', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000/', + users: [ + UsersMock.user, + ], + editable: true, + }, + }).$mount(); + + expect(component.assigneeUrl(UsersMock.user).indexOf('http://localhost:3000/')).not.toEqual(-1); + }); + }); + + describe('Two or more assignees/users', () => { + it('displays two assignee icons when collapsed', () => { + const users = UsersMockHelper.createNumberRandomUsers(2); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: false, + }, + }).$mount(); + + const collapsed = component.$el.querySelector('.sidebar-collapsed-icon'); + expect(collapsed.childElementCount).toEqual(2); + + const first = collapsed.children[0]; + expect(first.querySelector('.avatar').getAttribute('src')).toEqual(users[0].avatarUrl); + expect(first.querySelector('.avatar').getAttribute('alt')).toEqual(`${users[0].name}'s avatar`); + expect(first.querySelector('.author').innerText.trim()).toEqual(users[0].name); + + const second = collapsed.children[1]; + expect(second.querySelector('.avatar').getAttribute('src')).toEqual(users[1].avatarUrl); + expect(second.querySelector('.avatar').getAttribute('alt')).toEqual(`${users[1].name}'s avatar`); + expect(second.querySelector('.author').innerText.trim()).toEqual(users[1].name); + }); + + it('displays one assignee icon and counter when collapsed', () => { + const users = UsersMockHelper.createNumberRandomUsers(3); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: false, + }, + }).$mount(); + + const collapsed = component.$el.querySelector('.sidebar-collapsed-icon'); + expect(collapsed.childElementCount).toEqual(2); + + const first = collapsed.children[0]; + expect(first.querySelector('.avatar').getAttribute('src')).toEqual(users[0].avatarUrl); + expect(first.querySelector('.avatar').getAttribute('alt')).toEqual(`${users[0].name}'s avatar`); + expect(first.querySelector('.author').innerText.trim()).toEqual(users[0].name); + + const second = collapsed.children[1]; + expect(second.querySelector('.avatar-counter').innerText.trim()).toEqual('+2'); + }); + + it('Shows two assignees', () => { + const users = UsersMockHelper.createNumberRandomUsers(2); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: true, + }, + }).$mount(); + + expect(component.$el.querySelectorAll('.user-item').length).toEqual(users.length); + expect(component.$el.querySelector('.user-list-more')).toBe(null); + }); + + it('Shows the "show-less" assignees label', (done) => { + const users = UsersMockHelper.createNumberRandomUsers(6); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: true, + }, + }).$mount(); + + expect(component.$el.querySelectorAll('.user-item').length).toEqual(component.defaultRenderCount); + expect(component.$el.querySelector('.user-list-more')).not.toBe(null); + const usersLabelExpectation = users.length - component.defaultRenderCount; + expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()) + .not.toBe(`+${usersLabelExpectation} more`); + component.toggleShowLess(); + Vue.nextTick(() => { + expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()) + .toBe('- show less'); + done(); + }); + }); + + it('Shows the "show-less" when "n+ more " label is clicked', (done) => { + const users = UsersMockHelper.createNumberRandomUsers(6); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: true, + }, + }).$mount(); + + component.$el.querySelector('.user-list-more .btn-link').click(); + Vue.nextTick(() => { + expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()) + .toBe('- show less'); + done(); + }); + }); + + it('gets the count of avatar via a computed property ', () => { + const users = UsersMockHelper.createNumberRandomUsers(6); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: true, + }, + }).$mount(); + + expect(component.sidebarAvatarCounter).toEqual(`+${users.length - 1}`); + }); + + describe('n+ more label', () => { + beforeEach(() => { + const users = UsersMockHelper.createNumberRandomUsers(6); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: true, + }, + }).$mount(); + }); + + it('shows "+1 more" label', () => { + expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()) + .toBe('+ 1 more'); + }); + + it('shows "show less" label', (done) => { + component.toggleShowLess(); + + Vue.nextTick(() => { + expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()) + .toBe('- show less'); + done(); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/sidebar/mock_data.js b/spec/javascripts/sidebar/mock_data.js new file mode 100644 index 0000000000000..0599a63912c71 --- /dev/null +++ b/spec/javascripts/sidebar/mock_data.js @@ -0,0 +1,109 @@ +/* eslint-disable quote-props*/ + +const sidebarMockData = { + 'GET': { + '/gitlab-org/gitlab-shell/issues/5.json': { + id: 45, + iid: 5, + author_id: 23, + description: 'Nulla ullam commodi delectus adipisci quis sit.', + lock_version: null, + milestone_id: 21, + position: 0, + state: 'closed', + title: 'Vel et nulla voluptatibus corporis dolor iste saepe laborum.', + updated_by_id: 1, + created_at: '2017-02-02T21: 49: 49.664Z', + updated_at: '2017-05-03T22: 26: 03.760Z', + deleted_at: null, + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + branch_name: null, + confidential: false, + assignees: [ + { + name: 'User 0', + username: 'user0', + id: 22, + state: 'active', + avatar_url: 'http: //www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80\u0026d=identicon', + web_url: 'http: //localhost:3001/user0', + }, + { + name: 'Marguerite Bartell', + username: 'tajuana', + id: 18, + state: 'active', + avatar_url: 'http: //www.gravatar.com/avatar/4852a41fb41616bf8f140d3701673f53?s=80\u0026d=identicon', + web_url: 'http: //localhost:3001/tajuana', + }, + { + name: 'Laureen Ritchie', + username: 'michaele.will', + id: 16, + state: 'active', + avatar_url: 'http: //www.gravatar.com/avatar/e301827eb03be955c9c172cb9a8e4e8a?s=80\u0026d=identicon', + web_url: 'http: //localhost:3001/michaele.will', + }, + ], + due_date: null, + moved_to_id: null, + project_id: 4, + weight: null, + milestone: { + id: 21, + iid: 1, + project_id: 4, + title: 'v0.0', + description: 'Molestiae commodi laboriosam odio sunt eaque reprehenderit.', + state: 'active', + created_at: '2017-02-02T21: 49: 30.530Z', + updated_at: '2017-02-02T21: 49: 30.530Z', + due_date: null, + start_date: null, + }, + labels: [], + }, + }, + 'PUT': { + '/gitlab-org/gitlab-shell/issues/5.json': { + data: {}, + }, + }, +}; + +export default { + mediator: { + endpoint: '/gitlab-org/gitlab-shell/issues/5.json', + editable: true, + currentUser: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }, + rootPath: '/', + }, + time: { + time_estimate: 3600, + total_time_spent: 0, + human_time_estimate: '1h', + human_total_time_spent: null, + }, + user: { + avatarUrl: 'http://gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: 1, + name: 'Administrator', + username: 'root', + }, + + sidebarMockInterceptor(request, next) { + const body = sidebarMockData[request.method.toUpperCase()][request.url]; + + next(request.respondWith(JSON.stringify(body), { + status: 200, + })); + }, +}; diff --git a/spec/javascripts/sidebar/sidebar_assignees_spec.js b/spec/javascripts/sidebar/sidebar_assignees_spec.js new file mode 100644 index 0000000000000..e0df0a3228f9f --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_assignees_spec.js @@ -0,0 +1,45 @@ +import Vue from 'vue'; +import SidebarAssignees from '~/sidebar/components/assignees/sidebar_assignees'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import Mock from './mock_data'; + +describe('sidebar assignees', () => { + let component; + let SidebarAssigneeComponent; + preloadFixtures('issues/open-issue.html.raw'); + + beforeEach(() => { + Vue.http.interceptors.push(Mock.sidebarMockInterceptor); + SidebarAssigneeComponent = Vue.extend(SidebarAssignees); + spyOn(SidebarMediator.prototype, 'saveAssignees').and.callThrough(); + spyOn(SidebarMediator.prototype, 'assignYourself').and.callThrough(); + this.mediator = new SidebarMediator(Mock.mediator); + loadFixtures('issues/open-issue.html.raw'); + this.sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees'); + }); + + afterEach(() => { + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + }); + + it('calls the mediator when saves the assignees', () => { + component = new SidebarAssigneeComponent() + .$mount(this.sidebarAssigneesEl); + component.saveAssignees(); + + expect(SidebarMediator.prototype.saveAssignees).toHaveBeenCalled(); + }); + + it('calls the mediator when "assignSelf" method is called', () => { + component = new SidebarAssigneeComponent() + .$mount(this.sidebarAssigneesEl); + component.assignSelf(); + + expect(SidebarMediator.prototype.assignYourself).toHaveBeenCalled(); + expect(this.mediator.store.assignees.length).toEqual(1); + }); +}); diff --git a/spec/javascripts/sidebar/sidebar_bundle_spec.js b/spec/javascripts/sidebar/sidebar_bundle_spec.js new file mode 100644 index 0000000000000..7760b34e07198 --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_bundle_spec.js @@ -0,0 +1,42 @@ +import Vue from 'vue'; +import SidebarBundleDomContentLoaded from '~/sidebar/sidebar_bundle'; +import SidebarTimeTracking from '~/sidebar/components/time_tracking/sidebar_time_tracking'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import Mock from './mock_data'; + +describe('sidebar bundle', () => { + gl.sidebarOptions = Mock.mediator; + + beforeEach(() => { + spyOn(SidebarTimeTracking.methods, 'listenForSlashCommands').and.callFake(() => { }); + preloadFixtures('issues/open-issue.html.raw'); + Vue.http.interceptors.push(Mock.sidebarMockInterceptor); + loadFixtures('issues/open-issue.html.raw'); + spyOn(Vue.prototype, '$mount'); + SidebarBundleDomContentLoaded(); + this.mediator = new SidebarMediator(); + }); + + afterEach(() => { + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + }); + + it('the mediator should be already defined with some data', () => { + SidebarBundleDomContentLoaded(); + + expect(this.mediator.store).toBeDefined(); + expect(this.mediator.service).toBeDefined(); + expect(this.mediator.store.currentUser).toEqual(Mock.mediator.currentUser); + expect(this.mediator.store.rootPath).toEqual(Mock.mediator.rootPath); + expect(this.mediator.store.endPoint).toEqual(Mock.mediator.endPoint); + expect(this.mediator.store.editable).toEqual(Mock.mediator.editable); + }); + + it('the sidebar time tracking and assignees components to have been mounted', () => { + expect(Vue.prototype.$mount).toHaveBeenCalledTimes(2); + }); +}); diff --git a/spec/javascripts/sidebar/sidebar_mediator_spec.js b/spec/javascripts/sidebar/sidebar_mediator_spec.js new file mode 100644 index 0000000000000..9bfca0c099103 --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_mediator_spec.js @@ -0,0 +1,38 @@ +import Vue from 'vue'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import Mock from './mock_data'; + +describe('Sidebar mediator', () => { + beforeEach(() => { + Vue.http.interceptors.push(Mock.sidebarMockInterceptor); + this.mediator = new SidebarMediator(Mock.mediator); + }); + + afterEach(() => { + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + }); + + it('assigns yourself ', () => { + this.mediator.assignYourself(); + + expect(this.mediator.store.currentUser).toEqual(Mock.mediator.currentUser); + expect(this.mediator.store.assignees[0]).toEqual(Mock.mediator.currentUser); + }); + + it('saves assignees', (done) => { + this.mediator.saveAssignees('issue[assignee_ids]').then((resp) => { + expect(resp.status).toEqual(200); + done(); + }); + }); + + it('fetches the data', () => { + spyOn(this.mediator.service, 'get').and.callThrough(); + this.mediator.fetch(); + expect(this.mediator.service.get).toHaveBeenCalled(); + }); +}); diff --git a/spec/javascripts/sidebar/sidebar_service_spec.js b/spec/javascripts/sidebar/sidebar_service_spec.js new file mode 100644 index 0000000000000..ed7be76dbcc8d --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_service_spec.js @@ -0,0 +1,28 @@ +import Vue from 'vue'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import Mock from './mock_data'; + +describe('Sidebar service', () => { + beforeEach(() => { + Vue.http.interceptors.push(Mock.sidebarMockInterceptor); + this.service = new SidebarService('/gitlab-org/gitlab-shell/issues/5.json'); + }); + + afterEach(() => { + SidebarService.singleton = null; + }); + + it('gets the data', (done) => { + this.service.get().then((resp) => { + expect(resp).toBeDefined(); + done(); + }); + }); + + it('updates the data', (done) => { + this.service.update('issue[assignee_ids]', [1]).then((resp) => { + expect(resp).toBeDefined(); + done(); + }); + }); +}); diff --git a/spec/javascripts/sidebar/sidebar_store_spec.js b/spec/javascripts/sidebar/sidebar_store_spec.js new file mode 100644 index 0000000000000..6c0f2b6a793f8 --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_store_spec.js @@ -0,0 +1,80 @@ +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import Mock from './mock_data'; +import UsersMockHelper from '../test_helpers/user_mock_data'; + +describe('Sidebar store', () => { + const assignee = { + id: 2, + name: 'gitlab user 2', + username: 'gitlab2', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }; + + const anotherAssignee = { + id: 3, + name: 'gitlab user 3', + username: 'gitlab3', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }; + + beforeEach(() => { + this.store = new SidebarStore({ + currentUser: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }, + editable: true, + rootPath: '/', + endpoint: '/gitlab-org/gitlab-shell/issues/5.json', + }); + }); + + afterEach(() => { + SidebarStore.singleton = null; + }); + + it('adds a new assignee', () => { + this.store.addAssignee(assignee); + expect(this.store.assignees.length).toEqual(1); + }); + + it('removes an assignee', () => { + this.store.removeAssignee(assignee); + expect(this.store.assignees.length).toEqual(0); + }); + + it('finds an existent assignee', () => { + let foundAssignee; + + this.store.addAssignee(assignee); + foundAssignee = this.store.findAssignee(assignee); + expect(foundAssignee).toBeDefined(); + expect(foundAssignee).toEqual(assignee); + foundAssignee = this.store.findAssignee(anotherAssignee); + expect(foundAssignee).toBeUndefined(); + }); + + it('removes all assignees', () => { + this.store.removeAllAssignees(); + expect(this.store.assignees.length).toEqual(0); + }); + + it('set assigned data', () => { + const users = { + assignees: UsersMockHelper.createNumberRandomUsers(3), + }; + + this.store.setAssigneeData(users); + expect(this.store.assignees.length).toEqual(3); + }); + + it('set time tracking data', () => { + this.store.setTimeTrackingData(Mock.time); + expect(this.store.timeEstimate).toEqual(Mock.time.time_estimate); + expect(this.store.totalTimeSpent).toEqual(Mock.time.total_time_spent); + expect(this.store.humanTimeEstimate).toEqual(Mock.time.human_time_estimate); + expect(this.store.humanTotalTimeSpent).toEqual(Mock.time.human_total_time_spent); + }); +}); diff --git a/spec/javascripts/sidebar/user_mock_data.js b/spec/javascripts/sidebar/user_mock_data.js new file mode 100644 index 0000000000000..9e7b834623878 --- /dev/null +++ b/spec/javascripts/sidebar/user_mock_data.js @@ -0,0 +1,16 @@ +export default { + createNumberRandomUsers(numberUsers) { + const users = []; + for (let i = 0; i < numberUsers; i = i += 1) { + users.push( + { + avatarUrl: 'http://gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: (i + 1), + name: `GitLab User ${i}`, + username: `gitlab${i}`, + }, + ); + } + return users; + }, +}; -- GitLab From 8985ea1b9c649183e997a76c32aca927a258b51e Mon Sep 17 00:00:00 2001 From: Regis <boudinot.regis@yahoo.com> Date: Fri, 5 May 2017 12:28:06 -0600 Subject: [PATCH 321/363] add changelog [ci skip] --- changelogs/unreleased/issue-title-description-realtime.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelogs/unreleased/issue-title-description-realtime.yml diff --git a/changelogs/unreleased/issue-title-description-realtime.yml b/changelogs/unreleased/issue-title-description-realtime.yml new file mode 100644 index 0000000000000..003e1a4ab334e --- /dev/null +++ b/changelogs/unreleased/issue-title-description-realtime.yml @@ -0,0 +1,4 @@ +--- +title: Add realtime descriptions to issue show pages +merge_request: +author: -- GitLab From f4a2dfb46f168d3fd7309aca8631cf680456fa82 Mon Sep 17 00:00:00 2001 From: Michael Kozono <mkozono@gmail.com> Date: Fri, 21 Apr 2017 14:05:19 -0700 Subject: [PATCH 322/363] Add happy path feature tests for redirect behavior --- spec/features/groups/group_settings_spec.rb | 77 +++++++++ spec/features/profiles/account_spec.rb | 60 +++++++ .../projects/project_settings_spec.rb | 146 ++++++++++++++---- 3 files changed, 255 insertions(+), 28 deletions(-) create mode 100644 spec/features/groups/group_settings_spec.rb create mode 100644 spec/features/profiles/account_spec.rb diff --git a/spec/features/groups/group_settings_spec.rb b/spec/features/groups/group_settings_spec.rb new file mode 100644 index 0000000000000..f18f2f2310f03 --- /dev/null +++ b/spec/features/groups/group_settings_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' + +feature 'Edit group settings', feature: true do + given(:user) { create(:user) } + given(:group) { create(:group, path: 'foo') } + + background do + group.add_owner(user) + login_as(user) + end + + describe 'when the group path is changed' do + let(:new_group_path) { 'bar' } + let(:old_group_full_path) { "/#{group.path}" } + let(:new_group_full_path) { "/#{new_group_path}" } + + scenario 'the group is accessible via the new path' do + update_path(new_group_path) + visit new_group_full_path + expect(current_path).to eq(new_group_full_path) + expect(find('h1.group-title')).to have_content(new_group_path) + end + + scenario 'the old group path redirects to the new path' do + update_path(new_group_path) + visit old_group_full_path + expect(current_path).to eq(new_group_full_path) + expect(find('h1.group-title')).to have_content(new_group_path) + end + + context 'with a subgroup' do + given!(:subgroup) { create(:group, parent: group, path: 'subgroup') } + given(:old_subgroup_full_path) { "/#{group.path}/#{subgroup.path}" } + given(:new_subgroup_full_path) { "/#{new_group_path}/#{subgroup.path}" } + + scenario 'the subgroup is accessible via the new path' do + update_path(new_group_path) + visit new_subgroup_full_path + expect(current_path).to eq(new_subgroup_full_path) + expect(find('h1.group-title')).to have_content(subgroup.path) + end + + scenario 'the old subgroup path redirects to the new path' do + update_path(new_group_path) + visit old_subgroup_full_path + expect(current_path).to eq(new_subgroup_full_path) + expect(find('h1.group-title')).to have_content(subgroup.path) + end + end + + context 'with a project' do + given!(:project) { create(:project, group: group, path: 'project') } + given(:old_project_full_path) { "/#{group.path}/#{project.path}" } + given(:new_project_full_path) { "/#{new_group_path}/#{project.path}" } + + scenario 'the project is accessible via the new path' do + update_path(new_group_path) + visit new_project_full_path + expect(current_path).to eq(new_project_full_path) + expect(find('h1.project-title')).to have_content(project.name) + end + + scenario 'the old project path redirects to the new path' do + update_path(new_group_path) + visit old_project_full_path + expect(current_path).to eq(new_project_full_path) + expect(find('h1.project-title')).to have_content(project.name) + end + end + end +end + +def update_path(new_group_path) + visit edit_group_path(group) + fill_in 'group_path', with: new_group_path + click_button 'Save group' +end diff --git a/spec/features/profiles/account_spec.rb b/spec/features/profiles/account_spec.rb new file mode 100644 index 0000000000000..b3902a6b37f7b --- /dev/null +++ b/spec/features/profiles/account_spec.rb @@ -0,0 +1,60 @@ +require 'rails_helper' + +feature 'Profile > Account', feature: true do + given(:user) { create(:user, username: 'foo') } + + before do + login_as(user) + end + + describe 'Change username' do + given(:new_username) { 'bar' } + given(:new_user_path) { "/#{new_username}" } + given(:old_user_path) { "/#{user.username}" } + + scenario 'the user is accessible via the new path' do + update_username(new_username) + visit new_user_path + expect(current_path).to eq(new_user_path) + expect(find('.user-info')).to have_content(new_username) + end + + scenario 'the old user path redirects to the new path' do + update_username(new_username) + visit old_user_path + expect(current_path).to eq(new_user_path) + expect(find('.user-info')).to have_content(new_username) + end + + context 'with a project' do + given!(:project) { create(:project, namespace: user.namespace, path: 'project') } + given(:new_project_path) { "/#{new_username}/#{project.path}" } + given(:old_project_path) { "/#{user.username}/#{project.path}" } + + after do + TestEnv.clean_test_path + end + + scenario 'the project is accessible via the new path' do + update_username(new_username) + visit new_project_path + expect(current_path).to eq(new_project_path) + expect(find('h1.project-title')).to have_content(project.name) + end + + scenario 'the old project path redirects to the new path' do + update_username(new_username) + visit old_project_path + expect(current_path).to eq(new_project_path) + expect(find('h1.project-title')).to have_content(project.name) + end + end + end +end + +def update_username(new_username) + allow(user.namespace).to receive(:move_dir) + visit profile_account_path + fill_in 'user_username', with: new_username + click_button 'Update username' +end diff --git a/spec/features/projects/project_settings_spec.rb b/spec/features/projects/project_settings_spec.rb index 5d0314d5c0987..364cc8ef0f29a 100644 --- a/spec/features/projects/project_settings_spec.rb +++ b/spec/features/projects/project_settings_spec.rb @@ -1,64 +1,154 @@ require 'spec_helper' describe 'Edit Project Settings', feature: true do + include Select2Helper + let(:user) { create(:user) } - let(:project) { create(:empty_project, path: 'gitlab', name: 'sample') } + let(:project) { create(:empty_project, namespace: user.namespace, path: 'gitlab', name: 'sample') } before do login_as(user) - project.team << [user, :master] end - describe 'Project settings', js: true do + describe 'Project settings section', js: true do it 'shows errors for invalid project name' do visit edit_namespace_project_path(project.namespace, project) - fill_in 'project_name_edit', with: 'foo&bar' - click_button 'Save changes' - expect(page).to have_field 'project_name_edit', with: 'foo&bar' expect(page).to have_content "Name can contain only letters, digits, emojis, '_', '.', dash, space. It must start with letter, digit, emoji or '_'." expect(page).to have_button 'Save changes' end - scenario 'shows a successful notice when the project is updated' do + it 'shows a successful notice when the project is updated' do visit edit_namespace_project_path(project.namespace, project) - fill_in 'project_name_edit', with: 'hello world' - click_button 'Save changes' - expect(page).to have_content "Project 'hello world' was successfully updated." end end - describe 'Rename repository' do - it 'shows errors for invalid project path/name' do - visit edit_namespace_project_path(project.namespace, project) - - fill_in 'project_name', with: 'foo&bar' - fill_in 'Path', with: 'foo&bar' + describe 'Rename repository section' do + context 'with invalid characters' do + it 'shows errors for invalid project path/name' do + rename_project(project, name: 'foo&bar', path: 'foo&bar') + expect(page).to have_field 'Project name', with: 'foo&bar' + expect(page).to have_field 'Path', with: 'foo&bar' + expect(page).to have_content "Name can contain only letters, digits, emojis, '_', '.', dash, space. It must start with letter, digit, emoji or '_'." + expect(page).to have_content "Path can contain only letters, digits, '_', '-' and '.'. Cannot start with '-', end in '.git' or end in '.atom'" + end + end - click_button 'Rename project' + context 'when changing project name' do + it 'renames the repository' do + rename_project(project, name: 'bar') + expect(find('h1.title')).to have_content(project.name) + end + + context 'with emojis' do + it 'shows error for invalid project name' do + rename_project(project, name: '🚀 foo bar â˜ï¸') + expect(page).to have_field 'Project name', with: '🚀 foo bar â˜ï¸' + expect(page).not_to have_content "Name can contain only letters, digits, emojis '_', '.', dash and space. It must start with letter, digit, emoji or '_'." + end + end + end - expect(page).to have_field 'Project name', with: 'foo&bar' - expect(page).to have_field 'Path', with: 'foo&bar' - expect(page).to have_content "Name can contain only letters, digits, emojis, '_', '.', dash, space. It must start with letter, digit, emoji or '_'." - expect(page).to have_content "Path can contain only letters, digits, '_', '-' and '.'. Cannot start with '-', end in '.git' or end in '.atom'" + context 'when changing project path' do + # Not using empty project because we need a repo to exist + let(:project) { create(:project, namespace: user.namespace, name: 'gitlabhq') } + + specify 'the project is accessible via the new path' do + rename_project(project, path: 'bar') + new_path = namespace_project_path(project.namespace, 'bar') + visit new_path + expect(current_path).to eq(new_path) + expect(find('h1.title')).to have_content(project.name) + end + + specify 'the project is accessible via a redirect from the old path' do + old_path = namespace_project_path(project.namespace, project) + rename_project(project, path: 'bar') + new_path = namespace_project_path(project.namespace, 'bar') + visit old_path + expect(current_path).to eq(new_path) + expect(find('h1.title')).to have_content(project.name) + end + + context 'and a new project is added with the same path' do + it 'overrides the redirect' do + old_path = namespace_project_path(project.namespace, project) + rename_project(project, path: 'bar') + new_project = create(:empty_project, namespace: user.namespace, path: 'gitlabhq', name: 'quz') + visit old_path + expect(current_path).to eq(old_path) + expect(find('h1.title')).to have_content(new_project.name) + end + end end end - describe 'Rename repository name with emojis' do - it 'shows error for invalid project name' do - visit edit_namespace_project_path(project.namespace, project) + describe 'Transfer project section', js: true do + # Not using empty project because we need a repo to exist + let(:project) { create(:project, namespace: user.namespace, name: 'gitlabhq') } + let(:group) { create(:group) } + before do + group.add_owner(user) + end - fill_in 'project_name', with: '🚀 foo bar â˜ï¸' + specify 'the project is accessible via the new path' do + transfer_project(project, group) + new_path = namespace_project_path(group, project) + visit new_path + expect(current_path).to eq(new_path) + expect(find('h1.title')).to have_content(project.name) + end - click_button 'Rename project' + specify 'the project is accessible via a redirect from the old path' do + old_path = namespace_project_path(project.namespace, project) + transfer_project(project, group) + new_path = namespace_project_path(group, project) + visit old_path + expect(current_path).to eq(new_path) + expect(find('h1.title')).to have_content(project.name) + end - expect(page).to have_field 'Project name', with: '🚀 foo bar â˜ï¸' - expect(page).not_to have_content "Name can contain only letters, digits, emojis '_', '.', dash and space. It must start with letter, digit, emoji or '_'." + context 'and a new project is added with the same path' do + it 'overrides the redirect' do + old_path = namespace_project_path(project.namespace, project) + transfer_project(project, group) + new_project = create(:empty_project, namespace: user.namespace, path: 'gitlabhq', name: 'quz') + visit old_path + expect(current_path).to eq(old_path) + expect(find('h1.title')).to have_content(new_project.name) + end end end end + +def rename_project(project, name: nil, path: nil) + visit edit_namespace_project_path(project.namespace, project) + fill_in('project_name', with: name) if name + fill_in('Path', with: path) if path + click_button('Rename project') + wait_for_edit_project_page_reload + project.reload +end + +def transfer_project(project, namespace) + visit edit_namespace_project_path(project.namespace, project) + select2(namespace.id, from: '#new_namespace_id') + click_button('Transfer project') + confirm_transfer_modal + wait_for_edit_project_page_reload + project.reload +end + +def confirm_transfer_modal + fill_in('confirm_name_input', with: project.path) + click_button 'Confirm' +end + +def wait_for_edit_project_page_reload + expect(find('.project-edit-container')).to have_content('Rename repository') +end -- GitLab From 7d02bcd2e0165a90a9f2c1edb34b064ff76afd69 Mon Sep 17 00:00:00 2001 From: Michael Kozono <mkozono@gmail.com> Date: Mon, 1 May 2017 13:46:30 -0700 Subject: [PATCH 323/363] Redirect from redirect routes to canonical routes --- app/controllers/application_controller.rb | 2 +- app/controllers/concerns/routable_actions.rb | 11 ++ .../groups/application_controller.rb | 21 +- app/controllers/groups_controller.rb | 2 +- .../projects/application_controller.rb | 14 +- app/controllers/users_controller.rb | 12 +- app/models/concerns/routable.rb | 24 ++- app/models/redirect_route.rb | 10 + app/models/user.rb | 5 + .../20170427215854_create_redirect_routes.rb | 15 ++ db/schema.rb | 8 + lib/constraints/group_url_constrainer.rb | 2 +- lib/constraints/project_url_constrainer.rb | 2 +- lib/constraints/user_url_constrainer.rb | 2 +- .../application_controller_spec.rb | 5 +- spec/controllers/groups_controller_spec.rb | 92 ++++++++- spec/controllers/projects_controller_spec.rb | 84 +++++++- spec/controllers/users_controller_spec.rb | 181 +++++++++++++++++- spec/features/groups/group_settings_spec.rb | 3 + spec/features/profiles/account_spec.rb | 5 +- .../projects/features_visibility_spec.rb | 37 ++-- .../projects/project_settings_spec.rb | 10 +- .../constraints/group_url_constrainer_spec.rb | 32 +++- .../project_url_constrainer_spec.rb | 21 +- .../constraints/user_url_constrainer_spec.rb | 21 +- spec/lib/gitlab/import_export/all_models.yml | 1 + spec/models/concerns/routable_spec.rb | 52 ++++- spec/models/redirect_route_spec.rb | 16 ++ spec/models/user_spec.rb | 59 ++++++ spec/routing/project_routing_spec.rb | 6 +- 30 files changed, 668 insertions(+), 87 deletions(-) create mode 100644 app/controllers/concerns/routable_actions.rb create mode 100644 app/models/redirect_route.rb create mode 100644 db/migrate/20170427215854_create_redirect_routes.rb create mode 100644 spec/models/redirect_route_spec.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d2c13da691741..65a1f640a764d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -58,7 +58,7 @@ def route_not_found if current_user not_found else - redirect_to new_user_session_path + authenticate_user! end end diff --git a/app/controllers/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb new file mode 100644 index 0000000000000..6f16377a156f6 --- /dev/null +++ b/app/controllers/concerns/routable_actions.rb @@ -0,0 +1,11 @@ +module RoutableActions + extend ActiveSupport::Concern + + def ensure_canonical_path(routable, requested_path) + return unless request.get? + + if routable.full_path != requested_path + redirect_to request.original_url.sub(requested_path, routable.full_path) + end + end +end diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb index 29ffaeb19c166..209d8b1a08afe 100644 --- a/app/controllers/groups/application_controller.rb +++ b/app/controllers/groups/application_controller.rb @@ -1,4 +1,6 @@ class Groups::ApplicationController < ApplicationController + include RoutableActions + layout 'group' skip_before_action :authenticate_user! @@ -8,18 +10,15 @@ class Groups::ApplicationController < ApplicationController def group unless @group - id = params[:group_id] || params[:id] - @group = Group.find_by_full_path(id) - @group_merge_requests = MergeRequestsFinder.new(current_user, group_id: @group.id).execute + given_path = params[:group_id] || params[:id] + @group = Group.find_by_full_path(given_path, follow_redirects: request.get?) - unless @group && can?(current_user, :read_group, @group) + if @group && can?(current_user, :read_group, @group) + ensure_canonical_path(@group, given_path) + else @group = nil - if current_user.nil? - authenticate_user! - else - render_404 - end + route_not_found end end @@ -30,6 +29,10 @@ def group_projects @projects ||= GroupProjectsFinder.new(group: group, current_user: current_user).execute end + def group_merge_requests + @group_merge_requests = MergeRequestsFinder.new(current_user, group_id: @group.id).execute + end + def authorize_admin_group! unless can?(current_user, :admin_group, group) return render_404 diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 593001e6396dc..46c3ff1069409 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -12,8 +12,8 @@ class GroupsController < Groups::ApplicationController before_action :authorize_admin_group!, only: [:edit, :update, :destroy, :projects] before_action :authorize_create_group!, only: [:new, :create] - # Load group projects before_action :group_projects, only: [:projects, :activity, :issues, :merge_requests] + before_action :group_merge_requests, only: [:merge_requests] before_action :event_filter, only: [:activity] before_action :user_actions, only: [:show, :subgroups] diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index 89f1128ec36ad..dbdf68776f1d7 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -1,4 +1,6 @@ class Projects::ApplicationController < ApplicationController + include RoutableActions + skip_before_action :authenticate_user! before_action :project before_action :repository @@ -24,20 +26,14 @@ def project end project_path = "#{namespace}/#{id}" - @project = Project.find_by_full_path(project_path) + @project = Project.find_by_full_path(project_path, follow_redirects: request.get?) if can?(current_user, :read_project, @project) && !@project.pending_delete? - if @project.path_with_namespace != project_path - redirect_to request.original_url.gsub(project_path, @project.path_with_namespace) - end + ensure_canonical_path(@project, project_path) else @project = nil - if current_user.nil? - authenticate_user! - else - render_404 - end + route_not_found end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index a452bbba422bb..6b6b3b20a8ddf 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,7 +1,9 @@ class UsersController < ApplicationController + include RoutableActions + skip_before_action :authenticate_user! before_action :user, except: [:exists] - before_action :authorize_read_user!, only: [:show] + before_action :authorize_read_user!, except: [:exists] def show respond_to do |format| @@ -92,11 +94,15 @@ def exists private def authorize_read_user! - render_404 unless can?(current_user, :read_user, user) + if can?(current_user, :read_user, user) + ensure_canonical_path(user.namespace, params[:username]) + else + render_404 + end end def user - @user ||= User.find_by_username!(params[:username]) + @user ||= User.find_by_full_path(params[:username], follow_redirects: true) end def contributed_projects diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index b28e05d0c280d..e351dbb45ddb8 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -5,6 +5,7 @@ module Routable included do has_one :route, as: :source, autosave: true, dependent: :destroy + has_many :redirect_routes, as: :source, autosave: true, dependent: :destroy validates_associated :route validates :route, presence: true @@ -26,16 +27,31 @@ module Routable # Klass.find_by_full_path('gitlab-org/gitlab-ce') # # Returns a single object, or nil. - def find_by_full_path(path) + def find_by_full_path(path, follow_redirects: false) # On MySQL we want to ensure the ORDER BY uses a case-sensitive match so # any literal matches come first, for this we have to use "BINARY". # Without this there's still no guarantee in what order MySQL will return # rows. + # + # Why do we do this? + # + # Even though we have Rails validation on Route for unique paths + # (case-insensitive), there are old projects in our DB (and possibly + # clients' DBs) that have the same path with different cases. + # See https://gitlab.com/gitlab-org/gitlab-ce/issues/18603. Also note that + # our unique index is case-sensitive in Postgres. binary = Gitlab::Database.mysql? ? 'BINARY' : '' - order_sql = "(CASE WHEN #{binary} routes.path = #{connection.quote(path)} THEN 0 ELSE 1 END)" - - where_full_path_in([path]).reorder(order_sql).take + found = where_full_path_in([path]).reorder(order_sql).take + return found if found + + if follow_redirects + if Gitlab::Database.postgresql? + joins(:redirect_routes).find_by("LOWER(redirect_routes.path) = LOWER(?)", path) + else + joins(:redirect_routes).find_by(path: path) + end + end end # Builds a relation to find multiple objects by their full paths. diff --git a/app/models/redirect_route.rb b/app/models/redirect_route.rb new file mode 100644 index 0000000000000..e36ca988701f1 --- /dev/null +++ b/app/models/redirect_route.rb @@ -0,0 +1,10 @@ +class RedirectRoute < ActiveRecord::Base + belongs_to :source, polymorphic: true + + validates :source, presence: true + + validates :path, + length: { within: 1..255 }, + presence: true, + uniqueness: { case_sensitive: false } +end diff --git a/app/models/user.rb b/app/models/user.rb index 43c5fdc038d47..c6354b989ade2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -333,6 +333,11 @@ def find_by_ssh_key_id(key_id) find_by(id: Key.unscoped.select(:user_id).where(id: key_id)) end + def find_by_full_path(path, follow_redirects: false) + namespace = Namespace.find_by_full_path(path, follow_redirects: follow_redirects) + namespace.owner if namespace && namespace.owner + end + def reference_prefix '@' end diff --git a/db/migrate/20170427215854_create_redirect_routes.rb b/db/migrate/20170427215854_create_redirect_routes.rb new file mode 100644 index 0000000000000..57b79a0267fd3 --- /dev/null +++ b/db/migrate/20170427215854_create_redirect_routes.rb @@ -0,0 +1,15 @@ +class CreateRedirectRoutes < ActiveRecord::Migration + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + create_table :redirect_routes do |t| + t.integer :source_id, null: false + t.string :source_type, null: false + t.string :path, null: false + + t.timestamps null: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 588e2082ae038..3a8970b2235e8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1040,6 +1040,14 @@ add_index "protected_tags", ["project_id"], name: "index_protected_tags_on_project_id", using: :btree + create_table "redirect_routes", force: :cascade do |t| + t.integer "source_id", null: false + t.string "source_type", null: false + t.string "path", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "releases", force: :cascade do |t| t.string "tag" t.text "description" diff --git a/lib/constraints/group_url_constrainer.rb b/lib/constraints/group_url_constrainer.rb index 1501f64d537af..5f379756c11ee 100644 --- a/lib/constraints/group_url_constrainer.rb +++ b/lib/constraints/group_url_constrainer.rb @@ -4,6 +4,6 @@ def matches?(request) return false unless DynamicPathValidator.valid?(id) - Group.find_by_full_path(id).present? + Group.find_by_full_path(id, follow_redirects: request.get?).present? end end diff --git a/lib/constraints/project_url_constrainer.rb b/lib/constraints/project_url_constrainer.rb index d0ce2caffffc4..6f542f63f9807 100644 --- a/lib/constraints/project_url_constrainer.rb +++ b/lib/constraints/project_url_constrainer.rb @@ -6,6 +6,6 @@ def matches?(request) return false unless DynamicPathValidator.valid?(full_path) - Project.find_by_full_path(full_path).present? + Project.find_by_full_path(full_path, follow_redirects: request.get?).present? end end diff --git a/lib/constraints/user_url_constrainer.rb b/lib/constraints/user_url_constrainer.rb index 9ab5bcb12ffc3..28159dc0decee 100644 --- a/lib/constraints/user_url_constrainer.rb +++ b/lib/constraints/user_url_constrainer.rb @@ -1,5 +1,5 @@ class UserUrlConstrainer def matches?(request) - User.find_by_username(request.params[:username]).present? + User.find_by_full_path(request.params[:username], follow_redirects: request.get?).present? end end diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 1bf0533ca2487..d40aae04fc3aa 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -106,10 +106,9 @@ def index controller.send(:route_not_found) end - it 'does redirect to login page if not authenticated' do + it 'does redirect to login page via authenticate_user! if not authenticated' do allow(controller).to receive(:current_user).and_return(nil) - expect(controller).to receive(:redirect_to) - expect(controller).to receive(:new_user_session_path) + expect(controller).to receive(:authenticate_user!) controller.send(:route_not_found) end end diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index cad82a34fb0e9..3398878f33058 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -49,6 +49,24 @@ expect(assigns(:issues)).to eq [issue_2, issue_1] end end + + context 'when requesting the canonical path with different casing' do + it 'redirects to the correct casing' do + get :issues, id: group.to_param.upcase + + expect(response).to redirect_to(issues_group_path(group.to_param)) + end + end + + context 'when requesting a redirected path' do + let(:redirect_route) { group.redirect_routes.create(path: 'old-path') } + + it 'redirects to the canonical path' do + get :issues, id: redirect_route.path + + expect(response).to redirect_to(issues_group_path(group.to_param)) + end + end end describe 'GET #merge_requests' do @@ -74,6 +92,24 @@ expect(assigns(:merge_requests)).to eq [merge_request_2, merge_request_1] end end + + context 'when requesting the canonical path with different casing' do + it 'redirects to the correct casing' do + get :merge_requests, id: group.to_param.upcase + + expect(response).to redirect_to(merge_requests_group_path(group.to_param)) + end + end + + context 'when requesting a redirected path' do + let(:redirect_route) { group.redirect_routes.create(path: 'old-path') } + + it 'redirects to the canonical path' do + get :merge_requests, id: redirect_route.path + + expect(response).to redirect_to(merge_requests_group_path(group.to_param)) + end + end end describe 'DELETE #destroy' do @@ -81,7 +117,7 @@ it 'returns 404' do sign_in(create(:user)) - delete :destroy, id: group.path + delete :destroy, id: group.to_param expect(response.status).to eq(404) end @@ -94,15 +130,39 @@ it 'schedules a group destroy' do Sidekiq::Testing.fake! do - expect { delete :destroy, id: group.path }.to change(GroupDestroyWorker.jobs, :size).by(1) + expect { delete :destroy, id: group.to_param }.to change(GroupDestroyWorker.jobs, :size).by(1) end end it 'redirects to the root path' do - delete :destroy, id: group.path + delete :destroy, id: group.to_param expect(response).to redirect_to(root_path) end + + context 'when requesting the canonical path with different casing' do + it 'does not 404' do + delete :destroy, id: group.to_param.upcase + + expect(response).to_not have_http_status(404) + end + + it 'does not redirect to the correct casing' do + delete :destroy, id: group.to_param.upcase + + expect(response).to_not redirect_to(group_path(group.to_param)) + end + end + + context 'when requesting a redirected path' do + let(:redirect_route) { group.redirect_routes.create(path: 'old-path') } + + it 'returns not found' do + delete :destroy, id: redirect_route.path + + expect(response).to have_http_status(404) + end + end end end @@ -111,7 +171,7 @@ sign_in(user) end - it 'updates the path succesfully' do + it 'updates the path successfully' do post :update, id: group.to_param, group: { path: 'new_path' } expect(response).to have_http_status(302) @@ -125,5 +185,29 @@ expect(assigns(:group).errors).not_to be_empty expect(assigns(:group).path).not_to eq('new_path') end + + context 'when requesting the canonical path with different casing' do + it 'does not 404' do + post :update, id: group.to_param.upcase, group: { path: 'new_path' } + + expect(response).to_not have_http_status(404) + end + + it 'does not redirect to the correct casing' do + post :update, id: group.to_param.upcase, group: { path: 'new_path' } + + expect(response).to_not redirect_to(group_path(group.to_param)) + end + end + + context 'when requesting a redirected path' do + let(:redirect_route) { group.redirect_routes.create(path: 'old-path') } + + it 'returns not found' do + post :update, id: redirect_route.path, group: { path: 'new_path' } + + expect(response).to have_http_status(404) + end + end end end diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index eafc2154568d1..1b0dd7c63696d 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -218,19 +218,32 @@ expect(response).to redirect_to(namespace_project_path) end end + + context 'when requesting a redirected path' do + let!(:redirect_route) { public_project.redirect_routes.create!(path: "foo/bar") } + + it 'redirects to the canonical path' do + get :show, namespace_id: 'foo', id: 'bar' + + expect(response).to redirect_to(public_project) + end + end end describe "#update" do render_views let(:admin) { create(:admin) } + let(:project) { create(:project, :repository) } + let(:new_path) { 'renamed_path' } + let(:project_params) { { path: new_path } } + + before do + sign_in(admin) + end it "sets the repository to the right path after a rename" do - project = create(:project, :repository) - new_path = 'renamed_path' - project_params = { path: new_path } controller.instance_variable_set(:@project, project) - sign_in(admin) put :update, namespace_id: project.namespace, @@ -241,6 +254,34 @@ expect(assigns(:repository).path).to eq(project.repository.path) expect(response).to have_http_status(302) end + + context 'when requesting the canonical path' do + it "is case-insensitive" do + controller.instance_variable_set(:@project, project) + + put :update, + namespace_id: 'FOo', + id: 'baR', + project: project_params + + expect(project.repository.path).to include(new_path) + expect(assigns(:repository).path).to eq(project.repository.path) + expect(response).to have_http_status(302) + end + end + + context 'when requesting a redirected path' do + let!(:redirect_route) { project.redirect_routes.create!(path: "foo/bar") } + + it 'returns not found' do + put :update, + namespace_id: 'foo', + id: 'bar', + project: project_params + + expect(response).to have_http_status(404) + end + end end describe "#destroy" do @@ -276,6 +317,31 @@ expect(merge_request.reload.state).to eq('closed') end end + + context 'when requesting the canonical path' do + it "is case-insensitive" do + controller.instance_variable_set(:@project, project) + sign_in(admin) + + orig_id = project.id + delete :destroy, namespace_id: project.namespace, id: project.path.upcase + + expect { Project.find(orig_id) }.to raise_error(ActiveRecord::RecordNotFound) + expect(response).to have_http_status(302) + expect(response).to redirect_to(dashboard_projects_path) + end + end + + context 'when requesting a redirected path' do + let!(:redirect_route) { project.redirect_routes.create!(path: "foo/bar") } + + it 'returns not found' do + sign_in(admin) + delete :destroy, namespace_id: 'foo', id: 'bar' + + expect(response).to have_http_status(404) + end + end end describe 'PUT #new_issue_address' do @@ -397,6 +463,16 @@ expect(parsed_body["Tags"]).to include("v1.0.0") expect(parsed_body["Commits"]).to include("123456") end + + context 'when requesting a redirected path' do + let!(:redirect_route) { public_project.redirect_routes.create!(path: "foo/bar") } + + it 'redirects to the canonical path' do + get :refs, namespace_id: 'foo', id: 'bar' + + expect(response).to redirect_to(refs_namespace_project_path(namespace_id: public_project.namespace, id: public_project)) + end + end end describe 'POST #preview_markdown' do diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index bbe9aaf737fa8..73fd5b6f90ded 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -4,15 +4,6 @@ let(:user) { create(:user) } describe 'GET #show' do - it 'is case-insensitive' do - user = create(:user, username: 'CamelCaseUser') - sign_in(user) - - get :show, username: user.username.downcase - - expect(response).to be_success - end - context 'with rendered views' do render_views @@ -61,6 +52,38 @@ end end end + + context 'when requesting the canonical path' do + let(:user) { create(:user, username: 'CamelCaseUser') } + + before { sign_in(user) } + + context 'with exactly matching casing' do + it 'responds with success' do + get :show, username: user.username + + expect(response).to be_success + end + end + + context 'with different casing' do + it 'redirects to the correct casing' do + get :show, username: user.username.downcase + + expect(response).to redirect_to(user) + end + end + end + + context 'when requesting a redirected path' do + let(:redirect_route) { user.namespace.redirect_routes.create(path: 'old-username') } + + it 'redirects to the canonical path' do + get :show, username: redirect_route.path + + expect(response).to redirect_to(user) + end + end end describe 'GET #calendar' do @@ -88,11 +111,43 @@ expect(assigns(:contributions_calendar).projects.count).to eq(2) end end + + context 'when requesting the canonical path' do + let(:user) { create(:user, username: 'CamelCaseUser') } + + before { sign_in(user) } + + context 'with exactly matching casing' do + it 'responds with success' do + get :calendar, username: user.username + + expect(response).to be_success + end + end + + context 'with different casing' do + it 'redirects to the correct casing' do + get :calendar, username: user.username.downcase + + expect(response).to redirect_to(user_calendar_path(user)) + end + end + end + + context 'when requesting a redirected path' do + let(:redirect_route) { user.namespace.redirect_routes.create(path: 'old-username') } + + it 'redirects to the canonical path' do + get :calendar, username: redirect_route.path + + expect(response).to redirect_to(user_calendar_path(user)) + end + end end describe 'GET #calendar_activities' do let!(:project) { create(:empty_project) } - let!(:user) { create(:user) } + let(:user) { create(:user) } before do allow_any_instance_of(User).to receive(:contributed_projects_ids).and_return([project.id]) @@ -110,6 +165,36 @@ get :calendar_activities, username: user.username expect(response).to render_template('calendar_activities') end + + context 'when requesting the canonical path' do + let(:user) { create(:user, username: 'CamelCaseUser') } + + context 'with exactly matching casing' do + it 'responds with success' do + get :calendar_activities, username: user.username + + expect(response).to be_success + end + end + + context 'with different casing' do + it 'redirects to the correct casing' do + get :calendar_activities, username: user.username.downcase + + expect(response).to redirect_to(user_calendar_activities_path(user)) + end + end + end + + context 'when requesting a redirected path' do + let(:redirect_route) { user.namespace.redirect_routes.create(path: 'old-username') } + + it 'redirects to the canonical path' do + get :calendar_activities, username: redirect_route.path + + expect(response).to redirect_to(user_calendar_activities_path(user)) + end + end end describe 'GET #snippets' do @@ -132,5 +217,81 @@ expect(JSON.parse(response.body)).to have_key('html') end end + + context 'when requesting the canonical path' do + let(:user) { create(:user, username: 'CamelCaseUser') } + + context 'with exactly matching casing' do + it 'responds with success' do + get :snippets, username: user.username + + expect(response).to be_success + end + end + + context 'with different casing' do + it 'redirects to the correct casing' do + get :snippets, username: user.username.downcase + + expect(response).to redirect_to(user_snippets_path(user)) + end + end + end + + context 'when requesting a redirected path' do + let(:redirect_route) { user.namespace.redirect_routes.create(path: 'old-username') } + + it 'redirects to the canonical path' do + get :snippets, username: redirect_route.path + + expect(response).to redirect_to(user_snippets_path(user)) + end + end + end + + describe 'GET #exists' do + before do + sign_in(user) + end + + context 'when user exists' do + it 'returns JSON indicating the user exists' do + get :exists, username: user.username + + expected_json = { exists: true }.to_json + expect(response.body).to eq(expected_json) + end + + context 'when the casing is different' do + let(:user) { create(:user, username: 'CamelCaseUser') } + + it 'returns JSON indicating the user exists' do + get :exists, username: user.username.downcase + + expected_json = { exists: true }.to_json + expect(response.body).to eq(expected_json) + end + end + end + + context 'when the user does not exist' do + it 'returns JSON indicating the user does not exist' do + get :exists, username: 'foo' + + expected_json = { exists: false }.to_json + expect(response.body).to eq(expected_json) + end + + context 'when a user changed their username' do + let(:redirect_route) { user.namespace.redirect_routes.create(path: 'old-username') } + + it 'returns JSON indicating a user by that username does not exist' do + get :exists, username: 'old-username' + + expected_json = { exists: false }.to_json + expect(response.body).to eq(expected_json) + end + end + end end end diff --git a/spec/features/groups/group_settings_spec.rb b/spec/features/groups/group_settings_spec.rb index f18f2f2310f03..cc25db4ad602f 100644 --- a/spec/features/groups/group_settings_spec.rb +++ b/spec/features/groups/group_settings_spec.rb @@ -52,6 +52,9 @@ given!(:project) { create(:project, group: group, path: 'project') } given(:old_project_full_path) { "/#{group.path}/#{project.path}" } given(:new_project_full_path) { "/#{new_group_path}/#{project.path}" } + + before(:context) { TestEnv.clean_test_path } + after(:example) { TestEnv.clean_test_path } scenario 'the project is accessible via the new path' do update_path(new_group_path) diff --git a/spec/features/profiles/account_spec.rb b/spec/features/profiles/account_spec.rb index b3902a6b37f7b..05a7587f8d44b 100644 --- a/spec/features/profiles/account_spec.rb +++ b/spec/features/profiles/account_spec.rb @@ -31,9 +31,8 @@ given(:new_project_path) { "/#{new_username}/#{project.path}" } given(:old_project_path) { "/#{user.username}/#{project.path}" } - after do - TestEnv.clean_test_path - end + before(:context) { TestEnv.clean_test_path } + after(:example) { TestEnv.clean_test_path } scenario 'the project is accessible via the new path' do update_username(new_username) diff --git a/spec/features/projects/features_visibility_spec.rb b/spec/features/projects/features_visibility_spec.rb index b080a8d500e52..a6ca0f4131a0a 100644 --- a/spec/features/projects/features_visibility_spec.rb +++ b/spec/features/projects/features_visibility_spec.rb @@ -68,20 +68,23 @@ end describe 'project features visibility pages' do - before do - @tools = - { - builds: namespace_project_pipelines_path(project.namespace, project), - issues: namespace_project_issues_path(project.namespace, project), - wiki: namespace_project_wiki_path(project.namespace, project, :home), - snippets: namespace_project_snippets_path(project.namespace, project), - merge_requests: namespace_project_merge_requests_path(project.namespace, project), - } - end + let(:tools) { + { + builds: namespace_project_pipelines_path(project.namespace, project), + issues: namespace_project_issues_path(project.namespace, project), + wiki: namespace_project_wiki_path(project.namespace, project, :home), + snippets: namespace_project_snippets_path(project.namespace, project), + merge_requests: namespace_project_merge_requests_path(project.namespace, project), + } + } context 'normal user' do + before do + login_as(member) + end + it 'renders 200 if tool is enabled' do - @tools.each do |method_name, url| + tools.each do |method_name, url| project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::ENABLED) visit url expect(page.status_code).to eq(200) @@ -89,7 +92,7 @@ end it 'renders 404 if feature is disabled' do - @tools.each do |method_name, url| + tools.each do |method_name, url| project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::DISABLED) visit url expect(page.status_code).to eq(404) @@ -99,21 +102,21 @@ it 'renders 404 if feature is enabled only for team members' do project.team.truncate - @tools.each do |method_name, url| + tools.each do |method_name, url| project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::PRIVATE) visit url expect(page.status_code).to eq(404) end end - it 'renders 200 if users is member of group' do + it 'renders 200 if user is member of group' do group = create(:group) project.group = group project.save group.add_owner(member) - @tools.each do |method_name, url| + tools.each do |method_name, url| project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::PRIVATE) visit url expect(page.status_code).to eq(200) @@ -128,7 +131,7 @@ end it 'renders 404 if feature is disabled' do - @tools.each do |method_name, url| + tools.each do |method_name, url| project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::DISABLED) visit url expect(page.status_code).to eq(404) @@ -138,7 +141,7 @@ it 'renders 200 if feature is enabled only for team members' do project.team.truncate - @tools.each do |method_name, url| + tools.each do |method_name, url| project.project_feature.update_attribute("#{method_name}_access_level", ProjectFeature::PRIVATE) visit url expect(page.status_code).to eq(200) diff --git a/spec/features/projects/project_settings_spec.rb b/spec/features/projects/project_settings_spec.rb index 364cc8ef0f29a..c0311e73ef5e7 100644 --- a/spec/features/projects/project_settings_spec.rb +++ b/spec/features/projects/project_settings_spec.rb @@ -58,6 +58,9 @@ # Not using empty project because we need a repo to exist let(:project) { create(:project, namespace: user.namespace, name: 'gitlabhq') } + before(:context) { TestEnv.clean_test_path } + after(:example) { TestEnv.clean_test_path } + specify 'the project is accessible via the new path' do rename_project(project, path: 'bar') new_path = namespace_project_path(project.namespace, 'bar') @@ -92,9 +95,10 @@ # Not using empty project because we need a repo to exist let(:project) { create(:project, namespace: user.namespace, name: 'gitlabhq') } let(:group) { create(:group) } - before do - group.add_owner(user) - end + + before(:context) { TestEnv.clean_test_path } + before(:example) { group.add_owner(user) } + after(:example) { TestEnv.clean_test_path } specify 'the project is accessible via the new path' do transfer_project(project, group) diff --git a/spec/lib/constraints/group_url_constrainer_spec.rb b/spec/lib/constraints/group_url_constrainer_spec.rb index f95adf3a84b88..db680489a8daa 100644 --- a/spec/lib/constraints/group_url_constrainer_spec.rb +++ b/spec/lib/constraints/group_url_constrainer_spec.rb @@ -29,9 +29,37 @@ it { expect(subject.matches?(request)).to be_falsey } end + + context 'when the request matches a redirect route' do + context 'for a root group' do + let!(:redirect_route) { group.redirect_routes.create!(path: 'gitlabb') } + + context 'and is a GET request' do + let(:request) { build_request(redirect_route.path) } + + it { expect(subject.matches?(request)).to be_truthy } + end + + context 'and is NOT a GET request' do + let(:request) { build_request(redirect_route.path, 'POST') } + + it { expect(subject.matches?(request)).to be_falsey } + end + end + + context 'for a nested group' do + let!(:nested_group) { create(:group, path: 'nested', parent: group) } + let!(:redirect_route) { nested_group.redirect_routes.create!(path: 'gitlabb/nested') } + let(:request) { build_request(redirect_route.path) } + + it { expect(subject.matches?(request)).to be_truthy } + end + end end - def build_request(path) - double(:request, params: { id: path }) + def build_request(path, method = 'GET') + double(:request, + 'get?': (method == 'GET'), + params: { id: path }) end end diff --git a/spec/lib/constraints/project_url_constrainer_spec.rb b/spec/lib/constraints/project_url_constrainer_spec.rb index 4f25ad8896050..b6884e37aa3d9 100644 --- a/spec/lib/constraints/project_url_constrainer_spec.rb +++ b/spec/lib/constraints/project_url_constrainer_spec.rb @@ -24,9 +24,26 @@ it { expect(subject.matches?(request)).to be_falsey } end end + + context 'when the request matches a redirect route' do + let(:old_project_path) { 'old_project_path' } + let!(:redirect_route) { project.redirect_routes.create!(path: "#{namespace.full_path}/#{old_project_path}") } + + context 'and is a GET request' do + let(:request) { build_request(namespace.full_path, old_project_path) } + it { expect(subject.matches?(request)).to be_truthy } + end + + context 'and is NOT a GET request' do + let(:request) { build_request(namespace.full_path, old_project_path, 'POST') } + it { expect(subject.matches?(request)).to be_falsey } + end + end end - def build_request(namespace, project) - double(:request, params: { namespace_id: namespace, id: project }) + def build_request(namespace, project, method = 'GET') + double(:request, + 'get?': (method == 'GET'), + params: { namespace_id: namespace, id: project }) end end diff --git a/spec/lib/constraints/user_url_constrainer_spec.rb b/spec/lib/constraints/user_url_constrainer_spec.rb index 207b6fe6c9ec5..ed69b8309790d 100644 --- a/spec/lib/constraints/user_url_constrainer_spec.rb +++ b/spec/lib/constraints/user_url_constrainer_spec.rb @@ -15,9 +15,26 @@ it { expect(subject.matches?(request)).to be_falsey } end + + context 'when the request matches a redirect route' do + let(:old_project_path) { 'old_project_path' } + let!(:redirect_route) { user.namespace.redirect_routes.create!(path: 'foo') } + + context 'and is a GET request' do + let(:request) { build_request(redirect_route.path) } + it { expect(subject.matches?(request)).to be_truthy } + end + + context 'and is NOT a GET request' do + let(:request) { build_request(redirect_route.path, 'POST') } + it { expect(subject.matches?(request)).to be_falsey } + end + end end - def build_request(username) - double(:request, params: { username: username }) + def build_request(username, method = 'GET') + double(:request, + 'get?': (method == 'GET'), + params: { username: username }) end end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 0abf89d060cec..f451fb5017b31 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -225,6 +225,7 @@ project: - authorized_users - project_authorizations - route +- redirect_routes - statistics - container_repositories - uploads diff --git a/spec/models/concerns/routable_spec.rb b/spec/models/concerns/routable_spec.rb index 221647d7a4872..49a4132f7638c 100644 --- a/spec/models/concerns/routable_spec.rb +++ b/spec/models/concerns/routable_spec.rb @@ -9,6 +9,7 @@ describe 'Associations' do it { is_expected.to have_one(:route).dependent(:destroy) } + it { is_expected.to have_many(:redirect_routes).dependent(:destroy) } end describe 'Callbacks' do @@ -35,10 +36,53 @@ describe '.find_by_full_path' do let!(:nested_group) { create(:group, parent: group) } - it { expect(described_class.find_by_full_path(group.to_param)).to eq(group) } - it { expect(described_class.find_by_full_path(group.to_param.upcase)).to eq(group) } - it { expect(described_class.find_by_full_path(nested_group.to_param)).to eq(nested_group) } - it { expect(described_class.find_by_full_path('unknown')).to eq(nil) } + context 'without any redirect routes' do + it { expect(described_class.find_by_full_path(group.to_param)).to eq(group) } + it { expect(described_class.find_by_full_path(group.to_param.upcase)).to eq(group) } + it { expect(described_class.find_by_full_path(nested_group.to_param)).to eq(nested_group) } + it { expect(described_class.find_by_full_path('unknown')).to eq(nil) } + end + + context 'with redirect routes' do + let!(:group_redirect_route) { group.redirect_routes.create!(path: 'bar') } + let!(:nested_group_redirect_route) { nested_group.redirect_routes.create!(path: nested_group.path.sub('foo', 'bar')) } + + context 'without follow_redirects option' do + context 'with the given path not matching any route' do + it { expect(described_class.find_by_full_path('unknown')).to eq(nil) } + end + + context 'with the given path matching the canonical route' do + it { expect(described_class.find_by_full_path(group.to_param)).to eq(group) } + it { expect(described_class.find_by_full_path(group.to_param.upcase)).to eq(group) } + it { expect(described_class.find_by_full_path(nested_group.to_param)).to eq(nested_group) } + end + + context 'with the given path matching a redirect route' do + it { expect(described_class.find_by_full_path(group_redirect_route.path)).to eq(nil) } + it { expect(described_class.find_by_full_path(group_redirect_route.path.upcase)).to eq(nil) } + it { expect(described_class.find_by_full_path(nested_group_redirect_route.path)).to eq(nil) } + end + end + + context 'with follow_redirects option set to true' do + context 'with the given path not matching any route' do + it { expect(described_class.find_by_full_path('unknown', follow_redirects: true)).to eq(nil) } + end + + context 'with the given path matching the canonical route' do + it { expect(described_class.find_by_full_path(group.to_param, follow_redirects: true)).to eq(group) } + it { expect(described_class.find_by_full_path(group.to_param.upcase, follow_redirects: true)).to eq(group) } + it { expect(described_class.find_by_full_path(nested_group.to_param, follow_redirects: true)).to eq(nested_group) } + end + + context 'with the given path matching a redirect route' do + it { expect(described_class.find_by_full_path(group_redirect_route.path, follow_redirects: true)).to eq(group) } + it { expect(described_class.find_by_full_path(group_redirect_route.path.upcase, follow_redirects: true)).to eq(group) } + it { expect(described_class.find_by_full_path(nested_group_redirect_route.path, follow_redirects: true)).to eq(nested_group) } + end + end + end end describe '.where_full_path_in' do diff --git a/spec/models/redirect_route_spec.rb b/spec/models/redirect_route_spec.rb new file mode 100644 index 0000000000000..fb72d87d94d12 --- /dev/null +++ b/spec/models/redirect_route_spec.rb @@ -0,0 +1,16 @@ +require 'rails_helper' + +describe RedirectRoute, models: true do + let!(:group) { create(:group, path: 'git_lab', name: 'git_lab') } + let!(:redirect_route) { group.redirect_routes.create(path: 'gitlabb') } + + describe 'relationships' do + it { is_expected.to belong_to(:source) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:source) } + it { is_expected.to validate_presence_of(:path) } + it { is_expected.to validate_uniqueness_of(:path) } + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 401eaac07a139..63e71f5ff2f8d 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -849,6 +849,65 @@ end end + describe '.find_by_full_path' do + let!(:user) { create(:user) } + + context 'with a route matching the given path' do + let!(:route) { user.namespace.route } + + it 'returns the user' do + expect(User.find_by_full_path(route.path)).to eq(user) + end + + it 'is case-insensitive' do + expect(User.find_by_full_path(route.path.upcase)).to eq(user) + expect(User.find_by_full_path(route.path.downcase)).to eq(user) + end + end + + context 'with a redirect route matching the given path' do + let!(:redirect_route) { user.namespace.redirect_routes.create(path: 'foo') } + + context 'without the follow_redirects option' do + it 'returns nil' do + expect(User.find_by_full_path(redirect_route.path)).to eq(nil) + end + end + + context 'with the follow_redirects option set to true' do + it 'returns the user' do + expect(User.find_by_full_path(redirect_route.path, follow_redirects: true)).to eq(user) + end + + it 'is case-insensitive' do + expect(User.find_by_full_path(redirect_route.path.upcase, follow_redirects: true)).to eq(user) + expect(User.find_by_full_path(redirect_route.path.downcase, follow_redirects: true)).to eq(user) + end + end + end + + context 'without a route or a redirect route matching the given path' do + context 'without the follow_redirects option' do + it 'returns nil' do + expect(User.find_by_full_path('unknown')).to eq(nil) + end + end + context 'with the follow_redirects option set to true' do + it 'returns nil' do + expect(User.find_by_full_path('unknown', follow_redirects: true)).to eq(nil) + end + end + end + + context 'with a group route matching the given path' do + let!(:group) { create(:group, path: 'group_path') } + + it 'returns nil' do + expect(User.find_by_full_path('group_path')).to eq(nil) + end + end + end + describe 'all_ssh_keys' do it { is_expected.to have_many(:keys).dependent(:destroy) } diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb index 163df072cf634..50e96d56191f6 100644 --- a/spec/routing/project_routing_spec.rb +++ b/spec/routing/project_routing_spec.rb @@ -3,7 +3,7 @@ describe 'project routing' do before do allow(Project).to receive(:find_by_full_path).and_return(false) - allow(Project).to receive(:find_by_full_path).with('gitlab/gitlabhq').and_return(true) + allow(Project).to receive(:find_by_full_path).with('gitlab/gitlabhq', any_args).and_return(true) end # Shared examples for a resource inside a Project @@ -93,13 +93,13 @@ end context 'name with dot' do - before { allow(Project).to receive(:find_by_full_path).with('gitlab/gitlabhq.keys').and_return(true) } + before { allow(Project).to receive(:find_by_full_path).with('gitlab/gitlabhq.keys', any_args).and_return(true) } it { expect(get('/gitlab/gitlabhq.keys')).to route_to('projects#show', namespace_id: 'gitlab', id: 'gitlabhq.keys') } end context 'with nested group' do - before { allow(Project).to receive(:find_by_full_path).with('gitlab/subgroup/gitlabhq').and_return(true) } + before { allow(Project).to receive(:find_by_full_path).with('gitlab/subgroup/gitlabhq', any_args).and_return(true) } it { expect(get('/gitlab/subgroup/gitlabhq')).to route_to('projects#show', namespace_id: 'gitlab/subgroup', id: 'gitlabhq') } end -- GitLab From a0368e91310a3b2c0e9e0b717f931a482eb0b90a Mon Sep 17 00:00:00 2001 From: Michael Kozono <mkozono@gmail.com> Date: Mon, 1 May 2017 16:48:05 -0700 Subject: [PATCH 324/363] Create redirect routes on path change --- app/models/route.rb | 24 +++++++++++++++++------- spec/models/route_spec.rb | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/app/models/route.rb b/app/models/route.rb index 4b3efab5c3cb8..df801714b5f4c 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -8,15 +8,17 @@ class Route < ActiveRecord::Base presence: true, uniqueness: { case_sensitive: false } - after_update :rename_descendants + after_update :create_redirect_if_path_changed + after_update :rename_direct_descendant_routes scope :inside_path, -> (path) { where('routes.path LIKE ?', "#{sanitize_sql_like(path)}/%") } + scope :direct_descendant_routes, -> (path) { where('routes.path LIKE ? AND routes.path NOT LIKE ?', "#{sanitize_sql_like(path)}/%", "#{sanitize_sql_like(path)}/%/%") } - def rename_descendants + def rename_direct_descendant_routes if path_changed? || name_changed? - descendants = self.class.inside_path(path_was) + direct_descendant_routes = self.class.direct_descendant_routes(path_was) - descendants.each do |route| + direct_descendant_routes.each do |route| attributes = {} if path_changed? && route.path.present? @@ -27,10 +29,18 @@ def rename_descendants attributes[:name] = route.name.sub(name_was, name) end - # Note that update_columns skips validation and callbacks. - # We need this to avoid recursive call of rename_descendants method - route.update_columns(attributes) unless attributes.empty? + route.update(attributes) unless attributes.empty? end end end + + def create_redirect_if_path_changed + if path_changed? + create_redirect(path_was) + end + end + + def create_redirect(old_path) + source.redirect_routes.create(path: old_path) + end end diff --git a/spec/models/route_spec.rb b/spec/models/route_spec.rb index 171a51fcc5b7f..c95043469a81b 100644 --- a/spec/models/route_spec.rb +++ b/spec/models/route_spec.rb @@ -26,7 +26,20 @@ end end - describe '#rename_descendants' do + describe '.direct_descendant_routes' do + let!(:nested_group) { create(:group, path: 'test', name: 'test', parent: group) } + let!(:deep_nested_group) { create(:group, path: 'foo', name: 'foo', parent: nested_group) } + let!(:another_group) { create(:group, path: 'other') } + let!(:similar_group) { create(:group, path: 'gitllab') } + let!(:another_group_nested) { create(:group, path: 'another', name: 'another', parent: similar_group) } + + it 'returns correct routes' do + expect(Route.direct_descendant_routes('git_lab')).to match_array([nested_group.route]) + expect(Route.direct_descendant_routes('git_lab/test')).to match_array([deep_nested_group.route]) + end + end + + describe '#rename_direct_descendant_routes' do let!(:nested_group) { create(:group, path: 'test', name: 'test', parent: group) } let!(:deep_nested_group) { create(:group, path: 'foo', name: 'foo', parent: nested_group) } let!(:similar_group) { create(:group, path: 'gitlab-org', name: 'gitlab-org') } @@ -77,4 +90,23 @@ end end end + + describe '#create_redirect_if_path_changed' do + context 'if the path changed' do + it 'creates a RedirectRoute for the old path' do + route.path = 'new-path' + route.save! + redirect = route.source.redirect_routes.first + expect(redirect.path).to eq('git_lab') + end + end + + context 'if the path did not change' do + it 'does not create a RedirectRoute' do + route.updated_at = Time.zone.now.utc + route.save! + expect(route.source.redirect_routes).to be_empty + end + end + end end -- GitLab From 72872ee2136436e48ce394268fc8bfb8a2118810 Mon Sep 17 00:00:00 2001 From: Michael Kozono <mkozono@gmail.com> Date: Wed, 3 May 2017 10:14:30 -0700 Subject: [PATCH 325/363] Delete conflicting redirects --- app/models/redirect_route.rb | 2 + app/models/route.rb | 21 ++++-- spec/models/redirect_route_spec.rb | 13 +++- spec/models/route_spec.rb | 111 +++++++++++++++++++++++++---- 4 files changed, 127 insertions(+), 20 deletions(-) diff --git a/app/models/redirect_route.rb b/app/models/redirect_route.rb index e36ca988701f1..99812bcde5321 100644 --- a/app/models/redirect_route.rb +++ b/app/models/redirect_route.rb @@ -7,4 +7,6 @@ class RedirectRoute < ActiveRecord::Base length: { within: 1..255 }, presence: true, uniqueness: { case_sensitive: false } + + scope :matching_path_and_descendants, -> (path) { where('redirect_routes.path = ? OR redirect_routes.path LIKE ?', path, "#{sanitize_sql_like(path)}/%") } end diff --git a/app/models/route.rb b/app/models/route.rb index df801714b5f4c..e0d85ff7db735 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -8,7 +8,8 @@ class Route < ActiveRecord::Base presence: true, uniqueness: { case_sensitive: false } - after_update :create_redirect_if_path_changed + after_save :delete_conflicting_redirects + after_update :create_redirect_for_old_path after_update :rename_direct_descendant_routes scope :inside_path, -> (path) { where('routes.path LIKE ?', "#{sanitize_sql_like(path)}/%") } @@ -34,13 +35,19 @@ def rename_direct_descendant_routes end end - def create_redirect_if_path_changed - if path_changed? - create_redirect(path_was) - end + def delete_conflicting_redirects + conflicting_redirects.delete_all + end + + def conflicting_redirects + RedirectRoute.matching_path_and_descendants(path) + end + + def create_redirect_for_old_path + create_redirect(path_was) if path_changed? end - def create_redirect(old_path) - source.redirect_routes.create(path: old_path) + def create_redirect(path) + RedirectRoute.create(source: source, path: path) end end diff --git a/spec/models/redirect_route_spec.rb b/spec/models/redirect_route_spec.rb index fb72d87d94d12..71827421dd720 100644 --- a/spec/models/redirect_route_spec.rb +++ b/spec/models/redirect_route_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' describe RedirectRoute, models: true do - let!(:group) { create(:group, path: 'git_lab', name: 'git_lab') } + let(:group) { create(:group) } let!(:redirect_route) { group.redirect_routes.create(path: 'gitlabb') } describe 'relationships' do @@ -13,4 +13,15 @@ it { is_expected.to validate_presence_of(:path) } it { is_expected.to validate_uniqueness_of(:path) } end + + describe '.matching_path_and_descendants' do + let!(:redirect2) { group.redirect_routes.create(path: 'gitlabb/test') } + let!(:redirect3) { group.redirect_routes.create(path: 'gitlabb/test/foo') } + let!(:redirect4) { group.redirect_routes.create(path: 'gitlabb/test/foo/bar') } + let!(:redirect5) { group.redirect_routes.create(path: 'gitlabb/test/baz') } + + it 'returns correct routes' do + expect(RedirectRoute.matching_path_and_descendants('gitlabb/test')).to match_array([redirect2, redirect3, redirect4, redirect5]) + end + end end diff --git a/spec/models/route_spec.rb b/spec/models/route_spec.rb index c95043469a81b..1aeddcef982b3 100644 --- a/spec/models/route_spec.rb +++ b/spec/models/route_spec.rb @@ -1,19 +1,43 @@ require 'spec_helper' describe Route, models: true do - let!(:group) { create(:group, path: 'git_lab', name: 'git_lab') } - let!(:route) { group.route } + let(:group) { create(:group, path: 'git_lab', name: 'git_lab') } + let(:route) { group.route } describe 'relationships' do it { is_expected.to belong_to(:source) } end describe 'validations' do + before { route } it { is_expected.to validate_presence_of(:source) } it { is_expected.to validate_presence_of(:path) } it { is_expected.to validate_uniqueness_of(:path) } end + describe 'callbacks' do + context 'after update' do + it 'calls #create_redirect_for_old_path' do + expect(route).to receive(:create_redirect_for_old_path) + route.update_attributes(path: 'foo') + end + + it 'calls #delete_conflicting_redirects' do + expect(route).to receive(:delete_conflicting_redirects) + route.update_attributes(path: 'foo') + end + end + + context 'after create' do + it 'calls #delete_conflicting_redirects' do + route.destroy + new_route = Route.new(source: group, path: group.path) + expect(new_route).to receive(:delete_conflicting_redirects) + new_route.save! + end + end + end + describe '.inside_path' do let!(:nested_group) { create(:group, path: 'test', name: 'test', parent: group) } let!(:deep_nested_group) { create(:group, path: 'foo', name: 'foo', parent: nested_group) } @@ -50,7 +74,7 @@ context 'when route name is set' do before { route.update_attributes(path: 'bar') } - it "updates children routes with new path" do + it 'updates children routes with new path' do expect(described_class.exists?(path: 'bar')).to be_truthy expect(described_class.exists?(path: 'bar/test')).to be_truthy expect(described_class.exists?(path: 'bar/test/foo')).to be_truthy @@ -69,10 +93,24 @@ expect(route.update_attributes(path: 'bar')).to be_truthy end end + + context 'when conflicting redirects exist' do + let!(:conflicting_redirect1) { route.create_redirect('bar/test') } + let!(:conflicting_redirect2) { route.create_redirect('bar/test/foo') } + let!(:conflicting_redirect3) { route.create_redirect('gitlab-org') } + + it 'deletes the conflicting redirects' do + route.update_attributes(path: 'bar') + + expect(RedirectRoute.exists?(path: 'bar/test')).to be_falsey + expect(RedirectRoute.exists?(path: 'bar/test/foo')).to be_falsey + expect(RedirectRoute.exists?(path: 'gitlab-org')).to be_truthy + end + end end context 'name update' do - it "updates children routes with new path" do + it 'updates children routes with new path' do route.update_attributes(name: 'bar') expect(described_class.exists?(name: 'bar')).to be_truthy @@ -91,21 +129,70 @@ end end - describe '#create_redirect_if_path_changed' do + describe '#create_redirect_for_old_path' do context 'if the path changed' do it 'creates a RedirectRoute for the old path' do + redirect_scope = route.source.redirect_routes.where(path: 'git_lab') + expect(redirect_scope.exists?).to be_falsey route.path = 'new-path' route.save! - redirect = route.source.redirect_routes.first - expect(redirect.path).to eq('git_lab') + expect(redirect_scope.exists?).to be_truthy end end + end - context 'if the path did not change' do - it 'does not create a RedirectRoute' do - route.updated_at = Time.zone.now.utc - route.save! - expect(route.source.redirect_routes).to be_empty + describe '#create_redirect' do + it 'creates a RedirectRoute with the same source' do + redirect_route = route.create_redirect('foo') + expect(redirect_route).to be_a(RedirectRoute) + expect(redirect_route).to be_persisted + expect(redirect_route.source).to eq(route.source) + expect(redirect_route.path).to eq('foo') + end + end + + describe '#delete_conflicting_redirects' do + context 'when a redirect route with the same path exists' do + let!(:redirect1) { route.create_redirect(route.path) } + + it 'deletes the redirect' do + route.delete_conflicting_redirects + expect(route.conflicting_redirects).to be_empty + end + + context 'when redirect routes with paths descending from the route path exists' do + let!(:redirect2) { route.create_redirect("#{route.path}/foo") } + let!(:redirect3) { route.create_redirect("#{route.path}/foo/bar") } + let!(:redirect4) { route.create_redirect("#{route.path}/baz/quz") } + let!(:other_redirect) { route.create_redirect("other") } + + it 'deletes all redirects with paths that descend from the route path' do + route.delete_conflicting_redirects + expect(route.conflicting_redirects).to be_empty + end + end + end + end + + describe '#conflicting_redirects' do + context 'when a redirect route with the same path exists' do + let!(:redirect1) { route.create_redirect(route.path) } + + it 'returns the redirect route' do + expect(route.conflicting_redirects).to be_an(ActiveRecord::Relation) + expect(route.conflicting_redirects).to match_array([redirect1]) + end + + context 'when redirect routes with paths descending from the route path exists' do + let!(:redirect2) { route.create_redirect("#{route.path}/foo") } + let!(:redirect3) { route.create_redirect("#{route.path}/foo/bar") } + let!(:redirect4) { route.create_redirect("#{route.path}/baz/quz") } + let!(:other_redirect) { route.create_redirect("other") } + + it 'returns the redirect routes' do + expect(route.conflicting_redirects).to be_an(ActiveRecord::Relation) + expect(route.conflicting_redirects).to match_array([redirect1, redirect2, redirect3, redirect4]) + end end end end -- GitLab From ca5c762cf577838a98c0ba96ce20b5f1d5dafc91 Mon Sep 17 00:00:00 2001 From: Michael Kozono <mkozono@gmail.com> Date: Wed, 3 May 2017 10:33:01 -0700 Subject: [PATCH 326/363] Refactor --- app/controllers/users_controller.rb | 8 +++----- app/models/route.rb | 10 ++++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 6b6b3b20a8ddf..d7c1241698af7 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -94,11 +94,9 @@ def exists private def authorize_read_user! - if can?(current_user, :read_user, user) - ensure_canonical_path(user.namespace, params[:username]) - else - render_404 - end + render_404 unless can?(current_user, :read_user, user) + + ensure_canonical_path(user.namespace, params[:username]) end def user diff --git a/app/models/route.rb b/app/models/route.rb index e0d85ff7db735..accc423ae463a 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -43,11 +43,13 @@ def conflicting_redirects RedirectRoute.matching_path_and_descendants(path) end - def create_redirect_for_old_path - create_redirect(path_was) if path_changed? - end - def create_redirect(path) RedirectRoute.create(source: source, path: path) end + + private + + def create_redirect_for_old_path + create_redirect(path_was) if path_changed? + end end -- GitLab From e8f2a7007a96d19cf7f7e7278bf21007fafa9f1c Mon Sep 17 00:00:00 2001 From: Michael Kozono <mkozono@gmail.com> Date: Wed, 3 May 2017 10:54:06 -0700 Subject: [PATCH 327/363] Fix or workaround spec failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not sure why this fixes this error: ``` ActiveRecord::RecordNotUnique: PG::UniqueViolation: ERROR: duplicate key value violates unique constraint "index_routes_on_source_type_and_source_id" DETAIL: Key (source_type, source_id)=(Project, 1) already exists. : INSERT INTO "routes" ("source_type", "path", "name", "source_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5, $6) RETURNING "id" ``` --- spec/features/projects/project_settings_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/features/projects/project_settings_spec.rb b/spec/features/projects/project_settings_spec.rb index c0311e73ef5e7..11dcab4d73742 100644 --- a/spec/features/projects/project_settings_spec.rb +++ b/spec/features/projects/project_settings_spec.rb @@ -93,8 +93,8 @@ describe 'Transfer project section', js: true do # Not using empty project because we need a repo to exist - let(:project) { create(:project, namespace: user.namespace, name: 'gitlabhq') } - let(:group) { create(:group) } + let!(:project) { create(:project, namespace: user.namespace, name: 'gitlabhq') } + let!(:group) { create(:group) } before(:context) { TestEnv.clean_test_path } before(:example) { group.add_owner(user) } -- GitLab From 4da848ef28fb9ff247145670a107dff82c83b270 Mon Sep 17 00:00:00 2001 From: Michael Kozono <mkozono@gmail.com> Date: Wed, 3 May 2017 11:49:17 -0700 Subject: [PATCH 328/363] Add index for source association and for path --- ...0503184421_add_index_to_redirect_routes.rb | 22 +++++++++++++++++++ db/schema.rb | 5 ++++- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20170503184421_add_index_to_redirect_routes.rb diff --git a/db/migrate/20170503184421_add_index_to_redirect_routes.rb b/db/migrate/20170503184421_add_index_to_redirect_routes.rb new file mode 100644 index 0000000000000..5991f6ab6a11a --- /dev/null +++ b/db/migrate/20170503184421_add_index_to_redirect_routes.rb @@ -0,0 +1,22 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +# rubocop:disable RemoveIndex +class AddIndexToRedirectRoutes < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index(:redirect_routes, :path, unique: true) + add_concurrent_index(:redirect_routes, [:source_type, :source_id]) + end + + def down + remove_index(:redirect_routes, :path) if index_exists?(:redirect_routes, :path) + remove_index(:redirect_routes, [:source_type, :source_id]) if index_exists?(:redirect_routes, [:source_type, :source_id]) + end +end diff --git a/db/schema.rb b/db/schema.rb index 3a8970b2235e8..45c3b9a4c917c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1048,6 +1048,9 @@ t.datetime "updated_at", null: false end + add_index "redirect_routes", ["path"], name: "index_redirect_routes_on_path", unique: true, using: :btree + add_index "redirect_routes", ["source_type", "source_id"], name: "index_redirect_routes_on_source_type_and_source_id", using: :btree + create_table "releases", force: :cascade do |t| t.string "tag" t.text "description" @@ -1423,4 +1426,4 @@ add_foreign_key "timelogs", "merge_requests", name: "fk_timelogs_merge_requests_merge_request_id", on_delete: :cascade add_foreign_key "trending_projects", "projects", on_delete: :cascade add_foreign_key "u2f_registrations", "users" -end \ No newline at end of file +end -- GitLab From 03144e1c0f2ba41a7570850b69d5029bc2619fd2 Mon Sep 17 00:00:00 2001 From: Michael Kozono <mkozono@gmail.com> Date: Wed, 3 May 2017 12:00:52 -0700 Subject: [PATCH 329/363] Index redirect_routes path for LIKE --- ...032_index_redirect_routes_path_for_like.rb | 29 +++++++++++++++++++ db/schema.rb | 1 + lib/tasks/migrate/setup_postgresql.rake | 1 + 3 files changed, 31 insertions(+) create mode 100644 db/migrate/20170503185032_index_redirect_routes_path_for_like.rb diff --git a/db/migrate/20170503185032_index_redirect_routes_path_for_like.rb b/db/migrate/20170503185032_index_redirect_routes_path_for_like.rb new file mode 100644 index 0000000000000..5b8b6c828be8e --- /dev/null +++ b/db/migrate/20170503185032_index_redirect_routes_path_for_like.rb @@ -0,0 +1,29 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class IndexRedirectRoutesPathForLike < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + INDEX_NAME = 'index_redirect_routes_on_path_text_pattern_ops' + + disable_ddl_transaction! + + def up + return unless Gitlab::Database.postgresql? + + unless index_exists?(:redirect_routes, :path, name: INDEX_NAME) + execute("CREATE INDEX CONCURRENTLY #{INDEX_NAME} ON redirect_routes (path varchar_pattern_ops);") + end + end + + def down + return unless Gitlab::Database.postgresql? + + if index_exists?(:redirect_routes, :path, name: INDEX_NAME) + execute("DROP INDEX CONCURRENTLY #{INDEX_NAME};") + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 45c3b9a4c917c..8d3f4b4bd3616 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1049,6 +1049,7 @@ end add_index "redirect_routes", ["path"], name: "index_redirect_routes_on_path", unique: true, using: :btree + add_index "redirect_routes", ["path"], name: "index_redirect_routes_on_path_text_pattern_ops", using: :btree, opclasses: {"path"=>"varchar_pattern_ops"} add_index "redirect_routes", ["source_type", "source_id"], name: "index_redirect_routes_on_source_type_and_source_id", using: :btree create_table "releases", force: :cascade do |t| diff --git a/lib/tasks/migrate/setup_postgresql.rake b/lib/tasks/migrate/setup_postgresql.rake index 8938bc515f5ae..1e00b47303d5f 100644 --- a/lib/tasks/migrate/setup_postgresql.rake +++ b/lib/tasks/migrate/setup_postgresql.rake @@ -11,4 +11,5 @@ task setup_postgresql: :environment do AddUsersLowerUsernameEmailIndexes.new.up AddLowerPathIndexToRoutes.new.up IndexRoutesPathForLike.new.up + IndexRedirectRoutesPathForLike.new.up end -- GitLab From fc061c2ecd2e292383017c703220bfb22d0d6dce Mon Sep 17 00:00:00 2001 From: Michael Kozono <mkozono@gmail.com> Date: Wed, 3 May 2017 12:01:44 -0700 Subject: [PATCH 330/363] Fix Rubocop failures --- db/migrate/20170427215854_create_redirect_routes.rb | 1 - spec/controllers/groups_controller_spec.rb | 8 ++++---- spec/features/projects/features_visibility_spec.rb | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/db/migrate/20170427215854_create_redirect_routes.rb b/db/migrate/20170427215854_create_redirect_routes.rb index 57b79a0267fd3..2bf086b3e30fc 100644 --- a/db/migrate/20170427215854_create_redirect_routes.rb +++ b/db/migrate/20170427215854_create_redirect_routes.rb @@ -1,5 +1,4 @@ class CreateRedirectRoutes < ActiveRecord::Migration - # Set this constant to true if this migration requires downtime. DOWNTIME = false diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index 3398878f33058..d86bf1a3d0e49 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -144,13 +144,13 @@ it 'does not 404' do delete :destroy, id: group.to_param.upcase - expect(response).to_not have_http_status(404) + expect(response).not_to have_http_status(404) end it 'does not redirect to the correct casing' do delete :destroy, id: group.to_param.upcase - expect(response).to_not redirect_to(group_path(group.to_param)) + expect(response).not_to redirect_to(group_path(group.to_param)) end end @@ -190,13 +190,13 @@ it 'does not 404' do post :update, id: group.to_param.upcase, group: { path: 'new_path' } - expect(response).to_not have_http_status(404) + expect(response).not_to have_http_status(404) end it 'does not redirect to the correct casing' do post :update, id: group.to_param.upcase, group: { path: 'new_path' } - expect(response).to_not redirect_to(group_path(group.to_param)) + expect(response).not_to redirect_to(group_path(group.to_param)) end end diff --git a/spec/features/projects/features_visibility_spec.rb b/spec/features/projects/features_visibility_spec.rb index a6ca0f4131a0a..e1781cf320ac5 100644 --- a/spec/features/projects/features_visibility_spec.rb +++ b/spec/features/projects/features_visibility_spec.rb @@ -68,7 +68,7 @@ end describe 'project features visibility pages' do - let(:tools) { + let(:tools) do { builds: namespace_project_pipelines_path(project.namespace, project), issues: namespace_project_issues_path(project.namespace, project), @@ -76,7 +76,7 @@ snippets: namespace_project_snippets_path(project.namespace, project), merge_requests: namespace_project_merge_requests_path(project.namespace, project), } - } + end context 'normal user' do before do -- GitLab From 0c866f4a575d8127efbf3eafda83d8ccfbd97817 Mon Sep 17 00:00:00 2001 From: Michael Kozono <mkozono@gmail.com> Date: Wed, 3 May 2017 15:26:44 -0700 Subject: [PATCH 331/363] Resolve discussions --- app/controllers/users_controller.rb | 18 ++++++++------- app/models/route.rb | 22 +++++++++---------- app/models/user.rb | 2 +- ...0503184421_add_index_to_redirect_routes.rb | 5 ++--- spec/controllers/users_controller_spec.rb | 18 +++++++++++++++ 5 files changed, 42 insertions(+), 23 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index d7c1241698af7..67783866c3fe4 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -3,7 +3,6 @@ class UsersController < ApplicationController skip_before_action :authenticate_user! before_action :user, except: [:exists] - before_action :authorize_read_user!, except: [:exists] def show respond_to do |format| @@ -93,14 +92,17 @@ def exists private - def authorize_read_user! - render_404 unless can?(current_user, :read_user, user) - - ensure_canonical_path(user.namespace, params[:username]) - end - def user - @user ||= User.find_by_full_path(params[:username], follow_redirects: true) + return @user if @user + + @user = User.find_by_full_path(params[:username], follow_redirects: true) + + return render_404 unless @user + return render_404 unless can?(current_user, :read_user, @user) + + ensure_canonical_path(@user.namespace, params[:username]) + + @user end def contributed_projects diff --git a/app/models/route.rb b/app/models/route.rb index accc423ae463a..3d798ce937ba1 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -16,22 +16,22 @@ class Route < ActiveRecord::Base scope :direct_descendant_routes, -> (path) { where('routes.path LIKE ? AND routes.path NOT LIKE ?', "#{sanitize_sql_like(path)}/%", "#{sanitize_sql_like(path)}/%/%") } def rename_direct_descendant_routes - if path_changed? || name_changed? - direct_descendant_routes = self.class.direct_descendant_routes(path_was) + return if !path_changed? && !name_changed? - direct_descendant_routes.each do |route| - attributes = {} + direct_descendant_routes = self.class.direct_descendant_routes(path_was) - if path_changed? && route.path.present? - attributes[:path] = route.path.sub(path_was, path) - end + direct_descendant_routes.each do |route| + attributes = {} - if name_changed? && name_was.present? && route.name.present? - attributes[:name] = route.name.sub(name_was, name) - end + if path_changed? && route.path.present? + attributes[:path] = route.path.sub(path_was, path) + end - route.update(attributes) unless attributes.empty? + if name_changed? && name_was.present? && route.name.present? + attributes[:name] = route.name.sub(name_was, name) end + + route.update(attributes) unless attributes.empty? end end diff --git a/app/models/user.rb b/app/models/user.rb index c6354b989ade2..7da92d03427fc 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -335,7 +335,7 @@ def find_by_ssh_key_id(key_id) def find_by_full_path(path, follow_redirects: false) namespace = Namespace.find_by_full_path(path, follow_redirects: follow_redirects) - namespace.owner if namespace && namespace.owner + namespace&.owner end def reference_prefix diff --git a/db/migrate/20170503184421_add_index_to_redirect_routes.rb b/db/migrate/20170503184421_add_index_to_redirect_routes.rb index 5991f6ab6a11a..9062cf19a7398 100644 --- a/db/migrate/20170503184421_add_index_to_redirect_routes.rb +++ b/db/migrate/20170503184421_add_index_to_redirect_routes.rb @@ -1,7 +1,6 @@ # See http://doc.gitlab.com/ce/development/migration_style_guide.html # for more information on how to write migrations for GitLab. -# rubocop:disable RemoveIndex class AddIndexToRedirectRoutes < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers @@ -16,7 +15,7 @@ def up end def down - remove_index(:redirect_routes, :path) if index_exists?(:redirect_routes, :path) - remove_index(:redirect_routes, [:source_type, :source_id]) if index_exists?(:redirect_routes, [:source_type, :source_id]) + remove_concurrent_index(:redirect_routes, :path) if index_exists?(:redirect_routes, :path) + remove_concurrent_index(:redirect_routes, [:source_type, :source_id]) if index_exists?(:redirect_routes, [:source_type, :source_id]) end end diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 73fd5b6f90ded..73f448d69ed05 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -84,6 +84,24 @@ expect(response).to redirect_to(user) end end + + context 'when a user by that username does not exist' do + context 'when logged out' do + it 'renders 404 (does not redirect to login)' do + get :show, username: 'nonexistent' + expect(response).to have_http_status(404) + end + end + + context 'when logged in' do + before { sign_in(user) } + + it 'renders 404' do + get :show, username: 'nonexistent' + expect(response).to have_http_status(404) + end + end + end end describe 'GET #calendar' do -- GitLab From e4bcc90d95fa3b78544cb9ddd6019a5f914c1628 Mon Sep 17 00:00:00 2001 From: Michael Kozono <mkozono@gmail.com> Date: Thu, 4 May 2017 11:12:19 -0700 Subject: [PATCH 332/363] =?UTF-8?q?Add=20=E2=80=9Cproject=20moved=E2=80=9D?= =?UTF-8?q?=20flash=20message=20on=20redirect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/concerns/routable_actions.rb | 1 + spec/controllers/groups_controller_spec.rb | 2 ++ spec/controllers/projects_controller_spec.rb | 2 ++ spec/controllers/users_controller_spec.rb | 4 ++++ 4 files changed, 9 insertions(+) diff --git a/app/controllers/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb index 6f16377a156f6..1714bc25e524c 100644 --- a/app/controllers/concerns/routable_actions.rb +++ b/app/controllers/concerns/routable_actions.rb @@ -5,6 +5,7 @@ def ensure_canonical_path(routable, requested_path) return unless request.get? if routable.full_path != requested_path + flash[:notice] = 'This project has moved to this location. Please update your links and bookmarks.' redirect_to request.original_url.sub(requested_path, routable.full_path) end end diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index d86bf1a3d0e49..b5fd611747a3d 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -65,6 +65,7 @@ get :issues, id: redirect_route.path expect(response).to redirect_to(issues_group_path(group.to_param)) + expect(controller).to set_flash[:notice].to(/moved/) end end end @@ -108,6 +109,7 @@ get :merge_requests, id: redirect_route.path expect(response).to redirect_to(merge_requests_group_path(group.to_param)) + expect(controller).to set_flash[:notice].to(/moved/) end end end diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index 1b0dd7c63696d..5e3e943c124e1 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -226,6 +226,7 @@ get :show, namespace_id: 'foo', id: 'bar' expect(response).to redirect_to(public_project) + expect(controller).to set_flash[:notice].to(/moved/) end end end @@ -471,6 +472,7 @@ get :refs, namespace_id: 'foo', id: 'bar' expect(response).to redirect_to(refs_namespace_project_path(namespace_id: public_project.namespace, id: public_project)) + expect(controller).to set_flash[:notice].to(/moved/) end end end diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 73f448d69ed05..5e8caa89cb7ad 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -82,6 +82,7 @@ get :show, username: redirect_route.path expect(response).to redirect_to(user) + expect(controller).to set_flash[:notice].to(/moved/) end end @@ -159,6 +160,7 @@ get :calendar, username: redirect_route.path expect(response).to redirect_to(user_calendar_path(user)) + expect(controller).to set_flash[:notice].to(/moved/) end end end @@ -211,6 +213,7 @@ get :calendar_activities, username: redirect_route.path expect(response).to redirect_to(user_calendar_activities_path(user)) + expect(controller).to set_flash[:notice].to(/moved/) end end end @@ -263,6 +266,7 @@ get :snippets, username: redirect_route.path expect(response).to redirect_to(user_snippets_path(user)) + expect(controller).to set_flash[:notice].to(/moved/) end end end -- GitLab From 9e48f02ea802814e4df1f1de5ed509942dca7581 Mon Sep 17 00:00:00 2001 From: Michael Kozono <mkozono@gmail.com> Date: Thu, 4 May 2017 14:20:13 -0700 Subject: [PATCH 333/363] Dry up routable lookups. Fixes #30317 Note: This changes the behavior of user lookups (see the spec change) so it acts the same way as groups and projects. Unauthenticated clients attempting to access a user page will be redirected to login whether the user exists and is publicly restricted, or does not exist at all. --- app/controllers/concerns/routable_actions.rb | 28 ++++++++++- .../groups/application_controller.rb | 17 ++----- .../projects/application_controller.rb | 49 +++++++++---------- app/controllers/users_controller.rb | 11 +---- spec/controllers/users_controller_spec.rb | 8 +-- 5 files changed, 58 insertions(+), 55 deletions(-) diff --git a/app/controllers/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb index 1714bc25e524c..ba236fa5459a2 100644 --- a/app/controllers/concerns/routable_actions.rb +++ b/app/controllers/concerns/routable_actions.rb @@ -1,12 +1,36 @@ module RoutableActions extend ActiveSupport::Concern + def find_routable!(routable_klass, requested_full_path, extra_authorization_method: nil) + routable = routable_klass.find_by_full_path(requested_full_path, follow_redirects: request.get?) + + if authorized?(routable_klass, routable, extra_authorization_method) + ensure_canonical_path(routable, requested_full_path) + routable + else + route_not_found + nil + end + end + + def authorized?(routable_klass, routable, extra_authorization_method) + action = :"read_#{routable_klass.to_s.underscore}" + return false unless can?(current_user, action, routable) + + if extra_authorization_method + send(extra_authorization_method, routable) + else + true + end + end + def ensure_canonical_path(routable, requested_path) return unless request.get? - if routable.full_path != requested_path + canonical_path = routable.try(:full_path) || routable.namespace.full_path + if canonical_path != requested_path flash[:notice] = 'This project has moved to this location. Please update your links and bookmarks.' - redirect_to request.original_url.sub(requested_path, routable.full_path) + redirect_to request.original_url.sub(requested_path, canonical_path) end end end diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb index 209d8b1a08afe..2157a56dea240 100644 --- a/app/controllers/groups/application_controller.rb +++ b/app/controllers/groups/application_controller.rb @@ -9,20 +9,11 @@ class Groups::ApplicationController < ApplicationController private def group - unless @group - given_path = params[:group_id] || params[:id] - @group = Group.find_by_full_path(given_path, follow_redirects: request.get?) - - if @group && can?(current_user, :read_group, @group) - ensure_canonical_path(@group, given_path) - else - @group = nil - - route_not_found - end - end + @group ||= find_routable!(Group, requested_full_path) + end - @group + def requested_full_path + params[:group_id] || params[:id] end def group_projects diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index dbdf68776f1d7..2301e1cca779b 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -2,6 +2,7 @@ class Projects::ApplicationController < ApplicationController include RoutableActions skip_before_action :authenticate_user! + before_action :redirect_git_extension before_action :project before_action :repository layout 'project' @@ -10,34 +11,30 @@ class Projects::ApplicationController < ApplicationController private - def project - unless @project - namespace = params[:namespace_id] - id = params[:project_id] || params[:id] - - # Redirect from - # localhost/group/project.git - # to - # localhost/group/project - # - if params[:format] == 'git' - redirect_to request.original_url.gsub(/\.git\/?\Z/, '') - return - end - - project_path = "#{namespace}/#{id}" - @project = Project.find_by_full_path(project_path, follow_redirects: request.get?) - - if can?(current_user, :read_project, @project) && !@project.pending_delete? - ensure_canonical_path(@project, project_path) - else - @project = nil - - route_not_found - end + def redirect_git_extension + # Redirect from + # localhost/group/project.git + # to + # localhost/group/project + # + if params[:format] == 'git' + redirect_to request.original_url.gsub(/\.git\/?\Z/, '') + return end + end + + def project + @project ||= find_routable!(Project, requested_full_path, extra_authorization_method: :project_not_being_deleted?) + end + + def requested_full_path + namespace = params[:namespace_id] + id = params[:project_id] || params[:id] + "#{namespace}/#{id}" + end - @project + def project_not_being_deleted?(project) + !project.pending_delete? end def repository diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 67783866c3fe4..ca89ed221c677 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -93,16 +93,7 @@ def exists private def user - return @user if @user - - @user = User.find_by_full_path(params[:username], follow_redirects: true) - - return render_404 unless @user - return render_404 unless can?(current_user, :read_user, @user) - - ensure_canonical_path(@user.namespace, params[:username]) - - @user + @user ||= find_routable!(User, params[:username]) end def contributed_projects diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 5e8caa89cb7ad..9b6b9358a4022 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -36,9 +36,9 @@ end context 'when logged out' do - it 'renders 404' do + it 'redirects to login page' do get :show, username: user.username - expect(response).to have_http_status(404) + expect(response).to redirect_to new_user_session_path end end @@ -88,9 +88,9 @@ context 'when a user by that username does not exist' do context 'when logged out' do - it 'renders 404 (does not redirect to login)' do + it 'redirects to login page' do get :show, username: 'nonexistent' - expect(response).to have_http_status(404) + expect(response).to redirect_to new_user_session_path end end -- GitLab From f05469f99b8c52c4dab7ac9160b47676c87124f9 Mon Sep 17 00:00:00 2001 From: Michael Kozono <mkozono@gmail.com> Date: Thu, 4 May 2017 17:06:01 -0700 Subject: [PATCH 334/363] Resolve discussions --- app/controllers/concerns/routable_actions.rb | 16 +++++++++------- .../groups/application_controller.rb | 6 +----- .../projects/application_controller.rb | 19 ++++++------------- app/models/user.rb | 4 ++++ .../17361-redirect-renamed-paths.yml | 4 ++++ spec/controllers/groups_controller_spec.rb | 2 ++ spec/controllers/projects_controller_spec.rb | 1 + spec/controllers/users_controller_spec.rb | 4 ++++ 8 files changed, 31 insertions(+), 25 deletions(-) create mode 100644 changelogs/unreleased/17361-redirect-renamed-paths.yml diff --git a/app/controllers/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb index ba236fa5459a2..d4ab67824441b 100644 --- a/app/controllers/concerns/routable_actions.rb +++ b/app/controllers/concerns/routable_actions.rb @@ -1,10 +1,10 @@ module RoutableActions extend ActiveSupport::Concern - def find_routable!(routable_klass, requested_full_path, extra_authorization_method: nil) + def find_routable!(routable_klass, requested_full_path, extra_authorization_proc: nil) routable = routable_klass.find_by_full_path(requested_full_path, follow_redirects: request.get?) - if authorized?(routable_klass, routable, extra_authorization_method) + if routable_authorized?(routable_klass, routable, extra_authorization_proc) ensure_canonical_path(routable, requested_full_path) routable else @@ -13,12 +13,12 @@ def find_routable!(routable_klass, requested_full_path, extra_authorization_meth end end - def authorized?(routable_klass, routable, extra_authorization_method) + def routable_authorized?(routable_klass, routable, extra_authorization_proc) action = :"read_#{routable_klass.to_s.underscore}" return false unless can?(current_user, action, routable) - if extra_authorization_method - send(extra_authorization_method, routable) + if extra_authorization_proc + extra_authorization_proc.call(routable) else true end @@ -27,9 +27,11 @@ def authorized?(routable_klass, routable, extra_authorization_method) def ensure_canonical_path(routable, requested_path) return unless request.get? - canonical_path = routable.try(:full_path) || routable.namespace.full_path + canonical_path = routable.full_path if canonical_path != requested_path - flash[:notice] = 'This project has moved to this location. Please update your links and bookmarks.' + if canonical_path.casecmp(requested_path) != 0 + flash[:notice] = "Project '#{requested_path}' was moved to '#{canonical_path}'. Please update any links and bookmarks that may still have the old path." + end redirect_to request.original_url.sub(requested_path, canonical_path) end end diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb index 2157a56dea240..afffb813b440b 100644 --- a/app/controllers/groups/application_controller.rb +++ b/app/controllers/groups/application_controller.rb @@ -9,11 +9,7 @@ class Groups::ApplicationController < ApplicationController private def group - @group ||= find_routable!(Group, requested_full_path) - end - - def requested_full_path - params[:group_id] || params[:id] + @group ||= find_routable!(Group, params[:group_id] || params[:id]) end def group_projects diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index 2301e1cca779b..25232fc9457c6 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -17,24 +17,17 @@ def redirect_git_extension # to # localhost/group/project # - if params[:format] == 'git' - redirect_to request.original_url.gsub(/\.git\/?\Z/, '') - return - end + redirect_to url_for(params.merge(format: nil)) if params[:format] == 'git' end def project - @project ||= find_routable!(Project, requested_full_path, extra_authorization_method: :project_not_being_deleted?) - end - - def requested_full_path - namespace = params[:namespace_id] - id = params[:project_id] || params[:id] - "#{namespace}/#{id}" + @project ||= find_routable!(Project, + File.join(params[:namespace_id], params[:project_id] || params[:id]), + extra_authorization_proc: project_not_being_deleted?) end - def project_not_being_deleted?(project) - !project.pending_delete? + def project_not_being_deleted? + ->(project) { !project.pending_delete? } end def repository diff --git a/app/models/user.rb b/app/models/user.rb index 7da92d03427fc..dd2c8f1b6ef1b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -360,6 +360,10 @@ def ghost end end + def full_path + username + end + def self.internal_attributes [:ghost] end diff --git a/changelogs/unreleased/17361-redirect-renamed-paths.yml b/changelogs/unreleased/17361-redirect-renamed-paths.yml new file mode 100644 index 0000000000000..7a33c9fb3ec47 --- /dev/null +++ b/changelogs/unreleased/17361-redirect-renamed-paths.yml @@ -0,0 +1,4 @@ +--- +title: Redirect old links after renaming a user/group/project. +merge_request: 10370 +author: diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index b5fd611747a3d..073b87a1cb483 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -55,6 +55,7 @@ get :issues, id: group.to_param.upcase expect(response).to redirect_to(issues_group_path(group.to_param)) + expect(controller).not_to set_flash[:notice] end end @@ -99,6 +100,7 @@ get :merge_requests, id: group.to_param.upcase expect(response).to redirect_to(merge_requests_group_path(group.to_param)) + expect(controller).not_to set_flash[:notice] end end diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index 5e3e943c124e1..e46ef447df252 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -185,6 +185,7 @@ expect(assigns(:project)).to eq(public_project) expect(response).to redirect_to("/#{public_project.full_path}") + expect(controller).not_to set_flash[:notice] end end end diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 9b6b9358a4022..74c5aa44ba96d 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -71,6 +71,7 @@ get :show, username: user.username.downcase expect(response).to redirect_to(user) + expect(controller).not_to set_flash[:notice] end end end @@ -149,6 +150,7 @@ get :calendar, username: user.username.downcase expect(response).to redirect_to(user_calendar_path(user)) + expect(controller).not_to set_flash[:notice] end end end @@ -202,6 +204,7 @@ get :calendar_activities, username: user.username.downcase expect(response).to redirect_to(user_calendar_activities_path(user)) + expect(controller).not_to set_flash[:notice] end end end @@ -255,6 +258,7 @@ get :snippets, username: user.username.downcase expect(response).to redirect_to(user_snippets_path(user)) + expect(controller).not_to set_flash[:notice] end end end -- GitLab From e1c245af51e294c84552cff8021342e7ae493b8a Mon Sep 17 00:00:00 2001 From: Michael Kozono <mkozono@gmail.com> Date: Fri, 5 May 2017 10:48:01 -0700 Subject: [PATCH 335/363] Resolve discussions --- app/controllers/projects/application_controller.rb | 11 +++++------ app/models/route.rb | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index 25232fc9457c6..b4b0dfc3eb890 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -21,13 +21,12 @@ def redirect_git_extension end def project - @project ||= find_routable!(Project, - File.join(params[:namespace_id], params[:project_id] || params[:id]), - extra_authorization_proc: project_not_being_deleted?) - end + return @project if @project + + path = File.join(params[:namespace_id], params[:project_id] || params[:id]) + auth_proc = ->(project) { !project.pending_delete? } - def project_not_being_deleted? - ->(project) { !project.pending_delete? } + @project = find_routable!(Project, path, extra_authorization_proc: auth_proc) end def repository diff --git a/app/models/route.rb b/app/models/route.rb index 3d798ce937ba1..b34cce9077ac1 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -16,7 +16,7 @@ class Route < ActiveRecord::Base scope :direct_descendant_routes, -> (path) { where('routes.path LIKE ? AND routes.path NOT LIKE ?', "#{sanitize_sql_like(path)}/%", "#{sanitize_sql_like(path)}/%/%") } def rename_direct_descendant_routes - return if !path_changed? && !name_changed? + return unless path_changed? || name_changed? direct_descendant_routes = self.class.direct_descendant_routes(path_was) -- GitLab From f1d48c25a2fa53995ed9fa491a72193bfc83be32 Mon Sep 17 00:00:00 2001 From: Clement Ho <ClemMakesApps@gmail.com> Date: Fri, 5 May 2017 14:45:14 -0500 Subject: [PATCH 336/363] Fix failing spec and eslint --- .../user_mock_data_helper.js} | 2 +- spec/javascripts/sidebar/assignees_spec.js | 12 +++++------ spec/javascripts/sidebar/mock_data.js | 2 +- .../sidebar/sidebar_mediator_spec.js | 10 ++++++---- .../sidebar/sidebar_service_spec.js | 20 +++++++++++-------- .../javascripts/sidebar/sidebar_store_spec.js | 2 +- 6 files changed, 27 insertions(+), 21 deletions(-) rename spec/javascripts/{sidebar/user_mock_data.js => helpers/user_mock_data_helper.js} (75%) diff --git a/spec/javascripts/sidebar/user_mock_data.js b/spec/javascripts/helpers/user_mock_data_helper.js similarity index 75% rename from spec/javascripts/sidebar/user_mock_data.js rename to spec/javascripts/helpers/user_mock_data_helper.js index 9e7b834623878..a9783ea065cf7 100644 --- a/spec/javascripts/sidebar/user_mock_data.js +++ b/spec/javascripts/helpers/user_mock_data_helper.js @@ -4,7 +4,7 @@ export default { for (let i = 0; i < numberUsers; i = i += 1) { users.push( { - avatarUrl: 'http://gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + avatar: 'http://gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', id: (i + 1), name: `GitLab User ${i}`, username: `gitlab${i}`, diff --git a/spec/javascripts/sidebar/assignees_spec.js b/spec/javascripts/sidebar/assignees_spec.js index cf47940ef1c18..c9453a2118972 100644 --- a/spec/javascripts/sidebar/assignees_spec.js +++ b/spec/javascripts/sidebar/assignees_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import Assignee from '~/sidebar/components/assignees/assignees'; import UsersMock from './mock_data'; -import UsersMockHelper from '../test_helpers/user_mock_data'; +import UsersMockHelper from '../helpers/user_mock_data_helper'; describe('Assignee component', () => { let component; @@ -86,7 +86,7 @@ describe('Assignee component', () => { const collapsed = component.$el.querySelector('.sidebar-collapsed-icon'); const assignee = collapsed.children[0]; expect(collapsed.childElementCount).toEqual(1); - expect(assignee.querySelector('.avatar').getAttribute('src')).toEqual(UsersMock.user.avatarUrl); + expect(assignee.querySelector('.avatar').getAttribute('src')).toEqual(UsersMock.user.avatar); expect(assignee.querySelector('.avatar').getAttribute('alt')).toEqual(`${UsersMock.user.name}'s avatar`); expect(assignee.querySelector('.author').innerText.trim()).toEqual(UsersMock.user.name); }); @@ -104,7 +104,7 @@ describe('Assignee component', () => { expect(component.$el.querySelector('.author_link')).not.toBeNull(); // The image - expect(component.$el.querySelector('.author_link img').getAttribute('src')).toEqual(UsersMock.user.avatarUrl); + expect(component.$el.querySelector('.author_link img').getAttribute('src')).toEqual(UsersMock.user.avatar); // Author name expect(component.$el.querySelector('.author_link .author').innerText.trim()).toEqual(UsersMock.user.name); // Username @@ -141,12 +141,12 @@ describe('Assignee component', () => { expect(collapsed.childElementCount).toEqual(2); const first = collapsed.children[0]; - expect(first.querySelector('.avatar').getAttribute('src')).toEqual(users[0].avatarUrl); + expect(first.querySelector('.avatar').getAttribute('src')).toEqual(users[0].avatar); expect(first.querySelector('.avatar').getAttribute('alt')).toEqual(`${users[0].name}'s avatar`); expect(first.querySelector('.author').innerText.trim()).toEqual(users[0].name); const second = collapsed.children[1]; - expect(second.querySelector('.avatar').getAttribute('src')).toEqual(users[1].avatarUrl); + expect(second.querySelector('.avatar').getAttribute('src')).toEqual(users[1].avatar); expect(second.querySelector('.avatar').getAttribute('alt')).toEqual(`${users[1].name}'s avatar`); expect(second.querySelector('.author').innerText.trim()).toEqual(users[1].name); }); @@ -165,7 +165,7 @@ describe('Assignee component', () => { expect(collapsed.childElementCount).toEqual(2); const first = collapsed.children[0]; - expect(first.querySelector('.avatar').getAttribute('src')).toEqual(users[0].avatarUrl); + expect(first.querySelector('.avatar').getAttribute('src')).toEqual(users[0].avatar); expect(first.querySelector('.avatar').getAttribute('alt')).toEqual(`${users[0].name}'s avatar`); expect(first.querySelector('.author').innerText.trim()).toEqual(users[0].name); diff --git a/spec/javascripts/sidebar/mock_data.js b/spec/javascripts/sidebar/mock_data.js index 0599a63912c71..9fc8667ecc9a7 100644 --- a/spec/javascripts/sidebar/mock_data.js +++ b/spec/javascripts/sidebar/mock_data.js @@ -93,7 +93,7 @@ export default { human_total_time_spent: null, }, user: { - avatarUrl: 'http://gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + avatar: 'http://gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', id: 1, name: 'Administrator', username: 'root', diff --git a/spec/javascripts/sidebar/sidebar_mediator_spec.js b/spec/javascripts/sidebar/sidebar_mediator_spec.js index 9bfca0c099103..2b00fa173346b 100644 --- a/spec/javascripts/sidebar/sidebar_mediator_spec.js +++ b/spec/javascripts/sidebar/sidebar_mediator_spec.js @@ -24,10 +24,12 @@ describe('Sidebar mediator', () => { }); it('saves assignees', (done) => { - this.mediator.saveAssignees('issue[assignee_ids]').then((resp) => { - expect(resp.status).toEqual(200); - done(); - }); + this.mediator.saveAssignees('issue[assignee_ids]') + .then((resp) => { + expect(resp.status).toEqual(200); + done(); + }) + .catch(() => {}); }); it('fetches the data', () => { diff --git a/spec/javascripts/sidebar/sidebar_service_spec.js b/spec/javascripts/sidebar/sidebar_service_spec.js index ed7be76dbcc8d..d41162096a68f 100644 --- a/spec/javascripts/sidebar/sidebar_service_spec.js +++ b/spec/javascripts/sidebar/sidebar_service_spec.js @@ -13,16 +13,20 @@ describe('Sidebar service', () => { }); it('gets the data', (done) => { - this.service.get().then((resp) => { - expect(resp).toBeDefined(); - done(); - }); + this.service.get() + .then((resp) => { + expect(resp).toBeDefined(); + done(); + }) + .catch(() => {}); }); it('updates the data', (done) => { - this.service.update('issue[assignee_ids]', [1]).then((resp) => { - expect(resp).toBeDefined(); - done(); - }); + this.service.update('issue[assignee_ids]', [1]) + .then((resp) => { + expect(resp).toBeDefined(); + done(); + }) + .catch(() => {}); }); }); diff --git a/spec/javascripts/sidebar/sidebar_store_spec.js b/spec/javascripts/sidebar/sidebar_store_spec.js index 6c0f2b6a793f8..29facf483b54e 100644 --- a/spec/javascripts/sidebar/sidebar_store_spec.js +++ b/spec/javascripts/sidebar/sidebar_store_spec.js @@ -1,6 +1,6 @@ import SidebarStore from '~/sidebar/stores/sidebar_store'; import Mock from './mock_data'; -import UsersMockHelper from '../test_helpers/user_mock_data'; +import UsersMockHelper from '../helpers/user_mock_data_helper'; describe('Sidebar store', () => { const assignee = { -- GitLab From b0ee22609a89572d6e3f98eebccf9fb2335dd939 Mon Sep 17 00:00:00 2001 From: Michael Kozono <mkozono@gmail.com> Date: Fri, 5 May 2017 14:31:33 -0700 Subject: [PATCH 337/363] Reduce risk of deadlocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We’ve seen a deadlock in CI here https://gitlab.com/mkozono/gitlab-ce/builds/15644492#down-build-trace. This commit should not fix that particular failure, but perhaps it will avoid others. * Don’t call delete_conflicting_redirects after update if the path wasn’t changed * Rename descendants without using recursion again, so we can run delete_conflicting_redirects exactly once. --- app/models/route.rb | 24 +++++++++++++++++------- spec/models/route_spec.rb | 15 +-------------- 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/app/models/route.rb b/app/models/route.rb index b34cce9077ac1..12a7fa3d01bfb 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -8,19 +8,19 @@ class Route < ActiveRecord::Base presence: true, uniqueness: { case_sensitive: false } - after_save :delete_conflicting_redirects + after_create :delete_conflicting_redirects + after_update :delete_conflicting_redirects, if: :path_changed? after_update :create_redirect_for_old_path - after_update :rename_direct_descendant_routes + after_update :rename_descendants scope :inside_path, -> (path) { where('routes.path LIKE ?', "#{sanitize_sql_like(path)}/%") } - scope :direct_descendant_routes, -> (path) { where('routes.path LIKE ? AND routes.path NOT LIKE ?', "#{sanitize_sql_like(path)}/%", "#{sanitize_sql_like(path)}/%/%") } - def rename_direct_descendant_routes + def rename_descendants return unless path_changed? || name_changed? - direct_descendant_routes = self.class.direct_descendant_routes(path_was) + descendant_routes = self.class.inside_path(path_was) - direct_descendant_routes.each do |route| + descendant_routes.each do |route| attributes = {} if path_changed? && route.path.present? @@ -31,7 +31,17 @@ def rename_direct_descendant_routes attributes[:name] = route.name.sub(name_was, name) end - route.update(attributes) unless attributes.empty? + if attributes.present? + old_path = route.path + + # Callbacks must be run manually + route.update_columns(attributes) + + # We are not calling route.delete_conflicting_redirects here, in hopes + # of avoiding deadlocks. The parent (self, in this method) already + # called it, which deletes conflicts for all descendants. + route.create_redirect(old_path) if attributes[:path] + end end end diff --git a/spec/models/route_spec.rb b/spec/models/route_spec.rb index 1aeddcef982b3..c1fe1b06c52b8 100644 --- a/spec/models/route_spec.rb +++ b/spec/models/route_spec.rb @@ -50,20 +50,7 @@ end end - describe '.direct_descendant_routes' do - let!(:nested_group) { create(:group, path: 'test', name: 'test', parent: group) } - let!(:deep_nested_group) { create(:group, path: 'foo', name: 'foo', parent: nested_group) } - let!(:another_group) { create(:group, path: 'other') } - let!(:similar_group) { create(:group, path: 'gitllab') } - let!(:another_group_nested) { create(:group, path: 'another', name: 'another', parent: similar_group) } - - it 'returns correct routes' do - expect(Route.direct_descendant_routes('git_lab')).to match_array([nested_group.route]) - expect(Route.direct_descendant_routes('git_lab/test')).to match_array([deep_nested_group.route]) - end - end - - describe '#rename_direct_descendant_routes' do + describe '#rename_descendants' do let!(:nested_group) { create(:group, path: 'test', name: 'test', parent: group) } let!(:deep_nested_group) { create(:group, path: 'foo', name: 'foo', parent: nested_group) } let!(:similar_group) { create(:group, path: 'gitlab-org', name: 'gitlab-org') } -- GitLab From b801f414aa6516ed6cd816220adff2f844175b91 Mon Sep 17 00:00:00 2001 From: Kushal Pandya <kushalspandya@gmail.com> Date: Fri, 5 May 2017 21:41:55 +0000 Subject: [PATCH 338/363] Fix Karma failures for jQuery deferreds --- app/assets/javascripts/notes.js | 2 +- spec/javascripts/notes_spec.js | 217 +++++++++++++------------------- 2 files changed, 89 insertions(+), 130 deletions(-) diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 72709f68070d9..55391ebc089a2 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1356,7 +1356,7 @@ const normalizeNewlines = function(str) { // Show updated comment content temporarily $noteBodyText.html(formContent); - $editingNote.removeClass('is-editing').addClass('being-posted fade-in-half'); + $editingNote.removeClass('is-editing fade-in-full').addClass('being-posted fade-in-half'); $editingNote.find('.note-headline-meta a').html('<i class="fa fa-spinner fa-spin" aria-label="Comment is being updated" aria-hidden="true"></i>'); /* eslint-disable promise/catch-or-return */ diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index 7bffa90ab145e..cfd599f793e73 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -29,7 +29,7 @@ import '~/notes'; $('.js-comment-button').on('click', function(e) { e.preventDefault(); }); - this.notes = new Notes(); + this.notes = new Notes('', []); }); it('modifies the Markdown field', function() { @@ -51,7 +51,7 @@ import '~/notes'; var textarea = '.js-note-text'; beforeEach(function() { - this.notes = new Notes(); + this.notes = new Notes('', []); this.autoSizeSpy = spyOnEvent($(textarea), 'autosize:update'); spyOn(this.notes, 'renderNote').and.stub(); @@ -273,9 +273,92 @@ import '~/notes'; }); }); + describe('postComment & updateComment', () => { + const sampleComment = 'foo'; + const updatedComment = 'bar'; + const note = { + id: 1234, + html: `<li class="note note-row-1234 timeline-entry" id="note_1234"> + <div class="note-text">${sampleComment}</div> + </li>`, + note: sampleComment, + valid: true + }; + let $form; + let $notesContainer; + + beforeEach(() => { + this.notes = new Notes('', []); + window.gon.current_username = 'root'; + window.gon.current_user_fullname = 'Administrator'; + $form = $('form.js-main-target-form'); + $notesContainer = $('ul.main-notes-list'); + $form.find('textarea.js-note-text').val(sampleComment); + }); + + it('should show placeholder note while new comment is being posted', () => { + $('.js-comment-button').click(); + expect($notesContainer.find('.note.being-posted').length > 0).toEqual(true); + }); + + it('should remove placeholder note when new comment is done posting', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + $('.js-comment-button').click(); + + deferred.resolve(note); + expect($notesContainer.find('.note.being-posted').length).toEqual(0); + }); + + it('should show actual note element when new comment is done posting', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + $('.js-comment-button').click(); + + deferred.resolve(note); + expect($notesContainer.find(`#note_${note.id}`).length > 0).toEqual(true); + }); + + it('should reset Form when new comment is done posting', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + $('.js-comment-button').click(); + + deferred.resolve(note); + expect($form.find('textarea.js-note-text').val()).toEqual(''); + }); + + it('should show flash error message when new comment failed to be posted', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + $('.js-comment-button').click(); + + deferred.reject(); + expect($notesContainer.parent().find('.flash-container .flash-text').is(':visible')).toEqual(true); + }); + + it('should show flash error message when comment failed to be updated', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + $('.js-comment-button').click(); + + deferred.resolve(note); + const $noteEl = $notesContainer.find(`#note_${note.id}`); + $noteEl.find('.js-note-edit').click(); + $noteEl.find('textarea.js-note-text').val(updatedComment); + $noteEl.find('.js-comment-save-button').click(); + + deferred.reject(); + const $updatedNoteEl = $notesContainer.find(`#note_${note.id}`); + expect($updatedNoteEl.hasClass('.being-posted')).toEqual(false); // Remove being-posted visuals + expect($updatedNoteEl.find('.note-text').text().trim()).toEqual(sampleComment); // See if comment reverted back to original + expect($('.flash-container').is(':visible')).toEqual(true); // Flash error message shown + }); + }); + describe('getFormData', () => { it('should return form metadata object from form reference', () => { - this.notes = new Notes(); + this.notes = new Notes('', []); const $form = $('form'); const sampleComment = 'foobar'; @@ -290,7 +373,7 @@ import '~/notes'; describe('hasSlashCommands', () => { beforeEach(() => { - this.notes = new Notes(); + this.notes = new Notes('', []); }); it('should return true when comment has slash commands', () => { @@ -327,7 +410,7 @@ import '~/notes'; const currentUserFullname = 'Administrator'; beforeEach(() => { - this.notes = new Notes(); + this.notes = new Notes('', []); }); it('should return constructed placeholder element for regular note based on form contents', () => { @@ -364,129 +447,5 @@ import '~/notes'; expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeTruthy(); }); }); - - describe('postComment & updateComment', () => { - const sampleComment = 'foo'; - const updatedComment = 'bar'; - const note = { - id: 1234, - html: `<li class="note note-row-1234 timeline-entry" id="note_1234"> - <div class="note-text">${sampleComment}</div> - </li>`, - note: sampleComment, - valid: true - }; - let $form; - let $notesContainer; - - beforeEach(() => { - this.notes = new Notes(); - window.gon.current_username = 'root'; - window.gon.current_user_fullname = 'Administrator'; - $form = $('form'); - $notesContainer = $('ul.main-notes-list'); - $form.find('textarea.js-note-text').val(sampleComment); - $('.js-comment-button').click(); - }); - - it('should show placeholder note while new comment is being posted', () => { - expect($notesContainer.find('.note.being-posted').length > 0).toEqual(true); - }); - - it('should remove placeholder note when new comment is done posting', () => { - spyOn($, 'ajax').and.callFake((options) => { - options.success(note); - expect($notesContainer.find('.note.being-posted').length).toEqual(0); - }); - }); - - it('should show actual note element when new comment is done posting', () => { - spyOn($, 'ajax').and.callFake((options) => { - options.success(note); - expect($notesContainer.find(`#${note.id}`).length > 0).toEqual(true); - }); - }); - - it('should reset Form when new comment is done posting', () => { - spyOn($, 'ajax').and.callFake((options) => { - options.success(note); - expect($form.find('textarea.js-note-text')).toEqual(''); - }); - }); - - it('should trigger ajax:success event on Form when new comment is done posting', () => { - spyOn($, 'ajax').and.callFake((options) => { - options.success(note); - spyOn($form, 'trigger'); - expect($form.trigger).toHaveBeenCalledWith('ajax:success', [note]); - }); - }); - - it('should show flash error message when new comment failed to be posted', () => { - spyOn($, 'ajax').and.callFake((options) => { - options.error(); - expect($notesContainer.parent().find('.flash-container .flash-text').is(':visible')).toEqual(true); - }); - }); - - it('should refill form textarea with original comment content when new comment failed to be posted', () => { - spyOn($, 'ajax').and.callFake((options) => { - options.error(); - expect($form.find('textarea.js-note-text')).toEqual(sampleComment); - }); - }); - - it('should show updated comment as _actively being posted_ while comment being updated', () => { - spyOn($, 'ajax').and.callFake((options) => { - options.success(note); - const $noteEl = $notesContainer.find(`#note_${note.id}`); - $noteEl.find('.js-note-edit').click(); - $noteEl.find('textarea.js-note-text').val(updatedComment); - $noteEl.find('.js-comment-save-button').click(); - expect($noteEl.hasClass('.being-posted')).toEqual(true); - expect($noteEl.find('.note-text').text()).toEqual(updatedComment); - }); - }); - - it('should show updated comment when comment update is done posting', () => { - spyOn($, 'ajax').and.callFake((options) => { - options.success(note); - const $noteEl = $notesContainer.find(`#note_${note.id}`); - $noteEl.find('.js-note-edit').click(); - $noteEl.find('textarea.js-note-text').val(updatedComment); - $noteEl.find('.js-comment-save-button').click(); - - spyOn($, 'ajax').and.callFake((updateOptions) => { - const updatedNote = Object.assign({}, note); - updatedNote.note = updatedComment; - updatedNote.html = `<li class="note note-row-1234 timeline-entry" id="note_1234"> - <div class="note-text">${updatedComment}</div> - </li>`; - updateOptions.success(updatedNote); - const $updatedNoteEl = $notesContainer.find(`#note_${updatedNote.id}`); - expect($updatedNoteEl.hasClass('.being-posted')).toEqual(false); // Remove being-posted visuals - expect($updatedNoteEl.find('note-text').text().trim()).toEqual(updatedComment); // Verify if comment text updated - }); - }); - }); - - it('should show flash error message when comment failed to be updated', () => { - spyOn($, 'ajax').and.callFake((options) => { - options.success(note); - const $noteEl = $notesContainer.find(`#note_${note.id}`); - $noteEl.find('.js-note-edit').click(); - $noteEl.find('textarea.js-note-text').val(updatedComment); - $noteEl.find('.js-comment-save-button').click(); - - spyOn($, 'ajax').and.callFake((updateOptions) => { - updateOptions.error(); - const $updatedNoteEl = $notesContainer.find(`#note_${note.id}`); - expect($updatedNoteEl.hasClass('.being-posted')).toEqual(false); // Remove being-posted visuals - expect($updatedNoteEl.find('note-text').text().trim()).toEqual(sampleComment); // See if comment reverted back to original - expect($notesContainer.parent().find('.flash-container .flash-text').is(':visible')).toEqual(true); // Flash error message shown - }); - }); - }); - }); }); }).call(window); -- GitLab From b51f2a60800829ee7585f10451a41bc4db0359a9 Mon Sep 17 00:00:00 2001 From: Winnie Hellmann <winnie@gitlab.com> Date: Fri, 5 May 2017 22:47:32 +0000 Subject: [PATCH 339/363] Colorize labels in issue search field --- .../filtered_search_visual_tokens.js | 40 +++++- .../javascripts/lib/utils/ajax_cache.js | 32 +++++ app/assets/stylesheets/framework/filters.scss | 14 +- .../stylesheets/framework/variables.scss | 2 + .../dashboard/labels_controller.rb | 2 +- app/controllers/groups/labels_controller.rb | 2 +- app/controllers/projects/labels_controller.rb | 2 +- app/serializers/label_entity.rb | 1 + app/serializers/label_serializer.rb | 7 + .../unreleased/winh-visual-token-labels.yml | 4 + .../filtered_search_visual_tokens_spec.js | 101 ++++++++++++++ spec/javascripts/fixtures/labels.rb | 56 ++++++++ spec/javascripts/lib/utils/ajax_cache_spec.js | 129 ++++++++++++++++++ spec/serializers/label_serializer_spec.rb | 46 +++++++ 14 files changed, 431 insertions(+), 7 deletions(-) create mode 100644 app/assets/javascripts/lib/utils/ajax_cache.js create mode 100644 app/serializers/label_serializer.rb create mode 100644 changelogs/unreleased/winh-visual-token-labels.yml create mode 100644 spec/javascripts/fixtures/labels.rb create mode 100644 spec/javascripts/lib/utils/ajax_cache_spec.js create mode 100644 spec/serializers/label_serializer_spec.rb diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index 59ce0587e1e40..f3003b86493ea 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -1,3 +1,5 @@ +import AjaxCache from '~/lib/utils/ajax_cache'; +import '~/flash'; /* global Flash */ import FilteredSearchContainer from './container'; class FilteredSearchVisualTokens { @@ -48,6 +50,40 @@ class FilteredSearchVisualTokens { `; } + static updateLabelTokenColor(tokenValueContainer, tokenValue) { + const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search'); + const baseEndpoint = filteredSearchInput.dataset.baseEndpoint; + const labelsEndpoint = `${baseEndpoint}/labels.json`; + + return AjaxCache.retrieve(labelsEndpoint) + .then((labels) => { + const matchingLabel = (labels || []).find(label => `~${gl.DropdownUtils.getEscapedText(label.title)}` === tokenValue); + + if (!matchingLabel) { + return; + } + + const tokenValueStyle = tokenValueContainer.style; + tokenValueStyle.backgroundColor = matchingLabel.color; + tokenValueStyle.color = matchingLabel.text_color; + + if (matchingLabel.text_color === '#FFFFFF') { + const removeToken = tokenValueContainer.querySelector('.remove-token'); + removeToken.classList.add('inverted'); + } + }) + .catch(() => new Flash('An error occurred while fetching label colors.')); + } + + static renderVisualTokenValue(parentElement, tokenName, tokenValue) { + const tokenValueContainer = parentElement.querySelector('.value-container'); + tokenValueContainer.querySelector('.value').innerText = tokenValue; + + if (tokenName.toLowerCase() === 'label') { + FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue); + } + } + static addVisualTokenElement(name, value, isSearchTerm) { const li = document.createElement('li'); li.classList.add('js-visual-token'); @@ -55,7 +91,7 @@ class FilteredSearchVisualTokens { if (value) { li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(); - li.querySelector('.value').innerText = value; + FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value); } else { li.innerHTML = '<div class="name"></div>'; } @@ -74,7 +110,7 @@ class FilteredSearchVisualTokens { const name = FilteredSearchVisualTokens.getLastTokenPartial(); lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(); lastVisualToken.querySelector('.name').innerText = name; - lastVisualToken.querySelector('.value').innerText = value; + FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value); } } diff --git a/app/assets/javascripts/lib/utils/ajax_cache.js b/app/assets/javascripts/lib/utils/ajax_cache.js new file mode 100644 index 0000000000000..d99eefb5089b2 --- /dev/null +++ b/app/assets/javascripts/lib/utils/ajax_cache.js @@ -0,0 +1,32 @@ +const AjaxCache = { + internalStorage: { }, + get(endpoint) { + return this.internalStorage[endpoint]; + }, + hasData(endpoint) { + return Object.prototype.hasOwnProperty.call(this.internalStorage, endpoint); + }, + purge(endpoint) { + delete this.internalStorage[endpoint]; + }, + retrieve(endpoint) { + if (AjaxCache.hasData(endpoint)) { + return Promise.resolve(AjaxCache.get(endpoint)); + } + + return new Promise((resolve, reject) => { + $.ajax(endpoint) // eslint-disable-line promise/catch-or-return + .then(data => resolve(data), + (jqXHR, textStatus, errorThrown) => { + const error = new Error(`${endpoint}: ${errorThrown}`); + error.textStatus = textStatus; + reject(error); + }, + ); + }) + .then((data) => { this.internalStorage[endpoint] = data; }) + .then(() => AjaxCache.get(endpoint)); + }, +}; + +export default AjaxCache; diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 0692f65043bcf..e624d0d951eb1 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -114,11 +114,21 @@ padding-right: 8px; .fa-close { - color: $gl-text-color-disabled; + color: $gl-text-color-secondary; } &:hover .fa-close { - color: $gl-text-color-secondary; + color: $gl-text-color; + } + + &.inverted { + .fa-close { + color: $gl-text-color-secondary-inverted; + } + + &:hover .fa-close { + color: $gl-text-color-inverted; + } } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 49741c963df50..08bcb58261317 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -101,6 +101,8 @@ $gl-font-size: 14px; $gl-text-color: rgba(0, 0, 0, .85); $gl-text-color-secondary: rgba(0, 0, 0, .55); $gl-text-color-disabled: rgba(0, 0, 0, .35); +$gl-text-color-inverted: rgba(255, 255, 255, 1.0); +$gl-text-color-secondary-inverted: rgba(255, 255, 255, .85); $gl-text-green: $green-600; $gl-text-red: $red-500; $gl-text-orange: $orange-600; diff --git a/app/controllers/dashboard/labels_controller.rb b/app/controllers/dashboard/labels_controller.rb index d5031da867af6..dd1d46a68c71c 100644 --- a/app/controllers/dashboard/labels_controller.rb +++ b/app/controllers/dashboard/labels_controller.rb @@ -3,7 +3,7 @@ def index labels = LabelsFinder.new(current_user).execute respond_to do |format| - format.json { render json: labels.as_json(only: [:id, :title, :color]) } + format.json { render json: LabelSerializer.new.represent_appearance(labels) } end end end diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb index facb25525b5ef..3fa0516fb0ce4 100644 --- a/app/controllers/groups/labels_controller.rb +++ b/app/controllers/groups/labels_controller.rb @@ -15,7 +15,7 @@ def index format.json do available_labels = LabelsFinder.new(current_user, group_id: @group.id).execute - render json: available_labels.as_json(only: [:id, :title, :color]) + render json: LabelSerializer.new.represent_appearance(available_labels) end end end diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index 2f55ba4e700b0..71bfb7163da14 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -19,7 +19,7 @@ def index respond_to do |format| format.html format.json do - render json: @available_labels.as_json(only: [:id, :title, :color]) + render json: LabelSerializer.new.represent_appearance(@available_labels) end end end diff --git a/app/serializers/label_entity.rb b/app/serializers/label_entity.rb index 304fd9de08f8e..ad565654342ba 100644 --- a/app/serializers/label_entity.rb +++ b/app/serializers/label_entity.rb @@ -6,6 +6,7 @@ class LabelEntity < Grape::Entity expose :group_id expose :project_id expose :template + expose :text_color expose :created_at expose :updated_at end diff --git a/app/serializers/label_serializer.rb b/app/serializers/label_serializer.rb new file mode 100644 index 0000000000000..ad6ba8c46c99e --- /dev/null +++ b/app/serializers/label_serializer.rb @@ -0,0 +1,7 @@ +class LabelSerializer < BaseSerializer + entity LabelEntity + + def represent_appearance(resource) + represent(resource, { only: [:id, :title, :color, :text_color] }) + end +end diff --git a/changelogs/unreleased/winh-visual-token-labels.yml b/changelogs/unreleased/winh-visual-token-labels.yml new file mode 100644 index 0000000000000..d4952e910b4c5 --- /dev/null +++ b/changelogs/unreleased/winh-visual-token-labels.yml @@ -0,0 +1,4 @@ +--- +title: Colorize labels in search field +merge_request: 11047 +author: diff --git a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js index d75b90612815e..8b750561eb7b5 100644 --- a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js @@ -1,3 +1,5 @@ +import AjaxCache from '~/lib/utils/ajax_cache'; + require('~/filtered_search/filtered_search_visual_tokens'); const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper'); @@ -611,4 +613,103 @@ describe('Filtered Search Visual Tokens', () => { expect(token.querySelector('.value').innerText).toEqual('~bug'); }); }); + + describe('renderVisualTokenValue', () => { + let searchTokens; + + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} + ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'upcoming')} + `); + + searchTokens = document.querySelectorAll('.filtered-search-token'); + }); + + it('renders a token value element', () => { + spyOn(gl.FilteredSearchVisualTokens, 'updateLabelTokenColor'); + const updateLabelTokenColorSpy = gl.FilteredSearchVisualTokens.updateLabelTokenColor; + + expect(searchTokens.length).toBe(2); + Array.prototype.forEach.call(searchTokens, (token) => { + updateLabelTokenColorSpy.calls.reset(); + + const tokenName = token.querySelector('.name').innerText; + const tokenValue = 'new value'; + gl.FilteredSearchVisualTokens.renderVisualTokenValue(token, tokenName, tokenValue); + + const tokenValueElement = token.querySelector('.value'); + expect(tokenValueElement.innerText).toBe(tokenValue); + + if (tokenName.toLowerCase() === 'label') { + const tokenValueContainer = token.querySelector('.value-container'); + expect(updateLabelTokenColorSpy.calls.count()).toBe(1); + const expectedArgs = [tokenValueContainer, tokenValue]; + expect(updateLabelTokenColorSpy.calls.argsFor(0)).toEqual(expectedArgs); + } else { + expect(updateLabelTokenColorSpy.calls.count()).toBe(0); + } + }); + }); + }); + + describe('updateLabelTokenColor', () => { + const jsonFixtureName = 'labels/project_labels.json'; + const dummyEndpoint = '/dummy/endpoint'; + + preloadFixtures(jsonFixtureName); + const labelData = getJSONFixture(jsonFixtureName); + const findLabel = tokenValue => labelData.find( + label => tokenValue === `~${gl.DropdownUtils.getEscapedText(label.title)}`, + ); + + const bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~bug'); + const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~doesnotexist'); + const spaceLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~"some space"'); + + const parseColor = (color) => { + const dummyElement = document.createElement('div'); + dummyElement.style.color = color; + return dummyElement.style.color; + }; + + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${bugLabelToken.outerHTML} + ${missingLabelToken.outerHTML} + ${spaceLabelToken.outerHTML} + `); + + const filteredSearchInput = document.querySelector('.filtered-search'); + filteredSearchInput.dataset.baseEndpoint = dummyEndpoint; + + AjaxCache.internalStorage = { }; + AjaxCache.internalStorage[`${dummyEndpoint}/labels.json`] = labelData; + }); + + const testCase = (token, done) => { + const tokenValueContainer = token.querySelector('.value-container'); + const tokenValue = token.querySelector('.value').innerText; + const label = findLabel(tokenValue); + + gl.FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue) + .then(() => { + if (label) { + expect(tokenValueContainer.getAttribute('style')).not.toBe(null); + expect(tokenValueContainer.style.backgroundColor).toBe(parseColor(label.color)); + expect(tokenValueContainer.style.color).toBe(parseColor(label.text_color)); + } else { + expect(token).toBe(missingLabelToken); + expect(tokenValueContainer.getAttribute('style')).toBe(null); + } + }) + .then(done) + .catch(fail); + }; + + it('updates the color of a label token', done => testCase(bugLabelToken, done)); + it('updates the color of a label token with spaces', done => testCase(spaceLabelToken, done)); + it('does not change color of a missing label', done => testCase(missingLabelToken, done)); + }); }); diff --git a/spec/javascripts/fixtures/labels.rb b/spec/javascripts/fixtures/labels.rb new file mode 100644 index 0000000000000..2e4811b64a4b4 --- /dev/null +++ b/spec/javascripts/fixtures/labels.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +describe 'Labels (JavaScript fixtures)' do + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin) } + let(:group) { create(:group, name: 'frontend-fixtures-group' )} + let(:project) { create(:project_empty_repo, namespace: group, path: 'labels-project') } + + let!(:project_label_bug) { create(:label, project: project, title: 'bug', color: '#FF0000') } + let!(:project_label_enhancement) { create(:label, project: project, title: 'enhancement', color: '#00FF00') } + let!(:project_label_feature) { create(:label, project: project, title: 'feature', color: '#0000FF') } + + let!(:group_label_roses) { create(:group_label, group: group, title: 'roses', color: '#FF0000') } + let!(:groub_label_space) { create(:group_label, group: group, title: 'some space', color: '#FFFFFF') } + let!(:groub_label_violets) { create(:group_label, group: group, title: 'violets', color: '#0000FF') } + + before(:all) do + clean_frontend_fixtures('labels/') + end + + describe Groups::LabelsController, '(JavaScript fixtures)', type: :controller do + render_views + + before(:each) do + sign_in(admin) + end + + it 'labels/group_labels.json' do |example| + get :index, + group_id: group, + format: 'json' + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end + end + + describe Projects::LabelsController, '(JavaScript fixtures)', type: :controller do + render_views + + before(:each) do + sign_in(admin) + end + + it 'labels/project_labels.json' do |example| + get :index, + namespace_id: group, + project_id: project, + format: 'json' + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end + end +end diff --git a/spec/javascripts/lib/utils/ajax_cache_spec.js b/spec/javascripts/lib/utils/ajax_cache_spec.js new file mode 100644 index 0000000000000..7b466a11b9240 --- /dev/null +++ b/spec/javascripts/lib/utils/ajax_cache_spec.js @@ -0,0 +1,129 @@ +import AjaxCache from '~/lib/utils/ajax_cache'; + +describe('AjaxCache', () => { + const dummyEndpoint = '/AjaxCache/dummyEndpoint'; + const dummyResponse = { + important: 'dummy data', + }; + let ajaxSpy = (url) => { + expect(url).toBe(dummyEndpoint); + const deferred = $.Deferred(); + deferred.resolve(dummyResponse); + return deferred.promise(); + }; + + beforeEach(() => { + AjaxCache.internalStorage = { }; + spyOn(jQuery, 'ajax').and.callFake(url => ajaxSpy(url)); + }); + + describe('#get', () => { + it('returns undefined if cache is empty', () => { + const data = AjaxCache.get(dummyEndpoint); + + expect(data).toBe(undefined); + }); + + it('returns undefined if cache contains no matching data', () => { + AjaxCache.internalStorage['not matching'] = dummyResponse; + + const data = AjaxCache.get(dummyEndpoint); + + expect(data).toBe(undefined); + }); + + it('returns matching data', () => { + AjaxCache.internalStorage[dummyEndpoint] = dummyResponse; + + const data = AjaxCache.get(dummyEndpoint); + + expect(data).toBe(dummyResponse); + }); + }); + + describe('#hasData', () => { + it('returns false if cache is empty', () => { + expect(AjaxCache.hasData(dummyEndpoint)).toBe(false); + }); + + it('returns false if cache contains no matching data', () => { + AjaxCache.internalStorage['not matching'] = dummyResponse; + + expect(AjaxCache.hasData(dummyEndpoint)).toBe(false); + }); + + it('returns true if data is available', () => { + AjaxCache.internalStorage[dummyEndpoint] = dummyResponse; + + expect(AjaxCache.hasData(dummyEndpoint)).toBe(true); + }); + }); + + describe('#purge', () => { + it('does nothing if cache is empty', () => { + AjaxCache.purge(dummyEndpoint); + + expect(AjaxCache.internalStorage).toEqual({ }); + }); + + it('does nothing if cache contains no matching data', () => { + AjaxCache.internalStorage['not matching'] = dummyResponse; + + AjaxCache.purge(dummyEndpoint); + + expect(AjaxCache.internalStorage['not matching']).toBe(dummyResponse); + }); + + it('removes matching data', () => { + AjaxCache.internalStorage[dummyEndpoint] = dummyResponse; + + AjaxCache.purge(dummyEndpoint); + + expect(AjaxCache.internalStorage).toEqual({ }); + }); + }); + + describe('#retrieve', () => { + it('stores and returns data from Ajax call if cache is empty', (done) => { + AjaxCache.retrieve(dummyEndpoint) + .then((data) => { + expect(data).toBe(dummyResponse); + expect(AjaxCache.internalStorage[dummyEndpoint]).toBe(dummyResponse); + }) + .then(done) + .catch(fail); + }); + + it('returns undefined if Ajax call fails and cache is empty', (done) => { + const dummyStatusText = 'exploded'; + const dummyErrorMessage = 'server exploded'; + ajaxSpy = (url) => { + expect(url).toBe(dummyEndpoint); + const deferred = $.Deferred(); + deferred.reject(null, dummyStatusText, dummyErrorMessage); + return deferred.promise(); + }; + + AjaxCache.retrieve(dummyEndpoint) + .then(data => fail(`Received unexpected data: ${JSON.stringify(data)}`)) + .catch((error) => { + expect(error.message).toBe(`${dummyEndpoint}: ${dummyErrorMessage}`); + expect(error.textStatus).toBe(dummyStatusText); + done(); + }) + .catch(fail); + }); + + it('makes no Ajax call if matching data exists', (done) => { + AjaxCache.internalStorage[dummyEndpoint] = dummyResponse; + ajaxSpy = () => fail(new Error('expected no Ajax call!')); + + AjaxCache.retrieve(dummyEndpoint) + .then((data) => { + expect(data).toBe(dummyResponse); + }) + .then(done) + .catch(fail); + }); + }); +}); diff --git a/spec/serializers/label_serializer_spec.rb b/spec/serializers/label_serializer_spec.rb new file mode 100644 index 0000000000000..c58c7da1f9ed5 --- /dev/null +++ b/spec/serializers/label_serializer_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +describe LabelSerializer do + let(:user) { create(:user) } + + let(:serializer) do + described_class.new(user: user) + end + + subject { serializer.represent(resource) } + + describe '#represent' do + context 'when a single object is being serialized' do + let(:resource) { create(:label) } + + it 'serializes the label object' do + expect(subject[:id]).to eq resource.id + end + end + + context 'when multiple objects are being serialized' do + let(:num_labels) { 2 } + let(:resource) { create_list(:label, num_labels) } + + it 'serializes the array of labels' do + expect(subject.size).to eq(num_labels) + end + end + end + + describe '#represent_appearance' do + context 'when represents only appearance' do + let(:resource) { create(:label) } + + subject { serializer.represent_appearance(resource) } + + it 'serializes only attributes used for appearance' do + expect(subject.keys).to eq([:id, :title, :color, :text_color]) + expect(subject[:id]).to eq(resource.id) + expect(subject[:title]).to eq(resource.title) + expect(subject[:color]).to eq(resource.color) + expect(subject[:text_color]).to eq(resource.text_color) + end + end + end +end -- GitLab From 61d0ac55d47b92668ce95b1e800e879f95327a48 Mon Sep 17 00:00:00 2001 From: Ruben Davila <rdavila84@gmail.com> Date: Fri, 5 May 2017 20:50:48 -0500 Subject: [PATCH 340/363] Use an absolute path for locale path in FastGettext config Unicorn was unable to start due to this bad config on Omnibus. --- config/initializers/fast_gettext.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/fast_gettext.rb b/config/initializers/fast_gettext.rb index bea85b43b9547..a69fe0c902e80 100644 --- a/config/initializers/fast_gettext.rb +++ b/config/initializers/fast_gettext.rb @@ -1,4 +1,4 @@ -FastGettext.add_text_domain 'gitlab', path: 'locale', type: :po +FastGettext.add_text_domain 'gitlab', path: File.join(Rails.root, 'locale'), type: :po FastGettext.default_text_domain = 'gitlab' FastGettext.default_available_locales = Gitlab::I18n.available_locales -- GitLab From b69bb08bf5a653695a2f4fb8b224924b42bc42c3 Mon Sep 17 00:00:00 2001 From: Mike Greiling <mike@pixelcog.com> Date: Fri, 5 May 2017 22:41:53 -0500 Subject: [PATCH 341/363] add tooltips to user contrib graph key --- app/assets/javascripts/users/calendar.js | 26 ++++++++++++++++-------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/app/assets/javascripts/users/calendar.js b/app/assets/javascripts/users/calendar.js index 754d448564fef..32ffa2f0ac08c 100644 --- a/app/assets/javascripts/users/calendar.js +++ b/app/assets/javascripts/users/calendar.js @@ -168,15 +168,23 @@ import d3 from 'd3'; }; Calendar.prototype.renderKey = function() { - var keyColors; - keyColors = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)]; - return this.svg.append('g').attr('transform', "translate(18, " + (this.daySizeWithSpace * 8 + 16) + ")").selectAll('rect').data(keyColors).enter().append('rect').attr('width', this.daySize).attr('height', this.daySize).attr('x', (function(_this) { - return function(color, i) { - return _this.daySizeWithSpace * i; - }; - })(this)).attr('y', 0).attr('fill', function(color) { - return color; - }); + const keyValues = ['no contributions', '1-9 contributions', '10-19 contributions', '20-29 contributions', '30+ contributions']; + const keyColors = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)]; + + this.svg.append('g') + .attr('transform', `translate(18, ${this.daySizeWithSpace * 8 + 16})`) + .selectAll('rect') + .data(keyColors) + .enter() + .append('rect') + .attr('width', this.daySize) + .attr('height', this.daySize) + .attr('x', (color, i) => this.daySizeWithSpace * i) + .attr('y', 0) + .attr('fill', color => color) + .attr('class', 'js-tooltip') + .attr('title', (color, i) => keyValues[i]) + .attr('data-container', 'body'); }; Calendar.prototype.initColor = function() { -- GitLab From 22bd020028a9e7cadafce01705dcd189cf14f945 Mon Sep 17 00:00:00 2001 From: Mike Greiling <mike@pixelcog.com> Date: Fri, 5 May 2017 22:43:11 -0500 Subject: [PATCH 342/363] add CHANGELOG.md entry for !11138 --- .../unreleased/23751-add-contribution-graph-key-tooltips.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelogs/unreleased/23751-add-contribution-graph-key-tooltips.yml diff --git a/changelogs/unreleased/23751-add-contribution-graph-key-tooltips.yml b/changelogs/unreleased/23751-add-contribution-graph-key-tooltips.yml new file mode 100644 index 0000000000000..7c4c6fb46a057 --- /dev/null +++ b/changelogs/unreleased/23751-add-contribution-graph-key-tooltips.yml @@ -0,0 +1,4 @@ +--- +title: Add tooltips to user contribution graph key +merge_request: 11138 +author: -- GitLab From da0d8e0491d993e0b5c7905c360b71fa930612d5 Mon Sep 17 00:00:00 2001 From: Sean McGivern <sean@gitlab.com> Date: Sat, 6 May 2017 09:45:25 +0100 Subject: [PATCH 343/363] Fix `Routable.find_by_full_path` on MySQL --- app/models/concerns/routable.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index e351dbb45ddb8..c4463abdfe6bf 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -49,7 +49,7 @@ def find_by_full_path(path, follow_redirects: false) if Gitlab::Database.postgresql? joins(:redirect_routes).find_by("LOWER(redirect_routes.path) = LOWER(?)", path) else - joins(:redirect_routes).find_by(path: path) + joins(:redirect_routes).find_by(redirect_routes: { path: path }) end end end -- GitLab From c17e6a6c68b0412b3433632802b852db474a7b30 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg <zegerjan@gitlab.com> Date: Sat, 6 May 2017 16:45:46 +0000 Subject: [PATCH 344/363] Real time pipeline show action --- .../projects/pipelines_controller.rb | 22 +- app/models/ci/group.rb | 40 ++ app/models/ci/stage.rb | 8 + app/serializers/job_group_entity.rb | 16 + app/serializers/stage_entity.rb | 8 +- app/serializers/status_entity.rb | 7 + .../unreleased/zj-real-time-pipelines.yml | 4 + lib/gitlab/ci/status/group/common.rb | 21 ++ lib/gitlab/ci/status/group/factory.rb | 13 + lib/gitlab/etag_caching/router.rb | 6 +- .../projects/pipelines_controller_spec.rb | 31 ++ spec/fixtures/api/schemas/pipeline.json | 354 ++++++++++++++++++ .../lib/gitlab/ci/status/group/common_spec.rb | 20 + .../gitlab/ci/status/group/factory_spec.rb | 13 + spec/models/ci/group_spec.rb | 44 +++ spec/models/ci/stage_spec.rb | 33 +- spec/serializers/stage_entity_spec.rb | 8 + 17 files changed, 637 insertions(+), 11 deletions(-) create mode 100644 app/models/ci/group.rb create mode 100644 app/serializers/job_group_entity.rb create mode 100644 changelogs/unreleased/zj-real-time-pipelines.yml create mode 100644 lib/gitlab/ci/status/group/common.rb create mode 100644 lib/gitlab/ci/status/group/factory.rb create mode 100644 spec/fixtures/api/schemas/pipeline.json create mode 100644 spec/lib/gitlab/ci/status/group/common_spec.rb create mode 100644 spec/lib/gitlab/ci/status/group/factory_spec.rb create mode 100644 spec/models/ci/group_spec.rb diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index f9adedcb074b8..5cb2e42820151 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -8,6 +8,8 @@ class Projects::PipelinesController < Projects::ApplicationController wrap_parameters Ci::Pipeline + POLLING_INTERVAL = 10_000 + def index @scope = params[:scope] @pipelines = PipelinesFinder @@ -31,7 +33,7 @@ def index respond_to do |format| format.html format.json do - Gitlab::PollingInterval.set_header(response, interval: 10_000) + Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) render json: { pipelines: PipelineSerializer @@ -57,15 +59,25 @@ def create @pipeline = Ci::CreatePipelineService .new(project, current_user, create_params) .execute(ignore_skip_ci: true, save_on_errors: false) - unless @pipeline.persisted? + + if @pipeline.persisted? + redirect_to namespace_project_pipeline_path(project.namespace, project, @pipeline) + else render 'new' - return end - - redirect_to namespace_project_pipeline_path(project.namespace, project, @pipeline) end def show + respond_to do |format| + format.html + format.json do + Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) + + render json: PipelineSerializer + .new(project: @project, user: @current_user) + .represent(@pipeline, grouped: true) + end + end end def builds diff --git a/app/models/ci/group.rb b/app/models/ci/group.rb new file mode 100644 index 0000000000000..87898b086c6ad --- /dev/null +++ b/app/models/ci/group.rb @@ -0,0 +1,40 @@ +module Ci + ## + # This domain model is a representation of a group of jobs that are related + # to each other, like `rspec 0 1`, `rspec 0 2`. + # + # It is not persisted in the database. + # + class Group + include StaticModel + + attr_reader :stage, :name, :jobs + + delegate :size, to: :jobs + + def initialize(stage, name:, jobs:) + @stage = stage + @name = name + @jobs = jobs + end + + def status + @status ||= commit_statuses.status + end + + def detailed_status(current_user) + if jobs.one? + jobs.first.detailed_status(current_user) + else + Gitlab::Ci::Status::Group::Factory + .new(self, current_user).fabricate! + end + end + + private + + def commit_statuses + @commit_statuses ||= CommitStatus.where(id: jobs.map(&:id)) + end + end +end diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index e7d6b17d445ef..9bda3186c3040 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -15,6 +15,14 @@ def initialize(pipeline, name:, status: nil, warnings: nil) @warnings = warnings end + def groups + @groups ||= statuses.ordered.latest + .sort_by(&:sortable_name).group_by(&:group_name) + .map do |group_name, grouped_statuses| + Ci::Group.new(self, name: group_name, jobs: grouped_statuses) + end + end + def to_param name end diff --git a/app/serializers/job_group_entity.rb b/app/serializers/job_group_entity.rb new file mode 100644 index 0000000000000..a4d3737429c4d --- /dev/null +++ b/app/serializers/job_group_entity.rb @@ -0,0 +1,16 @@ +class JobGroupEntity < Grape::Entity + include RequestAwareEntity + + expose :name + expose :size + expose :detailed_status, as: :status, with: StatusEntity + expose :jobs, with: BuildEntity + + private + + alias_method :group, :object + + def detailed_status + group.detailed_status(request.user) + end +end diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb index 7a047bdc71238..97ced8730edf7 100644 --- a/app/serializers/stage_entity.rb +++ b/app/serializers/stage_entity.rb @@ -7,9 +7,11 @@ class StageEntity < Grape::Entity "#{stage.name}: #{detailed_status.label}" end - expose :detailed_status, - as: :status, - with: StatusEntity + expose :groups, + if: -> (_, opts) { opts[:grouped] }, + with: JobGroupEntity + + expose :detailed_status, as: :status, with: StatusEntity expose :path do |stage| namespace_project_pipeline_path( diff --git a/app/serializers/status_entity.rb b/app/serializers/status_entity.rb index 188c3747f184f..3e40ecf1c1c09 100644 --- a/app/serializers/status_entity.rb +++ b/app/serializers/status_entity.rb @@ -12,4 +12,11 @@ class StatusEntity < Grape::Entity ActionController::Base.helpers.image_path(File.join(dir, "#{status.favicon}.ico")) end + + expose :action, if: -> (status, _) { status.has_action? } do + expose :action_icon, as: :icon + expose :action_title, as: :title + expose :action_path, as: :path + expose :action_method, as: :method + end end diff --git a/changelogs/unreleased/zj-real-time-pipelines.yml b/changelogs/unreleased/zj-real-time-pipelines.yml new file mode 100644 index 0000000000000..eec22e6746705 --- /dev/null +++ b/changelogs/unreleased/zj-real-time-pipelines.yml @@ -0,0 +1,4 @@ +--- +title: Pipeline view updates in near real time +merge_request: 10777 +author: diff --git a/lib/gitlab/ci/status/group/common.rb b/lib/gitlab/ci/status/group/common.rb new file mode 100644 index 0000000000000..cfd4329a92335 --- /dev/null +++ b/lib/gitlab/ci/status/group/common.rb @@ -0,0 +1,21 @@ +module Gitlab + module Ci + module Status + module Group + module Common + def has_details? + false + end + + def details_path + nil + end + + def has_action? + false + end + end + end + end + end +end diff --git a/lib/gitlab/ci/status/group/factory.rb b/lib/gitlab/ci/status/group/factory.rb new file mode 100644 index 0000000000000..d118116cfc362 --- /dev/null +++ b/lib/gitlab/ci/status/group/factory.rb @@ -0,0 +1,13 @@ +module Gitlab + module Ci + module Status + module Group + class Factory < Status::Factory + def self.common_helpers + Status::Group::Common + end + end + end + end + end +end diff --git a/lib/gitlab/etag_caching/router.rb b/lib/gitlab/etag_caching/router.rb index aac210f19e838..692c909d83821 100644 --- a/lib/gitlab/etag_caching/router.rb +++ b/lib/gitlab/etag_caching/router.rb @@ -36,7 +36,11 @@ class Router Gitlab::EtagCaching::Router::Route.new( %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/pipelines\.json\z), 'project_pipelines' - ) + ), + Gitlab::EtagCaching::Router::Route.new( + %r(^(?!.*(#{RESERVED_WORDS})).*/pipelines/\d+\.json\z), + 'project_pipeline' + ), ].freeze def self.match(env) diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb index 1b47d163c0b3d..fb4a4721a5879 100644 --- a/spec/controllers/projects/pipelines_controller_spec.rb +++ b/spec/controllers/projects/pipelines_controller_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Projects::PipelinesController do + include ApiHelpers + let(:user) { create(:user) } let(:project) { create(:empty_project, :public) } @@ -24,6 +26,7 @@ it 'returns JSON with serialized pipelines' do expect(response).to have_http_status(:ok) + expect(response).to match_response_schema('pipeline') expect(json_response).to include('pipelines') expect(json_response['pipelines'].count).to eq 4 @@ -34,6 +37,34 @@ end end + describe 'GET show JSON' do + let!(:pipeline) { create(:ci_pipeline_with_one_job, project: project) } + + it 'returns the pipeline' do + get_pipeline_json + + expect(response).to have_http_status(:ok) + expect(json_response).not_to be_an(Array) + expect(json_response['id']).to be(pipeline.id) + expect(json_response['details']).to have_key 'stages' + end + + context 'when the pipeline has multiple jobs' do + it 'does not perform N + 1 queries' do + control_count = ActiveRecord::QueryRecorder.new { get_pipeline_json }.count + + create(:ci_build, pipeline: pipeline) + + # The plus 2 is needed to group and sort + expect { get_pipeline_json }.not_to exceed_query_limit(control_count + 2) + end + end + + def get_pipeline_json + get :show, namespace_id: project.namespace, project_id: project, id: pipeline, format: :json + end + end + describe 'GET stages.json' do let(:pipeline) { create(:ci_pipeline, project: project) } diff --git a/spec/fixtures/api/schemas/pipeline.json b/spec/fixtures/api/schemas/pipeline.json new file mode 100644 index 0000000000000..55511d17b5e6d --- /dev/null +++ b/spec/fixtures/api/schemas/pipeline.json @@ -0,0 +1,354 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "definitions": {}, + "id": "http://example.com/example.json", + "properties": { + "commit": { + "id": "/properties/commit", + "properties": { + "author": { + "id": "/properties/commit/properties/author", + "type": "null" + }, + "author_email": { + "id": "/properties/commit/properties/author_email", + "type": "string" + }, + "author_gravatar_url": { + "id": "/properties/commit/properties/author_gravatar_url", + "type": "string" + }, + "author_name": { + "id": "/properties/commit/properties/author_name", + "type": "string" + }, + "authored_date": { + "id": "/properties/commit/properties/authored_date", + "type": "string" + }, + "commit_path": { + "id": "/properties/commit/properties/commit_path", + "type": "string" + }, + "commit_url": { + "id": "/properties/commit/properties/commit_url", + "type": "string" + }, + "committed_date": { + "id": "/properties/commit/properties/committed_date", + "type": "string" + }, + "committer_email": { + "id": "/properties/commit/properties/committer_email", + "type": "string" + }, + "committer_name": { + "id": "/properties/commit/properties/committer_name", + "type": "string" + }, + "created_at": { + "id": "/properties/commit/properties/created_at", + "type": "string" + }, + "id": { + "id": "/properties/commit/properties/id", + "type": "string" + }, + "message": { + "id": "/properties/commit/properties/message", + "type": "string" + }, + "parent_ids": { + "id": "/properties/commit/properties/parent_ids", + "items": { + "id": "/properties/commit/properties/parent_ids/items", + "type": "string" + }, + "type": "array" + }, + "short_id": { + "id": "/properties/commit/properties/short_id", + "type": "string" + }, + "title": { + "id": "/properties/commit/properties/title", + "type": "string" + } + }, + "type": "object" + }, + "created_at": { + "id": "/properties/created_at", + "type": "string" + }, + "details": { + "id": "/properties/details", + "properties": { + "artifacts": { + "id": "/properties/details/properties/artifacts", + "items": {}, + "type": "array" + }, + "duration": { + "id": "/properties/details/properties/duration", + "type": "integer" + }, + "finished_at": { + "id": "/properties/details/properties/finished_at", + "type": "string" + }, + "manual_actions": { + "id": "/properties/details/properties/manual_actions", + "items": {}, + "type": "array" + }, + "stages": { + "id": "/properties/details/properties/stages", + "items": { + "id": "/properties/details/properties/stages/items", + "properties": { + "dropdown_path": { + "id": "/properties/details/properties/stages/items/properties/dropdown_path", + "type": "string" + }, + "groups": { + "id": "/properties/details/properties/stages/items/properties/groups", + "items": { + "id": "/properties/details/properties/stages/items/properties/groups/items", + "properties": { + "name": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/name", + "type": "string" + }, + "size": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/size", + "type": "integer" + }, + "status": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status", + "properties": { + "details_path": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/details_path", + "type": "null" + }, + "favicon": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/favicon", + "type": "string" + }, + "group": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/group", + "type": "string" + }, + "has_details": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/has_details", + "type": "boolean" + }, + "icon": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/icon", + "type": "string" + }, + "label": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/label", + "type": "string" + }, + "text": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/text", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + }, + "name": { + "id": "/properties/details/properties/stages/items/properties/name", + "type": "string" + }, + "path": { + "id": "/properties/details/properties/stages/items/properties/path", + "type": "string" + }, + "status": { + "id": "/properties/details/properties/stages/items/properties/status", + "properties": { + "details_path": { + "id": "/properties/details/properties/stages/items/properties/status/properties/details_path", + "type": "string" + }, + "favicon": { + "id": "/properties/details/properties/stages/items/properties/status/properties/favicon", + "type": "string" + }, + "group": { + "id": "/properties/details/properties/stages/items/properties/status/properties/group", + "type": "string" + }, + "has_details": { + "id": "/properties/details/properties/stages/items/properties/status/properties/has_details", + "type": "boolean" + }, + "icon": { + "id": "/properties/details/properties/stages/items/properties/status/properties/icon", + "type": "string" + }, + "label": { + "id": "/properties/details/properties/stages/items/properties/status/properties/label", + "type": "string" + }, + "text": { + "id": "/properties/details/properties/stages/items/properties/status/properties/text", + "type": "string" + } + }, + "type": "object" + }, + "title": { + "id": "/properties/details/properties/stages/items/properties/title", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "status": { + "id": "/properties/details/properties/status", + "properties": { + "details_path": { + "id": "/properties/details/properties/status/properties/details_path", + "type": "string" + }, + "favicon": { + "id": "/properties/details/properties/status/properties/favicon", + "type": "string" + }, + "group": { + "id": "/properties/details/properties/status/properties/group", + "type": "string" + }, + "has_details": { + "id": "/properties/details/properties/status/properties/has_details", + "type": "boolean" + }, + "icon": { + "id": "/properties/details/properties/status/properties/icon", + "type": "string" + }, + "label": { + "id": "/properties/details/properties/status/properties/label", + "type": "string" + }, + "text": { + "id": "/properties/details/properties/status/properties/text", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "flags": { + "id": "/properties/flags", + "properties": { + "cancelable": { + "id": "/properties/flags/properties/cancelable", + "type": "boolean" + }, + "latest": { + "id": "/properties/flags/properties/latest", + "type": "boolean" + }, + "retryable": { + "id": "/properties/flags/properties/retryable", + "type": "boolean" + }, + "stuck": { + "id": "/properties/flags/properties/stuck", + "type": "boolean" + }, + "triggered": { + "id": "/properties/flags/properties/triggered", + "type": "boolean" + }, + "yaml_errors": { + "id": "/properties/flags/properties/yaml_errors", + "type": "boolean" + } + }, + "type": "object" + }, + "id": { + "id": "/properties/id", + "type": "integer" + }, + "path": { + "id": "/properties/path", + "type": "string" + }, + "ref": { + "id": "/properties/ref", + "properties": { + "branch": { + "id": "/properties/ref/properties/branch", + "type": "boolean" + }, + "name": { + "id": "/properties/ref/properties/name", + "type": "string" + }, + "path": { + "id": "/properties/ref/properties/path", + "type": "string" + }, + "tag": { + "id": "/properties/ref/properties/tag", + "type": "boolean" + } + }, + "type": "object" + }, + "retry_path": { + "id": "/properties/retry_path", + "type": "string" + }, + "updated_at": { + "id": "/properties/updated_at", + "type": "string" + }, + "user": { + "id": "/properties/user", + "properties": { + "avatar_url": { + "id": "/properties/user/properties/avatar_url", + "type": "string" + }, + "id": { + "id": "/properties/user/properties/id", + "type": "integer" + }, + "name": { + "id": "/properties/user/properties/name", + "type": "string" + }, + "state": { + "id": "/properties/user/properties/state", + "type": "string" + }, + "username": { + "id": "/properties/user/properties/username", + "type": "string" + }, + "web_url": { + "id": "/properties/user/properties/web_url", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" +} diff --git a/spec/lib/gitlab/ci/status/group/common_spec.rb b/spec/lib/gitlab/ci/status/group/common_spec.rb new file mode 100644 index 0000000000000..c0ca05881f5e2 --- /dev/null +++ b/spec/lib/gitlab/ci/status/group/common_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe Gitlab::Ci::Status::Group::Common do + subject do + Gitlab::Ci::Status::Core.new(double, double) + .extend(described_class) + end + + it 'does not have action' do + expect(subject).not_to have_action + end + + it 'has details' do + expect(subject).not_to have_details + end + + it 'has no details_path' do + expect(subject.details_path).to be_falsy + end +end diff --git a/spec/lib/gitlab/ci/status/group/factory_spec.rb b/spec/lib/gitlab/ci/status/group/factory_spec.rb new file mode 100644 index 0000000000000..0cd8312393801 --- /dev/null +++ b/spec/lib/gitlab/ci/status/group/factory_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +describe Gitlab::Ci::Status::Group::Factory do + it 'inherits from the core factory' do + expect(described_class) + .to be < Gitlab::Ci::Status::Factory + end + + it 'exposes group helpers' do + expect(described_class.common_helpers) + .to eq Gitlab::Ci::Status::Group::Common + end +end diff --git a/spec/models/ci/group_spec.rb b/spec/models/ci/group_spec.rb new file mode 100644 index 0000000000000..62e1509308985 --- /dev/null +++ b/spec/models/ci/group_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +describe Ci::Group, models: true do + subject do + described_class.new('test', name: 'rspec', jobs: jobs) + end + + let!(:jobs) { build_list(:ci_build, 1, :success) } + + it { is_expected.to include_module(StaticModel) } + + it { is_expected.to respond_to(:stage) } + it { is_expected.to respond_to(:name) } + it { is_expected.to respond_to(:jobs) } + it { is_expected.to respond_to(:status) } + + describe '#size' do + it 'returns the number of statuses in the group' do + expect(subject.size).to eq(1) + end + end + + describe '#detailed_status' do + context 'when there is only one item in the group' do + it 'calls the status from the object itself' do + expect(jobs.first).to receive(:detailed_status) + + expect(subject.detailed_status(double(:user))) + end + end + + context 'when there are more than one commit status in the group' do + let(:jobs) do + [create(:ci_build, :failed), + create(:ci_build, :success)] + end + + it 'fabricates a new detailed status object' do + expect(subject.detailed_status(double(:user))) + .to be_a(Gitlab::Ci::Status::Failed) + end + end + end +end diff --git a/spec/models/ci/stage_spec.rb b/spec/models/ci/stage_spec.rb index c38faf32f7df3..372b662fab2eb 100644 --- a/spec/models/ci/stage_spec.rb +++ b/spec/models/ci/stage_spec.rb @@ -28,6 +28,35 @@ end end + describe '#groups' do + before do + create_job(:ci_build, name: 'rspec 0 2') + create_job(:ci_build, name: 'rspec 0 1') + create_job(:ci_build, name: 'spinach 0 1') + create_job(:commit_status, name: 'aaaaa') + end + + it 'returns an array of three groups' do + expect(stage.groups).to be_a Array + expect(stage.groups).to all(be_a Ci::Group) + expect(stage.groups.size).to eq 3 + end + + it 'returns groups with correctly ordered statuses' do + expect(stage.groups.first.jobs.map(&:name)) + .to eq ['aaaaa'] + expect(stage.groups.second.jobs.map(&:name)) + .to eq ['rspec 0 1', 'rspec 0 2'] + expect(stage.groups.third.jobs.map(&:name)) + .to eq ['spinach 0 1'] + end + + it 'returns groups with correct names' do + expect(stage.groups.map(&:name)) + .to eq %w[aaaaa rspec spinach] + end + end + describe '#statuses_count' do before do create_job(:ci_build) @@ -223,7 +252,7 @@ end end - def create_job(type, status: 'success', stage: stage_name) - create(type, pipeline: pipeline, stage: stage, status: status) + def create_job(type, status: 'success', stage: stage_name, **opts) + create(type, pipeline: pipeline, stage: stage, status: status, **opts) end end diff --git a/spec/serializers/stage_entity_spec.rb b/spec/serializers/stage_entity_spec.rb index 4ab40d08432a7..0412b2d774181 100644 --- a/spec/serializers/stage_entity_spec.rb +++ b/spec/serializers/stage_entity_spec.rb @@ -47,5 +47,13 @@ it 'contains stage title' do expect(subject[:title]).to eq 'test: passed' end + + context 'when the jobs should be grouped' do + let(:entity) { described_class.new(stage, request: request, grouped: true) } + + it 'exposes the group key' do + expect(subject).to include :groups + end + end end end -- GitLab From aa440eb1c0947d2dc551c61abbd9d271b9002050 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Sat, 6 May 2017 19:02:06 +0200 Subject: [PATCH 345/363] Single commit squash of all changes for https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10878 It's needed due to https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10777 being merged with squash. --- app/assets/javascripts/ci_status_icons.js | 34 ----- app/assets/javascripts/dispatcher.js | 3 +- .../lib/utils/bootstrap_linked_tabs.js | 116 ++++++++-------- app/assets/javascripts/pipelines.js | 46 ++----- .../components/graph/action_component.vue | 59 ++++++++ .../graph/dropdown_action_component.vue | 56 ++++++++ .../graph/dropdown_job_component.vue | 86 ++++++++++++ .../components/graph/graph_component.vue | 92 +++++++++++++ .../components/graph/job_component.vue | 124 +++++++++++++++++ .../components/graph/job_name_component.vue | 37 +++++ .../graph/stage_column_component.vue | 64 +++++++++ .../pipelines/components/stage.vue | 6 +- .../javascripts/pipelines/graph_bundle.js | 10 ++ .../pipelines/services/pipeline_service.js | 14 ++ .../pipelines/stores/pipeline_store.js | 11 ++ .../javascripts/vue_shared/ci_action_icons.js | 22 +++ .../javascripts/vue_shared/ci_status_icons.js | 55 ++++++++ .../vue_shared/components/ci_icon.vue | 29 ++++ .../javascripts/vue_shared/mixins/tooltip.js | 9 ++ app/assets/stylesheets/pages/pipelines.scss | 69 +++++----- app/views/ci/status/_graph_badge.html.haml | 20 --- app/views/projects/pipelines/_graph.html.haml | 4 - .../projects/pipelines/_with_tabs.html.haml | 7 +- app/views/projects/stage/_graph.html.haml | 19 --- .../projects/stage/_in_stage_group.html.haml | 14 -- .../25226-realtime-pipelines-fe.yml | 4 + config/webpack.config.js | 2 + .../javascripts/bootstrap_linked_tabs_spec.js | 8 +- spec/javascripts/ci_status_icon_spec.js | 44 ------ spec/javascripts/fixtures/graph.html.haml | 1 + .../pipelines/graph/action_component_spec.js | 40 ++++++ .../graph/dropdown_action_component_spec.js | 30 ++++ .../pipelines/graph/graph_component_spec.js | 83 +++++++++++ .../pipelines/graph/job_component_spec.js | 117 ++++++++++++++++ .../graph/job_name_component_spec.js | 27 ++++ .../graph/stage_column_component_spec.js | 42 ++++++ spec/javascripts/pipelines_spec.js | 32 ++--- .../vue_shared/ci_action_icons_spec.js | 22 +++ .../vue_shared/ci_status_icon_spec.js | 27 ++++ .../vue_shared/components/ci_icon_spec.js | 130 ++++++++++++++++++ 40 files changed, 1321 insertions(+), 294 deletions(-) delete mode 100644 app/assets/javascripts/ci_status_icons.js create mode 100644 app/assets/javascripts/pipelines/components/graph/action_component.vue create mode 100644 app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue create mode 100644 app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue create mode 100644 app/assets/javascripts/pipelines/components/graph/graph_component.vue create mode 100644 app/assets/javascripts/pipelines/components/graph/job_component.vue create mode 100644 app/assets/javascripts/pipelines/components/graph/job_name_component.vue create mode 100644 app/assets/javascripts/pipelines/components/graph/stage_column_component.vue create mode 100644 app/assets/javascripts/pipelines/graph_bundle.js create mode 100644 app/assets/javascripts/pipelines/services/pipeline_service.js create mode 100644 app/assets/javascripts/pipelines/stores/pipeline_store.js create mode 100644 app/assets/javascripts/vue_shared/ci_action_icons.js create mode 100644 app/assets/javascripts/vue_shared/ci_status_icons.js create mode 100644 app/assets/javascripts/vue_shared/components/ci_icon.vue create mode 100644 app/assets/javascripts/vue_shared/mixins/tooltip.js delete mode 100644 app/views/ci/status/_graph_badge.html.haml delete mode 100644 app/views/projects/pipelines/_graph.html.haml delete mode 100644 app/views/projects/stage/_graph.html.haml delete mode 100644 app/views/projects/stage/_in_stage_group.html.haml create mode 100644 changelogs/unreleased/25226-realtime-pipelines-fe.yml delete mode 100644 spec/javascripts/ci_status_icon_spec.js create mode 100644 spec/javascripts/fixtures/graph.html.haml create mode 100644 spec/javascripts/pipelines/graph/action_component_spec.js create mode 100644 spec/javascripts/pipelines/graph/dropdown_action_component_spec.js create mode 100644 spec/javascripts/pipelines/graph/graph_component_spec.js create mode 100644 spec/javascripts/pipelines/graph/job_component_spec.js create mode 100644 spec/javascripts/pipelines/graph/job_name_component_spec.js create mode 100644 spec/javascripts/pipelines/graph/stage_column_component_spec.js create mode 100644 spec/javascripts/vue_shared/ci_action_icons_spec.js create mode 100644 spec/javascripts/vue_shared/ci_status_icon_spec.js create mode 100644 spec/javascripts/vue_shared/components/ci_icon_spec.js diff --git a/app/assets/javascripts/ci_status_icons.js b/app/assets/javascripts/ci_status_icons.js deleted file mode 100644 index f16616873b2ec..0000000000000 --- a/app/assets/javascripts/ci_status_icons.js +++ /dev/null @@ -1,34 +0,0 @@ -import CANCELED_SVG from 'icons/_icon_status_canceled_borderless.svg'; -import CREATED_SVG from 'icons/_icon_status_created_borderless.svg'; -import FAILED_SVG from 'icons/_icon_status_failed_borderless.svg'; -import MANUAL_SVG from 'icons/_icon_status_manual_borderless.svg'; -import PENDING_SVG from 'icons/_icon_status_pending_borderless.svg'; -import RUNNING_SVG from 'icons/_icon_status_running_borderless.svg'; -import SKIPPED_SVG from 'icons/_icon_status_skipped_borderless.svg'; -import SUCCESS_SVG from 'icons/_icon_status_success_borderless.svg'; -import WARNING_SVG from 'icons/_icon_status_warning_borderless.svg'; - -const StatusIconEntityMap = { - icon_status_canceled: CANCELED_SVG, - icon_status_created: CREATED_SVG, - icon_status_failed: FAILED_SVG, - icon_status_manual: MANUAL_SVG, - icon_status_pending: PENDING_SVG, - icon_status_running: RUNNING_SVG, - icon_status_skipped: SKIPPED_SVG, - icon_status_success: SUCCESS_SVG, - icon_status_warning: WARNING_SVG, -}; - -export { - CANCELED_SVG, - CREATED_SVG, - FAILED_SVG, - MANUAL_SVG, - PENDING_SVG, - RUNNING_SVG, - SKIPPED_SVG, - SUCCESS_SVG, - WARNING_SVG, - StatusIconEntityMap as default, -}; diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index b16ff2a022128..d27d89cf91db7 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -49,6 +49,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; import UserCallout from './user_callout'; import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags'; import ShortcutsWiki from './shortcuts_wiki'; +import Pipelines from './pipelines'; import BlobViewer from './blob/viewer/index'; import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; @@ -257,7 +258,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); const { controllerAction } = document.querySelector('.js-pipeline-container').dataset; const pipelineStatusUrl = `${document.querySelector('.js-pipeline-tab-link a').getAttribute('href')}/status.json`; - new gl.Pipelines({ + new Pipelines({ initTabs: true, pipelineStatusUrl, tabsOptions: { diff --git a/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js b/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js index 2955bda1a3626..0bf2ba6acc268 100644 --- a/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js +++ b/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js @@ -31,82 +31,78 @@ * * ### How to use * - * new window.gl.LinkedTabs({ + * new LinkedTabs({ * action: "#{controller.action_name}", * defaultAction: 'tab1', * parentEl: '.tab-links' * }); */ -(() => { - window.gl = window.gl || {}; +export default class LinkedTabs { + /** + * Binds the events and activates de default tab. + * + * @param {Object} options + */ + constructor(options = {}) { + this.options = options; - window.gl.LinkedTabs = class LinkedTabs { - /** - * Binds the events and activates de default tab. - * - * @param {Object} options - */ - constructor(options) { - this.options = options || {}; + this.defaultAction = this.options.defaultAction; + this.action = this.options.action || this.defaultAction; - this.defaultAction = this.options.defaultAction; - this.action = this.options.action || this.defaultAction; - - if (this.action === 'show') { - this.action = this.defaultAction; - } + if (this.action === 'show') { + this.action = this.defaultAction; + } - this.currentLocation = window.location; + this.currentLocation = window.location; - const tabSelector = `${this.options.parentEl} a[data-toggle="tab"]`; + const tabSelector = `${this.options.parentEl} a[data-toggle="tab"]`; - // since this is a custom event we need jQuery :( - $(document) - .off('shown.bs.tab', tabSelector) - .on('shown.bs.tab', tabSelector, e => this.tabShown(e)); + // since this is a custom event we need jQuery :( + $(document) + .off('shown.bs.tab', tabSelector) + .on('shown.bs.tab', tabSelector, e => this.tabShown(e)); - this.activateTab(this.action); - } + this.activateTab(this.action); + } - /** - * Handles the `shown.bs.tab` event to set the currect url action. - * - * @param {type} evt - * @return {Function} - */ - tabShown(evt) { - const source = evt.target.getAttribute('href'); + /** + * Handles the `shown.bs.tab` event to set the currect url action. + * + * @param {type} evt + * @return {Function} + */ + tabShown(evt) { + const source = evt.target.getAttribute('href'); - return this.setCurrentAction(source); - } + return this.setCurrentAction(source); + } - /** - * Updates the URL with the path that matched the given action. - * - * @param {String} source - * @return {String} - */ - setCurrentAction(source) { - const copySource = source; + /** + * Updates the URL with the path that matched the given action. + * + * @param {String} source + * @return {String} + */ + setCurrentAction(source) { + const copySource = source; - copySource.replace(/\/+$/, ''); + copySource.replace(/\/+$/, ''); - const newState = `${copySource}${this.currentLocation.search}${this.currentLocation.hash}`; + const newState = `${copySource}${this.currentLocation.search}${this.currentLocation.hash}`; - history.replaceState({ - url: newState, - }, document.title, newState); - return newState; - } + history.replaceState({ + url: newState, + }, document.title, newState); + return newState; + } - /** - * Given the current action activates the correct tab. - * http://getbootstrap.com/javascript/#tab-show - * Note: Will trigger `shown.bs.tab` - */ - activateTab() { - return $(`${this.options.parentEl} a[data-action='${this.action}']`).tab('show'); - } - }; -})(); + /** + * Given the current action activates the correct tab. + * http://getbootstrap.com/javascript/#tab-show + * Note: Will trigger `shown.bs.tab` + */ + activateTab() { + return $(`${this.options.parentEl} a[data-action='${this.action}']`).tab('show'); + } +} diff --git a/app/assets/javascripts/pipelines.js b/app/assets/javascripts/pipelines.js index 4252b6158877e..26a36ad54d1fe 100644 --- a/app/assets/javascripts/pipelines.js +++ b/app/assets/javascripts/pipelines.js @@ -1,42 +1,14 @@ -/* eslint-disable no-new, guard-for-in, no-restricted-syntax, no-continue, no-param-reassign, max-len */ +import LinkedTabs from './lib/utils/bootstrap_linked_tabs'; -require('./lib/utils/bootstrap_linked_tabs'); - -((global) => { - class Pipelines { - constructor(options = {}) { - if (options.initTabs && options.tabsOptions) { - new global.LinkedTabs(options.tabsOptions); - } - - if (options.pipelineStatusUrl) { - gl.utils.setCiStatusFavicon(options.pipelineStatusUrl); - } - - this.addMarginToBuildColumns(); +export default class Pipelines { + constructor(options = {}) { + if (options.initTabs && options.tabsOptions) { + // eslint-disable-next-line no-new + new LinkedTabs(options.tabsOptions); } - addMarginToBuildColumns() { - this.pipelineGraph = document.querySelector('.js-pipeline-graph'); - - const secondChildBuildNodes = this.pipelineGraph.querySelectorAll('.build:nth-child(2)'); - - for (const buildNodeIndex in secondChildBuildNodes) { - const buildNode = secondChildBuildNodes[buildNodeIndex]; - const firstChildBuildNode = buildNode.previousElementSibling; - if (!firstChildBuildNode || !firstChildBuildNode.matches('.build')) continue; - const multiBuildColumn = buildNode.closest('.stage-column'); - const previousColumn = multiBuildColumn.previousElementSibling; - if (!previousColumn || !previousColumn.matches('.stage-column')) continue; - multiBuildColumn.classList.add('left-margin'); - firstChildBuildNode.classList.add('left-connector'); - const columnBuilds = previousColumn.querySelectorAll('.build'); - if (columnBuilds.length === 1) previousColumn.classList.add('no-margin'); - } - - this.pipelineGraph.classList.remove('hidden'); + if (options.pipelineStatusUrl) { + gl.utils.setCiStatusFavicon(options.pipelineStatusUrl); } } - - global.Pipelines = Pipelines; -})(window.gl || (window.gl = {})); +} diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue new file mode 100644 index 0000000000000..14e485791ea5f --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -0,0 +1,59 @@ +<script> + import getActionIcon from '../../../vue_shared/ci_action_icons'; + import tooltipMixin from '../../../vue_shared/mixins/tooltip'; + + /** + * Renders either a cancel, retry or play icon pointing to the given path. + * TODO: Remove UJS from here and use an async request instead. + */ + export default { + props: { + tooltipText: { + type: String, + required: true, + }, + + link: { + type: String, + required: true, + }, + + actionMethod: { + type: String, + required: true, + }, + + actionIcon: { + type: String, + required: true, + }, + }, + + mixins: [ + tooltipMixin, + ], + + computed: { + actionIconSvg() { + return getActionIcon(this.actionIcon); + }, + }, + }; +</script> +<template> + <a + :data-method="actionMethod" + :title="tooltipText" + :href="link" + ref="tooltip" + class="ci-action-icon-container" + data-toggle="tooltip" + data-container="body"> + + <i + class="ci-action-icon-wrapper" + v-html="actionIconSvg" + aria-hidden="true" + /> + </a> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue new file mode 100644 index 0000000000000..19cafff4e1c6e --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue @@ -0,0 +1,56 @@ +<script> + import getActionIcon from '../../../vue_shared/ci_action_icons'; + import tooltipMixin from '../../../vue_shared/mixins/tooltip'; + + /** + * Renders either a cancel, retry or play icon pointing to the given path. + * TODO: Remove UJS from here and use an async request instead. + */ + export default { + props: { + tooltipText: { + type: String, + required: true, + }, + + link: { + type: String, + required: true, + }, + + actionMethod: { + type: String, + required: true, + }, + + actionIcon: { + type: String, + required: true, + }, + }, + + mixins: [ + tooltipMixin, + ], + + computed: { + actionIconSvg() { + return getActionIcon(this.actionIcon); + }, + }, + }; +</script> +<template> + <a + :data-method="actionMethod" + :title="tooltipText" + :href="link" + ref="tooltip" + rel="nofollow" + class="ci-action-icon-wrapper js-ci-status-icon" + data-toggle="tooltip" + data-container="body" + v-html="actionIconSvg" + aria-label="Job's action"> + </a> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue new file mode 100644 index 0000000000000..d597af8dfb5f8 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue @@ -0,0 +1,86 @@ +<script> + import jobNameComponent from './job_name_component.vue'; + import jobComponent from './job_component.vue'; + import tooltipMixin from '../../../vue_shared/mixins/tooltip'; + + /** + * Renders the dropdown for the pipeline graph. + * + * The following object should be provided as `job`: + * + * { + * "id": 4256, + * "name": "test", + * "status": { + * "icon": "icon_status_success", + * "text": "passed", + * "label": "passed", + * "group": "success", + * "details_path": "/root/ci-mock/builds/4256", + * "action": { + * "icon": "icon_action_retry", + * "title": "Retry", + * "path": "/root/ci-mock/builds/4256/retry", + * "method": "post" + * } + * } + * } + */ + export default { + props: { + job: { + type: Object, + required: true, + }, + }, + + mixins: [ + tooltipMixin, + ], + + components: { + jobComponent, + jobNameComponent, + }, + + computed: { + tooltipText() { + return `${this.job.name} - ${this.job.status.label}`; + }, + }, + }; +</script> +<template> + <div> + <button + type="button" + data-toggle="dropdown" + data-container="body" + class="dropdown-menu-toggle build-content" + :title="tooltipText" + ref="tooltip"> + + <job-name-component + :name="job.name" + :status="job.status" /> + + <span class="dropdown-counter-badge"> + {{job.size}} + </span> + </button> + + <ul class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown"> + <li class="scrollable-menu"> + <ul> + <li v-for="item in job.jobs"> + <job-component + :job="item" + :is-dropdown="true" + css-class-job-name="mini-pipeline-graph-dropdown-item" + /> + </li> + </ul> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue new file mode 100644 index 0000000000000..a84161ef5e796 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -0,0 +1,92 @@ +<script> + /* global Flash */ + import Visibility from 'visibilityjs'; + import Poll from '../../../lib/utils/poll'; + import PipelineService from '../../services/pipeline_service'; + import PipelineStore from '../../stores/pipeline_store'; + import stageColumnComponent from './stage_column_component.vue'; + import '../../../flash'; + + export default { + components: { + stageColumnComponent, + }, + + data() { + const DOMdata = document.getElementById('js-pipeline-graph-vue').dataset; + const store = new PipelineStore(); + + return { + isLoading: false, + endpoint: DOMdata.endpoint, + store, + state: store.state, + }; + }, + + created() { + this.service = new PipelineService(this.endpoint); + + const poll = new Poll({ + resource: this.service, + method: 'getPipeline', + successCallback: this.successCallback, + errorCallback: this.errorCallback, + }); + + if (!Visibility.hidden()) { + this.isLoading = true; + poll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + poll.restart(); + } else { + poll.stop(); + } + }); + }, + + methods: { + successCallback(response) { + const data = response.json(); + + this.isLoading = false; + this.store.storeGraph(data.details.stages); + }, + + errorCallback() { + this.isLoading = false; + return new Flash('An error occurred while fetching the pipeline.'); + }, + + capitalizeStageName(name) { + return name.charAt(0).toUpperCase() + name.slice(1); + }, + }, + }; +</script> +<template> + <div class="build-content middle-block js-pipeline-graph"> + <div class="pipeline-visualization pipeline-graph"> + <div class="text-center"> + <i + v-if="isLoading" + class="loading-icon fa fa-spin fa-spinner fa-3x" + aria-label="Loading" + aria-hidden="true" /> + </div> + + <ul + v-if="!isLoading" + class="stage-column-list"> + <stage-column-component + v-for="stage in state.graph" + :title="capitalizeStageName(stage.name)" + :jobs="stage.groups" + :key="stage.name"/> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/job_component.vue b/app/assets/javascripts/pipelines/components/graph/job_component.vue new file mode 100644 index 0000000000000..b39c936101e3f --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/job_component.vue @@ -0,0 +1,124 @@ +<script> + import actionComponent from './action_component.vue'; + import dropdownActionComponent from './dropdown_action_component.vue'; + import jobNameComponent from './job_name_component.vue'; + import tooltipMixin from '../../../vue_shared/mixins/tooltip'; + + /** + * Renders the badge for the pipeline graph and the job's dropdown. + * + * The following object should be provided as `job`: + * + * { + * "id": 4256, + * "name": "test", + * "status": { + * "icon": "icon_status_success", + * "text": "passed", + * "label": "passed", + * "group": "success", + * "details_path": "/root/ci-mock/builds/4256", + * "action": { + * "icon": "icon_action_retry", + * "title": "Retry", + * "path": "/root/ci-mock/builds/4256/retry", + * "method": "post" + * } + * } + * } + */ + + export default { + props: { + job: { + type: Object, + required: true, + }, + + cssClassJobName: { + type: String, + required: false, + default: '', + }, + + isDropdown: { + type: Boolean, + required: false, + default: false, + }, + }, + + components: { + actionComponent, + dropdownActionComponent, + jobNameComponent, + }, + + mixins: [ + tooltipMixin, + ], + + computed: { + tooltipText() { + return `${this.job.name} - ${this.job.status.label}`; + }, + + /** + * Verifies if the provided job has an action path + * + * @return {Boolean} + */ + hasAction() { + return this.job.status && this.job.status.action && this.job.status.action.path; + }, + }, + }; +</script> +<template> + <div> + <a + v-if="job.status.details_path" + :href="job.status.details_path" + :title="tooltipText" + :class="cssClassJobName" + ref="tooltip" + data-toggle="tooltip" + data-container="body"> + + <job-name-component + :name="job.name" + :status="job.status" + /> + </a> + + <div + v-else + :title="tooltipText" + :class="cssClassJobName" + ref="tooltip" + data-toggle="tooltip" + data-container="body"> + + <job-name-component + :name="job.name" + :status="job.status" + /> + </div> + + <action-component + v-if="hasAction && !isDropdown" + :tooltip-text="job.status.action.title" + :link="job.status.action.path" + :action-icon="job.status.action.icon" + :action-method="job.status.action.method" + /> + + <dropdown-action-component + v-if="hasAction && isDropdown" + :tooltip-text="job.status.action.title" + :link="job.status.action.path" + :action-icon="job.status.action.icon" + :action-method="job.status.action.method" + /> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue new file mode 100644 index 0000000000000..d8856e10668a2 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue @@ -0,0 +1,37 @@ +<script> + import ciIcon from '../../../vue_shared/components/ci_icon.vue'; + + /** + * Component that renders both the CI icon status and the job name. + * Used in + * - Badge component + * - Dropdown badge components + */ + export default { + props: { + name: { + type: String, + required: true, + }, + + status: { + type: Object, + required: true, + }, + }, + + components: { + ciIcon, + }, + }; +</script> +<template> + <span> + <ci-icon + :status="status" /> + + <span class="ci-status-text"> + {{name}} + </span> + </span> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue new file mode 100644 index 0000000000000..b7da185e2803e --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -0,0 +1,64 @@ +<script> +import jobComponent from './job_component.vue'; +import dropdownJobComponent from './dropdown_job_component.vue'; + +export default { + props: { + title: { + type: String, + required: true, + }, + + jobs: { + type: Array, + required: true, + }, + }, + + components: { + jobComponent, + dropdownJobComponent, + }, + + methods: { + firstJob(list) { + return list[0]; + }, + + jobId(job) { + return `ci-badge-${job.name}`; + }, + }, +}; +</script> +<template> + <li class="stage-column"> + <div class="stage-name"> + {{title}} + </div> + <div class="builds-container"> + <ul> + <li + v-for="job in jobs" + :key="job.id" + class="build" + :id="jobId(job)"> + + <div class="curve"></div> + + <job-component + v-if="job.size === 1" + :job="job" + css-class-job-name="build-content" + /> + + <dropdown-job-component + v-if="job.size > 1" + :job="job" + /> + + </li> + </ul> + </div> + </li> +</template> diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue index 2e485f951a186..dc42223269df2 100644 --- a/app/assets/javascripts/pipelines/components/stage.vue +++ b/app/assets/javascripts/pipelines/components/stage.vue @@ -14,7 +14,7 @@ */ /* global Flash */ -import StatusIconEntityMap from '../../ci_status_icons'; +import { statusCssClasses, borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons'; export default { props: { @@ -109,11 +109,11 @@ export default { }, triggerButtonClass() { - return `ci-status-icon-${this.stage.status.group}`; + return `ci-status-icon-${statusCssClasses[this.stage.status.icon]}`; }, svgIcon() { - return StatusIconEntityMap[this.stage.status.icon]; + return borderlessStatusIconEntityMap[this.stage.status.icon]; }, }, }; diff --git a/app/assets/javascripts/pipelines/graph_bundle.js b/app/assets/javascripts/pipelines/graph_bundle.js new file mode 100644 index 0000000000000..b7a6b5d847977 --- /dev/null +++ b/app/assets/javascripts/pipelines/graph_bundle.js @@ -0,0 +1,10 @@ +import Vue from 'vue'; +import pipelineGraph from './components/graph/graph_component.vue'; + +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: '#js-pipeline-graph-vue', + components: { + pipelineGraph, + }, + render: createElement => createElement('pipeline-graph'), +})); diff --git a/app/assets/javascripts/pipelines/services/pipeline_service.js b/app/assets/javascripts/pipelines/services/pipeline_service.js new file mode 100644 index 0000000000000..f1cc60c1ee031 --- /dev/null +++ b/app/assets/javascripts/pipelines/services/pipeline_service.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class PipelineService { + constructor(endpoint) { + this.pipeline = Vue.resource(endpoint); + } + + getPipeline() { + return this.pipeline.get(); + } +} diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js new file mode 100644 index 0000000000000..86ab50d8f1e97 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/pipeline_store.js @@ -0,0 +1,11 @@ +export default class PipelineStore { + constructor() { + this.state = {}; + + this.state.graph = []; + } + + storeGraph(graph = []) { + this.state.graph = graph; + } +} diff --git a/app/assets/javascripts/vue_shared/ci_action_icons.js b/app/assets/javascripts/vue_shared/ci_action_icons.js new file mode 100644 index 0000000000000..734b3c6c45e4a --- /dev/null +++ b/app/assets/javascripts/vue_shared/ci_action_icons.js @@ -0,0 +1,22 @@ +import cancelSVG from 'icons/_icon_action_cancel.svg'; +import retrySVG from 'icons/_icon_action_retry.svg'; +import playSVG from 'icons/_icon_action_play.svg'; + +export default function getActionIcon(action) { + let icon; + switch (action) { + case 'icon_action_cancel': + icon = cancelSVG; + break; + case 'icon_action_retry': + icon = retrySVG; + break; + case 'icon_action_play': + icon = playSVG; + break; + default: + icon = ''; + } + + return icon; +} diff --git a/app/assets/javascripts/vue_shared/ci_status_icons.js b/app/assets/javascripts/vue_shared/ci_status_icons.js new file mode 100644 index 0000000000000..48ad9214ac800 --- /dev/null +++ b/app/assets/javascripts/vue_shared/ci_status_icons.js @@ -0,0 +1,55 @@ +import BORDERLESS_CANCELED_SVG from 'icons/_icon_status_canceled_borderless.svg'; +import BORDERLESS_CREATED_SVG from 'icons/_icon_status_created_borderless.svg'; +import BORDERLESS_FAILED_SVG from 'icons/_icon_status_failed_borderless.svg'; +import BORDERLESS_MANUAL_SVG from 'icons/_icon_status_manual_borderless.svg'; +import BORDERLESS_PENDING_SVG from 'icons/_icon_status_pending_borderless.svg'; +import BORDERLESS_RUNNING_SVG from 'icons/_icon_status_running_borderless.svg'; +import BORDERLESS_SKIPPED_SVG from 'icons/_icon_status_skipped_borderless.svg'; +import BORDERLESS_SUCCESS_SVG from 'icons/_icon_status_success_borderless.svg'; +import BORDERLESS_WARNING_SVG from 'icons/_icon_status_warning_borderless.svg'; + +import CANCELED_SVG from 'icons/_icon_status_canceled.svg'; +import CREATED_SVG from 'icons/_icon_status_created.svg'; +import FAILED_SVG from 'icons/_icon_status_failed.svg'; +import MANUAL_SVG from 'icons/_icon_status_manual.svg'; +import PENDING_SVG from 'icons/_icon_status_pending.svg'; +import RUNNING_SVG from 'icons/_icon_status_running.svg'; +import SKIPPED_SVG from 'icons/_icon_status_skipped.svg'; +import SUCCESS_SVG from 'icons/_icon_status_success.svg'; +import WARNING_SVG from 'icons/_icon_status_warning.svg'; + +export const borderlessStatusIconEntityMap = { + icon_status_canceled: BORDERLESS_CANCELED_SVG, + icon_status_created: BORDERLESS_CREATED_SVG, + icon_status_failed: BORDERLESS_FAILED_SVG, + icon_status_manual: BORDERLESS_MANUAL_SVG, + icon_status_pending: BORDERLESS_PENDING_SVG, + icon_status_running: BORDERLESS_RUNNING_SVG, + icon_status_skipped: BORDERLESS_SKIPPED_SVG, + icon_status_success: BORDERLESS_SUCCESS_SVG, + icon_status_warning: BORDERLESS_WARNING_SVG, +}; + +export const statusIconEntityMap = { + icon_status_canceled: CANCELED_SVG, + icon_status_created: CREATED_SVG, + icon_status_failed: FAILED_SVG, + icon_status_manual: MANUAL_SVG, + icon_status_pending: PENDING_SVG, + icon_status_running: RUNNING_SVG, + icon_status_skipped: SKIPPED_SVG, + icon_status_success: SUCCESS_SVG, + icon_status_warning: WARNING_SVG, +}; + +export const statusCssClasses = { + icon_status_canceled: 'canceled', + icon_status_created: 'created', + icon_status_failed: 'failed', + icon_status_manual: 'manual', + icon_status_pending: 'pending', + icon_status_running: 'running', + icon_status_skipped: 'skipped', + icon_status_success: 'success', + icon_status_warning: 'warning', +}; diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue new file mode 100644 index 0000000000000..4d44baaa3c427 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue @@ -0,0 +1,29 @@ +<script> + import { statusIconEntityMap, statusCssClasses } from '../../vue_shared/ci_status_icons'; + + export default { + props: { + status: { + type: Object, + required: true, + }, + }, + + computed: { + statusIconSvg() { + return statusIconEntityMap[this.status.icon]; + }, + + cssClass() { + const status = statusCssClasses[this.status.icon]; + return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status}`; + }, + }, + }; +</script> +<template> + <span + :class="cssClass" + v-html="statusIconSvg"> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/mixins/tooltip.js b/app/assets/javascripts/vue_shared/mixins/tooltip.js new file mode 100644 index 0000000000000..9bb948bff66b2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/mixins/tooltip.js @@ -0,0 +1,9 @@ +export default { + mounted() { + $(this.$refs.tooltip).tooltip(); + }, + + updated() { + $(this.$refs.tooltip).tooltip('fixTitle'); + }, +}; diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 530a6f3c6a1a8..eaf3dd495674c 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -486,7 +486,7 @@ color: $gl-text-color-secondary; // Action Icons in big pipeline-graph nodes - > .ci-action-icon-container .ci-action-icon-wrapper { + > div > .ci-action-icon-container .ci-action-icon-wrapper { height: 30px; width: 30px; background: $white-light; @@ -511,7 +511,7 @@ } } - > .ci-action-icon-container { + > div > .ci-action-icon-container { position: absolute; right: 5px; top: 5px; @@ -541,7 +541,7 @@ } } - > .build-content { + > div > .build-content { display: inline-block; padding: 8px 10px 9px; width: 100%; @@ -557,34 +557,6 @@ } - .arrow { - &::before, - &::after { - content: ''; - display: inline-block; - position: absolute; - width: 0; - height: 0; - border-color: transparent; - border-style: solid; - top: 18px; - } - - &::before { - left: -5px; - margin-top: -6px; - border-width: 7px 5px 7px 0; - border-right-color: $border-color; - } - - &::after { - left: -4px; - margin-top: -9px; - border-width: 10px 7px 10px 0; - border-right-color: $white-light; - } - } - // Connect first build in each stage with right horizontal line &:first-child { &::after { @@ -859,7 +831,8 @@ border-radius: 3px; // build name - .ci-build-text { + .ci-build-text, + .ci-status-text { font-weight: 200; overflow: hidden; white-space: nowrap; @@ -911,6 +884,38 @@ } } +/** + * Top arrow in the dropdown in the big pipeline graph + */ +.big-pipeline-graph-dropdown-menu { + + &::before, + &::after { + content: ''; + display: inline-block; + position: absolute; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; + top: 18px; + } + + &::before { + left: -5px; + margin-top: -6px; + border-width: 7px 5px 7px 0; + border-right-color: $border-color; + } + + &::after { + left: -4px; + margin-top: -9px; + border-width: 10px 7px 10px 0; + border-right-color: $white-light; + } +} + /** * Top arrow in the dropdown in the mini pipeline graph */ diff --git a/app/views/ci/status/_graph_badge.html.haml b/app/views/ci/status/_graph_badge.html.haml deleted file mode 100644 index 128b418090fd2..0000000000000 --- a/app/views/ci/status/_graph_badge.html.haml +++ /dev/null @@ -1,20 +0,0 @@ --# Renders the graph node with both the status icon, status name and action icon - -- subject = local_assigns.fetch(:subject) -- status = subject.detailed_status(current_user) -- klass = "ci-status-icon ci-status-icon-#{status.group} js-ci-status-icon-#{status.group}" -- tooltip = "#{subject.name} - #{status.label}" - -- if status.has_details? - = link_to status.details_path, class: 'build-content has-tooltip', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do - %span{ class: klass }= custom_icon(status.icon) - .ci-status-text= subject.name -- else - .build-content.has-tooltip{ data: { toggle: 'tooltip', title: tooltip } } - %span{ class: klass }= custom_icon(status.icon) - .ci-status-text= subject.name - -- if status.has_action? - = link_to status.action_path, class: 'ci-action-icon-container has-tooltip', method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do - %i.ci-action-icon-wrapper{ class: "js-#{status.action_icon.dasherize}" } - = custom_icon(status.action_icon) diff --git a/app/views/projects/pipelines/_graph.html.haml b/app/views/projects/pipelines/_graph.html.haml deleted file mode 100644 index 0202833c0bf56..0000000000000 --- a/app/views/projects/pipelines/_graph.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -- pipeline = local_assigns.fetch(:pipeline) -.pipeline-visualization.pipeline-graph - %ul.stage-column-list - = render partial: "projects/stage/graph", collection: pipeline.stages, as: :stage diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 1aa48bf98131b..1c7d1768aa58c 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -17,8 +17,11 @@ .tab-content #js-tab-pipeline.tab-pane - .build-content.middle-block.js-pipeline-graph - = render "projects/pipelines/graph", pipeline: pipeline + #js-pipeline-graph-vue{ data: { endpoint: namespace_project_pipeline_path(@project.namespace, @project, @pipeline, format: :json) } } + + - content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('pipelines_graph') #js-tab-builds.tab-pane - if pipeline.yaml_errors.present? diff --git a/app/views/projects/stage/_graph.html.haml b/app/views/projects/stage/_graph.html.haml deleted file mode 100644 index 4ee30b023acc1..0000000000000 --- a/app/views/projects/stage/_graph.html.haml +++ /dev/null @@ -1,19 +0,0 @@ -- stage = local_assigns.fetch(:stage) -- statuses = stage.statuses.latest -- status_groups = statuses.sort_by(&:sortable_name).group_by(&:group_name) -%li.stage-column - .stage-name - %a{ name: stage.name } - = stage.name.titleize - .builds-container - %ul - - status_groups.each do |group_name, grouped_statuses| - - if grouped_statuses.one? - - status = grouped_statuses.first - %li.build{ 'id' => "ci-badge-#{group_name}" } - .curve - = render 'ci/status/graph_badge', subject: status - - else - %li.build{ 'id' => "ci-badge-#{group_name}" } - .curve - = render 'projects/stage/in_stage_group', name: group_name, subject: grouped_statuses diff --git a/app/views/projects/stage/_in_stage_group.html.haml b/app/views/projects/stage/_in_stage_group.html.haml deleted file mode 100644 index 671a3ef481c7e..0000000000000 --- a/app/views/projects/stage/_in_stage_group.html.haml +++ /dev/null @@ -1,14 +0,0 @@ -- group_status = CommitStatus.where(id: subject).status -%button.dropdown-menu-toggle.build-content.has-tooltip{ type: 'button', data: { toggle: 'dropdown', title: "#{name} - #{group_status}", container: 'body' } } - %span{ class: "ci-status-icon ci-status-icon-#{group_status}" } - = ci_icon_for_status(group_status) - %span.ci-status-text - = name - %span.dropdown-counter-badge= subject.size - -%ul.dropdown-menu.big-pipeline-graph-dropdown-menu.js-grouped-pipeline-dropdown - .arrow - .scrollable-menu - - subject.each do |status| - %li - = render 'ci/status/dropdown_graph_badge', subject: status diff --git a/changelogs/unreleased/25226-realtime-pipelines-fe.yml b/changelogs/unreleased/25226-realtime-pipelines-fe.yml new file mode 100644 index 0000000000000..1149c8f0eac15 --- /dev/null +++ b/changelogs/unreleased/25226-realtime-pipelines-fe.yml @@ -0,0 +1,4 @@ +--- +title: Re-rewrites pipeline graph in vue to support realtime data updates +merge_request: +author: diff --git a/config/webpack.config.js b/config/webpack.config.js index 119b1ea9d2e4d..a3dae6b2e131c 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -49,6 +49,7 @@ var config = { pdf_viewer: './blob/pdf_viewer.js', pipelines: './pipelines/index.js', balsamiq_viewer: './blob/balsamiq_viewer.js', + pipelines_graph: './pipelines/graph_bundle.js', profile: './profile/profile_bundle.js', protected_branches: './protected_branches/protected_branches_bundle.js', protected_tags: './protected_tags', @@ -145,6 +146,7 @@ var config = { 'pdf_viewer', 'pipelines', 'balsamiq_viewer', + 'pipelines_graph', ], minChunks: function(module, count) { return module.resource && (/vue_shared/).test(module.resource); diff --git a/spec/javascripts/bootstrap_linked_tabs_spec.js b/spec/javascripts/bootstrap_linked_tabs_spec.js index fa9f95e16cdca..a27dc48b3fd8b 100644 --- a/spec/javascripts/bootstrap_linked_tabs_spec.js +++ b/spec/javascripts/bootstrap_linked_tabs_spec.js @@ -1,4 +1,4 @@ -require('~/lib/utils/bootstrap_linked_tabs'); +import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs'; (() => { // TODO: remove this hack! @@ -25,7 +25,7 @@ require('~/lib/utils/bootstrap_linked_tabs'); }); it('should activate the tab correspondent to the given action', () => { - const linkedTabs = new window.gl.LinkedTabs({ // eslint-disable-line + const linkedTabs = new LinkedTabs({ // eslint-disable-line action: 'tab1', defaultAction: 'tab1', parentEl: '.linked-tabs', @@ -35,7 +35,7 @@ require('~/lib/utils/bootstrap_linked_tabs'); }); it('should active the default tab action when the action is show', () => { - const linkedTabs = new window.gl.LinkedTabs({ // eslint-disable-line + const linkedTabs = new LinkedTabs({ // eslint-disable-line action: 'show', defaultAction: 'tab1', parentEl: '.linked-tabs', @@ -49,7 +49,7 @@ require('~/lib/utils/bootstrap_linked_tabs'); it('should change the url according to the clicked tab', () => { const historySpy = !phantomjs && spyOn(history, 'replaceState').and.callFake(() => {}); - const linkedTabs = new window.gl.LinkedTabs({ // eslint-disable-line + const linkedTabs = new LinkedTabs({ action: 'show', defaultAction: 'tab1', parentEl: '.linked-tabs', diff --git a/spec/javascripts/ci_status_icon_spec.js b/spec/javascripts/ci_status_icon_spec.js deleted file mode 100644 index c83416c15efc1..0000000000000 --- a/spec/javascripts/ci_status_icon_spec.js +++ /dev/null @@ -1,44 +0,0 @@ -import * as icons from '~/ci_status_icons'; - -describe('CI status icons', () => { - const statuses = [ - 'canceled', - 'created', - 'failed', - 'manual', - 'pending', - 'running', - 'skipped', - 'success', - 'warning', - ]; - - statuses.forEach((status) => { - it(`should export a ${status} svg`, () => { - const key = `${status.toUpperCase()}_SVG`; - - expect(Object.hasOwnProperty.call(icons, key)).toBe(true); - expect(icons[key]).toMatch(/^<svg/); - }); - }); - - describe('default export map', () => { - const entityIconNames = [ - 'icon_status_canceled', - 'icon_status_created', - 'icon_status_failed', - 'icon_status_manual', - 'icon_status_pending', - 'icon_status_running', - 'icon_status_skipped', - 'icon_status_success', - 'icon_status_warning', - ]; - - entityIconNames.forEach((iconName) => { - it(`should have a '${iconName}' key`, () => { - expect(Object.hasOwnProperty.call(icons.default, iconName)).toBe(true); - }); - }); - }); -}); diff --git a/spec/javascripts/fixtures/graph.html.haml b/spec/javascripts/fixtures/graph.html.haml new file mode 100644 index 0000000000000..4fedb0f1ded07 --- /dev/null +++ b/spec/javascripts/fixtures/graph.html.haml @@ -0,0 +1 @@ +#js-pipeline-graph-vue{ data: { endpoint: "foo" } } diff --git a/spec/javascripts/pipelines/graph/action_component_spec.js b/spec/javascripts/pipelines/graph/action_component_spec.js new file mode 100644 index 0000000000000..f033956c0715b --- /dev/null +++ b/spec/javascripts/pipelines/graph/action_component_spec.js @@ -0,0 +1,40 @@ +import Vue from 'vue'; +import actionComponent from '~/pipelines/components/graph/action_component.vue'; + +describe('pipeline graph action component', () => { + let component; + + beforeEach(() => { + const ActionComponent = Vue.extend(actionComponent); + component = new ActionComponent({ + propsData: { + tooltipText: 'bar', + link: 'foo', + actionMethod: 'post', + actionIcon: 'icon_action_cancel', + }, + }).$mount(); + }); + + it('should render a link', () => { + expect(component.$el.getAttribute('href')).toEqual('foo'); + }); + + it('should render the provided title as a bootstrap tooltip', () => { + expect(component.$el.getAttribute('data-original-title')).toEqual('bar'); + }); + + it('should update bootstrap tooltip when title changes', (done) => { + component.tooltipText = 'changed'; + + Vue.nextTick(() => { + expect(component.$el.getAttribute('data-original-title')).toBe('changed'); + done(); + }); + }); + + it('should render an svg', () => { + expect(component.$el.querySelector('.ci-action-icon-wrapper')).toBeDefined(); + expect(component.$el.querySelector('svg')).toBeDefined(); + }); +}); diff --git a/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js b/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js new file mode 100644 index 0000000000000..14ff1b0d25cfc --- /dev/null +++ b/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js @@ -0,0 +1,30 @@ +import Vue from 'vue'; +import dropdownActionComponent from '~/pipelines/components/graph/dropdown_action_component.vue'; + +describe('action component', () => { + let component; + + beforeEach(() => { + const DropdownActionComponent = Vue.extend(dropdownActionComponent); + component = new DropdownActionComponent({ + propsData: { + tooltipText: 'bar', + link: 'foo', + actionMethod: 'post', + actionIcon: 'icon_action_cancel', + }, + }).$mount(); + }); + + it('should render a link', () => { + expect(component.$el.getAttribute('href')).toEqual('foo'); + }); + + it('should render the provided title as a bootstrap tooltip', () => { + expect(component.$el.getAttribute('data-original-title')).toEqual('bar'); + }); + + it('should render an svg', () => { + expect(component.$el.querySelector('svg')).toBeDefined(); + }); +}); diff --git a/spec/javascripts/pipelines/graph/graph_component_spec.js b/spec/javascripts/pipelines/graph/graph_component_spec.js new file mode 100644 index 0000000000000..a756617e65eae --- /dev/null +++ b/spec/javascripts/pipelines/graph/graph_component_spec.js @@ -0,0 +1,83 @@ +import Vue from 'vue'; +import graphComponent from '~/pipelines/components/graph/graph_component.vue'; + +describe('graph component', () => { + preloadFixtures('static/graph.html.raw'); + + let GraphComponent; + + beforeEach(() => { + loadFixtures('static/graph.html.raw'); + GraphComponent = Vue.extend(graphComponent); + }); + + describe('while is loading', () => { + it('should render a loading icon', () => { + const component = new GraphComponent().$mount('#js-pipeline-graph-vue'); + expect(component.$el.querySelector('.loading-icon')).toBeDefined(); + }); + }); + + describe('with a successfull response', () => { + const interceptor = (request, next) => { + next(request.respondWith(JSON.stringify({ + details: { + stages: [{ + name: 'test', + title: 'test: passed', + status: { + icon: 'icon_status_success', + text: 'passed', + label: 'passed', + details_path: '/root/ci-mock/pipelines/123#test', + }, + path: '/root/ci-mock/pipelines/123#test', + groups: [{ + name: 'test', + size: 1, + jobs: [{ + id: 4153, + name: 'test', + status: { + icon: 'icon_status_success', + text: 'passed', + label: 'passed', + details_path: '/root/ci-mock/builds/4153', + action: { + icon: 'icon_action_retry', + title: 'Retry', + path: '/root/ci-mock/builds/4153/retry', + method: 'post', + }, + }, + }], + }], + }], + }, + }), { + status: 200, + })); + }; + + beforeEach(() => { + Vue.http.interceptors.push(interceptor); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor); + }); + + it('should render the graph', (done) => { + const component = new GraphComponent().$mount('#js-pipeline-graph-vue'); + + setTimeout(() => { + expect(component.$el.classList.contains('js-pipeline-graph')).toEqual(true); + + expect(component.$el.querySelector('loading-icon')).toBe(null); + + expect(component.$el.querySelector('.stage-column-list')).toBeDefined(); + done(); + }, 0); + }); + }); +}); diff --git a/spec/javascripts/pipelines/graph/job_component_spec.js b/spec/javascripts/pipelines/graph/job_component_spec.js new file mode 100644 index 0000000000000..63986b6c0db1a --- /dev/null +++ b/spec/javascripts/pipelines/graph/job_component_spec.js @@ -0,0 +1,117 @@ +import Vue from 'vue'; +import jobComponent from '~/pipelines/components/graph/job_component.vue'; + +describe('pipeline graph job component', () => { + let JobComponent; + + const mockJob = { + id: 4256, + name: 'test', + status: { + icon: 'icon_status_success', + text: 'passed', + label: 'passed', + group: 'success', + details_path: '/root/ci-mock/builds/4256', + action: { + icon: 'icon_action_retry', + title: 'Retry', + path: '/root/ci-mock/builds/4256/retry', + method: 'post', + }, + }, + }; + + beforeEach(() => { + JobComponent = Vue.extend(jobComponent); + }); + + describe('name with link', () => { + it('should render the job name and status with a link', () => { + const component = new JobComponent({ + propsData: { + job: mockJob, + }, + }).$mount(); + + const link = component.$el.querySelector('a'); + + expect(link.getAttribute('href')).toEqual(mockJob.status.details_path); + + expect( + link.getAttribute('data-original-title'), + ).toEqual(`${mockJob.name} - ${mockJob.status.label}`); + + expect(component.$el.querySelector('.js-status-icon-success')).toBeDefined(); + + expect( + component.$el.querySelector('.ci-status-text').textContent.trim(), + ).toEqual(mockJob.name); + }); + }); + + describe('name without link', () => { + it('it should render status and name', () => { + const component = new JobComponent({ + propsData: { + job: { + id: 4256, + name: 'test', + status: { + icon: 'icon_status_success', + text: 'passed', + label: 'passed', + group: 'success', + details_path: '/root/ci-mock/builds/4256', + }, + }, + }, + }).$mount(); + + expect(component.$el.querySelector('.js-status-icon-success')).toBeDefined(); + + expect( + component.$el.querySelector('.ci-status-text').textContent.trim(), + ).toEqual(mockJob.name); + }); + }); + + describe('action icon', () => { + it('it should render the action icon', () => { + const component = new JobComponent({ + propsData: { + job: mockJob, + }, + }).$mount(); + + expect(component.$el.querySelector('a.ci-action-icon-container')).toBeDefined(); + expect(component.$el.querySelector('i.ci-action-icon-wrapper')).toBeDefined(); + }); + }); + + describe('dropdown', () => { + it('should render the dropdown action icon', () => { + const component = new JobComponent({ + propsData: { + job: mockJob, + isDropdown: true, + }, + }).$mount(); + + expect(component.$el.querySelector('a.ci-action-icon-wrapper')).toBeDefined(); + }); + }); + + it('should render provided class name', () => { + const component = new JobComponent({ + propsData: { + job: mockJob, + cssClassJobName: 'css-class-job-name', + }, + }).$mount(); + + expect( + component.$el.querySelector('a').classList.contains('css-class-job-name'), + ).toBe(true); + }); +}); diff --git a/spec/javascripts/pipelines/graph/job_name_component_spec.js b/spec/javascripts/pipelines/graph/job_name_component_spec.js new file mode 100644 index 0000000000000..8e2071ba0b3ed --- /dev/null +++ b/spec/javascripts/pipelines/graph/job_name_component_spec.js @@ -0,0 +1,27 @@ +import Vue from 'vue'; +import jobNameComponent from '~/pipelines/components/graph/job_name_component.vue'; + +describe('job name component', () => { + let component; + + beforeEach(() => { + const JobNameComponent = Vue.extend(jobNameComponent); + component = new JobNameComponent({ + propsData: { + name: 'foo', + status: { + icon: 'icon_status_success', + }, + }, + }).$mount(); + }); + + it('should render the provided name', () => { + expect(component.$el.querySelector('.ci-status-text').textContent.trim()).toEqual('foo'); + }); + + it('should render an icon with the provided status', () => { + expect(component.$el.querySelector('.ci-status-icon-success')).toBeDefined(); + expect(component.$el.querySelector('.ci-status-icon-success svg')).toBeDefined(); + }); +}); diff --git a/spec/javascripts/pipelines/graph/stage_column_component_spec.js b/spec/javascripts/pipelines/graph/stage_column_component_spec.js new file mode 100644 index 0000000000000..aa4d6eedaf422 --- /dev/null +++ b/spec/javascripts/pipelines/graph/stage_column_component_spec.js @@ -0,0 +1,42 @@ +import Vue from 'vue'; +import stageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; + +describe('stage column component', () => { + let component; + const mockJob = { + id: 4256, + name: 'test', + status: { + icon: 'icon_status_success', + text: 'passed', + label: 'passed', + group: 'success', + details_path: '/root/ci-mock/builds/4256', + action: { + icon: 'icon_action_retry', + title: 'Retry', + path: '/root/ci-mock/builds/4256/retry', + method: 'post', + }, + }, + }; + + beforeEach(() => { + const StageColumnComponent = Vue.extend(stageColumnComponent); + + component = new StageColumnComponent({ + propsData: { + title: 'foo', + jobs: [mockJob, mockJob, mockJob], + }, + }).$mount(); + }); + + it('should render provided title', () => { + expect(component.$el.querySelector('.stage-name').textContent.trim()).toEqual('foo'); + }); + + it('should render the provided jobs', () => { + expect(component.$el.querySelectorAll('.builds-container > ul > li').length).toEqual(3); + }); +}); diff --git a/spec/javascripts/pipelines_spec.js b/spec/javascripts/pipelines_spec.js index 72770a702d305..81ac589f4e600 100644 --- a/spec/javascripts/pipelines_spec.js +++ b/spec/javascripts/pipelines_spec.js @@ -1,30 +1,22 @@ -require('~/pipelines'); +import Pipelines from '~/pipelines'; // Fix for phantomJS if (!Element.prototype.matches && Element.prototype.webkitMatchesSelector) { Element.prototype.matches = Element.prototype.webkitMatchesSelector; } -(() => { - describe('Pipelines', () => { - preloadFixtures('static/pipeline_graph.html.raw'); +describe('Pipelines', () => { + preloadFixtures('static/pipeline_graph.html.raw'); - beforeEach(() => { - loadFixtures('static/pipeline_graph.html.raw'); - }); - - it('should be defined', () => { - expect(window.gl.Pipelines).toBeDefined(); - }); - - it('should create a `Pipelines` instance without options', () => { - expect(() => { new window.gl.Pipelines(); }).not.toThrow(); //eslint-disable-line - }); + beforeEach(() => { + loadFixtures('static/pipeline_graph.html.raw'); + }); - it('should create a `Pipelines` instance with options', () => { - const pipelines = new window.gl.Pipelines({ foo: 'bar' }); + it('should be defined', () => { + expect(Pipelines).toBeDefined(); + }); - expect(pipelines.pipelineGraph).toBeDefined(); - }); + it('should create a `Pipelines` instance without options', () => { + expect(() => { new Pipelines(); }).not.toThrow(); //eslint-disable-line }); -})(); +}); diff --git a/spec/javascripts/vue_shared/ci_action_icons_spec.js b/spec/javascripts/vue_shared/ci_action_icons_spec.js new file mode 100644 index 0000000000000..2e89a07e76e83 --- /dev/null +++ b/spec/javascripts/vue_shared/ci_action_icons_spec.js @@ -0,0 +1,22 @@ +import getActionIcon from '~/vue_shared/ci_action_icons'; +import cancelSVG from 'icons/_icon_action_cancel.svg'; +import retrySVG from 'icons/_icon_action_retry.svg'; +import playSVG from 'icons/_icon_action_play.svg'; + +describe('getActionIcon', () => { + it('should return an empty string', () => { + expect(getActionIcon()).toEqual(''); + }); + + it('should return cancel svg', () => { + expect(getActionIcon('icon_action_cancel')).toEqual(cancelSVG); + }); + + it('should return retry svg', () => { + expect(getActionIcon('icon_action_retry')).toEqual(retrySVG); + }); + + it('should return play svg', () => { + expect(getActionIcon('icon_action_play')).toEqual(playSVG); + }); +}); diff --git a/spec/javascripts/vue_shared/ci_status_icon_spec.js b/spec/javascripts/vue_shared/ci_status_icon_spec.js new file mode 100644 index 0000000000000..b6621d6054d0d --- /dev/null +++ b/spec/javascripts/vue_shared/ci_status_icon_spec.js @@ -0,0 +1,27 @@ +import { borderlessStatusIconEntityMap, statusIconEntityMap } from '~/vue_shared/ci_status_icons'; + +describe('CI status icons', () => { + const statuses = [ + 'icon_status_canceled', + 'icon_status_created', + 'icon_status_failed', + 'icon_status_manual', + 'icon_status_pending', + 'icon_status_running', + 'icon_status_skipped', + 'icon_status_success', + 'icon_status_warning', + ]; + + it('should have a dictionary for borderless icons', () => { + statuses.forEach((status) => { + expect(borderlessStatusIconEntityMap[status]).toBeDefined(); + }); + }); + + it('should have a dictionary for icons', () => { + statuses.forEach((status) => { + expect(statusIconEntityMap[status]).toBeDefined(); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/ci_icon_spec.js b/spec/javascripts/vue_shared/components/ci_icon_spec.js new file mode 100644 index 0000000000000..98dc6caa622ba --- /dev/null +++ b/spec/javascripts/vue_shared/components/ci_icon_spec.js @@ -0,0 +1,130 @@ +import Vue from 'vue'; +import ciIcon from '~/vue_shared/components/ci_icon.vue'; + +describe('CI Icon component', () => { + let CiIcon; + beforeEach(() => { + CiIcon = Vue.extend(ciIcon); + }); + + it('should render a span element with an svg', () => { + const component = new CiIcon({ + propsData: { + status: { + icon: 'icon_status_success', + }, + }, + }).$mount(); + + expect(component.$el.tagName).toEqual('SPAN'); + expect(component.$el.querySelector('span > svg')).toBeDefined(); + }); + + it('should render a success status', () => { + const component = new CiIcon({ + propsData: { + status: { + icon: 'icon_status_success', + }, + }, + }).$mount(); + + expect(component.$el.classList.contains('ci-status-icon-success')).toEqual(true); + }); + + it('should render a failed status', () => { + const component = new CiIcon({ + propsData: { + status: { + icon: 'icon_status_failed', + }, + }, + }).$mount(); + + expect(component.$el.classList.contains('ci-status-icon-failed')).toEqual(true); + }); + + it('should render success with warnings status', () => { + const component = new CiIcon({ + propsData: { + status: { + icon: 'icon_status_warning', + }, + }, + }).$mount(); + + expect(component.$el.classList.contains('ci-status-icon-warning')).toEqual(true); + }); + + it('should render pending status', () => { + const component = new CiIcon({ + propsData: { + status: { + icon: 'icon_status_pending', + }, + }, + }).$mount(); + + expect(component.$el.classList.contains('ci-status-icon-pending')).toEqual(true); + }); + + it('should render running status', () => { + const component = new CiIcon({ + propsData: { + status: { + icon: 'icon_status_running', + }, + }, + }).$mount(); + + expect(component.$el.classList.contains('ci-status-icon-running')).toEqual(true); + }); + + it('should render created status', () => { + const component = new CiIcon({ + propsData: { + status: { + icon: 'icon_status_created', + }, + }, + }).$mount(); + + expect(component.$el.classList.contains('ci-status-icon-created')).toEqual(true); + }); + + it('should render skipped status', () => { + const component = new CiIcon({ + propsData: { + status: { + icon: 'icon_status_skipped', + }, + }, + }).$mount(); + + expect(component.$el.classList.contains('ci-status-icon-skipped')).toEqual(true); + }); + + it('should render canceled status', () => { + const component = new CiIcon({ + propsData: { + status: { + icon: 'icon_status_canceled', + }, + }, + }).$mount(); + + expect(component.$el.classList.contains('ci-status-icon-canceled')).toEqual(true); + }); + + it('should render status for manual action', () => { + const component = new CiIcon({ + propsData: { + status: { + icon: 'icon_status_manual', + }, + }, + }).$mount(); + + expect(component.$el.classList.contains('ci-status-icon-manual')).toEqual(true); + }); +}); -- GitLab From 13b31d25ec9c5f8d17f95ca8ad756a88b5e3dbd9 Mon Sep 17 00:00:00 2001 From: Filipa Lacerda <filipa@gitlab.com> Date: Sat, 6 May 2017 21:35:07 +0100 Subject: [PATCH 346/363] Fix broken css class --- app/assets/javascripts/pipelines/components/stage.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue index dc42223269df2..310f44b06df44 100644 --- a/app/assets/javascripts/pipelines/components/stage.vue +++ b/app/assets/javascripts/pipelines/components/stage.vue @@ -14,7 +14,7 @@ */ /* global Flash */ -import { statusCssClasses, borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons'; +import { borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons'; export default { props: { @@ -109,7 +109,7 @@ export default { }, triggerButtonClass() { - return `ci-status-icon-${statusCssClasses[this.stage.status.icon]}`; + return `ci-status-icon-${this.stage.status.group}`; }, svgIcon() { -- GitLab From 8f64091a9550f9073889752bb975d96e074fd734 Mon Sep 17 00:00:00 2001 From: Filipa Lacerda <filipa@gitlab.com> Date: Sat, 6 May 2017 21:55:40 +0100 Subject: [PATCH 347/363] Adds missing CSS class --- .../pipelines/components/graph/action_component.vue | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue index 14e485791ea5f..1f9e3d3977938 100644 --- a/app/assets/javascripts/pipelines/components/graph/action_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -37,6 +37,10 @@ actionIconSvg() { return getActionIcon(this.actionIcon); }, + + cssClass() { + return `js-${gl.text.dasherize(this.actionIcon)}`; + }, }, }; </script> @@ -52,6 +56,7 @@ <i class="ci-action-icon-wrapper" + :class="cssClass" v-html="actionIconSvg" aria-hidden="true" /> -- GitLab From b46d38aef4948c0a428c4334adc8aa221923decd Mon Sep 17 00:00:00 2001 From: Filipa Lacerda <filipa@gitlab.com> Date: Sat, 6 May 2017 22:54:05 +0100 Subject: [PATCH 348/363] Move file loading to the top of the file --- app/views/projects/pipelines/_with_tabs.html.haml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 1c7d1768aa58c..075ddc0025c76 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -1,5 +1,9 @@ - failed_builds = @pipeline.statuses.latest.failed +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('pipelines_graph') + .tabs-holder %ul.pipelines-tabs.nav-links.no-top.no-bottom %li.js-pipeline-tab-link @@ -19,10 +23,6 @@ #js-tab-pipeline.tab-pane #js-pipeline-graph-vue{ data: { endpoint: namespace_project_pipeline_path(@project.namespace, @project, @pipeline, format: :json) } } - - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('common_vue') - = page_specific_javascript_bundle_tag('pipelines_graph') - #js-tab-builds.tab-pane - if pipeline.yaml_errors.present? .bs-callout.bs-callout-danger -- GitLab From c195b313ad2f84e79475058ab431648bed76fcbc Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Sun, 7 May 2017 14:09:21 +0200 Subject: [PATCH 349/363] Make test that actually displays pipeline graph --- .../projects/pipelines/pipelines_spec.rb | 54 +++++++++++++++ .../projects/pipelines/show.html.haml_spec.rb | 67 ------------------- 2 files changed, 54 insertions(+), 67 deletions(-) delete mode 100644 spec/views/projects/pipelines/show.html.haml_spec.rb diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index 2272b19bc8f17..8e820edee35e9 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -370,6 +370,60 @@ end end + describe 'GET /:project/pipelines/show' do + let(:project) { create(:project) } + + let(:pipeline) do + create(:ci_empty_pipeline, + project: project, + sha: project.commit.id, + user: user) + end + + before do + create_build('build', 0, 'build', :success) + create_build('test', 1, 'rspec 0:2', :pending) + create_build('test', 1, 'rspec 1:2', :running) + create_build('test', 1, 'spinach 0:2', :created) + create_build('test', 1, 'spinach 1:2', :created) + create_build('test', 1, 'audit', :created) + create_build('deploy', 2, 'production', :created) + + create(:generic_commit_status, pipeline: pipeline, stage: 'external', name: 'jenkins', stage_idx: 3) + + visit namespace_project_pipeline_path(project.namespace, project, pipeline) + wait_for_vue_resource + end + + it 'shows a graph with grouped stages' do + expect(page).to have_css('.js-pipeline-graph') + # expect(page).to have_css('.js-grouped-pipeline-dropdown') + + # header + expect(page).to have_text("##{pipeline.id}") + expect(page).to have_css('time', text: pipeline.created_at.strftime("%b %d, %Y")) + expect(page).to have_selector(%Q(img[alt$="#{pipeline.user.name}'s avatar"])) + expect(page).to have_link(pipeline.user.name, href: user_path(pipeline.user)) + + # stages + expect(page).to have_text('Build') + expect(page).to have_text('Test') + expect(page).to have_text('Deploy') + expect(page).to have_text('External') + + # builds + expect(page).to have_text('rspec') + expect(page).to have_text('spinach') + expect(page).to have_text('rspec 0:2') + expect(page).to have_text('production') + expect(page).to have_text('jenkins') + end + + def create_build(stage, stage_idx, name, status) + create(:ci_build, pipeline: pipeline, stage: stage, stage_idx: stage_idx, name: name, status: status) + end + end + describe 'POST /:project/pipelines' do let(:project) { create(:project) } diff --git a/spec/views/projects/pipelines/show.html.haml_spec.rb b/spec/views/projects/pipelines/show.html.haml_spec.rb deleted file mode 100644 index bb39ec8efbf4b..0000000000000 --- a/spec/views/projects/pipelines/show.html.haml_spec.rb +++ /dev/null @@ -1,67 +0,0 @@ -require 'spec_helper' - -describe 'projects/pipelines/show' do - include Devise::Test::ControllerHelpers - - let(:user) { create(:user) } - let(:project) { create(:project, :repository) } - - let(:pipeline) do - create(:ci_empty_pipeline, - project: project, - sha: project.commit.id, - user: user) - end - - before do - controller.prepend_view_path('app/views/projects') - - create_build('build', 0, 'build', :success) - create_build('test', 1, 'rspec 0:2', :pending) - create_build('test', 1, 'rspec 1:2', :running) - create_build('test', 1, 'spinach 0:2', :created) - create_build('test', 1, 'spinach 1:2', :created) - create_build('test', 1, 'audit', :created) - create_build('deploy', 2, 'production', :created) - - create(:generic_commit_status, pipeline: pipeline, stage: 'external', name: 'jenkins', stage_idx: 3) - - assign(:project, project) - assign(:pipeline, pipeline.present(current_user: user)) - assign(:commit, project.commit) - - allow(view).to receive(:can?).and_return(true) - end - - it 'shows a graph with grouped stages' do - render - - expect(rendered).to have_css('.js-pipeline-graph') - expect(rendered).to have_css('.js-grouped-pipeline-dropdown') - - # header - expect(rendered).to have_text("##{pipeline.id}") - expect(rendered).to have_css('time', text: pipeline.created_at.strftime("%b %d, %Y")) - expect(rendered).to have_selector(%Q(img[alt$="#{pipeline.user.name}'s avatar"])) - expect(rendered).to have_link(pipeline.user.name, href: user_path(pipeline.user)) - - # stages - expect(rendered).to have_text('Build') - expect(rendered).to have_text('Test') - expect(rendered).to have_text('Deploy') - expect(rendered).to have_text('External') - - # builds - expect(rendered).to have_text('rspec') - expect(rendered).to have_text('spinach') - expect(rendered).to have_text('rspec 0:2') - expect(rendered).to have_text('production') - expect(rendered).to have_text('jenkins') - end - - private - - def create_build(stage, stage_idx, name, status) - create(:ci_build, pipeline: pipeline, stage: stage, stage_idx: stage_idx, name: name, status: status) - end -end -- GitLab From b1f753f7f252945708aa9e7f2f6c0f140ab09ed8 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Sun, 7 May 2017 15:24:15 +0000 Subject: [PATCH 350/363] Correctly stub application settings in signup_spec.rb --- spec/features/signup_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/features/signup_spec.rb b/spec/features/signup_spec.rb index 9fde8d6e5cfb7..d7b6dda4946ea 100644 --- a/spec/features/signup_spec.rb +++ b/spec/features/signup_spec.rb @@ -3,7 +3,7 @@ feature 'Signup', feature: true do describe 'signup with no errors' do context "when sending confirmation email" do - before { allow_any_instance_of(ApplicationSetting).to receive(:send_user_confirmation_email).and_return(true) } + before { stub_application_setting(send_user_confirmation_email: true) } it 'creates the user account and sends a confirmation email' do user = build(:user) @@ -23,7 +23,7 @@ end context "when not sending confirmation email" do - before { allow_any_instance_of(ApplicationSetting).to receive(:send_user_confirmation_email).and_return(false) } + before { stub_application_setting(send_user_confirmation_email: false) } it 'creates the user account and goes to dashboard' do user = build(:user) -- GitLab From e6f008bb84754de3c0d70cce611aa6a30dba0c43 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Sun, 7 May 2017 15:34:27 +0000 Subject: [PATCH 351/363] Cast ENV['RSPEC_PROFILING_POSTGRES_URL'] to symbol in establish_connection call of rspec_profiling.rb --- config/initializers/rspec_profiling.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/rspec_profiling.rb b/config/initializers/rspec_profiling.rb index a7efd74f09e93..b8ad3b1e51c75 100644 --- a/config/initializers/rspec_profiling.rb +++ b/config/initializers/rspec_profiling.rb @@ -1,7 +1,7 @@ module RspecProfilingExt module PSQL def establish_connection - ::RspecProfiling::Collectors::PSQL::Result.establish_connection(ENV['RSPEC_PROFILING_POSTGRES_URL']) + ::RspecProfiling::Collectors::PSQL::Result.establish_connection(ENV['RSPEC_PROFILING_POSTGRES_URL'].to_sym) end end -- GitLab From e0e52fe5825736b60636d13b649e51a5bec0df57 Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" <git@zjvandeweg.nl> Date: Sun, 7 May 2017 18:26:39 +0200 Subject: [PATCH 352/363] Fix test| --- spec/features/projects/pipelines/pipelines_spec.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index 8e820edee35e9..8cc96c7b00f88 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -397,11 +397,9 @@ it 'shows a graph with grouped stages' do expect(page).to have_css('.js-pipeline-graph') - # expect(page).to have_css('.js-grouped-pipeline-dropdown') # header expect(page).to have_text("##{pipeline.id}") - expect(page).to have_css('time', text: pipeline.created_at.strftime("%b %d, %Y")) expect(page).to have_selector(%Q(img[alt$="#{pipeline.user.name}'s avatar"])) expect(page).to have_link(pipeline.user.name, href: user_path(pipeline.user)) @@ -414,7 +412,7 @@ # builds expect(page).to have_text('rspec') expect(page).to have_text('spinach') - expect(page).to have_text('rspec 0:2') + expect(page).to have_text('rspec') expect(page).to have_text('production') expect(page).to have_text('jenkins') end -- GitLab From 34cd10979757cdad26056a532cf09c59a7f74ae9 Mon Sep 17 00:00:00 2001 From: Ahmad Sherif <me@ahmadsherif.com> Date: Fri, 21 Apr 2017 15:02:21 +0200 Subject: [PATCH 353/363] Re-enable Gitaly commit_raw_diff feature --- GITALY_SERVER_VERSION | 2 +- Gemfile | 2 +- Gemfile.lock | 6 ++-- app/models/commit.rb | 15 ++++---- lib/gitlab/gitaly_client/commit.rb | 4 ++- spec/models/commit_spec.rb | 55 +++++++++++++++--------------- 6 files changed, 43 insertions(+), 41 deletions(-) diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index a3df0a6959e15..78bc1abd14f2c 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.8.0 +0.10.0 diff --git a/Gemfile b/Gemfile index 5dbb0bdfddb5d..0cffee6db5623 100644 --- a/Gemfile +++ b/Gemfile @@ -367,6 +367,6 @@ gem 'vmstat', '~> 2.3.0' gem 'sys-filesystem', '~> 1.1.6' # Gitaly GRPC client -gem 'gitaly', '~> 0.5.0' +gem 'gitaly', '~> 0.6.0' gem 'toml-rb', '~> 0.3.15', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 01c35a935f225..c0c56aa960266 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -263,7 +263,7 @@ GEM po_to_json (>= 1.0.0) rails (>= 3.2.0) gherkin-ruby (0.3.2) - gitaly (0.5.0) + gitaly (0.6.0) google-protobuf (~> 3.1) grpc (~> 1.0) github-linguist (4.7.6) @@ -434,7 +434,7 @@ GEM rugged (~> 0.24) little-plugger (1.1.4) locale (2.1.2) - logging (2.1.0) + logging (2.2.2) little-plugger (~> 1.1) multi_json (~> 1.10) loofah (2.0.3) @@ -922,7 +922,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.2.0) - gitaly (~> 0.5.0) + gitaly (~> 0.6.0) github-linguist (~> 4.7.0) gitlab-flowdock-git-hook (~> 1.0.1) gitlab-markup (~> 1.5.1) diff --git a/app/models/commit.rb b/app/models/commit.rb index 88a015cdb77b4..9359b323ed424 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -326,13 +326,14 @@ def uri_type(path) end def raw_diffs(*args) - # NOTE: This feature is intentionally disabled until - # https://gitlab.com/gitlab-org/gitaly/issues/178 is resolved - # if Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs) - # Gitlab::GitalyClient::Commit.diff_from_parent(self, *args) - # else - raw.diffs(*args) - # end + use_gitaly = Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs) + deltas_only = args.last.is_a?(Hash) && args.last[:deltas_only] + + if use_gitaly && !deltas_only + Gitlab::GitalyClient::Commit.diff_from_parent(self, *args) + else + raw.diffs(*args) + end end def diffs(diff_options = nil) diff --git a/lib/gitlab/gitaly_client/commit.rb b/lib/gitlab/gitaly_client/commit.rb index 27db1e19bc125..0b001a9903d56 100644 --- a/lib/gitlab/gitaly_client/commit.rb +++ b/lib/gitlab/gitaly_client/commit.rb @@ -32,7 +32,9 @@ def diff_from_parent(commit, options = {}) request = Gitaly::CommitDiffRequest.new( repository: gitaly_repo, left_commit_id: parent_id, - right_commit_id: commit.id + right_commit_id: commit.id, + ignore_whitespace_change: options.fetch(:ignore_whitespace_change, false), + paths: options.fetch(:paths, []), ) Gitlab::Git::DiffCollection.new(stub.commit_diff(request), options) diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index 08b2169fea76b..852889d4540e4 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -389,32 +389,31 @@ end end - # describe '#raw_diffs' do - # TODO: Uncomment when feature is reenabled - # context 'Gitaly commit_raw_diffs feature enabled' do - # before do - # allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:commit_raw_diffs).and_return(true) - # end - # - # context 'when a truthy deltas_only is not passed to args' do - # it 'fetches diffs from Gitaly server' do - # expect(Gitlab::GitalyClient::Commit).to receive(:diff_from_parent). - # with(commit) - # - # commit.raw_diffs - # end - # end - # - # context 'when a truthy deltas_only is passed to args' do - # it 'fetches diffs using Rugged' do - # opts = { deltas_only: true } - # - # expect(Gitlab::GitalyClient::Commit).not_to receive(:diff_from_parent) - # expect(commit.raw).to receive(:diffs).with(opts) - # - # commit.raw_diffs(opts) - # end - # end - # end - # end + describe '#raw_diffs' do + context 'Gitaly commit_raw_diffs feature enabled' do + before do + allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:commit_raw_diffs).and_return(true) + end + + context 'when a truthy deltas_only is not passed to args' do + it 'fetches diffs from Gitaly server' do + expect(Gitlab::GitalyClient::Commit).to receive(:diff_from_parent). + with(commit) + + commit.raw_diffs + end + end + + context 'when a truthy deltas_only is passed to args' do + it 'fetches diffs using Rugged' do + opts = { deltas_only: true } + + expect(Gitlab::GitalyClient::Commit).not_to receive(:diff_from_parent) + expect(commit.raw).to receive(:diffs).with(opts) + + commit.raw_diffs(opts) + end + end + end + end end -- GitLab From 2f60a402d1fa98bb0090343f8180fc03b715467e Mon Sep 17 00:00:00 2001 From: Ahmad Sherif <me@ahmadsherif.com> Date: Tue, 2 May 2017 13:01:28 +0200 Subject: [PATCH 354/363] Remove stubbing from Gitlab::GitalyClient::Commit specs Closes gitaly#198 --- spec/lib/gitlab/gitaly_client/commit_spec.rb | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/spec/lib/gitlab/gitaly_client/commit_spec.rb b/spec/lib/gitlab/gitaly_client/commit_spec.rb index 58f11ff89063b..abe08ccdfa1f0 100644 --- a/spec/lib/gitlab/gitaly_client/commit_spec.rb +++ b/spec/lib/gitlab/gitaly_client/commit_spec.rb @@ -7,11 +7,6 @@ let(:repository_message) { project.repository.gitaly_repository } let(:commit) { project.commit('913c66a37b4a45b9769037c55c2d238bd0942d2e') } - before do - allow(Gitaly::Diff::Stub).to receive(:new).and_return(diff_stub) - allow(diff_stub).to receive(:commit_diff).and_return([]) - end - context 'when a commit has a parent' do it 'sends an RPC request with the parent ID as left commit' do request = Gitaly::CommitDiffRequest.new( @@ -20,7 +15,7 @@ right_commit_id: commit.id, ) - expect(diff_stub).to receive(:commit_diff).with(request) + expect_any_instance_of(Gitaly::Diff::Stub).to receive(:commit_diff).with(request) described_class.diff_from_parent(commit) end @@ -35,7 +30,7 @@ right_commit_id: initial_commit.id, ) - expect(diff_stub).to receive(:commit_diff).with(request) + expect_any_instance_of(Gitaly::Diff::Stub).to receive(:commit_diff).with(request) described_class.diff_from_parent(initial_commit) end @@ -50,7 +45,7 @@ it 'passes options to Gitlab::Git::DiffCollection' do options = { max_files: 31, max_lines: 13 } - expect(Gitlab::Git::DiffCollection).to receive(:new).with([], options) + expect(Gitlab::Git::DiffCollection).to receive(:new).with(kind_of(Enumerable), options) described_class.diff_from_parent(commit, options) end -- GitLab From e08130223cfc48a6e662a87d32c3026088161de2 Mon Sep 17 00:00:00 2001 From: Stan Hu <stanhu@gmail.com> Date: Sun, 7 May 2017 22:35:20 +0000 Subject: [PATCH 355/363] Revert "Merge branch 'fix-rspec_profiling-establish_connection-string-deprecation' into 'master'" This reverts merge request !11150 --- config/initializers/rspec_profiling.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/rspec_profiling.rb b/config/initializers/rspec_profiling.rb index b8ad3b1e51c75..a7efd74f09e93 100644 --- a/config/initializers/rspec_profiling.rb +++ b/config/initializers/rspec_profiling.rb @@ -1,7 +1,7 @@ module RspecProfilingExt module PSQL def establish_connection - ::RspecProfiling::Collectors::PSQL::Result.establish_connection(ENV['RSPEC_PROFILING_POSTGRES_URL'].to_sym) + ::RspecProfiling::Collectors::PSQL::Result.establish_connection(ENV['RSPEC_PROFILING_POSTGRES_URL']) end end -- GitLab From 8df3997a92bffa2d29f3c559933a336b837cdb93 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg <zegerjan@gitlab.com> Date: Sun, 7 May 2017 22:35:56 +0000 Subject: [PATCH 356/363] Add Pipeline Schedules that supersedes experimental Trigger Schedule --- app/assets/javascripts/gl_field_error.js | 4 +- app/assets/javascripts/gl_field_errors.js | 9 + .../components/interval_pattern_input.js | 143 ++++++++++++ .../components/pipeline_schedules_callout.js | 44 ++++ .../components/target_branch_dropdown.js | 42 ++++ .../components/timezone_dropdown.js | 56 +++++ .../icons/intro_illustration.svg | 1 + .../pipeline_schedule_form_bundle.js | 21 ++ .../pipeline_schedules_index_bundle.js | 9 + .../stylesheets/pages/pipeline_schedules.scss | 71 ++++++ .../projects/pipeline_schedules_controller.rb | 68 ++++++ app/finders/pipeline_schedules_finder.rb | 22 ++ app/helpers/gitlab_routing_helper.rb | 20 ++ app/helpers/pipeline_schedules_helper.rb | 11 + app/models/ci/pipeline.rb | 1 + ...igger_schedule.rb => pipeline_schedule.rb} | 21 +- app/models/ci/trigger.rb | 7 - app/models/project.rb | 1 + app/policies/ci/pipeline_schedule_policy.rb | 4 + app/policies/project_policy.rb | 8 + .../ci/create_pipeline_schedule_service.rb | 13 ++ app/services/ci/create_pipeline_service.rb | 5 +- .../ci/create_trigger_request_service.rb | 5 +- .../pipeline_schedules/_form.html.haml | 32 +++ .../_pipeline_schedule.html.haml | 35 +++ .../pipeline_schedules/_table.html.haml | 12 + .../pipeline_schedules/_tabs.html.haml | 18 ++ .../pipeline_schedules/edit.html.haml | 7 + .../pipeline_schedules/index.html.haml | 24 ++ .../projects/pipeline_schedules/new.html.haml | 7 + app/views/projects/pipelines/_head.html.haml | 6 + app/views/projects/triggers/_form.html.haml | 22 -- app/views/projects/triggers/_index.html.haml | 2 - .../projects/triggers/_trigger.html.haml | 6 - app/workers/pipeline_schedule_worker.rb | 19 ++ app/workers/trigger_schedule_worker.rb | 18 -- .../zj-better-view-pipeline-schedule.yml | 4 + config/gitlab.yml.example | 2 +- config/initializers/1_settings.rb | 6 +- config/routes/project.rb | 6 + config/webpack.config.js | 2 + ...5112128_create_pipeline_schedules_table.rb | 28 +++ ...remove_foreigh_key_ci_trigger_schedules.rb | 13 ++ ...1_add_pipeline_schedule_id_to_pipelines.rb | 9 + ..._index_to_pipeline_pipeline_schedule_id.rb | 19 ++ ...4_add_foreign_key_to_pipeline_schedules.rb | 15 ++ ...gn_key_pipeline_schedules_and_pipelines.rb | 23 ++ ...trigger_schedules_to_pipeline_schedules.rb | 41 ++++ ...5130047_drop_ci_trigger_schedules_table.rb | 32 +++ db/schema.rb | 55 ++--- doc/ci/img/pipeline_schedules_list.png | Bin 0 -> 67555 bytes doc/ci/img/pipeline_schedules_new_form.png | Bin 0 -> 49873 bytes doc/ci/pipeline_schedules.md | 40 ++++ doc/ci/triggers/README.md | 38 --- .../triggers/img/trigger_schedule_create.png | Bin 34264 -> 0 bytes doc/ci/triggers/img/trigger_schedule_edit.png | Bin 18524 -> 0 bytes .../trigger_schedule_updated_next_run_at.png | Bin 21896 -> 0 bytes doc/user/project/settings/import_export.md | 3 +- lib/gitlab/import_export.rb | 2 +- lib/gitlab/import_export/import_export.yml | 6 +- lib/gitlab/import_export/relation_factory.rb | 2 +- lib/gitlab/usage_data.rb | 1 + .../pipeline_schedules_controller_spec.rb | 87 +++++++ ...gger_schedules.rb => pipeline_schedule.rb} | 13 +- .../import_export/test_project_export.tar.gz | Bin 679892 -> 681478 bytes .../projects/pipeline_schedules_spec.rb | 140 +++++++++++ .../security/project/internal_access_spec.rb | 16 +- .../security/project/private_access_spec.rb | 44 +++- .../security/project/public_access_spec.rb | 16 +- spec/features/triggers_spec.rb | 71 ------ .../finders/pipeline_schedules_finder_spec.rb | 41 ++++ .../interval_pattern_input_spec.js | 217 ++++++++++++++++++ .../pipeline_schedule_callout_spec.js | 91 ++++++++ spec/lib/gitlab/import_export/all_models.yml | 13 +- .../import_export/safe_model_attributes.yml | 16 +- spec/lib/gitlab/usage_data_spec.rb | 1 + spec/models/ci/pipeline_schedule_spec.rb | 112 +++++++++ spec/models/ci/pipeline_spec.rb | 1 + spec/models/ci/trigger_schedule_spec.rb | 108 --------- spec/models/ci/trigger_spec.rb | 1 - spec/models/project_spec.rb | 1 + spec/workers/pipeline_schedule_worker_spec.rb | 51 ++++ spec/workers/trigger_schedule_worker_spec.rb | 73 ------ 83 files changed, 1841 insertions(+), 413 deletions(-) create mode 100644 app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.js create mode 100644 app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js create mode 100644 app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js create mode 100644 app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js create mode 100644 app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg create mode 100644 app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js create mode 100644 app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js create mode 100644 app/assets/stylesheets/pages/pipeline_schedules.scss create mode 100644 app/controllers/projects/pipeline_schedules_controller.rb create mode 100644 app/finders/pipeline_schedules_finder.rb create mode 100644 app/helpers/pipeline_schedules_helper.rb rename app/models/ci/{trigger_schedule.rb => pipeline_schedule.rb} (66%) create mode 100644 app/policies/ci/pipeline_schedule_policy.rb create mode 100644 app/services/ci/create_pipeline_schedule_service.rb create mode 100644 app/views/projects/pipeline_schedules/_form.html.haml create mode 100644 app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml create mode 100644 app/views/projects/pipeline_schedules/_table.html.haml create mode 100644 app/views/projects/pipeline_schedules/_tabs.html.haml create mode 100644 app/views/projects/pipeline_schedules/edit.html.haml create mode 100644 app/views/projects/pipeline_schedules/index.html.haml create mode 100644 app/views/projects/pipeline_schedules/new.html.haml create mode 100644 app/workers/pipeline_schedule_worker.rb delete mode 100644 app/workers/trigger_schedule_worker.rb create mode 100644 changelogs/unreleased/zj-better-view-pipeline-schedule.yml create mode 100644 db/migrate/20170425112128_create_pipeline_schedules_table.rb create mode 100644 db/migrate/20170425112628_remove_foreigh_key_ci_trigger_schedules.rb create mode 100644 db/migrate/20170425114731_add_pipeline_schedule_id_to_pipelines.rb create mode 100644 db/migrate/20170506085040_add_index_to_pipeline_pipeline_schedule_id.rb create mode 100644 db/migrate/20170506091344_add_foreign_key_to_pipeline_schedules.rb create mode 100644 db/migrate/20170506185517_add_foreign_key_pipeline_schedules_and_pipelines.rb create mode 100644 db/post_migrate/20170425121605_migrate_trigger_schedules_to_pipeline_schedules.rb create mode 100644 db/post_migrate/20170425130047_drop_ci_trigger_schedules_table.rb create mode 100644 doc/ci/img/pipeline_schedules_list.png create mode 100644 doc/ci/img/pipeline_schedules_new_form.png create mode 100644 doc/ci/pipeline_schedules.md delete mode 100644 doc/ci/triggers/img/trigger_schedule_create.png delete mode 100644 doc/ci/triggers/img/trigger_schedule_edit.png delete mode 100644 doc/ci/triggers/img/trigger_schedule_updated_next_run_at.png create mode 100644 spec/controllers/projects/pipeline_schedules_controller_spec.rb rename spec/factories/ci/{trigger_schedules.rb => pipeline_schedule.rb} (70%) create mode 100644 spec/features/projects/pipeline_schedules_spec.rb create mode 100644 spec/finders/pipeline_schedules_finder_spec.rb create mode 100644 spec/javascripts/pipeline_schedules/interval_pattern_input_spec.js create mode 100644 spec/javascripts/pipeline_schedules/pipeline_schedule_callout_spec.js create mode 100644 spec/models/ci/pipeline_schedule_spec.rb delete mode 100644 spec/models/ci/trigger_schedule_spec.rb create mode 100644 spec/workers/pipeline_schedule_worker_spec.rb delete mode 100644 spec/workers/trigger_schedule_worker_spec.rb diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js index 76de249ac3bb0..0add707525498 100644 --- a/app/assets/javascripts/gl_field_error.js +++ b/app/assets/javascripts/gl_field_error.js @@ -65,6 +65,7 @@ class GlFieldError { this.state = { valid: false, empty: true, + submitted: false, }; this.initFieldValidation(); @@ -108,9 +109,10 @@ class GlFieldError { const currentValue = this.accessCurrentValue(); this.state.valid = false; this.state.empty = currentValue === ''; - + this.state.submitted = true; this.renderValidity(); this.form.focusOnFirstInvalid.apply(this.form); + // For UX, wait til after first invalid submission to check each keyup this.inputElement.off('keyup.fieldValidator') .on('keyup.fieldValidator', this.updateValidity.bind(this)); diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js index 636258ec55565..ca3cec07a88cb 100644 --- a/app/assets/javascripts/gl_field_errors.js +++ b/app/assets/javascripts/gl_field_errors.js @@ -37,6 +37,15 @@ class GlFieldErrors { } } + /* Public method for triggering validity updates manually */ + updateFormValidityState() { + this.state.inputs.forEach((field) => { + if (field.state.submitted) { + field.updateValidity(); + } + }); + } + focusOnFirstInvalid () { const firstInvalid = this.state.inputs.filter((input) => !input.inputDomElement.validity.valid)[0]; firstInvalid.inputElement.focus(); diff --git a/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.js b/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.js new file mode 100644 index 0000000000000..152e75b747e4b --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.js @@ -0,0 +1,143 @@ +import Vue from 'vue'; + +const inputNameAttribute = 'schedule[cron]'; + +export default { + props: { + initialCronInterval: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + inputNameAttribute, + cronInterval: this.initialCronInterval, + cronIntervalPresets: { + everyDay: '0 4 * * *', + everyWeek: '0 4 * * 0', + everyMonth: '0 4 1 * *', + }, + cronSyntaxUrl: 'https://en.wikipedia.org/wiki/Cron', + customInputEnabled: false, + }; + }, + computed: { + showUnsetWarning() { + return this.cronInterval === ''; + }, + intervalIsPreset() { + return _.contains(this.cronIntervalPresets, this.cronInterval); + }, + // The text input is editable when there's a custom interval, or when it's + // a preset interval and the user clicks the 'custom' radio button + isEditable() { + return !!(this.customInputEnabled || !this.intervalIsPreset); + }, + }, + methods: { + toggleCustomInput(shouldEnable) { + this.customInputEnabled = shouldEnable; + + if (shouldEnable) { + // We need to change the value so other radios don't remain selected + // because the model (cronInterval) hasn't changed. The server trims it. + this.cronInterval = `${this.cronInterval} `; + } + }, + }, + created() { + if (this.intervalIsPreset) { + this.enableCustomInput = false; + } + }, + watch: { + cronInterval() { + // updates field validation state when model changes, as + // glFieldError only updates on input. + Vue.nextTick(() => { + gl.pipelineScheduleFieldErrors.updateFormValidityState(); + }); + }, + }, + template: ` + <div class="interval-pattern-form-group"> + <input + id="custom" + class="label-light" + type="radio" + :name="inputNameAttribute" + :value="cronInterval" + :checked="isEditable" + @click="toggleCustomInput(true)" + /> + + <label for="custom"> + Custom + </label> + + <span class="cron-syntax-link-wrap"> + (<a :href="cronSyntaxUrl" target="_blank">Cron syntax</a>) + </span> + + <input + id="every-day" + class="label-light" + type="radio" + v-model="cronInterval" + :name="inputNameAttribute" + :value="cronIntervalPresets.everyDay" + @click="toggleCustomInput(false)" + /> + + <label class="label-light" for="every-day"> + Every day (at 4:00am) + </label> + + <input + id="every-week" + class="label-light" + type="radio" + v-model="cronInterval" + :name="inputNameAttribute" + :value="cronIntervalPresets.everyWeek" + @click="toggleCustomInput(false)" + /> + + <label class="label-light" for="every-week"> + Every week (Sundays at 4:00am) + </label> + + <input + id="every-month" + class="label-light" + type="radio" + v-model="cronInterval" + :name="inputNameAttribute" + :value="cronIntervalPresets.everyMonth" + @click="toggleCustomInput(false)" + /> + + <label class="label-light" for="every-month"> + Every month (on the 1st at 4:00am) + </label> + + <div class="cron-interval-input-wrapper col-md-6"> + <input + id="schedule_cron" + class="form-control inline cron-interval-input" + type="text" + placeholder="Define a custom pattern with cron syntax" + required="true" + v-model="cronInterval" + :name="inputNameAttribute" + :disabled="!isEditable" + /> + </div> + <span class="cron-unset-status col-md-3" v-if="showUnsetWarning"> + Schedule not yet set + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js b/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js new file mode 100644 index 0000000000000..27ffe6ea3046a --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js @@ -0,0 +1,44 @@ +import Cookies from 'js-cookie'; +import illustrationSvg from '../icons/intro_illustration.svg'; + +const cookieKey = 'pipeline_schedules_callout_dismissed'; + +export default { + data() { + return { + illustrationSvg, + calloutDismissed: Cookies.get(cookieKey) === 'true', + }; + }, + methods: { + dismissCallout() { + this.calloutDismissed = true; + Cookies.set(cookieKey, this.calloutDismissed, { expires: 365 }); + }, + }, + template: ` + <div v-if="!calloutDismissed" class="pipeline-schedules-user-callout user-callout"> + <div class="bordered-box landing content-block"> + <button + id="dismiss-callout-btn" + class="btn btn-default close" + @click="dismissCallout"> + <i class="fa fa-times"></i> + </button> + <div class="svg-container" v-html="illustrationSvg"></div> + <div class="user-callout-copy"> + <h4>Scheduling Pipelines</h4> + <p> + The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. + Those scheduled pipelines will inherit limited project access based on their associated user. + </p> + <p> Learn more in the + <!-- FIXME --> + <a href="random.com">pipeline schedules documentation</a>. + </p> + </div> + </div> + </div> + `, +}; + diff --git a/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js b/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js new file mode 100644 index 0000000000000..22e746ad2c328 --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js @@ -0,0 +1,42 @@ +export default class TargetBranchDropdown { + constructor() { + this.$dropdown = $('.js-target-branch-dropdown'); + this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text'); + this.$input = $('#schedule_ref'); + this.initialValue = this.$input.val(); + this.initDropdown(); + } + + initDropdown() { + this.$dropdown.glDropdown({ + data: this.formatBranchesList(), + filterable: true, + selectable: true, + toggleLabel: item => item.name, + search: { + fields: ['name'], + }, + clicked: cfg => this.updateInputValue(cfg), + text: item => item.name, + }); + + this.setDropdownToggle(); + } + + formatBranchesList() { + return this.$dropdown.data('data') + .map(val => ({ name: val })); + } + + setDropdownToggle() { + if (this.initialValue) { + this.$dropdownToggle.text(this.initialValue); + } + } + + updateInputValue({ selectedObj, e }) { + e.preventDefault(); + this.$input.val(selectedObj.name); + gl.pipelineScheduleFieldErrors.updateFormValidityState(); + } +} diff --git a/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js b/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js new file mode 100644 index 0000000000000..c70e0502cf8d5 --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js @@ -0,0 +1,56 @@ +/* eslint-disable class-methods-use-this */ + +export default class TimezoneDropdown { + constructor() { + this.$dropdown = $('.js-timezone-dropdown'); + this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text'); + this.$input = $('#schedule_cron_timezone'); + this.timezoneData = this.$dropdown.data('data'); + this.initialValue = this.$input.val(); + this.initDropdown(); + } + + initDropdown() { + this.$dropdown.glDropdown({ + data: this.timezoneData, + filterable: true, + selectable: true, + toggleLabel: item => item.name, + search: { + fields: ['name'], + }, + clicked: cfg => this.updateInputValue(cfg), + text: item => this.formatTimezone(item), + }); + + this.setDropdownToggle(); + } + + formatUtcOffset(offset) { + let prefix = ''; + + if (offset > 0) { + prefix = '+'; + } else if (offset < 0) { + prefix = '-'; + } + + return `${prefix} ${Math.abs(offset / 3600)}`; + } + + formatTimezone(item) { + return `[UTC ${this.formatUtcOffset(item.offset)}] ${item.name}`; + } + + setDropdownToggle() { + if (this.initialValue) { + this.$dropdownToggle.text(this.initialValue); + } + } + + updateInputValue({ selectedObj, e }) { + e.preventDefault(); + this.$input.val(selectedObj.identifier); + gl.pipelineScheduleFieldErrors.updateFormValidityState(); + } +} diff --git a/app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg b/app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg new file mode 100644 index 0000000000000..26d1ff97b3e43 --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg @@ -0,0 +1 @@ +<svg width="140" height="102" viewBox="0 0 140 102" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>illustration</title><defs><rect id="a" width="12.033" height="40.197" rx="3"/><rect id="b" width="12.033" height="40.197" rx="3"/></defs><g fill="none" fill-rule="evenodd"><g transform="translate(-.446)"><path d="M91.747 35.675v-6.039a2.996 2.996 0 0 0-2.999-3.005H54.635a2.997 2.997 0 0 0-2.999 3.005v6.039H40.092a3.007 3.007 0 0 0-2.996 3.005v34.187a2.995 2.995 0 0 0 2.996 3.005h11.544V79.9a2.996 2.996 0 0 0 2.999 3.005h34.113a2.997 2.997 0 0 0 2.999-3.005v-4.03h11.544a3.007 3.007 0 0 0 2.996-3.004V38.68a2.995 2.995 0 0 0-2.996-3.005H91.747z" stroke="#B5A7DD" stroke-width="2"/><rect stroke="#E5E5E5" stroke-width="2" fill="#FFF" x="21.556" y="38.69" width="98.27" height="34.167" rx="3"/><path d="M121.325 38.19c.55 0 .995.444.995 1.002 0 .554-.453 1.003-.995 1.003h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.454 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.453 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zM121.325 71.854a1.004 1.004 0 0 1 0 2.006h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038z" fill="#E5E5E5"/><g transform="translate(110.3 35.675)"><use fill="#FFF" xlink:href="#a"/><rect stroke="#FDE5D8" stroke-width="2" x="1" y="1" width="10.033" height="38.197" rx="3"/><ellipse fill="#FC8A51" cx="6.017" cy="9.547" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="20.099" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="30.65" rx="1.504" ry="1.507"/></g><path d="M6.008 38.19c.55 0 .996.444.996 1.002 0 .554-.454 1.003-.996 1.003H1.97a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.453 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.045 0c.55 0 .995.444.995 1.002 0 .554-.453 1.003-.995 1.003h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zM6.008 71.854a1.004 1.004 0 0 1 0 2.006H1.97a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.045 0a1.004 1.004 0 0 1 0 2.006h-4.039a1.004 1.004 0 0 1 0-2.006h4.039z" fill="#E5E5E5"/><g transform="translate(19.05 35.675)"><use fill="#FFF" xlink:href="#b"/><rect stroke="#FDE5D8" stroke-width="2" x="1" y="1" width="10.033" height="38.197" rx="3"/><ellipse fill="#FC8A51" cx="6.017" cy="10.049" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="20.601" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="31.153" rx="1.504" ry="1.507"/></g><g transform="translate(47.096)"><g transform="translate(7.05)"><ellipse fill="#FC8A51" cx="17.548" cy="5.025" rx="4.512" ry="4.522"/><rect stroke="#B5A7DD" stroke-width="2" fill="#FFF" x="13.036" y="4.02" width="9.025" height="20.099" rx="1.5"/><rect stroke="#FDE5D8" stroke-width="2" fill="#FFF" y="4.02" width="35.096" height="4.02" rx="2.01"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="4.512" y="18.089" width="26.072" height="17.084" rx="1.5"/></g><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(-45 43.117 35.117)" x="38.168" y="31.416" width="9.899" height="7.403" rx="3.702"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25" cy="55" rx="25" ry="25"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25" cy="55" rx="21" ry="21"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="43.05" y="53.281" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="4.305" y="53.281" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(90 25.153 74.422)" x="23.677" y="73.653" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(90 25.153 35.51)" x="23.844" y="34.742" width="2.616" height="1.538" rx=".769"/><path d="M13.362 42.502c-.124-.543.198-.854.74-.69l2.321.704c.533.161.643.592.235.972l-.22.206 7.06 7.572a1.002 1.002 0 1 1-1.467 1.368l-7.06-7.573-.118.11c-.402.375-.826.248-.952-.304l-.54-2.365zM21.606 67.576c-.408.38-.84.255-.968-.295l-.551-2.363c-.127-.542.191-.852.725-.69l.288.089 3.027-9.901a1.002 1.002 0 1 1 1.918.586l-3.027 9.901.154.047c.525.16.627.592.213.977l-1.779 1.65z" fill="#FC8A51"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25.099" cy="54.768" rx="2.507" ry="2.512"/></g></g><path d="M52.697 96.966a1.004 1.004 0 0 1 2.006 0v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zm0-9.044a1.004 1.004 0 0 1 2.006 0v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zM86.29 96.966c0-.55.444-.996 1.002-.996.554 0 1.003.454 1.003.996v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zm0-9.044c0-.55.444-.996 1.002-.996.554 0 1.003.453 1.003.996v4.038a1.004 1.004 0 0 1-2.006 0v-4.038z" fill="#E5E5E5"/></g></svg> \ No newline at end of file diff --git a/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js b/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js new file mode 100644 index 0000000000000..c60e77deccedf --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import IntervalPatternInput from './components/interval_pattern_input'; +import TimezoneDropdown from './components/timezone_dropdown'; +import TargetBranchDropdown from './components/target_branch_dropdown'; + +document.addEventListener('DOMContentLoaded', () => { + const IntervalPatternInputComponent = Vue.extend(IntervalPatternInput); + const intervalPatternMount = document.getElementById('interval-pattern-input'); + const initialCronInterval = intervalPatternMount ? intervalPatternMount.dataset.initialInterval : ''; + + new IntervalPatternInputComponent({ + propsData: { + initialCronInterval, + }, + }).$mount(intervalPatternMount); + + const formElement = document.getElementById('new-pipeline-schedule-form'); + gl.timezoneDropdown = new TimezoneDropdown(); + gl.targetBranchDropdown = new TargetBranchDropdown(); + gl.pipelineScheduleFieldErrors = new gl.GlFieldErrors(formElement); +}); diff --git a/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js b/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js new file mode 100644 index 0000000000000..e36dc5db2abf2 --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js @@ -0,0 +1,9 @@ +import Vue from 'vue'; +import PipelineSchedulesCallout from './components/pipeline_schedules_callout'; + +const PipelineSchedulesCalloutComponent = Vue.extend(PipelineSchedulesCallout); + +document.addEventListener('DOMContentLoaded', () => { + new PipelineSchedulesCalloutComponent() + .$mount('#scheduling-pipelines-callout'); +}); diff --git a/app/assets/stylesheets/pages/pipeline_schedules.scss b/app/assets/stylesheets/pages/pipeline_schedules.scss new file mode 100644 index 0000000000000..0fee54a0d195c --- /dev/null +++ b/app/assets/stylesheets/pages/pipeline_schedules.scss @@ -0,0 +1,71 @@ +.js-pipeline-schedule-form { + .dropdown-select, + .dropdown-menu-toggle { + width: 100%!important; + } + + .gl-field-error { + margin: 10px 0 0; + } +} + +.interval-pattern-form-group { + label { + margin-right: 10px; + font-size: 12px; + + &[for='custom'] { + margin-right: 0; + } + } + + .cron-interval-input-wrapper { + padding-left: 0; + } + + .cron-interval-input { + margin: 10px 10px 0 0; + } + + .cron-syntax-link-wrap { + margin-right: 10px; + font-size: 12px; + } + + .cron-unset-status { + padding-top: 16px; + margin-left: -16px; + color: $gl-text-color-secondary; + font-size: 12px; + font-weight: 600; + } +} + +.pipeline-schedule-table-row { + .branch-name-cell { + max-width: 300px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .next-run-cell { + color: $gl-text-color-secondary; + } + + a { + color: $text-color; + } +} + +.pipeline-schedules-user-callout { + .bordered-box.content-block { + border: 1px solid $border-color; + background-color: transparent; + padding: 16px; + } + + #dismiss-callout-btn { + color: $gl-text-color; + } +} diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb new file mode 100644 index 0000000000000..1616b2cb6b81c --- /dev/null +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -0,0 +1,68 @@ +class Projects::PipelineSchedulesController < Projects::ApplicationController + before_action :authorize_read_pipeline_schedule! + before_action :authorize_create_pipeline_schedule!, only: [:new, :create, :edit, :take_ownership, :update] + before_action :authorize_admin_pipeline_schedule!, only: [:destroy] + + before_action :schedule, only: [:edit, :update, :destroy, :take_ownership] + + def index + @scope = params[:scope] + @all_schedules = PipelineSchedulesFinder.new(@project).execute + @schedules = PipelineSchedulesFinder.new(@project).execute(scope: params[:scope]) + .includes(:last_pipeline) + end + + def new + @schedule = project.pipeline_schedules.new + end + + def create + @schedule = Ci::CreatePipelineScheduleService + .new(@project, current_user, schedule_params) + .execute + + if @schedule.persisted? + redirect_to pipeline_schedules_path(@project) + else + render :new + end + end + + def edit + end + + def update + if schedule.update(schedule_params) + redirect_to namespace_project_pipeline_schedules_path(@project.namespace.becomes(Namespace), @project) + else + render :edit + end + end + + def take_ownership + if schedule.update(owner: current_user) + redirect_to pipeline_schedules_path(@project) + else + redirect_to pipeline_schedules_path(@project), alert: "Failed to change the owner" + end + end + + def destroy + if schedule.destroy + redirect_to pipeline_schedules_path(@project) + else + redirect_to pipeline_schedules_path(@project), alert: "Failed to remove the pipeline schedule" + end + end + + private + + def schedule + @schedule ||= project.pipeline_schedules.find(params[:id]) + end + + def schedule_params + params.require(:schedule) + .permit(:description, :cron, :cron_timezone, :ref, :active) + end +end diff --git a/app/finders/pipeline_schedules_finder.rb b/app/finders/pipeline_schedules_finder.rb new file mode 100644 index 0000000000000..2ac4289fbbef5 --- /dev/null +++ b/app/finders/pipeline_schedules_finder.rb @@ -0,0 +1,22 @@ +class PipelineSchedulesFinder + attr_reader :project, :pipeline_schedules + + def initialize(project) + @project = project + @pipeline_schedules = project.pipeline_schedules + end + + def execute(scope: nil) + scoped_schedules = + case scope + when 'active' + pipeline_schedules.active + when 'inactive' + pipeline_schedules.inactive + else + pipeline_schedules + end + + scoped_schedules.order(id: :desc) + end +end diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index 3769830de2a1f..bcf71bc347b53 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -221,6 +221,26 @@ def artifacts_action_path(path, project, build) end end + # Pipeline Schedules + def pipeline_schedules_path(project, *args) + namespace_project_pipeline_schedules_path(project.namespace, project, *args) + end + + def pipeline_schedule_path(schedule, *args) + project = schedule.project + namespace_project_pipeline_schedule_path(project.namespace, project, schedule, *args) + end + + def edit_pipeline_schedule_path(schedule) + project = schedule.project + edit_namespace_project_pipeline_schedule_path(project.namespace, project, schedule) + end + + def take_ownership_pipeline_schedule_path(schedule, *args) + project = schedule.project + take_ownership_namespace_project_pipeline_schedule_path(project.namespace, project, schedule, *args) + end + # Settings def project_settings_integrations_path(project, *args) namespace_project_settings_integrations_path(project.namespace, project, *args) diff --git a/app/helpers/pipeline_schedules_helper.rb b/app/helpers/pipeline_schedules_helper.rb new file mode 100644 index 0000000000000..fee1edc2a1bba --- /dev/null +++ b/app/helpers/pipeline_schedules_helper.rb @@ -0,0 +1,11 @@ +module PipelineSchedulesHelper + def timezone_data + ActiveSupport::TimeZone.all.map do |timezone| + { + name: timezone.name, + offset: timezone.utc_offset, + identifier: timezone.tzinfo.identifier + } + end + end +end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 4be4aa9ffe2c2..db994b861e59d 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -9,6 +9,7 @@ class Pipeline < ActiveRecord::Base belongs_to :project belongs_to :user belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline' + belongs_to :pipeline_schedule, class_name: 'Ci::PipelineSchedule' has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id' has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id' diff --git a/app/models/ci/trigger_schedule.rb b/app/models/ci/pipeline_schedule.rb similarity index 66% rename from app/models/ci/trigger_schedule.rb rename to app/models/ci/pipeline_schedule.rb index 012a18eb43903..6d7cc83971e00 100644 --- a/app/models/ci/trigger_schedule.rb +++ b/app/models/ci/pipeline_schedule.rb @@ -1,24 +1,35 @@ module Ci - class TriggerSchedule < ActiveRecord::Base + class PipelineSchedule < ActiveRecord::Base extend Ci::Model include Importable acts_as_paranoid belongs_to :project - belongs_to :trigger + belongs_to :owner, class_name: 'User' + has_one :last_pipeline, -> { order(id: :desc) }, class_name: 'Ci::Pipeline' + has_many :pipelines - validates :trigger, presence: { unless: :importing? } validates :cron, unless: :importing_or_inactive?, cron: true, presence: { unless: :importing_or_inactive? } validates :cron_timezone, cron_timezone: true, presence: { unless: :importing_or_inactive? } validates :ref, presence: { unless: :importing_or_inactive? } + validates :description, presence: true before_save :set_next_run_at scope :active, -> { where(active: true) } + scope :inactive, -> { where(active: false) } + + def owned_by?(current_user) + owner == current_user + end + + def inactive? + !active? + end def importing_or_inactive? - importing? || !active? + importing? || inactive? end def set_next_run_at @@ -32,7 +43,7 @@ def schedule_next_run! end def real_next_run( - worker_cron: Settings.cron_jobs['trigger_schedule_worker']['cron'], + worker_cron: Settings.cron_jobs['pipeline_schedule_worker']['cron'], worker_time_zone: Time.zone.name) Gitlab::Ci::CronParser.new(worker_cron, worker_time_zone) .next_time_from(next_run_at) diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 2f64f70685a86..6df41a3f301e2 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -8,14 +8,11 @@ class Trigger < ActiveRecord::Base belongs_to :owner, class_name: "User" has_many :trigger_requests - has_one :trigger_schedule, dependent: :destroy validates :token, presence: true, uniqueness: true before_validation :set_default_values - accepts_nested_attributes_for :trigger_schedule - def set_default_values self.token = SecureRandom.hex(15) if self.token.blank? end @@ -39,9 +36,5 @@ def legacy? def can_access_project? self.owner_id.blank? || Ability.allowed?(self.owner, :create_build, project) end - - def trigger_schedule - super || build_trigger_schedule(project: project) - end end end diff --git a/app/models/project.rb b/app/models/project.rb index edbca3b537b33..a0413b4e6515a 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -178,6 +178,7 @@ def update_forks_visibility_level has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger' has_many :environments, dependent: :destroy has_many :deployments, dependent: :destroy + has_many :pipeline_schedules, dependent: :destroy, class_name: 'Ci::PipelineSchedule' has_many :active_runners, -> { active }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' diff --git a/app/policies/ci/pipeline_schedule_policy.rb b/app/policies/ci/pipeline_schedule_policy.rb new file mode 100644 index 0000000000000..1877e89bb23f1 --- /dev/null +++ b/app/policies/ci/pipeline_schedule_policy.rb @@ -0,0 +1,4 @@ +module Ci + class PipelineSchedulePolicy < PipelinePolicy + end +end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 5baac9ebe4b25..8f25ac30a2288 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -46,6 +46,7 @@ def guest_access! if project.public_builds? can! :read_pipeline + can! :read_pipeline_schedule can! :read_build end end @@ -63,6 +64,7 @@ def reporter_access! can! :read_build can! :read_container_image can! :read_pipeline + can! :read_pipeline_schedule can! :read_environment can! :read_deployment can! :read_merge_request @@ -83,6 +85,8 @@ def developer_access! can! :update_build can! :create_pipeline can! :update_pipeline + can! :create_pipeline_schedule + can! :update_pipeline_schedule can! :create_merge_request can! :create_wiki can! :push_code @@ -108,6 +112,7 @@ def master_access! can! :admin_build can! :admin_container_image can! :admin_pipeline + can! :admin_pipeline_schedule can! :admin_environment can! :admin_deployment can! :admin_pages @@ -120,6 +125,7 @@ def public_access! can! :fork_project can! :read_commit_status can! :read_pipeline + can! :read_pipeline_schedule can! :read_container_image can! :build_download_code can! :build_read_container_image @@ -198,6 +204,7 @@ def disabled_features! unless project.feature_available?(:builds, user) && repository_enabled cannot!(*named_abilities(:build)) cannot!(*named_abilities(:pipeline)) + cannot!(*named_abilities(:pipeline_schedule)) cannot!(*named_abilities(:environment)) cannot!(*named_abilities(:deployment)) end @@ -277,6 +284,7 @@ def base_readonly_access! can! :read_merge_request can! :read_note can! :read_pipeline + can! :read_pipeline_schedule can! :read_commit_status can! :read_container_image can! :download_code diff --git a/app/services/ci/create_pipeline_schedule_service.rb b/app/services/ci/create_pipeline_schedule_service.rb new file mode 100644 index 0000000000000..cd40deb61871b --- /dev/null +++ b/app/services/ci/create_pipeline_schedule_service.rb @@ -0,0 +1,13 @@ +module Ci + class CreatePipelineScheduleService < BaseService + def execute + project.pipeline_schedules.create(pipeline_schedule_params) + end + + private + + def pipeline_schedule_params + params.merge(owner: current_user) + end + end +end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 21350be555714..ccdda08d8859d 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -2,7 +2,7 @@ module Ci class CreatePipelineService < BaseService attr_reader :pipeline - def execute(ignore_skip_ci: false, save_on_errors: true, trigger_request: nil) + def execute(ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil) @pipeline = Ci::Pipeline.new( project: project, ref: ref, @@ -10,7 +10,8 @@ def execute(ignore_skip_ci: false, save_on_errors: true, trigger_request: nil) before_sha: before_sha, tag: tag?, trigger_requests: Array(trigger_request), - user: current_user + user: current_user, + pipeline_schedule: schedule ) unless project.builds_enabled? diff --git a/app/services/ci/create_trigger_request_service.rb b/app/services/ci/create_trigger_request_service.rb index dca5aa9f5d702..8362f01ddb8bc 100644 --- a/app/services/ci/create_trigger_request_service.rb +++ b/app/services/ci/create_trigger_request_service.rb @@ -5,9 +5,8 @@ def execute(project, trigger, ref, variables = nil) pipeline = Ci::CreatePipelineService.new(project, trigger.owner, ref: ref). execute(ignore_skip_ci: true, trigger_request: trigger_request) - if pipeline.persisted? - trigger_request - end + + trigger_request if pipeline.persisted? end end end diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml new file mode 100644 index 0000000000000..4a21cce024e40 --- /dev/null +++ b/app/views/projects/pipeline_schedules/_form.html.haml @@ -0,0 +1,32 @@ +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('schedule_form') + += form_for [@project.namespace.becomes(Namespace), @project, @schedule], as: :schedule, html: { id: "new-pipeline-schedule-form", class: "form-horizontal js-pipeline-schedule-form" } do |f| + = form_errors(@schedule) + .form-group + .col-md-6 + = f.label :description, 'Description', class: 'label-light' + = f.text_field :description, class: 'form-control', required: true, autofocus: true, placeholder: 'Provide a short description for this pipeline' + .form-group + .col-md-12 + = f.label :cron, 'Interval Pattern', class: 'label-light' + #interval-pattern-input{ data: { initial_interval: @schedule.cron } } + .form-group + .col-md-6 + = f.label :cron_timezone, 'Cron Timezone', class: 'label-light' + = dropdown_tag("Select a timezone", options: { toggle_class: 'btn js-timezone-dropdown', title: "Select a timezone", filter: true, placeholder: "Filter", data: { data: timezone_data } } ) + = f.text_field :cron_timezone, value: @schedule.cron_timezone, id: 'schedule_cron_timezone', class: 'hidden', name: 'schedule[cron_timezone]', required: true + .form-group + .col-md-6 + = f.label :ref, 'Target Branch', class: 'label-light' + = dropdown_tag("Select target branch", options: { toggle_class: 'btn js-target-branch-dropdown', title: "Select target branch", filter: true, placeholder: "Filter", data: { data: @project.repository.branch_names } } ) + = f.text_field :ref, value: @schedule.ref, id: 'schedule_ref', class: 'hidden', name: 'schedule[ref]', required: true + .form-group + .col-md-6 + = f.label :active, 'Activated', class: 'label-light' + %div + = f.check_box :active, required: false, value: @schedule.active? + active + .footer-block.row-content-block + = f.submit 'Save pipeline schedule', class: 'btn btn-create', tabindex: 3 + = link_to 'Cancel', pipeline_schedules_path(@project), class: 'btn btn-cancel' diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml new file mode 100644 index 0000000000000..1406868488fa1 --- /dev/null +++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml @@ -0,0 +1,35 @@ +- if pipeline_schedule + %tr.pipeline-schedule-table-row + %td + = pipeline_schedule.description + %td.branch-name-cell + = icon('code-fork') + = link_to pipeline_schedule.ref, namespace_project_commits_path(@project.namespace, @project, pipeline_schedule.ref), class: "branch-name" + %td + - if pipeline_schedule.last_pipeline + .status-icon-container{ class: "ci-status-icon-#{pipeline_schedule.last_pipeline.status}" } + = link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline_schedule.last_pipeline.id) do + = ci_icon_for_status(pipeline_schedule.last_pipeline.status) + - else + None + %td.next-run-cell + - if pipeline_schedule.active? + = time_ago_with_tooltip(pipeline_schedule.next_run_at) + - else + Inactive + %td + - if pipeline_schedule.owner + = image_tag avatar_icon(pipeline_schedule.owner, 20), class: "avatar s20" + = link_to user_path(pipeline_schedule.owner) do + = pipeline_schedule.owner&.name + %td + .pull-right.btn-group + - if can?(current_user, :update_pipeline_schedule, @project) && !pipeline_schedule.owned_by?(current_user) + = link_to take_ownership_pipeline_schedule_path(pipeline_schedule), method: :post, title: 'Take Ownership', class: 'btn' do + Take ownership + - if can?(current_user, :update_pipeline_schedule, pipeline_schedule) + = link_to edit_pipeline_schedule_path(pipeline_schedule), title: 'Edit', class: 'btn' do + = icon('pencil') + - if can?(current_user, :admin_pipeline_schedule, pipeline_schedule) + = link_to pipeline_schedule_path(pipeline_schedule), title: 'Delete', method: :delete, class: 'btn btn-remove', data: { confirm: "Are you sure you want to cancel this pipeline?" } do + = icon('trash') diff --git a/app/views/projects/pipeline_schedules/_table.html.haml b/app/views/projects/pipeline_schedules/_table.html.haml new file mode 100644 index 0000000000000..25c7604eb24d2 --- /dev/null +++ b/app/views/projects/pipeline_schedules/_table.html.haml @@ -0,0 +1,12 @@ +.table-holder + %table.table.ci-table + %thead + %tr + %th Description + %th Target + %th Last Pipeline + %th Next Run + %th Owner + %th + + = render partial: "pipeline_schedule", collection: @schedules diff --git a/app/views/projects/pipeline_schedules/_tabs.html.haml b/app/views/projects/pipeline_schedules/_tabs.html.haml new file mode 100644 index 0000000000000..2a1fb16876a19 --- /dev/null +++ b/app/views/projects/pipeline_schedules/_tabs.html.haml @@ -0,0 +1,18 @@ +%ul.nav-links + %li{ class: active_when(scope.nil?) }> + = link_to schedule_path_proc.call(nil) do + All + %span.badge.js-totalbuilds-count + = number_with_delimiter(all_schedules.count(:id)) + + %li{ class: active_when(scope == 'active') }> + = link_to schedule_path_proc.call('active') do + Active + %span.badge + = number_with_delimiter(all_schedules.active.count(:id)) + + %li{ class: active_when(scope == 'inactive') }> + = link_to schedule_path_proc.call('inactive') do + Inactive + %span.badge + = number_with_delimiter(all_schedules.inactive.count(:id)) diff --git a/app/views/projects/pipeline_schedules/edit.html.haml b/app/views/projects/pipeline_schedules/edit.html.haml new file mode 100644 index 0000000000000..e16fe0b7a9873 --- /dev/null +++ b/app/views/projects/pipeline_schedules/edit.html.haml @@ -0,0 +1,7 @@ +- page_title "Edit", @schedule.description, "Pipeline Schedule" + +%h3.page-title + Edit Pipeline Schedule #{@schedule.id} +%hr + += render "form" diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml new file mode 100644 index 0000000000000..dd35c3055f204 --- /dev/null +++ b/app/views/projects/pipeline_schedules/index.html.haml @@ -0,0 +1,24 @@ +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('schedules_index') + +- @no_container = true +- page_title "Pipeline Schedules" += render "projects/pipelines/head" + +%div{ class: container_class } + #scheduling-pipelines-callout + .top-area + - schedule_path_proc = ->(scope) { pipeline_schedules_path(@project, scope: scope) } + = render "tabs", schedule_path_proc: schedule_path_proc, all_schedules: @all_schedules, scope: @scope + + .nav-controls + = link_to new_namespace_project_pipeline_schedule_path(@project.namespace, @project), class: 'btn btn-create' do + %span New Schedule + + - if @schedules.present? + %ul.content-list + = render partial: "table" + - else + .light-well + .nothing-here-block No schedules + diff --git a/app/views/projects/pipeline_schedules/new.html.haml b/app/views/projects/pipeline_schedules/new.html.haml new file mode 100644 index 0000000000000..b89e170ad3c55 --- /dev/null +++ b/app/views/projects/pipeline_schedules/new.html.haml @@ -0,0 +1,7 @@ +- page_title "New Pipeline Schedule" + +%h3.page-title + Schedule a new pipeline +%hr + += render "form" diff --git a/app/views/projects/pipelines/_head.html.haml b/app/views/projects/pipelines/_head.html.haml index b0dac9de1c61c..db9d77dba1604 100644 --- a/app/views/projects/pipelines/_head.html.haml +++ b/app/views/projects/pipelines/_head.html.haml @@ -15,6 +15,12 @@ %span Jobs + - if project_nav_tab? :pipelines + = nav_link(controller: :pipeline_schedules) do + = link_to pipeline_schedules_path(@project), title: 'Schedules', class: 'shortcuts-builds' do + %span + Schedules + - if project_nav_tab? :environments = nav_link(controller: :environments) do = link_to project_environments_path(@project), title: 'Environments', class: 'shortcuts-environments' do diff --git a/app/views/projects/triggers/_form.html.haml b/app/views/projects/triggers/_form.html.haml index 70d654fa9a0ad..5f708b3a2eddb 100644 --- a/app/views/projects/triggers/_form.html.haml +++ b/app/views/projects/triggers/_form.html.haml @@ -8,26 +8,4 @@ .form-group = f.label :key, "Description", class: "label-light" = f.text_field :description, class: "form-control", required: true, title: 'Trigger description is required.', placeholder: "Trigger description" - - if @trigger.persisted? - %hr - = f.fields_for :trigger_schedule do |schedule_fields| - = schedule_fields.hidden_field :id - .form-group - .checkbox - = schedule_fields.label :active do - = schedule_fields.check_box :active - %strong Schedule trigger (experimental) - .help-block - If checked, this trigger will be executed periodically according to cron and timezone. - = link_to icon('question-circle'), help_page_path('ci/triggers/README', anchor: 'using-scheduled-triggers') - .form-group - = schedule_fields.label :cron, "Cron", class: "label-light" - = schedule_fields.text_field :cron, class: "form-control", title: 'Cron specification is required.', placeholder: "0 1 * * *" - .form-group - = schedule_fields.label :cron, "Timezone", class: "label-light" - = schedule_fields.text_field :cron_timezone, class: "form-control", title: 'Timezone is required.', placeholder: "UTC" - .form-group - = schedule_fields.label :ref, "Branch or tag", class: "label-light" - = schedule_fields.text_field :ref, class: "form-control", title: 'Branch or tag is required.', placeholder: "master" - .help-block Existing branch name, tag = f.submit btn_text, class: "btn btn-save" diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml index 84e945ee0df53..cc74e50a5e37a 100644 --- a/app/views/projects/triggers/_index.html.haml +++ b/app/views/projects/triggers/_index.html.haml @@ -22,8 +22,6 @@ %th %strong Last used %th - %strong Next run at - %th = render partial: 'projects/triggers/trigger', collection: @triggers, as: :trigger - else %p.settings-message.text-center.append-bottom-default diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml index ebd91a8e2af95..9b5f63ae81a5c 100644 --- a/app/views/projects/triggers/_trigger.html.haml +++ b/app/views/projects/triggers/_trigger.html.haml @@ -29,12 +29,6 @@ - else Never - %td - - if trigger.trigger_schedule&.active? - = trigger.trigger_schedule.real_next_run - - else - Never - %td.text-right.trigger-actions - take_ownership_confirmation = "By taking ownership you will bind this trigger to your user account. With this the trigger will have access to all your projects as if it was you. Are you sure?" - revoke_trigger_confirmation = "By revoking a trigger you will break any processes making use of it. Are you sure?" diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb new file mode 100644 index 0000000000000..a449a765f7b26 --- /dev/null +++ b/app/workers/pipeline_schedule_worker.rb @@ -0,0 +1,19 @@ +class PipelineScheduleWorker + include Sidekiq::Worker + include CronjobQueue + + def perform + Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now).find_each do |schedule| + begin + Ci::CreatePipelineService.new(schedule.project, + schedule.owner, + ref: schedule.ref) + .execute(save_on_errors: false, schedule: schedule) + rescue => e + Rails.logger.error "#{schedule.id}: Failed to create a scheduled pipeline: #{e.message}" + ensure + schedule.schedule_next_run! + end + end + end +end diff --git a/app/workers/trigger_schedule_worker.rb b/app/workers/trigger_schedule_worker.rb deleted file mode 100644 index 9c1baf7e6c5fe..0000000000000 --- a/app/workers/trigger_schedule_worker.rb +++ /dev/null @@ -1,18 +0,0 @@ -class TriggerScheduleWorker - include Sidekiq::Worker - include CronjobQueue - - def perform - Ci::TriggerSchedule.active.where("next_run_at < ?", Time.now).find_each do |trigger_schedule| - begin - Ci::CreateTriggerRequestService.new.execute(trigger_schedule.project, - trigger_schedule.trigger, - trigger_schedule.ref) - rescue => e - Rails.logger.error "#{trigger_schedule.id}: Failed to trigger_schedule job: #{e.message}" - ensure - trigger_schedule.schedule_next_run! - end - end - end -end diff --git a/changelogs/unreleased/zj-better-view-pipeline-schedule.yml b/changelogs/unreleased/zj-better-view-pipeline-schedule.yml new file mode 100644 index 0000000000000..6d6fa0784f22e --- /dev/null +++ b/changelogs/unreleased/zj-better-view-pipeline-schedule.yml @@ -0,0 +1,4 @@ +--- +title: Pipeline schedules got a new and improved UI +merge_request: 10853 +author: diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index fa503f84dd093..14d99c243fc49 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -181,7 +181,7 @@ production: &base stuck_ci_jobs_worker: cron: "0 * * * *" # Execute scheduled triggers - trigger_schedule_worker: + pipeline_schedule_worker: cron: "0 */12 * * *" # Remove expired build artifacts expire_build_artifacts_worker: diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 6a6fbe86df9ab..6097ae6534eec 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -323,9 +323,9 @@ def cron_random_weekly_time Settings.cron_jobs['stuck_ci_jobs_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['stuck_ci_jobs_worker']['cron'] ||= '0 * * * *' Settings.cron_jobs['stuck_ci_jobs_worker']['job_class'] = 'StuckCiJobsWorker' -Settings.cron_jobs['trigger_schedule_worker'] ||= Settingslogic.new({}) -Settings.cron_jobs['trigger_schedule_worker']['cron'] ||= '0 */12 * * *' -Settings.cron_jobs['trigger_schedule_worker']['job_class'] = 'TriggerScheduleWorker' +Settings.cron_jobs['pipeline_schedule_worker'] ||= Settingslogic.new({}) +Settings.cron_jobs['pipeline_schedule_worker']['cron'] ||= '0 */12 * * *' +Settings.cron_jobs['pipeline_schedule_worker']['job_class'] = 'PipelineScheduleWorker' Settings.cron_jobs['expire_build_artifacts_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['expire_build_artifacts_worker']['cron'] ||= '50 * * * *' Settings.cron_jobs['expire_build_artifacts_worker']['job_class'] = 'ExpireBuildArtifactsWorker' diff --git a/config/routes/project.rb b/config/routes/project.rb index 68474a443682a..7f6e5447b1982 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -128,6 +128,12 @@ end end + resources :pipeline_schedules, except: [:show] do + member do + post :take_ownership + end + end + resources :environments, except: [:destroy] do member do post :stop diff --git a/config/webpack.config.js b/config/webpack.config.js index a3dae6b2e131c..cb6bd949ddbb3 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -54,6 +54,8 @@ var config = { protected_branches: './protected_branches/protected_branches_bundle.js', protected_tags: './protected_tags', sidebar: './sidebar/sidebar_bundle.js', + schedule_form: './pipeline_schedules/pipeline_schedule_form_bundle.js', + schedules_index: './pipeline_schedules/pipeline_schedules_index_bundle.js', snippet: './snippet/snippet_bundle.js', sketch_viewer: './blob/sketch_viewer.js', stl_viewer: './blob/stl_viewer.js', diff --git a/db/migrate/20170425112128_create_pipeline_schedules_table.rb b/db/migrate/20170425112128_create_pipeline_schedules_table.rb new file mode 100644 index 0000000000000..3612a796ae869 --- /dev/null +++ b/db/migrate/20170425112128_create_pipeline_schedules_table.rb @@ -0,0 +1,28 @@ +class CreatePipelineSchedulesTable < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + create_table :ci_pipeline_schedules do |t| + t.string :description + t.string :ref + t.string :cron + t.string :cron_timezone + t.datetime :next_run_at + t.integer :project_id + t.integer :owner_id + t.boolean :active, default: true + t.datetime :deleted_at + + t.timestamps + end + + add_index(:ci_pipeline_schedules, :project_id) + add_index(:ci_pipeline_schedules, [:next_run_at, :active]) + end + + def down + drop_table :ci_pipeline_schedules + end +end diff --git a/db/migrate/20170425112628_remove_foreigh_key_ci_trigger_schedules.rb b/db/migrate/20170425112628_remove_foreigh_key_ci_trigger_schedules.rb new file mode 100644 index 0000000000000..6116ca59ee4d1 --- /dev/null +++ b/db/migrate/20170425112628_remove_foreigh_key_ci_trigger_schedules.rb @@ -0,0 +1,13 @@ +class RemoveForeighKeyCiTriggerSchedules < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + remove_foreign_key :ci_trigger_schedules, column: :trigger_id + end + + def down + # no op, the foreign key should not have been here + end +end diff --git a/db/migrate/20170425114731_add_pipeline_schedule_id_to_pipelines.rb b/db/migrate/20170425114731_add_pipeline_schedule_id_to_pipelines.rb new file mode 100644 index 0000000000000..ddb27d4dc8163 --- /dev/null +++ b/db/migrate/20170425114731_add_pipeline_schedule_id_to_pipelines.rb @@ -0,0 +1,9 @@ +class AddPipelineScheduleIdToPipelines < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :ci_pipelines, :pipeline_schedule_id, :integer + end +end diff --git a/db/migrate/20170506085040_add_index_to_pipeline_pipeline_schedule_id.rb b/db/migrate/20170506085040_add_index_to_pipeline_pipeline_schedule_id.rb new file mode 100644 index 0000000000000..08a7f3fc9abb7 --- /dev/null +++ b/db/migrate/20170506085040_add_index_to_pipeline_pipeline_schedule_id.rb @@ -0,0 +1,19 @@ +class AddIndexToPipelinePipelineScheduleId < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + unless index_exists?(:ci_pipelines, :pipeline_schedule_id) + add_concurrent_index(:ci_pipelines, :pipeline_schedule_id) + end + end + + def down + if index_exists?(:ci_pipelines, :pipeline_schedule_id) + remove_concurrent_index(:ci_pipelines, :pipeline_schedule_id) + end + end +end diff --git a/db/migrate/20170506091344_add_foreign_key_to_pipeline_schedules.rb b/db/migrate/20170506091344_add_foreign_key_to_pipeline_schedules.rb new file mode 100644 index 0000000000000..7f2dba702af58 --- /dev/null +++ b/db/migrate/20170506091344_add_foreign_key_to_pipeline_schedules.rb @@ -0,0 +1,15 @@ +class AddForeignKeyToPipelineSchedules < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_foreign_key :ci_pipeline_schedules, :projects, column: :project_id + end + + def down + remove_foreign_key :ci_pipeline_schedules, :projects + end +end diff --git a/db/migrate/20170506185517_add_foreign_key_pipeline_schedules_and_pipelines.rb b/db/migrate/20170506185517_add_foreign_key_pipeline_schedules_and_pipelines.rb new file mode 100644 index 0000000000000..55bf40ba24db6 --- /dev/null +++ b/db/migrate/20170506185517_add_foreign_key_pipeline_schedules_and_pipelines.rb @@ -0,0 +1,23 @@ +class AddForeignKeyPipelineSchedulesAndPipelines < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + on_delete = + if Gitlab::Database.mysql? + :nullify + else + 'SET NULL' + end + + add_concurrent_foreign_key :ci_pipelines, :ci_pipeline_schedules, + column: :pipeline_schedule_id, on_delete: on_delete + end + + def down + remove_foreign_key :ci_pipelines, column: :pipeline_schedule_id + end +end diff --git a/db/post_migrate/20170425121605_migrate_trigger_schedules_to_pipeline_schedules.rb b/db/post_migrate/20170425121605_migrate_trigger_schedules_to_pipeline_schedules.rb new file mode 100644 index 0000000000000..a44b399c4de65 --- /dev/null +++ b/db/post_migrate/20170425121605_migrate_trigger_schedules_to_pipeline_schedules.rb @@ -0,0 +1,41 @@ +class MigrateTriggerSchedulesToPipelineSchedules < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + connection.execute <<-SQL + INSERT INTO ci_pipeline_schedules ( + project_id, + created_at, + updated_at, + deleted_at, + cron, + cron_timezone, + next_run_at, + ref, + active, + owner_id, + description + ) + SELECT + ci_trigger_schedules.project_id, + ci_trigger_schedules.created_at, + ci_trigger_schedules.updated_at, + ci_trigger_schedules.deleted_at, + ci_trigger_schedules.cron, + ci_trigger_schedules.cron_timezone, + ci_trigger_schedules.next_run_at, + ci_trigger_schedules.ref, + ci_trigger_schedules.active, + ci_triggers.owner_id, + ci_triggers.description + FROM ci_trigger_schedules + INNER JOIN ci_triggers ON ci_trigger_schedules.trigger_id=ci_triggers.id; + SQL + end + + def down + # no op as the data has been removed + end +end diff --git a/db/post_migrate/20170425130047_drop_ci_trigger_schedules_table.rb b/db/post_migrate/20170425130047_drop_ci_trigger_schedules_table.rb new file mode 100644 index 0000000000000..24750c58ef008 --- /dev/null +++ b/db/post_migrate/20170425130047_drop_ci_trigger_schedules_table.rb @@ -0,0 +1,32 @@ +class DropCiTriggerSchedulesTable < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + drop_table :ci_trigger_schedules + end + + def down + create_table "ci_trigger_schedules", force: :cascade do |t| + t.integer "project_id" + t.integer "trigger_id", null: false + t.datetime "deleted_at" + t.datetime "created_at" + t.datetime "updated_at" + t.string "cron" + t.string "cron_timezone" + t.datetime "next_run_at" + t.string "ref" + t.boolean "active" + end + + add_index "ci_trigger_schedules", %w(active next_run_at), name: "index_ci_trigger_schedules_on_active_and_next_run_at", using: :btree + add_index "ci_trigger_schedules", ["project_id"], name: "index_ci_trigger_schedules_on_project_id", using: :btree + add_index "ci_trigger_schedules", ["next_run_at"], name: "index_ci_trigger_schedules_on_next_run_at" + + add_concurrent_foreign_key "ci_trigger_schedules", "ci_triggers", column: :trigger_id, on_delete: :cascade + end +end diff --git a/db/schema.rb b/db/schema.rb index 61af066ed3bea..722e776c27df8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170504102911) do +ActiveRecord::Schema.define(version: 20170506185517) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -95,6 +95,7 @@ t.string "enabled_git_access_protocol" t.boolean "domain_blacklist_enabled", default: false t.text "domain_blacklist" + t.boolean "usage_ping_enabled", default: true, null: false t.boolean "koding_enabled" t.string "koding_url" t.text "sign_in_text_html" @@ -113,14 +114,13 @@ t.string "plantuml_url" t.boolean "plantuml_enabled" t.integer "terminal_max_session_time", default: 0, null: false - t.string "default_artifacts_expire_in", default: "0", null: false t.integer "unique_ips_limit_per_user" t.integer "unique_ips_limit_time_window" t.boolean "unique_ips_limit_enabled", default: false, null: false + t.string "default_artifacts_expire_in", default: "0", null: false + t.string "uuid" t.decimal "polling_interval_multiplier", default: 1.0, null: false t.integer "cached_markdown_version" - t.boolean "usage_ping_enabled", default: true, null: false - t.string "uuid" t.boolean "clientside_sentry_enabled", default: false, null: false t.string "clientside_sentry_dsn" end @@ -246,6 +246,23 @@ add_index "ci_builds", ["updated_at"], name: "index_ci_builds_on_updated_at", using: :btree add_index "ci_builds", ["user_id"], name: "index_ci_builds_on_user_id", using: :btree + create_table "ci_pipeline_schedules", force: :cascade do |t| + t.string "description" + t.string "ref" + t.string "cron" + t.string "cron_timezone" + t.datetime "next_run_at" + t.integer "project_id" + t.integer "owner_id" + t.boolean "active", default: true + t.datetime "deleted_at" + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "ci_pipeline_schedules", ["next_run_at", "active"], name: "index_ci_pipeline_schedules_on_next_run_at_and_active", using: :btree + add_index "ci_pipeline_schedules", ["project_id"], name: "index_ci_pipeline_schedules_on_project_id", using: :btree + create_table "ci_pipelines", force: :cascade do |t| t.string "ref" t.string "sha" @@ -263,8 +280,10 @@ t.integer "user_id" t.integer "lock_version" t.integer "auto_canceled_by_id" + t.integer "pipeline_schedule_id" end + add_index "ci_pipelines", ["pipeline_schedule_id"], name: "index_ci_pipelines_on_pipeline_schedule_id", using: :btree add_index "ci_pipelines", ["project_id", "ref", "status"], name: "index_ci_pipelines_on_project_id_and_ref_and_status", using: :btree add_index "ci_pipelines", ["project_id", "sha"], name: "index_ci_pipelines_on_project_id_and_sha", using: :btree add_index "ci_pipelines", ["project_id"], name: "index_ci_pipelines_on_project_id", using: :btree @@ -313,23 +332,6 @@ add_index "ci_trigger_requests", ["commit_id"], name: "index_ci_trigger_requests_on_commit_id", using: :btree - create_table "ci_trigger_schedules", force: :cascade do |t| - t.integer "project_id" - t.integer "trigger_id", null: false - t.datetime "deleted_at" - t.datetime "created_at" - t.datetime "updated_at" - t.string "cron" - t.string "cron_timezone" - t.datetime "next_run_at" - t.string "ref" - t.boolean "active" - end - - add_index "ci_trigger_schedules", ["active", "next_run_at"], name: "index_ci_trigger_schedules_on_active_and_next_run_at", using: :btree - add_index "ci_trigger_schedules", ["next_run_at"], name: "index_ci_trigger_schedules_on_next_run_at", using: :btree - add_index "ci_trigger_schedules", ["project_id"], name: "index_ci_trigger_schedules_on_project_id", using: :btree - create_table "ci_triggers", force: :cascade do |t| t.string "token" t.datetime "deleted_at" @@ -981,8 +983,8 @@ t.boolean "lfs_enabled" t.text "description_html" t.boolean "only_allow_merge_if_all_discussions_are_resolved" - t.integer "auto_cancel_pending_pipelines", default: 0, null: false t.boolean "printing_merge_request_link_enabled", default: true, null: false + t.integer "auto_cancel_pending_pipelines", default: 0, null: false t.string "import_jid" t.integer "cached_markdown_version" t.datetime "last_repository_updated_at" @@ -1349,11 +1351,11 @@ t.string "incoming_email_token" t.string "organization" t.boolean "authorized_projects_populated" + t.boolean "require_two_factor_authentication_from_group", default: false, null: false + t.integer "two_factor_grace_period", default: 48, null: false t.boolean "ghost" t.date "last_activity_on" t.boolean "notified_of_own_activity" - t.boolean "require_two_factor_authentication_from_group", default: false, null: false - t.integer "two_factor_grace_period", default: 48, null: false t.string "preferred_language" end @@ -1408,13 +1410,14 @@ add_foreign_key "boards", "projects" add_foreign_key "chat_teams", "namespaces", on_delete: :cascade add_foreign_key "ci_builds", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_a2141b1522", on_delete: :nullify + add_foreign_key "ci_pipeline_schedules", "projects", name: "fk_8ead60fcc4", on_delete: :cascade + add_foreign_key "ci_pipelines", "ci_pipeline_schedules", column: "pipeline_schedule_id", name: "fk_3d34ab2e06", on_delete: :nullify add_foreign_key "ci_pipelines", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_262d4c2d19", on_delete: :nullify add_foreign_key "ci_trigger_requests", "ci_triggers", column: "trigger_id", name: "fk_b8ec8b7245", on_delete: :cascade - add_foreign_key "ci_trigger_schedules", "ci_triggers", column: "trigger_id", name: "fk_90a406cc94", on_delete: :cascade add_foreign_key "ci_triggers", "users", column: "owner_id", name: "fk_e8e10d1964", on_delete: :cascade + add_foreign_key "container_repositories", "projects" add_foreign_key "issue_assignees", "issues", on_delete: :cascade add_foreign_key "issue_assignees", "users", on_delete: :cascade - add_foreign_key "container_repositories", "projects" add_foreign_key "issue_metrics", "issues", on_delete: :cascade add_foreign_key "label_priorities", "labels", on_delete: :cascade add_foreign_key "label_priorities", "projects", on_delete: :cascade diff --git a/doc/ci/img/pipeline_schedules_list.png b/doc/ci/img/pipeline_schedules_list.png new file mode 100644 index 0000000000000000000000000000000000000000..9388fac98ebcfbb6d57e81f8620d9759b2900eaf GIT binary patch literal 67555 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%0DDYo>SPC6#a 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%0Awu3(>L%!h$VB+8K zAYKn-?{dFitVS2H$KQGY)m)R{yR-z-(JqIm;we=gEb_Zw*VP@wxT`godeg)l_WIrz zipn-p;;xvY^lE8Fww{%C=5LF^d0SC`^Qs-bnq_elE&5}n9w8q;;M`25_zqKmWcrQn zSssVE4Z`2w9}WRt<Rwjf>+mY2|9*(Gf!`IoGtxDW%${&n^OmP2<%_?9jMccgAV3Gz z<Qj_3wEw~SgPzV6E=%{z5)z-o$z8d)BCIa!(E-HsPTAi1U>eTm3;E~AYw;I7mPMj+ zgFf+N+BcnzT}$ViglpXxoYCU<JzE+tm*9cOF&#T!MSn)$a2Xs#b!^f--}b1B_7?nb zJ_+#msSxZ-Wict&53K<}CU}V5vymd}+O?soYwMa8)})^iZ^hX=xU_@s;}^SG(Oj`y z6a?%vR6SDpHXq+T`Z%}2K$f3`32yPnBQ~j#jpvX;@W7HVWs`m`T_ZAHQ*Us3h6^mR zzMT7bnO8*xi=(YKJv2+HR43e(XQNwAU9@u`kp~A-U`Q;}iC}DA4K(!Wsi=bE;cN}A zwQqT&id24p9tg0N>RPb27d=C1mE}po*t;w@VYmloiTP58^8?>4m^1X9|7?}R%f{GX zqDDK1h4AFLR6WB6E;#0Xt!;q=$N;d~0Vi^0r=Ys49i<9sAG_%iQ)uq_nhmk%16@nP zZI9!HJ1w0Rb~xzf*)6xF=%K6B2nB%wLCd5xrh6;kf-BjUMW0<Ohpc@c!&x+Ftr;Q( zu;wpBx-~wfG=6^D{mzE^XB+<6x?9EmhdG@ESX7_#aUWarFV#n9`#w+DfijSJ*URb{ z(Lak<smJ;r1%T*=>T1Nm`k@O-OXPMsWZ{OIq@*LcsQyCXpCLRK%V#RuiT)L(e9WJ+ zb6eR|Eix1~+_=yi$-inzG0;3NhaY)CTT9KQ^92q2G&Za@D7aKgFJ5MtX@dsT_ZQ5G z%G*dJ7xjv;l7Md^?V52`nhRsqwr#xL+7rTepO*}tFLtgCrSh#$0RZ8VF@p|Mwv%;p zR_Zs`>4)wIae+6uewpMr^G=su*nG!U&U7O^#kJgDqe*>tCS+S$&Pw%=IYZkg#}qP3 z1Min6=M<U9z!mtYY%?Za4zb?_gLAG0+UG5w98V)V1T+t*eNFnbz6|!n`jMmr!Z%Ly z^Vym+eN7PVj?yV0BW(4>g|BmzJzHm1efMqoPu;;EwhJ^Z4`K>@0?+SAXMWiz{xVU1 z3cahkIZy`sC(7GO{Ne3t5${anfpN}s3Q~~b3cb0?=%}CySu#dxvC7H<I>3^Xq)$6q zT)|k+auGb&xi1Z7Ye}k`!=&KLW>9T^acECGPIat1vI(?*qzd3$;>l2EYlYL7S`R)Y zfuo>-yNKg)Fbz{M;un+3I#DI$xER^6xOA<c?gQ3Xt`D-CWX4+{qZC_sk?oT>W|i+j zYaEEi1a0^}hy9<HMS2Pcl9{(YM#A&>MbToB?8W=W>=lQ9X{W>(pw2q7TxNxUt*hwE ze?No%&Dt8mlOYUq)Lb@x&*-BgP1Wa3X5(8#*~Ap_+7CN^PMN+x;E&d5#Giq24o~bX z{U-E3d&LI&pVhG`6f*p&Nk3Qed(gZ3CL%jETlnnoHK>zOtmP(fAVwsl^cCtw<5boK zcVA0&tikpB9ly}ai$8+EWK-DR#H<oh-yDj!c#IdS@8I~_+0vAXpfTTtHA?P#fE6*) z3;U9fd-b1Sd&M>7pf-@oC+_K&A)|3_V?t(Y&+YBx*r|AZ3yc^&c#}wa5&$Oad;gIv z8d+jjdqAUNG3|r=;*VwTcNR|%d7hQ&5+L&wjkFfjjWE{p{Q%>SmHLZg)9<~8mu=rv zx>{rQE(<`%Q1s@?E@p^P!$#hY5|rMwN%HWsIWU;X#kVY+r}wVfZcZjLs@QV(8VycM zpaSG@luUacI;3cYLzaV?`+F8|o@$;7(MWl30=$=*NzlM`J-2S3`YcE!5St;DH%Z3N z(`z=Dal&Ypz~WD=%Zh|65AnEaA@|y-g&l+vDB6jiYyCivF*aD#Xe*m%1Ta4HJyH|V zt*E3Pfym*Cn&^I~5X?svV&aWogvb_VLjidkfeyDHO&!{<H$S76s=>L_Cg}~f-~fCE zDXwt^aKh@VSDXfMeE$`Qf=Ou*`SQq>aoWlTsg+h2A8fD|OcxlF6(MmperF-xHV&Jh zK;PHaf+ic?hPIdI4^Jq7NeV;#@`+A&o{(JCGcNOGF3QoCE;EHyd@TTr85(>B&tW|R zO~alk707>~@hn=}>R+8X!(&~}$q93%haItM6>6*q)fCbX*0JkDg~N}t@sae?R;(Ns zn*m%ft~s&>ZH5W<ou8MP!RZPrZfR&}4;*HIz>WPwXq!IHr@<iO*-h&G0<A?Mpg+lh zH)ayJZB4C^rKmcxKLfBwGC5?m%1D%!b}lg2XiRt;(<cg5J!MFsi`jT0H?%=ENjWi& zVMS@r8r5CgtOm1esR;I5Yqg7+<Wbzl^93bXRN`o-k^tjC3}Vd|!Z0+059>}39|<P} z`thc#6aj~6ln=f{>zi&KV(q8MMpoPnrL$mxns-2ngnk+VqU%&i#AZV(*UUzwiA&9_ zl0uGPs2-FWwFHBgaus-f2;lVOo>D<w2Sc}$7aB>)!uOsyz7yq0zLPKe=sH0=;$FtH zbgmyKBXQ_cfJ)D$J)twWXqF|VVrccEk!+2Wnq@yOAer~gh8{@gENT-GO`u4nZU^@g zkwj|JxyLa_J&D19zDzjAbqt-4>Qy?6#>#|c^k;E?c`-fvH@4F*aI`PbN^W~bfi}?C zPZ6Rf401hQgwl0(Bck8ak5GIPnzBwq-}6I=aGGy66<Q#km`Xd}rNn%LU^Ytl5^j}# zuZ}snot{%E5Xl75JRtKn#djrs{|n%a?3ZNA$IU4%S0!zDX1e|xU)crlMUFZ&6-muQ zJ`1QHd|2eOs8uwVZ2V8M$g1b2I3&%kePN&J45ksCle{=!hxr`*lUM+GIew&VMW_OQ zYz5H@@SB}};3b~1WC@z>&k?<BZ6HEFz*vbYVS!{^BN;1J1cy68kKQ~fiFLN1>Wywd z{SpvN^h{&Qu3h^RY}qMe%Acp|v%zxiqu*y~F*B@A3DNl|A5!o+<l3(WDgszrG~SAl zI>R#(k36`GiwUk#NLvJ(wczkmjM%6wPkxY>W)N3jol&A@A|1)WT7#)xZauN%fcXq7 zj6ym4tBnwigl0M4OCd0-SiWnV&eG*AM%{%80lMbZ+e=IbzOz7eW|Rl%Ic4auzV)@= z(Uq>{B}`S$@?G_3W7~m+uX*bq4ZgoZiv94i7_#}5C4L~yfTsPOD&iaNb(WhNOi~X+ zwBTsm2s}~}W)n%S&ry5DTJm=4^1!3YO7yKC?j<z>+O^rY0E)4uxt{q-FLz6YQ7OJ} z&V$|pD|$&P18K(Jdsr9on#qv$r|U>&KnJIT)+Pt2{8N!N1}8*X3;A_-d@9cD5E}B< z`lGr`zV!<dRK2|9;!kgi+?`%fWN|iSbrvtLzReGeov(8ANVpXwd|8kfm5Yt>BbZ3O zTW6*N$f!XU3;8CAyuV@c^!+e2v%~e$Y^hsyr4adL2G*G5J+ChB3g~=zZ4FuTE!^a- zh`XKo2O1=+SXuYG@L04I7<S5uB}c>u5tExu`YdDHx>Ac~ViagXFk`d*I5sUPobF2% z2FR8K4>>_Hk{aI(hNzc6HZ;}{_&;ioQXGC%eNHhO=NT&!y*-RsZ5<J>=R_ln&-3i% ze|}A_c2Sa3<xR(l1zVSIm9N~uk+!NoMYKHRFK?*#%uek1DJ{bKdrqE<q*d6sSSl8| zXcmewabc#CpKEh|yUgM91}xO{s+_G^+t_R9KW6liaC5FzJ0iq_i}Kp?P8Mk(rV31; zRrBMZ*U69PR|m+tzo03&8od_nZuRiy8Y?0Y_FAmx0kU6RyxuVfRipA=y?<;-!Y;bB z@axpsOyo<X`EIBCMmT2aDu4}=AGP|M>7fQ55uD&%iHVp$B9yzc-cp?8_1Vf2GYWoY zC}=_#Wj5QD5>Yq3B*PCy?(JT0(;R!Np}qVBR@fYp2%{~0phT2*4{5eK+wLj=yM&`X ztkvql-Inuv(lz9J>-UfR$)Scq(K{=|IlYq6)6JuI+S-wOYnOF?J(qQ0mR>U(E)lb) z-<p*@Pq${|2Y3K?j?e4GHHO7&E~WR@+{t4<7p6;(hae{vqy*hg5-YlyaAZE{=)AsZ zh6k13nScZ)2%@NwiXWe-M8!`N;roim)@JK4taH_yEv$d}T>RC@6r1y`y(eqQ`}J;z zx1GPRVt(nB`cXVkc+3YyI*lgVr8@BB;gG_{F!{5Xu1xTgJDHIQyxDNW0M%kM!NAfR zMGti-2oua)jW+ca;%W|Y9T%cSb5$Vf(s<rw@mAfQlS*{NkF0Qz%f~jI>AM>oBSZaJ zOFbrmyf})~&5j8ckJec3O<S=q$E~9COsww&mT<sp-Ozm~j1SOBBS%fiqX&+^#>>Vq zkZF`r%JuE-R8}2-L{?K%S%+gZ&0D=J7q)_g!jOl26TSZ|4zVT$QM-f+`PuVIrgB(h z)JG_it4m{)FSEGpSV?G_XPvh6XK2goTuSq<gDc6D5(gzEgob|A(vBL*;5C=LdPc=r zg#~tp+QnW?W<pt27bakn42{>)n@AU`s!^y&@q9b|sk1Sa-K_&T@Q|B&#Agy{!@~mn z{+7N%_{UT+=~Mwdf0un)WS|Z=+?+dkF~2LZZCR>!-F1SmV2ud<K)ZCcserbhdXFIC z2hYfKnb>H#mv~hOGdz1HWqb9D-QmjQaiPjk2?~y~<%oWaDsn}!>BCiTl|gJ<``ls8 z8Efwm2N?TK;7a>eLc;|`<$|&siCL%(H8YRgx7RGXNV5Q<BUy@D&d?!_NNsURjRhnU zP?&<$Lpz=5s8VcoNZkP>r2HGX!&WDI)3#X@kJhUFKV0YQUJ&AUIR~GwVz7u_3PDNL z0LrhJ<&)yU=vko?Je<8k%nxZv#(M%ip%w8v&qm{nIxTQdZC?pcXwFyPj2Uhy^L(Vs zNE}oqIa1_j5;c<$ZSGGVK%>Ba!$}zXaS!d?j)@g47=hA9yd9I8m$#ajp2ea_1q;+( zBza-Yl)V<$Gj$NPrL5@OVm-HAbH|%eUW!XdlXqCz+s@36+b3XYCfr8PD<Un!qSG0` z+M%7KI?7Lugh=@G;sZq_CauJ<l40TmHpp^wiZc_5_D5J1!n*=ap9JOSeHqB790>J> zn)}rub*%=@LaG2&H51SaUohLhbdU=R+Dn)uBn?knSKnl9=XP`T%j1K2?PpIn=aKiX zG=D_WPDtZ0TPpzp2{N@?P<WiauVQXh3L=fsVV<q7xX4g`Ps7cY>uOU#FyRs8Gn3%c zJ4N-fQ)rSHSP$55@xGCNmtHQqFrZWV(j!c#<=mkmd!qNPz9coAFW8`}Mm0^XG^tMd z_GX9{s#*^SjVa}2MezAZPJZYKmULHw)g&Y6bQqI~g+j=c;=2(r`h9%AAHYEm^J}JN zpcsJhi=QLSi?$WH1TRGlUId{4*Ski0`XncKL}GjqHV@0@yPSO$O-NXo0A{1Q@6^^X zce@8&4Ud>81}CluZzKW2H_&QD{$Y=5PS`YTO`+HqT6ZFKysITha*6e*bnz@oigc#m z5yi4hHn3<-vSqa@D!WF~znoY@1`o2|ba#K#M}CMk{L=4b5|MXpOJY3i5AL#3t*oO} zJnSW(!2gE_BZ^ot%XUjQ+KTM1VBnxDI$K17ydPYe*-@))OY0sv-yoPB!#YoBY?>j& z__(X8;uLp=`b{EaDB8ydB^ovQSs!K-x?O&YmNE52?H4Og_Dt=1_>xHH;8j-WID7S| zL^gIiTq_^U?t@vA0kdtsFl;Sy%hK_i@OZarkcJvcGQ;z(sOYIwf^vY<m-B9=?*1Hw z(fll5<d=}zv>EEIriiQ$V_X_in7%`LQ%&;aLC~|U!?d=d9qcilYb*p85=q8WO{%H< zB7KxbrDCfDhEPsv1xDivdXKc%W0HMs_1_`$1`m~uP1dZcfSHGkXJ~Xa0%#IqB&zaw zV|?2!ogECH^(EJvMLOhozS;2;TT*-Kvm>(h?(rP1q;2lV(W`yWoV*AWU!fgS${SW1 z1WOl^Jy{^g!M~-5S!5Ss&W}katTv=a8X*4Uq<b5LhuY%J*2D*o&=+4oAa?lXsPYzC zq$KTo=KX6js;c5}<~0Sv_37XlsiE?;;aKagE2D0ZH$ibXw!B=Tzr)Deo_I=%qmZ7( zuKEkC1uN#A4Q6*ck^C+G9o~2k<uVq7EE@Y7W;S)(2i=Xu%O@3P7WAy}3A3z#+!s9K z>TSQyGH%EItZV2G!AAQ5N@Hya(c1w0&gDEO)KjetJkDcJ=KIv8Sa)SyCFNUUaio(I z2G0>Mce+G-N!Ca7%FEqs_tKYUe!7he0G^Yr;3YU;u+agd+4_r~e#CUG8P1o`%j4m+ zcM+@dJ8yNL2DM~y&~WgJGSqXrLx!?BRu#H___od{jCkyzt>+C}o6fP@%Pu_s3YmMw zRvr-eHk(@fIHb^;(dG%{&*0z$G`=M*5K_D9d|na$jKQ_5_iQTGDcrF*u_9FhCm(C` zI%IJ5@`}lWtg;GN&eK}?JFTq{$8n)*9I3l_2*UFs5X?<o`X~z~j$pVkmNkT}`CJ6e z6gY@KBEAEvSds?UU!IaA3%r5}UbORDx)cz-9wTQJ-^UZV+$Fl0cRGBjONour)~)(w zuc&bOYt4pQ<pt#QgFw<J9=n8k4T~kB4=^MeJUQ(I6m6GQ11x8>_|*RbiEYMExJH}z zNMUTH;Kd@l17SU?F%^6M5DJ%(M>8x=u>zuz&O0sNb6|`2^k=vW&j=2~q!|UsvlE73 z4k<G7jnhoY`*&L<CEo|cUV5h#&E4@JF`HqWd~Hc^^AhxZ4J|25@~bx=NCUKX;_WRI z-hOX9k@G38{eJv7X<M#OyPF`PIztqBC0pO;cd|rE#MQ9MuNtwe>qVbL|GL@om-XKv z09SL02*6u$+8H2GaXt<D<k5<b56_n5S6hYM_%htH!6+lD!t^Ndwky=agZ;a+$-%3d z7;GVI6c|pPcUJy~J9XrVuk#q5>szJtKK&-*1>7Eri}D}}1^Cy!=l{cjvhmSgi69bd z6wN>LYsUI<La^PpqJ9(j2i*dLuMv5zTQbc5X!<+cB<=^(&MAETD<8z)iodJmE2ZNZ zW-FHc4NzNsrI~l)p`d@FHnMRf_*c>yk|Lh)8!<ZYUb)~Z+(g!I=-wjnFJ{OzRrs#} z(7!P*_gD0{hDZGP8|ejk$X>A^z8S9S?~t1PD>L&V6$Si5z}G1h@>*BlC7V0=8!^_S zz+NAe0{8#PQ(qxv>@P@}Y^jp+8!=jbfw=$eAi|x0cY58&Maii58=b#)$rM7j+0|Ah z{B>^QVg&gAMumP*&zWFJ*H1o}ABBD=6z{7Rqs)7;e<#!3FM#-eL&I0hBw_eR+S<Qd zx36pB&dv^(+v$574(%bz-gj+RP|<SO3lxYSU|hw==9`(fAK3X7+5>oby*<676CnPk z-c%7moTot*_&0Av`l5A`m)|Fz5TCvx-HBIj%c06{eP0WQYz5@Lz^&{bVw@$q9`*9E zyS^H*oFeJz{@baRQ59LA*lt(8Y}Cp`3HBig&dK9DP*^98*+;>&k9|=h`tPYK=mcN5 z;4^y6nMATxj|DNfUs2Sy?&M;5{`8(2`gehIOX#o5!L5hj+uw2Gy<fxpzins%B!yc7 zWfuMWo!PH5&6^TVMOAg<HI}Tfyu7z30;|^Rbh-!~0upi}PX-GL78XFsbx!e1#Pw!r zKi229ne;uM|4c=lDyaAzz~i;0ihd1566^-~d!f#Um3;?Q`|HCQ8J>q_8b~mjNCcTb zc>gEMSCIaamr~L4pY47AXH@>-;RxPZE-;kM8qA<7TGaU*QZwgfoE`Q(;t)Y=`H@Sv zMmi`}A$-MiTk1zuw;34lw%mUArC%5Mr-5H5Bu}etVgC1g@IS)^H$662&FC1e28dkr zR4<;mp|A?@o;yD0?;(#%MFMshI)gV4rQ>yYLj>Ho{7bJWvo{|*Rnuw2dK!bJ27Qg$ z>y8<1HP*Fq==xZh+|Qj&GKzOLZVRAs#wdvlP3yGv_5KyQE|VZwjp#eXnMXzM@~rBu z3?9+OSBl}Tsdz8NQFUuV>IEQ>rll*l%pB}JOgNyjrS@S+G#a|Hj=hSqu6o`Pfa0*3 z_X{ELgsW)1O?{!7c_*hadswi0qFMjWXkq=QMs<ND{@^3JeR74Gy03CcbTPW>VNHx` zMq<$MGt9mfwb*DIT^Z6a?WG)LYqWIPCn4i4d0+%)eE7t;r>!*Y@^M~bU@g>vlvMw4 ziOp)pjK)VzJ$P$5dS=Jg?IDPF@9706lE__v*3gXipC}#MZ1NduP3r2itSmu$iS`aZ zjB&nw_9Xgl3})5+Lme=vyHFHyYgs#!y)VqIG<0>YCdMDFdk^#G#Ys>bWBhmrD0YBN zfETbaW^QRY^AZDXzN#BAox5`bd$n#|#6`@n$<nZ1=Bo-p$5ly;>>*~p{rY;73#kuk z?Hd6<{p~M*nC&Dc9>sXJC!DE-B2YGWFNjaHdD(`5vx6moq#zg9WipEE+X@J;%MOP= z{|bIQD9Gx4eN($(CEIpbg@RVsv*)m0XecrQ%EgAmNp;_G0Ul;0hA_LaR6F!ROt*oM zuP*)Gi!W^cD&LRtkc#s7T?3(E$ay81)$cv_#sk|HC&fF<@TCvNG@Bq!0=^G32>TIP zvyLV<8d397%BU;!{a9$!U42lifES;Zn^|r5^PjL6{iHD#lQTperz_3Op4S#n?1Yp@ zUbZ<n@c+&Q;6P`6!*cUjn)S+SO^>vTKE(4KChT}Aq*!{nfaf!gfsrJwpU+Bt($<#j zN@XF18RGvmxxQiY9%P#Sbk5Sx%;y^J?1XPuJl%YyUA5{$w$o*A#)B47lkO6V;1F8( zUO{5`9@^dW$dF9DDQ_2U*xpv8Ln#bGs?jSUb?~k2S0vSftIC6*oBo7(cm+B!S;Aax z$2vH?EUDRY9;ld(<(@dy)7waH95$}luvYBwee2Zx2-1w(r?RRlG`{lv;J{R(XQ{{s zFz0R$9Il6uop&&<Z4m0vIuxAdUFx&d_eTqiD6`aXbFK&?qI-4?c|)pP3j^W)B)QzM z4?D-wbKrK1pIh{Mn|SA31_rBFltK2Jqy(;4D(|^jYRfh1)jOGP`cc`~u2Wjxm3p|* z?DB4_X>>7$|7?GZ!3j;{E1J>H4yN@83v5Xap=O5fzjEhM*gF3rdUsNcDM9EbE%biV z-r@xGSt4(#w@Z7iTJA}0^~-Pu4^~P_idIJ)rNZV;lD@d2B04SZ*#Pm6v0AuRF9?LQ zHgJvzn+$}*&!98k(901#MW4$E7)gr-BK$N`eo2~xLJvF#radAg0^U#`9Lr<@OpV)n zuHi=~Lnl$MXs-*XM9W@Zy07)8ap54i=<VaKCJ%@Cs&nwtRrBb<BH5F$Y%@elcQs*e z#725`=R>)S%(p}+`1nx`4P1o^Q$<34oU87TI5<XrwOD;syW|BeyhkhIByxvg+wk;U za0l{T;k=rfOgfQ3^iVu<@WX?N-Bu1c)?0NOzQP&HX60QlJTt?nR(>b>Y=|P(_(}l` zOV~URk0Umoc^`?`;fhW5qZ88U-deyTHFS0C`H=IRCMUCKymjRVow!jzs@QrJ+^}vX zgtFbi3K?_Hi)^2;>G2wyN(ABlH0i)vStL+n*vE`oO%hioD<ue(%TMkpg0}zzCJ?$# zCHIV56L9g7_39@v@jfc{=O58Ri~6P`(8teX?H7}CFVi6YBuyYAgUb3&)%))+1t=@q z+)scc<n91n(u*lOPD5`EsHQ+DP9TcFL9VSPL+#WiMsgtH_qp<TlTWO`9nC64z>jBg zA{TumI(bN+Jg-@Urqa>j)wV?f3O;?c7=p1*XZleCS^ea2^j%kQ_Iqvzc7!PSmg9u# zACF@T{q8fe#vhbixgT>E7!~?h3f3rZ3yfz)^CF(*bPTc<R5yC*-&HdUz{790+cYzX z4CvQUzd4{Suy&|>uMDppThG^1uHRV$-H(!ypnS)AzY2Xx|3>O=!1ePU>+y}wMy86_ z`-D22zDQ2s1AYHv^{C+^MIRp(-~%$$2eA#?N(m%0!tRaqmE`}$-CG9L5$$W<2_(1^ z+}(o*cXxLS?!nyy1b26LcW2}7?hxGFVedVhd*<GAX6}5Ns;PSG{n}mKUES;7tJmuF z{GNyC#H3`TDG-_up`9mU9x8XGqh*~-T<1Je@@-4qr-D1YRbR2;Wps2@Qc_azXex_+ z8d=~SB?61l74f*h0MhNqlJyn%<3g;2uD$=giht40MQ`%CV%zp4Gkv+ZY?if>s!zDD zF;L(0KB8|S1&Qp%ka(}mbcB`81oc9<j$%STDpR@3mA_7gwqdo*{<1_nx`@p18e+dq z>rgAb>njbZ-RP3ZfEaFp9SI-G+X@;l1M*|K9MLMBii?DYS92(R7fTMPlm5m{Yf*_r z#w{5qYV7AkdBfM9MZed+Z~GqFeSXy_)@B{*hqONJ&1Ck&1a7&dr7)-S&WFT3wTAr@ zGSxZdtfYwTU99yI++uUJHCryi@Fgzd8*F_T!wq#d>8qBh!S^v)GHi-DfYsj7)dVBP z2zdP@if}AFwFYpx%98GQS&QTOE@M^$J9>!>6&3PHeT&l7y=+ZG&RhlPSL(_`)x)48 zEi?`M!&Lm~HG?~Qze7z)p$dm>6TQlIzFSZ+j(G0IUGmW+=iG~9wDHK(bEQnS)swp} zAt)kPlTV2|?fVN^!-V4%{12R@y22zY1v(+_7_MlZ>%xzp>cEU5%}aFFy1iP2Ao)<p zsg*8WK&UUy_?^l1z3qTR_#kPu-GpjoY_E!1&Z0x#m6nzkJ2)#XbnZ8_;l9Grxc8UX z^v!6VV4j5Q%A3r^HuF8<VCUD1>9q58xz&ROe_W3QpaYaynoIY>YVg#Bq>slLjgtP8 z?1v^Rjc%Fjs4)|Qv6_Vu{mgAYms%kbPwTV>^y6YuLhYjHRojA&-;WO7m@qp8BkYES z@#?&i(@)!Xdx7Y0Ui)Vvlr`iyj?aT%O=nb`kglHfl^oEZT`TA2u{M#uwSB)~99<82 z`14DOj)UZ>aL@U*zB5a+iD6mHx3~I1a++oqo%%y-q2Wd<aOMAfZxNrNvZ?V?njN+! zWtD`Pe4R+1`g^f{7Zz#F%GpL9`m5VzxG3{(AkUYdDud?F_rDid%>{<j?f8f2w(*DF z%)wD)SYy|}29@wJSF&bdh0qiTF3l5^eo$1<RFevbs2hlr_zLq<PC_Yft(`4UCA2?= zVUu?y29yEx16&y8`;yC$fA-4btgxcd)l7&i5hXAkNS!p6kQ+~}@RG~nN8?>(MIU1= zk$Bw5n%H_`Hf$~C_%!v=(FsgTikO*4u+$!w(WEBf1xMA?Bs`lpN|idRfn`craq3OS z&WE$cKK#U_ew!}9723-6&jXu7l?VgrExXCYDWr9a7RY6R+a?1@m~@JRSTE%tQHVc@ zlSgvJRbz+~FFi3~6~2qibea@)kr@W}RE9~7B#?_h4;hWxsPVujNSHMB90#m*Wv?_L z?3wXLup%?g-e<0=4B|vP+kW~y!)X({XsB>t2->BP#4n#Kv4#oAWvY{E4<?~5r1GTQ z+iX<X*Var;WLrf_0hJeA)jV8et;42>?r%V|3m&kQ?vG|WgdN`Zb9LVJM2n3n_wATI z_R@}^d35>hDvBfWPI`)rwVzhVC3GKvYzkN2(pe;(Cs5TXPBN1ls*2t>kkm)-5<s^Q z65DsC%T+U{ZM+FoAImU8bze?$4l-E~XpXv*g67}qaq8KXR_%*77u$8T66?SXIY5M~ zuBYtgKDWhkHP-@wNC*;K;I!UfJtf5W)fsz4w53GBX3CDcX4@FVdO5F>-0Gm|?G{8_ zwvlG8tIoqG(;?-Hr$#J}^~cLCv$v#YMBMw<i}{8h>o0w_>D$f1e3jwXvk_x{T~(dc zCOW-5^SrN?y;Yv%DwnxJ=(ZVdekNXE7rhW6MqiVZSCA8JmQb_WRj1v0M)%earIUvh zvy8H^6Ttn$lWj<S%WAQuCu5UO*<3BA1%LzVbUOvt_p76VGf3zs7zES?l`G8Kt{10p ztd5)NJH95e0G%3SKjKRhZ>YLtCAav0CEoq6im_&aMoc%;0Y@hu@zNBDdl22Pj5mMe zYc^GZA4C}<wLo1jJSP0V)*t^$G~4<;Ct-qbs)ia4iYn6%7Ie5=&-}W39$VF254!!j zwjPxp_19@Vs<U#o|D0V741|@BYn-J#9)W?GBF!JVxVbr&Gt^-xo{^8`lgVb=4uK@| z3z-a%w_GETW{+%L;)K!K0Sfr)BiBe49b&F`sl?Zj5mQdJKC+i%*C24zTpz>c!FVL8 zzc%l=(adFBW4li0sW0m#aj#ex^(uW+cUJP!`Z8P^k*LtCUe-d%S#*N0d#iEKn-lpu zcRx2iNV|?h#Wkj|Y?!X@mu`6R^pIKL>-79`$I(UL!3$Jb&s$d8<R58%ima{~)t=^P zGvhZ$S6|(l28}eoi1Bs4WTyU2icFaosK3~Jtye0soB!%Ls^eF@-_ov@Q{VNvpU@06 zP_w4ZejSEdPq=*>rQ%pYBH%LPNxX@E^y(LoZv%YgOP_R|Rf~r6K8+3Um~%b8tFc`? zs<17IzG$iXwz|$=WzRUSZp+gsRRk=wJ;-@lk0#)<S%1t2y{-7&QyU!pa(X5jZ|Hgo zzw{fa23Isw8Ei1d%Tlq34UO68fluQ_w7rvtngN$B1h4l&$Dk(LjP@gqtp<X(-5`c5 zJMKU?-c!r5N1(ZsR9M6eJUf;xOMzX6+i6X620X`#IPgtweJXsfc@s=izxnc_)0_ck z(7<=INVuMYMQej)%f4oC6D++xPvO_Fn0!FmnoV>C)^4H@-}F|svlC#+wp`cn+_ql~ zIsA%xzo>t~<zj1`b=q<(k`A8RjONtFjX8NJ(;~vOAmAwQ&AFzM0MA+oJUn~)7~9!J z|4QaI&%cQ6YVg>4w45qwW)&B>9Cx<7D%x`8ROz+`-@j{oZB~FMcwHTP_%W#DMuRkO z^VLl6*OHi@eRa*a?HLv4%E<#*bX~$UdI3<e9Uc*p;eJ;AVhgugF;?1$ex~MU^kmj$ zC(wKT#xu-i$NHwR-UteuRL|iv9&-b+^(%P)YLEd-CzvPNHe3kgJW<Kj2u)usieVq@ zYv342qhB3Y@PR^Xn`8K$SCq}#SmqtgtykY*9Jd20>6>hQ%`+}kXmq$EESo2Ze68M` zTjD@9llSnEp!9pU0eNK~qfI2w)#4=Al<uU{qg0@A*2PwpCwOYL+R<$9#7pH)2a3j4 zr9{37I1PS}c`Uir1a381r`R4&r2RP9R&04%vbGsE>R#RvQ2EuHCDK^aB(OL*G@^3y zw$?PU*3u2xv`T5q6&fjB#Gg&tpkb-`l*$i>mVp(_s<ao|UV<Vs<VRHRjzRMVhbkcW zPT~DZ6wr!goHy;lg={=hPs3FRSFn3g&^y(=_N4vxz;P+lFKJ8CBiU&?It00{&~qCU zNjW}pS&7IZ^LS|}+a*M#6HS2sr?=jmt$>>FMo23iJi-2EBC2j675lD_Vz$}wHJFP2 zcwK`it~oPKtCNZ_Hm$R4C61K{QsmTP0Nz)4XQj^gxt|E+e^%Xrq@nL&s#j&w$S!4u zd)V2H%xX7($7{PSNIoE2hM{hTRKIL!!U1$#>eH$h(0L5*GCGIH<3r<9*bFUf!b!bX z?mYiny={<^pPWZQrj7B6z4|+rj1|$NzPv~CPqlREdi_$5<9S`C4A)ZTyzfn13xR0r z1RK3OD|`axFuSIoIprT!4kAA0hfTJLuJ3Rx%n%#+Q7tmdJD=0anq*z++j78DLb6?2 zHJ_zTDMhPJwNjG)tW+e9#I2Ax3<L8}s7x?|xUT-*Xt|Xg{6={*E_3jWkM482S=*XJ z2P?kGQIY(zNc|zq(3EserSRi1YYa4B3#YY~7T))k+%s@cnKL(1RiTE}hFI`jJu@!2 zMzAs>+w54lbX4WYNs`{YP*)NWdtV;pP`1rx!0P+<+?WY3d@nL>cxY$L1g!gpzro!k z53ET(9-9yI(J?=L`)P+47GMbLzEj4)$?V(Rh0l3wM*gbpNPY$P&|h!zV&mh%Gea=_ z7*KQ3=-eUBgx`RvASBQ<?W^dQd;Cc`V*HcaIA}B31L*20u^q9pASNm2J9PpY+N~>9 zEZJ`AE;r^DpYGXkKuwnzn9!ouOpUCU_6Yq=S8Eq|Pjrb2?mz^8$jJ2DP|EzqCGLSD zh=u{x!bP}q)}+G+&sq_v6`Q%b34!nm;}Y+Db)l`|zh2X6+|2};r}T<HHCA`H-;kCc zF!8g;6v^)*#{#S&M4Faw%E;b^g6{h-Qi83e8IEs!&P#c#UmjpPb$6Y6IIyOTSC`)$ za!=_dj(1CA@Vv2(Zfi{ww1pX5%J4IbN2F@xlP|E03L8x^FMf7W4<NCto747c=+!)6 z5uBA4LPbP6-4}g_pFiCU01~|LU#q7v`^ky9?oCO(?iCPtER~g1LSYa{tNpbfQDhEm zFT>?SMn)itYz;XgzHHVU7!n_CgBsIV)8*n+eM|IHbt>Yb0C&eBy*M%-mj}SSal)6? z9R3|hgHz3P)Uz}da90h_qc4nQAq#;X8-8EMAeav}-1n)ArNA1p`r1aAMr|&d27158 z2srbL|3lCKZIrtN8~5&ggzyJU4Npa%z%-LRYhHNOAlm?`ZX*!+d^XTu!KZST#@uK8 zC|(G(DJ<?@1xS1H!QVs}A9&-^WPqs?r>VcuzKORXu5#X}(!ctuls@_77iB^nn@U%G zk@b~*4c!(0)$%i9g`UdfVW%g1%Z5R1X;5s+&C0-ZaFXVsbE>xxTMe_oo|6tYIH{{K z_mrr~_osaLeVFmR`HxeAh}y9sOAj{Bg)+cHIt$kx)9x!xESW>CL23}kIqgMy{Hd4u z)OkhVYa);Rj{jp>@@+*@mlfF8nsfbY*HkHR71z>Bra<1+BVvZwuWaF7CwWGEFuBDK zF*%DFE>qQ|Tyxl}#Pm^c1KvRss0t-b6fRs!AGJm5i)U=b$En>{PbKH_9Ahnjd#yn4 z$-Q`G$fx%DI$z1bTR(7+w8&Y2gStyG&X=T86F*DmX_@-Vg-uona<}#}R6CtQ9syCU zF$Xvbd0WHs$?}|z9&`-4O#$o-Vi^n!6Zu6})O6$406^_J><{8?>gI(Z^!*7lt5eLn zxP}p(Lz29AACq&B#h|n5Ysi<##u2BT$?ZrI#HcO}oS=XL)&80s@=EE-lKQ1u$Md$# zj538T+tdOPva+c6x<YXAsXLn@SX*|^eB6InUZveTIv(;&Kd#HE9?NE@$#CSOlj!V2 z=OnRv<V>Ig6)#qcg$<4fxnfhNP?*oL3pc)Gj@1OXO%KF%H1*`?IcDd$@FnhT*JkV$ z2DahiaAAao!wNqV6%M5Ve<b#5lonA?(E=JLer;%sSmz{qM=V&kJ1y2+Pd6~hpE8C- zh^!gnFwIop4x1`$o5&>2Eb;ty>07Z@?od#4!_s^_y8z1|Pk7sXUD}|N>E#&3VJOJ_ zEG?B74SN{n%ymyoYlcSr7EDKO=S-^<O1MMK;}b$#ueFvd72DFOM&Q6rgn}KQ@2bP` zA@P%pdGychVT{My`ddqCen&dmW<MbCzKu;Aa-BplE#_&(jcE%%JVzw%nTX+0q*A~b zLg~VBRa%T|ddue<`|J8NB@mTa_(bENK5{#97sQH&s<CQ^a*X}k7mW#66wK+YLN6{V zMl5{U*20ZfgSdgr?}t9C=V_!ALzJtLTP-TVe7G17?1}ay(SU?FWfEw*2PDk1EdO%+ z4UXu#T9~?H<WrloxR7a>Z|WvURuGzSl(^?z1Y|JHGqqUfR4w2b#(%AzdRgUoO_)1! zY;AwY?^j^>>l)1%SfI?+TE*@aIS?1v5%;i6Z(@@o<jNJQbvC_96`ond-ThSQa;@<= z{t@#LZ!@0Gt1`AMC@N*#ZCYa3$(7&ct>UIA6M3_~pa=gKEkwXuBzM@qn=^|-hn1gh zAgyL<XP}H1welRYKUlJ&PEp{So7tGq=8A^C{soST|ILZZPYI~@qMD{1xjH_=(+GOY z@oZloGWmBCILE083C+AykEz~=F$Cr@zjsNhb!y$$id~mt<uPdvAC%FCDvHm8%?<>W zaKZ{W79%>}*ST7T4X1n@LZjlOqR;ScOKF4oYFrn}gzEW(xf&9!1kS;wQ`fQxXJ^-l z5%^^%7PA-jUXljBHzwy5Y@jm$2!6g;?#|TlLS4OoeOr9PC>UFyyqs0mn8EDa-vXgg zhYhlBH(2uv;(5^vVq%wa&o_M9tgvn^$JVg(l3TX&RSxOGF>AFc-AIY;H3GKP4kZUA zsIU8mE?26vE-sKEW7IiaHWG$|GwwJv34P^bRh$xai+()dimytDN#{g5kJ>-xvawIj zC$B!n8y79xOJ)y?6<od_!w#z>xmLBwQbmG4S#U1BOw^Ougt&i|l%t`zX?IJp)QR~; zw<}5pE0+yS#<pya>M;wGK6o!X(@7n!$78z<bszyU?7p0z#zoOyLL`P!?3v@B=9x(? z_2iPetmHBbtNAe5tGd>O3JEm2l*s<Mu6Yd~lUuank=LgACLy^6F`6GO`_vTvMC4^z zm!-*|G<&;kyQZj&K6m-ovY4Teyc8ANlS+f>Qqf9xCHZ*{Vr}|rL%FhSfd_AX2k}Q- zWc&TLP6_e+5o*9ZMMxw<CaPKVD$L0@EGc7t{xJ$F{J-nNExDg>Bz5TDaIq*!4wl!L zLL+vSn&7PjZ8;r_Bkw4HLXJkee0F+h`!V8ZC=J~TFquaqkZkKKFVc{r+S0XdfC)(j zKZLr#`zf=hV@+O6%>XexP;|UQ9E_VM;aNf{6s~LuBKLwah2WdsSw2p0zq!BDc7ZYV zP-iW6T#nD54FzP93x>@ZJf1zA$S`~7M8445lY@US)Kjt6japtV_BnDO9$UT;mu<qK zsfOX3*%zbIG#}G*#_vEF1A|I5o_r|qIv=hNgm4q^%}UMRQCfXZ_v<4_e#uBqKOhhg zr<Pg{ivO%Xe#3zM9eU2k*@kwPhc2u>1k{Sqy!CUT79+SG8UX56-@CVPw|J1AVO2I5 zmX}-rC3`0X2E`#0pV`xhmnUVF!~H<ozN1%rd^nPfcswJJ^cp#a92x5%KZ044{#>H* zS$b8?Y(CkCsxeKEuk<TdN8$N>q+9gpRX1hsW0u6=NGzc8?Ck_!6fr5uNJE2IL~gE? zbCD{x7kp5b%BF0CUNW4=?=>VA^sXpY>e^5QUcxKyZjtt7oDko-$<9}ycirGdGpDT3 z0`uO#3ujZMMJhgaf81<p1Jldp#zq;_mU9K4Ci`s|+j%s4h8$A;*6liZluY7$_!o+K ze4CkWFQ5dQqtW7=LbK(0g+i0WcIK*v6|J#B)hYX%nZ6oe0FteCv@%5aVvdqZrRHUp zC5#E$4LeIittuxjP==C=VYMpuiLM|+DY8TC!@RrfeIMgHpF#Xh-ZH?T1SJF40t%p7 zWZkULho(hp=5~w1*ue}%W1QfyPjQq7<pq<%Xcpr0^PF6^ed=EM%BNbils*rRyW$tU zf?aB4(V*2=!P7tV%w`J}&-WpA!T8G0z`n)MG_J4XP@~p$Lp&!Y;Rog!XzuSf!_3x8 z^flaHg3IG+p`m~U-)4t?Abs;GSM$%7NmOm6U2CJJ*_KCaaweB<dO@%Q=`YSfl!kBV zY2E2ZyS!wNwWWD30pOU1HS3F{U8h_d_Ga0D&_g5fvMR0fB?lSk!kYTM$y7BzS#g6S z{Un!5lY6hei6Lz%#hcK{1=HKHy@n<mYe|ll_W)xCmbD8{@aONIj-M%do>gs5O4{|D zu6+us;Z6$|)kC`!)OE36dzAx}>joF=J(G}|^j2KROrdfg)vf?)ewx-Drmr;WP)IqO zAk@PRvt$ykpVoQ%z5QX20nn`8igsy2Xg4QTX_Z>rk`iiNq%Nn^ULFc7(;}jB$&i|E zh{Fwl$$7TL3~ZW^`aZI&O?=f6X{G|~9nolQEfQbERw_G>^-rh>bJ}05?gQ^3tN{6W zPCw__QFwl+8WC}7Q`Uf3&0ngh$C99_Y*N2#M8Raf<1XGBx<*go1cE(3ae%ce6xGed z+*KPNIUYgMCRMsY&a3h4l){AP>dqfLVyfF}RcpReXzD$|Kw`_^m0&yjfm`-+8}*T7 zUt1hN`l`lIcY89G@6{*`$W3nv8FIhLCyP9L4L8?>eQeY)zZ@z34sSKO>~socZ#1e? z<nZ0UP>(AVtX1jSdfV_MC$w@VQ^;Q<I40^tx#)zqPR+mz#lJUHa!XWUzr3fzbT`N* z8+8)giCkak1A!K`%<Y<lqgs|0*DljswuKwjJ$H4tf4jB$`&@@AuY?=>k$)K^uE?=C zCAs|^g)q^kgD~OCa!e1I4hxX$sjrPnb+*R7ZLS`IVCFsokR=#qqy2q2M32>&EW036 z7-s>BHpv?3o_uFdxV+Dm@gsi>HmQuD8Q4iZX+7hBbkJ<(C_j}vBUvz?wm^&W!)2;P zClAR|K+7--!1s=<qigh)n66`uV^&K(jYxgDDhI`wn_nfkFHRE9k;p>jOGiwNz~QZG z+XsG3b-r-b$PYt~Hz>WTEH64oOKx?W=I-FFx|;LSB{MoU!v1V9-(zq!1}=W~VP|LO z^t`7Y8y~-zwLD)}S^*r3v_0m02=gCeY)mfZ!E+p=VQ>BDjeJye&6KBa@}_1AYBpN% z!<IGDGz>`x@5;G<hKGn3ecMe?y1})Xov(8Zb5%J(&l!_9mF=%z^squO5{lly3!E3u zSHd`))bFCnD&strrt}Chgl<_Fjw%B#dBFXBRm%vh$Q1N3fn>hy`a&wxwWv_kuG=YT zlmo^<lyP4+-N&4Rylc(o%sv66>Fz`Zs8A8cCfTCl;}^}RdFn!5t|{NXa<VUgCX9eC zucv$ifG?t7*Nhrj9KEUa<ODX2&dJP}(}r`+=`iToawm}dLQS}w_pogKQDTVrkU^`M zVk!f0^hH;u=3@>Y>!@qMl`^0Pnfw%=Y4t9S2^TYN3A{~)SiDKSOXa^_at88krghF8 zZ`ypr#IUN7Pj8ay<Kk0kGJc2(OIZ}Q$~j;$Il{7{>Y0i^a&0;{fM<_#4BMBkX#Tdm zEwo%FZ-JSu*DbcLY_i1ZBQY);qf6wrZH04AHweq+;Q1QWkBZ(f*v7L%TL}x0w=wX? zv%`<PCoOWpzI=I6{h7e_no*dSBoud_G~XCdOp$P}Ci4Ane?Ie(VHspm_iDXCO^3Pw zi`f2%SKvk94>`p#yZj)Mo0q7y+&{{r6B<?chXN5abIfBr`k^)+dW)Pm-@?*47EOx+ zJJuChwCU_Ni>9r|f_??TUsh$U0a#z;_P-?xcIk8%5<_P!nTE&0IHyciQ5`MVx*^Wh zaTrmG=exhra-Fn)q9}=oYcMAJ;X^t8enps%^Gve2AlM^c=$C5uq`-5+1k(G83z)*( zX~V)Kk=l}1q^TLCqoM902}57)=vd!j@TikX`3}>m2w8Ey2l&}h3e+QVra<ef{>PFy zHi2W+k2is;^{CK}N`XJlm(fy&f%e@);T+?4x%#UmyIBcuiuJgAa8DJz99-~&?(?B! z)i&#iU1RQ2Ybg=8Hh}CuX$7~nJ5;dPR71WiyCxGU-N}{*<+cV*S?hFTe=tjsJ$Zt! zXOdItiZb8q_{66N!$XYg4oANrr2Oz2Q?X{-^NTlxdBnyINt^Ti?xnjXvDf77vR+O+ z0Q4l-y;Wf{-Q~38F&ka~k}Sz%bV_O9IZ~Khm{6zN>^He&Fpl-0$ns+<gPaPa(A(;T z9(zAbKVi_&@9P@HgLLY9k}lO#%F}EkwY(OWsYeqUCzP{x8HsCng&N^wZPIErPtRMS z&%Q<MH6#^}s?U&SKxy0I1hxW!aUproC@&390;O`S<1_j4bW<z2V}l=W4e-UeZI$ME z`GE79n`~U>wWnJhOuSa?xALO{e^$7(tm-Ayow=W?cqz1=+Ltm065|?TAEZcb7w8xd zB0t+I54snh4q0wtG)aILH4FyFlISj2U&dB6ydKw$%m5<hUEj-rtDPP9a_64ulThTj zwCWywWA}2=wGPMvQEBsN2Wk}2*4l$+am$q>70OE%DFsYNPs%45GVS}?$Xr1b^4<y~ zECxrW<w!J#EyP^M!blh$+WH!#Nk^D({LDBo?ngMo`gjcPRb58qB+R(5i=)~1Q%I^K zJwoyLOL-lf<aTNWQ>Pjzlt5n&dO#AikJax8zr=PsEl^ecVy0nK77*_6X#C7<s7Bm* z%RA*jK-|Q)ktHBA6W?Tnr3jkHyEcGEroT&b%aY$2QF$xTXW*rr_uSvK3Sl1`ZG*i} zsNT<K7&oHRh)PPIst`IdBP)`E1*R}l!;gaizsi}btEH>EUfqxDQF*EezXItt6H6m^ zSIXn6wz#gc-a{x`VQ;6e_{0vT`~lTjuhZGFo=J?G%gP_g_{ezB20lLe`16_b*1`eG zs#QWA>hzFY98wadOhn2f9?++Kjy|H|O3Tgjys7pyhfq02GO9r_Syg(^%q?F1*6S>m zyv9G>u|{9V@EpGoIK(L`kN0zi&e<yy%EzJFi~M)pLg$fbJ3YI_^W!cYFR^Yj{leF& zxT&1gn@R%y$1)#HM|r4XU!y}!`lV6O6I2e~3~|;vQ|x(_90(|z=A&a=W30xt!LaI= zGT}{bGvkuMW=C*{2aVmvz@yEi^*L9&otV+1B4Qs9MV@OmDTlQ(tCu2N9qf_Apw0|= zp@LE`>pQEfxvd?%(8OYE)1I-*Y^O~Bk^1_Jzr)S7>$p<sKLq>_;9A!^ZQCWO;K&a# zz3(T3<6KQy9KFaWodXx&F{cF+Ssca+hy^!?&*{EXXa`2id8uB0^y;?qgZX^gObAH< z{b0f^9q?>87;2pEg;pVFs5*j5$@`}AQJTL=0+4?QaoKh5h3)n7)VTaWlVoP#5i#+e zVpv+q^g8Ybr(^>}rlr_uS-Pv=J;nEYI9;TkgqGL*O@_W95tiPGBw@m&=VUks_Bwz$ z968S)q@6A<hoHhk^!L8t(O0*GbLv1Bp?nGLH_#*E6zWU0e25s1BRhQ0yGJ(Q&d=&F zj$!0$a+jJ$K&1jX6q{37Li@HM-6)Lq<e#@7DeDAfLdE4dZ_>7>|2=4^_Ab*l+0mO< ziWH3)QFNJIfKA9W3O+e`pF4>^W>?my^4X80SlhB+knTW&J}Q0RvWFmP`Yr9@={f1W z!9H&zK{<-K$0g}`8YI)xE#663a3q;Sp>Uq!5Za|R7L8kaV>HbPKX*ZdQ~KkLclU(1 zwxCa6Kw5g`&tj-b$Q=#U!FPv5S;;?~1o|{77l#p;l3)ow{$c`UKotdF5jXwo7~F3R zPA3C2G@~d`^l|*-@4cVS<}b8^YZsLj@^cx9&vcj7J@;=zCvf6ZqHESeLLILdIbb{t ziheQ=kJf`^oyICjD;&S0sU}>AoRU-8+QSVkuBihSgpGn0jV1x>DbO&xFy8xVZZy!Y z$$7AKhF0x}k9tx{cWJGxKfMs<^lHhboL)^T0wKoA5;380mswB+-K;a2{6ilBb-vYw zS0E8HL>9xl%U2eG!P1@<kSoMeZZ)N>JJDZBjdSf`2pyl*65E<!mVBj+*uq!J;pf#u z>3gFdz4<bJ+kbL96xsR|{d4Y<2R}vRse3A7Xar^V!gk`~iuu?kUn})VKM^g10=;)? zWCL|X=BF|OLK^6owsSEzr2YF%(hx%F=uvn7%+G%}lw9DPMtzd%3l4!z+#O|48&Doz zqX!~%o3V?^!Kb)VmBz%str>6Z%8{hAC#*ye6<45fGJ`(oUumw2hN2!C$*daEd8?5H zXsFX5izIk879Oy}pwtBK8%J6%0IdsBc&5o11+7EeQs(i2@72mpAlK6m%j8jb@)G=T zj)hIm3W9BgLQQ${$K}l>`|L+-f4eVre=<MZQl9FAq5x#KD@(h<TMJRTNJgMtBwV>k zmQ8ratH_WZfcB~A6BsX?wPyL$qV{JwJwMQ)A!_uhHxPOqHI4PHo9VIPk3qz>cq7Lq z(F<2QA1^$?My8KZ0)z5)VP{Z%6rkno^g^Z%pAysS8s4rK_rSoqv|Y&ok4Tf-{L)Mk zk8vb4k9$xq`uv*`p(cCy!R#o)xmz*o1*-7do%{3OkUS9XIe8A<BYkY#*4NZmYpzm; zbA%<{CfQ`Q!QBfUXiu!YDsQj5r)*&$NoSI=8))uRSyxngUJat>;vM<3GProm(GB@Q z#j@Sby4cjB4CgXxhO;!|%rGV@bj>$VdQ~Z~^C407>S+ne<4lj{kG%vjG4)D!&Q_LR zLNyK9_J!3K5pBUw``{t2I2^^Ci6JR#M+?(Z1A|TU2xbUq?nFajiy-XxtOwwWBXJJP zJs^4c$|VK;g=g&CPeKPeGbtckfxhTjY3I}Z*<8|)Z^8+yQ3uJ)0)MDSU{qAi8beD3 zms|k<hrQS@T1$h*Fw7IVF_M%ua#0m$3y9xjP3mAap&*6)#I8r4QUO2LDxUtUY4TD$ zSZav5lhL&{L>#Yud#0CFjmK-bc7^6N=ML2yO~qCLiDrTko^c-*PuDWXU7!C5IeH^e zB&i7IZ}E=E)^P4mVi~VNI#rRsxXMwf$GfY+54-LeS7HFPXnf0^e)*(JL~qv?yD02T zzN7F3mv!$<!P9Tqxi0aL<S4Oo=>^KzS{VKFvxqN;>mNW^%PF(A%*6Hl&QoXx;lEVz zGyJfn2f)*w{+(EqppglH=VCU?HTdj~qe<l@p?x%4V^zc%q>CXeBEr?M&EN16zr03- z&?7@wPqdjUu>VwKan0d6n@)rQTQ2->fW%I;zxGxQDDiKN*?UDrMU%#5kxQG*RB-|h z7_5mxU2+q`e(TTgW_g4A9|CZH@CVj$1@I7={Fjb9SL=&xKR`<u`=4|{?k1YO8^3Kw z7Y^-zbA$WG-Q<2K!KdT@o8{TZt`y=A6d>hi@on<IPX1qJV*XG9<Nuo*n*TQH@k}11 zmDNWK10$^{1cOlS5R@~*`MJ|9+p|w};(wFN%Mgp|)0J(p2o(U@$Z@u}VX2J)??#B8 zLt7V4YCJ0gFS@k<O}sA13Y(acvbWLZlsh^n8ymSr3^7a@X`{$#0IkgWHnu`^`;Kc6 z0fp$BLslgCpL*_p*Z6yVIHdhwD&POFCG-CuI}n0bik^}2X@BtSU*-I71yCiMYrR|@ zC0!RNr(0me#Pq+S2LIE}ZX10k-WM=UN-Ozq)%%|(iK6--YHlX51OE?v%Ljde`2UXs zQD&a0BmXTD=f56iH-)0UezeokRIgdXi@H5z5kUr1eE^9MUZwF*^ZSnwO_6MaJ)K_C zs=yu_ymWp52HZcHynkn?EMmSS*44<)*VZyQpRfFYpZ$*yQSMR>dJSqs=f_(jeAT&p zB<{B!YZF5e%l%(4#eZ>6jW8dHOxwvrz7ZR$kio9!pPVliP|fRI*_p)q13xSTIqClV zAFlct!pap;W%j7E<BH3mj00p}#KI+**7@@Pg1VY{l|X40k^FZGYK<(Qyqq>uz>jco zak16?Cf^l4i`zx`=TG8x=ab(%J3Ei=It%}^Ukmu4R5(z+1^&yFQP7Jd7XdgnCY`yN zQ-Sj^c=i6Zq}j}>rn8;fm#q(c6!NUzr~AW*`lC;_d~QjJEXw{zlg&te5N>#|k-h&y zaC&lJRaI4cZXa4o*HQwbmm*pn`|N{|TA{GyQ}Pal*`dI<B`~L(kOsEvfxG6*yQevP z{-@m<mNosiM{oJR{#gi7R6TtE8%ed3^3kY%uU*L3F-qXTIlfhx4iL9<J1=&Ym$S$- z(0KN!+l$Q@pdwlOEVlEGMdfgODdFIGwEbUf3D;9ReUXRccH-}a3y(|4#(~8TRwFsN z9b8e%#yNP-p-u)&n1c#=BAof%>2o6K!H&Vz{q2m;@XtqSJtLu7boctzK?D<-lX>O5 z`{;&>FJv&pycp3m^8%o_wi~^bwc(PL_b;Z!lWRv_3cp9p0d+n!(F`j+S^vaO7F&*+ zHso^VuT%~^^yBQfp&9bd{~+RB&xj!ZZCcj1hP^4B;YJ2|ff*d<zez*zQ~rDr=l*+1 zNVX`1RnR*(_mCEIb;Lt4rTzQ9klf@@?Z;Vjg>;0HedDLEU&W{^tdgm>#j48&0<$ zxzBjwsz{_5_5|JzG&s4RJanmn;u*b+o_vT&IY2xL9s$ALv1NPS7sSl9IxH~PtDX1F ztGYMv312nEC!)Y9d)Onaz=UlaqsZ9Z2H}rDj+#a!1+o+~tfY1;!m2@LfSHwFLI^uu zOQXg1prbRqk&)2`7;!uQOi5#1TZ5kS?h?0fKeO7U>qGz^oEa2UCZ*ZThlgQ~$A3L@ z{|zVMOE=z^@4Bu2B{{yhA1F!C;2ig~ddw<3>W$E6QhwfQpT|^S<95%lrFh@*I%i%c ze%L0t6^wOcQc!zsGeF9ki{(e9_<3Pj=q2U?n~$o<y*A-x4j(KZw+E)uYaJV<fVLu4 zPrTcs;oKx&<Jo~2j%EW&Di;Zy+(L1*_Yr)u6IJbDOj+pL_OqvpOZ5E8745WLBCngP zQp57!i=4sc5k9VxkF8e#F#ne483XV0PN4gKoJ8ZX70S=2%S~)P&qkoO@j>}@vVt=6 zd$;+4&bK+F5|VNXmPVTJ50l+SIta`{0kpucFLjdRS|Oz<A-BnUC!siiPYQN?kpq(a zKap5`?<YS%5v7uUf+D|-KCI4U#806IzdH=e12Kj~UEJE1P6e!{NS*?-N(mjHU-gzM ztCcIts~^u(Dt(~!?4tWP-UP&tF7=d*+x+2;W^#Z}u+DAmSlnNn<F5u`kN|K?<##Zh z!nf7MP+ga={8N7Sj60WclWWsC4Rm^dBRQSer*X}cBizB@5tQwyRSBm0i~#XhtqO2z z0O#Vg6^XV`Am!hmuDrRg4lJSA-_U~k1Oy_2f_0dVT(oXxW}Lks_yTe9*D4k|FNF6> z@ErET;`IAjK0a}R{jmSZfCT*~0}_jzv$>Z3<*^Mj&rLz_?<Pl&8CMLFklqY<6IrR- z1F~GfSBb~H0{m+T!H-7y1FEp%cQqjziX)S-=k$U;6oru5OZgilF!5nb;jCC<raC1q zqN;DOlsPsN1ypj1HLG7q`SW5N@Da=_)Z8X3Q$|9tY-XX7gSlHm{$wNcQlwcB39OT; zy}Cwan09;jrM%TokG?mqV)Z=y>L20e?98^iyPL}|R|55U%Y6^g#H94Q1p4NnFmDCU z8?Em+!WNqnt2kMJs|0arjxrZKEI`&R%arx>BKOhQx{2p;QvLZ~PzRe*i4(iA-}!=v zCW)hZ=QRSF#AhBAq}=*qOHv=q@neFoud+YK;pfbro`}R-j?YkEGPI80e)4*5aiIm| zrR?o@TovpZNZ2n1b)fLS=&SA%irpk<9e>;Ssxh>YuZPEUMj|)r1TA3sN)H}HU#aJX zSHY_3A!ojrzaStUdHl_e?Onrf7<;P<ftEcjSm!Dymuy0E!VgSe&7j^$2tNsgHm-fm zX#Irq5?v%Hz2vMlo7zAn_LRZ|uRkdqT+&^#KxMeu*T@RDU>;k^rpYkW&C+bXWrn~y zQYhOuut@D3uhecARa{b*VEui}@%@6vl>oh%>&!xEY3xL^xnc%YOg*bQjT%_AM=Hlz zmBxuJMHKo3O(Th&nQPz{bjb1Hjz@{|zv36LM83}?n2h=xUo5u@5H_+(CXb)i8?myg zkCE<LUR42$F6?_XzAZ)8?Y(wfQTVgGzr69Ga2t+<Hld78*IJ5ZqeZY>G|GiDe!!p9 zPw<(hs}%W6C|T;r9}t`VbuQ?WkA>suS2D~Q>$?xLj&-Alt|&56P4ew)TpR&`FS3wz zRn@k@0=L7cIzy^V7~###Ni=$8Os*bFG<s56lzVY5maD?6q$gN(zo-F(a|}_siD*Q$ z+Ui<$L>XxO7OA*aF?-e1`Rc9hJe9bZ45=_D{jodLyH{S9U3Vdd&#a;T&wSncTWv>~ zSA0)=Zc`tw<lRSJQ)_BnY!!O%^5s>1ekDy(9f|?gQG->tDXT{%6tG2CMLKuGjN`#d zS`fq>@>6)EH}1)buO*_~h$5&o0Bv}9B8FXc3?T{T7ib2IZfZFU>1K279FB(EN*Ah@ zI*6AF8(x+c_%YXjUHGaS^w?k1<KNRb*>h-xNSVhQ**FPCF)PEW{w4^w-FPnL-7$R8 z?-GT?)Hb0fhlem-5N6d=P}maa|M)-h`(sQHT0o%*^_kFp=kqwlS)CQK4`alwAx;h| zYF|EyqcCk`vj%1n09k?rt=2;Zs;IQ-@Cco<=Sy{9&!tJ_ippZ%r+G`rIR@L&Aou;t zI%~ls?#la8-K(%i;e{g%SAd?gDzvM+d9<^%|HRf)ciU&vqx7O<s6Bm~6|FzgWHfdr zr;H%1b4Cdjud$A0>IpAW%slj@&yXB;k$6<vlUAvj`<B%_(M_X+rEzx9Gx5JS?WzZR zr88h(vAli|UonOc`nN$r3vA>jnliZhsjt2=b4Ro&$XPyhw_sWe@FJZ&9^O%}tNiqu z;xqN(mn3Q4y1T^<uZv@K@KPl12J5n2M$Ycx+74<onNp=)`bEc)s8GvCycUSRpDFOn zIF3ekjOYL)z1|W9Bg5Rk>2JSfH%8?E_uL*fRP}`~3NT-}JVhBG9JjaaF8185oh5b1 zJrT5nL<dL=)1mlXm+#?sW7+h`<g`^Uq$J$?;1;G7M7E0Y$)7PvZ^C|_?F;(^qW;;Y zBIC|Oz-jXsAwKi(W51EyG(53$Q8hxpHHa)G`Saym$0S3GL&a}K^H<R7t#4fD>nV$N zxurqa%Kp*?348$7e4ciEa6W_9!Zx~TVHi_UgcU7EuIh<`blb>jV`TN{c;dngkX!Cl zz>+GMxVIW?(E*vm{F!nG?sML`0QmiG<wNaj^0&kFLc3y4z+#42L)3-Kb`ZffWzLmd zFD1x@Oz8>3HwjQUkxVa_$gSrxw(GNaOk95KQZMDf8s$q!Ep$6yM-4wFsQ*3cIawuG z(ky)rh87W~az>1-OE9>SAtSCKzI>cLOmd4>W93jB9pNA?G%{4&_RGPveBf1p&A@4? zFa~DOH-ri^Vh=rrImZaa#4h~cvv}0kaG~fzfRUllVZ<mCm=xaQ!<Ob&;>;1FXeu&Z z<2UM?_V*}p72Z(^A#PHUQg2nmbq@JQ#5lE9XJPC1u@e(mnzwK?n<%1kvFMvG^@kRT zGOD<q*`nIY*<y$bw=dl$yF^jvBH`guymNtlbw&knmUaik#$lMNG6kpxxz1Ke&kIpa znjm$;+bv1q?>BJ1gNDu86QNn(N#@@~6DP(6`()ewsOl+}27d69V5C!6j<_rTa;};t zmEQoyHCDlR^4w|9-zbmrDo{-9|N4!BjZKXRF)%pTL{nAshx0-|01!UwMv;gSK|zQ6 zOh>0W@JaBTo7mJE;<vbt@=Y8pQ@}6u+;-mcC{mk`Y;iD_S<g>_T{Ff+v+dvJ3GoIQ z{*`hbl3qxXek5pN>lbVAv;6P8sB0Xzu?^g?O{zH|fYMYfH)ZIx?eOUi$&Xi%ZaQ%< zJU^4;hgw0VnQ4O~Kv?!&c!2i@Mu?k@ppaISQ^1yQ>Z@JC4uvNvdBKYjI}#ANX&ar& zFbI7dDP~<%%%%rx?32h{8)NO0EPFWUF^L{<z&j@JJ!JW!v4%GbtE5_J_A~NC2AFO@ z^x5$0O>4zo5(nX!UOyS8RY^#DMC0@58nDITdC)k4I9wg{_iO~*`uT1b%h*Ae9LX;; zMx!NsN^m=vPNQ1TrpxU`n6LUr+N`2}z+DIkinVGSi}j_C$4^BK@%wVNgp&vv??~MC z!QI32V~vDiext@{CwS~D`jia_Mp)fV_$1l+XZ8ls+*F~^4P=?RxpVm^hJ0VzZ@vb{ zq!#tWiLW?F%g)H-z@&F9#{e-8YEK$C66CYk`w%ioRLsH*8z2v}eeH`R=6xQ?i~s;1 zXWk#pSgmC62WFws;AdiE4TyK02fclH-HPhW`@BVr3hyf^2kJE!6s_qXAsz|@Db=gc zr}^imDr6zLPd2@vELRN?9zHljRkQ67XP68c%4r*^mq=Kq&&x2Hi9U6%e)}VFC=IVh zS~ZHV<`by_DF(i)>;apSddyk0G;q<wzd}!M=pPqjB0fYcw4)-Hnw1MD`Lx+)er3ua zF?^%LKY|RjN@DG<qh{qOrVXdD1ZLmj`wYa;3vdk#25cGncD<TzBX1IkZK}t`*(TRg zkCcjPf?8W#NyVtEz6nbMrAT7LKvZgy5!b;d^lC3M@hBLy;+l<|W-!8H{nRv7&tyZU z36995B&t7F=YBDhJs%ZuN<7p}e}zPY*B;i{KO&SAT$62ckk-?nXO~Y`M+|g?XPsP8 z=|{!<+bI;g6hR}EG`F+U{KX>+H)*vqPP~%tEKZct)MY_wtjL_2c3gR>b9RlR3HI;! ztr9Z_Vt=7K@vvYrWPCl&?=0u_>lV5~if@kn`T3ti(F&(2Dgs|{I3MHnaT%^;n*qv0 zi3}1b9AdM-st$e~?F-YmRBO!m)T6j6@Jw&ZFLk$ipDxgJ0w32~pSHY;#(L@y%DsF; z$=g$9FmYbJj(teK<~=_N<IGAyN|?k_GZ^=F(hqDZOna><LHL<L$ma5VnTVqO!uP<9 zrVlUn&f3OV&mjLYxmuW1iIuD>)%<$*7sX;BE>vGTv^?~iGRgkWihki8yYB_B4Z^Na zOH#noM&Fjr?CDHYlyJ|CosfZ~I+*MH&>J_%q>wlRAHw6FbHL#YROL%gG5s*T&nHr^ zjGcp!nYy0dd3NV}k;zfNF{u%$xtCDE$W~2}Q_SlMe^d+Naem16T22N`U*)@Rp>_Il z&N+dlu-PGbLxBWC+d___Scl<dXn(*dVwNtN@x{Xg@s(z}<<Re>W`?Kk0@4Q|+YB9e z>~eZGJT${mFdCMW;Pd9Lxh1bYJb?WS)zS$(E9gchCn*v5T5w>0r))UZQDbD`Pc)TK z?{^5`7k^mVY!zRB@@ep4>;~SIV0U@vor&47<;T9Nu2EQx;&rDwqVlmC9$@FodTVCc zhif?(DJ&l1%3-Q0IgiDN{*qB7Gx@INXH#?m#gJk#>pv8(`*o5Hm5~P`v76GGdp*pg zsU`CLYvq>Ig|}Ck%?S^;O4O2|&w~H^gZSxudUKDr>8DRT6jGu>s-*&J7-tY(VPgp^ zgd_a%(`ld3k4Zm!nHrdIc|X@Cs!#*_>NP62ucGWP>feoBc^`+mcshn%sNWlnc!|Gg zHM#<-=R*DP8AV24F&Zhy{3is86ieu*PQAs5j8wM%mL$<bTH(QrS(N8qDfid%5DkUX zs?Lr$UO3R!7abKR@|(o#f&L2Z_k@W%;C)ra@E#r5Xr)9$cbz#t-C#NvmXd0A!lqV! ziM~-MS3+tMW1w`hm4Zr3mQ!A`3sdcHs(**SO<5`?sh3uk=RL+bUO0B&JTZ^QIkr1m z3msz=cS~w~Xyg@=9Mexhih%f)pXQ0eY-mI|T+*1XwKO5y6P8y0scnEFG>h(5aadF7 zQOw4?-V~NQD6*wF`?ly2q}-n^4aq2Oh}Oa;i>qqEXlY;oJ}!v49>TNRIi4-RW;H|j zVY{{<ALm_)j`C-sh@#X>8vbg&$Rt-{D%b*3I4GRJTIf5m<&copz|MEQW+WS$oade| zse&yq&%}lrQ1zBkc8&o^TJz^TduFMY_E<8)l^5Zy!9UpGav%CA%ZUy=RU>k}Y+O>o z<c5U5{8LfXU6cXZiO3`={SPRq;Y#YZn~(*R%7H$uLM(xHHT}%DAW8aeC(LvJPlyQ5 zZ5)6bnM@yu!8Ptg9|8>_45?&)jel)Nrr2G89|w=M``&StUoV7Qlg>!pPyeivuq{dX zWMtX16%)ZMj5;sM4_S&T?j&<`liQ1MgDrMtHftb2@4c9R4sS?!UHm!3$_hPRASXym zg^JAnUGzDsnZH$Xf$HyCll7b|Xi+9*G%<&8!4_o$+RG@m18+ms`QDkT;Hz6}1(Z&W zP-;8cx4#=T2XH`5NpUw}y)tZ*yxKp>mX&o<OyNIVqFH7QJ2w8N9Zz$<F2@V#oPM3n z$!~ggRaTu-*an5`6>?&L+=7VmEiEM*wDq|QE$`}lJc_PF;|Ft4wd*ZrVwMGE3Xp?4 zC?ISLHNQ79PR9R^k@bgd70%@|H5hrmiSdgx&+%EpaDe<_>vdtbO!!PoK~^*zB3tjg z>nDm})SMvoKIi<tXM^v&0EwQ|yw<%`>|`CRp%Ff!n8#)nS1TEl8`4<Hj?p)>^7<Gp zpP`D0m=+)WUJUcrhrkqz-rTnwFGW%ypUVX4wblGx#@)nLAU2^x{adNG>+Sk<$w2Gd zTq#Q|kS6M!+fp#7j5mrU_l)w2`%9c<Thn;?q@ZFDZb50}1DYINmum#&(7-4GBLDNg zxn$}uj#QyBE3bNnzIIjl^p`25aB@@5@JiHpeA!+4_}6KNyTi%3%I=l)y_;d8l+4FQ z4erTC?C|ixBNvlXb7C;!d?<jap&(lLGC~-LEHc=O7V6;5tU|nWonpfvjzWV;vhcF= zk0O17ExnvAu%O_Y(oT-lWw~)f(0*$c6`k|9KHHJGh}y)^f~;-g(kq3$aJ(xU|01b{ z&+{$Y^@W$5VC5eT<ap|^4)ZKE)<pz?CZ6l5g&FPRLHPMeN7Y3qJ8s+W)AP=^@@Wra z4w@^OOPr8DA%m5E?44(ltDf{qxT+}~r~$2S(L6Rw5WdW<JQgqug%fU=jF&6qpjffb z7OR4Ff8ptoh^VS14RxQ5-iHev6Nv<(``2&F=-KG6SA^-aEoBPGFPZ{7I(UYTON{$u zriENWka>UI2^*WTTk(TfK`(1Ot!pbmG^=zw^2-7_IS?%*fz0R70oa7Rux{euZ^nc< zBp#;%Hy!veTGe0CAHn9|)9%c7M(9kmsGEC9B|Hx2gJCE`y!`kuBsw@&!JHR`^O%|W z3>bbzf<S^<L^@tgF?6P6Enn6b+1#sTjT&-CpklrD^LC!^xQ<dYe{vUg89vm4vJjiJ z(qu91xJAB?G<V5Crs;y0j|WwFc0AQrL8MGe&O9}yl`Af3SxH>zj|rq_sK3}MmPDQS zHcJEJ0KBPz=EIic^gG`@b{FZN^2N)qb*6JB>0wd@3<>$?OU~97i7H<b*t{-vn~Rh= z3`$7{6U}6!8VM5SniIXH7mEV>48-+QguTC+j_C&MLKcch#M;y;IGHjmG$s-seq<zO zuq9T-NZh9@FYqmUQjTaznH(f%mS_GD@&cg0kR}+5EQrU#X*=q|+?p!B-`6c6;&R2M zS4L1JT>FfKU)v?j-~0|=at;$$;P|lkq_?eF4QhwNRn41D`2*J+ydOjhC6v6I%|g8V zvu_&n7-Kwu$po8KvB{IAY8W{f{GAp(t;&UR{Wgm;x$MH+(Y3)I3Xw{&z=OQ%!Ij4r z{V9TuvGW=N4Lz949t%P%Hq^yGxG^jCihE7iVIH|^?6+VwVx{mce3hwbyd&uZq}Jan z2m|@PFWe-#St)-Gynfg1vKJ&OU%IIfh>xd6>Wu#+J;N({6;Vs8HOp_6HwizUI_>!$ zx<k$<n|r88n6|PVm4w5cp?rBl;sZFEoLY|cdX5}a-7nbb@9#l2vWKR*IwgAlGVBCr zpFLJ$z$Q&wRFZ*hY0)#VQ>Ki2V&sI5D;e=r%&Wm2mf{VkSe#no7vgZ_`gd`|O^MeG z_Fl;3e0weuN_oHY9Rk&p3W%H&G6uwpZQ6rpo4}?TO}Shi_v7W``zwL;98`bBzX0D) zso<Xiwy2MONnSc!mI(*VbO3k6BgHyjHd?TG9XY+7RhU=IIX5ijz(4E1T!V`Ug_C6h z@R>@I6Kwy|HGYx5H?UaeK~)Wii6tPe>Y^(7^Xhd$X;i5B5@Xe>cs1I7(}hMpo9v(d z3DL-+hw-*avasUre-w9?VRbFppAK%po!}0^HMqOG2e;q^*WeoT;O-8=okNgd!3l0b zLU2DgXHM?z+kLw`|Cwi=`7nIj&puVP*00v4YFE8`6~=V}(HDmd*^(aP(bS2S7ar?b z6#{^d10UU9x#HOH>m!G_Px{ISX2JJ)et6fmsuE+A9A5}W(Uc5zcZ7L8#nhgi3T#fr z<|k^!E4(V8znv)-E!CB`MeT!55w11}dW^C~Y@ZF?qA?4Ofx$L`7LpQu^V6<?SxO~W zFzVh%-?{`=8^la)FY^x6fgf6w@@6#iUXl`7q~Kb?`p`sBSCLfSRvS=xL+0V_0Du}a zP*78o^KG0VQmQW{l`u$SxZF3{nGs?fRY;Um&J6vm`BH0HobRoGFMM!w23s!8;4O~U zmH>U~R+m)BtR5nHe0}k1$>hh9L<`@$EBkolyE)w@Zb{q}E`w8Q8>>WgsE%A>c0E@U zUH4RYj?h+Lb<kj}^Ngae<%YL8^}xMKe8G2gn7+LkW!SPqRVlmkZ8`)ht|rwBsZBCQ z!L3)AB-wH5uAekq0A4Z!RLPH&i@9lv##vfZ%rro_Ld8ZMSu`|(Wag6%Z*sbbs&Xpj zk1RhB5|hcco{;o(ju^x$)+SJL#`be?9&S!$L`6Tute{k!_1__}sZudpL0@U$pdfjo zs@vSjWAZWM7^upxsGi6dAxxXnIjGsZ|GFqJ`Nh)|8Yb-n5qs4|aWim>keR#m1b=Kq zV;Om%wy?WfMJFHNA%IjyW3f!*GQZRYN=GCV(m-hOMCe&(huRzM^f_7s)DQ$xPCj#A zquRWIoDQ%7IW6{;t%D%L{6z2yNxI~*$F&H9lIjOQ-{FLdg`2SwnASAAA=f072k-+t zR!{q$m+#K5_l|R@{yUhZzp9Qj)#^kbrgc?cx6Rk?`u3Zf2*Jfd{t=T=KA!i&!9u<L zzN6;CEP3A&tLigfN~XuF1Y!(*j++aY;RK2#&nGA0W%St%m8~ifoCLan2FX{F;mGgF z`p+sv66TMLZ;!}SRftp!&cpg0J|R4(fC~UExyucW0-&PlE0Rzq9q~a5_FHM?pUb)( z)9R3HtFi9zBYKU3(_3tkdlE3mm+tfwbOYaG<cl}DKn$+MUDVMYF40oAN3zu5^$&of z9Bnd*iB<#J_8zo`l!)@OfD9b8VyaYPxgolN*I;Z+p!^c^NW>#$w^Xhl^6<m+k@Elx zHx-?LR5e(&co-qDBEC|5af}b~^`z7h_V+m_1<9!ct^>@-&r*Cw!{W=8KAD)Wu_!S~ zF;OmqiJ8hgTeX~jp0-49qeCV)ZnC1)(-2%+Xx;Bp=}@sp6*QRn)RVqM#-&>uNE}Zh zEXgY6;TujS4#nR_=tWCZfg}#6)5o=3Yp<2$o>Rhk=oKWP8QJH@=@bQ3uUW7ceblIK zV~5L`q(a|N9D+vZw4b1eYt^xPIsY;gk<(6pKUaLO`F%2G#aOBtJ2R40m8FdPR`Bjy z*Wwb&7B0jcuLNS~%(QT-l&+&u3S_=co1897WI5Z&qzVegrNnUamBQr-+6pdNfd=eM z%kMl0;o=0Su=W8XJ)H74sGn91+H?FZRo*@|dh8}7k;X4Bjb0|i7V6+2XyfFG#bN0r z6b&rWQJY~k7V7M9j3^~FNPb>JkVCjMDW<_rta5u(&rxl@qz<#I%2N_K50i26iia_e zr<8KuRXNS=wQcyyglBM4r3$*G<&I8ps*m8^PBKeM|6Tzbk?x2zE;DC+`ZXUmx#t^x z(;rM!#^#WBS##*Q^NB)-N~^8}hhxDiX!B3;mED9c6_Km-*75pDAvl8+)f!Ub^3r%O z3QIH~Gxbr*ySf<IuHzZs4!%S6DemUWC9^t#5zO_+*Sd}Dd0h_%uyB-C)2tXatczGB z#7}fHfWx4*bjgXkV)E=eC2AohZatSu86B;F;!jV}k5Kl#omDrZ`vGhyF<_&5+Mi6h zu90Qunx#_E)<Q5(89Ir1^(attLsP8LV*MR{)kYP=r5wxw9K%~xx;@6M;OmQvzNyUz z7uyX}zl=sW>z06*lcwtgg!lPPLV0IjSc$Jt3%MU#MZC!TN|hk5uN_YjE+y;K8DL+y z*szTY*yZ)fv630PAI0AXi1+lGE?u3?a<TC9%b?zOONet{vF=U<K8!Jj3QBO`PxbQi zohPTB(nmMR?vf>jVfW3<ve^8XYW5f<Pg8k0<4H^nl>PE15*I0{PxMBReJx1_?uksf zSKS9!i3mwVo3cJ56+eof-Z3uySq)mxl2-Np4TYjEWz<O@vAh#1wNs8#?lngq8x-x7 zCA2>Cohv8N`DO^Zyh36qA$aq1NM|;X4y%RhZRpC>O8#9ad;r56QUjKN1?<*#7Q&F> z&tuJpNun8p{O%RPMc4WV+9>p$^cnMuSry`*#2*vWP;t!ZG9K*){3VI%t!8UyTGz!i z+ftFg`Aml;dyH-d!!lGX6?-L=xK`pO^XOO(2iv1Y&OR<&c2U;Nyx~g`5@m5*ZNq#3 z9rr`9!FrR=!B7{Pj|7UFcyyxf+|BT={-@Q<Kaq0{iqVdcqXQDd6?d{SEndH1)i++> zE@&ql=OXxCK!576<B7LH8?pwMm^SB!Zf#EDG2(OBR8fJn@onyVqYQ+?k8_mJJS)q1 zJY|hdZCMw{$fe@@`3iVNSY8IZRdo$l?h}Ym`_?`Q9w8X?W6MM7!x(a)lMvZP1Y~_Q zNNMwH1}A}lhkY8UYhNhRyjyC<Q?4oq^|R;n35!wPoD9<~cle|VwM@B#0L_BU4FIM6 z&{_DD*-lSDpn_KAxP!{rGxcl9EveeAQAZI<)Ogv2E$f{)4$13xokQIyu5TD1gE!>A z5~PoMVIAS1X*0dvv>v`tX7M5E4{kzybf9z$6pg7;N^`b)5FU9244h-K9q-w{mDB9X ziGRPdc)HH3&h`$}ghsNmj{JB__-k!K0J@g`k+%|1%lIf{6!)s&%+vq2uq>|><wDa_ zD11CCcypspVJQsZJM~`;r$b7?LBHd!k)s}bYI*olK)VOSm3@!39(0qiEsWi>)T6WP z<1`mbCXaUCBcK(f#u*qYU>txZF&vZ!XvH5LZvE4VpmnM#pq6b4dN3(yUx>($4&oG~ z?mup>?!bm_om8BM3NT#YqmvEY745!hyrc4a*i(VhA7N-&$6mk9>b6}2OWb^3S;-o| zb-Fmsj+y*b-X>lY$g}TMH|3Y}r%->^ng1Tz%m`efkmkHQifdSMT};_FtFKXioqP7> zv@)b;;&hndj~}j4_0m2yp^T&I_HO{KZ#!>lomu{77#kENIgXF^!JXEhc6PunU+VcD zVtuEx{%6np<piXg*j^xh>Jxd<BN+huZ<YLY5eKXL-IKa657HC=>e;`_zkC5*eTM#l zHIXdn@1Fe8|IVD)1Y9;4`uh5gKV;f!8(IxvRs|7^(_UI<=zaH;4y%Lgs{BjNKLRFQ zv<{Pi-;G|8c9yx~3dIum{0_y>ozpj)*LVNarI;z<zZ&U<HKbs(Uu2tGSSTtjg<q&m zgkO~rv;VlTBKGy@LHM44ws!R7+yI?2-Cw40h%5q*<G&mI<yi2GtgB1k|1{?hmrY(^ znS=$t2&j0J{abwfOAFoad!GLY_`hWN)0y5E)V;{y7mw<9iT{4%A4-1en>_uUsBA${ zvLA4tp>9hqsD7gnq)1ra#_!#LNqF)45#~%`SH|pjecg*J0)+Xk6Y%6v@u1v_o}Qj= z`ksk@2Kk?k{KLpAjv&+i@OHN$u?6}zKnOPH?`?R>g=l&6P#1|Z{JVO3-60{<IT8E* z-|GJB;^z${+)i(dG4X%b*XbV)e??;s;oiUG|C_W^r%0drHco+*Eabn5Rmj!><n}2) z=fvmV*_FQm(G|E6C}k-6Ut#%6fLh4y4R@>Tkl(~$x+svY7da3Jj&A$#TX_xXHoyDe z{SOUWBmIir`g+WTTBAd~&qe@`cgnK(8X$WP-pS8r-^K?9w3dWkrx&9_f>;6)#9_Y+ z;@)vtQThjQZmp_s&hHOjNw`Uz8J%fdKWBTP*zR2G7;HZMJR;S(e+b_hzlC?r_|4P2 z8IFO$`QTr1w2=^TP^j7}*30I}J4Xa^W0zS-uJ7A$*UnD*jqi>xpx8S%bT}V)H(d<z z7y!?yuhWbs7WCk?Zw06Z&VMV1qc|i6HbRYw@H2i>W4fsIuONuU82W95wILy0^`P`+ zVPO4RX<n+J(o$N(4mTv7TEoxZs&q<ZoIO1w%F5`%UlKyIvespt*SCHB)0`<ONc*0c zICPK+-d~Sy@3^=q{XHrwYMa;3w&FP*r#nRJ*5j0xE~275G@!9?9Jqw5RVfG7$;nCk z(J7TRb!_BU7&#|Go;Vih?VW#C1Q~lKVHIhyzivFUW}>BjuDN5Bo<ItR@D5uS?Uy#~ zesI`teb6ce{LaQB^74HKI|(lRxN2|XQ(O8ECMO3i|6$Vt(iu^}LWT?4-=gtK0a|=X z1Tvlq=YSOE{MC`K{crl7eBTlraS{iJWQEngCrbwS-xR#X;YCa95e}KOen#H&025L= zsld)I@uw5zC~zzgsu?E4Jd#N7Avq3wtFoH=_Kgqh#Ni2nU`{OyZ%^HglsCM^Ib#uG z5<7*-EQJ_aE?>Pzh8W>U0liRnX3Sl%Wy2tkJ_9bobuL)%I!~{gFL2i48D&|$HIVaU zTlnl%jstI|2D)isIzYpveE)!Izd4agc{y%2H==OmvtRIV>cHc;8d;X>gB<v{P4?hK z(Qff;l4Q~&n&WKEAO15xdB)_&eKoj;Vw{^^S^3LbbBWfmMZbD64gvhqy?HQx)=J94 z*`Vpsb9Kkst$~nu`nmDr0`Hr4FI<)dqXet7*Sxp|-}8(Mt!|+F&VP`(@E;0gueFnH z<UC>-J1vJhfy-X%On_OHX>0muMyV_eb;_BfKe6C^u6pS(nEp`~?W5#HgBu{g2u1E& zkBKBGD~z}4)J#|}a%xI-4;+HB>)OOz(~ryfuGNaF^(edl`XsP=F*kO&&7$~}K5XA? z0fDq5P-bET))5stjn(+e#qL<W{Srs|c=zSXg<jLkZ8X0imq0#d5`R4AQ{GQ?W5pYO zl?2n6BhH$poF2qM&u`r~L@eG5$@5r=<Ksr;f3G}Ak>QdI<l3^1Dcsy%?}_XvnoEBa zY*q?xVn)E^9+c^LYpfT1WGUK5%-o@u$#Zecn*ORP;pxFI^oh(r!m@diGhtHX`PRG( z{QTf`F+vBKoI>xb4U+fv_&>1u-@6?)_n4X73w%6CyQAE%Na$gN-bh2bVu#|4#t)@z zHjF52AhwBEjy4xIik*toWZLj}$sWX8_@!wrX0k)Bcl84+{b3|6d&=Q)M?xz6(fx6i zO%>k3dFM-#Y<E>6pio>l*$WZxsY%<e!!QC}zJYwX@lFFa`)E9TG?Kwh%>F5}p4hSC zDdWcmWQi}aedOq#{B_}J8#Y|LM!|c>S8L5pCXl(>){HS2{_D&3+UrM4-)PHnxG=V! zV!P{3X?HkzdY-x~Sn=@JM-_P~cMn*hQ33O)_xzgMMb5>V=#c@$sFaoAm8$jraFl7d zl(yJ<f^OG6Js;{S$0`I;-mQvp<&O17Z8(eMYv^V}whKf}&bI~^J3Z?F+mP9^*Itfi z{=w&sT5~nQp>?>^jQ6VSmjQ;H#(m`9Fjl*-S<AsDRJGZ6%k18Dfjx1A&-^>cmVx@n z9;u}LQP_0O)oyf2&_O$qZkpE-dW0pd`L4jYQ9awp1A+T@6RE?a&rQ@gdb-3+;-gID z*Lwx@&FQvLW~xKi{PMGE4D`yfpzfdxfp&k97&K&Laa~=4foZG(@?PK5?uaC;>D`|z zs&-$cK=aEz%&;3dzdB9p1N{f6L{{9^TuY4uhZN45y7;lLtvZOSJZLeT92gIqBx@2g zGl=XumBzPZw?@nI)rN9lW89%BP>T1taO}0cNRKZKAx3-g(v!pxo8V7j*q$UrFx~Z; zKNoE7`bK|74O#zHD>i|UHT>g`@$g@Bcl?v^&IOFvm;S099x7WJSP@^S0#Bcrz~2~g zYT3yhE#V7gn^{{tK8TK?G_ad`G`PjDmLx_RY6ZXB&rD}xC-<)P8|T1>WMLQHqEqaJ zJqJf=0st$DL7~-N4K_><)H-oxTos3?Y|RTd#rKjzhRk>L7&5qeL>5U6tbpUth!j)! zOhA+$B{r@H#{rs^!fq3w;yOnB$E|>Cjb*Igd{3+u!FsIBeJ-*AUIYJl043>QnH-VJ zXn+}X61wVDkfEe<B%p3Z{gMze(65n`TU|CbG++=CMnS&eQo#C^V@mc|fp@??Q>t;2 ze*7l_&QZj}F_p<3Q`}Z#8;jNB;9k$mHJ#h>JymHM^ccht=?ozq)CKaBweewHl5mQx z3dt~#J!SOBSD_odPWcBw%?wW-v&2b*S4m%7F>>dCwrL(W`E!&#fl3NRGNZKGzKn2I z)J&s?7VG#sd|>jss<RDShZ#sVYkhN<sz*!EovobszVH70<N27Ysb=bn;g89e;j)6c zG=OJ@MCE?fD{bf|JUn6jff~l(II|0MeMUdEOh$nHWRtLym6+b4vZFcNh0c<fg(E)T z7<v$2f18ecEjP`G!{wc@R<NX5s;#Xve-g7Uwn0B#nCtd+wZz_v7-;<LJW+;J%+itu z5`s)2FVF?gKI|qP+jtw>%7xoRmfSdKv3JMQ60Fzz(VbuUYwKOQ-9D>VR!R)YYFb30 z#aJF2GmM}ZON%_aa_kk|UXi4F7GFQ4+W;jc7>vKu!SDJjhfbfAnK0wzu?1x%{=v+d zKREFD!v3C`+o&s_d~H9DI;BSP^C;Y}#vGbU0(diyN-sc{waH6xW7a>xp-Cj?O{Ecd z4m1hm%hcz`+uq}aCS_!`)m}Q}aXGR9$PD<$Z08+opVZ@ZqHFa9V2aMRyW-TPo#thC zL4^ZXn4!ucK%LWx^hcu%BIT38W%T$l4*d!9(|bW;o>cRjEOBYl$}s0kZ@@9bC!vL0 z4z9(KpSMW9blUdad%N5?=2X^OpnOq3aJ$VaVbKuxa&+ChF=dW5Ffo-xscJUfwamxc zg*=b^-6TTK#MXdB5Qrg&0Q;Q2tLH|@_7(61u&ntuSlp%{uSjN&lKkfBX<j(4RXU<c z%9L6=CL!_-wc>2=&`D;17;#dmU9ntDrO-a2if+*Io$c@nAOv<p{GCFGnxDMm)xoP+ zwnt&YX(Y?x;c*FgJp`QED4EJ;=Hv1!+8rQ)lEh1-#nxe-M#s{Y^<U`RiDhLSOHoNC zC?!(KagDSA!;77yl17hyIZ5hCNKHBuQDfOsqaCV5K2UvBO1sR`P3diEarnuC?c956 zqGa&|lZC*hwV=sgGo-K&s-V2UXYZrsW`%|`$k8%2TKi;NHVLl_JF;8M!dnz<J2Pt) z`Z^NJACQ5g#_CoIskHJrwfvUnJ@4Eww66G<in}tTMbH7{cl){kI7@QYG-<!F3UO*q zC0p1OoOIj{n_~R(gl0=lS8a9oc3R941Iyi^RoQ55Z(2&KLbX?7ca5`~zT~g!`72e! z;3(kwD+y)a>cljyQsP*$%6#vz2<<WB#K1v8;V@PVzr)uX_O)vVi0G%Gc+af;@4z`X z^FuQISuJI<)P#o9H|`>uF~r+Q7UR?O6%|}pf>BMyf-ywpyO;hW10qCA5j!)HP)S$$ z&ee<M;>!kn!@7l1pc$J1eEJ-H8ew;H=2T!xL>F&Tf<jlYu}gXl-c!LWO*(O6WRf?b zY`WadsbtGQ=HeH+V(~_5R6>=}kG0qA2*YwFCiz^582I=LpeUwOJ42?*A6680?pU#} zk}<1Mr5~fncXq5OOjDs=Z)p#s`p})S4qupmnY}tu8WWvvp=3g*FD8stCHQ$r%F7Hd zX8{GB2@~}oGN+a<>Jf}1uRU^V4&5Ek=Tw{@&CgYUl5!m;f2WSUW$N`N41T_lt+FVf zo;nfC78IcT;o#yEZ}g`-%Sy0uI>nQ4c53?n5xbKBZs=D|_8V({jS;AT$yrZk^-%m^ z_47~c?w03&!tONq#`h*gx$?NbgbZWDz;b!1b^$r1GC{E-jjz1tz7EX@QI=IeG)M6T zsy-qnhy(4p8SYB+$UVM`f6YR}aWahQn%v>wYUL`pSLDt-23&tZNhyC8*K1vOh5H5; zj0;{xr6((s`I@XzP@1cL7g0wA-iH4gV;no^lKAG)MfmL+qx?o-U&?Duk7b|Ji!dKU zNOs<WF6ce<CsZqaNSdXKFwf`GN+QErq4*!+(q0Kvz_Go<^JC-qK1tiUByVsE3T^h% zBW7szN$3poey?D=NO4YvSmVOKs5?zV2z7Ubh*A;|-p{?(?;1!04l^`SBaMC=Ek4}O zPyO^AQ5||D*e$sxkPaS=hioKRm<#rPFOABasJY-vrE-h|lk8wLjOdqI*jp6rK)VD1 zmzoaOnFwv2>WXf<!2+8FF$*DV_+tPYwOTh76K<^WYa$W<Bm)nc7=Pd6+HemaoWAm} zJ?Q%wJQ|9*dQz82kb#i}>l6Kh+41vmA{B+z^<cx>f~(1Nb{JORV?zv<k;>aY$vc?^ znrlC_hl3%h{dbZnI|W=!+6QiGxGFymG#S8@XzcGL^6^s2DUCWUIJF`h^xTKhIFaSP z*HJ21eXL{MYQZx-@7BMKymynw;90Pw%CvI$dRacBBfe@NkVpy8My4ovE>0Mt1Sc~V zOWpRlrB*)D$VDRKQZeZhbHWxBngRgYkC@vHa_tC-7>rr=wyo)ykC*D%MfZz)!BA0f zHQUS=@6oBr9ZFZ<l+?aXC8ATR3Kwe+%dk4C_>LIkyiP8AI0z_~w{><l2<}q;IBLHx zOEsTzElZi)z<)r$JHv^SV##2)ZgZTqEXn!#t~@;}+*DmK!JK?7Xj<F`(PPG;f1@@b zRdpCOCK{exK0!FG8tsSm=Awd+ZBrNL(z86FMhmgnDj;Ap@bhT(o-!oST3G8wz$*x7 zSEotXlvv$W*FhsC>nl%MzhY<*{F9qv@uqZ@eaf~Wp*zcJzCvtaeRkwwg?wF@87p^s z_D0tFFpeUU*dUHb{&f9`|7S(eWOZ>IGD(Rj3~bb^AVAF!h9~e*c%QbMJt%;cCdKDx z3aebF4QF(2E3-xd=cO^N^CcD=Zzaan+@Y)&GtT+s&em|tn>X{~G>2g{T1hp}es5rj z+^GxG@Cq3z2bb$xzL`>-@usOg=}`#492N(->jO8sWPQtaiV<5;17r;eV_vXE69)3C zdK27z+k>*U0!PmnT|JxrG&f^!@5vTy-RnQ|rqX_<#SxcWA;-DYyFGRap!7v0IF0xL zGj?{@`Z1k*;?)q?fZoXCeEZAyxB|OGshc-s#br068dyt}msI`<brRmRz#*)9uhORV z&>7xsb)ijsb`*{M0JrczDZMzS3hIu@XTy?=$Ho<qyum4w(_^1lo^BcI*V`dhe{FTp zVSH-;xI!4iX-{+kRKOdR(dP{CJ;65vPYB??aWf9;z6DL@nO$9k)r!azVTR3>&9JB` zte=TBNhvT^6H5T1(5tW~tfE!#H63cFH$8Br${uAMBX)z!F*(xgX<KJJLN4I$UrD)t z*bPC>w55FO0lTOiAIn4*W4k7Nhoak!eeb)hV41jduEwnobFBt*vGA%w#jfzVd{Dh^ z+pk4;SI!>YB4&#d^5Wg0*!y+~KLd?Sm}wVR9{A}9_wdG-%CM6OoK6zo?Q%MCGF>@- z$W(9M#rWTNUi8<GV1Rf|!<(v0T>nTV?}$9@hL`0I<n%=q!95D7{U?3|uParhsK%Oy z_E~DeCFN>zEYbqKF!-Dc)mEjUwBR;GnPcrJ*-a5X)=r>?umAuS0ue!!DW?QZcGFt# zV)Pa9e3D*rFlSlp(OR`>rv{2W&n$CEYRipAAqc_bFup&Ft4GU|v&skN*ikV83D&gK zzs+92QZV=$@iQT}MOht)4TMHz5a$Jx%PpmG+FH?W4k+FkvbZ3b?L`aDv1<eTF;_C| z^@7nTwrgt2{A^!5KK+Kq`>wHXGyfTDuIEZ=O5n#~t>O7MSz50dI(_WL>4z#JmL4gp z!Vo%g`v9yyn^f_#+0hVF9CKRM9m+^x;R*#XUUZ*~L(T3kZUj~j_Tl(rA!+j|tlact z1QKM0TUd56CCFZXJIgR%VxH?^qK(jsXmv0N!>*H(RQDAT+?0<g_P2Ofp-QPKp%(tH zfmjSLxVCATCbV+q>hN|YhIgHXU`x#aS{gUK<xz7cWR3}6cS!_@Nn5?m=iP+Haer=l zX0`26D_#L%#@3np&KuFq4{U~rDN+41%w?8Q;l0F#)XGy`iS?9VFmINd8;f(}^W^ct z49v`*x=GZ-J-~|HD)gY#O{mlQfb#WvypcSWzUqo(%r^m)kJ1KYb4i~j;~`6n$yOJ2 zZ#xuAAGr#*co0uQgxH7IP#qcheLDI-W`o-Q!RIB7ggBopxQ5*(#h!$A%$VIbtLb9L zOmXudeSwgUSw)I&i!7dAw9PRftrsP!Pmr^7)T2f9W1Q%uX#CqjI!Ba)``?hO$u~68 zQ^UEcc1t8OdB|4Mbnca@%@zm?Xky@>yil9C?p50iD5s>Ocf}uOm>6y$3&kH^!!B!p z!eYt5w?kf_C2J#0UXxjVJEhL&PdW??4B`-Kv;-SWWroq1qqvn;x=m)ionJ(nUf%Q# z^cHh}RsZ|96iN=RR87iEIHO{jDGB|#Ok`6Vdm~txG9|{CaK#bv0F6+8Q+R@5B!lmE zBg4gZ`lj%pE(fF3v*G0cayri%deyt5oz^^+D3_ZOHB0gb4fU_tT;zS4q;tp>OvvVC z(C&ycQ|0wEIXp08nD0j1I@c?S^eJ=5!uVxRY|cK@iAeOhb$)(H^u|V|MM`~P%ywiN z9G}JOlnNOu=ZWJ*p4)bs%DUARa$>blL)vEeb?e+f&-bC51Bw1@kF8KN0>jyP9<lkY z*xox+>;unsw#=kNM4Juc`ZkH|aw_v|7j>l$cf=f*GPKUv*s5zqGSl*|+09IZ?G{_7 z1Wh1^sK{cdh565EkQ44-H^7_Nz3)&(Sl<1ik%ok5=~vDX!D0<ca5J+6t*YhTym_Sd zGpwqDobw_{4zMJsG3$8#JXxegqb%<vaMK!XpOc#lor^Y)vfYQjEkc`8^myw+yq+4r z2KaJXA7%kJ)$ypE=<mFBauUaWzn^|K^ImoBI}G|o#FVby$IN(eY+w%vt(J$!R33!f zHhVp}SJvw=Z1VC!j657cr7ve%WH5cmS~mAy<&gzJJk{=V=o2_Y;m0H4n+s$;yl96{ zHma+B9T=Z?fIsTWwrp-_AIQpb+-%PbmI_r2OY0=u`jJPBA<3ZI#y%?w<H%-!zP-*f z5&=}Bm1%+0cAOYS=Z6~bsZ#ns=reXmGgK~VS`sQXsmMXjvT2O<sbf8roYo-Xi>tn} z`G3LeS`=rd=*tjkZ9%K;_Az}HJ*raC3=TsU#mDJ@mXaNfN){5FXO4>woUNkr-XF*~ zWaK3L!4?|3hB|sE+{`qQ6xjRe8^s0o{(>1MX@w{4^PFN6@|3SzcQkjKkd_~EhVe6q zUlsBMYfdF?izl##Y?Z|~J5CKp%~==;)~KvSR3n0U_()jGZItla2|^uNGAi_z^&64g zo4C~Mk-TinO$=m}%grz>7)}^qTv2Hs?StNCOe0E!v%_dr(ariis@|R!awIP4TQ@Lo zM44y6_wC`o^@56u>LYHmmasmpTQu&PSB&%iF(3<`WdTgP;<)0tRijaukxPrVUh>-$ z+E4#1#Bi@GsWxN1fcHXg_4`4EO|S^tbUE|MPlZjpi*`k6<jJwJfB0aqt@$ObzGIPQ zU^yf9ydus59CgmOAR~;=az5#z<JR7~mp%BY)wvM@OvoPne6~hl$;@mIA+KI@0#*Op z*IZ9odEbyXSL99kWnIL{Yo^Ph@loB{B=(`Wd2?FR5hHONBosJ_WL+*VP$YlmT#5LR zGTu0e&d0G%v6&NhF<UxiHR{xP`S|q36Q3bjZupnuhb!g2U<FTGkX$m?jMt^%JQa98 zOy5RC;5Z52FvG_aqmiv?z624XdFs!Kq%%Q123ABQ?5dL^e684-A>YT%u^*IPS&zy` z_6s%Ko?iBKbu9-jI8sVSr+~}DQLb~VUv0b=!*W0>N17rJhkv9rj*;x(M^{6yFkMZJ z(mZ%5v7dIo@b&xypkrpvfZ;>tB4G~u+_5KA0#t(eE+E=6O0|JuccIoq9_qnCPfMnv zi9X)&nki={TGNirky&zILhX#^Fi5c;Fhdjy$LO!y3Vnw?i{V;^G^j8Di~k2P8vVg} zYC+r}I#<ve>-xH{O98F=$Ut^6Zq2du7e1Gi{C9kgUNEU_<{Rwer!l*tm-OrU(b4kD zSt;{2w0NqZf)?oL#;;`|i3&I6^sG{}e70Ed8tjmClkYr_j|g`(nX$h#78B$Z%KIS< zs%N)Knc_h&qd@Jjr&{gJ?tPh&ZRqy%X^{!@dslr#R2VJ4m#fd+I2`e!L@6$viI1(C zUjc@&Z$*gVq0cw)FM#f3S0o2V!l&b`y$SpCKELL){^RY)XGBzHfFI^<L{jprcPxb= zvug5a9*l8Vr;%P3Nye}%mr46}jmRA(JFTJsTOPjjA}GLwMa%<w)2Bdi04yx5TcB?N z3zRMR$>A129QHGK|AD<vax`!s%Jqi?2e+q>LQ=%^1>OgK7+-l?K9p|!E&pOPaYp~L z!KQiONp+$}YkI7z5q&IvrzU~&04)3qC~h<DT{*kJ!G7I$ifgQ?$+<1i^;I@br?a^9 zC2Pukwn!Nsa@p=YwcQc;9FBwhpM*%e1C8jH9QsM1lJ~bvA8m(BH5YjEzaQb#uP4YX zl$sevkjmwZVS6IJ{779EAmw!<k>p(qZ=Apj<|YHo-ap7t!)<K0U{VsUuenSS0X+<w z33w04(?8i=AL_Qa@uoD&F<Rg46|X6G!Mt5Nk#cwLbd*LV<dD3m1&_--nBG6g`CUgq zUks?sV}%T#nVpyu2wx-2WImzfJ;5BvZQH)?ac)Be^#U(M05kJs0zv<c#|?k&aX%9y z;IsI`AF@-eN4n_o;TStK_KqKrfW37lMKj~{_5p`a{VkSM=bJ%FKM(4pKqpaokaAT# z#oh{Yvf&_6tvjKO9@X_&u`@YI{f;buZuZ9LKLEL?>I;va6v>IKk$(YlVYCHI%?V6> z#s@`}7$8@)zR~xX50Y=LSFkXf_&?q|UokQ~O(ujjX<c(sX;Vv53R2?zfaB9u_Mg!H zTmW2Qj1z%uEKgBD)k_CH-CuiMj`P-V-ia^a+kqa;tOBP@+8y5udfZ}k<18H$2DI5F zxH@y5DRQY}ha%U8+9EA>F^+Uh%w)KC^-?fOn)YP04t&t9oO&+>(%bR&OC%hj!_k`& zi->J?lPyK&T)XhLgBLF*R0(u-P7PXaZ)(n9F__@`4@52%h{0Y=CWQtyuF#(-|JHLC zBP`pfNqK$7iUC!PgIkhDV8+?rKyxJ^NIs;AN}xaw&+)wpedx4k?~k-2fg-n!7h~Ni zE0APXJKsd?bkG>~&m(hc1kp$6eWYAWq|OO<=w0CW<YWECDQVeX`$(}QuTnE8;H>&5 zxUr#2>vLeX%ST|p?&nWs=2k_s)QI+KBpn?cu7hR1{<SaE`H1MSp<}k?tfqP}`f$kU z&+cyaF7eTf8sLPDwDVC=p55*$11CJR*WSn4Ozn~5m_v*+O|8j2DQbOaC(j^2<Eq<1 zA$dbaKXXeJz#@WAX93NN3w7XSJ<X%V`F6O)BUdYkeNpDydTg7fq$41Y_MIG>I!?qV zWn3f%LcJG}smkBvo){sS5m~SjB3O47a~&r-aNLyEChXap>%f>BkqhsUKc!kZ?Xuyb zq&;=IgVtQXc~E=zpj{kgvc%UHP-E9vD1W@gy^4n&rnDjaoOIXt$;upao)jz1?N%$B z^wskaPoxQ0QJuCG^Pjw2yG|;n3m%>bp~blplkwp;@a@~T1Vnx!EXM+|tnXVkzs|>h z=llAKN`ef~vIl{B!a{n)n!>>#gwv48ri!En<5n<R?OYc1WnkTh`ZI%Ea)-p9quVxr z0&{%-1m=dKuzeyz-~BWmEdYbkv=F_+Em&l!z|eD@m}8zeQu@Ug@xy&cru(+|T<?GC z3s@$M$u#gIB^gtj6Awhg%9-Z-=B7K26bC<})0r@IN5k)jk-bh_RNTdOS6wA$e=34) z7*))_auFxECN9C1dr#lZozUgZ<2^s-*9ZBmBMr%>d1q|gW*`Jzn4Vb*%jz)%0sX}F zIahXZ#tYL?!YSlOva4M7cKz;_r;tn5VG?^JahG8y3lc!DS|ja%Q@^gnvH3$oOp;t8 zyGGc_8zL&!SM*<$IqnuBA0oNXQhKh@3j3Nh%jaWQvYRyE@u_9y#%Tvbm{K%q(wo{D zCGaflM6-VYbN6t)Q@)H;96`CdY=8N<*y==9s*)E6hl01-R(5c-RzJ{sbHe|6>DmuC zv2)H%%chDrbsD7))85mn@xsuj3&PI1Madb{(k9>0HmbG>$xXZQ*i3!wYmO8gzlvA9 z%Ng&R70TLACAC<0JpZ~;(#iFj!?!%$sG|%eDl;?l%t(nu*muV^eg7wqBwS$T2>7|r z#`dr~d7m#SqzCf(UXYJGd5^yFhsqnz&4WQ&TW4|`Cl<#A*ft2VqAIC8`fJDECapJv z)0)lWXwhT)n2J_0-Q>w8W9bw!wZQPhf7BQ7YFr;$8i|AhemT2~+Cgk~DBB@A&UK~m zB$?Rj-eszqwfnYHVdS}ESLU+Ty-0-|=;^esCwy7qx2({EAvF}eeB!d9XBGE1SgV&= zx>ph#eb0lWcSNerTsHwP2&go0jBex7ypJ_uzd`4{eG{KaQ?tZNA`%tddg&5J{IK>Q zjVwK$J6~YFV(^kigaxvG*R9x#8wQrf9oGs2mW{bk;XDWMOEVW~tX&iEI3WiI0$D6o z0>_!+|Au6&QRQpTW=g8BI(0e`zjGB9?XO_{PU3~mcf$ES>)5xX=0<w_wqPg!v{NI6 zfcsLW?Q(aIEtL3H&b!moU}Er3&gKaq3f3;s5=Qh{EXyn0Hv1dXln;h{;@MgD1^c9U z*jUhycrQM6`|FSRv*SDwI2)R>MIRNfZ6{EvI~aWp0Rmp|Y@Y@iQ>8`zvT%wD_P$vz zFkX-X#q@6!2eZ6r^&!x0AFI}|J-DhVR}<D#-;VJQ8ZWF-1nn245%etjLFwMsZt2yh zaA4r%*-Zp*{Mg^nub?cz2i2c?6+k(lAPWyfIK_Mf#5b2lD==nyu)i+<LPq|<Mg1}+ z?H944aPQ~zH(KK_rpW}FHxdGg@FFtBSpHuZ?#&kqnVOk#9ZRxlhi~&nJ2~I84g(G) zHvEoKat`aADv&Vc$Z(V^F{Xd`h5b}z?ISm^lvuJp8TAcBLBjVx?cbEfB;c_pI5<31 z)6fX9Tabl0DI#zAa(GN`1@b5Je~GPQd2?HXfcdqjXlXmXANn`s&@Vm2Ad=n*{b%KW zD8=>y!d;2H0BQzM|5lyqvrB=$$^S2^?EedSw+V)k?_LMjd?@gJ0r``YQkJY0Hwpe< D(*ymN literal 0 HcmV?d00001 diff --git a/doc/ci/img/pipeline_schedules_new_form.png b/doc/ci/img/pipeline_schedules_new_form.png new file mode 100644 index 0000000000000000000000000000000000000000..ea5394fa8a685e582f0c649edb6cd9309f4e2845 GIT binary patch literal 49873 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 Gul^4(TZ9Jy literal 0 HcmV?d00001 diff --git a/doc/ci/pipeline_schedules.md b/doc/ci/pipeline_schedules.md new file mode 100644 index 0000000000000..0a9b0e7173f39 --- /dev/null +++ b/doc/ci/pipeline_schedules.md @@ -0,0 +1,40 @@ +# Pipeline Schedules + +> **Note**: +- This feature was introduced in 9.1 as [Trigger Schedule][ce-105533] +- In 9.2, the feature was [renamed to Pipeline Schedule][ce-10853] + +Pipeline schedules can be used to run pipelines only once, or for example every +month on the 22nd for a certain branch. + +## Using Pipeline Schedules + +In order to schedule pipelines, navigate to your their pages **Pipelines âž” Schedules** +and click the **New Schedule** button. + +![New Schedule Form](img/pipeline_schedules_new_form.png) + +After entering the form, hit **Save Schedule** for the changes to have effect. +You can check a next execution date of the scheduled trigger, which is automatically calculated by a server. + +## Taking ownership + +![Schedules list](img/pipeline_schedules_list.png) + +Pipelines are executed as a user, which owns a schedule. This influences what +projects and other resources the pipeline has access to. If a user does not own +a pipeline, you can take ownership by clicking the **Take ownership** button. +The next time a pipeline is scheduled, your credentials will be used. + +> **Notes**: +- Those pipelines won't be executed precicely. Because schedules are handled by +Sidekiq, which runs according to its interval. For exmaple, if you set a schedule to +create a pipeline every minute (`* * * * *`) and the Sidekiq worker performs 00:00 +and 12:00 o'clock every day (`0 */12 * * *`), only 2 pipelines will be created per day. +To change the Sidekiq worker's frequency, you have to edit the `trigger_schedule_worker_cron` +value in your `gitlab.rb` and restart GitLab. The Sidekiq worker's configuration +on GiLab.com is able to be looked up at [here](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/config/gitlab.yml.example#L185). +- Cron notation is parsed by [Rufus-Scheduler](https://github.com/jmettraux/rufus-scheduler). + +[ce-10533]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10533 +[ce-10853]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10853 diff --git a/doc/ci/triggers/README.md b/doc/ci/triggers/README.md index 5f611314d099b..1251313cd142b 100644 --- a/doc/ci/triggers/README.md +++ b/doc/ci/triggers/README.md @@ -216,42 +216,4 @@ You can add the following webhook to another project in order to trigger a job: https://gitlab.example.com/api/v4/projects/9/ref/master/trigger/pipeline?token=TOKEN&variables[UPLOAD_TO_S3]=true ``` -### Using cron to trigger nightly jobs - -Whether you craft a script or just run cURL directly, you can trigger jobs -in conjunction with cron. The example below triggers a job on the `master` -branch of project with ID `9` every night at `00:30`: - -```bash -30 0 * * * curl --request POST --form token=TOKEN --form ref=master https://gitlab.example.com/api/v4/projects/9/trigger/pipeline -``` - [ci-229]: https://gitlab.com/gitlab-org/gitlab-ci/merge_requests/229 - -## Using scheduled triggers - -> [Introduced][ci-10533] in GitLab CE 9.1 as experimental. - -In order to schedule a trigger, navigate to your project's **Settings âž” CI/CD Pipelines âž” Triggers** and edit an existing trigger token. - -![Triggers Schedule edit](img/trigger_schedule_edit.png) - -To set up a scheduled trigger: - -1. Check the **Schedule trigger (experimental)** checkbox -1. Enter a cron value for the frequency of the trigger ([learn more about cron notation](http://www.nncron.ru/help/EN/working/cron-format.htm)) -1. Enter the timezone of the cron trigger ([see a list of timezones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)) -1. Enter the branch or tag that the trigger will target -1. Hit **Save trigger** for the changes to take effect - -![Triggers Schedule create](img/trigger_schedule_create.png) - -You can check a next execution date of the scheduled trigger, which is automatically calculated by a server. - -![Triggers Schedule create](img/trigger_schedule_updated_next_run_at.png) - -> **Notes**: -- Those triggers won't be executed precicely. Because scheduled triggers are handled by Sidekiq, which runs according to its interval. For exmaple, if you set a trigger to be executed every minute (`* * * * *`) and the Sidekiq worker performs 00:00 and 12:00 o'clock every day (`0 */12 * * *`), then your trigger will be executed only 00:00 and 12:00 o'clock every day. To change the Sidekiq worker's frequency, you have to edit the `trigger_schedule_worker` value in `config/gitlab.yml` and restart GitLab. The Sidekiq worker's configuration on GiLab.com is able to be looked up at [here](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/config/gitlab.yml.example#L185). -- Cron notation is parsed by [Rufus-Scheduler](https://github.com/jmettraux/rufus-scheduler). - -[ci-10533]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10533 diff --git a/doc/ci/triggers/img/trigger_schedule_create.png b/doc/ci/triggers/img/trigger_schedule_create.png deleted file mode 100644 index 3cfdc00b7a70cc4d5b9b134c87a2cf45d16a330c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34264 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 diff --git a/doc/ci/triggers/img/trigger_schedule_edit.png b/doc/ci/triggers/img/trigger_schedule_edit.png deleted file mode 100644 index 647eac0a5d0f7be6527c8cb491204905ca2e0a79..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18524 zcmd?RV|%5~5-&WlZQHhO+fF7nS8O{ITNB%wSTnJciEZ0Cnf>4UY@X)}JnO}}?z^h1 zs=KPXYxS@DS`mu!5^&Ji&|kiMfs>LHRsQk?B=Ymw5EA_JTc<rb;L8^vZ%YvoMJW*x zLPaNgGfNxOFJCAVObiXFrD!RC8W|ZH{v4;Ff_4HZhlfWi8+P~ijQ8{t4j2v+=A`NB zZek*C_WPrFcMSDF7BHLyj!ECVddwC#dB3Bx*Q7hClX17+1poo0tsI#_Ku93sv$K@5 zvS7X*eEH4&3rvhm@E(>>r{%}@F9ZBQU!zcg@F2f#0M*SBogxsrA*YQG0IQ=Q@4|kS z`SKfB3b7eHJR9Cf$A9S;Rs@w`GsI8&aGW0h*?zI!)gI-=1pf{x@HVa@MjpZZ!Nd#< zY*;4<#8F0f##Tm3#xxd8Mos2JX3muF>Ph~n+y@w{=%&TyNtB$xBJ66tTmmh5jigPI z61^-8S_%@qKYOOWe1}IhM=TV82#_;J<%vTaHTc<MST@$vvl@gblQGm&>w^FiG(Z)c zHo^-I?rRMmYI^{U9EH3EZ~i(Y9Bz<hW&>(;!^y;0yZ-X=(SQvC@<Iw8+Kuw@@zHts z@zEU>a1XL`DhU6D6kxd-ZH<iZ<qI%~rK+ZjrkpIdiM=ho(N}w8Q+jt>htD{D`NHeY z{rPBX>S9FbZfj%b%<axc{I?d|pU;2D48(+gYvN+fN31EQNGM|OWJ<_J&q~io%nwaS zNXYB-)r?zNRQ&JapKpA`7A`Ih+zbo=0DvCALT~S6&cMXQ#l^tL%)rb{_t}EZ*~8Ao z$eqs4ndC1e|Ij09>TKd<>EL2%Z%6n?uaU96s|z17@gGC~_w!djP2Da3Y01v{@5A~W zAj2OH0~0+X!~YU<u{8UCi2dRGCHA**{bi2#k1}pWOLtQnEm2EbQ#<F+uJLoR^Zw1s ze=+{q&wmlr{1-vSf0O(d&c8_h=?J%ilcnjWkN$XqpNW^@zi5AN&&%+~P5<Jyzhd$? z>Ss{+p?MkpZ-DusafWF@zkCt=A|)!M>JD_44X3N7*8btwg&&6O95_UN?h;hDyOic~ zBfSH<hD<9_3`-khDM~JOF6sbtyOF0_yd#lMZ-vsu3Oc?N)vIZgVlh5g4Ywc7DKD%( zb2yj|O@0%<e|7hv^Rk!avxz%_gea6V80LKc+pE)eQQx}8oNrRQV-kxy90gcd5E2rI zh!8Z`9})5IFNTPRG^GFX{!gYRRCu065z+Af5c-0Ix<~#G4gZvY1VR8Nru5v5C-@&S zQyT<<C=~LKH^hq}|1|#(;XDWwOh!Y0iF2{S!vGQA|Lo<TW{{Ci2>*)?0fdCg=kODO z=pVZNWspKj=Rp61eo06eSO5Y4MD(fl|9ax@;>46AeE&leFg~cL;%yI+z<)~!3xbNZ zLHrMTPta$l@i}+H{?o>%d&2W5AO6esC*2(pQD_<@?Dc<n_tWe6|BL?r;dO^|FfntM zjEoHL4-OuXiVzhzN)K9?BZMm9x3ZqVaE3q5=?Rg|)4Hq5#rYU=bk6g!((t#e7Fqw9 zfXT&-;5v>cTg$R@Y^XjVbGZ+0<7ZFP+}(|*^I^_|4G;Es%FB>`mJ`8^aAaH5#t}>8 zIfW@*Q9{mu7mBS=-)+sm%y+@?D;cpB5$aZ8F{HdEEht!`r8!{?_;fW9QU`~>QHCPU zWRWH_-`LZEN#OYjhVZZCz+eWVpq@2OcrZF)7MC~hFG3(C<}JNzqUW1cjdP;wb(&+} zBX+9Y$kCr%&*!exZUCB~F^*5+Rrlj><fx{nyKIxHJiqfM<x`%az8)A}G!~jUR6p1Y z<^YzrX*IBCxETQ$FNc4*5VKc$;s%sM*;jhNH^nl_dbwlwwa;=E4M*p%-l0CYV?iep z>otyFecfdkRZKbMzqp>t0kQXy^V&WdVdP_))cYkdbF2z{pj(Lfx>gt7Mg<?CvTbO) zz=VQ~#6bk^h-H)`T##|Z@=+yLB#C45dcV@i@?-YbZ*{+h(2-mLfd$^5b$^m04+ja; zr6?y|vMNqabPVzhpl6AXJhWTbtMjp6W3OuTBZOu2?4tch!N=gvM73KhAd;gZ!995+ zX9e%N!jqPPuD2YJxjGrvyv=+wr{1M@`eS|e`Sm%*!;a{xSag>+D<~<`MRhNZoaE%r zlcOaT_sPV0<oC`}(|}QPYQQ@vrz=7F2OWu0_qg@(N<UYvhZvN!bjwcb@Duzi<LotB zNVg}`jXS#urGcgEv`0e@yFf-T2Y<801VpG67i5FSk<6>(Wir6tCx)Rr+i~5%M|<{i zuchL~W;_x1f&0@xim*>c(2?rs#=RyXl)71^AYGPJkvZD_eI=2KoL-ecYY}=i48uQ_ zSXG$c8`Qf3$Qu$HRAbg{IqQlTv&rRdb2W`0osGS;y3pUBPihvsZbqB?PtCG&yYinD zJS#!%Q4fyZ)6{2_FOD?7C5imWYQYvVMw9Ae6O6o4Zv^TTK8xK3P=@#XU&#l>52Fdy z&oYJfvIg3*;0?~-Yuf3^B$XFq-Z>)7-aGgKUJkzPcux&$ixTAfMx$ZkuO-I`HFtwr zlv*9Sapw0psI<pdtA5kvyWQ#q`cWeh9QZQE0$H&bEjGR7VRKA7xaq+d8;d(uo^elU ztP+zC9g{>pZrti`3Lp>%bUOtY6l|O!TGhO9xXHn4I48QM=T|=bqq^S;PrfplQn^*@ z`X|ky?pCfwn9kmnEsoEzmhWNS)!d|ur+Ao(KV>$b+*OVF!l`st4Y4@Sd&xm2#oRbI ztk(?R9j~Wl$DKh$zw~Kzc39<w;o%m6dzan$Vn@6-oil-!GOgAt6Y!*+kycD$3dE}{ ztU)bRqIs}f`z6FyCa>s#Fs8?h0T>FoYQ^kX;_AjRiC&wBn~%i?r&YuD%)Ic@L#_qp zdZ)QL*51dmcv3sKJbu|`J{$n~jFmiNsBBi2xUJ)I_}+H&KWCsHwJwi{j9zU^uM^mq zYnk4ivp2d#Rjgw-A6^|`p>W>e#B2=i>GBW0PaGQD7yuqr=)Rf!C?9WovwGX}It4lM zzK^3LN!Sq1ON7jF7MjjbIDyUljg_$oKr<(LN>E##{E0LK;xLFi@ZPuPc<dWwv@@o; zw%qnpw-8pzMDYhT7*Q6Hs^fQl?QJB9Uxj6o^!GOLt~I+R6Ny9psa+@12Zm4Nok&&z zdm?`-;#3eMF3-`qQvYy?Quq{$!FBgDVapQ(S$K9rN&0#Jp+SrC3Fp>2M!q^Q+r^eY z6$^oD<BMcgdMKwA`3XCn#I@hdtBROd{?4;h8KsBDTlid+2tTcanK5lMf%1WW(D9B4 ze7s*%<kb`#zDjQCM|?Lw?;*BiZ+^AiVBeVReTUhGZdeG5@zqd?JZFa6(N@!x>(cYN z`W)A7Ntn6rxck+*=9pF?!Z-<=Ej7e2m((($JdB<9?HvDxN8SYET8!uwh=7~5h6k&! zS*%f!N-VbYR(^l=l(bG)k-Ev+Ez%<Mv3acw%4vpT2_H*9?I=XYW#g}64~%d2mP!{F zDTH_Tw5QA|@lWLd4Pd@~|3pj#GQt|wHHC!m4k?1Lq*rRRsOH0+p!S*HKXvnm;F%=T z(+jRPewe|V)s##*G(u(4nQb0-t^J4sZcDwk9$PZbqPUBax9V?9xlDgwvG=;wV9<?A zBN&xJsp>4}<RD>ijwKp6w!F=nL?-2J8}xE}b9`KPr_C-1TyPv~39-icEr3bB%H<Jp z_)&DxHKY^hnyGHI1>M=L6pB?`F>Bu2m^&PI4A*~H@1OX5aZPa({}K6n1Bfh6(Vfq5 z$neQ+fkfuS`UoHzZA8}h?z9hT^Mq@AAD5!Js|e|W9YBwU>a(vsp21%xFr%EJmRI=$ zmd8M^+{+ID-|TQ{=;~@;DLhkiJK=p4DjW^Br8acsMG{+7bAB6McrIn<zk?)38o9n2 z!RNKwP~Svwq}RfR$Au33S-o#XfwvaPkAE+K=g8a8jW7CA&{y~1-`9A#r3l?zZ3){H zS!(R~_Io?_vF?853-5DP)9fh56mve)Zhd~xpWDH?B4j_?f6n8Z*W?xwXu4x)C!f;A zMtm4GR=rpa%4RsUo*ciaiXJSc6`VikTWTl-w#<l!;FaiQdZh>{r8mfv@SO!#omJRU z=xf@*ck~(QO=M?`8>ojXpDbZ#6A-&;Nk<9I$;`RC<==>%%I8L2G<Tr9U7ntVjm52i ziM@CSi{YKW?0T0~(`fPSImm24>R_!wb?7p$g4?h;{<!qg^H*9P<11eWVBdA2k@-zs zU^U2VFLb1+BD_H6w!@D4>P1sG$Mq%CUNG!5B6D^rqoB#psW@oRpI<@zmUmLT??$84 zR|(Ft<_eITJ>=sV$qNe?(PFzeOIF{~hbi|-8>?*E>>A-h_dA<b=;S>42t+37E83gK zJvkNny^U&(%(wm6J9tC+Z8q7=7>y1d9K4N!bI$VPp#)A~f|h-G5LYx#Y9?flYBb(y zZIk^48?Bpz!(I-|t(@Poe4WDMX6G*JnCCZx)m!$KkwMVPc0bzwoS>{uGpv|3Cp<kv zLGn*|+E%G<&!sIJjT}A4(EvmX2%zKr_5dDD^D&n}qZv8Rod!69D2Vi?y;lLVvGyY_ zy5TH|+roiY;nyYIhdxeOysa#rnqlX5jKz|$EcaR1Xg2&Ll4`ZMW$wA@VCko;dB+ao zZy<lGQ2>g~W0tac9#_Ps%v;!0fp7lW^M1;VEC<cW>lcv@dR~EY9^u7h#-xjVav7ZX ziR7a73g4SeO8BGjIrt`)@=O?gv%14RU}df0vzTL+Tc5^u>m1Isc1(}H_*m1MJN+<& z3e0nm&BzH?i%TlBTjVch#|E(ta#0Wp+O+1=5?OOwLwLE#j6M*2jA6n>O{ukkPi+>z znZtR0?M0IO+DZRfn*uDy2j4Y{ZN@jmmSS?_<!aF`f8=;n94*FqGIl3>`zthXT7IYO zc?4JYL&Fq)>}6mogUgkyCRD?6iT8MobETonCj9jQ$zt`IUSIPb{P-AWTCyr{<u@X~ zZUo&Ns?*u(PYJDKD$6t%XWXGU#fEtoU7iDEV2I<A<(s1#zUPY6pz7v2e~;Oail0!4 zh!VCL>)%!qE2<Gt*q`Z1-`fLHOvyC)P59y0zZVP2Vr;%~xzIwV78wegMIbSPn>r6= zCV<W@w(`cMg-Hp(0%B0~Y=7?cJ_xO{-e)g=VZlX0IgImhdfQOzwp`^go62L1eiLG8 zw)q{oXR7LiYn!JQ;-T~b|K0BvmZ&>e@CC{RMrgXd&7YZOJ{yjm|8^|wo5#YlUQPq{ zteu{}Bs;$~ZUhz=<blZ)*Hq|$A-r=|FQn_>C}!Yl9WDT_boF3Y@^2-_8H!aUs1)In zMB*?r<Y-6%cfTUw>>)58{3!z9_~lnnc>bxiNOAu2yu**$cQHHS1}xVLWs4oZcM_^O zLZ^|5B#<h_B>$xmCi<EK9gn#b#58jV?HBW#@45B}T!V2NIm-tfTGTf)y3o|;WwPd- zUQRCYL-vf~8)8EH8aj2RJ7%oPc`~DtLSwz+HpO5Qp%t!@R2({Y@w|&eH&d1iFOYTx zHzxdQP-||fGpIaByt)yh#bG@sYX)lBEhmlABiJU@Osd62S)`WVe>V_eR(Ok%W8`l~ zIwf_#6HAxAtv`=YpApO{o_W~(AUpZG^N{2A;<(s=A7U%IayD(>dCJjXEZbU|`?mHF z>(SKVM^H$L?sOBxN<i0}rO{mQBcx|HHAtyPsP|a^3>09>_+#WFg?+c1!CL2s>IqQ| z$D)OEVtpf$373(OsW`<~g91e0Goq;$z5s5X=rb=}ggmi!tjv~^a`%IPvn4~9sg&y2 zE@Q>UYE6#J0(d6Nx~|aadp(v|$>`jyz@*=nk}`4whZi9v*X1<2EP~q1Lw)ZP^kD-O ze|CtR1fa152aq|-JQN#E4?{CpKRKQ!1~{R?-l}^v`(3E^_r03N6=%#!HjAf?APJCS z4WZq}2`+@sh;WRH>8Ho$$}OL3PB0M)o=Dl~<}xT-Ja*I<hm3SmKBofh2LZ#r3Xq`Y z&wyO=9w)weoNgvio&8@XoE>H85Iz=UHF;%4`>k8!_mg-1q1`s0Da5;dROa*kU>t8! zJGN7d?>gkE=-~Cb2cU+_b>a$VVk`eBhN0P`NwHoQdHDqH^eQ+V&q|w{sgX@;Z1a0B ze+}tfByhw4);J;raAPhjh#ke^A&lrK`1W=l9oIdqQf28`XW!LU%4!_7*pttSxv1o7 zO9m}N<HYMqd$-@_Iaz^c(Pz9f_p^%2SonEPXQqw|SSYl|;QX|Ow{{!POBVJ@VlRdo z+XYt&YdqVCza5+GEwPUk@!a_>O%*mqcmmB)j7tzO$$74s&wDf&Tn`G}oT;AVrs@FO z?SY5t(00f4vp-{}nj6@Ph0O1Eo7rp>JeXE9ntM20PKHjTnmzEeA?*ffu{5&n=odnf zPU?e<ND(1&0(Cp<2T05V<-YYX#WVmi7caLsSkN==Be!gtjjn~%{Gv0&OeLxT)$GBJ zp=5wH-lUCA#5}37YXZHntgGlC98MehDv@V85+n{ju!$LD6Q&Pd)r$mxoNHbvY3b)K zZ7r>*ucNSceQH+DAhbhV*;<mr%?cYLrlR&3qpmdbTj11Wv$LE2xRQjitt-}Ls@f`~ z?1l3zehvg~9=RBd2N?Jb71Diw$72*o8sG+r$BGAnp0%4LczQZxGyh>i8}TH}TG58j z61U+ZcE5XGuA|9RU_6FaJekn;H#x7Q(PBpAnb-a`Xy;_y);@_&6SE`tRjTFZN>g~& z=4CyycIVEB+@{Zx%QNQm0z>*%8f?~^azHXUp_2;PnZo7LHBv3tek?MM8ok*rt|Q=c zL)6K?V`xuA(X4iTS?AfgShWSeLoh^SnubD#dYL?`r6EVMm2-r?j(74~pWFd%zYo&Z zNt7-){UeGt_{z%}22OoEP<5xX$nodXp2b|ZP5}re`Y~5;Z-X<rBh=U!apidA)sF?g zJfoZuIQ1j~ZF#Mx6zV?l&&qDl867SRy`v<)L=88oUaWj*G=UyLW&@Gg<hD8CMfh-V zU+~oK&)W$(&tjW)Rj4}G`7PqxulZ$eF@Q}<mzj6kiwllKv+w>_sDAy)=Fe))V@(pD zE0dp4u2$B?j-Z9m88sx=czKDne|mGuLfu9P>Wz4)9YYL(n}K7-P7kOaGUE#?2x~e+ z(-ki8w7f0GC~m_KY`^*<!1Y)glNH*8aMG|!mLAoJw=~4B8)J$!G}b~yIwYGO7HI{* z)s94G0lMYDuj^m+NFe!J9gibpB!-7+-Ri@y?q~ASHtjO)VmCRVA#0D9bnYwA$nCdq z3)o7`?U_mU+-N)g6{1-b9FVE3__`nToG}oOnNdv7LMtFk!~_nLvl5Jn&|4T1f${Q6 z!x~RFaihDu<2@gwt#8;0;infM+TS?iHt&x5!94h5z?FM0tvR~F0CJ`kNqz!V@4*r} zf+`N9uy${>)ypAQAni`|%_^b!T@lDpu3)CR_;B7zmW3RIRkPWlD|e}f-KSC?AaYh+ zn}B7aQyqTI$Y*=U`x;xczOiNl8Nrk`+%8>Le4?Fh{(TLlJ5YQl;+2)|z?JsIEK-|F zx3Ge1d_7)9OT9`g!%hC|JhLWUc&OBI1Xtwdr_{wM+YmS{tR|p`^?9Lin*Sw)sAP3+ zX?8M9cY{lX;3dth$9JW$+tY|2-+Q?6hjN+tcJHur^!-XZown0r6})Sr6`u@yGl;V@ zQR-WX0K7M!I5iq#`RiaA6Xw`m<W01r$$KY*#R8K43$_7j<y-i5zn)rECg?m<oTW;% zSAJ1U`CW?=STEg<Nm7u6tg%7ma9y&n@u7<khE;Nwr5-@tLmg-kDgYkw&^ffa>XJe} z%i_f8z(8f65EV383urN<Ws=Gn*|d7#caGcebXO8t(%)d37i2|pH!A}`M&|Ml*VqiM zoE)D_gjwMOB|U|M6VH_pjT*wdP!p6|7Z8sAw#Y6ugDTvcIXm<FCt8)!JN>iR1{`o3 ze$n0_;090eP*}FRaJ|Io*m<wT-8vOzs9j&yi<a1Erm^Ow1-=!mGp6`!7wFa-oL?TD z0x>W7K0XBqcAekXy_59ib0vU5z+o>Xv*WVt_Em>h5`coJV~^cCwCYHCKDQx|0XOo3 zP~rTbf^_!fA9u{7w&Q@zttxyUTmlyMWe3UtwYxdIvkNuu&hL{1HjrU@G{nbfzo$JH zO^$$a16Jd*9lM{bqoCEy8;1o)W<kma4bUe~-hN{}h>QZ?fAh4~vUys2gLDyyF@7*1 z<wE|m^MMSIC(dIBwcISGHToD@WX(c|C`lY4n*Il3+d2i=I7b<o=bk@bn<VjHFtkGo zs5W$@UPt=4mYMnw7@$A1>fhi|!9^5Ap}LcFnaDrJ{*51CCnpFxXs5^bjqCq!Vuzpb zYN15j^Zy%EXZ{*4asHB(f5S=gzo6?p4HU%LS$(}XW%vK~o%JW0-)u%~{_m0a6PC`5 zPh`8Z{kiD!zoW7636$H6i%$G|kp4u>lMSf5Bj?_V`@fB~hYGqQ%oxq*o@0|AFeT%w z;v6J=Bc=O~B=AQ(6ay74T#BOBiKPB_UOQjFd1cj!l=dXNdZPs@52Rw?e`gUr>*t8{ zE!e2{kbwWQ<1X?3X*q6)d7v2o!P;C!;`DxGjK$}RWHFz}>JZf)SS?W?00jdx`Wa6| zSKat_lUq}?eX_=|dHZX}*6Z|ph0hDZz+mC|U=aWnpd5W}@r>xchy*C1k@!!ekU$(j z&TqM<F$KuT$*st^xY1oc9TS+>w@0%MoVbd>g<-XaWN~IdN%8K-JIt_6c&7T}4F~s` zzXyoS5uf1{j$MrEq9PsafTT|UQ?;%;XcBo{MTn@F5>yejUb1hVyd`pnvgF(i6rEhM zn>h~>Do<wVUq5jF<xoMe1W|E$pn8r}o26=o4`)Zl)NWZVLv+%6J1eby1Y3;8^e?32 z4{A0J<$W{G!RdC2DWAm|pi&}l*><*ALjVqo=`R!nzNNGAtj@s@@Kxcj`RWM_G);LQ zto@A_8t6~13D!{LfetWqJpxn6XM@r=8)4lWU)QxDF`x3Zp4N}DqLd|~;wBU9X=MFb zdjG62v51H<#uA58n*T^uJwZX+(%OSe@mHTIDcSZzW!S@6jE1n-p{?2BsbNV2OEd^Q z)L?rXpUYnwa;?_`h;r_yfu~Z)fBDJ*2>DQ1s4l-6`{jKr_-rM=_M<0u)0Zxs8?SUt z4F-ZjCWzsM|FmC3+Z}SVq!5bDHP^y17#w-ne4f%fA4y<bR#0;Em61zA1tr#mP*^xn zhhhQ(fCA{c^Q>yJU&I=I*PdhNd|U~wB@iVVlbMqvSlmuzzz%rNf8^H+0c{I>5#Zbt zRWV`d_w>Z$Wd&s~jGhpYvsj=@S<QKd6aT50dD5cqwy1DsXs8GoSmnXB9K77^8z{b@ zWs!z=PoslTe6)jo`Xm5hgHg9`UpvQhbHq6(+f}7k;iByYB+sQ-7pVg@{E7;-*u0~E zg<euVTR6<bIX6CXi!Rv-c4|&=vVb1V$aCUXnjUynwJfAZhu!^^r}gzEJklj~{MRcR z!Gm<=ywBldWdu@VX$`s*n=M2C#k~aK$vNdivf#4Om%H=Wshh)x?hn3ouM57(L^|K! zHtf8&QzEUmGcr253Aw^~4@io_#`7F-X@0vt-{CQffDT2jE+c_3cIu`G@(Du3_vE)= z;d3m+Z;p1{s}w-0c^0BIgClB7rxX5upvS2LOeY%A0Eb`d2Y+}FCv?wU;m4z{+7<jr z<Jvp$<VaR5KO$H)KWZCQYodj$9fhy1aK2M~Hi-GqKz5FDe0UCg>Q3Bijyq8ZN2%R} ze${P3N6CFM!ni-7D{egeymn#)7*$+qslr52QPZyb(Y(BqA-&MyT;O!2`ZdWt3E4S! zBXq7Vl+mFaYOrD0=$HB=CVKLP;7n6Mf`w-Z;tsyW8+~$H&AGT0DU4$|P{?#kUlhXW zdZzSM@_B=Fbj0}OSnzXl1HRWqi}gV^frjDScN+wOy7VH=tK4Z%+#kwsr?j$@HT92D z!$WIY;fBN$p4ZupJx=337P&3>Cl|A`j=8puF7-%gqmh(l6fUuO?v3aTL$aYGw)k3P zH0l{JdFzPb1bJ?H^yHHaEZg(lGr7JvT8>7Z(sy4yJ1A5Jc8yj>zRG{)RAMrNox}UR ztaUzJ_@emwQ<T;t3Q$cD8}^`4VByO<Lq%CW13m)}QueU$<edBTdNbKX@UY=0ARdjm z)n$lFXV8TU0gv0GVc<g+XiURx?kwC*5oP0C_nv>~4}9)bf|)!%E!o|#u+lKFUs)qO zT>+n)Xp3Icg}`GpGyJQW<?bH(t6G)#DPxbl*3x1M-5Xw?nHkYCAI1((+`&;KGtpb- z4bAD{3t27;5#;d-K?xONxnGggkH!r`I&+H2tf2rD_#f`A@DdYQJ-vwrg{MpUsM&Q! ztc&y`?PB-#R)LKMklN!f*ohVE<dBmpO%KOki*h_&?{aVYDm3)7YGNFhX>}bqpS@*r zaRr>+2^^xA1MN23zs4A!wcE%^j(NS?p00IZbhLB^lST$(U76zQ(<}xPlaY#>8Ts7V zjZZdT18-L8?pBjj`s<?jmIRAJhpt#X96R#nLNK1yBP88VbTnA@?<l4k?pzlIQ^`rH znxCeepn75$6hcMRwG3Wo;0Mv@>OkBjXT+zy--^@uc>(N2de?fs<?_Y)gu2y~Jp(f0 zTT8t&w+?J~*T8ql)q4O`yeNFKTNr6kEr8RWSgQxW>-o@4=NYIylBm17m1A>>`iiWH z!g8}y$|{$z#qIV*TtVeLHDThAC)*C!^^;s#vHAL=(#l2a`Kw-SIZ0V<=`H2?mrT@B zrQnF}>IG&SoSf5yZaf}0la<j*miEWX(Z&mQ0nYJ!Y0*8ElGbw5_V`j_m}dEWP(^#l zp12J6&-v^vPX*@0%yQg)2(<npp;m5XSo7(OS)OPw&8DC#n9E1-d3YPi@~g_YHMW&_ zjKaR59p?StLR)Vclw_nsPS7BbHxLqa&QJpZjUpd+)lMkkUiZJv6SBv<@&~(-kq2GD z1I$i$zN^~m6;4k+N6khoW2EL?wrOv?CksBvB9)g_vg`HdE;!X!0UJz}=dDeo-IkT| zt)5e6#lU%&tLE2f-3b6C?d=+=rl1;a5OsE20s0O&4V|4?ghOEmcYFICJwBo=C}%K+ z<#PwTBoMY65+x|<13%0!5%52@egbJ9FD-p%5VM);CcOKmlu=e5DS}I}Y!=<$%G413 zz91(m#~dAn7skBvd#{UFLQg^Pxa=hTAr`g!{n>gIu;CLP71dvo>$_Y9y+(10e%sN2 z>@&n${`QbrkB5nw=5(lwvAYTrnblO4W*{~`Bu*MP-$dRVPf1&@9XCITvu~Gs_We<i zZem+?32t^c-Wo+Q!%|}J6`B=G7&R;U1k37cbsPBNDi@4cjavUv`#l&0l%Voh3_Z$B zd|e%?WI#su^w^|BY+Oa{*Zr@${hZ7>WujI6FNFE~ZLn6W%1Y175XTN-VGTfoWP-gF zJhjjHp!zqk7muwO*cn>duZiaS1;(k!zg!MZF(NA)fnRS^Am2dE!|Y$<RPlMXSi!Zz z5XzOmZ=1~~&7g-uIhLccim{H4^lIO=6OXkg;#Q|eubNX&8IFf03e23cTEdB6Dn4{u zx|J6VV-^zkTf7~1;p5sQv~u>B2P2qDTgCL7x_KVglDF=U$4Z1Fjrn>Zi!i#8<g%f` zJX+K5V=qJLxYI)C<RQRQ%k?gi*cE^L;-QvBFOG)UYB|qvQzKj~ZP2>ml3cT=qn)*0 z4J&sIS@y|iX^A$*3b|>(Mm5cFtp`;jXhCno>xg8k2)=uEDs!9ERDt}y4t%BZM(rAq z5T)qQo$qM+M#0+*S0v`_m~MoOi=7xE#_)lPB>sWubQbdiM-yDFWCYA(A?)msLbEox z!1N0BtjoT%AV<@3`k`77nF4bakc;oV!DFrWAUC~_&B{&ZiZ96no40<K#Qpv>EkWSQ z`%NED1af9Rcpw9G5j5TaSLhaLd0{toW*bAeA&Y}#mYif6+QidlCo>WESpJryW?CE; z5<jzAPLd3c<J_{4mcO4-ZjY8uP+&TSKbieXM@R?~3o&@Ji~Y<XJ-O@du0->GQo#Dd zkH;4iB9`ZJES0VF(UTzb6KS{_L`q{PMYa^F>HofYVWK$I^3@qfZBe6qq^fUMf|@sX z^=q*%pbMiB_Wm0}S&6>#w4SDH6VL6<q(Izf5g>qrgUje~Uk@1++7QpJxS0R=cw@@? z-3?{V?<w>l@XYnR-YVIRp0E`1U7KRQ<*rPg-}xHm)FeqtgNJGr)hV7EYuV7(kI{Je zID=cyYfygK-64|;5+8S@-{)DD4Q!%MX(zrj8aurRj4};1vX5()$tt}%JCF_-YOXS= z$_uYa4p&uWs%%X$fO!b<14K^{Z2WXcHX~%a%8k~u{>yzQR+@Jl^X`o|uLZg6Q`1jR zF1%@0qiY9t9{mDvIwIyJGQ_rO8s~ATS*6ExJTQeC!M<AUuN%%Z)X`efE6J=!1rA5r zFj6SJay5>IaTZp-x0-zgNu_GhiyOa71}|*N2WckjQCq}F`Vh&>@o|EVZeWZQXnP`y zpi1>pVsr=?3A||^%&-->AM&?bWEidYX}?l#m9Nv`av><!8dJYCtLMc7&sC;lQEF?z zg;9lHYl~D}_qsCSL?-ldnwjnw*kqPV_1{JH=)wyp{_1ssuNJD>MbIPMd+zr%ea1X9 zy2o(~qBg+7Ui-}yG`rdaJ*z<_M-hU*Q)wpdh0xU3zx~SvN%%uM{OGDs7aK0wQ_UCL ztaDTJdPa;?OMHK)c43Z~WkRV=Md_!jdzAaez_+tXDHC1S0y1YSf|OMeTbF$qNld&2 zX=fbC&ZmbRQ4)%9W0Zc2W?k`O8;Gn^O4<)lr4~_~zGk@ThawWoMlH{U{${z12#4GG zA->b%B0ROK^oa}F*Ez(>vxlzW&d{=^L~ZH$K-tAw4=D0t2EloRdd17H%vJ<xBQN1> z#c(<9qZZm<1<t1IJ2T6&-vFMT+|bZV%})eKNCCDh&H44pYf9Tckn`q6m{v>&tFe>l z7AS<xOwkLTSsqv=&f3W+mrrx(9i|30_>|NGmJz5EGPR<?WmYt1VUleg8ls`vw#UUg z?8}Qy`$elRG&?v!u&Vl)c6qXTv`;PHHdhS>UpnIxo%Ch49P6v3a>+AZ%y~_2AH++K z&7GBQW0m-myT=O9P3uH2mLQVt*`zqrJ!+mx>%=DQuU6#X=V=%NAIqZxe4e)qAmD|V zL%Qwnj^bQn5$X=yQK;iy;l#q+jK=fVmRxpKPWj%+M(_xX9_koc>$?bSU`yCLx%$H@ zB4x6KHmht<wU<LU==muyf1gJ(vnU$zn>6;wtMVlW2#;TjWIGh>Hxc_NXC}7E`0DKW zc4eHo>7XrGC62T$9oW^c*UxxOO7Y#Tw_gl=qmkS0d{~d_4T2yc5~6Z1eYdm`RZ37+ z%9h|DObgs(y^Zn0_o4qFde+S>#<T7Y6T1Pl``Ruo-K0rPh3?_<902K2YF#$`N}G@~ z**5<^LmC?Nl)Dxg?JDgx6mXQRlz~q1@FbmjKUKz_u2mY0rWb8p{v1>6s;k$QdSc#6 ze)cZr{&M7!u{V!tWFx8hBcnUj@VZs0b%-B2k$@glSvtY6GN{s1sH#n7c%>;So|Ovd zXw=O_EYecCjBF4AVxgSPxCM>Sm30sizFCJmw_#lfiPFcH&#mT{u&^$#axSH*mEd6R zDhL~zMt`WTZEb-pD8>&lI(5@>nY4O11>vJLRtdS>aHV@|Myu7Ys0%<vaYr02&;*VO zR0|qs;tM`d;oU*nlLu%JR!3uDF&R<}TK=a3zYz&AWNvhzHCp0?w6YH*ZF~D^DZpBm zB=a2JU82+V7KA0K#qays!i}{~QG1+JDB6!@_xk(s%7xsvfvp<Ur)-jxFQ_s)m1fb7 zLDYkg_jOv!56rG$n<A?3GT<KNxN)3Dte^#szGeiA?cuN$vw68z<*O4f%*SdR5Ff>o zj_EakL~$C`8p<WJn^X>BaV@sy_*b%ZG@bk&z4ekY%{M~>Q(6zenJwMg2NgNVI>NH8 zSa%_?ougx$W~&@751D1+a-}<UESvLPDfQX^IxIUG(1d*5B*YET*i!th%!45#B5flp zO;KC1!Z3z#QVL<VLOZoxy-G)pMN?k7FH$?Z$RiG7243$f9fIy;jdufTL#0@@_k68G zO!Ic|8~HAqeOW|8ndT@}JetD(v~D-#tO-mE|8#^8gTr{fk#-q*&(PFBcdJtF&-#n; zq1^%4q)xR$+xH@}LNonlbmE;YA4&K2FIpPx#=k&Nlzv3f*&0$7P!w6M_Z1W%$&5li zuqNdEni>^RQu`KD{`PEtr`6Ghn0yLDZ+-OpOpHf4btU7L%R3%LDH$&1{@1TmIMY(r zXXJx?@X3139gRdGlckLmR>G4AU{V8>pfbRW?D}yD4t~W0X?eD6^2sn-ga$ljU~AL| zT0aE*AR%;guMdQ=_f&H0-8PLQRFknZBP@XfRM+H*&-3NpD67=l=>QV1>*3@w4@QR( zQU|+?ch0agm8fMb-XQB!ONOG?*Y8zO;@aRL_*J6!qbX7__dyNnS9Z{N1!5|9bCj~# zD&dE?+VNvMx4V5>m^blE>DJ9Fb6T34wI+7*tf7r*qz^x3>|crQ=5VavvXZo*BFf6j z9wz`@K@_>($cuV_Y;xLiww2jpIUWj2a9qoq4Ts{C9p-kH-0qr>ixS1l`C-lm0No<x zyGn<f=S$)8!_zb8!CInio)Rltnt_=OwTKYIGZu90oXc5<2^a@ERKFkEq?nc!`;ent zes-V?9mM=5#~jDnmKj?GABIxsy+}4p)M(#2r>p32@7*^?>8ZT^h`_|RmFU6($ln#E zTR5ZR!gfð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 C1Es0} diff --git a/doc/ci/triggers/img/trigger_schedule_updated_next_run_at.png b/doc/ci/triggers/img/trigger_schedule_updated_next_run_at.png deleted file mode 100644 index 71d08d04c374c6fb75284b735e6bf18706066333..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21896 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 diff --git a/doc/user/project/settings/import_export.md b/doc/user/project/settings/import_export.md index 7a4f9f408f190..58d2fd76c6102 100644 --- a/doc/user/project/settings/import_export.md +++ b/doc/user/project/settings/import_export.md @@ -27,7 +27,8 @@ with all their related data and be moved into a new GitLab instance. | GitLab version | Import/Export version | | -------- | -------- | -| 8.17.0 to current | 0.1.6 | +| 9.2.0 to current | 0.1.7 | +| 8.17.0 | 0.1.6 | | 8.13.0 | 0.1.5 | | 8.12.0 | 0.1.4 | | 8.10.3 | 0.1.3 | diff --git a/lib/gitlab/import_export.rb b/lib/gitlab/import_export.rb index 8b327cfc22617..27d5a9198b69d 100644 --- a/lib/gitlab/import_export.rb +++ b/lib/gitlab/import_export.rb @@ -3,7 +3,7 @@ module ImportExport extend self # For every version update, the version history in import_export.md has to be kept up to date. - VERSION = '0.1.6'.freeze + VERSION = '0.1.7'.freeze FILENAME_LIMIT = 50 def export_path(relative_path:) diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index 5f757f99fb301..89088ee8762a6 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -39,8 +39,8 @@ project_tree: - :author - :events - :statuses - - triggers: - - :trigger_schedule + - :triggers + - :pipeline_schedules - :services - :hooks - protected_branches: @@ -116,4 +116,4 @@ methods: merge_requests: - :diff_head_sha project: - - :description_html \ No newline at end of file + - :description_html diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb index 956763fa39927..19e23a4715f86 100644 --- a/lib/gitlab/import_export/relation_factory.rb +++ b/lib/gitlab/import_export/relation_factory.rb @@ -5,7 +5,7 @@ class RelationFactory pipelines: 'Ci::Pipeline', statuses: 'commit_status', triggers: 'Ci::Trigger', - trigger_schedule: 'Ci::TriggerSchedule', + pipeline_schedules: 'Ci::PipelineSchedule', builds: 'Ci::Build', hooks: 'ProjectHook', merge_access_levels: 'ProtectedBranch::MergeAccessLevel', diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb index 6aca6db312300..14d8e925d0e48 100644 --- a/lib/gitlab/usage_data.rb +++ b/lib/gitlab/usage_data.rb @@ -23,6 +23,7 @@ def system_usage_data ci_pipelines: ::Ci::Pipeline.count, ci_runners: ::Ci::Runner.count, ci_triggers: ::Ci::Trigger.count, + ci_pipeline_schedules: ::Ci::PipelineSchedule.count, deploy_keys: DeployKey.count, deployments: Deployment.count, environments: Environment.count, diff --git a/spec/controllers/projects/pipeline_schedules_controller_spec.rb b/spec/controllers/projects/pipeline_schedules_controller_spec.rb new file mode 100644 index 0000000000000..f8f95dd9bc873 --- /dev/null +++ b/spec/controllers/projects/pipeline_schedules_controller_spec.rb @@ -0,0 +1,87 @@ +require 'spec_helper' + +describe Projects::PipelineSchedulesController do + set(:project) { create(:empty_project, :public) } + let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project) } + + describe 'GET #index' do + let(:scope) { nil } + let!(:inactive_pipeline_schedule) do + create(:ci_pipeline_schedule, :inactive, project: project) + end + + it 'renders the index view' do + visit_pipelines_schedules + + expect(response).to have_http_status(:ok) + expect(response).to render_template(:index) + end + + context 'when the scope is set to active' do + let(:scope) { 'active' } + + before do + visit_pipelines_schedules + end + + it 'only shows active pipeline schedules' do + expect(response).to have_http_status(:ok) + expect(assigns(:schedules)).to include(pipeline_schedule) + expect(assigns(:schedules)).not_to include(inactive_pipeline_schedule) + end + end + + def visit_pipelines_schedules + get :index, namespace_id: project.namespace.to_param, project_id: project, scope: scope + end + end + + describe 'GET edit' do + let(:user) { create(:user) } + + before do + project.add_master(user) + + sign_in(user) + end + + it 'loads the pipeline schedule' do + get :edit, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id + + expect(response).to have_http_status(:ok) + expect(assigns(:schedule)).to eq(pipeline_schedule) + end + end + + describe 'DELETE #destroy' do + set(:user) { create(:user) } + + context 'when a developer makes the request' do + before do + project.add_developer(user) + sign_in(user) + + delete :destroy, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id + end + + it 'does not delete the pipeline schedule' do + expect(response).not_to have_http_status(:ok) + end + end + + context 'when a master makes the request' do + before do + project.add_master(user) + sign_in(user) + end + + it 'destroys the pipeline schedule' do + expect do + delete :destroy, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id + end.to change { project.pipeline_schedules.count }.by(-1) + + expect(response).to have_http_status(302) + end + end + end +end diff --git a/spec/factories/ci/trigger_schedules.rb b/spec/factories/ci/pipeline_schedule.rb similarity index 70% rename from spec/factories/ci/trigger_schedules.rb rename to spec/factories/ci/pipeline_schedule.rb index 2390706fa415d..a716da46ac69c 100644 --- a/spec/factories/ci/trigger_schedules.rb +++ b/spec/factories/ci/pipeline_schedule.rb @@ -1,14 +1,11 @@ FactoryGirl.define do - factory :ci_trigger_schedule, class: Ci::TriggerSchedule do - trigger factory: :ci_trigger_for_trigger_schedule + factory :ci_pipeline_schedule, class: Ci::PipelineSchedule do cron '0 1 * * *' cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE ref 'master' active true - - after(:build) do |trigger_schedule, evaluator| - trigger_schedule.project ||= trigger_schedule.trigger.project - end + description "pipeline schedule" + project factory: :empty_project trait :nightly do cron '0 1 * * *' @@ -24,5 +21,9 @@ cron '0 1 22 * *' cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE end + + trait :inactive do + active false + end end end diff --git a/spec/features/projects/import_export/test_project_export.tar.gz b/spec/features/projects/import_export/test_project_export.tar.gz index 399c1d478c5bf7e6e71905710c0c80e25f430a9f..4efd5a26a823ea7a93d6e1c1ce379a05b6171d47 100644 GIT binary patch delta 210765 zcmV(pK=8lRzbS^yDSsb}2mnZD4_N>M<hgZJ)bG|eOiN04ihv?D9fBYY(jX|!%rHZD zgCa;tcOy!PbcY}*C7{xs(jh6Jgg)c%ob#M>o^#*Nde^(w{l~lBS&KCT?7goYpX;;t z{*Jf=!T<OR006+DP`<z3VSjrEfFOT+|NDy%2nGVcFc=sL1b^@WfFK|YNWcgEpLdOb zz{AZQ<%)O37i)!ewM9ApcXD_!9Pa;O=kIa+@Av<Yyu>A(U7c*P7<X~BhohxE_J86G zd_G_h$bVgbAP`@35Ex&702B;`0buxl0Dw>c1WdpO_@8j*|9t)by#6=&JVAU`Htu|X zD?cR`3WFfwXn!;m2LPjx02CAmLV|G^m?ablgCh`dFaQY!BalcwS1is=!WxURbd$K{ zj&*atMG1wVz$hRP0Y?MT2p9ql!XTjds|*K2FlZ1Q1H-`pa1;iI{#R9bCk)o!UQEx) z&BqZVh5$n;L0}*XfCHhRID{n(j6WI#g5qEhC<F&UL4Pb!c=xa<G!~5eS1U?3j&2xh zteBjujS~)s#d-X(g@jWgAwUQcip0SY7y#4~0LEf~01y}p#UM~vEEIyZ1i>sVVHm)_ z+S0am@^Hn9>Df49ot-Sj;1DDw4243#@SKFjp)65IG@hLB0fB*F6c~+zgYejf;!}l0 z0sqwuK7XP=9?7HrG6JK70ze=H6oCPP&{z}*Z3%(HARssj4S)b(SOgFQ!9(8?g@ymC zjfbvI9?sTc4_$qnt-YM^b|6R~B^--G0})V5OArWW3B^G1UI8EwI1*|J1EJvv{3iea z1BU#YGgt>_cONmdE6NdLO$k6l&=@2Nf<!}MAb%|05*CiZAh8HA1PVl8Q7{Au0S6(` z|5jlf7Uho53MC#F_;_(xEC>j;L?XbJC<Fonf#Z-E90Y@bVNm!_FqkFgztA<uI630% zZ7}YX5Cjf@#zN4RKmZ14iN#>CFdPaC1>uk=EDZlg92|~@0dSCiGh*ZY{|cHodz8C7 z)_+mV(#F+|5{d-l0SG|B(O5J90ER-4I1Chr2LT>r5ID*bjln`tP!RTC4cIu~`Oy<A zf%3w-IXPe{0a!}}K2R7OFNgnt&leaAM<elg1;WudC<<R-01|`vH%K|4T<t8Kyc{WE z5I6#i&o2y#gIS_cFc=7f!dDQ5#UOxKD1RDh3CCeEU?A|{mEGL2u6QV;5th(DKtWg{ z{`>-i!GLf)5D*}|4jKc+g7I+1{@dmHb1lVyl=!faus>-=B5+V34h6MD;;#V^3&TM0 zkVK-uP!t>hL|Oi)Du5CLL_(kl7!C&kBj7M35&#FH(0FhF;27|q%wqvqC<^%Rw}0_( zx<E=41PisqVbM4U2#W_75(WjqP#C=DKp+|e$LA4%g<#SDl`e=9hD5+|cvu4=7(7<6 za1aD$i60Np5HJP{#3Jz*5)4M5f&YarloAO?0W5Jy7(Qqa3V%EvW? @D#5PaMa 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�F7)EcQp(1KhxyIH%uyCIUVQ(>_N~KFz1is+q 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 RAN;TJe*pn^{n`Nd3;@<MuucE~ delta 258336 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@Z@nO<Vjx{UTFg}SUovQ=5yZY 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 diff --git a/spec/features/projects/pipeline_schedules_spec.rb b/spec/features/projects/pipeline_schedules_spec.rb new file mode 100644 index 0000000000000..cdac4fe2111ff --- /dev/null +++ b/spec/features/projects/pipeline_schedules_spec.rb @@ -0,0 +1,140 @@ +require 'spec_helper' + +feature 'Pipeline Schedules', :feature do + include PipelineSchedulesHelper + include WaitForAjax + + let!(:project) { create(:project) } + let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project) } + let(:scope) { nil } + let!(:user) { create(:user) } + + before do + project.add_master(user) + + login_as(user) + visit_page + end + + describe 'GET /projects/pipeline_schedules' do + let(:visit_page) { visit_pipelines_schedules } + + it 'avoids N + 1 queries' do + control_count = ActiveRecord::QueryRecorder.new { visit_pipelines_schedules }.count + + create_list(:ci_pipeline_schedule, 2, project: project) + + expect { visit_pipelines_schedules }.not_to exceed_query_limit(control_count) + end + + describe 'The view' do + it 'displays the required information description' do + page.within('.pipeline-schedule-table-row') do + expect(page).to have_content('pipeline schedule') + expect(page).to have_link('master') + expect(page).to have_content('None') + end + end + + it 'creates a new scheduled pipeline' do + click_link 'New Schedule' + + expect(page).to have_content('Schedule a new pipeline') + end + + it 'changes ownership of the pipeline' do + click_link 'Take ownership' + page.within('.pipeline-schedule-table-row') do + expect(page).not_to have_content('No owner') + expect(page).to have_link('John Doe') + end + end + + it 'edits the pipeline' do + page.within('.pipeline-schedule-table-row') do + click_link 'Edit' + end + + expect(page).to have_content('Edit Pipeline Schedule') + end + + it 'deletes the pipeline' do + click_link 'Delete' + + expect(page).not_to have_content('pipeline schedule') + end + end + end + + describe 'POST /projects/pipeline_schedules/new', js: true do + let(:visit_page) { visit_new_pipeline_schedule } + + it 'it creates a new scheduled pipeline' do + fill_in_schedule_form + save_pipeline_schedule + + expect(page).to have_content('my fancy description') + end + + it 'it prevents an invalid form from being submitted' do + save_pipeline_schedule + + expect(page).to have_content('This field is required') + end + end + + describe 'PATCH /projects/pipelines_schedules/:id/edit', js: true do + let(:visit_page) do + edit_pipeline_schedule + end + + it 'it displays existing properties' do + description = find_field('schedule_description').value + expect(description).to eq('pipeline schedule') + expect(page).to have_button('master') + expect(page).to have_button('UTC') + end + + it 'edits the scheduled pipeline' do + fill_in 'schedule_description', with: 'my brand new description' + + save_pipeline_schedule + + expect(page).to have_content('my brand new description') + end + end + + def visit_new_pipeline_schedule + visit new_namespace_project_pipeline_schedule_path(project.namespace, project, pipeline_schedule) + end + + def edit_pipeline_schedule + visit edit_namespace_project_pipeline_schedule_path(project.namespace, project, pipeline_schedule) + end + + def visit_pipelines_schedules + visit namespace_project_pipeline_schedules_path(project.namespace, project, scope: scope) + end + + def select_timezone + click_button 'Select a timezone' + click_link 'American Samoa' + end + + def select_target_branch + click_button 'Select target branch' + click_link 'master' + end + + def save_pipeline_schedule + click_button 'Save pipeline schedule' + end + + def fill_in_schedule_form + fill_in 'schedule_description', with: 'my fancy description' + fill_in 'schedule_cron', with: '* 1 2 3 4' + + select_timezone + select_target_branch + end +end diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb index 26879a77c487e..78a76d9c11246 100644 --- a/spec/features/security/project/internal_access_spec.rb +++ b/spec/features/security/project/internal_access_spec.rb @@ -3,7 +3,7 @@ describe "Internal Project Access", feature: true do include AccessMatchers - let(:project) { create(:project, :internal) } + set(:project) { create(:project, :internal) } describe "Project should be internal" do describe '#internal?' do @@ -437,6 +437,20 @@ end end + describe "GET /:project_path/pipeline_schedules" do + subject { namespace_project_pipeline_schedules_path(project.namespace, project) } + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_allowed_for(:developer).of(project) } + it { is_expected.to be_allowed_for(:reporter).of(project) } + it { is_expected.to be_allowed_for(:guest).of(project) } + it { is_expected.to be_allowed_for(:user) } + it { is_expected.to be_denied_for(:external) } + it { is_expected.to be_denied_for(:visitor) } + end + describe "GET /:project_path/environments" do subject { namespace_project_environments_path(project.namespace, project) } diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb index 699ca4f724c4f..a66f6e0905591 100644 --- a/spec/features/security/project/private_access_spec.rb +++ b/spec/features/security/project/private_access_spec.rb @@ -3,7 +3,7 @@ describe "Private Project Access", feature: true do include AccessMatchers - let(:project) { create(:project, :private, public_builds: false) } + set(:project) { create(:project, :private, public_builds: false) } describe "Project should be private" do describe '#private?' do @@ -478,6 +478,48 @@ it { is_expected.to be_denied_for(:visitor) } end + describe "GET /:project_path/pipeline_schedules" do + subject { namespace_project_pipeline_schedules_path(project.namespace, project) } + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_allowed_for(:developer).of(project) } + it { is_expected.to be_allowed_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } + it { is_expected.to be_denied_for(:external) } + it { is_expected.to be_denied_for(:visitor) } + end + + describe "GET /:project_path/pipeline_schedules/new" do + subject { new_namespace_project_pipeline_schedule_path(project.namespace, project) } + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_allowed_for(:developer).of(project) } + it { is_expected.to be_denied_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } + it { is_expected.to be_denied_for(:external) } + it { is_expected.to be_denied_for(:visitor) } + end + + describe "GET /:project_path/environments/new" do + subject { new_namespace_project_pipeline_schedule_path(project.namespace, project) } + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_allowed_for(:developer).of(project) } + it { is_expected.to be_denied_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } + it { is_expected.to be_denied_for(:external) } + it { is_expected.to be_denied_for(:visitor) } + end + describe "GET /:project_path/container_registry" do let(:container_repository) { create(:container_repository) } diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb index 624f0d0f48590..5cd575500c329 100644 --- a/spec/features/security/project/public_access_spec.rb +++ b/spec/features/security/project/public_access_spec.rb @@ -3,7 +3,7 @@ describe "Public Project Access", feature: true do include AccessMatchers - let(:project) { create(:project, :public) } + set(:project) { create(:project, :public) } describe "Project should be public" do describe '#public?' do @@ -257,6 +257,20 @@ end end + describe "GET /:project_path/pipeline_schedules" do + subject { namespace_project_pipeline_schedules_path(project.namespace, project) } + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_allowed_for(:developer).of(project) } + it { is_expected.to be_allowed_for(:reporter).of(project) } + it { is_expected.to be_allowed_for(:guest).of(project) } + it { is_expected.to be_allowed_for(:user) } + it { is_expected.to be_allowed_for(:external) } + it { is_expected.to be_allowed_for(:visitor) } + end + describe "GET /:project_path/environments" do subject { namespace_project_environments_path(project.namespace, project) } diff --git a/spec/features/triggers_spec.rb b/spec/features/triggers_spec.rb index 783f330221cac..c1ae6db00c640 100644 --- a/spec/features/triggers_spec.rb +++ b/spec/features/triggers_spec.rb @@ -77,77 +77,6 @@ expect(page.find('.flash-notice')).to have_content 'Trigger was successfully updated.' expect(page.find('.triggers-list')).to have_content new_trigger_title end - - context 'scheduled triggers' do - let!(:trigger) do - create(:ci_trigger, owner: user, project: @project, description: trigger_title) - end - - context 'enabling schedule' do - before do - visit edit_namespace_project_trigger_path(@project.namespace, @project, trigger) - end - - scenario 'do fill form with valid data and save' do - find('#trigger_trigger_schedule_attributes_active').click - fill_in 'trigger_trigger_schedule_attributes_cron', with: '1 * * * *' - fill_in 'trigger_trigger_schedule_attributes_cron_timezone', with: 'UTC' - fill_in 'trigger_trigger_schedule_attributes_ref', with: 'master' - click_button 'Save trigger' - - expect(page.find('.flash-notice')).to have_content 'Trigger was successfully updated.' - end - - scenario 'do not fill form with valid data and save' do - find('#trigger_trigger_schedule_attributes_active').click - click_button 'Save trigger' - - expect(page).to have_content 'The form contains the following errors' - end - - context 'when GitLab time_zone is ActiveSupport::TimeZone format' do - before do - allow(Time).to receive(:zone) - .and_return(ActiveSupport::TimeZone['Eastern Time (US & Canada)']) - end - - scenario 'do fill form with valid data and save' do - find('#trigger_trigger_schedule_attributes_active').click - fill_in 'trigger_trigger_schedule_attributes_cron', with: '1 * * * *' - fill_in 'trigger_trigger_schedule_attributes_cron_timezone', with: 'UTC' - fill_in 'trigger_trigger_schedule_attributes_ref', with: 'master' - click_button 'Save trigger' - - expect(page.find('.flash-notice')) - .to have_content 'Trigger was successfully updated.' - end - end - end - - context 'disabling schedule' do - before do - trigger.create_trigger_schedule( - project: trigger.project, - active: true, - ref: 'master', - cron: '1 * * * *', - cron_timezone: 'UTC') - - visit edit_namespace_project_trigger_path(@project.namespace, @project, trigger) - end - - scenario 'disable and save form' do - find('#trigger_trigger_schedule_attributes_active').click - click_button 'Save trigger' - expect(page.find('.flash-notice')).to have_content 'Trigger was successfully updated.' - - visit edit_namespace_project_trigger_path(@project.namespace, @project, trigger) - checkbox = find_field('trigger_trigger_schedule_attributes_active') - - expect(checkbox).not_to be_checked - end - end - end end describe 'trigger "Take ownership" workflow' do diff --git a/spec/finders/pipeline_schedules_finder_spec.rb b/spec/finders/pipeline_schedules_finder_spec.rb new file mode 100644 index 0000000000000..e184a87c9c725 --- /dev/null +++ b/spec/finders/pipeline_schedules_finder_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe PipelineSchedulesFinder do + let(:project) { create(:empty_project) } + + let!(:active_schedule) { create(:ci_pipeline_schedule, project: project) } + let!(:inactive_schedule) { create(:ci_pipeline_schedule, :inactive, project: project) } + + subject { described_class.new(project).execute(params) } + + describe "#execute" do + context 'when the scope is nil' do + let(:params) { { scope: nil } } + + it 'selects all pipeline pipeline schedules' do + expect(subject.count).to be(2) + expect(subject).to include(active_schedule, inactive_schedule) + end + end + + context 'when the scope is active' do + let(:params) { { scope: 'active' } } + + it 'selects only active pipelines' do + expect(subject.count).to be(1) + expect(subject).to include(active_schedule) + expect(subject).not_to include(inactive_schedule) + end + end + + context 'when the scope is inactve' do + let(:params) { { scope: 'inactive' } } + + it 'selects only inactive pipelines' do + expect(subject.count).to be(1) + expect(subject).not_to include(active_schedule) + expect(subject).to include(inactive_schedule) + end + end + end +end diff --git a/spec/javascripts/pipeline_schedules/interval_pattern_input_spec.js b/spec/javascripts/pipeline_schedules/interval_pattern_input_spec.js new file mode 100644 index 0000000000000..08fa6ca9057c4 --- /dev/null +++ b/spec/javascripts/pipeline_schedules/interval_pattern_input_spec.js @@ -0,0 +1,217 @@ +import Vue from 'vue'; +import IntervalPatternInput from '~/pipeline_schedules/components/interval_pattern_input'; + +const IntervalPatternInputComponent = Vue.extend(IntervalPatternInput); +const inputNameAttribute = 'schedule[cron]'; + +const cronIntervalPresets = { + everyDay: '0 4 * * *', + everyWeek: '0 4 * * 0', + everyMonth: '0 4 1 * *', +}; + +window.gl = window.gl || {}; + +window.gl.pipelineScheduleFieldErrors = { + updateFormValidityState: () => {}, +}; + +describe('Interval Pattern Input Component', function () { + describe('when prop initialCronInterval is passed (edit)', function () { + describe('when prop initialCronInterval is custom', function () { + beforeEach(function () { + this.initialCronInterval = '1 2 3 4 5'; + this.intervalPatternComponent = new IntervalPatternInputComponent({ + propsData: { + initialCronInterval: this.initialCronInterval, + }, + }).$mount(); + }); + + it('is initialized as a Vue component', function () { + expect(this.intervalPatternComponent).toBeDefined(); + }); + + it('prop initialCronInterval is set', function () { + expect(this.intervalPatternComponent.initialCronInterval).toBe(this.initialCronInterval); + }); + + it('sets showUnsetWarning to false', function (done) { + Vue.nextTick(() => { + expect(this.intervalPatternComponent.showUnsetWarning).toBe(false); + done(); + }); + }); + + it('does not render showUnsetWarning', function (done) { + Vue.nextTick(() => { + expect(this.intervalPatternComponent.$el.outerHTML).not.toContain('Schedule not yet set'); + done(); + }); + }); + + it('sets isEditable to true', function (done) { + Vue.nextTick(() => { + expect(this.intervalPatternComponent.isEditable).toBe(true); + done(); + }); + }); + }); + + describe('when prop initialCronInterval is preset', function () { + beforeEach(function () { + this.intervalPatternComponent = new IntervalPatternInputComponent({ + propsData: { + inputNameAttribute, + initialCronInterval: '0 4 * * *', + }, + }).$mount(); + }); + + it('is initialized as a Vue component', function () { + expect(this.intervalPatternComponent).toBeDefined(); + }); + + it('sets showUnsetWarning to false', function (done) { + Vue.nextTick(() => { + expect(this.intervalPatternComponent.showUnsetWarning).toBe(false); + done(); + }); + }); + + it('does not render showUnsetWarning', function (done) { + Vue.nextTick(() => { + expect(this.intervalPatternComponent.$el.outerHTML).not.toContain('Schedule not yet set'); + done(); + }); + }); + + it('sets isEditable to false', function (done) { + Vue.nextTick(() => { + expect(this.intervalPatternComponent.isEditable).toBe(false); + done(); + }); + }); + }); + }); + + describe('when prop initialCronInterval is not passed (new)', function () { + beforeEach(function () { + this.intervalPatternComponent = new IntervalPatternInputComponent({ + propsData: { + inputNameAttribute, + }, + }).$mount(); + }); + + it('is initialized as a Vue component', function () { + expect(this.intervalPatternComponent).toBeDefined(); + }); + + it('prop initialCronInterval is set', function () { + const defaultInitialCronInterval = ''; + expect(this.intervalPatternComponent.initialCronInterval).toBe(defaultInitialCronInterval); + }); + + it('sets showUnsetWarning to true', function (done) { + Vue.nextTick(() => { + expect(this.intervalPatternComponent.showUnsetWarning).toBe(true); + done(); + }); + }); + + it('renders showUnsetWarning to true', function (done) { + Vue.nextTick(() => { + expect(this.intervalPatternComponent.$el.outerHTML).toContain('Schedule not yet set'); + done(); + }); + }); + + it('sets isEditable to true', function (done) { + Vue.nextTick(() => { + expect(this.intervalPatternComponent.isEditable).toBe(true); + done(); + }); + }); + }); + + describe('User Actions', function () { + beforeEach(function () { + // For an unknown reason, Phantom.js doesn't trigger click events + // on radio buttons in a way Vue can register. So, we have to mount + // to a fixture. + setFixtures('<div id="my-mount"></div>'); + + this.initialCronInterval = '1 2 3 4 5'; + this.intervalPatternComponent = new IntervalPatternInputComponent({ + propsData: { + initialCronInterval: this.initialCronInterval, + }, + }).$mount('#my-mount'); + }); + + it('cronInterval is updated when everyday preset interval is selected', function (done) { + this.intervalPatternComponent.$el.querySelector('#every-day').click(); + + Vue.nextTick(() => { + expect(this.intervalPatternComponent.cronInterval).toBe(cronIntervalPresets.everyDay); + expect(this.intervalPatternComponent.$el.querySelector('.cron-interval-input').value).toBe(cronIntervalPresets.everyDay); + done(); + }); + }); + + it('cronInterval is updated when everyweek preset interval is selected', function (done) { + this.intervalPatternComponent.$el.querySelector('#every-week').click(); + + Vue.nextTick(() => { + expect(this.intervalPatternComponent.cronInterval).toBe(cronIntervalPresets.everyWeek); + expect(this.intervalPatternComponent.$el.querySelector('.cron-interval-input').value).toBe(cronIntervalPresets.everyWeek); + + done(); + }); + }); + + it('cronInterval is updated when everymonth preset interval is selected', function (done) { + this.intervalPatternComponent.$el.querySelector('#every-month').click(); + + Vue.nextTick(() => { + expect(this.intervalPatternComponent.cronInterval).toBe(cronIntervalPresets.everyMonth); + expect(this.intervalPatternComponent.$el.querySelector('.cron-interval-input').value).toBe(cronIntervalPresets.everyMonth); + done(); + }); + }); + + it('only a space is added to cronInterval (trimmed later) when custom radio is selected', function (done) { + this.intervalPatternComponent.$el.querySelector('#every-month').click(); + this.intervalPatternComponent.$el.querySelector('#custom').click(); + + Vue.nextTick(() => { + const intervalWithSpaceAppended = `${cronIntervalPresets.everyMonth} `; + expect(this.intervalPatternComponent.cronInterval).toBe(intervalWithSpaceAppended); + expect(this.intervalPatternComponent.$el.querySelector('.cron-interval-input').value).toBe(intervalWithSpaceAppended); + done(); + }); + }); + + it('text input is disabled when preset interval is selected', function (done) { + this.intervalPatternComponent.$el.querySelector('#every-month').click(); + + Vue.nextTick(() => { + expect(this.intervalPatternComponent.isEditable).toBe(false); + expect(this.intervalPatternComponent.$el.querySelector('.cron-interval-input').disabled).toBe(true); + done(); + }); + }); + + it('text input is enabled when custom is selected', function (done) { + this.intervalPatternComponent.$el.querySelector('#every-month').click(); + this.intervalPatternComponent.$el.querySelector('#custom').click(); + + Vue.nextTick(() => { + expect(this.intervalPatternComponent.isEditable).toBe(true); + expect(this.intervalPatternComponent.$el.querySelector('.cron-interval-input').disabled).toBe(false); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/pipeline_schedules/pipeline_schedule_callout_spec.js b/spec/javascripts/pipeline_schedules/pipeline_schedule_callout_spec.js new file mode 100644 index 0000000000000..1d05f37cb3615 --- /dev/null +++ b/spec/javascripts/pipeline_schedules/pipeline_schedule_callout_spec.js @@ -0,0 +1,91 @@ +import Vue from 'vue'; +import Cookies from 'js-cookie'; +import PipelineSchedulesCallout from '~/pipeline_schedules/components/pipeline_schedules_callout'; + +const PipelineSchedulesCalloutComponent = Vue.extend(PipelineSchedulesCallout); +const cookieKey = 'pipeline_schedules_callout_dismissed'; + +describe('Pipeline Schedule Callout', () => { + describe('independent of cookies', () => { + beforeEach(() => { + this.calloutComponent = new PipelineSchedulesCalloutComponent().$mount(); + }); + + it('the component can be initialized', () => { + expect(this.calloutComponent).toBeDefined(); + }); + + it('correctly sets illustrationSvg', () => { + expect(this.calloutComponent.illustrationSvg).toContain('<svg'); + }); + }); + + describe(`when ${cookieKey} cookie is set`, () => { + beforeEach(() => { + Cookies.set(cookieKey, true); + this.calloutComponent = new PipelineSchedulesCalloutComponent().$mount(); + }); + + it('correctly sets calloutDismissed to true', () => { + expect(this.calloutComponent.calloutDismissed).toBe(true); + }); + + it('does not render the callout', () => { + expect(this.calloutComponent.$el.childNodes.length).toBe(0); + }); + }); + + describe('when cookie is not set', () => { + beforeEach(() => { + Cookies.remove(cookieKey); + this.calloutComponent = new PipelineSchedulesCalloutComponent().$mount(); + }); + + it('correctly sets calloutDismissed to false', () => { + expect(this.calloutComponent.calloutDismissed).toBe(false); + }); + + it('renders the callout container', () => { + expect(this.calloutComponent.$el.querySelector('.bordered-box')).not.toBeNull(); + }); + + it('renders the callout svg', () => { + expect(this.calloutComponent.$el.outerHTML).toContain('<svg'); + }); + + it('renders the callout title', () => { + expect(this.calloutComponent.$el.outerHTML).toContain('Scheduling Pipelines'); + }); + + it('renders the callout text', () => { + expect(this.calloutComponent.$el.outerHTML).toContain('runs pipelines in the future'); + }); + + it('updates calloutDismissed when close button is clicked', (done) => { + this.calloutComponent.$el.querySelector('#dismiss-callout-btn').click(); + + Vue.nextTick(() => { + expect(this.calloutComponent.calloutDismissed).toBe(true); + done(); + }); + }); + + it('#dismissCallout updates calloutDismissed', (done) => { + this.calloutComponent.dismissCallout(); + + Vue.nextTick(() => { + expect(this.calloutComponent.calloutDismissed).toBe(true); + done(); + }); + }); + + it('is hidden when close button is clicked', (done) => { + this.calloutComponent.$el.querySelector('#dismiss-callout-btn').click(); + + Vue.nextTick(() => { + expect(this.calloutComponent.$el.childNodes.length).toBe(0); + done(); + }); + }); + }); +}); diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index baa81870e810d..688e731bf156c 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -101,6 +101,7 @@ pipelines: - cancelable_statuses - manual_actions - artifacts +- pipeline_schedule statuses: - project - pipeline @@ -112,9 +113,13 @@ triggers: - project - trigger_requests - owner -- trigger_schedule -trigger_schedule: -- trigger +pipeline_schedules: +- project +- owner +- pipelines +- last_pipeline +pipeline_schedule: +- pipelines deploy_keys: - user - deploy_keys_projects @@ -221,7 +226,7 @@ project: - active_runners - variables - triggers -- trigger_schedules +- pipeline_schedules - environments - deployments - project_feature diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index a66086f8b4756..3af2a172e6dfa 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -188,6 +188,7 @@ Ci::Pipeline: - user_id - lock_version - auto_canceled_by_id +- pipeline_schedule_id CommitStatus: - id - project_id @@ -247,18 +248,19 @@ Ci::Trigger: - owner_id - description - ref -Ci::TriggerSchedule: +Ci::PipelineSchedule: - id -- project_id -- trigger_id -- deleted_at -- created_at -- updated_at +- description +- ref - cron - cron_timezone - next_run_at -- ref +- project_id +- owner_id - active +- deleted_at +- created_at +- updated_at DeployKey: - id - user_id diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index bf1dfe7f41297..9046d5c413fe9 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -32,6 +32,7 @@ ci_pipelines ci_runners ci_triggers + ci_pipeline_schedules deploy_keys deployments environments diff --git a/spec/models/ci/pipeline_schedule_spec.rb b/spec/models/ci/pipeline_schedule_spec.rb new file mode 100644 index 0000000000000..822b98c5f6c9a --- /dev/null +++ b/spec/models/ci/pipeline_schedule_spec.rb @@ -0,0 +1,112 @@ +require 'spec_helper' + +describe Ci::PipelineSchedule, models: true do + it { is_expected.to belong_to(:project) } + it { is_expected.to belong_to(:owner) } + + it { is_expected.to have_many(:pipelines) } + + it { is_expected.to respond_to(:ref) } + it { is_expected.to respond_to(:cron) } + it { is_expected.to respond_to(:cron_timezone) } + it { is_expected.to respond_to(:description) } + it { is_expected.to respond_to(:next_run_at) } + it { is_expected.to respond_to(:deleted_at) } + + describe 'validations' do + it 'does not allow invalid cron patters' do + pipeline_schedule = build(:ci_pipeline_schedule, cron: '0 0 0 * *') + + expect(pipeline_schedule).not_to be_valid + end + + it 'does not allow invalid cron patters' do + pipeline_schedule = build(:ci_pipeline_schedule, cron_timezone: 'invalid') + + expect(pipeline_schedule).not_to be_valid + end + end + + describe '#set_next_run_at' do + let!(:pipeline_schedule) { create(:ci_pipeline_schedule, :nightly) } + + context 'when creates new pipeline schedule' do + let(:expected_next_run_at) do + Gitlab::Ci::CronParser.new(pipeline_schedule.cron, pipeline_schedule.cron_timezone). + next_time_from(Time.now) + end + + it 'updates next_run_at automatically' do + expect(Ci::PipelineSchedule.last.next_run_at).to eq(expected_next_run_at) + end + end + + context 'when updates cron of exsisted pipeline schedule' do + let(:new_cron) { '0 0 1 1 *' } + + let(:expected_next_run_at) do + Gitlab::Ci::CronParser.new(new_cron, pipeline_schedule.cron_timezone). + next_time_from(Time.now) + end + + it 'updates next_run_at automatically' do + pipeline_schedule.update!(cron: new_cron) + + expect(Ci::PipelineSchedule.last.next_run_at).to eq(expected_next_run_at) + end + end + end + + describe '#schedule_next_run!' do + let!(:pipeline_schedule) { create(:ci_pipeline_schedule, :nightly) } + + context 'when reschedules after 10 days from now' do + let(:future_time) { 10.days.from_now } + + let(:expected_next_run_at) do + Gitlab::Ci::CronParser.new(pipeline_schedule.cron, pipeline_schedule.cron_timezone). + next_time_from(future_time) + end + + it 'points to proper next_run_at' do + Timecop.freeze(future_time) do + pipeline_schedule.schedule_next_run! + + expect(pipeline_schedule.next_run_at).to eq(expected_next_run_at) + end + end + end + end + + describe '#real_next_run' do + subject do + described_class.last.real_next_run(worker_cron: worker_cron, + worker_time_zone: worker_time_zone) + end + + context 'when GitLab time_zone is UTC' do + before do + allow(Time).to receive(:zone) + .and_return(ActiveSupport::TimeZone[worker_time_zone]) + end + + let(:worker_time_zone) { 'UTC' } + + context 'when cron_timezone is Eastern Time (US & Canada)' do + before do + create(:ci_pipeline_schedule, :nightly, + cron_timezone: 'Eastern Time (US & Canada)') + end + + let(:worker_cron) { '0 1 2 3 *' } + + it 'returns the next time worker executes' do + expect(subject.min).to eq(0) + expect(subject.hour).to eq(1) + expect(subject.day).to eq(2) + expect(subject.month).to eq(3) + end + end + end + end +end diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 3b222ea1c3dc7..208c8cb1c3d41 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -13,6 +13,7 @@ it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:user) } it { is_expected.to belong_to(:auto_canceled_by) } + it { is_expected.to belong_to(:pipeline_schedule) } it { is_expected.to have_many(:statuses) } it { is_expected.to have_many(:trigger_requests) } diff --git a/spec/models/ci/trigger_schedule_spec.rb b/spec/models/ci/trigger_schedule_spec.rb deleted file mode 100644 index 92447564d7c87..0000000000000 --- a/spec/models/ci/trigger_schedule_spec.rb +++ /dev/null @@ -1,108 +0,0 @@ -require 'spec_helper' - -describe Ci::TriggerSchedule, models: true do - it { is_expected.to belong_to(:project) } - it { is_expected.to belong_to(:trigger) } - it { is_expected.to respond_to(:ref) } - - describe '#set_next_run_at' do - context 'when creates new TriggerSchedule' do - before do - trigger_schedule = create(:ci_trigger_schedule, :nightly) - @expected_next_run_at = Gitlab::Ci::CronParser.new(trigger_schedule.cron, trigger_schedule.cron_timezone) - .next_time_from(Time.now) - end - - it 'updates next_run_at automatically' do - expect(Ci::TriggerSchedule.last.next_run_at).to eq(@expected_next_run_at) - end - end - - context 'when updates cron of exsisted TriggerSchedule' do - before do - trigger_schedule = create(:ci_trigger_schedule, :nightly) - new_cron = '0 0 1 1 *' - trigger_schedule.update!(cron: new_cron) # Subject - @expected_next_run_at = Gitlab::Ci::CronParser.new(new_cron, trigger_schedule.cron_timezone) - .next_time_from(Time.now) - end - - it 'updates next_run_at automatically' do - expect(Ci::TriggerSchedule.last.next_run_at).to eq(@expected_next_run_at) - end - end - end - - describe '#schedule_next_run!' do - context 'when reschedules after 10 days from now' do - before do - trigger_schedule = create(:ci_trigger_schedule, :nightly) - time_future = Time.now + 10.days - allow(Time).to receive(:now).and_return(time_future) - trigger_schedule.schedule_next_run! # Subject - @expected_next_run_at = Gitlab::Ci::CronParser.new(trigger_schedule.cron, trigger_schedule.cron_timezone) - .next_time_from(time_future) - end - - it 'points to proper next_run_at' do - expect(Ci::TriggerSchedule.last.next_run_at).to eq(@expected_next_run_at) - end - end - - context 'when cron is invalid' do - before do - trigger_schedule = create(:ci_trigger_schedule, :nightly) - trigger_schedule.cron = 'Invalid-cron' - trigger_schedule.schedule_next_run! # Subject - end - - it 'sets nil to next_run_at' do - expect(Ci::TriggerSchedule.last.next_run_at).to be_nil - end - end - - context 'when cron_timezone is invalid' do - before do - trigger_schedule = create(:ci_trigger_schedule, :nightly) - trigger_schedule.cron_timezone = 'Invalid-cron_timezone' - trigger_schedule.schedule_next_run! # Subject - end - - it 'sets nil to next_run_at' do - expect(Ci::TriggerSchedule.last.next_run_at).to be_nil - end - end - end - - describe '#real_next_run' do - subject do - Ci::TriggerSchedule.last.real_next_run(worker_cron: worker_cron, - worker_time_zone: worker_time_zone) - end - - context 'when GitLab time_zone is UTC' do - before do - allow(Time).to receive(:zone) - .and_return(ActiveSupport::TimeZone[worker_time_zone]) - end - - let(:worker_time_zone) { 'UTC' } - - context 'when cron_timezone is Eastern Time (US & Canada)' do - before do - create(:ci_trigger_schedule, :nightly, - cron_timezone: 'Eastern Time (US & Canada)') - end - - let(:worker_cron) { '0 1 2 3 *' } - - it 'returns the next time worker executes' do - expect(subject.min).to eq(0) - expect(subject.hour).to eq(1) - expect(subject.day).to eq(2) - expect(subject.month).to eq(3) - end - end - end - end -end diff --git a/spec/models/ci/trigger_spec.rb b/spec/models/ci/trigger_spec.rb index d26121018ce05..92c15c13c1843 100644 --- a/spec/models/ci/trigger_spec.rb +++ b/spec/models/ci/trigger_spec.rb @@ -7,7 +7,6 @@ it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:owner) } it { is_expected.to have_many(:trigger_requests) } - it { is_expected.to have_one(:trigger_schedule) } end describe 'before_validation' do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 2fc8ffed80a09..429b3dd83afb3 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -73,6 +73,7 @@ it { is_expected.to have_many(:notification_settings).dependent(:destroy) } it { is_expected.to have_many(:forks).through(:forked_project_links) } it { is_expected.to have_many(:uploads).dependent(:destroy) } + it { is_expected.to have_many(:pipeline_schedules).dependent(:destroy) } context 'after initialized' do it "has a project_feature" do diff --git a/spec/workers/pipeline_schedule_worker_spec.rb b/spec/workers/pipeline_schedule_worker_spec.rb new file mode 100644 index 0000000000000..91d5a16993fd2 --- /dev/null +++ b/spec/workers/pipeline_schedule_worker_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +describe PipelineScheduleWorker do + subject { described_class.new.perform } + + set(:project) { create(:project, :repository) } + set(:user) { create(:user) } + + let!(:pipeline_schedule) do + create(:ci_pipeline_schedule, :nightly, project: project, owner: user) + end + + before do + project.add_master(user) + + stub_ci_pipeline_to_return_yaml_file + end + + context 'when there is a scheduled pipeline within next_run_at' do + let(:next_run_at) { 2.days.ago } + + before do + pipeline_schedule.update_column(:next_run_at, next_run_at) + end + + it 'creates a new pipeline' do + expect { subject }.to change { project.pipelines.count }.by(1) + end + + it 'updates the next_run_at field' do + subject + + expect(pipeline_schedule.reload.next_run_at).to be > Time.now + end + + it 'sets the schedule on the pipeline' do + subject + expect(project.pipelines.last.pipeline_schedule).to eq(pipeline_schedule) + end + end + + context 'inactive schedule' do + before do + pipeline_schedule.update(active: false) + end + + it 'does not creates a new pipeline' do + expect { subject }.not_to change { project.pipelines.count } + end + end +end diff --git a/spec/workers/trigger_schedule_worker_spec.rb b/spec/workers/trigger_schedule_worker_spec.rb deleted file mode 100644 index 861bed4442ec5..0000000000000 --- a/spec/workers/trigger_schedule_worker_spec.rb +++ /dev/null @@ -1,73 +0,0 @@ -require 'spec_helper' - -describe TriggerScheduleWorker do - let(:worker) { described_class.new } - - before do - stub_ci_pipeline_to_return_yaml_file - end - - context 'when there is a scheduled trigger within next_run_at' do - let(:next_run_at) { 2.days.ago } - - let!(:trigger_schedule) do - create(:ci_trigger_schedule, :nightly) - end - - before do - trigger_schedule.update_column(:next_run_at, next_run_at) - end - - it 'creates a new trigger request' do - expect { worker.perform }.to change { Ci::TriggerRequest.count } - end - - it 'creates a new pipeline' do - expect { worker.perform }.to change { Ci::Pipeline.count } - expect(Ci::Pipeline.last).to be_pending - end - - it 'updates next_run_at' do - worker.perform - - expect(trigger_schedule.reload.next_run_at).not_to eq(next_run_at) - end - - context 'inactive schedule' do - before do - trigger_schedule.update(active: false) - end - - it 'does not create a new trigger' do - expect { worker.perform }.not_to change { Ci::TriggerRequest.count } - end - end - end - - context 'when there are no scheduled triggers within next_run_at' do - before { create(:ci_trigger_schedule, :nightly) } - - it 'does not create a new pipeline' do - expect { worker.perform }.not_to change { Ci::Pipeline.count } - end - - it 'does not update next_run_at' do - expect { worker.perform }.not_to change { Ci::TriggerSchedule.last.next_run_at } - end - end - - context 'when next_run_at is nil' do - before do - schedule = create(:ci_trigger_schedule, :nightly) - schedule.update_column(:next_run_at, nil) - end - - it 'does not create a new pipeline' do - expect { worker.perform }.not_to change { Ci::Pipeline.count } - end - - it 'does not update next_run_at' do - expect { worker.perform }.not_to change { Ci::TriggerSchedule.last.next_run_at } - end - end -end -- GitLab From e4814ddc44cc2f37ecd86eb0390c388451243585 Mon Sep 17 00:00:00 2001 From: Timothy Andrew <mail@timothyandrew.net> Date: Mon, 8 May 2017 10:24:10 +0000 Subject: [PATCH 357/363] Merge branch 'update-deps-licenses-for-9-2' into 'master' Update the vendor licenses file for 9.2 See merge request !11159 --- vendor/licenses.csv | 297 +++++++++++++++++++++++--------------------- 1 file changed, 158 insertions(+), 139 deletions(-) diff --git a/vendor/licenses.csv b/vendor/licenses.csv index 6441df25fe1b2..a8e7f5e3ea970 100644 --- a/vendor/licenses.csv +++ b/vendor/licenses.csv @@ -3,7 +3,7 @@ abbrev,1.0.9,ISC accepts,1.3.3,MIT ace-rails-ap,4.1.2,MIT acorn,4.0.11,MIT -acorn-dynamic-import,2.0.2,MIT +acorn-dynamic-import,2.0.1,MIT acorn-jsx,3.0.1,MIT actionmailer,4.2.8,MIT actionpack,4.2.8,MIT @@ -16,7 +16,7 @@ acts-as-taggable-on,4.0.0,MIT addressable,2.3.8,Apache 2.0 after,0.8.2,MIT after_commit_queue,1.3.0,MIT -ajv,4.11.5,MIT +ajv,4.11.2,MIT ajv-keywords,1.5.1,MIT akismet,2.0.0,MIT align-text,0.1.4,MIT @@ -29,7 +29,7 @@ ansi-regex,2.1.1,MIT ansi-styles,2.2.1,MIT anymatch,1.3.0,ISC append-transform,0.4.0,MIT -aproba,1.1.1,ISC +aproba,1.1.0,ISC are-we-there-yet,1.1.2,ISC arel,6.0.4,MIT argparse,1.0.9,MIT @@ -43,7 +43,7 @@ array-uniq,1.0.3,MIT array-unique,0.2.1,MIT arraybuffer.slice,0.0.6,MIT arrify,1.0.1,MIT -asana,0.4.0,MIT +asana,0.6.0,MIT asciidoctor,1.5.3,MIT asciidoctor-plantuml,0.0.7,MIT asn1,0.2.3,MIT @@ -62,8 +62,8 @@ aws-sign2,0.6.0,Apache 2.0 aws4,1.6.0,MIT axiom-types,0.1.1,MIT babel-code-frame,6.22.0,MIT -babel-core,6.24.0,MIT -babel-generator,6.24.0,MIT +babel-core,6.23.1,MIT +babel-generator,6.23.0,MIT babel-helper-bindify-decorators,6.22.0,MIT babel-helper-builder-binary-assignment-operator-visitor,6.22.0,MIT babel-helper-call-delegate,6.22.0,MIT @@ -78,10 +78,10 @@ babel-helper-regex,6.22.0,MIT babel-helper-remap-async-to-generator,6.22.0,MIT babel-helper-replace-supers,6.23.0,MIT babel-helpers,6.23.0,MIT -babel-loader,6.4.1,MIT +babel-loader,6.2.10,MIT babel-messages,6.23.0,MIT babel-plugin-check-es2015-constants,6.22.0,MIT -babel-plugin-istanbul,4.1.1,New BSD +babel-plugin-istanbul,4.0.0,New BSD babel-plugin-syntax-async-functions,6.13.0,MIT babel-plugin-syntax-async-generators,6.13.0,MIT babel-plugin-syntax-class-properties,6.13.0,MIT @@ -127,13 +127,13 @@ babel-preset-es2017,6.22.0,MIT babel-preset-latest,6.24.0,MIT babel-preset-stage-2,6.22.0,MIT babel-preset-stage-3,6.22.0,MIT -babel-register,6.24.0,MIT -babel-runtime,6.23.0,MIT +babel-register,6.23.0,MIT +babel-runtime,6.22.0,MIT babel-template,6.23.0,MIT babel-traverse,6.23.1,MIT babel-types,6.23.0,MIT babosa,1.0.2,MIT -babylon,6.16.1,MIT +babylon,6.15.0,MIT backo2,1.0.2,MIT balanced-match,0.4.2,MIT base32,0.3.2,MIT @@ -149,20 +149,20 @@ binary-extensions,1.8.0,MIT bindata,2.3.5,ruby blob,0.0.4,unknown block-stream,0.0.9,ISC -bluebird,3.5.0,MIT +bluebird,3.4.7,MIT bn.js,4.11.6,MIT -body-parser,1.17.1,MIT +body-parser,1.16.0,MIT boom,2.10.1,New BSD bootstrap-sass,3.3.6,MIT brace-expansion,1.1.6,MIT braces,1.8.5,MIT -brorand,1.1.0,MIT +brorand,1.0.7,MIT browser,2.2.0,MIT browserify-aes,1.0.6,MIT browserify-cipher,1.0.0,MIT browserify-des,1.0.0,MIT browserify-rsa,4.0.1,MIT -browserify-sign,4.0.4,ISC +browserify-sign,4.0.0,ISC browserify-zlib,0.1.4,MIT browserslist,1.7.7,MIT buffer,4.9.1,MIT @@ -178,8 +178,8 @@ callsites,0.2.0,MIT camelcase,1.2.1,MIT caniuse-api,1.6.1,MIT caniuse-db,1.0.30000649,CC-BY-4.0 -carrierwave,0.11.2,MIT -caseless,0.12.0,Apache 2.0 +carrierwave,1.0.0,MIT +caseless,0.11.0,Apache 2.0 cause,0.1,MIT center-align,0.1.3,MIT chalk,1.1.3,MIT @@ -194,6 +194,7 @@ citrus,3.0.2,MIT clap,1.1.3,MIT cli-cursor,1.0.2,MIT cli-width,2.1.0,ISC +clipboard,1.6.1,MIT cliui,2.1.0,ISC clone,1.0.2,MIT co,4.6.0,MIT @@ -216,14 +217,14 @@ commondir,1.0.1,MIT component-bind,1.0.0,unknown component-emitter,1.2.1,MIT component-inherit,0.0.3,unknown -compressible,2.0.10,MIT +compressible,2.0.9,MIT compression,1.6.2,MIT compression-webpack-plugin,0.3.2,MIT concat-map,0.0.1,MIT concat-stream,1.6.0,MIT config-chain,1.1.11,MIT configstore,1.4.0,Simplified BSD -connect,3.6.0,MIT +connect,3.5.0,MIT connect-history-api-fallback,1.3.0,MIT connection_pool,2.2.1,MIT console-browserify,1.1.0,MIT @@ -233,7 +234,7 @@ constants-browserify,1.0.0,MIT contains-path,0.1.0,MIT content-disposition,0.5.2,MIT content-type,1.0.2,MIT -convert-source-map,1.5.0,MIT +convert-source-map,1.3.0,MIT cookie,0.3.1,MIT cookie-signature,1.0.6,MIT core-js,2.4.1,MIT @@ -254,13 +255,13 @@ cssesc,0.1.0,MIT cssnano,3.10.0,MIT csso,2.3.2,MIT custom-event,1.0.1,MIT -d,1.0.0,MIT -d3,3.5.17,New BSD +d,0.1.1,MIT +d3,3.5.11,New BSD d3_rails,3.5.11,MIT dashdash,1.14.1,MIT date-now,0.1.4,MIT de-indent,1.0.2,MIT -debug,2.6.3,MIT +debug,2.6.0,MIT decamelize,1.2.0,MIT deckar01-task_list,1.0.6,MIT deep-extend,0.4.1,MIT @@ -271,6 +272,7 @@ defaults,1.0.3,MIT defined,1.0.0,MIT del,2.2.2,MIT delayed-stream,1.0.0,MIT +delegate,3.1.2,MIT delegates,1.0.0,MIT depd,1.1.0,MIT des.js,1.0.0,MIT @@ -283,8 +285,8 @@ di,0.0.1,MIT diff-lcs,1.2.5,"MIT,Perl Artistic v2,GNU GPL v2" diffie-hellman,5.0.2,MIT diffy,3.1.0,MIT -doctrine,2.0.0,Apache 2.0 -document-register-element,1.4.1,MIT +doctrine,1.5.0,BSD +document-register-element,1.3.0,MIT dom-serialize,2.2.1,MIT dom-serializer,0.1.0,MIT domain-browser,1.1.7,MIT @@ -294,7 +296,7 @@ domhandler,2.3.0,unknown domutils,1.5.1,unknown doorkeeper,4.2.0,MIT doorkeeper-openid_connect,1.1.2,MIT -dropzone,4.3.0,MIT +dropzone,4.2.0,MIT dropzonejs-rails,0.7.2,MIT duplexer,0.1.1,MIT duplexify,3.5.0,MIT @@ -303,36 +305,36 @@ editorconfig,0.13.2,MIT ee-first,1.1.1,MIT ejs,2.5.6,Apache 2.0 electron-to-chromium,1.3.3,ISC -elliptic,6.4.0,MIT +elliptic,6.3.3,MIT email_reply_trimmer,0.1.6,MIT emoji-unicode-version,0.2.1,MIT emojis-list,2.1.0,MIT encodeurl,1.0.1,MIT encryptor,3.0.0,MIT end-of-stream,1.0.0,MIT -engine.io,1.8.3,MIT -engine.io-client,1.8.3,MIT +engine.io,1.8.2,MIT +engine.io-client,1.8.2,MIT engine.io-parser,1.3.2,MIT enhanced-resolve,3.1.0,MIT ent,2.2.0,MIT entities,1.1.1,BSD-like equalizer,0.0.11,MIT errno,0.1.4,MIT -error-ex,1.3.1,MIT +error-ex,1.3.0,MIT erubis,2.7.0,MIT -es5-ext,0.10.15,MIT -es6-iterator,2.0.1,MIT -es6-map,0.1.5,MIT +es5-ext,0.10.12,MIT +es6-iterator,2.0.0,MIT +es6-map,0.1.4,MIT es6-promise,3.0.2,MIT -es6-set,0.1.5,MIT -es6-symbol,3.1.1,MIT -es6-weak-map,2.0.2,MIT +es6-set,0.1.4,MIT +es6-symbol,3.1.0,MIT +es6-weak-map,2.0.1,MIT escape-html,1.0.3,MIT escape-string-regexp,1.0.5,MIT escape_utils,1.1.1,MIT escodegen,1.8.1,Simplified BSD escope,3.6.0,Simplified BSD -eslint,3.19.0,MIT +eslint,3.15.0,MIT eslint-config-airbnb-base,10.0.1,MIT eslint-import-resolver-node,0.2.3,MIT eslint-import-resolver-webpack,0.8.1,MIT @@ -341,37 +343,39 @@ eslint-plugin-filenames,1.1.0,MIT eslint-plugin-html,2.0.1,ISC eslint-plugin-import,2.2.0,MIT eslint-plugin-jasmine,2.2.0,MIT -espree,3.4.1,Simplified BSD -esprima,2.7.3,Simplified BSD -esquery,1.0.0,BSD +eslint-plugin-promise,3.5.0,ISC +espree,3.4.0,Simplified BSD +esprima,3.1.3,Simplified BSD esrecurse,4.1.0,Simplified BSD estraverse,4.1.1,Simplified BSD esutils,2.0.2,BSD -etag,1.8.0,MIT +etag,1.7.0,MIT eve-raphael,0.5.0,Apache 2.0 -event-emitter,0.3.5,MIT +event-emitter,0.3.4,MIT event-stream,3.3.4,MIT eventemitter3,1.2.0,MIT events,1.1.1,MIT eventsource,0.1.6,MIT evp_bytestokey,1.0.0,MIT -excon,0.52.0,MIT +excon,0.55.0,MIT execjs,2.6.0,MIT exit-hook,1.1.1,MIT expand-braces,0.1.2,MIT expand-brackets,0.1.5,MIT expand-range,1.8.2,MIT -express,4.15.2,MIT +exports-loader,0.6.4,MIT +express,4.14.1,MIT expression_parser,0.9.0,MIT extend,3.0.0,MIT extglob,0.3.2,MIT extlib,0.9.16,MIT extract-zip,1.5.0,Simplified BSD extsprintf,1.0.2,MIT -faraday,0.9.2,MIT -faraday_middleware,0.10.0,MIT +faraday,0.11.0,MIT +faraday_middleware,0.11.0.1,MIT faraday_middleware-multi_json,0.0.6,MIT fast-levenshtein,2.0.6,MIT +fast_gettext,1.4.0,"MIT,ruby" fastparse,1.1.1,MIT faye-websocket,0.7.3,MIT fd-slicer,1.0.1,MIT @@ -383,37 +387,37 @@ filename-regex,2.0.0,MIT fileset,2.0.3,MIT filesize,3.3.0,New BSD fill-range,2.2.3,MIT -finalhandler,1.0.1,MIT +finalhandler,0.5.1,MIT find-cache-dir,0.1.1,MIT find-root,0.1.2,MIT find-up,2.1.0,MIT flat-cache,1.2.2,MIT flatten,1.0.2,MIT flowdock,0.7.1,MIT -fog-aws,0.11.0,MIT -fog-core,1.42.0,MIT +fog-aws,0.13.0,MIT +fog-core,1.44.1,MIT fog-google,0.5.0,MIT fog-json,1.0.2,MIT fog-local,0.3.0,MIT fog-openstack,0.1.6,MIT fog-rackspace,0.1.1,MIT -fog-xml,0.1.2,MIT +fog-xml,0.1.3,MIT font-awesome-rails,4.7.0.1,"MIT,SIL Open Font License" -for-in,1.0.2,MIT -for-own,0.1.5,MIT +for-in,0.1.6,MIT +for-own,0.1.4,MIT forever-agent,0.6.1,Apache 2.0 form-data,2.1.2,MIT formatador,0.2.5,MIT forwarded,0.1.0,MIT -fresh,0.5.0,MIT +fresh,0.3.0,MIT from,0.1.7,MIT fs-extra,1.0.0,MIT fs.realpath,1.0.0,ISC fsevents,,unknown -fstream,1.0.11,ISC +fstream,1.0.10,ISC fstream-ignore,1.0.5,ISC function-bind,1.1.0,MIT -gauge,2.7.3,ISC +gauge,2.7.2,ISC gemnasium-gitlab-service,0.2.6,MIT gemojione,3.0.1,MIT generate-function,2.0.0,MIT @@ -421,7 +425,9 @@ generate-object-property,1.2.0,MIT get-caller-file,1.0.2,ISC get_process_mem,0.2.0,MIT getpass,0.1.6,MIT -gitaly,0.5.0,MIT +gettext_i18n_rails,1.8.0,MIT +gettext_i18n_rails_js,1.2.0,MIT +gitaly,0.6.0,MIT github-linguist,4.7.6,MIT github-markup,1.4.0,MIT gitlab-flowdock-git-hook,1.0.1,MIT @@ -432,12 +438,13 @@ glob,7.1.1,ISC glob-base,0.3.0,MIT glob-parent,2.0.0,ISC globalid,0.3.7,MIT -globals,9.17.0,MIT +globals,9.14.0,MIT globby,5.0.0,MIT gollum-grit_adapter,1.0.1,MIT gollum-lib,4.2.1,MIT gollum-rugged_adapter,0.4.4,MIT gon,6.1.0,MIT +good-listener,1.2.2,MIT google-api-client,0.8.7,Apache 2.0 google-protobuf,3.2.0.2,New BSD googleauth,0.5.1,Apache 2.0 @@ -446,13 +453,12 @@ graceful-fs,4.1.11,ISC graceful-readlink,1.0.1,MIT grape,0.19.1,MIT grape-entity,0.6.0,MIT -grpc,1.1.2,New BSD +grpc,1.2.5,New BSD gzip-size,3.0.0,MIT hamlit,2.6.1,MIT handle-thing,1.2.5,MIT handlebars,4.0.6,MIT -har-schema,1.0.5,ISC -har-validator,4.2.1,ISC +har-validator,2.0.6,ISC has,1.0.1,MIT has-ansi,2.0.0,MIT has-binary,0.1.7,MIT @@ -463,14 +469,14 @@ hash-sum,1.0.2,MIT hash.js,1.0.3,MIT hasha,2.2.0,MIT hashie,3.5.5,MIT +hashie-forbidden_attributes,0.1.1,MIT hawk,3.1.3,New BSD he,1.1.1,MIT health_check,2.6.0,MIT hipchat,1.5.2,MIT -hmac-drbg,1.0.0,MIT hoek,2.16.3,New BSD home-or-tmp,2.0.0,MIT -hosted-git-info,2.4.1,ISC +hosted-git-info,2.2.0,ISC hpack.js,2.1.6,MIT html-comment-regex,1.1.1,MIT html-entities,1.2.0,MIT @@ -481,7 +487,7 @@ htmlparser2,3.9.2,MIT http,0.9.8,MIT http-cookie,1.0.3,MIT http-deceiver,1.2.7,MIT -http-errors,1.6.1,MIT +http-errors,1.5.1,MIT http-form_data,1.0.1,MIT http-proxy,1.16.2,MIT http-proxy-middleware,0.17.4,MIT @@ -495,7 +501,7 @@ ice_nine,0.11.2,MIT iconv-lite,0.4.15,MIT icss-replace-symbols,1.0.2,ISC ieee754,1.1.8,New BSD -ignore,3.2.6,MIT +ignore,3.2.2,MIT ignore-by-default,1.0.1,ISC immediate,3.0.6,MIT imurmurhash,0.1.4,MIT @@ -507,16 +513,16 @@ influxdb,0.2.3,MIT inherits,2.0.3,ISC ini,1.3.4,ISC inquirer,0.12.0,MIT -interpret,1.0.2,MIT +interpret,1.0.1,MIT invariant,2.2.2,New BSD invert-kv,1.0.0,MIT -ipaddr.js,1.3.0,MIT +ipaddr.js,1.2.0,MIT ipaddress,0.8.3,MIT is-absolute,0.2.6,MIT is-absolute-url,2.1.0,MIT is-arrayish,0.2.1,MIT is-binary-path,1.0.1,MIT -is-buffer,1.1.5,MIT +is-buffer,1.1.4,MIT is-builtin-module,1.0.0,MIT is-dotfile,1.0.2,MIT is-equal-shallow,0.1.3,MIT @@ -525,7 +531,7 @@ is-extglob,1.0.0,MIT is-finite,1.0.2,MIT is-fullwidth-code-point,1.0.0,MIT is-glob,2.0.1,MIT -is-my-json-valid,2.16.0,MIT +is-my-json-valid,2.15.0,MIT is-npm,1.0.0,MIT is-number,2.1.0,MIT is-path-cwd,1.0.0,MIT @@ -546,31 +552,32 @@ is-utf8,0.2.1,MIT is-windows,0.2.0,MIT isarray,1.0.0,MIT isbinaryfile,3.0.2,MIT -isexe,2.0.0,ISC +isexe,1.1.2,ISC isobject,2.1.0,MIT isstream,0.1.2,MIT istanbul,0.4.5,New BSD -istanbul-api,1.1.7,New BSD -istanbul-lib-coverage,1.0.2,New BSD -istanbul-lib-hook,1.0.5,New BSD -istanbul-lib-instrument,1.7.0,New BSD -istanbul-lib-report,1.0.0,New BSD -istanbul-lib-source-maps,1.1.1,New BSD -istanbul-reports,1.0.2,New BSD +istanbul-api,1.1.1,New BSD +istanbul-lib-coverage,1.0.1,New BSD +istanbul-lib-hook,1.0.0,New BSD +istanbul-lib-instrument,1.4.2,New BSD +istanbul-lib-report,1.0.0-alpha.3,New BSD +istanbul-lib-source-maps,1.1.0,New BSD +istanbul-reports,1.0.1,New BSD jasmine-core,2.5.2,MIT jasmine-jquery,2.1.1,MIT +jed,1.1.1,MIT jira-ruby,1.1.2,MIT jodid25519,1.0.2,MIT -jquery,2.2.4,MIT +jquery,2.2.1,MIT jquery-atwho-rails,1.3.2,MIT jquery-rails,4.1.1,MIT -jquery-ujs,1.2.2,MIT +jquery-ujs,1.2.1,MIT js-base64,2.1.9,BSD js-beautify,1.6.12,MIT -js-cookie,2.1.4,MIT +js-cookie,2.1.3,MIT js-tokens,3.0.1,MIT js-yaml,3.7.0,MIT -jsbn,0.1.1,MIT +jsbn,0.1.0,BSD jsesc,1.3.0,MIT json,1.8.6,ruby json-jwt,1.7.1,MIT @@ -583,18 +590,18 @@ json5,0.5.1,MIT jsonfile,2.4.0,MIT jsonify,0.0.0,Public Domain jsonpointer,4.0.1,MIT -jsprim,1.4.0,MIT +jsprim,1.3.1,MIT jszip,3.1.3,(MIT OR GPL-3.0) jszip-utils,0.0.2,MIT or GPLv3 jwt,1.5.6,MIT kaminari,0.17.0,MIT -karma,1.6.0,MIT -karma-coverage-istanbul-reporter,0.2.3,MIT +karma,1.4.1,MIT +karma-coverage-istanbul-reporter,0.2.0,MIT karma-jasmine,1.1.0,MIT -karma-mocha-reporter,2.2.3,MIT -karma-phantomjs-launcher,1.0.4,MIT +karma-mocha-reporter,2.2.2,MIT +karma-phantomjs-launcher,1.0.2,MIT karma-sourcemap-loader,0.3.7,MIT -karma-webpack,2.0.3,MIT +karma-webpack,2.0.2,MIT kew,0.7.0,Apache 2.0 kgio,2.10.0,LGPL-2.1+ kind-of,3.1.0,MIT @@ -610,7 +617,8 @@ lie,3.1.1,MIT little-plugger,1.1.4,MIT load-json-file,1.1.0,MIT loader-runner,2.3.0,MIT -loader-utils,0.2.17,MIT +loader-utils,0.2.16,MIT +locale,2.1.2,"ruby,LGPLv3+" locate-path,2.0.0,MIT lodash,4.17.4,MIT lodash._baseassign,3.2.0,MIT @@ -638,16 +646,17 @@ lodash.snakecase,4.0.1,MIT lodash.uniq,4.5.0,MIT lodash.words,4.2.0,MIT log4js,0.6.38,Apache 2.0 -logging,2.1.0,MIT +logging,2.2.2,MIT longest,1.0.1,MIT loofah,2.0.3,MIT loose-envify,1.3.1,MIT lowercase-keys,1.0.0,MIT lru-cache,3.2.0,ISC macaddress,0.2.8,MIT -mail,2.6.4,MIT +mail,2.6.5,MIT mail_room,0.9.1,MIT map-stream,0.1.0,unknown +marked,0.3.6,MIT math-expression-evaluator,1.2.16,MIT media-typer,0.3.0,MIT memoist,0.15.0,MIT @@ -658,17 +667,16 @@ methods,1.1.2,MIT micromatch,2.3.11,MIT miller-rabin,4.0.0,MIT mime,1.3.4,MIT -mime-db,1.27.0,MIT +mime-db,1.26.0,MIT mime-types,2.99.3,"MIT,Artistic-2.0,GPL-2.0" mimemagic,0.3.0,MIT mini_portile2,2.1.0,MIT minimalistic-assert,1.0.0,ISC -minimalistic-crypto-utils,1.0.1,MIT minimatch,3.0.3,ISC minimist,0.0.8,MIT mkdirp,0.5.1,MIT -moment,2.18.1,MIT -mousetrap,1.6.1,Apache 2.0 +moment,2.17.1,MIT +mousetrap,1.4.6,Apache 2.0 mousetrap-rails,1.4.6,"MIT,Apache" ms,0.7.2,MIT multi_json,1.12.1,MIT @@ -684,14 +692,15 @@ nested-error-stacks,1.0.2,MIT net-ldap,0.12.1,MIT net-ssh,3.0.1,MIT netrc,0.11.0,MIT +node-ensure,0.0.0,MIT node-libs-browser,2.0.0,MIT -node-pre-gyp,0.6.34,New BSD +node-pre-gyp,0.6.33,New BSD node-zopfli,2.0.2,MIT nodemon,1.11.0,MIT nokogiri,1.6.8.1,MIT -nopt,4.0.1,ISC -normalize-package-data,2.3.6,Simplified BSD -normalize-path,2.1.1,MIT +nopt,3.0.6,ISC +normalize-package-data,2.3.5,Simplified BSD +normalize-path,2.0.1,MIT normalize-range,0.1.2,MIT normalize-url,1.9.1,MIT npmlog,4.0.2,ISC @@ -700,13 +709,13 @@ number-is-nan,1.0.1,MIT numerizer,0.1.1,MIT oauth,0.5.1,MIT oauth-sign,0.8.2,Apache 2.0 -oauth2,1.2.0,MIT +oauth2,1.3.1,MIT object-assign,4.1.1,MIT object-component,0.0.3,unknown object.omit,2.0.1,MIT obuf,1.1.1,MIT octokit,4.6.2,MIT -oj,2.17.4,MIT +oj,2.17.5,MIT omniauth,1.4.2,MIT omniauth-auth0,1.4.1,MIT omniauth-authentiq,0.3.0,MIT @@ -727,7 +736,7 @@ omniauth-twitter,1.2.1,MIT omniauth_crowd,2.2.3,MIT on-finished,2.3.0,MIT on-headers,1.0.1,MIT -once,1.4.0,ISC +once,1.3.3,ISC onetime,1.1.0,MIT opener,1.4.3,(WTFPL OR MIT) opn,4.0.2,MIT @@ -748,7 +757,7 @@ p-locate,2.0.0,MIT package-json,1.2.0,MIT pako,1.0.5,(MIT AND Zlib) paranoia,2.2.0,MIT -parse-asn1,5.1.0,ISC +parse-asn1,5.0.0,ISC parse-glob,3.0.4,MIT parse-json,2.2.0,MIT parsejson,0.0.3,MIT @@ -762,10 +771,10 @@ path-is-inside,1.0.2,(WTFPL OR MIT) path-parse,1.0.5,MIT path-to-regexp,0.1.7,MIT path-type,1.1.0,MIT -pause-stream,0.0.11,"Apache2,MIT" +pause-stream,0.0.11,"MIT,Apache2" pbkdf2,3.0.9,MIT +pdfjs-dist,1.8.252,Apache 2.0 pend,1.2.0,MIT -performance-now,0.2.0,MIT pg,0.18.4,"BSD,ruby,GPL" phantomjs-prebuilt,2.1.14,Apache 2.0 pify,2.3.0,MIT @@ -775,6 +784,7 @@ pinkie-promise,2.0.1,MIT pkg-dir,1.0.0,MIT pkg-up,1.0.0,MIT pluralize,1.2.1,MIT +po_to_json,1.0.1,MIT portfinder,1.0.13,MIT posix-spawn,0.3.11,"MIT,LGPL" postcss,5.2.16,MIT @@ -818,12 +828,13 @@ premailer,1.8.6,New BSD premailer-rails,1.9.2,MIT prepend-http,1.0.4,MIT preserve,0.2.0,MIT +prismjs,1.6.0,MIT private,0.1.7,MIT process,0.11.9,MIT process-nextick-args,1.0.7,MIT progress,1.1.8,MIT proto-list,1.2.4,ISC -proxy-addr,1.1.4,MIT +proxy-addr,1.1.3,MIT prr,0.0.0,MIT ps-tree,1.1.0,MIT pseudomap,1.0.2,ISC @@ -832,7 +843,7 @@ punycode,1.4.1,MIT pyu-ruby-sasl,0.0.3.3,MIT q,1.5.0,MIT qjobs,1.1.5,MIT -qs,6.4.0,New BSD +qs,6.2.0,New BSD query-string,4.3.2,MIT querystring,0.2.0,MIT querystring-es3,0.2.1,MIT @@ -857,15 +868,16 @@ randomatic,1.1.6,MIT randombytes,2.0.3,MIT range-parser,1.2.0,MIT raphael,2.2.7,MIT +raven-js,3.15.0,Simplified BSD raw-body,2.2.0,MIT raw-loader,0.5.1,MIT -rc,1.2.1,(BSD-2-Clause OR MIT OR Apache-2.0) +rc,1.1.6,(BSD-2-Clause OR MIT OR Apache-2.0) rdoc,4.2.2,ruby react-dev-utils,0.5.2,New BSD read-all-stream,3.1.0,MIT read-pkg,1.1.0,MIT read-pkg-up,1.0.1,MIT -readable-stream,2.0.6,MIT +readable-stream,2.2.2,MIT readdirp,2.1.0,MIT readline2,1.0.1,MIT recaptcha,3.0.0,MIT @@ -873,7 +885,7 @@ rechoir,0.6.2,MIT recursive-open-struct,1.0.0,MIT recursive-readdir,2.1.1,MIT redcarpet,3.4.0,MIT -redis,3.2.2,MIT +redis,3.3.3,MIT redis-actionpack,5.0.1,MIT redis-activesupport,5.0.1,MIT redis-namespace,1.5.2,MIT @@ -883,18 +895,17 @@ redis-store,1.2.0,MIT reduce-css-calc,1.3.0,MIT reduce-function-call,1.0.2,MIT regenerate,1.3.2,MIT -regenerator-runtime,0.10.3,MIT +regenerator-runtime,0.10.1,MIT regenerator-transform,0.9.8,BSD regex-cache,0.4.3,MIT regexpu-core,2.0.0,MIT registry-url,3.1.0,MIT regjsgen,0.2.0,MIT regjsparser,0.1.5,BSD -remove-trailing-separator,1.0.1,ISC repeat-element,1.1.2,MIT repeat-string,1.6.1,MIT repeating,2.0.1,MIT -request,2.81.0,Apache 2.0 +request,2.79.0,Apache 2.0 request-progress,2.0.1,MIT request_store,1.3.1,MIT require-directory,2.1.1,MIT @@ -902,14 +913,14 @@ require-from-string,1.2.1,MIT require-main-filename,1.0.1,ISC require-uncached,1.0.3,MIT requires-port,1.0.0,MIT -resolve,1.3.2,MIT +resolve,1.2.0,MIT resolve-from,1.0.1,MIT responders,2.3.0,MIT rest-client,2.0.0,MIT restore-cursor,1.0.1,MIT retriable,1.4.1,MIT right-align,0.1.3,MIT -rimraf,2.6.1,ISC +rimraf,2.5.4,ISC rinku,2.0.0,ISC ripemd160,1.0.1,New BSD rotp,2.1.2,MIT @@ -919,6 +930,7 @@ rqrcode-rails3,0.1.7,MIT ruby-fogbugz,0.2.1,MIT ruby-prof,0.16.2,Simplified BSD ruby-saml,1.4.1,MIT +ruby_parser,3.8.4,MIT rubyntlm,0.5.2,MIT rubypants,0.2.0,BSD rufus-scheduler,3.1.10,MIT @@ -934,23 +946,25 @@ sawyer,0.8.1,MIT sax,1.2.2,ISC securecompare,1.0.0,MIT seed-fu,2.3.6,MIT +select,1.1.2,MIT select-hose,2.0.0,MIT select2,3.5.2-browserify,unknown select2-rails,3.5.9.3,MIT semver,5.3.0,ISC semver-diff,2.1.0,MIT -send,0.15.1,MIT +send,0.14.2,MIT sentry-raven,2.4.0,Apache 2.0 serve-index,1.8.0,MIT -serve-static,1.12.1,MIT +serve-static,1.11.2,MIT set-blocking,2.0.0,ISC set-immediate-shim,1.0.1,MIT setimmediate,1.0.5,MIT -setprototypeof,1.0.3,ISC +setprototypeof,1.0.2,ISC settingslogic,2.0.9,MIT +sexp_processor,4.8.0,MIT sha.js,2.4.8,MIT -shelljs,0.7.7,New BSD -sidekiq,4.2.7,LGPL +shelljs,0.7.6,New BSD +sidekiq,5.0.0,LGPL sidekiq-cron,0.4.4,MIT sidekiq-limit_fetch,3.4.0,MIT sigmund,1.0.1,ISC @@ -961,16 +975,16 @@ slash,1.0.0,MIT slice-ansi,0.0.4,MIT slide,1.1.6,ISC sntp,1.0.9,BSD -socket.io,1.7.3,MIT +socket.io,1.7.2,MIT socket.io-adapter,0.5.0,MIT -socket.io-client,1.7.3,MIT +socket.io-client,1.7.2,MIT socket.io-parser,2.3.1,MIT sockjs,0.3.18,MIT sockjs-client,1.0.1,MIT sort-keys,1.1.2,MIT source-list-map,0.1.8,MIT source-map,0.5.6,New BSD -source-map-support,0.4.14,MIT +source-map-support,0.4.11,MIT spdx-correct,1.0.2,Apache 2.0 spdx-expression-parse,1.0.4,(MIT AND CC-BY-3.0) spdx-license-ids,1.2.2,Unlicense @@ -980,7 +994,8 @@ split,0.3.3,MIT sprintf-js,1.0.3,New BSD sprockets,3.7.1,MIT sprockets-rails,3.2.0,MIT -sshpk,1.11.0,MIT +sql.js,0.4.0,MIT +sshpk,1.10.2,MIT state_machines,0.4.0,MIT state_machines-activemodel,0.4.0,MIT state_machines-activerecord,0.4.0,MIT @@ -988,7 +1003,7 @@ stats-webpack-plugin,0.4.3,MIT statuses,1.3.1,MIT stream-browserify,2.0.1,MIT stream-combiner,0.0.4,MIT -stream-http,2.7.0,MIT +stream-http,2.6.3,MIT stream-shift,1.0.0,MIT strict-uri-encode,1.1.0,MIT string-length,1.0.1,MIT @@ -998,16 +1013,17 @@ stringex,2.5.2,MIT stringstream,0.0.5,MIT strip-ansi,3.0.1,MIT strip-bom,2.0.0,MIT -strip-json-comments,2.0.1,MIT -supports-color,3.2.3,MIT +strip-json-comments,1.0.4,MIT +supports-color,0.2.0,MIT svgo,0.7.2,MIT sys-filesystem,1.1.6,Artistic 2.0 table,3.8.3,New BSD tapable,0.2.6,MIT tar,2.2.1,ISC -tar-pack,3.4.0,Simplified BSD +tar-pack,3.3.0,Simplified BSD temple,0.7.7,MIT -test-exclude,4.0.3,ISC +test-exclude,4.0.0,ISC +text,1.3.1,MIT text-table,0.2.0,MIT thor,0.19.4,MIT thread_safe,0.3.6,Apache 2.0 @@ -1021,7 +1037,8 @@ timeago.js,2.0.5,MIT timed-out,2.0.0,MIT timers-browserify,2.0.2,MIT timfel-krb5-auth,0.8.3,LGPL -tmp,0.0.31,MIT +tiny-emitter,1.1.0,MIT +tmp,0.0.28,MIT to-array,0.1.4,MIT to-arraybuffer,1.0.1,MIT to-fast-properties,1.0.2,MIT @@ -1034,10 +1051,10 @@ trim-right,1.0.1,MIT truncato,0.7.8,MIT tryit,1.0.3,MIT tty-browserify,0.0.0,MIT -tunnel-agent,0.6.0,Apache 2.0 +tunnel-agent,0.4.3,Apache 2.0 tweetnacl,0.14.5,Unlicense type-check,0.3.2,MIT -type-is,1.6.15,MIT +type-is,1.6.14,MIT typedarray,0.0.6,MIT tzinfo,1.2.2,MIT u2f,0.2.1,MIT @@ -1060,17 +1077,18 @@ uniqs,2.0.0,MIT unpipe,1.0.0,MIT update-notifier,0.5.0,Simplified BSD url,0.11.0,MIT +url-loader,0.5.8,MIT url-parse,1.0.5,MIT url_safe_base64,0.2.2,MIT user-home,2.0.0,MIT -useragent,2.1.13,MIT +useragent,2.1.12,MIT util,0.10.3,MIT util-deprecate,1.0.2,MIT utils-merge,1.0.0,MIT uuid,3.0.1,MIT validate-npm-package-license,3.0.1,Apache 2.0 validates_hostname,1.0.6,MIT -vary,1.1.1,MIT +vary,1.1.0,MIT vendors,1.0.1,MIT verror,1.3.6,MIT version_sorter,2.1.0,MIT @@ -1085,30 +1103,31 @@ vue-loader,11.3.4,MIT vue-resource,0.9.3,MIT vue-style-loader,2.0.5,MIT vue-template-compiler,2.2.6,MIT -vue-template-es2015-compiler,1.5.2,MIT +vue-template-es2015-compiler,1.5.1,MIT warden,1.2.6,MIT watchpack,1.3.1,MIT wbuf,1.7.2,MIT webpack,2.3.3,MIT -webpack-bundle-analyzer,2.3.1,MIT -webpack-dev-middleware,1.10.1,MIT +webpack-bundle-analyzer,2.3.0,MIT +webpack-dev-middleware,1.10.0,MIT webpack-dev-server,2.4.2,MIT webpack-rails,0.9.10,MIT -webpack-sources,0.1.5,MIT +webpack-sources,0.1.4,MIT websocket-driver,0.6.5,MIT websocket-extensions,0.1.1,MIT whet.extend,0.9.9,MIT -which,1.2.14,ISC +which,1.2.12,ISC which-module,1.0.0,ISC wide-align,1.1.0,ISC wikicloth,0.8.1,MIT window-size,0.1.0,MIT -wordwrap,1.0.0,MIT +wordwrap,0.0.2,MIT/X11 +worker-loader,0.8.0,MIT wrap-ansi,2.1.0,MIT wrappy,1.0.2,ISC write,0.2.1,MIT write-file-atomic,1.3.1,ISC -ws,1.1.2,MIT +ws,1.1.1,MIT wtf-8,1.0.0,MIT xdg-basedir,2.0.0,MIT xmlhttprequest-ssl,1.5.3,MIT -- GitLab From 07adf34ce022a5d84770ac9f605df9fafee85b8d Mon Sep 17 00:00:00 2001 From: Timothy Andrew <mail@timothyandrew.net> Date: Mon, 8 May 2017 10:46:24 +0000 Subject: [PATCH 358/363] Merge branch 'update-templates-for-9-2' into 'master' Update gitignore, dockerfile, and license templates for 9.2 See merge request !11158 --- vendor/Dockerfile/OpenJDK-alpine.Dockerfile | 8 ++++++ vendor/Dockerfile/OpenJDK.Dockerfile | 8 ++++++ vendor/Dockerfile/Python-alpine.Dockerfile | 19 +++++++++++++ vendor/Dockerfile/Python.Dockerfile | 22 +++++++++++++++ vendor/gitignore/Global/Archives.gitignore | 1 + vendor/gitignore/Global/JetBrains.gitignore | 3 +++ .../Global/MicrosoftOffice.gitignore | 2 +- vendor/gitignore/Magento.gitignore | 27 +++++++++++++++++++ vendor/gitignore/Python.gitignore | 4 +++ vendor/gitignore/Qt.gitignore | 1 + vendor/gitignore/UnrealEngine.gitignore | 5 ++++ 11 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 vendor/Dockerfile/OpenJDK-alpine.Dockerfile create mode 100644 vendor/Dockerfile/OpenJDK.Dockerfile create mode 100644 vendor/Dockerfile/Python-alpine.Dockerfile create mode 100644 vendor/Dockerfile/Python.Dockerfile diff --git a/vendor/Dockerfile/OpenJDK-alpine.Dockerfile b/vendor/Dockerfile/OpenJDK-alpine.Dockerfile new file mode 100644 index 0000000000000..ee853d9cfd26f --- /dev/null +++ b/vendor/Dockerfile/OpenJDK-alpine.Dockerfile @@ -0,0 +1,8 @@ +FROM openjdk:8-alpine + +COPY . /usr/src/myapp +WORKDIR /usr/src/myapp + +RUN javac Main.java + +CMD ["java", "Main"] diff --git a/vendor/Dockerfile/OpenJDK.Dockerfile b/vendor/Dockerfile/OpenJDK.Dockerfile new file mode 100644 index 0000000000000..8a2ae62d93bf2 --- /dev/null +++ b/vendor/Dockerfile/OpenJDK.Dockerfile @@ -0,0 +1,8 @@ +FROM openjdk:9 + +COPY . /usr/src/myapp +WORKDIR /usr/src/myapp + +RUN javac Main.java + +CMD ["java", "Main"] diff --git a/vendor/Dockerfile/Python-alpine.Dockerfile b/vendor/Dockerfile/Python-alpine.Dockerfile new file mode 100644 index 0000000000000..59ac9f504dec5 --- /dev/null +++ b/vendor/Dockerfile/Python-alpine.Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.6-alpine + +# Edit with mysql-client, postgresql-client, sqlite3, etc. for your needs. +# Or delete entirely if not needed. +RUN apk --no-cache add postgresql-client + +WORKDIR /usr/src/app + +COPY requirements.txt /usr/src/app/ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /usr/src/app + +# For Django +EXPOSE 8000 +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] + +# For some other command +# CMD ["python", "app.py"] diff --git a/vendor/Dockerfile/Python.Dockerfile b/vendor/Dockerfile/Python.Dockerfile new file mode 100644 index 0000000000000..7c43ad990609f --- /dev/null +++ b/vendor/Dockerfile/Python.Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.6 + +# Edit with mysql-client, postgresql-client, sqlite3, etc. for your needs. +# Or delete entirely if not needed. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /usr/src/app + +COPY requirements.txt /usr/src/app/ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /usr/src/app + +# For Django +EXPOSE 8000 +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] + +# For some other command +# CMD ["python", "app.py"] diff --git a/vendor/gitignore/Global/Archives.gitignore b/vendor/gitignore/Global/Archives.gitignore index e9eda68baf2e6..f440b808d982b 100644 --- a/vendor/gitignore/Global/Archives.gitignore +++ b/vendor/gitignore/Global/Archives.gitignore @@ -5,6 +5,7 @@ *.rar *.zip *.gz +*.tgz *.bzip *.bz2 *.xz diff --git a/vendor/gitignore/Global/JetBrains.gitignore b/vendor/gitignore/Global/JetBrains.gitignore index a5d4cc86d33d5..ff23445e2b0d7 100644 --- a/vendor/gitignore/Global/JetBrains.gitignore +++ b/vendor/gitignore/Global/JetBrains.gitignore @@ -19,6 +19,9 @@ .idea/**/gradle.xml .idea/**/libraries +# CMake +cmake-build-debug/ + # Mongo Explorer plugin: .idea/**/mongoSettings.xml diff --git a/vendor/gitignore/Global/MicrosoftOffice.gitignore b/vendor/gitignore/Global/MicrosoftOffice.gitignore index cb89174566014..0c203662d3908 100644 --- a/vendor/gitignore/Global/MicrosoftOffice.gitignore +++ b/vendor/gitignore/Global/MicrosoftOffice.gitignore @@ -13,4 +13,4 @@ ~$*.ppt* # Visio autosave temporary files -*.~vsdx +*.~vsd* diff --git a/vendor/gitignore/Magento.gitignore b/vendor/gitignore/Magento.gitignore index b282f5cf54703..6f1fa22399234 100644 --- a/vendor/gitignore/Magento.gitignore +++ b/vendor/gitignore/Magento.gitignore @@ -3,14 +3,41 @@ #--------------------------# /app/etc/local.xml + /media/* !/media/.htaccess + +!/media/customer +/media/customer/* !/media/customer/.htaccess + +!/media/dhl +/media/dhl/* !/media/dhl/logo.jpg + +!/media/downloadable +/media/downloadable/* !/media/downloadable/.htaccess + +!/media/xmlconnect +/media/xmlconnect/* + +!/media/xmlconnect/custom +/media/xmlconnect/custom/* !/media/xmlconnect/custom/ok.gif + +!/media/xmlconnect/original +/media/xmlconnect/original/* !/media/xmlconnect/original/ok.gif + +!/media/xmlconnect/system +/media/xmlconnect/system/* !/media/xmlconnect/system/ok.gif + /var/* !/var/.htaccess + +!/var/package +/var/package/* !/var/package/*.xml + diff --git a/vendor/gitignore/Python.gitignore b/vendor/gitignore/Python.gitignore index ff65a437185fe..768d5f400bb1d 100644 --- a/vendor/gitignore/Python.gitignore +++ b/vendor/gitignore/Python.gitignore @@ -89,9 +89,13 @@ ENV/ # Spyder project settings .spyderproject +.spyproject # Rope project settings .ropeproject # mkdocs documentation /site + +# mypy +.mypy_cache/ diff --git a/vendor/gitignore/Qt.gitignore b/vendor/gitignore/Qt.gitignore index c7659c24f386b..6732e72091c00 100644 --- a/vendor/gitignore/Qt.gitignore +++ b/vendor/gitignore/Qt.gitignore @@ -20,6 +20,7 @@ *.qbs.user.* *.moc moc_*.cpp +moc_*.h qrc_*.cpp ui_*.h Makefile* diff --git a/vendor/gitignore/UnrealEngine.gitignore b/vendor/gitignore/UnrealEngine.gitignore index 2f096001fec46..6c6e1c327fd14 100644 --- a/vendor/gitignore/UnrealEngine.gitignore +++ b/vendor/gitignore/UnrealEngine.gitignore @@ -54,6 +54,11 @@ Binaries/* # Builds Build/* +# Whitelist PakBlacklist-<BuildConfiguration>.txt files +!Build/*/ +Build/*/** +!Build/*/PakBlacklist*.txt + # Don't ignore icon files in Build !Build/**/*.ico -- GitLab From 5c03b64b9d22f0d521b77389349caf0268f6e85c Mon Sep 17 00:00:00 2001 From: Timothy Andrew <mail@timothyandrew.net> Date: Mon, 8 May 2017 17:03:48 +0000 Subject: [PATCH 359/363] Merge branch 'update-guides-for-9-2' into 'master' Update guides for 9.2 See merge request !11157 --- doc/install/installation.md | 4 +- doc/update/9.1-to-9.2.md | 288 ++++++++++++++++++++++++++++++++++++ 2 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 doc/update/9.1-to-9.2.md diff --git a/doc/install/installation.md b/doc/install/installation.md index dc807d93bbb9d..5615b2a534bae 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -289,9 +289,9 @@ sudo usermod -aG redis git ### Clone the Source # Clone GitLab repository - sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 9-1-stable gitlab + sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 9-2-stable gitlab -**Note:** You can change `9-1-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server! +**Note:** You can change `9-2-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server! ### Configure It diff --git a/doc/update/9.1-to-9.2.md b/doc/update/9.1-to-9.2.md new file mode 100644 index 0000000000000..19db6e5763efa --- /dev/null +++ b/doc/update/9.1-to-9.2.md @@ -0,0 +1,288 @@ +# From 9.1 to 9.2 + +Make sure you view this update guide from the tag (version) of GitLab you would +like to install. In most cases this should be the highest numbered production +tag (without rc in it). You can select the tag in the version dropdown at the +top left corner of GitLab (below the menu bar). + +If the highest number stable branch is unclear please check the +[GitLab Blog](https://about.gitlab.com/blog/archives.html) for installation +guide links by version. + +### 1. Stop server + +```bash +sudo service gitlab stop +``` + +### 2. Backup + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production +``` + +### 3. Update Ruby + +NOTE: GitLab 9.0 and higher only support Ruby 2.3.x and dropped support for Ruby 2.1.x. Be +sure to upgrade your interpreter if necessary. + +You can check which version you are running with `ruby -v`. + +Download and compile Ruby: + +```bash +mkdir /tmp/ruby && cd /tmp/ruby +curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.3/ruby-2.3.3.tar.gz +echo '1014ee699071aa2ddd501907d18cbe15399c997d ruby-2.3.3.tar.gz' | shasum -c - && tar xzf ruby-2.3.3.tar.gz +cd ruby-2.3.3 +./configure --disable-install-rdoc +make +sudo make install +``` + +Install Bundler: + +```bash +sudo gem install bundler --no-ri --no-rdoc +``` + +### 4. Update Node + +GitLab now runs [webpack](http://webpack.js.org) to compile frontend assets and +it has a minimum requirement of node v4.3.0. + +You can check which version you are running with `node -v`. If you are running +a version older than `v4.3.0` you will need to update to a newer version. You +can find instructions to install from community maintained packages or compile +from source at the nodejs.org website. + +<https://nodejs.org/en/download/> + + +Since 8.17, GitLab requires the use of yarn `>= v0.17.0` to manage +JavaScript dependencies. + +```bash +curl --location https://yarnpkg.com/install.sh | bash - +``` + +More information can be found on the [yarn website](https://yarnpkg.com/en/docs/install). + +### 5. Get latest code + +```bash +cd /home/git/gitlab + +sudo -u git -H git fetch --all +sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically +``` + +For GitLab Community Edition: + +```bash +cd /home/git/gitlab + +sudo -u git -H git checkout 9-2-stable +``` + +OR + +For GitLab Enterprise Edition: + +```bash +cd /home/git/gitlab + +sudo -u git -H git checkout 9-2-stable-ee +``` + +### 6. Update gitlab-shell + +```bash +cd /home/git/gitlab-shell + +sudo -u git -H git fetch --all --tags +sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_SHELL_VERSION) +sudo -u git -H bin/compile +``` + +### 7. Update gitlab-workhorse + +Install and compile gitlab-workhorse. This requires +[Go 1.5](https://golang.org/dl) which should already be on your system from +GitLab 8.1. GitLab-Workhorse uses [GNU Make](https://www.gnu.org/software/make/). +If you are not using Linux you may have to run `gmake` instead of +`make` below. + +```bash +cd /home/git/gitlab-workhorse + +sudo -u git -H git fetch --all --tags +sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_WORKHORSE_VERSION) +sudo -u git -H make +``` + +### 8. Update configuration files + +#### New configuration options for `gitlab.yml` + +There might be configuration options available for [`gitlab.yml`][yaml]. View them with the command below and apply them manually to your current `gitlab.yml`: + +```sh +cd /home/git/gitlab + +git diff origin/9-1-stable:config/gitlab.yml.example origin/9-2-stable:config/gitlab.yml.example +``` + +#### Nginx configuration + +Ensure you're still up-to-date with the latest NGINX configuration changes: + +```sh +cd /home/git/gitlab + +# For HTTPS configurations +git diff origin/9-1-stable:lib/support/nginx/gitlab-ssl origin/9-2-stable:lib/support/nginx/gitlab-ssl + +# For HTTP configurations +git diff origin/9-1-stable:lib/support/nginx/gitlab origin/9-2-stable:lib/support/nginx/gitlab +``` + +If you are using Strict-Transport-Security in your installation to continue using it you must enable it in your Nginx +configuration as GitLab application no longer handles setting it. + +If you are using Apache instead of NGINX please see the updated [Apache templates]. +Also note that because Apache does not support upstreams behind Unix sockets you +will need to let gitlab-workhorse listen on a TCP port. You can do this +via [/etc/default/gitlab]. + +[Apache templates]: https://gitlab.com/gitlab-org/gitlab-recipes/tree/master/web-server/apache +[/etc/default/gitlab]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-2-stable/lib/support/init.d/gitlab.default.example#L38 + +#### SMTP configuration + +If you're installing from source and use SMTP to deliver mail, you will need to add the following line +to config/initializers/smtp_settings.rb: + +```ruby +ActionMailer::Base.delivery_method = :smtp +``` + +See [smtp_settings.rb.sample] as an example. + +[smtp_settings.rb.sample]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-1-stable/config/initializers/smtp_settings.rb.sample#L13 + +#### Init script + +There might be new configuration options available for [`gitlab.default.example`][gl-example]. View them with the command below and apply them manually to your current `/etc/default/gitlab`: + +```sh +cd /home/git/gitlab + +git diff origin/9-1-stable:lib/support/init.d/gitlab.default.example origin/9-2-stable:lib/support/init.d/gitlab.default.example +``` + +Ensure you're still up-to-date with the latest init script changes: + +```bash +cd /home/git/gitlab + +sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab +``` + +For Ubuntu 16.04.1 LTS: + +```bash +sudo systemctl daemon-reload +``` + +### 9. Install libs, migrations, etc. + +```bash +cd /home/git/gitlab + +# MySQL installations (note: the line below states '--without postgres') +sudo -u git -H bundle install --without postgres development test --deployment + +# PostgreSQL installations (note: the line below states '--without mysql') +sudo -u git -H bundle install --without mysql development test --deployment + +# Optional: clean up old gems +sudo -u git -H bundle clean + +# Run database migrations +sudo -u git -H bundle exec rake db:migrate RAILS_ENV=production + +# Update node dependencies and recompile assets +sudo -u git -H bundle exec rake yarn:install gitlab:assets:clean gitlab:assets:compile RAILS_ENV=production NODE_ENV=production + +# Clean up cache +sudo -u git -H bundle exec rake cache:clear RAILS_ENV=production +``` + +**MySQL installations**: Run through the `MySQL strings limits` and `Tables and data conversion to utf8mb4` [tasks](../install/database_mysql.md). + +### 10. Optional: install Gitaly + +Gitaly is still an optional component of GitLab. If you want to save time +during your 9.2 upgrade **you can skip this step**. + +If you have not yet set up Gitaly then follow [Gitaly section of the installation +guide](../install/installation.md#install-gitaly). + +#### Compile Gitaly + +```shell +cd /home/git/gitaly +sudo -u git -H git fetch --all --tags +sudo -u git -H git checkout v$(</home/git/gitlab/GITALY_SERVER_VERSION) +sudo -u git -H make +``` + +### 11. Start application + +```bash +sudo service gitlab start +sudo service nginx restart +``` + +### 12. Check application status + +Check if GitLab and its environment are configured correctly: + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production +``` + +To make sure you didn't miss anything run a more thorough check: + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production +``` + +If all items are green, then congratulations, the upgrade is complete! + +## Things went south? Revert to previous version (9.1) + +### 1. Revert the code to the previous version + +Follow the [upgrade guide from 9.0 to 9.1](9.0-to-9.1.md), except for the +database migration (the backup is already migrated to the previous version). + +### 2. Restore from the backup + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production +``` + +If you have more than one backup `*.tar` file(s) please add `BACKUP=timestamp_of_backup` to the command above. + +[yaml]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-2-stable/config/gitlab.yml.example +[gl-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-2-stable/lib/support/init.d/gitlab.default.example -- GitLab From 848466ee6c42186b4e8a3c49027438254c476ae4 Mon Sep 17 00:00:00 2001 From: Sean McGivern <sean@mcgivern.me.uk> Date: Mon, 8 May 2017 11:06:54 +0000 Subject: [PATCH 360/363] Merge branch 'sh-fix-almost-there-spec-mysql' into 'master' Fix sub-second timing comparison error for Devise confirmation period Closes gitlab-ee#2362 See merge request !11156 --- app/models/user.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/models/user.rb b/app/models/user.rb index accaa91b8056e..4e5f94683b826 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1000,6 +1000,15 @@ def send_devise_notification(notification, *args) devise_mailer.send(notification, self, *args).deliver_later end + # This works around a bug in Devise 4.2.0 that erroneously causes a user to + # be considered active in MySQL specs due to a sub-second comparison + # issue. For more details, see: https://gitlab.com/gitlab-org/gitlab-ee/issues/2362#note_29004709 + def confirmation_period_valid? + return false if self.class.allow_unconfirmed_access_for == 0.days + + super + end + def ensure_external_user_rights return unless external? -- GitLab From 3d6086d0ee5d16e77be5f83c0b19d6fe4d4b6c43 Mon Sep 17 00:00:00 2001 From: Sean McGivern <sean@mcgivern.me.uk> Date: Mon, 8 May 2017 12:16:15 +0000 Subject: [PATCH 361/363] Merge branch 'fix-notes_on_personal_snippets_spec-timeago-assertion-ce' into 'master' Fix notes_on_personal_snippets_spec Closes #31938 See merge request !11160 --- spec/features/snippets/notes_on_personal_snippets_spec.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/features/snippets/notes_on_personal_snippets_spec.rb b/spec/features/snippets/notes_on_personal_snippets_spec.rb index 957baac02eb00..698eb46573fc1 100644 --- a/spec/features/snippets/notes_on_personal_snippets_spec.rb +++ b/spec/features/snippets/notes_on_personal_snippets_spec.rb @@ -78,9 +78,11 @@ end page.within("#notes-list li#note_#{snippet_notes[0].id}") do + edited_text = find('.edited-text') + expect(page).to have_css('.note_edited_ago') expect(page).to have_content('new content') - expect(find('.note_edited_ago').text).to match(/less than a minute ago/) + expect(edited_text).to have_selector('.note_edited_ago') end end end -- GitLab From 9632e01d986390282353017bdf31534c9c5eb924 Mon Sep 17 00:00:00 2001 From: Sean McGivern <sean@mcgivern.me.uk> Date: Mon, 8 May 2017 11:51:26 +0000 Subject: [PATCH 362/363] Merge branch 'Add-index_redirect_routes_path_for_link-migration-to-setup_postgresql' into 'master' Add index_redirect_routes_path_for_link migration to setup_postgresql See merge request !11165 --- lib/tasks/migrate/setup_postgresql.rake | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/tasks/migrate/setup_postgresql.rake b/lib/tasks/migrate/setup_postgresql.rake index 1e00b47303d5f..4108cee08b4d0 100644 --- a/lib/tasks/migrate/setup_postgresql.rake +++ b/lib/tasks/migrate/setup_postgresql.rake @@ -4,6 +4,7 @@ require Rails.root.join('db/migrate/20151007120511_namespaces_projects_path_lowe require Rails.root.join('db/migrate/20151008110232_add_users_lower_username_email_indexes') require Rails.root.join('db/migrate/20161212142807_add_lower_path_index_to_routes') require Rails.root.join('db/migrate/20170317203554_index_routes_path_for_like') +require Rails.root.join('db/migrate/20170503185032_index_redirect_routes_path_for_like') desc 'GitLab | Sets up PostgreSQL' task setup_postgresql: :environment do -- GitLab From fa0596a9ca5e81498e4485637ac53dec227f5c72 Mon Sep 17 00:00:00 2001 From: Timothy Andrew <mail@timothyandrew.net> Date: Tue, 9 May 2017 06:11:39 +0000 Subject: [PATCH 363/363] Merge branch `ce_upstream` into `master` - https://gitlab.slack.com/archives/C0XM5UU6B/p1494309973105903 - `ce_upstream` has was green at ef0b3a56892896a56193a581ee0174716a4b996e, but the MR was _not_ merged in, and has since been refreshed (and thus has more conflicts to fix). - For the purposes of `9-2-stable-ee`, we don't need the _latest_ `ce_upstream`, or the latest `ee/master`, simply the newest version since `9-2-stable-ee` was branched off. - We merge `ef0b3a` into the latest version of `ee/master` that it merges to without conflicts, and then pick that merge commit into `9-2-stable-ee` --- .eslintignore | 1 + .gitignore | 3 + .gitlab-ci.yml | 35 +- CHANGELOG.md | 35 + CONTRIBUTING.md | 219 +++++-- Gemfile | 6 + Gemfile.lock | 19 + PROCESS.md | 95 ++- app/assets/javascripts/autosave.js | 44 +- .../behaviors/gl_emoji/unicode_support_map.js | 17 +- .../javascripts/behaviors/quick_submit.js | 2 +- .../blob/balsamiq/balsamiq_viewer.js | 114 ++++ .../javascripts/blob/balsamiq_viewer.js | 6 + .../blob/file_template_mediator.js | 6 +- .../blob/file_template_selector.js | 10 +- .../blob/target_branch_dropdown.js | 4 +- .../javascripts/blob/template_selector.js | 7 +- .../template_selectors/ci_yaml_selector.js | 2 +- .../template_selectors/dockerfile_selector.js | 2 +- .../template_selectors/gitignore_selector.js | 2 +- .../template_selectors/license_selector.js | 13 +- .../blob/template_selectors/type_selector.js | 2 +- .../javascripts/boards/boards_bundle.js | 5 +- .../boards/components/board_new_issue.js | 1 + .../boards/components/board_sidebar.js | 61 +- .../boards/components/issue_card_inner.js | 104 ++- .../boards/components/new_list_dropdown.js | 4 +- .../javascripts/boards/models/assignee.js | 12 + app/assets/javascripts/boards/models/issue.js | 34 +- app/assets/javascripts/boards/models/list.js | 5 +- app/assets/javascripts/boards/models/user.js | 12 - .../javascripts/boards/stores/boards_store.js | 4 +- .../components/limit_warning_component.js | 4 +- .../components/stage_code_component.js | 4 +- .../components/stage_issue_component.js | 4 +- .../components/stage_plan_component.js | 4 +- .../components/stage_production_component.js | 4 +- .../components/stage_review_component.js | 4 +- .../components/stage_staging_component.js | 2 +- .../components/total_time_component.js | 8 +- .../cycle_analytics/cycle_analytics_bundle.js | 3 + .../cycle_analytics_service.js | 2 +- .../cycle_analytics/cycle_analytics_store.js | 17 +- .../deploy_keys/components/action_btn.vue | 54 ++ .../deploy_keys/components/app.vue | 102 +++ .../deploy_keys/components/key.vue | 80 +++ .../deploy_keys/components/keys_panel.vue | 52 ++ .../javascripts/deploy_keys/eventhub.js | 3 + app/assets/javascripts/deploy_keys/index.js | 21 + .../javascripts/deploy_keys/service/index.js | 34 + .../javascripts/deploy_keys/store/index.js | 9 + app/assets/javascripts/dispatcher.js | 6 + app/assets/javascripts/droplab/constants.js | 3 + app/assets/javascripts/droplab/drop_down.js | 2 +- app/assets/javascripts/droplab/utils.js | 16 +- .../recent_searches_dropdown_content.js | 12 +- .../filtered_search/dropdown_hint.js | 2 +- .../filtered_search_manager.js | 11 +- .../filtered_search_visual_tokens.js | 43 +- .../filtered_search/recent_searches_root.js | 7 +- .../services/recent_searches_service.js | 14 + .../services/recent_searches_service_error.js | 11 + app/assets/javascripts/gfm_auto_complete.js | 169 +++-- app/assets/javascripts/gl_dropdown.js | 56 +- .../issuable/auto_width_dropdown_select.js | 38 ++ .../javascripts/issuable/issuable_bundle.js | 1 - .../components/collapsed_state.js | 60 -- .../components/comparison_pane.js | 82 --- .../components/estimate_only_pane.js | 19 - .../time_tracking/components/help_state.js | 30 - .../components/no_tracking_pane.js | 12 - .../components/spent_only_pane.js | 19 - .../time_tracking/components/time_tracker.js | 135 ---- .../time_tracking/time_tracking_bundle.js | 66 -- app/assets/javascripts/issue_status_select.js | 4 +- .../javascripts/issues_bulk_assignment.js | 3 + app/assets/javascripts/labels_select.js | 7 +- app/assets/javascripts/lib/utils/accessor.js | 47 ++ .../javascripts/lib/utils/ajax_cache.js | 32 + .../javascripts/lib/utils/common_utils.js | 8 + app/assets/javascripts/locale/de/app.js | 1 + app/assets/javascripts/locale/en/app.js | 1 + app/assets/javascripts/locale/es/app.js | 1 + app/assets/javascripts/locale/index.js | 70 ++ app/assets/javascripts/main.js | 1 - app/assets/javascripts/members.js | 4 +- .../javascripts/merge_request_widget.js | 2 +- app/assets/javascripts/milestone_select.js | 5 +- app/assets/javascripts/namespace_select.js | 3 +- app/assets/javascripts/new_branch_form.js | 16 + app/assets/javascripts/notes.js | 345 ++++++++-- .../pipelines/services/pipelines_service.js | 2 +- app/assets/javascripts/project.js | 3 +- .../protected_branch_access_dropdown.js | 5 +- .../protected_branch_dropdown.js | 3 +- .../protected_tag_access_dropdown.js | 4 +- .../protected_tags/protected_tag_dropdown.js | 4 +- app/assets/javascripts/raven/index.js | 16 + app/assets/javascripts/raven/raven_config.js | 100 +++ .../components/assignees/assignee_title.js | 41 ++ .../sidebar/components/assignees/assignees.js | 224 +++++++ .../components/assignees/sidebar_assignees.js | 82 +++ .../time_tracking/collapsed_state.js | 97 +++ .../time_tracking/comparison_pane.js | 98 +++ .../time_tracking/estimate_only_pane.js | 17 + .../components/time_tracking/help_state.js | 44 ++ .../time_tracking/no_tracking_pane.js | 10 + .../time_tracking/sidebar_time_tracking.js | 51 ++ .../time_tracking/spent_only_pane.js | 15 + .../components/time_tracking/time_tracker.js | 163 +++++ app/assets/javascripts/sidebar/event_hub.js | 3 + .../sidebar/services/sidebar_service.js | 28 + .../javascripts/sidebar/sidebar_bundle.js | 24 + .../javascripts/sidebar/sidebar_mediator.js | 38 ++ .../sidebar/stores/sidebar_store.js | 52 ++ .../javascripts/signin_tabs_memoizer.js | 12 +- app/assets/javascripts/subbable_resource.js | 51 -- app/assets/javascripts/subscription_select.js | 4 +- app/assets/javascripts/users_select.js | 403 +++++++++--- .../javascripts/vue_shared/translate.js | 42 ++ app/assets/javascripts/weight_select.js | 5 +- .../stylesheets/framework/animations.scss | 28 + app/assets/stylesheets/framework/avatar.scss | 11 + .../stylesheets/framework/dropdowns.scss | 16 +- app/assets/stylesheets/framework/files.scss | 12 + app/assets/stylesheets/framework/filters.scss | 14 +- app/assets/stylesheets/framework/lists.scss | 1 + .../stylesheets/framework/variables.scss | 2 + app/assets/stylesheets/pages/boards.scss | 73 ++- .../stylesheets/pages/cycle_analytics.scss | 31 +- app/assets/stylesheets/pages/diff.scss | 9 +- app/assets/stylesheets/pages/issuable.scss | 125 +++- app/assets/stylesheets/pages/labels.scss | 2 +- .../stylesheets/pages/merge_requests.scss | 4 + app/assets/stylesheets/pages/notes.scss | 39 +- app/assets/stylesheets/pages/pipelines.scss | 27 + app/assets/stylesheets/pages/todos.scss | 3 +- .../admin/application_settings_controller.rb | 2 + app/controllers/admin/services_controller.rb | 2 + app/controllers/application_controller.rb | 10 + app/controllers/concerns/issuable_actions.rb | 1 + .../concerns/issuable_collections.rb | 2 +- .../dashboard/labels_controller.rb | 2 +- app/controllers/groups/labels_controller.rb | 2 +- .../omniauth_callbacks_controller.rb | 2 +- app/controllers/profiles_controller.rb | 3 +- .../projects/artifacts_controller.rb | 28 +- .../projects/boards/issues_controller.rb | 2 +- .../projects/deploy_keys_controller.rb | 18 +- app/controllers/projects/issues_controller.rb | 8 +- app/controllers/projects/labels_controller.rb | 2 +- .../projects/pipelines_controller.rb | 40 +- app/finders/issuable_finder.rb | 2 +- app/finders/issues_finder.rb | 19 +- app/helpers/application_helper.rb | 16 +- app/helpers/blob_helper.rb | 6 +- app/helpers/boards_helper.rb | 1 + app/helpers/builds_helper.rb | 12 + app/helpers/form_helper.rb | 32 + app/helpers/gitlab_routing_helper.rb | 2 + app/helpers/issuables_helper.rb | 10 + app/helpers/sorting_helper.rb | 8 + app/helpers/system_note_helper.rb | 1 + app/mailers/emails/issues.rb | 6 +- app/models/application_setting.rb | 4 + app/models/blob.rb | 1 + app/models/blob_viewer/balsamiq.rb | 12 + app/models/ci/artifact_blob.rb | 35 + app/models/concerns/elastic/issues_search.rb | 11 +- app/models/concerns/elastic/notes_search.rb | 2 +- app/models/concerns/issuable.rb | 29 +- app/models/concerns/milestoneish.rb | 2 +- app/models/global_milestone.rb | 6 +- app/models/issue.rb | 35 +- app/models/issue_assignee.rb | 29 + app/models/merge_request.rb | 31 + app/models/milestone.rb | 5 +- app/models/note.rb | 6 + app/models/snippet.rb | 5 + app/models/system_note_metadata.rb | 2 +- app/models/user.rb | 8 +- .../settings/deploy_keys_presenter.rb | 11 + app/serializers/README.md | 325 ++++++++++ app/serializers/analytics_stage_entity.rb | 1 + app/serializers/analytics_summary_entity.rb | 5 +- app/serializers/deploy_key_entity.rb | 14 + app/serializers/deploy_key_serializer.rb | 3 + app/serializers/issuable_entity.rb | 1 - app/serializers/issue_entity.rb | 1 + app/serializers/label_entity.rb | 1 + app/serializers/label_serializer.rb | 7 + app/serializers/merge_request_entity.rb | 1 + app/serializers/project_entity.rb | 14 + app/services/issuable/bulk_update_service.rb | 6 +- app/services/issuable_base_service.rb | 39 +- app/services/issues/base_service.rb | 19 + app/services/issues/export_csv_service.rb | 6 +- app/services/issues/update_service.rb | 14 +- .../members/authorized_destroy_service.rb | 15 +- .../merge_requests/assign_issues_service.rb | 4 +- app/services/merge_requests/base_service.rb | 5 + app/services/merge_requests/update_service.rb | 5 +- .../notification_recipient_service.rb | 7 +- app/services/notification_service.rb | 29 +- .../projects/propagate_service_template.rb | 103 +++ .../slash_commands/interpret_service.rb | 33 +- app/services/system_note_service.rb | 55 ++ app/services/todo_service.rb | 4 +- .../application_settings/_form.html.haml | 18 +- app/views/discussions/_notes.html.haml | 1 + app/views/errors/omniauth_error.html.haml | 21 +- app/views/issues/_issue.atom.builder | 15 +- app/views/layouts/_head.html.haml | 3 + app/views/layouts/application.html.haml | 4 +- app/views/layouts/devise.html.haml | 1 - app/views/layouts/devise_empty.html.haml | 1 - app/views/layouts/oauth_error.html.haml | 127 ++++ .../_reassigned_issuable_email.text.erb | 6 - app/views/notify/new_issue_email.html.haml | 4 +- app/views/notify/new_issue_email.text.erb | 2 +- .../new_mention_in_issue_email.text.erb | 2 +- .../notify/reassigned_issue_email.html.haml | 11 +- .../notify/reassigned_issue_email.text.erb | 7 +- .../reassigned_merge_request_email.html.haml | 10 +- .../reassigned_merge_request_email.text.erb | 7 +- app/views/profiles/show.html.haml | 5 + .../projects/artifacts/_tree_file.html.haml | 9 +- app/views/projects/artifacts/file.html.haml | 33 + .../projects/blob/viewers/_balsamiq.html.haml | 4 + .../boards/components/_board.html.haml | 4 +- .../components/sidebar/_assignee.html.haml | 46 +- app/views/projects/branches/new.html.haml | 12 +- app/views/projects/compare/_form.html.haml | 4 +- .../cycle_analytics/_empty_stage.html.haml | 2 +- .../cycle_analytics/_no_access.html.haml | 4 +- .../projects/cycle_analytics/show.html.haml | 53 +- .../projects/deploy_keys/_index.html.haml | 23 +- .../projects/group_links/_index.html.haml | 4 +- app/views/projects/issues/_issue.html.haml | 4 +- .../projects/merge_requests/_show.html.haml | 2 +- app/views/projects/notes/_actions.html.haml | 6 +- .../projects/pipelines/_with_tabs.html.haml | 19 +- .../projects/project_members/_index.html.haml | 2 +- .../_create_protected_branch.html.haml | 4 +- .../_update_protected_branch.html.haml | 4 +- .../protected_tags/_dropdown.html.haml | 2 +- .../protected_tags/_update_protected_tag.haml | 2 +- .../settings/repository/show.html.haml | 4 + app/views/projects/tags/index.html.haml | 17 +- .../_ref_dropdown.html.haml | 4 +- app/views/shared/errors/_graphic_422.svg | 1 + .../shared/issuable/_assignees.html.haml | 15 + .../shared/issuable/_participants.html.haml | 8 +- .../shared/issuable/_search_bar.html.haml | 7 +- app/views/shared/issuable/_sidebar.html.haml | 94 +-- .../issuable/form/_branch_chooser.html.haml | 10 +- .../issuable/form/_issue_assignee.html.haml | 30 + .../form/_merge_request_assignee.html.haml | 31 + .../shared/issuable/form/_metadata.html.haml | 28 +- app/views/shared/members/_requests.html.haml | 2 +- .../shared/milestones/_issuable.html.haml | 8 +- .../shared/milestones/_labels_tab.html.haml | 14 +- app/views/shared/notes/_edit_form.html.haml | 2 +- app/views/shared/notes/_note.html.haml | 2 +- app/views/shared/snippets/_header.html.haml | 2 +- app/views/snippets/notes/_actions.html.haml | 6 +- app/workers/elastic_indexer_worker.rb | 2 +- .../propagate_service_template_worker.rb | 21 + .../24883-build-failure-summary-page.yml | 4 + .../unreleased/27614-instant-comments.yml | 4 + changelogs/unreleased/29145-oauth-422.yml | 4 + .../30007-done-todo-hover-state.yml | 4 + .../30903-vertically-align-mini-pipeline.yml | 4 + ...-network-graph-sorted-by-date-and-topo.yml | 4 + .../31689-request-access-spacing.yml | 4 + .../31760-add-tooltips-to-note-actions.yml | 4 + changelogs/unreleased/31810-commit-link.yml | 4 + .../add_system_note_for_editing_issuable.yml | 4 + changelogs/unreleased/balsalmiq-support.yml | 4 + .../unreleased/deploy-keys-load-async.yml | 4 + .../unreleased/dm-artifact-blob-viewer.yml | 4 + .../unreleased/fix-admin-integrations.yml | 4 + .../unreleased/implement-i18n-support.yml | 4 + .../unreleased/issue-boards-no-avatar.yml | 4 + .../issue-title-description-realtime.yml | 4 + .../merge-request-poll-json-endpoint.yml | 4 + .../mrchrisw-import-shell-timeout.yml | 4 + ...rometheus-integration-test-setting-fix.yml | 4 + changelogs/unreleased/tags-sort-default.yml | 4 + .../update-issue-board-cards-design.yml | 4 + .../unreleased/winh-visual-token-labels.yml | 4 + config/application.rb | 3 + config/gitlab.yml.example | 3 + config/initializers/1_settings.rb | 1 + config/initializers/fast_gettext.rb | 5 + .../initializers/gettext_rails_i18n_patch.rb | 42 ++ config/locales/de.yml | 219 +++++++ config/locales/es.yml | 217 +++++++ config/routes/project.rb | 2 + config/sidekiq_queues.yml | 2 +- config/webpack.config.js | 25 +- db/fixtures/development/09_issues.rb | 2 +- db/fixtures/development/20_burndown.rb | 2 +- ...0320171632_create_issue_assignees_table.rb | 40 ++ .../20170320173259_migrate_assignees.rb | 52 ++ ...3035209_add_preferred_language_to_users.rb | 16 + ...ited_at_and_last_edited_by_id_to_issues.rb | 14 + ...and_last_edited_by_id_to_merge_requests.rb | 14 + ...ientside_sentry_to_application_settings.rb | 33 + db/schema.rb | 21 +- doc/api/issues.md | 104 ++- doc/development/README.md | 1 + doc/development/build_test_package.md | 35 + doc/development/code_review.md | 41 +- doc/development/fe_guide/droplab/droplab.md | 2 + doc/development/fe_guide/style_guide_js.md | 602 ++++++++++-------- doc/development/fe_guide/vue.md | 15 + doc/user/project/integrations/webhooks.md | 12 + features/project/builds/artifacts.feature | 3 +- features/project/deploy_keys.feature | 6 + features/steps/dashboard/dashboard.rb | 2 +- features/steps/dashboard/todos.rb | 2 +- features/steps/group/milestones.rb | 4 +- features/steps/groups.rb | 4 +- features/steps/project/builds/artifacts.rb | 15 +- features/steps/project/deploy_keys.rb | 16 +- features/steps/project/merge_requests.rb | 7 + features/steps/shared/note.rb | 4 + lib/api/api.rb | 5 + lib/api/entities.rb | 6 +- lib/api/helpers/common_helpers.rb | 13 + lib/api/issues.rb | 9 +- lib/api/settings.rb | 5 + lib/api/v3/entities.rb | 7 + lib/api/v3/issues.rb | 31 +- lib/api/v3/merge_requests.rb | 2 +- lib/api/v3/milestones.rb | 4 +- lib/banzai/reference_parser/issue_parser.rb | 2 +- lib/github/import.rb | 2 +- .../chat_commands/presenters/issue_base.rb | 2 +- .../ci/build/artifacts/metadata/entry.rb | 6 + lib/gitlab/cycle_analytics/base_stage.rb | 2 +- lib/gitlab/cycle_analytics/code_stage.rb | 8 +- lib/gitlab/cycle_analytics/issue_stage.rb | 8 +- lib/gitlab/cycle_analytics/plan_stage.rb | 8 +- .../cycle_analytics/production_stage.rb | 8 +- lib/gitlab/cycle_analytics/review_stage.rb | 8 +- lib/gitlab/cycle_analytics/staging_stage.rb | 8 +- lib/gitlab/cycle_analytics/summary/base.rb | 2 +- lib/gitlab/cycle_analytics/summary/commit.rb | 4 + lib/gitlab/cycle_analytics/summary/deploy.rb | 4 + lib/gitlab/cycle_analytics/summary/issue.rb | 2 +- lib/gitlab/cycle_analytics/test_stage.rb | 8 +- lib/gitlab/fogbugz_import/importer.rb | 18 +- lib/gitlab/git/repository.rb | 17 +- lib/gitlab/github_import/issue_formatter.rb | 2 +- lib/gitlab/gon_helper.rb | 2 + lib/gitlab/google_code_import/importer.rb | 14 +- lib/gitlab/i18n.rb | 26 + lib/gitlab/import_export/relation_factory.rb | 2 +- lib/gitlab/prometheus.rb | 6 + lib/gitlab/shell.rb | 4 +- lib/tasks/gettext.rake | 14 + locale/de/gitlab.po | 207 ++++++ locale/de/gitlab.po.time_stamp | 0 locale/en/gitlab.po | 207 ++++++ locale/en/gitlab.po.time_stamp | 0 locale/es/gitlab.po | 208 ++++++ locale/es/gitlab.po.time_stamp | 0 locale/gitlab.pot | 208 ++++++ package.json | 4 + scripts/static-analysis | 1 + .../admin/services_controller_spec.rb | 32 + .../dashboard/todos_controller_spec.rb | 2 +- .../projects/artifacts_controller_spec.rb | 188 ++++++ .../projects/boards/issues_controller_spec.rb | 2 +- .../projects/deploy_keys_controller_spec.rb | 66 ++ .../projects/issues_controller_spec.rb | 8 +- .../merge_requests_controller_spec.rb | 2 +- .../projects/pipelines_controller_spec.rb | 36 ++ spec/features/atom/dashboard_issues_spec.rb | 8 +- spec/features/atom/issues_spec.rb | 8 +- .../boards/board_with_milestone_spec.rb | 2 +- spec/features/boards/boards_spec.rb | 2 +- spec/features/boards/modal_filter_spec.rb | 2 +- spec/features/boards/sidebar_spec.rb | 35 +- spec/features/cycle_analytics_spec.rb | 19 + .../dashboard/issuables_counter_spec.rb | 6 +- spec/features/dashboard/issues_spec.rb | 7 +- spec/features/dashboard_issues_spec.rb | 4 +- .../features/gitlab_flavored_markdown_spec.rb | 4 +- spec/features/issues/award_emoji_spec.rb | 2 +- spec/features/issues/csv_spec.rb | 2 +- .../filtered_search/filter_issues_spec.rb | 10 +- .../filter_issues_weight_spec.rb | 2 +- spec/features/issues/form_spec.rb | 69 +- spec/features/issues/issue_sidebar_spec.rb | 15 + spec/features/issues/update_issues_spec.rb | 2 +- spec/features/issues_spec.rb | 42 +- .../merge_requests/assign_issues_spec.rb | 2 +- .../merge_requests/user_posts_notes_spec.rb | 1 + .../user_uses_slash_commands_spec.rb | 1 + spec/features/milestones/show_spec.rb | 4 +- spec/features/projects/artifacts/file_spec.rb | 59 ++ spec/features/projects/audit_events_spec.rb | 2 +- .../branches/new_branch_ref_dropdown_spec.rb | 48 ++ spec/features/projects/deploy_keys_spec.rb | 12 +- .../projects/issuable_templates_spec.rb | 4 +- .../projects/pipelines/pipeline_spec.rb | 53 ++ spec/features/raven_js_spec.rb | 23 + spec/features/search_spec.rb | 2 +- spec/features/unsubscribe_links_spec.rb | 2 +- spec/finders/issues_finder_spec.rb | 12 +- spec/fixtures/api/schemas/issue.json | 50 +- .../api/schemas/public_api/v4/issues.json | 17 +- spec/helpers/issuables_helper_spec.rb | 17 + spec/javascripts/autosave_spec.js | 134 ++++ .../gl_emoji/unicode_support_map_spec.js | 47 ++ .../blob/balsamiq/balsamiq_viewer_spec.js | 342 ++++++++++ spec/javascripts/boards/board_card_spec.js | 8 +- spec/javascripts/boards/board_list_spec.js | 1 + spec/javascripts/boards/boards_store_spec.js | 19 +- spec/javascripts/boards/issue_card_spec.js | 131 +++- spec/javascripts/boards/issue_spec.js | 76 ++- spec/javascripts/boards/list_spec.js | 25 +- spec/javascripts/boards/mock_data.js | 3 +- spec/javascripts/boards/modal_store_spec.js | 12 +- .../limit_warning_component_spec.js | 3 + .../deploy_keys/components/action_btn_spec.js | 70 ++ .../deploy_keys/components/app_spec.js | 142 +++++ .../deploy_keys/components/key_spec.js | 92 +++ .../deploy_keys/components/keys_panel_spec.js | 70 ++ spec/javascripts/droplab/constants_spec.js | 6 + spec/javascripts/droplab/drop_down_spec.js | 4 +- .../recent_searches_dropdown_content_spec.js | 20 + .../filtered_search_manager_spec.js | 34 + .../filtered_search_visual_tokens_spec.js | 101 +++ .../recent_searches_root_spec.js | 31 + .../recent_searches_service_error_spec.js | 18 + .../services/recent_searches_service_spec.js | 95 ++- spec/javascripts/fixtures/deploy_keys.rb | 36 ++ spec/javascripts/fixtures/labels.rb | 56 ++ .../helpers/user_mock_data_helper.js | 16 + .../javascripts/issuable_time_tracker_spec.js | 250 ++++---- spec/javascripts/lib/utils/accessor_spec.js | 78 +++ spec/javascripts/lib/utils/ajax_cache_spec.js | 129 ++++ .../lib/utils/common_utils_spec.js | 11 + spec/javascripts/notes_spec.js | 192 +++++- spec/javascripts/raven/index_spec.js | 42 ++ spec/javascripts/raven/raven_config_spec.js | 276 ++++++++ .../sidebar/assignee_title_spec.js | 80 +++ spec/javascripts/sidebar/assignees_spec.js | 272 ++++++++ spec/javascripts/sidebar/mock_data.js | 109 ++++ .../sidebar/sidebar_assignees_spec.js | 45 ++ .../sidebar/sidebar_bundle_spec.js | 42 ++ .../sidebar/sidebar_mediator_spec.js | 40 ++ .../sidebar/sidebar_service_spec.js | 32 + .../javascripts/sidebar/sidebar_store_spec.js | 80 +++ spec/javascripts/signin_tabs_memoizer_spec.js | 90 +++ spec/javascripts/subbable_resource_spec.js | 63 -- spec/javascripts/vue_shared/translate_spec.js | 90 +++ .../lib/banzai/filter/redactor_filter_spec.rb | 2 +- .../ci/build/artifacts/metadata/entry_spec.rb | 11 + .../elastic/project_search_results_spec.rb | 2 +- .../lib/gitlab/elastic/search_results_spec.rb | 4 +- spec/lib/gitlab/git/repository_spec.rb | 2 +- .../github_import/issue_formatter_spec.rb | 10 +- .../google_code_import/importer_spec.rb | 2 +- .../health_checks/fs_shards_check_spec.rb | 12 +- .../health_checks/simple_check_shared.rb | 6 +- spec/lib/gitlab/i18n_spec.rb | 27 + spec/lib/gitlab/import_export/all_models.yml | 6 +- .../import_export/project_tree_saver_spec.rb | 2 +- .../import_export/safe_model_attributes.yml | 4 + .../lib/gitlab/project_search_results_spec.rb | 2 +- spec/lib/gitlab/prometheus_spec.rb | 30 + spec/lib/gitlab/search_results_spec.rb | 4 +- spec/lib/gitlab/shell_spec.rb | 41 ++ spec/mailers/notify_spec.rb | 10 +- spec/models/ci/artifact_blob_spec.rb | 44 ++ spec/models/concerns/elastic/issue_spec.rb | 7 +- spec/models/concerns/issuable_spec.rb | 109 +--- spec/models/concerns/milestoneish_spec.rb | 6 +- spec/models/event_spec.rb | 4 +- spec/models/issue_collection_spec.rb | 2 +- spec/models/issue_spec.rb | 91 ++- spec/models/merge_request_spec.rb | 91 ++- spec/models/network/graph_spec.rb | 21 +- .../prometheus_service_spec.rb | 2 +- spec/models/user_spec.rb | 8 + spec/policies/issue_policy_spec.rb | 8 +- spec/requests/api/issues_spec.rb | 70 +- spec/requests/api/v3/issues_spec.rb | 31 +- .../projects/artifacts_controller_spec.rb | 117 ---- spec/serializers/deploy_key_entity_spec.rb | 38 ++ spec/serializers/label_serializer_spec.rb | 46 ++ .../issuable/bulk_update_service_spec.rb | 62 +- spec/services/issues/close_service_spec.rb | 2 +- spec/services/issues/create_service_spec.rb | 86 ++- .../issues/export_csv_service_spec.rb | 6 +- spec/services/issues/update_service_spec.rb | 70 +- .../authorized_destroy_service_spec.rb | 6 +- .../assign_issues_service_spec.rb | 10 +- .../merge_requests/create_service_spec.rb | 82 ++- .../merge_requests/update_service_spec.rb | 55 ++ .../notes/slash_commands_service_spec.rb | 31 +- spec/services/notification_service_spec.rb | 80 +-- .../projects/autocomplete_service_spec.rb | 2 +- .../propagate_service_template_spec.rb | 103 +++ .../slash_commands/interpret_service_spec.rb | 86 ++- spec/services/system_note_service_spec.rb | 61 ++ spec/services/todo_service_spec.rb | 26 +- spec/services/users/destroy_service_spec.rb | 4 +- ...issuable_slash_commands_shared_examples.rb | 5 +- .../import_export/export_file_helper.rb | 2 +- spec/support/prometheus_helpers.rb | 4 + ...issuable_create_service_shared_examples.rb | 52 -- ..._service_slash_commands_shared_examples.rb | 18 +- ...issuable_update_service_shared_examples.rb | 48 -- spec/support/test_env.rb | 1 + spec/support/time_tracking_shared_examples.rb | 13 +- .../projects/tags/index.html.haml_spec.rb | 20 + .../elastic_commit_indexer_worker_spec.rb | 3 +- .../propagate_service_template_worker_spec.rb | 29 + yarn.lock | 31 +- 525 files changed, 13038 insertions(+), 2700 deletions(-) create mode 100644 app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js create mode 100644 app/assets/javascripts/blob/balsamiq_viewer.js create mode 100644 app/assets/javascripts/boards/models/assignee.js delete mode 100644 app/assets/javascripts/boards/models/user.js create mode 100644 app/assets/javascripts/deploy_keys/components/action_btn.vue create mode 100644 app/assets/javascripts/deploy_keys/components/app.vue create mode 100644 app/assets/javascripts/deploy_keys/components/key.vue create mode 100644 app/assets/javascripts/deploy_keys/components/keys_panel.vue create mode 100644 app/assets/javascripts/deploy_keys/eventhub.js create mode 100644 app/assets/javascripts/deploy_keys/index.js create mode 100644 app/assets/javascripts/deploy_keys/service/index.js create mode 100644 app/assets/javascripts/deploy_keys/store/index.js create mode 100644 app/assets/javascripts/filtered_search/services/recent_searches_service_error.js create mode 100644 app/assets/javascripts/issuable/auto_width_dropdown_select.js delete mode 100644 app/assets/javascripts/issuable/issuable_bundle.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/components/help_state.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/components/time_tracker.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js create mode 100644 app/assets/javascripts/lib/utils/accessor.js create mode 100644 app/assets/javascripts/lib/utils/ajax_cache.js create mode 100644 app/assets/javascripts/locale/de/app.js create mode 100644 app/assets/javascripts/locale/en/app.js create mode 100644 app/assets/javascripts/locale/es/app.js create mode 100644 app/assets/javascripts/locale/index.js create mode 100644 app/assets/javascripts/raven/index.js create mode 100644 app/assets/javascripts/raven/raven_config.js create mode 100644 app/assets/javascripts/sidebar/components/assignees/assignee_title.js create mode 100644 app/assets/javascripts/sidebar/components/assignees/assignees.js create mode 100644 app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js create mode 100644 app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js create mode 100644 app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js create mode 100644 app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js create mode 100644 app/assets/javascripts/sidebar/components/time_tracking/help_state.js create mode 100644 app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js create mode 100644 app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js create mode 100644 app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js create mode 100644 app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js create mode 100644 app/assets/javascripts/sidebar/event_hub.js create mode 100644 app/assets/javascripts/sidebar/services/sidebar_service.js create mode 100644 app/assets/javascripts/sidebar/sidebar_bundle.js create mode 100644 app/assets/javascripts/sidebar/sidebar_mediator.js create mode 100644 app/assets/javascripts/sidebar/stores/sidebar_store.js delete mode 100644 app/assets/javascripts/subbable_resource.js create mode 100644 app/assets/javascripts/vue_shared/translate.js create mode 100644 app/models/blob_viewer/balsamiq.rb create mode 100644 app/models/ci/artifact_blob.rb create mode 100644 app/models/issue_assignee.rb create mode 100644 app/serializers/README.md create mode 100644 app/serializers/deploy_key_entity.rb create mode 100644 app/serializers/deploy_key_serializer.rb create mode 100644 app/serializers/label_serializer.rb create mode 100644 app/serializers/project_entity.rb create mode 100644 app/services/projects/propagate_service_template.rb create mode 100644 app/views/layouts/oauth_error.html.haml delete mode 100644 app/views/notify/_reassigned_issuable_email.text.erb create mode 100644 app/views/projects/artifacts/file.html.haml create mode 100644 app/views/projects/blob/viewers/_balsamiq.html.haml rename app/views/{projects/compare => shared}/_ref_dropdown.html.haml (50%) create mode 100644 app/views/shared/errors/_graphic_422.svg create mode 100644 app/views/shared/issuable/_assignees.html.haml create mode 100644 app/views/shared/issuable/form/_issue_assignee.html.haml create mode 100644 app/views/shared/issuable/form/_merge_request_assignee.html.haml create mode 100644 app/workers/propagate_service_template_worker.rb create mode 100644 changelogs/unreleased/24883-build-failure-summary-page.yml create mode 100644 changelogs/unreleased/27614-instant-comments.yml create mode 100644 changelogs/unreleased/29145-oauth-422.yml create mode 100644 changelogs/unreleased/30007-done-todo-hover-state.yml create mode 100644 changelogs/unreleased/30903-vertically-align-mini-pipeline.yml create mode 100644 changelogs/unreleased/30973-network-graph-sorted-by-date-and-topo.yml create mode 100644 changelogs/unreleased/31689-request-access-spacing.yml create mode 100644 changelogs/unreleased/31760-add-tooltips-to-note-actions.yml create mode 100644 changelogs/unreleased/31810-commit-link.yml create mode 100644 changelogs/unreleased/add_system_note_for_editing_issuable.yml create mode 100644 changelogs/unreleased/balsalmiq-support.yml create mode 100644 changelogs/unreleased/deploy-keys-load-async.yml create mode 100644 changelogs/unreleased/dm-artifact-blob-viewer.yml create mode 100644 changelogs/unreleased/fix-admin-integrations.yml create mode 100644 changelogs/unreleased/implement-i18n-support.yml create mode 100644 changelogs/unreleased/issue-boards-no-avatar.yml create mode 100644 changelogs/unreleased/issue-title-description-realtime.yml create mode 100644 changelogs/unreleased/merge-request-poll-json-endpoint.yml create mode 100644 changelogs/unreleased/mrchrisw-import-shell-timeout.yml create mode 100644 changelogs/unreleased/prometheus-integration-test-setting-fix.yml create mode 100644 changelogs/unreleased/tags-sort-default.yml create mode 100644 changelogs/unreleased/update-issue-board-cards-design.yml create mode 100644 changelogs/unreleased/winh-visual-token-labels.yml create mode 100644 config/initializers/fast_gettext.rb create mode 100644 config/initializers/gettext_rails_i18n_patch.rb create mode 100644 config/locales/de.yml create mode 100644 config/locales/es.yml create mode 100644 db/migrate/20170320171632_create_issue_assignees_table.rb create mode 100644 db/migrate/20170320173259_migrate_assignees.rb create mode 100644 db/migrate/20170413035209_add_preferred_language_to_users.rb create mode 100644 db/migrate/20170503021915_add_last_edited_at_and_last_edited_by_id_to_issues.rb create mode 100644 db/migrate/20170503022548_add_last_edited_at_and_last_edited_by_id_to_merge_requests.rb create mode 100644 db/migrate/20170504102911_add_clientside_sentry_to_application_settings.rb create mode 100644 doc/development/build_test_package.md create mode 100644 lib/api/helpers/common_helpers.rb create mode 100644 lib/gitlab/i18n.rb create mode 100644 lib/tasks/gettext.rake create mode 100644 locale/de/gitlab.po create mode 100644 locale/de/gitlab.po.time_stamp create mode 100644 locale/en/gitlab.po create mode 100644 locale/en/gitlab.po.time_stamp create mode 100644 locale/es/gitlab.po create mode 100644 locale/es/gitlab.po.time_stamp create mode 100644 locale/gitlab.pot create mode 100644 spec/controllers/projects/artifacts_controller_spec.rb create mode 100644 spec/controllers/projects/deploy_keys_controller_spec.rb create mode 100644 spec/features/projects/artifacts/file_spec.rb create mode 100644 spec/features/projects/branches/new_branch_ref_dropdown_spec.rb create mode 100644 spec/features/raven_js_spec.rb create mode 100644 spec/javascripts/autosave_spec.js create mode 100644 spec/javascripts/behaviors/gl_emoji/unicode_support_map_spec.js create mode 100644 spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js create mode 100644 spec/javascripts/deploy_keys/components/action_btn_spec.js create mode 100644 spec/javascripts/deploy_keys/components/app_spec.js create mode 100644 spec/javascripts/deploy_keys/components/key_spec.js create mode 100644 spec/javascripts/deploy_keys/components/keys_panel_spec.js create mode 100644 spec/javascripts/filtered_search/recent_searches_root_spec.js create mode 100644 spec/javascripts/filtered_search/services/recent_searches_service_error_spec.js create mode 100644 spec/javascripts/fixtures/deploy_keys.rb create mode 100644 spec/javascripts/fixtures/labels.rb create mode 100644 spec/javascripts/helpers/user_mock_data_helper.js create mode 100644 spec/javascripts/lib/utils/accessor_spec.js create mode 100644 spec/javascripts/lib/utils/ajax_cache_spec.js create mode 100644 spec/javascripts/raven/index_spec.js create mode 100644 spec/javascripts/raven/raven_config_spec.js create mode 100644 spec/javascripts/sidebar/assignee_title_spec.js create mode 100644 spec/javascripts/sidebar/assignees_spec.js create mode 100644 spec/javascripts/sidebar/mock_data.js create mode 100644 spec/javascripts/sidebar/sidebar_assignees_spec.js create mode 100644 spec/javascripts/sidebar/sidebar_bundle_spec.js create mode 100644 spec/javascripts/sidebar/sidebar_mediator_spec.js create mode 100644 spec/javascripts/sidebar/sidebar_service_spec.js create mode 100644 spec/javascripts/sidebar/sidebar_store_spec.js delete mode 100644 spec/javascripts/subbable_resource_spec.js create mode 100644 spec/javascripts/vue_shared/translate_spec.js create mode 100644 spec/lib/gitlab/i18n_spec.rb create mode 100644 spec/models/ci/artifact_blob_spec.rb delete mode 100644 spec/requests/projects/artifacts_controller_spec.rb create mode 100644 spec/serializers/deploy_key_entity_spec.rb create mode 100644 spec/serializers/label_serializer_spec.rb create mode 100644 spec/services/projects/propagate_service_template_spec.rb delete mode 100644 spec/support/services/issuable_create_service_shared_examples.rb create mode 100644 spec/views/projects/tags/index.html.haml_spec.rb create mode 100644 spec/workers/propagate_service_template_worker_spec.rb diff --git a/.eslintignore b/.eslintignore index c742b08c00540..1605e483e9e63 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,3 +7,4 @@ /vendor/ karma.config.js webpack.config.js +/app/assets/javascripts/locale/**/*.js diff --git a/.gitignore b/.gitignore index 9862ee53d4b73..ccaf475c4df16 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ *.log *.swp +*.mo +*.edit.po .DS_Store .bundle .chef @@ -55,3 +57,4 @@ eslint-report.html /shared/* /.gitlab_workhorse_secret /webpack-report/ +/locale/**/LC_MESSAGES diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6389aeb84648a..71b6d5d841382 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -26,6 +26,7 @@ before_script: - source scripts/prepare_build.sh stages: +- build - prepare - test - post-test @@ -142,6 +143,28 @@ stages: <<: *only-master-and-ee-or-mysql <<: *except-docs +# Trigger a package build on omnibus-gitlab repository + +build-package: + services: [] + variables: + SETUP_DB: "false" + USE_BUNDLE_INSTALL: "false" + stage: build + when: manual + script: + # If no branch in omnibus is specified, trigger pipeline against master + - if [ -z "$OMNIBUS_BRANCH" ] ; then export OMNIBUS_BRANCH=master ;fi + - echo "token=${BUILD_TRIGGER_TOKEN}" > version_details + - echo "ref=${OMNIBUS_BRANCH}" >> version_details + - echo "variables[ALTERNATIVE_SOURCES]=true" >> version_details + - echo "variables[GITLAB_VERSION]=${CI_COMMIT_SHA}" >> version_details + # Collect version details of all components + - for f in *_VERSION; do echo "variables[$f]=$(cat $f)" >> version_details; done + # Trigger the API and pass values collected above as parameters to it + - cat version_details | tr '\n' '&' | curl -X POST https://gitlab.com/api/v4/projects/20699/trigger/pipeline --data-binary @- + - rm version_details + # Prepare and merge knapsack tests knapsack: <<: *knapsack-state @@ -397,18 +420,6 @@ rake karma: paths: - coverage-javascript/ -bundler:audit: - stage: test - <<: *ruby-static-analysis - <<: *dedicated-runner - only: - - master@gitlab-org/gitlab-ce - - master@gitlab-org/gitlab-ee - - master@gitlab/gitlabhq - - master@gitlab/gitlab-ee - script: - - "bundle exec bundle-audit check --update --ignore CVE-2016-4658" - .migration-paths: &migration-paths stage: test <<: *dedicated-runner diff --git a/CHANGELOG.md b/CHANGELOG.md index 639f078fe1fd3..a4342bc541245 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 9.1.3 (2017-05-05) + +- Do not show private groups on subgroups page if user doesn't have access to. +- Enforce project features when searching blobs and wikis. +- Fixed branches dropdown rendering branch names as HTML. +- Make Asciidoc & other markup go through pipeline to prevent XSS. +- Validate URLs in markdown using URI to detect the host correctly. +- Fix for XSS in project import view caused by Hamlit filter usage. +- Sanitize submodule URLs before linking to them in the file tree view. +- Refactor snippets finder & dont return internal snippets for external users. +- Fix snippets visibility for show action - external users can not see internal snippets. + ## 9.1.2 (2017-05-01) - Add index on ci_runners.contacted_at. !10876 (blackst0ne) @@ -277,6 +289,18 @@ entry. - Only send chat notifications for the default branch. - Don't fill in the default kubernetes namespace. +## 9.0.7 (2017-05-05) + +- Enforce project features when searching blobs and wikis. +- Fixed branches dropdown rendering branch names as HTML. +- Make Asciidoc & other markup go through pipeline to prevent XSS. +- Validate URLs in markdown using URI to detect the host correctly. +- Fix for XSS in project import view caused by Hamlit filter usage. +- Sanitize submodule URLs before linking to them in the file tree view. +- Refactor snippets finder & dont return internal snippets for external users. +- Fix snippets visibility for show action - external users can not see internal snippets. +- Do not show private groups on subgroups page if user doesn't have access to. + ## 9.0.6 (2017-04-21) - Bugfix: POST /projects/:id/hooks and PUT /projects/:id/hook/:hook_id no longer ignore the the job_events param in the V4 API. !10586 @@ -621,6 +645,17 @@ entry. - Change development tanuki favicon colors to match logo color order. - API issues - support filtering by iids. +## 8.17.6 (2017-05-05) + +- Enforce project features when searching blobs and wikis. +- Fixed branches dropdown rendering branch names as HTML. +- Make Asciidoc & other markup go through pipeline to prevent XSS. +- Validate URLs in markdown using URI to detect the host correctly. +- Fix for XSS in project import view caused by Hamlit filter usage. +- Sanitize submodule URLs before linking to them in the file tree view. +- Refactor snippets finder & dont return internal snippets for external users. +- Fix snippets visibility for show action - external users can not see internal snippets. + ## 8.17.5 (2017-04-05) - Don’t show source project name when user does not have access. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3bedfe1061d19..bd03c6b0c94d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,11 +19,14 @@ _This notice should stay as the first item in the CONTRIBUTING.MD file._ - [Helping others](#helping-others) - [I want to contribute!](#i-want-to-contribute) - [Workflow labels](#workflow-labels) + - [Type labels (~"feature proposal", ~bug, ~customer, etc.)](#type-labels-feature-proposal-bug-customer-etc) + - [Subject labels (~wiki, ~"container registry", ~ldap, ~api, etc.)](#subject-labels-wiki-container-registry-ldap-api-etc) + - [Team labels (~CI, ~Discussion, ~Edge, ~Platform, etc.)](#team-labels-ci-discussion-edge-platform-etc) + - [Priority labels (~Deliverable and ~Stretch)](#priority-labels-deliverable-and-stretch) + - [Label for community contributors (~"Accepting Merge Requests")](#label-for-community-contributors-accepting-merge-requests) - [Implement design & UI elements](#implement-design--ui-elements) -- [Release retrospective and kickoff](#release-retrospective-and-kickoff) - - [Retrospective](#retrospective) - - [Kickoff](#kickoff) - [Issue tracker](#issue-tracker) + - [Issue triaging](#issue-triaging) - [Feature proposals](#feature-proposals) - [Issue tracker guidelines](#issue-tracker-guidelines) - [Issue weight](#issue-weight) @@ -32,9 +35,7 @@ _This notice should stay as the first item in the CONTRIBUTING.MD file._ - [Stewardship](#stewardship) - [Merge requests](#merge-requests) - [Merge request guidelines](#merge-request-guidelines) - - [Getting your merge request reviewed, approved, and merged](#getting-your-merge-request-reviewed-approved-and-merged) - [Contribution acceptance criteria](#contribution-acceptance-criteria) -- [Changes for Stable Releases](#changes-for-stable-releases) - [Definition of done](#definition-of-done) - [Style guides](#style-guides) - [Code of conduct](#code-of-conduct) @@ -105,34 +106,128 @@ contributing to GitLab. ## Workflow labels -Labelling issues is described in the [GitLab Inc engineering workflow]. +To allow for asynchronous issue handling, we use [milestones][milestones-page] +and [labels][labels-page]. Leads and product managers handle most of the +scheduling into milestones. Labelling is a task for everyone. -## Implement design & UI elements +Most issues will have labels for at least one of the following: -Please see the [UX Guide for GitLab]. +- Type: ~"feature proposal", ~bug, ~customer, etc. +- Subject: ~wiki, ~"container registry", ~ldap, ~api, etc. +- Team: ~CI, ~Discussion, ~Edge, ~Frontend, ~Platform, etc. +- Priority: ~Deliverable, ~Stretch + +All labels, their meaning and priority are defined on the +[labels page][labels-page]. + +If you come across an issue that has none of these, and you're allowed to set +labels, you can _always_ add the team and type, and often also the subject. + +[milestones-page]: https://gitlab.com/gitlab-org/gitlab-ce/milestones +[labels-page]: https://gitlab.com/gitlab-org/gitlab-ce/labels + +### Type labels (~"feature proposal", ~bug, ~customer, etc.) + +Type labels are very important. They define what kind of issue this is. Every +issue should have one or more. + +Examples of type labels are ~"feature proposal", ~bug, ~customer, ~security, +and ~"direction". + +A number of type labels have a priority assigned to them, which automatically +makes them float to the top, depending on their importance. + +Type labels are always lowercase, and can have any color, besides blue (which is +already reserved for subject labels). + +The descriptions on the [labels page][labels-page] explain what falls under each type label. + +### Subject labels (~wiki, ~"container registry", ~ldap, ~api, etc.) + +Subject labels are labels that define what area or feature of GitLab this issue +hits. They are not always necessary, but very convenient. + +If you are an expert in a particular area, it makes it easier to find issues to +work on. You can also subscribe to those labels to receive an email each time an +issue is labelled with a subject label corresponding to your expertise. + +Examples of subject labels are ~wiki, ~"container registry", ~ldap, ~api, +~issues, ~"merge requests", ~labels, and ~"container registry". + +Subject labels are always all-lowercase. + +### Team labels (~CI, ~Discussion, ~Edge, ~Platform, etc.) + +Team labels specify what team is responsible for this issue. +Assigning a team label makes sure issues get the attention of the appropriate +people. + +The current team labels are ~Build, ~CI, ~Discussion, ~Documentation, ~Edge, +~Gitaly, ~Platform, ~Prometheus, ~Release, and ~"UX". + +The descriptions on the [labels page][labels-page] explain what falls under the +responsibility of each team. + +Within those team labels, we also have the ~backend and ~frontend labels to +indicate if an issue needs backend work, frontend work, or both. + +Team labels are always capitalized so that they show up as the first label for +any issue. -## Release retrospective and kickoff +### Priority labels (~Deliverable and ~Stretch) -### Retrospective +Priority labels help us clearly communicate expectations of the work for the +release. There are two levels of priority labels: -After each release, we have a retrospective call where we discuss what went well, -what went wrong, and what we can improve for the next release. The -[retrospective notes] are public and you are invited to comment on them. -If you're interested, you can even join the -[retrospective call][retro-kickoff-call], on the first working day after the -22nd at 6pm CET / 9am PST. +- ~Deliverable: Issues that are expected to be delivered in the current + milestone. +- ~Stretch: Issues that are a stretch goal for delivering in the current + milestone. If these issues are not done in the current release, they will + strongly be considered for the next release. -### Kickoff +### Label for community contributors (~"Accepting Merge Requests") -Before working on the next release, we have a -kickoff call to explain what we expect to ship in the next release. The -[kickoff notes] are public and you are invited to comment on them. -If you're interested, you can even join the [kickoff call][retro-kickoff-call], -on the first working day after the 7th at 6pm CET / 9am PST.. +Issues that are beneficial to our users, 'nice to haves', that we currently do +not have the capacity for or want to give the priority to, are labeled as +~"Accepting Merge Requests", so the community can make a contribution. -[retrospective notes]: https://docs.google.com/document/d/1nEkM_7Dj4bT21GJy0Ut3By76FZqCfLBmFQNVThmW2TY/edit?usp=sharing -[kickoff notes]: https://docs.google.com/document/d/1ElPkZ90A8ey_iOkTvUs_ByMlwKK6NAB2VOK5835wYK0/edit?usp=sharing -[retro-kickoff-call]: https://gitlab.zoom.us/j/918821206 +Community contributors can submit merge requests for any issue they want, but +the ~"Accepting Merge Requests" label has a special meaning. It points to +changes that: + +1. We already agreed on, +1. Are well-defined, +1. Are likely to get accepted by a maintainer. + +We want to avoid a situation when a contributor picks an +~"Accepting Merge Requests" issue and then their merge request gets closed, +because we realize that it does not fit our vision, or we want to solve it in a +different way. + +We add the ~"Accepting Merge Requests" label to: + +- Low priority ~bug issues (i.e. we do not add it to the bugs that we want to +solve in the ~"Next Patch Release") +- Small ~"feature proposal" that do not need ~UX / ~"Product work", or for which +the ~UX / ~"Product work" is already done +- Small ~"technical debt" issues + +After adding the ~"Accepting Merge Requests" label, we try to estimate the +[weight](#issue-weight) of the issue. We use issue weight to let contributors +know how difficult the issue is. Additionally: + +- We advertise [~"Accepting Merge Requests" issues with weight < 5][up-for-grabs] + as suitable for people that have never contributed to GitLab before on the + [Up For Grabs campaign](http://up-for-grabs.net) +- We encourage people that have never contributed to any open source project to + look for [~"Accepting Merge Requests" issues with a weight of 1][firt-timers] + +[up-for-grabs]: https://gitlab.com/gitlab-org/gitlab-ce/issues?label_name=Accepting+Merge+Requests&scope=all&sort=weight_asc&state=opened +[firt-timers]: https://gitlab.com/gitlab-org/gitlab-ce/issues?label_name%5B%5D=Accepting+Merge+Requests&scope=all&sort=upvotes_desc&state=opened&weight=1 + +## Implement design & UI elements + +Please see the [UX Guide for GitLab]. ## Issue tracker @@ -156,6 +251,21 @@ If it happens that you know the solution to an existing bug, please first open the issue in order to keep track of it and then open the relevant merge request that potentially fixes it. +### Issue triaging + +Our issue triage policies are [described in our handbook]. You are very welcome +to help the GitLab team triage issues. We also organize [issue bash events] once +every quarter. + +The most important thing is making sure valid issues receive feedback from the +development team. Therefore the priority is mentioning developers that can help +on those issues. Please select someone with relevant experience from the +[GitLab team][team]. If there is nobody mentioned with that expertise look in +the commit history for the affected files to find someone. + +[described in our handbook]: https://about.gitlab.com/handbook/engineering/issues/issue-triage-policies/ +[issue bash events]: https://gitlab.com/gitlab-org/gitlab-ce/issues/17815 + ### Feature proposals To create a feature proposal for CE, open an issue on the @@ -329,13 +439,17 @@ request is as follows: "Description" field. 1. If you are contributing documentation, choose `Documentation` from the "Choose a template" menu and fill in the template. + 1. Mention the issue(s) your merge request solves, using the `Solves #XXX` or + `Closes #XXX` syntax to auto-close the issue(s) once the merge request will + be merged. +1. If you're allowed to, set a relevant milestone and labels 1. If the MR changes the UI it should include *Before* and *After* screenshots 1. If the MR changes CSS classes please include the list of affected pages, `grep css-class ./app -R` -1. Link any relevant [issues][ce-tracker] in the merge request description and - leave a comment on them with a link back to the MR 1. Be prepared to answer questions and incorporate feedback even if requests for this arrive weeks or months after your MR submission + 1. If a discussion has been addressed, select the "Resolve discussion" button + beneath it to mark it resolved. 1. If your MR touches code that executes shell commands, reads or opens files or handles paths to files on disk, make sure it adheres to the [shell command guidelines](doc/development/shell_commands.md) @@ -371,24 +485,6 @@ Please ensure that your merge request meets the contribution acceptance criteria When having your code reviewed and when reviewing merge requests please take the [code review guidelines](doc/development/code_review.md) into account. -### Getting your merge request reviewed, approved, and merged - -There are a few rules to get your merge request accepted: - -1. Your merge request should only be **merged by a [maintainer][team]**. - 1. If your merge request includes only backend changes [^1], it must be - **approved by a [backend maintainer][team]**. - 1. If your merge request includes only frontend changes [^1], it must be - **approved by a [frontend maintainer][team]**. - 1. If your merge request includes frontend and backend changes [^1], it must - be **approved by a [frontend and a backend maintainer][team]**. -1. To lower the amount of merge requests maintainers need to review, you can - ask or assign any [reviewers][team] for a first review. - 1. If you need some guidance (e.g. it's your first merge request), feel free - to ask one of the [Merge request coaches][team]. - 1. The reviewer will assign the merge request to a maintainer once the - reviewer is satisfied with the state of the merge request. - ### Contribution acceptance criteria 1. The change is as small as possible @@ -418,8 +514,7 @@ There are a few rules to get your merge request accepted: 1. If you need polling to support real-time features, please use [polling with ETag caching][polling-etag]. 1. Changes after submitting the merge request should be in separate commits - (no squashing). If necessary, you will be asked to squash when the review is - over, before merging. + (no squashing). 1. It conforms to the [style guides](#style-guides) and the following: - If your change touches a line that does not follow the style, modify the entire line to follow it. This prevents linting tools from generating warnings. @@ -430,19 +525,6 @@ There are a few rules to get your merge request accepted: See the instructions in that document for help if your MR fails the "license-finder" test with a "Dependencies that need approval" error. -## Changes for Stable Releases - -Sometimes certain changes have to be added to an existing stable release. -Two examples are bug fixes and performance improvements. In these cases the -corresponding merge request should be updated to have the following: - -1. A milestone indicating what release the merge request should be merged into. -1. The label "Pick into Stable" - -This makes it easier for release managers to keep track of what still has to be -merged and where changes have to be merged into. -Like all merge requests the target should be master so all bugfixes are in master. - ## Definition of done If you contribute to GitLab please know that changes involve more than just @@ -451,16 +533,16 @@ the feature you contribute through all of these steps. 1. Description explaining the relevancy (see following item) 1. Working and clean code that is commented where needed -1. Unit and integration tests that pass on the CI server +1. [Unit and system tests][testing] that pass on the CI server 1. Performance/scalability implications have been considered, addressed, and tested -1. [Documented][doc-styleguide] in the /doc directory -1. Changelog entry added +1. [Documented][doc-styleguide] in the `/doc` directory +1. [Changelog entry added][changelog], if necessary 1. Reviewed and any concerns are addressed -1. Merged by the project lead -1. Added to the release blog article -1. Added to [the website](https://gitlab.com/gitlab-com/www-gitlab-com/) if relevant +1. Merged by a project maintainer +1. Added to the release blog article, if relevant +1. Added to [the website](https://gitlab.com/gitlab-com/www-gitlab-com/), if relevant 1. Community questions answered -1. Answers to questions radiated (in docs/wiki/etc.) +1. Answers to questions radiated (in docs/wiki/support etc.) If you add a dependency in GitLab (such as an operating system package) please consider updating the following and note the applicability of each in your @@ -483,7 +565,7 @@ merge request: - string literal quoting style **Option A**: single quoted by default 1. [Rails](https://github.com/bbatsov/rails-style-guide) 1. [Newlines styleguide][newlines-styleguide] -1. [Testing](doc/development/testing.md) +1. [Testing][testing] 1. [JavaScript styleguide][js-styleguide] 1. [SCSS styleguide][scss-styleguide] 1. [Shell commands](doc/development/shell_commands.md) created by GitLab @@ -578,6 +660,7 @@ When your code contains more than 500 changes, any major breaking changes, or an [license-finder-doc]: doc/development/licensing.md [GitLab Inc engineering workflow]: https://about.gitlab.com/handbook/engineering/workflow/#labelling-issues [polling-etag]: https://docs.gitlab.com/ce/development/polling.html +[testing]: doc/development/testing.md [^1]: Please note that specs other than JavaScript specs are considered backend code. diff --git a/Gemfile b/Gemfile index 976b105ca7c10..d60419a9d24c8 100644 --- a/Gemfile +++ b/Gemfile @@ -266,6 +266,12 @@ gem 'sentry-raven', '~> 2.4.0' gem 'premailer-rails', '~> 1.9.0' +# I18n +gem 'ruby_parser', '~> 3.8.4', require: false +gem 'gettext_i18n_rails', '~> 1.8.0' +gem 'gettext_i18n_rails_js', '~> 1.2.0' +gem 'gettext', '~> 3.2.2', require: false, group: :development + # Metrics group :metrics do gem 'allocations', '~> 1.0', require: false, platform: :mri diff --git a/Gemfile.lock b/Gemfile.lock index 0d90374fd14b4..a18bfc103dd0c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -222,6 +222,7 @@ GEM faraday_middleware-multi_json (0.0.6) faraday_middleware multi_json + fast_gettext (1.4.0) ffaker (2.4.0) ffi (1.9.10) flay (2.8.1) @@ -275,6 +276,16 @@ GEM gemojione (3.0.1) json get_process_mem (0.2.0) + gettext (3.2.2) + locale (>= 2.0.5) + text (>= 1.3.0) + gettext_i18n_rails (1.8.0) + fast_gettext (>= 0.9.0) + gettext_i18n_rails_js (1.2.0) + gettext (>= 3.0.2) + gettext_i18n_rails (>= 0.7.1) + po_to_json (>= 1.0.0) + rails (>= 3.2.0) gherkin-ruby (0.3.2) gitaly (0.5.0) google-protobuf (~> 3.1) @@ -450,6 +461,7 @@ GEM licensee (8.7.0) rugged (~> 0.24) little-plugger (1.1.4) + locale (2.1.2) logging (2.1.0) little-plugger (~> 1.1) multi_json (~> 1.10) @@ -553,6 +565,8 @@ GEM ast (~> 2.2) path_expander (1.0.1) pg (0.18.4) + po_to_json (1.0.1) + json (>= 1.6.0) poltergeist (1.9.0) capybara (~> 2.1) cliver (~> 0.3.1) @@ -805,6 +819,7 @@ GEM temple (0.7.7) test_after_commit (1.1.0) activerecord (>= 3.2) + text (1.3.1) thin (1.7.0) daemons (~> 1.0, >= 1.0.9) eventmachine (~> 1.0, >= 1.0.4) @@ -937,6 +952,9 @@ DEPENDENCIES fuubar (~> 2.0.0) gemnasium-gitlab-service (~> 0.2) gemojione (~> 3.0) + gettext (~> 3.2.2) + gettext_i18n_rails (~> 1.8.0) + gettext_i18n_rails_js (~> 1.2.0) gitaly (~> 0.5.0) github-linguist (~> 4.7.0) gitlab-flowdock-git-hook (~> 1.0.1) @@ -1030,6 +1048,7 @@ DEPENDENCIES rubocop-rspec (~> 1.15.0) ruby-fogbugz (~> 0.2.1) ruby-prof (~> 0.16.2) + ruby_parser (~> 3.8.4) rufus-scheduler (~> 3.1.10) rugged (~> 0.25.1.1) sanitize (~> 2.0) diff --git a/PROCESS.md b/PROCESS.md index fac3c22e09fc8..3b97a4e8c75fc 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -1,35 +1,53 @@ -# GitLab Contributing Process +## GitLab Core Team & GitLab Inc. Contribution Process + +--- + +<!-- START doctoc generated TOC please keep comment here to allow auto update --> +<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE --> +**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* + +- [Purpose of describing the contributing process](#purpose-of-describing-the-contributing-process) +- [Common actions](#common-actions) + - [Merge request coaching](#merge-request-coaching) +- [Assigning issues](#assigning-issues) +- [Be kind](#be-kind) +- [Feature freeze on the 7th for the release on the 22nd](#feature-freeze-on-the-7th-for-the-release-on-the-22nd) + - [Between the 1st and the 7th](#between-the-1st-and-the-7th) + - [On the 7th](#on-the-7th) + - [After the 7th](#after-the-7th) +- [Release retrospective and kickoff](#release-retrospective-and-kickoff) + - [Retrospective](#retrospective) + - [Kickoff](#kickoff) +- [Copy & paste responses](#copy--paste-responses) + - [Improperly formatted issue](#improperly-formatted-issue) + - [Issue report for old version](#issue-report-for-old-version) + - [Support requests and configuration questions](#support-requests-and-configuration-questions) + - [Code format](#code-format) + - [Issue fixed in newer version](#issue-fixed-in-newer-version) + - [Improperly formatted merge request](#improperly-formatted-merge-request) + - [Inactivity close of an issue](#inactivity-close-of-an-issue) + - [Inactivity close of a merge request](#inactivity-close-of-a-merge-request) + - [Accepting merge requests](#accepting-merge-requests) + - [Only accepting merge requests with green tests](#only-accepting-merge-requests-with-green-tests) + - [Closing down the issue tracker on GitHub](#closing-down-the-issue-tracker-on-github) + +<!-- END doctoc generated TOC please keep comment here to allow auto update --> + +--- ## Purpose of describing the contributing process -Below we describe the contributing process to GitLab for two reasons. So that -contributors know what to expect from maintainers (possible responses, friendly -treatment, etc.). And so that maintainers know what to expect from contributors -(use the latest version, ensure that the issue is addressed, friendly treatment, -etc.). +Below we describe the contributing process to GitLab for two reasons: + +1. Contributors know what to expect from maintainers (possible responses, friendly + treatment, etc.) +1. Maintainers know what to expect from contributors (use the latest version, + ensure that the issue is addressed, friendly treatment, etc.). - [GitLab Inc engineers should refer to the engineering workflow document](https://about.gitlab.com/handbook/engineering/workflow/) ## Common actions -### Issue triaging - -Our issue triage policies are [described in our handbook]. You are very welcome -to help the GitLab team triage issues. We also organize [issue bash events] once -every quarter. - -The most important thing is making sure valid issues receive feedback from the -development team. Therefore the priority is mentioning developers that can help -on those issues. Please select someone with relevant experience from -[GitLab team][team]. If there is nobody mentioned with that expertise -look in the commit history for the affected files to find someone. Avoid -mentioning the lead developer, this is the person that is least likely to give a -timely response. If the involvement of the lead developer is needed the other -core team members will mention this person. - -[described in our handbook]: https://about.gitlab.com/handbook/engineering/issues/issue-triage-policies/ -[issue bash events]: https://gitlab.com/gitlab-org/gitlab-ce/issues/17815 - ### Merge request coaching Several people from the [GitLab team][team] are helping community members to get @@ -37,12 +55,6 @@ their contributions accepted by meeting our [Definition of done][done]. What you can expect from them is described at https://about.gitlab.com/jobs/merge-request-coach/. -## Workflow labels - -Labelling issues is described in the [GitLab Inc engineering workflow]. - -[GitLab Inc engineering workflow]: https://about.gitlab.com/handbook/engineering/workflow/#labelling-issues - ## Assigning issues If an issue is complex and needs the attention of a specific person, assignment is a good option but assigning issues might discourage other people from contributing to that issue. We need all the contributions we can get so this should never be discouraged. Also, an assigned person might not have time for a few weeks, so others should feel free to takeover. @@ -146,6 +158,29 @@ release should have the correct milestone assigned _and_ have the label Merge requests without a milestone and this label will not be merged into any stable branches. +## Release retrospective and kickoff + +### Retrospective + +After each release, we have a retrospective call where we discuss what went well, +what went wrong, and what we can improve for the next release. The +[retrospective notes] are public and you are invited to comment on them. +If you're interested, you can even join the +[retrospective call][retro-kickoff-call], on the first working day after the +22nd at 6pm CET / 9am PST. + +### Kickoff + +Before working on the next release, we have a +kickoff call to explain what we expect to ship in the next release. The +[kickoff notes] are public and you are invited to comment on them. +If you're interested, you can even join the [kickoff call][retro-kickoff-call], +on the first working day after the 7th at 6pm CET / 9am PST.. + +[retrospective notes]: https://docs.google.com/document/d/1nEkM_7Dj4bT21GJy0Ut3By76FZqCfLBmFQNVThmW2TY/edit?usp=sharing +[kickoff notes]: https://docs.google.com/document/d/1ElPkZ90A8ey_iOkTvUs_ByMlwKK6NAB2VOK5835wYK0/edit?usp=sharing +[retro-kickoff-call]: https://gitlab.zoom.us/j/918821206 + ## Copy & paste responses ### Improperly formatted issue diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js index 8630b18a73f96..cfab6c40b34f7 100644 --- a/app/assets/javascripts/autosave.js +++ b/app/assets/javascripts/autosave.js @@ -1,8 +1,11 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-param-reassign, quotes, prefer-template, no-var, one-var, no-unused-vars, one-var-declaration-per-line, no-void, consistent-return, no-empty, max-len */ +import AccessorUtilities from './lib/utils/accessor'; window.Autosave = (function() { function Autosave(field, key) { this.field = field; + this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); + if (key.join != null) { key = key.join("/"); } @@ -17,16 +20,12 @@ window.Autosave = (function() { } Autosave.prototype.restore = function() { - var e, text; - if (window.localStorage == null) { - return; - } - try { - text = window.localStorage.getItem(this.key); - } catch (error) { - e = error; - return; - } + var text; + + if (!this.isLocalStorageAvailable) return; + + text = window.localStorage.getItem(this.key); + if ((text != null ? text.length : void 0) > 0) { this.field.val(text); } @@ -35,27 +34,22 @@ window.Autosave = (function() { Autosave.prototype.save = function() { var text; - if (window.localStorage == null) { - return; - } text = this.field.val(); - if ((text != null ? text.length : void 0) > 0) { - try { - return window.localStorage.setItem(this.key, text); - } catch (error) {} - } else { - return this.reset(); + + if (this.isLocalStorageAvailable && (text != null ? text.length : void 0) > 0) { + return window.localStorage.setItem(this.key, text); } + + return this.reset(); }; Autosave.prototype.reset = function() { - if (window.localStorage == null) { - return; - } - try { - return window.localStorage.removeItem(this.key); - } catch (error) {} + if (!this.isLocalStorageAvailable) return; + + return window.localStorage.removeItem(this.key); }; return Autosave; })(); + +export default window.Autosave; diff --git a/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js b/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js index aa522e20c3603..257df55e54fb4 100644 --- a/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js +++ b/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js @@ -1,3 +1,5 @@ +import AccessorUtilities from '../../lib/utils/accessor'; + const unicodeSupportTestMap = { // man, student (emojione does not have any of these yet), http://emojipedia.org/emoji-zwj-sequences/ // occupationZwj: '\u{1F468}\u{200D}\u{1F393}', @@ -140,16 +142,25 @@ function generateUnicodeSupportMap(testMap) { function getUnicodeSupportMap() { let unicodeSupportMap; - const userAgentFromCache = window.localStorage.getItem('gl-emoji-user-agent'); + let userAgentFromCache; + + const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); + + if (isLocalStorageAvailable) userAgentFromCache = window.localStorage.getItem('gl-emoji-user-agent'); + try { unicodeSupportMap = JSON.parse(window.localStorage.getItem('gl-emoji-unicode-support-map')); } catch (err) { // swallow } + if (!unicodeSupportMap || userAgentFromCache !== navigator.userAgent) { unicodeSupportMap = generateUnicodeSupportMap(unicodeSupportTestMap); - window.localStorage.setItem('gl-emoji-user-agent', navigator.userAgent); - window.localStorage.setItem('gl-emoji-unicode-support-map', JSON.stringify(unicodeSupportMap)); + + if (isLocalStorageAvailable) { + window.localStorage.setItem('gl-emoji-user-agent', navigator.userAgent); + window.localStorage.setItem('gl-emoji-unicode-support-map', JSON.stringify(unicodeSupportMap)); + } } return unicodeSupportMap; diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js index 3d162b244135c..1f9e044808451 100644 --- a/app/assets/javascripts/behaviors/quick_submit.js +++ b/app/assets/javascripts/behaviors/quick_submit.js @@ -43,8 +43,8 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => { const $submitButton = $form.find('input[type=submit], button[type=submit]'); if (!$submitButton.attr('disabled')) { + $submitButton.trigger('click', [e]); $submitButton.disable(); - $form.submit(); } }); diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js new file mode 100644 index 0000000000000..cdbfe36ca1cc3 --- /dev/null +++ b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js @@ -0,0 +1,114 @@ +/* global Flash */ + +import sqljs from 'sql.js'; +import { template as _template } from 'underscore'; + +const PREVIEW_TEMPLATE = _template(` + <div class="panel panel-default"> + <div class="panel-heading"><%- name %></div> + <div class="panel-body"> + <img class="img-thumbnail" src="data:image/png;base64,<%- image %>"/> + </div> + </div> +`); + +class BalsamiqViewer { + constructor(viewer) { + this.viewer = viewer; + this.endpoint = this.viewer.dataset.endpoint; + } + + loadFile() { + const xhr = new XMLHttpRequest(); + + xhr.open('GET', this.endpoint, true); + xhr.responseType = 'arraybuffer'; + + xhr.onload = this.renderFile.bind(this); + xhr.onerror = BalsamiqViewer.onError; + + xhr.send(); + } + + renderFile(loadEvent) { + const container = document.createElement('ul'); + + this.initDatabase(loadEvent.target.response); + + const previews = this.getPreviews(); + previews.forEach((preview) => { + const renderedPreview = this.renderPreview(preview); + + container.appendChild(renderedPreview); + }); + + container.classList.add('list-inline'); + container.classList.add('previews'); + + this.viewer.appendChild(container); + } + + initDatabase(data) { + const previewBinary = new Uint8Array(data); + + this.database = new sqljs.Database(previewBinary); + } + + getPreviews() { + const thumbnails = this.database.exec('SELECT * FROM thumbnails'); + + return thumbnails[0].values.map(BalsamiqViewer.parsePreview); + } + + getResource(resourceID) { + const resources = this.database.exec(`SELECT * FROM resources WHERE id = '${resourceID}'`); + + return resources[0]; + } + + renderPreview(preview) { + const previewElement = document.createElement('li'); + + previewElement.classList.add('preview'); + previewElement.innerHTML = this.renderTemplate(preview); + + return previewElement; + } + + renderTemplate(preview) { + const resource = this.getResource(preview.resourceID); + const name = BalsamiqViewer.parseTitle(resource); + const image = preview.image; + + const template = PREVIEW_TEMPLATE({ + name, + image, + }); + + return template; + } + + static parsePreview(preview) { + return JSON.parse(preview[1]); + } + + /* + * resource = { + * columns: ['ID', 'BRANCHID', 'ATTRIBUTES', 'DATA'], + * values: [['id', 'branchId', 'attributes', 'data']], + * } + * + * 'attributes' being a JSON string containing the `name` property. + */ + static parseTitle(resource) { + return JSON.parse(resource.values[0][2]).name; + } + + static onError() { + const flash = new Flash('Balsamiq file could not be loaded.'); + + return flash; + } +} + +export default BalsamiqViewer; diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js new file mode 100644 index 0000000000000..1dacf84470f3f --- /dev/null +++ b/app/assets/javascripts/blob/balsamiq_viewer.js @@ -0,0 +1,6 @@ +import BalsamiqViewer from './balsamiq/balsamiq_viewer'; + +document.addEventListener('DOMContentLoaded', () => { + const balsamiqViewer = new BalsamiqViewer(document.getElementById('js-balsamiq-viewer')); + balsamiqViewer.loadFile(); +}); diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index 3062cd51ee370..a20c6ca7a21ab 100644 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -99,7 +99,7 @@ export default class FileTemplateMediator { }); } - selectTemplateType(item, el, e) { + selectTemplateType(item, e) { if (e) { e.preventDefault(); } @@ -117,6 +117,10 @@ export default class FileTemplateMediator { this.cacheToggleText(); } + selectTemplateTypeOptions(options) { + this.selectTemplateType(options.selectedObj, options.e); + } + selectTemplateFile(selector, query, data) { selector.renderLoading(); // in case undo menu is already already there diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js index 31dd45fac89ad..ab5b3751c4e2f 100644 --- a/app/assets/javascripts/blob/file_template_selector.js +++ b/app/assets/javascripts/blob/file_template_selector.js @@ -52,9 +52,17 @@ export default class FileTemplateSelector { .removeClass('fa-spinner fa-spin'); } - reportSelection(query, el, e, data) { + reportSelection(options) { + const { query, e, data } = options; e.preventDefault(); return this.mediator.selectTemplateFile(this, query, data); } + + reportSelectionName(options) { + const opts = options; + opts.query = options.selectedObj.name; + + this.reportSelection(opts); + } } diff --git a/app/assets/javascripts/blob/target_branch_dropdown.js b/app/assets/javascripts/blob/target_branch_dropdown.js index 216f069ef71cd..d52d69b127488 100644 --- a/app/assets/javascripts/blob/target_branch_dropdown.js +++ b/app/assets/javascripts/blob/target_branch_dropdown.js @@ -37,8 +37,8 @@ class TargetBranchDropDown { } return SELECT_ITEM_MSG; }, - clicked(item, el, e) { - e.preventDefault(); + clicked(options) { + options.e.preventDefault(); self.onClick.call(self); }, fieldName: self.fieldName, diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js index d7c1c32efbd40..888883163c5b2 100644 --- a/app/assets/javascripts/blob/template_selector.js +++ b/app/assets/javascripts/blob/template_selector.js @@ -24,7 +24,7 @@ export default class TemplateSelector { search: { fields: ['name'], }, - clicked: (item, el, e) => this.fetchFileTemplate(item, el, e), + clicked: options => this.fetchFileTemplate(options), text: item => item.name, }); } @@ -51,7 +51,10 @@ export default class TemplateSelector { return this.$dropdownContainer.removeClass('hidden'); } - fetchFileTemplate(item, el, e) { + fetchFileTemplate(options) { + const { e } = options; + const item = options.selectedObj; + e.preventDefault(); return this.requestFile(item); } diff --git a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js index 935df07677cb2..f2f81af137b4b 100644 --- a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js +++ b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js @@ -25,7 +25,7 @@ export default class BlobCiYamlSelector extends FileTemplateSelector { search: { fields: ['name'], }, - clicked: (query, el, e) => this.reportSelection(query.name, el, e), + clicked: options => this.reportSelectionName(options), text: item => item.name, }); } diff --git a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js index b4b4d09c315b3..3cb7b960aaa7c 100644 --- a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js +++ b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js @@ -25,7 +25,7 @@ export default class DockerfileSelector extends FileTemplateSelector { search: { fields: ['name'], }, - clicked: (query, el, e) => this.reportSelection(query.name, el, e), + clicked: options => this.reportSelectionName(options), text: item => item.name, }); } diff --git a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js index aefae54ae71a4..7efda8e7f50d8 100644 --- a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js +++ b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js @@ -24,7 +24,7 @@ export default class BlobGitignoreSelector extends FileTemplateSelector { search: { fields: ['name'], }, - clicked: (query, el, e) => this.reportSelection(query.name, el, e), + clicked: options => this.reportSelectionName(options), text: item => item.name, }); } diff --git a/app/assets/javascripts/blob/template_selectors/license_selector.js b/app/assets/javascripts/blob/template_selectors/license_selector.js index c8abd689ab4ef..1d757332f6c9f 100644 --- a/app/assets/javascripts/blob/template_selectors/license_selector.js +++ b/app/assets/javascripts/blob/template_selectors/license_selector.js @@ -24,13 +24,22 @@ export default class BlobLicenseSelector extends FileTemplateSelector { search: { fields: ['name'], }, - clicked: (query, el, e) => { + clicked: (options) => { + const { e } = options; + const el = options.$el; + const query = options.selectedObj; + const data = { project: this.$dropdown.data('project'), fullname: this.$dropdown.data('fullname'), }; - this.reportSelection(query.id, el, e, data); + this.reportSelection({ + query: query.id, + el, + e, + data, + }); }, text: item => item.name, }); diff --git a/app/assets/javascripts/blob/template_selectors/type_selector.js b/app/assets/javascripts/blob/template_selectors/type_selector.js index 56f23ef05687a..a09381014a754 100644 --- a/app/assets/javascripts/blob/template_selectors/type_selector.js +++ b/app/assets/javascripts/blob/template_selectors/type_selector.js @@ -17,7 +17,7 @@ export default class FileTemplateTypeSelector extends FileTemplateSelector { filterable: false, selectable: true, toggleLabel: item => item.name, - clicked: (item, el, e) => this.mediator.selectTemplateType(item, el, e), + clicked: options => this.mediator.selectTemplateTypeOptions(options), text: item => item.name, }); } diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js index 7a90da2fd9e1e..9650f95fda2f6 100644 --- a/app/assets/javascripts/boards/boards_bundle.js +++ b/app/assets/javascripts/boards/boards_bundle.js @@ -13,7 +13,7 @@ require('./models/issue'); require('./models/label'); require('./models/list'); require('./models/milestone'); -require('./models/user'); +require('./models/assignee'); require('./stores/boards_store'); require('./stores/modal_store'); require('./services/board_service'); @@ -65,6 +65,7 @@ $(() => { bulkUpdatePath: $boardApp.dataset.bulkUpdatePath, detailIssue: Store.detail, milestoneTitle: $boardApp.dataset.boardMilestoneTitle, + defaultAvatar: $boardApp.dataset.defaultAvatar, }, computed: { detailIssueVisible () { @@ -98,7 +99,7 @@ $(() => { gl.boardService.all() .then((resp) => { resp.json().forEach((board) => { - const list = Store.addList(board); + const list = Store.addList(board, this.defaultAvatar); if (list.type === 'closed') { list.position = Infinity; diff --git a/app/assets/javascripts/boards/components/board_new_issue.js b/app/assets/javascripts/boards/components/board_new_issue.js index b5a8d947a9ce7..9ed262fefd3c8 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.js +++ b/app/assets/javascripts/boards/components/board_new_issue.js @@ -26,6 +26,7 @@ export default { title: this.title, labels, subscribed: true, + assignees: [], }); if (Store.state.currentBoard) { diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index f0066d4ec5d72..317cef9f22750 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -3,8 +3,13 @@ /* global MilestoneSelect */ /* global LabelsSelect */ /* global Sidebar */ +/* global Flash */ import Vue from 'vue'; +import eventHub from '../../sidebar/event_hub'; + +import AssigneeTitle from '../../sidebar/components/assignees/assignee_title'; +import Assignees from '../../sidebar/components/assignees/assignees'; require('./sidebar/remove_issue'); @@ -22,6 +27,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({ detail: Store.detail, issue: {}, list: {}, + loadingAssignees: false, }; }, computed: { @@ -43,6 +49,10 @@ gl.issueBoards.BoardSidebar = Vue.extend({ this.issue = this.detail.issue; this.list = this.detail.list; + + this.$nextTick(() => { + this.endpoint = this.$refs.assigneeDropdown.dataset.issueUpdate; + }); }, deep: true }, @@ -53,12 +63,57 @@ gl.issueBoards.BoardSidebar = Vue.extend({ $('.right-sidebar').getNiceScroll().resize(); }); } - } + + this.issue = this.detail.issue; + this.list = this.detail.list; + }, + deep: true }, methods: { closeSidebar () { this.detail.issue = {}; - } + }, + assignSelf () { + // Notify gl dropdown that we are now assigning to current user + this.$refs.assigneeBlock.dispatchEvent(new Event('assignYourself')); + + this.addAssignee(this.currentUser); + this.saveAssignees(); + }, + removeAssignee (a) { + gl.issueBoards.BoardsStore.detail.issue.removeAssignee(a); + }, + addAssignee (a) { + gl.issueBoards.BoardsStore.detail.issue.addAssignee(a); + }, + removeAllAssignees () { + gl.issueBoards.BoardsStore.detail.issue.removeAllAssignees(); + }, + saveAssignees () { + this.loadingAssignees = true; + + gl.issueBoards.BoardsStore.detail.issue.update(this.endpoint) + .then(() => { + this.loadingAssignees = false; + }) + .catch(() => { + this.loadingAssignees = false; + return new Flash('An error occurred while saving assignees'); + }); + }, + }, + created () { + // Get events from glDropdown + eventHub.$on('sidebar.removeAssignee', this.removeAssignee); + eventHub.$on('sidebar.addAssignee', this.addAssignee); + eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$on('sidebar.saveAssignees', this.saveAssignees); + }, + beforeDestroy() { + eventHub.$off('sidebar.removeAssignee', this.removeAssignee); + eventHub.$off('sidebar.addAssignee', this.addAssignee); + eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$off('sidebar.saveAssignees', this.saveAssignees); }, mounted () { new IssuableContext(this.currentUser); @@ -70,5 +125,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({ }, components: { removeBtn: gl.issueBoards.RemoveIssueBtn, + 'assignee-title': AssigneeTitle, + assignees: Assignees, }, }); diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js index fc154ee7b8b03..710207db0c741 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.js +++ b/app/assets/javascripts/boards/components/issue_card_inner.js @@ -31,18 +31,36 @@ gl.issueBoards.IssueCardInner = Vue.extend({ default: false, }, }, + data() { + return { + limitBeforeCounter: 3, + maxRender: 4, + maxCounter: 99, + }; + }, computed: { - cardUrl() { - return `${this.issueLinkBase}/${this.issue.id}`; + numberOverLimit() { + return this.issue.assignees.length - this.limitBeforeCounter; }, - assigneeUrl() { - return `${this.rootPath}${this.issue.assignee.username}`; + assigneeCounterTooltip() { + return `${this.assigneeCounterLabel} more`; + }, + assigneeCounterLabel() { + if (this.numberOverLimit > this.maxCounter) { + return `${this.maxCounter}+`; + } + + return `+${this.numberOverLimit}`; }, - assigneeUrlTitle() { - return `Assigned to ${this.issue.assignee.name}`; + shouldRenderCounter() { + if (this.issue.assignees.length <= this.maxRender) { + return false; + } + + return this.issue.assignees.length > this.numberOverLimit; }, - avatarUrlTitle() { - return `Avatar for ${this.issue.assignee.name}`; + cardUrl() { + return `${this.issueLinkBase}/${this.issue.id}`; }, issueId() { return `#${this.issue.id}`; @@ -52,6 +70,28 @@ gl.issueBoards.IssueCardInner = Vue.extend({ }, }, methods: { + isIndexLessThanlimit(index) { + return index < this.limitBeforeCounter; + }, + shouldRenderAssignee(index) { + // Eg. maxRender is 4, + // Render up to all 4 assignees if there are only 4 assigness + // Otherwise render up to the limitBeforeCounter + if (this.issue.assignees.length <= this.maxRender) { + return index < this.maxRender; + } + + return index < this.limitBeforeCounter; + }, + assigneeUrl(assignee) { + return `${this.rootPath}${assignee.username}`; + }, + assigneeUrlTitle(assignee) { + return `Assigned to ${assignee.name}`; + }, + avatarUrlTitle(assignee) { + return `Avatar for ${assignee.name}`; + }, showLabel(label) { if (!this.list) return true; @@ -105,25 +145,39 @@ gl.issueBoards.IssueCardInner = Vue.extend({ {{ issueId }} </span> </h4> - <a - class="card-assignee has-tooltip js-no-trigger" - :href="assigneeUrl" - :title="assigneeUrlTitle" - v-if="issue.assignee" - data-container="body" - > - <img - class="avatar avatar-inline s20 js-no-trigger" - :src="issue.assignee.avatar" - width="20" - height="20" - :alt="avatarUrlTitle" - /> - </a> + <div class="card-assignee"> + <a + class="has-tooltip js-no-trigger" + :href="assigneeUrl(assignee)" + :title="assigneeUrlTitle(assignee)" + v-for="(assignee, index) in issue.assignees" + v-if="shouldRenderAssignee(index)" + data-container="body" + data-placement="bottom" + > + <img + class="avatar avatar-inline s20" + :src="assignee.avatar" + width="20" + height="20" + :alt="avatarUrlTitle(assignee)" + /> + </a> + <span + class="avatar-counter has-tooltip" + :title="assigneeCounterTooltip" + v-if="shouldRenderCounter" + > + {{ assigneeCounterLabel }} + </span> + </div> </div> - <div class="card-footer" v-if="showLabelFooter"> + <div + class="card-footer" + v-if="showLabelFooter" + > <button - class="label color-label has-tooltip js-no-trigger" + class="label color-label has-tooltip" v-for="label in issue.labels" type="button" v-if="showLabel(label)" diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index 7e3bb79af1d4a..f29b6caa1acec 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -52,7 +52,9 @@ gl.issueBoards.newListDropdownInit = () => { filterable: true, selectable: true, multiSelect: true, - clicked (label, $el, e) { + clicked (options) { + const { e } = options; + const label = options.selectedObj; e.preventDefault(); if (!Store.findList('title', label.title)) { diff --git a/app/assets/javascripts/boards/models/assignee.js b/app/assets/javascripts/boards/models/assignee.js new file mode 100644 index 0000000000000..05dd449e4fd8d --- /dev/null +++ b/app/assets/javascripts/boards/models/assignee.js @@ -0,0 +1,12 @@ +/* eslint-disable no-unused-vars */ + +class ListAssignee { + constructor(user, defaultAvatar) { + this.id = user.id; + this.name = user.name; + this.username = user.username; + this.avatar = user.avatar_url || defaultAvatar; + } +} + +window.ListAssignee = ListAssignee; diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index 40ff31d0160bc..c2f32bed9f6b8 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -1,12 +1,12 @@ /* eslint-disable no-unused-vars, space-before-function-paren, arrow-body-style, arrow-parens, comma-dangle, max-len */ /* global ListLabel */ /* global ListMilestone */ -/* global ListUser */ +/* global ListAssignee */ import Vue from 'vue'; class ListIssue { - constructor (obj) { + constructor (obj, defaultAvatar) { this.globalId = obj.id; this.id = obj.iid; this.title = obj.title; @@ -14,15 +14,11 @@ class ListIssue { this.dueDate = obj.due_date; this.subscribed = obj.subscribed; this.labels = []; + this.assignees = []; this.selected = false; - this.assignee = false; this.position = obj.relative_position || Infinity; this.milestone_id = obj.milestone_id; - if (obj.assignee) { - this.assignee = new ListUser(obj.assignee); - } - if (obj.milestone) { this.milestone = new ListMilestone(obj.milestone); } @@ -30,6 +26,8 @@ class ListIssue { obj.labels.forEach((label) => { this.labels.push(new ListLabel(label)); }); + + this.assignees = obj.assignees.map(a => new ListAssignee(a, defaultAvatar)); } addLabel (label) { @@ -52,6 +50,26 @@ class ListIssue { labels.forEach(this.removeLabel.bind(this)); } + addAssignee (assignee) { + if (!this.findAssignee(assignee)) { + this.assignees.push(new ListAssignee(assignee)); + } + } + + findAssignee (findAssignee) { + return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0]; + } + + removeAssignee (removeAssignee) { + if (removeAssignee) { + this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id); + } + } + + removeAllAssignees () { + this.assignees = []; + } + getLists () { return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id)); } @@ -61,7 +79,7 @@ class ListIssue { issue: { milestone_id: this.milestone ? this.milestone.id : null, due_date: this.dueDate, - assignee_id: this.assignee ? this.assignee.id : null, + assignee_ids: this.assignees.length > 0 ? this.assignees.map((u) => u.id) : [0], label_ids: this.labels.map((label) => label.id) } }; diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index cac5f8291e0f1..64fc4f5e25233 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -6,7 +6,7 @@ import queryData from '../utils/query_data'; const PER_PAGE = 20; class List { - constructor (obj) { + constructor (obj, defaultAvatar) { this.id = obj.id; this._uid = this.guid(); this.position = obj.position; @@ -18,6 +18,7 @@ class List { this.loadingMore = false; this.issues = []; this.issuesSize = 0; + this.defaultAvatar = defaultAvatar; if (obj.label) { this.label = new ListLabel(obj.label); @@ -107,7 +108,7 @@ class List { createIssues (data) { data.forEach((issueObj) => { - this.addIssue(new ListIssue(issueObj)); + this.addIssue(new ListIssue(issueObj, this.defaultAvatar)); }); } diff --git a/app/assets/javascripts/boards/models/user.js b/app/assets/javascripts/boards/models/user.js deleted file mode 100644 index 8e9de4d4cbb69..0000000000000 --- a/app/assets/javascripts/boards/models/user.js +++ /dev/null @@ -1,12 +0,0 @@ -/* eslint-disable no-unused-vars */ - -class ListUser { - constructor(user) { - this.id = user.id; - this.name = user.name; - this.username = user.username; - this.avatar = user.avatar_url; - } -} - -window.ListUser = ListUser; diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 5a449bd911722..afa0c4f3245b3 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -28,8 +28,8 @@ gl.issueBoards.BoardsStore = { this.state.currentPage = ''; this.state.reload = false; }, - addList (listObj) { - const list = new List(listObj); + addList (listObj, defaultAvatar) { + const list = new List(listObj, defaultAvatar); this.state.lists.push(list); return list; diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js index abe48572347b0..8d3d34f836f5e 100644 --- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js +++ b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js @@ -9,9 +9,9 @@ export default { <span v-if="count === 50" class="events-info pull-right"> <i class="fa fa-warning has-tooltip" aria-hidden="true" - title="Limited to showing 50 events at most" + :title="n__('Limited to showing %d event at most', 'Limited to showing %d events at most', 50)" data-placement="top"></i> - Showing 50 events + {{ n__('Showing %d event', 'Showing %d events', 50) }} </span> `, }; diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js index 80bd2df6f42ea..0d9ad197abf18 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js @@ -28,11 +28,11 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({ <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> · <span> - Opened + {{ __('OpenedNDaysAgo|Opened') }} <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> </span> <span> - by + {{ __('ByAuthor|by') }} <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> </span> </div> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js index 20a43798fbedc..ad285874643fd 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js @@ -28,11 +28,11 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({ <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a> · <span> - Opened + {{ __('OpenedNDaysAgo|Opened') }} <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> </span> <span> - by + {{ __('ByAuthor|by') }} <a :href="issue.author.webUrl" class="issue-author-link"> {{ issue.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js index f33cac3da8248..222084deee94c 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js @@ -31,10 +31,10 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({ </a> </h5> <span> - First + {{ __('FirstPushedBy|First') }} <span class="commit-icon">${iconCommit}</span> <a :href="commit.commitUrl" class="commit-hash-link monospace">{{ commit.shortSha }}</a> - pushed by + {{ __('FirstPushedBy|pushed by') }} <a :href="commit.author.webUrl" class="commit-author-link"> {{ commit.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js index 657f538537433..a14ebc3ece942 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js @@ -28,11 +28,11 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({ <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a> · <span> - Opened + {{ __('OpenedNDaysAgo|Opened') }} <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> </span> <span> - by + {{ __('ByAuthor|by') }} <a :href="issue.author.webUrl" class="issue-author-link"> {{ issue.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js index 8a801300647aa..1a5bf9bc0b511 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js @@ -28,11 +28,11 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({ <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> · <span> - Opened + {{ __('OpenedNDaysAgo|Opened') }} <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> </span> <span> - by + {{ __('ByAuthor|by') }} <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> </span> <template v-if="mergeRequest.state === 'closed'"> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js index 4a28637958848..b1e9362434f1f 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js @@ -32,7 +32,7 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({ </h5> <span> <a :href="build.url" class="build-date">{{ build.date }}</a> - by + {{ __('ByAuthor|by') }} <a :href="build.author.webUrl" class="issue-author-link"> {{ build.author.name }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.js b/app/assets/javascripts/cycle_analytics/components/total_time_component.js index 77edcb7627323..d5e6167b2a82f 100644 --- a/app/assets/javascripts/cycle_analytics/components/total_time_component.js +++ b/app/assets/javascripts/cycle_analytics/components/total_time_component.js @@ -12,10 +12,10 @@ global.cycleAnalytics.TotalTimeComponent = Vue.extend({ template: ` <span class="total-time"> <template v-if="Object.keys(time).length"> - <template v-if="time.days">{{ time.days }} <span>{{ time.days === 1 ? 'day' : 'days' }}</span></template> - <template v-if="time.hours">{{ time.hours }} <span>hr</span></template> - <template v-if="time.mins && !time.days">{{ time.mins }} <span>mins</span></template> - <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>s</span></template> + <template v-if="time.days">{{ time.days }} <span>{{ n__('day', 'days', time.days) }}</span></template> + <template v-if="time.hours">{{ time.hours }} <span>{{ n__('Time|hr', 'Time|hrs', time.hours) }}</span></template> + <template v-if="time.mins && !time.days">{{ time.mins }} <span>{{ n__('Time|min', 'Time|mins', time.mins) }}</span></template> + <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>{{ s__('Time|s') }}</span></template> </template> <template v-else> -- diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index 48cab437e02e6..c8e53cb554eb2 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import Cookies from 'js-cookie'; +import Translate from '../vue_shared/translate'; import LimitWarningComponent from './components/limit_warning_component'; require('./components/stage_code_component'); @@ -16,6 +17,8 @@ require('./cycle_analytics_service'); require('./cycle_analytics_store'); require('./default_event_objects'); +Vue.use(Translate); + $(() => { const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed'; const cycleAnalyticsEl = document.querySelector('#cycle-analytics'); diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js index 681d6eef56577..6504d7db2f2ae 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js @@ -30,7 +30,7 @@ class CycleAnalyticsService { startDate, } = options; - return $.get(`${this.requestPath}/events/${stage.title.toLowerCase()}.json`, { + return $.get(`${this.requestPath}/events/${stage.name}.json`, { cycle_analytics: { start_date: startDate, }, diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js index 6536a8fd7fa28..50bd394e90e0a 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js @@ -1,4 +1,5 @@ /* eslint-disable no-param-reassign */ +import { __ } from '../locale'; require('../lib/utils/text_utility'); const DEFAULT_EVENT_OBJECTS = require('./default_event_objects'); @@ -7,13 +8,13 @@ const global = window.gl || (window.gl = {}); global.cycleAnalytics = global.cycleAnalytics || {}; const EMPTY_STAGE_TEXTS = { - issue: 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.', - plan: 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.', - code: 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.', - test: 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.', - review: 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.', - staging: 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.', - production: 'The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.', + issue: __('The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.'), + plan: __('The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.'), + code: __('The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.'), + test: __('The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.'), + review: __('The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.'), + staging: __('The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.'), + production: __('The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.'), }; global.cycleAnalytics.CycleAnalyticsStore = { @@ -38,7 +39,7 @@ global.cycleAnalytics.CycleAnalyticsStore = { }); newData.stages.forEach((item) => { - const stageSlug = gl.text.dasherize(item.title.toLowerCase()); + const stageSlug = gl.text.dasherize(item.name.toLowerCase()); item.active = false; item.isUserAllowed = data.permissions[stageSlug]; item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug]; diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue new file mode 100644 index 0000000000000..3ff3a9d977e77 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue @@ -0,0 +1,54 @@ +<script> + import eventHub from '../eventhub'; + + export default { + data() { + return { + isLoading: false, + }; + }, + props: { + deployKey: { + type: Object, + required: true, + }, + type: { + type: String, + required: true, + }, + btnCssClass: { + type: String, + required: false, + default: 'btn-default', + }, + }, + methods: { + doAction() { + this.isLoading = true; + + eventHub.$emit(`${this.type}.key`, this.deployKey); + }, + }, + computed: { + text() { + return `${this.type.charAt(0).toUpperCase()}${this.type.slice(1)}`; + }, + }, + }; +</script> + +<template> + <button + class="btn btn-sm prepend-left-10" + :class="[{ disabled: isLoading }, btnCssClass]" + :disabled="isLoading" + @click="doAction"> + {{ text }} + <i + v-if="isLoading" + class="fa fa-spinner fa-spin" + aria-hidden="true" + aria-label="Loading"> + </i> + </button> +</template> diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue new file mode 100644 index 0000000000000..7315a9e11cb02 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -0,0 +1,102 @@ +<script> + /* global Flash */ + import eventHub from '../eventhub'; + import DeployKeysService from '../service'; + import DeployKeysStore from '../store'; + import keysPanel from './keys_panel.vue'; + + export default { + data() { + return { + isLoading: false, + store: new DeployKeysStore(), + }; + }, + props: { + endpoint: { + type: String, + required: true, + }, + }, + computed: { + hasKeys() { + return Object.keys(this.keys).length; + }, + keys() { + return this.store.keys; + }, + }, + components: { + keysPanel, + }, + methods: { + fetchKeys() { + this.isLoading = true; + + this.service.getKeys() + .then((data) => { + this.isLoading = false; + this.store.keys = data; + }) + .catch(() => new Flash('Error getting deploy keys')); + }, + enableKey(deployKey) { + this.service.enableKey(deployKey.id) + .then(() => this.fetchKeys()) + .catch(() => new Flash('Error enabling deploy key')); + }, + disableKey(deployKey) { + // eslint-disable-next-line no-alert + if (confirm('You are going to remove this deploy key. Are you sure?')) { + this.service.disableKey(deployKey.id) + .then(() => this.fetchKeys()) + .catch(() => new Flash('Error removing deploy key')); + } + }, + }, + created() { + this.service = new DeployKeysService(this.endpoint); + + eventHub.$on('enable.key', this.enableKey); + eventHub.$on('remove.key', this.disableKey); + eventHub.$on('disable.key', this.disableKey); + }, + mounted() { + this.fetchKeys(); + }, + beforeDestroy() { + eventHub.$off('enable.key', this.enableKey); + eventHub.$off('remove.key', this.disableKey); + eventHub.$off('disable.key', this.disableKey); + }, + }; +</script> + +<template> + <div class="col-lg-9 col-lg-offset-3 append-bottom-default deploy-keys"> + <div + class="text-center" + v-if="isLoading && !hasKeys"> + <i + class="fa fa-spinner fa-spin fa-2x" + aria-hidden="true" + aria-label="Loading deploy keys"> + </i> + </div> + <div v-else-if="hasKeys"> + <keys-panel + title="Enabled deploy keys for this project" + :keys="keys.enabled_keys" + :store="store" /> + <keys-panel + title="Deploy keys from projects you have access to" + :keys="keys.available_project_keys" + :store="store" /> + <keys-panel + v-if="keys.public_keys.length" + title="Public deploy keys available to any project" + :keys="keys.public_keys" + :store="store" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue new file mode 100644 index 0000000000000..0a06a481b9686 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -0,0 +1,80 @@ +<script> + import actionBtn from './action_btn.vue'; + + export default { + props: { + deployKey: { + type: Object, + required: true, + }, + store: { + type: Object, + required: true, + }, + }, + components: { + actionBtn, + }, + computed: { + timeagoDate() { + return gl.utils.getTimeago().format(this.deployKey.created_at); + }, + }, + methods: { + isEnabled(id) { + return this.store.findEnabledKey(id) !== undefined; + }, + }, + }; +</script> + +<template> + <div> + <div class="pull-left append-right-10 hidden-xs"> + <i + aria-hidden="true" + class="fa fa-key key-icon"> + </i> + </div> + <div class="deploy-key-content key-list-item-info"> + <strong class="title"> + {{ deployKey.title }} + </strong> + <div class="description"> + {{ deployKey.fingerprint }} + </div> + <div + v-if="deployKey.can_push" + class="write-access-allowed"> + Write access allowed + </div> + </div> + <div class="deploy-key-content prepend-left-default deploy-key-projects"> + <a + v-for="project in deployKey.projects" + class="label deploy-project-label" + :href="project.full_path"> + {{ project.full_name }} + </a> + </div> + <div class="deploy-key-content"> + <span class="key-created-at"> + created {{ timeagoDate }} + </span> + <action-btn + v-if="!isEnabled(deployKey.id)" + :deploy-key="deployKey" + type="enable"/> + <action-btn + v-else-if="deployKey.destroyed_when_orphaned && deployKey.almost_orphaned" + :deploy-key="deployKey" + btn-css-class="btn-warning" + type="remove" /> + <action-btn + v-else + :deploy-key="deployKey" + btn-css-class="btn-warning" + type="disable" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue new file mode 100644 index 0000000000000..eccc470578b6e --- /dev/null +++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue @@ -0,0 +1,52 @@ +<script> + import key from './key.vue'; + + export default { + props: { + title: { + type: String, + required: true, + }, + keys: { + type: Array, + required: true, + }, + showHelpBox: { + type: Boolean, + required: false, + default: true, + }, + store: { + type: Object, + required: true, + }, + }, + components: { + key, + }, + }; +</script> + +<template> + <div class="deploy-keys-panel"> + <h5> + {{ title }} + ({{ keys.length }}) + </h5> + <ul class="well-list" + v-if="keys.length"> + <li + v-for="deployKey in keys" + :key="deployKey.id"> + <key + :deploy-key="deployKey" + :store="store" /> + </li> + </ul> + <div + class="settings-message text-center" + v-else-if="showHelpBox"> + No deploy keys found. Create one with the form above. + </div> + </div> +</template> diff --git a/app/assets/javascripts/deploy_keys/eventhub.js b/app/assets/javascripts/deploy_keys/eventhub.js new file mode 100644 index 0000000000000..0948c2e53524a --- /dev/null +++ b/app/assets/javascripts/deploy_keys/eventhub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/deploy_keys/index.js b/app/assets/javascripts/deploy_keys/index.js new file mode 100644 index 0000000000000..a5f232f950a61 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/index.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import deployKeysApp from './components/app.vue'; + +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: document.getElementById('js-deploy-keys'), + data() { + return { + endpoint: this.$options.el.dataset.endpoint, + }; + }, + components: { + deployKeysApp, + }, + render(createElement) { + return createElement('deploy-keys-app', { + props: { + endpoint: this.endpoint, + }, + }); + }, +})); diff --git a/app/assets/javascripts/deploy_keys/service/index.js b/app/assets/javascripts/deploy_keys/service/index.js new file mode 100644 index 0000000000000..fe6dbaa949809 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/service/index.js @@ -0,0 +1,34 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class DeployKeysService { + constructor(endpoint) { + this.endpoint = endpoint; + + this.resource = Vue.resource(`${this.endpoint}{/id}`, {}, { + enable: { + method: 'PUT', + url: `${this.endpoint}{/id}/enable`, + }, + disable: { + method: 'PUT', + url: `${this.endpoint}{/id}/disable`, + }, + }); + } + + getKeys() { + return this.resource.get() + .then(response => response.json()); + } + + enableKey(id) { + return this.resource.enable({ id }, {}); + } + + disableKey(id) { + return this.resource.disable({ id }, {}); + } +} diff --git a/app/assets/javascripts/deploy_keys/store/index.js b/app/assets/javascripts/deploy_keys/store/index.js new file mode 100644 index 0000000000000..6210361af26e8 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/store/index.js @@ -0,0 +1,9 @@ +export default class DeployKeysStore { + constructor() { + this.keys = {}; + } + + findEnabledKey(id) { + return this.keys.enabled_keys.find(key => key.id === id); + } +} diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index c48116d45991a..3844666d25501 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -55,6 +55,7 @@ import ShortcutsWiki from './shortcuts_wiki'; import BlobViewer from './blob/viewer/index'; import GeoNodes from './geo_nodes'; import ServiceDeskRoot from './projects/settings_service_desk/service_desk_root'; +import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; const ShortcutsBlob = require('./shortcuts_blob'); @@ -204,6 +205,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); new LabelsSelect(); new MilestoneSelect(); new gl.IssuableTemplateSelectors(); + new AutoWidthDropdownSelect($('.js-target-branch-select')).init(); break; case 'projects:tags:new': new ZenMode(); @@ -263,6 +265,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); } break; case 'projects:pipelines:builds': + case 'projects:pipelines:failures': case 'projects:pipelines:show': const { controllerAction } = document.querySelector('.js-pipeline-container').dataset; const pipelineStatusUrl = `${document.querySelector('.js-pipeline-tab-link a').getAttribute('href')}/status.json`; @@ -357,6 +360,9 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'projects:artifacts:browse': new BuildArtifacts(); break; + case 'projects:artifacts:file': + new BlobViewer(); + break; case 'help:index': gl.VersionCheckImage.bindErrorEvent($('img.js-version-status-badge')); break; diff --git a/app/assets/javascripts/droplab/constants.js b/app/assets/javascripts/droplab/constants.js index 8883ed9aa142a..868d47e91b3e4 100644 --- a/app/assets/javascripts/droplab/constants.js +++ b/app/assets/javascripts/droplab/constants.js @@ -3,11 +3,14 @@ const DATA_DROPDOWN = 'data-dropdown'; const SELECTED_CLASS = 'droplab-item-selected'; const ACTIVE_CLASS = 'droplab-item-active'; const IGNORE_CLASS = 'droplab-item-ignore'; +// Matches `{{anything}}` and `{{ everything }}`. +const TEMPLATE_REGEX = /\{\{(.+?)\}\}/g; export { DATA_TRIGGER, DATA_DROPDOWN, SELECTED_CLASS, ACTIVE_CLASS, + TEMPLATE_REGEX, IGNORE_CLASS, }; diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js index 1fb4d63923c52..de3927d683ccd 100644 --- a/app/assets/javascripts/droplab/drop_down.js +++ b/app/assets/javascripts/droplab/drop_down.js @@ -94,7 +94,7 @@ Object.assign(DropDown.prototype, { }, renderChildren: function(data) { - var html = utils.t(this.templateString, data); + var html = utils.template(this.templateString, data); var template = document.createElement('div'); template.innerHTML = html; diff --git a/app/assets/javascripts/droplab/utils.js b/app/assets/javascripts/droplab/utils.js index c149a33a1e9ed..4da7344604eda 100644 --- a/app/assets/javascripts/droplab/utils.js +++ b/app/assets/javascripts/droplab/utils.js @@ -1,19 +1,19 @@ /* eslint-disable */ -import { DATA_TRIGGER, DATA_DROPDOWN } from './constants'; +import { template as _template } from 'underscore'; +import { DATA_TRIGGER, DATA_DROPDOWN, TEMPLATE_REGEX } from './constants'; const utils = { toCamelCase(attr) { return this.camelize(attr.split('-').slice(1).join(' ')); }, - t(s, d) { - for (const p in d) { - if (Object.prototype.hasOwnProperty.call(d, p)) { - s = s.replace(new RegExp(`{{${p}}}`, 'g'), d[p]); - } - } - return s; + template(templateString, data) { + const template = _template(templateString, { + escape: TEMPLATE_REGEX, + }); + + return template(data); }, camelize(str) { diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js index 9126422b33547..15052dbd362f5 100644 --- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js +++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js @@ -8,6 +8,11 @@ export default { type: Array, required: true, }, + isLocalStorageAvailable: { + type: Boolean, + required: false, + default: true, + }, }, computed: { @@ -47,7 +52,12 @@ export default { template: ` <div> - <ul v-if="hasItems"> + <div + v-if="!isLocalStorageAvailable" + class="dropdown-info-note"> + This feature requires local storage to be enabled + </div> + <ul v-else-if="hasItems"> <li v-for="(item, index) in processedItems" :key="index"> diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js index 3e7a892756cdd..5e9434fd48f4d 100644 --- a/app/assets/javascripts/filtered_search/dropdown_hint.js +++ b/app/assets/javascripts/filtered_search/dropdown_hint.js @@ -62,7 +62,7 @@ class DropdownHint extends gl.FilteredSearchDropdown { Object.assign({ icon: `fa-${icon}`, hint, - tag: `<${tag}>`, + tag: `<${tag}>`, }, type && { type }), ); } diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 58ff18acd4c9f..22fe2c7f721b3 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -1,5 +1,3 @@ -/* global Flash */ - import FilteredSearchContainer from './container'; import RecentSearchesRoot from './recent_searches_root'; import RecentSearchesStore from './stores/recent_searches_store'; @@ -19,7 +17,9 @@ class FilteredSearchManager { this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeysWithWeights; } - this.recentSearchesStore = new RecentSearchesStore(); + this.recentSearchesStore = new RecentSearchesStore({ + isLocalStorageAvailable: RecentSearchesService.isAvailable(), + }); let recentSearchesKey = 'issue-recent-searches'; if (page === 'merge_requests') { recentSearchesKey = 'merge-request-recent-searches'; @@ -28,9 +28,10 @@ class FilteredSearchManager { // Fetch recent searches from localStorage this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch() - .catch(() => { + .catch((error) => { + if (error.name === 'RecentSearchesServiceError') return undefined; // eslint-disable-next-line no-new - new Flash('An error occured while parsing recent searches'); + new window.Flash('An error occured while parsing recent searches'); // Gracefully fail to empty array return []; }) diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index 453ecccc6fc8d..f3003b86493ea 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -1,3 +1,5 @@ +import AjaxCache from '~/lib/utils/ajax_cache'; +import '~/flash'; /* global Flash */ import FilteredSearchContainer from './container'; class FilteredSearchVisualTokens { @@ -48,6 +50,40 @@ class FilteredSearchVisualTokens { `; } + static updateLabelTokenColor(tokenValueContainer, tokenValue) { + const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search'); + const baseEndpoint = filteredSearchInput.dataset.baseEndpoint; + const labelsEndpoint = `${baseEndpoint}/labels.json`; + + return AjaxCache.retrieve(labelsEndpoint) + .then((labels) => { + const matchingLabel = (labels || []).find(label => `~${gl.DropdownUtils.getEscapedText(label.title)}` === tokenValue); + + if (!matchingLabel) { + return; + } + + const tokenValueStyle = tokenValueContainer.style; + tokenValueStyle.backgroundColor = matchingLabel.color; + tokenValueStyle.color = matchingLabel.text_color; + + if (matchingLabel.text_color === '#FFFFFF') { + const removeToken = tokenValueContainer.querySelector('.remove-token'); + removeToken.classList.add('inverted'); + } + }) + .catch(() => new Flash('An error occurred while fetching label colors.')); + } + + static renderVisualTokenValue(parentElement, tokenName, tokenValue) { + const tokenValueContainer = parentElement.querySelector('.value-container'); + tokenValueContainer.querySelector('.value').innerText = tokenValue; + + if (tokenName.toLowerCase() === 'label') { + FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue); + } + } + static addVisualTokenElement(name, value, isSearchTerm) { const li = document.createElement('li'); li.classList.add('js-visual-token'); @@ -55,7 +91,7 @@ class FilteredSearchVisualTokens { if (value) { li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(); - li.querySelector('.value').innerText = value; + FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value); } else { li.innerHTML = '<div class="name"></div>'; } @@ -74,7 +110,7 @@ class FilteredSearchVisualTokens { const name = FilteredSearchVisualTokens.getLastTokenPartial(); lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(); lastVisualToken.querySelector('.name').innerText = name; - lastVisualToken.querySelector('.value').innerText = value; + FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value); } } @@ -183,6 +219,9 @@ class FilteredSearchVisualTokens { static moveInputToTheRight() { const input = FilteredSearchContainer.container.querySelector('.filtered-search'); + + if (!input) return; + const inputLi = input.parentElement; const tokenContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); diff --git a/app/assets/javascripts/filtered_search/recent_searches_root.js b/app/assets/javascripts/filtered_search/recent_searches_root.js index 4e38409e12a5a..b2e6f63aacf34 100644 --- a/app/assets/javascripts/filtered_search/recent_searches_root.js +++ b/app/assets/javascripts/filtered_search/recent_searches_root.js @@ -29,12 +29,15 @@ class RecentSearchesRoot { } render() { + const state = this.store.state; this.vm = new Vue({ el: this.wrapperElement, - data: this.store.state, + data() { return state; }, template: ` <recent-searches-dropdown-content - :items="recentSearches" /> + :items="recentSearches" + :is-local-storage-available="isLocalStorageAvailable" + /> `, components: { 'recent-searches-dropdown-content': RecentSearchesDropdownContent, diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service.js b/app/assets/javascripts/filtered_search/services/recent_searches_service.js index 3e402d5aed006..a056dea928dcc 100644 --- a/app/assets/javascripts/filtered_search/services/recent_searches_service.js +++ b/app/assets/javascripts/filtered_search/services/recent_searches_service.js @@ -1,9 +1,17 @@ +import RecentSearchesServiceError from './recent_searches_service_error'; +import AccessorUtilities from '../../lib/utils/accessor'; + class RecentSearchesService { constructor(localStorageKey = 'issuable-recent-searches') { this.localStorageKey = localStorageKey; } fetch() { + if (!RecentSearchesService.isAvailable()) { + const error = new RecentSearchesServiceError(); + return Promise.reject(error); + } + const input = window.localStorage.getItem(this.localStorageKey); let searches = []; @@ -19,8 +27,14 @@ class RecentSearchesService { } save(searches = []) { + if (!RecentSearchesService.isAvailable()) return; + window.localStorage.setItem(this.localStorageKey, JSON.stringify(searches)); } + + static isAvailable() { + return AccessorUtilities.isLocalStorageAccessSafe(); + } } export default RecentSearchesService; diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js new file mode 100644 index 0000000000000..5917b223d63fd --- /dev/null +++ b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js @@ -0,0 +1,11 @@ +class RecentSearchesServiceError { + constructor(message) { + this.name = 'RecentSearchesServiceError'; + this.message = message || 'Recent Searches Service is unavailable'; + } +} + +// Can't use `extends` for builtin prototypes and get true inheritance yet +RecentSearchesServiceError.prototype = Error.prototype; + +export default RecentSearchesServiceError; diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 687a462a0d41a..f1b99023c723c 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -101,9 +101,17 @@ window.gl.GfmAutoComplete = { } } }, - setup: function(input) { + setup: function(input, enableMap = { + emojis: true, + members: true, + issues: true, + milestones: true, + mergeRequests: true, + labels: true + }) { // Add GFM auto-completion to all input fields, that accept GFM input. this.input = input || $('.js-gfm-input'); + this.enableMap = enableMap; this.setupLifecycle(); }, setupLifecycle() { @@ -115,7 +123,84 @@ window.gl.GfmAutoComplete = { $input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup')); }); }, + setupAtWho: function($input) { + if (this.enableMap.emojis) this.setupEmoji($input); + if (this.enableMap.members) this.setupMembers($input); + if (this.enableMap.issues) this.setupIssues($input); + if (this.enableMap.milestones) this.setupMilestones($input); + if (this.enableMap.mergeRequests) this.setupMergeRequests($input); + if (this.enableMap.labels) this.setupLabels($input); + + // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms + $input.filter('[data-supports-slash-commands="true"]').atwho({ + at: '/', + alias: 'commands', + searchKey: 'search', + skipSpecialCharacterTest: true, + data: this.defaultLoadingData, + displayTpl: function(value) { + if (this.isLoading(value)) return this.Loading.template; + var tpl = '<li>/${name}'; + if (value.aliases.length > 0) { + tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>'; + } + if (value.params.length > 0) { + tpl += ' <small><%- params.join(" ") %></small>'; + } + if (value.description !== '') { + tpl += '<small class="description"><i><%- description %></i></small>'; + } + tpl += '</li>'; + return _.template(tpl)(value); + }.bind(this), + insertTpl: function(value) { + var tpl = "/${name} "; + var reference_prefix = null; + if (value.params.length > 0) { + reference_prefix = value.params[0][0]; + if (/^[@%~]/.test(reference_prefix)) { + tpl += '<%- reference_prefix %>'; + } + } + return _.template(tpl)({ reference_prefix: reference_prefix }); + }, + suffix: '', + callbacks: { + sorter: this.DefaultOptions.sorter, + filter: this.DefaultOptions.filter, + beforeInsert: this.DefaultOptions.beforeInsert, + beforeSave: function(commands) { + if (gl.GfmAutoComplete.isLoading(commands)) return commands; + return $.map(commands, function(c) { + var search = c.name; + if (c.aliases.length > 0) { + search = search + " " + c.aliases.join(" "); + } + return { + name: c.name, + aliases: c.aliases, + params: c.params, + description: c.description, + search: search + }; + }); + }, + matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) { + var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi; + var match = regexp.exec(subtext); + if (match) { + return match[1]; + } else { + return null; + } + } + } + }); + return; + }, + + setupEmoji($input) { // Emoji $input.atwho({ at: ':', @@ -139,6 +224,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupMembers($input) { // Team Members $input.atwho({ at: '@', @@ -180,6 +268,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupIssues($input) { $input.atwho({ at: '#', alias: 'issues', @@ -208,6 +299,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupMilestones($input) { $input.atwho({ at: '%', alias: 'milestones', @@ -236,6 +330,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupMergeRequests($input) { $input.atwho({ at: '!', alias: 'mergerequests', @@ -264,6 +361,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupLabels($input) { $input.atwho({ at: '~', alias: 'labels', @@ -298,73 +398,8 @@ window.gl.GfmAutoComplete = { } } }); - // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms - $input.filter('[data-supports-slash-commands="true"]').atwho({ - at: '/', - alias: 'commands', - searchKey: 'search', - skipSpecialCharacterTest: true, - data: this.defaultLoadingData, - displayTpl: function(value) { - if (this.isLoading(value)) return this.Loading.template; - var tpl = '<li>/${name}'; - if (value.aliases.length > 0) { - tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>'; - } - if (value.params.length > 0) { - tpl += ' <small><%- params.join(" ") %></small>'; - } - if (value.description !== '') { - tpl += '<small class="description"><i><%- description %></i></small>'; - } - tpl += '</li>'; - return _.template(tpl)(value); - }.bind(this), - insertTpl: function(value) { - var tpl = "/${name} "; - var reference_prefix = null; - if (value.params.length > 0) { - reference_prefix = value.params[0][0]; - if (/^[@%~]/.test(reference_prefix)) { - tpl += '<%- reference_prefix %>'; - } - } - return _.template(tpl)({ reference_prefix: reference_prefix }); - }, - suffix: '', - callbacks: { - sorter: this.DefaultOptions.sorter, - filter: this.DefaultOptions.filter, - beforeInsert: this.DefaultOptions.beforeInsert, - beforeSave: function(commands) { - if (gl.GfmAutoComplete.isLoading(commands)) return commands; - return $.map(commands, function(c) { - var search = c.name; - if (c.aliases.length > 0) { - search = search + " " + c.aliases.join(" "); - } - return { - name: c.name, - aliases: c.aliases, - params: c.params, - description: c.description, - search: search - }; - }); - }, - matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) { - var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi; - var match = regexp.exec(subtext); - if (match) { - return match[1]; - } else { - return null; - } - } - } - }); - return; }, + fetchData: function($input, at) { if (this.isLoadingData[at]) return; this.isLoadingData[at] = true; diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 491c34869c2ff..72f147242b2a4 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -255,7 +255,8 @@ GitLabDropdown = (function() { } }; // Remote data - })(this) + })(this), + instance: this, }); } } @@ -269,6 +270,7 @@ GitLabDropdown = (function() { remote: this.options.filterRemote, query: this.options.data, keys: searchFields, + instance: this, elements: (function(_this) { return function() { selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')'; @@ -343,21 +345,26 @@ GitLabDropdown = (function() { } this.dropdown.on("click", selector, function(e) { var $el, selected, selectedObj, isMarking; - $el = $(this); + $el = $(e.currentTarget); selected = self.rowClicked($el); selectedObj = selected ? selected[0] : null; isMarking = selected ? selected[1] : null; - if (self.options.clicked) { - self.options.clicked(selectedObj, $el, e, isMarking); + if (this.options.clicked) { + this.options.clicked.call(this, { + selectedObj, + $el, + e, + isMarking, + }); } // Update label right after all modifications in dropdown has been done - if (self.options.toggleLabel) { - self.updateLabel(selectedObj, $el, self); + if (this.options.toggleLabel) { + this.updateLabel(selectedObj, $el, this); } $el.trigger('blur'); - }); + }.bind(this)); } } @@ -439,15 +446,34 @@ GitLabDropdown = (function() { } }; + GitLabDropdown.prototype.filteredFullData = function() { + return this.fullData.filter(r => typeof r === 'object' + && !Object.prototype.hasOwnProperty.call(r, 'beforeDivider') + && !Object.prototype.hasOwnProperty.call(r, 'header') + ); + }; + GitLabDropdown.prototype.opened = function(e) { var contentHtml; this.resetRows(); this.addArrowKeyEvent(); + const dropdownToggle = this.dropdown.find('.dropdown-menu-toggle'); + const hasFilterBulkUpdate = dropdownToggle.hasClass('js-filter-bulk-update'); + const hasMultiSelect = dropdownToggle.hasClass('js-multiselect'); + // Makes indeterminate items effective - if (this.fullData && this.dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update')) { + if (this.fullData && hasFilterBulkUpdate) { this.parseData(this.fullData); } + + // Process the data to make sure rendered data + // matches the correct layout + if (this.fullData && hasMultiSelect && this.options.processData) { + const inputValue = this.filterInput.val(); + this.options.processData.call(this.options, inputValue, this.filteredFullData(), this.parseData.bind(this)); + } + contentHtml = $('.dropdown-content', this.dropdown).html(); if (this.remote && contentHtml === "") { this.remote.execute(); @@ -724,6 +750,11 @@ GitLabDropdown = (function() { if (this.options.inputId != null) { $input.attr('id', this.options.inputId); } + + if (this.options.inputMeta) { + $input.attr('data-meta', selectedObject[this.options.inputMeta]); + } + return this.dropdown.before($input); }; @@ -844,7 +875,14 @@ GitLabDropdown = (function() { if (instance == null) { instance = null; } - return $(this.el).find(".dropdown-toggle-text").text(this.options.toggleLabel(selected, el, instance)); + + let toggleText = this.options.toggleLabel(selected, el, instance); + if (this.options.updateLabel) { + // Option to override the dropdown label text + toggleText = this.options.updateLabel; + } + + return $(this.el).find(".dropdown-toggle-text").text(toggleText); }; GitLabDropdown.prototype.clearField = function(field, isInput) { diff --git a/app/assets/javascripts/issuable/auto_width_dropdown_select.js b/app/assets/javascripts/issuable/auto_width_dropdown_select.js new file mode 100644 index 0000000000000..2203a56315e86 --- /dev/null +++ b/app/assets/javascripts/issuable/auto_width_dropdown_select.js @@ -0,0 +1,38 @@ +let instanceCount = 0; + +class AutoWidthDropdownSelect { + constructor(selectElement) { + this.$selectElement = $(selectElement); + this.dropdownClass = `js-auto-width-select-dropdown-${instanceCount}`; + instanceCount += 1; + } + + init() { + const dropdownClass = this.dropdownClass; + this.$selectElement.select2({ + dropdownCssClass: dropdownClass, + dropdownCss() { + let resultantWidth = 'auto'; + const $dropdown = $(`.${dropdownClass}`); + + // We have to look at the parent because + // `offsetParent` on a `display: none;` is `null` + const offsetParentWidth = $(this).parent().offsetParent().width(); + // Reset any width to let it naturally flow + $dropdown.css('width', 'auto'); + if ($dropdown.outerWidth(false) > offsetParentWidth) { + resultantWidth = offsetParentWidth; + } + + return { + width: resultantWidth, + maxWidth: offsetParentWidth, + }; + }, + }); + + return this; + } +} + +export default AutoWidthDropdownSelect; diff --git a/app/assets/javascripts/issuable/issuable_bundle.js b/app/assets/javascripts/issuable/issuable_bundle.js deleted file mode 100644 index e927cc0077c5b..0000000000000 --- a/app/assets/javascripts/issuable/issuable_bundle.js +++ /dev/null @@ -1 +0,0 @@ -require('./time_tracking/time_tracking_bundle'); diff --git a/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js b/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js deleted file mode 100644 index 2fdf7a3cdb2f4..0000000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js +++ /dev/null @@ -1,60 +0,0 @@ -import Vue from 'vue'; -import stopwatchSvg from 'icons/_icon_stopwatch.svg'; - -require('../../../lib/utils/pretty_time'); - -(() => { - Vue.component('time-tracking-collapsed-state', { - name: 'time-tracking-collapsed-state', - props: { - showComparisonState: { - type: Boolean, - required: true, - }, - showSpentOnlyState: { - type: Boolean, - required: true, - }, - showEstimateOnlyState: { - type: Boolean, - required: true, - }, - showNoTimeTrackingState: { - type: Boolean, - required: true, - }, - timeSpentHumanReadable: { - type: String, - required: false, - }, - timeEstimateHumanReadable: { - type: String, - required: false, - }, - }, - methods: { - abbreviateTime(timeStr) { - return gl.utils.prettyTime.abbreviateTime(timeStr); - }, - }, - template: ` - <div class='sidebar-collapsed-icon'> - ${stopwatchSvg} - <div class='time-tracking-collapsed-summary'> - <div class='compare' v-if='showComparisonState'> - <span>{{ abbreviateTime(timeSpentHumanReadable) }} / {{ abbreviateTime(timeEstimateHumanReadable) }}</span> - </div> - <div class='estimate-only' v-if='showEstimateOnlyState'> - <span class='bold'>-- / {{ abbreviateTime(timeEstimateHumanReadable) }}</span> - </div> - <div class='spend-only' v-if='showSpentOnlyState'> - <span class='bold'>{{ abbreviateTime(timeSpentHumanReadable) }} / --</span> - </div> - <div class='no-tracking' v-if='showNoTimeTrackingState'> - <span class='no-value'>None</span> - </div> - </div> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js b/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js deleted file mode 100644 index 21914ad794620..0000000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js +++ /dev/null @@ -1,82 +0,0 @@ -import Vue from 'vue'; - -require('../../../lib/utils/pretty_time'); - -(() => { - const prettyTime = gl.utils.prettyTime; - - Vue.component('time-tracking-comparison-pane', { - name: 'time-tracking-comparison-pane', - props: { - timeSpent: { - type: Number, - required: true, - }, - timeEstimate: { - type: Number, - required: true, - }, - timeSpentHumanReadable: { - type: String, - required: true, - }, - timeEstimateHumanReadable: { - type: String, - required: true, - }, - }, - computed: { - parsedRemaining() { - const diffSeconds = this.timeEstimate - this.timeSpent; - return prettyTime.parseSeconds(diffSeconds); - }, - timeRemainingHumanReadable() { - return prettyTime.stringifyTime(this.parsedRemaining); - }, - timeRemainingTooltip() { - const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:'; - return `${prefix} ${this.timeRemainingHumanReadable}`; - }, - /* Diff values for comparison meter */ - timeRemainingMinutes() { - return this.timeEstimate - this.timeSpent; - }, - timeRemainingPercent() { - return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`; - }, - timeRemainingStatusClass() { - return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate'; - }, - /* Parsed time values */ - parsedEstimate() { - return prettyTime.parseSeconds(this.timeEstimate); - }, - parsedSpent() { - return prettyTime.parseSeconds(this.timeSpent); - }, - }, - template: ` - <div class='time-tracking-comparison-pane'> - <div class='compare-meter' data-toggle='tooltip' data-placement='top' role='timeRemainingDisplay' - :aria-valuenow='timeRemainingTooltip' - :title='timeRemainingTooltip' - :data-original-title='timeRemainingTooltip' - :class='timeRemainingStatusClass'> - <div class='meter-container' role='timeSpentPercent' :aria-valuenow='timeRemainingPercent'> - <div :style='{ width: timeRemainingPercent }' class='meter-fill'></div> - </div> - <div class='compare-display-container'> - <div class='compare-display pull-left'> - <span class='compare-label'>Spent</span> - <span class='compare-value spent'>{{ timeSpentHumanReadable }}</span> - </div> - <div class='compare-display estimated pull-right'> - <span class='compare-label'>Est</span> - <span class='compare-value'>{{ timeEstimateHumanReadable }}</span> - </div> - </div> - </div> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js b/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js deleted file mode 100644 index deb84e5e43a95..0000000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js +++ /dev/null @@ -1,19 +0,0 @@ -import Vue from 'vue'; - -(() => { - Vue.component('time-tracking-estimate-only-pane', { - name: 'time-tracking-estimate-only-pane', - props: { - timeEstimateHumanReadable: { - type: String, - required: true, - }, - }, - template: ` - <div class='time-tracking-estimate-only-pane'> - <span class='bold'>Estimated:</span> - {{ timeEstimateHumanReadable }} - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/help_state.js b/app/assets/javascripts/issuable/time_tracking/components/help_state.js deleted file mode 100644 index 25e11b9d1fce8..0000000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/help_state.js +++ /dev/null @@ -1,30 +0,0 @@ -import Vue from 'vue'; - -(() => { - Vue.component('time-tracking-help-state', { - name: 'time-tracking-help-state', - props: { - docsUrl: { - type: String, - required: true, - }, - }, - template: ` - <div class='time-tracking-help-state'> - <div class='time-tracking-info'> - <h4>Track time with slash commands</h4> - <p>Slash commands can be used in the issues description and comment boxes.</p> - <p> - <code>/estimate</code> - will update the estimated time with the latest command. - </p> - <p> - <code>/spend</code> - will update the sum of the time spent. - </p> - <a class='btn btn-default learn-more-button' :href='docsUrl'>Learn more</a> - </div> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js b/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js deleted file mode 100644 index b081adf5e643b..0000000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js +++ /dev/null @@ -1,12 +0,0 @@ -import Vue from 'vue'; - -(() => { - Vue.component('time-tracking-no-tracking-pane', { - name: 'time-tracking-no-tracking-pane', - template: ` - <div class='time-tracking-no-tracking-pane'> - <span class='no-value'>No estimate or time spent</span> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js b/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js deleted file mode 100644 index 52a9f5dce23a9..0000000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js +++ /dev/null @@ -1,19 +0,0 @@ -import Vue from 'vue'; - -(() => { - Vue.component('time-tracking-spent-only-pane', { - name: 'time-tracking-spent-only-pane', - props: { - timeSpentHumanReadable: { - type: String, - required: true, - }, - }, - template: ` - <div class='time-tracking-spend-only-pane'> - <span class='bold'>Spent:</span> - {{ timeSpentHumanReadable }} - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js b/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js deleted file mode 100644 index 6e4f11c65b77a..0000000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js +++ /dev/null @@ -1,135 +0,0 @@ -import Vue from 'vue'; - -require('./help_state'); -require('./collapsed_state'); -require('./spent_only_pane'); -require('./no_tracking_pane'); -require('./estimate_only_pane'); -require('./comparison_pane'); - -(() => { - Vue.component('issuable-time-tracker', { - name: 'issuable-time-tracker', - props: { - time_estimate: { - type: Number, - required: true, - default: 0, - }, - time_spent: { - type: Number, - required: true, - default: 0, - }, - human_time_estimate: { - type: String, - required: false, - }, - human_time_spent: { - type: String, - required: false, - }, - docsUrl: { - type: String, - required: true, - }, - }, - data() { - return { - showHelp: false, - }; - }, - computed: { - timeSpent() { - return this.time_spent; - }, - timeEstimate() { - return this.time_estimate; - }, - timeEstimateHumanReadable() { - return this.human_time_estimate; - }, - timeSpentHumanReadable() { - return this.human_time_spent; - }, - hasTimeSpent() { - return !!this.timeSpent; - }, - hasTimeEstimate() { - return !!this.timeEstimate; - }, - showComparisonState() { - return this.hasTimeEstimate && this.hasTimeSpent; - }, - showEstimateOnlyState() { - return this.hasTimeEstimate && !this.hasTimeSpent; - }, - showSpentOnlyState() { - return this.hasTimeSpent && !this.hasTimeEstimate; - }, - showNoTimeTrackingState() { - return !this.hasTimeEstimate && !this.hasTimeSpent; - }, - showHelpState() { - return !!this.showHelp; - }, - }, - methods: { - toggleHelpState(show) { - this.showHelp = show; - }, - }, - template: ` - <div class='time_tracker time-tracking-component-wrap' v-cloak> - <time-tracking-collapsed-state - :show-comparison-state='showComparisonState' - :show-no-time-tracking-state='showNoTimeTrackingState' - :show-help-state='showHelpState' - :show-spent-only-state='showSpentOnlyState' - :show-estimate-only-state='showEstimateOnlyState' - :time-spent-human-readable='timeSpentHumanReadable' - :time-estimate-human-readable='timeEstimateHumanReadable'> - </time-tracking-collapsed-state> - <div class='title hide-collapsed'> - Time tracking - <div class='help-button pull-right' - v-if='!showHelpState' - @click='toggleHelpState(true)'> - <i class='fa fa-question-circle' aria-hidden='true'></i> - </div> - <div class='close-help-button pull-right' - v-if='showHelpState' - @click='toggleHelpState(false)'> - <i class='fa fa-close' aria-hidden='true'></i> - </div> - </div> - <div class='time-tracking-content hide-collapsed'> - <time-tracking-estimate-only-pane - v-if='showEstimateOnlyState' - :time-estimate-human-readable='timeEstimateHumanReadable'> - </time-tracking-estimate-only-pane> - <time-tracking-spent-only-pane - v-if='showSpentOnlyState' - :time-spent-human-readable='timeSpentHumanReadable'> - </time-tracking-spent-only-pane> - <time-tracking-no-tracking-pane - v-if='showNoTimeTrackingState'> - </time-tracking-no-tracking-pane> - <time-tracking-comparison-pane - v-if='showComparisonState' - :time-estimate='timeEstimate' - :time-spent='timeSpent' - :time-spent-human-readable='timeSpentHumanReadable' - :time-estimate-human-readable='timeEstimateHumanReadable'> - </time-tracking-comparison-pane> - <transition name='help-state-toggle'> - <time-tracking-help-state - v-if='showHelpState' - :docs-url='docsUrl'> - </time-tracking-help-state> - </transition> - </div> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js b/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js deleted file mode 100644 index 1689a69e1ed94..0000000000000 --- a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js +++ /dev/null @@ -1,66 +0,0 @@ -import Vue from 'vue'; -import VueResource from 'vue-resource'; - -require('./components/time_tracker'); -require('../../smart_interval'); -require('../../subbable_resource'); - -Vue.use(VueResource); - -(() => { - /* This Vue instance represents what will become the parent instance for the - * sidebar. It will be responsible for managing `issuable` state and propagating - * changes to sidebar components. We will want to create a separate service to - * interface with the server at that point. - */ - - class IssuableTimeTracking { - constructor(issuableJSON) { - const parsedIssuable = JSON.parse(issuableJSON); - return this.initComponent(parsedIssuable); - } - - initComponent(parsedIssuable) { - this.parentInstance = new Vue({ - el: '#issuable-time-tracker', - data: { - issuable: parsedIssuable, - }, - methods: { - fetchIssuable() { - return gl.IssuableResource.get.call(gl.IssuableResource, { - type: 'GET', - url: gl.IssuableResource.endpoint, - }); - }, - updateState(data) { - this.issuable = data; - }, - subscribeToUpdates() { - gl.IssuableResource.subscribe(data => this.updateState(data)); - }, - listenForSlashCommands() { - $(document).on('ajax:success', '.gfm-form', (e, data) => { - const subscribedCommands = ['spend_time', 'time_estimate']; - const changedCommands = data.commands_changes - ? Object.keys(data.commands_changes) - : []; - if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) { - this.fetchIssuable(); - } - }); - }, - }, - created() { - this.fetchIssuable(); - }, - mounted() { - this.subscribeToUpdates(); - this.listenForSlashCommands(); - }, - }); - } - } - - gl.IssuableTimeTracking = IssuableTimeTracking; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/issue_status_select.js b/app/assets/javascripts/issue_status_select.js index b2cfd3ef2a3cb..56cb536dcde7d 100644 --- a/app/assets/javascripts/issue_status_select.js +++ b/app/assets/javascripts/issue_status_select.js @@ -19,8 +19,8 @@ return label; }; })(this), - clicked: function(item, $el, e) { - return e.preventDefault(); + clicked: function(options) { + return options.e.preventDefault(); }, id: function(obj, el) { return $(el).data("id"); diff --git a/app/assets/javascripts/issues_bulk_assignment.js b/app/assets/javascripts/issues_bulk_assignment.js index e0ebd36a65ce8..fee3429e2b846 100644 --- a/app/assets/javascripts/issues_bulk_assignment.js +++ b/app/assets/javascripts/issues_bulk_assignment.js @@ -88,7 +88,10 @@ const formData = { update: { state_event: this.form.find('input[name="update[state_event]"]').val(), + // For Merge Requests assignee_id: this.form.find('input[name="update[assignee_id]"]').val(), + // For Issues + assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()], milestone_id: this.form.find('input[name="update[milestone_id]"]').val(), issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(), subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 9a60f5464df95..ac5ce84e31b1f 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -330,7 +330,10 @@ }, multiSelect: $dropdown.hasClass('js-multiselect'), vue: $dropdown.hasClass('js-issue-board-sidebar'), - clicked: function(label, $el, e, isMarking) { + clicked: function(options) { + const { $el, e, isMarking } = options; + const label = options.selectedObj; + var isIssueIndex, isMRIndex, page, boardsModel; var fadeOutLoader = () => { $loading.fadeOut(); @@ -352,7 +355,7 @@ if ($dropdown.hasClass('js-filter-bulk-update')) { _this.enableBulkLabelDropdown(); - _this.setDropdownData($dropdown, isMarking, this.id(label)); + _this.setDropdownData($dropdown, isMarking, label.id); return; } diff --git a/app/assets/javascripts/lib/utils/accessor.js b/app/assets/javascripts/lib/utils/accessor.js new file mode 100644 index 0000000000000..1d18992af6325 --- /dev/null +++ b/app/assets/javascripts/lib/utils/accessor.js @@ -0,0 +1,47 @@ +function isPropertyAccessSafe(base, property) { + let safe; + + try { + safe = !!base[property]; + } catch (error) { + safe = false; + } + + return safe; +} + +function isFunctionCallSafe(base, functionName, ...args) { + let safe = true; + + try { + base[functionName](...args); + } catch (error) { + safe = false; + } + + return safe; +} + +function isLocalStorageAccessSafe() { + let safe; + + const TEST_KEY = 'isLocalStorageAccessSafe'; + const TEST_VALUE = 'true'; + + safe = isPropertyAccessSafe(window, 'localStorage'); + if (!safe) return safe; + + safe = isFunctionCallSafe(window.localStorage, 'setItem', TEST_KEY, TEST_VALUE); + + if (safe) window.localStorage.removeItem(TEST_KEY); + + return safe; +} + +const AccessorUtilities = { + isPropertyAccessSafe, + isFunctionCallSafe, + isLocalStorageAccessSafe, +}; + +export default AccessorUtilities; diff --git a/app/assets/javascripts/lib/utils/ajax_cache.js b/app/assets/javascripts/lib/utils/ajax_cache.js new file mode 100644 index 0000000000000..d99eefb5089b2 --- /dev/null +++ b/app/assets/javascripts/lib/utils/ajax_cache.js @@ -0,0 +1,32 @@ +const AjaxCache = { + internalStorage: { }, + get(endpoint) { + return this.internalStorage[endpoint]; + }, + hasData(endpoint) { + return Object.prototype.hasOwnProperty.call(this.internalStorage, endpoint); + }, + purge(endpoint) { + delete this.internalStorage[endpoint]; + }, + retrieve(endpoint) { + if (AjaxCache.hasData(endpoint)) { + return Promise.resolve(AjaxCache.get(endpoint)); + } + + return new Promise((resolve, reject) => { + $.ajax(endpoint) // eslint-disable-line promise/catch-or-return + .then(data => resolve(data), + (jqXHR, textStatus, errorThrown) => { + const error = new Error(`${endpoint}: ${errorThrown}`); + error.textStatus = textStatus; + reject(error); + }, + ); + }) + .then((data) => { this.internalStorage[endpoint] = data; }) + .then(() => AjaxCache.get(endpoint)); + }, +}; + +export default AjaxCache; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 8058672eaa90e..2f682fbd2fbf8 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -35,6 +35,14 @@ }); }; + w.gl.utils.ajaxPost = function(url, data) { + return $.ajax({ + type: 'POST', + url: url, + data: data, + }); + }; + w.gl.utils.extractLast = function(term) { return this.split(term).pop(); }; diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js new file mode 100644 index 0000000000000..e96090da80e41 --- /dev/null +++ b/app/assets/javascripts/locale/de/app.js @@ -0,0 +1 @@ +var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Deployed Jobs":[""],"Related Issues":[""],"Related Jobs":[""],"Related Merge Requests":[""],"Related Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/en/app.js b/app/assets/javascripts/locale/en/app.js new file mode 100644 index 0000000000000..ade9b667b3c0f --- /dev/null +++ b/app/assets/javascripts/locale/en/app.js @@ -0,0 +1 @@ +var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Deployed Jobs":[""],"Related Issues":[""],"Related Jobs":[""],"Related Merge Requests":[""],"Related Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/es/app.js b/app/assets/javascripts/locale/es/app.js new file mode 100644 index 0000000000000..3dafa21f2359a --- /dev/null +++ b/app/assets/javascripts/locale/es/app.js @@ -0,0 +1 @@ +var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-04 19:24-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":["por"],"Commit":["Cambio","Cambios"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"CycleAnalyticsStage|Code":["Código"],"CycleAnalyticsStage|Issue":["Incidencia"],"CycleAnalyticsStage|Plan":["Planificación"],"CycleAnalyticsStage|Production":["Producción"],"CycleAnalyticsStage|Review":["Revisión"],"CycleAnalyticsStage|Staging":["Puesta en escena"],"CycleAnalyticsStage|Test":["Pruebas"],"Deploy":["Despliegue","Despliegues"],"FirstPushedBy|First":["Primer"],"FirstPushedBy|pushed by":["enviado por"],"From issue creation until deploy to production":["Desde la creación de la incidencia hasta el despliegue a producción"],"From merge request merge until deploy to production":["Desde la integración de la solicitud de fusión hasta el despliegue a producción"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last %d day":["Último %d dÃa","Últimos %d dÃas"],"Limited to showing %d event at most":["Limitado a mostrar máximo %d evento","Limitado a mostrar máximo %d eventos"],"Median":["Mediana"],"New Issue":["Nueva incidencia","Nuevas incidencias"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"OpenedNDaysAgo|Opened":["Abierto"],"Pipeline Health":["Estado del Pipeline"],"ProjectLifecycle|Stage":["Etapa"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Deployed Jobs":["Trabajos Desplegados Relacionados"],"Related Issues":["Incidencias Relacionadas"],"Related Jobs":["Trabajos Relacionados"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Related Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":["La colección de eventos agregados a los datos recopilados para esa etapa."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":["La etapa del ciclo de vida de desarrollo."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe el primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["El tiempo utilizado por cada entrada de datos obtenido por esa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."],"Time before an issue gets scheduled":["Tiempo antes de que una incidencia sea programada"],"Time before an issue starts implementation":["Tiempo antes de que empieze la implementación de una incidencia"],"Time between merge request creation and merge/close":["Tiempo entre la creación de la solicitud de fusión y la integración o cierre de ésta"],"Time until first merge request":["Tiempo hasta la primera solicitud de fusión"],"Time|hr":["hr","hrs"],"Time|min":["min","mins"],"Time|s":["s"],"Total Time":["Tiempo Total"],"Total test time for all commits/merges":["Tiempo total de pruebas para todos los cambios o integraciones"],"Want to see the data? Please ask an administrator for access.":["¿Quieres ver los datos? Por favor pide acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"You need permission.":["Necesitas permisos."],"day":["dÃa","dÃas"]}}}; \ No newline at end of file diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js new file mode 100644 index 0000000000000..7ba676d6d20aa --- /dev/null +++ b/app/assets/javascripts/locale/index.js @@ -0,0 +1,70 @@ +import Jed from 'jed'; + +/** + This is required to require all the translation folders in the current directory + this saves us having to do this manually & keep up to date with new languages +**/ +function requireAll(requireContext) { return requireContext.keys().map(requireContext); } + +const allLocales = requireAll(require.context('./', true, /^(?!.*(?:index.js$)).*\.js$/)); +const locales = allLocales.reduce((d, obj) => { + const data = d; + const localeKey = Object.keys(obj)[0]; + + data[localeKey] = obj[localeKey]; + + return data; +}, {}); + +let lang = document.querySelector('html').getAttribute('lang') || 'en'; +lang = lang.replace(/-/g, '_'); + +const locale = new Jed(locales[lang]); + +/** + Translates `text` + + @param text The text to be translated + @returns {String} The translated text +**/ +const gettext = locale.gettext.bind(locale); + +/** + Translate the text with a number + if the number is more than 1 it will use the `pluralText` translation. + This method allows for contexts, see below re. contexts + + @param text Singular text to translate (eg. '%d day') + @param pluralText Plural text to translate (eg. '%d days') + @param count Number to decide which translation to use (eg. 2) + @returns {String} Translated text with the number replaced (eg. '2 days') +**/ +const ngettext = (text, pluralText, count) => { + const translated = locale.ngettext(text, pluralText, count).replace(/%d/g, count).split('|'); + + return translated[translated.length - 1]; +}; + +/** + Translate context based text + Either pass in the context translation like `Context|Text to translate` + or allow for dynamic text by doing passing in the context first & then the text to translate + + @param keyOrContext Can be either the key to translate including the context + (eg. 'Context|Text') or just the context for the translation + (eg. 'Context') + @param key Is the dynamic variable you want to be translated + @returns {String} Translated context based text +**/ +const pgettext = (keyOrContext, key) => { + const normalizedKey = key ? `${keyOrContext}|${key}` : keyOrContext; + const translated = gettext(normalizedKey).split('|'); + + return translated[translated.length - 1]; +}; + +export { lang }; +export { gettext as __ }; +export { ngettext as n__ }; +export { pgettext as s__ }; +export default locale; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 5f96d63802399..8e8123f72a932 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -158,7 +158,6 @@ import './single_file_diff'; import './smart_interval'; import './snippets_list'; import './star'; -import './subbable_resource'; import './subscription'; import './subscription_select'; import './syntax_highlight'; diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js index f7d809dd5f4f8..7dec27b2c6cfe 100644 --- a/app/assets/javascripts/members.js +++ b/app/assets/javascripts/members.js @@ -43,7 +43,9 @@ return $el.text(); }, - clicked: (selected, $link) => { + clicked: (options) => { + const $link = options.$el; + if (!$link.data('revert')) { this.formSubmit(null, $link); } else { diff --git a/app/assets/javascripts/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js index 0742ca79fd50b..df80c1458c44c 100644 --- a/app/assets/javascripts/merge_request_widget.js +++ b/app/assets/javascripts/merge_request_widget.js @@ -313,7 +313,7 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; MergeRequestWidget.prototype.updateCommitUrls = function(id) { const commitsUrl = this.opts.commits_path; - $('.js-commit-link').text(`#${id}`).attr('href', [commitsUrl, id].join('/')); + $('.js-commit-link').text(id).attr('href', [commitsUrl, id].join('/')); }; MergeRequestWidget.prototype.initMiniPipelineGraph = function() { diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index 29ce1df2dee61..d43cd84bbd682 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -142,7 +142,10 @@ return true; }, - clicked: function(selected, $el, e) { + clicked: function(options) { + const { $el, e } = options; + let selected = options.selectedObj; + var data, isIssueIndex, isMRIndex, page, boardsStore; if (!selected) return; page = $('body').data('page'); diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js index b98e6121967a7..36bc1257cefd2 100644 --- a/app/assets/javascripts/namespace_select.js +++ b/app/assets/javascripts/namespace_select.js @@ -58,7 +58,8 @@ }); } - NamespaceSelect.prototype.onSelectItem = function(item, el, e) { + NamespaceSelect.prototype.onSelectItem = function(options) { + const { e } = options; return e.preventDefault(); }; diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js index 5828f460a235a..67046d52a653c 100644 --- a/app/assets/javascripts/new_branch_form.js +++ b/app/assets/javascripts/new_branch_form.js @@ -34,6 +34,7 @@ filterByText: true, remote: false, fieldName: $branchSelect.data('field-name'), + filterInput: 'input[type="search"]', selectable: true, isSelectable: function(branch, $el) { return !$el.hasClass('is-active'); @@ -50,6 +51,21 @@ } } }); + + const $dropdownContainer = $branchSelect.closest('.dropdown'); + const $fieldInput = $(`input[name="${$branchSelect.data('field-name')}"]`, $dropdownContainer); + const $filterInput = $('input[type="search"]', $dropdownContainer); + + $filterInput.on('keyup', (e) => { + const keyCode = e.keyCode || e.which; + if (keyCode !== 13) return; + + const text = $filterInput.val(); + $fieldInput.val(text); + $('.dropdown-toggle-text', $branchSelect).text(text); + + $dropdownContainer.removeClass('open'); + }); }; NewBranchForm.prototype.setupRestrictions = function() { diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 87f03a40eba3f..55391ebc089a2 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -26,12 +26,13 @@ const normalizeNewlines = function(str) { this.Notes = (function() { const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; + const REGEX_SLASH_COMMANDS = /\/\w+/g; Notes.interval = null; function Notes(notes_url, note_ids, last_fetched_at, view) { this.updateTargetButtons = bind(this.updateTargetButtons, this); - this.updateCloseButton = bind(this.updateCloseButton, this); + this.updateComment = bind(this.updateComment, this); this.visibilityChange = bind(this.visibilityChange, this); this.cancelDiscussionForm = bind(this.cancelDiscussionForm, this); this.addDiffNote = bind(this.addDiffNote, this); @@ -47,6 +48,7 @@ const normalizeNewlines = function(str) { this.refresh = bind(this.refresh, this); this.keydownNoteText = bind(this.keydownNoteText, this); this.toggleCommitList = bind(this.toggleCommitList, this); + this.postComment = bind(this.postComment, this); this.notes_url = notes_url; this.note_ids = note_ids; @@ -82,28 +84,19 @@ const normalizeNewlines = function(str) { }; Notes.prototype.addBinding = function() { - // add note to UI after creation - $(document).on("ajax:success", ".js-main-target-form", this.addNote); - $(document).on("ajax:success", ".js-discussion-note-form", this.addDiscussionNote); - // catch note ajax errors - $(document).on("ajax:error", ".js-main-target-form", this.addNoteError); - // change note in UI after update - $(document).on("ajax:success", "form.edit-note", this.updateNote); // Edit note link $(document).on("click", ".js-note-edit", this.showEditForm.bind(this)); $(document).on("click", ".note-edit-cancel", this.cancelEdit); // Reopen and close actions for Issue/MR combined with note form submit - $(document).on("click", ".js-comment-button", this.updateCloseButton); + $(document).on("click", ".js-comment-submit-button", this.postComment); + $(document).on("click", ".js-comment-save-button", this.updateComment); $(document).on("keyup input", ".js-note-text", this.updateTargetButtons); // resolve a discussion - $(document).on('click', '.js-comment-resolve-button', this.resolveDiscussion); + $(document).on('click', '.js-comment-resolve-button', this.postComment); // remove a note (in general) $(document).on("click", ".js-note-delete", this.removeNote); // delete note attachment $(document).on("click", ".js-note-attachment-delete", this.removeAttachment); - // reset main target form after submit - $(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton); - $(document).on("ajax:success", ".js-main-target-form", this.resetMainTargetForm); // reset main target form when clicking discard $(document).on("click", ".js-note-discard", this.resetMainTargetForm); // update the file name when an attachment is selected @@ -120,20 +113,20 @@ const normalizeNewlines = function(str) { $(document).on("visibilitychange", this.visibilityChange); // when issue status changes, we need to refresh data $(document).on("issuable:change", this.refresh); + // ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs. + $(document).on("ajax:success", ".js-main-target-form", this.addNote); + $(document).on("ajax:success", ".js-discussion-note-form", this.addDiscussionNote); + $(document).on("ajax:success", ".js-main-target-form", this.resetMainTargetForm); + $(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton); // when a key is clicked on the notes return $(document).on("keydown", ".js-note-text", this.keydownNoteText); }; Notes.prototype.cleanBinding = function() { - $(document).off("ajax:success", ".js-main-target-form"); - $(document).off("ajax:success", ".js-discussion-note-form"); - $(document).off("ajax:success", "form.edit-note"); $(document).off("click", ".js-note-edit"); $(document).off("click", ".note-edit-cancel"); $(document).off("click", ".js-note-delete"); $(document).off("click", ".js-note-attachment-delete"); - $(document).off("ajax:complete", ".js-main-target-form"); - $(document).off("ajax:success", ".js-main-target-form"); $(document).off("click", ".js-discussion-reply-button"); $(document).off("click", ".js-add-diff-note-button"); $(document).off("visibilitychange"); @@ -144,6 +137,9 @@ const normalizeNewlines = function(str) { $(document).off("keydown", ".js-note-text"); $(document).off('click', '.js-comment-resolve-button'); $(document).off("click", '.system-note-commit-list-toggler'); + $(document).off("ajax:success", ".js-main-target-form"); + $(document).off("ajax:success", ".js-discussion-note-form"); + $(document).off("ajax:complete", ".js-main-target-form"); }; Notes.initCommentTypeToggle = function (form) { @@ -276,12 +272,8 @@ const normalizeNewlines = function(str) { return this.initRefresh(); }; - Notes.prototype.handleCreateChanges = function(noteEntity) { + Notes.prototype.handleSlashCommands = function(noteEntity) { var votesBlock; - if (typeof noteEntity === 'undefined') { - return; - } - if (noteEntity.commands_changes) { if ('merge' in noteEntity.commands_changes) { $.get(mrRefreshWidgetUrl); @@ -556,24 +548,29 @@ const normalizeNewlines = function(str) { Adds new note to list. */ - Notes.prototype.addNote = function(xhr, note, status) { - this.handleCreateChanges(note); + Notes.prototype.addNote = function($form, note) { return this.renderNote(note); }; - Notes.prototype.addNoteError = function(xhr, note, status) { - return new Flash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', this.parentTimeline); + Notes.prototype.addNoteError = ($form) => { + let formParentTimeline; + if ($form.hasClass('js-main-target-form')) { + formParentTimeline = $form.parents('.timeline'); + } else if ($form.hasClass('js-discussion-note-form')) { + formParentTimeline = $form.closest('.discussion-notes').find('.notes'); + } + return new Flash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', formParentTimeline); }; + Notes.prototype.updateNoteError = $parentTimeline => new Flash('Your comment could not be updated! Please check your network connection and try again.'); + /* Called in response to the new note form being submitted Adds new note to list. */ - Notes.prototype.addDiscussionNote = function(xhr, note, status) { - var $form = $(xhr.target); - + Notes.prototype.addDiscussionNote = function($form, note, isNewDiffComment) { if ($form.attr('data-resolve-all') != null) { var projectPath = $form.data('project-path'); var discussionId = $form.data('discussion-id'); @@ -586,7 +583,9 @@ const normalizeNewlines = function(str) { this.renderNote(note, $form); // cleanup after successfully creating a diff/discussion note - this.removeDiscussionNoteForm($form); + if (isNewDiffComment) { + this.removeDiscussionNoteForm($form); + } }; /* @@ -596,17 +595,18 @@ const normalizeNewlines = function(str) { */ Notes.prototype.updateNote = function(_xhr, noteEntity, _status) { - var $html, $note_li; + var $noteEntityEl, $note_li; // Convert returned HTML to a jQuery object so we can modify it further - $html = $(noteEntity.html); + $noteEntityEl = $(noteEntity.html); + $noteEntityEl.addClass('fade-in-full'); this.revertNoteEditForm(); - gl.utils.localTimeAgo($('.js-timeago', $html)); - $html.renderGFM(); - $html.find('.js-task-list-container').taskList('enable'); + gl.utils.localTimeAgo($('.js-timeago', $noteEntityEl)); + $noteEntityEl.renderGFM(); + $noteEntityEl.find('.js-task-list-container').taskList('enable'); // Find the note's `li` element by ID and replace it with the updated HTML $note_li = $('.note-row-' + noteEntity.id); - $note_li.replaceWith($html); + $note_li.replaceWith($noteEntityEl); if (typeof gl.diffNotesCompileComponents !== 'undefined') { gl.diffNotesCompileComponents(); @@ -698,7 +698,7 @@ const normalizeNewlines = function(str) { var $editForm = $(selector); $editForm.insertBefore('.notes-form'); - $editForm.find('.js-comment-button').enable(); + $editForm.find('.js-comment-save-button').enable(); $editForm.find('.js-finish-edit-warning').hide(); }; @@ -982,14 +982,6 @@ const normalizeNewlines = function(str) { return this.refresh(); }; - Notes.prototype.updateCloseButton = function(e) { - var closebtn, form, textarea; - textarea = $(e.target); - form = textarea.parents('form'); - closebtn = form.find('.js-note-target-close'); - return closebtn.text(closebtn.data('original-text')); - }; - Notes.prototype.updateTargetButtons = function(e) { var closebtn, closetext, discardbtn, form, reopenbtn, reopentext, textarea; textarea = $(e.target); @@ -1078,17 +1070,6 @@ const normalizeNewlines = function(str) { return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount); }; - Notes.prototype.resolveDiscussion = function() { - var $this = $(this); - var discussionId = $this.attr('data-discussion-id'); - - $this - .closest('form') - .attr('data-discussion-id', discussionId) - .attr('data-resolve-all', 'true') - .attr('data-project-path', $this.attr('data-project-path')); - }; - Notes.prototype.toggleCommitList = function(e) { const $element = $(e.currentTarget); const $closestSystemCommitList = $element.siblings('.system-note-commit-list'); @@ -1137,7 +1118,7 @@ const normalizeNewlines = function(str) { Notes.animateAppendNote = function(noteHtml, $notesList) { const $note = $(noteHtml); - $note.addClass('fade-in').renderGFM(); + $note.addClass('fade-in-full').renderGFM(); $notesList.append($note); return $note; }; @@ -1150,6 +1131,254 @@ const normalizeNewlines = function(str) { return $updatedNote; }; + /** + * Get data from Form attributes to use for saving/submitting comment. + */ + Notes.prototype.getFormData = function($form) { + return { + formData: $form.serialize(), + formContent: $form.find('.js-note-text').val(), + formAction: $form.attr('action'), + }; + }; + + /** + * Identify if comment has any slash commands + */ + Notes.prototype.hasSlashCommands = function(formContent) { + return REGEX_SLASH_COMMANDS.test(formContent); + }; + + /** + * Remove slash commands and leave comment with pure message + */ + Notes.prototype.stripSlashCommands = function(formContent) { + return formContent.replace(REGEX_SLASH_COMMANDS, '').trim(); + }; + + /** + * Create placeholder note DOM element populated with comment body + * that we will show while comment is being posted. + * Once comment is _actually_ posted on server, we will have final element + * in response that we will show in place of this temporary element. + */ + Notes.prototype.createPlaceholderNote = function({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname }) { + const discussionClass = isDiscussionNote ? 'discussion' : ''; + const $tempNote = $( + `<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry"> + <div class="timeline-entry-inner"> + <div class="timeline-icon"> + <a href="/${currentUsername}"><span class="dummy-avatar"></span></a> + </div> + <div class="timeline-content ${discussionClass}"> + <div class="note-header"> + <div class="note-header-info"> + <a href="/${currentUsername}"> + <span class="hidden-xs">${currentUserFullname}</span> + <span class="note-headline-light">@${currentUsername}</span> + </a> + <span class="note-headline-light"> + <i class="fa fa-spinner fa-spin" aria-label="Comment is being posted" aria-hidden="true"></i> + </span> + </div> + </div> + <div class="note-body"> + <div class="note-text"> + <p>${formContent}</p> + </div> + </div> + </div> + </div> + </li>` + ); + + return $tempNote; + }; + + /** + * This method does following tasks step-by-step whenever a new comment + * is submitted by user (both main thread comments as well as discussion comments). + * + * 1) Get Form metadata + * 2) Identify comment type; a) Main thread b) Discussion thread c) Discussion resolve + * 3) Build temporary placeholder element (using `createPlaceholderNote`) + * 4) Show placeholder note on UI + * 5) Perform network request to submit the note using `gl.utils.ajaxPost` + * a) If request is successfully completed + * 1. Remove placeholder element + * 2. Show submitted Note element + * 3. Perform post-submit errands + * a. Mark discussion as resolved if comment submission was for resolve. + * b. Reset comment form to original state. + * b) If request failed + * 1. Remove placeholder element + * 2. Show error Flash message about failure + */ + Notes.prototype.postComment = function(e) { + e.preventDefault(); + + // Get Form metadata + const $submitBtn = $(e.target); + let $form = $submitBtn.parents('form'); + const $closeBtn = $form.find('.js-note-target-close'); + const isDiscussionNote = $submitBtn.parent().find('li.droplab-item-selected').attr('id') === 'discussion'; + const isMainForm = $form.hasClass('js-main-target-form'); + const isDiscussionForm = $form.hasClass('js-discussion-note-form'); + const isDiscussionResolve = $submitBtn.hasClass('js-comment-resolve-button'); + const { formData, formContent, formAction } = this.getFormData($form); + const uniqueId = _.uniqueId('tempNote_'); + let $notesContainer; + let tempFormContent; + + // Get reference to notes container based on type of comment + if (isDiscussionForm) { + $notesContainer = $form.parent('.discussion-notes').find('.notes'); + } else if (isMainForm) { + $notesContainer = $('ul.main-notes-list'); + } + + // If comment is to resolve discussion, disable submit buttons while + // comment posting is finished. + if (isDiscussionResolve) { + $submitBtn.disable(); + $form.find('.js-comment-submit-button').disable(); + } + + tempFormContent = formContent; + if (this.hasSlashCommands(formContent)) { + tempFormContent = this.stripSlashCommands(formContent); + } + + if (tempFormContent) { + // Show placeholder note + $notesContainer.append(this.createPlaceholderNote({ + formContent: tempFormContent, + uniqueId, + isDiscussionNote, + currentUsername: gon.current_username, + currentUserFullname: gon.current_user_fullname, + })); + } + + // Clear the form textarea + if ($notesContainer.length) { + if (isMainForm) { + this.resetMainTargetForm(e); + } else if (isDiscussionForm) { + this.removeDiscussionNoteForm($form); + } + } + + /* eslint-disable promise/catch-or-return */ + // Make request to submit comment on server + gl.utils.ajaxPost(formAction, formData) + .then((note) => { + // Submission successful! remove placeholder + $notesContainer.find(`#${uniqueId}`).remove(); + + // Check if this was discussion comment + if (isDiscussionForm) { + // Remove flash-container + $notesContainer.find('.flash-container').remove(); + + // If comment intends to resolve discussion, do the same. + if (isDiscussionResolve) { + $form + .attr('data-discussion-id', $submitBtn.data('discussion-id')) + .attr('data-resolve-all', 'true') + .attr('data-project-path', $submitBtn.data('project-path')); + } + + // Show final note element on UI + this.addDiscussionNote($form, note, $notesContainer.length === 0); + + // append flash-container to the Notes list + if ($notesContainer.length) { + $notesContainer.append('<div class="flash-container" style="display: none;"></div>'); + } + } else if (isMainForm) { // Check if this was main thread comment + // Show final note element on UI and perform form and action buttons cleanup + this.addNote($form, note); + this.reenableTargetFormSubmitButton(e); + } + + if (note.commands_changes) { + this.handleSlashCommands(note); + } + + $form.trigger('ajax:success', [note]); + }).fail(() => { + // Submission failed, remove placeholder note and show Flash error message + $notesContainer.find(`#${uniqueId}`).remove(); + + // Show form again on UI on failure + if (isDiscussionForm && $notesContainer.length) { + const replyButton = $notesContainer.parent().find('.js-discussion-reply-button'); + $.proxy(this.replyToDiscussionNote, replyButton[0], { target: replyButton[0] }).call(); + $form = $notesContainer.parent().find('form'); + } + + $form.find('.js-note-text').val(formContent); + this.reenableTargetFormSubmitButton(e); + this.addNoteError($form); + }); + + return $closeBtn.text($closeBtn.data('original-text')); + }; + + /** + * This method does following tasks step-by-step whenever an existing comment + * is updated by user (both main thread comments as well as discussion comments). + * + * 1) Get Form metadata + * 2) Update note element with new content + * 3) Perform network request to submit the updated note using `gl.utils.ajaxPost` + * a) If request is successfully completed + * 1. Show submitted Note element + * b) If request failed + * 1. Revert Note element to original content + * 2. Show error Flash message about failure + */ + Notes.prototype.updateComment = function(e) { + e.preventDefault(); + + // Get Form metadata + const $submitBtn = $(e.target); + const $form = $submitBtn.parents('form'); + const $closeBtn = $form.find('.js-note-target-close'); + const $editingNote = $form.parents('.note.is-editing'); + const $noteBody = $editingNote.find('.js-task-list-container'); + const $noteBodyText = $noteBody.find('.note-text'); + const { formData, formContent, formAction } = this.getFormData($form); + + // Cache original comment content + const cachedNoteBodyText = $noteBodyText.html(); + + // Show updated comment content temporarily + $noteBodyText.html(formContent); + $editingNote.removeClass('is-editing fade-in-full').addClass('being-posted fade-in-half'); + $editingNote.find('.note-headline-meta a').html('<i class="fa fa-spinner fa-spin" aria-label="Comment is being updated" aria-hidden="true"></i>'); + + /* eslint-disable promise/catch-or-return */ + // Make request to update comment on server + gl.utils.ajaxPost(formAction, formData) + .then((note) => { + // Submission successful! render final note element + this.updateNote(null, note, null); + }) + .fail(() => { + // Submission failed, revert back to original note + $noteBodyText.html(cachedNoteBodyText); + $editingNote.removeClass('being-posted fade-in'); + $editingNote.find('.fa.fa-spinner').remove(); + + // Show Flash message about failure + this.updateNoteError(); + }); + + return $closeBtn.text($closeBtn.data('original-text')); + }; + return Notes; })(); }).call(window); diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js index 255cd513490e4..b21f84b454529 100644 --- a/app/assets/javascripts/pipelines/services/pipelines_service.js +++ b/app/assets/javascripts/pipelines/services/pipelines_service.js @@ -40,6 +40,6 @@ export default class PipelinesService { * @return {Promise} */ postAction(endpoint) { - return Vue.http.post(endpoint, {}, { emulateJSON: true }); + return Vue.http.post(`${endpoint}.json`); } } diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js index 2a5934c36213a..23e807a28bbf6 100644 --- a/app/assets/javascripts/project.js +++ b/app/assets/javascripts/project.js @@ -119,7 +119,8 @@ import Cookies from 'js-cookie'; toggleLabel: function(obj, $el) { return $el.text().trim(); }, - clicked: function(selected, $el, e) { + clicked: function(options) { + const { e } = options; e.preventDefault(); if ($('input[name="ref"]').length) { var $form = $dropdown.closest('form'); diff --git a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js index 59d5f00f05093..5d54c220aac43 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js +++ b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js @@ -53,7 +53,10 @@ onHide(); } }, - clicked(item, $el, e) { + clicked(opts) { + const { $el, e } = opts; + const item = opts.selectedObj; + e.preventDefault(); if ($el.is('.is-active')) { diff --git a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_dropdown.js index 1d4bb8a13d677..bc6110fcd4e4f 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js +++ b/app/assets/javascripts/protected_branches/protected_branch_dropdown.js @@ -35,7 +35,8 @@ class ProtectedBranchDropdown { return _.escape(protectedBranch.id); }, onFilter: this.toggleCreateNewButton.bind(this), - clicked: (item, $el, e) => { + clicked: (options) => { + const { $el, e } = options; e.preventDefault(); this.onSelect(); } diff --git a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js index fff83f3af3bb3..d4c9a91a74a42 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js +++ b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js @@ -17,8 +17,8 @@ export default class ProtectedTagAccessDropdown { } return 'Select'; }, - clicked(item, $el, e) { - e.preventDefault(); + clicked(options) { + options.e.preventDefault(); onSelect(); }, }); diff --git a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js index 5ff4e4432622b..068e9698e1d02 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js +++ b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js @@ -39,8 +39,8 @@ export default class ProtectedTagDropdown { return _.escape(protectedTag.id); }, onFilter: this.toggleCreateNewButton.bind(this), - clicked: (item, $el, e) => { - e.preventDefault(); + clicked: (options) => { + options.e.preventDefault(); this.onSelect(); }, }); diff --git a/app/assets/javascripts/raven/index.js b/app/assets/javascripts/raven/index.js new file mode 100644 index 0000000000000..5325e495815f7 --- /dev/null +++ b/app/assets/javascripts/raven/index.js @@ -0,0 +1,16 @@ +import RavenConfig from './raven_config'; + +const index = function index() { + RavenConfig.init({ + sentryDsn: gon.sentry_dsn, + currentUserId: gon.current_user_id, + whitelistUrls: [gon.gitlab_url], + isProduction: process.env.NODE_ENV, + }); + + return RavenConfig; +}; + +index(); + +export default index; diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/raven/raven_config.js new file mode 100644 index 0000000000000..c7fe1cacf49ea --- /dev/null +++ b/app/assets/javascripts/raven/raven_config.js @@ -0,0 +1,100 @@ +import Raven from 'raven-js'; + +const IGNORE_ERRORS = [ + // Random plugins/extensions + 'top.GLOBALS', + // See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error. html + 'originalCreateNotification', + 'canvas.contentDocument', + 'MyApp_RemoveAllHighlights', + 'http://tt.epicplay.com', + 'Can\'t find variable: ZiteReader', + 'jigsaw is not defined', + 'ComboSearch is not defined', + 'http://loading.retry.widdit.com/', + 'atomicFindClose', + // Facebook borked + 'fb_xd_fragment', + // ISP "optimizing" proxy - `Cache-Control: no-transform` seems to + // reduce this. (thanks @acdha) + // See http://stackoverflow.com/questions/4113268 + 'bmi_SafeAddOnload', + 'EBCallBackMessageReceived', + // See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx + 'conduitPage', +]; + +const IGNORE_URLS = [ + // Facebook flakiness + /graph\.facebook\.com/i, + // Facebook blocked + /connect\.facebook\.net\/en_US\/all\.js/i, + // Woopra flakiness + /eatdifferent\.com\.woopra-ns\.com/i, + /static\.woopra\.com\/js\/woopra\.js/i, + // Chrome extensions + /extensions\//i, + /^chrome:\/\//i, + // Other plugins + /127\.0\.0\.1:4001\/isrunning/i, // Cacaoweb + /webappstoolbarba\.texthelp\.com\//i, + /metrics\.itunes\.apple\.com\.edgesuite\.net\//i, +]; + +const SAMPLE_RATE = 95; + +const RavenConfig = { + IGNORE_ERRORS, + IGNORE_URLS, + SAMPLE_RATE, + init(options = {}) { + this.options = options; + + this.configure(); + this.bindRavenErrors(); + if (this.options.currentUserId) this.setUser(); + }, + + configure() { + Raven.config(this.options.sentryDsn, { + whitelistUrls: this.options.whitelistUrls, + environment: this.options.isProduction ? 'production' : 'development', + ignoreErrors: this.IGNORE_ERRORS, + ignoreUrls: this.IGNORE_URLS, + shouldSendCallback: this.shouldSendSample.bind(this), + }).install(); + }, + + setUser() { + Raven.setUserContext({ + id: this.options.currentUserId, + }); + }, + + bindRavenErrors() { + window.$(document).on('ajaxError.raven', this.handleRavenErrors); + }, + + handleRavenErrors(event, req, config, err) { + const error = err || req.statusText; + const responseText = req.responseText || 'Unknown response text'; + + Raven.captureMessage(error, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: responseText, + error, + event, + }, + }); + }, + + shouldSendSample() { + return Math.random() * 100 <= this.SAMPLE_RATE; + }, +}; + +export default RavenConfig; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.js b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js new file mode 100644 index 0000000000000..a9ad3708514b7 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js @@ -0,0 +1,41 @@ +export default { + name: 'AssigneeTitle', + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + numberOfAssignees: { + type: Number, + required: true, + }, + editable: { + type: Boolean, + required: true, + }, + }, + computed: { + assigneeTitle() { + const assignees = this.numberOfAssignees; + return assignees > 1 ? `${assignees} Assignees` : 'Assignee'; + }, + }, + template: ` + <div class="title hide-collapsed"> + {{assigneeTitle}} + <i + v-if="loading" + aria-hidden="true" + class="fa fa-spinner fa-spin block-loading" + /> + <a + v-if="editable" + class="edit-link pull-right" + href="#" + > + Edit + </a> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.js b/app/assets/javascripts/sidebar/components/assignees/assignees.js new file mode 100644 index 0000000000000..7e5feac622c7a --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.js @@ -0,0 +1,224 @@ +export default { + name: 'Assignees', + data() { + return { + defaultRenderCount: 5, + defaultMaxCounter: 99, + showLess: true, + }; + }, + props: { + rootPath: { + type: String, + required: true, + }, + users: { + type: Array, + required: true, + }, + editable: { + type: Boolean, + required: true, + }, + }, + computed: { + firstUser() { + return this.users[0]; + }, + hasMoreThanTwoAssignees() { + return this.users.length > 2; + }, + hasMoreThanOneAssignee() { + return this.users.length > 1; + }, + hasAssignees() { + return this.users.length > 0; + }, + hasNoUsers() { + return !this.users.length; + }, + hasOneUser() { + return this.users.length === 1; + }, + renderShowMoreSection() { + return this.users.length > this.defaultRenderCount; + }, + numberOfHiddenAssignees() { + return this.users.length - this.defaultRenderCount; + }, + isHiddenAssignees() { + return this.numberOfHiddenAssignees > 0; + }, + hiddenAssigneesLabel() { + return `+ ${this.numberOfHiddenAssignees} more`; + }, + collapsedTooltipTitle() { + const maxRender = Math.min(this.defaultRenderCount, this.users.length); + const renderUsers = this.users.slice(0, maxRender); + const names = renderUsers.map(u => u.name); + + if (this.users.length > maxRender) { + names.push(`+ ${this.users.length - maxRender} more`); + } + + return names.join(', '); + }, + sidebarAvatarCounter() { + let counter = `+${this.users.length - 1}`; + + if (this.users.length > this.defaultMaxCounter) { + counter = `${this.defaultMaxCounter}+`; + } + + return counter; + }, + }, + methods: { + assignSelf() { + this.$emit('assign-self'); + }, + toggleShowLess() { + this.showLess = !this.showLess; + }, + renderAssignee(index) { + return !this.showLess || (index < this.defaultRenderCount && this.showLess); + }, + avatarUrl(user) { + return user.avatar || user.avatar_url; + }, + assigneeUrl(user) { + return `${this.rootPath}${user.username}`; + }, + assigneeAlt(user) { + return `${user.name}'s avatar`; + }, + assigneeUsername(user) { + return `@${user.username}`; + }, + shouldRenderCollapsedAssignee(index) { + const firstTwo = this.users.length <= 2 && index <= 2; + + return index === 0 || firstTwo; + }, + }, + template: ` + <div> + <div + class="sidebar-collapsed-icon sidebar-collapsed-user" + :class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }" + data-container="body" + data-placement="left" + :title="collapsedTooltipTitle" + > + <i + v-if="hasNoUsers" + aria-label="No Assignee" + class="fa fa-user" + /> + <button + type="button" + class="btn-link" + v-for="(user, index) in users" + v-if="shouldRenderCollapsedAssignee(index)" + > + <img + width="24" + class="avatar avatar-inline s24" + :alt="assigneeAlt(user)" + :src="avatarUrl(user)" + /> + <span class="author"> + {{ user.name }} + </span> + </button> + <button + v-if="hasMoreThanTwoAssignees" + class="btn-link" + type="button" + > + <span + class="avatar-counter sidebar-avatar-counter" + > + {{ sidebarAvatarCounter }} + </span> + </button> + </div> + <div class="value hide-collapsed"> + <template v-if="hasNoUsers"> + <span class="assign-yourself no-value"> + No assignee + <template v-if="editable"> + - + <button + type="button" + class="btn-link" + @click="assignSelf" + > + assign yourself + </button> + </template> + </span> + </template> + <template v-else-if="hasOneUser"> + <a + class="author_link bold" + :href="assigneeUrl(firstUser)" + > + <img + width="32" + class="avatar avatar-inline s32" + :alt="assigneeAlt(firstUser)" + :src="avatarUrl(firstUser)" + /> + <span class="author"> + {{ firstUser.name }} + </span> + <span class="username"> + {{ assigneeUsername(firstUser) }} + </span> + </a> + </template> + <template v-else> + <div class="user-list"> + <div + class="user-item" + v-for="(user, index) in users" + v-if="renderAssignee(index)" + > + <a + class="user-link has-tooltip" + data-placement="bottom" + :href="assigneeUrl(user)" + :data-title="user.name" + > + <img + width="32" + class="avatar avatar-inline s32" + :alt="assigneeAlt(user)" + :src="avatarUrl(user)" + /> + </a> + </div> + </div> + <div + v-if="renderShowMoreSection" + class="user-list-more" + > + <button + type="button" + class="btn-link" + @click="toggleShowLess" + > + <template v-if="showLess"> + {{ hiddenAssigneesLabel }} + </template> + <template v-else> + - show less + </template> + </button> + </div> + </template> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js new file mode 100644 index 0000000000000..4ee7a9c619cee --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js @@ -0,0 +1,82 @@ +/* global Flash */ + +import AssigneeTitle from './assignee_title'; +import Assignees from './assignees'; + +import Store from '../../stores/sidebar_store'; +import Mediator from '../../sidebar_mediator'; + +import eventHub from '../../event_hub'; + +export default { + name: 'SidebarAssignees', + data() { + return { + mediator: new Mediator(), + store: new Store(), + loading: false, + field: '', + }; + }, + components: { + 'assignee-title': AssigneeTitle, + assignees: Assignees, + }, + methods: { + assignSelf() { + // Notify gl dropdown that we are now assigning to current user + this.$el.parentElement.dispatchEvent(new Event('assignYourself')); + + this.mediator.assignYourself(); + this.saveAssignees(); + }, + saveAssignees() { + this.loading = true; + + this.mediator.saveAssignees(this.field) + .then(() => { + this.loading = false; + }) + .catch(() => { + this.loading = false; + return new Flash('Error occurred when saving assignees'); + }); + }, + }, + created() { + this.removeAssignee = this.store.removeAssignee.bind(this.store); + this.addAssignee = this.store.addAssignee.bind(this.store); + this.removeAllAssignees = this.store.removeAllAssignees.bind(this.store); + + // Get events from glDropdown + eventHub.$on('sidebar.removeAssignee', this.removeAssignee); + eventHub.$on('sidebar.addAssignee', this.addAssignee); + eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$on('sidebar.saveAssignees', this.saveAssignees); + }, + beforeDestroy() { + eventHub.$off('sidebar.removeAssignee', this.removeAssignee); + eventHub.$off('sidebar.addAssignee', this.addAssignee); + eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$off('sidebar.saveAssignees', this.saveAssignees); + }, + beforeMount() { + this.field = this.$el.dataset.field; + }, + template: ` + <div> + <assignee-title + :number-of-assignees="store.assignees.length" + :loading="loading" + :editable="store.editable" + /> + <assignees + class="value" + :root-path="store.rootPath" + :users="store.assignees" + :editable="store.editable" + @assign-self="assignSelf" + /> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js new file mode 100644 index 0000000000000..0da265053bd0e --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js @@ -0,0 +1,97 @@ +import stopwatchSvg from 'icons/_icon_stopwatch.svg'; + +import '../../../lib/utils/pretty_time'; + +export default { + name: 'time-tracking-collapsed-state', + props: { + showComparisonState: { + type: Boolean, + required: true, + }, + showSpentOnlyState: { + type: Boolean, + required: true, + }, + showEstimateOnlyState: { + type: Boolean, + required: true, + }, + showNoTimeTrackingState: { + type: Boolean, + required: true, + }, + timeSpentHumanReadable: { + type: String, + required: false, + default: '', + }, + timeEstimateHumanReadable: { + type: String, + required: false, + default: '', + }, + }, + computed: { + timeSpent() { + return this.abbreviateTime(this.timeSpentHumanReadable); + }, + timeEstimate() { + return this.abbreviateTime(this.timeEstimateHumanReadable); + }, + divClass() { + if (this.showComparisonState) { + return 'compare'; + } else if (this.showEstimateOnlyState) { + return 'estimate-only'; + } else if (this.showSpentOnlyState) { + return 'spend-only'; + } else if (this.showNoTimeTrackingState) { + return 'no-tracking'; + } + + return ''; + }, + spanClass() { + if (this.showComparisonState) { + return ''; + } else if (this.showEstimateOnlyState || this.showSpentOnlyState) { + return 'bold'; + } else if (this.showNoTimeTrackingState) { + return 'no-value'; + } + + return ''; + }, + text() { + if (this.showComparisonState) { + return `${this.timeSpent} / ${this.timeEstimate}`; + } else if (this.showEstimateOnlyState) { + return `-- / ${this.timeEstimate}`; + } else if (this.showSpentOnlyState) { + return `${this.timeSpent} / --`; + } else if (this.showNoTimeTrackingState) { + return 'None'; + } + + return ''; + }, + }, + methods: { + abbreviateTime(timeStr) { + return gl.utils.prettyTime.abbreviateTime(timeStr); + }, + }, + template: ` + <div class="sidebar-collapsed-icon"> + ${stopwatchSvg} + <div class="time-tracking-collapsed-summary"> + <div :class="divClass"> + <span :class="spanClass"> + {{ text }} + </span> + </div> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js new file mode 100644 index 0000000000000..40f5c89c5bbcf --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js @@ -0,0 +1,98 @@ +import '../../../lib/utils/pretty_time'; + +const prettyTime = gl.utils.prettyTime; + +export default { + name: 'time-tracking-comparison-pane', + props: { + timeSpent: { + type: Number, + required: true, + }, + timeEstimate: { + type: Number, + required: true, + }, + timeSpentHumanReadable: { + type: String, + required: true, + }, + timeEstimateHumanReadable: { + type: String, + required: true, + }, + }, + computed: { + parsedRemaining() { + const diffSeconds = this.timeEstimate - this.timeSpent; + return prettyTime.parseSeconds(diffSeconds); + }, + timeRemainingHumanReadable() { + return prettyTime.stringifyTime(this.parsedRemaining); + }, + timeRemainingTooltip() { + const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:'; + return `${prefix} ${this.timeRemainingHumanReadable}`; + }, + /* Diff values for comparison meter */ + timeRemainingMinutes() { + return this.timeEstimate - this.timeSpent; + }, + timeRemainingPercent() { + return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`; + }, + timeRemainingStatusClass() { + return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate'; + }, + /* Parsed time values */ + parsedEstimate() { + return prettyTime.parseSeconds(this.timeEstimate); + }, + parsedSpent() { + return prettyTime.parseSeconds(this.timeSpent); + }, + }, + template: ` + <div class="time-tracking-comparison-pane"> + <div + class="compare-meter" + data-toggle="tooltip" + data-placement="top" + role="timeRemainingDisplay" + :aria-valuenow="timeRemainingTooltip" + :title="timeRemainingTooltip" + :data-original-title="timeRemainingTooltip" + :class="timeRemainingStatusClass" + > + <div + class="meter-container" + role="timeSpentPercent" + :aria-valuenow="timeRemainingPercent" + > + <div + :style="{ width: timeRemainingPercent }" + class="meter-fill" + /> + </div> + <div class="compare-display-container"> + <div class="compare-display pull-left"> + <span class="compare-label"> + Spent + </span> + <span class="compare-value spent"> + {{ timeSpentHumanReadable }} + </span> + </div> + <div class="compare-display estimated pull-right"> + <span class="compare-label"> + Est + </span> + <span class="compare-value"> + {{ timeEstimateHumanReadable }} + </span> + </div> + </div> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js new file mode 100644 index 0000000000000..ad1b9179db017 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js @@ -0,0 +1,17 @@ +export default { + name: 'time-tracking-estimate-only-pane', + props: { + timeEstimateHumanReadable: { + type: String, + required: true, + }, + }, + template: ` + <div class="time-tracking-estimate-only-pane"> + <span class="bold"> + Estimated: + </span> + {{ timeEstimateHumanReadable }} + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js new file mode 100644 index 0000000000000..b2a77462fe0d4 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js @@ -0,0 +1,44 @@ +export default { + name: 'time-tracking-help-state', + props: { + rootPath: { + type: String, + required: true, + }, + }, + computed: { + href() { + return `${this.rootPath}help/workflow/time_tracking.md`; + }, + }, + template: ` + <div class="time-tracking-help-state"> + <div class="time-tracking-info"> + <h4> + Track time with slash commands + </h4> + <p> + Slash commands can be used in the issues description and comment boxes. + </p> + <p> + <code> + /estimate + </code> + will update the estimated time with the latest command. + </p> + <p> + <code> + /spend + </code> + will update the sum of the time spent. + </p> + <a + class="btn btn-default learn-more-button" + :href="href" + > + Learn more + </a> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js new file mode 100644 index 0000000000000..d1dd1dcdd277d --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js @@ -0,0 +1,10 @@ +export default { + name: 'time-tracking-no-tracking-pane', + template: ` + <div class="time-tracking-no-tracking-pane"> + <span class="no-value"> + No estimate or time spent + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js new file mode 100644 index 0000000000000..244b67b3ad926 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js @@ -0,0 +1,51 @@ +import '~/smart_interval'; + +import timeTracker from './time_tracker'; + +import Store from '../../stores/sidebar_store'; +import Mediator from '../../sidebar_mediator'; + +export default { + data() { + return { + mediator: new Mediator(), + store: new Store(), + }; + }, + components: { + 'issuable-time-tracker': timeTracker, + }, + methods: { + listenForSlashCommands() { + $(document).on('ajax:success', '.gfm-form', this.slashCommandListened); + }, + slashCommandListened(e, data) { + const subscribedCommands = ['spend_time', 'time_estimate']; + let changedCommands; + if (data !== undefined) { + changedCommands = data.commands_changes + ? Object.keys(data.commands_changes) + : []; + } else { + changedCommands = []; + } + if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) { + this.mediator.fetch(); + } + }, + }, + mounted() { + this.listenForSlashCommands(); + }, + template: ` + <div class="block"> + <issuable-time-tracker + :time_estimate="store.timeEstimate" + :time_spent="store.totalTimeSpent" + :human_time_estimate="store.humanTimeEstimate" + :human_time_spent="store.humanTotalTimeSpent" + :rootPath="store.rootPath" + /> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js new file mode 100644 index 0000000000000..bf9875626475e --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js @@ -0,0 +1,15 @@ +export default { + name: 'time-tracking-spent-only-pane', + props: { + timeSpentHumanReadable: { + type: String, + required: true, + }, + }, + template: ` + <div class="time-tracking-spend-only-pane"> + <span class="bold">Spent:</span> + {{ timeSpentHumanReadable }} + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js new file mode 100644 index 0000000000000..ed0d71a4f797d --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js @@ -0,0 +1,163 @@ +import timeTrackingHelpState from './help_state'; +import timeTrackingCollapsedState from './collapsed_state'; +import timeTrackingSpentOnlyPane from './spent_only_pane'; +import timeTrackingNoTrackingPane from './no_tracking_pane'; +import timeTrackingEstimateOnlyPane from './estimate_only_pane'; +import timeTrackingComparisonPane from './comparison_pane'; + +import eventHub from '../../event_hub'; + +export default { + name: 'issuable-time-tracker', + props: { + time_estimate: { + type: Number, + required: true, + }, + time_spent: { + type: Number, + required: true, + }, + human_time_estimate: { + type: String, + required: false, + default: '', + }, + human_time_spent: { + type: String, + required: false, + default: '', + }, + rootPath: { + type: String, + required: true, + }, + }, + data() { + return { + showHelp: false, + }; + }, + components: { + 'time-tracking-collapsed-state': timeTrackingCollapsedState, + 'time-tracking-estimate-only-pane': timeTrackingEstimateOnlyPane, + 'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane, + 'time-tracking-no-tracking-pane': timeTrackingNoTrackingPane, + 'time-tracking-comparison-pane': timeTrackingComparisonPane, + 'time-tracking-help-state': timeTrackingHelpState, + }, + computed: { + timeSpent() { + return this.time_spent; + }, + timeEstimate() { + return this.time_estimate; + }, + timeEstimateHumanReadable() { + return this.human_time_estimate; + }, + timeSpentHumanReadable() { + return this.human_time_spent; + }, + hasTimeSpent() { + return !!this.timeSpent; + }, + hasTimeEstimate() { + return !!this.timeEstimate; + }, + showComparisonState() { + return this.hasTimeEstimate && this.hasTimeSpent; + }, + showEstimateOnlyState() { + return this.hasTimeEstimate && !this.hasTimeSpent; + }, + showSpentOnlyState() { + return this.hasTimeSpent && !this.hasTimeEstimate; + }, + showNoTimeTrackingState() { + return !this.hasTimeEstimate && !this.hasTimeSpent; + }, + showHelpState() { + return !!this.showHelp; + }, + }, + methods: { + toggleHelpState(show) { + this.showHelp = show; + }, + update(data) { + this.time_estimate = data.time_estimate; + this.time_spent = data.time_spent; + this.human_time_estimate = data.human_time_estimate; + this.human_time_spent = data.human_time_spent; + }, + }, + created() { + eventHub.$on('timeTracker:updateData', this.update); + }, + template: ` + <div + class="time_tracker time-tracking-component-wrap" + v-cloak + > + <time-tracking-collapsed-state + :show-comparison-state="showComparisonState" + :show-no-time-tracking-state="showNoTimeTrackingState" + :show-help-state="showHelpState" + :show-spent-only-state="showSpentOnlyState" + :show-estimate-only-state="showEstimateOnlyState" + :time-spent-human-readable="timeSpentHumanReadable" + :time-estimate-human-readable="timeEstimateHumanReadable" + /> + <div class="title hide-collapsed"> + Time tracking + <div + class="help-button pull-right" + v-if="!showHelpState" + @click="toggleHelpState(true)" + > + <i + class="fa fa-question-circle" + aria-hidden="true" + /> + </div> + <div + class="close-help-button pull-right" + v-if="showHelpState" + @click="toggleHelpState(false)" + > + <i + class="fa fa-close" + aria-hidden="true" + /> + </div> + </div> + <div class="time-tracking-content hide-collapsed"> + <time-tracking-estimate-only-pane + v-if="showEstimateOnlyState" + :time-estimate-human-readable="timeEstimateHumanReadable" + /> + <time-tracking-spent-only-pane + v-if="showSpentOnlyState" + :time-spent-human-readable="timeSpentHumanReadable" + /> + <time-tracking-no-tracking-pane + v-if="showNoTimeTrackingState" + /> + <time-tracking-comparison-pane + v-if="showComparisonState" + :time-estimate="timeEstimate" + :time-spent="timeSpent" + :time-spent-human-readable="timeSpentHumanReadable" + :time-estimate-human-readable="timeEstimateHumanReadable" + /> + <transition name="help-state-toggle"> + <time-tracking-help-state + v-if="showHelpState" + :rootPath="rootPath" + /> + </transition> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/event_hub.js b/app/assets/javascripts/sidebar/event_hub.js new file mode 100644 index 0000000000000..0948c2e53524a --- /dev/null +++ b/app/assets/javascripts/sidebar/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js new file mode 100644 index 0000000000000..5a82d01dc41e7 --- /dev/null +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -0,0 +1,28 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class SidebarService { + constructor(endpoint) { + if (!SidebarService.singleton) { + this.endpoint = endpoint; + + SidebarService.singleton = this; + } + + return SidebarService.singleton; + } + + get() { + return Vue.http.get(this.endpoint); + } + + update(key, data) { + return Vue.http.put(this.endpoint, { + [key]: data, + }, { + emulateJSON: true, + }); + } +} diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js new file mode 100644 index 0000000000000..2b02af87d8ac4 --- /dev/null +++ b/app/assets/javascripts/sidebar/sidebar_bundle.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking'; +import sidebarAssignees from './components/assignees/sidebar_assignees'; + +import Mediator from './sidebar_mediator'; + +function domContentLoaded() { + const mediator = new Mediator(gl.sidebarOptions); + mediator.fetch(); + + const sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees'); + + // Only create the sidebarAssignees vue app if it is found in the DOM + // We currently do not use sidebarAssignees for the MR page + if (sidebarAssigneesEl) { + new Vue(sidebarAssignees).$mount(sidebarAssigneesEl); + } + + new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker'); +} + +document.addEventListener('DOMContentLoaded', domContentLoaded); + +export default domContentLoaded; diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js new file mode 100644 index 0000000000000..5ccfb4ee9c1d3 --- /dev/null +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -0,0 +1,38 @@ +/* global Flash */ + +import Service from './services/sidebar_service'; +import Store from './stores/sidebar_store'; + +export default class SidebarMediator { + constructor(options) { + if (!SidebarMediator.singleton) { + this.store = new Store(options); + this.service = new Service(options.endpoint); + SidebarMediator.singleton = this; + } + + return SidebarMediator.singleton; + } + + assignYourself() { + this.store.addAssignee(this.store.currentUser); + } + + saveAssignees(field) { + const selected = this.store.assignees.map(u => u.id); + + // If there are no ids, that means we have to unassign (which is id = 0) + // And it only accepts an array, hence [0] + return this.service.update(field, selected.length === 0 ? [0] : selected); + } + + fetch() { + this.service.get() + .then((response) => { + const data = response.json(); + this.store.setAssigneeData(data); + this.store.setTimeTrackingData(data); + }) + .catch(() => new Flash('Error occured when fetching sidebar data')); + } +} diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js new file mode 100644 index 0000000000000..2d44c05bb8d24 --- /dev/null +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -0,0 +1,52 @@ +export default class SidebarStore { + constructor(store) { + if (!SidebarStore.singleton) { + const { currentUser, rootPath, editable } = store; + this.currentUser = currentUser; + this.rootPath = rootPath; + this.editable = editable; + this.timeEstimate = 0; + this.totalTimeSpent = 0; + this.humanTimeEstimate = ''; + this.humanTimeSpent = ''; + this.assignees = []; + + SidebarStore.singleton = this; + } + + return SidebarStore.singleton; + } + + setAssigneeData(data) { + if (data.assignees) { + this.assignees = data.assignees; + } + } + + setTimeTrackingData(data) { + this.timeEstimate = data.time_estimate; + this.totalTimeSpent = data.total_time_spent; + this.humanTimeEstimate = data.human_time_estimate; + this.humanTotalTimeSpent = data.human_total_time_spent; + } + + addAssignee(assignee) { + if (!this.findAssignee(assignee)) { + this.assignees.push(assignee); + } + } + + findAssignee(findAssignee) { + return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0]; + } + + removeAssignee(removeAssignee) { + if (removeAssignee) { + this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id); + } + } + + removeAllAssignees() { + this.assignees = []; + } +} diff --git a/app/assets/javascripts/signin_tabs_memoizer.js b/app/assets/javascripts/signin_tabs_memoizer.js index d811d1cd53abf..2587facc58225 100644 --- a/app/assets/javascripts/signin_tabs_memoizer.js +++ b/app/assets/javascripts/signin_tabs_memoizer.js @@ -1,5 +1,7 @@ /* eslint no-param-reassign: ["error", { "props": false }]*/ /* eslint no-new: "off" */ +import AccessorUtilities from './lib/utils/accessor'; + ((global) => { /** * Memorize the last selected tab after reloading a page. @@ -9,6 +11,8 @@ constructor({ currentTabKey = 'current_signin_tab', tabSelector = 'ul.nav-tabs' } = {}) { this.currentTabKey = currentTabKey; this.tabSelector = tabSelector; + this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); + this.bootstrap(); } @@ -37,11 +41,15 @@ } saveData(val) { - localStorage.setItem(this.currentTabKey, val); + if (!this.isLocalStorageAvailable) return undefined; + + return window.localStorage.setItem(this.currentTabKey, val); } readData() { - return localStorage.getItem(this.currentTabKey); + if (!this.isLocalStorageAvailable) return null; + + return window.localStorage.getItem(this.currentTabKey); } } diff --git a/app/assets/javascripts/subbable_resource.js b/app/assets/javascripts/subbable_resource.js deleted file mode 100644 index d819160512849..0000000000000 --- a/app/assets/javascripts/subbable_resource.js +++ /dev/null @@ -1,51 +0,0 @@ -(() => { -/* -* SubbableResource can be extended to provide a pubsub-style service for one-off REST -* calls. Subscribe by passing a callback or render method you will use to handle responses. - * -* */ - - class SubbableResource { - constructor(resourcePath) { - this.endpoint = resourcePath; - - // TODO: Switch to axios.create - this.resource = $.ajax; - this.subscribers = []; - } - - subscribe(callback) { - this.subscribers.push(callback); - } - - publish(newResponse) { - const responseCopy = _.extend({}, newResponse); - this.subscribers.forEach((fn) => { - fn(responseCopy); - }); - return newResponse; - } - - get(payload) { - return this.resource(payload) - .then(data => this.publish(data)); - } - - post(payload) { - return this.resource(payload) - .then(data => this.publish(data)); - } - - put(payload) { - return this.resource(payload) - .then(data => this.publish(data)); - } - - delete(payload) { - return this.resource(payload) - .then(data => this.publish(data)); - } - } - - gl.SubbableResource = SubbableResource; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/subscription_select.js b/app/assets/javascripts/subscription_select.js index 8b25f43ffc745..0cd591c732086 100644 --- a/app/assets/javascripts/subscription_select.js +++ b/app/assets/javascripts/subscription_select.js @@ -19,8 +19,8 @@ return label; }; })(this), - clicked: function(item, $el, e) { - return e.preventDefault(); + clicked: function(options) { + return options.e.preventDefault(); }, id: function(obj, el) { return $(el).data("id"); diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 26c90bf8229b9..fecb09cb51ae3 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -1,6 +1,7 @@ /* eslint-disable func-names, space-before-function-paren, one-var, no-var, prefer-rest-params, wrap-iife, quotes, max-len, one-var-declaration-per-line, vars-on-top, prefer-arrow-callback, consistent-return, comma-dangle, object-shorthand, no-shadow, no-unused-vars, no-else-return, no-self-compare, prefer-template, no-unused-expressions, no-lonely-if, yoda, prefer-spread, no-void, camelcase, no-param-reassign */ /* global Issuable */ -/* global ListUser */ + +import eventHub from './sidebar/event_hub'; (function() { var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }, @@ -54,42 +55,115 @@ selectedIdDefault = (defaultNullUser && showNullUser) ? 0 : null; selectedId = $dropdown.data('selected') || selectedIdDefault; - var updateIssueBoardsIssue = function () { - $loading.removeClass('hidden').fadeIn(); - gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update')) - .then(function () { - $loading.fadeOut(); - }) - .catch(function () { - $loading.fadeOut(); - }); + const assignYourself = function () { + const unassignedSelected = $dropdown.closest('.selectbox') + .find(`input[name='${$dropdown.data('field-name')}'][value=0]`); + + if (unassignedSelected) { + unassignedSelected.remove(); + } + + // Save current selected user to the DOM + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = $dropdown.data('field-name'); + + const currentUserInfo = $dropdown.data('currentUserInfo'); + + if (currentUserInfo) { + input.value = currentUserInfo.id; + input.dataset.meta = currentUserInfo.name; + } else if (_this.currentUser) { + input.value = _this.currentUser.id; + } + + if ($selectbox) { + $dropdown.parent().before(input); + } else { + $dropdown.after(input); + } + }; + + if ($block[0]) { + $block[0].addEventListener('assignYourself', assignYourself); + } + + const getSelectedUserInputs = function() { + return $selectbox + .find(`input[name="${$dropdown.data('field-name')}"]`); + }; + + const getSelected = function() { + return getSelectedUserInputs() + .map((index, input) => parseInt(input.value, 10)) + .get(); + }; + + const checkMaxSelect = function() { + const maxSelect = $dropdown.data('max-select'); + if (maxSelect) { + const selected = getSelected(); + + if (selected.length > maxSelect) { + const firstSelectedId = selected[0]; + const firstSelected = $dropdown.closest('.selectbox') + .find(`input[name='${$dropdown.data('field-name')}'][value=${firstSelectedId}]`); + + firstSelected.remove(); + eventHub.$emit('sidebar.removeAssignee', { + id: firstSelectedId, + }); + } + } + }; + + const getMultiSelectDropdownTitle = function(selectedUser, isSelected) { + const selectedUsers = getSelected() + .filter(u => u !== 0); + + const firstUser = getSelectedUserInputs() + .map((index, input) => ({ + name: input.dataset.meta, + value: parseInt(input.value, 10), + })) + .filter(u => u.id !== 0) + .get(0); + + if (selectedUsers.length === 0) { + return 'Unassigned'; + } else if (selectedUsers.length === 1) { + return firstUser.name; + } else if (isSelected) { + const otherSelected = selectedUsers.filter(s => s !== selectedUser.id); + return `${selectedUser.name} + ${otherSelected.length} more`; + } else { + return `${firstUser.name} + ${selectedUsers.length - 1} more`; + } }; $('.assign-to-me-link').on('click', (e) => { e.preventDefault(); $(e.currentTarget).hide(); - const $input = $(`input[name="${$dropdown.data('field-name')}"]`); - $input.val(gon.current_user_id); - selectedId = $input.val(); - $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default'); - }); - $block.on('click', '.js-assign-yourself', function(e) { - e.preventDefault(); - - if ($dropdown.hasClass('js-issue-board-sidebar')) { - gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({ - id: _this.currentUser.id, - username: _this.currentUser.username, - name: _this.currentUser.name, - avatar_url: _this.currentUser.avatar_url - })); + if ($dropdown.data('multiSelect')) { + assignYourself(); + checkMaxSelect(); - updateIssueBoardsIssue(); + const currentUserInfo = $dropdown.data('currentUserInfo'); + $dropdown.find('.dropdown-toggle-text').text(getMultiSelectDropdownTitle(currentUserInfo)).removeClass('is-default'); } else { - return assignTo(_this.currentUser.id); + const $input = $(`input[name="${$dropdown.data('field-name')}"]`); + $input.val(gon.current_user_id); + selectedId = $input.val(); + $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default'); } }); + + $block.on('click', '.js-assign-yourself', (e) => { + e.preventDefault(); + return assignTo(_this.currentUser.id); + }); + assignTo = function(selected) { var data; data = {}; @@ -97,6 +171,7 @@ data[abilityName].assignee_id = selected != null ? selected : null; $loading.removeClass('hidden').fadeIn(); $dropdown.trigger('loading.gl.dropdown'); + return $.ajax({ type: 'PUT', dataType: 'json', @@ -106,7 +181,6 @@ var user; $dropdown.trigger('loaded.gl.dropdown'); $loading.fadeOut(); - $selectbox.hide(); if (data.assignee) { user = { name: data.assignee.name, @@ -133,51 +207,90 @@ var isAuthorFilter; isAuthorFilter = $('.js-author-search'); return _this.users(term, options, function(users) { - var anyUser, index, j, len, name, obj, showDivider; - if (term.length === 0) { - showDivider = 0; - if (firstUser) { - // Move current user to the front of the list - for (index = j = 0, len = users.length; j < len; index = (j += 1)) { - obj = users[index]; - if (obj.username === firstUser) { - users.splice(index, 1); - users.unshift(obj); - break; - } + // GitLabDropdownFilter returns this.instance + // GitLabDropdownRemote returns this.options.instance + const glDropdown = this.instance || this.options.instance; + glDropdown.options.processData(term, users, callback); + }.bind(this)); + }, + processData: function(term, users, callback) { + let anyUser; + let index; + let j; + let len; + let name; + let obj; + let showDivider; + if (term.length === 0) { + showDivider = 0; + if (firstUser) { + // Move current user to the front of the list + for (index = j = 0, len = users.length; j < len; index = (j += 1)) { + obj = users[index]; + if (obj.username === firstUser) { + users.splice(index, 1); + users.unshift(obj); + break; } } - if (showNullUser) { - showDivider += 1; - users.unshift({ - beforeDivider: true, - name: 'Unassigned', - id: 0 - }); - } - if (showAnyUser) { - showDivider += 1; - name = showAnyUser; - if (name === true) { - name = 'Any User'; - } - anyUser = { - beforeDivider: true, - name: name, - id: null - }; - users.unshift(anyUser); + } + if (showNullUser) { + showDivider += 1; + users.unshift({ + beforeDivider: true, + name: 'Unassigned', + id: 0 + }); + } + if (showAnyUser) { + showDivider += 1; + name = showAnyUser; + if (name === true) { + name = 'Any User'; } + anyUser = { + beforeDivider: true, + name: name, + id: null + }; + users.unshift(anyUser); } + if (showDivider) { - users.splice(showDivider, 0, "divider"); + users.splice(showDivider, 0, 'divider'); } - callback(users); - if (showMenuAbove) { - $dropdown.data('glDropdown').positionMenuAbove(); + if ($dropdown.hasClass('js-multiselect')) { + const selected = getSelected().filter(i => i !== 0); + + if (selected.length > 0) { + if ($dropdown.data('dropdown-header')) { + showDivider += 1; + users.splice(showDivider, 0, { + header: $dropdown.data('dropdown-header'), + }); + } + + const selectedUsers = users + .filter(u => selected.indexOf(u.id) !== -1) + .sort((a, b) => a.name > b.name); + + users = users.filter(u => selected.indexOf(u.id) === -1); + + selectedUsers.forEach((selectedUser) => { + showDivider += 1; + users.splice(showDivider, 0, selectedUser); + }); + + users.splice(showDivider + 1, 0, 'divider'); + } } - }); + } + + callback(users); + if (showMenuAbove) { + $dropdown.data('glDropdown').positionMenuAbove(); + } }, filterable: true, filterRemote: true, @@ -186,7 +299,22 @@ }, selectable: true, fieldName: $dropdown.data('field-name'), - toggleLabel: function(selected, el) { + toggleLabel: function(selected, el, glDropdown) { + const inputValue = glDropdown.filterInput.val(); + + if (this.multiSelect && inputValue === '') { + // Remove non-users from the fullData array + const users = glDropdown.filteredFullData(); + const callback = glDropdown.parseData.bind(glDropdown); + + // Update the data model + this.processData(inputValue, users, callback); + } + + if (this.multiSelect) { + return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active')); + } + if (selected && 'id' in selected && $(el).hasClass('is-active')) { $dropdown.find('.dropdown-toggle-text').removeClass('is-default'); if (selected.text) { @@ -200,22 +328,81 @@ } }, defaultLabel: defaultLabel, - inputId: 'issue_assignee_id', hidden: function(e) { - $selectbox.hide(); - // display:block overrides the hide-collapse rule - return $value.css('display', ''); + if ($dropdown.hasClass('js-multiselect')) { + eventHub.$emit('sidebar.saveAssignees'); + } + + if (!$dropdown.data('always-show-selectbox')) { + $selectbox.hide(); + + // Recalculate where .value is because vue might have changed it + $block = $selectbox.closest('.block'); + $value = $block.find('.value'); + // display:block overrides the hide-collapse rule + $value.css('display', ''); + } }, - vue: $dropdown.hasClass('js-issue-board-sidebar'), - clicked: function(user, $el, e) { - var isIssueIndex, isMRIndex, page, selected, isSelecting; + multiSelect: $dropdown.hasClass('js-multiselect'), + inputMeta: $dropdown.data('input-meta'), + clicked: function(options) { + const { $el, e, isMarking } = options; + const user = options.selectedObj; + + if ($dropdown.hasClass('js-multiselect')) { + const isActive = $el.hasClass('is-active'); + const previouslySelected = $dropdown.closest('.selectbox') + .find("input[name='" + ($dropdown.data('field-name')) + "'][value!=0]"); + + // Enables support for limiting the number of users selected + // Automatically removes the first on the list if more users are selected + checkMaxSelect(); + + if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') { + // Unassigned selected + previouslySelected.each((index, element) => { + const id = parseInt(element.value, 10); + element.remove(); + }); + eventHub.$emit('sidebar.removeAllAssignees'); + } else if (isActive) { + // user selected + eventHub.$emit('sidebar.addAssignee', user); + + // Remove unassigned selection (if it was previously selected) + const unassignedSelected = $dropdown.closest('.selectbox') + .find("input[name='" + ($dropdown.data('field-name')) + "'][value=0]"); + + if (unassignedSelected) { + unassignedSelected.remove(); + } + } else { + if (previouslySelected.length === 0) { + // Select unassigned because there is no more selected users + this.addInput($dropdown.data('field-name'), 0, {}); + } + + // User unselected + eventHub.$emit('sidebar.removeAssignee', user); + } + + if (getSelected().find(u => u === gon.current_user_id)) { + $('.assign-to-me-link').hide(); + } else { + $('.assign-to-me-link').show(); + } + } + + var isIssueIndex, isMRIndex, page, selected; page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; isMRIndex = (page === page && page === 'projects:merge_requests:index'); - isSelecting = (user.id !== selectedId); - selectedId = isSelecting ? user.id : selectedIdDefault; if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { e.preventDefault(); + + const isSelecting = (user.id !== selectedId); + selectedId = isSelecting ? user.id : selectedIdDefault; + if (selectedId === gon.current_user_id) { $('.assign-to-me-link').hide(); } else { @@ -229,20 +416,7 @@ return Issuable.filterResults($dropdown.closest('form')); } else if ($dropdown.hasClass('js-filter-submit')) { return $dropdown.closest('form').submit(); - } else if ($dropdown.hasClass('js-issue-board-sidebar')) { - if (user.id && isSelecting) { - gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({ - id: user.id, - username: user.username, - name: user.name, - avatar_url: user.avatar_url - })); - } else { - gl.issueBoards.boardStoreIssueDelete('assignee'); - } - - updateIssueBoardsIssue(); - } else { + } else if (!$dropdown.hasClass('js-multiselect')) { selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val(); return assignTo(selected); } @@ -256,29 +430,54 @@ selectedId = parseInt($dropdown[0].dataset.selected, 10) || selectedIdDefault; } $el.find('.is-active').removeClass('is-active'); - $el.find(`li[data-user-id="${selectedId}"] .dropdown-menu-user-link`).addClass('is-active'); + + function highlightSelected(id) { + $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active'); + } + + if ($selectbox[0]) { + getSelected().forEach(selectedId => highlightSelected(selectedId)); + } else { + highlightSelected(selectedId); + } }, + updateLabel: $dropdown.data('dropdown-title'), renderRow: function(user) { - var avatar, img, listClosingTags, listWithName, listWithUserName, selected, username; + var avatar, img, listClosingTags, listWithName, listWithUserName, username; username = user.username ? "@" + user.username : ""; avatar = user.avatar_url ? user.avatar_url : false; - selected = user.id === parseInt(selectedId, 10) ? "is-active" : ""; + + let selected = user.id === parseInt(selectedId, 10); + + if (this.multiSelect) { + const fieldName = this.fieldName; + const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']"); + + if (field.length) { + selected = true; + } + } + img = ""; if (user.beforeDivider != null) { - "<li> <a href='#' class='" + selected + "'> " + user.name + " </a> </li>"; + `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`; } else { if (avatar) { - img = "<img src='" + avatar + "' class='avatar avatar-inline' width='30' />"; + img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />"; } } - // split into three parts so we can remove the username section if nessesary - listWithName = "<li data-user-id=" + user.id + "> <a href='#' class='dropdown-menu-user-link " + selected + "'> " + img + " <strong class='dropdown-menu-user-full-name'> " + user.name + " </strong>"; - listWithUserName = "<span class='dropdown-menu-user-username'> " + username + " </span>"; - listClosingTags = "</a> </li>"; - if (username === '') { - listWithUserName = ''; - } - return listWithName + listWithUserName + listClosingTags; + + return ` + <li data-user-id=${user.id}> + <a href='#' class='dropdown-menu-user-link ${selected === true ? 'is-active' : ''}'> + ${img} + <strong class='dropdown-menu-user-full-name'> + ${user.name} + </strong> + ${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''} + </a> + </li> + `; } }); }; diff --git a/app/assets/javascripts/vue_shared/translate.js b/app/assets/javascripts/vue_shared/translate.js new file mode 100644 index 0000000000000..f83c4b00761d2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/translate.js @@ -0,0 +1,42 @@ +import { + __, + n__, + s__, +} from '../locale'; + +export default (Vue) => { + Vue.mixin({ + methods: { + /** + Translates `text` + + @param text The text to be translated + @returns {String} The translated text + **/ + __, + /** + Translate the text with a number + if the number is more than 1 it will use the `pluralText` translation. + This method allows for contexts, see below re. contexts + + @param text Singular text to translate (eg. '%d day') + @param pluralText Plural text to translate (eg. '%d days') + @param count Number to decide which translation to use (eg. 2) + @returns {String} Translated text with the number replaced (eg. '2 days') + **/ + n__, + /** + Translate context based text + Either pass in the context translation like `Context|Text to translate` + or allow for dynamic text by doing passing in the context first & then the text to translate + + @param keyOrContext Can be either the key to translate including the context + (eg. 'Context|Text') or just the context for the translation + (eg. 'Context') + @param key Is the dynamic variable you want to be translated + @returns {String} Translated context based text + **/ + s__, + }, + }); +}; diff --git a/app/assets/javascripts/weight_select.js b/app/assets/javascripts/weight_select.js index 7964023537cf7..ca9a1184add77 100644 --- a/app/assets/javascripts/weight_select.js +++ b/app/assets/javascripts/weight_select.js @@ -54,7 +54,10 @@ return ''; } }, - clicked: function(selected, $el, e) { + clicked: function(options) { + const e = options.e; + let selected = options.selectedObj; + if ($(dropdown).is(".js-filter-submit")) { return $(dropdown).parents('form').submit(); } else if ($dropdown.is('.js-issuable-form-weight')) { diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 7c50b80fd2bb5..3cd7f81da4719 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -159,3 +159,31 @@ a { .fade-in { animation: fadeIn $fade-in-duration 1; } + +@keyframes fadeInHalf { + 0% { + opacity: 0; + } + + 100% { + opacity: 0.5; + } +} + +.fade-in-half { + animation: fadeInHalf $fade-in-duration 1; +} + +@keyframes fadeInFull { + 0% { + opacity: 0.5; + } + + 100% { + opacity: 1; + } +} + +.fade-in-full { + animation: fadeInFull $fade-in-duration 1; +} diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss index 3f5b78ed44536..91c1ebd5a7de3 100644 --- a/app/assets/stylesheets/framework/avatar.scss +++ b/app/assets/stylesheets/framework/avatar.scss @@ -93,3 +93,14 @@ align-self: center; } } + +.avatar-counter { + background-color: $gray-darkest; + color: $white-light; + border: 1px solid $border-color; + border-radius: 1em; + font-family: $regular_font; + font-size: 9px; + line-height: 16px; + text-align: center; +} diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 73ded9f30d470..5c9b71a452cc0 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -251,14 +251,16 @@ } .dropdown-header { - color: $gl-text-color; + color: $gl-text-color-secondary; font-size: 13px; - font-weight: 600; line-height: 22px; - text-transform: capitalize; padding: 0 16px; } + &.capitalize-header .dropdown-header { + text-transform: capitalize; + } + .separator + .dropdown-header { padding-top: 2px; } @@ -337,8 +339,8 @@ .dropdown-menu-user { .avatar { float: left; - width: 30px; - height: 30px; + width: 2 * $gl-padding; + height: 2 * $gl-padding; margin: 0 10px 0 0; } } @@ -381,6 +383,7 @@ .dropdown-menu-selectable { a { padding-left: 26px; + position: relative; &.is-indeterminate, &.is-active { @@ -406,6 +409,9 @@ &.is-active::before { content: "\f00c"; + position: absolute; + top: 50%; + transform: translateY(-50%); } } } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index c197bf6b9f578..1dd0e5ab581b4 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -162,6 +162,18 @@ &.code { padding: 0; } + + .list-inline.previews { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-content: flex-start; + align-items: baseline; + + .preview { + padding: $gl-padding; + } + } } } diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 03c206f26a4ae..3f75148f8b8c1 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -114,11 +114,21 @@ padding-right: 8px; .fa-close { - color: $gl-text-color-disabled; + color: $gl-text-color-secondary; } &:hover .fa-close { - color: $gl-text-color-secondary; + color: $gl-text-color; + } + + &.inverted { + .fa-close { + color: $gl-text-color-secondary-inverted; + } + + &:hover .fa-close { + color: $gl-text-color-inverted; + } } } diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index cb51323809c87..0b71cf16bd72c 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -261,6 +261,7 @@ ul.controls { .avatar-inline { margin-left: 0; margin-right: 0; + margin-bottom: 0; } } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 60f02921c0a63..8615801700183 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -101,6 +101,8 @@ $gl-font-size: 14px; $gl-text-color: rgba(0, 0, 0, .85); $gl-text-color-secondary: rgba(0, 0, 0, .55); $gl-text-color-disabled: rgba(0, 0, 0, .35); +$gl-text-color-inverted: rgba(255, 255, 255, 1.0); +$gl-text-color-secondary-inverted: rgba(255, 255, 255, .85); $gl-text-green: $green-600; $gl-text-red: $red-500; $gl-text-orange: $orange-600; diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 353dd13dff41d..46548189b0488 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -236,8 +236,13 @@ margin-bottom: 5px; } - &.is-active { + &.is-active, + &.is-active .card-assignee:hover a { background-color: $row-hover; + + &:first-child:not(:only-child) { + box-shadow: -10px 0 10px 1px $row-hover; + } } .label { @@ -253,7 +258,7 @@ } .card-title { - margin: 0; + margin: 0 30px 0 0; font-size: 1em; line-height: inherit; @@ -269,10 +274,68 @@ min-height: 20px; .card-assignee { - margin-left: auto; - margin-right: 5px; - padding-left: 10px; + display: flex; + justify-content: flex-end; + position: absolute; + right: 15px; height: 20px; + width: 20px; + + .avatar-counter { + display: none; + vertical-align: middle; + line-height: 18px; + height: 20px; + padding-left: 3px; + padding-right: 3px; + border-radius: 2em; + } + + img { + vertical-align: top; + } + + a { + position: relative; + margin-left: -15px; + } + + a:nth-child(1) { + z-index: 3; + } + + a:nth-child(2) { + z-index: 2; + } + + a:nth-child(3) { + z-index: 1; + } + + a:nth-child(4) { + display: none; + } + + &:hover { + .avatar-counter { + display: inline-block; + } + + a { + position: static; + background-color: $white-light; + transition: background-color 0s; + margin-left: auto; + + &:nth-child(4) { + display: block; + } + + &:first-child:not(:only-child) { + box-shadow: -10px 0 10px 1px $white-light; + } + } + } } .avatar { diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index 403724cd68a84..d29944207c5ba 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -3,6 +3,25 @@ margin: 24px auto 0; position: relative; + .landing { + margin-top: 10px; + + .inner-content { + white-space: normal; + + h4, + p { + margin: 7px 0 0; + max-width: 480px; + padding: 0 $gl-padding; + + @media (max-width: $screen-sm-min) { + margin: 0 auto; + } + } + } + } + .col-headers { ul { margin: 0; @@ -175,7 +194,7 @@ } .stage-nav-item { - display: block; + display: flex; line-height: 65px; border-top: 1px solid transparent; border-bottom: 1px solid transparent; @@ -209,14 +228,10 @@ } .stage-nav-item-cell { - float: left; - - &.stage-name { - width: 65%; - } - &.stage-median { - width: 35%; + margin-left: auto; + margin-right: $gl-padding; + min-width: calc(35% - #{$gl-padding}); } } diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index feefaad8a15c6..77f2638683a1d 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -570,14 +570,7 @@ .diff-comments-more-count, .diff-notes-collapse { - background-color: $gray-darkest; - color: $white-light; - border: 1px solid $white-light; - border-radius: 1em; - font-family: $regular_font; - font-size: 9px; - line-height: 17px; - text-align: center; + @extend .avatar-counter; } .diff-notes-collapse { diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index ad6eb9f6fe040..485ea369f3d17 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -95,10 +95,15 @@ } .right-sidebar { - a { + a, + .btn-link { color: inherit; } + .btn-link { + outline: none; + } + .issuable-header-text { margin-top: 7px; } @@ -215,6 +220,10 @@ } } + .assign-yourself .btn-link { + padding-left: 0; + } + .light { font-weight: normal; } @@ -239,6 +248,10 @@ margin-left: 0; } + .assignee .user-list .avatar { + margin: 0; + } + .username { display: block; margin-top: 4px; @@ -301,6 +314,10 @@ margin-top: 0; } + .sidebar-avatar-counter { + padding-top: 2px; + } + .todo-undone { color: $gl-link-color; } @@ -309,10 +326,15 @@ display: none; } - .avatar:hover { + .avatar:hover, + .avatar-counter:hover { border-color: $issuable-sidebar-color; } + .avatar-counter:hover { + color: $issuable-sidebar-color; + } + .btn-clipboard { border: none; color: $issuable-sidebar-color; @@ -322,6 +344,17 @@ color: $gl-text-color; } } + + &.multiple-users { + display: flex; + justify-content: center; + } + } + + .sidebar-avatar-counter { + width: 24px; + height: 24px; + border-radius: 12px; } .sidebar-collapsed-user { @@ -332,6 +365,37 @@ .issuable-header-btn { display: none; } + + .multiple-users { + height: 24px; + margin-bottom: 17px; + margin-top: 4px; + padding-bottom: 4px; + + .btn-link { + padding: 0; + border: 0; + + .avatar { + margin: 0; + } + } + + .btn-link:first-child { + position: absolute; + left: 10px; + z-index: 1; + } + + .btn-link:last-child { + position: absolute; + right: 10px; + + &:hover { + text-decoration: none; + } + } + } } a { @@ -380,17 +444,21 @@ } .participants-list { + display: flex; + flex-wrap: wrap; + justify-content: space-between; margin: -5px; } +.user-list { + display: flex; + flex-wrap: wrap; +} + .participants-author { - display: inline-block; + flex-basis: 14%; padding: 5px; - &:nth-of-type(7n) { - padding-right: 0; - } - .author_link { display: block; } @@ -400,13 +468,39 @@ } } -.participants-more { +.user-item { + display: inline-block; + padding: 5px; + flex-basis: 20%; + + .user-link { + display: inline-block; + } +} + +.participants-more, +.user-list-more { margin-top: 5px; margin-left: 5px; - a { + a, + .btn-link { color: $gl-text-color-secondary; } + + .btn-link { + outline: none; + padding: 0; + } + + .btn-link:hover { + @extend a:hover; + text-decoration: none; + } + + .btn-link:focus { + text-decoration: none; + } } .issuable-form-padding-top { @@ -499,6 +593,19 @@ } } +.issuable-list li, +.issue-info-container .controls { + .avatar-counter { + display: inline-block; + vertical-align: middle; + min-width: 16px; + line-height: 14px; + height: 16px; + padding-left: 2px; + padding-right: 2px; + } +} + .time_tracker { padding-bottom: 0; border-bottom: 0; diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index e1ef0b029a59a..c10588ac58e6a 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -116,7 +116,7 @@ } .manage-labels-list { - > li:not(.empty-message) { + > li:not(.empty-message):not(.is-not-draggable) { background-color: $white-light; cursor: move; cursor: -webkit-grab; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 10e402f33e266..c5046dbeeff2e 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -482,6 +482,10 @@ } } +.target-branch-select-dropdown-container { + position: relative; +} + .assign-to-me-link { padding-left: 12px; white-space: nowrap; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index f89150ebead7e..69c328d09ffab 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -57,6 +57,25 @@ ul.notes { position: relative; border-bottom: 1px solid $white-normal; + &.being-posted { + pointer-events: none; + opacity: 0.5; + + .dummy-avatar { + display: inline-block; + height: 40px; + width: 40px; + border-radius: 50%; + background-color: $kdb-border; + border: 1px solid darken($kdb-border, 25%); + } + + .note-headline-light, + .fa-spinner { + margin-left: 3px; + } + } + &.note-discussion { &.timeline-entry { padding: 14px 10px; @@ -111,12 +130,6 @@ ul.notes { } .note-header { - padding-bottom: 8px; - padding-right: 20px; - - @media (min-width: $screen-sm-min) { - padding-right: 0; - } @media (max-width: $screen-xs-min) { .inline { @@ -365,10 +378,15 @@ ul.notes { .note-header { display: flex; justify-content: space-between; + + @media (max-width: $screen-xs-max) { + flex-flow: row wrap; + } } .note-header-info { min-width: 0; + padding-bottom: 5px; } .note-headline-light { @@ -416,6 +434,11 @@ ul.notes { margin-left: 10px; color: $gray-darkest; + @media (max-width: $screen-xs-max) { + float: none; + margin-left: 0; + } + .note-action-button { margin-left: 8px; } @@ -687,6 +710,10 @@ ul.notes { } } +.discussion-notes .flash-container { + margin-bottom: 0; +} + // Merge request notes in diffs .diff-file { // Diff is side by side diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 60f79665135af..106b0ee45d2b5 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -277,6 +277,7 @@ .stage-container { display: inline-block; position: relative; + vertical-align: middle; height: 22px; margin: 3px 6px 3px 0; @@ -320,6 +321,32 @@ } } +.build-failures { + .build-state { + padding: 20px 2px; + + .build-name { + float: right; + font-weight: 500; + } + + .ci-status-icon-failed svg { + vertical-align: middle; + } + + .stage { + color: $gl-text-color-secondary; + font-weight: 500; + vertical-align: middle; + } + } + + .build-log { + border: none; + line-height: initial; + } +} + // Pipeline graph .pipeline-graph { width: 100%; diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss index a39815319f377..de652a7936988 100644 --- a/app/assets/stylesheets/pages/todos.scss +++ b/app/assets/stylesheets/pages/todos.scss @@ -54,8 +54,9 @@ background-color: $white-light; &:hover { - border-color: $white-dark; + border-color: $white-normal; background-color: $gray-light; + border-top: 1px solid transparent; .todo-avatar, .todo-item { diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 0bc99451d5e39..8fdb887a7f933 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -134,6 +134,8 @@ def application_setting_params_ce :signup_enabled, :sentry_dsn, :sentry_enabled, + :clientside_sentry_dsn, + :clientside_sentry_enabled, :send_user_confirmation_email, :shared_runners_enabled, :shared_runners_text, diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb index 37a1a23178eb7..4c3d336b3aff8 100644 --- a/app/controllers/admin/services_controller.rb +++ b/app/controllers/admin/services_controller.rb @@ -16,6 +16,8 @@ def edit def update if service.update_attributes(service_params[:service]) + PropagateServiceTemplateWorker.perform_async(service.id) if service.active? + redirect_to admin_application_settings_services_path, notice: 'Application settings saved successfully' else diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 1f8a12a44d004..09f84b7a5997f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -21,6 +21,8 @@ class ApplicationController < ActionController::Base before_action :configure_permitted_parameters, if: :devise_controller? before_action :require_email, unless: :devise_controller? + around_action :set_locale + protect_from_forgery with: :exception helper_method :can?, :current_application_settings @@ -273,4 +275,12 @@ def gitlab_project_import_enabled? def u2f_app_id request.base_url end + + def set_locale + Gitlab::I18n.set_locale(current_user) + + yield + ensure + Gitlab::I18n.reset_locale + end end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 3ccf2a9ce33e1..b199f18da1e6d 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -66,6 +66,7 @@ def bulk_update_params :milestone_id, :state_event, :subscription_event, + assignee_ids: [], label_ids: [], add_label_ids: [], remove_label_ids: [] diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index ebc494051f8c6..5fc0702ab990d 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -43,7 +43,7 @@ def issuable_meta_data(issuable_collection, collection_type) end def issues_collection - issues_finder.execute.preload(:project, :author, :assignee, :labels, :milestone, project: :namespace) + issues_finder.execute.preload(:project, :author, :assignees, :labels, :milestone, project: :namespace) end def merge_requests_collection diff --git a/app/controllers/dashboard/labels_controller.rb b/app/controllers/dashboard/labels_controller.rb index d5031da867af6..dd1d46a68c71c 100644 --- a/app/controllers/dashboard/labels_controller.rb +++ b/app/controllers/dashboard/labels_controller.rb @@ -3,7 +3,7 @@ def index labels = LabelsFinder.new(current_user).execute respond_to do |format| - format.json { render json: labels.as_json(only: [:id, :title, :color]) } + format.json { render json: LabelSerializer.new.represent_appearance(labels) } end end end diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb index facb25525b5ef..3fa0516fb0ce4 100644 --- a/app/controllers/groups/labels_controller.rb +++ b/app/controllers/groups/labels_controller.rb @@ -15,7 +15,7 @@ def index format.json do available_labels = LabelsFinder.new(current_user, group_id: @group.id).execute - render json: available_labels.as_json(only: [:id, :title, :color]) + render json: LabelSerializer.new.represent_appearance(available_labels) end end end diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 4a719ebdbd61d..a27848de3a6b8 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -67,7 +67,7 @@ def saml def omniauth_error @provider = params[:provider] @error = params[:error] - render 'errors/omniauth_error', layout: "errors", status: 422 + render 'errors/omniauth_error', layout: "oauth_error", status: 422 end def cas3 diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 987b95e89b9e6..57e23cea00eb5 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -85,7 +85,8 @@ def user_params :twitter, :username, :website_url, - :organization + :organization, + :preferred_language ) end end diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index a13588b4218c2..1224e9503c93d 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -1,11 +1,13 @@ class Projects::ArtifactsController < Projects::ApplicationController include ExtractsPath + include RendersBlob layout 'project' before_action :authorize_read_build! before_action :authorize_update_build!, only: [:keep] before_action :extract_ref_name_and_path before_action :validate_artifacts! + before_action :set_path_and_entry, only: [:file, :raw] def download if artifacts_file.file_storage? @@ -24,15 +26,24 @@ def browse end def file - entry = build.artifacts_metadata_entry(params[:path]) + blob = @entry.blob + override_max_blob_size(blob) - if entry.exists? - send_artifacts_entry(build, entry) - else - render_404 + respond_to do |format| + format.html do + render 'file' + end + + format.json do + render_blob_json(blob) + end end end + def raw + send_artifacts_entry(build, @entry) + end + def keep build.keep_artifacts! redirect_to namespace_project_build_path(project.namespace, project, build) @@ -81,4 +92,11 @@ def build_from_ref def artifacts_file @artifacts_file ||= build.artifacts_file end + + def set_path_and_entry + @path = params[:path] + @entry = build.artifacts_metadata_entry(@path) + + render_404 unless @entry.exists? + end end diff --git a/app/controllers/projects/boards/issues_controller.rb b/app/controllers/projects/boards/issues_controller.rb index 1093414462ccf..8085a9c450b35 100644 --- a/app/controllers/projects/boards/issues_controller.rb +++ b/app/controllers/projects/boards/issues_controller.rb @@ -82,7 +82,7 @@ def serialize_as_json(resource) labels: true, only: [:id, :iid, :title, :confidential, :due_date, :relative_position], include: { - assignee: { only: [:id, :name, :username], methods: [:avatar_url] }, + assignees: { only: [:id, :name, :username], methods: [:avatar_url] }, milestone: { only: [:id, :title] } }, user: current_user diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb index 5e0d7fd02a42c..d46c7ac9b985d 100644 --- a/app/controllers/projects/deploy_keys_controller.rb +++ b/app/controllers/projects/deploy_keys_controller.rb @@ -8,7 +8,12 @@ class Projects::DeployKeysController < Projects::ApplicationController layout "project_settings" def index - redirect_to_repository_settings(@project) + respond_to do |format| + format.html { redirect_to_repository_settings(@project) } + format.json do + render json: Projects::Settings::DeployKeysPresenter.new(@project, current_user: current_user).as_json + end + end end def new @@ -23,6 +28,7 @@ def create else log_audit_event(@key.title, action: :create) end + redirect_to_repository_settings(@project) end @@ -31,7 +37,10 @@ def enable Projects::EnableDeployKeyService.new(@project, current_user, params).execute log_audit_event(@key.title, action: :create) - redirect_to_repository_settings(@project) + respond_to do |format| + format.html { redirect_to_repository_settings(@project) } + format.json { head :ok } + end end def disable @@ -42,7 +51,10 @@ def disable deploy_key_project.destroy! log_audit_event(@key.title, action: :destroy) - redirect_to_repository_settings(@project) + respond_to do |format| + format.html { redirect_to_repository_settings(@project) } + format.json { head :ok } + end end protected diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 5672ff7e1dcaa..86cd3c5cd699d 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -68,7 +68,7 @@ def index def new params[:issue] ||= ActionController::Parameters.new( - assignee_id: "" + assignee_ids: "" ) build_params = issue_params.merge( merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], @@ -151,7 +151,7 @@ def update if @issue.valid? render json: @issue.to_json(methods: [:task_status, :task_status_short], include: { milestone: {}, - assignee: { only: [:name, :username], methods: [:avatar_url] }, + assignees: { only: [:id, :name, :username], methods: [:avatar_url] }, labels: { methods: :text_color } }) else render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity @@ -291,8 +291,8 @@ def redirect_old def issue_params params.require(:issue).permit( - :title, :assignee_id, :position, :description, :confidential, :weight, - :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [] + :title, :position, :description, :confidential, :weight, + :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [], assignee_ids: [], ) end diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index 2f55ba4e700b0..71bfb7163da14 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -19,7 +19,7 @@ def index respond_to do |format| format.html format.json do - render json: @available_labels.as_json(only: [:id, :title, :color]) + render json: LabelSerializer.new.represent_appearance(@available_labels) end end end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 454b8ee17af2b..f9adedcb074b8 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -1,11 +1,13 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :pipeline, except: [:index, :new, :create, :charts] - before_action :commit, only: [:show, :builds] + before_action :commit, only: [:show, :builds, :failures] before_action :authorize_read_pipeline! before_action :authorize_create_pipeline!, only: [:new, :create] before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action :builds_enabled, only: :charts + wrap_parameters Ci::Pipeline + def index @scope = params[:scope] @pipelines = PipelinesFinder @@ -67,10 +69,14 @@ def show end def builds - respond_to do |format| - format.html do - render 'show' - end + render_show + end + + def failures + if @pipeline.statuses.latest.failed.present? + render_show + else + redirect_to pipeline_path(@pipeline) end end @@ -92,13 +98,25 @@ def stage def retry pipeline.retry_failed(current_user) - redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project) + respond_to do |format| + format.html do + redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project) + end + + format.json { head :no_content } + end end def cancel pipeline.cancel_running - redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project) + respond_to do |format| + format.html do + redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project) + end + + format.json { head :no_content } + end end def charts @@ -111,6 +129,14 @@ def charts private + def render_show + respond_to do |format| + format.html do + render 'show' + end + end + end + def create_params params.require(:pipeline).permit(:ref) end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 82994dd43ac0c..caa0d128630f6 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -236,7 +236,7 @@ def by_scope(items) when 'created-by-me', 'authored' items.where(author_id: current_user.id) when 'assigned-to-me' - items.where(assignee_id: current_user.id) + items.assigned_to(current_user) else items end diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index 34ed2c1fce8a9..6d2c0277fec81 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -26,17 +26,28 @@ def init_collection IssuesFinder.not_restricted_by_confidentiality(current_user) end + def by_assignee(items) + if assignee + items.assigned_to(assignee) + elsif no_assignee? + items.unassigned + elsif assignee_id? || assignee_username? # assignee not found + items.none + else + items + end + end + def self.not_restricted_by_confidentiality(user) - return Issue.where('issues.confidential IS NULL OR issues.confidential IS FALSE') if user.blank? + return Issue.where('issues.confidential IS NOT TRUE') if user.blank? return Issue.all if user.admin_or_auditor? Issue.where(' - issues.confidential IS NULL - OR issues.confidential IS FALSE + issues.confidential IS NOT TRUE OR (issues.confidential = TRUE AND (issues.author_id = :user_id - OR issues.assignee_id = :user_id + OR EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = :user_id AND issue_id = issues.id) OR issues.project_id IN(:project_ids)))', user_id: user.id, project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id)) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index c410a1a46e218..be097fe854cfa 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -183,16 +183,16 @@ def time_ago_with_tooltip(time, placement: 'top', html_class: '', short_format: element end - def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', include_author: false) - return if object.updated_at == object.created_at + def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', exclude_author: false) + return if object.last_edited_at == object.created_at || object.last_edited_at.blank? - content_tag :small, class: "edited-text" do - output = content_tag(:span, "Edited ") - output << time_ago_with_tooltip(object.updated_at, placement: placement, html_class: html_class) + content_tag :small, class: 'edited-text' do + output = content_tag(:span, 'Edited ') + output << time_ago_with_tooltip(object.last_edited_at, placement: placement, html_class: html_class) - if include_author && object.updated_by && object.updated_by != object.author - output << content_tag(:span, " by ") - output << link_to_member(object.project, object.updated_by, avatar: false, author_class: nil) + if !exclude_author && object.last_edited_by + output << content_tag(:span, ' by ') + output << link_to_member(object.project, object.last_edited_by, avatar: false, author_class: nil) end output diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 37b6f4ad5cc6b..af430270ae424 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -119,7 +119,9 @@ def blob_icon(mode, name) end def blob_raw_url - if @snippet + if @build && @entry + raw_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: @entry.path) + elsif @snippet if @snippet.project_id raw_namespace_project_snippet_path(@project.namespace, @project, @snippet) else @@ -250,6 +252,8 @@ def blob_render_error_reason(viewer) case viewer.blob.external_storage when :lfs 'it is stored in LFS' + when :build_artifact + 'it is stored as a job artifact' else 'it is stored externally' end diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index 1bd59822a54fb..2a9dc8b6858bd 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -10,6 +10,7 @@ def board_data issue_link_base: namespace_project_issues_path(@project.namespace, @project), root_path: root_path, bulk_update_path: bulk_update_namespace_project_issues_path(@project.namespace, @project), + default_avatar: image_path(default_avatar) } end diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb index 2fcb7a59fc32a..2eb2c6c738945 100644 --- a/app/helpers/builds_helper.rb +++ b/app/helpers/builds_helper.rb @@ -1,4 +1,16 @@ module BuildsHelper + def build_summary(build, skip: false) + if build.has_trace? + if skip + link_to "View job trace", pipeline_build_url(build.pipeline, build) + else + build.trace.html(last_lines: 10).html_safe + end + else + "No job trace" + end + end + def sidebar_build_class(build, current_build) build_class = '' build_class += ' active' if build.id === current_build.id diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index 1182939f65695..53962b846185b 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -15,4 +15,36 @@ def form_errors(model) end end end + + def issue_dropdown_options(issuable, has_multiple_assignees = true) + options = { + toggle_class: 'js-user-search js-assignee-search js-multiselect js-save-user-data', + title: 'Select assignee', + filter: true, + dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee', + placeholder: 'Search users', + data: { + first_user: current_user&.username, + null_user: true, + current_user: true, + project_id: issuable.project.try(:id), + field_name: "#{issuable.class.model_name.param_key}[assignee_ids][]", + default_label: 'Assignee', + 'max-select': 1, + 'dropdown-header': 'Assignee', + multi_select: true, + 'input-meta': 'name', + 'always-show-selectbox': true, + current_user_info: current_user.to_json(only: [:id, :name]) + } + } + + if has_multiple_assignees + options[:title] = 'Select assignee(s)' + options[:data][:'dropdown-header'] = 'Assignee(s)' + options[:data].delete(:'max-select') + end + + options + end end diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index 1f02f8b99af1f..3769830de2a1f 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -216,6 +216,8 @@ def artifacts_action_path(path, project, build) browse_namespace_project_build_artifacts_path(*args) when 'file' file_namespace_project_build_artifacts_path(*args) + when 'raw' + raw_namespace_project_build_artifacts_path(*args) end end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 69c0a61bd6555..f21d33a96cf98 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -63,6 +63,16 @@ def template_dropdown_tag(issuable, &block) end end + def users_dropdown_label(selected_users) + if selected_users.length == 0 + "Unassigned" + elsif selected_users.length == 1 + selected_users[0].name + else + "#{selected_users[0].name} + #{selected_users.length - 1} more" + end + end + def user_dropdown_label(user_id, default_label) return default_label if user_id.nil? return "Unassigned" if user_id == "0" diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 75a2d24435e4e..c61170d711c2a 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -72,6 +72,14 @@ def branches_sort_options_hash } end + def tags_sort_options_hash + { + sort_value_name => sort_title_name, + sort_value_recently_updated => sort_title_recently_updated, + sort_value_oldest_updated => sort_title_oldest_updated + } + end + def sort_title_priority 'Priority' end diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index 419729d88490d..ac4eb8598b777 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -1,6 +1,7 @@ module SystemNoteHelper ICON_NAMES_BY_ACTION = { 'commit' => 'icon_commit', + 'description' => 'icon_edit', 'merge' => 'icon_merge', 'merged' => 'icon_merged', 'opened' => 'icon_status_open', diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index d64e48f774b92..0f84784129511 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -11,10 +11,12 @@ def new_mention_in_issue_email(recipient_id, issue_id, updated_by_user_id) mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) end - def reassigned_issue_email(recipient_id, issue_id, previous_assignee_id, updated_by_user_id) + def reassigned_issue_email(recipient_id, issue_id, previous_assignee_ids, updated_by_user_id) setup_issue_mail(issue_id, recipient_id) - @previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id + @previous_assignees = [] + @previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any? + mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index e4a17186ff7eb..17338cea73e84 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -63,6 +63,10 @@ class ApplicationSetting < ActiveRecord::Base presence: true, if: :sentry_enabled + validates :clientside_sentry_dsn, + presence: true, + if: :clientside_sentry_enabled + validates :akismet_api_key, presence: true, if: :akismet_enabled diff --git a/app/models/blob.rb b/app/models/blob.rb index a4fae22a0c459..eaf0b71312288 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -26,6 +26,7 @@ class Blob < SimpleDelegator BlobViewer::Image, BlobViewer::Sketch, + BlobViewer::Balsamiq, BlobViewer::Video, diff --git a/app/models/blob_viewer/balsamiq.rb b/app/models/blob_viewer/balsamiq.rb new file mode 100644 index 0000000000000..f982521db9973 --- /dev/null +++ b/app/models/blob_viewer/balsamiq.rb @@ -0,0 +1,12 @@ +module BlobViewer + class Balsamiq < Base + include Rich + include ClientSide + + self.partial_name = 'balsamiq' + self.extensions = %w(bmpr) + self.binary = true + self.switcher_icon = 'file-image-o' + self.switcher_title = 'preview' + end +end diff --git a/app/models/ci/artifact_blob.rb b/app/models/ci/artifact_blob.rb new file mode 100644 index 0000000000000..b35febc9ac5a5 --- /dev/null +++ b/app/models/ci/artifact_blob.rb @@ -0,0 +1,35 @@ +module Ci + class ArtifactBlob + include BlobLike + + attr_reader :entry + + def initialize(entry) + @entry = entry + end + + delegate :name, :path, to: :entry + + def id + Digest::SHA1.hexdigest(path) + end + + def size + entry.metadata[:size] + end + + def data + "Build artifact #{path}" + end + + def mode + entry.metadata[:mode] + end + + def external_storage + :build_artifact + end + + alias_method :external_size, :size + end +end diff --git a/app/models/concerns/elastic/issues_search.rb b/app/models/concerns/elastic/issues_search.rb index 6fd6156ce3c24..181ade2f5e700 100644 --- a/app/models/concerns/elastic/issues_search.rb +++ b/app/models/concerns/elastic/issues_search.rb @@ -17,7 +17,14 @@ module IssuesSearch indexes :state, type: :text indexes :project_id, type: :integer indexes :author_id, type: :integer + + # The field assignee_id does not exist in issues table anymore. + # Nevertheless we'll keep this field as is because we don't want users to rebuild index + # + the ES treats arrays transparently so + # to any integer field you can write any array of integers and you don't have to change mapping. + # More over you can query those items just like a single integer value. indexes :assignee_id, type: :integer + indexes :confidential, type: :boolean end @@ -26,10 +33,12 @@ def as_indexed_json(options = {}) # We don't use as_json(only: ...) because it calls all virtual and serialized attributtes # https://gitlab.com/gitlab-org/gitlab-ee/issues/349 - [:id, :iid, :title, :description, :created_at, :updated_at, :state, :project_id, :author_id, :assignee_id, :confidential].each do |attr| + [:id, :iid, :title, :description, :created_at, :updated_at, :state, :project_id, :author_id, :confidential].each do |attr| data[attr.to_s] = safely_read_attribute_for_elasticsearch(attr) end + data['assignee_id'] = safely_read_attribute_for_elasticsearch(:assignee_ids) + data end diff --git a/app/models/concerns/elastic/notes_search.rb b/app/models/concerns/elastic/notes_search.rb index 5576bff63dbb8..7e524a59bf225 100644 --- a/app/models/concerns/elastic/notes_search.rb +++ b/app/models/concerns/elastic/notes_search.rb @@ -34,7 +34,7 @@ def as_indexed_json(options = {}) if noteable.is_a?(Issue) data['issue'] = { - assignee_id: noteable.assignee_id, + assignee_id: noteable.assignee_ids, author_id: noteable.author_id, confidential: noteable.confidential } diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index b61b5585ca1fd..7e843207fdd4c 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -26,8 +26,8 @@ module Issuable cache_markdown_field :description, issuable_state_filter_enabled: true belongs_to :author, class_name: "User" - belongs_to :assignee, class_name: "User" belongs_to :updated_by, class_name: "User" + belongs_to :last_edited_by, class_name: 'User' belongs_to :milestone has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do def authors_loaded? @@ -65,11 +65,8 @@ def award_emojis_loaded? validates :title, presence: true, length: { maximum: 255 } scope :authored, ->(user) { where(author_id: user) } - scope :assigned_to, ->(u) { where(assignee_id: u.id)} scope :recent, -> { reorder(id: :desc) } scope :order_position_asc, -> { reorder(position: :asc) } - scope :assigned, -> { where("assignee_id IS NOT NULL") } - scope :unassigned, -> { where("assignee_id IS NULL") } scope :of_projects, ->(ids) { where(project_id: ids) } scope :of_milestones, ->(ids) { where(milestone_id: ids) } scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) } @@ -95,23 +92,14 @@ def award_emojis_loaded? attr_mentionable :description participant :author - participant :assignee participant :notes_with_associations strip_attributes :title acts_as_paranoid - after_save :update_assignee_cache_counts, if: :assignee_id_changed? after_save :record_metrics, unless: :imported? - def update_assignee_cache_counts - # make sure we flush the cache for both the old *and* new assignees(if they exist) - previous_assignee = User.find_by_id(assignee_id_was) if assignee_id_was - previous_assignee&.update_cache_counts - assignee&.update_cache_counts - end - # We want to use optimistic lock for cases when only title or description are involved # http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html def locking_enabled? @@ -248,10 +236,6 @@ def new? today? && created_at == updated_at end - def is_being_reassigned? - assignee_id_changed? - end - def open? opened? || reopened? end @@ -280,7 +264,11 @@ def to_hook_data(user) # DEPRECATED repository: project.hook_attrs.slice(:name, :url, :description, :homepage) } - hook_data[:assignee] = assignee.hook_attrs if assignee + if self.is_a?(Issue) + hook_data[:assignees] = assignees.map(&:hook_attrs) if assignees.any? + else + hook_data[:assignee] = assignee.hook_attrs if assignee + end hook_data end @@ -342,11 +330,6 @@ def can_move?(*) false end - def assignee_or_author?(user) - # We're comparing IDs here so we don't need to load any associations. - author_id == user.id || assignee_id == user.id - end - def record_metrics metrics = self.metrics || create_metrics metrics.record! diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index f449229864d44..a3472af5c55c9 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -40,7 +40,7 @@ def elapsed_days def issues_visible_to_user(user) memoize_per_user(user, :issues_visible_to_user) do IssuesFinder.new(user, issues_finder_params) - .execute.where(milestone_id: milestoneish_ids) + .execute.includes(:assignees).where(milestone_id: milestoneish_ids) end end diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb index 0afbca2cb325e..538615130a762 100644 --- a/app/models/global_milestone.rb +++ b/app/models/global_milestone.rb @@ -36,7 +36,7 @@ def self.states_count(projects) closed = count_by_state(milestones_by_state_and_title, 'closed') all = milestones_by_state_and_title.map { |(_, title), _| title }.uniq.count - { + { opened: opened, closed: closed, all: all @@ -86,7 +86,7 @@ def closed? end def issues - @issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignee, :labels) + @issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignees, :labels) end def merge_requests @@ -94,7 +94,7 @@ def merge_requests end def participants - @participants ||= milestones.includes(:participants).map(&:participants).flatten.compact.uniq + @participants ||= milestones.map(&:participants).flatten.uniq end def labels diff --git a/app/models/issue.rb b/app/models/issue.rb index 28a6c1173b78e..06da4c7a2b5b4 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -32,10 +32,17 @@ class Issue < ActiveRecord::Base has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all + has_many :issue_assignees + has_many :assignees, class_name: "User", through: :issue_assignees + validates :project, presence: true scope :in_projects, ->(project_ids) { where(project_id: project_ids) } + scope :assigned, -> { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') } + scope :unassigned, -> { where('NOT EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') } + scope :assigned_to, ->(u) { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = ? AND issue_id = issues.id)', u.id)} + scope :without_due_date, -> { where(due_date: nil) } scope :due_before, ->(date) { where('issues.due_date < ?', date) } scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) } @@ -47,13 +54,15 @@ class Issue < ActiveRecord::Base scope :created_after, -> (datetime) { where("created_at >= ?", datetime) } - scope :include_associations, -> { includes(:assignee, :labels, project: :namespace) } + scope :include_associations, -> { includes(:labels, project: :namespace) } after_save :expire_etag_cache attr_spammable :title, spam_title: true attr_spammable :description, spam_description: true + participant :assignees + state_machine :state, initial: :opened do event :close do transition [:reopened, :opened] => :closed @@ -73,10 +82,14 @@ class Issue < ActiveRecord::Base end def hook_attrs + assignee_ids = self.assignee_ids + attrs = { total_time_spent: total_time_spent, human_total_time_spent: human_total_time_spent, - human_time_estimate: human_time_estimate + human_time_estimate: human_time_estimate, + assignee_ids: assignee_ids, + assignee_id: assignee_ids.first # This key is deprecated } attributes.merge!(attrs) @@ -126,6 +139,22 @@ def self.order_by_position_and_priority "id DESC") end + # Returns a Hash of attributes to be used for Twitter card metadata + def card_attributes + { + 'Author' => author.try(:name), + 'Assignee' => assignee_list + } + end + + def assignee_or_author?(user) + author_id == user.id || assignees.exists?(user.id) + end + + def assignee_list + assignees.map(&:name).to_sentence + end + # `from` argument can be a Namespace or Project. def to_reference(from = nil, full: false) reference = "#{self.class.reference_prefix}#{iid}" @@ -268,7 +297,7 @@ def readable_by?(user) true elsif confidential? author == user || - assignee == user || + assignees.include?(user) || project.team.member?(user, Gitlab::Access::REPORTER) else project.public? || diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb new file mode 100644 index 0000000000000..b94b55bb1d1ec --- /dev/null +++ b/app/models/issue_assignee.rb @@ -0,0 +1,29 @@ +class IssueAssignee < ActiveRecord::Base + extend Gitlab::CurrentSettings + + belongs_to :issue + belongs_to :assignee, class_name: "User", foreign_key: :user_id + + after_create :update_assignee_cache_counts + after_destroy :update_assignee_cache_counts + + # EE-specific + after_create :update_elasticsearch_index + after_destroy :update_elasticsearch_index + # EE-specific + + def update_assignee_cache_counts + assignee&.update_cache_counts + end + + def update_elasticsearch_index + if current_application_settings.elasticsearch_indexing? + ElasticIndexerWorker.perform_async( + :update, + 'Issue', + issue.id, + changed_fields: ['assignee_ids'] + ) + end + end +end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index cf176b98fdf18..81c2fe2fdede9 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -22,6 +22,8 @@ class MergeRequest < ActiveRecord::Base has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all + belongs_to :assignee, class_name: "User" + serialize :merge_params, Hash after_create :ensure_merge_request_diff, unless: :importing? @@ -119,10 +121,15 @@ class MergeRequest < ActiveRecord::Base scope :from_source_branches, ->(branches) { where(source_branch: branches) } scope :join_project, -> { joins(:target_project) } scope :references_project, -> { references(:target_project) } + scope :assigned, -> { where("assignee_id IS NOT NULL") } + scope :unassigned, -> { where("assignee_id IS NULL") } + scope :assigned_to, ->(u) { where(assignee_id: u.id)} participant :approvers_left + participant :assignee after_save :keep_around_commit + after_save :update_assignee_cache_counts, if: :assignee_id_changed? def self.reference_prefix '!' @@ -184,6 +191,30 @@ def self.wip_title(title) work_in_progress?(title) ? title : "WIP: #{title}" end + def update_assignee_cache_counts + # make sure we flush the cache for both the old *and* new assignees(if they exist) + previous_assignee = User.find_by_id(assignee_id_was) if assignee_id_was + previous_assignee&.update_cache_counts + assignee&.update_cache_counts + end + + # Returns a Hash of attributes to be used for Twitter card metadata + def card_attributes + { + 'Author' => author.try(:name), + 'Assignee' => assignee.try(:name) + } + end + + # This method is needed for compatibility with issues to not mess view and other code + def assignees + Array(assignee) + end + + def assignee_or_author?(user) + author_id == user.id || assignee_id == user.id + end + # `from` argument can be a Namespace or Project. def to_reference(from = nil, full: false) reference = "#{self.class.reference_prefix}#{iid}" diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 66f017d057fe0..7c5ceb28d1cd1 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -23,7 +23,6 @@ class Milestone < ActiveRecord::Base has_many :issues has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues has_many :merge_requests - has_many :participants, -> { distinct.reorder('users.name') }, through: :issues, source: :assignee has_many :events, as: :target, dependent: :destroy scope :active, -> { with_state(:active) } @@ -109,6 +108,10 @@ def self.upcoming_ids_by_projects(projects) end end + def participants + User.joins(assigned_issues: :milestone).where("milestones.id = ?", id) + end + def self.sort(method) case method.to_s when 'due_date_asc' diff --git a/app/models/note.rb b/app/models/note.rb index dc8d60b7e0cff..a7d15e6a701cb 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -19,6 +19,11 @@ class Note < ActiveRecord::Base cache_markdown_field :note, pipeline: :note, issuable_state_filter_enabled: true + # Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with notes. + # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10392/diffs#note_28719102 + alias_attribute :last_edited_at, :updated_at + alias_attribute :last_edited_by, :updated_by + # Attribute containing rendered and redacted Markdown as generated by # Banzai::ObjectRenderer. attr_accessor :redacted_note_html @@ -39,6 +44,7 @@ class Note < ActiveRecord::Base belongs_to :noteable, polymorphic: true, touch: true belongs_to :author, class_name: "User" belongs_to :updated_by, class_name: "User" + belongs_to :last_edited_by, class_name: 'User' has_many :todos, dependent: :destroy has_many :events, as: :target, dependent: :destroy diff --git a/app/models/snippet.rb b/app/models/snippet.rb index d6ac49c570b4c..0dfb6da484304 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -13,6 +13,11 @@ class Snippet < ActiveRecord::Base cache_markdown_field :title, pipeline: :single_line cache_markdown_field :content + # Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with snippets. + # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10392/diffs#note_28719102 + alias_attribute :last_edited_at, :updated_at + alias_attribute :last_edited_by, :updated_by + # If file_name changes, it invalidates content alias_method :default_content_html_invalidator, :content_html_invalidated? def content_html_invalidated? diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 729a2b6be91bd..789843fd34e5d 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -1,6 +1,6 @@ class SystemNoteMetadata < ActiveRecord::Base ICON_TYPES = %w[ - commit merge confidential visible label assignee cross_reference + commit description merge confidential visible label assignee cross_reference title time_tracking branch milestone discussion task moved opened closed merged approved unapproved ].freeze diff --git a/app/models/user.rb b/app/models/user.rb index 00fe0803d0ba2..85ca243525eff 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -25,6 +25,7 @@ class User < ActiveRecord::Base default_value_for :hide_no_password, false default_value_for :project_view, :files default_value_for :notified_of_own_activity, false + default_value_for :preferred_language, I18n.default_locale attr_encrypted :otp_secret, key: Gitlab::Application.secrets.otp_key_base, @@ -89,7 +90,8 @@ class User < ActiveRecord::Base has_many :merge_requests, dependent: :destroy, foreign_key: :author_id has_many :events, dependent: :destroy, foreign_key: :author_id has_many :subscriptions, dependent: :destroy - has_many :recent_events, -> { order "id DESC" }, foreign_key: :author_id, class_name: "Event" + has_many :recent_events, -> { order "id DESC" }, foreign_key: :author_id, class_name: "Event" + has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy has_one :abuse_report, dependent: :destroy, foreign_key: :user_id has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport" @@ -109,6 +111,10 @@ class User < ActiveRecord::Base has_many :protected_branch_push_access_levels, dependent: :destroy, class_name: ProtectedBranch::PushAccessLevel has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id + has_many :issue_assignees + has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue + has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" + # Issues that a user owns are expected to be moved to the "ghost" user before # the user is destroyed. If the user owns any issues during deletion, this # should be treated as an exceptional condition. diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb index 86ac513b3c07e..070b0c35e3673 100644 --- a/app/presenters/projects/settings/deploy_keys_presenter.rb +++ b/app/presenters/projects/settings/deploy_keys_presenter.rb @@ -48,6 +48,17 @@ def any_available_public_keys_enabled? available_public_keys.any? end + def as_json + serializer = DeployKeySerializer.new + opts = { user: current_user } + + { + enabled_keys: serializer.represent(enabled_keys, opts), + available_project_keys: serializer.represent(available_project_keys, opts), + public_keys: serializer.represent(available_public_keys, opts) + } + end + def to_partial_path 'projects/deploy_keys/index' end diff --git a/app/serializers/README.md b/app/serializers/README.md new file mode 100644 index 0000000000000..0337f88db5f65 --- /dev/null +++ b/app/serializers/README.md @@ -0,0 +1,325 @@ +# Serializers + +This is a documentation for classes located in `app/serializers` directory. + +In GitLab, we use [grape-entities][grape-entity-project], accompanied by a +serializer, to convert a Ruby object to its JSON representation. + +Serializers are typically used in controllers to build a JSON response +that is usually consumed by a frontend code. + +## Why using a serializer is important? + +Using serializers, instead of `to_json` method, has several benefits: + +* it helps to prevent exposure of a sensitive data stored in the database +* it makes it easier to test what should and should not be exposed +* it makes it easier to reuse serialization entities that are building blocks +* it makes it easier to move complexity from controllers to easily testable + classes +* it encourages hiding complexity behind intentions-revealing interfaces +* it makes it easier to take care about serialization performance concerns +* it makes it easier to reduce merge conflicts between CE -> EE +* it makes it easier to benefit from domain driven development techniques + +## What is a serializer? + +A serializer is a class that encapsulates all business rules for building a +JSON response using serialization entities. + +It is designed to be testable and to support passing additional context from +the controller. + +## What is a serialization entity? + +Entities are lightweight structures that allow to represent domain models +in a consistent and abstracted way, and reuse them as building blocks to +create a payload. + +Entities located in `app/serializers` are usually derived from a +[`Grape::Entity`][grape-entity-class] class. + +Serialization entities that do require to have a knowledge about specific +elements of the request, need to mix `RequestAwareEntity` in. + +A serialization entity usually maps a domain model class into its JSON +representation. It rarely happens that a serialization entity exists without +a corresponding domain model class. As an example, we have an `Issue` class and +a corresponding `IssueSerializer`. + +Serialization entites are designed to reuse other serialization entities, which +is a convenient way to create a multi-level JSON representation of a piece of +a domain model you want to serialize. + +See [documentation for Grape Entites][grape-entity-readme] for more details. + +## How to implement a serializer? + +### Base implementation + +In order to effectively implement a serializer it is necessary to create a new +class in `app/serializers`. See existing serializers as an example. + +A new serializer should inherit from a `BaseSerializer` class. It is necessary +to specify which serialization entity will be used to serialize a resource. + +```ruby +class MyResourceSerializer < BaseSerialize + entity MyResourceEntity +end +``` + +The example above shows how a most simple serializer can look like. + +Given that the entity `MyResourceEntity` exists, you can now use +`MyResourceSerializer` in the controller by creating an instance of it, and +calling `MyResourceSerializer#represent(resource)` method. + +Note that a `resource` can be either a single object, an array of objects or an +`ActiveRecord::Relation` object. A serialization entity should be smart enough +to accurately represent each of these. + +It should not be necessary to use `Enumerable#map`, and it should be avoided +from the performance reasons. + +### Choosing what gets serialized + +It often happens that you might want to use the same serializer in many places, +but sometimes the intention is to only expose a small subset of object's +attributes in one place, and a different subset in another. + +`BaseSerializer#represent(resource, opts = {})` method can take an additional +hash argument, `opts`, that defines what is going to be serialized. + +`BaseSerializer` will pass these options to a serialization entity. See +how it is [documented in the upstream project][grape-entity-only]. + +With this approach you can extend the serializer to respond to methods that will +create a JSON response according to your needs. + +```ruby +class PipelineSerializer < BaseSerializer + entity PipelineEntity + + def represent_details(resource) + represent(resource, only: [:details]) + end + + def represent_status(resource) + represent(resource, only: [:status]) + end +end +``` + +It is possible to use `only` and `except` keywords. Both keywords do support +nested attributes, like `except: [:id, { user: [:id] }]`. + +Passing `only` and `except` to the `represent` method from a controller is +possible, but it defies principles of encapsulation and testability, and it is +better to avoid it, and to add a specific method to the serializer instead. + +### Reusing serialization entities from the API + +Public API in GitLab is implemented using [Grape][grape-project]. + +Under the hood it also uses [`Grape::Entity`][grape-entity-class] classes. +This means that it is possible to reuse these classes to implement internal +serializers. + +You can either use such entity directly: + +```ruby +class MyResourceSerializer < BaseSerializer + entity API::Entities::SomeEntity +end +``` + +Or derive a new serialization entity class from it: + +```ruby +class MyEntity < API::Entities::SomeEntity + include RequestAwareEntity + + unexpose :something +end +``` + +It might be a good idea to write specs for entities that do inherit from +the API, because when API payloads are changed / extended, it is easy to forget +about the impact on the internal API through a serializer that reuses API +entities. + +It is usually safe to do that, because API entities rarely break backward +compatibility, but additional exposure may have a performance impact when API +gets extended significantly. Write tests that check if only necessary data is +exposed. + +## How to write tests for a serializer? + +Like every other class in the project, creating a serializer warrants writing +tests for it. + +It is usually a good idea to test each public method in the serializer against +a valid payload. `BaseSerializer#represent` returns a hash, so it is possible +to use usual RSpec matchers like `include`. + +Sometimes, when the payload is large, it makes sense to validate it entirely +using `match_response_schema` matcher along with a new fixture that can be +stored in `spec/fixtures/api/schemas/`. This matcher is using a `json-schema` +gem, which is quite flexible, see a [documentation][json-schema-gem] for it. + +## How to use a serializer in a controller? + +Once a new serializer is implemented, it is possible to use it in a controller. + +Create an instance of the serializer and render the response. + +```ruby +def index + format.json do + render json: MyResourceSerializer + .new(current_user: @current_user) + .represent_details(@project.resources) + nd +end +``` + +If it is necessary to include additional information in the payload, it is +possible to extend what is going to be rendered, the usual way: + +```ruby +def index + format.json do + render json: { + resources: MyResourceSerializer + .new(current_user: @current_user) + .represent_details(@project.resources), + count: @project.resources.count + } + nd +end +``` + +Note that in these examples an additional context is being passed to the +serializer (`current_user: @current_user`). + +## How to pass an additional context from the controller? + +It is possible to pass an additional context from a controller to a +serializer and each serialization entity that is used in the process. + +Serialization entities that do require an additional context have +`RequestAwareEntity` concern mixed in. This piece of the code exposes a method +called `request` in every serialization entity that is instantiated during +serialization. + +An object returned by this method is an instance of `EntityRequest`, which +behaves like an `OpenStruct` object, with the difference that it will raise +an error if an unknown method is called. + +In other words, in the previous example, `request` method will return an +instance of `EntityRequest` that responds to `current_user` method. It will be +available in every serialization entity instantiated by `MyResourceSerializer`. + +`EntityRequest` is a workaround for [#20045][issue-20045] and is meant to be +refactored soon. Please avoid passing an additional context that is not +required by a serialization entity. + +At the moment, the context that is passed to entities most often is +`current_user` and `project`. + +## How is this related to using presenters? + +Payload created by a serializer is usually a representation of the backed code, +combined with the current request data. Therefore, technically, serializers +are presenters that create payload consumed by a frontend code, usually Vue +components. + +In GitLab, it is possible to use [presenters][presenters-readme], but +`BaseSerializer` still needs to learn how to use it, see [#30898][issue-30898]. + +It is possible to use presenters when serializer is used to represent only +a single object. It is not supported when `ActiveRecord::Relation` is being +serialized. + +```ruby +MyObjectSerializer.new.represent(object.present) +``` + +## Best practices + +1. Do not invoke a serializer from within a serialization entity. + + If you need to use a serializer from within a serialization entity, it is + possible that you are missing a class for an important domain concept. + + Consider creating a new domain class and a corresponding serialization + entity for it. + +1. Use only one approach to switch behavior of the serializer. + + It is possible to use a few approaches to switch a behavior of the + serializer. Most common are using a [Fluent Interface][fluent-interface] + and creating a separate `represent_something` methods. + + Whatever you choose, it might be better to use only one approach at a time. + +1. Do not forget about creating specs for serialization entities. + + Writing tests for the serializer indeed does cover testing a behavior of + serialization entities that the serializer instantiates. However it might + be a good idea to write separate tests for entities as well, because these + are meant to be reused in different serializers, and a serializer can + change a behavior of a serialization entity. + +1. Use `ActiveRecord::Relation` where possible + + Using an `ActiveRecord::Relation` might help from the performance perspective. + +1. Be diligent about passing an additional context from the controller. + + Using `EntityRequest` and `RequestAwareEntity` is a workaround for the lack + of high-level mechanism. It is meant to be refactored, and current + implementation is error prone. Imagine the situation that one serialization + entity requires `request.user` attribute, but the second one wants + `request.current_user`. When it happens that these two entities are used in + the same serialization request, you might need to pass both parameters to + the serializer, which is obviously not a perfect situation. + + When in doubt, pass only `current_user` and `project` if these are required. + +1. Keep performance concerns in mind + + Using a serializer incorrectly can have significant impact on the + performance. + + Because serializers are technically presenters, it is often necessary + to calculate, for example, paths to various controller-actions. + Since using URL helpers usually involve passing `project` and `namespace` + adding `includes(project: :namespace)` in the serializer, can help to avoid + N+1 queries. + + Also, try to avoid using `Enumerable#map` or other methods that will + execute a database query eagerly. + +1. Avoid passing `only` and `except` from the controller. +1. Write tests checking for N+1 queries. +1. Write controller tests for actions / formats using serializers. +1. Write tests that check if only necessary data is exposed. +1. Write tests that check if no sensitive data is exposed. + +## Future + +* [Next iteration of serializers][issue-27569] + +[grape-project]: http://www.ruby-grape.org +[grape-entity-project]: https://github.com/ruby-grape/grape-entity +[grape-entity-readme]: https://github.com/ruby-grape/grape-entity/blob/master/README.md +[grape-entity-class]: https://github.com/ruby-grape/grape-entity/blob/master/lib/grape_entity/entity.rb +[grape-entity-only]: https://github.com/ruby-grape/grape-entity/blob/master/README.md#returning-only-the-fields-you-want +[presenters-readme]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/presenters/README.md +[fluent-interface]: https://en.wikipedia.org/wiki/Fluent_interface +[json-schema-gem]: https://github.com/ruby-json-schema/json-schema +[issue-20045]: https://gitlab.com/gitlab-org/gitlab-ce/issues/20045 +[issue-30898]: https://gitlab.com/gitlab-org/gitlab-ce/issues/30898 +[issue-27569]: https://gitlab.com/gitlab-org/gitlab-ce/issues/27569 diff --git a/app/serializers/analytics_stage_entity.rb b/app/serializers/analytics_stage_entity.rb index 69bf693de8d8e..564612202b5ec 100644 --- a/app/serializers/analytics_stage_entity.rb +++ b/app/serializers/analytics_stage_entity.rb @@ -2,6 +2,7 @@ class AnalyticsStageEntity < Grape::Entity include EntityDateHelper expose :title + expose :name expose :legend expose :description diff --git a/app/serializers/analytics_summary_entity.rb b/app/serializers/analytics_summary_entity.rb index 91803ec07f5ff..9c37afd53e1ac 100644 --- a/app/serializers/analytics_summary_entity.rb +++ b/app/serializers/analytics_summary_entity.rb @@ -1,7 +1,4 @@ class AnalyticsSummaryEntity < Grape::Entity expose :value, safe: true - - expose :title do |object| - object.title.pluralize(object.value) - end + expose :title end diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb new file mode 100644 index 0000000000000..d75a83d0fa529 --- /dev/null +++ b/app/serializers/deploy_key_entity.rb @@ -0,0 +1,14 @@ +class DeployKeyEntity < Grape::Entity + expose :id + expose :user_id + expose :title + expose :fingerprint + expose :can_push + expose :destroyed_when_orphaned?, as: :destroyed_when_orphaned + expose :almost_orphaned?, as: :almost_orphaned + expose :created_at + expose :updated_at + expose :projects, using: ProjectEntity do |deploy_key| + deploy_key.projects.select { |project| options[:user].can?(:read_project, project) } + end +end diff --git a/app/serializers/deploy_key_serializer.rb b/app/serializers/deploy_key_serializer.rb new file mode 100644 index 0000000000000..8f849eb88b717 --- /dev/null +++ b/app/serializers/deploy_key_serializer.rb @@ -0,0 +1,3 @@ +class DeployKeySerializer < BaseSerializer + entity DeployKeyEntity +end diff --git a/app/serializers/issuable_entity.rb b/app/serializers/issuable_entity.rb index 29aecb50849fc..65b204d4dd27b 100644 --- a/app/serializers/issuable_entity.rb +++ b/app/serializers/issuable_entity.rb @@ -1,7 +1,6 @@ class IssuableEntity < Grape::Entity expose :id expose :iid - expose :assignee_id expose :author_id expose :description expose :lock_version diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index 46cdf2c0aa51d..dc85232499dec 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -1,6 +1,7 @@ class IssueEntity < IssuableEntity expose :branch_name expose :confidential + expose :assignees, using: API::Entities::UserBasic expose :due_date expose :moved_to_id expose :project_id diff --git a/app/serializers/label_entity.rb b/app/serializers/label_entity.rb index 304fd9de08f8e..ad565654342ba 100644 --- a/app/serializers/label_entity.rb +++ b/app/serializers/label_entity.rb @@ -6,6 +6,7 @@ class LabelEntity < Grape::Entity expose :group_id expose :project_id expose :template + expose :text_color expose :created_at expose :updated_at end diff --git a/app/serializers/label_serializer.rb b/app/serializers/label_serializer.rb new file mode 100644 index 0000000000000..ad6ba8c46c99e --- /dev/null +++ b/app/serializers/label_serializer.rb @@ -0,0 +1,7 @@ +class LabelSerializer < BaseSerializer + entity LabelEntity + + def represent_appearance(resource) + represent(resource, { only: [:id, :title, :color, :text_color] }) + end +end diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_entity.rb index 59c94c613f6c0..d2a9d76108ddd 100644 --- a/app/serializers/merge_request_entity.rb +++ b/app/serializers/merge_request_entity.rb @@ -1,5 +1,6 @@ class MergeRequestEntity < IssuableEntity expose :approvals_before_merge + expose :assignee_id expose :in_progress_merge_commit_sha expose :locked_at expose :merge_commit_sha diff --git a/app/serializers/project_entity.rb b/app/serializers/project_entity.rb new file mode 100644 index 0000000000000..a471a7e6a8825 --- /dev/null +++ b/app/serializers/project_entity.rb @@ -0,0 +1,14 @@ +class ProjectEntity < Grape::Entity + include RequestAwareEntity + + expose :id + expose :name + + expose :full_path do |project| + namespace_project_path(project.namespace, project) + end + + expose :full_name do |project| + project.full_name + end +end diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb index 60891cbb255fb..40ff9b8b8679a 100644 --- a/app/services/issuable/bulk_update_service.rb +++ b/app/services/issuable/bulk_update_service.rb @@ -7,10 +7,14 @@ def execute(type) ids = params.delete(:issuable_ids).split(",") items = model_class.where(id: ids) - %i(state_event milestone_id assignee_id add_label_ids remove_label_ids subscription_event).each do |key| + %i(state_event milestone_id assignee_id assignee_ids add_label_ids remove_label_ids subscription_event).each do |key| params.delete(key) unless params[key].present? end + if params[:assignee_ids] == [IssuableFinder::NONE.to_s] + params[:assignee_ids] = [] + end + items.each do |issuable| next unless can?(current_user, :"update_#{type}", issuable) diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 97058db922923..2802ae36226fd 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -1,11 +1,6 @@ class IssuableBaseService < BaseService private - def create_assignee_note(issuable) - SystemNoteService.change_assignee( - issuable, issuable.project, current_user, issuable.assignee) - end - def create_milestone_note(issuable) SystemNoteService.change_milestone( issuable, issuable.project, current_user, issuable.milestone) @@ -24,6 +19,10 @@ def create_title_change_note(issuable, old_title) issuable, issuable.project, current_user, old_title) end + def create_description_change_note(issuable) + SystemNoteService.change_description(issuable, issuable.project, current_user) + end + def create_branch_change_note(issuable, branch_type, old_branch, new_branch) SystemNoteService.change_branch( issuable, issuable.project, current_user, branch_type, @@ -53,6 +52,7 @@ def filter_params(issuable) params.delete(:add_label_ids) params.delete(:remove_label_ids) params.delete(:label_ids) + params.delete(:assignee_ids) params.delete(:assignee_id) params.delete(:due_date) end @@ -77,7 +77,7 @@ def filter_assignee(issuable) def assignee_can_read?(issuable, assignee_id) new_assignee = User.find_by_id(assignee_id) - return false unless new_assignee.present? + return false unless new_assignee ability_name = :"read_#{issuable.to_ability_name}" resource = issuable.persisted? ? issuable : project @@ -207,6 +207,7 @@ def update(issuable) filter_params(issuable) old_labels = issuable.labels.to_a old_mentioned_users = issuable.mentioned_users.to_a + old_assignees = issuable.assignees.to_a label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids) params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids) @@ -214,6 +215,10 @@ def update(issuable) if issuable.changed? || params.present? issuable.assign_attributes(params.merge(updated_by: current_user)) + if has_title_or_description_changed?(issuable) + issuable.assign_attributes(last_edited_at: Time.now, last_edited_by: current_user) + end + before_update(issuable) if issuable.with_transaction_returning_status { issuable.save } @@ -222,7 +227,13 @@ def update(issuable) handle_common_system_notes(issuable, old_labels: old_labels) end - handle_changes(issuable, old_labels: old_labels, old_mentioned_users: old_mentioned_users) + handle_changes( + issuable, + old_labels: old_labels, + old_mentioned_users: old_mentioned_users, + old_assignees: old_assignees + ) + after_update(issuable) issuable.create_new_cross_references!(current_user) execute_hooks(issuable, 'update') @@ -236,6 +247,10 @@ def labels_changing?(old_label_ids, new_label_ids) old_label_ids.sort != new_label_ids.sort end + def has_title_or_description_changed?(issuable) + issuable.title_changed? || issuable.description_changed? + end + def change_state(issuable) case params.delete(:state_event) when 'reopen' @@ -272,7 +287,7 @@ def toggle_award(issuable) end end - def has_changes?(issuable, old_labels: []) + def has_changes?(issuable, old_labels: [], old_assignees: []) valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch] attrs_changed = valid_attrs.any? do |attr| @@ -281,7 +296,9 @@ def has_changes?(issuable, old_labels: []) labels_changed = issuable.labels != old_labels - attrs_changed || labels_changed + assignees_changed = issuable.assignees != old_assignees + + attrs_changed || labels_changed || assignees_changed end def handle_common_system_notes(issuable, old_labels: []) @@ -289,6 +306,10 @@ def handle_common_system_notes(issuable, old_labels: []) create_title_change_note(issuable, issuable.previous_changes['title'].first) end + if issuable.previous_changes.include?('description') + create_description_change_note(issuable) + end + if issuable.previous_changes.include?('description') && issuable.tasks? create_task_status_note(issuable) end diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index ee1b40db71891..eedbfa724ff66 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -9,11 +9,30 @@ def hook_data(issue, action) private + def create_assignee_note(issue, old_assignees) + SystemNoteService.change_issue_assignees( + issue, issue.project, current_user, old_assignees) + end + def execute_hooks(issue, action = 'open') issue_data = hook_data(issue, action) hooks_scope = issue.confidential? ? :confidential_issue_hooks : :issue_hooks issue.project.execute_hooks(issue_data, hooks_scope) issue.project.execute_services(issue_data, hooks_scope) end + + def filter_assignee(issuable) + return if params[:assignee_ids].blank? + + assignee_ids = params[:assignee_ids].select { |assignee_id| assignee_can_read?(issuable, assignee_id) } + + if params[:assignee_ids].map(&:to_s) == [IssuableFinder::NONE] + params[:assignee_ids] = [] + elsif assignee_ids.any? + params[:assignee_ids] = assignee_ids + else + params.delete(:assignee_ids) + end + end end end diff --git a/app/services/issues/export_csv_service.rb b/app/services/issues/export_csv_service.rb index fe76c0f1f0511..333437dfc3020 100644 --- a/app/services/issues/export_csv_service.rb +++ b/app/services/issues/export_csv_service.rb @@ -21,7 +21,7 @@ def email(user, project) def csv_builder @csv_builder ||= - CsvBuilder.new(@issues.includes(:author, :assignee), header_to_value_hash) + CsvBuilder.new(@issues.includes(:author, :assignees), header_to_value_hash) end private @@ -35,8 +35,8 @@ def header_to_value_hash 'Description' => 'description', 'Author' => 'author_name', 'Author Username' => -> (issue) { issue.author&.username }, - 'Assignee' => 'assignee_name', - 'Assignee Username' => -> (issue) { issue.assignee&.username }, + 'Assignee' => -> (issue) { issue.assignees.map(&:name).join(', ') }, + 'Assignee Username' => -> (issue) { issue.assignees.map(&:username).join(', ') }, 'Confidential' => -> (issue) { issue.confidential? ? 'Yes' : 'No' }, 'Due Date' => -> (issue) { issue.due_date&.to_s(:csv) }, 'Created At (UTC)' => -> (issue) { issue.created_at&.to_s(:csv) }, diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index b7fe5cb168b97..cd9f9a4a16e02 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -12,8 +12,12 @@ def before_update(issue) spam_check(issue, current_user) end - def handle_changes(issue, old_labels: [], old_mentioned_users: []) - if has_changes?(issue, old_labels: old_labels) + def handle_changes(issue, options) + old_labels = options[:old_labels] || [] + old_mentioned_users = options[:old_mentioned_users] || [] + old_assignees = options[:old_assignees] || [] + + if has_changes?(issue, old_labels: old_labels, old_assignees: old_assignees) todo_service.mark_pending_todos_as_done(issue, current_user) end @@ -26,9 +30,9 @@ def handle_changes(issue, old_labels: [], old_mentioned_users: []) create_milestone_note(issue) end - if issue.previous_changes.include?('assignee_id') - create_assignee_note(issue) - notification_service.reassigned_issue(issue, current_user) + if issue.assignees != old_assignees + create_assignee_note(issue, old_assignees) + notification_service.reassigned_issue(issue, current_user, old_assignees) todo_service.reassigned_issue(issue, current_user) end diff --git a/app/services/members/authorized_destroy_service.rb b/app/services/members/authorized_destroy_service.rb index 1711be7211c6f..a85b9465c8483 100644 --- a/app/services/members/authorized_destroy_service.rb +++ b/app/services/members/authorized_destroy_service.rb @@ -26,15 +26,22 @@ def execute def unassign_issues_and_merge_requests(member) if member.is_a?(GroupMember) - IssuesFinder.new(user, group_id: member.source_id, assignee_id: member.user_id). - execute. - update_all(assignee_id: nil) + issue_ids = IssuesFinder.new(user, group_id: member.source_id, assignee_id: member.user_id). + execute.pluck(:id) + + IssueAssignee.destroy_all(issue_id: issue_ids, user_id: member.user_id) + MergeRequestsFinder.new(user, group_id: member.source_id, assignee_id: member.user_id). execute. update_all(assignee_id: nil) else project = member.source - project.issues.opened.assigned_to(member.user).update_all(assignee_id: nil) + + IssueAssignee.destroy_all( + user_id: member.user_id, + issue_id: project.issues.opened.assigned_to(member.user).select(:id) + ) + project.merge_requests.opened.assigned_to(member.user).update_all(assignee_id: nil) member.user.update_cache_counts end diff --git a/app/services/merge_requests/assign_issues_service.rb b/app/services/merge_requests/assign_issues_service.rb index 066efa1acc3dd..8c6c484102039 100644 --- a/app/services/merge_requests/assign_issues_service.rb +++ b/app/services/merge_requests/assign_issues_service.rb @@ -4,7 +4,7 @@ def assignable_issues @assignable_issues ||= begin if current_user == merge_request.author closes_issues.select do |issue| - !issue.is_a?(ExternalIssue) && !issue.assignee_id? && can?(current_user, :admin_issue, issue) + !issue.is_a?(ExternalIssue) && !issue.assignees.present? && can?(current_user, :admin_issue, issue) end else [] @@ -14,7 +14,7 @@ def assignable_issues def execute assignable_issues.each do |issue| - Issues::UpdateService.new(issue.project, current_user, assignee_id: current_user.id).execute(issue) + Issues::UpdateService.new(issue.project, current_user, assignee_ids: [current_user.id]).execute(issue) end { diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 582d5c47b6603..3542a41ac831b 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -38,6 +38,11 @@ def execute_hooks(merge_request, action = 'open', oldrev = nil) private + def create_assignee_note(merge_request) + SystemNoteService.change_assignee( + merge_request, merge_request.project, current_user, merge_request.assignee) + end + # Returns all origin and fork merge requests from `@project` satisfying passed arguments. def merge_requests_for(source_branch, mr_states: [:opened, :reopened]) MergeRequest diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 6a6d606a8b6c9..0fb9be6f9ae46 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -31,7 +31,10 @@ def execute(merge_request) merge_request end - def handle_changes(merge_request, old_labels: [], old_mentioned_users: []) + def handle_changes(merge_request, options) + old_labels = options[:old_labels] || [] + old_mentioned_users = options[:old_mentioned_users] || [] + if has_changes?(merge_request, old_labels: old_labels) todo_service.mark_pending_todos_as_done(merge_request, current_user) end diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb index 8bb995158de7e..988bd0a7cdbe9 100644 --- a/app/services/notification_recipient_service.rb +++ b/app/services/notification_recipient_service.rb @@ -19,9 +19,14 @@ def build_recipients(target, current_user, action: nil, previous_assignee: nil, # Re-assign is considered as a mention of the new assignee so we add the # new assignee to the list of recipients after we rejected users with # the "on mention" notification level - if [:reassign_merge_request, :reassign_issue].include?(custom_action) + case custom_action + when :reassign_merge_request recipients << previous_assignee if previous_assignee recipients << target.assignee + when :reassign_issue + previous_assignees = Array(previous_assignee) + recipients.concat(previous_assignees) + recipients.concat(target.assignees) end recipients = reject_muted_users(recipients) diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index b48afe5e4b2e3..c4022c7c8e1d8 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -68,8 +68,25 @@ def close_issue(issue, current_user) # * issue new assignee if their notification level is not Disabled # * users with custom level checked with "reassign issue" # - def reassigned_issue(issue, current_user) - reassign_resource_email(issue, issue.project, current_user, :reassigned_issue_email) + def reassigned_issue(issue, current_user, previous_assignees = []) + recipients = NotificationRecipientService.new(issue.project).build_recipients( + issue, + current_user, + action: "reassign", + previous_assignee: previous_assignees + ) + + previous_assignee_ids = previous_assignees.map(&:id) + + recipients.each do |recipient| + mailer.send( + :reassigned_issue_email, + recipient.id, + issue.id, + previous_assignee_ids, + current_user.id + ).deliver_later + end end # When we add labels to an issue we should send an email to: @@ -414,10 +431,10 @@ def mailer end def previous_record(object, attribute) - if object && attribute - if object.previous_changes.include?(attribute) - object.previous_changes[attribute].first - end + return unless object && attribute + + if object.previous_changes.include?(attribute) + object.previous_changes[attribute].first end end end diff --git a/app/services/projects/propagate_service_template.rb b/app/services/projects/propagate_service_template.rb new file mode 100644 index 0000000000000..a8ef2108492e7 --- /dev/null +++ b/app/services/projects/propagate_service_template.rb @@ -0,0 +1,103 @@ +module Projects + class PropagateServiceTemplate + BATCH_SIZE = 100 + + def self.propagate(*args) + new(*args).propagate + end + + def initialize(template) + @template = template + end + + def propagate + return unless @template.active? + + Rails.logger.info("Propagating services for template #{@template.id}") + + propagate_projects_with_template + end + + private + + def propagate_projects_with_template + loop do + batch = project_ids_batch + + bulk_create_from_template(batch) unless batch.empty? + + break if batch.size < BATCH_SIZE + end + end + + def bulk_create_from_template(batch) + service_list = batch.map do |project_id| + service_hash.values << project_id + end + + Project.transaction do + bulk_insert_services(service_hash.keys << 'project_id', service_list) + run_callbacks(batch) + end + end + + def project_ids_batch + Project.connection.select_values( + <<-SQL + SELECT id + FROM projects + WHERE NOT EXISTS ( + SELECT true + FROM services + WHERE services.project_id = projects.id + AND services.type = '#{@template.type}' + ) + AND projects.pending_delete = false + AND projects.archived = false + LIMIT #{BATCH_SIZE} + SQL + ) + end + + def bulk_insert_services(columns, values_array) + ActiveRecord::Base.connection.execute( + <<-SQL.strip_heredoc + INSERT INTO services (#{columns.join(', ')}) + VALUES #{values_array.map { |tuple| "(#{tuple.join(', ')})" }.join(', ')} + SQL + ) + end + + def service_hash + @service_hash ||= + begin + template_hash = @template.as_json(methods: :type).except('id', 'template', 'project_id') + + template_hash.each_with_object({}) do |(key, value), service_hash| + value = value.is_a?(Hash) ? value.to_json : value + + service_hash[ActiveRecord::Base.connection.quote_column_name(key)] = + ActiveRecord::Base.sanitize(value) + end + end + end + + def run_callbacks(batch) + if active_external_issue_tracker? + Project.where(id: batch).update_all(has_external_issue_tracker: true) + end + + if active_external_wiki? + Project.where(id: batch).update_all(has_external_wiki: true) + end + end + + def active_external_issue_tracker? + @template.issue_tracker? && !@template.default + end + + def active_external_wiki? + @template.type == 'ExternalWikiService' + end + end +end diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index e906f52ce33b4..2c239e123ab19 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -91,32 +91,47 @@ def extractor end desc 'Assign' - explanation do |user| - "Assigns #{user.to_reference}." if user + explanation do |users| + "Assigns #{users.map(&:to_reference).to_sentence}." if users.any? end params '@user' condition do current_user.can?(:"admin_#{issuable.to_ability_name}", project) end parse_params do |assignee_param| - extract_references(assignee_param, :user).first || - User.find_by(username: assignee_param) + users = extract_references(assignee_param, :user) + + if users.empty? + users = User.where(username: assignee_param.split(' ').map(&:strip)) + end + + users end - command :assign do |user| - @updates[:assignee_id] = user.id if user + command :assign do |users| + next if users.empty? + + if issuable.is_a?(Issue) + @updates[:assignee_ids] = users.map(&:id) + else + @updates[:assignee_id] = users.last.id + end end desc 'Remove assignee' explanation do - "Removes assignee #{issuable.assignee.to_reference}." + "Removes assignee #{issuable.assignees.first.to_reference}." end condition do issuable.persisted? && - issuable.assignee_id? && + issuable.assignees.any? && current_user.can?(:"admin_#{issuable.to_ability_name}", project) end command :unassign do - @updates[:assignee_id] = nil + if issuable.is_a?(Issue) + @updates[:assignee_ids] = [] + else + @updates[:assignee_id] = nil + end end desc 'Set milestone' diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 5c52870016bf8..3ee5c05094c5a 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -49,6 +49,44 @@ def change_assignee(noteable, project, author, assignee) create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee')) end + # Called when the assignees of an Issue is changed or removed + # + # issue - Issue object + # project - Project owning noteable + # author - User performing the change + # assignees - Users being assigned, or nil + # + # Example Note text: + # + # "removed all assignees" + # + # "assigned to @user1 additionally to @user2" + # + # "assigned to @user1, @user2 and @user3 and unassigned from @user4 and @user5" + # + # "assigned to @user1 and @user2" + # + # Returns the created Note object + def change_issue_assignees(issue, project, author, old_assignees) + body = + if issue.assignees.any? && old_assignees.any? + unassigned_users = old_assignees - issue.assignees + added_users = issue.assignees.to_a - old_assignees + + text_parts = [] + text_parts << "assigned to #{added_users.map(&:to_reference).to_sentence}" if added_users.any? + text_parts << "unassigned #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any? + + text_parts.join(' and ') + elsif old_assignees.any? + "removed all assignees" + elsif issue.assignees.any? + "assigned to #{issue.assignees.map(&:to_reference).to_sentence}" + end + + create_note(NoteSummary.new(issue, project, author, body, action: 'assignee')) + end + # Called when one or more labels on a Noteable are added and/or removed # # noteable - Noteable object @@ -261,6 +299,23 @@ def change_title(noteable, project, author, old_title) create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) end + # Called when the description of a Noteable is changed + # + # noteable - Noteable object that responds to `description` + # project - Project owning noteable + # author - User performing the change + # + # Example Note text: + # + # "changed the description" + # + # Returns the created Note object + def change_description(noteable, project, author) + body = 'changed the description' + + create_note(NoteSummary.new(noteable, project, author, body, action: 'description')) + end + # Called when the confidentiality changes # # issue - Issue object diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 3ddaf7bed5939..cfa34faab8684 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -264,9 +264,9 @@ def handle_note(note, author, skip_users = []) end def create_assignment_todo(issuable, author) - if issuable.assignee + if issuable.assignees.any? attributes = attributes_for_todo(issuable.project, issuable, author, Todo::ASSIGNED) - create_todos(issuable.assignee, attributes) + create_todos(issuable.assignees, attributes) end end diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 0afe327a539d8..8116569444946 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -414,8 +414,6 @@ %fieldset %legend Error Reporting and Logging - %p - These settings require a restart to take effect. .form-group .col-sm-offset-2.col-sm-10 .checkbox @@ -423,6 +421,7 @@ = f.check_box :sentry_enabled Enable Sentry .help-block + %p This setting requires a restart to take effect. Sentry is an error reporting and logging tool which is currently not shipped with GitLab, get it here: %a{ href: 'https://getsentry.com', target: '_blank', rel: 'noopener noreferrer' } https://getsentry.com @@ -431,6 +430,21 @@ .col-sm-10 = f.text_field :sentry_dsn, class: 'form-control' + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :clientside_sentry_enabled do + = f.check_box :clientside_sentry_enabled + Enable Clientside Sentry + .help-block + Sentry can also be used for reporting and logging clientside exceptions. + %a{ href: 'https://sentry.io/for/javascript/', target: '_blank', rel: 'noopener noreferrer' } https://sentry.io/for/javascript/ + + .form-group + = f.label :clientside_sentry_dsn, 'Clientside Sentry DSN', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :clientside_sentry_dsn, class: 'form-control' + %fieldset %legend Repository Storage .form-group diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml index 964473ee3e08c..7ba3f3f6c42a5 100644 --- a/app/views/discussions/_notes.html.haml +++ b/app/views/discussions/_notes.html.haml @@ -1,6 +1,7 @@ .discussion-notes %ul.notes{ data: { discussion_id: discussion.id } } = render partial: "shared/notes/note", collection: discussion.notes, as: :note + .flash-container - if current_user .discussion-reply-holder diff --git a/app/views/errors/omniauth_error.html.haml b/app/views/errors/omniauth_error.html.haml index 72508b9113411..20b7fa471a0a3 100644 --- a/app/views/errors/omniauth_error.html.haml +++ b/app/views/errors/omniauth_error.html.haml @@ -1,16 +1,15 @@ - content_for(:title, 'Auth Error') -%img{ :alt => "GitLab Logo", :src => image_path('logo.svg') } - %h1 - 422 + .container + = render "shared/errors/graphic_422.svg" %h3 Sign-in using #{@provider} auth failed - %hr - %p Sign-in failed because #{@error}. - %p There are couple of steps you can take: -%ul - %li Try logging in using your email - %li Try logging in using your username - %li If you have forgotten your password, try recovering it using #{ link_to "Password recovery", new_password_path(resource_name) } + %p.light.subtitle Sign-in failed because #{@error}. + + %p Try logging in using your username or email. If you have forgotten your password, try recovering it -%p If none of the options work, try contacting the GitLab administrator. + = link_to "Sign in", new_session_path(:user), class: 'btn primary' + = link_to "Recover password", new_password_path(resource_name), class: 'btn secondary' + + %hr + %p.light If none of the options work, try contacting a GitLab administrator. diff --git a/app/views/issues/_issue.atom.builder b/app/views/issues/_issue.atom.builder index 23a884480552a..2ed78bb3b6586 100644 --- a/app/views/issues/_issue.atom.builder +++ b/app/views/issues/_issue.atom.builder @@ -23,10 +23,19 @@ xml.entry do end end - if issue.assignee + if issue.assignees.any? + xml.assignees do + issue.assignees.each do |assignee| + xml.assignee do + xml.name assignee.name + xml.email assignee.public_email + end + end + end + xml.assignee do - xml.name issue.assignee.name - xml.email issue.assignee_public_email + xml.name issue.assignees.first.name + xml.email issue.assignees.first.public_email end end end diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 19473b6ab276d..afcc2b6e4f31f 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -28,9 +28,12 @@ = stylesheet_link_tag "application", media: "all" = stylesheet_link_tag "print", media: "print" + = Gon::Base.render_data + = webpack_bundle_tag "runtime" = webpack_bundle_tag "common" = webpack_bundle_tag "main" + = webpack_bundle_tag "raven" if current_application_settings.clientside_sentry_enabled - if content_for?(:page_specific_javascripts) = yield :page_specific_javascripts diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 36543edc040f9..7e011ac3e75b7 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,9 +1,7 @@ !!! 5 -%html{ lang: "en", class: "#{page_class}" } +%html{ lang: I18n.locale, class: "#{page_class}" } = render "layouts/head" %body{ class: @body_class, data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}" } } - = Gon::Base.render_data - = render "layouts/header/default", title: header_title = render 'layouts/page', sidebar: sidebar, nav: nav diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 07982e6dd920a..2018007b887e1 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -3,7 +3,6 @@ = render "layouts/head" %body.ui_charcoal.login-page.application.navless{ data: { page: body_data_page } } .page-wrap - = Gon::Base.render_data = render "layouts/header/empty" = render "layouts/broadcast" .container.navless-container diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml index 7466423a934ec..ed6731bde9540 100644 --- a/app/views/layouts/devise_empty.html.haml +++ b/app/views/layouts/devise_empty.html.haml @@ -2,7 +2,6 @@ %html{ lang: "en" } = render "layouts/head" %body.ui_charcoal.login-page.application.navless - = Gon::Base.render_data = render "layouts/header/empty" = render "layouts/broadcast" .container.navless-container diff --git a/app/views/layouts/oauth_error.html.haml b/app/views/layouts/oauth_error.html.haml new file mode 100644 index 0000000000000..34bcd2a8b3a60 --- /dev/null +++ b/app/views/layouts/oauth_error.html.haml @@ -0,0 +1,127 @@ +!!! 5 +%html{ lang: "en" } + %head + %meta{ :content => "width=device-width, initial-scale=1, maximum-scale=1", :name => "viewport" } + %title= yield(:title) + :css + body { + color: #666; + text-align: center; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + margin: auto; + font-size: 16px; + } + + .container { + margin: auto 20px; + } + + h3 { + color: #456; + font-size: 22px; + font-weight: bold; + margin-bottom: 6px; + } + + p { + max-width: 470px; + margin: 16px auto; + } + + .subtitle { + margin: 0 auto 20px; + } + + svg { + width: 280px; + height: 280px; + display: block; + margin: 40px auto; + } + + .tv-screen path { + animation: move-lines 1s linear infinite; + } + + + @keyframes move-lines { + 0% {transform: translateY(0)} + 50% {transform: translateY(-10px)} + 100% {transform: translateY(-20px)} + } + + .tv-screen path:nth-child(1) { + animation-delay: .2s + } + + .tv-screen path:nth-child(2) { + animation-delay: .4s + } + + .tv-screen path:nth-child(3) { + animation-delay: .6s + } + + .tv-screen path:nth-child(4) { + animation-delay: .8s + } + + .tv-screen path:nth-child(5) { + animation-delay: 2s + } + + .text-422 { + animation: flicker 1s infinite; + } + + @keyframes flicker { + 0% {opacity: 0.3;} + 10% {opacity: 1;} + 15% {opacity: .3;} + 20% {opacity: .5;} + 25% {opacity: 1;} + } + + .light { + color: #8D8D8D; + } + + hr { + max-width: 600px; + margin: 18px auto; + border: 0; + border-top: 1px solid #EEE; + } + + .btn { + padding: 8px 14px; + border-radius: 3px; + border: 1px solid; + display: inline-block; + text-decoration: none; + margin: 4px 8px; + font-size: 14px; + } + + .primary { + color: #fff; + background-color: #1aaa55; + border-color: #168f48; + } + + .primary:hover { + background-color: #168f48; + } + + .secondary { + color: #1aaa55; + background-color: #fff; + border-color: #1aaa55; + } + + .secondary:hover { + background-color: #f3fff8; + } + +%body + = yield diff --git a/app/views/notify/_reassigned_issuable_email.text.erb b/app/views/notify/_reassigned_issuable_email.text.erb deleted file mode 100644 index daf20a226dd7b..0000000000000 --- a/app/views/notify/_reassigned_issuable_email.text.erb +++ /dev/null @@ -1,6 +0,0 @@ -Reassigned <%= issuable.class.model_name.human.titleize %> <%= issuable.iid %> - -<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, { only_path: false }]) %> - -Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%> - to <%= "#{issuable.assignee_id ? issuable.assignee_name : 'Unassigned'}" %> diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml index c762578971a5f..eb5157ccac920 100644 --- a/app/views/notify/new_issue_email.html.haml +++ b/app/views/notify/new_issue_email.html.haml @@ -2,9 +2,9 @@ %p.details #{link_to @issue.author_name, user_url(@issue.author)} created an issue: -- if @issue.assignee_id.present? +- if @issue.assignees.any? %p - Assignee: #{@issue.assignee_name} + Assignee: #{@issue.assignee_list} - if @issue.description %div diff --git a/app/views/notify/new_issue_email.text.erb b/app/views/notify/new_issue_email.text.erb index ca5c2f2688c0c..13f1ac08e9454 100644 --- a/app/views/notify/new_issue_email.text.erb +++ b/app/views/notify/new_issue_email.text.erb @@ -2,6 +2,6 @@ New Issue was created. Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %> Author: <%= @issue.author_name %> -Assignee: <%= @issue.assignee_name %> +Assignee: <%= @issue.assignee_list %> <%= @issue.description %> diff --git a/app/views/notify/new_mention_in_issue_email.text.erb b/app/views/notify/new_mention_in_issue_email.text.erb index 457e94b48001b..f19ac3adfc7aa 100644 --- a/app/views/notify/new_mention_in_issue_email.text.erb +++ b/app/views/notify/new_mention_in_issue_email.text.erb @@ -2,6 +2,6 @@ You have been mentioned in an issue. Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %> Author: <%= @issue.author_name %> -Assignee: <%= @issue.assignee_name %> +Assignee: <%= @issue.assignee_list %> <%= @issue.description %> diff --git a/app/views/notify/reassigned_issue_email.html.haml b/app/views/notify/reassigned_issue_email.html.haml index 498ba8b83651b..ee2f40e1683a4 100644 --- a/app/views/notify/reassigned_issue_email.html.haml +++ b/app/views/notify/reassigned_issue_email.html.haml @@ -1 +1,10 @@ -= render 'reassigned_issuable_email', issuable: @issue +%p + Assignee changed + - if @previous_assignees.any? + from + %strong= @previous_assignees.map(&:name).to_sentence + to + - if @issue.assignees.any? + %strong= @issue.assignee_list + - else + %strong Unassigned diff --git a/app/views/notify/reassigned_issue_email.text.erb b/app/views/notify/reassigned_issue_email.text.erb index 710253be98429..6c357f1074a48 100644 --- a/app/views/notify/reassigned_issue_email.text.erb +++ b/app/views/notify/reassigned_issue_email.text.erb @@ -1 +1,6 @@ -<%= render 'reassigned_issuable_email', issuable: @issue %> +Reassigned Issue <%= @issue.iid %> + +<%= url_for([@issue.project.namespace.becomes(Namespace), @issue.project, @issue, { only_path: false }]) %> + +Assignee changed <%= "from #{@previous_assignees.map(&:name).to_sentence}" if @previous_assignees.any? -%> + to <%= "#{@issue.assignees.any? ? @issue.assignee_list : 'Unassigned'}" %> diff --git a/app/views/notify/reassigned_merge_request_email.html.haml b/app/views/notify/reassigned_merge_request_email.html.haml index 2a650130f59ce..841df872857fc 100644 --- a/app/views/notify/reassigned_merge_request_email.html.haml +++ b/app/views/notify/reassigned_merge_request_email.html.haml @@ -1 +1,9 @@ -= render 'reassigned_issuable_email', issuable: @merge_request +Reassigned Merge Request #{ @merge_request.iid } + += url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }]) + +Assignee changed +- if @previous_assignee + from #{@previous_assignee.name} +to += @merge_request.assignee_id ? @merge_request.assignee_name : 'Unassigned' diff --git a/app/views/notify/reassigned_merge_request_email.text.erb b/app/views/notify/reassigned_merge_request_email.text.erb index b5b4f1ff99a6b..998a40fefdeb3 100644 --- a/app/views/notify/reassigned_merge_request_email.text.erb +++ b/app/views/notify/reassigned_merge_request_email.text.erb @@ -1 +1,6 @@ -<%= render 'reassigned_issuable_email', issuable: @merge_request %> +Reassigned Merge Request <%= @merge_request.iid %> + +<%= url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }]) %> + +Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%> + to <%= "#{@merge_request.assignee_id ? @merge_request.assignee_name : 'Unassigned'}" %> diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index c74b3249a1305..4a1438aa68e92 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -72,6 +72,11 @@ = f.label :public_email, class: "label-light" = f.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email), { include_blank: 'Do not show on profile' }, class: "select2" %span.help-block This email will be displayed on your public profile. + .form-group + = f.label :preferred_language, class: "label-light" + = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] }, + {}, class: "select2" + %span.help-block This feature is experimental and translations are not complete yet. .form-group = f.label :skype, class: "label-light" = f.text_field :skype, class: "form-control" diff --git a/app/views/projects/artifacts/_tree_file.html.haml b/app/views/projects/artifacts/_tree_file.html.haml index 36fb4c998c96b..ce7e25d774b62 100644 --- a/app/views/projects/artifacts/_tree_file.html.haml +++ b/app/views/projects/artifacts/_tree_file.html.haml @@ -1,9 +1,10 @@ - path_to_file = file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: file.path) %tr.tree-item{ 'data-link' => path_to_file } + - blob = file.blob %td.tree-item-file-name - = tree_icon('file', '664', file.name) - %span.str-truncated - = link_to file.name, path_to_file + = tree_icon('file', blob.mode, blob.name) + = link_to path_to_file do + %span.str-truncated= blob.name %td - = number_to_human_size(file.metadata[:size], precision: 2) + = number_to_human_size(blob.size, precision: 2) diff --git a/app/views/projects/artifacts/file.html.haml b/app/views/projects/artifacts/file.html.haml new file mode 100644 index 0000000000000..d8da83b9a80ba --- /dev/null +++ b/app/views/projects/artifacts/file.html.haml @@ -0,0 +1,33 @@ +- page_title @path, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' += render "projects/pipelines/head" + += render "projects/builds/header", show_controls: false + +#tree-holder.tree-holder + .nav-block + %ul.breadcrumb.repo-breadcrumb + %li + = link_to 'Artifacts', browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build) + - path_breadcrumbs do |title, path| + - title = truncate(title, length: 40) + %li + - if path == @path + = link_to file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) do + %strong= title + - else + = link_to title, browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) + + + %article.file-holder + - blob = @entry.blob + .js-file-title.file-title-flex-parent + = render 'projects/blob/header_content', blob: blob + + .file-actions.hidden-xs + = render 'projects/blob/viewer_switcher', blob: blob + + .btn-group{ role: "group" }< + = copy_blob_source_button(blob) + = open_raw_blob_button(blob) + + = render 'projects/blob/content', blob: blob diff --git a/app/views/projects/blob/viewers/_balsamiq.html.haml b/app/views/projects/blob/viewers/_balsamiq.html.haml new file mode 100644 index 0000000000000..28670e7de9755 --- /dev/null +++ b/app/views/projects/blob/viewers/_balsamiq.html.haml @@ -0,0 +1,4 @@ +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('balsamiq_viewer') + +.file-content.balsamiq-viewer#js-balsamiq-viewer{ data: { endpoint: blob_raw_url } } diff --git a/app/views/projects/boards/components/_board.html.haml b/app/views/projects/boards/components/_board.html.haml index 5a4eaf92b1613..bc5c727bf0df2 100644 --- a/app/views/projects/boards/components/_board.html.haml +++ b/app/views/projects/boards/components/_board.html.haml @@ -13,8 +13,8 @@ %button.btn.btn-small.btn-default.pull-right.has-tooltip{ type: "button", "@click" => "showNewIssueForm", "v-if" => 'list.type !== "closed"', - "aria-label" => "Add an issue", - "title" => "Add an issue", + "aria-label" => "New issue", + "title" => "New issue", data: { placement: "top", container: "body" } } = icon("plus") - if can?(current_user, :admin_list, @project) diff --git a/app/views/projects/boards/components/sidebar/_assignee.html.haml b/app/views/projects/boards/components/sidebar/_assignee.html.haml index 0f42433452134..0c09e71feeed5 100644 --- a/app/views/projects/boards/components/sidebar/_assignee.html.haml +++ b/app/views/projects/boards/components/sidebar/_assignee.html.haml @@ -1,40 +1,28 @@ -.block.assignee - .title.hide-collapsed - Assignee - - if can?(current_user, :admin_issue, @project) - = icon("spinner spin", class: "block-loading") - = link_to "Edit", "#", class: "edit-link pull-right" - .value.hide-collapsed - %span.assign-yourself.no-value{ "v-if" => "!issue.assignee" } - No assignee - - if can?(current_user, :admin_issue, @project) - \- - %a.js-assign-yourself{ href: "#" } - assign yourself - %a.author_link.bold{ ":href" => "'#{root_url}' + issue.assignee.username", - "v-if" => "issue.assignee" } - %img.avatar.avatar-inline.s32{ ":src" => "issue.assignee.avatar", - width: "32", alt: "Avatar" } - %span.author - {{ issue.assignee.name }} - %span.username - = precede "@" do - {{ issue.assignee.username }} +.block.assignee{ ref: "assigneeBlock" } + %template{ "v-if" => "issue.assignees" } + %assignee-title{ ":number-of-assignees" => "issue.assignees.length", + ":loading" => "loadingAssignees", + ":editable" => can?(current_user, :admin_issue, @project) } + %assignees.value{ "root-path" => "#{root_url}", + ":users" => "issue.assignees", + ":editable" => can?(current_user, :admin_issue, @project), + "@assign-self" => "assignSelf" } + - if can?(current_user, :admin_issue, @project) .selectbox.hide-collapsed %input{ type: "hidden", - name: "issue[assignee_id]", - id: "issue_assignee_id", - ":value" => "issue.assignee.id", - "v-if" => "issue.assignee" } + name: "issue[assignee_ids][]", + ":value" => "assignee.id", + "v-if" => "issue.assignees", + "v-for" => "assignee in issue.assignees" } .dropdown - %button.dropdown-menu-toggle.js-user-search.js-author-search.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", field_name: "issue[assignee_id]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true", null_user_default: "true" }, + %button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: "button", ref: "assigneeDropdown", data: { toggle: "dropdown", field_name: "issue[assignee_ids][]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true", multi_select: "true", dropdown: { header: 'Assignee(s)'} }, ":data-issuable-id" => "issue.id", ":data-selected" => "assigneeId", ":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" } - Select assignee + Select assignee(s) = icon("chevron-down") - .dropdown-menu.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author + .dropdown-menu.dropdown-select.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author = dropdown_title("Assign to") = dropdown_filter("Search users") = dropdown_content diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml index d3c3e40d5185a..796ecdfd0145a 100644 --- a/app/views/projects/branches/new.html.haml +++ b/app/views/projects/branches/new.html.haml @@ -1,4 +1,5 @@ - page_title "New Branch" +- default_ref = params[:ref] || @project.default_branch - if @error .alert.alert-danger @@ -16,12 +17,11 @@ .help-block.text-danger.js-branch-name-error .form-group = label_tag :ref, 'Create from', class: 'control-label' - .col-sm-10 - = hidden_field_tag :ref, params[:ref] || @project.default_branch - = dropdown_tag(params[:ref] || @project.default_branch, - options: { toggle_class: 'js-branch-select wide', - filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search branches", - data: { selected: params[:ref] || @project.default_branch, field_name: 'ref' } }) + .col-sm-10.dropdown.create-from + = hidden_field_tag :ref, default_ref + = button_tag type: 'button', title: default_ref, class: 'dropdown-toggle form-control js-branch-select', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do + .text-left.dropdown-toggle-text= default_ref + = render 'shared/ref_dropdown', dropdown_class: 'wide' .help-block Existing branch name, tag, or commit SHA .form-actions = button_tag 'Create branch', class: 'btn btn-create', tabindex: 3 diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml index 0f080b6aceefb..1f4c9fac54c73 100644 --- a/app/views/projects/compare/_form.html.haml +++ b/app/views/projects/compare/_form.html.haml @@ -9,7 +9,7 @@ = hidden_field_tag :from, params[:from] = button_tag type: 'button', title: params[:from], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do .dropdown-toggle-text.str-truncated= params[:from] || 'Select branch/tag' - = render "ref_dropdown" + = render 'shared/ref_dropdown' .compare-ellipsis.inline ... .form-group.dropdown.compare-form-group.to.js-compare-to-dropdown .input-group.inline-input-group @@ -17,7 +17,7 @@ = hidden_field_tag :to, params[:to] = button_tag type: 'button', title: params[:to], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do .dropdown-toggle-text.str-truncated= params[:to] || 'Select branch/tag' - = render "ref_dropdown" + = render 'shared/ref_dropdown' = button_tag "Compare", class: "btn btn-create commits-compare-btn" - if @merge_request.present? diff --git a/app/views/projects/cycle_analytics/_empty_stage.html.haml b/app/views/projects/cycle_analytics/_empty_stage.html.haml index c3f95860e9237..cdad0bc723188 100644 --- a/app/views/projects/cycle_analytics/_empty_stage.html.haml +++ b/app/views/projects/cycle_analytics/_empty_stage.html.haml @@ -2,6 +2,6 @@ .empty-stage .icon-no-data = custom_icon ('icon_no_data') - %h4 We don't have enough data to show this stage. + %h4 {{ __('We don\'t have enough data to show this stage.') }} %p {{currentStage.emptyStageText}} diff --git a/app/views/projects/cycle_analytics/_no_access.html.haml b/app/views/projects/cycle_analytics/_no_access.html.haml index 0ffc79b318122..c3eda39823422 100644 --- a/app/views/projects/cycle_analytics/_no_access.html.haml +++ b/app/views/projects/cycle_analytics/_no_access.html.haml @@ -2,6 +2,6 @@ .no-access-stage .icon-lock = custom_icon ('icon_lock') - %h4 You need permission. + %h4 {{ __('You need permission.') }} %p - Want to see the data? Please ask administrator for access. + {{ __('Want to see the data? Please ask an administrator for access.') }} diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index dd3fa814716ea..b158a81471cc4 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -2,29 +2,30 @@ - page_title "Cycle Analytics" - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('locale') = page_specific_javascript_bundle_tag('cycle_analytics') = render "projects/head" #cycle-analytics{ class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } } - if @cycle_analytics_no_data - .bordered-box.landing.content-block{ "v-if" => "!isOverviewDialogDismissed" } - = icon("times", class: "dismiss-icon", "@click" => "dismissOverviewDialog()") - .row - .col-sm-3.col-xs-12.svg-container - = custom_icon('icon_cycle_analytics_splash') - .col-sm-8.col-xs-12.inner-content - %h4 - Introducing Cycle Analytics - %p - Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project. - - = link_to "Read more", help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn' + .landing.content-block{ "v-if" => "!isOverviewDialogDismissed" } + %button.dismiss-button{ type: 'button', 'aria-label': 'Dismiss Cycle Analytics introduction box' } + = icon("times", "@click" => "dismissOverviewDialog()") + .svg-container + = custom_icon('icon_cycle_analytics_splash') + .inner-content + %h4 + {{ __('Introducing Cycle Analytics') }} + %p + {{ __('Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.') }} + %p + = link_to _('Read more'), help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn' = icon("spinner spin", "v-show" => "isLoading") .wrapper{ "v-show" => "!isLoading && !hasError" } .panel.panel-default .panel-heading - Pipeline Health + {{ __('Pipeline Health') }} .content-block .container-fluid .row @@ -34,15 +35,15 @@ .col-sm-3.col-xs-12.column .dropdown.inline.js-ca-dropdown %button.dropdown-menu-toggle{ "data-toggle" => "dropdown", :type => "button" } - %span.dropdown-label Last 30 days + %span.dropdown-label {{ n__('Last %d day', 'Last %d days', 30) }} %i.fa.fa-chevron-down %ul.dropdown-menu.dropdown-menu-align-right %li %a{ "href" => "#", "data-value" => "30" } - Last 30 days + {{ n__('Last %d day', 'Last %d days', 30) }} %li %a{ "href" => "#", "data-value" => "90" } - Last 90 days + {{ n__('Last %d day', 'Last %d days', 90) }} .stage-panel-container .panel.panel-default.stage-panel .panel-heading @@ -50,20 +51,20 @@ %ul %li.stage-header %span.stage-name - Stage - %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The phase of the development lifecycle.", "aria-hidden" => "true" } + {{ __('ProjectLifecycle|Stage') }} + %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The phase of the development lifecycle."), "aria-hidden" => "true" } %li.median-header %span.stage-name - Median - %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.", "aria-hidden" => "true" } + {{ __('Median') }} + %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."), "aria-hidden" => "true" } %li.event-header %span.stage-name - {{ currentStage ? currentStage.legend : 'Related Issues' }} - %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The collection of events added to the data gathered for that stage.", "aria-hidden" => "true" } + {{ currentStage ? __(currentStage.legend) : __('Related Issues') }} + %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The collection of events added to the data gathered for that stage."), "aria-hidden" => "true" } %li.total-time-header %span.stage-name - Total Time - %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The time taken by each data entry gathered by that stage.", "aria-hidden" => "true" } + {{ __('Total Time') }} + %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The time taken by each data entry gathered by that stage."), "aria-hidden" => "true" } .stage-panel-body %nav.stage-nav %ul @@ -75,10 +76,10 @@ %span{ "v-if" => "stage.value" } {{ stage.value }} %span.stage-empty{ "v-else" => true } - Not enough data + {{ __('Not enough data') }} %template{ "v-else" => true } %span.not-available - Not available + {{ __('Not available') }} .section.stage-events %template{ "v-if" => "isLoadingStage" } = icon("spinner spin") diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml index 4cfbd9add009d..74756b58439a0 100644 --- a/app/views/projects/deploy_keys/_index.html.haml +++ b/app/views/projects/deploy_keys/_index.html.haml @@ -10,25 +10,4 @@ = render @deploy_keys.form_partial_path .col-lg-9.col-lg-offset-3 %hr - .col-lg-9.col-lg-offset-3.append-bottom-default.deploy-keys - %h5.prepend-top-0 - Enabled deploy keys for this project (#{@deploy_keys.enabled_keys_size}) - - if @deploy_keys.any_keys_enabled? - %ul.well-list - = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.enabled_keys, as: :deploy_key - - else - .settings-message.text-center - No deploy keys found. Create one with the form above. - %h5.prepend-top-default - Deploy keys from projects you have access to (#{@deploy_keys.available_project_keys_size}) - - if @deploy_keys.any_available_project_keys_enabled? - %ul.well-list - = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.available_project_keys, as: :deploy_key - - else - .settings-message.text-center - No deploy keys from your projects could be found. Create one with the form above or add existing one below. - - if @deploy_keys.any_available_public_keys_enabled? - %h5.prepend-top-default - Public deploy keys available to any project (#{@deploy_keys.available_public_keys_size}) - %ul.well-list - = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.available_public_keys, as: :deploy_key + #js-deploy-keys{ data: { endpoint: namespace_project_deploy_keys_path } } diff --git a/app/views/projects/group_links/_index.html.haml b/app/views/projects/group_links/_index.html.haml index b6116dbec414a..debb0214d068c 100644 --- a/app/views/projects/group_links/_index.html.haml +++ b/app/views/projects/group_links/_index.html.haml @@ -6,11 +6,9 @@ %p Projects can be stored in only one group at once. However you can share a project with other groups here. .col-lg-9 - %h5.prepend-top-0 - Set a group to share = form_tag namespace_project_group_links_path(@project.namespace, @project), class: 'js-requires-input', method: :post do .form-group - = label_tag :link_group_id, "Group", class: "label-light" + = label_tag :link_group_id, "Select a group to share with", class: "label-light" = groups_select_tag(:link_group_id, data: { skip_groups: @skip_groups }, required: true) .form-group = label_tag :link_group_access, "Max access level", class: "label-light" diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 74befc5f2e154..57e380b4879d6 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -13,9 +13,9 @@ %li CLOSED - - if issue.assignee + - if issue.assignees.any? %li - = link_to_member(@project, issue.assignee, name: false, title: "Assigned to :name") + = render 'shared/issuable/assignees', project: @project, issue: issue = render 'shared/issuable_meta_data', issuable: issue diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index 881ee9fd59656..9e306d4543ce9 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -6,7 +6,7 @@ = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('diff_notes') -.merge-request{ 'data-url' => merge_request_path(@merge_request), 'data-project-path' => project_path(@merge_request.project) } +.merge-request{ 'data-url' => merge_request_path(@merge_request, format: :json), 'data-project-path' => project_path(@merge_request.project) } = render "projects/merge_requests/show/mr_title" .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } } diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml index 718b52dd82e81..d70ec8a6062a5 100644 --- a/app/views/projects/notes/_actions.html.haml +++ b/app/views/projects/notes/_actions.html.haml @@ -31,14 +31,14 @@ - if current_user - if note.emoji_awardable? - user_authored = note.user_authored?(current_user) - = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored}", data: { position: 'right' } do + = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do = icon('spinner spin') %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile') - if note_editable - = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do + = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip' do = icon('pencil', class: 'link-highlight') - = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger' do + = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger has-tooltip' do = icon('trash-o', class: 'danger-highlight') diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index d7cefb8613e4a..1aa48bf98131b 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -1,3 +1,5 @@ +- failed_builds = @pipeline.statuses.latest.failed + .tabs-holder %ul.pipelines-tabs.nav-links.no-top.no-bottom %li.js-pipeline-tab-link @@ -7,8 +9,11 @@ = link_to builds_namespace_project_pipeline_path(@project.namespace, @project, @pipeline), data: {target: 'div#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do Jobs %span.badge.js-builds-counter= pipeline.statuses.count - - + - if failed_builds.present? + %li.js-failures-tab-link + = link_to failures_namespace_project_pipeline_path(@project.namespace, @project, @pipeline), data: {target: 'div#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do + Failed Jobs + %span.badge.js-failures-counter= failed_builds.count .tab-content #js-tab-pipeline.tab-pane @@ -39,3 +44,13 @@ %th Coverage %th = render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage + - if failed_builds.present? + #js-tab-failures.build-failures.tab-pane + - failed_builds.each_with_index do |build, index| + .build-state + %span.ci-status-icon-failed= custom_icon('icon_status_failed') + %span.stage + = build.stage.titleize + %span.build-name + = link_to build.name, pipeline_build_url(pipeline, build) + %pre.build-log= build_summary(build, skip: index >= 10) diff --git a/app/views/projects/project_members/_index.html.haml b/app/views/projects/project_members/_index.html.haml index 543801451f9f0..ea93ebfbe285e 100644 --- a/app/views/projects/project_members/_index.html.haml +++ b/app/views/projects/project_members/_index.html.haml @@ -18,7 +18,7 @@ = render "projects/project_members/new_project_member" = render 'shared/members/requests', membership_source: @project, requesters: @requesters - .append-bottom-default.clearfix + .clearfix %h5.member.existing-title Existing members and groups - if @group_links.any? diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml index 77b05c6bf4b1c..f35718a90d46d 100644 --- a/app/views/projects/protected_branches/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml @@ -25,7 +25,7 @@ .merge_access_levels-container = dropdown_tag('Select', options: { toggle_class: 'js-allowed-to-merge js-multiselect wide', - dropdown_class: 'dropdown-menu-user dropdown-menu-selectable', filter: true, + dropdown_class: 'dropdown-menu-user dropdown-menu-selectable capitalize-header', filter: true, data: { input_id: 'merge_access_levels_attributes', default_label: 'Select' } }) .form-group %label.col-md-2.text-right{ for: 'push_access_levels_attributes' } @@ -34,7 +34,7 @@ .push_access_levels-container = dropdown_tag('Select', options: { toggle_class: 'js-allowed-to-push js-multiselect wide', - dropdown_class: 'dropdown-menu-user dropdown-menu-selectable', filter: true, + dropdown_class: 'dropdown-menu-user dropdown-menu-selectable capitalize-header', filter: true, data: { input_id: 'push_access_levels_attributes', default_label: 'Select' } }) .help-block Only groups that diff --git a/app/views/projects/protected_branches/_update_protected_branch.html.haml b/app/views/projects/protected_branches/_update_protected_branch.html.haml index d6044aacaec85..0a757b7f0901b 100644 --- a/app/views/projects/protected_branches/_update_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_update_protected_branch.html.haml @@ -1,10 +1,10 @@ %td = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_levels.first.access_level = dropdown_tag( (protected_branch.merge_access_levels.first.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container', + options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container capitalize-header', data: { field_name: "allowed_to_merge_#{protected_branch.id}", access_level_id: protected_branch.merge_access_levels.first.id }}) %td = hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_levels.first.access_level = dropdown_tag( (protected_branch.push_access_levels.first.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container', + options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container capitalize-header', data: { field_name: "allowed_to_push_#{protected_branch.id}", access_level_id: protected_branch.push_access_levels.first.id }}) diff --git a/app/views/projects/protected_tags/_dropdown.html.haml b/app/views/projects/protected_tags/_dropdown.html.haml index 748515190779f..c50515cfe06e9 100644 --- a/app/views/projects/protected_tags/_dropdown.html.haml +++ b/app/views/projects/protected_tags/_dropdown.html.haml @@ -2,7 +2,7 @@ = dropdown_tag('Select tag or create wildcard', options: { toggle_class: 'js-protected-tag-select js-filter-submit wide', - filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search protected tag", + filter: true, dropdown_class: "dropdown-menu-selectable capitalize-header", placeholder: "Search protected tag", footer_content: true, data: { show_no: true, show_any: true, show_upcoming: true, selected: params[:protected_tag_name], diff --git a/app/views/projects/protected_tags/_update_protected_tag.haml b/app/views/projects/protected_tags/_update_protected_tag.haml index 62823bee46e1f..cc80bd04dd067 100644 --- a/app/views/projects/protected_tags/_update_protected_tag.haml +++ b/app/views/projects/protected_tags/_update_protected_tag.haml @@ -1,5 +1,5 @@ %td = hidden_field_tag "allowed_to_create_#{protected_tag.id}", protected_tag.create_access_levels.first.access_level = dropdown_tag( (protected_tag.create_access_levels.first.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-create', dropdown_class: 'dropdown-menu-selectable js-allowed-to-create-container', + options: { toggle_class: 'js-allowed-to-create', dropdown_class: 'dropdown-menu-selectable capitalize-header js-allowed-to-create-container', data: { field_name: "allowed_to_create_#{protected_tag.id}", access_level_id: protected_tag.create_access_levels.first.id }}) diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index 3f50cc12137da..cfc3faa75b0dd 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -1,6 +1,10 @@ - page_title "Repository" = render "projects/settings/head" +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('deploy_keys') + = render @deploy_keys = render "projects/push_rules/index" = render "projects/mirrors/show" diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index e64bf9bedcc98..1941bd0c08e91 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -1,4 +1,5 @@ - @no_container = true +- @sort ||= sort_value_recently_updated - page_title "Tags" = render "projects/commits/head" @@ -14,16 +15,14 @@ .dropdown %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown'} } %span.light - = projects_sort_options_hash[@sort] + = tags_sort_options_hash[@sort] = icon('chevron-down') - %ul.dropdown-menu.dropdown-menu-align-right - %li - = link_to filter_tags_path(sort: sort_value_name) do - = sort_title_name - = link_to filter_tags_path(sort: sort_value_recently_updated) do - = sort_title_recently_updated - = link_to filter_tags_path(sort: sort_value_oldest_updated) do - = sort_title_oldest_updated + %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable + %li.dropdown-header + Sort by + - tags_sort_options_hash.each do |value, title| + %li + = link_to title, filter_tags_path(sort: value), class: ("is-active" if @sort == value) - if can?(current_user, :push_code, @project) = link_to new_namespace_project_tag_path(@project.namespace, @project), class: 'btn btn-create new-tag-btn' do New tag diff --git a/app/views/projects/compare/_ref_dropdown.html.haml b/app/views/shared/_ref_dropdown.html.haml similarity index 50% rename from app/views/projects/compare/_ref_dropdown.html.haml rename to app/views/shared/_ref_dropdown.html.haml index 05fb37cdc0f52..96f68c80c48cd 100644 --- a/app/views/projects/compare/_ref_dropdown.html.haml +++ b/app/views/shared/_ref_dropdown.html.haml @@ -1,4 +1,6 @@ -.dropdown-menu.dropdown-menu-selectable +- dropdown_class = local_assigns.fetch(:dropdown_class, '') + +.dropdown-menu.dropdown-menu-selectable{ class: dropdown_class } = dropdown_title "Select Git revision" = dropdown_filter "Filter by Git revision" = dropdown_content diff --git a/app/views/shared/errors/_graphic_422.svg b/app/views/shared/errors/_graphic_422.svg new file mode 100644 index 0000000000000..87128ecd69d54 --- /dev/null +++ b/app/views/shared/errors/_graphic_422.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 246" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="0" width="178" height="136" rx="10"/><mask id="1" width="178" height="136" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask></defs><g fill="none" fill-rule="evenodd"><g fill="#e5e5e5" fill-rule="nonzero"><path d="m109.88 37.634c5.587-3.567 12.225-5.634 19.345-5.634 7.445 0 14.363 2.26 20.1 6.132l21.435-37.13c.554-.959 1.771-1.292 2.734-.736.957.552 1.284 1.777.73 2.736l-21.496 37.23c-.065.112-.138.215-.219.309 3.686 3.13 6.733 6.988 8.919 11.353l-3.393.002c-5.775-10.322-16.705-16.901-28.814-16.901-12.12 0-23.06 6.594-28.833 16.935l-3.393.002c2.32-4.646 5.616-8.72 9.618-11.954l-21.349-36.977c-.554-.959-.227-2.184.73-2.736.963-.556 2.181-.223 2.734.736l21.15 36.629"/><path d="m3 70v134c0 9.389 7.611 17 16.997 17h220.01c9.389 0 16.997-7.611 16.997-17v-134c0-9.389-7.611-17-16.997-17h-220.01c-9.389 0-16.997 7.611-16.997 17m-3 0c0-11.05 8.95-20 19.997-20h220.01c11.04 0 19.997 8.958 19.997 20v134c0 11.05-8.95 20-19.997 20h-220.01c-11.04 0-19.997-8.958-19.997-20v-134"/></g><ellipse cx="129" cy="241.5" fill="#f9f9f9" rx="89" ry="4.5"/><g fill-rule="nonzero" transform="translate(210 70)"><path fill="#eaeaea" d="m16 29c7.18 0 13-5.82 13-13 0-7.18-5.82-13-13-13-7.18 0-13 5.82-13 13 0 7.18 5.82 13 13 13m0 3c-8.837 0-16-7.163-16-16 0-8.837 7.163-16 16-16 8.837 0 16 7.163 16 16 0 8.837-7.163 16-16 16" id="2"/><path fill="#6b4fbb" d="m16 21c2.761 0 5-2.239 5-5 0-2.761-2.239-5-5-5-2.761 0-5 2.239-5 5 0 2.761 2.239 5 5 5m0 3c-4.418 0-8-3.582-8-8 0-4.418 3.582-8 8-8 4.418 0 8 3.582 8 8 0 4.418-3.582 8-8 8" id="3"/></g><g fill-rule="nonzero" transform="translate(210 109)"><use xlink:href="#2"/><use xlink:href="#3"/></g><g transform="translate(210 147)"><path fill="#e5e5e5" fill-rule="nonzero" d="m3 5.992v45.02c0 1.647 1.346 2.992 3 2.992h20c1.657 0 3-1.341 3-2.992v-45.02c0-1.647-1.346-2.992-3-2.992h-20c-1.657 0-3 1.341-3 2.992m-3 0c0-3.309 2.687-5.992 6-5.992h20c3.314 0 6 2.692 6 5.992v45.02c0 3.309-2.687 5.992-6 5.992h-20c-3.314 0-6-2.692-6-5.992v-45.02"/><rect width="16" height="4" x="8" y="27" fill="#fdb692" rx="2"/><rect width="16" height="4" x="8" y="19" fill="#fc9867" rx="2"/><rect width="16" height="4" x="8" y="11" fill="#fc6d26" rx="2"/><rect width="16" height="4" x="8" y="35" fill="#fed3bd" rx="2"/><rect width="16" height="4" x="8" y="43" fill="#fef0e9" rx="2"/></g><g transform="translate(16 69)"><use fill="#6b4fbb" fill-opacity=".1" stroke="#e5e5e5" stroke-width="6" mask="url(#1)" xlink:href="#0"/><g class="tv-screen" fill="#fff"><path opacity=".4" mix-blend-mode="overlay" d="m3 17h172v16h-172z"/><path opacity=".6" mix-blend-mode="overlay" d="m3 70h172v24h-172z"/><path opacity=".3" mix-blend-mode="overlay" d="m3 107h172v16h-172z"/><path opacity=".4" mix-blend-mode="overlay" d="m3 40h172v8h-172z"/><path opacity=".3" mix-blend-mode="overlay" d="m3 55h172v8h-172z"/></g></g><path class="text-422" d="m.693 19h5.808c.277 0 .498-.224.498-.5 0-.268-.223-.5-.498-.5h-5.808v-2.094l3.777-5.906h3.916l-4.124 6.454h6.259v-6.454h.978c.273 0 .5-.224.5-.5 0-.268-.224-.5-.5-.5h-.978v-2h4.698v6h-2.721c-.277 0-.498.224-.498.5 0 .268.223.5.498.5h2.721v2.454h2.723v4.2h-2.723v5.346h-4.698v-5.346h-9.828v-1.654m4.417-10l1.279-2h3.914l-1.278 2h-3.916m1.919-3l1.279-2h4.192c.27 0 .5-.224.5-.5 0-.268-.224-.5-.5-.5h-3.552l1.142-1.786h5.13v4.786h-8.191m31.09 19v1h-15.738v-2h5.118c.271 0 .503-.224.503-.5 0-.268-.225-.5-.503-.5h-5.118v-1.184l2.656-2.822c.682-.725 1.306-1.39 1.872-1.994h5.428c-.389.394-.808.815-1.256 1.264-1.428 1.428-2.562 2.568-3.403 3.42h10.442v2.316h-4.614c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h4.614m-6.674-13c.493-.631.87-1.208 1.129-1.73.365-.736.548-1.464.548-2.183 0-1.107-.335-1.962-1-2.565-.67-.603-1.619-.905-2.847-.905-.874 0-1.857.174-2.947.523-1.09.349-2.227.855-3.412 1.519v-2.659h3.589c.27 0 .5-.224.5-.5 0-.268-.224-.5-.5-.5h-3.589v-.906c1.184-.432 2.344-.761 3.478-.988 1.134-.227 2.222-.34 3.262-.34 2.623 0 4.684.611 6.184 1.834.157.128.307.262.448.4h-2.782c-.27 0-.5.224-.5.5 0 .268.224.5.5.5h3.602c.654 1.01.981 2.209.981 3.605 0 .974-.163 1.887-.49 2.739-.326.852-.888 1.798-1.685 2.839-.397.509-1.261 1.448-2.594 2.816h-5.474c1.34-1.436 2.261-2.436 2.763-3h4.396c.271 0 .499-.224.499-.5 0-.268-.223-.5-.499-.5h-3.557m28.14 12v2h-15.738v-4.184l2.651-2.816h5.313c-1.087 1.089-1.976 1.983-2.668 2.684h10.442v1.316h-4.083c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h4.083m-2.069-11c-.045.061-.092.122-.139.184-.567.727-2.089 2.333-4.568 4.816h-5.372c2.601-2.77 4.204-4.503 4.81-5.198.83-.952 1.428-1.796 1.793-2.532.365-.736.548-1.464.548-2.183 0-1.107-.335-1.962-1-2.565-.67-.603-1.619-.905-2.847-.905-.874 0-1.857.174-2.947.523-1.09.349-2.227.855-3.412 1.519v-2.659h3.117c.271 0 .503-.224.503-.5 0-.268-.225-.5-.503-.5h-3.117v-.906c1.184-.432 2.344-.761 3.478-.988 1.134-.227 2.222-.34 3.262-.34 2.623 0 4.684.611 6.184 1.834.157.128.307.262.448.4h-1.248c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h2.069c.654 1.01.981 2.209.981 3.605 0 .844-.123 1.642-.368 2.395h-2.683c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h2.272c-.159.321-.347.655-.566 1h-3.706c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h3.01" transform="translate(75 124)" fill="#5c5c5c"/></g></svg> diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml new file mode 100644 index 0000000000000..36bbb1148d49b --- /dev/null +++ b/app/views/shared/issuable/_assignees.html.haml @@ -0,0 +1,15 @@ +- max_render = 3 +- max = [max_render, issue.assignees.length].min + +- issue.assignees.each_with_index do |assignee, index| + - if index < max + = link_to_member(@project, assignee, name: false, title: "Assigned to :name") + +- if issue.assignees.length > max_render + - counter = issue.assignees.length - max_render + + %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', 'original-title' => "+#{counter} more assignees" } } + - if counter < 99 + = "+#{counter}" + - else + 99+ diff --git a/app/views/shared/issuable/_participants.html.haml b/app/views/shared/issuable/_participants.html.haml index 171da89993736..db407363a0929 100644 --- a/app/views/shared/issuable/_participants.html.haml +++ b/app/views/shared/issuable/_participants.html.haml @@ -12,9 +12,9 @@ - participants.each do |participant| .participants-author.js-participants-author = link_to_member(@project, participant, name: false, size: 24) - - if participants_extra > 0 - .participants-more - %a.js-participants-more{ href: "#", data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } } - + #{participants_extra} more + - if participants_extra > 0 + .hide-collapsed.participants-more + %a.js-participants-more{ href: "#", data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } } + + #{participants_extra} more :javascript IssuableContext.prototype.PARTICIPANTS_ROW_COUNT = #{participants_row}; diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 3439775b10899..c99bec21774e0 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -147,8 +147,13 @@ %li %a{ href: "#", data: { id: "close" } } Closed .filter-item.inline + - if type == :issues + - field_name = "update[assignee_ids][]" + - else + - field_name = "update[assignee_id]" + = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", - placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]", default_label: "Assignee" } }) + placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } }) .filter-item.inline = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true, default_label: "Milestone" } }) .filter-item.inline.labels-filter diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index eec97e9000957..95d30831aaac0 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -1,10 +1,10 @@ - todo = issuable_todo(issuable) - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('common_vue') - = page_specific_javascript_bundle_tag('issuable') + = page_specific_javascript_bundle_tag('sidebar') %aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => "102", "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } - .issuable-sidebar + .issuable-sidebar{ data: { endpoint: "#{issuable_json_path(issuable)}" } } - can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project) .block.issuable-sidebar-header - if current_user @@ -20,36 +20,54 @@ .block.todo.hide-expanded = render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable, is_collapsed: true .block.assignee - .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } - - if issuable.assignee - = link_to_member(@project, issuable.assignee, size: 24) - - else - = icon('user', 'aria-hidden': 'true') - .title.hide-collapsed - Assignee - = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - - if can_edit_issuable - = link_to 'Edit', '#', class: 'edit-link pull-right' - .value.hide-collapsed - - if issuable.assignee - = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do - - if issuable.instance_of?(MergeRequest) && !issuable.can_be_merged_by?(issuable.assignee) - %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } - = icon('exclamation-triangle', 'aria-hidden': 'true') - %span.username - = issuable.assignee.to_reference - - else - %span.assign-yourself.no-value - No assignee - - if can_edit_issuable - \- - %a.js-assign-yourself{ href: '#' } - assign yourself + - if issuable.instance_of?(Issue) + #js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]" } } + - else + .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } + - if issuable.assignee + = link_to_member(@project, issuable.assignee, size: 24) + - else + = icon('user', 'aria-hidden': 'true') + .title.hide-collapsed + Assignee + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') + - if can_edit_issuable + = link_to 'Edit', '#', class: 'edit-link pull-right' + .value.hide-collapsed + - if issuable.assignee + = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do + - if issuable.instance_of?(MergeRequest) && !issuable.can_be_merged_by?(issuable.assignee) + %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } + = icon('exclamation-triangle', 'aria-hidden': 'true') + %span.username + = issuable.assignee.to_reference + - else + %span.assign-yourself.no-value + No assignee + - if can_edit_issuable + \- + %a.js-assign-yourself{ href: '#' } + assign yourself .selectbox.hide-collapsed - = f.hidden_field 'assignee_id', value: issuable.assignee_id, id: 'issue_assignee_id' - = dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true, null_user_default: true, selected: issuable.assignee_id } }) + - issuable.assignees.each do |assignee| + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil + - options = { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } } + + - if issuable.instance_of?(Issue) + - if issuable.assignees.length == 0 + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil + - title = 'Select assignee(s)' + - options[:toggle_class] += ' js-multiselect js-save-user-data' + - options[:data][:field_name] = "#{issuable.to_ability_name}[assignee_ids][]" + - options[:data][:multi_select] = true + - options[:data]['dropdown-title'] = title + - options[:data]['dropdown-header'] = 'Assignee(s)' + - else + - title = 'Select assignee' + + = dropdown_tag(title, options: options) .block.milestone .sidebar-collapsed-icon = icon('clock-o', 'aria-hidden': 'true') @@ -75,11 +93,10 @@ = dropdown_tag('Milestone', options: { title: 'Assign milestone', toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: 'Search milestones', data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true }}) - if issuable.has_attribute?(:time_estimate) #issuable-time-tracker.block - %issuable-time-tracker{ ':time_estimate' => 'issuable.time_estimate', ':time_spent' => 'issuable.total_time_spent', ':human_time_estimate' => 'issuable.human_time_estimate', ':human_time_spent' => 'issuable.human_total_time_spent', 'docs-url' => help_page_path('workflow/time_tracking.md') } - // Fallback while content is loading - .title.hide-collapsed - Time tracking - = icon('spinner spin', 'aria-hidden': 'true') + // Fallback while content is loading + .title.hide-collapsed + Time tracking + = icon('spinner spin', 'aria-hidden': 'true') - if issuable.has_attribute?(:due_date) .block.due_date .sidebar-collapsed-icon @@ -196,8 +213,13 @@ = clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left") :javascript - gl.IssuableResource = new gl.SubbableResource('#{issuable_json_path(issuable)}'); - new gl.IssuableTimeTracking("#{escape_javascript(serialize_issuable(issuable))}"); + gl.sidebarOptions = { + endpoint: "#{issuable_json_path(issuable)}", + editable: #{can_edit_issuable ? true : false}, + currentUser: #{current_user.to_json(only: [:username, :id, :name], methods: :avatar_url)}, + rootPath: "#{root_path}" + }; + new MilestoneSelect('{"full_path":"#{@project.full_path}"}'); new LabelsSelect(); new WeightSelect(); diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml index 2793e7bcff430..f57b4d899ce98 100644 --- a/app/views/shared/issuable/form/_branch_chooser.html.haml +++ b/app/views/shared/issuable/form/_branch_chooser.html.haml @@ -10,12 +10,16 @@ = form.label :source_branch, class: 'control-label' .col-sm-10 .issuable-form-select-holder - = form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2 span2', disabled: true }) + = form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2', disabled: true }) .form-group = form.label :target_branch, class: 'control-label' - .col-sm-10 + .col-sm-10.target-branch-select-dropdown-container .issuable-form-select-holder - = form.select(:target_branch, issuable.target_branches, { include_blank: true }, { class: 'target_branch select2 span2', disabled: issuable.new_record?, data: { placeholder: "Select branch" }}) + = form.select(:target_branch, issuable.target_branches, + { include_blank: true }, + { class: 'target_branch js-target-branch-select', + disabled: issuable.new_record?, + data: { placeholder: "Select branch" }}) - if issuable.new_record? = link_to 'Change branches', mr_change_branches_path(issuable) diff --git a/app/views/shared/issuable/form/_issue_assignee.html.haml b/app/views/shared/issuable/form/_issue_assignee.html.haml new file mode 100644 index 0000000000000..c33474ac3b4e7 --- /dev/null +++ b/app/views/shared/issuable/form/_issue_assignee.html.haml @@ -0,0 +1,30 @@ +- issue = issuable +.block.assignee + .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee_list) } + - if issue.assignees.any? + - issue.assignees.each do |assignee| + = link_to_member(@project, assignee, size: 24) + - else + = icon('user', 'aria-hidden': 'true') + .title.hide-collapsed + Assignee + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') + - if can_edit_issuable + = link_to 'Edit', '#', class: 'edit-link pull-right' + .value.hide-collapsed + - if issue.assignees.any? + - issue.assignees.each do |assignee| + = link_to_member(@project, assignee, size: 32, extra_class: 'bold') do + %span.username + = assignee.to_reference + - else + %span.assign-yourself.no-value + No assignee + - if can_edit_issuable + \- + %a.js-assign-yourself{ href: '#' } + assign yourself + + .selectbox.hide-collapsed + = f.hidden_field 'assignee_ids', value: issuable.assignee_ids, id: 'issue_assignee_ids' + = dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } }) diff --git a/app/views/shared/issuable/form/_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_merge_request_assignee.html.haml new file mode 100644 index 0000000000000..18011d528a0be --- /dev/null +++ b/app/views/shared/issuable/form/_merge_request_assignee.html.haml @@ -0,0 +1,31 @@ +- merge_request = issuable +.block.assignee + .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (merge_request.assignee.name if merge_request.assignee) } + - if merge_request.assignee + = link_to_member(@project, merge_request.assignee, size: 24) + - else + = icon('user', 'aria-hidden': 'true') + .title.hide-collapsed + Assignee + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') + - if can_edit_issuable + = link_to 'Edit', '#', class: 'edit-link pull-right' + .value.hide-collapsed + - if merge_request.assignee + = link_to_member(@project, merge_request.assignee, size: 32, extra_class: 'bold') do + - unless merge_request.can_be_merged_by?(merge_request.assignee) + %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } + = icon('exclamation-triangle', 'aria-hidden': 'true') + %span.username + = merge_request.assignee.to_reference + - else + %span.assign-yourself.no-value + No assignee + - if can_edit_issuable + \- + %a.js-assign-yourself{ href: '#' } + assign yourself + + .selectbox.hide-collapsed + = f.hidden_field 'assignee_id', value: merge_request.assignee_id, id: 'issue_assignee_id' + = dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: @project&.id, author_id: merge_request.author_id, field_name: 'merge_request[assignee_id]', issue_update: issuable_json_path(merge_request), ability_name: 'merge_request', null_user: true } }) diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml index 5d5ba7752d1f4..651d19754f800 100644 --- a/app/views/shared/issuable/form/_metadata.html.haml +++ b/app/views/shared/issuable/form/_metadata.html.haml @@ -10,13 +10,27 @@ .row %div{ class: (has_due_date ? "col-lg-6" : "col-sm-12") } .form-group.issue-assignee - = form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" - .col-sm-10{ class: ("col-lg-8" if has_due_date) } - .issuable-form-select-holder - = form.hidden_field :assignee_id - = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", - placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} }) - = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}" + - if issuable.is_a?(Issue) + = form.label :assignee_ids, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" + .col-sm-10{ class: ("col-lg-8" if has_due_date) } + .issuable-form-select-holder.selectbox + - issuable.assignees.each do |assignee| + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { meta: assignee.name } + + - if issuable.assignees.length === 0 + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' } + + = dropdown_tag(users_dropdown_label(issuable.assignees), options: issue_dropdown_options(issuable, true)) + = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}" + - else + = form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" + .col-sm-10{ class: ("col-lg-8" if has_due_date) } + .issuable-form-select-holder + = form.hidden_field :assignee_id + + = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", + placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} }) + = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}" .form-group.issue-milestone = form.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}" .col-sm-10{ class: ("col-lg-8" if has_due_date) } diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml index 10050adfda5a4..92f6e7428ae2e 100644 --- a/app/views/shared/members/_requests.html.haml +++ b/app/views/shared/members/_requests.html.haml @@ -1,5 +1,5 @@ - if requesters.any? - .panel.panel-default + .panel.panel-default.prepend-top-default .panel-heading Users requesting access to %strong= membership_source.name diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml index 5247d6a51e642..22547a30cdfb1 100644 --- a/app/views/shared/milestones/_issuable.html.haml +++ b/app/views/shared/milestones/_issuable.html.haml @@ -1,7 +1,7 @@ -# @project is present when viewing Project's milestone - project = @project || issuable.project - namespace = @project_namespace || project.namespace.becomes(Namespace) -- assignee = issuable.assignee +- assignees = issuable.assignees - issuable_type = issuable.class.table_name - base_url_args = [namespace, project] - issuable_type_args = base_url_args + [issuable_type] @@ -26,7 +26,7 @@ - render_colored_label(label) %span.assignee-icon - - if assignee - = link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, assignee_id: issuable.assignee_id, state: 'all' }), + - assignees.each do |assignee| + = link_to polymorphic_path(base_url_args, { milestone_title: @milestone.title, assignee_id: assignee.id, state: 'all' }), class: 'has-tooltip', title: "Assigned to #{assignee.name}", data: { container: 'body' } do - - image_tag(avatar_icon(issuable.assignee, 16), class: "avatar s16", alt: '') + - image_tag(avatar_icon(assignee, 16), class: "avatar s16", alt: '') diff --git a/app/views/shared/milestones/_labels_tab.html.haml b/app/views/shared/milestones/_labels_tab.html.haml index 33f93dccd3c48..a26b3b8009e96 100644 --- a/app/views/shared/milestones/_labels_tab.html.haml +++ b/app/views/shared/milestones/_labels_tab.html.haml @@ -2,7 +2,7 @@ - labels.each do |label| - options = { milestone_title: @milestone.title, label_name: label.title } - %li + %li.is-not-draggable %span.label-row %span.label-name = link_to milestones_label_path(options) do @@ -10,10 +10,8 @@ %span.prepend-description-left = markdown_field(label, :description) - .pull-info-right - %span.append-right-20 - = link_to milestones_label_path(options.merge(state: 'opened')) do - - pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), 'open issue' - %span.append-right-20 - = link_to milestones_label_path(options.merge(state: 'closed')) do - - pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), 'closed issue' + .pull-right.hidden-xs.hidden-sm.hidden-md + = link_to milestones_label_path(options.merge(state: 'opened')), class: 'btn btn-transparent btn-action' do + - pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), 'open issue' + = link_to milestones_label_path(options.merge(state: 'closed')), class: 'btn btn-transparent btn-action' do + - pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), 'closed issue' diff --git a/app/views/shared/notes/_edit_form.html.haml b/app/views/shared/notes/_edit_form.html.haml index fc57b92d4a6ca..8923e5602a41f 100644 --- a/app/views/shared/notes/_edit_form.html.haml +++ b/app/views/shared/notes/_edit_form.html.haml @@ -9,6 +9,6 @@ .note-form-actions.clearfix .settings-message.note-edit-warning.js-finish-edit-warning Finish editing this message first! - = submit_tag 'Save comment', class: 'btn btn-nr btn-save js-comment-button' + = submit_tag 'Save comment', class: 'btn btn-nr btn-save js-comment-save-button' %button.btn.btn-nr.btn-cancel.note-edit-cancel{ type: 'button' } Cancel diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index 071c48fa2e494..5c1156b06fb16 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -40,7 +40,7 @@ .note-body{ class: note_editable ? 'js-task-list-container' : '' } .note-text.md = note.redacted_note_html - = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago', include_author: true) + = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago') - if note_editable = render 'shared/notes/edit', note: note .note-awards diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml index d084f5e968416..501c09d71d510 100644 --- a/app/views/shared/snippets/_header.html.haml +++ b/app/views/shared/snippets/_header.html.haml @@ -21,4 +21,4 @@ = markdown_field(@snippet, :title) - if @snippet.updated_at != @snippet.created_at - = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago') + = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago', exclude_author: true) diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml index dace11e547461..679a5e934da0e 100644 --- a/app/views/snippets/notes/_actions.html.haml +++ b/app/views/snippets/notes/_actions.html.haml @@ -1,13 +1,13 @@ - if current_user - if note.emoji_awardable? - user_authored = note.user_authored?(current_user) - = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored}", data: { position: 'right' } do + = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do = icon('spinner spin') %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile') - if note_editable - = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do + = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip' do = icon('pencil', class: 'link-highlight') - = link_to snippet_note_path(note.noteable, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger' do + = link_to snippet_note_path(note.noteable, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger has-tooltip' do = icon('trash-o', class: 'danger-highlight') diff --git a/app/workers/elastic_indexer_worker.rb b/app/workers/elastic_indexer_worker.rb index 0db28180259b2..846c70647455f 100644 --- a/app/workers/elastic_indexer_worker.rb +++ b/app/workers/elastic_indexer_worker.rb @@ -6,7 +6,7 @@ class ElasticIndexerWorker sidekiq_options retry: 2 - ISSUE_TRACKED_FIELDS = %w(assignee_id author_id confidential).freeze + ISSUE_TRACKED_FIELDS = %w(assignee_ids author_id confidential).freeze def perform(operation, class_name, record_id, options = {}) return true unless current_application_settings.elasticsearch_indexing? diff --git a/app/workers/propagate_service_template_worker.rb b/app/workers/propagate_service_template_worker.rb new file mode 100644 index 0000000000000..5ce0e0405d0ac --- /dev/null +++ b/app/workers/propagate_service_template_worker.rb @@ -0,0 +1,21 @@ +# Worker for updating any project specific caches. +class PropagateServiceTemplateWorker + include Sidekiq::Worker + include DedicatedSidekiqQueue + + LEASE_TIMEOUT = 4.hours.to_i + + def perform(template_id) + return unless try_obtain_lease_for(template_id) + + Projects::PropagateServiceTemplate.propagate(Service.find_by(id: template_id)) + end + + private + + def try_obtain_lease_for(template_id) + Gitlab::ExclusiveLease. + new("propagate_service_template_worker:#{template_id}", timeout: LEASE_TIMEOUT). + try_obtain + end +end diff --git a/changelogs/unreleased/24883-build-failure-summary-page.yml b/changelogs/unreleased/24883-build-failure-summary-page.yml new file mode 100644 index 0000000000000..214cd3e2bc71f --- /dev/null +++ b/changelogs/unreleased/24883-build-failure-summary-page.yml @@ -0,0 +1,4 @@ +--- +title: Added build failures summary page for pipelines +merge_request: 10719 +author: diff --git a/changelogs/unreleased/27614-instant-comments.yml b/changelogs/unreleased/27614-instant-comments.yml new file mode 100644 index 0000000000000..7b2592f46ede2 --- /dev/null +++ b/changelogs/unreleased/27614-instant-comments.yml @@ -0,0 +1,4 @@ +--- +title: Add support for instantly updating comments +merge_request: 10760 +author: diff --git a/changelogs/unreleased/29145-oauth-422.yml b/changelogs/unreleased/29145-oauth-422.yml new file mode 100644 index 0000000000000..94e4cd84ad195 --- /dev/null +++ b/changelogs/unreleased/29145-oauth-422.yml @@ -0,0 +1,4 @@ +--- +title: Redesign auth 422 page +merge_request: +author: diff --git a/changelogs/unreleased/30007-done-todo-hover-state.yml b/changelogs/unreleased/30007-done-todo-hover-state.yml new file mode 100644 index 0000000000000..bfbde7a49c825 --- /dev/null +++ b/changelogs/unreleased/30007-done-todo-hover-state.yml @@ -0,0 +1,4 @@ +--- +title: Add transparent top-border to the hover state of done todos +merge_request: +author: diff --git a/changelogs/unreleased/30903-vertically-align-mini-pipeline.yml b/changelogs/unreleased/30903-vertically-align-mini-pipeline.yml new file mode 100644 index 0000000000000..af87e5ce39f17 --- /dev/null +++ b/changelogs/unreleased/30903-vertically-align-mini-pipeline.yml @@ -0,0 +1,4 @@ +--- +title: Vertically align mini pipeline stage container +merge_request: +author: diff --git a/changelogs/unreleased/30973-network-graph-sorted-by-date-and-topo.yml b/changelogs/unreleased/30973-network-graph-sorted-by-date-and-topo.yml new file mode 100644 index 0000000000000..42426c1865e72 --- /dev/null +++ b/changelogs/unreleased/30973-network-graph-sorted-by-date-and-topo.yml @@ -0,0 +1,4 @@ +--- +title: Sort the network graph both by commit date and topographically +merge_request: 11057 +author: diff --git a/changelogs/unreleased/31689-request-access-spacing.yml b/changelogs/unreleased/31689-request-access-spacing.yml new file mode 100644 index 0000000000000..66076b44f466a --- /dev/null +++ b/changelogs/unreleased/31689-request-access-spacing.yml @@ -0,0 +1,4 @@ +--- +title: Add default margin-top to user request table on project members page +merge_request: +author: diff --git a/changelogs/unreleased/31760-add-tooltips-to-note-actions.yml b/changelogs/unreleased/31760-add-tooltips-to-note-actions.yml new file mode 100644 index 0000000000000..9bbf43d652e37 --- /dev/null +++ b/changelogs/unreleased/31760-add-tooltips-to-note-actions.yml @@ -0,0 +1,4 @@ +--- +title: Add tooltips to note action buttons +merge_request: +author: diff --git a/changelogs/unreleased/31810-commit-link.yml b/changelogs/unreleased/31810-commit-link.yml new file mode 100644 index 0000000000000..857c9cb95c595 --- /dev/null +++ b/changelogs/unreleased/31810-commit-link.yml @@ -0,0 +1,4 @@ +--- +title: Remove `#` being added on commit sha in MR widget +merge_request: +author: diff --git a/changelogs/unreleased/add_system_note_for_editing_issuable.yml b/changelogs/unreleased/add_system_note_for_editing_issuable.yml new file mode 100644 index 0000000000000..3cbc7f91bf007 --- /dev/null +++ b/changelogs/unreleased/add_system_note_for_editing_issuable.yml @@ -0,0 +1,4 @@ +--- +title: Add system note on description change of issue/merge request +merge_request: 10392 +author: blackst0ne diff --git a/changelogs/unreleased/balsalmiq-support.yml b/changelogs/unreleased/balsalmiq-support.yml new file mode 100644 index 0000000000000..56a0b4c83fae6 --- /dev/null +++ b/changelogs/unreleased/balsalmiq-support.yml @@ -0,0 +1,4 @@ +--- +title: Added balsamiq file viewer +merge_request: 10564 +author: diff --git a/changelogs/unreleased/deploy-keys-load-async.yml b/changelogs/unreleased/deploy-keys-load-async.yml new file mode 100644 index 0000000000000..e90910278e807 --- /dev/null +++ b/changelogs/unreleased/deploy-keys-load-async.yml @@ -0,0 +1,4 @@ +--- +title: Deploy keys load are loaded async +merge_request: +author: diff --git a/changelogs/unreleased/dm-artifact-blob-viewer.yml b/changelogs/unreleased/dm-artifact-blob-viewer.yml new file mode 100644 index 0000000000000..38f5cbb73e18e --- /dev/null +++ b/changelogs/unreleased/dm-artifact-blob-viewer.yml @@ -0,0 +1,4 @@ +--- +title: Add artifact file page that uses the blob viewer +merge_request: +author: diff --git a/changelogs/unreleased/fix-admin-integrations.yml b/changelogs/unreleased/fix-admin-integrations.yml new file mode 100644 index 0000000000000..7689623501ffc --- /dev/null +++ b/changelogs/unreleased/fix-admin-integrations.yml @@ -0,0 +1,4 @@ +--- +title: Fix new admin integrations not taking effect on existing projects +merge_request: +author: diff --git a/changelogs/unreleased/implement-i18n-support.yml b/changelogs/unreleased/implement-i18n-support.yml new file mode 100644 index 0000000000000..d304fbecf9088 --- /dev/null +++ b/changelogs/unreleased/implement-i18n-support.yml @@ -0,0 +1,4 @@ +--- +title: Add support for i18n on Cycle Analytics page +merge_request: 10669 +author: diff --git a/changelogs/unreleased/issue-boards-no-avatar.yml b/changelogs/unreleased/issue-boards-no-avatar.yml new file mode 100644 index 0000000000000..a2dd53b3f2fe8 --- /dev/null +++ b/changelogs/unreleased/issue-boards-no-avatar.yml @@ -0,0 +1,4 @@ +--- +title: Fixed avatar not display on issue boards when Gravatar is disabled +merge_request: +author: diff --git a/changelogs/unreleased/issue-title-description-realtime.yml b/changelogs/unreleased/issue-title-description-realtime.yml new file mode 100644 index 0000000000000..003e1a4ab334e --- /dev/null +++ b/changelogs/unreleased/issue-title-description-realtime.yml @@ -0,0 +1,4 @@ +--- +title: Add realtime descriptions to issue show pages +merge_request: +author: diff --git a/changelogs/unreleased/merge-request-poll-json-endpoint.yml b/changelogs/unreleased/merge-request-poll-json-endpoint.yml new file mode 100644 index 0000000000000..6c41984e9b702 --- /dev/null +++ b/changelogs/unreleased/merge-request-poll-json-endpoint.yml @@ -0,0 +1,4 @@ +--- +title: Fixed bug where merge request JSON would be displayed +merge_request: +author: diff --git a/changelogs/unreleased/mrchrisw-import-shell-timeout.yml b/changelogs/unreleased/mrchrisw-import-shell-timeout.yml new file mode 100644 index 0000000000000..e43409109d6c5 --- /dev/null +++ b/changelogs/unreleased/mrchrisw-import-shell-timeout.yml @@ -0,0 +1,4 @@ +--- +title: Add configurable timeout for git fetch and clone operations +merge_request: 10697 +author: diff --git a/changelogs/unreleased/prometheus-integration-test-setting-fix.yml b/changelogs/unreleased/prometheus-integration-test-setting-fix.yml new file mode 100644 index 0000000000000..45b7c2263e694 --- /dev/null +++ b/changelogs/unreleased/prometheus-integration-test-setting-fix.yml @@ -0,0 +1,4 @@ +--- +title: Prevent 500 errors caused by testing the Prometheus service +merge_request: 10994 +author: diff --git a/changelogs/unreleased/tags-sort-default.yml b/changelogs/unreleased/tags-sort-default.yml new file mode 100644 index 0000000000000..265b765d5400c --- /dev/null +++ b/changelogs/unreleased/tags-sort-default.yml @@ -0,0 +1,4 @@ +--- +title: Fixed tags sort from defaulting to empty +merge_request: +author: diff --git a/changelogs/unreleased/update-issue-board-cards-design.yml b/changelogs/unreleased/update-issue-board-cards-design.yml new file mode 100644 index 0000000000000..5ef94a74e8a92 --- /dev/null +++ b/changelogs/unreleased/update-issue-board-cards-design.yml @@ -0,0 +1,4 @@ +--- +title: Update issue board cards design +merge_request: 10353 +author: diff --git a/changelogs/unreleased/winh-visual-token-labels.yml b/changelogs/unreleased/winh-visual-token-labels.yml new file mode 100644 index 0000000000000..d4952e910b4c5 --- /dev/null +++ b/changelogs/unreleased/winh-visual-token-labels.yml @@ -0,0 +1,4 @@ +--- +title: Colorize labels in search field +merge_request: 11047 +author: diff --git a/config/application.rb b/config/application.rb index cddfc892cdd93..35e1550ec9838 100644 --- a/config/application.rb +++ b/config/application.rb @@ -45,6 +45,9 @@ class Application < Rails::Application # config.i18n.default_locale = :de config.i18n.enforce_available_locales = false + # Translation for AR attrs is not working well for POROs like WikiPage + config.gettext_i18n_rails.use_for_active_record_attributes = false + # Configure the default encoding used in templates for Ruby 1.9. config.encoding = "utf-8" diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index f52a10be069a4..d3002a0f82204 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -589,6 +589,9 @@ production: &base upload_pack: true receive_pack: true + # Git import/fetch timeout + # git_timeout: 800 + # If you use non-standard ssh port you need to specify it # ssh_port: 22 diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index df26e44534577..7dfb8613098e2 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -462,6 +462,7 @@ def cron_random_weekly_time Settings.gitlab_shell['ssh_user'] ||= Settings.gitlab.user Settings.gitlab_shell['owner_group'] ||= Settings.gitlab.user Settings.gitlab_shell['ssh_path_prefix'] ||= Settings.__send__(:build_gitlab_shell_ssh_path_prefix) +Settings.gitlab_shell['git_timeout'] ||= 800 # # Workhorse diff --git a/config/initializers/fast_gettext.rb b/config/initializers/fast_gettext.rb new file mode 100644 index 0000000000000..a69fe0c902e80 --- /dev/null +++ b/config/initializers/fast_gettext.rb @@ -0,0 +1,5 @@ +FastGettext.add_text_domain 'gitlab', path: File.join(Rails.root, 'locale'), type: :po +FastGettext.default_text_domain = 'gitlab' +FastGettext.default_available_locales = Gitlab::I18n.available_locales + +I18n.available_locales = Gitlab::I18n.available_locales diff --git a/config/initializers/gettext_rails_i18n_patch.rb b/config/initializers/gettext_rails_i18n_patch.rb new file mode 100644 index 0000000000000..69118f464caa0 --- /dev/null +++ b/config/initializers/gettext_rails_i18n_patch.rb @@ -0,0 +1,42 @@ +require 'gettext_i18n_rails/haml_parser' +require 'gettext_i18n_rails_js/parser/javascript' + +VUE_TRANSLATE_REGEX = /((%[\w.-]+)(?:\s))?{{ (N|n|s)?__\((.*)\) }}/ + +module GettextI18nRails + class HamlParser + singleton_class.send(:alias_method, :old_convert_to_code, :convert_to_code) + + # We need to convert text in Mustache format + # to a format that can be parsed by Gettext scripts. + # If we found a content like "{{ __('Stage') }}" + # in a HAML file we convert it to "= _('Stage')", that way + # it can be processed by the "rake gettext:find" script. + # + # Overwrites: https://github.com/grosser/gettext_i18n_rails/blob/8396387a431e0f8ead72fc1cd425cad2fa4992f2/lib/gettext_i18n_rails/haml_parser.rb#L9 + def self.convert_to_code(text) + text.gsub!(VUE_TRANSLATE_REGEX, "\\2= \\3_(\\4)") + + old_convert_to_code(text) + end + end +end + +module GettextI18nRailsJs + module Parser + module Javascript + # This is required to tell the `rake gettext:find` script to use the Javascript + # parser for *.vue files. + # + # Overwrites: https://github.com/webhippie/gettext_i18n_rails_js/blob/46c58db6d2053a4f5f36a0eb024ea706ff5707cb/lib/gettext_i18n_rails_js/parser/javascript.rb#L36 + def target?(file) + [ + ".js", + ".jsx", + ".coffee", + ".vue" + ].include? ::File.extname(file) + end + end + end +end diff --git a/config/locales/de.yml b/config/locales/de.yml new file mode 100644 index 0000000000000..533663a270495 --- /dev/null +++ b/config/locales/de.yml @@ -0,0 +1,219 @@ +--- +de: + activerecord: + errors: + messages: + record_invalid: 'Gültigkeitsprüfung ist fehlgeschlagen: %{errors}' + restrict_dependent_destroy: + has_one: Datensatz kann nicht gelöscht werden, da ein abhängiger %{record}-Datensatz + existiert. + has_many: Datensatz kann nicht gelöscht werden, da abhängige %{record} existieren. + date: + abbr_day_names: + - So + - Mo + - Di + - Mi + - Do + - Fr + - Sa + abbr_month_names: + - + - Jan + - Feb + - Mär + - Apr + - Mai + - Jun + - Jul + - Aug + - Sep + - Okt + - Nov + - Dez + day_names: + - Sonntag + - Montag + - Dienstag + - Mittwoch + - Donnerstag + - Freitag + - Samstag + formats: + default: "%d.%m.%Y" + long: "%e. %B %Y" + short: "%e. %b" + month_names: + - + - Januar + - Februar + - März + - April + - Mai + - Juni + - Juli + - August + - September + - Oktober + - November + - Dezember + order: + - :day + - :month + - :year + datetime: + distance_in_words: + about_x_hours: + one: etwa eine Stunde + other: etwa %{count} Stunden + about_x_months: + one: etwa ein Monat + other: etwa %{count} Monate + about_x_years: + one: etwa ein Jahr + other: etwa %{count} Jahre + almost_x_years: + one: fast ein Jahr + other: fast %{count} Jahre + half_a_minute: eine halbe Minute + less_than_x_minutes: + one: weniger als eine Minute + other: weniger als %{count} Minuten + less_than_x_seconds: + one: weniger als eine Sekunde + other: weniger als %{count} Sekunden + over_x_years: + one: mehr als ein Jahr + other: mehr als %{count} Jahre + x_days: + one: ein Tag + other: "%{count} Tage" + x_minutes: + one: eine Minute + other: "%{count} Minuten" + x_months: + one: ein Monat + other: "%{count} Monate" + x_seconds: + one: eine Sekunde + other: "%{count} Sekunden" + prompts: + day: Tag + hour: Stunden + minute: Minute + month: Monat + second: Sekunde + year: Jahr + errors: + format: "%{attribute} %{message}" + messages: + accepted: muss akzeptiert werden + blank: muss ausgefüllt werden + present: darf nicht ausgefüllt werden + confirmation: stimmt nicht mit %{attribute} überein + empty: muss ausgefüllt werden + equal_to: muss genau %{count} sein + even: muss gerade sein + exclusion: ist nicht verfügbar + greater_than: muss größer als %{count} sein + greater_than_or_equal_to: muss größer oder gleich %{count} sein + inclusion: ist kein gültiger Wert + invalid: ist nicht gültig + less_than: muss kleiner als %{count} sein + less_than_or_equal_to: muss kleiner oder gleich %{count} sein + model_invalid: 'Gültigkeitsprüfung ist fehlgeschlagen: %{errors}' + not_a_number: ist keine Zahl + not_an_integer: muss ganzzahlig sein + odd: muss ungerade sein + required: muss ausgefüllt werden + taken: ist bereits vergeben + too_long: + one: ist zu lang (mehr als 1 Zeichen) + other: ist zu lang (mehr als %{count} Zeichen) + too_short: + one: ist zu kurz (weniger als 1 Zeichen) + other: ist zu kurz (weniger als %{count} Zeichen) + wrong_length: + one: hat die falsche Länge (muss genau 1 Zeichen haben) + other: hat die falsche Länge (muss genau %{count} Zeichen haben) + other_than: darf nicht gleich %{count} sein + template: + body: 'Bitte überprüfen Sie die folgenden Felder:' + header: + one: 'Konnte %{model} nicht speichern: ein Fehler.' + other: 'Konnte %{model} nicht speichern: %{count} Fehler.' + helpers: + select: + prompt: Bitte wählen + submit: + create: "%{model} erstellen" + submit: "%{model} speichern" + update: "%{model} aktualisieren" + number: + currency: + format: + delimiter: "." + format: "%n %u" + precision: 2 + separator: "," + significant: false + strip_insignificant_zeros: false + unit: "€" + format: + delimiter: "." + precision: 2 + separator: "," + significant: false + strip_insignificant_zeros: false + human: + decimal_units: + format: "%n %u" + units: + billion: + one: Milliarde + other: Milliarden + million: + one: Million + other: Millionen + quadrillion: + one: Billiarde + other: Billiarden + thousand: Tausend + trillion: + one: Billion + other: Billionen + unit: '' + format: + delimiter: '' + precision: 3 + significant: true + strip_insignificant_zeros: true + storage_units: + format: "%n %u" + units: + byte: + one: Byte + other: Bytes + gb: GB + kb: KB + mb: MB + tb: TB + percentage: + format: + delimiter: '' + format: "%n %" + precision: + format: + delimiter: '' + support: + array: + last_word_connector: " und " + two_words_connector: " und " + words_connector: ", " + time: + am: vormittags + formats: + default: "%A, %d. %B %Y, %H:%M Uhr" + long: "%A, %d. %B %Y, %H:%M Uhr" + short: "%d. %B, %H:%M Uhr" + pm: nachmittags diff --git a/config/locales/es.yml b/config/locales/es.yml new file mode 100644 index 0000000000000..87e79beee7460 --- /dev/null +++ b/config/locales/es.yml @@ -0,0 +1,217 @@ +--- +es: + activerecord: + errors: + messages: + record_invalid: "La validación falló: %{errors}" + restrict_dependent_destroy: + has_one: No se puede eliminar el registro porque existe un %{record} dependiente + has_many: No se puede eliminar el registro porque existen %{record} dependientes + date: + abbr_day_names: + - dom + - lun + - mar + - mié + - jue + - vie + - sáb + abbr_month_names: + - + - ene + - feb + - mar + - abr + - may + - jun + - jul + - ago + - sep + - oct + - nov + - dic + day_names: + - domingo + - lunes + - martes + - miércoles + - jueves + - viernes + - sábado + formats: + default: "%d/%m/%Y" + long: "%d de %B de %Y" + short: "%d de %b" + month_names: + - + - enero + - febrero + - marzo + - abril + - mayo + - junio + - julio + - agosto + - septiembre + - octubre + - noviembre + - diciembre + order: + - :day + - :month + - :year + datetime: + distance_in_words: + about_x_hours: + one: alrededor de 1 hora + other: alrededor de %{count} horas + about_x_months: + one: alrededor de 1 mes + other: alrededor de %{count} meses + about_x_years: + one: alrededor de 1 año + other: alrededor de %{count} años + almost_x_years: + one: casi 1 año + other: casi %{count} años + half_a_minute: medio minuto + less_than_x_minutes: + one: menos de 1 minuto + other: menos de %{count} minutos + less_than_x_seconds: + one: menos de 1 segundo + other: menos de %{count} segundos + over_x_years: + one: más de 1 año + other: más de %{count} años + x_days: + one: 1 dÃa + other: "%{count} dÃas" + x_minutes: + one: 1 minuto + other: "%{count} minutos" + x_months: + one: 1 mes + other: "%{count} meses" + x_years: + one: 1 año + other: "%{count} años" + x_seconds: + one: 1 segundo + other: "%{count} segundos" + prompts: + day: DÃa + hour: Hora + minute: Minutos + month: Mes + second: Segundos + year: Año + errors: + format: "%{attribute} %{message}" + messages: + accepted: debe ser aceptado + blank: no puede estar en blanco + present: debe estar en blanco + confirmation: no coincide + empty: no puede estar vacÃo + equal_to: debe ser igual a %{count} + even: debe ser par + exclusion: está reservado + greater_than: debe ser mayor que %{count} + greater_than_or_equal_to: debe ser mayor que o igual a %{count} + inclusion: no está incluido en la lista + invalid: no es válido + less_than: debe ser menor que %{count} + less_than_or_equal_to: debe ser menor que o igual a %{count} + model_invalid: "La validación falló: %{errors}" + not_a_number: no es un número + not_an_integer: debe ser un entero + odd: debe ser impar + required: debe existir + taken: ya está en uso + too_long: + one: "es demasiado largo (1 carácter máximo)" + other: "es demasiado largo (%{count} caracteres máximo)" + too_short: + one: "es demasiado corto (1 carácter mÃnimo)" + other: "es demasiado corto (%{count} caracteres mÃnimo)" + wrong_length: + one: "no tiene la longitud correcta (1 carácter exactos)" + other: "no tiene la longitud correcta (%{count} caracteres exactos)" + other_than: debe ser distinto de %{count} + template: + body: 'Se encontraron problemas con los siguientes campos:' + header: + one: No se pudo guardar este/a %{model} porque se encontró 1 error + other: No se pudo guardar este/a %{model} porque se encontraron %{count} errores + helpers: + select: + prompt: Por favor seleccione + submit: + create: Crear %{model} + submit: Guardar %{model} + update: Actualizar %{model} + number: + currency: + format: + delimiter: "." + format: "%n %u" + precision: 2 + separator: "," + significant: false + strip_insignificant_zeros: false + unit: "€" + format: + delimiter: "." + precision: 3 + separator: "," + significant: false + strip_insignificant_zeros: false + human: + decimal_units: + format: "%n %u" + units: + billion: mil millones + million: + one: millón + other: millones + quadrillion: mil billones + thousand: mil + trillion: + one: billón + other: billones + unit: '' + format: + delimiter: '' + precision: 1 + significant: true + strip_insignificant_zeros: true + storage_units: + format: "%n %u" + units: + byte: + one: Byte + other: Bytes + gb: GB + kb: KB + mb: MB + tb: TB + percentage: + format: + delimiter: '' + format: "%n %" + precision: + format: + delimiter: '' + support: + array: + last_word_connector: " y " + two_words_connector: " y " + words_connector: ", " + time: + am: am + formats: + default: "%A, %d de %B de %Y %H:%M:%S %z" + long: "%d de %B de %Y %H:%M" + short: "%d de %b %H:%M" + pm: pm diff --git a/config/routes/project.rb b/config/routes/project.rb index 7cfd64fd8361e..7822c5d2bf3be 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -157,6 +157,7 @@ post :cancel post :retry get :builds + get :failures get :status end end @@ -218,6 +219,7 @@ get :download get :browse, path: 'browse(/*path)', format: false get :file, path: 'file/*path', format: false + get :raw, path: 'raw/*path', format: false post :keep end end diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index eccd3999391f8..0d9a9d06aeb79 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -53,6 +53,7 @@ - [pages, 1] - [system_hook_push, 1] - [update_user_activity, 1] + - [propagate_service_template, 1] # EE specific queues - [geo, 1] - [project_mirror, 1] @@ -63,4 +64,3 @@ - [elastic_indexer, 1] - [elastic_commit_indexer, 1] - [export_csv, 1] - diff --git a/config/webpack.config.js b/config/webpack.config.js index 5bb636e7d99a1..772392833c20c 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -17,6 +17,10 @@ var DEV_SERVER_LIVERELOAD = process.env.DEV_SERVER_LIVERELOAD !== 'false'; var WEBPACK_REPORT = process.env.WEBPACK_REPORT; var config = { + // because sqljs requires fs. + node: { + fs: "empty" + }, context: path.join(ROOT_PATH, 'app/assets/javascripts'), entry: { blob: './blob_edit/blob_bundle.js', @@ -27,6 +31,7 @@ var config = { common_d3: ['d3'], cycle_analytics: './cycle_analytics/cycle_analytics_bundle.js', commit_pipelines: './commit/pipelines/pipelines_bundle.js', + deploy_keys: './deploy_keys/index.js', diff_notes: './diff_notes/diff_notes_bundle.js', environments: './environments/environments_bundle.js', environments_folder: './environments/folder/environments_folder_bundle.js', @@ -35,8 +40,8 @@ var config = { group: './group.js', groups_list: './groups_list.js', issues: './issues/issues_bundle.js', - issuable: './issuable/issuable_bundle.js', issue_show: './issue_show/index.js', + locale: './locale/index.js', main: './main.js', merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js', merge_request_widget: './merge_request_widget/ci_bundle.js', @@ -46,15 +51,18 @@ var config = { notebook_viewer: './blob/notebook_viewer.js', pdf_viewer: './blob/pdf_viewer.js', pipelines: './pipelines/index.js', + balsamiq_viewer: './blob/balsamiq_viewer.js', profile: './profile/profile_bundle.js', protected_branches: './protected_branches/protected_branches_bundle.js', protected_tags: './protected_tags', + sidebar: './sidebar/sidebar_bundle.js', snippet: './snippet/snippet_bundle.js', sketch_viewer: './blob/sketch_viewer.js', stl_viewer: './blob/stl_viewer.js', terminal: './terminal/terminal_bundle.js', u2f: ['vendor/u2f'], users: './users/users_bundle.js', + raven: './raven/index.js', }, output: { @@ -90,6 +98,10 @@ var config = { exclude: /node_modules/, loader: 'file-loader', }, + { + test: /locale\/[a-z]+\/(.*)\.js$/, + loader: 'exports-loader?locales', + }, ] }, @@ -125,10 +137,11 @@ var config = { 'boards', 'commit_pipelines', 'cycle_analytics', + 'deploy_keys', 'diff_notes', 'environments', 'environments_folder', - 'issuable', + 'sidebar', 'issue_show', 'merge_conflicts', 'notebook_viewer', @@ -157,6 +170,14 @@ var config = { new webpack.optimize.CommonsChunkPlugin({ names: ['main', 'common', 'runtime'], }), + + // locale common library + new webpack.optimize.CommonsChunkPlugin({ + name: 'locale', + chunks: [ + 'cycle_analytics', + ], + }), ], resolve: { diff --git a/db/fixtures/development/09_issues.rb b/db/fixtures/development/09_issues.rb index d93d133d15767..0b32a461d56b1 100644 --- a/db/fixtures/development/09_issues.rb +++ b/db/fixtures/development/09_issues.rb @@ -8,7 +8,7 @@ description: FFaker::Lorem.sentence, state: ['opened', 'closed'].sample, milestone: project.milestones.sample, - assignee: project.team.users.sample + assignees: [project.team.users.sample] } Issues::CreateService.new(project, project.team.users.sample, issue_params).execute diff --git a/db/fixtures/development/20_burndown.rb b/db/fixtures/development/20_burndown.rb index f6a92d5275a55..c31327fc85729 100644 --- a/db/fixtures/development/20_burndown.rb +++ b/db/fixtures/development/20_burndown.rb @@ -49,7 +49,7 @@ def create_issues description: FFaker::Lorem.sentence, state: 'opened', milestone: @milestone, - assignee: @project.team.users.sample, + assignees: [@project.team.users.sample], weight: rand(1..9) } diff --git a/db/migrate/20170320171632_create_issue_assignees_table.rb b/db/migrate/20170320171632_create_issue_assignees_table.rb new file mode 100644 index 0000000000000..23b8da37b6d75 --- /dev/null +++ b/db/migrate/20170320171632_create_issue_assignees_table.rb @@ -0,0 +1,40 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class CreateIssueAssigneesTable < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + INDEX_NAME = 'index_issue_assignees_on_issue_id_and_user_id' + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def up + create_table :issue_assignees do |t| + t.references :user, foreign_key: { on_delete: :cascade }, index: true, null: false + t.references :issue, foreign_key: { on_delete: :cascade }, null: false + end + + add_index :issue_assignees, [:issue_id, :user_id], unique: true, name: INDEX_NAME + end + + def down + drop_table :issue_assignees + end +end diff --git a/db/migrate/20170320173259_migrate_assignees.rb b/db/migrate/20170320173259_migrate_assignees.rb new file mode 100644 index 0000000000000..ba8edbd7d32e2 --- /dev/null +++ b/db/migrate/20170320173259_migrate_assignees.rb @@ -0,0 +1,52 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class MigrateAssignees < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + disable_ddl_transaction! + + def up + # Optimisation: this accounts for most of the invalid assignee IDs on GitLab.com + update_column_in_batches(:issues, :assignee_id, nil) do |table, query| + query.where(table[:assignee_id].eq(0)) + end + + users = Arel::Table.new(:users) + + update_column_in_batches(:issues, :assignee_id, nil) do |table, query| + query.where(table[:assignee_id].not_eq(nil)\ + .and( + users.project("true").where(users[:id].eq(table[:assignee_id])).exists.not + )) + end + + execute <<-EOF + INSERT INTO issue_assignees(issue_id, user_id) + SELECT id, assignee_id FROM issues WHERE assignee_id IS NOT NULL + EOF + end + + def down + execute <<-EOF + DELETE FROM issue_assignees + EOF + end +end diff --git a/db/migrate/20170413035209_add_preferred_language_to_users.rb b/db/migrate/20170413035209_add_preferred_language_to_users.rb new file mode 100644 index 0000000000000..92f1d6f243633 --- /dev/null +++ b/db/migrate/20170413035209_add_preferred_language_to_users.rb @@ -0,0 +1,16 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddPreferredLanguageToUsers < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + add_column :users, :preferred_language, :string + end + + def down + remove_column :users, :preferred_language + end +end diff --git a/db/migrate/20170503021915_add_last_edited_at_and_last_edited_by_id_to_issues.rb b/db/migrate/20170503021915_add_last_edited_at_and_last_edited_by_id_to_issues.rb new file mode 100644 index 0000000000000..6ac10723c82f9 --- /dev/null +++ b/db/migrate/20170503021915_add_last_edited_at_and_last_edited_by_id_to_issues.rb @@ -0,0 +1,14 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddLastEditedAtAndLastEditedByIdToIssues < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + add_column :issues, :last_edited_at, :timestamp + add_column :issues, :last_edited_by_id, :integer + end +end diff --git a/db/migrate/20170503022548_add_last_edited_at_and_last_edited_by_id_to_merge_requests.rb b/db/migrate/20170503022548_add_last_edited_at_and_last_edited_by_id_to_merge_requests.rb new file mode 100644 index 0000000000000..7a1acdcbf69ef --- /dev/null +++ b/db/migrate/20170503022548_add_last_edited_at_and_last_edited_by_id_to_merge_requests.rb @@ -0,0 +1,14 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddLastEditedAtAndLastEditedByIdToMergeRequests < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + add_column :merge_requests, :last_edited_at, :timestamp + add_column :merge_requests, :last_edited_by_id, :integer + end +end diff --git a/db/migrate/20170504102911_add_clientside_sentry_to_application_settings.rb b/db/migrate/20170504102911_add_clientside_sentry_to_application_settings.rb new file mode 100644 index 0000000000000..141112f8b5084 --- /dev/null +++ b/db/migrate/20170504102911_add_clientside_sentry_to_application_settings.rb @@ -0,0 +1,33 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddClientsideSentryToApplicationSettings < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using "add_concurrent_index" or "remove_concurrent_index" methods make sure + # that either of them is the _only_ method called in the migration, + # any other changes should go in a separate migration. + # This ensures that upon failure _only_ the index creation or removing fails + # and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + disable_ddl_transaction! + + def up + add_column_with_default :application_settings, :clientside_sentry_enabled, :boolean, default: false + add_column :application_settings, :clientside_sentry_dsn, :string + end + + def down + remove_columns :application_settings, :clientside_sentry_enabled, :clientside_sentry_dsn + end +end diff --git a/db/schema.rb b/db/schema.rb index 731320e4f6005..e6afe36bf02ac 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170503185032) do +ActiveRecord::Schema.define(version: 20170504102911) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -135,6 +135,8 @@ t.decimal "polling_interval_multiplier", default: 1.0, null: false t.boolean "elasticsearch_experimental_indexer" t.integer "cached_markdown_version" + t.boolean "clientside_sentry_enabled", default: false, null: false + t.string "clientside_sentry_dsn" end create_table "approvals", force: :cascade do |t| @@ -538,6 +540,14 @@ add_index "index_statuses", ["project_id"], name: "index_index_statuses_on_project_id", unique: true, using: :btree + create_table "issue_assignees", force: :cascade do |t| + t.integer "user_id", null: false + t.integer "issue_id", null: false + end + + add_index "issue_assignees", ["issue_id", "user_id"], name: "index_issue_assignees_on_issue_id_and_user_id", unique: true, using: :btree + add_index "issue_assignees", ["user_id"], name: "index_issue_assignees_on_user_id", using: :btree + create_table "issue_metrics", force: :cascade do |t| t.integer "issue_id", null: false t.datetime "first_mentioned_in_commit_at" @@ -576,6 +586,8 @@ t.datetime "closed_at" t.string "service_desk_reply_to" t.integer "cached_markdown_version" + t.datetime "last_edited_at" + t.integer "last_edited_by_id" end add_index "issues", ["assignee_id"], name: "index_issues_on_assignee_id", using: :btree @@ -782,6 +794,8 @@ t.integer "time_estimate" t.boolean "squash", default: false, null: false t.integer "cached_markdown_version" + t.datetime "last_edited_at" + t.integer "last_edited_by_id" end add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree @@ -1538,12 +1552,13 @@ t.string "organization" t.boolean "authorized_projects_populated" t.boolean "auditor", default: false, null: false + t.boolean "ghost" t.boolean "require_two_factor_authentication_from_group", default: false, null: false t.integer "two_factor_grace_period", default: 48, null: false - t.boolean "ghost" t.date "last_activity_on" t.boolean "notified_of_own_activity" t.boolean "support_bot" + t.string "preferred_language" end add_index "users", ["admin"], name: "index_users_on_admin", using: :btree @@ -1605,6 +1620,8 @@ add_foreign_key "ci_trigger_schedules", "ci_triggers", column: "trigger_id", name: "fk_90a406cc94", on_delete: :cascade add_foreign_key "ci_triggers", "users", column: "owner_id", name: "fk_e8e10d1964", on_delete: :cascade add_foreign_key "container_repositories", "projects" + add_foreign_key "issue_assignees", "issues", on_delete: :cascade + add_foreign_key "issue_assignees", "users", on_delete: :cascade add_foreign_key "issue_metrics", "issues", on_delete: :cascade add_foreign_key "label_priorities", "labels", on_delete: :cascade add_foreign_key "label_priorities", "projects", on_delete: :cascade diff --git a/doc/api/issues.md b/doc/api/issues.md index da177ce6b75a3..75b1ab79f36ed 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -70,6 +70,14 @@ Example response: "updated_at" : "2016-01-04T15:31:39.996Z" }, "project_id" : 1, + "assignees" : [{ + "state" : "active", + "id" : 1, + "name" : "Administrator", + "web_url" : "https://gitlab.example.com/root", + "avatar_url" : null, + "username" : "root" + }], "assignee" : { "state" : "active", "id" : 1, @@ -93,6 +101,8 @@ Example response: ] ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## List group issues Get a list of a group's issues. @@ -154,6 +164,14 @@ Example response: "description" : "Omnis vero earum sunt corporis dolor et placeat.", "state" : "closed", "iid" : 1, + "assignees" : [{ + "avatar_url" : null, + "web_url" : "https://gitlab.example.com/lennie", + "state" : "active", + "username" : "lennie", + "id" : 9, + "name" : "Dr. Luella Kovacek" + }], "assignee" : { "avatar_url" : null, "web_url" : "https://gitlab.example.com/lennie", @@ -176,6 +194,8 @@ Example response: ] ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## List project issues Get a list of a project's issues. @@ -237,6 +257,14 @@ Example response: "description" : "Omnis vero earum sunt corporis dolor et placeat.", "state" : "closed", "iid" : 1, + "assignees" : [{ + "avatar_url" : null, + "web_url" : "https://gitlab.example.com/lennie", + "state" : "active", + "username" : "lennie", + "id" : 9, + "name" : "Dr. Luella Kovacek" + }], "assignee" : { "avatar_url" : null, "web_url" : "https://gitlab.example.com/lennie", @@ -259,6 +287,8 @@ Example response: ] ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Single issue Get a single project issue. @@ -303,6 +333,14 @@ Example response: "description" : "Omnis vero earum sunt corporis dolor et placeat.", "state" : "closed", "iid" : 1, + "assignees" : [{ + "avatar_url" : null, + "web_url" : "https://gitlab.example.com/lennie", + "state" : "active", + "username" : "lennie", + "id" : 9, + "name" : "Dr. Luella Kovacek" + }], "assignee" : { "avatar_url" : null, "web_url" : "https://gitlab.example.com/lennie", @@ -325,6 +363,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## New issue Creates a new project issue. @@ -333,20 +373,20 @@ Creates a new project issue. POST /projects/:id/issues ``` -| Attribute | Type | Required | Description | -|-------------------------------------------|---------|----------|--------------| +| Attribute | Type | Required | Description | +|-------------------------------------------|----------------|----------|--------------| | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | -| `title` | string | yes | The title of an issue | -| `description` | string | no | The description of an issue | -| `confidential` | boolean | no | Set an issue to be confidential. Default is `false`. | -| `assignee_id` | integer | no | The ID of a user to assign issue | -| `milestone_id` | integer | no | The ID of a milestone to assign issue | -| `labels` | string | no | Comma-separated label names for an issue | -| `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. `2016-03-11T03:45:40Z` (requires admin or project owner rights) | -| `due_date` | string | no | Date time string in the format YEAR-MONTH-DAY, e.g. `2016-03-11` | -| `merge_request_to_resolve_discussions_of` | integer | no | The IID of a merge request in which to resolve all issues. This will fill the issue with a default description and mark all discussions as resolved. When passing a description or title, these values will take precedence over the default values.| -| `discussion_to_resolve` | string | no | The ID of a discussion to resolve. This will fill in the issue with a default description and mark the discussion as resolved. Use in combination with `merge_request_to_resolve_discussions_of`. | -| `weight` | integer | no | The weight of the issue in range 0 to 9 | +| `title` | string | yes | The title of an issue | +| `description` | string | no | The description of an issue | +| `confidential` | boolean | no | Set an issue to be confidential. Default is `false`. | +| `assignee_ids` | Array[integer] | no | The ID of a user to assign issue | +| `milestone_id` | integer | no | The ID of a milestone to assign issue | +| `labels` | string | no | Comma-separated label names for an issue | +| `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. `2016-03-11T03:45:40Z` (requires admin or project owner rights) | +| `due_date` | string | no | Date time string in the format YEAR-MONTH-DAY, e.g. `2016-03-11` | +| `merge_request_to_resolve_discussions_of` | integer | no | The IID of a merge request in which to resolve all issues. This will fill the issue with a default description and mark all discussions as resolved. When passing a description or title, these values will take precedence over the default values.| +| `discussion_to_resolve` | string | no | The ID of a discussion to resolve. This will fill in the issue with a default description and mark the discussion as resolved. Use in combination with `merge_request_to_resolve_discussions_of`. | +| `weight` | integer | no | The weight of the issue in range 0 to 9 | ```bash curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/4/issues?title=Issues%20with%20auth&labels=bug @@ -362,6 +402,7 @@ Example response: "iid" : 14, "title" : "Issues with auth", "state" : "opened", + "assignees" : [], "assignee" : null, "labels" : [ "bug" @@ -386,6 +427,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Edit issue Updates an existing project issue. This call is also used to mark an issue as @@ -402,7 +445,7 @@ PUT /projects/:id/issues/:issue_iid | `title` | string | no | The title of an issue | | `description` | string | no | The description of an issue | | `confidential` | boolean | no | Updates an issue to be confidential | -| `assignee_id` | integer | no | The ID of a user to assign the issue to | +| `assignee_ids` | Array[integer] | no | The ID of a user to assign the issue to | | `milestone_id` | integer | no | The ID of a milestone to assign the issue to | | `labels` | string | no | Comma-separated label names for an issue | | `state_event` | string | no | The state event of an issue. Set `close` to close the issue and `reopen` to reopen it | @@ -437,6 +480,7 @@ Example response: "bug" ], "id" : 85, + "assignees" : [], "assignee" : null, "milestone" : null, "subscribed" : true, @@ -448,6 +492,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Delete an issue Only for admins and project owners. Soft deletes the issue in question. @@ -502,6 +548,14 @@ Example response: "updated_at": "2016-04-07T12:20:17.596Z", "labels": [], "milestone": null, + "assignees": [{ + "name": "Miss Monserrate Beier", + "username": "axel.block", + "id": 12, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon", + "web_url": "https://gitlab.example.com/axel.block" + }], "assignee": { "name": "Miss Monserrate Beier", "username": "axel.block", @@ -525,6 +579,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Subscribe to an issue Subscribes the authenticated user to an issue to receive notifications. @@ -558,6 +614,14 @@ Example response: "updated_at": "2016-04-07T12:20:17.596Z", "labels": [], "milestone": null, + "assignees": [{ + "name": "Miss Monserrate Beier", + "username": "axel.block", + "id": 12, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon", + "web_url": "https://gitlab.example.com/axel.block" + }], "assignee": { "name": "Miss Monserrate Beier", "username": "axel.block", @@ -581,6 +645,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Unsubscribe from an issue Unsubscribes the authenticated user from the issue to not receive notifications @@ -662,6 +728,14 @@ Example response: "updated_at": "2016-06-17T07:47:33.832Z", "due_date": null }, + "assignees": [{ + "name": "Jarret O'Keefe", + "username": "francisca", + "id": 14, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/a7fa515d53450023c83d62986d0658a8?s=80&d=identicon", + "web_url": "https://gitlab.example.com/francisca" + }], "assignee": { "name": "Jarret O'Keefe", "username": "francisca", @@ -694,6 +768,8 @@ Example response: } ``` +**Note**: `assignee` column is deprecated, it shows the first assignee only. + ## Set a time estimate for an issue Sets an estimated time of work for this issue. diff --git a/doc/development/README.md b/doc/development/README.md index c86ce12403e89..bffd3fafdc9ca 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -42,6 +42,7 @@ - [Shell commands](shell_commands.md) in the GitLab codebase - [Sidekiq debugging](sidekiq_debugging.md) - [Object state models](object_state_models.md) +- [Building a package for testing purposes](build_test_package.md) ## Databases diff --git a/doc/development/build_test_package.md b/doc/development/build_test_package.md new file mode 100644 index 0000000000000..2bc1a7008444d --- /dev/null +++ b/doc/development/build_test_package.md @@ -0,0 +1,35 @@ +# Building a package for testing + +While developing a new feature or modifying an existing one, it is helpful if an +installable package (or a docker image) containing those changes is available +for testing. For this very purpose, a manual job is provided in the GitLab CI/CD +pipeline that can be used to trigger a pipeline in the omnibus-gitlab repository +that will create +1. A deb package for Ubuntu 16.04, available as a build artifact, and +2. A docker image, which is pushed to [Omnibus GitLab's container +registry](https://gitlab.com/gitlab-org/omnibus-gitlab/container_registry) +(images titled `gitlab-ce` and `gitlab-ee` respectively and image tag is the +commit which triggered the pipeline). + +When you push a commit to either the gitlab-ce or gitlab-ee project, the +pipeline for that commit will have a `build-package` manual action you can +trigger. + +## Specifying versions of components + +If you want to create a package from a specific branch, commit or tag of any of +the GitLab components (like GitLab Workhorse, Gitaly, GitLab Pages, etc.), you +can specify the branch name, commit sha or tag in the component's respective +`*_VERSION` file. For example, if you want to build a package that uses the +branch `0-1-stable`, modify the content of `GITALY_SERVER_VERSION` to +`0-1-stable` and push the commit. This will create a manual job that can be +used to trigger the build. + +## Specifying the branch in omnibus-gitlab repository + +In scenarios where a configuration change is to be introduced and omnibus-gitlab +repository already has the necessary changes in a specific branch, you can build +a package against that branch through an environment variable named +`OMNIBUS_BRANCH`. To do this, specify that environment variable with the name of +the branch as value in `.gitlab-ci.yml` and push a commit. This will create a +manual job that can be used to trigger the build. diff --git a/doc/development/code_review.md b/doc/development/code_review.md index 819578404b6fb..be3dd1e2cc69b 100644 --- a/doc/development/code_review.md +++ b/doc/development/code_review.md @@ -1,5 +1,25 @@ # Code Review Guidelines +## Getting your merge request reviewed, approved, and merged + +There are a few rules to get your merge request accepted: + +1. Your merge request should only be **merged by a [maintainer][team]**. + 1. If your merge request includes only backend changes [^1], it must be + **approved by a [backend maintainer][team]**. + 1. If your merge request includes only frontend changes [^1], it must be + **approved by a [frontend maintainer][team]**. + 1. If your merge request includes frontend and backend changes [^1], it must + be **approved by a [frontend and a backend maintainer][team]**. +1. To lower the amount of merge requests maintainers need to review, you can + ask or assign any [reviewers][team] for a first review. + 1. If you need some guidance (e.g. it's your first merge request), feel free + to ask one of the [Merge request coaches][team]. + 1. The reviewer will assign the merge request to a maintainer once the + reviewer is satisfied with the state of the merge request. + +## Best practices + This guide contains advice and best practices for performing code review, and having your code reviewed. @@ -12,7 +32,7 @@ of colleagues and contributors. However, the final decision to accept a merge request is up to one the project's maintainers, denoted on the [team page](https://about.gitlab.com/team). -## Everyone +### Everyone - Accept that many programming decisions are opinions. Discuss tradeoffs, which you prefer, and reach a resolution quickly. @@ -31,8 +51,11 @@ request is up to one the project's maintainers, denoted on the - Consider one-on-one chats or video calls if there are too many "I didn't understand" or "Alternative solution:" comments. Post a follow-up comment summarizing one-on-one discussion. +- If you ask a question to a specific person, always start the comment by + mentioning them; this will ensure they see it if their notification level is + set to "mentioned" and other people will understand they don't have to respond. -## Having your code reviewed +### Having your code reviewed Please keep in mind that code review is a process that can take multiple iterations, and reviewers may spot things later that they may not have seen the @@ -50,11 +73,12 @@ first time. - Extract unrelated changes and refactorings into future merge requests/issues. - Seek to understand the reviewer's perspective. - Try to respond to every comment. +- Let the reviewer select the "Resolve discussion" buttons. - Push commits based on earlier rounds of feedback as isolated commits to the branch. Do not squash until the branch is ready to merge. Reviewers should be able to read individual updates based on their earlier feedback. -## Reviewing code +### Reviewing code Understand why the change is necessary (fixes a bug, improves the user experience, refactors the existing code). Then: @@ -69,12 +93,19 @@ experience, refactors the existing code). Then: someone else would be confused by it as well. - After a round of line notes, it can be helpful to post a summary note such as "LGTM :thumbsup:", or "Just a couple things to address." +- Assign the merge request to the author if changes are required following your + review. +- Set the milestone before merging a merge request. - Avoid accepting a merge request before the job succeeds. Of course, "Merge When Pipeline Succeeds" (MWPS) is fine. - If you set the MR to "Merge When Pipeline Succeeds", you should take over subsequent revisions for anything that would be spotted after that. +- Consider using the [Squash and + merge][squash-and-merge] feature when the merge request has a lot of commits. + +[squash-and-merge]: https://docs.gitlab.com/ee/user/project/merge_requests/squash_and_merge.html#squash-and-merge -## The right balance +### The right balance One of the most difficult things during code review is finding the right balance in how deep the reviewer can interfere with the code created by a @@ -100,7 +131,7 @@ reviewee. tomorrow. When you are not able to find the right balance, ask other people about their opinion. -## Credits +### Credits Largely based on the [thoughtbot code review guide]. diff --git a/doc/development/fe_guide/droplab/droplab.md b/doc/development/fe_guide/droplab/droplab.md index 8f0b6b21953a0..112ff3419d975 100644 --- a/doc/development/fe_guide/droplab/droplab.md +++ b/doc/development/fe_guide/droplab/droplab.md @@ -183,6 +183,8 @@ For example, either by a mouse click or by enter key selection. * The `droplab-item-active` css class is added to items that have been selected using arrow key navigation. +* You can add the `droplab-item-ignore` css class to any item that you do not want to be selectable. For example, +an `<li class="divider"></li>` list divider element that should not be interactive. ## Internal events diff --git a/doc/development/fe_guide/style_guide_js.md b/doc/development/fe_guide/style_guide_js.md index 1d2b055894830..d2d895172410f 100644 --- a/doc/development/fe_guide/style_guide_js.md +++ b/doc/development/fe_guide/style_guide_js.md @@ -11,207 +11,205 @@ See [our current .eslintrc][eslintrc] for specific rules and patterns. #### ESlint -- **Never** disable eslint rules unless you have a good reason. You may see a lot of legacy files with `/* eslint-disable some-rule, some-other-rule */` at the top, but legacy files are a special case. Any time you develop a new feature or refactor an existing one, you should abide by the eslint rules. - -- **Never Ever EVER** disable eslint globally for a file +1. **Never** disable eslint rules unless you have a good reason. +You may see a lot of legacy files with `/* eslint-disable some-rule, some-other-rule */` +at the top, but legacy files are a special case. Any time you develop a new feature or +refactor an existing one, you should abide by the eslint rules. +1. **Never Ever EVER** disable eslint globally for a file ```javascript - // bad - /* eslint-disable */ + // bad + /* eslint-disable */ - // better - /* eslint-disable some-rule, some-other-rule */ + // better + /* eslint-disable some-rule, some-other-rule */ - // best - // nothing :) + // best + // nothing :) ``` -- If you do need to disable a rule for a single violation, try to do it as locally as possible - +1. If you do need to disable a rule for a single violation, try to do it as locally as possible ```javascript - // bad - /* eslint-disable no-new */ + // bad + /* eslint-disable no-new */ - import Foo from 'foo'; + import Foo from 'foo'; - new Foo(); + new Foo(); - // better - import Foo from 'foo'; + // better + import Foo from 'foo'; - // eslint-disable-next-line no-new - new Foo(); + // eslint-disable-next-line no-new + new Foo(); ``` +1. There are few rules that we need to disable due to technical debt. Which are: + 1. [no-new][eslint-new] + 1. [class-methods-use-this][eslint-this] -- When they are needed _always_ place ESlint directive comment blocks on the first line of a script, followed by any global declarations, then a blank newline prior to any imports or code. - +1. When they are needed _always_ place ESlint directive comment blocks on the first line of a script, +followed by any global declarations, then a blank newline prior to any imports or code. ```javascript - // bad - /* global Foo */ - /* eslint-disable no-new */ - import Bar from './bar'; + // bad + /* global Foo */ + /* eslint-disable no-new */ + import Bar from './bar'; - // good - /* eslint-disable no-new */ - /* global Foo */ + // good + /* eslint-disable no-new */ + /* global Foo */ - import Bar from './bar'; + import Bar from './bar'; ``` -- **Never** disable the `no-undef` rule. Declare globals with `/* global Foo */` instead. - -- When declaring multiple globals, always use one `/* global [name] */` line per variable. +1. **Never** disable the `no-undef` rule. Declare globals with `/* global Foo */` instead. +1. When declaring multiple globals, always use one `/* global [name] */` line per variable. ```javascript - // bad - /* globals Flash, Cookies, jQuery */ + // bad + /* globals Flash, Cookies, jQuery */ - // good - /* global Flash */ - /* global Cookies */ - /* global jQuery */ + // good + /* global Flash */ + /* global Cookies */ + /* global jQuery */ ``` - -- Use up to 3 parameters for a function or class. If you need more accept an Object instead. +1. Use up to 3 parameters for a function or class. If you need more accept an Object instead. ```javascript - // bad - fn(p1, p2, p3, p4) {} + // bad + fn(p1, p2, p3, p4) {} - // good - fn(options) {} + // good + fn(options) {} ``` #### Modules, Imports, and Exports -- Use ES module syntax to import modules - +1. Use ES module syntax to import modules ```javascript - // bad - require('foo'); + // bad + require('foo'); - // good - import Foo from 'foo'; + // good + import Foo from 'foo'; - // bad - module.exports = Foo; + // bad + module.exports = Foo; - // good - export default Foo; + // good + export default Foo; ``` -- Relative paths - - Unless you are writing a test, always reference other scripts using relative paths instead of `~` +1. Relative paths: Unless you are writing a test, always reference other scripts using +relative paths instead of `~` + * In **app/assets/javascripts**: - In **app/assets/javascripts**: - ```javascript - // bad - import Foo from '~/foo' - - // good - import Foo from '../foo'; - ``` + ```javascript + // bad + import Foo from '~/foo' - In **spec/javascripts**: - ```javascript - // bad - import Foo from '../../app/assets/javascripts/foo' + // good + import Foo from '../foo'; + ``` + * In **spec/javascripts**: - // good - import Foo from '~/foo'; - ``` + ```javascript + // bad + import Foo from '../../app/assets/javascripts/foo' -- Avoid using IIFE. Although we have a lot of examples of files which wrap their contents in IIFEs (immediately-invoked function expressions), this is no longer necessary after the transition from Sprockets to webpack. Do not use them anymore and feel free to remove them when refactoring legacy code. + // good + import Foo from '~/foo'; + ``` -- Avoid adding to the global namespace. +1. Avoid using IIFE. Although we have a lot of examples of files which wrap their +contents in IIFEs (immediately-invoked function expressions), +this is no longer necessary after the transition from Sprockets to webpack. +Do not use them anymore and feel free to remove them when refactoring legacy code. +1. Avoid adding to the global namespace. ```javascript - // bad - window.MyClass = class { /* ... */ }; + // bad + window.MyClass = class { /* ... */ }; - // good - export default class MyClass { /* ... */ } + // good + export default class MyClass { /* ... */ } ``` -- Side effects are forbidden in any script which contains exports - +1. Side effects are forbidden in any script which contains exports ```javascript - // bad - export default class MyClass { /* ... */ } + // bad + export default class MyClass { /* ... */ } - document.addEventListener("DOMContentLoaded", function(event) { - new MyClass(); - } + document.addEventListener("DOMContentLoaded", function(event) { + new MyClass(); + } ``` #### Data Mutation and Pure functions -- Strive to write many small pure functions, and minimize where mutations occur. - +1. Strive to write many small pure functions, and minimize where mutations occur. ```javascript - // bad - const values = {foo: 1}; + // bad + const values = {foo: 1}; - function impureFunction(items) { - const bar = 1; + function impureFunction(items) { + const bar = 1; - items.foo = items.a * bar + 2; + items.foo = items.a * bar + 2; - return items.a; - } + return items.a; + } - const c = impureFunction(values); + const c = impureFunction(values); - // good - var values = {foo: 1}; + // good + var values = {foo: 1}; - function pureFunction (foo) { - var bar = 1; + function pureFunction (foo) { + var bar = 1; - foo = foo * bar + 2; + foo = foo * bar + 2; - return foo; - } + return foo; + } - var c = pureFunction(values.foo); + var c = pureFunction(values.foo); ``` -- Avoid constructors with side-effects +1. Avoid constructors with side-effects -- Prefer `.map`, `.reduce` or `.filter` over `.forEach` +1. Prefer `.map`, `.reduce` or `.filter` over `.forEach` A forEach will cause side effects, it will be mutating the array being iterated. Prefer using `.map`, `.reduce` or `.filter` - ```javascript - const users = [ { name: 'Foo' }, { name: 'Bar' } ]; + const users = [ { name: 'Foo' }, { name: 'Bar' } ]; - // bad - users.forEach((user, index) => { - user.id = index; - }); + // bad + users.forEach((user, index) => { + user.id = index; + }); - // good - const usersWithId = users.map((user, index) => { - return Object.assign({}, user, { id: index }); - }); + // good + const usersWithId = users.map((user, index) => { + return Object.assign({}, user, { id: index }); + }); ``` #### Parse Strings into Numbers -- `parseInt()` is preferable over `Number()` or `+` - +1. `parseInt()` is preferable over `Number()` or `+` ```javascript - // bad - +'10' // 10 + // bad + +'10' // 10 - // good - Number('10') // 10 + // good + Number('10') // 10 - // better - parseInt('10', 10); + // better + parseInt('10', 10); ``` #### CSS classes used for JavaScript -- If the class is being used in Javascript it needs to be prepend with `js-` +1. If the class is being used in Javascript it needs to be prepend with `js-` ```html // bad <button class="add-user"> @@ -226,234 +224,270 @@ A forEach will cause side effects, it will be mutating the array being iterated. ### Vue.js - #### Basic Rules -- Only include one Vue.js component per file. -- Export components as plain objects: - +1. The service has it's own file +1. The store has it's own file +1. Use a function in the bundle file to instantiate the Vue component: ```javascript - export default { - template: `<h1>I'm a component</h1> - } - ``` + // bad + class { + init() { + new Component({}) + } + } -#### Naming -- **Extensions**: Use `.vue` extension for Vue components. -- **Reference Naming**: Use PascalCase for Vue components and camelCase for their instances: + // good + document.addEventListener('DOMContentLoaded', () => new Vue({ + el: '#element', + components: { + componentName + }, + render: createElement => createElement('component-name'), + })); + ``` +1. Don not use a singleton for the service or the store ```javascript - // bad - import cardBoard from 'cardBoard'; + // bad + class Store { + constructor() { + if (!this.prototype.singleton) { + // do something + } + } + } - // good - import CardBoard from 'cardBoard' + // good + class Store { + constructor() { + // do something + } + } + ``` - // bad - components: { - CardBoard: CardBoard - }; +#### Naming +1. **Extensions**: Use `.vue` extension for Vue components. +1. **Reference Naming**: Use camelCase for their instances: + ```javascript + // good + import cardBoard from 'cardBoard' - // good - components: { - cardBoard: CardBoard - }; + components: { + cardBoard: + }; ``` -- **Props Naming:** -- Avoid using DOM component prop names. -- Use kebab-case instead of camelCase to provide props in templates. - +1. **Props Naming:** Avoid using DOM component prop names. +1. **Props Naming:** Use kebab-case instead of camelCase to provide props in templates. ```javascript - // bad - <component class="btn"> + // bad + <component class="btn"> - // good - <component css-class="btn"> + // good + <component css-class="btn"> - // bad - <component myProp="prop" /> + // bad + <component myProp="prop" /> - // good - <component my-prop="prop" /> -``` + // good + <component my-prop="prop" /> + ``` #### Alignment -- Follow these alignment styles for the template method: - +1. Follow these alignment styles for the template method: ```javascript - // bad - <component v-if="bar" - param="baz" /> + // bad + <component v-if="bar" + param="baz" /> - <button class="btn">Click me</button> + <button class="btn">Click me</button> - // good - <component - v-if="bar" - param="baz" - /> + // good + <component + v-if="bar" + param="baz" + /> - <button class="btn"> - Click me - </button> + <button class="btn"> + Click me + </button> - // if props fit in one line then keep it on the same line - <component bar="bar" /> + // if props fit in one line then keep it on the same line + <component bar="bar" /> ``` #### Quotes -- Always use double quotes `"` inside templates and single quotes `'` for all other JS. - +1. Always use double quotes `"` inside templates and single quotes `'` for all other JS. ```javascript - // bad - template: ` - <button :class='style'>Button</button> - ` - - // good - template: ` - <button :class="style">Button</button> - ` + // bad + template: ` + <button :class='style'>Button</button> + ` + + // good + template: ` + <button :class="style">Button</button> + ` ``` #### Props -- Props should be declared as an object - +1. Props should be declared as an object ```javascript - // bad - props: ['foo'] - - // good - props: { - foo: { - type: String, - required: false, - default: 'bar' + // bad + props: ['foo'] + + // good + props: { + foo: { + type: String, + required: false, + default: 'bar' + } } - } ``` -- Required key should always be provided when declaring a prop - +1. Required key should always be provided when declaring a prop ```javascript - // bad - props: { - foo: { - type: String, + // bad + props: { + foo: { + type: String, + } } - } - - // good - props: { - foo: { - type: String, - required: false, - default: 'bar' + + // good + props: { + foo: { + type: String, + required: false, + default: 'bar' + } } - } ``` -- Default key should always be provided if the prop is not required: - +1. Default key should always be provided if the prop is not required: ```javascript - // bad - props: { - foo: { - type: String, - required: false, + // bad + props: { + foo: { + type: String, + required: false, + } } - } - - // good - props: { - foo: { - type: String, - required: false, - default: 'bar' + + // good + props: { + foo: { + type: String, + required: false, + default: 'bar' + } } - } - // good - props: { - foo: { - type: String, - required: true + // good + props: { + foo: { + type: String, + required: true + } } - } ``` #### Data -- `data` method should always be a function +1. `data` method should always be a function ```javascript - // bad - data: { - foo: 'foo' - } - - // good - data() { - return { + // bad + data: { foo: 'foo' - }; - } + } + + // good + data() { + return { + foo: 'foo' + }; + } ``` #### Directives -- Shorthand `@` is preferable over `v-on` - +1. Shorthand `@` is preferable over `v-on` ```javascript - // bad - <component v-on:click="eventHandler"/> + // bad + <component v-on:click="eventHandler"/> - // good - <component @click="eventHandler"/> + // good + <component @click="eventHandler"/> ``` -- Shorthand `:` is preferable over `v-bind` - +1. Shorthand `:` is preferable over `v-bind` ```javascript - // bad - <component v-bind:class="btn"/> + // bad + <component v-bind:class="btn"/> - // good - <component :class="btn"/> + // good + <component :class="btn"/> ``` #### Closing tags -- Prefer self closing component tags - +1. Prefer self closing component tags ```javascript - // bad - <component></component> + // bad + <component></component> - // good - <component /> + // good + <component /> ``` #### Ordering -- Order for a Vue Component: +1. Order for a Vue Component: 1. `name` - 2. `props` - 3. `data` - 4. `components` - 5. `computedProps` - 6. `methods` - 7. lifecycle methods - 1. `beforeCreate` - 2. `created` - 3. `beforeMount` - 4. `mounted` - 5. `beforeUpdate` - 6. `updated` - 7. `activated` - 8. `deactivated` - 9. `beforeDestroy` - 10. `destroyed` - 8. `template` + 1. `props` + 1. `mixins` + 1. `data` + 1. `components` + 1. `computedProps` + 1. `methods` + 1. `beforeCreate` + 1. `created` + 1. `beforeMount` + 1. `mounted` + 1. `beforeUpdate` + 1. `updated` + 1. `activated` + 1. `deactivated` + 1. `beforeDestroy` + 1. `destroyed` + +#### Vue and Boostrap +1. Tooltips: Do not rely on `has-tooltip` class name for vue components + ```javascript + // bad + <span class="has-tooltip"> + Text + </span> + + // good + <span data-toggle="tooltip"> + Text + </span> + ``` + +1. Tooltips: When using a tooltip, include the tooltip mixin + +1. Don't change `data-original-title`. + ```javascript + // bad + <span data-original-title="tooltip text">Foo</span> + + // good + <span title="tooltip text">Foo</span> + + $('span').tooltip('fixTitle'); + ``` ## SCSS @@ -461,3 +495,5 @@ A forEach will cause side effects, it will be mutating the array being iterated. [airbnb-js-style-guide]: https://github.com/airbnb/javascript [eslintrc]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.eslintrc +[eslint-this]: http://eslint.org/docs/rules/class-methods-use-this +[eslint-new]: http://eslint.org/docs/rules/no-new diff --git a/doc/development/fe_guide/vue.md b/doc/development/fe_guide/vue.md index 73d2ffc1bdc7b..a984bb6c94ce6 100644 --- a/doc/development/fe_guide/vue.md +++ b/doc/development/fe_guide/vue.md @@ -387,6 +387,10 @@ describe('Todos App', () => { }); }); ``` +#### Test the component's output +The main return value of a Vue component is the rendered output. In order to test the component we +need to test the rendered output. [Vue][vue-test] guide's to unit test show us exactly that: + ### Stubbing API responses [Vue Resource Interceptors][vue-resource-interceptor] allow us to add a interceptor with @@ -419,6 +423,16 @@ the response we need: }); ``` +1. Use `$.mount()` to mount the component +```javascript + // bad + new Component({ + el: document.createElement('div') + }); + + // good + new Component().$mount(); +``` [vue-docs]: http://vuejs.org/guide/index.html [issue-boards]: https://gitlab.com/gitlab-org/gitlab-ce/tree/master/app/assets/javascripts/boards @@ -429,5 +443,6 @@ the response we need: [one-way-data-flow]: https://vuejs.org/v2/guide/components.html#One-Way-Data-Flow [vue-resource-repo]: https://github.com/pagekit/vue-resource [vue-resource-interceptor]: https://github.com/pagekit/vue-resource/blob/develop/docs/http.md#interceptors +[vue-test]: https://vuejs.org/v2/guide/unit-testing.html [issue-boards-service]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/assets/javascripts/boards/services/board_service.js.es6 [flux]: https://facebook.github.io/flux diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md index dbdc93a77a8eb..e15daa2feae1b 100644 --- a/doc/user/project/integrations/webhooks.md +++ b/doc/user/project/integrations/webhooks.md @@ -232,6 +232,7 @@ X-Gitlab-Event: Issue Hook "object_attributes": { "id": 301, "title": "New API: create/update/delete file", + "assignee_ids": [51], "assignee_id": 51, "author_id": 51, "project_id": 14, @@ -246,6 +247,11 @@ X-Gitlab-Event: Issue Hook "url": "http://example.com/diaspora/issues/23", "action": "open" }, + "assignees": [{ + "name": "User1", + "username": "user1", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + }], "assignee": { "name": "User1", "username": "user1", @@ -265,6 +271,9 @@ X-Gitlab-Event: Issue Hook }] } ``` + +**Note**: `assignee` and `assignee_id` keys are deprecated and now show the first assignee only. + ### Comment events Triggered when a new comment is made on commits, merge requests, issues, and code snippets. @@ -544,6 +553,7 @@ X-Gitlab-Event: Note Hook "issue": { "id": 92, "title": "test", + "assignee_ids": [], "assignee_id": null, "author_id": 1, "project_id": 5, @@ -559,6 +569,8 @@ X-Gitlab-Event: Note Hook } ``` +**Note**: `assignee_id` field is deprecated and now shows the first assignee only. + #### Comment on code snippet **Request header**: diff --git a/features/project/builds/artifacts.feature b/features/project/builds/artifacts.feature index 09094d638c966..5abc24949cf5c 100644 --- a/features/project/builds/artifacts.feature +++ b/features/project/builds/artifacts.feature @@ -46,13 +46,14 @@ Feature: Project Builds Artifacts And I navigate to parent directory of directory with invalid name Then I should not see directory with invalid name on the list + @javascript Scenario: I download a single file from build artifacts Given recent build has artifacts available And recent build has artifacts metadata available When I visit recent build details page And I click artifacts browse button And I click a link to file within build artifacts - Then download of a file extracted from build artifacts should start + Then I see a download link @javascript Scenario: I click on a row in an artifacts table diff --git a/features/project/deploy_keys.feature b/features/project/deploy_keys.feature index 960b4100ee506..6f1ed9ff5b6b6 100644 --- a/features/project/deploy_keys.feature +++ b/features/project/deploy_keys.feature @@ -3,28 +3,33 @@ Feature: Project Deploy Keys Given I sign in as a user And I own project "Shop" + @javascript Scenario: I should see deploy keys list Given project has deploy key When I visit project deploy keys page Then I should see project deploy key + @javascript Scenario: I should see project deploy keys Given other projects have deploy keys When I visit project deploy keys page Then I should see other project deploy key And I should only see the same deploy key once + @javascript Scenario: I should see public deploy keys Given public deploy key exists When I visit project deploy keys page Then I should see public deploy key + @javascript Scenario: I add new deploy key Given I visit project deploy keys page And I submit new deploy key Then I should be on deploy keys page And I should see newly created deploy key + @javascript Scenario: I attach other project deploy key to project Given other projects have deploy keys And I visit project deploy keys page @@ -32,6 +37,7 @@ Feature: Project Deploy Keys Then I should be on deploy keys page And I should see newly created deploy key + @javascript Scenario: I attach public deploy key to project Given public deploy key exists And I visit project deploy keys page diff --git a/features/steps/dashboard/dashboard.rb b/features/steps/dashboard/dashboard.rb index c715c85c43c01..bf09d7b7114be 100644 --- a/features/steps/dashboard/dashboard.rb +++ b/features/steps/dashboard/dashboard.rb @@ -77,7 +77,7 @@ class Spinach::Features::Dashboard < Spinach::FeatureSteps step 'project "Shop" has issue "Bugfix1" with label "feature"' do project = Project.find_by(name: "Shop") - issue = create(:issue, title: "Bugfix1", project: project, assignee: current_user) + issue = create(:issue, title: "Bugfix1", project: project, assignees: [current_user]) issue.labels << project.labels.find_by(title: 'feature') end end diff --git a/features/steps/dashboard/todos.rb b/features/steps/dashboard/todos.rb index 3225e19995bbe..b56558ba0d2ae 100644 --- a/features/steps/dashboard/todos.rb +++ b/features/steps/dashboard/todos.rb @@ -182,7 +182,7 @@ def enterprise end def issue - @issue ||= create(:issue, assignee: current_user, project: project) + @issue ||= create(:issue, assignees: [current_user], project: project) end def merge_request diff --git a/features/steps/group/milestones.rb b/features/steps/group/milestones.rb index 49fcd6f120143..0b0983f0d0653 100644 --- a/features/steps/group/milestones.rb +++ b/features/steps/group/milestones.rb @@ -113,7 +113,7 @@ def group_milestone create :issue, project: project, - assignee: current_user, + assignees: [current_user], author: current_user, milestone: milestone @@ -125,7 +125,7 @@ def group_milestone issue = create :issue, project: project, - assignee: current_user, + assignees: [current_user], author: current_user, milestone: milestone diff --git a/features/steps/groups.rb b/features/steps/groups.rb index 7bb12374e5fbe..24d91c9dfbb1b 100644 --- a/features/steps/groups.rb +++ b/features/steps/groups.rb @@ -61,7 +61,7 @@ class Spinach::Features::Groups < Spinach::FeatureSteps step 'project from group "Owned" has issues assigned to me' do create :issue, project: project, - assignee: current_user, + assignees: [current_user], author: current_user end @@ -168,7 +168,7 @@ class Spinach::Features::Groups < Spinach::FeatureSteps step 'the archived project have some issues' do create :issue, project: @archived_project, - assignee: current_user, + assignees: [current_user], author: current_user end diff --git a/features/steps/project/builds/artifacts.rb b/features/steps/project/builds/artifacts.rb index 3ec5c8e2f4ffc..eec375b0532fc 100644 --- a/features/steps/project/builds/artifacts.rb +++ b/features/steps/project/builds/artifacts.rb @@ -3,6 +3,7 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps include SharedProject include SharedBuilds include RepoHelpers + include WaitForAjax step 'I click artifacts download button' do click_link 'Download' @@ -78,19 +79,11 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps step 'I click a link to file within build artifacts' do page.within('.tree-table') { find_link('ci_artifacts.txt').click } + wait_for_ajax end - step 'download of a file extracted from build artifacts should start' do - send_data = response_headers[Gitlab::Workhorse::SEND_DATA_HEADER] - - expect(send_data).to start_with('artifacts-entry:') - - base64_params = send_data.sub(/\Aartifacts\-entry:/, '') - params = JSON.parse(Base64.urlsafe_decode64(base64_params)) - - expect(params.keys).to eq(%w(Archive Entry)) - expect(params['Archive']).to end_with('build_artifacts.zip') - expect(params['Entry']).to eq(Base64.encode64('ci_artifacts.txt')) + step 'I see a download link' do + expect(page).to have_link 'download it' end step 'I click a first row within build artifacts table' do diff --git a/features/steps/project/deploy_keys.rb b/features/steps/project/deploy_keys.rb index ec59a2c094e79..8ad9d4a47419c 100644 --- a/features/steps/project/deploy_keys.rb +++ b/features/steps/project/deploy_keys.rb @@ -8,19 +8,19 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps end step 'I should see project deploy key' do - page.within '.deploy-keys' do + page.within(find('.deploy-keys')) do expect(page).to have_content deploy_key.title end end step 'I should see other project deploy key' do - page.within '.deploy-keys' do + page.within(find('.deploy-keys')) do expect(page).to have_content other_deploy_key.title end end step 'I should see public deploy key' do - page.within '.deploy-keys' do + page.within(find('.deploy-keys')) do expect(page).to have_content public_deploy_key.title end end @@ -40,7 +40,8 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps end step 'I should see newly created deploy key' do - page.within '.deploy-keys' do + @project.reload + page.within(find('.deploy-keys')) do expect(page).to have_content(deploy_key.title) end end @@ -56,7 +57,7 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps end step 'I should only see the same deploy key once' do - page.within '.deploy-keys' do + page.within(find('.deploy-keys')) do expect(page).to have_selector('ul li', count: 1) end end @@ -66,8 +67,9 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps end step 'I click attach deploy key' do - page.within '.deploy-keys' do - click_link 'Enable' + page.within(find('.deploy-keys')) do + click_button 'Enable' + expect(page).not_to have_selector('.fa-spinner') end end diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index ce7674a931460..08d5d6972ffa9 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -458,6 +458,8 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps click_button "Comment" end + wait_for_ajax + page.within ".files>div:nth-child(2) .note-body > .note-text" do expect(page).to have_content "Line is correct" end @@ -470,6 +472,8 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps fill_in "note_note", with: "Line is wrong on here" click_button "Comment" end + + wait_for_ajax end step 'I should still see a comment like "Line is correct" in the second file' do @@ -715,6 +719,9 @@ def leave_comment(message) fill_in "note_note", with: message click_button "Comment" end + + wait_for_ajax + page.within(".notes_holder", visible: true) do expect(page).to have_content message end diff --git a/features/steps/shared/note.rb b/features/steps/shared/note.rb index 7885cc7ab7729..7d260025052cf 100644 --- a/features/steps/shared/note.rb +++ b/features/steps/shared/note.rb @@ -24,6 +24,8 @@ module SharedNote fill_in "note[note]", with: "XML attached" click_button "Comment" end + + wait_for_ajax end step 'I preview a comment text like "Bug fixed :smile:"' do @@ -37,6 +39,8 @@ module SharedNote page.within(".js-main-target-form") do click_button "Comment" end + + wait_for_ajax end step 'I write a comment like ":+1: Nice"' do diff --git a/lib/api/api.rb b/lib/api/api.rb index 5cf69d7212cf7..244c5992e80d8 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -6,6 +6,7 @@ class API < Grape::API version 'v3', using: :path do helpers ::API::V3::Helpers + helpers ::API::Helpers::CommonHelpers mount ::API::V3::AwardEmoji mount ::API::V3::Boards @@ -48,6 +49,9 @@ class API < Grape::API end before { allow_access_with_scope :api } + before { Gitlab::I18n.set_locale(current_user) } + + after { Gitlab::I18n.reset_locale } rescue_from Gitlab::Access::AccessDeniedError do rack_response({ 'message' => '403 Forbidden' }.to_json, 403) @@ -81,6 +85,7 @@ class API < Grape::API # Ensure the namespace is right, otherwise we might load Grape::API::Helpers helpers ::SentryHelper helpers ::API::Helpers + helpers ::API::Helpers::CommonHelpers # Keep in alphabetical order mount ::API::AccessRequests diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 9735e40030f23..92f2865053bf4 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -277,7 +277,11 @@ class Milestone < ProjectEntity class IssueBasic < ProjectEntity expose :label_names, as: :labels expose :milestone, using: Entities::Milestone - expose :assignee, :author, using: Entities::UserBasic + expose :assignees, :author, using: Entities::UserBasic + + expose :assignee, using: ::API::Entities::UserBasic do |issue, options| + issue.assignees.first + end expose :user_notes_count expose :upvotes, :downvotes diff --git a/lib/api/helpers/common_helpers.rb b/lib/api/helpers/common_helpers.rb new file mode 100644 index 0000000000000..6236fdd43ca1e --- /dev/null +++ b/lib/api/helpers/common_helpers.rb @@ -0,0 +1,13 @@ +module API + module Helpers + module CommonHelpers + def convert_parameters_from_legacy_format(params) + if params[:assignee_id].present? + params[:assignee_ids] = [params.delete(:assignee_id)] + end + + params + end + end + end +end diff --git a/lib/api/issues.rb b/lib/api/issues.rb index 3af3181142009..4e3132f4dca78 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -32,7 +32,8 @@ def find_issues(args = {}) params :issue_params_ce do optional :description, type: String, desc: 'The description of an issue' - optional :assignee_id, type: Integer, desc: 'The ID of a user to assign issue' + optional :assignee_ids, type: Array[Integer], desc: 'The array of user IDs to assign issue' + optional :assignee_id, type: Integer, desc: '[Deprecated] The ID of a user to assign issue' optional :milestone_id, type: Integer, desc: 'The ID of a milestone to assign issue' optional :labels, type: String, desc: 'Comma-separated list of label names' optional :due_date, type: String, desc: 'Date string in the format YEAR-MONTH-DAY' @@ -140,6 +141,8 @@ def find_issues(args = {}) issue_params = declared_params(include_missing: false) + issue_params = convert_parameters_from_legacy_format(issue_params) + issue = ::Issues::CreateService.new(user_project, current_user, issue_params.merge(request: request, api: true)).execute @@ -164,7 +167,7 @@ def find_issues(args = {}) desc: 'Date time when the issue was updated. Available only for admins and project owners.' optional :state_event, type: String, values: %w[reopen close], desc: 'State of the issue' use :issue_params - at_least_one_of :title, :description, :assignee_id, :milestone_id, + at_least_one_of :title, :description, :assignee_ids, :assignee_id, :milestone_id, :labels, :created_at, :due_date, :confidential, :state_event, :weight end @@ -179,6 +182,8 @@ def find_issues(args = {}) update_params = declared_params(include_missing: false).merge(request: request, api: true) + update_params = convert_parameters_from_legacy_format(update_params) + issue = ::Issues::UpdateService.new(user_project, current_user, update_params).execute(issue) diff --git a/lib/api/settings.rb b/lib/api/settings.rb index f71be7a11c32d..1d8aebe11231a 100644 --- a/lib/api/settings.rb +++ b/lib/api/settings.rb @@ -58,6 +58,7 @@ def current_settings :restricted_visibility_levels, :send_user_confirmation_email, :sentry_enabled, + :clientside_sentry_enabled, :session_expire_delay, :shared_runners_enabled, :sidekiq_throttling_enabled, @@ -138,6 +139,10 @@ def current_settings given sentry_enabled: ->(val) { val } do requires :sentry_dsn, type: String, desc: 'Sentry Data Source Name' end + optional :clientside_sentry_enabled, type: Boolean, desc: 'Sentry can also be used for reporting and logging clientside exceptions. https://sentry.io/for/javascript/' + given clientside_sentry_enabled: ->(val) { val } do + requires :clientside_sentry_dsn, type: String, desc: 'Clientside Sentry Data Source Name' + end optional :repository_storage, type: String, desc: 'Storage paths for new projects' optional :repository_checks_enabled, type: Boolean, desc: "GitLab will periodically run 'git fsck' in all project and wiki repositories to look for silent disk corruption issues." optional :koding_enabled, type: Boolean, desc: 'Enable Koding' diff --git a/lib/api/v3/entities.rb b/lib/api/v3/entities.rb index de87986c18b43..739a9219147d1 100644 --- a/lib/api/v3/entities.rb +++ b/lib/api/v3/entities.rb @@ -295,6 +295,13 @@ class ProjectHook < ::API::Entities::Hook expose :project_id, :issues_events, :merge_requests_events expose :note_events, :build_events, :pipeline_events, :wiki_page_events end + + class Issue < ::API::Entities::Issue + unexpose :assignees + expose :assignee do |issue, options| + ::API::Entities::UserBasic.represent(issue.assignees.first, options) + end + end end end end diff --git a/lib/api/v3/issues.rb b/lib/api/v3/issues.rb index 6c0705820d121..be9d1feb84d5e 100644 --- a/lib/api/v3/issues.rb +++ b/lib/api/v3/issues.rb @@ -8,6 +8,7 @@ class Issues < Grape::API helpers do def find_issues(args = {}) args = params.merge(args) + args = convert_parameters_from_legacy_format(args) args.delete(:id) args[:milestone_title] = args.delete(:milestone) @@ -53,7 +54,7 @@ def find_issues(args = {}) resource :issues do desc "Get currently authenticated user's issues" do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do optional :state, type: String, values: %w[opened closed all], default: 'all', @@ -62,7 +63,7 @@ def find_issues(args = {}) end get do issues = find_issues(scope: 'authored') - present paginate(issues), with: ::API::Entities::Issue, current_user: current_user + present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user end end @@ -71,7 +72,7 @@ def find_issues(args = {}) end resource :groups, requirements: { id: %r{[^/]+} } do desc 'Get a list of group issues' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do optional :state, type: String, values: %w[opened closed all], default: 'all', @@ -83,7 +84,7 @@ def find_issues(args = {}) issues = find_issues(group_id: group.id, match_all_labels: true) - present paginate(issues), with: ::API::Entities::Issue, current_user: current_user + present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user end end @@ -95,7 +96,7 @@ def find_issues(args = {}) desc 'Get a list of project issues' do detail 'iid filter is deprecated have been removed on V4' - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do optional :state, type: String, values: %w[opened closed all], default: 'all', @@ -108,22 +109,22 @@ def find_issues(args = {}) issues = find_issues(project_id: project.id) - present paginate(issues), with: ::API::Entities::Issue, current_user: current_user, project: user_project + present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project end desc 'Get a single project issue' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do requires :issue_id, type: Integer, desc: 'The ID of a project issue' end get ":id/issues/:issue_id" do issue = find_project_issue(params[:issue_id]) - present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project + present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project end desc 'Create a new project issue' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do requires :title, type: String, desc: 'The title of an issue' @@ -141,6 +142,7 @@ def find_issues(args = {}) issue_params = declared_params(include_missing: false) issue_params = issue_params.merge(merge_request_to_resolve_discussions_of: issue_params.delete(:merge_request_for_resolving_discussions)) + issue_params = convert_parameters_from_legacy_format(issue_params) issue = ::Issues::CreateService.new(user_project, current_user, @@ -148,14 +150,14 @@ def find_issues(args = {}) render_spam_error! if issue.spam? if issue.valid? - present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project + present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project else render_validation_error!(issue) end end desc 'Update an existing issue' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do requires :issue_id, type: Integer, desc: 'The ID of a project issue' @@ -178,6 +180,7 @@ def find_issues(args = {}) end update_params = declared_params(include_missing: false).merge(request: request, api: true) + update_params = convert_parameters_from_legacy_format(update_params) issue = ::Issues::UpdateService.new(user_project, current_user, @@ -186,14 +189,14 @@ def find_issues(args = {}) render_spam_error! if issue.spam? if issue.valid? - present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project + present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project else render_validation_error!(issue) end end desc 'Move an existing issue' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do requires :issue_id, type: Integer, desc: 'The ID of a project issue' @@ -208,7 +211,7 @@ def find_issues(args = {}) begin issue = ::Issues::MoveService.new(user_project, current_user).execute(issue, new_project) - present issue, with: ::API::Entities::Issue, current_user: current_user, project: user_project + present issue, with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project rescue ::Issues::MoveService::MoveError => error render_api_error!(error.message, 400) end diff --git a/lib/api/v3/merge_requests.rb b/lib/api/v3/merge_requests.rb index 534c7992d6bf9..a70a272b5c47f 100644 --- a/lib/api/v3/merge_requests.rb +++ b/lib/api/v3/merge_requests.rb @@ -34,7 +34,7 @@ def issue_entity(project) if project.has_external_issue_tracker? ::API::Entities::ExternalIssue else - ::API::Entities::Issue + ::API::V3::Entities::Issue end end diff --git a/lib/api/v3/milestones.rb b/lib/api/v3/milestones.rb index be90cec4afcbc..4c7061d493948 100644 --- a/lib/api/v3/milestones.rb +++ b/lib/api/v3/milestones.rb @@ -39,7 +39,7 @@ def filter_milestones_state(milestones, state) end desc 'Get all issues for a single project milestone' do - success ::API::Entities::Issue + success ::API::V3::Entities::Issue end params do requires :milestone_id, type: Integer, desc: 'The ID of a project milestone' @@ -56,7 +56,7 @@ def filter_milestones_state(milestones, state) } issues = IssuesFinder.new(current_user, finder_params).execute - present paginate(issues), with: ::API::Entities::Issue, current_user: current_user, project: user_project + present paginate(issues), with: ::API::V3::Entities::Issue, current_user: current_user, project: user_project end end end diff --git a/lib/banzai/reference_parser/issue_parser.rb b/lib/banzai/reference_parser/issue_parser.rb index e02b360924ae2..89ec715ddf6d9 100644 --- a/lib/banzai/reference_parser/issue_parser.rb +++ b/lib/banzai/reference_parser/issue_parser.rb @@ -28,7 +28,7 @@ def issues_for_nodes(nodes) nodes, Issue.all.includes( :author, - :assignee, + :assignees, { # These associations are primarily used for checking permissions. # Eager loading these ensures we don't end up running dozens of diff --git a/lib/github/import.rb b/lib/github/import.rb index d49761fd6c608..06beb607a3eca 100644 --- a/lib/github/import.rb +++ b/lib/github/import.rb @@ -245,7 +245,7 @@ def fetch_issues issue.label_ids = label_ids(representation.labels) issue.milestone_id = milestone_id(representation.milestone) issue.author_id = author_id - issue.assignee_id = user_id(representation.assignee) + issue.assignee_ids = [user_id(representation.assignee)] issue.created_at = representation.created_at issue.updated_at = representation.updated_at issue.save!(validate: false) diff --git a/lib/gitlab/chat_commands/presenters/issue_base.rb b/lib/gitlab/chat_commands/presenters/issue_base.rb index da097a7039127..6385b5eea7494 100644 --- a/lib/gitlab/chat_commands/presenters/issue_base.rb +++ b/lib/gitlab/chat_commands/presenters/issue_base.rb @@ -22,7 +22,7 @@ def fields [ { title: "Assignee", - value: @resource.assignee ? @resource.assignee.name : "_None_", + value: @resource.assignees.any? ? @resource.assignees.first.name : "_None_", short: true }, { diff --git a/lib/gitlab/ci/build/artifacts/metadata/entry.rb b/lib/gitlab/ci/build/artifacts/metadata/entry.rb index 6f799c2f031b0..2e073334abca9 100644 --- a/lib/gitlab/ci/build/artifacts/metadata/entry.rb +++ b/lib/gitlab/ci/build/artifacts/metadata/entry.rb @@ -37,6 +37,12 @@ def file? !directory? end + def blob + return unless file? + + @blob ||= Blob.decorate(::Ci::ArtifactBlob.new(self), nil) + end + def has_parent? nodes > 0 end diff --git a/lib/gitlab/cycle_analytics/base_stage.rb b/lib/gitlab/cycle_analytics/base_stage.rb index 559e3939da641..cac31ea8cff42 100644 --- a/lib/gitlab/cycle_analytics/base_stage.rb +++ b/lib/gitlab/cycle_analytics/base_stage.rb @@ -17,7 +17,7 @@ def as_json end def title - name.to_s.capitalize + raise NotImplementedError.new("Expected #{self.name} to implement title") end def median diff --git a/lib/gitlab/cycle_analytics/code_stage.rb b/lib/gitlab/cycle_analytics/code_stage.rb index 1e52b6614a152..5f9dc9a430376 100644 --- a/lib/gitlab/cycle_analytics/code_stage.rb +++ b/lib/gitlab/cycle_analytics/code_stage.rb @@ -13,12 +13,16 @@ def name :code end + def title + s_('CycleAnalyticsStage|Code') + end + def legend - "Related Merge Requests" + _("Related Merge Requests") end def description - "Time until first merge request" + _("Time until first merge request") end end end diff --git a/lib/gitlab/cycle_analytics/issue_stage.rb b/lib/gitlab/cycle_analytics/issue_stage.rb index 213994988a5a5..7b03811efb21e 100644 --- a/lib/gitlab/cycle_analytics/issue_stage.rb +++ b/lib/gitlab/cycle_analytics/issue_stage.rb @@ -14,12 +14,16 @@ def name :issue end + def title + s_('CycleAnalyticsStage|Issue') + end + def legend - "Related Issues" + _("Related Issues") end def description - "Time before an issue gets scheduled" + _("Time before an issue gets scheduled") end end end diff --git a/lib/gitlab/cycle_analytics/plan_stage.rb b/lib/gitlab/cycle_analytics/plan_stage.rb index 45d51d30ccc6b..1a0afb56b4fb0 100644 --- a/lib/gitlab/cycle_analytics/plan_stage.rb +++ b/lib/gitlab/cycle_analytics/plan_stage.rb @@ -14,12 +14,16 @@ def name :plan end + def title + s_('CycleAnalyticsStage|Plan') + end + def legend - "Related Commits" + _("Related Commits") end def description - "Time before an issue starts implementation" + _("Time before an issue starts implementation") end end end diff --git a/lib/gitlab/cycle_analytics/production_stage.rb b/lib/gitlab/cycle_analytics/production_stage.rb index 9f387a0294533..0fa8a65cb9911 100644 --- a/lib/gitlab/cycle_analytics/production_stage.rb +++ b/lib/gitlab/cycle_analytics/production_stage.rb @@ -15,12 +15,16 @@ def name :production end + def title + s_('CycleAnalyticsStage|Production') + end + def legend - "Related Issues" + _("Related Issues") end def description - "From issue creation until deploy to production" + _("From issue creation until deploy to production") end def query diff --git a/lib/gitlab/cycle_analytics/review_stage.rb b/lib/gitlab/cycle_analytics/review_stage.rb index 4744be834de27..cfbbdc43fd93c 100644 --- a/lib/gitlab/cycle_analytics/review_stage.rb +++ b/lib/gitlab/cycle_analytics/review_stage.rb @@ -13,12 +13,16 @@ def name :review end + def title + s_('CycleAnalyticsStage|Review') + end + def legend - "Relative Merged Requests" + _("Related Merged Requests") end def description - "Time between merge request creation and merge/close" + _("Time between merge request creation and merge/close") end end end diff --git a/lib/gitlab/cycle_analytics/staging_stage.rb b/lib/gitlab/cycle_analytics/staging_stage.rb index 3cdbe04fbaf1d..d5684bb920178 100644 --- a/lib/gitlab/cycle_analytics/staging_stage.rb +++ b/lib/gitlab/cycle_analytics/staging_stage.rb @@ -14,12 +14,16 @@ def name :staging end + def title + s_('CycleAnalyticsStage|Staging') + end + def legend - "Relative Deployed Builds" + _("Related Deployed Jobs") end def description - "From merge request merge until deploy to production" + _("From merge request merge until deploy to production") end end end diff --git a/lib/gitlab/cycle_analytics/summary/base.rb b/lib/gitlab/cycle_analytics/summary/base.rb index 43fa3795e5ca9..a917ddccac732 100644 --- a/lib/gitlab/cycle_analytics/summary/base.rb +++ b/lib/gitlab/cycle_analytics/summary/base.rb @@ -8,7 +8,7 @@ def initialize(project:, from:) end def title - self.class.name.demodulize + raise NotImplementedError.new("Expected #{self.name} to implement title") end def value diff --git a/lib/gitlab/cycle_analytics/summary/commit.rb b/lib/gitlab/cycle_analytics/summary/commit.rb index 7b8faa4d854ed..bea7886275718 100644 --- a/lib/gitlab/cycle_analytics/summary/commit.rb +++ b/lib/gitlab/cycle_analytics/summary/commit.rb @@ -2,6 +2,10 @@ module Gitlab module CycleAnalytics module Summary class Commit < Base + def title + n_('Commit', 'Commits', value) + end + def value @value ||= count_commits end diff --git a/lib/gitlab/cycle_analytics/summary/deploy.rb b/lib/gitlab/cycle_analytics/summary/deploy.rb index 06032e9200edf..099d798aac691 100644 --- a/lib/gitlab/cycle_analytics/summary/deploy.rb +++ b/lib/gitlab/cycle_analytics/summary/deploy.rb @@ -2,6 +2,10 @@ module Gitlab module CycleAnalytics module Summary class Deploy < Base + def title + n_('Deploy', 'Deploys', value) + end + def value @value ||= @project.deployments.where("created_at > ?", @from).count end diff --git a/lib/gitlab/cycle_analytics/summary/issue.rb b/lib/gitlab/cycle_analytics/summary/issue.rb index 008468f24b90a..9bbf7a2685f52 100644 --- a/lib/gitlab/cycle_analytics/summary/issue.rb +++ b/lib/gitlab/cycle_analytics/summary/issue.rb @@ -9,7 +9,7 @@ def initialize(project:, from:, current_user:) end def title - 'New Issue' + n_('New Issue', 'New Issues', value) end def value diff --git a/lib/gitlab/cycle_analytics/test_stage.rb b/lib/gitlab/cycle_analytics/test_stage.rb index e96943833bc46..2b5f72bef8930 100644 --- a/lib/gitlab/cycle_analytics/test_stage.rb +++ b/lib/gitlab/cycle_analytics/test_stage.rb @@ -13,12 +13,16 @@ def name :test end + def title + s_('CycleAnalyticsStage|Test') + end + def legend - "Relative Builds Trigger by Commits" + _("Related Jobs") end def description - "Total test time for all commits/merges" + _("Total test time for all commits/merges") end def stage_query diff --git a/lib/gitlab/fogbugz_import/importer.rb b/lib/gitlab/fogbugz_import/importer.rb index 222bcdcbf9c8a..3dcee681c72f4 100644 --- a/lib/gitlab/fogbugz_import/importer.rb +++ b/lib/gitlab/fogbugz_import/importer.rb @@ -122,15 +122,15 @@ def import_cases author_id = user_info(bug['ixPersonOpenedBy'])[:gitlab_id] || project.creator_id issue = Issue.create!( - iid: bug['ixBug'], - project_id: project.id, - title: bug['sTitle'], - description: body, - author_id: author_id, - assignee_id: assignee_id, - state: bug['fOpen'] == 'true' ? 'opened' : 'closed', - created_at: date, - updated_at: DateTime.parse(bug['dtLastUpdated']) + iid: bug['ixBug'], + project_id: project.id, + title: bug['sTitle'], + description: body, + author_id: author_id, + assignee_ids: [assignee_id], + state: bug['fOpen'] == 'true' ? 'opened' : 'closed', + created_at: date, + updated_at: DateTime.parse(bug['dtLastUpdated']) ) issue_labels = ::LabelsFinder.new(nil, project_id: project.id, title: labels).execute(skip_authorization: true) diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index e7485c220399d..6a0f12b7e5027 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -499,8 +499,9 @@ def ref_name_for_sha(ref_path, sha) # :contains is the commit contained by the refs from which to begin (SHA1 or name) # :max_count is the maximum number of commits to fetch # :skip is the number of commits to skip - # :order is the commits order and allowed value is :none (default), :date, or :topo - # commit ordering types are documented here: + # :order is the commits order and allowed value is :none (default), :date, + # :topo, or any combination of them (in an array). Commit ordering types + # are documented here: # http://www.rubydoc.info/github/libgit2/rugged/Rugged#SORT_NONE-constant) # def find_commits(options = {}) @@ -1269,16 +1270,18 @@ def gitaly_migrate(method, &block) raise CommandError.new(e) end - # Returns the `Rugged` sorting type constant for a given - # sort type key. Valid keys are `:none`, `:topo`, and `:date` - def rugged_sort_type(key) + # Returns the `Rugged` sorting type constant for one or more given + # sort types. Valid keys are `:none`, `:topo`, and `:date`, or an array + # containing more than one of them. `:date` uses a combination of date and + # topological sorting to closer mimic git's native ordering. + def rugged_sort_type(sort_type) @rugged_sort_types ||= { none: Rugged::SORT_NONE, topo: Rugged::SORT_TOPO, - date: Rugged::SORT_DATE + date: Rugged::SORT_DATE | Rugged::SORT_TOPO } - @rugged_sort_types.fetch(key, Rugged::SORT_NONE) + @rugged_sort_types.fetch(sort_type, Rugged::SORT_NONE) end end end diff --git a/lib/gitlab/github_import/issue_formatter.rb b/lib/gitlab/github_import/issue_formatter.rb index 6f5ac4dac0d38..977cd0423ba53 100644 --- a/lib/gitlab/github_import/issue_formatter.rb +++ b/lib/gitlab/github_import/issue_formatter.rb @@ -10,7 +10,7 @@ def attributes description: description, state: state, author_id: author_id, - assignee_id: assignee_id, + assignee_ids: Array(assignee_id), created_at: raw_data.created_at, updated_at: raw_data.updated_at } diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index 5ab84266b7d51..26473f99bc351 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -10,6 +10,8 @@ def add_gon_variables gon.user_color_scheme = Gitlab::ColorSchemes.for_user(current_user).css_class gon.katex_css_url = ActionController::Base.helpers.asset_path('katex.css') gon.katex_js_url = ActionController::Base.helpers.asset_path('katex.js') + gon.sentry_dsn = current_application_settings.clientside_sentry_dsn if current_application_settings.clientside_sentry_enabled + gon.gitlab_url = Gitlab.config.gitlab.url if current_user gon.current_user_id = current_user.id diff --git a/lib/gitlab/google_code_import/importer.rb b/lib/gitlab/google_code_import/importer.rb index 5ca3e6a95cab8..1b43440673c11 100644 --- a/lib/gitlab/google_code_import/importer.rb +++ b/lib/gitlab/google_code_import/importer.rb @@ -108,13 +108,13 @@ def import_issues end issue = Issue.create!( - iid: raw_issue['id'], - project_id: project.id, - title: raw_issue['title'], - description: body, - author_id: project.creator_id, - assignee_id: assignee_id, - state: raw_issue['state'] == 'closed' ? 'closed' : 'opened' + iid: raw_issue['id'], + project_id: project.id, + title: raw_issue['title'], + description: body, + author_id: project.creator_id, + assignee_ids: [assignee_id], + state: raw_issue['state'] == 'closed' ? 'closed' : 'opened' ) issue_labels = ::LabelsFinder.new(nil, project_id: project.id, title: labels).execute(skip_authorization: true) diff --git a/lib/gitlab/i18n.rb b/lib/gitlab/i18n.rb new file mode 100644 index 0000000000000..3411516319f88 --- /dev/null +++ b/lib/gitlab/i18n.rb @@ -0,0 +1,26 @@ +module Gitlab + module I18n + extend self + + AVAILABLE_LANGUAGES = { + 'en' => 'English', + 'es' => 'Español', + 'de' => 'Deutsch' + }.freeze + + def available_locales + AVAILABLE_LANGUAGES.keys + end + + def set_locale(current_user) + requested_locale = current_user&.preferred_language || ::I18n.default_locale + locale = FastGettext.set_locale(requested_locale) + ::I18n.locale = locale + end + + def reset_locale + FastGettext.set_locale(::I18n.default_locale) + ::I18n.locale = ::I18n.default_locale + end + end +end diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb index 4a54e7ef2e736..956763fa39927 100644 --- a/lib/gitlab/import_export/relation_factory.rb +++ b/lib/gitlab/import_export/relation_factory.rb @@ -15,7 +15,7 @@ class RelationFactory priorities: :label_priorities, label: :project_label }.freeze - USER_REFERENCES = %w[author_id assignee_id updated_by_id user_id created_by_id merge_user_id resolved_by_id].freeze + USER_REFERENCES = %w[author_id assignee_id updated_by_id user_id created_by_id last_edited_by_id merge_user_id resolved_by_id].freeze PROJECT_REFERENCES = %w[project_id source_project_id target_project_id].freeze diff --git a/lib/gitlab/prometheus.rb b/lib/gitlab/prometheus.rb index 62239779454f6..8827507955dd7 100644 --- a/lib/gitlab/prometheus.rb +++ b/lib/gitlab/prometheus.rb @@ -50,6 +50,12 @@ def join_api_url(type, args = {}) def get(url) handle_response(HTTParty.get(url)) + rescue SocketError + raise PrometheusError, "Can't connect to #{url}" + rescue OpenSSL::SSL::SSLError + raise PrometheusError, "#{url} contains invalid SSL data" + rescue HTTParty::Error + raise PrometheusError, "Network connection error" end def handle_response(response) diff --git a/lib/gitlab/shell.rb b/lib/gitlab/shell.rb index 0309877e4689b..4a01ceaf275a5 100644 --- a/lib/gitlab/shell.rb +++ b/lib/gitlab/shell.rb @@ -83,7 +83,7 @@ def import_repository(storage, name, url) # Timeout should be less than 900 ideally, to prevent the memory killer # to silently kill the process without knowing we are timing out here. output, status = Popen.popen([gitlab_shell_projects_path, 'import-project', - storage, "#{name}.git", url, '800']) + storage, "#{name}.git", url, "#{Gitlab.config.gitlab_shell.git_timeout}"]) raise Error, output unless status.zero? true end @@ -127,7 +127,7 @@ def list_remote_tags(storage, name, remote) # fetch_remote("gitlab/gitlab-ci", "upstream") # def fetch_remote(storage, name, remote, forced: false, no_tags: false) - args = [gitlab_shell_projects_path, 'fetch-remote', storage, "#{name}.git", remote, '800'] + args = [gitlab_shell_projects_path, 'fetch-remote', storage, "#{name}.git", remote, "#{Gitlab.config.gitlab_shell.git_timeout}"] args << '--force' if forced args << '--no-tags' if no_tags diff --git a/lib/tasks/gettext.rake b/lib/tasks/gettext.rake new file mode 100644 index 0000000000000..0aa21a4bd13ba --- /dev/null +++ b/lib/tasks/gettext.rake @@ -0,0 +1,14 @@ +require "gettext_i18n_rails/tasks" + +namespace :gettext do + # Customize list of translatable files + # See: https://github.com/grosser/gettext_i18n_rails#customizing-list-of-translatable-files + def files_to_translate + folders = %W(app lib config #{locale_path}).join(',') + exts = %w(rb erb haml slim rhtml js jsx vue coffee handlebars hbs mustache).join(',') + + Dir.glob( + "{#{folders}}/**/*.{#{exts}}" + ) + end +end diff --git a/locale/de/gitlab.po b/locale/de/gitlab.po new file mode 100644 index 0000000000000..b804dc0436f4f --- /dev/null +++ b/locale/de/gitlab.po @@ -0,0 +1,207 @@ +# German translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2017-04-12 22:37-0500\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: German\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"\n" + +msgid "ByAuthor|by" +msgstr "" + +msgid "Commit" +msgid_plural "Commits" +msgstr[0] "" +msgstr[1] "" + +msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." +msgstr "" + +msgid "CycleAnalyticsStage|Code" +msgstr "" + +msgid "CycleAnalyticsStage|Issue" +msgstr "" + +msgid "CycleAnalyticsStage|Plan" +msgstr "" + +msgid "CycleAnalyticsStage|Production" +msgstr "" + +msgid "CycleAnalyticsStage|Review" +msgstr "" + +msgid "CycleAnalyticsStage|Staging" +msgstr "" + +msgid "CycleAnalyticsStage|Test" +msgstr "" + +msgid "Deploy" +msgid_plural "Deploys" +msgstr[0] "" +msgstr[1] "" + +msgid "FirstPushedBy|First" +msgstr "" + +msgid "FirstPushedBy|pushed by" +msgstr "" + +msgid "From issue creation until deploy to production" +msgstr "" + +msgid "From merge request merge until deploy to production" +msgstr "" + +msgid "Introducing Cycle Analytics" +msgstr "" + +msgid "Last %d day" +msgid_plural "Last %d days" +msgstr[0] "" +msgstr[1] "" + +msgid "Limited to showing %d event at most" +msgid_plural "Limited to showing %d events at most" +msgstr[0] "" +msgstr[1] "" + +msgid "Median" +msgstr "" + +msgid "New Issue" +msgid_plural "New Issues" +msgstr[0] "" +msgstr[1] "" + +msgid "Not available" +msgstr "" + +msgid "Not enough data" +msgstr "" + +msgid "OpenedNDaysAgo|Opened" +msgstr "" + +msgid "Pipeline Health" +msgstr "" + +msgid "ProjectLifecycle|Stage" +msgstr "" + +msgid "Read more" +msgstr "" + +msgid "Related Commits" +msgstr "" + +msgid "Related Deployed Jobs" +msgstr "" + +msgid "Related Issues" +msgstr "" + +msgid "Related Jobs" +msgstr "" + +msgid "Related Merge Requests" +msgstr "" + +msgid "Related Merged Requests" +msgstr "" + +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "" +msgstr[1] "" + +msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." +msgstr "" + +msgid "The collection of events added to the data gathered for that stage." +msgstr "" + +msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." +msgstr "" + +msgid "The phase of the development lifecycle." +msgstr "" + +msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." +msgstr "" + +msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." +msgstr "" + +msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." +msgstr "" + +msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time." +msgstr "" + +msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." +msgstr "" + +msgid "The time taken by each data entry gathered by that stage." +msgstr "" + +msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." +msgstr "" + +msgid "Time before an issue gets scheduled" +msgstr "" + +msgid "Time before an issue starts implementation" +msgstr "" + +msgid "Time between merge request creation and merge/close" +msgstr "" + +msgid "Time until first merge request" +msgstr "" + +msgid "Time|hr" +msgid_plural "Time|hrs" +msgstr[0] "" +msgstr[1] "" + +msgid "Time|min" +msgid_plural "Time|mins" +msgstr[0] "" +msgstr[1] "" + +msgid "Time|s" +msgstr "" + +msgid "Total Time" +msgstr "" + +msgid "Total test time for all commits/merges" +msgstr "" + +msgid "Want to see the data? Please ask an administrator for access." +msgstr "" + +msgid "We don't have enough data to show this stage." +msgstr "" + +msgid "You need permission." +msgstr "" + +msgid "day" +msgid_plural "days" +msgstr[0] "" +msgstr[1] "" diff --git a/locale/de/gitlab.po.time_stamp b/locale/de/gitlab.po.time_stamp new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po new file mode 100644 index 0000000000000..a43bafbbe2872 --- /dev/null +++ b/locale/en/gitlab.po @@ -0,0 +1,207 @@ +# English translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2017-04-12 22:36-0500\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: English\n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"\n" + +msgid "ByAuthor|by" +msgstr "" + +msgid "Commit" +msgid_plural "Commits" +msgstr[0] "" +msgstr[1] "" + +msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." +msgstr "" + +msgid "CycleAnalyticsStage|Code" +msgstr "" + +msgid "CycleAnalyticsStage|Issue" +msgstr "" + +msgid "CycleAnalyticsStage|Plan" +msgstr "" + +msgid "CycleAnalyticsStage|Production" +msgstr "" + +msgid "CycleAnalyticsStage|Review" +msgstr "" + +msgid "CycleAnalyticsStage|Staging" +msgstr "" + +msgid "CycleAnalyticsStage|Test" +msgstr "" + +msgid "Deploy" +msgid_plural "Deploys" +msgstr[0] "" +msgstr[1] "" + +msgid "FirstPushedBy|First" +msgstr "" + +msgid "FirstPushedBy|pushed by" +msgstr "" + +msgid "From issue creation until deploy to production" +msgstr "" + +msgid "From merge request merge until deploy to production" +msgstr "" + +msgid "Introducing Cycle Analytics" +msgstr "" + +msgid "Last %d day" +msgid_plural "Last %d days" +msgstr[0] "" +msgstr[1] "" + +msgid "Limited to showing %d event at most" +msgid_plural "Limited to showing %d events at most" +msgstr[0] "" +msgstr[1] "" + +msgid "Median" +msgstr "" + +msgid "New Issue" +msgid_plural "New Issues" +msgstr[0] "" +msgstr[1] "" + +msgid "Not available" +msgstr "" + +msgid "Not enough data" +msgstr "" + +msgid "OpenedNDaysAgo|Opened" +msgstr "" + +msgid "Pipeline Health" +msgstr "" + +msgid "ProjectLifecycle|Stage" +msgstr "" + +msgid "Read more" +msgstr "" + +msgid "Related Commits" +msgstr "" + +msgid "Related Deployed Jobs" +msgstr "" + +msgid "Related Issues" +msgstr "" + +msgid "Related Jobs" +msgstr "" + +msgid "Related Merge Requests" +msgstr "" + +msgid "Related Merged Requests" +msgstr "" + +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "" +msgstr[1] "" + +msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." +msgstr "" + +msgid "The collection of events added to the data gathered for that stage." +msgstr "" + +msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." +msgstr "" + +msgid "The phase of the development lifecycle." +msgstr "" + +msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." +msgstr "" + +msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." +msgstr "" + +msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." +msgstr "" + +msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time." +msgstr "" + +msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." +msgstr "" + +msgid "The time taken by each data entry gathered by that stage." +msgstr "" + +msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." +msgstr "" + +msgid "Time before an issue gets scheduled" +msgstr "" + +msgid "Time before an issue starts implementation" +msgstr "" + +msgid "Time between merge request creation and merge/close" +msgstr "" + +msgid "Time until first merge request" +msgstr "" + +msgid "Time|hr" +msgid_plural "Time|hrs" +msgstr[0] "" +msgstr[1] "" + +msgid "Time|min" +msgid_plural "Time|mins" +msgstr[0] "" +msgstr[1] "" + +msgid "Time|s" +msgstr "" + +msgid "Total Time" +msgstr "" + +msgid "Total test time for all commits/merges" +msgstr "" + +msgid "Want to see the data? Please ask an administrator for access." +msgstr "" + +msgid "We don't have enough data to show this stage." +msgstr "" + +msgid "You need permission." +msgstr "" + +msgid "day" +msgid_plural "days" +msgstr[0] "" +msgstr[1] "" diff --git a/locale/en/gitlab.po.time_stamp b/locale/en/gitlab.po.time_stamp new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/locale/es/gitlab.po b/locale/es/gitlab.po new file mode 100644 index 0000000000000..c14ddd3b94c6c --- /dev/null +++ b/locale/es/gitlab.po @@ -0,0 +1,208 @@ +# Spanish translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2017-05-04 19:24-0500\n" +"Language-Team: Spanish\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"Last-Translator: \n" +"X-Generator: Poedit 2.0.1\n" + +msgid "ByAuthor|by" +msgstr "por" + +msgid "Commit" +msgid_plural "Commits" +msgstr[0] "Cambio" +msgstr[1] "Cambios" + +msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." +msgstr "Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto." + +msgid "CycleAnalyticsStage|Code" +msgstr "Código" + +msgid "CycleAnalyticsStage|Issue" +msgstr "Incidencia" + +msgid "CycleAnalyticsStage|Plan" +msgstr "Planificación" + +msgid "CycleAnalyticsStage|Production" +msgstr "Producción" + +msgid "CycleAnalyticsStage|Review" +msgstr "Revisión" + +#, fuzzy +msgid "CycleAnalyticsStage|Staging" +msgstr "Puesta en escena" + +msgid "CycleAnalyticsStage|Test" +msgstr "Pruebas" + +msgid "Deploy" +msgid_plural "Deploys" +msgstr[0] "Despliegue" +msgstr[1] "Despliegues" + +msgid "FirstPushedBy|First" +msgstr "Primer" + +msgid "FirstPushedBy|pushed by" +msgstr "enviado por" + +msgid "From issue creation until deploy to production" +msgstr "Desde la creación de la incidencia hasta el despliegue a producción" + +msgid "From merge request merge until deploy to production" +msgstr "Desde la integración de la solicitud de fusión hasta el despliegue a producción" + +msgid "Introducing Cycle Analytics" +msgstr "Introducción a Cycle Analytics" + +msgid "Last %d day" +msgid_plural "Last %d days" +msgstr[0] "Último %d dÃa" +msgstr[1] "Últimos %d dÃas" + +msgid "Limited to showing %d event at most" +msgid_plural "Limited to showing %d events at most" +msgstr[0] "Limitado a mostrar máximo %d evento" +msgstr[1] "Limitado a mostrar máximo %d eventos" + +msgid "Median" +msgstr "Mediana" + +msgid "New Issue" +msgid_plural "New Issues" +msgstr[0] "Nueva incidencia" +msgstr[1] "Nuevas incidencias" + +msgid "Not available" +msgstr "No disponible" + +msgid "Not enough data" +msgstr "No hay suficientes datos" + +msgid "OpenedNDaysAgo|Opened" +msgstr "Abierto" + +msgid "Pipeline Health" +msgstr "Estado del Pipeline" + +msgid "ProjectLifecycle|Stage" +msgstr "Etapa" + +msgid "Read more" +msgstr "Leer más" + +msgid "Related Commits" +msgstr "Cambios Relacionados" + +msgid "Related Deployed Jobs" +msgstr "Trabajos Desplegados Relacionados" + +msgid "Related Issues" +msgstr "Incidencias Relacionadas" + +msgid "Related Jobs" +msgstr "Trabajos Relacionados" + +msgid "Related Merge Requests" +msgstr "Solicitudes de fusión Relacionadas" + +msgid "Related Merged Requests" +msgstr "Solicitudes de fusión Relacionadas" + +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "Mostrando %d evento" +msgstr[1] "Mostrando %d eventos" + +msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." +msgstr "La etapa de codificación muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión." + +msgid "The collection of events added to the data gathered for that stage." +msgstr "La colección de eventos agregados a los datos recopilados para esa etapa." + +msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." +msgstr "La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa." + +msgid "The phase of the development lifecycle." +msgstr "La etapa del ciclo de vida de desarrollo." + +msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." +msgstr "La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe el primer cambio." + +msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." +msgstr "La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción." + +msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." +msgstr "La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión." + +msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time." +msgstr "La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez." + +msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." +msgstr "La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse." + +msgid "The time taken by each data entry gathered by that stage." +msgstr "El tiempo utilizado por cada entrada de datos obtenido por esa etapa." + +msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." +msgstr "El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6." + +msgid "Time before an issue gets scheduled" +msgstr "Tiempo antes de que una incidencia sea programada" + +msgid "Time before an issue starts implementation" +msgstr "Tiempo antes de que empieze la implementación de una incidencia" + +msgid "Time between merge request creation and merge/close" +msgstr "Tiempo entre la creación de la solicitud de fusión y la integración o cierre de ésta" + +msgid "Time until first merge request" +msgstr "Tiempo hasta la primera solicitud de fusión" + +msgid "Time|hr" +msgid_plural "Time|hrs" +msgstr[0] "hr" +msgstr[1] "hrs" + +msgid "Time|min" +msgid_plural "Time|mins" +msgstr[0] "min" +msgstr[1] "mins" + +msgid "Time|s" +msgstr "s" + +msgid "Total Time" +msgstr "Tiempo Total" + +msgid "Total test time for all commits/merges" +msgstr "Tiempo total de pruebas para todos los cambios o integraciones" + +msgid "Want to see the data? Please ask an administrator for access." +msgstr "¿Quieres ver los datos? Por favor pide acceso al administrador." + +msgid "We don't have enough data to show this stage." +msgstr "No hay suficientes datos para mostrar en esta etapa." + +msgid "You need permission." +msgstr "Necesitas permisos." + +msgid "day" +msgid_plural "days" +msgstr[0] "dÃa" +msgstr[1] "dÃas" diff --git a/locale/es/gitlab.po.time_stamp b/locale/es/gitlab.po.time_stamp new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/locale/gitlab.pot b/locale/gitlab.pot new file mode 100644 index 0000000000000..3967d40ea9e50 --- /dev/null +++ b/locale/gitlab.pot @@ -0,0 +1,208 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-05-04 19:24-0500\n" +"PO-Revision-Date: 2017-05-04 19:24-0500\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" + +msgid "ByAuthor|by" +msgstr "" + +msgid "Commit" +msgid_plural "Commits" +msgstr[0] "" +msgstr[1] "" + +msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." +msgstr "" + +msgid "CycleAnalyticsStage|Code" +msgstr "" + +msgid "CycleAnalyticsStage|Issue" +msgstr "" + +msgid "CycleAnalyticsStage|Plan" +msgstr "" + +msgid "CycleAnalyticsStage|Production" +msgstr "" + +msgid "CycleAnalyticsStage|Review" +msgstr "" + +msgid "CycleAnalyticsStage|Staging" +msgstr "" + +msgid "CycleAnalyticsStage|Test" +msgstr "" + +msgid "Deploy" +msgid_plural "Deploys" +msgstr[0] "" +msgstr[1] "" + +msgid "FirstPushedBy|First" +msgstr "" + +msgid "FirstPushedBy|pushed by" +msgstr "" + +msgid "From issue creation until deploy to production" +msgstr "" + +msgid "From merge request merge until deploy to production" +msgstr "" + +msgid "Introducing Cycle Analytics" +msgstr "" + +msgid "Last %d day" +msgid_plural "Last %d days" +msgstr[0] "" +msgstr[1] "" + +msgid "Limited to showing %d event at most" +msgid_plural "Limited to showing %d events at most" +msgstr[0] "" +msgstr[1] "" + +msgid "Median" +msgstr "" + +msgid "New Issue" +msgid_plural "New Issues" +msgstr[0] "" +msgstr[1] "" + +msgid "Not available" +msgstr "" + +msgid "Not enough data" +msgstr "" + +msgid "OpenedNDaysAgo|Opened" +msgstr "" + +msgid "Pipeline Health" +msgstr "" + +msgid "ProjectLifecycle|Stage" +msgstr "" + +msgid "Read more" +msgstr "" + +msgid "Related Commits" +msgstr "" + +msgid "Related Deployed Jobs" +msgstr "" + +msgid "Related Issues" +msgstr "" + +msgid "Related Jobs" +msgstr "" + +msgid "Related Merge Requests" +msgstr "" + +msgid "Related Merged Requests" +msgstr "" + +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "" +msgstr[1] "" + +msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." +msgstr "" + +msgid "The collection of events added to the data gathered for that stage." +msgstr "" + +msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." +msgstr "" + +msgid "The phase of the development lifecycle." +msgstr "" + +msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." +msgstr "" + +msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." +msgstr "" + +msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." +msgstr "" + +msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time." +msgstr "" + +msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." +msgstr "" + +msgid "The time taken by each data entry gathered by that stage." +msgstr "" + +msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." +msgstr "" + +msgid "Time before an issue gets scheduled" +msgstr "" + +msgid "Time before an issue starts implementation" +msgstr "" + +msgid "Time between merge request creation and merge/close" +msgstr "" + +msgid "Time until first merge request" +msgstr "" + +msgid "Time|hr" +msgid_plural "Time|hrs" +msgstr[0] "" +msgstr[1] "" + +msgid "Time|min" +msgid_plural "Time|mins" +msgstr[0] "" +msgstr[1] "" + +msgid "Time|s" +msgstr "" + +msgid "Total Time" +msgstr "" + +msgid "Total test time for all commits/merges" +msgstr "" + +msgid "Want to see the data? Please ask an administrator for access." +msgstr "" + +msgid "We don't have enough data to show this stage." +msgstr "" + +msgid "You need permission." +msgstr "" + +msgid "day" +msgid_plural "days" +msgstr[0] "" +msgstr[1] "" diff --git a/package.json b/package.json index 3d260a66a53f4..22f9a6833aa65 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,9 @@ "dropzone": "^4.2.0", "emoji-unicode-version": "^0.2.1", "eslint-plugin-html": "^2.0.1", + "exports-loader": "^0.6.4", "file-loader": "^0.11.1", + "jed": "^1.1.1", "jquery": "^2.2.1", "jquery-ujs": "^1.2.1", "js-cookie": "^2.1.3", @@ -39,9 +41,11 @@ "pikaday": "^1.5.1", "prismjs": "^1.6.0", "raphael": "^2.2.7", + "raven-js": "^3.14.0", "raw-loader": "^0.5.1", "react-dev-utils": "^0.5.2", "select2": "3.5.2-browserify", + "sql.js": "^0.4.0", "stats-webpack-plugin": "^0.4.3", "three": "^0.84.0", "three-orbit-controls": "^82.1.0", diff --git a/scripts/static-analysis b/scripts/static-analysis index 1bd6b33983090..7dc8f67903640 100755 --- a/scripts/static-analysis +++ b/scripts/static-analysis @@ -3,6 +3,7 @@ require ::File.expand_path('../lib/gitlab/popen', __dir__) tasks = [ + %w[bundle exec bundle-audit check --update --ignore CVE-2016-4658], %w[bundle exec rake config_lint], %w[bundle exec rake flay], %w[bundle exec rake haml_lint], diff --git a/spec/controllers/admin/services_controller_spec.rb b/spec/controllers/admin/services_controller_spec.rb index e5cdd52307ee4..c94616d8508f4 100644 --- a/spec/controllers/admin/services_controller_spec.rb +++ b/spec/controllers/admin/services_controller_spec.rb @@ -23,4 +23,36 @@ end end end + + describe "#update" do + let(:project) { create(:empty_project) } + let!(:service) do + RedmineService.create( + project: project, + active: false, + template: true, + properties: { + project_url: 'http://abc', + issues_url: 'http://abc', + new_issue_url: 'http://abc' + } + ) + end + + it 'calls the propagation worker when service is active' do + expect(PropagateServiceTemplateWorker).to receive(:perform_async).with(service.id) + + put :update, id: service.id, service: { active: true } + + expect(response).to have_http_status(302) + end + + it 'does not call the propagation worker when service is not active' do + expect(PropagateServiceTemplateWorker).not_to receive(:perform_async) + + put :update, id: service.id, service: { properties: {} } + + expect(response).to have_http_status(302) + end + end end diff --git a/spec/controllers/dashboard/todos_controller_spec.rb b/spec/controllers/dashboard/todos_controller_spec.rb index 762e90f4a1672..085f3fd8543d5 100644 --- a/spec/controllers/dashboard/todos_controller_spec.rb +++ b/spec/controllers/dashboard/todos_controller_spec.rb @@ -14,7 +14,7 @@ describe 'GET #index' do context 'when using pagination' do let(:last_page) { user.todos.page.total_pages } - let!(:issues) { create_list(:issue, 2, project: project, assignee: user) } + let!(:issues) { create_list(:issue, 2, project: project, assignees: [user]) } before do issues.each { |issue| todo_service.new_issue(issue, user) } diff --git a/spec/controllers/projects/artifacts_controller_spec.rb b/spec/controllers/projects/artifacts_controller_spec.rb new file mode 100644 index 0000000000000..eff9fab8da2a5 --- /dev/null +++ b/spec/controllers/projects/artifacts_controller_spec.rb @@ -0,0 +1,188 @@ +require 'spec_helper' + +describe Projects::ArtifactsController do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + + let(:pipeline) do + create(:ci_pipeline, + project: project, + sha: project.commit.sha, + ref: project.default_branch, + status: 'success') + end + + let(:build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) } + + before do + project.team << [user, :developer] + + sign_in(user) + end + + describe 'GET download' do + it 'sends the artifacts file' do + expect(controller).to receive(:send_file).with(build.artifacts_file.path, disposition: 'attachment').and_call_original + + get :download, namespace_id: project.namespace, project_id: project, build_id: build + end + end + + describe 'GET browse' do + context 'when the directory exists' do + it 'renders the browse view' do + get :browse, namespace_id: project.namespace, project_id: project, build_id: build, path: 'other_artifacts_0.1.2' + + expect(response).to render_template('projects/artifacts/browse') + end + end + + context 'when the directory does not exist' do + it 'responds Not Found' do + get :browse, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + + expect(response).to be_not_found + end + end + end + + describe 'GET file' do + context 'when the file exists' do + it 'renders the file view' do + get :file, namespace_id: project.namespace, project_id: project, build_id: build, path: 'ci_artifacts.txt' + + expect(response).to render_template('projects/artifacts/file') + end + end + + context 'when the file does not exist' do + it 'responds Not Found' do + get :file, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + + expect(response).to be_not_found + end + end + end + + describe 'GET raw' do + context 'when the file exists' do + it 'serves the file using workhorse' do + get :raw, namespace_id: project.namespace, project_id: project, build_id: build, path: 'ci_artifacts.txt' + + send_data = response.headers[Gitlab::Workhorse::SEND_DATA_HEADER] + + expect(send_data).to start_with('artifacts-entry:') + + base64_params = send_data.sub(/\Aartifacts\-entry:/, '') + params = JSON.parse(Base64.urlsafe_decode64(base64_params)) + + expect(params.keys).to eq(%w(Archive Entry)) + expect(params['Archive']).to end_with('build_artifacts.zip') + expect(params['Entry']).to eq(Base64.encode64('ci_artifacts.txt')) + end + end + + context 'when the file does not exist' do + it 'responds Not Found' do + get :raw, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + + expect(response).to be_not_found + end + end + end + + describe 'GET latest_succeeded' do + def params_from_ref(ref = pipeline.ref, job = build.name, path = 'browse') + { + namespace_id: project.namespace, + project_id: project, + ref_name_and_path: File.join(ref, path), + job: job + } + end + + context 'cannot find the build' do + shared_examples 'not found' do + it { expect(response).to have_http_status(:not_found) } + end + + context 'has no such ref' do + before do + get :latest_succeeded, params_from_ref('TAIL', build.name) + end + + it_behaves_like 'not found' + end + + context 'has no such build' do + before do + get :latest_succeeded, params_from_ref(pipeline.ref, 'NOBUILD') + end + + it_behaves_like 'not found' + end + + context 'has no path' do + before do + get :latest_succeeded, params_from_ref(pipeline.sha, build.name, '') + end + + it_behaves_like 'not found' + end + end + + context 'found the build and redirect' do + shared_examples 'redirect to the build' do + it 'redirects' do + path = browse_namespace_project_build_artifacts_path( + project.namespace, + project, + build) + + expect(response).to redirect_to(path) + end + end + + context 'with regular branch' do + before do + pipeline.update(ref: 'master', + sha: project.commit('master').sha) + + get :latest_succeeded, params_from_ref('master') + end + + it_behaves_like 'redirect to the build' + end + + context 'with branch name containing slash' do + before do + pipeline.update(ref: 'improve/awesome', + sha: project.commit('improve/awesome').sha) + + get :latest_succeeded, params_from_ref('improve/awesome') + end + + it_behaves_like 'redirect to the build' + end + + context 'with branch name and path containing slashes' do + before do + pipeline.update(ref: 'improve/awesome', + sha: project.commit('improve/awesome').sha) + + get :latest_succeeded, params_from_ref('improve/awesome', build.name, 'file/README.md') + end + + it 'redirects' do + path = file_namespace_project_build_artifacts_path( + project.namespace, + project, + build, + 'README.md') + + expect(response).to redirect_to(path) + end + end + end + end +end diff --git a/spec/controllers/projects/boards/issues_controller_spec.rb b/spec/controllers/projects/boards/issues_controller_spec.rb index 817c2285672c1..f5d5204c24563 100644 --- a/spec/controllers/projects/boards/issues_controller_spec.rb +++ b/spec/controllers/projects/boards/issues_controller_spec.rb @@ -34,7 +34,7 @@ issue = create(:labeled_issue, project: project, labels: [planning]) create(:labeled_issue, project: project, labels: [planning]) create(:labeled_issue, project: project, labels: [development], due_date: Date.tomorrow) - create(:labeled_issue, project: project, labels: [development], assignee: johndoe) + create(:labeled_issue, project: project, labels: [development], assignees: [johndoe]) issue.subscribe(johndoe, project) list_issues user: user, board: board, list: list2 diff --git a/spec/controllers/projects/deploy_keys_controller_spec.rb b/spec/controllers/projects/deploy_keys_controller_spec.rb new file mode 100644 index 0000000000000..efe1a78415b7d --- /dev/null +++ b/spec/controllers/projects/deploy_keys_controller_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' + +describe Projects::DeployKeysController do + let(:project) { create(:project, :repository) } + let(:user) { create(:user) } + + before do + project.team << [user, :master] + + sign_in(user) + end + + describe 'GET index' do + let(:params) do + { namespace_id: project.namespace, project_id: project } + end + + context 'when html requested' do + it 'redirects to blob' do + get :index, params + + expect(response).to redirect_to(namespace_project_settings_repository_path(params)) + end + end + + context 'when json requested' do + let(:project2) { create(:empty_project, :internal)} + let(:project_private) { create(:empty_project, :private)} + + let(:deploy_key_internal) do + create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCdMHEHyhRjbhEZVddFn6lTWdgEy5Q6Bz4nwGB76xWZI5YT/1WJOMEW+sL5zYd31kk7sd3FJ5L9ft8zWMWrr/iWXQikC2cqZK24H1xy+ZUmrRuJD4qGAaIVoyyzBL+avL+lF8J5lg6YSw8gwJY/lX64/vnJHUlWw2n5BF8IFOWhiw== dummy@gitlab.com') + end + let(:deploy_key_actual) do + create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDNd/UJWhPrpb+b/G5oL109y57yKuCxE+WUGJGYaj7WQKsYRJmLYh1mgjrl+KVyfsWpq4ylOxIfFSnN9xBBFN8mlb0Fma5DC7YsSsibJr3MZ19ZNBprwNcdogET7aW9I0In7Wu5f2KqI6e5W/spJHCy4JVxzVMUvk6Myab0LnJ2iQ== dummy@gitlab.com') + end + let!(:deploy_key_public) { create(:deploy_key, public: true) } + + let!(:deploy_keys_project_internal) do + create(:deploy_keys_project, project: project2, deploy_key: deploy_key_internal) + end + + let!(:deploy_keys_actual_project) do + create(:deploy_keys_project, project: project, deploy_key: deploy_key_actual) + end + + let!(:deploy_keys_project_private) do + create(:deploy_keys_project, project: project_private, deploy_key: create(:another_deploy_key)) + end + + before do + project2.team << [user, :developer] + end + + it 'returns json in a correct format' do + get :index, params.merge(format: :json) + + json = JSON.parse(response.body) + + expect(json.keys).to match_array(%w(enabled_keys available_project_keys public_keys)) + expect(json['enabled_keys'].count).to eq(1) + expect(json['available_project_keys'].count).to eq(1) + expect(json['public_keys'].count).to eq(1) + end + end + end +end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 5f1f892821a05..1f79e72495ac7 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -173,12 +173,12 @@ namespace_id: project.namespace.to_param, project_id: project, id: issue.iid, - issue: { assignee_id: assignee.id }, + issue: { assignee_ids: [assignee.id] }, format: :json body = JSON.parse(response.body) - expect(body['assignee'].keys) - .to match_array(%w(name username avatar_url)) + expect(body['assignees'].first.keys) + .to match_array(%w(id name username avatar_url)) end end @@ -348,7 +348,7 @@ def move_issue let(:admin) { create(:admin) } let!(:issue) { create(:issue, project: project) } let!(:unescaped_parameter_value) { create(:issue, :confidential, project: project, author: author) } - let!(:request_forgery_timing_attack) { create(:issue, :confidential, project: project, assignee: assignee) } + let!(:request_forgery_timing_attack) { create(:issue, :confidential, project: project, assignees: [assignee]) } describe 'GET #index' do it 'does not list confidential issues for guests' do diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index df54e21c8083c..730d2156b1dfd 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -1356,7 +1356,7 @@ def post_assign_issues end it 'correctly pluralizes flash message on success' do - issue2.update!(assignee: user) + issue2.assignees = [user] post_assign_issues diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb index b9bacc5a64a25..1b47d163c0b3d 100644 --- a/spec/controllers/projects/pipelines_controller_spec.rb +++ b/spec/controllers/projects/pipelines_controller_spec.rb @@ -5,6 +5,8 @@ let(:project) { create(:empty_project, :public) } before do + project.add_developer(user) + sign_in(user) end @@ -87,4 +89,38 @@ def get_stage(name) expect(json_response['favicon']).to eq "/assets/ci_favicons/#{status.favicon}.ico" end end + + describe 'POST retry.json' do + let!(:pipeline) { create(:ci_pipeline, :failed, project: project) } + let!(:build) { create(:ci_build, :failed, pipeline: pipeline) } + + before do + post :retry, namespace_id: project.namespace, + project_id: project, + id: pipeline.id, + format: :json + end + + it 'retries a pipeline without returning any content' do + expect(response).to have_http_status(:no_content) + expect(build.reload).to be_retried + end + end + + describe 'POST cancel.json' do + let!(:pipeline) { create(:ci_pipeline, project: project) } + let!(:build) { create(:ci_build, :running, pipeline: pipeline) } + + before do + post :cancel, namespace_id: project.namespace, + project_id: project, + id: pipeline.id, + format: :json + end + + it 'cancels a pipeline without returning any content' do + expect(response).to have_http_status(:no_content) + expect(pipeline.reload).to be_canceled + end + end end diff --git a/spec/features/atom/dashboard_issues_spec.rb b/spec/features/atom/dashboard_issues_spec.rb index 58b14e09740fa..9ea325ab41b05 100644 --- a/spec/features/atom/dashboard_issues_spec.rb +++ b/spec/features/atom/dashboard_issues_spec.rb @@ -32,7 +32,7 @@ end context "issue with basic fields" do - let!(:issue2) { create(:issue, author: user, assignee: assignee, project: project2, description: 'test desc') } + let!(:issue2) { create(:issue, author: user, assignees: [assignee], project: project2, description: 'test desc') } it "renders issue fields" do visit issues_dashboard_path(:atom, private_token: user.private_token) @@ -41,7 +41,7 @@ expect(entry).to be_present expect(entry).to have_selector('author email', text: issue2.author_public_email) - expect(entry).to have_selector('assignee email', text: issue2.assignee_public_email) + expect(entry).to have_selector('assignees email', text: assignee.public_email) expect(entry).not_to have_selector('labels') expect(entry).not_to have_selector('milestone') expect(entry).to have_selector('description', text: issue2.description) @@ -51,7 +51,7 @@ context "issue with label and milestone" do let!(:milestone1) { create(:milestone, project: project1, title: 'v1') } let!(:label1) { create(:label, project: project1, title: 'label1') } - let!(:issue1) { create(:issue, author: user, assignee: assignee, project: project1, milestone: milestone1) } + let!(:issue1) { create(:issue, author: user, assignees: [assignee], project: project1, milestone: milestone1) } before do issue1.labels << label1 @@ -64,7 +64,7 @@ expect(entry).to be_present expect(entry).to have_selector('author email', text: issue1.author_public_email) - expect(entry).to have_selector('assignee email', text: issue1.assignee_public_email) + expect(entry).to have_selector('assignees email', text: assignee.public_email) expect(entry).to have_selector('labels label', text: label1.title) expect(entry).to have_selector('milestone', text: milestone1.title) expect(entry).not_to have_selector('description') diff --git a/spec/features/atom/issues_spec.rb b/spec/features/atom/issues_spec.rb index b3903ec2faf92..4f6754ad54105 100644 --- a/spec/features/atom/issues_spec.rb +++ b/spec/features/atom/issues_spec.rb @@ -6,7 +6,7 @@ let!(:assignee) { create(:user, email: 'private2@example.com', public_email: 'public2@example.com') } let!(:group) { create(:group) } let!(:project) { create(:project) } - let!(:issue) { create(:issue, author: user, assignee: assignee, project: project) } + let!(:issue) { create(:issue, author: user, assignees: [assignee], project: project) } before do project.team << [user, :developer] @@ -22,7 +22,8 @@ to have_content('application/atom+xml') expect(body).to have_selector('title', text: "#{project.name} issues") expect(body).to have_selector('author email', text: issue.author_public_email) - expect(body).to have_selector('assignee email', text: issue.author_public_email) + expect(body).to have_selector('assignees assignee email', text: issue.assignees.first.public_email) + expect(body).to have_selector('assignee email', text: issue.assignees.first.public_email) expect(body).to have_selector('entry summary', text: issue.title) end end @@ -36,7 +37,8 @@ to have_content('application/atom+xml') expect(body).to have_selector('title', text: "#{project.name} issues") expect(body).to have_selector('author email', text: issue.author_public_email) - expect(body).to have_selector('assignee email', text: issue.author_public_email) + expect(body).to have_selector('assignees assignee email', text: issue.assignees.first.public_email) + expect(body).to have_selector('assignee email', text: issue.assignees.first.public_email) expect(body).to have_selector('entry summary', text: issue.title) end end diff --git a/spec/features/boards/board_with_milestone_spec.rb b/spec/features/boards/board_with_milestone_spec.rb index 845921ad0686c..2b11ec1cf6409 100644 --- a/spec/features/boards/board_with_milestone_spec.rb +++ b/spec/features/boards/board_with_milestone_spec.rb @@ -109,7 +109,7 @@ it 'removes issues milestone when removing from the board' do wait_for_vue_resource - first('.card').click + first('.card .card-number').click click_button('Remove from board') diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index f962cd6d0b6eb..83e03f539bbe2 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -71,7 +71,7 @@ let!(:list2) { create(:list, board: board, label: development, position: 1) } let!(:confidential_issue) { create(:labeled_issue, :confidential, project: project, author: user, labels: [planning], relative_position: 9) } - let!(:issue1) { create(:labeled_issue, project: project, assignee: user, labels: [planning], relative_position: 8) } + let!(:issue1) { create(:labeled_issue, project: project, assignees: [user], labels: [planning], relative_position: 8) } let!(:issue2) { create(:labeled_issue, project: project, author: user2, labels: [planning], relative_position: 7) } let!(:issue3) { create(:labeled_issue, project: project, labels: [planning], relative_position: 6) } let!(:issue4) { create(:labeled_issue, project: project, labels: [planning], relative_position: 5) } diff --git a/spec/features/boards/modal_filter_spec.rb b/spec/features/boards/modal_filter_spec.rb index 4a4c13e79c8a9..e1367c675e58b 100644 --- a/spec/features/boards/modal_filter_spec.rb +++ b/spec/features/boards/modal_filter_spec.rb @@ -98,7 +98,7 @@ end context 'assignee' do - let!(:issue) { create(:issue, project: project, assignee: user2) } + let!(:issue) { create(:issue, project: project, assignees: [user2]) } before do project.team << [user2, :developer] diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb index bafa4f0593710..02b6b5dc88865 100644 --- a/spec/features/boards/sidebar_spec.rb +++ b/spec/features/boards/sidebar_spec.rb @@ -4,13 +4,14 @@ include WaitForVueResource let(:user) { create(:user) } + let(:user2) { create(:user) } let(:project) { create(:empty_project, :public) } let!(:milestone) { create(:milestone, project: project) } let!(:development) { create(:label, project: project, name: 'Development') } let!(:bug) { create(:label, project: project, name: 'Bug') } let!(:regression) { create(:label, project: project, name: 'Regression') } let!(:stretch) { create(:label, project: project, name: 'Stretch') } - let!(:issue1) { create(:labeled_issue, project: project, assignee: user, milestone: milestone, labels: [development], relative_position: 2) } + let!(:issue1) { create(:labeled_issue, project: project, assignees: [user], milestone: milestone, labels: [development], relative_position: 2) } let!(:issue2) { create(:labeled_issue, project: project, labels: [development, stretch], relative_position: 1) } let(:board) { create(:board, project: project) } let!(:list) { create(:list, board: board, label: development, position: 0) } @@ -20,6 +21,7 @@ Timecop.freeze project.team << [user, :master] + project.team.add_developer(user2) login_as(user) @@ -101,6 +103,26 @@ expect(card).to have_selector('.avatar') end + it 'adds multiple assignees' do + click_card(card) + + page.within('.assignee') do + click_link 'Edit' + + wait_for_ajax + + page.within('.dropdown-menu-user') do + click_link user.name + click_link user2.name + end + + expect(page).to have_content(user.name) + expect(page).to have_content(user2.name) + end + + expect(card.all('.avatar').length).to eq(2) + end + it 'removes the assignee' do card_two = first('.board').find('.card:nth-child(2)') click_card(card_two) @@ -112,10 +134,11 @@ page.within('.dropdown-menu-user') do click_link 'Unassigned' - - wait_for_vue_resource end + find('.dropdown-menu-toggle').click + wait_for_vue_resource + expect(page).to have_content('No assignee') end @@ -128,7 +151,7 @@ page.within(find('.assignee')) do expect(page).to have_content('No assignee') - click_link 'assign yourself' + click_button 'assign yourself' wait_for_vue_resource @@ -138,7 +161,7 @@ expect(card).to have_selector('.avatar') end - it 'resets assignee dropdown' do + it 'updates assignee dropdown' do click_card(card) page.within('.assignee') do @@ -162,7 +185,7 @@ page.within('.assignee') do click_link 'Edit' - expect(page).not_to have_selector('.is-active') + expect(page).to have_selector('.is-active') end end end diff --git a/spec/features/cycle_analytics_spec.rb b/spec/features/cycle_analytics_spec.rb index b93275c330bbc..7c9d522273bea 100644 --- a/spec/features/cycle_analytics_spec.rb +++ b/spec/features/cycle_analytics_spec.rb @@ -62,6 +62,25 @@ expect_issue_to_be_present end end + + context "when my preferred language is Spanish" do + before do + user.update_attribute(:preferred_language, 'es') + + project.team << [user, :master] + login_as(user) + visit namespace_project_cycle_analytics_path(project.namespace, project) + wait_for_ajax + end + + it 'shows the content in Spanish' do + expect(page).to have_content('Estado del Pipeline') + end + + it 'resets the language to English' do + expect(I18n.locale).to eq(:en) + end + end end context "as a guest" do diff --git a/spec/features/dashboard/issuables_counter_spec.rb b/spec/features/dashboard/issuables_counter_spec.rb index 4fca7577e7431..6f7bf0eba6ebf 100644 --- a/spec/features/dashboard/issuables_counter_spec.rb +++ b/spec/features/dashboard/issuables_counter_spec.rb @@ -7,7 +7,7 @@ let(:merge_request) { create(:merge_request, source_project: project) } before do - issue.update(assignee: user) + issue.assignees = [user] merge_request.update(assignee: user) login_as(user) end @@ -17,7 +17,9 @@ expect_counters('issues', '1') - issue.update(assignee: nil) + issue.assignees = [] + + user.update_cache_counts Timecop.travel(3.minutes.from_now) do visit issues_path diff --git a/spec/features/dashboard/issues_spec.rb b/spec/features/dashboard/issues_spec.rb index f4420814c3a1b..86c7954e60cf0 100644 --- a/spec/features/dashboard/issues_spec.rb +++ b/spec/features/dashboard/issues_spec.rb @@ -11,7 +11,7 @@ let!(:authored_issue) { create :issue, author: current_user, project: project } let!(:authored_issue_on_public_project) { create :issue, author: current_user, project: public_project } - let!(:assigned_issue) { create :issue, assignee: current_user, project: project } + let!(:assigned_issue) { create :issue, assignees: [current_user], project: project } let!(:other_issue) { create :issue, project: project } before do @@ -30,6 +30,11 @@ find('#assignee_id', visible: false).set('') find('.js-author-search', match: :first).click find('.dropdown-menu-author li a', match: :first, text: current_user.to_reference).click + find('.js-author-search', match: :first).click + + page.within '.dropdown-menu-user' do + expect(find('.dropdown-menu-author li a.is-active', match: :first, text: current_user.to_reference)).to be_visible + end expect(page).to have_content(authored_issue.title) expect(page).to have_content(authored_issue_on_public_project.title) diff --git a/spec/features/dashboard_issues_spec.rb b/spec/features/dashboard_issues_spec.rb index b6b879052315c..ad60fb2c74f51 100644 --- a/spec/features/dashboard_issues_spec.rb +++ b/spec/features/dashboard_issues_spec.rb @@ -10,8 +10,8 @@ project.team << [user, :master] login_as(user) - create(:issue, project: project, author: user, assignee: user) - create(:issue, project: project, author: user, assignee: user, milestone: milestone) + create(:issue, project: project, author: user, assignees: [user]) + create(:issue, project: project, author: user, assignees: [user], milestone: milestone) visit_issues end diff --git a/spec/features/gitlab_flavored_markdown_spec.rb b/spec/features/gitlab_flavored_markdown_spec.rb index f5b54463df8ff..005a029a39319 100644 --- a/spec/features/gitlab_flavored_markdown_spec.rb +++ b/spec/features/gitlab_flavored_markdown_spec.rb @@ -54,11 +54,11 @@ before do @other_issue = create(:issue, author: @user, - assignee: @user, + assignees: [@user], project: project) @issue = create(:issue, author: @user, - assignee: @user, + assignees: [@user], project: project, title: "fix #{@other_issue.to_reference}", description: "ask #{fred.to_reference} for details") diff --git a/spec/features/issues/award_emoji_spec.rb b/spec/features/issues/award_emoji_spec.rb index 71df3c949db4b..853632614c447 100644 --- a/spec/features/issues/award_emoji_spec.rb +++ b/spec/features/issues/award_emoji_spec.rb @@ -7,7 +7,7 @@ let!(:user) { create(:user) } let(:issue) do create(:issue, - assignee: @user, + assignees: [user], project: project) end diff --git a/spec/features/issues/csv_spec.rb b/spec/features/issues/csv_spec.rb index 4667859c4c8f5..7dc83dd595831 100644 --- a/spec/features/issues/csv_spec.rb +++ b/spec/features/issues/csv_spec.rb @@ -76,7 +76,7 @@ def csv create_list(:labeled_issue, 10, project: project, - assignee: user, + assignees: [user], author: user, milestone: milestone, labels: [feature_label, idea_label]) diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb index c824aa6a41450..a8f4e2d7e10ad 100644 --- a/spec/features/issues/filtered_search/filter_issues_spec.rb +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -51,15 +51,15 @@ def select_search_at_index(pos) create(:issue, project: project, title: "issue with 'single quotes'") create(:issue, project: project, title: "issue with \"double quotes\"") create(:issue, project: project, title: "issue with !@\#{$%^&*()-+") - create(:issue, project: project, title: "issue by assignee", milestone: milestone, author: user, assignee: user) - create(:issue, project: project, title: "issue by assignee with searchTerm", milestone: milestone, author: user, assignee: user) + create(:issue, project: project, title: "issue by assignee", milestone: milestone, author: user, assignees: [user]) + create(:issue, project: project, title: "issue by assignee with searchTerm", milestone: milestone, author: user, assignees: [user]) issue = create(:issue, title: "Bug 2", project: project, milestone: milestone, author: user, - assignee: user) + assignees: [user]) issue.labels << bug_label issue_with_caps_label = create(:issue, @@ -67,7 +67,7 @@ def select_search_at_index(pos) project: project, milestone: milestone, author: user, - assignee: user) + assignees: [user]) issue_with_caps_label.labels << caps_sensitive_label issue_with_everything = create(:issue, @@ -75,7 +75,7 @@ def select_search_at_index(pos) project: project, milestone: milestone, author: user, - assignee: user) + assignees: [user]) issue_with_everything.labels << bug_label issue_with_everything.labels << caps_sensitive_label diff --git a/spec/features/issues/filtered_search/filter_issues_weight_spec.rb b/spec/features/issues/filtered_search/filter_issues_weight_spec.rb index b0c0f4679ad4a..f53bddfb50350 100644 --- a/spec/features/issues/filtered_search/filter_issues_weight_spec.rb +++ b/spec/features/issues/filtered_search/filter_issues_weight_spec.rb @@ -31,7 +31,7 @@ def expect_issues_list_count(open_count, closed_count = 0) title: 'Bug report 1', milestone: milestone, author: user, - assignee: user) + assignees: [user]) issue.labels << label visit namespace_project_issues_path(project.namespace, project) diff --git a/spec/features/issues/form_spec.rb b/spec/features/issues/form_spec.rb index eb55a9f9fd494..4b4530c17110b 100644 --- a/spec/features/issues/form_spec.rb +++ b/spec/features/issues/form_spec.rb @@ -10,7 +10,7 @@ let!(:milestone) { create(:milestone, project: project) } let!(:label) { create(:label, project: project) } let!(:label2) { create(:label, project: project) } - let!(:issue) { create(:issue, project: project, assignee: user, milestone: milestone) } + let!(:issue) { create(:issue, project: project, assignees: [user], milestone: milestone) } before do project.team << [user, :master] @@ -23,25 +23,65 @@ visit new_namespace_project_issue_path(project.namespace, project) end + describe 'multiple assignees' do + before do + click_button 'Unassigned' + end + + it 'unselects other assignees when unassigned is selected' do + page.within '.dropdown-menu-user' do + click_link user2.name + end + + page.within '.dropdown-menu-user' do + click_link 'Unassigned' + end + + page.within '.js-assignee-search' do + expect(page).to have_content 'Unassigned' + end + + expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match('0') + end + + it 'toggles assign to me when current user is selected and unselected' do + page.within '.dropdown-menu-user' do + click_link user.name + end + + expect(find('a', text: 'Assign to me', visible: false)).not_to be_visible + + page.within '.dropdown-menu-user' do + click_link user.name + end + + expect(find('a', text: 'Assign to me')).to be_visible + end + end + it 'allows user to create new issue' do fill_in 'issue_title', with: 'title' fill_in 'issue_description', with: 'title' expect(find('a', text: 'Assign to me')).to be_visible - click_button 'Assignee' + click_button 'Unassigned' page.within '.dropdown-menu-user' do click_link user2.name end - expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user2.id.to_s) + expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user2.id.to_s) page.within '.js-assignee-search' do expect(page).to have_content user2.name end expect(find('a', text: 'Assign to me')).to be_visible click_link 'Assign to me' - expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user.id.to_s) + assignee_ids = page.all('input[name="issue[assignee_ids][]"]', visible: false) + + expect(assignee_ids[0].value).to match(user2.id.to_s) + expect(assignee_ids[1].value).to match(user.id.to_s) + page.within '.js-assignee-search' do - expect(page).to have_content user.name + expect(page).to have_content "#{user2.name} + 1 more" end expect(find('a', text: 'Assign to me', visible: false)).not_to be_visible @@ -77,7 +117,7 @@ page.within '.issuable-sidebar' do page.within '.assignee' do - expect(page).to have_content user.name + expect(page).to have_content "2 Assignees" end page.within '.milestone' do @@ -120,15 +160,12 @@ end it 'correctly updates the selected user when changing assignee' do - click_button 'Assignee' + click_button 'Unassigned' page.within '.dropdown-menu-user' do click_link user.name end - expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user.id.to_s) - - click_button user.name - + expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user.id.to_s) expect(find('.dropdown-menu-user a.is-active').first(:xpath, '..')['data-user-id']).to eq(user.id.to_s) # check the ::before pseudo element to ensure checkmark icon is present @@ -139,11 +176,13 @@ click_link user2.name end - expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user2.id.to_s) + expect(page.all('input[name="issue[assignee_ids][]"]', visible: false)[0].value).to match(user.id.to_s) + expect(page.all('input[name="issue[assignee_ids][]"]', visible: false)[1].value).to match(user2.id.to_s) - click_button user2.name + expect(page.all('.dropdown-menu-user a.is-active').length).to eq(2) - expect(find('.dropdown-menu-user a.is-active').first(:xpath, '..')['data-user-id']).to eq(user2.id.to_s) + expect(page.all('.dropdown-menu-user a.is-active')[0].first(:xpath, '..')['data-user-id']).to eq(user.id.to_s) + expect(page.all('.dropdown-menu-user a.is-active')[1].first(:xpath, '..')['data-user-id']).to eq(user2.id.to_s) end end @@ -153,7 +192,7 @@ end it 'allows user to update issue' do - expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user.id.to_s) + expect(find('input[name="issue[assignee_ids][]"]', visible: false).value).to match(user.id.to_s) expect(find('input[name="issue[milestone_id]"]', visible: false).value).to match(milestone.id.to_s) expect(find('a', text: 'Assign to me', visible: false)).not_to be_visible diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb index a1987e904a58c..e6ed8f1434483 100644 --- a/spec/features/issues/issue_sidebar_spec.rb +++ b/spec/features/issues/issue_sidebar_spec.rb @@ -42,6 +42,21 @@ expect(page).to have_content(user2.name) end end + + it 'assigns yourself' do + find('.block.assignee .dropdown-menu-toggle').click + + click_button 'assign yourself' + + wait_for_ajax + + find('.block.assignee .edit-link').click + + page.within '.dropdown-menu-user' do + expect(page.find('.dropdown-header')).to be_visible + expect(page.find('.dropdown-menu-user-link.is-active')).to have_content(user.name) + end + end end context 'as a allowed user' do diff --git a/spec/features/issues/update_issues_spec.rb b/spec/features/issues/update_issues_spec.rb index 7fa83c1fcf764..b250fa2ed3c80 100644 --- a/spec/features/issues/update_issues_spec.rb +++ b/spec/features/issues/update_issues_spec.rb @@ -99,7 +99,7 @@ def create_closed end def create_assigned - create(:issue, project: project, assignee: user) + create(:issue, project: project, assignees: [user]) end def create_with_milestone diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index 8154d5d264c5e..2ee2abe8ed0fe 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -18,7 +18,7 @@ let!(:issue) do create(:issue, author: @user, - assignee: @user, + assignees: [@user], project: project) end @@ -43,7 +43,7 @@ let!(:issue) do create(:issue, author: @user, - assignee: @user, + assignees: [@user], project: project) end @@ -61,7 +61,7 @@ expect(page).to have_content 'No assignee - assign yourself' end - expect(issue.reload.assignee).to be_nil + expect(issue.reload.assignees).to be_empty end end @@ -138,7 +138,7 @@ describe 'Issue info' do it 'excludes award_emoji from comment count' do - issue = create(:issue, author: @user, assignee: @user, project: project, title: 'foobar') + issue = create(:issue, author: @user, assignees: [@user], project: project, title: 'foobar') create(:award_emoji, awardable: issue) visit namespace_project_issues_path(project.namespace, project, assignee_id: @user.id) @@ -164,14 +164,14 @@ %w(foobar barbaz gitlab).each do |title| create(:issue, author: @user, - assignee: @user, + assignees: [@user], project: project, title: title) end @issue = Issue.find_by(title: 'foobar') @issue.milestone = create(:milestone, project: project) - @issue.assignee = nil + @issue.assignees = [] @issue.save end @@ -362,9 +362,9 @@ let(:user2) { create(:user) } before do - foo.assignee = user2 + foo.assignees << user2 foo.save - bar.assignee = user2 + bar.assignees << user2 bar.save end @@ -407,7 +407,7 @@ end describe 'update labels from issue#show', js: true do - let(:issue) { create(:issue, project: project, author: @user, assignee: @user) } + let(:issue) { create(:issue, project: project, author: @user, assignees: [@user]) } let!(:label) { create(:label, project: project) } before do @@ -426,7 +426,7 @@ end describe 'update assignee from issue#show' do - let(:issue) { create(:issue, project: project, author: @user, assignee: @user) } + let(:issue) { create(:issue, project: project, author: @user, assignees: [@user]) } context 'by authorized user' do it 'allows user to select unassigned', js: true do @@ -437,10 +437,14 @@ click_link 'Edit' click_link 'Unassigned' + first('.title').click expect(page).to have_content 'No assignee' end - expect(issue.reload.assignee).to be_nil + # wait_for_ajax does not work with vue-resource at the moment + sleep 1 + + expect(issue.reload.assignees).to be_empty end it 'allows user to select an assignee', js: true do @@ -472,14 +476,18 @@ click_link 'Edit' click_link @user.name - page.within '.value' do + find('.dropdown-menu-toggle').click + + page.within '.value .author' do expect(page).to have_content @user.name end click_link 'Edit' click_link @user.name - page.within '.value' do + find('.dropdown-menu-toggle').click + + page.within '.value .assign-yourself' do expect(page).to have_content "No assignee" end end @@ -498,7 +506,7 @@ login_with guest visit namespace_project_issue_path(project.namespace, project, issue) - expect(page).to have_content issue.assignee.name + expect(page).to have_content issue.assignees.first.name end end end @@ -590,7 +598,7 @@ let(:user2) { create(:user) } before do - issue.assignee = user2 + issue.assignees << user2 issue.save end end @@ -687,7 +695,7 @@ describe 'due date' do context 'update due on issue#show', js: true do - let(:issue) { create(:issue, project: project, author: @user, assignee: @user) } + let(:issue) { create(:issue, project: project, author: @user, assignees: [@user]) } before do visit namespace_project_issue_path(project.namespace, project, issue) @@ -734,7 +742,7 @@ include WaitForVueResource it 'updates the title', js: true do - issue = create(:issue, author: @user, assignee: @user, project: project, title: 'new title') + issue = create(:issue, author: @user, assignees: [@user], project: project, title: 'new title') visit namespace_project_issue_path(project.namespace, project, issue) diff --git a/spec/features/merge_requests/assign_issues_spec.rb b/spec/features/merge_requests/assign_issues_spec.rb index 43cc6f2a2a7d7..ec49003772b82 100644 --- a/spec/features/merge_requests/assign_issues_spec.rb +++ b/spec/features/merge_requests/assign_issues_spec.rb @@ -33,7 +33,7 @@ def visit_merge_request(current_user = nil) end it "doesn't display if related issues are already assigned" do - [issue1, issue2].each { |issue| issue.update!(assignee: user) } + [issue1, issue2].each { |issue| issue.update!(assignees: [user]) } visit_merge_request diff --git a/spec/features/merge_requests/user_posts_notes_spec.rb b/spec/features/merge_requests/user_posts_notes_spec.rb index c7cc4d6bc724e..7fc0e2ce6eca4 100644 --- a/spec/features/merge_requests/user_posts_notes_spec.rb +++ b/spec/features/merge_requests/user_posts_notes_spec.rb @@ -98,6 +98,7 @@ find('.btn-save').click end + wait_for_ajax find('.note').hover find('.js-note-edit').click diff --git a/spec/features/merge_requests/user_uses_slash_commands_spec.rb b/spec/features/merge_requests/user_uses_slash_commands_spec.rb index 3986439061ec8..08cd39fb8589c 100644 --- a/spec/features/merge_requests/user_uses_slash_commands_spec.rb +++ b/spec/features/merge_requests/user_uses_slash_commands_spec.rb @@ -160,6 +160,7 @@ it 'changes target branch from a note' do write_note("message start \n/target_branch merge-test\n message end.") + wait_for_ajax expect(page).not_to have_content('/target_branch') expect(page).to have_content('message start') expect(page).to have_content('message end.') diff --git a/spec/features/milestones/show_spec.rb b/spec/features/milestones/show_spec.rb index 41f569678b6ed..1b24ec0dc4929 100644 --- a/spec/features/milestones/show_spec.rb +++ b/spec/features/milestones/show_spec.rb @@ -5,7 +5,7 @@ let(:project) { create(:empty_project) } let(:milestone) { create(:milestone, project: project, start_date: Date.today, due_date: 7.days.from_now) } let(:labels) { create_list(:label, 2, project: project) } - let(:issue_params) { { project: project, assignee: user, author: user, milestone: milestone, labels: labels } } + let(:issue_params) { { project: project, assignees: [user], author: user, milestone: milestone, labels: labels } } before do project.add_user(user, :developer) @@ -25,7 +25,7 @@ def visit_milestone end context 'burndown' do - let(:issue_params) { { project: project, assignee: user, author: user, milestone: milestone } } + let(:issue_params) { { project: project, assignees: [user], author: user, milestone: milestone } } context 'when any closed issues do not have closed_at value' do it 'shows warning' do diff --git a/spec/features/projects/artifacts/file_spec.rb b/spec/features/projects/artifacts/file_spec.rb new file mode 100644 index 0000000000000..74308a7e8dd39 --- /dev/null +++ b/spec/features/projects/artifacts/file_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +feature 'Artifact file', :js, feature: true do + let(:project) { create(:project, :public) } + let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.sha, ref: 'master') } + let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) } + + def visit_file(path) + visit file_namespace_project_build_artifacts_path(project.namespace, project, build, path) + end + + context 'Text file' do + before do + visit_file('other_artifacts_0.1.2/doc_sample.txt') + + wait_for_ajax + end + + it 'displays an error' do + aggregate_failures do + # shows an error message + expect(page).to have_content('The source could not be displayed because it is stored as a job artifact. You can download it instead.') + + # does not show a viewer switcher + expect(page).not_to have_selector('.js-blob-viewer-switcher') + + # does not show a copy button + expect(page).not_to have_selector('.js-copy-blob-source-btn') + + # shows a download button + expect(page).to have_link('Download') + end + end + end + + context 'JPG file' do + before do + visit_file('rails_sample.jpg') + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # shows rendered image + expect(page).to have_selector('.image_file img') + + # does not show a viewer switcher + expect(page).not_to have_selector('.js-blob-viewer-switcher') + + # does not show a copy button + expect(page).not_to have_selector('.js-copy-blob-source-btn') + + # shows a download button + expect(page).to have_link('Download') + end + end + end +end diff --git a/spec/features/projects/audit_events_spec.rb b/spec/features/projects/audit_events_spec.rb index d3c28177c8646..891a5c76ba2dd 100644 --- a/spec/features/projects/audit_events_spec.rb +++ b/spec/features/projects/audit_events_spec.rb @@ -26,7 +26,7 @@ visit namespace_project_deploy_keys_path(project.namespace, project) accept_confirm do - click_link 'Remove' + click_on 'Remove' end visit namespace_project_audit_events_path(project.namespace, project) diff --git a/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb b/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb new file mode 100644 index 0000000000000..cfc782c98ad42 --- /dev/null +++ b/spec/features/projects/branches/new_branch_ref_dropdown_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +describe 'New Branch Ref Dropdown', :js, :feature do + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + let(:toggle) { find('.create-from .dropdown-toggle') } + + before do + project.add_master(user) + + login_as(user) + visit new_namespace_project_branch_path(project.namespace, project) + end + + it 'filters a list of branches and tags' do + toggle.click + + filter_by('v1.0.0') + + expect(items_count).to be(1) + + filter_by('video') + + expect(items_count).to be(1) + + find('.create-from .dropdown-content li').click + + expect(toggle).to have_content 'video' + end + + it 'accepts a manually entered commit SHA' do + toggle.click + + filter_by('somecommitsha') + + find('.create-from input[type=search]').send_keys(:enter) + + expect(toggle).to have_content 'somecommitsha' + end + + def items_count + all('.create-from .dropdown-content li').length + end + + def filter_by(filter_text) + fill_in 'Filter by Git revision', with: filter_text + end +end diff --git a/spec/features/projects/deploy_keys_spec.rb b/spec/features/projects/deploy_keys_spec.rb index 0b997f130ea6a..06abfbbc86b78 100644 --- a/spec/features/projects/deploy_keys_spec.rb +++ b/spec/features/projects/deploy_keys_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'Project deploy keys', feature: true do +describe 'Project deploy keys', :js, :feature do let(:user) { create(:user) } let(:project) { create(:project_empty_repo) } @@ -17,9 +17,13 @@ it 'removes association between project and deploy key' do visit namespace_project_settings_repository_path(project.namespace, project) - page.within '.deploy-keys' do - expect { click_on 'Remove' } - .to change { project.deploy_keys.count }.by(-1) + page.within(find('.deploy-keys')) do + expect(page).to have_selector('.deploy-keys li', count: 1) + + click_on 'Remove' + + expect(page).not_to have_selector('.fa-spinner', count: 0) + expect(page).to have_selector('.deploy-keys li', count: 0) end end end diff --git a/spec/features/projects/issuable_templates_spec.rb b/spec/features/projects/issuable_templates_spec.rb index d28a853bbc2fc..fa5e30075e305 100644 --- a/spec/features/projects/issuable_templates_spec.rb +++ b/spec/features/projects/issuable_templates_spec.rb @@ -12,7 +12,7 @@ context 'user creates an issue using templates' do let(:template_content) { 'this is a test "bug" template' } let(:longtemplate_content) { %Q(this\n\n\n\n\nis\n\n\n\n\na\n\n\n\n\nbug\n\n\n\n\ntemplate) } - let(:issue) { create(:issue, author: user, assignee: user, project: project) } + let(:issue) { create(:issue, author: user, assignees: [user], project: project) } let(:description_addition) { ' appending to description' } background do @@ -72,7 +72,7 @@ context 'user creates an issue using templates, with a prior description' do let(:prior_description) { 'test issue description' } let(:template_content) { 'this is a test "bug" template' } - let(:issue) { create(:issue, author: user, assignee: user, project: project) } + let(:issue) { create(:issue, author: user, assignees: [user], project: project) } background do project.repository.create_file( diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb index 5a53e48f5f81d..cfac54ef259ad 100644 --- a/spec/features/projects/pipelines/pipeline_spec.rb +++ b/spec/features/projects/pipelines/pipeline_spec.rb @@ -254,4 +254,57 @@ it { expect(build_manual.reload).to be_pending } end end + + describe 'GET /:project/pipelines/:id/failures' do + let(:project) { create(:project) } + let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) } + let(:pipeline_failures_page) { failures_namespace_project_pipeline_path(project.namespace, project, pipeline) } + let!(:failed_build) { create(:ci_build, :failed, pipeline: pipeline) } + + context 'with failed build' do + before do + failed_build.trace.set('4 examples, 1 failure') + + visit pipeline_failures_page + end + + it 'shows jobs tab pane as active' do + expect(page).to have_content('Failed Jobs') + expect(page).to have_css('#js-tab-failures.active') + end + + it 'lists failed builds' do + expect(page).to have_content(failed_build.name) + expect(page).to have_content(failed_build.stage) + end + + it 'shows build failure logs' do + expect(page).to have_content('4 examples, 1 failure') + end + end + + context 'when missing build logs' do + before do + visit pipeline_failures_page + end + + it 'includes failed jobs' do + expect(page).to have_content('No job trace') + end + end + + context 'without failures' do + before do + failed_build.update!(status: :success) + + visit pipeline_failures_page + end + + it 'displays the pipeline graph' do + expect(current_path).to eq(pipeline_path(pipeline)) + expect(page).not_to have_content('Failed Jobs') + expect(page).to have_selector('.pipeline-visualization') + end + end + end end diff --git a/spec/features/raven_js_spec.rb b/spec/features/raven_js_spec.rb new file mode 100644 index 0000000000000..e8fa49c18cbd5 --- /dev/null +++ b/spec/features/raven_js_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +feature 'RavenJS', :feature, :js do + let(:raven_path) { '/raven.bundle.js' } + + it 'should not load raven if sentry is disabled' do + visit new_user_session_path + + expect(has_requested_raven).to eq(false) + end + + it 'should load raven if sentry is enabled' do + stub_application_setting(clientside_sentry_dsn: 'https://key@domain.com/id', clientside_sentry_enabled: true) + + visit new_user_session_path + + expect(has_requested_raven).to eq(true) + end + + def has_requested_raven + page.driver.network_traffic.one? {|request| request.url.end_with?(raven_path)} + end +end diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb index da6388dcdf20d..498a4a5cba04c 100644 --- a/spec/features/search_spec.rb +++ b/spec/features/search_spec.rb @@ -5,7 +5,7 @@ let(:user) { create(:user) } let(:project) { create(:empty_project, namespace: user.namespace) } - let!(:issue) { create(:issue, project: project, assignee: user) } + let!(:issue) { create(:issue, project: project, assignees: [user]) } let!(:issue2) { create(:issue, project: project, author: user) } before do diff --git a/spec/features/unsubscribe_links_spec.rb b/spec/features/unsubscribe_links_spec.rb index e2d9cfdd0b037..a23c4ca2b92f6 100644 --- a/spec/features/unsubscribe_links_spec.rb +++ b/spec/features/unsubscribe_links_spec.rb @@ -6,7 +6,7 @@ let(:recipient) { create(:user) } let(:author) { create(:user) } let(:project) { create(:empty_project, :public) } - let(:params) { { title: 'A bug!', description: 'Fix it!', assignee: recipient } } + let(:params) { { title: 'A bug!', description: 'Fix it!', assignees: [recipient] } } let(:issue) { Issues::CreateService.new(project, author, params).execute } let(:mail) { ActionMailer::Base.deliveries.last } diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index 2b251b76ac8b2..1a7aec1dc782c 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -7,12 +7,12 @@ set(:project2) { create(:empty_project) } set(:milestone) { create(:milestone, project: project1) } set(:label) { create(:label, project: project2) } - set(:issue1) { create(:issue, author: user, assignee: user, project: project1, milestone: milestone, title: 'gitlab') } - set(:issue2) { create(:issue, author: user, assignee: user, project: project2, description: 'gitlab') } - set(:issue3) { create(:issue, author: user2, assignee: user2, project: project2, title: 'tanuki', description: 'tanuki') } + set(:issue1) { create(:issue, author: user, assignees: [user], project: project1, milestone: milestone, title: 'gitlab') } + set(:issue2) { create(:issue, author: user, assignees: [user], project: project2, description: 'gitlab') } + set(:issue3) { create(:issue, author: user2, assignees: [user2], project: project2, title: 'tanuki', description: 'tanuki') } describe '#execute' do - set(:closed_issue) { create(:issue, author: user2, assignee: user2, project: project2, state: 'closed') } + set(:closed_issue) { create(:issue, author: user2, assignees: [user2], project: project2, state: 'closed') } set(:label_link) { create(:label_link, label: label, target: issue2) } let(:search_user) { user } let(:params) { {} } @@ -107,7 +107,7 @@ before do milestones.each do |milestone| - create(:issue, project: milestone.project, milestone: milestone, author: user, assignee: user) + create(:issue, project: milestone.project, milestone: milestone, author: user, assignees: [user]) end end @@ -142,7 +142,7 @@ before do milestones.each do |milestone| - create(:issue, project: milestone.project, milestone: milestone, author: user, assignee: user) + create(:issue, project: milestone.project, milestone: milestone, author: user, assignees: [user]) end end diff --git a/spec/fixtures/api/schemas/issue.json b/spec/fixtures/api/schemas/issue.json index e01af38a75e04..0020f267aef1e 100644 --- a/spec/fixtures/api/schemas/issue.json +++ b/spec/fixtures/api/schemas/issue.json @@ -40,21 +40,41 @@ "additionalProperties": false } }, - "assignee": { - "type": ["object", "null"], - "required": [ - "id", - "name", - "username", - "avatar_url" - ], - "properties": { - "id": { "type": "integer" }, - "name": { "type": "string" }, - "username": { "type": "string" }, - "avatar_url": { "type": "uri" } - }, - "additionalProperties": false + "assignees": { + "type": "array", + "items": { + "type": ["object", "null"], + "required": [ + "id", + "name", + "username", + "avatar_url" + ], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "username": { "type": "string" }, + "avatar_url": { "type": "uri" } + } + } + }, + "assignees": { + "type": "array", + "items": { + "type": ["object", "null"], + "required": [ + "id", + "name", + "username", + "avatar_url" + ], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "username": { "type": "string" }, + "avatar_url": { "type": "uri" } + } + } }, "subscribed": { "type": ["boolean", "null"] } }, diff --git a/spec/fixtures/api/schemas/public_api/v4/issues.json b/spec/fixtures/api/schemas/public_api/v4/issues.json index 0ca0a550a7c23..b2839c059d3e9 100644 --- a/spec/fixtures/api/schemas/public_api/v4/issues.json +++ b/spec/fixtures/api/schemas/public_api/v4/issues.json @@ -33,6 +33,21 @@ }, "additionalProperties": false }, + "assignees": { + "type": "array", + "items": { + "type": ["object", "null"], + "properties": { + "name": { "type": "string" }, + "username": { "type": "string" }, + "id": { "type": "integer" }, + "state": { "type": "string" }, + "avatar_url": { "type": "uri" }, + "web_url": { "type": "uri" } + }, + "additionalProperties": false + } + }, "assignee": { "type": ["object", "null"], "properties": { @@ -68,7 +83,7 @@ "required": [ "id", "iid", "project_id", "title", "description", "state", "created_at", "updated_at", "labels", - "milestone", "assignee", "author", "user_notes_count", + "milestone", "assignees", "author", "user_notes_count", "upvotes", "downvotes", "due_date", "confidential", "web_url", "weight" ], diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index 93bb711f29ab3..c1ecb46aece84 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -4,6 +4,23 @@ let(:label) { build_stubbed(:label) } let(:label2) { build_stubbed(:label) } + describe '#users_dropdown_label' do + let(:user) { build_stubbed(:user) } + let(:user2) { build_stubbed(:user) } + + it 'returns unassigned' do + expect(users_dropdown_label([])).to eq('Unassigned') + end + + it 'returns selected user\'s name' do + expect(users_dropdown_label([user])).to eq(user.name) + end + + it 'returns selected user\'s name and counter' do + expect(users_dropdown_label([user, user2])).to eq("#{user.name} + 1 more") + end + end + describe '#issuable_labels_tooltip' do it 'returns label text' do expect(issuable_labels_tooltip([label])).to eq(label.title) diff --git a/spec/javascripts/autosave_spec.js b/spec/javascripts/autosave_spec.js new file mode 100644 index 0000000000000..9f9acc392c286 --- /dev/null +++ b/spec/javascripts/autosave_spec.js @@ -0,0 +1,134 @@ +import Autosave from '~/autosave'; +import AccessorUtilities from '~/lib/utils/accessor'; + +describe('Autosave', () => { + let autosave; + + describe('class constructor', () => { + const key = 'key'; + const field = jasmine.createSpyObj('field', ['data', 'on']); + + beforeEach(() => { + spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.returnValue(true); + spyOn(Autosave.prototype, 'restore'); + + autosave = new Autosave(field, key); + }); + + it('should set .isLocalStorageAvailable', () => { + expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + expect(autosave.isLocalStorageAvailable).toBe(true); + }); + }); + + describe('restore', () => { + const key = 'key'; + const field = jasmine.createSpyObj('field', ['trigger']); + + beforeEach(() => { + autosave = { + field, + key, + }; + + spyOn(window.localStorage, 'getItem'); + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = false; + + Autosave.prototype.restore.call(autosave); + }); + + it('should not call .getItem', () => { + expect(window.localStorage.getItem).not.toHaveBeenCalled(); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = true; + + Autosave.prototype.restore.call(autosave); + }); + + it('should call .getItem', () => { + expect(window.localStorage.getItem).toHaveBeenCalledWith(key); + }); + }); + }); + + describe('save', () => { + const field = jasmine.createSpyObj('field', ['val']); + + beforeEach(() => { + autosave = jasmine.createSpyObj('autosave', ['reset']); + autosave.field = field; + + field.val.and.returnValue('value'); + + spyOn(window.localStorage, 'setItem'); + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = false; + + Autosave.prototype.save.call(autosave); + }); + + it('should not call .setItem', () => { + expect(window.localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = true; + + Autosave.prototype.save.call(autosave); + }); + + it('should call .setItem', () => { + expect(window.localStorage.setItem).toHaveBeenCalled(); + }); + }); + }); + + describe('reset', () => { + const key = 'key'; + + beforeEach(() => { + autosave = { + key, + }; + + spyOn(window.localStorage, 'removeItem'); + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = false; + + Autosave.prototype.reset.call(autosave); + }); + + it('should not call .removeItem', () => { + expect(window.localStorage.removeItem).not.toHaveBeenCalled(); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = true; + + Autosave.prototype.reset.call(autosave); + }); + + it('should call .removeItem', () => { + expect(window.localStorage.removeItem).toHaveBeenCalledWith(key); + }); + }); + }); +}); diff --git a/spec/javascripts/behaviors/gl_emoji/unicode_support_map_spec.js b/spec/javascripts/behaviors/gl_emoji/unicode_support_map_spec.js new file mode 100644 index 0000000000000..1ed96a6747813 --- /dev/null +++ b/spec/javascripts/behaviors/gl_emoji/unicode_support_map_spec.js @@ -0,0 +1,47 @@ +import { getUnicodeSupportMap } from '~/behaviors/gl_emoji/unicode_support_map'; +import AccessorUtilities from '~/lib/utils/accessor'; + +describe('Unicode Support Map', () => { + describe('getUnicodeSupportMap', () => { + const stringSupportMap = 'stringSupportMap'; + + beforeEach(() => { + spyOn(AccessorUtilities, 'isLocalStorageAccessSafe'); + spyOn(window.localStorage, 'getItem'); + spyOn(window.localStorage, 'setItem'); + spyOn(JSON, 'parse'); + spyOn(JSON, 'stringify').and.returnValue(stringSupportMap); + }); + + describe('if isLocalStorageAvailable is `true`', function () { + beforeEach(() => { + AccessorUtilities.isLocalStorageAccessSafe.and.returnValue(true); + + getUnicodeSupportMap(); + }); + + it('should call .getItem and .setItem', () => { + const allArgs = window.localStorage.setItem.calls.allArgs(); + + expect(window.localStorage.getItem).toHaveBeenCalledWith('gl-emoji-user-agent'); + expect(allArgs[0][0]).toBe('gl-emoji-user-agent'); + expect(allArgs[0][1]).toBe(navigator.userAgent); + expect(allArgs[1][0]).toBe('gl-emoji-unicode-support-map'); + expect(allArgs[1][1]).toBe(stringSupportMap); + }); + }); + + describe('if isLocalStorageAvailable is `false`', function () { + beforeEach(() => { + AccessorUtilities.isLocalStorageAccessSafe.and.returnValue(false); + + getUnicodeSupportMap(); + }); + + it('should not call .getItem or .setItem', () => { + expect(window.localStorage.getItem.calls.count()).toBe(1); + expect(window.localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js b/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js new file mode 100644 index 0000000000000..85816ee1f1161 --- /dev/null +++ b/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js @@ -0,0 +1,342 @@ +import sqljs from 'sql.js'; +import BalsamiqViewer from '~/blob/balsamiq/balsamiq_viewer'; +import ClassSpecHelper from '../../helpers/class_spec_helper'; + +describe('BalsamiqViewer', () => { + let balsamiqViewer; + let endpoint; + let viewer; + + describe('class constructor', () => { + beforeEach(() => { + endpoint = 'endpoint'; + viewer = { + dataset: { + endpoint, + }, + }; + + balsamiqViewer = new BalsamiqViewer(viewer); + }); + + it('should set .viewer', () => { + expect(balsamiqViewer.viewer).toBe(viewer); + }); + + it('should set .endpoint', () => { + expect(balsamiqViewer.endpoint).toBe(endpoint); + }); + }); + + describe('loadFile', () => { + let xhr; + + beforeEach(() => { + endpoint = 'endpoint'; + xhr = jasmine.createSpyObj('xhr', ['open', 'send']); + + balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['renderFile']); + balsamiqViewer.endpoint = endpoint; + + spyOn(window, 'XMLHttpRequest').and.returnValue(xhr); + + BalsamiqViewer.prototype.loadFile.call(balsamiqViewer); + }); + + it('should call .open', () => { + expect(xhr.open).toHaveBeenCalledWith('GET', endpoint, true); + }); + + it('should set .responseType', () => { + expect(xhr.responseType).toBe('arraybuffer'); + }); + + it('should call .send', () => { + expect(xhr.send).toHaveBeenCalled(); + }); + }); + + describe('renderFile', () => { + let container; + let loadEvent; + let previews; + + beforeEach(() => { + loadEvent = { target: { response: {} } }; + viewer = jasmine.createSpyObj('viewer', ['appendChild']); + previews = [document.createElement('ul'), document.createElement('ul')]; + + balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['initDatabase', 'getPreviews', 'renderPreview']); + balsamiqViewer.viewer = viewer; + + balsamiqViewer.getPreviews.and.returnValue(previews); + balsamiqViewer.renderPreview.and.callFake(preview => preview); + viewer.appendChild.and.callFake((containerElement) => { + container = containerElement; + }); + + BalsamiqViewer.prototype.renderFile.call(balsamiqViewer, loadEvent); + }); + + it('should call .initDatabase', () => { + expect(balsamiqViewer.initDatabase).toHaveBeenCalledWith(loadEvent.target.response); + }); + + it('should call .getPreviews', () => { + expect(balsamiqViewer.getPreviews).toHaveBeenCalled(); + }); + + it('should call .renderPreview for each preview', () => { + const allArgs = balsamiqViewer.renderPreview.calls.allArgs(); + + expect(allArgs.length).toBe(2); + + previews.forEach((preview, i) => { + expect(allArgs[i][0]).toBe(preview); + }); + }); + + it('should set the container HTML', () => { + expect(container.innerHTML).toBe('<ul></ul><ul></ul>'); + }); + + it('should add inline preview classes', () => { + expect(container.classList[0]).toBe('list-inline'); + expect(container.classList[1]).toBe('previews'); + }); + + it('should call viewer.appendChild', () => { + expect(viewer.appendChild).toHaveBeenCalledWith(container); + }); + }); + + describe('initDatabase', () => { + let database; + let uint8Array; + let data; + + beforeEach(() => { + uint8Array = {}; + database = {}; + data = 'data'; + + balsamiqViewer = {}; + + spyOn(window, 'Uint8Array').and.returnValue(uint8Array); + spyOn(sqljs, 'Database').and.returnValue(database); + + BalsamiqViewer.prototype.initDatabase.call(balsamiqViewer, data); + }); + + it('should instantiate Uint8Array', () => { + expect(window.Uint8Array).toHaveBeenCalledWith(data); + }); + + it('should call sqljs.Database', () => { + expect(sqljs.Database).toHaveBeenCalledWith(uint8Array); + }); + + it('should set .database', () => { + expect(balsamiqViewer.database).toBe(database); + }); + }); + + describe('getPreviews', () => { + let database; + let thumbnails; + let getPreviews; + + beforeEach(() => { + database = jasmine.createSpyObj('database', ['exec']); + thumbnails = [{ values: [0, 1, 2] }]; + + balsamiqViewer = { + database, + }; + + spyOn(BalsamiqViewer, 'parsePreview').and.callFake(preview => preview.toString()); + database.exec.and.returnValue(thumbnails); + + getPreviews = BalsamiqViewer.prototype.getPreviews.call(balsamiqViewer); + }); + + it('should call database.exec', () => { + expect(database.exec).toHaveBeenCalledWith('SELECT * FROM thumbnails'); + }); + + it('should call .parsePreview for each value', () => { + const allArgs = BalsamiqViewer.parsePreview.calls.allArgs(); + + expect(allArgs.length).toBe(3); + + thumbnails[0].values.forEach((value, i) => { + expect(allArgs[i][0]).toBe(value); + }); + }); + + it('should return an array of parsed values', () => { + expect(getPreviews).toEqual(['0', '1', '2']); + }); + }); + + describe('getResource', () => { + let database; + let resourceID; + let resource; + let getResource; + + beforeEach(() => { + database = jasmine.createSpyObj('database', ['exec']); + resourceID = 4; + resource = ['resource']; + + balsamiqViewer = { + database, + }; + + database.exec.and.returnValue(resource); + + getResource = BalsamiqViewer.prototype.getResource.call(balsamiqViewer, resourceID); + }); + + it('should call database.exec', () => { + expect(database.exec).toHaveBeenCalledWith(`SELECT * FROM resources WHERE id = '${resourceID}'`); + }); + + it('should return the selected resource', () => { + expect(getResource).toBe(resource[0]); + }); + }); + + describe('renderPreview', () => { + let previewElement; + let innerHTML; + let preview; + let renderPreview; + + beforeEach(() => { + innerHTML = '<a>innerHTML</a>'; + previewElement = { + outerHTML: '<p>outerHTML</p>', + classList: jasmine.createSpyObj('classList', ['add']), + }; + preview = {}; + + balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['renderTemplate']); + + spyOn(document, 'createElement').and.returnValue(previewElement); + balsamiqViewer.renderTemplate.and.returnValue(innerHTML); + + renderPreview = BalsamiqViewer.prototype.renderPreview.call(balsamiqViewer, preview); + }); + + it('should call classList.add', () => { + expect(previewElement.classList.add).toHaveBeenCalledWith('preview'); + }); + + it('should call .renderTemplate', () => { + expect(balsamiqViewer.renderTemplate).toHaveBeenCalledWith(preview); + }); + + it('should set .innerHTML', () => { + expect(previewElement.innerHTML).toBe(innerHTML); + }); + + it('should return element', () => { + expect(renderPreview).toBe(previewElement); + }); + }); + + describe('renderTemplate', () => { + let preview; + let name; + let resource; + let template; + let renderTemplate; + + beforeEach(() => { + preview = { resourceID: 1, image: 'image' }; + name = 'name'; + resource = 'resource'; + template = ` + <div class="panel panel-default"> + <div class="panel-heading">name</div> + <div class="panel-body"> + <img class="img-thumbnail" src="data:image/png;base64,image"/> + </div> + </div> + `; + + balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['getResource']); + + spyOn(BalsamiqViewer, 'parseTitle').and.returnValue(name); + balsamiqViewer.getResource.and.returnValue(resource); + + renderTemplate = BalsamiqViewer.prototype.renderTemplate.call(balsamiqViewer, preview); + }); + + it('should call .getResource', () => { + expect(balsamiqViewer.getResource).toHaveBeenCalledWith(preview.resourceID); + }); + + it('should call .parseTitle', () => { + expect(BalsamiqViewer.parseTitle).toHaveBeenCalledWith(resource); + }); + + it('should return the template string', function () { + expect(renderTemplate.replace(/\s/g, '')).toEqual(template.replace(/\s/g, '')); + }); + }); + + describe('parsePreview', () => { + let preview; + let parsePreview; + + beforeEach(() => { + preview = ['{}', '{ "id": 1 }']; + + spyOn(JSON, 'parse').and.callThrough(); + + parsePreview = BalsamiqViewer.parsePreview(preview); + }); + + ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview'); + + it('should return the parsed JSON', () => { + expect(parsePreview).toEqual(JSON.parse('{ "id": 1 }')); + }); + }); + + describe('parseTitle', () => { + let title; + let parseTitle; + + beforeEach(() => { + title = { values: [['{}', '{}', '{"name":"name"}']] }; + + spyOn(JSON, 'parse').and.callThrough(); + + parseTitle = BalsamiqViewer.parseTitle(title); + }); + + ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview'); + + it('should return the name value', () => { + expect(parseTitle).toBe('name'); + }); + }); + + describe('onError', () => { + beforeEach(() => { + spyOn(window, 'Flash'); + + BalsamiqViewer.onError(); + }); + + ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'onError'); + + it('should instantiate Flash', () => { + expect(window.Flash).toHaveBeenCalledWith('Balsamiq file could not be loaded.'); + }); + }); +}); diff --git a/spec/javascripts/boards/board_card_spec.js b/spec/javascripts/boards/board_card_spec.js index de072e7e470bf..376e706d1db82 100644 --- a/spec/javascripts/boards/board_card_spec.js +++ b/spec/javascripts/boards/board_card_spec.js @@ -1,12 +1,12 @@ /* global List */ -/* global ListUser */ +/* global ListAssignee */ /* global ListLabel */ /* global listObj */ /* global boardsMockInterceptor */ /* global BoardService */ import Vue from 'vue'; -import '~/boards/models/user'; +import '~/boards/models/assignee'; require('~/boards/models/list'); require('~/boards/models/label'); @@ -133,12 +133,12 @@ describe('Issue card', () => { }); it('does not set detail issue if img is clicked', (done) => { - vm.issue.assignee = new ListUser({ + vm.issue.assignees = [new ListAssignee({ id: 1, name: 'testing 123', username: 'test', avatar: 'test_image', - }); + })]; Vue.nextTick(() => { triggerEvent('mouseup', vm.$el.querySelector('img')); diff --git a/spec/javascripts/boards/board_list_spec.js b/spec/javascripts/boards/board_list_spec.js index 3f598887603af..a89be91166700 100644 --- a/spec/javascripts/boards/board_list_spec.js +++ b/spec/javascripts/boards/board_list_spec.js @@ -35,6 +35,7 @@ describe('Board list component', () => { iid: 1, confidential: false, labels: [], + assignees: [], }); list.issuesSize = 1; list.issues.push(issue); diff --git a/spec/javascripts/boards/boards_store_spec.js b/spec/javascripts/boards/boards_store_spec.js index b55ff2f473a23..5ea160b7790c4 100644 --- a/spec/javascripts/boards/boards_store_spec.js +++ b/spec/javascripts/boards/boards_store_spec.js @@ -8,14 +8,14 @@ import Vue from 'vue'; import Cookies from 'js-cookie'; -require('~/lib/utils/url_utility'); -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/services/board_service'); -require('~/boards/stores/boards_store'); -require('./mock_data'); +import '~/lib/utils/url_utility'; +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/services/board_service'; +import '~/boards/stores/boards_store'; +import './mock_data'; describe('Store', () => { beforeEach(() => { @@ -212,7 +212,8 @@ describe('Store', () => { title: 'Testing', iid: 2, confidential: false, - labels: [] + labels: [], + assignees: [], }); const list = gl.issueBoards.BoardsStore.addList(listObj); diff --git a/spec/javascripts/boards/issue_card_spec.js b/spec/javascripts/boards/issue_card_spec.js index 1a5e9e9fd0794..fddde799d01ac 100644 --- a/spec/javascripts/boards/issue_card_spec.js +++ b/spec/javascripts/boards/issue_card_spec.js @@ -1,20 +1,20 @@ -/* global ListUser */ +/* global ListAssignee */ /* global ListLabel */ /* global listObj */ /* global ListIssue */ import Vue from 'vue'; -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/stores/boards_store'); -require('~/boards/components/issue_card_inner'); -require('./mock_data'); +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/stores/boards_store'; +import '~/boards/components/issue_card_inner'; +import './mock_data'; describe('Issue card component', () => { - const user = new ListUser({ + const user = new ListAssignee({ id: 1, name: 'testing 123', username: 'test', @@ -40,6 +40,7 @@ describe('Issue card component', () => { iid: 1, confidential: false, labels: [list.label], + assignees: [], }); component = new Vue({ @@ -92,12 +93,12 @@ describe('Issue card component', () => { it('renders confidential icon', (done) => { component.issue.confidential = true; - setTimeout(() => { + Vue.nextTick(() => { expect( component.$el.querySelector('.confidential-icon'), ).not.toBeNull(); done(); - }, 0); + }); }); it('renders issue ID with #', () => { @@ -109,34 +110,32 @@ describe('Issue card component', () => { describe('assignee', () => { it('does not render assignee', () => { expect( - component.$el.querySelector('.card-assignee'), + component.$el.querySelector('.card-assignee .avatar'), ).toBeNull(); }); describe('exists', () => { beforeEach((done) => { - component.issue.assignee = user; + component.issue.assignees = [user]; - setTimeout(() => { - done(); - }, 0); + Vue.nextTick(() => done()); }); it('renders assignee', () => { expect( - component.$el.querySelector('.card-assignee'), + component.$el.querySelector('.card-assignee .avatar'), ).not.toBeNull(); }); it('sets title', () => { expect( - component.$el.querySelector('.card-assignee').getAttribute('title'), + component.$el.querySelector('.card-assignee a').getAttribute('title'), ).toContain(`Assigned to ${user.name}`); }); it('sets users path', () => { expect( - component.$el.querySelector('.card-assignee').getAttribute('href'), + component.$el.querySelector('.card-assignee a').getAttribute('href'), ).toBe('/test'); }); @@ -146,6 +145,96 @@ describe('Issue card component', () => { ).not.toBeNull(); }); }); + + describe('assignee default avatar', () => { + beforeEach((done) => { + component.issue.assignees = [new ListAssignee({ + id: 1, + name: 'testing 123', + username: 'test', + }, 'default_avatar')]; + + Vue.nextTick(done); + }); + + it('displays defaults avatar if users avatar is null', () => { + expect( + component.$el.querySelector('.card-assignee img'), + ).not.toBeNull(); + expect( + component.$el.querySelector('.card-assignee img').getAttribute('src'), + ).toBe('default_avatar'); + }); + }); + }); + + describe('multiple assignees', () => { + beforeEach((done) => { + component.issue.assignees = [ + user, + new ListAssignee({ + id: 2, + name: 'user2', + username: 'user2', + avatar: 'test_image', + }), + new ListAssignee({ + id: 3, + name: 'user3', + username: 'user3', + avatar: 'test_image', + }), + new ListAssignee({ + id: 4, + name: 'user4', + username: 'user4', + avatar: 'test_image', + })]; + + Vue.nextTick(() => done()); + }); + + it('renders all four assignees', () => { + expect(component.$el.querySelectorAll('.card-assignee .avatar').length).toEqual(4); + }); + + describe('more than four assignees', () => { + beforeEach((done) => { + component.issue.assignees.push(new ListAssignee({ + id: 5, + name: 'user5', + username: 'user5', + avatar: 'test_image', + })); + + Vue.nextTick(() => done()); + }); + + it('renders more avatar counter', () => { + expect(component.$el.querySelector('.card-assignee .avatar-counter').innerText).toEqual('+2'); + }); + + it('renders three assignees', () => { + expect(component.$el.querySelectorAll('.card-assignee .avatar').length).toEqual(3); + }); + + it('renders 99+ avatar counter', (done) => { + for (let i = 5; i < 104; i += 1) { + const u = new ListAssignee({ + id: i, + name: 'name', + username: 'username', + avatar: 'test_image', + }); + component.issue.assignees.push(u); + } + + Vue.nextTick(() => { + expect(component.$el.querySelector('.card-assignee .avatar-counter').innerText).toEqual('99+'); + done(); + }); + }); + }); }); describe('labels', () => { @@ -159,9 +248,7 @@ describe('Issue card component', () => { beforeEach((done) => { component.issue.addLabel(label1); - setTimeout(() => { - done(); - }, 0); + Vue.nextTick(() => done()); }); it('does not render list label', () => { diff --git a/spec/javascripts/boards/issue_spec.js b/spec/javascripts/boards/issue_spec.js index c96dfe94a4a5d..cd1497bc5e615 100644 --- a/spec/javascripts/boards/issue_spec.js +++ b/spec/javascripts/boards/issue_spec.js @@ -2,14 +2,15 @@ /* global BoardService */ /* global ListIssue */ -require('~/lib/utils/url_utility'); -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/services/board_service'); -require('~/boards/stores/boards_store'); -require('./mock_data'); +import Vue from 'vue'; +import '~/lib/utils/url_utility'; +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/services/board_service'; +import '~/boards/stores/boards_store'; +import './mock_data'; describe('Issue model', () => { let issue; @@ -27,7 +28,13 @@ describe('Issue model', () => { title: 'test', color: 'red', description: 'testing' - }] + }], + assignees: [{ + id: 1, + name: 'name', + username: 'username', + avatar_url: 'http://avatar_url', + }], }); }); @@ -80,6 +87,33 @@ describe('Issue model', () => { expect(issue.labels.length).toBe(0); }); + it('adds assignee', () => { + issue.addAssignee({ + id: 2, + name: 'Bruce Wayne', + username: 'batman', + avatar_url: 'http://batman', + }); + + expect(issue.assignees.length).toBe(2); + }); + + it('finds assignee', () => { + const assignee = issue.findAssignee(issue.assignees[0]); + expect(assignee).toBeDefined(); + }); + + it('removes assignee', () => { + const assignee = issue.findAssignee(issue.assignees[0]); + issue.removeAssignee(assignee); + expect(issue.assignees.length).toBe(0); + }); + + it('removes all assignees', () => { + issue.removeAllAssignees(); + expect(issue.assignees.length).toBe(0); + }); + it('sets position to infinity if no position is stored', () => { expect(issue.position).toBe(Infinity); }); @@ -90,9 +124,31 @@ describe('Issue model', () => { iid: 1, confidential: false, relative_position: 1, - labels: [] + labels: [], + assignees: [], }); expect(relativePositionIssue.position).toBe(1); }); + + describe('update', () => { + it('passes assignee ids when there are assignees', (done) => { + spyOn(Vue.http, 'patch').and.callFake((url, data) => { + expect(data.issue.assignee_ids).toEqual([1]); + done(); + }); + + issue.update('url'); + }); + + it('passes assignee ids of [0] when there are no assignees', (done) => { + spyOn(Vue.http, 'patch').and.callFake((url, data) => { + expect(data.issue.assignee_ids).toEqual([0]); + done(); + }); + + issue.removeAllAssignees(); + issue.update('url'); + }); + }); }); diff --git a/spec/javascripts/boards/list_spec.js b/spec/javascripts/boards/list_spec.js index 24a2da9f6b629..8e3d9fd77a073 100644 --- a/spec/javascripts/boards/list_spec.js +++ b/spec/javascripts/boards/list_spec.js @@ -8,14 +8,14 @@ import Vue from 'vue'; -require('~/lib/utils/url_utility'); -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/services/board_service'); -require('~/boards/stores/boards_store'); -require('./mock_data'); +import '~/lib/utils/url_utility'; +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/services/board_service'; +import '~/boards/stores/boards_store'; +import './mock_data'; describe('List model', () => { let list; @@ -94,7 +94,8 @@ describe('List model', () => { title: 'Testing', iid: _.random(10000), confidential: false, - labels: [list.label, listDup.label] + labels: [list.label, listDup.label], + assignees: [], }); list.issues.push(issue); @@ -119,7 +120,8 @@ describe('List model', () => { title: 'Testing', iid: _.random(10000) + i, confidential: false, - labels: [list.label] + labels: [list.label], + assignees: [], })); } list.issuesSize = 50; @@ -137,7 +139,8 @@ describe('List model', () => { title: 'Testing', iid: _.random(10000), confidential: false, - labels: [list.label] + labels: [list.label], + assignees: [], })); list.issuesSize = 2; diff --git a/spec/javascripts/boards/mock_data.js b/spec/javascripts/boards/mock_data.js index 00f9a76ccc182..24ace51f12852 100644 --- a/spec/javascripts/boards/mock_data.js +++ b/spec/javascripts/boards/mock_data.js @@ -38,7 +38,8 @@ const BoardsMockData = { title: 'Testing', iid: 1, confidential: false, - labels: [] + labels: [], + assignees: [], }], size: 1 }, diff --git a/spec/javascripts/boards/modal_store_spec.js b/spec/javascripts/boards/modal_store_spec.js index 80db816aff852..32e6d04df9fed 100644 --- a/spec/javascripts/boards/modal_store_spec.js +++ b/spec/javascripts/boards/modal_store_spec.js @@ -1,10 +1,10 @@ /* global ListIssue */ -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/stores/modal_store'); +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/stores/modal_store'; describe('Modal store', () => { let issue; @@ -21,12 +21,14 @@ describe('Modal store', () => { iid: 1, confidential: false, labels: [], + assignees: [], }); issue2 = new ListIssue({ title: 'Testing', iid: 2, confidential: false, labels: [], + assignees: [], }); Store.store.issues.push(issue); Store.store.issues.push(issue2); diff --git a/spec/javascripts/cycle_analytics/limit_warning_component_spec.js b/spec/javascripts/cycle_analytics/limit_warning_component_spec.js index 50000c5a5f560..2fb9eb0ca8561 100644 --- a/spec/javascripts/cycle_analytics/limit_warning_component_spec.js +++ b/spec/javascripts/cycle_analytics/limit_warning_component_spec.js @@ -1,6 +1,9 @@ import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; import limitWarningComp from '~/cycle_analytics/components/limit_warning_component'; +Vue.use(Translate); + describe('Limit warning component', () => { let component; let LimitWarningComponent; diff --git a/spec/javascripts/deploy_keys/components/action_btn_spec.js b/spec/javascripts/deploy_keys/components/action_btn_spec.js new file mode 100644 index 0000000000000..5b93fbc5575d7 --- /dev/null +++ b/spec/javascripts/deploy_keys/components/action_btn_spec.js @@ -0,0 +1,70 @@ +import Vue from 'vue'; +import eventHub from '~/deploy_keys/eventhub'; +import actionBtn from '~/deploy_keys/components/action_btn.vue'; + +describe('Deploy keys action btn', () => { + const data = getJSONFixture('deploy_keys/keys.json'); + const deployKey = data.enabled_keys[0]; + let vm; + + beforeEach((done) => { + const ActionBtnComponent = Vue.extend(actionBtn); + + vm = new ActionBtnComponent({ + propsData: { + deployKey, + type: 'enable', + }, + }).$mount(); + + setTimeout(done); + }); + + it('renders the type as uppercase', () => { + expect( + vm.$el.textContent.trim(), + ).toBe('Enable'); + }); + + it('sends eventHub event with btn type', (done) => { + spyOn(eventHub, '$emit'); + + vm.$el.click(); + + setTimeout(() => { + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('enable.key', deployKey); + + done(); + }); + }); + + it('shows loading spinner after click', (done) => { + vm.$el.click(); + + setTimeout(() => { + expect( + vm.$el.querySelector('.fa'), + ).toBeDefined(); + + done(); + }); + }); + + it('disables button after click', (done) => { + vm.$el.click(); + + setTimeout(() => { + expect( + vm.$el.classList.contains('disabled'), + ).toBeTruthy(); + + expect( + vm.$el.getAttribute('disabled'), + ).toBe('disabled'); + + done(); + }); + }); +}); diff --git a/spec/javascripts/deploy_keys/components/app_spec.js b/spec/javascripts/deploy_keys/components/app_spec.js new file mode 100644 index 0000000000000..700897f50b0ee --- /dev/null +++ b/spec/javascripts/deploy_keys/components/app_spec.js @@ -0,0 +1,142 @@ +import Vue from 'vue'; +import eventHub from '~/deploy_keys/eventhub'; +import deployKeysApp from '~/deploy_keys/components/app.vue'; + +describe('Deploy keys app component', () => { + const data = getJSONFixture('deploy_keys/keys.json'); + let vm; + + const deployKeysResponse = (request, next) => { + next(request.respondWith(JSON.stringify(data), { + status: 200, + })); + }; + + beforeEach((done) => { + const Component = Vue.extend(deployKeysApp); + + Vue.http.interceptors.push(deployKeysResponse); + + vm = new Component({ + propsData: { + endpoint: '/test', + }, + }).$mount(); + + setTimeout(done); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, deployKeysResponse); + }); + + it('renders loading icon', (done) => { + vm.store.keys = {}; + vm.isLoading = false; + + Vue.nextTick(() => { + expect( + vm.$el.querySelectorAll('.deploy-keys-panel').length, + ).toBe(0); + + expect( + vm.$el.querySelector('.fa-spinner'), + ).toBeDefined(); + + done(); + }); + }); + + it('renders keys panels', () => { + expect( + vm.$el.querySelectorAll('.deploy-keys-panel').length, + ).toBe(3); + }); + + it('does not render key panels when keys object is empty', (done) => { + vm.store.keys = {}; + + Vue.nextTick(() => { + expect( + vm.$el.querySelectorAll('.deploy-keys-panel').length, + ).toBe(0); + + done(); + }); + }); + + it('does not render public panel when empty', (done) => { + vm.store.keys.public_keys = []; + + Vue.nextTick(() => { + expect( + vm.$el.querySelectorAll('.deploy-keys-panel').length, + ).toBe(2); + + done(); + }); + }); + + it('re-fetches deploy keys when enabling a key', (done) => { + const key = data.public_keys[0]; + + spyOn(vm.service, 'getKeys'); + spyOn(vm.service, 'enableKey').and.callFake(() => new Promise((resolve) => { + resolve(); + + setTimeout(() => { + expect(vm.service.getKeys).toHaveBeenCalled(); + + done(); + }); + })); + + eventHub.$emit('enable.key', key); + + expect(vm.service.enableKey).toHaveBeenCalledWith(key.id); + }); + + it('re-fetches deploy keys when disabling a key', (done) => { + const key = data.public_keys[0]; + + spyOn(window, 'confirm').and.returnValue(true); + spyOn(vm.service, 'getKeys'); + spyOn(vm.service, 'disableKey').and.callFake(() => new Promise((resolve) => { + resolve(); + + setTimeout(() => { + expect(vm.service.getKeys).toHaveBeenCalled(); + + done(); + }); + })); + + eventHub.$emit('disable.key', key); + + expect(vm.service.disableKey).toHaveBeenCalledWith(key.id); + }); + + it('calls disableKey when removing a key', (done) => { + const key = data.public_keys[0]; + + spyOn(window, 'confirm').and.returnValue(true); + spyOn(vm.service, 'getKeys'); + spyOn(vm.service, 'disableKey').and.callFake(() => new Promise((resolve) => { + resolve(); + + setTimeout(() => { + expect(vm.service.getKeys).toHaveBeenCalled(); + + done(); + }); + })); + + eventHub.$emit('remove.key', key); + + expect(vm.service.disableKey).toHaveBeenCalledWith(key.id); + }); + + it('hasKeys returns true when there are keys', () => { + expect(vm.hasKeys).toEqual(3); + }); +}); diff --git a/spec/javascripts/deploy_keys/components/key_spec.js b/spec/javascripts/deploy_keys/components/key_spec.js new file mode 100644 index 0000000000000..793ab8c451d85 --- /dev/null +++ b/spec/javascripts/deploy_keys/components/key_spec.js @@ -0,0 +1,92 @@ +import Vue from 'vue'; +import DeployKeysStore from '~/deploy_keys/store'; +import key from '~/deploy_keys/components/key.vue'; + +describe('Deploy keys key', () => { + let vm; + const KeyComponent = Vue.extend(key); + const data = getJSONFixture('deploy_keys/keys.json'); + const createComponent = (deployKey) => { + const store = new DeployKeysStore(); + store.keys = data; + + vm = new KeyComponent({ + propsData: { + deployKey, + store, + }, + }).$mount(); + }; + + describe('enabled key', () => { + const deployKey = data.enabled_keys[0]; + + beforeEach((done) => { + createComponent(deployKey); + + setTimeout(done); + }); + + it('renders the keys title', () => { + expect( + vm.$el.querySelector('.title').textContent.trim(), + ).toContain('My title'); + }); + + it('renders human friendly formatted created date', () => { + expect( + vm.$el.querySelector('.key-created-at').textContent.trim(), + ).toBe(`created ${gl.utils.getTimeago().format(deployKey.created_at)}`); + }); + + it('shows remove button', () => { + expect( + vm.$el.querySelector('.btn').textContent.trim(), + ).toBe('Remove'); + }); + + it('shows write access text when key has write access', (done) => { + vm.deployKey.can_push = true; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.write-access-allowed'), + ).not.toBeNull(); + + expect( + vm.$el.querySelector('.write-access-allowed').textContent.trim(), + ).toBe('Write access allowed'); + + done(); + }); + }); + }); + + describe('public keys', () => { + const deployKey = data.public_keys[0]; + + beforeEach((done) => { + createComponent(deployKey); + + setTimeout(done); + }); + + it('shows enable button', () => { + expect( + vm.$el.querySelector('.btn').textContent.trim(), + ).toBe('Enable'); + }); + + it('shows disable button when key is enabled', (done) => { + vm.store.keys.enabled_keys.push(deployKey); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn').textContent.trim(), + ).toBe('Disable'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/deploy_keys/components/keys_panel_spec.js b/spec/javascripts/deploy_keys/components/keys_panel_spec.js new file mode 100644 index 0000000000000..a69b39c35c4f1 --- /dev/null +++ b/spec/javascripts/deploy_keys/components/keys_panel_spec.js @@ -0,0 +1,70 @@ +import Vue from 'vue'; +import DeployKeysStore from '~/deploy_keys/store'; +import deployKeysPanel from '~/deploy_keys/components/keys_panel.vue'; + +describe('Deploy keys panel', () => { + const data = getJSONFixture('deploy_keys/keys.json'); + let vm; + + beforeEach((done) => { + const DeployKeysPanelComponent = Vue.extend(deployKeysPanel); + const store = new DeployKeysStore(); + store.keys = data; + + vm = new DeployKeysPanelComponent({ + propsData: { + title: 'test', + keys: data.enabled_keys, + showHelpBox: true, + store, + }, + }).$mount(); + + setTimeout(done); + }); + + it('renders the title with keys count', () => { + expect( + vm.$el.querySelector('h5').textContent.trim(), + ).toContain('test'); + + expect( + vm.$el.querySelector('h5').textContent.trim(), + ).toContain(`(${vm.keys.length})`); + }); + + it('renders list of keys', () => { + expect( + vm.$el.querySelectorAll('li').length, + ).toBe(vm.keys.length); + }); + + it('renders help box if keys are empty', (done) => { + vm.keys = []; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.settings-message'), + ).toBeDefined(); + + expect( + vm.$el.querySelector('.settings-message').textContent.trim(), + ).toBe('No deploy keys found. Create one with the form above.'); + + done(); + }); + }); + + it('does not render help box if keys are empty & showHelpBox is false', (done) => { + vm.keys = []; + vm.showHelpBox = false; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.settings-message'), + ).toBeNull(); + + done(); + }); + }); +}); diff --git a/spec/javascripts/droplab/constants_spec.js b/spec/javascripts/droplab/constants_spec.js index fd153a49fcdbb..b9d28db74cc5b 100644 --- a/spec/javascripts/droplab/constants_spec.js +++ b/spec/javascripts/droplab/constants_spec.js @@ -27,6 +27,12 @@ describe('constants', function () { }); }); + describe('TEMPLATE_REGEX', function () { + it('should be a handlebars templating syntax regex', function() { + expect(constants.TEMPLATE_REGEX).toEqual(/\{\{(.+?)\}\}/g); + }); + }); + describe('IGNORE_CLASS', function () { it('should be `droplab-item-ignore`', function() { expect(constants.IGNORE_CLASS).toBe('droplab-item-ignore'); diff --git a/spec/javascripts/droplab/drop_down_spec.js b/spec/javascripts/droplab/drop_down_spec.js index 7516b30191782..e7786e8cc2ce1 100644 --- a/spec/javascripts/droplab/drop_down_spec.js +++ b/spec/javascripts/droplab/drop_down_spec.js @@ -451,7 +451,7 @@ describe('DropDown', function () { this.html = 'html'; this.template = { firstChild: { outerHTML: 'outerHTML', style: {} } }; - spyOn(utils, 't').and.returnValue(this.html); + spyOn(utils, 'template').and.returnValue(this.html); spyOn(document, 'createElement').and.returnValue(this.template); spyOn(this.dropdown, 'setImagesSrc'); @@ -459,7 +459,7 @@ describe('DropDown', function () { }); it('should call utils.t with .templateString and data', function () { - expect(utils.t).toHaveBeenCalledWith(this.templateString, this.data); + expect(utils.template).toHaveBeenCalledWith(this.templateString, this.data); }); it('should call document.createElement', function () { diff --git a/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js b/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js index 2722882375f44..d0f09a561d5ab 100644 --- a/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js +++ b/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js @@ -76,6 +76,26 @@ describe('RecentSearchesDropdownContent', () => { }); }); + describe('if isLocalStorageAvailable is `false`', () => { + let el; + + beforeEach(() => { + const props = Object.assign({ isLocalStorageAvailable: false }, propsDataWithItems); + + vm = createComponent(props); + el = vm.$el; + }); + + it('should render an info note', () => { + const note = el.querySelector('.dropdown-info-note'); + const items = el.querySelectorAll('.filtered-search-history-dropdown-item'); + + expect(note).toBeDefined(); + expect(note.innerText.trim()).toBe('This feature requires local storage to be enabled'); + expect(items.length).toEqual(propsDataWithoutItems.items.length); + }); + }); + describe('computed', () => { describe('processedItems', () => { it('with items', () => { diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_manager_spec.js index e747aa497c20b..063d547d00c93 100644 --- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_manager_spec.js @@ -1,3 +1,7 @@ +import * as recentSearchesStoreSrc from '~/filtered_search/stores/recent_searches_store'; +import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; +import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error'; + require('~/lib/utils/url_utility'); require('~/lib/utils/common_utils'); require('~/filtered_search/filtered_search_token_keys'); @@ -60,6 +64,36 @@ describe('Filtered Search Manager', () => { manager.cleanup(); }); + describe('class constructor', () => { + const isLocalStorageAvailable = 'isLocalStorageAvailable'; + let filteredSearchManager; + + beforeEach(() => { + spyOn(RecentSearchesService, 'isAvailable').and.returnValue(isLocalStorageAvailable); + spyOn(recentSearchesStoreSrc, 'default'); + + filteredSearchManager = new gl.FilteredSearchManager(); + + return filteredSearchManager; + }); + + it('should instantiate RecentSearchesStore with isLocalStorageAvailable', () => { + expect(RecentSearchesService.isAvailable).toHaveBeenCalled(); + expect(recentSearchesStoreSrc.default).toHaveBeenCalledWith({ + isLocalStorageAvailable, + }); + }); + + it('should not instantiate Flash if an RecentSearchesServiceError is caught', () => { + spyOn(RecentSearchesService.prototype, 'fetch').and.callFake(() => Promise.reject(new RecentSearchesServiceError())); + spyOn(window, 'Flash'); + + filteredSearchManager = new gl.FilteredSearchManager(); + + expect(window.Flash).not.toHaveBeenCalled(); + }); + }); + describe('search', () => { const defaultParams = '?scope=all&utf8=%E2%9C%93&state=opened'; diff --git a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js index d75b90612815e..8b750561eb7b5 100644 --- a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js @@ -1,3 +1,5 @@ +import AjaxCache from '~/lib/utils/ajax_cache'; + require('~/filtered_search/filtered_search_visual_tokens'); const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper'); @@ -611,4 +613,103 @@ describe('Filtered Search Visual Tokens', () => { expect(token.querySelector('.value').innerText).toEqual('~bug'); }); }); + + describe('renderVisualTokenValue', () => { + let searchTokens; + + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} + ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'upcoming')} + `); + + searchTokens = document.querySelectorAll('.filtered-search-token'); + }); + + it('renders a token value element', () => { + spyOn(gl.FilteredSearchVisualTokens, 'updateLabelTokenColor'); + const updateLabelTokenColorSpy = gl.FilteredSearchVisualTokens.updateLabelTokenColor; + + expect(searchTokens.length).toBe(2); + Array.prototype.forEach.call(searchTokens, (token) => { + updateLabelTokenColorSpy.calls.reset(); + + const tokenName = token.querySelector('.name').innerText; + const tokenValue = 'new value'; + gl.FilteredSearchVisualTokens.renderVisualTokenValue(token, tokenName, tokenValue); + + const tokenValueElement = token.querySelector('.value'); + expect(tokenValueElement.innerText).toBe(tokenValue); + + if (tokenName.toLowerCase() === 'label') { + const tokenValueContainer = token.querySelector('.value-container'); + expect(updateLabelTokenColorSpy.calls.count()).toBe(1); + const expectedArgs = [tokenValueContainer, tokenValue]; + expect(updateLabelTokenColorSpy.calls.argsFor(0)).toEqual(expectedArgs); + } else { + expect(updateLabelTokenColorSpy.calls.count()).toBe(0); + } + }); + }); + }); + + describe('updateLabelTokenColor', () => { + const jsonFixtureName = 'labels/project_labels.json'; + const dummyEndpoint = '/dummy/endpoint'; + + preloadFixtures(jsonFixtureName); + const labelData = getJSONFixture(jsonFixtureName); + const findLabel = tokenValue => labelData.find( + label => tokenValue === `~${gl.DropdownUtils.getEscapedText(label.title)}`, + ); + + const bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~bug'); + const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~doesnotexist'); + const spaceLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~"some space"'); + + const parseColor = (color) => { + const dummyElement = document.createElement('div'); + dummyElement.style.color = color; + return dummyElement.style.color; + }; + + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${bugLabelToken.outerHTML} + ${missingLabelToken.outerHTML} + ${spaceLabelToken.outerHTML} + `); + + const filteredSearchInput = document.querySelector('.filtered-search'); + filteredSearchInput.dataset.baseEndpoint = dummyEndpoint; + + AjaxCache.internalStorage = { }; + AjaxCache.internalStorage[`${dummyEndpoint}/labels.json`] = labelData; + }); + + const testCase = (token, done) => { + const tokenValueContainer = token.querySelector('.value-container'); + const tokenValue = token.querySelector('.value').innerText; + const label = findLabel(tokenValue); + + gl.FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue) + .then(() => { + if (label) { + expect(tokenValueContainer.getAttribute('style')).not.toBe(null); + expect(tokenValueContainer.style.backgroundColor).toBe(parseColor(label.color)); + expect(tokenValueContainer.style.color).toBe(parseColor(label.text_color)); + } else { + expect(token).toBe(missingLabelToken); + expect(tokenValueContainer.getAttribute('style')).toBe(null); + } + }) + .then(done) + .catch(fail); + }; + + it('updates the color of a label token', done => testCase(bugLabelToken, done)); + it('updates the color of a label token with spaces', done => testCase(spaceLabelToken, done)); + it('does not change color of a missing label', done => testCase(missingLabelToken, done)); + }); }); diff --git a/spec/javascripts/filtered_search/recent_searches_root_spec.js b/spec/javascripts/filtered_search/recent_searches_root_spec.js new file mode 100644 index 0000000000000..d8ba6de5f45d9 --- /dev/null +++ b/spec/javascripts/filtered_search/recent_searches_root_spec.js @@ -0,0 +1,31 @@ +import RecentSearchesRoot from '~/filtered_search/recent_searches_root'; +import * as vueSrc from 'vue'; + +describe('RecentSearchesRoot', () => { + describe('render', () => { + let recentSearchesRoot; + let data; + let template; + + beforeEach(() => { + recentSearchesRoot = { + store: { + state: 'state', + }, + }; + + spyOn(vueSrc, 'default').and.callFake((options) => { + data = options.data; + template = options.template; + }); + + RecentSearchesRoot.prototype.render.call(recentSearchesRoot); + }); + + it('should instantiate Vue', () => { + expect(vueSrc.default).toHaveBeenCalled(); + expect(data()).toBe(recentSearchesRoot.store.state); + expect(template).toContain(':is-local-storage-available="isLocalStorageAvailable"'); + }); + }); +}); diff --git a/spec/javascripts/filtered_search/services/recent_searches_service_error_spec.js b/spec/javascripts/filtered_search/services/recent_searches_service_error_spec.js new file mode 100644 index 0000000000000..ea7c146fa4f01 --- /dev/null +++ b/spec/javascripts/filtered_search/services/recent_searches_service_error_spec.js @@ -0,0 +1,18 @@ +import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error'; + +describe('RecentSearchesServiceError', () => { + let recentSearchesServiceError; + + beforeEach(() => { + recentSearchesServiceError = new RecentSearchesServiceError(); + }); + + it('instantiates an instance of RecentSearchesServiceError and not an Error', () => { + expect(recentSearchesServiceError).toEqual(jasmine.any(RecentSearchesServiceError)); + expect(recentSearchesServiceError.name).toBe('RecentSearchesServiceError'); + }); + + it('should set a default message', () => { + expect(recentSearchesServiceError.message).toBe('Recent Searches Service is unavailable'); + }); +}); diff --git a/spec/javascripts/filtered_search/services/recent_searches_service_spec.js b/spec/javascripts/filtered_search/services/recent_searches_service_spec.js index c255bf7c93996..31fa478804af9 100644 --- a/spec/javascripts/filtered_search/services/recent_searches_service_spec.js +++ b/spec/javascripts/filtered_search/services/recent_searches_service_spec.js @@ -1,6 +1,7 @@ /* eslint-disable promise/catch-or-return */ import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; +import AccessorUtilities from '~/lib/utils/accessor'; describe('RecentSearchesService', () => { let service; @@ -11,6 +12,10 @@ describe('RecentSearchesService', () => { }); describe('fetch', () => { + beforeEach(() => { + spyOn(RecentSearchesService, 'isAvailable').and.returnValue(true); + }); + it('should default to empty array', (done) => { const fetchItemsPromise = service.fetch(); @@ -29,11 +34,21 @@ describe('RecentSearchesService', () => { const fetchItemsPromise = service.fetch(); fetchItemsPromise - .catch(() => { + .catch((error) => { + expect(error).toEqual(jasmine.any(SyntaxError)); done(); }); }); + it('should reject when service is unavailable', (done) => { + RecentSearchesService.isAvailable.and.returnValue(false); + + service.fetch().catch((error) => { + expect(error).toEqual(jasmine.any(Error)); + done(); + }); + }); + it('should return items from localStorage', (done) => { window.localStorage.setItem(service.localStorageKey, '["foo", "bar"]'); const fetchItemsPromise = service.fetch(); @@ -44,15 +59,89 @@ describe('RecentSearchesService', () => { done(); }); }); + + describe('if .isAvailable returns `false`', () => { + beforeEach(() => { + RecentSearchesService.isAvailable.and.returnValue(false); + + spyOn(window.localStorage, 'getItem'); + + RecentSearchesService.prototype.fetch(); + }); + + it('should not call .getItem', () => { + expect(window.localStorage.getItem).not.toHaveBeenCalled(); + }); + }); }); describe('setRecentSearches', () => { + beforeEach(() => { + spyOn(RecentSearchesService, 'isAvailable').and.returnValue(true); + }); + it('should save things in localStorage', () => { const items = ['foo', 'bar']; service.save(items); - const newLocalStorageValue = - window.localStorage.getItem(service.localStorageKey); + const newLocalStorageValue = window.localStorage.getItem(service.localStorageKey); expect(JSON.parse(newLocalStorageValue)).toEqual(items); }); }); + + describe('save', () => { + beforeEach(() => { + spyOn(window.localStorage, 'setItem'); + spyOn(RecentSearchesService, 'isAvailable'); + }); + + describe('if .isAvailable returns `true`', () => { + const searchesString = 'searchesString'; + const localStorageKey = 'localStorageKey'; + const recentSearchesService = { + localStorageKey, + }; + + beforeEach(() => { + RecentSearchesService.isAvailable.and.returnValue(true); + + spyOn(JSON, 'stringify').and.returnValue(searchesString); + + RecentSearchesService.prototype.save.call(recentSearchesService); + }); + + it('should call .setItem', () => { + expect(window.localStorage.setItem).toHaveBeenCalledWith(localStorageKey, searchesString); + }); + }); + + describe('if .isAvailable returns `false`', () => { + beforeEach(() => { + RecentSearchesService.isAvailable.and.returnValue(false); + + RecentSearchesService.prototype.save(); + }); + + it('should not call .setItem', () => { + expect(window.localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + }); + + describe('isAvailable', () => { + let isAvailable; + + beforeEach(() => { + spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.callThrough(); + + isAvailable = RecentSearchesService.isAvailable(); + }); + + it('should call .isLocalStorageAccessSafe', () => { + expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + }); + + it('should return a boolean', () => { + expect(typeof isAvailable).toBe('boolean'); + }); + }); }); diff --git a/spec/javascripts/fixtures/deploy_keys.rb b/spec/javascripts/fixtures/deploy_keys.rb new file mode 100644 index 0000000000000..16e598a4b290c --- /dev/null +++ b/spec/javascripts/fixtures/deploy_keys.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :controller do + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin) } + let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} + let(:project) { create(:project_empty_repo, namespace: namespace, path: 'todos-project') } + let(:project2) { create(:empty_project, :internal)} + + before(:all) do + clean_frontend_fixtures('deploy_keys/') + end + + before(:each) do + sign_in(admin) + end + + render_views + + it 'deploy_keys/keys.json' do |example| + create(:deploy_key, public: true) + project_key = create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCdMHEHyhRjbhEZVddFn6lTWdgEy5Q6Bz4nwGB76xWZI5YT/1WJOMEW+sL5zYd31kk7sd3FJ5L9ft8zWMWrr/iWXQikC2cqZK24H1xy+ZUmrRuJD4qGAaIVoyyzBL+avL+lF8J5lg6YSw8gwJY/lX64/vnJHUlWw2n5BF8IFOWhiw== dummy@gitlab.com') + internal_key = create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDNd/UJWhPrpb+b/G5oL109y57yKuCxE+WUGJGYaj7WQKsYRJmLYh1mgjrl+KVyfsWpq4ylOxIfFSnN9xBBFN8mlb0Fma5DC7YsSsibJr3MZ19ZNBprwNcdogET7aW9I0In7Wu5f2KqI6e5W/spJHCy4JVxzVMUvk6Myab0LnJ2iQ== dummy@gitlab.com') + create(:deploy_keys_project, project: project, deploy_key: project_key) + create(:deploy_keys_project, project: project2, deploy_key: internal_key) + + get :index, + namespace_id: project.namespace.to_param, + project_id: project, + format: :json + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end +end diff --git a/spec/javascripts/fixtures/labels.rb b/spec/javascripts/fixtures/labels.rb new file mode 100644 index 0000000000000..2e4811b64a4b4 --- /dev/null +++ b/spec/javascripts/fixtures/labels.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +describe 'Labels (JavaScript fixtures)' do + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin) } + let(:group) { create(:group, name: 'frontend-fixtures-group' )} + let(:project) { create(:project_empty_repo, namespace: group, path: 'labels-project') } + + let!(:project_label_bug) { create(:label, project: project, title: 'bug', color: '#FF0000') } + let!(:project_label_enhancement) { create(:label, project: project, title: 'enhancement', color: '#00FF00') } + let!(:project_label_feature) { create(:label, project: project, title: 'feature', color: '#0000FF') } + + let!(:group_label_roses) { create(:group_label, group: group, title: 'roses', color: '#FF0000') } + let!(:groub_label_space) { create(:group_label, group: group, title: 'some space', color: '#FFFFFF') } + let!(:groub_label_violets) { create(:group_label, group: group, title: 'violets', color: '#0000FF') } + + before(:all) do + clean_frontend_fixtures('labels/') + end + + describe Groups::LabelsController, '(JavaScript fixtures)', type: :controller do + render_views + + before(:each) do + sign_in(admin) + end + + it 'labels/group_labels.json' do |example| + get :index, + group_id: group, + format: 'json' + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end + end + + describe Projects::LabelsController, '(JavaScript fixtures)', type: :controller do + render_views + + before(:each) do + sign_in(admin) + end + + it 'labels/project_labels.json' do |example| + get :index, + namespace_id: group, + project_id: project, + format: 'json' + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end + end +end diff --git a/spec/javascripts/helpers/user_mock_data_helper.js b/spec/javascripts/helpers/user_mock_data_helper.js new file mode 100644 index 0000000000000..a9783ea065cf7 --- /dev/null +++ b/spec/javascripts/helpers/user_mock_data_helper.js @@ -0,0 +1,16 @@ +export default { + createNumberRandomUsers(numberUsers) { + const users = []; + for (let i = 0; i < numberUsers; i = i += 1) { + users.push( + { + avatar: 'http://gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: (i + 1), + name: `GitLab User ${i}`, + username: `gitlab${i}`, + }, + ); + } + return users; + }, +}; diff --git a/spec/javascripts/issuable_time_tracker_spec.js b/spec/javascripts/issuable_time_tracker_spec.js index 0a830f25e29ed..8ff93c4f918d2 100644 --- a/spec/javascripts/issuable_time_tracker_spec.js +++ b/spec/javascripts/issuable_time_tracker_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; -require('~/issuable/time_tracking/components/time_tracker'); +import timeTracker from '~/sidebar/components/time_tracking/time_tracker'; function initTimeTrackingComponent(opts) { setFixtures(` @@ -16,187 +16,185 @@ function initTimeTrackingComponent(opts) { time_spent: opts.timeSpent, human_time_estimate: opts.timeEstimateHumanReadable, human_time_spent: opts.timeSpentHumanReadable, - docsUrl: '/help/workflow/time_tracking.md', + rootPath: '/', }; - const TimeTrackingComponent = Vue.component('issuable-time-tracker'); + const TimeTrackingComponent = Vue.extend(timeTracker); this.timeTracker = new TimeTrackingComponent({ el: '#mock-container', propsData: this.initialData, }); } -((gl) => { - describe('Issuable Time Tracker', function() { - describe('Initialization', function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' }); - }); +describe('Issuable Time Tracker', function() { + describe('Initialization', function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' }); + }); - it('should return something defined', function() { - expect(this.timeTracker).toBeDefined(); - }); + it('should return something defined', function() { + expect(this.timeTracker).toBeDefined(); + }); - it ('should correctly set timeEstimate', function(done) { - Vue.nextTick(() => { - expect(this.timeTracker.timeEstimate).toBe(this.initialData.time_estimate); - done(); - }); + it ('should correctly set timeEstimate', function(done) { + Vue.nextTick(() => { + expect(this.timeTracker.timeEstimate).toBe(this.initialData.time_estimate); + done(); }); - it ('should correctly set time_spent', function(done) { - Vue.nextTick(() => { - expect(this.timeTracker.timeSpent).toBe(this.initialData.time_spent); - done(); - }); + }); + it ('should correctly set time_spent', function(done) { + Vue.nextTick(() => { + expect(this.timeTracker.timeSpent).toBe(this.initialData.time_spent); + done(); }); }); + }); - describe('Content Display', function() { - describe('Panes', function() { - describe('Comparison pane', function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '', timeSpentHumanReadable: '' }); + describe('Content Display', function() { + describe('Panes', function() { + describe('Comparison pane', function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '', timeSpentHumanReadable: '' }); + }); + + it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', function(done) { + Vue.nextTick(() => { + const $comparisonPane = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane'); + expect(this.timeTracker.showComparisonState).toBe(true); + done(); }); + }); - it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', function(done) { + describe('Remaining meter', function() { + it('should display the remaining meter with the correct width', function(done) { Vue.nextTick(() => { - const $comparisonPane = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane'); - expect(this.timeTracker.showComparisonState).toBe(true); + const meterWidth = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane .meter-fill').style.width; + const correctWidth = '5%'; + + expect(meterWidth).toBe(correctWidth); done(); - }); + }) }); - describe('Remaining meter', function() { - it('should display the remaining meter with the correct width', function(done) { - Vue.nextTick(() => { - const meterWidth = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane .meter-fill').style.width; - const correctWidth = '5%'; - - expect(meterWidth).toBe(correctWidth); - done(); - }) - }); - - it('should display the remaining meter with the correct background color when within estimate', function(done) { - Vue.nextTick(() => { - const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .within_estimate .meter-fill'); - expect(styledMeter.length).toBe(1); - done() - }); + it('should display the remaining meter with the correct background color when within estimate', function(done) { + Vue.nextTick(() => { + const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .within_estimate .meter-fill'); + expect(styledMeter.length).toBe(1); + done() }); + }); - it('should display the remaining meter with the correct background color when over estimate', function(done) { - this.timeTracker.time_estimate = 100000; - this.timeTracker.time_spent = 20000000; - Vue.nextTick(() => { - const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .over_estimate .meter-fill'); - expect(styledMeter.length).toBe(1); - done(); - }); + it('should display the remaining meter with the correct background color when over estimate', function(done) { + this.timeTracker.time_estimate = 100000; + this.timeTracker.time_spent = 20000000; + Vue.nextTick(() => { + const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .over_estimate .meter-fill'); + expect(styledMeter.length).toBe(1); + done(); }); }); }); + }); - describe("Estimate only pane", function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 0, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '' }); - }); + describe("Estimate only pane", function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 0, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '' }); + }); - it('should display the human readable version of time estimated', function(done) { - Vue.nextTick(() => { - const estimateText = this.timeTracker.$el.querySelector('.time-tracking-estimate-only-pane').innerText; - const correctText = 'Estimated: 2h 46m'; + it('should display the human readable version of time estimated', function(done) { + Vue.nextTick(() => { + const estimateText = this.timeTracker.$el.querySelector('.time-tracking-estimate-only-pane').innerText; + const correctText = 'Estimated: 2h 46m'; - expect(estimateText).toBe(correctText); - done(); - }); + expect(estimateText).toBe(correctText); + done(); }); }); + }); - describe('Spent only pane', function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' }); - }); + describe('Spent only pane', function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' }); + }); - it('should display the human readable version of time spent', function(done) { - Vue.nextTick(() => { - const spentText = this.timeTracker.$el.querySelector('.time-tracking-spend-only-pane').innerText; - const correctText = 'Spent: 1h 23m'; + it('should display the human readable version of time spent', function(done) { + Vue.nextTick(() => { + const spentText = this.timeTracker.$el.querySelector('.time-tracking-spend-only-pane').innerText; + const correctText = 'Spent: 1h 23m'; - expect(spentText).toBe(correctText); - done(); - }); + expect(spentText).toBe(correctText); + done(); }); }); + }); - describe('No time tracking pane', function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0, timeEstimateHumanReadable: 0, timeSpentHumanReadable: 0 }); - }); + describe('No time tracking pane', function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0, timeEstimateHumanReadable: '', timeSpentHumanReadable: '' }); + }); - it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', function(done) { - Vue.nextTick(() => { - const $noTrackingPane = this.timeTracker.$el.querySelector('.time-tracking-no-tracking-pane'); - const noTrackingText =$noTrackingPane.innerText; - const correctText = 'No estimate or time spent'; + it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', function(done) { + Vue.nextTick(() => { + const $noTrackingPane = this.timeTracker.$el.querySelector('.time-tracking-no-tracking-pane'); + const noTrackingText =$noTrackingPane.innerText; + const correctText = 'No estimate or time spent'; - expect(this.timeTracker.showNoTimeTrackingState).toBe(true); - expect($noTrackingPane).toBeVisible(); - expect(noTrackingText).toBe(correctText); - done(); - }); + expect(this.timeTracker.showNoTimeTrackingState).toBe(true); + expect($noTrackingPane).toBeVisible(); + expect(noTrackingText).toBe(correctText); + done(); }); }); + }); - describe("Help pane", function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0 }); - }); + describe("Help pane", function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0 }); + }); - it('should not show the "Help" pane by default', function(done) { - Vue.nextTick(() => { - const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); + it('should not show the "Help" pane by default', function(done) { + Vue.nextTick(() => { + const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); - expect(this.timeTracker.showHelpState).toBe(false); - expect($helpPane).toBeNull(); - done(); - }); + expect(this.timeTracker.showHelpState).toBe(false); + expect($helpPane).toBeNull(); + done(); }); + }); - it('should show the "Help" pane when help button is clicked', function(done) { - Vue.nextTick(() => { - $(this.timeTracker.$el).find('.help-button').click(); + it('should show the "Help" pane when help button is clicked', function(done) { + Vue.nextTick(() => { + $(this.timeTracker.$el).find('.help-button').click(); - setTimeout(() => { - const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); - expect(this.timeTracker.showHelpState).toBe(true); - expect($helpPane).toBeVisible(); - done(); - }, 10); - }); + setTimeout(() => { + const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); + expect(this.timeTracker.showHelpState).toBe(true); + expect($helpPane).toBeVisible(); + done(); + }, 10); }); + }); - it('should not show the "Help" pane when help button is clicked and then closed', function(done) { - Vue.nextTick(() => { - $(this.timeTracker.$el).find('.help-button').click(); + it('should not show the "Help" pane when help button is clicked and then closed', function(done) { + Vue.nextTick(() => { + $(this.timeTracker.$el).find('.help-button').click(); - setTimeout(() => { + setTimeout(() => { - $(this.timeTracker.$el).find('.close-help-button').click(); + $(this.timeTracker.$el).find('.close-help-button').click(); - setTimeout(() => { - const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); + setTimeout(() => { + const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); - expect(this.timeTracker.showHelpState).toBe(false); - expect($helpPane).toBeNull(); + expect(this.timeTracker.showHelpState).toBe(false); + expect($helpPane).toBeNull(); - done(); - }, 1000); + done(); }, 1000); - }); + }, 1000); }); }); }); }); }); -})(window.gl || (window.gl = {})); +}); diff --git a/spec/javascripts/lib/utils/accessor_spec.js b/spec/javascripts/lib/utils/accessor_spec.js new file mode 100644 index 0000000000000..b768d6f2a68b4 --- /dev/null +++ b/spec/javascripts/lib/utils/accessor_spec.js @@ -0,0 +1,78 @@ +import AccessorUtilities from '~/lib/utils/accessor'; + +describe('AccessorUtilities', () => { + const testError = new Error('test error'); + + describe('isPropertyAccessSafe', () => { + let base; + + it('should return `true` if access is safe', () => { + base = { testProp: 'testProp' }; + + expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(true); + }); + + it('should return `false` if access throws an error', () => { + base = { get testProp() { throw testError; } }; + + expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(false); + }); + + it('should return `false` if property is undefined', () => { + base = {}; + + expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(false); + }); + }); + + describe('isFunctionCallSafe', () => { + const base = {}; + + it('should return `true` if calling is safe', () => { + base.func = () => {}; + + expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(true); + }); + + it('should return `false` if calling throws an error', () => { + base.func = () => { throw new Error('test error'); }; + + expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(false); + }); + + it('should return `false` if function is undefined', () => { + base.func = undefined; + + expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(false); + }); + }); + + describe('isLocalStorageAccessSafe', () => { + beforeEach(() => { + spyOn(window.localStorage, 'setItem'); + spyOn(window.localStorage, 'removeItem'); + }); + + it('should return `true` if access is safe', () => { + expect(AccessorUtilities.isLocalStorageAccessSafe()).toBe(true); + }); + + it('should return `false` if access to .setItem isnt safe', () => { + window.localStorage.setItem.and.callFake(() => { throw testError; }); + + expect(AccessorUtilities.isLocalStorageAccessSafe()).toBe(false); + }); + + it('should set a test item if access is safe', () => { + AccessorUtilities.isLocalStorageAccessSafe(); + + expect(window.localStorage.setItem).toHaveBeenCalledWith('isLocalStorageAccessSafe', 'true'); + }); + + it('should remove the test item if access is safe', () => { + AccessorUtilities.isLocalStorageAccessSafe(); + + expect(window.localStorage.removeItem).toHaveBeenCalledWith('isLocalStorageAccessSafe'); + }); + }); +}); diff --git a/spec/javascripts/lib/utils/ajax_cache_spec.js b/spec/javascripts/lib/utils/ajax_cache_spec.js new file mode 100644 index 0000000000000..7b466a11b9240 --- /dev/null +++ b/spec/javascripts/lib/utils/ajax_cache_spec.js @@ -0,0 +1,129 @@ +import AjaxCache from '~/lib/utils/ajax_cache'; + +describe('AjaxCache', () => { + const dummyEndpoint = '/AjaxCache/dummyEndpoint'; + const dummyResponse = { + important: 'dummy data', + }; + let ajaxSpy = (url) => { + expect(url).toBe(dummyEndpoint); + const deferred = $.Deferred(); + deferred.resolve(dummyResponse); + return deferred.promise(); + }; + + beforeEach(() => { + AjaxCache.internalStorage = { }; + spyOn(jQuery, 'ajax').and.callFake(url => ajaxSpy(url)); + }); + + describe('#get', () => { + it('returns undefined if cache is empty', () => { + const data = AjaxCache.get(dummyEndpoint); + + expect(data).toBe(undefined); + }); + + it('returns undefined if cache contains no matching data', () => { + AjaxCache.internalStorage['not matching'] = dummyResponse; + + const data = AjaxCache.get(dummyEndpoint); + + expect(data).toBe(undefined); + }); + + it('returns matching data', () => { + AjaxCache.internalStorage[dummyEndpoint] = dummyResponse; + + const data = AjaxCache.get(dummyEndpoint); + + expect(data).toBe(dummyResponse); + }); + }); + + describe('#hasData', () => { + it('returns false if cache is empty', () => { + expect(AjaxCache.hasData(dummyEndpoint)).toBe(false); + }); + + it('returns false if cache contains no matching data', () => { + AjaxCache.internalStorage['not matching'] = dummyResponse; + + expect(AjaxCache.hasData(dummyEndpoint)).toBe(false); + }); + + it('returns true if data is available', () => { + AjaxCache.internalStorage[dummyEndpoint] = dummyResponse; + + expect(AjaxCache.hasData(dummyEndpoint)).toBe(true); + }); + }); + + describe('#purge', () => { + it('does nothing if cache is empty', () => { + AjaxCache.purge(dummyEndpoint); + + expect(AjaxCache.internalStorage).toEqual({ }); + }); + + it('does nothing if cache contains no matching data', () => { + AjaxCache.internalStorage['not matching'] = dummyResponse; + + AjaxCache.purge(dummyEndpoint); + + expect(AjaxCache.internalStorage['not matching']).toBe(dummyResponse); + }); + + it('removes matching data', () => { + AjaxCache.internalStorage[dummyEndpoint] = dummyResponse; + + AjaxCache.purge(dummyEndpoint); + + expect(AjaxCache.internalStorage).toEqual({ }); + }); + }); + + describe('#retrieve', () => { + it('stores and returns data from Ajax call if cache is empty', (done) => { + AjaxCache.retrieve(dummyEndpoint) + .then((data) => { + expect(data).toBe(dummyResponse); + expect(AjaxCache.internalStorage[dummyEndpoint]).toBe(dummyResponse); + }) + .then(done) + .catch(fail); + }); + + it('returns undefined if Ajax call fails and cache is empty', (done) => { + const dummyStatusText = 'exploded'; + const dummyErrorMessage = 'server exploded'; + ajaxSpy = (url) => { + expect(url).toBe(dummyEndpoint); + const deferred = $.Deferred(); + deferred.reject(null, dummyStatusText, dummyErrorMessage); + return deferred.promise(); + }; + + AjaxCache.retrieve(dummyEndpoint) + .then(data => fail(`Received unexpected data: ${JSON.stringify(data)}`)) + .catch((error) => { + expect(error.message).toBe(`${dummyEndpoint}: ${dummyErrorMessage}`); + expect(error.textStatus).toBe(dummyStatusText); + done(); + }) + .catch(fail); + }); + + it('makes no Ajax call if matching data exists', (done) => { + AjaxCache.internalStorage[dummyEndpoint] = dummyResponse; + ajaxSpy = () => fail(new Error('expected no Ajax call!')); + + AjaxCache.retrieve(dummyEndpoint) + .then((data) => { + expect(data).toBe(dummyResponse); + }) + .then(done) + .catch(fail); + }); + }); +}); diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js index a00efa10119ce..5eb147ed88806 100644 --- a/spec/javascripts/lib/utils/common_utils_spec.js +++ b/spec/javascripts/lib/utils/common_utils_spec.js @@ -362,5 +362,16 @@ require('~/lib/utils/common_utils'); gl.utils.setCiStatusFavicon(BUILD_URL); }); }); + + describe('gl.utils.ajaxPost', () => { + it('should perform `$.ajax` call and do `POST` request', () => { + const requestURL = '/some/random/api'; + const data = { keyname: 'value' }; + const ajaxSpy = spyOn($, 'ajax').and.callFake(() => {}); + + gl.utils.ajaxPost(requestURL, data); + expect(ajaxSpy.calls.allArgs()[0][0].type).toEqual('POST'); + }); + }); }); })(); diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index cdc5c4510ffcd..cfd599f793e73 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -26,10 +26,10 @@ import '~/notes'; describe('task lists', function() { beforeEach(function() { - $('form').on('submit', function(e) { + $('.js-comment-button').on('click', function(e) { e.preventDefault(); }); - this.notes = new Notes(); + this.notes = new Notes('', []); }); it('modifies the Markdown field', function() { @@ -51,7 +51,7 @@ import '~/notes'; var textarea = '.js-note-text'; beforeEach(function() { - this.notes = new Notes(); + this.notes = new Notes('', []); this.autoSizeSpy = spyOnEvent($(textarea), 'autosize:update'); spyOn(this.notes, 'renderNote').and.stub(); @@ -60,9 +60,12 @@ import '~/notes'; reset: function() {} }); - $('form').on('submit', function(e) { + $('.js-comment-button').on('click', (e) => { + const $form = $(this); e.preventDefault(); - $('.js-main-target-form').trigger('ajax:success'); + this.notes.addNote($form); + this.notes.reenableTargetFormSubmitButton(e); + this.notes.resetMainTargetForm(e); }); }); @@ -238,8 +241,8 @@ import '~/notes'; $resultantNote = Notes.animateAppendNote(noteHTML, $notesList); }); - it('should have `fade-in` class', () => { - expect($resultantNote.hasClass('fade-in')).toEqual(true); + it('should have `fade-in-full` class', () => { + expect($resultantNote.hasClass('fade-in-full')).toEqual(true); }); it('should append note to the notes list', () => { @@ -269,5 +272,180 @@ import '~/notes'; expect($note.replaceWith).toHaveBeenCalledWith($updatedNote); }); }); + + describe('postComment & updateComment', () => { + const sampleComment = 'foo'; + const updatedComment = 'bar'; + const note = { + id: 1234, + html: `<li class="note note-row-1234 timeline-entry" id="note_1234"> + <div class="note-text">${sampleComment}</div> + </li>`, + note: sampleComment, + valid: true + }; + let $form; + let $notesContainer; + + beforeEach(() => { + this.notes = new Notes('', []); + window.gon.current_username = 'root'; + window.gon.current_user_fullname = 'Administrator'; + $form = $('form.js-main-target-form'); + $notesContainer = $('ul.main-notes-list'); + $form.find('textarea.js-note-text').val(sampleComment); + }); + + it('should show placeholder note while new comment is being posted', () => { + $('.js-comment-button').click(); + expect($notesContainer.find('.note.being-posted').length > 0).toEqual(true); + }); + + it('should remove placeholder note when new comment is done posting', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + $('.js-comment-button').click(); + + deferred.resolve(note); + expect($notesContainer.find('.note.being-posted').length).toEqual(0); + }); + + it('should show actual note element when new comment is done posting', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + $('.js-comment-button').click(); + + deferred.resolve(note); + expect($notesContainer.find(`#note_${note.id}`).length > 0).toEqual(true); + }); + + it('should reset Form when new comment is done posting', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + $('.js-comment-button').click(); + + deferred.resolve(note); + expect($form.find('textarea.js-note-text').val()).toEqual(''); + }); + + it('should show flash error message when new comment failed to be posted', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + $('.js-comment-button').click(); + + deferred.reject(); + expect($notesContainer.parent().find('.flash-container .flash-text').is(':visible')).toEqual(true); + }); + + it('should show flash error message when comment failed to be updated', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + $('.js-comment-button').click(); + + deferred.resolve(note); + const $noteEl = $notesContainer.find(`#note_${note.id}`); + $noteEl.find('.js-note-edit').click(); + $noteEl.find('textarea.js-note-text').val(updatedComment); + $noteEl.find('.js-comment-save-button').click(); + + deferred.reject(); + const $updatedNoteEl = $notesContainer.find(`#note_${note.id}`); + expect($updatedNoteEl.hasClass('.being-posted')).toEqual(false); // Remove being-posted visuals + expect($updatedNoteEl.find('.note-text').text().trim()).toEqual(sampleComment); // See if comment reverted back to original + expect($('.flash-container').is(':visible')).toEqual(true); // Flash error message shown + }); + }); + + describe('getFormData', () => { + it('should return form metadata object from form reference', () => { + this.notes = new Notes('', []); + + const $form = $('form'); + const sampleComment = 'foobar'; + $form.find('textarea.js-note-text').val(sampleComment); + const { formData, formContent, formAction } = this.notes.getFormData($form); + + expect(formData.indexOf(sampleComment) > -1).toBe(true); + expect(formContent).toEqual(sampleComment); + expect(formAction).toEqual($form.attr('action')); + }); + }); + + describe('hasSlashCommands', () => { + beforeEach(() => { + this.notes = new Notes('', []); + }); + + it('should return true when comment has slash commands', () => { + const sampleComment = '/wip /milestone %1.0 /merge /unassign Merging this'; + const hasSlashCommands = this.notes.hasSlashCommands(sampleComment); + + expect(hasSlashCommands).toBeTruthy(); + }); + + it('should return false when comment does NOT have any slash commands', () => { + const sampleComment = 'Looking good, Awesome!'; + const hasSlashCommands = this.notes.hasSlashCommands(sampleComment); + + expect(hasSlashCommands).toBeFalsy(); + }); + }); + + describe('stripSlashCommands', () => { + const REGEX_SLASH_COMMANDS = /\/\w+/g; + + it('should strip slash commands from the comment', () => { + this.notes = new Notes(); + const sampleComment = '/wip /milestone %1.0 /merge /unassign Merging this'; + const stripedComment = this.notes.stripSlashCommands(sampleComment); + + expect(REGEX_SLASH_COMMANDS.test(stripedComment)).toBeFalsy(); + }); + }); + + describe('createPlaceholderNote', () => { + const sampleComment = 'foobar'; + const uniqueId = 'b1234-a4567'; + const currentUsername = 'root'; + const currentUserFullname = 'Administrator'; + + beforeEach(() => { + this.notes = new Notes('', []); + }); + + it('should return constructed placeholder element for regular note based on form contents', () => { + const $tempNote = this.notes.createPlaceholderNote({ + formContent: sampleComment, + uniqueId, + isDiscussionNote: false, + currentUsername, + currentUserFullname + }); + const $tempNoteHeader = $tempNote.find('.note-header'); + + expect($tempNote.prop('nodeName')).toEqual('LI'); + expect($tempNote.attr('id')).toEqual(uniqueId); + $tempNote.find('.timeline-icon > a, .note-header-info > a').each(function() { + expect($(this).attr('href')).toEqual(`/${currentUsername}`); + }); + expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeFalsy(); + expect($tempNoteHeader.find('.hidden-xs').text().trim()).toEqual(currentUserFullname); + expect($tempNoteHeader.find('.note-headline-light').text().trim()).toEqual(`@${currentUsername}`); + expect($tempNote.find('.note-body .note-text').text().trim()).toEqual(sampleComment); + }); + + it('should return constructed placeholder element for discussion note based on form contents', () => { + const $tempNote = this.notes.createPlaceholderNote({ + formContent: sampleComment, + uniqueId, + isDiscussionNote: true, + currentUsername, + currentUserFullname + }); + + expect($tempNote.prop('nodeName')).toEqual('LI'); + expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeTruthy(); + }); + }); }); }).call(window); diff --git a/spec/javascripts/raven/index_spec.js b/spec/javascripts/raven/index_spec.js new file mode 100644 index 0000000000000..b5662cd0331c7 --- /dev/null +++ b/spec/javascripts/raven/index_spec.js @@ -0,0 +1,42 @@ +import RavenConfig from '~/raven/raven_config'; +import index from '~/raven/index'; + +describe('RavenConfig options', () => { + let sentryDsn; + let currentUserId; + let gitlabUrl; + let isProduction; + let indexReturnValue; + + beforeEach(() => { + sentryDsn = 'sentryDsn'; + currentUserId = 'currentUserId'; + gitlabUrl = 'gitlabUrl'; + isProduction = 'isProduction'; + + window.gon = { + sentry_dsn: sentryDsn, + current_user_id: currentUserId, + gitlab_url: gitlabUrl, + }; + + process.env.NODE_ENV = isProduction; + + spyOn(RavenConfig, 'init'); + + indexReturnValue = index(); + }); + + it('should init with .sentryDsn, .currentUserId, .whitelistUrls and .isProduction', () => { + expect(RavenConfig.init).toHaveBeenCalledWith({ + sentryDsn, + currentUserId, + whitelistUrls: [gitlabUrl], + isProduction, + }); + }); + + it('should return RavenConfig', () => { + expect(indexReturnValue).toBe(RavenConfig); + }); +}); diff --git a/spec/javascripts/raven/raven_config_spec.js b/spec/javascripts/raven/raven_config_spec.js new file mode 100644 index 0000000000000..a2d720760fce6 --- /dev/null +++ b/spec/javascripts/raven/raven_config_spec.js @@ -0,0 +1,276 @@ +import Raven from 'raven-js'; +import RavenConfig from '~/raven/raven_config'; + +describe('RavenConfig', () => { + describe('IGNORE_ERRORS', () => { + it('should be an array of strings', () => { + const areStrings = RavenConfig.IGNORE_ERRORS.every(error => typeof error === 'string'); + + expect(areStrings).toBe(true); + }); + }); + + describe('IGNORE_URLS', () => { + it('should be an array of regexps', () => { + const areRegExps = RavenConfig.IGNORE_URLS.every(url => url instanceof RegExp); + + expect(areRegExps).toBe(true); + }); + }); + + describe('SAMPLE_RATE', () => { + it('should be a finite number', () => { + expect(typeof RavenConfig.SAMPLE_RATE).toEqual('number'); + }); + }); + + describe('init', () => { + let options; + + beforeEach(() => { + options = { + sentryDsn: '//sentryDsn', + ravenAssetUrl: '//ravenAssetUrl', + currentUserId: 1, + whitelistUrls: ['//gitlabUrl'], + isProduction: true, + }; + + spyOn(RavenConfig, 'configure'); + spyOn(RavenConfig, 'bindRavenErrors'); + spyOn(RavenConfig, 'setUser'); + + RavenConfig.init(options); + }); + + it('should set the options property', () => { + expect(RavenConfig.options).toEqual(options); + }); + + it('should call the configure method', () => { + expect(RavenConfig.configure).toHaveBeenCalled(); + }); + + it('should call the error bindings method', () => { + expect(RavenConfig.bindRavenErrors).toHaveBeenCalled(); + }); + + it('should call setUser', () => { + expect(RavenConfig.setUser).toHaveBeenCalled(); + }); + + it('should not call setUser if there is no current user ID', () => { + RavenConfig.setUser.calls.reset(); + + RavenConfig.init({ + sentryDsn: '//sentryDsn', + ravenAssetUrl: '//ravenAssetUrl', + currentUserId: undefined, + whitelistUrls: ['//gitlabUrl'], + isProduction: true, + }); + + expect(RavenConfig.setUser).not.toHaveBeenCalled(); + }); + }); + + describe('configure', () => { + let options; + let raven; + let ravenConfig; + + beforeEach(() => { + options = { + sentryDsn: '//sentryDsn', + whitelistUrls: ['//gitlabUrl'], + isProduction: true, + }; + + ravenConfig = jasmine.createSpyObj('ravenConfig', ['shouldSendSample']); + raven = jasmine.createSpyObj('raven', ['install']); + + spyOn(Raven, 'config').and.returnValue(raven); + + ravenConfig.options = options; + ravenConfig.IGNORE_ERRORS = 'ignore_errors'; + ravenConfig.IGNORE_URLS = 'ignore_urls'; + + RavenConfig.configure.call(ravenConfig); + }); + + it('should call Raven.config', () => { + expect(Raven.config).toHaveBeenCalledWith(options.sentryDsn, { + whitelistUrls: options.whitelistUrls, + environment: 'production', + ignoreErrors: ravenConfig.IGNORE_ERRORS, + ignoreUrls: ravenConfig.IGNORE_URLS, + shouldSendCallback: jasmine.any(Function), + }); + }); + + it('should call Raven.install', () => { + expect(raven.install).toHaveBeenCalled(); + }); + + it('should set .environment to development if isProduction is false', () => { + ravenConfig.options.isProduction = false; + + RavenConfig.configure.call(ravenConfig); + + expect(Raven.config).toHaveBeenCalledWith(options.sentryDsn, { + whitelistUrls: options.whitelistUrls, + environment: 'development', + ignoreErrors: ravenConfig.IGNORE_ERRORS, + ignoreUrls: ravenConfig.IGNORE_URLS, + shouldSendCallback: jasmine.any(Function), + }); + }); + }); + + describe('setUser', () => { + let ravenConfig; + + beforeEach(() => { + ravenConfig = { options: { currentUserId: 1 } }; + spyOn(Raven, 'setUserContext'); + + RavenConfig.setUser.call(ravenConfig); + }); + + it('should call .setUserContext', function () { + expect(Raven.setUserContext).toHaveBeenCalledWith({ + id: ravenConfig.options.currentUserId, + }); + }); + }); + + describe('bindRavenErrors', () => { + let $document; + let $; + + beforeEach(() => { + $document = jasmine.createSpyObj('$document', ['on']); + $ = jasmine.createSpy('$').and.returnValue($document); + + window.$ = $; + + RavenConfig.bindRavenErrors(); + }); + + it('should call .on', function () { + expect($document.on).toHaveBeenCalledWith('ajaxError.raven', RavenConfig.handleRavenErrors); + }); + }); + + describe('handleRavenErrors', () => { + let event; + let req; + let config; + let err; + + beforeEach(() => { + event = {}; + req = { status: 'status', responseText: 'responseText', statusText: 'statusText' }; + config = { type: 'type', url: 'url', data: 'data' }; + err = {}; + + spyOn(Raven, 'captureMessage'); + + RavenConfig.handleRavenErrors(event, req, config, err); + }); + + it('should call Raven.captureMessage', () => { + expect(Raven.captureMessage).toHaveBeenCalledWith(err, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: req.responseText, + error: err, + event, + }, + }); + }); + + describe('if no err is provided', () => { + beforeEach(() => { + Raven.captureMessage.calls.reset(); + + RavenConfig.handleRavenErrors(event, req, config); + }); + + it('should use req.statusText as the error value', () => { + expect(Raven.captureMessage).toHaveBeenCalledWith(req.statusText, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: req.responseText, + error: req.statusText, + event, + }, + }); + }); + }); + + describe('if no req.responseText is provided', () => { + beforeEach(() => { + req.responseText = undefined; + + Raven.captureMessage.calls.reset(); + + RavenConfig.handleRavenErrors(event, req, config, err); + }); + + it('should use `Unknown response text` as the response', () => { + expect(Raven.captureMessage).toHaveBeenCalledWith(err, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: 'Unknown response text', + error: err, + event, + }, + }); + }); + }); + }); + + describe('shouldSendSample', () => { + let randomNumber; + + beforeEach(() => { + RavenConfig.SAMPLE_RATE = 50; + + spyOn(Math, 'random').and.callFake(() => randomNumber); + }); + + it('should call Math.random', () => { + RavenConfig.shouldSendSample(); + + expect(Math.random).toHaveBeenCalled(); + }); + + it('should return true if the sample rate is greater than the random number * 100', () => { + randomNumber = 0.1; + + expect(RavenConfig.shouldSendSample()).toBe(true); + }); + + it('should return false if the sample rate is less than the random number * 100', () => { + randomNumber = 0.9; + + expect(RavenConfig.shouldSendSample()).toBe(false); + }); + + it('should return true if the sample rate is equal to the random number * 100', () => { + randomNumber = 0.5; + + expect(RavenConfig.shouldSendSample()).toBe(true); + }); + }); +}); diff --git a/spec/javascripts/sidebar/assignee_title_spec.js b/spec/javascripts/sidebar/assignee_title_spec.js new file mode 100644 index 0000000000000..5b5b1bf414010 --- /dev/null +++ b/spec/javascripts/sidebar/assignee_title_spec.js @@ -0,0 +1,80 @@ +import Vue from 'vue'; +import AssigneeTitle from '~/sidebar/components/assignees/assignee_title'; + +describe('AssigneeTitle component', () => { + let component; + let AssigneeTitleComponent; + + beforeEach(() => { + AssigneeTitleComponent = Vue.extend(AssigneeTitle); + }); + + describe('assignee title', () => { + it('renders assignee', () => { + component = new AssigneeTitleComponent({ + propsData: { + numberOfAssignees: 1, + editable: false, + }, + }).$mount(); + + expect(component.$el.innerText.trim()).toEqual('Assignee'); + }); + + it('renders 2 assignees', () => { + component = new AssigneeTitleComponent({ + propsData: { + numberOfAssignees: 2, + editable: false, + }, + }).$mount(); + + expect(component.$el.innerText.trim()).toEqual('2 Assignees'); + }); + }); + + it('does not render spinner by default', () => { + component = new AssigneeTitleComponent({ + propsData: { + numberOfAssignees: 0, + editable: false, + }, + }).$mount(); + + expect(component.$el.querySelector('.fa')).toBeNull(); + }); + + it('renders spinner when loading', () => { + component = new AssigneeTitleComponent({ + propsData: { + loading: true, + numberOfAssignees: 0, + editable: false, + }, + }).$mount(); + + expect(component.$el.querySelector('.fa')).not.toBeNull(); + }); + + it('does not render edit link when not editable', () => { + component = new AssigneeTitleComponent({ + propsData: { + numberOfAssignees: 0, + editable: false, + }, + }).$mount(); + + expect(component.$el.querySelector('.edit-link')).toBeNull(); + }); + + it('renders edit link when editable', () => { + component = new AssigneeTitleComponent({ + propsData: { + numberOfAssignees: 0, + editable: true, + }, + }).$mount(); + + expect(component.$el.querySelector('.edit-link')).not.toBeNull(); + }); +}); diff --git a/spec/javascripts/sidebar/assignees_spec.js b/spec/javascripts/sidebar/assignees_spec.js new file mode 100644 index 0000000000000..c9453a2118972 --- /dev/null +++ b/spec/javascripts/sidebar/assignees_spec.js @@ -0,0 +1,272 @@ +import Vue from 'vue'; +import Assignee from '~/sidebar/components/assignees/assignees'; +import UsersMock from './mock_data'; +import UsersMockHelper from '../helpers/user_mock_data_helper'; + +describe('Assignee component', () => { + let component; + let AssigneeComponent; + + beforeEach(() => { + AssigneeComponent = Vue.extend(Assignee); + }); + + describe('No assignees/users', () => { + it('displays no assignee icon when collapsed', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users: [], + editable: false, + }, + }).$mount(); + + const collapsed = component.$el.querySelector('.sidebar-collapsed-icon'); + expect(collapsed.childElementCount).toEqual(1); + expect(collapsed.children[0].getAttribute('aria-label')).toEqual('No Assignee'); + expect(collapsed.children[0].classList.contains('fa')).toEqual(true); + expect(collapsed.children[0].classList.contains('fa-user')).toEqual(true); + }); + + it('displays only "No assignee" when no users are assigned and the issue is read-only', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users: [], + editable: false, + }, + }).$mount(); + const componentTextNoUsers = component.$el.querySelector('.assign-yourself').innerText.trim(); + + expect(componentTextNoUsers).toBe('No assignee'); + expect(componentTextNoUsers.indexOf('assign yourself')).toEqual(-1); + }); + + it('displays only "No assignee" when no users are assigned and the issue can be edited', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users: [], + editable: true, + }, + }).$mount(); + const componentTextNoUsers = component.$el.querySelector('.assign-yourself').innerText.trim(); + + expect(componentTextNoUsers.indexOf('No assignee')).toEqual(0); + expect(componentTextNoUsers.indexOf('assign yourself')).toBeGreaterThan(0); + }); + + it('emits the assign-self event when "assign yourself" is clicked', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users: [], + editable: true, + }, + }).$mount(); + + spyOn(component, '$emit'); + component.$el.querySelector('.assign-yourself .btn-link').click(); + expect(component.$emit).toHaveBeenCalledWith('assign-self'); + }); + }); + + describe('One assignee/user', () => { + it('displays one assignee icon when collapsed', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users: [ + UsersMock.user, + ], + editable: false, + }, + }).$mount(); + + const collapsed = component.$el.querySelector('.sidebar-collapsed-icon'); + const assignee = collapsed.children[0]; + expect(collapsed.childElementCount).toEqual(1); + expect(assignee.querySelector('.avatar').getAttribute('src')).toEqual(UsersMock.user.avatar); + expect(assignee.querySelector('.avatar').getAttribute('alt')).toEqual(`${UsersMock.user.name}'s avatar`); + expect(assignee.querySelector('.author').innerText.trim()).toEqual(UsersMock.user.name); + }); + + it('Shows one user with avatar, username and author name', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000/', + users: [ + UsersMock.user, + ], + editable: true, + }, + }).$mount(); + + expect(component.$el.querySelector('.author_link')).not.toBeNull(); + // The image + expect(component.$el.querySelector('.author_link img').getAttribute('src')).toEqual(UsersMock.user.avatar); + // Author name + expect(component.$el.querySelector('.author_link .author').innerText.trim()).toEqual(UsersMock.user.name); + // Username + expect(component.$el.querySelector('.author_link .username').innerText.trim()).toEqual(`@${UsersMock.user.username}`); + }); + + it('has the root url present in the assigneeUrl method', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000/', + users: [ + UsersMock.user, + ], + editable: true, + }, + }).$mount(); + + expect(component.assigneeUrl(UsersMock.user).indexOf('http://localhost:3000/')).not.toEqual(-1); + }); + }); + + describe('Two or more assignees/users', () => { + it('displays two assignee icons when collapsed', () => { + const users = UsersMockHelper.createNumberRandomUsers(2); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: false, + }, + }).$mount(); + + const collapsed = component.$el.querySelector('.sidebar-collapsed-icon'); + expect(collapsed.childElementCount).toEqual(2); + + const first = collapsed.children[0]; + expect(first.querySelector('.avatar').getAttribute('src')).toEqual(users[0].avatar); + expect(first.querySelector('.avatar').getAttribute('alt')).toEqual(`${users[0].name}'s avatar`); + expect(first.querySelector('.author').innerText.trim()).toEqual(users[0].name); + + const second = collapsed.children[1]; + expect(second.querySelector('.avatar').getAttribute('src')).toEqual(users[1].avatar); + expect(second.querySelector('.avatar').getAttribute('alt')).toEqual(`${users[1].name}'s avatar`); + expect(second.querySelector('.author').innerText.trim()).toEqual(users[1].name); + }); + + it('displays one assignee icon and counter when collapsed', () => { + const users = UsersMockHelper.createNumberRandomUsers(3); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: false, + }, + }).$mount(); + + const collapsed = component.$el.querySelector('.sidebar-collapsed-icon'); + expect(collapsed.childElementCount).toEqual(2); + + const first = collapsed.children[0]; + expect(first.querySelector('.avatar').getAttribute('src')).toEqual(users[0].avatar); + expect(first.querySelector('.avatar').getAttribute('alt')).toEqual(`${users[0].name}'s avatar`); + expect(first.querySelector('.author').innerText.trim()).toEqual(users[0].name); + + const second = collapsed.children[1]; + expect(second.querySelector('.avatar-counter').innerText.trim()).toEqual('+2'); + }); + + it('Shows two assignees', () => { + const users = UsersMockHelper.createNumberRandomUsers(2); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: true, + }, + }).$mount(); + + expect(component.$el.querySelectorAll('.user-item').length).toEqual(users.length); + expect(component.$el.querySelector('.user-list-more')).toBe(null); + }); + + it('Shows the "show-less" assignees label', (done) => { + const users = UsersMockHelper.createNumberRandomUsers(6); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: true, + }, + }).$mount(); + + expect(component.$el.querySelectorAll('.user-item').length).toEqual(component.defaultRenderCount); + expect(component.$el.querySelector('.user-list-more')).not.toBe(null); + const usersLabelExpectation = users.length - component.defaultRenderCount; + expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()) + .not.toBe(`+${usersLabelExpectation} more`); + component.toggleShowLess(); + Vue.nextTick(() => { + expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()) + .toBe('- show less'); + done(); + }); + }); + + it('Shows the "show-less" when "n+ more " label is clicked', (done) => { + const users = UsersMockHelper.createNumberRandomUsers(6); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: true, + }, + }).$mount(); + + component.$el.querySelector('.user-list-more .btn-link').click(); + Vue.nextTick(() => { + expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()) + .toBe('- show less'); + done(); + }); + }); + + it('gets the count of avatar via a computed property ', () => { + const users = UsersMockHelper.createNumberRandomUsers(6); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: true, + }, + }).$mount(); + + expect(component.sidebarAvatarCounter).toEqual(`+${users.length - 1}`); + }); + + describe('n+ more label', () => { + beforeEach(() => { + const users = UsersMockHelper.createNumberRandomUsers(6); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: true, + }, + }).$mount(); + }); + + it('shows "+1 more" label', () => { + expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()) + .toBe('+ 1 more'); + }); + + it('shows "show less" label', (done) => { + component.toggleShowLess(); + + Vue.nextTick(() => { + expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()) + .toBe('- show less'); + done(); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/sidebar/mock_data.js b/spec/javascripts/sidebar/mock_data.js new file mode 100644 index 0000000000000..9fc8667ecc9a7 --- /dev/null +++ b/spec/javascripts/sidebar/mock_data.js @@ -0,0 +1,109 @@ +/* eslint-disable quote-props*/ + +const sidebarMockData = { + 'GET': { + '/gitlab-org/gitlab-shell/issues/5.json': { + id: 45, + iid: 5, + author_id: 23, + description: 'Nulla ullam commodi delectus adipisci quis sit.', + lock_version: null, + milestone_id: 21, + position: 0, + state: 'closed', + title: 'Vel et nulla voluptatibus corporis dolor iste saepe laborum.', + updated_by_id: 1, + created_at: '2017-02-02T21: 49: 49.664Z', + updated_at: '2017-05-03T22: 26: 03.760Z', + deleted_at: null, + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + branch_name: null, + confidential: false, + assignees: [ + { + name: 'User 0', + username: 'user0', + id: 22, + state: 'active', + avatar_url: 'http: //www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80\u0026d=identicon', + web_url: 'http: //localhost:3001/user0', + }, + { + name: 'Marguerite Bartell', + username: 'tajuana', + id: 18, + state: 'active', + avatar_url: 'http: //www.gravatar.com/avatar/4852a41fb41616bf8f140d3701673f53?s=80\u0026d=identicon', + web_url: 'http: //localhost:3001/tajuana', + }, + { + name: 'Laureen Ritchie', + username: 'michaele.will', + id: 16, + state: 'active', + avatar_url: 'http: //www.gravatar.com/avatar/e301827eb03be955c9c172cb9a8e4e8a?s=80\u0026d=identicon', + web_url: 'http: //localhost:3001/michaele.will', + }, + ], + due_date: null, + moved_to_id: null, + project_id: 4, + weight: null, + milestone: { + id: 21, + iid: 1, + project_id: 4, + title: 'v0.0', + description: 'Molestiae commodi laboriosam odio sunt eaque reprehenderit.', + state: 'active', + created_at: '2017-02-02T21: 49: 30.530Z', + updated_at: '2017-02-02T21: 49: 30.530Z', + due_date: null, + start_date: null, + }, + labels: [], + }, + }, + 'PUT': { + '/gitlab-org/gitlab-shell/issues/5.json': { + data: {}, + }, + }, +}; + +export default { + mediator: { + endpoint: '/gitlab-org/gitlab-shell/issues/5.json', + editable: true, + currentUser: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }, + rootPath: '/', + }, + time: { + time_estimate: 3600, + total_time_spent: 0, + human_time_estimate: '1h', + human_total_time_spent: null, + }, + user: { + avatar: 'http://gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: 1, + name: 'Administrator', + username: 'root', + }, + + sidebarMockInterceptor(request, next) { + const body = sidebarMockData[request.method.toUpperCase()][request.url]; + + next(request.respondWith(JSON.stringify(body), { + status: 200, + })); + }, +}; diff --git a/spec/javascripts/sidebar/sidebar_assignees_spec.js b/spec/javascripts/sidebar/sidebar_assignees_spec.js new file mode 100644 index 0000000000000..e0df0a3228f9f --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_assignees_spec.js @@ -0,0 +1,45 @@ +import Vue from 'vue'; +import SidebarAssignees from '~/sidebar/components/assignees/sidebar_assignees'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import Mock from './mock_data'; + +describe('sidebar assignees', () => { + let component; + let SidebarAssigneeComponent; + preloadFixtures('issues/open-issue.html.raw'); + + beforeEach(() => { + Vue.http.interceptors.push(Mock.sidebarMockInterceptor); + SidebarAssigneeComponent = Vue.extend(SidebarAssignees); + spyOn(SidebarMediator.prototype, 'saveAssignees').and.callThrough(); + spyOn(SidebarMediator.prototype, 'assignYourself').and.callThrough(); + this.mediator = new SidebarMediator(Mock.mediator); + loadFixtures('issues/open-issue.html.raw'); + this.sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees'); + }); + + afterEach(() => { + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + }); + + it('calls the mediator when saves the assignees', () => { + component = new SidebarAssigneeComponent() + .$mount(this.sidebarAssigneesEl); + component.saveAssignees(); + + expect(SidebarMediator.prototype.saveAssignees).toHaveBeenCalled(); + }); + + it('calls the mediator when "assignSelf" method is called', () => { + component = new SidebarAssigneeComponent() + .$mount(this.sidebarAssigneesEl); + component.assignSelf(); + + expect(SidebarMediator.prototype.assignYourself).toHaveBeenCalled(); + expect(this.mediator.store.assignees.length).toEqual(1); + }); +}); diff --git a/spec/javascripts/sidebar/sidebar_bundle_spec.js b/spec/javascripts/sidebar/sidebar_bundle_spec.js new file mode 100644 index 0000000000000..7760b34e07198 --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_bundle_spec.js @@ -0,0 +1,42 @@ +import Vue from 'vue'; +import SidebarBundleDomContentLoaded from '~/sidebar/sidebar_bundle'; +import SidebarTimeTracking from '~/sidebar/components/time_tracking/sidebar_time_tracking'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import Mock from './mock_data'; + +describe('sidebar bundle', () => { + gl.sidebarOptions = Mock.mediator; + + beforeEach(() => { + spyOn(SidebarTimeTracking.methods, 'listenForSlashCommands').and.callFake(() => { }); + preloadFixtures('issues/open-issue.html.raw'); + Vue.http.interceptors.push(Mock.sidebarMockInterceptor); + loadFixtures('issues/open-issue.html.raw'); + spyOn(Vue.prototype, '$mount'); + SidebarBundleDomContentLoaded(); + this.mediator = new SidebarMediator(); + }); + + afterEach(() => { + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + }); + + it('the mediator should be already defined with some data', () => { + SidebarBundleDomContentLoaded(); + + expect(this.mediator.store).toBeDefined(); + expect(this.mediator.service).toBeDefined(); + expect(this.mediator.store.currentUser).toEqual(Mock.mediator.currentUser); + expect(this.mediator.store.rootPath).toEqual(Mock.mediator.rootPath); + expect(this.mediator.store.endPoint).toEqual(Mock.mediator.endPoint); + expect(this.mediator.store.editable).toEqual(Mock.mediator.editable); + }); + + it('the sidebar time tracking and assignees components to have been mounted', () => { + expect(Vue.prototype.$mount).toHaveBeenCalledTimes(2); + }); +}); diff --git a/spec/javascripts/sidebar/sidebar_mediator_spec.js b/spec/javascripts/sidebar/sidebar_mediator_spec.js new file mode 100644 index 0000000000000..2b00fa173346b --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_mediator_spec.js @@ -0,0 +1,40 @@ +import Vue from 'vue'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import Mock from './mock_data'; + +describe('Sidebar mediator', () => { + beforeEach(() => { + Vue.http.interceptors.push(Mock.sidebarMockInterceptor); + this.mediator = new SidebarMediator(Mock.mediator); + }); + + afterEach(() => { + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + }); + + it('assigns yourself ', () => { + this.mediator.assignYourself(); + + expect(this.mediator.store.currentUser).toEqual(Mock.mediator.currentUser); + expect(this.mediator.store.assignees[0]).toEqual(Mock.mediator.currentUser); + }); + + it('saves assignees', (done) => { + this.mediator.saveAssignees('issue[assignee_ids]') + .then((resp) => { + expect(resp.status).toEqual(200); + done(); + }) + .catch(() => {}); + }); + + it('fetches the data', () => { + spyOn(this.mediator.service, 'get').and.callThrough(); + this.mediator.fetch(); + expect(this.mediator.service.get).toHaveBeenCalled(); + }); +}); diff --git a/spec/javascripts/sidebar/sidebar_service_spec.js b/spec/javascripts/sidebar/sidebar_service_spec.js new file mode 100644 index 0000000000000..d41162096a68f --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_service_spec.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import Mock from './mock_data'; + +describe('Sidebar service', () => { + beforeEach(() => { + Vue.http.interceptors.push(Mock.sidebarMockInterceptor); + this.service = new SidebarService('/gitlab-org/gitlab-shell/issues/5.json'); + }); + + afterEach(() => { + SidebarService.singleton = null; + }); + + it('gets the data', (done) => { + this.service.get() + .then((resp) => { + expect(resp).toBeDefined(); + done(); + }) + .catch(() => {}); + }); + + it('updates the data', (done) => { + this.service.update('issue[assignee_ids]', [1]) + .then((resp) => { + expect(resp).toBeDefined(); + done(); + }) + .catch(() => {}); + }); +}); diff --git a/spec/javascripts/sidebar/sidebar_store_spec.js b/spec/javascripts/sidebar/sidebar_store_spec.js new file mode 100644 index 0000000000000..29facf483b54e --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_store_spec.js @@ -0,0 +1,80 @@ +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import Mock from './mock_data'; +import UsersMockHelper from '../helpers/user_mock_data_helper'; + +describe('Sidebar store', () => { + const assignee = { + id: 2, + name: 'gitlab user 2', + username: 'gitlab2', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }; + + const anotherAssignee = { + id: 3, + name: 'gitlab user 3', + username: 'gitlab3', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }; + + beforeEach(() => { + this.store = new SidebarStore({ + currentUser: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }, + editable: true, + rootPath: '/', + endpoint: '/gitlab-org/gitlab-shell/issues/5.json', + }); + }); + + afterEach(() => { + SidebarStore.singleton = null; + }); + + it('adds a new assignee', () => { + this.store.addAssignee(assignee); + expect(this.store.assignees.length).toEqual(1); + }); + + it('removes an assignee', () => { + this.store.removeAssignee(assignee); + expect(this.store.assignees.length).toEqual(0); + }); + + it('finds an existent assignee', () => { + let foundAssignee; + + this.store.addAssignee(assignee); + foundAssignee = this.store.findAssignee(assignee); + expect(foundAssignee).toBeDefined(); + expect(foundAssignee).toEqual(assignee); + foundAssignee = this.store.findAssignee(anotherAssignee); + expect(foundAssignee).toBeUndefined(); + }); + + it('removes all assignees', () => { + this.store.removeAllAssignees(); + expect(this.store.assignees.length).toEqual(0); + }); + + it('set assigned data', () => { + const users = { + assignees: UsersMockHelper.createNumberRandomUsers(3), + }; + + this.store.setAssigneeData(users); + expect(this.store.assignees.length).toEqual(3); + }); + + it('set time tracking data', () => { + this.store.setTimeTrackingData(Mock.time); + expect(this.store.timeEstimate).toEqual(Mock.time.time_estimate); + expect(this.store.totalTimeSpent).toEqual(Mock.time.total_time_spent); + expect(this.store.humanTimeEstimate).toEqual(Mock.time.human_time_estimate); + expect(this.store.humanTotalTimeSpent).toEqual(Mock.time.human_total_time_spent); + }); +}); diff --git a/spec/javascripts/signin_tabs_memoizer_spec.js b/spec/javascripts/signin_tabs_memoizer_spec.js index d83d9a57b4246..5b4f5933b34de 100644 --- a/spec/javascripts/signin_tabs_memoizer_spec.js +++ b/spec/javascripts/signin_tabs_memoizer_spec.js @@ -1,3 +1,5 @@ +import AccessorUtilities from '~/lib/utils/accessor'; + require('~/signin_tabs_memoizer'); ((global) => { @@ -19,6 +21,8 @@ require('~/signin_tabs_memoizer'); beforeEach(() => { loadFixtures(fixtureTemplate); + + spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.returnValue(true); }); it('does nothing if no tab was previously selected', () => { @@ -49,5 +53,91 @@ require('~/signin_tabs_memoizer'); expect(memo.readData()).toEqual('#standard'); }); + + describe('class constructor', () => { + beforeEach(() => { + memo = createMemoizer(); + }); + + it('should set .isLocalStorageAvailable', () => { + expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + expect(memo.isLocalStorageAvailable).toBe(true); + }); + }); + + describe('saveData', () => { + beforeEach(() => { + memo = { + currentTabKey, + }; + + spyOn(localStorage, 'setItem'); + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(function () { + memo.isLocalStorageAvailable = false; + + global.ActiveTabMemoizer.prototype.saveData.call(memo); + }); + + it('should not call .setItem', () => { + expect(localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + const value = 'value'; + + beforeEach(function () { + memo.isLocalStorageAvailable = true; + + global.ActiveTabMemoizer.prototype.saveData.call(memo, value); + }); + + it('should call .setItem', () => { + expect(localStorage.setItem).toHaveBeenCalledWith(currentTabKey, value); + }); + }); + }); + + describe('readData', () => { + const itemValue = 'itemValue'; + let readData; + + beforeEach(() => { + memo = { + currentTabKey, + }; + + spyOn(localStorage, 'getItem').and.returnValue(itemValue); + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(function () { + memo.isLocalStorageAvailable = false; + + readData = global.ActiveTabMemoizer.prototype.readData.call(memo); + }); + + it('should not call .getItem and should return `null`', () => { + expect(localStorage.getItem).not.toHaveBeenCalled(); + expect(readData).toBe(null); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + beforeEach(function () { + memo.isLocalStorageAvailable = true; + + readData = global.ActiveTabMemoizer.prototype.readData.call(memo); + }); + + it('should call .getItem and return the localStorage value', () => { + expect(window.localStorage.getItem).toHaveBeenCalledWith(currentTabKey); + expect(readData).toBe(itemValue); + }); + }); + }); }); })(window); diff --git a/spec/javascripts/subbable_resource_spec.js b/spec/javascripts/subbable_resource_spec.js deleted file mode 100644 index 454386697f54f..0000000000000 --- a/spec/javascripts/subbable_resource_spec.js +++ /dev/null @@ -1,63 +0,0 @@ -/* eslint-disable max-len, arrow-parens, comma-dangle */ - -require('~/subbable_resource'); - -/* -* Test that each rest verb calls the publish and subscribe function and passes the correct value back -* -* -* */ -((global) => { - describe('Subbable Resource', function () { - describe('PubSub', function () { - beforeEach(function () { - this.MockResource = new global.SubbableResource('https://example.com'); - }); - it('should successfully add a single subscriber', function () { - const callback = () => {}; - this.MockResource.subscribe(callback); - - expect(this.MockResource.subscribers.length).toBe(1); - expect(this.MockResource.subscribers[0]).toBe(callback); - }); - - it('should successfully add multiple subscribers', function () { - const callbackOne = () => {}; - const callbackTwo = () => {}; - const callbackThree = () => {}; - - this.MockResource.subscribe(callbackOne); - this.MockResource.subscribe(callbackTwo); - this.MockResource.subscribe(callbackThree); - - expect(this.MockResource.subscribers.length).toBe(3); - }); - - it('should successfully publish an update to a single subscriber', function () { - const state = { myprop: 1 }; - - const callbacks = { - one: (data) => expect(data.myprop).toBe(2), - two: (data) => expect(data.myprop).toBe(2), - three: (data) => expect(data.myprop).toBe(2) - }; - - const spyOne = spyOn(callbacks, 'one'); - const spyTwo = spyOn(callbacks, 'two'); - const spyThree = spyOn(callbacks, 'three'); - - this.MockResource.subscribe(callbacks.one); - this.MockResource.subscribe(callbacks.two); - this.MockResource.subscribe(callbacks.three); - - state.myprop += 1; - - this.MockResource.publish(state); - - expect(spyOne).toHaveBeenCalled(); - expect(spyTwo).toHaveBeenCalled(); - expect(spyThree).toHaveBeenCalled(); - }); - }); - }); -})(window.gl || (window.gl = {})); diff --git a/spec/javascripts/vue_shared/translate_spec.js b/spec/javascripts/vue_shared/translate_spec.js new file mode 100644 index 0000000000000..cbb3cbdff4675 --- /dev/null +++ b/spec/javascripts/vue_shared/translate_spec.js @@ -0,0 +1,90 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; + +Vue.use(Translate); + +describe('Vue translate filter', () => { + let el; + + beforeEach(() => { + el = document.createElement('div'); + + document.body.appendChild(el); + }); + + it('translate single text', (done) => { + const comp = new Vue({ + el, + template: ` + <span> + {{ __('testing') }} + </span> + `, + }).$mount(); + + Vue.nextTick(() => { + expect( + comp.$el.textContent.trim(), + ).toBe('testing'); + + done(); + }); + }); + + it('translate plural text with single count', (done) => { + const comp = new Vue({ + el, + template: ` + <span> + {{ n__('%d day', '%d days', 1) }} + </span> + `, + }).$mount(); + + Vue.nextTick(() => { + expect( + comp.$el.textContent.trim(), + ).toBe('1 day'); + + done(); + }); + }); + + it('translate plural text with multiple count', (done) => { + const comp = new Vue({ + el, + template: ` + <span> + {{ n__('%d day', '%d days', 2) }} + </span> + `, + }).$mount(); + + Vue.nextTick(() => { + expect( + comp.$el.textContent.trim(), + ).toBe('2 days'); + + done(); + }); + }); + + it('translate plural without replacing any text', (done) => { + const comp = new Vue({ + el, + template: ` + <span> + {{ n__('day', 'days', 2) }} + </span> + `, + }).$mount(); + + Vue.nextTick(() => { + expect( + comp.$el.textContent.trim(), + ).toBe('days'); + + done(); + }); + }); +}); diff --git a/spec/lib/banzai/filter/redactor_filter_spec.rb b/spec/lib/banzai/filter/redactor_filter_spec.rb index 8a6fe1ad6a39d..7c4a0f32c7b73 100644 --- a/spec/lib/banzai/filter/redactor_filter_spec.rb +++ b/spec/lib/banzai/filter/redactor_filter_spec.rb @@ -113,7 +113,7 @@ def reference_link(data) it 'allows references for assignee' do assignee = create(:user) project = create(:empty_project, :public) - issue = create(:issue, :confidential, project: project, assignee: assignee) + issue = create(:issue, :confidential, project: project, assignees: [assignee]) link = reference_link(project: project.id, issue: issue.id, reference_type: 'issue') doc = filter(link, current_user: assignee) diff --git a/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb b/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb index abc93e1b44a92..3b9056114674f 100644 --- a/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb +++ b/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb @@ -135,6 +135,17 @@ def entry(path) subject { |example| path(example).nodes } it { is_expected.to eq 4 } end + + describe '#blob' do + let(:file_entry) { |example| path(example) } + subject { file_entry.blob } + + it 'returns a blob representing the entry data' do + expect(subject).to be_a(Blob) + expect(subject.path).to eq(file_entry.path) + expect(subject.size).to eq(file_entry.metadata[:size]) + end + end end describe 'non-existent/', path: 'non-existent/' do diff --git a/spec/lib/gitlab/elastic/project_search_results_spec.rb b/spec/lib/gitlab/elastic/project_search_results_spec.rb index c97ba15c06fb8..f7d7ffec2f78e 100644 --- a/spec/lib/gitlab/elastic/project_search_results_spec.rb +++ b/spec/lib/gitlab/elastic/project_search_results_spec.rb @@ -100,7 +100,7 @@ let(:admin) { create(:admin) } let!(:issue) { create(:issue, project: project, title: 'Issue 1') } let!(:security_issue_1) { create(:issue, :confidential, project: project, title: 'Security issue 1', author: author) } - let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignee: assignee) } + let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignees: [assignee]) } before do Gitlab::Elastic::Helper.refresh_index diff --git a/spec/lib/gitlab/elastic/search_results_spec.rb b/spec/lib/gitlab/elastic/search_results_spec.rb index c98c91fe2f91b..a2819da397366 100644 --- a/spec/lib/gitlab/elastic/search_results_spec.rb +++ b/spec/lib/gitlab/elastic/search_results_spec.rb @@ -89,9 +89,9 @@ before do @issue = create(:issue, project: project_1, title: 'Issue 1', iid: 1) @security_issue_1 = create(:issue, :confidential, project: project_1, title: 'Security issue 1', author: author, iid: 2) - @security_issue_2 = create(:issue, :confidential, title: 'Security issue 2', project: project_1, assignee: assignee, iid: 3) + @security_issue_2 = create(:issue, :confidential, title: 'Security issue 2', project: project_1, assignees: [assignee], iid: 3) @security_issue_3 = create(:issue, :confidential, project: project_2, title: 'Security issue 3', author: author, iid: 1) - @security_issue_4 = create(:issue, :confidential, project: project_3, title: 'Security issue 4', assignee: assignee, iid: 1) + @security_issue_4 = create(:issue, :confidential, project: project_3, title: 'Security issue 4', assignees: [assignee], iid: 1) @security_issue_5 = create(:issue, :confidential, project: project_4, title: 'Security issue 5', iid: 1) Gitlab::Elastic::Helper.refresh_index diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index ddedb7c34438b..fea186fd4f457 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -1062,7 +1062,7 @@ def commit_files(commit) end it "allows ordering by date" do - expect_any_instance_of(Rugged::Walker).to receive(:sorting).with(Rugged::SORT_DATE) + expect_any_instance_of(Rugged::Walker).to receive(:sorting).with(Rugged::SORT_DATE | Rugged::SORT_TOPO) repository.find_commits(order: :date) end diff --git a/spec/lib/gitlab/github_import/issue_formatter_spec.rb b/spec/lib/gitlab/github_import/issue_formatter_spec.rb index f34d09f2c1d9a..a4089592cf215 100644 --- a/spec/lib/gitlab/github_import/issue_formatter_spec.rb +++ b/spec/lib/gitlab/github_import/issue_formatter_spec.rb @@ -43,7 +43,7 @@ description: "*Created by: octocat*\n\nI'm having a problem with this.", state: 'opened', author_id: project.creator_id, - assignee_id: nil, + assignee_ids: [], created_at: created_at, updated_at: updated_at } @@ -64,7 +64,7 @@ description: "*Created by: octocat*\n\nI'm having a problem with this.", state: 'closed', author_id: project.creator_id, - assignee_id: nil, + assignee_ids: [], created_at: created_at, updated_at: updated_at } @@ -77,19 +77,19 @@ let(:raw_data) { double(base_data.merge(assignee: octocat)) } it 'returns nil as assignee_id when is not a GitLab user' do - expect(issue.attributes.fetch(:assignee_id)).to be_nil + expect(issue.attributes.fetch(:assignee_ids)).to be_empty end it 'returns GitLab user id associated with GitHub id as assignee_id' do gl_user = create(:omniauth_user, extern_uid: octocat.id, provider: 'github') - expect(issue.attributes.fetch(:assignee_id)).to eq gl_user.id + expect(issue.attributes.fetch(:assignee_ids)).to eq [gl_user.id] end it 'returns GitLab user id associated with GitHub email as assignee_id' do gl_user = create(:user, email: octocat.email) - expect(issue.attributes.fetch(:assignee_id)).to eq gl_user.id + expect(issue.attributes.fetch(:assignee_ids)).to eq [gl_user.id] end end diff --git a/spec/lib/gitlab/google_code_import/importer_spec.rb b/spec/lib/gitlab/google_code_import/importer_spec.rb index ccaa88a5c798f..622a0f513f43a 100644 --- a/spec/lib/gitlab/google_code_import/importer_spec.rb +++ b/spec/lib/gitlab/google_code_import/importer_spec.rb @@ -49,7 +49,7 @@ expect(issue).not_to be_nil expect(issue.iid).to eq(169) expect(issue.author).to eq(project.creator) - expect(issue.assignee).to eq(mapped_user) + expect(issue.assignees).to eq([mapped_user]) expect(issue.state).to eq("closed") expect(issue.label_names).to include("Priority: Medium") expect(issue.label_names).to include("Status: Fixed") diff --git a/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb b/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb index 4cd8cf313a54a..45ccd3d6459d6 100644 --- a/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb +++ b/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb @@ -82,9 +82,9 @@ it { is_expected.to include(metric_class.new(:filesystem_readable, 0, shard: :default)) } it { is_expected.to include(metric_class.new(:filesystem_writable, 0, shard: :default)) } - it { is_expected.to include(have_attributes(name: :filesystem_access_latency, value: be > 0, labels: { shard: :default })) } - it { is_expected.to include(have_attributes(name: :filesystem_read_latency, value: be > 0, labels: { shard: :default })) } - it { is_expected.to include(have_attributes(name: :filesystem_write_latency, value: be > 0, labels: { shard: :default })) } + it { is_expected.to include(have_attributes(name: :filesystem_access_latency, value: be >= 0, labels: { shard: :default })) } + it { is_expected.to include(have_attributes(name: :filesystem_read_latency, value: be >= 0, labels: { shard: :default })) } + it { is_expected.to include(have_attributes(name: :filesystem_write_latency, value: be >= 0, labels: { shard: :default })) } end context 'storage points to directory that has both read and write rights' do @@ -96,9 +96,9 @@ it { is_expected.to include(metric_class.new(:filesystem_readable, 1, shard: :default)) } it { is_expected.to include(metric_class.new(:filesystem_writable, 1, shard: :default)) } - it { is_expected.to include(have_attributes(name: :filesystem_access_latency, value: be > 0, labels: { shard: :default })) } - it { is_expected.to include(have_attributes(name: :filesystem_read_latency, value: be > 0, labels: { shard: :default })) } - it { is_expected.to include(have_attributes(name: :filesystem_write_latency, value: be > 0, labels: { shard: :default })) } + it { is_expected.to include(have_attributes(name: :filesystem_access_latency, value: be >= 0, labels: { shard: :default })) } + it { is_expected.to include(have_attributes(name: :filesystem_read_latency, value: be >= 0, labels: { shard: :default })) } + it { is_expected.to include(have_attributes(name: :filesystem_write_latency, value: be >= 0, labels: { shard: :default })) } end end end diff --git a/spec/lib/gitlab/health_checks/simple_check_shared.rb b/spec/lib/gitlab/health_checks/simple_check_shared.rb index 1fa6d0faef983..3f871d6603442 100644 --- a/spec/lib/gitlab/health_checks/simple_check_shared.rb +++ b/spec/lib/gitlab/health_checks/simple_check_shared.rb @@ -8,7 +8,7 @@ it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_success", value: 1)) } it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_timeout", value: 0)) } - it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be > 0)) } + it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be >= 0)) } end context 'Check is misbehaving' do @@ -18,7 +18,7 @@ it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_success", value: 0)) } it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_timeout", value: 0)) } - it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be > 0)) } + it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be >= 0)) } end context 'Check is timeouting' do @@ -28,7 +28,7 @@ it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_success", value: 0)) } it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_timeout", value: 1)) } - it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be > 0)) } + it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be >= 0)) } end end diff --git a/spec/lib/gitlab/i18n_spec.rb b/spec/lib/gitlab/i18n_spec.rb new file mode 100644 index 0000000000000..52f2614d5cab6 --- /dev/null +++ b/spec/lib/gitlab/i18n_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +module Gitlab + describe I18n, lib: true do + let(:user) { create(:user, preferred_language: 'es') } + + describe '.set_locale' do + it 'sets the locale based on current user preferred language' do + Gitlab::I18n.set_locale(user) + + expect(FastGettext.locale).to eq('es') + expect(::I18n.locale).to eq(:es) + end + end + + describe '.reset_locale' do + it 'resets the locale to the default language' do + Gitlab::I18n.set_locale(user) + + Gitlab::I18n.reset_locale + + expect(FastGettext.locale).to eq('en') + expect(::I18n.locale).to eq(:en) + end + end + end +end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 99739b6b4af53..41d7c7fff27fc 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -3,12 +3,13 @@ issues: - subscriptions - award_emoji - author -- assignee +- assignees - updated_by - milestone - notes - label_links - labels +- last_edited_by - todos - user_agent_detail - moved_to @@ -16,6 +17,7 @@ issues: - merge_requests_closing_issues - metrics - timelogs +- issue_assignees events: - author - project @@ -26,6 +28,7 @@ notes: - noteable - author - updated_by +- last_edited_by - resolved_by - todos - events @@ -72,6 +75,7 @@ merge_requests: - notes - label_links - labels +- last_edited_by - todos - target_project - source_project diff --git a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb index 1035428b2e7d3..5aeb29b7fecb7 100644 --- a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb @@ -203,7 +203,7 @@ end def setup_project - issue = create(:issue, assignee: user) + issue = create(:issue, assignees: [user]) snippet = create(:project_snippet) release = create(:release) group = create(:group) diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index bb83f1f53f84a..f25722bc1c14b 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -24,6 +24,8 @@ Issue: - time_estimate - relative_position - service_desk_reply_to +- last_edited_at +- last_edited_by_id Event: - id - target_type @@ -158,6 +160,8 @@ MergeRequest: - rebase_commit_sha - time_estimate - squash +- last_edited_at +- last_edited_by_id MergeRequestDiff: - id - state diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index 6618ce49a9c7d..0bfa1d39d8b59 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -90,7 +90,7 @@ let(:project) { create(:empty_project, :internal) } let!(:issue) { create(:issue, project: project, title: 'Issue 1') } let!(:security_issue_1) { create(:issue, :confidential, project: project, title: 'Security issue 1', author: author) } - let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignee: assignee) } + let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignees: [assignee]) } it 'does not list project confidential issues for non project members' do results = described_class.new(non_member, project, query) diff --git a/spec/lib/gitlab/prometheus_spec.rb b/spec/lib/gitlab/prometheus_spec.rb index 280264188e2e2..fc453a2704b36 100644 --- a/spec/lib/gitlab/prometheus_spec.rb +++ b/spec/lib/gitlab/prometheus_spec.rb @@ -49,6 +49,36 @@ end end + describe 'failure to reach a provided prometheus url' do + let(:prometheus_url) {"https://prometheus.invalid.example.com"} + + context 'exceptions are raised' do + it 'raises a Gitlab::PrometheusError error when a SocketError is rescued' do + req_stub = stub_prometheus_request_with_exception(prometheus_url, SocketError) + + expect { subject.send(:get, prometheus_url) } + .to raise_error(Gitlab::PrometheusError, "Can't connect to #{prometheus_url}") + expect(req_stub).to have_been_requested + end + + it 'raises a Gitlab::PrometheusError error when a SSLError is rescued' do + req_stub = stub_prometheus_request_with_exception(prometheus_url, OpenSSL::SSL::SSLError) + + expect { subject.send(:get, prometheus_url) } + .to raise_error(Gitlab::PrometheusError, "#{prometheus_url} contains invalid SSL data") + expect(req_stub).to have_been_requested + end + + it 'raises a Gitlab::PrometheusError error when a HTTParty::Error is rescued' do + req_stub = stub_prometheus_request_with_exception(prometheus_url, HTTParty::Error) + + expect { subject.send(:get, prometheus_url) } + .to raise_error(Gitlab::PrometheusError, "Network connection error") + expect(req_stub).to have_been_requested + end + end + end + describe '#query' do let(:prometheus_query) { prometheus_cpu_query('env-slug') } let(:query_url) { prometheus_query_url(prometheus_query) } diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb index 847fb97740015..31c3cd4d53c8e 100644 --- a/spec/lib/gitlab/search_results_spec.rb +++ b/spec/lib/gitlab/search_results_spec.rb @@ -72,9 +72,9 @@ let(:admin) { create(:admin) } let!(:issue) { create(:issue, project: project_1, title: 'Issue 1') } let!(:security_issue_1) { create(:issue, :confidential, project: project_1, title: 'Security issue 1', author: author) } - let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project_1, assignee: assignee) } + let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project_1, assignees: [assignee]) } let!(:security_issue_3) { create(:issue, :confidential, project: project_2, title: 'Security issue 3', author: author) } - let!(:security_issue_4) { create(:issue, :confidential, project: project_3, title: 'Security issue 4', assignee: assignee) } + let!(:security_issue_4) { create(:issue, :confidential, project: project_3, title: 'Security issue 4', assignees: [assignee]) } let!(:security_issue_5) { create(:issue, :confidential, project: project_4, title: 'Security issue 5') } it 'does not list confidential issues for non project members' do diff --git a/spec/lib/gitlab/shell_spec.rb b/spec/lib/gitlab/shell_spec.rb index 7f037b1916b6d..22c69eeef2f02 100644 --- a/spec/lib/gitlab/shell_spec.rb +++ b/spec/lib/gitlab/shell_spec.rb @@ -147,4 +147,45 @@ end end end + + describe 'projects commands' do + let(:projects_path) { 'tmp/tests/shell-projects-test/bin/gitlab-projects' } + + before do + allow(Gitlab.config.gitlab_shell).to receive(:path).and_return('tmp/tests/shell-projects-test') + allow(Gitlab.config.gitlab_shell).to receive(:git_timeout).and_return(800) + end + + describe '#fetch_remote' do + it 'returns true when the command succeeds' do + expect(Gitlab::Popen).to receive(:popen) + .with([projects_path, 'fetch-remote', 'current/storage', 'project/path.git', 'new/storage', '800']).and_return([nil, 0]) + + expect(gitlab_shell.fetch_remote('current/storage', 'project/path', 'new/storage')).to be true + end + + it 'raises an exception when the command fails' do + expect(Gitlab::Popen).to receive(:popen) + .with([projects_path, 'fetch-remote', 'current/storage', 'project/path.git', 'new/storage', '800']).and_return(["error", 1]) + + expect { gitlab_shell.fetch_remote('current/storage', 'project/path', 'new/storage') }.to raise_error(Gitlab::Shell::Error, "error") + end + end + + describe '#import_repository' do + it 'returns true when the command succeeds' do + expect(Gitlab::Popen).to receive(:popen) + .with([projects_path, 'import-project', 'current/storage', 'project/path.git', 'https://gitlab.com/gitlab-org/gitlab-ce.git', "800"]).and_return([nil, 0]) + + expect(gitlab_shell.import_repository('current/storage', 'project/path', 'https://gitlab.com/gitlab-org/gitlab-ce.git')).to be true + end + + it 'raises an exception when the command fails' do + expect(Gitlab::Popen).to receive(:popen) + .with([projects_path, 'import-project', 'current/storage', 'project/path.git', 'https://gitlab.com/gitlab-org/gitlab-ce.git', "800"]).and_return(["error", 1]) + + expect { gitlab_shell.import_repository('current/storage', 'project/path', 'https://gitlab.com/gitlab-org/gitlab-ce.git') }.to raise_error(Gitlab::Shell::Error, "error") + end + end + end end diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 5c89d001c8d22..80e8440b35d4d 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -36,11 +36,11 @@ def have_referable_subject(referable, reply: false) end context 'for issues' do - let(:issue) { create(:issue, author: current_user, assignee: assignee, project: project) } - let(:issue_with_description) { create(:issue, author: current_user, assignee: assignee, project: project, description: 'My awesome description') } + let(:issue) { create(:issue, author: current_user, assignees: [assignee], project: project) } + let(:issue_with_description) { create(:issue, author: current_user, assignees: [assignee], project: project, description: 'My awesome description') } describe 'that are new' do - subject { described_class.new_issue_email(issue.assignee_id, issue.id) } + subject { described_class.new_issue_email(issue.assignees.first.id, issue.id) } it_behaves_like 'an assignee email' it_behaves_like 'an email starting a new thread with reply-by-email enabled' do @@ -69,7 +69,7 @@ def have_referable_subject(referable, reply: false) end describe 'that are new with a description' do - subject { described_class.new_issue_email(issue_with_description.assignee_id, issue_with_description.id) } + subject { described_class.new_issue_email(issue_with_description.assignees.first.id, issue_with_description.id) } it_behaves_like 'it should show Gmail Actions View Issue link' @@ -79,7 +79,7 @@ def have_referable_subject(referable, reply: false) end describe 'that have been reassigned' do - subject { described_class.reassigned_issue_email(recipient.id, issue.id, previous_assignee.id, current_user.id) } + subject { described_class.reassigned_issue_email(recipient.id, issue.id, [previous_assignee.id], current_user.id) } it_behaves_like 'a multiple recipients email' it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do diff --git a/spec/models/ci/artifact_blob_spec.rb b/spec/models/ci/artifact_blob_spec.rb new file mode 100644 index 0000000000000..968593d7e9bef --- /dev/null +++ b/spec/models/ci/artifact_blob_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +describe Ci::ArtifactBlob, models: true do + let(:build) { create(:ci_build, :artifacts) } + let(:entry) { build.artifacts_metadata_entry('other_artifacts_0.1.2/another-subdirectory/banana_sample.gif') } + + subject { described_class.new(entry) } + + describe '#id' do + it 'returns a hash of the path' do + expect(subject.id).to eq(Digest::SHA1.hexdigest(entry.path)) + end + end + + describe '#name' do + it 'returns the entry name' do + expect(subject.name).to eq(entry.name) + end + end + + describe '#path' do + it 'returns the entry path' do + expect(subject.path).to eq(entry.path) + end + end + + describe '#size' do + it 'returns the entry size' do + expect(subject.size).to eq(entry.metadata[:size]) + end + end + + describe '#mode' do + it 'returns the entry mode' do + expect(subject.mode).to eq(entry.metadata[:mode]) + end + end + + describe '#external_storage' do + it 'returns :build_artifact' do + expect(subject.external_storage).to eq(:build_artifact) + end + end +end diff --git a/spec/models/concerns/elastic/issue_spec.rb b/spec/models/concerns/elastic/issue_spec.rb index f7c6861c1cd1c..9995ab255a81c 100644 --- a/spec/models/concerns/elastic/issue_spec.rb +++ b/spec/models/concerns/elastic/issue_spec.rb @@ -31,11 +31,14 @@ end it "returns json with all needed elements" do - issue = create :issue, project: project + assignee = create(:user) + issue = create :issue, project: project, assignees: [assignee] expected_hash = issue.attributes.extract!('id', 'iid', 'title', 'description', 'created_at', 'updated_at', 'state', 'project_id', 'author_id', - 'assignee_id', 'confidential') + 'confidential') + + expected_hash['assignee_id'] = [assignee.id] expect(issue.as_indexed_json).to eq(expected_hash) end diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index 37d7513b3ead7..46e0fd78d702a 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -10,7 +10,6 @@ it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:author) } - it { is_expected.to belong_to(:assignee) } it { is_expected.to have_many(:notes).dependent(:destroy) } it { is_expected.to have_many(:todos).dependent(:destroy) } @@ -66,60 +65,6 @@ end end - describe 'assignee_name' do - it 'is delegated to assignee' do - issue.update!(assignee: create(:user)) - - expect(issue.assignee_name).to eq issue.assignee.name - end - - it 'returns nil when assignee is nil' do - issue.assignee_id = nil - issue.save(validate: false) - - expect(issue.assignee_name).to eq nil - end - end - - describe "before_save" do - describe "#update_cache_counts" do - context "when previous assignee exists" do - before do - assignee = create(:user) - issue.project.team << [assignee, :developer] - issue.update(assignee: assignee) - end - - it "updates cache counts for new assignee" do - user = create(:user) - - expect(user).to receive(:update_cache_counts) - - issue.update(assignee: user) - end - - it "updates cache counts for previous assignee" do - old_assignee = issue.assignee - allow(User).to receive(:find_by_id).with(old_assignee.id).and_return(old_assignee) - - expect(old_assignee).to receive(:update_cache_counts) - - issue.update(assignee: nil) - end - end - - context "when previous assignee does not exist" do - before{ issue.update(assignee: nil) } - - it "updates cache count for the new assignee" do - expect_any_instance_of(User).to receive(:update_cache_counts) - - issue.update(assignee: user) - end - end - end - end - describe ".search" do let!(:searchable_issue) { create(:issue, title: "Searchable issue") } @@ -324,7 +269,20 @@ end context "issue is assigned" do - before { issue.update_attribute(:assignee, user) } + before { issue.assignees << user } + + it "returns correct hook data" do + expect(data[:assignees].first).to eq(user.hook_attrs) + end + end + + context "merge_request is assigned" do + let(:merge_request) { create(:merge_request) } + let(:data) { merge_request.to_hook_data(user) } + + before do + merge_request.update_attribute(:assignee, user) + end it "returns correct hook data" do expect(data[:object_attributes]['assignee_id']).to eq(user.id) @@ -346,24 +304,6 @@ include_examples 'deprecated repository hook data' end - describe '#card_attributes' do - it 'includes the author name' do - allow(issue).to receive(:author).and_return(double(name: 'Robert')) - allow(issue).to receive(:assignee).and_return(nil) - - expect(issue.card_attributes). - to eq({ 'Author' => 'Robert', 'Assignee' => nil }) - end - - it 'includes the assignee name' do - allow(issue).to receive(:author).and_return(double(name: 'Robert')) - allow(issue).to receive(:assignee).and_return(double(name: 'Douwe')) - - expect(issue.card_attributes). - to eq({ 'Author' => 'Robert', 'Assignee' => 'Douwe' }) - end - end - describe '#labels_array' do let(:project) { create(:empty_project) } let(:bug) { create(:label, project: project, title: 'bug') } @@ -502,27 +442,6 @@ def create_issue(milestone, labels) end end - describe '#assignee_or_author?' do - let(:user) { build(:user, id: 1) } - let(:issue) { build(:issue) } - - it 'returns true for a user that is assigned to an issue' do - issue.assignee = user - - expect(issue.assignee_or_author?(user)).to eq(true) - end - - it 'returns true for a user that is the author of an issue' do - issue.author = user - - expect(issue.assignee_or_author?(user)).to eq(true) - end - - it 'returns false for a user that is not the assignee or author' do - expect(issue.assignee_or_author?(user)).to eq(false) - end - end - describe '#spend_time' do let(:user) { create(:user) } let(:issue) { create(:issue) } diff --git a/spec/models/concerns/milestoneish_spec.rb b/spec/models/concerns/milestoneish_spec.rb index 68e4c0a522bc8..675b730c5575a 100644 --- a/spec/models/concerns/milestoneish_spec.rb +++ b/spec/models/concerns/milestoneish_spec.rb @@ -11,13 +11,13 @@ let(:milestone) { create(:milestone, project: project) } let!(:issue) { create(:issue, project: project, milestone: milestone) } let!(:security_issue_1) { create(:issue, :confidential, project: project, author: author, milestone: milestone) } - let!(:security_issue_2) { create(:issue, :confidential, project: project, assignee: assignee, milestone: milestone) } + let!(:security_issue_2) { create(:issue, :confidential, project: project, assignees: [assignee], milestone: milestone) } let!(:closed_issue_1) { create(:issue, :closed, project: project, milestone: milestone) } let!(:closed_issue_2) { create(:issue, :closed, project: project, milestone: milestone) } let!(:closed_security_issue_1) { create(:issue, :confidential, :closed, project: project, author: author, milestone: milestone) } - let!(:closed_security_issue_2) { create(:issue, :confidential, :closed, project: project, assignee: assignee, milestone: milestone) } + let!(:closed_security_issue_2) { create(:issue, :confidential, :closed, project: project, assignees: [assignee], milestone: milestone) } let!(:closed_security_issue_3) { create(:issue, :confidential, :closed, project: project, author: author, milestone: milestone) } - let!(:closed_security_issue_4) { create(:issue, :confidential, :closed, project: project, assignee: assignee, milestone: milestone) } + let!(:closed_security_issue_4) { create(:issue, :confidential, :closed, project: project, assignees: [assignee], milestone: milestone) } let!(:merge_request) { create(:merge_request, source_project: project, target_project: project, milestone: milestone) } before do diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index a9c5b604268cd..b8cb967c4cc16 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -118,8 +118,8 @@ let(:author) { create(:author) } let(:assignee) { create(:user) } let(:admin) { create(:admin) } - let(:issue) { create(:issue, project: project, author: author, assignee: assignee) } - let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee) } + let(:issue) { create(:issue, project: project, author: author, assignees: [assignee]) } + let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee]) } let(:note_on_commit) { create(:note_on_commit, project: project) } let(:note_on_issue) { create(:note_on_issue, noteable: issue, project: project) } let(:note_on_confidential_issue) { create(:note_on_issue, noteable: confidential_issue, project: project) } diff --git a/spec/models/issue_collection_spec.rb b/spec/models/issue_collection_spec.rb index d8aed25c041f5..93c2c538e1056 100644 --- a/spec/models/issue_collection_spec.rb +++ b/spec/models/issue_collection_spec.rb @@ -28,7 +28,7 @@ end it 'returns the issues the user is assigned to' do - issue1.assignee = user + issue1.assignees << user expect(collection.updatable_by_user(user)).to eq([issue1]) end diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 8748b98a4e357..725f5c2311fd3 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -3,6 +3,7 @@ describe Issue, models: true do describe "Associations" do it { is_expected.to belong_to(:milestone) } + it { is_expected.to have_many(:assignees) } end describe 'modules' do @@ -37,6 +38,64 @@ end end + describe "before_save" do + describe "#update_cache_counts when an issue is reassigned" do + let(:issue) { create(:issue) } + let(:assignee) { create(:user) } + + context "when previous assignee exists" do + before do + issue.project.team << [assignee, :developer] + issue.assignees << assignee + end + + it "updates cache counts for new assignee" do + user = create(:user) + + expect(user).to receive(:update_cache_counts) + + issue.assignees << user + end + + it "updates cache counts for previous assignee" do + issue.assignees.first + + expect_any_instance_of(User).to receive(:update_cache_counts) + + issue.assignees.destroy_all + end + end + + context "when previous assignee does not exist" do + it "updates cache count for the new assignee" do + issue.assignees = [] + + expect_any_instance_of(User).to receive(:update_cache_counts) + + issue.assignees << assignee + end + end + end + end + + describe '#card_attributes' do + it 'includes the author name' do + allow(subject).to receive(:author).and_return(double(name: 'Robert')) + allow(subject).to receive(:assignees).and_return([]) + + expect(subject.card_attributes). + to eq({ 'Author' => 'Robert', 'Assignee' => '' }) + end + + it 'includes the assignee name' do + allow(subject).to receive(:author).and_return(double(name: 'Robert')) + allow(subject).to receive(:assignees).and_return([double(name: 'Douwe')]) + + expect(subject.card_attributes). + to eq({ 'Author' => 'Robert', 'Assignee' => 'Douwe' }) + end + end + describe '#closed_at' do after do Timecop.return @@ -124,13 +183,24 @@ end end - describe '#is_being_reassigned?' do - it 'returns true if the issue assignee has changed' do - subject.assignee = create(:user) - expect(subject.is_being_reassigned?).to be_truthy + describe '#assignee_or_author?' do + let(:user) { create(:user) } + let(:issue) { create(:issue) } + + it 'returns true for a user that is assigned to an issue' do + issue.assignees << user + + expect(issue.assignee_or_author?(user)).to be_truthy end - it 'returns false if the issue assignee has not changed' do - expect(subject.is_being_reassigned?).to be_falsey + + it 'returns true for a user that is the author of an issue' do + issue.update(author: user) + + expect(issue.assignee_or_author?(user)).to be_truthy + end + + it 'returns false for a user that is not the assignee or author' do + expect(issue.assignee_or_author?(user)).to be_falsey end end @@ -383,14 +453,14 @@ user1 = create(:user) user2 = create(:user) project = create(:empty_project) - issue = create(:issue, assignee: user1, project: project) + issue = create(:issue, assignees: [user1], project: project) project.add_developer(user1) project.add_developer(user2) expect(user1.assigned_open_issues_count).to eq(1) expect(user2.assigned_open_issues_count).to eq(0) - issue.assignee = user2 + issue.assignees = [user2] issue.save expect(user1.assigned_open_issues_count).to eq(0) @@ -676,6 +746,11 @@ expect(attrs_hash).to include(:human_total_time_spent) expect(attrs_hash).to include('time_estimate') end + + it 'includes assignee_ids and deprecated assignee_id' do + expect(attrs_hash).to include(:assignee_id) + expect(attrs_hash).to include(:assignee_ids) + end end describe '#check_for_spam' do diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 49568109a4c42..dad4e923bc682 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -9,6 +9,7 @@ it { is_expected.to belong_to(:target_project).class_name('Project') } it { is_expected.to belong_to(:source_project).class_name('Project') } it { is_expected.to belong_to(:merge_user).class_name("User") } + it { is_expected.to belong_to(:assignee) } it { is_expected.to have_many(:merge_request_diffs).dependent(:destroy) } it { is_expected.to have_many(:approver_groups).dependent(:destroy) } end @@ -87,6 +88,86 @@ end end + describe "before_save" do + describe "#update_cache_counts when a merge request is reassigned" do + let(:project) { create :project } + let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } + let(:assignee) { create :user } + + context "when previous assignee exists" do + before do + project.team << [assignee, :developer] + merge_request.update(assignee: assignee) + end + + it "updates cache counts for new assignee" do + user = create(:user) + + expect(user).to receive(:update_cache_counts) + + merge_request.update(assignee: user) + end + + it "updates cache counts for previous assignee" do + old_assignee = merge_request.assignee + allow(User).to receive(:find_by_id).with(old_assignee.id).and_return(old_assignee) + + expect(old_assignee).to receive(:update_cache_counts) + + merge_request.update(assignee: nil) + end + end + + context "when previous assignee does not exist" do + it "updates cache count for the new assignee" do + merge_request.update(assignee: nil) + + expect_any_instance_of(User).to receive(:update_cache_counts) + + merge_request.update(assignee: assignee) + end + end + end + end + + describe '#card_attributes' do + it 'includes the author name' do + allow(subject).to receive(:author).and_return(double(name: 'Robert')) + allow(subject).to receive(:assignee).and_return(nil) + + expect(subject.card_attributes). + to eq({ 'Author' => 'Robert', 'Assignee' => nil }) + end + + it 'includes the assignee name' do + allow(subject).to receive(:author).and_return(double(name: 'Robert')) + allow(subject).to receive(:assignee).and_return(double(name: 'Douwe')) + + expect(subject.card_attributes). + to eq({ 'Author' => 'Robert', 'Assignee' => 'Douwe' }) + end + end + + describe '#assignee_or_author?' do + let(:user) { create(:user) } + + it 'returns true for a user that is assigned to a merge request' do + subject.assignee = user + + expect(subject.assignee_or_author?(user)).to eq(true) + end + + it 'returns true for a user that is the author of a merge request' do + subject.author = user + + expect(subject.assignee_or_author?(user)).to eq(true) + end + + it 'returns false for a user that is not the assignee or author' do + expect(subject.assignee_or_author?(user)).to eq(false) + end + end + describe '#cache_merge_request_closes_issues!' do before do subject.project.team << [subject.author, :developer] @@ -296,16 +377,6 @@ def set_compare(merge_request) end end - describe '#is_being_reassigned?' do - it 'returns true if the merge_request assignee has changed' do - subject.assignee = create(:user) - expect(subject.is_being_reassigned?).to be_truthy - end - it 'returns false if the merge request assignee has not changed' do - expect(subject.is_being_reassigned?).to be_falsey - end - end - describe '#for_fork?' do it 'returns true if the merge request is for a fork' do subject.source_project = build_stubbed(:empty_project, namespace: create(:group)) diff --git a/spec/models/network/graph_spec.rb b/spec/models/network/graph_spec.rb index 46b36e11c230f..0fe8a591a4557 100644 --- a/spec/models/network/graph_spec.rb +++ b/spec/models/network/graph_spec.rb @@ -10,17 +10,17 @@ expect(graph.notes).to eq( { note_on_commit.commit_id => 1 } ) end - describe "#commits" do + describe '#commits' do let(:graph) { described_class.new(project, 'refs/heads/master', project.repository.commit, nil) } - it "returns a list of commits" do + it 'returns a list of commits' do commits = graph.commits expect(commits).not_to be_empty expect(commits).to all( be_kind_of(Network::Commit) ) end - it "sorts the commits by commit date (descending)" do + it 'it the commits by commit date (descending)' do # Remove duplicate timestamps because they make it harder to # assert that the commits are sorted as expected. commits = graph.commits.uniq(&:date) @@ -29,5 +29,20 @@ expect(commits).not_to be_empty expect(commits.map(&:id)).to eq(sorted_commits.map(&:id)) end + + it 'sorts children before parents for commits with the same timestamp' do + commits_by_time = graph.commits.group_by(&:date) + + commits_by_time.each do |time, commits| + commit_ids = commits.map(&:id) + + commits.each_with_index do |commit, index| + parent_indexes = commit.parent_ids.map { |parent_id| commit_ids.find_index(parent_id) }.compact + + # All parents of the current commit should appear after it + expect(parent_indexes).to all( be > index ) + end + end + end end end diff --git a/spec/models/project_services/prometheus_service_spec.rb b/spec/models/project_services/prometheus_service_spec.rb index d15079b686be9..f3126bc1e570b 100644 --- a/spec/models/project_services/prometheus_service_spec.rb +++ b/spec/models/project_services/prometheus_service_spec.rb @@ -94,7 +94,7 @@ [404, 500].each do |status| context "when Prometheus responds with #{status}" do before do - stub_all_prometheus_requests(environment.slug, status: status, body: 'QUERY FAILED!') + stub_all_prometheus_requests(environment.slug, status: status, body: "QUERY FAILED!") end it { is_expected.to eq(success: false, result: %(#{status} - "QUERY FAILED!")) } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 0c01b5773e171..e09f3b4f6cd31 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1930,4 +1930,12 @@ def add_user(access) expect(User.active.count).to eq(1) end end + + describe 'preferred language' do + it 'is English by default' do + user = create(:user) + + expect(user.preferred_language).to eq('en') + end + end end diff --git a/spec/policies/issue_policy_spec.rb b/spec/policies/issue_policy_spec.rb index 9a870b7fda12f..4a07c864428c6 100644 --- a/spec/policies/issue_policy_spec.rb +++ b/spec/policies/issue_policy_spec.rb @@ -15,7 +15,7 @@ def permissions(user, issue) context 'a private project' do let(:non_member) { create(:user) } let(:project) { create(:empty_project, :private) } - let(:issue) { create(:issue, project: project, assignee: assignee, author: author) } + let(:issue) { create(:issue, project: project, assignees: [assignee], author: author) } let(:issue_no_assignee) { create(:issue, project: project) } before do @@ -69,7 +69,7 @@ def permissions(user, issue) end context 'with confidential issues' do - let(:confidential_issue) { create(:issue, :confidential, project: project, assignee: assignee, author: author) } + let(:confidential_issue) { create(:issue, :confidential, project: project, assignees: [assignee], author: author) } let(:confidential_issue_no_assignee) { create(:issue, :confidential, project: project) } it 'does not allow non-members to read confidential issues' do @@ -110,7 +110,7 @@ def permissions(user, issue) context 'a public project' do let(:project) { create(:empty_project, :public) } - let(:issue) { create(:issue, project: project, assignee: assignee, author: author) } + let(:issue) { create(:issue, project: project, assignees: [assignee], author: author) } let(:issue_no_assignee) { create(:issue, project: project) } before do @@ -157,7 +157,7 @@ def permissions(user, issue) end context 'with confidential issues' do - let(:confidential_issue) { create(:issue, :confidential, project: project, assignee: assignee, author: author) } + let(:confidential_issue) { create(:issue, :confidential, project: project, assignees: [assignee], author: author) } let(:confidential_issue_no_assignee) { create(:issue, :confidential, project: project) } it 'does not allow guests to read confidential issues' do diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index c28587a785b55..619680f211351 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -19,7 +19,7 @@ let!(:closed_issue) do create :closed_issue, author: user, - assignee: user, + assignees: [user], project: project, state: :closed, milestone: milestone, @@ -31,14 +31,14 @@ :confidential, project: project, author: author, - assignee: assignee, + assignees: [assignee], created_at: generate(:past_time), updated_at: 2.hours.ago end let!(:issue) do create :issue, author: user, - assignee: user, + assignees: [user], project: project, milestone: milestone, created_at: generate(:past_time), @@ -265,7 +265,7 @@ let!(:group_closed_issue) do create :closed_issue, author: user, - assignee: user, + assignees: [user], project: group_project, state: :closed, milestone: group_milestone, @@ -276,13 +276,13 @@ :confidential, project: group_project, author: author, - assignee: assignee, + assignees: [assignee], updated_at: 2.hours.ago end let!(:group_issue) do create :issue, author: user, - assignee: user, + assignees: [user], project: group_project, milestone: group_milestone, updated_at: 1.hour.ago, @@ -687,6 +687,7 @@ expect(json_response['updated_at']).to be_present expect(json_response['labels']).to eq(issue.label_names) expect(json_response['milestone']).to be_a Hash + expect(json_response['assignees']).to be_a Array expect(json_response['assignee']).to be_a Hash expect(json_response['author']).to be_a Hash expect(json_response['confidential']).to be_falsy @@ -760,9 +761,22 @@ end describe "POST /projects/:id/issues" do + context 'support for deprecated assignee_id' do + it 'creates a new project issue' do + post api("/projects/#{project.id}/issues", user), + title: 'new issue', assignee_id: user2.id + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['assignee']['name']).to eq(user2.name) + expect(json_response['assignees'].first['name']).to eq(user2.name) + end + end + it 'creates a new project issue' do post api("/projects/#{project.id}/issues", user), - title: 'new issue', labels: 'label, label2', weight: 3 + title: 'new issue', labels: 'label, label2', weight: 3, + assignee_ids: [user2.id] expect(response).to have_http_status(201) expect(json_response['title']).to eq('new issue') @@ -770,6 +784,8 @@ expect(json_response['labels']).to eq(%w(label label2)) expect(json_response['confidential']).to be_falsy expect(json_response['weight']).to eq(3) + expect(json_response['assignee']['name']).to eq(user2.name) + expect(json_response['assignees'].first['name']).to eq(user2.name) end it 'creates a new confidential project issue' do @@ -1059,6 +1075,46 @@ end end + describe 'PUT /projects/:id/issues/:issue_iid to update assignee' do + context 'support for deprecated assignee_id' do + it 'removes assignee' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + assignee_id: 0 + + expect(response).to have_http_status(200) + + expect(json_response['assignee']).to be_nil + end + + it 'updates an issue with new assignee' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + assignee_id: user2.id + + expect(response).to have_http_status(200) + + expect(json_response['assignee']['name']).to eq(user2.name) + end + end + + it 'removes assignee' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + assignee_ids: [0] + + expect(response).to have_http_status(200) + + expect(json_response['assignees']).to be_empty + end + + it 'updates an issue with new assignee' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + assignee_ids: [user2.id] + + expect(response).to have_http_status(200) + + expect(json_response['assignees'].first['name']).to eq(user2.name) + end + end + describe 'PUT /projects/:id/issues/:issue_iid to update labels' do let!(:label) { create(:label, title: 'dummy', project: project) } let!(:label_link) { create(:label_link, label: label, target: issue) } diff --git a/spec/requests/api/v3/issues_spec.rb b/spec/requests/api/v3/issues_spec.rb index 2be1c8e29c705..c91a370722bed 100644 --- a/spec/requests/api/v3/issues_spec.rb +++ b/spec/requests/api/v3/issues_spec.rb @@ -14,7 +14,7 @@ let!(:closed_issue) do create :closed_issue, author: user, - assignee: user, + assignees: [user], project: project, state: :closed, milestone: milestone, @@ -26,14 +26,14 @@ :confidential, project: project, author: author, - assignee: assignee, + assignees: [assignee], created_at: generate(:past_time), updated_at: 2.hours.ago end let!(:issue) do create :issue, author: user, - assignee: user, + assignees: [user], project: project, milestone: milestone, created_at: generate(:past_time), @@ -247,7 +247,7 @@ let!(:group_closed_issue) do create :closed_issue, author: user, - assignee: user, + assignees: [user], project: group_project, state: :closed, milestone: group_milestone, @@ -258,13 +258,13 @@ :confidential, project: group_project, author: author, - assignee: assignee, + assignees: [assignee], updated_at: 2.hours.ago end let!(:group_issue) do create :issue, author: user, - assignee: user, + assignees: [user], project: group_project, milestone: group_milestone, updated_at: 1.hour.ago @@ -738,7 +738,7 @@ describe "POST /projects/:id/issues" do it 'creates a new project issue' do post v3_api("/projects/#{project.id}/issues", user), - title: 'new issue', labels: 'label, label2', weight: 3 + title: 'new issue', labels: 'label, label2', weight: 3, assignee_id: assignee.id expect(response).to have_http_status(201) expect(json_response['title']).to eq('new issue') @@ -746,6 +746,7 @@ expect(json_response['labels']).to eq(%w(label label2)) expect(json_response['confidential']).to be_falsy expect(json_response['weight']).to eq(3) + expect(json_response['assignee']['name']).to eq(assignee.name) end it 'creates a new confidential project issue' do @@ -1142,6 +1143,22 @@ end end + describe 'PUT /projects/:id/issues/:issue_id to update assignee' do + it 'updates an issue with no assignee' do + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), assignee_id: 0 + + expect(response).to have_http_status(200) + expect(json_response['assignee']).to eq(nil) + end + + it 'updates an issue with assignee' do + put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), assignee_id: user2.id + + expect(response).to have_http_status(200) + expect(json_response['assignee']['name']).to eq(user2.name) + end + end + describe 'PUT /projects/:id/issues/:issue_id to update weight' do it 'updates an issue with no weight' do put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), weight: 5 diff --git a/spec/requests/projects/artifacts_controller_spec.rb b/spec/requests/projects/artifacts_controller_spec.rb deleted file mode 100644 index d20866c0d44d4..0000000000000 --- a/spec/requests/projects/artifacts_controller_spec.rb +++ /dev/null @@ -1,117 +0,0 @@ -require 'spec_helper' - -describe Projects::ArtifactsController do - let(:user) { create(:user) } - let(:project) { create(:project, :repository) } - - let(:pipeline) do - create(:ci_pipeline, - project: project, - sha: project.commit.sha, - ref: project.default_branch, - status: 'success') - end - - let(:build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) } - - describe 'GET /:project/builds/artifacts/:ref_name/browse?job=name' do - before do - project.team << [user, :developer] - - login_as(user) - end - - def path_from_ref( - ref = pipeline.ref, job = build.name, path = 'browse') - latest_succeeded_namespace_project_artifacts_path( - project.namespace, - project, - [ref, path].join('/'), - job: job) - end - - context 'cannot find the build' do - shared_examples 'not found' do - it { expect(response).to have_http_status(:not_found) } - end - - context 'has no such ref' do - before do - get path_from_ref('TAIL', build.name) - end - - it_behaves_like 'not found' - end - - context 'has no such build' do - before do - get path_from_ref(pipeline.ref, 'NOBUILD') - end - - it_behaves_like 'not found' - end - - context 'has no path' do - before do - get path_from_ref(pipeline.sha, build.name, '') - end - - it_behaves_like 'not found' - end - end - - context 'found the build and redirect' do - shared_examples 'redirect to the build' do - it 'redirects' do - path = browse_namespace_project_build_artifacts_path( - project.namespace, - project, - build) - - expect(response).to redirect_to(path) - end - end - - context 'with regular branch' do - before do - pipeline.update(ref: 'master', - sha: project.commit('master').sha) - - get path_from_ref('master') - end - - it_behaves_like 'redirect to the build' - end - - context 'with branch name containing slash' do - before do - pipeline.update(ref: 'improve/awesome', - sha: project.commit('improve/awesome').sha) - - get path_from_ref('improve/awesome') - end - - it_behaves_like 'redirect to the build' - end - - context 'with branch name and path containing slashes' do - before do - pipeline.update(ref: 'improve/awesome', - sha: project.commit('improve/awesome').sha) - - get path_from_ref('improve/awesome', build.name, 'file/README.md') - end - - it 'redirects' do - path = file_namespace_project_build_artifacts_path( - project.namespace, - project, - build, - 'README.md') - - expect(response).to redirect_to(path) - end - end - end - end -end diff --git a/spec/serializers/deploy_key_entity_spec.rb b/spec/serializers/deploy_key_entity_spec.rb new file mode 100644 index 0000000000000..e73fbe190ca18 --- /dev/null +++ b/spec/serializers/deploy_key_entity_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe DeployKeyEntity do + include RequestAwareEntity + + let(:user) { create(:user) } + let(:project) { create(:empty_project, :internal)} + let(:project_private) { create(:empty_project, :private)} + let(:deploy_key) { create(:deploy_key) } + let!(:deploy_key_internal) { create(:deploy_keys_project, project: project, deploy_key: deploy_key) } + let!(:deploy_key_private) { create(:deploy_keys_project, project: project_private, deploy_key: deploy_key) } + + let(:entity) { described_class.new(deploy_key, user: user) } + + it 'returns deploy keys with projects a user can read' do + expected_result = { + id: deploy_key.id, + user_id: deploy_key.user_id, + title: deploy_key.title, + fingerprint: deploy_key.fingerprint, + can_push: deploy_key.can_push, + destroyed_when_orphaned: true, + almost_orphaned: false, + created_at: deploy_key.created_at, + updated_at: deploy_key.updated_at, + projects: [ + { + id: project.id, + name: project.name, + full_path: namespace_project_path(project.namespace, project), + full_name: project.full_name + } + ] + } + + expect(entity.as_json).to eq(expected_result) + end +end diff --git a/spec/serializers/label_serializer_spec.rb b/spec/serializers/label_serializer_spec.rb new file mode 100644 index 0000000000000..c58c7da1f9ed5 --- /dev/null +++ b/spec/serializers/label_serializer_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +describe LabelSerializer do + let(:user) { create(:user) } + + let(:serializer) do + described_class.new(user: user) + end + + subject { serializer.represent(resource) } + + describe '#represent' do + context 'when a single object is being serialized' do + let(:resource) { create(:label) } + + it 'serializes the label object' do + expect(subject[:id]).to eq resource.id + end + end + + context 'when multiple objects are being serialized' do + let(:num_labels) { 2 } + let(:resource) { create_list(:label, num_labels) } + + it 'serializes the array of labels' do + expect(subject.size).to eq(num_labels) + end + end + end + + describe '#represent_appearance' do + context 'when represents only appearance' do + let(:resource) { create(:label) } + + subject { serializer.represent_appearance(resource) } + + it 'serializes only attributes used for appearance' do + expect(subject.keys).to eq([:id, :title, :color, :text_color]) + expect(subject[:id]).to eq(resource.id) + expect(subject[:title]).to eq(resource.title) + expect(subject[:color]).to eq(resource.color) + expect(subject[:text_color]).to eq(resource.text_color) + end + end + end +end diff --git a/spec/services/issuable/bulk_update_service_spec.rb b/spec/services/issuable/bulk_update_service_spec.rb index 7a1ac02731075..5b1639ca0d6e5 100644 --- a/spec/services/issuable/bulk_update_service_spec.rb +++ b/spec/services/issuable/bulk_update_service_spec.rb @@ -4,11 +4,12 @@ let(:user) { create(:user) } let(:project) { create(:empty_project, namespace: user.namespace) } - def bulk_update(issues, extra_params = {}) + def bulk_update(issuables, extra_params = {}) bulk_update_params = extra_params - .reverse_merge(issuable_ids: Array(issues).map(&:id).join(',')) + .reverse_merge(issuable_ids: Array(issuables).map(&:id).join(',')) - Issuable::BulkUpdateService.new(project, user, bulk_update_params).execute('issue') + type = Array(issuables).first.model_name.param_key + Issuable::BulkUpdateService.new(project, user, bulk_update_params).execute(type) end describe 'close issues' do @@ -47,15 +48,15 @@ def bulk_update(issues, extra_params = {}) end end - describe 'updating assignee' do - let(:issue) { create(:issue, project: project, assignee: user) } + describe 'updating merge request assignee' do + let(:merge_request) { create(:merge_request, target_project: project, source_project: project, assignee: user) } context 'when the new assignee ID is a valid user' do it 'succeeds' do new_assignee = create(:user) project.team << [new_assignee, :developer] - result = bulk_update(issue, assignee_id: new_assignee.id) + result = bulk_update(merge_request, assignee_id: new_assignee.id) expect(result[:success]).to be_truthy expect(result[:count]).to eq(1) @@ -65,22 +66,59 @@ def bulk_update(issues, extra_params = {}) assignee = create(:user) project.team << [assignee, :developer] - expect { bulk_update(issue, assignee_id: assignee.id) } - .to change { issue.reload.assignee }.from(user).to(assignee) + expect { bulk_update(merge_request, assignee_id: assignee.id) } + .to change { merge_request.reload.assignee }.from(user).to(assignee) end end context "when the new assignee ID is #{IssuableFinder::NONE}" do it "unassigns the issues" do - expect { bulk_update(issue, assignee_id: IssuableFinder::NONE) } - .to change { issue.reload.assignee }.to(nil) + expect { bulk_update(merge_request, assignee_id: IssuableFinder::NONE) } + .to change { merge_request.reload.assignee }.to(nil) end end context 'when the new assignee ID is not present' do it 'does not unassign' do - expect { bulk_update(issue, assignee_id: nil) } - .not_to change { issue.reload.assignee } + expect { bulk_update(merge_request, assignee_id: nil) } + .not_to change { merge_request.reload.assignee } + end + end + end + + describe 'updating issue assignee' do + let(:issue) { create(:issue, project: project, assignees: [user]) } + + context 'when the new assignee ID is a valid user' do + it 'succeeds' do + new_assignee = create(:user) + project.team << [new_assignee, :developer] + + result = bulk_update(issue, assignee_ids: [new_assignee.id]) + + expect(result[:success]).to be_truthy + expect(result[:count]).to eq(1) + end + + it 'updates the assignee to the use ID passed' do + assignee = create(:user) + project.team << [assignee, :developer] + expect { bulk_update(issue, assignee_ids: [assignee.id]) } + .to change { issue.reload.assignees.first }.from(user).to(assignee) + end + end + + context "when the new assignee ID is #{IssuableFinder::NONE}" do + it "unassigns the issues" do + expect { bulk_update(issue, assignee_ids: [IssuableFinder::NONE.to_s]) } + .to change { issue.reload.assignees.count }.from(1).to(0) + end + end + + context 'when the new assignee ID is not present' do + it 'does not unassign' do + expect { bulk_update(issue, assignee_ids: []) } + .not_to change{ issue.reload.assignees } end end end diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb index 7a54373963e54..5184053171186 100644 --- a/spec/services/issues/close_service_spec.rb +++ b/spec/services/issues/close_service_spec.rb @@ -4,7 +4,7 @@ let(:user) { create(:user) } let(:user2) { create(:user) } let(:guest) { create(:user) } - let(:issue) { create(:issue, assignee: user2) } + let(:issue) { create(:issue, assignees: [user2]) } let(:project) { issue.project } let!(:todo) { create(:todo, :assigned, user: user, project: project, target: issue, author: user2) } diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb index 80bfb7315505a..01edc46496d97 100644 --- a/spec/services/issues/create_service_spec.rb +++ b/spec/services/issues/create_service_spec.rb @@ -6,10 +6,10 @@ describe '#execute' do let(:issue) { described_class.new(project, user, opts).execute } + let(:assignee) { create(:user) } + let(:milestone) { create(:milestone, project: project) } context 'when params are valid' do - let(:assignee) { create(:user) } - let(:milestone) { create(:milestone, project: project) } let(:labels) { create_pair(:label, project: project) } before do @@ -20,7 +20,7 @@ let(:opts) do { title: 'Awesome issue', description: 'please fix', - assignee_id: assignee.id, + assignee_ids: [assignee.id], label_ids: labels.map(&:id), milestone_id: milestone.id, due_date: Date.tomorrow } @@ -29,7 +29,7 @@ it 'creates the issue with the given params' do expect(issue).to be_persisted expect(issue.title).to eq('Awesome issue') - expect(issue.assignee).to eq assignee + expect(issue.assignees).to eq [assignee] expect(issue.labels).to match_array labels expect(issue.milestone).to eq milestone expect(issue.due_date).to eq Date.tomorrow @@ -37,6 +37,7 @@ context 'when current user cannot admin issues in the project' do let(:guest) { create(:user) } + before do project.team << [guest, :guest] end @@ -47,7 +48,7 @@ expect(issue).to be_persisted expect(issue.title).to eq('Awesome issue') expect(issue.description).to eq('please fix') - expect(issue.assignee).to be_nil + expect(issue.assignees).to be_empty expect(issue.labels).to be_empty expect(issue.milestone).to be_nil expect(issue.due_date).to be_nil @@ -136,10 +137,83 @@ end end - it_behaves_like 'issuable create service' + context 'issue create service' do + context 'assignees' do + before { project.team << [user, :master] } + + it 'removes assignee when user id is invalid' do + opts = { title: 'Title', description: 'Description', assignee_ids: [-1] } + + issue = described_class.new(project, user, opts).execute + + expect(issue.assignees).to be_empty + end + + it 'removes assignee when user id is 0' do + opts = { title: 'Title', description: 'Description', assignee_ids: [0] } + + issue = described_class.new(project, user, opts).execute + + expect(issue.assignees).to be_empty + end + + it 'saves assignee when user id is valid' do + project.team << [assignee, :master] + opts = { title: 'Title', description: 'Description', assignee_ids: [assignee.id] } + + issue = described_class.new(project, user, opts).execute + + expect(issue.assignees).to eq([assignee]) + end + + context "when issuable feature is private" do + before do + project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE, + merge_requests_access_level: ProjectFeature::PRIVATE) + end + + levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] + + levels.each do |level| + it "removes not authorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do + project.update(visibility_level: level) + opts = { title: 'Title', description: 'Description', assignee_ids: [assignee.id] } + + issue = described_class.new(project, user, opts).execute + + expect(issue.assignees).to be_empty + end + end + end + end + end it_behaves_like 'new issuable record that supports slash commands' + context 'Slash commands' do + context 'with assignee and milestone in params and command' do + let(:opts) do + { + assignee_ids: [create(:user).id], + milestone_id: 1, + title: 'Title', + description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}") + } + end + + before do + project.team << [user, :master] + project.team << [assignee, :master] + end + + it 'assigns and sets milestone to issuable from command' do + expect(issue).to be_persisted + expect(issue.assignees).to eq([assignee]) + expect(issue.milestone).to eq(milestone) + end + end + end + context 'resolving discussions' do let(:discussion) { create(:diff_note_on_merge_request).to_discussion } let(:merge_request) { discussion.noteable } diff --git a/spec/services/issues/export_csv_service_spec.rb b/spec/services/issues/export_csv_service_spec.rb index 6f8d6d87dc66d..f09c8b48a697f 100644 --- a/spec/services/issues/export_csv_service_spec.rb +++ b/spec/services/issues/export_csv_service_spec.rb @@ -33,7 +33,7 @@ def csv before do issue.update!(milestone: milestone, - assignee: user, + assignees: [user], description: 'Issue with details', state: :reopened, due_date: DateTime.new(2014, 3, 2), @@ -71,11 +71,11 @@ def csv end specify 'assignee name' do - expect(csv[0]['Assignee']).to eq issue.assignee_name + expect(csv[0]['Assignee']).to eq user.name end specify 'assignee username' do - expect(csv[0]['Assignee Username']).to eq issue.assignee.username + expect(csv[0]['Assignee Username']).to eq user.username end specify 'confidential' do diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index 5b324f3c706d6..1954d8739f69b 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -14,7 +14,7 @@ let(:issue) do create(:issue, title: 'Old title', description: "for #{user2.to_reference}", - assignee_id: user3.id, + assignee_ids: [user3.id], project: project) end @@ -40,7 +40,7 @@ def update_issue(opts) { title: 'New title', description: 'Also please fix', - assignee_id: user2.id, + assignee_ids: [user2.id], state_event: 'close', label_ids: [label.id], due_date: Date.tomorrow @@ -53,15 +53,15 @@ def update_issue(opts) expect(issue).to be_valid expect(issue.title).to eq 'New title' expect(issue.description).to eq 'Also please fix' - expect(issue.assignee).to eq user2 + expect(issue.assignees).to match_array([user2]) expect(issue).to be_closed expect(issue.labels).to match_array [label] expect(issue.due_date).to eq Date.tomorrow end it 'sorts issues as specified by parameters' do - issue1 = create(:issue, project: project, assignee_id: user3.id) - issue2 = create(:issue, project: project, assignee_id: user3.id) + issue1 = create(:issue, project: project, assignees: [user3]) + issue2 = create(:issue, project: project, assignees: [user3]) [issue, issue1, issue2].each do |issue| issue.move_to_end @@ -87,7 +87,7 @@ def update_issue(opts) expect(issue).to be_valid expect(issue.title).to eq 'New title' expect(issue.description).to eq 'Also please fix' - expect(issue.assignee).to eq user3 + expect(issue.assignees).to match_array [user3] expect(issue.labels).to be_empty expect(issue.milestone).to be_nil expect(issue.due_date).to be_nil @@ -132,12 +132,23 @@ def update_issue(opts) end end + context 'when description changed' do + it 'creates system note about description change' do + update_issue(description: 'Changed description') + + note = find_note('changed the description') + + expect(note).not_to be_nil + expect(note.note).to eq('changed the description') + end + end + context 'when issue turns confidential' do let(:opts) do { title: 'New title', description: 'Also please fix', - assignee_id: user2.id, + assignee_ids: [user2], state_event: 'close', label_ids: [label.id], confidential: true @@ -163,12 +174,12 @@ def update_issue(opts) it 'does not update assignee_id with unauthorized users' do project.update(visibility_level: Gitlab::VisibilityLevel::PUBLIC) update_issue(confidential: true) - non_member = create(:user) - original_assignee = issue.assignee + non_member = create(:user) + original_assignees = issue.assignees - update_issue(assignee_id: non_member.id) + update_issue(assignee_ids: [non_member.id]) - expect(issue.reload.assignee_id).to eq(original_assignee.id) + expect(issue.reload.assignees).to eq(original_assignees) end end @@ -205,7 +216,7 @@ def update_issue(opts) context 'when is reassigned' do before do - update_issue(assignee: user2) + update_issue(assignees: [user2]) end it 'marks previous assignee todos as done' do @@ -408,6 +419,41 @@ def update_issue(opts) end end + context 'updating asssignee_id' do + it 'does not update assignee when assignee_id is invalid' do + update_issue(assignee_ids: [-1]) + + expect(issue.reload.assignees).to eq([user3]) + end + + it 'unassigns assignee when user id is 0' do + update_issue(assignee_ids: [0]) + + expect(issue.reload.assignees).to be_empty + end + + it 'does not update assignee_id when user cannot read issue' do + update_issue(assignee_ids: [create(:user).id]) + + expect(issue.reload.assignees).to eq([user3]) + end + + context "when issuable feature is private" do + levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] + + levels.each do |level| + it "does not update with unauthorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do + assignee = create(:user) + project.update(visibility_level: level) + feature_visibility_attr = :"#{issue.model_name.plural}_access_level" + project.project_feature.update_attribute(feature_visibility_attr, ProjectFeature::PRIVATE) + + expect{ update_issue(assignee_ids: [assignee.id]) }.not_to change{ issue.assignees } + end + end + end + end + context 'updating mentions' do let(:mentionable) { issue } include_examples 'updating mentions', Issues::UpdateService diff --git a/spec/services/members/authorized_destroy_service_spec.rb b/spec/services/members/authorized_destroy_service_spec.rb index 3b35a3b8e3a3a..ab440d18e9f68 100644 --- a/spec/services/members/authorized_destroy_service_spec.rb +++ b/spec/services/members/authorized_destroy_service_spec.rb @@ -14,8 +14,8 @@ def number_of_assigned_issuables(user) it "unassigns issues and merge requests" do group.add_developer(member_user) - issue = create :issue, project: group_project, assignee: member_user - create :issue, assignee: member_user + issue = create :issue, project: group_project, assignees: [member_user] + create :issue, assignees: [member_user] merge_request = create :merge_request, target_project: group_project, source_project: group_project, assignee: member_user create :merge_request, target_project: project, source_project: project, assignee: member_user @@ -33,7 +33,7 @@ def number_of_assigned_issuables(user) it "unassigns issues and merge requests" do project.team << [member_user, :developer] - create :issue, project: project, assignee: member_user + create :issue, project: project, assignees: [member_user] create :merge_request, target_project: project, source_project: project, assignee: member_user member = project.members.find_by(user_id: member_user.id) diff --git a/spec/services/merge_requests/assign_issues_service_spec.rb b/spec/services/merge_requests/assign_issues_service_spec.rb index fe75757dd29c4..d3556020d4d06 100644 --- a/spec/services/merge_requests/assign_issues_service_spec.rb +++ b/spec/services/merge_requests/assign_issues_service_spec.rb @@ -15,14 +15,14 @@ expect(service.assignable_issues.map(&:id)).to include(issue.id) end - it 'ignores issues already assigned to any user' do - issue.update!(assignee: create(:user)) + it 'ignores issues the user cannot update assignee on' do + project.team.truncate expect(service.assignable_issues).to be_empty end - it 'ignores issues the user cannot update assignee on' do - project.team.truncate + it 'ignores issues already assigned to any user' do + issue.assignees = [create(:user)] expect(service.assignable_issues).to be_empty end @@ -44,7 +44,7 @@ end it 'assigns these to the merge request owner' do - expect { service.execute }.to change { issue.reload.assignee }.to(user) + expect { service.execute }.to change { issue.assignees.first }.to(user) end it 'ignores external issues' do diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb index 0e16c7cc94bbd..ace82380cc999 100644 --- a/spec/services/merge_requests/create_service_spec.rb +++ b/spec/services/merge_requests/create_service_spec.rb @@ -84,7 +84,87 @@ end end - it_behaves_like 'issuable create service' + context 'Slash commands' do + context 'with assignee and milestone in params and command' do + let(:merge_request) { described_class.new(project, user, opts).execute } + let(:milestone) { create(:milestone, project: project) } + + let(:opts) do + { + assignee_id: create(:user).id, + milestone_id: 1, + title: 'Title', + description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}"), + source_branch: 'feature', + target_branch: 'master' + } + end + + before do + project.team << [user, :master] + project.team << [assignee, :master] + end + + it 'assigns and sets milestone to issuable from command' do + expect(merge_request).to be_persisted + expect(merge_request.assignee).to eq(assignee) + expect(merge_request.milestone).to eq(milestone) + end + end + end + + context 'merge request create service' do + context 'asssignee_id' do + let(:assignee) { create(:user) } + + before { project.team << [user, :master] } + + it 'removes assignee_id when user id is invalid' do + opts = { title: 'Title', description: 'Description', assignee_id: -1 } + + merge_request = described_class.new(project, user, opts).execute + + expect(merge_request.assignee_id).to be_nil + end + + it 'removes assignee_id when user id is 0' do + opts = { title: 'Title', description: 'Description', assignee_id: 0 } + + merge_request = described_class.new(project, user, opts).execute + + expect(merge_request.assignee_id).to be_nil + end + + it 'saves assignee when user id is valid' do + project.team << [assignee, :master] + opts = { title: 'Title', description: 'Description', assignee_id: assignee.id } + + merge_request = described_class.new(project, user, opts).execute + + expect(merge_request.assignee).to eq(assignee) + end + + context "when issuable feature is private" do + before do + project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE, + merge_requests_access_level: ProjectFeature::PRIVATE) + end + + levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] + + levels.each do |level| + it "removes not authorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do + project.update(visibility_level: level) + opts = { title: 'Title', description: 'Description', assignee_id: assignee.id } + + merge_request = described_class.new(project, user, opts).execute + + expect(merge_request.assignee_id).to be_nil + end + end + end + end + end context 'while saving references to issues that the created merge request closes' do let(:first_issue) { create(:issue, project: project) } diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index b182d854ff077..77eca3be73364 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -102,6 +102,13 @@ def update_merge_request(opts) expect(note.note).to eq 'changed title from **{-Old-} title** to **{+New+} title**' end + it 'creates system note about description change' do + note = find_note('changed the description') + + expect(note).not_to be_nil + expect(note.note).to eq('changed the description') + end + it 'creates system note about branch change' do note = find_note('changed target') @@ -475,6 +482,54 @@ def update_merge_request(opts) end end + context 'updating asssignee_id' do + it 'does not update assignee when assignee_id is invalid' do + merge_request.update(assignee_id: user.id) + + update_merge_request(assignee_id: -1) + + expect(merge_request.reload.assignee).to eq(user) + end + + it 'unassigns assignee when user id is 0' do + merge_request.update(assignee_id: user.id) + + update_merge_request(assignee_id: 0) + + expect(merge_request.assignee_id).to be_nil + end + + it 'saves assignee when user id is valid' do + update_merge_request(assignee_id: user.id) + + expect(merge_request.assignee_id).to eq(user.id) + end + + it 'does not update assignee_id when user cannot read issue' do + non_member = create(:user) + original_assignee = merge_request.assignee + + update_merge_request(assignee_id: non_member.id) + + expect(merge_request.assignee_id).to eq(original_assignee.id) + end + + context "when issuable feature is private" do + levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] + + levels.each do |level| + it "does not update with unauthorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do + assignee = create(:user) + project.update(visibility_level: level) + feature_visibility_attr = :"#{merge_request.model_name.plural}_access_level" + project.project_feature.update_attribute(feature_visibility_attr, ProjectFeature::PRIVATE) + + expect{ update_merge_request(assignee_id: assignee) }.not_to change{ merge_request.assignee } + end + end + end + end + include_examples 'issuable update service' do let(:open_issuable) { merge_request } let(:closed_issuable) { create(:closed_merge_request, source_project: project) } diff --git a/spec/services/notes/slash_commands_service_spec.rb b/spec/services/notes/slash_commands_service_spec.rb index 1a64c8bbf00df..d837184382f5c 100644 --- a/spec/services/notes/slash_commands_service_spec.rb +++ b/spec/services/notes/slash_commands_service_spec.rb @@ -66,7 +66,7 @@ expect(content).to eq '' expect(note.noteable).to be_closed expect(note.noteable.labels).to match_array(labels) - expect(note.noteable.assignee).to eq(assignee) + expect(note.noteable.assignees).to eq([assignee]) expect(note.noteable.milestone).to eq(milestone) end end @@ -113,7 +113,7 @@ expect(content).to eq "HELLO\nWORLD" expect(note.noteable).to be_closed expect(note.noteable.labels).to match_array(labels) - expect(note.noteable.assignee).to eq(assignee) + expect(note.noteable.assignees).to eq([assignee]) expect(note.noteable.milestone).to eq(milestone) end end @@ -220,4 +220,31 @@ let(:note) { build(:note_on_commit, project: project) } end end + + context 'CE restriction for issue assignees' do + describe '/assign' do + let(:project) { create(:empty_project) } + let(:master) { create(:user).tap { |u| project.team << [u, :master] } } + let(:assignee) { create(:user) } + let(:master) { create(:user) } + let(:service) { described_class.new(project, master) } + let(:note) { create(:note_on_issue, note: note_text, project: project) } + + let(:note_text) do + %(/assign @#{assignee.username} @#{master.username}\n") + end + + before do + project.team << [master, :master] + project.team << [assignee, :master] + end + + it 'adds only one assignee from the list' do + _, command_params = service.extract_commands(note) + service.execute(command_params, note) + + expect(note.noteable.assignees.count).to eq(2) + end + end + end end diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 817c31e72182f..5e8b54d2b6d4b 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -4,6 +4,7 @@ include EmailHelpers let(:notification) { NotificationService.new } + let(:assignee) { create(:user) } around(:each) do |example| perform_enqueued_jobs do @@ -52,7 +53,11 @@ def send_notifications(*new_mentions) shared_examples 'participating by assignee notification' do it 'emails the participant' do - issuable.update_attribute(:assignee, participant) + if issuable.is_a?(Issue) + issuable.assignees << participant + else + issuable.update_attribute(:assignee, participant) + end notification_trigger @@ -103,14 +108,14 @@ def send_notifications(*new_mentions) describe 'Notes' do context 'issue note' do let(:project) { create(:empty_project, :private) } - let(:issue) { create(:issue, project: project, assignee: create(:user)) } - let(:mentioned_issue) { create(:issue, assignee: issue.assignee) } + let(:issue) { create(:issue, project: project, assignees: [assignee]) } + let(:mentioned_issue) { create(:issue, assignees: issue.assignees) } let(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@mention referenced, @outsider also') } before do build_team(note.project) project.add_master(issue.author) - project.add_master(issue.assignee) + project.add_master(assignee) project.add_master(note.author) create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@subscribed_participant cc this guy') update_custom_notification(:new_note, @u_guest_custom, resource: project) @@ -130,7 +135,7 @@ def send_notifications(*new_mentions) should_email(@u_watcher) should_email(note.noteable.author) - should_email(note.noteable.assignee) + should_email(note.noteable.assignees.first) should_email(@u_custom_global) should_email(@u_mentioned) should_email(@subscriber) @@ -196,7 +201,7 @@ def send_notifications(*new_mentions) notification.new_note(note) should_email(note.noteable.author) - should_email(note.noteable.assignee) + should_email(note.noteable.assignees.first) should_email(@u_mentioned) should_email(@u_custom_global) should_not_email(@u_guest_custom) @@ -218,7 +223,7 @@ def send_notifications(*new_mentions) let(:member) { create(:user) } let(:guest) { create(:user) } let(:admin) { create(:admin) } - let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee) } + let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee]) } let(:note) { create(:note_on_issue, noteable: confidential_issue, project: project, note: "#{author.to_reference} #{assignee.to_reference} #{non_member.to_reference} #{member.to_reference} #{admin.to_reference}") } let(:guest_watcher) { create_user_with_notification(:watch, "guest-watcher-confidential") } @@ -244,8 +249,8 @@ def send_notifications(*new_mentions) context 'issue note mention' do let(:project) { create(:empty_project, :public) } - let(:issue) { create(:issue, project: project, assignee: create(:user)) } - let(:mentioned_issue) { create(:issue, assignee: issue.assignee) } + let(:issue) { create(:issue, project: project, assignees: [assignee]) } + let(:mentioned_issue) { create(:issue, assignees: issue.assignees) } let(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@all mentioned') } before do @@ -269,7 +274,7 @@ def send_notifications(*new_mentions) should_email(@u_guest_watcher) should_email(note.noteable.author) - should_email(note.noteable.assignee) + should_email(note.noteable.assignees.first) should_not_email(note.author) should_email(@u_mentioned) should_not_email(@u_disabled) @@ -449,7 +454,7 @@ def send_notifications(*new_mentions) let(:group) { create(:group) } let(:project) { create(:empty_project, :public, namespace: group) } let(:another_project) { create(:empty_project, :public, namespace: group) } - let(:issue) { create :issue, project: project, assignee: create(:user), description: 'cc @participant' } + let(:issue) { create :issue, project: project, assignees: [assignee], description: 'cc @participant' } before do build_team(issue.project) @@ -465,7 +470,7 @@ def send_notifications(*new_mentions) it do notification.new_issue(issue, @u_disabled) - should_email(issue.assignee) + should_email(assignee) should_email(@u_watcher) should_email(@u_guest_watcher) should_email(@u_guest_custom) @@ -480,10 +485,10 @@ def send_notifications(*new_mentions) end it do - create_global_setting_for(issue.assignee, :mention) + create_global_setting_for(issue.assignees.first, :mention) notification.new_issue(issue, @u_disabled) - should_not_email(issue.assignee) + should_not_email(issue.assignees.first) end it "emails the author if they've opted into notifications about their activity" do @@ -528,7 +533,7 @@ def send_notifications(*new_mentions) let(:member) { create(:user) } let(:guest) { create(:user) } let(:admin) { create(:admin) } - let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignee: assignee) } + let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignees: [assignee]) } it "emails subscribers of the issue's labels that can read the issue" do project.add_developer(member) @@ -572,9 +577,9 @@ def send_notifications(*new_mentions) end it 'emails new assignee' do - notification.reassigned_issue(issue, @u_disabled) + notification.reassigned_issue(issue, @u_disabled, [assignee]) - should_email(issue.assignee) + should_email(issue.assignees.first) should_email(@u_watcher) should_email(@u_guest_watcher) should_email(@u_guest_custom) @@ -588,9 +593,8 @@ def send_notifications(*new_mentions) end it 'emails previous assignee even if he has the "on mention" notif level' do - issue.update_attribute(:assignee, @u_mentioned) - issue.update_attributes(assignee: @u_watcher) - notification.reassigned_issue(issue, @u_disabled) + issue.assignees = [@u_mentioned] + notification.reassigned_issue(issue, @u_disabled, [@u_watcher]) should_email(@u_mentioned) should_email(@u_watcher) @@ -606,11 +610,11 @@ def send_notifications(*new_mentions) end it 'emails new assignee even if he has the "on mention" notif level' do - issue.update_attributes(assignee: @u_mentioned) - notification.reassigned_issue(issue, @u_disabled) + issue.assignees = [@u_mentioned] + notification.reassigned_issue(issue, @u_disabled, [@u_mentioned]) - expect(issue.assignee).to be @u_mentioned - should_email(issue.assignee) + expect(issue.assignees.first).to be @u_mentioned + should_email(issue.assignees.first) should_email(@u_watcher) should_email(@u_guest_watcher) should_email(@u_guest_custom) @@ -624,11 +628,11 @@ def send_notifications(*new_mentions) end it 'emails new assignee' do - issue.update_attribute(:assignee, @u_mentioned) - notification.reassigned_issue(issue, @u_disabled) + issue.assignees = [@u_mentioned] + notification.reassigned_issue(issue, @u_disabled, [@u_mentioned]) - expect(issue.assignee).to be @u_mentioned - should_email(issue.assignee) + expect(issue.assignees.first).to be @u_mentioned + should_email(issue.assignees.first) should_email(@u_watcher) should_email(@u_guest_watcher) should_email(@u_guest_custom) @@ -642,17 +646,17 @@ def send_notifications(*new_mentions) end it 'does not email new assignee if they are the current user' do - issue.update_attribute(:assignee, @u_mentioned) - notification.reassigned_issue(issue, @u_mentioned) + issue.assignees = [@u_mentioned] + notification.reassigned_issue(issue, @u_mentioned, [@u_mentioned]) - expect(issue.assignee).to be @u_mentioned + expect(issue.assignees.first).to be @u_mentioned should_email(@u_watcher) should_email(@u_guest_watcher) should_email(@u_guest_custom) should_email(@u_participant_mentioned) should_email(@subscriber) should_email(@u_custom_global) - should_not_email(issue.assignee) + should_not_email(issue.assignees.first) should_not_email(@unsubscriber) should_not_email(@u_participating) should_not_email(@u_disabled) @@ -662,7 +666,7 @@ def send_notifications(*new_mentions) it_behaves_like 'participating notifications' do let(:participant) { create(:user, username: 'user-participant') } let(:issuable) { issue } - let(:notification_trigger) { notification.reassigned_issue(issue, @u_disabled) } + let(:notification_trigger) { notification.reassigned_issue(issue, @u_disabled, [assignee]) } end end @@ -705,7 +709,7 @@ def send_notifications(*new_mentions) it "doesn't send email to anyone but subscribers of the given labels" do notification.relabeled_issue(issue, [group_label_2, label_2], @u_disabled) - should_not_email(issue.assignee) + should_not_email(issue.assignees.first) should_not_email(issue.author) should_not_email(@u_watcher) should_not_email(@u_guest_watcher) @@ -729,7 +733,7 @@ def send_notifications(*new_mentions) let(:member) { create(:user) } let(:guest) { create(:user) } let(:admin) { create(:admin) } - let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignee: assignee) } + let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignees: [assignee]) } let!(:label_1) { create(:label, project: project, issues: [confidential_issue]) } let!(:label_2) { create(:label, project: project) } @@ -767,7 +771,7 @@ def send_notifications(*new_mentions) it 'sends email to issue assignee and issue author' do notification.close_issue(issue, @u_disabled) - should_email(issue.assignee) + should_email(issue.assignees.first) should_email(issue.author) should_email(@u_watcher) should_email(@u_guest_watcher) @@ -798,7 +802,7 @@ def send_notifications(*new_mentions) it 'sends email to issue notification recipients' do notification.reopen_issue(issue, @u_disabled) - should_email(issue.assignee) + should_email(issue.assignees.first) should_email(issue.author) should_email(@u_watcher) should_email(@u_guest_watcher) @@ -826,7 +830,7 @@ def send_notifications(*new_mentions) it 'sends email to issue notification recipients' do notification.issue_moved(issue, new_issue, @u_disabled) - should_email(issue.assignee) + should_email(issue.assignees.first) should_email(issue.author) should_email(@u_watcher) should_email(@u_guest_watcher) diff --git a/spec/services/projects/autocomplete_service_spec.rb b/spec/services/projects/autocomplete_service_spec.rb index 7916c2d957cc0..c198c3eedfca2 100644 --- a/spec/services/projects/autocomplete_service_spec.rb +++ b/spec/services/projects/autocomplete_service_spec.rb @@ -11,7 +11,7 @@ let(:project) { create(:empty_project, :public) } let!(:issue) { create(:issue, project: project, title: 'Issue 1') } let!(:security_issue_1) { create(:issue, :confidential, project: project, title: 'Security issue 1', author: author) } - let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignee: assignee) } + let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignees: [assignee]) } it 'does not list project confidential issues for guests' do autocomplete = described_class.new(project, nil) diff --git a/spec/services/projects/propagate_service_template_spec.rb b/spec/services/projects/propagate_service_template_spec.rb new file mode 100644 index 0000000000000..90eff3bbc1e0b --- /dev/null +++ b/spec/services/projects/propagate_service_template_spec.rb @@ -0,0 +1,103 @@ +require 'spec_helper' + +describe Projects::PropagateServiceTemplate, services: true do + describe '.propagate' do + let!(:service_template) do + PushoverService.create( + template: true, + active: true, + properties: { + device: 'MyDevice', + sound: 'mic', + priority: 4, + user_key: 'asdf', + api_key: '123456789' + }) + end + + let!(:project) { create(:empty_project) } + + it 'creates services for projects' do + expect(project.pushover_service).to be_nil + + described_class.propagate(service_template) + + expect(project.reload.pushover_service).to be_present + end + + it 'creates services for a project that has another service' do + BambooService.create( + template: true, + active: true, + project: project, + properties: { + bamboo_url: 'http://gitlab.com', + username: 'mic', + password: "password", + build_key: 'build' + } + ) + + expect(project.pushover_service).to be_nil + + described_class.propagate(service_template) + + expect(project.reload.pushover_service).to be_present + end + + it 'does not create the service if it exists already' do + other_service = BambooService.create( + template: true, + active: true, + properties: { + bamboo_url: 'http://gitlab.com', + username: 'mic', + password: "password", + build_key: 'build' + } + ) + + Service.build_from_template(project.id, service_template).save! + Service.build_from_template(project.id, other_service).save! + + expect { described_class.propagate(service_template) }. + not_to change { Service.count } + end + + it 'creates the service containing the template attributes' do + described_class.propagate(service_template) + + expect(project.pushover_service.properties).to eq(service_template.properties) + end + + describe 'bulk update' do + it 'creates services for all projects' do + project_total = 5 + stub_const 'Projects::PropagateServiceTemplate::BATCH_SIZE', 3 + + project_total.times { create(:empty_project) } + + expect { described_class.propagate(service_template) }. + to change { Service.count }.by(project_total + 1) + end + end + + describe 'external tracker' do + it 'updates the project external tracker' do + service_template.update!(category: 'issue_tracker', default: false) + + expect { described_class.propagate(service_template) }. + to change { project.reload.has_external_issue_tracker }.to(true) + end + end + + describe 'external wiki' do + it 'updates the project external tracker' do + service_template.update!(type: 'ExternalWikiService') + + expect { described_class.propagate(service_template) }. + to change { project.reload.has_external_wiki }.to(true) + end + end + end +end diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb index bbe85f3127583..40427fc2173d9 100644 --- a/spec/services/slash_commands/interpret_service_spec.rb +++ b/spec/services/slash_commands/interpret_service_spec.rb @@ -3,6 +3,7 @@ describe SlashCommands::InterpretService, services: true do let(:project) { create(:empty_project, :public) } let(:developer) { create(:user) } + let(:developer2) { create(:user) } let(:issue) { create(:issue, project: project) } let(:milestone) { create(:milestone, project: project, title: '9.10') } let(:inprogress) { create(:label, project: project, title: 'In Progress') } @@ -42,23 +43,6 @@ end end - shared_examples 'assign command' do - it 'fetches assignee and populates assignee_id if content contains /assign' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(assignee_id: developer.id) - end - end - - shared_examples 'unassign command' do - it 'populates assignee_id: nil if content contains /unassign' do - issuable.update!(assignee_id: developer.id) - _, updates = service.execute(content, issuable) - - expect(updates).to eq(assignee_id: nil) - end - end - shared_examples 'milestone command' do it 'fetches milestone and populates milestone_id if content contains /milestone' do milestone # populate the milestone @@ -388,14 +372,46 @@ let(:issuable) { issue } end - it_behaves_like 'assign command' do + context 'assign command' do let(:content) { "/assign @#{developer.username}" } - let(:issuable) { issue } + + context 'Issue' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, issue) + + expect(updates).to eq(assignee_ids: [developer.id]) + end + end + + context 'Merge Request' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, merge_request) + + expect(updates).to eq(assignee_id: developer.id) + end + end end - it_behaves_like 'assign command' do - let(:content) { "/assign @#{developer.username}" } - let(:issuable) { merge_request } + context 'assign command with multiple assignees' do + let(:content) { "/assign @#{developer.username} @#{developer2.username}" } + + before{ project.team << [developer2, :developer] } + + context 'Issue' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, issue) + + expect(updates[:assignee_ids]).to match_array([developer.id, developer2.id]) + end + end + + context 'Merge Request' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, merge_request) + + expect(updates).to eq(assignee_id: developer.id) + end + end end it_behaves_like 'empty command' do @@ -408,14 +424,26 @@ let(:issuable) { issue } end - it_behaves_like 'unassign command' do + context 'unassign command' do let(:content) { '/unassign' } - let(:issuable) { issue } - end - it_behaves_like 'unassign command' do - let(:content) { '/unassign' } - let(:issuable) { merge_request } + context 'Issue' do + it 'populates assignee_ids: [] if content contains /unassign' do + issue.update(assignee_ids: [developer.id]) + _, updates = service.execute(content, issue) + + expect(updates).to eq(assignee_ids: []) + end + end + + context 'Merge Request' do + it 'populates assignee_id: nil if content contains /unassign' do + merge_request.update(assignee_id: developer.id) + _, updates = service.execute(content, merge_request) + + expect(updates).to eq(assignee_id: nil) + end + end end it_behaves_like 'milestone command' do @@ -873,7 +901,7 @@ describe 'unassign command' do let(:content) { '/unassign' } - let(:issue) { create(:issue, project: project, assignee: developer) } + let(:issue) { create(:issue, project: project, assignees: [developer]) } it 'includes current assignee reference' do _, explanations = service.explain(content, issue) diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 7b883c636ae0b..2fdcd579f995c 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -6,6 +6,7 @@ let(:project) { create(:empty_project) } let(:author) { create(:user) } let(:noteable) { create(:issue, project: project) } + let(:issue) { noteable } shared_examples_for 'a system note' do let(:expected_noteable) { noteable } @@ -155,6 +156,52 @@ end end + describe '.change_issue_assignees' do + subject { described_class.change_issue_assignees(noteable, project, author, [assignee]) } + + let(:assignee) { create(:user) } + let(:assignee1) { create(:user) } + let(:assignee2) { create(:user) } + let(:assignee3) { create(:user) } + + it_behaves_like 'a system note' do + let(:action) { 'assignee' } + end + + def build_note(old_assignees, new_assignees) + issue.assignees = new_assignees + described_class.change_issue_assignees(issue, project, author, old_assignees).note + end + + it 'builds a correct phrase when an assignee is added to a non-assigned issue' do + expect(build_note([], [assignee1])).to eq "assigned to @#{assignee1.username}" + end + + it 'builds a correct phrase when assignee removed' do + expect(build_note([assignee1], [])).to eq 'removed all assignees' + end + + it 'builds a correct phrase when assignees changed' do + expect(build_note([assignee1], [assignee2])).to eq \ + "assigned to @#{assignee2.username} and unassigned @#{assignee1.username}" + end + + it 'builds a correct phrase when three assignees removed and one added' do + expect(build_note([assignee, assignee1, assignee2], [assignee3])).to eq \ + "assigned to @#{assignee3.username} and unassigned @#{assignee.username}, @#{assignee1.username}, and @#{assignee2.username}" + end + + it 'builds a correct phrase when one assignee changed from a set' do + expect(build_note([assignee, assignee1], [assignee, assignee2])).to eq \ + "assigned to @#{assignee2.username} and unassigned @#{assignee1.username}" + end + + it 'builds a correct phrase when one assignee removed from a set' do + expect(build_note([assignee, assignee1, assignee2], [assignee, assignee1])).to eq \ + "unassigned @#{assignee2.username}" + end + end + describe '.change_label' do subject { described_class.change_label(noteable, project, author, added, removed) } @@ -292,6 +339,20 @@ end end + describe '.change_description' do + subject { described_class.change_description(noteable, project, author) } + + context 'when noteable responds to `description`' do + it_behaves_like 'a system note' do + let(:action) { 'description' } + end + + it 'sets the note text' do + expect(subject.note).to eq('changed the description') + end + end + end + describe '.change_issue_confidentiality' do subject { described_class.change_issue_confidentiality(noteable, project, author) } diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb index 4639c93b441ae..09027e30bd3ea 100644 --- a/spec/services/todo_service_spec.rb +++ b/spec/services/todo_service_spec.rb @@ -25,11 +25,11 @@ end describe 'Issues' do - let(:issue) { create(:issue, project: project, assignee: john_doe, author: author, description: "- [ ] Task 1\n- [ ] Task 2 #{mentions}") } - let(:addressed_issue) { create(:issue, project: project, assignee: john_doe, author: author, description: "#{directly_addressed}\n- [ ] Task 1\n- [ ] Task 2") } - let(:unassigned_issue) { create(:issue, project: project, assignee: nil) } - let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee, description: mentions) } - let(:addressed_confident_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee, description: directly_addressed) } + let(:issue) { create(:issue, project: project, assignees: [john_doe], author: author, description: "- [ ] Task 1\n- [ ] Task 2 #{mentions}") } + let(:addressed_issue) { create(:issue, project: project, assignees: [john_doe], author: author, description: "#{directly_addressed}\n- [ ] Task 1\n- [ ] Task 2") } + let(:unassigned_issue) { create(:issue, project: project, assignees: []) } + let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee], description: mentions) } + let(:addressed_confident_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee], description: directly_addressed) } describe '#new_issue' do it 'creates a todo if assigned' do @@ -43,7 +43,7 @@ end it 'creates a todo if assignee is the current user' do - unassigned_issue.update_attribute(:assignee, john_doe) + unassigned_issue.assignees = [john_doe] service.new_issue(unassigned_issue, john_doe) should_create_todo(user: john_doe, target: unassigned_issue, author: john_doe, action: Todo::ASSIGNED) @@ -258,20 +258,20 @@ describe '#reassigned_issue' do it 'creates a pending todo for new assignee' do - unassigned_issue.update_attribute(:assignee, john_doe) + unassigned_issue.assignees << john_doe service.reassigned_issue(unassigned_issue, author) should_create_todo(user: john_doe, target: unassigned_issue, action: Todo::ASSIGNED) end it 'does not create a todo if unassigned' do - issue.update_attribute(:assignee, nil) + issue.assignees.destroy_all should_not_create_any_todo { service.reassigned_issue(issue, author) } end it 'creates a todo if new assignee is the current user' do - unassigned_issue.update_attribute(:assignee, john_doe) + unassigned_issue.assignees << john_doe service.reassigned_issue(unassigned_issue, john_doe) should_create_todo(user: john_doe, target: unassigned_issue, author: john_doe, action: Todo::ASSIGNED) @@ -361,7 +361,7 @@ describe '#new_note' do let!(:first_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) } let!(:second_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) } - let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee) } + let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee]) } let(:note) { create(:note, project: project, noteable: issue, author: john_doe, note: mentions) } let(:addressed_note) { create(:note, project: project, noteable: issue, author: john_doe, note: directly_addressed) } let(:note_on_commit) { create(:note_on_commit, project: project, author: john_doe, note: mentions) } @@ -884,7 +884,7 @@ end it 'updates cached counts when a todo is created' do - issue = create(:issue, project: project, assignee: john_doe, author: author, description: mentions) + issue = create(:issue, project: project, assignees: [john_doe], author: author, description: mentions) expect(john_doe.todos_pending_count).to eq(0) expect(john_doe).to receive(:update_todos_count_cache).and_call_original @@ -896,8 +896,8 @@ end describe '#mark_todos_as_done' do - let(:issue) { create(:issue, project: project, author: author, assignee: john_doe) } - let(:another_issue) { create(:issue, project: project, author: author, assignee: john_doe) } + let(:issue) { create(:issue, project: project, author: author, assignees: [john_doe]) } + let(:another_issue) { create(:issue, project: project, author: author, assignees: [john_doe]) } it 'marks a relation of todos as done' do create(:todo, :mentioned, user: john_doe, target: issue, project: project) diff --git a/spec/services/users/destroy_service_spec.rb b/spec/services/users/destroy_service_spec.rb index 4bc30018ebd5d..de37a61e38880 100644 --- a/spec/services/users/destroy_service_spec.rb +++ b/spec/services/users/destroy_service_spec.rb @@ -47,7 +47,7 @@ end context "for an issue the user was assigned to" do - let!(:issue) { create(:issue, project: project, assignee: user) } + let!(:issue) { create(:issue, project: project, assignees: [user]) } before do service.execute(user) @@ -60,7 +60,7 @@ it 'migrates the issue so that it is "Unassigned"' do migrated_issue = Issue.find_by_id(issue.id) - expect(migrated_issue.assignee).to be_nil + expect(migrated_issue.assignees).to be_empty end end end diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb index 1d26c7baace36..ad46b163cd619 100644 --- a/spec/support/features/issuable_slash_commands_shared_examples.rb +++ b/spec/support/features/issuable_slash_commands_shared_examples.rb @@ -58,11 +58,12 @@ expect(page).not_to have_content '/label ~bug' expect(page).not_to have_content '/milestone %"ASAP"' + wait_for_ajax issuable.reload note = issuable.notes.user.first expect(note.note).to eq "Awesome!" - expect(issuable.assignee).to eq assignee + expect(issuable.assignees).to eq [assignee] expect(issuable.labels).to eq [label_bug] expect(issuable.milestone).to eq milestone end @@ -80,7 +81,7 @@ issuable.reload expect(issuable.notes.user).to be_empty - expect(issuable.assignee).to eq assignee + expect(issuable.assignees).to eq [assignee] expect(issuable.labels).to eq [label_bug] expect(issuable.milestone).to eq milestone end diff --git a/spec/support/import_export/export_file_helper.rb b/spec/support/import_export/export_file_helper.rb index 944ea30656fc9..57b6abe12b7ca 100644 --- a/spec/support/import_export/export_file_helper.rb +++ b/spec/support/import_export/export_file_helper.rb @@ -10,7 +10,7 @@ def setup_project create(:release, project: project) - issue = create(:issue, assignee: user, project: project) + issue = create(:issue, assignees: [user], project: project) snippet = create(:project_snippet, project: project) label = create(:label, project: project) milestone = create(:milestone, project: project) diff --git a/spec/support/prometheus_helpers.rb b/spec/support/prometheus_helpers.rb index cc79b11616aca..a204365431b1e 100644 --- a/spec/support/prometheus_helpers.rb +++ b/spec/support/prometheus_helpers.rb @@ -33,6 +33,10 @@ def stub_prometheus_request(url, body: {}, status: 200) }) end + def stub_prometheus_request_with_exception(url, exception_type) + WebMock.stub_request(:get, url).to_raise(exception_type) + end + def stub_all_prometheus_requests(environment_slug, body: nil, status: 200) stub_prometheus_request( prometheus_query_url(prometheus_memory_query(environment_slug)), diff --git a/spec/support/services/issuable_create_service_shared_examples.rb b/spec/support/services/issuable_create_service_shared_examples.rb deleted file mode 100644 index 4f0c745b7ee48..0000000000000 --- a/spec/support/services/issuable_create_service_shared_examples.rb +++ /dev/null @@ -1,52 +0,0 @@ -shared_examples 'issuable create service' do - context 'asssignee_id' do - let(:assignee) { create(:user) } - - before { project.team << [user, :master] } - - it 'removes assignee_id when user id is invalid' do - opts = { title: 'Title', description: 'Description', assignee_id: -1 } - - issuable = described_class.new(project, user, opts).execute - - expect(issuable.assignee_id).to be_nil - end - - it 'removes assignee_id when user id is 0' do - opts = { title: 'Title', description: 'Description', assignee_id: 0 } - - issuable = described_class.new(project, user, opts).execute - - expect(issuable.assignee_id).to be_nil - end - - it 'saves assignee when user id is valid' do - project.team << [assignee, :master] - opts = { title: 'Title', description: 'Description', assignee_id: assignee.id } - - issuable = described_class.new(project, user, opts).execute - - expect(issuable.assignee_id).to eq(assignee.id) - end - - context "when issuable feature is private" do - before do - project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE, - merge_requests_access_level: ProjectFeature::PRIVATE) - end - - levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] - - levels.each do |level| - it "removes not authorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do - project.update(visibility_level: level) - opts = { title: 'Title', description: 'Description', assignee_id: assignee.id } - - issuable = described_class.new(project, user, opts).execute - - expect(issuable.assignee_id).to be_nil - end - end - end - end -end diff --git a/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb b/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb index 9e9cdf3e48b49..1dd3663b944fd 100644 --- a/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb +++ b/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb @@ -49,23 +49,7 @@ it 'assigns and sets milestone to issuable' do expect(issuable).to be_persisted - expect(issuable.assignee).to eq(assignee) - expect(issuable.milestone).to eq(milestone) - end - end - - context 'with assignee and milestone in params and command' do - let(:example_params) do - { - assignee: create(:user), - milestone_id: 1, - description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}") - } - end - - it 'assigns and sets milestone to issuable from command' do - expect(issuable).to be_persisted - expect(issuable.assignee).to eq(assignee) + expect(issuable.assignees).to eq([assignee]) expect(issuable.milestone).to eq(milestone) end end diff --git a/spec/support/services/issuable_update_service_shared_examples.rb b/spec/support/services/issuable_update_service_shared_examples.rb index 49cea1e608cd9..8947f20562f8b 100644 --- a/spec/support/services/issuable_update_service_shared_examples.rb +++ b/spec/support/services/issuable_update_service_shared_examples.rb @@ -18,52 +18,4 @@ def update_issuable(opts) end end end - - context 'asssignee_id' do - it 'does not update assignee when assignee_id is invalid' do - open_issuable.update(assignee_id: user.id) - - update_issuable(assignee_id: -1) - - expect(open_issuable.reload.assignee).to eq(user) - end - - it 'unassigns assignee when user id is 0' do - open_issuable.update(assignee_id: user.id) - - update_issuable(assignee_id: 0) - - expect(open_issuable.assignee_id).to be_nil - end - - it 'saves assignee when user id is valid' do - update_issuable(assignee_id: user.id) - - expect(open_issuable.assignee_id).to eq(user.id) - end - - it 'does not update assignee_id when user cannot read issue' do - non_member = create(:user) - original_assignee = open_issuable.assignee - - update_issuable(assignee_id: non_member.id) - - expect(open_issuable.assignee_id).to eq(original_assignee.id) - end - - context "when issuable feature is private" do - levels = [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC] - - levels.each do |level| - it "does not update with unauthorized assignee when project is #{Gitlab::VisibilityLevel.level_name(level)}" do - assignee = create(:user) - project.update(visibility_level: level) - feature_visibility_attr = :"#{open_issuable.model_name.plural}_access_level" - project.project_feature.update_attribute(feature_visibility_attr, ProjectFeature::PRIVATE) - - expect{ update_issuable(assignee_id: assignee) }.not_to change{ open_issuable.assignee } - end - end - end - end end diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index a5f034461b50f..8dc513be495fb 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -28,6 +28,7 @@ module TestEnv 'expand-collapse-files' => '025db92', 'expand-collapse-lines' => '238e82d', 'video' => '8879059', + 'add-balsamiq-file' => 'b89b56d', 'crlf-diff' => '5938907', 'conflict-start' => '824be60', 'conflict-resolvable' => '1450cd6', diff --git a/spec/support/time_tracking_shared_examples.rb b/spec/support/time_tracking_shared_examples.rb index 01bc80f957e24..84ef46ffa27bf 100644 --- a/spec/support/time_tracking_shared_examples.rb +++ b/spec/support/time_tracking_shared_examples.rb @@ -8,6 +8,7 @@ it 'updates the sidebar component when estimate is added' do submit_time('/estimate 3w 1d 1h') + wait_for_ajax page.within '.time-tracking-estimate-only-pane' do expect(page).to have_content '3w 1d 1h' end @@ -16,6 +17,7 @@ it 'updates the sidebar component when spent is added' do submit_time('/spend 3w 1d 1h') + wait_for_ajax page.within '.time-tracking-spend-only-pane' do expect(page).to have_content '3w 1d 1h' end @@ -25,6 +27,7 @@ submit_time('/estimate 3w 1d 1h') submit_time('/spend 3w 1d 1h') + wait_for_ajax page.within '.time-tracking-comparison-pane' do expect(page).to have_content '3w 1d 1h' end @@ -34,7 +37,7 @@ submit_time('/estimate 3w 1d 1h') submit_time('/remove_estimate') - page.within '#issuable-time-tracker' do + page.within '.time-tracking-component-wrap' do expect(page).to have_content 'No estimate or time spent' end end @@ -43,13 +46,13 @@ submit_time('/spend 3w 1d 1h') submit_time('/remove_time_spent') - page.within '#issuable-time-tracker' do + page.within '.time-tracking-component-wrap' do expect(page).to have_content 'No estimate or time spent' end end it 'shows the help state when icon is clicked' do - page.within '#issuable-time-tracker' do + page.within '.time-tracking-component-wrap' do find('.help-button').click expect(page).to have_content 'Track time with slash commands' expect(page).to have_content 'Learn more' @@ -57,7 +60,7 @@ end it 'hides the help state when close icon is clicked' do - page.within '#issuable-time-tracker' do + page.within '.time-tracking-component-wrap' do find('.help-button').click find('.close-help-button').click @@ -67,7 +70,7 @@ end it 'displays the correct help url' do - page.within '#issuable-time-tracker' do + page.within '.time-tracking-component-wrap' do find('.help-button').click expect(find_link('Learn more')[:href]).to have_content('/help/workflow/time_tracking.md') diff --git a/spec/views/projects/tags/index.html.haml_spec.rb b/spec/views/projects/tags/index.html.haml_spec.rb new file mode 100644 index 0000000000000..33122365e9ab7 --- /dev/null +++ b/spec/views/projects/tags/index.html.haml_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe 'projects/tags/index', :view do + let(:project) { create(:project) } + + before do + assign(:project, project) + assign(:repository, project.repository) + assign(:tags, []) + + allow(view).to receive(:current_ref).and_return('master') + allow(view).to receive(:can?).and_return(false) + end + + it 'defaults sort dropdown toggle to last updated' do + render + + expect(rendered).to have_button('Last updated') + end +end diff --git a/spec/workers/elastic_commit_indexer_worker_spec.rb b/spec/workers/elastic_commit_indexer_worker_spec.rb index d60f6deb3b118..0227b9c88a540 100644 --- a/spec/workers/elastic_commit_indexer_worker_spec.rb +++ b/spec/workers/elastic_commit_indexer_worker_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe ElasticCommitIndexerWorker do - let(:project) { create(:project) } + let!(:project) { create(:project) } subject { described_class.new } @@ -12,6 +12,7 @@ it 'runs indexer' do expect_any_instance_of(Gitlab::Elastic::Indexer).to receive(:run) + subject.perform(project.id, '0000', '0000') end diff --git a/spec/workers/propagate_service_template_worker_spec.rb b/spec/workers/propagate_service_template_worker_spec.rb new file mode 100644 index 0000000000000..7040d5ef81c97 --- /dev/null +++ b/spec/workers/propagate_service_template_worker_spec.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +describe PropagateServiceTemplateWorker do + let!(:service_template) do + PushoverService.create( + template: true, + active: true, + properties: { + device: 'MyDevice', + sound: 'mic', + priority: 4, + user_key: 'asdf', + api_key: '123456789' + }) + end + + before do + allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain). + and_return(true) + end + + describe '#perform' do + it 'calls the propagate service with the template' do + expect(Projects::PropagateServiceTemplate).to receive(:propagate).with(service_template) + + subject.perform(service_template.id) + end + end +end diff --git a/yarn.lock b/yarn.lock index 2042d60036f9d..2024f7ce30c0b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2159,6 +2159,13 @@ expand-range@^1.8.1: dependencies: fill-range "^2.1.0" +exports-loader@^0.6.4: + version "0.6.4" + resolved "https://registry.yarnpkg.com/exports-loader/-/exports-loader-0.6.4.tgz#d70fc6121975b35fc12830cf52754be2740fc886" + dependencies: + loader-utils "^1.0.2" + source-map "0.5.x" + express@^4.13.3, express@^4.14.1: version "4.14.1" resolved "https://registry.yarnpkg.com/express/-/express-4.14.1.tgz#646c237f766f148c2120aff073817b9e4d7e0d33" @@ -3102,6 +3109,10 @@ jasmine-jquery@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/jasmine-jquery/-/jasmine-jquery-2.1.1.tgz#d4095e646944a26763235769ab018d9f30f0d47b" +jed@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/jed/-/jed-1.1.1.tgz#7a549bbd9ffe1585b0cd0a191e203055bee574b4" + jodid25519@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/jodid25519/-/jodid25519-1.0.2.tgz#06d4912255093419477d425633606e0e90782967" @@ -3179,7 +3190,7 @@ json-stable-stringify@^1.0.0, json-stable-stringify@^1.0.1: dependencies: jsonify "~0.0.0" -json-stringify-safe@~5.0.1: +json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" @@ -4573,6 +4584,12 @@ raphael@^2.2.7: dependencies: eve-raphael "0.5.0" +raven-js@^3.14.0: + version "3.14.0" + resolved "https://registry.yarnpkg.com/raven-js/-/raven-js-3.14.0.tgz#94dda81d975fdc4a42f193db437cf70021d654e0" + dependencies: + json-stringify-safe "^5.0.1" + raw-body@~2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.2.0.tgz#994976cf6a5096a41162840492f0bdc5d6e7fb96" @@ -5118,6 +5135,10 @@ source-map-support@^0.4.2: dependencies: source-map "^0.5.3" +source-map@0.5.x, source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6, source-map@~0.5.1, source-map@~0.5.3: + version "0.5.6" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" + source-map@^0.1.41: version "0.1.43" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.1.43.tgz#c24bc146ca517c1471f5dacbe2571b2b7f9e3346" @@ -5130,10 +5151,6 @@ source-map@^0.4.4: dependencies: amdefine ">=0.0.4" -source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6, source-map@~0.5.1, source-map@~0.5.3: - version "0.5.6" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" - source-map@~0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.2.0.tgz#dab73fbcfc2ba819b4de03bd6f6eaa48164b3f9d" @@ -5184,6 +5201,10 @@ sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" +sql.js@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/sql.js/-/sql.js-0.4.0.tgz#23be9635520eb0ff43a741e7e830397266e88445" + sshpk@^1.7.0: version "1.10.2" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.10.2.tgz#d5a804ce22695515638e798dbe23273de070a5fa" -- GitLab