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 ...
This commit is contained in:
commit
9b1240783b
1
Gemfile
1
Gemfile
|
@ -330,6 +330,7 @@ gem 'octokit', '~> 4.3.0'
|
|||
gem 'mail_room', '~> 0.9.0'
|
||||
|
||||
gem 'email_reply_parser', '~> 0.5.8'
|
||||
gem 'html2text'
|
||||
|
||||
gem 'ruby-prof', '~> 0.16.2'
|
||||
|
||||
|
|
|
@ -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')), ' '));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,248 @@
|
|||
//= require vue
|
||||
//= require vue-resource
|
||||
//= require_tree ../services/
|
||||
//= require ./environment_item
|
||||
|
||||
/* globals Vue, EnvironmentsService */
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
(() => { // eslint-disable-line
|
||||
window.gl = window.gl || {};
|
||||
|
||||
/**
|
||||
* Given the visibility prop provided by the url query parameter and which
|
||||
* changes according to the active tab we need to filter which environments
|
||||
* should be visible.
|
||||
*
|
||||
* The environments array is a recursive tree structure and we need to filter
|
||||
* both root level environments and children environments.
|
||||
*
|
||||
* In order to acomplish that, both `filterState` and `filterEnvironmnetsByState`
|
||||
* functions work together.
|
||||
* The first one works as the filter that verifies if the given environment matches
|
||||
* the given state.
|
||||
* The second guarantees both root level and children elements are filtered as well.
|
||||
*/
|
||||
|
||||
const filterState = state => environment => environment.state === state && environment;
|
||||
/**
|
||||
* Given the filter function and the array of environments will return only
|
||||
* the environments that match the state provided to the filter function.
|
||||
*
|
||||
* @param {Function} fn
|
||||
* @param {Array} array
|
||||
* @return {Array}
|
||||
*/
|
||||
const filterEnvironmnetsByState = (fn, arr) => arr.map((item) => {
|
||||
if (item.children) {
|
||||
const filteredChildren = filterEnvironmnetsByState(fn, item.children).filter(Boolean);
|
||||
if (filteredChildren.length) {
|
||||
item.children = filteredChildren;
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return fn(item);
|
||||
}).filter(Boolean);
|
||||
|
||||
window.gl.environmentsList.EnvironmentsComponent = Vue.component('environment-component', {
|
||||
props: {
|
||||
store: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
|
||||
components: {
|
||||
'environment-item': window.gl.environmentsList.EnvironmentItem,
|
||||
},
|
||||
|
||||
data() {
|
||||
const environmentsData = document.querySelector('#environments-list-view').dataset;
|
||||
|
||||
return {
|
||||
state: this.store.state,
|
||||
visibility: 'available',
|
||||
isLoading: false,
|
||||
cssContainerClass: environmentsData.cssClass,
|
||||
endpoint: environmentsData.environmentsDataEndpoint,
|
||||
canCreateDeployment: environmentsData.canCreateDeployment,
|
||||
canReadEnvironment: environmentsData.canReadEnvironment,
|
||||
canCreateEnvironment: environmentsData.canCreateEnvironment,
|
||||
projectEnvironmentsPath: environmentsData.projectEnvironmentsPath,
|
||||
projectStoppedEnvironmentsPath: environmentsData.projectStoppedEnvironmentsPath,
|
||||
newEnvironmentPath: environmentsData.newEnvironmentPath,
|
||||
helpPagePath: environmentsData.helpPagePath,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
filteredEnvironments() {
|
||||
return filterEnvironmnetsByState(filterState(this.visibility), this.state.environments);
|
||||
},
|
||||
|
||||
scope() {
|
||||
return this.$options.getQueryParameter('scope');
|
||||
},
|
||||
|
||||
canReadEnvironmentParsed() {
|
||||
return this.$options.convertPermissionToBoolean(this.canReadEnvironment);
|
||||
},
|
||||
|
||||
canCreateDeploymentParsed() {
|
||||
return this.$options.convertPermissionToBoolean(this.canCreateDeployment);
|
||||
},
|
||||
|
||||
canCreateEnvironmentParsed() {
|
||||
return this.$options.convertPermissionToBoolean(this.canCreateEnvironment);
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches all the environmnets and stores them.
|
||||
* Toggles loading property.
|
||||
*/
|
||||
created() {
|
||||
gl.environmentsService = new EnvironmentsService(this.endpoint);
|
||||
|
||||
const scope = this.$options.getQueryParameter('scope');
|
||||
if (scope) {
|
||||
this.visibility = scope;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
return gl.environmentsService.all()
|
||||
.then(resp => resp.json())
|
||||
.then((json) => {
|
||||
this.store.storeEnvironments(json);
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Transforms the url parameter into an object and
|
||||
* returns the one requested.
|
||||
*
|
||||
* @param {String} param
|
||||
* @returns {String} The value of the requested parameter.
|
||||
*/
|
||||
getQueryParameter(parameter) {
|
||||
return window.location.search.substring(1).split('&').reduce((acc, param) => {
|
||||
const paramSplited = param.split('=');
|
||||
acc[paramSplited[0]] = paramSplited[1];
|
||||
return acc;
|
||||
}, {})[parameter];
|
||||
},
|
||||
|
||||
/**
|
||||
* Converts permission provided as strings to booleans.
|
||||
* @param {String} string
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
convertPermissionToBoolean(string) {
|
||||
return string === 'true';
|
||||
},
|
||||
|
||||
methods: {
|
||||
toggleRow(model) {
|
||||
return this.store.toggleFolder(model.name);
|
||||
},
|
||||
},
|
||||
|
||||
template: `
|
||||
<div :class="cssContainerClass">
|
||||
<div class="top-area">
|
||||
<ul v-if="!isLoading" class="nav-links">
|
||||
<li v-bind:class="{ 'active': scope === undefined }">
|
||||
<a :href="projectEnvironmentsPath">
|
||||
Available
|
||||
<span
|
||||
class="badge js-available-environments-count"
|
||||
v-html="state.availableCounter"></span>
|
||||
</a>
|
||||
</li>
|
||||
<li v-bind:class="{ 'active' : scope === 'stopped' }">
|
||||
<a :href="projectStoppedEnvironmentsPath">
|
||||
Stopped
|
||||
<span
|
||||
class="badge js-stopped-environments-count"
|
||||
v-html="state.stoppedCounter"></span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="canCreateEnvironmentParsed && !isLoading" class="nav-controls">
|
||||
<a :href="newEnvironmentPath" class="btn btn-create">
|
||||
New environment
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="environments-container">
|
||||
<div class="environments-list-loading text-center" v-if="isLoading">
|
||||
<i class="fa fa-spinner spin"></i>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="blank-state blank-state-no-icon"
|
||||
v-if="!isLoading && state.environments.length === 0">
|
||||
<h2 class="blank-state-title">
|
||||
You don't have any environments right now.
|
||||
</h2>
|
||||
<p class="blank-state-text">
|
||||
Environments are places where code gets deployed, such as staging or production.
|
||||
<br />
|
||||
<a :href="helpPagePath">
|
||||
Read more about environments
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<a
|
||||
v-if="canCreateEnvironmentParsed"
|
||||
:href="newEnvironmentPath"
|
||||
class="btn btn-create">
|
||||
New Environment
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="table-holder"
|
||||
v-if="!isLoading && state.environments.length > 0">
|
||||
<table class="table ci-table environments">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Environment</th>
|
||||
<th>Last deployment</th>
|
||||
<th>Build</th>
|
||||
<th>Commit</th>
|
||||
<th></th>
|
||||
<th class="hidden-xs"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="model in filteredEnvironments"
|
||||
v-bind:model="model">
|
||||
|
||||
<tr
|
||||
is="environment-item"
|
||||
:model="model"
|
||||
:toggleRow="toggleRow.bind(model)"
|
||||
:can-create-deployment="canCreateDeploymentParsed"
|
||||
:can-read-environment="canReadEnvironmentParsed"></tr>
|
||||
|
||||
<tr v-if="model.isOpen && model.children && model.children.length > 0"
|
||||
is="environment-item"
|
||||
v-for="children in model.children"
|
||||
:model="children"
|
||||
:toggleRow="toggleRow.bind(children)">
|
||||
</tr>
|
||||
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})();
|
|
@ -0,0 +1,67 @@
|
|||
/*= require vue */
|
||||
/* global Vue */
|
||||
|
||||
(() => {
|
||||
window.gl = window.gl || {};
|
||||
window.gl.environmentsList = window.gl.environmentsList || {};
|
||||
|
||||
window.gl.environmentsList.ActionsComponent = Vue.component('actions-component', {
|
||||
props: {
|
||||
actions: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Appends the svg icon that were render in the index page.
|
||||
* In order to reuse the svg instead of copy and paste in this template
|
||||
* we need to render it outside this component using =custom_icon partial.
|
||||
*
|
||||
* TODO: Remove this when webpack is merged.
|
||||
*
|
||||
*/
|
||||
mounted() {
|
||||
const playIcon = document.querySelector('.play-icon-svg.hidden svg');
|
||||
|
||||
const dropdownContainer = this.$el.querySelector('.dropdown-play-icon-container');
|
||||
const actionContainers = this.$el.querySelectorAll('.action-play-icon-container');
|
||||
// Phantomjs does not have support to iterate a nodelist.
|
||||
const actionsArray = [].slice.call(actionContainers);
|
||||
|
||||
if (playIcon && actionsArray && dropdownContainer) {
|
||||
dropdownContainer.appendChild(playIcon.cloneNode(true));
|
||||
|
||||
actionsArray.forEach((element) => {
|
||||
element.appendChild(playIcon.cloneNode(true));
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
template: `
|
||||
<div class="inline">
|
||||
<div class="dropdown">
|
||||
<a class="dropdown-new btn btn-default" data-toggle="dropdown">
|
||||
<span class="dropdown-play-icon-container">
|
||||
</span>
|
||||
<i class="fa fa-caret-down"></i>
|
||||
</a>
|
||||
|
||||
<ul class="dropdown-menu dropdown-menu-align-right">
|
||||
<li v-for="action in actions">
|
||||
<a :href="action.play_path"
|
||||
data-method="post"
|
||||
rel="nofollow"
|
||||
class="js-manual-action-link">
|
||||
<span class="action-play-icon-container">
|
||||
</span>
|
||||
<span v-html="action.name"></span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})();
|
|
@ -0,0 +1,22 @@
|
|||
/*= require vue */
|
||||
/* global Vue */
|
||||
|
||||
(() => {
|
||||
window.gl = window.gl || {};
|
||||
window.gl.environmentsList = window.gl.environmentsList || {};
|
||||
|
||||
window.gl.environmentsList.ExternalUrlComponent = Vue.component('external-url-component', {
|
||||
props: {
|
||||
external_url: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
|
||||
template: `
|
||||
<a class="btn external_url" :href="external_url" target="_blank">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</a>
|
||||
`,
|
||||
});
|
||||
})();
|
|
@ -0,0 +1,494 @@
|
|||
/*= require lib/utils/timeago */
|
||||
/*= require lib/utils/text_utility */
|
||||
/*= require vue_common_component/commit */
|
||||
/*= require ./environment_actions */
|
||||
/*= require ./environment_external_url */
|
||||
/*= require ./environment_stop */
|
||||
/*= require ./environment_rollback */
|
||||
|
||||
/* globals Vue, timeago */
|
||||
|
||||
(() => {
|
||||
/**
|
||||
* Envrionment Item Component
|
||||
*
|
||||
* Used in a hierarchical structure to show folders with children
|
||||
* in a table.
|
||||
* Recursive component based on [Tree View](https://vuejs.org/examples/tree-view.html)
|
||||
*
|
||||
* See this [issue](https://gitlab.com/gitlab-org/gitlab-ce/issues/22539)
|
||||
* for more information.15
|
||||
*/
|
||||
|
||||
window.gl = window.gl || {};
|
||||
window.gl.environmentsList = window.gl.environmentsList || {};
|
||||
|
||||
gl.environmentsList.EnvironmentItem = Vue.component('environment-item', {
|
||||
|
||||
components: {
|
||||
'commit-component': window.gl.CommitComponent,
|
||||
'actions-component': window.gl.environmentsList.ActionsComponent,
|
||||
'external-url-component': window.gl.environmentsList.ExternalUrlComponent,
|
||||
'stop-component': window.gl.environmentsList.StopComponent,
|
||||
'rollback-component': window.gl.environmentsList.RollbackComponent,
|
||||
},
|
||||
|
||||
props: {
|
||||
model: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({}),
|
||||
},
|
||||
|
||||
toggleRow: {
|
||||
type: Function,
|
||||
required: false,
|
||||
},
|
||||
|
||||
canCreateDeployment: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
|
||||
canReadEnvironment: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
rowClass: {
|
||||
'children-row': this.model['vue-isChildren'],
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
||||
/**
|
||||
* If an item has a `children` entry it means it is a folder.
|
||||
* Folder items have different behaviours - it is possible to toggle
|
||||
* them and show their children.
|
||||
*
|
||||
* @returns {Boolean|Undefined}
|
||||
*/
|
||||
isFolder() {
|
||||
return this.model.children && this.model.children.length > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* If an item is inside a folder structure will return true.
|
||||
* Used for css purposes.
|
||||
*
|
||||
* @returns {Boolean|undefined}
|
||||
*/
|
||||
isChildren() {
|
||||
return this.model['vue-isChildren'];
|
||||
},
|
||||
|
||||
/**
|
||||
* Counts the number of environments in each folder.
|
||||
* Used to show a badge with the counter.
|
||||
*
|
||||
* @returns {Number|Undefined} The number of environments for the current folder.
|
||||
*/
|
||||
childrenCounter() {
|
||||
return this.model.children && this.model.children.length;
|
||||
},
|
||||
|
||||
/**
|
||||
* Verifies if `last_deployment` key exists in the current Envrionment.
|
||||
* This key is required to render most of the html - this method works has
|
||||
* an helper.
|
||||
*
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
hasLastDeploymentKey() {
|
||||
if (this.model.last_deployment &&
|
||||
!this.$options.isObjectEmpty(this.model.last_deployment)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Verifies is the given environment has manual actions.
|
||||
* Used to verify if we should render them or nor.
|
||||
*
|
||||
* @returns {Boolean|Undefined}
|
||||
*/
|
||||
hasManualActions() {
|
||||
return this.model.last_deployment && this.model.last_deployment.manual_actions &&
|
||||
this.model.last_deployment.manual_actions.length > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the value of the `stoppable?` key provided in the response.
|
||||
*
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
isStoppable() {
|
||||
return this.model['stoppable?'];
|
||||
},
|
||||
|
||||
/**
|
||||
* Verifies if the `deployable` key is present in `last_deployment` key.
|
||||
* Used to verify whether we should or not render the rollback partial.
|
||||
*
|
||||
* @returns {Boolean|Undefined}
|
||||
*/
|
||||
canRetry() {
|
||||
return this.hasLastDeploymentKey &&
|
||||
this.model.last_deployment &&
|
||||
this.model.last_deployment.deployable;
|
||||
},
|
||||
|
||||
/**
|
||||
* Human readable date.
|
||||
*
|
||||
* @returns {String}
|
||||
*/
|
||||
createdDate() {
|
||||
const timeagoInstance = new timeago(); // eslint-disable-line
|
||||
|
||||
return timeagoInstance.format(this.model.created_at);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the manual actions with the name parsed.
|
||||
*
|
||||
* @returns {Array.<Object>|Undefined}
|
||||
*/
|
||||
manualActions() {
|
||||
if (this.hasManualActions) {
|
||||
return this.model.last_deployment.manual_actions.map((action) => {
|
||||
const parsedAction = {
|
||||
name: gl.text.humanize(action.name),
|
||||
play_path: action.play_path,
|
||||
};
|
||||
return parsedAction;
|
||||
});
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Builds the string used in the user image alt attribute.
|
||||
*
|
||||
* @returns {String}
|
||||
*/
|
||||
userImageAltDescription() {
|
||||
if (this.model.last_deployment &&
|
||||
this.model.last_deployment.user &&
|
||||
this.model.last_deployment.user.username) {
|
||||
return `${this.model.last_deployment.user.username}'s avatar'`;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
/**
|
||||
* If provided, returns the commit tag.
|
||||
*
|
||||
* @returns {String|Undefined}
|
||||
*/
|
||||
commitTag() {
|
||||
if (this.model.last_deployment &&
|
||||
this.model.last_deployment.tag) {
|
||||
return this.model.last_deployment.tag;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* If provided, returns the commit ref.
|
||||
*
|
||||
* @returns {Object|Undefined}
|
||||
*/
|
||||
commitRef() {
|
||||
if (this.model.last_deployment && this.model.last_deployment.ref) {
|
||||
return this.model.last_deployment.ref;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* If provided, returns the commit url.
|
||||
*
|
||||
* @returns {String|Undefined}
|
||||
*/
|
||||
commitUrl() {
|
||||
if (this.model.last_deployment &&
|
||||
this.model.last_deployment.commit &&
|
||||
this.model.last_deployment.commit.commit_path) {
|
||||
return this.model.last_deployment.commit.commit_path;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* If provided, returns the commit short sha.
|
||||
*
|
||||
* @returns {String|Undefined}
|
||||
*/
|
||||
commitShortSha() {
|
||||
if (this.model.last_deployment &&
|
||||
this.model.last_deployment.commit &&
|
||||
this.model.last_deployment.commit.short_id) {
|
||||
return this.model.last_deployment.commit.short_id;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* If provided, returns the commit title.
|
||||
*
|
||||
* @returns {String|Undefined}
|
||||
*/
|
||||
commitTitle() {
|
||||
if (this.model.last_deployment &&
|
||||
this.model.last_deployment.commit &&
|
||||
this.model.last_deployment.commit.title) {
|
||||
return this.model.last_deployment.commit.title;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* If provided, returns the commit tag.
|
||||
*
|
||||
* @returns {Object|Undefined}
|
||||
*/
|
||||
commitAuthor() {
|
||||
if (this.model.last_deployment &&
|
||||
this.model.last_deployment.commit &&
|
||||
this.model.last_deployment.commit.author) {
|
||||
return this.model.last_deployment.commit.author;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* Verifies if the `retry_path` key is present and returns its value.
|
||||
*
|
||||
* @returns {String|Undefined}
|
||||
*/
|
||||
retryUrl() {
|
||||
if (this.model.last_deployment &&
|
||||
this.model.last_deployment.deployable &&
|
||||
this.model.last_deployment.deployable.retry_path) {
|
||||
return this.model.last_deployment.deployable.retry_path;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* Verifies if the `last?` key is present and returns its value.
|
||||
*
|
||||
* @returns {Boolean|Undefined}
|
||||
*/
|
||||
isLastDeployment() {
|
||||
return this.model.last_deployment && this.model.last_deployment['last?'];
|
||||
},
|
||||
|
||||
/**
|
||||
* Builds the name of the builds needed to display both the name and the id.
|
||||
*
|
||||
* @returns {String}
|
||||
*/
|
||||
buildName() {
|
||||
if (this.model.last_deployment &&
|
||||
this.model.last_deployment.deployable) {
|
||||
return `${this.model.last_deployment.deployable.name} #${this.model.last_deployment.deployable.id}`;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Builds the needed string to show the internal id.
|
||||
*
|
||||
* @returns {String}
|
||||
*/
|
||||
deploymentInternalId() {
|
||||
if (this.model.last_deployment &&
|
||||
this.model.last_deployment.iid) {
|
||||
return `#${this.model.last_deployment.iid}`;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Verifies if the user object is present under last_deployment object.
|
||||
*
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
deploymentHasUser() {
|
||||
return !this.$options.isObjectEmpty(this.model.last_deployment) &&
|
||||
!this.$options.isObjectEmpty(this.model.last_deployment.user);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the user object nested with the last_deployment object.
|
||||
* Used to render the template.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
deploymentUser() {
|
||||
if (!this.$options.isObjectEmpty(this.model.last_deployment) &&
|
||||
!this.$options.isObjectEmpty(this.model.last_deployment.user)) {
|
||||
return this.model.last_deployment.user;
|
||||
}
|
||||
return {};
|
||||
},
|
||||
|
||||
/**
|
||||
* Verifies if the build name column should be rendered by verifing
|
||||
* if all the information needed is present
|
||||
* and if the environment is not a folder.
|
||||
*
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
shouldRenderBuildName() {
|
||||
return !this.isFolder &&
|
||||
!this.$options.isObjectEmpty(this.model.last_deployment) &&
|
||||
!this.$options.isObjectEmpty(this.model.last_deployment.deployable);
|
||||
},
|
||||
|
||||
/**
|
||||
* Verifies if deplyment internal ID should be rendered by verifing
|
||||
* if all the information needed is present
|
||||
* and if the environment is not a folder.
|
||||
*
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
shouldRenderDeploymentID() {
|
||||
return !this.isFolder &&
|
||||
!this.$options.isObjectEmpty(this.model.last_deployment) &&
|
||||
this.model.last_deployment.iid !== undefined;
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Helper to verify if certain given object are empty.
|
||||
* Should be replaced by lodash _.isEmpty - https://lodash.com/docs/4.17.2#isEmpty
|
||||
* @param {Object} object
|
||||
* @returns {Bollean}
|
||||
*/
|
||||
isObjectEmpty(object) {
|
||||
for (const key in object) { // eslint-disable-line
|
||||
if (hasOwnProperty.call(object, key)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
template: `
|
||||
<tr>
|
||||
<td v-bind:class="{ 'children-row': isChildren}">
|
||||
<a
|
||||
v-if="!isFolder"
|
||||
class="environment-name"
|
||||
:href="model.environment_path"
|
||||
v-html="model.name">
|
||||
</a>
|
||||
<span v-else v-on:click="toggleRow(model)" class="folder-name">
|
||||
<span class="folder-icon">
|
||||
<i v-show="model.isOpen" class="fa fa-caret-down"></i>
|
||||
<i v-show="!model.isOpen" class="fa fa-caret-right"></i>
|
||||
</span>
|
||||
|
||||
<span v-html="model.name"></span>
|
||||
|
||||
<span class="badge" v-html="childrenCounter"></span>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td class="deployment-column">
|
||||
<span
|
||||
v-if="shouldRenderDeploymentID"
|
||||
v-html="deploymentInternalId">
|
||||
</span>
|
||||
|
||||
<span v-if="!isFolder && deploymentHasUser">
|
||||
by
|
||||
<a :href="deploymentUser.web_url" class="js-deploy-user-container">
|
||||
<img class="avatar has-tooltip s20"
|
||||
:src="deploymentUser.avatar_url"
|
||||
:alt="userImageAltDescription"
|
||||
:title="deploymentUser.username" />
|
||||
</a>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a v-if="shouldRenderBuildName"
|
||||
class="build-link"
|
||||
:href="model.last_deployment.deployable.build_path"
|
||||
v-html="buildName">
|
||||
</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div v-if="!isFolder && hasLastDeploymentKey" class="js-commit-component">
|
||||
<commit-component
|
||||
:tag="commitTag"
|
||||
:ref="commitRef"
|
||||
:commit_url="commitUrl"
|
||||
:short_sha="commitShortSha"
|
||||
:title="commitTitle"
|
||||
:author="commitAuthor">
|
||||
</commit-component>
|
||||
</div>
|
||||
<p v-if="!isFolder && !hasLastDeploymentKey" class="commit-title">
|
||||
No deployments yet
|
||||
</p>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span
|
||||
v-if="!isFolder && model.last_deployment"
|
||||
class="environment-created-date-timeago"
|
||||
v-html="createdDate">
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td class="hidden-xs">
|
||||
<div v-if="!isFolder">
|
||||
<div v-if="hasManualActions && canCreateDeployment"
|
||||
class="inline js-manual-actions-container">
|
||||
<actions-component
|
||||
:actions="manualActions">
|
||||
</actions-component>
|
||||
</div>
|
||||
|
||||
<div v-if="model.external_url && canReadEnvironment"
|
||||
class="inline js-external-url-container">
|
||||
<external-url-component
|
||||
:external_url="model.external_url">
|
||||
</external_url-component>
|
||||
</div>
|
||||
|
||||
<div v-if="isStoppable && canCreateDeployment"
|
||||
class="inline js-stop-component-container">
|
||||
<stop-component
|
||||
:stop_url="model.stop_path">
|
||||
</stop-component>
|
||||
</div>
|
||||
|
||||
<div v-if="canRetry && canCreateDeployment"
|
||||
class="inline js-rollback-component-container">
|
||||
<rollback-component
|
||||
:is_last_deployment="isLastDeployment"
|
||||
:retry_url="retryUrl">
|
||||
</rollback-component>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`,
|
||||
});
|
||||
})();
|
|
@ -0,0 +1,31 @@
|
|||
/*= require vue */
|
||||
/* global Vue */
|
||||
|
||||
(() => {
|
||||
window.gl = window.gl || {};
|
||||
window.gl.environmentsList = window.gl.environmentsList || {};
|
||||
|
||||
window.gl.environmentsList.RollbackComponent = Vue.component('rollback-component', {
|
||||
props: {
|
||||
retry_url: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
is_last_deployment: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
|
||||
template: `
|
||||
<a class="btn" :href="retry_url" data-method="post" rel="nofollow">
|
||||
<span v-if="is_last_deployment">
|
||||
Re-deploy
|
||||
</span>
|
||||
<span v-else>
|
||||
Rollback
|
||||
</span>
|
||||
</a>
|
||||
`,
|
||||
});
|
||||
})();
|
|
@ -0,0 +1,27 @@
|
|||
/*= require vue */
|
||||
/* global Vue */
|
||||
|
||||
(() => {
|
||||
window.gl = window.gl || {};
|
||||
window.gl.environmentsList = window.gl.environmentsList || {};
|
||||
|
||||
window.gl.environmentsList.StopComponent = Vue.component('stop-component', {
|
||||
props: {
|
||||
stop_url: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
|
||||
template: `
|
||||
<a
|
||||
class="btn stop-env-link"
|
||||
:href="stop_url"
|
||||
data-confirm="Are you sure you want to stop this environment?"
|
||||
data-method="post"
|
||||
rel="nofollow">
|
||||
<i class="fa fa-stop stop-env-icon"></i>
|
||||
</a>
|
||||
`,
|
||||
});
|
||||
})();
|
|
@ -0,0 +1,21 @@
|
|||
//= require vue
|
||||
//= require_tree ./stores/
|
||||
//= require ./components/environment
|
||||
//= require ./vue_resource_interceptor
|
||||
|
||||
|
||||
$(() => {
|
||||
window.gl = window.gl || {};
|
||||
|
||||
if (window.gl.EnvironmentsListApp) {
|
||||
window.gl.EnvironmentsListApp.$destroy(true);
|
||||
}
|
||||
const Store = window.gl.environmentsList.EnvironmentsStore;
|
||||
|
||||
window.gl.EnvironmentsListApp = new window.gl.environmentsList.EnvironmentsComponent({
|
||||
el: document.querySelector('#environments-list-view'),
|
||||
propsData: {
|
||||
store: Store.create(),
|
||||
},
|
||||
});
|
||||
});
|
|
@ -0,0 +1,22 @@
|
|||
/* globals Vue */
|
||||
/* eslint-disable no-unused-vars, no-param-reassign */
|
||||
class EnvironmentsService {
|
||||
|
||||
constructor(root) {
|
||||
Vue.http.options.root = root;
|
||||
|
||||
this.environments = Vue.resource(root);
|
||||
|
||||
Vue.http.interceptors.push((request, next) => {
|
||||
// needed in order to not break the tests.
|
||||
if ($.rails) {
|
||||
request.headers['X-CSRF-Token'] = $.rails.csrfToken();
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
all() {
|
||||
return this.environments.get();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,131 @@
|
|||
/* eslint-disable no-param-reassign */
|
||||
(() => {
|
||||
window.gl = window.gl || {};
|
||||
window.gl.environmentsList = window.gl.environmentsList || {};
|
||||
|
||||
gl.environmentsList.EnvironmentsStore = {
|
||||
state: {},
|
||||
|
||||
create() {
|
||||
this.state.environments = [];
|
||||
this.state.stoppedCounter = 0;
|
||||
this.state.availableCounter = 0;
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
/**
|
||||
* In order to display a tree view we need to modify the received
|
||||
* data in to a tree structure based on `environment_type`
|
||||
* sorted alphabetically.
|
||||
* In each children a `vue-` property will be added. This property will be
|
||||
* used to know if an item is a children mostly for css purposes. This is
|
||||
* needed because the children row is a fragment instance and therfore does
|
||||
* not accept non-prop attributes.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* it will transform this:
|
||||
* [
|
||||
* { name: "environment", environment_type: "review" },
|
||||
* { name: "environment_1", environment_type: null }
|
||||
* { name: "environment_2, environment_type: "review" }
|
||||
* ]
|
||||
* into this:
|
||||
* [
|
||||
* { name: "review", children:
|
||||
* [
|
||||
* { name: "environment", environment_type: "review", vue-isChildren: true},
|
||||
* { name: "environment_2", environment_type: "review", vue-isChildren: true}
|
||||
* ]
|
||||
* },
|
||||
* {name: "environment_1", environment_type: null}
|
||||
* ]
|
||||
*
|
||||
*
|
||||
* @param {Array} environments List of environments.
|
||||
* @returns {Array} Tree structured array with the received environments.
|
||||
*/
|
||||
storeEnvironments(environments = []) {
|
||||
this.state.stoppedCounter = this.countByState(environments, 'stopped');
|
||||
this.state.availableCounter = this.countByState(environments, 'available');
|
||||
|
||||
const environmentsTree = environments.reduce((acc, environment) => {
|
||||
if (environment.environment_type !== null) {
|
||||
const occurs = acc.filter(element => element.children &&
|
||||
element.name === environment.environment_type);
|
||||
|
||||
environment['vue-isChildren'] = true;
|
||||
|
||||
if (occurs.length) {
|
||||
acc[acc.indexOf(occurs[0])].children.push(environment);
|
||||
acc[acc.indexOf(occurs[0])].children.sort(this.sortByName);
|
||||
} else {
|
||||
acc.push({
|
||||
name: environment.environment_type,
|
||||
children: [environment],
|
||||
isOpen: false,
|
||||
'vue-isChildren': environment['vue-isChildren'],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
acc.push(environment);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []).sort(this.sortByName);
|
||||
|
||||
this.state.environments = environmentsTree;
|
||||
|
||||
return environmentsTree;
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggles folder open property given the environment type.
|
||||
*
|
||||
* @param {String} envType
|
||||
* @return {Array}
|
||||
*/
|
||||
toggleFolder(envType) {
|
||||
const environments = this.state.environments;
|
||||
|
||||
const environmentsCopy = environments.map((env) => {
|
||||
if (env['vue-isChildren'] && env.name === envType) {
|
||||
env.isOpen = !env.isOpen;
|
||||
}
|
||||
|
||||
return env;
|
||||
});
|
||||
|
||||
this.state.environments = environmentsCopy;
|
||||
|
||||
return environmentsCopy;
|
||||
},
|
||||
|
||||
/**
|
||||
* Given an array of environments, returns the number of environments
|
||||
* that have the given state.
|
||||
*
|
||||
* @param {Array} environments
|
||||
* @param {String} state
|
||||
* @returns {Number}
|
||||
*/
|
||||
countByState(environments, state) {
|
||||
return environments.filter(env => env.state === state).length;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sorts the two objects provided by their name.
|
||||
*
|
||||
* @param {Object} a
|
||||
* @param {Object} b
|
||||
* @returns {Number}
|
||||
*/
|
||||
sortByName(a, b) {
|
||||
const nameA = a.name.toUpperCase();
|
||||
const nameB = b.name.toUpperCase();
|
||||
|
||||
return nameA < nameB ? -1 : nameA > nameB ? 1 : 0; // eslint-disable-line
|
||||
},
|
||||
};
|
||||
})();
|
|
@ -0,0 +1,12 @@
|
|||
/* global Vue */
|
||||
Vue.http.interceptors.push((request, next) => {
|
||||
Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1;
|
||||
|
||||
next((response) => {
|
||||
if (typeof response.data === 'string') {
|
||||
response.data = JSON.parse(response.data); // eslint-disable-line
|
||||
}
|
||||
|
||||
Vue.activeResources--; // eslint-disable-line
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1,176 @@
|
|||
/*= require vue */
|
||||
/* global Vue */
|
||||
(() => {
|
||||
window.gl = window.gl || {};
|
||||
|
||||
window.gl.CommitComponent = Vue.component('commit-component', {
|
||||
|
||||
props: {
|
||||
/**
|
||||
* Indicates the existance of a tag.
|
||||
* Used to render the correct icon, if true will render `fa-tag` icon,
|
||||
* if false will render `fa-code-fork` icon.
|
||||
*/
|
||||
tag: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
|
||||
/**
|
||||
* If provided is used to render the branch name and url.
|
||||
* Should contain the following properties:
|
||||
* name
|
||||
* ref_url
|
||||
*/
|
||||
ref: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
|
||||
/**
|
||||
* Used to link to the commit sha.
|
||||
*/
|
||||
commit_url: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
|
||||
/**
|
||||
* Used to show the commit short_sha that links to the commit url.
|
||||
*/
|
||||
short_sha: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
|
||||
/**
|
||||
* If provided shows the commit tile.
|
||||
*/
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
|
||||
/**
|
||||
* If provided renders information about the author of the commit.
|
||||
* When provided should include:
|
||||
* `avatar_url` to render the avatar icon
|
||||
* `web_url` to link to user profile
|
||||
* `username` to render alt and title tags
|
||||
*/
|
||||
author: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Used to verify if all the properties needed to render the commit
|
||||
* ref section were provided.
|
||||
*
|
||||
* TODO: Improve this! Use lodash _.has when we have it.
|
||||
*
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
hasRef() {
|
||||
return this.ref && this.ref.name && this.ref.ref_url;
|
||||
},
|
||||
|
||||
/**
|
||||
* Used to verify if all the properties needed to render the commit
|
||||
* author section were provided.
|
||||
*
|
||||
* TODO: Improve this! Use lodash _.has when we have it.
|
||||
*
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
hasAuthor() {
|
||||
return this.author &&
|
||||
this.author.avatar_url &&
|
||||
this.author.web_url &&
|
||||
this.author.username;
|
||||
},
|
||||
|
||||
/**
|
||||
* If information about the author is provided will return a string
|
||||
* to be rendered as the alt attribute of the img tag.
|
||||
*
|
||||
* @returns {String}
|
||||
*/
|
||||
userImageAltDescription() {
|
||||
return this.author &&
|
||||
this.author.username ? `${this.author.username}'s avatar` : null;
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* In order to reuse the svg instead of copy and paste in this template
|
||||
* we need to render it outside this component using =custom_icon partial.
|
||||
* Make sure it has this structure:
|
||||
* .commit-icon-svg.hidden
|
||||
* svg
|
||||
*
|
||||
* TODO: Find a better way to include SVG
|
||||
*/
|
||||
mounted() {
|
||||
const commitIconContainer = this.$el.querySelector('.commit-icon-container');
|
||||
const commitIcon = document.querySelector('.commit-icon-svg.hidden svg');
|
||||
|
||||
if (commitIconContainer && commitIcon) {
|
||||
commitIconContainer.appendChild(commitIcon.cloneNode(true));
|
||||
}
|
||||
},
|
||||
|
||||
template: `
|
||||
<div class="branch-commit">
|
||||
|
||||
<div v-if="hasRef" class="icon-container">
|
||||
<i v-if="tag" class="fa fa-tag"></i>
|
||||
<i v-if="!tag" class="fa fa-code-fork"></i>
|
||||
</div>
|
||||
|
||||
<a v-if="hasRef"
|
||||
class="monospace branch-name"
|
||||
:href="ref.ref_url"
|
||||
v-html="ref.name">
|
||||
</a>
|
||||
|
||||
<div class="icon-container commit-icon commit-icon-container">
|
||||
</div>
|
||||
|
||||
<a class="commit-id monospace"
|
||||
:href="commit_url"
|
||||
v-html="short_sha">
|
||||
</a>
|
||||
|
||||
<p class="commit-title">
|
||||
<span v-if="title">
|
||||
<a v-if="hasAuthor"
|
||||
class="avatar-image-container"
|
||||
:href="author.web_url">
|
||||
<img
|
||||
class="avatar has-tooltip s20"
|
||||
:src="author.avatar_url"
|
||||
:alt="userImageAltDescription"
|
||||
:title="author.username" />
|
||||
</a>
|
||||
|
||||
<a class="commit-row-message"
|
||||
:href="commit_url" v-html="title">
|
||||
</a>
|
||||
</span>
|
||||
<span v-else>
|
||||
Cant find HEAD commit for this branch
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})();
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
.page-content-header {
|
||||
line-height: 34px;
|
||||
padding: 10px 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
@media (min-width: $screen-sm-min) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.header-main-content {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.header-action-buttons {
|
||||
i {
|
||||
color: $gl-icon-color;
|
||||
font-size: 13px;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
@media (max-width: $screen-xs-max) {
|
||||
.btn {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
@extend .avatar-inline;
|
||||
margin-left: 0;
|
||||
|
||||
@media (min-width: $screen-sm-min) {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.commit-committer-link,
|
||||
.commit-author-link {
|
||||
color: $gl-gray;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.fa-clipboard {
|
||||
color: $dropdown-title-btn-color;
|
||||
}
|
||||
|
||||
.commit-info {
|
||||
&.branches {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.ci-status-link {
|
||||
|
||||
svg {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
margin: 0 2px 0 3px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
.info-well {
|
||||
background: $background-color;
|
||||
color: $gl-gray;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $border-radius-default;
|
||||
|
||||
.well-segment {
|
||||
padding: $gl-padding;
|
||||
|
||||
&:not(:last-of-type) {
|
||||
border-bottom: 1px solid $well-inner-border;
|
||||
}
|
||||
|
||||
&.branch-info {
|
||||
.monospace,
|
||||
.commit-info {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
|
||||
svg {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
&.commit-icon {
|
||||
svg {
|
||||
path {
|
||||
fill: $gl-text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label.label-gray {
|
||||
background-color: $well-expand-item;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -1,10 +1,23 @@
|
|||
.environments-container,
|
||||
.deployments-container {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.environments-list-loading {
|
||||
width: 100%;
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
@media (max-width: $screen-sm-min) {
|
||||
.environments-container {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.environments {
|
||||
table-layout: fixed;
|
||||
|
||||
.deployment-column {
|
||||
.avatar {
|
||||
float: none;
|
||||
|
@ -15,6 +28,10 @@
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.avatar-image-container {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.icon-play {
|
||||
height: 13px;
|
||||
width: 12px;
|
||||
|
@ -38,7 +55,8 @@
|
|||
color: $gl-dark-link-color;
|
||||
}
|
||||
|
||||
.stop-env-link {
|
||||
.stop-env-link,
|
||||
.external-url {
|
||||
color: $table-text-gray;
|
||||
|
||||
.stop-env-icon {
|
||||
|
@ -58,10 +76,29 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.children-row .environment-name {
|
||||
margin-left: 17px;
|
||||
margin-right: -17px;
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
padding: 0 5px 0 0;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
cursor: pointer;
|
||||
|
||||
.badge {
|
||||
font-weight: normal;
|
||||
background-color: $gray-darker;
|
||||
color: $gl-placeholder-color;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table.ci-table.environments {
|
||||
|
||||
.icon-container {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
module CycleAnalyticsParams
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def start_date(params)
|
||||
params[:start_date] == '30' ? 30.days.ago : 90.days.ago
|
||||
end
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
module Projects
|
||||
module CycleAnalytics
|
||||
class EventsController < Projects::ApplicationController
|
||||
include CycleAnalyticsParams
|
||||
|
||||
before_action :authorize_read_cycle_analytics!
|
||||
before_action :authorize_read_build!, only: [:test, :staging]
|
||||
before_action :authorize_read_issue!, only: [:issue, :production]
|
||||
before_action :authorize_read_merge_request!, only: [:code, :review]
|
||||
|
||||
def issue
|
||||
render_events(events.issue_events)
|
||||
end
|
||||
|
||||
def plan
|
||||
render_events(events.plan_events)
|
||||
end
|
||||
|
||||
def code
|
||||
render_events(events.code_events)
|
||||
end
|
||||
|
||||
def test
|
||||
options[:branch] = events_params[:branch_name]
|
||||
|
||||
render_events(events.test_events)
|
||||
end
|
||||
|
||||
def review
|
||||
render_events(events.review_events)
|
||||
end
|
||||
|
||||
def staging
|
||||
render_events(events.staging_events)
|
||||
end
|
||||
|
||||
def production
|
||||
render_events(events.production_events)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_events(events_list)
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.json { render json: { events: events_list } }
|
||||
end
|
||||
end
|
||||
|
||||
def events
|
||||
@events ||= Gitlab::CycleAnalytics::Events.new(project: project, options: options)
|
||||
end
|
||||
|
||||
def options
|
||||
@options ||= { from: start_date(events_params), current_user: current_user }
|
||||
end
|
||||
|
||||
def events_params
|
||||
return {} unless params[:events].present?
|
||||
|
||||
params[:events].slice(:start_date, :branch_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,11 +1,12 @@
|
|||
class Projects::CycleAnalyticsController < Projects::ApplicationController
|
||||
include ActionView::Helpers::DateHelper
|
||||
include ActionView::Helpers::TextHelper
|
||||
include CycleAnalyticsParams
|
||||
|
||||
before_action :authorize_read_cycle_analytics!
|
||||
|
||||
def show
|
||||
@cycle_analytics = CycleAnalytics.new(@project, from: parse_start_date)
|
||||
@cycle_analytics = ::CycleAnalytics.new(@project, from: start_date(cycle_analytics_params))
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
|
@ -15,14 +16,6 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
|
|||
|
||||
private
|
||||
|
||||
def parse_start_date
|
||||
case cycle_analytics_params[:start_date]
|
||||
when '30' then 30.days.ago
|
||||
when '90' then 90.days.ago
|
||||
else 90.days.ago
|
||||
end
|
||||
end
|
||||
|
||||
def cycle_analytics_params
|
||||
return {} unless params[:cycle_analytics].present?
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
module EnvironmentHelper
|
||||
def environment_for_build(project, build)
|
||||
return unless build.environment
|
||||
|
||||
project.environments.find_by(name: build.expanded_environment_name)
|
||||
end
|
||||
|
||||
def environment_link_for_build(project, build)
|
||||
environment = environment_for_build(project, build)
|
||||
if environment
|
||||
link_to environment.name, namespace_project_environment_path(project.namespace, project, environment)
|
||||
else
|
||||
content_tag :span, build.expanded_environment_name
|
||||
end
|
||||
end
|
||||
|
||||
def deployment_link(deployment)
|
||||
return unless deployment
|
||||
|
||||
link_to "##{deployment.iid}", [deployment.project.namespace.becomes(Namespace), deployment.project, deployment.deployable]
|
||||
end
|
||||
|
||||
def last_deployment_link_for_environment_build(project, build)
|
||||
environment = environment_for_build(project, build)
|
||||
return unless environment
|
||||
|
||||
deployment_link(environment.last_deployment)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,7 @@
|
|||
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
|
||||
{
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
module SelectForProjectAuthorization
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
module ClassMethods
|
||||
def select_for_project_authorization
|
||||
select("members.user_id, projects.id AS project_id, members.access_level")
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,12 +1,8 @@
|
|||
class CycleAnalytics
|
||||
include Gitlab::Database::Median
|
||||
include Gitlab::Database::DateTime
|
||||
|
||||
DEPLOYMENT_METRIC_STAGES = %i[production staging]
|
||||
|
||||
def initialize(project, from:)
|
||||
@project = project
|
||||
@from = from
|
||||
@fetcher = Gitlab::CycleAnalytics::MetricsFetcher.new(project: project, from: from, branch: nil)
|
||||
end
|
||||
|
||||
def summary
|
||||
|
@ -14,90 +10,46 @@ class CycleAnalytics
|
|||
end
|
||||
|
||||
def issue
|
||||
calculate_metric(:issue,
|
||||
@fetcher.calculate_metric(:issue,
|
||||
Issue.arel_table[:created_at],
|
||||
[Issue::Metrics.arel_table[:first_associated_with_milestone_at],
|
||||
Issue::Metrics.arel_table[:first_added_to_board_at]])
|
||||
end
|
||||
|
||||
def plan
|
||||
calculate_metric(:plan,
|
||||
@fetcher.calculate_metric(:plan,
|
||||
[Issue::Metrics.arel_table[:first_associated_with_milestone_at],
|
||||
Issue::Metrics.arel_table[:first_added_to_board_at]],
|
||||
Issue::Metrics.arel_table[:first_mentioned_in_commit_at])
|
||||
end
|
||||
|
||||
def code
|
||||
calculate_metric(:code,
|
||||
@fetcher.calculate_metric(:code,
|
||||
Issue::Metrics.arel_table[:first_mentioned_in_commit_at],
|
||||
MergeRequest.arel_table[:created_at])
|
||||
end
|
||||
|
||||
def test
|
||||
calculate_metric(:test,
|
||||
@fetcher.calculate_metric(:test,
|
||||
MergeRequest::Metrics.arel_table[:latest_build_started_at],
|
||||
MergeRequest::Metrics.arel_table[:latest_build_finished_at])
|
||||
end
|
||||
|
||||
def review
|
||||
calculate_metric(:review,
|
||||
@fetcher.calculate_metric(:review,
|
||||
MergeRequest.arel_table[:created_at],
|
||||
MergeRequest::Metrics.arel_table[:merged_at])
|
||||
end
|
||||
|
||||
def staging
|
||||
calculate_metric(:staging,
|
||||
@fetcher.calculate_metric(:staging,
|
||||
MergeRequest::Metrics.arel_table[:merged_at],
|
||||
MergeRequest::Metrics.arel_table[:first_deployed_to_production_at])
|
||||
end
|
||||
|
||||
def production
|
||||
calculate_metric(:production,
|
||||
@fetcher.calculate_metric(:production,
|
||||
Issue.arel_table[:created_at],
|
||||
MergeRequest::Metrics.arel_table[:first_deployed_to_production_at])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def calculate_metric(name, start_time_attrs, end_time_attrs)
|
||||
cte_table = Arel::Table.new("cte_table_for_#{name}")
|
||||
|
||||
# Build a `SELECT` query. We find the first of the `end_time_attrs` that isn't `NULL` (call this end_time).
|
||||
# Next, we find the first of the start_time_attrs that isn't `NULL` (call this start_time).
|
||||
# We compute the (end_time - start_time) interval, and give it an alias based on the current
|
||||
# cycle analytics stage.
|
||||
interval_query = Arel::Nodes::As.new(
|
||||
cte_table,
|
||||
subtract_datetimes(base_query_for(name), end_time_attrs, start_time_attrs, name.to_s))
|
||||
|
||||
median_datetime(cte_table, interval_query, name)
|
||||
end
|
||||
|
||||
# Join table with a row for every <issue,merge_request> pair (where the merge request
|
||||
# closes the given issue) with issue and merge request metrics included. The metrics
|
||||
# are loaded with an inner join, so issues / merge requests without metrics are
|
||||
# automatically excluded.
|
||||
def base_query_for(name)
|
||||
arel_table = MergeRequestsClosingIssues.arel_table
|
||||
|
||||
# Load issues
|
||||
query = arel_table.join(Issue.arel_table).on(Issue.arel_table[:id].eq(arel_table[:issue_id])).
|
||||
join(Issue::Metrics.arel_table).on(Issue.arel_table[:id].eq(Issue::Metrics.arel_table[:issue_id])).
|
||||
where(Issue.arel_table[:project_id].eq(@project.id)).
|
||||
where(Issue.arel_table[:deleted_at].eq(nil)).
|
||||
where(Issue.arel_table[:created_at].gteq(@from))
|
||||
|
||||
# Load merge_requests
|
||||
query = query.join(MergeRequest.arel_table, Arel::Nodes::OuterJoin).
|
||||
on(MergeRequest.arel_table[:id].eq(arel_table[:merge_request_id])).
|
||||
join(MergeRequest::Metrics.arel_table).
|
||||
on(MergeRequest.arel_table[:id].eq(MergeRequest::Metrics.arel_table[:merge_request_id]))
|
||||
|
||||
if DEPLOYMENT_METRIC_STAGES.include?(name)
|
||||
# Limit to merge requests that have been deployed to production after `@from`
|
||||
query.where(MergeRequest::Metrics.arel_table[:first_deployed_to_production_at].gteq(@from))
|
||||
end
|
||||
|
||||
query
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
class MergeRequest::Metrics < ActiveRecord::Base
|
||||
belongs_to :merge_request
|
||||
belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :pipeline_id
|
||||
|
||||
def record!
|
||||
if merge_request.merged? && self.merged_at.blank?
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
class ProjectAuthorization < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
belongs_to :project
|
||||
|
||||
validates :project, presence: true
|
||||
validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true
|
||||
validates :user, uniqueness: { scope: [:project, :access_level] }, presence: true
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
# Base class for Chat services
|
||||
# This class is not meant to be used directly, but only to inherrit from.
|
||||
class ChatService < Service
|
||||
default_value_for :category, 'chat'
|
||||
|
||||
has_many :chat_names, foreign_key: :service_id
|
||||
|
||||
def valid_token?(token)
|
||||
self.respond_to?(:token) &&
|
||||
self.token.present? &&
|
||||
ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.token)
|
||||
end
|
||||
|
||||
def supported_events
|
||||
[]
|
||||
end
|
||||
|
||||
def trigger(params)
|
||||
raise NotImplementedError
|
||||
end
|
||||
end
|
|
@ -1,24 +1,3 @@
|
|||
# == Schema Information
|
||||
#
|
||||
# Table name: services
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# type :string(255)
|
||||
# title :string(255)
|
||||
# project_id :integer
|
||||
# created_at :datetime
|
||||
# updated_at :datetime
|
||||
# active :boolean default(FALSE), not null
|
||||
# properties :text
|
||||
# template :boolean default(FALSE)
|
||||
# push_events :boolean default(TRUE)
|
||||
# issues_events :boolean default(TRUE)
|
||||
# merge_requests_events :boolean default(TRUE)
|
||||
# tag_push_events :boolean default(TRUE)
|
||||
# note_events :boolean default(TRUE), not null
|
||||
# build_events :boolean default(FALSE), not null
|
||||
#
|
||||
|
||||
class JiraService < IssueTrackerService
|
||||
include Gitlab::Routing.url_helpers
|
||||
|
||||
|
@ -30,6 +9,10 @@ class JiraService < IssueTrackerService
|
|||
|
||||
before_update :reset_password
|
||||
|
||||
def supported_events
|
||||
%w(commit merge_request)
|
||||
end
|
||||
|
||||
# {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1
|
||||
def reference_pattern
|
||||
@reference_pattern ||= %r{(?<issue>\b([A-Z][A-Z0-9_]+-)\d+)}
|
||||
|
@ -70,7 +53,7 @@ class JiraService < IssueTrackerService
|
|||
end
|
||||
|
||||
def jira_project
|
||||
@jira_project ||= client.Project.find(project_key)
|
||||
@jira_project ||= jira_request { client.Project.find(project_key) }
|
||||
end
|
||||
|
||||
def help
|
||||
|
@ -128,14 +111,25 @@ class JiraService < IssueTrackerService
|
|||
# we just want to test settings
|
||||
test_settings
|
||||
else
|
||||
close_issue(push, issue)
|
||||
jira_issue = jira_request { client.Issue.find(issue.iid) }
|
||||
|
||||
return false unless jira_issue.present?
|
||||
|
||||
close_issue(push, jira_issue)
|
||||
end
|
||||
end
|
||||
|
||||
def create_cross_reference_note(mentioned, noteable, author)
|
||||
issue_key = mentioned.id
|
||||
unless can_cross_reference?(noteable)
|
||||
return "Events for #{noteable.model_name.plural.humanize(capitalize: false)} are disabled."
|
||||
end
|
||||
|
||||
jira_issue = jira_request { client.Issue.find(mentioned.id) }
|
||||
|
||||
return unless jira_issue.present?
|
||||
|
||||
project = self.project
|
||||
noteable_name = noteable.class.name.underscore.downcase
|
||||
noteable_name = noteable.model_name.singular
|
||||
noteable_id = if noteable.is_a?(Commit)
|
||||
noteable.id
|
||||
else
|
||||
|
@ -160,7 +154,7 @@ class JiraService < IssueTrackerService
|
|||
}
|
||||
}
|
||||
|
||||
add_comment(data, issue_key)
|
||||
add_comment(data, jira_issue)
|
||||
end
|
||||
|
||||
# reason why service cannot be tested
|
||||
|
@ -181,16 +175,22 @@ class JiraService < IssueTrackerService
|
|||
def test_settings
|
||||
return unless url.present?
|
||||
# Test settings by getting the project
|
||||
jira_project
|
||||
|
||||
rescue Errno::ECONNREFUSED, JIRA::HTTPError => e
|
||||
Rails.logger.info "#{self.class.name} ERROR: #{e.message}. API URL: #{url}."
|
||||
false
|
||||
jira_request { jira_project.present? }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def can_cross_reference?(noteable)
|
||||
case noteable
|
||||
when Commit then commit_events
|
||||
when MergeRequest then merge_requests_events
|
||||
else true
|
||||
end
|
||||
end
|
||||
|
||||
def close_issue(entity, issue)
|
||||
return if issue.nil? || issue.resolution.present? || !jira_issue_transition_id.present?
|
||||
|
||||
commit_id = if entity.is_a?(Commit)
|
||||
entity.id
|
||||
elsif entity.is_a?(MergeRequest)
|
||||
|
@ -200,55 +200,85 @@ class JiraService < IssueTrackerService
|
|||
commit_url = build_entity_url(:commit, commit_id)
|
||||
|
||||
# Depending on the JIRA project's workflow, a comment during transition
|
||||
# may or may not be allowed. Split the operation in to two calls so the
|
||||
# comment always works.
|
||||
transition_issue(issue)
|
||||
add_issue_solved_comment(issue, commit_id, commit_url)
|
||||
# may or may not be allowed. Refresh the issue after transition and check
|
||||
# if it is closed, so we don't have one comment for every commit.
|
||||
issue = jira_request { client.Issue.find(issue.key) } if transition_issue(issue)
|
||||
add_issue_solved_comment(issue, commit_id, commit_url) if issue.resolution
|
||||
end
|
||||
|
||||
def transition_issue(issue)
|
||||
issue = client.Issue.find(issue.iid)
|
||||
issue.transitions.build.save(transition: { id: jira_issue_transition_id })
|
||||
end
|
||||
|
||||
def add_issue_solved_comment(issue, commit_id, commit_url)
|
||||
comment = "Issue solved with [#{commit_id}|#{commit_url}]."
|
||||
send_message(issue.iid, comment)
|
||||
link_title = "GitLab: Solved by commit #{commit_id}."
|
||||
comment = "Issue solved with [#{commit_id}|#{commit_url}]."
|
||||
link_props = build_remote_link_props(url: commit_url, title: link_title, resolved: true)
|
||||
send_message(issue, comment, link_props)
|
||||
end
|
||||
|
||||
def add_comment(data, issue_key)
|
||||
user_name = data[:user][:name]
|
||||
user_url = data[:user][:url]
|
||||
entity_name = data[:entity][:name]
|
||||
entity_url = data[:entity][:url]
|
||||
def add_comment(data, issue)
|
||||
user_name = data[:user][:name]
|
||||
user_url = data[:user][:url]
|
||||
entity_name = data[:entity][:name]
|
||||
entity_url = data[:entity][:url]
|
||||
entity_title = data[:entity][:title]
|
||||
project_name = data[:project][:name]
|
||||
|
||||
message = "[#{user_name}|#{user_url}] mentioned this issue in [a #{entity_name} of #{project_name}|#{entity_url}]:\n'#{entity_title}'"
|
||||
message = "[#{user_name}|#{user_url}] mentioned this issue in [a #{entity_name} of #{project_name}|#{entity_url}]:\n'#{entity_title}'"
|
||||
link_title = "GitLab: Mentioned on #{entity_name} - #{entity_title}"
|
||||
link_props = build_remote_link_props(url: entity_url, title: link_title)
|
||||
|
||||
unless comment_exists?(issue_key, message)
|
||||
send_message(issue_key, message)
|
||||
unless comment_exists?(issue, message)
|
||||
send_message(issue, message, link_props)
|
||||
end
|
||||
end
|
||||
|
||||
def comment_exists?(issue_key, message)
|
||||
comments = client.Issue.find(issue_key).comments
|
||||
comments.map { |comment| comment.body.include?(message) }.any?
|
||||
def comment_exists?(issue, message)
|
||||
comments = jira_request { issue.comments }
|
||||
|
||||
comments.present? && comments.any? { |comment| comment.body.include?(message) }
|
||||
end
|
||||
|
||||
def send_message(issue_key, message)
|
||||
def send_message(issue, message, remote_link_props)
|
||||
return unless url.present?
|
||||
|
||||
issue = client.Issue.find(issue_key)
|
||||
jira_request do
|
||||
if issue.comments.build.save!(body: message)
|
||||
remote_link = issue.remotelink.build
|
||||
remote_link.save!(remote_link_props)
|
||||
result_message = "#{self.class.name} SUCCESS: Successfully posted to #{url}."
|
||||
end
|
||||
|
||||
if issue.comments.build.save!(body: message)
|
||||
result_message = "#{self.class.name} SUCCESS: Successfully posted to #{url}."
|
||||
Rails.logger.info(result_message)
|
||||
result_message
|
||||
end
|
||||
end
|
||||
|
||||
# Build remote link on JIRA properties
|
||||
# Icons here must be available on WEB so JIRA can read the URL
|
||||
# We are using a open word graphics icon which have LGPL license
|
||||
def build_remote_link_props(url:, title:, resolved: false)
|
||||
status = {
|
||||
resolved: resolved
|
||||
}
|
||||
|
||||
if resolved
|
||||
status[:icon] = {
|
||||
title: 'Closed',
|
||||
url16x16: 'http://www.openwebgraphics.com/resources/data/1768/16x16_apply.png'
|
||||
}
|
||||
end
|
||||
|
||||
Rails.logger.info(result_message)
|
||||
result_message
|
||||
rescue URI::InvalidURIError, Errno::ECONNREFUSED, JIRA::HTTPError => e
|
||||
Rails.logger.info "#{self.class.name} Send message ERROR: #{url} - #{e.message}"
|
||||
{
|
||||
GlobalID: 'GitLab',
|
||||
object: {
|
||||
url: url,
|
||||
title: title,
|
||||
status: status,
|
||||
icon: { title: 'GitLab', url16x16: 'https://gitlab.com/favicon.ico' }
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def resource_url(resource)
|
||||
|
@ -266,4 +296,13 @@ class JiraService < IssueTrackerService
|
|||
host: Settings.gitlab.base_url
|
||||
)
|
||||
end
|
||||
|
||||
# Handle errors when doing JIRA API calls
|
||||
def jira_request
|
||||
yield
|
||||
|
||||
rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError => e
|
||||
Rails.logger.info "#{self.class.name} Send message ERROR: #{url} - #{e.message}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
class MattermostSlashCommandsService < ChatService
|
||||
include TriggersHelper
|
||||
|
||||
prop_accessor :token
|
||||
|
||||
def can_test?
|
||||
false
|
||||
end
|
||||
|
||||
def title
|
||||
'Mattermost Command'
|
||||
end
|
||||
|
||||
def description
|
||||
"Perform common operations on GitLab in Mattermost"
|
||||
end
|
||||
|
||||
def to_param
|
||||
'mattermost_slash_commands'
|
||||
end
|
||||
|
||||
def help
|
||||
"This service allows you to use slash commands with your Mattermost installation.<br/>
|
||||
To setup this Service you need to create a new <b>Slash commands</b> in your Mattermost integration panel.<br/>
|
||||
<br/>
|
||||
Create integration with URL #{service_trigger_url(self)} and enter the token below."
|
||||
end
|
||||
|
||||
def fields
|
||||
[
|
||||
{ type: 'text', name: 'token', placeholder: '' }
|
||||
]
|
||||
end
|
||||
|
||||
def trigger(params)
|
||||
return nil unless valid_token?(params[:token])
|
||||
|
||||
user = find_chat_user(params)
|
||||
unless user
|
||||
url = authorize_chat_name_url(params)
|
||||
return Mattermost::Presenter.authorize_chat_name(url)
|
||||
end
|
||||
|
||||
Gitlab::ChatCommands::Command.new(project, user, params).execute
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_chat_user(params)
|
||||
ChatNames::FindUserService.new(self, params).execute
|
||||
end
|
||||
|
||||
def authorize_chat_name_url(params)
|
||||
ChatNames::AuthorizeUserService.new(self, params).execute
|
||||
end
|
||||
end
|
|
@ -1,11 +1,10 @@
|
|||
class SlackService
|
||||
class PipelineMessage < BaseMessage
|
||||
attr_reader :sha, :ref_type, :ref, :status, :project_name, :project_url,
|
||||
attr_reader :ref_type, :ref, :status, :project_name, :project_url,
|
||||
:user_name, :duration, :pipeline_id
|
||||
|
||||
def initialize(data)
|
||||
pipeline_attributes = data[:object_attributes]
|
||||
@sha = pipeline_attributes[:sha]
|
||||
@ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch'
|
||||
@ref = pipeline_attributes[:ref]
|
||||
@status = pipeline_attributes[:status]
|
||||
|
@ -14,7 +13,7 @@ class SlackService
|
|||
|
||||
@project_name = data[:project][:path_with_namespace]
|
||||
@project_url = data[:project][:web_url]
|
||||
@user_name = data[:commit] && data[:commit][:author_name]
|
||||
@user_name = data[:user] && data[:user][:name]
|
||||
end
|
||||
|
||||
def pretext
|
||||
|
@ -73,7 +72,7 @@ class SlackService
|
|||
end
|
||||
|
||||
def pipeline_link
|
||||
"[#{Commit.truncate_sha(sha)}](#{pipeline_url})"
|
||||
"[##{pipeline_id}](#{pipeline_url})"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
class AnalyticsBuildEntity < Grape::Entity
|
||||
include RequestAwareEntity
|
||||
include EntityDateHelper
|
||||
|
||||
expose :name
|
||||
expose :id
|
||||
expose :ref, as: :branch
|
||||
expose :short_sha
|
||||
expose :author, using: UserEntity
|
||||
|
||||
expose :started_at, as: :date do |build|
|
||||
interval_in_words(build[:started_at])
|
||||
end
|
||||
|
||||
expose :duration, as: :total_time do |build|
|
||||
distance_of_time_as_hash(build[:duration].to_f)
|
||||
end
|
||||
|
||||
expose :branch do
|
||||
expose :ref, as: :name
|
||||
|
||||
expose :url do |build|
|
||||
url_to(:namespace_project_tree, build, build.ref)
|
||||
end
|
||||
end
|
||||
|
||||
expose :url do |build|
|
||||
url_to(:namespace_project_build, build)
|
||||
end
|
||||
|
||||
expose :commit_url do |build|
|
||||
url_to(:namespace_project_commit, build, build.sha)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def url_to(route, build, id = nil)
|
||||
public_send("#{route}_url", build.project.namespace, build.project, id || build)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,3 @@
|
|||
class AnalyticsBuildSerializer < BaseSerializer
|
||||
entity AnalyticsBuildEntity
|
||||
end
|
|
@ -0,0 +1,13 @@
|
|||
class AnalyticsCommitEntity < CommitEntity
|
||||
include EntityDateHelper
|
||||
|
||||
expose :short_id, as: :short_sha
|
||||
|
||||
expose :total_time do |commit|
|
||||
distance_of_time_as_hash(request.total_time.to_f)
|
||||
end
|
||||
|
||||
unexpose :author_name
|
||||
unexpose :author_email
|
||||
unexpose :message
|
||||
end
|
|
@ -0,0 +1,3 @@
|
|||
class AnalyticsCommitSerializer < BaseSerializer
|
||||
entity AnalyticsCommitEntity
|
||||
end
|
|
@ -0,0 +1,7 @@
|
|||
class AnalyticsGenericSerializer < BaseSerializer
|
||||
def represent(resource, opts = {})
|
||||
resource.symbolize_keys!
|
||||
|
||||
super(resource, opts)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,29 @@
|
|||
class AnalyticsIssueEntity < Grape::Entity
|
||||
include RequestAwareEntity
|
||||
include EntityDateHelper
|
||||
|
||||
expose :title
|
||||
expose :author, using: UserEntity
|
||||
|
||||
expose :iid do |object|
|
||||
object[:iid].to_s
|
||||
end
|
||||
|
||||
expose :total_time do |object|
|
||||
distance_of_time_as_hash(object[:total_time].to_f)
|
||||
end
|
||||
|
||||
expose(:created_at) do |object|
|
||||
interval_in_words(object[:created_at])
|
||||
end
|
||||
|
||||
expose :url do |object|
|
||||
url_to(:namespace_project_issue, id: object[:iid].to_s)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def url_to(route, id)
|
||||
public_send("#{route}_url", request.project.namespace, request.project, id)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,3 @@
|
|||
class AnalyticsIssueSerializer < AnalyticsGenericSerializer
|
||||
entity AnalyticsIssueEntity
|
||||
end
|
|
@ -0,0 +1,7 @@
|
|||
class AnalyticsMergeRequestEntity < AnalyticsIssueEntity
|
||||
expose :state
|
||||
|
||||
expose :url do |object|
|
||||
url_to(:namespace_project_merge_request, id: object[:iid].to_s)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,3 @@
|
|||
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)
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
module EntityDateHelper
|
||||
include ActionView::Helpers::DateHelper
|
||||
|
||||
def interval_in_words(diff)
|
||||
"#{distance_of_time_in_words(diff.to_f)} ago"
|
||||
end
|
||||
|
||||
# Converts seconds into a hash such as:
|
||||
# { days: 1, hours: 3, mins: 42, seconds: 40 }
|
||||
#
|
||||
# It returns 0 seconds for zero or negative numbers
|
||||
# It rounds to nearest time unit and does not return zero
|
||||
# i.e { min: 1 } instead of { mins: 1, seconds: 0 }
|
||||
def distance_of_time_as_hash(diff)
|
||||
diff = diff.abs.floor
|
||||
|
||||
return { seconds: 0 } if diff == 0
|
||||
|
||||
mins = (diff / 60).floor
|
||||
seconds = diff % 60
|
||||
hours = (mins / 60).floor
|
||||
mins = mins % 60
|
||||
days = (hours / 24).floor
|
||||
hours = hours % 24
|
||||
|
||||
duration_hash = {}
|
||||
|
||||
duration_hash[:days] = days if days > 0
|
||||
duration_hash[:hours] = hours if hours > 0
|
||||
duration_hash[:mins] = mins if mins > 0
|
||||
duration_hash[:seconds] = seconds if seconds > 0
|
||||
|
||||
duration_hash
|
||||
end
|
||||
end
|
|
@ -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)
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
class IssuableEntity < Grape::Entity
|
||||
expose :id
|
||||
expose :iid
|
||||
expose :assignee_id
|
||||
expose :author_id
|
||||
expose :description
|
||||
expose :lock_version
|
||||
expose :milestone_id
|
||||
expose :position
|
||||
expose :state
|
||||
expose :title
|
||||
expose :updated_by_id
|
||||
expose :created_at
|
||||
expose :updated_at
|
||||
expose :deleted_at
|
||||
end
|
|
@ -0,0 +1,9 @@
|
|||
class IssueEntity < IssuableEntity
|
||||
expose :branch_name
|
||||
expose :confidential
|
||||
expose :due_date
|
||||
expose :moved_to_id
|
||||
expose :project_id
|
||||
expose :milestone, using: API::Entities::Milestone
|
||||
expose :labels, using: LabelEntity
|
||||
end
|
|
@ -0,0 +1,3 @@
|
|||
class IssueSerializer < BaseSerializer
|
||||
entity IssueEntity
|
||||
end
|
|
@ -0,0 +1,11 @@
|
|||
class LabelEntity < Grape::Entity
|
||||
expose :id
|
||||
expose :title
|
||||
expose :color
|
||||
expose :description
|
||||
expose :group_id
|
||||
expose :project_id
|
||||
expose :template
|
||||
expose :created_at
|
||||
expose :updated_at
|
||||
end
|
|
@ -0,0 +1,14 @@
|
|||
class MergeRequestEntity < IssuableEntity
|
||||
expose :in_progress_merge_commit_sha
|
||||
expose :locked_at
|
||||
expose :merge_commit_sha
|
||||
expose :merge_error
|
||||
expose :merge_params
|
||||
expose :merge_status
|
||||
expose :merge_user_id
|
||||
expose :merge_when_build_succeeds
|
||||
expose :source_branch
|
||||
expose :source_project_id
|
||||
expose :target_branch
|
||||
expose :target_project_id
|
||||
end
|
|
@ -0,0 +1,3 @@
|
|||
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
|
||||
|
|
|
@ -1,13 +1,18 @@
|
|||
module MergeRequests
|
||||
class AddTodoWhenBuildFailsService < MergeRequests::BaseService
|
||||
# Adds a todo to the parent merge_request when a CI build fails
|
||||
#
|
||||
def execute(commit_status)
|
||||
return if commit_status.allow_failure?
|
||||
|
||||
commit_status_merge_requests(commit_status) do |merge_request|
|
||||
todo_service.merge_request_build_failed(merge_request)
|
||||
end
|
||||
end
|
||||
|
||||
# Closes any pending build failed todos for the parent MRs when a build is retried
|
||||
# Closes any pending build failed todos for the parent MRs when a
|
||||
# build is retried
|
||||
#
|
||||
def close(commit_status)
|
||||
commit_status_merge_requests(commit_status) do |merge_request|
|
||||
todo_service.merge_request_build_retried(merge_request)
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
class UserProjectAccessChangedService
|
||||
def initialize(user_ids)
|
||||
@user_ids = Array.wrap(user_ids)
|
||||
end
|
||||
|
||||
def execute
|
||||
AuthorizedProjectsWorker.bulk_perform_async(@user_ids.map { |id| [id] })
|
||||
end
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
- page_title "Edit", @group.name, "Groups"
|
||||
%h3.page-title Edit group: #{@group.name}
|
||||
%hr
|
||||
= render 'form'
|
||||
= render 'form', visibility_level: @group.visibility_level
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
- page_title "New Group"
|
||||
%h3.page-title New group
|
||||
%hr
|
||||
= render 'form'
|
||||
= render 'form', visibility_level: default_group_visibility
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue