Commit 6f59e713 authored by Simon Knox's avatar Simon Knox

Merge branch 'master' of gitlab.com:gitlab-org/gitlab-ce into dispatcher-projects-efi

parents 917080af 74f2f9b3
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
*.swp *.swp
*.mo *.mo
*.edit.po *.edit.po
*.rej
.DS_Store .DS_Store
.bundle .bundle
.chef .chef
......
...@@ -61,6 +61,9 @@ stages: ...@@ -61,6 +61,9 @@ stages:
.use-pg: &use-pg .use-pg: &use-pg
services: services:
# As of Jan 2018, we don't have a strong reason to upgrade to 9.6 for CI yet,
# so using the least common denominator ensures backwards compatibility
# (as many users are still using 9.2).
- postgres:9.2 - postgres:9.2
- redis:alpine - redis:alpine
......
...@@ -171,7 +171,7 @@ Assigning a team label makes sure issues get the attention of the appropriate ...@@ -171,7 +171,7 @@ Assigning a team label makes sure issues get the attention of the appropriate
people. people.
The current team labels are ~Build, ~"CI/CD", ~Discussion, ~Documentation, ~Edge, The current team labels are ~Build, ~"CI/CD", ~Discussion, ~Documentation, ~Edge,
~Geo, ~Gitaly, ~Platform, ~Prometheus, ~Release, and ~"UX". ~Geo, ~Gitaly, ~Platform, ~Monitoring, ~Release, and ~"UX".
The descriptions on the [labels page][labels-page] explain what falls under the The descriptions on the [labels page][labels-page] explain what falls under the
responsibility of each team. responsibility of each team.
......
...@@ -340,6 +340,8 @@ GEM ...@@ -340,6 +340,8 @@ GEM
representable (~> 3.0) representable (~> 3.0)
retriable (>= 2.0, < 4.0) retriable (>= 2.0, < 4.0)
google-protobuf (3.4.1.1) google-protobuf (3.4.1.1)
googleapis-common-protos-types (1.0.1)
google-protobuf (~> 3.0)
googleauth (0.5.3) googleauth (0.5.3)
faraday (~> 0.12) faraday (~> 0.12)
jwt (~> 1.4) jwt (~> 1.4)
...@@ -366,9 +368,10 @@ GEM ...@@ -366,9 +368,10 @@ GEM
rake rake
grape_logging (1.7.0) grape_logging (1.7.0)
grape grape
grpc (1.4.5) grpc (1.8.3)
google-protobuf (~> 3.1) google-protobuf (~> 3.1)
googleauth (~> 0.5.1) googleapis-common-protos-types (~> 1.0.0)
googleauth (>= 0.5.1, < 0.7)
haml (4.0.7) haml (4.0.7)
tilt tilt
haml_lint (0.26.0) haml_lint (0.26.0)
......
...@@ -81,8 +81,7 @@ ...@@ -81,8 +81,7 @@
{ {
gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ce/user/project/integrations/prometheus.html" gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ce/user/project/integrations/prometheus.html"
target="_blank" rel="noopener noreferrer"> target="_blank" rel="noopener noreferrer">
${_.escape(s__('ClusterIntegration|Gitlab Integration'))} ${_.escape(s__('ClusterIntegration|GitLab Integration'))}</a>`,
</a>`,
}, },
false, false,
); );
......
/* global Flash */
import axios from './lib/utils/axios_utils';
import { n__, s__ } from './locale';
export function getHeaderText(childElementCount, mergeRequestCount) {
if (childElementCount === 0) {
return `${mergeRequestCount} ${n__('merge request', 'merge requests', mergeRequestCount)}`;
}
return ',';
}
export function createHeader(childElementCount, mergeRequestCount) {
const headerText = getHeaderText(childElementCount, mergeRequestCount);
return $('<span />', {
class: 'append-right-5',
text: headerText,
});
}
export function createLink(mergeRequest) {
return $('<a />', {
class: 'append-right-5',
href: mergeRequest.path,
text: `!${mergeRequest.iid}`,
});
}
export function createTitle(mergeRequest) {
return $('<span />', {
text: mergeRequest.title,
});
}
export function createItem(mergeRequest) {
const $item = $('<span />');
const $link = createLink(mergeRequest);
const $title = createTitle(mergeRequest);
$item.append($link);
$item.append($title);
return $item;
}
export function createContent(mergeRequests) {
const $content = $('<span />');
if (mergeRequests.length === 0) {
$content.text(s__('Commits|No related merge requests found'));
} else {
mergeRequests.forEach((mergeRequest) => {
const $header = createHeader($content.children().length, mergeRequests.length);
const $item = createItem(mergeRequest);
$content.append($header);
$content.append($item);
});
}
return $content;
}
export function fetchCommitMergeRequests() {
const $container = $('.merge-requests');
axios.get($container.data('projectCommitPath'))
.then((response) => {
const $content = createContent(response.data);
$container.html($content);
})
.catch(() => Flash(s__('Commits|An error occurred while fetching merge requests data.')));
}
import _ from 'underscore'; import _ from 'underscore';
export default class ProtectedTagDropdown { export default class CreateItemDropdown {
/** /**
* @param {Object} options containing * @param {Object} options containing
* `$dropdown` target element * `$dropdown` target element
...@@ -8,11 +8,14 @@ export default class ProtectedTagDropdown { ...@@ -8,11 +8,14 @@ export default class ProtectedTagDropdown {
* $dropdown must be an element created using `dropdown_tag()` rails helper * $dropdown must be an element created using `dropdown_tag()` rails helper
*/ */
constructor(options) { constructor(options) {
this.onSelect = options.onSelect; this.defaultToggleLabel = options.defaultToggleLabel;
this.fieldName = options.fieldName;
this.onSelect = options.onSelect || (() => {});
this.getDataOption = options.getData;
this.$dropdown = options.$dropdown; this.$dropdown = options.$dropdown;
this.$dropdownContainer = this.$dropdown.parent(); this.$dropdownContainer = this.$dropdown.parent();
this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer'); this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer');
this.$protectedTag = this.$dropdownContainer.find('.js-create-new-protected-tag'); this.$createButton = this.$dropdownContainer.find('.js-dropdown-create-new-item');
this.buildDropdown(); this.buildDropdown();
this.bindEvents(); this.bindEvents();
...@@ -23,7 +26,7 @@ export default class ProtectedTagDropdown { ...@@ -23,7 +26,7 @@ export default class ProtectedTagDropdown {
buildDropdown() { buildDropdown() {
this.$dropdown.glDropdown({ this.$dropdown.glDropdown({
data: this.getProtectedTags.bind(this), data: this.getData.bind(this),
filterable: true, filterable: true,
remote: false, remote: false,
search: { search: {
...@@ -31,14 +34,14 @@ export default class ProtectedTagDropdown { ...@@ -31,14 +34,14 @@ export default class ProtectedTagDropdown {
}, },
selectable: true, selectable: true,
toggleLabel(selected) { toggleLabel(selected) {
return (selected && 'id' in selected) ? selected.title : 'Protected Tag'; return (selected && 'id' in selected) ? selected.title : this.defaultToggleLabel;
}, },
fieldName: 'protected_tag[name]', fieldName: this.fieldName,
text(protectedTag) { text(item) {
return _.escape(protectedTag.title); return _.escape(item.title);
}, },
id(protectedTag) { id(item) {
return _.escape(protectedTag.id); return _.escape(item.id);
}, },
onFilter: this.toggleCreateNewButton.bind(this), onFilter: this.toggleCreateNewButton.bind(this),
clicked: (options) => { clicked: (options) => {
...@@ -49,37 +52,37 @@ export default class ProtectedTagDropdown { ...@@ -49,37 +52,37 @@ export default class ProtectedTagDropdown {
} }
bindEvents() { bindEvents() {
this.$protectedTag.on('click', this.onClickCreateWildcard.bind(this)); this.$createButton.on('click', this.onClickCreateWildcard.bind(this));
} }
onClickCreateWildcard(e) { onClickCreateWildcard(e) {
e.preventDefault();
// Refresh the dropdown's data, which ends up calling `getData`
this.$dropdown.data('glDropdown').remote.execute(); this.$dropdown.data('glDropdown').remote.execute();
this.$dropdown.data('glDropdown').selectRowAtIndex(); this.$dropdown.data('glDropdown').selectRowAtIndex();
e.preventDefault();
} }
getProtectedTags(term, callback) { getData(term, callback) {
if (this.selectedTag) { this.getDataOption(term, (data = []) => {
callback(gon.open_tags.concat(this.selectedTag)); callback(data.concat(this.selectedItem || []));
} else { });
callback(gon.open_tags);
}
} }
toggleCreateNewButton(tagName) { toggleCreateNewButton(item) {
if (tagName) { if (item) {
this.selectedTag = { this.selectedItem = {
title: tagName, title: item,
id: tagName, id: item,
text: tagName, text: item,
}; };
this.$dropdownContainer this.$dropdownContainer
.find('.js-create-new-protected-tag code') .find('.js-dropdown-create-new-item code')
.text(tagName); .text(item);
} }
this.toggleFooter(!tagName); this.toggleFooter(!item);
} }
toggleFooter(toggleState) { toggleFooter(toggleState) {
......
...@@ -27,12 +27,9 @@ import Flash from './flash'; ...@@ -27,12 +27,9 @@ import Flash from './flash';
import CommitsList from './commits'; import CommitsList from './commits';
import BindInOut from './behaviors/bind_in_out'; import BindInOut from './behaviors/bind_in_out';
import SecretValues from './behaviors/secret_values'; import SecretValues from './behaviors/secret_values';
import DeleteModal from './branches/branches_delete_modal';
import Group from './group'; import Group from './group';
import ProjectsList from './projects_list'; import ProjectsList from './projects_list';
import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater';
import BlobForkSuggestion from './blob/blob_fork_suggestion';
import UserCallout from './user_callout'; import UserCallout from './user_callout';
import ShortcutsWiki from './shortcuts_wiki'; import ShortcutsWiki from './shortcuts_wiki';
import BlobViewer from './blob/viewer/index'; import BlobViewer from './blob/viewer/index';
...@@ -40,7 +37,6 @@ import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; ...@@ -40,7 +37,6 @@ import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
import UsersSelect from './users_select'; import UsersSelect from './users_select';
import RefSelectDropdown from './ref_select_dropdown'; import RefSelectDropdown from './ref_select_dropdown';
import GfmAutoComplete from './gfm_auto_complete'; import GfmAutoComplete from './gfm_auto_complete';
import ShortcutsBlob from './shortcuts_blob';
import Star from './star'; import Star from './star';
import TreeView from './tree'; import TreeView from './tree';
import Wikis from './wikis'; import Wikis from './wikis';
...@@ -54,7 +50,6 @@ import GpgBadges from './gpg_badges'; ...@@ -54,7 +50,6 @@ import GpgBadges from './gpg_badges';
import initChangesDropdown from './init_changes_dropdown'; import initChangesDropdown from './init_changes_dropdown';
import NewGroupChild from './groups/new_group_child'; import NewGroupChild from './groups/new_group_child';
import { ajaxGet, convertPermissionToBoolean } from './lib/utils/common_utils'; import { ajaxGet, convertPermissionToBoolean } from './lib/utils/common_utils';
import AjaxLoadingSpinner from './ajax_loading_spinner';
import GlFieldErrors from './gl_field_errors'; import GlFieldErrors from './gl_field_errors';
import GLForm from './gl_form'; import GLForm from './gl_form';
import Shortcuts from './shortcuts'; import Shortcuts from './shortcuts';
...@@ -68,6 +63,7 @@ import Diff from './diff'; ...@@ -68,6 +63,7 @@ import Diff from './diff';
import ProjectLabelSubscription from './project_label_subscription'; import ProjectLabelSubscription from './project_label_subscription';
import SearchAutocomplete from './search_autocomplete'; import SearchAutocomplete from './search_autocomplete';
import Activities from './activities'; import Activities from './activities';
import { fetchCommitMergeRequests } from './commit_merge_requests';
(function() { (function() {
var Dispatcher; var Dispatcher;
...@@ -80,7 +76,7 @@ import Activities from './activities'; ...@@ -80,7 +76,7 @@ import Activities from './activities';
} }
Dispatcher.prototype.initPageScripts = function() { Dispatcher.prototype.initPageScripts = function() {
var path, shortcut_handler, fileBlobPermalinkUrlElement, fileBlobPermalinkUrl; var path, shortcut_handler;
const page = $('body').attr('data-page'); const page = $('body').attr('data-page');
if (!page) { if (!page) {
return false; return false;
...@@ -105,33 +101,6 @@ import Activities from './activities'; ...@@ -105,33 +101,6 @@ import Activities from './activities';
}); });
}); });
function initBlob() {
new LineHighlighter();
new BlobLinePermalinkUpdater(
document.querySelector('#blob-content-holder'),
'.diff-line-num[data-line-number]',
document.querySelectorAll('.js-data-file-blob-permalink-url, .js-blob-blame-link'),
);
shortcut_handler = new ShortcutsNavigation();
fileBlobPermalinkUrlElement = document.querySelector('.js-data-file-blob-permalink-url');
fileBlobPermalinkUrl = fileBlobPermalinkUrlElement && fileBlobPermalinkUrlElement.getAttribute('href');
new ShortcutsBlob({
skipResetBindings: true,
fileBlobPermalinkUrl,
});
new BlobForkSuggestion({
openButtons: document.querySelectorAll('.js-edit-blob-link-fork-toggler'),
forkButtons: document.querySelectorAll('.js-fork-suggestion-button'),
cancelButtons: document.querySelectorAll('.js-cancel-fork-suggestion-button'),
suggestionSections: document.querySelectorAll('.js-file-fork-suggestion-section'),
actionTextPieces: document.querySelectorAll('.js-file-fork-suggestion-section-action'),
})
.init();
}
const filteredSearchEnabled = gl.FilteredSearchManager && document.querySelector('.filtered-search'); const filteredSearchEnabled = gl.FilteredSearchManager && document.querySelector('.filtered-search');
switch (page) { switch (page) {
...@@ -247,8 +216,9 @@ import Activities from './activities'; ...@@ -247,8 +216,9 @@ import Activities from './activities';
new NewBranchForm($('.js-create-branch-form'), JSON.parse(document.getElementById('availableRefs').innerHTML)); new NewBranchForm($('.js-create-branch-form'), JSON.parse(document.getElementById('availableRefs').innerHTML));
break; break;
case 'projects:branches:index': case 'projects:branches:index':
AjaxLoadingSpinner.init(); import('./pages/projects/branches/index')
new DeleteModal(); .then(callDefault)
.catch(fail);
break; break;
case 'projects:issues:new': case 'projects:issues:new':
import('./pages/projects/issues/new') import('./pages/projects/issues/new')
...@@ -344,6 +314,7 @@ import Activities from './activities'; ...@@ -344,6 +314,7 @@ import Activities from './activities';
const stickyBarPaddingTop = 16; const stickyBarPaddingTop = 16;
initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight - stickyBarPaddingTop); initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight - stickyBarPaddingTop);
$('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath); $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath);
fetchCommitMergeRequests();
break; break;
case 'projects:commit:pipelines': case 'projects:commit:pipelines':
new MiniPipelineGraph({ new MiniPipelineGraph({
...@@ -460,20 +431,37 @@ import Activities from './activities'; ...@@ -460,20 +431,37 @@ import Activities from './activities';
shortcut_handler = true; shortcut_handler = true;
break; break;
case 'projects:blob:show': case 'projects:blob:show':
new BlobViewer(); import('./pages/projects/blob/show')
initBlob(); .then(callDefault)
.catch(fail);
shortcut_handler = true;
break; break;
case 'projects:blame:show': case 'projects:blame:show':
initBlob(); import('./pages/projects/blame/show')
.then(callDefault)
.catch(fail);
shortcut_handler = true;
break; break;
case 'groups:labels:new': case 'groups:labels:new':
case 'groups:labels:edit': case 'groups:labels:edit':
new Labels();
break;
case 'projects:labels:new': case 'projects:labels:new':
import('./pages/projects/labels/new')
.then(callDefault)
.catch(fail);
break;
case 'projects:labels:edit': case 'projects:labels:edit':
new Labels(); import('./pages/projects/labels/edit')
.then(callDefault)
.catch(fail);
break; break;
case 'groups:labels:index':
case 'projects:labels:index': case 'projects:labels:index':
import('./pages/projects/labels/index')
.then(callDefault)
.catch(fail);
break;
case 'groups:labels:index':
if ($('.prioritized-labels').length) { if ($('.prioritized-labels').length) {
new LabelManager(); new LabelManager();
} }
......
import LabelManager from './label_manager';
import GroupLabelSubscription from './group_label_subscription';
import ProjectLabelSubscription from './project_label_subscription';
export default () => {
if ($('.prioritized-labels').length) {
new LabelManager(); // eslint-disable-line no-new
}
$('.label-subscription').each((i, el) => {
const $el = $(el);
if ($el.find('.dropdown-group-label').length) {
new GroupLabelSubscription($el); // eslint-disable-line no-new
} else {
new ProjectLabelSubscription($el); // eslint-disable-line no-new
}
});
};
...@@ -30,8 +30,12 @@ ...@@ -30,8 +30,12 @@
shouldRenderContent() { shouldRenderContent() {
return !this.isLoading && Object.keys(this.job).length; return !this.isLoading && Object.keys(this.job).length;
}, },
/**
* When job has not started the key will be `false`
* When job started the key will be a string with a date.
*/
jobStarted() { jobStarted() {
return this.job.started; return !this.job.started === false;
}, },
}, },
watch: { watch: {
......
...@@ -271,7 +271,7 @@ Please check your network connection and try again.`; ...@@ -271,7 +271,7 @@ Please check your network connection and try again.`;
<div class="timeline-content timeline-content-form"> <div class="timeline-content timeline-content-form">
<form <form
ref="commentForm" ref="commentForm"
class="new-note js-quick-submit common-note-form gfm-form js-main-target-form" class="new-note common-note-form gfm-form js-main-target-form"
> >
<div class="error-alert"></div> <div class="error-alert"></div>
...@@ -301,7 +301,8 @@ js-gfm-input js-autosize markdown-area js-vue-textarea" ...@@ -301,7 +301,8 @@ js-gfm-input js-autosize markdown-area js-vue-textarea"
:disabled="isSubmitting" :disabled="isSubmitting"
placeholder="Write a comment or drag your files here..." placeholder="Write a comment or drag your files here..."
@keydown.up="editCurrentUserLastNote()" @keydown.up="editCurrentUserLastNote()"
@keydown.meta.enter="handleSave()"> @keydown.meta.enter="handleSave()"
@keydown.ctrl.enter="handleSave()">
</textarea> </textarea>
</markdown-field> </markdown-field>
<div class="note-form-actions"> <div class="note-form-actions">
......
...@@ -155,6 +155,7 @@ js-autosize markdown-area js-vue-issue-note-form js-vue-textarea" ...@@ -155,6 +155,7 @@ js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
slot="textarea" slot="textarea"
placeholder="Write a comment or drag your files here..." placeholder="Write a comment or drag your files here..."
@keydown.meta.enter="handleUpdate()" @keydown.meta.enter="handleUpdate()"
@keydown.ctrl.enter="handleUpdate()"
@keydown.up="editMyLastNote()" @keydown.up="editMyLastNote()"
@keydown.esc="cancelHandler(true)"> @keydown.esc="cancelHandler(true)">
</textarea> </textarea>
......
import initBlob from '~/pages/projects/init_blob';
export default initBlob;
import BlobViewer from '~/blob/viewer/index';
import initBlob from '~/pages/projects/init_blob';
export default () => {
new BlobViewer(); // eslint-disable-line no-new
initBlob();
};
import AjaxLoadingSpinner from '~/ajax_loading_spinner';
import DeleteModal from '~/branches/branches_delete_modal';
export default () => {
AjaxLoadingSpinner.init();
new DeleteModal(); // eslint-disable-line no-new
};
import LineHighlighter from '~/line_highlighter';
import BlobLinePermalinkUpdater from '~/blob/blob_line_permalink_updater';
import ShortcutsNavigation from '~/shortcuts_navigation';
import ShortcutsBlob from '~/shortcuts_blob';
import BlobForkSuggestion from '~/blob/blob_fork_suggestion';
export default () => {
new LineHighlighter(); // eslint-disable-line no-new
new BlobLinePermalinkUpdater( // eslint-disable-line no-new
document.querySelector('#blob-content-holder'),
'.diff-line-num[data-line-number]',
document.querySelectorAll('.js-data-file-blob-permalink-url, .js-blob-blame-link'),
);
const fileBlobPermalinkUrlElement = document.querySelector('.js-data-file-blob-permalink-url');
const fileBlobPermalinkUrl = fileBlobPermalinkUrlElement && fileBlobPermalinkUrlElement.getAttribute('href');
new ShortcutsNavigation(); // eslint-disable-line no-new
new ShortcutsBlob({ // eslint-disable-line no-new
skipResetBindings: true,
fileBlobPermalinkUrl,
});
new BlobForkSuggestion({ // eslint-disable-line no-new
openButtons: document.querySelectorAll('.js-edit-blob-link-fork-toggler'),
forkButtons: document.querySelectorAll('.js-fork-suggestion-button'),
cancelButtons: document.querySelectorAll('.js-cancel-fork-suggestion-button'),
suggestionSections: document.querySelectorAll('.js-file-fork-suggestion-section'),
actionTextPieces: document.querySelectorAll('.js-file-fork-suggestion-section-action'),
}).init();
};
import Labels from '~/labels';
export default () => new Labels();
import initLabels from '~/init_labels';
export default initLabels;
import Labels from '~/labels';
export default () => new Labels();
...@@ -19,12 +19,9 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -19,12 +19,9 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
$(navEl).on('show.bs.dropdown', (e) => { $(navEl).on('shown.bs.dropdown', () => {
const dropdownEl = $(e.currentTarget).find('.projects-dropdown-menu');
dropdownEl.one('transitionend', () => {
eventHub.$emit('dropdownOpen'); eventHub.$emit('dropdownOpen');
}); });
});
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
......
import _ from 'underscore'; import _ from 'underscore';
import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown'; import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown';
import ProtectedBranchDropdown from './protected_branch_dropdown'; import CreateItemDropdown from '../create_item_dropdown';
import AccessorUtilities from '../lib/utils/accessor'; import AccessorUtilities from '../lib/utils/accessor';
const PB_LOCAL_STORAGE_KEY = 'protected-branches-defaults'; const PB_LOCAL_STORAGE_KEY = 'protected-branches-defaults';
...@@ -35,10 +35,12 @@ export default class ProtectedBranchCreate { ...@@ -35,10 +35,12 @@ export default class ProtectedBranchCreate {
onSelect: this.onSelectCallback, onSelect: this.onSelectCallback,
}); });
// Protected branch dropdown this.createItemDropdown = new CreateItemDropdown({
this.protectedBranchDropdown = new ProtectedBranchDropdown({
$dropdown: $protectedBranchDropdown, $dropdown: $protectedBranchDropdown,
defaultToggleLabel: 'Protected Branch',
fieldName: 'protected_branch[name]',
onSelect: this.onSelectCallback, onSelect: this.onSelectCallback,
getData: ProtectedBranchCreate.getProtectedBranches,
}); });
this.loadPreviousSelection($allowedToMergeDropdown.data('glDropdown'), $allowedToPushDropdown.data('glDropdown')); this.loadPreviousSelection($allowedToMergeDropdown.data('glDropdown'), $allowedToPushDropdown.data('glDropdown'));
...@@ -60,6 +62,10 @@ export default class ProtectedBranchCreate { ...@@ -60,6 +62,10 @@ export default class ProtectedBranchCreate {
this.$form.find('input[type="submit"]').attr('disabled', completedForm); this.$form.find('input[type="submit"]').attr('disabled', completedForm);
} }
static getProtectedBranches(term, callback) {
callback(gon.open_branches);
}
loadPreviousSelection(mergeDropdown, pushDropdown) { loadPreviousSelection(mergeDropdown, pushDropdown) {
let mergeIndex = 0; let mergeIndex = 0;
let pushIndex = 0; let pushIndex = 0;
......
import _ from 'underscore';
export default class ProtectedBranchDropdown {
/**
* @param {Object} options containing
* `$dropdown` target element
* `onSelect` event callback
* $dropdown must be an element created using `dropdown_branch()` rails helper
*/
constructor(options) {
this.onSelect = options.onSelect;
this.$dropdown = options.$dropdown;
this.$dropdownContainer = this.$dropdown.parent();
this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer');
this.$protectedBranch = this.$dropdownContainer.find('.js-create-new-protected-branch');
this.buildDropdown();
this.bindEvents();
// Hide footer
this.toggleFooter(true);
}
buildDropdown() {
this.$dropdown.glDropdown({
data: this.getProtectedBranches.bind(this),
filterable: true,
remote: false,
search: {
fields: ['title'],
},
selectable: true,
toggleLabel(selected) {
return (selected && 'id' in selected) ? selected.title : 'Protected Branch';
},
fieldName: 'protected_branch[name]',
text(protectedBranch) {
return _.escape(protectedBranch.title);
},
id(protectedBranch) {
return _.escape(protectedBranch.id);
},
onFilter: this.toggleCreateNewButton.bind(this),
clicked: (options) => {
options.e.preventDefault();
this.onSelect();
},
});
}
bindEvents() {
this.$protectedBranch.on('click', this.onClickCreateWildcard.bind(this));
}
onClickCreateWildcard(e) {
e.preventDefault();
// Refresh the dropdown's data, which ends up calling `getProtectedBranches`
this.$dropdown.data('glDropdown').remote.execute();
this.$dropdown.data('glDropdown').selectRowAtIndex();
}
getProtectedBranches(term, callback) {
if (this.selectedBranch) {
callback(gon.open_branches.concat(this.selectedBranch));
} else {
callback(gon.open_branches);
}
}
toggleCreateNewButton(branchName) {
if (branchName) {
this.selectedBranch = {
title: branchName,
id: branchName,
text: branchName,
};
this.$dropdownContainer
.find('.js-create-new-protected-branch code')
.text(branchName);
}
this.toggleFooter(!branchName);
}
toggleFooter(toggleState) {
this.$dropdownFooter.toggleClass('hidden', toggleState);
}
}
import ProtectedTagAccessDropdown from './protected_tag_access_dropdown'; import ProtectedTagAccessDropdown from './protected_tag_access_dropdown';
import ProtectedTagDropdown from './protected_tag_dropdown'; import CreateItemDropdown from '../create_item_dropdown';
export default class ProtectedTagCreate { export default class ProtectedTagCreate {
constructor() { constructor() {
...@@ -24,9 +24,12 @@ export default class ProtectedTagCreate { ...@@ -24,9 +24,12 @@ export default class ProtectedTagCreate {
$allowedToCreateDropdown.data('glDropdown').selectRowAtIndex(0); $allowedToCreateDropdown.data('glDropdown').selectRowAtIndex(0);
// Protected tag dropdown // Protected tag dropdown
this.protectedTagDropdown = new ProtectedTagDropdown({ this.createItemDropdown = new CreateItemDropdown({
$dropdown: this.$form.find('.js-protected-tag-select'), $dropdown: this.$form.find('.js-protected-tag-select'),
defaultToggleLabel: 'Protected Tag',
fieldName: 'protected_tag[name]',
onSelect: this.onSelectCallback, onSelect: this.onSelectCallback,
getData: ProtectedTagCreate.getProtectedTags,
}); });
} }
...@@ -38,4 +41,8 @@ export default class ProtectedTagCreate { ...@@ -38,4 +41,8 @@ export default class ProtectedTagCreate {
this.$form.find('input[type="submit"]').attr('disabled', !($tagInput.val() && $allowedToCreateInput.length)); this.$form.find('input[type="submit"]').attr('disabled', !($tagInput.val() && $allowedToCreateInput.length));
} }
static getProtectedTags(term, callback) {
callback(gon.open_tags);
}
} }
<script> <script>
/* eslint-disable vue/require-default-prop */ import { __ } from '~/locale';
import { __ } from '../../../locale'; import icon from '~/vue_shared/components/icon.vue';
import toggleButton from '~/vue_shared/components/toggle_button.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import eventHub from '../../event_hub'; import eventHub from '../../event_hub';
import loadingButton from '../../../vue_shared/components/loading_button.vue';
const ICON_ON = 'notifications';
const ICON_OFF = 'notifications-off';
const LABEL_ON = __('Notifications on');
const LABEL_OFF = __('Notifications off');
export default { export default {
directives: {
tooltip,
},
components: { components: {
loadingButton, icon,
toggleButton,
}, },
props: { props: {
loading: { loading: {
...@@ -17,22 +27,23 @@ ...@@ -17,22 +27,23 @@
subscribed: { subscribed: {
type: Boolean, type: Boolean,
required: false, required: false,
default: null,
}, },
id: { id: {
type: Number, type: Number,
required: false, required: false,
default: null,
}, },
}, },
computed: { computed: {
buttonLabel() { showLoadingState() {
let label; return this.subscribed === null;
if (this.subscribed === false) { },
label = __('Subscribe'); notificationIcon() {
} else if (this.subscribed === true) { return this.subscribed ? ICON_ON : ICON_OFF;
label = __('Unsubscribe'); },
} notificationTooltip() {
return this.subscribed ? LABEL_ON : LABEL_OFF;
return label;
}, },
}, },
methods: { methods: {
...@@ -46,21 +57,29 @@ ...@@ -46,21 +57,29 @@
<template> <template>
<div> <div>
<div class="sidebar-collapsed-icon"> <div class="sidebar-collapsed-icon">
<i <span
class="fa fa-rss" v-tooltip
aria-hidden="true" :title="notificationTooltip"
data-container="body"
data-placement="left"
> >
</i> <icon
:name="notificationIcon"
:size="16"
aria-hidden="true"
class="sidebar-item-icon is-active"
/>
</span>
</div> </div>
<span class="issuable-header-text hide-collapsed pull-left"> <span class="issuable-header-text hide-collapsed pull-left">
{{ __('Notifications') }} {{ __('Notifications') }}
</span> </span>
<loading-button <toggle-button
ref="loadingButton" ref="toggleButton"
class="btn btn-default pull-right hide-collapsed js-issuable-subscribe-button" class="pull-right hide-collapsed js-issuable-subscribe-button"
:loading="loading" :is-loading="showLoadingState"
:label="buttonLabel" :value="subscribed"
@click="toggleSubscription" @change="toggleSubscription"
/> />
</div> </div>
</template> </template>
...@@ -23,11 +23,12 @@ ...@@ -23,11 +23,12 @@
name: { name: {
type: String, type: String,
required: false, required: false,
default: '', default: null,
}, },
value: { value: {
type: Boolean, type: Boolean,
required: true, required: false,
default: null,
}, },
disabledInput: { disabledInput: {
type: Boolean, type: Boolean,
...@@ -61,6 +62,7 @@ ...@@ -61,6 +62,7 @@
<template> <template>
<label class="toggle-wrapper"> <label class="toggle-wrapper">
<input <input
v-if="name"
type="hidden" type="hidden"
:name="name" :name="name"
:value="value" :value="value"
......
...@@ -666,6 +666,16 @@ ...@@ -666,6 +666,16 @@
} }
} }
.dropdown-create-new-item-button {
@include dropdown-link;
width: 100%;
background-color: transparent;
border: 0;
text-align: left;
text-overflow: ellipsis;
}
.dropdown-loading { .dropdown-loading {
position: absolute; position: absolute;
top: 0; top: 0;
......
...@@ -104,7 +104,10 @@ ...@@ -104,7 +104,10 @@
img { img {
height: 28px; height: 28px;
margin-right: 8px;
+ .logo-text {
margin-left: 8px;
}
} }
&.wrap { &.wrap {
......
...@@ -162,10 +162,6 @@ ...@@ -162,10 +162,6 @@
border: 0; border: 0;
} }
span {
display: inline-block;
}
.select2-container span { .select2-container span {
margin-top: 0; margin-top: 0;
} }
......
...@@ -895,17 +895,6 @@ pre.light-well { ...@@ -895,17 +895,6 @@ pre.light-well {
} }
} }
.create-new-protected-branch-button,
.create-new-protected-tag-button {
@include dropdown-link;
width: 100%;
background-color: transparent;
border: 0;
text-align: left;
text-overflow: ellipsis;
}
.protected-branches-list, .protected-branches-list,
.protected-tags-list { .protected-tags-list {
margin-bottom: 30px; margin-bottom: 30px;
......
...@@ -12,7 +12,7 @@ class Projects::CommitController < Projects::ApplicationController ...@@ -12,7 +12,7 @@ class Projects::CommitController < Projects::ApplicationController
before_action :authorize_download_code! before_action :authorize_download_code!
before_action :authorize_read_pipeline!, only: [:pipelines] before_action :authorize_read_pipeline!, only: [:pipelines]
before_action :commit before_action :commit
before_action :define_commit_vars, only: [:show, :diff_for_path, :pipelines] before_action :define_commit_vars, only: [:show, :diff_for_path, :pipelines, :merge_requests]
before_action :define_note_vars, only: [:show, :diff_for_path] before_action :define_note_vars, only: [:show, :diff_for_path]
before_action :authorize_edit_tree!, only: [:revert, :cherry_pick] before_action :authorize_edit_tree!, only: [:revert, :cherry_pick]
...@@ -52,6 +52,18 @@ class Projects::CommitController < Projects::ApplicationController ...@@ -52,6 +52,18 @@ class Projects::CommitController < Projects::ApplicationController
end end
end end
def merge_requests
@merge_requests = @commit.merge_requests.map do |mr|
{ iid: mr.iid, path: merge_request_path(mr), title: mr.title }
end
respond_to do |format|
format.json do
render json: @merge_requests.to_json
end
end
end
def branches def branches
# branch_names_contains/tag_names_contains can take a long time when there are thousands of # branch_names_contains/tag_names_contains can take a long time when there are thousands of
# branches/tags - each `git branch --contains xxx` request can consume a cpu core. # branches/tags - each `git branch --contains xxx` request can consume a cpu core.
......
...@@ -29,7 +29,7 @@ class Projects::JobsController < Projects::ApplicationController ...@@ -29,7 +29,7 @@ class Projects::JobsController < Projects::ApplicationController
:project, :project,
:tags :tags
]) ])
@builds = @builds.page(params[:page]).per(30) @builds = @builds.page(params[:page]).per(30).without_count
end end
def cancel_all def cancel_all
......
...@@ -238,6 +238,10 @@ class Commit ...@@ -238,6 +238,10 @@ class Commit
notes.includes(:author) notes.includes(:author)
end end
def merge_requests
@merge_requests ||= project.merge_requests.by_commit_sha(sha)
end
def method_missing(method, *args, &block) def method_missing(method, *args, &block)
@raw.__send__(method, *args, &block) # rubocop:disable GitlabSecurity/PublicSend @raw.__send__(method, *args, &block) # rubocop:disable GitlabSecurity/PublicSend
end end
...@@ -342,10 +346,11 @@ class Commit ...@@ -342,10 +346,11 @@ class Commit
@merged_merge_request_hash[current_user] @merged_merge_request_hash[current_user]
end end
def has_been_reverted?(current_user, noteable = self) def has_been_reverted?(current_user, notes_association = nil)
ext = all_references(current_user) ext = all_references(current_user)
notes_association ||= notes_with_associations
noteable.notes_with_associations.system.each do |note| notes_association.system.each do |note|
note.all_references(current_user, extractor: ext) note.all_references(current_user, extractor: ext)
end end
......
module ResolvableDiscussion module ResolvableDiscussion
extend ActiveSupport::Concern extend ActiveSupport::Concern
include ::Gitlab::Utils::StrongMemoize
included do included do
# A number of properties of this `Discussion`, like `first_note` and `resolvable?`, are memoized. # A number of properties of this `Discussion`, like `first_note` and `resolvable?`, are memoized.
...@@ -31,27 +32,37 @@ module ResolvableDiscussion ...@@ -31,27 +32,37 @@ module ResolvableDiscussion
end end
def resolvable? def resolvable?
@resolvable ||= potentially_resolvable? && notes.any?(&:resolvable?) strong_memoize(:resolvable) do
potentially_resolvable? && notes.any?(&:resolvable?)
end
end end
def resolved? def resolved?
@resolved ||= resolvable? && notes.none?(&:to_be_resolved?) strong_memoize(:resolved) do
resolvable? && notes.none?(&:to_be_resolved?)
end
end end
def first_note def first_note
@first_note ||= notes.first strong_memoize(:first_note) do
notes.first
end
end end
def first_note_to_resolve def first_note_to_resolve
return unless resolvable? return unless resolvable?
@first_note_to_resolve ||= notes.find(&:to_be_resolved?) # rubocop:disable Gitlab/ModuleWithInstanceVariables strong_memoize(:first_note_to_resolve) do
notes.find(&:to_be_resolved?)
end
end end
def last_resolved_note def last_resolved_note
return unless resolved? return unless resolved?
@last_resolved_note ||= resolved_notes.sort_by(&:resolved_at).last # rubocop:disable Gitlab/ModuleWithInstanceVariables strong_memoize(:last_resolved_note) do
resolved_notes.sort_by(&:resolved_at).last
end
end end
def resolved_notes def resolved_notes
...@@ -93,8 +104,8 @@ module ResolvableDiscussion ...@@ -93,8 +104,8 @@ module ResolvableDiscussion
# Set the notes array to the updated notes # Set the notes array to the updated notes
@notes = notes_relation.fresh.to_a # rubocop:disable Gitlab/ModuleWithInstanceVariables @notes = notes_relation.fresh.to_a # rubocop:disable Gitlab/ModuleWithInstanceVariables
self.class.memoized_values.each do |var| self.class.memoized_values.each do |name|
instance_variable_set(:"@#{var}", nil) clear_memoization(name)
end end
end end
end end
...@@ -140,7 +140,9 @@ class MergeRequest < ActiveRecord::Base ...@@ -140,7 +140,9 @@ class MergeRequest < ActiveRecord::Base
scope :merged, -> { with_state(:merged) } scope :merged, -> { with_state(:merged) }
scope :closed_and_merged, -> { with_states(:closed, :merged) } scope :closed_and_merged, -> { with_states(:closed, :merged) }
scope :from_source_branches, ->(branches) { where(source_branch: branches) } scope :from_source_branches, ->(branches) { where(source_branch: branches) }
scope :by_commit_sha, ->(sha) do
where('EXISTS (?)', MergeRequestDiff.select(1).where('merge_requests.latest_merge_request_diff_id = merge_request_diffs.id').by_commit_sha(sha)).reorder(nil)
end
scope :join_project, -> { joins(:target_project) } scope :join_project, -> { joins(:target_project) }
scope :references_project, -> { references(:target_project) } scope :references_project, -> { references(:target_project) }
scope :assigned, -> { where("assignee_id IS NOT NULL") } scope :assigned, -> { where("assignee_id IS NOT NULL") }
...@@ -982,7 +984,16 @@ class MergeRequest < ActiveRecord::Base ...@@ -982,7 +984,16 @@ class MergeRequest < ActiveRecord::Base
end end
def can_be_reverted?(current_user) def can_be_reverted?(current_user)
merge_commit && !merge_commit.has_been_reverted?(current_user, self) return false unless merge_commit
merged_at = metrics&.merged_at
notes_association = notes_with_associations
if merged_at
notes_association = notes_association.where('created_at > ?', merged_at)
end
!merge_commit.has_been_reverted?(current_user, notes_association)
end end
def can_be_cherry_picked? def can_be_cherry_picked?
......
...@@ -28,6 +28,9 @@ class MergeRequestDiff < ActiveRecord::Base ...@@ -28,6 +28,9 @@ class MergeRequestDiff < ActiveRecord::Base
end end
scope :viewable, -> { without_state(:empty) } scope :viewable, -> { without_state(:empty) }
scope :by_commit_sha, ->(sha) do
joins(:merge_request_diff_commits).where(merge_request_diff_commits: { sha: sha }).reorder(nil)
end
scope :recent, -> { order(id: :desc).limit(100) } scope :recent, -> { order(id: :desc).limit(100) }
......
...@@ -20,6 +20,7 @@ class Project < ActiveRecord::Base ...@@ -20,6 +20,7 @@ class Project < ActiveRecord::Base
include GroupDescendant include GroupDescendant
include Gitlab::SQL::Pattern include Gitlab::SQL::Pattern
include DeploymentPlatform include DeploymentPlatform
include ::Gitlab::Utils::StrongMemoize
extend Gitlab::ConfigHelper extend Gitlab::ConfigHelper
extend Gitlab::CurrentSettings extend Gitlab::CurrentSettings
...@@ -993,9 +994,13 @@ class Project < ActiveRecord::Base ...@@ -993,9 +994,13 @@ class Project < ActiveRecord::Base
end end
def repo_exists? def repo_exists?
@repo_exists ||= repository.exists? strong_memoize(:repo_exists) do
begin
repository.exists?
rescue rescue
@repo_exists = false false
end
end
end end
def root_ref?(branch) def root_ref?(branch)
......
...@@ -895,15 +895,18 @@ class Repository ...@@ -895,15 +895,18 @@ class Repository
branch = Gitlab::Git::Branch.find(self, branch_or_name) branch = Gitlab::Git::Branch.find(self, branch_or_name)
if branch if branch
@root_ref_sha ||= commit(root_ref).sha same_head = branch.target == root_ref_sha
same_head = branch.target == @root_ref_sha merged = ancestor?(branch.target, root_ref_sha)
merged = ancestor?(branch.target, @root_ref_sha)
!same_head && merged !same_head && merged
else else
nil nil
end end
end end
def root_ref_sha
@root_ref_sha ||= commit(root_ref).sha
end
delegate :merged_branch_names, to: :raw_repository delegate :merged_branch_names, to: :raw_repository
def merge_base(first_commit_id, second_commit_id) def merge_base(first_commit_id, second_commit_id)
......
...@@ -8,7 +8,7 @@ class Route < ActiveRecord::Base ...@@ -8,7 +8,7 @@ class Route < ActiveRecord::Base
presence: true, presence: true,
uniqueness: { case_sensitive: false } uniqueness: { case_sensitive: false }
validate :ensure_permanent_paths validate :ensure_permanent_paths, if: :path_changed?
after_create :delete_conflicting_redirects after_create :delete_conflicting_redirects
after_update :delete_conflicting_redirects, if: :path_changed? after_update :delete_conflicting_redirects, if: :path_changed?
......
...@@ -3,6 +3,34 @@ ...@@ -3,6 +3,34 @@
%div{ class: container_class } %div{ class: container_class }
.admin-dashboard.prepend-top-default .admin-dashboard.prepend-top-default
.row
.col-sm-4
.info-well.dark-well
.well-segment.well-centered
= link_to admin_projects_path do
%h3.text-center
Projects:
= number_with_delimiter(Project.cached_count)
%hr
= link_to('New project', new_project_path, class: "btn btn-new")
.col-sm-4
.info-well.dark-well
.well-segment.well-centered
= link_to admin_users_path do
%h3.text-center
Users:
= number_with_delimiter(User.count)
%hr
= link_to 'New user', new_admin_user_path, class: "btn btn-new"
.col-sm-4
.info-well.dark-well
.well-segment.well-centered
= link_to admin_groups_path do
%h3.text-center
Groups:
= number_with_delimiter(Group.count)
%hr
= link_to 'New group', new_admin_group_path, class: "btn btn-new"
.row .row
.col-md-4 .col-md-4
.info-well .info-well
...@@ -135,34 +163,6 @@ ...@@ -135,34 +163,6 @@
= Gitlab::Database.adapter_name = Gitlab::Database.adapter_name
%span.pull-right %span.pull-right
= Gitlab::Database.version = Gitlab::Database.version
.row
.col-sm-4
.info-well.dark-well
.well-segment.well-centered
= link_to admin_projects_path do
%h3.text-center
Projects:
= number_with_delimiter(Project.cached_count)
%hr
= link_to('New project', new_project_path, class: "btn btn-new")
.col-sm-4
.info-well.dark-well
.well-segment.well-centered
= link_to admin_users_path do
%h3.text-center
Users:
= number_with_delimiter(User.count)
%hr
= link_to 'New user', new_admin_user_path, class: "btn btn-new"
.col-sm-4
.info-well.dark-well
.well-segment.well-centered
= link_to admin_groups_path do
%h3.text-center
Groups:
= number_with_delimiter(Group.count)
%hr
= link_to 'New group', new_admin_group_path, class: "btn btn-new"
.row .row
.col-md-4 .col-md-4
.info-well .info-well
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
.top-area .top-area
= render 'shared/issuable/nav', type: :issues = render 'shared/issuable/nav', type: :issues
.nav-controls .nav-controls
= link_to params.merge(rss_url_options), class: 'btn has-tooltip', title: 'Subscribe' do = link_to params.merge(rss_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: 'Subscribe' do
= icon('rss') = icon('rss')
= render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", with_feature_enabled: 'issues', type: :issues = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", with_feature_enabled: 'issues', type: :issues
......
...@@ -6,8 +6,10 @@ ...@@ -6,8 +6,10 @@
%h1.title %h1.title
= link_to root_path, title: 'Dashboard', id: 'logo' do = link_to root_path, title: 'Dashboard', id: 'logo' do
= brand_header_logo = brand_header_logo
- logo_text = brand_header_logo_type
- if logo_text.present?
%span.logo-text.hidden-xs %span.logo-text.hidden-xs
= brand_header_logo_type = logo_text
- if current_user - if current_user
= render "layouts/nav/dashboard" = render "layouts/nav/dashboard"
......
...@@ -30,7 +30,7 @@ ...@@ -30,7 +30,7 @@
%li CI variables %li CI variables
%li Any encrypted tokens %li Any encrypted tokens
%p %p
Once the exported file is ready, you will receive a notification email with a download link. Once the exported file is ready, you will receive a notification email with a download link, or you can download it from this page.
- if project.export_project_path - if project.export_project_path
= link_to 'Download export', download_export_project_path(project), = link_to 'Download export', download_export_project_path(project),
rel: 'nofollow', download: '', method: :get, class: "btn btn-default" rel: 'nofollow', download: '', method: :get, class: "btn btn-default"
......
...@@ -64,6 +64,12 @@ ...@@ -64,6 +64,12 @@
.commit-info.branches .commit-info.branches
%i.fa.fa-spinner.fa-spin %i.fa.fa-spinner.fa-spin
.well-segment.merge-request-info
.icon-container
= custom_icon('mr_bold')
%span.commit-info.merge-requests{ 'data-project-commit-path' => merge_requests_project_commit_path(@project, @commit.id, format: :json) }
= icon('spinner spin')
- if @commit.last_pipeline - if @commit.last_pipeline
- last_pipeline = @commit.last_pipeline - last_pipeline = @commit.last_pipeline
.well-segment.pipeline-info .well-segment.pipeline-info
......
...@@ -22,4 +22,4 @@ ...@@ -22,4 +22,4 @@
= render partial: "projects/ci/builds/build", collection: builds, as: :build, locals: { commit_sha: true, ref: true, pipeline_link: true, stage: true, allow_retry: true, admin: admin } = render partial: "projects/ci/builds/build", collection: builds, as: :build, locals: { commit_sha: true, ref: true, pipeline_link: true, stage: true, allow_retry: true, admin: admin }
= paginate builds, theme: 'gitlab' = paginate_collection(builds)
...@@ -10,6 +10,6 @@ ...@@ -10,6 +10,6 @@
%ul.dropdown-footer-list %ul.dropdown-footer-list
%li %li
%button{ class: "create-new-protected-branch-button js-create-new-protected-branch", title: "New Protected Branch" } %button{ class: "dropdown-create-new-item-button js-dropdown-create-new-item", title: "New Protected Branch" }
Create wildcard Create wildcard
%code %code
...@@ -10,6 +10,6 @@ ...@@ -10,6 +10,6 @@
%ul.dropdown-footer-list %ul.dropdown-footer-list
%li %li
%button{ class: "create-new-protected-tag-button js-create-new-protected-tag", title: "New Protected Tag" } %button{ class: "dropdown-create-new-item-button js-dropdown-create-new-item", title: "New Protected Tag" }
Create wildcard Create wildcard
%code %code
---
title: Move row containing Projects, Users and Groups count to the top in admin dashboard
merge_request: 16421
author:
type: changed
---
title: Properly memoize some predicate methods
merge_request: 16329
author:
type: performance
---
title: Add reason to keep postgresql 9.2 for CI
merge_request: 16277
author: Takuya Noguchi
type: other
---
title: Speed up loading merged merge requests when they contained a lot of commits
before merging
merge_request: 16320
author:
type: performance
---
title: Ensure that emails contain absolute, rather than relative, links to user uploads
merge_request: 16364
author:
type: fixed
---
title: Fix Ctrl+Enter keyboard shortcut saving comment/note edit
merge_request: 16415
author:
type: fixed
---
title: Use simple Next/Prev paging for jobs to avoid large count queries on arbitrarily
large sets of historical jobs
merge_request:
author:
type: performance
---
title: Add link on commit page to merge request that introduced that commit
merge_request: 13713
author: Hiroyuki Sato
type: added
---
title: 'Fix custom header logo design nitpick: Remove unneeded margin on empty logo text'
merge_request: 16383
author: Markus Doits
type: fixed
---
title: Prevent invalid Route path if path is unchanged
merge_request: 16397
author:
type: fixed
...@@ -50,6 +50,7 @@ constraints(ProjectUrlConstrainer.new) do ...@@ -50,6 +50,7 @@ constraints(ProjectUrlConstrainer.new) do
post :revert post :revert
post :cherry_pick post :cherry_pick
get :diff_for_path get :diff_for_path
get :merge_requests
end end
end end
......
# rubocop:disable RemoveIndex
class AddIndexOnMergeRequestDiffCommitSha < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_index :merge_request_diff_commits, :sha, length: Gitlab::Database.mysql? ? 20 : nil
end
def down
remove_index :merge_request_diff_commits, :sha if index_exists? :merge_request_diff_commits, :sha
end
end
...@@ -1013,6 +1013,7 @@ ActiveRecord::Schema.define(version: 20180105212544) do ...@@ -1013,6 +1013,7 @@ ActiveRecord::Schema.define(version: 20180105212544) do
end end
add_index "merge_request_diff_commits", ["merge_request_diff_id", "relative_order"], name: "index_merge_request_diff_commits_on_mr_diff_id_and_order", unique: true, using: :btree add_index "merge_request_diff_commits", ["merge_request_diff_id", "relative_order"], name: "index_merge_request_diff_commits_on_mr_diff_id_and_order", unique: true, using: :btree
add_index "merge_request_diff_commits", ["sha"], name: "index_merge_request_diff_commits_on_sha", using: :btree
create_table "merge_request_diff_files", id: false, force: :cascade do |t| create_table "merge_request_diff_files", id: false, force: :cascade do |t|
t.integer "merge_request_diff_id", null: false t.integer "merge_request_diff_id", null: false
......
...@@ -415,6 +415,10 @@ GET /user ...@@ -415,6 +415,10 @@ GET /user
} }
``` ```
## List user projects
Please refer to the [List of user projects ](projects.md#list-user-projects).
## List SSH keys ## List SSH keys
Get a list of currently authenticated user's SSH keys. Get a list of currently authenticated user's SSH keys.
......
...@@ -10,25 +10,6 @@ They are written by members of the GitLab Team and by ...@@ -10,25 +10,6 @@ They are written by members of the GitLab Team and by
Part of the articles listed below link to the [GitLab Blog](https://about.gitlab.com/blog/), Part of the articles listed below link to the [GitLab Blog](https://about.gitlab.com/blog/),
where they were originally published. where they were originally published.
## Build, test, and deploy with GitLab CI/CD
Build, test, and deploy the software you develop with [GitLab CI/CD](../ci/README.md):
| Article title | Category | Publishing date |
| :------------ | :------: | --------------: |
| [Autoscaling GitLab Runners on AWS](runner_autoscale_aws/index.md) | Admin guide | 2017-11-24 |
| [Making CI Easier with GitLab](https://about.gitlab.com/2017/07/13/making-ci-easier-with-gitlab/) | Concepts | 2017-07-13 |
| [Dockerizing GitLab Review Apps](https://about.gitlab.com/2017/07/11/dockerizing-review-apps/) | Concepts | 2017-07-11 |
| [Continuous Integration: From Jenkins to GitLab Using Docker](https://about.gitlab.com/2017/07/27/docker-my-precious/) | Concepts | 2017-07-27 |
| [Continuous Delivery of a Spring Boot application with GitLab CI and Kubernetes](https://about.gitlab.com/2016/12/14/continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes/) | Tutorial | 2016-12-14 |
| [Setting up GitLab CI for Android projects](https://about.gitlab.com/2016/11/30/setting-up-gitlab-ci-for-android-projects/) | Tutorial | 2016-11-30 |
| [Automated Debian Package Build with GitLab CI](https://about.gitlab.com/2016/10/12/automated-debian-package-build-with-gitlab-ci/) | Tutorial | 2016-10-12 |
| [Building an Elixir Release into a Docker image using GitLab CI](https://about.gitlab.com/2016/08/11/building-an-elixir-release-into-docker-image-using-gitlab-ci-part-1/) | Tutorial | 2016-08-11 |
| [Continuous Delivery with GitLab and Convox](https://about.gitlab.com/2016/06/09/continuous-delivery-with-gitlab-and-convox/) | Technical overview | 2016-06-09 |
| [GitLab Container Registry](https://about.gitlab.com/2016/05/23/gitlab-container-registry/) | Technical overview | 2016-05-23 |
| [How to use GitLab CI and MacStadium to build your macOS or iOS projects](https://about.gitlab.com/2017/05/15/how-to-use-macstadium-and-gitlab-ci-to-build-your-macos-or-ios-projects/) | Technical overview | 2017-05-15 |
| [Setting up GitLab CI for iOS projects](https://about.gitlab.com/2016/03/10/setting-up-gitlab-ci-for-ios-projects/) | Tutorial | 2016-03-10 |
## GitLab Pages ## GitLab Pages
Learn how to deploy a static website with [GitLab Pages](../user/project/pages/index.md#getting-started): Learn how to deploy a static website with [GitLab Pages](../user/project/pages/index.md#getting-started):
......
This diff is collapsed.
This diff is collapsed.
...@@ -2,81 +2,59 @@ ...@@ -2,81 +2,59 @@
comments: false comments: false
--- ---
# GitLab CI Examples # GitLab CI/CD Examples
A collection of `.gitlab-ci.yml` files is maintained at the [GitLab CI Yml project][gitlab-ci-templates]. A collection of `.gitlab-ci.yml` template files is maintained at the [GitLab CI/CD YAML project][gitlab-ci-templates]. When you create a new file via the UI,
If your favorite programming language or framework are missing we would love your help by sending a merge request GitLab will give you the option to choose one of the templates existent on this project.
with a `.gitlab-ci.yml`. If your favorite programming language or framework are missing we would love your
help by sending a merge request with a new `.gitlab-ci.yml` to this project.
Apart from those, here is an collection of tutorials and guides on setting up your CI pipeline: There's also a collection of repositories with [example projects](https://gitlab.com/gitlab-examples) for various languages. You can fork an adjust them to your own needs.
## Languages, frameworks, OSs ## Languages, frameworks, OSs
### PHP - **PHP**:
- [Testing a PHP application](php.md)
- [Run PHP Composer & NPM scripts then deploy them to a staging server](deployment/composer-npm-deploy.md)
- [How to test and deploy Laravel/PHP applications with GitLab CI/CD and Envoy](laravel_with_gitlab_and_envoy/index.md)
- **Ruby**: [Test and deploy a Ruby application to Heroku](test-and-deploy-ruby-application-to-heroku.md)
- **Python**: [Test and deploy a Python application to Heroku](test-and-deploy-python-application-to-heroku.md)
- **Java**: [Continuous Delivery of a Spring Boot application with GitLab CI and Kubernetes](https://about.gitlab.com/2016/12/14/continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes/)
- **Scala**: [Test a Scala application](test-scala-application.md)
- **Clojure**: [Test a Clojure application](test-clojure-application.md)
- **Elixir**:
- [Test a Phoenix application](test-phoenix-application.md)
- [Building an Elixir Release into a Docker image using GitLab CI](https://about.gitlab.com/2016/08/11/building-an-elixir-release-into-docker-image-using-gitlab-ci-part-1/)
- **iOS and macOS**:
- [Setting up GitLab CI for iOS projects](https://about.gitlab.com/2016/03/10/setting-up-gitlab-ci-for-ios-projects/)
- [How to use GitLab CI and MacStadium to build your macOS or iOS projects](https://about.gitlab.com/2017/05/15/how-to-use-macstadium-and-gitlab-ci-to-build-your-macos-or-ios-projects/)
- **Android**: [Setting up GitLab CI for Android projects](https://about.gitlab.com/2016/11/30/setting-up-gitlab-ci-for-android-projects/)
- **Debian**: [Continuous Deployment with GitLab: how to build and deploy a Debian Package with GitLab CI](https://about.gitlab.com/2016/10/12/automated-debian-package-build-with-gitlab-ci/)
- **Maven**: [How to deploy Maven projects to Artifactory with GitLab CI/CD](artifactory_and_gitlab/index.md)
### Miscellaneous
- [Testing a PHP application](php.md) - [Using `dpl` as deployment tool](deployment/README.md)
- [Run PHP Composer & NPM scripts then deploy them to a staging server](deployment/composer-npm-deploy.md) - [The `.gitlab-ci.yml` file for GitLab itself](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.gitlab-ci.yml)
- [How to test and deploy Laravel/PHP applications with GitLab CI/CD and Envoy](laravel_with_gitlab_and_envoy/index.md)
### Ruby
- [Test and deploy a Ruby application to Heroku](test-and-deploy-ruby-application-to-heroku.md)
### Python
- [Test and deploy a Python application to Heroku](test-and-deploy-python-application-to-heroku.md)
### Java
- [Continuous Delivery of a Spring Boot application with GitLab CI and Kubernetes](https://about.gitlab.com/2016/12/14/continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes/)
### Scala
- [Test a Scala application](test-scala-application.md)
### Clojure
- [Test a Clojure application](test-clojure-application.md)
### Elixir
- [Test a Phoenix application](test-phoenix-application.md)
- [Building an Elixir Release into a Docker image using GitLab CI](https://about.gitlab.com/2016/08/11/building-an-elixir-release-into-docker-image-using-gitlab-ci-part-1/)
### iOS
- [Setting up GitLab CI for iOS projects](https://about.gitlab.com/2016/03/10/setting-up-gitlab-ci-for-ios-projects/)
### Android
- [Setting up GitLab CI for Android projects](https://about.gitlab.com/2016/11/30/setting-up-gitlab-ci-for-android-projects/)
### Code quality analysis ### Code quality analysis
- [Analyze code quality with the Code Climate CLI](code_climate.md) [Analyze code quality with the Code Climate CLI](code_climate.md).
### Other ### GitLab CI/CD for Review Apps
- [Using `dpl` as deployment tool](deployment/README.md)
- [Repositories with examples for various languages](https://gitlab.com/groups/gitlab-examples)
- [The .gitlab-ci.yml file for GitLab itself](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.gitlab-ci.yml)
- [Continuous Deployment with GitLab: how to build and deploy a Debian Package with GitLab CI](https://about.gitlab.com/2016/10/12/automated-debian-package-build-with-gitlab-ci/)
- [How to deploy Maven projects to Artifactory with GitLab CI/CD](artifactory_and_gitlab/index.md)
## GitLab CI/CD for GitLab Pages - [Example project](https://gitlab.com/gitlab-examples/review-apps-nginx/) that shows how to use GitLab CI/CD for [Review Apps](../review_apps/index.html).
- [Dockerizing GitLab Review Apps](https://about.gitlab.com/2017/07/11/dockerizing-review-apps/)
- [Example projects](https://gitlab.com/pages) ### GitLab CI/CD for GitLab Pages
- [Creating and Tweaking `.gitlab-ci.yml` for GitLab Pages](../../user/project/pages/getting_started_part_four.md)
- [SSGs Part 3: Build any SSG site with GitLab Pages](https://about.gitlab.com/2016/06/17/ssg-overview-gitlab-pages-part-3-examples-ci/):
examples for Ruby-, NodeJS-, Python-, and GoLang-based SSGs
- [Building a new GitLab docs site with Nanoc, GitLab CI, and GitLab Pages](https://about.gitlab.com/2016/12/07/building-a-new-gitlab-docs-site-with-nanoc-gitlab-ci-and-gitlab-pages/)
- [Publish code coverage reports with GitLab Pages](https://about.gitlab.com/2016/11/03/publish-code-coverage-report-with-gitlab-pages/)
See the documentation on [GitLab Pages](../../user/project/pages/index.md) for a complete overview. See the documentation on [GitLab Pages](../../user/project/pages/index.md) for a complete overview.
## More ## Contributing
Contributions are very much welcomed! You can help your favorite programming Contributions are very welcome! You can help your favorite programming
language and GitLab by sending a merge request with a guide for that language. language users and GitLab by sending a merge request with a guide for that language.
You may want to apply for the [GitLab Community Writers Program](https://about.gitlab.com/community-writers/)
to get paid for writing complete articles for GitLab.
[gitlab-ci-templates]: https://gitlab.com/gitlab-org/gitlab-ci-yml [gitlab-ci-templates]: https://gitlab.com/gitlab-org/gitlab-ci-yml
...@@ -164,7 +164,7 @@ Feature: Project Issues ...@@ -164,7 +164,7 @@ Feature: Project Issues
Given project "Shop" have "Release 0.4" open issue Given project "Shop" have "Release 0.4" open issue
When I visit issue page "Release 0.4" When I visit issue page "Release 0.4"
Then I should see that I am subscribed Then I should see that I am subscribed
When I click button "Unsubscribe" When I click the subscription toggle
Then I should see that I am unsubscribed Then I should see that I am unsubscribed
@javascript @javascript
......
...@@ -21,20 +21,20 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps ...@@ -21,20 +21,20 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
step 'I should see that I am subscribed' do step 'I should see that I am subscribed' do
wait_for_requests wait_for_requests
expect(find('.js-issuable-subscribe-button span')).to have_content 'Unsubscribe' expect(find('.js-issuable-subscribe-button')).to have_css 'button.is-checked'
end end
step 'I should see that I am unsubscribed' do step 'I should see that I am unsubscribed' do
wait_for_requests wait_for_requests
expect(find('.js-issuable-subscribe-button span')).to have_content 'Subscribe' expect(find('.js-issuable-subscribe-button')).to have_css 'button:not(.is-checked)'
end end
step 'I click link "Closed"' do step 'I click link "Closed"' do
find('.issues-state-filters [data-state="closed"] span', text: 'Closed').click find('.issues-state-filters [data-state="closed"] span', text: 'Closed').click
end end
step 'I click button "Unsubscribe"' do step 'I click the subscription toggle' do
click_on "Unsubscribe" find('.js-issuable-subscribe-button button').click
end end
step 'I should see "Release 0.3" in issues' do step 'I should see "Release 0.3" in issues' do
......
...@@ -50,15 +50,22 @@ module Banzai ...@@ -50,15 +50,22 @@ module Banzai
end end
def process_link_to_upload_attr(html_attr) def process_link_to_upload_attr(html_attr)
uri_parts = [html_attr.value] path_parts = [html_attr.value]
if group if group
uri_parts.unshift(relative_url_root, 'groups', group.full_path, '-') path_parts.unshift(relative_url_root, 'groups', group.full_path, '-')
elsif project elsif project
uri_parts.unshift(relative_url_root, project.full_path) path_parts.unshift(relative_url_root, project.full_path)
end end
html_attr.value = File.join(*uri_parts) path = File.join(*path_parts)
html_attr.value =
if context[:only_path]
path
else
URI.join(Gitlab.config.gitlab.base_url, path).to_s
end
end end
def process_link_to_repository_attr(html_attr) def process_link_to_repository_attr(html_attr)
......
...@@ -7,6 +7,7 @@ module Gitlab ...@@ -7,6 +7,7 @@ module Gitlab
class PrepareUntrackedUploads # rubocop:disable Metrics/ClassLength class PrepareUntrackedUploads # rubocop:disable Metrics/ClassLength
# For bulk_queue_background_migration_jobs_by_range # For bulk_queue_background_migration_jobs_by_range
include Database::MigrationHelpers include Database::MigrationHelpers
include ::Gitlab::Utils::StrongMemoize
FIND_BATCH_SIZE = 500 FIND_BATCH_SIZE = 500
RELATIVE_UPLOAD_DIR = "uploads".freeze RELATIVE_UPLOAD_DIR = "uploads".freeze
...@@ -142,7 +143,9 @@ module Gitlab ...@@ -142,7 +143,9 @@ module Gitlab
end end
def postgresql? def postgresql?
@postgresql ||= Gitlab::Database.postgresql? strong_memoize(:postgresql) do
Gitlab::Database.postgresql?
end
end end
def can_bulk_insert_and_ignore_duplicates? def can_bulk_insert_and_ignore_duplicates?
...@@ -150,8 +153,9 @@ module Gitlab ...@@ -150,8 +153,9 @@ module Gitlab
end end
def postgresql_pre_9_5? def postgresql_pre_9_5?
@postgresql_pre_9_5 ||= postgresql? && strong_memoize(:postgresql_pre_9_5) do
Gitlab::Database.version.to_f < 9.5 postgresql? && Gitlab::Database.version.to_f < 9.5
end
end end
def schedule_populate_untracked_uploads_jobs def schedule_populate_untracked_uploads_jobs
......
module Gitlab module Gitlab
module BareRepositoryImport module BareRepositoryImport
class Repository class Repository
include ::Gitlab::Utils::StrongMemoize
attr_reader :group_path, :project_name, :repo_path attr_reader :group_path, :project_name, :repo_path
def initialize(root_path, repo_path) def initialize(root_path, repo_path)
...@@ -41,11 +43,15 @@ module Gitlab ...@@ -41,11 +43,15 @@ module Gitlab
private private
def wiki? def wiki?
@wiki ||= repo_path.end_with?('.wiki.git') strong_memoize(:wiki) do
repo_path.end_with?('.wiki.git')
end
end end
def hashed? def hashed?
@hashed ||= repo_relative_path.include?('@hashed') strong_memoize(:hashed) do
repo_relative_path.include?('@hashed')
end
end end
def repo_relative_path def repo_relative_path
......
...@@ -3,6 +3,8 @@ module Gitlab ...@@ -3,6 +3,8 @@ module Gitlab
module Pipeline module Pipeline
module Chain module Chain
class Skip < Chain::Base class Skip < Chain::Base
include ::Gitlab::Utils::StrongMemoize
SKIP_PATTERN = /\[(ci[ _-]skip|skip[ _-]ci)\]/i SKIP_PATTERN = /\[(ci[ _-]skip|skip[ _-]ci)\]/i
def perform! def perform!
...@@ -24,7 +26,9 @@ module Gitlab ...@@ -24,7 +26,9 @@ module Gitlab
def commit_message_skips_ci? def commit_message_skips_ci?
return false unless @pipeline.git_commit_message return false unless @pipeline.git_commit_message
@skipped ||= !!(@pipeline.git_commit_message =~ SKIP_PATTERN) strong_memoize(:commit_message_skips_ci) do
!!(@pipeline.git_commit_message =~ SKIP_PATTERN)
end
end end
end end
end end
......
...@@ -2,6 +2,8 @@ module Gitlab ...@@ -2,6 +2,8 @@ module Gitlab
module Ci module Ci
module Stage module Stage
class Seed class Seed
include ::Gitlab::Utils::StrongMemoize
attr_reader :pipeline attr_reader :pipeline
delegate :project, to: :pipeline delegate :project, to: :pipeline
...@@ -50,7 +52,9 @@ module Gitlab ...@@ -50,7 +52,9 @@ module Gitlab
private private
def protected_ref? def protected_ref?
@protected_ref ||= project.protected_for?(pipeline.ref) strong_memoize(:protected_ref) do
project.protected_for?(pipeline.ref)
end
end end
end end
end end
......
...@@ -14,6 +14,8 @@ module Gitlab ...@@ -14,6 +14,8 @@ module Gitlab
# puts label.name # puts label.name
# end # end
class Client class Client
include ::Gitlab::Utils::StrongMemoize
attr_reader :octokit attr_reader :octokit
# A single page of data and the corresponding page number. # A single page of data and the corresponding page number.
...@@ -173,7 +175,9 @@ module Gitlab ...@@ -173,7 +175,9 @@ module Gitlab
end end
def rate_limiting_enabled? def rate_limiting_enabled?
@rate_limiting_enabled ||= api_endpoint.include?('.github.com') strong_memoize(:rate_limiting_enabled) do
api_endpoint.include?('.github.com')
end
end end
def api_endpoint def api_endpoint
......
...@@ -16,8 +16,10 @@ module Gitlab ...@@ -16,8 +16,10 @@ module Gitlab
def can_do_action?(action) def can_do_action?(action)
return false unless can_access_git? return false unless can_access_git?
@permission_cache ||= {} permission_cache[action] =
@permission_cache[action] ||= user.can?(action, project) permission_cache.fetch(action) do
user.can?(action, project)
end
end end
def cannot_do_action?(action) def cannot_do_action?(action)
...@@ -88,6 +90,10 @@ module Gitlab ...@@ -88,6 +90,10 @@ module Gitlab
private private
def permission_cache
@permission_cache ||= {}
end
def can_access_git? def can_access_git?
user && user.can?(:access_git) user && user.can?(:access_git)
end end
......
...@@ -17,6 +17,17 @@ against any existing instance. ...@@ -17,6 +17,17 @@ against any existing instance.
1. Along with GitLab Docker Images we also build and publish GitLab QA images. 1. Along with GitLab Docker Images we also build and publish GitLab QA images.
1. GitLab QA project uses these images to execute integration tests. 1. GitLab QA project uses these images to execute integration tests.
## Validating GitLab views / partials / selectors in merge requests
We recently added a new CI job that is going to be triggered for every push
event in CE and EE projects. The job is called `qa:selectors` and it will
verify coupling between page objects implemented as a part of GitLab QA
and corresponding views / partials / selectors in CE / EE.
Whenever `qa:selectors` job fails in your merge request, you are supposed to
fix [page objects](qa/page/README.md). You should also trigger end-to-end tests
using `package-qa` manual action, to test if everything works fine.
## How can I use it? ## How can I use it?
You can use GitLab QA to exercise tests on any live instance! For example, the You can use GitLab QA to exercise tests on any live instance! For example, the
......
module RuboCop
module Cop
module Gitlab
class PredicateMemoization < RuboCop::Cop::Cop
MSG = <<~EOL.freeze
Avoid using `@value ||= query` inside predicate methods in order to
properly memoize `false` or `nil` values.
https://docs.gitlab.com/ee/development/utilities.html#strongmemoize
EOL
def on_def(node)
return unless predicate_method?(node)
select_offenses(node).each do |offense|
add_offense(offense, location: :expression)
end
end
private
def predicate_method?(node)
node.method_name.to_s.end_with?('?')
end
def or_ivar_assignment?(or_assignment)
lhs = or_assignment.each_child_node.first
lhs.ivasgn_type?
end
def select_offenses(node)
node.each_descendant(:or_asgn).select do |or_assignment|
or_ivar_assignment?(or_assignment)
end
end
end
end
end
end
require_relative 'cop/gitlab/module_with_instance_variables' require_relative 'cop/gitlab/module_with_instance_variables'
require_relative 'cop/gitlab/predicate_memoization'
require_relative 'cop/include_sidekiq_worker' require_relative 'cop/include_sidekiq_worker'
require_relative 'cop/line_break_around_conditional_block' require_relative 'cop/line_break_around_conditional_block'
require_relative 'cop/migration/add_column' require_relative 'cop/migration/add_column'
......
FactoryBot.define do
factory :redirect_route do
sequence(:path) { |n| "redirect#{n}" }
source factory: :group
permanent false
trait :permanent do
permanent true
end
trait :temporary do
permanent false
end
end
end
...@@ -334,14 +334,14 @@ describe 'Issue Boards', :js do ...@@ -334,14 +334,14 @@ describe 'Issue Boards', :js do
wait_for_requests wait_for_requests
page.within('.subscriptions') do page.within('.subscriptions') do
click_button 'Subscribe' find('.js-issuable-subscribe-button button:not(.is-checked)').click
wait_for_requests wait_for_requests
expect(page).to have_content('Unsubscribe') expect(page).to have_css('.js-issuable-subscribe-button button.is-checked')
end end
end end
it 'has "Unsubscribe" button when already subscribed' do it 'has checked subscription toggle when already subscribed' do
create(:subscription, user: user, project: project, subscribable: issue2, subscribed: true) create(:subscription, user: user, project: project, subscribable: issue2, subscribed: true)
visit project_board_path(project, board) visit project_board_path(project, board)
wait_for_requests wait_for_requests
...@@ -350,10 +350,10 @@ describe 'Issue Boards', :js do ...@@ -350,10 +350,10 @@ describe 'Issue Boards', :js do
wait_for_requests wait_for_requests
page.within('.subscriptions') do page.within('.subscriptions') do
click_button 'Unsubscribe' find('.js-issuable-subscribe-button button.is-checked').click
wait_for_requests wait_for_requests
expect(page).to have_content('Subscribe') expect(page).to have_css('.js-issuable-subscribe-button button:not(.is-checked)')
end end
end end
end end
......
...@@ -13,20 +13,18 @@ describe 'User manages subscription', :js do ...@@ -13,20 +13,18 @@ describe 'User manages subscription', :js do
end end
it 'toggles subscription' do it 'toggles subscription' do
subscribe_button = find('.js-issuable-subscribe-button') page.within('.js-issuable-subscribe-button') do
expect(page).to have_css 'button:not(.is-checked)'
expect(subscribe_button).to have_content('Subscribe') find('button:not(.is-checked)').click
click_on('Subscribe')
wait_for_requests wait_for_requests
expect(subscribe_button).to have_content('Unsubscribe') expect(page).to have_css 'button.is-checked'
find('button.is-checked').click
click_on('Unsubscribe')
wait_for_requests wait_for_requests
expect(subscribe_button).to have_content('Subscribe') expect(page).to have_css 'button:not(.is-checked)'
end
end end
end end
import * as CommitMergeRequests from '~/commit_merge_requests';
describe('CommitMergeRequests', () => {
describe('createContent', () => {
it('should return created content', () => {
const content1 = CommitMergeRequests.createContent([{ iid: 1, path: '/path1', title: 'foo' }, { iid: 2, path: '/path2', title: 'baz' }])[0];
expect(content1.tagName).toEqual('SPAN');
expect(content1.childElementCount).toEqual(4);
const content2 = CommitMergeRequests.createContent([])[0];
expect(content2.tagName).toEqual('SPAN');
expect(content2.childElementCount).toEqual(0);
expect(content2.innerText).toEqual('No related merge requests found');
});
});
describe('getHeaderText', () => {
it('should return header text', () => {
expect(CommitMergeRequests.getHeaderText(0, 1)).toEqual('1 merge request');
expect(CommitMergeRequests.getHeaderText(0, 2)).toEqual('2 merge requests');
expect(CommitMergeRequests.getHeaderText(1, 1)).toEqual(',');
expect(CommitMergeRequests.getHeaderText(1, 2)).toEqual(',');
});
});
describe('createHeader', () => {
it('should return created header', () => {
const header = CommitMergeRequests.createHeader(0, 1)[0];
expect(header.tagName).toEqual('SPAN');
expect(header.innerText).toEqual('1 merge request');
});
});
describe('createItem', () => {
it('should return created item', () => {
const item = CommitMergeRequests.createItem({ iid: 1, path: '/path', title: 'foo' })[0];
expect(item.tagName).toEqual('SPAN');
expect(item.childElementCount).toEqual(2);
expect(item.children[0].tagName).toEqual('A');
expect(item.children[1].tagName).toEqual('SPAN');
});
});
describe('createLink', () => {
it('should return created link', () => {
const link = CommitMergeRequests.createLink({ iid: 1, path: '/path', title: 'foo' })[0];
expect(link.tagName).toEqual('A');
expect(link.href).toMatch(/\/path$/);
expect(link.innerText).toEqual('!1');
});
});
describe('createTitle', () => {
it('should return created title', () => {
const title = CommitMergeRequests.createTitle({ iid: 1, path: '/path', title: 'foo' })[0];
expect(title.tagName).toEqual('SPAN');
expect(title.innerText).toEqual('foo');
});
});
});
...@@ -31,6 +31,7 @@ describe('Job details header', () => { ...@@ -31,6 +31,7 @@ describe('Job details header', () => {
email: 'foo@bar.com', email: 'foo@bar.com',
avatar_url: 'link', avatar_url: 'link',
}, },
started: '2018-01-08T09:48:27.319Z',
new_issue_path: 'path', new_issue_path: 'path',
}, },
isLoading: false, isLoading: false,
...@@ -43,6 +44,11 @@ describe('Job details header', () => { ...@@ -43,6 +44,11 @@ describe('Job details header', () => {
vm.$destroy(); vm.$destroy();
}); });
describe('triggered job', () => {
beforeEach(() => {
vm = mountComponent(HeaderComponent, props);
});
it('should render provided job information', () => { it('should render provided job information', () => {
expect( expect(
vm.$el.querySelector('.header-main-content').textContent.replace(/\s+/g, ' ').trim(), vm.$el.querySelector('.header-main-content').textContent.replace(/\s+/g, ' ').trim(),
...@@ -54,4 +60,16 @@ describe('Job details header', () => { ...@@ -54,4 +60,16 @@ describe('Job details header', () => {
vm.$el.querySelector('.js-new-issue').getAttribute('href'), vm.$el.querySelector('.js-new-issue').getAttribute('href'),
).toEqual(props.job.new_issue_path); ).toEqual(props.job.new_issue_path);
}); });
});
describe('created job', () => {
it('should render created key', () => {
props.job.started = false;
vm = mountComponent(HeaderComponent, props);
expect(
vm.$el.querySelector('.header-main-content').textContent.replace(/\s+/g, ' ').trim(),
).toEqual('failed Job #123 created 3 weeks ago by Foo');
});
});
}); });
...@@ -139,13 +139,21 @@ describe('issue_comment_form component', () => { ...@@ -139,13 +139,21 @@ describe('issue_comment_form component', () => {
}); });
describe('event enter', () => { describe('event enter', () => {
it('should save note when cmd/ctrl+enter is pressed', () => { it('should save note when cmd+enter is pressed', () => {
spyOn(vm, 'handleSave').and.callThrough(); spyOn(vm, 'handleSave').and.callThrough();
vm.$el.querySelector('.js-main-target-form textarea').value = 'Foo'; vm.$el.querySelector('.js-main-target-form textarea').value = 'Foo';
vm.$el.querySelector('.js-main-target-form textarea').dispatchEvent(keyboardDownEvent(13, true)); vm.$el.querySelector('.js-main-target-form textarea').dispatchEvent(keyboardDownEvent(13, true));
expect(vm.handleSave).toHaveBeenCalled(); expect(vm.handleSave).toHaveBeenCalled();
}); });
it('should save note when ctrl+enter is pressed', () => {
spyOn(vm, 'handleSave').and.callThrough();
vm.$el.querySelector('.js-main-target-form textarea').value = 'Foo';
vm.$el.querySelector('.js-main-target-form textarea').dispatchEvent(keyboardDownEvent(13, false, true));
expect(vm.handleSave).toHaveBeenCalled();
});
}); });
}); });
......
...@@ -69,11 +69,18 @@ describe('issue_note_form component', () => { ...@@ -69,11 +69,18 @@ describe('issue_note_form component', () => {
}); });
describe('enter', () => { describe('enter', () => {
it('should submit note', () => { it('should save note when cmd+enter is pressed', () => {
spyOn(vm, 'handleUpdate').and.callThrough(); spyOn(vm, 'handleUpdate').and.callThrough();
vm.$el.querySelector('textarea').value = 'Foo'; vm.$el.querySelector('textarea').value = 'Foo';
vm.$el.querySelector('textarea').dispatchEvent(keyboardDownEvent(13, true)); vm.$el.querySelector('textarea').dispatchEvent(keyboardDownEvent(13, true));
expect(vm.handleUpdate).toHaveBeenCalled();
});
it('should save note when ctrl+enter is pressed', () => {
spyOn(vm, 'handleUpdate').and.callThrough();
vm.$el.querySelector('textarea').value = 'Foo';
vm.$el.querySelector('textarea').dispatchEvent(keyboardDownEvent(13, false, true));
expect(vm.handleUpdate).toHaveBeenCalled(); expect(vm.handleUpdate).toHaveBeenCalled();
}); });
}); });
......
...@@ -20,23 +20,23 @@ describe('Subscriptions', function () { ...@@ -20,23 +20,23 @@ describe('Subscriptions', function () {
subscribed: undefined, subscribed: undefined,
}); });
expect(vm.$refs.loadingButton.loading).toBe(true); expect(vm.$refs.toggleButton.isLoading).toBe(true);
expect(vm.$refs.loadingButton.label).toBeUndefined(); expect(vm.$refs.toggleButton.$el.querySelector('.project-feature-toggle')).toHaveClass('is-loading');
}); });
it('has "Subscribe" text when currently not subscribed', () => { it('is toggled "off" when currently not subscribed', () => {
vm = mountComponent(Subscriptions, { vm = mountComponent(Subscriptions, {
subscribed: false, subscribed: false,
}); });
expect(vm.$refs.loadingButton.label).toBe('Subscribe'); expect(vm.$refs.toggleButton.$el.querySelector('.project-feature-toggle')).not.toHaveClass('is-checked');
}); });
it('has "Unsubscribe" text when currently not subscribed', () => { it('is toggled "on" when currently subscribed', () => {
vm = mountComponent(Subscriptions, { vm = mountComponent(Subscriptions, {
subscribed: true, subscribed: true,
}); });
expect(vm.$refs.loadingButton.label).toBe('Unsubscribe'); expect(vm.$refs.toggleButton.$el.querySelector('.project-feature-toggle')).toHaveClass('is-checked');
}); });
}); });
...@@ -8,7 +8,8 @@ describe Banzai::Filter::RelativeLinkFilter do ...@@ -8,7 +8,8 @@ describe Banzai::Filter::RelativeLinkFilter do
group: group, group: group,
project_wiki: project_wiki, project_wiki: project_wiki,
ref: ref, ref: ref,
requested_path: requested_path requested_path: requested_path,
only_path: only_path
}) })
described_class.call(doc, contexts) described_class.call(doc, contexts)
...@@ -37,6 +38,7 @@ describe Banzai::Filter::RelativeLinkFilter do ...@@ -37,6 +38,7 @@ describe Banzai::Filter::RelativeLinkFilter do
let(:commit) { project.commit(ref) } let(:commit) { project.commit(ref) }
let(:project_wiki) { nil } let(:project_wiki) { nil }
let(:requested_path) { '/' } let(:requested_path) { '/' }
let(:only_path) { true }
shared_examples :preserve_unchanged do shared_examples :preserve_unchanged do
it 'does not modify any relative URL in anchor' do it 'does not modify any relative URL in anchor' do
...@@ -240,26 +242,35 @@ describe Banzai::Filter::RelativeLinkFilter do ...@@ -240,26 +242,35 @@ describe Banzai::Filter::RelativeLinkFilter do
let(:commit) { nil } let(:commit) { nil }
let(:ref) { nil } let(:ref) { nil }
let(:requested_path) { nil } let(:requested_path) { nil }
let(:upload_path) { '/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg' }
let(:relative_path) { "/#{project.full_path}#{upload_path}" }
context 'to a project upload' do context 'to a project upload' do
context 'with an absolute URL' do
let(:absolute_path) { Gitlab.config.gitlab.url + relative_path }
let(:only_path) { false }
it 'rewrites the link correctly' do
doc = filter(link(upload_path))
expect(doc.at_css('a')['href']).to eq(absolute_path)
end
end
it 'rebuilds relative URL for a link' do it 'rebuilds relative URL for a link' do
doc = filter(link('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg')) doc = filter(link(upload_path))
expect(doc.at_css('a')['href']) expect(doc.at_css('a')['href']).to eq(relative_path)
.to eq "/#{project.full_path}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg"
doc = filter(nested(link('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg'))) doc = filter(nested(link(upload_path)))
expect(doc.at_css('a')['href']) expect(doc.at_css('a')['href']).to eq(relative_path)
.to eq "/#{project.full_path}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg"
end end
it 'rebuilds relative URL for an image' do it 'rebuilds relative URL for an image' do
doc = filter(image('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg')) doc = filter(image(upload_path))
expect(doc.at_css('img')['src']) expect(doc.at_css('img')['src']).to eq(relative_path)
.to eq "/#{project.full_path}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg"
doc = filter(nested(image('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg'))) doc = filter(nested(image(upload_path)))
expect(doc.at_css('img')['src']) expect(doc.at_css('img')['src']).to eq(relative_path)
.to eq "/#{project.full_path}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg"
end end
it 'does not modify absolute URL' do it 'does not modify absolute URL' do
...@@ -288,6 +299,17 @@ describe Banzai::Filter::RelativeLinkFilter do ...@@ -288,6 +299,17 @@ describe Banzai::Filter::RelativeLinkFilter do
let(:project) { nil } let(:project) { nil }
let(:relative_path) { "/groups/#{group.full_path}/-/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg" } let(:relative_path) { "/groups/#{group.full_path}/-/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg" }
context 'with an absolute URL' do
let(:absolute_path) { Gitlab.config.gitlab.url + relative_path }
let(:only_path) { false }
it 'rewrites the link correctly' do
doc = filter(upload_link)
expect(doc.at_css('a')['href']).to eq(absolute_path)
end
end
it 'rewrites the link correctly' do it 'rewrites the link correctly' do
doc = filter(upload_link) doc = filter(upload_link)
......
...@@ -151,11 +151,11 @@ describe CommitRange do ...@@ -151,11 +151,11 @@ describe CommitRange do
.with(commit1, user) .with(commit1, user)
.and_return(true) .and_return(true)
expect(commit1.has_been_reverted?(user, issue)).to eq(true) expect(commit1.has_been_reverted?(user, issue.notes_with_associations)).to eq(true)
end end
it 'returns false a commit has not been reverted' do it 'returns false if the commit has not been reverted' do
expect(commit1.has_been_reverted?(user, issue)).to eq(false) expect(commit1.has_been_reverted?(user, issue.notes_with_associations)).to eq(false)
end end
end end
end end
...@@ -513,4 +513,17 @@ eos ...@@ -513,4 +513,17 @@ eos
expect(described_class.valid_hash?('a' * 41)).to be false expect(described_class.valid_hash?('a' * 41)).to be false
end end
end end
describe '#merge_requests' do
let!(:project) { create(:project, :repository) }
let!(:merge_request1) { create(:merge_request, source_project: project, source_branch: 'master', target_branch: 'feature') }
let!(:merge_request2) { create(:merge_request, source_project: project, source_branch: 'merged-target', target_branch: 'feature') }
let(:commit1) { merge_request1.merge_request_diff.commits.last }
let(:commit2) { merge_request1.merge_request_diff.commits.first }
it 'returns merge_requests that introduced that commit' do
expect(commit1.merge_requests).to eq([merge_request1, merge_request2])
expect(commit2.merge_requests).to eq([merge_request1])
end
end
end end
...@@ -15,6 +15,28 @@ describe MergeRequestDiff do ...@@ -15,6 +15,28 @@ describe MergeRequestDiff do
it { expect(subject.start_commit_sha).to eq('0b4bc9a49b562e85de7cc9e834518ea6828729b9') } it { expect(subject.start_commit_sha).to eq('0b4bc9a49b562e85de7cc9e834518ea6828729b9') }
end end
describe '.by_commit_sha' do
subject(:by_commit_sha) { described_class.by_commit_sha(sha) }
let!(:merge_request) { create(:merge_request, :with_diffs) }
context 'with sha contained in' do
let(:sha) { 'b83d6e391c22777fca1ed3012fce84f633d7fed0' }
it 'returns merge request diffs' do
expect(by_commit_sha).to eq([merge_request.merge_request_diff])
end
end
context 'with sha not contained in' do
let(:sha) { 'b83d6e3' }
it 'returns empty result' do
expect(by_commit_sha).to be_empty
end
end
end
describe '#latest' do describe '#latest' do
let!(:mr) { create(:merge_request, :with_diffs) } let!(:mr) { create(:merge_request, :with_diffs) }
let!(:first_diff) { mr.merge_request_diff } let!(:first_diff) { mr.merge_request_diff }
......
...@@ -87,6 +87,39 @@ describe MergeRequest do ...@@ -87,6 +87,39 @@ describe MergeRequest do
it { is_expected.to respond_to(:merge_when_pipeline_succeeds) } it { is_expected.to respond_to(:merge_when_pipeline_succeeds) }
end end
describe '.by_commit_sha' do
subject(:by_commit_sha) { described_class.by_commit_sha(sha) }
let!(:merge_request) { create(:merge_request, :with_diffs) }
context 'with sha contained in latest merge request diff' do
let(:sha) { 'b83d6e391c22777fca1ed3012fce84f633d7fed0' }
it 'returns merge requests' do
expect(by_commit_sha).to eq([merge_request])
end
end
context 'with sha contained not in latest merge request diff' do
let(:sha) { 'b83d6e391c22777fca1ed3012fce84f633d7fed0' }
it 'returns empty requests' do
latest_merge_request_diff = merge_request.merge_request_diffs.create
latest_merge_request_diff.merge_request_diff_commits.where(sha: 'b83d6e391c22777fca1ed3012fce84f633d7fed0').delete_all
expect(by_commit_sha).to be_empty
end
end
context 'with sha not contained in' do
let(:sha) { 'b83d6e3' }
it 'returns empty result' do
expect(by_commit_sha).to be_empty
end
end
end
describe '.in_projects' do describe '.in_projects' do
it 'returns the merge requests for a set of projects' do it 'returns the merge requests for a set of projects' do
expect(described_class.in_projects(Project.all)).to eq([subject]) expect(described_class.in_projects(Project.all)).to eq([subject])
...@@ -1030,6 +1063,83 @@ describe MergeRequest do ...@@ -1030,6 +1063,83 @@ describe MergeRequest do
end end
end end
describe '#can_be_reverted?' do
context 'when there is no merged_at for the MR' do
before do
subject.metrics.update!(merged_at: nil)
end
it 'returns false' do
expect(subject.can_be_reverted?(nil)).to be_falsey
end
end
context 'when there is no merge_commit for the MR' do
before do
subject.metrics.update!(merged_at: Time.now.utc)
end
it 'returns false' do
expect(subject.can_be_reverted?(nil)).to be_falsey
end
end
context 'when the MR has been merged' do
before do
MergeRequests::MergeService
.new(subject.target_project, subject.author)
.execute(subject)
end
context 'when there is no revert commit' do
it 'returns true' do
expect(subject.can_be_reverted?(nil)).to be_truthy
end
end
context 'when there is a revert commit' do
let(:current_user) { subject.author }
let(:branch) { subject.target_branch }
let(:project) { subject.target_project }
let(:revert_commit_id) do
params = {
commit: subject.merge_commit,
branch_name: branch,
start_branch: branch
}
Commits::RevertService.new(project, current_user, params).execute[:result]
end
before do
project.add_master(current_user)
ProcessCommitWorker.new.perform(project.id,
current_user.id,
project.commit(revert_commit_id).to_hash,
project.default_branch == branch)
end
context 'when the revert commit is mentioned in a note after the MR was merged' do
it 'returns false' do
expect(subject.can_be_reverted?(current_user)).to be_falsey
end
end
context 'when the revert commit is mentioned in a note before the MR was merged' do
before do
subject.notes.last.update!(created_at: subject.metrics.merged_at - 1.second)
end
it 'returns true' do
expect(subject.can_be_reverted?(current_user)).to be_truthy
end
end
end
end
end
describe '#participants' do describe '#participants' do
let(:project) { create(:project, :public) } let(:project) { create(:project, :public) }
......
...@@ -16,6 +16,66 @@ describe Route do ...@@ -16,6 +16,66 @@ describe Route do
it { is_expected.to validate_presence_of(:source) } it { is_expected.to validate_presence_of(:source) }
it { is_expected.to validate_presence_of(:path) } it { is_expected.to validate_presence_of(:path) }
it { is_expected.to validate_uniqueness_of(:path).case_insensitive } it { is_expected.to validate_uniqueness_of(:path).case_insensitive }
describe '#ensure_permanent_paths' do
context 'when the route is not yet persisted' do
let(:new_route) { described_class.new(path: 'foo', source: build(:group)) }
context 'when permanent conflicting redirects exist' do
it 'is invalid' do
redirect = build(:redirect_route, :permanent, path: 'foo/bar/baz')
redirect.save!(validate: false)
expect(new_route.valid?).to be_falsey
expect(new_route.errors.first[1]).to eq('foo has been taken before. Please use another one')
end
end
context 'when no permanent conflicting redirects exist' do
it 'is valid' do
expect(new_route.valid?).to be_truthy
end
end
end
context 'when path has changed' do
before do
route.path = 'foo'
end
context 'when permanent conflicting redirects exist' do
it 'is invalid' do
redirect = build(:redirect_route, :permanent, path: 'foo/bar/baz')
redirect.save!(validate: false)
expect(route.valid?).to be_falsey
expect(route.errors.first[1]).to eq('foo has been taken before. Please use another one')
end
end
context 'when no permanent conflicting redirects exist' do
it 'is valid' do
expect(route.valid?).to be_truthy
end
end
end
context 'when path has not changed' do
context 'when permanent conflicting redirects exist' do
it 'is valid' do
redirect = build(:redirect_route, :permanent, path: 'git_lab/foo/bar')
redirect.save!(validate: false)
expect(route.valid?).to be_truthy
end
end
context 'when no permanent conflicting redirects exist' do
it 'is valid' do
expect(route.valid?).to be_truthy
end
end
end
end
end end
describe 'callbacks' do describe 'callbacks' do
......
require 'spec_helper'
require 'rubocop'
require 'rubocop/rspec/support'
require_relative '../../../../rubocop/cop/gitlab/predicate_memoization'
describe RuboCop::Cop::Gitlab::PredicateMemoization do
include CopHelper
subject(:cop) { described_class.new }
shared_examples('registering offense') do |options|
let(:offending_lines) { options[:offending_lines] }
it 'registers an offense when a predicate method is memoizing via ivar' do
inspect_source(source)
aggregate_failures do
expect(cop.offenses.size).to eq(offending_lines.size)
expect(cop.offenses.map(&:line)).to eq(offending_lines)
end
end
end
shared_examples('not registering offense') do
it 'does not register offenses' do
inspect_source(source)
expect(cop.offenses).to be_empty
end
end
context 'when source is a predicate method memoizing via ivar' do
it_behaves_like 'registering offense', offending_lines: [3] do
let(:source) do
<<~RUBY
class C
def really?
@really ||= true
end
end
RUBY
end
end
it_behaves_like 'registering offense', offending_lines: [4] do
let(:source) do
<<~RUBY
class C
def really?
value = true
@really ||= value
end
end
RUBY
end
end
end
context 'when source is a predicate method using ivar with assignment' do
it_behaves_like 'not registering offense' do
let(:source) do
<<~RUBY
class C
def really?
@really = true
end
end
RUBY
end
end
end
context 'when source is a predicate method using local with ||=' do
it_behaves_like 'not registering offense' do
let(:source) do
<<~RUBY
class C
def really?
really ||= true
end
end
RUBY
end
end
end
context 'when source is a regular method memoizing via ivar' do
it_behaves_like 'not registering offense' do
let(:source) do
<<~RUBY
class C
def really
@really ||= true
end
end
RUBY
end
end
end
end
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