diff options
author | Sean McGivern <sean@mcgivern.me.uk> | 2018-02-02 11:58:32 +0000 |
---|---|---|
committer | Sean McGivern <sean@mcgivern.me.uk> | 2018-02-02 11:58:32 +0000 |
commit | 36838a843e99f551c971ec7062327e114c8f0188 (patch) | |
tree | 0ee2afe8228f184f7ad28e74d86bff2c5965c4ae | |
parent | a5bb17ffd5d466568c3bcea161bac42123bd3c1e (diff) | |
parent | c63af942e5baf7849a94fa99da8494bcba28e3f8 (diff) | |
download | gitlab-ce-36838a843e99f551c971ec7062327e114c8f0188.tar.gz |
Merge branch 'master' into '3867-port-to-ce'
# Conflicts:
# db/schema.rb
45 files changed, 942 insertions, 447 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index be18520b876..b4afa953175 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -738,8 +738,9 @@ cache gems: gitlab_git_test: <<: *dedicated-runner <<: *except-docs-and-qa - <<: *pull-cache variables: SETUP_DB: "false" + before_script: [] + cache: {} script: - spec/support/prepare-gitlab-git-test-for-commit --check-for-changes diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 62df9f538d8..4a7076db09a 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.76.0 +0.77.0 diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js deleted file mode 100644 index 982b5e8e373..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js +++ /dev/null @@ -1,28 +0,0 @@ -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> - `, -}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue new file mode 100644 index 00000000000..7ac9eadcde0 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue @@ -0,0 +1,33 @@ +<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> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js deleted file mode 100644 index 6d2ed5fda64..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js +++ /dev/null @@ -1,27 +0,0 @@ -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> - `, -}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue new file mode 100644 index 00000000000..8f1fd809a81 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue @@ -0,0 +1,42 @@ +<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> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js deleted file mode 100644 index de6e5149a87..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js +++ /dev/null @@ -1,117 +0,0 @@ -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> - `, -}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue new file mode 100644 index 00000000000..18a3787857d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -0,0 +1,145 @@ +<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> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js deleted file mode 100644 index 1d9f9863dd9..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js +++ /dev/null @@ -1,23 +0,0 @@ -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> - `, -}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue new file mode 100644 index 00000000000..62b61e1f41f --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue @@ -0,0 +1,41 @@ +<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> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue index 71bfdaf801e..68b691fc914 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue @@ -1,5 +1,5 @@ <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'; export default { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue index 72887528bd8..de98a77be6f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue @@ -1,7 +1,7 @@ <script> import Flash from '../../../flash'; 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'; export default { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue index a92e0b3c124..c1618bc6ea0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue @@ -3,7 +3,7 @@ import tooltip from '~/vue_shared/directives/tooltip'; import loadingIcon from '~/vue_shared/components/loading_icon.vue'; 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 eventHub from '../../event_hub'; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js index 303877d6fbf..7733fb74afe 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js @@ -1,6 +1,6 @@ import statusIcon from '../mr_widget_status_icon.vue'; 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 { name: 'MRWidgetMissingBranch', diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js index 515eb32f5d6..7ca15537719 100644 --- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -11,8 +11,8 @@ export { default as Vue } from 'vue'; export { default as SmartInterval } from '~/smart_interval'; -export { default as WidgetHeader } from './components/mr_widget_header'; -export { default as WidgetMergeHelp } from './components/mr_widget_merge_help'; +export { default as WidgetHeader } from './components/mr_widget_header.vue'; +export { default as WidgetMergeHelp } from './components/mr_widget_merge_help.vue'; export { default as WidgetPipeline } from './components/mr_widget_pipeline.vue'; export { default as WidgetDeployment } from './components/mr_widget_deployment'; export { default as WidgetRelatedLinks } from './components/mr_widget_related_links.vue'; diff --git a/app/assets/javascripts/vue_shared/components/confirmation_input.vue b/app/assets/javascripts/vue_shared/components/confirmation_input.vue new file mode 100644 index 00000000000..1aa03ea6317 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/confirmation_input.vue @@ -0,0 +1,62 @@ +<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> diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index bf8eb4c1f06..4a528bc2bb1 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -410,7 +410,6 @@ width: 298px; } - @media (max-width: $screen-xs-max) { display: flex; width: 100%; diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 8ef561d90e6..e91e1d29d64 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -96,12 +96,12 @@ module ApplicationSettingsHelper ] end - def repository_storages_options_for_select + def repository_storages_options_for_select(selected) options = Gitlab.config.repositories.storages.map do |name, storage| ["#{name} - #{storage['path']}", name] end - options_for_select(options, @application_setting.repository_storages) + options_for_select(options, selected) end def sidekiq_queue_options_for_select diff --git a/app/policies/ci/pipeline_schedule_policy.rb b/app/policies/ci/pipeline_schedule_policy.rb index abcf536b2f7..dc7a4aed577 100644 --- a/app/policies/ci/pipeline_schedule_policy.rb +++ b/app/policies/ci/pipeline_schedule_policy.rb @@ -10,6 +10,10 @@ module Ci can?(:developer_access) && pipeline_schedule.owned_by?(@user) end + condition(:non_owner_of_schedule) do + !pipeline_schedule.owned_by?(@user) + end + rule { can?(:developer_access) }.policy do enable :play_pipeline_schedule end @@ -19,6 +23,10 @@ module Ci enable :admin_pipeline_schedule end + rule { can?(:master_access) & non_owner_of_schedule }.policy do + enable :take_ownership_pipeline_schedule + end + rule { protected_ref }.prevent :play_pipeline_schedule end end diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index ba4ca88a8a9..fb5e6f337a7 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -537,7 +537,8 @@ .form-group = f.label :repository_storages, 'Storage paths for new projects', class: 'control-label col-sm-2' .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 Manage repository storage paths. Learn more in the = succeed "." do diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 331d62cf247..37b00a14fc6 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -15,10 +15,10 @@ %span.text Checking branch availability… .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 - %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') %ul#create-merge-request-dropdown.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-align-right.gl-show-field-errors{ data: { dropdown: true } } diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml index 800e234275c..a8692b83b07 100644 --- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml +++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml @@ -29,9 +29,10 @@ - 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 = 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 = 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 = icon('pencil') - if can?(current_user, :admin_pipeline_schedule, pipeline_schedule) diff --git a/changelogs/unreleased/35285-user-interface-bugs-for-schedule-pipelines.yml b/changelogs/unreleased/35285-user-interface-bugs-for-schedule-pipelines.yml new file mode 100644 index 00000000000..f3a04469884 --- /dev/null +++ b/changelogs/unreleased/35285-user-interface-bugs-for-schedule-pipelines.yml @@ -0,0 +1,5 @@ +--- +title: Hide pipeline schedule take ownership for current owner +merge_request: 12986 +author: +type: fixed diff --git a/changelogs/unreleased/add-confirmation-input-for-modals.yml b/changelogs/unreleased/add-confirmation-input-for-modals.yml new file mode 100644 index 00000000000..ff1027bc55a --- /dev/null +++ b/changelogs/unreleased/add-confirmation-input-for-modals.yml @@ -0,0 +1,5 @@ +--- +title: Add confirmation-input component +merge_request: 16816 +author: +type: other diff --git a/changelogs/unreleased/fix-improve-issue-note-dropdown.yml b/changelogs/unreleased/fix-improve-issue-note-dropdown.yml new file mode 100644 index 00000000000..aaf4811c64a --- /dev/null +++ b/changelogs/unreleased/fix-improve-issue-note-dropdown.yml @@ -0,0 +1,5 @@ +--- +title: Improve issue note dropdown and mr button +merge_request: 16758 +author: George Tsiolis +type: changed diff --git a/changelogs/unreleased/issues-closed-at-steal.yml b/changelogs/unreleased/issues-closed-at-steal.yml new file mode 100644 index 00000000000..a5f0898995f --- /dev/null +++ b/changelogs/unreleased/issues-closed-at-steal.yml @@ -0,0 +1,5 @@ +--- +title: Finish any remaining jobs for issues.closed_at +merge_request: +author: +type: other diff --git a/db/migrate/20180201145907_migrate_remaining_issues_closed_at.rb b/db/migrate/20180201145907_migrate_remaining_issues_closed_at.rb new file mode 100644 index 00000000000..7cb913bb2bf --- /dev/null +++ b/db/migrate/20180201145907_migrate_remaining_issues_closed_at.rb @@ -0,0 +1,42 @@ +# 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 diff --git a/db/schema.rb b/db/schema.rb index 962d25b40b1..01a2df13dd3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # 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 enable_extension "plpgsql" diff --git a/doc/README.md b/doc/README.md index 330670587f5..c8b6b4f32b8 100644 --- a/doc/README.md +++ b/doc/README.md @@ -8,23 +8,13 @@ comments: false Welcome to [GitLab](https://about.gitlab.com/), a Git-based fully featured 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/), -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). +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. -> **GitLab EE** contains all features available in **GitLab CE**, -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**. +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. ----- - -Shortcuts to GitLab's most visited docs: +## Shortcuts to GitLab's most visited docs | [GitLab CI/CD](ci/README.md) | Other | | :----- | :----- | @@ -134,14 +124,8 @@ Manage your [repositories](user/project/repository/index.md) from the UI (user i ## Administrator documentation -[Administration documentation](administration/index.md) applies to admin users of GitLab -self-hosted instances: - -- GitLab Community Edition -- GitLab [Enterprise Editions](https://about.gitlab.com/gitlab-ee/) - - Enterprise Edition Starter (EES) - - Enterprise Edition Premium (EEP) - - Enterprise Edition Ultimate (EEU) +[Administration documentation](administration/index.md) applies to admin users of [GitLab +self-hosted instances](#self-hosted-gitlab): Libre, Starter, Premium, Ultimate. 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. diff --git a/doc/install/kubernetes/gitlab_runner_chart.md b/doc/install/kubernetes/gitlab_runner_chart.md index ca9c95aeced..1f53e12d5f8 100644 --- a/doc/install/kubernetes/gitlab_runner_chart.md +++ b/doc/install/kubernetes/gitlab_runner_chart.md @@ -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: - - `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 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: ## 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 ## -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 ## be retreived from your GitLab Instance. diff --git a/lib/gitlab/git.rb b/lib/gitlab/git.rb index c77db0f685f..d4e893b881c 100644 --- a/lib/gitlab/git.rb +++ b/lib/gitlab/git.rb @@ -6,6 +6,7 @@ module Gitlab CommandError = Class.new(StandardError) CommitError = Class.new(StandardError) + OSError = Class.new(StandardError) class << self include Gitlab::EncodingHelper diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 68b54d28876..f28624ff37a 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -1306,7 +1306,15 @@ module Gitlab # rubocop:enable Metrics/ParameterLists 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 def gitaly_repository @@ -1446,6 +1454,10 @@ module Gitlab end end + def rugged_write_config(full_path:) + rugged.config['gitlab.fullpath'] = full_path + end + 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 #{ref.inspect}" if ref.include?("\x00") @@ -1507,7 +1519,7 @@ module Gitlab if sparse_checkout_files # Create worktree without checking out 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) diff --git a/lib/gitlab/gitaly_client/repository_service.rb b/lib/gitlab/gitaly_client/repository_service.rb index 7adf32af209..60706b4f0d8 100644 --- a/lib/gitlab/gitaly_client/repository_service.rb +++ b/lib/gitlab/gitaly_client/repository_service.rb @@ -219,6 +219,19 @@ module Gitlab true 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 diff --git a/qa/qa/page/component/dropzone.rb b/qa/qa/page/component/dropzone.rb index 5e6fdff20eb..15bdc742fda 100644 --- a/qa/qa/page/component/dropzone.rb +++ b/qa/qa/page/component/dropzone.rb @@ -4,6 +4,8 @@ module QA class Dropzone attr_reader :page, :container + # page - A QA::Page::Base object + # container - CSS selector of the comment textarea's container def initialize(page, container) @page = page @container = container diff --git a/qa/qa/page/project/issue/show.rb b/qa/qa/page/project/issue/show.rb index 364a2c61665..5bc0598a524 100644 --- a/qa/qa/page/project/issue/show.rb +++ b/qa/qa/page/project/issue/show.rb @@ -27,7 +27,7 @@ module QA fill_in(with: text, name: 'note[note]') unless attachment.nil? - QA::Page::Component::Dropzone.new(page, '.new-note') + QA::Page::Component::Dropzone.new(self, '.new-note') .attach_file(attachment) end diff --git a/qa/qa/runtime/browser.rb b/qa/qa/runtime/browser.rb index ce888b51ea5..a12d95683af 100644 --- a/qa/qa/runtime/browser.rb +++ b/qa/qa/runtime/browser.rb @@ -84,7 +84,7 @@ module QA config.javascript_driver = :chrome config.default_max_wait_time = 10 # https://github.com/mattheworiordan/capybara-screenshot/issues/164 - config.save_path = 'tmp' + config.save_path = File.expand_path('../../tmp', __dir__) end end diff --git a/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb b/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb index fb73ab05f87..dbca279569a 100644 --- a/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb +++ b/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb @@ -61,7 +61,7 @@ describe 'Merge request > User selects branches for new MR', :js do fill_in "merge_request_title", with: "Orphaned MR test" 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' end diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js index a750bc78f36..f14d5f6f76c 100644 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_author_spec.js @@ -1,39 +1,39 @@ import Vue from 'vue'; -import authorComponent from '~/vue_merge_request_widget/components/mr_widget_author'; - -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 }, - }); -}; +import authorComponent from '~/vue_merge_request_widget/components/mr_widget_author.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; describe('MRWidgetAuthor', () => { - describe('props', () => { - it('should have props', () => { - const authorProp = authorComponent.props.author; + let vm; + + 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', () => { - it('should have correct elements', () => { - const el = createComponent().$el; + afterEach(() => { + vm.$destroy(); + }); - expect(el.tagName).toEqual('A'); - expect(el.getAttribute('href')).toEqual(author.webUrl); - expect(el.querySelector('img').getAttribute('src')).toEqual(author.avatarUrl); - expect(el.querySelector('.author').innerText.trim()).toEqual(author.name); - }); + it('renders link with the author web url', () => { + expect(vm.$el.getAttribute('href')).toEqual('http://localhost:3000/root'); + }); + + 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'); }); }); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_author_time_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_author_time_spec.js index 515ddcbb875..8c55622b15e 100644 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_author_time_spec.js +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_author_time_spec.js @@ -1,61 +1,40 @@ import Vue from 'vue'; -import authorTimeComponent from '~/vue_merge_request_widget/components/mr_widget_author_time'; - -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, - }); -}; +import authorTimeComponent from '~/vue_merge_request_widget/components/mr_widget_author_time.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; describe('MRWidgetAuthorTime', () => { - describe('props', () => { - it('should have props', () => { - const { actionText, author, dateTitle, dateReadable } = authorTimeComponent.props; - const ActionTextClass = actionText.type; - const DateTitleClass = dateTitle.type; - const DateReadableClass = dateReadable.type; - - expect(new ActionTextClass() instanceof String).toBeTruthy(); - expect(actionText.required).toBeTruthy(); - - expect(author.type instanceof Object).toBeTruthy(); - expect(author.required).toBeTruthy(); - - expect(new DateTitleClass() instanceof String).toBeTruthy(); - expect(dateTitle.required).toBeTruthy(); - - expect(new DateReadableClass() instanceof String).toBeTruthy(); - expect(dateReadable.required).toBeTruthy(); + let vm; + + beforeEach(() => { + const Component = Vue.extend(authorTimeComponent); + + vm = mountComponent(Component, { + actionText: 'Merged by', + author: { + name: 'Administrator', + username: 'root', + webUrl: 'http://localhost:3000/root', + avatarUrl: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }, + dateTitle: '2017-03-23T23:02:00.807Z', + dateReadable: '12 hours ago', }); }); - describe('components', () => { - it('should have components', () => { - expect(authorTimeComponent.components['mr-widget-author']).toBeDefined(); - }); + afterEach(() => { + vm.$destroy(); + }); + + it('renders provided action text', () => { + expect(vm.$el.textContent).toContain('Merged by'); }); - describe('template', () => { - it('should have correct elements', () => { - const el = createComponent().$el; + it('renders author', () => { + expect(vm.$el.textContent).toContain('Administrator'); + }); - expect(el.tagName).toEqual('H4'); - expect(el.querySelector('a').getAttribute('href')).toEqual(props.author.webUrl); - expect(el.querySelector('time').innerText).toContain(props.dateReadable); - expect(el.querySelector('time').getAttribute('title')).toEqual(props.dateTitle); - }); + it('renders provided time', () => { + expect(vm.$el.querySelector('time').getAttribute('title')).toEqual('2017-03-23T23:02:00.807Z'); + expect(vm.$el.querySelector('time').textContent.trim()).toEqual('12 hours ago'); }); }); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js index 93bb83ca8bd..13e5595bbfc 100644 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_header_spec.js @@ -1,121 +1,220 @@ import Vue from 'vue'; -import headerComponent from '~/vue_merge_request_widget/components/mr_widget_header'; - -const createComponent = (mr) => { - const Component = Vue.extend(headerComponent); - return new Component({ - el: document.createElement('div'), - propsData: { mr }, - }); -}; +import headerComponent from '~/vue_merge_request_widget/components/mr_widget_header.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; describe('MRWidgetHeader', () => { - describe('props', () => { - it('should have props', () => { - const { mr } = headerComponent.props; + let vm; + let Component; - expect(mr.type instanceof Object).toBeTruthy(); - expect(mr.required).toBeTruthy(); - }); + beforeEach(() => { + Component = Vue.extend(headerComponent); + }); + + afterEach(() => { + vm.$destroy(); }); describe('computed', () => { - let vm; - beforeEach(() => { - vm = createComponent({ - divergedCommitsCount: 12, - sourceBranch: 'mr-widget-refactor', - sourceBranchLink: '/foo/bar/mr-widget-refactor', - targetBranch: 'master', + describe('shouldShowCommitsBehindText', () => { + it('return true when there are divergedCommitsCount', () => { + vm = mountComponent(Component, { mr: { + divergedCommitsCount: 12, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>', + targetBranch: 'master', + } }); + + expect(vm.shouldShowCommitsBehindText).toEqual(true); + }); + + it('returns false where there are no divergedComits count', () => { + vm = mountComponent(Component, { mr: { + divergedCommitsCount: 0, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>', + targetBranch: 'master', + } }); + expect(vm.shouldShowCommitsBehindText).toEqual(false); }); }); - it('shouldShowCommitsBehindText', () => { - expect(vm.shouldShowCommitsBehindText).toBeTruthy(); + describe('commitsText', () => { + it('returns singular when there is one commit', () => { + vm = mountComponent(Component, { mr: { + divergedCommitsCount: 1, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>', + targetBranch: 'master', + } }); - vm.mr.divergedCommitsCount = 0; - expect(vm.shouldShowCommitsBehindText).toBeFalsy(); - }); + expect(vm.commitsText).toEqual('1 commit behind'); + }); - it('commitsText', () => { - expect(vm.commitsText).toEqual('commits'); + it('returns plural when there is more than one commit', () => { + vm = mountComponent(Component, { mr: { + divergedCommitsCount: 2, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>', + targetBranch: 'master', + } }); - vm.mr.divergedCommitsCount = 1; - expect(vm.commitsText).toEqual('commit'); + expect(vm.commitsText).toEqual('2 commits behind'); + }); }); }); describe('template', () => { - let vm; - let el; - let mr; - const sourceBranchPath = '/foo/bar/mr-widget-refactor'; - - beforeEach(() => { - mr = { - divergedCommitsCount: 12, - sourceBranch: 'mr-widget-refactor', - sourceBranchLink: `<a href="${sourceBranchPath}">mr-widget-refactor</a>`, - sourceBranchRemoved: false, - targetBranchPath: 'foo/bar/commits-path', - targetBranchTreePath: 'foo/bar/tree/path', - targetBranch: 'master', - isOpen: true, - emailPatchesPath: '/mr/email-patches', - plainDiffPath: '/mr/plainDiffPath', - }; - - vm = createComponent(mr); - el = vm.$el; + describe('common elements', () => { + beforeEach(() => { + vm = mountComponent(Component, { mr: { + divergedCommitsCount: 12, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', + sourceBranchRemoved: false, + targetBranchPath: 'foo/bar/commits-path', + targetBranchTreePath: 'foo/bar/tree/path', + targetBranch: 'master', + isOpen: true, + emailPatchesPath: '/mr/email-patches', + plainDiffPath: '/mr/plainDiffPath', + } }); + }); + + it('renders source branch link', () => { + expect( + vm.$el.querySelector('.js-source-branch').innerHTML, + ).toEqual('<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>'); + }); + + it('renders clipboard button', () => { + expect(vm.$el.querySelector('.btn-clipboard')).not.toEqual(null); + }); + + it('renders target branch', () => { + expect(vm.$el.querySelector('.js-target-branch').textContent.trim()).toEqual('master'); + }); }); - it('should render template elements correctly', () => { - expect(el.classList.contains('mr-source-target')).toBeTruthy(); - const sourceBranchLink = el.querySelectorAll('.label-branch')[0]; - const targetBranchLink = el.querySelectorAll('.label-branch')[1]; - const commitsCount = el.querySelector('.diverged-commits-count'); + describe('with an open merge request', () => { + afterEach(() => { + vm.$destroy(); + }); - expect(sourceBranchLink.textContent).toContain(mr.sourceBranch); - expect(targetBranchLink.textContent).toContain(mr.targetBranch); - expect(sourceBranchLink.querySelector('a').getAttribute('href')).toEqual(sourceBranchPath); - expect(targetBranchLink.querySelector('a').getAttribute('href')).toEqual(mr.targetBranchTreePath); - expect(commitsCount.textContent).toContain('12 commits behind'); - expect(commitsCount.querySelector('a').getAttribute('href')).toEqual(mr.targetBranchPath); + beforeEach(() => { + vm = mountComponent(Component, { mr: { + divergedCommitsCount: 12, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', + sourceBranchRemoved: false, + targetBranchPath: 'foo/bar/commits-path', + targetBranchTreePath: 'foo/bar/tree/path', + targetBranch: 'master', + isOpen: true, + emailPatchesPath: '/mr/email-patches', + plainDiffPath: '/mr/plainDiffPath', + } }); + }); + + it('renders checkout branch button with modal trigger', () => { + const button = vm.$el.querySelector('.js-check-out-branch'); + + expect(button.textContent.trim()).toEqual('Check out branch'); + expect(button.getAttribute('data-target')).toEqual('#modal_merge_info'); + expect(button.getAttribute('data-toggle')).toEqual('modal'); + }); + + it('renders download dropdown with links', () => { + expect( + vm.$el.querySelector('.js-download-email-patches').textContent.trim(), + ).toEqual('Email patches'); - expect(el.textContent).toContain('Check out branch'); - expect(el.querySelectorAll('.dropdown li a')[0].getAttribute('href')).toEqual(mr.emailPatchesPath); - expect(el.querySelectorAll('.dropdown li a')[1].getAttribute('href')).toEqual(mr.plainDiffPath); + expect( + vm.$el.querySelector('.js-download-email-patches').getAttribute('href'), + ).toEqual('/mr/email-patches'); - expect(el.querySelector('a[href="#modal_merge_info"]').getAttribute('disabled')).toBeNull(); + expect( + vm.$el.querySelector('.js-download-plain-diff').textContent.trim(), + ).toEqual('Plain diff'); + + expect( + vm.$el.querySelector('.js-download-plain-diff').getAttribute('href'), + ).toEqual('/mr/plainDiffPath'); + }); }); - it('should not have right action links if the MR state is not open', (done) => { - vm.mr.isOpen = false; - Vue.nextTick(() => { - expect(el.textContent).not.toContain('Check out branch'); - expect(el.querySelectorAll('.dropdown li a').length).toEqual(0); - done(); + describe('with a closed merge request', () => { + beforeEach(() => { + vm = mountComponent(Component, { mr: { + divergedCommitsCount: 12, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', + sourceBranchRemoved: false, + targetBranchPath: 'foo/bar/commits-path', + targetBranchTreePath: 'foo/bar/tree/path', + targetBranch: 'master', + isOpen: false, + emailPatchesPath: '/mr/email-patches', + plainDiffPath: '/mr/plainDiffPath', + } }); + }); + + it('does not render checkout branch button with modal trigger', () => { + const button = vm.$el.querySelector('.js-check-out-branch'); + + expect(button).toEqual(null); + }); + + it('does not render download dropdown with links', () => { + expect( + vm.$el.querySelector('.js-download-email-patches'), + ).toEqual(null); + + expect( + vm.$el.querySelector('.js-download-plain-diff'), + ).toEqual(null); }); }); - it('should not render diverged commits count if the MR has no diverged commits', (done) => { - vm.mr.divergedCommitsCount = null; - Vue.nextTick(() => { - expect(el.textContent).not.toContain('commits behind'); - expect(el.querySelectorAll('.diverged-commits-count').length).toEqual(0); - done(); + describe('without diverged commits', () => { + beforeEach(() => { + vm = mountComponent(Component, { mr: { + divergedCommitsCount: 0, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', + sourceBranchRemoved: false, + targetBranchPath: 'foo/bar/commits-path', + targetBranchTreePath: 'foo/bar/tree/path', + targetBranch: 'master', + isOpen: true, + emailPatchesPath: '/mr/email-patches', + plainDiffPath: '/mr/plainDiffPath', + } }); + }); + + it('does not render diverged commits info', () => { + expect(vm.$el.querySelector('.diverged-commits-count')).toEqual(null); }); }); - it('should disable check out branch button if source branch has been removed', (done) => { - vm.mr.sourceBranchRemoved = true; + describe('with diverged commits', () => { + beforeEach(() => { + vm = mountComponent(Component, { mr: { + divergedCommitsCount: 12, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', + sourceBranchRemoved: false, + targetBranchPath: 'foo/bar/commits-path', + targetBranchTreePath: 'foo/bar/tree/path', + targetBranch: 'master', + isOpen: true, + emailPatchesPath: '/mr/email-patches', + plainDiffPath: '/mr/plainDiffPath', + } }); + }); - Vue.nextTick() - .then(() => { - expect(el.querySelector('a[href="#modal_merge_info"]').getAttribute('disabled')).toBe('disabled'); - done(); - }) - .catch(done.fail); + it('renders diverged commits info', () => { + expect(vm.$el.querySelector('.diverged-commits-count').textContent.trim()).toEqual('(12 commits behind)'); + }); }); }); }); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_merge_help_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_merge_help_spec.js index 4da4fc82c26..cc43639f576 100644 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_merge_help_spec.js +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_merge_help_spec.js @@ -1,51 +1,56 @@ import Vue from 'vue'; -import mergeHelpComponent from '~/vue_merge_request_widget/components/mr_widget_merge_help'; - -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, - }); -}; +import mergeHelpComponent from '~/vue_merge_request_widget/components/mr_widget_merge_help.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; describe('MRWidgetMergeHelp', () => { - describe('props', () => { - it('should have props', () => { - const { missingBranch } = mergeHelpComponent.props; - const MissingBranchTypeClass = missingBranch.type; - - expect(new MissingBranchTypeClass() instanceof String).toBeTruthy(); - expect(missingBranch.required).toBeFalsy(); - expect(missingBranch.default).toEqual(''); - }); + let vm; + let Component; + + beforeEach(() => { + Component = Vue.extend(mergeHelpComponent); }); - describe('template', () => { - let vm; - let el; + afterEach(() => { + vm.$destroy(); + }); + describe('with missing branch', () => { beforeEach(() => { - vm = createComponent(); - el = vm.$el; + vm = mountComponent(Component, { + missingBranch: 'this-is-not-the-branch-you-are-looking-for', + }); }); - it('should have the correct elements', () => { - expect(el.classList.contains('mr-widget-help')).toBeTruthy(); - expect(el.textContent).toContain(text); + it('renders missing branch information', () => { + expect( + 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) => { - vm.missingBranch = null; - Vue.nextTick(() => { - expect(el.textContent).not.toContain(text); - done(); - }); + it('renders button to open help 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'); + }); + }); + + 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'); }); }); }); diff --git a/spec/javascripts/vue_shared/components/confirmation_input_spec.js b/spec/javascripts/vue_shared/components/confirmation_input_spec.js new file mode 100644 index 00000000000..a6a12614e77 --- /dev/null +++ b/spec/javascripts/vue_shared/components/confirmation_input_spec.js @@ -0,0 +1,63 @@ +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<e></e>ds escap"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); + }); + }); + }); +}); diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index d4f56a41d9a..96a442f782f 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -20,6 +20,7 @@ describe Gitlab::Git::Repository, seed_helper: true do let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') } let(:storage_path) { TestEnv.repos_path } + let(:user) { build(:user) } describe '.create_hooks' do let(:repo_path) { File.join(storage_path, 'hook-test.git') } @@ -693,7 +694,6 @@ describe Gitlab::Git::Repository, seed_helper: true do describe '#remote_tags' do let(:remote_name) { 'upstream' } let(:target_commit_id) { SeedRepo::Commit::ID } - let(:user) { create(:user) } let(:tag_name) { 'v0.0.1' } let(:tag_message) { 'My tag' } let(:remote_repository) do @@ -1711,7 +1711,6 @@ describe Gitlab::Git::Repository, seed_helper: true do shared_examples "user deleting a branch" do let(:project) { create(:project, :repository) } let(:repository) { project.repository.raw } - let(:user) { create(:user) } let(:branch_name) { "to-be-deleted-soon" } before do @@ -1752,12 +1751,49 @@ describe Gitlab::Git::Repository, seed_helper: true do 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 let(:repository) do Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '') end let(:source_sha) { '913c66a37b4a45b9769037c55c2d238bd0942d2e' } - let(:user) { build(:user) } let(:target_branch) { 'test-merge-target-branch' } before do @@ -1810,7 +1846,6 @@ describe Gitlab::Git::Repository, seed_helper: true do end let(:branch_head) { '6d394385cf567f80a8fd85055db1ab4c5295806f' } let(:source_sha) { 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660' } - let(:user) { build(:user) } let(:target_branch) { 'test-ff-target-branch' } before do @@ -2129,6 +2164,47 @@ describe Gitlab::Git::Repository, seed_helper: true do expect { subject }.to raise_error(Gitlab::Git::CommandError, 'error') 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 def create_remote_branch(repository, remote_name, branch_name, source_branch_name) diff --git a/spec/policies/ci/pipeline_schedule_policy_spec.rb b/spec/policies/ci/pipeline_schedule_policy_spec.rb index 1b0e9fac355..c0c3eda4911 100644 --- a/spec/policies/ci/pipeline_schedule_policy_spec.rb +++ b/spec/policies/ci/pipeline_schedule_policy_spec.rb @@ -88,5 +88,19 @@ describe Ci::PipelineSchedulePolicy, :models do expect(policy).to be_allowed :admin_pipeline_schedule 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 diff --git a/spec/views/projects/pipeline_schedules/_pipeline_schedule.html.haml_spec.rb b/spec/views/projects/pipeline_schedules/_pipeline_schedule.html.haml_spec.rb new file mode 100644 index 00000000000..6e7d8db99c4 --- /dev/null +++ b/spec/views/projects/pipeline_schedules/_pipeline_schedule.html.haml_spec.rb @@ -0,0 +1,47 @@ +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 |