提交 9b124078 编写于 作者: L Lin Jen-Shin

Merge remote-tracking branch 'upstream/master' into fix-cancelling-pipelines

* upstream/master: (302 commits)
  Fix a wrong "The build for this merge request failed" message
  Add documentation about todos and failed builds
  Add Changelog entry for failed builds todos fix
  Do not create TODO when build is allowed to fail
  Fix 404 on some group pages when name contains dot
  Grapify the users API
  Remove duplicate sidekiq throttling parameters
  Fix regression in Merge request form
  Fix wrong link
  Fix test
  Fix broken test
  Changes for stop url to path
  fix typo in gitlab_flow.md ('munch'->'much')
  Fix spec
  Backport some changes done from Time Tracking feature in EE.
  Use build instead create in BroadcastMessage model spec
  Try to fix tests
  Remove unnecessary self from user model
  Expose stop_path for environment to not construct that in frontend
  Bring back the `commit_url` as it's used by CycleAnalytics
  ...
......@@ -330,6 +330,7 @@ gem 'octokit', '~> 4.3.0'
gem 'mail_room', '~> 0.9.0'
gem 'email_reply_parser', '~> 0.5.8'
gem 'html2text'
gem 'ruby-prof', '~> 0.16.2'
......
......@@ -339,6 +339,8 @@ GEM
html-pipeline (1.11.0)
activesupport (>= 2)
nokogiri (~> 1.4)
html2text (0.2.0)
nokogiri (~> 1.6)
htmlentities (4.3.4)
httparty (0.13.7)
json (~> 1.8)
......@@ -873,6 +875,7 @@ DEPENDENCIES
health_check (~> 2.2.0)
hipchat (~> 1.5.0)
html-pipeline (~> 1.11.0)
html2text
httparty (~> 0.13.3)
influxdb (~> 0.2)
jira-ruby (~> 1.1.2)
......
......@@ -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')), ' '));
}
};
......
//= 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>
`,
});
})();
/*= 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>
`,
});
})();
/*= 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>
`,
});
})();
/*= 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>
`,
});
})();
/*= 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>
`,
});
})();
/*= 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>
`,
});
})();
//= 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(),
},
});
});
/* 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();
}
}
/* 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
},
};
})();
/* 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
});
});
......@@ -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);
......@@ -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)) + '...';
};
......
......@@ -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;
})();
......
......@@ -3,26 +3,12 @@
class Pipelines {
constructor() {
this.initGraphToggle();
this.addMarginToBuildColumns();
}
initGraphToggle() {
this.pipelineGraph = document.querySelector('.pipeline-graph');
this.toggleButton = document.querySelector('.toggle-pipeline-btn');
this.toggleButtonText = this.toggleButton.querySelector('.toggle-btn-text');
this.toggleButton.addEventListener('click', this.toggleGraph.bind(this));
}
toggleGraph() {
const graphCollapsed = this.pipelineGraph.classList.contains('graph-collapsed');
this.toggleButton.classList.toggle('graph-collapsed');
this.pipelineGraph.classList.toggle('graph-collapsed');
this.toggleButtonText.textContent = graphCollapsed ? 'Hide' : 'Expand';
}
addMarginToBuildColumns() {
const secondChildBuildNodes = this.pipelineGraph.querySelectorAll('.build:nth-child(2)');
this.pipelineGraph = document.querySelector('.pipeline-graph');
const secondChildBuildNodes = document.querySelector('.pipeline-graph').querySelectorAll('.build:nth-child(2)');
for (buildNodeIndex in secondChildBuildNodes) {
const buildNode = secondChildBuildNodes[buildNodeIndex];
const firstChildBuildNode = buildNode.previousElementSibling;
......
/*= 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>
`,
});
})();
......@@ -39,3 +39,5 @@
@import "framework/typography.scss";
@import "framework/zen.scss";
@import "framework/blank";
@import "framework/wells.scss";
@import "framework/page-header.scss";
......@@ -349,6 +349,12 @@
}
}
.btn-inverted {
&-secondary {
@include btn-outline($white-light, $blue-normal, $blue-normal, $blue-light, $white-light, $blue-light);
}
}
@media (max-width: $screen-xs-max) {
.btn-wide-on-xs {
width: 100%;
......
......@@ -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
}
.page-content-header {
line-height: 34px;
padding: 10px 0;
margin-bottom: 0;
@media (min-width: $screen-sm-min) {
display: flex;
align-items: center;
.header-main-content {
flex: 1;
}
}
.header-action-buttons {
i {
color: $gl-icon-color;
font-size: 13px;
margin-right: 3px;
}
@media (max-width: $screen-xs-max) {
.btn {
width: 100%;
margin-top: 10px;
}
.dropdown {
width: 100%;
}
}
}
.avatar {
@extend .avatar-inline;
margin-left: 0;
@media (min-width: $screen-sm-min) {
margin-left: 4px;
}
}
.commit-committer-link,
.commit-author-link {
color: $gl-gray;
font-weight: bold;
}
.fa-clipboard {
color: $dropdown-title-btn-color;
}
.commit-info {
&.branches {
margin-left: 8px;
}
}
.ci-status-link {
svg {
position: relative;
top: 2px;
margin: 0 2px 0 3px;
}
}
}
......@@ -90,8 +90,8 @@ $table-border-color: #f0f0f0;
$background-color: $gray-light;
$dark-background-color: #f5f5f5;
$table-text-gray: #8f8f8f;
$widget-expand-item: #e8f2f7;
$widget-inner-border: #eef0f2;
$well-expand-item: #e8f2f7;
$well-inner-border: #eef0f2;
/*
* Text
......
.info-well {
background: $background-color;
color: $gl-gray;
border: 1px solid $border-color;
border-radius: $border-radius-default;
.well-segment {
padding: $gl-padding;
&:not(:last-of-type) {
border-bottom: 1px solid $well-inner-border;
}
&.branch-info {
.monospace,
.commit-info {
margin-left: 4px;
}
}
}
.icon-container {
display: inline-block;
margin-right: 8px;
svg {
position: relative;
top: 2px;
height: 16px;
width: 16px;
}
&.commit-icon {
svg {
path {
fill: $gl-text-color;
}
}
}
}
.label.label-gray {
background-color: $well-expand-item;
}
}
......@@ -160,3 +160,9 @@
}
}
}
.admin-builds-table {
.ci-table td:last-child {
min-width: 120px;
}
}
......@@ -40,6 +40,19 @@
margin-bottom: 10px;
}
}
.environment-information {
background-color: $background-color;
border: 1px solid $border-color;
padding: 12px $gl-padding;
border-radius: $border-radius-default;
svg {
position: relative;
top: 1px;
margin-right: 5px;
}
}
}
.build-header {
......@@ -49,10 +62,6 @@
min-height: 58px;
align-items: center;
.btn-inverted {
@include btn-outline($white-light, $blue-normal, $blue-normal, $blue-light, $white-light, $blue-light);
}
@media (max-width: $screen-sm-max) {
padding-right: 40px;
......@@ -63,14 +72,14 @@
.header-content {
flex: 1;
}
a {
color: $gl-gray;
a {
color: $gl-gray;
&:hover {
color: $gl-link-color;
text-decoration: none;
&:hover {
color: $gl-link-color;
text-decoration: none;
}
}
}
......
......@@ -26,143 +26,12 @@
white-space: pre-wrap;
}
.commit-info-row {
margin-bottom: 10px;
line-height: 24px;
padding-top: 6px;
&.commit-info-row-header {
line-height: 34px;
padding: 10px 0;
margin-bottom: 0;
@media (min-width: $screen-sm-min) {
display: flex;
align-items: center;
.commit-meta {
flex: 1;
}
}
.commit-hash-full {
@media (max-width: $screen-sm-max) {
width: 80px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
vertical-align: bottom;
}
}
.commit-action-buttons {
i {
color: $gl-icon-color;
font-size: 13px;
margin-right: 3px;
}
@media (max-width: $screen-xs-max) {
.dropdown {
width: 100%;
margin-top: 10px;
}
.dropdown-toggle {
width: 100%;
}
}
}
}
.avatar {
@extend .avatar-inline;
margin-left: 0;
@media (min-width: $screen-sm-min) {
margin-left: 4px;
}
}
.commit-committer-link,
.commit-author-link {
color: $gl-gray;
font-weight: bold;
}
.fa-clipboard {
color: $dropdown-title-btn-color;
}
.commit-info {
&.branches {
margin-left: 8px;
}
}
.ci-status-link {
svg {
position: relative;
top: 2px;
margin: 0 2px 0 3px;
}
}
}
.js-details-expand {
&:hover {
text-decoration: none;
}
}
.commit-info-widget {
background: $background-color;
color: $gl-gray;
border: 1px solid $border-color;
border-radius: $border-radius-default;
.widget-row {
padding: $gl-padding;
&:not(:last-of-type) {
border-bottom: 1px solid $widget-inner-border;
}
&.branch-info {
.monospace,
.commit-info {
margin-left: 4px;
}
}
}
.icon-container {
display: inline-block;
margin-right: 8px;
svg {
position: relative;
top: 2px;
height: 16px;
width: 16px;
}
&.commit-icon {
svg {
path {
fill: $gl-text-color;
}
}
}
}
.label.label-gray {
background-color: $widget-expand-item;
}
}
.ci-status-link {
svg {
overflow: visible;
......@@ -184,6 +53,17 @@
}
}
.commit-hash-full {
@media (max-width: $screen-sm-max) {
width: 80px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
vertical-align: bottom;
}
}
.file-stats {
ul {
list-style: none;
......
.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;
......
......@@ -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;
}
}
......@@ -61,7 +61,7 @@
}
.ci_widget {
border-bottom: 1px solid $widget-inner-border;
border-bottom: 1px solid $well-inner-border;
svg {
margin-right: 4px;
......
......@@ -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;
......
......@@ -300,6 +300,8 @@
.pipeline-graph {
width: 100%;
background-color: $background-color;
padding: $gl-padding;
overflow: auto;
white-space: nowrap;
transition: max-height 0.3s, padding 0.3s;
......@@ -363,6 +365,7 @@
.build {
border: 1px solid $border-color;
background-color: $white-light;
position: relative;
padding: 7px 10px 8px;
border-radius: 30px;
......
......@@ -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;
}
}
}
......
......@@ -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]
......
module CycleAnalyticsParams
extend ActiveSupport::Concern
def start_date(params)
params[:start_date] == '30' ? 30.days.ago : 90.days.ago
end
end
......@@ -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
......
......@@ -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
......
......@@ -7,7 +7,6 @@ module IssuesAction
@issues = issues_collection
.non_archived
.preload(:author, :project)
.page(params[:page])
respond_to do |format|
......
......@@ -7,7 +7,6 @@ module MergeRequestsAction
@merge_requests = merge_requests_collection
.non_archived
.preload(:author, :target_project)
.page(params[:page])
end
end
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
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?
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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!
......
......@@ -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)
......
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
module EnvironmentsHelper
def environments_list_data
{
endpoint: namespace_project_environments_path(@project.namespace, @project, format: :json)
}
end
end
......@@ -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]
......
......@@ -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
......
......@@ -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
......@@ -7,6 +7,8 @@ module Ci
belongs_to :trigger_request
belongs_to :erased_by, class_name: 'User'
has_many :deployments, as: :deployable
serialize :options
serialize :yaml_variables
......@@ -68,7 +70,11 @@ module Ci
environment: build.environment,
status_event: 'enqueue'
)
MergeRequests::AddTodoWhenBuildFailsService.new(build.project, nil).close(new_build)
MergeRequests::AddTodoWhenBuildFailsService
.new(build.project, nil)
.close(new_build)
build.pipeline.mark_as_processable_after_stage(build.stage_idx)
new_build
end
......@@ -125,6 +131,34 @@ module Ci
!self.pipeline.statuses.latest.include?(self)
end
def expanded_environment_name
ExpandVariables.expand(environment, variables) if environment
end
def has_environment?
self.environment.present?
end
def starts_environment?
has_environment? && self.environment_action == 'start'
end
def stops_environment?
has_environment? && self.environment_action == 'stop'
end
def environment_action
self.options.fetch(:environment, {}).fetch(:action, 'start')
end
def outdated_deployment?
success? && !last_deployment.try(:last?)
end
def last_deployment
deployments.last
end
def depends_on_builds
# Get builds of the same type
latest_builds = self.pipeline.builds.latest
......
......@@ -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
{
......
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
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
......@@ -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
......@@ -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
......
......@@ -686,7 +686,7 @@ class MergeRequest < ActiveRecord::Base
def mergeable_ci_state?
return true unless project.only_allow_merge_if_build_succeeds?
!pipeline || pipeline.success?
!pipeline || pipeline.success? || pipeline.skipped?
end
def environments
......
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?
......
......@@ -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
......
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
......@@ -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
......
......@@ -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
# 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
# == 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
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
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
......@@ -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)
......
......@@ -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
......
......@@ -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
......
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
class AnalyticsBuildSerializer < BaseSerializer
entity AnalyticsBuildEntity
end
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
class AnalyticsCommitSerializer < BaseSerializer
entity AnalyticsCommitEntity
end
class AnalyticsGenericSerializer < BaseSerializer
def represent(resource, opts = {})
resource.symbolize_keys!
super(resource, opts)
end
end
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
class AnalyticsIssueSerializer < AnalyticsGenericSerializer
entity AnalyticsIssueEntity
end
class AnalyticsMergeRequestEntity < AnalyticsIssueEntity
expose :state
expose :url do |object|
url_to(:namespace_project_merge_request, id: object[:iid].to_s)
end
end
class AnalyticsMergeRequestSerializer < AnalyticsGenericSerializer
entity AnalyticsMergeRequestEntity
end
......@@ -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
......@@ -9,4 +9,11 @@ class CommitEntity < API::Entities::RepoCommit
request.project,
id: commit.id)
end
expose :commit_path do |commit|
namespace_project_tree_path(
request.project.namespace,
request.project,
id: commit.id)
end
end
......@@ -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)
......
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
......@@ -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)
......
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
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
class IssueSerializer < BaseSerializer
entity IssueEntity
end
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
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
class MergeRequestSerializer < BaseSerializer
entity MergeRequestEntity
end
......@@ -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
......
module MergeRequests
class AddTodoWhenBuildFailsService < MergeRequests::BaseService
# Adds a todo to the parent merge_request when a CI build fails
#
def execute(commit_status)
return if commit_status.allow_failure?
commit_status_merge_requests(commit_status) do |merge_request|
todo_service.merge_request_build_failed(merge_request)
end
end
# Closes any pending build failed todos for the parent MRs when a build is retried
# Closes any pending build failed todos for the parent MRs when a
# build is retried
#
def close(commit_status)
commit_status_merge_requests(commit_status) do |merge_request|
todo_service.merge_request_build_retried(merge_request)
......
......@@ -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
......
......@@ -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
......
......@@ -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?
......
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
......@@ -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
......@@ -7,7 +7,7 @@
.col-sm-10
= render 'shared/choose_group_avatar_button', f: f
= render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group
= render 'shared/visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group
.form-group
.col-sm-offset-2.col-sm-10
......
- page_title "Edit", @group.name, "Groups"
%h3.page-title Edit group: #{@group.name}
%hr
= render 'form'
= render 'form', visibility_level: @group.visibility_level
- page_title "New Group"
%h3.page-title New group
%hr
= render 'form'
= render 'form', visibility_level: default_group_visibility
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册