diff --git a/Gemfile b/Gemfile index 327d35c2350..9e815925a1f 100644 --- a/Gemfile +++ b/Gemfile @@ -330,6 +330,7 @@ gem 'octokit', '~> 4.3.0' gem 'mail_room', '~> 0.9.0' gem 'email_reply_parser', '~> 0.5.8' +gem 'html2text' gem 'ruby-prof', '~> 0.16.2' diff --git a/Gemfile.lock b/Gemfile.lock index 4532f16cfe0..bdc60552480 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -339,6 +339,8 @@ GEM html-pipeline (1.11.0) activesupport (>= 2) nokogiri (~> 1.4) + html2text (0.2.0) + nokogiri (~> 1.6) htmlentities (4.3.4) httparty (0.13.7) json (~> 1.8) @@ -873,6 +875,7 @@ DEPENDENCIES health_check (~> 2.2.0) hipchat (~> 1.5.0) html-pipeline (~> 1.11.0) + html2text httparty (~> 0.13.3) influxdb (~> 0.2) jira-ruby (~> 1.1.2) diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js index 68012e8cf42..e198306e67a 100644 --- a/app/assets/javascripts/build.js +++ b/app/assets/javascripts/build.js @@ -172,7 +172,7 @@ $date = $('.js-artifacts-remove'); if ($date.length) { date = $date.text(); - return $date.text(gl.utils.timefor(new Date(date.replace(/([0-9]+)-([0-9]+)-([0-9]+)/g, '$1/$2/$3')), ' ')); + return $date.text(gl.utils.timeFor(new Date(date.replace(/([0-9]+)-([0-9]+)-([0-9]+)/g, '$1/$2/$3')), ' ')); } }; diff --git a/app/assets/javascripts/environments/components/environment.js.es6 b/app/assets/javascripts/environments/components/environment.js.es6 new file mode 100644 index 00000000000..b769161e058 --- /dev/null +++ b/app/assets/javascripts/environments/components/environment.js.es6 @@ -0,0 +1,248 @@ +//= require vue +//= require vue-resource +//= require_tree ../services/ +//= require ./environment_item + +/* globals Vue, EnvironmentsService */ +/* eslint-disable no-param-reassign */ + +(() => { // eslint-disable-line + window.gl = window.gl || {}; + + /** + * Given the visibility prop provided by the url query parameter and which + * changes according to the active tab we need to filter which environments + * should be visible. + * + * The environments array is a recursive tree structure and we need to filter + * both root level environments and children environments. + * + * In order to acomplish that, both `filterState` and `filterEnvironmnetsByState` + * functions work together. + * The first one works as the filter that verifies if the given environment matches + * the given state. + * The second guarantees both root level and children elements are filtered as well. + */ + + const filterState = state => environment => environment.state === state && environment; + /** + * Given the filter function and the array of environments will return only + * the environments that match the state provided to the filter function. + * + * @param {Function} fn + * @param {Array} array + * @return {Array} + */ + const filterEnvironmnetsByState = (fn, arr) => arr.map((item) => { + if (item.children) { + const filteredChildren = filterEnvironmnetsByState(fn, item.children).filter(Boolean); + if (filteredChildren.length) { + item.children = filteredChildren; + return item; + } + } + return fn(item); + }).filter(Boolean); + + window.gl.environmentsList.EnvironmentsComponent = Vue.component('environment-component', { + props: { + store: { + type: Object, + required: true, + default: () => ({}), + }, + }, + + components: { + 'environment-item': window.gl.environmentsList.EnvironmentItem, + }, + + data() { + const environmentsData = document.querySelector('#environments-list-view').dataset; + + return { + state: this.store.state, + visibility: 'available', + isLoading: false, + cssContainerClass: environmentsData.cssClass, + endpoint: environmentsData.environmentsDataEndpoint, + canCreateDeployment: environmentsData.canCreateDeployment, + canReadEnvironment: environmentsData.canReadEnvironment, + canCreateEnvironment: environmentsData.canCreateEnvironment, + projectEnvironmentsPath: environmentsData.projectEnvironmentsPath, + projectStoppedEnvironmentsPath: environmentsData.projectStoppedEnvironmentsPath, + newEnvironmentPath: environmentsData.newEnvironmentPath, + helpPagePath: environmentsData.helpPagePath, + }; + }, + + computed: { + filteredEnvironments() { + return filterEnvironmnetsByState(filterState(this.visibility), this.state.environments); + }, + + scope() { + return this.$options.getQueryParameter('scope'); + }, + + canReadEnvironmentParsed() { + return this.$options.convertPermissionToBoolean(this.canReadEnvironment); + }, + + canCreateDeploymentParsed() { + return this.$options.convertPermissionToBoolean(this.canCreateDeployment); + }, + + canCreateEnvironmentParsed() { + return this.$options.convertPermissionToBoolean(this.canCreateEnvironment); + }, + }, + + /** + * Fetches all the environmnets and stores them. + * Toggles loading property. + */ + created() { + gl.environmentsService = new EnvironmentsService(this.endpoint); + + const scope = this.$options.getQueryParameter('scope'); + if (scope) { + this.visibility = scope; + } + + this.isLoading = true; + + return gl.environmentsService.all() + .then(resp => resp.json()) + .then((json) => { + this.store.storeEnvironments(json); + this.isLoading = false; + }); + }, + + /** + * Transforms the url parameter into an object and + * returns the one requested. + * + * @param {String} param + * @returns {String} The value of the requested parameter. + */ + getQueryParameter(parameter) { + return window.location.search.substring(1).split('&').reduce((acc, param) => { + const paramSplited = param.split('='); + acc[paramSplited[0]] = paramSplited[1]; + return acc; + }, {})[parameter]; + }, + + /** + * Converts permission provided as strings to booleans. + * @param {String} string + * @returns {Boolean} + */ + convertPermissionToBoolean(string) { + return string === 'true'; + }, + + methods: { + toggleRow(model) { + return this.store.toggleFolder(model.name); + }, + }, + + template: ` +
+
+ + +
+ +
+
+ +
+ +
+

+ You don't have any environments right now. +

+

+ Environments are places where code gets deployed, such as staging or production. +
+ + Read more about environments + +

+ + + New Environment + +
+ +
+ + + + + + + + + + + + + + +
EnvironmentLast deploymentBuildCommit
+
+
+
+ `, + }); +})(); diff --git a/app/assets/javascripts/environments/components/environment_actions.js.es6 b/app/assets/javascripts/environments/components/environment_actions.js.es6 new file mode 100644 index 00000000000..edd39c02a46 --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_actions.js.es6 @@ -0,0 +1,67 @@ +/*= require vue */ +/* global Vue */ + +(() => { + window.gl = window.gl || {}; + window.gl.environmentsList = window.gl.environmentsList || {}; + + window.gl.environmentsList.ActionsComponent = Vue.component('actions-component', { + props: { + actions: { + type: Array, + required: false, + default: () => [], + }, + }, + + /** + * Appends the svg icon that were render in the index page. + * In order to reuse the svg instead of copy and paste in this template + * we need to render it outside this component using =custom_icon partial. + * + * TODO: Remove this when webpack is merged. + * + */ + mounted() { + const playIcon = document.querySelector('.play-icon-svg.hidden svg'); + + const dropdownContainer = this.$el.querySelector('.dropdown-play-icon-container'); + const actionContainers = this.$el.querySelectorAll('.action-play-icon-container'); + // Phantomjs does not have support to iterate a nodelist. + const actionsArray = [].slice.call(actionContainers); + + if (playIcon && actionsArray && dropdownContainer) { + dropdownContainer.appendChild(playIcon.cloneNode(true)); + + actionsArray.forEach((element) => { + element.appendChild(playIcon.cloneNode(true)); + }); + } + }, + + template: ` +
+ +
+ `, + }); +})(); diff --git a/app/assets/javascripts/environments/components/environment_external_url.js.es6 b/app/assets/javascripts/environments/components/environment_external_url.js.es6 new file mode 100644 index 00000000000..79cd5ded5bd --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_external_url.js.es6 @@ -0,0 +1,22 @@ +/*= require vue */ +/* global Vue */ + +(() => { + window.gl = window.gl || {}; + window.gl.environmentsList = window.gl.environmentsList || {}; + + window.gl.environmentsList.ExternalUrlComponent = Vue.component('external-url-component', { + props: { + external_url: { + type: String, + default: '', + }, + }, + + template: ` + + + + `, + }); +})(); diff --git a/app/assets/javascripts/environments/components/environment_item.js.es6 b/app/assets/javascripts/environments/components/environment_item.js.es6 new file mode 100644 index 00000000000..2f7d1d2a177 --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_item.js.es6 @@ -0,0 +1,494 @@ +/*= require lib/utils/timeago */ +/*= require lib/utils/text_utility */ +/*= require vue_common_component/commit */ +/*= require ./environment_actions */ +/*= require ./environment_external_url */ +/*= require ./environment_stop */ +/*= require ./environment_rollback */ + +/* globals Vue, timeago */ + +(() => { + /** + * Envrionment Item Component + * + * Used in a hierarchical structure to show folders with children + * in a table. + * Recursive component based on [Tree View](https://vuejs.org/examples/tree-view.html) + * + * See this [issue](https://gitlab.com/gitlab-org/gitlab-ce/issues/22539) + * for more information.15 + */ + + window.gl = window.gl || {}; + window.gl.environmentsList = window.gl.environmentsList || {}; + + gl.environmentsList.EnvironmentItem = Vue.component('environment-item', { + + components: { + 'commit-component': window.gl.CommitComponent, + 'actions-component': window.gl.environmentsList.ActionsComponent, + 'external-url-component': window.gl.environmentsList.ExternalUrlComponent, + 'stop-component': window.gl.environmentsList.StopComponent, + 'rollback-component': window.gl.environmentsList.RollbackComponent, + }, + + props: { + model: { + type: Object, + required: true, + default: () => ({}), + }, + + toggleRow: { + type: Function, + required: false, + }, + + canCreateDeployment: { + type: Boolean, + required: false, + default: false, + }, + + canReadEnvironment: { + type: Boolean, + required: false, + default: false, + }, + }, + + data() { + return { + rowClass: { + 'children-row': this.model['vue-isChildren'], + }, + }; + }, + + computed: { + + /** + * If an item has a `children` entry it means it is a folder. + * Folder items have different behaviours - it is possible to toggle + * them and show their children. + * + * @returns {Boolean|Undefined} + */ + isFolder() { + return this.model.children && this.model.children.length > 0; + }, + + /** + * If an item is inside a folder structure will return true. + * Used for css purposes. + * + * @returns {Boolean|undefined} + */ + isChildren() { + return this.model['vue-isChildren']; + }, + + /** + * Counts the number of environments in each folder. + * Used to show a badge with the counter. + * + * @returns {Number|Undefined} The number of environments for the current folder. + */ + childrenCounter() { + return this.model.children && this.model.children.length; + }, + + /** + * Verifies if `last_deployment` key exists in the current Envrionment. + * This key is required to render most of the html - this method works has + * an helper. + * + * @returns {Boolean} + */ + hasLastDeploymentKey() { + if (this.model.last_deployment && + !this.$options.isObjectEmpty(this.model.last_deployment)) { + return true; + } + return false; + }, + + /** + * Verifies is the given environment has manual actions. + * Used to verify if we should render them or nor. + * + * @returns {Boolean|Undefined} + */ + hasManualActions() { + return this.model.last_deployment && this.model.last_deployment.manual_actions && + this.model.last_deployment.manual_actions.length > 0; + }, + + /** + * Returns the value of the `stoppable?` key provided in the response. + * + * @returns {Boolean} + */ + isStoppable() { + return this.model['stoppable?']; + }, + + /** + * Verifies if the `deployable` key is present in `last_deployment` key. + * Used to verify whether we should or not render the rollback partial. + * + * @returns {Boolean|Undefined} + */ + canRetry() { + return this.hasLastDeploymentKey && + this.model.last_deployment && + this.model.last_deployment.deployable; + }, + + /** + * Human readable date. + * + * @returns {String} + */ + createdDate() { + const timeagoInstance = new timeago(); // eslint-disable-line + + return timeagoInstance.format(this.model.created_at); + }, + + /** + * Returns the manual actions with the name parsed. + * + * @returns {Array.|Undefined} + */ + manualActions() { + if (this.hasManualActions) { + return this.model.last_deployment.manual_actions.map((action) => { + const parsedAction = { + name: gl.text.humanize(action.name), + play_path: action.play_path, + }; + return parsedAction; + }); + } + return []; + }, + + /** + * Builds the string used in the user image alt attribute. + * + * @returns {String} + */ + userImageAltDescription() { + if (this.model.last_deployment && + this.model.last_deployment.user && + this.model.last_deployment.user.username) { + return `${this.model.last_deployment.user.username}'s avatar'`; + } + return ''; + }, + + /** + * If provided, returns the commit tag. + * + * @returns {String|Undefined} + */ + commitTag() { + if (this.model.last_deployment && + this.model.last_deployment.tag) { + return this.model.last_deployment.tag; + } + return undefined; + }, + + /** + * If provided, returns the commit ref. + * + * @returns {Object|Undefined} + */ + commitRef() { + if (this.model.last_deployment && this.model.last_deployment.ref) { + return this.model.last_deployment.ref; + } + return undefined; + }, + + /** + * If provided, returns the commit url. + * + * @returns {String|Undefined} + */ + commitUrl() { + if (this.model.last_deployment && + this.model.last_deployment.commit && + this.model.last_deployment.commit.commit_path) { + return this.model.last_deployment.commit.commit_path; + } + return undefined; + }, + + /** + * If provided, returns the commit short sha. + * + * @returns {String|Undefined} + */ + commitShortSha() { + if (this.model.last_deployment && + this.model.last_deployment.commit && + this.model.last_deployment.commit.short_id) { + return this.model.last_deployment.commit.short_id; + } + return undefined; + }, + + /** + * If provided, returns the commit title. + * + * @returns {String|Undefined} + */ + commitTitle() { + if (this.model.last_deployment && + this.model.last_deployment.commit && + this.model.last_deployment.commit.title) { + return this.model.last_deployment.commit.title; + } + return undefined; + }, + + /** + * If provided, returns the commit tag. + * + * @returns {Object|Undefined} + */ + commitAuthor() { + if (this.model.last_deployment && + this.model.last_deployment.commit && + this.model.last_deployment.commit.author) { + return this.model.last_deployment.commit.author; + } + + return undefined; + }, + + /** + * Verifies if the `retry_path` key is present and returns its value. + * + * @returns {String|Undefined} + */ + retryUrl() { + if (this.model.last_deployment && + this.model.last_deployment.deployable && + this.model.last_deployment.deployable.retry_path) { + return this.model.last_deployment.deployable.retry_path; + } + return undefined; + }, + + /** + * Verifies if the `last?` key is present and returns its value. + * + * @returns {Boolean|Undefined} + */ + isLastDeployment() { + return this.model.last_deployment && this.model.last_deployment['last?']; + }, + + /** + * Builds the name of the builds needed to display both the name and the id. + * + * @returns {String} + */ + buildName() { + if (this.model.last_deployment && + this.model.last_deployment.deployable) { + return `${this.model.last_deployment.deployable.name} #${this.model.last_deployment.deployable.id}`; + } + return ''; + }, + + /** + * Builds the needed string to show the internal id. + * + * @returns {String} + */ + deploymentInternalId() { + if (this.model.last_deployment && + this.model.last_deployment.iid) { + return `#${this.model.last_deployment.iid}`; + } + return ''; + }, + + /** + * Verifies if the user object is present under last_deployment object. + * + * @returns {Boolean} + */ + deploymentHasUser() { + return !this.$options.isObjectEmpty(this.model.last_deployment) && + !this.$options.isObjectEmpty(this.model.last_deployment.user); + }, + + /** + * Returns the user object nested with the last_deployment object. + * Used to render the template. + * + * @returns {Object} + */ + deploymentUser() { + if (!this.$options.isObjectEmpty(this.model.last_deployment) && + !this.$options.isObjectEmpty(this.model.last_deployment.user)) { + return this.model.last_deployment.user; + } + return {}; + }, + + /** + * Verifies if the build name column should be rendered by verifing + * if all the information needed is present + * and if the environment is not a folder. + * + * @returns {Boolean} + */ + shouldRenderBuildName() { + return !this.isFolder && + !this.$options.isObjectEmpty(this.model.last_deployment) && + !this.$options.isObjectEmpty(this.model.last_deployment.deployable); + }, + + /** + * Verifies if deplyment internal ID should be rendered by verifing + * if all the information needed is present + * and if the environment is not a folder. + * + * @returns {Boolean} + */ + shouldRenderDeploymentID() { + return !this.isFolder && + !this.$options.isObjectEmpty(this.model.last_deployment) && + this.model.last_deployment.iid !== undefined; + }, + }, + + /** + * Helper to verify if certain given object are empty. + * Should be replaced by lodash _.isEmpty - https://lodash.com/docs/4.17.2#isEmpty + * @param {Object} object + * @returns {Bollean} + */ + isObjectEmpty(object) { + for (const key in object) { // eslint-disable-line + if (hasOwnProperty.call(object, key)) { + return false; + } + } + return true; + }, + + template: ` + + + + + + + + + + + + + + + + + + + + + + by + + + + + + + + + + + + +
+ + +
+

+ No deployments yet +

+ + + + + + + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + `, + }); +})(); diff --git a/app/assets/javascripts/environments/components/environment_rollback.js.es6 b/app/assets/javascripts/environments/components/environment_rollback.js.es6 new file mode 100644 index 00000000000..55e5c826e07 --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_rollback.js.es6 @@ -0,0 +1,31 @@ +/*= require vue */ +/* global Vue */ + +(() => { + window.gl = window.gl || {}; + window.gl.environmentsList = window.gl.environmentsList || {}; + + window.gl.environmentsList.RollbackComponent = Vue.component('rollback-component', { + props: { + retry_url: { + type: String, + default: '', + }, + is_last_deployment: { + type: Boolean, + default: true, + }, + }, + + template: ` + + + Re-deploy + + + Rollback + + + `, + }); +})(); diff --git a/app/assets/javascripts/environments/components/environment_stop.js.es6 b/app/assets/javascripts/environments/components/environment_stop.js.es6 new file mode 100644 index 00000000000..2c732e50180 --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_stop.js.es6 @@ -0,0 +1,27 @@ +/*= require vue */ +/* global Vue */ + +(() => { + window.gl = window.gl || {}; + window.gl.environmentsList = window.gl.environmentsList || {}; + + window.gl.environmentsList.StopComponent = Vue.component('stop-component', { + props: { + stop_url: { + type: String, + default: '', + }, + }, + + template: ` + + + + `, + }); +})(); diff --git a/app/assets/javascripts/environments/environments_bundle.js.es6 b/app/assets/javascripts/environments/environments_bundle.js.es6 new file mode 100644 index 00000000000..20eee7976ec --- /dev/null +++ b/app/assets/javascripts/environments/environments_bundle.js.es6 @@ -0,0 +1,21 @@ +//= require vue +//= require_tree ./stores/ +//= require ./components/environment +//= require ./vue_resource_interceptor + + +$(() => { + window.gl = window.gl || {}; + + if (window.gl.EnvironmentsListApp) { + window.gl.EnvironmentsListApp.$destroy(true); + } + const Store = window.gl.environmentsList.EnvironmentsStore; + + window.gl.EnvironmentsListApp = new window.gl.environmentsList.EnvironmentsComponent({ + el: document.querySelector('#environments-list-view'), + propsData: { + store: Store.create(), + }, + }); +}); diff --git a/app/assets/javascripts/environments/services/environments_service.js.es6 b/app/assets/javascripts/environments/services/environments_service.js.es6 new file mode 100644 index 00000000000..15ec7b76c3d --- /dev/null +++ b/app/assets/javascripts/environments/services/environments_service.js.es6 @@ -0,0 +1,22 @@ +/* globals Vue */ +/* eslint-disable no-unused-vars, no-param-reassign */ +class EnvironmentsService { + + constructor(root) { + Vue.http.options.root = root; + + this.environments = Vue.resource(root); + + Vue.http.interceptors.push((request, next) => { + // needed in order to not break the tests. + if ($.rails) { + request.headers['X-CSRF-Token'] = $.rails.csrfToken(); + } + next(); + }); + } + + all() { + return this.environments.get(); + } +} diff --git a/app/assets/javascripts/environments/stores/environments_store.js.es6 b/app/assets/javascripts/environments/stores/environments_store.js.es6 new file mode 100644 index 00000000000..0204a903ab5 --- /dev/null +++ b/app/assets/javascripts/environments/stores/environments_store.js.es6 @@ -0,0 +1,131 @@ +/* eslint-disable no-param-reassign */ +(() => { + window.gl = window.gl || {}; + window.gl.environmentsList = window.gl.environmentsList || {}; + + gl.environmentsList.EnvironmentsStore = { + state: {}, + + create() { + this.state.environments = []; + this.state.stoppedCounter = 0; + this.state.availableCounter = 0; + + return this; + }, + + /** + * In order to display a tree view we need to modify the received + * data in to a tree structure based on `environment_type` + * sorted alphabetically. + * In each children a `vue-` property will be added. This property will be + * used to know if an item is a children mostly for css purposes. This is + * needed because the children row is a fragment instance and therfore does + * not accept non-prop attributes. + * + * + * @example + * it will transform this: + * [ + * { name: "environment", environment_type: "review" }, + * { name: "environment_1", environment_type: null } + * { name: "environment_2, environment_type: "review" } + * ] + * into this: + * [ + * { name: "review", children: + * [ + * { name: "environment", environment_type: "review", vue-isChildren: true}, + * { name: "environment_2", environment_type: "review", vue-isChildren: true} + * ] + * }, + * {name: "environment_1", environment_type: null} + * ] + * + * + * @param {Array} environments List of environments. + * @returns {Array} Tree structured array with the received environments. + */ + storeEnvironments(environments = []) { + this.state.stoppedCounter = this.countByState(environments, 'stopped'); + this.state.availableCounter = this.countByState(environments, 'available'); + + const environmentsTree = environments.reduce((acc, environment) => { + if (environment.environment_type !== null) { + const occurs = acc.filter(element => element.children && + element.name === environment.environment_type); + + environment['vue-isChildren'] = true; + + if (occurs.length) { + acc[acc.indexOf(occurs[0])].children.push(environment); + acc[acc.indexOf(occurs[0])].children.sort(this.sortByName); + } else { + acc.push({ + name: environment.environment_type, + children: [environment], + isOpen: false, + 'vue-isChildren': environment['vue-isChildren'], + }); + } + } else { + acc.push(environment); + } + + return acc; + }, []).sort(this.sortByName); + + this.state.environments = environmentsTree; + + return environmentsTree; + }, + + /** + * Toggles folder open property given the environment type. + * + * @param {String} envType + * @return {Array} + */ + toggleFolder(envType) { + const environments = this.state.environments; + + const environmentsCopy = environments.map((env) => { + if (env['vue-isChildren'] && env.name === envType) { + env.isOpen = !env.isOpen; + } + + return env; + }); + + this.state.environments = environmentsCopy; + + return environmentsCopy; + }, + + /** + * Given an array of environments, returns the number of environments + * that have the given state. + * + * @param {Array} environments + * @param {String} state + * @returns {Number} + */ + countByState(environments, state) { + return environments.filter(env => env.state === state).length; + }, + + /** + * Sorts the two objects provided by their name. + * + * @param {Object} a + * @param {Object} b + * @returns {Number} + */ + sortByName(a, b) { + const nameA = a.name.toUpperCase(); + const nameB = b.name.toUpperCase(); + + return nameA < nameB ? -1 : nameA > nameB ? 1 : 0; // eslint-disable-line + }, + }; +})(); diff --git a/app/assets/javascripts/environments/vue_resource_interceptor.js.es6 b/app/assets/javascripts/environments/vue_resource_interceptor.js.es6 new file mode 100644 index 00000000000..406bdbc1c7d --- /dev/null +++ b/app/assets/javascripts/environments/vue_resource_interceptor.js.es6 @@ -0,0 +1,12 @@ +/* global Vue */ +Vue.http.interceptors.push((request, next) => { + Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1; + + next((response) => { + if (typeof response.data === 'string') { + response.data = JSON.parse(response.data); // eslint-disable-line + } + + Vue.activeResources--; // eslint-disable-line + }); +}); diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 2a38ac28172..d83c41fae9d 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -125,6 +125,11 @@ // Close any open tooltips $('.has-tooltip, [data-toggle="tooltip"]').tooltip('destroy'); }; + + gl.utils.isMetaKey = function(e) { + return e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; + }; + })(window); }).call(this); diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 5b4123a483b..ac44b81ee22 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -112,6 +112,9 @@ gl.text.removeListeners = function(form) { return $('.js-md', form).off(); }; + gl.text.humanize = function(string) { + return string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); + } return gl.text.truncate = function(string, maxLength) { return string.substr(0, (maxLength - 3)) + '...'; }; diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 44079bc3ca3..6cb87f9ba81 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -12,7 +12,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; this.Notes = (function() { - var isMetaKey; + const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; Notes.interval = null; @@ -33,6 +33,7 @@ this.resetMainTargetForm = bind(this.resetMainTargetForm, this); this.refresh = bind(this.refresh, this); this.keydownNoteText = bind(this.keydownNoteText, this); + this.toggleCommitList = bind(this.toggleCommitList, this); this.notes_url = notes_url; this.note_ids = note_ids; this.last_fetched_at = last_fetched_at; @@ -46,6 +47,7 @@ this.setPollingInterval(); this.setupMainTargetNoteForm(); this.initTaskList(); + this.collapseLongCommitList(); } Notes.prototype.addBinding = function() { @@ -81,10 +83,13 @@ $(document).on("click", ".js-add-diff-note-button", this.addDiffNote); // hide diff note form $(document).on("click", ".js-close-discussion-note-form", this.cancelDiscussionForm); + // toggle commit list + $(document).on("click", '.system-note-commit-list-toggler', this.toggleCommitList); // fetch notes when tab becomes visible $(document).on("visibilitychange", this.visibilityChange); // when issue status changes, we need to refresh data $(document).on("issuable:change", this.refresh); + // when a key is clicked on the notes return $(document).on("keydown", ".js-note-text", this.keydownNoteText); }; @@ -114,9 +119,10 @@ Notes.prototype.keydownNoteText = function(e) { var $textarea, discussionNoteForm, editNote, myLastNote, myLastNoteEditBtn, newText, originalText; - if (isMetaKey(e)) { + if (gl.utils.isMetaKey(e)) { return; } + $textarea = $(e.target); // Edit previous note when UP arrow is hit switch (e.which) { @@ -156,10 +162,6 @@ } }; - isMetaKey = function(e) { - return e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; - }; - Notes.prototype.initRefresh = function() { clearInterval(Notes.interval); return Notes.interval = setInterval((function(_this) { @@ -263,6 +265,7 @@ $notesList.append(note.html).syntaxHighlight(); // Update datetime format on the recent note gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false); + this.collapseLongCommitList(); this.initTaskList(); this.refresh(); return this.updateNotesCount(1); @@ -433,9 +436,9 @@ var $form = $(xhr.target); if ($form.attr('data-resolve-all') != null) { - var projectPath = $form.data('project-path') - discussionId = $form.data('discussion-id'), - mergeRequestId = $form.data('noteable-iid'); + var projectPath = $form.data('project-path'); + var discussionId = $form.data('discussion-id'); + var mergeRequestId = $form.data('noteable-iid'); if (ResolveService != null) { ResolveService.toggleResolveForDiscussion(projectPath, mergeRequestId, discussionId); @@ -844,9 +847,9 @@ return this.notesCountBadge.text(parseInt(this.notesCountBadge.text()) + updateCount); }; - Notes.prototype.resolveDiscussion = function () { - var $this = $(this), - discussionId = $this.attr('data-discussion-id'); + Notes.prototype.resolveDiscussion = function() { + var $this = $(this); + var discussionId = $this.attr('data-discussion-id'); $this .closest('form') @@ -855,6 +858,36 @@ .attr('data-project-path', $this.attr('data-project-path')); }; + Notes.prototype.toggleCommitList = function(e) { + const $element = $(e.target); + const $closestSystemCommitList = $element.siblings('.system-note-commit-list'); + + $closestSystemCommitList.toggleClass('hide-shade'); + }; + + /** + Scans system notes with `ul` elements in system note body + then collapse long commit list pushed by user to make it less + intrusive. + */ + Notes.prototype.collapseLongCommitList = function() { + const systemNotes = $('#notes-list').find('li.system-note').has('ul'); + + $.each(systemNotes, function(index, systemNote) { + const $systemNote = $(systemNote); + const headerMessage = $systemNote.find('.note-text').find('p:first').text().replace(':', ''); + + $systemNote.find('.note-header .system-note-message').html(headerMessage); + + if ($systemNote.find('li').length > MAX_VISIBLE_COMMIT_LIST_COUNT) { + $systemNote.find('.note-text').addClass('system-note-commit-list'); + $systemNote.find('.system-note-commit-list-toggler').show(); + } else { + $systemNote.find('.note-text').addClass('system-note-commit-list hide-shade'); + } + }); + }; + return Notes; })(); diff --git a/app/assets/javascripts/pipelines.js.es6 b/app/assets/javascripts/pipelines.js.es6 index e6fada5c84c..a84db9c0233 100644 --- a/app/assets/javascripts/pipelines.js.es6 +++ b/app/assets/javascripts/pipelines.js.es6 @@ -3,26 +3,12 @@ class Pipelines { constructor() { - this.initGraphToggle(); this.addMarginToBuildColumns(); } - initGraphToggle() { - this.pipelineGraph = document.querySelector('.pipeline-graph'); - this.toggleButton = document.querySelector('.toggle-pipeline-btn'); - this.toggleButtonText = this.toggleButton.querySelector('.toggle-btn-text'); - this.toggleButton.addEventListener('click', this.toggleGraph.bind(this)); - } - - toggleGraph() { - const graphCollapsed = this.pipelineGraph.classList.contains('graph-collapsed'); - this.toggleButton.classList.toggle('graph-collapsed'); - this.pipelineGraph.classList.toggle('graph-collapsed'); - this.toggleButtonText.textContent = graphCollapsed ? 'Hide' : 'Expand'; - } - addMarginToBuildColumns() { - const secondChildBuildNodes = this.pipelineGraph.querySelectorAll('.build:nth-child(2)'); + this.pipelineGraph = document.querySelector('.pipeline-graph'); + const secondChildBuildNodes = document.querySelector('.pipeline-graph').querySelectorAll('.build:nth-child(2)'); for (buildNodeIndex in secondChildBuildNodes) { const buildNode = secondChildBuildNodes[buildNodeIndex]; const firstChildBuildNode = buildNode.previousElementSibling; diff --git a/app/assets/javascripts/vue_common_component/commit.js.es6 b/app/assets/javascripts/vue_common_component/commit.js.es6 new file mode 100644 index 00000000000..fd628fad4d7 --- /dev/null +++ b/app/assets/javascripts/vue_common_component/commit.js.es6 @@ -0,0 +1,176 @@ +/*= require vue */ +/* global Vue */ +(() => { + window.gl = window.gl || {}; + + window.gl.CommitComponent = Vue.component('commit-component', { + + props: { + /** + * Indicates the existance of a tag. + * Used to render the correct icon, if true will render `fa-tag` icon, + * if false will render `fa-code-fork` icon. + */ + tag: { + type: Boolean, + required: false, + default: false, + }, + + /** + * If provided is used to render the branch name and url. + * Should contain the following properties: + * name + * ref_url + */ + ref: { + type: Object, + required: false, + default: () => ({}), + }, + + /** + * Used to link to the commit sha. + */ + commit_url: { + type: String, + required: false, + default: '', + }, + + /** + * Used to show the commit short_sha that links to the commit url. + */ + short_sha: { + type: String, + required: false, + default: '', + }, + + /** + * If provided shows the commit tile. + */ + title: { + type: String, + required: false, + default: '', + }, + + /** + * If provided renders information about the author of the commit. + * When provided should include: + * `avatar_url` to render the avatar icon + * `web_url` to link to user profile + * `username` to render alt and title tags + */ + author: { + type: Object, + required: false, + default: () => ({}), + }, + }, + + computed: { + /** + * Used to verify if all the properties needed to render the commit + * ref section were provided. + * + * TODO: Improve this! Use lodash _.has when we have it. + * + * @returns {Boolean} + */ + hasRef() { + return this.ref && this.ref.name && this.ref.ref_url; + }, + + /** + * Used to verify if all the properties needed to render the commit + * author section were provided. + * + * TODO: Improve this! Use lodash _.has when we have it. + * + * @returns {Boolean} + */ + hasAuthor() { + return this.author && + this.author.avatar_url && + this.author.web_url && + this.author.username; + }, + + /** + * If information about the author is provided will return a string + * to be rendered as the alt attribute of the img tag. + * + * @returns {String} + */ + userImageAltDescription() { + return this.author && + this.author.username ? `${this.author.username}'s avatar` : null; + }, + }, + + /** + * In order to reuse the svg instead of copy and paste in this template + * we need to render it outside this component using =custom_icon partial. + * Make sure it has this structure: + * .commit-icon-svg.hidden + * svg + * + * TODO: Find a better way to include SVG + */ + mounted() { + const commitIconContainer = this.$el.querySelector('.commit-icon-container'); + const commitIcon = document.querySelector('.commit-icon-svg.hidden svg'); + + if (commitIconContainer && commitIcon) { + commitIconContainer.appendChild(commitIcon.cloneNode(true)); + } + }, + + template: ` +
+ +
+ + +
+ + + + +
+
+ + + + +

+ + + + + + + + + + Cant find HEAD commit for this branch + +

+
+ `, + }); +})(); diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index d5cca1b10fb..7c7f991dd87 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -39,3 +39,5 @@ @import "framework/typography.scss"; @import "framework/zen.scss"; @import "framework/blank"; +@import "framework/wells.scss"; +@import "framework/page-header.scss"; diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 9acff45de75..4a9aa0f8717 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -349,6 +349,12 @@ } } +.btn-inverted { + &-secondary { + @include btn-outline($white-light, $blue-normal, $blue-normal, $blue-light, $white-light, $blue-light); + } +} + @media (max-width: $screen-xs-max) { .btn-wide-on-xs { width: 100%; diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 8a93eac1b6d..42087c91530 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -64,12 +64,17 @@ a { padding-top: 0; - line-height: 1; + line-height: 19px; border-bottom: 1px solid $border-color; &.btn.btn-xs { padding: 2px 5px; } + + &:focus { + margin-top: -10px; + padding-top: 10px; + } } } } @@ -163,4 +168,4 @@ border: 1px solid $white-light; } } -} \ No newline at end of file +} diff --git a/app/assets/stylesheets/framework/page-header.scss b/app/assets/stylesheets/framework/page-header.scss new file mode 100644 index 00000000000..85c1385d5d9 --- /dev/null +++ b/app/assets/stylesheets/framework/page-header.scss @@ -0,0 +1,67 @@ +.page-content-header { + line-height: 34px; + padding: 10px 0; + margin-bottom: 0; + + @media (min-width: $screen-sm-min) { + display: flex; + align-items: center; + + .header-main-content { + flex: 1; + } + } + + .header-action-buttons { + i { + color: $gl-icon-color; + font-size: 13px; + margin-right: 3px; + } + + @media (max-width: $screen-xs-max) { + .btn { + width: 100%; + margin-top: 10px; + } + + .dropdown { + width: 100%; + } + } + } + + .avatar { + @extend .avatar-inline; + margin-left: 0; + + @media (min-width: $screen-sm-min) { + margin-left: 4px; + } + } + + .commit-committer-link, + .commit-author-link { + color: $gl-gray; + font-weight: bold; + } + + .fa-clipboard { + color: $dropdown-title-btn-color; + } + + .commit-info { + &.branches { + margin-left: 8px; + } + } + + .ci-status-link { + + svg { + position: relative; + top: 2px; + margin: 0 2px 0 3px; + } + } +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 8bf5edfde50..92226f7432e 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -90,8 +90,8 @@ $table-border-color: #f0f0f0; $background-color: $gray-light; $dark-background-color: #f5f5f5; $table-text-gray: #8f8f8f; -$widget-expand-item: #e8f2f7; -$widget-inner-border: #eef0f2; +$well-expand-item: #e8f2f7; +$well-inner-border: #eef0f2; /* * Text diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss new file mode 100644 index 00000000000..192939f4527 --- /dev/null +++ b/app/assets/stylesheets/framework/wells.scss @@ -0,0 +1,45 @@ +.info-well { + background: $background-color; + color: $gl-gray; + border: 1px solid $border-color; + border-radius: $border-radius-default; + + .well-segment { + padding: $gl-padding; + + &:not(:last-of-type) { + border-bottom: 1px solid $well-inner-border; + } + + &.branch-info { + .monospace, + .commit-info { + margin-left: 4px; + } + } + } + + .icon-container { + display: inline-block; + margin-right: 8px; + + svg { + position: relative; + top: 2px; + height: 16px; + width: 16px; + } + + &.commit-icon { + svg { + path { + fill: $gl-text-color; + } + } + } + } + + .label.label-gray { + background-color: $well-expand-item; + } +} diff --git a/app/assets/stylesheets/pages/admin.scss b/app/assets/stylesheets/pages/admin.scss index 6cefafd8fc7..14812e171fd 100644 --- a/app/assets/stylesheets/pages/admin.scss +++ b/app/assets/stylesheets/pages/admin.scss @@ -160,3 +160,9 @@ } } } + +.admin-builds-table { + .ci-table td:last-child { + min-width: 120px; + } +} diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index f1d311cabbe..48f11eb2552 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -40,6 +40,19 @@ margin-bottom: 10px; } } + + .environment-information { + background-color: $background-color; + border: 1px solid $border-color; + padding: 12px $gl-padding; + border-radius: $border-radius-default; + + svg { + position: relative; + top: 1px; + margin-right: 5px; + } + } } .build-header { @@ -49,10 +62,6 @@ min-height: 58px; align-items: center; - .btn-inverted { - @include btn-outline($white-light, $blue-normal, $blue-normal, $blue-light, $white-light, $blue-light); - } - @media (max-width: $screen-sm-max) { padding-right: 40px; @@ -63,14 +72,14 @@ .header-content { flex: 1; - } - a { - color: $gl-gray; + a { + color: $gl-gray; - &:hover { - color: $gl-link-color; - text-decoration: none; + &:hover { + color: $gl-link-color; + text-decoration: none; + } } } diff --git a/app/assets/stylesheets/pages/commit.scss b/app/assets/stylesheets/pages/commit.scss index 47d3e72679b..ddc9d0e2b1a 100644 --- a/app/assets/stylesheets/pages/commit.scss +++ b/app/assets/stylesheets/pages/commit.scss @@ -26,143 +26,12 @@ white-space: pre-wrap; } -.commit-info-row { - margin-bottom: 10px; - line-height: 24px; - padding-top: 6px; - - &.commit-info-row-header { - line-height: 34px; - padding: 10px 0; - margin-bottom: 0; - - @media (min-width: $screen-sm-min) { - display: flex; - align-items: center; - - .commit-meta { - flex: 1; - } - } - - .commit-hash-full { - @media (max-width: $screen-sm-max) { - width: 80px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: inline-block; - vertical-align: bottom; - } - } - - .commit-action-buttons { - i { - color: $gl-icon-color; - font-size: 13px; - margin-right: 3px; - } - - @media (max-width: $screen-xs-max) { - .dropdown { - width: 100%; - margin-top: 10px; - } - - .dropdown-toggle { - width: 100%; - } - } - } - } - - .avatar { - @extend .avatar-inline; - margin-left: 0; - - @media (min-width: $screen-sm-min) { - margin-left: 4px; - } - } - - .commit-committer-link, - .commit-author-link { - color: $gl-gray; - font-weight: bold; - } - - .fa-clipboard { - color: $dropdown-title-btn-color; - } - - .commit-info { - &.branches { - margin-left: 8px; - } - } - - .ci-status-link { - - svg { - position: relative; - top: 2px; - margin: 0 2px 0 3px; - } - } -} - .js-details-expand { &:hover { text-decoration: none; } } -.commit-info-widget { - background: $background-color; - color: $gl-gray; - border: 1px solid $border-color; - border-radius: $border-radius-default; - - .widget-row { - padding: $gl-padding; - - &:not(:last-of-type) { - border-bottom: 1px solid $widget-inner-border; - } - - &.branch-info { - .monospace, - .commit-info { - margin-left: 4px; - } - } - } - - .icon-container { - display: inline-block; - margin-right: 8px; - - svg { - position: relative; - top: 2px; - height: 16px; - width: 16px; - } - - &.commit-icon { - svg { - path { - fill: $gl-text-color; - } - } - } - } - - .label.label-gray { - background-color: $widget-expand-item; - } -} - .ci-status-link { svg { overflow: visible; @@ -184,6 +53,17 @@ } } +.commit-hash-full { + @media (max-width: $screen-sm-max) { + width: 80px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: inline-block; + vertical-align: bottom; + } +} + .file-stats { ul { list-style: none; diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index fc49ff780fc..e9ff43a8adb 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -1,10 +1,23 @@ -.environments-container, .deployments-container { width: 100%; overflow: auto; } +.environments-list-loading { + width: 100%; + font-size: 34px; +} + +@media (max-width: $screen-sm-min) { + .environments-container { + width: 100%; + overflow: auto; + } +} + .environments { + table-layout: fixed; + .deployment-column { .avatar { float: none; @@ -15,6 +28,10 @@ margin: 0; } + .avatar-image-container { + text-decoration: none; + } + .icon-play { height: 13px; width: 12px; @@ -38,7 +55,8 @@ color: $gl-dark-link-color; } - .stop-env-link { + .stop-env-link, + .external-url { color: $table-text-gray; .stop-env-icon { @@ -58,10 +76,29 @@ } } } + + .children-row .environment-name { + margin-left: 17px; + margin-right: -17px; + } + + .folder-icon { + padding: 0 5px 0 0; + } + + .folder-name { + cursor: pointer; + + .badge { + font-weight: normal; + background-color: $gray-darker; + color: $gl-placeholder-color; + vertical-align: baseline; + } + } } .table.ci-table.environments { - .icon-container { width: 20px; text-align: center; diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss index 10f67b47998..54c89d75e94 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/pages/login.scss @@ -255,26 +255,3 @@ } } -// For sign in pane only, to improve tab order, the following removes the submit button from -// normal document flow and pins it to the bottom of the form. For context, see !6867 & !6928 - -.login-box { - .new_user { - position: relative; - padding-bottom: 35px; - - @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { - .forgot-password { - float: none !important; - margin-top: 5px; - } - } - } - - .move-submit-down { - position: absolute; - width: 100%; - bottom: 0; - } -} - diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 6cf43713fec..b6a82460f25 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -61,7 +61,7 @@ } .ci_widget { - border-bottom: 1px solid $widget-inner-border; + border-bottom: 1px solid $well-inner-border; svg { margin-right: 4px; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 9bfa1c96a5d..0dfd4ab7ec9 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -35,11 +35,84 @@ ul.notes { .system-note { font-size: 14px; - padding-top: 10px; - padding-bottom: 10px; - background: #fdfdfd; + padding: 0; + clear: both; + + &.timeline-entry::after { + clear: none; + } + + .system-note-message { + text-transform: lowercase; + + a { + color: $gl-link-color; + text-decoration: none; + } + } + + .timeline-content { + padding: 14px 10px; + } + + .note-body { + overflow: hidden; + + .system-note-commit-list-toggler { + display: none; + padding: 10px 0 0; + cursor: pointer; + } + + .note-text { + & p:first-child { + display: none; + } + + &.system-note-commit-list { + max-height: 63px; + overflow: hidden; + display: block; + + ul { + margin: 3px 0 3px 15px !important; + + li { + font-family: $monospace_font; + font-size: 12px; + } + } + + p:first-child { + display: none; + } + + &::after { + content: ''; + width: 100%; + height: 20px; + position: absolute; + left: 0; + bottom: 50px; + background: linear-gradient(rgba($gray-light, .3) 0, $white-light); + } + + &.hide-shade { + max-height: 100%; + overflow: auto; + + &::after { + display: none; + background: transparent; + } + } + } + } + } .timeline-icon { + display: none; + .avatar { visibility: hidden; @@ -65,6 +138,12 @@ ul.notes { position: relative; border-bottom: 1px solid $table-border-gray; + &.note-discussion { + &.timeline-entry { + padding: 14px 10px; + } + } + &.is-editting { .note-header, .note-text, @@ -88,10 +167,8 @@ ul.notes { overflow: auto; word-wrap: break-word; @include md-typography; - // Reset ul style types since we're nested inside a ul already @include bulleted-list; - ul.task-list { ul:not(.task-list) { padding-left: 1.3em; @@ -111,6 +188,11 @@ ul.notes { padding-bottom: 3px; padding-right: 20px; + p { + display: inline; + margin: 0; + } + @media (min-width: $screen-sm-min) { padding-right: 0; } @@ -238,6 +320,10 @@ ul.notes { } } +.discussion-header { + font-size: 14px; +} + .note-headline-light, .discussion-headline-light { color: $notes-light-color; diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 881621a2655..a44a496c728 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -300,6 +300,8 @@ .pipeline-graph { width: 100%; + background-color: $background-color; + padding: $gl-padding; overflow: auto; white-space: nowrap; transition: max-height 0.3s, padding 0.3s; @@ -363,6 +365,7 @@ .build { border: 1px solid $border-color; + background-color: $white-light; position: relative; padding: 7px 10px 8px; border-radius: 30px; diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index ad46a2a9128..19a7a97ea0d 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -145,6 +145,10 @@ } } +.nav > .project-repo-buttons { + margin-top: 0; +} + .project-repo-buttons, .group-buttons { margin-top: 15px; @@ -184,6 +188,12 @@ margin-left: 10px; } + .download-button { + @media (max-width: $screen-lg-min) { + margin-left: 0; + } + } + .count-buttons { display: inline-block; vertical-align: top; @@ -468,6 +478,20 @@ a.deploy-project-label { } } +.page-sidebar-pinned { + .project-stats .nav > li.right { + @media (min-width: $screen-lg-min) { + float: none; + } + } + + .download-button { + @media (min-width: $screen-lg-min) { + margin-left: 0; + } + } +} + .project-stats { font-size: 0; border-bottom: 1px solid $border-color; @@ -485,9 +509,11 @@ a.deploy-project-label { } &.right { - @media (min-width: $screen-md-min) { + vertical-align: top; + margin-top: 0; + + @media (min-width: $screen-lg-min) { float: right; - margin-top: 0; } } } diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index fcfa508128e..5c44637fdee 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -55,7 +55,7 @@ class AutocompleteController < ApplicationController def find_users @users = if @project - user_ids = @project.team.users.map(&:id) + user_ids = @project.team.users.pluck(:id) if params[:author_id].present? user_ids << params[:author_id] diff --git a/app/controllers/concerns/cycle_analytics_params.rb b/app/controllers/concerns/cycle_analytics_params.rb new file mode 100644 index 00000000000..2aaf8f2b451 --- /dev/null +++ b/app/controllers/concerns/cycle_analytics_params.rb @@ -0,0 +1,7 @@ +module CycleAnalyticsParams + extend ActiveSupport::Concern + + def start_date(params) + params[:start_date] == '30' ? 30.days.ago : 90.days.ago + end +end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index be86fa106f8..0821974aa93 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -12,7 +12,7 @@ module IssuableActions destroy_method = "destroy_#{issuable.class.name.underscore}".to_sym TodoService.new.public_send(destroy_method, issuable, current_user) - name = issuable.class.name.titleize.downcase + name = issuable.human_class_name flash[:notice] = "The #{name} was successfully deleted." redirect_to polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class]) end diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index b5e79099e39..6247934f81e 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -10,11 +10,11 @@ module IssuableCollections private def issues_collection - issues_finder.execute + issues_finder.execute.preload(:project, :author, :assignee, :labels, :milestone, project: :namespace) end def merge_requests_collection - merge_requests_finder.execute + merge_requests_finder.execute.preload(:source_project, :target_project, :author, :assignee, :labels, :milestone, :merge_request_diff, target_project: :namespace) end def issues_finder diff --git a/app/controllers/concerns/issues_action.rb b/app/controllers/concerns/issues_action.rb index b89fb94be6e..b46adcceb60 100644 --- a/app/controllers/concerns/issues_action.rb +++ b/app/controllers/concerns/issues_action.rb @@ -7,7 +7,6 @@ module IssuesAction @issues = issues_collection .non_archived - .preload(:author, :project) .page(params[:page]) respond_to do |format| diff --git a/app/controllers/concerns/merge_requests_action.rb b/app/controllers/concerns/merge_requests_action.rb index a1b0eee37f9..6546a07b41c 100644 --- a/app/controllers/concerns/merge_requests_action.rb +++ b/app/controllers/concerns/merge_requests_action.rb @@ -7,7 +7,6 @@ module MergeRequestsAction @merge_requests = merge_requests_collection .non_archived - .preload(:author, :target_project) .page(params[:page]) end end diff --git a/app/controllers/projects/cycle_analytics/events_controller.rb b/app/controllers/projects/cycle_analytics/events_controller.rb new file mode 100644 index 00000000000..13b3eec761f --- /dev/null +++ b/app/controllers/projects/cycle_analytics/events_controller.rb @@ -0,0 +1,65 @@ +module Projects + module CycleAnalytics + class EventsController < Projects::ApplicationController + include CycleAnalyticsParams + + before_action :authorize_read_cycle_analytics! + before_action :authorize_read_build!, only: [:test, :staging] + before_action :authorize_read_issue!, only: [:issue, :production] + before_action :authorize_read_merge_request!, only: [:code, :review] + + def issue + render_events(events.issue_events) + end + + def plan + render_events(events.plan_events) + end + + def code + render_events(events.code_events) + end + + def test + options[:branch] = events_params[:branch_name] + + render_events(events.test_events) + end + + def review + render_events(events.review_events) + end + + def staging + render_events(events.staging_events) + end + + def production + render_events(events.production_events) + end + + private + + def render_events(events_list) + respond_to do |format| + format.html + format.json { render json: { events: events_list } } + end + end + + def events + @events ||= Gitlab::CycleAnalytics::Events.new(project: project, options: options) + end + + def options + @options ||= { from: start_date(events_params), current_user: current_user } + end + + def events_params + return {} unless params[:events].present? + + params[:events].slice(:start_date, :branch_name) + end + end + end +end diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb index 16a7b1fc6e2..96eb75a0547 100644 --- a/app/controllers/projects/cycle_analytics_controller.rb +++ b/app/controllers/projects/cycle_analytics_controller.rb @@ -1,11 +1,12 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController include ActionView::Helpers::DateHelper include ActionView::Helpers::TextHelper + include CycleAnalyticsParams before_action :authorize_read_cycle_analytics! def show - @cycle_analytics = CycleAnalytics.new(@project, from: parse_start_date) + @cycle_analytics = ::CycleAnalytics.new(@project, from: start_date(cycle_analytics_params)) respond_to do |format| format.html @@ -15,14 +16,6 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController private - def parse_start_date - case cycle_analytics_params[:start_date] - when '30' then 30.days.ago - when '90' then 90.days.ago - else 90.days.ago - end - end - def cycle_analytics_params return {} unless params[:cycle_analytics].present? diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index ea22b2dcc15..6bd4cb3f2f5 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -8,13 +8,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController def index @scope = params[:scope] - @all_environments = project.environments - @environments = - if @scope == 'stopped' - @all_environments.stopped - else - @all_environments.available + @environments = project.environments + + respond_to do |format| + format.html + format.json do + render json: EnvironmentSerializer + .new(project: @project) + .represent(@environments) end + end end def show diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 3f1a1d1c511..4aea7bb62c4 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -69,7 +69,7 @@ class Projects::IssuesController < Projects::ApplicationController respond_to do |format| format.html format.json do - render json: @issue.to_json(include: [:milestone, :labels]) + render json: IssueSerializer.new.represent(@issue) end end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index dff0213411c..b343ba0b744 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -38,7 +38,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController def index @merge_requests = merge_requests_collection @merge_requests = @merge_requests.page(params[:page]) - @merge_requests = @merge_requests.preload(:target_project) if params[:label_name].present? labels_params = { project_id: @project.id, title: params[:label_name] } @@ -61,7 +60,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController format.html { define_discussion_vars } format.json do - render json: @merge_request + render json: MergeRequestSerializer.new.represent(@merge_request) end format.patch do diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index 0948ad21649..f029fde2a2f 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -146,24 +146,26 @@ class Projects::NotesController < Projects::ApplicationController end def note_json(note) + attrs = { + award: false, + id: note.id + } + if note.is_a?(AwardEmoji) - { + attrs.merge!( valid: note.valid?, award: true, - id: note.id, name: note.name - } + ) elsif note.persisted? Banzai::NoteRenderer.render([note], @project, current_user) - attrs = { + attrs.merge!( valid: true, - id: note.id, discussion_id: note.discussion_id, html: note_html(note), - award: false, note: note.note - } + ) if note.diff_note? discussion = note.to_discussion @@ -188,15 +190,14 @@ class Projects::NotesController < Projects::ApplicationController attrs[:original_discussion_id] = note.original_discussion_id end end - - attrs else - { + attrs.merge!( valid: false, - award: false, errors: note.errors - } + ) end + + attrs end def authorize_admin_note! diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index 40a23a6f806..30c2a5d9982 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -28,6 +28,8 @@ class Projects::ServicesController < Projects::ApplicationController end def test + return render_404 unless @service.can_test? + data = @service.test_data(project, current_user) outcome = @service.test(data) diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb new file mode 100644 index 00000000000..27975b7ddb7 --- /dev/null +++ b/app/helpers/environment_helper.rb @@ -0,0 +1,29 @@ +module EnvironmentHelper + def environment_for_build(project, build) + return unless build.environment + + project.environments.find_by(name: build.expanded_environment_name) + end + + def environment_link_for_build(project, build) + environment = environment_for_build(project, build) + if environment + link_to environment.name, namespace_project_environment_path(project.namespace, project, environment) + else + content_tag :span, build.expanded_environment_name + end + end + + def deployment_link(deployment) + return unless deployment + + link_to "##{deployment.iid}", [deployment.project.namespace.becomes(Namespace), deployment.project, deployment.deployable] + end + + def last_deployment_link_for_environment_build(project, build) + environment = environment_for_build(project, build) + return unless environment + + deployment_link(environment.last_deployment) + end +end diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb new file mode 100644 index 00000000000..515e802e01e --- /dev/null +++ b/app/helpers/environments_helper.rb @@ -0,0 +1,7 @@ +module EnvironmentsHelper + def environments_list_data + { + endpoint: namespace_project_environments_path(@project.namespace, @project, format: :json) + } + end +end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index ce2cabd7a3a..8bebda07787 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -171,9 +171,11 @@ module IssuablesHelper def issuables_count_for_state(issuable_type, state) issuables_finder = public_send("#{issuable_type}_finder") - issuables_finder.params[:state] = state + + params = issuables_finder.params.merge(state: state) + finder = issuables_finder.class.new(issuables_finder.current_user, params) - issuables_finder.execute.page(1).total_count + finder.execute.page(1).total_count end IRRELEVANT_PARAMS_FOR_CACHE_KEY = %i[utf8 sort page] diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb index 3d4abf76419..9bab140e60a 100644 --- a/app/helpers/services_helper.rb +++ b/app/helpers/services_helper.rb @@ -17,6 +17,8 @@ module ServicesHelper "Event will be triggered when a build status changes" when "wiki_page" "Event will be triggered when a wiki page is created/updated" + when "commit" + "Event will be triggered when a commit is created/updated" end end diff --git a/app/helpers/triggers_helper.rb b/app/helpers/triggers_helper.rb index c41181bab3d..b0135ea2e95 100644 --- a/app/helpers/triggers_helper.rb +++ b/app/helpers/triggers_helper.rb @@ -6,4 +6,8 @@ module TriggersHelper "#{Settings.gitlab.url}/api/v3/projects/#{project_id}/ref/#{ref}/trigger/builds" end end + + def service_trigger_url(service) + "#{Settings.gitlab.url}/api/v3/projects/#{service.project_id}/services/#{service.to_param}/trigger" + end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 33612256540..83c0c64e5fb 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -7,6 +7,8 @@ module Ci belongs_to :trigger_request belongs_to :erased_by, class_name: 'User' + has_many :deployments, as: :deployable + serialize :options serialize :yaml_variables @@ -68,7 +70,11 @@ module Ci environment: build.environment, status_event: 'enqueue' ) - MergeRequests::AddTodoWhenBuildFailsService.new(build.project, nil).close(new_build) + + MergeRequests::AddTodoWhenBuildFailsService + .new(build.project, nil) + .close(new_build) + build.pipeline.mark_as_processable_after_stage(build.stage_idx) new_build end @@ -125,6 +131,34 @@ module Ci !self.pipeline.statuses.latest.include?(self) end + def expanded_environment_name + ExpandVariables.expand(environment, variables) if environment + end + + def has_environment? + self.environment.present? + end + + def starts_environment? + has_environment? && self.environment_action == 'start' + end + + def stops_environment? + has_environment? && self.environment_action == 'stop' + end + + def environment_action + self.options.fetch(:environment, {}).fetch(:action, 'start') + end + + def outdated_deployment? + success? && !last_deployment.try(:last?) + end + + def last_deployment + deployments.last + end + def depends_on_builds # Get builds of the same type latest_builds = self.pipeline.builds.latest diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index ec9e7e5ae2b..69d8afc45da 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -251,6 +251,17 @@ module Issuable self.class.to_ability_name end + # Convert this Issuable class name to a format usable by notifications. + # + # Examples: + # + # issuable.class # => MergeRequest + # issuable.human_class_name # => "merge request" + + def human_class_name + @human_class_name ||= self.class.name.titleize.downcase + end + # Returns a Hash of attributes to be used for Twitter card metadata def card_attributes { diff --git a/app/models/concerns/select_for_project_authorization.rb b/app/models/concerns/select_for_project_authorization.rb new file mode 100644 index 00000000000..50a1d7fc3e1 --- /dev/null +++ b/app/models/concerns/select_for_project_authorization.rb @@ -0,0 +1,9 @@ +module SelectForProjectAuthorization + extend ActiveSupport::Concern + + module ClassMethods + def select_for_project_authorization + select("members.user_id, projects.id AS project_id, members.access_level") + end + end +end diff --git a/app/models/cycle_analytics.rb b/app/models/cycle_analytics.rb index 8ed4a56b19b..314a1ce9b63 100644 --- a/app/models/cycle_analytics.rb +++ b/app/models/cycle_analytics.rb @@ -1,12 +1,8 @@ class CycleAnalytics - include Gitlab::Database::Median - include Gitlab::Database::DateTime - - DEPLOYMENT_METRIC_STAGES = %i[production staging] - def initialize(project, from:) @project = project @from = from + @fetcher = Gitlab::CycleAnalytics::MetricsFetcher.new(project: project, from: from, branch: nil) end def summary @@ -14,90 +10,46 @@ class CycleAnalytics end def issue - calculate_metric(:issue, + @fetcher.calculate_metric(:issue, Issue.arel_table[:created_at], [Issue::Metrics.arel_table[:first_associated_with_milestone_at], Issue::Metrics.arel_table[:first_added_to_board_at]]) end def plan - calculate_metric(:plan, + @fetcher.calculate_metric(:plan, [Issue::Metrics.arel_table[:first_associated_with_milestone_at], Issue::Metrics.arel_table[:first_added_to_board_at]], Issue::Metrics.arel_table[:first_mentioned_in_commit_at]) end def code - calculate_metric(:code, + @fetcher.calculate_metric(:code, Issue::Metrics.arel_table[:first_mentioned_in_commit_at], MergeRequest.arel_table[:created_at]) end def test - calculate_metric(:test, + @fetcher.calculate_metric(:test, MergeRequest::Metrics.arel_table[:latest_build_started_at], MergeRequest::Metrics.arel_table[:latest_build_finished_at]) end def review - calculate_metric(:review, + @fetcher.calculate_metric(:review, MergeRequest.arel_table[:created_at], MergeRequest::Metrics.arel_table[:merged_at]) end def staging - calculate_metric(:staging, + @fetcher.calculate_metric(:staging, MergeRequest::Metrics.arel_table[:merged_at], MergeRequest::Metrics.arel_table[:first_deployed_to_production_at]) end def production - calculate_metric(:production, + @fetcher.calculate_metric(:production, Issue.arel_table[:created_at], MergeRequest::Metrics.arel_table[:first_deployed_to_production_at]) end - - private - - def calculate_metric(name, start_time_attrs, end_time_attrs) - cte_table = Arel::Table.new("cte_table_for_#{name}") - - # Build a `SELECT` query. We find the first of the `end_time_attrs` that isn't `NULL` (call this end_time). - # Next, we find the first of the start_time_attrs that isn't `NULL` (call this start_time). - # We compute the (end_time - start_time) interval, and give it an alias based on the current - # cycle analytics stage. - interval_query = Arel::Nodes::As.new( - cte_table, - subtract_datetimes(base_query_for(name), end_time_attrs, start_time_attrs, name.to_s)) - - median_datetime(cte_table, interval_query, name) - end - - # Join table with a row for every pair (where the merge request - # closes the given issue) with issue and merge request metrics included. The metrics - # are loaded with an inner join, so issues / merge requests without metrics are - # automatically excluded. - def base_query_for(name) - arel_table = MergeRequestsClosingIssues.arel_table - - # Load issues - query = arel_table.join(Issue.arel_table).on(Issue.arel_table[:id].eq(arel_table[:issue_id])). - join(Issue::Metrics.arel_table).on(Issue.arel_table[:id].eq(Issue::Metrics.arel_table[:issue_id])). - where(Issue.arel_table[:project_id].eq(@project.id)). - where(Issue.arel_table[:deleted_at].eq(nil)). - where(Issue.arel_table[:created_at].gteq(@from)) - - # Load merge_requests - query = query.join(MergeRequest.arel_table, Arel::Nodes::OuterJoin). - on(MergeRequest.arel_table[:id].eq(arel_table[:merge_request_id])). - join(MergeRequest::Metrics.arel_table). - on(MergeRequest.arel_table[:id].eq(MergeRequest::Metrics.arel_table[:merge_request_id])) - - if DEPLOYMENT_METRIC_STAGES.include?(name) - # Limit to merge requests that have been deployed to production after `@from` - query.where(MergeRequest::Metrics.arel_table[:first_deployed_to_production_at].gteq(@from)) - end - - query - end end diff --git a/app/models/group.rb b/app/models/group.rb index d9e90cd256a..73b0f1c6572 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -5,6 +5,7 @@ class Group < Namespace include Gitlab::VisibilityLevel include AccessRequestable include Referable + include SelectForProjectAuthorization has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source alias_method :members, :group_members @@ -61,6 +62,14 @@ class Group < Namespace def visible_to_user(user) where(id: user.authorized_groups.select(:id).reorder(nil)) end + + def select_for_project_authorization + if current_scope.joins_values.include?(:shared_projects) + select("members.user_id, projects.id AS project_id, project_group_links.group_access") + else + super + end + end end def to_reference(_from_project = nil) @@ -176,4 +185,8 @@ class Group < Namespace def system_hook_service SystemHooksService.new end + + def refresh_members_authorized_projects + UserProjectAccessChangedService.new(users.pluck(:id)).execute + end end diff --git a/app/models/member.rb b/app/models/member.rb index b89ba8ecbb8..7be2665bf48 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -113,6 +113,8 @@ class Member < ActiveRecord::Base member.save end + UserProjectAccessChangedService.new(user.id).execute if user.is_a?(User) + member end @@ -239,6 +241,7 @@ class Member < ActiveRecord::Base end def post_create_hook + UserProjectAccessChangedService.new(user.id).execute system_hook_service.execute_hooks_for(self, :create) end @@ -247,9 +250,19 @@ class Member < ActiveRecord::Base end def post_destroy_hook + refresh_member_authorized_projects system_hook_service.execute_hooks_for(self, :destroy) end + def refresh_member_authorized_projects + # If user/source is being destroyed, project access are gonna be destroyed eventually + # because of DB foreign keys, so we shouldn't bother with refreshing after each + # member is destroyed through association + return if destroyed_by_association.present? + + UserProjectAccessChangedService.new(user_id).execute + end + def after_accept_invite post_create_hook end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 9d3eab52189..6c3c093d084 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -686,7 +686,7 @@ class MergeRequest < ActiveRecord::Base def mergeable_ci_state? return true unless project.only_allow_merge_if_build_succeeds? - !pipeline || pipeline.success? + !pipeline || pipeline.success? || pipeline.skipped? end def environments diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb index 99c49a020c9..cdc408738be 100644 --- a/app/models/merge_request/metrics.rb +++ b/app/models/merge_request/metrics.rb @@ -1,5 +1,6 @@ class MergeRequest::Metrics < ActiveRecord::Base belongs_to :merge_request + belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :pipeline_id def record! if merge_request.merged? && self.merged_at.blank? diff --git a/app/models/project.rb b/app/models/project.rb index f9bcc547c36..995359daf1e 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -13,6 +13,7 @@ class Project < ActiveRecord::Base include CaseSensitivity include TokenAuthenticatable include ProjectFeaturesCompatibility + include SelectForProjectAuthorization extend Gitlab::ConfigHelper @@ -23,7 +24,9 @@ class Project < ActiveRecord::Base cache_markdown_field :description, pipeline: :description - delegate :feature_available?, :builds_enabled?, :wiki_enabled?, :merge_requests_enabled?, to: :project_feature, allow_nil: true + delegate :feature_available?, :builds_enabled?, :wiki_enabled?, + :merge_requests_enabled?, :issues_enabled?, to: :project_feature, + allow_nil: true default_value_for :archived, false default_value_for :visibility_level, gitlab_config_features.visibility_level @@ -75,6 +78,7 @@ class Project < ActiveRecord::Base has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event' has_many :boards, before_add: :validate_board_limit, dependent: :destroy + has_many :chat_services # Project services has_one :campfire_service, dependent: :destroy @@ -89,6 +93,7 @@ class Project < ActiveRecord::Base has_one :assembla_service, dependent: :destroy has_one :asana_service, dependent: :destroy has_one :gemnasium_service, dependent: :destroy + has_one :mattermost_slash_commands_service, dependent: :destroy has_one :slack_service, dependent: :destroy has_one :buildkite_service, dependent: :destroy has_one :bamboo_service, dependent: :destroy @@ -1289,16 +1294,10 @@ class Project < ActiveRecord::Base # Checks if `user` is authorized for this project, with at least the # `min_access_level` (if given). - # - # If you change the logic of this method, please also update `User#authorized_projects` def authorized_for_user?(user, min_access_level = nil) return false unless user - return true if personal? && namespace_id == user.namespace_id - - authorized_for_user_by_group?(user, min_access_level) || - authorized_for_user_by_members?(user, min_access_level) || - authorized_for_user_by_shared_projects?(user, min_access_level) + user.authorized_project?(self, min_access_level) end def append_or_update_attribute(name, value) @@ -1358,30 +1357,6 @@ class Project < ActiveRecord::Base current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE end - def authorized_for_user_by_group?(user, min_access_level) - member = user.group_members.find_by(source_id: group) - - member && (!min_access_level || member.access_level >= min_access_level) - end - - def authorized_for_user_by_members?(user, min_access_level) - member = members.find_by(user_id: user) - - member && (!min_access_level || member.access_level >= min_access_level) - end - - def authorized_for_user_by_shared_projects?(user, min_access_level) - shared_projects = user.group_members.joins(group: :shared_projects). - where(project_group_links: { project_id: self }) - - if min_access_level - members_scope = { access_level: Gitlab::Access.values.select { |access| access >= min_access_level } } - shared_projects = shared_projects.where(members: members_scope) - end - - shared_projects.any? - end - # Similar to the normal callbacks that hook into the life cycle of an # Active Record object, you can also define callbacks that get triggered # when you add an object to an association collection. If any of these diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb new file mode 100644 index 00000000000..a00d43773d9 --- /dev/null +++ b/app/models/project_authorization.rb @@ -0,0 +1,8 @@ +class ProjectAuthorization < ActiveRecord::Base + belongs_to :user + belongs_to :project + + validates :project, presence: true + validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true + validates :user, uniqueness: { scope: [:project, :access_level] }, presence: true +end diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 5c53c8f1ee5..03194fc2141 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -60,6 +60,10 @@ class ProjectFeature < ActiveRecord::Base merge_requests_access_level > DISABLED end + def issues_enabled? + issues_access_level > DISABLED + end + private # Validates builds and merge requests access level diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb index db46def11eb..6149c35cc61 100644 --- a/app/models/project_group_link.rb +++ b/app/models/project_group_link.rb @@ -16,6 +16,9 @@ class ProjectGroupLink < ActiveRecord::Base validates :group_access, inclusion: { in: Gitlab::Access.values }, presence: true validate :different_group + after_create :refresh_group_members_authorized_projects + after_destroy :refresh_group_members_authorized_projects + def self.access_options Gitlab::Access.options end @@ -35,4 +38,8 @@ class ProjectGroupLink < ActiveRecord::Base errors.add(:base, "Project cannot be shared with the project it is in.") end end + + def refresh_group_members_authorized_projects + group.refresh_members_authorized_projects + end end diff --git a/app/models/project_services/chat_service.rb b/app/models/project_services/chat_service.rb new file mode 100644 index 00000000000..d36beff5fa6 --- /dev/null +++ b/app/models/project_services/chat_service.rb @@ -0,0 +1,21 @@ +# Base class for Chat services +# This class is not meant to be used directly, but only to inherrit from. +class ChatService < Service + default_value_for :category, 'chat' + + has_many :chat_names, foreign_key: :service_id + + def valid_token?(token) + self.respond_to?(:token) && + self.token.present? && + ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.token) + end + + def supported_events + [] + end + + def trigger(params) + raise NotImplementedError + end +end diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 7ce274b5dca..2caf6179ef8 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -1,24 +1,3 @@ -# == Schema Information -# -# Table name: services -# -# id :integer not null, primary key -# type :string(255) -# title :string(255) -# project_id :integer -# created_at :datetime -# updated_at :datetime -# active :boolean default(FALSE), not null -# properties :text -# template :boolean default(FALSE) -# push_events :boolean default(TRUE) -# issues_events :boolean default(TRUE) -# merge_requests_events :boolean default(TRUE) -# tag_push_events :boolean default(TRUE) -# note_events :boolean default(TRUE), not null -# build_events :boolean default(FALSE), not null -# - class JiraService < IssueTrackerService include Gitlab::Routing.url_helpers @@ -30,6 +9,10 @@ class JiraService < IssueTrackerService before_update :reset_password + def supported_events + %w(commit merge_request) + end + # {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1 def reference_pattern @reference_pattern ||= %r{(?\b([A-Z][A-Z0-9_]+-)\d+)} @@ -70,7 +53,7 @@ class JiraService < IssueTrackerService end def jira_project - @jira_project ||= client.Project.find(project_key) + @jira_project ||= jira_request { client.Project.find(project_key) } end def help @@ -128,14 +111,25 @@ class JiraService < IssueTrackerService # we just want to test settings test_settings else - close_issue(push, issue) + jira_issue = jira_request { client.Issue.find(issue.iid) } + + return false unless jira_issue.present? + + close_issue(push, jira_issue) end end def create_cross_reference_note(mentioned, noteable, author) - issue_key = mentioned.id + unless can_cross_reference?(noteable) + return "Events for #{noteable.model_name.plural.humanize(capitalize: false)} are disabled." + end + + jira_issue = jira_request { client.Issue.find(mentioned.id) } + + return unless jira_issue.present? + project = self.project - noteable_name = noteable.class.name.underscore.downcase + noteable_name = noteable.model_name.singular noteable_id = if noteable.is_a?(Commit) noteable.id else @@ -160,7 +154,7 @@ class JiraService < IssueTrackerService } } - add_comment(data, issue_key) + add_comment(data, jira_issue) end # reason why service cannot be tested @@ -181,16 +175,22 @@ class JiraService < IssueTrackerService def test_settings return unless url.present? # Test settings by getting the project - jira_project - - rescue Errno::ECONNREFUSED, JIRA::HTTPError => e - Rails.logger.info "#{self.class.name} ERROR: #{e.message}. API URL: #{url}." - false + jira_request { jira_project.present? } end private + def can_cross_reference?(noteable) + case noteable + when Commit then commit_events + when MergeRequest then merge_requests_events + else true + end + end + def close_issue(entity, issue) + return if issue.nil? || issue.resolution.present? || !jira_issue_transition_id.present? + commit_id = if entity.is_a?(Commit) entity.id elsif entity.is_a?(MergeRequest) @@ -200,55 +200,85 @@ class JiraService < IssueTrackerService commit_url = build_entity_url(:commit, commit_id) # Depending on the JIRA project's workflow, a comment during transition - # may or may not be allowed. Split the operation in to two calls so the - # comment always works. - transition_issue(issue) - add_issue_solved_comment(issue, commit_id, commit_url) + # may or may not be allowed. Refresh the issue after transition and check + # if it is closed, so we don't have one comment for every commit. + issue = jira_request { client.Issue.find(issue.key) } if transition_issue(issue) + add_issue_solved_comment(issue, commit_id, commit_url) if issue.resolution end def transition_issue(issue) - issue = client.Issue.find(issue.iid) issue.transitions.build.save(transition: { id: jira_issue_transition_id }) end def add_issue_solved_comment(issue, commit_id, commit_url) - comment = "Issue solved with [#{commit_id}|#{commit_url}]." - send_message(issue.iid, comment) + link_title = "GitLab: Solved by commit #{commit_id}." + comment = "Issue solved with [#{commit_id}|#{commit_url}]." + link_props = build_remote_link_props(url: commit_url, title: link_title, resolved: true) + send_message(issue, comment, link_props) end - def add_comment(data, issue_key) - user_name = data[:user][:name] - user_url = data[:user][:url] - entity_name = data[:entity][:name] - entity_url = data[:entity][:url] + def add_comment(data, issue) + user_name = data[:user][:name] + user_url = data[:user][:url] + entity_name = data[:entity][:name] + entity_url = data[:entity][:url] entity_title = data[:entity][:title] project_name = data[:project][:name] - message = "[#{user_name}|#{user_url}] mentioned this issue in [a #{entity_name} of #{project_name}|#{entity_url}]:\n'#{entity_title}'" + message = "[#{user_name}|#{user_url}] mentioned this issue in [a #{entity_name} of #{project_name}|#{entity_url}]:\n'#{entity_title}'" + link_title = "GitLab: Mentioned on #{entity_name} - #{entity_title}" + link_props = build_remote_link_props(url: entity_url, title: link_title) - unless comment_exists?(issue_key, message) - send_message(issue_key, message) + unless comment_exists?(issue, message) + send_message(issue, message, link_props) end end - def comment_exists?(issue_key, message) - comments = client.Issue.find(issue_key).comments - comments.map { |comment| comment.body.include?(message) }.any? + def comment_exists?(issue, message) + comments = jira_request { issue.comments } + + comments.present? && comments.any? { |comment| comment.body.include?(message) } end - def send_message(issue_key, message) + def send_message(issue, message, remote_link_props) return unless url.present? - issue = client.Issue.find(issue_key) + jira_request do + if issue.comments.build.save!(body: message) + remote_link = issue.remotelink.build + remote_link.save!(remote_link_props) + result_message = "#{self.class.name} SUCCESS: Successfully posted to #{url}." + end - if issue.comments.build.save!(body: message) - result_message = "#{self.class.name} SUCCESS: Successfully posted to #{url}." + Rails.logger.info(result_message) + result_message + end + end + + # Build remote link on JIRA properties + # Icons here must be available on WEB so JIRA can read the URL + # We are using a open word graphics icon which have LGPL license + def build_remote_link_props(url:, title:, resolved: false) + status = { + resolved: resolved + } + + if resolved + status[:icon] = { + title: 'Closed', + url16x16: 'http://www.openwebgraphics.com/resources/data/1768/16x16_apply.png' + } end - Rails.logger.info(result_message) - result_message - rescue URI::InvalidURIError, Errno::ECONNREFUSED, JIRA::HTTPError => e - Rails.logger.info "#{self.class.name} Send message ERROR: #{url} - #{e.message}" + { + GlobalID: 'GitLab', + object: { + url: url, + title: title, + status: status, + icon: { title: 'GitLab', url16x16: 'https://gitlab.com/favicon.ico' } + } + } end def resource_url(resource) @@ -266,4 +296,13 @@ class JiraService < IssueTrackerService host: Settings.gitlab.base_url ) end + + # Handle errors when doing JIRA API calls + def jira_request + yield + + rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError => e + Rails.logger.info "#{self.class.name} Send message ERROR: #{url} - #{e.message}" + nil + end end diff --git a/app/models/project_services/mattermost_slash_commands_service.rb b/app/models/project_services/mattermost_slash_commands_service.rb new file mode 100644 index 00000000000..67902329593 --- /dev/null +++ b/app/models/project_services/mattermost_slash_commands_service.rb @@ -0,0 +1,56 @@ +class MattermostSlashCommandsService < ChatService + include TriggersHelper + + prop_accessor :token + + def can_test? + false + end + + def title + 'Mattermost Command' + end + + def description + "Perform common operations on GitLab in Mattermost" + end + + def to_param + 'mattermost_slash_commands' + end + + def help + "This service allows you to use slash commands with your Mattermost installation.
+ To setup this Service you need to create a new Slash commands in your Mattermost integration panel.
+
+ Create integration with URL #{service_trigger_url(self)} and enter the token below." + end + + def fields + [ + { type: 'text', name: 'token', placeholder: '' } + ] + end + + def trigger(params) + return nil unless valid_token?(params[:token]) + + user = find_chat_user(params) + unless user + url = authorize_chat_name_url(params) + return Mattermost::Presenter.authorize_chat_name(url) + end + + Gitlab::ChatCommands::Command.new(project, user, params).execute + end + + private + + def find_chat_user(params) + ChatNames::FindUserService.new(self, params).execute + end + + def authorize_chat_name_url(params) + ChatNames::AuthorizeUserService.new(self, params).execute + end +end diff --git a/app/models/project_services/slack_service/pipeline_message.rb b/app/models/project_services/slack_service/pipeline_message.rb index f06b3562965..f8d03c0e2fa 100644 --- a/app/models/project_services/slack_service/pipeline_message.rb +++ b/app/models/project_services/slack_service/pipeline_message.rb @@ -1,11 +1,10 @@ class SlackService class PipelineMessage < BaseMessage - attr_reader :sha, :ref_type, :ref, :status, :project_name, :project_url, + attr_reader :ref_type, :ref, :status, :project_name, :project_url, :user_name, :duration, :pipeline_id def initialize(data) pipeline_attributes = data[:object_attributes] - @sha = pipeline_attributes[:sha] @ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch' @ref = pipeline_attributes[:ref] @status = pipeline_attributes[:status] @@ -14,7 +13,7 @@ class SlackService @project_name = data[:project][:path_with_namespace] @project_url = data[:project][:web_url] - @user_name = data[:commit] && data[:commit][:author_name] + @user_name = data[:user] && data[:user][:name] end def pretext @@ -73,7 +72,7 @@ class SlackService end def pipeline_link - "[#{Commit.truncate_sha(sha)}](#{pipeline_url})" + "[##{pipeline_id}](#{pipeline_url})" end end end diff --git a/app/models/repository.rb b/app/models/repository.rb index 146424d2b1c..31be06be50c 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -176,11 +176,18 @@ class Repository options = { message: message, tagger: user_to_committer(user) } if message - GitHooksService.new.execute(user, path_to_repo, oldrev, target, ref) do - rugged.tags.create(tag_name, target, options) + rugged.tags.create(tag_name, target, options) + tag = find_tag(tag_name) + + GitHooksService.new.execute(user, path_to_repo, oldrev, tag.target, ref) do + # we already created a tag, because we need tag SHA to pass correct + # values to hooks end - find_tag(tag_name) + tag + rescue GitHooksService::PreReceiveError + rugged.tags.delete(tag_name) + raise end def rm_branch(user, branch_name) diff --git a/app/models/service.rb b/app/models/service.rb index 9d6ff190cdf..0c36acfc1b7 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -8,6 +8,7 @@ class Service < ActiveRecord::Base default_value_for :push_events, true default_value_for :issues_events, true default_value_for :confidential_issues_events, true + default_value_for :commit_events, true default_value_for :merge_requests_events, true default_value_for :tag_push_events, true default_value_for :note_events, true @@ -202,7 +203,6 @@ class Service < ActiveRecord::Base bamboo buildkite builds_email - pipelines_email bugzilla campfire custom_issue_tracker @@ -214,6 +214,8 @@ class Service < ActiveRecord::Base hipchat irker jira + mattermost_slash_commands + pipelines_email pivotaltracker pushover redmine diff --git a/app/models/user.rb b/app/models/user.rb index 519ed92e28b..29fb849940a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -73,6 +73,8 @@ class User < ActiveRecord::Base has_many :created_projects, foreign_key: :creator_id, class_name: 'Project' has_many :users_star_projects, dependent: :destroy has_many :starred_projects, through: :users_star_projects, source: :project + has_many :project_authorizations, dependent: :destroy + has_many :authorized_projects, through: :project_authorizations, source: :project has_many :snippets, dependent: :destroy, foreign_key: :author_id has_many :issues, dependent: :destroy, foreign_key: :author_id @@ -227,19 +229,19 @@ class User < ActiveRecord::Base def filter(filter_name) case filter_name when 'admins' - self.admins + admins when 'blocked' - self.blocked + blocked when 'two_factor_disabled' - self.without_two_factor + without_two_factor when 'two_factor_enabled' - self.with_two_factor + with_two_factor when 'wop' - self.without_projects + without_projects when 'external' - self.external + external else - self.active + active end end @@ -337,7 +339,7 @@ class User < ActiveRecord::Base end def generate_password - if self.force_random_password + if force_random_password self.password = self.password_confirmation = Devise.friendly_token.first(Devise.password_length.min) end end @@ -378,56 +380,55 @@ class User < ActiveRecord::Base end def two_factor_otp_enabled? - self.otp_required_for_login? + otp_required_for_login? end def two_factor_u2f_enabled? - self.u2f_registrations.exists? + u2f_registrations.exists? end def namespace_uniq # Return early if username already failed the first uniqueness validation - return if self.errors.key?(:username) && - self.errors[:username].include?('has already been taken') + return if errors.key?(:username) && + errors[:username].include?('has already been taken') - namespace_name = self.username - existing_namespace = Namespace.by_path(namespace_name) - if existing_namespace && existing_namespace != self.namespace - self.errors.add(:username, 'has already been taken') + existing_namespace = Namespace.by_path(username) + if existing_namespace && existing_namespace != namespace + errors.add(:username, 'has already been taken') end end def avatar_type - unless self.avatar.image? - self.errors.add :avatar, "only images allowed" + unless avatar.image? + errors.add :avatar, "only images allowed" end end def unique_email - if !self.emails.exists?(email: self.email) && Email.exists?(email: self.email) - self.errors.add(:email, 'has already been taken') + if !emails.exists?(email: email) && Email.exists?(email: email) + errors.add(:email, 'has already been taken') end end def owns_notification_email - return if self.temp_oauth_email? + return if temp_oauth_email? - self.errors.add(:notification_email, "is not an email you own") unless self.all_emails.include?(self.notification_email) + errors.add(:notification_email, "is not an email you own") unless all_emails.include?(notification_email) end def owns_public_email - return if self.public_email.blank? + return if public_email.blank? - self.errors.add(:public_email, "is not an email you own") unless self.all_emails.include?(self.public_email) + errors.add(:public_email, "is not an email you own") unless all_emails.include?(public_email) end def update_emails_with_primary_email - primary_email_record = self.emails.find_by(email: self.email) + primary_email_record = emails.find_by(email: email) if primary_email_record primary_email_record.destroy - self.emails.create(email: self.email_was) + emails.create(email: email_was) - self.update_secondary_emails! + update_secondary_emails! end end @@ -439,11 +440,44 @@ class User < ActiveRecord::Base Group.where("namespaces.id IN (#{union.to_sql})") end - # Returns projects user is authorized to access. - # - # If you change the logic of this method, please also update `Project#authorized_for_user` + def refresh_authorized_projects + loop do + begin + Gitlab::Database.serialized_transaction do + project_authorizations.delete_all + + # project_authorizations_union can return multiple records for the same project/user with + # different access_level so we take row with the maximum access_level + project_authorizations.connection.execute <<-SQL + INSERT INTO project_authorizations (user_id, project_id, access_level) + SELECT user_id, project_id, MAX(access_level) AS access_level + FROM (#{project_authorizations_union.to_sql}) sub + GROUP BY user_id, project_id + SQL + + update_column(:authorized_projects_populated, true) unless authorized_projects_populated + end + + break + # In the event of a concurrent modification Rails raises StatementInvalid. + # In this case we want to keep retrying until the transaction succeeds + rescue ActiveRecord::StatementInvalid + end + end + end + def authorized_projects(min_access_level = nil) - Project.where("projects.id IN (#{projects_union(min_access_level).to_sql})") + refresh_authorized_projects unless authorized_projects_populated + + # We're overriding an association, so explicitly call super with no arguments or it would be passed as `force_reload` to the association + projects = super() + projects = projects.where('project_authorizations.access_level >= ?', min_access_level) if min_access_level + + projects + end + + def authorized_project?(project, min_access_level = nil) + authorized_projects(min_access_level).exists?({ id: project.id }) end # Returns the projects this user has reporter (or greater) access to, limited @@ -457,8 +491,9 @@ class User < ActiveRecord::Base end def viewable_starred_projects - starred_projects.where("projects.visibility_level IN (?) OR projects.id IN (#{projects_union.to_sql})", - [Project::PUBLIC, Project::INTERNAL]) + starred_projects.where("projects.visibility_level IN (?) OR projects.id IN (?)", + [Project::PUBLIC, Project::INTERNAL], + authorized_projects.select(:project_id)) end def owned_projects @@ -581,7 +616,7 @@ class User < ActiveRecord::Base end def project_deploy_keys - DeployKey.unscoped.in_projects(self.authorized_projects.pluck(:id)).distinct(:id) + DeployKey.unscoped.in_projects(authorized_projects.pluck(:id)).distinct(:id) end def accessible_deploy_keys @@ -597,38 +632,38 @@ class User < ActiveRecord::Base end def sanitize_attrs - %w(name username skype linkedin twitter).each do |attr| - value = self.send(attr) - self.send("#{attr}=", Sanitize.clean(value)) if value.present? + %w[name username skype linkedin twitter].each do |attr| + value = public_send(attr) + public_send("#{attr}=", Sanitize.clean(value)) if value.present? end end def set_notification_email - if self.notification_email.blank? || !self.all_emails.include?(self.notification_email) - self.notification_email = self.email + if notification_email.blank? || !all_emails.include?(notification_email) + self.notification_email = email end end def set_public_email - if self.public_email.blank? || !self.all_emails.include?(self.public_email) + if public_email.blank? || !all_emails.include?(public_email) self.public_email = '' end end def update_secondary_emails! - self.set_notification_email - self.set_public_email - self.save if self.notification_email_changed? || self.public_email_changed? + set_notification_email + set_public_email + save if notification_email_changed? || public_email_changed? end def set_projects_limit # `User.select(:id)` raises # `ActiveModel::MissingAttributeError: missing attribute: projects_limit` # without this safeguard! - return unless self.has_attribute?(:projects_limit) + return unless has_attribute?(:projects_limit) connection_default_value_defined = new_record? && !projects_limit_changed? - return unless self.projects_limit.nil? || connection_default_value_defined + return unless projects_limit.nil? || connection_default_value_defined self.projects_limit = current_application_settings.default_projects_limit end @@ -658,7 +693,7 @@ class User < ActiveRecord::Base def with_defaults User.defaults.each do |k, v| - self.send("#{k}=", v) + public_send("#{k}=", v) end self @@ -678,7 +713,7 @@ class User < ActiveRecord::Base # Thus it will automatically generate a new fragment # when the event is updated because the key changes. def reset_events_cache - Event.where(author_id: self.id). + Event.where(author_id: id). order('id DESC').limit(1000). update_all(updated_at: Time.now) end @@ -711,8 +746,8 @@ class User < ActiveRecord::Base def all_emails all_emails = [] - all_emails << self.email unless self.temp_oauth_email? - all_emails.concat(self.emails.map(&:email)) + all_emails << email unless temp_oauth_email? + all_emails.concat(emails.map(&:email)) all_emails end @@ -726,21 +761,21 @@ class User < ActiveRecord::Base def ensure_namespace_correct # Ensure user has namespace - self.create_namespace!(path: self.username, name: self.username) unless self.namespace + create_namespace!(path: username, name: username) unless namespace - if self.username_changed? - self.namespace.update_attributes(path: self.username, name: self.username) + if username_changed? + namespace.update_attributes(path: username, name: username) end end def post_create_hook - log_info("User \"#{self.name}\" (#{self.email}) was created") - notification_service.new_user(self, @reset_token) if self.created_by_id + log_info("User \"#{name}\" (#{email}) was created") + notification_service.new_user(self, @reset_token) if created_by_id system_hook_service.execute_hooks_for(self, :create) end def post_destroy_hook - log_info("User \"#{self.name}\" (#{self.email}) was removed") + log_info("User \"#{name}\" (#{email}) was removed") system_hook_service.execute_hooks_for(self, :destroy) end @@ -784,7 +819,7 @@ class User < ActiveRecord::Base end def oauth_authorized_tokens - Doorkeeper::AccessToken.where(resource_owner_id: self.id, revoked_at: nil) + Doorkeeper::AccessToken.where(resource_owner_id: id, revoked_at: nil) end # Returns the projects a user contributed to in the last year. @@ -888,16 +923,14 @@ class User < ActiveRecord::Base private - def projects_union(min_access_level = nil) - relations = [personal_projects.select(:id), - groups_projects.select(:id), - projects.select(:id), - groups.joins(:shared_projects).select(:project_id)] - - if min_access_level - scope = { access_level: Gitlab::Access.all_values.select { |access| access >= min_access_level } } - relations = [relations.shift] + relations.map { |relation| relation.where(members: scope) } - end + # Returns a union query of projects that the user is authorized to access + def project_authorizations_union + relations = [ + personal_projects.select("#{id} AS user_id, projects.id AS project_id, #{Gitlab::Access::OWNER} AS access_level"), + groups_projects.select_for_project_authorization, + projects.select_for_project_authorization, + groups.joins(:shared_projects).select_for_project_authorization + ] Gitlab::SQL::Union.new(relations) end @@ -917,7 +950,7 @@ class User < ActiveRecord::Base end def ensure_external_user_rights - return unless self.external? + return unless external? self.can_create_group = false self.projects_limit = 0 @@ -929,7 +962,7 @@ class User < ActiveRecord::Base if current_application_settings.domain_blacklist_enabled? blocked_domains = current_application_settings.domain_blacklist - if domain_matches?(blocked_domains, self.email) + if domain_matches?(blocked_domains, email) error = 'is not from an allowed domain.' valid = false end @@ -937,7 +970,7 @@ class User < ActiveRecord::Base allowed_domains = current_application_settings.domain_whitelist unless allowed_domains.blank? - if domain_matches?(allowed_domains, self.email) + if domain_matches?(allowed_domains, email) valid = true else error = "domain is not authorized for sign-up" @@ -945,7 +978,7 @@ class User < ActiveRecord::Base end end - self.errors.add(:email, error) unless valid + errors.add(:email, error) unless valid valid end diff --git a/app/serializers/analytics_build_entity.rb b/app/serializers/analytics_build_entity.rb new file mode 100644 index 00000000000..5fdf2bbf7c3 --- /dev/null +++ b/app/serializers/analytics_build_entity.rb @@ -0,0 +1,40 @@ +class AnalyticsBuildEntity < Grape::Entity + include RequestAwareEntity + include EntityDateHelper + + expose :name + expose :id + expose :ref, as: :branch + expose :short_sha + expose :author, using: UserEntity + + expose :started_at, as: :date do |build| + interval_in_words(build[:started_at]) + end + + expose :duration, as: :total_time do |build| + distance_of_time_as_hash(build[:duration].to_f) + end + + expose :branch do + expose :ref, as: :name + + expose :url do |build| + url_to(:namespace_project_tree, build, build.ref) + end + end + + expose :url do |build| + url_to(:namespace_project_build, build) + end + + expose :commit_url do |build| + url_to(:namespace_project_commit, build, build.sha) + end + + private + + def url_to(route, build, id = nil) + public_send("#{route}_url", build.project.namespace, build.project, id || build) + end +end diff --git a/app/serializers/analytics_build_serializer.rb b/app/serializers/analytics_build_serializer.rb new file mode 100644 index 00000000000..f172d67d356 --- /dev/null +++ b/app/serializers/analytics_build_serializer.rb @@ -0,0 +1,3 @@ +class AnalyticsBuildSerializer < BaseSerializer + entity AnalyticsBuildEntity +end diff --git a/app/serializers/analytics_commit_entity.rb b/app/serializers/analytics_commit_entity.rb new file mode 100644 index 00000000000..402cecbfd08 --- /dev/null +++ b/app/serializers/analytics_commit_entity.rb @@ -0,0 +1,13 @@ +class AnalyticsCommitEntity < CommitEntity + include EntityDateHelper + + expose :short_id, as: :short_sha + + expose :total_time do |commit| + distance_of_time_as_hash(request.total_time.to_f) + end + + unexpose :author_name + unexpose :author_email + unexpose :message +end diff --git a/app/serializers/analytics_commit_serializer.rb b/app/serializers/analytics_commit_serializer.rb new file mode 100644 index 00000000000..cdbfecf2b70 --- /dev/null +++ b/app/serializers/analytics_commit_serializer.rb @@ -0,0 +1,3 @@ +class AnalyticsCommitSerializer < BaseSerializer + entity AnalyticsCommitEntity +end diff --git a/app/serializers/analytics_generic_serializer.rb b/app/serializers/analytics_generic_serializer.rb new file mode 100644 index 00000000000..9f4859e8410 --- /dev/null +++ b/app/serializers/analytics_generic_serializer.rb @@ -0,0 +1,7 @@ +class AnalyticsGenericSerializer < BaseSerializer + def represent(resource, opts = {}) + resource.symbolize_keys! + + super(resource, opts) + end +end diff --git a/app/serializers/analytics_issue_entity.rb b/app/serializers/analytics_issue_entity.rb new file mode 100644 index 00000000000..44c50f18613 --- /dev/null +++ b/app/serializers/analytics_issue_entity.rb @@ -0,0 +1,29 @@ +class AnalyticsIssueEntity < Grape::Entity + include RequestAwareEntity + include EntityDateHelper + + expose :title + expose :author, using: UserEntity + + expose :iid do |object| + object[:iid].to_s + end + + expose :total_time do |object| + distance_of_time_as_hash(object[:total_time].to_f) + end + + expose(:created_at) do |object| + interval_in_words(object[:created_at]) + end + + expose :url do |object| + url_to(:namespace_project_issue, id: object[:iid].to_s) + end + + private + + def url_to(route, id) + public_send("#{route}_url", request.project.namespace, request.project, id) + end +end diff --git a/app/serializers/analytics_issue_serializer.rb b/app/serializers/analytics_issue_serializer.rb new file mode 100644 index 00000000000..4fb3e8f1bb4 --- /dev/null +++ b/app/serializers/analytics_issue_serializer.rb @@ -0,0 +1,3 @@ +class AnalyticsIssueSerializer < AnalyticsGenericSerializer + entity AnalyticsIssueEntity +end diff --git a/app/serializers/analytics_merge_request_entity.rb b/app/serializers/analytics_merge_request_entity.rb new file mode 100644 index 00000000000..888265eaa38 --- /dev/null +++ b/app/serializers/analytics_merge_request_entity.rb @@ -0,0 +1,7 @@ +class AnalyticsMergeRequestEntity < AnalyticsIssueEntity + expose :state + + expose :url do |object| + url_to(:namespace_project_merge_request, id: object[:iid].to_s) + end +end diff --git a/app/serializers/analytics_merge_request_serializer.rb b/app/serializers/analytics_merge_request_serializer.rb new file mode 100644 index 00000000000..4622a1dd855 --- /dev/null +++ b/app/serializers/analytics_merge_request_serializer.rb @@ -0,0 +1,3 @@ +class AnalyticsMergeRequestSerializer < AnalyticsGenericSerializer + entity AnalyticsMergeRequestEntity +end diff --git a/app/serializers/build_entity.rb b/app/serializers/build_entity.rb index 3d9ac66de0e..cf1c418a88e 100644 --- a/app/serializers/build_entity.rb +++ b/app/serializers/build_entity.rb @@ -4,21 +4,21 @@ class BuildEntity < Grape::Entity expose :id expose :name - expose :build_url do |build| - url_to(:namespace_project_build, build) + expose :build_path do |build| + path_to(:namespace_project_build, build) end - expose :retry_url do |build| - url_to(:retry_namespace_project_build, build) + expose :retry_path do |build| + path_to(:retry_namespace_project_build, build) end - expose :play_url, if: ->(build, _) { build.manual? } do |build| - url_to(:play_namespace_project_build, build) + expose :play_path, if: ->(build, _) { build.manual? } do |build| + path_to(:play_namespace_project_build, build) end private - def url_to(route, build) - send("#{route}_url", build.project.namespace, build.project, build) + def path_to(route, build) + send("#{route}_path", build.project.namespace, build.project, build) end end diff --git a/app/serializers/commit_entity.rb b/app/serializers/commit_entity.rb index f7eba6fc1e3..acc20f6dc52 100644 --- a/app/serializers/commit_entity.rb +++ b/app/serializers/commit_entity.rb @@ -9,4 +9,11 @@ class CommitEntity < API::Entities::RepoCommit request.project, id: commit.id) end + + expose :commit_path do |commit| + namespace_project_tree_path( + request.project.namespace, + request.project, + id: commit.id) + end end diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb index ad6fc8d665b..d610fbe0c8a 100644 --- a/app/serializers/deployment_entity.rb +++ b/app/serializers/deployment_entity.rb @@ -10,8 +10,8 @@ class DeploymentEntity < Grape::Entity deployment.ref end - expose :ref_url do |deployment| - namespace_project_tree_url( + expose :ref_path do |deployment| + namespace_project_tree_path( deployment.project.namespace, deployment.project, id: deployment.ref) diff --git a/app/serializers/entity_date_helper.rb b/app/serializers/entity_date_helper.rb new file mode 100644 index 00000000000..b333b3344c3 --- /dev/null +++ b/app/serializers/entity_date_helper.rb @@ -0,0 +1,35 @@ +module EntityDateHelper + include ActionView::Helpers::DateHelper + + def interval_in_words(diff) + "#{distance_of_time_in_words(diff.to_f)} ago" + end + + # Converts seconds into a hash such as: + # { days: 1, hours: 3, mins: 42, seconds: 40 } + # + # It returns 0 seconds for zero or negative numbers + # It rounds to nearest time unit and does not return zero + # i.e { min: 1 } instead of { mins: 1, seconds: 0 } + def distance_of_time_as_hash(diff) + diff = diff.abs.floor + + return { seconds: 0 } if diff == 0 + + mins = (diff / 60).floor + seconds = diff % 60 + hours = (mins / 60).floor + mins = mins % 60 + days = (hours / 24).floor + hours = hours % 24 + + duration_hash = {} + + duration_hash[:days] = days if days > 0 + duration_hash[:hours] = hours if hours > 0 + duration_hash[:mins] = mins if mins > 0 + duration_hash[:seconds] = seconds if seconds > 0 + + duration_hash + end +end diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb index ee4392cc46d..7e0fc9c071e 100644 --- a/app/serializers/environment_entity.rb +++ b/app/serializers/environment_entity.rb @@ -9,8 +9,15 @@ class EnvironmentEntity < Grape::Entity expose :last_deployment, using: DeploymentEntity expose :stoppable? - expose :environment_url do |environment| - namespace_project_environment_url( + expose :environment_path do |environment| + namespace_project_environment_path( + environment.project.namespace, + environment.project, + environment) + end + + expose :stop_path do |environment| + stop_namespace_project_environment_path( environment.project.namespace, environment.project, environment) diff --git a/app/serializers/issuable_entity.rb b/app/serializers/issuable_entity.rb new file mode 100644 index 00000000000..17c9160cb19 --- /dev/null +++ b/app/serializers/issuable_entity.rb @@ -0,0 +1,16 @@ +class IssuableEntity < Grape::Entity + expose :id + expose :iid + expose :assignee_id + expose :author_id + expose :description + expose :lock_version + expose :milestone_id + expose :position + expose :state + expose :title + expose :updated_by_id + expose :created_at + expose :updated_at + expose :deleted_at +end diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb new file mode 100644 index 00000000000..6429159ebe1 --- /dev/null +++ b/app/serializers/issue_entity.rb @@ -0,0 +1,9 @@ +class IssueEntity < IssuableEntity + expose :branch_name + expose :confidential + expose :due_date + expose :moved_to_id + expose :project_id + expose :milestone, using: API::Entities::Milestone + expose :labels, using: LabelEntity +end diff --git a/app/serializers/issue_serializer.rb b/app/serializers/issue_serializer.rb new file mode 100644 index 00000000000..4fff54a9126 --- /dev/null +++ b/app/serializers/issue_serializer.rb @@ -0,0 +1,3 @@ +class IssueSerializer < BaseSerializer + entity IssueEntity +end diff --git a/app/serializers/label_entity.rb b/app/serializers/label_entity.rb new file mode 100644 index 00000000000..304fd9de08f --- /dev/null +++ b/app/serializers/label_entity.rb @@ -0,0 +1,11 @@ +class LabelEntity < Grape::Entity + expose :id + expose :title + expose :color + expose :description + expose :group_id + expose :project_id + expose :template + expose :created_at + expose :updated_at +end diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_entity.rb new file mode 100644 index 00000000000..7445298c714 --- /dev/null +++ b/app/serializers/merge_request_entity.rb @@ -0,0 +1,14 @@ +class MergeRequestEntity < IssuableEntity + expose :in_progress_merge_commit_sha + expose :locked_at + expose :merge_commit_sha + expose :merge_error + expose :merge_params + expose :merge_status + expose :merge_user_id + expose :merge_when_build_succeeds + expose :source_branch + expose :source_project_id + expose :target_branch + expose :target_project_id +end diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb new file mode 100644 index 00000000000..aa6e00dfcb4 --- /dev/null +++ b/app/serializers/merge_request_serializer.rb @@ -0,0 +1,3 @@ +class MergeRequestSerializer < BaseSerializer + entity MergeRequestEntity +end diff --git a/app/services/destroy_group_service.rb b/app/services/destroy_group_service.rb index 0081364b8aa..a880952e274 100644 --- a/app/services/destroy_group_service.rb +++ b/app/services/destroy_group_service.rb @@ -6,12 +6,10 @@ class DestroyGroupService end def async_execute - group.transaction do - # Soft delete via paranoia gem - group.destroy - job_id = GroupDestroyWorker.perform_async(group.id, current_user.id) - Rails.logger.info("User #{current_user.id} scheduled a deletion of group ID #{group.id} with job ID #{job_id}") - end + # Soft delete via paranoia gem + group.destroy + job_id = GroupDestroyWorker.perform_async(group.id, current_user.id) + Rails.logger.info("User #{current_user.id} scheduled a deletion of group ID #{group.id} with job ID #{job_id}") end def execute diff --git a/app/services/merge_requests/add_todo_when_build_fails_service.rb b/app/services/merge_requests/add_todo_when_build_fails_service.rb index d572a928a42..12a8415d9a5 100644 --- a/app/services/merge_requests/add_todo_when_build_fails_service.rb +++ b/app/services/merge_requests/add_todo_when_build_fails_service.rb @@ -1,13 +1,18 @@ module MergeRequests class AddTodoWhenBuildFailsService < MergeRequests::BaseService # Adds a todo to the parent merge_request when a CI build fails + # def execute(commit_status) + return if commit_status.allow_failure? + commit_status_merge_requests(commit_status) do |merge_request| todo_service.merge_request_build_failed(merge_request) end end - # Closes any pending build failed todos for the parent MRs when a build is retried + # Closes any pending build failed todos for the parent MRs when a + # build is retried + # def close(commit_status) commit_status_merge_requests(commit_status) do |merge_request| todo_service.merge_request_build_retried(merge_request) diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index f415244068b..dd0d738674e 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -48,11 +48,11 @@ module MergeRequests end # See if source and target branches exist - unless merge_request.source_project.commit(merge_request.source_branch) + if merge_request.source_branch.present? && !merge_request.source_project.commit(merge_request.source_branch) messages << "Source branch \"#{merge_request.source_branch}\" does not exist" end - unless merge_request.target_project.commit(merge_request.target_branch) + if merge_request.target_branch.present? && !merge_request.target_project.commit(merge_request.target_branch) messages << "Target branch \"#{merge_request.target_branch}\" does not exist" end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index e338792412b..7935fabe2da 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -35,7 +35,7 @@ module Notes todo_service.new_note(note, current_user) end - if command_params && command_params.any? + if command_params.present? slash_commands_service.execute(command_params, note) # We must add the error after we call #save because errors are reset diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 28db145a1f4..159f46cd465 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -106,6 +106,8 @@ module Projects unless @project.group || @project.gitlab_project_import? @project.team << [current_user, :master, current_user] end + + @project.group.refresh_members_authorized_projects if @project.group end def skip_wiki? diff --git a/app/services/user_project_access_changed_service.rb b/app/services/user_project_access_changed_service.rb new file mode 100644 index 00000000000..2469b4f0d7c --- /dev/null +++ b/app/services/user_project_access_changed_service.rb @@ -0,0 +1,9 @@ +class UserProjectAccessChangedService + def initialize(user_ids) + @user_ids = Array.wrap(user_ids) + end + + def execute + AuthorizedProjectsWorker.bulk_perform_async(@user_ids.map { |id| [id] }) + end +end diff --git a/app/views/admin/builds/index.html.haml b/app/views/admin/builds/index.html.haml index 26a8846b609..5e3f105d41f 100644 --- a/app/views/admin/builds/index.html.haml +++ b/app/views/admin/builds/index.html.haml @@ -14,5 +14,5 @@ .row-content-block.second-block #{(@scope || 'all').capitalize} builds - %ul.content-list.builds-content-list + %ul.content-list.builds-content-list.admin-builds-table = render "projects/builds/table", builds: @builds, admin: true diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml index 817910f7ddf..589f4557b52 100644 --- a/app/views/admin/groups/_form.html.haml +++ b/app/views/admin/groups/_form.html.haml @@ -7,7 +7,7 @@ .col-sm-10 = render 'shared/choose_group_avatar_button', f: f - = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group + = render 'shared/visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group .form-group .col-sm-offset-2.col-sm-10 diff --git a/app/views/admin/groups/edit.html.haml b/app/views/admin/groups/edit.html.haml index eb09a6328ed..c2b9807015d 100644 --- a/app/views/admin/groups/edit.html.haml +++ b/app/views/admin/groups/edit.html.haml @@ -1,4 +1,4 @@ - page_title "Edit", @group.name, "Groups" %h3.page-title Edit group: #{@group.name} %hr -= render 'form' += render 'form', visibility_level: @group.visibility_level diff --git a/app/views/admin/groups/new.html.haml b/app/views/admin/groups/new.html.haml index c81ee552ac3..8f9fe96249f 100644 --- a/app/views/admin/groups/new.html.haml +++ b/app/views/admin/groups/new.html.haml @@ -1,4 +1,4 @@ - page_title "New Group" %h3.page-title New group %hr -= render 'form' += render 'form', visibility_level: default_group_visibility diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml index 21b89580818..84e13693dfd 100644 --- a/app/views/devise/sessions/_new_base.html.haml +++ b/app/views/devise/sessions/_new_base.html.haml @@ -5,8 +5,6 @@ %div.form-group = f.label :password = f.password_field :password, class: "form-control bottom", required: true, title: "This field is required." - %div.submit-container.move-submit-down - = f.submit "Sign in", class: "btn btn-save" - if devise_mapping.rememberable? .remember-me.checkbox %label{for: "user_remember_me"} @@ -14,3 +12,5 @@ %span Remember me .pull-right.forgot-password = link_to "Forgot your password?", new_password_path(resource_name) + %div.submit-container.move-submit-down + = f.submit "Sign in", class: "btn btn-save" diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml index 077e8e64e5f..e4b4ea675d2 100644 --- a/app/views/discussions/_discussion.html.haml +++ b/app/views/discussions/_discussion.html.haml @@ -1,9 +1,6 @@ - expanded = discussion.expanded? %li.note.note-discussion.timeline-entry .timeline-entry-inner - .timeline-icon - = link_to user_path(discussion.author) do - = image_tag avatar_icon(discussion.author), class: "avatar s40" .timeline-content .discussion.js-toggle-container{ class: discussion.id, data: { discussion_id: discussion.id } } .discussion-header @@ -13,9 +10,7 @@ = icon("chevron-up") - else = icon("chevron-down") - Toggle discussion - = link_to_member(@project, discussion.author, avatar: false) .inline.discussion-headline-light @@ -38,8 +33,6 @@ = time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago") - = render "discussions/headline", discussion: discussion - .discussion-body.js-toggle-content{ class: ("hide" unless expanded) } - if discussion.diff_discussion? && discussion.diff_file = render "discussions/diff_with_notes", discussion: discussion diff --git a/app/views/projects/builds/_header.html.haml b/app/views/projects/builds/_header.html.haml index 9f69bd64f71..f6aa20c4579 100644 --- a/app/views/projects/builds/_header.html.haml +++ b/app/views/projects/builds/_header.html.haml @@ -17,6 +17,6 @@ = render "user" = time_ago_with_tooltip(@build.created_at) - if can?(current_user, :update_build, @build) && @build.retryable? - = link_to "Retry build", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-inverted pull-right', method: :post + = link_to "Retry build", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary pull-right', method: :post %button.btn.btn-default.pull-right.visible-xs-block.visible-sm-block.build-gutter-toggle.js-sidebar-build-toggle{ role: "button", type: "button" } = icon('angle-double-left') diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml index f533eec642e..d8cbfd7173a 100644 --- a/app/views/projects/builds/show.html.haml +++ b/app/views/projects/builds/show.html.haml @@ -26,6 +26,30 @@ = link_to namespace_project_runners_path(@build.project.namespace, @build.project) do Runners page + - if @build.starts_environment? + .prepend-top-default + .environment-information + - if @build.outdated_deployment? + = ci_icon_for_status('success_with_warnings') + - else + = ci_icon_for_status(@build.status) + + - environment = environment_for_build(@build.project, @build) + - if @build.success? && @build.last_deployment.present? + - if @build.last_deployment.last? + This build is the most recent deployment to #{environment_link_for_build(@build.project, @build)}. + - else + This build is an out-of-date deployment to #{environment_link_for_build(@build.project, @build)}. + - if environment.last_deployment + View the most recent deployment #{deployment_link(environment.last_deployment)}. + - elsif @build.complete? && !@build.success? + The deployment of this build to #{environment_link_for_build(@build.project, @build)} did not succeed. + - else + This build is creating a deployment to #{environment_link_for_build(@build.project, @build)} + - if environment.last_deployment + and will overwrite the + = link_to 'latest deployment', deployment_link(environment.last_deployment) + .prepend-top-default - if @build.erased? .erased.alert.alert-warning diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 0ebc38d16cf..503cbd13b5e 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -1,5 +1,5 @@ -.commit-info-row.commit-info-row-header - .commit-meta +.page-content-header + .header-main-content %strong Commit %strong.monospace.js-details-short= @commit.short_id = link_to("#", class: "js-details-expand hidden-xs hidden-sm") do @@ -19,7 +19,8 @@ %strong = commit_committer_link(@commit, avatar: true, size: 24) #{time_ago_with_tooltip(@commit.committed_date)} - .commit-action-buttons + + .header-action-buttons - if defined?(@notes_count) && @notes_count > 0 %span.btn.disabled.btn-grouped.hidden-xs.append-right-10 = icon('comment') @@ -55,8 +56,8 @@ %pre.commit-description = preserve(markdown(@commit.description, pipeline: :single_line, author: @commit.author)) -.commit-info-widget - .widget-row.branch-info +.info-well + .well-segment.branch-info .icon-container.commit-icon = custom_icon("icon_commit") %span.cgray= pluralize(@commit.parents.count, "parent") @@ -66,7 +67,7 @@ %i.fa.fa-spinner.fa-spin - if @commit.status - .widget-row.pipeline-info + .well-segment.pipeline-info .icon-container = ci_icon_for_status(@commit.status) Pipeline diff --git a/app/views/projects/commit/_pipeline.html.haml b/app/views/projects/commit/_pipeline.html.haml index 062a8905a19..1174158eb65 100644 --- a/app/views/projects/commit/_pipeline.html.haml +++ b/app/views/projects/commit/_pipeline.html.haml @@ -1,10 +1,6 @@ .pipeline-graph-container .row-content-block.build-content.middle-block.pipeline-actions .pull-right - %button.btn.btn-grouped.btn-white.toggle-pipeline-btn - %span.toggle-btn-text Hide - %span pipeline graph - %span.caret - if can?(current_user, :update_pipeline, pipeline.project) - if pipeline.builds.latest.failed.any?(&:retryable?) = link_to "Retry failed", retry_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: 'btn btn-grouped btn-primary', method: :post diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 34855c54176..12096941209 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -36,7 +36,6 @@ %pre.commit-row-description.js-toggle-content = preserve(markdown(commit.description, pipeline: :single_line, author: commit.author)) - .commit-row-info - = commit_author_link(commit, avatar: false, size: 24) - authored - #{time_ago_with_tooltip(commit.committed_date)} + = commit_author_link(commit, avatar: false, size: 24) + authored + #{time_ago_with_tooltip(commit.committed_date)} diff --git a/app/views/projects/environments/_environment.html.haml b/app/views/projects/environments/_environment.html.haml deleted file mode 100644 index b75d5df4150..00000000000 --- a/app/views/projects/environments/_environment.html.haml +++ /dev/null @@ -1,35 +0,0 @@ -- last_deployment = environment.last_deployment - -%tr.environment - %td - = link_to environment.name, namespace_project_environment_path(@project.namespace, @project, environment) - - %td.deployment-column - - if last_deployment - %span ##{last_deployment.iid} - - if last_deployment.user - by - = user_avatar(user: last_deployment.user, size: 20) - - %td - - if last_deployment && last_deployment.deployable - = link_to [@project.namespace.becomes(Namespace), @project, last_deployment.deployable], class: 'build-link' do - = "#{last_deployment.deployable.name} (##{last_deployment.deployable.id})" - - %td - - if last_deployment - = render 'projects/deployments/commit', deployment: last_deployment - - else - %p.commit-title - No deployments yet - - %td - - if last_deployment - #{time_ago_with_tooltip(last_deployment.created_at)} - - %td.hidden-xs - .pull-right - = render 'projects/environments/external_url', environment: environment - = render 'projects/deployments/actions', deployment: last_deployment - = render 'projects/environments/stop', environment: environment - = render 'projects/deployments/rollback', deployment: last_deployment diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index 8f555afcf11..a9235d6af35 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -2,47 +2,19 @@ - page_title "Environments" = render "projects/pipelines/head" -%div{ class: container_class } - .top-area - %ul.nav-links - %li{class: ('active' if @scope.nil?)} - = link_to project_environments_path(@project) do - Available - %span.badge.js-available-environments-count - = number_with_delimiter(@all_environments.available.count) +- content_for :page_specific_javascripts do + = page_specific_javascript_tag("environments/environments_bundle.js") +.commit-icon-svg.hidden + = custom_icon("icon_commit") +.play-icon-svg.hidden + = custom_icon("icon_play") - %li{class: ('active' if @scope == 'stopped')} - = link_to project_environments_path(@project, scope: :stopped) do - Stopped - %span.badge.js-stopped-environments-count - = number_with_delimiter(@all_environments.stopped.count) - - - if can?(current_user, :create_environment, @project) && !@all_environments.blank? - .nav-controls - = link_to new_namespace_project_environment_path(@project.namespace, @project), class: 'btn btn-create' do - New environment - - .environments-container - - if @all_environments.blank? - .blank-state.blank-state-no-icon - %h2.blank-state-title - You don't have any environments right now. - %p.blank-state-text - Environments are places where code gets deployed, such as staging or production. - %br - = succeed "." do - = link_to "Read more about environments", help_page_path("ci/environments") - - if can?(current_user, :create_environment, @project) - = link_to new_namespace_project_environment_path(@project.namespace, @project), class: 'btn btn-create' do - New environment - - else - .table-holder - %table.table.ci-table.environments - %tbody - %th Environment - %th Last Deployment - %th Build - %th Commit - %th - %th.hidden-xs - = render @environments +#environments-list-view{ data: { environments_data: environments_list_data, + "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s, + "can-read-environment" => can?(current_user, :read_environment, @project).to_s, + "can-create-environment" => can?(current_user, :create_environment, @project).to_s, + "project-environments-path" => project_environments_path(@project), + "project-stopped-environments-path" => project_environments_path(@project, scope: :stopped), + "new-environment-path" => new_namespace_project_environment_path(@project.namespace, @project), + "help-page-path" => help_page_path("ci/environments"), + "css-class" => container_class}} diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index 12408068834..9ffcc48eb80 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -54,15 +54,18 @@ = link_to namespace_project_commits_path(merge_request.project.namespace, merge_request.project, merge_request.target_branch) do = icon('code-fork') = merge_request.target_branch + - if merge_request.milestone   = link_to namespace_project_merge_requests_path(merge_request.project.namespace, merge_request.project, milestone_title: merge_request.milestone.title) do = icon('clock-o') = merge_request.milestone.title + - if merge_request.labels.any?   - merge_request.labels.each do |label| = link_to_label(label, subject: merge_request.project, type: :merge_request) + - if merge_request.tasks?   %span.task-status diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml index 01314eb37d0..ac26aa569ba 100644 --- a/app/views/projects/merge_requests/widget/_open.html.haml +++ b/app/views/projects/merge_requests/widget/_open.html.haml @@ -23,7 +23,7 @@ = render 'projects/merge_requests/widget/open/merge_when_build_succeeds' - elsif !@merge_request.can_be_merged_by?(current_user) = render 'projects/merge_requests/widget/open/not_allowed' - - elsif !@merge_request.mergeable_ci_state? + - elsif !@merge_request.mergeable_ci_state? && (@pipeline.failed? || @pipeline.canceled?) = render 'projects/merge_requests/widget/open/build_failed' - elsif !@merge_request.mergeable_discussions_state? = render 'projects/merge_requests/widget/open/unresolved_discussions' diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml index afff15228c1..89ae64554c0 100644 --- a/app/views/projects/notes/_note.html.haml +++ b/app/views/projects/notes/_note.html.haml @@ -14,6 +14,9 @@ = note.author.to_reference - unless note.system commented + - if note.system + %span{class: 'system-note-message'} + = h(note.note_html.downcase.html_safe) %a{ href: "##{dom_id(note)}" } = time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago') - unless note.system? @@ -67,7 +70,9 @@ = render 'projects/notes/edit_form', note: note .note-awards = render 'award_emoji/awards_block', awardable: note, inline: false - + - if note.system + .system-note-commit-list-toggler + Toggle commit list - if note.attachment.url .note-attachment - if note.attachment.image? diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index d288efc546f..a0de125d765 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -1,39 +1,49 @@ -%p -.commit-info-row - Pipeline - = link_to "##{@pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, @pipeline.id), class: "monospace" - with - = pluralize @pipeline.statuses.count(:id), "build" - - if @pipeline.ref - for - = link_to @pipeline.ref, namespace_project_commits_path(@project.namespace, @project, @pipeline.ref), class: "monospace" - - if @pipeline.duration - in - = time_interval_in_words(@pipeline.duration) - - if @pipeline.queued_duration - = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})" - - .pull-right +.page-content-header + .header-main-content = link_to namespace_project_pipeline_path(@project.namespace, @project, @pipeline), class: "ci-status ci-#{@pipeline.status}" do = ci_icon_for_status(@pipeline.status) = ci_label_for_status(@pipeline.status) + %strong Pipeline ##{@commit.pipelines.last.id} + triggered #{time_ago_with_tooltip(@commit.authored_date)} by + = author_avatar(@commit, size: 24) + = commit_author_link(@commit) + .header-action-buttons + - if can?(current_user, :update_pipeline, @pipeline.project) + - if @pipeline.builds.latest.failed.any?(&:retryable?) + = link_to "Retry failed", retry_namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'btn btn-inverted-secondary', method: :post + - if @pipeline.builds.running_or_pending.any? + = link_to "Cancel running", cancel_namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post - if @commit - .commit-info-row - %span.light Authored by - %strong - = commit_author_link(@commit, avatar: true, size: 24) - #{time_ago_with_tooltip(@commit.authored_date)} - -.commit-info-row - %span.light Commit - = link_to @pipeline.sha, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "monospace" - = clipboard_button(clipboard_text: @pipeline.sha) - -- if @commit - .commit-box.content-block + .commit-box %h3.commit-title = markdown(@commit.title, pipeline: :single_line) - if @commit.description.present? %pre.commit-description = preserve(markdown(@commit.description, pipeline: :single_line)) + +.info-well + - if @commit.status + .well-segment.pipeline-info + .icon-container + = ci_icon_for_status(@commit.status) + = pluralize @pipeline.statuses.count(:id), "build" + - if @pipeline.ref + from + = link_to @pipeline.ref, namespace_project_commits_path(@project.namespace, @project, @pipeline.ref), class: "monospace" + - if @pipeline.duration + in + = time_interval_in_words(@pipeline.duration) + - if @pipeline.queued_duration + = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})" + + .well-segment.branch-info + .icon-container.commit-icon + = custom_icon("icon_commit") + = link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "monospace js-details-short" + = link_to("#", class: "js-details-expand hidden-xs hidden-sm") do + %span.text-expander + \... + %span.js-details-content.hide + = link_to @pipeline.sha, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "monospace commit-hash-full" + = clipboard_button(clipboard_text: @pipeline.sha, title: "Copy commit SHA to clipboard") diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml new file mode 100644 index 00000000000..718314701f9 --- /dev/null +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -0,0 +1,51 @@ +.tabs-holder + %ul.nav-links.no-top.no-bottom + %li.active + = link_to "Pipeline", "#js-tab-pipeline", data: { target: '#js-tab-pipeline', action: 'pipeline', toggle: 'tab' }, class: 'pipeline-tab' + %li + = link_to "#js-tab-builds", data: { target: '#js-tab-builds', action: 'build', toggle: 'tab' }, class: 'builds-tab' do + Builds + %span.badge= pipeline.statuses.count + +.tab-content + #js-tab-pipeline.tab-pane.active + .build-content.middle-block.pipeline-graph + .pipeline-visualization + %ul.stage-column-list + - stages = pipeline.stages_with_latest_statuses + - stages.each do |stage, statuses| + %li.stage-column + .stage-name + %a{name: stage} + - if stage + = stage.titleize + .builds-container + %ul + = render "projects/commit/pipeline_stage", statuses: statuses + + #js-tab-builds.tab-pane + - if pipeline.yaml_errors.present? + .bs-callout.bs-callout-danger + %h4 Found errors in your .gitlab-ci.yml: + %ul + - pipeline.yaml_errors.split(",").each do |error| + %li= error + You can also test your .gitlab-ci.yml in the #{link_to "Lint", ci_lint_path} + + - if pipeline.project.builds_enabled? && !pipeline.ci_yaml_file + .bs-callout.bs-callout-warning + \.gitlab-ci.yml not found in this commit + + .table-holder.pipeline-holder + %table.table.ci-table.pipeline + %thead + %tr + %th Status + %th Build ID + %th Name + %th + - if pipeline.project.build_coverage_enabled? + %th Coverage + %th + - pipeline.statuses.relevant.stages.each do |stage| + = render 'projects/commit/ci_stage', stage: stage, statuses: pipeline.statuses.relevant.where(stage: stage) diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index 688535ad764..8c6652a5f90 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -3,9 +3,7 @@ = render "projects/pipelines/head" %div{ class: container_class } - .prepend-top-default - - if @commit - = render "projects/pipelines/info" - %div.block-connector + - if @commit + = render "projects/pipelines/info" - = render "projects/commit/pipeline", pipeline: @pipeline + = render "projects/pipelines/with_tabs", pipeline: @pipeline diff --git a/app/views/projects/refs/logs_tree.js.haml b/app/views/projects/refs/logs_tree.js.haml index 44fa4b60343..d07bb661615 100644 --- a/app/views/projects/refs/logs_tree.js.haml +++ b/app/views/projects/refs/logs_tree.js.haml @@ -14,8 +14,8 @@ // Load more commit logs for each file in tree // if we still on the same page var url = "#{escape_javascript(@more_log_url)}"; - ajaxGet(url); + gl.utils.ajaxGet(url); } :plain - gl.utils.localTimeAgo($('.js-timeago', 'table.table_#{@hex_path} tbody')); \ No newline at end of file + gl.utils.localTimeAgo($('.js-timeago', 'table.table_#{@hex_path} tbody')); diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml index 5254d265918..601ef51737a 100644 --- a/app/views/shared/_service_settings.html.haml +++ b/app/views/shared/_service_settings.html.haml @@ -10,26 +10,27 @@ .col-sm-10 = form.check_box :active -.form-group - = form.label :url, "Trigger", class: 'control-label' +- if @service.supported_events.present? + .form-group + = form.label :url, "Trigger", class: 'control-label' - .col-sm-10 - - @service.supported_events.each do |event| - %div - = form.check_box service_event_field_name(event), class: 'pull-left' - .prepend-left-20 - = form.label service_event_field_name(event), class: 'list-label' do - %strong - = event.humanize + .col-sm-10 + - @service.supported_events.each do |event| + %div + = form.check_box service_event_field_name(event), class: 'pull-left' + .prepend-left-20 + = form.label service_event_field_name(event), class: 'list-label' do + %strong + = event.humanize - - field = @service.event_field(event) + - field = @service.event_field(event) - - if field - %p - = form.text_field field[:name], class: "form-control", placeholder: field[:placeholder] + - if field + %p + = form.text_field field[:name], class: "form-control", placeholder: field[:placeholder] - %p.light - = service_event_description(event) + %p.light + = service_event_description(event) - @service.global_fields.each do |field| - type = field[:type] diff --git a/app/views/shared/icons/_icon_status_skipped.svg b/app/views/shared/icons/_icon_status_skipped.svg index 3420af411f6..e0a2d4282f0 100644 --- a/app/views/shared/icons/_icon_status_skipped.svg +++ b/app/views/shared/icons/_icon_status_skipped.svg @@ -1 +1 @@ -Group Copy 31 + diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 2fe9e82194b..9b9ad510444 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -125,7 +125,7 @@ - else .pull-right - if can?(current_user, :"destroy_#{issuable.to_ability_name}", @project) - = link_to 'Delete', polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable]), data: { confirm: "#{issuable.class.name.titleize} will be removed! Are you sure?" }, + = link_to 'Delete', polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable]), data: { confirm: "#{issuable.human_class_name} will be removed! Are you sure?" }, method: :delete, class: 'btn btn-danger btn-grouped' = link_to 'Cancel', polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable]), class: 'btn btn-grouped btn-cancel' diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb new file mode 100644 index 00000000000..331727ba9d8 --- /dev/null +++ b/app/workers/authorized_projects_worker.rb @@ -0,0 +1,15 @@ +class AuthorizedProjectsWorker + include Sidekiq::Worker + include DedicatedSidekiqQueue + + def self.bulk_perform_async(args_list) + Sidekiq::Client.push_bulk('class' => self, 'args' => args_list) + end + + def perform(user_id) + user = User.find_by(id: user_id) + return unless user + + user.refresh_authorized_projects + end +end diff --git a/app/workers/build_success_worker.rb b/app/workers/build_success_worker.rb index e0ad5268664..e17add7421f 100644 --- a/app/workers/build_success_worker.rb +++ b/app/workers/build_success_worker.rb @@ -4,15 +4,13 @@ class BuildSuccessWorker def perform(build_id) Ci::Build.find_by(id: build_id).try do |build| - create_deployment(build) + create_deployment(build) if build.has_environment? end end private def create_deployment(build) - return if build.environment.blank? - service = CreateDeploymentService.new( build.project, build.user, environment: build.environment, diff --git a/app/workers/pipeline_metrics_worker.rb b/app/workers/pipeline_metrics_worker.rb index 34f6ef161fb..070943f1ecc 100644 --- a/app/workers/pipeline_metrics_worker.rb +++ b/app/workers/pipeline_metrics_worker.rb @@ -12,11 +12,11 @@ class PipelineMetricsWorker private def update_metrics_for_active_pipeline(pipeline) - metrics(pipeline).update_all(latest_build_started_at: pipeline.started_at, latest_build_finished_at: nil) + metrics(pipeline).update_all(latest_build_started_at: pipeline.started_at, latest_build_finished_at: nil, pipeline_id: pipeline.id) end def update_metrics_for_succeeded_pipeline(pipeline) - metrics(pipeline).update_all(latest_build_started_at: pipeline.started_at, latest_build_finished_at: pipeline.finished_at) + metrics(pipeline).update_all(latest_build_started_at: pipeline.started_at, latest_build_finished_at: pipeline.finished_at, pipeline_id: pipeline.id) end def metrics(pipeline) diff --git a/changelogs/unreleased/19981-admin-links-new-group-default-visibility.yml b/changelogs/unreleased/19981-admin-links-new-group-default-visibility.yml new file mode 100644 index 00000000000..18fb8a6ad45 --- /dev/null +++ b/changelogs/unreleased/19981-admin-links-new-group-default-visibility.yml @@ -0,0 +1,4 @@ +--- +title: Make New Group form respect default visibility application setting +merge_request: 7454 +author: Jacopo Beschi @jacopo-beschi diff --git a/changelogs/unreleased/22539-display-folders.yml b/changelogs/unreleased/22539-display-folders.yml new file mode 100644 index 00000000000..d46cdedf7a7 --- /dev/null +++ b/changelogs/unreleased/22539-display-folders.yml @@ -0,0 +1,4 @@ +--- +title: Display "folders" for environments +merge_request: 7015 +author: diff --git a/changelogs/unreleased/23223-group-deletion-race-condition.yml b/changelogs/unreleased/23223-group-deletion-race-condition.yml new file mode 100644 index 00000000000..6f22e85fb4b --- /dev/null +++ b/changelogs/unreleased/23223-group-deletion-race-condition.yml @@ -0,0 +1,4 @@ +--- +title: Fix race condition during group deletion and remove stale records present due to this bug +merge_request: 7528 +author: Timothy Andrew diff --git a/changelogs/unreleased/23637-title-bar-pipelines.yml b/changelogs/unreleased/23637-title-bar-pipelines.yml new file mode 100644 index 00000000000..3d4cf88c54c --- /dev/null +++ b/changelogs/unreleased/23637-title-bar-pipelines.yml @@ -0,0 +1,4 @@ +--- +title: Redesign pipelines page +merge_request: +author: diff --git a/changelogs/unreleased/24070-project-margins.yml b/changelogs/unreleased/24070-project-margins.yml new file mode 100644 index 00000000000..cbc2b0269f0 --- /dev/null +++ b/changelogs/unreleased/24070-project-margins.yml @@ -0,0 +1,4 @@ +--- +title: Fix Margins look weird in Project page with pinned sidebar in project stats bar +merge_request: 7580 +author: diff --git a/changelogs/unreleased/24616-mr-shows-the-build-for-this-merge-request-failed-although-builds-still-running.yml b/changelogs/unreleased/24616-mr-shows-the-build-for-this-merge-request-failed-although-builds-still-running.yml new file mode 100644 index 00000000000..3773459550f --- /dev/null +++ b/changelogs/unreleased/24616-mr-shows-the-build-for-this-merge-request-failed-although-builds-still-running.yml @@ -0,0 +1,4 @@ +--- +title: Fix a wrong "The build for this merge request failed" message +merge_request: 7579 +author: diff --git a/changelogs/unreleased/24627-fix-bad-mr-error-message.yml b/changelogs/unreleased/24627-fix-bad-mr-error-message.yml new file mode 100644 index 00000000000..d6a9818b2ce --- /dev/null +++ b/changelogs/unreleased/24627-fix-bad-mr-error-message.yml @@ -0,0 +1,4 @@ +--- +title: Fix regression causing bad error message to appear on Merge Request form +merge_request: 7599 +author: Alex Sanford diff --git a/changelogs/unreleased/bugfix-html-only-mail.yml b/changelogs/unreleased/bugfix-html-only-mail.yml new file mode 100644 index 00000000000..ea0d4e7396f --- /dev/null +++ b/changelogs/unreleased/bugfix-html-only-mail.yml @@ -0,0 +1,4 @@ +--- +title: Add support for reply-by-email when the email only contains HTML +merge_request: 7397 +author: diff --git a/changelogs/unreleased/changelog-update.yml b/changelogs/unreleased/changelog-update.yml new file mode 100644 index 00000000000..24fa34f4121 --- /dev/null +++ b/changelogs/unreleased/changelog-update.yml @@ -0,0 +1,4 @@ +--- +title: Add environment info to builds page +merge_request: +author: diff --git a/changelogs/unreleased/create-pipeline-endpoint.yml b/changelogs/unreleased/create-pipeline-endpoint.yml new file mode 100644 index 00000000000..c7638b7b7aa --- /dev/null +++ b/changelogs/unreleased/create-pipeline-endpoint.yml @@ -0,0 +1,4 @@ +--- +title: Add api endpoint for creating a pipeline +merge_request: 7209 +author: Ido Leibovich diff --git a/changelogs/unreleased/dz-fix-group-name-dot.yml b/changelogs/unreleased/dz-fix-group-name-dot.yml new file mode 100644 index 00000000000..439cb229e6b --- /dev/null +++ b/changelogs/unreleased/dz-fix-group-name-dot.yml @@ -0,0 +1,4 @@ +--- +title: Fix 404 on some group pages when name contains dot +merge_request: 7614 +author: diff --git a/changelogs/unreleased/feature-cycle-analytics-events.yml b/changelogs/unreleased/feature-cycle-analytics-events.yml new file mode 100644 index 00000000000..e1211a3c774 --- /dev/null +++ b/changelogs/unreleased/feature-cycle-analytics-events.yml @@ -0,0 +1,4 @@ +--- +title: Add events per stage to cycle analytics +merge_request: +author: diff --git a/changelogs/unreleased/feature-precalculate-authorized-projects.yml b/changelogs/unreleased/feature-precalculate-authorized-projects.yml new file mode 100644 index 00000000000..6e48b710fec --- /dev/null +++ b/changelogs/unreleased/feature-precalculate-authorized-projects.yml @@ -0,0 +1,4 @@ +--- +title: Precalculate user's authorized projects in database +merge_request: 6839 +author: diff --git a/changelogs/unreleased/fix-Build-timeFor.yml b/changelogs/unreleased/fix-Build-timeFor.yml new file mode 100644 index 00000000000..ea115f7ee67 --- /dev/null +++ b/changelogs/unreleased/fix-Build-timeFor.yml @@ -0,0 +1,4 @@ +--- +title: Fix typo in Build page JavaScript +merge_request: 7563 +author: winniehell diff --git a/changelogs/unreleased/fix-admin-ci-table.yml b/changelogs/unreleased/fix-admin-ci-table.yml new file mode 100644 index 00000000000..9a9e39ee94a --- /dev/null +++ b/changelogs/unreleased/fix-admin-ci-table.yml @@ -0,0 +1,4 @@ +--- +title: Fix misaligned buttons on admin builds page +merge_request: 7424 +author: Didem Acet diff --git a/changelogs/unreleased/fix-do-not-add-todo-when-build-allowed-to-fail.yml b/changelogs/unreleased/fix-do-not-add-todo-when-build-allowed-to-fail.yml new file mode 100644 index 00000000000..6402d0c7ece --- /dev/null +++ b/changelogs/unreleased/fix-do-not-add-todo-when-build-allowed-to-fail.yml @@ -0,0 +1,4 @@ +--- +title: Do not create a new TODO when failed build is allowed to fail +merge_request: 7618 +author: diff --git a/changelogs/unreleased/fix-require-build-script-configuration-entry.yml b/changelogs/unreleased/fix-require-build-script-configuration-entry.yml new file mode 100644 index 00000000000..00b3fd2681f --- /dev/null +++ b/changelogs/unreleased/fix-require-build-script-configuration-entry.yml @@ -0,0 +1,4 @@ +--- +title: Make job script a required configuration entry +merge_request: 7566 +author: diff --git a/changelogs/unreleased/fix-slack-pipeline-event.yml b/changelogs/unreleased/fix-slack-pipeline-event.yml new file mode 100644 index 00000000000..fec864eeb3d --- /dev/null +++ b/changelogs/unreleased/fix-slack-pipeline-event.yml @@ -0,0 +1,4 @@ +--- +title: Fix pipeline author for Slack and use pipeline id for pipeline link +merge_request: 7506 +author: diff --git a/changelogs/unreleased/issue_13232.yml b/changelogs/unreleased/issue_13232.yml new file mode 100644 index 00000000000..6dc2de5afe4 --- /dev/null +++ b/changelogs/unreleased/issue_13232.yml @@ -0,0 +1,4 @@ +--- +title: Add JIRA remotelinks and prevent duplicated closing messages +merge_request: +author: diff --git a/changelogs/unreleased/issue_5541.yml b/changelogs/unreleased/issue_5541.yml new file mode 100644 index 00000000000..cf553cf8d80 --- /dev/null +++ b/changelogs/unreleased/issue_5541.yml @@ -0,0 +1,4 @@ +--- +title: Allow enabling and disabling commit and MR events for JIRA +merge_request: +author: diff --git a/changelogs/unreleased/less-intrusive-system-note.yml b/changelogs/unreleased/less-intrusive-system-note.yml new file mode 100644 index 00000000000..87a5a3be246 --- /dev/null +++ b/changelogs/unreleased/less-intrusive-system-note.yml @@ -0,0 +1,4 @@ +--- +title: Make system notes less intrusive +merge_request: 6755 +author: diff --git a/changelogs/unreleased/optimize-mr-index.yml b/changelogs/unreleased/optimize-mr-index.yml new file mode 100644 index 00000000000..1090b6d4528 --- /dev/null +++ b/changelogs/unreleased/optimize-mr-index.yml @@ -0,0 +1,4 @@ +--- +title: More aggressively preload on merge request and issue index pages +merge_request: +author: diff --git a/changelogs/unreleased/pass-correct-tag-target-to-post-receive.yml b/changelogs/unreleased/pass-correct-tag-target-to-post-receive.yml new file mode 100644 index 00000000000..5e868027ed6 --- /dev/null +++ b/changelogs/unreleased/pass-correct-tag-target-to-post-receive.yml @@ -0,0 +1,4 @@ +--- +title: Pass correct tag target to post-receive hook when creating tag via UI +merge_request: 7556 +author: diff --git a/changelogs/unreleased/rack_attack_logging.yml b/changelogs/unreleased/rack_attack_logging.yml new file mode 100644 index 00000000000..c0d6c1fd12e --- /dev/null +++ b/changelogs/unreleased/rack_attack_logging.yml @@ -0,0 +1,4 @@ +--- +title: Add logging for rack attack events to production.log +merge_request: +author: diff --git a/changelogs/unreleased/remove-unnecessary-self-from-user-model.yml b/changelogs/unreleased/remove-unnecessary-self-from-user-model.yml new file mode 100644 index 00000000000..bef11c63675 --- /dev/null +++ b/changelogs/unreleased/remove-unnecessary-self-from-user-model.yml @@ -0,0 +1,4 @@ +--- +title: Remove unnecessary self from user model +merge_request: 7551 +author: Semyon Pupkov diff --git a/changelogs/unreleased/zj-slash-commands-mattermost.yml b/changelogs/unreleased/zj-slash-commands-mattermost.yml new file mode 100644 index 00000000000..996ffe954f3 --- /dev/null +++ b/changelogs/unreleased/zj-slash-commands-mattermost.yml @@ -0,0 +1,4 @@ +--- +title: Added Mattermost slash command +merge_request: 7438 +author: diff --git a/config/application.rb b/config/application.rb index 946b632b0e8..fb84870dfbd 100644 --- a/config/application.rb +++ b/config/application.rb @@ -94,6 +94,7 @@ module Gitlab config.assets.precompile << "cycle_analytics/cycle_analytics_bundle.js" config.assets.precompile << "merge_conflicts/merge_conflicts_bundle.js" config.assets.precompile << "boards/test_utils/simulate_drag.js" + config.assets.precompile << "environments/environments_bundle.js" config.assets.precompile << "blob_edit/blob_edit_bundle.js" config.assets.precompile << "snippet/snippet_bundle.js" config.assets.precompile << "lib/utils/*.js" diff --git a/config/initializers/rack_attack_logging.rb b/config/initializers/rack_attack_logging.rb new file mode 100644 index 00000000000..8bb9ea29c33 --- /dev/null +++ b/config/initializers/rack_attack_logging.rb @@ -0,0 +1,7 @@ +# Adds logging for all Rack Attack blocks and throttling events. + +ActiveSupport::Notifications.subscribe('rack.attack') do |name, start, finish, request_id, req| + if [:throttle, :blacklist].include? req.env['rack.attack.match_type'] + Rails.logger.info("Rack_Attack: #{req.env['rack.attack.match_type']} #{req.ip} #{req.request_method} #{req.fullpath}") + end +end diff --git a/config/no_todos_messages.yml b/config/no_todos_messages.yml index 8372fb4ebe9..264a975b614 100644 --- a/config/no_todos_messages.yml +++ b/config/no_todos_messages.yml @@ -1,13 +1,11 @@ -# When the Todos list on the user's dashboard becomes empty, one of the messages below shows up randomly. +# When the todo list on the user's dashboard becomes empty, a random message +# from the list below will be shown. # # If you come up with a fun one, please feel free to contribute it to GitLab! # https://about.gitlab.com/contributing/ - --- - Good job! Looks like you don't have any todos left. -- Coffee really tastes better without any todos left. -- Isn't an empty To Do list beautiful? -- Time for a rewarding coffee break +- Isn't an empty todo list beautiful? - Give yourself a pat on the back! -- High five! -- Hence forth you shall be known as 'Todo Destroyer' \ No newline at end of file +- Nothing left to do, high five! +- Henceforth you shall be known as "Todo Destroyer". diff --git a/config/routes/group.rb b/config/routes/group.rb index 068e0b6e843..a3a001178b4 100644 --- a/config/routes/group.rb +++ b/config/routes/group.rb @@ -14,7 +14,9 @@ end resources :groups, only: [:index, :new, :create] -scope(path: 'groups/:id', controller: :groups) do +scope(path: 'groups/:id', + controller: :groups, + constraints: { id: Gitlab::Regex.namespace_route_regex }) do get :edit, as: :edit_group get :issues, as: :issues_group get :merge_requests, as: :merge_requests_group @@ -22,7 +24,10 @@ scope(path: 'groups/:id', controller: :groups) do get :activity, as: :activity_group end -scope(path: 'groups/:group_id', module: :groups, as: :group) do +scope(path: 'groups/:group_id', + module: :groups, + as: :group, + constraints: { group_id: Gitlab::Regex.namespace_route_regex }) do resources :group_members, only: [:index, :create, :update, :destroy], concerns: :access_requestable do post :resend_invite, on: :member delete :leave, on: :collection @@ -37,4 +42,4 @@ scope(path: 'groups/:group_id', module: :groups, as: :group) do end # Must be last route in this file -get 'groups/:id' => 'groups#show', as: :group_canonical +get 'groups/:id' => 'groups#show', as: :group_canonical, constraints: { id: Gitlab::Regex.namespace_route_regex } diff --git a/config/routes/project.rb b/config/routes/project.rb index 9cf8465dca8..d6eae1c9fce 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -153,6 +153,18 @@ resources :namespaces, path: '/', constraints: { id: /[a-zA-Z.0-9_\-]+/ }, only: resource :cycle_analytics, only: [:show] + namespace :cycle_analytics do + scope :events, controller: 'events' do + get :issue + get :plan + get :code + get :test + get :review + get :staging + get :production + end + end + resources :builds, only: [:index, :show], constraints: { id: /\d+/ } do collection do post :cancel_all diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index f3531dd30a5..69136b73946 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -35,6 +35,7 @@ - [clear_database_cache, 1] - [delete_user, 1] - [delete_merged_branches, 1] + - [authorized_projects, 1] - [expire_build_instance_artifacts, 1] - [group_destroy, 1] - [irker, 1] diff --git a/db/fixtures/development/04_project.rb b/db/fixtures/development/04_project.rb index a984eda5ab5..18a2df7c059 100644 --- a/db/fixtures/development/04_project.rb +++ b/db/fixtures/development/04_project.rb @@ -1,4 +1,5 @@ require 'sidekiq/testing' +require './db/fixtures/support/serialized_transaction' Sidekiq::Testing.inline! do Gitlab::Seeder.quiet do diff --git a/db/fixtures/development/17_cycle_analytics.rb b/db/fixtures/development/17_cycle_analytics.rb index e882a492757..7b3908fae98 100644 --- a/db/fixtures/development/17_cycle_analytics.rb +++ b/db/fixtures/development/17_cycle_analytics.rb @@ -1,5 +1,6 @@ require 'sidekiq/testing' require './spec/support/test_env' +require './db/fixtures/support/serialized_transaction' class Gitlab::Seeder::CycleAnalytics def initialize(project, perf: false) @@ -203,6 +204,8 @@ class Gitlab::Seeder::CycleAnalytics pipeline.run! Timecop.travel rand(1..6).hours.from_now pipeline.succeed! + + PipelineMetricsWorker.new.perform(pipeline.id) end end diff --git a/db/fixtures/support/serialized_transaction.rb b/db/fixtures/support/serialized_transaction.rb new file mode 100644 index 00000000000..d3305b661e5 --- /dev/null +++ b/db/fixtures/support/serialized_transaction.rb @@ -0,0 +1,9 @@ +require 'gitlab/database' + +module Gitlab + module Database + def self.serialized_transaction + connection.transaction { yield } + end + end +end diff --git a/db/migrate/20161010142410_create_project_authorizations.rb b/db/migrate/20161010142410_create_project_authorizations.rb new file mode 100644 index 00000000000..e095ab969f8 --- /dev/null +++ b/db/migrate/20161010142410_create_project_authorizations.rb @@ -0,0 +1,15 @@ +class CreateProjectAuthorizations < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :project_authorizations do |t| + t.references :user, foreign_key: { on_delete: :cascade } + t.references :project, foreign_key: { on_delete: :cascade } + t.integer :access_level + + t.index [:user_id, :project_id, :access_level], unique: true, name: 'index_project_authorizations_on_user_id_project_id_access_level' + end + end +end diff --git a/db/migrate/20161017091941_add_authorized_projects_populated_to_users.rb b/db/migrate/20161017091941_add_authorized_projects_populated_to_users.rb new file mode 100644 index 00000000000..8f6be9dd677 --- /dev/null +++ b/db/migrate/20161017091941_add_authorized_projects_populated_to_users.rb @@ -0,0 +1,9 @@ +class AddAuthorizedProjectsPopulatedToUsers < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :users, :authorized_projects_populated, :boolean + end +end diff --git a/db/migrate/20161020083353_add_pipeline_id_to_merge_request_metrics.rb b/db/migrate/20161020083353_add_pipeline_id_to_merge_request_metrics.rb new file mode 100644 index 00000000000..f49df6802a7 --- /dev/null +++ b/db/migrate/20161020083353_add_pipeline_id_to_merge_request_metrics.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 AddPipelineIdToMergeRequestMetrics < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + disable_ddl_transaction! + + # Set this constant to true if this migration requires downtime. + DOWNTIME = true + + # 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 = 'Adding a foreign key' + + # 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 change + add_column :merge_request_metrics, :pipeline_id, :integer + add_concurrent_index :merge_request_metrics, :pipeline_id + add_foreign_key :merge_request_metrics, :ci_commits, column: :pipeline_id, on_delete: :cascade + end +end diff --git a/db/migrate/20161117114805_remove_undeleted_groups.rb b/db/migrate/20161117114805_remove_undeleted_groups.rb new file mode 100644 index 00000000000..ebc2d974ae0 --- /dev/null +++ b/db/migrate/20161117114805_remove_undeleted_groups.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 RemoveUndeletedGroups < ActiveRecord::Migration + DOWNTIME = false + + def up + execute "DELETE FROM namespaces WHERE deleted_at IS NOT NULL;" + end + + def down + # This is an irreversible migration; + # If someone is trying to rollback for other reasons, we should not throw an Exception. + # raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/migrate/20161118183841_add_commit_events_to_services.rb b/db/migrate/20161118183841_add_commit_events_to_services.rb new file mode 100644 index 00000000000..4f9b5dd2281 --- /dev/null +++ b/db/migrate/20161118183841_add_commit_events_to_services.rb @@ -0,0 +1,15 @@ +class AddCommitEventsToServices < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default(:services, :commit_events, :boolean, default: true, allow_null: false) + end + + def down + remove_column(:services, :commit_events) + end +end diff --git a/db/schema.rb b/db/schema.rb index 8f8a03e1534..6b28e80f01d 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: 20161113184239) do +ActiveRecord::Schema.define(version: 20161118183841) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -649,10 +649,12 @@ ActiveRecord::Schema.define(version: 20161113184239) do t.datetime "merged_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "pipeline_id" end add_index "merge_request_metrics", ["first_deployed_to_production_at"], name: "index_merge_request_metrics_on_first_deployed_to_production_at", using: :btree add_index "merge_request_metrics", ["merge_request_id"], name: "index_merge_request_metrics", using: :btree + add_index "merge_request_metrics", ["pipeline_id"], name: "index_merge_request_metrics_on_pipeline_id", using: :btree create_table "merge_requests", force: :cascade do |t| t.string "target_branch", null: false @@ -859,6 +861,14 @@ ActiveRecord::Schema.define(version: 20161113184239) do add_index "personal_access_tokens", ["token"], name: "index_personal_access_tokens_on_token", unique: true, using: :btree add_index "personal_access_tokens", ["user_id"], name: "index_personal_access_tokens_on_user_id", using: :btree + create_table "project_authorizations", force: :cascade do |t| + t.integer "user_id" + t.integer "project_id" + t.integer "access_level" + end + + add_index "project_authorizations", ["user_id", "project_id", "access_level"], name: "index_project_authorizations_on_user_id_project_id_access_level", unique: true, using: :btree + create_table "project_features", force: :cascade do |t| t.integer "project_id" t.integer "merge_requests_access_level" @@ -1021,6 +1031,7 @@ ActiveRecord::Schema.define(version: 20161113184239) do t.boolean "wiki_page_events", default: true t.boolean "pipeline_events", default: false, null: false t.boolean "confidential_issues_events", default: true, null: false + t.boolean "commit_events", default: true, null: false end add_index "services", ["project_id"], name: "index_services_on_project_id", using: :btree @@ -1203,6 +1214,7 @@ ActiveRecord::Schema.define(version: 20161113184239) do t.boolean "external", default: false t.string "organization" t.string "incoming_email_token" + t.boolean "authorized_projects_populated" end add_index "users", ["admin"], name: "index_users_on_admin", using: :btree @@ -1260,10 +1272,13 @@ ActiveRecord::Schema.define(version: 20161113184239) do add_foreign_key "labels", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "lists", "boards" add_foreign_key "lists", "labels" + add_foreign_key "merge_request_metrics", "ci_commits", column: "pipeline_id", on_delete: :cascade add_foreign_key "merge_request_metrics", "merge_requests", on_delete: :cascade add_foreign_key "merge_requests_closing_issues", "issues", on_delete: :cascade add_foreign_key "merge_requests_closing_issues", "merge_requests", on_delete: :cascade add_foreign_key "personal_access_tokens", "users" + add_foreign_key "project_authorizations", "projects", on_delete: :cascade + add_foreign_key "project_authorizations", "users", on_delete: :cascade add_foreign_key "protected_branch_merge_access_levels", "protected_branches" add_foreign_key "protected_branch_push_access_levels", "protected_branches" add_foreign_key "subscriptions", "projects", on_delete: :cascade diff --git a/doc/api/pipelines.md b/doc/api/pipelines.md index a29b3eb6f44..6455c333faf 100644 --- a/doc/api/pipelines.md +++ b/doc/api/pipelines.md @@ -114,6 +114,51 @@ Example of response } ``` +## Create a new pipeline + +> [Introduced][ce-7209] in GitLab 8.14 + +``` +POST /projects/:id/pipeline +``` + +| Attribute | Type | Required | Description | +|------------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a project | +| `ref` | string | yes | Reference to commit | + +``` +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/pipeline?ref=master" +``` + +Example of response + +```json +{ + "id": 61, + "sha": "384c444e840a515b23f21915ee5766b87068a70d", + "ref": "master", + "status": "pending", + "before_sha": "0000000000000000000000000000000000000000", + "tag": false, + "yaml_errors": null, + "user": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root" + }, + "created_at": "2016-11-04T09:36:13.747Z", + "updated_at": "2016-11-04T09:36:13.977Z", + "started_at": null, + "finished_at": null, + "committed_at": null, + "duration": null +} +``` + ## Retry failed builds in a pipeline > [Introduced][ce-5837] in GitLab 8.11 @@ -205,3 +250,4 @@ Response: ``` [ce-5837]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5837 +[ce-7209]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7209 diff --git a/doc/api/projects.md b/doc/api/projects.md index bbb3bfb4995..467a880ac13 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -850,7 +850,7 @@ POST /projects/:id/archive | `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME | ```bash -curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/archive" +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/archive" ``` Example response: @@ -939,7 +939,7 @@ POST /projects/:id/unarchive | `id` | integer/string | yes | The ID of the project or NAMESPACE/PROJECT_NAME | ```bash -curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/unarchive" +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/5/unarchive" ``` Example response: diff --git a/doc/api/users.md b/doc/api/users.md index 041df07c051..b38c335490a 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -369,24 +369,24 @@ Parameters: Get a list of a specified user's SSH keys. Available only for admin ``` -GET /users/:uid/keys +GET /users/:id/keys ``` Parameters: -- `uid` (required) - id of specified user +- `id` (required) - id of specified user ## Single SSH key Get a single key. ``` -GET /user/keys/:id +GET /user/keys/:key_id ``` Parameters: -- `id` (required) - The ID of an SSH key +- `key_id` (required) - The ID of an SSH key ```json { @@ -458,25 +458,25 @@ This is an idempotent function and calling it on a key that is already deleted or not available results in `200 OK`. ``` -DELETE /user/keys/:id +DELETE /user/keys/:key_id ``` Parameters: -- `id` (required) - SSH key ID +- `key_id` (required) - SSH key ID ## Delete SSH key for given user Deletes key owned by a specified user. Available only for admin. ``` -DELETE /users/:uid/keys/:id +DELETE /users/:id/keys/:key_id ``` Parameters: -- `uid` (required) - id of specified user -- `id` (required) - SSH key ID +- `id` (required) - id of specified user +- `key_id` (required) - SSH key ID Will return `200 OK` on success, or `404 Not found` if either user or key cannot be found. @@ -510,24 +510,24 @@ Parameters: Get a list of a specified user's emails. Available only for admin ``` -GET /users/:uid/emails +GET /users/:id/emails ``` Parameters: -- `uid` (required) - id of specified user +- `id` (required) - id of specified user ## Single email Get a single email. ``` -GET /user/emails/:id +GET /user/emails/:email_id ``` Parameters: -- `id` (required) - email ID +- `email_id` (required) - email ID ```json { @@ -590,25 +590,25 @@ This is an idempotent function and calling it on a email that is already deleted or not available results in `200 OK`. ``` -DELETE /user/emails/:id +DELETE /user/emails/:email_id ``` Parameters: -- `id` (required) - email ID +- `email_id` (required) - email ID ## Delete email for given user Deletes email owned by a specified user. Available only for admin. ``` -DELETE /users/:uid/emails/:id +DELETE /users/:id/emails/:email_id ``` Parameters: -- `uid` (required) - id of specified user -- `id` (required) - email ID +- `id` (required) - id of specified user +- `email_id` (required) - email ID Will return `200 OK` on success, or `404 Not found` if either user or email cannot be found. @@ -617,12 +617,12 @@ Will return `200 OK` on success, or `404 Not found` if either user or email cann Blocks the specified user. Available only for admin. ``` -PUT /users/:uid/block +PUT /users/:id/block ``` Parameters: -- `uid` (required) - id of specified user +- `id` (required) - id of specified user Will return `200 OK` on success, `404 User Not Found` is user cannot be found or `403 Forbidden` when trying to block an already blocked user by LDAP synchronization. @@ -632,12 +632,12 @@ Will return `200 OK` on success, `404 User Not Found` is user cannot be found or Unblocks the specified user. Available only for admin. ``` -PUT /users/:uid/unblock +PUT /users/:id/unblock ``` Parameters: -- `uid` (required) - id of specified user +- `id` (required) - id of specified user Will return `200 OK` on success, `404 User Not Found` is user cannot be found or `403 Forbidden` when trying to unblock a user blocked by LDAP synchronization. diff --git a/doc/ci/environments.md b/doc/ci/environments.md index 096c567c992..9dd84a5ff81 100644 --- a/doc/ci/environments.md +++ b/doc/ci/environments.md @@ -235,7 +235,7 @@ will help us achieve that. As the name suggests, it is possible to create environments on the fly by just declaring their names dynamically in `.gitlab-ci.yml`. Dynamic environments is -the basis of [Review apps](review_apps.md). +the basis of [Review apps](review_apps/index.md). GitLab Runner exposes various [environment variables][variables] when a job runs, and as such, you can use them as environment names. Let's add another job in diff --git a/doc/ci/examples/test-scala-application.md b/doc/ci/examples/test-scala-application.md index 7412fdbbc78..85f8849fa99 100644 --- a/doc/ci/examples/test-scala-application.md +++ b/doc/ci/examples/test-scala-application.md @@ -1,11 +1,11 @@ -## Test a Scala application +# Test and deploy to Heroku a Scala application This example demonstrates the integration of Gitlab CI with Scala applications using SBT. Checkout the example [project](https://gitlab.com/gitlab-examples/scala-sbt) and [build status](https://gitlab.com/gitlab-examples/scala-sbt/builds). -### Add `.gitlab-ci.yml` file to project +## Add `.gitlab-ci.yml` file to project The following `.gitlab-ci.yml` should be added in the root of your repository to trigger CI: @@ -13,10 +13,14 @@ repository to trigger CI: ``` yaml image: java:8 +stages: + - test + - deploy + before_script: - apt-get update -y - apt-get install apt-transport-https -y - # Install SBT + ## Install SBT - echo "deb http://dl.bintray.com/sbt/debian /" | tee -a /etc/apt/sources.list.d/sbt.list - apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 642AC823 - apt-get update -y @@ -24,8 +28,17 @@ before_script: - sbt sbt-version test: + stage: test script: - sbt clean coverage test coverageReport + +deploy: + stage: deploy + script: + - apt-get update -yq + - apt-get install rubygems ruby-dev -y + - gem install dpl + - dpl --provider=heroku --app=gitlab-play-sample-app --api-key=$HEROKU_API_KEY ``` The `before_script` installs [SBT](http://www.scala-sbt.org/) and @@ -33,15 +46,31 @@ displays the version that is being used. The `test` stage executes SBT to compile and test the project. [scoverage](https://github.com/scoverage/sbt-scoverage) is used as an SBT plugin to measure test coverage. +The `deploy` stage automatically deploys the project to Heroku using dpl. You can use other versions of Scala and SBT by defining them in `build.sbt`. -### Display test coverage in build +## Display test coverage in build Add the `Coverage was \[\d+.\d+\%\]` regular expression in the -**Settings > Edit Project > Test coverage parsing** project setting to -retrieve the test coverage rate from the build trace and have it +**Settings âž” Edit Project âž” Test coverage parsing** project setting to +retrieve the [test coverage] rate from the build trace and have it displayed with your builds. **Builds** must be enabled for this option to appear. + +## Heroku application + +A Heroku application is required. You can create one through the +[Dashboard](https://dashboard.heroku.com/). Substitute `gitlab-play-sample-app` +in the `.gitlab-ci.yml` file with your application's name. + +## Heroku API key + +You can look up your Heroku API key in your +[account](https://dashboard.heroku.com/account). Add a secure [variable] with +this value in **Project âž” Variables** with key `HEROKU_API_KEY`. + +[variable]: ../variables/README.md#user-defined-variables-secure-variables +[test coverage]: ../../user/project/pipelines/settings.md#test-coverage-report-badge diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 6fee750c709..338c9a27789 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -760,6 +760,15 @@ artifacts: - binaries/ ``` +To disable artifact passing, define the job with empty [dependencies](#dependencies): + +```yaml +job: + stage: build + script: make build + dependencies: [] +``` + You may want to create artifacts only for tagged releases to avoid filling the build server storage with temporary build artifacts. diff --git a/doc/development/testing.md b/doc/development/testing.md index 4dc535fb359..6106e47daa0 100644 --- a/doc/development/testing.md +++ b/doc/development/testing.md @@ -70,6 +70,7 @@ the command line via `bundle exec teaspoon`, or via a web browser at - Try to match the ordering of tests to the ordering within the class. - Try to follow the [Four-Phase Test][four-phase-test] pattern, using newlines to separate phases. +- Try to use `Gitlab.config.gitlab.host` rather than hard coding `'localhost'` [four-phase-test]: https://robots.thoughtbot.com/four-phase-test diff --git a/doc/workflow/gitlab_flow.md b/doc/workflow/gitlab_flow.md index 2215f37b81a..c228ea72f22 100644 --- a/doc/workflow/gitlab_flow.md +++ b/doc/workflow/gitlab_flow.md @@ -279,7 +279,7 @@ The trick is to use the merge/pull request with multiple commits when your work The commit message should reflect your intention, not the contents of the commit. The contents of the commit can be easily seen anyway, the question is why you did it. An example of a good commit message is: "Combine templates to dry up the user views.". -Some words that are bad commit messages because they don't contain munch information are: change, improve and refactor. +Some words that are bad commit messages because they don't contain much information are: change, improve and refactor. The word fix or fixes is also a red flag, unless it comes after the commit sentence and references an issue number. To see more information about the formatting of commit messages please see this great [blog post by Tim Pope](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). diff --git a/doc/workflow/todos.md b/doc/workflow/todos.md index a50ba305deb..54e7ae19ea5 100644 --- a/doc/workflow/todos.md +++ b/doc/workflow/todos.md @@ -24,9 +24,11 @@ you still have open. A Todo appears in your Todos dashboard when: -- an issue or merge request is assigned to you +- an issue or merge request is assigned to you, - you are `@mentioned` in an issue or merge request, be it the description of - the issue/merge request or in a comment + the issue/merge request or in a comment, +- build in the CI pipeline running for your merge request failed, but this + build is not allowed to fail. >**Note:** Commenting on a commit will _not_ trigger a Todo. diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 84cc9200d1b..2c593dbb4ea 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -85,8 +85,8 @@ module API end end - def project_service - @project_service ||= user_project.find_or_initialize_service(params[:service_slug].underscore) + def project_service(project = user_project) + @project_service ||= project.find_or_initialize_service(params[:service_slug].underscore) @project_service || not_found!("Service") end diff --git a/lib/api/pipelines.rb b/lib/api/pipelines.rb index 2a0c8e1f2c0..e69b0569612 100644 --- a/lib/api/pipelines.rb +++ b/lib/api/pipelines.rb @@ -22,6 +22,27 @@ module API pipelines = PipelinesFinder.new(user_project).execute(scope: params[:scope]) present paginate(pipelines), with: Entities::Pipeline end + + desc 'Create a new pipeline' do + detail 'This feature was introduced in GitLab 8.14' + success Entities::Pipeline + end + params do + requires :ref, type: String, desc: 'Reference' + end + post ':id/pipeline' do + authorize! :create_pipeline, user_project + + new_pipeline = Ci::CreatePipelineService.new(user_project, + current_user, + declared_params(include_missing: false)) + .execute(ignore_skip_ci: true, save_on_errors: false) + if new_pipeline.persisted? + present new_pipeline, with: Entities::Pipeline + else + render_validation_error!(new_pipeline) + end + end desc 'Gets a specific pipeline for the project' do detail 'This feature was introduced in GitLab 8.11' diff --git a/lib/api/services.rb b/lib/api/services.rb index fc8598daa32..4d23499aa39 100644 --- a/lib/api/services.rb +++ b/lib/api/services.rb @@ -1,10 +1,10 @@ module API # Projects API class Services < Grape::API - before { authenticate! } - before { authorize_admin_project } - resource :projects do + before { authenticate! } + before { authorize_admin_project } + # Set service for project # # Example Request: @@ -59,5 +59,28 @@ module API present project_service, with: Entities::ProjectService, include_passwords: current_user.is_admin? end end + + resource :projects do + desc 'Trigger a slash command' do + detail 'Added in GitLab 8.13' + end + post ':id/services/:service_slug/trigger' do + project = Project.find_with_namespace(params[:id]) || Project.find_by(id: params[:id]) + + # This is not accurate, but done to prevent leakage of the project names + not_found!('Service') unless project + + service = project_service(project) + + result = service.try(:active?) && service.try(:trigger, params) + + if result + status result[:status] || 200 + present result + else + not_found!('Service') + end + end + end end end diff --git a/lib/api/users.rb b/lib/api/users.rb index aea328d2f8f..c07539194ed 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -4,89 +4,93 @@ module API before { authenticate! } resource :users, requirements: { uid: /[0-9]*/, id: /[0-9]*/ } do - # Get a users list - # - # Example Request: - # GET /users - # GET /users?search=Admin - # GET /users?username=root - # GET /users?active=true - # GET /users?external=true - # GET /users?blocked=true + helpers do + params :optional_attributes do + optional :skype, type: String, desc: 'The Skype username' + optional :linkedin, type: String, desc: 'The LinkedIn username' + optional :twitter, type: String, desc: 'The Twitter username' + optional :website_url, type: String, desc: 'The website of the user' + optional :organization, type: String, desc: 'The organization of the user' + optional :projects_limit, type: Integer, desc: 'The number of projects a user can create' + optional :extern_uid, type: Integer, desc: 'The external authentication provider UID' + optional :provider, type: String, desc: 'The external provider' + optional :bio, type: String, desc: 'The biography of the user' + optional :location, type: String, desc: 'The location of the user' + optional :admin, type: Boolean, desc: 'Flag indicating the user is an administrator' + optional :can_create_group, type: Boolean, desc: 'Flag indicating the user can create groups' + optional :confirm, type: Boolean, desc: 'Flag indicating the account needs to be confirmed' + optional :external, type: Boolean, desc: 'Flag indicating the user is an external user' + all_or_none_of :extern_uid, :provider + end + end + + desc 'Get the list of users' do + success Entities::UserBasic + end + params do + optional :username, type: String, desc: 'Get a single user with a specific username' + optional :search, type: String, desc: 'Search for a username' + optional :active, type: Boolean, default: false, desc: 'Filters only active users' + optional :external, type: Boolean, default: false, desc: 'Filters only external users' + optional :blocked, type: Boolean, default: false, desc: 'Filters only blocked users' + end get do unless can?(current_user, :read_users_list, nil) render_api_error!("Not authorized.", 403) end if params[:username].present? - @users = User.where(username: params[:username]) + users = User.where(username: params[:username]) else - @users = User.all - @users = @users.active if to_boolean(params[:active]) - @users = @users.search(params[:search]) if params[:search].present? - @users = @users.blocked if to_boolean(params[:blocked]) - @users = @users.external if to_boolean(params[:external]) && current_user.is_admin? - @users = paginate @users + users = User.all + users = users.active if params[:active] + users = users.search(params[:search]) if params[:search].present? + users = users.blocked if params[:blocked] + users = users.external if params[:external] && current_user.is_admin? end - if current_user.is_admin? - present @users, with: Entities::UserFull - else - present @users, with: Entities::UserBasic - end + entity = current_user.is_admin? ? Entities::UserFull : Entities::UserBasic + present paginate(users), with: entity end - # Get a single user - # - # Parameters: - # id (required) - The ID of a user - # Example Request: - # GET /users/:id + desc 'Get a single user' do + success Entities::UserBasic + end + params do + requires :id, type: Integer, desc: 'The ID of the user' + end get ":id" do - @user = User.find(params[:id]) + user = User.find_by(id: params[:id]) + not_found!('User') unless user if current_user && current_user.is_admin? - present @user, with: Entities::UserFull - elsif can?(current_user, :read_user, @user) - present @user, with: Entities::User + present user, with: Entities::UserFull + elsif can?(current_user, :read_user, user) + present user, with: Entities::User else render_api_error!("User not found.", 404) end end - # Create user. Available only for admin - # - # Parameters: - # email (required) - Email - # password (required) - Password - # name (required) - Name - # username (required) - Name - # skype - Skype ID - # linkedin - Linkedin - # twitter - Twitter account - # website_url - Website url - # organization - Organization - # projects_limit - Number of projects user can create - # extern_uid - External authentication provider UID - # provider - External provider - # bio - Bio - # location - Location of the user - # admin - User is admin - true or false (default) - # can_create_group - User can create groups - true or false - # confirm - Require user confirmation - true (default) or false - # external - Flags the user as external - true or false(default) - # Example Request: - # POST /users + desc 'Create a user. Available only for admins.' do + success Entities::UserFull + end + params do + requires :email, type: String, desc: 'The email of the user' + requires :password, type: String, desc: 'The password of the new user' + requires :name, type: String, desc: 'The name of the user' + requires :username, type: String, desc: 'The username of the user' + use :optional_attributes + end post do authenticated_as_admin! - required_attributes! [:email, :password, :name, :username] - attrs = attributes_for_keys [:email, :name, :password, :skype, :linkedin, :twitter, :projects_limit, :username, :bio, :location, :can_create_group, :admin, :confirm, :external, :organization] - admin = attrs.delete(:admin) - confirm = !(attrs.delete(:confirm) =~ /(false|f|no|0)$/i) - user = User.build_user(attrs) - user.admin = admin unless admin.nil? + + # Filter out params which are used later + identity_attrs = params.slice(:provider, :extern_uid) + confirm = params.delete(:confirm) + + user = User.build_user(declared_params(include_missing: false)) user.skip_confirmation! unless confirm - identity_attrs = attributes_for_keys [:provider, :extern_uid] if identity_attrs.any? user.identities.build(identity_attrs) @@ -107,46 +111,40 @@ module API end end - # Update user. Available only for admin - # - # Parameters: - # email - Email - # name - Name - # password - Password - # skype - Skype ID - # linkedin - Linkedin - # twitter - Twitter account - # website_url - Website url - # organization - Organization - # projects_limit - Limit projects each user can create - # bio - Bio - # location - Location of the user - # admin - User is admin - true or false (default) - # can_create_group - User can create groups - true or false - # external - Flags the user as external - true or false(default) - # Example Request: - # PUT /users/:id + desc 'Update a user. Available only for admins.' do + success Entities::UserFull + end + params do + requires :id, type: Integer, desc: 'The ID of the user' + optional :email, type: String, desc: 'The email of the user' + optional :password, type: String, desc: 'The password of the new user' + optional :name, type: String, desc: 'The name of the user' + optional :username, type: String, desc: 'The username of the user' + use :optional_attributes + at_least_one_of :email, :password, :name, :username, :skype, :linkedin, + :twitter, :website_url, :organization, :projects_limit, + :extern_uid, :provider, :bio, :location, :admin, + :can_create_group, :confirm, :external + end put ":id" do authenticated_as_admin! - attrs = attributes_for_keys [:email, :name, :password, :skype, :linkedin, :twitter, :website_url, :projects_limit, :username, :bio, :location, :can_create_group, :admin, :external, :organization] - user = User.find(params[:id]) + user = User.find_by(id: params.delete(:id)) not_found!('User') unless user - admin = attrs.delete(:admin) - user.admin = admin unless admin.nil? - - conflict!('Email has already been taken') if attrs[:email] && - User.where(email: attrs[:email]). + conflict!('Email has already been taken') if params[:email] && + User.where(email: params[:email]). where.not(id: user.id).count > 0 - conflict!('Username has already been taken') if attrs[:username] && - User.where(username: attrs[:username]). + conflict!('Username has already been taken') if params[:username] && + User.where(username: params[:username]). where.not(id: user.id).count > 0 - identity_attrs = attributes_for_keys [:provider, :extern_uid] + identity_attrs = params.slice(:provider, :extern_uid) + if identity_attrs.any? identity = user.identities.find_by(provider: identity_attrs[:provider]) + if identity identity.update_attributes(identity_attrs) else @@ -155,28 +153,33 @@ module API end end - if user.update_attributes(attrs) + # Delete already handled parameters + params.delete(:extern_uid) + params.delete(:provider) + + if user.update_attributes(declared_params(include_missing: false)) present user, with: Entities::UserFull else render_validation_error!(user) end end - # Add ssh key to a specified user. Only available to admin users. - # - # Parameters: - # id (required) - The ID of a user - # key (required) - New SSH Key - # title (required) - New SSH Key's title - # Example Request: - # POST /users/:id/keys + desc 'Add an SSH key to a specified user. Available only for admins.' do + success Entities::SSHKey + end + params do + requires :id, type: Integer, desc: 'The ID of the user' + requires :key, type: String, desc: 'The new SSH key' + requires :title, type: String, desc: 'The title of the new SSH key' + end post ":id/keys" do authenticated_as_admin! - required_attributes! [:title, :key] - user = User.find(params[:id]) - attrs = attributes_for_keys [:title, :key] - key = user.keys.new attrs + user = User.find_by(id: params.delete(:id)) + not_found!('User') unless user + + key = user.keys.new(declared_params(include_missing: false)) + if key.save present key, with: Entities::SSHKey else @@ -184,55 +187,55 @@ module API end end - # Get ssh keys of a specified user. Only available to admin users. - # - # Parameters: - # uid (required) - The ID of a user - # Example Request: - # GET /users/:uid/keys - get ':uid/keys' do + desc 'Get the SSH keys of a specified user. Available only for admins.' do + success Entities::SSHKey + end + params do + requires :id, type: Integer, desc: 'The ID of the user' + end + get ':id/keys' do authenticated_as_admin! - user = User.find_by(id: params[:uid]) + + user = User.find_by(id: params[:id]) not_found!('User') unless user present user.keys, with: Entities::SSHKey end - # Delete existing ssh key of a specified user. Only available to admin - # users. - # - # Parameters: - # uid (required) - The ID of a user - # id (required) - SSH Key ID - # Example Request: - # DELETE /users/:uid/keys/:id - delete ':uid/keys/:id' do + desc 'Delete an existing SSH key from a specified user. Available only for admins.' do + success Entities::SSHKey + end + params do + requires :id, type: Integer, desc: 'The ID of the user' + requires :key_id, type: Integer, desc: 'The ID of the SSH key' + end + delete ':id/keys/:key_id' do authenticated_as_admin! - user = User.find_by(id: params[:uid]) + + user = User.find_by(id: params[:id]) not_found!('User') unless user - begin - key = user.keys.find params[:id] - key.destroy - rescue ActiveRecord::RecordNotFound - not_found!('Key') - end + key = user.keys.find_by(id: params[:key_id]) + not_found!('Key') unless key + + present key.destroy, with: Entities::SSHKey end - # Add email to a specified user. Only available to admin users. - # - # Parameters: - # id (required) - The ID of a user - # email (required) - Email address - # Example Request: - # POST /users/:id/emails + desc 'Add an email address to a specified user. Available only for admins.' do + success Entities::Email + end + params do + requires :id, type: Integer, desc: 'The ID of the user' + requires :email, type: String, desc: 'The email of the user' + end post ":id/emails" do authenticated_as_admin! - required_attributes! [:email] - user = User.find(params[:id]) - attrs = attributes_for_keys [:email] - email = user.emails.new attrs + user = User.find_by(id: params.delete(:id)) + not_found!('User') unless user + + email = user.emails.new(declared_params(include_missing: false)) + if email.save NotificationService.new.new_email(email) present email, with: Entities::Email @@ -241,98 +244,91 @@ module API end end - # Get emails of a specified user. Only available to admin users. - # - # Parameters: - # uid (required) - The ID of a user - # Example Request: - # GET /users/:uid/emails - get ':uid/emails' do + desc 'Get the emails addresses of a specified user. Available only for admins.' do + success Entities::Email + end + params do + requires :id, type: Integer, desc: 'The ID of the user' + end + get ':id/emails' do authenticated_as_admin! - user = User.find_by(id: params[:uid]) + user = User.find_by(id: params[:id]) not_found!('User') unless user present user.emails, with: Entities::Email end - # Delete existing email of a specified user. Only available to admin - # users. - # - # Parameters: - # uid (required) - The ID of a user - # id (required) - Email ID - # Example Request: - # DELETE /users/:uid/emails/:id - delete ':uid/emails/:id' do + desc 'Delete an email address of a specified user. Available only for admins.' do + success Entities::Email + end + params do + requires :id, type: Integer, desc: 'The ID of the user' + requires :email_id, type: Integer, desc: 'The ID of the email' + end + delete ':id/emails/:email_id' do authenticated_as_admin! - user = User.find_by(id: params[:uid]) + user = User.find_by(id: params[:id]) not_found!('User') unless user - begin - email = user.emails.find params[:id] - email.destroy + email = user.emails.find_by(id: params[:email_id]) + not_found!('Email') unless email - user.update_secondary_emails! - rescue ActiveRecord::RecordNotFound - not_found!('Email') - end + email.destroy + user.update_secondary_emails! end - # Delete user. Available only for admin - # - # Example Request: - # DELETE /users/:id + desc 'Delete a user. Available only for admins.' do + success Entities::Email + end + params do + requires :id, type: Integer, desc: 'The ID of the user' + end delete ":id" do authenticated_as_admin! user = User.find_by(id: params[:id]) + not_found!('User') unless user - if user - DeleteUserService.new(current_user).execute(user) - else - not_found!('User') - end + DeleteUserService.new(current_user).execute(user) end - # Block user. Available only for admin - # - # Example Request: - # PUT /users/:id/block + desc 'Block a user. Available only for admins.' + params do + requires :id, type: Integer, desc: 'The ID of the user' + end put ':id/block' do authenticated_as_admin! user = User.find_by(id: params[:id]) + not_found!('User') unless user - if !user - not_found!('User') - elsif !user.ldap_blocked? + if !user.ldap_blocked? user.block else forbidden!('LDAP blocked users cannot be modified by the API') end end - # Unblock user. Available only for admin - # - # Example Request: - # PUT /users/:id/unblock + desc 'Unblock a user. Available only for admins.' + params do + requires :id, type: Integer, desc: 'The ID of the user' + end put ':id/unblock' do authenticated_as_admin! user = User.find_by(id: params[:id]) + not_found!('User') unless user - if !user - not_found!('User') - elsif user.ldap_blocked? + if user.ldap_blocked? forbidden!('LDAP blocked users cannot be unblocked by the API') else user.activate end end - desc 'Get contribution events of a specified user' do + desc 'Get the contribution events of a specified user' do detail 'This feature was introduced in GitLab 8.13.' success Entities::Event end params do - requires :id, type: String, desc: 'The user ID' + requires :id, type: Integer, desc: 'The ID of the user' end get ':id/events' do user = User.find_by(id: params[:id]) @@ -349,43 +345,43 @@ module API end resource :user do - # Get currently authenticated user - # - # Example Request: - # GET /user + desc 'Get the currently authenticated user' do + success Entities::UserFull + end get do - present @current_user, with: Entities::UserFull + present current_user, with: Entities::UserFull end - # Get currently authenticated user's keys - # - # Example Request: - # GET /user/keys + desc "Get the currently authenticated user's SSH keys" do + success Entities::SSHKey + end get "keys" do present current_user.keys, with: Entities::SSHKey end - # Get single key owned by currently authenticated user - # - # Example Request: - # GET /user/keys/:id - get "keys/:id" do - key = current_user.keys.find params[:id] + desc 'Get a single key owned by currently authenticated user' do + success Entities::SSHKey + end + params do + requires :key_id, type: Integer, desc: 'The ID of the SSH key' + end + get "keys/:key_id" do + key = current_user.keys.find_by(id: params[:key_id]) + not_found!('Key') unless key + present key, with: Entities::SSHKey end - # Add new ssh key to currently authenticated user - # - # Parameters: - # key (required) - New SSH Key - # title (required) - New SSH Key's title - # Example Request: - # POST /user/keys + desc 'Add a new SSH key to the currently authenticated user' do + success Entities::SSHKey + end + params do + requires :key, type: String, desc: 'The new SSH key' + requires :title, type: String, desc: 'The title of the new SSH key' + end post "keys" do - required_attributes! [:title, :key] + key = current_user.keys.new(declared_params) - attrs = attributes_for_keys [:title, :key] - key = current_user.keys.new attrs if key.save present key, with: Entities::SSHKey else @@ -393,48 +389,48 @@ module API end end - # Delete existing ssh key of currently authenticated user - # - # Parameters: - # id (required) - SSH Key ID - # Example Request: - # DELETE /user/keys/:id - delete "keys/:id" do - begin - key = current_user.keys.find params[:id] - key.destroy - rescue - end + desc 'Delete an SSH key from the currently authenticated user' do + success Entities::SSHKey + end + params do + requires :key_id, type: Integer, desc: 'The ID of the SSH key' + end + delete "keys/:key_id" do + key = current_user.keys.find_by(id: params[:key_id]) + not_found!('Key') unless key + + present key.destroy, with: Entities::SSHKey end - # Get currently authenticated user's emails - # - # Example Request: - # GET /user/emails + desc "Get the currently authenticated user's email addresses" do + success Entities::Email + end get "emails" do present current_user.emails, with: Entities::Email end - # Get single email owned by currently authenticated user - # - # Example Request: - # GET /user/emails/:id - get "emails/:id" do - email = current_user.emails.find params[:id] + desc 'Get a single email address owned by the currently authenticated user' do + success Entities::Email + end + params do + requires :email_id, type: Integer, desc: 'The ID of the email' + end + get "emails/:email_id" do + email = current_user.emails.find_by(id: params[:email_id]) + not_found!('Email') unless email + present email, with: Entities::Email end - # Add new email to currently authenticated user - # - # Parameters: - # email (required) - Email address - # Example Request: - # POST /user/emails + desc 'Add new email address to the currently authenticated user' do + success Entities::Email + end + params do + requires :email, type: String, desc: 'The new email' + end post "emails" do - required_attributes! [:email] + email = current_user.emails.new(declared_params) - attrs = attributes_for_keys [:email] - email = current_user.emails.new attrs if email.save NotificationService.new.new_email(email) present email, with: Entities::Email @@ -443,20 +439,16 @@ module API end end - # Delete existing email of currently authenticated user - # - # Parameters: - # id (required) - EMail ID - # Example Request: - # DELETE /user/emails/:id - delete "emails/:id" do - begin - email = current_user.emails.find params[:id] - email.destroy + desc 'Delete an email address from the currently authenticated user' + params do + requires :email_id, type: Integer, desc: 'The ID of the email' + end + delete "emails/:email_id" do + email = current_user.emails.find_by(id: params[:email_id]) + not_found!('Email') unless email - current_user.update_secondary_emails! - rescue - end + email.destroy + current_user.update_secondary_emails! end end end diff --git a/lib/gitlab/chat_commands/base_command.rb b/lib/gitlab/chat_commands/base_command.rb new file mode 100644 index 00000000000..e59d69b72b9 --- /dev/null +++ b/lib/gitlab/chat_commands/base_command.rb @@ -0,0 +1,49 @@ +module Gitlab + module ChatCommands + class BaseCommand + QUERY_LIMIT = 5 + + def self.match(_text) + raise NotImplementedError + end + + def self.help_message + raise NotImplementedError + end + + def self.available?(_project) + raise NotImplementedError + end + + def self.allowed?(_user, _ability) + true + end + + def self.can?(object, action, subject) + Ability.allowed?(object, action, subject) + end + + def execute(_) + raise NotImplementedError + end + + def collection + raise NotImplementedError + end + + attr_accessor :project, :current_user, :params + + def initialize(project, user, params = {}) + @project, @current_user, @params = project, user, params.dup + end + + private + + def find_by_iid(iid) + resource = collection.find_by(iid: iid) + + readable?(resource) ? resource : nil + end + end + end +end diff --git a/lib/gitlab/chat_commands/command.rb b/lib/gitlab/chat_commands/command.rb new file mode 100644 index 00000000000..5f131703d40 --- /dev/null +++ b/lib/gitlab/chat_commands/command.rb @@ -0,0 +1,61 @@ +module Gitlab + module ChatCommands + class Command < BaseCommand + COMMANDS = [ + Gitlab::ChatCommands::IssueShow, + Gitlab::ChatCommands::IssueCreate, + ].freeze + + def execute + command, match = match_command + + if command + if command.allowed?(project, current_user) + present command.new(project, current_user, params).execute(match) + else + access_denied + end + else + help(help_messages) + end + end + + private + + def match_command + match = nil + service = available_commands.find do |klass| + match = klass.match(command) + end + + [service, match] + end + + def help_messages + available_commands.map(&:help_message) + end + + def available_commands + COMMANDS.select do |klass| + klass.available?(project) + end + end + + def command + params[:text] + end + + def help(messages) + Mattermost::Presenter.help(messages, params[:command]) + end + + def access_denied + Mattermost::Presenter.access_denied + end + + def present(resource) + Mattermost::Presenter.present(resource) + end + end + end +end diff --git a/lib/gitlab/chat_commands/issue_command.rb b/lib/gitlab/chat_commands/issue_command.rb new file mode 100644 index 00000000000..f1bc36239d5 --- /dev/null +++ b/lib/gitlab/chat_commands/issue_command.rb @@ -0,0 +1,17 @@ +module Gitlab + module ChatCommands + class IssueCommand < BaseCommand + def self.available?(project) + project.issues_enabled? && project.default_issues_tracker? + end + + def collection + project.issues + end + + def readable?(issue) + self.class.can?(current_user, :read_issue, issue) + end + end + end +end diff --git a/lib/gitlab/chat_commands/issue_create.rb b/lib/gitlab/chat_commands/issue_create.rb new file mode 100644 index 00000000000..98338ebfa27 --- /dev/null +++ b/lib/gitlab/chat_commands/issue_create.rb @@ -0,0 +1,24 @@ +module Gitlab + module ChatCommands + class IssueCreate < IssueCommand + def self.match(text) + /\Aissue\s+create\s+(?[^\n]*)\n*(?<description>.*)\z/.match(text) + end + + def self.help_message + 'issue create <title>\n<description>' + end + + def self.allowed?(project, user) + can?(user, :create_issue, project) + end + + def execute(match) + title = match[:title] + description = match[:description] + + Issues::CreateService.new(project, current_user, title: title, description: description).execute + end + end + end +end diff --git a/lib/gitlab/chat_commands/issue_show.rb b/lib/gitlab/chat_commands/issue_show.rb new file mode 100644 index 00000000000..f5bceb038e5 --- /dev/null +++ b/lib/gitlab/chat_commands/issue_show.rb @@ -0,0 +1,17 @@ +module Gitlab + module ChatCommands + class IssueShow < IssueCommand + def self.match(text) + /\Aissue\s+show\s+(?<iid>\d+)/.match(text) + end + + def self.help_message + "issue show <id>" + end + + def execute(match) + find_by_iid(match[:iid]) + end + end + end +end diff --git a/lib/gitlab/ci/config.rb b/lib/gitlab/ci/config.rb index 06599238d22..f7ff7ea212e 100644 --- a/lib/gitlab/ci/config.rb +++ b/lib/gitlab/ci/config.rb @@ -4,12 +4,6 @@ module Gitlab # Base GitLab CI Configuration facade # class Config - ## - # Temporary delegations that should be removed after refactoring - # - delegate :before_script, :image, :services, :after_script, :variables, - :stages, :cache, :jobs, to: :@global - def initialize(config) @config = Loader.new(config).load! @@ -28,6 +22,41 @@ module Gitlab def to_hash @config end + + ## + # Temporary method that should be removed after refactoring + # + def before_script + @global.before_script_value + end + + def image + @global.image_value + end + + def services + @global.services_value + end + + def after_script + @global.after_script_value + end + + def variables + @global.variables_value + end + + def stages + @global.stages_value + end + + def cache + @global.cache_value + end + + def jobs + @global.jobs_value + end end end end diff --git a/lib/gitlab/ci/config/entry/configurable.rb b/lib/gitlab/ci/config/entry/configurable.rb index 0f438faeda2..833ae4a0ff3 100644 --- a/lib/gitlab/ci/config/entry/configurable.rb +++ b/lib/gitlab/ci/config/entry/configurable.rb @@ -66,8 +66,6 @@ module Gitlab @entries[symbol].value end - - alias_method symbol.to_sym, "#{symbol}_value".to_sym end end end diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb index ab4ef333629..20dcc024b4e 100644 --- a/lib/gitlab/ci/config/entry/job.rb +++ b/lib/gitlab/ci/config/entry/job.rb @@ -13,12 +13,10 @@ module Gitlab type stage when artifacts cache dependencies before_script after_script variables environment] - attributes :tags, :allow_failure, :when, :dependencies - validations do validates :config, allowed_keys: ALLOWED_KEYS - validates :config, presence: true + validates :script, presence: true validates :name, presence: true validates :name, type: Symbol @@ -77,6 +75,8 @@ module Gitlab :cache, :image, :services, :only, :except, :variables, :artifacts, :commands, :environment + attributes :script, :tags, :allow_failure, :when, :dependencies + def compose!(deps = nil) super do if type_defined? && !stage_defined? @@ -118,20 +118,20 @@ module Gitlab def to_hash { name: name, - before_script: before_script, - script: script, + before_script: before_script_value, + script: script_value, commands: commands, - image: image, - services: services, - stage: stage, - cache: cache, - only: only, - except: except, - variables: variables_defined? ? variables : nil, - environment: environment_defined? ? environment : nil, - environment_name: environment_defined? ? environment[:name] : nil, - artifacts: artifacts, - after_script: after_script } + image: image_value, + services: services_value, + stage: stage_value, + cache: cache_value, + only: only_value, + except: except_value, + variables: variables_defined? ? variables_value : nil, + environment: environment_defined? ? environment_value : nil, + environment_name: environment_defined? ? environment_value[:name] : nil, + artifacts: artifacts_value, + after_script: after_script_value } end end end diff --git a/lib/gitlab/cycle_analytics/base_event.rb b/lib/gitlab/cycle_analytics/base_event.rb new file mode 100644 index 00000000000..486139b1687 --- /dev/null +++ b/lib/gitlab/cycle_analytics/base_event.rb @@ -0,0 +1,57 @@ +module Gitlab + module CycleAnalytics + class BaseEvent + include MetricsTables + + attr_reader :stage, :start_time_attrs, :end_time_attrs, :projections, :query + + def initialize(project:, options:) + @query = EventsQuery.new(project: project, options: options) + @project = project + @options = options + end + + def fetch + update_author! + + event_result.map do |event| + serialize(event) if has_permission?(event['id']) + end + end + + def custom_query(_base_query); end + + def order + @order || @start_time_attrs + end + + private + + def update_author! + return unless event_result.any? && event_result.first['author_id'] + + Updater.update!(event_result, from: 'author_id', to: 'author', klass: User) + end + + def event_result + @event_result ||= @query.execute(self).to_a + end + + def serialize(_event) + raise NotImplementedError.new("Expected #{self.name} to implement serialize(event)") + end + + def has_permission?(id) + allowed_ids.nil? || allowed_ids.include?(id.to_i) + end + + def allowed_ids + nil + end + + def event_result_ids + event_result.map { |event| event['id'] } + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/code_event.rb b/lib/gitlab/cycle_analytics/code_event.rb new file mode 100644 index 00000000000..2afdf0b8518 --- /dev/null +++ b/lib/gitlab/cycle_analytics/code_event.rb @@ -0,0 +1,28 @@ +module Gitlab + module CycleAnalytics + class CodeEvent < BaseEvent + include MergeRequestAllowed + + def initialize(*args) + @stage = :code + @start_time_attrs = issue_metrics_table[:first_mentioned_in_commit_at] + @end_time_attrs = mr_table[:created_at] + @projections = [mr_table[:title], + mr_table[:iid], + mr_table[:id], + mr_table[:created_at], + mr_table[:state], + mr_table[:author_id]] + @order = mr_table[:created_at] + + super(*args) + end + + private + + def serialize(event) + AnalyticsMergeRequestSerializer.new(project: @project).represent(event).as_json + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/events.rb b/lib/gitlab/cycle_analytics/events.rb new file mode 100644 index 00000000000..2d703d76cbb --- /dev/null +++ b/lib/gitlab/cycle_analytics/events.rb @@ -0,0 +1,38 @@ +module Gitlab + module CycleAnalytics + class Events + def initialize(project:, options:) + @project = project + @options = options + end + + def issue_events + IssueEvent.new(project: @project, options: @options).fetch + end + + def plan_events + PlanEvent.new(project: @project, options: @options).fetch + end + + def code_events + CodeEvent.new(project: @project, options: @options).fetch + end + + def test_events + TestEvent.new(project: @project, options: @options).fetch + end + + def review_events + ReviewEvent.new(project: @project, options: @options).fetch + end + + def staging_events + StagingEvent.new(project: @project, options: @options).fetch + end + + def production_events + ProductionEvent.new(project: @project, options: @options).fetch + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/events_query.rb b/lib/gitlab/cycle_analytics/events_query.rb new file mode 100644 index 00000000000..2418832ccc2 --- /dev/null +++ b/lib/gitlab/cycle_analytics/events_query.rb @@ -0,0 +1,37 @@ +module Gitlab + module CycleAnalytics + class EventsQuery + attr_reader :project + + def initialize(project:, options: {}) + @project = project + @from = options[:from] + @branch = options[:branch] + @fetcher = Gitlab::CycleAnalytics::MetricsFetcher.new(project: project, from: @from, branch: @branch) + end + + def execute(stage_class) + @stage_class = stage_class + + ActiveRecord::Base.connection.exec_query(query.to_sql) + end + + private + + def query + base_query = @fetcher.base_query_for(@stage_class.stage) + diff_fn = @fetcher.subtract_datetimes_diff(base_query, @stage_class.start_time_attrs, @stage_class.end_time_attrs) + + @stage_class.custom_query(base_query) + + base_query.project(extract_epoch(diff_fn).as('total_time'), *@stage_class.projections).order(@stage_class.order.desc) + end + + def extract_epoch(arel_attribute) + return arel_attribute unless Gitlab::Database.postgresql? + + Arel.sql(%Q{EXTRACT(EPOCH FROM (#{arel_attribute.to_sql}))}) + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/issue_allowed.rb b/lib/gitlab/cycle_analytics/issue_allowed.rb new file mode 100644 index 00000000000..a7652a70641 --- /dev/null +++ b/lib/gitlab/cycle_analytics/issue_allowed.rb @@ -0,0 +1,9 @@ +module Gitlab + module CycleAnalytics + module IssueAllowed + def allowed_ids + @allowed_ids ||= IssuesFinder.new(@options[:current_user], project_id: @project.id).execute.where(id: event_result_ids).pluck(:id) + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/issue_event.rb b/lib/gitlab/cycle_analytics/issue_event.rb new file mode 100644 index 00000000000..705b7e5ce24 --- /dev/null +++ b/lib/gitlab/cycle_analytics/issue_event.rb @@ -0,0 +1,27 @@ +module Gitlab + module CycleAnalytics + class IssueEvent < BaseEvent + include IssueAllowed + + def initialize(*args) + @stage = :issue + @start_time_attrs = issue_table[:created_at] + @end_time_attrs = [issue_metrics_table[:first_associated_with_milestone_at], + issue_metrics_table[:first_added_to_board_at]] + @projections = [issue_table[:title], + issue_table[:iid], + issue_table[:id], + issue_table[:created_at], + issue_table[:author_id]] + + super(*args) + end + + private + + def serialize(event) + AnalyticsIssueSerializer.new(project: @project).represent(event).as_json + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/merge_request_allowed.rb b/lib/gitlab/cycle_analytics/merge_request_allowed.rb new file mode 100644 index 00000000000..28f6db44759 --- /dev/null +++ b/lib/gitlab/cycle_analytics/merge_request_allowed.rb @@ -0,0 +1,9 @@ +module Gitlab + module CycleAnalytics + module MergeRequestAllowed + def allowed_ids + @allowed_ids ||= MergeRequestsFinder.new(@options[:current_user], project_id: @project.id).execute.where(id: event_result_ids).pluck(:id) + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/metrics_fetcher.rb b/lib/gitlab/cycle_analytics/metrics_fetcher.rb new file mode 100644 index 00000000000..b71e8735e27 --- /dev/null +++ b/lib/gitlab/cycle_analytics/metrics_fetcher.rb @@ -0,0 +1,60 @@ +module Gitlab + module CycleAnalytics + class MetricsFetcher + include Gitlab::Database::Median + include Gitlab::Database::DateTime + include MetricsTables + + DEPLOYMENT_METRIC_STAGES = %i[production staging] + + def initialize(project:, from:, branch:) + @project = project + @project = project + @from = from + @branch = branch + end + + def calculate_metric(name, start_time_attrs, end_time_attrs) + cte_table = Arel::Table.new("cte_table_for_#{name}") + + # Build a `SELECT` query. We find the first of the `end_time_attrs` that isn't `NULL` (call this end_time). + # Next, we find the first of the start_time_attrs that isn't `NULL` (call this start_time). + # We compute the (end_time - start_time) interval, and give it an alias based on the current + # cycle analytics stage. + interval_query = Arel::Nodes::As.new( + cte_table, + subtract_datetimes(base_query_for(name), start_time_attrs, end_time_attrs, name.to_s)) + + median_datetime(cte_table, interval_query, name) + end + + # Join table with a row for every <issue,merge_request> pair (where the merge request + # closes the given issue) with issue and merge request metrics included. The metrics + # are loaded with an inner join, so issues / merge requests without metrics are + # automatically excluded. + def base_query_for(name) + # Load issues + query = mr_closing_issues_table.join(issue_table).on(issue_table[:id].eq(mr_closing_issues_table[:issue_id])). + join(issue_metrics_table).on(issue_table[:id].eq(issue_metrics_table[:issue_id])). + where(issue_table[:project_id].eq(@project.id)). + where(issue_table[:deleted_at].eq(nil)). + where(issue_table[:created_at].gteq(@from)) + + query = query.where(build_table[:ref].eq(@branch)) if name == :test && @branch + + # Load merge_requests + query = query.join(mr_table, Arel::Nodes::OuterJoin). + on(mr_table[:id].eq(mr_closing_issues_table[:merge_request_id])). + join(mr_metrics_table). + on(mr_table[:id].eq(mr_metrics_table[:merge_request_id])) + + if DEPLOYMENT_METRIC_STAGES.include?(name) + # Limit to merge requests that have been deployed to production after `@from` + query.where(mr_metrics_table[:first_deployed_to_production_at].gteq(@from)) + end + + query + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/metrics_tables.rb b/lib/gitlab/cycle_analytics/metrics_tables.rb new file mode 100644 index 00000000000..9d25ef078e8 --- /dev/null +++ b/lib/gitlab/cycle_analytics/metrics_tables.rb @@ -0,0 +1,37 @@ +module Gitlab + module CycleAnalytics + module MetricsTables + def mr_metrics_table + MergeRequest::Metrics.arel_table + end + + def mr_table + MergeRequest.arel_table + end + + def mr_diff_table + MergeRequestDiff.arel_table + end + + def mr_closing_issues_table + MergeRequestsClosingIssues.arel_table + end + + def issue_table + Issue.arel_table + end + + def issue_metrics_table + Issue::Metrics.arel_table + end + + def user_table + User.arel_table + end + + def build_table + ::CommitStatus.arel_table + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/plan_event.rb b/lib/gitlab/cycle_analytics/plan_event.rb new file mode 100644 index 00000000000..b1ae215f348 --- /dev/null +++ b/lib/gitlab/cycle_analytics/plan_event.rb @@ -0,0 +1,44 @@ +module Gitlab + module CycleAnalytics + class PlanEvent < BaseEvent + def initialize(*args) + @stage = :plan + @start_time_attrs = issue_metrics_table[:first_associated_with_milestone_at] + @end_time_attrs = [issue_metrics_table[:first_added_to_board_at], + issue_metrics_table[:first_mentioned_in_commit_at]] + @projections = [mr_diff_table[:st_commits].as('commits'), + issue_metrics_table[:first_mentioned_in_commit_at]] + + super(*args) + end + + def custom_query(base_query) + base_query.join(mr_diff_table).on(mr_diff_table[:merge_request_id].eq(mr_table[:id])) + end + + private + + def serialize(event) + st_commit = first_time_reference_commit(event.delete('commits'), event) + + return unless st_commit + + serialize_commit(event, st_commit, query) + end + + def first_time_reference_commit(commits, event) + YAML.load(commits).find do |commit| + next unless commit[:committed_date] && event['first_mentioned_in_commit_at'] + + commit[:committed_date].to_i == DateTime.parse(event['first_mentioned_in_commit_at'].to_s).to_i + end + end + + def serialize_commit(event, st_commit, query) + commit = Commit.new(Gitlab::Git::Commit.new(st_commit), @project) + + AnalyticsCommitSerializer.new(project: @project, total_time: event['total_time']).represent(commit).as_json + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/production_event.rb b/lib/gitlab/cycle_analytics/production_event.rb new file mode 100644 index 00000000000..4868c3c6237 --- /dev/null +++ b/lib/gitlab/cycle_analytics/production_event.rb @@ -0,0 +1,26 @@ +module Gitlab + module CycleAnalytics + class ProductionEvent < BaseEvent + include IssueAllowed + + def initialize(*args) + @stage = :production + @start_time_attrs = issue_table[:created_at] + @end_time_attrs = mr_metrics_table[:first_deployed_to_production_at] + @projections = [issue_table[:title], + issue_table[:iid], + issue_table[:id], + issue_table[:created_at], + issue_table[:author_id]] + + super(*args) + end + + private + + def serialize(event) + AnalyticsIssueSerializer.new(project: @project).represent(event).as_json + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/review_event.rb b/lib/gitlab/cycle_analytics/review_event.rb new file mode 100644 index 00000000000..b394a02cc52 --- /dev/null +++ b/lib/gitlab/cycle_analytics/review_event.rb @@ -0,0 +1,25 @@ +module Gitlab + module CycleAnalytics + class ReviewEvent < BaseEvent + include MergeRequestAllowed + + def initialize(*args) + @stage = :review + @start_time_attrs = mr_table[:created_at] + @end_time_attrs = mr_metrics_table[:merged_at] + @projections = [mr_table[:title], + mr_table[:iid], + mr_table[:id], + mr_table[:created_at], + mr_table[:state], + mr_table[:author_id]] + + super(*args) + end + + def serialize(event) + AnalyticsMergeRequestSerializer.new(project: @project).represent(event).as_json + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/staging_event.rb b/lib/gitlab/cycle_analytics/staging_event.rb new file mode 100644 index 00000000000..a1f30b716f6 --- /dev/null +++ b/lib/gitlab/cycle_analytics/staging_event.rb @@ -0,0 +1,31 @@ +module Gitlab + module CycleAnalytics + class StagingEvent < BaseEvent + def initialize(*args) + @stage = :staging + @start_time_attrs = mr_metrics_table[:merged_at] + @end_time_attrs = mr_metrics_table[:first_deployed_to_production_at] + @projections = [build_table[:id]] + @order = build_table[:created_at] + + super(*args) + end + + def fetch + Updater.update!(event_result, from: 'id', to: 'build', klass: ::Ci::Build) + + super + end + + def custom_query(base_query) + base_query.join(build_table).on(mr_metrics_table[:pipeline_id].eq(build_table[:commit_id])) + end + + private + + def serialize(event) + AnalyticsBuildSerializer.new.represent(event['build']).as_json + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/test_event.rb b/lib/gitlab/cycle_analytics/test_event.rb new file mode 100644 index 00000000000..d553d0b5aec --- /dev/null +++ b/lib/gitlab/cycle_analytics/test_event.rb @@ -0,0 +1,13 @@ +module Gitlab + module CycleAnalytics + class TestEvent < StagingEvent + def initialize(*args) + super(*args) + + @stage = :test + @start_time_attrs = mr_metrics_table[:latest_build_started_at] + @end_time_attrs = mr_metrics_table[:latest_build_finished_at] + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/updater.rb b/lib/gitlab/cycle_analytics/updater.rb new file mode 100644 index 00000000000..953268ebd46 --- /dev/null +++ b/lib/gitlab/cycle_analytics/updater.rb @@ -0,0 +1,30 @@ +module Gitlab + module CycleAnalytics + class Updater + def self.update!(*args) + new(*args).update! + end + + def initialize(event_result, from:, to:, klass:) + @event_result = event_result + @klass = klass + @from = from + @to = to + end + + def update! + @event_result.each do |event| + event[@to] = items[event.delete(@from).to_i].first + end + end + + def result_ids + @event_result.map { |event| event[@from] } + end + + def items + @items ||= @klass.find(result_ids).group_by { |item| item['id'] } + end + end + end +end diff --git a/lib/gitlab/database.rb b/lib/gitlab/database.rb index 55b8f888d53..2d5c9232425 100644 --- a/lib/gitlab/database.rb +++ b/lib/gitlab/database.rb @@ -35,6 +35,13 @@ module Gitlab order end + def self.serialized_transaction + opts = {} + opts[:isolation] = :serializable unless Rails.env.test? && connection.transaction_open? + + connection.transaction(opts) { yield } + end + def self.random Gitlab::Database.postgresql? ? "RANDOM()" : "RAND()" end diff --git a/lib/gitlab/database/date_time.rb b/lib/gitlab/database/date_time.rb index b6a89f715fd..25e56998038 100644 --- a/lib/gitlab/database/date_time.rb +++ b/lib/gitlab/database/date_time.rb @@ -7,21 +7,25 @@ module Gitlab # # Note: For MySQL, the interval is returned in seconds. # For PostgreSQL, the interval is returned as an INTERVAL type. - def subtract_datetimes(query_so_far, end_time_attrs, start_time_attrs, as) - diff_fn = if Gitlab::Database.postgresql? - Arel::Nodes::Subtraction.new( - Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(end_time_attrs)), - Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(start_time_attrs))) - elsif Gitlab::Database.mysql? - Arel::Nodes::NamedFunction.new( - "TIMESTAMPDIFF", - [Arel.sql('second'), - Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(start_time_attrs)), - Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(end_time_attrs))]) - end + def subtract_datetimes(query_so_far, start_time_attrs, end_time_attrs, as) + diff_fn = subtract_datetimes_diff(query_so_far, start_time_attrs, end_time_attrs) query_so_far.project(diff_fn.as(as)) end + + def subtract_datetimes_diff(query_so_far, start_time_attrs, end_time_attrs) + if Gitlab::Database.postgresql? + Arel::Nodes::Subtraction.new( + Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(end_time_attrs)), + Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(start_time_attrs))) + elsif Gitlab::Database.mysql? + Arel::Nodes::NamedFunction.new( + "TIMESTAMPDIFF", + [Arel.sql('second'), + Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(start_time_attrs)), + Arel::Nodes::NamedFunction.new("COALESCE", Array.wrap(end_time_attrs))]) + end + end end end end diff --git a/lib/gitlab/email/html_parser.rb b/lib/gitlab/email/html_parser.rb new file mode 100644 index 00000000000..a4ca62bfc41 --- /dev/null +++ b/lib/gitlab/email/html_parser.rb @@ -0,0 +1,34 @@ +module Gitlab + module Email + class HTMLParser + def self.parse_reply(raw_body) + new(raw_body).filtered_text + end + + attr_reader :raw_body + def initialize(raw_body) + @raw_body = raw_body + end + + def document + @document ||= Nokogiri::HTML.parse(raw_body) + end + + def filter_replies! + document.xpath('//blockquote').each(&:remove) + document.xpath('//table').each(&:remove) + end + + def filtered_html + @filtered_html ||= begin + filter_replies! + document.inner_html + end + end + + def filtered_text + @filtered_text ||= Html2Text.convert(filtered_html) + end + end + end +end diff --git a/lib/gitlab/email/reply_parser.rb b/lib/gitlab/email/reply_parser.rb index 3411eb1d9ce..85402c2a278 100644 --- a/lib/gitlab/email/reply_parser.rb +++ b/lib/gitlab/email/reply_parser.rb @@ -23,19 +23,26 @@ module Gitlab private def select_body(message) - text = message.text_part if message.multipart? - text ||= message if message.content_type !~ /text\/html/ + if message.multipart? + part = message.text_part || message.html_part || message + else + part = message + end - return "" unless text + decoded = fix_charset(part) - text = fix_charset(text) + return "" unless decoded # Certain trigger phrases that means we didn't parse correctly - if text =~ /(Content\-Type\:|multipart\/alternative|text\/plain)/ + if decoded =~ /(Content\-Type\:|multipart\/alternative|text\/plain)/ return "" end - text + if (part.content_type || '').include? 'text/html' + HTMLParser.parse_reply(decoded) + else + decoded + end end # Force encoding to UTF-8 on a Mail::Message or Mail::Part diff --git a/lib/mattermost/presenter.rb b/lib/mattermost/presenter.rb new file mode 100644 index 00000000000..bfbb089eb02 --- /dev/null +++ b/lib/mattermost/presenter.rb @@ -0,0 +1,117 @@ +module Mattermost + class Presenter + class << self + include Gitlab::Routing.url_helpers + + def authorize_chat_name(url) + message = if url + ":wave: Hi there! Before I do anything for you, please [connect your GitLab account](#{url})." + else + ":sweat_smile: Couldn't identify you, nor can I autorize you!" + end + + ephemeral_response(message) + end + + def help(commands, trigger) + if commands.none? + ephemeral_response("No commands configured") + else + commands.map! { |command| "#{trigger} #{command}" } + message = header_with_list("Available commands", commands) + + ephemeral_response(message) + end + end + + def present(resource) + return not_found unless resource + + if resource.respond_to?(:count) + if resource.count > 1 + return multiple_resources(resource) + elsif resource.count == 0 + return not_found + else + resource = resource.first + end + end + + single_resource(resource) + end + + def access_denied + ephemeral_response("Whoops! That action is not allowed. This incident will be [reported](https://xkcd.com/838/).") + end + + private + + def not_found + ephemeral_response("404 not found! GitLab couldn't find what you were looking for! :boom:") + end + + def single_resource(resource) + return error(resource) if resource.errors.any? || !resource.persisted? + + message = "### #{title(resource)}" + message << "\n\n#{resource.description}" if resource.description + + in_channel_response(message) + end + + def multiple_resources(resources) + resources.map! { |resource| title(resource) } + + message = header_with_list("Multiple results were found:", resources) + + ephemeral_response(message) + end + + def error(resource) + message = header_with_list("The action was not successful, because:", resource.errors.messages) + + ephemeral_response(message) + end + + def title(resource) + "[#{resource.to_reference} #{resource.title}](#{url(resource)})" + end + + def header_with_list(header, items) + message = [header] + + items.each do |item| + message << "- #{item}" + end + + message.join("\n") + end + + def url(resource) + url_for( + [ + resource.project.namespace.becomes(Namespace), + resource.project, + resource + ] + ) + end + + def ephemeral_response(message) + { + response_type: :ephemeral, + text: message, + status: 200 + } + end + + def in_channel_response(message) + { + response_type: :in_channel, + text: message, + status: 200 + } + end + end + end +end diff --git a/lib/tasks/gitlab/cleanup.rake b/lib/tasks/gitlab/cleanup.rake index b7cbdc6cd78..4a696a52b4d 100644 --- a/lib/tasks/gitlab/cleanup.rake +++ b/lib/tasks/gitlab/cleanup.rake @@ -91,5 +91,28 @@ namespace :gitlab do puts "To block these users run this command with BLOCK=true".color(:yellow) end end + + # This is a rake task which removes faulty refs. These refs where only + # created in the 8.13.RC cycle, and fixed in the stable builds which were + # released. So likely this should only be run once on gitlab.com + # Faulty refs are moved so they are kept around, else some features break. + desc 'GitLab | Cleanup | Remove faulty deployment refs' + task move_faulty_deployment_refs: :environment do + projects = Project.where(id: Deployment.select(:project_id).distinct) + + projects.find_each do |project| + rugged = project.repository.rugged + + max_iid = project.deployments.maximum(:iid) + + rugged.references.each('refs/environments/**/*') do |ref| + id = ref.name.split('/').last.to_i + next unless id > max_iid + + project.deployments.find(id).create_ref + rugged.references.delete(ref) + end + end + end end end diff --git a/scripts/prepare_build.sh b/scripts/prepare_build.sh index 1eaafdce389..6e3f76b8399 100755 --- a/scripts/prepare_build.sh +++ b/scripts/prepare_build.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/bin/sh retry() { if eval "$@"; then @@ -24,11 +24,12 @@ if [ -f /.dockerenv ] || [ -f ./dockerinit ]; then cp config/resque.yml.example config/resque.yml sed -i 's/localhost/redis/g' config/resque.yml - export FLAGS=(--path vendor --retry 3 --quiet) + export FLAGS="--path vendor --retry 3 --quiet" else - export PATH=$HOME/bin:/usr/local/bin:/usr/bin:/bin + rnd=$(awk 'BEGIN { srand() ; printf("%d\n",rand()*5) }') + export PATH="$HOME/bin:/usr/local/bin:/usr/bin:/bin" cp config/database.yml.mysql config/database.yml sed "s/username\:.*$/username\: runner/" -i config/database.yml sed "s/password\:.*$/password\: 'password'/" -i config/database.yml - sed "s/gitlabhq_test/gitlabhq_test_$((RANDOM/5000))/" -i config/database.yml + sed "s/gitlabhq_test/gitlabhq_test_$rnd/" -i config/database.yml fi diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb index 768105cae95..bc5e2711125 100644 --- a/spec/controllers/projects/environments_controller_spec.rb +++ b/spec/controllers/projects/environments_controller_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Projects::EnvironmentsController do + include ApiHelpers + let(:environment) { create(:environment) } let(:project) { environment.project } let(:user) { create(:user) } @@ -11,6 +13,27 @@ describe Projects::EnvironmentsController do sign_in(user) end + describe 'GET index' do + context 'when standardrequest has been made' do + it 'responds with status code 200' do + get :index, environment_params + + expect(response).to be_ok + end + end + + context 'when requesting JSON response' do + it 'responds with correct JSON' do + get :index, environment_params(format: :json) + + first_environment = json_response.first + + expect(first_environment).not_to be_empty + expect(first_environment['name']). to eq environment.name + end + end + end + describe 'GET show' do context 'with valid id' do it 'responds with a status code 200' do @@ -48,11 +71,9 @@ describe Projects::EnvironmentsController do end end - def environment_params - { - namespace_id: project.namespace, - project_id: project, - id: environment.id - } + def environment_params(opts = {}) + opts.reverse_merge(namespace_id: project.namespace, + project_id: project, + id: environment.id) end end diff --git a/spec/features/admin/admin_groups_spec.rb b/spec/features/admin/admin_groups_spec.rb new file mode 100644 index 00000000000..f6d625fa7f6 --- /dev/null +++ b/spec/features/admin/admin_groups_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +feature 'Admin Groups', feature: true do + let(:internal) { Gitlab::VisibilityLevel::INTERNAL } + + before do + login_as(:admin) + + stub_application_setting(default_group_visibility: internal) + end + + describe 'create a group' do + scenario 'shows the visibility level radio populated with the default value' do + visit new_admin_group_path + + expect_selected_visibility(internal) + end + end + + describe 'group edit' do + scenario 'shows the visibility level radio populated with the group visibility_level value' do + group = create(:group, :private) + + visit edit_admin_group_path(group) + + expect_selected_visibility(group.visibility_level) + end + end + + def expect_selected_visibility(level) + selector = "#group_visibility_level_#{level}[checked=checked]" + + expect(page).to have_selector(selector, count: 1) + end +end diff --git a/spec/features/environment_spec.rb b/spec/features/environment_spec.rb new file mode 100644 index 00000000000..0c1939fd885 --- /dev/null +++ b/spec/features/environment_spec.rb @@ -0,0 +1,161 @@ +require 'spec_helper' + +feature 'Environment', :feature do + given(:project) { create(:empty_project) } + given(:user) { create(:user) } + given(:role) { :developer } + + background do + login_as(user) + project.team << [user, role] + end + + feature 'environment details page' do + given!(:environment) { create(:environment, project: project) } + given!(:deployment) { } + given!(:manual) { } + + before do + visit_environment(environment) + end + + context 'without deployments' do + scenario 'does show no deployments' do + expect(page).to have_content('You don\'t have any deployments right now.') + end + end + + context 'with deployments' do + context 'when there is no related deployable' do + given(:deployment) do + create(:deployment, environment: environment, deployable: nil) + end + + scenario 'does show deployment SHA' do + expect(page).to have_link(deployment.short_sha) + end + + scenario 'does not show a re-deploy button for deployment without build' do + expect(page).not_to have_link('Re-deploy') + end + end + + context 'with related deployable present' do + given(:pipeline) { create(:ci_pipeline, project: project) } + given(:build) { create(:ci_build, pipeline: pipeline) } + + given(:deployment) do + create(:deployment, environment: environment, deployable: build) + end + + scenario 'does show build name' do + expect(page).to have_link("#{build.name} (##{build.id})") + end + + scenario 'does show re-deploy button' do + expect(page).to have_link('Re-deploy') + end + + scenario 'does not show stop button' do + expect(page).not_to have_link('Stop') + end + + context 'with manual action' do + given(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'deploy to production') } + + scenario 'does show a play button' do + expect(page).to have_link(manual.name.humanize) + end + + scenario 'does allow to play manual action' do + expect(manual).to be_skipped + expect{ click_link(manual.name.humanize) }.not_to change { Ci::Pipeline.count } + expect(page).to have_content(manual.name) + expect(manual.reload).to be_pending + end + + context 'with external_url' do + given(:environment) { create(:environment, project: project, external_url: 'https://git.gitlab.com') } + given(:build) { create(:ci_build, pipeline: pipeline) } + given(:deployment) { create(:deployment, environment: environment, deployable: build) } + + scenario 'does show an external link button' do + expect(page).to have_link(nil, href: environment.external_url) + end + end + + context 'with stop action' do + given(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') } + given(:deployment) { create(:deployment, environment: environment, deployable: build, on_stop: 'close_app') } + + scenario 'does show stop button' do + expect(page).to have_link('Stop') + end + + scenario 'does allow to stop environment' do + click_link('Stop') + + expect(page).to have_content('close_app') + end + + context 'for reporter' do + let(:role) { :reporter } + + scenario 'does not show stop button' do + expect(page).not_to have_link('Stop') + end + end + end + end + end + end + end + + feature 'auto-close environment when branch is deleted' do + given(:project) { create(:project) } + + given!(:environment) do + create(:environment, :with_review_app, project: project, + ref: 'feature') + end + + scenario 'user visits environment page' do + visit_environment(environment) + + expect(page).to have_link('Stop') + end + + scenario 'user deletes the branch with running environment' do + visit namespace_project_branches_path(project.namespace, project) + + remove_branch_with_hooks(project, user, 'feature') do + page.within('.js-branch-feature') { find('a.btn-remove').click } + end + + visit_environment(environment) + + expect(page).to have_no_link('Stop') + end + + ## + # This is a workaround for problem described in #24543 + # + def remove_branch_with_hooks(project, user, branch) + params = { + oldrev: project.commit(branch).id, + newrev: Gitlab::Git::BLANK_SHA, + ref: "refs/heads/#{branch}" + } + + yield + + GitPushService.new(project, user, params).execute + end + end + + def visit_environment(environment) + visit namespace_project_environment_path(environment.project.namespace, + environment.project, + environment) + end +end diff --git a/spec/features/environments_spec.rb b/spec/features/environments_spec.rb index 1fe509c2cac..c7fe622c477 100644 --- a/spec/features/environments_spec.rb +++ b/spec/features/environments_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -feature 'Environments', feature: true do +feature 'Environments page', :feature, :js do given(:project) { create(:empty_project) } given(:user) { create(:user) } given(:role) { :developer } @@ -10,221 +10,138 @@ feature 'Environments', feature: true do login_as(user) end - describe 'when showing environments' do - given!(:environment) { } - given!(:deployment) { } - given!(:manual) { } + given!(:environment) { } + given!(:deployment) { } + given!(:manual) { } - before do - visit_environments(project) + before do + visit_environments(project) + end + + describe 'page tabs' do + scenario 'shows "Available" and "Stopped" tab with links' do + expect(page).to have_link('Available') + expect(page).to have_link('Stopped') + end + end + + context 'without environments' do + scenario 'does show no environments' do + expect(page).to have_content('You don\'t have any environments right now.') end - context 'shows two tabs' do - scenario 'shows "Available" and "Stopped" tab with links' do - expect(page).to have_link('Available') - expect(page).to have_link('Stopped') - end - end - - context 'without environments' do - scenario 'does show no environments' do - expect(page).to have_content('You don\'t have any environments right now.') - end - - scenario 'does show 0 as counter for environments in both tabs' do - expect(page.find('.js-available-environments-count').text).to eq('0') - expect(page.find('.js-stopped-environments-count').text).to eq('0') - end - end - - context 'with environments' do - given(:environment) { create(:environment, project: project) } - - scenario 'does show environment name' do - expect(page).to have_link(environment.name) - end - - scenario 'does show number of available and stopped environments' do - expect(page.find('.js-available-environments-count').text).to eq('1') - expect(page.find('.js-stopped-environments-count').text).to eq('0') - end - - context 'without deployments' do - scenario 'does show no deployments' do - expect(page).to have_content('No deployments yet') - end - end - - context 'with deployments' do - given(:deployment) { create(:deployment, environment: environment) } - - scenario 'does show deployment SHA' do - expect(page).to have_link(deployment.short_sha) - end - - scenario 'does show deployment internal id' do - expect(page).to have_content(deployment.iid) - end - - context 'with build and manual actions' do - given(:pipeline) { create(:ci_pipeline, project: project) } - given(:build) { create(:ci_build, pipeline: pipeline) } - given(:deployment) { create(:deployment, environment: environment, deployable: build) } - given(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'deploy to production') } - - scenario 'does show a play button' do - expect(page).to have_link(manual.name.humanize) - end - - scenario 'does allow to play manual action' do - expect(manual).to be_skipped - expect{ click_link(manual.name.humanize) }.not_to change { Ci::Pipeline.count } - expect(page).to have_content(manual.name) - expect(manual.reload).to be_pending - end - - scenario 'does show build name and id' do - expect(page).to have_link("#{build.name} (##{build.id})") - end - - scenario 'does not show stop button' do - expect(page).not_to have_selector('.stop-env-link') - end - - scenario 'does not show external link button' do - expect(page).not_to have_css('external-url') - end - - context 'with external_url' do - given(:environment) { create(:environment, project: project, external_url: 'https://git.gitlab.com') } - given(:build) { create(:ci_build, pipeline: pipeline) } - given(:deployment) { create(:deployment, environment: environment, deployable: build) } - - scenario 'does show an external link button' do - expect(page).to have_link(nil, href: environment.external_url) - end - end - - context 'with stop action' do - given(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') } - given(:deployment) { create(:deployment, environment: environment, deployable: build, on_stop: 'close_app') } - - scenario 'does show stop button' do - expect(page).to have_selector('.stop-env-link') - end - - scenario 'starts build when stop button clicked' do - first('.stop-env-link').click - - expect(page).to have_content('close_app') - end - - context 'for reporter' do - let(:role) { :reporter } - - scenario 'does not show stop button' do - expect(page).not_to have_selector('.stop-env-link') - end - end - end - end - end - end - - scenario 'does have a New environment button' do - expect(page).to have_link('New environment') + scenario 'does show 0 as counter for environments in both tabs' do + expect(page.find('.js-available-environments-count').text).to eq('0') + expect(page.find('.js-stopped-environments-count').text).to eq('0') end end describe 'when showing the environment' do given(:environment) { create(:environment, project: project) } - given!(:deployment) { } - given!(:manual) { } - before do - visit_environment(environment) + scenario 'does show environment name' do + expect(page).to have_link(environment.name) + end + + scenario 'does show number of available and stopped environments' do + expect(page.find('.js-available-environments-count').text).to eq('1') + expect(page.find('.js-stopped-environments-count').text).to eq('0') end context 'without deployments' do scenario 'does show no deployments' do - expect(page).to have_content('You don\'t have any deployments right now.') + expect(page).to have_content('No deployments yet') end end context 'with deployments' do + given(:project) { create(:project) } + given(:deployment) do - create(:deployment, environment: environment, deployable: nil) + create(:deployment, environment: environment, + sha: project.commit.id) end scenario 'does show deployment SHA' do expect(page).to have_link(deployment.short_sha) end - scenario 'does not show a re-deploy button for deployment without build' do - expect(page).not_to have_link('Re-deploy') + scenario 'does show deployment internal id' do + expect(page).to have_content(deployment.iid) end - context 'with build' do + context 'with build and manual actions' do given(:pipeline) { create(:ci_pipeline, project: project) } given(:build) { create(:ci_build, pipeline: pipeline) } - given(:deployment) { create(:deployment, environment: environment, deployable: build) } - scenario 'does show build name' do - expect(page).to have_link("#{build.name} (##{build.id})") + given(:manual) do + create(:ci_build, :manual, pipeline: pipeline, name: 'deploy to production') end - scenario 'does show re-deploy button' do - expect(page).to have_link('Re-deploy') + given(:deployment) do + create(:deployment, environment: environment, + deployable: build, + sha: project.commit.id) + end + + scenario 'does show a play button' do + find('.dropdown-play-icon-container').click + expect(page).to have_content(manual.name.humanize) + end + + scenario 'does allow to play manual action', js: true do + expect(manual).to be_skipped + + find('.dropdown-play-icon-container').click + expect(page).to have_content(manual.name.humanize) + + expect { click_link(manual.name.humanize) } + .not_to change { Ci::Pipeline.count } + + expect(manual.reload).to be_pending + end + + scenario 'does show build name and id' do + expect(page).to have_link("#{build.name} ##{build.id}") end scenario 'does not show stop button' do - expect(page).not_to have_link('Stop') + expect(page).not_to have_selector('.stop-env-link') end - context 'with manual action' do - given(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'deploy to production') } + scenario 'does not show external link button' do + expect(page).not_to have_css('external-url') + end - scenario 'does show a play button' do - expect(page).to have_link(manual.name.humanize) + context 'with external_url' do + given(:environment) { create(:environment, project: project, external_url: 'https://git.gitlab.com') } + given(:build) { create(:ci_build, pipeline: pipeline) } + given(:deployment) { create(:deployment, environment: environment, deployable: build) } + + scenario 'does show an external link button' do + expect(page).to have_link(nil, href: environment.external_url) + end + end + + context 'with stop action' do + given(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') } + given(:deployment) { create(:deployment, environment: environment, deployable: build, on_stop: 'close_app') } + + scenario 'does show stop button' do + expect(page).to have_selector('.stop-env-link') end - scenario 'does allow to play manual action' do - expect(manual).to be_skipped - expect{ click_link(manual.name.humanize) }.not_to change { Ci::Pipeline.count } - expect(page).to have_content(manual.name) - expect(manual.reload).to be_pending + scenario 'starts build when stop button clicked' do + find('.stop-env-link').click + + expect(page).to have_content('close_app') end - context 'with external_url' do - given(:environment) { create(:environment, project: project, external_url: 'https://git.gitlab.com') } - given(:build) { create(:ci_build, pipeline: pipeline) } - given(:deployment) { create(:deployment, environment: environment, deployable: build) } + context 'for reporter' do + let(:role) { :reporter } - scenario 'does show an external link button' do - expect(page).to have_link(nil, href: environment.external_url) - end - end - - context 'with stop action' do - given(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') } - given(:deployment) { create(:deployment, environment: environment, deployable: build, on_stop: 'close_app') } - - scenario 'does show stop button' do - expect(page).to have_link('Stop') - end - - scenario 'does allow to stop environment' do - click_link('Stop') - - expect(page).to have_content('close_app') - end - - context 'for reporter' do - let(:role) { :reporter } - - scenario 'does not show stop button' do - expect(page).not_to have_link('Stop') - end + scenario 'does not show stop button' do + expect(page).not_to have_selector('.stop-env-link') end end end @@ -232,6 +149,10 @@ feature 'Environments', feature: true do end end + scenario 'does have a New environment button' do + expect(page).to have_link('New environment') + end + describe 'when creating a new environment' do before do visit_environments(project) @@ -274,55 +195,7 @@ feature 'Environments', feature: true do end end - feature 'auto-close environment when branch deleted' do - given(:project) { create(:project) } - - given!(:environment) do - create(:environment, :with_review_app, project: project, - ref: 'feature') - end - - scenario 'user visits environment page' do - visit_environment(environment) - - expect(page).to have_link('Stop') - end - - scenario 'user deletes the branch with running environment' do - visit namespace_project_branches_path(project.namespace, project) - - remove_branch_with_hooks(project, user, 'feature') do - page.within('.js-branch-feature') { find('a.btn-remove').click } - end - - visit_environment(environment) - - expect(page).to have_no_link('Stop') - end - - ## - # This is a workaround for problem described in #24543 - # - def remove_branch_with_hooks(project, user, branch) - params = { - oldrev: project.commit(branch).id, - newrev: Gitlab::Git::BLANK_SHA, - ref: "refs/heads/#{branch}" - } - - yield - - GitPushService.new(project, user, params).execute - end - end - def visit_environments(project) visit namespace_project_environments_path(project.namespace, project) end - - def visit_environment(environment) - visit namespace_project_environment_path(environment.project.namespace, - environment.project, - environment) - end end diff --git a/spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions.rb b/spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions_spec.rb similarity index 100% rename from spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions.rb rename to spec/features/merge_requests/check_if_mergeable_with_unresolved_discussions_spec.rb diff --git a/spec/features/merge_requests/create_new_mr_spec.rb b/spec/features/merge_requests/create_new_mr_spec.rb index 584574cc91a..702869b6e8b 100644 --- a/spec/features/merge_requests/create_new_mr_spec.rb +++ b/spec/features/merge_requests/create_new_mr_spec.rb @@ -77,4 +77,11 @@ feature 'Create New Merge Request', feature: true, js: true do expect(page).to have_content "6049019_460s.jpg" end end + + # Isolates a regression (see #24627) + it 'does not show error messages on initial form' do + visit new_namespace_project_merge_request_path(project.namespace, project) + expect(page).not_to have_selector('#error_explanation') + expect(page).not_to have_content('The form contains the following error') + end end diff --git a/spec/features/merge_requests/diff_notes_resolve_spec.rb b/spec/features/merge_requests/diff_notes_resolve_spec.rb index d5e3d8e7eff..eab64bd4b54 100644 --- a/spec/features/merge_requests/diff_notes_resolve_spec.rb +++ b/spec/features/merge_requests/diff_notes_resolve_spec.rb @@ -201,7 +201,7 @@ feature 'Diff notes resolve', feature: true, js: true do expect(first('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}") end - expect(page).to have_content('Last updated') + expect(page).not_to have_content('Last updated') page.within '.line-resolve-all-container' do expect(page).to have_content('0/1 discussion resolved') diff --git a/spec/features/merge_requests/merge_when_build_succeeds_spec.rb b/spec/features/merge_requests/merge_when_build_succeeds_spec.rb index c3c3ab33872..8eceaad2457 100644 --- a/spec/features/merge_requests/merge_when_build_succeeds_spec.rb +++ b/spec/features/merge_requests/merge_when_build_succeeds_spec.rb @@ -73,7 +73,7 @@ feature 'Merge When Build Succeeds', feature: true, js: true do expect(page).to have_button "Merge When Build Succeeds" visit_merge_request(merge_request) # refresh the page - expect(page).to have_content "Canceled the automatic merge" + expect(page).to have_content "canceled the automatic merge" end it "allows the user to remove the source branch" do @@ -101,7 +101,7 @@ feature 'Merge When Build Succeeds', feature: true, js: true do expect(page).not_to have_link "Merge When Build Succeeds" end end - + def visit_merge_request(merge_request) visit namespace_project_merge_request_path(merge_request.project.namespace, merge_request.project, merge_request) end diff --git a/spec/features/merge_requests/only_allow_merge_if_build_succeeds.rb b/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb similarity index 69% rename from spec/features/merge_requests/only_allow_merge_if_build_succeeds.rb rename to spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb index 80e8b8fc642..1ec3103feef 100644 --- a/spec/features/merge_requests/only_allow_merge_if_build_succeeds.rb +++ b/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb @@ -1,8 +1,8 @@ require 'spec_helper' feature 'Only allow merge requests to be merged if the build succeeds', feature: true do - let(:project) { create(:project, :public) } - let(:merge_request) { create(:merge_request_with_diffs, source_project: project) } + let(:merge_request) { create(:merge_request_with_diffs) } + let(:project) { merge_request.target_project } before do login_as merge_request.author @@ -19,7 +19,13 @@ feature 'Only allow merge requests to be merged if the build succeeds', feature: end context 'when project has CI enabled' do - let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch) } + given!(:pipeline) do + create(:ci_empty_pipeline, + project: project, + sha: merge_request.diff_head_sha, + ref: merge_request.source_branch, + status: status) + end context 'when merge requests can only be merged if the build succeeds' do before do @@ -27,7 +33,7 @@ feature 'Only allow merge requests to be merged if the build succeeds', feature: end context 'when CI is running' do - before { pipeline.update_column(:status, :running) } + given(:status) { :running } it 'does not allow to merge immediately' do visit_merge_request(merge_request) @@ -38,7 +44,18 @@ feature 'Only allow merge requests to be merged if the build succeeds', feature: end context 'when CI failed' do - before { pipeline.update_column(:status, :failed) } + given(:status) { :failed } + + it 'does not allow MR to be merged' do + visit_merge_request(merge_request) + + expect(page).not_to have_button 'Accept Merge Request' + expect(page).to have_content('Please retry the build or push a new commit to fix the failure.') + end + end + + context 'when CI canceled' do + given(:status) { :canceled } it 'does not allow MR to be merged' do visit_merge_request(merge_request) @@ -49,7 +66,17 @@ feature 'Only allow merge requests to be merged if the build succeeds', feature: end context 'when CI succeeded' do - before { pipeline.update_column(:status, :success) } + given(:status) { :success } + + it 'allows MR to be merged' do + visit_merge_request(merge_request) + + expect(page).to have_button 'Accept Merge Request' + end + end + + context 'when CI skipped' do + given(:status) { :skipped } it 'allows MR to be merged' do visit_merge_request(merge_request) @@ -65,7 +92,7 @@ feature 'Only allow merge requests to be merged if the build succeeds', feature: end context 'when CI is running' do - before { pipeline.update_column(:status, :running) } + given(:status) { :running } it 'allows MR to be merged immediately', js: true do visit_merge_request(merge_request) @@ -78,7 +105,7 @@ feature 'Only allow merge requests to be merged if the build succeeds', feature: end context 'when CI failed' do - before { pipeline.update_column(:status, :failed) } + given(:status) { :failed } it 'allows MR to be merged' do visit_merge_request(merge_request) @@ -88,7 +115,7 @@ feature 'Only allow merge requests to be merged if the build succeeds', feature: end context 'when CI succeeded' do - before { pipeline.update_column(:status, :success) } + given(:status) { :success } it 'allows MR to be merged' do visit_merge_request(merge_request) diff --git a/spec/features/merge_requests/toggle_whitespace_changes.rb b/spec/features/merge_requests/toggle_whitespace_changes_spec.rb similarity index 100% rename from spec/features/merge_requests/toggle_whitespace_changes.rb rename to spec/features/merge_requests/toggle_whitespace_changes_spec.rb diff --git a/spec/features/projects/commits/cherry_pick_spec.rb b/spec/features/projects/commits/cherry_pick_spec.rb index e45e3a36d01..d46d9e9399e 100644 --- a/spec/features/projects/commits/cherry_pick_spec.rb +++ b/spec/features/projects/commits/cherry_pick_spec.rb @@ -64,7 +64,7 @@ describe 'Cherry-pick Commits' do context "I cherry-pick a commit from a different branch", js: true do it do - find('.commit-action-buttons a.dropdown-toggle').click + find('.header-action-buttons a.dropdown-toggle').click find(:css, "a[href='#modal-cherry-pick-commit']").click page.within('#modal-cherry-pick-commit') do diff --git a/spec/features/projects/pipelines_spec.rb b/spec/features/projects/pipelines_spec.rb index 544372108ed..10e5466fc85 100644 --- a/spec/features/projects/pipelines_spec.rb +++ b/spec/features/projects/pipelines_spec.rb @@ -153,7 +153,8 @@ describe "Pipelines" do end describe 'GET /:project/pipelines/:id' do - let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master') } + let(:project) { create(:project) } + let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) } before do @success = create(:ci_build, :success, pipeline: pipeline, stage: 'build', name: 'build') diff --git a/spec/finders/group_projects_finder_spec.rb b/spec/finders/group_projects_finder_spec.rb index fbe09b28b3c..00eec3f3f4c 100644 --- a/spec/finders/group_projects_finder_spec.rb +++ b/spec/finders/group_projects_finder_spec.rb @@ -38,7 +38,10 @@ describe GroupProjectsFinder do end describe 'without group member current_user' do - before { shared_project_2.team << [current_user, Gitlab::Access::MASTER] } + before do + shared_project_2.team << [current_user, Gitlab::Access::MASTER] + current_user.reload + end context "only shared" do context "without external user" do diff --git a/spec/fixtures/emails/outlook_html.eml b/spec/fixtures/emails/outlook_html.eml new file mode 100644 index 00000000000..506d69efe83 --- /dev/null +++ b/spec/fixtures/emails/outlook_html.eml @@ -0,0 +1,140 @@ + +MIME-Version: 1.0 +Received: by 10.25.161.144 with HTTP; Tue, 7 Oct 2014 22:17:17 -0700 (PDT) +X-Originating-IP: [117.207.85.84] +In-Reply-To: <5434c8b52bb3a_623ff09fec70f049749@discourse-app.mail> +References: <topic/35@discourse.techapj.com> + <5434c8b52bb3a_623ff09fec70f049749@discourse-app.mail> +Date: Wed, 8 Oct 2014 10:47:17 +0530 +Delivered-To: arpit@techapj.com +Message-ID: <CAOJeqne=SJ_LwN4sb-0Y95ejc2OpreVhdmcPn0TnmwSvTCYzzQ@mail.gmail.com> +Subject: Re: [Discourse] [Meta] Welcome to techAPJ's Discourse! +From: Arpit Jalan <arpit@techapj.com> +To: Discourse <mail+e1c7f2a380e33840aeb654f075490bad@arpitjalan.com>Accept-Language: en-US +Content-Language: en-US +X-MS-Has-Attach: +X-MS-TNEF-Correlator: +x-originating-ip: [134.68.31.227] +Content-Type: multipart/alternative; + boundary="_000_B0DFE1BEB3739743BC9B639D0E6BC8FF217A6341IUMSSGMBX104ads_" +MIME-Version: 1.0 + +--_000_B0DFE1BEB3739743BC9B639D0E6BC8FF217A6341IUMSSGMBX104ads_ +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: base64 + +PGh0bWwgeG1sbnM6dj0idXJuOnNjaGVtYXMtbWljcm9zb2Z0LWNvbTp2bWwiIHhtbG5zOm89InVy +bjpzY2hlbWFzLW1pY3Jvc29mdC1jb206b2ZmaWNlOm9mZmljZSIgeG1sbnM6dz0idXJuOnNjaGVt +YXMtbWljcm9zb2Z0LWNvbTpvZmZpY2U6d29yZCIgeG1sbnM6bT0iaHR0cDovL3NjaGVtYXMubWlj +cm9zb2Z0LmNvbS9vZmZpY2UvMjAwNC8xMi9vbW1sIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcv +VFIvUkVDLWh0bWw0MCI+DQo8aGVhZD4NCjxtZXRhIGh0dHAtZXF1aXY9IkNvbnRlbnQtVHlwZSIg +Y29udGVudD0idGV4dC9odG1sOyBjaGFyc2V0PXV0Zi04Ij4NCjxtZXRhIG5hbWU9IkdlbmVyYXRv +ciIgY29udGVudD0iTWljcm9zb2Z0IFdvcmQgMTQgKGZpbHRlcmVkIG1lZGl1bSkiPg0KPCEtLVtp +ZiAhbXNvXT48c3R5bGU+dlw6KiB7YmVoYXZpb3I6dXJsKCNkZWZhdWx0I1ZNTCk7fQ0Kb1w6KiB7 +YmVoYXZpb3I6dXJsKCNkZWZhdWx0I1ZNTCk7fQ0Kd1w6KiB7YmVoYXZpb3I6dXJsKCNkZWZhdWx0 +I1ZNTCk7fQ0KLnNoYXBlIHtiZWhhdmlvcjp1cmwoI2RlZmF1bHQjVk1MKTt9DQo8L3N0eWxlPjwh +W2VuZGlmXS0tPjxzdHlsZT48IS0tDQovKiBGb250IERlZmluaXRpb25zICovDQpAZm9udC1mYWNl +DQoJe2ZvbnQtZmFtaWx5OkNhbGlicmk7DQoJcGFub3NlLTE6MiAxNSA1IDIgMiAyIDQgMyAyIDQ7 +fQ0KQGZvbnQtZmFjZQ0KCXtmb250LWZhbWlseTpUYWhvbWE7DQoJcGFub3NlLTE6MiAxMSA2IDQg +MyA1IDQgNCAyIDQ7fQ0KLyogU3R5bGUgRGVmaW5pdGlvbnMgKi8NCnAuTXNvTm9ybWFsLCBsaS5N +c29Ob3JtYWwsIGRpdi5Nc29Ob3JtYWwNCgl7bWFyZ2luOjBpbjsNCgltYXJnaW4tYm90dG9tOi4w +MDAxcHQ7DQoJZm9udC1zaXplOjEyLjBwdDsNCglmb250LWZhbWlseToiVGltZXMgTmV3IFJvbWFu +Iiwic2VyaWYiO30NCmE6bGluaywgc3Bhbi5Nc29IeXBlcmxpbmsNCgl7bXNvLXN0eWxlLXByaW9y +aXR5Ojk5Ow0KCWNvbG9yOmJsdWU7DQoJdGV4dC1kZWNvcmF0aW9uOnVuZGVybGluZTt9DQphOnZp +c2l0ZWQsIHNwYW4uTXNvSHlwZXJsaW5rRm9sbG93ZWQNCgl7bXNvLXN0eWxlLXByaW9yaXR5Ojk5 +Ow0KCWNvbG9yOnB1cnBsZTsNCgl0ZXh0LWRlY29yYXRpb246dW5kZXJsaW5lO30NCnANCgl7bXNv +LXN0eWxlLXByaW9yaXR5Ojk5Ow0KCW1zby1tYXJnaW4tdG9wLWFsdDphdXRvOw0KCW1hcmdpbi1y +aWdodDowaW47DQoJbXNvLW1hcmdpbi1ib3R0b20tYWx0OmF1dG87DQoJbWFyZ2luLWxlZnQ6MGlu +Ow0KCWZvbnQtc2l6ZToxMi4wcHQ7DQoJZm9udC1mYW1pbHk6IlRpbWVzIE5ldyBSb21hbiIsInNl +cmlmIjt9DQpzcGFuLkVtYWlsU3R5bGUxOA0KCXttc28tc3R5bGUtdHlwZTpwZXJzb25hbC1yZXBs +eTsNCglmb250LWZhbWlseToiQ2FsaWJyaSIsInNhbnMtc2VyaWYiOw0KCWNvbG9yOiMxRjQ5N0Q7 +fQ0KLk1zb0NocERlZmF1bHQNCgl7bXNvLXN0eWxlLXR5cGU6ZXhwb3J0LW9ubHk7DQoJZm9udC1m +YW1pbHk6IkNhbGlicmkiLCJzYW5zLXNlcmlmIjt9DQpAcGFnZSBXb3JkU2VjdGlvbjENCgl7c2l6 +ZTo4LjVpbiAxMS4waW47DQoJbWFyZ2luOjEuMGluIDEuMGluIDEuMGluIDEuMGluO30NCmRpdi5X +b3JkU2VjdGlvbjENCgl7cGFnZTpXb3JkU2VjdGlvbjE7fQ0KLS0+PC9zdHlsZT48IS0tW2lmIGd0 +ZSBtc28gOV0+PHhtbD4NCjxvOnNoYXBlZGVmYXVsdHMgdjpleHQ9ImVkaXQiIHNwaWRtYXg9IjEw +MjYiIC8+DQo8L3htbD48IVtlbmRpZl0tLT48IS0tW2lmIGd0ZSBtc28gOV0+PHhtbD4NCjxvOnNo +YXBlbGF5b3V0IHY6ZXh0PSJlZGl0Ij4NCjxvOmlkbWFwIHY6ZXh0PSJlZGl0IiBkYXRhPSIxIiAv +Pg0KPC9vOnNoYXBlbGF5b3V0PjwveG1sPjwhW2VuZGlmXS0tPg0KPC9oZWFkPg0KPGJvZHkgbGFu +Zz0iRU4tVVMiIGxpbms9ImJsdWUiIHZsaW5rPSJwdXJwbGUiPg0KPGRpdiBjbGFzcz0iV29yZFNl +Y3Rpb24xIj4NCjxwIGNsYXNzPSJNc29Ob3JtYWwiPjxzcGFuIHN0eWxlPSJmb250LXNpemU6MTEu +MHB0O2ZvbnQtZmFtaWx5OiZxdW90O0NhbGlicmkmcXVvdDssJnF1b3Q7c2Fucy1zZXJpZiZxdW90 +Oztjb2xvcjojMUY0OTdEIj5NaWNyb3NvZnQgT3V0bG9vayAyMDEwPG86cD48L286cD48L3NwYW4+ +PC9wPg0KPHAgY2xhc3M9Ik1zb05vcm1hbCI+PHNwYW4gc3R5bGU9ImZvbnQtc2l6ZToxMS4wcHQ7 +Zm9udC1mYW1pbHk6JnF1b3Q7Q2FsaWJyaSZxdW90OywmcXVvdDtzYW5zLXNlcmlmJnF1b3Q7O2Nv +bG9yOiMxRjQ5N0QiPjxvOnA+Jm5ic3A7PC9vOnA+PC9zcGFuPjwvcD4NCjxwIGNsYXNzPSJNc29O +b3JtYWwiPjxiPjxzcGFuIHN0eWxlPSJmb250LXNpemU6MTAuMHB0O2ZvbnQtZmFtaWx5OiZxdW90 +O1RhaG9tYSZxdW90OywmcXVvdDtzYW5zLXNlcmlmJnF1b3Q7Ij5Gcm9tOjwvc3Bhbj48L2I+PHNw +YW4gc3R5bGU9ImZvbnQtc2l6ZToxMC4wcHQ7Zm9udC1mYW1pbHk6JnF1b3Q7VGFob21hJnF1b3Q7 +LCZxdW90O3NhbnMtc2VyaWYmcXVvdDsiPiBtaWNoYWVsIFttYWlsdG86dGFsa0BvcGVubXJzLm9y +Z10NCjxicj4NCjxiPlNlbnQ6PC9iPiBNb25kYXksIE9jdG9iZXIgMTMsIDIwMTQgOTozOCBBTTxi +cj4NCjxiPlRvOjwvYj4gUG93ZXIsIENocmlzPGJyPg0KPGI+U3ViamVjdDo8L2I+IFtQTV0gWW91 +ciBwb3N0IGluICZxdW90O0J1cmdlcmhhdXM6IE5ldyByZXN0YXVyYW50IC8gbHVuY2ggdmVudWUm +cXVvdDs8bzpwPjwvbzpwPjwvc3Bhbj48L3A+DQo8cCBjbGFzcz0iTXNvTm9ybWFsIj48bzpwPiZu +YnNwOzwvbzpwPjwvcD4NCjxkaXY+DQo8dGFibGUgY2xhc3M9Ik1zb05vcm1hbFRhYmxlIiBib3Jk +ZXI9IjAiIGNlbGxzcGFjaW5nPSIwIiBjZWxscGFkZGluZz0iMCI+DQo8dGJvZHk+DQo8dHI+DQo8 +dGQgdmFsaWduPSJ0b3AiIHN0eWxlPSJwYWRkaW5nOjBpbiAwaW4gMGluIDBpbiI+PC90ZD4NCjx0 +ZCBzdHlsZT0icGFkZGluZzowaW4gMGluIDBpbiAwaW4iPg0KPHAgY2xhc3M9Ik1zb05vcm1hbCIg +c3R5bGU9Im1hcmdpbi1ib3R0b206MTguNzVwdCI+PGEgaHJlZj0iaHR0cDovL2NsLm9wZW5tcnMu +b3JnL3RyYWNrL2NsaWNrLzMwMDM5OTA1L3RhbGsub3Blbm1ycy5vcmc/cD1leUp6SWpvaWJHbFph +MVYwZVhoQ1kwMU1SVEZzVURKbVl6VlFNMFpsZWpFNElpd2lkaUk2TVN3aWNDSTZJbnRjSW5WY0lq +b3pNREF6T1Rrd05TeGNJblpjSWpveExGd2lkWEpzWENJNlhDSm9kSFJ3Y3pwY1hGd3ZYRnhjTDNS +aGJHc3ViM0JsYm0xeWN5NXZjbWRjWEZ3dmRYTmxjbk5jWEZ3dmJXbGphR0ZsYkZ3aUxGd2lhV1Jj +SWpwY0ltUTFZbU13TjJOa05EUmpaRFE0TUdNNFlUZzJNemxqWldJMU56Z3pZbVkyWENJc1hDSjFj +bXhmYVdSelhDSTZXMXdpWWpoa09EZzFNams1TnpkbVpqWTFaV1l5TlRFM09XUmlOR1l5TVdJM056 +RmpOemhqWmpoa09Gd2lYWDBpZlEiIHRhcmdldD0iX2JsYW5rIj48Yj48c3BhbiBzdHlsZT0iZm9u +dC1zaXplOjEwLjBwdDtmb250LWZhbWlseTomcXVvdDtUYWhvbWEmcXVvdDssJnF1b3Q7c2Fucy1z +ZXJpZiZxdW90Oztjb2xvcjojMDA2Njk5O3RleHQtZGVjb3JhdGlvbjpub25lIj5taWNoYWVsPC9z +cGFuPjwvYj48L2E+PGJyPg0KPHNwYW4gc3R5bGU9ImZvbnQtc2l6ZTo4LjVwdDtmb250LWZhbWls +eTomcXVvdDtUYWhvbWEmcXVvdDssJnF1b3Q7c2Fucy1zZXJpZiZxdW90Oztjb2xvcjojOTk5OTk5 +Ij5PY3RvYmVyIDEzPC9zcGFuPg0KPG86cD48L286cD48L3A+DQo8L3RkPg0KPC90cj4NCjx0cj4N +Cjx0ZCBjb2xzcGFuPSIyIiBzdHlsZT0icGFkZGluZzozLjc1cHQgMGluIDBpbiAwaW4iPg0KPHAg +Y2xhc3M9Ik1zb05vcm1hbCIgc3R5bGU9Im1hcmdpbi1ib3R0b206MTguNzVwdCI+PGEgaHJlZj0i +aHR0cDovL2NsLm9wZW5tcnMub3JnL3RyYWNrL2NsaWNrLzMwMDM5OTA1L3RhbGsub3Blbm1ycy5v +cmc/cD1leUp6SWpvaVVFUklTVU55UjNsVk1EZEJWVmhwV25SM1dXeDRNV05zVFc1Wklpd2lkaUk2 +TVN3aWNDSTZJbnRjSW5WY0lqb3pNREF6T1Rrd05TeGNJblpjSWpveExGd2lkWEpzWENJNlhDSm9k +SFJ3Y3pwY1hGd3ZYRnhjTDNSaGJHc3ViM0JsYm0xeWN5NXZjbWRjWEZ3dmRGeGNYQzlpZFhKblpY +Sm9ZWFZ6TFc1bGR5MXlaWE4wWVhWeVlXNTBMV3gxYm1Ob0xYWmxiblZsWEZ4Y0x6WTNNbHhjWEM4 +elhDSXNYQ0pwWkZ3aU9sd2laRFZpWXpBM1kyUTBOR05rTkRnd1l6aGhPRFl6T1dObFlqVTNPRE5p +WmpaY0lpeGNJblZ5YkY5cFpITmNJanBiWENKaU56WmlZamswWlRGaU56STVaVGsyWlRSbFpXTTRO +R1JtTWpRNE1ETXdZall5WVdZeU1HTTBYQ0pkZlNKOSI+PGI+PHNwYW4gc3R5bGU9ImNvbG9yOiMw +MDY2OTk7dGV4dC1kZWNvcmF0aW9uOm5vbmUiPmh0dHBzOi8vdGFsay5vcGVubXJzLm9yZy90L2J1 +cmdlcmhhdXMtbmV3LXJlc3RhdXJhbnQtbHVuY2gtdmVudWUvNjcyLzM8L3NwYW4+PC9iPjwvYT4N +CjxvOnA+PC9vOnA+PC9wPg0KPHAgc3R5bGU9Im1hcmdpbi10b3A6MGluIj5Mb29rcyBsaWtlIHlv +dXIgcmVwbHktYnktZW1haWwgd2Fzbid0IHByb2Nlc3NlZCBjb3JyZWN0bHkgYnkgb3VyIHNvZnR3 +YXJlLiBDYW4geW91IGxldCBtZSBrbm93IHdoYXQgdmVyc2lvbi9PUyBvZiB3aGF0IGVtYWlsIHBy +b2dyYW0geW91J3JlIHVzaW5nPyBXZSB3aWxsIHdhbnQgdG8gdHJ5IHRvIGZpeCB0aGUgYnVnLiA6 +c21pbGU6PG86cD48L286cD48L3A+DQo8cCBzdHlsZT0ibWFyZ2luLXRvcDowaW4iPlRoYW5rcyE8 +bzpwPjwvbzpwPjwvcD4NCjwvdGQ+DQo8L3RyPg0KPC90Ym9keT4NCjwvdGFibGU+DQo8ZGl2IGNs +YXNzPSJNc29Ob3JtYWwiIGFsaWduPSJjZW50ZXIiIHN0eWxlPSJ0ZXh0LWFsaWduOmNlbnRlciI+ +DQo8aHIgc2l6ZT0iMSIgd2lkdGg9IjEwMCUiIGFsaWduPSJjZW50ZXIiPg0KPC9kaXY+DQo8ZGl2 +Pg0KPHA+PHNwYW4gc3R5bGU9ImNvbG9yOiM2NjY2NjYiPlRvIHJlc3BvbmQsIHJlcGx5IHRvIHRo +aXMgZW1haWwgb3IgdmlzaXQgPGEgaHJlZj0iaHR0cDovL2NsLm9wZW5tcnMub3JnL3RyYWNrL2Ns +aWNrLzMwMDM5OTA1L3RhbGsub3Blbm1ycy5vcmc/cD1leUp6SWpvaWVYaDJWbnBGTUhSMU1uRm5a +RWR1TlhFd01GcFFPVlp0VFZvNElpd2lkaUk2TVN3aWNDSTZJbnRjSW5WY0lqb3pNREF6T1Rrd05T +eGNJblpjSWpveExGd2lkWEpzWENJNlhDSm9kSFJ3Y3pwY1hGd3ZYRnhjTDNSaGJHc3ViM0JsYm0x +eWN5NXZjbWRjWEZ3dmRGeGNYQzk1YjNWeUxYQnZjM1F0YVc0dFluVnlaMlZ5YUdGMWN5MXVaWGN0 +Y21WemRHRjFjbUZ1ZEMxc2RXNWphQzEyWlc1MVpWeGNYQzgyTnpSY1hGd3ZNVndpTEZ3aWFXUmNJ +anBjSW1RMVltTXdOMk5rTkRSalpEUTRNR000WVRnMk16bGpaV0kxTnpnelltWTJYQ0lzWENKMWNt +eGZhV1J6WENJNlcxd2lZamMyWW1JNU5HVXhZamN5T1dVNU5tVTBaV1ZqT0RSa1pqSTBPREF6TUdJ +Mk1tRm1NakJqTkZ3aVhYMGlmUSI+DQo8Yj48c3BhbiBzdHlsZT0iY29sb3I6IzAwNjY5OTt0ZXh0 +LWRlY29yYXRpb246bm9uZSI+aHR0cHM6Ly90YWxrLm9wZW5tcnMub3JnL3QveW91ci1wb3N0LWlu +LWJ1cmdlcmhhdXMtbmV3LXJlc3RhdXJhbnQtbHVuY2gtdmVudWUvNjc0LzE8L3NwYW4+PC9iPjwv +YT4gaW4geW91ciBicm93c2VyLjxvOnA+PC9vOnA+PC9zcGFuPjwvcD4NCjwvZGl2Pg0KPGRpdj4N +CjxwPjxzcGFuIHN0eWxlPSJjb2xvcjojNjY2NjY2Ij5UbyB1bnN1YnNjcmliZSBmcm9tIHRoZXNl +IGVtYWlscywgdmlzaXQgeW91ciA8YSBocmVmPSJodHRwOi8vY2wub3Blbm1ycy5vcmcvdHJhY2sv +Y2xpY2svMzAwMzk5MDUvdGFsay5vcGVubXJzLm9yZz9wPWV5SnpJam9pZFV4dVdsZzVWRmMwT1da +V1MwWTRiRmRMZG1seVdHc3hUVjl6SWl3aWRpSTZNU3dpY0NJNkludGNJblZjSWpvek1EQXpPVGt3 +TlN4Y0luWmNJam94TEZ3aWRYSnNYQ0k2WENKb2RIUndjenBjWEZ3dlhGeGNMM1JoYkdzdWIzQmxi +bTF5Y3k1dmNtZGNYRnd2YlhsY1hGd3ZjSEpsWm1WeVpXNWpaWE5jSWl4Y0ltbGtYQ0k2WENKa05X +SmpNRGRqWkRRMFkyUTBPREJqT0dFNE5qTTVZMlZpTlRjNE0ySm1ObHdpTEZ3aWRYSnNYMmxrYzF3 +aU9sdGNJbUk0TVdVd1pqQTFORFk1TkRNME56Z3lNMkZtTWpBMk5qRmpaamMzWkdOaU4yTmhZemRt +TWpKY0lsMTlJbjAiPg0KPGI+PHNwYW4gc3R5bGU9ImNvbG9yOiMwMDY2OTk7dGV4dC1kZWNvcmF0 +aW9uOm5vbmUiPnVzZXIgcHJlZmVyZW5jZXM8L3NwYW4+PC9iPjwvYT4uPG86cD48L286cD48L3Nw +YW4+PC9wPg0KPC9kaXY+DQo8L2Rpdj4NCjxwIGNsYXNzPSJNc29Ob3JtYWwiPjxpbWcgYm9yZGVy +PSIwIiB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBpZD0iX3gwMDAwX2kxMDI2IiBzcmM9Imh0dHA6Ly9j +bC5vcGVubXJzLm9yZy90cmFjay9vcGVuLnBocD91PTMwMDM5OTA1JmFtcDtpZD1kNWJjMDdjZDQ0 +Y2Q0ODBjOGE4NjM5Y2ViNTc4M2JmNiI+PG86cD48L286cD48L3A+DQo8L2Rpdj4NCjwvYm9keT4N +CjwvaHRtbD4NCg== + +--_000_B0DFE1BEB3739743BC9B639D0E6BC8FF217A6341IUMSSGMBX104ads_-- diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index c706e418d26..15863d444f8 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -57,7 +57,7 @@ describe ApplicationHelper do it 'returns an url for the avatar' do project = create(:project, avatar: File.open(avatar_file_path)) - avatar_url = "http://localhost/uploads/project/avatar/#{project.id}/banana_sample.gif" + avatar_url = "http://#{Gitlab.config.gitlab.host}/uploads/project/avatar/#{project.id}/banana_sample.gif" expect(helper.project_icon("#{project.namespace.to_param}/#{project.to_param}").to_s). to eq "<img src=\"#{avatar_url}\" alt=\"Banana sample\" />" end @@ -67,7 +67,7 @@ describe ApplicationHelper do allow_any_instance_of(Project).to receive(:avatar_in_git).and_return(true) - avatar_url = 'http://localhost' + namespace_project_avatar_path(project.namespace, project) + avatar_url = "http://#{Gitlab.config.gitlab.host}#{namespace_project_avatar_path(project.namespace, project)}" expect(helper.project_icon("#{project.namespace.to_param}/#{project.to_param}").to_s).to match( image_tag(avatar_url)) end diff --git a/spec/helpers/gitlab_markdown_helper_spec.rb b/spec/helpers/gitlab_markdown_helper_spec.rb index 5368e5fab06..1d494edcd3b 100644 --- a/spec/helpers/gitlab_markdown_helper_spec.rb +++ b/spec/helpers/gitlab_markdown_helper_spec.rb @@ -113,7 +113,7 @@ describe GitlabMarkdownHelper do it 'replaces commit message with emoji to link' do actual = link_to_gfm(':book:Book', '/foo') expect(actual). - to eq %Q(<img class="emoji" title=":book:" alt=":book:" src="http://localhost/assets/1F4D6.png" height="20" width="20" align="absmiddle"><a href="/foo">Book</a>) + to eq %Q(<img class="emoji" title=":book:" alt=":book:" src="http://#{Gitlab.config.gitlab.host}/assets/1F4D6.png" height="20" width="20" align="absmiddle"><a href="/foo">Book</a>) end end diff --git a/spec/javascripts/build_spec.js.es6 b/spec/javascripts/build_spec.js.es6 index 370944b6a8c..e21e5844a26 100644 --- a/spec/javascripts/build_spec.js.es6 +++ b/spec/javascripts/build_spec.js.es6 @@ -1,5 +1,7 @@ /* global Build */ /* eslint-disable no-new */ +//= require lib/utils/timeago +//= require lib/utils/datetime_utility //= require build //= require breakpoints //= require jquery.nicescroll @@ -24,7 +26,15 @@ }); describe('setup', function () { + const removeDate = new Date(); + removeDate.setUTCFullYear(removeDate.getUTCFullYear() + 1); + // give the test three days to run + removeDate.setTime(removeDate.getTime() + (3 * 24 * 60 * 60 * 1000)); + beforeEach(function () { + const removeDateElement = document.querySelector('.js-artifacts-remove'); + removeDateElement.innerText = removeDate.toString(); + this.build = new Build(); }); @@ -54,6 +64,11 @@ expect($('.build-job[data-stage="test"]').is(':visible')).toBe(false); expect($('.build-job[data-stage="deploy"]').is(':visible')).toBe(false); }); + + it('displays the remove date correctly', function () { + const removeDateElement = document.querySelector('.js-artifacts-remove'); + expect(removeDateElement.innerText.trim()).toBe('1 year'); + }); }); describe('initial build trace', function () { diff --git a/spec/javascripts/environments/environment_actions_spec.js.es6 b/spec/javascripts/environments/environment_actions_spec.js.es6 new file mode 100644 index 00000000000..c9ac7a73fd0 --- /dev/null +++ b/spec/javascripts/environments/environment_actions_spec.js.es6 @@ -0,0 +1,37 @@ +//= require vue +//= require environments/components/environment_actions + +describe('Actions Component', () => { + fixture.preload('environments/element.html'); + + beforeEach(() => { + fixture.load('environments/element.html'); + }); + + it('Should render a dropdown with the provided actions', () => { + const actionsMock = [ + { + name: 'bar', + play_path: 'https://gitlab.com/play', + }, + { + name: 'foo', + play_path: '#', + }, + ]; + + const component = new window.gl.environmentsList.ActionsComponent({ + el: document.querySelector('.test-dom-element'), + propsData: { + actions: actionsMock, + }, + }); + + expect( + component.$el.querySelectorAll('.dropdown-menu li').length + ).toEqual(actionsMock.length); + expect( + component.$el.querySelector('.dropdown-menu li a').getAttribute('href') + ).toEqual(actionsMock[0].play_path); + }); +}); diff --git a/spec/javascripts/environments/environment_external_url_spec.js.es6 b/spec/javascripts/environments/environment_external_url_spec.js.es6 new file mode 100644 index 00000000000..156506ef28f --- /dev/null +++ b/spec/javascripts/environments/environment_external_url_spec.js.es6 @@ -0,0 +1,22 @@ +//= require vue +//= require environments/components/environment_external_url + +describe('External URL Component', () => { + fixture.preload('environments/element.html'); + beforeEach(() => { + fixture.load('environments/element.html'); + }); + + it('should link to the provided external_url', () => { + const externalURL = 'https://gitlab.com'; + const component = new window.gl.environmentsList.ExternalUrlComponent({ + el: document.querySelector('.test-dom-element'), + propsData: { + external_url: externalURL, + }, + }); + + expect(component.$el.getAttribute('href')).toEqual(externalURL); + expect(component.$el.querySelector('fa-external-link')).toBeDefined(); + }); +}); diff --git a/spec/javascripts/environments/environment_item_spec.js.es6 b/spec/javascripts/environments/environment_item_spec.js.es6 new file mode 100644 index 00000000000..54c93367b17 --- /dev/null +++ b/spec/javascripts/environments/environment_item_spec.js.es6 @@ -0,0 +1,215 @@ +//= require vue +//= require environments/components/environment_item + +describe('Environment item', () => { + fixture.preload('environments/table.html'); + beforeEach(() => { + fixture.load('environments/table.html'); + }); + + describe('When item is folder', () => { + let mockItem; + let component; + + beforeEach(() => { + mockItem = { + name: 'review', + children: [ + { + name: 'review-app', + id: 1, + state: 'available', + external_url: '', + last_deployment: {}, + created_at: '2016-11-07T11:11:16.525Z', + updated_at: '2016-11-10T15:55:58.778Z', + }, + { + name: 'production', + id: 2, + state: 'available', + external_url: '', + last_deployment: {}, + created_at: '2016-11-07T11:11:16.525Z', + updated_at: '2016-11-10T15:55:58.778Z', + }, + ], + }; + + component = new window.gl.environmentsList.EnvironmentItem({ + el: document.querySelector('tr#environment-row'), + propsData: { + model: mockItem, + toggleRow: () => {}, + canCreateDeployment: false, + canReadEnvironment: true, + }, + }); + }); + + it('Should render folder icon and name', () => { + expect(component.$el.querySelector('.folder-name').textContent).toContain(mockItem.name); + expect(component.$el.querySelector('.folder-icon')).toBeDefined(); + }); + + it('Should render the number of children in a badge', () => { + expect(component.$el.querySelector('.folder-name .badge').textContent).toContain(mockItem.children.length); + }); + }); + + describe('when item is not folder', () => { + let environment; + let component; + + beforeEach(() => { + environment = { + id: 31, + name: 'production', + state: 'stopped', + external_url: 'http://external.com', + environment_type: null, + last_deployment: { + id: 66, + iid: 6, + sha: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd', + ref: { + name: 'master', + ref_path: 'root/ci-folders/tree/master', + }, + tag: true, + 'last?': true, + user: { + name: 'Administrator', + username: 'root', + id: 1, + state: 'active', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://localhost:3000/root', + }, + commit: { + id: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd', + short_id: '500aabcb', + title: 'Update .gitlab-ci.yml', + author_name: 'Administrator', + author_email: 'admin@example.com', + created_at: '2016-11-07T18:28:13.000+00:00', + message: 'Update .gitlab-ci.yml', + author: { + name: 'Administrator', + username: 'root', + id: 1, + state: 'active', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://localhost:3000/root', + }, + commit_path: '/root/ci-folders/tree/500aabcb17c97bdcf2d0c410b70cb8556f0362dd', + }, + deployable: { + id: 1279, + name: 'deploy', + build_path: '/root/ci-folders/builds/1279', + retry_path: '/root/ci-folders/builds/1279/retry', + }, + manual_actions: [ + { + name: 'action', + play_path: '/play', + }, + ], + }, + 'stoppable?': true, + environment_path: 'root/ci-folders/environments/31', + created_at: '2016-11-07T11:11:16.525Z', + updated_at: '2016-11-10T15:55:58.778Z', + }; + + component = new window.gl.environmentsList.EnvironmentItem({ + el: document.querySelector('tr#environment-row'), + propsData: { + model: environment, + toggleRow: () => {}, + canCreateDeployment: true, + canReadEnvironment: true, + }, + }); + }); + + it('should render environment name', () => { + expect(component.$el.querySelector('.environment-name').textContent).toEqual(environment.name); + }); + + describe('With deployment', () => { + it('should render deployment internal id', () => { + expect( + component.$el.querySelector('.deployment-column span').textContent + ).toContain(environment.last_deployment.iid); + + expect( + component.$el.querySelector('.deployment-column span').textContent + ).toContain('#'); + }); + + describe('With user information', () => { + it('should render user avatar with link to profile', () => { + expect( + component.$el.querySelector('.js-deploy-user-container').getAttribute('href') + ).toEqual(environment.last_deployment.user.web_url); + }); + }); + + describe('With build url', () => { + it('Should link to build url provided', () => { + expect( + component.$el.querySelector('.build-link').getAttribute('href') + ).toEqual(environment.last_deployment.deployable.build_path); + }); + + it('Should render deployable name and id', () => { + expect( + component.$el.querySelector('.build-link').getAttribute('href') + ).toEqual(environment.last_deployment.deployable.build_path); + }); + }); + + describe('With commit information', () => { + it('should render commit component', () => { + expect( + component.$el.querySelector('.js-commit-component') + ).toBeDefined(); + }); + }); + }); + + describe('With manual actions', () => { + it('Should render actions component', () => { + expect( + component.$el.querySelector('.js-manual-actions-container') + ).toBeDefined(); + }); + }); + + describe('With external URL', () => { + it('should render external url component', () => { + expect( + component.$el.querySelector('.js-external-url-container') + ).toBeDefined(); + }); + }); + + describe('With stop action', () => { + it('Should render stop action component', () => { + expect( + component.$el.querySelector('.js-stop-component-container') + ).toBeDefined(); + }); + }); + + describe('With retry action', () => { + it('Should render rollback component', () => { + expect( + component.$el.querySelector('.js-rollback-component-container') + ).toBeDefined(); + }); + }); + }); +}); diff --git a/spec/javascripts/environments/environment_rollback_spec.js.es6 b/spec/javascripts/environments/environment_rollback_spec.js.es6 new file mode 100644 index 00000000000..29449bbbd9e --- /dev/null +++ b/spec/javascripts/environments/environment_rollback_spec.js.es6 @@ -0,0 +1,48 @@ +//= require vue +//= require environments/components/environment_rollback +describe('Rollback Component', () => { + fixture.preload('environments/element.html'); + + const retryURL = 'https://gitlab.com/retry'; + + beforeEach(() => { + fixture.load('environments/element.html'); + }); + + it('Should link to the provided retry_url', () => { + const component = new window.gl.environmentsList.RollbackComponent({ + el: document.querySelector('.test-dom-element'), + propsData: { + retry_url: retryURL, + is_last_deployment: true, + }, + }); + + expect(component.$el.getAttribute('href')).toEqual(retryURL); + }); + + it('Should render Re-deploy label when is_last_deployment is true', () => { + const component = new window.gl.environmentsList.RollbackComponent({ + el: document.querySelector('.test-dom-element'), + propsData: { + retry_url: retryURL, + is_last_deployment: true, + }, + }); + + expect(component.$el.querySelector('span').textContent).toContain('Re-deploy'); + }); + + + it('Should render Rollback label when is_last_deployment is false', () => { + const component = new window.gl.environmentsList.RollbackComponent({ + el: document.querySelector('.test-dom-element'), + propsData: { + retry_url: retryURL, + is_last_deployment: false, + }, + }); + + expect(component.$el.querySelector('span').textContent).toContain('Rollback'); + }); +}); diff --git a/spec/javascripts/environments/environment_stop_spec.js.es6 b/spec/javascripts/environments/environment_stop_spec.js.es6 new file mode 100644 index 00000000000..b842be4da61 --- /dev/null +++ b/spec/javascripts/environments/environment_stop_spec.js.es6 @@ -0,0 +1,28 @@ +//= require vue +//= require environments/components/environment_stop +describe('Stop Component', () => { + fixture.preload('environments/element.html'); + + let stopURL; + let component; + + beforeEach(() => { + fixture.load('environments/element.html'); + + stopURL = '/stop'; + component = new window.gl.environmentsList.StopComponent({ + el: document.querySelector('.test-dom-element'), + propsData: { + stop_url: stopURL, + }, + }); + }); + + it('should link to the provided URL', () => { + expect(component.$el.getAttribute('href')).toEqual(stopURL); + }); + + it('should have a data-confirm attribute', () => { + expect(component.$el.getAttribute('data-confirm')).toEqual('Are you sure you want to stop this environment?'); + }); +}); diff --git a/spec/javascripts/environments/environments_store_spec.js.es6 b/spec/javascripts/environments/environments_store_spec.js.es6 new file mode 100644 index 00000000000..82d9599f372 --- /dev/null +++ b/spec/javascripts/environments/environments_store_spec.js.es6 @@ -0,0 +1,69 @@ +//= require vue +//= require environments/stores/environments_store +//= require ./mock_data +/* globals environmentsList */ +(() => { + beforeEach(() => { + gl.environmentsList.EnvironmentsStore.create(); + }); + + describe('Store', () => { + it('should start with a blank state', () => { + expect(gl.environmentsList.EnvironmentsStore.state.environments.length).toBe(0); + expect(gl.environmentsList.EnvironmentsStore.state.stoppedCounter).toBe(0); + expect(gl.environmentsList.EnvironmentsStore.state.availableCounter).toBe(0); + }); + + describe('store environments', () => { + beforeEach(() => { + gl.environmentsList.EnvironmentsStore.storeEnvironments(environmentsList); + }); + + it('should count stopped environments and save the count in the state', () => { + expect(gl.environmentsList.EnvironmentsStore.state.stoppedCounter).toBe(1); + }); + + it('should count available environments and save the count in the state', () => { + expect(gl.environmentsList.EnvironmentsStore.state.availableCounter).toBe(3); + }); + + it('should store environments with same environment_type as sibilings', () => { + expect(gl.environmentsList.EnvironmentsStore.state.environments.length).toBe(3); + + const parentFolder = gl.environmentsList.EnvironmentsStore.state.environments + .filter(env => env.children && env.children.length > 0); + + expect(parentFolder[0].children.length).toBe(2); + expect(parentFolder[0].children[0].environment_type).toBe('review'); + expect(parentFolder[0].children[1].environment_type).toBe('review'); + expect(parentFolder[0].children[0].name).toBe('test-environment'); + expect(parentFolder[0].children[1].name).toBe('test-environment-1'); + }); + + it('should sort the environments alphabetically', () => { + const { environments } = gl.environmentsList.EnvironmentsStore.state; + + expect(environments[0].name).toBe('production'); + expect(environments[1].name).toBe('review'); + expect(environments[1].children[0].name).toBe('test-environment'); + expect(environments[1].children[1].name).toBe('test-environment-1'); + expect(environments[2].name).toBe('review_app'); + }); + }); + + describe('toggleFolder', () => { + beforeEach(() => { + gl.environmentsList.EnvironmentsStore.storeEnvironments(environmentsList); + }); + + it('should toggle the open property for the given environment', () => { + gl.environmentsList.EnvironmentsStore.toggleFolder('review'); + + const { environments } = gl.environmentsList.EnvironmentsStore.state; + const environment = environments.filter(env => env['vue-isChildren'] === true && env.name === 'review'); + + expect(environment[0].isOpen).toBe(true); + }); + }); + }); +})(); diff --git a/spec/javascripts/environments/mock_data.js.es6 b/spec/javascripts/environments/mock_data.js.es6 new file mode 100644 index 00000000000..9e16bc3e6a5 --- /dev/null +++ b/spec/javascripts/environments/mock_data.js.es6 @@ -0,0 +1,135 @@ +/* eslint-disable no-unused-vars */ +const environmentsList = [ + { + id: 31, + name: 'production', + state: 'available', + external_url: 'https://www.gitlab.com', + environment_type: null, + last_deployment: { + id: 64, + iid: 5, + sha: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd', + ref: { + name: 'master', + ref_url: 'http://localhost:3000/root/ci-folders/tree/master', + }, + tag: false, + 'last?': true, + user: { + name: 'Administrator', + username: 'root', + id: 1, + state: 'active', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://localhost:3000/root', + }, + commit: { + id: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd', + short_id: '500aabcb', + title: 'Update .gitlab-ci.yml', + author_name: 'Administrator', + author_email: 'admin@example.com', + created_at: '2016-11-07T18:28:13.000+00:00', + message: 'Update .gitlab-ci.yml', + author: { + name: 'Administrator', + username: 'root', + id: 1, + state: 'active', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://localhost:3000/root', + }, + commit_path: '/root/ci-folders/tree/500aabcb17c97bdcf2d0c410b70cb8556f0362dd', + }, + deployable: { + id: 1278, + name: 'build', + build_path: '/root/ci-folders/builds/1278', + retry_path: '/root/ci-folders/builds/1278/retry', + }, + manual_actions: [], + }, + 'stoppable?': true, + environment_path: '/root/ci-folders/environments/31', + created_at: '2016-11-07T11:11:16.525Z', + updated_at: '2016-11-07T11:11:16.525Z', + }, + { + id: 32, + name: 'review_app', + state: 'stopped', + external_url: 'https://www.gitlab.com', + environment_type: null, + last_deployment: { + id: 64, + iid: 5, + sha: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd', + ref: { + name: 'master', + ref_url: 'http://localhost:3000/root/ci-folders/tree/master', + }, + tag: false, + 'last?': true, + user: { + name: 'Administrator', + username: 'root', + id: 1, + state: 'active', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://localhost:3000/root', + }, + commit: { + id: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd', + short_id: '500aabcb', + title: 'Update .gitlab-ci.yml', + author_name: 'Administrator', + author_email: 'admin@example.com', + created_at: '2016-11-07T18:28:13.000+00:00', + message: 'Update .gitlab-ci.yml', + author: { + name: 'Administrator', + username: 'root', + id: 1, + state: 'active', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://localhost:3000/root', + }, + commit_path: '/root/ci-folders/tree/500aabcb17c97bdcf2d0c410b70cb8556f0362dd', + }, + deployable: { + id: 1278, + name: 'build', + build_path: '/root/ci-folders/builds/1278', + retry_path: '/root/ci-folders/builds/1278/retry', + }, + manual_actions: [], + }, + 'stoppable?': false, + environment_path: '/root/ci-folders/environments/31', + created_at: '2016-11-07T11:11:16.525Z', + updated_at: '2016-11-07T11:11:16.525Z', + }, + { + id: 33, + name: 'test-environment', + state: 'available', + environment_type: 'review', + last_deployment: null, + 'stoppable?': true, + environment_path: '/root/ci-folders/environments/31', + created_at: '2016-11-07T11:11:16.525Z', + updated_at: '2016-11-07T11:11:16.525Z', + }, + { + id: 34, + name: 'test-environment-1', + state: 'available', + environment_type: 'review', + last_deployment: null, + 'stoppable?': true, + environment_path: '/root/ci-folders/environments/31', + created_at: '2016-11-07T11:11:16.525Z', + updated_at: '2016-11-07T11:11:16.525Z', + }, +]; diff --git a/spec/javascripts/fixtures/build.html.haml b/spec/javascripts/fixtures/build.html.haml index a2bc81c6be7..27136beb14c 100644 --- a/spec/javascripts/fixtures/build.html.haml +++ b/spec/javascripts/fixtures/build.html.haml @@ -55,3 +55,8 @@ build_status: 'passed', build_stage: 'test', state1: 'buildstate' }} + +%p.build-detail-row + The artifacts will be removed in + %span.js-artifacts-remove + 2016-12-19 09:02:12 UTC diff --git a/spec/javascripts/fixtures/environments/element.html.haml b/spec/javascripts/fixtures/environments/element.html.haml new file mode 100644 index 00000000000..8d7aeb23356 --- /dev/null +++ b/spec/javascripts/fixtures/environments/element.html.haml @@ -0,0 +1 @@ +.test-dom-element diff --git a/spec/javascripts/fixtures/environments/environments.html.haml b/spec/javascripts/fixtures/environments/environments.html.haml new file mode 100644 index 00000000000..d89bc50c1f0 --- /dev/null +++ b/spec/javascripts/fixtures/environments/environments.html.haml @@ -0,0 +1,9 @@ +%div + #environments-list-view{ data: { environments_data: "https://gitlab.com/foo/environments", + "can-create-deployment" => "true", + "can-read-environment" => "true", + "can-create-environment" => "true", + "project-environments-path" => "https://gitlab.com/foo/environments", + "project-stopped-environments-path" => "https://gitlab.com/foo/environments?scope=stopped", + "new-environment-path" => "https://gitlab.com/foo/environments/new", + "help-page-path" => "https://gitlab.com/help_page"}} diff --git a/spec/javascripts/fixtures/environments/table.html.haml b/spec/javascripts/fixtures/environments/table.html.haml new file mode 100644 index 00000000000..1ea1725c561 --- /dev/null +++ b/spec/javascripts/fixtures/environments/table.html.haml @@ -0,0 +1,11 @@ +%table + %thead + %tr + %th Environment + %th Last deployment + %th Build + %th Commit + %th + %th + %tbody + %tr#environment-row diff --git a/spec/javascripts/merge_request_widget_spec.js b/spec/javascripts/merge_request_widget_spec.js index 8c5afc2ff3c..575b87e6f17 100644 --- a/spec/javascripts/merge_request_widget_spec.js +++ b/spec/javascripts/merge_request_widget_spec.js @@ -35,9 +35,9 @@ external_url_formatted: 'test-url.com' }]; - spyOn(jQuery, 'getJSON').and.callFake((req, cb) => { + spyOn(jQuery, 'getJSON').and.callFake(function(req, cb) { cb(this.ciEnvironmentsStatusData); - }); + }.bind(this)); }); it('should call renderEnvironments when the environments property is set', function() { diff --git a/spec/javascripts/vue_common_components/commit_spec.js.es6 b/spec/javascripts/vue_common_components/commit_spec.js.es6 new file mode 100644 index 00000000000..0e3b82967c1 --- /dev/null +++ b/spec/javascripts/vue_common_components/commit_spec.js.es6 @@ -0,0 +1,126 @@ +//= require vue_common_component/commit + +describe('Commit component', () => { + let props; + let component; + + it('should render a code-fork icon if it does not represent a tag', () => { + fixture.set('<div class="test-commit-container"></div>'); + component = new window.gl.CommitComponent({ + el: document.querySelector('.test-commit-container'), + propsData: { + tag: false, + ref: { + name: 'master', + ref_url: 'http://localhost/namespace2/gitlabhq/tree/master', + }, + commit_url: 'https://gitlab.com/gitlab-org/gitlab-ce/commit/b7836eddf62d663c665769e1b0960197fd215067', + short_sha: 'b7836edd', + title: 'Commit message', + author: { + avatar_url: 'https://gitlab.com/uploads/user/avatar/300478/avatar.png', + web_url: 'https://gitlab.com/jschatz1', + username: 'jschatz1', + }, + }, + }); + + expect(component.$el.querySelector('.icon-container i').classList).toContain('fa-code-fork'); + }); + + describe('Given all the props', () => { + beforeEach(() => { + fixture.set('<div class="test-commit-container"></div>'); + + props = { + tag: true, + ref: { + name: 'master', + ref_url: 'http://localhost/namespace2/gitlabhq/tree/master', + }, + commit_url: 'https://gitlab.com/gitlab-org/gitlab-ce/commit/b7836eddf62d663c665769e1b0960197fd215067', + short_sha: 'b7836edd', + title: 'Commit message', + author: { + avatar_url: 'https://gitlab.com/uploads/user/avatar/300478/avatar.png', + web_url: 'https://gitlab.com/jschatz1', + username: 'jschatz1', + }, + }; + + component = new window.gl.CommitComponent({ + el: document.querySelector('.test-commit-container'), + propsData: props, + }); + }); + + it('should render a tag icon if it represents a tag', () => { + expect(component.$el.querySelector('.icon-container i').classList).toContain('fa-tag'); + }); + + it('should render a link to the ref url', () => { + expect(component.$el.querySelector('.branch-name').getAttribute('href')).toEqual(props.ref.ref_url); + }); + + it('should render the ref name', () => { + expect(component.$el.querySelector('.branch-name').textContent).toContain(props.ref.name); + }); + + it('should render the commit short sha with a link to the commit url', () => { + expect(component.$el.querySelector('.commit-id').getAttribute('href')).toEqual(props.commit_url); + expect(component.$el.querySelector('.commit-id').textContent).toContain(props.short_sha); + }); + + describe('Given commit title and author props', () => { + it('Should render a link to the author profile', () => { + expect( + component.$el.querySelector('.commit-title .avatar-image-container').getAttribute('href') + ).toEqual(props.author.web_url); + }); + + it('Should render the author avatar with title and alt attributes', () => { + expect( + component.$el.querySelector('.commit-title .avatar-image-container img').getAttribute('title') + ).toContain(props.author.username); + expect( + component.$el.querySelector('.commit-title .avatar-image-container img').getAttribute('alt') + ).toContain(`${props.author.username}'s avatar`); + }); + }); + + it('should render the commit title', () => { + expect( + component.$el.querySelector('a.commit-row-message').getAttribute('href') + ).toEqual(props.commit_url); + expect( + component.$el.querySelector('a.commit-row-message').textContent + ).toContain(props.title); + }); + }); + + describe('When commit title is not provided', () => { + it('Should render default message', () => { + fixture.set('<div class="test-commit-container"></div>'); + props = { + tag: false, + ref: { + name: 'master', + ref_url: 'http://localhost/namespace2/gitlabhq/tree/master', + }, + commit_url: 'https://gitlab.com/gitlab-org/gitlab-ce/commit/b7836eddf62d663c665769e1b0960197fd215067', + short_sha: 'b7836edd', + title: null, + author: {}, + }; + + component = new window.gl.CommitComponent({ + el: document.querySelector('.test-commit-container'), + propsData: props, + }); + + expect( + component.$el.querySelector('.commit-title span').textContent + ).toContain('Cant find HEAD commit for this branch'); + }); + }); +}); diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index 84f21631719..ff5dcc06ab3 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -1124,8 +1124,8 @@ EOT end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:extra config should be a hash") end - it "returns errors if there are unknown parameters that are hashes, but doesn't have a script" do - config = YAML.dump({ extra: { services: "test" } }) + it "returns errors if services configuration is not correct" do + config = YAML.dump({ extra: { script: 'rspec', services: "test" } }) expect do GitlabCiYamlProcessor.new(config, path) end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:extra:services config should be an array of strings") diff --git a/spec/lib/gitlab/chat_commands/command_spec.rb b/spec/lib/gitlab/chat_commands/command_spec.rb new file mode 100644 index 00000000000..8cedbb0240f --- /dev/null +++ b/spec/lib/gitlab/chat_commands/command_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' + +describe Gitlab::ChatCommands::Command, service: true do + let(:project) { create(:empty_project) } + let(:user) { create(:user) } + + subject { described_class.new(project, user, params).execute } + + describe '#execute' do + context 'when no command is available' do + let(:params) { { text: 'issue show 1' } } + let(:project) { create(:project, has_external_issue_tracker: true) } + + it 'displays 404 messages' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to start_with('404 not found') + end + end + + context 'when an unknown command is triggered' do + let(:params) { { command: '/gitlab', text: "unknown command 123" } } + + it 'displays the help message' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to start_with('Available commands') + expect(subject[:text]).to match('/gitlab issue show') + end + end + + context 'the user can not create an issue' do + let(:params) { { text: "issue create my new issue" } } + + it 'rejects the actions' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to start_with('Whoops! That action is not allowed') + end + end + + context 'issue is successfully created' do + let(:params) { { text: "issue create my new issue" } } + + before do + project.team << [user, :master] + end + + it 'presents the issue' do + expect(subject[:text]).to match("my new issue") + end + + it 'shows a link to the new issue' do + expect(subject[:text]).to match(/\/issues\/\d+/) + end + end + end +end diff --git a/spec/lib/gitlab/chat_commands/issue_create_spec.rb b/spec/lib/gitlab/chat_commands/issue_create_spec.rb new file mode 100644 index 00000000000..df0c317ccea --- /dev/null +++ b/spec/lib/gitlab/chat_commands/issue_create_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +describe Gitlab::ChatCommands::IssueCreate, service: true do + describe '#execute' do + let(:project) { create(:empty_project) } + let(:user) { create(:user) } + let(:regex_match) { described_class.match("issue create bird is the word") } + + before do + project.team << [user, :master] + end + + subject do + described_class.new(project, user).execute(regex_match) + end + + context 'without description' do + it 'creates the issue' do + expect { subject }.to change { project.issues.count }.by(1) + + expect(subject.title).to eq('bird is the word') + end + end + + context 'with description' do + let(:description) { "Surfin bird" } + let(:regex_match) { described_class.match("issue create bird is the word\n#{description}") } + + it 'creates the issue with description' do + subject + + expect(Issue.last.description).to eq(description) + end + end + end + + describe '.match' do + it 'matches the title without description' do + match = described_class.match("issue create my title") + + expect(match[:title]).to eq('my title') + expect(match[:description]).to eq("") + end + + it 'matches the title with description' do + match = described_class.match("issue create my title\n\ndescription") + + expect(match[:title]).to eq('my title') + expect(match[:description]).to eq('description') + end + end +end diff --git a/spec/lib/gitlab/chat_commands/issue_show_spec.rb b/spec/lib/gitlab/chat_commands/issue_show_spec.rb new file mode 100644 index 00000000000..331a4604e9b --- /dev/null +++ b/spec/lib/gitlab/chat_commands/issue_show_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe Gitlab::ChatCommands::IssueShow, service: true do + describe '#execute' do + let(:issue) { create(:issue) } + let(:project) { issue.project } + let(:user) { issue.author } + let(:regex_match) { described_class.match("issue show #{issue.iid}") } + + before do + project.team << [user, :master] + end + + subject do + described_class.new(project, user).execute(regex_match) + end + + context 'the issue exists' do + it 'returns the issue' do + expect(subject.iid).to be issue.iid + end + end + + context 'the issue does not exist' do + let(:regex_match) { described_class.match("issue show 2343242") } + + it "returns nil" do + expect(subject).to be_nil + end + end + end + + describe 'self.match' do + it 'matches the iid' do + match = described_class.match("issue show 123") + + expect(match[:iid]).to eq("123") + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/global_spec.rb b/spec/lib/gitlab/ci/config/entry/global_spec.rb index c7726adfd27..5e5c5dcc385 100644 --- a/spec/lib/gitlab/ci/config/entry/global_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/global_spec.rb @@ -60,9 +60,9 @@ describe Gitlab::Ci::Config::Entry::Global do end context 'when not composed' do - describe '#before_script' do + describe '#before_script_value' do it 'returns nil' do - expect(global.before_script).to be nil + expect(global.before_script_value).to be nil end end @@ -82,40 +82,40 @@ describe Gitlab::Ci::Config::Entry::Global do end end - describe '#before_script' do + describe '#before_script_value' do it 'returns correct script' do - expect(global.before_script).to eq ['ls', 'pwd'] + expect(global.before_script_value).to eq ['ls', 'pwd'] end end - describe '#image' do + describe '#image_value' do it 'returns valid image' do - expect(global.image).to eq 'ruby:2.2' + expect(global.image_value).to eq 'ruby:2.2' end end - describe '#services' do + describe '#services_value' do it 'returns array of services' do - expect(global.services).to eq ['postgres:9.1', 'mysql:5.5'] + expect(global.services_value).to eq ['postgres:9.1', 'mysql:5.5'] end end - describe '#after_script' do + describe '#after_script_value' do it 'returns after script' do - expect(global.after_script).to eq ['make clean'] + expect(global.after_script_value).to eq ['make clean'] end end - describe '#variables' do + describe '#variables_value' do it 'returns variables' do - expect(global.variables).to eq(VAR: 'value') + expect(global.variables_value).to eq(VAR: 'value') end end - describe '#stages' do + describe '#stages_value' do context 'when stages key defined' do it 'returns array of stages' do - expect(global.stages).to eq %w[build pages] + expect(global.stages_value).to eq %w[build pages] end end @@ -126,21 +126,21 @@ describe Gitlab::Ci::Config::Entry::Global do end it 'returns array of types as stages' do - expect(global.stages).to eq %w[test deploy] + expect(global.stages_value).to eq %w[test deploy] end end end - describe '#cache' do + describe '#cache_value' do it 'returns cache configuration' do - expect(global.cache) + expect(global.cache_value) .to eq(key: 'k', untracked: true, paths: ['public/']) end end - describe '#jobs' do + describe '#jobs_value' do it 'returns jobs configuration' do - expect(global.jobs).to eq( + expect(global.jobs_value).to eq( rspec: { name: :rspec, script: %w[rspec ls], before_script: ['ls', 'pwd'], @@ -185,21 +185,21 @@ describe Gitlab::Ci::Config::Entry::Global do end end - describe '#variables' do + describe '#variables_value' do it 'returns default value for variables' do - expect(global.variables).to eq({}) + expect(global.variables_value).to eq({}) end end - describe '#stages' do + describe '#stages_value' do it 'returns an array of default stages' do - expect(global.stages).to eq %w[build test deploy] + expect(global.stages_value).to eq %w[build test deploy] end end - describe '#cache' do + describe '#cache_value' do it 'returns correct cache definition' do - expect(global.cache).to eq(key: 'a') + expect(global.cache_value).to eq(key: 'a') end end end @@ -217,9 +217,9 @@ describe Gitlab::Ci::Config::Entry::Global do { variables: nil, rspec: { script: 'rspec' } } end - describe '#variables' do + describe '#variables_value' do it 'undefined entry returns a default value' do - expect(global.variables).to eq({}) + expect(global.variables_value).to eq({}) end end end @@ -245,9 +245,9 @@ describe Gitlab::Ci::Config::Entry::Global do end end - describe '#before_script' do + describe '#before_script_value' do it 'returns nil' do - expect(global.before_script).to be_nil + expect(global.before_script_value).to be_nil end end end diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb index c05711b6338..fc9b8b86dc4 100644 --- a/spec/lib/gitlab/ci/config/entry/job_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb @@ -19,8 +19,7 @@ describe Gitlab::Ci::Config::Entry::Job do let(:entry) { described_class.new(config, name: ''.to_sym) } it 'reports error' do - expect(entry.errors) - .to include "job name can't be blank" + expect(entry.errors).to include "job name can't be blank" end end end @@ -56,6 +55,15 @@ describe Gitlab::Ci::Config::Entry::Job do end end end + + context 'when script is not provided' do + let(:config) { { stage: 'test' } } + + it 'returns error about missing script entry' do + expect(entry).not_to be_valid + expect(entry.errors).to include "job script can't be blank" + end + end end end @@ -78,7 +86,7 @@ describe Gitlab::Ci::Config::Entry::Job do before { entry.compose!(deps) } let(:config) do - { image: 'some_image', cache: { key: 'test' } } + { script: 'rspec', image: 'some_image', cache: { key: 'test' } } end it 'overrides global config' do diff --git a/spec/lib/gitlab/cycle_analytics/code_event_spec.rb b/spec/lib/gitlab/cycle_analytics/code_event_spec.rb new file mode 100644 index 00000000000..43f42d1bde8 --- /dev/null +++ b/spec/lib/gitlab/cycle_analytics/code_event_spec.rb @@ -0,0 +1,10 @@ +require 'spec_helper' +require 'lib/gitlab/cycle_analytics/shared_event_spec' + +describe Gitlab::CycleAnalytics::CodeEvent do + it_behaves_like 'default query config' do + it 'does not have the default order' do + expect(event.order).not_to eq(event.start_time_attrs) + end + end +end diff --git a/spec/lib/gitlab/cycle_analytics/events_spec.rb b/spec/lib/gitlab/cycle_analytics/events_spec.rb new file mode 100644 index 00000000000..9aeaa6b3ee8 --- /dev/null +++ b/spec/lib/gitlab/cycle_analytics/events_spec.rb @@ -0,0 +1,326 @@ +require 'spec_helper' + +describe Gitlab::CycleAnalytics::Events do + let(:project) { create(:project) } + let(:from_date) { 10.days.ago } + let(:user) { create(:user, :admin) } + let!(:context) { create(:issue, project: project, created_at: 2.days.ago) } + + subject { described_class.new(project: project, options: { from: from_date, current_user: user }) } + + before do + allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([context]) + + setup(context) + end + + describe '#issue_events' do + it 'has the total time' do + expect(subject.issue_events.first[:total_time]).not_to be_empty + end + + it 'has a title' do + expect(subject.issue_events.first[:title]).to eq(context.title) + end + + it 'has the URL' do + expect(subject.issue_events.first[:url]).not_to be_nil + end + + it 'has an iid' do + expect(subject.issue_events.first[:iid]).to eq(context.iid.to_s) + end + + it 'has a created_at timestamp' do + expect(subject.issue_events.first[:created_at]).to end_with('ago') + end + + it "has the author's URL" do + expect(subject.issue_events.first[:author][:web_url]).not_to be_nil + end + + it "has the author's avatar URL" do + expect(subject.issue_events.first[:author][:avatar_url]).not_to be_nil + end + + it "has the author's name" do + expect(subject.issue_events.first[:author][:name]).to eq(context.author.name) + end + end + + describe '#plan_events' do + it 'has a title' do + expect(subject.plan_events.first[:title]).not_to be_nil + end + + it 'has a sha short ID' do + expect(subject.plan_events.first[:short_sha]).not_to be_nil + end + + it 'has the URL' do + expect(subject.plan_events.first[:commit_url]).not_to be_nil + end + + it 'has the total time' do + expect(subject.plan_events.first[:total_time]).not_to be_empty + end + + it "has the author's URL" do + expect(subject.plan_events.first[:author][:web_url]).not_to be_nil + end + + it "has the author's avatar URL" do + expect(subject.plan_events.first[:author][:avatar_url]).not_to be_nil + end + + it "has the author's name" do + expect(subject.plan_events.first[:author][:name]).not_to be_nil + end + end + + describe '#code_events' do + before do + create_commit_referencing_issue(context) + end + + it 'has the total time' do + expect(subject.code_events.first[:total_time]).not_to be_empty + end + + it 'has a title' do + expect(subject.code_events.first[:title]).to eq('Awesome merge_request') + end + + it 'has an iid' do + expect(subject.code_events.first[:iid]).to eq(context.iid.to_s) + end + + it 'has a created_at timestamp' do + expect(subject.code_events.first[:created_at]).to end_with('ago') + end + + it "has the author's URL" do + expect(subject.code_events.first[:author][:web_url]).not_to be_nil + end + + it "has the author's avatar URL" do + expect(subject.code_events.first[:author][:avatar_url]).not_to be_nil + end + + it "has the author's name" do + expect(subject.code_events.first[:author][:name]).to eq(MergeRequest.first.author.name) + end + end + + describe '#test_events' do + let(:merge_request) { MergeRequest.first } + let!(:pipeline) do + create(:ci_pipeline, + ref: merge_request.source_branch, + sha: merge_request.diff_head_sha, + project: context.project) + end + + before do + create(:ci_build, pipeline: pipeline, status: :success, author: user) + create(:ci_build, pipeline: pipeline, status: :success, author: user) + + pipeline.run! + pipeline.succeed! + end + + it 'has the name' do + expect(subject.test_events.first[:name]).not_to be_nil + end + + it 'has the ID' do + expect(subject.test_events.first[:id]).not_to be_nil + end + + it 'has the URL' do + expect(subject.test_events.first[:url]).not_to be_nil + end + + it 'has the branch name' do + expect(subject.test_events.first[:branch]).not_to be_nil + end + + it 'has the branch URL' do + expect(subject.test_events.first[:branch][:url]).not_to be_nil + end + + it 'has the short SHA' do + expect(subject.test_events.first[:short_sha]).not_to be_nil + end + + it 'has the commit URL' do + expect(subject.test_events.first[:commit_url]).not_to be_nil + end + + it 'has the date' do + expect(subject.test_events.first[:date]).not_to be_nil + end + + it 'has the total time' do + expect(subject.test_events.first[:total_time]).not_to be_empty + end + end + + describe '#review_events' do + let!(:context) { create(:issue, project: project, created_at: 2.days.ago) } + + it 'has the total time' do + expect(subject.review_events.first[:total_time]).not_to be_empty + end + + it 'has a title' do + expect(subject.review_events.first[:title]).to eq('Awesome merge_request') + end + + it 'has an iid' do + expect(subject.review_events.first[:iid]).to eq(context.iid.to_s) + end + + it 'has the URL' do + expect(subject.review_events.first[:url]).not_to be_nil + end + + it 'has a state' do + expect(subject.review_events.first[:state]).not_to be_nil + end + + it 'has a created_at timestamp' do + expect(subject.review_events.first[:created_at]).not_to be_nil + end + + it "has the author's URL" do + expect(subject.review_events.first[:author][:web_url]).not_to be_nil + end + + it "has the author's avatar URL" do + expect(subject.review_events.first[:author][:avatar_url]).not_to be_nil + end + + it "has the author's name" do + expect(subject.review_events.first[:author][:name]).to eq(MergeRequest.first.author.name) + end + end + + describe '#staging_events' do + let(:merge_request) { MergeRequest.first } + let!(:pipeline) do + create(:ci_pipeline, + ref: merge_request.source_branch, + sha: merge_request.diff_head_sha, + project: context.project) + end + + before do + create(:ci_build, pipeline: pipeline, status: :success, author: user) + create(:ci_build, pipeline: pipeline, status: :success, author: user) + + pipeline.run! + pipeline.succeed! + + merge_merge_requests_closing_issue(context) + deploy_master + end + + it 'has the name' do + expect(subject.staging_events.first[:name]).not_to be_nil + end + + it 'has the ID' do + expect(subject.staging_events.first[:id]).not_to be_nil + end + + it 'has the URL' do + expect(subject.staging_events.first[:url]).not_to be_nil + end + + it 'has the branch name' do + expect(subject.staging_events.first[:branch]).not_to be_nil + end + + it 'has the branch URL' do + expect(subject.staging_events.first[:branch][:url]).not_to be_nil + end + + it 'has the short SHA' do + expect(subject.staging_events.first[:short_sha]).not_to be_nil + end + + it 'has the commit URL' do + expect(subject.staging_events.first[:commit_url]).not_to be_nil + end + + it 'has the date' do + expect(subject.staging_events.first[:date]).not_to be_nil + end + + it 'has the total time' do + expect(subject.staging_events.first[:total_time]).not_to be_empty + end + + it "has the author's URL" do + expect(subject.staging_events.first[:author][:web_url]).not_to be_nil + end + + it "has the author's avatar URL" do + expect(subject.staging_events.first[:author][:avatar_url]).not_to be_nil + end + + it "has the author's name" do + expect(subject.staging_events.first[:author][:name]).to eq(MergeRequest.first.author.name) + end + end + + describe '#production_events' do + let!(:context) { create(:issue, project: project, created_at: 2.days.ago) } + + before do + merge_merge_requests_closing_issue(context) + deploy_master + end + + it 'has the total time' do + expect(subject.production_events.first[:total_time]).not_to be_empty + end + + it 'has a title' do + expect(subject.production_events.first[:title]).to eq(context.title) + end + + it 'has the URL' do + expect(subject.production_events.first[:url]).not_to be_nil + end + + it 'has an iid' do + expect(subject.production_events.first[:iid]).to eq(context.iid.to_s) + end + + it 'has a created_at timestamp' do + expect(subject.production_events.first[:created_at]).to end_with('ago') + end + + it "has the author's URL" do + expect(subject.production_events.first[:author][:web_url]).not_to be_nil + end + + it "has the author's avatar URL" do + expect(subject.production_events.first[:author][:avatar_url]).not_to be_nil + end + + it "has the author's name" do + expect(subject.production_events.first[:author][:name]).to eq(context.author.name) + end + end + + def setup(context) + milestone = create(:milestone, project: project) + context.update(milestone: milestone) + mr = create_merge_request_closing_issue(context) + + ProcessCommitWorker.new.perform(project.id, user.id, mr.commits.last.sha) + end +end diff --git a/spec/lib/gitlab/cycle_analytics/issue_event_spec.rb b/spec/lib/gitlab/cycle_analytics/issue_event_spec.rb new file mode 100644 index 00000000000..1c5c308da7d --- /dev/null +++ b/spec/lib/gitlab/cycle_analytics/issue_event_spec.rb @@ -0,0 +1,10 @@ +require 'spec_helper' +require 'lib/gitlab/cycle_analytics/shared_event_spec' + +describe Gitlab::CycleAnalytics::IssueEvent do + it_behaves_like 'default query config' do + it 'has the default order' do + expect(event.order).to eq(event.start_time_attrs) + end + end +end diff --git a/spec/lib/gitlab/cycle_analytics/plan_event_spec.rb b/spec/lib/gitlab/cycle_analytics/plan_event_spec.rb new file mode 100644 index 00000000000..d76a255acf5 --- /dev/null +++ b/spec/lib/gitlab/cycle_analytics/plan_event_spec.rb @@ -0,0 +1,10 @@ +require 'spec_helper' +require 'lib/gitlab/cycle_analytics/shared_event_spec' + +describe Gitlab::CycleAnalytics::PlanEvent do + it_behaves_like 'default query config' do + it 'has the default order' do + expect(event.order).to eq(event.start_time_attrs) + end + end +end diff --git a/spec/lib/gitlab/cycle_analytics/production_event_spec.rb b/spec/lib/gitlab/cycle_analytics/production_event_spec.rb new file mode 100644 index 00000000000..ac17e3b4287 --- /dev/null +++ b/spec/lib/gitlab/cycle_analytics/production_event_spec.rb @@ -0,0 +1,10 @@ +require 'spec_helper' +require 'lib/gitlab/cycle_analytics/shared_event_spec' + +describe Gitlab::CycleAnalytics::ProductionEvent do + it_behaves_like 'default query config' do + it 'has the default order' do + expect(event.order).to eq(event.start_time_attrs) + end + end +end diff --git a/spec/lib/gitlab/cycle_analytics/review_event_spec.rb b/spec/lib/gitlab/cycle_analytics/review_event_spec.rb new file mode 100644 index 00000000000..1ff53aa0227 --- /dev/null +++ b/spec/lib/gitlab/cycle_analytics/review_event_spec.rb @@ -0,0 +1,10 @@ +require 'spec_helper' +require 'lib/gitlab/cycle_analytics/shared_event_spec' + +describe Gitlab::CycleAnalytics::ReviewEvent do + it_behaves_like 'default query config' do + it 'has the default order' do + expect(event.order).to eq(event.start_time_attrs) + end + end +end diff --git a/spec/lib/gitlab/cycle_analytics/shared_event_spec.rb b/spec/lib/gitlab/cycle_analytics/shared_event_spec.rb new file mode 100644 index 00000000000..7019e4c3351 --- /dev/null +++ b/spec/lib/gitlab/cycle_analytics/shared_event_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +shared_examples 'default query config' do + let(:event) { described_class.new(project: double, options: {}) } + + it 'has the start attributes' do + expect(event.start_time_attrs).not_to be_nil + end + + it 'has the stage attribute' do + expect(event.stage).not_to be_nil + end + + it 'has the end attributes' do + expect(event.end_time_attrs).not_to be_nil + end + + it 'has the projection attributes' do + expect(event.projections).not_to be_nil + end +end diff --git a/spec/lib/gitlab/cycle_analytics/staging_event_spec.rb b/spec/lib/gitlab/cycle_analytics/staging_event_spec.rb new file mode 100644 index 00000000000..4862d4765f2 --- /dev/null +++ b/spec/lib/gitlab/cycle_analytics/staging_event_spec.rb @@ -0,0 +1,10 @@ +require 'spec_helper' +require 'lib/gitlab/cycle_analytics/shared_event_spec' + +describe Gitlab::CycleAnalytics::StagingEvent do + it_behaves_like 'default query config' do + it 'does not have the default order' do + expect(event.order).not_to eq(event.start_time_attrs) + end + end +end diff --git a/spec/lib/gitlab/cycle_analytics/test_event_spec.rb b/spec/lib/gitlab/cycle_analytics/test_event_spec.rb new file mode 100644 index 00000000000..e249db69fc6 --- /dev/null +++ b/spec/lib/gitlab/cycle_analytics/test_event_spec.rb @@ -0,0 +1,10 @@ +require 'spec_helper' +require 'lib/gitlab/cycle_analytics/shared_event_spec' + +describe Gitlab::CycleAnalytics::TestEvent do + it_behaves_like 'default query config' do + it 'does not have the default order' do + expect(event.order).not_to eq(event.start_time_attrs) + end + end +end diff --git a/spec/lib/gitlab/cycle_analytics/updater_spec.rb b/spec/lib/gitlab/cycle_analytics/updater_spec.rb new file mode 100644 index 00000000000..eff54cd3692 --- /dev/null +++ b/spec/lib/gitlab/cycle_analytics/updater_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe Gitlab::CycleAnalytics::Updater do + describe 'updates authors' do + let(:user) { create(:user) } + let(:events) { [{ 'author_id' => user.id }] } + + it 'maps the correct user' do + described_class.update!(events, from: 'author_id', to: 'author', klass: User) + + expect(events.first['author']).to eq(user) + end + end + + describe 'updates builds' do + let(:build) { create(:ci_build) } + let(:events) { [{ 'id' => build.id }] } + + it 'maps the correct build' do + described_class.update!(events, from: 'id', to: 'build', klass: ::Ci::Build) + + expect(events.first['build']).to eq(build) + end + end +end diff --git a/spec/lib/gitlab/email/reply_parser_spec.rb b/spec/lib/gitlab/email/reply_parser_spec.rb index 6f8e9a4be64..c7a0139d32a 100644 --- a/spec/lib/gitlab/email/reply_parser_spec.rb +++ b/spec/lib/gitlab/email/reply_parser_spec.rb @@ -206,5 +206,9 @@ describe Gitlab::Email::ReplyParser, lib: true do it "properly renders email reply from MS Outlook client" do expect(test_parse_body(fixture_file("emails/outlook.eml"))).to eq("Microsoft Outlook 2010") end + + it "properly renders html-only email from MS Outlook" do + expect(test_parse_body(fixture_file("emails/outlook_html.eml"))).to eq("Microsoft Outlook 2010") + end end end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 02b11bd999a..fe3c39e38db 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -116,6 +116,7 @@ project: - base_tags - tag_taggings - tags +- chat_services - creator - group - namespace @@ -127,6 +128,7 @@ project: - emails_on_push_service - builds_email_service - pipelines_email_service +- mattermost_slash_commands_service - irker_service - pivotaltracker_service - hipchat_service @@ -188,4 +190,4 @@ award_emoji: - awardable - user priorities: -- label \ No newline at end of file +- label diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 07a2c316899..d6807941b31 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -258,6 +258,7 @@ Service: - template - push_events - issues_events +- commit_events - merge_requests_events - tag_push_events - note_events @@ -339,4 +340,4 @@ LabelPriority: - label_id - priority - created_at -- updated_at \ No newline at end of file +- updated_at diff --git a/spec/lib/gitlab/middleware/go_spec.rb b/spec/lib/gitlab/middleware/go_spec.rb index 117a15264da..fd3769d75b5 100644 --- a/spec/lib/gitlab/middleware/go_spec.rb +++ b/spec/lib/gitlab/middleware/go_spec.rb @@ -22,7 +22,7 @@ describe Gitlab::Middleware::Go, lib: true do resp = middleware.call(env) expect(resp[0]).to eq(200) expect(resp[1]['Content-Type']).to eq('text/html') - expected_body = "<!DOCTYPE html><html><head><meta content='localhost/group/project git http://localhost/group/project.git' name='go-import'></head></html>\n" + expected_body = "<!DOCTYPE html><html><head><meta content='#{Gitlab.config.gitlab.host}/group/project git http://#{Gitlab.config.gitlab.host}/group/project.git' name='go-import'></head></html>\n" expect(resp[2].body).to eq([expected_body]) end end diff --git a/spec/lib/light_url_builder_spec.rb b/spec/lib/light_url_builder_spec.rb new file mode 100644 index 00000000000..a826b24419a --- /dev/null +++ b/spec/lib/light_url_builder_spec.rb @@ -0,0 +1,119 @@ +require 'spec_helper' + +describe Gitlab::UrlBuilder, lib: true do + describe '.build' do + context 'when passing a Commit' do + it 'returns a proper URL' do + commit = build_stubbed(:commit) + + url = described_class.build(commit) + + expect(url).to eq "#{Settings.gitlab['url']}/#{commit.project.path_with_namespace}/commit/#{commit.id}" + end + end + + context 'when passing an Issue' do + it 'returns a proper URL' do + issue = build_stubbed(:issue, iid: 42) + + url = described_class.build(issue) + + expect(url).to eq "#{Settings.gitlab['url']}/#{issue.project.path_with_namespace}/issues/#{issue.iid}" + end + end + + context 'when passing a MergeRequest' do + it 'returns a proper URL' do + merge_request = build_stubbed(:merge_request, iid: 42) + + url = described_class.build(merge_request) + + expect(url).to eq "#{Settings.gitlab['url']}/#{merge_request.project.path_with_namespace}/merge_requests/#{merge_request.iid}" + end + end + + context 'when passing a Note' do + context 'on a Commit' do + it 'returns a proper URL' do + note = build_stubbed(:note_on_commit) + + url = described_class.build(note) + + expect(url).to eq "#{Settings.gitlab['url']}/#{note.project.path_with_namespace}/commit/#{note.commit_id}#note_#{note.id}" + end + end + + context 'on a Commit Diff' do + it 'returns a proper URL' do + note = build_stubbed(:diff_note_on_commit) + + url = described_class.build(note) + + expect(url).to eq "#{Settings.gitlab['url']}/#{note.project.path_with_namespace}/commit/#{note.commit_id}#note_#{note.id}" + end + end + + context 'on an Issue' do + it 'returns a proper URL' do + issue = create(:issue, iid: 42) + note = build_stubbed(:note_on_issue, noteable: issue) + + url = described_class.build(note) + + expect(url).to eq "#{Settings.gitlab['url']}/#{issue.project.path_with_namespace}/issues/#{issue.iid}#note_#{note.id}" + end + end + + context 'on a MergeRequest' do + it 'returns a proper URL' do + merge_request = create(:merge_request, iid: 42) + note = build_stubbed(:note_on_merge_request, noteable: merge_request) + + url = described_class.build(note) + + expect(url).to eq "#{Settings.gitlab['url']}/#{merge_request.project.path_with_namespace}/merge_requests/#{merge_request.iid}#note_#{note.id}" + end + end + + context 'on a MergeRequest Diff' do + it 'returns a proper URL' do + merge_request = create(:merge_request, iid: 42) + note = build_stubbed(:diff_note_on_merge_request, noteable: merge_request) + + url = described_class.build(note) + + expect(url).to eq "#{Settings.gitlab['url']}/#{merge_request.project.path_with_namespace}/merge_requests/#{merge_request.iid}#note_#{note.id}" + end + end + + context 'on a ProjectSnippet' do + it 'returns a proper URL' do + project_snippet = create(:project_snippet) + note = build_stubbed(:note_on_project_snippet, noteable: project_snippet) + + url = described_class.build(note) + + expect(url).to eq "#{Settings.gitlab['url']}/#{project_snippet.project.path_with_namespace}/snippets/#{note.noteable_id}#note_#{note.id}" + end + end + + context 'on another object' do + it 'returns a proper URL' do + project = build_stubbed(:project) + + expect { described_class.build(project) }. + to raise_error(NotImplementedError, 'No URL builder defined for Project') + end + end + end + + context 'when passing a WikiPage' do + it 'returns a proper URL' do + wiki_page = build(:wiki_page) + url = described_class.build(wiki_page) + + expect(url).to eq "#{Gitlab.config.gitlab.url}#{wiki_page.wiki.wiki_base_path}/#{wiki_page.slug}" + end + end + end +end diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb index 14bc062ef12..e1877d5fde0 100644 --- a/spec/mailers/emails/profile_spec.rb +++ b/spec/mailers/emails/profile_spec.rb @@ -25,7 +25,7 @@ describe Notify do it 'includes a link for user to set password' do params = "reset_password_token=#{token}" is_expected.to have_body_text( - %r{http://localhost(:\d+)?/users/password/edit\?#{params}} + %r{http://#{Gitlab.config.gitlab.host}(:\d+)?/users/password/edit\?#{params}} ) end diff --git a/spec/models/broadcast_message_spec.rb b/spec/models/broadcast_message_spec.rb index 02d6263094a..219db365a91 100644 --- a/spec/models/broadcast_message_spec.rb +++ b/spec/models/broadcast_message_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe BroadcastMessage, models: true do - subject { create(:broadcast_message) } + subject { build(:broadcast_message) } it { is_expected.to be_valid } diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index ae185de9ca3..ef07f2275b1 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -1052,4 +1052,132 @@ describe Ci::Build, models: true do end end end + + describe '#has_environment?' do + subject { build.has_environment? } + + context 'when environment is defined' do + before do + build.update(environment: 'review') + end + + it { is_expected.to be_truthy } + end + + context 'when environment is not defined' do + before do + build.update(environment: nil) + end + + it { is_expected.to be_falsey } + end + end + + describe '#starts_environment?' do + subject { build.starts_environment? } + + context 'when environment is defined' do + before do + build.update(environment: 'review') + end + + context 'no action is defined' do + it { is_expected.to be_truthy } + end + + context 'and start action is defined' do + before do + build.update(options: { environment: { action: 'start' } } ) + end + + it { is_expected.to be_truthy } + end + end + + context 'when environment is not defined' do + before do + build.update(environment: nil) + end + + it { is_expected.to be_falsey } + end + end + + describe '#stops_environment?' do + subject { build.stops_environment? } + + context 'when environment is defined' do + before do + build.update(environment: 'review') + end + + context 'no action is defined' do + it { is_expected.to be_falsey } + end + + context 'and stop action is defined' do + before do + build.update(options: { environment: { action: 'stop' } } ) + end + + it { is_expected.to be_truthy } + end + end + + context 'when environment is not defined' do + before do + build.update(environment: nil) + end + + it { is_expected.to be_falsey } + end + end + + describe '#last_deployment' do + subject { build.last_deployment } + + context 'when multiple deployments are created' do + let!(:deployment1) { create(:deployment, deployable: build) } + let!(:deployment2) { create(:deployment, deployable: build) } + + it 'returns the latest one' do + is_expected.to eq(deployment2) + end + end + end + + describe '#outdated_deployment?' do + subject { build.outdated_deployment? } + + context 'when build succeeded' do + let(:build) { create(:ci_build, :success) } + let!(:deployment) { create(:deployment, deployable: build) } + + context 'current deployment is latest' do + it { is_expected.to be_falsey } + end + + context 'current deployment is not latest on environment' do + let!(:deployment2) { create(:deployment, environment: deployment.environment) } + + it { is_expected.to be_truthy } + end + end + + context 'when build failed' do + let(:build) { create(:ci_build, :failed) } + + it { is_expected.to be_falsey } + end + end + + describe '#expanded_environment_name' do + subject { build.expanded_environment_name } + + context 'when environment uses variables' do + let(:build) { create(:ci_build, ref: 'master', environment: 'review/$CI_BUILD_REF_NAME') } + + it { is_expected.to eq('review/master') } + end + end end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index a37a00f461a..a7e90c8a381 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -4,6 +4,12 @@ describe Ci::Build, models: true do let(:build) { create(:ci_build) } let(:test_trace) { 'This is a test' } + it { is_expected.to belong_to(:runner) } + it { is_expected.to belong_to(:trigger_request) } + it { is_expected.to belong_to(:erased_by) } + + it { is_expected.to have_many(:deployments) } + describe '#trace' do it 'obfuscates project runners token' do allow(build).to receive(:raw_trace).and_return("Test: #{build.project.runners_token}") diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb index 1a26cee9f3d..90731f55470 100644 --- a/spec/models/key_spec.rb +++ b/spec/models/key_spec.rb @@ -19,7 +19,7 @@ describe Key, models: true do describe "#publishable_keys" do it 'replaces SSH key comment with simple identifier of username + hostname' do - expect(build(:key, user: user).publishable_key).to include("#{user.name} (localhost)") + expect(build(:key, user: user).publishable_key).to include("#{user.name} (#{Gitlab.config.gitlab.host})") end end end diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index 12419d6fd5a..4f7c8a36cb5 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -443,6 +443,16 @@ describe Member, models: true do member.accept_invite!(user) end + + it "refreshes user's authorized projects", truncate: true do + project = member.source + + expect(user.authorized_projects).not_to include(project) + + member.accept_invite!(user) + + expect(user.authorized_projects.reload).to include(project) + end end describe "#decline_invite!" do @@ -468,4 +478,16 @@ describe Member, models: true do expect { member.generate_invite_token }.to change { member.invite_token} end end + + describe "destroying a record", truncate: true do + it "refreshes user's authorized projects" do + project = create(:project, :private) + user = create(:user) + member = project.team << [user, :reporter] + + member.destroy + + expect(user.authorized_projects).not_to include(project) + end + end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index fb032a89d50..0b4277b1edd 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -856,13 +856,31 @@ describe MergeRequest, models: true do context 'when it is only allowed to merge when build is green' do context 'and a failed pipeline is associated' do before do - pipeline.statuses << create(:commit_status, status: 'failed', project: project) + pipeline.update(status: 'failed') allow(subject).to receive(:pipeline) { pipeline } end it { expect(subject.mergeable_ci_state?).to be_falsey } end + context 'and a successful pipeline is associated' do + before do + pipeline.update(status: 'success') + allow(subject).to receive(:pipeline) { pipeline } + end + + it { expect(subject.mergeable_ci_state?).to be_truthy } + end + + context 'and a skipped pipeline is associated' do + before do + pipeline.update(status: 'skipped') + allow(subject).to receive(:pipeline) { pipeline } + end + + it { expect(subject.mergeable_ci_state?).to be_truthy } + end + context 'when no pipeline is associated' do before do allow(subject).to receive(:pipeline) { nil } diff --git a/spec/models/project_group_link_spec.rb b/spec/models/project_group_link_spec.rb index c5ff1941378..47397a822c1 100644 --- a/spec/models/project_group_link_spec.rb +++ b/spec/models/project_group_link_spec.rb @@ -14,4 +14,20 @@ describe ProjectGroupLink do it { should validate_presence_of(:group) } it { should validate_presence_of(:group_access) } end + + describe "destroying a record", truncate: true do + it "refreshes group users' authorized projects" do + project = create(:project, :private) + group = create(:group) + reporter = create(:user) + group_users = group.users + + group.add_reporter(reporter) + project.project_group_links.create(group: group) + group_users.each { |user| expect(user.authorized_projects).to include(project) } + + project.project_group_links.destroy_all + group_users.each { |user| expect(user.authorized_projects).not_to include(project) } + end + end end diff --git a/spec/models/project_services/chat_service_spec.rb b/spec/models/project_services/chat_service_spec.rb new file mode 100644 index 00000000000..c6a45a3e1be --- /dev/null +++ b/spec/models/project_services/chat_service_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' + +describe ChatService, models: true do + describe "Associations" do + it { is_expected.to have_many :chat_names } + end + + describe '#valid_token?' do + subject { described_class.new } + + it 'is false as it has no token' do + expect(subject.valid_token?('wer')).to be_falsey + end + end +end diff --git a/spec/models/project_services/gitlab_issue_tracker_service_spec.rb b/spec/models/project_services/gitlab_issue_tracker_service_spec.rb index 652804fb444..9b80f0e7296 100644 --- a/spec/models/project_services/gitlab_issue_tracker_service_spec.rb +++ b/spec/models/project_services/gitlab_issue_tracker_service_spec.rb @@ -35,9 +35,9 @@ describe GitlabIssueTrackerService, models: true do end it 'gives the correct path' do - expect(@service.project_url).to eq("http://localhost/gitlab/root/#{project.path_with_namespace}/issues") - expect(@service.new_issue_url).to eq("http://localhost/gitlab/root/#{project.path_with_namespace}/issues/new") - expect(@service.issue_url(432)).to eq("http://localhost/gitlab/root/#{project.path_with_namespace}/issues/432") + expect(@service.project_url).to eq("http://#{Gitlab.config.gitlab.host}/gitlab/root/#{project.path_with_namespace}/issues") + expect(@service.new_issue_url).to eq("http://#{Gitlab.config.gitlab.host}/gitlab/root/#{project.path_with_namespace}/issues/new") + expect(@service.issue_url(432)).to eq("http://#{Gitlab.config.gitlab.host}/gitlab/root/#{project.path_with_namespace}/issues/432") end end diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb index 2a87a411e9d..f5da967cd14 100644 --- a/spec/models/project_services/jira_service_spec.rb +++ b/spec/models/project_services/jira_service_spec.rb @@ -83,20 +83,34 @@ describe JiraService, models: true do url: 'http://jira.example.com', username: 'gitlab_jira_username', password: 'gitlab_jira_password', - project_key: 'GitLabProject' + project_key: 'GitLabProject', + jira_issue_transition_id: "custom-id" ) + # These stubs are needed to test JiraService#close_issue. + # We close the issue then do another request to API to check if it got closed. + # Here is stubbed the API return with a closed and an opened issues. + open_issue = JIRA::Resource::Issue.new(@jira_service.client, attrs: { "id" => "JIRA-123" }) + closed_issue = open_issue.dup + allow(open_issue).to receive(:resolution).and_return(false) + allow(closed_issue).to receive(:resolution).and_return(true) + allow(JIRA::Resource::Issue).to receive(:find).and_return(open_issue, closed_issue) + + allow_any_instance_of(JIRA::Resource::Issue).to receive(:key).and_return("JIRA-123") + @jira_service.save project_issues_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123' @project_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/project/GitLabProject' @transitions_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123/transitions' @comment_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123/comment' + @remote_link_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123/remotelink' WebMock.stub_request(:get, @project_url) WebMock.stub_request(:get, project_issues_url) WebMock.stub_request(:post, @transitions_url) WebMock.stub_request(:post, @comment_url) + WebMock.stub_request(:post, @remote_link_url) end it "calls JIRA API" do @@ -107,6 +121,37 @@ describe JiraService, models: true do ).once end + # Check https://developer.atlassian.com/jiradev/jira-platform/guides/other/guide-jira-remote-issue-links/fields-in-remote-issue-links + # for more information + it "creates Remote Link reference in JIRA for comment" do + @jira_service.execute(merge_request, ExternalIssue.new("JIRA-123", project)) + + # Creates comment + expect(WebMock).to have_requested(:post, @comment_url) + + # Creates Remote Link in JIRA issue fields + expect(WebMock).to have_requested(:post, @remote_link_url).with( + body: hash_including( + GlobalID: "GitLab", + object: { + url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/commit/#{merge_request.diff_head_sha}", + title: "GitLab: Solved by commit #{merge_request.diff_head_sha}.", + icon: { title: "GitLab", url16x16: "https://gitlab.com/favicon.ico" }, + status: { resolved: true, icon: { url16x16: "http://www.openwebgraphics.com/resources/data/1768/16x16_apply.png", title: "Closed" } } + } + ) + ).once + end + + it "does not send comment or remote links to issues already closed" do + allow_any_instance_of(JIRA::Resource::Issue).to receive(:resolution).and_return(true) + + @jira_service.execute(merge_request, ExternalIssue.new("JIRA-123", project)) + + expect(WebMock).not_to have_requested(:post, @comment_url) + expect(WebMock).not_to have_requested(:post, @remote_link_url) + end + it "references the GitLab commit/merge request" do stub_config_setting(base_url: custom_base_url) @@ -133,11 +178,10 @@ describe JiraService, models: true do end it "calls the api with jira_issue_transition_id" do - @jira_service.jira_issue_transition_id = 'this-is-a-custom-id' @jira_service.execute(merge_request, ExternalIssue.new("JIRA-123", project)) expect(WebMock).to have_requested(:post, @transitions_url).with( - body: /this-is-a-custom-id/ + body: /custom-id/ ).once end diff --git a/spec/models/project_services/mattermost_slash_commands_service_spec.rb b/spec/models/project_services/mattermost_slash_commands_service_spec.rb new file mode 100644 index 00000000000..4a1037e950b --- /dev/null +++ b/spec/models/project_services/mattermost_slash_commands_service_spec.rb @@ -0,0 +1,99 @@ +require 'spec_helper' + +describe MattermostSlashCommandsService, models: true do + describe "Associations" do + it { is_expected.to respond_to :token } + end + + describe '#valid_token?' do + subject { described_class.new } + + context 'when the token is empty' do + it 'is false' do + expect(subject.valid_token?('wer')).to be_falsey + end + end + + context 'when there is a token' do + before do + subject.token = '123' + end + + it 'accepts equal tokens' do + expect(subject.valid_token?('123')).to be_truthy + end + end + end + + describe '#trigger' do + subject { described_class.new } + + context 'no token is passed' do + let(:params) { Hash.new } + + it 'returns nil' do + expect(subject.trigger(params)).to be_nil + end + end + + context 'with a token passed' do + let(:project) { create(:empty_project) } + let(:params) { { token: 'token' } } + + before do + allow(subject).to receive(:token).and_return('token') + end + + context 'no user can be found' do + context 'when no url can be generated' do + it 'responds with the authorize url' do + response = subject.trigger(params) + + expect(response[:response_type]).to eq :ephemeral + expect(response[:text]).to start_with ":sweat_smile: Couldn't identify you" + end + end + + context 'when an auth url can be generated' do + let(:params) do + { + team_domain: 'http://domain.tld', + team_id: 'T3423423', + user_id: 'U234234', + user_name: 'mepmep', + token: 'token' + } + end + + let(:service) do + project.create_mattermost_slash_commands_service( + properties: { token: 'token' } + ) + end + + it 'generates the url' do + response = service.trigger(params) + + expect(response[:text]).to start_with(':wave: Hi there!') + end + end + end + + context 'when the user is authenticated' do + let!(:chat_name) { create(:chat_name, service: service) } + let(:service) do + project.create_mattermost_slash_commands_service( + properties: { token: 'token' } + ) + end + let(:params) { { token: 'token', team_id: chat_name.team_id, user_id: chat_name.chat_id } } + + it 'triggers the command' do + expect_any_instance_of(Gitlab::ChatCommands::Command).to receive(:execute) + + service.trigger(params) + end + end + end + end +end diff --git a/spec/models/project_services/slack_service/pipeline_message_spec.rb b/spec/models/project_services/slack_service/pipeline_message_spec.rb index babb3909f56..363138a9454 100644 --- a/spec/models/project_services/slack_service/pipeline_message_spec.rb +++ b/spec/models/project_services/slack_service/pipeline_message_spec.rb @@ -15,7 +15,7 @@ describe SlackService::PipelineMessage do }, project: { path_with_namespace: 'project_name', web_url: 'example.gitlab.com' }, - commit: { author_name: 'hacker' } + user: { name: 'hacker' } } end @@ -48,7 +48,7 @@ describe SlackService::PipelineMessage do def build_message(status_text = status) "<example.gitlab.com|project_name>:" \ - " Pipeline <example.gitlab.com/pipelines/123|97de212e>" \ + " Pipeline <example.gitlab.com/pipelines/123|#123>" \ " of <example.gitlab.com/commits/develop|develop> branch" \ " by hacker #{status_text} in #{duration} #{'second'.pluralize(duration)}" end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 46fa00a79c4..25458e20618 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -20,6 +20,7 @@ describe Project, models: true do it { is_expected.to have_many(:deploy_keys) } it { is_expected.to have_many(:hooks).dependent(:destroy) } it { is_expected.to have_many(:protected_branches).dependent(:destroy) } + it { is_expected.to have_many(:chat_services) } it { is_expected.to have_one(:forked_project_link).dependent(:destroy) } it { is_expected.to have_one(:slack_service).dependent(:destroy) } it { is_expected.to have_one(:pushover_service).dependent(:destroy) } @@ -35,6 +36,7 @@ describe Project, models: true do it { is_expected.to have_one(:hipchat_service).dependent(:destroy) } it { is_expected.to have_one(:flowdock_service).dependent(:destroy) } it { is_expected.to have_one(:assembla_service).dependent(:destroy) } + it { is_expected.to have_one(:mattermost_slash_commands_service).dependent(:destroy) } it { is_expected.to have_one(:gemnasium_service).dependent(:destroy) } it { is_expected.to have_one(:buildkite_service).dependent(:destroy) } it { is_expected.to have_one(:bamboo_service).dependent(:destroy) } @@ -700,7 +702,7 @@ describe Project, models: true do "/uploads/project/avatar/#{project.id}/uploads/avatar.png" end - it { should eq "http://localhost#{avatar_path}" } + it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } end context 'When avatar file in git' do @@ -712,7 +714,7 @@ describe Project, models: true do "/#{project.namespace.name}/#{project.path}/avatar" end - it { should eq "http://localhost#{avatar_path}" } + it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } end context 'when git repo is empty' do @@ -1514,7 +1516,7 @@ describe Project, models: true do members_project.team << [developer, :developer] members_project.team << [master, :master] - create(:project_group_link, project: shared_project, group: group) + create(:project_group_link, project: shared_project, group: group, group_access: Gitlab::Access::DEVELOPER) end it 'returns false for no user' do @@ -1543,7 +1545,9 @@ describe Project, models: true do expect(members_project.authorized_for_user?(developer, Gitlab::Access::MASTER)).to be(false) expect(members_project.authorized_for_user?(master, Gitlab::Access::MASTER)).to be(true) expect(shared_project.authorized_for_user?(developer, Gitlab::Access::MASTER)).to be(false) - expect(shared_project.authorized_for_user?(master, Gitlab::Access::MASTER)).to be(true) + expect(shared_project.authorized_for_user?(master, Gitlab::Access::MASTER)).to be(false) + expect(shared_project.authorized_for_user?(developer, Gitlab::Access::DEVELOPER)).to be(true) + expect(shared_project.authorized_for_user?(master, Gitlab::Access::DEVELOPER)).to be(true) end end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 2470d504c68..72ac41f3472 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -1354,6 +1354,28 @@ describe Repository, models: true do repository.add_tag(user, '8.5', 'master', 'foo') end + it 'does not create a tag when a pre-hook fails' do + allow_any_instance_of(Gitlab::Git::Hook).to receive(:trigger).and_return([false, '']) + + expect do + repository.add_tag(user, '8.5', 'master', 'foo') + end.to raise_error(GitHooksService::PreReceiveError) + + repository.expire_tags_cache + expect(repository.find_tag('8.5')).to be_nil + end + + it 'passes tag SHA to hooks' do + spy = GitHooksService.new + allow(GitHooksService).to receive(:new).and_return(spy) + allow(spy).to receive(:execute).and_call_original + + tag = repository.add_tag(user, '8.5', 'master', 'foo') + + expect(spy).to have_received(:execute). + with(anything, anything, anything, tag.target, anything) + end + it 'returns a Gitlab::Git::Tag object' do tag = repository.add_tag(user, '8.5', 'master', 'foo') diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 0994159e210..e84042f8063 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1073,7 +1073,7 @@ describe User, models: true do it { is_expected.to eq([private_group]) } end - describe '#authorized_projects' do + describe '#authorized_projects', truncate: true do context 'with a minimum access level' do it 'includes projects for which the user is an owner' do user = create(:user) @@ -1093,6 +1093,80 @@ describe User, models: true do .to contain_exactly(project) end end + + it "includes user's personal projects" do + user = create(:user) + project = create(:project, :private, namespace: user.namespace) + + expect(user.authorized_projects).to include(project) + end + + it "includes personal projects user has been given access to" do + user1 = create(:user) + user2 = create(:user) + project = create(:project, :private, namespace: user1.namespace) + + project.team << [user2, Gitlab::Access::DEVELOPER] + + expect(user2.authorized_projects).to include(project) + end + + it "includes projects of groups user has been added to" do + group = create(:group) + project = create(:project, group: group) + user = create(:user) + + group.add_developer(user) + + expect(user.authorized_projects).to include(project) + end + + it "does not include projects of groups user has been removed from" do + group = create(:group) + project = create(:project, group: group) + user = create(:user) + + member = group.add_developer(user) + expect(user.authorized_projects).to include(project) + + member.destroy + expect(user.authorized_projects).not_to include(project) + end + + it "includes projects shared with user's group" do + user = create(:user) + project = create(:project, :private) + group = create(:group) + + group.add_reporter(user) + project.project_group_links.create(group: group) + + expect(user.authorized_projects).to include(project) + end + + it "does not include destroyed projects user had access to" do + user1 = create(:user) + user2 = create(:user) + project = create(:project, :private, namespace: user1.namespace) + + project.team << [user2, Gitlab::Access::DEVELOPER] + expect(user2.authorized_projects).to include(project) + + project.destroy + expect(user2.authorized_projects).not_to include(project) + end + + it "does not include projects of destroyed groups user had access to" do + group = create(:group) + project = create(:project, namespace: group) + user = create(:user) + + group.add_developer(user) + expect(user.authorized_projects).to include(project) + + group.destroy + expect(user.authorized_projects).not_to include(project) + end end describe '#projects_where_can_admin_issues' do diff --git a/spec/requests/api/internal_spec.rb b/spec/requests/api/internal_spec.rb index 8f1a1f9e827..e88a7e27d45 100644 --- a/spec/requests/api/internal_spec.rb +++ b/spec/requests/api/internal_spec.rb @@ -5,7 +5,7 @@ describe API::API, api: true do let(:user) { create(:user) } let(:key) { create(:key, user: user) } let(:project) { create(:project) } - let(:secret_token) { File.read Gitlab.config.gitlab_shell.secret_file } + let(:secret_token) { Gitlab::Shell.secret_token } describe "GET /internal/check", no_db: true do it do @@ -406,7 +406,7 @@ describe API::API, api: true do it 'returns link to create new merge request' do expect(json_response).to match [{ "branch_name" => "new_branch", - "url" => "http://localhost/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=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 diff --git a/spec/requests/api/pipelines_spec.rb b/spec/requests/api/pipelines_spec.rb index 7011bdc9ec0..d83f7883c78 100644 --- a/spec/requests/api/pipelines_spec.rb +++ b/spec/requests/api/pipelines_spec.rb @@ -41,6 +41,52 @@ describe API::API, api: true do end end + describe 'POST /projects/:id/pipeline ' do + context 'authorized user' do + context 'with gitlab-ci.yml' do + before { stub_ci_pipeline_to_return_yaml_file } + + it 'creates and returns a new pipeline' do + expect do + post api("/projects/#{project.id}/pipeline", user), ref: project.default_branch + end.to change { Ci::Pipeline.count }.by(1) + + expect(response).to have_http_status(201) + expect(json_response).to be_a Hash + expect(json_response['sha']).to eq project.commit.id + end + + it 'fails when using an invalid ref' do + post api("/projects/#{project.id}/pipeline", user), ref: 'invalid_ref' + + expect(response).to have_http_status(400) + expect(json_response['message']['base'].first).to eq 'Reference not found' + expect(json_response).not_to be_an Array + end + end + + context 'without gitlab-ci.yml' do + it 'fails to create pipeline' do + post api("/projects/#{project.id}/pipeline", user), ref: project.default_branch + + expect(response).to have_http_status(400) + expect(json_response['message']['base'].first).to eq 'Missing .gitlab-ci.yml file' + expect(json_response).not_to be_an Array + end + end + end + + context 'unauthorized user' do + it 'does not create pipeline' do + post api("/projects/#{project.id}/pipeline", non_member), ref: project.default_branch + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq '404 Project Not Found' + expect(json_response).not_to be_an Array + end + end + end + describe 'GET /projects/:id/pipelines/:pipeline_id' do context 'authorized user' do it 'returns project pipelines' do diff --git a/spec/requests/api/services_spec.rb b/spec/requests/api/services_spec.rb index 2aadab3cbe1..ce9c96ace21 100644 --- a/spec/requests/api/services_spec.rb +++ b/spec/requests/api/services_spec.rb @@ -88,4 +88,61 @@ describe API::API, api: true do end end end + + describe 'POST /projects/:id/services/:slug/trigger' do + let!(:project) { create(:empty_project) } + let(:service_name) { 'mattermost_slash_commands' } + + context 'no service is available' do + it 'returns a not found message' do + post api("/projects/#{project.id}/services/idonotexist/trigger") + + expect(response).to have_http_status(404) + expect(json_response["message"]).to eq("404 Service Not Found") + end + end + + context 'the service exists' do + let(:params) { { token: 'token' } } + + context 'the service is not active' do + let!(:inactive_service) do + project.create_mattermost_slash_commands_service( + active: false, + properties: { token: 'token' } + ) + end + + it 'when the service is inactive' do + post api("/projects/#{project.id}/services/mattermost_slash_commands/trigger") + + expect(response).to have_http_status(404) + end + end + + context 'the service is active' do + let!(:active_service) do + project.create_mattermost_slash_commands_service( + active: true, + properties: { token: 'token' } + ) + end + + it 'retusn status 200' do + post api("/projects/#{project.id}/services/mattermost_slash_commands/trigger"), params + + expect(response).to have_http_status(200) + end + end + + context 'when the project can not be found' do + it 'returns a generic 404' do + post api("/projects/404/services/mattermost_slash_commands/trigger"), params + + expect(response).to have_http_status(404) + expect(json_response["message"]).to eq("404 Service Not Found") + end + end + end + end end diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 34d1f557e4b..1a6e7716b2f 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -108,7 +108,7 @@ describe API::API, api: true do it "returns a 404 error if user id not found" do get api("/users/9999", user) expect(response).to have_http_status(404) - expect(json_response['message']).to eq('404 Not found') + expect(json_response['message']).to eq('404 User Not Found') end it "returns a 404 for invalid ID" do @@ -359,7 +359,7 @@ describe API::API, api: true do it "returns 404 for non-existing user" do put api("/users/999999", admin), { bio: 'update should fail' } expect(response).to have_http_status(404) - expect(json_response['message']).to eq('404 Not found') + expect(json_response['message']).to eq('404 User Not Found') end it "returns a 404 if invalid ID" do @@ -387,6 +387,18 @@ describe API::API, api: true do to eq([Gitlab::Regex.namespace_regex_message]) end + it 'returns 400 if provider is missing for identity update' do + put api("/users/#{omniauth_user.id}", admin), extern_uid: '654321' + + expect(response).to have_http_status(400) + end + + it 'returns 400 if external UID is missing for identity update' do + put api("/users/#{omniauth_user.id}", admin), provider: 'ldap' + + expect(response).to have_http_status(400) + end + context "with existing user" do before do post api("/users", admin), { email: 'test@example.com', password: 'password', username: 'test', name: 'test' } @@ -414,14 +426,16 @@ describe API::API, api: true do it "does not create invalid ssh key" do post api("/users/#{user.id}/keys", admin), { title: "invalid key" } + expect(response).to have_http_status(400) - expect(json_response['message']).to eq('400 (Bad request) "key" not given') + expect(json_response['error']).to eq('key is missing') end it 'does not create key without title' do post api("/users/#{user.id}/keys", admin), key: 'some key' + expect(response).to have_http_status(400) - expect(json_response['message']).to eq('400 (Bad request) "title" not given') + expect(json_response['error']).to eq('title is missing') end it "creates ssh key" do @@ -437,7 +451,7 @@ describe API::API, api: true do end end - describe 'GET /user/:uid/keys' do + describe 'GET /user/:id/keys' do before { admin } context 'when unauthenticated' do @@ -465,7 +479,7 @@ describe API::API, api: true do end end - describe 'DELETE /user/:uid/keys/:id' do + describe 'DELETE /user/:id/keys/:key_id' do before { admin } context 'when unauthenticated' do @@ -506,8 +520,9 @@ describe API::API, api: true do it "does not create invalid email" do post api("/users/#{user.id}/emails", admin), {} + expect(response).to have_http_status(400) - expect(json_response['message']).to eq('400 (Bad request) "email" not given') + expect(json_response['error']).to eq('email is missing') end it "creates email" do @@ -524,7 +539,7 @@ describe API::API, api: true do end end - describe 'GET /user/:uid/emails' do + describe 'GET /user/:id/emails' do before { admin } context 'when unauthenticated' do @@ -558,7 +573,7 @@ describe API::API, api: true do end end - describe 'DELETE /user/:uid/emails/:id' do + describe 'DELETE /user/:id/emails/:email_id' do before { admin } context 'when unauthenticated' do @@ -673,7 +688,7 @@ describe API::API, api: true do end end - describe "GET /user/keys/:id" do + describe "GET /user/keys/:key_id" do it "returns single key" do user.keys << key user.save @@ -686,7 +701,7 @@ describe API::API, api: true do get api("/user/keys/42", user) expect(response).to have_http_status(404) - expect(json_response['message']).to eq('404 Not found') + expect(json_response['message']).to eq('404 Key Not Found') end it "returns 404 error if admin accesses user's ssh key" do @@ -695,7 +710,7 @@ describe API::API, api: true do admin get api("/user/keys/#{key.id}", admin) expect(response).to have_http_status(404) - expect(json_response['message']).to eq('404 Not found') + expect(json_response['message']).to eq('404 Key Not Found') end it "returns 404 for invalid ID" do @@ -721,14 +736,16 @@ describe API::API, api: true do it "does not create ssh key without key" do post api("/user/keys", user), title: 'title' + expect(response).to have_http_status(400) - expect(json_response['message']).to eq('400 (Bad request) "key" not given') + expect(json_response['error']).to eq('key is missing') end it 'does not create ssh key without title' do post api('/user/keys', user), key: 'some key' + expect(response).to have_http_status(400) - expect(json_response['message']).to eq('400 (Bad request) "title" not given') + expect(json_response['error']).to eq('title is missing') end it "does not create ssh key without title" do @@ -737,7 +754,7 @@ describe API::API, api: true do end end - describe "DELETE /user/keys/:id" do + describe "DELETE /user/keys/:key_id" do it "deletes existed key" do user.keys << key user.save @@ -747,9 +764,11 @@ describe API::API, api: true do expect(response).to have_http_status(200) end - it "returns success if key ID not found" do + it "returns 404 if key ID not found" do delete api("/user/keys/42", user) - expect(response).to have_http_status(200) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Key Not Found') end it "returns 401 error if unauthorized" do @@ -786,7 +805,7 @@ describe API::API, api: true do end end - describe "GET /user/emails/:id" do + describe "GET /user/emails/:email_id" do it "returns single email" do user.emails << email user.save @@ -798,7 +817,7 @@ describe API::API, api: true do it "returns 404 Not Found within invalid ID" do get api("/user/emails/42", user) expect(response).to have_http_status(404) - expect(json_response['message']).to eq('404 Not found') + expect(json_response['message']).to eq('404 Email Not Found') end it "returns 404 error if admin accesses user's email" do @@ -807,7 +826,7 @@ describe API::API, api: true do admin get api("/user/emails/#{email.id}", admin) expect(response).to have_http_status(404) - expect(json_response['message']).to eq('404 Not found') + expect(json_response['message']).to eq('404 Email Not Found') end it "returns 404 for invalid ID" do @@ -833,12 +852,13 @@ describe API::API, api: true do it "does not create email with invalid email" do post api("/user/emails", user), {} + expect(response).to have_http_status(400) - expect(json_response['message']).to eq('400 (Bad request) "email" not given') + expect(json_response['error']).to eq('email is missing') end end - describe "DELETE /user/emails/:id" do + describe "DELETE /user/emails/:email_id" do it "deletes existed email" do user.emails << email user.save @@ -848,9 +868,11 @@ describe API::API, api: true do expect(response).to have_http_status(200) end - it "returns success if email ID not found" do + it "returns 404 if email ID not found" do delete api("/user/emails/42", user) - expect(response).to have_http_status(200) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Email Not Found') end it "returns 401 error if unauthorized" do @@ -860,10 +882,10 @@ describe API::API, api: true do expect(response).to have_http_status(401) end - it "returns a 404 for invalid ID" do - delete api("/users/emails/ASDF", admin) + it "returns 400 for invalid ID" do + delete api("/user/emails/ASDF", admin) - expect(response).to have_http_status(404) + expect(response).to have_http_status(400) end end diff --git a/spec/requests/projects/cycle_analytics_events_spec.rb b/spec/requests/projects/cycle_analytics_events_spec.rb new file mode 100644 index 00000000000..705dbb7d1c0 --- /dev/null +++ b/spec/requests/projects/cycle_analytics_events_spec.rb @@ -0,0 +1,140 @@ +require 'spec_helper' + +describe 'cycle analytics events' do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:issue) { create(:issue, project: project, created_at: 2.days.ago) } + + describe 'GET /:namespace/:project/cycle_analytics/events/issues' do + before do + project.team << [user, :developer] + + allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([issue]) + + 3.times { create_cycle } + deploy_master + + login_as(user) + end + + it 'lists the issue events' do + get namespace_project_cycle_analytics_issue_path(project.namespace, project, format: :json) + + expect(json_response['events']).not_to be_empty + + first_issue_iid = Issue.order(created_at: :desc).pluck(:iid).first.to_s + + expect(json_response['events'].first['iid']).to eq(first_issue_iid) + end + + it 'lists the plan events' do + get namespace_project_cycle_analytics_plan_path(project.namespace, project, format: :json) + + expect(json_response['events']).not_to be_empty + + expect(json_response['events'].first['short_sha']).to eq(MergeRequest.last.commits.first.short_id) + end + + it 'lists the code events' do + get namespace_project_cycle_analytics_code_path(project.namespace, project, format: :json) + + expect(json_response['events']).not_to be_empty + + first_mr_iid = MergeRequest.order(created_at: :desc).pluck(:iid).first.to_s + + expect(json_response['events'].first['iid']).to eq(first_mr_iid) + end + + it 'lists the test events' do + get namespace_project_cycle_analytics_test_path(project.namespace, project, format: :json) + + expect(json_response['events']).not_to be_empty + + expect(json_response['events'].first['date']).not_to be_empty + end + + it 'lists the review events' do + get namespace_project_cycle_analytics_review_path(project.namespace, project, format: :json) + + expect(json_response['events']).not_to be_empty + + first_mr_iid = MergeRequest.order(created_at: :desc).pluck(:iid).first.to_s + + expect(json_response['events'].first['iid']).to eq(first_mr_iid) + end + + it 'lists the staging events' do + get namespace_project_cycle_analytics_staging_path(project.namespace, project, format: :json) + + expect(json_response['events']).not_to be_empty + + expect(json_response['events'].first['date']).not_to be_empty + end + + it 'lists the production events' do + get namespace_project_cycle_analytics_production_path(project.namespace, project, format: :json) + + expect(json_response['events']).not_to be_empty + + first_issue_iid = Issue.order(created_at: :desc).pluck(:iid).first.to_s + + expect(json_response['events'].first['iid']).to eq(first_issue_iid) + end + + context 'specific branch' do + it 'lists the test events' do + branch = MergeRequest.first.source_branch + + get namespace_project_cycle_analytics_test_path(project.namespace, project, format: :json, branch: branch) + + expect(json_response['events']).not_to be_empty + + expect(json_response['events'].first['date']).not_to be_empty + end + end + + context 'with private project and builds' do + before do + ProjectMember.first.update(access_level: Gitlab::Access::GUEST) + end + + it 'does not list the test events' do + get namespace_project_cycle_analytics_test_path(project.namespace, project, format: :json) + + expect(response).to have_http_status(:not_found) + end + + it 'does not list the staging events' do + get namespace_project_cycle_analytics_staging_path(project.namespace, project, format: :json) + + expect(response).to have_http_status(:not_found) + end + + it 'lists the issue events' do + get namespace_project_cycle_analytics_issue_path(project.namespace, project, format: :json) + + expect(response).to have_http_status(:ok) + end + end + end + + def json_response + JSON.parse(response.body) + end + + def create_cycle + milestone = create(:milestone, project: project) + issue.update(milestone: milestone) + mr = create_merge_request_closing_issue(issue) + + pipeline = create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha) + pipeline.run + + create(:ci_build, pipeline: pipeline, status: :success, author: user) + create(:ci_build, pipeline: pipeline, status: :success, author: user) + + merge_merge_requests_closing_issue(issue) + + ProcessCommitWorker.new.perform(project.id, user.id, mr.commits.last.sha) + end +end diff --git a/spec/routing/routing_spec.rb b/spec/routing/routing_spec.rb index 61dca5d5a62..7aba4f08088 100644 --- a/spec/routing/routing_spec.rb +++ b/spec/routing/routing_spec.rb @@ -261,20 +261,28 @@ describe "Authentication", "routing" do end describe "Groups", "routing" do + let(:name) { 'complex.group-name' } + it "to #show" do - expect(get("/groups/1")).to route_to('groups#show', id: '1') + expect(get("/groups/#{name}")).to route_to('groups#show', id: name) end it "also display group#show on the short path" do allow(Group).to receive(:find_by).and_return(true) - expect(get('/1')).to route_to('groups#show', id: '1') + expect(get("/#{name}")).to route_to('groups#show', id: name) end - it "also display group#show with dot in the path" do - allow(Group).to receive(:find_by).and_return(true) + it "to #activity" do + expect(get("/groups/#{name}/activity")).to route_to('groups#activity', id: name) + end - expect(get('/group.with.dot')).to route_to('groups#show', id: 'group.with.dot') + it "to #issues" do + expect(get("/groups/#{name}/issues")).to route_to('groups#issues', id: name) + end + + it "to #members" do + expect(get("/groups/#{name}/group_members")).to route_to('groups/group_members#index', group_id: name) end end diff --git a/spec/serializers/analytics_build_entity_spec.rb b/spec/serializers/analytics_build_entity_spec.rb new file mode 100644 index 00000000000..9ac6f20fd3c --- /dev/null +++ b/spec/serializers/analytics_build_entity_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe AnalyticsBuildEntity do + let(:entity) do + described_class.new(build, request: double) + end + + context 'build with an author' do + let(:user) { create(:user) } + let(:build) { create(:ci_build, author: user) } + + subject { entity.as_json } + + it 'contains the URL' do + expect(subject).to include(:url) + end + + it 'contains the author' do + expect(subject).to include(:author) + end + + it 'does not contain sensitive information' do + expect(subject).not_to include(/token/) + expect(subject).not_to include(/variables/) + end + end +end diff --git a/spec/serializers/analytics_build_serializer_spec.rb b/spec/serializers/analytics_build_serializer_spec.rb new file mode 100644 index 00000000000..a0a9d9a5f12 --- /dev/null +++ b/spec/serializers/analytics_build_serializer_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe AnalyticsBuildSerializer do + let(:serializer) do + described_class + .new.represent(resource) + end + + let(:json) { serializer.as_json } + let(:resource) { create(:ci_build) } + + context 'when there is a single object provided' do + it 'it generates payload for single object' do + expect(json).to be_an_instance_of Hash + end + + it 'contains important elements of analyticsBuild' do + expect(json) + .to include(:name, :branch, :short_sha, :date, :total_time, :url, :author) + end + end +end diff --git a/spec/serializers/analytics_generic_entity_spec.rb b/spec/serializers/analytics_generic_entity_spec.rb new file mode 100644 index 00000000000..68086216ba9 --- /dev/null +++ b/spec/serializers/analytics_generic_entity_spec.rb @@ -0,0 +1,39 @@ +require 'spec_helper' + +describe AnalyticsIssueEntity do + let(:user) { create(:user) } + let(:entity_hash) do + { + total_time: "172802.724419", + title: "Eos voluptatem inventore in sed.", + iid: "1", + id: "1", + created_at: "2016-11-12 15:04:02.948604", + author: user, + } + end + + let(:project) { create(:empty_project) } + let(:request) { EntityRequest.new(project: project, entity: :merge_request) } + + let(:entity) do + described_class.new(entity_hash, request: request, project: project) + end + + context 'generic entity' do + subject { entity.as_json } + + it 'contains the entity URL' do + expect(subject).to include(:url) + end + + it 'contains the author' do + expect(subject).to include(:author) + end + + it 'does not contain sensitive information' do + expect(subject).not_to include(/token/) + expect(subject).not_to include(/variables/) + end + end +end diff --git a/spec/serializers/analytics_issue_serializer_spec.rb b/spec/serializers/analytics_issue_serializer_spec.rb new file mode 100644 index 00000000000..2842e1ba52f --- /dev/null +++ b/spec/serializers/analytics_issue_serializer_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +describe AnalyticsIssueSerializer do + let(:serializer) do + described_class + .new(project: project, entity: :merge_request) + .represent(resource) + end + + let(:user) { create(:user) } + let(:json) { serializer.as_json } + let(:project) { create(:project) } + let(:resource) do + { + total_time: "172802.724419", + title: "Eos voluptatem inventore in sed.", + iid: "1", + id: "1", + created_at: "2016-11-12 15:04:02.948604", + author: user, + } + end + + context 'when there is a single object provided' do + it 'it generates payload for single object' do + expect(json).to be_an_instance_of Hash + end + + it 'contains important elements of the issue' do + expect(json).to include(:title, :iid, :created_at, :total_time, :url, :author) + end + end +end diff --git a/spec/serializers/analytics_merge_request_serializer_spec.rb b/spec/serializers/analytics_merge_request_serializer_spec.rb new file mode 100644 index 00000000000..564207984df --- /dev/null +++ b/spec/serializers/analytics_merge_request_serializer_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' + +describe AnalyticsMergeRequestSerializer do + let(:serializer) do + described_class + .new(project: project, entity: :merge_request) + .represent(resource) + end + + let(:user) { create(:user) } + let(:json) { serializer.as_json } + let(:project) { create(:project) } + let(:resource) do + { + total_time: "172802.724419", + title: "Eos voluptatem inventore in sed.", + iid: "1", + id: "1", + state: 'open', + created_at: "2016-11-12 15:04:02.948604", + author: user + } + end + + context 'when there is a single object provided' do + it 'it generates payload for single object' do + expect(json).to be_an_instance_of Hash + end + + it 'contains important elements of the merge request' do + expect(json).to include(:title, :iid, :created_at, :total_time, :url, :author, :state) + end + end +end diff --git a/spec/serializers/build_entity_spec.rb b/spec/serializers/build_entity_spec.rb index 2734f5bedca..6dcfaec259e 100644 --- a/spec/serializers/build_entity_spec.rb +++ b/spec/serializers/build_entity_spec.rb @@ -10,9 +10,9 @@ describe BuildEntity do context 'when build is a regular job' do let(:build) { create(:ci_build) } - it 'contains url to build page and retry action' do - expect(subject).to include(:build_url, :retry_url) - expect(subject).not_to include(:play_url) + it 'contains paths to build page and retry action' do + expect(subject).to include(:build_path, :retry_path) + expect(subject).not_to include(:play_path) end it 'does not contain sensitive information' do @@ -24,8 +24,8 @@ describe BuildEntity do context 'when build is a manual action' do let(:build) { create(:ci_build, :manual) } - it 'contains url to play action' do - expect(subject).to include(:play_url) + it 'contains path to play action' do + expect(subject).to include(:play_path) end end end diff --git a/spec/serializers/commit_entity_spec.rb b/spec/serializers/commit_entity_spec.rb index 628e35c9a28..15f11ac3df9 100644 --- a/spec/serializers/commit_entity_spec.rb +++ b/spec/serializers/commit_entity_spec.rb @@ -31,7 +31,11 @@ describe CommitEntity do end end - it 'contains commit URL' do + it 'contains path to commit' do + expect(subject).to include(:commit_path) + end + + it 'contains URL to commit' do expect(subject).to include(:commit_url) end diff --git a/spec/serializers/deployment_entity_spec.rb b/spec/serializers/deployment_entity_spec.rb index 51b6de91571..ea87771e2a2 100644 --- a/spec/serializers/deployment_entity_spec.rb +++ b/spec/serializers/deployment_entity_spec.rb @@ -15,6 +15,6 @@ describe DeploymentEntity do it 'exposes nested information about branch' do expect(subject[:ref][:name]).to eq 'master' - expect(subject[:ref][:ref_url]).not_to be_empty + expect(subject[:ref][:ref_path]).not_to be_empty end end diff --git a/spec/serializers/entity_date_helper_spec.rb b/spec/serializers/entity_date_helper_spec.rb new file mode 100644 index 00000000000..b9cc2f64831 --- /dev/null +++ b/spec/serializers/entity_date_helper_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +describe EntityDateHelper do + let(:date_helper_class) { Class.new { include EntityDateHelper }.new } + + it 'converts 0 seconds' do + expect(date_helper_class.distance_of_time_as_hash(0)).to eq(seconds: 0) + end + + it 'converts 40 seconds' do + expect(date_helper_class.distance_of_time_as_hash(40)).to eq(seconds: 40) + end + + it 'converts 60 seconds' do + expect(date_helper_class.distance_of_time_as_hash(60)).to eq(mins: 1) + end + + it 'converts 70 seconds' do + expect(date_helper_class.distance_of_time_as_hash(70)).to eq(mins: 1, seconds: 10) + end + + it 'converts 3600 seconds' do + expect(date_helper_class.distance_of_time_as_hash(3600)).to eq(hours: 1) + end + + it 'converts 3750 seconds' do + expect(date_helper_class.distance_of_time_as_hash(3750)).to eq(hours: 1, mins: 2, seconds: 30) + end + + it 'converts 86400 seconds' do + expect(date_helper_class.distance_of_time_as_hash(86400)).to eq(days: 1) + end + + it 'converts 86560 seconds' do + expect(date_helper_class.distance_of_time_as_hash(86560)).to eq(days: 1, mins: 2, seconds: 40) + end + + it 'converts 86760 seconds' do + expect(date_helper_class.distance_of_time_as_hash(99760)).to eq(days: 1, hours: 3, mins: 42, seconds: 40) + end + + it 'converts 986760 seconds' do + expect(date_helper_class.distance_of_time_as_hash(986760)).to eq(days: 11, hours: 10, mins: 6) + end +end diff --git a/spec/serializers/environment_entity_spec.rb b/spec/serializers/environment_entity_spec.rb index 4ca8c299147..57728ce3181 100644 --- a/spec/serializers/environment_entity_spec.rb +++ b/spec/serializers/environment_entity_spec.rb @@ -13,6 +13,6 @@ describe EnvironmentEntity do end it 'exposes core elements of environment' do - expect(subject).to include(:id, :name, :state, :environment_url) + expect(subject).to include(:id, :name, :state, :environment_path) end end diff --git a/spec/serializers/environment_serializer_spec.rb b/spec/serializers/environment_serializer_spec.rb index 37bc086826c..8f95c9250b0 100644 --- a/spec/serializers/environment_serializer_spec.rb +++ b/spec/serializers/environment_serializer_spec.rb @@ -33,7 +33,7 @@ describe EnvironmentSerializer do it 'contains important elements of environment' do expect(json) - .to include(:name, :external_url, :environment_url, :last_deployment) + .to include(:name, :external_url, :environment_path, :last_deployment) end it 'contains relevant information about last deployment' do diff --git a/spec/services/chat_names/find_user_service_spec.rb b/spec/services/chat_names/find_user_service_spec.rb index 5b885b2c657..51441e8f3be 100644 --- a/spec/services/chat_names/find_user_service_spec.rb +++ b/spec/services/chat_names/find_user_service_spec.rb @@ -13,7 +13,7 @@ describe ChatNames::FindUserService, services: true do context 'when existing user is requested' do let(:params) { { team_id: chat_name.team_id, user_id: chat_name.chat_id } } - it 'returns existing user' do + it 'returns the existing user' do is_expected.to eq(user) end diff --git a/spec/services/destroy_group_service_spec.rb b/spec/services/destroy_group_service_spec.rb index da724643604..538e85cdc89 100644 --- a/spec/services/destroy_group_service_spec.rb +++ b/spec/services/destroy_group_service_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe DestroyGroupService, services: true do + include DatabaseConnectionHelpers + let!(:user) { create(:user) } let!(:group) { create(:group) } let!(:project) { create(:project, namespace: group) } @@ -50,6 +52,44 @@ describe DestroyGroupService, services: true do describe 'asynchronous delete' do it_behaves_like 'group destruction', true + + context 'potential race conditions' do + context "when the `GroupDestroyWorker` task runs immediately" do + it "deletes the group" do + # Commit the contents of this spec's transaction so far + # so subsequent db connections can see it. + # + # DO NOT REMOVE THIS LINE, even if you see a WARNING with "No + # transaction is currently in progress". Without this, this + # spec will always be green, since the group created in setup + # cannot be seen by any other connections / threads in this spec. + Group.connection.commit_db_transaction + + group_record = run_with_new_database_connection do |conn| + conn.execute("SELECT * FROM namespaces WHERE id = #{group.id}").first + end + + expect(group_record).not_to be_nil + + # Execute the contents of `GroupDestroyWorker` in a separate thread, to + # simulate data manipulation by the Sidekiq worker (different database + # connection / transaction). + expect(GroupDestroyWorker).to receive(:perform_async).and_wrap_original do |m, group_id, user_id| + Thread.new { m[group_id, user_id] }.join(5) + end + + # Kick off the initial group destroy in a new thread, so that + # it doesn't share this spec's database transaction. + Thread.new { DestroyGroupService.new(group, user).async_execute }.join(5) + + group_record = run_with_new_database_connection do |conn| + conn.execute("SELECT * FROM namespaces WHERE id = #{group.id}").first + end + + expect(group_record).to be_nil + end + end + end end describe 'synchronous delete' do diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index cea7e6429f9..62f9982e840 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -490,7 +490,17 @@ describe GitPushService, services: true do context "closing an issue" do let(:message) { "this is some work.\n\ncloses JIRA-1" } - let(:comment_body) { { body: "Issue solved with [#{closing_commit.id}|http://localhost/#{project.path_with_namespace}/commit/#{closing_commit.id}]." }.to_json } + let(:comment_body) { { body: "Issue solved with [#{closing_commit.id}|http://#{Gitlab.config.gitlab.host}/#{project.path_with_namespace}/commit/#{closing_commit.id}]." }.to_json } + + before do + open_issue = JIRA::Resource::Issue.new(jira_tracker.client, attrs: { "id" => "JIRA-1" }) + closed_issue = open_issue.dup + allow(open_issue).to receive(:resolution).and_return(false) + allow(closed_issue).to receive(:resolution).and_return(true) + allow(JIRA::Resource::Issue).to receive(:find).and_return(open_issue, closed_issue) + + allow_any_instance_of(JIRA::Resource::Issue).to receive(:key).and_return("JIRA-1") + end context "using right markdown" do it "initiates one api call to jira server to close the issue" do diff --git a/spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb b/spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb index dd656c3bbb7..a44312dd363 100644 --- a/spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb +++ b/spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb @@ -1,13 +1,22 @@ require 'spec_helper' -# Write specs in this file. describe MergeRequests::AddTodoWhenBuildFailsService do let(:user) { create(:user) } let(:merge_request) { create(:merge_request) } let(:project) { create(:project) } let(:sha) { '1234567890abcdef1234567890abcdef12345678' } - let(:pipeline) { create(:ci_pipeline_with_one_job, ref: merge_request.source_branch, project: project, sha: sha) } - let(:service) { MergeRequests::AddTodoWhenBuildFailsService.new(project, user, commit_message: 'Awesome message') } + let(:ref) { merge_request.source_branch } + + let(:pipeline) do + create(:ci_pipeline_with_one_job, ref: ref, + project: project, + sha: sha) + end + + let(:service) do + described_class.new(project, user, commit_message: 'Awesome message') + end + let(:todo_service) { TodoService.new } let(:merge_request) do @@ -23,7 +32,9 @@ describe MergeRequests::AddTodoWhenBuildFailsService do describe '#execute' do context 'commit status with ref' do - let(:commit_status) { create(:generic_commit_status, ref: merge_request.source_branch, pipeline: pipeline) } + let(:commit_status) do + create(:generic_commit_status, ref: ref, pipeline: pipeline) + end it 'notifies the todo service' do expect(todo_service).to receive(:merge_request_build_failed).with(merge_request) @@ -32,7 +43,7 @@ describe MergeRequests::AddTodoWhenBuildFailsService do end context 'commit status with non-HEAD ref' do - let(:commit_status) { create(:generic_commit_status, ref: merge_request.source_branch) } + let(:commit_status) { create(:generic_commit_status, ref: ref) } it 'does not notify the todo service' do expect(todo_service).not_to receive(:merge_request_build_failed) @@ -48,6 +59,18 @@ describe MergeRequests::AddTodoWhenBuildFailsService do service.execute(commit_status) end end + + context 'when commit status is a build allowed to fail' do + let(:commit_status) do + create(:ci_build, :allowed_to_fail, ref: ref, pipeline: pipeline) + end + + it 'does not create todo' do + expect(todo_service).not_to receive(:merge_request_build_failed) + + service.execute(commit_status) + end + end end describe '#close' do diff --git a/spec/services/merge_requests/get_urls_service_spec.rb b/spec/services/merge_requests/get_urls_service_spec.rb index 3a71776e81f..08829e4be70 100644 --- a/spec/services/merge_requests/get_urls_service_spec.rb +++ b/spec/services/merge_requests/get_urls_service_spec.rb @@ -4,8 +4,8 @@ describe MergeRequests::GetUrlsService do let(:project) { create(:project, :public) } let(:service) { MergeRequests::GetUrlsService.new(project) } let(:source_branch) { "my_branch" } - let(:new_merge_request_url) { "http://localhost/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=#{source_branch}" } - let(:show_merge_request_url) { "http://localhost/#{project.namespace.name}/#{project.path}/merge_requests/#{merge_request.iid}" } + let(:new_merge_request_url) { "http://#{Gitlab.config.gitlab.host}/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=#{source_branch}" } + let(:show_merge_request_url) { "http://#{Gitlab.config.gitlab.host}/#{project.namespace.name}/#{project.path}/merge_requests/#{merge_request.iid}" } let(:new_branch_changes) { "#{Gitlab::Git::BLANK_SHA} 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/#{source_branch}" } let(:deleted_branch_changes) { "d14d6c0abdd253381df51a723d58691b2ee1ab08 #{Gitlab::Git::BLANK_SHA} refs/heads/#{source_branch}" } let(:existing_branch_changes) { "d14d6c0abdd253381df51a723d58691b2ee1ab08 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/#{source_branch}" } @@ -115,7 +115,7 @@ describe MergeRequests::GetUrlsService do let(:new_branch_changes) { "#{Gitlab::Git::BLANK_SHA} 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/new_branch" } let(:existing_branch_changes) { "d14d6c0abdd253381df51a723d58691b2ee1ab08 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/existing_branch" } let(:changes) { "#{new_branch_changes}\n#{existing_branch_changes}" } - let(:new_merge_request_url) { "http://localhost/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=new_branch" } + let(:new_merge_request_url) { "http://#{Gitlab.config.gitlab.host}/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=new_branch" } it 'returns 2 urls for both creating new and showing merge request' do result = service.execute(changes) diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb index f93d7732a9a..7db32a33c93 100644 --- a/spec/services/merge_requests/merge_service_spec.rb +++ b/spec/services/merge_requests/merge_service_spec.rb @@ -59,25 +59,43 @@ describe MergeRequests::MergeService, services: true do include JiraServiceHelper let(:jira_tracker) { project.create_jira_service } + let(:jira_issue) { ExternalIssue.new('JIRA-123', project) } + let(:commit) { double('commit', safe_message: "Fixes #{jira_issue.to_reference}") } before do project.update_attributes!(has_external_issue_tracker: true) jira_service_settings + stub_jira_urls(jira_issue.id) + allow(merge_request).to receive(:commits).and_return([commit]) end it 'closes issues on JIRA issue tracker' do jira_issue = ExternalIssue.new('JIRA-123', project) + stub_jira_urls(jira_issue) commit = double('commit', safe_message: "Fixes #{jira_issue.to_reference}") allow(merge_request).to receive(:commits).and_return([commit]) - expect_any_instance_of(JiraService).to receive(:close_issue).with(merge_request, jira_issue).once + expect_any_instance_of(JiraService).to receive(:close_issue).with(merge_request, an_instance_of(JIRA::Resource::Issue)).once service.execute(merge_request) end + context "when jira_issue_transition_id is not present" do + before { allow_any_instance_of(JIRA::Resource::Issue).to receive(:resolution).and_return(nil) } + + it "does not close issue" do + allow(jira_tracker).to receive_messages(jira_issue_transition_id: nil) + + expect_any_instance_of(JiraService).not_to receive(:transition_issue) + + service.execute(merge_request) + end + end + context "wrong issue markdown" do it 'does not close issues on JIRA issue tracker' do - jira_issue = ExternalIssue.new('#123', project) + jira_issue = ExternalIssue.new('#JIRA-123', project) + stub_jira_urls(jira_issue) commit = double('commit', safe_message: "Fixes #{jira_issue.to_reference}") allow(merge_request).to receive(:commits).and_return([commit]) diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index 2cf9883113c..fbd22560d6e 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -34,6 +34,8 @@ describe Projects::CreateService, services: true do @group = create :group @group.add_owner(@user) + @user.refresh_authorized_projects # Ensure cache is warm + @opts.merge!(namespace_id: @group.id) @project = create_project(@user, @opts) end @@ -41,6 +43,7 @@ describe Projects::CreateService, services: true do it { expect(@project).to be_valid } it { expect(@project.owner).to eq(@group) } it { expect(@project.namespace).to eq(@group) } + it { expect(@user.authorized_projects).to include(@project) } end context 'error handling' do diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 5bb107fdd85..150e21574a1 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe SystemNoteService, services: true do + include Gitlab::Routing.url_helpers + let(:project) { create(:project) } let(:author) { create(:user) } let(:noteable) { create(:issue, project: project) } @@ -534,7 +536,7 @@ describe SystemNoteService, services: true do let(:project) { create(:jira_project) } let(:author) { create(:user) } let(:issue) { create(:issue, project: project) } - let(:mergereq) { create(:merge_request, :simple, target_project: project, source_project: project) } + let(:merge_request) { create(:merge_request, :simple, target_project: project, source_project: project) } let(:jira_issue) { ExternalIssue.new("JIRA-1", project)} let(:jira_tracker) { project.jira_service } let(:commit) { project.commit } @@ -543,6 +545,30 @@ describe SystemNoteService, services: true do before { stub_jira_urls(jira_issue.id) } + noteable_types = ["merge_requests", "commit"] + + noteable_types.each do |type| + context "when noteable is a #{type}" do + it "blocks cross reference when #{type.underscore}_events is false" do + jira_tracker.update("#{type}_events" => false) + + noteable = type == "commit" ? commit : merge_request + result = described_class.cross_reference(jira_issue, noteable, author) + + expect(result).to eq("Events for #{noteable.class.to_s.underscore.humanize.pluralize.downcase} are disabled.") + end + + it "blocks cross reference when #{type.underscore}_events is true" do + jira_tracker.update("#{type}_events" => true) + + noteable = type == "commit" ? commit : merge_request + result = described_class.cross_reference(jira_issue, noteable, author) + + expect(result).to eq(success_message) + end + end + end + context 'in JIRA issue tracker' do before { jira_service_settings } @@ -550,16 +576,48 @@ describe SystemNoteService, services: true do subject { described_class.cross_reference(jira_issue, commit, author) } it { is_expected.to eq(success_message) } + + it "creates remote link" do + subject + + expect(WebMock).to have_requested(:post, jira_api_remote_link_url(jira_issue)).with( + body: hash_including( + GlobalID: "GitLab", + object: { + url: namespace_project_commit_url(project.namespace, project, commit), + title: "GitLab: Mentioned on commit - #{commit.title}", + icon: { title: "GitLab", url16x16: "https://gitlab.com/favicon.ico" }, + status: { resolved: false } + } + ) + ).once + end end end - context 'issue from an issue' do + context 'in commit' do context 'in JIRA issue tracker' do before { jira_service_settings } subject { described_class.cross_reference(jira_issue, issue, author) } it { is_expected.to eq(success_message) } + + it "creates remote link" do + subject + + expect(WebMock).to have_requested(:post, jira_api_remote_link_url(jira_issue)).with( + body: hash_including( + GlobalID: "GitLab", + object: { + url: namespace_project_issue_url(project.namespace, project, issue), + title: "GitLab: Mentioned on issue - #{issue.title}", + icon: { title: "GitLab", url16x16: "https://gitlab.com/favicon.ico" }, + status: { resolved: false } + } + ) + ).once + end end end @@ -572,6 +630,13 @@ describe SystemNoteService, services: true do subject { described_class.cross_reference(jira_issue, commit, author) } it { is_expected.not_to eq(success_message) } + + it 'does not try to create comment and remote link' do + subject + + expect(WebMock).not_to have_requested(:post, jira_api_comment_url(jira_issue)) + expect(WebMock).not_to have_requested(:post, jira_api_remote_link_url(jira_issue)) + end end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 73cf4c9a24c..bead1a006d1 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -26,10 +26,11 @@ RSpec.configure do |config| config.verbose_retry = true config.display_try_failure_messages = true - config.include Devise::Test::ControllerHelpers, type: :controller + config.include Devise::Test::ControllerHelpers, type: :controller + config.include Devise::Test::ControllerHelpers, type: :view config.include Warden::Test::Helpers, type: :request - config.include LoginHelpers, type: :feature - config.include SearchHelpers, type: :feature + config.include LoginHelpers, type: :feature + config.include SearchHelpers, type: :feature config.include StubConfiguration config.include EmailHelpers config.include TestEnv diff --git a/spec/support/database_connection_helpers.rb b/spec/support/database_connection_helpers.rb new file mode 100644 index 00000000000..763329499f0 --- /dev/null +++ b/spec/support/database_connection_helpers.rb @@ -0,0 +1,9 @@ +module DatabaseConnectionHelpers + def run_with_new_database_connection + pool = ActiveRecord::Base.connection_pool + conn = pool.checkout + yield conn + ensure + pool.checkin(conn) + end +end diff --git a/spec/support/db_cleaner.rb b/spec/support/db_cleaner.rb index ac38e31b77e..247f0954221 100644 --- a/spec/support/db_cleaner.rb +++ b/spec/support/db_cleaner.rb @@ -11,6 +11,10 @@ RSpec.configure do |config| DatabaseCleaner.strategy = :truncation end + config.before(:each, truncate: true) do + DatabaseCleaner.strategy = :truncation + end + config.before(:each) do DatabaseCleaner.start end diff --git a/spec/support/jira_service_helper.rb b/spec/support/jira_service_helper.rb index 96e0dad6b55..929fc0c5182 100644 --- a/spec/support/jira_service_helper.rb +++ b/spec/support/jira_service_helper.rb @@ -6,7 +6,8 @@ module JiraServiceHelper properties = { title: "JIRA tracker", url: JIRA_URL, - project_key: "JIRA" + project_key: "JIRA", + jira_issue_transition_id: '1' } jira_tracker.update_attributes(properties: properties, active: true) @@ -57,6 +58,10 @@ module JiraServiceHelper JIRA_API + "/issue/#{issue_id}/comment" end + def jira_api_remote_link_url(issue_id) + JIRA_API + "/issue/#{issue_id}/remotelink" + end + def jira_api_transition_url(issue_id) JIRA_API + "/issue/#{issue_id}/transitions" end @@ -75,6 +80,7 @@ module JiraServiceHelper WebMock.stub_request(:get, jira_issue_url(issue_id)) WebMock.stub_request(:get, jira_api_test_url) WebMock.stub_request(:post, jira_api_comment_url(issue_id)) + WebMock.stub_request(:post, jira_api_remote_link_url(issue_id)) WebMock.stub_request(:post, jira_api_transition_url(issue_id)) end end diff --git a/spec/views/projects/builds/show.html.haml_spec.rb b/spec/views/projects/builds/show.html.haml_spec.rb index da43622d3f9..e0c77201116 100644 --- a/spec/views/projects/builds/show.html.haml_spec.rb +++ b/spec/views/projects/builds/show.html.haml_spec.rb @@ -1,15 +1,13 @@ require 'spec_helper' -describe 'projects/builds/show' do - include Devise::Test::ControllerHelpers - +describe 'projects/builds/show', :view do let(:project) { create(:project) } - let(:pipeline) do - create(:ci_pipeline, project: project, - sha: project.commit.id) - end let(:build) { create(:ci_build, pipeline: pipeline) } + let(:pipeline) do + create(:ci_pipeline, project: project, sha: project.commit.id) + end + before do assign(:build, build) assign(:project, project) @@ -17,6 +15,129 @@ describe 'projects/builds/show' do allow(view).to receive(:can?).and_return(true) end + describe 'environment info in build view' do + context 'build with latest deployment' do + let(:build) do + create(:ci_build, :success, environment: 'staging') + end + + before do + create(:environment, name: 'staging') + create(:deployment, deployable: build) + end + + it 'shows deployment message' do + expected_text = 'This build is the most recent deployment' + render + + expect(rendered).to have_css( + '.environment-information', text: expected_text) + end + end + + context 'build with outdated deployment' do + let(:build) do + create(:ci_build, :success, environment: 'staging', pipeline: pipeline) + end + + let(:second_build) do + create(:ci_build, :success, environment: 'staging', pipeline: pipeline) + end + + let(:environment) do + create(:environment, name: 'staging', project: project) + end + + let!(:first_deployment) do + create(:deployment, environment: environment, deployable: build) + end + + let!(:second_deployment) do + create(:deployment, environment: environment, deployable: second_build) + end + + it 'shows deployment message' do + expected_text = 'This build is an out-of-date deployment ' \ + "to staging.\nView the most recent deployment ##{second_deployment.iid}." + render + + expect(rendered).to have_css('.environment-information', text: expected_text) + end + end + + context 'build failed to deploy' do + let(:build) do + create(:ci_build, :failed, environment: 'staging', pipeline: pipeline) + end + + let!(:environment) do + create(:environment, name: 'staging', project: project) + end + + it 'shows deployment message' do + expected_text = 'The deployment of this build to staging did not succeed.' + render + + expect(rendered).to have_css( + '.environment-information', text: expected_text) + end + end + + context 'build will deploy' do + let(:build) do + create(:ci_build, :running, environment: 'staging', pipeline: pipeline) + end + + let!(:environment) do + create(:environment, name: 'staging', project: project) + end + + it 'shows deployment message' do + expected_text = 'This build is creating a deployment to staging' + render + + expect(rendered).to have_css( + '.environment-information', text: expected_text) + end + end + + context 'build that failed to deploy and environment has not been created' do + let(:build) do + create(:ci_build, :failed, environment: 'staging', pipeline: pipeline) + end + + let!(:environment) do + create(:environment, name: 'staging', project: project) + end + + it 'shows deployment message' do + expected_text = 'The deployment of this build to staging did not succeed' + render + + expect(rendered).to have_css( + '.environment-information', text: expected_text) + end + end + + context 'build that will deploy and environment has not been created' do + let(:build) do + create(:ci_build, :running, environment: 'staging', pipeline: pipeline) + end + + let!(:environment) do + create(:environment, name: 'staging', project: project) + end + + it 'shows deployment message' do + expected_text = 'This build is creating a deployment to staging' + render + + expect(rendered).to have_css( + '.environment-information', text: expected_text) + end + end + end + context 'when build is running' do before do build.run! diff --git a/spec/workers/authorized_projects_worker_spec.rb b/spec/workers/authorized_projects_worker_spec.rb new file mode 100644 index 00000000000..18a1aab766c --- /dev/null +++ b/spec/workers/authorized_projects_worker_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe AuthorizedProjectsWorker do + describe '#perform' do + it "refreshes user's authorized projects" do + user = create(:user) + + expect(User).to receive(:find_by).with(id: user.id).and_return(user) + expect(user).to receive(:refresh_authorized_projects) + + described_class.new.perform(user.id) + end + + context "when user is not found" do + it "does nothing" do + expect_any_instance_of(User).not_to receive(:refresh_authorized_projects) + + described_class.new.perform(999_999) + end + end + end +end diff --git a/spec/workers/pipeline_metrics_worker_spec.rb b/spec/workers/pipeline_metrics_worker_spec.rb index 2c9e7c2cd02..2d47d93acec 100644 --- a/spec/workers/pipeline_metrics_worker_spec.rb +++ b/spec/workers/pipeline_metrics_worker_spec.rb @@ -15,32 +15,36 @@ describe PipelineMetricsWorker do end describe '#perform' do - subject { described_class.new.perform(pipeline.id) } + before do + described_class.new.perform(pipeline.id) + end context 'when pipeline is running' do let(:status) { 'running' } it 'records the build start time' do - subject - expect(merge_request.reload.metrics.latest_build_started_at).to be_like_time(pipeline.started_at) end it 'clears the build end time' do - subject - expect(merge_request.reload.metrics.latest_build_finished_at).to be_nil end + + it 'records the pipeline' do + expect(merge_request.reload.metrics.pipeline).to eq(pipeline) + end end context 'when pipeline succeeded' do let(:status) { 'success' } it 'records the build end time' do - subject - expect(merge_request.reload.metrics.latest_build_finished_at).to be_like_time(pipeline.finished_at) end + + it 'records the pipeline' do + expect(merge_request.reload.metrics.pipeline).to eq(pipeline) + end end end end