diff options
| author | Regis <boudinot.regis@yahoo.com> | 2016-11-21 07:17:15 -0600 |
|---|---|---|
| committer | Regis <boudinot.regis@yahoo.com> | 2016-11-21 07:17:15 -0600 |
| commit | c8788fff688b834dcd59f38167bd7e96b6196d27 (patch) | |
| tree | 558b82fac13b774f3e462056b6cf01fdf9446092 /app | |
| parent | ff4edf37f3d8e7742292db6d5e50ba6f599950ff (diff) | |
| parent | 671c6d7d577d6b872bee7634c4eaf6b4da16919f (diff) | |
| download | gitlab-ce-c8788fff688b834dcd59f38167bd7e96b6196d27.tar.gz | |
Merge branch 'master' into auto-pipelines-vue
Diffstat (limited to 'app')
98 files changed, 2380 insertions, 448 deletions
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: ` + <div :class="cssContainerClass"> + <div class="top-area"> + <ul v-if="!isLoading" class="nav-links"> + <li v-bind:class="{ 'active': scope === undefined }"> + <a :href="projectEnvironmentsPath"> + Available + <span + class="badge js-available-environments-count" + v-html="state.availableCounter"></span> + </a> + </li> + <li v-bind:class="{ 'active' : scope === 'stopped' }"> + <a :href="projectStoppedEnvironmentsPath"> + Stopped + <span + class="badge js-stopped-environments-count" + v-html="state.stoppedCounter"></span> + </a> + </li> + </ul> + <div v-if="canCreateEnvironmentParsed && !isLoading" class="nav-controls"> + <a :href="newEnvironmentPath" class="btn btn-create"> + New environment + </a> + </div> + </div> + + <div class="environments-container"> + <div class="environments-list-loading text-center" v-if="isLoading"> + <i class="fa fa-spinner spin"></i> + </div> + + <div + class="blank-state blank-state-no-icon" + v-if="!isLoading && state.environments.length === 0"> + <h2 class="blank-state-title"> + You don't have any environments right now. + </h2> + <p class="blank-state-text"> + Environments are places where code gets deployed, such as staging or production. + <br /> + <a :href="helpPagePath"> + Read more about environments + </a> + </p> + + <a + v-if="canCreateEnvironmentParsed" + :href="newEnvironmentPath" + class="btn btn-create"> + New Environment + </a> + </div> + + <div + class="table-holder" + v-if="!isLoading && state.environments.length > 0"> + <table class="table ci-table environments"> + <thead> + <tr> + <th>Environment</th> + <th>Last deployment</th> + <th>Build</th> + <th>Commit</th> + <th></th> + <th class="hidden-xs"></th> + </tr> + </thead> + <tbody> + <template v-for="model in filteredEnvironments" + v-bind:model="model"> + + <tr + is="environment-item" + :model="model" + :toggleRow="toggleRow.bind(model)" + :can-create-deployment="canCreateDeploymentParsed" + :can-read-environment="canReadEnvironmentParsed"></tr> + + <tr v-if="model.isOpen && model.children && model.children.length > 0" + is="environment-item" + v-for="children in model.children" + :model="children" + :toggleRow="toggleRow.bind(children)"> + </tr> + + </template> + </tbody> + </table> + </div> + </div> + </div> + `, + }); +})(); 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: ` + <div class="inline"> + <div class="dropdown"> + <a class="dropdown-new btn btn-default" data-toggle="dropdown"> + <span class="dropdown-play-icon-container"> + </span> + <i class="fa fa-caret-down"></i> + </a> + + <ul class="dropdown-menu dropdown-menu-align-right"> + <li v-for="action in actions"> + <a :href="action.play_path" + data-method="post" + rel="nofollow" + class="js-manual-action-link"> + <span class="action-play-icon-container"> + </span> + <span v-html="action.name"></span> + </a> + </li> + </ul> + </div> + </div> + `, + }); +})(); 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: ` + <a class="btn external_url" :href="external_url" target="_blank"> + <i class="fa fa-external-link"></i> + </a> + `, + }); +})(); 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.<Object>|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: ` + <tr> + <td v-bind:class="{ 'children-row': isChildren}"> + <a + v-if="!isFolder" + class="environment-name" + :href="model.environment_path" + v-html="model.name"> + </a> + <span v-else v-on:click="toggleRow(model)" class="folder-name"> + <span class="folder-icon"> + <i v-show="model.isOpen" class="fa fa-caret-down"></i> + <i v-show="!model.isOpen" class="fa fa-caret-right"></i> + </span> + + <span v-html="model.name"></span> + + <span class="badge" v-html="childrenCounter"></span> + </span> + </td> + + <td class="deployment-column"> + <span + v-if="shouldRenderDeploymentID" + v-html="deploymentInternalId"> + </span> + + <span v-if="!isFolder && deploymentHasUser"> + by + <a :href="deploymentUser.web_url" class="js-deploy-user-container"> + <img class="avatar has-tooltip s20" + :src="deploymentUser.avatar_url" + :alt="userImageAltDescription" + :title="deploymentUser.username" /> + </a> + </span> + </td> + + <td> + <a v-if="shouldRenderBuildName" + class="build-link" + :href="model.last_deployment.deployable.build_path" + v-html="buildName"> + </a> + </td> + + <td> + <div v-if="!isFolder && hasLastDeploymentKey" class="js-commit-component"> + <commit-component + :tag="commitTag" + :ref="commitRef" + :commit_url="commitUrl" + :short_sha="commitShortSha" + :title="commitTitle" + :author="commitAuthor"> + </commit-component> + </div> + <p v-if="!isFolder && !hasLastDeploymentKey" class="commit-title"> + No deployments yet + </p> + </td> + + <td> + <span + v-if="!isFolder && model.last_deployment" + class="environment-created-date-timeago" + v-html="createdDate"> + </span> + </td> + + <td class="hidden-xs"> + <div v-if="!isFolder"> + <div v-if="hasManualActions && canCreateDeployment" + class="inline js-manual-actions-container"> + <actions-component + :actions="manualActions"> + </actions-component> + </div> + + <div v-if="model.external_url && canReadEnvironment" + class="inline js-external-url-container"> + <external-url-component + :external_url="model.external_url"> + </external_url-component> + </div> + + <div v-if="isStoppable && canCreateDeployment" + class="inline js-stop-component-container"> + <stop-component + :stop_url="model.stop_path"> + </stop-component> + </div> + + <div v-if="canRetry && canCreateDeployment" + class="inline js-rollback-component-container"> + <rollback-component + :is_last_deployment="isLastDeployment" + :retry_url="retryUrl"> + </rollback-component> + </div> + </div> + </td> + </tr> + `, + }); +})(); 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: ` + <a class="btn" :href="retry_url" data-method="post" rel="nofollow"> + <span v-if="is_last_deployment"> + Re-deploy + </span> + <span v-else> + Rollback + </span> + </a> + `, + }); +})(); 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: ` + <a + class="btn stop-env-link" + :href="stop_url" + data-confirm="Are you sure you want to stop this environment?" + data-method="post" + rel="nofollow"> + <i class="fa fa-stop stop-env-icon"></i> + </a> + `, + }); +})(); 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/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: ` + <div class="branch-commit"> + + <div v-if="hasRef" class="icon-container"> + <i v-if="tag" class="fa fa-tag"></i> + <i v-if="!tag" class="fa fa-code-fork"></i> + </div> + + <a v-if="hasRef" + class="monospace branch-name" + :href="ref.ref_url" + v-html="ref.name"> + </a> + + <div class="icon-container commit-icon commit-icon-container"> + </div> + + <a class="commit-id monospace" + :href="commit_url" + v-html="short_sha"> + </a> + + <p class="commit-title"> + <span v-if="title"> + <a v-if="hasAuthor" + class="avatar-image-container" + :href="author.web_url"> + <img + class="avatar has-tooltip s20" + :src="author.avatar_url" + :alt="userImageAltDescription" + :title="author.username" /> + </a> + + <a class="commit-row-message" + :href="commit_url" v-html="title"> + </a> + </span> + <span v-else> + Cant find HEAD commit for this branch + </span> + </p> + </div> + `, + }); +})(); 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/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 5320f3aba66..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 { 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/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/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..5d2e7d94190 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 @@ -125,6 +127,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 <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) - 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/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{(?<issue>\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 - 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}" + # 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 + + { + 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.<br/> + To setup this Service you need to create a new <b>Slash commands</b> in your Mattermost integration panel.<br/> + <br/> + 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 121ca76a16f..49f4db36295 100644 --- a/app/serializers/commit_entity.rb +++ b/app/serializers/commit_entity.rb @@ -13,4 +13,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/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/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/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/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/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/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' - - .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) - - - if field - %p - = form.text_field field[:name], class: "form-control", placeholder: field[:placeholder] - - %p.light - = service_event_description(event) +- 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 + + - field = @service.event_field(event) + + - if field + %p + = form.text_field field[:name], class: "form-control", placeholder: field[:placeholder] + + %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 @@ -<svg width="14" height="14" class="ci-status-icon-skipped" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><title>Group Copy 31</title><g fill="#5C5C5C" fill-rule="evenodd"><path d="M10 17.857c4.286 0 7.857-3.571 7.857-7.857S14.286 2.143 10 2.143 2.143 5.714 2.143 10 5.714 17.857 10 17.857M10 0c5.571 0 10 4.429 10 10s-4.429 10-10 10S0 15.571 0 10 4.429 0 10 0"/><path d="M10.986 11l-1.293 1.293a1 1 0 0 0 1.414 1.414l2.644-2.644a1.505 1.505 0 0 0 0-2.126l-2.644-2.644a1 1 0 0 0-1.414 1.414L10.986 9H6.4a1 1 0 0 0 0 2h4.586z"/></g></svg> +<svg width="14" height="14" class="ci-status-icon-skipped" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><g fill="#5C5C5C" fill-rule="evenodd"><path d="M10 17.857c4.286 0 7.857-3.571 7.857-7.857S14.286 2.143 10 2.143 2.143 5.714 2.143 10 5.714 17.857 10 17.857M10 0c5.571 0 10 4.429 10 10s-4.429 10-10 10S0 15.571 0 10 4.429 0 10 0"/><path d="M10.986 11l-1.293 1.293a1 1 0 0 0 1.414 1.414l2.644-2.644a1.505 1.505 0 0 0 0-2.126l-2.644-2.644a1 1 0 0 0-1.414 1.414L10.986 9H6.4a1 1 0 0 0 0 2h4.586z"/></g></svg> 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) |
