Commit d9f3af50 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'master' into 44846-improve-web-ide-left-panel-and-modes

parents fa8d70a9 2d23cb1b
......@@ -9,6 +9,10 @@ terms.
[DCO + License](https://gitlab.com/gitlab-org/dco/blob/master/README.md)
All Documentation content that resides under the [doc/ directory](/doc) of this
repository is licensed under Creative Commons:
[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/).
_This notice should stay as the first item in the CONTRIBUTING.md file._
---
......
......@@ -33,7 +33,7 @@ gem 'grape-route-helpers', '~> 2.1.0'
gem 'faraday', '~> 0.12'
# Authentication libraries
gem 'devise', '~> 4.2'
gem 'devise', '~> 4.4'
gem 'doorkeeper', '~> 4.3'
gem 'doorkeeper-openid_connect', '~> 1.3'
gem 'omniauth', '~> 1.8'
......@@ -41,7 +41,7 @@ gem 'omniauth-auth0', '~> 2.0.0'
gem 'omniauth-azure-oauth2', '~> 0.0.9'
gem 'omniauth-cas3', '~> 1.1.4'
gem 'omniauth-facebook', '~> 4.0.0'
gem 'omniauth-github', '~> 1.1.1'
gem 'omniauth-github', '~> 1.3'
gem 'omniauth-gitlab', '~> 1.0.2'
gem 'omniauth-google-oauth2', '~> 0.5.3'
gem 'omniauth-kerberos', '~> 0.3.0', group: :kerberos
......@@ -90,7 +90,7 @@ gem 'github-linguist', '~> 5.3.3', require: 'linguist'
# API
gem 'grape', '~> 1.0'
gem 'grape-entity', '~> 0.6.0'
gem 'grape-entity', '~> 0.7.1'
gem 'rack-cors', '~> 1.0.0', require: 'rack/cors'
# Disable strong_params so that Mash does not respond to :permitted?
......
......@@ -143,7 +143,7 @@ GEM
connection_pool (2.2.1)
crack (0.4.3)
safe_yaml (~> 1.0.0)
crass (1.0.3)
crass (1.0.4)
creole (0.5.0)
css_parser (1.5.0)
addressable
......@@ -162,10 +162,10 @@ GEM
descendants_tracker (0.0.4)
thread_safe (~> 0.3, >= 0.3.1)
device_detector (1.0.0)
devise (4.2.0)
devise (4.4.3)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0, < 5.1)
railties (>= 4.1.0, < 6.0)
responders
warden (~> 1.2.3)
devise-two-factor (3.0.0)
......@@ -366,8 +366,8 @@ GEM
rack (>= 1.3.0)
rack-accept
virtus (>= 1.0.0)
grape-entity (0.6.0)
activesupport
grape-entity (0.7.1)
activesupport (>= 4.0)
multi_json (>= 1.3.2)
grape-route-helpers (2.1.0)
activesupport
......@@ -546,9 +546,9 @@ GEM
omniauth (~> 1.2)
omniauth-facebook (4.0.0)
omniauth-oauth2 (~> 1.2)
omniauth-github (1.1.2)
omniauth (~> 1.0)
omniauth-oauth2 (~> 1.1)
omniauth-github (1.3.0)
omniauth (~> 1.5)
omniauth-oauth2 (>= 1.4.0, < 2.0)
omniauth-gitlab (1.0.2)
omniauth (~> 1.0)
omniauth-oauth2 (~> 1.0)
......@@ -646,7 +646,7 @@ GEM
pry (>= 0.9.10)
public_suffix (3.0.2)
pyu-ruby-sasl (0.0.3.3)
rack (1.6.9)
rack (1.6.10)
rack-accept (0.4.5)
rack (>= 0.4)
rack-attack (4.4.1)
......@@ -694,7 +694,7 @@ GEM
rainbow (2.2.2)
rake
raindrops (0.18.0)
rake (12.3.0)
rake (12.3.1)
rb-fsevent (0.10.2)
rb-inotify (0.9.10)
ffi (>= 0.5.0, < 2)
......@@ -735,8 +735,9 @@ GEM
declarative-option (< 0.2.0)
uber (< 0.2.0)
request_store (1.3.1)
responders (2.3.0)
railties (>= 4.2.0, < 5.1)
responders (2.4.0)
actionpack (>= 4.2.0, < 5.3)
railties (>= 4.2.0, < 5.3)
rest-client (2.0.2)
http-cookie (>= 1.0.2, < 2.0)
mime-types (>= 1.16, < 4.0)
......@@ -966,7 +967,7 @@ GEM
descendants_tracker (~> 0.0, >= 0.0.3)
equalizer (~> 0.0, >= 0.0.9)
vmstat (2.3.0)
warden (1.2.6)
warden (1.2.7)
rack (>= 1.0)
webmock (2.3.2)
addressable (>= 2.3.6)
......@@ -1028,7 +1029,7 @@ DEPENDENCIES
deckar01-task_list (= 2.0.0)
default_value_for (~> 3.0.0)
device_detector
devise (~> 4.2)
devise (~> 4.4)
devise-two-factor (~> 3.0.0)
diffy (~> 3.1.0)
doorkeeper (~> 4.3)
......@@ -1072,7 +1073,7 @@ DEPENDENCIES
google-protobuf (= 3.5.1)
gpgme
grape (~> 1.0)
grape-entity (~> 0.6.0)
grape-entity (~> 0.7.1)
grape-route-helpers (~> 2.1.0)
grape_logging (~> 1.7)
grpc (~> 1.11.0)
......@@ -1113,7 +1114,7 @@ DEPENDENCIES
omniauth-azure-oauth2 (~> 0.0.9)
omniauth-cas3 (~> 1.1.4)
omniauth-facebook (~> 4.0.0)
omniauth-github (~> 1.1.1)
omniauth-github (~> 1.3)
omniauth-gitlab (~> 1.0.2)
omniauth-google-oauth2 (~> 0.5.3)
omniauth-kerberos (~> 0.3.0)
......
......@@ -5,3 +5,8 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
---
All Documentation content that resides under the doc/ directory of this
repository is licensed under Creative Commons: CC BY-SA 4.0.
......@@ -43,6 +43,7 @@
<div class="environments-container">
<loading-icon
class="prepend-top-default"
label="Loading environments"
v-if="isLoading"
size="3"
......
......@@ -43,7 +43,7 @@ export default {
return `${this.changedIcon}-solid`;
},
changedIconClass() {
return `multi-${this.changedIcon} prepend-left-5 pull-left`;
return `multi-${this.changedIcon} pull-left`;
},
tooltipTitle() {
if (!this.showTooltip) return undefined;
......@@ -79,13 +79,7 @@ export default {
class="ide-file-changed-icon"
>
<icon
v-if="file.staged && showStagedIcon"
:name="stagedIcon"
:size="12"
:css-classes="changedIconClass"
/>
<icon
v-if="file.changed || file.tempFile || (file.staged && !showStagedIcon)"
v-if="file.changed || file.tempFile || file.staged"
:name="changedIcon"
:size="12"
:css-classes="changedIconClass"
......
......@@ -36,7 +36,7 @@ export default {
return this.file.tempFile ? `file-addition${prefix}` : `file-modified${prefix}`;
},
iconClass() {
return `multi-file-${this.file.tempFile ? 'additions' : 'modified'} append-right-8`;
return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`;
},
},
methods: {
......
<script>
import { mapActions } from 'vuex';
import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
import fileIcon from '~/vue_shared/components/file_icon.vue';
import { mapActions, mapGetters } from 'vuex';
import { n__, __, sprintf } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
import Icon from '~/vue_shared/components/icon.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import router from '../ide_router';
import newDropdown from './new_dropdown/index.vue';
import fileStatusIcon from './repo_file_status_icon.vue';
import changedFileIcon from './changed_file_icon.vue';
import mrFileIcon from './mr_file_icon.vue';
import NewDropdown from './new_dropdown/index.vue';
import FileStatusIcon from './repo_file_status_icon.vue';
import ChangedFileIcon from './changed_file_icon.vue';
import MrFileIcon from './mr_file_icon.vue';
export default {
name: 'RepoFile',
directives: {
tooltip,
},
components: {
skeletonLoadingContainer,
newDropdown,
fileStatusIcon,
fileIcon,
changedFileIcon,
mrFileIcon,
SkeletonLoadingContainer,
NewDropdown,
FileStatusIcon,
FileIcon,
ChangedFileIcon,
MrFileIcon,
Icon,
},
props: {
file: {
......@@ -34,6 +41,34 @@ export default {
},
},
computed: {
...mapGetters([
'getChangesInFolder',
'getUnstagedFilesCountForPath',
'getStagedFilesCountForPath',
]),
folderUnstagedCount() {
return this.getUnstagedFilesCountForPath(this.file.path);
},
folderStagedCount() {
return this.getStagedFilesCountForPath(this.file.path);
},
changesCount() {
return this.getChangesInFolder(this.file.path);
},
folderChangesTooltip() {
if (this.changesCount === 0) return undefined;
if (this.folderUnstagedCount > 0 && this.folderStagedCount === 0) {
return n__('%d unstaged change', '%d unstaged changes', this.folderUnstagedCount);
} else if (this.folderUnstagedCount === 0 && this.folderStagedCount > 0) {
return n__('%d staged change', '%d staged changes', this.folderStagedCount);
}
return sprintf(__('%{unstaged} unstaged and %{staged} staged changes'), {
unstaged: this.folderUnstagedCount,
staged: this.folderStagedCount,
});
},
isTree() {
return this.file.type === 'tree';
},
......@@ -53,10 +88,19 @@ export default {
'is-open': this.file.opened,
};
},
showTreeChangesCount() {
return this.isTree && this.changesCount > 0 && !this.file.opened;
},
showChangedFileIcon() {
return this.file.changed || this.file.tempFile || this.file.staged;
},
},
updated() {
if (this.file.type === 'blob' && this.file.active) {
this.$el.scrollIntoView();
this.$el.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
},
methods: {
......@@ -104,8 +148,23 @@ export default {
<mr-file-icon
v-if="file.mrChange"
/>
<span
v-if="showTreeChangesCount"
class="ide-tree-changes"
>
{{ changesCount }}
<icon
v-tooltip
:title="folderChangesTooltip"
data-container="body"
data-placement="right"
name="file-modified"
:size="12"
css-classes="prepend-left-5 multi-file-modified"
/>
</span>
<changed-file-icon
v-if="file.changed || file.tempFile || file.staged"
v-else-if="showChangedFileIcon"
:file="file"
:show-tooltip="true"
:show-staged-icon="true"
......
<<<<<<< HEAD
import { activityBarViews } from '../constants';
=======
import { __ } from '~/locale';
import { getChangesCountForFiles, filePathMatches } from './utils';
>>>>>>> master
export const activeFile = state => state.openFiles.find(file => file.active) || null;
......@@ -52,11 +57,27 @@ export const allBlobs = state =>
}, [])
.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
export const getChangedFile = state => path => state.changedFiles.find(f => f.path === path);
export const getStagedFile = state => path => state.stagedFiles.find(f => f.path === path);
export const isEditModeActive = state => state.currentActivityView === activityBarViews.edit;
export const isCommitModeActive = state => state.currentActivityView === activityBarViews.commit;
export const isReviewModeActive = state => state.currentActivityView === activityBarViews.review;
export const getChangesInFolder = state => path => {
const changedFilesCount = state.changedFiles.filter(f => filePathMatches(f, path)).length;
const stagedFilesCount = state.stagedFiles.filter(
f => filePathMatches(f, path) && !getChangedFile(state)(f.path),
).length;
return changedFilesCount + stagedFilesCount;
};
export const getUnstagedFilesCountForPath = state => path =>
getChangesCountForFiles(state.changedFiles, path);
export const getStagedFilesCountForPath = state => path =>
getChangesCountForFiles(state.stagedFiles, path);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -33,7 +33,6 @@ export const dataStructure = () => ({
raw: '',
content: '',
parentTreeUrl: '',
parentPath: '',
renderError: false,
base64: false,
editorRow: 1,
......@@ -43,6 +42,7 @@ export const dataStructure = () => ({
viewMode: 'edit',
previewMode: null,
size: 0,
parentPath: null,
lastOpenedAt: 0,
});
......@@ -83,7 +83,6 @@ export const decorateData = entity => {
opened,
active,
parentTreeUrl,
parentPath,
changed,
renderError,
content,
......@@ -91,6 +90,7 @@ export const decorateData = entity => {
previewMode,
file_lock,
html,
parentPath,
};
};
......@@ -137,3 +137,9 @@ export const sortTree = sortedTree =>
}),
)
.sort(sortTreesByTypeAndName);
export const filePathMatches = (f, path) =>
f.path.replace(new RegExp(`${f.name}$`), '').indexOf(`${path}/`) === 0;
export const getChangesCountForFiles = (files, path) =>
files.filter(f => filePathMatches(f, path)).length;
......@@ -70,6 +70,9 @@
toggleMoreParticipants() {
this.isShowingMoreParticipants = !this.isShowingMoreParticipants;
},
onClickCollapsedIcon() {
this.$emit('toggleSidebar');
},
},
};
</script>
......@@ -82,6 +85,7 @@
data-container="body"
data-placement="left"
:title="participantLabel"
@click="onClickCollapsedIcon"
>
<i
class="fa fa-users"
......
<script>
import Store from '../../stores/sidebar_store';
import eventHub from '../../event_hub';
import Flash from '../../../flash';
import { __ } from '../../../locale';
import subscriptions from './subscriptions.vue';
......@@ -20,12 +19,6 @@ export default {
store: new Store(),
};
},
created() {
eventHub.$on('toggleSubscription', this.onToggleSubscription);
},
beforeDestroy() {
eventHub.$off('toggleSubscription', this.onToggleSubscription);
},
methods: {
onToggleSubscription() {
this.mediator.toggleSubscription()
......@@ -42,6 +35,7 @@ export default {
<subscriptions
:loading="store.isFetching.subscriptions"
:subscribed="store.subscribed"
@toggleSubscription="onToggleSubscription"
/>
</div>
</template>
......@@ -47,8 +47,25 @@
},
},
methods: {
/**
* We need to emit this event on both component & eventHub
* for 2 dependencies;
*
* 1. eventHub: This component is used in Issue Boards sidebar
* where component template is part of HAML
* and event listeners are tied to app's eventHub.
* 2. Component: This compone is also used in Epics in EE
* where listeners are tied to component event.
*/
toggleSubscription() {
// App's eventHub event emission.
eventHub.$emit('toggleSubscription', this.id);
// Component event emission.
this.$emit('toggleSubscription', this.id);
},
onClickCollapsedIcon() {
this.$emit('toggleSidebar');
},
},
};
......@@ -56,7 +73,10 @@
<template>
<div>
<div class="sidebar-collapsed-icon">
<div
class="sidebar-collapsed-icon"
@click="onClickCollapsedIcon"
>
<span
v-tooltip
:title="notificationTooltip"
......
<script>
export default {
name: 'time-tracking-spent-only-pane',
name: 'TimeTrackingSpentOnlyPane',
props: {
timeSpentHumanReadable: {
type: String,
required: true,
},
},
template: `
};
</script>
<template>
<div class="time-tracking-spend-only-pane">
<span class="bold">Spent:</span>
{{ timeSpentHumanReadable }}
</div>
`,
};
</template>
<script>
import TimeTrackingHelpState from './help_state.vue';
import TimeTrackingCollapsedState from './collapsed_state.vue';
import timeTrackingSpentOnlyPane from './spent_only_pane';
import TimeTrackingSpentOnlyPane from './spent_only_pane.vue';
import TimeTrackingNoTrackingPane from './no_tracking_pane.vue';
import TimeTrackingEstimateOnlyPane from './estimate_only_pane.vue';
import TimeTrackingComparisonPane from './comparison_pane.vue';
......@@ -13,7 +13,7 @@ export default {
components: {
TimeTrackingCollapsedState,
TimeTrackingEstimateOnlyPane,
'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane,
TimeTrackingSpentOnlyPane,
TimeTrackingNoTrackingPane,
TimeTrackingComparisonPane,
TimeTrackingHelpState,
......
......@@ -317,6 +317,7 @@
a {
color: $gl-text-color;
word-wrap: break-word;
word-break: break-word;
margin-right: 2px;
}
}
......@@ -462,6 +463,7 @@
.issuable-header-text {
padding-right: 35px;
word-break: break-word;
> strong {
font-weight: $gl-font-weight-bold;
......
......@@ -539,14 +539,14 @@
}
}
.multi-file-additions,
.multi-file-additions-solid {
fill: $green-500;
.multi-file-addition,
.multi-file-addition-solid {
color: $green-500;
}
.multi-file-modified,
.multi-file-modified-solid {
fill: $orange-500;
color: $orange-500;
}
.multi-file-commit-list-collapsed {
......@@ -978,6 +978,12 @@
color: $gl-text-color-secondary;
}
.ide-tree-changes {
display: flex;
align-items: center;
font-size: 12px;
}
.ide-new-modal-label {
line-height: 34px;
}
......@@ -52,6 +52,12 @@ class Projects::RunnersController < Projects::ApplicationController
redirect_to project_settings_ci_cd_path(@project)
end
def toggle_group_runners
project.toggle_ci_cd_settings!(:group_runners_enabled)
redirect_to project_settings_ci_cd_path(@project)
end
protected
def set_runner
......
......@@ -67,10 +67,18 @@ module Projects
def define_runners_variables
@project_runners = @project.runners.ordered
@assignable_runners = current_user.ci_authorized_runners
.assignable_for(project).ordered.page(params[:page]).per(20)
@assignable_runners = current_user
.ci_authorized_runners
.assignable_for(project)
.ordered
.page(params[:page]).per(20)
@shared_runners = ::Ci::Runner.shared.active
@shared_runners_count = @shared_runners.count(:all)
@group_runners = ::Ci::Runner.belonging_to_parent_group_of_project(@project.id)
end
def define_secret_variables
......
......@@ -14,31 +14,49 @@ module Ci
has_many :builds
has_many :runner_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :projects, through: :runner_projects
has_many :runner_namespaces
has_many :groups, through: :runner_namespaces
has_one :last_build, ->() { order('id DESC') }, class_name: 'Ci::Build'
before_validation :set_default_values
scope :specific, ->() { where(is_shared: false) }
scope :shared, ->() { where(is_shared: true) }
scope :active, ->() { where(active: true) }
scope :paused, ->() { where(active: false) }
scope :online, ->() { where('contacted_at > ?', contact_time_deadline) }
scope :ordered, ->() { order(id: :desc) }
scope :specific, -> { where(is_shared: false) }
scope :shared, -> { where(is_shared: true) }
scope :active, -> { where(active: true) }
scope :paused, -> { where(active: false) }
scope :online, -> { where('contacted_at > ?', contact_time_deadline) }
scope :ordered, -> { order(id: :desc) }
scope :owned_or_shared, ->(project_id) do
joins('LEFT JOIN ci_runner_projects ON ci_runner_projects.runner_id = ci_runners.id')
.where("ci_runner_projects.project_id = :project_id OR ci_runners.is_shared = true", project_id: project_id)
scope :belonging_to_project, -> (project_id) {
joins(:runner_projects).where(ci_runner_projects: { project_id: project_id })
}
scope :belonging_to_parent_group_of_project, -> (project_id) {
project_groups = ::Group.joins(:projects).where(projects: { id: project_id })
hierarchy_groups = Gitlab::GroupHierarchy.new(project_groups).base_and_ancestors
joins(:groups).where(namespaces: { id: hierarchy_groups })
}
scope :owned_or_shared, -> (project_id) do
union = Gitlab::SQL::Union.new(
[belonging_to_project(project_id), belonging_to_parent_group_of_project(project_id), shared],
remove_duplicates: false
)
from("(#{union.to_sql}) ci_runners")
end
scope :assignable_for, ->(project) do
# FIXME: That `to_sql` is needed to workaround a weird Rails bug.
# Without that, placeholders would miss one and couldn't match.
where(locked: false)
.where.not("id IN (#{project.runners.select(:id).to_sql})").specific
.where.not("ci_runners.id IN (#{project.runners.select(:id).to_sql})")
.specific
end
validate :tag_constraints
validate :either_projects_or_group
validates :access_level, presence: true
acts_as_taggable
......@@ -50,6 +68,12 @@ module Ci
ref_protected: 1
}
enum runner_type: {
instance_type: 1,
group_type: 2,
project_type: 3
}
cached_attr_reader :version, :revision, :platform, :architecture, :contacted_at, :ip_address
chronic_duration_attr :maximum_timeout_human_readable, :maximum_timeout
......@@ -120,6 +144,14 @@ module Ci
!shared?
end
def assigned_to_group?
runner_namespaces.any?
end
def assigned_to_project?
runner_projects.any?
end
def can_pick?(build)
return false if self.ref_protected? && !build.protected?
......@@ -174,6 +206,12 @@ module Ci
end
end
def pick_build!(build)
if can_pick?(build)
tick_runner_queue
end
end
private
def cleanup_runner_queue
......@@ -205,7 +243,17 @@ module Ci
end
def assignable_for?(project_id)
is_shared? || projects.exists?(id: project_id)
self.class.owned_or_shared(project_id).where(id: self.id).any?
end
def either_projects_or_group
if groups.many?
errors.add(:runner, 'can only be assigned to one group')
end
if assigned_to_group? && assigned_to_project?
errors.add(:runner, 'can only be assigned either to projects or to a group')
end
end
def accepting_tags?(build)
......
module Ci
class RunnerNamespace < ActiveRecord::Base
extend Gitlab::Ci::Model
belongs_to :runner
belongs_to :namespace, class_name: '::Namespace'
belongs_to :group, class_name: '::Group', foreign_key: :namespace_id
end
end
......@@ -9,6 +9,7 @@ class Group < Namespace
include SelectForProjectAuthorization
include LoadedInGroupList
include GroupDescendant
include TokenAuthenticatable
has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
alias_method :members, :group_members
......@@ -43,6 +44,8 @@ class Group < Namespace
validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 }
add_authentication_token_field :runners_token
after_create :post_create_hook
after_destroy :post_destroy_hook
after_save :update_two_factor_requirement
......@@ -294,6 +297,13 @@ class Group < Namespace
refresh_members_authorized_projects(blocking: false)
end
# each existing group needs to have a `runners_token`.
# we do this on read since migrating all existing groups is not a feasible
# solution.
def runners_token
ensure_runners_token!
end
private
def update_two_factor_requirement
......
......@@ -21,6 +21,9 @@ class Namespace < ActiveRecord::Base
has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :project_statistics
has_many :runner_namespaces, class_name: 'Ci::RunnerNamespace'
has_many :runners, through: :runner_namespaces, source: :runner, class_name: 'Ci::Runner'
# This should _not_ be `inverse_of: :namespace`, because that would also set
# `user.namespace` when this user creates a group with themselves as `owner`.
belongs_to :owner, class_name: "User"
......
......@@ -230,13 +230,11 @@ class Project < ActiveRecord::Base
has_many :project_deploy_tokens
has_many :deploy_tokens, through: :project_deploy_tokens
has_many :active_runners, -> { active }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
has_one :auto_devops, class_name: 'ProjectAutoDevops'
has_many :custom_attributes, class_name: 'ProjectCustomAttribute'
has_many :project_badges, class_name: 'ProjectBadge'
has_one :ci_cd_settings, class_name: 'ProjectCiCdSetting'
has_one :ci_cd_settings, class_name: 'ProjectCiCdSetting', inverse_of: :project, autosave: true
accepts_nested_attributes_for :variables, allow_destroy: true
accepts_nested_attributes_for :project_feature, update_only: true
......@@ -247,6 +245,7 @@ class Project < ActiveRecord::Base
delegate :members, to: :team, prefix: true
delegate :add_user, :add_users, to: :team
delegate :add_guest, :add_reporter, :add_developer, :add_master, :add_role, to: :team
delegate :group_runners_enabled, :group_runners_enabled=, :group_runners_enabled?, to: :ci_cd_settings
# Validations
validates :creator, presence: true, on: :create
......@@ -332,6 +331,11 @@ class Project < ActiveRecord::Base
scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) }
scope :with_merge_requests_enabled, -> { with_feature_enabled(:merge_requests) }
scope :with_group_runners_enabled, -> do
joins(:ci_cd_settings)
.where(project_ci_cd_settings: { group_runners_enabled: true })
end
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
chronic_duration_attr :build_timeout_human_readable, :build_timeout, default: 3600
......@@ -1301,12 +1305,17 @@ class Project < ActiveRecord::Base
@shared_runners ||= shared_runners_available? ? Ci::Runner.shared : Ci::Runner.none
end
def active_shared_runners
@active_shared_runners ||= shared_runners.active
def group_runners
@group_runners ||= group_runners_enabled? ? Ci::Runner.belonging_to_parent_group_of_project(self.id) : Ci::Runner.none
end
def all_runners
union = Gitlab::SQL::Union.new([runners, group_runners, shared_runners])
Ci::Runner.from("(#{union.to_sql}) ci_runners")
end
def any_runners?(&block)
active_runners.any?(&block) || active_shared_runners.any?(&block)
all_runners.active.any?(&block)
end
def valid_runners_token?(token)
......@@ -1874,6 +1883,10 @@ class Project < ActiveRecord::Base
[]
end
def toggle_ci_cd_settings!(settings_attribute)
ci_cd_settings.toggle!(settings_attribute)
end
def gitlab_deploy_token
@gitlab_deploy_token ||= deploy_tokens.gitlab_deploy_token
end
......
class ProjectCiCdSetting < ActiveRecord::Base
belongs_to :project
belongs_to :project, inverse_of: :ci_cd_settings
# The version of the schema that first introduced this model/table.
MINIMUM_SCHEMA_VERSION = 20180403035759
......
......@@ -17,8 +17,10 @@ module Ci
builds =
if runner.shared?
builds_for_shared_runner
elsif runner.group_type?
builds_for_group_runner
else
builds_for_specific_runner
builds_for_project_runner
end
valid = true
......@@ -82,8 +84,17 @@ module Ci
.order('COALESCE(project_builds.running_builds, 0) ASC', 'ci_builds.id ASC')
end
def builds_for_specific_runner
new_builds.where(project: runner.projects.without_deleted.with_builds_enabled).order('created_at ASC')
def builds_for_project_runner
new_builds.where(project: runner.projects.without_deleted.with_builds_enabled).order('id ASC')
end
def builds_for_group_runner
hierarchy_groups = Gitlab::GroupHierarchy.new(runner.groups).base_and_descendants
projects = Project.where(namespace_id: hierarchy_groups)
.with_group_runners_enabled
.with_builds_enabled
.without_deleted
new_builds.where(project: projects).order('id ASC')
end
def running_builds_for_shared_runners
......@@ -97,10 +108,6 @@ module Ci
builds
end
def shared_runner_build_limits_feature_enabled?
ENV['DISABLE_SHARED_RUNNER_BUILD_MINUTES_LIMIT'].to_s != 'true'
end
def register_failure
failed_attempt_counter.increment
attempt_counter.increment
......
module Ci
class UpdateBuildQueueService
def execute(build)
build.project.runners.each do |runner|
if runner.can_pick?(build)
runner.tick_runner_queue
end
tick_for(build, build.project.all_runners)
end
return unless build.project.shared_runners_enabled?
private
Ci::Runner.shared.each do |runner|
if runner.can_pick?(build)
runner.tick_runner_queue
end
def tick_for(build, runners)
runners.each do |runner|
runner.pick_build!(build)
end
end
end
......
......@@ -2,6 +2,8 @@
%td
- if runner.shared?
%span.label.label-success shared
- elsif runner.group_type?
%span.label.label-success group
- else
%span.label.label-info specific
- if runner.locked?
......@@ -19,7 +21,7 @@
%td
= runner.ip_address
%td
- if runner.shared?
- if runner.shared? || runner.group_type?
n/a
- else
= runner.projects.count(:all)
......
......@@ -16,6 +16,9 @@
%li
%span.label.label-success shared
\- Runner runs jobs from all unassigned projects
%li
%span.label.label-success group
\- Runner runs jobs from all unassigned projects in its group
%li
%span.label.label-info specific
\- Runner runs jobs from assigned projects
......
......@@ -19,6 +19,9 @@
%p
If you want Runners to build only specific projects, enable them in the table below.
Keep in mind that this is a one way transition.
- elsif @runner.group_type?
.bs-callout.bs-callout-success
%h4 This runner will process jobs from all projects in its group and subgroups
- else
.bs-callout.bs-callout-info
%h4 This Runner will process jobs only from ASSIGNED projects
......
......@@ -42,31 +42,31 @@
= nav_link(html_options: { class: active_when(params[:filter].nil?) }) do
= link_to admin_users_path do
Active
%small.badge= number_with_delimiter(User.active.count)
%small.badge= limited_counter_with_delimiter(User.active)
= nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do
= link_to admin_users_path(filter: "admins") do
Admins
%small.badge= number_with_delimiter(User.admins.count)
%small.badge= limited_counter_with_delimiter(User.admins)
= nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do
= link_to admin_users_path(filter: 'two_factor_enabled') do
2FA Enabled
%small.badge= number_with_delimiter(User.with_two_factor.count)
%small.badge= limited_counter_with_delimiter(User.with_two_factor)
= nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do
= link_to admin_users_path(filter: 'two_factor_disabled') do
2FA Disabled
%small.badge= number_with_delimiter(User.without_two_factor.count)
%small.badge= limited_counter_with_delimiter(User.without_two_factor)
= nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do
= link_to admin_users_path(filter: 'external') do
External
%small.badge= number_with_delimiter(User.external.count)
%small.badge= limited_counter_with_delimiter(User.external)
= nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do
= link_to admin_users_path(filter: "blocked") do
Blocked
%small.badge= number_with_delimiter(User.blocked.count)
%small.badge= limited_counter_with_delimiter(User.blocked)
= nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do
= link_to admin_users_path(filter: "wop") do
Without projects
%small.badge= number_with_delimiter(User.without_projects.count)
%small.badge= limited_counter_with_delimiter(User.without_projects)
%ul.flex-list.content-list
- if @users.empty?
......
- active_tab = local_assigns.fetch(:active_tab, 'blank')
- f = local_assigns.fetch(:f)
.project-import.row
.col-lg-12
.form-group.import-btn-container.clearfix
= f.label :visibility_level, class: 'label-light' do #the label here seems wrong
Import project from
.import-buttons
- if gitlab_project_import_enabled?
.import_gitlab_project.has-tooltip{ data: { container: 'body' } }
= link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do
= icon('gitlab', text: 'GitLab export')
%div
- if github_import_enabled?
= link_to new_import_github_path, class: 'btn js-import-github' do
= icon('github', text: 'GitHub')
%div
- if bitbucket_import_enabled?
= link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}" do
= icon('bitbucket', text: 'Bitbucket')
- unless bitbucket_import_configured?
= render 'bitbucket_import_modal'
%div
- if gitlab_import_enabled?
= link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}" do
= icon('gitlab', text: 'GitLab.com')
- unless gitlab_import_configured?
= render 'gitlab_import_modal'
%div
- if google_code_import_enabled?
= link_to new_import_google_code_path, class: 'btn import_google_code' do
= icon('google', text: 'Google Code')
%div
- if fogbugz_import_enabled?
= link_to new_import_fogbugz_path, class: 'btn import_fogbugz' do
= icon('bug', text: 'Fogbugz')
%div
- if gitea_import_enabled?
= link_to new_import_gitea_path, class: 'btn import_gitea' do
= custom_icon('go_logo')
Gitea
%div
- if git_import_enabled?
%button.btn.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active' } }
= icon('git', text: 'Repo by URL')
.col-lg-12
.js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') }
%hr
= render "shared/import_form", f: f
= render 'new_project_fields', f: f, project_name_id: "import-url-name"
......@@ -57,54 +57,11 @@
.tab-pane.import-project-pane.js-toggle-container{ id: 'import-project-pane', class: active_when(active_tab == 'import'), role: 'tabpanel' }
= form_for @project, html: { class: 'new_project' } do |f|
- if import_sources_enabled?
.project-import.row
.col-lg-12
.form-group.import-btn-container.clearfix
= f.label :visibility_level, class: 'label-light' do #the label here seems wrong
Import project from
.import-buttons
- if gitlab_project_import_enabled?
.import_gitlab_project.has-tooltip{ data: { container: 'body' } }
= link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do
= icon('gitlab', text: 'GitLab export')
%div
- if github_import_enabled?
= link_to new_import_github_path, class: 'btn js-import-github' do
= icon('github', text: 'GitHub')
%div
- if bitbucket_import_enabled?
= link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}" do
= icon('bitbucket', text: 'Bitbucket')
- unless bitbucket_import_configured?
= render 'bitbucket_import_modal'
%div
- if gitlab_import_enabled?
= link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}" do
= icon('gitlab', text: 'GitLab.com')
- unless gitlab_import_configured?
= render 'gitlab_import_modal'
%div
- if google_code_import_enabled?
= link_to new_import_google_code_path, class: 'btn import_google_code' do
= icon('google', text: 'Google Code')
%div
- if fogbugz_import_enabled?
= link_to new_import_fogbugz_path, class: 'btn import_fogbugz' do
= icon('bug', text: 'Fogbugz')
%div
- if gitea_import_enabled?
= link_to new_import_gitea_path, class: 'btn import_gitea' do
= custom_icon('go_logo')
Gitea
%div
- if git_import_enabled?
%button.btn.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active' } }
= icon('git', text: 'Repo by URL')
.col-lg-12
.js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') }
%hr
= render "shared/import_form", f: f
= render 'new_project_fields', f: f, project_name_id: "import-url-name"
= render 'import_project_pane', f: f, active_tab: active_tab
- else
.nothing-here-block
%h4 No import options available
%p Contact an administrator to enable options for importing your project.
.save-project-loader.hide
.center
......
%h3 Group Runners
.bs-callout.bs-callout-warning
GitLab Group Runners can execute code for all the projects in this group.
They can be managed using the #{link_to 'Runners API', help_page_path('api/runners.md')}.
- if @project.group
%hr
- if @project.group_runners_enabled?
= link_to toggle_group_runners_project_runners_path(@project), class: 'btn btn-warning', method: :post do
Disable group Runners
- else
= link_to toggle_group_runners_project_runners_path(@project), class: 'btn btn-success', method: :post do
Enable group Runners
&nbsp; for this project
- if !@project.group
This project does not belong to a group and can therefore not make use of group Runners.
- elsif @group_runners.empty?
This group does not provide any group Runners yet.
- if can?(current_user, :admin_pipeline, @project.group)
= render partial: 'ci/runner/how_to_setup_runner',
locals: { registration_token: @project.group.runners_token, type: 'group' }
- else
Ask your group master to setup a group Runner.
- else
%h4.underlined-title Available group Runners : #{@group_runners.count}
%ul.bordered-list
= render partial: 'projects/runners/runner', collection: @group_runners, as: :runner
......@@ -23,3 +23,7 @@
= render 'projects/runners/specific_runners'
.col-sm-6
= render 'projects/runners/shared_runners'
.row
.col-sm-6
.col-sm-6
= render 'projects/runners/group_runners'
......@@ -26,7 +26,7 @@
- else
- runner_project = @project.runner_projects.find_by(runner_id: runner)
= link_to 'Disable for this project', project_runner_project_path(@project, runner_project), data: { confirm: "Are you sure?" }, method: :delete, class: 'btn btn-danger btn-sm'
- elsif runner.specific?
- elsif !(runner.is_shared? || runner.group_type?) # We can simplify this to `runner.project_type?` when migrating #runner_type is complete
= form_for [@project.namespace.becomes(Namespace), @project, @project.runner_projects.new] do |f|
= f.hidden_field :runner_id, value: runner.id
= f.submit 'Enable for this project', class: 'btn btn-sm'
......
......@@ -35,7 +35,7 @@
= link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
= image_tag avatar_icon_for_user(@user, 90), class: "avatar s90", alt: ''
.user-info
.user-info.prepend-left-default.append-right-default
.cover-title
= @user.name
......
---
title: Reconcile project templates with Auto DevOps
merge_request: 18737
author:
type: changed
---
title: Add loading icon padding for pipeline environments
merge_request: 18631
author: George Tsiolis
type: fixed
---
title: Add padding to profile description
merge_request: 18663
author: George Tsiolis
type: changed
---
title: Break issue title for board card title and issuable header text
merge_request: 18674
author: George Tsiolis
type: changed
---
title: Output some useful information when running the rails console
merge_request: 18697
author:
type: added
---
title: Allow group masters to configure runners for groups
merge_request: 9646
author: Alexis Reigel
type: added
---
title: Inform the user when there are no project import options available
merge_request: 18716
author: George Tsiolis
type: changed
---
title: Move TimeTrackingSpentOnlyPane vue component
merge_request: 18710
author: George Tsiolis
type: performance
# rubocop:disable Rails/Output
if defined?(Rails::Console)
# note that this will not print out when using `spring`
justify = 15
puts "-------------------------------------------------------------------------------------"
puts " Gitlab:".ljust(justify) + "#{Gitlab::VERSION} (#{Gitlab::REVISION})"
puts " Gitlab Shell:".ljust(justify) + Gitlab::Shell.new.version
puts " #{Gitlab::Database.adapter_name}:".ljust(justify) + Gitlab::Database.version
puts "-------------------------------------------------------------------------------------"
end
......@@ -22,3 +22,16 @@ end.compact
Rails.application.config.action_dispatch.trusted_proxies = (
['127.0.0.1', '::1'] + gitlab_trusted_proxies)
# A monkey patch to make trusted proxies work with Rails 5.0.
# Inspired by https://github.com/rails/rails/issues/5223#issuecomment-263778719
# Remove this monkey patch when upstream is fixed.
if Gitlab.rails5?
module TrustedProxyMonkeyPatch
def ip
@ip ||= (get_header("action_dispatch.remote_ip") || super).to_s
end
end
ActionDispatch::Request.send(:include, TrustedProxyMonkeyPatch)
end
......@@ -409,6 +409,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
collection do
post :toggle_shared_runners
post :toggle_group_runners
end
end
......
class AddCiRunnerNamespaces < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
create_table :ci_runner_namespaces do |t|
t.integer :runner_id
t.integer :namespace_id
t.index [:runner_id, :namespace_id], unique: true
t.index :namespace_id
t.foreign_key :ci_runners, column: :runner_id, on_delete: :cascade
t.foreign_key :namespaces, column: :namespace_id, on_delete: :cascade
end
end
end
class AddRunnersTokenToGroups < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
add_column :namespaces, :runners_token, :string
end
end
class AddRunnerTypeToCiRunners < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
add_column :ci_runners, :runner_type, :smallint
end
end
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddIndexToNamespacesRunnersToken < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_index :namespaces, :runners_token, unique: true
end
def down
if index_exists?(:namespaces, :runners_token, unique: true)
remove_index :namespaces, :runners_token
end
end
end
class BackfillRunnerTypeForCiRunnersPostMigrate < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
INSTANCE_RUNNER_TYPE = 1
PROJECT_RUNNER_TYPE = 3
disable_ddl_transaction!
def up
update_column_in_batches(:ci_runners, :runner_type, INSTANCE_RUNNER_TYPE) do |table, query|
query.where(table[:is_shared].eq(true)).where(table[:runner_type].eq(nil))
end
update_column_in_batches(:ci_runners, :runner_type, PROJECT_RUNNER_TYPE) do |table, query|
query.where(table[:is_shared].eq(false)).where(table[:runner_type].eq(nil))
end
end
def down
end
end
......@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20180425131009) do
ActiveRecord::Schema.define(version: 20180503150427) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
......@@ -444,6 +444,14 @@ ActiveRecord::Schema.define(version: 20180425131009) do
add_index "ci_pipelines", ["status"], name: "index_ci_pipelines_on_status", using: :btree
add_index "ci_pipelines", ["user_id"], name: "index_ci_pipelines_on_user_id", using: :btree
create_table "ci_runner_namespaces", force: :cascade do |t|
t.integer "runner_id"
t.integer "namespace_id"
end
add_index "ci_runner_namespaces", ["namespace_id"], name: "index_ci_runner_namespaces_on_namespace_id", using: :btree
add_index "ci_runner_namespaces", ["runner_id", "namespace_id"], name: "index_ci_runner_namespaces_on_runner_id_and_namespace_id", unique: true, using: :btree
create_table "ci_runner_projects", force: :cascade do |t|
t.integer "runner_id", null: false
t.datetime "created_at"
......@@ -472,6 +480,7 @@ ActiveRecord::Schema.define(version: 20180425131009) do
t.integer "access_level", default: 0, null: false
t.string "ip_address"
t.integer "maximum_timeout"
t.integer "runner_type", limit: 2
end
add_index "ci_runners", ["contacted_at"], name: "index_ci_runners_on_contacted_at", using: :btree
......@@ -1261,6 +1270,7 @@ ActiveRecord::Schema.define(version: 20180425131009) do
t.boolean "require_two_factor_authentication", default: false, null: false
t.integer "two_factor_grace_period", default: 48, null: false
t.integer "cached_markdown_version"
t.string "runners_token"
end
add_index "namespaces", ["created_at"], name: "index_namespaces_on_created_at", using: :btree
......@@ -1271,6 +1281,7 @@ ActiveRecord::Schema.define(version: 20180425131009) do
add_index "namespaces", ["path"], name: "index_namespaces_on_path", using: :btree
add_index "namespaces", ["path"], name: "index_namespaces_on_path_trigram", using: :gin, opclasses: {"path"=>"gin_trgm_ops"}
add_index "namespaces", ["require_two_factor_authentication"], name: "index_namespaces_on_require_two_factor_authentication", using: :btree
add_index "namespaces", ["runners_token"], name: "index_namespaces_on_runners_token", unique: true, using: :btree
add_index "namespaces", ["type"], name: "index_namespaces_on_type", using: :btree
create_table "notes", force: :cascade do |t|
......@@ -2087,6 +2098,8 @@ ActiveRecord::Schema.define(version: 20180425131009) do
add_foreign_key "ci_pipelines", "ci_pipeline_schedules", column: "pipeline_schedule_id", name: "fk_3d34ab2e06", on_delete: :nullify
add_foreign_key "ci_pipelines", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_262d4c2d19", on_delete: :nullify
add_foreign_key "ci_pipelines", "projects", name: "fk_86635dbd80", on_delete: :cascade
add_foreign_key "ci_runner_namespaces", "ci_runners", column: "runner_id", on_delete: :cascade
add_foreign_key "ci_runner_namespaces", "namespaces", on_delete: :cascade
add_foreign_key "ci_runner_projects", "projects", name: "fk_4478a6f1e4", on_delete: :cascade
add_foreign_key "ci_stages", "ci_pipelines", column: "pipeline_id", name: "fk_fb57e6cc56", on_delete: :cascade
add_foreign_key "ci_stages", "projects", name: "fk_2360681d1d", on_delete: :cascade
......
......@@ -310,7 +310,7 @@ Please check this [rules][eslint-plugin-vue-rules] for more documentation.
}));
```
1. Don not use a singleton for the service or the store
1. Do not use a singleton for the service or the store
```javascript
// bad
class Store {
......@@ -328,9 +328,11 @@ Please check this [rules][eslint-plugin-vue-rules] for more documentation.
}
}
```
1. Use `.vue` for Vue templates. Do not use `%template` in HAML.
#### Naming
1. **Extensions**: Use `.vue` extension for Vue components.
1. **Extensions**: Use `.vue` extension for Vue components. Do not use `.js` as file extension ([#34371]).
1. **Reference Naming**: Use PascalCase for their instances:
```javascript
// bad
......@@ -364,6 +366,8 @@ Please check this [rules][eslint-plugin-vue-rules] for more documentation.
<component my-prop="prop" />
```
[#34371]: https://gitlab.com/gitlab-org/gitlab-ce/issues/34371
#### Alignment
1. Follow these alignment styles for the template method:
1. With more than one attribute, all attributes should be on a new line:
......
......@@ -5,10 +5,6 @@ module SharedGroup
is_member_of(current_user.name, "Owned", Gitlab::Access::DEVELOPER)
end
step '"John Doe" is owner of group "Owned"' do
is_member_of("John Doe", "Owned", Gitlab::Access::OWNER)
end
step '"John Doe" is guest of group "Guest"' do
is_member_of("John Doe", "Guest", Gitlab::Access::GUEST)
end
......
......@@ -48,10 +48,6 @@ module SharedPaths
visit group_group_members_path(Group.find_by(name: "Owned"))
end
step 'I visit group "Owned" settings page' do
visit edit_group_path(Group.find_by(name: "Owned"))
end
step 'I visit group "Owned" projects page' do
visit projects_group_path(Group.find_by(name: "Owned"))
end
......
......@@ -149,11 +149,11 @@ module API
expose_url(api_v4_projects_path(id: project.id))
end
expose :issues, if: -> (*args) { issues_available?(*args) } do |project|
expose :issues, if: -> (project, options) { issues_available?(project, options) } do |project|
expose_url(api_v4_projects_issues_path(id: project.id))
end
expose :merge_requests, if: -> (*args) { mrs_available?(*args) } do |project|
expose :merge_requests, if: -> (project, options) { mrs_available?(project, options) } do |project|
expose_url(api_v4_projects_merge_requests_path(id: project.id))
end
......@@ -242,13 +242,18 @@ module API
expose :requested_at
end
class Group < Grape::Entity
expose :id, :name, :path, :description, :visibility
class BasicGroupDetails < Grape::Entity
expose :id
expose :web_url
expose :name
end
class Group < BasicGroupDetails
expose :path, :description, :visibility
expose :lfs_enabled?, as: :lfs_enabled
expose :avatar_url do |group, options|
group.avatar_url(only_path: false)
end
expose :web_url
expose :request_access_enabled
expose :full_name, :full_path
......@@ -984,6 +989,13 @@ module API
options[:current_user].authorized_projects.where(id: runner.projects)
end
end
expose :groups, with: Entities::BasicGroupDetails do |runner, options|
if options[:current_user].admin?
runner.groups
else
options[:current_user].authorized_groups.where(id: runner.groups)
end
end
end
class RunnerRegistrationDetails < Grape::Entity
......
......@@ -23,10 +23,13 @@ module API
runner =
if runner_registration_token_valid?
# Create shared runner. Requires admin access
Ci::Runner.create(attributes.merge(is_shared: true))
Ci::Runner.create(attributes.merge(is_shared: true, runner_type: :instance_type))
elsif project = Project.find_by(runners_token: params[:token])
# Create a specific runner for project.
project.runners.create(attributes)
# Create a specific runner for the project
project.runners.create(attributes.merge(runner_type: :project_type))
elsif group = Group.find_by(runners_token: params[:token])
# Create a specific runner for the group
group.runners.create(attributes.merge(runner_type: :group_type))
end
break forbidden! unless runner
......
......@@ -20,6 +20,9 @@ module Gitlab
GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE
].freeze
SEARCH_CONTEXT_LINES = 3
# In https://gitlab.com/gitlab-org/gitaly/merge_requests/698
# We copied these two prefixes into gitaly-go, so don't change these
# or things will break! (REBASE_WORKTREE_PREFIX and SQUASH_WORKTREE_PREFIX)
REBASE_WORKTREE_PREFIX = 'rebase'.freeze
SQUASH_WORKTREE_PREFIX = 'squash'.freeze
GITALY_INTERNAL_URL = 'ssh://gitaly/internal.git'.freeze
......@@ -1671,10 +1674,14 @@ module Gitlab
end
end
# This function is duplicated in Gitaly-Go, don't change it!
# https://gitlab.com/gitlab-org/gitaly/merge_requests/698
def fresh_worktree?(path)
File.exist?(path) && !clean_stuck_worktree(path)
end
# This function is duplicated in Gitaly-Go, don't change it!
# https://gitlab.com/gitlab-org/gitaly/merge_requests/698
def clean_stuck_worktree(path)
return false unless File.mtime(path) < 15.minutes.ago
......
......@@ -25,9 +25,9 @@ module Gitlab
end
TEMPLATES_TABLE = [
ProjectTemplate.new('rails', 'Ruby on Rails', 'Includes an MVC structure, gemfile, rakefile, and .gitlab-ci.yml file, along with many others, to help you get started.', 'https://gitlab.com/gitlab-org/project-templates/rails'),
ProjectTemplate.new('spring', 'Spring', 'Includes an MVC structure, mvnw, pom.xml, and .gitlab-ci.yml file to help you get started.', 'https://gitlab.com/gitlab-org/project-templates/spring'),
ProjectTemplate.new('express', 'NodeJS Express', 'Includes an MVC structure and .gitlab-ci.yml file to help you get started.', 'https://gitlab.com/gitlab-org/project-templates/express')
ProjectTemplate.new('rails', 'Ruby on Rails', 'Includes an MVC structure, Gemfile, Rakefile, along with many others, to help you get started.', 'https://gitlab.com/gitlab-org/project-templates/rails'),
ProjectTemplate.new('spring', 'Spring', 'Includes an MVC structure, mvnw and pom.xml to help you get started.', 'https://gitlab.com/gitlab-org/project-templates/spring'),
ProjectTemplate.new('express', 'NodeJS Express', 'Includes an MVC structure to help you get started.', 'https://gitlab.com/gitlab-org/project-templates/express')
].freeze
class << self
......
......@@ -17,6 +17,23 @@ describe Projects::Settings::CiCdController do
expect(response).to have_gitlab_http_status(200)
expect(response).to render_template(:show)
end
context 'with group runners' do
let(:group_runner) { create(:ci_runner) }
let(:parent_group) { create(:group) }
let(:group) { create(:group, runners: [group_runner], parent: parent_group) }
let(:other_project) { create(:project, group: group) }
let!(:project_runner) { create(:ci_runner, projects: [other_project]) }
let!(:shared_runner) { create(:ci_runner, :shared) }
it 'sets assignable project runners only' do
group.add_master(user)
get :show, namespace_id: project.namespace, project_id: project
expect(assigns(:assignable_runners)).to eq [project_runner]
end
end
end
describe '#reset_cache' do
......
......@@ -15,14 +15,18 @@ FactoryBot.define do
namespace
creator { group ? create(:user) : namespace&.owner }
# Nest Project Feature attributes
transient do
# Nest Project Feature attributes
wiki_access_level ProjectFeature::ENABLED
builds_access_level ProjectFeature::ENABLED
snippets_access_level ProjectFeature::ENABLED
issues_access_level ProjectFeature::ENABLED
merge_requests_access_level ProjectFeature::ENABLED
repository_access_level ProjectFeature::ENABLED
# we can't assign the delegated `#ci_cd_settings` attributes directly, as the
# `#ci_cd_settings` relation needs to be created first
group_runners_enabled nil
end
after(:create) do |project, evaluator|
......@@ -47,6 +51,9 @@ FactoryBot.define do
end
project.group&.refresh_members_authorized_projects
# assign the delegated `#ci_cd_settings` attributes after create
project.reload.group_runners_enabled = evaluator.group_runners_enabled unless evaluator.group_runners_enabled.nil?
end
trait :public do
......
......@@ -59,6 +59,47 @@ describe "Admin Runners" do
expect(page).to have_text 'No runners found'
end
end
context 'group runner' do
let(:group) { create(:group) }
let!(:runner) { create(:ci_runner, groups: [group], runner_type: :group_type) }
it 'shows the label and does not show the project count' do
visit admin_runners_path
within "#runner_#{runner.id}" do
expect(page).to have_selector '.label', text: 'group'
expect(page).to have_text 'n/a'
end
end
end
context 'shared runner' do
it 'shows the label and does not show the project count' do
runner = create :ci_runner, :shared
visit admin_runners_path
within "#runner_#{runner.id}" do
expect(page).to have_selector '.label', text: 'shared'
expect(page).to have_text 'n/a'
end
end
end
context 'specific runner' do
it 'shows the label and the project count' do
project = create :project
runner = create :ci_runner, projects: [project]
visit admin_runners_path
within "#runner_#{runner.id}" do
expect(page).to have_selector '.label', text: 'specific'
expect(page).to have_text '1'
end
end
end
end
describe "Runner show page" do
......
......@@ -285,7 +285,7 @@ describe "Admin::Users" do
it "lists group projects" do
within(:css, '.append-bottom-default + .panel') do
expect(page).to have_content 'Group projects'
expect(page).to have_link group.name, admin_group_path(group)
expect(page).to have_link group.name, href: admin_group_path(group)
end
end
......
......@@ -161,6 +161,7 @@ feature 'Issues > User uses quick actions', :js do
before do
target_project.add_master(user)
gitlab_sign_out
sign_in(user)
visit project_issue_path(project, issue)
end
......@@ -220,6 +221,7 @@ feature 'Issues > User uses quick actions', :js do
before do
target_project.add_master(user)
gitlab_sign_out
sign_in(user)
visit project_issue_path(project, issue)
end
......
......@@ -181,4 +181,84 @@ feature 'Runners' do
expect(page.find('.shared-runners-description')).to have_content('Disable shared Runners')
end
end
context 'group runners' do
background do
project.add_master(user)
end
given(:group) { create :group }
context 'as project and group master' do
background do
group.add_master(user)
end
context 'project with a group but no group runner' do
given(:project) { create :project, group: group }
scenario 'group runners are not available' do
visit runners_path(project)
expect(page).to have_content 'This group does not provide any group Runners yet.'
expect(page).to have_content 'Setup a group Runner manually'
expect(page).not_to have_content 'Ask your group master to setup a group Runner.'
end
end
end
context 'as project master' do
context 'project without a group' do
given(:project) { create :project }
scenario 'group runners are not available' do
visit runners_path(project)
expect(page).to have_content 'This project does not belong to a group and can therefore not make use of group Runners.'
end
end
context 'project with a group but no group runner' do
given(:group) { create :group }
given(:project) { create :project, group: group }
scenario 'group runners are not available' do
visit runners_path(project)
expect(page).to have_content 'This group does not provide any group Runners yet.'
expect(page).not_to have_content 'Setup a group Runner manually'
expect(page).to have_content 'Ask your group master to setup a group Runner.'
end
end
context 'project with a group and a group runner' do
given(:group) { create :group }
given(:project) { create :project, group: group }
given!(:ci_runner) { create :ci_runner, groups: [group], description: 'group-runner' }
scenario 'group runners are available' do
visit runners_path(project)
expect(page).to have_content 'Available group Runners : 1'
expect(page).to have_content 'group-runner'
end
scenario 'group runners may be disabled for a project' do
visit runners_path(project)
click_on 'Disable group Runners'
expect(page).to have_content 'Enable group Runners'
expect(project.reload.group_runners_enabled).to be false
click_on 'Enable group Runners'
expect(page).to have_content 'Disable group Runners'
expect(project.reload.group_runners_enabled).to be true
end
end
end
end
end
......@@ -48,6 +48,33 @@ describe('RepoFile', () => {
});
});
describe('folder', () => {
it('renders changes count inside folder', () => {
const f = {
...file('folder'),
path: 'testing',
type: 'tree',
branchId: 'master',
projectId: 'project',
};
store.state.changedFiles.push({
...file('fileName'),
path: 'testing/fileName',
});
createComponent({
file: f,
level: 0,
});
const treeChangesEl = vm.$el.querySelector('.ide-tree-changes');
expect(treeChangesEl).not.toBeNull();
expect(treeChangesEl.textContent).toContain('1');
});
});
describe('locked file', () => {
let f;
......
......@@ -78,4 +78,67 @@ describe('IDE store getters', () => {
expect(getters.allBlobs(localState)[0].name).toBe('blob');
});
});
describe('getChangesInFolder', () => {
it('returns length of changed files for a path', () => {
localState.changedFiles.push(
{
path: 'test/index',
name: 'index',
},
{
path: 'app/123',
name: '123',
},
);
expect(getters.getChangesInFolder(localState)('test')).toBe(1);
});
it('returns length of changed & staged files for a path', () => {
localState.changedFiles.push(
{
path: 'test/index',
name: 'index',
},
{
path: 'testing/123',
name: '123',
},
);
localState.stagedFiles.push(
{
path: 'test/123',
name: '123',
},
{
path: 'test/index',
name: 'index',
},
{
path: 'testing/12345',
name: '12345',
},
);
expect(getters.getChangesInFolder(localState)('test')).toBe(2);
});
it('returns length of changed & tempFiles files for a path', () => {
localState.changedFiles.push(
{
path: 'test/index',
name: 'index',
},
{
path: 'test/newfile',
name: 'newfile',
tempFile: true,
},
);
expect(getters.getChangesInFolder(localState)('test')).toBe(2);
});
});
});
......@@ -170,5 +170,19 @@ describe('Participants', function () {
expect(vm.isShowingMoreParticipants).toBe(true);
});
it('clicking on participants icon emits `toggleSidebar` event', () => {
vm = mountComponent(Participants, {
loading: false,
participants: PARTICIPANT_LIST,
numberOfLessParticipants: 2,
});
spyOn(vm, '$emit');
const participantsIconEl = vm.$el.querySelector('.sidebar-collapsed-icon');
participantsIconEl.click();
expect(vm.$emit).toHaveBeenCalledWith('toggleSidebar');
});
});
});
......@@ -3,7 +3,6 @@ import sidebarSubscriptions from '~/sidebar/components/subscriptions/sidebar_sub
import SidebarMediator from '~/sidebar/sidebar_mediator';
import SidebarService from '~/sidebar/services/sidebar_service';
import SidebarStore from '~/sidebar/stores/sidebar_store';
import eventHub from '~/sidebar/event_hub';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import Mock from './mock_data';
......@@ -32,7 +31,7 @@ describe('Sidebar Subscriptions', function () {
mediator,
});
eventHub.$emit('toggleSubscription');
vm.onToggleSubscription();
expect(mediator.toggleSubscription).toHaveBeenCalled();
});
......
import Vue from 'vue';
import subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue';
import eventHub from '~/sidebar/event_hub';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Subscriptions', function () {
......@@ -39,4 +40,22 @@ describe('Subscriptions', function () {
expect(vm.$refs.toggleButton.$el.querySelector('.project-feature-toggle')).toHaveClass('is-checked');
});
it('toggleSubscription method emits `toggleSubscription` event on eventHub and Component', () => {
vm = mountComponent(Subscriptions, { subscribed: true });
spyOn(eventHub, '$emit');
spyOn(vm, '$emit');
vm.toggleSubscription();
expect(eventHub.$emit).toHaveBeenCalledWith('toggleSubscription', jasmine.any(Object));
expect(vm.$emit).toHaveBeenCalledWith('toggleSubscription', jasmine.any(Object));
});
it('onClickCollapsedIcon method emits `toggleSidebar` event on component', () => {
vm = mountComponent(Subscriptions, { subscribed: true });
spyOn(vm, '$emit');
vm.onClickCollapsedIcon();
expect(vm.$emit).toHaveBeenCalledWith('toggleSidebar');
});
});
......@@ -258,7 +258,6 @@ project:
- builds
- runner_projects
- runners
- active_runners
- variables
- triggers
- pipeline_schedules
......@@ -286,6 +285,7 @@ project:
- internal_ids
- project_deploy_tokens
- deploy_tokens
- settings
- ci_cd_settings
award_emoji:
- awardable
......
......@@ -19,6 +19,63 @@ describe Ci::Runner do
end
end
end
context 'either_projects_or_group' do
let(:group) { create(:group) }
it 'disallows assigning to a group if already assigned to a group' do
runner = create(:ci_runner, groups: [group])
runner.groups << build(:group)
expect(runner).not_to be_valid
expect(runner.errors.full_messages).to eq ['Runner can only be assigned to one group']
end
it 'disallows assigning to a group if already assigned to a project' do
project = create(:project)
runner = create(:ci_runner, projects: [project])
runner.groups << build(:group)
expect(runner).not_to be_valid
expect(runner.errors.full_messages).to eq ['Runner can only be assigned either to projects or to a group']
end
it 'disallows assigning to a project if already assigned to a group' do
runner = create(:ci_runner, groups: [group])
runner.projects << build(:project)
expect(runner).not_to be_valid
expect(runner.errors.full_messages).to eq ['Runner can only be assigned either to projects or to a group']
end
it 'allows assigning to a group if not assigned to a group nor a project' do
runner = create(:ci_runner)
runner.groups << build(:group)
expect(runner).to be_valid
end
it 'allows assigning to a project if not assigned to a group nor a project' do
runner = create(:ci_runner)
runner.projects << build(:project)
expect(runner).to be_valid
end
it 'allows assigning to a project if already assigned to a project' do
project = create(:project)
runner = create(:ci_runner, projects: [project])
runner.projects << build(:project)
expect(runner).to be_valid
end
end
end
describe '#access_level' do
......@@ -49,6 +106,80 @@ describe Ci::Runner do
end
end
describe '.shared' do
let(:group) { create(:group) }
let(:project) { create(:project) }
it 'returns the shared group runner' do
runner = create(:ci_runner, :shared, groups: [group])
expect(described_class.shared).to eq [runner]
end
it 'returns the shared project runner' do
runner = create(:ci_runner, :shared, projects: [project])
expect(described_class.shared).to eq [runner]
end
end
describe '.belonging_to_project' do
it 'returns the specific project runner' do
# own
specific_project = create(:project)
specific_runner = create(:ci_runner, :specific, projects: [specific_project])
# other
other_project = create(:project)
create(:ci_runner, :specific, projects: [other_project])
expect(described_class.belonging_to_project(specific_project.id)).to eq [specific_runner]
end
end
describe '.belonging_to_parent_group_of_project' do
let(:project) { create(:project, group: group) }
let(:group) { create(:group) }
let(:runner) { create(:ci_runner, :specific, groups: [group]) }
let!(:unrelated_group) { create(:group) }
let!(:unrelated_project) { create(:project, group: unrelated_group) }
let!(:unrelated_runner) { create(:ci_runner, :specific, groups: [unrelated_group]) }
it 'returns the specific group runner' do
expect(described_class.belonging_to_parent_group_of_project(project.id)).to contain_exactly(runner)
end
context 'with a parent group with a runner', :nested_groups do
let(:runner) { create(:ci_runner, :specific, groups: [parent_group]) }
let(:project) { create(:project, group: group) }
let(:group) { create(:group, parent: parent_group) }
let(:parent_group) { create(:group) }
it 'returns the group runner from the parent group' do
expect(described_class.belonging_to_parent_group_of_project(project.id)).to contain_exactly(runner)
end
end
end
describe '.owned_or_shared' do
it 'returns a globally shared, a project specific and a group specific runner' do
# group specific
group = create(:group)
project = create(:project, group: group)
group_runner = create(:ci_runner, :specific, groups: [group])
# project specific
project_runner = create(:ci_runner, :specific, projects: [project])
# globally shared
shared_runner = create(:ci_runner, :shared)
expect(described_class.owned_or_shared(project.id)).to contain_exactly(
group_runner, project_runner, shared_runner
)
end
end
describe '#display_name' do
it 'returns the description if it has a value' do
runner = FactoryBot.build(:ci_runner, description: 'Linux/Ruby-1.9.3-p448')
......@@ -163,7 +294,9 @@ describe Ci::Runner do
describe '#can_pick?' do
let(:pipeline) { create(:ci_pipeline) }
let(:build) { create(:ci_build, pipeline: pipeline) }
let(:runner) { create(:ci_runner) }
let(:runner) { create(:ci_runner, tag_list: tag_list, run_untagged: run_untagged) }
let(:tag_list) { [] }
let(:run_untagged) { true }
subject { runner.can_pick?(build) }
......@@ -171,6 +304,13 @@ describe Ci::Runner do
build.project.runners << runner
end
context 'a different runner' do
it 'cannot handle builds' do
other_runner = create(:ci_runner)
expect(other_runner.can_pick?(build)).to be_falsey
end
end
context 'when runner does not have tags' do
it 'can handle builds without tags' do
expect(runner.can_pick?(build)).to be_truthy
......@@ -184,9 +324,7 @@ describe Ci::Runner do
end
context 'when runner has tags' do
before do
runner.tag_list = %w(bb cc)
end
let(:tag_list) { %w(bb cc) }
shared_examples 'tagged build picker' do
it 'can handle build with matching tags' do
......@@ -211,9 +349,7 @@ describe Ci::Runner do
end
context 'when runner cannot pick untagged jobs' do
before do
runner.run_untagged = false
end
let(:run_untagged) { false }
it 'cannot handle builds without tags' do
expect(runner.can_pick?(build)).to be_falsey
......@@ -224,8 +360,9 @@ describe Ci::Runner do
end
context 'when runner is shared' do
let(:runner) { create(:ci_runner, :shared) }
before do
runner.is_shared = true
build.project.runners = []
end
......@@ -234,9 +371,7 @@ describe Ci::Runner do
end
context 'when runner is locked' do
before do
runner.locked = true
end
let(:runner) { create(:ci_runner, :shared, locked: true) }
it 'can handle builds' do
expect(runner.can_pick?(build)).to be_truthy
......@@ -260,6 +395,17 @@ describe Ci::Runner do
expect(runner.can_pick?(build)).to be_falsey
end
end
context 'when runner is assigned to a group' do
before do
build.project.runners = []
runner.groups << create(:group, projects: [build.project])
end
it 'can handle builds' do
expect(runner.can_pick?(build)).to be_truthy
end
end
end
context 'when access_level of runner is not_protected' do
......@@ -583,4 +729,76 @@ describe Ci::Runner do
expect(described_class.search(runner.description.upcase)).to eq([runner])
end
end
describe '#assigned_to_group?' do
subject { runner.assigned_to_group? }
context 'when project runner' do
let(:runner) { create(:ci_runner, description: 'Project runner', projects: [project]) }
let(:project) { create(:project) }
it { is_expected.to be_falsey }
end
context 'when shared runner' do
let(:runner) { create(:ci_runner, :shared, description: 'Shared runner') }
it { is_expected.to be_falsey }
end
context 'when group runner' do
let(:group) { create(:group) }
let(:runner) { create(:ci_runner, description: 'Group runner', groups: [group]) }
it { is_expected.to be_truthy }
end
end
describe '#assigned_to_project?' do
subject { runner.assigned_to_project? }
context 'when group runner' do
let(:runner) { create(:ci_runner, description: 'Group runner', groups: [group]) }
let(:group) { create(:group) }
it { is_expected.to be_falsey }
end
context 'when shared runner' do
let(:runner) { create(:ci_runner, :shared, description: 'Shared runner') }
it { is_expected.to be_falsey }
end
context 'when project runner' do
let(:runner) { create(:ci_runner, description: 'Group runner', projects: [project]) }
let(:project) { create(:project) }
it { is_expected.to be_truthy }
end
end
describe '#pick_build!' do
context 'runner can pick the build' do
it 'calls #tick_runner_queue' do
ci_build = build(:ci_build)
runner = build(:ci_runner)
allow(runner).to receive(:can_pick?).with(ci_build).and_return(true)
expect(runner).to receive(:tick_runner_queue)
runner.pick_build!(ci_build)
end
end
context 'runner cannot pick the build' do
it 'does not call #tick_runner_queue' do
ci_build = build(:ci_build)
runner = build(:ci_runner)
allow(runner).to receive(:can_pick?).with(ci_build).and_return(false)
expect(runner).not_to receive(:tick_runner_queue)
runner.pick_build!(ci_build)
end
end
end
end
......@@ -63,7 +63,6 @@ describe Project do
it { is_expected.to have_many(:build_trace_section_names)}
it { is_expected.to have_many(:runner_projects) }
it { is_expected.to have_many(:runners) }
it { is_expected.to have_many(:active_runners) }
it { is_expected.to have_many(:variables) }
it { is_expected.to have_many(:triggers) }
it { is_expected.to have_many(:pages_domains) }
......@@ -1139,10 +1138,11 @@ describe Project do
end
end
describe '#any_runners' do
let(:project) { create(:project, shared_runners_enabled: shared_runners_enabled) }
let(:specific_runner) { create(:ci_runner) }
let(:shared_runner) { create(:ci_runner, :shared) }
describe '#any_runners?' do
context 'shared runners' do
let(:project) { create :project, shared_runners_enabled: shared_runners_enabled }
let(:specific_runner) { create :ci_runner }
let(:shared_runner) { create :ci_runner, :shared }
context 'for shared runners disabled' do
let(:shared_runners_enabled) { false }
......@@ -1153,18 +1153,27 @@ describe Project do
it 'has a specific runner' do
project.runners << specific_runner
expect(project.any_runners?).to be_truthy
end
it 'has a shared runner, but they are prohibited to use' do
shared_runner
expect(project.any_runners?).to be_falsey
end
it 'checks the presence of specific runner' do
project.runners << specific_runner
expect(project.any_runners? { |runner| runner == specific_runner }).to be_truthy
end
it 'returns false if match cannot be found' do
project.runners << specific_runner
expect(project.any_runners? { false }).to be_falsey
end
end
context 'for shared runners enabled' do
......@@ -1172,13 +1181,64 @@ describe Project do
it 'has a shared runner' do
shared_runner
expect(project.any_runners?).to be_truthy
end
it 'checks the presence of shared runner' do
shared_runner
expect(project.any_runners? { |runner| runner == shared_runner }).to be_truthy
end
it 'returns false if match cannot be found' do
shared_runner
expect(project.any_runners? { false }).to be_falsey
end
end
end
context 'group runners' do
let(:project) { create :project, group_runners_enabled: group_runners_enabled }
let(:group) { create :group, projects: [project] }
let(:group_runner) { create :ci_runner, groups: [group] }
context 'for group runners disabled' do
let(:group_runners_enabled) { false }
it 'has no runners available' do
expect(project.any_runners?).to be_falsey
end
it 'has a group runner, but they are prohibited to use' do
group_runner
expect(project.any_runners?).to be_falsey
end
end
context 'for group runners enabled' do
let(:group_runners_enabled) { true }
it 'has a group runner' do
group_runner
expect(project.any_runners?).to be_truthy
end
it 'checks the presence of group runner' do
group_runner
expect(project.any_runners? { |runner| runner == group_runner }).to be_truthy
end
it 'returns false if match cannot be found' do
group_runner
expect(project.any_runners? { false }).to be_falsey
end
end
end
end
......@@ -3541,6 +3601,18 @@ describe Project do
end
end
describe '#toggle_ci_cd_settings!' do
it 'toggles the value on #settings' do
project = create(:project, group_runners_enabled: false)
expect(project.group_runners_enabled).to be false
project.toggle_ci_cd_settings!(:group_runners_enabled)
expect(project.group_runners_enabled).to be true
end
end
describe '#gitlab_deploy_token' do
let(:project) { create(:project) }
......
......@@ -40,18 +40,36 @@ describe API::Runner do
expect(json_response['token']).to eq(runner.token)
expect(runner.run_untagged).to be true
expect(runner.token).not_to eq(registration_token)
expect(runner).to be_instance_type
end
context 'when project token is used' do
let(:project) { create(:project) }
it 'creates runner' do
it 'creates project runner' do
post api('/runners'), token: project.runners_token
expect(response).to have_gitlab_http_status 201
expect(project.runners.size).to eq(1)
expect(Ci::Runner.first.token).not_to eq(registration_token)
expect(Ci::Runner.first.token).not_to eq(project.runners_token)
runner = Ci::Runner.first
expect(runner.token).not_to eq(registration_token)
expect(runner.token).not_to eq(project.runners_token)
expect(runner).to be_project_type
end
end
context 'when group token is used' do
let(:group) { create(:group) }
it 'creates a group runner' do
post api('/runners'), token: group.runners_token
expect(response).to have_http_status 201
expect(group.runners.size).to eq(1)
runner = Ci::Runner.first
expect(runner.token).not_to eq(registration_token)
expect(runner.token).not_to eq(group.runners_token)
expect(runner).to be_group_type
end
end
end
......
......@@ -8,22 +8,27 @@ describe API::Runners do
let(:project) { create(:project, creator_id: user.id) }
let(:project2) { create(:project, creator_id: user.id) }
let!(:shared_runner) { create(:ci_runner, :shared) }
let!(:unused_specific_runner) { create(:ci_runner) }
let(:group) { create(:group).tap { |group| group.add_owner(user) } }
let(:group2) { create(:group).tap { |group| group.add_owner(user) } }
let!(:specific_runner) do
create(:ci_runner).tap do |runner|
let!(:shared_runner) { create(:ci_runner, :shared, description: 'Shared runner') }
let!(:unused_project_runner) { create(:ci_runner) }
let!(:project_runner) do
create(:ci_runner, description: 'Project runner').tap do |runner|
create(:ci_runner_project, runner: runner, project: project)
end
end
let!(:two_projects_runner) do
create(:ci_runner).tap do |runner|
create(:ci_runner, description: 'Two projects runner').tap do |runner|
create(:ci_runner_project, runner: runner, project: project)
create(:ci_runner_project, runner: runner, project: project2)
end
end
let!(:group_runner) { create(:ci_runner, description: 'Group runner', groups: [group]) }
before do
# Set project access for users
create(:project_member, :master, user: user, project: project)
......@@ -37,9 +42,13 @@ describe API::Runners do
get api('/runners', user)
shared = json_response.any? { |r| r['is_shared'] }
descriptions = json_response.map { |runner| runner['description'] }
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(descriptions).to contain_exactly(
'Project runner', 'Two projects runner'
)
expect(shared).to be_falsey
end
......@@ -129,10 +138,16 @@ describe API::Runners do
context 'when runner is not shared' do
it "returns runner's details" do
get api("/runners/#{specific_runner.id}", admin)
get api("/runners/#{project_runner.id}", admin)
expect(response).to have_gitlab_http_status(200)
expect(json_response['description']).to eq(specific_runner.description)
expect(json_response['description']).to eq(project_runner.description)
end
it "returns the project's details for a project runner" do
get api("/runners/#{project_runner.id}", admin)
expect(json_response['projects'].first['id']).to eq(project.id)
end
end
......@@ -146,10 +161,10 @@ describe API::Runners do
context "runner project's administrative user" do
context 'when runner is not shared' do
it "returns runner's details" do
get api("/runners/#{specific_runner.id}", user)
get api("/runners/#{project_runner.id}", user)
expect(response).to have_gitlab_http_status(200)
expect(json_response['description']).to eq(specific_runner.description)
expect(json_response['description']).to eq(project_runner.description)
end
end
......@@ -164,18 +179,18 @@ describe API::Runners do
end
context 'other authorized user' do
it "does not return runner's details" do
get api("/runners/#{specific_runner.id}", user2)
it "does not return project runner's details" do
get api("/runners/#{project_runner.id}", user2)
expect(response).to have_gitlab_http_status(403)
expect(response).to have_http_status(403)
end
end
context 'unauthorized user' do
it "does not return runner's details" do
get api("/runners/#{specific_runner.id}")
it "does not return project runner's details" do
get api("/runners/#{project_runner.id}")
expect(response).to have_gitlab_http_status(401)
expect(response).to have_http_status(401)
end
end
end
......@@ -212,16 +227,16 @@ describe API::Runners do
context 'when runner is not shared' do
it 'updates runner' do
description = specific_runner.description
runner_queue_value = specific_runner.ensure_runner_queue_value
description = project_runner.description
runner_queue_value = project_runner.ensure_runner_queue_value
update_runner(specific_runner.id, admin, description: 'test')
specific_runner.reload
update_runner(project_runner.id, admin, description: 'test')
project_runner.reload
expect(response).to have_gitlab_http_status(200)
expect(specific_runner.description).to eq('test')
expect(specific_runner.description).not_to eq(description)
expect(specific_runner.ensure_runner_queue_value)
expect(project_runner.description).to eq('test')
expect(project_runner.description).not_to eq(description)
expect(project_runner.ensure_runner_queue_value)
.not_to eq(runner_queue_value)
end
end
......@@ -247,29 +262,29 @@ describe API::Runners do
end
context 'when runner is not shared' do
it 'does not update runner without access to it' do
put api("/runners/#{specific_runner.id}", user2), description: 'test'
it 'does not update project runner without access to it' do
put api("/runners/#{project_runner.id}", user2), description: 'test'
expect(response).to have_gitlab_http_status(403)
expect(response).to have_http_status(403)
end
it 'updates runner with access to it' do
description = specific_runner.description
put api("/runners/#{specific_runner.id}", admin), description: 'test'
specific_runner.reload
it 'updates project runner with access to it' do
description = project_runner.description
put api("/runners/#{project_runner.id}", admin), description: 'test'
project_runner.reload
expect(response).to have_gitlab_http_status(200)
expect(specific_runner.description).to eq('test')
expect(specific_runner.description).not_to eq(description)
expect(project_runner.description).to eq('test')
expect(project_runner.description).not_to eq(description)
end
end
end
context 'unauthorized user' do
it 'does not delete runner' do
put api("/runners/#{specific_runner.id}")
it 'does not delete project runner' do
put api("/runners/#{project_runner.id}")
expect(response).to have_gitlab_http_status(401)
expect(response).to have_http_status(401)
end
end
end
......@@ -293,17 +308,17 @@ describe API::Runners do
context 'when runner is not shared' do
it 'deletes unused runner' do
expect do
delete api("/runners/#{unused_specific_runner.id}", admin)
delete api("/runners/#{unused_project_runner.id}", admin)
expect(response).to have_gitlab_http_status(204)
end.to change { Ci::Runner.specific.count }.by(-1)
end
it 'deletes used runner' do
it 'deletes used project runner' do
expect do
delete api("/runners/#{specific_runner.id}", admin)
delete api("/runners/#{project_runner.id}", admin)
expect(response).to have_gitlab_http_status(204)
expect(response).to have_http_status(204)
end.to change { Ci::Runner.specific.count }.by(-1)
end
end
......@@ -325,34 +340,34 @@ describe API::Runners do
context 'when runner is not shared' do
it 'does not delete runner without access to it' do
delete api("/runners/#{specific_runner.id}", user2)
delete api("/runners/#{project_runner.id}", user2)
expect(response).to have_gitlab_http_status(403)
end
it 'does not delete runner with more than one associated project' do
it 'does not delete project runner with more than one associated project' do
delete api("/runners/#{two_projects_runner.id}", user)
expect(response).to have_gitlab_http_status(403)
end
it 'deletes runner for one owned project' do
it 'deletes project runner for one owned project' do
expect do
delete api("/runners/#{specific_runner.id}", user)
delete api("/runners/#{project_runner.id}", user)
expect(response).to have_gitlab_http_status(204)
expect(response).to have_http_status(204)
end.to change { Ci::Runner.specific.count }.by(-1)
end
it_behaves_like '412 response' do
let(:request) { api("/runners/#{specific_runner.id}", user) }
let(:request) { api("/runners/#{project_runner.id}", user) }
end
end
end
context 'unauthorized user' do
it 'does not delete runner' do
delete api("/runners/#{specific_runner.id}")
it 'does not delete project runner' do
delete api("/runners/#{project_runner.id}")
expect(response).to have_gitlab_http_status(401)
expect(response).to have_http_status(401)
end
end
end
......@@ -361,8 +376,8 @@ describe API::Runners do
set(:job_1) { create(:ci_build) }
let!(:job_2) { create(:ci_build, :running, runner: shared_runner, project: project) }
let!(:job_3) { create(:ci_build, :failed, runner: shared_runner, project: project) }
let!(:job_4) { create(:ci_build, :running, runner: specific_runner, project: project) }
let!(:job_5) { create(:ci_build, :failed, runner: specific_runner, project: project) }
let!(:job_4) { create(:ci_build, :running, runner: project_runner, project: project) }
let!(:job_5) { create(:ci_build, :failed, runner: project_runner, project: project) }
context 'admin user' do
context 'when runner exists' do
......@@ -380,7 +395,7 @@ describe API::Runners do
context 'when runner is specific' do
it 'return jobs' do
get api("/runners/#{specific_runner.id}/jobs", admin)
get api("/runners/#{project_runner.id}/jobs", admin)
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
......@@ -392,7 +407,7 @@ describe API::Runners do
context 'when valid status is provided' do
it 'return filtered jobs' do
get api("/runners/#{specific_runner.id}/jobs?status=failed", admin)
get api("/runners/#{project_runner.id}/jobs?status=failed", admin)
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
......@@ -405,7 +420,7 @@ describe API::Runners do
context 'when invalid status is provided' do
it 'return 400' do
get api("/runners/#{specific_runner.id}/jobs?status=non-existing", admin)
get api("/runners/#{project_runner.id}/jobs?status=non-existing", admin)
expect(response).to have_gitlab_http_status(400)
end
......@@ -433,7 +448,7 @@ describe API::Runners do
context 'when runner is specific' do
it 'return jobs' do
get api("/runners/#{specific_runner.id}/jobs", user)
get api("/runners/#{project_runner.id}/jobs", user)
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
......@@ -445,7 +460,7 @@ describe API::Runners do
context 'when valid status is provided' do
it 'return filtered jobs' do
get api("/runners/#{specific_runner.id}/jobs?status=failed", user)
get api("/runners/#{project_runner.id}/jobs?status=failed", user)
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
......@@ -458,7 +473,7 @@ describe API::Runners do
context 'when invalid status is provided' do
it 'return 400' do
get api("/runners/#{specific_runner.id}/jobs?status=non-existing", user)
get api("/runners/#{project_runner.id}/jobs?status=non-existing", user)
expect(response).to have_gitlab_http_status(400)
end
......@@ -476,7 +491,7 @@ describe API::Runners do
context 'other authorized user' do
it 'does not return jobs' do
get api("/runners/#{specific_runner.id}/jobs", user2)
get api("/runners/#{project_runner.id}/jobs", user2)
expect(response).to have_gitlab_http_status(403)
end
......@@ -484,7 +499,7 @@ describe API::Runners do
context 'unauthorized user' do
it 'does not return jobs' do
get api("/runners/#{specific_runner.id}/jobs")
get api("/runners/#{project_runner.id}/jobs")
expect(response).to have_gitlab_http_status(401)
end
......@@ -523,7 +538,7 @@ describe API::Runners do
describe 'POST /projects/:id/runners' do
context 'authorized user' do
let(:specific_runner2) do
let(:project_runner2) do
create(:ci_runner).tap do |runner|
create(:ci_runner_project, runner: runner, project: project2)
end
......@@ -531,23 +546,23 @@ describe API::Runners do
it 'enables specific runner' do
expect do
post api("/projects/#{project.id}/runners", user), runner_id: specific_runner2.id
post api("/projects/#{project.id}/runners", user), runner_id: project_runner2.id
end.to change { project.runners.count }.by(+1)
expect(response).to have_gitlab_http_status(201)
end
it 'avoids changes when enabling already enabled runner' do
expect do
post api("/projects/#{project.id}/runners", user), runner_id: specific_runner.id
post api("/projects/#{project.id}/runners", user), runner_id: project_runner.id
end.to change { project.runners.count }.by(0)
expect(response).to have_gitlab_http_status(409)
end
it 'does not enable locked runner' do
specific_runner2.update(locked: true)
project_runner2.update(locked: true)
expect do
post api("/projects/#{project.id}/runners", user), runner_id: specific_runner2.id
post api("/projects/#{project.id}/runners", user), runner_id: project_runner2.id
end.to change { project.runners.count }.by(0)
expect(response).to have_gitlab_http_status(403)
......@@ -559,10 +574,16 @@ describe API::Runners do
expect(response).to have_gitlab_http_status(403)
end
it 'does not enable group runner' do
post api("/projects/#{project.id}/runners", user), runner_id: group_runner.id
expect(response).to have_http_status(403)
end
context 'user is admin' do
it 'enables any specific runner' do
expect do
post api("/projects/#{project.id}/runners", admin), runner_id: unused_specific_runner.id
post api("/projects/#{project.id}/runners", admin), runner_id: unused_project_runner.id
end.to change { project.runners.count }.by(+1)
expect(response).to have_gitlab_http_status(201)
end
......@@ -570,7 +591,7 @@ describe API::Runners do
context 'user is not admin' do
it 'does not enable runner without access to' do
post api("/projects/#{project.id}/runners", user), runner_id: unused_specific_runner.id
post api("/projects/#{project.id}/runners", user), runner_id: unused_project_runner.id
expect(response).to have_gitlab_http_status(403)
end
......@@ -619,7 +640,7 @@ describe API::Runners do
context 'when runner have one associated projects' do
it "does not disable project's runner" do
expect do
delete api("/projects/#{project.id}/runners/#{specific_runner.id}", user)
delete api("/projects/#{project.id}/runners/#{project_runner.id}", user)
end.to change { project.runners.count }.by(0)
expect(response).to have_gitlab_http_status(403)
end
......@@ -634,7 +655,7 @@ describe API::Runners do
context 'authorized user without permissions' do
it "does not disable project's runner" do
delete api("/projects/#{project.id}/runners/#{specific_runner.id}", user2)
delete api("/projects/#{project.id}/runners/#{project_runner.id}", user2)
expect(response).to have_gitlab_http_status(403)
end
......@@ -642,7 +663,7 @@ describe API::Runners do
context 'unauthorized user' do
it "does not disable project's runner" do
delete api("/projects/#{project.id}/runners/#{specific_runner.id}")
delete api("/projects/#{project.id}/runners/#{project_runner.id}")
expect(response).to have_gitlab_http_status(401)
end
......
......@@ -118,7 +118,7 @@ describe PipelineSerializer do
it 'verifies number of queries', :request_store do
recorded = ActiveRecord::QueryRecorder.new { subject }
expect(recorded.count).to be_within(1).of(36)
expect(recorded.count).to be_within(1).of(44)
expect(recorded.cached_count).to eq(0)
end
end
......
......@@ -2,11 +2,13 @@ require 'spec_helper'
module Ci
describe RegisterJobService do
let!(:project) { FactoryBot.create :project, shared_runners_enabled: false }
let!(:pipeline) { FactoryBot.create :ci_pipeline, project: project }
let!(:pending_job) { FactoryBot.create :ci_build, pipeline: pipeline }
let!(:shared_runner) { FactoryBot.create(:ci_runner, is_shared: true) }
let!(:specific_runner) { FactoryBot.create(:ci_runner, is_shared: false) }
set(:group) { create(:group) }
set(:project) { create(:project, group: group, shared_runners_enabled: false, group_runners_enabled: false) }
set(:pipeline) { create(:ci_pipeline, project: project) }
let!(:shared_runner) { create(:ci_runner, is_shared: true) }
let!(:specific_runner) { create(:ci_runner, is_shared: false) }
let!(:group_runner) { create(:ci_runner, groups: [group], runner_type: :group_type) }
let!(:pending_job) { create(:ci_build, pipeline: pipeline) }
before do
specific_runner.assign_to(project)
......@@ -150,7 +152,7 @@ module Ci
context 'disallow when builds are disabled' do
before do
project.update(shared_runners_enabled: true)
project.update(shared_runners_enabled: true, group_runners_enabled: true)
project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED)
end
......@@ -160,13 +162,90 @@ module Ci
it { expect(build).to be_nil }
end
context 'and uses specific runner' do
context 'and uses group runner' do
let(:build) { execute(group_runner) }
it { expect(build).to be_nil }
end
context 'and uses project runner' do
let(:build) { execute(specific_runner) }
it { expect(build).to be_nil }
end
end
context 'allow group runners' do
before do
project.update!(group_runners_enabled: true)
end
context 'for multiple builds' do
let!(:project2) { create :project, group_runners_enabled: true, group: group }
let!(:pipeline2) { create :ci_pipeline, project: project2 }
let!(:project3) { create :project, group_runners_enabled: true, group: group }
let!(:pipeline3) { create :ci_pipeline, project: project3 }
let!(:build1_project1) { pending_job }
let!(:build2_project1) { create :ci_build, pipeline: pipeline }
let!(:build3_project1) { create :ci_build, pipeline: pipeline }
let!(:build1_project2) { create :ci_build, pipeline: pipeline2 }
let!(:build2_project2) { create :ci_build, pipeline: pipeline2 }
let!(:build1_project3) { create :ci_build, pipeline: pipeline3 }
# these shouldn't influence the scheduling
let!(:unrelated_group) { create :group }
let!(:unrelated_project) { create :project, group_runners_enabled: true, group: unrelated_group }
let!(:unrelated_pipeline) { create :ci_pipeline, project: unrelated_project }
let!(:build1_unrelated_project) { create :ci_build, pipeline: unrelated_pipeline }
let!(:unrelated_group_runner) { create :ci_runner, groups: [unrelated_group] }
it 'does not consider builds from other group runners' do
expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 6
execute(group_runner)
expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 5
execute(group_runner)
expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 4
execute(group_runner)
expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 3
execute(group_runner)
expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 2
execute(group_runner)
expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 1
execute(group_runner)
expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 0
expect(execute(group_runner)).to be_nil
end
end
context 'group runner' do
let(:build) { execute(group_runner) }
it { expect(build).to be_kind_of(Build) }
it { expect(build).to be_valid }
it { expect(build).to be_running }
it { expect(build.runner).to eq(group_runner) }
end
end
context 'disallow group runners' do
before do
project.update!(group_runners_enabled: false)
end
context 'group runner' do
let(:build) { execute(group_runner) }
it { expect(build).to be_nil }
end
end
context 'when first build is stalled' do
before do
pending_job.update(lock_version: 0)
......@@ -178,7 +257,7 @@ module Ci
let!(:other_build) { create :ci_build, pipeline: pipeline }
before do
allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_specific_runner)
allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_project_runner)
.and_return(Ci::Build.where(id: [pending_job, other_build]))
end
......@@ -190,7 +269,7 @@ module Ci
context 'when single build is in queue' do
before do
allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_specific_runner)
allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_project_runner)
.and_return(Ci::Build.where(id: pending_job))
end
......@@ -201,7 +280,7 @@ module Ci
context 'when there is no build in queue' do
before do
allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_specific_runner)
allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_project_runner)
.and_return(Ci::Build.none)
end
......
......@@ -8,21 +8,19 @@ describe Ci::UpdateBuildQueueService do
context 'when updating specific runners' do
let(:runner) { create(:ci_runner) }
context 'when there are runner that can pick build' do
context 'when there is a runner that can pick build' do
before do
build.project.runners << runner
end
it 'ticks runner queue value' do
expect { subject.execute(build) }
.to change { runner.ensure_runner_queue_value }
expect { subject.execute(build) }.to change { runner.ensure_runner_queue_value }
end
end
context 'when there are no runners that can pick build' do
context 'when there is no runner that can pick build' do
it 'does not tick runner queue value' do
expect { subject.execute(build) }
.not_to change { runner.ensure_runner_queue_value }
expect { subject.execute(build) }.not_to change { runner.ensure_runner_queue_value }
end
end
end
......@@ -30,21 +28,61 @@ describe Ci::UpdateBuildQueueService do
context 'when updating shared runners' do
let(:runner) { create(:ci_runner, :shared) }
context 'when there are runner that can pick build' do
context 'when there is no runner that can pick build' do
it 'ticks runner queue value' do
expect { subject.execute(build) }
.to change { runner.ensure_runner_queue_value }
expect { subject.execute(build) }.to change { runner.ensure_runner_queue_value }
end
end
context 'when there are no runners that can pick build' do
context 'when there is no runner that can pick build due to tag mismatch' do
before do
build.tag_list = [:docker]
end
it 'does not tick runner queue value' do
expect { subject.execute(build) }
.not_to change { runner.ensure_runner_queue_value }
expect { subject.execute(build) }.not_to change { runner.ensure_runner_queue_value }
end
end
context 'when there is no runner that can pick build due to being disabled on project' do
before do
build.project.shared_runners_enabled = false
end
it 'does not tick runner queue value' do
expect { subject.execute(build) }.not_to change { runner.ensure_runner_queue_value }
end
end
end
context 'when updating group runners' do
let(:group) { create :group }
let(:project) { create :project, group: group }
let(:runner) { create :ci_runner, groups: [group] }
context 'when there is a runner that can pick build' do
it 'ticks runner queue value' do
expect { subject.execute(build) }.to change { runner.ensure_runner_queue_value }
end
end
context 'when there is no runner that can pick build due to tag mismatch' do
before do
build.tag_list = [:docker]
end
it 'does not tick runner queue value' do
expect { subject.execute(build) }.not_to change { runner.ensure_runner_queue_value }
end
end
context 'when there is no runner that can pick build due to being disabled on project' do
before do
build.project.group_runners_enabled = false
end
it 'does not tick runner queue value' do
expect { subject.execute(build) }.not_to change { runner.ensure_runner_queue_value }
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