Commit 36838a84 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'master' into '3867-port-to-ce'

# Conflicts:
#   db/schema.rb
parents a5bb17ff c63af942
...@@ -738,8 +738,9 @@ cache gems: ...@@ -738,8 +738,9 @@ cache gems:
gitlab_git_test: gitlab_git_test:
<<: *dedicated-runner <<: *dedicated-runner
<<: *except-docs-and-qa <<: *except-docs-and-qa
<<: *pull-cache
variables: variables:
SETUP_DB: "false" SETUP_DB: "false"
before_script: []
cache: {}
script: script:
- spec/support/prepare-gitlab-git-test-for-commit --check-for-changes - spec/support/prepare-gitlab-git-test-for-commit --check-for-changes
import tooltip from '../../vue_shared/directives/tooltip';
export default {
name: 'MRWidgetAuthor',
props: {
author: { type: Object, required: true },
showAuthorName: { type: Boolean, required: false, default: true },
showAuthorTooltip: { type: Boolean, required: false, default: false },
},
directives: {
tooltip,
},
template: `
<a
:href="author.webUrl || author.web_url"
class="author-link inline"
:v-tooltip="showAuthorTooltip"
:title="author.name">
<img
:src="author.avatarUrl || author.avatar_url"
class="avatar avatar-inline s16" />
<span
v-if="showAuthorName"
class="author">{{author.name}}
</span>
</a>
`,
};
<script>
export default {
name: 'MRWidgetAuthor',
props: {
author: {
type: Object,
required: true,
},
},
computed: {
authorUrl() {
return this.author.webUrl || this.author.web_url;
},
avatarUrl() {
return this.author.avatarUrl || this.author.avatar_url;
},
},
};
</script>
<template>
<a
:href="authorUrl"
class="author-link inline"
>
<img
:src="avatarUrl"
class="avatar avatar-inline s16"
/>
<span class="author">
{{ author.name }}
</span>
</a>
</template>
import MRWidgetAuthor from './mr_widget_author';
export default {
name: 'MRWidgetAuthorTime',
props: {
actionText: { type: String, required: true },
author: { type: Object, required: true },
dateTitle: { type: String, required: true },
dateReadable: { type: String, required: true },
},
components: {
'mr-widget-author': MRWidgetAuthor,
},
template: `
<h4 class="js-mr-widget-author">
{{actionText}}
<mr-widget-author :author="author" />
<time
:title="dateTitle"
data-toggle="tooltip"
data-placement="top"
data-container="body">
{{dateReadable}}
</time>
</h4>
`,
};
<script>
import mrWidgetAuthor from './mr_widget_author.vue';
export default {
name: 'MRWidgetAuthorTime',
components: {
mrWidgetAuthor,
},
props: {
actionText: {
type: String,
required: true,
},
author: {
type: Object,
required: true,
},
dateTitle: {
type: String,
required: true,
},
dateReadable: {
type: String,
required: true,
},
},
};
</script>
<template>
<h4 class="js-mr-widget-author">
{{ actionText }}
<mr-widget-author :author="author" />
<time
:title="dateTitle"
data-toggle="tooltip"
data-placement="top"
data-container="body"
>
{{ dateReadable }}
</time>
</h4>
</template>
import tooltip from '../../vue_shared/directives/tooltip';
import { pluralize } from '../../lib/utils/text_utility';
import icon from '../../vue_shared/components/icon.vue';
export default {
name: 'MRWidgetHeader',
props: {
mr: { type: Object, required: true },
},
directives: {
tooltip,
},
components: {
icon,
},
computed: {
shouldShowCommitsBehindText() {
return this.mr.divergedCommitsCount > 0;
},
commitsText() {
return pluralize('commit', this.mr.divergedCommitsCount);
},
branchNameClipboardData() {
// This supports code in app/assets/javascripts/copy_to_clipboard.js that
// works around ClipboardJS limitations to allow the context-specific
// copy/pasting of plain text or GFM.
return JSON.stringify({
text: this.mr.sourceBranch,
gfm: `\`${this.mr.sourceBranch}\``,
});
},
},
methods: {
isBranchTitleLong(branchTitle) {
return branchTitle.length > 32;
},
},
template: `
<div class="mr-source-target">
<div class="normal">
<strong>
Request to merge
<span
class="label-branch"
:class="{'label-truncated': isBranchTitleLong(mr.sourceBranch)}"
:title="isBranchTitleLong(mr.sourceBranch) ? mr.sourceBranch : ''"
data-placement="bottom"
:v-tooltip="isBranchTitleLong(mr.sourceBranch)"
v-html="mr.sourceBranchLink"></span>
<button
v-tooltip
class="btn btn-transparent btn-clipboard"
data-title="Copy branch name to clipboard"
:data-clipboard-text="branchNameClipboardData">
<i
aria-hidden="true"
class="fa fa-clipboard"></i>
</button>
into
<span
class="label-branch"
:v-tooltip="isBranchTitleLong(mr.sourceBranch)"
:class="{'label-truncatedtooltip': isBranchTitleLong(mr.targetBranch)}"
:title="isBranchTitleLong(mr.targetBranch) ? mr.targetBranch : ''"
data-placement="bottom">
<a :href="mr.targetBranchTreePath">{{mr.targetBranch}}</a>
</span>
</strong>
<span
v-if="shouldShowCommitsBehindText"
class="diverged-commits-count">
(<a :href="mr.targetBranchPath">{{mr.divergedCommitsCount}} {{commitsText}} behind</a>)
</span>
</div>
<div v-if="mr.isOpen">
<a
href="#modal_merge_info"
data-toggle="modal"
:disabled="mr.sourceBranchRemoved"
class="btn btn-sm inline">
Check out branch
</a>
<span class="dropdown prepend-left-10">
<a
class="btn btn-sm inline dropdown-toggle"
data-toggle="dropdown"
aria-label="Download as"
role="button">
<icon
name="download">
</icon>
<i
class="fa fa-caret-down"
aria-hidden="true">
</i>
</a>
<ul class="dropdown-menu dropdown-menu-align-right">
<li>
<a
:href="mr.emailPatchesPath"
download>
Email patches
</a>
</li>
<li>
<a
:href="mr.plainDiffPath"
download>
Plain diff
</a>
</li>
</ul>
</span>
</div>
</div>
`,
};
<script>
import tooltip from '~/vue_shared/directives/tooltip';
import { n__ } from '~/locale';
import icon from '~/vue_shared/components/icon.vue';
import clipboardButton from '~/vue_shared/components/clipboard_button.vue';
export default {
name: 'MRWidgetHeader',
directives: {
tooltip,
},
components: {
icon,
clipboardButton,
},
props: {
mr: {
type: Object,
required: true,
},
},
computed: {
shouldShowCommitsBehindText() {
return this.mr.divergedCommitsCount > 0;
},
commitsText() {
return n__('%d commit behind', '%d commits behind', this.mr.divergedCommitsCount);
},
branchNameClipboardData() {
// This supports code in app/assets/javascripts/copy_to_clipboard.js that
// works around ClipboardJS limitations to allow the context-specific
// copy/pasting of plain text or GFM.
return JSON.stringify({
text: this.mr.sourceBranch,
gfm: `\`${this.mr.sourceBranch}\``,
});
},
isSourceBranchLong() {
return this.isBranchTitleLong(this.mr.sourceBranch);
},
isTargetBranchLong() {
return this.isBranchTitleLong(this.mr.targetBranch);
},
},
methods: {
isBranchTitleLong(branchTitle) {
return branchTitle.length > 32;
},
},
};
</script>
<template>
<div class="mr-source-target">
<div class="normal">
<strong>
{{ s__("mrWidget|Request to merge") }}
<span
class="label-branch js-source-branch"
:class="{ 'label-truncated': isSourceBranchLong }"
:title="isSourceBranchLong ? mr.sourceBranch : ''"
data-placement="bottom"
:v-tooltip="isSourceBranchLong"
v-html="mr.sourceBranchLink"
>
</span>
<clipboard-button
:text="branchNameClipboardData"
:title="__('Copy branch name to clipboard')"
/>
{{ s__("mrWidget|into") }}
<span
class="label-branch"
:v-tooltip="isTargetBranchLong"
:class="{ 'label-truncatedtooltip': isTargetBranchLong }"
:title="isTargetBranchLong ? mr.targetBranch : ''"
data-placement="bottom"
>
<a
:href="mr.targetBranchTreePath"
class="js-target-branch"
>
{{ mr.targetBranch }}
</a>
</span>
</strong>
<span
v-if="shouldShowCommitsBehindText"
class="diverged-commits-count"
>
(<a :href="mr.targetBranchPath">{{ commitsText }}</a>)
</span>
</div>
<div v-if="mr.isOpen">
<button
data-target="#modal_merge_info"
data-toggle="modal"
:disabled="mr.sourceBranchRemoved"
class="btn btn-sm btn-default inline js-check-out-branch"
type="button"
>
{{ s__("mrWidget|Check out branch") }}
</button>
<span class="dropdown prepend-left-10">
<button
type="button"
class="btn btn-sm inline dropdown-toggle"
data-toggle="dropdown"
aria-label="Download as"
aria-haspopup="true"
aria-expanded="false"
>
<icon name="download" />
<i
class="fa fa-caret-down"
aria-hidden="true">
</i>
</button>
<ul class="dropdown-menu dropdown-menu-align-right">
<li>
<a
class="js-download-email-patches"
:href="mr.emailPatchesPath"
download
>
{{ s__("mrWidget|Email patches") }}
</a>
</li>
<li>
<a
class="js-download-plain-diff"
:href="mr.plainDiffPath"
download
>
{{ s__("mrWidget|Plain diff") }}
</a>
</li>
</ul>
</span>
</div>
</div>
</template>
export default {
name: 'MRWidgetMergeHelp',
props: {
missingBranch: { type: String, required: false, default: '' },
},
template: `
<section class="mr-widget-help">
<template
v-if="missingBranch">
If the {{missingBranch}} branch exists in your local repository, you
</template>
<template v-else>
You
</template>
can merge this merge request manually using the
<a
data-toggle="modal"
href="#modal_merge_info">
command line
</a>
</section>
`,
};
<script>
import { sprintf, s__ } from '~/locale';
export default {
name: 'MRWidgetMergeHelp',
props: {
missingBranch: {
type: String,
required: false,
default: '',
},
},
computed: {
missingBranchInfo() {
return sprintf(
s__('mrWidget|If the %{branch} branch exists in your local repository, you can merge this merge request manually using the'),
{ branch: this.missingBranch },
);
},
},
};
</script>
<template>
<section class="mr-widget-help">
<template v-if="missingBranch">
{{ missingBranchInfo }}
</template>
<template v-else>
{{ s__("mrWidget|You can merge this merge request manually using the") }}
</template>
<button
type="button"
class="btn-link btn-blank js-open-modal-help"
data-toggle="modal"
data-target="#modal_merge_info"
>
{{ s__("mrWidget|command line") }}
</button>
</section>
</template>
<script> <script>
import mrWidgetAuthorTime from '../../components/mr_widget_author_time'; import mrWidgetAuthorTime from '../../components/mr_widget_author_time.vue';
import statusIcon from '../mr_widget_status_icon.vue'; import statusIcon from '../mr_widget_status_icon.vue';
export default { export default {
......
<script> <script>
import Flash from '../../../flash'; import Flash from '../../../flash';
import statusIcon from '../mr_widget_status_icon.vue'; import statusIcon from '../mr_widget_status_icon.vue';
import mrWidgetAuthor from '../../components/mr_widget_author'; import mrWidgetAuthor from '../../components/mr_widget_author.vue';
import eventHub from '../../event_hub'; import eventHub from '../../event_hub';
export default { export default {
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import mrWidgetAuthorTime from '../../components/mr_widget_author_time'; import mrWidgetAuthorTime from '../../components/mr_widget_author_time.vue';
import statusIcon from '../mr_widget_status_icon.vue'; import statusIcon from '../mr_widget_status_icon.vue';
import eventHub from '../../event_hub'; import eventHub from '../../event_hub';
......
import statusIcon from '../mr_widget_status_icon.vue'; import statusIcon from '../mr_widget_status_icon.vue';
import tooltip from '../../../vue_shared/directives/tooltip'; import tooltip from '../../../vue_shared/directives/tooltip';
import mrWidgetMergeHelp from '../../components/mr_widget_merge_help'; import mrWidgetMergeHelp from '../../components/mr_widget_merge_help.vue';
export default { export default {
name: 'MRWidgetMissingBranch', name: 'MRWidgetMissingBranch',
......
...@@ -11,8 +11,8 @@ ...@@ -11,8 +11,8 @@
export { default as Vue } from 'vue'; export { default as Vue } from 'vue';
export { default as SmartInterval } from '~/smart_interval'; export { default as SmartInterval } from '~/smart_interval';
export { default as WidgetHeader } from './components/mr_widget_header'; export { default as WidgetHeader } from './components/mr_widget_header.vue';
export { default as WidgetMergeHelp } from './components/mr_widget_merge_help'; export { default as WidgetMergeHelp } from './components/mr_widget_merge_help.vue';
export { default as WidgetPipeline } from './components/mr_widget_pipeline.vue'; export { default as WidgetPipeline } from './components/mr_widget_pipeline.vue';
export { default as WidgetDeployment } from './components/mr_widget_deployment'; export { default as WidgetDeployment } from './components/mr_widget_deployment';
export { default as WidgetRelatedLinks } from './components/mr_widget_related_links.vue'; export { default as WidgetRelatedLinks } from './components/mr_widget_related_links.vue';
......
<script>
import _ from 'underscore';
import { __, sprintf } from '~/locale';
export default {
props: {
inputId: {
type: String,
required: true,
},
confirmationKey: {
type: String,
required: true,
},
confirmationValue: {
type: String,
required: true,
},
shouldEscapeConfirmationValue: {
type: Boolean,
required: false,
default: true,
},
},
computed: {
inputLabel() {
let value = this.confirmationValue;
if (this.shouldEscapeConfirmationValue) {
value = _.escape(value);
}
return sprintf(
__('Type %{value} to confirm:'),
{ value: `<code>${value}</code>` },
false,
);
},
},
methods: {
hasCorrectValue() {
return this.$refs.enteredValue.value === this.confirmationValue;
},
},
};
</script>
<template>
<div>
<label
v-html="inputLabel"
:for="inputId"
>
</label>
<input
:id="inputId"
:name="confirmationKey"
type="text"
ref="enteredValue"
class="form-control"
/>
</div>
</template>
...@@ -410,7 +410,6 @@ ...@@ -410,7 +410,6 @@
width: 298px; width: 298px;
} }
@media (max-width: $screen-xs-max) { @media (max-width: $screen-xs-max) {
display: flex; display: flex;
width: 100%; width: 100%;
......
...@@ -96,12 +96,12 @@ module ApplicationSettingsHelper ...@@ -96,12 +96,12 @@ module ApplicationSettingsHelper
] ]
end end
def repository_storages_options_for_select def repository_storages_options_for_select(selected)
options = Gitlab.config.repositories.storages.map do |name, storage| options = Gitlab.config.repositories.storages.map do |name, storage|
["#{name} - #{storage['path']}", name] ["#{name} - #{storage['path']}", name]
end end
options_for_select(options, @application_setting.repository_storages) options_for_select(options, selected)
end end
def sidekiq_queue_options_for_select def sidekiq_queue_options_for_select
......
...@@ -10,6 +10,10 @@ module Ci ...@@ -10,6 +10,10 @@ module Ci
can?(:developer_access) && pipeline_schedule.owned_by?(@user) can?(:developer_access) && pipeline_schedule.owned_by?(@user)
end end
condition(:non_owner_of_schedule) do
!pipeline_schedule.owned_by?(@user)
end
rule { can?(:developer_access) }.policy do rule { can?(:developer_access) }.policy do
enable :play_pipeline_schedule enable :play_pipeline_schedule
end end
...@@ -19,6 +23,10 @@ module Ci ...@@ -19,6 +23,10 @@ module Ci
enable :admin_pipeline_schedule enable :admin_pipeline_schedule
end end
rule { can?(:master_access) & non_owner_of_schedule }.policy do
enable :take_ownership_pipeline_schedule
end
rule { protected_ref }.prevent :play_pipeline_schedule rule { protected_ref }.prevent :play_pipeline_schedule
end end
end end
...@@ -537,7 +537,8 @@ ...@@ -537,7 +537,8 @@
.form-group .form-group
= f.label :repository_storages, 'Storage paths for new projects', class: 'control-label col-sm-2' = f.label :repository_storages, 'Storage paths for new projects', class: 'control-label col-sm-2'
.col-sm-10 .col-sm-10
= f.select :repository_storages, repository_storages_options_for_select, {include_hidden: false}, multiple: true, class: 'form-control' = f.select :repository_storages, repository_storages_options_for_select(@application_setting.repository_storages),
{include_hidden: false}, multiple: true, class: 'form-control'
.help-block .help-block
Manage repository storage paths. Learn more in the Manage repository storage paths. Learn more in the
= succeed "." do = succeed "." do
......
...@@ -15,10 +15,10 @@ ...@@ -15,10 +15,10 @@
%span.text %span.text
Checking branch availability… Checking branch availability…
.btn-group.available.hide .btn-group.available.hide
%button.btn.js-create-merge-request.btn-default{ type: 'button', data: { action: data_action } } %button.btn.js-create-merge-request.btn-success.btn-inverted{ type: 'button', data: { action: data_action } }
= value = value
%button.btn.create-merge-request-dropdown-toggle.dropdown-toggle.btn-default.js-dropdown-toggle{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' } } } %button.btn.create-merge-request-dropdown-toggle.dropdown-toggle.btn-success.btn-inverted.js-dropdown-toggle{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' } } }
= icon('caret-down') = icon('caret-down')
%ul#create-merge-request-dropdown.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-align-right.gl-show-field-errors{ data: { dropdown: true } } %ul#create-merge-request-dropdown.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-align-right.gl-show-field-errors{ data: { dropdown: true } }
......
...@@ -29,9 +29,10 @@ ...@@ -29,9 +29,10 @@
- if can?(current_user, :play_pipeline_schedule, pipeline_schedule) - if can?(current_user, :play_pipeline_schedule, pipeline_schedule)
= link_to play_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('Play'), class: 'btn' do = link_to play_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('Play'), class: 'btn' do
= icon('play') = icon('play')
- if can?(current_user, :update_pipeline_schedule, pipeline_schedule) - if can?(current_user, :take_ownership_pipeline_schedule, pipeline_schedule)
= link_to take_ownership_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('PipelineSchedules|Take ownership'), class: 'btn' do = link_to take_ownership_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('PipelineSchedules|Take ownership'), class: 'btn' do
= s_('PipelineSchedules|Take ownership') = s_('PipelineSchedules|Take ownership')
- if can?(current_user, :update_pipeline_schedule, pipeline_schedule)
= link_to edit_pipeline_schedule_path(pipeline_schedule), title: _('Edit'), class: 'btn' do = link_to edit_pipeline_schedule_path(pipeline_schedule), title: _('Edit'), class: 'btn' do
= icon('pencil') = icon('pencil')
- if can?(current_user, :admin_pipeline_schedule, pipeline_schedule) - if can?(current_user, :admin_pipeline_schedule, pipeline_schedule)
......
---
title: Hide pipeline schedule take ownership for current owner
merge_request: 12986
author:
type: fixed
---
title: Add confirmation-input component
merge_request: 16816
author:
type: other
---
title: Improve issue note dropdown and mr button
merge_request: 16758
author: George Tsiolis
type: changed
---
title: Finish any remaining jobs for issues.closed_at
merge_request:
author:
type: other
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class MigrateRemainingIssuesClosedAt < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
disable_ddl_transaction!
class Issue < ActiveRecord::Base
self.table_name = 'issues'
include EachBatch
end
def up
Gitlab::BackgroundMigration.steal('CopyColumn')
Gitlab::BackgroundMigration.steal('CleanupConcurrentTypeChange')
# It's possible the cleanup job was killed which means we need to manually
# migrate any remaining rows.
migrate_remaining_rows if migrate_column_type?
end
def down
end
def migrate_remaining_rows
Issue.where('closed_at_for_type_change IS NULL AND closed_at IS NOT NULL').each_batch do |batch|
batch.update_all('closed_at_for_type_change = closed_at')
end
cleanup_concurrent_column_type_change(:issues, :closed_at)
end
def migrate_column_type?
# Some environments may have already executed the previous version of this
# migration, thus we don't need to migrate those environments again.
column_for('issues', 'closed_at').type == :datetime # rubocop:disable Migration/Datetime
end
end
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20180119135717) do ActiveRecord::Schema.define(version: 20180201145907) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
......
...@@ -8,23 +8,13 @@ comments: false ...@@ -8,23 +8,13 @@ comments: false
Welcome to [GitLab](https://about.gitlab.com/), a Git-based fully featured Welcome to [GitLab](https://about.gitlab.com/), a Git-based fully featured
platform for software development! platform for software development!
GitLab offers the most scalable Git-based fully integrated platform for software development, with flexible products and subscription plans: GitLab offers the most scalable Git-based fully integrated platform for software development, with flexible products and subscription plans.
- **GitLab Community Edition (CE)** is an [open source product](https://gitlab.com/gitlab-org/gitlab-ce/), With GitLab self-hosted, you deploy your own GitLab instance on-premises or on a private cloud of your choice. GitLab self-hosted is available for [free and with paid subscriptions](https://about.gitlab.com/products/): Libre, Starter, Premium, and Ultimate.
self-hosted, free to use. Every feature available in GitLab CE is also available on GitLab Enterprise Edition (Starter and Premium) and GitLab.com.
- **GitLab Enterprise Edition (EE)** is an [open-core product](https://gitlab.com/gitlab-org/gitlab-ee/),
self-hosted, fully featured solution of GitLab, available under distinct [subscriptions](https://about.gitlab.com/products/): **GitLab Enterprise Edition Starter (EES)**, **GitLab Enterprise Edition Premium (EEP)**, and **GitLab Enterprise Edition Ultimate (EEU)**.
- **GitLab.com**: SaaS GitLab solution, with [free and paid subscriptions](https://about.gitlab.com/gitlab-com/). GitLab.com is hosted by GitLab, Inc., and administrated by GitLab (users don't have access to admin settings).
> **GitLab EE** contains all features available in **GitLab CE**, GitLab.com is our SaaS offering. It's hosted, managed, and administered by GitLab, with [free and paid plans](https://about.gitlab.com/gitlab-com/) for individuals and teams: Free, Bronze, Silver, and Gold.
plus premium features available in each version: **Enterprise Edition Starter**
(**EES**), **Enterprise Edition Premium** (**EEP**), and **Enterprise Edition Ultimate**
(**EEU**). Everything available in **EES** is also available in **EEP**. Every feature
available in **EEP** is also available in **EEU**.
---- ## Shortcuts to GitLab's most visited docs
Shortcuts to GitLab's most visited docs:
| [GitLab CI/CD](ci/README.md) | Other | | [GitLab CI/CD](ci/README.md) | Other |
| :----- | :----- | | :----- | :----- |
...@@ -134,14 +124,8 @@ Manage your [repositories](user/project/repository/index.md) from the UI (user i ...@@ -134,14 +124,8 @@ Manage your [repositories](user/project/repository/index.md) from the UI (user i
## Administrator documentation ## Administrator documentation
[Administration documentation](administration/index.md) applies to admin users of GitLab [Administration documentation](administration/index.md) applies to admin users of [GitLab
self-hosted instances: self-hosted instances](#self-hosted-gitlab): Libre, Starter, Premium, Ultimate.
- GitLab Community Edition
- GitLab [Enterprise Editions](https://about.gitlab.com/gitlab-ee/)
- Enterprise Edition Starter (EES)
- Enterprise Edition Premium (EEP)
- Enterprise Edition Ultimate (EEU)
Learn how to install, configure, update, upgrade, integrate, and maintain your own instance. Learn how to install, configure, update, upgrade, integrate, and maintain your own instance.
Regular users don't have access to GitLab administration tools and settings. Regular users don't have access to GitLab administration tools and settings.
......
...@@ -31,7 +31,7 @@ The default configuration can always be found in the [values.yaml](https://gitla ...@@ -31,7 +31,7 @@ The default configuration can always be found in the [values.yaml](https://gitla
In order for GitLab Runner to function, your config file **must** specify the following: In order for GitLab Runner to function, your config file **must** specify the following:
- `gitlabURL` - the GitLab Server URL (with protocol) to register the runner against - `gitlabUrl` - the GitLab Server URL (with protocol) to register the runner against
- `runnerRegistrationToken` - The Registration Token for adding new Runners to the GitLab Server. This must be - `runnerRegistrationToken` - The Registration Token for adding new Runners to the GitLab Server. This must be
retrieved from your GitLab Instance. See the [GitLab Runner Documentation](../../ci/runners/README.md#creating-and-registering-a-runner) for more information. retrieved from your GitLab Instance. See the [GitLab Runner Documentation](../../ci/runners/README.md#creating-and-registering-a-runner) for more information.
...@@ -47,7 +47,7 @@ Here is a snippet of the important settings: ...@@ -47,7 +47,7 @@ Here is a snippet of the important settings:
## The GitLab Server URL (with protocol) that want to register the runner against ## The GitLab Server URL (with protocol) that want to register the runner against
## ref: https://docs.gitlab.com/runner/commands/README.html#gitlab-runner-register ## ref: https://docs.gitlab.com/runner/commands/README.html#gitlab-runner-register
## ##
gitlabURL: http://gitlab.your-domain.com/ gitlabUrl: http://gitlab.your-domain.com/
## The Registration Token for adding new Runners to the GitLab Server. This must ## The Registration Token for adding new Runners to the GitLab Server. This must
## be retreived from your GitLab Instance. ## be retreived from your GitLab Instance.
......
...@@ -6,6 +6,7 @@ module Gitlab ...@@ -6,6 +6,7 @@ module Gitlab
CommandError = Class.new(StandardError) CommandError = Class.new(StandardError)
CommitError = Class.new(StandardError) CommitError = Class.new(StandardError)
OSError = Class.new(StandardError)
class << self class << self
include Gitlab::EncodingHelper include Gitlab::EncodingHelper
......
...@@ -1306,7 +1306,15 @@ module Gitlab ...@@ -1306,7 +1306,15 @@ module Gitlab
# rubocop:enable Metrics/ParameterLists # rubocop:enable Metrics/ParameterLists
def write_config(full_path:) def write_config(full_path:)
rugged.config['gitlab.fullpath'] = full_path if full_path.present? return unless full_path.present?
gitaly_migrate(:write_config) do |is_enabled|
if is_enabled
gitaly_repository_client.write_config(full_path: full_path)
else
rugged_write_config(full_path: full_path)
end
end
end end
def gitaly_repository def gitaly_repository
...@@ -1446,6 +1454,10 @@ module Gitlab ...@@ -1446,6 +1454,10 @@ module Gitlab
end end
end end
def rugged_write_config(full_path:)
rugged.config['gitlab.fullpath'] = full_path
end
def shell_write_ref(ref_path, ref, old_ref) def shell_write_ref(ref_path, ref, old_ref)
raise ArgumentError, "invalid ref_path #{ref_path.inspect}" if ref_path.include?(' ') raise ArgumentError, "invalid ref_path #{ref_path.inspect}" if ref_path.include?(' ')
raise ArgumentError, "invalid ref #{ref.inspect}" if ref.include?("\x00") raise ArgumentError, "invalid ref #{ref.inspect}" if ref.include?("\x00")
...@@ -1507,7 +1519,7 @@ module Gitlab ...@@ -1507,7 +1519,7 @@ module Gitlab
if sparse_checkout_files if sparse_checkout_files
# Create worktree without checking out # Create worktree without checking out
run_git!(base_args + ['--no-checkout', worktree_path], env: env) run_git!(base_args + ['--no-checkout', worktree_path], env: env)
worktree_git_path = run_git!(%w(rev-parse --git-dir), chdir: worktree_path) worktree_git_path = run_git!(%w(rev-parse --git-dir), chdir: worktree_path).chomp
configure_sparse_checkout(worktree_git_path, sparse_checkout_files) configure_sparse_checkout(worktree_git_path, sparse_checkout_files)
......
...@@ -219,6 +219,19 @@ module Gitlab ...@@ -219,6 +219,19 @@ module Gitlab
true true
end end
def write_config(full_path:)
request = Gitaly::WriteConfigRequest.new(repository: @gitaly_repo, full_path: full_path)
response = GitalyClient.call(
@storage,
:repository_service,
:write_config,
request,
timeout: GitalyClient.fast_timeout
)
raise Gitlab::Git::OSError.new(response.error) unless response.error.empty?
end
end end
end end
end end
...@@ -4,6 +4,8 @@ module QA ...@@ -4,6 +4,8 @@ module QA
class Dropzone class Dropzone
attr_reader :page, :container attr_reader :page, :container
# page - A QA::Page::Base object
# container - CSS selector of the comment textarea's container
def initialize(page, container) def initialize(page, container)
@page = page @page = page
@container = container @container = container
......
...@@ -27,7 +27,7 @@ module QA ...@@ -27,7 +27,7 @@ module QA
fill_in(with: text, name: 'note[note]') fill_in(with: text, name: 'note[note]')
unless attachment.nil? unless attachment.nil?
QA::Page::Component::Dropzone.new(page, '.new-note') QA::Page::Component::Dropzone.new(self, '.new-note')
.attach_file(attachment) .attach_file(attachment)
end end
......
...@@ -84,7 +84,7 @@ module QA ...@@ -84,7 +84,7 @@ module QA
config.javascript_driver = :chrome config.javascript_driver = :chrome
config.default_max_wait_time = 10 config.default_max_wait_time = 10
# https://github.com/mattheworiordan/capybara-screenshot/issues/164 # https://github.com/mattheworiordan/capybara-screenshot/issues/164
config.save_path = 'tmp' config.save_path = File.expand_path('../../tmp', __dir__)
end end
end end
......
...@@ -61,7 +61,7 @@ describe 'Merge request > User selects branches for new MR', :js do ...@@ -61,7 +61,7 @@ describe 'Merge request > User selects branches for new MR', :js do
fill_in "merge_request_title", with: "Orphaned MR test" fill_in "merge_request_title", with: "Orphaned MR test"
click_button "Submit merge request" click_button "Submit merge request"
click_link "Check out branch" click_button "Check out branch"
expect(page).to have_content 'git checkout -b orphaned-branch origin/orphaned-branch' expect(page).to have_content 'git checkout -b orphaned-branch origin/orphaned-branch'
end end
......
import Vue from 'vue'; import Vue from 'vue';
import authorComponent from '~/vue_merge_request_widget/components/mr_widget_author'; import authorComponent from '~/vue_merge_request_widget/components/mr_widget_author.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
const author = {
webUrl: 'http://foo.bar',
avatarUrl: 'http://gravatar.com/foo',
name: 'fatihacet',
};
const createComponent = () => {
const Component = Vue.extend(authorComponent);
return new Component({
el: document.createElement('div'),
propsData: { author },
});
};
describe('MRWidgetAuthor', () => { describe('MRWidgetAuthor', () => {
describe('props', () => { let vm;
it('should have props', () => {
const authorProp = authorComponent.props.author; beforeEach(() => {
const Component = Vue.extend(authorComponent);
vm = mountComponent(Component, {
author: {
name: 'Administrator',
username: 'root',
webUrl: 'http://localhost:3000/root',
avatarUrl: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
},
expect(authorProp).toBeDefined();
expect(authorProp.type instanceof Object).toBeTruthy();
expect(authorProp.required).toBeTruthy();
}); });
}); });
describe('template', () => { afterEach(() => {
it('should have correct elements', () => { vm.$destroy();
const el = createComponent().$el; });
expect(el.tagName).toEqual('A'); it('renders link with the author web url', () => {
expect(el.getAttribute('href')).toEqual(author.webUrl); expect(vm.$el.getAttribute('href')).toEqual('http://localhost:3000/root');
expect(el.querySelector('img').getAttribute('src')).toEqual(author.avatarUrl); });
expect(el.querySelector('.author').innerText.trim()).toEqual(author.name);
}); it('renders image with avatar url', () => {
expect(
vm.$el.querySelector('img').getAttribute('src'),
).toEqual('http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon');
});
it('renders author name', () => {
expect(vm.$el.textContent.trim()).toEqual('Administrator');
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import authorTimeComponent from '~/vue_merge_request_widget/components/mr_widget_author_time'; import authorTimeComponent from '~/vue_merge_request_widget/components/mr_widget_author_time.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
const props = {
actionText: 'Merged by',
author: {
webUrl: 'http://foo.bar',
avatarUrl: 'http://gravatar.com/foo',
name: 'fatihacet',
},
dateTitle: '2017-03-23T23:02:00.807Z',
dateReadable: '12 hours ago',
};
const createComponent = () => {
const Component = Vue.extend(authorTimeComponent);
return new Component({
el: document.createElement('div'),
propsData: props,
});
};
describe('MRWidgetAuthorTime', () => { describe('MRWidgetAuthorTime', () => {
describe('props', () => { let vm;
it('should have props', () => {
const { actionText, author, dateTitle, dateReadable } = authorTimeComponent.props; beforeEach(() => {
const ActionTextClass = actionText.type; const Component = Vue.extend(authorTimeComponent);
const DateTitleClass = dateTitle.type;
const DateReadableClass = dateReadable.type; vm = mountComponent(Component, {
actionText: 'Merged by',
expect(new ActionTextClass() instanceof String).toBeTruthy(); author: {
expect(actionText.required).toBeTruthy(); name: 'Administrator',
username: 'root',
expect(author.type instanceof Object).toBeTruthy(); webUrl: 'http://localhost:3000/root',
expect(author.required).toBeTruthy(); avatarUrl: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
},
expect(new DateTitleClass() instanceof String).toBeTruthy(); dateTitle: '2017-03-23T23:02:00.807Z',
expect(dateTitle.required).toBeTruthy(); dateReadable: '12 hours ago',
expect(new DateReadableClass() instanceof String).toBeTruthy();
expect(dateReadable.required).toBeTruthy();
}); });
}); });
describe('components', () => { afterEach(() => {
it('should have components', () => { vm.$destroy();
expect(authorTimeComponent.components['mr-widget-author']).toBeDefined(); });
});
it('renders provided action text', () => {
expect(vm.$el.textContent).toContain('Merged by');
}); });
describe('template', () => { it('renders author', () => {
it('should have correct elements', () => { expect(vm.$el.textContent).toContain('Administrator');
const el = createComponent().$el; });
expect(el.tagName).toEqual('H4'); it('renders provided time', () => {
expect(el.querySelector('a').getAttribute('href')).toEqual(props.author.webUrl); expect(vm.$el.querySelector('time').getAttribute('title')).toEqual('2017-03-23T23:02:00.807Z');
expect(el.querySelector('time').innerText).toContain(props.dateReadable); expect(vm.$el.querySelector('time').textContent.trim()).toEqual('12 hours ago');
expect(el.querySelector('time').getAttribute('title')).toEqual(props.dateTitle);
});
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import mergeHelpComponent from '~/vue_merge_request_widget/components/mr_widget_merge_help'; import mergeHelpComponent from '~/vue_merge_request_widget/components/mr_widget_merge_help.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
const props = {
missingBranch: 'this-is-not-the-branch-you-are-looking-for',
};
const text = `If the ${props.missingBranch} branch exists in your local repository`;
const createComponent = () => {
const Component = Vue.extend(mergeHelpComponent);
return new Component({
el: document.createElement('div'),
propsData: props,
});
};
describe('MRWidgetMergeHelp', () => { describe('MRWidgetMergeHelp', () => {
describe('props', () => { let vm;
it('should have props', () => { let Component;
const { missingBranch } = mergeHelpComponent.props;
const MissingBranchTypeClass = missingBranch.type; beforeEach(() => {
Component = Vue.extend(mergeHelpComponent);
expect(new MissingBranchTypeClass() instanceof String).toBeTruthy();
expect(missingBranch.required).toBeFalsy();
expect(missingBranch.default).toEqual('');
});
}); });
describe('template', () => { afterEach(() => {
let vm; vm.$destroy();
let el; });
describe('with missing branch', () => {
beforeEach(() => { beforeEach(() => {
vm = createComponent(); vm = mountComponent(Component, {
el = vm.$el; missingBranch: 'this-is-not-the-branch-you-are-looking-for',
});
}); });
it('should have the correct elements', () => { it('renders missing branch information', () => {
expect(el.classList.contains('mr-widget-help')).toBeTruthy(); expect(
expect(el.textContent).toContain(text); vm.$el.textContent.trim().replace(/[\r\n]+/g, ' ').replace(/\s\s+/g, ' '),
).toEqual(
'If the this-is-not-the-branch-you-are-looking-for branch exists in your local repository, you can merge this merge request manually using the command line',
);
}); });
it('should not show missing branch name if missingBranch props is not provided', (done) => { it('renders button to open help modal', () => {
vm.missingBranch = null; expect(vm.$el.querySelector('.js-open-modal-help').getAttribute('data-target')).toEqual('#modal_merge_info');
Vue.nextTick(() => { expect(vm.$el.querySelector('.js-open-modal-help').getAttribute('data-toggle')).toEqual('modal');
expect(el.textContent).not.toContain(text); });
done(); });
});
describe('without missing branch', () => {
beforeEach(() => {
vm = mountComponent(Component);
});
it('renders information about how to merge manually', () => {
expect(
vm.$el.textContent.trim().replace(/[\r\n]+/g, ' ').replace(/\s\s+/g, ' '),
).toEqual(
'You can merge this merge request manually using the command line',
);
});
it('renders element to open a modal', () => {
expect(vm.$el.querySelector('.js-open-modal-help').getAttribute('data-target')).toEqual('#modal_merge_info');
expect(vm.$el.querySelector('.js-open-modal-help').getAttribute('data-toggle')).toEqual('modal');
}); });
}); });
}); });
import Vue from 'vue';
import confirmationInput from '~/vue_shared/components/confirmation_input.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
describe('Confirmation input component', () => {
const Component = Vue.extend(confirmationInput);
const props = {
inputId: 'dummy-id',
confirmationKey: 'confirmation-key',
confirmationValue: 'confirmation-value',
};
let vm;
afterEach(() => {
vm.$destroy();
});
describe('props', () => {
beforeEach(() => {
vm = mountComponent(Component, props);
});
it('sets id of the input field to inputId', () => {
expect(vm.$refs.enteredValue.id).toBe(props.inputId);
});
it('sets name of the input field to confirmationKey', () => {
expect(vm.$refs.enteredValue.name).toBe(props.confirmationKey);
});
});
describe('computed', () => {
describe('inputLabel', () => {
it('escapes confirmationValue by default', () => {
vm = mountComponent(Component, { ...props, confirmationValue: 'n<e></e>ds escap"ng' });
expect(vm.inputLabel).toBe('Type <code>n&lt;e&gt;&lt;/e&gt;ds escap&quot;ng</code> to confirm:');
});
it('does not escape confirmationValue if escapeValue is false', () => {
vm = mountComponent(Component, { ...props, confirmationValue: 'n<e></e>ds escap"ng', shouldEscapeConfirmationValue: false });
expect(vm.inputLabel).toBe('Type <code>n<e></e>ds escap"ng</code> to confirm:');
});
});
});
describe('methods', () => {
describe('hasCorrectValue', () => {
beforeEach(() => {
vm = mountComponent(Component, props);
});
it('returns false if entered value is incorrect', () => {
vm.$refs.enteredValue.value = 'incorrect';
expect(vm.hasCorrectValue()).toBe(false);
});
it('returns true if entered value is correct', () => {
vm.$refs.enteredValue.value = props.confirmationValue;
expect(vm.hasCorrectValue()).toBe(true);
});
});
});
});
...@@ -20,6 +20,7 @@ describe Gitlab::Git::Repository, seed_helper: true do ...@@ -20,6 +20,7 @@ describe Gitlab::Git::Repository, seed_helper: true do
let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') } let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') }
let(:storage_path) { TestEnv.repos_path } let(:storage_path) { TestEnv.repos_path }
let(:user) { build(:user) }
describe '.create_hooks' do describe '.create_hooks' do
let(:repo_path) { File.join(storage_path, 'hook-test.git') } let(:repo_path) { File.join(storage_path, 'hook-test.git') }
...@@ -693,7 +694,6 @@ describe Gitlab::Git::Repository, seed_helper: true do ...@@ -693,7 +694,6 @@ describe Gitlab::Git::Repository, seed_helper: true do
describe '#remote_tags' do describe '#remote_tags' do
let(:remote_name) { 'upstream' } let(:remote_name) { 'upstream' }
let(:target_commit_id) { SeedRepo::Commit::ID } let(:target_commit_id) { SeedRepo::Commit::ID }
let(:user) { create(:user) }
let(:tag_name) { 'v0.0.1' } let(:tag_name) { 'v0.0.1' }
let(:tag_message) { 'My tag' } let(:tag_message) { 'My tag' }
let(:remote_repository) do let(:remote_repository) do
...@@ -1711,7 +1711,6 @@ describe Gitlab::Git::Repository, seed_helper: true do ...@@ -1711,7 +1711,6 @@ describe Gitlab::Git::Repository, seed_helper: true do
shared_examples "user deleting a branch" do shared_examples "user deleting a branch" do
let(:project) { create(:project, :repository) } let(:project) { create(:project, :repository) }
let(:repository) { project.repository.raw } let(:repository) { project.repository.raw }
let(:user) { create(:user) }
let(:branch_name) { "to-be-deleted-soon" } let(:branch_name) { "to-be-deleted-soon" }
before do before do
...@@ -1752,12 +1751,49 @@ describe Gitlab::Git::Repository, seed_helper: true do ...@@ -1752,12 +1751,49 @@ describe Gitlab::Git::Repository, seed_helper: true do
end end
end end
describe '#write_config' do
before do
repository.rugged.config["gitlab.fullpath"] = repository.path
end
shared_examples 'writing repo config' do
context 'is given a path' do
it 'writes it to disk' do
repository.write_config(full_path: "not-the/real-path.git")
config = File.read(File.join(repository.path, "config"))
expect(config).to include("[gitlab]")
expect(config).to include("fullpath = not-the/real-path.git")
end
end
context 'it is given an empty path' do
it 'does not write it to disk' do
repository.write_config(full_path: "")
config = File.read(File.join(repository.path, "config"))
expect(config).to include("[gitlab]")
expect(config).to include("fullpath = #{repository.path}")
end
end
end
context "when gitaly_write_config is enabled" do
it_behaves_like "writing repo config"
end
context "when gitaly_write_config is disabled", :disable_gitaly do
it_behaves_like "writing repo config"
end
end
describe '#merge' do describe '#merge' do
let(:repository) do let(:repository) do
Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '') Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '')
end end
let(:source_sha) { '913c66a37b4a45b9769037c55c2d238bd0942d2e' } let(:source_sha) { '913c66a37b4a45b9769037c55c2d238bd0942d2e' }
let(:user) { build(:user) }
let(:target_branch) { 'test-merge-target-branch' } let(:target_branch) { 'test-merge-target-branch' }
before do before do
...@@ -1810,7 +1846,6 @@ describe Gitlab::Git::Repository, seed_helper: true do ...@@ -1810,7 +1846,6 @@ describe Gitlab::Git::Repository, seed_helper: true do
end end
let(:branch_head) { '6d394385cf567f80a8fd85055db1ab4c5295806f' } let(:branch_head) { '6d394385cf567f80a8fd85055db1ab4c5295806f' }
let(:source_sha) { 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660' } let(:source_sha) { 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660' }
let(:user) { build(:user) }
let(:target_branch) { 'test-ff-target-branch' } let(:target_branch) { 'test-ff-target-branch' }
before do before do
...@@ -2129,6 +2164,47 @@ describe Gitlab::Git::Repository, seed_helper: true do ...@@ -2129,6 +2164,47 @@ describe Gitlab::Git::Repository, seed_helper: true do
expect { subject }.to raise_error(Gitlab::Git::CommandError, 'error') expect { subject }.to raise_error(Gitlab::Git::CommandError, 'error')
end end
end end
describe '#squash' do
let(:squash_id) { '1' }
let(:branch_name) { 'fix' }
let(:start_sha) { '4b4918a572fa86f9771e5ba40fbd48e1eb03e2c6' }
let(:end_sha) { '12d65c8dd2b2676fa3ac47d955accc085a37a9c1' }
subject do
opts = {
branch: branch_name,
start_sha: start_sha,
end_sha: end_sha,
author: user,
message: 'Squash commit message'
}
repository.squash(user, squash_id, opts)
end
context 'sparse checkout' do
let(:expected_files) { %w(files files/js files/js/application.js) }
before do
allow(repository).to receive(:with_worktree).and_wrap_original do |m, *args|
m.call(*args) do
worktree_path = args[0]
files_pattern = File.join(worktree_path, '**', '*')
expected = expected_files.map do |path|
File.expand_path(path, worktree_path)
end
expect(Dir[files_pattern]).to eq(expected)
end
end
end
it 'checkouts only the files in the diff' do
subject
end
end
end
end end
def create_remote_branch(repository, remote_name, branch_name, source_branch_name) def create_remote_branch(repository, remote_name, branch_name, source_branch_name)
......
...@@ -88,5 +88,19 @@ describe Ci::PipelineSchedulePolicy, :models do ...@@ -88,5 +88,19 @@ describe Ci::PipelineSchedulePolicy, :models do
expect(policy).to be_allowed :admin_pipeline_schedule expect(policy).to be_allowed :admin_pipeline_schedule
end end
end end
describe 'rules for non-owner of schedule' do
let(:owner) { create(:user) }
before do
project.add_master(owner)
project.add_master(user)
pipeline_schedule.update(owner: owner)
end
it 'includes abilities to take ownership' do
expect(policy).to be_allowed :take_ownership_pipeline_schedule
end
end
end end
end end
require 'spec_helper'
describe 'projects/pipeline_schedules/_pipeline_schedule' do
let(:owner) { create(:user) }
let(:master) { create(:user) }
let(:project) { create(:project) }
let(:pipeline_schedule) { create(:ci_pipeline_schedule, :nightly, project: project) }
before do
assign(:project, project)
allow(view).to receive(:current_user).and_return(user)
allow(view).to receive(:pipeline_schedule).and_return(pipeline_schedule)
allow(view).to receive(:can?).and_return(true)
end
context 'taking ownership of schedule' do
context 'when non-owner is signed in' do
let(:user) { master }
before do
allow(view).to receive(:can?).with(master, :take_ownership_pipeline_schedule, pipeline_schedule).and_return(true)
end
it 'non-owner can take ownership of pipeline' do
render
expect(rendered).to have_link('Take ownership')
end
end
context 'when owner is signed in' do
let(:user) { owner }
before do
allow(view).to receive(:can?).with(owner, :take_ownership_pipeline_schedule, pipeline_schedule).and_return(false)
end
it 'owner cannot take ownership of pipeline' do
render
expect(rendered).not_to have_link('Take ownership')
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment