Commit 9b124078 authored by Lin Jen-Shin's avatar Lin Jen-Shin

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

* upstream/master: (302 commits)
  Fix a wrong "The build for this merge request failed" message
  Add documentation about todos and failed builds
  Add Changelog entry for failed builds todos fix
  Do not create TODO when build is allowed to fail
  Fix 404 on some group pages when name contains dot
  Grapify the users API
  Remove duplicate sidekiq throttling parameters
  Fix regression in Merge request form
  Fix wrong link
  Fix test
  Fix broken test
  Changes for stop url to path
  fix typo in gitlab_flow.md ('munch'->'much')
  Fix spec
  Backport some changes done from Time Tracking feature in EE.
  Use build instead create in BroadcastMessage model spec
  Try to fix tests
  Remove unnecessary self from user model
  Expose stop_path for environment to not construct that in frontend
  Bring back the `commit_url` as it's used by CycleAnalytics
  ...
parents 1edb1746 e33a9bee
...@@ -330,6 +330,7 @@ gem 'octokit', '~> 4.3.0' ...@@ -330,6 +330,7 @@ gem 'octokit', '~> 4.3.0'
gem 'mail_room', '~> 0.9.0' gem 'mail_room', '~> 0.9.0'
gem 'email_reply_parser', '~> 0.5.8' gem 'email_reply_parser', '~> 0.5.8'
gem 'html2text'
gem 'ruby-prof', '~> 0.16.2' gem 'ruby-prof', '~> 0.16.2'
......
...@@ -339,6 +339,8 @@ GEM ...@@ -339,6 +339,8 @@ GEM
html-pipeline (1.11.0) html-pipeline (1.11.0)
activesupport (>= 2) activesupport (>= 2)
nokogiri (~> 1.4) nokogiri (~> 1.4)
html2text (0.2.0)
nokogiri (~> 1.6)
htmlentities (4.3.4) htmlentities (4.3.4)
httparty (0.13.7) httparty (0.13.7)
json (~> 1.8) json (~> 1.8)
...@@ -873,6 +875,7 @@ DEPENDENCIES ...@@ -873,6 +875,7 @@ DEPENDENCIES
health_check (~> 2.2.0) health_check (~> 2.2.0)
hipchat (~> 1.5.0) hipchat (~> 1.5.0)
html-pipeline (~> 1.11.0) html-pipeline (~> 1.11.0)
html2text
httparty (~> 0.13.3) httparty (~> 0.13.3)
influxdb (~> 0.2) influxdb (~> 0.2)
jira-ruby (~> 1.1.2) jira-ruby (~> 1.1.2)
......
...@@ -172,7 +172,7 @@ ...@@ -172,7 +172,7 @@
$date = $('.js-artifacts-remove'); $date = $('.js-artifacts-remove');
if ($date.length) { if ($date.length) {
date = $date.text(); date = $date.text();
return $date.text(gl.utils.timefor(new Date(date.replace(/([0-9]+)-([0-9]+)-([0-9]+)/g, '$1/$2/$3')), ' ')); return $date.text(gl.utils.timeFor(new Date(date.replace(/([0-9]+)-([0-9]+)-([0-9]+)/g, '$1/$2/$3')), ' '));
} }
}; };
......
//= require vue
//= require vue-resource
//= require_tree ../services/
//= require ./environment_item
/* globals Vue, EnvironmentsService */
/* eslint-disable no-param-reassign */
(() => { // eslint-disable-line
window.gl = window.gl || {};
/**
* Given the visibility prop provided by the url query parameter and which
* changes according to the active tab we need to filter which environments
* should be visible.
*
* The environments array is a recursive tree structure and we need to filter
* both root level environments and children environments.
*
* In order to acomplish that, both `filterState` and `filterEnvironmnetsByState`
* functions work together.
* The first one works as the filter that verifies if the given environment matches
* the given state.
* The second guarantees both root level and children elements are filtered as well.
*/
const filterState = state => environment => environment.state === state && environment;
/**
* Given the filter function and the array of environments will return only
* the environments that match the state provided to the filter function.
*
* @param {Function} fn
* @param {Array} array
* @return {Array}
*/
const filterEnvironmnetsByState = (fn, arr) => arr.map((item) => {
if (item.children) {
const filteredChildren = filterEnvironmnetsByState(fn, item.children).filter(Boolean);
if (filteredChildren.length) {
item.children = filteredChildren;
return item;
}
}
return fn(item);
}).filter(Boolean);
window.gl.environmentsList.EnvironmentsComponent = Vue.component('environment-component', {
props: {
store: {
type: Object,
required: true,
default: () => ({}),
},
},
components: {
'environment-item': window.gl.environmentsList.EnvironmentItem,
},
data() {
const environmentsData = document.querySelector('#environments-list-view').dataset;
return {
state: this.store.state,
visibility: 'available',
isLoading: false,
cssContainerClass: environmentsData.cssClass,
endpoint: environmentsData.environmentsDataEndpoint,
canCreateDeployment: environmentsData.canCreateDeployment,
canReadEnvironment: environmentsData.canReadEnvironment,
canCreateEnvironment: environmentsData.canCreateEnvironment,
projectEnvironmentsPath: environmentsData.projectEnvironmentsPath,
projectStoppedEnvironmentsPath: environmentsData.projectStoppedEnvironmentsPath,
newEnvironmentPath: environmentsData.newEnvironmentPath,
helpPagePath: environmentsData.helpPagePath,
};
},
computed: {
filteredEnvironments() {
return filterEnvironmnetsByState(filterState(this.visibility), this.state.environments);
},
scope() {
return this.$options.getQueryParameter('scope');
},
canReadEnvironmentParsed() {
return this.$options.convertPermissionToBoolean(this.canReadEnvironment);
},
canCreateDeploymentParsed() {
return this.$options.convertPermissionToBoolean(this.canCreateDeployment);
},
canCreateEnvironmentParsed() {
return this.$options.convertPermissionToBoolean(this.canCreateEnvironment);
},
},
/**
* Fetches all the environmnets and stores them.
* Toggles loading property.
*/
created() {
gl.environmentsService = new EnvironmentsService(this.endpoint);
const scope = this.$options.getQueryParameter('scope');
if (scope) {
this.visibility = scope;
}
this.isLoading = true;
return gl.environmentsService.all()
.then(resp => resp.json())
.then((json) => {
this.store.storeEnvironments(json);
this.isLoading = false;
});
},
/**
* Transforms the url parameter into an object and
* returns the one requested.
*
* @param {String} param
* @returns {String} The value of the requested parameter.
*/
getQueryParameter(parameter) {
return window.location.search.substring(1).split('&').reduce((acc, param) => {
const paramSplited = param.split('=');
acc[paramSplited[0]] = paramSplited[1];
return acc;
}, {})[parameter];
},
/**
* Converts permission provided as strings to booleans.
* @param {String} string
* @returns {Boolean}
*/
convertPermissionToBoolean(string) {
return string === 'true';
},
methods: {
toggleRow(model) {
return this.store.toggleFolder(model.name);
},
},
template: `
<div :class="cssContainerClass">
<div class="top-area">
<ul v-if="!isLoading" class="nav-links">
<li v-bind:class="{ 'active': scope === undefined }">
<a :href="projectEnvironmentsPath">
Available
<span
class="badge js-available-environments-count"
v-html="state.availableCounter"></span>
</a>
</li>
<li v-bind:class="{ 'active' : scope === 'stopped' }">
<a :href="projectStoppedEnvironmentsPath">
Stopped
<span
class="badge js-stopped-environments-count"
v-html="state.stoppedCounter"></span>
</a>
</li>
</ul>
<div v-if="canCreateEnvironmentParsed && !isLoading" class="nav-controls">
<a :href="newEnvironmentPath" class="btn btn-create">
New environment
</a>
</div>
</div>
<div class="environments-container">
<div class="environments-list-loading text-center" v-if="isLoading">
<i class="fa fa-spinner spin"></i>
</div>
<div
class="blank-state blank-state-no-icon"
v-if="!isLoading && state.environments.length === 0">
<h2 class="blank-state-title">
You don't have any environments right now.
</h2>
<p class="blank-state-text">
Environments are places where code gets deployed, such as staging or production.
<br />
<a :href="helpPagePath">
Read more about environments
</a>
</p>
<a
v-if="canCreateEnvironmentParsed"
:href="newEnvironmentPath"
class="btn btn-create">
New Environment
</a>
</div>
<div
class="table-holder"
v-if="!isLoading && state.environments.length > 0">
<table class="table ci-table environments">
<thead>
<tr>
<th>Environment</th>
<th>Last deployment</th>
<th>Build</th>
<th>Commit</th>
<th></th>
<th class="hidden-xs"></th>
</tr>
</thead>
<tbody>
<template v-for="model in filteredEnvironments"
v-bind:model="model">
<tr
is="environment-item"
:model="model"
:toggleRow="toggleRow.bind(model)"
:can-create-deployment="canCreateDeploymentParsed"
:can-read-environment="canReadEnvironmentParsed"></tr>
<tr v-if="model.isOpen && model.children && model.children.length > 0"
is="environment-item"
v-for="children in model.children"
:model="children"
:toggleRow="toggleRow.bind(children)">
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
`,
});
})();
/*= require vue */
/* global Vue */
(() => {
window.gl = window.gl || {};
window.gl.environmentsList = window.gl.environmentsList || {};
window.gl.environmentsList.ActionsComponent = Vue.component('actions-component', {
props: {
actions: {
type: Array,
required: false,
default: () => [],
},
},
/**
* Appends the svg icon that were render in the index page.
* In order to reuse the svg instead of copy and paste in this template
* we need to render it outside this component using =custom_icon partial.
*
* TODO: Remove this when webpack is merged.
*
*/
mounted() {
const playIcon = document.querySelector('.play-icon-svg.hidden svg');
const dropdownContainer = this.$el.querySelector('.dropdown-play-icon-container');
const actionContainers = this.$el.querySelectorAll('.action-play-icon-container');
// Phantomjs does not have support to iterate a nodelist.
const actionsArray = [].slice.call(actionContainers);
if (playIcon && actionsArray && dropdownContainer) {
dropdownContainer.appendChild(playIcon.cloneNode(true));
actionsArray.forEach((element) => {
element.appendChild(playIcon.cloneNode(true));
});
}
},
template: `
<div class="inline">
<div class="dropdown">
<a class="dropdown-new btn btn-default" data-toggle="dropdown">
<span class="dropdown-play-icon-container">
</span>
<i class="fa fa-caret-down"></i>
</a>
<ul class="dropdown-menu dropdown-menu-align-right">
<li v-for="action in actions">
<a :href="action.play_path"
data-method="post"
rel="nofollow"
class="js-manual-action-link">
<span class="action-play-icon-container">
</span>
<span v-html="action.name"></span>
</a>
</li>
</ul>
</div>
</div>
`,
});
})();
/*= require vue */
/* global Vue */
(() => {
window.gl = window.gl || {};
window.gl.environmentsList = window.gl.environmentsList || {};
window.gl.environmentsList.ExternalUrlComponent = Vue.component('external-url-component', {
props: {
external_url: {
type: String,
default: '',
},
},
template: `
<a class="btn external_url" :href="external_url" target="_blank">
<i class="fa fa-external-link"></i>
</a>
`,
});
})();
/*= require vue */
/* global Vue */
(() => {
window.gl = window.gl || {};
window.gl.environmentsList = window.gl.environmentsList || {};
window.gl.environmentsList.RollbackComponent = Vue.component('rollback-component', {
props: {
retry_url: {
type: String,
default: '',
},
is_last_deployment: {
type: Boolean,
default: true,
},
},
template: `
<a class="btn" :href="retry_url" data-method="post" rel="nofollow">
<span v-if="is_last_deployment">
Re-deploy
</span>
<span v-else>
Rollback
</span>
</a>
`,
});
})();
/*= require vue */
/* global Vue */
(() => {
window.gl = window.gl || {};
window.gl.environmentsList = window.gl.environmentsList || {};
window.gl.environmentsList.StopComponent = Vue.component('stop-component', {
props: {
stop_url: {
type: String,
default: '',
},
},
template: `
<a
class="btn stop-env-link"
:href="stop_url"
data-confirm="Are you sure you want to stop this environment?"
data-method="post"
rel="nofollow">
<i class="fa fa-stop stop-env-icon"></i>
</a>
`,
});
})();
//= require vue
//= require_tree ./stores/
//= require ./components/environment
//= require ./vue_resource_interceptor
$(() => {
window.gl = window.gl || {};
if (window.gl.EnvironmentsListApp) {
window.gl.EnvironmentsListApp.$destroy(true);
}
const Store = window.gl.environmentsList.EnvironmentsStore;
window.gl.EnvironmentsListApp = new window.gl.environmentsList.EnvironmentsComponent({
el: document.querySelector('#environments-list-view'),
propsData: {
store: Store.create(),
},
});
});
/* globals Vue */
/* eslint-disable no-unused-vars, no-param-reassign */
class EnvironmentsService {
constructor(root) {
Vue.http.options.root = root;
this.environments = Vue.resource(root);
Vue.http.interceptors.push((request, next) => {
// needed in order to not break the tests.
if ($.rails) {
request.headers['X-CSRF-Token'] = $.rails.csrfToken();
}
next();
});
}
all() {
return this.environments.get();
}
}
/* eslint-disable no-param-reassign */
(() => {
window.gl = window.gl || {};
window.gl.environmentsList = window.gl.environmentsList || {};
gl.environmentsList.EnvironmentsStore = {
state: {},
create() {
this.state.environments = [];
this.state.stoppedCounter = 0;
this.state.availableCounter = 0;
return this;
},
/**
* In order to display a tree view we need to modify the received
* data in to a tree structure based on `environment_type`
* sorted alphabetically.
* In each children a `vue-` property will be added. This property will be
* used to know if an item is a children mostly for css purposes. This is
* needed because the children row is a fragment instance and therfore does
* not accept non-prop attributes.
*
*
* @example
* it will transform this:
* [
* { name: "environment", environment_type: "review" },
* { name: "environment_1", environment_type: null }
* { name: "environment_2, environment_type: "review" }
* ]
* into this:
* [
* { name: "review", children:
* [
* { name: "environment", environment_type: "review", vue-isChildren: true},
* { name: "environment_2", environment_type: "review", vue-isChildren: true}
* ]
* },
* {name: "environment_1", environment_type: null}
* ]
*
*
* @param {Array} environments List of environments.
* @returns {Array} Tree structured array with the received environments.
*/
storeEnvironments(environments = []) {
this.state.stoppedCounter = this.countByState(environments, 'stopped');
this.state.availableCounter = this.countByState(environments, 'available');
const environmentsTree = environments.reduce((acc, environment) => {
if (environment.environment_type !== null) {
const occurs = acc.filter(element => element.children &&
element.name === environment.environment_type);
environment['vue-isChildren'] = true;
if (occurs.length) {
acc[acc.indexOf(occurs[0])].children.push(environment);
acc[acc.indexOf(occurs[0])].children.sort(this.sortByName);
} else {
acc.push({
name: environment.environment_type,
children: [environment],
isOpen: false,
'vue-isChildren': environment['vue-isChildren'],
});
}
} else {
acc.push(environment);
}
return acc;
}, []).sort(this.sortByName);
this.state.environments = environmentsTree;
return environmentsTree;
},
/**
* Toggles folder open property given the environment type.
*
* @param {String} envType
* @return {Array}
*/
toggleFolder(envType) {
const environments = this.state.environments;
const environmentsCopy = environments.map((env) => {
if (env['vue-isChildren'] && env.name === envType) {
env.isOpen = !env.isOpen;
}
return env;
});
this.state.environments = environmentsCopy;
return environmentsCopy;
},
/**
* Given an array of environments, returns the number of environments
* that have the given state.
*
* @param {Array} environments
* @param {String} state
* @returns {Number}
*/
countByState(environments, state) {
return environments.filter(env => env.state === state).length;
},
/**
* Sorts the two objects provided by their name.
*
* @param {Object} a
* @param {Object} b
* @returns {Number}
*/
sortByName(a, b) {
const nameA = a.name.toUpperCase();
const nameB = b.name.toUpperCase();
return nameA < nameB ? -1 : nameA > nameB ? 1 : 0; // eslint-disable-line
},
};
})();
/* global Vue */
Vue.http.interceptors.push((request, next) => {
Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1;
next((response) => {
if (typeof response.data === 'string') {
response.data = JSON.parse(response.data); // eslint-disable-line
}
Vue.activeResources--; // eslint-disable-line
});
});
...@@ -125,6 +125,11 @@ ...@@ -125,6 +125,11 @@
// Close any open tooltips // Close any open tooltips
$('.has-tooltip, [data-toggle="tooltip"]').tooltip('destroy'); $('.has-tooltip, [data-toggle="tooltip"]').tooltip('destroy');
}; };
gl.utils.isMetaKey = function(e) {
return e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
};
})(window); })(window);
}).call(this); }).call(this);
...@@ -112,6 +112,9 @@ ...@@ -112,6 +112,9 @@
gl.text.removeListeners = function(form) { gl.text.removeListeners = function(form) {
return $('.js-md', form).off(); 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 gl.text.truncate = function(string, maxLength) {
return string.substr(0, (maxLength - 3)) + '...'; return string.substr(0, (maxLength - 3)) + '...';
}; };
......
...@@ -12,7 +12,7 @@ ...@@ -12,7 +12,7 @@
var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; };
this.Notes = (function() { this.Notes = (function() {
var isMetaKey; const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
Notes.interval = null; Notes.interval = null;
...@@ -33,6 +33,7 @@ ...@@ -33,6 +33,7 @@
this.resetMainTargetForm = bind(this.resetMainTargetForm, this); this.resetMainTargetForm = bind(this.resetMainTargetForm, this);
this.refresh = bind(this.refresh, this); this.refresh = bind(this.refresh, this);
this.keydownNoteText = bind(this.keydownNoteText, this); this.keydownNoteText = bind(this.keydownNoteText, this);
this.toggleCommitList = bind(this.toggleCommitList, this);
this.notes_url = notes_url; this.notes_url = notes_url;
this.note_ids = note_ids; this.note_ids = note_ids;
this.last_fetched_at = last_fetched_at; this.last_fetched_at = last_fetched_at;
...@@ -46,6 +47,7 @@ ...@@ -46,6 +47,7 @@
this.setPollingInterval(); this.setPollingInterval();
this.setupMainTargetNoteForm(); this.setupMainTargetNoteForm();
this.initTaskList(); this.initTaskList();
this.collapseLongCommitList();
} }
Notes.prototype.addBinding = function() { Notes.prototype.addBinding = function() {
...@@ -81,10 +83,13 @@ ...@@ -81,10 +83,13 @@
$(document).on("click", ".js-add-diff-note-button", this.addDiffNote); $(document).on("click", ".js-add-diff-note-button", this.addDiffNote);
// hide diff note form // hide diff note form
$(document).on("click", ".js-close-discussion-note-form", this.cancelDiscussionForm); $(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 // fetch notes when tab becomes visible
$(document).on("visibilitychange", this.visibilityChange); $(document).on("visibilitychange", this.visibilityChange);
// when issue status changes, we need to refresh data // when issue status changes, we need to refresh data
$(document).on("issuable:change", this.refresh); $(document).on("issuable:change", this.refresh);
// when a key is clicked on the notes // when a key is clicked on the notes
return $(document).on("keydown", ".js-note-text", this.keydownNoteText); return $(document).on("keydown", ".js-note-text", this.keydownNoteText);
}; };
...@@ -114,9 +119,10 @@ ...@@ -114,9 +119,10 @@
Notes.prototype.keydownNoteText = function(e) { Notes.prototype.keydownNoteText = function(e) {
var $textarea, discussionNoteForm, editNote, myLastNote, myLastNoteEditBtn, newText, originalText; var $textarea, discussionNoteForm, editNote, myLastNote, myLastNoteEditBtn, newText, originalText;
if (isMetaKey(e)) { if (gl.utils.isMetaKey(e)) {
return; return;
} }
$textarea = $(e.target); $textarea = $(e.target);
// Edit previous note when UP arrow is hit // Edit previous note when UP arrow is hit
switch (e.which) { switch (e.which) {
...@@ -156,10 +162,6 @@ ...@@ -156,10 +162,6 @@
} }
}; };
isMetaKey = function(e) {
return e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
};
Notes.prototype.initRefresh = function() { Notes.prototype.initRefresh = function() {
clearInterval(Notes.interval); clearInterval(Notes.interval);
return Notes.interval = setInterval((function(_this) { return Notes.interval = setInterval((function(_this) {
...@@ -263,6 +265,7 @@ ...@@ -263,6 +265,7 @@
$notesList.append(note.html).syntaxHighlight(); $notesList.append(note.html).syntaxHighlight();
// Update datetime format on the recent note // Update datetime format on the recent note
gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false); gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false);
this.collapseLongCommitList();
this.initTaskList(); this.initTaskList();
this.refresh(); this.refresh();
return this.updateNotesCount(1); return this.updateNotesCount(1);
...@@ -433,9 +436,9 @@ ...@@ -433,9 +436,9 @@
var $form = $(xhr.target); var $form = $(xhr.target);
if ($form.attr('data-resolve-all') != null) { if ($form.attr('data-resolve-all') != null) {
var projectPath = $form.data('project-path') var projectPath = $form.data('project-path');
discussionId = $form.data('discussion-id'), var discussionId = $form.data('discussion-id');
mergeRequestId = $form.data('noteable-iid'); var mergeRequestId = $form.data('noteable-iid');
if (ResolveService != null) { if (ResolveService != null) {
ResolveService.toggleResolveForDiscussion(projectPath, mergeRequestId, discussionId); ResolveService.toggleResolveForDiscussion(projectPath, mergeRequestId, discussionId);
...@@ -844,9 +847,9 @@ ...@@ -844,9 +847,9 @@
return this.notesCountBadge.text(parseInt(this.notesCountBadge.text()) + updateCount); return this.notesCountBadge.text(parseInt(this.notesCountBadge.text()) + updateCount);
}; };
Notes.prototype.resolveDiscussion = function () { Notes.prototype.resolveDiscussion = function() {
var $this = $(this), var $this = $(this);
discussionId = $this.attr('data-discussion-id'); var discussionId = $this.attr('data-discussion-id');
$this $this
.closest('form') .closest('form')
...@@ -855,6 +858,36 @@ ...@@ -855,6 +858,36 @@
.attr('data-project-path', $this.attr('data-project-path')); .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; return Notes;
})(); })();
......
...@@ -3,26 +3,12 @@ ...@@ -3,26 +3,12 @@
class Pipelines { class Pipelines {
constructor() { constructor() {
this.initGraphToggle();
this.addMarginToBuildColumns(); 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() { 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) { for (buildNodeIndex in secondChildBuildNodes) {
const buildNode = secondChildBuildNodes[buildNodeIndex]; const buildNode = secondChildBuildNodes[buildNodeIndex];
const firstChildBuildNode = buildNode.previousElementSibling; const firstChildBuildNode = buildNode.previousElementSibling;
......
/*= require vue */
/* global Vue */
(() => {
window.gl = window.gl || {};
window.gl.CommitComponent = Vue.component('commit-component', {
props: {
/**
* Indicates the existance of a tag.
* Used to render the correct icon, if true will render `fa-tag` icon,
* if false will render `fa-code-fork` icon.
*/
tag: {
type: Boolean,
required: false,
default: false,
},
/**
* If provided is used to render the branch name and url.
* Should contain the following properties:
* name
* ref_url
*/
ref: {
type: Object,
required: false,
default: () => ({}),
},
/**
* Used to link to the commit sha.
*/
commit_url: {
type: String,
required: false,
default: '',
},
/**
* Used to show the commit short_sha that links to the commit url.
*/
short_sha: {
type: String,
required: false,
default: '',
},
/**
* If provided shows the commit tile.
*/
title: {
type: String,
required: false,
default: '',
},
/**
* If provided renders information about the author of the commit.
* When provided should include:
* `avatar_url` to render the avatar icon
* `web_url` to link to user profile
* `username` to render alt and title tags
*/
author: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
/**
* Used to verify if all the properties needed to render the commit
* ref section were provided.
*
* TODO: Improve this! Use lodash _.has when we have it.
*
* @returns {Boolean}
*/
hasRef() {
return this.ref && this.ref.name && this.ref.ref_url;
},
/**
* Used to verify if all the properties needed to render the commit
* author section were provided.
*
* TODO: Improve this! Use lodash _.has when we have it.
*
* @returns {Boolean}
*/
hasAuthor() {
return this.author &&
this.author.avatar_url &&
this.author.web_url &&
this.author.username;
},
/**
* If information about the author is provided will return a string
* to be rendered as the alt attribute of the img tag.
*
* @returns {String}
*/
userImageAltDescription() {
return this.author &&
this.author.username ? `${this.author.username}'s avatar` : null;
},
},
/**
* In order to reuse the svg instead of copy and paste in this template
* we need to render it outside this component using =custom_icon partial.
* Make sure it has this structure:
* .commit-icon-svg.hidden
* svg
*
* TODO: Find a better way to include SVG
*/
mounted() {
const commitIconContainer = this.$el.querySelector('.commit-icon-container');
const commitIcon = document.querySelector('.commit-icon-svg.hidden svg');
if (commitIconContainer && commitIcon) {
commitIconContainer.appendChild(commitIcon.cloneNode(true));
}
},
template: `
<div class="branch-commit">
<div v-if="hasRef" class="icon-container">
<i v-if="tag" class="fa fa-tag"></i>
<i v-if="!tag" class="fa fa-code-fork"></i>
</div>
<a v-if="hasRef"
class="monospace branch-name"
:href="ref.ref_url"
v-html="ref.name">
</a>
<div class="icon-container commit-icon commit-icon-container">
</div>
<a class="commit-id monospace"
:href="commit_url"
v-html="short_sha">
</a>
<p class="commit-title">
<span v-if="title">
<a v-if="hasAuthor"
class="avatar-image-container"
:href="author.web_url">
<img
class="avatar has-tooltip s20"
:src="author.avatar_url"
:alt="userImageAltDescription"
:title="author.username" />
</a>
<a class="commit-row-message"
:href="commit_url" v-html="title">
</a>
</span>
<span v-else>
Cant find HEAD commit for this branch
</span>
</p>
</div>
`,
});
})();
...@@ -39,3 +39,5 @@ ...@@ -39,3 +39,5 @@
@import "framework/typography.scss"; @import "framework/typography.scss";
@import "framework/zen.scss"; @import "framework/zen.scss";
@import "framework/blank"; @import "framework/blank";
@import "framework/wells.scss";
@import "framework/page-header.scss";
...@@ -349,6 +349,12 @@ ...@@ -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) { @media (max-width: $screen-xs-max) {
.btn-wide-on-xs { .btn-wide-on-xs {
width: 100%; width: 100%;
......
...@@ -64,12 +64,17 @@ ...@@ -64,12 +64,17 @@
a { a {
padding-top: 0; padding-top: 0;
line-height: 1; line-height: 19px;
border-bottom: 1px solid $border-color; border-bottom: 1px solid $border-color;
&.btn.btn-xs { &.btn.btn-xs {
padding: 2px 5px; padding: 2px 5px;
} }
&:focus {
margin-top: -10px;
padding-top: 10px;
}
} }
} }
} }
...@@ -163,4 +168,4 @@ ...@@ -163,4 +168,4 @@
border: 1px solid $white-light; border: 1px solid $white-light;
} }
} }
} }
\ No newline at end of file
.page-content-header {
line-height: 34px;
padding: 10px 0;
margin-bottom: 0;
@media (min-width: $screen-sm-min) {
display: flex;
align-items: center;
.header-main-content {
flex: 1;
}
}
.header-action-buttons {
i {
color: $gl-icon-color;
font-size: 13px;
margin-right: 3px;
}
@media (max-width: $screen-xs-max) {
.btn {
width: 100%;
margin-top: 10px;
}
.dropdown {
width: 100%;
}
}
}
.avatar {
@extend .avatar-inline;
margin-left: 0;
@media (min-width: $screen-sm-min) {
margin-left: 4px;
}
}
.commit-committer-link,
.commit-author-link {
color: $gl-gray;
font-weight: bold;
}
.fa-clipboard {
color: $dropdown-title-btn-color;
}
.commit-info {
&.branches {
margin-left: 8px;
}
}
.ci-status-link {
svg {
position: relative;
top: 2px;
margin: 0 2px 0 3px;
}
}
}
...@@ -90,8 +90,8 @@ $table-border-color: #f0f0f0; ...@@ -90,8 +90,8 @@ $table-border-color: #f0f0f0;
$background-color: $gray-light; $background-color: $gray-light;
$dark-background-color: #f5f5f5; $dark-background-color: #f5f5f5;
$table-text-gray: #8f8f8f; $table-text-gray: #8f8f8f;
$widget-expand-item: #e8f2f7; $well-expand-item: #e8f2f7;
$widget-inner-border: #eef0f2; $well-inner-border: #eef0f2;
/* /*
* Text * Text
......
.info-well {
background: $background-color;
color: $gl-gray;
border: 1px solid $border-color;
border-radius: $border-radius-default;
.well-segment {
padding: $gl-padding;
&:not(:last-of-type) {
border-bottom: 1px solid $well-inner-border;
}
&.branch-info {
.monospace,
.commit-info {
margin-left: 4px;
}
}
}
.icon-container {
display: inline-block;
margin-right: 8px;
svg {
position: relative;
top: 2px;
height: 16px;
width: 16px;
}
&.commit-icon {
svg {
path {
fill: $gl-text-color;
}
}
}
}
.label.label-gray {
background-color: $well-expand-item;
}
}
...@@ -160,3 +160,9 @@ ...@@ -160,3 +160,9 @@
} }
} }
} }
.admin-builds-table {
.ci-table td:last-child {
min-width: 120px;
}
}
...@@ -40,6 +40,19 @@ ...@@ -40,6 +40,19 @@
margin-bottom: 10px; 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 { .build-header {
...@@ -49,10 +62,6 @@ ...@@ -49,10 +62,6 @@
min-height: 58px; min-height: 58px;
align-items: center; 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) { @media (max-width: $screen-sm-max) {
padding-right: 40px; padding-right: 40px;
...@@ -63,14 +72,14 @@ ...@@ -63,14 +72,14 @@
.header-content { .header-content {
flex: 1; flex: 1;
}
a { a {
color: $gl-gray; color: $gl-gray;
&:hover { &:hover {
color: $gl-link-color; color: $gl-link-color;
text-decoration: none; text-decoration: none;
}
} }
} }
......
...@@ -26,143 +26,12 @@ ...@@ -26,143 +26,12 @@
white-space: pre-wrap; 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 { .js-details-expand {
&:hover { &:hover {
text-decoration: none; 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 { .ci-status-link {
svg { svg {
overflow: visible; overflow: visible;
...@@ -184,6 +53,17 @@ ...@@ -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 { .file-stats {
ul { ul {
list-style: none; list-style: none;
......
.environments-container,
.deployments-container { .deployments-container {
width: 100%; width: 100%;
overflow: auto; overflow: auto;
} }
.environments-list-loading {
width: 100%;
font-size: 34px;
}
@media (max-width: $screen-sm-min) {
.environments-container {
width: 100%;
overflow: auto;
}
}
.environments { .environments {
table-layout: fixed;
.deployment-column { .deployment-column {
.avatar { .avatar {
float: none; float: none;
...@@ -15,6 +28,10 @@ ...@@ -15,6 +28,10 @@
margin: 0; margin: 0;
} }
.avatar-image-container {
text-decoration: none;
}
.icon-play { .icon-play {
height: 13px; height: 13px;
width: 12px; width: 12px;
...@@ -38,7 +55,8 @@ ...@@ -38,7 +55,8 @@
color: $gl-dark-link-color; color: $gl-dark-link-color;
} }
.stop-env-link { .stop-env-link,
.external-url {
color: $table-text-gray; color: $table-text-gray;
.stop-env-icon { .stop-env-icon {
...@@ -58,10 +76,29 @@ ...@@ -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 { .table.ci-table.environments {
.icon-container { .icon-container {
width: 20px; width: 20px;
text-align: center; text-align: center;
......
...@@ -255,26 +255,3 @@ ...@@ -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 @@ ...@@ -61,7 +61,7 @@
} }
.ci_widget { .ci_widget {
border-bottom: 1px solid $widget-inner-border; border-bottom: 1px solid $well-inner-border;
svg { svg {
margin-right: 4px; margin-right: 4px;
......
...@@ -35,11 +35,84 @@ ul.notes { ...@@ -35,11 +35,84 @@ ul.notes {
.system-note { .system-note {
font-size: 14px; font-size: 14px;
padding-top: 10px; padding: 0;
padding-bottom: 10px; clear: both;
background: #fdfdfd;
&.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 { .timeline-icon {
display: none;
.avatar { .avatar {
visibility: hidden; visibility: hidden;
...@@ -65,6 +138,12 @@ ul.notes { ...@@ -65,6 +138,12 @@ ul.notes {
position: relative; position: relative;
border-bottom: 1px solid $table-border-gray; border-bottom: 1px solid $table-border-gray;
&.note-discussion {
&.timeline-entry {
padding: 14px 10px;
}
}
&.is-editting { &.is-editting {
.note-header, .note-header,
.note-text, .note-text,
...@@ -88,10 +167,8 @@ ul.notes { ...@@ -88,10 +167,8 @@ ul.notes {
overflow: auto; overflow: auto;
word-wrap: break-word; word-wrap: break-word;
@include md-typography; @include md-typography;
// Reset ul style types since we're nested inside a ul already // Reset ul style types since we're nested inside a ul already
@include bulleted-list; @include bulleted-list;
ul.task-list { ul.task-list {
ul:not(.task-list) { ul:not(.task-list) {
padding-left: 1.3em; padding-left: 1.3em;
...@@ -111,6 +188,11 @@ ul.notes { ...@@ -111,6 +188,11 @@ ul.notes {
padding-bottom: 3px; padding-bottom: 3px;
padding-right: 20px; padding-right: 20px;
p {
display: inline;
margin: 0;
}
@media (min-width: $screen-sm-min) { @media (min-width: $screen-sm-min) {
padding-right: 0; padding-right: 0;
} }
...@@ -238,6 +320,10 @@ ul.notes { ...@@ -238,6 +320,10 @@ ul.notes {
} }
} }
.discussion-header {
font-size: 14px;
}
.note-headline-light, .note-headline-light,
.discussion-headline-light { .discussion-headline-light {
color: $notes-light-color; color: $notes-light-color;
......
...@@ -300,6 +300,8 @@ ...@@ -300,6 +300,8 @@
.pipeline-graph { .pipeline-graph {
width: 100%; width: 100%;
background-color: $background-color;
padding: $gl-padding;
overflow: auto; overflow: auto;
white-space: nowrap; white-space: nowrap;
transition: max-height 0.3s, padding 0.3s; transition: max-height 0.3s, padding 0.3s;
...@@ -363,6 +365,7 @@ ...@@ -363,6 +365,7 @@
.build { .build {
border: 1px solid $border-color; border: 1px solid $border-color;
background-color: $white-light;
position: relative; position: relative;
padding: 7px 10px 8px; padding: 7px 10px 8px;
border-radius: 30px; border-radius: 30px;
......
...@@ -145,6 +145,10 @@ ...@@ -145,6 +145,10 @@
} }
} }
.nav > .project-repo-buttons {
margin-top: 0;
}
.project-repo-buttons, .project-repo-buttons,
.group-buttons { .group-buttons {
margin-top: 15px; margin-top: 15px;
...@@ -184,6 +188,12 @@ ...@@ -184,6 +188,12 @@
margin-left: 10px; margin-left: 10px;
} }
.download-button {
@media (max-width: $screen-lg-min) {
margin-left: 0;
}
}
.count-buttons { .count-buttons {
display: inline-block; display: inline-block;
vertical-align: top; vertical-align: top;
...@@ -468,6 +478,20 @@ a.deploy-project-label { ...@@ -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 { .project-stats {
font-size: 0; font-size: 0;
border-bottom: 1px solid $border-color; border-bottom: 1px solid $border-color;
...@@ -485,9 +509,11 @@ a.deploy-project-label { ...@@ -485,9 +509,11 @@ a.deploy-project-label {
} }
&.right { &.right {
@media (min-width: $screen-md-min) { vertical-align: top;
margin-top: 0;
@media (min-width: $screen-lg-min) {
float: right; float: right;
margin-top: 0;
} }
} }
} }
......
...@@ -55,7 +55,7 @@ class AutocompleteController < ApplicationController ...@@ -55,7 +55,7 @@ class AutocompleteController < ApplicationController
def find_users def find_users
@users = @users =
if @project if @project
user_ids = @project.team.users.map(&:id) user_ids = @project.team.users.pluck(:id)
if params[:author_id].present? if params[:author_id].present?
user_ids << params[:author_id] user_ids << params[:author_id]
......
module CycleAnalyticsParams
extend ActiveSupport::Concern
def start_date(params)
params[:start_date] == '30' ? 30.days.ago : 90.days.ago
end
end
...@@ -12,7 +12,7 @@ module IssuableActions ...@@ -12,7 +12,7 @@ module IssuableActions
destroy_method = "destroy_#{issuable.class.name.underscore}".to_sym destroy_method = "destroy_#{issuable.class.name.underscore}".to_sym
TodoService.new.public_send(destroy_method, issuable, current_user) 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." flash[:notice] = "The #{name} was successfully deleted."
redirect_to polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class]) redirect_to polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class])
end end
......
...@@ -10,11 +10,11 @@ module IssuableCollections ...@@ -10,11 +10,11 @@ module IssuableCollections
private private
def issues_collection def issues_collection
issues_finder.execute issues_finder.execute.preload(:project, :author, :assignee, :labels, :milestone, project: :namespace)
end end
def merge_requests_collection 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 end
def issues_finder def issues_finder
......
...@@ -7,7 +7,6 @@ module IssuesAction ...@@ -7,7 +7,6 @@ module IssuesAction
@issues = issues_collection @issues = issues_collection
.non_archived .non_archived
.preload(:author, :project)
.page(params[:page]) .page(params[:page])
respond_to do |format| respond_to do |format|
......
...@@ -7,7 +7,6 @@ module MergeRequestsAction ...@@ -7,7 +7,6 @@ module MergeRequestsAction
@merge_requests = merge_requests_collection @merge_requests = merge_requests_collection
.non_archived .non_archived
.preload(:author, :target_project)
.page(params[:page]) .page(params[:page])
end end
end end
module Projects
module CycleAnalytics
class EventsController < Projects::ApplicationController
include CycleAnalyticsParams
before_action :authorize_read_cycle_analytics!
before_action :authorize_read_build!, only: [:test, :staging]
before_action :authorize_read_issue!, only: [:issue, :production]
before_action :authorize_read_merge_request!, only: [:code, :review]
def issue
render_events(events.issue_events)
end
def plan
render_events(events.plan_events)
end
def code
render_events(events.code_events)
end
def test
options[:branch] = events_params[:branch_name]
render_events(events.test_events)
end
def review
render_events(events.review_events)
end
def staging
render_events(events.staging_events)
end
def production
render_events(events.production_events)
end
private
def render_events(events_list)
respond_to do |format|
format.html
format.json { render json: { events: events_list } }
end
end
def events
@events ||= Gitlab::CycleAnalytics::Events.new(project: project, options: options)
end
def options
@options ||= { from: start_date(events_params), current_user: current_user }
end
def events_params
return {} unless params[:events].present?
params[:events].slice(:start_date, :branch_name)
end
end
end
end
class Projects::CycleAnalyticsController < Projects::ApplicationController class Projects::CycleAnalyticsController < Projects::ApplicationController
include ActionView::Helpers::DateHelper include ActionView::Helpers::DateHelper
include ActionView::Helpers::TextHelper include ActionView::Helpers::TextHelper
include CycleAnalyticsParams
before_action :authorize_read_cycle_analytics! before_action :authorize_read_cycle_analytics!
def show 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| respond_to do |format|
format.html format.html
...@@ -15,14 +16,6 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController ...@@ -15,14 +16,6 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
private 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 def cycle_analytics_params
return {} unless params[:cycle_analytics].present? return {} unless params[:cycle_analytics].present?
......
...@@ -8,13 +8,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController ...@@ -8,13 +8,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def index def index
@scope = params[:scope] @scope = params[:scope]
@all_environments = project.environments @environments = project.environments
@environments =
if @scope == 'stopped' respond_to do |format|
@all_environments.stopped format.html
else format.json do
@all_environments.available render json: EnvironmentSerializer
.new(project: @project)
.represent(@environments)
end end
end
end end
def show def show
......
...@@ -69,7 +69,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -69,7 +69,7 @@ class Projects::IssuesController < Projects::ApplicationController
respond_to do |format| respond_to do |format|
format.html format.html
format.json do format.json do
render json: @issue.to_json(include: [:milestone, :labels]) render json: IssueSerializer.new.represent(@issue)
end end
end end
end end
......
...@@ -38,7 +38,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController ...@@ -38,7 +38,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController
def index def index
@merge_requests = merge_requests_collection @merge_requests = merge_requests_collection
@merge_requests = @merge_requests.page(params[:page]) @merge_requests = @merge_requests.page(params[:page])
@merge_requests = @merge_requests.preload(:target_project)
if params[:label_name].present? if params[:label_name].present?
labels_params = { project_id: @project.id, title: params[:label_name] } labels_params = { project_id: @project.id, title: params[:label_name] }
...@@ -61,7 +60,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController ...@@ -61,7 +60,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
format.html { define_discussion_vars } format.html { define_discussion_vars }
format.json do format.json do
render json: @merge_request render json: MergeRequestSerializer.new.represent(@merge_request)
end end
format.patch do format.patch do
......
...@@ -146,24 +146,26 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -146,24 +146,26 @@ class Projects::NotesController < Projects::ApplicationController
end end
def note_json(note) def note_json(note)
attrs = {
award: false,
id: note.id
}
if note.is_a?(AwardEmoji) if note.is_a?(AwardEmoji)
{ attrs.merge!(
valid: note.valid?, valid: note.valid?,
award: true, award: true,
id: note.id,
name: note.name name: note.name
} )
elsif note.persisted? elsif note.persisted?
Banzai::NoteRenderer.render([note], @project, current_user) Banzai::NoteRenderer.render([note], @project, current_user)
attrs = { attrs.merge!(
valid: true, valid: true,
id: note.id,
discussion_id: note.discussion_id, discussion_id: note.discussion_id,
html: note_html(note), html: note_html(note),
award: false,
note: note.note note: note.note
} )
if note.diff_note? if note.diff_note?
discussion = note.to_discussion discussion = note.to_discussion
...@@ -188,15 +190,14 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -188,15 +190,14 @@ class Projects::NotesController < Projects::ApplicationController
attrs[:original_discussion_id] = note.original_discussion_id attrs[:original_discussion_id] = note.original_discussion_id
end end
end end
attrs
else else
{ attrs.merge!(
valid: false, valid: false,
award: false,
errors: note.errors errors: note.errors
} )
end end
attrs
end end
def authorize_admin_note! def authorize_admin_note!
......
...@@ -28,6 +28,8 @@ class Projects::ServicesController < Projects::ApplicationController ...@@ -28,6 +28,8 @@ class Projects::ServicesController < Projects::ApplicationController
end end
def test def test
return render_404 unless @service.can_test?
data = @service.test_data(project, current_user) data = @service.test_data(project, current_user)
outcome = @service.test(data) outcome = @service.test(data)
......
module EnvironmentHelper
def environment_for_build(project, build)
return unless build.environment
project.environments.find_by(name: build.expanded_environment_name)
end
def environment_link_for_build(project, build)
environment = environment_for_build(project, build)
if environment
link_to environment.name, namespace_project_environment_path(project.namespace, project, environment)
else
content_tag :span, build.expanded_environment_name
end
end
def deployment_link(deployment)
return unless deployment
link_to "##{deployment.iid}", [deployment.project.namespace.becomes(Namespace), deployment.project, deployment.deployable]
end
def last_deployment_link_for_environment_build(project, build)
environment = environment_for_build(project, build)
return unless environment
deployment_link(environment.last_deployment)
end
end
module EnvironmentsHelper
def environments_list_data
{
endpoint: namespace_project_environments_path(@project.namespace, @project, format: :json)
}
end
end
...@@ -171,9 +171,11 @@ module IssuablesHelper ...@@ -171,9 +171,11 @@ module IssuablesHelper
def issuables_count_for_state(issuable_type, state) def issuables_count_for_state(issuable_type, state)
issuables_finder = public_send("#{issuable_type}_finder") 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 end
IRRELEVANT_PARAMS_FOR_CACHE_KEY = %i[utf8 sort page] IRRELEVANT_PARAMS_FOR_CACHE_KEY = %i[utf8 sort page]
......
...@@ -17,6 +17,8 @@ module ServicesHelper ...@@ -17,6 +17,8 @@ module ServicesHelper
"Event will be triggered when a build status changes" "Event will be triggered when a build status changes"
when "wiki_page" when "wiki_page"
"Event will be triggered when a wiki page is created/updated" "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
end end
......
...@@ -6,4 +6,8 @@ module TriggersHelper ...@@ -6,4 +6,8 @@ module TriggersHelper
"#{Settings.gitlab.url}/api/v3/projects/#{project_id}/ref/#{ref}/trigger/builds" "#{Settings.gitlab.url}/api/v3/projects/#{project_id}/ref/#{ref}/trigger/builds"
end end
end end
def service_trigger_url(service)
"#{Settings.gitlab.url}/api/v3/projects/#{service.project_id}/services/#{service.to_param}/trigger"
end
end end
...@@ -7,6 +7,8 @@ module Ci ...@@ -7,6 +7,8 @@ module Ci
belongs_to :trigger_request belongs_to :trigger_request
belongs_to :erased_by, class_name: 'User' belongs_to :erased_by, class_name: 'User'
has_many :deployments, as: :deployable
serialize :options serialize :options
serialize :yaml_variables serialize :yaml_variables
...@@ -68,7 +70,11 @@ module Ci ...@@ -68,7 +70,11 @@ module Ci
environment: build.environment, environment: build.environment,
status_event: 'enqueue' 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) build.pipeline.mark_as_processable_after_stage(build.stage_idx)
new_build new_build
end end
...@@ -125,6 +131,34 @@ module Ci ...@@ -125,6 +131,34 @@ module Ci
!self.pipeline.statuses.latest.include?(self) !self.pipeline.statuses.latest.include?(self)
end 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 def depends_on_builds
# Get builds of the same type # Get builds of the same type
latest_builds = self.pipeline.builds.latest latest_builds = self.pipeline.builds.latest
......
...@@ -251,6 +251,17 @@ module Issuable ...@@ -251,6 +251,17 @@ module Issuable
self.class.to_ability_name self.class.to_ability_name
end 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 # Returns a Hash of attributes to be used for Twitter card metadata
def card_attributes def card_attributes
{ {
......
module SelectForProjectAuthorization
extend ActiveSupport::Concern
module ClassMethods
def select_for_project_authorization
select("members.user_id, projects.id AS project_id, members.access_level")
end
end
end
class CycleAnalytics class CycleAnalytics
include Gitlab::Database::Median
include Gitlab::Database::DateTime
DEPLOYMENT_METRIC_STAGES = %i[production staging]
def initialize(project, from:) def initialize(project, from:)
@project = project @project = project
@from = from @from = from
@fetcher = Gitlab::CycleAnalytics::MetricsFetcher.new(project: project, from: from, branch: nil)
end end
def summary def summary
...@@ -14,90 +10,46 @@ class CycleAnalytics ...@@ -14,90 +10,46 @@ class CycleAnalytics
end end
def issue def issue
calculate_metric(:issue, @fetcher.calculate_metric(:issue,
Issue.arel_table[:created_at], Issue.arel_table[:created_at],
[Issue::Metrics.arel_table[:first_associated_with_milestone_at], [Issue::Metrics.arel_table[:first_associated_with_milestone_at],
Issue::Metrics.arel_table[:first_added_to_board_at]]) Issue::Metrics.arel_table[:first_added_to_board_at]])
end end
def plan def plan
calculate_metric(:plan, @fetcher.calculate_metric(:plan,
[Issue::Metrics.arel_table[:first_associated_with_milestone_at], [Issue::Metrics.arel_table[:first_associated_with_milestone_at],
Issue::Metrics.arel_table[:first_added_to_board_at]], Issue::Metrics.arel_table[:first_added_to_board_at]],
Issue::Metrics.arel_table[:first_mentioned_in_commit_at]) Issue::Metrics.arel_table[:first_mentioned_in_commit_at])
end end
def code def code
calculate_metric(:code, @fetcher.calculate_metric(:code,
Issue::Metrics.arel_table[:first_mentioned_in_commit_at], Issue::Metrics.arel_table[:first_mentioned_in_commit_at],
MergeRequest.arel_table[:created_at]) MergeRequest.arel_table[:created_at])
end end
def test def test
calculate_metric(:test, @fetcher.calculate_metric(:test,
MergeRequest::Metrics.arel_table[:latest_build_started_at], MergeRequest::Metrics.arel_table[:latest_build_started_at],
MergeRequest::Metrics.arel_table[:latest_build_finished_at]) MergeRequest::Metrics.arel_table[:latest_build_finished_at])
end end
def review def review
calculate_metric(:review, @fetcher.calculate_metric(:review,
MergeRequest.arel_table[:created_at], MergeRequest.arel_table[:created_at],
MergeRequest::Metrics.arel_table[:merged_at]) MergeRequest::Metrics.arel_table[:merged_at])
end end
def staging def staging
calculate_metric(:staging, @fetcher.calculate_metric(:staging,
MergeRequest::Metrics.arel_table[:merged_at], MergeRequest::Metrics.arel_table[:merged_at],
MergeRequest::Metrics.arel_table[:first_deployed_to_production_at]) MergeRequest::Metrics.arel_table[:first_deployed_to_production_at])
end end
def production def production
calculate_metric(:production, @fetcher.calculate_metric(:production,
Issue.arel_table[:created_at], Issue.arel_table[:created_at],
MergeRequest::Metrics.arel_table[:first_deployed_to_production_at]) MergeRequest::Metrics.arel_table[:first_deployed_to_production_at])
end 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 end
...@@ -5,6 +5,7 @@ class Group < Namespace ...@@ -5,6 +5,7 @@ class Group < Namespace
include Gitlab::VisibilityLevel include Gitlab::VisibilityLevel
include AccessRequestable include AccessRequestable
include Referable include Referable
include SelectForProjectAuthorization
has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source
alias_method :members, :group_members alias_method :members, :group_members
...@@ -61,6 +62,14 @@ class Group < Namespace ...@@ -61,6 +62,14 @@ class Group < Namespace
def visible_to_user(user) def visible_to_user(user)
where(id: user.authorized_groups.select(:id).reorder(nil)) where(id: user.authorized_groups.select(:id).reorder(nil))
end 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 end
def to_reference(_from_project = nil) def to_reference(_from_project = nil)
...@@ -176,4 +185,8 @@ class Group < Namespace ...@@ -176,4 +185,8 @@ class Group < Namespace
def system_hook_service def system_hook_service
SystemHooksService.new SystemHooksService.new
end end
def refresh_members_authorized_projects
UserProjectAccessChangedService.new(users.pluck(:id)).execute
end
end end
...@@ -113,6 +113,8 @@ class Member < ActiveRecord::Base ...@@ -113,6 +113,8 @@ class Member < ActiveRecord::Base
member.save member.save
end end
UserProjectAccessChangedService.new(user.id).execute if user.is_a?(User)
member member
end end
...@@ -239,6 +241,7 @@ class Member < ActiveRecord::Base ...@@ -239,6 +241,7 @@ class Member < ActiveRecord::Base
end end
def post_create_hook def post_create_hook
UserProjectAccessChangedService.new(user.id).execute
system_hook_service.execute_hooks_for(self, :create) system_hook_service.execute_hooks_for(self, :create)
end end
...@@ -247,9 +250,19 @@ class Member < ActiveRecord::Base ...@@ -247,9 +250,19 @@ class Member < ActiveRecord::Base
end end
def post_destroy_hook def post_destroy_hook
refresh_member_authorized_projects
system_hook_service.execute_hooks_for(self, :destroy) system_hook_service.execute_hooks_for(self, :destroy)
end 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 def after_accept_invite
post_create_hook post_create_hook
end end
......
...@@ -686,7 +686,7 @@ class MergeRequest < ActiveRecord::Base ...@@ -686,7 +686,7 @@ class MergeRequest < ActiveRecord::Base
def mergeable_ci_state? def mergeable_ci_state?
return true unless project.only_allow_merge_if_build_succeeds? return true unless project.only_allow_merge_if_build_succeeds?
!pipeline || pipeline.success? !pipeline || pipeline.success? || pipeline.skipped?
end end
def environments def environments
......
class MergeRequest::Metrics < ActiveRecord::Base class MergeRequest::Metrics < ActiveRecord::Base
belongs_to :merge_request belongs_to :merge_request
belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :pipeline_id
def record! def record!
if merge_request.merged? && self.merged_at.blank? if merge_request.merged? && self.merged_at.blank?
......
...@@ -13,6 +13,7 @@ class Project < ActiveRecord::Base ...@@ -13,6 +13,7 @@ class Project < ActiveRecord::Base
include CaseSensitivity include CaseSensitivity
include TokenAuthenticatable include TokenAuthenticatable
include ProjectFeaturesCompatibility include ProjectFeaturesCompatibility
include SelectForProjectAuthorization
extend Gitlab::ConfigHelper extend Gitlab::ConfigHelper
...@@ -23,7 +24,9 @@ class Project < ActiveRecord::Base ...@@ -23,7 +24,9 @@ class Project < ActiveRecord::Base
cache_markdown_field :description, pipeline: :description 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 :archived, false
default_value_for :visibility_level, gitlab_config_features.visibility_level default_value_for :visibility_level, gitlab_config_features.visibility_level
...@@ -75,6 +78,7 @@ class Project < ActiveRecord::Base ...@@ -75,6 +78,7 @@ class Project < ActiveRecord::Base
has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event' has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event'
has_many :boards, before_add: :validate_board_limit, dependent: :destroy has_many :boards, before_add: :validate_board_limit, dependent: :destroy
has_many :chat_services
# Project services # Project services
has_one :campfire_service, dependent: :destroy has_one :campfire_service, dependent: :destroy
...@@ -89,6 +93,7 @@ class Project < ActiveRecord::Base ...@@ -89,6 +93,7 @@ class Project < ActiveRecord::Base
has_one :assembla_service, dependent: :destroy has_one :assembla_service, dependent: :destroy
has_one :asana_service, dependent: :destroy has_one :asana_service, dependent: :destroy
has_one :gemnasium_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 :slack_service, dependent: :destroy
has_one :buildkite_service, dependent: :destroy has_one :buildkite_service, dependent: :destroy
has_one :bamboo_service, dependent: :destroy has_one :bamboo_service, dependent: :destroy
...@@ -1289,16 +1294,10 @@ class Project < ActiveRecord::Base ...@@ -1289,16 +1294,10 @@ class Project < ActiveRecord::Base
# Checks if `user` is authorized for this project, with at least the # Checks if `user` is authorized for this project, with at least the
# `min_access_level` (if given). # `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) def authorized_for_user?(user, min_access_level = nil)
return false unless user return false unless user
return true if personal? && namespace_id == user.namespace_id user.authorized_project?(self, min_access_level)
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)
end end
def append_or_update_attribute(name, value) def append_or_update_attribute(name, value)
...@@ -1358,30 +1357,6 @@ class Project < ActiveRecord::Base ...@@ -1358,30 +1357,6 @@ class Project < ActiveRecord::Base
current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE
end 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 # Similar to the normal callbacks that hook into the life cycle of an
# Active Record object, you can also define callbacks that get triggered # Active Record object, you can also define callbacks that get triggered
# when you add an object to an association collection. If any of these # when you add an object to an association collection. If any of these
......
class ProjectAuthorization < ActiveRecord::Base
belongs_to :user
belongs_to :project
validates :project, presence: true
validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true
validates :user, uniqueness: { scope: [:project, :access_level] }, presence: true
end
...@@ -60,6 +60,10 @@ class ProjectFeature < ActiveRecord::Base ...@@ -60,6 +60,10 @@ class ProjectFeature < ActiveRecord::Base
merge_requests_access_level > DISABLED merge_requests_access_level > DISABLED
end end
def issues_enabled?
issues_access_level > DISABLED
end
private private
# Validates builds and merge requests access level # Validates builds and merge requests access level
......
...@@ -16,6 +16,9 @@ class ProjectGroupLink < ActiveRecord::Base ...@@ -16,6 +16,9 @@ class ProjectGroupLink < ActiveRecord::Base
validates :group_access, inclusion: { in: Gitlab::Access.values }, presence: true validates :group_access, inclusion: { in: Gitlab::Access.values }, presence: true
validate :different_group validate :different_group
after_create :refresh_group_members_authorized_projects
after_destroy :refresh_group_members_authorized_projects
def self.access_options def self.access_options
Gitlab::Access.options Gitlab::Access.options
end end
...@@ -35,4 +38,8 @@ class ProjectGroupLink < ActiveRecord::Base ...@@ -35,4 +38,8 @@ class ProjectGroupLink < ActiveRecord::Base
errors.add(:base, "Project cannot be shared with the project it is in.") errors.add(:base, "Project cannot be shared with the project it is in.")
end end
end end
def refresh_group_members_authorized_projects
group.refresh_members_authorized_projects
end
end end
# Base class for Chat services
# This class is not meant to be used directly, but only to inherrit from.
class ChatService < Service
default_value_for :category, 'chat'
has_many :chat_names, foreign_key: :service_id
def valid_token?(token)
self.respond_to?(:token) &&
self.token.present? &&
ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.token)
end
def supported_events
[]
end
def trigger(params)
raise NotImplementedError
end
end
# == Schema Information
#
# Table name: services
#
# id :integer not null, primary key
# type :string(255)
# title :string(255)
# project_id :integer
# created_at :datetime
# updated_at :datetime
# active :boolean default(FALSE), not null
# properties :text
# template :boolean default(FALSE)
# push_events :boolean default(TRUE)
# issues_events :boolean default(TRUE)
# merge_requests_events :boolean default(TRUE)
# tag_push_events :boolean default(TRUE)
# note_events :boolean default(TRUE), not null
# build_events :boolean default(FALSE), not null
#
class JiraService < IssueTrackerService class JiraService < IssueTrackerService
include Gitlab::Routing.url_helpers include Gitlab::Routing.url_helpers
...@@ -30,6 +9,10 @@ class JiraService < IssueTrackerService ...@@ -30,6 +9,10 @@ class JiraService < IssueTrackerService
before_update :reset_password before_update :reset_password
def supported_events
%w(commit merge_request)
end
# {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1 # {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1
def reference_pattern def reference_pattern
@reference_pattern ||= %r{(?<issue>\b([A-Z][A-Z0-9_]+-)\d+)} @reference_pattern ||= %r{(?<issue>\b([A-Z][A-Z0-9_]+-)\d+)}
...@@ -70,7 +53,7 @@ class JiraService < IssueTrackerService ...@@ -70,7 +53,7 @@ class JiraService < IssueTrackerService
end end
def jira_project def jira_project
@jira_project ||= client.Project.find(project_key) @jira_project ||= jira_request { client.Project.find(project_key) }
end end
def help def help
...@@ -128,14 +111,25 @@ class JiraService < IssueTrackerService ...@@ -128,14 +111,25 @@ class JiraService < IssueTrackerService
# we just want to test settings # we just want to test settings
test_settings test_settings
else 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
end end
def create_cross_reference_note(mentioned, noteable, author) 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 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 = if noteable.is_a?(Commit)
noteable.id noteable.id
else else
...@@ -160,7 +154,7 @@ class JiraService < IssueTrackerService ...@@ -160,7 +154,7 @@ class JiraService < IssueTrackerService
} }
} }
add_comment(data, issue_key) add_comment(data, jira_issue)
end end
# reason why service cannot be tested # reason why service cannot be tested
...@@ -181,16 +175,22 @@ class JiraService < IssueTrackerService ...@@ -181,16 +175,22 @@ class JiraService < IssueTrackerService
def test_settings def test_settings
return unless url.present? return unless url.present?
# Test settings by getting the project # Test settings by getting the project
jira_project jira_request { jira_project.present? }
rescue Errno::ECONNREFUSED, JIRA::HTTPError => e
Rails.logger.info "#{self.class.name} ERROR: #{e.message}. API URL: #{url}."
false
end end
private 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) def close_issue(entity, issue)
return if issue.nil? || issue.resolution.present? || !jira_issue_transition_id.present?
commit_id = if entity.is_a?(Commit) commit_id = if entity.is_a?(Commit)
entity.id entity.id
elsif entity.is_a?(MergeRequest) elsif entity.is_a?(MergeRequest)
...@@ -200,55 +200,85 @@ class JiraService < IssueTrackerService ...@@ -200,55 +200,85 @@ class JiraService < IssueTrackerService
commit_url = build_entity_url(:commit, commit_id) commit_url = build_entity_url(:commit, commit_id)
# Depending on the JIRA project's workflow, a comment during transition # 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 # may or may not be allowed. Refresh the issue after transition and check
# comment always works. # if it is closed, so we don't have one comment for every commit.
transition_issue(issue) issue = jira_request { client.Issue.find(issue.key) } if transition_issue(issue)
add_issue_solved_comment(issue, commit_id, commit_url) add_issue_solved_comment(issue, commit_id, commit_url) if issue.resolution
end end
def transition_issue(issue) def transition_issue(issue)
issue = client.Issue.find(issue.iid)
issue.transitions.build.save(transition: { id: jira_issue_transition_id }) issue.transitions.build.save(transition: { id: jira_issue_transition_id })
end end
def add_issue_solved_comment(issue, commit_id, commit_url) def add_issue_solved_comment(issue, commit_id, commit_url)
comment = "Issue solved with [#{commit_id}|#{commit_url}]." link_title = "GitLab: Solved by commit #{commit_id}."
send_message(issue.iid, comment) 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 end
def add_comment(data, issue_key) def add_comment(data, issue)
user_name = data[:user][:name] user_name = data[:user][:name]
user_url = data[:user][:url] user_url = data[:user][:url]
entity_name = data[:entity][:name] entity_name = data[:entity][:name]
entity_url = data[:entity][:url] entity_url = data[:entity][:url]
entity_title = data[:entity][:title] entity_title = data[:entity][:title]
project_name = data[:project][:name] 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) unless comment_exists?(issue, message)
send_message(issue_key, message) send_message(issue, message, link_props)
end end
end end
def comment_exists?(issue_key, message) def comment_exists?(issue, message)
comments = client.Issue.find(issue_key).comments comments = jira_request { issue.comments }
comments.map { |comment| comment.body.include?(message) }.any?
comments.present? && comments.any? { |comment| comment.body.include?(message) }
end end
def send_message(issue_key, message) def send_message(issue, message, remote_link_props)
return unless url.present? 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) Rails.logger.info(result_message)
result_message = "#{self.class.name} SUCCESS: Successfully posted to #{url}." result_message
end end
end
Rails.logger.info(result_message) # Build remote link on JIRA properties
result_message # Icons here must be available on WEB so JIRA can read the URL
rescue URI::InvalidURIError, Errno::ECONNREFUSED, JIRA::HTTPError => e # We are using a open word graphics icon which have LGPL license
Rails.logger.info "#{self.class.name} Send message ERROR: #{url} - #{e.message}" def build_remote_link_props(url:, title:, resolved: false)
status = {
resolved: resolved
}
if resolved
status[:icon] = {
title: 'Closed',
url16x16: 'http://www.openwebgraphics.com/resources/data/1768/16x16_apply.png'
}
end
{
GlobalID: 'GitLab',
object: {
url: url,
title: title,
status: status,
icon: { title: 'GitLab', url16x16: 'https://gitlab.com/favicon.ico' }
}
}
end end
def resource_url(resource) def resource_url(resource)
...@@ -266,4 +296,13 @@ class JiraService < IssueTrackerService ...@@ -266,4 +296,13 @@ class JiraService < IssueTrackerService
host: Settings.gitlab.base_url host: Settings.gitlab.base_url
) )
end 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 end
class MattermostSlashCommandsService < ChatService
include TriggersHelper
prop_accessor :token
def can_test?
false
end
def title
'Mattermost Command'
end
def description
"Perform common operations on GitLab in Mattermost"
end
def to_param
'mattermost_slash_commands'
end
def help
"This service allows you to use slash commands with your Mattermost installation.<br/>
To setup this Service you need to create a new <b>Slash commands</b> in your Mattermost integration panel.<br/>
<br/>
Create integration with URL #{service_trigger_url(self)} and enter the token below."
end
def fields
[
{ type: 'text', name: 'token', placeholder: '' }
]
end
def trigger(params)
return nil unless valid_token?(params[:token])
user = find_chat_user(params)
unless user
url = authorize_chat_name_url(params)
return Mattermost::Presenter.authorize_chat_name(url)
end
Gitlab::ChatCommands::Command.new(project, user, params).execute
end
private
def find_chat_user(params)
ChatNames::FindUserService.new(self, params).execute
end
def authorize_chat_name_url(params)
ChatNames::AuthorizeUserService.new(self, params).execute
end
end
class SlackService class SlackService
class PipelineMessage < BaseMessage 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 :user_name, :duration, :pipeline_id
def initialize(data) def initialize(data)
pipeline_attributes = data[:object_attributes] pipeline_attributes = data[:object_attributes]
@sha = pipeline_attributes[:sha]
@ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch' @ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch'
@ref = pipeline_attributes[:ref] @ref = pipeline_attributes[:ref]
@status = pipeline_attributes[:status] @status = pipeline_attributes[:status]
...@@ -14,7 +13,7 @@ class SlackService ...@@ -14,7 +13,7 @@ class SlackService
@project_name = data[:project][:path_with_namespace] @project_name = data[:project][:path_with_namespace]
@project_url = data[:project][:web_url] @project_url = data[:project][:web_url]
@user_name = data[:commit] && data[:commit][:author_name] @user_name = data[:user] && data[:user][:name]
end end
def pretext def pretext
...@@ -73,7 +72,7 @@ class SlackService ...@@ -73,7 +72,7 @@ class SlackService
end end
def pipeline_link def pipeline_link
"[#{Commit.truncate_sha(sha)}](#{pipeline_url})" "[##{pipeline_id}](#{pipeline_url})"
end end
end end
end end
...@@ -176,11 +176,18 @@ class Repository ...@@ -176,11 +176,18 @@ class Repository
options = { message: message, tagger: user_to_committer(user) } if message 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 end
find_tag(tag_name) tag
rescue GitHooksService::PreReceiveError
rugged.tags.delete(tag_name)
raise
end end
def rm_branch(user, branch_name) def rm_branch(user, branch_name)
......
...@@ -8,6 +8,7 @@ class Service < ActiveRecord::Base ...@@ -8,6 +8,7 @@ class Service < ActiveRecord::Base
default_value_for :push_events, true default_value_for :push_events, true
default_value_for :issues_events, true default_value_for :issues_events, true
default_value_for :confidential_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 :merge_requests_events, true
default_value_for :tag_push_events, true default_value_for :tag_push_events, true
default_value_for :note_events, true default_value_for :note_events, true
...@@ -202,7 +203,6 @@ class Service < ActiveRecord::Base ...@@ -202,7 +203,6 @@ class Service < ActiveRecord::Base
bamboo bamboo
buildkite buildkite
builds_email builds_email
pipelines_email
bugzilla bugzilla
campfire campfire
custom_issue_tracker custom_issue_tracker
...@@ -214,6 +214,8 @@ class Service < ActiveRecord::Base ...@@ -214,6 +214,8 @@ class Service < ActiveRecord::Base
hipchat hipchat
irker irker
jira jira
mattermost_slash_commands
pipelines_email
pivotaltracker pivotaltracker
pushover pushover
redmine redmine
......
This diff is collapsed.
class AnalyticsBuildEntity < Grape::Entity
include RequestAwareEntity
include EntityDateHelper
expose :name
expose :id
expose :ref, as: :branch
expose :short_sha
expose :author, using: UserEntity
expose :started_at, as: :date do |build|
interval_in_words(build[:started_at])
end
expose :duration, as: :total_time do |build|
distance_of_time_as_hash(build[:duration].to_f)
end
expose :branch do
expose :ref, as: :name
expose :url do |build|
url_to(:namespace_project_tree, build, build.ref)
end
end
expose :url do |build|
url_to(:namespace_project_build, build)
end
expose :commit_url do |build|
url_to(:namespace_project_commit, build, build.sha)
end
private
def url_to(route, build, id = nil)
public_send("#{route}_url", build.project.namespace, build.project, id || build)
end
end
class AnalyticsBuildSerializer < BaseSerializer
entity AnalyticsBuildEntity
end
class AnalyticsCommitEntity < CommitEntity
include EntityDateHelper
expose :short_id, as: :short_sha
expose :total_time do |commit|
distance_of_time_as_hash(request.total_time.to_f)
end
unexpose :author_name
unexpose :author_email
unexpose :message
end
class AnalyticsCommitSerializer < BaseSerializer
entity AnalyticsCommitEntity
end
class AnalyticsGenericSerializer < BaseSerializer
def represent(resource, opts = {})
resource.symbolize_keys!
super(resource, opts)
end
end
class AnalyticsIssueEntity < Grape::Entity
include RequestAwareEntity
include EntityDateHelper
expose :title
expose :author, using: UserEntity
expose :iid do |object|
object[:iid].to_s
end
expose :total_time do |object|
distance_of_time_as_hash(object[:total_time].to_f)
end
expose(:created_at) do |object|
interval_in_words(object[:created_at])
end
expose :url do |object|
url_to(:namespace_project_issue, id: object[:iid].to_s)
end
private
def url_to(route, id)
public_send("#{route}_url", request.project.namespace, request.project, id)
end
end
class AnalyticsIssueSerializer < AnalyticsGenericSerializer
entity AnalyticsIssueEntity
end
class AnalyticsMergeRequestEntity < AnalyticsIssueEntity
expose :state
expose :url do |object|
url_to(:namespace_project_merge_request, id: object[:iid].to_s)
end
end
class AnalyticsMergeRequestSerializer < AnalyticsGenericSerializer
entity AnalyticsMergeRequestEntity
end
...@@ -4,21 +4,21 @@ class BuildEntity < Grape::Entity ...@@ -4,21 +4,21 @@ class BuildEntity < Grape::Entity
expose :id expose :id
expose :name expose :name
expose :build_url do |build| expose :build_path do |build|
url_to(:namespace_project_build, build) path_to(:namespace_project_build, build)
end end
expose :retry_url do |build| expose :retry_path do |build|
url_to(:retry_namespace_project_build, build) path_to(:retry_namespace_project_build, build)
end end
expose :play_url, if: ->(build, _) { build.manual? } do |build| expose :play_path, if: ->(build, _) { build.manual? } do |build|
url_to(:play_namespace_project_build, build) path_to(:play_namespace_project_build, build)
end end
private private
def url_to(route, build) def path_to(route, build)
send("#{route}_url", build.project.namespace, build.project, build) send("#{route}_path", build.project.namespace, build.project, build)
end end
end end
...@@ -9,4 +9,11 @@ class CommitEntity < API::Entities::RepoCommit ...@@ -9,4 +9,11 @@ class CommitEntity < API::Entities::RepoCommit
request.project, request.project,
id: commit.id) id: commit.id)
end end
expose :commit_path do |commit|
namespace_project_tree_path(
request.project.namespace,
request.project,
id: commit.id)
end
end end
...@@ -10,8 +10,8 @@ class DeploymentEntity < Grape::Entity ...@@ -10,8 +10,8 @@ class DeploymentEntity < Grape::Entity
deployment.ref deployment.ref
end end
expose :ref_url do |deployment| expose :ref_path do |deployment|
namespace_project_tree_url( namespace_project_tree_path(
deployment.project.namespace, deployment.project.namespace,
deployment.project, deployment.project,
id: deployment.ref) id: deployment.ref)
......
module EntityDateHelper
include ActionView::Helpers::DateHelper
def interval_in_words(diff)
"#{distance_of_time_in_words(diff.to_f)} ago"
end
# Converts seconds into a hash such as:
# { days: 1, hours: 3, mins: 42, seconds: 40 }
#
# It returns 0 seconds for zero or negative numbers
# It rounds to nearest time unit and does not return zero
# i.e { min: 1 } instead of { mins: 1, seconds: 0 }
def distance_of_time_as_hash(diff)
diff = diff.abs.floor
return { seconds: 0 } if diff == 0
mins = (diff / 60).floor
seconds = diff % 60
hours = (mins / 60).floor
mins = mins % 60
days = (hours / 24).floor
hours = hours % 24
duration_hash = {}
duration_hash[:days] = days if days > 0
duration_hash[:hours] = hours if hours > 0
duration_hash[:mins] = mins if mins > 0
duration_hash[:seconds] = seconds if seconds > 0
duration_hash
end
end
...@@ -9,8 +9,15 @@ class EnvironmentEntity < Grape::Entity ...@@ -9,8 +9,15 @@ class EnvironmentEntity < Grape::Entity
expose :last_deployment, using: DeploymentEntity expose :last_deployment, using: DeploymentEntity
expose :stoppable? expose :stoppable?
expose :environment_url do |environment| expose :environment_path do |environment|
namespace_project_environment_url( 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.namespace,
environment.project, environment.project,
environment) environment)
......
class IssuableEntity < Grape::Entity
expose :id
expose :iid
expose :assignee_id
expose :author_id
expose :description
expose :lock_version
expose :milestone_id
expose :position
expose :state
expose :title
expose :updated_by_id
expose :created_at
expose :updated_at
expose :deleted_at
end
class IssueEntity < IssuableEntity
expose :branch_name
expose :confidential
expose :due_date
expose :moved_to_id
expose :project_id
expose :milestone, using: API::Entities::Milestone
expose :labels, using: LabelEntity
end
class IssueSerializer < BaseSerializer
entity IssueEntity
end
class LabelEntity < Grape::Entity
expose :id
expose :title
expose :color
expose :description
expose :group_id
expose :project_id
expose :template
expose :created_at
expose :updated_at
end
class MergeRequestEntity < IssuableEntity
expose :in_progress_merge_commit_sha
expose :locked_at
expose :merge_commit_sha
expose :merge_error
expose :merge_params
expose :merge_status
expose :merge_user_id
expose :merge_when_build_succeeds
expose :source_branch
expose :source_project_id
expose :target_branch
expose :target_project_id
end
class MergeRequestSerializer < BaseSerializer
entity MergeRequestEntity
end
...@@ -6,12 +6,10 @@ class DestroyGroupService ...@@ -6,12 +6,10 @@ class DestroyGroupService
end end
def async_execute def async_execute
group.transaction do # Soft delete via paranoia gem
# Soft delete via paranoia gem group.destroy
group.destroy job_id = GroupDestroyWorker.perform_async(group.id, current_user.id)
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}")
Rails.logger.info("User #{current_user.id} scheduled a deletion of group ID #{group.id} with job ID #{job_id}")
end
end end
def execute def execute
......
module MergeRequests module MergeRequests
class AddTodoWhenBuildFailsService < MergeRequests::BaseService class AddTodoWhenBuildFailsService < MergeRequests::BaseService
# Adds a todo to the parent merge_request when a CI build fails # Adds a todo to the parent merge_request when a CI build fails
#
def execute(commit_status) def execute(commit_status)
return if commit_status.allow_failure?
commit_status_merge_requests(commit_status) do |merge_request| commit_status_merge_requests(commit_status) do |merge_request|
todo_service.merge_request_build_failed(merge_request) todo_service.merge_request_build_failed(merge_request)
end end
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) def close(commit_status)
commit_status_merge_requests(commit_status) do |merge_request| commit_status_merge_requests(commit_status) do |merge_request|
todo_service.merge_request_build_retried(merge_request) todo_service.merge_request_build_retried(merge_request)
......
...@@ -48,11 +48,11 @@ module MergeRequests ...@@ -48,11 +48,11 @@ module MergeRequests
end end
# See if source and target branches exist # 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" messages << "Source branch \"#{merge_request.source_branch}\" does not exist"
end 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" messages << "Target branch \"#{merge_request.target_branch}\" does not exist"
end end
......
...@@ -35,7 +35,7 @@ module Notes ...@@ -35,7 +35,7 @@ module Notes
todo_service.new_note(note, current_user) todo_service.new_note(note, current_user)
end end
if command_params && command_params.any? if command_params.present?
slash_commands_service.execute(command_params, note) slash_commands_service.execute(command_params, note)
# We must add the error after we call #save because errors are reset # We must add the error after we call #save because errors are reset
......
...@@ -106,6 +106,8 @@ module Projects ...@@ -106,6 +106,8 @@ module Projects
unless @project.group || @project.gitlab_project_import? unless @project.group || @project.gitlab_project_import?
@project.team << [current_user, :master, current_user] @project.team << [current_user, :master, current_user]
end end
@project.group.refresh_members_authorized_projects if @project.group
end end
def skip_wiki? def skip_wiki?
......
class UserProjectAccessChangedService
def initialize(user_ids)
@user_ids = Array.wrap(user_ids)
end
def execute
AuthorizedProjectsWorker.bulk_perform_async(@user_ids.map { |id| [id] })
end
end
...@@ -14,5 +14,5 @@ ...@@ -14,5 +14,5 @@
.row-content-block.second-block .row-content-block.second-block
#{(@scope || 'all').capitalize} builds #{(@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 = render "projects/builds/table", builds: @builds, admin: true
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
.col-sm-10 .col-sm-10
= render 'shared/choose_group_avatar_button', f: f = 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 .form-group
.col-sm-offset-2.col-sm-10 .col-sm-offset-2.col-sm-10
......
- page_title "Edit", @group.name, "Groups" - page_title "Edit", @group.name, "Groups"
%h3.page-title Edit group: #{@group.name} %h3.page-title Edit group: #{@group.name}
%hr %hr
= render 'form' = render 'form', visibility_level: @group.visibility_level
- page_title "New Group" - page_title "New Group"
%h3.page-title New group %h3.page-title New group
%hr %hr
= render 'form' = render 'form', visibility_level: default_group_visibility
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment