diff options
237 files changed, 4474 insertions, 683 deletions
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 4c2a8041846..4db8830b115 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.128.0 +0.129.0 diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue index 97232d7f783..8512bf9dd7b 100644 --- a/app/assets/javascripts/badges/components/badge.vue +++ b/app/assets/javascripts/badges/components/badge.vue @@ -1,12 +1,14 @@ <script> import Icon from '~/vue_shared/components/icon.vue'; import Tooltip from '~/vue_shared/directives/tooltip'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; export default { name: 'Badge', components: { Icon, Tooltip, + GlLoadingIcon, }, directives: { Tooltip, diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue index aff7c4180e3..47e6e618219 100644 --- a/app/assets/javascripts/badges/components/badge_form.vue +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -4,6 +4,7 @@ import { mapActions, mapState } from 'vuex'; import createFlash from '~/flash'; import { s__, sprintf } from '~/locale'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import createEmptyBadge from '../empty_badge'; import Badge from './badge.vue'; @@ -14,6 +15,7 @@ export default { components: { Badge, LoadingButton, + GlLoadingIcon, }, props: { isEditing: { diff --git a/app/assets/javascripts/badges/components/badge_list.vue b/app/assets/javascripts/badges/components/badge_list.vue index 359d3e10380..ab518820378 100644 --- a/app/assets/javascripts/badges/components/badge_list.vue +++ b/app/assets/javascripts/badges/components/badge_list.vue @@ -1,5 +1,6 @@ <script> import { mapState } from 'vuex'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import BadgeListRow from './badge_list_row.vue'; import { GROUP_BADGE } from '../constants'; @@ -7,6 +8,7 @@ export default { name: 'BadgeList', components: { BadgeListRow, + GlLoadingIcon, }, computed: { ...mapState(['badges', 'isLoading', 'kind']), diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue index 5d16ba3ce6d..f28eff18f03 100644 --- a/app/assets/javascripts/badges/components/badge_list_row.vue +++ b/app/assets/javascripts/badges/components/badge_list_row.vue @@ -2,6 +2,7 @@ import { mapActions, mapState } from 'vuex'; import { s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import { PROJECT_BADGE } from '../constants'; import Badge from './badge.vue'; @@ -10,6 +11,7 @@ export default { components: { Badge, Icon, + GlLoadingIcon, }, props: { badge: { diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 4dc56c670f0..5e28fc396ab 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -1,5 +1,6 @@ <script> import Sortable from 'sortablejs'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import boardNewIssue from './board_new_issue.vue'; import boardCard from './board_card.vue'; import eventHub from '../eventhub'; @@ -11,6 +12,7 @@ export default { components: { boardCard, boardNewIssue, + GlLoadingIcon, }, props: { groupId: { diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue index 40949cc0656..fdd1346d4c7 100644 --- a/app/assets/javascripts/boards/components/modal/index.vue +++ b/app/assets/javascripts/boards/components/modal/index.vue @@ -6,6 +6,7 @@ import ModalList from './list.vue'; import ModalFooter from './footer.vue'; import EmptyState from './empty_state.vue'; import ModalStore from '../../stores/modal_store'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; export default { components: { @@ -13,6 +14,7 @@ export default { ModalHeader, ModalList, ModalFooter, + GlLoadingIcon, }, props: { newIssuePath: { diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 0f01a2a6c09..503417644fa 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -2,6 +2,7 @@ import $ from 'jquery'; import _ from 'underscore'; import Icon from '~/vue_shared/components/icon.vue'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import eventHub from '../eventhub'; import Api from '../../api'; @@ -9,6 +10,7 @@ export default { name: 'BoardProjectSelect', components: { Icon, + GlLoadingIcon, }, props: { groupId: { diff --git a/app/assets/javascripts/commons/gitlab_ui.js b/app/assets/javascripts/commons/gitlab_ui.js deleted file mode 100644 index 6c18a0fd390..00000000000 --- a/app/assets/javascripts/commons/gitlab_ui.js +++ /dev/null @@ -1,4 +0,0 @@ -import Vue from 'vue'; -import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; - -Vue.component('gl-loading-icon', GlLoadingIcon); diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js index ea945cd3fa5..0d2fe2925d8 100644 --- a/app/assets/javascripts/commons/index.js +++ b/app/assets/javascripts/commons/index.js @@ -3,5 +3,4 @@ import './polyfills'; import './jquery'; import './bootstrap'; import './vue'; -import './gitlab_ui'; import '../lib/utils/axios_utils'; diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue index 10548da8ec5..ea74fd27ff6 100644 --- a/app/assets/javascripts/deploy_keys/components/action_btn.vue +++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue @@ -1,7 +1,11 @@ <script> +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import eventHub from '../eventhub'; export default { + components: { + GlLoadingIcon, + }, props: { deployKey: { type: Object, diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index 3589599986d..631a9673b3e 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -6,11 +6,13 @@ import eventHub from '../eventhub'; import DeployKeysService from '../service'; import DeployKeysStore from '../store'; import KeysPanel from './keys_panel.vue'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; export default { components: { KeysPanel, NavigationTabs, + GlLoadingIcon, }, props: { endpoint: { diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 59680959bb1..7c60fb3da42 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -3,6 +3,7 @@ import { mapState, mapGetters, mapActions } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; import { __ } from '~/locale'; import createFlash from '~/flash'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import eventHub from '../../notes/event_hub'; import CompareVersions from './compare_versions.vue'; import DiffFile from './diff_file.vue'; @@ -21,6 +22,7 @@ export default { HiddenFilesWarning, CommitWidget, TreeList, + GlLoadingIcon, }, props: { endpoint: { @@ -223,7 +225,10 @@ export default { :commit="commit" /> - <div class="files d-flex prepend-top-default"> + <div + :data-can-create-note="getNoteableData.current_user.can_create_note" + class="files d-flex prepend-top-default" + > <div v-show="showTreeList" class="diff-tree-list" diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index fb5556e3cd7..547742a5ff4 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -1,15 +1,22 @@ <script> -import { mapGetters, mapState } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; -import { diffModes } from '~/ide/constants'; import InlineDiffView from './inline_diff_view.vue'; import ParallelDiffView from './parallel_diff_view.vue'; +import NoteForm from '../../notes/components/note_form.vue'; +import ImageDiffOverlay from './image_diff_overlay.vue'; +import DiffDiscussions from './diff_discussions.vue'; +import { IMAGE_DIFF_POSITION_TYPE } from '../constants'; +import { getDiffMode } from '../store/utils'; export default { components: { InlineDiffView, ParallelDiffView, DiffViewer, + NoteForm, + DiffDiscussions, + ImageDiffOverlay, }, props: { diffFile: { @@ -23,13 +30,38 @@ export default { endpoint: state => state.diffs.endpoint, }), ...mapGetters('diffs', ['isInlineView', 'isParallelView']), + ...mapGetters('diffs', ['getCommentFormForDiffFile']), + ...mapGetters(['getNoteableData', 'noteableType']), diffMode() { - const diffModeKey = Object.keys(diffModes).find(key => this.diffFile[`${key}File`]); - return diffModes[diffModeKey] || diffModes.replaced; + return getDiffMode(this.diffFile); }, isTextFile() { return this.diffFile.viewer.name === 'text'; }, + diffFileCommentForm() { + return this.getCommentFormForDiffFile(this.diffFile.fileHash); + }, + showNotesContainer() { + return this.diffFile.discussions.length || this.diffFileCommentForm; + }, + }, + methods: { + ...mapActions('diffs', ['saveDiffDiscussion', 'closeDiffFileCommentForm']), + handleSaveNote(note) { + this.saveDiffDiscussion({ + note, + formData: { + noteableData: this.getNoteableData, + noteableType: this.noteableType, + diffFile: this.diffFile, + positionType: IMAGE_DIFF_POSITION_TYPE, + x: this.diffFileCommentForm.x, + y: this.diffFileCommentForm.y, + width: this.diffFileCommentForm.width, + height: this.diffFileCommentForm.height, + }, + }); + }, }, }; </script> @@ -56,7 +88,37 @@ export default { :new-sha="diffFile.diffRefs.headSha" :old-path="diffFile.oldPath" :old-sha="diffFile.diffRefs.baseSha" - :project-path="projectPath"/> + :file-hash="diffFile.fileHash" + :project-path="projectPath" + > + <image-diff-overlay + slot="image-overlay" + :discussions="diffFile.discussions" + :file-hash="diffFile.fileHash" + :can-comment="getNoteableData.current_user.can_create_note" + /> + <div + v-if="showNotesContainer" + class="note-container" + > + <diff-discussions + v-if="diffFile.discussions.length" + class="diff-file-discussions" + :discussions="diffFile.discussions" + :should-collapse-discussions="true" + :render-avatar-badge="true" + /> + <note-form + v-if="diffFileCommentForm" + ref="noteForm" + :is-editing="false" + :save-button-title="__('Comment')" + class="diff-comment-form new-note discussion-form discussion-form-container" + @handleFormUpdate="handleSaveNote" + @cancelForm="closeDiffFileCommentForm(diffFile.fileHash)" + /> + </div> + </diff-viewer> </div> </div> </template> diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue index cddbe554fbd..e19207bdc95 100644 --- a/app/assets/javascripts/diffs/components/diff_discussions.vue +++ b/app/assets/javascripts/diffs/components/diff_discussions.vue @@ -1,24 +1,40 @@ <script> import { mapActions } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; import noteableDiscussion from '../../notes/components/noteable_discussion.vue'; export default { components: { noteableDiscussion, + Icon, }, props: { discussions: { type: Array, required: true, }, + shouldCollapseDiscussions: { + type: Boolean, + required: false, + default: false, + }, + renderAvatarBadge: { + type: Boolean, + required: false, + default: false, + }, }, methods: { + ...mapActions(['toggleDiscussion']), ...mapActions('diffs', ['removeDiscussionsFromDiff']), deleteNoteHandler(discussion) { if (discussion.notes.length <= 1) { this.removeDiscussionsFromDiff(discussion); } }, + isExpanded(discussion) { + return this.shouldCollapseDiscussions ? discussion.expanded : true; + }, }, }; </script> @@ -26,22 +42,54 @@ export default { <template> <div> <div - v-for="discussion in discussions" + v-for="(discussion, index) in discussions" :key="discussion.id" - class="discussion-notes diff-discussions" + :class="{ + collapsed: !isExpanded(discussion) + }" + class="discussion-notes diff-discussions position-relative" > <ul :data-discussion-id="discussion.id" class="notes" > + <template v-if="shouldCollapseDiscussions"> + <button + :class="{ + 'diff-notes-collapse': discussion.expanded, + 'btn-transparent badge badge-pill': !discussion.expanded + }" + type="button" + class="js-diff-notes-toggle" + @click="toggleDiscussion({ discussionId: discussion.id })" + > + <icon + v-if="discussion.expanded" + name="collapse" + class="collapse-icon" + /> + <template v-else> + {{ index + 1 }} + </template> + </button> + </template> <noteable-discussion + v-show="isExpanded(discussion)" :discussion="discussion" :render-header="false" :render-diff-file="false" :always-expanded="true" :discussions-by-diff-order="true" @noteDeleted="deleteNoteHandler" - /> + > + <span + v-if="renderAvatarBadge" + slot="avatar-badge" + class="badge badge-pill" + > + {{ index + 1 }} + </span> + </noteable-discussion> </ul> </div> </div> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 958e57c5652..e76c7afd863 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -3,6 +3,7 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import _ from 'underscore'; import { __, sprintf } from '~/locale'; import createFlash from '~/flash'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import DiffFileHeader from './diff_file_header.vue'; import DiffContent from './diff_content.vue'; @@ -10,6 +11,7 @@ export default { components: { DiffFileHeader, DiffContent, + GlLoadingIcon, }, props: { file: { diff --git a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue index f4a9be19496..e31a3546b69 100644 --- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue +++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue @@ -55,11 +55,6 @@ export default { required: false, default: false, }, - isContextLine: { - type: Boolean, - required: false, - default: false, - }, isHover: { type: Boolean, required: false, @@ -81,7 +76,6 @@ export default { this.showCommentButton && this.isHover && !this.isMatchLine && - !this.isContextLine && !this.isMetaLine && !this.hasDiscussions ); diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue index 5d9a0b123fe..e26aa9c9b00 100644 --- a/app/assets/javascripts/diffs/components/diff_table_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue @@ -3,7 +3,6 @@ import { mapGetters } from 'vuex'; import DiffLineGutterContent from './diff_line_gutter_content.vue'; import { MATCH_LINE_TYPE, - CONTEXT_LINE_TYPE, EMPTY_CELL_TYPE, OLD_LINE_TYPE, OLD_NO_NEW_LINE_TYPE, @@ -71,9 +70,6 @@ export default { isMatchLine() { return this.line.type === MATCH_LINE_TYPE; }, - isContextLine() { - return this.line.type === CONTEXT_LINE_TYPE; - }, isMetaLine() { const { type } = this.line; @@ -88,11 +84,7 @@ export default { [type]: type, [LINE_UNFOLD_CLASS_NAME]: this.isMatchLine, [LINE_HOVER_CLASS_NAME]: - this.isLoggedIn && - this.isHover && - !this.isMatchLine && - !this.isContextLine && - !this.isMetaLine, + this.isLoggedIn && this.isHover && !this.isMatchLine && !this.isMetaLine, }; }, lineNumber() { diff --git a/app/assets/javascripts/diffs/components/image_diff_overlay.vue b/app/assets/javascripts/diffs/components/image_diff_overlay.vue new file mode 100644 index 00000000000..ae1b0a52901 --- /dev/null +++ b/app/assets/javascripts/diffs/components/image_diff_overlay.vue @@ -0,0 +1,139 @@ +<script> +import { mapActions, mapGetters } from 'vuex'; +import _ from 'underscore'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + name: 'ImageDiffOverlay', + components: { + Icon, + }, + props: { + discussions: { + type: [Array, Object], + required: true, + }, + fileHash: { + type: String, + required: true, + }, + canComment: { + type: Boolean, + required: false, + default: false, + }, + showCommentIcon: { + type: Boolean, + required: false, + default: false, + }, + badgeClass: { + type: String, + required: false, + default: 'badge badge-pill', + }, + shouldToggleDiscussion: { + type: Boolean, + required: false, + default: true, + }, + }, + computed: { + ...mapGetters('diffs', ['getDiffFileByHash', 'getCommentFormForDiffFile']), + currentCommentForm() { + return this.getCommentFormForDiffFile(this.fileHash); + }, + allDiscussions() { + return _.isArray(this.discussions) ? this.discussions : [this.discussions]; + }, + }, + methods: { + ...mapActions(['toggleDiscussion']), + ...mapActions('diffs', ['openDiffFileCommentForm']), + getImageDimensions() { + return { + width: this.$parent.width, + height: this.$parent.height, + }; + }, + getPositionForObject(meta) { + const { x, y, width, height } = meta; + const imageWidth = this.getImageDimensions().width; + const imageHeight = this.getImageDimensions().height; + const widthRatio = imageWidth / width; + const heightRatio = imageHeight / height; + + return { + x: Math.round(x * widthRatio), + y: Math.round(y * heightRatio), + }; + }, + getPosition(discussion) { + const { x, y } = this.getPositionForObject(discussion.position); + + return { + left: `${x}px`, + top: `${y}px`, + }; + }, + clickedImage(x, y) { + const { width, height } = this.getImageDimensions(); + + this.openDiffFileCommentForm({ + fileHash: this.fileHash, + width, + height, + x, + y, + }); + }, + }, +}; +</script> + +<template> + <div class="position-absolute w-100 h-100 image-diff-overlay"> + <button + v-if="canComment" + type="button" + class="btn-transparent position-absolute image-diff-overlay-add-comment w-100 h-100 js-add-image-diff-note-button" + @click="clickedImage($event.offsetX, $event.offsetY)" + > + <span class="sr-only"> + {{ __('Add image comment') }} + </span> + </button> + <button + v-for="(discussion, index) in allDiscussions" + :key="discussion.id" + :style="getPosition(discussion)" + :class="badgeClass" + :disabled="!shouldToggleDiscussion" + class="js-image-badge" + type="button" + @click="toggleDiscussion({ discussionId: discussion.id })" + > + <icon + v-if="showCommentIcon" + name="image-comment-dark" + /> + <template v-else> + {{ index + 1 }} + </template> + </button> + <button + v-if="currentCommentForm" + :style="{ + left: `${currentCommentForm.x}px`, + top: `${currentCommentForm.y}px` + }" + :aria-label="__('Comment form position')" + class="btn-transparent comment-indicator" + type="button" + > + <icon + name="image-comment-dark" + /> + </button> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue index 542acd3d930..44c05e4b634 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue @@ -4,8 +4,6 @@ import DiffTableCell from './diff_table_cell.vue'; import { NEW_LINE_TYPE, OLD_LINE_TYPE, - CONTEXT_LINE_TYPE, - CONTEXT_LINE_CLASS_NAME, PARALLEL_DIFF_VIEW_TYPE, LINE_POSITION_LEFT, LINE_POSITION_RIGHT, @@ -41,13 +39,9 @@ export default { }, computed: { ...mapGetters('diffs', ['isInlineView']), - isContextLine() { - return this.line.type === CONTEXT_LINE_TYPE; - }, classNameMap() { return { [this.line.type]: this.line.type, - [CONTEXT_LINE_CLASS_NAME]: this.isContextLine, [PARALLEL_DIFF_VIEW_TYPE]: this.isParallelView, }; }, diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue index fcc3b3e9117..39312cddfce 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue @@ -5,8 +5,6 @@ import DiffTableCell from './diff_table_cell.vue'; import { NEW_LINE_TYPE, OLD_LINE_TYPE, - CONTEXT_LINE_TYPE, - CONTEXT_LINE_CLASS_NAME, OLD_NO_NEW_LINE_TYPE, PARALLEL_DIFF_VIEW_TYPE, NEW_NO_NEW_LINE_TYPE, @@ -43,12 +41,8 @@ export default { }; }, computed: { - isContextLine() { - return this.line.left && this.line.left.type === CONTEXT_LINE_TYPE; - }, classNameMap() { return { - [CONTEXT_LINE_CLASS_NAME]: this.isContextLine, [PARALLEL_DIFF_VIEW_TYPE]: true, }; }, diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index 6a50d2c1426..f5f5c0ffc29 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -3,7 +3,6 @@ export const PARALLEL_DIFF_VIEW_TYPE = 'parallel'; export const MATCH_LINE_TYPE = 'match'; export const OLD_NO_NEW_LINE_TYPE = 'old-nonewline'; export const NEW_NO_NEW_LINE_TYPE = 'new-nonewline'; -export const CONTEXT_LINE_TYPE = 'context'; export const EMPTY_CELL_TYPE = 'empty-cell'; export const COMMENT_FORM_TYPE = 'commentForm'; export const DIFF_NOTE_TYPE = 'DiffNote'; @@ -12,6 +11,7 @@ export const NOTE_TYPE = 'Note'; export const NEW_LINE_TYPE = 'new'; export const OLD_LINE_TYPE = 'old'; export const TEXT_DIFF_POSITION_TYPE = 'text'; +export const IMAGE_DIFF_POSITION_TYPE = 'image'; export const LINE_POSITION_LEFT = 'left'; export const LINE_POSITION_RIGHT = 'right'; @@ -21,7 +21,6 @@ export const LINE_SIDE_RIGHT = 'right-side'; export const DIFF_VIEW_COOKIE_NAME = 'diff_view'; export const LINE_HOVER_CLASS_NAME = 'is-over'; export const LINE_UNFOLD_CLASS_NAME = 'unfold js-unfold'; -export const CONTEXT_LINE_CLASS_NAME = 'diff-expanded'; export const UNFOLD_COUNT = 20; export const COUNT_OF_AVATARS_IN_GUTTER = 3; diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index ca8ae605cb4..d3e9c7c88f0 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -50,8 +50,8 @@ export const assignDiscussionsToDiff = ( }; export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => { - const { fileHash, line_code } = removeDiscussion; - commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash, lineCode: line_code }); + const { fileHash, line_code, id } = removeDiscussion; + commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash, lineCode: line_code, id }); }; export const startRenderDiffsQueue = ({ state, commit }) => { @@ -189,6 +189,7 @@ export const saveDiffDiscussion = ({ dispatch }, { note, formData }) => { return dispatch('saveNote', postData, { root: true }) .then(result => dispatch('updateDiscussion', result.discussion, { root: true })) .then(discussion => dispatch('assignDiscussionsToDiff', [discussion])) + .then(() => dispatch('closeDiffFileCommentForm', formData.diffFile.fileHash)) .catch(() => createFlash(s__('MergeRequests|Saving the comment failed'))); }; @@ -210,5 +211,19 @@ export const toggleShowTreeList = ({ commit, state }) => { localStorage.setItem(MR_TREE_SHOW_KEY, state.showTreeList); }; +export const openDiffFileCommentForm = ({ commit, getters }, formData) => { + const form = getters.getCommentFormForDiffFile(formData.fileHash); + + if (form) { + commit(types.UPDATE_DIFF_FILE_COMMENT_FORM, formData); + } else { + commit(types.OPEN_DIFF_FILE_COMMENT_FORM, formData); + } +}; + +export const closeDiffFileCommentForm = ({ commit }, fileHash) => { + commit(types.CLOSE_DIFF_FILE_COMMENT_FORM, fileHash); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index d4c205882ff..2bf0ad99c22 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -114,5 +114,8 @@ export const allBlobs = state => Object.values(state.treeEntries).filter(f => f. export const diffFilesLength = state => state.diffFiles.length; +export const getCommentFormForDiffFile = state => fileHash => + state.commentForms.find(form => form.fileHash === fileHash); + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index 1c5c35071de..085e255f1d3 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -24,4 +24,6 @@ export default () => ({ showTreeList: storedTreeShow === null ? bp.getBreakpointSize() !== 'xs' : storedTreeShow === 'true', currentDiffFileId: '', + projectPath: '', + commentForms: [], }); diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index 6474ee628e2..e011031e72c 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -14,3 +14,7 @@ export const REMOVE_LINE_DISCUSSIONS_FOR_FILE = 'REMOVE_LINE_DISCUSSIONS_FOR_FIL export const TOGGLE_FOLDER_OPEN = 'TOGGLE_FOLDER_OPEN'; export const TOGGLE_SHOW_TREE_LIST = 'TOGGLE_SHOW_TREE_LIST'; export const UPDATE_CURRENT_DIFF_FILE_ID = 'UPDATE_CURRENT_DIFF_FILE_ID'; + +export const OPEN_DIFF_FILE_COMMENT_FORM = 'OPEN_DIFF_FILE_COMMENT_FORM'; +export const UPDATE_DIFF_FILE_COMMENT_FORM = 'UPDATE_DIFF_FILE_COMMENT_FORM'; +export const CLOSE_DIFF_FILE_COMMENT_FORM = 'CLOSE_DIFF_FILE_COMMENT_FORM'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 38a65f111a2..e651c197968 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -65,7 +65,13 @@ export default { const { highlightedDiffLines, parallelDiffLines } = diffFile; removeMatchLine(diffFile, lineNumbers, bottom); - const lines = addLineReferences(contextLines, lineNumbers, bottom); + + const lines = addLineReferences(contextLines, lineNumbers, bottom).map(line => ({ + ...line, + lineCode: line.lineCode || `${fileHash}_${line.oldLine}_${line.newLine}`, + discussions: line.discussions || [], + })); + addContextLines({ inlineLines: highlightedDiffLines, parallelLines: parallelDiffLines, @@ -153,20 +159,22 @@ export default { }); }, - [types.REMOVE_LINE_DISCUSSIONS_FOR_FILE](state, { fileHash, lineCode }) { + [types.REMOVE_LINE_DISCUSSIONS_FOR_FILE](state, { fileHash, lineCode, id }) { const selectedFile = state.diffFiles.find(f => f.fileHash === fileHash); if (selectedFile) { - const targetLine = selectedFile.parallelDiffLines.find( - line => - (line.left && line.left.lineCode === lineCode) || - (line.right && line.right.lineCode === lineCode), - ); - if (targetLine) { - const side = targetLine.left && targetLine.left.lineCode === lineCode ? 'left' : 'right'; - - Object.assign(targetLine[side], { - discussions: [], - }); + if (selectedFile.parallelDiffLines) { + const targetLine = selectedFile.parallelDiffLines.find( + line => + (line.left && line.left.lineCode === lineCode) || + (line.right && line.right.lineCode === lineCode), + ); + if (targetLine) { + const side = targetLine.left && targetLine.left.lineCode === lineCode ? 'left' : 'right'; + + Object.assign(targetLine[side], { + discussions: [], + }); + } } if (selectedFile.highlightedDiffLines) { @@ -180,6 +188,12 @@ export default { }); } } + + if (selectedFile.discussions && selectedFile.discussions.length) { + selectedFile.discussions = selectedFile.discussions.filter( + discussion => discussion.id !== id, + ); + } } }, [types.TOGGLE_FOLDER_OPEN](state, path) { @@ -191,4 +205,25 @@ export default { [types.UPDATE_CURRENT_DIFF_FILE_ID](state, fileId) { state.currentDiffFileId = fileId; }, + [types.OPEN_DIFF_FILE_COMMENT_FORM](state, formData) { + state.commentForms.push({ + ...formData, + }); + }, + [types.UPDATE_DIFF_FILE_COMMENT_FORM](state, formData) { + const { fileHash } = formData; + + state.commentForms = state.commentForms.map(form => { + if (form.fileHash === fileHash) { + return { + ...formData, + }; + } + + return form; + }); + }, + [types.CLOSE_DIFF_FILE_COMMENT_FORM](state, fileHash) { + state.commentForms = state.commentForms.filter(form => form.fileHash !== fileHash); + }, }; diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index a482a2b82c0..a935b9b1ffa 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -1,5 +1,6 @@ import _ from 'underscore'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { diffModes } from '~/ide/constants'; import { LINE_POSITION_LEFT, LINE_POSITION_RIGHT, @@ -34,6 +35,7 @@ export function getFormData(params) { noteTargetLine, diffViewType, linePosition, + positionType, } = params; const position = JSON.stringify({ @@ -42,9 +44,13 @@ export function getFormData(params) { head_sha: diffFile.diffRefs.headSha, old_path: diffFile.oldPath, new_path: diffFile.newPath, - position_type: TEXT_DIFF_POSITION_TYPE, - old_line: noteTargetLine.oldLine, - new_line: noteTargetLine.newLine, + position_type: positionType || TEXT_DIFF_POSITION_TYPE, + old_line: noteTargetLine ? noteTargetLine.oldLine : null, + new_line: noteTargetLine ? noteTargetLine.newLine : null, + x: params.x, + y: params.y, + width: params.width, + height: params.height, }); const postData = { @@ -66,7 +72,7 @@ export function getFormData(params) { diffFile.diffRefs.startSha && diffFile.diffRefs.headSha ? DIFF_NOTE_TYPE : LEGACY_DIFF_NOTE_TYPE, - line_code: noteTargetLine.lineCode, + line_code: noteTargetLine ? noteTargetLine.lineCode : null, }, }; @@ -225,6 +231,7 @@ export function prepareDiffData(diffData) { Object.assign(file, { renderIt: showingLines < LINES_TO_BE_RENDERED_DIRECTLY, collapsed: file.text && showingLines > MAX_LINES_TO_BE_RENDERED, + discussions: [], }); } } @@ -320,3 +327,8 @@ export const generateTreeList = files => }, { treeEntries: {}, tree: [] }, ); + +export const getDiffMode = diffFile => { + const diffModeKey = Object.keys(diffModes).find(key => diffFile[`${key}File`]); + return diffModes[diffModeKey] || diffModes.replaced; +}; diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue index 00d197d294f..a48f5fcb7d6 100644 --- a/app/assets/javascripts/environments/components/container.vue +++ b/app/assets/javascripts/environments/components/container.vue @@ -1,4 +1,5 @@ <script> +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import tablePagination from '../../vue_shared/components/table_pagination.vue'; import environmentTable from '../components/environments_table.vue'; @@ -6,6 +7,7 @@ export default { components: { environmentTable, tablePagination, + GlLoadingIcon, }, props: { isLoading: { diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index 0a3ae384afa..03c3ad0401f 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -4,6 +4,7 @@ import { formatTime } from '~/lib/utils/datetime_utility'; import Icon from '~/vue_shared/components/icon.vue'; import eventHub from '../event_hub'; import tooltip from '../../vue_shared/directives/tooltip'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; export default { directives: { @@ -11,6 +12,7 @@ export default { }, components: { Icon, + GlLoadingIcon, }, props: { actions: { diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue index 9e137f79dcc..69856abc2d5 100644 --- a/app/assets/javascripts/environments/components/environment_rollback.vue +++ b/app/assets/javascripts/environments/components/environment_rollback.vue @@ -9,10 +9,12 @@ import { s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import eventHub from '../event_hub'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; export default { components: { Icon, + GlLoadingIcon, }, directives: { diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 16abafebbc0..c03d4f29ff9 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -2,11 +2,13 @@ /** * Render environments table. */ +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import environmentItem from './environment_item.vue'; export default { components: { environmentItem, + GlLoadingIcon, }, props: { diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue index 70a8838b772..159c0bdc992 100644 --- a/app/assets/javascripts/frequent_items/components/app.vue +++ b/app/assets/javascripts/frequent_items/components/app.vue @@ -1,6 +1,7 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; import AccessorUtilities from '~/lib/utils/accessor'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import eventHub from '../event_hub'; import store from '../store/'; import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants'; @@ -14,6 +15,7 @@ export default { components: { FrequentItemsSearchInput, FrequentItemsList, + GlLoadingIcon, }, mixins: [frequentItemsMixin], props: { diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index a032f291546..2a4a39436e7 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -8,6 +8,7 @@ import { HIDDEN_CLASS } from '~/lib/utils/constants'; import { getParameterByName } from '~/lib/utils/common_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import eventHub from '../event_hub'; import { COMMON_STR, CONTENT_LIST_CLASS } from '../constants'; import groupsComponent from './groups.vue'; @@ -16,6 +17,7 @@ export default { components: { DeprecatedModal, groupsComponent, + GlLoadingIcon, }, props: { action: { diff --git a/app/assets/javascripts/ide/components/branches/search_list.vue b/app/assets/javascripts/ide/components/branches/search_list.vue index 52ccc537c9d..358f1153de2 100644 --- a/app/assets/javascripts/ide/components/branches/search_list.vue +++ b/app/assets/javascripts/ide/components/branches/search_list.vue @@ -2,12 +2,14 @@ import { mapActions, mapState } from 'vuex'; import _ from 'underscore'; import Icon from '~/vue_shared/components/icon.vue'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import Item from './item.vue'; export default { components: { Item, Icon, + GlLoadingIcon, }, data() { return { diff --git a/app/assets/javascripts/ide/components/error_message.vue b/app/assets/javascripts/ide/components/error_message.vue index a20dc0a7006..2d9bd99e82a 100644 --- a/app/assets/javascripts/ide/components/error_message.vue +++ b/app/assets/javascripts/ide/components/error_message.vue @@ -1,7 +1,11 @@ <script> import { mapActions } from 'vuex'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; export default { + components: { + GlLoadingIcon, + }, props: { message: { type: Object, diff --git a/app/assets/javascripts/ide/components/file_templates/dropdown.vue b/app/assets/javascripts/ide/components/file_templates/dropdown.vue index 94222c08e91..891f7d48b4c 100644 --- a/app/assets/javascripts/ide/components/file_templates/dropdown.vue +++ b/app/assets/javascripts/ide/components/file_templates/dropdown.vue @@ -2,10 +2,12 @@ import $ from 'jquery'; import { mapActions, mapState } from 'vuex'; import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; export default { components: { DropdownButton, + GlLoadingIcon, }, props: { data: { diff --git a/app/assets/javascripts/ide/components/jobs/list.vue b/app/assets/javascripts/ide/components/jobs/list.vue index acd37605d16..57da8b4e2cb 100644 --- a/app/assets/javascripts/ide/components/jobs/list.vue +++ b/app/assets/javascripts/ide/components/jobs/list.vue @@ -1,10 +1,12 @@ <script> import { mapActions } from 'vuex'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import Stage from './stage.vue'; export default { components: { Stage, + GlLoadingIcon, }, props: { stages: { diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue index ec168d36b9e..5644759d2f9 100644 --- a/app/assets/javascripts/ide/components/jobs/stage.vue +++ b/app/assets/javascripts/ide/components/jobs/stage.vue @@ -1,4 +1,5 @@ <script> +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import tooltip from '../../../vue_shared/directives/tooltip'; import Icon from '../../../vue_shared/components/icon.vue'; import CiIcon from '../../../vue_shared/components/ci_icon.vue'; @@ -12,6 +13,7 @@ export default { Icon, CiIcon, Item, + GlLoadingIcon, }, props: { stage: { diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue index f5e42e87f1b..e4000f588bd 100644 --- a/app/assets/javascripts/ide/components/merge_requests/list.vue +++ b/app/assets/javascripts/ide/components/merge_requests/list.vue @@ -3,6 +3,7 @@ import { mapActions, mapState } from 'vuex'; import _ from 'underscore'; import { __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import Item from './item.vue'; import TokenedInput from '../shared/tokened_input.vue'; @@ -16,6 +17,7 @@ export default { TokenedInput, Item, Icon, + GlLoadingIcon, }, data() { return { diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index b670b0355b7..16aec1decd6 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -1,6 +1,7 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import _ from 'underscore'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import { sprintf, __ } from '../../../locale'; import Icon from '../../../vue_shared/components/icon.vue'; import CiIcon from '../../../vue_shared/components/ci_icon.vue'; @@ -17,6 +18,7 @@ export default { Tab, JobsList, EmptyState, + GlLoadingIcon, }, computed: { ...mapState(['pipelinesEmptyStateSvgPath', 'links']), diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue index 37a8ad36507..0bd56ff6e9b 100644 --- a/app/assets/javascripts/ide/components/preview/clientside.vue +++ b/app/assets/javascripts/ide/components/preview/clientside.vue @@ -3,6 +3,7 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import _ from 'underscore'; import { Manager } from 'smooshpack'; import { listen } from 'codesandbox-api'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import Navigator from './navigator.vue'; import { packageJsonPath } from '../../constants'; import { createPathWithExt } from '../../utils'; @@ -10,6 +11,7 @@ import { createPathWithExt } from '../../utils'; export default { components: { Navigator, + GlLoadingIcon, }, data() { return { diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue index 42f23801692..af8959186f9 100644 --- a/app/assets/javascripts/ide/components/preview/navigator.vue +++ b/app/assets/javascripts/ide/components/preview/navigator.vue @@ -1,10 +1,12 @@ <script> import { listen } from 'codesandbox-api'; import Icon from '~/vue_shared/components/icon.vue'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; export default { components: { Icon, + GlLoadingIcon, }, props: { manager: { diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index 6e95e3d16f8..35104c80694 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -3,9 +3,11 @@ import _ from 'underscore'; import { mapGetters, mapState, mapActions } from 'vuex'; import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; +import { polyfillSticky } from '~/lib/utils/sticky'; import bp from '~/breakpoints'; import CiHeader from '~/vue_shared/components/header_ci_component.vue'; import Callout from '~/vue_shared/components/callout.vue'; +import Icon from '~/vue_shared/components/icon.vue'; import createStore from '../store'; import EmptyState from './empty_state.vue'; import EnvironmentsBlock from './environments_block.vue'; @@ -14,6 +16,8 @@ import Log from './job_log.vue'; import LogTopBar from './job_log_controllers.vue'; import StuckBlock from './stuck_block.vue'; import Sidebar from './sidebar.vue'; +import { sprintf } from '~/locale'; +import delayedJobMixin from '../mixins/delayed_job_mixin'; export default { name: 'JobPageApp', @@ -24,12 +28,14 @@ export default { EmptyState, EnvironmentsBlock, ErasedBlock, - GlLoadingIcon, + Icon, Log, LogTopBar, StuckBlock, Sidebar, + GlLoadingIcon, }, + mixins: [delayedJobMixin], props: { runnerSettingsUrl: { type: String, @@ -89,6 +95,17 @@ export default { shouldRenderContent() { return !this.isLoading && !this.hasError; }, + + emptyStateTitle() { + const { emptyStateIllustration, remainingTime } = this; + const { title } = emptyStateIllustration; + + if (this.isDelayedJob) { + return sprintf(title, { remainingTime }); + } + + return title; + }, }, watch: { // Once the job log is loaded, @@ -97,6 +114,14 @@ export default { if (_.isEmpty(oldVal) && !_.isEmpty(newVal.pipeline)) { this.fetchStages(); } + + if (newVal.archived) { + this.$nextTick(() => { + if (this.$refs.sticky) { + polyfillSticky(this.$refs.sticky); + } + }); + } }, }, created() { @@ -114,16 +139,13 @@ export default { window.addEventListener('resize', this.onResize); window.addEventListener('scroll', this.updateScroll); }, - mounted() { this.updateSidebar(); }, - destroyed() { window.removeEventListener('resize', this.onResize); window.removeEventListener('scroll', this.updateScroll); }, - methods: { ...mapActions([ 'setJobEndpoint', @@ -218,14 +240,28 @@ export default { :erased-at="job.erased_at" /> + <div + v-if="job.archived" + ref="sticky" + class="js-archived-job prepend-top-default archived-sticky sticky-top" + > + <icon + name="lock" + class="align-text-bottom" + /> + + {{ __('This job is archived. Only the complete pipeline can be retried.') }} + </div> <!--job log --> <div v-if="hasTrace" - class="build-trace-container prepend-top-default"> + class="build-trace-container" + > <log-top-bar :class="{ 'sidebar-expanded': isSidebarOpen, - 'sidebar-collapsed': !isSidebarOpen + 'sidebar-collapsed': !isSidebarOpen, + 'has-archived-block': job.archived }" :erase-path="job.erase_path" :size="traceSize" @@ -250,7 +286,7 @@ export default { class="js-job-empty-state" :illustration-path="emptyStateIllustration.image" :illustration-size-class="emptyStateIllustration.size" - :title="emptyStateIllustration.title" + :title="emptyStateTitle" :content="emptyStateIllustration.content" :action="emptyStateAction" /> diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue index cdac8a391d1..3ddcfd11dca 100644 --- a/app/assets/javascripts/jobs/components/job_container_item.vue +++ b/app/assets/javascripts/jobs/components/job_container_item.vue @@ -1,7 +1,10 @@ <script> -import { GlTooltipDirective, GlLink } from '@gitlab-org/gitlab-ui'; +import { GlLink } from '@gitlab-org/gitlab-ui'; +import tooltip from '~/vue_shared/directives/tooltip'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import Icon from '~/vue_shared/components/icon.vue'; +import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; +import { sprintf } from '~/locale'; export default { components: { @@ -10,8 +13,9 @@ export default { GlLink, }, directives: { - GlTooltip: GlTooltipDirective, + tooltip, }, + mixins: [delayedJobMixin], props: { job: { type: Object, @@ -24,7 +28,14 @@ export default { }, computed: { tooltipText() { - return `${this.job.name} - ${this.job.status.tooltip}`; + const { name, status } = this.job; + const text = `${name} - ${status.tooltip}`; + + if (this.isDelayedJob) { + return sprintf(text, { remainingTime: this.remainingTime }); + } + + return text; }, }, }; @@ -39,7 +50,7 @@ export default { }" > <gl-link - v-gl-tooltip + v-tooltip :href="job.status.details_path" :title="tooltipText" data-boundary="viewport" diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue index eeefa33264f..8b506b124ec 100644 --- a/app/assets/javascripts/jobs/components/job_log_controllers.vue +++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue @@ -69,7 +69,7 @@ export default { }; </script> <template> - <div class="top-bar affix"> + <div class="top-bar"> <!-- truncate information --> <div class="js-truncated-info truncated-info d-none d-sm-block float-left"> <template v-if="isTraceSizeVisible"> diff --git a/app/assets/javascripts/jobs/mixins/delayed_job_mixin.js b/app/assets/javascripts/jobs/mixins/delayed_job_mixin.js new file mode 100644 index 00000000000..8c7fb785a61 --- /dev/null +++ b/app/assets/javascripts/jobs/mixins/delayed_job_mixin.js @@ -0,0 +1,50 @@ +import { calculateRemainingMilliseconds, formatTime } from '~/lib/utils/datetime_utility'; + +export default { + data() { + return { + remainingTime: formatTime(0), + remainingTimeIntervalId: null, + }; + }, + + mounted() { + this.startRemainingTimeInterval(); + }, + + beforeDestroy() { + if (this.remainingTimeIntervalId) { + clearInterval(this.remainingTimeIntervalId); + } + }, + + computed: { + isDelayedJob() { + return this.job && this.job.scheduled; + }, + }, + + watch: { + isDelayedJob() { + this.startRemainingTimeInterval(); + }, + }, + + methods: { + startRemainingTimeInterval() { + if (this.remainingTimeIntervalId) { + clearInterval(this.remainingTimeIntervalId); + } + + if (this.isDelayedJob) { + this.updateRemainingTime(); + this.remainingTimeIntervalId = setInterval(() => this.updateRemainingTime(), 1000); + } + }, + + updateRemainingTime() { + const remainingMilliseconds = calculateRemainingMilliseconds(this.job.scheduled_at); + this.remainingTime = formatTime(remainingMilliseconds); + }, + }, +}; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 2950c2299ab..d8255181574 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -11,7 +11,6 @@ import bp from './breakpoints'; import { parseUrlPathname, handleLocationHash, isMetaClick } from './lib/utils/common_utils'; import { isInVueNoteablePage } from './lib/utils/dom_utils'; import { getLocationHash } from './lib/utils/url_utility'; -import initDiscussionTab from './image_diff/init_discussion_tab'; import Diff from './diff'; import { localTimeAgo } from './lib/utils/datetime_utility'; import syntaxHighlight from './syntax_highlight'; @@ -207,8 +206,6 @@ export default class MergeRequestTabs { } this.resetViewContainer(); this.destroyPipelinesView(); - - initDiscussionTab(); } if (this.setUrl) { this.setCurrentAction(action); diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index eaa0cded224..b209f736c3f 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -1,15 +1,18 @@ <script> import { mapState, mapActions } from 'vuex'; -import imageDiffHelper from '~/image_diff/helpers/index'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; +import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; +import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue'; import { GlSkeletonLoading } from '@gitlab-org/gitlab-ui'; -import { trimFirstCharOfLineContent } from '~/diffs/store/utils'; +import { trimFirstCharOfLineContent, getDiffMode } from '~/diffs/store/utils'; export default { components: { DiffFileHeader, GlSkeletonLoading, + DiffViewer, + ImageDiffOverlay, }, props: { discussion: { @@ -25,7 +28,11 @@ export default { computed: { ...mapState({ noteableData: state => state.notes.noteableData, + projectPath: state => state.diffs.projectPath, }), + diffMode() { + return getDiffMode(this.diffFile); + }, hasTruncatedDiffLines() { return this.discussion.truncatedDiffLines && this.discussion.truncatedDiffLines.length !== 0; }, @@ -62,11 +69,7 @@ export default { }, }, mounted() { - if (this.isImageDiff) { - const canCreateNote = false; - const renderCommentBadge = true; - imageDiffHelper.initImageDiff(this.$refs.fileHolder, canCreateNote, renderCommentBadge); - } else if (!this.hasTruncatedDiffLines) { + if (!this.hasTruncatedDiffLines) { this.fetchDiff(); } }, @@ -160,7 +163,24 @@ export default { <div v-else > - <div v-html="imageDiffHtml"></div> + <diff-viewer + :diff-mode="diffMode" + :new-path="diffFile.newPath" + :new-sha="diffFile.diffRefs.headSha" + :old-path="diffFile.oldPath" + :old-sha="diffFile.diffRefs.baseSha" + :file-hash="diffFile.fileHash" + :project-path="projectPath" + > + <image-diff-overlay + slot="image-overlay" + :discussions="discussion" + :file-hash="diffFile.fileHash" + :show-comment-icon="true" + :should-toggle-discussion="false" + badge-class="image-comment-badge" + /> + </diff-viewer> <slot></slot> </div> </div> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index e075f94b82b..01cbe40f444 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -9,11 +9,13 @@ import resolvedDiscussionSvg from 'icons/_icon_status_success_solid.svg'; import ellipsisSvg from 'icons/_ellipsis_v.svg'; import Icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; export default { name: 'NoteActions', components: { Icon, + GlLoadingIcon, }, directives: { tooltip, @@ -246,7 +248,7 @@ export default { <ul class="dropdown-menu more-actions-dropdown dropdown-open-left"> <li v-if="canReportAsAbuse"> <a :href="reportAbusePath"> - Report as abuse + {{ __('Report abuse to GitLab') }} </a> </li> <li v-if="noteUrl"> @@ -255,7 +257,7 @@ export default { type="button" class="btn-default btn-transparent js-btn-copy-note-link" > - Copy link + {{ __('Copy link') }} </button> </li> <li v-if="canEdit"> @@ -264,7 +266,7 @@ export default { type="button" @click.prevent="onDelete"> <span class="text-danger"> - Delete comment + {{ __('Delete comment') }} </span> </button> </li> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 6293dd5b7e1..07115ca07c4 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -350,11 +350,18 @@ Please check your network connection and try again.`; <ul class="notes"> <component :is="componentName(note)" - v-for="note in discussion.notes" + v-for="(note, index) in discussion.notes" :key="note.id" :note="componentData(note)" @handleDeleteNote="deleteNoteHandler" - /> + > + <slot + v-if="index === 0" + slot="avatar-badge" + name="avatar-badge" + > + </slot> + </component> </ul> <div :class="{ 'is-replying': isReplying }" diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index f391ed848a4..40222ac4a80 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -182,7 +182,13 @@ export default { :img-src="author.avatar_url" :img-alt="author.name" :img-size="40" - /> + > + <slot + slot="avatar-badge" + name="avatar-badge" + > + </slot> + </user-avatar-link> </div> <div class="timeline-content"> <div class="note-header"> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 23c0be7742e..4de8b3401e8 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -1,10 +1,12 @@ <script> import _ from 'underscore'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import StageColumnComponent from './stage_column_component.vue'; export default { components: { StageColumnComponent, + GlLoadingIcon, }, props: { isLoading: { diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index a1504592bbc..7cdde8a53b3 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -2,6 +2,8 @@ import ActionComponent from './action_component.vue'; import JobNameComponent from './job_name_component.vue'; import tooltip from '../../../vue_shared/directives/tooltip'; +import { sprintf } from '~/locale'; +import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; /** * Renders the badge for the pipeline graph and the job's dropdown. @@ -36,6 +38,7 @@ export default { directives: { tooltip, }, + mixins: [delayedJobMixin], props: { job: { type: Object, @@ -52,6 +55,7 @@ export default { default: Infinity, }, }, + computed: { status() { return this.job && this.job.status ? this.job.status : {}; @@ -59,17 +63,23 @@ export default { tooltipText() { const textBuilder = []; + const { name: jobName } = this.job; - if (this.job.name) { - textBuilder.push(this.job.name); + if (jobName) { + textBuilder.push(jobName); } - if (this.job.name && this.status.tooltip) { + const { tooltip: statusTooltip } = this.status; + if (jobName && statusTooltip) { textBuilder.push('-'); } - if (this.status.tooltip) { - textBuilder.push(this.job.status.tooltip); + if (statusTooltip) { + if (this.isDelayedJob) { + textBuilder.push(sprintf(statusTooltip, { remainingTime: this.remainingTime })); + } else { + textBuilder.push(statusTooltip); + } } return textBuilder.join(' '); @@ -88,6 +98,7 @@ export default { return this.job.status && this.job.status.action && this.job.status.action.path; }, }, + methods: { pipelineActionRequestComplete() { this.$emit('pipelineActionRequestComplete'); diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index 1f9187c3d65..8f004b491c8 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -1,4 +1,5 @@ <script> +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import ciHeader from '../../vue_shared/components/header_ci_component.vue'; import eventHub from '../event_hub'; @@ -6,6 +7,7 @@ export default { name: 'PipelineHeaderSection', components: { ciHeader, + GlLoadingIcon, }, props: { pipeline: { diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_actions.vue index 07a4af3e61e..cb47704ca26 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_actions.vue @@ -4,6 +4,7 @@ import eventHub from '../event_hub'; import Icon from '../../vue_shared/components/icon.vue'; import tooltip from '../../vue_shared/directives/tooltip'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; export default { directives: { @@ -12,6 +13,7 @@ export default { components: { Icon, GlCountdown, + GlLoadingIcon, }, props: { actions: { diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue index 7ec55792850..3df8f7a6da6 100644 --- a/app/assets/javascripts/pipelines/components/stage.vue +++ b/app/assets/javascripts/pipelines/components/stage.vue @@ -13,6 +13,7 @@ */ import $ from 'jquery'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import { __ } from '../../locale'; import Flash from '../../flash'; import axios from '../../lib/utils/axios_utils'; @@ -26,6 +27,7 @@ export default { components: { Icon, JobItem, + GlLoadingIcon, }, directives: { diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js index 85781f548c6..41bc5dcce5c 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines.js @@ -1,4 +1,5 @@ import Visibility from 'visibilityjs'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import { __ } from '../../locale'; import Flash from '../../flash'; import Poll from '../../lib/utils/poll'; @@ -13,6 +14,7 @@ export default { PipelinesTableComponent, SvgBlankState, EmptyState, + GlLoadingIcon, }, data() { return { diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_dropdown_mixin.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_dropdown_mixin.js index d5266544307..f5dae5ad808 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_dropdown_mixin.js +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_dropdown_mixin.js @@ -2,6 +2,7 @@ import _ from 'underscore'; import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_search_input.vue'; import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import store from '../store'; @@ -11,6 +12,7 @@ export default { DropdownButton, DropdownSearchInput, DropdownHiddenInput, + GlLoadingIcon, }, props: { fieldId: { diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue index 120b4fc2f2b..9a729ca9b91 100644 --- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue +++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue @@ -5,6 +5,7 @@ import Poll from '~/lib/utils/poll'; import Flash from '~/flash'; import { s__, sprintf } from '~/locale'; import tooltip from '~/vue_shared/directives/tooltip'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import CommitPipelineService from '../services/commit_pipeline_service'; export default { @@ -13,6 +14,7 @@ export default { }, components: { ciIcon, + GlLoadingIcon, }, props: { endpoint: { diff --git a/app/assets/javascripts/registry/components/app.vue b/app/assets/javascripts/registry/components/app.vue index 9dd1c87a87d..0a906f40f5a 100644 --- a/app/assets/javascripts/registry/components/app.vue +++ b/app/assets/javascripts/registry/components/app.vue @@ -1,5 +1,6 @@ <script> import { mapGetters, mapActions } from 'vuex'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import Flash from '../../flash'; import store from '../stores'; import collapsibleContainer from './collapsible_container.vue'; @@ -9,6 +10,7 @@ export default { name: 'RegistryListApp', components: { collapsibleContainer, + GlLoadingIcon, }, props: { endpoint: { diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue index 501b2625ae5..be9816a55c4 100644 --- a/app/assets/javascripts/registry/components/collapsible_container.vue +++ b/app/assets/javascripts/registry/components/collapsible_container.vue @@ -1,5 +1,6 @@ <script> import { mapActions } from 'vuex'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import Flash from '../../flash'; import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; import tooltip from '../../vue_shared/directives/tooltip'; @@ -12,6 +13,7 @@ export default { components: { clipboardButton, tableRegistry, + GlLoadingIcon, }, directives: { tooltip, diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue index 51188981bed..a44ba833b63 100644 --- a/app/assets/javascripts/reports/components/summary_row.vue +++ b/app/assets/javascripts/reports/components/summary_row.vue @@ -1,6 +1,7 @@ <script> import CiIcon from '~/vue_shared/components/ci_icon.vue'; import Popover from '~/vue_shared/components/help_popover.vue'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; /** * Renders the summary row for each report @@ -15,6 +16,7 @@ export default { components: { CiIcon, Popover, + GlLoadingIcon, }, props: { summary: { diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index 11b5dbe5f8e..fe73f6a0cef 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -2,6 +2,7 @@ import { __, n__, sprintf } from '~/locale'; import tooltip from '~/vue_shared/directives/tooltip'; import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; export default { directives: { @@ -9,6 +10,7 @@ export default { }, components: { userAvatarImage, + GlLoadingIcon, }, props: { loading: { diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue index bc59774f0a8..913a616d9f1 100644 --- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue @@ -1,6 +1,7 @@ <script> import { __ } from '~/locale'; import tooltip from '~/vue_shared/directives/tooltip'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import Icon from '~/vue_shared/components/icon.vue'; @@ -13,6 +14,7 @@ export default { }, components: { Icon, + GlLoadingIcon, }, props: { issuableId: { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index ba6a1687e51..b3340290ed3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -1,9 +1,11 @@ <script> +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import ciIcon from '../../vue_shared/components/ci_icon.vue'; export default { components: { ciIcon, + GlLoadingIcon, }, props: { status: { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue index 4f8b07484c0..4bfbdcf1404 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue @@ -1,4 +1,5 @@ <script> +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import eventHub from '../../event_hub'; import statusIcon from '../mr_widget_status_icon.vue'; @@ -6,6 +7,7 @@ export default { name: 'MRWidgetAutoMergeFailed', components: { statusIcon, + GlLoadingIcon, }, props: { mr: { 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 656c3b5c47e..7e33021e4b4 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 @@ -6,6 +6,7 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import MrWidgetAuthorTime from '../../components/mr_widget_author_time.vue'; import statusIcon from '../mr_widget_status_icon.vue'; import eventHub from '../../event_hub'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; export default { name: 'MRWidgetMerged', @@ -16,6 +17,7 @@ export default { MrWidgetAuthorTime, statusIcon, ClipboardButton, + GlLoadingIcon, }, props: { mr: { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index 041fa13a8f5..0e714cc2aa1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -1,4 +1,5 @@ <script> +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import simplePoll from '../../../lib/utils/simple_poll'; import eventHub from '../../event_hub'; import statusIcon from '../mr_widget_status_icon.vue'; @@ -8,6 +9,7 @@ export default { name: 'MRWidgetRebase', components: { statusIcon, + GlLoadingIcon, }, props: { mr: { diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue index 8163947cd0c..6f2f0f98690 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue @@ -17,19 +17,37 @@ export default { type: Boolean, default: true, }, + innerCssClasses: { + type: [Array, Object, String], + required: false, + default: '', + }, }, data() { return { width: 0, height: 0, - isZoomable: false, - isZoomed: false, + isLoaded: false, }; }, computed: { fileSizeReadable() { return numberToHumanSize(this.fileSize); }, + dimensionStyles() { + if (!this.isLoaded) return {}; + + return { + width: `${this.width}px`, + height: `${this.height}px`, + }; + }, + hasFileSize() { + return this.fileSize > 0; + }, + hasDimensions() { + return this.width && this.height; + }, }, beforeDestroy() { window.removeEventListener('resize', this.resizeThrottled, false); @@ -48,51 +66,52 @@ export default { const { contentImg } = this.$refs; if (contentImg) { - this.isZoomable = - contentImg.naturalWidth > contentImg.width || - contentImg.naturalHeight > contentImg.height; - this.width = contentImg.naturalWidth; this.height = contentImg.naturalHeight; - this.$emit('imgLoaded', { - width: this.width, - height: this.height, - renderedWidth: contentImg.clientWidth, - renderedHeight: contentImg.clientHeight, + this.$nextTick(() => { + this.isLoaded = true; + + this.$emit('imgLoaded', { + width: this.width, + height: this.height, + renderedWidth: contentImg.clientWidth, + renderedHeight: contentImg.clientHeight, + }); }); } }, - onImgClick() { - if (this.isZoomable) this.isZoomed = !this.isZoomed; - }, }, }; </script> <template> - <div class="file-container"> - <div class="file-content image_file"> + <div> + <div + :class="innerCssClasses" + :style="dimensionStyles" + class="position-relative" + > <img ref="contentImg" - :class="{ 'is-zoomable': isZoomable, 'is-zoomed': isZoomed }" :src="path" - :alt="path" @load="onImgLoad" - @click="onImgClick"/> - <p - v-if="renderInfo" - class="file-info prepend-top-10"> - <template v-if="fileSize>0"> - {{ fileSizeReadable }} - </template> - <template v-if="fileSize>0 && width && height"> - | - </template> - <template v-if="width && height"> - W: {{ width }} | H: {{ height }} - </template> - </p> + /> + <slot name="image-overlay"></slot> </div> + <p + v-if="renderInfo" + class="image-info" + > + <template v-if="hasFileSize"> + {{ fileSizeReadable }} + </template> + <template v-if="hasFileSize && hasDimensions"> + | + </template> + <template v-if="hasDimensions"> + <strong>W</strong>: {{ width }} | <strong>H</strong>: {{ height }} + </template> + </p> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue index cfc5343217c..9c3f3e7f7a9 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue @@ -69,6 +69,13 @@ export default { :new-path="fullNewPath" :old-path="fullOldPath" :project-path="projectPath" - /> + > + <slot + slot="image-overlay" + name="image-overlay" + > + </slot> + </component> + <slot></slot> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue index 38e881d17a2..cd0c1e850af 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue @@ -15,11 +15,6 @@ export default { type: String, required: true, }, - projectPath: { - type: String, - required: false, - default: '', - }, }, data() { return { @@ -120,7 +115,6 @@ export default { key="onionOldImg" :render-info="false" :path="oldPath" - :project-path="projectPath" @imgLoaded="onionOldImgLoaded" /> </div> @@ -136,9 +130,14 @@ export default { key="onionNewImg" :render-info="false" :path="newPath" - :project-path="projectPath" @imgLoaded="onionNewImgLoaded" - /> + > + <slot + slot="image-overlay" + name="image-overlay" + > + </slot> + </image-viewer> </div> <div class="controls"> <div class="transparent"></div> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue index 86366c799a2..c3cfe54eb4d 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue @@ -16,11 +16,6 @@ export default { type: String, required: true, }, - projectPath: { - type: String, - required: false, - default: '', - }, }, data() { return { @@ -117,16 +112,14 @@ export default { 'height': swipeMaxPixelHeight, }" class="swipe-frame"> - <div class="frame deleted"> - <image-viewer - key="swipeOldImg" - ref="swipeOldImg" - :render-info="false" - :path="oldPath" - :project-path="projectPath" - @imgLoaded="swipeOldImgLoaded" - /> - </div> + <image-viewer + key="swipeOldImg" + ref="swipeOldImg" + :render-info="false" + :path="oldPath" + class="frame deleted" + @imgLoaded="swipeOldImgLoaded" + /> <div ref="swipeWrap" :style="{ @@ -134,15 +127,19 @@ export default { 'height': swipeMaxPixelHeight, }" class="swipe-wrap"> - <div class="frame added"> - <image-viewer - key="swipeNewImg" - :render-info="false" - :path="newPath" - :project-path="projectPath" - @imgLoaded="swipeNewImgLoaded" - /> - </div> + <image-viewer + key="swipeNewImg" + :render-info="false" + :path="newPath" + class="frame added" + @imgLoaded="swipeNewImgLoaded" + > + <slot + slot="image-overlay" + name="image-overlay" + > + </slot> + </image-viewer> </div> <span ref="swipeBar" diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue index 9c19266ecdf..9806d65e940 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue @@ -14,28 +14,29 @@ export default { type: String, required: true, }, - projectPath: { - type: String, - required: false, - default: '', - }, }, }; </script> <template> - <div class="two-up view row"> - <div class="col-sm-6 frame deleted"> - <image-viewer - :path="oldPath" - :project-path="projectPath" - /> - </div> - <div class="col-sm-6 frame added"> - <image-viewer - :path="newPath" - :project-path="projectPath" - /> - </div> + <div class="two-up view"> + <image-viewer + :path="oldPath" + :render-info="true" + inner-css-classes="frame deleted" + class="wrap" + /> + <image-viewer + :path="newPath" + :render-info="true" + :inner-css-classes="['frame', 'added']" + class="wrap" + > + <slot + slot="image-overlay" + name="image-overlay" + > + </slot> + </image-viewer> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue index 1af85283277..e68a2aa73fa 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue @@ -8,9 +8,6 @@ import { diffModes, imageViewMode } from '../constants'; export default { components: { ImageViewer, - TwoUpViewer, - SwipeViewer, - OnionSkinViewer, }, props: { diffMode: { @@ -25,17 +22,32 @@ export default { type: String, required: true, }, - projectPath: { - type: String, - required: false, - default: '', - }, }, data() { return { mode: imageViewMode.twoup, }; }, + computed: { + imageViewComponent() { + switch (this.mode) { + case imageViewMode.twoup: + return TwoUpViewer; + case imageViewMode.swipe: + return SwipeViewer; + case imageViewMode.onion: + return OnionSkinViewer; + default: + return undefined; + } + }, + isNew() { + return this.diffMode === diffModes.new; + }, + imagePath() { + return this.isNew ? this.newPath : this.oldPath; + }, + }, methods: { changeMode(newMode) { this.mode = newMode; @@ -52,15 +64,16 @@ export default { v-if="diffMode === $options.diffModes.replaced" class="diff-viewer"> <div class="image js-replaced-image"> - <two-up-viewer - v-if="mode === $options.imageViewMode.twoup" - v-bind="$props"/> - <swipe-viewer - v-else-if="mode === $options.imageViewMode.swipe" - v-bind="$props"/> - <onion-skin-viewer - v-else-if="mode === $options.imageViewMode.onion" - v-bind="$props"/> + <component + :is="imageViewComponent" + v-bind="$props" + > + <slot + slot="image-overlay" + name="image-overlay" + > + </slot> + </component> </div> <div class="view-modes"> <ul class="view-modes-menu"> @@ -87,23 +100,27 @@ export default { </li> </ul> </div> - <div class="note-container"></div> - </div> - <div - v-else-if="diffMode === $options.diffModes.new" - class="diff-viewer added"> - <image-viewer - :path="newPath" - :project-path="projectPath" - /> </div> <div v-else - class="diff-viewer deleted"> - <image-viewer - :path="oldPath" - :project-path="projectPath" - /> + class="diff-viewer" + > + <div class="image"> + <image-viewer + :path="imagePath" + :inner-css-classes="['frame', { + 'added': isNew, + 'deleted': diffMode === $options.diffModes.deleted + }]" + > + <slot + v-if="isNew" + slot="image-overlay" + name="image-overlay" + > + </slot> + </image-viewer> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue index 31087017968..0e194eaaed5 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue @@ -1,7 +1,11 @@ <script> import { __ } from '~/locale'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; export default { + components: { + GlLoadingIcon, + }, props: { isDisabled: { type: Boolean, diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue index 408f7d7965f..03818be6a69 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/file_icon.vue @@ -1,4 +1,5 @@ <script> +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import getIconForFile from './file_icon/file_icon_map'; import icon from '../../vue_shared/components/icon.vue'; @@ -17,6 +18,7 @@ import icon from '../../vue_shared/components/icon.vue'; export default { components: { icon, + GlLoadingIcon, }, props: { fileName: { diff --git a/app/assets/javascripts/vue_shared/components/loading_button.vue b/app/assets/javascripts/vue_shared/components/loading_button.vue index f9b7fd5b1f9..69d7e5c46f5 100644 --- a/app/assets/javascripts/vue_shared/components/loading_button.vue +++ b/app/assets/javascripts/vue_shared/components/loading_button.vue @@ -1,4 +1,5 @@ <script> +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; /* eslint-disable vue/require-default-prop */ /* This is a re-usable vue component for rendering a button that will probably be sending off ajax requests and need @@ -18,6 +19,9 @@ */ export default { + components: { + GlLoadingIcon, + }, props: { loading: { type: Boolean, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue index 500586302cf..5b12bb6b59e 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue @@ -1,4 +1,5 @@ <script> +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import datePicker from '../pikaday.vue'; import toggleSidebar from './toggle_sidebar.vue'; import collapsedCalendarIcon from './collapsed_calendar_icon.vue'; @@ -10,6 +11,7 @@ export default { datePicker, toggleSidebar, collapsedCalendarIcon, + GlLoadingIcon, }, props: { blockClass: { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue index 3df286de129..e50d612ce36 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue @@ -4,6 +4,7 @@ import { __ } from '~/locale'; import LabelsSelect from '~/labels_select'; import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import DropdownTitle from './dropdown_title.vue'; import DropdownValue from './dropdown_value.vue'; import DropdownValueCollapsed from './dropdown_value_collapsed.vue'; @@ -24,6 +25,7 @@ export default { DropdownSearchInput, DropdownFooter, DropdownCreateLabel, + GlLoadingIcon, }, props: { showCreate: { diff --git a/app/assets/javascripts/vue_shared/components/toggle_button.vue b/app/assets/javascripts/vue_shared/components/toggle_button.vue index 4e9289cbed8..e7cb5cfac12 100644 --- a/app/assets/javascripts/vue_shared/components/toggle_button.vue +++ b/app/assets/javascripts/vue_shared/components/toggle_button.vue @@ -1,4 +1,5 @@ <script> +import { GlLoadingIcon } from '@gitlab-org/gitlab-ui'; import { s__ } from '../../locale'; import icon from './icon.vue'; @@ -10,6 +11,7 @@ const LABEL_OFF = s__('ToggleButton|Toggle Status: OFF'); export default { components: { icon, + GlLoadingIcon, }, model: { diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue index 86c7498a092..dd6f96e2609 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue @@ -99,6 +99,6 @@ export default { v-tooltip :title="tooltipText" :tooltip-placement="tooltipPlacement" - >{{ username }}</span> + >{{ username }}</span><slot name="avatar-badge"></slot> </gl-link> </template> diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 1449723de52..81cb519883b 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -55,9 +55,29 @@ @include build-trace(); } + .archived-sticky { + top: $header-height; + border-radius: 2px 2px 0 0; + color: $orange-600; + background-color: $orange-100; + border: 1px solid $border-gray-normal; + border-bottom: 0; + padding: 3px 12px; + margin: auto; + align-items: center; + + .with-performance-bar & { + top: $header-height + $performance-bar-height; + } + } + .top-bar { @include build-trace-top-bar(35px); + &.has-archived-block { + top: $header-height + $performance-bar-height + 28px; + } + &.affix { top: $header-height; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 52c91266ff4..19bc4262e21 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -421,21 +421,13 @@ .diff-file-container { .frame.deleted { - border: 0; + border: 1px solid $deleted; background-color: inherit; - - .image_file img { - border: 1px solid $deleted; - } } .frame.added { - border: 0; + border: 1px solid $added; background-color: inherit; - - .image_file img { - border: 1px solid $added; - } } .swipe.view, @@ -481,6 +473,11 @@ bottom: -25px; } } + + .discussion-notes .discussion-notes { + margin-left: 0; + border-left: 0; + } } .file-content .diff-file { @@ -804,7 +801,7 @@ // double jagged line divider .discussion-notes + .discussion-notes::before, - .discussion-notes + .discussion-form::before { + .diff-file-discussions + .discussion-form::before { content: ''; position: relative; display: block; @@ -844,6 +841,13 @@ background-repeat: repeat; } + .diff-file-discussions + .discussion-form::before { + width: auto; + margin-left: -16px; + margin-right: -16px; + margin-bottom: 16px; + } + .notes { position: relative; } @@ -870,11 +874,13 @@ } } -.files:not([data-can-create-note]) .frame { +.files:not([data-can-create-note="true"]) .frame { cursor: auto; } -.frame.click-to-comment { +.frame, +.frame.click-to-comment, +.btn-transparent.image-diff-overlay-add-comment { position: relative; cursor: image-url('illustrations/image_comment_light_cursor.svg') $image-comment-cursor-left-offset $image-comment-cursor-top-offset, @@ -910,6 +916,7 @@ .frame .badge.badge-pill, .image-diff-avatar-link .badge.badge-pill, +.user-avatar-link .badge.badge-pill, .notes > .badge.badge-pill { position: absolute; background-color: $blue-400; @@ -944,7 +951,8 @@ } } -.image-diff-avatar-link { +.image-diff-avatar-link, +.user-avatar-link { position: relative; .badge.badge-pill, @@ -1073,3 +1081,14 @@ top: 0; } } + +.image-diff-overlay, +.image-diff-overlay-add-comment { + top: 0; + left: 0; + + &:active, + &:focus { + outline: 0; + } +} diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb index 7f874687212..0dd7500623d 100644 --- a/app/controllers/boards/issues_controller.rb +++ b/app/controllers/boards/issues_controller.rb @@ -100,18 +100,12 @@ module Boards .merge(board_id: params[:board_id], list_id: params[:list_id], request: request) end + def serializer + IssueSerializer.new(current_user: current_user) + end + def serialize_as_json(resource) - resource.as_json( - only: [:id, :iid, :project_id, :title, :confidential, :due_date, :relative_position, :weight], - labels: true, - issue_endpoints: true, - include_full_project_path: board.group_board?, - include: { - project: { only: [:id, :path] }, - assignees: { only: [:id, :name, :username], methods: [:avatar_url] }, - milestone: { only: [:id, :title] } - } - ) + serializer.represent(resource, serializer: 'board', include_full_project_path: board.group_board?) end def whitelist_query_limiting diff --git a/app/controllers/concerns/members_presentation.rb b/app/controllers/concerns/members_presentation.rb index c6c3598a976..0a9d3d86245 100644 --- a/app/controllers/concerns/members_presentation.rb +++ b/app/controllers/concerns/members_presentation.rb @@ -12,12 +12,7 @@ module MembersPresentation ).fabricate! end - # rubocop: disable CodeReuse/ActiveRecord def preload_associations(members) - ActiveRecord::Associations::Preloader.new.preload(members, :user) - ActiveRecord::Associations::Preloader.new.preload(members, :source) - ActiveRecord::Associations::Preloader.new.preload(members.map(&:user), :status) - ActiveRecord::Associations::Preloader.new.preload(members.map(&:user), :u2f_registrations) + MembersPreloader.new(members).preload_all end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb index d386fb63d9f..9c130af8394 100644 --- a/app/controllers/projects/autocomplete_sources_controller.rb +++ b/app/controllers/projects/autocomplete_sources_controller.rb @@ -1,40 +1,38 @@ # frozen_string_literal: true class Projects::AutocompleteSourcesController < Projects::ApplicationController - before_action :load_autocomplete_service, except: [:members] - def members render json: ::Projects::ParticipantsService.new(@project, current_user).execute(target) end def issues - render json: @autocomplete_service.issues + render json: autocomplete_service.issues end def merge_requests - render json: @autocomplete_service.merge_requests + render json: autocomplete_service.merge_requests end def labels - render json: @autocomplete_service.labels_as_hash(target) + render json: autocomplete_service.labels_as_hash(target) end def milestones - render json: @autocomplete_service.milestones + render json: autocomplete_service.milestones end def commands - render json: @autocomplete_service.commands(target, params[:type]) + render json: autocomplete_service.commands(target, params[:type]) end def snippets - render json: @autocomplete_service.snippets + render json: autocomplete_service.snippets end private - def load_autocomplete_service - @autocomplete_service = ::Projects::AutocompleteService.new(@project, current_user) + def autocomplete_service + @autocomplete_service ||= ::Projects::AutocompleteService.new(@project, current_user) end def target diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index c02ec407262..2a6fe3b9c97 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -122,7 +122,7 @@ class Projects::BlobController < Projects::ApplicationController @lines.map! do |line| # These are marked as context lines but are loaded from blobs. # We also have context lines loaded from diffs in other places. - diff_line = Gitlab::Diff::Line.new(line, 'context', nil, nil, nil) + diff_line = Gitlab::Diff::Line.new(line, nil, nil, nil, nil) diff_line.rich_text = line diff_line end diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 5307cd0720a..740f41c0642 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -22,6 +22,12 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic def render_diffs @environment = @merge_request.environments_for(current_user).last + notes_grouped_by_path = @notes.group_by { |note| note.position.file_path } + + @diffs.diff_files.each do |diff_file| + notes = notes_grouped_by_path.fetch(diff_file.file_path, []) + notes.each { |note| diff_file.unfold_diff_lines(note.position) } + end @diffs.write_cache diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 2adfc04deb8..3ce2398f1de 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -91,7 +91,14 @@ module EventsHelper words << "##{event.target_iid}" if event.target_iid words << "in" elsif event.target - words << "##{event.target_iid}:" + prefix = + if event.merge_request? + MergeRequest.reference_prefix + else + Issue.reference_prefix + end + + words << "#{prefix}#{event.target_iid}:" if event.target_iid words << event.target.title if event.target.respond_to?(:title) words << "at" end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 360c9924a7d..25596581d0f 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -815,7 +815,7 @@ module Ci end end - def predefined_variables + def predefined_variables # rubocop:disable Metrics/AbcSize Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.append(key: 'CI', value: 'true') variables.append(key: 'GITLAB_CI', value: 'true') @@ -835,6 +835,8 @@ module Ci variables.append(key: "CI_COMMIT_TAG", value: ref) if tag? variables.append(key: "CI_PIPELINE_TRIGGERED", value: 'true') if trigger_request variables.append(key: "CI_JOB_MANUAL", value: 'true') if action? + variables.append(key: "CI_NODE_INDEX", value: self.options[:instance].to_s) if self.options&.include?(:instance) + variables.append(key: "CI_NODE_TOTAL", value: (self.options&.dig(:parallel) || 1).to_s) variables.concat(legacy_variables) end end diff --git a/app/models/compare.rb b/app/models/compare.rb index b2d46ada831..f1ed84ab5a5 100644 --- a/app/models/compare.rb +++ b/app/models/compare.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'set' + class Compare include Gitlab::Utils::StrongMemoize @@ -77,4 +79,13 @@ class Compare head_sha: head_commit_sha ) end + + def modified_paths + paths = Set.new + diffs.diff_files.each do |diff| + paths.add diff.old_path + paths.add diff.new_path + end + paths.to_a + end end diff --git a/app/models/concerns/each_batch.rb b/app/models/concerns/each_batch.rb index 8cf0b8b154d..6314b46a7e3 100644 --- a/app/models/concerns/each_batch.rb +++ b/app/models/concerns/each_batch.rb @@ -39,7 +39,15 @@ module EachBatch # # of - The number of rows to retrieve per batch. # column - The column to use for ordering the batches. - def each_batch(of: 1000, column: primary_key) + # order_hint - An optional column to append to the `ORDER BY id` + # clause to help the query planner. PostgreSQL might perform badly + # with a LIMIT 1 because the planner is guessing that scanning the + # index in ID order will come across the desired row in less time + # it will take the planner than using another index. The + # order_hint does not affect the search results. For example, + # `ORDER BY id ASC, updated_at ASC` means the same thing as `ORDER + # BY id ASC`. + def each_batch(of: 1000, column: primary_key, order_hint: nil) unless column raise ArgumentError, 'the column: argument must be set to a column name to use for ordering rows' @@ -48,7 +56,9 @@ module EachBatch start = except(:select) .select(column) .reorder(column => :asc) - .take + + start = start.order(order_hint) if order_hint + start = start.take return unless start @@ -60,6 +70,9 @@ module EachBatch .select(column) .where(arel_table[column].gteq(start_id)) .reorder(column => :asc) + + stop = stop.order(order_hint) if order_hint + stop = stop .offset(of) .limit(1) .take diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 5f59e4832db..c32008aa9c7 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -66,6 +66,10 @@ class DiffNote < Note self.original_position.diff_refs == diff_refs end + def discussion_first_note? + self == discussion.first_note + end + private def enqueue_diff_file_creation_job @@ -78,26 +82,33 @@ class DiffNote < Note end def should_create_diff_file? - on_text? && note_diff_file.nil? && self == discussion.first_note + on_text? && note_diff_file.nil? && discussion_first_note? end def fetch_diff_file - if note_diff_file - diff = Gitlab::Git::Diff.new(note_diff_file.to_hash) - Gitlab::Diff::File.new(diff, - repository: project.repository, - diff_refs: original_position.diff_refs) - elsif created_at_diff?(noteable.diff_refs) - # We're able to use the already persisted diffs (Postgres) if we're - # presenting a "current version" of the MR discussion diff. - # So no need to make an extra Gitaly diff request for it. - # As an extra benefit, the returned `diff_file` already - # has `highlighted_diff_lines` data set from Redis on - # `Diff::FileCollection::MergeRequestDiff`. - noteable.diffs(original_position.diff_options).diff_files.first - else - original_position.diff_file(self.project.repository) - end + file = + if note_diff_file + diff = Gitlab::Git::Diff.new(note_diff_file.to_hash) + Gitlab::Diff::File.new(diff, + repository: project.repository, + diff_refs: original_position.diff_refs) + elsif created_at_diff?(noteable.diff_refs) + # We're able to use the already persisted diffs (Postgres) if we're + # presenting a "current version" of the MR discussion diff. + # So no need to make an extra Gitaly diff request for it. + # As an extra benefit, the returned `diff_file` already + # has `highlighted_diff_lines` data set from Redis on + # `Diff::FileCollection::MergeRequestDiff`. + noteable.diffs(original_position.diff_options).diff_files.first + else + original_position.diff_file(self.project.repository) + end + + # Since persisted diff files already have its content "unfolded" + # there's no need to make it pass through the unfolding process. + file&.unfold_diff_lines(position) unless note_diff_file + + file end def supported? diff --git a/app/models/issue.rb b/app/models/issue.rb index 0de5e434b02..abdb3448d4e 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -231,20 +231,6 @@ class Issue < ActiveRecord::Base def as_json(options = {}) super(options).tap do |json| - if options.key?(:issue_endpoints) && project - url_helper = Gitlab::Routing.url_helpers - - issue_reference = options[:include_full_project_path] ? to_reference(full: true) : to_reference - - json.merge!( - reference_path: issue_reference, - real_path: url_helper.project_issue_path(project, self), - issue_sidebar_endpoint: url_helper.project_issue_path(project, self, format: :json, serializer: 'sidebar'), - toggle_subscription_endpoint: url_helper.toggle_subscription_project_issue_path(project, self), - assignable_labels_endpoint: url_helper.project_labels_path(project, format: :json, include_ancestor_groups: true) - ) - end - if options.key?(:labels) json[:labels] = labels.as_json( project: project, diff --git a/app/models/label.rb b/app/models/label.rb index 43b49445765..165e4a8f3e5 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -41,8 +41,8 @@ class Label < ActiveRecord::Base scope :templates, -> { where(template: true) } scope :with_title, ->(title) { where(title: title) } scope :with_lists_and_board, -> { joins(lists: :board).merge(List.movable) } - scope :on_group_boards, ->(group_id) { with_lists_and_board.where(boards: { group_id: group_id }) } scope :on_project_boards, ->(project_id) { with_lists_and_board.where(boards: { project_id: project_id }) } + scope :on_board, ->(board_id) { with_lists_and_board.where(boards: { id: board_id }) } scope :order_name_asc, -> { reorder(title: :asc) } scope :order_name_desc, -> { reorder(title: :desc) } scope :subscribed_by, ->(user_id) { joins(:subscriptions).where(subscriptions: { user_id: user_id, subscribed: true }) } diff --git a/app/models/members_preloader.rb b/app/models/members_preloader.rb new file mode 100644 index 00000000000..33855191ca8 --- /dev/null +++ b/app/models/members_preloader.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class MembersPreloader + attr_reader :members + + def initialize(members) + @members = members + end + + def preload_all + ActiveRecord::Associations::Preloader.new.preload(members, :user) + ActiveRecord::Associations::Preloader.new.preload(members, :source) + ActiveRecord::Associations::Preloader.new.preload(members.map(&:user), :status) + ActiveRecord::Associations::Preloader.new.preload(members.map(&:user), :u2f_registrations) + end +end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 735d9fba966..df5678ec2f1 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -409,6 +409,18 @@ class MergeRequest < ActiveRecord::Base merge_request_diff&.real_size || diffs.real_size end + def modified_paths(past_merge_request_diff: nil) + diffs = if past_merge_request_diff + past_merge_request_diff + elsif compare + compare + else + self.merge_request_diff + end + + diffs.modified_paths + end + def diff_base_commit if persisted? merge_request_diff.base_commit diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index bb6ff8921df..74583af1a29 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -6,6 +6,7 @@ class MergeRequestDiff < ActiveRecord::Base include ManualInverseAssociation include IgnorableColumn include EachBatch + include Gitlab::Utils::StrongMemoize # Don't display more than 100 commits at once COMMITS_SAFE_SIZE = 100 @@ -234,6 +235,12 @@ class MergeRequestDiff < ActiveRecord::Base end # rubocop: enable CodeReuse/ServiceClass + def modified_paths + strong_memoize(:modified_paths) do + merge_request_diff_files.pluck(:new_path, :old_path).flatten.uniq + end + end + private def create_merge_request_diff_files(diffs) diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb index 7cff0e30e8d..a399982e5ec 100644 --- a/app/models/project_services/issue_tracker_service.rb +++ b/app/models/project_services/issue_tracker_service.rb @@ -12,9 +12,9 @@ class IssueTrackerService < Service # overridden patterns. See ReferenceRegexes::EXTERNAL_PATTERN def self.reference_pattern(only_long: false) if only_long - /(\b[A-Z][A-Z0-9_]+-)(?<issue>\d+)/ + /(\b[A-Z][A-Z0-9_]*-)(?<issue>\d+)/ else - /(\b[A-Z][A-Z0-9_]+-|#{Issue.reference_prefix})(?<issue>\d+)/ + /(\b[A-Z][A-Z0-9_]*-|#{Issue.reference_prefix})(?<issue>\d+)/ end end diff --git a/app/models/repository.rb b/app/models/repository.rb index 37a1dd64052..ee5579329a8 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -912,10 +912,6 @@ class Repository async_remove_remote(remote_name) if tmp_remote_name end - def fetch_remote(remote, forced: false, ssh_auth: nil, no_tags: false, prune: true) - gitlab_shell.fetch_remote(raw_repository, remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags, prune: prune) - end - def async_remove_remote(remote_name) return unless remote_name diff --git a/app/models/upload.rb b/app/models/upload.rb index 23bc9ca42fc..e01e9c6a4f0 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -11,7 +11,8 @@ class Upload < ActiveRecord::Base validates :model, presence: true validates :uploader, presence: true - scope :with_files_stored_locally, -> { where(store: [nil, ObjectStorage::Store::LOCAL]) } + scope :with_files_stored_locally, -> { where(store: ObjectStorage::Store::LOCAL) } + scope :with_files_stored_remotely, -> { where(store: ObjectStorage::Store::REMOTE) } before_save :calculate_checksum!, if: :foreground_checksummable? after_commit :schedule_checksum, if: :checksummable? @@ -46,7 +47,18 @@ class Upload < ActiveRecord::Base end def exist? - File.exist?(absolute_path) + exist = File.exist?(absolute_path) + + # Help sysadmins find missing upload files + if persisted? && !exist + if Gitlab::Sentry.enabled? + Raven.capture_message("Upload file does not exist", extra: self.attributes) + end + + Gitlab::Metrics.counter(:upload_file_does_not_exist_total, 'The number of times an upload record could not find its file').increment + end + + exist end def uploader_context @@ -57,8 +69,6 @@ class Upload < ActiveRecord::Base end def local? - return true if store.nil? - store == ObjectStorage::Store::LOCAL end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index c5e349ae913..ae7fb5f962a 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -195,7 +195,7 @@ class WikiPage update_attributes(attrs) save(page_details: title) do - wiki.create_page(title, content, format, message) + wiki.create_page(title, content, format, attrs[:message]) end end diff --git a/app/serializers/README.md b/app/serializers/README.md index 0337f88db5f..bb94745b0b5 100644 --- a/app/serializers/README.md +++ b/app/serializers/README.md @@ -180,7 +180,7 @@ def index render json: MyResourceSerializer .new(current_user: @current_user) .represent_details(@project.resources) - nd + end end ``` @@ -196,7 +196,7 @@ def index .represent_details(@project.resources), count: @project.resources.count } - nd + end end ``` diff --git a/app/serializers/issue_board_entity.rb b/app/serializers/issue_board_entity.rb new file mode 100644 index 00000000000..6a9e9638e70 --- /dev/null +++ b/app/serializers/issue_board_entity.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class IssueBoardEntity < Grape::Entity + include RequestAwareEntity + + expose :id + expose :iid + expose :title + + expose :confidential + expose :due_date + expose :project_id + expose :relative_position + expose :weight, if: -> (*) { respond_to?(:weight) } + + expose :project do |issue| + API::Entities::Project.represent issue.project, only: [:id, :path] + end + + expose :milestone, expose_nil: false do |issue| + API::Entities::Project.represent issue.milestone, only: [:id, :title] + end + + expose :assignees do |issue| + API::Entities::UserBasic.represent issue.assignees, only: [:id, :name, :username, :avatar_url] + end + + expose :labels do |issue| + LabelEntity.represent issue.labels, project: issue.project, only: [:id, :title, :description, :color, :priority, :text_color] + end + + expose :reference_path, if: -> (issue) { issue.project } do |issue, options| + options[:include_full_project_path] ? issue.to_reference(full: true) : issue.to_reference + end + + expose :real_path, if: -> (issue) { issue.project } do |issue| + project_issue_path(issue.project, issue) + end + + expose :issue_sidebar_endpoint, if: -> (issue) { issue.project } do |issue| + project_issue_path(issue.project, issue, format: :json, serializer: 'sidebar') + end + + expose :toggle_subscription_endpoint, if: -> (issue) { issue.project } do |issue| + toggle_subscription_project_issue_path(issue.project, issue) + end + + expose :assignable_labels_endpoint, if: -> (issue) { issue.project } do |issue| + project_labels_path(issue.project, format: :json, include_ancestor_groups: true) + end +end diff --git a/app/serializers/issue_serializer.rb b/app/serializers/issue_serializer.rb index 37cf5e28396..d66f0a5acb7 100644 --- a/app/serializers/issue_serializer.rb +++ b/app/serializers/issue_serializer.rb @@ -4,15 +4,17 @@ class IssueSerializer < BaseSerializer # This overrided method takes care of which entity should be used # to serialize the `issue` based on `basic` key in `opts` param. # Hence, `entity` doesn't need to be declared on the class scope. - def represent(merge_request, opts = {}) + def represent(issue, opts = {}) entity = case opts[:serializer] when 'sidebar' IssueSidebarEntity + when 'board' + IssueBoardEntity else IssueEntity end - super(merge_request, opts, entity) + super(issue, opts, entity) end end diff --git a/app/serializers/label_entity.rb b/app/serializers/label_entity.rb index 98743d62b50..5082245dda9 100644 --- a/app/serializers/label_entity.rb +++ b/app/serializers/label_entity.rb @@ -12,4 +12,8 @@ class LabelEntity < Grape::Entity expose :text_color expose :created_at expose :updated_at + + expose :priority, if: -> (*) { options.key?(:project) } do |label| + label.priority(options[:project]) + end end diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb index 7dd87034410..43a26f4264e 100644 --- a/app/services/boards/issues/move_service.rb +++ b/app/services/boards/issues/move_service.rb @@ -70,10 +70,8 @@ module Boards label_ids = if moving_to_list.movable? moving_from_list.label_id - elsif board.group_board? - ::Label.on_group_boards(parent.id).pluck(:label_id) else - ::Label.on_project_boards(parent.id).pluck(:label_id) + ::Label.on_board(board.id).pluck(:label_id) end Array(label_ids).compact diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index 53768ff2cbe..5fe48da1cd6 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -2,18 +2,18 @@ module MergeRequests class RefreshService < MergeRequests::BaseService + attr_reader :push + def execute(oldrev, newrev, ref) - push = Gitlab::Git::Push.new(@project, oldrev, newrev, ref) - return true unless push.branch_push? + @push = Gitlab::Git::Push.new(@project, oldrev, newrev, ref) + return true unless @push.branch_push? - refresh_merge_requests!(push) + refresh_merge_requests! end private - def refresh_merge_requests!(push) - @push = push - + def refresh_merge_requests! Gitlab::GitalyClient.allow_n_plus_1_calls(&method(:find_new_commits)) # Be sure to close outstanding MRs before reloading them to avoid generating an # empty diff during a manual merge diff --git a/app/services/merge_requests/reload_diffs_service.rb b/app/services/merge_requests/reload_diffs_service.rb index b47d8f3f63a..c64b2e99b52 100644 --- a/app/services/merge_requests/reload_diffs_service.rb +++ b/app/services/merge_requests/reload_diffs_service.rb @@ -29,10 +29,6 @@ module MergeRequests # rubocop: disable CodeReuse/ActiveRecord def clear_cache(new_diff) - # Executing the iteration we cache highlighted diffs for each diff file of - # MergeRequestDiff. - cacheable_collection(new_diff).write_cache - # Remove cache for all diffs on this MR. Do not use the association on the # model, as that will interfere with other actions happening when # reloading the diff. diff --git a/app/services/notes/base_service.rb b/app/services/notes/base_service.rb new file mode 100644 index 00000000000..431ff6c11c4 --- /dev/null +++ b/app/services/notes/base_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Notes + class BaseService < ::BaseService + def clear_noteable_diffs_cache(note) + noteable = note.noteable + + if note.is_a?(DiffNote) && + note.discussion_first_note? && + note.position.unfolded_diff?(project.repository) + noteable.diffs.clear_cache + end + end + end +end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 049e6c5a871..e03789e3ca9 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Notes - class CreateService < ::BaseService + class CreateService < ::Notes::BaseService def execute merge_request_diff_head_sha = params.delete(:merge_request_diff_head_sha) @@ -35,6 +35,7 @@ module Notes if !only_commands && note.save todo_service.new_note(note, current_user) + clear_noteable_diffs_cache(note) end if command_params.present? diff --git a/app/services/notes/destroy_service.rb b/app/services/notes/destroy_service.rb index 64e9accd97f..fa0c2c5c86b 100644 --- a/app/services/notes/destroy_service.rb +++ b/app/services/notes/destroy_service.rb @@ -1,11 +1,13 @@ # frozen_string_literal: true module Notes - class DestroyService < BaseService + class DestroyService < ::Notes::BaseService def execute(note) TodoService.new.destroy_target(note) do |note| note.destroy end + + clear_noteable_diffs_cache(note) end end end diff --git a/app/views/abuse_reports/new.html.haml b/app/views/abuse_reports/new.html.haml index 391115a67b5..84c3dfd8b91 100644 --- a/app/views/abuse_reports/new.html.haml +++ b/app/views/abuse_reports/new.html.haml @@ -1,6 +1,10 @@ -- page_title "Report abuse" -%h3.page-title Report abuse -%p Please use this form to report users who create spam issues, comments or behave inappropriately. +- page_title _("Report abuse to GitLab") +%h3.page-title + = _('Report abuse to GitLab') +%p + = _('Please use this form to report users to GitLab who create spam issues, comments or behave inappropriately.') +%p + = _("A member of GitLab's abuse team will review your report as soon as possible.") %hr = form_for @abuse_report, html: { class: 'js-quick-submit js-requires-input'} do |f| = form_errors(@abuse_report) @@ -16,7 +20,7 @@ .col-sm-10 = f.text_area :message, class: "form-control", rows: 2, required: true, value: sanitize(@ref_url) .form-text.text-muted - Explain the problem with this user. If appropriate, provide a link to the relevant issue or comment. + = _('Explain the problem. If appropriate, provide a link to the relevant issue or comment.') .form-actions = f.submit "Send report", class: "btn btn-success" diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml index 0904e44a658..51dcc9d0cda 100644 --- a/app/views/groups/new.html.haml +++ b/app/views/groups/new.html.haml @@ -35,7 +35,7 @@ %p = _('Who will be able to see this group?') = link_to _('View the documentation'), help_page_path("public_access/public_access"), target: '_blank' - = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group, with_label: false + = render 'shared/visibility_level', f: f, visibility_level: default_group_visibility, can_change_visibility_level: true, form_model: @group, with_label: false = render 'create_chat_team', f: f if Gitlab.config.mattermost.enabled diff --git a/app/views/projects/notes/_more_actions_dropdown.html.haml b/app/views/projects/notes/_more_actions_dropdown.html.haml index 88085c7185b..8de84f82e9f 100644 --- a/app/views/projects/notes/_more_actions_dropdown.html.haml +++ b/app/views/projects/notes/_more_actions_dropdown.html.haml @@ -11,8 +11,9 @@ - unless is_current_user %li = link_to new_abuse_report_path(user_id: note.author.id, ref_url: noteable_note_url(note)) do - Report as abuse + = _('Report abuse to GitLab') - if note_editable %li = link_to note_url(note), method: :delete, data: { confirm: 'Are you sure you want to delete this comment?' }, remote: true, class: 'js-note-delete' do - %span.text-danger Delete comment + %span.text-danger + = _('Delete comment') diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index 41afaa9ffc0..621b7922072 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -91,7 +91,7 @@ %code \(\d+.\d+\%\) covered %li pytest-cov (Python) - - %code \d+\%\s*$ + %code ^TOTAL\s+\d+\s+\d+\s+(\d+\%)$ %li phpunit --coverage-text --colors=never (PHP) - %code ^\s*Lines:\s*\d+.\d+\% diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index 2682d92fc56..b4b3f4a6b7e 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -14,6 +14,8 @@ = user_status(user) %span.cgray= user.to_reference + = render_if_exists 'shared/members/ee/sso_badge', member: member + - if user == current_user %span.badge.badge-success.prepend-left-5 It's you diff --git a/changelogs/unreleased/21480-parallel-job-keyword-mvc.yml b/changelogs/unreleased/21480-parallel-job-keyword-mvc.yml new file mode 100644 index 00000000000..7ac2410b18c --- /dev/null +++ b/changelogs/unreleased/21480-parallel-job-keyword-mvc.yml @@ -0,0 +1,5 @@ +--- +title: Implement parallel job keyword. +merge_request: 22631 +author: +type: added diff --git a/changelogs/unreleased/22717-single-letter-identifier-external-issue-tracker.yml b/changelogs/unreleased/22717-single-letter-identifier-external-issue-tracker.yml new file mode 100644 index 00000000000..3f7a0d9204e --- /dev/null +++ b/changelogs/unreleased/22717-single-letter-identifier-external-issue-tracker.yml @@ -0,0 +1,5 @@ +---
+title: "Allowing issues with single letter identifiers to be linked to external issue tracker (f.ex T-123)"
+merge_request: 22717
+author: DÃdac RodrÃguez Arbonès
+type: changed
\ No newline at end of file diff --git a/changelogs/unreleased/52771-ldap-users-can-t-choose-private-or-internal-when-creating-a-new-group.yml b/changelogs/unreleased/52771-ldap-users-can-t-choose-private-or-internal-when-creating-a-new-group.yml new file mode 100644 index 00000000000..a05ef75b6a6 --- /dev/null +++ b/changelogs/unreleased/52771-ldap-users-can-t-choose-private-or-internal-when-creating-a-new-group.yml @@ -0,0 +1,5 @@ +--- +title: Fix bug stopping non-admin users from changing visibility level on group creation +merge_request: 22468 +author: +type: fixed diff --git a/changelogs/unreleased/53535-sticky-archived.yml b/changelogs/unreleased/53535-sticky-archived.yml new file mode 100644 index 00000000000..8d452d84871 --- /dev/null +++ b/changelogs/unreleased/53535-sticky-archived.yml @@ -0,0 +1,5 @@ +--- +title: Renders warning info when job is archieved +merge_request: +author: +type: added diff --git a/changelogs/unreleased/ccr-51052_keep_labels_on_issue.yml b/changelogs/unreleased/ccr-51052_keep_labels_on_issue.yml new file mode 100644 index 00000000000..7ef857d38ed --- /dev/null +++ b/changelogs/unreleased/ccr-51052_keep_labels_on_issue.yml @@ -0,0 +1,5 @@ +--- +title: Fixed label removal from issue +merge_request: 22762 +author: +type: fixed diff --git a/changelogs/unreleased/fj-50890-fix-commit-message-wiki-new-page.yml b/changelogs/unreleased/fj-50890-fix-commit-message-wiki-new-page.yml new file mode 100644 index 00000000000..5add6d727ac --- /dev/null +++ b/changelogs/unreleased/fj-50890-fix-commit-message-wiki-new-page.yml @@ -0,0 +1,5 @@ +--- +title: Fix bug with wiki page create message +merge_request: 22849 +author: +type: fixed diff --git a/changelogs/unreleased/fj-bump-gitaly-0-129-0.yml b/changelogs/unreleased/fj-bump-gitaly-0-129-0.yml new file mode 100644 index 00000000000..9d44e46c0ed --- /dev/null +++ b/changelogs/unreleased/fj-bump-gitaly-0-129-0.yml @@ -0,0 +1,5 @@ +--- +title: Bump Gitaly to 0.129.0 +merge_request: 22868 +author: +type: added diff --git a/changelogs/unreleased/gl-ui-loading-icon.yml b/changelogs/unreleased/gl-ui-loading-icon.yml new file mode 100644 index 00000000000..5540fc7d7ea --- /dev/null +++ b/changelogs/unreleased/gl-ui-loading-icon.yml @@ -0,0 +1,5 @@ +--- +title: Remove gitlab-ui's loading icon from global +merge_request: +author: +type: performance diff --git a/changelogs/unreleased/gt-use-merge-request-prefix-in-event-feed-title.yml b/changelogs/unreleased/gt-use-merge-request-prefix-in-event-feed-title.yml new file mode 100644 index 00000000000..51af2807a03 --- /dev/null +++ b/changelogs/unreleased/gt-use-merge-request-prefix-in-event-feed-title.yml @@ -0,0 +1,5 @@ +--- +title: Use merge request prefix symbol in event feed title +merge_request: 22449 +author: George Tsiolis +type: changed diff --git a/changelogs/unreleased/mr-image-commenting.yml b/changelogs/unreleased/mr-image-commenting.yml new file mode 100644 index 00000000000..3cc3becc795 --- /dev/null +++ b/changelogs/unreleased/mr-image-commenting.yml @@ -0,0 +1,5 @@ +--- +title: Reimplemented image commenting in merge request diffs +merge_request: +author: +type: added diff --git a/changelogs/unreleased/osw-comment-on-any-line-on-diffs.yml b/changelogs/unreleased/osw-comment-on-any-line-on-diffs.yml new file mode 100644 index 00000000000..7b48a94a993 --- /dev/null +++ b/changelogs/unreleased/osw-comment-on-any-line-on-diffs.yml @@ -0,0 +1,5 @@ +--- +title: Allow commenting on any diff line in Merge Requests +merge_request: 22398 +author: +type: added diff --git a/changelogs/unreleased/rails5-mysql-milliseconds-deployment-spec.yml b/changelogs/unreleased/rails5-mysql-milliseconds-deployment-spec.yml new file mode 100644 index 00000000000..8c71ecebfdb --- /dev/null +++ b/changelogs/unreleased/rails5-mysql-milliseconds-deployment-spec.yml @@ -0,0 +1,5 @@ +--- +title: 'Rails5: fix mysql milliseconds issue in deployment model specs' +merge_request: 22850 +author: Jasper Maes +type: other diff --git a/changelogs/unreleased/sh-fix-issue-52649.yml b/changelogs/unreleased/sh-fix-issue-52649.yml new file mode 100644 index 00000000000..34b7f74a345 --- /dev/null +++ b/changelogs/unreleased/sh-fix-issue-52649.yml @@ -0,0 +1,5 @@ +--- +title: Fix statement timeouts in RemoveRestrictedTodos migration +merge_request: 22795 +author: +type: other diff --git a/changelogs/unreleased/tc-index-uploads-file-store.yml b/changelogs/unreleased/tc-index-uploads-file-store.yml new file mode 100644 index 00000000000..fa3b3164e38 --- /dev/null +++ b/changelogs/unreleased/tc-index-uploads-file-store.yml @@ -0,0 +1,5 @@ +--- +title: Enhance performance of counting local Uploads +merge_request: 22522 +author: +type: performance diff --git a/changelogs/unreleased/winh-delayed-jobs-dynamic-timer.yml b/changelogs/unreleased/winh-delayed-jobs-dynamic-timer.yml new file mode 100644 index 00000000000..fbedd2796b2 --- /dev/null +++ b/changelogs/unreleased/winh-delayed-jobs-dynamic-timer.yml @@ -0,0 +1,5 @@ +--- +title: Add dynamic timer to delayed jobs +merge_request: 22382 +author: +type: changed diff --git a/db/migrate/20181005125926_add_index_to_uploads_store.rb b/db/migrate/20181005125926_add_index_to_uploads_store.rb new file mode 100644 index 00000000000..d32ca05e980 --- /dev/null +++ b/db/migrate/20181005125926_add_index_to_uploads_store.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddIndexToUploadsStore < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :uploads, :store + end + + def down + remove_concurrent_index :uploads, :store + end +end diff --git a/db/post_migrate/20181105201455_steal_fill_store_upload.rb b/db/post_migrate/20181105201455_steal_fill_store_upload.rb new file mode 100644 index 00000000000..982001fedbe --- /dev/null +++ b/db/post_migrate/20181105201455_steal_fill_store_upload.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class StealFillStoreUpload < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + BATCH_SIZE = 10_000 + + disable_ddl_transaction! + + class Upload < ActiveRecord::Base + include EachBatch + + self.table_name = 'uploads' + self.inheritance_column = :_type_disabled # Disable STI + end + + def up + Gitlab::BackgroundMigration.steal('FillStoreUpload') + + Upload.where(store: nil).each_batch(of: BATCH_SIZE) do |batch| + range = batch.pluck('MIN(id)', 'MAX(id)').first + + Gitlab::BackgroundMigration::FillStoreUpload.new.perform(*range) + end + end + + def down + # noop + end +end diff --git a/db/post_migrate/20181107054254_remove_restricted_todos_again.rb b/db/post_migrate/20181107054254_remove_restricted_todos_again.rb new file mode 100644 index 00000000000..644e0074c46 --- /dev/null +++ b/db/post_migrate/20181107054254_remove_restricted_todos_again.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# rescheduling of the revised RemoveRestrictedTodosWithCte background migration +class RemoveRestrictedTodosAgain < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + disable_ddl_transaction! + + MIGRATION = 'RemoveRestrictedTodos'.freeze + BATCH_SIZE = 1000 + DELAY_INTERVAL = 5.minutes.to_i + + class Project < ActiveRecord::Base + include EachBatch + + self.table_name = 'projects' + end + + def up + Project.where('EXISTS (SELECT 1 FROM todos WHERE todos.project_id = projects.id)') + .each_batch(of: BATCH_SIZE) do |batch, index| + range = batch.pluck('MIN(id)', 'MAX(id)').first + + BackgroundMigrationWorker.perform_in(index * DELAY_INTERVAL, MIGRATION, range) + end + end + + def down + # nothing to do + end +end diff --git a/db/schema.rb b/db/schema.rb index b6b89c703fb..3ccf9056c93 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: 20181101144347) do +ActiveRecord::Schema.define(version: 20181107054254) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -2155,6 +2155,7 @@ ActiveRecord::Schema.define(version: 20181101144347) do add_index "uploads", ["checksum"], name: "index_uploads_on_checksum", using: :btree add_index "uploads", ["model_id", "model_type"], name: "index_uploads_on_model_id_and_model_type", using: :btree + add_index "uploads", ["store"], name: "index_uploads_on_store", using: :btree add_index "uploads", ["uploader", "path"], name: "index_uploads_on_uploader_and_path", using: :btree create_table "user_agent_details", force: :cascade do |t| diff --git a/doc/administration/monitoring/prometheus/gitlab_metrics.md b/doc/administration/monitoring/prometheus/gitlab_metrics.md index c6fd7ef7360..5700f640e4c 100644 --- a/doc/administration/monitoring/prometheus/gitlab_metrics.md +++ b/doc/administration/monitoring/prometheus/gitlab_metrics.md @@ -45,6 +45,7 @@ The following metrics are available: | redis_ping_success | Gauge | 9.4 | Whether or not the last redis ping succeeded | | redis_ping_latency_seconds | Gauge | 9.4 | Round trip time of the redis ping | | user_session_logins_total | Counter | 9.4 | Counter of how many users have logged in | +| upload_file_does_not_exist | Counter | 10.7 | Number of times an upload record could not find its file | | failed_login_captcha_total | Gauge | 11.0 | Counter of failed CAPTCHA attempts during login | | successful_login_captcha_total | Gauge | 11.0 | Counter of successful CAPTCHA attempts during login | diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index 2d23bf6d2fd..bdbcf8c9435 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -65,6 +65,8 @@ future GitLab releases.** | **CI_JOB_NAME** | 9.0 | 0.5 | The name of the job as defined in `.gitlab-ci.yml` | | **CI_JOB_STAGE** | 9.0 | 0.5 | The name of the stage as defined in `.gitlab-ci.yml` | | **CI_JOB_TOKEN** | 9.0 | 1.2 | Token used for authenticating with the [GitLab Container Registry][registry] and downloading [dependent repositories][dependent-repositories] | +| **CI_NODE_INDEX** | 11.5 | all | Index of the job in the job set. If the job is not parallelized, this variable is not set. | +| **CI_NODE_TOTAL** | 11.5 | all | Total number of instances of this job running in parallel. If the job is not parallelized, this variable is set to `1`. | | **CI_JOB_URL** | 11.1 | 0.5 | Job details URL | | **CI_REPOSITORY_URL** | 9.0 | all | The URL to clone the Git repository | | **CI_RUNNER_DESCRIPTION** | 8.10 | 0.5 | The description of the runner as saved in GitLab | diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 981aa101dd3..b3a55e48f4e 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -75,6 +75,7 @@ A job is defined by a list of parameters that define the job behavior. | environment | no | Defines a name of environment to which deployment is done by this job | | coverage | no | Define code coverage settings for a given job | | retry | no | Define how many times a job can be auto-retried in case of a failure | +| parallel | no | Defines how many instances of a job should be run in parallel | ### `extends` @@ -1451,6 +1452,26 @@ test: retry: 2 ``` +## `parallel` + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/22631) in GitLab 11.5. + +`parallel` allows you to configure how many instances of a job to run in +parallel. This value has to be greater than or equal to two (2). + +This creates N instances of the same job that run in parallel. They're named +sequentially from `job_name 1/N` to `job_name N/N`. + +For every job, `CI_NODE_INDEX` and `CI_NODE_TOTAL` [environment variables](../variables/README.html#predefined-variables-environment-variables) are set. + +A simple example: + +```yaml +test: + script: rspec + parallel: 5 +``` + ## `include` > Introduced in [GitLab Edition Premium][ee] 10.5. @@ -2034,4 +2055,4 @@ CI with various languages. [schedules]: ../../user/project/pipelines/schedules.md [variables-expressions]: ../variables/README.md#variables-expressions [ee]: https://about.gitlab.com/gitlab-ee/ -[gitlab-versions]: https://about.gitlab.com/products/
\ No newline at end of file +[gitlab-versions]: https://about.gitlab.com/products/ diff --git a/doc/development/contributing/issue_workflow.md b/doc/development/contributing/issue_workflow.md index 4661d11b29e..233dc83f95b 100644 --- a/doc/development/contributing/issue_workflow.md +++ b/doc/development/contributing/issue_workflow.md @@ -8,7 +8,7 @@ Most issues will have labels for at least one of the following: - Type: ~"feature proposal", ~bug, ~customer, etc. - Subject: ~wiki, ~"container registry", ~ldap, ~api, ~frontend, etc. -- Team: ~"CI/CD", ~Plan, ~Manage, ~Quality, etc. +- Team: ~Plan, ~Manage, ~Quality, etc. - Stage: ~"devops:plan", ~"devops:create", etc. - Release Scoping: ~Deliverable, ~Stretch, ~"Next Patch Release" - Priority: ~P1, ~P2, ~P3, ~P4 @@ -61,7 +61,6 @@ people. The current team labels are: - ~Configure -- ~"CI/CD" - ~Create - ~Distribution - ~Documentation @@ -74,6 +73,7 @@ The current team labels are: - ~Release - ~Secure - ~UX +- ~Verify The descriptions on the [labels page][labels-page] explain what falls under the responsibility of each team. diff --git a/doc/install/README.md b/doc/install/README.md index 27df03c6ac6..92116305775 100644 --- a/doc/install/README.md +++ b/doc/install/README.md @@ -34,11 +34,11 @@ the hardware requirements. - [Install GitLab on Google Cloud Platform](google_cloud_platform/index.md) - [Install GitLab on Google Kubernetes Engine (GKE)](https://about.gitlab.com/2017/01/23/video-tutorial-idea-to-production-on-google-container-engine-gke/): video tutorial on the full process of installing GitLab on Google Kubernetes Engine (GKE), pushing an application to GitLab, building the app with GitLab CI/CD, and deploying to production. -- [Install on AWS](https://about.gitlab.com/aws/) -- _Testing only!_ [DigitalOcean and Docker Machine](digitaloceandocker.md) - - Quickly test any version of GitLab on DigitalOcean using Docker Machine. +- [Install on AWS](aws/index.md): Install GitLab on AWS using the community AMIs that GitLab provides. - [Getting started with GitLab and DigitalOcean](https://about.gitlab.com/2016/04/27/getting-started-with-gitlab-and-digitalocean/): requirements, installation process, updates. - [Demo: Cloud Native Development with GitLab](https://about.gitlab.com/2017/04/18/cloud-native-demo/): video demonstration on how to install GitLab on Kubernetes, build a project, create Review Apps, store Docker images in Container Registry, deploy to production on Kubernetes, and monitor with Prometheus. +- _Testing only!_ [DigitalOcean and Docker Machine](digitaloceandocker.md) - + Quickly test any version of GitLab on DigitalOcean using Docker Machine. ## Database diff --git a/doc/install/aws/img/add_tags.png b/doc/install/aws/img/add_tags.png Binary files differnew file mode 100644 index 00000000000..3572cd5daa1 --- /dev/null +++ b/doc/install/aws/img/add_tags.png diff --git a/doc/install/aws/img/associate_subnet_gateway.png b/doc/install/aws/img/associate_subnet_gateway.png Binary files differnew file mode 100644 index 00000000000..1edca974fca --- /dev/null +++ b/doc/install/aws/img/associate_subnet_gateway.png diff --git a/doc/install/aws/img/associate_subnet_gateway_2.png b/doc/install/aws/img/associate_subnet_gateway_2.png Binary files differnew file mode 100644 index 00000000000..76e101d32a3 --- /dev/null +++ b/doc/install/aws/img/associate_subnet_gateway_2.png diff --git a/doc/install/aws/img/aws_diagram.png b/doc/install/aws/img/aws_diagram.png Binary files differnew file mode 100644 index 00000000000..bcd5c69bbeb --- /dev/null +++ b/doc/install/aws/img/aws_diagram.png diff --git a/doc/install/aws/img/choose_ami.png b/doc/install/aws/img/choose_ami.png Binary files differnew file mode 100644 index 00000000000..034ac92691d --- /dev/null +++ b/doc/install/aws/img/choose_ami.png diff --git a/doc/install/aws/img/create_gateway.png b/doc/install/aws/img/create_gateway.png Binary files differnew file mode 100644 index 00000000000..9408520e050 --- /dev/null +++ b/doc/install/aws/img/create_gateway.png diff --git a/doc/install/aws/img/create_route_table.png b/doc/install/aws/img/create_route_table.png Binary files differnew file mode 100644 index 00000000000..ea72c57257e --- /dev/null +++ b/doc/install/aws/img/create_route_table.png diff --git a/doc/install/aws/img/create_security_group.png b/doc/install/aws/img/create_security_group.png Binary files differnew file mode 100644 index 00000000000..9a0dfccfe37 --- /dev/null +++ b/doc/install/aws/img/create_security_group.png diff --git a/doc/install/aws/img/create_subnet.png b/doc/install/aws/img/create_subnet.png Binary files differnew file mode 100644 index 00000000000..26f5ab1b625 --- /dev/null +++ b/doc/install/aws/img/create_subnet.png diff --git a/doc/install/aws/img/create_vpc.png b/doc/install/aws/img/create_vpc.png Binary files differnew file mode 100644 index 00000000000..a678f7013fd --- /dev/null +++ b/doc/install/aws/img/create_vpc.png diff --git a/doc/install/aws/img/ec_az.png b/doc/install/aws/img/ec_az.png Binary files differnew file mode 100644 index 00000000000..22a8291c593 --- /dev/null +++ b/doc/install/aws/img/ec_az.png diff --git a/doc/install/aws/img/ec_subnet.png b/doc/install/aws/img/ec_subnet.png Binary files differnew file mode 100644 index 00000000000..c44fb4485e3 --- /dev/null +++ b/doc/install/aws/img/ec_subnet.png diff --git a/doc/install/aws/img/policies.png b/doc/install/aws/img/policies.png Binary files differnew file mode 100644 index 00000000000..e99497a52a2 --- /dev/null +++ b/doc/install/aws/img/policies.png diff --git a/doc/install/aws/img/rds_subnet_group.png b/doc/install/aws/img/rds_subnet_group.png Binary files differnew file mode 100644 index 00000000000..7c6157e38e0 --- /dev/null +++ b/doc/install/aws/img/rds_subnet_group.png diff --git a/doc/install/aws/index.md b/doc/install/aws/index.md new file mode 100644 index 00000000000..53fe1a6b25b --- /dev/null +++ b/doc/install/aws/index.md @@ -0,0 +1,655 @@ +# Installing GitLab on Amazon Web Services (AWS) + +To install GitLab on AWS, you can use the Amazon Machine Images (AMIs) that GitLab +provides with [each release](https://about.gitlab.com/releases/). + +This page offers a walkthrough of a common HA (Highly Available) configuration +for GitLab on AWS. You should customize it to accommodate your needs. + +## Introduction + +GitLab on AWS can leverage many of the services that are already +configurable with GitLab High Availability (HA). These services offer a great deal of +flexibility and can be adapted to the needs of most companies, while enabling the +automation of both vertical and horizontal scaling. + +In this guide, we'll go through a basic HA setup where we'll start by +configuring our Virtual Private Cloud and subnets to later integrate +services such as RDS for our database server and ElastiCache as a Redis +cluster to finally manage them within an auto scaling group with custom +scaling policies. + +## Requirements + +In addition to having a basic familiarity with [AWS](https://docs.aws.amazon.com/) and [Amazon EC2](https://docs.aws.amazon.com/ec2/), you will need: + +- [An AWS account](https://console.aws.amazon.com/console/home) +- [To create or upload an SSH key](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html) + to connect to the instance via SSH +- A domain name for the GitLab instance + +## Architecture + +Below is a diagram of the recommended architecture. + +![AWS architecture diagram](img/aws_diagram.png) + +## AWS costs + +Here's a list of the AWS services we will use, with links to pricing information: + +- **EC2**: GitLab will deployed on shared hardware which means + [on-demand pricing](https://aws.amazon.com/ec2/pricing/on-demand) + will apply. If you want to run it on a dedicated or reserved instance, + consult the [EC2 pricing page](https://aws.amazon.com/ec2/pricing/) for more + information on the cost. +- **EBS**: We will also use an EBS volume to store the Git data. See the + [Amazon EBS pricing](https://aws.amazon.com/ebs/pricing/). +- **S3**: We will use S3 to store backups, artifacts, LFS objects, etc. See the + [Amazon S3 pricing](https://aws.amazon.com/s3/pricing/). +- **ALB**: An Application Load Balancer will be used to route requests to the + GitLab instance. See the [Amazon ELB pricing](https://aws.amazon.com/elasticloadbalancing/pricing/). +- **RDS**: An Amazon Relational Database Service using PostgreSQL will be used + to provide a High Availability database configuration. See the + [Amazon RDS pricing](https://aws.amazon.com/rds/postgresql/pricing/). +- **ElastiCache**: An in-memory cache environment will be used to provide a + High Availability Redis configuration. See the + [Amazon ElastiCache pricing](https://aws.amazon.com/elasticache/pricing/). + +## Creating an IAM EC2 instance role and profile +To minimize the permissions of the user, we'll create a new [IAM](https://docs.aws.amazon.com/IAM/latest/UserGuide/introduction.html) +role with limited access: + +1. Navigate to the IAM dashboard https://console.aws.amazon.com/iam/home and + click **Create role**. +1. Create a new role by selecting **AWS service > EC2**, then click + **Next: Permissions**. +1. Choose **AmazonEC2FullAccess** and **AmazonS3FullAccess**, then click **Next: Review**. +1. Give the role the name `GitLabAdmin` and click **Create role**. + +## Configuring the network + +We'll start by creating a VPC for our GitLab cloud infrastructure, then +we can create subnets to have public and private instances in at least +two [Availability Zones (AZs)](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html). Public subnets will require a Route Table keep and an associated +Internet Gateway. + +### Creating the Virtual Private Cloud (VPC) + +We'll now create a VPC, a virtual networking environment that you'll control: + +1. Navigate to https://console.aws.amazon.com/vpc/home. +1. Select **Your VPCs** from the left menu and then click **Create VPC**. + At the "Name tag" enter `gitlab-vpc` and at the "IPv4 CIDR block" enter + `10.0.0.0/16`. If you don't require dedicated hardware, you can leave + "Tenancy" as default. Click **Yes, Create** when ready. + + ![Create VPC](img/create_vpc.png) + +### Subnets + +Now, let's create some subnets in different Availability Zones. Make sure +that each subnet is associated the the VPC we just created and +that CIDR blocks don't overlap. This will also +allow us to enable multi AZ for redundancy. + +We will create private and public subnets to match load balancers and +RDS instances as well: + +1. Select **Subnets** from the left menu. +1. Click **Create subnet**. Give it a descriptive name tag based on the IP, + for example `gitlab-public-10.0.0.0`, select the VPC we created previously, + and at the IPv4 CIDR block let's give it a 24 subnet `10.0.0.0/24`: + + ![Create subnet](img/create_subnet.png) + +1. Follow the same steps to create all subnets: + + | Name tag | Type |Availability Zone | CIDR block | + | -------- | ---- | ---------------- | ---------- | + | gitlab-public-10.0.0.0 | public | us-west-2a | 10.0.0.0 | + | gitlab-private-10.0.1.0 | private | us-west-2a | 10.0.1.0 | + | gitlab-public-10.0.2.0 | public | us-west-2b | 10.0.2.0 | + | gitlab-private-10.0.3.0 | private | us-west-2b | 10.0.3.0 | + +### Route Table + +Up to now all our subnets are private. We need to create a Route Table +to associate an Internet Gateway. On the same VPC dashboard: + +1. Select **Route Tables** from the left menu. +1. Click **Create Route Table**. +1. At the "Name tag" enter `gitlab-public` and choose `gitlab-vpc` under "VPC". +1. Hit **Yes, Create**. + +### Internet Gateway + +Now, still on the same dashboard, go to Internet Gateways and +create a new one: + +1. Select **Internet Gateways** from the left menu. +1. Click **Create internet gateway**, give it the name `gitlab-gateway` and + click **Create**. +1. Select it from the table, and then under the **Actions** dropdown choose + "Attach to VPC". + + ![Create gateway](img/create_gateway.png) + +1. Choose `gitlab-vpc` from the list and hit **Attach**. + +### Configuring subnets + +We now need to add a new target which will be our Internet Gateway and have +it receive traffic from any destination. + +1. Select **Route Tables** from the left menu and select the `gitlab-public` + route to show the options at the bottom. +1. Select the **Routes** tab, hit **Edit > Add another route** and set `0.0.0.0/0` + as destination. In the target, select the `gitlab-gateway` we created previously. + Hit **Save** once done. + + ![Associate subnet with gateway](img/associate_subnet_gateway.png) + +Next, we must associate the **public** subnets to the route table: + +1. Select the **Subnet Associations** tab and hit **Edit**. +1. Check only the public subnet and hit **Save**. + + ![Associate subnet with gateway](img/associate_subnet_gateway_2.png) + +--- + +Now that we're done with the network, let's create a security group. + +## Creating a security group + +The security group is basically the firewall: + +1. Select **Security Groups** from the left menu. +1. Click **Create Security Group** and fill in the details. Give it a name, + add a description, and choose the VPC we created previously +1. Select the security group from the list and at the the bottom select the + Inbound Rules tab. You will need to open the SSH, HTTP, and HTTPS ports. Set + the source to `0.0.0.0/0`. + + ![Create security group](img/create_security_group.png) + + TIP: **Tip:** + Based on best practices, you should allow SSH traffic from only a known + host or CIDR block. In that case, change the SSH source to be custom and give + it the IP you want to SSH from. + +1. When done, click **Save**. + +## PostgreSQL with RDS + +For our database server we will use Amazon RDS which offers Multi AZ +for redundancy. Let's start by creating a subnet group and then we'll +create the actual RDS instance. + +### RDS Subnet Group + +1. Navigate to the RDS dashboard and select **Subnet Groups** from the left menu. +1. Give it a name (`gitlab-rds-group`), a description, and choose the VPC from + the VPC dropdown. +1. Click "Add all the subnets related to this VPC" and + remove the public ones, we only want the **private subnets**. + In the end, you should see `10.0.1.0/24` and `10.0.3.0/24` (as + we defined them in the [subnets section](#subnets)). + Click **Create** when ready. + + ![RDS Subnet Group](img/rds_subnet_group.png) + +### Creating the database + +Now, it's time to create the database: + +1. Select **Instances** from the left menu and click **Create database**. +1. Select PostgreSQL and click **Next**. +1. Since this is a production server, let's choose "Production". Click **Next**. +1. Let's see the instance specifications: + 1. Leave the license model as is (`postgresql-license`). + 1. For the version, select the latest of the 9.6 series (check the + [database requirements](../../install/requirements.md#postgresql-requirements)) + if there are any updates on this). + 1. For the size, let's select a `t2.medium` instance. + 1. Multi-AZ-deployment is recommended as redundancy, so choose "Create + replica in different zone". Read more at + [High Availability (Multi-AZ)](http://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.MultiAZ.html). + 1. A Provisioned IOPS (SSD) storage type is best suited for HA (though you can + choose a General Purpose (SSD) to reduce the costs). Read more about it at + [Storage for Amazon RDS](http://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_Storage.html). + +1. The rest of the settings on this page request a DB isntance identifier, username + and a master password. We've chosen to use `gitlab-db-ha`, `gitlab` and a + very secure password respectively. Keep these in hand for later. +1. Click **Next** to proceed to the advanced settings. +1. Make sure to choose our gitlab VPC, our subnet group, set public accessibility to + **No**, and to leave it to create a new security group. The only additional + change which will be helpful is the database name for which we can use + `gitlabhq_production`. At the very bottom, there's an option to enable + auto updates to minor versions. You may want to turn it off. +1. When done, click **Create database**. + +### Installing the `pg_trgm` extension for PostgreSQL + +Once the database is created, connect to your new RDS instance to verify access +and to install a required extension. + +You can find the host or endpoint by selecting the instance you just created and +after the details drop down you'll find it labeled as 'Endpoint'. Do not to +include the colon and port number: + +```sh +sudo /opt/gitlab/embedded/bin/psql -U gitlab -h <rds-endpoint> -d gitlabhq_production +``` + +At the psql prompt create the extension and then quit the session: + +```sh +psql (9.4.7) +Type "help" for help. + +gitlab=# CREATE EXTENSION pg_trgm; +gitlab=# \q +``` + +--- + +Now that the database is created, let's move on setting up Redis with ElasticCache. + +## Redis with ElastiCache + +ElastiCache is an in-memory hosted caching solution. Redis maintains its own +persistence and is used for certain types of the GitLab application. + +To set up Redis: + +1. Navigate to the ElastiCache dashboard from your AWS console. +1. Go to **Subnet Groups** in the left menu, and create a new subnet group. + Make sure to select our VPC and its [private subnets](#subnets). Click + **Create** when ready. + + ![ElastiCache subnet](img/ec_subnet.png) + +1. Select **Redis** on the left menu and click **Create** to create a new + Redis cluster. Depending on your load, you can choose whether to enable + cluster mode or not. Even without cluster mode on, you still get the + chance to deploy Redis in multi availability zones. In this guide, we chose + not to enable it. +1. In the settings section: + 1. Give the cluster a name (`gitlab-redis`) and a description. + 1. For the version, select the latest of `3.2` series (e.g., `3.2.10`). + 1. Select the node type and the number of replicas. +1. In the advanced settings section: + 1. Select the multi-AZ auto-failover option. + 1. Select the subnet group we created previously. + 1. Manually select the preferred availability zones, and under "Replica 2" + choose a different zone than the other two. + + ![Redis availability zones](img/ec_az.png) + +1. In the security settings, edit the security groups and choose the + `gitlab-security-group` we had previously created. +1. Leave the rest of the settings to their default values or edit to your liking. +1. When done, click **Create**. + +## RDS and Redis Security Group + +Let's navigate to our EC2 security groups and add a small change for our EC2 +instances to be able to connect to RDS. First, copy the security group name we +defined, namely `gitlab-security-group`, select the RDS security group and edit the +inbound rules. Choose the rule type to be PostgreSQL and paste the name under +source. + +Similar to the above, jump to the `gitlab-security-group` group +and add a custom TCP rule for port `6379` accessible within itself. + +## Load Balancer + +On the EC2 dashboard, look for Load Balancer on the left column: + +1. Click the **Create Load Balancer** button. + 1. Choose the Application Load Balancer. + 1. Give it a name (`gitlab-loadbalancer`) and set the scheme to "internet-facing". + 1. In the "Listeners" section, make sure it has HTTP and HTTPS. + 1. In the "Availability Zones" section, select the `gitlab-vpc` we have created + and associate the **public subnets**. +1. Click **Configure Security Settings** to go to the next section to + select the TLS certificate. When done, go to the next step. +1. In the "Security Groups" section, create a new one by giving it a name + (`gitlab-loadbalancer-sec-group`) and allow both HTTP ad HTTPS traffic + from anywhere (`0.0.0.0/0, ::/0`). +1. In the next step, configure the routing and select an existing target group + (`gitlab-public`). The Load Balancer Health will allow us to indicate where to + ping and what makes up a healthy or unhealthy instance. +1. Leave the "Register Targets" section as is, and finally review the settings + and create the ELB. + +After the Load Balancer is up and running, you can revisit your Security +Groups to refine the access only through the ELB and any other requirement +you might have. + +## Deploying GitLab inside an auto scaling group + +We'll use AWS's wizard to deploy GitLab and then SSH into the instance to +configure the PostgreSQL and Redis connections. + +The Auto Scaling Group option is available through the EC2 dashboard on the left +sidebar. + +1. Click **Create Auto Scaling group**. +1. Create a new launch configuration. + +### Choose the AMI + +Choose the AMI: + +1. Go to the Community AMIs and search for `GitLab EE <version>` + where `<version>` the latest version as seen on the + [releases page](https://about.gitlab.com/releases/). + + ![Choose AMI](img/choose_ami.png) + +### Choose an instance type + +You should choose an instance type based on your workload. Consult +[the hardware requirements](../requirements.md#hardware-requirements) to choose +one that fits your needs (at least `c4.xlarge`, which is enough to accommodate 100 users): + +1. Choose the your instance type. +1. Click **Next: Configure Instance Details**. + +### Configure details + +In this step we'll configure some details: + +1. Enter a name (`gitlab-autoscaling`). +1. Select the IAM role we created. +1. Optionally, enable CloudWatch and the EBS-optimized instance settings. +1. In the "Advanced Details" section, set the IP address type to + "Do not assign a public IP address to any instances." +1. Click **Next: Add Storage**. + +### Add storage + +The root volume is 8GB by default and should be enough given that we won't store +any data there. Let's create a new EBS volume that will host the Git data. Its +size depends on your needs and you can always migrate to a bigger volume later. +You will be able to [set up that volume](#setting-up-the-ebs-volume) +after the instance is created. + +### Configure security group + +As a last step, configure the security group: + +1. Select the existing load balancer security group we have [created](#load-balancer). +1. Select **Review**. + +### Review and launch + +Now is a good time to review all the previous settings. When ready, click +**Create launch configuration** and select the SSH key pair with which you will +connect to the instance. + +### Create Auto Scaling Group + +We are now able to start creating our Auto Scaling Group: + +1. Give it a group name. +1. Set the group size to 2 as we want to always start with two instances. +1. Assign it our network VPC and add the **private subnets**. +1. In the "Advanced Details" section, choose to receive traffic from ELBs + and select our ELB. +1. Choose the ELB health check. +1. Click **Next: Configure scaling policies**. + +This is the really great part of Auto Scaling; we get to choose when AWS +launches new instances and when it removes them. For this group we'll +scale between 2 and 4 instances where one instance will be added if CPU +utilization is greater than 60% and one instance is removed if it falls +to less than 45%. + +![Auto scaling group policies](img/policies.png) + +Finally, configure notifications and tags as you see fit, and create the +auto scaling group. + +You'll notice that after we save the configuration, AWS starts launching our two +instances in different AZs and without a public IP which is exactly what +we intended. + +## After deployment + +After a few minutes, the instances should be up and accessible via the internet. +Let's connect to the primary and configure some things before logging in. + +### Configuring GitLab to connect with postgres and Redis + +While connected to your server, let's connect to the RDS instance to verify +access and to install a required extension: + +```sh +sudo /opt/gitlab/embedded/bin/psql -U gitlab -h <rds-endpoint> -d gitlabhq_production +``` + +Edit the `gitlab.rb` file at `/etc/gitlab/gitlab.rb` +find the `external_url 'http://gitlab.example.com'` option and change it +to the domain you will be using or the public IP address of the current +instance to test the configuration. + +For a more detailed description about configuring GitLab, see [Configuring GitLab for HA](../../administration/high_availability/gitlab.md) + +Now look for the GitLab database settings and uncomment as necessary. In +our current case we'll specify the database adapter, encoding, host, name, +username, and password: + +```ruby +# Disable the built-in Postgres +postgresql['enable'] = false + +# Fill in the connection details +gitlab_rails['db_adapter'] = "postgresql" +gitlab_rails['db_encoding'] = "unicode" +gitlab_rails['db_database'] = "gitlabhq_production" +gitlab_rails['db_username'] = "gitlab" +gitlab_rails['db_password'] = "mypassword" +gitlab_rails['db_host'] = "<rds-endpoint>" +``` + +Next, we need to configure the Redis section by adding the host and +uncommenting the port: + +```ruby +# Disable the built-in Redis +redis['enable'] = false + +# Fill in the connection details +gitlab_rails['redis_host'] = "<redis-endpoint>" +gitlab_rails['redis_port'] = 6379 +``` + +Finally, reconfigure GitLab for the change to take effect: + + +```sh +sudo gitlab-ctl reconfigure +``` + +You might also find it useful to run a check and a service status to make sure +everything has been setup correctly: + +```sh +sudo gitlab-rake gitlab:check +sudo gitlab-ctl status +``` + +If everything looks good, you should be able to reach GitLab in your browser. + +### Setting up the EBS volume + +The EBS volume will host the Git repositories data: + +1. First, format the `/dev/xvdb` volume and then mount it under the directory + where the data will be stored. For example, `/mnt/gitlab-data/`. +1. Tell GitLab to store its data in the new directory by editing + `/etc/gitlab/gitlab.rb` with your editor: + + ```ruby + git_data_dirs({ + "default" => { "path" => "/mnt/gitlab-data" } + }) + ``` + + where `/mnt/gitlab-data` the location where you will store the Git data. + +1. Save the file and reconfigure GitLab: + + ```sh + sudo gitlab-ctl reconfigure + ``` + +TIP: **Tip:** +If you wish to add more than one data volumes to store the Git repositories, +read the [repository storage paths docs](../../administration/repository_storage_paths.md). + +### Setting up Gitaly + +Gitaly is a service that provides high-level RPC access to Git repositories. +It should be enabled and configured in a separate EC2 instance on the +[private VPC](#subnets) we configured previously. + +Follow the [documentation to set up Gitaly](../../administration/gitaly/index.md). + +### Using Amazon S3 object storage + +GitLab stores many objects outside the Git repository, many of which can be +uploaded to S3. That way, you can offload the root disk volume of these objects +which would otherwise take much space. + +In particular, you can store in S3: + +- [The Git LFS objects](../../workflow/lfs/lfs_administration.md#s3-for-omnibus-installations) ((Omnibus GitLab installations)) +- [The Container Registry images](../../administration/container_registry.md#container-registry-storage-driver) (Omnibus GitLab installations) +- [The GitLab CI/CD job artifacts](../../administration/job_artifacts.md#using-object-storage) (Omnibus GitLab installations) + +### Setting up a domain name + +After you SSH into the instance, configure the domain name: + +1. Open `/etc/gitlab/gitlab.rb` with your preferred editor. +1. Edit the `external_url` value: + + ```ruby + external_url 'http://example.com' + ``` + +1. Reconfigure GitLab: + + ```sh + sudo gitlab-ctl reconfigure + ``` + +You should now be able to reach GitLab at the URL you defined. To use HTTPS +(recommended), see the [HTTPS documentation](https://docs.gitlab.com/omnibus/settings/nginx.html#enable-https). + +### Logging in for the first time + +If you followed the previous section, you should be now able to visit GitLab +in your browser. The very first time, you will be asked to set up a password +for the `root` user which has admin privileges on the GitLab instance. + +After you set it up, login with username `root` and the newly created password. + +## Health check and monitoring with Prometheus + +Apart from Amazon's Cloudwatch which you can enable on various services, +GitLab provides its own integrated monitoring solution based on Prometheus. +For more information on how to set it up, visit the +[GitLab Prometheus documentation](../../administration/monitoring/prometheus/index.md) + +GitLab also has various [health check endpoints](../..//user/admin_area/monitoring/health_check.md) +that you can ping and get reports. + +## GitLab Runners + +If you want to take advantage of [GitLab CI/CD](../../ci/README.md), you have to +set up at least one [GitLab Runner](https://docs.gitlab.com/runner/). + +Read more on configuring an +[autoscaling GitLab Runner on AWS](https://docs.gitlab.com/runner/configuration/runner_autoscale_aws/). + +## Backup and restore + +GitLab provides [a tool to backup](../../raketasks/backup_restore.md#creating-a-backup-of-the-gitlab-system) +and restore its Git data, database, attachments, LFS objects, etc. + +Some important things to know: + +- The backup/restore tool **does not** store some configuration files, like secrets; you'll + need to [configure this yourself](../../raketasks/backup_restore.md#storing-configuration-files). +- By default, the backup files are stored locally, but you can + [backup GitLab using S3](../../raketasks/backup_restore.md#using-amazon-s3). +- You can [exclude specific directories form the backup](../../raketasks/backup_restore.md#excluding-specific-directories-from-the-backup). + +### Backing up GitLab + +To back up GitLab: + +1. SSH into your instance. +1. Take a backup: + + ```sh + sudo gitlab-rake gitlab:backup:create + ``` + +### Restoring GitLab from a backup + +To restore GitLab, first review the [restore documentation](../../raketasks/backup_restore.md#restore), +and primarily the restore prerequisites. Then, follow the steps under the +[Omnibus installations section](../../raketasks/backup_restore.md#restore-for-omnibus-installations). + +## Updating GitLab + +GitLab releases a new version every month on the 22nd. Whenever a new version is +released, you can update your GitLab instance: + +1. SSH into your instance +1. Take a backup: + + ```sh + sudo gitlab-rake gitlab:backup:create + ``` + +1. Update the repositories and install GitLab: + + ```sh + sudo apt update + sudo apt install gitlab-ee + ``` + +After a few minutes, the new version should be up and running. + +## Conclusion + +In this guide, we went mostly through scaling and some redundancy options, +your mileage may vary. + +Keep in mind that all Highly Available solutions come with a trade-off between +cost/complexity and uptime. The more uptime you want, the more complex the solution. +And the more complex the solution, the more work is involved in setting up and +maintaining it. + +Have a read through these other resources and feel free to +[open an issue](https://gitlab.com/gitlab-org/gitlab-ce/issues/new) +to request additional material: + +- [GitLab High Availability](https://docs.gitlab.com/ee/administration/high_availability/): + GitLab supports several different types of clustering and high-availability. +- [Geo replication](https://docs.gitlab.com/ee/administration/geo/replication/): + Geo is the solution for widely distributed development teams. +- [Omnibus GitLab](https://docs.gitlab.com/omnibus/) - Everything you need to know + about administering your GitLab instance. +- [Upload a license](https://docs.gitlab.com/ee/user/admin_area/license.html): + Activate all GitLab Enterprise Edition functionality with a license. +- [Pricing](https://about.gitlab.com/pricing): Pricing for the different tiers. diff --git a/doc/install/openshift_and_gitlab/index.md b/doc/install/openshift_and_gitlab/index.md index 5c8a830ac8f..4c88b6f97fc 100644 --- a/doc/install/openshift_and_gitlab/index.md +++ b/doc/install/openshift_and_gitlab/index.md @@ -505,7 +505,7 @@ PaaS and managing your applications with the ease of containers. [RedHat]: https://www.redhat.com/en "RedHat website" [openshift]: https://www.openshift.org "OpenShift Origin website" [vm]: https://www.openshift.org/vm/ "OpenShift All-in-one VM" -[vm-new]: https://atlas.hashicorp.com/openshift/boxes/origin-all-in-one "Official OpenShift Vagrant box on Atlas" +[vm-new]: https://app.vagrantup.com/openshift/boxes/origin-all-in-one "Official OpenShift Vagrant box on Vagrant Cloud" [template]: https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/docker/openshift-template.json "OpenShift template for GitLab" [openshift.com]: https://openshift.com "OpenShift Online" [kubernetes]: http://kubernetes.io/ "Kubernetes website" diff --git a/doc/university/README.md b/doc/university/README.md index f19b1ffd3d9..3e7d02770e4 100644 --- a/doc/university/README.md +++ b/doc/university/README.md @@ -104,7 +104,7 @@ The curriculum is composed of GitLab videos, screencasts, presentations, project 1. [Due Dates and Milestones for GitLab Issues](https://about.gitlab.com/2016/08/05/feature-highlight-set-dates-for-issues/) 1. [How to Use GitLab Labels](https://about.gitlab.com/2016/08/17/using-gitlab-labels/) 1. [Applying GitLab Labels Automatically](https://about.gitlab.com/2016/08/19/applying-gitlab-labels-automatically/) -1. [GitLab Issue Board - Product Page](https://about.gitlab.com/solutions/issueboard/) +1. [GitLab Issue Board - Product Page](https://about.gitlab.com/product/issueboard/) 1. [An Overview of GitLab Issue Board](https://about.gitlab.com/2016/08/22/announcing-the-gitlab-issue-board/) 1. [Designing GitLab Issue Board](https://about.gitlab.com/2016/08/31/designing-issue-boards/) 1. [From Idea to Production with GitLab - Video](https://www.youtube.com/watch?v=25pHyknRgEo&index=14&list=PLFGfElNsQthbQu_IWlNOxul0TbS_2JH-e) @@ -125,7 +125,7 @@ The curriculum is composed of GitLab videos, screencasts, presentations, project 1. [Setting up GitLab CI for iOS projects](https://about.gitlab.com/2016/03/10/setting-up-gitlab-ci-for-ios-projects/) 1. [IBM: Continuous Delivery vs Continuous Deployment - Video](https://www.youtube.com/watch?v=igwFj8PPSnw) 1. [Amazon: Transition to Continuous Delivery - Video](https://www.youtube.com/watch?v=esEFaY0FDKc) -2. [TechBeacon: Doing continuous delivery? Focus first on reducing release cycle times](https://techbeacon.com/doing-continuous-delivery-focus-first-reducing-release-cycle-times) +1. [TechBeacon: Doing continuous delivery? Focus first on reducing release cycle times](https://techbeacon.com/doing-continuous-delivery-focus-first-reducing-release-cycle-times) 1. See **[Integrations](#39-integrations)** for integrations with other CI services. #### 2.4. Workflow @@ -140,7 +140,7 @@ The curriculum is composed of GitLab videos, screencasts, presentations, project 1. [GitLab Compared to Other Tools](https://about.gitlab.com/comparison/) 1. [Comparing GitLab Terminology](https://about.gitlab.com/2016/01/27/comparing-terms-gitlab-github-bitbucket/) -1. [GitLab Compared to Atlassian (Recording 2016-03-03) ](https://youtu.be/Nbzp1t45ERo) +1. [GitLab Compared to Atlassian (Recording 2016-03-03)](https://youtu.be/Nbzp1t45ERo) 1. [GitLab Position FAQ](https://about.gitlab.com/handbook/positioning-faq) 1. [Customer review of GitLab with points on why they prefer GitLab](https://www.enovate.co.uk/web-design-blog/2015/11/25/gitlab-review/) @@ -189,7 +189,7 @@ The curriculum is composed of GitLab videos, screencasts, presentations, project #### 3.8 Cycle Analytics 1. [GitLab Cycle Analytics Overview](https://about.gitlab.com/2016/09/21/cycle-analytics-feature-highlight/) -1. [GitLab Cycle Analytics - Product Page](https://about.gitlab.com/solutions/cycle-analytics/) +1. [GitLab Cycle Analytics - Product Page](https://about.gitlab.com/product/cycle-analytics/) #### 3.9. Integrations @@ -213,7 +213,8 @@ The curriculum is composed of GitLab videos, screencasts, presentations, project ### 5. Resources for GitLab Team Members -*Some content can only be accessed by GitLab team members* +NOTE: **Note:** +Some content can only be accessed by GitLab team members 1. [Support Path](support/README.md) 1. [Sales Path (redirect to sales handbook)](https://about.gitlab.com/handbook/sales-onboarding/) diff --git a/doc/user/markdown.md b/doc/user/markdown.md index 96a509c4b21..93aa41e9a98 100644 --- a/doc/user/markdown.md +++ b/doc/user/markdown.md @@ -774,7 +774,7 @@ These details <em>will</em> remain <strong>hidden</strong> until expanded. </details> </p> -**Note:** Markdown inside these tags is supported, as long as you have a blank link after the `</summary>` tag and before the `</details>` tag, as shown in the example. _Redcarpet does not support Markdown inside these tags. You can work around this by using HTML, for example you can use `<pre><code>` tags instead of [code fences](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#code-and-syntax-highlighting)._ +**Note:** Markdown inside these tags is supported, as long as you have a blank line after the `</summary>` tag and before the `</details>` tag, as shown in the example. _Redcarpet does not support Markdown inside these tags. You can work around this by using HTML, for example you can use `<pre><code>` tags instead of [code fences](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#code-and-syntax-highlighting)._ ```html <details> diff --git a/lib/gitlab/background_migration/remove_restricted_todos.rb b/lib/gitlab/background_migration/remove_restricted_todos.rb index 9941c2fe6d9..47579d46c1b 100644 --- a/lib/gitlab/background_migration/remove_restricted_todos.rb +++ b/lib/gitlab/background_migration/remove_restricted_todos.rb @@ -67,7 +67,7 @@ module Gitlab .where('access_level >= ?', 20) confidential_issues = Issue.select(:id, :author_id).where(confidential: true, project_id: project_id) - confidential_issues.each_batch(of: 100) do |batch| + confidential_issues.each_batch(of: 100, order_hint: :confidential) do |batch| batch.each do |issue| assigned_users = IssueAssignee.select(:user_id).where(issue_id: issue.id) diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb index e4610faa327..362014b1a09 100644 --- a/lib/gitlab/ci/config/entry/job.rb +++ b/lib/gitlab/ci/config/entry/job.rb @@ -14,7 +14,7 @@ module Gitlab ALLOWED_KEYS = %i[tags script only except type image services allow_failure type stage when start_in artifacts cache dependencies before_script after_script variables - environment coverage retry extends].freeze + environment coverage retry parallel extends].freeze validations do validates :config, allowed_keys: ALLOWED_KEYS @@ -29,6 +29,8 @@ module Gitlab validates :retry, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 2 } + validates :parallel, numericality: { only_integer: true, + greater_than_or_equal_to: 2 } validates :when, inclusion: { in: %w[on_success on_failure always manual delayed], message: 'should be on_success, on_failure, ' \ @@ -79,17 +81,18 @@ module Gitlab description: 'Artifacts configuration for this job.' entry :environment, Entry::Environment, - description: 'Environment configuration for this job.' + description: 'Environment configuration for this job.' entry :coverage, Entry::Coverage, - description: 'Coverage configuration for this job.' + description: 'Coverage configuration for this job.' helpers :before_script, :script, :stage, :type, :after_script, :cache, :image, :services, :only, :except, :variables, - :artifacts, :commands, :environment, :coverage, :retry + :artifacts, :commands, :environment, :coverage, :retry, + :parallel attributes :script, :tags, :allow_failure, :when, :dependencies, - :retry, :extends, :start_in + :retry, :parallel, :extends, :start_in def compose!(deps = nil) super do @@ -158,6 +161,7 @@ module Gitlab environment_name: environment_defined? ? environment_value[:name] : nil, coverage: coverage_defined? ? coverage_value : nil, retry: retry_defined? ? retry_value.to_i : nil, + parallel: parallel_defined? ? parallel_value.to_i : nil, artifacts: artifacts_value, after_script: after_script_value, ignore: ignored? } diff --git a/lib/gitlab/ci/config/normalizer.rb b/lib/gitlab/ci/config/normalizer.rb new file mode 100644 index 00000000000..969ae093e8b --- /dev/null +++ b/lib/gitlab/ci/config/normalizer.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + class Normalizer + def initialize(jobs_config) + @jobs_config = jobs_config + end + + def normalize_jobs + extract_parallelized_jobs + parallelized_config = parallelize_jobs + parallelize_dependencies(parallelized_config) + end + + private + + def extract_parallelized_jobs + @parallelized_jobs = {} + + @jobs_config.each do |job_name, config| + if config[:parallel] + @parallelized_jobs[job_name] = self.class.parallelize_job_names(job_name, config[:parallel]) + end + end + + @parallelized_jobs + end + + def parallelize_jobs + @jobs_config.each_with_object({}) do |(job_name, config), hash| + if @parallelized_jobs.key?(job_name) + @parallelized_jobs[job_name].each { |name, index| hash[name.to_sym] = config.merge(name: name, instance: index) } + else + hash[job_name] = config + end + + hash + end + end + + def parallelize_dependencies(parallelized_config) + parallelized_config.each_with_object({}) do |(job_name, config), hash| + parallelized_job_names = @parallelized_jobs.keys.map(&:to_s) + if config[:dependencies] && (intersection = config[:dependencies] & parallelized_job_names).any? + deps = intersection.map { |dep| @parallelized_jobs[dep.to_sym].map(&:first) }.flatten + hash[job_name] = config.merge(dependencies: deps) + else + hash[job_name] = config + end + + hash + end + end + + def self.parallelize_job_names(name, total) + Array.new(total) { |index| ["#{name} #{index + 1}/#{total}", index + 1] } + end + end + end + end +end diff --git a/lib/gitlab/ci/status/build/scheduled.rb b/lib/gitlab/ci/status/build/scheduled.rb index f443dbee120..b3452eae189 100644 --- a/lib/gitlab/ci/status/build/scheduled.rb +++ b/lib/gitlab/ci/status/build/scheduled.rb @@ -9,7 +9,7 @@ module Gitlab { image: 'illustrations/illustrations_scheduled-job_countdown.svg', size: 'svg-394', - title: _("This is a delayed to run in ") + " #{execute_in}", + title: _("This is a delayed job to run in %{remainingTime}"), content: _("This job will automatically run after it's timer finishes. " \ "Often they are used for incremental roll-out deploys " \ "to production environments. When unscheduled it converts " \ @@ -18,21 +18,12 @@ module Gitlab end def status_tooltip - "delayed manual action (#{execute_in})" + "delayed manual action (%{remainingTime})" end def self.matches?(build, user) build.scheduled? && build.scheduled_at end - - private - - include TimeHelper - - def execute_in - remaining_seconds = [0, subject.scheduled_at - Time.now].max - duration_in_numbers(remaining_seconds) - end end end end diff --git a/lib/gitlab/ci/yaml_processor.rb b/lib/gitlab/ci/yaml_processor.rb index 39a1b52e531..e6ec400e476 100644 --- a/lib/gitlab/ci/yaml_processor.rb +++ b/lib/gitlab/ci/yaml_processor.rb @@ -52,6 +52,8 @@ module Gitlab after_script: job[:after_script], environment: job[:environment], retry: job[:retry], + parallel: job[:parallel], + instance: job[:instance], start_in: job[:start_in] }.compact } end @@ -104,7 +106,7 @@ module Gitlab ## # Jobs # - @jobs = @ci_config.jobs + @jobs = Ci::Config::Normalizer.new(@ci_config.jobs).normalize_jobs @jobs.each do |name, job| # logical validation for job diff --git a/lib/gitlab/diff/file.rb b/lib/gitlab/diff/file.rb index fb117baca9e..84595f8afd7 100644 --- a/lib/gitlab/diff/file.rb +++ b/lib/gitlab/diff/file.rb @@ -26,6 +26,7 @@ module Gitlab @repository = repository @diff_refs = diff_refs @fallback_diff_refs = fallback_diff_refs + @unfolded = false # Ensure items are collected in the the batch new_blob_lazy @@ -135,6 +136,24 @@ module Gitlab Gitlab::Diff::Parser.new.parse(raw_diff.each_line, diff_file: self).to_a end + # Changes diff_lines according to the given position. That is, + # it checks whether the position requires blob lines into the diff + # in order to be presented. + def unfold_diff_lines(position) + return unless position + + unfolder = Gitlab::Diff::LinesUnfolder.new(self, position) + + if unfolder.unfold_required? + @diff_lines = unfolder.unfolded_diff_lines + @unfolded = true + end + end + + def unfolded? + @unfolded + end + def highlighted_diff_lines @highlighted_diff_lines ||= Gitlab::Diff::Highlight.new(self, repository: self.repository).highlight diff --git a/lib/gitlab/diff/line.rb b/lib/gitlab/diff/line.rb index 5b67cd46c48..70063071ee7 100644 --- a/lib/gitlab/diff/line.rb +++ b/lib/gitlab/diff/line.rb @@ -3,9 +3,9 @@ module Gitlab class Line SERIALIZE_KEYS = %i(line_code rich_text text type index old_pos new_pos).freeze - attr_reader :line_code, :type, :index, :old_pos, :new_pos + attr_reader :line_code, :type, :old_pos, :new_pos attr_writer :rich_text - attr_accessor :text + attr_accessor :text, :index def initialize(text, type, index, old_pos, new_pos, parent_file: nil, line_code: nil, rich_text: nil) @text, @type, @index = text, type, index @@ -19,7 +19,14 @@ module Gitlab end def self.init_from_hash(hash) - new(hash[:text], hash[:type], hash[:index], hash[:old_pos], hash[:new_pos], line_code: hash[:line_code], rich_text: hash[:rich_text]) + new(hash[:text], + hash[:type], + hash[:index], + hash[:old_pos], + hash[:new_pos], + parent_file: hash[:parent_file], + line_code: hash[:line_code], + rich_text: hash[:rich_text]) end def to_hash diff --git a/lib/gitlab/diff/lines_unfolder.rb b/lib/gitlab/diff/lines_unfolder.rb new file mode 100644 index 00000000000..9306b7e16a2 --- /dev/null +++ b/lib/gitlab/diff/lines_unfolder.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +# Given a position, calculates which Blob lines should be extracted, treated and +# injected in the current diff file lines in order to present a "unfolded" diff. +module Gitlab + module Diff + class LinesUnfolder + include Gitlab::Utils::StrongMemoize + + UNFOLD_CONTEXT_SIZE = 3 + + def initialize(diff_file, position) + @diff_file = diff_file + @blob = diff_file.old_blob + @position = position + @generate_top_match_line = true + @generate_bottom_match_line = true + + # These methods update `@generate_top_match_line` and + # `@generate_bottom_match_line`. + @from_blob_line = calculate_from_blob_line! + @to_blob_line = calculate_to_blob_line! + end + + # Returns merged diff lines with required blob lines with correct + # positions. + def unfolded_diff_lines + strong_memoize(:unfolded_diff_lines) do + next unless unfold_required? + + merged_diff_with_blob_lines + end + end + + # Returns the extracted lines from the old blob which should be merged + # with the current diff lines. + def blob_lines + strong_memoize(:blob_lines) do + # Blob lines, unlike diffs, doesn't start with an empty space for + # unchanged line, so the parsing and highlighting step can get fuzzy + # without the following change. + line_prefix = ' ' + blob_as_diff_lines = @blob.data.each_line.map { |line| "#{line_prefix}#{line}" } + + lines = Gitlab::Diff::Parser.new.parse(blob_as_diff_lines, diff_file: @diff_file).to_a + + from = from_blob_line - 1 + to = to_blob_line - 1 + + lines[from..to] + end + end + + def unfold_required? + strong_memoize(:unfold_required) do + next false unless @diff_file.text? + next false unless @position.unchanged? + next false if @diff_file.new_file? || @diff_file.deleted_file? + next false unless @position.old_line + # Invalid position (MR import scenario) + next false if @position.old_line > @blob.lines.size + next false if @diff_file.diff_lines.empty? + next false if @diff_file.line_for_position(@position) + next false unless unfold_line + + true + end + end + + private + + attr_reader :from_blob_line, :to_blob_line + + def merged_diff_with_blob_lines + lines = @diff_file.diff_lines + match_line = unfold_line + insert_index = bottom? ? -1 : match_line.index + + lines -= [match_line] unless bottom? + + lines.insert(insert_index, *blob_lines_with_matches) + + # The inserted blob lines have invalid indexes, so we need + # to reindex them. + reindex(lines) + + lines + end + + # Returns 'unchanged' blob lines with recalculated `old_pos` and + # `new_pos` and the recalculated new match line (needed if we for instance + # we unfolded once, but there are still folded lines). + def blob_lines_with_matches + old_pos = from_blob_line + new_pos = from_blob_line + offset + + new_blob_lines = [] + + new_blob_lines.push(top_blob_match_line) if top_blob_match_line + + blob_lines.each do |line| + new_blob_lines << Gitlab::Diff::Line.new(line.text, line.type, nil, old_pos, new_pos, + parent_file: @diff_file) + + old_pos += 1 + new_pos += 1 + end + + new_blob_lines.push(bottom_blob_match_line) if bottom_blob_match_line + + new_blob_lines + end + + def reindex(lines) + lines.each_with_index { |line, i| line.index = i } + end + + def top_blob_match_line + strong_memoize(:top_blob_match_line) do + next unless @generate_top_match_line + + old_pos = from_blob_line + new_pos = from_blob_line + offset + + build_match_line(old_pos, new_pos) + end + end + + def bottom_blob_match_line + strong_memoize(:bottom_blob_match_line) do + # The bottom line match addition is already handled on + # Diff::File#diff_lines_for_serializer + next if bottom? + next unless @generate_bottom_match_line + + position = line_after_unfold_position.old_pos + + old_pos = position + new_pos = position + offset + + build_match_line(old_pos, new_pos) + end + end + + def build_match_line(old_pos, new_pos) + blob_lines_length = blob_lines.length + old_line_ref = [old_pos, blob_lines_length].join(',') + new_line_ref = [new_pos, blob_lines_length].join(',') + new_match_line_str = "@@ -#{old_line_ref}+#{new_line_ref} @@" + + Gitlab::Diff::Line.new(new_match_line_str, 'match', nil, old_pos, new_pos) + end + + # Returns the first line position that should be extracted + # from `blob_lines`. + def calculate_from_blob_line! + return unless unfold_required? + + from = comment_position - UNFOLD_CONTEXT_SIZE + + # There's no line before the match if it's in the top-most + # position. + prev_line_number = line_before_unfold_position&.old_pos || 0 + + if from <= prev_line_number + 1 + @generate_top_match_line = false + from = prev_line_number + 1 + end + + from + end + + # Returns the last line position that should be extracted + # from `blob_lines`. + def calculate_to_blob_line! + return unless unfold_required? + + to = comment_position + UNFOLD_CONTEXT_SIZE + + return to if bottom? + + next_line_number = line_after_unfold_position.old_pos + + if to >= next_line_number - 1 + @generate_bottom_match_line = false + to = next_line_number - 1 + end + + to + end + + def offset + unfold_line.new_pos - unfold_line.old_pos + end + + def line_before_unfold_position + return unless index = unfold_line&.index + + @diff_file.diff_lines[index - 1] if index > 0 + end + + def line_after_unfold_position + return unless index = unfold_line&.index + + @diff_file.diff_lines[index + 1] if index >= 0 + end + + def bottom? + strong_memoize(:bottom) do + @position.old_line > last_line.old_pos + end + end + + # Returns the line which needed to be expanded in order to send a comment + # in `@position`. + def unfold_line + strong_memoize(:unfold_line) do + next last_line if bottom? + + @diff_file.diff_lines.find do |line| + line.old_pos > comment_position && line.type == 'match' + end + end + end + + def comment_position + @position.old_line + end + + def last_line + @diff_file.diff_lines.last + end + end + end +end diff --git a/lib/gitlab/diff/position.rb b/lib/gitlab/diff/position.rb index f967494199e..7bfab2d808f 100644 --- a/lib/gitlab/diff/position.rb +++ b/lib/gitlab/diff/position.rb @@ -101,6 +101,10 @@ module Gitlab @diff_refs ||= DiffRefs.new(base_sha: base_sha, start_sha: start_sha, head_sha: head_sha) end + def unfolded_diff?(repository) + diff_file(repository)&.unfolded? + end + def diff_file(repository) return @diff_file if defined?(@diff_file) @@ -134,7 +138,13 @@ module Gitlab return unless diff_refs.complete? return unless comparison = diff_refs.compare_in(repository.project) - comparison.diffs(diff_options).diff_files.first + file = comparison.diffs(diff_options).diff_files.first + + # We need to unfold diff lines according to the position in order + # to correctly calculate the line code and trace position changes. + file&.unfold_diff_lines(self) + + file end def get_formatter_class(type) diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index fcc92341c40..20cd257bb98 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -720,6 +720,26 @@ module Gitlab end end + # Fetch remote for repository + # + # remote - remote name + # ssh_auth - SSH known_hosts data and a private key to use for public-key authentication + # forced - should we use --force flag? + # no_tags - should we use --no-tags flag? + # prune - should we use --prune flag? + def fetch_remote(remote, ssh_auth: nil, forced: false, no_tags: false, prune: true) + wrapped_gitaly_errors do + gitaly_repository_client.fetch_remote( + remote, + ssh_auth: ssh_auth, + forced: forced, + no_tags: no_tags, + prune: prune, + timeout: GITLAB_PROJECTS_TIMEOUT + ) + end + end + def blob_at(sha, path) Gitlab::Git::Blob.find(self, sha, path) unless Gitlab::Git.blank_ref?(sha) end diff --git a/lib/gitlab/shell.rb b/lib/gitlab/shell.rb index 16c1edb2f11..c6a6fb9b5ce 100644 --- a/lib/gitlab/shell.rb +++ b/lib/gitlab/shell.rb @@ -108,23 +108,6 @@ module Gitlab success end - # Fetch remote for repository - # - # repository - an instance of Git::Repository - # remote - remote name - # ssh_auth - SSH known_hosts data and a private key to use for public-key authentication - # forced - should we use --force flag? - # no_tags - should we use --no-tags flag? - # - # Ex. - # fetch_remote(my_repo, "upstream") - # - def fetch_remote(repository, remote, ssh_auth: nil, forced: false, no_tags: false, prune: true) - wrapped_gitaly_errors do - repository.gitaly_repository_client.fetch_remote(remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags, timeout: git_timeout, prune: prune) - end - end - # Move repository reroutes to mv_directory which is an alias for # mv_namespace. Given the underlying implementation is a move action, # indescriminate of what the folders might be. diff --git a/lib/gitlab/utils.rb b/lib/gitlab/utils.rb index 2c92458f777..9e59137a2c0 100644 --- a/lib/gitlab/utils.rb +++ b/lib/gitlab/utils.rb @@ -16,6 +16,11 @@ module Gitlab str.force_encoding(Encoding::UTF_8) end + # Append path to host, making sure there's one single / in between + def append_path(host, path) + "#{host.to_s.sub(%r{\/+$}, '')}/#{path.to_s.sub(%r{^\/+}, '')}" + end + # A slugified version of the string, suitable for inclusion in URLs and # domain names. Rules: # diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 3e35a5dbdf4..d834db6caa3 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -264,6 +264,9 @@ msgstr "" msgid "A deleted user" msgstr "" +msgid "A member of GitLab's abuse team will review your report as soon as possible." +msgstr "" + msgid "A new branch will be created in your fork and a new merge request will be started." msgstr "" @@ -336,6 +339,9 @@ msgstr "" msgid "Add a table" msgstr "" +msgid "Add image comment" +msgstr "" + msgid "Add license" msgstr "" @@ -1718,12 +1724,18 @@ msgstr "" msgid "Collapse sidebar" msgstr "" +msgid "Comment" +msgstr "" + msgid "Comment & resolve discussion" msgstr "" msgid "Comment & unresolve discussion" msgstr "" +msgid "Comment form position" +msgstr "" + msgid "Comments" msgstr "" @@ -1971,6 +1983,9 @@ msgstr "" msgid "Copy file path to clipboard" msgstr "" +msgid "Copy link" +msgstr "" + msgid "Copy reference to clipboard" msgstr "" @@ -2190,6 +2205,9 @@ msgstr "" msgid "Delete Snippet" msgstr "" +msgid "Delete comment" +msgstr "" + msgid "Delete list" msgstr "" @@ -2726,6 +2744,9 @@ msgstr "" msgid "Expiration date" msgstr "" +msgid "Explain the problem. If appropriate, provide a link to the relevant issue or comment." +msgstr "" + msgid "Explore" msgstr "" @@ -4590,6 +4611,9 @@ msgstr "" msgid "Please try again" msgstr "" +msgid "Please use this form to report users to GitLab who create spam issues, comments or behave inappropriately." +msgstr "" + msgid "Please wait while we import the repository for you. Refresh at will." msgstr "" @@ -5159,6 +5183,9 @@ msgstr "" msgid "Reply to this email directly or %{view_it_on_gitlab}." msgstr "" +msgid "Report abuse to GitLab" +msgstr "" + msgid "Reporting" msgstr "" @@ -6188,7 +6215,7 @@ msgstr "" msgid "This is a confidential issue." msgstr "" -msgid "This is a delayed to run in " +msgid "This is a delayed job to run in %{remainingTime}" msgstr "" msgid "This is the author's first Merge Request to this project." @@ -6230,6 +6257,9 @@ msgstr "" msgid "This job is an out-of-date deployment to %{environmentLink}. View the most recent deployment %{deploymentLink}." msgstr "" +msgid "This job is archived. Only the complete pipeline can be retried." +msgstr "" + msgid "This job is creating a deployment to %{environmentLink} and will overwrite the %{deploymentLink}." msgstr "" diff --git a/spec/controllers/boards/issues_controller_spec.rb b/spec/controllers/boards/issues_controller_spec.rb index 98946e4287b..6d0483f0032 100644 --- a/spec/controllers/boards/issues_controller_spec.rb +++ b/spec/controllers/boards/issues_controller_spec.rb @@ -50,7 +50,7 @@ describe Boards::IssuesController do parsed_response = JSON.parse(response.body) - expect(response).to match_response_schema('issues') + expect(response).to match_response_schema('entities/issue_boards') expect(parsed_response['issues'].length).to eq 2 expect(development.issues.map(&:relative_position)).not_to include(nil) end @@ -121,7 +121,7 @@ describe Boards::IssuesController do parsed_response = JSON.parse(response.body) - expect(response).to match_response_schema('issues') + expect(response).to match_response_schema('entities/issue_boards') expect(parsed_response['issues'].length).to eq 2 end end @@ -168,7 +168,7 @@ describe Boards::IssuesController do it 'returns the created issue' do create_issue user: user, board: board, list: list1, title: 'New issue' - expect(response).to match_response_schema('issue') + expect(response).to match_response_schema('entities/issue_board') end end diff --git a/spec/controllers/projects/blob_controller_spec.rb b/spec/controllers/projects/blob_controller_spec.rb index 64b589a6d83..f58aa25cbdd 100644 --- a/spec/controllers/projects/blob_controller_spec.rb +++ b/spec/controllers/projects/blob_controller_spec.rb @@ -157,7 +157,7 @@ describe Projects::BlobController do match_line = JSON.parse(response.body).first - expect(match_line['type']).to eq('context') + expect(match_line['type']).to be_nil end it 'adds bottom match line when "t"o is less than blob size' do @@ -177,7 +177,7 @@ describe Projects::BlobController do match_line = JSON.parse(response.body).last - expect(match_line['type']).to eq('context') + expect(match_line['type']).to be_nil end end end diff --git a/spec/controllers/projects/milestones_controller_spec.rb b/spec/controllers/projects/milestones_controller_spec.rb index 3190f1ce9d4..ccd4fc4db3a 100644 --- a/spec/controllers/projects/milestones_controller_spec.rb +++ b/spec/controllers/projects/milestones_controller_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Projects::MilestonesController do - let(:project) { create(:project) } + let(:project) { create(:project, :repository) } let(:user) { create(:user) } let(:milestone) { create(:milestone, project: project) } let(:issue) { create(:issue, project: project, milestone: milestone) } diff --git a/spec/factories/merge_request_diff_files.rb b/spec/factories/merge_request_diff_files.rb new file mode 100644 index 00000000000..469a7a0ac8d --- /dev/null +++ b/spec/factories/merge_request_diff_files.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :merge_request_diff_file do + association :merge_request_diff + + relative_order 0 + new_file true + renamed_file false + deleted_file false + too_large false + a_mode 0 + b_mode 100644 + new_path 'foo' + old_path 'foo' + diff '' + binary false + + trait :new_file do + relative_order 0 + new_file true + renamed_file false + deleted_file false + too_large false + a_mode 0 + b_mode 100644 + new_path 'foo' + old_path 'foo' + diff '' + binary false + end + + trait :renamed_file do + relative_order 662 + new_file false + renamed_file true + deleted_file false + too_large false + a_mode 100644 + b_mode 100644 + new_path 'bar' + old_path 'baz' + diff '' + binary false + end + end +end diff --git a/spec/factories/merge_request_diffs.rb b/spec/factories/merge_request_diffs.rb new file mode 100644 index 00000000000..e7b51189538 --- /dev/null +++ b/spec/factories/merge_request_diffs.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :merge_request_diff do + association :merge_request + state :collected + commits_count 1 + + base_commit_sha { Digest::SHA1.hexdigest(SecureRandom.hex) } + head_commit_sha { Digest::SHA1.hexdigest(SecureRandom.hex) } + start_commit_sha { Digest::SHA1.hexdigest(SecureRandom.hex) } + end +end diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb index 4d04b8043ec..d01fc04311a 100644 --- a/spec/features/groups_spec.rb +++ b/spec/features/groups_spec.rb @@ -1,8 +1,10 @@ require 'spec_helper' describe 'Group' do + let(:user) { create(:admin) } + before do - sign_in(create(:admin)) + sign_in(user) end matcher :have_namespace_error_message do @@ -16,6 +18,24 @@ describe 'Group' do visit new_group_path end + describe 'as a non-admin' do + let(:user) { create(:user) } + + it 'creates a group and persists visibility radio selection', :js do + stub_application_setting(default_group_visibility: :private) + + fill_in 'Group name', with: 'test-group' + find("input[name='group[visibility_level]'][value='#{Gitlab::VisibilityLevel::PUBLIC}']").click + click_button 'Create group' + + group = Group.find_by(name: 'test-group') + + expect(group.visibility_level).to eq(Gitlab::VisibilityLevel::PUBLIC) + expect(current_path).to eq(group_path(group)) + expect(page).to have_selector '.visibility-icon .fa-globe' + end + end + describe 'with space in group path' do it 'renders new group form with validation errors' do fill_in 'Group URL', with: 'space group' diff --git a/spec/features/merge_request/user_creates_image_diff_notes_spec.rb b/spec/features/merge_request/user_creates_image_diff_notes_spec.rb index f0d38dc6a0c..d790bdc82ce 100644 --- a/spec/features/merge_request/user_creates_image_diff_notes_spec.rb +++ b/spec/features/merge_request/user_creates_image_diff_notes_spec.rb @@ -114,10 +114,9 @@ describe 'Merge request > User creates image diff notes', :js do create_image_diff_note end - # TODO: https://gitlab.com/gitlab-org/gitlab-ce/issues/48034 - xit 'shows indicator and avatar badges, and allows collapsing/expanding the discussion notes' do + it 'shows indicator and avatar badges, and allows collapsing/expanding the discussion notes' do indicator = find('.js-image-badge', match: :first) - badge = find('.image-diff-avatar-link .badge', match: :first) + badge = find('.user-avatar-link .badge', match: :first) expect(indicator).to have_content('1') expect(badge).to have_content('1') @@ -157,8 +156,7 @@ describe 'Merge request > User creates image diff notes', :js do visit project_merge_request_path(project, merge_request) end - # TODO: https://gitlab.com/gitlab-org/gitlab-ce/issues/48034 - xit 'render diff indicators within the image frame' do + it 'render diff indicators within the image frame' do diff_note = create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) wait_for_requests @@ -200,7 +198,6 @@ describe 'Merge request > User creates image diff notes', :js do def create_image_diff_note find('.js-add-image-diff-note-button', match: :first).click - page.all('.js-add-image-diff-note-button')[0].click find('.diff-content .note-textarea').native.send_keys('image diff test comment') click_button 'Comment' wait_for_requests diff --git a/spec/features/merge_request/user_posts_diff_notes_spec.rb b/spec/features/merge_request/user_posts_diff_notes_spec.rb index fa148715855..51b78d3e7d1 100644 --- a/spec/features/merge_request/user_posts_diff_notes_spec.rb +++ b/spec/features/merge_request/user_posts_diff_notes_spec.rb @@ -85,12 +85,13 @@ describe 'Merge request > User posts diff notes', :js do # `.line_holder` will be an unfolded line. let(:line_holder) { first('#a5cc2925ca8258af241be7e5b0381edf30266302 .line_holder') } - it 'does not allow commenting on the left side' do - should_not_allow_commenting(line_holder, 'left') + it 'allows commenting on the left side' do + should_allow_commenting(line_holder, 'left') end - it 'does not allow commenting on the right side' do - should_not_allow_commenting(line_holder, 'right') + it 'allows commenting on the right side' do + # Automatically shifts comment box to left side. + should_allow_commenting(line_holder, 'right') end end end @@ -147,8 +148,8 @@ describe 'Merge request > User posts diff notes', :js do # `.line_holder` will be an unfolded line. let(:line_holder) { first('.line_holder[id="a5cc2925ca8258af241be7e5b0381edf30266302_1_1"]') } - it 'does not allow commenting' do - should_not_allow_commenting line_holder + it 'allows commenting' do + should_allow_commenting line_holder end end diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb index cbb935abd53..a1323699969 100644 --- a/spec/features/projects/jobs_spec.rb +++ b/spec/features/projects/jobs_spec.rb @@ -595,7 +595,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do end it 'shows delayed job', :js do - expect(page).to have_content('This is a delayed to run in') + expect(page).to have_content('This is a delayed job to run in') expect(page).to have_content("This job will automatically run after it's timer finishes.") expect(page).to have_link('Unschedule job') end diff --git a/spec/fixtures/api/schemas/entities/issue_board.json b/spec/fixtures/api/schemas/entities/issue_board.json new file mode 100644 index 00000000000..8d821ebb843 --- /dev/null +++ b/spec/fixtures/api/schemas/entities/issue_board.json @@ -0,0 +1,38 @@ +{ + "type": "object", + "properties" : { + "id": { "type": "integer" }, + "iid": { "type": "integer" }, + "title": { "type": "string" }, + "confidential": { "type": "boolean" }, + "due_date": { "type": "date" }, + "project_id": { "type": "integer" }, + "relative_position": { "type": ["integer", "null"] }, + "weight": { "type": "integer" }, + "project": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "path": { "type": "string" } + } + }, + "milestone": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "title": { "type": "string" } + } + }, + "assignees": { "type": ["array", "null"] }, + "labels": { + "type": "array", + "items": { "$ref": "label.json" } + }, + "reference_path": { "type": "string" }, + "real_path": { "type": "string" }, + "issue_sidebar_endpoint": { "type": "string" }, + "toggle_subscription_endpoint": { "type": "string" }, + "assignable_labels_endpoint": { "type": "string" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/entities/issue_boards.json b/spec/fixtures/api/schemas/entities/issue_boards.json new file mode 100644 index 00000000000..0ac1d9468c8 --- /dev/null +++ b/spec/fixtures/api/schemas/entities/issue_boards.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "required" : [ + "issues", + "size" + ], + "properties" : { + "issues": { + "type": "array", + "items": { "$ref": "issue_board.json" } + }, + "size": { "type": "integer" } + }, + "additionalProperties": false +} diff --git a/spec/helpers/events_helper_spec.rb b/spec/helpers/events_helper_spec.rb index 466e018d68c..8d0679e5699 100644 --- a/spec/helpers/events_helper_spec.rb +++ b/spec/helpers/events_helper_spec.rb @@ -2,18 +2,18 @@ require 'spec_helper' describe EventsHelper do describe '#event_commit_title' do - let(:message) { "foo & bar " + "A" * 70 + "\n" + "B" * 80 } + let(:message) { 'foo & bar ' + 'A' * 70 + '\n' + 'B' * 80 } subject { helper.event_commit_title(message) } - it "returns the first line, truncated to 70 chars" do + it 'returns the first line, truncated to 70 chars' do is_expected.to eq(message[0..66] + "...") end - it "is not html-safe" do + it 'is not html-safe' do is_expected.not_to be_a(ActiveSupport::SafeBuffer) end - it "handles empty strings" do + it 'handles empty strings' do expect(helper.event_commit_title("")).to eq("") end @@ -22,7 +22,7 @@ describe EventsHelper do end it 'does not escape HTML entities' do - expect(helper.event_commit_title("foo & bar")).to eq("foo & bar") + expect(helper.event_commit_title('foo & bar')).to eq('foo & bar') end end @@ -30,38 +30,54 @@ describe EventsHelper do let(:event) { create(:event) } let(:project) { create(:project, :public, :repository) } - it "returns project issue url" do - event.target = create(:issue) + context 'issue' do + before do + event.target = create(:issue) + end - expect(helper.event_feed_url(event)).to eq(project_issue_url(event.project, event.issue)) + it 'returns the project issue url' do + expect(helper.event_feed_url(event)).to eq(project_issue_url(event.project, event.target)) + end + + it 'contains the project issue IID link' do + expect(helper.event_feed_title(event)).to include("##{event.target.iid}") + end end - it "returns project merge_request url" do - event.target = create(:merge_request) + context 'merge request' do + before do + event.target = create(:merge_request) + end + + it 'returns the project merge request url' do + expect(helper.event_feed_url(event)).to eq(project_merge_request_url(event.project, event.target)) + end - expect(helper.event_feed_url(event)).to eq(project_merge_request_url(event.project, event.merge_request)) + it 'contains the project merge request IID link' do + expect(helper.event_feed_title(event)).to include("!#{event.target.iid}") + end end - it "returns project commit url" do + it 'returns project commit url' do event.target = create(:note_on_commit, project: project) expect(helper.event_feed_url(event)).to eq(project_commit_url(event.project, event.note_target)) end - it "returns event note target url" do + it 'returns event note target url' do event.target = create(:note) expect(helper.event_feed_url(event)).to eq(event_note_target_url(event)) end - it "returns project url" do + it 'returns project url' do event.project = project event.action = 1 expect(helper.event_feed_url(event)).to eq(project_url(event.project)) end - it "returns push event feed url" do + it 'returns push event feed url' do event = create(:push_event) create(:push_event_payload, event: event, action: :pushed) diff --git a/spec/javascripts/diffs/components/diff_content_spec.js b/spec/javascripts/diffs/components/diff_content_spec.js index 67f7b569f47..36bd042f3c4 100644 --- a/spec/javascripts/diffs/components/diff_content_spec.js +++ b/spec/javascripts/diffs/components/diff_content_spec.js @@ -1,15 +1,24 @@ import Vue from 'vue'; import DiffContentComponent from '~/diffs/components/diff_content.vue'; -import store from '~/mr_notes/stores'; +import { createStore } from '~/mr_notes/stores'; import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants'; +import '~/behaviors/markdown/render_gfm'; import diffFileMockData from '../mock_data/diff_file'; +import discussionsMockData from '../mock_data/diff_discussions'; describe('DiffContent', () => { const Component = Vue.extend(DiffContentComponent); let vm; beforeEach(() => { + const store = createStore(); + store.state.notes.noteableData = { + current_user: { + can_create_note: false, + }, + }; + vm = mountComponentWithStore(Component, { store, props: { @@ -46,21 +55,57 @@ describe('DiffContent', () => { }); describe('image diff', () => { - beforeEach(() => { + beforeEach(done => { vm.diffFile.newPath = GREEN_BOX_IMAGE_URL; vm.diffFile.newSha = 'DEF'; vm.diffFile.oldPath = RED_BOX_IMAGE_URL; vm.diffFile.oldSha = 'ABC'; vm.diffFile.viewPath = ''; + vm.diffFile.discussions = [{ ...discussionsMockData }]; + vm.$store.state.diffs.commentForms.push({ + fileHash: vm.diffFile.fileHash, + x: 10, + y: 20, + width: 100, + height: 200, + }); + + vm.$nextTick(done); }); - it('should have image diff view in place', done => { - vm.$nextTick(() => { - expect(vm.$el.querySelectorAll('.js-diff-inline-view').length).toEqual(0); + it('should have image diff view in place', () => { + expect(vm.$el.querySelectorAll('.js-diff-inline-view').length).toEqual(0); - expect(vm.$el.querySelectorAll('.diff-viewer .image').length).toEqual(1); + expect(vm.$el.querySelectorAll('.diff-viewer .image').length).toEqual(1); + }); - done(); + it('renders image diff overlay', () => { + expect(vm.$el.querySelector('.image-diff-overlay')).not.toBe(null); + }); + + it('renders diff file discussions', () => { + expect(vm.$el.querySelectorAll('.discussion .note.timeline-entry').length).toEqual(5); + }); + + describe('handleSaveNote', () => { + it('dispatches handleSaveNote', () => { + spyOn(vm.$store, 'dispatch').and.stub(); + + vm.handleSaveNote('test'); + + expect(vm.$store.dispatch).toHaveBeenCalledWith('diffs/saveDiffDiscussion', { + note: 'test', + formData: { + noteableData: jasmine.anything(), + noteableType: jasmine.anything(), + diffFile: vm.diffFile, + positionType: 'image', + x: 10, + y: 20, + width: 100, + height: 200, + }, + }); }); }); }); diff --git a/spec/javascripts/diffs/components/diff_discussions_spec.js b/spec/javascripts/diffs/components/diff_discussions_spec.js index 270f363825f..0bc9da5ad0f 100644 --- a/spec/javascripts/diffs/components/diff_discussions_spec.js +++ b/spec/javascripts/diffs/components/diff_discussions_spec.js @@ -1,24 +1,90 @@ import Vue from 'vue'; import DiffDiscussions from '~/diffs/components/diff_discussions.vue'; -import store from '~/mr_notes/stores'; +import { createStore } from '~/mr_notes/stores'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import '~/behaviors/markdown/render_gfm'; import discussionsMockData from '../mock_data/diff_discussions'; describe('DiffDiscussions', () => { - let component; + let vm; const getDiscussionsMockData = () => [Object.assign({}, discussionsMockData)]; - beforeEach(() => { - component = createComponentWithStore(Vue.extend(DiffDiscussions), store, { + function createComponent(props = {}) { + const store = createStore(); + + vm = createComponentWithStore(Vue.extend(DiffDiscussions), store, { discussions: getDiscussionsMockData(), + ...props, }).$mount(); + } + + afterEach(() => { + vm.$destroy(); }); describe('template', () => { it('should have notes list', () => { - const { $el } = component; + createComponent(); + + expect(vm.$el.querySelectorAll('.discussion .note.timeline-entry').length).toEqual(5); + }); + }); + + describe('image commenting', () => { + it('renders collapsible discussion button', () => { + createComponent({ shouldCollapseDiscussions: true }); + + expect(vm.$el.querySelector('.js-diff-notes-toggle')).not.toBe(null); + expect(vm.$el.querySelector('.js-diff-notes-toggle svg')).not.toBe(null); + expect(vm.$el.querySelector('.js-diff-notes-toggle').classList).toContain( + 'diff-notes-collapse', + ); + }); + + it('dispatches toggleDiscussion when clicking collapse button', () => { + createComponent({ shouldCollapseDiscussions: true }); + + spyOn(vm.$store, 'dispatch').and.stub(); + + vm.$el.querySelector('.js-diff-notes-toggle').click(); + + expect(vm.$store.dispatch).toHaveBeenCalledWith('toggleDiscussion', { + discussionId: vm.discussions[0].id, + }); + }); + + it('renders expand button when discussion is collapsed', done => { + createComponent({ shouldCollapseDiscussions: true }); + + vm.discussions[0].expanded = false; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.js-diff-notes-toggle').textContent.trim()).toBe('1'); + expect(vm.$el.querySelector('.js-diff-notes-toggle').className).toContain( + 'btn-transparent badge badge-pill', + ); + + done(); + }); + }); + + it('hides discussion when collapsed', done => { + createComponent({ shouldCollapseDiscussions: true }); + + vm.discussions[0].expanded = false; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.note-discussion').style.display).toBe('none'); + + done(); + }); + }); + + it('renders badge on avatar', () => { + createComponent({ renderAvatarBadge: true, discussions: [{ ...discussionsMockData }] }); - expect($el.querySelectorAll('.discussion .note.timeline-entry').length).toEqual(5); + expect(vm.$el.querySelector('.user-avatar-link .badge-pill')).not.toBe(null); + expect(vm.$el.querySelector('.user-avatar-link .badge-pill').textContent.trim()).toBe('1'); }); }); }); diff --git a/spec/javascripts/diffs/components/image_diff_overlay_spec.js b/spec/javascripts/diffs/components/image_diff_overlay_spec.js new file mode 100644 index 00000000000..d76ab745fe1 --- /dev/null +++ b/spec/javascripts/diffs/components/image_diff_overlay_spec.js @@ -0,0 +1,146 @@ +import Vue from 'vue'; +import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue'; +import { createStore } from '~/mr_notes/stores'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { imageDiffDiscussions } from '../mock_data/diff_discussions'; + +describe('Diffs image diff overlay component', () => { + const dimensions = { + width: 100, + height: 200, + }; + let Component; + let vm; + + function createComponent(props = {}, extendStore = () => {}) { + const store = createStore(); + + extendStore(store); + + vm = createComponentWithStore(Component, store, { + discussions: [...imageDiffDiscussions], + fileHash: 'ABC', + ...props, + }); + } + + beforeAll(() => { + Component = Vue.extend(ImageDiffOverlay); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders comment badges', () => { + createComponent(); + spyOn(vm, 'getImageDimensions').and.returnValue(dimensions); + vm.$mount(); + + expect(vm.$el.querySelectorAll('.js-image-badge').length).toBe(2); + }); + + it('renders index of discussion in badge', () => { + createComponent(); + spyOn(vm, 'getImageDimensions').and.returnValue(dimensions); + vm.$mount(); + + expect(vm.$el.querySelectorAll('.js-image-badge')[0].textContent.trim()).toBe('1'); + expect(vm.$el.querySelectorAll('.js-image-badge')[1].textContent.trim()).toBe('2'); + }); + + it('renders icon when showCommentIcon is true', () => { + createComponent({ showCommentIcon: true }); + spyOn(vm, 'getImageDimensions').and.returnValue(dimensions); + vm.$mount(); + + expect(vm.$el.querySelector('.js-image-badge svg')).not.toBe(null); + }); + + it('sets badge comment positions', () => { + createComponent(); + spyOn(vm, 'getImageDimensions').and.returnValue(dimensions); + vm.$mount(); + + expect(vm.$el.querySelectorAll('.js-image-badge')[0].style.left).toBe('10px'); + expect(vm.$el.querySelectorAll('.js-image-badge')[0].style.top).toBe('10px'); + + expect(vm.$el.querySelectorAll('.js-image-badge')[1].style.left).toBe('5px'); + expect(vm.$el.querySelectorAll('.js-image-badge')[1].style.top).toBe('5px'); + }); + + it('renders single badge for discussion object', () => { + createComponent({ + discussions: { + ...imageDiffDiscussions[0], + }, + }); + spyOn(vm, 'getImageDimensions').and.returnValue(dimensions); + vm.$mount(); + + expect(vm.$el.querySelectorAll('.js-image-badge').length).toBe(1); + }); + + it('dispatches openDiffFileCommentForm when clicking overlay', () => { + createComponent({ canComment: true }); + spyOn(vm, 'getImageDimensions').and.returnValue(dimensions); + vm.$mount(); + + spyOn(vm.$store, 'dispatch').and.stub(); + + vm.$el.querySelector('.js-add-image-diff-note-button').click(); + + expect(vm.$store.dispatch).toHaveBeenCalledWith('diffs/openDiffFileCommentForm', { + fileHash: 'ABC', + x: 0, + y: 0, + width: 100, + height: 200, + }); + }); + + describe('toggle discussion', () => { + it('disables buttons when shouldToggleDiscussion is false', () => { + createComponent({ shouldToggleDiscussion: false }); + spyOn(vm, 'getImageDimensions').and.returnValue(dimensions); + vm.$mount(); + + expect(vm.$el.querySelector('.js-image-badge').hasAttribute('disabled')).toBe(true); + }); + + it('dispatches toggleDiscussion when clicking image badge', () => { + createComponent(); + spyOn(vm, 'getImageDimensions').and.returnValue(dimensions); + vm.$mount(); + + spyOn(vm.$store, 'dispatch').and.stub(); + + vm.$el.querySelector('.js-image-badge').click(); + + expect(vm.$store.dispatch).toHaveBeenCalledWith('toggleDiscussion', { discussionId: '1' }); + }); + }); + + describe('comment form', () => { + beforeEach(() => { + createComponent({}, store => { + store.state.diffs.commentForms.push({ + fileHash: 'ABC', + x: 20, + y: 10, + }); + }); + spyOn(vm, 'getImageDimensions').and.returnValue(dimensions); + vm.$mount(); + }); + + it('renders comment form badge', () => { + expect(vm.$el.querySelector('.comment-indicator')).not.toBe(null); + }); + + it('sets comment form badge position', () => { + expect(vm.$el.querySelector('.comment-indicator').style.left).toBe('20px'); + expect(vm.$el.querySelector('.comment-indicator').style.top).toBe('10px'); + }); + }); +}); diff --git a/spec/javascripts/diffs/mock_data/diff_discussions.js b/spec/javascripts/diffs/mock_data/diff_discussions.js index 0ad214ea4a4..5ffe5a366ba 100644 --- a/spec/javascripts/diffs/mock_data/diff_discussions.js +++ b/spec/javascripts/diffs/mock_data/diff_discussions.js @@ -492,3 +492,24 @@ export default { image_diff_html: '<div class="image js-replaced-image" data="">\n<div class="two-up view">\n<div class="wrap">\n<div class="frame deleted">\n<img alt="CHANGELOG" src="http://localhost:3000/gitlab-org/gitlab-test/raw/e63f41fe459e62e1228fcef60d7189127aeba95a/CHANGELOG" />\n</div>\n<p class="image-info hide">\n<span class="meta-filesize">22.3 KB</span>\n|\n<strong>W:</strong>\n<span class="meta-width"></span>\n|\n<strong>H:</strong>\n<span class="meta-height"></span>\n</p>\n</div>\n<div class="wrap">\n<div class="added frame js-image-frame" data-note-type="DiffNote" data-position="{"base_sha":"e63f41fe459e62e1228fcef60d7189127aeba95a","start_sha":"d9eaefe5a676b820c57ff18cf5b68316025f7962","head_sha":"c48ee0d1bf3b30453f5b32250ce03134beaa6d13","old_path":"CHANGELOG","new_path":"CHANGELOG","position_type":"text","old_line":null,"new_line":2}">\n<img alt="CHANGELOG" draggable="false" src="http://localhost:3000/gitlab-org/gitlab-test/raw/c48ee0d1bf3b30453f5b32250ce03134beaa6d13/CHANGELOG" />\n</div>\n\n<p class="image-info hide">\n<span class="meta-filesize">22.3 KB</span>\n|\n<strong>W:</strong>\n<span class="meta-width"></span>\n|\n<strong>H:</strong>\n<span class="meta-height"></span>\n</p>\n</div>\n</div>\n<div class="swipe view hide">\n<div class="swipe-frame">\n<div class="frame deleted">\n<img alt="CHANGELOG" src="http://localhost:3000/gitlab-org/gitlab-test/raw/e63f41fe459e62e1228fcef60d7189127aeba95a/CHANGELOG" />\n</div>\n<div class="swipe-wrap">\n<div class="added frame js-image-frame" data-note-type="DiffNote" data-position="{"base_sha":"e63f41fe459e62e1228fcef60d7189127aeba95a","start_sha":"d9eaefe5a676b820c57ff18cf5b68316025f7962","head_sha":"c48ee0d1bf3b30453f5b32250ce03134beaa6d13","old_path":"CHANGELOG","new_path":"CHANGELOG","position_type":"text","old_line":null,"new_line":2}">\n<img alt="CHANGELOG" draggable="false" src="http://localhost:3000/gitlab-org/gitlab-test/raw/c48ee0d1bf3b30453f5b32250ce03134beaa6d13/CHANGELOG" />\n</div>\n\n</div>\n<span class="swipe-bar">\n<span class="top-handle"></span>\n<span class="bottom-handle"></span>\n</span>\n</div>\n</div>\n<div class="onion-skin view hide">\n<div class="onion-skin-frame">\n<div class="frame deleted">\n<img alt="CHANGELOG" src="http://localhost:3000/gitlab-org/gitlab-test/raw/e63f41fe459e62e1228fcef60d7189127aeba95a/CHANGELOG" />\n</div>\n<div class="added frame js-image-frame" data-note-type="DiffNote" data-position="{"base_sha":"e63f41fe459e62e1228fcef60d7189127aeba95a","start_sha":"d9eaefe5a676b820c57ff18cf5b68316025f7962","head_sha":"c48ee0d1bf3b30453f5b32250ce03134beaa6d13","old_path":"CHANGELOG","new_path":"CHANGELOG","position_type":"text","old_line":null,"new_line":2}">\n<img alt="CHANGELOG" draggable="false" src="http://localhost:3000/gitlab-org/gitlab-test/raw/c48ee0d1bf3b30453f5b32250ce03134beaa6d13/CHANGELOG" />\n</div>\n\n<div class="controls">\n<div class="transparent"></div>\n<div class="drag-track">\n<div class="dragger" style="left: 0px;"></div>\n</div>\n<div class="opaque"></div>\n</div>\n</div>\n</div>\n</div>\n<div class="view-modes hide">\n<ul class="view-modes-menu">\n<li class="two-up" data-mode="two-up">2-up</li>\n<li class="swipe" data-mode="swipe">Swipe</li>\n<li class="onion-skin" data-mode="onion-skin">Onion skin</li>\n</ul>\n</div>\n', }; + +export const imageDiffDiscussions = [ + { + id: '1', + position: { + x: 10, + y: 10, + width: 100, + height: 200, + }, + }, + { + id: '2', + position: { + x: 5, + y: 5, + width: 100, + height: 200, + }, + }, +]; diff --git a/spec/javascripts/diffs/mock_data/diff_file.js b/spec/javascripts/diffs/mock_data/diff_file.js index d7bc0dbe431..be194ab414f 100644 --- a/spec/javascripts/diffs/mock_data/diff_file.js +++ b/spec/javascripts/diffs/mock_data/diff_file.js @@ -237,4 +237,5 @@ export default { }, }, ], + discussions: [], }; diff --git a/spec/javascripts/diffs/store/actions_spec.js b/spec/javascripts/diffs/store/actions_spec.js index bb623953710..17d0f31bdd3 100644 --- a/spec/javascripts/diffs/store/actions_spec.js +++ b/spec/javascripts/diffs/store/actions_spec.js @@ -218,6 +218,7 @@ describe('DiffsStoreActions', () => { ], }; const singleDiscussion = { + id: '1', fileHash: 'ABC', line_code: 'ABC_1_1', }; @@ -230,6 +231,7 @@ describe('DiffsStoreActions', () => { { type: types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, payload: { + id: '1', fileHash: 'ABC', lineCode: 'ABC_1_1', }, diff --git a/spec/javascripts/diffs/store/mutations_spec.js b/spec/javascripts/diffs/store/mutations_spec.js index fed04cbaed8..8821cde76f4 100644 --- a/spec/javascripts/diffs/store/mutations_spec.js +++ b/spec/javascripts/diffs/store/mutations_spec.js @@ -98,7 +98,7 @@ describe('DiffsStoreMutations', () => { it('should call utils.addContextLines with proper params', () => { const options = { lineNumbers: { oldLineNumber: 1, newLineNumber: 2 }, - contextLines: [{ oldLine: 1 }], + contextLines: [{ oldLine: 1, newLine: 1, lineCode: 'ff9200_1_1', discussions: [] }], fileHash: 'ff9200', params: { bottom: true, @@ -110,7 +110,7 @@ describe('DiffsStoreMutations', () => { parallelDiffLines: [], }; const state = { diffFiles: [diffFile] }; - const lines = [{ oldLine: 1 }]; + const lines = [{ oldLine: 1, newLine: 1 }]; const findDiffFileSpy = spyOnDependency(mutations, 'findDiffFile').and.returnValue(diffFile); const removeMatchLineSpy = spyOnDependency(mutations, 'removeMatchLine'); diff --git a/spec/javascripts/fixtures/jobs.rb b/spec/javascripts/fixtures/jobs.rb index 6d5c6d5334f..82d7a5e394e 100644 --- a/spec/javascripts/fixtures/jobs.rb +++ b/spec/javascripts/fixtures/jobs.rb @@ -5,16 +5,24 @@ describe Projects::JobsController, '(JavaScript fixtures)', type: :controller do let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} - let(:project) { create(:project_empty_repo, namespace: namespace, path: 'builds-project') } - let(:pipeline) { create(:ci_empty_pipeline, project: project) } + let(:project) { create(:project, :repository, namespace: namespace, path: 'builds-project') } + let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.id) } let!(:build_with_artifacts) { create(:ci_build, :success, :artifacts, :trace_artifact, pipeline: pipeline, stage: 'test', artifacts_expire_at: Time.now + 18.months) } let!(:failed_build) { create(:ci_build, :failed, pipeline: pipeline, stage: 'build') } let!(:pending_build) { create(:ci_build, :pending, pipeline: pipeline, stage: 'deploy') } + let!(:delayed_job) do + create(:ci_build, :scheduled, + pipeline: pipeline, + name: 'delayed job', + stage: 'test', + commands: 'test') + end render_views before(:all) do clean_frontend_fixtures('builds/') + clean_frontend_fixtures('jobs/') end before do @@ -34,4 +42,15 @@ describe Projects::JobsController, '(JavaScript fixtures)', type: :controller do expect(response).to be_success store_frontend_fixture(response, example.description) end + + it 'jobs/delayed.json' do |example| + get :show, + namespace_id: project.namespace.to_param, + project_id: project, + id: delayed_job.to_param, + format: :json + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end end diff --git a/spec/javascripts/jobs/components/job_app_spec.js b/spec/javascripts/jobs/components/job_app_spec.js index ba1889c4dcd..98c995393b9 100644 --- a/spec/javascripts/jobs/components/job_app_spec.js +++ b/spec/javascripts/jobs/components/job_app_spec.js @@ -8,6 +8,7 @@ import { resetStore } from '../store/helpers'; import job from '../mock_data'; describe('Job App ', () => { + const delayedJobFixture = getJSONFixture('jobs/delayed.json'); const Component = Vue.extend(jobApp); let store; let vm; @@ -420,6 +421,70 @@ describe('Job App ', () => { done(); }, 0); }); + + it('displays remaining time for a delayed job', done => { + const oneHourInMilliseconds = 3600000; + spyOn(Date, 'now').and.callFake( + () => new Date(delayedJobFixture.scheduled_at).getTime() - oneHourInMilliseconds, + ); + mock.onGet(props.endpoint).replyOnce(200, { ...delayedJobFixture }); + + vm = mountComponentWithStore(Component, { + props, + store, + }); + + store.subscribeAction(action => { + if (action.type !== 'receiveJobSuccess') { + return; + } + + Vue.nextTick() + .then(() => { + expect(vm.$el.querySelector('.js-job-empty-state')).not.toBeNull(); + + const title = vm.$el.querySelector('.js-job-empty-state-title'); + + expect(title).toContainText('01:00:00'); + done(); + }) + .catch(done.fail); + }); + }); + }); + }); + + describe('archived job', () => { + beforeEach(() => { + mock.onGet(props.endpoint).reply(200, Object.assign({}, job, { archived: true }), {}); + vm = mountComponentWithStore(Component, { + props, + store, + }); + }); + + it('renders warning about job being archived', done => { + setTimeout(() => { + expect(vm.$el.querySelector('.js-archived-job ')).not.toBeNull(); + done(); + }, 0); + }); + }); + + describe('non-archived job', () => { + beforeEach(() => { + mock.onGet(props.endpoint).reply(200, job, {}); + vm = mountComponentWithStore(Component, { + props, + store, + }); + }); + + it('does not warning about job being archived', done => { + setTimeout(() => { + expect(vm.$el.querySelector('.js-archived-job ')).toBeNull(); + done(); + }, 0); }); }); diff --git a/spec/javascripts/jobs/components/job_container_item_spec.js b/spec/javascripts/jobs/components/job_container_item_spec.js index 8588eda19c8..2d108f1ad7f 100644 --- a/spec/javascripts/jobs/components/job_container_item_spec.js +++ b/spec/javascripts/jobs/components/job_container_item_spec.js @@ -4,6 +4,7 @@ import mountComponent from 'spec/helpers/vue_mount_component_helper'; import job from '../mock_data'; describe('JobContainerItem', () => { + const delayedJobFixture = getJSONFixture('jobs/delayed.json'); const Component = Vue.extend(JobContainerItem); let vm; @@ -70,4 +71,29 @@ describe('JobContainerItem', () => { expect(vm.$el).toHaveSpriteIcon('retry'); }); }); + + describe('for delayed job', () => { + beforeEach(() => { + const remainingMilliseconds = 1337000; + spyOn(Date, 'now').and.callFake( + () => new Date(delayedJobFixture.scheduled_at).getTime() - remainingMilliseconds, + ); + }); + + it('displays remaining time in tooltip', done => { + vm = mountComponent(Component, { + job: delayedJobFixture, + isActive: false, + }); + + Vue.nextTick() + .then(() => { + expect(vm.$el.querySelector('.js-job-link').getAttribute('data-original-title')).toEqual( + 'delayed job - delayed manual action (00:22:17)', + ); + }) + .then(done) + .catch(done.fail); + }); + }); }); diff --git a/spec/javascripts/jobs/mixins/delayed_job_mixin_spec.js b/spec/javascripts/jobs/mixins/delayed_job_mixin_spec.js new file mode 100644 index 00000000000..48a6b80b365 --- /dev/null +++ b/spec/javascripts/jobs/mixins/delayed_job_mixin_spec.js @@ -0,0 +1,93 @@ +import Vue from 'vue'; +import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('DelayedJobMixin', () => { + const delayedJobFixture = getJSONFixture('jobs/delayed.json'); + const dummyComponent = Vue.extend({ + mixins: [delayedJobMixin], + props: { + job: { + type: Object, + required: true, + }, + }, + template: '<div>{{ remainingTime }}</div>', + }); + + let vm; + + beforeEach(() => { + jasmine.clock().install(); + }); + + afterEach(() => { + vm.$destroy(); + jasmine.clock().uninstall(); + }); + + describe('if job is empty object', () => { + beforeEach(() => { + vm = mountComponent(dummyComponent, { + job: {}, + }); + }); + + it('sets remaining time to 00:00:00', () => { + expect(vm.$el.innerText).toBe('00:00:00'); + }); + + describe('after mounting', () => { + beforeEach(done => { + Vue.nextTick() + .then(done) + .catch(done.fail); + }); + + it('doe not update remaining time', () => { + expect(vm.$el.innerText).toBe('00:00:00'); + }); + }); + }); + + describe('if job is delayed job', () => { + let remainingTimeInMilliseconds = 42000; + + beforeEach(() => { + spyOn(Date, 'now').and.callFake( + () => new Date(delayedJobFixture.scheduled_at).getTime() - remainingTimeInMilliseconds, + ); + vm = mountComponent(dummyComponent, { + job: delayedJobFixture, + }); + }); + + it('sets remaining time to 00:00:00', () => { + expect(vm.$el.innerText).toBe('00:00:00'); + }); + + describe('after mounting', () => { + beforeEach(done => { + Vue.nextTick() + .then(done) + .catch(done.fail); + }); + + it('sets remaining time', () => { + expect(vm.$el.innerText).toBe('00:00:42'); + }); + + it('updates remaining time', done => { + remainingTimeInMilliseconds = 41000; + jasmine.clock().tick(1000); + + Vue.nextTick() + .then(() => { + expect(vm.$el.innerText).toBe('00:00:41'); + }) + .then(done) + .catch(done.fail); + }); + }); + }); +}); diff --git a/spec/javascripts/notes/components/diff_with_note_spec.js b/spec/javascripts/notes/components/diff_with_note_spec.js index 239d7950907..0c16103714a 100644 --- a/spec/javascripts/notes/components/diff_with_note_spec.js +++ b/spec/javascripts/notes/components/diff_with_note_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import DiffWithNote from '~/notes/components/diff_with_note.vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import createStore from '~/notes/stores'; +import { createStore } from '~/mr_notes/stores'; import { mountComponentWithStore } from 'spec/helpers'; const discussionFixture = 'merge_requests/diff_discussion.json'; diff --git a/spec/javascripts/notes/components/note_actions_spec.js b/spec/javascripts/notes/components/note_actions_spec.js index d7298cb3483..f6c854e6def 100644 --- a/spec/javascripts/notes/components/note_actions_spec.js +++ b/spec/javascripts/notes/components/note_actions_spec.js @@ -55,7 +55,7 @@ describe('issue_note_actions component', () => { expect(vm.$el.querySelector('.js-note-edit')).toBeDefined(); }); - it('should be possible to report as abuse', () => { + it('should be possible to report abuse to GitLab', () => { expect(vm.$el.querySelector(`a[href="${props.reportAbusePath}"]`)).toBeDefined(); }); diff --git a/spec/javascripts/pipelines/graph/job_item_spec.js b/spec/javascripts/pipelines/graph/job_item_spec.js index 7cbcdc791e7..41b614cc95e 100644 --- a/spec/javascripts/pipelines/graph/job_item_spec.js +++ b/spec/javascripts/pipelines/graph/job_item_spec.js @@ -6,6 +6,7 @@ describe('pipeline graph job item', () => { const JobComponent = Vue.extend(JobItem); let component; + const delayedJobFixture = getJSONFixture('jobs/delayed.json'); const mockJob = { id: 4256, name: 'test', @@ -167,4 +168,30 @@ describe('pipeline graph job item', () => { expect(component.$el.querySelector(tooltipBoundary)).toBeNull(); }); }); + + describe('for delayed job', () => { + beforeEach(() => { + const fifteenMinutesInMilliseconds = 900000; + spyOn(Date, 'now').and.callFake( + () => new Date(delayedJobFixture.scheduled_at).getTime() - fifteenMinutesInMilliseconds, + ); + }); + + it('displays remaining time in tooltip', done => { + component = mountComponent(JobComponent, { + job: delayedJobFixture, + }); + + Vue.nextTick() + .then(() => { + expect( + component.$el + .querySelector('.js-pipeline-graph-job-link') + .getAttribute('data-original-title'), + ).toEqual('delayed job - delayed manual action (00:15:00)'); + }) + .then(done) + .catch(done.fail); + }); + }); }); diff --git a/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js b/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js index e2c34508b0d..4da8c6196b1 100644 --- a/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js +++ b/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js @@ -47,7 +47,7 @@ describe('ContentViewer', () => { }); setTimeout(() => { - expect(vm.$el.querySelector('.image_file img').getAttribute('src')).toBe(GREEN_BOX_IMAGE_URL); + expect(vm.$el.querySelector('img').getAttribute('src')).toBe(GREEN_BOX_IMAGE_URL); done(); }); diff --git a/spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js b/spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js index fcd231ec693..67a3a2e08bc 100644 --- a/spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js +++ b/spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js @@ -30,11 +30,11 @@ describe('DiffViewer', () => { }); setTimeout(() => { - expect(vm.$el.querySelector('.deleted .image_file img').getAttribute('src')).toBe( + expect(vm.$el.querySelector('.deleted img').getAttribute('src')).toBe( `//raw/DEF/${RED_BOX_IMAGE_URL}`, ); - expect(vm.$el.querySelector('.added .image_file img').getAttribute('src')).toBe( + expect(vm.$el.querySelector('.added img').getAttribute('src')).toBe( `//raw/ABC/${GREEN_BOX_IMAGE_URL}`, ); diff --git a/spec/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js b/spec/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js index 380effdb669..2d3e178d249 100644 --- a/spec/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js +++ b/spec/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js @@ -52,13 +52,9 @@ describe('ImageDiffViewer', () => { }); setTimeout(() => { - expect(vm.$el.querySelector('.added .image_file img').getAttribute('src')).toBe( - GREEN_BOX_IMAGE_URL, - ); + expect(vm.$el.querySelector('.added img').getAttribute('src')).toBe(GREEN_BOX_IMAGE_URL); - expect(vm.$el.querySelector('.deleted .image_file img').getAttribute('src')).toBe( - RED_BOX_IMAGE_URL, - ); + expect(vm.$el.querySelector('.deleted img').getAttribute('src')).toBe(RED_BOX_IMAGE_URL); expect(vm.$el.querySelector('.view-modes-menu li.active').textContent.trim()).toBe('2-up'); expect(vm.$el.querySelector('.view-modes-menu li:nth-child(2)').textContent.trim()).toBe( @@ -81,9 +77,7 @@ describe('ImageDiffViewer', () => { }); setTimeout(() => { - expect(vm.$el.querySelector('.added .image_file img').getAttribute('src')).toBe( - GREEN_BOX_IMAGE_URL, - ); + expect(vm.$el.querySelector('.added img').getAttribute('src')).toBe(GREEN_BOX_IMAGE_URL); done(); }); @@ -97,9 +91,7 @@ describe('ImageDiffViewer', () => { }); setTimeout(() => { - expect(vm.$el.querySelector('.deleted .image_file img').getAttribute('src')).toBe( - RED_BOX_IMAGE_URL, - ); + expect(vm.$el.querySelector('.deleted img').getAttribute('src')).toBe(RED_BOX_IMAGE_URL); done(); }); diff --git a/spec/lib/banzai/filter/external_issue_reference_filter_spec.rb b/spec/lib/banzai/filter/external_issue_reference_filter_spec.rb index 0d0554a2259..a0270d93d50 100644 --- a/spec/lib/banzai/filter/external_issue_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/external_issue_reference_filter_spec.rb @@ -101,15 +101,24 @@ describe Banzai::Filter::ExternalIssueReferenceFilter do context "redmine project" do let(:project) { create(:redmine_project) } - let(:issue) { ExternalIssue.new("#123", project) } - let(:reference) { issue.to_reference } before do - project.issues_enabled = false - project.save! + project.update!(issues_enabled: false) + end + + context "with a hash prefix" do + let(:issue) { ExternalIssue.new("#123", project) } + let(:reference) { issue.to_reference } + + it_behaves_like "external issue tracker" end - it_behaves_like "external issue tracker" + context "with a single-letter prefix" do + let(:issue) { ExternalIssue.new("T-123", project) } + let(:reference) { issue.to_reference } + + it_behaves_like "external issue tracker" + end end context "jira project" do @@ -122,6 +131,15 @@ describe Banzai::Filter::ExternalIssueReferenceFilter do it_behaves_like "external issue tracker" end + context "with a single-letter prefix" do + let(:issue) { ExternalIssue.new("J-123", project) } + + it "ignores reference" do + exp = act = "Issue #{reference}" + expect(filter(act).to_html).to eq exp + end + end + context "with wrong markdown" do let(:issue) { ExternalIssue.new("#123", project) } diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb index 1169938b80c..f1a2946acda 100644 --- a/spec/lib/gitlab/ci/config/entry/job_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb @@ -1,5 +1,4 @@ -require 'fast_spec_helper' -require_dependency 'active_model' +require 'spec_helper' describe Gitlab::Ci::Config::Entry::Job do let(:entry) { described_class.new(config, name: :rspec) } @@ -138,6 +137,36 @@ describe Gitlab::Ci::Config::Entry::Job do end end + context 'when parallel value is not correct' do + context 'when it is not a numeric value' do + let(:config) { { parallel: true } } + + it 'returns error about invalid type' do + expect(entry).not_to be_valid + expect(entry.errors).to include 'job parallel is not a number' + end + end + + context 'when it is lower than two' do + let(:config) { { parallel: 1 } } + + it 'returns error about value too low' do + expect(entry).not_to be_valid + expect(entry.errors) + .to include 'job parallel must be greater than or equal to 2' + end + end + + context 'when it is not an integer' do + let(:config) { { parallel: 1.5 } } + + it 'returns error about wrong value' do + expect(entry).not_to be_valid + expect(entry.errors).to include 'job parallel must be an integer' + end + end + end + context 'when delayed job' do context 'when start_in is specified' do let(:config) { { script: 'echo', when: 'delayed', start_in: '1 day' } } diff --git a/spec/lib/gitlab/ci/config/normalizer_spec.rb b/spec/lib/gitlab/ci/config/normalizer_spec.rb new file mode 100644 index 00000000000..7c558cacdd5 --- /dev/null +++ b/spec/lib/gitlab/ci/config/normalizer_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +describe Gitlab::Ci::Config::Normalizer do + let(:job_name) { :rspec } + let(:job_config) { { script: 'rspec', parallel: 5, name: 'rspec' } } + let(:config) { { job_name => job_config } } + + describe '.normalize_jobs' do + subject { described_class.new(config).normalize_jobs } + + it 'does not have original job' do + is_expected.not_to include(job_name) + end + + it 'has parallelized jobs' do + job_names = [:"rspec 1/5", :"rspec 2/5", :"rspec 3/5", :"rspec 4/5", :"rspec 5/5"] + + is_expected.to include(*job_names) + end + + it 'sets job instance in options' do + expect(subject.values).to all(include(:instance)) + end + + it 'parallelizes jobs with original config' do + original_config = config[job_name].except(:name) + configs = subject.values.map { |config| config.except(:name, :instance) } + + expect(configs).to all(eq(original_config)) + end + + context 'when there is a job with a slash in it' do + let(:job_name) { :"rspec 35/2" } + + it 'properly parallelizes job names' do + job_names = [:"rspec 35/2 1/5", :"rspec 35/2 2/5", :"rspec 35/2 3/5", :"rspec 35/2 4/5", :"rspec 35/2 5/5"] + + is_expected.to include(*job_names) + end + end + + context 'when jobs depend on parallelized jobs' do + let(:config) { { job_name => job_config, other_job: { script: 'echo 1', dependencies: [job_name.to_s] } } } + + it 'parallelizes dependencies' do + job_names = ["rspec 1/5", "rspec 2/5", "rspec 3/5", "rspec 4/5", "rspec 5/5"] + + expect(subject[:other_job][:dependencies]).to include(*job_names) + end + + it 'does not include original job name in dependencies' do + expect(subject[:other_job][:dependencies]).not_to include(job_name) + end + end + end +end diff --git a/spec/lib/gitlab/ci/status/build/scheduled_spec.rb b/spec/lib/gitlab/ci/status/build/scheduled_spec.rb index 4a52b3ab8de..68b87fea75d 100644 --- a/spec/lib/gitlab/ci/status/build/scheduled_spec.rb +++ b/spec/lib/gitlab/ci/status/build/scheduled_spec.rb @@ -13,24 +13,10 @@ describe Gitlab::Ci::Status::Build::Scheduled do end describe '#status_tooltip' do - context 'when scheduled_at is not expired' do - let(:build) { create(:ci_build, scheduled_at: 1.minute.since, project: project) } - - it 'shows execute_in of the scheduled job' do - Timecop.freeze(Time.now.change(usec: 0)) do - expect(subject.status_tooltip).to include('00:01:00') - end - end - end - - context 'when scheduled_at is expired' do - let(:build) { create(:ci_build, :expired_scheduled, project: project) } + let(:build) { create(:ci_build, scheduled_at: 1.minute.since, project: project) } - it 'shows 00:00' do - Timecop.freeze do - expect(subject.status_tooltip).to include('00:00') - end - end + it 'has a placeholder for the remaining time' do + expect(subject.status_tooltip).to include('%{remainingTime}') end end diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index 85b23edce9f..dcfd54107a3 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -645,6 +645,33 @@ module Gitlab end end + describe 'Parallel' do + context 'when job is parallelized' do + let(:parallel) { 5 } + + let(:config) do + YAML.dump(rspec: { script: 'rspec', + parallel: parallel }) + end + + it 'returns parallelized jobs' do + config_processor = Gitlab::Ci::YamlProcessor.new(config) + builds = config_processor.stage_builds_attributes('test') + build_options = builds.map { |build| build[:options] } + + expect(builds.size).to eq(5) + expect(build_options).to all(include(:instance, parallel: parallel)) + end + + it 'does not have the original job' do + config_processor = Gitlab::Ci::YamlProcessor.new(config) + builds = config_processor.stage_builds_attributes('test') + + expect(builds).not_to include(:rspec) + end + end + end + describe 'cache' do context 'when cache definition has unknown keys' do it 'raises relevant validation error' do diff --git a/spec/lib/gitlab/diff/file_spec.rb b/spec/lib/gitlab/diff/file_spec.rb index 2f51642b58e..3417896e259 100644 --- a/spec/lib/gitlab/diff/file_spec.rb +++ b/spec/lib/gitlab/diff/file_spec.rb @@ -41,6 +41,52 @@ describe Gitlab::Diff::File do end end + describe '#unfold_diff_lines' do + let(:unfolded_lines) { double('expanded-lines') } + let(:unfolder) { instance_double(Gitlab::Diff::LinesUnfolder) } + let(:position) { instance_double(Gitlab::Diff::Position, old_line: 10) } + + before do + allow(Gitlab::Diff::LinesUnfolder).to receive(:new) { unfolder } + end + + context 'when unfold required' do + before do + allow(unfolder).to receive(:unfold_required?) { true } + allow(unfolder).to receive(:unfolded_diff_lines) { unfolded_lines } + end + + it 'changes @unfolded to true' do + diff_file.unfold_diff_lines(position) + + expect(diff_file).to be_unfolded + end + + it 'updates @diff_lines' do + diff_file.unfold_diff_lines(position) + + expect(diff_file.diff_lines).to eq(unfolded_lines) + end + end + + context 'when unfold not required' do + before do + allow(unfolder).to receive(:unfold_required?) { false } + end + + it 'keeps @unfolded false' do + diff_file.unfold_diff_lines(position) + + expect(diff_file).not_to be_unfolded + end + + it 'does not update @diff_lines' do + expect { diff_file.unfold_diff_lines(position) } + .not_to change(diff_file, :diff_lines) + end + end + end + describe '#mode_changed?' do it { expect(diff_file.mode_changed?).to be_falsey } end diff --git a/spec/lib/gitlab/diff/lines_unfolder_spec.rb b/spec/lib/gitlab/diff/lines_unfolder_spec.rb new file mode 100644 index 00000000000..8e00c8e0e30 --- /dev/null +++ b/spec/lib/gitlab/diff/lines_unfolder_spec.rb @@ -0,0 +1,750 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Diff::LinesUnfolder do + let(:raw_diff) do + <<-DIFF.strip_heredoc + @@ -7,9 +7,6 @@ + "tags": ["devel", "development", "nightly"], + "desktop-file-name-prefix": "(Development) ", + "finish-args": [ + - "--share=ipc", "--socket=x11", + - "--socket=wayland", + - "--talk-name=org.gnome.OnlineAccounts", + "--talk-name=org.freedesktop.Tracker1", + "--filesystem=home", + "--talk-name=org.gtk.vfs", "--talk-name=org.gtk.vfs.*", + @@ -62,7 +59,7 @@ + }, + { + "name": "gnome-desktop", + - "config-opts": ["--disable-debug-tools", "--disable-udev"], + + "config-opts": ["--disable-debug-tools", "--disable-"], + "sources": [ + { + "type": "git", + @@ -83,11 +80,6 @@ + "buildsystem": "meson", + "builddir": true, + "name": "nautilus", + - "config-opts": [ + - "-Denable-desktop=false", + - "-Denable-selinux=false", + - "--libdir=/app/lib" + - ], + "sources": [ + { + "type": "git", + DIFF + end + + let(:raw_old_blob) do + <<-BLOB.strip_heredoc + { + "app-id": "org.gnome.Nautilus", + "runtime": "org.gnome.Platform", + "runtime-version": "master", + "sdk": "org.gnome.Sdk", + "command": "nautilus", + "tags": ["devel", "development", "nightly"], + "desktop-file-name-prefix": "(Development) ", + "finish-args": [ + "--share=ipc", "--socket=x11", + "--socket=wayland", + "--talk-name=org.gnome.OnlineAccounts", + "--talk-name=org.freedesktop.Tracker1", + "--filesystem=home", + "--talk-name=org.gtk.vfs", "--talk-name=org.gtk.vfs.*", + "--filesystem=xdg-run/dconf", "--filesystem=~/.config/dconf:ro", + "--talk-name=ca.desrt.dconf", "--env=DCONF_USER_CONFIG_DIR=.config/dconf" + ], + "cleanup": [ "/include", "/share/bash-completion" ], + "modules": [ + { + "name": "exiv2", + "sources": [ + { + "type": "archive", + "url": "http://exiv2.org/builds/exiv2-0.26-trunk.tar.gz", + "sha256": "c75e3c4a0811bf700d92c82319373b7a825a2331c12b8b37d41eb58e4f18eafb" + }, + { + "type": "shell", + "commands": [ + "cp -f /usr/share/gnu-config/config.sub ./config/", + "cp -f /usr/share/gnu-config/config.guess ./config/" + ] + } + ] + }, + { + "name": "gexiv2", + "config-opts": [ "--disable-introspection" ], + "sources": [ + { + "type": "git", + "url": "https://git.gnome.org/browse/gexiv2" + } + ] + }, + { + "name": "tracker", + "cleanup": [ "/bin", "/etc", "/libexec" ], + "config-opts": [ "--disable-miner-apps", "--disable-static", + "--disable-tracker-extract", "--disable-tracker-needle", + "--disable-tracker-preferences", "--disable-artwork", + "--disable-tracker-writeback", "--disable-miner-user-guides", + "--with-bash-completion-dir=no" ], + "sources": [ + { + "type": "git", + "url": "https://git.gnome.org/browse/tracker" + } + ] + }, + { + "name": "gnome-desktop", + "config-opts": ["--disable-debug-tools", "--disable-udev"], + "sources": [ + { + "type": "git", + "url": "https://git.gnome.org/browse/gnome-desktop" + } + ] + }, + { + "name": "gnome-autoar", + "sources": [ + { + "type": "git", + "url": "https://git.gnome.org/browse/gnome-autoar" + } + ] + }, + { + "buildsystem": "meson", + "builddir": true, + "name": "nautilus", + "config-opts": [ + "-Denable-desktop=false", + "-Denable-selinux=false", + "--libdir=/app/lib" + ], + "sources": [ + { + "type": "git", + "url": "https://gitlab.gnome.org/GNOME/nautilus.git" + } + ] + } + ] + }, + { + "app-id": "foo", + "runtime": "foo", + "runtime-version": "foo", + "sdk": "foo", + "command": "foo", + "tags": ["foo", "bar", "kux"], + "desktop-file-name-prefix": "(Foo) ", + { + "buildsystem": "meson", + "builddir": true, + "name": "nautilus", + "sources": [ + { + "type": "git", + "url": "https://gitlab.gnome.org/GNOME/nautilus.git" + } + ] + } + }, + { + "app-id": "foo", + "runtime": "foo", + "runtime-version": "foo", + "sdk": "foo", + "command": "foo", + "tags": ["foo", "bar", "kux"], + "desktop-file-name-prefix": "(Foo) ", + { + "buildsystem": "meson", + "builddir": true, + "name": "nautilus", + "sources": [ + { + "type": "git", + "url": "https://gitlab.gnome.org/GNOME/nautilus.git" + } + ] + } + } + BLOB + end + + let(:project) { create(:project) } + + let(:old_blob) { Gitlab::Git::Blob.new(data: raw_old_blob) } + + let(:diff) do + Gitlab::Git::Diff.new(diff: raw_diff, + new_path: "build-aux/flatpak/org.gnome.Nautilus.json", + old_path: "build-aux/flatpak/org.gnome.Nautilus.json", + a_mode: "100644", + b_mode: "100644", + new_file: false, + renamed_file: false, + deleted_file: false, + too_large: false) + end + + let(:diff_file) do + Gitlab::Diff::File.new(diff, repository: project.repository) + end + + before do + allow(old_blob).to receive(:load_all_data!) + allow(diff_file).to receive(:old_blob) { old_blob } + end + + subject { described_class.new(diff_file, position) } + + context 'position requires a middle expansion and new match lines' do + let(:position) do + Gitlab::Diff::Position.new(base_sha: "1c59dfa64afbea8c721bb09a06a9d326c952ea19", + start_sha: "1c59dfa64afbea8c721bb09a06a9d326c952ea19", + head_sha: "1487062132228de836236c522fe52fed4980a46c", + old_path: "build-aux/flatpak/org.gnome.Nautilus.json", + new_path: "build-aux/flatpak/org.gnome.Nautilus.json", + position_type: "text", + old_line: 43, + new_line: 40) + end + + context 'blob lines' do + let(:expected_blob_lines) do + [[40, 40, " \"config-opts\": [ \"--disable-introspection\" ],"], + [41, 41, " \"sources\": ["], + [42, 42, " {"], + [43, 43, " \"type\": \"git\","], + [44, 44, " \"url\": \"https://git.gnome.org/browse/gexiv2\""], + [45, 45, " }"], + [46, 46, " ]"]] + end + + it 'returns the extracted blob lines correctly' do + extracted_lines = subject.blob_lines + + expect(extracted_lines.size).to eq(7) + + extracted_lines.each_with_index do |line, i| + expect([line.old_line, line.new_line, line.text]).to eq(expected_blob_lines[i]) + end + end + end + + context 'diff lines' do + let(:expected_diff_lines) do + [[7, 7, "@@ -7,9 +7,6 @@"], + [7, 7, " \"tags\": [\"devel\", \"development\", \"nightly\"],"], + [8, 8, " \"desktop-file-name-prefix\": \"(Development) \","], + [9, 9, " \"finish-args\": ["], + [10, 10, "- \"--share=ipc\", \"--socket=x11\","], + [11, 10, "- \"--socket=wayland\","], + [12, 10, "- \"--talk-name=org.gnome.OnlineAccounts\","], + [13, 10, " \"--talk-name=org.freedesktop.Tracker1\","], + [14, 11, " \"--filesystem=home\","], + [15, 12, " \"--talk-name=org.gtk.vfs\", \"--talk-name=org.gtk.vfs.*\","], + + # New match line + [40, 37, "@@ -40,7+37,7 @@"], + + # Injected blob lines + [40, 37, " \"config-opts\": [ \"--disable-introspection\" ],"], + [41, 38, " \"sources\": ["], + [42, 39, " {"], + [43, 40, " \"type\": \"git\","], # comment + [44, 41, " \"url\": \"https://git.gnome.org/browse/gexiv2\""], + [45, 42, " }"], + [46, 43, " ]"], + # end + + # Second match line + [62, 59, "@@ -62,7+59,7 @@"], + + [62, 59, " },"], + [63, 60, " {"], + [64, 61, " \"name\": \"gnome-desktop\","], + [65, 62, "- \"config-opts\": [\"--disable-debug-tools\", \"--disable-udev\"],"], + [66, 62, "+ \"config-opts\": [\"--disable-debug-tools\", \"--disable-\"],"], + [66, 63, " \"sources\": ["], + [67, 64, " {"], + [68, 65, " \"type\": \"git\","], + [83, 80, "@@ -83,11 +80,6 @@"], + [83, 80, " \"buildsystem\": \"meson\","], + [84, 81, " \"builddir\": true,"], + [85, 82, " \"name\": \"nautilus\","], + [86, 83, "- \"config-opts\": ["], + [87, 83, "- \"-Denable-desktop=false\","], + [88, 83, "- \"-Denable-selinux=false\","], + [89, 83, "- \"--libdir=/app/lib\""], + [90, 83, "- ],"], + [91, 83, " \"sources\": ["], + [92, 84, " {"], + [93, 85, " \"type\": \"git\","]] + end + + it 'return merge of blob lines with diff lines correctly' do + new_diff_lines = subject.unfolded_diff_lines + + expected_diff_lines.each_with_index do |expected_line, i| + line = new_diff_lines[i] + + expect([line.old_pos, line.new_pos, line.text]) + .to eq([expected_line[0], expected_line[1], expected_line[2]]) + end + end + + it 'merged lines have correct line codes' do + new_diff_lines = subject.unfolded_diff_lines + + new_diff_lines.each_with_index do |line, i| + old_pos, new_pos = expected_diff_lines[i][0], expected_diff_lines[i][1] + + unless line.type == 'match' + expect(line.line_code).to eq(Gitlab::Git.diff_line_code(diff_file.file_path, new_pos, old_pos)) + end + end + end + end + end + + context 'position requires a middle expansion and no top match line' do + let(:position) do + Gitlab::Diff::Position.new(base_sha: "1c59dfa64afbea8c721bb09a06a9d326c952ea19", + start_sha: "1c59dfa64afbea8c721bb09a06a9d326c952ea19", + head_sha: "1487062132228de836236c522fe52fed4980a46c", + old_path: "build-aux/flatpak/org.gnome.Nautilus.json", + new_path: "build-aux/flatpak/org.gnome.Nautilus.json", + position_type: "text", + old_line: 16, + new_line: 17) + end + + context 'blob lines' do + let(:expected_blob_lines) do + [[16, 16, " \"--filesystem=xdg-run/dconf\", \"--filesystem=~/.config/dconf:ro\","], + [17, 17, " \"--talk-name=ca.desrt.dconf\", \"--env=DCONF_USER_CONFIG_DIR=.config/dconf\""], + [18, 18, " ],"], + [19, 19, " \"cleanup\": [ \"/include\", \"/share/bash-completion\" ],"]] + end + + it 'returns the extracted blob lines correctly' do + extracted_lines = subject.blob_lines + + expect(extracted_lines.size).to eq(4) + + extracted_lines.each_with_index do |line, i| + expect([line.old_line, line.new_line, line.text]).to eq(expected_blob_lines[i]) + end + end + end + + context 'diff lines' do + let(:expected_diff_lines) do + [[7, 7, "@@ -7,9 +7,6 @@"], + [7, 7, " \"tags\": [\"devel\", \"development\", \"nightly\"],"], + [8, 8, " \"desktop-file-name-prefix\": \"(Development) \","], + [9, 9, " \"finish-args\": ["], + [10, 10, "- \"--share=ipc\", \"--socket=x11\","], + [11, 10, "- \"--socket=wayland\","], + [12, 10, "- \"--talk-name=org.gnome.OnlineAccounts\","], + [13, 10, " \"--talk-name=org.freedesktop.Tracker1\","], + [14, 11, " \"--filesystem=home\","], + [15, 12, " \"--talk-name=org.gtk.vfs\", \"--talk-name=org.gtk.vfs.*\","], + # No new match needed + + # Injected blob lines + [16, 13, " \"--filesystem=xdg-run/dconf\", \"--filesystem=~/.config/dconf:ro\","], + [17, 14, " \"--talk-name=ca.desrt.dconf\", \"--env=DCONF_USER_CONFIG_DIR=.config/dconf\""], + [18, 15, " ],"], + [19, 16, " \"cleanup\": [ \"/include\", \"/share/bash-completion\" ],"], + # end + + # Second match line + [62, 59, "@@ -62,4+59,4 @@"], + + [62, 59, " },"], + [63, 60, " {"], + [64, 61, " \"name\": \"gnome-desktop\","], + [65, 62, "- \"config-opts\": [\"--disable-debug-tools\", \"--disable-udev\"],"], + [66, 62, "+ \"config-opts\": [\"--disable-debug-tools\", \"--disable-\"],"], + [66, 63, " \"sources\": ["], + [67, 64, " {"], + [68, 65, " \"type\": \"git\","], + [83, 80, "@@ -83,11 +80,6 @@"], + [83, 80, " \"buildsystem\": \"meson\","], + [84, 81, " \"builddir\": true,"], + [85, 82, " \"name\": \"nautilus\","], + [86, 83, "- \"config-opts\": ["], + [87, 83, "- \"-Denable-desktop=false\","], + [88, 83, "- \"-Denable-selinux=false\","], + [89, 83, "- \"--libdir=/app/lib\""], + [90, 83, "- ],"], + [91, 83, " \"sources\": ["], + [92, 84, " {"], + [93, 85, " \"type\": \"git\","]] + end + + it 'return merge of blob lines with diff lines correctly' do + new_diff_lines = subject.unfolded_diff_lines + + expected_diff_lines.each_with_index do |expected_line, i| + line = new_diff_lines[i] + + expect([line.old_pos, line.new_pos, line.text]) + .to eq([expected_line[0], expected_line[1], expected_line[2]]) + end + end + + it 'merged lines have correct line codes' do + new_diff_lines = subject.unfolded_diff_lines + + new_diff_lines.each_with_index do |line, i| + old_pos, new_pos = expected_diff_lines[i][0], expected_diff_lines[i][1] + + unless line.type == 'match' + expect(line.line_code).to eq(Gitlab::Git.diff_line_code(diff_file.file_path, new_pos, old_pos)) + end + end + end + end + end + + context 'position requires a middle expansion and no bottom match line' do + let(:position) do + Gitlab::Diff::Position.new(base_sha: "1c59dfa64afbea8c721bb09a06a9d326c952ea19", + start_sha: "1c59dfa64afbea8c721bb09a06a9d326c952ea19", + head_sha: "1487062132228de836236c522fe52fed4980a46c", + old_path: "build-aux/flatpak/org.gnome.Nautilus.json", + new_path: "build-aux/flatpak/org.gnome.Nautilus.json", + position_type: "text", + old_line: 82, + new_line: 79) + end + + context 'blob lines' do + let(:expected_blob_lines) do + [[79, 79, " }"], + [80, 80, " ]"], + [81, 81, " },"], + [82, 82, " {"]] + end + + it 'returns the extracted blob lines correctly' do + extracted_lines = subject.blob_lines + + expect(extracted_lines.size).to eq(4) + + extracted_lines.each_with_index do |line, i| + expect([line.old_line, line.new_line, line.text]).to eq(expected_blob_lines[i]) + end + end + end + + context 'diff lines' do + let(:expected_diff_lines) do + [[7, 7, "@@ -7,9 +7,6 @@"], + [7, 7, " \"tags\": [\"devel\", \"development\", \"nightly\"],"], + [8, 8, " \"desktop-file-name-prefix\": \"(Development) \","], + [9, 9, " \"finish-args\": ["], + [10, 10, "- \"--share=ipc\", \"--socket=x11\","], + [11, 10, "- \"--socket=wayland\","], + [12, 10, "- \"--talk-name=org.gnome.OnlineAccounts\","], + [13, 10, " \"--talk-name=org.freedesktop.Tracker1\","], + [14, 11, " \"--filesystem=home\","], + [15, 12, " \"--talk-name=org.gtk.vfs\", \"--talk-name=org.gtk.vfs.*\","], + [62, 59, "@@ -62,7 +59,7 @@"], + [62, 59, " },"], + [63, 60, " {"], + [64, 61, " \"name\": \"gnome-desktop\","], + [65, 62, "- \"config-opts\": [\"--disable-debug-tools\", \"--disable-udev\"],"], + [66, 62, "+ \"config-opts\": [\"--disable-debug-tools\", \"--disable-\"],"], + [66, 63, " \"sources\": ["], + [67, 64, " {"], + [68, 65, " \"type\": \"git\","], + + # New top match line + [79, 76, "@@ -79,4+76,4 @@"], + + # Injected blob lines + [79, 76, " }"], + [80, 77, " ]"], + [81, 78, " },"], + [82, 79, " {"], + # end + + # No new second match line + [83, 80, " \"buildsystem\": \"meson\","], + [84, 81, " \"builddir\": true,"], + [85, 82, " \"name\": \"nautilus\","], + [86, 83, "- \"config-opts\": ["], + [87, 83, "- \"-Denable-desktop=false\","], + [88, 83, "- \"-Denable-selinux=false\","], + [89, 83, "- \"--libdir=/app/lib\""], + [90, 83, "- ],"], + [91, 83, " \"sources\": ["], + [92, 84, " {"], + [93, 85, " \"type\": \"git\","]] + end + + it 'return merge of blob lines with diff lines correctly' do + new_diff_lines = subject.unfolded_diff_lines + + expected_diff_lines.each_with_index do |expected_line, i| + line = new_diff_lines[i] + + expect([line.old_pos, line.new_pos, line.text]) + .to eq([expected_line[0], expected_line[1], expected_line[2]]) + end + end + + it 'merged lines have correct line codes' do + new_diff_lines = subject.unfolded_diff_lines + + new_diff_lines.each_with_index do |line, i| + old_pos, new_pos = expected_diff_lines[i][0], expected_diff_lines[i][1] + + unless line.type == 'match' + expect(line.line_code).to eq(Gitlab::Git.diff_line_code(diff_file.file_path, new_pos, old_pos)) + end + end + end + end + end + + context 'position requires a short top expansion' do + let(:position) do + Gitlab::Diff::Position.new(base_sha: "1c59dfa64afbea8c721bb09a06a9d326c952ea19", + start_sha: "1c59dfa64afbea8c721bb09a06a9d326c952ea19", + head_sha: "1487062132228de836236c522fe52fed4980a46c", + old_path: "build-aux/flatpak/org.gnome.Nautilus.json", + new_path: "build-aux/flatpak/org.gnome.Nautilus.json", + position_type: "text", + old_line: 6, + new_line: 6) + end + + context 'blob lines' do + let(:expected_blob_lines) do + [[3, 3, " \"runtime\": \"org.gnome.Platform\","], + [4, 4, " \"runtime-version\": \"master\","], + [5, 5, " \"sdk\": \"org.gnome.Sdk\","], + [6, 6, " \"command\": \"nautilus\","]] + end + + it 'returns the extracted blob lines correctly' do + extracted_lines = subject.blob_lines + + expect(extracted_lines.size).to eq(4) + + extracted_lines.each_with_index do |line, i| + expect([line.old_line, line.new_line, line.text]).to eq(expected_blob_lines[i]) + end + end + end + + context 'diff lines' do + let(:expected_diff_lines) do + # New match line + [[3, 3, "@@ -3,4+3,4 @@"], + + # Injected blob lines + [3, 3, " \"runtime\": \"org.gnome.Platform\","], + [4, 4, " \"runtime-version\": \"master\","], + [5, 5, " \"sdk\": \"org.gnome.Sdk\","], + [6, 6, " \"command\": \"nautilus\","], + # end + [7, 7, " \"tags\": [\"devel\", \"development\", \"nightly\"],"], + [8, 8, " \"desktop-file-name-prefix\": \"(Development) \","], + [9, 9, " \"finish-args\": ["], + [10, 10, "- \"--share=ipc\", \"--socket=x11\","], + [11, 10, "- \"--socket=wayland\","], + [12, 10, "- \"--talk-name=org.gnome.OnlineAccounts\","], + [13, 10, " \"--talk-name=org.freedesktop.Tracker1\","], + [14, 11, " \"--filesystem=home\","], + [15, 12, " \"--talk-name=org.gtk.vfs\", \"--talk-name=org.gtk.vfs.*\","], + [62, 59, "@@ -62,7 +59,7 @@"], + [62, 59, " },"], + [63, 60, " {"], + [64, 61, " \"name\": \"gnome-desktop\","], + [65, 62, "- \"config-opts\": [\"--disable-debug-tools\", \"--disable-udev\"],"], + [66, 62, "+ \"config-opts\": [\"--disable-debug-tools\", \"--disable-\"],"], + [66, 63, " \"sources\": ["], + [67, 64, " {"], + [68, 65, " \"type\": \"git\","], + [83, 80, "@@ -83,11 +80,6 @@"], + [83, 80, " \"buildsystem\": \"meson\","], + [84, 81, " \"builddir\": true,"], + [85, 82, " \"name\": \"nautilus\","], + [86, 83, "- \"config-opts\": ["], + [87, 83, "- \"-Denable-desktop=false\","], + [88, 83, "- \"-Denable-selinux=false\","], + [89, 83, "- \"--libdir=/app/lib\""], + [90, 83, "- ],"], + [91, 83, " \"sources\": ["], + [92, 84, " {"], + [93, 85, " \"type\": \"git\","]] + end + + it 'return merge of blob lines with diff lines correctly' do + new_diff_lines = subject.unfolded_diff_lines + + expected_diff_lines.each_with_index do |expected_line, i| + line = new_diff_lines[i] + + expect([line.old_pos, line.new_pos, line.text]) + .to eq([expected_line[0], expected_line[1], expected_line[2]]) + end + end + + it 'merged lines have correct line codes' do + new_diff_lines = subject.unfolded_diff_lines + + new_diff_lines.each_with_index do |line, i| + old_pos, new_pos = expected_diff_lines[i][0], expected_diff_lines[i][1] + + unless line.type == 'match' + expect(line.line_code).to eq(Gitlab::Git.diff_line_code(diff_file.file_path, new_pos, old_pos)) + end + end + end + end + end + + context 'position sits between two match lines (no expasion needed)' do + let(:position) do + Gitlab::Diff::Position.new(base_sha: "1c59dfa64afbea8c721bb09a06a9d326c952ea19", + start_sha: "1c59dfa64afbea8c721bb09a06a9d326c952ea19", + head_sha: "1487062132228de836236c522fe52fed4980a46c", + old_path: "build-aux/flatpak/org.gnome.Nautilus.json", + new_path: "build-aux/flatpak/org.gnome.Nautilus.json", + position_type: "text", + old_line: 64, + new_line: 61) + end + + context 'diff lines' do + it 'returns nil' do + expect(subject.unfolded_diff_lines).to be_nil + end + end + end + + context 'position requires bottom expansion and new match lines' do + let(:position) do + Gitlab::Diff::Position.new(base_sha: "1c59dfa64afbea8c721bb09a06a9d326c952ea19", + start_sha: "1c59dfa64afbea8c721bb09a06a9d326c952ea19", + head_sha: "1487062132228de836236c522fe52fed4980a46c", + old_path: "build-aux/flatpak/org.gnome.Nautilus.json", + new_path: "build-aux/flatpak/org.gnome.Nautilus.json", + position_type: "text", + old_line: 107, + new_line: 99) + end + + context 'blob lines' do + let(:expected_blob_lines) do + [[104, 104, " \"sdk\": \"foo\","], + [105, 105, " \"command\": \"foo\","], + [106, 106, " \"tags\": [\"foo\", \"bar\", \"kux\"],"], + [107, 107, " \"desktop-file-name-prefix\": \"(Foo) \","], + [108, 108, " {"], + [109, 109, " \"buildsystem\": \"meson\","], + [110, 110, " \"builddir\": true,"]] + end + + it 'returns the extracted blob lines correctly' do + extracted_lines = subject.blob_lines + + expect(extracted_lines.size).to eq(7) + + extracted_lines.each_with_index do |line, i| + expect([line.old_line, line.new_line, line.text]).to eq(expected_blob_lines[i]) + end + end + end + + context 'diff lines' do + let(:expected_diff_lines) do + [[7, 7, "@@ -7,9 +7,6 @@"], + [7, 7, " \"tags\": [\"devel\", \"development\", \"nightly\"],"], + [8, 8, " \"desktop-file-name-prefix\": \"(Development) \","], + [9, 9, " \"finish-args\": ["], + [10, 10, "- \"--share=ipc\", \"--socket=x11\","], + [11, 10, "- \"--socket=wayland\","], + [12, 10, "- \"--talk-name=org.gnome.OnlineAccounts\","], + [13, 10, " \"--talk-name=org.freedesktop.Tracker1\","], + [14, 11, " \"--filesystem=home\","], + [15, 12, " \"--talk-name=org.gtk.vfs\", \"--talk-name=org.gtk.vfs.*\","], + [62, 59, "@@ -62,7 +59,7 @@"], + [62, 59, " },"], + [63, 60, " {"], + [64, 61, " \"name\": \"gnome-desktop\","], + [65, 62, "- \"config-opts\": [\"--disable-debug-tools\", \"--disable-udev\"],"], + [66, 62, "+ \"config-opts\": [\"--disable-debug-tools\", \"--disable-\"],"], + [66, 63, " \"sources\": ["], + [67, 64, " {"], + [68, 65, " \"type\": \"git\","], + [83, 80, "@@ -83,11 +80,6 @@"], + [83, 80, " \"buildsystem\": \"meson\","], + [84, 81, " \"builddir\": true,"], + [85, 82, " \"name\": \"nautilus\","], + [86, 83, "- \"config-opts\": ["], + [87, 83, "- \"-Denable-desktop=false\","], + [88, 83, "- \"-Denable-selinux=false\","], + [89, 83, "- \"--libdir=/app/lib\""], + [90, 83, "- ],"], + [91, 83, " \"sources\": ["], + [92, 84, " {"], + [93, 85, " \"type\": \"git\","], + # New match line + [104, 96, "@@ -104,7+96,7 @@"], + + # Injected blob lines + [104, 96, " \"sdk\": \"foo\","], + [105, 97, " \"command\": \"foo\","], + [106, 98, " \"tags\": [\"foo\", \"bar\", \"kux\"],"], + [107, 99, " \"desktop-file-name-prefix\": \"(Foo) \","], + [108, 100, " {"], + [109, 101, " \"buildsystem\": \"meson\","], + [110, 102, " \"builddir\": true,"]] + # end + end + + it 'return merge of blob lines with diff lines correctly' do + new_diff_lines = subject.unfolded_diff_lines + + expected_diff_lines.each_with_index do |expected_line, i| + line = new_diff_lines[i] + + expect([line.old_pos, line.new_pos, line.text]) + .to eq([expected_line[0], expected_line[1], expected_line[2]]) + end + end + + it 'merged lines have correct line codes' do + new_diff_lines = subject.unfolded_diff_lines + + new_diff_lines.each_with_index do |line, i| + old_pos, new_pos = expected_diff_lines[i][0], expected_diff_lines[i][1] + + unless line.type == 'match' + expect(line.line_code).to eq(Gitlab::Git.diff_line_code(diff_file.file_path, new_pos, old_pos)) + end + end + end + end + end +end diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 9a443fa7f20..54291e847d8 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -476,6 +476,27 @@ describe Gitlab::Git::Repository, :seed_helper do end end + describe '#fetch_remote' do + it 'delegates to the gitaly RepositoryService' do + ssh_auth = double(:ssh_auth) + expected_opts = { + ssh_auth: ssh_auth, + forced: true, + no_tags: true, + timeout: described_class::GITLAB_PROJECTS_TIMEOUT, + prune: false + } + + expect(repository.gitaly_repository_client).to receive(:fetch_remote).with('remote-name', expected_opts) + + repository.fetch_remote('remote-name', ssh_auth: ssh_auth, forced: true, no_tags: true, prune: false) + end + + it_behaves_like 'wrapping gRPC errors', Gitlab::GitalyClient::RepositoryService, :fetch_remote do + subject { repository.fetch_remote('remote-name') } + end + end + describe '#find_remote_root_ref' do it 'gets the remote root ref from GitalyClient' do expect_any_instance_of(Gitlab::GitalyClient::RemoteService) diff --git a/spec/lib/gitlab/gitaly_client/repository_service_spec.rb b/spec/lib/gitlab/gitaly_client/repository_service_spec.rb index 1547d447197..d605fcbafee 100644 --- a/spec/lib/gitlab/gitaly_client/repository_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/repository_service_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Gitlab::GitalyClient::RepositoryService do + using RSpec::Parameterized::TableSyntax + let(:project) { create(:project) } let(:storage_name) { project.repository_storage } let(:relative_path) { project.disk_path + '.git' } @@ -107,16 +109,67 @@ describe Gitlab::GitalyClient::RepositoryService do end describe '#fetch_remote' do - let(:ssh_auth) { double(:ssh_auth, ssh_import?: true, ssh_key_auth?: false, ssh_known_hosts: nil) } - let(:import_url) { 'ssh://example.com' } + let(:remote) { 'remote-name' } it 'sends a fetch_remote_request message' do + expected_request = gitaly_request_with_params( + remote: remote, + ssh_key: '', + known_hosts: '', + force: false, + no_tags: false, + no_prune: false + ) + expect_any_instance_of(Gitaly::RepositoryService::Stub) .to receive(:fetch_remote) - .with(gitaly_request_with_params(no_prune: false), kind_of(Hash)) + .with(expected_request, kind_of(Hash)) .and_return(double(value: true)) - client.fetch_remote(import_url, ssh_auth: ssh_auth, forced: false, no_tags: false, timeout: 60) + client.fetch_remote(remote, ssh_auth: nil, forced: false, no_tags: false, timeout: 1) + end + + context 'SSH auth' do + where(:ssh_import, :ssh_key_auth, :ssh_private_key, :ssh_known_hosts, :expected_params) do + false | false | 'key' | 'known_hosts' | {} + false | true | 'key' | 'known_hosts' | {} + true | false | 'key' | 'known_hosts' | { known_hosts: 'known_hosts' } + true | true | 'key' | 'known_hosts' | { ssh_key: 'key', known_hosts: 'known_hosts' } + true | true | 'key' | nil | { ssh_key: 'key' } + true | true | nil | 'known_hosts' | { known_hosts: 'known_hosts' } + true | true | nil | nil | {} + true | true | '' | '' | {} + end + + with_them do + let(:ssh_auth) do + double( + :ssh_auth, + ssh_import?: ssh_import, + ssh_key_auth?: ssh_key_auth, + ssh_private_key: ssh_private_key, + ssh_known_hosts: ssh_known_hosts + ) + end + + it do + expected_request = gitaly_request_with_params({ + remote: remote, + ssh_key: '', + known_hosts: '', + force: false, + no_tags: false, + no_prune: false + }.update(expected_params)) + + expect_any_instance_of(Gitaly::RepositoryService::Stub) + .to receive(:fetch_remote) + .with(expected_request, kind_of(Hash)) + .and_return(double(value: true)) + + client.fetch_remote(remote, ssh_auth: ssh_auth, forced: false, no_tags: false, timeout: 1) + end + end end end diff --git a/spec/lib/gitlab/shell_spec.rb b/spec/lib/gitlab/shell_spec.rb index b1b7c427313..6ce9d515a0f 100644 --- a/spec/lib/gitlab/shell_spec.rb +++ b/spec/lib/gitlab/shell_spec.rb @@ -498,126 +498,6 @@ describe Gitlab::Shell do end end - describe '#fetch_remote' do - def fetch_remote(ssh_auth = nil, prune = true) - gitlab_shell.fetch_remote(repository.raw_repository, 'remote-name', ssh_auth: ssh_auth, prune: prune) - end - - def expect_call(fail, options = {}) - receive_fetch_remote = - if fail - receive(:fetch_remote).and_raise(GRPC::NotFound) - else - receive(:fetch_remote).and_return(true) - end - - expect_any_instance_of(Gitlab::GitalyClient::RepositoryService).to receive_fetch_remote - end - - def build_ssh_auth(opts = {}) - defaults = { - ssh_import?: true, - ssh_key_auth?: false, - ssh_known_hosts: nil, - ssh_private_key: nil - } - - double(:ssh_auth, defaults.merge(opts)) - end - - it 'returns true when the command succeeds' do - expect_call(false, force: false, tags: true, prune: true) - - expect(fetch_remote).to be_truthy - end - - it 'returns true when the command succeeds' do - expect_call(false, force: false, tags: true, prune: false) - - expect(fetch_remote(nil, false)).to be_truthy - end - - it 'raises an exception when the command fails' do - expect_call(true, force: false, tags: true, prune: true) - - expect { fetch_remote }.to raise_error(Gitlab::Shell::Error) - end - - it 'allows forced and no_tags to be changed' do - expect_call(false, force: true, tags: false, prune: true) - - result = gitlab_shell.fetch_remote(repository.raw_repository, 'remote-name', forced: true, no_tags: true, prune: true) - expect(result).to be_truthy - end - - context 'SSH auth' do - it 'passes the SSH key if specified' do - expect_call(false, force: false, tags: true, prune: true, ssh_key: 'foo') - - ssh_auth = build_ssh_auth(ssh_key_auth?: true, ssh_private_key: 'foo') - - expect(fetch_remote(ssh_auth)).to be_truthy - end - - it 'does not pass an empty SSH key' do - expect_call(false, force: false, tags: true, prune: true) - - ssh_auth = build_ssh_auth(ssh_key_auth: true, ssh_private_key: '') - - expect(fetch_remote(ssh_auth)).to be_truthy - end - - it 'does not pass the key unless SSH key auth is to be used' do - expect_call(false, force: false, tags: true, prune: true) - - ssh_auth = build_ssh_auth(ssh_key_auth: false, ssh_private_key: 'foo') - - expect(fetch_remote(ssh_auth)).to be_truthy - end - - it 'passes the known_hosts data if specified' do - expect_call(false, force: false, tags: true, prune: true, known_hosts: 'foo') - - ssh_auth = build_ssh_auth(ssh_known_hosts: 'foo') - - expect(fetch_remote(ssh_auth)).to be_truthy - end - - it 'does not pass empty known_hosts data' do - expect_call(false, force: false, tags: true, prune: true) - - ssh_auth = build_ssh_auth(ssh_known_hosts: '') - - expect(fetch_remote(ssh_auth)).to be_truthy - end - - it 'does not pass known_hosts data unless SSH is to be used' do - expect_call(false, force: false, tags: true, prune: true) - - ssh_auth = build_ssh_auth(ssh_import?: false, ssh_known_hosts: 'foo') - - expect(fetch_remote(ssh_auth)).to be_truthy - end - end - - context 'gitaly call' do - let(:remote_name) { 'remote-name' } - let(:ssh_auth) { double(:ssh_auth) } - - subject do - gitlab_shell.fetch_remote(repository.raw_repository, remote_name, - forced: true, no_tags: true, ssh_auth: ssh_auth) - end - - it 'passes the correct params to the gitaly service' do - expect(repository.gitaly_repository_client).to receive(:fetch_remote) - .with(remote_name, ssh_auth: ssh_auth, forced: true, no_tags: true, prune: true, timeout: timeout) - - subject - end - end - end - describe '#import_repository' do let(:import_url) { 'https://gitlab.com/gitlab-org/gitlab-ce.git' } diff --git a/spec/lib/gitlab/utils_spec.rb b/spec/lib/gitlab/utils_spec.rb index 4ba99009855..ad2c9d7f2af 100644 --- a/spec/lib/gitlab/utils_spec.rb +++ b/spec/lib/gitlab/utils_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Gitlab::Utils do delegate :to_boolean, :boolean_to_yes_no, :slugify, :random_string, :which, :ensure_array_from_string, - :bytes_to_megabytes, to: :described_class + :bytes_to_megabytes, :append_path, to: :described_class describe '.slugify' do { @@ -106,4 +106,25 @@ describe Gitlab::Utils do expect(bytes_to_megabytes(bytes)).to eq(1) end end + + describe '.append_path' do + using RSpec::Parameterized::TableSyntax + + where(:host, :path, :result) do + 'http://test/' | '/foo/bar' | 'http://test/foo/bar' + 'http://test/' | '//foo/bar' | 'http://test/foo/bar' + 'http://test//' | '/foo/bar' | 'http://test/foo/bar' + 'http://test' | 'foo/bar' | 'http://test/foo/bar' + 'http://test//' | '' | 'http://test/' + 'http://test//' | nil | 'http://test/' + '' | '/foo/bar' | '/foo/bar' + nil | '/foo/bar' | '/foo/bar' + end + + with_them do + it 'makes sure there is only one slash as path separator' do + expect(append_path(host, path)).to eq(result) + end + end + end end diff --git a/spec/migrations/steal_fill_store_upload_spec.rb b/spec/migrations/steal_fill_store_upload_spec.rb new file mode 100644 index 00000000000..ed809baf2b5 --- /dev/null +++ b/spec/migrations/steal_fill_store_upload_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20181105201455_steal_fill_store_upload.rb') + +describe StealFillStoreUpload, :migration do + let(:uploads) { table(:uploads) } + + describe '#up' do + it 'steals the FillStoreUpload background migration' do + expect(Gitlab::BackgroundMigration).to receive(:steal).with('FillStoreUpload').and_call_original + + migrate! + end + + it 'does not run migration if not needed' do + uploads.create(size: 100.kilobytes, + uploader: 'AvatarUploader', + path: 'uploads/-/system/avatar.jpg', + store: 1) + + expect_any_instance_of(Gitlab::BackgroundMigration::FillStoreUpload).not_to receive(:perform) + + migrate! + end + + it 'ensures all rows are migrated' do + uploads.create(size: 100.kilobytes, + uploader: 'AvatarUploader', + path: 'uploads/-/system/avatar.jpg', + store: nil) + + expect_any_instance_of(Gitlab::BackgroundMigration::FillStoreUpload).to receive(:perform).and_call_original + + expect do + migrate! + end.to change { uploads.where(store: nil).count }.from(1).to(0) + end + end +end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 2e65a6a2a0f..5bd2f096656 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -2015,6 +2015,7 @@ describe Ci::Build do { key: 'CI_COMMIT_BEFORE_SHA', value: build.before_sha, public: true }, { key: 'CI_COMMIT_REF_NAME', value: build.ref, public: true }, { key: 'CI_COMMIT_REF_SLUG', value: build.ref_slug, public: true }, + { key: 'CI_NODE_TOTAL', value: '1', public: true }, { key: 'CI_BUILD_REF', value: build.sha, public: true }, { key: 'CI_BUILD_BEFORE_SHA', value: build.before_sha, public: true }, { key: 'CI_BUILD_REF_NAME', value: build.ref, public: true }, @@ -2476,6 +2477,29 @@ describe Ci::Build do end end + context 'when build is parallelized' do + let(:total) { 5 } + let(:index) { 3 } + + before do + build.options[:parallel] = total + build.options[:instance] = index + build.name = "#{build.name} #{index}/#{total}" + end + + it 'includes CI_NODE_INDEX' do + is_expected.to include( + { key: 'CI_NODE_INDEX', value: index.to_s, public: true } + ) + end + + it 'includes correct CI_NODE_TOTAL' do + is_expected.to include( + { key: 'CI_NODE_TOTAL', value: total.to_s, public: true } + ) + end + end + describe 'variables ordering' do context 'when variables hierarchy is stubbed' do let(:build_pre_var) { { key: 'build', value: 'value', public: true } } diff --git a/spec/models/compare_spec.rb b/spec/models/compare_spec.rb index 8e88bb81162..0bc3ee014e6 100644 --- a/spec/models/compare_spec.rb +++ b/spec/models/compare_spec.rb @@ -92,4 +92,33 @@ describe Compare do expect(subject.diff_refs.head_sha).to eq(head_commit.id) end end + + describe '#modified_paths' do + context 'changes are present' do + let(:raw_compare) do + Gitlab::Git::Compare.new( + project.repository.raw_repository, 'before-create-delete-modify-move', 'after-create-delete-modify-move' + ) + end + + it 'returns affected file paths, without duplication' do + expect(subject.modified_paths).to contain_exactly(*%w{ + foo/for_move.txt + foo/bar/for_move.txt + foo/for_create.txt + foo/for_delete.txt + foo/for_edit.txt + }) + end + end + + context 'changes are absent' do + let(:start_commit) { sample_commit } + let(:head_commit) { sample_commit } + + it 'returns empty array' do + expect(subject.modified_paths).to eq([]) + end + end + end end diff --git a/spec/models/concerns/each_batch_spec.rb b/spec/models/concerns/each_batch_spec.rb index 951690a217b..17224c09693 100644 --- a/spec/models/concerns/each_batch_spec.rb +++ b/spec/models/concerns/each_batch_spec.rb @@ -14,40 +14,45 @@ describe EachBatch do 5.times { create(:user, updated_at: 1.day.ago) } end - it 'yields an ActiveRecord::Relation when a block is given' do - model.each_batch do |relation| - expect(relation).to be_a_kind_of(ActiveRecord::Relation) + shared_examples 'each_batch handling' do |kwargs| + it 'yields an ActiveRecord::Relation when a block is given' do + model.each_batch(kwargs) do |relation| + expect(relation).to be_a_kind_of(ActiveRecord::Relation) + end end - end - it 'yields a batch index as the second argument' do - model.each_batch do |_, index| - expect(index).to eq(1) + it 'yields a batch index as the second argument' do + model.each_batch(kwargs) do |_, index| + expect(index).to eq(1) + end end - end - it 'accepts a custom batch size' do - amount = 0 + it 'accepts a custom batch size' do + amount = 0 - model.each_batch(of: 1) { amount += 1 } + model.each_batch(kwargs.merge({ of: 1 })) { amount += 1 } - expect(amount).to eq(5) - end + expect(amount).to eq(5) + end - it 'does not include ORDER BYs in the yielded relations' do - model.each_batch do |relation| - expect(relation.to_sql).not_to include('ORDER BY') + it 'does not include ORDER BYs in the yielded relations' do + model.each_batch do |relation| + expect(relation.to_sql).not_to include('ORDER BY') + end end - end - it 'allows updating of the yielded relations' do - time = Time.now + it 'allows updating of the yielded relations' do + time = Time.now - model.each_batch do |relation| - relation.update_all(updated_at: time) - end + model.each_batch do |relation| + relation.update_all(updated_at: time) + end - expect(model.where(updated_at: time).count).to eq(5) + expect(model.where(updated_at: time).count).to eq(5) + end end + + it_behaves_like 'each_batch handling', {} + it_behaves_like 'each_batch handling', { order_hint: :updated_at } end end diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb index 06c1e9c8c6a..270b2767c68 100644 --- a/spec/models/deployment_spec.rb +++ b/spec/models/deployment_spec.rb @@ -88,7 +88,7 @@ describe Deployment do deployment.succeed! expect(deployment).to be_success - expect(deployment.finished_at).to eq(Time.now) + expect(deployment.finished_at).to be_like_time(Time.now) end end @@ -108,7 +108,7 @@ describe Deployment do deployment.drop! expect(deployment).to be_failed - expect(deployment.finished_at).to eq(Time.now) + expect(deployment.finished_at).to be_like_time(Time.now) end end end @@ -121,7 +121,7 @@ describe Deployment do deployment.cancel! expect(deployment).to be_canceled - expect(deployment.finished_at).to eq(Time.now) + expect(deployment.finished_at).to be_like_time(Time.now) end end end diff --git a/spec/models/merge_request_diff_spec.rb b/spec/models/merge_request_diff_spec.rb index 47e8f04e728..cbe60b3a4a5 100644 --- a/spec/models/merge_request_diff_spec.rb +++ b/spec/models/merge_request_diff_spec.rb @@ -232,4 +232,17 @@ describe MergeRequestDiff do expect(commits.map(&:sha)).to match_array(commit_shas) end end + + describe '#modified_paths' do + subject do + diff = create(:merge_request_diff) + create(:merge_request_diff_file, :new_file, merge_request_diff: diff) + create(:merge_request_diff_file, :renamed_file, merge_request_diff: diff) + diff + end + + it 'returns affected file paths' do + expect(subject.modified_paths).to eq(%w{foo bar baz}) + end + end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 3a54725c7ec..c7202b481d3 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -631,6 +631,44 @@ describe MergeRequest do end end + describe '#modified_paths' do + let(:paths) { double(:paths) } + subject(:merge_request) { build(:merge_request) } + + before do + expect(diff).to receive(:modified_paths).and_return(paths) + end + + context 'when past_merge_request_diff is specified' do + let(:another_diff) { double(:merge_request_diff) } + let(:diff) { another_diff } + + it 'returns affected file paths from specified past_merge_request_diff' do + expect(merge_request.modified_paths(past_merge_request_diff: another_diff)).to eq(paths) + end + end + + context 'when compare is present' do + let(:compare) { double(:compare) } + let(:diff) { compare } + + it 'returns affected file paths from compare' do + merge_request.compare = compare + + expect(merge_request.modified_paths).to eq(paths) + end + end + + context 'when no arguments provided' do + let(:diff) { merge_request.merge_request_diff } + subject(:merge_request) { create(:merge_request, source_branch: 'feature', target_branch: 'master') } + + it 'returns affected file paths for merge_request_diff' do + expect(merge_request.modified_paths).to eq(paths) + end + end + end + describe "#related_notes" do let!(:merge_request) { create(:merge_request) } diff --git a/spec/models/upload_spec.rb b/spec/models/upload_spec.rb index 3c89e99abf0..5a0df9fbbb0 100644 --- a/spec/models/upload_spec.rb +++ b/spec/models/upload_spec.rb @@ -21,7 +21,8 @@ describe Upload do path: __FILE__, size: described_class::CHECKSUM_THRESHOLD + 1.kilobyte, model: build_stubbed(:user), - uploader: double('ExampleUploader') + uploader: double('ExampleUploader'), + store: ObjectStorage::Store::LOCAL ) expect(UploadChecksumWorker) @@ -35,7 +36,8 @@ describe Upload do path: __FILE__, size: described_class::CHECKSUM_THRESHOLD, model: build_stubbed(:user), - uploader: double('ExampleUploader') + uploader: double('ExampleUploader'), + store: ObjectStorage::Store::LOCAL ) expect { upload.save } @@ -60,7 +62,7 @@ describe Upload do describe '#absolute_path' do it 'returns the path directly when already absolute' do path = '/path/to/namespace/project/secret/file.jpg' - upload = described_class.new(path: path) + upload = described_class.new(path: path, store: ObjectStorage::Store::LOCAL) expect(upload).not_to receive(:uploader_class) @@ -69,7 +71,7 @@ describe Upload do it "delegates to the uploader's absolute_path method" do uploader = spy('FakeUploader') - upload = described_class.new(path: 'secret/file.jpg') + upload = described_class.new(path: 'secret/file.jpg', store: ObjectStorage::Store::LOCAL) expect(upload).to receive(:uploader_class).and_return(uploader) upload.absolute_path @@ -81,7 +83,8 @@ describe Upload do describe '#calculate_checksum!' do let(:upload) do described_class.new(path: __FILE__, - size: described_class::CHECKSUM_THRESHOLD - 1.megabyte) + size: described_class::CHECKSUM_THRESHOLD - 1.megabyte, + store: ObjectStorage::Store::LOCAL) end it 'sets `checksum` to SHA256 sum of the file' do @@ -104,15 +107,56 @@ describe Upload do describe '#exist?' do it 'returns true when the file exists' do - upload = described_class.new(path: __FILE__) + upload = described_class.new(path: __FILE__, store: ObjectStorage::Store::LOCAL) expect(upload).to exist end - it 'returns false when the file does not exist' do - upload = described_class.new(path: "#{__FILE__}-nope") + context 'when the file does not exist' do + it 'returns false' do + upload = described_class.new(path: "#{__FILE__}-nope", store: ObjectStorage::Store::LOCAL) - expect(upload).not_to exist + expect(upload).not_to exist + end + + context 'when the record is persisted' do + it 'sends a message to Sentry' do + upload = create(:upload, :issuable_upload) + + expect(Gitlab::Sentry).to receive(:enabled?).and_return(true) + expect(Raven).to receive(:capture_message).with("Upload file does not exist", extra: upload.attributes) + + upload.exist? + end + + it 'increments a metric counter to signal a problem' do + upload = create(:upload, :issuable_upload) + + counter = double(:counter) + expect(counter).to receive(:increment) + expect(Gitlab::Metrics).to receive(:counter).with(:upload_file_does_not_exist_total, 'The number of times an upload record could not find its file').and_return(counter) + + upload.exist? + end + end + + context 'when the record is not persisted' do + it 'does not send a message to Sentry' do + upload = described_class.new(path: "#{__FILE__}-nope", store: ObjectStorage::Store::LOCAL) + + expect(Raven).not_to receive(:capture_message) + + upload.exist? + end + + it 'does not increment a metric counter' do + upload = described_class.new(path: "#{__FILE__}-nope", store: ObjectStorage::Store::LOCAL) + + expect(Gitlab::Metrics).not_to receive(:counter) + + upload.exist? + end + end end end diff --git a/spec/models/wiki_page_spec.rb b/spec/models/wiki_page_spec.rb index 821946a47e5..b87a2d871e5 100644 --- a/spec/models/wiki_page_spec.rb +++ b/spec/models/wiki_page_spec.rb @@ -126,23 +126,34 @@ describe WikiPage do end end - before do - @wiki_attr = { title: "Index", content: "Home Page", format: "markdown" } - end - describe "#create" do + let(:wiki_attr) do + { + title: "Index", + content: "Home Page", + format: "markdown", + message: 'Custom Commit Message' + } + end + after do destroy_page("Index") end context "with valid attributes" do it "saves the wiki page" do - subject.create(@wiki_attr) + subject.create(wiki_attr) expect(wiki.find_page("Index")).not_to be_nil end it "returns true" do - expect(subject.create(@wiki_attr)).to eq(true) + expect(subject.create(wiki_attr)).to eq(true) + end + + it 'saves the wiki page with message' do + subject.create(wiki_attr) + + expect(wiki.find_page("Index").message).to eq 'Custom Commit Message' end end end diff --git a/spec/serializers/issue_board_entity_spec.rb b/spec/serializers/issue_board_entity_spec.rb new file mode 100644 index 00000000000..06d9d3657e6 --- /dev/null +++ b/spec/serializers/issue_board_entity_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe IssueBoardEntity do + let(:project) { create(:project) } + let(:resource) { create(:issue, project: project) } + let(:user) { create(:user) } + + let(:request) { double('request', current_user: user) } + + subject { described_class.new(resource, request: request).as_json } + + it 'has basic attributes' do + expect(subject).to include(:id, :iid, :title, :confidential, :due_date, :project_id, :relative_position, + :project, :labels) + end + + it 'has path and endpoints' do + expect(subject).to include(:reference_path, :real_path, :issue_sidebar_endpoint, + :toggle_subscription_endpoint, :assignable_labels_endpoint) + end +end diff --git a/spec/serializers/issue_serializer_spec.rb b/spec/serializers/issue_serializer_spec.rb index 75578816e75..e8c46c0cdee 100644 --- a/spec/serializers/issue_serializer_spec.rb +++ b/spec/serializers/issue_serializer_spec.rb @@ -24,4 +24,12 @@ describe IssueSerializer do expect(json_entity).to match_schema('entities/issue_sidebar') end end + + context 'board issue serialization' do + let(:serializer) { 'board' } + + it 'matches board issue json schema' do + expect(json_entity).to match_schema('entities/issue_board') + end + end end diff --git a/spec/services/issuable/bulk_update_service_spec.rb b/spec/services/issuable/bulk_update_service_spec.rb index 53c85f73cde..f0b0f7956ce 100644 --- a/spec/services/issuable/bulk_update_service_spec.rb +++ b/spec/services/issuable/bulk_update_service_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Issuable::BulkUpdateService do let(:user) { create(:user) } - let(:project) { create(:project, namespace: user.namespace) } + let(:project) { create(:project, :repository, namespace: user.namespace) } def bulk_update(issuables, extra_params = {}) bulk_update_params = extra_params diff --git a/spec/services/merge_requests/reload_diffs_service_spec.rb b/spec/services/merge_requests/reload_diffs_service_spec.rb index 546c9f277c5..5acd01828cb 100644 --- a/spec/services/merge_requests/reload_diffs_service_spec.rb +++ b/spec/services/merge_requests/reload_diffs_service_spec.rb @@ -31,32 +31,11 @@ describe MergeRequests::ReloadDiffsService, :use_clean_rails_memory_store_cachin end context 'cache clearing' do - before do - allow_any_instance_of(Gitlab::Diff::File).to receive(:text?).and_return(true) - allow_any_instance_of(Gitlab::Diff::File).to receive(:diffable?).and_return(true) - end - - it 'retrieves the diff files to cache the highlighted result' do - new_diff = merge_request.create_merge_request_diff - cache_key = new_diff.diffs_collection.cache_key - - expect(merge_request).to receive(:create_merge_request_diff).and_return(new_diff) - expect(Rails.cache).to receive(:read).with(cache_key).and_call_original - expect(Rails.cache).to receive(:write).with(cache_key, anything, anything).and_call_original - - subject.execute - end - it 'clears the cache for older diffs on the merge request' do old_diff = merge_request.merge_request_diff old_cache_key = old_diff.diffs_collection.cache_key - new_diff = merge_request.create_merge_request_diff - new_cache_key = new_diff.diffs_collection.cache_key - expect(merge_request).to receive(:create_merge_request_diff).and_return(new_diff) expect(Rails.cache).to receive(:delete).with(old_cache_key).and_call_original - expect(Rails.cache).to receive(:read).with(new_cache_key).and_call_original - expect(Rails.cache).to receive(:write).with(new_cache_key, anything, anything).and_call_original subject.execute end diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index 1b599ba11b6..be5ad849ba7 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -593,8 +593,8 @@ describe MergeRequests::UpdateService, :mailer do end context 'setting `allow_collaboration`' do - let(:target_project) { create(:project, :public) } - let(:source_project) { fork_project(target_project) } + let(:target_project) { create(:project, :repository, :public) } + let(:source_project) { fork_project(target_project, nil, repository: true) } let(:user) { create(:user) } let(:merge_request) do create(:merge_request, diff --git a/spec/services/milestones/destroy_service_spec.rb b/spec/services/milestones/destroy_service_spec.rb index 8680e428517..9d2be30c636 100644 --- a/spec/services/milestones/destroy_service_spec.rb +++ b/spec/services/milestones/destroy_service_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Milestones::DestroyService do let(:user) { create(:user) } - let(:project) { create(:project) } + let(:project) { create(:project, :repository) } let(:milestone) { create(:milestone, title: 'Milestone v1.0', project: project) } before do diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb index b1290fd0d47..80b015d4cd0 100644 --- a/spec/services/notes/create_service_spec.rb +++ b/spec/services/notes/create_service_spec.rb @@ -57,6 +57,57 @@ describe Notes::CreateService do end end + context 'noteable highlight cache clearing' do + let(:project_with_repo) { create(:project, :repository) } + let(:merge_request) do + create(:merge_request, source_project: project_with_repo, + target_project: project_with_repo) + end + + let(:position) do + Gitlab::Diff::Position.new(old_path: "files/ruby/popen.rb", + new_path: "files/ruby/popen.rb", + old_line: nil, + new_line: 14, + diff_refs: merge_request.diff_refs) + end + + let(:new_opts) do + opts.merge(in_reply_to_discussion_id: nil, + type: 'DiffNote', + noteable_type: 'MergeRequest', + noteable_id: merge_request.id, + position: position.to_h) + end + + before do + allow_any_instance_of(Gitlab::Diff::Position) + .to receive(:unfolded_diff?) { true } + end + + it 'clears noteable diff cache when it was unfolded for the note position' do + expect_any_instance_of(Gitlab::Diff::HighlightCache).to receive(:clear) + + described_class.new(project_with_repo, user, new_opts).execute + end + + it 'does not clear cache when note is not the first of the discussion' do + prev_note = + create(:diff_note_on_merge_request, noteable: merge_request, + project: project_with_repo) + reply_opts = + opts.merge(in_reply_to_discussion_id: prev_note.discussion_id, + type: 'DiffNote', + noteable_type: 'MergeRequest', + noteable_id: merge_request.id, + position: position.to_h) + + expect(merge_request).not_to receive(:diffs) + + described_class.new(project_with_repo, user, reply_opts).execute + end + end + context 'note diff file' do let(:project_with_repo) { create(:project, :repository) } let(:merge_request) do diff --git a/spec/services/notes/destroy_service_spec.rb b/spec/services/notes/destroy_service_spec.rb index 64445be560e..b1f4e87e8ea 100644 --- a/spec/services/notes/destroy_service_spec.rb +++ b/spec/services/notes/destroy_service_spec.rb @@ -21,5 +21,38 @@ describe Notes::DestroyService do expect { described_class.new(project, user).execute(note) } .to change { user.todos_pending_count }.from(1).to(0) end + + context 'noteable highlight cache clearing' do + let(:repo_project) { create(:project, :repository) } + let(:merge_request) do + create(:merge_request, source_project: repo_project, + target_project: repo_project) + end + + let(:note) do + create(:diff_note_on_merge_request, project: repo_project, + noteable: merge_request) + end + + before do + allow(note.position).to receive(:unfolded_diff?) { true } + end + + it 'clears noteable diff cache when it was unfolded for the note position' do + expect(merge_request).to receive_message_chain(:diffs, :clear_cache) + + described_class.new(repo_project, user).execute(note) + end + + it 'does not clear cache when note is not the first of the discussion' do + reply_note = create(:diff_note_on_merge_request, in_reply_to: note, + project: repo_project, + noteable: merge_request) + + expect(merge_request).not_to receive(:diffs) + + described_class.new(repo_project, user).execute(reply_note) + end + end end end diff --git a/spec/services/notes/quick_actions_service_spec.rb b/spec/services/notes/quick_actions_service_spec.rb index a8c994c101c..14d62763a5b 100644 --- a/spec/services/notes/quick_actions_service_spec.rb +++ b/spec/services/notes/quick_actions_service_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Notes::QuickActionsService do shared_context 'note on noteable' do - let(:project) { create(:project) } + let(:project) { create(:project, :repository) } let(:maintainer) { create(:user).tap { |u| project.add_maintainer(u) } } let(:assignee) { create(:user) } diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb index 1746721b0d0..c52515aefd8 100644 --- a/spec/services/todo_service_spec.rb +++ b/spec/services/todo_service_spec.rb @@ -10,7 +10,7 @@ describe TodoService do let(:john_doe) { create(:user) } let(:skipped) { create(:user) } let(:skip_users) { [skipped] } - let(:project) { create(:project) } + let(:project) { create(:project, :repository) } let(:mentions) { 'FYI: ' + [author, assignee, john_doe, member, guest, non_member, admin, skipped].map(&:to_reference).join(' ') } let(:directly_addressed) { [author, assignee, john_doe, member, guest, non_member, admin, skipped].map(&:to_reference).join(' ') } let(:directly_addressed_and_mentioned) { member.to_reference + ", what do you think? cc: " + [guest, admin, skipped].map(&:to_reference).join(' ') } diff --git a/spec/support/features/reportable_note_shared_examples.rb b/spec/support/features/reportable_note_shared_examples.rb index 89a5518239d..8cfce49da8a 100644 --- a/spec/support/features/reportable_note_shared_examples.rb +++ b/spec/support/features/reportable_note_shared_examples.rb @@ -20,7 +20,7 @@ shared_examples 'reportable note' do |type| dropdown = comment.find(more_actions_selector) open_dropdown(dropdown) - expect(dropdown).to have_link('Report as abuse', href: abuse_report_path) + expect(dropdown).to have_link('Report abuse to GitLab', href: abuse_report_path) if type == 'issue' || type == 'merge_request' expect(dropdown).to have_button('Delete comment') @@ -33,7 +33,7 @@ shared_examples 'reportable note' do |type| dropdown = comment.find(more_actions_selector) open_dropdown(dropdown) - dropdown.click_link('Report as abuse') + dropdown.click_link('Report abuse to GitLab') expect(find('#user_name')['value']).to match(note.author.username) expect(find('#abuse_report_message')['value']).to match(noteable_note_url(note)) diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb index 71d72ff27e9..80b96f20e3f 100644 --- a/spec/support/helpers/test_env.rb +++ b/spec/support/helpers/test_env.rb @@ -55,6 +55,9 @@ module TestEnv 'update-gitlab-shell-v-6-0-1' => '2f61d70', 'update-gitlab-shell-v-6-0-3' => 'de78448', '2-mb-file' => 'bf12d25', + 'before-create-delete-modify-move' => '845009f', + 'between-create-delete-modify-move' => '3f5f443', + 'after-create-delete-modify-move' => 'ba3faa7', 'with-codeowners' => '219560e' }.freeze diff --git a/spec/support/shared_examples/services/boards/issues_move_service.rb b/spec/support/shared_examples/services/boards/issues_move_service.rb index 6d29a97c56d..ec44b99d10e 100644 --- a/spec/support/shared_examples/services/boards/issues_move_service.rb +++ b/spec/support/shared_examples/services/boards/issues_move_service.rb @@ -34,7 +34,7 @@ shared_examples 'issues move service' do |group| described_class.new(parent, user, params).execute(issue) issue.reload - expect(issue.labels).to contain_exactly(bug) + expect(issue.labels).to contain_exactly(bug, regression) expect(issue).to be_closed end end diff --git a/spec/tasks/gitlab/uploads/migrate_rake_spec.rb b/spec/tasks/gitlab/uploads/migrate_rake_spec.rb index 6fcfae358ec..9588e8be5dc 100644 --- a/spec/tasks/gitlab/uploads/migrate_rake_spec.rb +++ b/spec/tasks/gitlab/uploads/migrate_rake_spec.rb @@ -38,14 +38,6 @@ describe 'gitlab:uploads:migrate rake tasks' do let!(:projects) { create_list(:project, 10, :with_avatar) } it_behaves_like 'enqueue jobs in batch', batch: 4 - - context 'Upload has store = nil' do - before do - Upload.where(model: projects).update_all(store: nil) - end - - it_behaves_like 'enqueue jobs in batch', batch: 4 - end end context "for Group" do diff --git a/spec/views/projects/notes/_more_actions_dropdown.html.haml_spec.rb b/spec/views/projects/notes/_more_actions_dropdown.html.haml_spec.rb index 9c0be249a50..8a9ab02eaca 100644 --- a/spec/views/projects/notes/_more_actions_dropdown.html.haml_spec.rb +++ b/spec/views/projects/notes/_more_actions_dropdown.html.haml_spec.rb @@ -12,10 +12,10 @@ describe 'projects/notes/_more_actions_dropdown' do assign(:project, project) end - it 'shows Report as abuse button if not editable and not current users comment' do + it 'shows Report abuse to GitLab button if not editable and not current users comment' do render 'projects/notes/more_actions_dropdown', current_user: not_author_user, note_editable: false, note: note - expect(rendered).to have_link('Report as abuse') + expect(rendered).to have_link('Report abuse to GitLab') end it 'does not show the More actions button if not editable and current users comment' do @@ -24,10 +24,10 @@ describe 'projects/notes/_more_actions_dropdown' do expect(rendered).not_to have_selector('.dropdown.more-actions') end - it 'shows Report as abuse and Delete buttons if editable and not current users comment' do + it 'shows Report abuse to GitLab and Delete buttons if editable and not current users comment' do render 'projects/notes/more_actions_dropdown', current_user: not_author_user, note_editable: true, note: note - expect(rendered).to have_link('Report as abuse') + expect(rendered).to have_link('Report abuse to GitLab') expect(rendered).to have_link('Delete comment') end |