diff options
author | Winnie Hellmann <winnie@gitlab.com> | 2017-11-30 14:02:33 +0000 |
---|---|---|
committer | Winnie Hellmann <winnie@gitlab.com> | 2017-11-30 14:02:33 +0000 |
commit | 07886087156029b552aba1d089aaa482fad90905 (patch) | |
tree | 9112ab0aa905475dfdb866dc9076c76fc1a16ac9 | |
parent | da70bc43bff32a928485ba1202b76387f8e22b90 (diff) | |
parent | f3f8dd47dfa289dc9b5ded78e60d3b92938e18fe (diff) | |
download | gitlab-ce-07886087156029b552aba1d089aaa482fad90905.tar.gz |
Merge branch '10-2-stable-patch-3' into '10-2-stable'
Prepare 10.2.3 release
See merge request gitlab-org/gitlab-ce!15625
80 files changed, 1549 insertions, 281 deletions
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 4f9b378b40f..27d68a6aacb 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.52.0 +0.52.1 diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index b7747ee3f83..c84be42649a 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -36,7 +36,10 @@ export default function dropzoneInput(form) { $formDropzone.append(divHover); $formDropzone.find('.div-dropzone-hover').append(iconPaperclip); - if (!uploadsPath) return; + if (!uploadsPath) { + $formDropzone.addClass('js-invalid-dropzone'); + return; + } const dropzone = $formDropzone.dropzone({ url: uploadsPath, diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index 4e39d483b31..5bdc7c99503 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -16,6 +16,10 @@ export default { required: true, type: String, }, + updateEndpoint: { + required: true, + type: String, + }, canUpdate: { required: true, type: Boolean, @@ -34,6 +38,11 @@ export default { required: false, default: true, }, + enableAutocomplete: { + type: Boolean, + required: false, + default: true, + }, issuableRef: { type: String, required: true, @@ -240,6 +249,7 @@ export default { :project-namespace="projectNamespace" :show-delete-button="showDeleteButton" :can-attach-file="canAttachFile" + :enable-autocomplete="enableAutocomplete" /> <div v-else> <title-component @@ -256,6 +266,8 @@ export default { :description-text="state.descriptionText" :updated-at="state.updatedAt" :task-status="state.taskStatus" + :issuable-type="issuableType" + :update-url="updateEndpoint" /> <edited-component v-if="hasUpdated" diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index 48bad8f1e68..b7559ced946 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -22,6 +22,16 @@ required: false, default: '', }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + updateUrl: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -48,7 +58,7 @@ if (this.canUpdate) { // eslint-disable-next-line no-new new TaskList({ - dataType: 'issue', + dataType: this.issuableType, fieldName: 'description', selector: '.detail-page-description', }); @@ -95,7 +105,9 @@ <textarea class="hidden js-task-list-field" v-if="descriptionText" - v-model="descriptionText"> + v-model="descriptionText" + :data-update-url="updateUrl" + > </textarea> </div> </template> diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue index 4d2ef409bad..52fe4ecd08b 100644 --- a/app/assets/javascripts/issue_show/components/fields/description.vue +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -22,6 +22,11 @@ required: false, default: true, }, + enableAutocomplete: { + type: Boolean, + required: false, + default: true, + }, }, components: { markdownField, @@ -42,7 +47,9 @@ <markdown-field :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" - :can-attach-file="canAttachFile"> + :can-attach-file="canAttachFile" + :enable-autocomplete="enableAutocomplete" + > <textarea id="issue-description" class="note-textarea js-gfm-input js-autosize markdown-area" diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue index d61776d480d..0fa19022336 100644 --- a/app/assets/javascripts/issue_show/components/form.vue +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -46,6 +46,11 @@ required: false, default: true, }, + enableAutocomplete: { + type: Boolean, + required: false, + default: true, + }, }, components: { lockedWarning, @@ -89,7 +94,9 @@ :form-state="formState" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" - :can-attach-file="canAttachFile" /> + :can-attach-file="canAttachFile" + :enable-autocomplete="enableAutocomplete" + /> <edit-actions :form-state="formState" :can-destroy="canDestroy" diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 29fc91733b3..f871ff4aa35 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -149,3 +149,17 @@ export function timeIntervalInWords(intervalInSeconds) { } return text; } + +export function dateInWords(date, abbreviated = false) { + if (!date) return date; + + const month = date.getMonth(); + const year = date.getFullYear(); + + const monthNames = [s__('January'), s__('February'), s__('March'), s__('April'), s__('May'), s__('June'), s__('July'), s__('August'), s__('September'), s__('October'), s__('November'), s__('December')]; + const monthNamesAbbr = [s__('Jan'), s__('Feb'), s__('Mar'), s__('Apr'), s__('May'), s__('Jun'), s__('Jul'), s__('Aug'), s__('Sep'), s__('Oct'), s__('Nov'), s__('Dec')]; + + const monthName = abbreviated ? monthNamesAbbr[month] : monthNames[month]; + + return `${monthName} ${date.getDate()}, ${year}`; +} diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index f776829f69c..d070dd1e7c3 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -195,3 +195,12 @@ gl.text.dasherize = function(str) { gl.text.slugify = function(str) { return str.trim().toLowerCase().latinise(); }; +/** + * Capitalizes first character + * + * @param {String} text + * @return {String} + */ +export function capitalizeFirstCharacter(text) { + return `${text[0].toUpperCase()}${text.slice(1)}`; +} diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index ee50ce27c3d..15e3d713448 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -30,6 +30,11 @@ required: false, default: true, }, + enableAutocomplete: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -97,7 +102,7 @@ /* GLForm class handles all the toolbar buttons */ - return new GLForm($(this.$refs['gl-form']), true); + return new GLForm($(this.$refs['gl-form']), this.enableAutocomplete); }, beforeDestroy() { const glForm = $(this.$refs['gl-form']).data('gl-form'); diff --git a/app/assets/javascripts/vue_shared/components/pikaday.vue b/app/assets/javascripts/vue_shared/components/pikaday.vue new file mode 100644 index 00000000000..d8d974a2ff7 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/pikaday.vue @@ -0,0 +1,79 @@ +<script> + import Pikaday from 'pikaday'; + import { parsePikadayDate, pikadayToString } from '../../lib/utils/datefix'; + + export default { + name: 'datePicker', + props: { + label: { + type: String, + required: false, + default: 'Date picker', + }, + selectedDate: { + type: Date, + required: false, + }, + minDate: { + type: Date, + required: false, + }, + maxDate: { + type: Date, + required: false, + }, + }, + methods: { + selected(dateText) { + this.$emit('newDateSelected', this.calendar.toString(dateText)); + }, + toggled() { + this.$emit('hidePicker'); + }, + }, + mounted() { + this.calendar = new Pikaday({ + field: this.$el.querySelector('.dropdown-menu-toggle'), + theme: 'gitlab-theme animate-picker', + format: 'yyyy-mm-dd', + container: this.$el, + defaultDate: this.selectedDate, + setDefaultDate: !!this.selectedDate, + minDate: this.minDate, + maxDate: this.maxDate, + parse: dateString => parsePikadayDate(dateString), + toString: date => pikadayToString(date), + onSelect: this.selected.bind(this), + onClose: this.toggled.bind(this), + }); + + this.$el.append(this.calendar.el); + this.calendar.show(); + }, + beforeDestroy() { + this.calendar.destroy(); + }, + }; +</script> + +<template> + <div class="pikaday-container"> + <div class="dropdown open"> + <button + type="button" + class="dropdown-menu-toggle" + data-toggle="dropdown" + @click="toggled" + > + <span class="dropdown-toggle-text"> + {{label}} + </span> + <i + class="fa fa-chevron-down" + aria-hidden="true" + > + </i> + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue new file mode 100644 index 00000000000..a88e1310131 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue @@ -0,0 +1,46 @@ +<script> + export default { + name: 'collapsedCalendarIcon', + props: { + containerClass: { + type: String, + required: false, + default: '', + }, + text: { + type: String, + required: false, + default: '', + }, + showIcon: { + type: Boolean, + required: false, + default: true, + }, + }, + methods: { + click() { + this.$emit('click'); + }, + }, + }; +</script> + +<template> + <div + :class="containerClass" + @click="click" + > + <i + v-if="showIcon" + class="fa fa-calendar" + aria-hidden="true" + > + </i> + <slot> + <span> + {{ text }} + </span> + </slot> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue new file mode 100644 index 00000000000..9ede5553bc5 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue @@ -0,0 +1,109 @@ +<script> + import { dateInWords } from '../../../lib/utils/datetime_utility'; + import toggleSidebar from './toggle_sidebar.vue'; + import collapsedCalendarIcon from './collapsed_calendar_icon.vue'; + + export default { + name: 'sidebarCollapsedGroupedDatePicker', + props: { + collapsed: { + type: Boolean, + required: false, + default: true, + }, + showToggleSidebar: { + type: Boolean, + required: false, + default: false, + }, + minDate: { + type: Date, + required: false, + }, + maxDate: { + type: Date, + required: false, + }, + disableClickableIcons: { + type: Boolean, + required: false, + default: false, + }, + }, + components: { + toggleSidebar, + collapsedCalendarIcon, + }, + computed: { + hasMinAndMaxDates() { + return this.minDate && this.maxDate; + }, + hasNoMinAndMaxDates() { + return !this.minDate && !this.maxDate; + }, + showMinDateBlock() { + return this.minDate || this.hasNoMinAndMaxDates; + }, + showFromText() { + return !this.maxDate && this.minDate; + }, + iconClass() { + const disabledClass = this.disableClickableIcons ? 'disabled' : ''; + return `block sidebar-collapsed-icon calendar-icon ${disabledClass}`; + }, + }, + methods: { + toggleSidebar() { + this.$emit('toggleCollapse'); + }, + dateText(dateType = 'min') { + const date = this[`${dateType}Date`]; + const dateWords = dateInWords(date, true); + const parsedDateWords = dateWords ? dateWords.replace(',', '') : dateWords; + + return date ? parsedDateWords : 'None'; + }, + }, + }; +</script> + +<template> + <div class="block sidebar-grouped-item"> + <div + v-if="showToggleSidebar" + class="issuable-sidebar-header" + > + <toggle-sidebar + :collapsed="collapsed" + @toggle="toggleSidebar" + /> + </div> + <collapsed-calendar-icon + v-if="showMinDateBlock" + :container-class="iconClass" + @click="toggleSidebar" + > + <span class="sidebar-collapsed-value"> + <span v-if="showFromText">From</span> + <span>{{ dateText('min') }}</span> + </span> + </collapsed-calendar-icon> + <div + v-if="hasMinAndMaxDates" + class="text-center sidebar-collapsed-divider" + > + - + </div> + <collapsed-calendar-icon + v-if="maxDate" + :container-class="iconClass" + :show-icon="!minDate" + @click="toggleSidebar" + > + <span class="sidebar-collapsed-value"> + <span v-if="!minDate">Until</span> + <span>{{ dateText('max') }}</span> + </span> + </collapsed-calendar-icon> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue new file mode 100644 index 00000000000..9c3413377a3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue @@ -0,0 +1,163 @@ +<script> + import datePicker from '../pikaday.vue'; + import loadingIcon from '../loading_icon.vue'; + import toggleSidebar from './toggle_sidebar.vue'; + import collapsedCalendarIcon from './collapsed_calendar_icon.vue'; + import { dateInWords } from '../../../lib/utils/datetime_utility'; + + export default { + name: 'sidebarDatePicker', + props: { + collapsed: { + type: Boolean, + required: false, + default: true, + }, + showToggleSidebar: { + type: Boolean, + required: false, + default: false, + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + editable: { + type: Boolean, + required: false, + default: false, + }, + label: { + type: String, + required: false, + default: 'Date picker', + }, + selectedDate: { + type: Date, + required: false, + }, + minDate: { + type: Date, + required: false, + }, + maxDate: { + type: Date, + required: false, + }, + }, + data() { + return { + editing: false, + }; + }, + components: { + datePicker, + toggleSidebar, + loadingIcon, + collapsedCalendarIcon, + }, + computed: { + selectedAndEditable() { + return this.selectedDate && this.editable; + }, + selectedDateWords() { + return dateInWords(this.selectedDate, true); + }, + collapsedText() { + return this.selectedDateWords ? this.selectedDateWords : 'None'; + }, + }, + methods: { + stopEditing() { + this.editing = false; + }, + toggleDatePicker() { + this.editing = !this.editing; + }, + newDateSelected(date = null) { + this.date = date; + this.editing = false; + this.$emit('saveDate', date); + }, + toggleSidebar() { + this.$emit('toggleCollapse'); + }, + }, + }; +</script> + +<template> + <div class="block"> + <div class="issuable-sidebar-header"> + <toggle-sidebar + :collapsed="collapsed" + @toggle="toggleSidebar" + /> + </div> + <collapsed-calendar-icon + class="sidebar-collapsed-icon" + :text="collapsedText" + /> + <div class="title"> + {{ label }} + <loading-icon + v-if="isLoading" + :inline="true" + /> + <div class="pull-right"> + <button + v-if="editable && !editing" + type="button" + class="btn-blank btn-link btn-primary-hover-link btn-sidebar-action" + @click="toggleDatePicker" + > + Edit + </button> + <toggle-sidebar + v-if="showToggleSidebar" + :collapsed="collapsed" + @toggle="toggleSidebar" + /> + </div> + </div> + <div class="value"> + <date-picker + v-if="editing" + :selected-date="selectedDate" + :min-date="minDate" + :max-date="maxDate" + :label="label" + @newDateSelected="newDateSelected" + @hidePicker="stopEditing" + /> + <span + v-else + class="value-content" + > + <template v-if="selectedDate"> + <strong>{{ selectedDateWords }}</strong> + <span + v-if="selectedAndEditable" + class="no-value" + > + - + <button + type="button" + class="btn-blank btn-link btn-secondary-hover-link" + @click="newDateSelected(null)" + > + remove + </button> + </span> + </template> + <span + v-else + class="no-value" + > + None + </span> + </span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue new file mode 100644 index 00000000000..5ae76adad71 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue @@ -0,0 +1,30 @@ +<script> + export default { + name: 'toggleSidebar', + props: { + collapsed: { + type: Boolean, + required: true, + }, + }, + methods: { + toggle() { + this.$emit('toggle'); + }, + }, + }; +</script> + +<template> + <button + type="button" + class="btn btn-blank gutter-toggle btn-sidebar-action" + @click="toggle" + > + <i + aria-label="toggle collapse" + class="fa" + :class="{ 'fa-angle-double-right': !collapsed, 'fa-angle-double-left': collapsed }" + ></i> + </button> +</template> diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js index cba7b9227cd..06a86f3b94a 100644 --- a/app/assets/javascripts/zen_mode.js +++ b/app/assets/javascripts/zen_mode.js @@ -71,7 +71,7 @@ export default class ZenMode { this.active_textarea = this.active_backdrop.find('textarea'); // Prevent a user-resized textarea from persisting to fullscreen this.active_textarea.removeAttr('style'); - return this.active_textarea.focus(); + this.active_textarea.focus(); } exit() { @@ -81,7 +81,11 @@ export default class ZenMode { this.scrollTo(this.active_textarea); this.active_textarea = null; this.active_backdrop = null; - return Dropzone.forElement('.div-dropzone').enable(); + + const $dropzone = $('.div-dropzone'); + if ($dropzone && !$dropzone.hasClass('js-invalid-dropzone')) { + Dropzone.forElement('.div-dropzone').enable(); + } } } diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index b2f26cf7159..dfa3d4c6fb9 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -408,6 +408,7 @@ padding: 0; background: transparent; border: 0; + border-radius: 0; &:hover, &:active, @@ -417,3 +418,25 @@ box-shadow: none; } } + +.btn-link.btn-secondary-hover-link { + color: $gl-text-color-secondary; + + &:hover, + &:active, + &:focus { + color: $gl-link-color; + text-decoration: none; + } +} + +.btn-link.btn-primary-hover-link { + color: inherit; + + &:hover, + &:active, + &:focus { + color: $gl-link-color; + text-decoration: none; + } +} diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 1a19b7320a0..792981fdc48 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -43,11 +43,13 @@ } .sidebar-collapsed-icon { - cursor: pointer; - .btn { background-color: $gray-light; } + + &:not(.disabled) { + cursor: pointer; + } } } @@ -55,6 +57,10 @@ padding-right: 0; z-index: 300; + .btn-sidebar-action { + display: inline-flex; + } + @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { &:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper { padding-right: $gutter_collapsed_width; @@ -136,3 +142,18 @@ .issuable-sidebar { @include new-style-dropdown; } + +.pikaday-container { + .pika-single { + margin-top: 2px; + width: 250px; + } + + .dropdown-menu-toggle { + line-height: 20px; + } +} + +.sidebar-collapsed-icon .sidebar-collapsed-value { + font-size: 12px; +} diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 760c7c80aff..d9d00197dc0 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -284,10 +284,15 @@ font-weight: $gl-font-weight-normal; } - .no-value { + .no-value, + .btn-secondary-hover-link { color: $gl-text-color-secondary; } + .btn-secondary-hover-link:hover { + color: $gl-link-color; + } + .sidebar-collapsed-icon { display: none; } @@ -295,6 +300,8 @@ .gutter-toggle { margin-top: 7px; border-left: 1px solid $border-gray-normal; + padding-left: 0; + text-align: center; } .title .gutter-toggle { @@ -367,7 +374,7 @@ fill: $issuable-sidebar-color; } - &:hover, + &:hover:not(.disabled), &:hover .todo-undone { color: $gl-text-color; @@ -908,3 +915,21 @@ margin: 0 3px; } } + +.right-sidebar-collapsed { + .sidebar-grouped-item { + .sidebar-collapsed-icon { + margin-bottom: 0; + } + + .sidebar-collapsed-divider { + line-height: 5px; + font-size: 12px; + color: $theme-gray-700; + + + .sidebar-collapsed-icon { + padding-top: 0; + } + } + } +} diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb index 5ce602b55a8..e9b9e9b38bc 100644 --- a/app/controllers/concerns/preview_markdown.rb +++ b/app/controllers/concerns/preview_markdown.rb @@ -8,6 +8,7 @@ module PreviewMarkdown case controller_name when 'wikis' then { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] } when 'snippets' then { skip_project_check: true } + when 'groups' then { group: group } else {} end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index a9840d19178..4c60f4b0cd0 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -212,6 +212,7 @@ module IssuablesHelper def issuable_initial_data(issuable) data = { endpoint: issuable_path(issuable), + updateEndpoint: "#{issuable_path(issuable)}.json", canUpdate: can?(current_user, :"update_#{issuable.to_ability_name}", issuable), canDestroy: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable), issuableRef: issuable.to_reference, diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 6ca46ae89c1..11a57e4b7cb 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -104,6 +104,7 @@ module Ci end before_transition any => [:failed] do |build| + next unless build.project next if build.retries_max.zero? if build.retries_count < build.retries_max diff --git a/app/models/commit.rb b/app/models/commit.rb index a31ebe9cc87..383412f0a56 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -109,12 +109,12 @@ class Commit @link_reference_pattern ||= super("commit", /(?<commit>#{COMMIT_SHA_PATTERN})/) end - def to_reference(from_project = nil, full: false) - commit_reference(from_project, id, full: full) + def to_reference(from = nil, full: false) + commit_reference(from, id, full: full) end - def reference_link_text(from_project = nil, full: false) - commit_reference(from_project, short_id, full: full) + def reference_link_text(from = nil, full: false) + commit_reference(from, short_id, full: full) end def diff_line_count @@ -381,8 +381,8 @@ class Commit private - def commit_reference(from_project, referable_commit_id, full: false) - reference = project.to_reference(from_project, full: full) + def commit_reference(from, referable_commit_id, full: false) + reference = project.to_reference(from, full: full) if reference.present? "#{reference}#{self.class.reference_prefix}#{referable_commit_id}" diff --git a/app/models/commit_range.rb b/app/models/commit_range.rb index 84e2e8a5dd5..b93c111dabc 100644 --- a/app/models/commit_range.rb +++ b/app/models/commit_range.rb @@ -89,8 +89,8 @@ class CommitRange alias_method :id, :to_s - def to_reference(from_project = nil, full: false) - project_reference = project.to_reference(from_project, full: full) + def to_reference(from = nil, full: false) + project_reference = project.to_reference(from, full: full) if project_reference.present? project_reference + self.class.reference_prefix + self.id @@ -99,8 +99,8 @@ class CommitRange end end - def reference_link_text(from_project = nil) - project_reference = project.to_reference(from_project) + def reference_link_text(from = nil) + project_reference = project.to_reference(from) reference = ref_from + notation + ref_to if project_reference.present? diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 6b07dbdf3ea..ee21ed8e420 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -17,6 +17,7 @@ class CommitStatus < ActiveRecord::Base validates :name, presence: true, unless: :importing? alias_attribute :author, :user + alias_attribute :pipeline_id, :commit_id scope :failed_but_allowed, -> do where(allow_failure: true, status: [:failed, :canceled]) @@ -103,26 +104,29 @@ class CommitStatus < ActiveRecord::Base end after_transition do |commit_status, transition| + next unless commit_status.project next if transition.loopback? commit_status.run_after_commit do - if pipeline + if pipeline_id if complete? || manual? - PipelineProcessWorker.perform_async(pipeline.id) + PipelineProcessWorker.perform_async(pipeline_id) else - PipelineUpdateWorker.perform_async(pipeline.id) + PipelineUpdateWorker.perform_async(pipeline_id) end end - StageUpdateWorker.perform_async(commit_status.stage_id) - ExpireJobCacheWorker.perform_async(commit_status.id) + StageUpdateWorker.perform_async(stage_id) + ExpireJobCacheWorker.perform_async(id) end end after_transition any => :failed do |commit_status| + next unless commit_status.project + commit_status.run_after_commit do MergeRequests::AddTodoWhenBuildFailsService - .new(pipeline.project, nil).execute(self) + .new(project, nil).execute(self) end end end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index c008fb91a16..0172ad9c56e 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -341,4 +341,11 @@ module Issuable def first_contribution? false end + + ## + # Overriden in MergeRequest + # + def wipless_title_changed(old_title) + old_title != title + end end diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index 1db6b2d2fa2..b43eaeaeea0 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -31,11 +31,11 @@ module Mentionable # # By default this will be the class name and the result of calling # `to_reference` on the object. - def gfm_reference(from_project = nil) + def gfm_reference(from = nil) # "MergeRequest" > "merge_request" > "Merge request" > "merge request" friendly_name = self.class.to_s.underscore.humanize.downcase - "#{friendly_name} #{to_reference(from_project)}" + "#{friendly_name} #{to_reference(from)}" end # The GFM reference to this Mentionable, which shouldn't be included in its #references. diff --git a/app/models/concerns/referable.rb b/app/models/concerns/referable.rb index 78ac4f324e7..b782e85717e 100644 --- a/app/models/concerns/referable.rb +++ b/app/models/concerns/referable.rb @@ -7,7 +7,7 @@ module Referable # Returns the String necessary to reference this object in Markdown # - # from_project - Refering Project object + # from - Referring parent object # # This should be overridden by the including class. # @@ -17,12 +17,12 @@ module Referable # Issue.last.to_reference(other_project) # => "cross-project#1" # # Returns a String - def to_reference(_from_project = nil, full:) + def to_reference(_from = nil, full:) '' end - def reference_link_text(from_project = nil) - to_reference(from_project) + def reference_link_text(from = nil) + to_reference(from) end included do diff --git a/app/models/external_issue.rb b/app/models/external_issue.rb index 9ff56f229bc..2aaba2e4c90 100644 --- a/app/models/external_issue.rb +++ b/app/models/external_issue.rb @@ -38,11 +38,11 @@ class ExternalIssue @project.id end - def to_reference(_from_project = nil, full: nil) + def to_reference(_from = nil, full: nil) id end - def reference_link_text(from_project = nil) + def reference_link_text(from = nil) return "##{id}" if id =~ /^\d+$/ id diff --git a/app/models/group.rb b/app/models/group.rb index 8cf632fb566..dc4500360b9 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -97,7 +97,7 @@ class Group < Namespace end end - def to_reference(_from_project = nil, full: nil) + def to_reference(_from = nil, full: nil) "#{self.class.reference_prefix}#{full_path}" end diff --git a/app/models/label.rb b/app/models/label.rb index 899028a01a0..b5bfa6ea2dd 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -165,12 +165,12 @@ class Label < ActiveRecord::Base # # Returns a String # - def to_reference(from_project = nil, target_project: nil, format: :id, full: false) + def to_reference(from = nil, target_project: nil, format: :id, full: false) format_reference = label_format_reference(format) reference = "#{self.class.reference_prefix}#{format_reference}" - if from_project - "#{from_project.to_reference(target_project, full: full)}#{reference}" + if from + "#{from.to_reference(target_project, full: full)}#{reference}" else reference end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index dd4e67bc9da..72e786b6b57 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -181,6 +181,12 @@ class MergeRequest < ActiveRecord::Base work_in_progress?(title) ? title : "WIP: #{title}" end + # Verifies if title has changed not taking into account WIP prefix + # for merge requests. + def wipless_title_changed(old_title) + self.class.wipless_title(old_title) != self.wipless_title + end + def hook_attrs Gitlab::HookData::MergeRequestBuilder.new(self).build end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 47e6b785c39..637b7f5a062 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -162,18 +162,18 @@ class Milestone < ActiveRecord::Base # Milestone.first.to_reference(cross_namespace_project) # => "gitlab-org/gitlab-ce%1" # Milestone.first.to_reference(same_namespace_project) # => "gitlab-ce%1" # - def to_reference(from_project = nil, format: :name, full: false) + def to_reference(from = nil, format: :name, full: false) format_reference = milestone_format_reference(format) reference = "#{self.class.reference_prefix}#{format_reference}" if project - "#{project.to_reference(from_project, full: full)}#{reference}" + "#{project.to_reference(from, full: full)}#{reference}" else reference end end - def reference_link_text(from_project = nil) + def reference_link_text(from = nil) self.title end diff --git a/app/models/project.rb b/app/models/project.rb index 853f6bc504a..f6bf29e772f 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -18,6 +18,7 @@ class Project < ActiveRecord::Base include SelectForProjectAuthorization include Routable include GroupDescendant + include Gitlab::SQL::Pattern extend Gitlab::ConfigHelper extend Gitlab::CurrentSettings @@ -424,32 +425,17 @@ class Project < ActiveRecord::Base # # query - The search query as a String. def search(query) - ptable = arel_table - ntable = Namespace.arel_table - pattern = "%#{query}%" - - # unscoping unnecessary conditions that'll be applied - # when executing `where("projects.id IN (#{union.to_sql})")` - projects = unscoped.select(:id).where( - ptable[:path].matches(pattern) - .or(ptable[:name].matches(pattern)) - .or(ptable[:description].matches(pattern)) - ) - - namespaces = unscoped.select(:id) - .joins(:namespace) - .where(ntable[:name].matches(pattern)) - - union = Gitlab::SQL::Union.new([projects, namespaces]) + pattern = to_pattern(query) - where("projects.id IN (#{union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection + where( + arel_table[:path].matches(pattern) + .or(arel_table[:name].matches(pattern)) + .or(arel_table[:description].matches(pattern)) + ) end def search_by_title(query) - pattern = "%#{query}%" - table = Project.arel_table - - non_archived.where(table[:name].matches(pattern)) + non_archived.where(arel_table[:name].matches(to_pattern(query))) end def visibility_levels @@ -764,10 +750,10 @@ class Project < ActiveRecord::Base end end - def to_human_reference(from_project = nil) - if cross_namespace_reference?(from_project) + def to_human_reference(from = nil) + if cross_namespace_reference?(from) name_with_namespace - elsif cross_project_reference?(from_project) + elsif cross_project_reference?(from) name end end diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 9533aa7f555..2a5f07a15c4 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -75,11 +75,11 @@ class Snippet < ActiveRecord::Base @link_reference_pattern ||= super("snippets", /(?<snippet>\d+)/) end - def to_reference(from_project = nil, full: false) + def to_reference(from = nil, full: false) reference = "#{self.class.reference_prefix}#{id}" if project.present? - "#{project.to_reference(from_project, full: full)}#{reference}" + "#{project.to_reference(from, full: full)}#{reference}" else reference end diff --git a/app/models/user.rb b/app/models/user.rb index 47c78d23591..3209138a772 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -438,7 +438,7 @@ class User < ActiveRecord::Base username end - def to_reference(_from_project = nil, target_project: nil, full: nil) + def to_reference(_from = nil, target_project: nil, full: nil) "#{self.class.reference_prefix}#{username}" end diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb index 92eaa5d5115..3da21bd8b8f 100644 --- a/app/services/issuable/common_system_notes_service.rb +++ b/app/services/issuable/common_system_notes_service.rb @@ -41,6 +41,14 @@ module Issuable end end + def create_wip_note(old_title) + return unless issuable.is_a?(MergeRequest) + + if MergeRequest.work_in_progress?(old_title) != issuable.work_in_progress? + SystemNoteService.handle_merge_request_wip(issuable, issuable.project, current_user) + end + end + def create_labels_note(old_labels) added_labels = issuable.labels - old_labels removed_labels = old_labels - issuable.labels @@ -49,7 +57,11 @@ module Issuable end def create_title_change_note(old_title) - SystemNoteService.change_title(issuable, issuable.project, current_user, old_title) + create_wip_note(old_title) + + if issuable.wipless_title_changed(old_title) + SystemNoteService.change_title(issuable, issuable.project, current_user, old_title) + end end def create_description_change_note diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index fe71a405565..30a5aab13bf 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -241,14 +241,10 @@ module SystemNoteService create_note(NoteSummary.new(noteable, project, author, body, action: 'merge')) end - def remove_merge_request_wip(noteable, project, author) - body = 'unmarked as a **Work In Progress**' + def handle_merge_request_wip(noteable, project, author) + prefix = noteable.work_in_progress? ? "marked" : "unmarked" - create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) - end - - def add_merge_request_wip(noteable, project, author) - body = 'marked as a **Work In Progress**' + body = "#{prefix} as a **Work In Progress**" create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) end diff --git a/app/views/events/_event.atom.builder b/app/views/events/_event.atom.builder index e2aec532a9d..38741fe6662 100644 --- a/app/views/events/_event.atom.builder +++ b/app/views/events/_event.atom.builder @@ -5,7 +5,12 @@ xml.entry do xml.link href: event_feed_url(event) xml.title truncate(event_feed_title(event), length: 80) xml.updated event.updated_at.xmlschema - xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon(event.author_email)) + + # We're deliberately re-using "event.author" here since this data is + # eager-loaded. This allows us to re-use the user object's Email address, + # instead of having to run additional queries to figure out what Email to use + # for the avatar. + xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon(event.author)) xml.author do xml.username event.author_username diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb index 269776a1f62..b0c739bbcb6 100644 --- a/app/workers/stuck_ci_jobs_worker.rb +++ b/app/workers/stuck_ci_jobs_worker.rb @@ -44,9 +44,17 @@ class StuckCiJobsWorker end def search(status, timeout) - builds = Ci::Build.where(status: status).where('ci_builds.updated_at < ?', timeout.ago) - builds.joins(:project).merge(Project.without_deleted).includes(:tags, :runner, project: :namespace).find_each(batch_size: 50).each do |build| - yield(build) + loop do + jobs = Ci::Build.where(status: status) + .where('ci_builds.updated_at < ?', timeout.ago) + .includes(:tags, :runner, project: :namespace) + .limit(100) + .to_a + break if jobs.empty? + + jobs.each do |job| + yield(job) + end end end diff --git a/changelogs/unreleased/40291-ignore-hashed-repos-cleanup-repositories.yml b/changelogs/unreleased/40291-ignore-hashed-repos-cleanup-repositories.yml new file mode 100644 index 00000000000..1e3f52b3a9c --- /dev/null +++ b/changelogs/unreleased/40291-ignore-hashed-repos-cleanup-repositories.yml @@ -0,0 +1,5 @@ +--- +title: Ensure that rake gitlab:cleanup:repos task does not mess with hashed repositories +merge_request: 15520 +author: +type: fixed diff --git a/changelogs/unreleased/40352-ignore-hashed-repos-cleanup-dirs.yml b/changelogs/unreleased/40352-ignore-hashed-repos-cleanup-dirs.yml new file mode 100644 index 00000000000..0ccbc699729 --- /dev/null +++ b/changelogs/unreleased/40352-ignore-hashed-repos-cleanup-dirs.yml @@ -0,0 +1,5 @@ +--- +title: Ensure that rake gitlab:cleanup:dirs task does not mess with hashed repositories +merge_request: 15600 +author: +type: fixed diff --git a/changelogs/unreleased/default-values-for-mr-states.yml b/changelogs/unreleased/default-values-for-mr-states.yml new file mode 100644 index 00000000000..f873a5335d0 --- /dev/null +++ b/changelogs/unreleased/default-values-for-mr-states.yml @@ -0,0 +1,5 @@ +--- +title: Fix defaults for MR states and merge statuses +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/dm-fix-registry-with-sudo-token.yml b/changelogs/unreleased/dm-fix-registry-with-sudo-token.yml new file mode 100644 index 00000000000..be687fda147 --- /dev/null +++ b/changelogs/unreleased/dm-fix-registry-with-sudo-token.yml @@ -0,0 +1,5 @@ +--- +title: Fix pulling and pushing using a personal access token with the sudo scope +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/dm-project-search-performance.yml b/changelogs/unreleased/dm-project-search-performance.yml new file mode 100644 index 00000000000..b533043b163 --- /dev/null +++ b/changelogs/unreleased/dm-project-search-performance.yml @@ -0,0 +1,6 @@ +--- +title: Drastically improve project search performance by no longer searching namespace + name +merge_request: +author: +type: performance diff --git a/changelogs/unreleased/events-atom-feed-author-query.yml b/changelogs/unreleased/events-atom-feed-author-query.yml new file mode 100644 index 00000000000..84c51f25de7 --- /dev/null +++ b/changelogs/unreleased/events-atom-feed-author-query.yml @@ -0,0 +1,5 @@ +--- +title: Reuse authors when rendering event Atom feeds +merge_request: +author: +type: performance diff --git a/changelogs/unreleased/fix-import-uploads-hashed-storage.yml b/changelogs/unreleased/fix-import-uploads-hashed-storage.yml new file mode 100644 index 00000000000..d43cabbfb8f --- /dev/null +++ b/changelogs/unreleased/fix-import-uploads-hashed-storage.yml @@ -0,0 +1,5 @@ +--- +title: Fix hashed storage for Import/Export uploads +merge_request: 15482 +author: +type: fixed diff --git a/changelogs/unreleased/issue_40374.yml b/changelogs/unreleased/issue_40374.yml new file mode 100644 index 00000000000..73b48b890fe --- /dev/null +++ b/changelogs/unreleased/issue_40374.yml @@ -0,0 +1,5 @@ +--- +title: Fix WIP system note not being created +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/jk-group-mentions-fix.yml b/changelogs/unreleased/jk-group-mentions-fix.yml new file mode 100644 index 00000000000..a28e3a87b6d --- /dev/null +++ b/changelogs/unreleased/jk-group-mentions-fix.yml @@ -0,0 +1,5 @@ +--- +title: Fix link text from group context +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/optimise-stuck-ci-jobs-worker.yml b/changelogs/unreleased/optimise-stuck-ci-jobs-worker.yml new file mode 100644 index 00000000000..7f6adfb4fd8 --- /dev/null +++ b/changelogs/unreleased/optimise-stuck-ci-jobs-worker.yml @@ -0,0 +1,5 @@ +--- +title: Optimise StuckCiJobsWorker using cheap SQL query outside, and expensive inside +merge_request: +author: +type: performance diff --git a/db/migrate/20171124125042_add_default_values_to_merge_request_states.rb b/db/migrate/20171124125042_add_default_values_to_merge_request_states.rb new file mode 100644 index 00000000000..d08863c3b78 --- /dev/null +++ b/db/migrate/20171124125042_add_default_values_to_merge_request_states.rb @@ -0,0 +1,19 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddDefaultValuesToMergeRequestStates < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def up + change_column_default :merge_requests, :state, :opened + change_column_default :merge_requests, :merge_status, :unchecked + end + + def down + change_column_default :merge_requests, :state, nil + change_column_default :merge_requests, :merge_status, nil + end +end diff --git a/db/migrate/20171124125748_populate_missing_merge_request_statuses.rb b/db/migrate/20171124125748_populate_missing_merge_request_statuses.rb new file mode 100644 index 00000000000..72fbab59f4c --- /dev/null +++ b/db/migrate/20171124125748_populate_missing_merge_request_statuses.rb @@ -0,0 +1,50 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class PopulateMissingMergeRequestStatuses < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + disable_ddl_transaction! + + class MergeRequest < ActiveRecord::Base + include EachBatch + + self.table_name = 'merge_requests' + end + + def up + say 'Populating missing merge_requests.state values' + + # GitLab.com has no rows where "state" is NULL, and technically this should + # never happen. However it doesn't hurt to be 100% certain. + MergeRequest.where(state: nil).each_batch do |batch| + batch.update_all(state: 'opened') + end + + say 'Populating missing merge_requests.merge_status values. ' \ + 'This will take a few minutes...' + + # GitLab.com has 66 880 rows where "merge_status" is NULL, dating back all + # the way to 2011. + MergeRequest.where(merge_status: nil).each_batch(of: 10_000) do |batch| + batch.update_all(merge_status: 'unchecked') + + # We want to give PostgreSQL some time to vacuum any dead tuples. In + # production we see it takes roughly 1 minute for a vacuuming run to clear + # out 10-20k dead tuples, so we'll wait for 90 seconds between every + # batch. + sleep(90) if sleep? + end + end + + def down + # Reverting this makes no sense. + end + + def sleep? + Rails.env.staging? || Rails.env.production? + end +end diff --git a/db/migrate/20171124132536_make_merge_request_statuses_not_null.rb b/db/migrate/20171124132536_make_merge_request_statuses_not_null.rb new file mode 100644 index 00000000000..4bb09126036 --- /dev/null +++ b/db/migrate/20171124132536_make_merge_request_statuses_not_null.rb @@ -0,0 +1,14 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class MakeMergeRequestStatusesNotNull < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + change_column_null :merge_requests, :state, false + change_column_null :merge_requests, :merge_status, false + end +end diff --git a/db/schema.rb b/db/schema.rb index 66aa505bb04..0eef3f503a1 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: 20171121144800) do +ActiveRecord::Schema.define(version: 20171124132536) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -1037,8 +1037,8 @@ ActiveRecord::Schema.define(version: 20171121144800) do t.datetime "created_at" t.datetime "updated_at" t.integer "milestone_id" - t.string "state" - t.string "merge_status" + t.string "state", default: "opened", null: false + t.string "merge_status", default: "unchecked", null: false t.integer "target_project_id", null: false t.integer "iid" t.text "description" diff --git a/lib/banzai/filter/abstract_reference_filter.rb b/lib/banzai/filter/abstract_reference_filter.rb index 9fef386de16..8975395aff1 100644 --- a/lib/banzai/filter/abstract_reference_filter.rb +++ b/lib/banzai/filter/abstract_reference_filter.rb @@ -213,7 +213,8 @@ module Banzai end def object_link_text(object, matches) - text = object.reference_link_text(context[:project]) + parent = context[:project] || context[:group] + text = object.reference_link_text(parent) extras = object_link_text_extras(object, matches) text += " (#{extras.join(", ")})" if extras.any? diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index cbbc51db99e..0e7958ef90f 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -128,7 +128,7 @@ module Gitlab token = PersonalAccessTokensFinder.new(state: 'active').find_by(token: password) if token && valid_scoped_token?(token, available_scopes) - Gitlab::Auth::Result.new(token.user, nil, :personal_access_token, abilities_for_scope(token.scopes)) + Gitlab::Auth::Result.new(token.user, nil, :personal_access_token, abilities_for_scopes(token.scopes)) end end @@ -140,10 +140,15 @@ module Gitlab AccessTokenValidationService.new(token).include_any_scope?(scopes) end - def abilities_for_scope(scopes) - scopes.map do |scope| - self.public_send(:"#{scope}_scope_authentication_abilities") # rubocop:disable GitlabSecurity/PublicSend - end.flatten.uniq + def abilities_for_scopes(scopes) + abilities_by_scope = { + api: full_authentication_abilities, + read_registry: [:read_container_image] + } + + scopes.flat_map do |scope| + abilities_by_scope.fetch(scope.to_sym, []) + end.uniq end def lfs_token_check(login, password, project) @@ -222,16 +227,6 @@ module Gitlab :admin_container_image ] end - alias_method :api_scope_authentication_abilities, :full_authentication_abilities - - def read_registry_scope_authentication_abilities - [:read_container_image] - end - - # The currently used auth method doesn't allow any actions for this scope - def read_user_scope_authentication_abilities - [] - end def available_scopes(current_user = nil) scopes = API_SCOPES + registry_scopes diff --git a/lib/gitlab/import_export/uploads_saver.rb b/lib/gitlab/import_export/uploads_saver.rb index f9ae5079d7c..627a487d577 100644 --- a/lib/gitlab/import_export/uploads_saver.rb +++ b/lib/gitlab/import_export/uploads_saver.rb @@ -24,8 +24,7 @@ module Gitlab end def uploads_path - # TODO: decide what to do with uploads. We will use UUIDs here too? - File.join(Rails.root.join('public/uploads'), @project.path_with_namespace) + FileUploader.dynamic_path_segment(@project) end end end diff --git a/lib/gitlab/search_results.rb b/lib/gitlab/search_results.rb index efe8095beea..fef9d3e31d4 100644 --- a/lib/gitlab/search_results.rb +++ b/lib/gitlab/search_results.rb @@ -30,7 +30,7 @@ module Gitlab def initialize(current_user, limit_projects, query) @current_user = current_user @limit_projects = limit_projects || Project.all - @query = Shellwords.shellescape(query) if query.present? + @query = query end def objects(scope, page = nil) diff --git a/lib/tasks/gitlab/cleanup.rake b/lib/tasks/gitlab/cleanup.rake index a0c3e6adae8..57ee2eb3f7b 100644 --- a/lib/tasks/gitlab/cleanup.rake +++ b/lib/tasks/gitlab/cleanup.rake @@ -1,11 +1,14 @@ namespace :gitlab do namespace :cleanup do + HASHED_REPOSITORY_NAME = '@hashed'.freeze + desc "GitLab | Cleanup | Clean namespaces" task dirs: :environment do warn_user_is_not_gitlab remove_flag = ENV['REMOVE'] - namespaces = Namespace.pluck(:path) + namespaces = Namespace.pluck(:path) + namespaces << HASHED_REPOSITORY_NAME # add so that it will be ignored Gitlab.config.repositories.storages.each do |name, repository_storage| git_base_path = repository_storage['path'] all_dirs = Dir.glob(git_base_path + '/*') @@ -62,7 +65,7 @@ namespace :gitlab do # TODO ignoring hashed repositories for now. But revisit to fully support # possible orphaned hashed repos - next if repo_with_namespace.start_with?('@hashed/') || Project.find_by_full_path(repo_with_namespace) + next if repo_with_namespace.start_with?("#{HASHED_REPOSITORY_NAME}/") || Project.find_by_full_path(repo_with_namespace) new_path = path + move_suffix puts path.inspect + ' -> ' + new_path.inspect diff --git a/spec/finders/admin/projects_finder_spec.rb b/spec/finders/admin/projects_finder_spec.rb index 4b67203a0df..7901d5fee28 100644 --- a/spec/finders/admin/projects_finder_spec.rb +++ b/spec/finders/admin/projects_finder_spec.rb @@ -136,7 +136,7 @@ describe Admin::ProjectsFinder do context 'filter by name' do let(:params) { { name: 'C' } } - it { is_expected.to match_array([shared_project, public_project, private_project]) } + it { is_expected.to match_array([public_project]) } end context 'sorting' do diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index cb851d828f2..d601cbdb39b 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -174,6 +174,7 @@ describe IssuablesHelper do expected_data = { 'endpoint' => "/#{@project.full_path}/issues/#{issue.iid}", + 'updateEndpoint' => "/#{@project.full_path}/issues/#{issue.iid}.json", 'canUpdate' => true, 'canDestroy' => true, 'issuableRef' => "##{issue.iid}", diff --git a/spec/javascripts/datetime_utility_spec.js b/spec/javascripts/datetime_utility_spec.js index 3391cade541..0f7bf9ec712 100644 --- a/spec/javascripts/datetime_utility_spec.js +++ b/spec/javascripts/datetime_utility_spec.js @@ -1,4 +1,4 @@ -import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; +import * as datetimeUtility from '~/lib/utils/datetime_utility'; (() => { describe('Date time utils', () => { @@ -89,10 +89,22 @@ import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; describe('timeIntervalInWords', () => { it('should return string with number of minutes and seconds', () => { - expect(timeIntervalInWords(9.54)).toEqual('9 seconds'); - expect(timeIntervalInWords(1)).toEqual('1 second'); - expect(timeIntervalInWords(200)).toEqual('3 minutes 20 seconds'); - expect(timeIntervalInWords(6008)).toEqual('100 minutes 8 seconds'); + expect(datetimeUtility.timeIntervalInWords(9.54)).toEqual('9 seconds'); + expect(datetimeUtility.timeIntervalInWords(1)).toEqual('1 second'); + expect(datetimeUtility.timeIntervalInWords(200)).toEqual('3 minutes 20 seconds'); + expect(datetimeUtility.timeIntervalInWords(6008)).toEqual('100 minutes 8 seconds'); + }); + }); + + describe('dateInWords', () => { + const date = new Date('07/01/2016'); + + it('should return date in words', () => { + expect(datetimeUtility.dateInWords(date)).toEqual('July 1, 2016'); + }); + + it('should return abbreviated month name', () => { + expect(datetimeUtility.dateInWords(date, true)).toEqual('Jul 1, 2016'); }); }); })(); diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js index 5662c7387fb..b47a8bf705f 100644 --- a/spec/javascripts/issue_show/components/app_spec.js +++ b/spec/javascripts/issue_show/components/app_spec.js @@ -35,11 +35,12 @@ describe('Issuable output', () => { canUpdate: true, canDestroy: true, endpoint: '/gitlab-org/gitlab-shell/issues/9/realtime_changes', + updateEndpoint: gl.TEST_HOST, issuableRef: '#1', initialTitleHtml: '', initialTitleText: '', - initialDescriptionHtml: '', - initialDescriptionText: '', + initialDescriptionHtml: 'test', + initialDescriptionText: 'test', markdownPreviewPath: '/', markdownDocsPath: '/', projectNamespace: '/', diff --git a/spec/javascripts/issue_show/components/description_spec.js b/spec/javascripts/issue_show/components/description_spec.js index 360691a3546..163e5cdd062 100644 --- a/spec/javascripts/issue_show/components/description_spec.js +++ b/spec/javascripts/issue_show/components/description_spec.js @@ -1,11 +1,22 @@ import Vue from 'vue'; import descriptionComponent from '~/issue_show/components/description.vue'; +import * as taskList from '~/task_list'; +import mountComponent from '../../helpers/vue_mount_component_helper'; describe('Description component', () => { let vm; + let DescriptionComponent; + const props = { + canUpdate: true, + descriptionHtml: 'test', + descriptionText: 'test', + updatedAt: new Date().toString(), + taskStatus: '', + updateUrl: gl.TEST_HOST, + }; beforeEach(() => { - const Component = Vue.extend(descriptionComponent); + DescriptionComponent = Vue.extend(descriptionComponent); if (!document.querySelector('.issuable-meta')) { const metaData = document.createElement('div'); @@ -15,15 +26,11 @@ describe('Description component', () => { document.body.appendChild(metaData); } - vm = new Component({ - propsData: { - canUpdate: true, - descriptionHtml: 'test', - descriptionText: 'test', - updatedAt: new Date().toString(), - taskStatus: '', - }, - }).$mount(); + vm = mountComponent(DescriptionComponent, props); + }); + + afterEach(() => { + vm.$destroy(); }); it('animates description changes', (done) => { @@ -44,34 +51,46 @@ describe('Description component', () => { }); }); - // TODO: gl.TaskList no longer exists. rewrite these tests once we have a way to rewire ES modules - - // it('re-inits the TaskList when description changed', (done) => { - // spyOn(gl, 'TaskList'); - // vm.descriptionHtml = 'changed'; - // - // setTimeout(() => { - // expect( - // gl.TaskList, - // ).toHaveBeenCalled(); - // - // done(); - // }); - // }); - - // it('does not re-init the TaskList when canUpdate is false', (done) => { - // spyOn(gl, 'TaskList'); - // vm.canUpdate = false; - // vm.descriptionHtml = 'changed'; - // - // setTimeout(() => { - // expect( - // gl.TaskList, - // ).not.toHaveBeenCalled(); - // - // done(); - // }); - // }); + describe('TaskList', () => { + beforeEach(() => { + vm = mountComponent(DescriptionComponent, Object.assign({}, props, { + issuableType: 'issuableType', + })); + spyOn(taskList, 'default'); + }); + + it('re-inits the TaskList when description changed', (done) => { + vm.descriptionHtml = 'changed'; + + setTimeout(() => { + expect(taskList.default).toHaveBeenCalled(); + done(); + }); + }); + + it('does not re-init the TaskList when canUpdate is false', (done) => { + vm.canUpdate = false; + vm.descriptionHtml = 'changed'; + + setTimeout(() => { + expect(taskList.default).not.toHaveBeenCalled(); + done(); + }); + }); + + it('calls with issuableType dataType', (done) => { + vm.descriptionHtml = 'changed'; + + setTimeout(() => { + expect(taskList.default).toHaveBeenCalledWith({ + dataType: 'issuableType', + fieldName: 'description', + selector: '.detail-page-description', + }); + done(); + }); + }); + }); describe('taskStatus', () => { it('adds full taskStatus', (done) => { @@ -126,4 +145,8 @@ describe('Description component', () => { }); }); }); + + it('sets data-update-url', () => { + expect(vm.$el.querySelector('textarea').dataset.updateUrl).toEqual(gl.TEST_HOST); + }); }); diff --git a/spec/javascripts/lib/utils/text_utility_spec.js b/spec/javascripts/lib/utils/text_utility_spec.js index 829b3ef5735..d8415f8ba6a 100644 --- a/spec/javascripts/lib/utils/text_utility_spec.js +++ b/spec/javascripts/lib/utils/text_utility_spec.js @@ -1,4 +1,4 @@ -import { highCountTrim } from '~/lib/utils/text_utility'; +import * as textUtils from '~/lib/utils/text_utility'; describe('text_utility', () => { describe('gl.text.getTextWidth', () => { @@ -37,12 +37,20 @@ describe('text_utility', () => { describe('highCountTrim', () => { it('returns 99+ for count >= 100', () => { - expect(highCountTrim(105)).toBe('99+'); - expect(highCountTrim(100)).toBe('99+'); + expect(textUtils.highCountTrim(105)).toBe('99+'); + expect(textUtils.highCountTrim(100)).toBe('99+'); }); it('returns exact number for count < 100', () => { - expect(highCountTrim(45)).toBe(45); + expect(textUtils.highCountTrim(45)).toBe(45); + }); + }); + + describe('capitalizeFirstCharacter', () => { + it('returns string with first letter capitalized', () => { + expect(textUtils.capitalizeFirstCharacter('gitlab')).toEqual('Gitlab'); + expect(textUtils.highCountTrim(105)).toBe('99+'); + expect(textUtils.highCountTrim(100)).toBe('99+'); }); }); diff --git a/spec/javascripts/vue_shared/components/pikaday_spec.js b/spec/javascripts/vue_shared/components/pikaday_spec.js new file mode 100644 index 00000000000..47af9534737 --- /dev/null +++ b/spec/javascripts/vue_shared/components/pikaday_spec.js @@ -0,0 +1,29 @@ +import Vue from 'vue'; +import datePicker from '~/vue_shared/components/pikaday.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +describe('datePicker', () => { + let vm; + beforeEach(() => { + const DatePicker = Vue.extend(datePicker); + vm = mountComponent(DatePicker, { + label: 'label', + }); + }); + + it('should render label text', () => { + expect(vm.$el.querySelector('.dropdown-toggle-text').innerText.trim()).toEqual('label'); + }); + + it('should show calendar', () => { + expect(vm.$el.querySelector('.pika-single')).toBeDefined(); + }); + + it('should toggle when dropdown is clicked', () => { + const hidePicker = jasmine.createSpy(); + vm.$on('hidePicker', hidePicker); + + vm.$el.querySelector('.dropdown-menu-toggle').click(); + expect(hidePicker).toHaveBeenCalled(); + }); +}); diff --git a/spec/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js b/spec/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js new file mode 100644 index 00000000000..cce53193870 --- /dev/null +++ b/spec/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import collapsedCalendarIcon from '~/vue_shared/components/sidebar/collapsed_calendar_icon.vue'; +import mountComponent from '../../../helpers/vue_mount_component_helper'; + +describe('collapsedCalendarIcon', () => { + let vm; + beforeEach(() => { + const CollapsedCalendarIcon = Vue.extend(collapsedCalendarIcon); + vm = mountComponent(CollapsedCalendarIcon, { + containerClass: 'test-class', + text: 'text', + showIcon: false, + }); + }); + + it('should add class to container', () => { + expect(vm.$el.classList.contains('test-class')).toEqual(true); + }); + + it('should hide calendar icon if showIcon', () => { + expect(vm.$el.querySelector('.fa-calendar')).toBeNull(); + }); + + it('should render text', () => { + expect(vm.$el.querySelector('span').innerText.trim()).toEqual('text'); + }); + + it('should emit click event when container is clicked', () => { + const click = jasmine.createSpy(); + vm.$on('click', click); + + vm.$el.click(); + expect(click).toHaveBeenCalled(); + }); +}); diff --git a/spec/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js b/spec/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js new file mode 100644 index 00000000000..20363e78094 --- /dev/null +++ b/spec/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js @@ -0,0 +1,91 @@ +import Vue from 'vue'; +import collapsedGroupedDatePicker from '~/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue'; +import mountComponent from '../../../helpers/vue_mount_component_helper'; + +describe('collapsedGroupedDatePicker', () => { + let vm; + beforeEach(() => { + const CollapsedGroupedDatePicker = Vue.extend(collapsedGroupedDatePicker); + vm = mountComponent(CollapsedGroupedDatePicker, { + showToggleSidebar: true, + }); + }); + + it('should render toggle sidebar if showToggleSidebar', (done) => { + expect(vm.$el.querySelector('.issuable-sidebar-header')).toBeDefined(); + + vm.showToggleSidebar = false; + Vue.nextTick(() => { + expect(vm.$el.querySelector('.issuable-sidebar-header')).toBeNull(); + done(); + }); + }); + + it('toggleCollapse events', () => { + const toggleCollapse = jasmine.createSpy(); + + beforeEach((done) => { + vm.minDate = new Date('07/17/2016'); + Vue.nextTick(done); + }); + + it('should emit when sidebar is toggled', () => { + vm.$el.querySelector('.gutter-toggle').click(); + expect(toggleCollapse).toHaveBeenCalled(); + }); + + it('should emit when collapsed-calendar-icon is clicked', () => { + vm.$el.querySelector('.sidebar-collapsed-icon').click(); + expect(toggleCollapse).toHaveBeenCalled(); + }); + }); + + describe('minDate and maxDate', () => { + beforeEach((done) => { + vm.minDate = new Date('07/17/2016'); + vm.maxDate = new Date('07/17/2017'); + Vue.nextTick(done); + }); + + it('should render both collapsed-calendar-icon', () => { + const icons = vm.$el.querySelectorAll('.sidebar-collapsed-icon'); + expect(icons.length).toEqual(2); + expect(icons[0].innerText.trim()).toEqual('Jul 17 2016'); + expect(icons[1].innerText.trim()).toEqual('Jul 17 2017'); + }); + }); + + describe('minDate', () => { + beforeEach((done) => { + vm.minDate = new Date('07/17/2016'); + Vue.nextTick(done); + }); + + it('should render minDate in collapsed-calendar-icon', () => { + const icons = vm.$el.querySelectorAll('.sidebar-collapsed-icon'); + expect(icons.length).toEqual(1); + expect(icons[0].innerText.trim()).toEqual('From Jul 17 2016'); + }); + }); + + describe('maxDate', () => { + beforeEach((done) => { + vm.maxDate = new Date('07/17/2017'); + Vue.nextTick(done); + }); + + it('should render maxDate in collapsed-calendar-icon', () => { + const icons = vm.$el.querySelectorAll('.sidebar-collapsed-icon'); + expect(icons.length).toEqual(1); + expect(icons[0].innerText.trim()).toEqual('Until Jul 17 2017'); + }); + }); + + describe('no dates', () => { + it('should render None', () => { + const icons = vm.$el.querySelectorAll('.sidebar-collapsed-icon'); + expect(icons.length).toEqual(1); + expect(icons[0].innerText.trim()).toEqual('None'); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/sidebar/date_picker_spec.js b/spec/javascripts/vue_shared/components/sidebar/date_picker_spec.js new file mode 100644 index 00000000000..926e11b4d30 --- /dev/null +++ b/spec/javascripts/vue_shared/components/sidebar/date_picker_spec.js @@ -0,0 +1,117 @@ +import Vue from 'vue'; +import sidebarDatePicker from '~/vue_shared/components/sidebar/date_picker.vue'; +import mountComponent from '../../../helpers/vue_mount_component_helper'; + +describe('sidebarDatePicker', () => { + let vm; + beforeEach(() => { + const SidebarDatePicker = Vue.extend(sidebarDatePicker); + vm = mountComponent(SidebarDatePicker, { + label: 'label', + isLoading: true, + }); + }); + + it('should emit toggleCollapse when collapsed toggle sidebar is clicked', () => { + const toggleCollapse = jasmine.createSpy(); + vm.$on('toggleCollapse', toggleCollapse); + + vm.$el.querySelector('.issuable-sidebar-header .gutter-toggle').click(); + expect(toggleCollapse).toHaveBeenCalled(); + }); + + it('should render collapsed-calendar-icon', () => { + expect(vm.$el.querySelector('.sidebar-collapsed-icon')).toBeDefined(); + }); + + it('should render label', () => { + expect(vm.$el.querySelector('.title').innerText.trim()).toEqual('label'); + }); + + it('should render loading-icon when isLoading', () => { + expect(vm.$el.querySelector('.fa-spin')).toBeDefined(); + }); + + it('should render value when not editing', () => { + expect(vm.$el.querySelector('.value-content')).toBeDefined(); + }); + + it('should render None if there is no selectedDate', () => { + expect(vm.$el.querySelector('.value-content span').innerText.trim()).toEqual('None'); + }); + + it('should render date-picker when editing', (done) => { + vm.editing = true; + Vue.nextTick(() => { + expect(vm.$el.querySelector('.pika-label')).toBeDefined(); + done(); + }); + }); + + describe('editable', () => { + beforeEach((done) => { + vm.editable = true; + Vue.nextTick(done); + }); + + it('should render edit button', () => { + expect(vm.$el.querySelector('.title .btn-blank').innerText.trim()).toEqual('Edit'); + }); + + it('should enable editing when edit button is clicked', (done) => { + vm.isLoading = false; + Vue.nextTick(() => { + vm.$el.querySelector('.title .btn-blank').click(); + expect(vm.editing).toEqual(true); + done(); + }); + }); + }); + + it('should render date if selectedDate', (done) => { + vm.selectedDate = new Date('07/07/2017'); + Vue.nextTick(() => { + expect(vm.$el.querySelector('.value-content strong').innerText.trim()).toEqual('Jul 7, 2017'); + done(); + }); + }); + + describe('selectedDate and editable', () => { + beforeEach((done) => { + vm.selectedDate = new Date('07/07/2017'); + vm.editable = true; + Vue.nextTick(done); + }); + + it('should render remove button if selectedDate and editable', () => { + expect(vm.$el.querySelector('.value-content .btn-blank').innerText.trim()).toEqual('remove'); + }); + + it('should emit saveDate when remove button is clicked', () => { + const saveDate = jasmine.createSpy(); + vm.$on('saveDate', saveDate); + + vm.$el.querySelector('.value-content .btn-blank').click(); + expect(saveDate).toHaveBeenCalled(); + }); + }); + + describe('showToggleSidebar', () => { + beforeEach((done) => { + vm.showToggleSidebar = true; + Vue.nextTick(done); + }); + + it('should render toggle-sidebar when showToggleSidebar', () => { + expect(vm.$el.querySelector('.title .gutter-toggle')).toBeDefined(); + }); + + it('should emit toggleCollapse when toggle sidebar is clicked', () => { + const toggleCollapse = jasmine.createSpy(); + vm.$on('toggleCollapse', toggleCollapse); + + vm.$el.querySelector('.title .gutter-toggle').click(); + expect(toggleCollapse).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/sidebar/toggle_sidebar_spec.js b/spec/javascripts/vue_shared/components/sidebar/toggle_sidebar_spec.js new file mode 100644 index 00000000000..752a9e89d50 --- /dev/null +++ b/spec/javascripts/vue_shared/components/sidebar/toggle_sidebar_spec.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import toggleSidebar from '~/vue_shared/components/sidebar/toggle_sidebar.vue'; +import mountComponent from '../../../helpers/vue_mount_component_helper'; + +describe('toggleSidebar', () => { + let vm; + beforeEach(() => { + const ToggleSidebar = Vue.extend(toggleSidebar); + vm = mountComponent(ToggleSidebar, { + collapsed: true, + }); + }); + + it('should render << when collapsed', () => { + expect(vm.$el.querySelector('.fa').classList.contains('fa-angle-double-left')).toEqual(true); + }); + + it('should render >> when collapsed', () => { + vm.collapsed = false; + Vue.nextTick(() => { + expect(vm.$el.querySelector('.fa').classList.contains('fa-angle-double-right')).toEqual(true); + }); + }); + + it('should emit toggle event when button clicked', () => { + const toggle = jasmine.createSpy(); + vm.$on('toggle', toggle); + vm.$el.click(); + + expect(toggle).toHaveBeenCalled(); + }); +}); diff --git a/spec/javascripts/zen_mode_spec.js b/spec/javascripts/zen_mode_spec.js index 7047053d131..45a0bb0650f 100644 --- a/spec/javascripts/zen_mode_spec.js +++ b/spec/javascripts/zen_mode_spec.js @@ -1,77 +1,93 @@ -/* eslint-disable space-before-function-paren, no-var, one-var, one-var-declaration-per-line, object-shorthand, comma-dangle, no-return-assign, new-cap, max-len */ /* global Mousetrap */ import Dropzone from 'dropzone'; import ZenMode from '~/zen_mode'; -(function() { - var enterZen, escapeKeydown, exitZen; - - describe('ZenMode', function() { - var fixtureName = 'merge_requests/merge_request_with_comment.html.raw'; - preloadFixtures(fixtureName); - beforeEach(function() { - loadFixtures(fixtureName); - spyOn(Dropzone, 'forElement').and.callFake(function() { - return { - enable: function() { - return true; - } - }; - // Stub Dropzone.forElement(...).enable() - }); - this.zen = new ZenMode(); - // Set this manually because we can't actually scroll the window - return this.zen.scroll_position = 456; +describe('ZenMode', () => { + let zen; + const fixtureName = 'merge_requests/merge_request_with_comment.html.raw'; + + preloadFixtures(fixtureName); + + function enterZen() { + $('.notes-form .js-zen-enter').click(); + } + + function exitZen() { + $('.notes-form .js-zen-leave').click(); + } + + function escapeKeydown() { + $('.notes-form textarea').trigger($.Event('keydown', { + keyCode: 27, + })); + } + + beforeEach(() => { + loadFixtures(fixtureName); + + spyOn(Dropzone, 'forElement').and.callFake(() => ({ + enable: () => true, + })); + zen = new ZenMode(); + + // Set this manually because we can't actually scroll the window + zen.scroll_position = 456; + }); + + describe('on enter', () => { + it('pauses Mousetrap', () => { + spyOn(Mousetrap, 'pause'); + enterZen(); + expect(Mousetrap.pause).toHaveBeenCalled(); }); - describe('on enter', function() { - it('pauses Mousetrap', function() { - spyOn(Mousetrap, 'pause'); - enterZen(); - return expect(Mousetrap.pause).toHaveBeenCalled(); - }); - return it('removes textarea styling', function() { - $('.notes-form textarea').attr('style', 'height: 400px'); - enterZen(); - return expect($('.notes-form textarea')).not.toHaveAttr('style'); - }); + + it('removes textarea styling', () => { + $('.notes-form textarea').attr('style', 'height: 400px'); + enterZen(); + expect($('.notes-form textarea')).not.toHaveAttr('style'); }); - describe('in use', function() { - beforeEach(function() { - return enterZen(); - }); - return it('exits on Escape', function() { - escapeKeydown(); - return expect($('.notes-form .zen-backdrop')).not.toHaveClass('fullscreen'); - }); + }); + + describe('in use', () => { + beforeEach(enterZen); + + it('exits on Escape', () => { + escapeKeydown(); + expect($('.notes-form .zen-backdrop')).not.toHaveClass('fullscreen'); + }); + }); + + describe('on exit', () => { + beforeEach(enterZen); + + it('unpauses Mousetrap', () => { + spyOn(Mousetrap, 'unpause'); + exitZen(); + expect(Mousetrap.unpause).toHaveBeenCalled(); }); - return describe('on exit', function() { - beforeEach(function() { - return enterZen(); - }); - it('unpauses Mousetrap', function() { - spyOn(Mousetrap, 'unpause'); - exitZen(); - return expect(Mousetrap.unpause).toHaveBeenCalled(); - }); - return it('restores the scroll position', function() { - spyOn(this.zen, 'scrollTo'); - exitZen(); - return expect(this.zen.scrollTo).toHaveBeenCalled(); - }); + + it('restores the scroll position', () => { + spyOn(zen, 'scrollTo'); + exitZen(); + expect(zen.scrollTo).toHaveBeenCalled(); }); }); - enterZen = function() { - return $('.notes-form .js-zen-enter').click(); - }; + describe('enabling dropzone', () => { + beforeEach(() => { + enterZen(); + }); - exitZen = function() { - return $('.notes-form .js-zen-leave').click(); - }; + it('should not call dropzone if element is not dropzone valid', () => { + $('.div-dropzone').addClass('js-invalid-dropzone'); + exitZen(); + expect(Dropzone.forElement).not.toHaveBeenCalled(); + }); - escapeKeydown = function() { - return $('.notes-form textarea').trigger($.Event('keydown', { - keyCode: 27 - })); - }; -}).call(window); + it('should call dropzone if element is dropzone valid', () => { + $('.div-dropzone').removeClass('js-invalid-dropzone'); + exitZen(); + expect(Dropzone.forElement).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/lib/banzai/filter/issue_reference_filter_spec.rb b/spec/lib/banzai/filter/issue_reference_filter_spec.rb index 3c98b18f99b..f70c69ef588 100644 --- a/spec/lib/banzai/filter/issue_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/issue_reference_filter_spec.rb @@ -343,7 +343,9 @@ describe Banzai::Filter::IssueReferenceFilter do reference = "#{project.full_path}##{issue.iid}" doc = reference_filter("See #{reference}", context) - expect(doc.css('a').first.attr('href')).to eq helper.url_for_issue(issue.iid, project) + link = doc.css('a').first + expect(link.attr('href')).to eq(helper.url_for_issue(issue.iid, project)) + expect(link.text).to include("#{project.full_path}##{issue.iid}") end it 'ignores reference for shorthand cross-reference' do @@ -358,7 +360,9 @@ describe Banzai::Filter::IssueReferenceFilter do doc = reference_filter("See #{reference}", context) - expect(doc.css('a').first.attr('href')).to eq(helper.url_for_issue(issue.iid, project) + "#note_123") + link = doc.css('a').first + expect(link.attr('href')).to eq(helper.url_for_issue(issue.iid, project) + "#note_123") + expect(link.text).to include("#{project.full_path}##{issue.iid}") end it 'links to a valid reference for cross-reference in link href' do @@ -367,7 +371,9 @@ describe Banzai::Filter::IssueReferenceFilter do doc = reference_filter("See #{reference_link}", context) - expect(doc.css('a').first.attr('href')).to eq helper.url_for_issue(issue.iid, project) + "#note_123" + link = doc.css('a').first + expect(link.attr('href')).to eq(helper.url_for_issue(issue.iid, project) + "#note_123") + expect(link.text).to include('Reference') end it 'links to a valid reference for issue reference in the link href' do @@ -375,7 +381,9 @@ describe Banzai::Filter::IssueReferenceFilter do reference_link = %{<a href="#{reference}">Reference</a>} doc = reference_filter("See #{reference_link}", context) - expect(doc.css('a').first.attr('href')).to eq helper.url_for_issue(issue.iid, project) + link = doc.css('a').first + expect(link.attr('href')).to eq(helper.url_for_issue(issue.iid, project)) + expect(link.text).to include('Reference') end end diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb index 3164d2ebf04..8677e53a204 100644 --- a/spec/lib/gitlab/auth_spec.rb +++ b/spec/lib/gitlab/auth_spec.rb @@ -207,7 +207,7 @@ describe Gitlab::Auth do end it 'limits abilities based on scope' do - personal_access_token = create(:personal_access_token, scopes: ['read_user']) + personal_access_token = create(:personal_access_token, scopes: %w[read_user sudo]) expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: '') expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(personal_access_token.user, nil, :personal_access_token, [])) diff --git a/spec/lib/gitlab/import_export/uploads_restorer_spec.rb b/spec/lib/gitlab/import_export/uploads_restorer_spec.rb new file mode 100644 index 00000000000..63992ea8ab8 --- /dev/null +++ b/spec/lib/gitlab/import_export/uploads_restorer_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' + +describe Gitlab::ImportExport::UploadsRestorer do + describe 'bundle a project Git repo' do + let(:export_path) { "#{Dir.tmpdir}/uploads_saver_spec" } + let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: project.full_path) } + let(:uploads_path) { FileUploader.dynamic_path_segment(project) } + + before do + allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path) + FileUtils.mkdir_p(File.join(shared.export_path, 'uploads/random')) + FileUtils.touch(File.join(shared.export_path, 'uploads/random', "dummy.txt")) + end + + after do + FileUtils.rm_rf(export_path) + end + + describe 'legacy storage' do + let(:project) { create(:project) } + + subject(:restorer) { described_class.new(project: project, shared: shared) } + + it 'saves the uploads successfully' do + expect(restorer.restore).to be true + end + + it 'copies the uploads to the project path' do + restorer.restore + + uploads = Dir.glob(File.join(uploads_path, '**/*')).map { |file| File.basename(file) } + + expect(uploads).to include('dummy.txt') + end + end + + describe 'hashed storage' do + let(:project) { create(:project, :hashed) } + + subject(:restorer) { described_class.new(project: project, shared: shared) } + + it 'saves the uploads successfully' do + expect(restorer.restore).to be true + end + + it 'copies the uploads to the project path' do + restorer.restore + + uploads = Dir.glob(File.join(uploads_path, '**/*')).map { |file| File.basename(file) } + + expect(uploads).to include('dummy.txt') + end + end + end +end diff --git a/spec/lib/gitlab/import_export/uploads_saver_spec.rb b/spec/lib/gitlab/import_export/uploads_saver_spec.rb new file mode 100644 index 00000000000..e8948de1f3a --- /dev/null +++ b/spec/lib/gitlab/import_export/uploads_saver_spec.rb @@ -0,0 +1,61 @@ +require 'spec_helper' + +describe Gitlab::ImportExport::UploadsSaver do + describe 'bundle a project Git repo' do + let(:export_path) { "#{Dir.tmpdir}/uploads_saver_spec" } + let(:file) { fixture_file_upload(Rails.root + 'spec/fixtures/banana_sample.gif', 'image/gif') } + let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: project.full_path) } + + before do + allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path) + end + + after do + FileUtils.rm_rf(export_path) + end + + describe 'legacy storage' do + let(:project) { create(:project) } + + subject(:saver) { described_class.new(shared: shared, project: project) } + + before do + UploadService.new(project, file, FileUploader).execute + end + + it 'saves the uploads successfully' do + expect(saver.save).to be true + end + + it 'copies the uploads to the export path' do + saver.save + + uploads = Dir.glob(File.join(shared.export_path, 'uploads', '**/*')).map { |file| File.basename(file) } + + expect(uploads).to include('banana_sample.gif') + end + end + + describe 'hashed storage' do + let(:project) { create(:project, :hashed) } + + subject(:saver) { described_class.new(shared: shared, project: project) } + + before do + UploadService.new(project, file, FileUploader).execute + end + + it 'saves the uploads successfully' do + expect(saver.save).to be true + end + + it 'copies the uploads to the export path' do + saver.save + + uploads = Dir.glob(File.join(shared.export_path, 'uploads', '**/*')).map { |file| File.basename(file) } + + expect(uploads).to include('banana_sample.gif') + end + end + end +end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index f7f19d464d1..549c97a9afd 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -1254,24 +1254,6 @@ describe Project do expect(described_class.search(project.path.upcase)).to eq([project]) end - it 'returns projects with a matching namespace name' do - expect(described_class.search(project.namespace.name)).to eq([project]) - end - - it 'returns projects with a partially matching namespace name' do - expect(described_class.search(project.namespace.name[0..2])).to eq([project]) - end - - it 'returns projects with a matching namespace name regardless of the casing' do - expect(described_class.search(project.namespace.name.upcase)).to eq([project]) - end - - it 'returns projects when eager loading namespaces' do - relation = described_class.all.includes(:namespace) - - expect(relation.search(project.namespace.name)).to eq([project]) - end - describe 'with pending_delete project' do let(:pending_delete_project) { create(:project, pending_delete: true) } diff --git a/spec/services/issuable/common_system_notes_service_spec.rb b/spec/services/issuable/common_system_notes_service_spec.rb index 9f92b662be1..b8fa3e3d124 100644 --- a/spec/services/issuable/common_system_notes_service_spec.rb +++ b/spec/services/issuable/common_system_notes_service_spec.rb @@ -18,7 +18,18 @@ describe Issuable::CommonSystemNotesService do note = Note.last expect(note.note).to match(note_text) - expect(note.noteable_type).to eq('Issue') + expect(note.noteable_type).to eq(issuable.class.name) + end + end + + shared_examples 'WIP notes creation' do |wip_action| + subject { described_class.new(project, user).execute(issuable, []) } + + it 'creates WIP toggle and title change notes' do + expect { subject }.to change { Note.count }.from(0).to(2) + + expect(Note.first.note).to match("#{wip_action} as a **Work In Progress**") + expect(Note.second.note).to match('changed title') end end @@ -45,5 +56,35 @@ describe Issuable::CommonSystemNotesService do it_behaves_like 'system note creation', {}, 'changed milestone' end + + context 'with merge requests WIP note' do + context 'adding WIP note' do + let(:issuable) { create(:merge_request, title: "merge request") } + + it_behaves_like 'system note creation', { title: "WIP merge request" }, 'marked as a **Work In Progress**' + + context 'and changing title' do + before do + issuable.update_attribute(:title, "WIP changed title") + end + + it_behaves_like 'WIP notes creation', 'marked' + end + end + + context 'removing WIP note' do + let(:issuable) { create(:merge_request, title: "WIP merge request") } + + it_behaves_like 'system note creation', { title: "merge request" }, 'unmarked as a **Work In Progress**' + + context 'and changing title' do + before do + issuable.update_attribute(:title, "changed title") + end + + it_behaves_like 'WIP notes creation', 'unmarked' + end + end + end end end diff --git a/spec/services/search/global_service_spec.rb b/spec/services/search/global_service_spec.rb index 1309240b430..d8dba26e194 100644 --- a/spec/services/search/global_service_spec.rb +++ b/spec/services/search/global_service_spec.rb @@ -35,8 +35,8 @@ describe Search::GlobalService do expect(results.objects('projects')).to match_array [internal_project, public_project] end - it 'namespace name is searchable' do - results = described_class.new(user, search: found_project.namespace.path).execute + it 'project name is searchable' do + results = described_class.new(user, search: found_project.name).execute expect(results.objects('projects')).to match_array [found_project] end diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 0a6ab455abe..a918383ecd2 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -970,31 +970,33 @@ describe SystemNoteService do end end - describe '.remove_merge_request_wip' do - let(:noteable) { create(:issue, project: project, title: 'WIP: Lorem ipsum') } + describe '.handle_merge_request_wip' do + context 'adding wip note' do + let(:noteable) { create(:merge_request, source_project: project, title: 'WIP Lorem ipsum') } - subject { described_class.remove_merge_request_wip(noteable, project, author) } + subject { described_class.handle_merge_request_wip(noteable, project, author) } - it_behaves_like 'a system note' do - let(:action) { 'title' } - end + it_behaves_like 'a system note' do + let(:action) { 'title' } + end - it 'sets the note text' do - expect(subject.note).to eq 'unmarked as a **Work In Progress**' + it 'sets the note text' do + expect(subject.note).to eq 'marked as a **Work In Progress**' + end end - end - describe '.add_merge_request_wip' do - let(:noteable) { create(:issue, project: project, title: 'Lorem ipsum') } + context 'removing wip note' do + let(:noteable) { create(:merge_request, source_project: project, title: 'Lorem ipsum') } - subject { described_class.add_merge_request_wip(noteable, project, author) } + subject { described_class.handle_merge_request_wip(noteable, project, author) } - it_behaves_like 'a system note' do - let(:action) { 'title' } - end + it_behaves_like 'a system note' do + let(:action) { 'title' } + end - it 'sets the note text' do - expect(subject.note).to eq 'marked as a **Work In Progress**' + it 'sets the note text' do + expect(subject.note).to eq 'unmarked as a **Work In Progress**' + end end end diff --git a/spec/tasks/gitlab/cleanup_rake_spec.rb b/spec/tasks/gitlab/cleanup_rake_spec.rb index 641eccfd334..9e746ceddd6 100644 --- a/spec/tasks/gitlab/cleanup_rake_spec.rb +++ b/spec/tasks/gitlab/cleanup_rake_spec.rb @@ -5,7 +5,7 @@ describe 'gitlab:cleanup rake tasks' do Rake.application.rake_require 'tasks/gitlab/cleanup' end - context 'cleanup repositories' do + describe 'cleanup' do let(:gitaly_address) { Gitlab.config.repositories.storages.default.gitaly_address } let(:storages) do { @@ -22,20 +22,46 @@ describe 'gitlab:cleanup rake tasks' do FileUtils.rm_rf(Settings.absolute('tmp/tests/default_storage')) end - it 'moves it to an orphaned path' do - FileUtils.mkdir_p(Settings.absolute('tmp/tests/default_storage/broken/project.git')) - run_rake_task('gitlab:cleanup:repos') - repo_list = Dir['tmp/tests/default_storage/broken/*'] + describe 'cleanup:repos' do + before do + FileUtils.mkdir_p(Settings.absolute('tmp/tests/default_storage/broken/project.git')) + FileUtils.mkdir_p(Settings.absolute('tmp/tests/default_storage/@hashed/12/34/5678.git')) + end - expect(repo_list.first).to include('+orphaned+') + it 'moves it to an orphaned path' do + run_rake_task('gitlab:cleanup:repos') + repo_list = Dir['tmp/tests/default_storage/broken/*'] + + expect(repo_list.first).to include('+orphaned+') + end + + it 'ignores @hashed repos' do + run_rake_task('gitlab:cleanup:repos') + + expect(Dir.exist?(Settings.absolute('tmp/tests/default_storage/@hashed/12/34/5678.git'))).to be_truthy + end end - it 'ignores @hashed repos' do - FileUtils.mkdir_p(Settings.absolute('tmp/tests/default_storage/@hashed/12/34/5678.git')) + describe 'cleanup:dirs' do + it 'removes missing namespaces' do + FileUtils.mkdir_p(Settings.absolute("tmp/tests/default_storage/namespace_1/project.git")) + FileUtils.mkdir_p(Settings.absolute("tmp/tests/default_storage/namespace_2/project.git")) + allow(Namespace).to receive(:pluck).and_return('namespace_1') + + stub_env('REMOVE', 'true') + run_rake_task('gitlab:cleanup:dirs') + + expect(Dir.exist?(Settings.absolute('tmp/tests/default_storage/namespace_1'))).to be_truthy + expect(Dir.exist?(Settings.absolute('tmp/tests/default_storage/namespace_2'))).to be_falsey + end + + it 'ignores @hashed directory' do + FileUtils.mkdir_p(Settings.absolute('tmp/tests/default_storage/@hashed/12/34/5678.git')) - run_rake_task('gitlab:cleanup:repos') + run_rake_task('gitlab:cleanup:dirs') - expect(Dir.exist?(Settings.absolute('tmp/tests/default_storage/@hashed/12/34/5678.git'))).to be_truthy + expect(Dir.exist?(Settings.absolute('tmp/tests/default_storage/@hashed/12/34/5678.git'))).to be_truthy + end end end end diff --git a/spec/workers/stuck_ci_jobs_worker_spec.rb b/spec/workers/stuck_ci_jobs_worker_spec.rb index ac6f4fefb4e..bdc64c6785b 100644 --- a/spec/workers/stuck_ci_jobs_worker_spec.rb +++ b/spec/workers/stuck_ci_jobs_worker_spec.rb @@ -105,8 +105,8 @@ describe StuckCiJobsWorker do job.project.update(pending_delete: true) end - it 'does not drop job' do - expect_any_instance_of(Ci::Build).not_to receive(:drop) + it 'does drop job' do + expect_any_instance_of(Ci::Build).to receive(:drop).and_call_original worker.perform end end @@ -117,7 +117,7 @@ describe StuckCiJobsWorker do let(:worker2) { described_class.new } it 'is guard by exclusive lease when executed concurrently' do - expect(worker).to receive(:drop).at_least(:once) + expect(worker).to receive(:drop).at_least(:once).and_call_original expect(worker2).not_to receive(:drop) worker.perform allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain).and_return(false) @@ -125,8 +125,8 @@ describe StuckCiJobsWorker do end it 'can be executed in sequence' do - expect(worker).to receive(:drop).at_least(:once) - expect(worker2).to receive(:drop).at_least(:once) + expect(worker).to receive(:drop).at_least(:once).and_call_original + expect(worker2).to receive(:drop).at_least(:once).and_call_original worker.perform worker2.perform end |