diff options
387 files changed, 6755 insertions, 3552 deletions
@@ -35,7 +35,7 @@ gem 'faraday', '~> 0.12' # Authentication libraries gem 'devise', '~> 4.4' gem 'doorkeeper', '~> 4.3' -gem 'doorkeeper-openid_connect', '~> 1.3' +gem 'doorkeeper-openid_connect', '~> 1.5' gem 'omniauth', '~> 1.8' gem 'omniauth-auth0', '~> 2.0.0' gem 'omniauth-azure-oauth2', '~> 0.0.9' diff --git a/Gemfile.lock b/Gemfile.lock index 13f9212c637..1cd336c95d0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -76,7 +76,7 @@ GEM babosa (1.0.2) base32 (0.3.2) batch-loader (1.2.1) - bcrypt (3.1.11) + bcrypt (3.1.12) bcrypt_pbkdf (1.0.0) benchmark-ips (2.3.0) better_errors (2.1.1) @@ -171,7 +171,7 @@ GEM unf (>= 0.0.5, < 1.0.0) doorkeeper (4.3.2) railties (>= 4.2) - doorkeeper-openid_connect (1.4.0) + doorkeeper-openid_connect (1.5.0) doorkeeper (~> 4.3) json-jwt (~> 1.6) dropzonejs-rails (0.7.2) @@ -427,12 +427,10 @@ GEM oauth (~> 0.5, >= 0.5.0) jquery-atwho-rails (1.3.2) json (1.8.6) - json-jwt (1.9.2) + json-jwt (1.9.4) activesupport aes_key_wrap bindata - securecompare - url_safe_base64 json-schema (2.8.0) addressable (>= 2.4) jwt (1.5.6) @@ -512,7 +510,7 @@ GEM net-ldap (0.16.0) net-ssh (5.0.1) netrc (0.11.0) - nokogiri (1.8.2) + nokogiri (1.8.3) mini_portile2 (~> 2.3.0) nokogumbo (1.5.0) nokogiri @@ -827,7 +825,6 @@ GEM scss_lint (0.56.0) rake (>= 0.9, < 13) sass (~> 3.5.3) - securecompare (1.0.0) seed-fu (2.3.7) activerecord (>= 3.1) activesupport (>= 3.1) @@ -939,7 +936,6 @@ GEM equalizer (~> 0.0.9) parser (>= 2.3.1.2, < 2.6) procto (~> 0.0.2) - url_safe_base64 (0.2.2) validates_hostname (1.0.6) activerecord (>= 3.0) activesupport (>= 3.0) @@ -1013,7 +1009,7 @@ DEPENDENCIES devise-two-factor (~> 3.0.0) diffy (~> 3.1.0) doorkeeper (~> 4.3) - doorkeeper-openid_connect (~> 1.3) + doorkeeper-openid_connect (~> 1.5) dropzonejs-rails (~> 0.7.1) ed25519 (~> 1.2) email_reply_trimmer (~> 0.1) diff --git a/Gemfile.rails5.lock b/Gemfile.rails5.lock index 3161e4653ee..3159942b4c5 100644 --- a/Gemfile.rails5.lock +++ b/Gemfile.rails5.lock @@ -174,7 +174,7 @@ GEM unf (>= 0.0.5, < 1.0.0) doorkeeper (4.3.2) railties (>= 4.2) - doorkeeper-openid_connect (1.4.0) + doorkeeper-openid_connect (1.5.0) doorkeeper (~> 4.3) json-jwt (~> 1.6) dropzonejs-rails (0.7.2) @@ -430,12 +430,10 @@ GEM oauth (~> 0.5, >= 0.5.0) jquery-atwho-rails (1.3.2) json (1.8.6) - json-jwt (1.9.2) + json-jwt (1.9.4) activesupport aes_key_wrap bindata - securecompare - url_safe_base64 json-schema (2.8.0) addressable (>= 2.4) jwt (1.5.6) @@ -836,7 +834,6 @@ GEM scss_lint (0.56.0) rake (>= 0.9, < 13) sass (~> 3.5.3) - securecompare (1.0.0) seed-fu (2.3.7) activerecord (>= 3.1) activesupport (>= 3.1) @@ -946,7 +943,6 @@ GEM equalizer (~> 0.0.9) parser (>= 2.3.1.2, < 2.6) procto (~> 0.0.2) - url_safe_base64 (0.2.2) validates_hostname (1.0.6) activerecord (>= 3.0) activesupport (>= 3.0) @@ -1023,7 +1019,7 @@ DEPENDENCIES devise-two-factor (~> 3.0.0) diffy (~> 3.1.0) doorkeeper (~> 4.3) - doorkeeper-openid_connect (~> 1.3) + doorkeeper-openid_connect (~> 1.5) dropzonejs-rails (~> 0.7.1) ed25519 (~> 1.2) email_reply_trimmer (~> 0.1) diff --git a/PROCESS.md b/PROCESS.md index 1cc5b44aa67..a06ddb68b77 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -15,6 +15,8 @@ - [Between the 1st and the 7th](#between-the-1st-and-the-7th) - [On the 7th](#on-the-7th) - [After the 7th](#after-the-7th) +- [Regressions](#regressions) + - [How to manage a regression](#how-to-manage-a-regression) - [Release retrospective and kickoff](#release-retrospective-and-kickoff) - [Retrospective](#retrospective) - [Kickoff](#kickoff) @@ -199,7 +201,7 @@ you can ask for an exception to be made. Check [this guide](https://gitlab.com/gitlab-org/release/docs/blob/master/general/exception-request/process.md) about how to open an exception request before opening one. -### Regressions +## Regressions A regression for a particular monthly release is a bug that exists in that release, but wasn't present in the release before. This includes bugs in @@ -217,10 +219,30 @@ month. When we say 'the most recent monthly release', this can refer to either the version currently running on GitLab.com, or the most recent version available in the package repositories. -A regression issue should be labeled with the appropriate [subject label](../CONTRIBUTING.md#subject-labels-wiki-container-registry-ldap-api-etc) -and [team label](../CONTRIBUTING.md#team-labels-ci-discussion-edge-platform-etc), -just like any other issue, to help GitLab team members focus on issues that are -relevant to [their area of responsibility](https://about.gitlab.com/handbook/engineering/workflow/#choosing-something-to-work-on). +### How to manage a regression + +Regressions are very important, and they should be considered high priority +issues that should be solved as soon as possible, especially if they affect +users. Despite that, ~regression label itself does not imply when the issue +will be scheduled. + +When a regression is found: +1. Create an issue describing the problem in the most detailed way possible +1. If possible, provide links to real examples and how to reproduce the problem +1. Label the issue properly, using the [team label](../CONTRIBUTING.md#team-labels), + the [subject label](../CONTRIBUTING.md#subject-labels) + and any other label that may apply in the specific case +1. Add the ~bug and ~regression labels +1. Notify the respective Engineering Manager to evaluate the Severity of the regression and add a [Severity label](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#bug-severity-labels). The counterpart Product Manager is included to weigh-in on prioritization as needed to set the [Priority label](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#bug-priority-labels). +1. If the regression is either an ~S1, ~S2 or ~S3 severity, label the regression with the current milestone as it should be fixed in the current milestone. + 1. If the regression was introduced in an RC of the current release, label with ~Deliverable + 1. If the regression was introduced in the previous release, label with ~"Next Patch Release" +1. If the regression is an ~S4 severity, the regression may be scheduled for later milestones at the discretion of Engineering Manager and Product Manager. + +When a new issue is found, the fix should start as soon as possible. You can +ping the Engineering Manager or the Product Manager for the relative area to +make them aware of the issue earlier. They will analyze the priority and change +it if needed. ## Release retrospective and kickoff diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 000938e475f..0ca0e8f35dd 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -150,14 +150,15 @@ const Api = { }, // Return group projects list. Filtered by query - groupProjects(groupId, query, callback) { + groupProjects(groupId, query, options, callback) { const url = Api.buildUrl(Api.groupProjectsPath).replace(':id', groupId); + const defaults = { + search: query, + per_page: 20, + }; return axios .get(url, { - params: { - search: query, - per_page: 20, - }, + params: Object.assign({}, defaults, options), }) .then(({ data }) => callback(data)); }, @@ -243,6 +244,15 @@ const Api = { }); }, + createBranch(id, { ref, branch }) { + const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id)); + + return axios.post(url, { + ref, + branch, + }); + }, + buildUrl(url) { let urlRoot = ''; if (gon.relative_url_root != null) { diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index a0b7fe81a4a..d50641dc3a9 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -2,6 +2,7 @@ import $ from 'jquery'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import eventHub from '../eventhub'; + import tooltip from '../../vue_shared/directives/tooltip'; const Store = gl.issueBoards.BoardsStore; @@ -9,6 +10,9 @@ components: { UserAvatarLink, }, + directives: { + tooltip, + }, props: { issue: { type: Object, @@ -166,9 +170,10 @@ tooltip-placement="bottom" /> <span + v-tooltip v-if="shouldRenderCounter" :title="assigneeCounterTooltip" - class="avatar-counter has-tooltip" + class="avatar-counter" > {{ assigneeCounterLabel }} </span> @@ -179,12 +184,13 @@ class="board-card-footer" > <button + v-tooltip v-for="label in issue.labels" v-if="showLabel(label)" :key="label.id" :style="labelStyle(label)" :title="label.description" - class="badge color-label has-tooltip" + class="badge color-label" type="button" data-container="body" @click="filterByLabel(label, $event)" diff --git a/app/assets/javascripts/boards/filters/due_date_filters.js b/app/assets/javascripts/boards/filters/due_date_filters.js index 70132dbfa6f..9eaa0cd227d 100644 --- a/app/assets/javascripts/boards/filters/due_date_filters.js +++ b/app/assets/javascripts/boards/filters/due_date_filters.js @@ -1,8 +1,7 @@ -/* global dateFormat */ - import Vue from 'vue'; +import dateFormat from 'dateformat'; -Vue.filter('due-date', (value) => { +Vue.filter('due-date', value => { const date = new Date(value); return dateFormat(date, 'mmm d, yyyy', true); }); diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index deddb61ca31..eb0985e5603 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 eventHub from '../../notes/event_hub'; import LoadingIcon from '../../vue_shared/components/loading_icon.vue'; import CompareVersions from './compare_versions.vue'; import ChangedFiles from './changed_files.vue'; @@ -62,7 +63,7 @@ export default { plainDiffPath: state => state.diffs.plainDiffPath, emailPatchPath: state => state.diffs.emailPatchPath, }), - ...mapGetters(['isParallelView']), + ...mapGetters(['isParallelView', 'isNotesFetched']), targetBranch() { return { branchName: this.targetBranchName, @@ -94,20 +95,36 @@ export default { this.adjustView(); }, shouldShow() { + // When the shouldShow property changed to true, the route is rendered for the first time + // and if we have the isLoading as true this means we didn't fetch the data + if (this.isLoading) { + this.fetchData(); + } + this.adjustView(); }, }, mounted() { this.setBaseConfig({ endpoint: this.endpoint, projectPath: this.projectPath }); - this.fetchDiffFiles().catch(() => { - createFlash(__('Fetching diff files failed. Please reload the page to try again!')); - }); + + if (this.shouldShow) { + this.fetchData(); + } }, created() { this.adjustView(); }, methods: { ...mapActions(['setBaseConfig', 'fetchDiffFiles']), + fetchData() { + this.fetchDiffFiles().catch(() => { + createFlash(__('Something went wrong on our end. Please try again!')); + }); + + if (!this.isNotesFetched) { + eventHub.$emit('fetchNotesData'); + } + }, setActive(filePath) { this.activeFile = filePath; }, @@ -128,7 +145,7 @@ export default { </script> <template> - <div v-if="shouldShow"> + <div v-show="shouldShow"> <div v-if="isLoading" class="loading" diff --git a/app/assets/javascripts/diffs/components/changed_files_dropdown.vue b/app/assets/javascripts/diffs/components/changed_files_dropdown.vue index f224b9dd246..b38d217fbe3 100644 --- a/app/assets/javascripts/diffs/components/changed_files_dropdown.vue +++ b/app/assets/javascripts/diffs/components/changed_files_dropdown.vue @@ -66,59 +66,61 @@ export default { @click="clearSearch" ></i> </div> - <ul> - <li - v-for="diffFile in filteredDiffFiles" - :key="diffFile.name" - > - <a - :href="`#${diffFile.fileHash}`" - :title="diffFile.newPath" - class="diff-changed-file" + <div class="dropdown-content"> + <ul> + <li + v-for="diffFile in filteredDiffFiles" + :key="diffFile.name" > - <icon - :name="fileChangedIcon(diffFile)" - :size="16" - :class="fileChangedClass(diffFile)" - class="diff-file-changed-icon append-right-8" - /> - <span class="diff-changed-file-content append-right-8"> - <strong - v-if="diffFile.blob && diffFile.blob.name" - class="diff-changed-file-name" - > - {{ diffFile.blob.name }} - </strong> - <strong - v-else - class="diff-changed-blank-file-name" - > - {{ s__('Diffs|No file name available') }} - </strong> - <span class="diff-changed-file-path prepend-top-5"> - {{ truncatedDiffPath(diffFile.blob.path) }} + <a + :href="`#${diffFile.fileHash}`" + :title="diffFile.newPath" + class="diff-changed-file" + > + <icon + :name="fileChangedIcon(diffFile)" + :size="16" + :class="fileChangedClass(diffFile)" + class="diff-file-changed-icon append-right-8" + /> + <span class="diff-changed-file-content append-right-8"> + <strong + v-if="diffFile.blob && diffFile.blob.name" + class="diff-changed-file-name" + > + {{ diffFile.blob.name }} + </strong> + <strong + v-else + class="diff-changed-blank-file-name" + > + {{ s__('Diffs|No file name available') }} + </strong> + <span class="diff-changed-file-path prepend-top-5"> + {{ truncatedDiffPath(diffFile.blob.path) }} + </span> </span> - </span> - <span class="diff-changed-stats"> - <span class="cgreen"> - +{{ diffFile.addedLines }} + <span class="diff-changed-stats"> + <span class="cgreen"> + +{{ diffFile.addedLines }} + </span> + <span class="cred"> + -{{ diffFile.removedLines }} + </span> </span> - <span class="cred"> - -{{ diffFile.removedLines }} - </span> - </span> - </a> - </li> + </a> + </li> - <li - v-show="filteredDiffFiles.length === 0" - class="dropdown-menu-empty-item" - > - <a> - {{ __('No files found') }} - </a> - </li> - </ul> + <li + v-show="filteredDiffFiles.length === 0" + class="dropdown-menu-empty-item" + > + <a> + {{ __('No files found') }} + </a> + </li> + </ul> + </div> </div> </span> </template> diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 6bad389f778..fba1d1af7cd 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -112,7 +112,11 @@ export default { }, methods: { handleToggle(e, checkTarget) { - if (!checkTarget || e.target === this.$refs.header) { + if ( + !checkTarget || + e.target === this.$refs.header || + (e.target.classList && e.target.classList.contains('diff-toggle-caret')) + ) { this.$emit('toggleFile'); } }, @@ -201,7 +205,7 @@ export default { <div v-if="!diffFile.submodule && addMergeRequestButtons" - class="file-actions d-none d-md-block" + class="file-actions d-none d-sm-block" > <template v-if="diffFile.blob && diffFile.blob.readableText" 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 8999fd2ac96..a74ea4bfaaf 100644 --- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue +++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue @@ -4,14 +4,7 @@ import { s__ } from '~/locale'; import { mapState, mapGetters, mapActions } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; import DiffGutterAvatars from './diff_gutter_avatars.vue'; -import { - MATCH_LINE_TYPE, - CONTEXT_LINE_TYPE, - OLD_NO_NEW_LINE_TYPE, - NEW_NO_NEW_LINE_TYPE, - LINE_POSITION_RIGHT, - UNFOLD_COUNT, -} from '../constants'; +import { LINE_POSITION_RIGHT, UNFOLD_COUNT } from '../constants'; import * as utils from '../store/utils'; export default { @@ -63,6 +56,21 @@ export default { required: false, default: false, }, + isMatchLine: { + type: Boolean, + required: false, + default: false, + }, + isMetaLine: { + type: Boolean, + required: false, + default: false, + }, + isContextLine: { + type: Boolean, + required: false, + default: false, + }, }, computed: { ...mapState({ @@ -70,15 +78,6 @@ export default { diffFiles: state => state.diffs.diffFiles, }), ...mapGetters(['isLoggedIn', 'discussionsByLineCode']), - isMatchLine() { - return this.lineType === MATCH_LINE_TYPE; - }, - isContextLine() { - return this.lineType === CONTEXT_LINE_TYPE; - }, - isMetaLine() { - return this.lineType === OLD_NO_NEW_LINE_TYPE || this.lineType === NEW_NO_NEW_LINE_TYPE; - }, lineHref() { return this.lineCode ? `#${this.lineCode}` : '#'; }, @@ -109,9 +108,9 @@ export default { }, }, methods: { - ...mapActions(['loadMoreLines']), + ...mapActions(['loadMoreLines', 'showCommentForm']), handleCommentButton() { - this.$emit('showCommentForm', { lineCode: this.lineCode }); + this.showCommentForm({ lineCode: this.lineCode }); }, handleLoadMoreLines() { if (this.isRequesting) { diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue new file mode 100644 index 00000000000..68fe6787f9b --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue @@ -0,0 +1,131 @@ +<script> +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, + NEW_NO_NEW_LINE_TYPE, + LINE_HOVER_CLASS_NAME, + LINE_UNFOLD_CLASS_NAME, +} from '../constants'; + +export default { + components: { + DiffLineGutterContent, + }, + props: { + line: { + type: Object, + required: true, + }, + diffFile: { + type: Object, + required: true, + }, + showCommentButton: { + type: Boolean, + required: false, + default: false, + }, + linePosition: { + type: String, + required: false, + default: '', + }, + lineType: { + type: String, + required: false, + default: '', + }, + isContentLine: { + type: Boolean, + required: false, + default: false, + }, + isBottom: { + type: Boolean, + required: false, + default: false, + }, + isHover: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + ...mapGetters(['isLoggedIn', 'isInlineView']), + normalizedLine() { + if (this.isInlineView) { + return this.line; + } + + return this.lineType === OLD_LINE_TYPE ? this.line.left : this.line.right; + }, + isMatchLine() { + return this.normalizedLine.type === MATCH_LINE_TYPE; + }, + isContextLine() { + return this.normalizedLine.type === CONTEXT_LINE_TYPE; + }, + isMetaLine() { + return ( + this.normalizedLine.type === OLD_NO_NEW_LINE_TYPE || + this.normalizedLine.type === NEW_NO_NEW_LINE_TYPE || + this.normalizedLine.type === EMPTY_CELL_TYPE + ); + }, + classNameMap() { + const { type } = this.normalizedLine; + + return { + [type]: type, + [LINE_UNFOLD_CLASS_NAME]: this.isMatchLine, + [LINE_HOVER_CLASS_NAME]: + this.isLoggedIn && + this.isHover && + !this.isMatchLine && + !this.isContextLine && + !this.isMetaLine, + }; + }, + lineNumber() { + const { lineType, normalizedLine } = this; + + return lineType === OLD_LINE_TYPE ? normalizedLine.oldLine : normalizedLine.newLine; + }, + }, +}; +</script> + +<template> + <td + v-if="isContentLine" + :class="lineType" + class="line_content" + v-html="normalizedLine.richText" + > + </td> + <td + v-else + :class="classNameMap" + > + <diff-line-gutter-content + :file-hash="diffFile.fileHash" + :line-type="normalizedLine.type" + :line-code="normalizedLine.lineCode" + :line-position="linePosition" + :line-number="lineNumber" + :meta-data="normalizedLine.metaData" + :show-comment-button="showCommentButton" + :context-lines-path="diffFile.contextLinesPath" + :is-bottom="isBottom" + :is-match-line="isMatchLine" + :is-context-line="isContentLine" + :is-meta-line="isMetaLine" + /> + </td> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_table_row.vue b/app/assets/javascripts/diffs/components/diff_table_row.vue new file mode 100644 index 00000000000..8716fdaf44d --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_table_row.vue @@ -0,0 +1,191 @@ +<script> +import $ from 'jquery'; +import { mapGetters } from 'vuex'; +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, + LINE_POSITION_LEFT, + LINE_POSITION_RIGHT, +} from '../constants'; + +export default { + components: { + DiffTableCell, + }, + props: { + diffFile: { + type: Object, + required: true, + }, + line: { + type: Object, + required: true, + }, + isBottom: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isHover: false, + isLeftHover: false, + isRightHover: false, + }; + }, + computed: { + ...mapGetters(['isInlineView', 'isParallelView']), + isContextLine() { + return this.line.left + ? this.line.left.type === CONTEXT_LINE_TYPE + : 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, + }; + }, + inlineRowId() { + const { lineCode, oldLine, newLine } = this.line; + + return lineCode || `${this.diffFile.fileHash}_${oldLine}_${newLine}`; + }, + parallelViewLeftLineType() { + if (this.line.right.type === NEW_NO_NEW_LINE_TYPE) { + return OLD_NO_NEW_LINE_TYPE; + } + + return this.line.left.type; + }, + }, + created() { + this.newLineType = NEW_LINE_TYPE; + this.oldLineType = OLD_LINE_TYPE; + this.linePositionLeft = LINE_POSITION_LEFT; + this.linePositionRight = LINE_POSITION_RIGHT; + }, + methods: { + handleMouseMove(e) { + const isHover = e.type === 'mouseover'; + + if (this.isInlineView) { + this.isHover = isHover; + } else { + const hoveringCell = e.target.closest('td'); + const allCellsInHoveringRow = Array.from(e.currentTarget.children); + const hoverIndex = allCellsInHoveringRow.indexOf(hoveringCell); + + if (hoverIndex >= 2) { + this.isRightHover = isHover; + } else { + this.isLeftHover = isHover; + } + } + }, + // Prevent text selecting on both sides of parallel diff view + // Backport of the same code from legacy diff notes. + handleParallelLineMouseDown(e) { + const line = $(e.currentTarget); + const table = line.closest('table'); + + table.removeClass('left-side-selected right-side-selected'); + const [lineClass] = ['left-side', 'right-side'].filter(name => line.hasClass(name)); + + if (lineClass) { + table.addClass(`${lineClass}-selected`); + } + }, + }, +}; +</script> + +<template> + <tr + v-if="isInlineView" + :id="inlineRowId" + :class="classNameMap" + class="line_holder" + @mouseover="handleMouseMove" + @mouseout="handleMouseMove" + > + <diff-table-cell + :diff-file="diffFile" + :line="line" + :line-type="oldLineType" + :is-bottom="isBottom" + :is-hover="isHover" + :show-comment-button="true" + class="diff-line-num old_line" + /> + <diff-table-cell + :diff-file="diffFile" + :line="line" + :line-type="newLineType" + :is-bottom="isBottom" + :is-hover="isHover" + class="diff-line-num new_line" + /> + <diff-table-cell + :class="line.type" + :diff-file="diffFile" + :line="line" + :is-content-line="true" + /> + </tr> + + <tr + v-else + :class="classNameMap" + class="line_holder" + @mouseover="handleMouseMove" + @mouseout="handleMouseMove" + > + <diff-table-cell + :diff-file="diffFile" + :line="line" + :line-type="oldLineType" + :line-position="linePositionLeft" + :is-bottom="isBottom" + :is-hover="isLeftHover" + :show-comment-button="true" + class="diff-line-num old_line" + /> + <diff-table-cell + :id="line.left.lineCode" + :diff-file="diffFile" + :line="line" + :is-content-line="true" + :line-type="parallelViewLeftLineType" + class="line_content parallel left-side" + @mousedown.native="handleParallelLineMouseDown" + /> + <diff-table-cell + :diff-file="diffFile" + :line="line" + :line-type="newLineType" + :line-position="linePositionRight" + :is-bottom="isBottom" + :is-hover="isRightHover" + :show-comment-button="true" + class="diff-line-num new_line" + /> + <diff-table-cell + :id="line.right.lineCode" + :diff-file="diffFile" + :line="line" + :is-content-line="true" + :line-type="line.right.type" + class="line_content parallel right-side" + @mousedown.native="handleParallelLineMouseDown" + /> + </tr> +</template> diff --git a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue new file mode 100644 index 00000000000..0e935f1d68e --- /dev/null +++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue @@ -0,0 +1,82 @@ +<script> +import { mapState, mapGetters } from 'vuex'; +import diffDiscussions from './diff_discussions.vue'; +import diffLineNoteForm from './diff_line_note_form.vue'; + +export default { + components: { + diffDiscussions, + diffLineNoteForm, + }, + props: { + line: { + type: Object, + required: true, + }, + diffFile: { + type: Object, + required: true, + }, + diffLines: { + type: Array, + required: true, + }, + lineIndex: { + type: Number, + required: true, + }, + }, + computed: { + ...mapState({ + diffLineCommentForms: state => state.diffs.diffLineCommentForms, + }), + ...mapGetters(['discussionsByLineCode']), + isDiscussionExpanded() { + if (!this.discussions.length) { + return false; + } + + return this.discussions.every(discussion => discussion.expanded); + }, + hasCommentForm() { + return this.diffLineCommentForms[this.line.lineCode]; + }, + discussions() { + return this.discussionsByLineCode[this.line.lineCode] || []; + }, + shouldRender() { + return this.isDiscussionExpanded || this.hasCommentForm; + }, + className() { + return this.discussions.length ? '' : 'js-temp-notes-holder'; + }, + }, +}; +</script> + +<template> + <tr + v-if="shouldRender" + :class="className" + class="notes_holder" + > + <td + class="notes_line" + colspan="2" + ></td> + <td class="notes_content"> + <div class="content"> + <diff-discussions + :discussions="discussions" + /> + <diff-line-note-form + v-if="diffLineCommentForms[line.lineCode]" + :diff-file="diffFile" + :diff-lines="diffLines" + :line="line" + :note-target-line="diffLines[lineIndex]" + /> + </div> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue index 21376117bef..e72f85df77a 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_view.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -1,34 +1,12 @@ <script> import diffContentMixin from '../mixins/diff_content'; -import { - MATCH_LINE_TYPE, - CONTEXT_LINE_TYPE, - OLD_NO_NEW_LINE_TYPE, - NEW_NO_NEW_LINE_TYPE, - LINE_HOVER_CLASS_NAME, - LINE_UNFOLD_CLASS_NAME, -} from '../constants'; +import inlineDiffCommentRow from './inline_diff_comment_row.vue'; export default { - mixins: [diffContentMixin], - methods: { - handleMouse(lineCode, isOver) { - this.hoveredLineCode = isOver ? lineCode : null; - }, - getLineClass(line) { - const isSameLine = this.hoveredLineCode && this.hoveredLineCode === line.lineCode; - const isMatchLine = line.type === MATCH_LINE_TYPE; - const isContextLine = line.type === CONTEXT_LINE_TYPE; - const isMetaLine = line.type === OLD_NO_NEW_LINE_TYPE || line.type === NEW_NO_NEW_LINE_TYPE; - - return { - [line.type]: line.type, - [LINE_UNFOLD_CLASS_NAME]: isMatchLine, - [LINE_HOVER_CLASS_NAME]: - this.isLoggedIn && isSameLine && !isMatchLine && !isContextLine && !isMetaLine, - }; - }, + components: { + inlineDiffCommentRow, }, + mixins: [diffContentMixin], }; </script> @@ -41,76 +19,19 @@ export default { <template v-for="(line, index) in normalizedDiffLines" > - <tr - :id="line.lineCode || `${fileHash}_${line.oldLine}_${line.newLine}`" + <diff-table-row + :diff-file="diffFile" + :line="line" + :is-bottom="index + 1 === diffLinesLength" :key="line.lineCode" - :class="getRowClass(line)" - class="line_holder" - @mouseover="handleMouse(line.lineCode, true)" - @mouseout="handleMouse(line.lineCode, false)" - > - <td - :class="getLineClass(line)" - class="diff-line-num old_line" - > - <diff-line-gutter-content - :file-hash="fileHash" - :line-type="line.type" - :line-code="line.lineCode" - :line-number="line.oldLine" - :meta-data="line.metaData" - :show-comment-button="true" - :context-lines-path="diffFile.contextLinesPath" - :is-bottom="index + 1 === diffLinesLength" - @showCommentForm="handleShowCommentForm" - /> - </td> - <td - :class="getLineClass(line)" - class="diff-line-num new_line" - > - <diff-line-gutter-content - :file-hash="fileHash" - :line-type="line.type" - :line-code="line.lineCode" - :line-number="line.newLine" - :meta-data="line.metaData" - :is-bottom="index + 1 === diffLinesLength" - :context-lines-path="diffFile.contextLinesPath" - /> - </td> - <td - :class="line.type" - class="line_content" - v-html="line.richText" - > - </td> - </tr> - <tr - v-if="isDiscussionExpanded(line.lineCode) || diffLineCommentForms[line.lineCode]" + /> + <inline-diff-comment-row + :diff-file="diffFile" + :diff-lines="normalizedDiffLines" + :line="line" + :line-index="index" :key="index" - :class="discussionsByLineCode[line.lineCode] ? '' : 'js-temp-notes-holder'" - class="notes_holder" - > - <td - class="notes_line" - colspan="2" - ></td> - <td class="notes_content"> - <div class="content"> - <diff-discussions - :discussions="discussionsByLineCode[line.lineCode] || []" - /> - <diff-line-note-form - v-if="diffLineCommentForms[line.lineCode]" - :diff-file="diffFile" - :diff-lines="diffLines" - :line="line" - :note-target-line="diffLines[index]" - /> - </div> - </td> - </tr> + /> </template> </tbody> </table> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue new file mode 100644 index 00000000000..5f33ec7a3c2 --- /dev/null +++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue @@ -0,0 +1,129 @@ +<script> +import { mapState, mapGetters } from 'vuex'; +import diffDiscussions from './diff_discussions.vue'; +import diffLineNoteForm from './diff_line_note_form.vue'; + +export default { + components: { + diffDiscussions, + diffLineNoteForm, + }, + props: { + line: { + type: Object, + required: true, + }, + diffFile: { + type: Object, + required: true, + }, + diffLines: { + type: Array, + required: true, + }, + lineIndex: { + type: Number, + required: true, + }, + }, + computed: { + ...mapState({ + diffLineCommentForms: state => state.diffs.diffLineCommentForms, + }), + ...mapGetters(['discussionsByLineCode']), + leftLineCode() { + return this.line.left.lineCode; + }, + rightLineCode() { + return this.line.right.lineCode; + }, + hasDiscussion() { + const discussions = this.discussionsByLineCode; + + return discussions[this.leftLineCode] || discussions[this.rightLineCode]; + }, + hasExpandedDiscussionOnLeft() { + const discussions = this.discussionsByLineCode[this.leftLineCode]; + + return discussions ? discussions.every(discussion => discussion.expanded) : false; + }, + hasExpandedDiscussionOnRight() { + const discussions = this.discussionsByLineCode[this.rightLineCode]; + + return discussions ? discussions.every(discussion => discussion.expanded) : false; + }, + hasAnyExpandedDiscussion() { + return this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight; + }, + shouldRenderDiscussionsRow() { + const hasDiscussion = this.hasDiscussion && this.hasAnyExpandedDiscussion; + const hasCommentFormOnLeft = this.diffLineCommentForms[this.leftLineCode]; + const hasCommentFormOnRight = this.diffLineCommentForms[this.rightLineCode]; + + return hasDiscussion || hasCommentFormOnLeft || hasCommentFormOnRight; + }, + shouldRenderDiscussionsOnLeft() { + return this.discussionsByLineCode[this.leftLineCode] && this.hasExpandedDiscussionOnLeft; + }, + shouldRenderDiscussionsOnRight() { + return ( + this.discussionsByLineCode[this.rightLineCode] && + this.hasExpandedDiscussionOnRight && + this.line.right.type + ); + }, + className() { + return this.hasDiscussion ? '' : 'js-temp-notes-holder'; + }, + }, +}; +</script> + +<template> + <tr + v-if="shouldRenderDiscussionsRow" + :class="className" + class="notes_holder" + > + <td class="notes_line old"></td> + <td class="notes_content parallel old"> + <div + v-if="shouldRenderDiscussionsOnLeft" + class="content" + > + <diff-discussions + :discussions="discussionsByLineCode[leftLineCode]" + /> + </div> + <diff-line-note-form + v-if="diffLineCommentForms[leftLineCode] && + diffLineCommentForms[leftLineCode]" + :diff-file="diffFile" + :diff-lines="diffLines" + :line="line.left" + :note-target-line="diffLines[lineIndex].left" + position="left" + /> + </td> + <td class="notes_line new"></td> + <td class="notes_content parallel new"> + <div + v-if="shouldRenderDiscussionsOnRight" + class="content" + > + <diff-discussions + :discussions="discussionsByLineCode[rightLineCode]" + /> + </div> + <diff-line-note-form + v-if="diffLineCommentForms[rightLineCode] && + diffLineCommentForms[rightLineCode] && line.right.type" + :diff-file="diffFile" + :diff-lines="diffLines" + :line="line.right" + :note-target-line="diffLines[lineIndex].right" + position="right" + /> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue index 60edbcbbda8..ed92b4ee249 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -1,17 +1,12 @@ <script> import diffContentMixin from '../mixins/diff_content'; -import { - EMPTY_CELL_TYPE, - MATCH_LINE_TYPE, - CONTEXT_LINE_TYPE, - OLD_NO_NEW_LINE_TYPE, - NEW_NO_NEW_LINE_TYPE, - LINE_HOVER_CLASS_NAME, - LINE_UNFOLD_CLASS_NAME, - LINE_POSITION_RIGHT, -} from '../constants'; +import parallelDiffCommentRow from './parallel_diff_comment_row.vue'; +import { EMPTY_CELL_TYPE } from '../constants'; export default { + components: { + parallelDiffCommentRow, + }, mixins: [diffContentMixin], computed: { parallelDiffLines() { @@ -26,77 +21,6 @@ export default { }); }, }, - methods: { - hasDiscussion(line) { - const discussions = this.discussionsByLineCode; - const hasDiscussion = discussions[line.left.lineCode] || discussions[line.right.lineCode]; - - return hasDiscussion; - }, - getClassName(line, position) { - const { type, lineCode } = line[position]; - const isMatchLine = type === MATCH_LINE_TYPE; - const isContextLine = !isMatchLine && type !== EMPTY_CELL_TYPE && type !== CONTEXT_LINE_TYPE; - const isMetaLine = type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE; - const isSameLine = this.hoveredLineCode && this.hoveredLineCode === lineCode; - const isSameSection = position === this.hoveredSection; - - return { - [type]: type, - [LINE_UNFOLD_CLASS_NAME]: isMatchLine, - [LINE_HOVER_CLASS_NAME]: - this.isLoggedIn && isContextLine && isSameLine && isSameSection && !isMetaLine, - }; - }, - handleMouse(e, line, isHover) { - if (isHover) { - const cell = e.target.closest('td'); - - if (this.$refs.leftLines.indexOf(cell) > -1) { - this.hoveredLineCode = line.left.lineCode; - this.hoveredSection = 'left'; - } else if (this.$refs.rightLines.indexOf(cell) > -1) { - this.hoveredLineCode = line.right.lineCode; - this.hoveredSection = 'right'; - } - } else { - this.hoveredLineCode = null; - this.hoveredSection = null; - } - }, - shouldRenderDiscussionsRow(line) { - const hasDiscussion = this.hasDiscussion(line) && this.hasAnyExpandedDiscussion(line); - const hasCommentFormOnLeft = this.diffLineCommentForms[line.left.lineCode]; - const hasCommentFormOnRight = this.diffLineCommentForms[line.right.lineCode]; - - return hasDiscussion || hasCommentFormOnLeft || hasCommentFormOnRight; - }, - shouldRenderDiscussions(line, position) { - const { lineCode } = line[position]; - let render = this.discussionsByLineCode[lineCode] && this.isDiscussionExpanded(lineCode); - - // Avoid rendering context line discussions on the right side in parallel view - if (position === LINE_POSITION_RIGHT) { - render = render && line.right.type; - } - - return render; - }, - hasAnyExpandedDiscussion(line) { - const isLeftExpanded = this.isDiscussionExpanded(line.left.lineCode); - const isRightExpanded = this.isDiscussionExpanded(line.right.lineCode); - - return isLeftExpanded || isRightExpanded; - }, - getLineCode(line, side) { - const { lineCode } = side; - if (lineCode) { - return lineCode; - } - - return `${this.fileHash}_${line.left.oldLine}_${line.right.newLine}`; - }, - }, }; </script> @@ -104,119 +28,26 @@ export default { <div :class="userColorScheme" :data-commit-id="commitId" - class="code diff-wrap-lines js-syntax-highlight text-file"> + class="code diff-wrap-lines js-syntax-highlight text-file" + > <table> <tbody> <template v-for="(line, index) in parallelDiffLines" > - <tr + <diff-table-row + :diff-file="diffFile" + :line="line" + :is-bottom="index + 1 === diffLinesLength" :key="index" - :class="getRowClass(line)" - class="line_holder parallel" - @mouseover="handleMouse($event, line, true)" - @mouseout="handleMouse($event, line, false)" - > - <td - ref="leftLines" - :class="getClassName(line, 'left')" - class="diff-line-num old_line" - > - <diff-line-gutter-content - :file-hash="fileHash" - :line-type="line.left.type" - :line-code="line.left.lineCode" - :line-number="line.left.oldLine" - :meta-data="line.left.metaData" - :show-comment-button="true" - :context-lines-path="diffFile.contextLinesPath" - :is-bottom="index + 1 === diffLinesLength" - line-position="left" - @showCommentForm="handleShowCommentForm" - /> - </td> - <td - ref="leftLines" - :class="getClassName(line, 'left')" - :id="getLineCode(line, line.left)" - class="line_content parallel left-side" - v-html="line.left.richText" - > - </td> - <td - ref="rightLines" - :class="getClassName(line, 'right')" - class="diff-line-num new_line" - > - <diff-line-gutter-content - :file-hash="fileHash" - :line-type="line.right.type" - :line-code="line.right.lineCode" - :line-number="line.right.newLine" - :meta-data="line.right.metaData" - :show-comment-button="true" - :context-lines-path="diffFile.contextLinesPath" - :is-bottom="index + 1 === diffLinesLength" - line-position="right" - @showCommentForm="handleShowCommentForm" - /> - </td> - <td - ref="rightLines" - :class="getClassName(line, 'right')" - :id="getLineCode(line, line.right)" - class="line_content parallel right-side" - v-html="line.right.richText" - > - </td> - </tr> - <tr - v-if="shouldRenderDiscussionsRow(line)" + /> + <parallel-diff-comment-row :key="line.left.lineCode || line.right.lineCode" - :class="hasDiscussion(line) ? '' : 'js-temp-notes-holder'" - class="notes_holder" - > - <td class="notes_line old"></td> - <td class="notes_content parallel old"> - <div - v-if="shouldRenderDiscussions(line, 'left')" - class="content" - > - <diff-discussions - :discussions="discussionsByLineCode[line.left.lineCode]" - /> - </div> - <diff-line-note-form - v-if="diffLineCommentForms[line.left.lineCode] && - diffLineCommentForms[line.left.lineCode]" - :diff-file="diffFile" - :diff-lines="diffLines" - :line="line.left" - :note-target-line="diffLines[index].left" - position="left" - /> - </td> - <td class="notes_line new"></td> - <td class="notes_content parallel new"> - <div - v-if="shouldRenderDiscussions(line, 'right')" - class="content" - > - <diff-discussions - :discussions="discussionsByLineCode[line.right.lineCode]" - /> - </div> - <diff-line-note-form - v-if="diffLineCommentForms[line.right.lineCode] && - diffLineCommentForms[line.right.lineCode] && line.right.type" - :diff-file="diffFile" - :diff-lines="diffLines" - :line="line.right" - :note-target-line="diffLines[index].right" - position="right" - /> - </td> - </tr> + :line="line" + :diff-file="diffFile" + :diff-lines="parallelDiffLines" + :line-index="index" + /> </template> </tbody> </table> diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index d314f08e60e..2fa8367f528 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -14,6 +14,8 @@ export const TEXT_DIFF_POSITION_TYPE = 'text'; export const LINE_POSITION_LEFT = 'left'; export const LINE_POSITION_RIGHT = 'right'; +export const LINE_SIDE_LEFT = 'left-side'; +export const LINE_SIDE_RIGHT = 'right-side'; export const DIFF_VIEW_COOKIE_NAME = 'diff_view'; export const LINE_HOVER_CLASS_NAME = 'is-over'; diff --git a/app/assets/javascripts/diffs/mixins/diff_content.js b/app/assets/javascripts/diffs/mixins/diff_content.js index bef06ad2b52..ebb511d3a7e 100644 --- a/app/assets/javascripts/diffs/mixins/diff_content.js +++ b/app/assets/javascripts/diffs/mixins/diff_content.js @@ -1,9 +1,9 @@ -import { mapState, mapGetters, mapActions } from 'vuex'; +import { mapGetters } from 'vuex'; import diffDiscussions from '../components/diff_discussions.vue'; import diffLineGutterContent from '../components/diff_line_gutter_content.vue'; import diffLineNoteForm from '../components/diff_line_note_form.vue'; +import diffTableRow from '../components/diff_table_row.vue'; import { trimFirstCharOfLineContent } from '../store/utils'; -import { CONTEXT_LINE_TYPE, CONTEXT_LINE_CLASS_NAME } from '../constants'; export default { props: { @@ -16,22 +16,14 @@ export default { required: true, }, }, - data() { - return { - hoveredLineCode: null, - hoveredSection: null, - }; - }, components: { diffDiscussions, + diffTableRow, diffLineNoteForm, diffLineGutterContent, }, computed: { - ...mapState({ - diffLineCommentForms: state => state.diffs.diffLineCommentForms, - }), - ...mapGetters(['discussionsByLineCode', 'isLoggedIn', 'commit']), + ...mapGetters(['commit']), commitId() { return this.commit && this.commit.id; }, @@ -41,15 +33,15 @@ export default { normalizedDiffLines() { return this.diffLines.map(line => { if (line.richText) { - return this.trimFirstChar(line); + return trimFirstCharOfLineContent(line); } if (line.left) { - Object.assign(line, { left: this.trimFirstChar(line.left) }); + Object.assign(line, { left: trimFirstCharOfLineContent(line.left) }); } if (line.right) { - Object.assign(line, { right: this.trimFirstChar(line.right) }); + Object.assign(line, { right: trimFirstCharOfLineContent(line.right) }); } return line; @@ -62,28 +54,4 @@ export default { return this.diffFile.fileHash; }, }, - methods: { - ...mapActions(['showCommentForm', 'cancelCommentForm']), - getRowClass(line) { - const isContextLine = line.left - ? line.left.type === CONTEXT_LINE_TYPE - : line.type === CONTEXT_LINE_TYPE; - - return { - [line.type]: line.type, - [CONTEXT_LINE_CLASS_NAME]: isContextLine, - }; - }, - trimFirstChar(line) { - return trimFirstCharOfLineContent(line); - }, - handleShowCommentForm(params) { - this.showCommentForm({ lineCode: params.lineCode }); - }, - isDiscussionExpanded(lineCode) { - const discussions = this.discussionsByLineCode[lineCode]; - - return discussions ? discussions.every(discussion => discussion.expanded) : false; - }, - }, }; diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index bf188a44022..5e0fd5109bb 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -15,10 +15,6 @@ export const setBaseConfig = ({ commit }, options) => { commit(types.SET_BASE_CONFIG, { endpoint, projectPath }); }; -export const setLoadingState = ({ commit }, state) => { - commit(types.SET_LOADING, state); -}; - export const fetchDiffFiles = ({ state, commit }) => { commit(types.SET_LOADING, true); @@ -88,7 +84,6 @@ export const expandAllFiles = ({ commit }) => { export default { setBaseConfig, - setLoadingState, fetchDiffFiles, setInlineDiffViewType, setParallelDiffViewType, diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index b755458aa4b..a5af37e80b6 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -1,12 +1,12 @@ /* eslint-disable consistent-return, no-new */ import $ from 'jquery'; -import Flash from './flash'; import GfmAutoComplete from './gfm_auto_complete'; import { convertPermissionToBoolean } from './lib/utils/common_utils'; import GlFieldErrors from './gl_field_errors'; import Shortcuts from './shortcuts'; import SearchAutocomplete from './search_autocomplete'; +import performanceBar from './performance_bar'; function initSearch() { // Only when search form is present @@ -72,9 +72,7 @@ function initGFMInput() { function initPerformanceBar() { if (document.querySelector('#js-peek')) { - import('./performance_bar') - .then(m => new m.default({ container: '#js-peek' })) // eslint-disable-line new-cap - .catch(() => Flash('Error loading performance bar module')); + performanceBar({ container: '#js-peek' }); } } diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index 4164149dd06..17ea3bdb179 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -1,7 +1,6 @@ -/* global dateFormat */ - import $ from 'jquery'; import Pikaday from 'pikaday'; +import dateFormat from 'dateformat'; import { __ } from '~/locale'; import axios from './lib/utils/axios_utils'; import { timeFor } from './lib/utils/datetime_utility'; @@ -55,7 +54,7 @@ class DueDateSelect { format: 'yyyy-mm-dd', parse: dateString => parsePikadayDate(dateString), toString: date => pikadayToString(date), - onSelect: (dateText) => { + onSelect: dateText => { $dueDateInput.val(calendar.toString(dateText)); if (this.$dropdown.hasClass('js-issue-boards-due-date')) { @@ -73,7 +72,7 @@ class DueDateSelect { } initRemoveDueDate() { - this.$block.on('click', '.js-remove-due-date', (e) => { + this.$block.on('click', '.js-remove-due-date', e => { const calendar = this.$datePicker.data('pikaday'); e.preventDefault(); @@ -124,7 +123,8 @@ class DueDateSelect { this.$loading.fadeOut(); }; - gl.issueBoards.BoardsStore.detail.issue.update(this.$dropdown.attr('data-issue-update')) + gl.issueBoards.BoardsStore.detail.issue + .update(this.$dropdown.attr('data-issue-update')) .then(fadeOutLoader) .catch(fadeOutLoader); } @@ -147,17 +147,18 @@ class DueDateSelect { $('.js-remove-due-date-holder').toggleClass('hidden', selectedDateValue.length); - return axios.put(this.issueUpdateURL, this.datePayload) - .then(() => { - const tooltipText = hasDueDate ? `${__('Due date')}<br />${selectedDateValue} (${timeFor(selectedDateValue)})` : __('Due date'); - if (isDropdown) { - this.$dropdown.trigger('loaded.gl.dropdown'); - this.$dropdown.dropdown('toggle'); - } - this.$sidebarCollapsedValue.attr('data-original-title', tooltipText); + return axios.put(this.issueUpdateURL, this.datePayload).then(() => { + const tooltipText = hasDueDate + ? `${__('Due date')}<br />${selectedDateValue} (${timeFor(selectedDateValue)})` + : __('Due date'); + if (isDropdown) { + this.$dropdown.trigger('loaded.gl.dropdown'); + this.$dropdown.dropdown('toggle'); + } + this.$sidebarCollapsedValue.attr('data-original-title', tooltipText); - return this.$loading.fadeOut(); - }); + return this.$loading.fadeOut(); + }); } } @@ -187,15 +188,19 @@ export default class DueDateSelectors { $datePicker.data('pikaday', calendar); }); - $('.js-clear-due-date,.js-clear-start-date').on('click', (e) => { + $('.js-clear-due-date,.js-clear-start-date').on('click', e => { e.preventDefault(); - const calendar = $(e.target).siblings('.datepicker').data('pikaday'); + const calendar = $(e.target) + .siblings('.datepicker') + .data('pikaday'); calendar.setDate(null); }); } // eslint-disable-next-line class-methods-use-this initIssuableSelect() { - const $loading = $('.js-issuable-update .due_date').find('.block-loading').hide(); + const $loading = $('.js-issuable-update .due_date') + .find('.block-loading') + .hide(); $('.js-due-date-select').each((i, dropdown) => { const $dropdown = $(dropdown); diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 09186a865e4..73b2cd0b2c7 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -12,7 +12,7 @@ export const defaultAutocompleteConfig = { members: true, issues: true, mergeRequests: true, - epics: false, + epics: true, milestones: true, labels: true, }; @@ -493,6 +493,7 @@ GfmAutoComplete.atTypeMap = { '@': 'members', '#': 'issues', '!': 'mergeRequests', + '&': 'epics', '~': 'labels', '%': 'milestones', '/': 'commands', diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index f802971a3ca..c74de7ac34d 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -9,6 +9,13 @@ export default class GLForm { this.form = form; this.textarea = this.form.find('textarea.js-gfm-input'); this.enableGFM = Object.assign({}, GFMConfig.defaultAutocompleteConfig, enableGFM); + // Disable autocomplete for keywords which do not have dataSources available + const dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {}; + Object.keys(this.enableGFM).forEach(item => { + if (item !== 'emojis') { + this.enableGFM[item] = !!dataSources[item]; + } + }); // Before we start, we should clean up any previous data for this form this.destroy(); // Setup the form diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue index b4f3778d946..eb7cb9745ec 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue @@ -10,7 +10,7 @@ export default { }, computed: { ...mapState(['currentBranchId', 'changedFiles', 'stagedFiles']), - ...mapGetters(['currentProject']), + ...mapGetters(['currentProject', 'currentBranch']), commitToCurrentBranchText() { return sprintf( __('Commit to %{branchName} branch'), @@ -22,17 +22,30 @@ export default { return this.changedFiles.length > 0 && this.stagedFiles.length > 0; }, }, + watch: { + disableMergeRequestRadio() { + this.updateSelectedCommitAction(); + }, + }, mounted() { - if (this.disableMergeRequestRadio) { - this.updateCommitAction(consts.COMMIT_TO_CURRENT_BRANCH); - } + this.updateSelectedCommitAction(); }, methods: { ...mapActions('commit', ['updateCommitAction']), + updateSelectedCommitAction() { + if (this.currentBranch && !this.currentBranch.can_push) { + this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH); + } else if (this.disableMergeRequestRadio) { + this.updateCommitAction(consts.COMMIT_TO_CURRENT_BRANCH); + } + }, }, commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH, commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH, commitToNewBranchMR: consts.COMMIT_TO_NEW_BRANCH_MR, + currentBranchPermissionsTooltip: __( + "This option is disabled as you don't have write permissions for the current branch", + ), }; </script> @@ -40,9 +53,11 @@ export default { <div class="append-bottom-15 ide-commit-radios"> <radio-group :value="$options.commitToCurrentBranch" - :checked="true" + :disabled="currentBranch && !currentBranch.can_push" + :title="$options.currentBranchPermissionsTooltip" > <span + class="ide-radio-label" v-html="commitToCurrentBranchText" > </span> @@ -56,6 +71,7 @@ export default { v-if="currentProject.merge_requests_enabled" :value="$options.commitToNewBranchMR" :label="__('Create a new branch and merge request')" + :title="__('This option is disabled while you still have unstaged changes')" :show-input="true" :disabled="disableMergeRequestRadio" /> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index 14c74687ab4..ee8eb206980 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -24,7 +24,7 @@ export default { ...mapState(['changedFiles', 'stagedFiles', 'currentActivityView', 'lastCommitMsg']), ...mapState('commit', ['commitMessage', 'submitCommitLoading']), ...mapGetters(['hasChanges']), - ...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled']), + ...mapGetters('commit', ['discardDraftButtonDisabled', 'preBuiltCommitMessage']), overviewText() { return sprintf( __( @@ -36,6 +36,9 @@ export default { }, ); }, + commitButtonText() { + return this.stagedFiles.length ? __('Commit') : __('Stage & Commit'); + }, }, watch: { currentActivityView() { @@ -136,14 +139,14 @@ export default { </transition> <commit-message-field :text="commitMessage" + :placeholder="preBuiltCommitMessage" @input="updateCommitMessage" /> <div class="clearfix prepend-top-15"> <actions /> <loading-button :loading="submitCommitLoading" - :disabled="commitButtonDisabled" - :label="__('Commit')" + :label="commitButtonText" container-class="btn btn-success btn-sm float-left" @click="commitChanges" /> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue index 40496c80a46..37ca108fafc 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue @@ -16,6 +16,10 @@ export default { type: String, required: true, }, + placeholder: { + type: String, + required: true, + }, }, data() { return { @@ -114,7 +118,7 @@ export default { </div> <textarea ref="textarea" - :placeholder="__('Write a commit message...')" + :placeholder="placeholder" :value="text" class="note-textarea ide-commit-message-textarea" name="commit-message" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue index 35ab3fd11df..969e2aa61c4 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue @@ -1,6 +1,5 @@ <script> import { mapActions, mapState, mapGetters } from 'vuex'; -import { __ } from '~/locale'; import tooltip from '~/vue_shared/directives/tooltip'; export default { @@ -32,14 +31,17 @@ export default { required: false, default: false, }, + title: { + type: String, + required: false, + default: '', + }, }, computed: { ...mapState('commit', ['commitAction']), ...mapGetters('commit', ['newBranchName']), tooltipTitle() { - return this.disabled - ? __('This option is disabled while you still have unstaged changes') - : ''; + return this.disabled ? this.title : ''; }, }, methods: { diff --git a/app/assets/javascripts/ide/components/error_message.vue b/app/assets/javascripts/ide/components/error_message.vue new file mode 100644 index 00000000000..acbc98b7a7b --- /dev/null +++ b/app/assets/javascripts/ide/components/error_message.vue @@ -0,0 +1,69 @@ +<script> +import { mapActions } from 'vuex'; +import LoadingIcon from '../../vue_shared/components/loading_icon.vue'; + +export default { + components: { + LoadingIcon, + }, + props: { + message: { + type: Object, + required: true, + }, + }, + data() { + return { + isLoading: false, + }; + }, + methods: { + ...mapActions(['setErrorMessage']), + clickAction() { + if (this.isLoading) return; + + this.isLoading = true; + + this.message + .action(this.message.actionPayload) + .then(() => { + this.isLoading = false; + }) + .catch(() => { + this.isLoading = false; + }); + }, + clickFlash() { + if (!this.message.action) { + this.setErrorMessage(null); + } + }, + }, +}; +</script> + +<template> + <div + class="flash-container flash-container-page" + @click="clickFlash" + > + <div class="flash-alert"> + <span + v-html="message.text" + > + </span> + <button + v-if="message.action" + type="button" + class="flash-action text-white p-0 border-top-0 border-right-0 border-left-0 bg-transparent" + @click.stop.prevent="clickAction" + > + {{ message.actionText }} + <loading-icon + v-show="isLoading" + inline + /> + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index f5f7f967a92..9f016e0338f 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -7,6 +7,7 @@ import IdeStatusBar from './ide_status_bar.vue'; import RepoEditor from './repo_editor.vue'; import FindFile from './file_finder/index.vue'; import RightPane from './panes/right.vue'; +import ErrorMessage from './error_message.vue'; const originalStopCallback = Mousetrap.stopCallback; @@ -18,6 +19,7 @@ export default { RepoEditor, FindFile, RightPane, + ErrorMessage, }, computed: { ...mapState([ @@ -28,6 +30,7 @@ export default { 'fileFindVisible', 'emptyStateSvgPath', 'currentProjectId', + 'errorMessage', ]), ...mapGetters(['activeFile', 'hasChanges']), }, @@ -72,6 +75,10 @@ export default { <template> <article class="ide"> + <error-message + v-if="errorMessage" + :message="errorMessage" + /> <div class="ide-view" > diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index c2c678ff0be..50ab242ba2a 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -28,7 +28,7 @@ export default { ]), ...mapState('commit', ['commitMessage', 'submitCommitLoading']), ...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommitedChanges', 'activeFile']), - ...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled']), + ...mapGetters('commit', ['discardDraftButtonDisabled']), showStageUnstageArea() { return !!(this.someUncommitedChanges || this.lastCommitMsg || !this.unusedSeal); }, diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index b52618f4fde..cc8dbb942d8 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -95,14 +95,6 @@ router.beforeEach((to, from, next) => { } }) .catch(e => { - flash( - 'Error while loading the branch files. Please try again.', - 'alert', - document, - null, - false, - true, - ); throw e; }); } else if (to.params.mrid) { diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index da9de25302a..3e939f0c1a3 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -1,15 +1,11 @@ -import Vue from 'vue'; -import VueResource from 'vue-resource'; +import axios from '~/lib/utils/axios_utils'; import Api from '~/api'; -Vue.use(VueResource); - export default { - getTreeData(endpoint) { - return Vue.http.get(endpoint, { params: { format: 'json' } }); - }, getFileData(endpoint) { - return Vue.http.get(endpoint, { params: { format: 'json', viewer: 'none' } }); + return axios.get(endpoint, { + params: { format: 'json', viewer: 'none' }, + }); }, getRawFileData(file) { if (file.tempFile) { @@ -20,7 +16,11 @@ export default { return Promise.resolve(file.raw); } - return Vue.http.get(file.rawPath, { params: { format: 'json' } }).then(res => res.text()); + return axios + .get(file.rawPath, { + params: { format: 'json' }, + }) + .then(({ data }) => data); }, getBaseRawFileData(file, sha) { if (file.tempFile) { @@ -31,11 +31,11 @@ export default { return Promise.resolve(file.baseRaw); } - return Vue.http + return axios .get(file.rawPath.replace(`/raw/${file.branchId}/${file.path}`, `/raw/${sha}/${file.path}`), { params: { format: 'json' }, }) - .then(res => res.text()); + .then(({ data }) => data); }, getProjectData(namespace, project) { return Api.project(`${namespace}/${project}`); @@ -52,28 +52,12 @@ export default { getBranchData(projectId, currentBranchId) { return Api.branchSingle(projectId, currentBranchId); }, - createBranch(projectId, payload) { - const url = Api.buildUrl(Api.createBranchPath).replace(':id', projectId); - - return Vue.http.post(url, payload); - }, commit(projectId, payload) { return Api.commitMultiple(projectId, payload); }, - getTreeLastCommit(endpoint) { - return Vue.http.get(endpoint, { - params: { - format: 'json', - }, - }); - }, getFiles(projectUrl, branchId) { const url = `${projectUrl}/files/${branchId}`; - return Vue.http.get(url, { - params: { - format: 'json', - }, - }); + return axios.get(url, { params: { format: 'json' } }); }, lastCommitPipelines({ getters }) { const commitSha = getters.lastCommit.id; diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index 3dc365eaead..5e91fa915ff 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -175,6 +175,9 @@ export const setRightPane = ({ commit }, view) => { export const setLinks = ({ commit }, links) => commit(types.SET_LINKS, links); +export const setErrorMessage = ({ commit }, errorMessage) => + commit(types.SET_ERROR_MESSAGE, errorMessage); + export * from './actions/tree'; export * from './actions/file'; export * from './actions/project'; diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 29995a29d1a..6c0887e11ee 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -1,5 +1,5 @@ -import { normalizeHeaders } from '~/lib/utils/common_utils'; -import flash from '~/flash'; +import { __ } from '../../../locale'; +import { normalizeHeaders } from '../../../lib/utils/common_utils'; import eventHub from '../../eventhub'; import service from '../../services'; import * as types from '../mutation_types'; @@ -66,13 +66,10 @@ export const getFileData = ({ state, commit, dispatch }, { path, makeFileActive .getFileData( `${gon.relative_url_root ? gon.relative_url_root : ''}${file.url.replace('/-/', '/')}`, ) - .then(res => { - const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']); - setPageTitle(pageTitle); + .then(({ data, headers }) => { + const normalizedHeaders = normalizeHeaders(headers); + setPageTitle(decodeURI(normalizedHeaders['PAGE-TITLE'])); - return res.json(); - }) - .then(data => { commit(types.SET_FILE_DATA, { data, file }); commit(types.TOGGLE_FILE_OPEN, path); if (makeFileActive) dispatch('setFileActive', path); @@ -80,7 +77,13 @@ export const getFileData = ({ state, commit, dispatch }, { path, makeFileActive }) .catch(() => { commit(types.TOGGLE_LOADING, { entry: file }); - flash('Error loading file data. Please try again.', 'alert', document, null, false, true); + dispatch('setErrorMessage', { + text: __('An error occured whilst loading the file.'), + action: payload => + dispatch('getFileData', payload).then(() => dispatch('setErrorMessage', null)), + actionText: __('Please try again'), + actionPayload: { path, makeFileActive }, + }); }); }; @@ -88,7 +91,7 @@ export const setFileMrChange = ({ commit }, { file, mrChange }) => { commit(types.SET_FILE_MERGE_REQUEST_CHANGE, { file, mrChange }); }; -export const getRawFileData = ({ state, commit }, { path, baseSha }) => { +export const getRawFileData = ({ state, commit, dispatch }, { path, baseSha }) => { const file = state.entries[path]; return new Promise((resolve, reject) => { service @@ -113,7 +116,13 @@ export const getRawFileData = ({ state, commit }, { path, baseSha }) => { } }) .catch(() => { - flash('Error loading file content. Please try again.'); + dispatch('setErrorMessage', { + text: __('An error occured whilst loading the file content.'), + action: payload => + dispatch('getRawFileData', payload).then(() => dispatch('setErrorMessage', null)), + actionText: __('Please try again'), + actionPayload: { path, baseSha }, + }); reject(); }); }); diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index edb20ff96fc..4aa151abcb7 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -1,17 +1,16 @@ -import flash from '~/flash'; +import { __ } from '../../../locale'; import service from '../../services'; import * as types from '../mutation_types'; export const getMergeRequestData = ( - { commit, state }, + { commit, dispatch, state }, { projectId, mergeRequestId, force = false } = {}, ) => new Promise((resolve, reject) => { if (!state.projects[projectId].mergeRequests[mergeRequestId] || force) { service .getProjectMergeRequestData(projectId, mergeRequestId) - .then(res => res.data) - .then(data => { + .then(({ data }) => { commit(types.SET_MERGE_REQUEST, { projectPath: projectId, mergeRequestId, @@ -21,7 +20,15 @@ export const getMergeRequestData = ( resolve(data); }) .catch(() => { - flash('Error loading merge request data. Please try again.'); + dispatch('setErrorMessage', { + text: __('An error occured whilst loading the merge request.'), + action: payload => + dispatch('getMergeRequestData', payload).then(() => + dispatch('setErrorMessage', null), + ), + actionText: __('Please try again'), + actionPayload: { projectId, mergeRequestId, force }, + }); reject(new Error(`Merge Request not loaded ${projectId}`)); }); } else { @@ -30,15 +37,14 @@ export const getMergeRequestData = ( }); export const getMergeRequestChanges = ( - { commit, state }, + { commit, dispatch, state }, { projectId, mergeRequestId, force = false } = {}, ) => new Promise((resolve, reject) => { if (!state.projects[projectId].mergeRequests[mergeRequestId].changes.length || force) { service .getProjectMergeRequestChanges(projectId, mergeRequestId) - .then(res => res.data) - .then(data => { + .then(({ data }) => { commit(types.SET_MERGE_REQUEST_CHANGES, { projectPath: projectId, mergeRequestId, @@ -47,7 +53,15 @@ export const getMergeRequestChanges = ( resolve(data); }) .catch(() => { - flash('Error loading merge request changes. Please try again.'); + dispatch('setErrorMessage', { + text: __('An error occured whilst loading the merge request changes.'), + action: payload => + dispatch('getMergeRequestChanges', payload).then(() => + dispatch('setErrorMessage', null), + ), + actionText: __('Please try again'), + actionPayload: { projectId, mergeRequestId, force }, + }); reject(new Error(`Merge Request Changes not loaded ${projectId}`)); }); } else { @@ -56,7 +70,7 @@ export const getMergeRequestChanges = ( }); export const getMergeRequestVersions = ( - { commit, state }, + { commit, dispatch, state }, { projectId, mergeRequestId, force = false } = {}, ) => new Promise((resolve, reject) => { @@ -73,7 +87,15 @@ export const getMergeRequestVersions = ( resolve(data); }) .catch(() => { - flash('Error loading merge request versions. Please try again.'); + dispatch('setErrorMessage', { + text: __('An error occured whilst loading the merge request version data.'), + action: payload => + dispatch('getMergeRequestVersions', payload).then(() => + dispatch('setErrorMessage', null), + ), + actionText: __('Please try again'), + actionPayload: { projectId, mergeRequestId, force }, + }); reject(new Error(`Merge Request Versions not loaded ${projectId}`)); }); } else { diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index 0b99bce4a8e..501e25d452b 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -1,7 +1,10 @@ +import _ from 'underscore'; import flash from '~/flash'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; import service from '../../services'; +import api from '../../../api'; import * as types from '../mutation_types'; +import router from '../../ide_router'; export const getProjectData = ({ commit, state }, { namespace, projectId, force = false } = {}) => new Promise((resolve, reject) => { @@ -32,7 +35,10 @@ export const getProjectData = ({ commit, state }, { namespace, projectId, force } }); -export const getBranchData = ({ commit, state }, { projectId, branchId, force = false } = {}) => +export const getBranchData = ( + { commit, dispatch, state }, + { projectId, branchId, force = false } = {}, +) => new Promise((resolve, reject) => { if ( typeof state.projects[`${projectId}`] === 'undefined' || @@ -51,15 +57,19 @@ export const getBranchData = ({ commit, state }, { projectId, branchId, force = commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id }); resolve(data); }) - .catch(() => { - flash( - __('Error loading branch data. Please try again.'), - 'alert', - document, - null, - false, - true, - ); + .catch(e => { + if (e.response.status === 404) { + dispatch('showBranchNotFoundError', branchId); + } else { + flash( + __('Error loading branch data. Please try again.'), + 'alert', + document, + null, + false, + true, + ); + } reject(new Error(`Branch not loaded - ${projectId}/${branchId}`)); }); } else { @@ -80,3 +90,37 @@ export const refreshLastCommitData = ({ commit }, { projectId, branchId } = {}) .catch(() => { flash(__('Error loading last commit.'), 'alert', document, null, false, true); }); + +export const createNewBranchFromDefault = ({ state, dispatch, getters }, branch) => + api + .createBranch(state.currentProjectId, { + ref: getters.currentProject.default_branch, + branch, + }) + .then(() => { + dispatch('setErrorMessage', null); + router.push(`${router.currentRoute.path}?${Date.now()}`); + }) + .catch(() => { + dispatch('setErrorMessage', { + text: __('An error occured creating the new branch.'), + action: payload => dispatch('createNewBranchFromDefault', payload), + actionText: __('Please try again'), + actionPayload: branch, + }); + }); + +export const showBranchNotFoundError = ({ dispatch }, branchId) => { + dispatch('setErrorMessage', { + text: sprintf( + __("Branch %{branchName} was not found in this project's repository."), + { + branchName: `<strong>${_.escape(branchId)}</strong>`, + }, + false, + ), + action: payload => dispatch('createNewBranchFromDefault', payload), + actionText: __('Create branch'), + actionPayload: branchId, + }); +}; diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js index 2fbc9990fa2..ffaaaabff17 100644 --- a/app/assets/javascripts/ide/stores/actions/tree.js +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -1,8 +1,6 @@ -import { normalizeHeaders } from '~/lib/utils/common_utils'; -import flash from '~/flash'; +import { __ } from '../../../locale'; import service from '../../services'; import * as types from '../mutation_types'; -import { findEntry } from '../utils'; import FilesDecoratorWorker from '../workers/files_decorator_worker'; export const toggleTreeOpen = ({ commit }, path) => { @@ -36,42 +34,19 @@ export const handleTreeEntryAction = ({ commit, dispatch }, row) => { dispatch('showTreeEntry', row.path); }; -export const getLastCommitData = ({ state, commit, dispatch }, tree = state) => { - if (!tree || tree.lastCommitPath === null || !tree.lastCommitPath) return; - - service - .getTreeLastCommit(tree.lastCommitPath) - .then(res => { - const lastCommitPath = normalizeHeaders(res.headers)['MORE-LOGS-URL'] || null; - - commit(types.SET_LAST_COMMIT_URL, { tree, url: lastCommitPath }); - - return res.json(); - }) - .then(data => { - data.forEach(lastCommit => { - const entry = findEntry(tree.tree, lastCommit.type, lastCommit.file_name); - - if (entry) { - commit(types.SET_LAST_COMMIT_DATA, { entry, lastCommit }); - } - }); - - dispatch('getLastCommitData', tree); - }) - .catch(() => flash('Error fetching log data.', 'alert', document, null, false, true)); -}; - -export const getFiles = ({ state, commit }, { projectId, branchId } = {}) => +export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } = {}) => new Promise((resolve, reject) => { - if (!state.trees[`${projectId}/${branchId}`]) { + if ( + !state.trees[`${projectId}/${branchId}`] || + (state.trees[`${projectId}/${branchId}`].tree && + state.trees[`${projectId}/${branchId}`].tree.length === 0) + ) { const selectedProject = state.projects[projectId]; commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` }); service .getFiles(selectedProject.web_url, branchId) - .then(res => res.json()) - .then(data => { + .then(({ data }) => { const worker = new FilesDecoratorWorker(); worker.addEventListener('message', e => { const { entries, treeList } = e.data; @@ -99,7 +74,17 @@ export const getFiles = ({ state, commit }, { projectId, branchId } = {}) => }); }) .catch(e => { - flash('Error loading tree data. Please try again.', 'alert', document, null, false, true); + if (e.response.status === 404) { + dispatch('showBranchNotFoundError', branchId); + } else { + dispatch('setErrorMessage', { + text: __('An error occured whilst loading all the files.'), + action: payload => + dispatch('getFiles', payload).then(() => dispatch('setErrorMessage', null)), + actionText: __('Please try again'), + actionPayload: { projectId, branchId }, + }); + } reject(e); }); } else { diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index b239a605371..5ce268b0d05 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -82,10 +82,13 @@ export const getStagedFilesCountForPath = state => path => getChangesCountForFiles(state.stagedFiles, path); export const lastCommit = (state, getters) => { - const branch = getters.currentProject && getters.currentProject.branches[state.currentBranchId]; + const branch = getters.currentProject && getters.currentBranch; return branch ? branch.commit : null; }; +export const currentBranch = (state, getters) => + getters.currentProject && getters.currentProject.branches[state.currentBranchId]; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index 7219abc4185..69b6fe2985b 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -103,17 +103,24 @@ export const updateFilesAfterCommit = ({ commit, dispatch, rootState }, { data } export const commitChanges = ({ commit, state, getters, dispatch, rootState, rootGetters }) => { const newBranch = state.commitAction !== consts.COMMIT_TO_CURRENT_BRANCH; - const payload = createCommitPayload({ - branch: getters.branchName, - newBranch, - state, - rootState, - }); + const stageFilesPromise = rootState.stagedFiles.length + ? Promise.resolve() + : dispatch('stageAllChanges', null, { root: true }); commit(types.UPDATE_LOADING, true); - return service - .commit(rootState.currentProjectId, payload) + return stageFilesPromise + .then(() => { + const payload = createCommitPayload({ + branch: getters.branchName, + newBranch, + getters, + state, + rootState, + }); + + return service.commit(rootState.currentProjectId, payload); + }) .then(({ data }) => { commit(types.UPDATE_LOADING, false); diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js index d01060201f2..3db4b2f903e 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/getters.js +++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js @@ -1,3 +1,4 @@ +import { sprintf, n__ } from '../../../../locale'; import * as consts from './constants'; const BRANCH_SUFFIX_COUNT = 5; @@ -5,9 +6,6 @@ const BRANCH_SUFFIX_COUNT = 5; export const discardDraftButtonDisabled = state => state.commitMessage === '' || state.submitCommitLoading; -export const commitButtonDisabled = (state, getters, rootState) => - getters.discardDraftButtonDisabled || !rootState.stagedFiles.length; - export const newBranchName = (state, _, rootState) => `${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr( -BRANCH_SUFFIX_COUNT, @@ -28,5 +26,18 @@ export const branchName = (state, getters, rootState) => { return rootState.currentBranchId; }; +export const preBuiltCommitMessage = (state, _, rootState) => { + if (state.commitMessage) return state.commitMessage; + + const files = (rootState.stagedFiles.length + ? rootState.stagedFiles + : rootState.changedFiles + ).reduce((acc, val) => acc.concat(val.path), []); + + return sprintf(n__('Update %{files}', 'Update %{files} files', files.length), { + files: files.join(', '), + }); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index fda606dbf01..555802e1811 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -72,3 +72,5 @@ export const SET_RIGHT_PANE = 'SET_RIGHT_PANE'; export const CLEAR_PROJECTS = 'CLEAR_PROJECTS'; export const RESET_OPEN_FILES = 'RESET_OPEN_FILES'; + +export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index 48f1da4eccf..702be2140e2 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -163,6 +163,9 @@ export default { [types.RESET_OPEN_FILES](state) { Object.assign(state, { openFiles: [] }); }, + [types.SET_ERROR_MESSAGE](state, errorMessage) { + Object.assign(state, { errorMessage }); + }, ...projectMutations, ...mergeRequestMutation, ...fileMutations, diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index 4aac4696075..be229b2c723 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -25,4 +25,5 @@ export default () => ({ fileFindVisible: false, rightPane: null, links: {}, + errorMessage: null, }); diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 10368a4d97c..9e6b86dd844 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -105,9 +105,9 @@ export const setPageTitle = title => { document.title = title; }; -export const createCommitPayload = ({ branch, newBranch, state, rootState }) => ({ +export const createCommitPayload = ({ branch, getters, newBranch, state, rootState }) => ({ branch, - commit_message: state.commitMessage, + commit_message: state.commitMessage || getters.preBuiltCommitMessage, actions: rootState.stagedFiles.map(f => ({ action: f.tempFile ? 'create' : 'update', file_path: f.path, diff --git a/app/assets/javascripts/job.js b/app/assets/javascripts/job.js index fc13f467675..d4f2a3ef7d3 100644 --- a/app/assets/javascripts/job.js +++ b/app/assets/javascripts/job.js @@ -164,7 +164,7 @@ export default class Job extends LogOutputBehaviours { // eslint-disable-next-line class-methods-use-this shouldHideSidebarForViewport() { const bootstrapBreakpoint = bp.getBreakpointSize(); - return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm'; + return bootstrapBreakpoint === 'xs'; } toggleSidebar(shouldHide) { diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 7cca32dc6fa..1f66fa811ea 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -1,11 +1,10 @@ import $ from 'jquery'; import timeago from 'timeago.js'; -import dateFormat from 'vendor/date.format'; +import dateFormat from 'dateformat'; import { pluralize } from './text_utility'; import { languageCode, s__ } from '../../locale'; window.timeago = timeago; -window.dateFormat = dateFormat; /** * Returns i18n month names array. @@ -143,7 +142,8 @@ export const localTimeAgo = ($timeagoEls, setTimeago = true) => { if (setTimeago) { // Recreate with custom template $(el).tooltip({ - template: '<div class="tooltip local-timeago" role="tooltip"><div class="arrow"></div><div class="tooltip-inner"></div></div>', + template: + '<div class="tooltip local-timeago" role="tooltip"><div class="arrow"></div><div class="tooltip-inner"></div></div>', }); } @@ -275,10 +275,8 @@ export const totalDaysInMonth = date => { * * @param {Array} quarter */ -export const totalDaysInQuarter = quarter => quarter.reduce( - (acc, month) => acc + totalDaysInMonth(month), - 0, -); +export const totalDaysInQuarter = quarter => + quarter.reduce((acc, month) => acc + totalDaysInMonth(month), 0); /** * Returns list of Dates referring to Sundays of the month @@ -333,14 +331,8 @@ export const getTimeframeWindowFrom = (startDate, length) => { // Iterate and set date for the size of length // and push date reference to timeframe list const timeframe = new Array(length) - .fill() - .map( - (val, i) => new Date( - startDate.getFullYear(), - startDate.getMonth() + i, - 1, - ), - ); + .fill() + .map((val, i) => new Date(startDate.getFullYear(), startDate.getMonth() + i, 1)); // Change date of last timeframe item to last date of the month timeframe[length - 1].setDate(totalDaysInMonth(timeframe[length - 1])); @@ -362,14 +354,15 @@ export const getTimeframeWindowFrom = (startDate, length) => { * @param {Date} date * @param {Array} quarter */ -export const dayInQuarter = (date, quarter) => quarter.reduce((acc, month) => { - if (date.getMonth() > month.getMonth()) { - return acc + totalDaysInMonth(month); - } else if (date.getMonth() === month.getMonth()) { - return acc + date.getDate(); - } - return acc + 0; -}, 0); +export const dayInQuarter = (date, quarter) => + quarter.reduce((acc, month) => { + if (date.getMonth() > month.getMonth()) { + return acc + totalDaysInMonth(month); + } else if (date.getMonth() === month.getMonth()) { + return acc + date.getDate(); + } + return acc + 0; + }, 0); window.gl = window.gl || {}; window.gl.utils = { diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 329d4303132..53d7504de35 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -16,6 +16,7 @@ import Diff from './diff'; import { localTimeAgo } from './lib/utils/datetime_utility'; import syntaxHighlight from './syntax_highlight'; import Notes from './notes'; +import { polyfillSticky } from './lib/utils/sticky'; /* eslint-disable max-len */ // MergeRequestTabs @@ -68,12 +69,23 @@ let { location } = window; export default class MergeRequestTabs { constructor({ action, setUrl, stubLocation } = {}) { - const mergeRequestTabs = document.querySelector('.js-tabs-affix'); + this.mergeRequestTabs = document.querySelector('.merge-request-tabs-container'); + this.mergeRequestTabsAll = + this.mergeRequestTabs && this.mergeRequestTabs.querySelectorAll + ? this.mergeRequestTabs.querySelectorAll('.merge-request-tabs li') + : null; + this.mergeRequestTabPanes = document.querySelector('#diff-notes-app'); + this.mergeRequestTabPanesAll = + this.mergeRequestTabPanes && this.mergeRequestTabPanes.querySelectorAll + ? this.mergeRequestTabPanes.querySelectorAll('.tab-pane') + : null; const navbar = document.querySelector('.navbar-gitlab'); const peek = document.getElementById('js-peek'); const paddingTop = 16; + this.commitsTab = document.querySelector('.tab-content .commits.tab-pane'); + this.currentTab = null; this.diffsLoaded = false; this.pipelinesLoaded = false; this.commitsLoaded = false; @@ -83,15 +95,15 @@ export default class MergeRequestTabs { this.setUrl = setUrl !== undefined ? setUrl : true; this.setCurrentAction = this.setCurrentAction.bind(this); this.tabShown = this.tabShown.bind(this); - this.showTab = this.showTab.bind(this); + this.clickTab = this.clickTab.bind(this); this.stickyTop = navbar ? navbar.offsetHeight - paddingTop : 0; if (peek) { this.stickyTop += peek.offsetHeight; } - if (mergeRequestTabs) { - this.stickyTop += mergeRequestTabs.offsetHeight; + if (this.mergeRequestTabs) { + this.stickyTop += this.mergeRequestTabs.offsetHeight; } if (stubLocation) { @@ -99,25 +111,22 @@ export default class MergeRequestTabs { } this.bindEvents(); - this.activateTab(action); + if ( + this.mergeRequestTabs && + this.mergeRequestTabs.querySelector(`a[data-action='${action}']`) && + this.mergeRequestTabs.querySelector(`a[data-action='${action}']`).click + ) + this.mergeRequestTabs.querySelector(`a[data-action='${action}']`).click(); this.initAffix(); } bindEvents() { - $(document) - .on('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown) - .on('click', '.js-show-tab', this.showTab); - - $('.merge-request-tabs a[data-toggle="tab"]').on('click', this.clickTab); + $('.merge-request-tabs a[data-toggle="tabvue"]').on('click', this.clickTab); } // Used in tests unbindEvents() { - $(document) - .off('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown) - .off('click', '.js-show-tab', this.showTab); - - $('.merge-request-tabs a[data-toggle="tab"]').off('click', this.clickTab); + $('.merge-request-tabs a[data-toggle="tabvue"]').off('click', this.clickTab); } destroyPipelinesView() { @@ -129,58 +138,87 @@ export default class MergeRequestTabs { } } - showTab(e) { - e.preventDefault(); - this.activateTab($(e.target).data('action')); - } - clickTab(e) { - if (e.currentTarget && isMetaClick(e)) { - const targetLink = e.currentTarget.getAttribute('href'); + if (e.currentTarget) { e.stopImmediatePropagation(); e.preventDefault(); - window.open(targetLink, '_blank'); + + const { action } = e.currentTarget.dataset; + + if (action) { + const href = e.currentTarget.getAttribute('href'); + this.tabShown(action, href); + } else if (isMetaClick(e)) { + const targetLink = e.currentTarget.getAttribute('href'); + window.open(targetLink, '_blank'); + } } } - tabShown(e) { - const $target = $(e.target); - const action = $target.data('action'); - - if (action === 'commits') { - this.loadCommits($target.attr('href')); - this.expandView(); - this.resetViewContainer(); - this.destroyPipelinesView(); - } else if (this.isDiffAction(action)) { - if (!isInVueNoteablePage()) { - this.loadDiff($target.attr('href')); - } - if (bp.getBreakpointSize() !== 'lg') { - this.shrinkView(); + tabShown(action, href) { + if (action !== this.currentTab && this.mergeRequestTabs) { + this.currentTab = action; + + if (this.mergeRequestTabPanesAll) { + this.mergeRequestTabPanesAll.forEach(el => { + const tabPane = el; + tabPane.style.display = 'none'; + }); } - if (this.diffViewType() === 'parallel') { - this.expandViewContainer(); + + if (this.mergeRequestTabsAll) { + this.mergeRequestTabsAll.forEach(el => { + el.classList.remove('active'); + }); } - this.destroyPipelinesView(); - this.commitsTab.classList.remove('active'); - } else if (action === 'pipelines') { - this.resetViewContainer(); - this.mountPipelinesView(); - } else { - if (bp.getBreakpointSize() !== 'xs') { + + const tabPane = this.mergeRequestTabPanes.querySelector(`#${action}`); + if (tabPane) tabPane.style.display = 'block'; + const tab = this.mergeRequestTabs.querySelector(`.${action}-tab`); + if (tab) tab.classList.add('active'); + + if (action === 'commits') { + this.loadCommits(href); + this.expandView(); + this.resetViewContainer(); + this.destroyPipelinesView(); + } else if (action === 'new') { this.expandView(); + this.resetViewContainer(); + this.destroyPipelinesView(); + } else if (this.isDiffAction(action)) { + if (!isInVueNoteablePage()) { + this.loadDiff(href); + } + if (bp.getBreakpointSize() !== 'lg') { + this.shrinkView(); + } + if (this.diffViewType() === 'parallel') { + this.expandViewContainer(); + } + this.destroyPipelinesView(); + this.commitsTab.classList.remove('active'); + } else if (action === 'pipelines') { + this.resetViewContainer(); + this.mountPipelinesView(); + } else { + this.mergeRequestTabPanes.querySelector('#notes').style.display = 'block'; + this.mergeRequestTabs.querySelector('.notes-tab').classList.add('active'); + + if (bp.getBreakpointSize() !== 'xs') { + this.expandView(); + } + this.resetViewContainer(); + this.destroyPipelinesView(); + + initDiscussionTab(); + } + if (this.setUrl) { + this.setCurrentAction(action); } - this.resetViewContainer(); - this.destroyPipelinesView(); - initDiscussionTab(); - } - if (this.setUrl) { - this.setCurrentAction(action); + this.eventHub.$emit('MergeRequestTabChange', this.getCurrentAction()); } - - this.eventHub.$emit('MergeRequestTabChange', this.getCurrentAction()); } scrollToElement(container) { @@ -193,12 +231,6 @@ export default class MergeRequestTabs { } } - // Activate a tab based on the current action - activateTab(action) { - // important note: the .tab('show') method triggers 'shown.bs.tab' event itself - $(`.merge-request-tabs a[data-action='${action}']`).tab('show'); - } - // Replaces the current Merge Request-specific action in the URL with a new one // // If the action is "notes", the URL is reset to the standard @@ -426,7 +458,6 @@ export default class MergeRequestTabs { initAffix() { const $tabs = $('.js-tabs-affix'); - const $fixedNav = $('.navbar-gitlab'); // Screen space on small screens is usually very sparse // So we dont affix the tabs on these @@ -439,21 +470,6 @@ export default class MergeRequestTabs { */ if ($tabs.css('position') !== 'static') return; - const $diffTabs = $('#diff-notes-app'); - - $tabs - .off('affix.bs.affix affix-top.bs.affix') - .affix({ - offset: { - top: () => $diffTabs.offset().top - $tabs.height() - $fixedNav.height(), - }, - }) - .on('affix.bs.affix', () => $diffTabs.css({ marginTop: $tabs.height() })) - .on('affix-top.bs.affix', () => $diffTabs.css({ marginTop: '' })); - - // Fix bug when reloading the page already scrolling - if ($tabs.hasClass('affix')) { - $tabs.trigger('affix.bs.affix'); - } + polyfillSticky($tabs); } } diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index 3c0c9995cc2..8aabb840847 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -45,17 +45,17 @@ export default function initMrNotes() { this.updateDiscussionTabCounter(); }, }, + created() { + this.setActiveTab(window.mrTabs.getCurrentAction()); + }, mounted() { this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge'); - this.setActiveTab(window.mrTabs.getCurrentAction()); - - window.mrTabs.eventHub.$on('MergeRequestTabChange', tab => { - this.setActiveTab(tab); - }); $(document).on('visibilitychange', this.updateDiscussionTabCounter); + window.mrTabs.eventHub.$on('MergeRequestTabChange', this.setActiveTab); }, beforeDestroy() { $(document).off('visibilitychange', this.updateDiscussionTabCounter); + window.mrTabs.eventHub.$off('MergeRequestTabChange', this.setActiveTab); }, methods: { ...mapActions(['setActiveTab']), diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index 521b4d16286..225d9f18612 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -200,6 +200,7 @@ export default { :class="getAwardClassBindings(awardList, awardName)" :title="awardTitle(awardList)" class="btn award-control" + data-boundary="viewport" data-placement="bottom" type="button" @click="handleAward(awardName)"> @@ -217,6 +218,7 @@ export default { class="award-control btn js-add-award" title="Add reaction" aria-label="Add reaction" + data-boundary="viewport" data-placement="bottom" type="button"> <span diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 98f8b9af168..a8995021699 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -3,6 +3,7 @@ import { mapGetters, mapActions } from 'vuex'; import { getLocationHash } from '../../lib/utils/url_utility'; import Flash from '../../flash'; import * as constants from '../constants'; +import eventHub from '../event_hub'; import noteableNote from './noteable_note.vue'; import noteableDiscussion from './noteable_discussion.vue'; import systemNote from '../../vue_shared/components/notes/system_note.vue'; @@ -49,7 +50,7 @@ export default { }; }, computed: { - ...mapGetters(['discussions', 'getNotesDataByProp', 'discussionCount']), + ...mapGetters(['isNotesFetched', 'discussions', 'getNotesDataByProp', 'discussionCount']), noteableType() { return this.noteableData.noteableType; }, @@ -61,19 +62,30 @@ export default { isSkeletonNote: true, }); } + return this.discussions; }, }, + watch: { + shouldShow() { + if (!this.isNotesFetched) { + this.fetchNotes(); + } + }, + }, created() { this.setNotesData(this.notesData); this.setNoteableData(this.noteableData); this.setUserData(this.userData); this.setTargetNoteHash(getLocationHash()); + eventHub.$once('fetchNotesData', this.fetchNotes); }, mounted() { - this.fetchNotes(); - const { parentElement } = this.$el; + if (this.shouldShow) { + this.fetchNotes(); + } + const { parentElement } = this.$el; if (parentElement && parentElement.classList.contains('js-vue-notes-event')) { parentElement.addEventListener('toggleAward', event => { const { awardName, noteId } = event.detail; @@ -93,6 +105,7 @@ export default { setLastFetchedAt: 'setLastFetchedAt', setTargetNoteHash: 'setTargetNoteHash', toggleDiscussion: 'toggleDiscussion', + setNotesFetchedState: 'setNotesFetchedState', }), getComponentName(discussion) { if (discussion.isSkeletonNote) { @@ -119,11 +132,13 @@ export default { }) .then(() => { this.isLoading = false; + this.setNotesFetchedState(true); }) .then(() => this.$nextTick()) .then(() => this.checkLocationHash()) .catch(() => { this.isLoading = false; + this.setNotesFetchedState(true); Flash('Something went wrong while fetching comments. Please try again.'); }); }, @@ -160,12 +175,13 @@ export default { <template> <div - v-if="shouldShow" - id="notes"> + v-show="shouldShow" + id="notes" + > <ul id="notes-list" - class="notes main-notes-list timeline"> - + class="notes main-notes-list timeline" + > <component v-for="discussion in allDiscussions" :is="getComponentName(discussion)" diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 0a40b48257f..671fa4d7d22 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -28,6 +28,9 @@ export const setInitialNotes = ({ commit }, discussions) => export const setTargetNoteHash = ({ commit }, data) => commit(types.SET_TARGET_NOTE_HASH, data); +export const setNotesFetchedState = ({ commit }, state) => + commit(types.SET_NOTES_FETCHED_STATE, state); + export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data); export const fetchDiscussions = ({ commit }, path) => diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index ab28bb48e9e..a5518383d44 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -8,6 +8,8 @@ export const targetNoteHash = state => state.targetNoteHash; export const getNotesData = state => state.notesData; +export const isNotesFetched = state => state.isNotesFetched; + export const getNotesDataByProp = state => prop => state.notesData[prop]; export const getNoteableData = state => state.noteableData; diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index a978490c009..b4cb9267e0f 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -10,6 +10,7 @@ export default { // View layer isToggleStateButtonLoading: false, + isNotesFetched: false, // holds endpoints and permissions provided through haml notesData: { diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index caead4cb860..a25098fbc06 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -15,6 +15,7 @@ export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION'; export const UPDATE_NOTE = 'UPDATE_NOTE'; export const UPDATE_DISCUSSION = 'UPDATE_DISCUSSION'; export const SET_DISCUSSION_DIFF_LINES = 'SET_DISCUSSION_DIFF_LINES'; +export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE'; // Issue export const CLOSE_ISSUE = 'CLOSE_ISSUE'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index ea165709e61..e5e40ce07fa 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -205,6 +205,10 @@ export default { Object.assign(state, { isToggleStateButtonLoading: value }); }, + [types.SET_NOTES_FETCHED_STATE](state, value) { + Object.assign(state, { isNotesFetched: value }); + }, + [types.SET_DISCUSSION_DIFF_LINES](state, { discussionId, diffLines }) { const discussion = utils.findNoteObjectById(state.discussions, discussionId); const index = state.discussions.indexOf(discussion); diff --git a/app/assets/javascripts/pages/projects/wikis/wikis.js b/app/assets/javascripts/pages/projects/wikis/wikis.js index dcd0b9a76ce..d3e8dbf4000 100644 --- a/app/assets/javascripts/pages/projects/wikis/wikis.js +++ b/app/assets/javascripts/pages/projects/wikis/wikis.js @@ -48,7 +48,7 @@ export default class Wikis { static sidebarCanCollapse() { const bootstrapBreakpoint = bp.getBreakpointSize(); - return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm'; + return bootstrapBreakpoint === 'xs'; } renderSidebar() { diff --git a/app/assets/javascripts/pages/search/show/search.js b/app/assets/javascripts/pages/search/show/search.js index 2e1fe78b3fa..e3e0ab91993 100644 --- a/app/assets/javascripts/pages/search/show/search.js +++ b/app/assets/javascripts/pages/search/show/search.js @@ -105,7 +105,7 @@ export default class Search { getProjectsData(term) { return new Promise((resolve) => { if (this.groupId) { - Api.groupProjects(this.groupId, term, resolve); + Api.groupProjects(this.groupId, term, {}, resolve); } else { Api.projects(term, { order_by: 'id', diff --git a/app/assets/javascripts/pages/snippets/form.js b/app/assets/javascripts/pages/snippets/form.js index 758bbafead3..f369c7ef9a6 100644 --- a/app/assets/javascripts/pages/snippets/form.js +++ b/app/assets/javascripts/pages/snippets/form.js @@ -8,6 +8,7 @@ export default () => { members: false, issues: false, mergeRequests: false, + epics: false, milestones: false, labels: false, }); diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index 50d042fef29..9892a039941 100644 --- a/app/assets/javascripts/pages/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import _ from 'underscore'; import { scaleLinear, scaleThreshold } from 'd3-scale'; import { select } from 'd3-selection'; +import dateFormat from 'dateformat'; import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility'; import axios from '~/lib/utils/axios_utils'; import flash from '~/flash'; @@ -26,7 +27,7 @@ function getSystemDate(systemUtcOffsetSeconds) { function formatTooltipText({ date, count }) { const dateObject = new Date(date); const dateDayName = getDayName(dateObject); - const dateText = dateObject.format('mmm d, yyyy'); + const dateText = dateFormat(dateObject, 'mmm d, yyyy'); let contribText = 'No contributions'; if (count > 0) { @@ -84,7 +85,7 @@ export default class ActivityCalendar { date.setDate(date.getDate() + i); const day = date.getDay(); - const count = timestamps[date.format('yyyy-mm-dd')] || 0; + const count = timestamps[dateFormat(date, 'yyyy-mm-dd')] || 0; // Create a new group array if this is the first day of the week // or if is first object diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index 8ffaa52d9e8..b76965f280b 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -113,7 +113,7 @@ export default { > <div v-if="currentRequest" - class="container-fluid container-limited" + class="d-flex container-fluid container-limited" > <div id="peek-view-host" @@ -179,6 +179,7 @@ export default { v-if="currentRequest" :current-request="currentRequest" :requests="requests" + class="ml-auto" @change-current-request="changeCurrentRequest" /> </div> diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue index dd9578a6c7f..ad74f7b38f9 100644 --- a/app/assets/javascripts/performance_bar/components/request_selector.vue +++ b/app/assets/javascripts/performance_bar/components/request_selector.vue @@ -35,10 +35,7 @@ export default { }; </script> <template> - <div - id="peek-request-selector" - class="float-right" - > + <div id="peek-request-selector"> <select v-model="currentRequestId"> <option v-for="request in requests" diff --git a/app/assets/javascripts/pipelines/components/blank_state.vue b/app/assets/javascripts/pipelines/components/blank_state.vue index f3219b8291c..34360105176 100644 --- a/app/assets/javascripts/pipelines/components/blank_state.vue +++ b/app/assets/javascripts/pipelines/components/blank_state.vue @@ -1,18 +1,18 @@ <script> - export default { - name: 'PipelinesSvgState', - props: { - svgPath: { - type: String, - required: true, - }, +export default { + name: 'PipelinesSvgState', + props: { + svgPath: { + type: String, + required: true, + }, - message: { - type: String, - required: true, - }, + message: { + type: String, + required: true, }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/pipelines/components/empty_state.vue b/app/assets/javascripts/pipelines/components/empty_state.vue index 50c27bed9fd..c5a45afc634 100644 --- a/app/assets/javascripts/pipelines/components/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/empty_state.vue @@ -1,21 +1,21 @@ <script> - export default { - name: 'PipelinesEmptyState', - props: { - helpPagePath: { - type: String, - required: true, - }, - emptyStateSvgPath: { - type: String, - required: true, - }, - canSetCi: { - type: Boolean, - required: true, - }, +export default { + name: 'PipelinesEmptyState', + props: { + helpPagePath: { + type: String, + required: true, }, - }; + emptyStateSvgPath: { + type: String, + required: true, + }, + canSetCi: { + type: Boolean, + required: true, + }, + }, +}; </script> <template> <div class="row empty-state js-empty-state"> diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue index 1f152ed438d..b82e28a0735 100644 --- a/app/assets/javascripts/pipelines/components/graph/action_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -41,7 +41,6 @@ export default { type: String, required: true, }, - }, data() { return { @@ -67,7 +66,8 @@ export default { this.isDisabled = true; - axios.post(`${this.link}.json`) + axios + .post(`${this.link}.json`) .then(() => { this.isDisabled = false; this.$emit('pipelineActionRequestComplete'); diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue index e047d10ac93..c32dc83da8e 100644 --- a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue @@ -109,6 +109,7 @@ export default { :key="i" > <job-component + :dropdown-length="job.size" :job="item" css-class-job-name="mini-pipeline-graph-dropdown-item" @pipelineActionRequestComplete="pipelineActionRequestComplete" diff --git a/app/assets/javascripts/pipelines/components/graph/job_component.vue b/app/assets/javascripts/pipelines/components/graph/job_component.vue index 886e62ab1a7..8af984ef91a 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_component.vue @@ -46,6 +46,11 @@ export default { required: false, default: '', }, + dropdownLength: { + type: Number, + required: false, + default: Infinity, + }, }, computed: { status() { @@ -70,6 +75,10 @@ export default { return textBuilder.join(' '); }, + tooltipBoundary() { + return this.dropdownLength < 5 ? 'viewport' : null; + }, + /** * Verifies if the provided job has an action path * @@ -94,9 +103,9 @@ export default { :href="status.details_path" :title="tooltipText" :class="cssClassJobName" + :data-boundary="tooltipBoundary" data-container="body" data-html="true" - data-boundary="viewport" class="js-pipeline-graph-job-link" > diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue index 14f4964a406..6fdbcc1e049 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue @@ -1,28 +1,28 @@ <script> - import ciIcon from '../../../vue_shared/components/ci_icon.vue'; +import ciIcon from '../../../vue_shared/components/ci_icon.vue'; - /** - * Component that renders both the CI icon status and the job name. - * Used in - * - Badge component - * - Dropdown badge components - */ - export default { - components: { - ciIcon, +/** + * Component that renders both the CI icon status and the job name. + * Used in + * - Badge component + * - Dropdown badge components + */ +export default { + components: { + ciIcon, + }, + props: { + name: { + type: String, + required: true, }, - props: { - name: { - type: String, - required: true, - }, - status: { - type: Object, - required: true, - }, + status: { + type: Object, + required: true, }, - }; + }, +}; </script> <template> <span class="ci-job-name-component"> diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index 5b212ee8931..001eaeaa065 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -1,81 +1,81 @@ <script> - import ciHeader from '../../vue_shared/components/header_ci_component.vue'; - import eventHub from '../event_hub'; - import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import ciHeader from '../../vue_shared/components/header_ci_component.vue'; +import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; - export default { - name: 'PipelineHeaderSection', - components: { - ciHeader, - loadingIcon, +export default { + name: 'PipelineHeaderSection', + components: { + ciHeader, + loadingIcon, + }, + props: { + pipeline: { + type: Object, + required: true, }, - props: { - pipeline: { - type: Object, - required: true, - }, - isLoading: { - type: Boolean, - required: true, - }, - }, - data() { - return { - actions: this.getActions(), - }; + isLoading: { + type: Boolean, + required: true, }, + }, + data() { + return { + actions: this.getActions(), + }; + }, - computed: { - status() { - return this.pipeline.details && this.pipeline.details.status; - }, - shouldRenderContent() { - return !this.isLoading && Object.keys(this.pipeline).length; - }, + computed: { + status() { + return this.pipeline.details && this.pipeline.details.status; + }, + shouldRenderContent() { + return !this.isLoading && Object.keys(this.pipeline).length; }, + }, - watch: { - pipeline() { - this.actions = this.getActions(); - }, + watch: { + pipeline() { + this.actions = this.getActions(); }, + }, - methods: { - postAction(action) { - const index = this.actions.indexOf(action); + methods: { + postAction(action) { + const index = this.actions.indexOf(action); - this.$set(this.actions[index], 'isLoading', true); + this.$set(this.actions[index], 'isLoading', true); - eventHub.$emit('headerPostAction', action); - }, + eventHub.$emit('headerPostAction', action); + }, - getActions() { - const actions = []; + getActions() { + const actions = []; - if (this.pipeline.retry_path) { - actions.push({ - label: 'Retry', - path: this.pipeline.retry_path, - cssClass: 'js-retry-button btn btn-inverted-secondary', - type: 'button', - isLoading: false, - }); - } + if (this.pipeline.retry_path) { + actions.push({ + label: 'Retry', + path: this.pipeline.retry_path, + cssClass: 'js-retry-button btn btn-inverted-secondary', + type: 'button', + isLoading: false, + }); + } - if (this.pipeline.cancel_path) { - actions.push({ - label: 'Cancel running', - path: this.pipeline.cancel_path, - cssClass: 'js-btn-cancel-pipeline btn btn-danger', - type: 'button', - isLoading: false, - }); - } + if (this.pipeline.cancel_path) { + actions.push({ + label: 'Cancel running', + path: this.pipeline.cancel_path, + cssClass: 'js-btn-cancel-pipeline btn btn-danger', + type: 'button', + isLoading: false, + }); + } - return actions; - }, + return actions; }, - }; + }, +}; </script> <template> <div class="pipeline-header-container"> diff --git a/app/assets/javascripts/pipelines/components/nav_controls.vue b/app/assets/javascripts/pipelines/components/nav_controls.vue index 1fce9f16ee0..9501afb7493 100644 --- a/app/assets/javascripts/pipelines/components/nav_controls.vue +++ b/app/assets/javascripts/pipelines/components/nav_controls.vue @@ -1,42 +1,42 @@ <script> - import LoadingButton from '../../vue_shared/components/loading_button.vue'; +import LoadingButton from '../../vue_shared/components/loading_button.vue'; - export default { - name: 'PipelineNavControls', - components: { - LoadingButton, +export default { + name: 'PipelineNavControls', + components: { + LoadingButton, + }, + props: { + newPipelinePath: { + type: String, + required: false, + default: null, }, - props: { - newPipelinePath: { - type: String, - required: false, - default: null, - }, - resetCachePath: { - type: String, - required: false, - default: null, - }, + resetCachePath: { + type: String, + required: false, + default: null, + }, - ciLintPath: { - type: String, - required: false, - default: null, - }, + ciLintPath: { + type: String, + required: false, + default: null, + }, - isResetCacheButtonLoading: { - type: Boolean, - required: false, - default: false, - }, + isResetCacheButtonLoading: { + type: Boolean, + required: false, + default: false, }, - methods: { - onClickResetCache() { - this.$emit('resetRunnersCache', this.resetCachePath); - }, + }, + methods: { + onClickResetCache() { + this.$emit('resetRunnersCache', this.resetCachePath); }, - }; + }, +}; </script> <template> <div class="nav-controls"> diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue index a107e579457..75db1e9ae7c 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue @@ -1,49 +1,49 @@ <script> - import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; - import tooltip from '../../vue_shared/directives/tooltip'; - import popover from '../../vue_shared/directives/popover'; +import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import tooltip from '../../vue_shared/directives/tooltip'; +import popover from '../../vue_shared/directives/popover'; - export default { - components: { - userAvatarLink, +export default { + components: { + userAvatarLink, + }, + directives: { + tooltip, + popover, + }, + props: { + pipeline: { + type: Object, + required: true, }, - directives: { - tooltip, - popover, + autoDevopsHelpPath: { + type: String, + required: true, }, - props: { - pipeline: { - type: Object, - required: true, - }, - autoDevopsHelpPath: { - type: String, - required: true, - }, + }, + computed: { + user() { + return this.pipeline.user; }, - computed: { - user() { - return this.pipeline.user; - }, - popoverOptions() { - return { - html: true, - trigger: 'focus', - placement: 'top', - title: `<div class="autodevops-title"> + popoverOptions() { + return { + html: true, + trigger: 'focus', + placement: 'top', + title: `<div class="autodevops-title"> This pipeline makes use of a predefined CI/CD configuration enabled by <b>Auto DevOps.</b> </div>`, - content: `<a + content: `<a class="autodevops-link" href="${this.autoDevopsHelpPath}" target="_blank" rel="noopener noreferrer nofollow"> Learn more about Auto DevOps </a>`, - }; - }, + }; }, - }; + }, +}; </script> <template> <div class="table-section section-15 d-none d-sm-none d-md-block pipeline-tags"> diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue index b31b4bad7a0..c9d2dc3a3c5 100644 --- a/app/assets/javascripts/pipelines/components/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines.vue @@ -1,283 +1,283 @@ <script> - import _ from 'underscore'; - import { __, sprintf, s__ } from '../../locale'; - import createFlash from '../../flash'; - import PipelinesService from '../services/pipelines_service'; - import pipelinesMixin from '../mixins/pipelines'; - import TablePagination from '../../vue_shared/components/table_pagination.vue'; - import NavigationTabs from '../../vue_shared/components/navigation_tabs.vue'; - import NavigationControls from './nav_controls.vue'; - import { getParameterByName } from '../../lib/utils/common_utils'; - import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin'; +import _ from 'underscore'; +import { __, sprintf, s__ } from '../../locale'; +import createFlash from '../../flash'; +import PipelinesService from '../services/pipelines_service'; +import pipelinesMixin from '../mixins/pipelines'; +import TablePagination from '../../vue_shared/components/table_pagination.vue'; +import NavigationTabs from '../../vue_shared/components/navigation_tabs.vue'; +import NavigationControls from './nav_controls.vue'; +import { getParameterByName } from '../../lib/utils/common_utils'; +import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin'; - export default { - components: { - TablePagination, - NavigationTabs, - NavigationControls, +export default { + components: { + TablePagination, + NavigationTabs, + NavigationControls, + }, + mixins: [pipelinesMixin, CIPaginationMixin], + props: { + store: { + type: Object, + required: true, }, - mixins: [pipelinesMixin, CIPaginationMixin], - props: { - store: { - type: Object, - required: true, - }, - // Can be rendered in 3 different places, with some visual differences - // Accepts root | child - // `root` -> main view - // `child` -> rendered inside MR or Commit View - viewType: { - type: String, - required: false, - default: 'root', - }, - endpoint: { - type: String, - required: true, - }, - helpPagePath: { - type: String, - required: true, - }, - emptyStateSvgPath: { - type: String, - required: true, - }, - errorStateSvgPath: { - type: String, - required: true, - }, - noPipelinesSvgPath: { - type: String, - required: true, - }, - autoDevopsPath: { - type: String, - required: true, - }, - hasGitlabCi: { - type: Boolean, - required: true, - }, - canCreatePipeline: { - type: Boolean, - required: true, - }, - ciLintPath: { - type: String, - required: false, - default: null, - }, - resetCachePath: { - type: String, - required: false, - default: null, - }, - newPipelinePath: { - type: String, - required: false, - default: null, - }, + // Can be rendered in 3 different places, with some visual differences + // Accepts root | child + // `root` -> main view + // `child` -> rendered inside MR or Commit View + viewType: { + type: String, + required: false, + default: 'root', }, - data() { - return { - // Start with loading state to avoid a glitch when the empty state will be rendered - isLoading: true, - state: this.store.state, - scope: getParameterByName('scope') || 'all', - page: getParameterByName('page') || '1', - requestData: {}, - isResetCacheButtonLoading: false, - }; + endpoint: { + type: String, + required: true, }, - stateMap: { - // with tabs - loading: 'loading', - tableList: 'tableList', - error: 'error', - emptyTab: 'emptyTab', - - // without tabs - emptyState: 'emptyState', + helpPagePath: { + type: String, + required: true, + }, + emptyStateSvgPath: { + type: String, + required: true, + }, + errorStateSvgPath: { + type: String, + required: true, + }, + noPipelinesSvgPath: { + type: String, + required: true, + }, + autoDevopsPath: { + type: String, + required: true, + }, + hasGitlabCi: { + type: Boolean, + required: true, + }, + canCreatePipeline: { + type: Boolean, + required: true, + }, + ciLintPath: { + type: String, + required: false, + default: null, }, - scopes: { - all: 'all', - pending: 'pending', - running: 'running', - finished: 'finished', - branches: 'branches', - tags: 'tags', + resetCachePath: { + type: String, + required: false, + default: null, }, - computed: { - /** - * `hasGitlabCi` handles both internal and external CI. - * The order on which the checks are made in this method is - * important to guarantee we handle all the corner cases. - */ - stateToRender() { - const { stateMap } = this.$options; + newPipelinePath: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + // Start with loading state to avoid a glitch when the empty state will be rendered + isLoading: true, + state: this.store.state, + scope: getParameterByName('scope') || 'all', + page: getParameterByName('page') || '1', + requestData: {}, + isResetCacheButtonLoading: false, + }; + }, + stateMap: { + // with tabs + loading: 'loading', + tableList: 'tableList', + error: 'error', + emptyTab: 'emptyTab', + + // without tabs + emptyState: 'emptyState', + }, + scopes: { + all: 'all', + pending: 'pending', + running: 'running', + finished: 'finished', + branches: 'branches', + tags: 'tags', + }, + computed: { + /** + * `hasGitlabCi` handles both internal and external CI. + * The order on which the checks are made in this method is + * important to guarantee we handle all the corner cases. + */ + stateToRender() { + const { stateMap } = this.$options; - if (this.isLoading) { - return stateMap.loading; - } + if (this.isLoading) { + return stateMap.loading; + } - if (this.hasError) { - return stateMap.error; - } + if (this.hasError) { + return stateMap.error; + } - if (this.state.pipelines.length) { - return stateMap.tableList; - } + if (this.state.pipelines.length) { + return stateMap.tableList; + } - if ((this.scope !== 'all' && this.scope !== null) || this.hasGitlabCi) { - return stateMap.emptyTab; - } + if ((this.scope !== 'all' && this.scope !== null) || this.hasGitlabCi) { + return stateMap.emptyTab; + } - return stateMap.emptyState; - }, - /** - * Tabs are rendered in all states except empty state. - * They are not rendered before the first request to avoid a flicker on first load. - */ - shouldRenderTabs() { - const { stateMap } = this.$options; - return ( - this.hasMadeRequest && - [stateMap.loading, stateMap.tableList, stateMap.error, stateMap.emptyTab].includes( - this.stateToRender, - ) - ); - }, + return stateMap.emptyState; + }, + /** + * Tabs are rendered in all states except empty state. + * They are not rendered before the first request to avoid a flicker on first load. + */ + shouldRenderTabs() { + const { stateMap } = this.$options; + return ( + this.hasMadeRequest && + [stateMap.loading, stateMap.tableList, stateMap.error, stateMap.emptyTab].includes( + this.stateToRender, + ) + ); + }, - shouldRenderButtons() { - return ( - (this.newPipelinePath || this.resetCachePath || this.ciLintPath) && this.shouldRenderTabs - ); - }, + shouldRenderButtons() { + return ( + (this.newPipelinePath || this.resetCachePath || this.ciLintPath) && this.shouldRenderTabs + ); + }, - shouldRenderPagination() { - return ( - !this.isLoading && - this.state.pipelines.length && - this.state.pageInfo.total > this.state.pageInfo.perPage - ); - }, + shouldRenderPagination() { + return ( + !this.isLoading && + this.state.pipelines.length && + this.state.pageInfo.total > this.state.pageInfo.perPage + ); + }, - emptyTabMessage() { - const { scopes } = this.$options; - const possibleScopes = [scopes.pending, scopes.running, scopes.finished]; + emptyTabMessage() { + const { scopes } = this.$options; + const possibleScopes = [scopes.pending, scopes.running, scopes.finished]; - if (possibleScopes.includes(this.scope)) { - return sprintf(s__('Pipelines|There are currently no %{scope} pipelines.'), { - scope: this.scope, - }); - } + if (possibleScopes.includes(this.scope)) { + return sprintf(s__('Pipelines|There are currently no %{scope} pipelines.'), { + scope: this.scope, + }); + } - return s__('Pipelines|There are currently no pipelines.'); - }, + return s__('Pipelines|There are currently no pipelines.'); + }, - tabs() { - const { count } = this.state; - const { scopes } = this.$options; + tabs() { + const { count } = this.state; + const { scopes } = this.$options; - return [ - { - name: __('All'), - scope: scopes.all, - count: count.all, - isActive: this.scope === 'all', - }, - { - name: __('Pending'), - scope: scopes.pending, - count: count.pending, - isActive: this.scope === 'pending', - }, - { - name: __('Running'), - scope: scopes.running, - count: count.running, - isActive: this.scope === 'running', - }, - { - name: __('Finished'), - scope: scopes.finished, - count: count.finished, - isActive: this.scope === 'finished', - }, - { - name: __('Branches'), - scope: scopes.branches, - isActive: this.scope === 'branches', - }, - { - name: __('Tags'), - scope: scopes.tags, - isActive: this.scope === 'tags', - }, - ]; - }, + return [ + { + name: __('All'), + scope: scopes.all, + count: count.all, + isActive: this.scope === 'all', + }, + { + name: __('Pending'), + scope: scopes.pending, + count: count.pending, + isActive: this.scope === 'pending', + }, + { + name: __('Running'), + scope: scopes.running, + count: count.running, + isActive: this.scope === 'running', + }, + { + name: __('Finished'), + scope: scopes.finished, + count: count.finished, + isActive: this.scope === 'finished', + }, + { + name: __('Branches'), + scope: scopes.branches, + isActive: this.scope === 'branches', + }, + { + name: __('Tags'), + scope: scopes.tags, + isActive: this.scope === 'tags', + }, + ]; }, - created() { - this.service = new PipelinesService(this.endpoint); - this.requestData = { page: this.page, scope: this.scope }; + }, + created() { + this.service = new PipelinesService(this.endpoint); + this.requestData = { page: this.page, scope: this.scope }; + }, + methods: { + successCallback(resp) { + // Because we are polling & the user is interacting verify if the response received + // matches the last request made + if (_.isEqual(resp.config.params, this.requestData)) { + this.store.storeCount(resp.data.count); + this.store.storePagination(resp.headers); + this.setCommonData(resp.data.pipelines); + } }, - methods: { - successCallback(resp) { - // Because we are polling & the user is interacting verify if the response received - // matches the last request made - if (_.isEqual(resp.config.params, this.requestData)) { - this.store.storeCount(resp.data.count); - this.store.storePagination(resp.headers); - this.setCommonData(resp.data.pipelines); - } - }, - /** - * Handles URL and query parameter changes. - * When the user uses the pagination or the tabs, - * - update URL - * - Make API request to the server with new parameters - * - Update the polling function - * - Update the internal state - */ - updateContent(parameters) { - this.updateInternalState(parameters); + /** + * Handles URL and query parameter changes. + * When the user uses the pagination or the tabs, + * - update URL + * - Make API request to the server with new parameters + * - Update the polling function + * - Update the internal state + */ + updateContent(parameters) { + this.updateInternalState(parameters); - // fetch new data - return this.service - .getPipelines(this.requestData) - .then(response => { - this.isLoading = false; - this.successCallback(response); + // fetch new data + return this.service + .getPipelines(this.requestData) + .then(response => { + this.isLoading = false; + this.successCallback(response); - // restart polling - this.poll.restart({ data: this.requestData }); - }) - .catch(() => { - this.isLoading = false; - this.errorCallback(); + // restart polling + this.poll.restart({ data: this.requestData }); + }) + .catch(() => { + this.isLoading = false; + this.errorCallback(); - // restart polling - this.poll.restart({ data: this.requestData }); - }); - }, + // restart polling + this.poll.restart({ data: this.requestData }); + }); + }, - handleResetRunnersCache(endpoint) { - this.isResetCacheButtonLoading = true; + handleResetRunnersCache(endpoint) { + this.isResetCacheButtonLoading = true; - this.service - .postAction(endpoint) - .then(() => { - this.isResetCacheButtonLoading = false; - createFlash(s__('Pipelines|Project cache successfully reset.'), 'notice'); - }) - .catch(() => { - this.isResetCacheButtonLoading = false; - createFlash(s__('Pipelines|Something went wrong while cleaning runners cache.')); - }); - }, + this.service + .postAction(endpoint) + .then(() => { + this.isResetCacheButtonLoading = false; + createFlash(s__('Pipelines|Project cache successfully reset.'), 'notice'); + }) + .catch(() => { + this.isResetCacheButtonLoading = false; + createFlash(s__('Pipelines|Something went wrong while cleaning runners cache.')); + }); }, - }; + }, +}; </script> <template> <div class="pipelines-container"> diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_actions.vue index 5070c253f11..1c8d7303c52 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_actions.vue @@ -1,44 +1,44 @@ <script> - import eventHub from '../event_hub'; - import loadingIcon from '../../vue_shared/components/loading_icon.vue'; - import icon from '../../vue_shared/components/icon.vue'; - import tooltip from '../../vue_shared/directives/tooltip'; +import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import icon from '../../vue_shared/components/icon.vue'; +import tooltip from '../../vue_shared/directives/tooltip'; - export default { - directives: { - tooltip, +export default { + directives: { + tooltip, + }, + components: { + loadingIcon, + icon, + }, + props: { + actions: { + type: Array, + required: true, }, - components: { - loadingIcon, - icon, - }, - props: { - actions: { - type: Array, - required: true, - }, - }, - data() { - return { - isLoading: false, - }; - }, - methods: { - onClickAction(endpoint) { - this.isLoading = true; + }, + data() { + return { + isLoading: false, + }; + }, + methods: { + onClickAction(endpoint) { + this.isLoading = true; - eventHub.$emit('postAction', endpoint); - }, + eventHub.$emit('postAction', endpoint); + }, - isActionDisabled(action) { - if (action.playable === undefined) { - return false; - } + isActionDisabled(action) { + if (action.playable === undefined) { + return false; + } - return !action.playable; - }, + return !action.playable; }, - }; + }, +}; </script> <template> <div class="btn-group"> diff --git a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue index 490df47e154..d40de95e051 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue @@ -1,21 +1,21 @@ <script> - import tooltip from '../../vue_shared/directives/tooltip'; - import icon from '../../vue_shared/components/icon.vue'; +import tooltip from '../../vue_shared/directives/tooltip'; +import icon from '../../vue_shared/components/icon.vue'; - export default { - directives: { - tooltip, +export default { + directives: { + tooltip, + }, + components: { + icon, + }, + props: { + artifacts: { + type: Array, + required: true, }, - components: { - icon, - }, - props: { - artifacts: { - type: Array, - required: true, - }, - }, - }; + }, +}; </script> <template> <div diff --git a/app/assets/javascripts/pipelines/components/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_table.vue index 2e777783636..0d7324f3fb5 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table.vue @@ -1,74 +1,82 @@ <script> - import Modal from '~/vue_shared/components/gl_modal.vue'; - import { s__, sprintf } from '~/locale'; - import PipelinesTableRowComponent from './pipelines_table_row.vue'; - import eventHub from '../event_hub'; +import Modal from '~/vue_shared/components/gl_modal.vue'; +import { s__, sprintf } from '~/locale'; +import PipelinesTableRowComponent from './pipelines_table_row.vue'; +import eventHub from '../event_hub'; - /** - * Pipelines Table Component. - * - * Given an array of objects, renders a table. - */ - export default { - components: { - PipelinesTableRowComponent, - Modal, +/** + * Pipelines Table Component. + * + * Given an array of objects, renders a table. + */ +export default { + components: { + PipelinesTableRowComponent, + Modal, + }, + props: { + pipelines: { + type: Array, + required: true, }, - props: { - pipelines: { - type: Array, - required: true, - }, - updateGraphDropdown: { - type: Boolean, - required: false, - default: false, - }, - autoDevopsHelpPath: { - type: String, - required: true, - }, - viewType: { - type: String, - required: true, - }, + updateGraphDropdown: { + type: Boolean, + required: false, + default: false, }, - data() { - return { - pipelineId: '', - endpoint: '', - cancelingPipeline: null, - }; + autoDevopsHelpPath: { + type: String, + required: true, }, - computed: { - modalTitle() { - return sprintf(s__('Pipeline|Stop pipeline #%{pipelineId}?'), { + viewType: { + type: String, + required: true, + }, + }, + data() { + return { + pipelineId: '', + endpoint: '', + cancelingPipeline: null, + }; + }, + computed: { + modalTitle() { + return sprintf( + s__('Pipeline|Stop pipeline #%{pipelineId}?'), + { pipelineId: `${this.pipelineId}`, - }, false); - }, - modalText() { - return sprintf(s__('Pipeline|You’re about to stop pipeline %{pipelineId}.'), { - pipelineId: `<strong>#${this.pipelineId}</strong>`, - }, false); - }, + }, + false, + ); }, - created() { - eventHub.$on('openConfirmationModal', this.setModalData); + modalText() { + return sprintf( + s__('Pipeline|You’re about to stop pipeline %{pipelineId}.'), + { + pipelineId: `<strong>#${this.pipelineId}</strong>`, + }, + false, + ); }, - beforeDestroy() { - eventHub.$off('openConfirmationModal', this.setModalData); + }, + created() { + eventHub.$on('openConfirmationModal', this.setModalData); + }, + beforeDestroy() { + eventHub.$off('openConfirmationModal', this.setModalData); + }, + methods: { + setModalData(data) { + this.pipelineId = data.pipelineId; + this.endpoint = data.endpoint; }, - methods: { - setModalData(data) { - this.pipelineId = data.pipelineId; - this.endpoint = data.endpoint; - }, - onSubmit() { - eventHub.$emit('postAction', this.endpoint); - this.cancelingPipeline = this.pipelineId; - }, + onSubmit() { + eventHub.$emit('postAction', this.endpoint); + this.cancelingPipeline = this.pipelineId; }, - }; + }, +}; </script> <template> <div class="ci-table"> diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue index b2744a30c2a..804822a3ea8 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue @@ -1,255 +1,253 @@ <script> - import eventHub from '../event_hub'; - import PipelinesActionsComponent from './pipelines_actions.vue'; - import PipelinesArtifactsComponent from './pipelines_artifacts.vue'; - import CiBadge from '../../vue_shared/components/ci_badge_link.vue'; - import PipelineStage from './stage.vue'; - import PipelineUrl from './pipeline_url.vue'; - import PipelinesTimeago from './time_ago.vue'; - import CommitComponent from '../../vue_shared/components/commit.vue'; - import LoadingButton from '../../vue_shared/components/loading_button.vue'; - import Icon from '../../vue_shared/components/icon.vue'; - import { PIPELINES_TABLE } from '../constants'; +import eventHub from '../event_hub'; +import PipelinesActionsComponent from './pipelines_actions.vue'; +import PipelinesArtifactsComponent from './pipelines_artifacts.vue'; +import CiBadge from '../../vue_shared/components/ci_badge_link.vue'; +import PipelineStage from './stage.vue'; +import PipelineUrl from './pipeline_url.vue'; +import PipelinesTimeago from './time_ago.vue'; +import CommitComponent from '../../vue_shared/components/commit.vue'; +import LoadingButton from '../../vue_shared/components/loading_button.vue'; +import Icon from '../../vue_shared/components/icon.vue'; +import { PIPELINES_TABLE } from '../constants'; - /** - * Pipeline table row. - * - * Given the received object renders a table row in the pipelines' table. - */ - export default { - components: { - PipelinesActionsComponent, - PipelinesArtifactsComponent, - CommitComponent, - PipelineStage, - PipelineUrl, - CiBadge, - PipelinesTimeago, - LoadingButton, - Icon, +/** + * Pipeline table row. + * + * Given the received object renders a table row in the pipelines' table. + */ +export default { + components: { + PipelinesActionsComponent, + PipelinesArtifactsComponent, + CommitComponent, + PipelineStage, + PipelineUrl, + CiBadge, + PipelinesTimeago, + LoadingButton, + Icon, + }, + props: { + pipeline: { + type: Object, + required: true, }, - props: { - pipeline: { - type: Object, - required: true, - }, - updateGraphDropdown: { - type: Boolean, - required: false, - default: false, - }, - autoDevopsHelpPath: { - type: String, - required: true, - }, - viewType: { - type: String, - required: true, - }, - cancelingPipeline: { - type: String, - required: false, - default: null, - }, + updateGraphDropdown: { + type: Boolean, + required: false, + default: false, }, - pipelinesTable: PIPELINES_TABLE, - data() { - return { - isRetrying: false, - }; + autoDevopsHelpPath: { + type: String, + required: true, }, - computed: { - /** - * If provided, returns the commit tag. - * Needed to render the commit component column. - * - * This field needs a lot of verification, because of different possible cases: - * - * 1. person who is an author of a commit might be a GitLab user - * 2. if person who is an author of a commit is a GitLab user he/she can have a GitLab avatar - * 3. If GitLab user does not have avatar he/she might have a Gravatar - * 4. If committer is not a GitLab User he/she can have a Gravatar - * 5. We do not have consistent API object in this case - * 6. We should improve API and the code - * - * @returns {Object|Undefined} - */ - commitAuthor() { - let commitAuthorInformation; + viewType: { + type: String, + required: true, + }, + cancelingPipeline: { + type: String, + required: false, + default: null, + }, + }, + pipelinesTable: PIPELINES_TABLE, + data() { + return { + isRetrying: false, + }; + }, + computed: { + /** + * If provided, returns the commit tag. + * Needed to render the commit component column. + * + * This field needs a lot of verification, because of different possible cases: + * + * 1. person who is an author of a commit might be a GitLab user + * 2. if person who is an author of a commit is a GitLab user he/she can have a GitLab avatar + * 3. If GitLab user does not have avatar he/she might have a Gravatar + * 4. If committer is not a GitLab User he/she can have a Gravatar + * 5. We do not have consistent API object in this case + * 6. We should improve API and the code + * + * @returns {Object|Undefined} + */ + commitAuthor() { + let commitAuthorInformation; - if (!this.pipeline || !this.pipeline.commit) { - return null; - } + if (!this.pipeline || !this.pipeline.commit) { + return null; + } - // 1. person who is an author of a commit might be a GitLab user - if (this.pipeline.commit.author) { - // 2. if person who is an author of a commit is a GitLab user - // he/she can have a GitLab avatar - if (this.pipeline.commit.author.avatar_url) { - commitAuthorInformation = this.pipeline.commit.author; + // 1. person who is an author of a commit might be a GitLab user + if (this.pipeline.commit.author) { + // 2. if person who is an author of a commit is a GitLab user + // he/she can have a GitLab avatar + if (this.pipeline.commit.author.avatar_url) { + commitAuthorInformation = this.pipeline.commit.author; - // 3. If GitLab user does not have avatar he/she might have a Gravatar - } else if (this.pipeline.commit.author_gravatar_url) { - commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, { - avatar_url: this.pipeline.commit.author_gravatar_url, - }); - } - // 4. If committer is not a GitLab User he/she can have a Gravatar - } else { - commitAuthorInformation = { + // 3. If GitLab user does not have avatar he/she might have a Gravatar + } else if (this.pipeline.commit.author_gravatar_url) { + commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, { avatar_url: this.pipeline.commit.author_gravatar_url, - path: `mailto:${this.pipeline.commit.author_email}`, - username: this.pipeline.commit.author_name, - }; + }); } + // 4. If committer is not a GitLab User he/she can have a Gravatar + } else { + commitAuthorInformation = { + avatar_url: this.pipeline.commit.author_gravatar_url, + path: `mailto:${this.pipeline.commit.author_email}`, + username: this.pipeline.commit.author_name, + }; + } - return commitAuthorInformation; - }, + return commitAuthorInformation; + }, - /** - * If provided, returns the commit tag. - * Needed to render the commit component column. - * - * @returns {String|Undefined} - */ - commitTag() { - if (this.pipeline.ref && - this.pipeline.ref.tag) { - return this.pipeline.ref.tag; - } - return undefined; - }, + /** + * If provided, returns the commit tag. + * Needed to render the commit component column. + * + * @returns {String|Undefined} + */ + commitTag() { + if (this.pipeline.ref && this.pipeline.ref.tag) { + return this.pipeline.ref.tag; + } + return undefined; + }, - /** - * If provided, returns the commit ref. - * Needed to render the commit component column. - * - * Matches `path` prop sent in the API to `ref_url` prop needed - * in the commit component. - * - * @returns {Object|Undefined} - */ - commitRef() { - if (this.pipeline.ref) { - return Object.keys(this.pipeline.ref).reduce((accumulator, prop) => { - if (prop === 'path') { - // eslint-disable-next-line no-param-reassign - accumulator.ref_url = this.pipeline.ref[prop]; - } else { - // eslint-disable-next-line no-param-reassign - accumulator[prop] = this.pipeline.ref[prop]; - } - return accumulator; - }, {}); - } + /** + * If provided, returns the commit ref. + * Needed to render the commit component column. + * + * Matches `path` prop sent in the API to `ref_url` prop needed + * in the commit component. + * + * @returns {Object|Undefined} + */ + commitRef() { + if (this.pipeline.ref) { + return Object.keys(this.pipeline.ref).reduce((accumulator, prop) => { + if (prop === 'path') { + // eslint-disable-next-line no-param-reassign + accumulator.ref_url = this.pipeline.ref[prop]; + } else { + // eslint-disable-next-line no-param-reassign + accumulator[prop] = this.pipeline.ref[prop]; + } + return accumulator; + }, {}); + } - return undefined; - }, + return undefined; + }, - /** - * If provided, returns the commit url. - * Needed to render the commit component column. - * - * @returns {String|Undefined} - */ - commitUrl() { - if (this.pipeline.commit && - this.pipeline.commit.commit_path) { - return this.pipeline.commit.commit_path; - } - return undefined; - }, + /** + * If provided, returns the commit url. + * Needed to render the commit component column. + * + * @returns {String|Undefined} + */ + commitUrl() { + if (this.pipeline.commit && this.pipeline.commit.commit_path) { + return this.pipeline.commit.commit_path; + } + return undefined; + }, - /** - * If provided, returns the commit short sha. - * Needed to render the commit component column. - * - * @returns {String|Undefined} - */ - commitShortSha() { - if (this.pipeline.commit && - this.pipeline.commit.short_id) { - return this.pipeline.commit.short_id; - } - return undefined; - }, + /** + * If provided, returns the commit short sha. + * Needed to render the commit component column. + * + * @returns {String|Undefined} + */ + commitShortSha() { + if (this.pipeline.commit && this.pipeline.commit.short_id) { + return this.pipeline.commit.short_id; + } + return undefined; + }, - /** - * If provided, returns the commit title. - * Needed to render the commit component column. - * - * @returns {String|Undefined} - */ - commitTitle() { - if (this.pipeline.commit && - this.pipeline.commit.title) { - return this.pipeline.commit.title; - } - return undefined; - }, + /** + * If provided, returns the commit title. + * Needed to render the commit component column. + * + * @returns {String|Undefined} + */ + commitTitle() { + if (this.pipeline.commit && this.pipeline.commit.title) { + return this.pipeline.commit.title; + } + return undefined; + }, - /** - * Timeago components expects a number - * - * @return {type} description - */ - pipelineDuration() { - if (this.pipeline.details && this.pipeline.details.duration) { - return this.pipeline.details.duration; - } + /** + * Timeago components expects a number + * + * @return {type} description + */ + pipelineDuration() { + if (this.pipeline.details && this.pipeline.details.duration) { + return this.pipeline.details.duration; + } - return 0; - }, + return 0; + }, - /** - * Timeago component expects a String. - * - * @return {String} - */ - pipelineFinishedAt() { - if (this.pipeline.details && this.pipeline.details.finished_at) { - return this.pipeline.details.finished_at; - } + /** + * Timeago component expects a String. + * + * @return {String} + */ + pipelineFinishedAt() { + if (this.pipeline.details && this.pipeline.details.finished_at) { + return this.pipeline.details.finished_at; + } - return ''; - }, + return ''; + }, - pipelineStatus() { - if (this.pipeline.details && this.pipeline.details.status) { - return this.pipeline.details.status; - } - return {}; - }, + pipelineStatus() { + if (this.pipeline.details && this.pipeline.details.status) { + return this.pipeline.details.status; + } + return {}; + }, - displayPipelineActions() { - return this.pipeline.flags.retryable || - this.pipeline.flags.cancelable || - this.pipeline.details.manual_actions.length || - this.pipeline.details.artifacts.length; - }, + displayPipelineActions() { + return ( + this.pipeline.flags.retryable || + this.pipeline.flags.cancelable || + this.pipeline.details.manual_actions.length || + this.pipeline.details.artifacts.length + ); + }, - isChildView() { - return this.viewType === 'child'; - }, + isChildView() { + return this.viewType === 'child'; + }, - isCancelling() { - return this.cancelingPipeline === this.pipeline.id; - }, + isCancelling() { + return this.cancelingPipeline === this.pipeline.id; }, + }, - methods: { - handleCancelClick() { - eventHub.$emit('openConfirmationModal', { - pipelineId: this.pipeline.id, - endpoint: this.pipeline.cancel_path, - }); - }, - handleRetryClick() { - this.isRetrying = true; - eventHub.$emit('retryPipeline', this.pipeline.retry_path); - }, + methods: { + handleCancelClick() { + eventHub.$emit('openConfirmationModal', { + pipelineId: this.pipeline.id, + endpoint: this.pipeline.cancel_path, + }); + }, + handleRetryClick() { + this.isRetrying = true; + eventHub.$emit('retryPipeline', this.pipeline.retry_path); }, - }; + }, +}; </script> <template> <div class="commit gl-responsive-table-row"> diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue index b9231c002fd..56fdb858088 100644 --- a/app/assets/javascripts/pipelines/components/stage.vue +++ b/app/assets/javascripts/pipelines/components/stage.vue @@ -186,32 +186,27 @@ export default { </i> </button> - <ul + <div class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container" aria-labelledby="stageDropdown" > - - <li + <loading-icon v-if="isLoading"/> + <ul + v-else class="js-builds-dropdown-list scrollable-menu" > - - <loading-icon v-if="isLoading"/> - - <ul - v-else + <li + v-for="job in dropdownContent" + :key="job.id" > - <li - v-for="job in dropdownContent" - :key="job.id" - > - <job-component - :job="job" - css-class-job-name="mini-pipeline-graph-dropdown-item" - @pipelineActionRequestComplete="pipelineActionRequestComplete" - /> - </li> - </ul> - </li> - </ul> + <job-component + :dropdown-length="dropdownContent.length" + :job="job" + css-class-job-name="mini-pipeline-graph-dropdown-item" + @pipelineActionRequestComplete="pipelineActionRequestComplete" + /> + </li> + </ul> + </div> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/time_ago.vue b/app/assets/javascripts/pipelines/components/time_ago.vue index 0a97df2dc18..cd43d78de40 100644 --- a/app/assets/javascripts/pipelines/components/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/time_ago.vue @@ -1,60 +1,58 @@ <script> - import iconTimerSvg from 'icons/_icon_timer.svg'; - import '../../lib/utils/datetime_utility'; - import tooltip from '../../vue_shared/directives/tooltip'; - import timeagoMixin from '../../vue_shared/mixins/timeago'; +import iconTimerSvg from 'icons/_icon_timer.svg'; +import '../../lib/utils/datetime_utility'; +import tooltip from '../../vue_shared/directives/tooltip'; +import timeagoMixin from '../../vue_shared/mixins/timeago'; - export default { - directives: { - tooltip, +export default { + directives: { + tooltip, + }, + mixins: [timeagoMixin], + props: { + finishedTime: { + type: String, + required: true, }, - mixins: [ - timeagoMixin, - ], - props: { - finishedTime: { - type: String, - required: true, - }, - duration: { - type: Number, - required: true, - }, + duration: { + type: Number, + required: true, }, - data() { - return { - iconTimerSvg, - }; + }, + data() { + return { + iconTimerSvg, + }; + }, + computed: { + hasDuration() { + return this.duration > 0; }, - computed: { - hasDuration() { - return this.duration > 0; - }, - hasFinishedTime() { - return this.finishedTime !== ''; - }, - durationFormated() { - const date = new Date(this.duration * 1000); + hasFinishedTime() { + return this.finishedTime !== ''; + }, + durationFormated() { + const date = new Date(this.duration * 1000); - let hh = date.getUTCHours(); - let mm = date.getUTCMinutes(); - let ss = date.getSeconds(); + let hh = date.getUTCHours(); + let mm = date.getUTCMinutes(); + let ss = date.getSeconds(); - // left pad - if (hh < 10) { - hh = `0${hh}`; - } - if (mm < 10) { - mm = `0${mm}`; - } - if (ss < 10) { - ss = `0${ss}`; - } + // left pad + if (hh < 10) { + hh = `0${hh}`; + } + if (mm < 10) { + mm = `0${mm}`; + } + if (ss < 10) { + ss = `0${ss}`; + } - return `${hh}:${mm}:${ss}`; - }, + return `${hh}:${mm}:${ss}`; }, - }; + }, +}; </script> <template> <div class="table-section section-15 pipelines-time-ago"> diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js index 30b1eee186d..2cb558b0dec 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines.js @@ -75,8 +75,7 @@ export default { // Stop polling this.poll.stop(); // Update the table - return this.getPipelines() - .then(() => this.poll.restart()); + return this.getPipelines().then(() => this.poll.restart()); }, fetchPipelines() { if (!this.isMakingRequest) { @@ -86,9 +85,10 @@ export default { } }, getPipelines() { - return this.service.getPipelines(this.requestData) + return this.service + .getPipelines(this.requestData) .then(response => this.successCallback(response)) - .catch((error) => this.errorCallback(error)); + .catch(error => this.errorCallback(error)); }, setCommonData(pipelines) { this.store.storePipelines(pipelines); @@ -118,7 +118,8 @@ export default { } }, postAction(endpoint) { - this.service.postAction(endpoint) + this.service + .postAction(endpoint) .then(() => this.fetchPipelines()) .catch(() => Flash(__('An error occurred while making the request.'))); }, diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index cf3ff48e608..dc9befe6349 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -31,7 +31,8 @@ export default () => { requestRefreshPipelineGraph() { // When an action is clicked // (wether in the dropdown or in the main nodes, we refresh the big graph) - this.mediator.refreshPipeline() + this.mediator + .refreshPipeline() .catch(() => Flash(__('An error occurred while making the request.'))); }, }, diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js index 5633e54b28a..bd1e1895660 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js +++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js @@ -52,7 +52,8 @@ export default class pipelinesMediator { refreshPipeline() { this.poll.stop(); - return this.service.getPipeline() + return this.service + .getPipeline() .then(response => this.successCallback(response)) .catch(() => this.errorCallback()) .finally(() => this.poll.restart()); diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index 5d58d968d30..8cf7f2f23d0 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -61,7 +61,13 @@ export default class Profile { url: this.form.attr('action'), data: formData, }) - .then(({ data }) => flash(data.message, 'notice')) + .then(({ data }) => { + if (avatarBlob != null) { + this.updateHeaderAvatar(); + } + + flash(data.message, 'notice'); + }) .then(() => { window.scrollTo(0, 0); // Enable submit button after requests ends @@ -70,6 +76,10 @@ export default class Profile { .catch(error => flash(error.message)); } + updateHeaderAvatar() { + $('.header-user-avatar').attr('src', this.avatarGlCrop.dataURL); + } + setRepoRadio() { const multiEditRadios = $('input[name="user[multi_file]"]'); if (this.newRepoActivated || this.newRepoActivated === 'true') { diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index 240dde56325..bce7556bd40 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -47,7 +47,10 @@ export default function projectSelect() { projectsCallback = finalCallback; } if (_this.groupId) { - return Api.groupProjects(_this.groupId, query.term, projectsCallback); + return Api.groupProjects(_this.groupId, query.term, { + with_issues_enabled: _this.withIssuesEnabled, + with_merge_requests_enabled: _this.withMergeRequestsEnabled, + }, projectsCallback); } else { return Api.projects(query.term, { order_by: _this.orderBy, diff --git a/app/assets/javascripts/shared/milestones/form.js b/app/assets/javascripts/shared/milestones/form.js index 060f374310c..8681a1776c6 100644 --- a/app/assets/javascripts/shared/milestones/form.js +++ b/app/assets/javascripts/shared/milestones/form.js @@ -8,10 +8,11 @@ export default (initGFM = true) => { new DueDateSelectors(); // eslint-disable-line no-new // eslint-disable-next-line no-new new GLForm($('.milestone-form'), { - emojis: initGFM, + emojis: true, members: initGFM, issues: initGFM, mergeRequests: initGFM, + epics: initGFM, milestones: initGFM, labels: initGFM, }); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue index c44419d24e6..5e464f8a0e2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue @@ -1,4 +1,5 @@ <script> +import Icon from '~/vue_shared/components/icon.vue'; import timeagoMixin from '../../vue_shared/mixins/timeago'; import tooltip from '../../vue_shared/directives/tooltip'; import LoadingButton from '../../vue_shared/components/loading_button.vue'; @@ -14,6 +15,7 @@ export default { LoadingButton, MemoryUsage, StatusIcon, + Icon, }, directives: { tooltip, @@ -110,11 +112,10 @@ export default { class="deploy-link js-deploy-url" > {{ deployment.external_url_formatted }} - <i - class="fa fa-external-link" - aria-hidden="true" - > - </i> + <icon + :size="16" + name="external-link" + /> </a> </template> <span 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 1fdc3218671..53c4dc8c8f4 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 @@ -32,7 +32,7 @@ }; </script> <template> - <div class="space-children flex-container-block append-right-10"> + <div class="space-children d-flex append-right-10"> <div v-if="isLoading" class="mr-widget-icon" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue index 0d9a560c88e..97f4196b94d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue @@ -82,7 +82,7 @@ <div class="mr-widget-body media"> <status-icon status="success" /> <div class="media-body"> - <h4 class="flex-container-block"> + <h4 class="d-flex align-items-start"> <span class="append-right-10"> {{ s__("mrWidget|Set by") }} <mr-widget-author :author="mr.setToMWPSBy" /> @@ -119,7 +119,7 @@ </p> <p v-else - class="flex-container-block" + class="d-flex align-items-start" > <span class="append-right-10"> {{ s__("mrWidget|The source branch will not be removed") }} diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index fba67681777..298971a36b2 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -67,6 +67,7 @@ members: this.enableAutocomplete, issues: this.enableAutocomplete, mergeRequests: this.enableAutocomplete, + epics: this.enableAutocomplete, milestones: this.enableAutocomplete, labels: this.enableAutocomplete, }); diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index f610a1aea08..ded33e8b151 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -128,6 +128,11 @@ table { border-spacing: 0; } +.tooltip { + // Fix bootstrap4 bug whereby tooltips flicker when they are hovered over their borders + pointer-events: none; +} + .popover { font-size: 14px; } diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index a538b5a2946..8d11b92cf88 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -104,6 +104,10 @@ position: relative; top: 3px; } + + > gl-emoji { + line-height: 1.5; + } } .award-menu-holder { diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index 1d4828be223..340fddd398b 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -350,11 +350,6 @@ } } -.flex-container-block { - display: -webkit-flex; - display: flex; -} - .flex-right { margin-left: auto; } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 326499125fc..218e37602dd 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -262,12 +262,7 @@ li.note { } .milestone { - &.milestone-closed { - background: $gray-light; - } - .progress { - margin-bottom: 0; margin-top: 4px; box-shadow: none; background-color: $border-gray-light; diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss index 527e7d57c5c..3cde0490371 100644 --- a/app/assets/stylesheets/framework/emojis.scss +++ b/app/assets/stylesheets/framework/emojis.scss @@ -4,4 +4,5 @@ gl-emoji { vertical-align: middle; font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; font-size: 1.5em; + line-height: 0.9; } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index f060254777c..00eac1688f2 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -322,14 +322,17 @@ span.idiff { } .file-title-flex-parent { - display: flex; - align-items: center; - justify-content: space-between; - background-color: $gray-light; - border-bottom: 1px solid $border-color; - padding: 5px $gl-padding; - margin: 0; - border-radius: $border-radius-default $border-radius-default 0 0; + &, + .file-holder & { + display: flex; + align-items: center; + justify-content: space-between; + background-color: $gray-light; + border-bottom: 1px solid $border-color; + padding: 5px $gl-padding; + margin: 0; + border-radius: $border-radius-default $border-radius-default 0 0; + } .file-header-content { white-space: nowrap; @@ -337,6 +340,17 @@ span.idiff { text-overflow: ellipsis; padding-right: 30px; position: relative; + width: auto; + + @media (max-width: map-get($grid-breakpoints, sm)-1) { + width: 100%; + } + } + + .file-holder & { + .file-actions { + position: static; + } } .btn-clipboard { diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index a6e324036ae..e4bcb92876d 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -42,7 +42,7 @@ display: inline-block; } - a.flash-action { + .flash-action { margin-left: 5px; text-decoration: none; font-weight: $gl-font-weight-normal; diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index 2b2e6d69e33..282e424fc38 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -243,3 +243,15 @@ label { } } } + +.input-icon-wrapper { + position: relative; + + .input-icon-right { + position: absolute; + right: 0.8em; + top: 50%; + transform: translateY(-50%); + color: $theme-gray-600; + } +} diff --git a/app/assets/stylesheets/framework/issue_box.scss b/app/assets/stylesheets/framework/issue_box.scss index 1d247671761..86de88729ee 100644 --- a/app/assets/stylesheets/framework/issue_box.scss +++ b/app/assets/stylesheets/framework/issue_box.scss @@ -45,4 +45,9 @@ &.status-box-upcoming { background: $gl-text-color-secondary; } + + &.status-box-milestone { + color: $gl-text-color; + background: $gray-darker; + } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index f30f296d41f..7808f6d3a25 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -233,7 +233,7 @@ $md-area-border: #ddd; /* * Code */ -$code_font_size: 12px; +$code_font_size: 90%; $code_line_height: 1.6; /* diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss index 514fac82b1e..161943766d4 100644 --- a/app/assets/stylesheets/framework/wells.scss +++ b/app/assets/stylesheets/framework/wells.scss @@ -93,6 +93,10 @@ font-size: 12px; } } + + svg { + vertical-align: text-top; + } } .light-well { diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss index 8cc5252648d..90a5250c247 100644 --- a/app/assets/stylesheets/highlight/white_base.scss +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -102,7 +102,9 @@ pre.code, // Diff line .line_holder { - &.match .line_content { + &.match .line_content, + .new-nonewline.line_content, + .old-nonewline.line_content { @include matchLine; } diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 7b8e66b6f12..a90a9c6e486 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -14,8 +14,8 @@ background-color: $gray-normal; } - .diff-toggle-caret { - padding-right: 6px; + svg { + vertical-align: text-bottom; } } @@ -698,7 +698,7 @@ &.diff-files-changed-merge-request { position: sticky; top: 90px; - z-index: 190; + z-index: 200; margin: $gl-padding 0; padding: 0; } @@ -706,6 +706,7 @@ &.is-stuck { padding-top: 0; padding-bottom: 0; + border-top: 1px solid $white-dark; border-bottom: 1px solid $white-dark; .diff-stats-additions-deletions-expanded, @@ -736,6 +737,10 @@ max-width: 560px; width: 100%; z-index: 150; + min-height: $dropdown-min-height; + max-height: $dropdown-max-height; + overflow-y: auto; + margin-bottom: 0; @include media-breakpoint-up(sm) { left: $gl-padding; diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 79cac7f4ff0..391dfea0703 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -79,6 +79,7 @@ justify-content: space-between; padding: $gl-padding; border-radius: $border-radius-default; + border: 1px solid $theme-gray-100; &.sortable-ghost { opacity: 0.3; @@ -89,6 +90,7 @@ cursor: move; cursor: -webkit-grab; cursor: -moz-grab; + border: 0; &:active { cursor: -webkit-grabbing; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index ccf5d632614..efd730af558 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -737,6 +737,10 @@ > *:not(:last-child) { margin-right: .3em; } + + svg { + vertical-align: text-top; + } } .deploy-link { diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss index dba83e56d72..46437ce5841 100644 --- a/app/assets/stylesheets/pages/milestone.scss +++ b/app/assets/stylesheets/pages/milestone.scss @@ -3,8 +3,20 @@ } .milestones { + padding: $gl-padding-8; + margin-top: $gl-padding-8; + border-radius: $border-radius-default; + background-color: $theme-gray-100; + .milestone { - padding: 10px 16px; + border: 0; + padding: $gl-padding-top $gl-padding; + border-radius: $border-radius-default; + background-color: $white-light; + + &:not(:last-child) { + margin-bottom: $gl-padding-4; + } h4 { font-weight: $gl-font-weight-bold; @@ -13,6 +25,24 @@ .progress { width: 100%; height: 6px; + margin-bottom: $gl-padding-4; + } + + .milestone-progress { + a { + color: $gl-link-color; + } + } + + .status-box { + font-size: $tooltip-font-size; + margin-top: 0; + margin-right: $gl-padding-4; + + @include media-breakpoint-down(xs) { + line-height: unset; + padding: $gl-padding-4 $gl-input-padding; + } } } } @@ -229,6 +259,10 @@ } } +.milestone-range { + color: $gl-text-color-tertiary; +} + @include media-breakpoint-down(xs) { .milestone-banner-text, .milestone-banner-link { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 25400d886fb..32d14049067 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -721,7 +721,7 @@ ul.notes { .line-resolve-all { vertical-align: middle; display: inline-block; - padding: 6px 10px; + padding: 5px 10px 6px; background-color: $gray-light; border: 1px solid $border-color; border-radius: $border-radius-default; diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 2f28031b9c8..e264b06c4b2 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -255,25 +255,12 @@ } } -.modal-doorkeepr-auth, -.doorkeeper-app-form { - .scope-description { - color: $theme-gray-700; - } -} - .modal-doorkeepr-auth { .modal-body { padding: $gl-padding; } } -.doorkeeper-app-form { - .scope-description { - margin: 0 0 5px 17px; - } -} - .deprecated-service { cursor: default; } diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss index 5127ddfde6e..7a93c4dfa28 100644 --- a/app/assets/stylesheets/performance_bar.scss +++ b/app/assets/stylesheets/performance_bar.scss @@ -7,7 +7,6 @@ top: 0; width: 100%; z-index: 2000; - overflow-x: hidden; height: $performance-bar-height; background: $black; @@ -82,7 +81,7 @@ .view { margin-right: 15px; - float: left; + flex-shrink: 0; &:last-child { margin-right: 0; diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb index ee4ed674110..3f4962b543d 100644 --- a/app/controllers/projects/lfs_api_controller.rb +++ b/app/controllers/projects/lfs_api_controller.rb @@ -93,7 +93,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController end def lfs_check_batch_operation! - if upload_request? && Gitlab::Database.read_only? + if batch_operation_disallowed? render( json: { message: lfs_read_only_message @@ -105,6 +105,11 @@ class Projects::LfsApiController < Projects::GitHttpClientController end # Overridden in EE + def batch_operation_disallowed? + upload_request? && Gitlab::Database.read_only? + end + + # Overridden in EE def lfs_read_only_message _('You cannot write to this read-only GitLab instance.') end diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index f85dcfe6bfc..594563d1f6f 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -77,7 +77,7 @@ class Projects::MilestonesController < Projects::ApplicationController def promote promoted_milestone = Milestones::PromoteService.new(project, current_user).execute(milestone) - flash[:notice] = "#{milestone.title} promoted to <a href=\"#{group_milestone_path(project.group, promoted_milestone.iid)}\">group milestone</a>.".html_safe + flash[:notice] = "#{milestone.title} promoted to <a href=\"#{group_milestone_path(project.group, promoted_milestone.iid)}\"><u>group milestone</u></a>.".html_safe respond_to do |format| format.html do redirect_to project_milestones_path(project) diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 5d5f72c4d86..6fdfd964fca 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -7,7 +7,7 @@ # current_user - which user use # params: # scope: 'created_by_me' or 'assigned_to_me' or 'all' -# state: 'opened' or 'closed' or 'all' +# state: 'opened' or 'closed' or 'locked' or 'all' # group_id: integer # project_id: integer # milestone_title: string @@ -311,6 +311,8 @@ class IssuableFinder items.respond_to?(:merged) ? items.merged : items.closed when 'opened' items.opened + when 'locked' + items.where(state: 'locked') else items end diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index 8d84ed4bdfb..40089c082c1 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -6,7 +6,7 @@ # current_user - which user use # params: # scope: 'created_by_me' or 'assigned_to_me' or 'all' -# state: 'open', 'closed', 'merged', or 'all' +# state: 'open', 'closed', 'merged', 'locked', or 'all' # group_id: integer # project_id: integer # milestone_title: string diff --git a/app/graphql/types/base_object.rb b/app/graphql/types/base_object.rb index e033ef96ce9..754adf4c04d 100644 --- a/app/graphql/types/base_object.rb +++ b/app/graphql/types/base_object.rb @@ -1,6 +1,7 @@ module Types class BaseObject < GraphQL::Schema::Object prepend Gitlab::Graphql::Present + prepend Gitlab::Graphql::ExposePermissions field_class Types::BaseField end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index d5d24952984..a1f3c0dd8c0 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -1,5 +1,7 @@ module Types class MergeRequestType < BaseObject + expose_permissions Types::PermissionTypes::MergeRequest + present_using MergeRequestPresenter graphql_name 'MergeRequest' diff --git a/app/graphql/types/permission_types/base_permission_type.rb b/app/graphql/types/permission_types/base_permission_type.rb new file mode 100644 index 00000000000..934ed572e56 --- /dev/null +++ b/app/graphql/types/permission_types/base_permission_type.rb @@ -0,0 +1,38 @@ +module Types + module PermissionTypes + class BasePermissionType < BaseObject + extend Gitlab::Allowable + + RESOLVING_KEYWORDS = [:resolver, :method, :hash_key, :function].to_set.freeze + + def self.abilities(*abilities) + abilities.each { |ability| ability_field(ability) } + end + + def self.ability_field(ability, **kword_args) + unless resolving_keywords?(kword_args) + kword_args[:resolve] ||= -> (object, args, context) do + can?(context[:current_user], ability, object, args.to_h) + end + end + + permission_field(ability, **kword_args) + end + + def self.permission_field(name, **kword_args) + kword_args = kword_args.reverse_merge( + name: name, + type: GraphQL::BOOLEAN_TYPE, + description: "Whether or not a user can perform `#{name}` on this resource", + null: false) + + field(**kword_args) + end + + def self.resolving_keywords?(arguments) + RESOLVING_KEYWORDS.intersect?(arguments.keys.to_set) + end + private_class_method :resolving_keywords? + end + end +end diff --git a/app/graphql/types/permission_types/merge_request.rb b/app/graphql/types/permission_types/merge_request.rb new file mode 100644 index 00000000000..5c21f6ee9c6 --- /dev/null +++ b/app/graphql/types/permission_types/merge_request.rb @@ -0,0 +1,17 @@ +module Types + module PermissionTypes + class MergeRequest < BasePermissionType + present_using MergeRequestPresenter + description 'Check permissions for the current user on a merge request' + graphql_name 'MergeRequestPermissions' + + abilities :read_merge_request, :admin_merge_request, + :update_merge_request, :create_note + + permission_field :push_to_source_branch, method: :can_push_to_source_branch? + permission_field :remove_source_branch, method: :can_remove_source_branch? + permission_field :cherry_pick_on_current_merge_request, method: :can_cherry_pick_on_current_merge_request? + permission_field :revert_on_current_merge_request, method: :can_revert_on_current_merge_request? + end + end +end diff --git a/app/graphql/types/permission_types/project.rb b/app/graphql/types/permission_types/project.rb new file mode 100644 index 00000000000..755699a4415 --- /dev/null +++ b/app/graphql/types/permission_types/project.rb @@ -0,0 +1,20 @@ +module Types + module PermissionTypes + class Project < BasePermissionType + graphql_name 'ProjectPermissions' + + abilities :change_namespace, :change_visibility_level, :rename_project, + :remove_project, :archive_project, :remove_fork_project, + :remove_pages, :read_project, :create_merge_request_in, + :read_wiki, :read_project_member, :create_issue, :upload_file, + :read_cycle_analytics, :download_code, :download_wiki_code, + :fork_project, :create_project_snippet, :read_commit_status, + :request_access, :create_pipeline, :create_pipeline_schedule, + :create_merge_request_from, :create_wiki, :push_code, + :create_deployment, :push_to_delete_protected_branch, + :admin_wiki, :admin_project, :update_pages, + :admin_remote_mirror, :create_label, :update_wiki, :destroy_wiki, + :create_pages, :destroy_pages + end + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index d9058ae7431..a832e8b4bde 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -1,5 +1,7 @@ module Types class ProjectType < BaseObject + expose_permissions Types::PermissionTypes::Project + graphql_name 'Project' field :id, GraphQL::ID_TYPE, null: false diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 82a7931c557..097be8a0643 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -108,7 +108,7 @@ module MergeRequestsHelper data_attrs = { action: tab.to_s, target: "##{tab}", - toggle: options.fetch(:force_link, false) ? '' : 'tab' + toggle: options.fetch(:force_link, false) ? '' : 'tabvue' } url = case tab diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index e1a0cf1604c..3fa2e5452c8 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -148,6 +148,7 @@ module NotesHelper members: autocomplete, issues: autocomplete, mergeRequests: autocomplete, + epics: autocomplete, milestones: autocomplete, labels: autocomplete } diff --git a/app/models/issue.rb b/app/models/issue.rb index d3df2da14e2..4715d942c8d 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -48,7 +48,7 @@ class Issue < ActiveRecord::Base scope :unassigned, -> { where('NOT EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') } scope :assigned_to, ->(u) { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = ? AND issue_id = issues.id)', u.id)} - scope :with_due_date, -> { where('due_date IS NOT NULL') } + scope :with_due_date, -> { where.not(due_date: nil) } scope :without_due_date, -> { where(due_date: nil) } scope :due_before, ->(date) { where('issues.due_date < ?', date) } scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) } @@ -56,7 +56,7 @@ class Issue < ActiveRecord::Base scope :order_due_date_asc, -> { reorder('issues.due_date IS NULL, issues.due_date ASC') } scope :order_due_date_desc, -> { reorder('issues.due_date IS NULL, issues.due_date DESC') } - scope :order_closest_future_date, -> { reorder('CASE WHEN due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - due_date) ASC') } + scope :order_closest_future_date, -> { reorder('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC') } scope :preload_associations, -> { preload(:labels, project: :namespace) } diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 6c96c8ca391..b4090fd8baf 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -128,14 +128,9 @@ class MergeRequest < ActiveRecord::Base end after_transition unchecked: :cannot_be_merged do |merge_request, transition| - begin - if merge_request.notify_conflict? - NotificationService.new.merge_request_unmergeable(merge_request) - TodoService.new.merge_request_became_unmergeable(merge_request) - end - rescue Gitlab::Git::CommandError - # Checking mergeability can trigger exception, e.g. non-utf8 - # We ignore this type of errors. + if merge_request.notify_conflict? + NotificationService.new.merge_request_unmergeable(merge_request) + TodoService.new.merge_request_became_unmergeable(merge_request) end end @@ -707,7 +702,14 @@ class MergeRequest < ActiveRecord::Base end def notify_conflict? - (opened? || locked?) && !project.repository.can_be_merged?(diff_head_sha, target_branch) + (opened? || locked?) && + has_commits? && + !branch_missing? && + !project.repository.can_be_merged?(diff_head_sha, target_branch) + rescue Gitlab::Git::CommandError + # Checking mergeability can trigger exception, e.g. non-utf8 + # We ignore this type of errors. + false end def related_notes diff --git a/app/models/repository.rb b/app/models/repository.rb index 3056c20516a..5f9894f1168 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -99,11 +99,11 @@ class Repository "#<#{self.class.name}:#{@disk_path}>" end - def commit(ref = 'HEAD') + def commit(ref = nil) return nil unless exists? return ref if ref.is_a?(::Commit) - find_commit(ref) + find_commit(ref || root_ref) end # Finding a commit by the passed SHA @@ -283,6 +283,10 @@ class Repository ) end + def cached_methods + CACHED_METHODS + end + def expire_tags_cache expire_method_caches(%i(tag_names tag_count)) @tags = nil @@ -423,7 +427,7 @@ class Repository # Runs code after the HEAD of a repository is changed. def after_change_head - expire_method_caches(METHOD_CACHES_FOR_FILE_TYPES.keys) + expire_all_method_caches end # Runs code after a repository has been forked/imported. diff --git a/app/models/user.rb b/app/models/user.rb index 8e0dc91b2a7..48629c58490 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -244,7 +244,7 @@ class User < ActiveRecord::Base scope :blocked, -> { with_states(:blocked, :ldap_blocked) } scope :external, -> { where(external: true) } scope :active, -> { with_state(:active).non_internal } - scope :without_projects, -> { where('id NOT IN (SELECT DISTINCT(user_id) FROM members WHERE user_id IS NOT NULL AND requested_at IS NULL)') } + scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) } scope :todo_authors, ->(user_id, state) { where(id: Todo.where(user_id: user_id, state: state).select(:author_id)) } scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) } scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) } diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index eb54ab2cda6..f77b3541644 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -168,6 +168,10 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated .can_push_to_branch?(source_branch) end + def can_remove_source_branch? + source_branch_exists? && merge_request.can_remove_source_branch?(current_user) + end + def mergeable_discussions_state # This avoids calling MergeRequest#mergeable_discussions_state without # considering the state of the MR first. If a MR isn't mergeable, we can diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index 0426afc1b4a..5d72ebdd7fd 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -109,7 +109,7 @@ class MergeRequestWidgetEntity < IssuableEntity expose :current_user do expose :can_remove_source_branch do |merge_request| - merge_request.source_branch_exists? && merge_request.can_remove_source_branch?(current_user) + presenter(merge_request).can_remove_source_branch? end expose :can_revert_on_current_merge_request do |merge_request| diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index 78e79344c99..6e5c29a5c40 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -58,7 +58,8 @@ module Issues def cloneable_label_ids params = { project_id: @new_project.id, - title: @old_issue.labels.pluck(:title) + title: @old_issue.labels.pluck(:title), + include_ancestor_groups: true } LabelsFinder.new(current_user, params).execute.pluck(:id) diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb index 5b160ffba67..7606d68ff29 100644 --- a/app/services/merge_requests/post_merge_service.rb +++ b/app/services/merge_requests/post_merge_service.rb @@ -6,9 +6,9 @@ module MergeRequests # class PostMergeService < MergeRequests::BaseService def execute(merge_request) + merge_request.mark_as_merged close_issues(merge_request) todo_service.merge_merge_request(merge_request, current_user) - merge_request.mark_as_merged create_event(merge_request) create_note(merge_request) notification_service.merge_mr(merge_request, current_user) diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb index c0083cd6afd..5b4bc86b9ba 100644 --- a/app/services/merge_requests/rebase_service.rb +++ b/app/services/merge_requests/rebase_service.rb @@ -18,10 +18,18 @@ module MergeRequests return false end + log_prefix = "#{self.class.name} info (#{merge_request.to_reference(full: true)}):" + + Gitlab::GitLogger.info("#{log_prefix} rebase started") + rebase_sha = repository.rebase(current_user, merge_request) + Gitlab::GitLogger.info("#{log_prefix} rebased to #{rebase_sha}") + merge_request.update_attributes(rebase_commit_sha: rebase_sha) + Gitlab::GitLogger.info("#{log_prefix} rebase SHA saved: #{rebase_sha}") + true rescue => e log_error(REBASE_ERROR, save_message_on_model: true) diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 3cdeb103bb8..18f2c1a509f 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -2,7 +2,7 @@ - breadcrumb_title "Dashboard" %div{ class: container_class } - = render_if_exists "admin/licenses/breakdown", license: @license + = render_if_exists 'admin/licenses/breakdown', license: @license .admin-dashboard.prepend-top-default .row @@ -22,7 +22,7 @@ %h3.text-center Users: = approximate_count_with_delimiters(@counts, User) - = render_if_exists 'users_statistics' + = render_if_exists 'admin/dashboard/users_statistics' %hr = link_to 'New user', new_admin_user_path, class: "btn btn-new" .col-sm-4 @@ -101,7 +101,7 @@ %span.light.float-right = boolean_to_icon Gitlab::IncomingEmail.enabled? - = render_if_exists 'elastic_and_geo' + = render_if_exists 'admin/dashboard/elastic_and_geo' - container_reg = "Container Registry" %p{ "aria-label" => "#{container_reg}: status " + (Gitlab.config.registry.enabled ? "on" : "off") } @@ -151,7 +151,7 @@ %span.float-right = Gitlab::Pages::VERSION - = render_if_exists 'geo' + = render_if_exists 'admin/dashboard/geo' %p Ruby diff --git a/app/views/admin/identities/_form.html.haml b/app/views/admin/identities/_form.html.haml index 231c0f70882..946d868da01 100644 --- a/app/views/admin/identities/_form.html.haml +++ b/app/views/admin/identities/_form.html.haml @@ -7,10 +7,10 @@ - values = Gitlab::Auth::OAuth::Provider.providers.map { |name| ["#{Gitlab::Auth::OAuth::Provider.label_for(name)} (#{name})", name] } = f.select :provider, values, { allow_blank: false }, class: 'form-control' .form-group.row - = f.label :extern_uid, "Identifier", class: 'col-form-label col-sm-2' + = f.label :extern_uid, _("Identifier"), class: 'col-form-label col-sm-2' .col-sm-10 = f.text_field :extern_uid, class: 'form-control', required: true .form-actions - = f.submit 'Save changes', class: "btn btn-save" + = f.submit _('Save changes'), class: "btn btn-save" diff --git a/app/views/admin/identities/_identity.html.haml b/app/views/admin/identities/_identity.html.haml index 50fe9478a78..5ed59809db5 100644 --- a/app/views/admin/identities/_identity.html.haml +++ b/app/views/admin/identities/_identity.html.haml @@ -5,8 +5,8 @@ = identity.extern_uid %td = link_to edit_admin_user_identity_path(@user, identity), class: 'btn btn-sm btn-grouped' do - Edit + = _("Edit") = link_to [:admin, @user, identity], method: :delete, class: 'btn btn-sm btn-danger', - data: { confirm: "Are you sure you want to remove this identity?" } do - Delete + data: { confirm: _("Are you sure you want to remove this identity?") } do + = _('Delete') diff --git a/app/views/admin/identities/edit.html.haml b/app/views/admin/identities/edit.html.haml index 515d46b0f29..1ad6ce969cb 100644 --- a/app/views/admin/identities/edit.html.haml +++ b/app/views/admin/identities/edit.html.haml @@ -1,6 +1,6 @@ -- page_title "Edit", @identity.provider, "Identities", @user.name, "Users" +- page_title _("Edit"), @identity.provider, _("Identities"), @user.name, _("Users") %h3.page-title - Edit identity for #{@user.name} + = _('Edit identity for %{user_name}') % { user_name: @user.name } %hr = render 'form' diff --git a/app/views/admin/identities/index.html.haml b/app/views/admin/identities/index.html.haml index ee51fb3fda1..59373ee6752 100644 --- a/app/views/admin/identities/index.html.haml +++ b/app/views/admin/identities/index.html.haml @@ -1,15 +1,15 @@ -- page_title "Identities", @user.name, "Users" +- page_title _("Identities"), @user.name, _("Users") = render 'admin/users/head' -= link_to 'New identity', new_admin_user_identity_path, class: 'float-right btn btn-new' += link_to _('New identity'), new_admin_user_identity_path, class: 'float-right btn btn-new' - if @identities.present? .table-holder %table.table %thead %tr - %th Provider - %th Identifier + %th= _('Provider') + %th= _('Identifier') %th = render @identities - else - %h4 This user has no identities + %h4= _('This user has no identities') diff --git a/app/views/admin/identities/new.html.haml b/app/views/admin/identities/new.html.haml index e30bf0ef0ee..ee743b0fd3c 100644 --- a/app/views/admin/identities/new.html.haml +++ b/app/views/admin/identities/new.html.haml @@ -1,4 +1,4 @@ -- page_title "New Identity" -%h3.page-title New identity +- page_title _("New Identity") +%h3.page-title= _('New identity') %hr = render 'form' diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml index 6d9c6b5572a..28cdc7607e0 100644 --- a/app/views/doorkeeper/authorizations/new.html.haml +++ b/app/views/doorkeeper/authorizations/new.html.haml @@ -35,7 +35,7 @@ - @pre_auth.scopes.each do |scope| %li %strong= t scope, scope: [:doorkeeper, :scopes] - .scope-description= t scope, scope: [:doorkeeper, :scope_desc] + .text-secondary= t scope, scope: [:doorkeeper, :scope_desc] .form-actions.text-right = form_tag oauth_authorization_path, method: :delete, class: 'inline' do = hidden_field_tag :client_id, @pre_auth.client.uid diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml index 8037cf4b69d..5e1ae1dbe38 100644 --- a/app/views/groups/issues.html.haml +++ b/app/views/groups/issues.html.haml @@ -9,7 +9,7 @@ = render 'shared/issuable/nav', type: :issues .nav-controls = render 'shared/issuable/feed_buttons' - = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", type: :issues + = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", type: :issues, with_feature_enabled: 'issues' = render 'shared/issuable/search_bar', type: :issues diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml index 4ccd16f3e11..e2a317dbf67 100644 --- a/app/views/groups/merge_requests.html.haml +++ b/app/views/groups/merge_requests.html.haml @@ -7,7 +7,7 @@ = render 'shared/issuable/nav', type: :merge_requests - if current_user .nav-controls - = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", type: :merge_requests + = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", type: :merge_requests, with_feature_enabled: 'merge_requests' = render 'shared/issuable/search_bar', type: :merge_requests diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index 1e7d9444986..f4d4888bd15 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -3,7 +3,7 @@ - project = local_assigns.fetch(:project) - expanded = Rails.env.test? -%section.settings.no-animate{ class: ('expanded' if expanded) } +%section.settings.no-animate#js-export-project{ class: ('expanded' if expanded) } .settings-header %h4 Export project diff --git a/app/views/projects/clusters/user/_form.html.haml b/app/views/projects/clusters/user/_form.html.haml index db57da99ec7..d45ae6ec91f 100644 --- a/app/views/projects/clusters/user/_form.html.haml +++ b/app/views/projects/clusters/user/_form.html.haml @@ -3,9 +3,10 @@ .form-group = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-light' = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name') - .form-group - = field.label :environment_scope, s_('ClusterIntegration|Environment scope'), class: 'label-light' - = field.text_field :environment_scope, class: 'form-control', readonly: !has_multiple_clusters?(@project), placeholder: s_('ClusterIntegration|Environment scope') + - if has_multiple_clusters?(@project) + .form-group + = field.label :environment_scope, s_('ClusterIntegration|Environment scope'), class: 'label-light' + = field.text_field :environment_scope, class: 'form-control', placeholder: s_('ClusterIntegration|Environment scope') = field.fields_for :platform_kubernetes, @cluster.platform_kubernetes do |platform_kubernetes_field| .form-group diff --git a/app/views/projects/deploy_tokens/_index.html.haml b/app/views/projects/deploy_tokens/_index.html.haml index 725720d2222..33faab0c510 100644 --- a/app/views/projects/deploy_tokens/_index.html.haml +++ b/app/views/projects/deploy_tokens/_index.html.haml @@ -1,6 +1,6 @@ - expanded = expand_deploy_tokens_section?(@new_deploy_token) -%section.settings.no-animate{ class: ('expanded' if expanded) } +%section.settings.no-animate#js-deploy-tokens{ class: ('expanded' if expanded) } .settings-header %h4= s_('DeployTokens|Deploy Tokens') %button.btn.js-settings-toggle.qa-expand-deploy-keys{ type: 'button' } diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 9f175d2376f..c2d900cbcf7 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -4,7 +4,7 @@ - expanded = Rails.env.test? .project-edit-container - %section.settings.general-settings.no-animate{ class: ('expanded' if expanded) } + %section.settings.general-settings.no-animate#js-general-project-settings{ class: ('expanded' if expanded) } .settings-header %h4 General project @@ -65,7 +65,7 @@ = link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _("Avatar will be removed. Are you sure?") }, method: :delete, class: "btn btn-danger btn-inverted" = f.submit 'Save changes', class: "btn btn-success js-btn-save-general-project-settings" - %section.settings.sharing-permissions.no-animate{ class: ('expanded' if expanded) } + %section.settings.sharing-permissions.no-animate#js-shared-permissions{ class: ('expanded' if expanded) } .settings-header %h4 Permissions @@ -82,7 +82,7 @@ = render_if_exists 'projects/issues_settings' - %section.qa-merge-request-settings.settings.merge-requests-feature.no-animate{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)] } + %section.qa-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)] } .settings-header %h4 Merge request @@ -101,7 +101,7 @@ = render 'export', project: @project - %section.qa-advanced-settings.settings.advanced-settings.no-animate{ class: ('expanded' if expanded) } + %section.qa-advanced-settings.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded) } .settings-header %h4 Advanced diff --git a/app/views/projects/environments/_external_url.html.haml b/app/views/projects/environments/_external_url.html.haml index a82ef5ee5bb..a264252e095 100644 --- a/app/views/projects/environments/_external_url.html.haml +++ b/app/views/projects/environments/_external_url.html.haml @@ -1,4 +1,4 @@ - if environment.external_url && can?(current_user, :read_environment, environment) = link_to environment.external_url, target: '_blank', rel: 'noopener noreferrer', class: 'btn external-url' do - = icon('external-link') + = sprite_icon('external-link') View deployment diff --git a/app/views/projects/environments/_metrics_button.html.haml b/app/views/projects/environments/_metrics_button.html.haml index b4102fcf103..a4b27575095 100644 --- a/app/views/projects/environments/_metrics_button.html.haml +++ b/app/views/projects/environments/_metrics_button.html.haml @@ -3,5 +3,5 @@ - return unless can?(current_user, :read_environment, environment) = link_to environment_metrics_path(environment), title: 'See metrics', class: 'btn metrics-button' do - = icon('area-chart') + = sprite_icon('chart') Monitoring diff --git a/app/views/projects/environments/_terminal_button.html.haml b/app/views/projects/environments/_terminal_button.html.haml index a6201bdbc42..38bc087664b 100644 --- a/app/views/projects/environments/_terminal_button.html.haml +++ b/app/views/projects/environments/_terminal_button.html.haml @@ -1,3 +1,3 @@ - if environment.has_terminals? && can?(current_user, :admin_environment, @project) = link_to terminal_project_environment_path(@project, environment), class: 'btn terminal-button' do - = icon('terminal') + = sprite_icon('terminal') diff --git a/app/views/projects/environments/terminal.html.haml b/app/views/projects/environments/terminal.html.haml index 6ec4ff56552..5b680189bc8 100644 --- a/app/views/projects/environments/terminal.html.haml +++ b/app/views/projects/environments/terminal.html.haml @@ -16,7 +16,7 @@ .nav-controls - if @environment.external_url.present? = link_to @environment.external_url, class: 'btn btn-default', target: '_blank', rel: 'noopener noreferrer nofollow' do - = icon('external-link') + = sprite_icon('external-link') = render 'projects/deployments/actions', deployment: @environment.last_deployment .terminal-container{ class: container_class } diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml index b2eacabc21a..f7a5d85500f 100644 --- a/app/views/projects/merge_requests/creations/_new_submit.html.haml +++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml @@ -24,23 +24,28 @@ There are no commits yet. = custom_icon ('illustration_no_commits') - else - %ul.merge-request-tabs.nav.nav-tabs.nav-links.no-top.no-bottom - %li.commits-tab - = link_to url_for(safe_params), data: {target: 'div#commits', action: 'new', toggle: 'tab'} do - Commits - %span.badge.badge-pill= @commits.size - - if @pipelines.any? - %li.builds-tab - = link_to url_for(safe_params.merge(action: 'pipelines')), data: {target: 'div#pipelines', action: 'pipelines', toggle: 'tab'} do - Pipelines - %span.badge.badge-pill= @pipelines.size - %li.diffs-tab - = link_to url_for(safe_params.merge(action: 'diffs')), data: {target: 'div#diffs', action: 'diffs', toggle: 'tab'} do - Changes - %span.badge.badge-pill= @merge_request.diff_size + .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') } + .merge-request-tabs-container + .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller + .fade-left= icon('angle-left') + .fade-right= icon('angle-right') + %ul.merge-request-tabs.nav.nav-tabs.nav-links.no-top.no-bottom.js-tabs-affix + %li.commits-tab.new-tab + = link_to url_for(safe_params), data: {target: 'div#commits', action: 'new', toggle: 'tabvue'} do + Commits + %span.badge.badge-pill= @commits.size + - if @pipelines.any? + %li.builds-tab + = link_to url_for(safe_params.merge(action: 'pipelines')), data: {target: 'div#pipelines', action: 'pipelines', toggle: 'tabvue'} do + Pipelines + %span.badge.badge-pill= @pipelines.size + %li.diffs-tab + = link_to url_for(safe_params.merge(action: 'diffs')), data: {target: 'div#diffs', action: 'diffs', toggle: 'tabvue'} do + Changes + %span.badge.badge-pill= @merge_request.diff_size - .tab-content - #commits.commits.tab-pane.active + #diff-notes-app.tab-content + #new.commits.tab-pane.active = render "projects/merge_requests/commits" #diffs.diffs.tab-pane -# This tab is always loaded via AJAX diff --git a/app/views/projects/mirrors/_push.html.haml b/app/views/projects/mirrors/_push.html.haml index c3dcd9617a6..2b2871a81e5 100644 --- a/app/views/projects/mirrors/_push.html.haml +++ b/app/views/projects/mirrors/_push.html.haml @@ -1,5 +1,5 @@ - expanded = Rails.env.test? -%section.settings.no-animate{ class: ('expanded' if expanded) } +%section.settings.no-animate#js-push-remote-settings{ class: ('expanded' if expanded) } .settings-header %h4 Push to a remote repository diff --git a/app/views/projects/protected_tags/shared/_index.html.haml b/app/views/projects/protected_tags/shared/_index.html.haml index fe2903b456f..9a50a51e4be 100644 --- a/app/views/projects/protected_tags/shared/_index.html.haml +++ b/app/views/projects/protected_tags/shared/_index.html.haml @@ -1,6 +1,6 @@ - expanded = Rails.env.test? -%section.settings.no-animate{ class: ('expanded' if expanded) } +%section.settings.no-animate#js-protected-tags-settings{ class: ('expanded' if expanded) } .settings-header %h4 Protected Tags diff --git a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml index 209b9c71390..9314804c5dd 100644 --- a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml +++ b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml @@ -6,13 +6,13 @@ 1. = link_to 'https://docs.mattermost.com/developer/slash-commands.html#enabling-custom-commands', target: '_blank', rel: 'noopener noreferrer nofollow' do Enable custom slash commands - = icon('external-link') + = sprite_icon('external-link', size: 16) on your Mattermost installation %li 2. = link_to 'https://docs.mattermost.com/developer/slash-commands.html#set-up-a-custom-command', target: '_blank', rel: 'noopener noreferrer nofollow' do Add a slash command - = icon('external-link') + = sprite_icon('external-link', size: 16) in your Mattermost team with these options: %hr diff --git a/app/views/projects/services/mattermost_slash_commands/_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_help.html.haml index b20614dc88f..f51dd581d29 100644 --- a/app/views/projects/services/mattermost_slash_commands/_help.html.haml +++ b/app/views/projects/services/mattermost_slash_commands/_help.html.haml @@ -7,7 +7,7 @@ project by entering slash commands in Mattermost. = link_to help_page_path('user/project/integrations/mattermost_slash_commands.md'), target: '_blank' do View documentation - = icon('external-link') + = sprite_icon('external-link', size: 16) %p.inline See list of available commands in Mattermost after setting up this service, by entering diff --git a/app/views/projects/services/slack_slash_commands/_help.html.haml b/app/views/projects/services/slack_slash_commands/_help.html.haml index 9d045d84b52..f25d2ecdfb1 100644 --- a/app/views/projects/services/slack_slash_commands/_help.html.haml +++ b/app/views/projects/services/slack_slash_commands/_help.html.haml @@ -8,7 +8,7 @@ project by entering slash commands in Slack. = link_to help_page_path('user/project/integrations/slack_slash_commands.md'), target: '_blank' do View documentation - = icon('external-link') + = sprite_icon('external-link', size: 16) %p.inline See list of available commands in Slack after setting up this service, by entering @@ -20,7 +20,7 @@ 1. = link_to 'https://my.slack.com/services/new/slash-commands', target: '_blank', rel: 'noreferrer noopener nofollow' do Add a slash command - = icon('external-link') + = sprite_icon('external-link', size: 16) in your Slack team with these options: %hr diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml index 4359362bb05..31c2616d283 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -63,4 +63,4 @@ .form-text.text-muted = s_('CICD|An explicit %{ci_file} needs to be specified before you can begin using Continuous Integration and Delivery.').html_safe % { ci_file: ci_file_formatted } - = f.submit 'Save changes', class: "btn btn-success prepend-top-15" + = f.submit _('Save changes'), class: "btn btn-success prepend-top-15" diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index 7142a9d635e..5025460a2d0 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -4,20 +4,20 @@ = form_errors(@project) %fieldset.builds-feature .form-group.append-bottom-default.js-secret-runner-token - = f.label :runners_token, "Runner token", class: 'label-light' + = f.label :runners_token, _("Runner token"), class: 'label-light' .form-control.js-secret-value-placeholder = '*' * 20 = f.text_field :runners_token, class: "form-control hide js-secret-value", placeholder: 'xEeFCaDAB89' - %p.form-text.text-muted The secure token used by the Runner to checkout the project + %p.form-text.text-muted= _("The secure token used by the Runner to checkout the project") %button.btn.btn-info.prepend-top-10.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: 'false' } } = _('Reveal value') %hr .form-group %h5.prepend-top-0 - Git strategy for pipelines + = _("Git strategy for pipelines") %p - Choose between <code>clone</code> or <code>fetch</code> to get the recent application code + = _("Choose between <code>clone</code> or <code>fetch</code> to get the recent application code") = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'git-strategy'), target: '_blank' .form-check = f.radio_button :build_allow_git_fetch, 'false', { class: 'form-check-input' } @@ -25,29 +25,29 @@ %strong git clone %br %span.descr - Slower but makes sure the project workspace is pristine as it clones the repository from scratch for every job + = _("Slower but makes sure the project workspace is pristine as it clones the repository from scratch for every job") .form-check = f.radio_button :build_allow_git_fetch, 'true', { class: 'form-check-input' } = f.label :build_allow_git_fetch_true, class: 'form-check-label' do %strong git fetch %br %span.descr - Faster as it re-uses the project workspace (falling back to clone if it doesn't exist) + = _("Faster as it re-uses the project workspace (falling back to clone if it doesn't exist)") %hr .form-group - = f.label :build_timeout_human_readable, 'Timeout', class: 'label-light' + = f.label :build_timeout_human_readable, _('Timeout'), class: 'label-light' = f.text_field :build_timeout_human_readable, class: 'form-control' %p.form-text.text-muted - Per job. If a job passes this threshold, it will be marked as failed + = _("Per job. If a job passes this threshold, it will be marked as failed") = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'timeout'), target: '_blank' %hr .form-group - = f.label :ci_config_path, 'Custom CI config path', class: 'label-light' + = f.label :ci_config_path, _('Custom CI config path'), class: 'label-light' = f.text_field :ci_config_path, class: 'form-control', placeholder: '.gitlab-ci.yml' %p.form-text.text-muted - The path to CI config file. Defaults to <code>.gitlab-ci.yml</code> + = _("The path to CI config file. Defaults to <code>.gitlab-ci.yml</code>") = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'custom-ci-config-path'), target: '_blank' %hr @@ -55,36 +55,35 @@ .form-check = f.check_box :public_builds, { class: 'form-check-input' } = f.label :public_builds, class: 'form-check-label' do - %strong Public pipelines + %strong= _("Public pipelines") .form-text.text-muted - Allow public access to pipelines and job details, including output logs and artifacts + = _("Allow public access to pipelines and job details, including output logs and artifacts") = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'visibility-of-pipelines'), target: '_blank' .bs-callout.bs-callout-info - %p If enabled: + %p #{_("If enabled")}: %ul %li - For public projects, anyone can view pipelines and access job details (output logs and artifacts) + = _("For public projects, anyone can view pipelines and access job details (output logs and artifacts)") %li - For internal projects, any logged in user can view pipelines and access job details (output logs and artifacts) + = _("For internal projects, any logged in user can view pipelines and access job details (output logs and artifacts)") %li - For private projects, any member (guest or higher) can view pipelines and access job details (output logs and artifacts) + = _("For private projects, any member (guest or higher) can view pipelines and access job details (output logs and artifacts)") %p - If disabled, the access level will depend on the user's - permissions in the project. + = _("If disabled, the access level will depend on the user's permissions in the project.") %hr .form-group .form-check = f.check_box :auto_cancel_pending_pipelines, { class: 'form-check-input' }, 'enabled', 'disabled' = f.label :auto_cancel_pending_pipelines, class: 'form-check-label' do - %strong Auto-cancel redundant, pending pipelines + %strong= _("Auto-cancel redundant, pending pipelines") .form-text.text-muted - New pipelines will cancel older, pending pipelines on the same branch + = _("New pipelines will cancel older, pending pipelines on the same branch") = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'auto-cancel-pending-pipelines'), target: '_blank' %hr .form-group - = f.label :build_coverage_regex, "Test coverage parsing", class: 'label-light' + = f.label :build_coverage_regex, _("Test coverage parsing"), class: 'label-light' .input-group %span.input-group-prepend .input-group-text / @@ -92,11 +91,10 @@ %span.input-group-append .input-group-text / %p.form-text.text-muted - A regular expression that will be used to find the test coverage - output in the job trace. Leave blank to disable + = _("A regular expression that will be used to find the test coverage output in the job trace. Leave blank to disable") = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'test-coverage-parsing'), target: '_blank' .bs-callout.bs-callout-info - %p Below are examples of regex for existing tools: + %p= _("Below are examples of regex for existing tools:") %ul %li Simplecov (Ruby) - @@ -120,7 +118,7 @@ JaCoCo (Java/Kotlin) %code Total.*?([0-9]{1,3})% - = f.submit 'Save changes', class: "btn btn-save" + = f.submit _('Save changes'), class: "btn btn-save" %hr diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 56c175f5649..be22bbd7a9b 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -1,6 +1,6 @@ - @content_class = "limit-container-width" unless fluid_layout -- page_title "CI / CD Settings" -- page_title "CI / CD" +- page_title _("CI / CD Settings") +- page_title _("CI / CD") - expanded = Rails.env.test? - general_expanded = @project.errors.empty? ? expanded : true @@ -8,11 +8,11 @@ %section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if general_expanded) } .settings-header %h4 - General pipelines + = _("General pipelines") %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + = expanded ? _('Collapse') : _('Expand') %p - Access your runner token, customize your pipeline configuration, and view your pipeline status and coverage report. + = _("Access your runner token, customize your pipeline configuration, and view your pipeline status and coverage report.") .settings-content = render 'form' @@ -31,11 +31,11 @@ %section.qa-runners-settings.settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 - Runners + = _("Runners") %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + = expanded ? _('Collapse') : _('Expand') %p - Register and see your runners for this project. + = _("Register and see your runners for this project.") .settings-content = render 'projects/runners/index' @@ -45,21 +45,19 @@ = _('Variables') = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'variables'), target: '_blank', rel: 'noopener noreferrer' %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + = expanded ? _('Collapse') : _('Expand') %p.append-bottom-0 = render "ci/variables/content" .settings-content = render 'ci/variables/index', save_endpoint: project_variables_path(@project) -%section.settings.no-animate{ class: ('expanded' if expanded) } +%section.settings.no-animate#js-pipeline-triggers{ class: ('expanded' if expanded) } .settings-header %h4 - Pipeline triggers + = _("Pipeline triggers") %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + = expanded ? _('Collapse') : _('Expand') %p - Triggers can force a specific branch or tag to get rebuilt with an API call. These tokens will - impersonate their associated user including their access to projects and their project - permissions. + = _("Triggers can force a specific branch or tag to get rebuilt with an API call. These tokens will impersonate their associated user including their access to projects and their project permissions.") .settings-content = render 'projects/triggers/index' diff --git a/app/views/projects/settings/integrations/_project_hook.html.haml b/app/views/projects/settings/integrations/_project_hook.html.haml index 77d88aed883..ef445f2e139 100644 --- a/app/views/projects/settings/integrations/_project_hook.html.haml +++ b/app/views/projects/settings/integrations/_project_hook.html.haml @@ -8,9 +8,9 @@ %span.badge.badge-gray.deploy-project-label= event.to_s.titleize .col-md-4.col-lg-5.text-right-lg.prepend-top-5 %span.append-right-10.inline - SSL Verification: #{hook.enable_ssl_verification ? 'enabled' : 'disabled'} - = link_to 'Edit', edit_project_hook_path(@project, hook), class: 'btn btn-sm' + #{_("SSL Verification")}: #{hook.enable_ssl_verification ? _('enabled') : _('disabled')} + = link_to _('Edit'), edit_project_hook_path(@project, hook), class: 'btn btn-sm' = render 'shared/web_hooks/test_button', triggers: ProjectHook.triggers, hook: hook, button_class: 'btn-sm' - = link_to project_hook_path(@project, hook), data: { confirm: 'Are you sure?'}, method: :delete, class: 'btn btn-transparent' do - %span.sr-only Remove + = link_to project_hook_path(@project, hook), data: { confirm: _('Are you sure?') }, method: :delete, class: 'btn btn-transparent' do + %span.sr-only= _("Remove") = icon('trash') diff --git a/app/views/projects/settings/integrations/show.html.haml b/app/views/projects/settings/integrations/show.html.haml index 2f1a548e119..76770290f36 100644 --- a/app/views/projects/settings/integrations/show.html.haml +++ b/app/views/projects/settings/integrations/show.html.haml @@ -1,5 +1,5 @@ - @content_class = "limit-container-width" unless fluid_layout -- breadcrumb_title "Integrations Settings" -- page_title 'Integrations' +- breadcrumb_title _("Integrations Settings") +- page_title _('Integrations') = render 'projects/hooks/index' = render 'projects/services/index' diff --git a/app/views/projects/settings/members/show.html.haml b/app/views/projects/settings/members/show.html.haml index ea2cd36b212..5fca734222b 100644 --- a/app/views/projects/settings/members/show.html.haml +++ b/app/views/projects/settings/members/show.html.haml @@ -1,5 +1,5 @@ - @content_class = "limit-container-width" unless fluid_layout -- page_title "Members" +- page_title _("Members") = render "projects/project_members/index" diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index 5dda2ec28b4..98c609d7bd4 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title "Repository Settings" -- page_title "Repository" +- breadcrumb_title _("Repository Settings") +- page_title _("Repository") - @content_class = "limit-container-width" unless fluid_layout = render "projects/mirrors/show" diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index 5eec7b02b54..e93925b5ef9 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -21,8 +21,9 @@ = sprite_icon('star-o') %button.label-action.remove-priority.btn.btn-transparent.has-tooltip{ title: _('Remove priority'), type: 'button', data: { placement: 'top' }, aria_label: _('Deprioritize label') } = sprite_icon('star') + - if can?(current_user, :admin_label, label) %li.inline - = link_to edit_label_path(label), class: 'btn btn-transparent label-action', aria_label: 'Edit label' do + = link_to edit_label_path(label), class: 'btn btn-transparent label-action edit', aria_label: 'Edit label' do = sprite_icon('pencil') %li.inline .dropdown @@ -42,9 +43,10 @@ container: 'body', toggle: 'modal' } } = _('Promote to group label') - %li - %span{ data: { toggle: 'modal', target: "#modal-delete-label-#{label.id}" } } - %button.text-danger.remove-row{ type: 'button' }= _('Delete') + - if can?(current_user, :admin_label, label) + %li + %span{ data: { toggle: 'modal', target: "#modal-delete-label-#{label.id}" } } + %button.text-danger.remove-row{ type: 'button' }= _('Delete') - if current_user %li.inline.label-subscription - if can_subscribe_to_label_in_different_levels?(label) diff --git a/app/views/shared/_milestone_expired.html.haml b/app/views/shared/_milestone_expired.html.haml index 5e9007aaaac..099e3ac8462 100644 --- a/app/views/shared/_milestone_expired.html.haml +++ b/app/views/shared/_milestone_expired.html.haml @@ -1,7 +1,6 @@ - if milestone.expired? and not milestone.closed? - %span.cred (Expired) + .status-box.status-box-expired.append-bottom-5 Expired - if milestone.upcoming? - %span.clgray (Upcoming) -- if milestone.due_date || milestone.start_date - %span - = milestone_date_range(milestone) + .status-box.status-box-mr-merged.append-bottom-5 Upcoming +- if milestone.closed? + .status-box.status-box-closed.append-bottom-5 Closed diff --git a/app/views/shared/_personal_access_tokens_form.html.haml b/app/views/shared/_personal_access_tokens_form.html.haml index b8b1f4ca42f..28407b543b9 100644 --- a/app/views/shared/_personal_access_tokens_form.html.haml +++ b/app/views/shared/_personal_access_tokens_form.html.haml @@ -9,13 +9,17 @@ = form_errors(token) - .form-group - = f.label :name, class: 'label-light' - = f.text_field :name, class: "form-control", required: true + .row + .form-group.col-md-6 + = f.label :name, class: 'label-light' + = f.text_field :name, class: "form-control", required: true - .form-group - = f.label :expires_at, class: 'label-light' - = f.text_field :expires_at, class: "datepicker form-control" + .row + .form-group.col-md-6 + = f.label :expires_at, class: 'label-light' + .input-icon-wrapper + = f.text_field :expires_at, class: "datepicker form-control", placeholder: 'YYYY-MM-DD' + = icon('calendar', { class: 'input-icon-right' }) .form-group = f.label :scopes, class: 'label-light' diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml index c7c33288e9d..2e26fe63d3e 100644 --- a/app/views/shared/empty_states/_issues.html.haml +++ b/app/views/shared/empty_states/_issues.html.haml @@ -16,7 +16,7 @@ - if has_button .text-center - if project_select_button - = render 'shared/new_project_item_select', path: 'issues/new', label: 'New issue', type: :issues + = render 'shared/new_project_item_select', path: 'issues/new', label: 'New issue', type: :issues, with_feature_enabled: 'issues' - else = link_to 'New issue', button_path, class: 'btn btn-success', title: 'New issue', id: 'new_issue_link' - else diff --git a/app/views/shared/empty_states/_merge_requests.html.haml b/app/views/shared/empty_states/_merge_requests.html.haml index 014220761a9..186139f3526 100644 --- a/app/views/shared/empty_states/_merge_requests.html.haml +++ b/app/views/shared/empty_states/_merge_requests.html.haml @@ -15,7 +15,7 @@ = _("Interested parties can even contribute by pushing commits if they want to.") .text-center - if project_select_button - = render 'shared/new_project_item_select', path: 'merge_requests/new', label: _('New merge request'), type: :merge_requests + = render 'shared/new_project_item_select', path: 'merge_requests/new', label: _('New merge request'), type: :merge_requests, with_feature_enabled: 'merge_requests' - else = link_to _('New merge request'), button_path, class: 'btn btn-new', title: _('New merge request'), id: 'new_merge_request_link' - else diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index 09bbd04c2bf..c559945a9c9 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -1,76 +1,59 @@ - dashboard = local_assigns[:dashboard] - custom_dom_id = dom_id(milestone.try(:milestones) ? milestone.milestones.first : milestone) +- milestone_type = milestone.group_milestone? ? 'Group Milestone' : 'Project Milestone' %li{ class: "milestone milestone-#{milestone.closed? ? 'closed' : 'open'}", id: custom_dom_id } .row .col-sm-6 - %strong= link_to truncate(milestone.title, length: 100), milestone_path - - if milestone.group_milestone? - %span - Group Milestone - - else - %span - Project Milestone + .append-bottom-5 + %strong= link_to truncate(milestone.title, length: 100), milestone_path + - if @group + = " - #{milestone_type}" - .col-sm-6 - .float-right.light #{milestone.percent_complete(current_user)}% complete - .row - .col-sm-6 + - if @project || milestone.is_a?(GlobalMilestone) || milestone.group_milestone? + - if milestone.due_date || milestone.start_date + .milestone-range.append-bottom-5 + = milestone_date_range(milestone) + %div + = render('shared/milestone_expired', milestone: milestone) + - if milestone.legacy_group_milestone? + .projects + - milestone.milestones.each do |milestone| + = link_to milestone_path(milestone) do + %span.label-badge.label-badge-blue.d-inline-block.append-bottom-5 + = dashboard ? milestone.project.full_name : milestone.project.name + + .col-sm-4.milestone-progress + = milestone_progress_bar(milestone) = link_to pluralize(milestone.total_issues_count(current_user), 'Issue'), issues_path · = link_to pluralize(milestone.merge_requests.size, 'Merge Request'), merge_requests_path - .col-sm-6= milestone_progress_bar(milestone) - - if milestone.is_a?(GlobalMilestone) || milestone.group_milestone? - .row - .col-sm-6 - - if milestone.legacy_group_milestone? - .expiration= render('shared/milestone_expired', milestone: milestone) - .projects - - milestone.milestones.each do |milestone| - = link_to milestone_path(milestone) do - %span.badge.badge-gray - = dashboard ? milestone.project.full_name : milestone.project.name - - if @group - .col-sm-6.milestone-actions + .float-lg-right.light #{milestone.percent_complete(current_user)}% complete + .col-sm-2 + .milestone-actions.d-flex.justify-content-sm-start.justify-content-md-end + - if @project + - if can?(current_user, :admin_milestone, milestone.project) and milestone.active? + - if @project.group + %button.js-promote-project-milestone-button.btn.btn-blank.btn-sm.btn-grouped.has-tooltip{ title: _('Promote to Group Milestone'), + disabled: true, + type: 'button', + data: { url: promote_project_milestone_path(milestone.project, milestone), + milestone_title: milestone.title, + group_name: @project.group.name, + target: '#promote-milestone-modal', + container: 'body', + toggle: 'modal' } } + = sprite_icon('level-up', size: 14) + + = link_to 'Close Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-sm btn-close btn-grouped" + - unless milestone.active? + = link_to 'Reopen Milestone', project_milestone_path(@project, milestone, {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen" + - if @group - if can?(current_user, :admin_milestones, @group) - - if milestone.group_milestone? - = link_to edit_group_milestone_path(@group, milestone), class: "btn btn-sm btn-grouped" do - Edit - \ - if milestone.closed? = link_to 'Reopen Milestone', group_milestone_route(milestone, {state_event: :activate }), method: :put, class: "btn btn-sm btn-grouped btn-reopen" - else = link_to 'Close Milestone', group_milestone_route(milestone, {state_event: :close }), method: :put, class: "btn btn-sm btn-grouped btn-close" - - - if @project - .row - .col-sm-6 - = render('shared/milestone_expired', milestone: milestone) - .col-sm-6.milestone-actions - - if can?(current_user, :admin_milestone, milestone.project) and milestone.active? - = link_to edit_project_milestone_path(milestone.project, milestone), class: "btn btn-sm btn-grouped" do - Edit - \ - - - if @project.group - %button.js-promote-project-milestone-button.btn.btn-sm.btn-grouped.has-tooltip{ title: _('Promote to Group Milestone'), - disabled: true, - type: 'button', - data: { url: promote_project_milestone_path(milestone.project, milestone), - milestone_title: milestone.title, - group_name: @project.group.name, - target: '#promote-milestone-modal', - container: 'body', - toggle: 'modal' } } - = _('Promote') - - = link_to 'Close Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-sm btn-close btn-grouped" - - %button.js-delete-milestone-button.btn.btn-sm.btn-grouped.btn-danger{ data: { toggle: 'modal', - target: '#delete-milestone-modal', - milestone_id: milestone.id, - milestone_title: markdown_field(milestone, :title), - milestone_url: project_milestone_path(milestone.project, milestone), - milestone_issue_count: milestone.issues.count, - milestone_merge_request_count: milestone.merge_requests.count }, - disabled: true } - = _('Delete') - = icon('spin spinner', class: 'js-loading-icon hidden' ) + - if dashboard + .status-box.status-box-milestone + = milestone_type diff --git a/app/views/shared/tokens/_scopes_form.html.haml b/app/views/shared/tokens/_scopes_form.html.haml index e5c82962f82..dcb3fca23f2 100644 --- a/app/views/shared/tokens/_scopes_form.html.haml +++ b/app/views/shared/tokens/_scopes_form.html.haml @@ -3,7 +3,7 @@ - token = local_assigns.fetch(:token) - scopes.each do |scope| - %fieldset - = check_box_tag "#{prefix}[scopes][]", scope, token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}" - = label_tag ("#{prefix}_scopes_#{scope}"), scope, class: "label-light" - .scope-description= t scope, scope: [:doorkeeper, :scope_desc] + %fieldset.form-group.form-check + = check_box_tag "#{prefix}[scopes][]", scope, token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}", class: 'form-check-input' + = label_tag ("#{prefix}_scopes_#{scope}"), scope, class: 'label-light form-check-label' + .text-secondary= t scope, scope: [:doorkeeper, :scope_desc] diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 026f756582d..d06f51b1828 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -11,7 +11,7 @@ - cronjob:remove_old_web_hook_logs - cronjob:remove_unreferenced_lfs_objects - cronjob:repository_archive_cache -- cronjob:repository_check_batch +- cronjob:repository_check_dispatch - cronjob:requests_profiles - cronjob:schedule_update_user_activity - cronjob:stuck_ci_jobs @@ -71,6 +71,7 @@ - pipeline_processing:update_head_pipeline_for_merge_request - repository_check:repository_check_clear +- repository_check:repository_check_batch - repository_check:repository_check_single_repository - default diff --git a/app/workers/concerns/each_shard_worker.rb b/app/workers/concerns/each_shard_worker.rb new file mode 100644 index 00000000000..d0a728fb495 --- /dev/null +++ b/app/workers/concerns/each_shard_worker.rb @@ -0,0 +1,31 @@ +module EachShardWorker + extend ActiveSupport::Concern + include ::Gitlab::Utils::StrongMemoize + + def each_eligible_shard + Gitlab::ShardHealthCache.update(eligible_shard_names) + + eligible_shard_names.each do |shard_name| + yield shard_name + end + end + + # override when you want to filter out some shards + def eligible_shard_names + healthy_shard_names + end + + def healthy_shard_names + strong_memoize(:healthy_shard_names) do + healthy_ready_shards.map { |result| result.labels[:shard] } + end + end + + def healthy_ready_shards + ready_shards.select(&:success) + end + + def ready_shards + Gitlab::HealthChecks::GitalyCheck.readiness + end +end diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb index b0e1d8837d9..abe86066fb4 100644 --- a/app/workers/project_cache_worker.rb +++ b/app/workers/project_cache_worker.rb @@ -3,6 +3,7 @@ # Worker for updating any project specific caches. class ProjectCacheWorker include ApplicationWorker + include ExclusiveLeaseGuard LEASE_TIMEOUT = 15.minutes.to_i @@ -13,30 +14,30 @@ class ProjectCacheWorker # statistics - An Array containing columns from ProjectStatistics to # refresh, if empty all columns will be refreshed def perform(project_id, files = [], statistics = []) - project = Project.find_by(id: project_id) + @project = Project.find_by(id: project_id) + return unless @project&.repository&.exists? - return unless project && project.repository.exists? + update_statistics(statistics) - update_statistics(project, statistics.map(&:to_sym)) + @project.repository.refresh_method_caches(files.map(&:to_sym)) - project.repository.refresh_method_caches(files.map(&:to_sym)) - - project.cleanup + @project.cleanup end - def update_statistics(project, statistics = []) - return unless try_obtain_lease_for(project.id, :update_statistics) - - Rails.logger.info("Updating statistics for project #{project.id}") + private - project.statistics.refresh!(only: statistics) + def update_statistics(statistics = []) + try_obtain_lease do + Rails.logger.info("Updating statistics for project #{@project.id}") + @project.statistics.refresh!(only: statistics.to_a.map(&:to_sym)) + end end - private + def lease_timeout + LEASE_TIMEOUT + end - def try_obtain_lease_for(project_id, section) - Gitlab::ExclusiveLease - .new("project_cache_worker:#{project_id}:#{section}", timeout: LEASE_TIMEOUT) - .try_obtain + def lease_key + "project_cache_worker:#{@project.id}:update_statistics" end end diff --git a/app/workers/repository_check/batch_worker.rb b/app/workers/repository_check/batch_worker.rb index 898bca976ec..051382a08a9 100644 --- a/app/workers/repository_check/batch_worker.rb +++ b/app/workers/repository_check/batch_worker.rb @@ -3,13 +3,18 @@ module RepositoryCheck class BatchWorker include ApplicationWorker - include CronjobQueue + include RepositoryCheckQueue RUN_TIME = 3600 BATCH_SIZE = 10_000 - def perform + attr_reader :shard_name + + def perform(shard_name) + @shard_name = shard_name + return unless Gitlab::CurrentSettings.repository_checks_enabled + return unless Gitlab::ShardHealthCache.healthy_shard?(shard_name) start = Time.now @@ -39,18 +44,22 @@ module RepositoryCheck end def never_checked_project_ids(batch_size) - Project.where(last_repository_check_at: nil) + projects_on_shard.where(last_repository_check_at: nil) .where('created_at < ?', 24.hours.ago) .limit(batch_size).pluck(:id) end def old_checked_project_ids(batch_size) - Project.where.not(last_repository_check_at: nil) + projects_on_shard.where.not(last_repository_check_at: nil) .where('last_repository_check_at < ?', 1.month.ago) .reorder(last_repository_check_at: :asc) .limit(batch_size).pluck(:id) end + def projects_on_shard + Project.where(repository_storage: shard_name) + end + def try_obtain_lease(id) # Use a 24-hour timeout because on servers/projects where 'git fsck' is # super slow we definitely do not want to run it twice in parallel. diff --git a/app/workers/repository_check/dispatch_worker.rb b/app/workers/repository_check/dispatch_worker.rb new file mode 100644 index 00000000000..891a273afd7 --- /dev/null +++ b/app/workers/repository_check/dispatch_worker.rb @@ -0,0 +1,15 @@ +module RepositoryCheck + class DispatchWorker + include ApplicationWorker + include CronjobQueue + include ::EachShardWorker + + def perform + return unless Gitlab::CurrentSettings.repository_checks_enabled + + each_eligible_shard do |shard_name| + RepositoryCheck::BatchWorker.perform_async(shard_name) + end + end + end +end diff --git a/changelogs/unreleased/19439-api-file-sha56-and-head.yml b/changelogs/unreleased/19439-api-file-sha56-and-head.yml new file mode 100644 index 00000000000..4bc1e560631 --- /dev/null +++ b/changelogs/unreleased/19439-api-file-sha56-and-head.yml @@ -0,0 +1,5 @@ +--- +title: Add SHA256 and HEAD on File API +merge_request: 19439 +author: ahmet2mir +type: added diff --git a/changelogs/unreleased/37561-add-id-settings.yml b/changelogs/unreleased/37561-add-id-settings.yml new file mode 100644 index 00000000000..122ac23cb53 --- /dev/null +++ b/changelogs/unreleased/37561-add-id-settings.yml @@ -0,0 +1,5 @@ +--- +title: Allows settings sections to expand by default when linking to them +merge_request: 20211 +author: +type: other diff --git a/changelogs/unreleased/39543-milestone-page-list-redesign.yml b/changelogs/unreleased/39543-milestone-page-list-redesign.yml new file mode 100644 index 00000000000..dcd73c5eddf --- /dev/null +++ b/changelogs/unreleased/39543-milestone-page-list-redesign.yml @@ -0,0 +1,5 @@ +--- +title: Milestone page list redesign +merge_request: 19832 +author: Constance Okoghenun +type: changed diff --git a/changelogs/unreleased/39604-update-top-right-avatar-after-changing-avatar.yml b/changelogs/unreleased/39604-update-top-right-avatar-after-changing-avatar.yml new file mode 100644 index 00000000000..17192673996 --- /dev/null +++ b/changelogs/unreleased/39604-update-top-right-avatar-after-changing-avatar.yml @@ -0,0 +1,5 @@ +--- +title: Change avatar image in the header when user updates their avatar. +merge_request: 20119 +author: Jamie Schembri +type: added diff --git a/changelogs/unreleased/43270-import-with-milestones-failing.yml b/changelogs/unreleased/43270-import-with-milestones-failing.yml new file mode 100644 index 00000000000..13bf8072376 --- /dev/null +++ b/changelogs/unreleased/43270-import-with-milestones-failing.yml @@ -0,0 +1,5 @@ +--- +title: Fix label and milestone duplicated records and IID errors +merge_request: 19961 +author: +type: fixed diff --git a/changelogs/unreleased/43472-remove-environment-scope-field-on-cluster-creation-form-for-core-starter-plans.yml b/changelogs/unreleased/43472-remove-environment-scope-field-on-cluster-creation-form-for-core-starter-plans.yml new file mode 100644 index 00000000000..7d2804f0310 --- /dev/null +++ b/changelogs/unreleased/43472-remove-environment-scope-field-on-cluster-creation-form-for-core-starter-plans.yml @@ -0,0 +1,5 @@ +--- +title: Removes the environment scope field for users that cannot edit it +merge_request: 19643 +author: +type: changed diff --git a/changelogs/unreleased/44725-expire_correct_methods_after_change_head.yml b/changelogs/unreleased/44725-expire_correct_methods_after_change_head.yml new file mode 100644 index 00000000000..21a65f142c3 --- /dev/null +++ b/changelogs/unreleased/44725-expire_correct_methods_after_change_head.yml @@ -0,0 +1,5 @@ +--- +title: Expire correct method caches after HEAD changed +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/44726-cancel_lease_upon_completion_in_project_cache_worker.yml b/changelogs/unreleased/44726-cancel_lease_upon_completion_in_project_cache_worker.yml new file mode 100644 index 00000000000..bae6c2a8987 --- /dev/null +++ b/changelogs/unreleased/44726-cancel_lease_upon_completion_in_project_cache_worker.yml @@ -0,0 +1,5 @@ +--- +title: Cancel ExclusiveLease upon completion in ProjectCacheWorker +merge_request: 20103 +author: +type: fixed diff --git a/changelogs/unreleased/47462-issues-disabled-group-page.yml b/changelogs/unreleased/47462-issues-disabled-group-page.yml new file mode 100644 index 00000000000..c8cad608cb3 --- /dev/null +++ b/changelogs/unreleased/47462-issues-disabled-group-page.yml @@ -0,0 +1,6 @@ +--- +title: Only show new issue / new merge request on group page when issues / merge requests + are enabled +merge_request: 19869 +author: Jan Beckmann +type: fixed diff --git a/changelogs/unreleased/47516-pipe-scroll.yml b/changelogs/unreleased/47516-pipe-scroll.yml new file mode 100644 index 00000000000..3e283f649bd --- /dev/null +++ b/changelogs/unreleased/47516-pipe-scroll.yml @@ -0,0 +1,5 @@ +--- +title: Prevent pipeline job tooltip from scrolling off dropdown container +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/47661-merge-request-box-disappearing-on-chrome.yml b/changelogs/unreleased/47661-merge-request-box-disappearing-on-chrome.yml new file mode 100644 index 00000000000..7e6ab8d448b --- /dev/null +++ b/changelogs/unreleased/47661-merge-request-box-disappearing-on-chrome.yml @@ -0,0 +1,5 @@ +--- +title: Replace deprecated bs.affix in merge request tabs with sticky polyfill +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/47769-fix_ambiguous_due_date_for_issue_scopes.yml b/changelogs/unreleased/47769-fix_ambiguous_due_date_for_issue_scopes.yml new file mode 100644 index 00000000000..b8bb70b3266 --- /dev/null +++ b/changelogs/unreleased/47769-fix_ambiguous_due_date_for_issue_scopes.yml @@ -0,0 +1,5 @@ +--- +title: Fix ambiguous due_date column for Issue scopes +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/47865-changelog-for-style-updates.yml b/changelogs/unreleased/47865-changelog-for-style-updates.yml new file mode 100644 index 00000000000..2e4fbbda000 --- /dev/null +++ b/changelogs/unreleased/47865-changelog-for-style-updates.yml @@ -0,0 +1,5 @@ +--- +title: Minor style changes to personal access token form and scope checkboxes +merge_request: 20052 +author: +type: other diff --git a/changelogs/unreleased/48471-sidebar-on-jobs-and-wikis-is-missing-at-small-widths.yml b/changelogs/unreleased/48471-sidebar-on-jobs-and-wikis-is-missing-at-small-widths.yml new file mode 100644 index 00000000000..aaa816516c5 --- /dev/null +++ b/changelogs/unreleased/48471-sidebar-on-jobs-and-wikis-is-missing-at-small-widths.yml @@ -0,0 +1,5 @@ +--- +title: Fix sidebar collapse breapoints for job and wiki pages +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/48497-merge-request-refactor-displays-changes-dropdown-incorrectly.yml b/changelogs/unreleased/48497-merge-request-refactor-displays-changes-dropdown-incorrectly.yml new file mode 100644 index 00000000000..41af2f8cc4f --- /dev/null +++ b/changelogs/unreleased/48497-merge-request-refactor-displays-changes-dropdown-incorrectly.yml @@ -0,0 +1,5 @@ +--- +title: Fixed Merge request changes dropdown displays incorrectly +merge_request: 20237 +author: Constance Okoghenun +type: fixed diff --git a/changelogs/unreleased/48515-sql-queries-are-not-shown-from-the-performance-bar-in-safari.yml b/changelogs/unreleased/48515-sql-queries-are-not-shown-from-the-performance-bar-in-safari.yml new file mode 100644 index 00000000000..65c59dbf31f --- /dev/null +++ b/changelogs/unreleased/48515-sql-queries-are-not-shown-from-the-performance-bar-in-safari.yml @@ -0,0 +1,5 @@ +--- +title: Fix performance bar modal visibility in Safari +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/48549-markdown-header-code-does-not-have-the-correct-font-size.yml b/changelogs/unreleased/48549-markdown-header-code-does-not-have-the-correct-font-size.yml new file mode 100644 index 00000000000..f01f7f3db55 --- /dev/null +++ b/changelogs/unreleased/48549-markdown-header-code-does-not-have-the-correct-font-size.yml @@ -0,0 +1,5 @@ +--- +title: fix size of code blocks in headings +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/48603-merge-request-refactor-title-and-copy-to-clipboard-button-are-behind-the-action-buttons.yml b/changelogs/unreleased/48603-merge-request-refactor-title-and-copy-to-clipboard-button-are-behind-the-action-buttons.yml new file mode 100644 index 00000000000..792c7814f7e --- /dev/null +++ b/changelogs/unreleased/48603-merge-request-refactor-title-and-copy-to-clipboard-button-are-behind-the-action-buttons.yml @@ -0,0 +1,5 @@ +--- +title: Fix overlapping file title and file actions in MR changes tag +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/48653-mr-target-branch-missing.yml b/changelogs/unreleased/48653-mr-target-branch-missing.yml new file mode 100644 index 00000000000..c2b342b87d2 --- /dev/null +++ b/changelogs/unreleased/48653-mr-target-branch-missing.yml @@ -0,0 +1,5 @@ +--- +title: Fix merge request page rendering error when its target/source branch is missing +merge_request: 20280 +author: +type: fixed diff --git a/changelogs/unreleased/add-more-rebase-logging.yml b/changelogs/unreleased/add-more-rebase-logging.yml new file mode 100644 index 00000000000..a7d1c3aa664 --- /dev/null +++ b/changelogs/unreleased/add-more-rebase-logging.yml @@ -0,0 +1,5 @@ +--- +title: Add more detailed logging to githost.log when rebasing +merge_request: +author: +type: other diff --git a/changelogs/unreleased/backstage-gb-stages-position-migration-clean-up.yml b/changelogs/unreleased/backstage-gb-stages-position-migration-clean-up.yml new file mode 100644 index 00000000000..d2ada88870b --- /dev/null +++ b/changelogs/unreleased/backstage-gb-stages-position-migration-clean-up.yml @@ -0,0 +1,5 @@ +--- +title: Fully migrate pipeline stages position +merge_request: 19369 +author: +type: performance diff --git a/changelogs/unreleased/bvl-graphql-permissions.yml b/changelogs/unreleased/bvl-graphql-permissions.yml new file mode 100644 index 00000000000..42d5e24bb15 --- /dev/null +++ b/changelogs/unreleased/bvl-graphql-permissions.yml @@ -0,0 +1,5 @@ +--- +title: 'Expose permissions of the current user on resources in GraphQL' +merge_request: 20152 +author: +type: added diff --git a/changelogs/unreleased/bw-fix-ee-dashboard.yml b/changelogs/unreleased/bw-fix-ee-dashboard.yml new file mode 100644 index 00000000000..667181cdf73 --- /dev/null +++ b/changelogs/unreleased/bw-fix-ee-dashboard.yml @@ -0,0 +1,5 @@ +--- +title: Restore showing Elasticsearch and Geo status on dashboard +merge_request: 20276 +author: +type: fixed diff --git a/changelogs/unreleased/cr-add-locked-state-to-MR.yml b/changelogs/unreleased/cr-add-locked-state-to-MR.yml new file mode 100644 index 00000000000..f290ddc0b87 --- /dev/null +++ b/changelogs/unreleased/cr-add-locked-state-to-MR.yml @@ -0,0 +1,5 @@ +--- +title: Adds the `locked` state to the merge request API so that it can be used as a search filter. +merge_request: 20186 +author: +type: fixed diff --git a/changelogs/unreleased/cr-keep-issue-labels.yml b/changelogs/unreleased/cr-keep-issue-labels.yml new file mode 100644 index 00000000000..051e7faffea --- /dev/null +++ b/changelogs/unreleased/cr-keep-issue-labels.yml @@ -0,0 +1,5 @@ +--- +title: Keeps the label on an issue when the issue is moved. +merge_request: 20036 +author: +type: fixed diff --git a/changelogs/unreleased/db-configure-after-drop-tables.yml b/changelogs/unreleased/db-configure-after-drop-tables.yml new file mode 100644 index 00000000000..00844b334fa --- /dev/null +++ b/changelogs/unreleased/db-configure-after-drop-tables.yml @@ -0,0 +1,5 @@ +--- +title: Fixes an issue where migrations instead of schema loading were run +merge_request: 20227 +author: +type: changed diff --git a/changelogs/unreleased/dm-favicon-asset-host.yml b/changelogs/unreleased/dm-favicon-asset-host.yml new file mode 100644 index 00000000000..c2dc9d765e5 --- /dev/null +++ b/changelogs/unreleased/dm-favicon-asset-host.yml @@ -0,0 +1,6 @@ +--- +title: Always serve favicon from main GitLab domain so that CI badge can be drawn + over it +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/dm-user-without-projects-performance.yml b/changelogs/unreleased/dm-user-without-projects-performance.yml new file mode 100644 index 00000000000..e7fc0ae6d54 --- /dev/null +++ b/changelogs/unreleased/dm-user-without-projects-performance.yml @@ -0,0 +1,5 @@ +--- +title: Improve performance of listing users without projects +merge_request: +author: +type: performance diff --git a/changelogs/unreleased/feature-oidc-subject-claim.yml b/changelogs/unreleased/feature-oidc-subject-claim.yml new file mode 100644 index 00000000000..e995ca26234 --- /dev/null +++ b/changelogs/unreleased/feature-oidc-subject-claim.yml @@ -0,0 +1,5 @@ +--- +title: Don't hash user ID in OIDC subject claim +merge_request: 19784 +author: Markus Koller +type: changed diff --git a/changelogs/unreleased/fix-paragraph-line-height-for-emoji.yml b/changelogs/unreleased/fix-paragraph-line-height-for-emoji.yml new file mode 100644 index 00000000000..5aaf0fac60e --- /dev/null +++ b/changelogs/unreleased/fix-paragraph-line-height-for-emoji.yml @@ -0,0 +1,5 @@ +--- +title: Fix paragraph line height for emoji +merge_request: 20137 +author: George Tsiolis +type: fixed diff --git a/changelogs/unreleased/fix-tooltip-flicker.yml b/changelogs/unreleased/fix-tooltip-flicker.yml new file mode 100644 index 00000000000..c94723d83df --- /dev/null +++ b/changelogs/unreleased/fix-tooltip-flicker.yml @@ -0,0 +1,5 @@ +--- +title: Fix tooltip flickering bug +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/fj-46278-apply-doorkeeper-scope-patch.yml b/changelogs/unreleased/fj-46278-apply-doorkeeper-scope-patch.yml new file mode 100644 index 00000000000..1f4de2cb490 --- /dev/null +++ b/changelogs/unreleased/fj-46278-apply-doorkeeper-scope-patch.yml @@ -0,0 +1,5 @@ +--- +title: Fix OAuth Application Authorization screen to appear with each access +merge_request: 20216 +author: +type: fixed diff --git a/changelogs/unreleased/fj-46278-enable-doorkeeper-reuse-access-token.yml b/changelogs/unreleased/fj-46278-enable-doorkeeper-reuse-access-token.yml new file mode 100644 index 00000000000..0994f4de248 --- /dev/null +++ b/changelogs/unreleased/fj-46278-enable-doorkeeper-reuse-access-token.yml @@ -0,0 +1,6 @@ +--- +title: Enable Doorkeeper option to avoid generating new tokens when users login via + oauth +merge_request: 20200 +author: +type: fixed diff --git a/changelogs/unreleased/osw-delete-non-latest-mr-diff-files-migration.yml b/changelogs/unreleased/osw-delete-non-latest-mr-diff-files-migration.yml new file mode 100644 index 00000000000..e4cbae1a109 --- /dev/null +++ b/changelogs/unreleased/osw-delete-non-latest-mr-diff-files-migration.yml @@ -0,0 +1,5 @@ +--- +title: Schedule workers to delete non-latest diffs in post-migration +merge_request: +author: +type: other diff --git a/changelogs/unreleased/osw-mark-as-merged-as-first-post-merge-action.yml b/changelogs/unreleased/osw-mark-as-merged-as-first-post-merge-action.yml new file mode 100644 index 00000000000..2049afc3d44 --- /dev/null +++ b/changelogs/unreleased/osw-mark-as-merged-as-first-post-merge-action.yml @@ -0,0 +1,5 @@ +--- +title: Mark MR as merged regardless of errors when closing issues +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/rails5-fix-48430.yml b/changelogs/unreleased/rails5-fix-48430.yml new file mode 100644 index 00000000000..16495615395 --- /dev/null +++ b/changelogs/unreleased/rails5-fix-48430.yml @@ -0,0 +1,5 @@ +--- +title: Rails5 fix MySQL milliseconds problem in specs +merge_request: 20221 +author: Jasper Maes +type: fixed diff --git a/changelogs/unreleased/rails5-fix-48432.yml b/changelogs/unreleased/rails5-fix-48432.yml new file mode 100644 index 00000000000..732294447a9 --- /dev/null +++ b/changelogs/unreleased/rails5-fix-48432.yml @@ -0,0 +1,5 @@ +--- +title: Rails5 fix Mysql comparison failure caused by milliseconds problem +merge_request: 20222 +author: Jasper Maes +type: fixed diff --git a/changelogs/unreleased/revert-merge-request-discussion-buttons-padding.yml b/changelogs/unreleased/revert-merge-request-discussion-buttons-padding.yml new file mode 100644 index 00000000000..9f11dd3dc3f --- /dev/null +++ b/changelogs/unreleased/revert-merge-request-discussion-buttons-padding.yml @@ -0,0 +1,5 @@ +--- +title: Revert merge request discussion buttons padding +merge_request: 20060 +author: George Tsiolis +type: changed diff --git a/changelogs/unreleased/straight-comparision-mode.yml b/changelogs/unreleased/straight-comparision-mode.yml new file mode 100644 index 00000000000..2f6a0c0b54d --- /dev/null +++ b/changelogs/unreleased/straight-comparision-mode.yml @@ -0,0 +1,5 @@ +--- +title: Allow straight diff in Compare API +merge_request: 20120 +author: Maciej Nowak +type: added diff --git a/changelogs/unreleased/tc-repo-check-per-shard.yml b/changelogs/unreleased/tc-repo-check-per-shard.yml new file mode 100644 index 00000000000..227b6b0b93b --- /dev/null +++ b/changelogs/unreleased/tc-repo-check-per-shard.yml @@ -0,0 +1,5 @@ +--- +title: Run repository checks in parallel for each shard +merge_request: 20179 +author: +type: added diff --git a/changelogs/unreleased/transfer_project_api_endpoint.yml b/changelogs/unreleased/transfer_project_api_endpoint.yml new file mode 100644 index 00000000000..60c704c62a0 --- /dev/null +++ b/changelogs/unreleased/transfer_project_api_endpoint.yml @@ -0,0 +1,5 @@ +--- +title: Add transfer project API endpoint +merge_request: 20122 +author: Aram Visser +type: added diff --git a/changelogs/unreleased/update-bcrypt-to-support-libxcrypt.yml b/changelogs/unreleased/update-bcrypt-to-support-libxcrypt.yml new file mode 100644 index 00000000000..c18a0f75d22 --- /dev/null +++ b/changelogs/unreleased/update-bcrypt-to-support-libxcrypt.yml @@ -0,0 +1,5 @@ +--- +title: update bcrypt to also support libxcrypt +merge_request: 20260 +author: muhammadn +type: other diff --git a/changelogs/unreleased/update-environments-nav-controls.yml b/changelogs/unreleased/update-environments-nav-controls.yml new file mode 100644 index 00000000000..639dadd0cdf --- /dev/null +++ b/changelogs/unreleased/update-environments-nav-controls.yml @@ -0,0 +1,5 @@ +--- +title: Update environments nav controls icons +merge_request: 20199 +author: George Tsiolis +type: changed diff --git a/changelogs/unreleased/update-external-link-icon-in-merge-request-widget.yml b/changelogs/unreleased/update-external-link-icon-in-merge-request-widget.yml new file mode 100644 index 00000000000..c650c32f884 --- /dev/null +++ b/changelogs/unreleased/update-external-link-icon-in-merge-request-widget.yml @@ -0,0 +1,5 @@ +--- +title: Update external link icon in merge request widget +merge_request: 20154 +author: George Tsiolis +type: changed diff --git a/changelogs/unreleased/update-integrations-external-link-icons.yml b/changelogs/unreleased/update-integrations-external-link-icons.yml new file mode 100644 index 00000000000..9972744bd00 --- /dev/null +++ b/changelogs/unreleased/update-integrations-external-link-icons.yml @@ -0,0 +1,5 @@ +--- +title: Update integrations external link icons +merge_request: 20205 +author: George Tsiolis +type: changed diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 489dc8840e5..e0779112850 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -33,7 +33,7 @@ production: &base port: 80 # Set to 443 if using HTTPS, see installation.md#using-https for additional HTTPS configuration details https: false # Set to true if using HTTPS, see installation.md#using-https for additional HTTPS configuration details - # Uncommment this line below if your ssh host is different from HTTP/HTTPS one + # Uncomment this line below if your ssh host is different from HTTP/HTTPS one # (you'd obviously need to replace ssh.host_example.com with your own host). # Otherwise, ssh host will be set to the `host:` value above # ssh_host: ssh.host_example.com diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 837e577bc91..550647ae1c6 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -279,7 +279,7 @@ Settings.cron_jobs['expire_build_artifacts_worker']['cron'] ||= '50 * * * *' Settings.cron_jobs['expire_build_artifacts_worker']['job_class'] = 'ExpireBuildArtifactsWorker' Settings.cron_jobs['repository_check_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['repository_check_worker']['cron'] ||= '20 * * * *' -Settings.cron_jobs['repository_check_worker']['job_class'] = 'RepositoryCheck::BatchWorker' +Settings.cron_jobs['repository_check_worker']['job_class'] = 'RepositoryCheck::DispatchWorker' Settings.cron_jobs['admin_email_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['admin_email_worker']['cron'] ||= '0 0 * * 0' Settings.cron_jobs['admin_email_worker']['job_class'] = 'AdminEmailWorker' diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index e3a342590d4..f321b4ea763 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -37,7 +37,7 @@ Doorkeeper.configure do # Reuse access token for the same resource owner within an application (disabled by default) # Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/383 - # reuse_access_token + reuse_access_token # Issue access tokens with refresh token (disabled by default) use_refresh_token @@ -106,3 +106,53 @@ Doorkeeper.configure do base_controller '::Gitlab::BaseDoorkeeperController' end + +# Monkey patch to avoid creating new applications if the scope of the +# app created does not match the complete list of scopes of the configured app. +# It also prevents the OAuth authorize application window to appear every time. + +# Remove after we upgrade the doorkeeper gem from version 4.3.2 +if Doorkeeper.gem_version > Gem::Version.new('4.3.2') + raise "Doorkeeper was upgraded, please remove the monkey patch in #{__FILE__}" +end + +module Doorkeeper + module AccessTokenMixin + module ClassMethods + def matching_token_for(application, resource_owner_or_id, scopes) + resource_owner_id = + if resource_owner_or_id.respond_to?(:to_key) + resource_owner_or_id.id + else + resource_owner_or_id + end + + tokens = authorized_tokens_for(application.try(:id), resource_owner_id) + tokens.detect do |token| + scopes_match?(token.scopes, scopes, application.try(:scopes)) + end + end + + def scopes_match?(token_scopes, param_scopes, app_scopes) + return true if token_scopes.empty? && param_scopes.empty? + + (token_scopes.sort == param_scopes.sort) && + Doorkeeper::OAuth::Helpers::ScopeChecker.valid?( + param_scopes.to_s, + Doorkeeper.configuration.scopes, + app_scopes) + end + + def authorized_tokens_for(application_id, resource_owner_id) + ordered_by(:created_at, :desc) + .where(application_id: application_id, + resource_owner_id: resource_owner_id, + revoked_at: nil) + end + + def last_authorized_token_for(application_id, resource_owner_id) + authorized_tokens_for(application_id, resource_owner_id).first + end + end + end +end diff --git a/config/initializers/doorkeeper_openid_connect.rb b/config/initializers/doorkeeper_openid_connect.rb index 98e1f6e830f..ae5d834a02c 100644 --- a/config/initializers/doorkeeper_openid_connect.rb +++ b/config/initializers/doorkeeper_openid_connect.rb @@ -18,12 +18,17 @@ Doorkeeper::OpenidConnect.configure do end subject do |user| - # hash the user's ID with the Rails secret_key_base to avoid revealing it - Digest::SHA256.hexdigest "#{user.id}-#{Rails.application.secrets.secret_key_base}" + user.id end claims do with_options scope: :openid do |o| + o.claim(:sub_legacy, response: [:id_token, :user_info]) do |user| + # provide the previously hashed 'sub' claim to allow third-party apps + # to migrate to the new unhashed value + Digest::SHA256.hexdigest "#{user.id}-#{Rails.application.secrets.secret_key_base}" + end + o.claim(:name) { |user| user.name } o.claim(:nickname) { |user| user.username } o.claim(:email) { |user| user.public_email } diff --git a/db/post_migrate/20180604123514_cleanup_stages_position_migration.rb b/db/post_migrate/20180604123514_cleanup_stages_position_migration.rb new file mode 100644 index 00000000000..73c23dffca0 --- /dev/null +++ b/db/post_migrate/20180604123514_cleanup_stages_position_migration.rb @@ -0,0 +1,43 @@ +class CleanupStagesPositionMigration < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + TMP_INDEX_NAME = 'tmp_id_stage_position_partial_null_index'.freeze + + disable_ddl_transaction! + + class Stages < ActiveRecord::Base + include EachBatch + self.table_name = 'ci_stages' + end + + def up + disable_statement_timeout + + Gitlab::BackgroundMigration.steal('MigrateStageIndex') + + unless index_exists_by_name?(:ci_stages, TMP_INDEX_NAME) + add_concurrent_index(:ci_stages, :id, where: 'position IS NULL', name: TMP_INDEX_NAME) + end + + migratable = <<~SQL + position IS NULL AND EXISTS ( + SELECT 1 FROM ci_builds WHERE stage_id = ci_stages.id AND stage_idx IS NOT NULL + ) + SQL + + Stages.where(migratable).each_batch(of: 1000) do |batch| + batch.pluck(:id).each do |stage| + Gitlab::BackgroundMigration::MigrateStageIndex.new.perform(stage, stage) + end + end + + remove_concurrent_index_by_name(:ci_stages, TMP_INDEX_NAME) + end + + def down + if index_exists_by_name?(:ci_stages, TMP_INDEX_NAME) + remove_concurrent_index_by_name(:ci_stages, TMP_INDEX_NAME) + end + end +end diff --git a/db/post_migrate/20180619121030_enqueue_delete_diff_files_workers.rb b/db/post_migrate/20180619121030_enqueue_delete_diff_files_workers.rb new file mode 100644 index 00000000000..5fb3d545624 --- /dev/null +++ b/db/post_migrate/20180619121030_enqueue_delete_diff_files_workers.rb @@ -0,0 +1,70 @@ +class EnqueueDeleteDiffFilesWorkers < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + class MergeRequestDiff < ActiveRecord::Base + self.table_name = 'merge_request_diffs' + + belongs_to :merge_request + + include EachBatch + end + + DOWNTIME = false + BATCH_SIZE = 1000 + MIGRATION = 'DeleteDiffFiles' + DELAY_INTERVAL = 8.minutes + TMP_INDEX = 'tmp_partial_diff_id_with_files_index'.freeze + + disable_ddl_transaction! + + def up + # We add temporary index, to make iteration over batches more performant. + # Conditional here is to avoid the need of doing that in a separate + # migration file to make this operation idempotent. + # + unless index_exists_by_name?(:merge_request_diffs, TMP_INDEX) + add_concurrent_index(:merge_request_diffs, :id, where: "(state NOT IN ('without_files', 'empty'))", name: TMP_INDEX) + end + + + diffs_with_files = MergeRequestDiff.where.not(state: ['without_files', 'empty']) + + # explain (analyze, buffers) example for the iteration: + # + # Index Only Scan using tmp_index_20013 on merge_request_diffs (cost=0.43..1630.19 rows=60567 width=4) (actual time=0.047..9.572 rows=56976 loops=1) + # Index Cond: ((id >= 764586) AND (id < 835298)) + # Heap Fetches: 8 + # Buffers: shared hit=18188 + # Planning time: 0.752 ms + # Execution time: 12.430 ms + # + diffs_with_files.each_batch(of: BATCH_SIZE) do |relation, outer_index| + ids = relation.pluck(:id) + + ids.each_with_index do |diff_id, inner_index| + # This will give some space between batches of workers. + interval = DELAY_INTERVAL * outer_index + inner_index.minutes + + # A single `merge_request_diff` can be associated with way too many + # `merge_request_diff_files`. It's better to avoid batching these and + # schedule one at a time. + # + # Considering roughly 6M jobs, this should take ~30 days to process all + # of them. + # + BackgroundMigrationWorker.perform_in(interval, MIGRATION, [diff_id]) + end + end + + # We remove temporary index, because it is not required during standard + # operations and runtime. + # + remove_concurrent_index_by_name(:merge_request_diffs, TMP_INDEX) + end + + def down + if index_exists_by_name?(:merge_request_diffs, TMP_INDEX) + remove_concurrent_index_by_name(:merge_request_diffs, TMP_INDEX) + end + end +end diff --git a/doc/administration/job_artifacts.md b/doc/administration/job_artifacts.md index e59ab5a72e1..1bb018e368b 100644 --- a/doc/administration/job_artifacts.md +++ b/doc/administration/job_artifacts.md @@ -123,6 +123,7 @@ The connection settings match those provided by [Fog](https://github.com/fog), a | `provider` | Always `AWS` for compatible hosts | AWS | | `aws_access_key_id` | AWS credentials, or compatible | | | `aws_secret_access_key` | AWS credentials, or compatible | | +| `aws_signature_version` | AWS signature version to use. 2 or 4 are valid options. Digital Ocean Spaces and other providers may need 2. | 4 | | `region` | AWS region | us-east-1 | | `host` | S3 compatible host for when not using AWS, e.g. `localhost` or `storage.example.com` | s3.amazonaws.com | | `endpoint` | Can be used when configuring an S3 compatible service such as [Minio](https://www.minio.io), by entering a URL such as `http://127.0.0.1:9000` | (optional) | diff --git a/doc/administration/uploads.md b/doc/administration/uploads.md index 7f0bd8f04e3..6688181c5a8 100644 --- a/doc/administration/uploads.md +++ b/doc/administration/uploads.md @@ -79,6 +79,7 @@ The connection settings match those provided by [Fog](https://github.com/fog), a | `provider` | Always `AWS` for compatible hosts | AWS | | `aws_access_key_id` | AWS credentials, or compatible | | | `aws_secret_access_key` | AWS credentials, or compatible | | +| `aws_signature_version` | AWS signature version to use. 2 or 4 are valid options. Digital Ocean Spaces and other providers may need 2. | 4 | | `region` | AWS region | us-east-1 | | `host` | S3 compatible host for when not using AWS, e.g. `localhost` or `storage.example.com` | s3.amazonaws.com | | `endpoint` | Can be used when configuring an S3 compatible service such as [Minio](https://www.minio.io), by entering a URL such as `http://127.0.0.1:9000` | (optional) | diff --git a/doc/api/graphql/index.md b/doc/api/graphql/index.md index 59e27922f64..71922318227 100644 --- a/doc/api/graphql/index.md +++ b/doc/api/graphql/index.md @@ -1,4 +1,4 @@ -# GraphQL API (Beta) +# GraphQL API (Alpha) > [Introduced][ce-19008] in GitLab 11.0. diff --git a/doc/api/groups.md b/doc/api/groups.md index a48905f2f15..53d72509423 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -147,6 +147,8 @@ Parameters: | `simple` | boolean | no | Return only the ID, URL, name, and path of each project | | `owned` | boolean | no | Limit by projects owned by the current user | | `starred` | boolean | no | Limit by projects starred by the current user | +| `with_issues_enabled` | boolean | no | Limit by enabled issues feature | +| `with_merge_requests_enabled` | boolean | no | Limit by enabled merge requests feature | | `with_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (admins only) | Example response: diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index da74045b702..2057ed3588a 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -11,7 +11,7 @@ default it returns only merge requests created by the current user. To get all merge requests, use parameter `scope=all`. The `state` parameter can be used to get only merge requests with a -given state (`opened`, `closed`, or `merged`) or all of them (`all`). +given state (`opened`, `closed`, `locked`, or `merged`) or all of them (`all`). It should be noted that when searching by `locked` it will mostly return no results as it is a short-lived, transitional state. The pagination parameters `page` and `per_page` can be used to restrict the list of merge requests. @@ -35,7 +35,7 @@ Parameters: | Attribute | Type | Required | Description | | ------------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------- | -| `state` | string | no | Return all merge requests or just those that are `opened`, `closed`, or `merged` | +| `state` | string | no | Return all merge requests or just those that are `opened`, `closed`, `locked`, or `merged` | | `order_by` | string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` | | `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` | | `milestone` | string | no | Return merge requests for a specific milestone | @@ -122,7 +122,7 @@ Parameters: ## List project merge requests Get all merge requests for this project. -The `state` parameter can be used to get only merge requests with a given state (`opened`, `closed`, or `merged`) or all of them (`all`). +The `state` parameter can be used to get only merge requests with a given state (`opened`, `closed`, `locked`, or `merged`) or all of them (`all`). The pagination parameters `page` and `per_page` can be used to restrict the list of merge requests. ``` @@ -155,7 +155,7 @@ Parameters: | ------------------- | -------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ | | `id` | integer | yes | The ID of a project | | `iids[]` | Array[integer] | no | Return the request having the given `iid` | -| `state` | string | no | Return all merge requests or just those that are `opened`, `closed`, or `merged` | +| `state` | string | no | Return all merge requests or just those that are `opened`, `closed`, `locked`, or `merged` | | `order_by` | string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` | | `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` | | `milestone` | string | no | Return merge requests for a specific milestone | @@ -243,7 +243,7 @@ Parameters: ## List group merge requests Get all merge requests for this group and its subgroups. -The `state` parameter can be used to get only merge requests with a given state (`opened`, `closed`, or `merged`) or all of them (`all`). +The `state` parameter can be used to get only merge requests with a given state (`opened`, `closed`, `locked`, or `merged`) or all of them (`all`). The pagination parameters `page` and `per_page` can be used to restrict the list of merge requests. ``` @@ -262,7 +262,7 @@ Parameters: | Attribute | Type | Required | Description | | ------------------- | -------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ | | `id` | integer | yes | The ID of a group | -| `state` | string | no | Return all merge requests or just those that are `opened`, `closed`, or `merged` | +| `state` | string | no | Return all merge requests or just those that are `opened`, `closed`, `locked`, or `merged` | | `order_by` | string | no | Return merge requests ordered by `created_at` or `updated_at` fields. Default is `created_at` | | `sort` | string | no | Return merge requests sorted in `asc` or `desc` order. Default is `desc` | | `milestone` | string | no | Return merge requests for a specific milestone | diff --git a/doc/api/projects.md b/doc/api/projects.md index 30a41839f28..b4599fdc97e 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -1390,6 +1390,16 @@ POST /projects/:id/housekeeping | --------- | ---- | -------- | ----------- | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +### Transfer a project to a new namespace + +``` +PUT /projects/:id/transfer +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `namespace` | integer/string | yes | The ID or path of the namespace to transfer to project to | + ## Branches Read more in the [Branches](branches.md) documentation. diff --git a/doc/api/repositories.md b/doc/api/repositories.md index 5aff255c20a..cb816bbd712 100644 --- a/doc/api/repositories.md +++ b/doc/api/repositories.md @@ -130,6 +130,7 @@ Parameters: - `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user - `from` (required) - the commit SHA or branch name - `to` (required) - the commit SHA or branch name +- `straight` (optional) - comparison method, `true` for direct comparison between `from` and `to` (`from`..`to`), `false` to compare using merge base (`from`...`to`)'. Default is `false`. ``` GET /projects/:id/repository/compare?from=master&to=feature diff --git a/doc/api/repository_files.md b/doc/api/repository_files.md index c29dc22e12d..49fb9bc141d 100644 --- a/doc/api/repository_files.md +++ b/doc/api/repository_files.md @@ -27,6 +27,7 @@ Example response: "size": 1476, "encoding": "base64", "content": "IyA9PSBTY2hlbWEgSW5mb3...", + "content_sha256": "4c294617b60715c1d218e61164a3abd4808a4284cbc30e6728a01ad9aada4481", "ref": "master", "blob_id": "79f7bbd25901e8334750839545a9bd021f0e4c83", "commit_id": "d5a3ff139356ce33e37e73add446f16869741b50", @@ -39,6 +40,36 @@ Parameters: - `file_path` (required) - Url encoded full path to new file. Ex. lib%2Fclass%2Erb - `ref` (required) - The name of branch, tag or commit +NOTE: **Note:** +`blob_id` is the blob sha, see [repositories - Get a blob from repository](repositories.md#get-a-blob-from-repository) + +In addition to the `GET` method, you can also use `HEAD` to get just file metadata. + +``` +HEAD /projects/:id/repository/files/:file_path +``` + +```bash +curl --head --header 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' 'https://gitlab.example.com/api/v4/projects/13083/repository/files/app%2Fmodels%2Fkey%2Erb?ref=master' +``` + +Example response: + +```text +HTTP/1.1 200 OK +... +X-Gitlab-Blob-Id: 79f7bbd25901e8334750839545a9bd021f0e4c83 +X-Gitlab-Commit-Id: d5a3ff139356ce33e37e73add446f16869741b50 +X-Gitlab-Content-Sha256: 4c294617b60715c1d218e61164a3abd4808a4284cbc30e6728a01ad9aada4481 +X-Gitlab-Encoding: base64 +X-Gitlab-File-Name: key.rb +X-Gitlab-File-Path: app/models/key.rb +X-Gitlab-Last-Commit-Id: 570e7b2abdd848b95f2f578043fc23bd6f6fd24d +X-Gitlab-Ref: master +X-Gitlab-Size: 1476 +... +``` + ## Get raw file from repository ``` @@ -54,6 +85,9 @@ Parameters: - `file_path` (required) - Url encoded full path to new file. Ex. lib%2Fclass%2Erb - `ref` (required) - The name of branch, tag or commit +NOTE: **Note:** +Like [Get file from repository](repository_files.md#get-file-from-repository) you can use `HEAD` to get just file metadata. + ## Create new file in repository ``` diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index 07b144f6ddd..fbac37e688e 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -134,9 +134,20 @@ In order to do that, follow the steps: ```yaml image: docker:stable - # When using dind, it's wise to use the overlayfs driver for - # improved performance. variables: + # When using dind service we need to instruct docker, to talk with the + # daemon started inside of the service. The daemon is available with + # a network connection instead of the default /var/run/docker.sock socket. + # + # The 'docker' hostname is the alias of the service container as described at + # https://docs.gitlab.com/ee/ci/docker/using_docker_images.html#accessing-the-services + # + # Note that if you're using Kubernetes executor, the variable should be set to + # tcp://localhost:2375 because of how Kubernetes executor connects services + # to the job container + DOCKER_HOST: tcp://docker:2375/ + # When using dind, it's wise to use the overlayfs driver for + # improved performance. DOCKER_DRIVER: overlay2 services: @@ -293,6 +304,7 @@ services: variables: CONTAINER_IMAGE: registry.gitlab.com/$CI_PROJECT_PATH + DOCKER_HOST: tcp://docker:2375 DOCKER_DRIVER: overlay2 before_script: @@ -391,6 +403,9 @@ could look like: image: docker:stable services: - docker:dind + variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_DRIVER: overlay2 stage: build script: - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN registry.example.com @@ -410,6 +425,8 @@ services: - docker:dind variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_DRIVER: overlay2 IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME before_script: @@ -445,6 +462,8 @@ stages: - deploy variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_DRIVER: overlay2 CONTAINER_TEST_IMAGE: registry.example.com/my-group/my-project/my-image:$CI_COMMIT_REF_NAME CONTAINER_RELEASE_IMAGE: registry.example.com/my-group/my-project/my-image:latest diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 26dcf67fe23..096b64eb881 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -942,6 +942,11 @@ useful when you'd like to download the archive from GitLab. The `artifacts:name` variable can make use of any of the [predefined variables](../variables/README.md). The default name is `artifacts`, which becomes `artifacts.zip` when downloaded. +NOTE: **Note:** +If your branch-name contains forward slashes +(e.g. `feature/my-feature`) it is advised to use `$CI_COMMIT_REF_SLUG` +instead of `$CI_COMMIT_REF_NAME` for proper naming of the artifact. + To create an archive with a name of the current job: ```yaml diff --git a/doc/development/api_graphql_styleguide.md b/doc/development/api_graphql_styleguide.md index f74e4f0bd7e..33f078b0a63 100644 --- a/doc/development/api_graphql_styleguide.md +++ b/doc/development/api_graphql_styleguide.md @@ -54,6 +54,51 @@ a new presenter specifically for GraphQL. The presenter is initialized using the object resolved by a field, and the context. +### Exposing permissions for a type + +To expose permissions the current user has on a resource, you can call +the `expose_permissions` passing in a separate type representing the +permissions for the resource. + +For example: + +```ruby +module Types + class MergeRequestType < BaseObject + expose_permissions Types::MergeRequestPermissionsType + end +end +``` + +The permission type inherits from `BasePermissionType` which includes +some helper methods, that allow exposing permissions as non-nullable +booleans: + +```ruby +class MergeRequestPermissionsType < BasePermissionType + present_using MergeRequestPresenter + + graphql_name 'MergeRequestPermissions' + + abilities :admin_merge_request, :update_merge_request, :create_note + + ability_field :resolve_note, + description: 'Whether or not the user can resolve disussions on the merge request' + permission_field :push_to_source_branch, method: :can_push_to_source_branch? +end +``` + +- **`permission_field`**: Will act the same as `graphql-ruby`'s + `field` method but setting a default description and type and making + them non-nullable. These options can still be overridden by adding + them as arguments. +- **`ability_field`**: Expose an ability defined in our policies. This + takes behaves the same way as `permission_field` and the same + arguments can be overridden. +- **`abilities`**: Allows exposing several abilities defined in our + policies at once. The fields for these will all have be non-nullable + booleans with a default description. + ## Resolvers To find objects to display in a field, we can add resolvers to diff --git a/doc/development/ee_features.md b/doc/development/ee_features.md index 7f061d06da8..32de741c9fe 100644 --- a/doc/development/ee_features.md +++ b/doc/development/ee_features.md @@ -5,7 +5,7 @@ - **Write documentation.**: Add documentation to the `doc/` directory. Describe the feature and include screenshots, if applicable. - **Submit a MR to the `www-gitlab-com` project.**: Add the new feature to the - [EE features list][ee-features-list]. + [EE features list](https://about.gitlab.com/features/). ## Act as CE when unlicensed diff --git a/doc/development/i18n/externalization.md b/doc/development/i18n/externalization.md index 4ba9958e2c6..f7d703b8f0b 100644 --- a/doc/development/i18n/externalization.md +++ b/doc/development/i18n/externalization.md @@ -174,6 +174,8 @@ For example use `%{created_at}` in Ruby but `%{createdAt}` in JavaScript. # => When size == 2: 'There are 2 mice.' ``` + Avoid using `%d` or count variables in sigular strings. This allows more natural translation in some languages. + - In JavaScript: ```js diff --git a/doc/install/installation.md b/doc/install/installation.md index e4011b1a4ab..259d8f73a22 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -12,7 +12,7 @@ Since installations from source don't have Runit, Sidekiq can't be terminated an ## Select Version to Install -Make sure you view [this installation guide](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/install/installation.md) from the branch (version) of GitLab you would like to install (e.g., `11-0-stable`). +Make sure you view [this installation guide](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/install/installation.md) from the branch (version) of GitLab you would like to install (e.g., `11-1-stable`). You can select the branch in the version dropdown in the top left corner of GitLab (below the menu bar). If the highest number stable branch is unclear please check the [GitLab Blog](https://about.gitlab.com/blog/) for installation guide links by version. @@ -300,9 +300,9 @@ sudo usermod -aG redis git ### Clone the Source # Clone GitLab repository - sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 11-0-stable gitlab + sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 11-1-stable gitlab -**Note:** You can change `11-0-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server! +**Note:** You can change `11-1-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server! ### Configure It diff --git a/doc/install/requirements.md b/doc/install/requirements.md index 1f399a8a3f7..5531dcde4e9 100644 --- a/doc/install/requirements.md +++ b/doc/install/requirements.md @@ -64,16 +64,14 @@ If you have enough RAM memory and a recent CPU the speed of GitLab is mainly lim ### Memory -You need at least 4GB of addressable memory (RAM + swap) to install and use GitLab! +You need at least 8GB of addressable memory (RAM + swap) to install and use GitLab! The operating system and any other running applications will also be using memory so keep in mind that you need at least 4GB available before running GitLab. With less memory GitLab will give strange errors during the reconfigure run and 500 errors during usage. -- 1GB RAM + 3GB of swap is the absolute minimum but we strongly **advise against** this amount of memory. See the [unicorn worker section below](#unicorn-workers) for more advice. -- 2GB RAM + 2GB swap supports up to 100 users but it will be very slow -- **4GB RAM** is the **recommended** memory size for all installations and supports up to 100 users -- 8GB RAM supports up to 1,000 users +- 4GB RAM + 4GB swap supports up to 100 users but it will be very slow +- **8GB RAM** is the **recommended** memory size for all installations and supports up to 100 users - 16GB RAM supports up to 2,000 users - 32GB RAM supports up to 4,000 users - 64GB RAM supports up to 8,000 users diff --git a/doc/integration/openid_connect_provider.md b/doc/integration/openid_connect_provider.md index ad41be52045..a7f907254a1 100644 --- a/doc/integration/openid_connect_provider.md +++ b/doc/integration/openid_connect_provider.md @@ -5,11 +5,11 @@ to sign in to other services. ## Introduction to OpenID Connect -[OpenID Connect] \(OIC) is a simple identity layer on top of the +[OpenID Connect] \(OIDC) is a simple identity layer on top of the OAuth 2.0 protocol. It allows clients to verify the identity of the end-user based on the authentication performed by GitLab, as well as to obtain basic profile information about the end-user in an interoperable and -REST-like manner. OIC performs many of the same tasks as OpenID 2.0, +REST-like manner. OIDC performs many of the same tasks as OpenID 2.0, but does so in a way that is API-friendly, and usable by native and mobile applications. @@ -23,14 +23,17 @@ are supported. ## Enabling OpenID Connect for OAuth applications Refer to the [OAuth guide] for basic information on how to set up OAuth -applications in GitLab. To enable OIC for an application, all you have to do +applications in GitLab. To enable OIDC for an application, all you have to do is select the `openid` scope in the application settings. +## Shared information + Currently the following user information is shared with clients: | Claim | Type | Description | |:-----------------|:----------|:------------| -| `sub` | `string` | An opaque token that uniquely identifies the user +| `sub` | `string` | The ID of the user +| `sub_legacy` | `string` | An opaque token that uniquely identifies the user<br><br>**Deprecation notice:** this token isn't stable because it's tied to the Rails secret key base, and is provided only for migration to the new stable `sub` value available from GitLab 11.1 | `auth_time` | `integer` | The timestamp for the user's last authentication | `name` | `string` | The user's full name | `nickname` | `string` | The user's GitLab username @@ -41,6 +44,8 @@ Currently the following user information is shared with clients: | `picture` | `string` | URL for the user's GitLab avatar | `groups` | `array` | Names of the groups the user is a member of +Only the `sub` and `sub_legacy` claims are included in the ID token, all other claims are available from the `/oauth/userinfo` endpoint used by OIDC clients. + [OpenID Connect]: http://openid.net/connect/ "OpenID Connect website" [doorkeeper-openid_connect]: https://github.com/doorkeeper-gem/doorkeeper-openid_connect "Doorkeeper::OpenidConnect website" [OAuth guide]: oauth_provider.md "GitLab as OAuth2 authentication service provider" diff --git a/doc/raketasks/backup_restore.md b/doc/raketasks/backup_restore.md index 95221d8b6b1..f1881e0f767 100644 --- a/doc/raketasks/backup_restore.md +++ b/doc/raketasks/backup_restore.md @@ -195,6 +195,12 @@ This example can be used for a bucket in Amsterdam (AMS3). 1. [Reconfigure GitLab] for the changes to take effect +CAUTION: **Warning:** +If you see `400 Bad Request` by using Digital Ocean Spaces, the cause may be the +usage of backup encryption. Remove or comment the line that +contains `gitlab_rails['backup_encryption']` since Digital Ocean Spaces +doesn't support encryption. + #### Other S3 Providers Not all S3 providers are fully-compatible with the Fog library. For example, @@ -326,6 +332,16 @@ For installations from source: 1. [Restart GitLab] for the changes to take effect +#### Specifying a custom directory for backups + +Note: This option only works for remote storage. If you want to group your backups +you can pass a `DIRECTORY` environment variable: + +``` +sudo gitlab-rake gitlab:backup:create DIRECTORY=daily +sudo gitlab-rake gitlab:backup:create DIRECTORY=weekly +``` + ### Uploading to locally mounted shares You may also send backups to a mounted share (`NFS` / `CIFS` / `SMB` / etc.) by @@ -369,15 +385,6 @@ For installations from source: remote_directory: 'gitlab_backups' ``` -### Specifying a custom directory for backups - -If you want to group your backups you can pass a `DIRECTORY` environment variable: - -``` -sudo gitlab-rake gitlab:backup:create DIRECTORY=daily -sudo gitlab-rake gitlab:backup:create DIRECTORY=weekly -``` - ### Backup archive permissions The backup archives created by GitLab (`1393513186_2014_02_27_gitlab_backup.tar`) diff --git a/doc/update/11.0-to-11.1.md b/doc/update/11.0-to-11.1.md new file mode 100644 index 00000000000..306bd417ebf --- /dev/null +++ b/doc/update/11.0-to-11.1.md @@ -0,0 +1,361 @@ +--- +comments: false +--- + +# From 11.0 to 11.1 + +Make sure you view this update guide from the branch (version) of GitLab you would +like to install (e.g., `11-1-stable`. You can select the branch in the version +dropdown at the top left corner of GitLab (below the menu bar). + +If the highest number stable branch is unclear please check the +[GitLab Blog](https://about.gitlab.com/blog/archives.html) for installation +guide links by version. + +### 1. Stop server + +```bash +sudo service gitlab stop +``` + +### 2. Backup + +NOTE: If you installed GitLab from source, make sure `rsync` is installed. + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production +``` + +### 3. Update Ruby + +NOTE: GitLab 11.0 and higher only support Ruby 2.4.x and dropped support for Ruby 2.3.x. Be +sure to upgrade your interpreter if necessary. + +You can check which version you are running with `ruby -v`. + +Download Ruby and compile it: + +```bash +mkdir /tmp/ruby && cd /tmp/ruby +curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.4/ruby-2.4.4.tar.gz +echo 'ec82b0d53bd0adad9b19e6b45e44d54e9ec3f10c ruby-2.4.4.tar.gz' | shasum -c - && tar xzf ruby-2.4.4.tar.gz +cd ruby-2.4.4 + +./configure --disable-install-rdoc +make +sudo make install +``` + +Install Bundler: + +```bash +sudo gem install bundler --no-ri --no-rdoc +``` + +### 4. Update Node + +GitLab utilizes [webpack](http://webpack.js.org) to compile frontend assets. +This requires a minimum version of node v6.0.0. + +You can check which version you are running with `node -v`. If you are running +a version older than `v6.0.0` you will need to update to a newer version. You +can find instructions to install from community maintained packages or compile +from source at the nodejs.org website. + +<https://nodejs.org/en/download/> + +GitLab also requires the use of yarn `>= v1.2.0` to manage JavaScript +dependencies. + +```bash +curl --silent --show-error https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add - +echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list +sudo apt-get update +sudo apt-get install yarn +``` + +More information can be found on the [yarn website](https://yarnpkg.com/en/docs/install). + +### 5. Update Go + +NOTE: GitLab 11.0 and higher only supports Go 1.9.x and newer, and dropped support for Go +1.5.x through 1.8.x. Be sure to upgrade your installation if necessary. + +You can check which version you are running with `go version`. + +Download and install Go: + +```bash +# Remove former Go installation folder +sudo rm -rf /usr/local/go + +curl --remote-name --progress https://dl.google.com/go/go1.10.3.linux-amd64.tar.gz +echo 'fa1b0e45d3b647c252f51f5e1204aba049cde4af177ef9f2181f43004f901035 go1.10.3.linux-amd64.tar.gz' | shasum -a256 -c - && \ + sudo tar -C /usr/local -xzf go1.10.3.linux-amd64.tar.gz +sudo ln -sf /usr/local/go/bin/{go,godoc,gofmt} /usr/local/bin/ +rm go1.10.3.linux-amd64.tar.gz +``` + +### 6. Get latest code + +```bash +cd /home/git/gitlab + +sudo -u git -H git fetch --all --prune +sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically +sudo -u git -H git checkout -- locale +``` + +For GitLab Community Edition: + +```bash +cd /home/git/gitlab + +sudo -u git -H git checkout 11-1-stable +``` + +OR + +For GitLab Enterprise Edition: + +```bash +cd /home/git/gitlab + +sudo -u git -H git checkout 11-1-stable-ee +``` + +### 7. Update gitlab-shell + +```bash +cd /home/git/gitlab-shell + +sudo -u git -H git fetch --all --tags --prune +sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_SHELL_VERSION) +sudo -u git -H bin/compile +``` + +### 8. Update gitlab-workhorse + +Install and compile gitlab-workhorse. GitLab-Workhorse uses +[GNU Make](https://www.gnu.org/software/make/). +If you are not using Linux you may have to run `gmake` instead of +`make` below. + +```bash +cd /home/git/gitlab-workhorse + +sudo -u git -H git fetch --all --tags --prune +sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_WORKHORSE_VERSION) +sudo -u git -H make +``` + +### 9. Update Gitaly + +#### New Gitaly configuration options required + +In order to function Gitaly needs some additional configuration information. Below we assume you installed Gitaly in `/home/git/gitaly` and GitLab Shell in `/home/git/gitlab-shell`. + +```shell +echo ' +[gitaly-ruby] +dir = "/home/git/gitaly/ruby" + +[gitlab-shell] +dir = "/home/git/gitlab-shell" +' | sudo -u git tee -a /home/git/gitaly/config.toml +``` + +#### Check Gitaly configuration + +Due to a bug in the `rake gitlab:gitaly:install` script your Gitaly +configuration file may contain syntax errors. The block name +`[[storages]]`, which may occur more than once in your `config.toml` +file, should be `[[storage]]` instead. + +```shell +sudo -u git -H sed -i.pre-10.1 's/\[\[storages\]\]/[[storage]]/' /home/git/gitaly/config.toml +``` + +#### Compile Gitaly + +```shell +cd /home/git/gitaly +sudo -u git -H git fetch --all --tags --prune +sudo -u git -H git checkout v$(</home/git/gitlab/GITALY_SERVER_VERSION) +sudo -u git -H make +``` + +### 10. Update MySQL permissions + +If you are using MySQL you need to grant the GitLab user the necessary +permissions on the database: + +```bash +mysql -u root -p -e "GRANT TRIGGER ON \`gitlabhq_production\`.* TO 'git'@'localhost';" +``` + +If you use MySQL with replication, or just have MySQL configured with binary logging, +you will need to also run the following on all of your MySQL servers: + +```bash +mysql -u root -p -e "SET GLOBAL log_bin_trust_function_creators = 1;" +``` + +You can make this setting permanent by adding it to your `my.cnf`: + +``` +log_bin_trust_function_creators=1 +``` + +### 11. Update configuration files + +#### New configuration options for `gitlab.yml` + +There might be configuration options available for [`gitlab.yml`][yaml]. View them with the command below and apply them manually to your current `gitlab.yml`: + +```sh +cd /home/git/gitlab + +git diff origin/11-0-stable:config/gitlab.yml.example origin/11-1-stable:config/gitlab.yml.example +``` + +#### Nginx configuration + +Ensure you're still up-to-date with the latest NGINX configuration changes: + +```sh +cd /home/git/gitlab + +# For HTTPS configurations +git diff origin/11-0-stable:lib/support/nginx/gitlab-ssl origin/11-1-stable:lib/support/nginx/gitlab-ssl + +# For HTTP configurations +git diff origin/11-0-stable:lib/support/nginx/gitlab origin/11-1-stable:lib/support/nginx/gitlab +``` + +If you are using Strict-Transport-Security in your installation to continue using it you must enable it in your Nginx +configuration as GitLab application no longer handles setting it. + +If you are using Apache instead of NGINX please see the updated [Apache templates]. +Also note that because Apache does not support upstreams behind Unix sockets you +will need to let gitlab-workhorse listen on a TCP port. You can do this +via [/etc/default/gitlab]. + +[Apache templates]: https://gitlab.com/gitlab-org/gitlab-recipes/tree/master/web-server/apache +[/etc/default/gitlab]: https://gitlab.com/gitlab-org/gitlab-ce/blob/11-1-stable/lib/support/init.d/gitlab.default.example#L38 + +#### SMTP configuration + +If you're installing from source and use SMTP to deliver mail, you will need to add the following line +to config/initializers/smtp_settings.rb: + +```ruby +ActionMailer::Base.delivery_method = :smtp +``` + +See [smtp_settings.rb.sample] as an example. + +[smtp_settings.rb.sample]: https://gitlab.com/gitlab-org/gitlab-ce/blob/11-1-stable/config/initializers/smtp_settings.rb.sample#L13 + +#### Init script + +There might be new configuration options available for [`gitlab.default.example`][gl-example]. View them with the command below and apply them manually to your current `/etc/default/gitlab`: + +```sh +cd /home/git/gitlab + +git diff origin/11-0-stable:lib/support/init.d/gitlab.default.example origin/11-1-stable:lib/support/init.d/gitlab.default.example +``` + +Ensure you're still up-to-date with the latest init script changes: + +```bash +cd /home/git/gitlab + +sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab +``` + +For Ubuntu 16.04.1 LTS: + +```bash +sudo systemctl daemon-reload +``` + +### 12. Install libs, migrations, etc. + +```bash +cd /home/git/gitlab + +# MySQL installations (note: the line below states '--without postgres') +sudo -u git -H bundle install --without postgres development test --deployment + +# PostgreSQL installations (note: the line below states '--without mysql') +sudo -u git -H bundle install --without mysql development test --deployment + +# Optional: clean up old gems +sudo -u git -H bundle clean + +# Run database migrations +sudo -u git -H bundle exec rake db:migrate RAILS_ENV=production + +# Compile GetText PO files + +sudo -u git -H bundle exec rake gettext:compile RAILS_ENV=production + +# Update node dependencies and recompile assets +sudo -u git -H bundle exec rake yarn:install gitlab:assets:clean gitlab:assets:compile RAILS_ENV=production NODE_ENV=production + +# Clean up cache +sudo -u git -H bundle exec rake cache:clear RAILS_ENV=production +``` + +**MySQL installations**: Run through the `MySQL strings limits` and `Tables and data conversion to utf8mb4` [tasks](../install/database_mysql.md). + +### 13. Start application + +```bash +sudo service gitlab start +sudo service nginx restart +``` + +### 14. Check application status + +Check if GitLab and its environment are configured correctly: + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production +``` + +To make sure you didn't miss anything run a more thorough check: + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production +``` + +If all items are green, then congratulations, the upgrade is complete! + +## Things went south? Revert to previous version (11.0) + +### 1. Revert the code to the previous version + +Follow the [upgrade guide from 10.8 to 11.0](10.8-to-11.0.md), except for the +database migration (the backup is already migrated to the previous version). + +### 2. Restore from the backup + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production +``` + +If you have more than one backup `*.tar` file(s) please add `BACKUP=timestamp_of_backup` to the command above. + +[yaml]: https://gitlab.com/gitlab-org/gitlab-ce/blob/11-1-stable/config/gitlab.yml.example +[gl-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/11-1-stable/lib/support/init.d/gitlab.default.example diff --git a/doc/user/project/settings/import_export.md b/doc/user/project/settings/import_export.md index 9034a9b5179..f94671fcf87 100644 --- a/doc/user/project/settings/import_export.md +++ b/doc/user/project/settings/import_export.md @@ -32,7 +32,8 @@ with all their related data and be moved into a new GitLab instance. | GitLab version | Import/Export version | | ---------------- | --------------------- | -| 10.8 to current | 0.2.3 | +| 11.1 to current | 0.2.4 | +| 10.8 | 0.2.3 | | 10.4 | 0.2.2 | | 10.3 | 0.2.1 | | 10.0 | 0.2.0 | diff --git a/doc/workflow/lfs/lfs_administration.md b/doc/workflow/lfs/lfs_administration.md index 8a2f230f505..b1afe2cee42 100644 --- a/doc/workflow/lfs/lfs_administration.md +++ b/doc/workflow/lfs/lfs_administration.md @@ -90,6 +90,7 @@ Here is a configuration example with S3. | `provider` | The provider name | AWS | | `aws_access_key_id` | AWS credentials, or compatible | `ABC123DEF456` | | `aws_secret_access_key` | AWS credentials, or compatible | `ABC123DEF456ABC123DEF456ABC123DEF456` | +| `aws_signature_version` | AWS signature version to use. 2 or 4 are valid options. Digital Ocean Spaces and other providers may need 2. | 4 | | `region` | AWS region | us-east-1 | | `host` | S3 compatible host for when not using AWS, e.g. `localhost` or `storage.example.com` | s3.amazonaws.com | | `endpoint` | Can be used when configuring an S3 compatible service such as [Minio](https://www.minio.io), by entering a URL such as `http://127.0.0.1:9000` | (optional) | diff --git a/lib/api/files.rb b/lib/api/files.rb index 1598d3c00b8..29d7489bd7c 100644 --- a/lib/api/files.rb +++ b/lib/api/files.rb @@ -5,6 +5,8 @@ module API # Prevents returning plain/text responses for files with .txt extension after_validation { content_type "application/json" } + helpers ::API::Helpers::HeadersHelpers + helpers do def commit_params(attrs) { @@ -40,6 +42,20 @@ module API } end + def blob_data + { + file_name: @blob.name, + file_path: @blob.path, + size: @blob.size, + encoding: "base64", + content_sha256: Digest::SHA256.hexdigest(@blob.data), + ref: params[:ref], + blob_id: @blob.id, + commit_id: @commit.id, + last_commit_id: @repo.last_commit_id_for_path(@commit.sha, params[:file_path]) + } + end + params :simple_file_params do requires :file_path, type: String, desc: 'The url encoded path to the file. Ex. lib%2Fclass%2Erb' requires :branch, type: String, desc: 'Name of the branch to commit into. To create a new branch, also provide `start_branch`.' @@ -61,6 +77,17 @@ module API requires :id, type: String, desc: 'The project ID' end resource :projects, requirements: FILE_ENDPOINT_REQUIREMENTS do + desc 'Get raw file metadata from repository' + params do + requires :file_path, type: String, desc: 'The url encoded path to the file. Ex. lib%2Fclass%2Erb' + requires :ref, type: String, desc: 'The name of branch, tag or commit' + end + head ":id/repository/files/:file_path/raw", requirements: FILE_ENDPOINT_REQUIREMENTS do + assign_file_vars! + + set_http_headers(blob_data) + end + desc 'Get raw file contents from the repository' params do requires :file_path, type: String, desc: 'The url encoded path to the file. Ex. lib%2Fclass%2Erb' @@ -69,9 +96,22 @@ module API get ":id/repository/files/:file_path/raw", requirements: FILE_ENDPOINT_REQUIREMENTS do assign_file_vars! + set_http_headers(blob_data) + send_git_blob @repo, @blob end + desc 'Get file metadata from repository' + params do + requires :file_path, type: String, desc: 'The url encoded path to the file. Ex. lib%2Fclass%2Erb' + requires :ref, type: String, desc: 'The name of branch, tag or commit' + end + head ":id/repository/files/:file_path", requirements: FILE_ENDPOINT_REQUIREMENTS do + assign_file_vars! + + set_http_headers(blob_data) + end + desc 'Get a file from the repository' params do requires :file_path, type: String, desc: 'The url encoded path to the file. Ex. lib%2Fclass%2Erb' @@ -80,17 +120,11 @@ module API get ":id/repository/files/:file_path", requirements: FILE_ENDPOINT_REQUIREMENTS do assign_file_vars! - { - file_name: @blob.name, - file_path: @blob.path, - size: @blob.size, - encoding: "base64", - content: Base64.strict_encode64(@blob.data), - ref: params[:ref], - blob_id: @blob.id, - commit_id: @commit.id, - last_commit_id: @repo.last_commit_id_for_path(@commit.sha, params[:file_path]) - } + data = blob_data + + set_http_headers(data) + + data.merge(content: Base64.strict_encode64(@blob.data)) end desc 'Create new file in repository' diff --git a/lib/api/groups.rb b/lib/api/groups.rb index c7f41aba854..f633dd88d06 100644 --- a/lib/api/groups.rb +++ b/lib/api/groups.rb @@ -56,6 +56,8 @@ module API def find_group_projects(params) group = find_group!(params[:id]) projects = GroupProjectsFinder.new(group: group, current_user: current_user, params: project_finder_params).execute + projects = projects.with_issues_available_for_user(current_user) if params[:with_issues_enabled] + projects = projects.with_merge_requests_enabled if params[:with_merge_requests_enabled] projects = reorder_projects(projects) paginate(projects) end @@ -191,6 +193,8 @@ module API desc: 'Return only the ID, URL, name, and path of each project' optional :owned, type: Boolean, default: false, desc: 'Limit by owned by authenticated user' optional :starred, type: Boolean, default: false, desc: 'Limit by starred status' + optional :with_issues_enabled, type: Boolean, default: false, desc: 'Limit by enabled issues feature' + optional :with_merge_requests_enabled, type: Boolean, default: false, desc: 'Limit by enabled merge requests feature' use :pagination use :with_custom_attributes diff --git a/lib/api/helpers/headers_helpers.rb b/lib/api/helpers/headers_helpers.rb new file mode 100644 index 00000000000..cde51fccc62 --- /dev/null +++ b/lib/api/helpers/headers_helpers.rb @@ -0,0 +1,11 @@ +module API + module Helpers + module HeadersHelpers + def set_http_headers(header_data) + header_data.each do |key, value| + header "X-Gitlab-#{key.to_s.split('_').collect(&:capitalize).join('-')}", value + end + end + end + end +end diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index af7d2471b34..0f46bc4c98e 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -72,8 +72,8 @@ module API end params :merge_requests_params do - optional :state, type: String, values: %w[opened closed merged all], default: 'all', - desc: 'Return opened, closed, merged, or all merge requests' + optional :state, type: String, values: %w[opened closed locked merged all], default: 'all', + desc: 'Return opened, closed, locked, merged, or all merge requests' optional :order_by, type: String, values: %w[created_at updated_at], default: 'created_at', desc: 'Return merge requests ordered by `created_at` or `updated_at` fields.' optional :sort, type: String, values: %w[asc desc], default: 'desc', diff --git a/lib/api/projects.rb b/lib/api/projects.rb index 3ef3680c5d9..b83da00502d 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -459,6 +459,23 @@ module API conflict!(error.message) end end + + desc 'Transfer a project to a new namespace' + params do + requires :namespace, type: String, desc: 'The ID or path of the new namespace' + end + put ":id/transfer" do + authorize! :change_namespace, user_project + + namespace = find_namespace!(params[:namespace]) + result = ::Projects::TransferService.new(user_project, current_user).execute(namespace) + + if result + present user_project, with: Entities::Project + else + render_api_error!("Failed to transfer project #{user_project.errors.messages}", 400) + end + end end end end diff --git a/lib/api/repositories.rb b/lib/api/repositories.rb index bb3fa99af38..33a9646ac3b 100644 --- a/lib/api/repositories.rb +++ b/lib/api/repositories.rb @@ -100,9 +100,10 @@ module API params do requires :from, type: String, desc: 'The commit, branch name, or tag name to start comparison' requires :to, type: String, desc: 'The commit, branch name, or tag name to stop comparison' + optional :straight, type: Boolean, desc: 'Comparison method, `true` for direct comparison between `from` and `to` (`from`..`to`), `false` to compare using merge base (`from`...`to`)', default: false end get ':id/repository/compare' do - compare = Gitlab::Git::Compare.new(user_project.repository.raw_repository, params[:from], params[:to]) + compare = Gitlab::Git::Compare.new(user_project.repository.raw_repository, params[:from], params[:to], straight: params[:straight]) present compare, with: Entities::Compare end diff --git a/lib/banzai/filter/emoji_filter.rb b/lib/banzai/filter/emoji_filter.rb index e1261e7bbbe..4eccd9d5ed5 100644 --- a/lib/banzai/filter/emoji_filter.rb +++ b/lib/banzai/filter/emoji_filter.rb @@ -3,10 +3,6 @@ module Banzai # HTML filter that replaces :emoji: and unicode with images. # # Based on HTML::Pipeline::EmojiFilter - # - # Context options: - # :asset_root - # :asset_host class EmojiFilter < HTML::Pipeline::Filter IGNORED_ANCESTOR_TAGS = %w(pre code tt).to_set diff --git a/lib/banzai/pipeline/gfm_pipeline.rb b/lib/banzai/pipeline/gfm_pipeline.rb index a1f24e8b093..0d9b874ef85 100644 --- a/lib/banzai/pipeline/gfm_pipeline.rb +++ b/lib/banzai/pipeline/gfm_pipeline.rb @@ -44,11 +44,7 @@ module Banzai def self.transform_context(context) context[:only_path] = true unless context.key?(:only_path) - context.merge( - # EmojiFilter - asset_host: Gitlab::Application.config.asset_host, - asset_root: Gitlab.config.gitlab.base_url - ) + context end end end diff --git a/lib/gitlab/background_migration/delete_diff_files.rb b/lib/gitlab/background_migration/delete_diff_files.rb new file mode 100644 index 00000000000..0b785e1b056 --- /dev/null +++ b/lib/gitlab/background_migration/delete_diff_files.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true +# rubocop:disable Metrics/AbcSize +# rubocop:disable Style/Documentation + +module Gitlab + module BackgroundMigration + class DeleteDiffFiles + def perform(merge_request_diff_id) + merge_request_diff = MergeRequestDiff.find_by(id: merge_request_diff_id) + + return unless merge_request_diff + return unless should_delete_diff_files?(merge_request_diff) + + MergeRequestDiff.transaction do + merge_request_diff.update_column(:state, 'without_files') + + # explain (analyze, buffers) when deleting 453 diff files: + # + # Delete on merge_request_diff_files (cost=0.57..8487.35 rows=4846 width=6) (actual time=43.265..43.265 rows=0 loops=1) + # Buffers: shared hit=2043 read=259 dirtied=254 + # -> Index Scan using index_merge_request_diff_files_on_mr_diff_id_and_order on merge_request_diff_files (cost=0.57..8487.35 rows=4846 width=6) (actu + # al time=0.466..26.317 rows=453 loops=1) + # Index Cond: (merge_request_diff_id = 463448) + # Buffers: shared hit=17 read=84 + # Planning time: 0.107 ms + # Execution time: 43.287 ms + # + MergeRequestDiffFile.where(merge_request_diff_id: merge_request_diff.id).delete_all + end + end + + private + + def should_delete_diff_files?(merge_request_diff) + return false if merge_request_diff.state == 'without_files' + + merge_request = merge_request_diff.merge_request + + return false unless merge_request.state == 'merged' + return false if merge_request_diff.id == merge_request.latest_merge_request_diff_id + + true + end + end + end +end diff --git a/lib/gitlab/favicon.rb b/lib/gitlab/favicon.rb index d512fc58e46..4850a6c0430 100644 --- a/lib/gitlab/favicon.rb +++ b/lib/gitlab/favicon.rb @@ -38,7 +38,8 @@ module Gitlab # we only want to create full urls when there's a different asset_host # configured. def host - if Gitlab::Application.config.asset_host.nil? || Gitlab::Application.config.asset_host == Gitlab.config.gitlab.base_url + asset_host = ActionController::Base.asset_host + if asset_host.nil? || asset_host == Gitlab.config.gitlab.base_url nil else Gitlab.config.gitlab.base_url diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb index 74240cedc9d..c67826da1d2 100644 --- a/lib/gitlab/git/commit.rb +++ b/lib/gitlab/git/commit.rb @@ -116,15 +116,9 @@ module Gitlab # Commit.between(repo, '29eda46b', 'master') # def between(repo, base, head) - Gitlab::GitalyClient.migrate(:commits_between) do |is_enabled| - if is_enabled - repo.gitaly_commit_client.between(base, head) - else - repo.rugged_commits_between(base, head).map { |c| decorate(repo, c) } - end + repo.wrapped_gitaly_errors do + repo.gitaly_commit_client.between(base, head) end - rescue Rugged::ReferenceError - [] end # Returns commits collection @@ -149,58 +143,11 @@ module Gitlab # # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/326 def find_all(repo, options = {}) - Gitlab::GitalyClient.migrate(:find_all_commits) do |is_enabled| - if is_enabled - find_all_by_gitaly(repo, options) - else - find_all_by_rugged(repo, options) - end + repo.wrapped_gitaly_errors do + Gitlab::GitalyClient::CommitService.new(repo).find_all_commits(options) end end - def find_all_by_rugged(repo, options = {}) - actual_options = options.dup - - allowed_options = [:ref, :max_count, :skip, :order] - - actual_options.keep_if do |key| - allowed_options.include?(key) - end - - default_options = { skip: 0 } - actual_options = default_options.merge(actual_options) - - rugged = repo.rugged - walker = Rugged::Walker.new(rugged) - - if actual_options[:ref] - walker.push(rugged.rev_parse_oid(actual_options[:ref])) - else - rugged.references.each("refs/heads/*") do |ref| - walker.push(ref.target_id) - end - end - - walker.sorting(rugged_sort_type(actual_options[:order])) - - commits = [] - offset = actual_options[:skip] - limit = actual_options[:max_count] - walker.each(offset: offset, limit: limit) do |commit| - commits.push(decorate(repo, commit)) - end - - walker.reset - - commits - rescue Rugged::OdbError - [] - end - - def find_all_by_gitaly(repo, options = {}) - Gitlab::GitalyClient::CommitService.new(repo).find_all_commits(options) - end - def decorate(repository, commit, ref = nil) Gitlab::Git::Commit.new(repository, commit, ref) end @@ -220,19 +167,7 @@ module Gitlab end def shas_with_signatures(repository, shas) - GitalyClient.migrate(:filter_shas_with_signatures) do |is_enabled| - if is_enabled - Gitlab::GitalyClient::CommitService.new(repository).filter_shas_with_signatures(shas) - else - shas.select do |sha| - begin - Rugged::Commit.extract_signature(repository.rugged, sha) - rescue Rugged::OdbError - false - end - end - end - end + Gitlab::GitalyClient::CommitService.new(repository).filter_shas_with_signatures(shas) end # Only to be used when the object ids will not necessarily have a @@ -250,13 +185,7 @@ module Gitlab end def extract_signature(repository, commit_id) - repository.gitaly_migrate(:extract_commit_signature) do |is_enabled| - if is_enabled - repository.gitaly_commit_client.extract_signature(commit_id) - else - rugged_extract_signature(repository, commit_id) - end - end + repository.gitaly_commit_client.extract_signature(commit_id) end def extract_signature_lazily(repository, commit_id) @@ -276,36 +205,9 @@ module Gitlab end def batch_signature_extraction(repository, commit_ids) - repository.gitaly_migrate(:extract_commit_signature_in_batch) do |is_enabled| - if is_enabled - gitaly_batch_signature_extraction(repository, commit_ids) - else - rugged_batch_signature_extraction(repository, commit_ids) - end - end - end - - def gitaly_batch_signature_extraction(repository, commit_ids) repository.gitaly_commit_client.get_commit_signatures(commit_ids) end - def rugged_batch_signature_extraction(repository, commit_ids) - commit_ids.each_with_object({}) do |commit_id, signatures| - signature_data = rugged_extract_signature(repository, commit_id) - next unless signature_data - - signatures[commit_id] = signature_data - end - end - - def rugged_extract_signature(repository, commit_id) - begin - Rugged::Commit.extract_signature(repository.rugged, commit_id) - rescue Rugged::OdbError - nil - end - end - def get_message(repository, commit_id) BatchLoader.for({ repository: repository, commit_id: commit_id }).batch do |items, loader| items_by_repo = items.group_by { |i| i[:repository] } @@ -323,13 +225,7 @@ module Gitlab end def get_messages(repository, commit_ids) - repository.gitaly_migrate(:commit_messages) do |is_enabled| - if is_enabled - repository.gitaly_commit_client.get_commit_messages(commit_ids) - else - commit_ids.map { |id| [id, rugged_find(repository, id).message] }.to_h - end - end + repository.gitaly_commit_client.get_commit_messages(commit_ids) end end diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 88944cd62ea..7c3b91f6efb 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -492,27 +492,6 @@ module Gitlab Ref.dereference_object(obj) end - # Return a collection of Rugged::Commits between the two revspec arguments. - # See http://git-scm.com/docs/git-rev-parse.html#_specifying_revisions for - # a detailed list of valid arguments. - # - # Gitaly note: JV: to be deprecated in favor of Commit.between - def rugged_commits_between(from, to) - walker = Rugged::Walker.new(rugged) - walker.sorting(Rugged::SORT_NONE | Rugged::SORT_REVERSE) - - sha_from = sha_from_ref(from) - sha_to = sha_from_ref(to) - - walker.push(sha_to) - walker.hide(sha_from) - - commits = walker.to_a - walker.reset - - commits - end - # Counts the amount of commits between `from` and `to`. def count_commits_between(from, to, options = {}) count_commits(from: from, to: to, **options) @@ -1316,16 +1295,7 @@ module Gitlab safe_query = Regexp.escape(query) ref ||= root_ref - gitaly_migrate(:search_files_by_content) do |is_enabled| - if is_enabled - gitaly_repository_client.search_files_by_content(ref, safe_query) - else - offset = 2 - args = %W(grep -i -I -n -z --before-context #{offset} --after-context #{offset} -E -e #{safe_query} #{ref}) - - run_git(args).first.scrub.split(/^--\n/) - end - end + gitaly_repository_client.search_files_by_content(ref, safe_query) end def can_be_merged?(source_sha, target_branch) @@ -1342,15 +1312,7 @@ module Gitlab return [] if empty? || safe_query.blank? - gitaly_migrate(:search_files_by_name) do |is_enabled| - if is_enabled - gitaly_repository_client.search_files_by_name(ref, safe_query) - else - args = %W(ls-tree -r --name-status --full-tree #{ref} -- #{safe_query}) - - run_git(args).first.lines.map(&:strip) - end - end + gitaly_repository_client.search_files_by_name(ref, safe_query) end def find_commits_by_message(query, ref, path, limit, offset) diff --git a/lib/gitlab/git/tag.rb b/lib/gitlab/git/tag.rb index e44284572fd..bbf2ecdb1fa 100644 --- a/lib/gitlab/git/tag.rb +++ b/lib/gitlab/git/tag.rb @@ -28,18 +28,7 @@ module Gitlab end def get_messages(repository, tag_ids) - repository.gitaly_migrate(:tag_messages) do |is_enabled| - if is_enabled - repository.gitaly_ref_client.get_tag_messages(tag_ids) - else - tag_ids.map do |id| - tag = repository.rugged.lookup(id) - message = tag.is_a?(Rugged::Commit) ? "" : tag.message - - [id, message] - end.to_h - end - end + repository.gitaly_ref_client.get_tag_messages(tag_ids) end end diff --git a/lib/gitlab/gitaly_client/commit_service.rb b/lib/gitlab/gitaly_client/commit_service.rb index 077297b9d6d..c9c414e5d33 100644 --- a/lib/gitlab/gitaly_client/commit_service.rb +++ b/lib/gitlab/gitaly_client/commit_service.rb @@ -324,6 +324,8 @@ module Gitlab return if signature.blank? && signed_text.blank? [signature, signed_text] + rescue GRPC::InvalidArgument => ex + raise ArgumentError, ex end def get_commit_signatures(commit_ids) @@ -341,6 +343,8 @@ module Gitlab end signatures + rescue GRPC::InvalidArgument => ex + raise ArgumentError, ex end def get_commit_messages(commit_ids) diff --git a/lib/gitlab/graphql/expose_permissions.rb b/lib/gitlab/graphql/expose_permissions.rb new file mode 100644 index 00000000000..e3779995406 --- /dev/null +++ b/lib/gitlab/graphql/expose_permissions.rb @@ -0,0 +1,15 @@ +module Gitlab + module Graphql + module ExposePermissions + extend ActiveSupport::Concern + prepended do + def self.expose_permissions(permission_type, description: 'Permissions for the current user on the resource') + field :user_permissions, permission_type, + description: description, + null: false, + resolve: -> (obj, _, _) { obj } + end + end + end + end +end diff --git a/lib/gitlab/graphql/present/instrumentation.rb b/lib/gitlab/graphql/present/instrumentation.rb index 1688262974b..6f2b26c9676 100644 --- a/lib/gitlab/graphql/present/instrumentation.rb +++ b/lib/gitlab/graphql/present/instrumentation.rb @@ -10,9 +10,18 @@ module Gitlab old_resolver = field.resolve_proc resolve_with_presenter = -> (presented_type, args, context) do + # We need to wrap the original presentation type into a type that + # uses the presenter as an object. object = presented_type.object + + if object.is_a?(presented_in.presenter_class) + next old_resolver.call(presented_type, args, context) + end + presenter = presented_in.presenter_class.new(object, **context.to_h) - old_resolver.call(presenter, args, context) + wrapped = presented_type.class.new(presenter, context) + + old_resolver.call(wrapped, args, context) end field.redefine do diff --git a/lib/gitlab/import_export.rb b/lib/gitlab/import_export.rb index b713fa7e1cd..53fe2f8e436 100644 --- a/lib/gitlab/import_export.rb +++ b/lib/gitlab/import_export.rb @@ -3,7 +3,7 @@ module Gitlab extend self # For every version update, the version history in import_export.md has to be kept up to date. - VERSION = '0.2.3'.freeze + VERSION = '0.2.4'.freeze FILENAME_LIMIT = 50 def export_path(relative_path:) diff --git a/lib/gitlab/import_export/group_project_object_builder.rb b/lib/gitlab/import_export/group_project_object_builder.rb new file mode 100644 index 00000000000..6c2af770119 --- /dev/null +++ b/lib/gitlab/import_export/group_project_object_builder.rb @@ -0,0 +1,90 @@ +module Gitlab + module ImportExport + # Given a class, it finds or creates a new object + # (initializes in the case of Label) at group or project level. + # If it does not exist in the group, it creates it at project level. + # + # Example: + # `GroupProjectObjectBuilder.build(Label, label_attributes)` + # finds or initializes a label with the given attributes. + # + # It also adds some logic around Group Labels/Milestones for edge cases. + class GroupProjectObjectBuilder + def self.build(*args) + Project.transaction do + new(*args).find + end + end + + def initialize(klass, attributes) + @klass = klass < Label ? Label : klass + @attributes = attributes + @group = @attributes['group'] + @project = @attributes['project'] + end + + def find + find_object || @klass.create(project_attributes) + end + + private + + def find_object + @klass.where(where_clause).first + end + + def where_clause + @attributes.slice('title').map do |key, value| + scope_clause = table[:project_id].eq(@project.id) + scope_clause = scope_clause.or(table[:group_id].eq(@group.id)) if @group + + table[key].eq(value).and(scope_clause) + end.reduce(:or) + end + + def table + @table ||= @klass.arel_table + end + + def project_attributes + @attributes.except('group').tap do |atts| + if label? + atts['type'] = 'ProjectLabel' # Always create project labels + elsif milestone? + if atts['group_id'] # Transform new group milestones into project ones + atts['iid'] = nil + atts.delete('group_id') + else + claim_iid + end + end + end + end + + def label? + @klass == Label + end + + def milestone? + @klass == Milestone + end + + # If an existing group milestone used the IID + # claim the IID back and set the group milestone to use one available + # This is necessary to fix situations like the following: + # - Importing into a user namespace project with exported group milestones + # where the IID of the Group milestone could conflict with a project one. + def claim_iid + # The milestone has to be a group milestone, as it's the only case where + # we set the IID as the maximum. The rest of them are fixed. + milestone = @project.milestones.find_by(iid: @attributes['iid']) + + return unless milestone + + milestone.iid = nil + milestone.ensure_project_iid! + milestone.save! + end + end + end +end diff --git a/lib/gitlab/import_export/project_tree_restorer.rb b/lib/gitlab/import_export/project_tree_restorer.rb index 4eb67fbe11e..76b99b1de16 100644 --- a/lib/gitlab/import_export/project_tree_restorer.rb +++ b/lib/gitlab/import_export/project_tree_restorer.rb @@ -1,8 +1,8 @@ module Gitlab module ImportExport class ProjectTreeRestorer - # Relations which cannot have both group_id and project_id at the same time - RESTRICT_PROJECT_AND_GROUP = %i(milestone milestones).freeze + # Relations which cannot be saved at project level (and have a group assigned) + GROUP_MODELS = [GroupLabel, Milestone].freeze def initialize(user:, shared:, project:) @path = File.join(shared.export_path, 'project.json') @@ -70,12 +70,23 @@ module Gitlab def save_relation_hash(relation_hash_batch, relation_key) relation_hash = create_relation(relation_key, relation_hash_batch) + remove_group_models(relation_hash) if relation_hash.is_a?(Array) + @saved = false unless restored_project.append_or_update_attribute(relation_key, relation_hash) # Restore the project again, extra query that skips holding the AR objects in memory @restored_project = Project.find(@project_id) end + # Remove project models that became group models as we found them at group level. + # This no longer required saving them at the root project level. + # For example, in the case of an existing group label that matched the title. + def remove_group_models(relation_hash) + relation_hash.reject! do |value| + GROUP_MODELS.include?(value.class) && value.group_id + end + end + def default_relation_list reader.tree.reject do |model| model.is_a?(Hash) && model[:project_members] @@ -170,7 +181,7 @@ module Gitlab def create_relation(relation, relation_hash_list) relation_array = [relation_hash_list].flatten.map do |relation_hash| Gitlab::ImportExport::RelationFactory.create(relation_sym: relation.to_sym, - relation_hash: parsed_relation_hash(relation_hash, relation.to_sym), + relation_hash: relation_hash, members_mapper: members_mapper, user: @user, project: @restored_project, @@ -180,18 +191,6 @@ module Gitlab relation_hash_list.is_a?(Array) ? relation_array : relation_array.first end - def parsed_relation_hash(relation_hash, relation_type) - if RESTRICT_PROJECT_AND_GROUP.include?(relation_type) - params = {} - params['group_id'] = restored_project.group.try(:id) if relation_hash['group_id'] - params['project_id'] = restored_project.id if relation_hash['project_id'] - else - params = { 'group_id' => restored_project.group.try(:id), 'project_id' => restored_project.id } - end - - relation_hash.merge(params) - end - def reader @reader ||= Gitlab::ImportExport::Reader.new(shared: @shared) end diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb index c5cf290f191..091e81028bb 100644 --- a/lib/gitlab/import_export/relation_factory.rb +++ b/lib/gitlab/import_export/relation_factory.rb @@ -54,6 +54,8 @@ module Gitlab @project = project @imported_object_retries = 0 + @relation_hash['project_id'] = @project.id + # Remove excluded keys from relation_hash # We don't do this in the parsed_relation_hash because of the 'transformed attributes' # For example, MergeRequestDiffFiles exports its diff attribute as utf8_diff. Then, @@ -80,15 +82,12 @@ module Gitlab case @relation_name when :merge_request_diff_files then setup_diff when :notes then setup_note - when :project_label, :project_labels then setup_label - when :milestone, :milestones then setup_milestone when 'Ci::Pipeline' then setup_pipeline - else - @relation_hash['project_id'] = @project.id end update_user_references update_project_references + update_group_references remove_duplicate_assignees reset_tokens! @@ -151,39 +150,23 @@ module Gitlab end def update_project_references - project_id = @relation_hash.delete('project_id') - # If source and target are the same, populate them with the new project ID. if @relation_hash['source_project_id'] - @relation_hash['source_project_id'] = same_source_and_target? ? project_id : MergeRequestParser::FORKED_PROJECT_ID + @relation_hash['source_project_id'] = same_source_and_target? ? @relation_hash['project_id'] : MergeRequestParser::FORKED_PROJECT_ID end - # project_id may not be part of the export, but we always need to populate it if required. - @relation_hash['project_id'] = project_id - @relation_hash['target_project_id'] = project_id if @relation_hash['target_project_id'] + @relation_hash['target_project_id'] = @relation_hash['project_id'] if @relation_hash['target_project_id'] end def same_source_and_target? @relation_hash['target_project_id'] && @relation_hash['target_project_id'] == @relation_hash['source_project_id'] end - def setup_label - # If there's no group, move the label to a project label - if @relation_hash['type'] == 'GroupLabel' && @relation_hash['group_id'] - @relation_hash['project_id'] = nil - @relation_name = :group_label - else - @relation_hash['group_id'] = nil - @relation_hash['type'] = 'ProjectLabel' - end - end + def update_group_references + return unless EXISTING_OBJECT_CHECK.include?(@relation_name) + return unless @relation_hash['group_id'] - def setup_milestone - if @relation_hash['group_id'] - @relation_hash['group_id'] = @project.group.id - else - @relation_hash['project_id'] = @project.id - end + @relation_hash['group_id'] = @project.group&.id end def reset_tokens! @@ -271,15 +254,7 @@ module Gitlab end def existing_object - @existing_object ||= - begin - existing_object = find_or_create_object! - - # Done in two steps, as MySQL behaves differently than PostgreSQL using - # the +find_or_create_by+ method and does not return the ID the second time. - existing_object.update!(parsed_relation_hash) - existing_object - end + @existing_object ||= find_or_create_object! end def unknown_service? @@ -288,29 +263,16 @@ module Gitlab end def find_or_create_object! - finder_attributes = if @relation_name == :group_label - %w[title group_id] - elsif parsed_relation_hash['project_id'] - %w[title project_id] - else - %w[title group_id] - end - - finder_hash = parsed_relation_hash.slice(*finder_attributes) - - if label? - label = relation_class.find_or_initialize_by(finder_hash) - parsed_relation_hash.delete('priorities') if label.persisted? - - label.save! - label - else - relation_class.find_or_create_by(finder_hash) + return relation_class.find_or_create_by(project_id: @project.id) if @relation_name == :project_feature + + # Can't use IDs as validation exists calling `group` or `project` attributes + finder_hash = parsed_relation_hash.tap do |hash| + hash['group'] = @project.group if relation_class.attribute_method?('group_id') + hash['project'] = @project + hash.delete('project_id') end - end - def label? - @relation_name.to_s.include?('label') + GroupProjectObjectBuilder.build(relation_class, finder_hash) end end end diff --git a/lib/gitlab/middleware/read_only/controller.rb b/lib/gitlab/middleware/read_only/controller.rb index 45b644e6510..4a99b7cca5c 100644 --- a/lib/gitlab/middleware/read_only/controller.rb +++ b/lib/gitlab/middleware/read_only/controller.rb @@ -4,8 +4,18 @@ module Gitlab class Controller DISALLOWED_METHODS = %w(POST PATCH PUT DELETE).freeze APPLICATION_JSON = 'application/json'.freeze + APPLICATION_JSON_TYPES = %W{#{APPLICATION_JSON} application/vnd.git-lfs+json}.freeze ERROR_MESSAGE = 'You cannot perform write operations on a read-only instance'.freeze + WHITELISTED_GIT_ROUTES = { + 'projects/git_http' => %w{git_upload_pack git_receive_pack} + }.freeze + + WHITELISTED_GIT_LFS_ROUTES = { + 'projects/lfs_api' => %w{batch}, + 'projects/lfs_locks_api' => %w{verify create unlock} + }.freeze + def initialize(app, env) @app = app @env = env @@ -36,7 +46,7 @@ module Gitlab end def json_request? - request.media_type == APPLICATION_JSON + APPLICATION_JSON_TYPES.include?(request.media_type) end def rack_flash @@ -63,22 +73,27 @@ module Gitlab grack_route || ReadOnly.internal_routes.any? { |path| request.path.include?(path) } || lfs_route || sidekiq_route end - def sidekiq_route - request.path.start_with?('/admin/sidekiq') - end - def grack_route # Calling route_hash may be expensive. Only do it if we think there's a possible match - return false unless request.path.end_with?('.git/git-upload-pack') + return false unless + request.path.end_with?('.git/git-upload-pack', '.git/git-receive-pack') - route_hash[:controller] == 'projects/git_http' && route_hash[:action] == 'git_upload_pack' + WHITELISTED_GIT_ROUTES[route_hash[:controller]]&.include?(route_hash[:action]) end def lfs_route # Calling route_hash may be expensive. Only do it if we think there's a possible match - return false unless request.path.end_with?('/info/lfs/objects/batch') + unless request.path.end_with?('/info/lfs/objects/batch', + '/info/lfs/locks', '/info/lfs/locks/verify') || + %r{/info/lfs/locks/\d+/unlock\z}.match?(request.path) + return false + end + + WHITELISTED_GIT_LFS_ROUTES[route_hash[:controller]]&.include?(route_hash[:action]) + end - route_hash[:controller] == 'projects/lfs_api' && route_hash[:action] == 'batch' + def sidekiq_route + request.path.start_with?('/admin/sidekiq') end end end diff --git a/lib/gitlab/repository_cache_adapter.rb b/lib/gitlab/repository_cache_adapter.rb index 7f64a8c9e46..2ec871f0754 100644 --- a/lib/gitlab/repository_cache_adapter.rb +++ b/lib/gitlab/repository_cache_adapter.rb @@ -25,6 +25,11 @@ module Gitlab raise NotImplementedError end + # List of cached methods. Should be overridden by the including class + def cached_methods + raise NotImplementedError + end + # Caches the supplied block both in a cache and in an instance variable. # # The cache key and instance variable are named the same way as the value of @@ -67,6 +72,11 @@ module Gitlab # Expires the caches of a specific set of methods def expire_method_caches(methods) methods.each do |key| + unless cached_methods.include?(key.to_sym) + Rails.logger.error "Requested to expire non-existent method '#{key}' for Repository" + next + end + cache.expire(key) ivar = cache_instance_variable_name(key) diff --git a/lib/gitlab/shard_health_cache.rb b/lib/gitlab/shard_health_cache.rb new file mode 100644 index 00000000000..3f03f46d4b1 --- /dev/null +++ b/lib/gitlab/shard_health_cache.rb @@ -0,0 +1,41 @@ +module Gitlab + class ShardHealthCache + HEALTHY_SHARDS_KEY = 'gitlab-healthy-shards'.freeze + HEALTHY_SHARDS_TIMEOUT = 300 + + # Clears the Redis set storing the list of healthy shards + def self.clear + Gitlab::Redis::Cache.with { |redis| redis.del(HEALTHY_SHARDS_KEY) } + end + + # Updates the list of healthy shards using a Redis set + # + # shards - An array of shard names to store + def self.update(shards) + Gitlab::Redis::Cache.with do |redis| + redis.multi do |m| + m.del(HEALTHY_SHARDS_KEY) + shards.each { |shard_name| m.sadd(HEALTHY_SHARDS_KEY, shard_name) } + m.expire(HEALTHY_SHARDS_KEY, HEALTHY_SHARDS_TIMEOUT) + end + end + end + + # Returns an array of strings of healthy shards + def self.cached_healthy_shards + Gitlab::Redis::Cache.with { |redis| redis.smembers(HEALTHY_SHARDS_KEY) } + end + + # Checks whether the given shard name is in the list of healthy shards. + # + # shard_name - The string to check + def self.healthy_shard?(shard_name) + Gitlab::Redis::Cache.with { |redis| redis.sismember(HEALTHY_SHARDS_KEY, shard_name) } + end + + # Returns the number of healthy shards in the Redis set + def self.healthy_shard_count + Gitlab::Redis::Cache.with { |redis| redis.scard(HEALTHY_SHARDS_KEY) } + end + end +end diff --git a/lib/gitlab/slash_commands/presenters/access.rb b/lib/gitlab/slash_commands/presenters/access.rb index 1a817eb735b..81f7cd3ffe8 100644 --- a/lib/gitlab/slash_commands/presenters/access.rb +++ b/lib/gitlab/slash_commands/presenters/access.rb @@ -15,7 +15,7 @@ module Gitlab if @resource ":wave: Hi there! Before I do anything for you, please [connect your GitLab account](#{@resource})." else - ":sweat_smile: Couldn't identify you, nor can I autorize you!" + ":sweat_smile: Couldn't identify you, nor can I authorize you!" end ephemeral_response(text: message) diff --git a/lib/tasks/gitlab/db.rake b/lib/tasks/gitlab/db.rake index 139ab70e125..69166851816 100644 --- a/lib/tasks/gitlab/db.rake +++ b/lib/tasks/gitlab/db.rake @@ -46,7 +46,9 @@ namespace :gitlab do desc 'Configures the database by running migrate, or by loading the schema and seeding if needed' task configure: :environment do - if ActiveRecord::Base.connection.tables.any? + # Check if we have existing db tables + # The schema_migrations table will still exist if drop_tables was called + if ActiveRecord::Base.connection.tables.count > 1 Rake::Task['db:migrate'].invoke else Rake::Task['db:schema:load'].invoke diff --git a/lib/tasks/gitlab/import_export.rake b/lib/tasks/gitlab/import_export.rake index 44074397c05..900dbf7be24 100644 --- a/lib/tasks/gitlab/import_export.rake +++ b/lib/tasks/gitlab/import_export.rake @@ -10,15 +10,22 @@ namespace :gitlab do puts YAML.load_file(Gitlab::ImportExport.config_file)['project_tree'].to_yaml(SortKeys: true) end - desc 'GitLab | Bumps the Import/Export version for test_project_export.tar.gz' - task bump_test_version: :environment do - Dir.mktmpdir do |tmp_dir| - system("tar -zxf spec/features/projects/import_export/test_project_export.tar.gz -C #{tmp_dir} > /dev/null") - File.write(File.join(tmp_dir, 'VERSION'), Gitlab::ImportExport.version, mode: 'w') - system("tar -zcvf spec/features/projects/import_export/test_project_export.tar.gz -C #{tmp_dir} . > /dev/null") + desc 'GitLab | Bumps the Import/Export version in fixtures and project templates' + task bump_version: :environment do + archives = Dir['vendor/project_templates/*.tar.gz'] + archives.push('spec/features/projects/import_export/test_project_export.tar.gz') + + archives.each do |archive| + raise ArgumentError unless File.exist?(archive) + + Dir.mktmpdir do |tmp_dir| + system("tar -zxf #{archive} -C #{tmp_dir} > /dev/null") + File.write(File.join(tmp_dir, 'VERSION'), Gitlab::ImportExport.version, mode: 'w') + system("tar -zcvf #{archive} -C #{tmp_dir} . > /dev/null") + end end - puts "Updated to #{Gitlab::ImportExport.version}" + puts "Updated #{archives} to #{Gitlab::ImportExport.version}." end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 926bd708532..5c4e10bfd4a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2018-06-20 16:52+0300\n" -"PO-Revision-Date: 2018-06-20 16:52+0300\n" +"POT-Creation-Date: 2018-07-01 16:35+1000\n" +"PO-Revision-Date: 2018-07-01 16:35+1000\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" "Language: \n" @@ -87,6 +87,9 @@ msgstr[1] "" msgid "%{filePath} deleted" msgstr "" +msgid "%{group_docs_link_start}Groups%{group_docs_link_end} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects." +msgstr "" + msgid "%{loadingIcon} Started" msgstr "" @@ -216,6 +219,9 @@ msgstr "" msgid "A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}." msgstr "" +msgid "A regular expression that will be used to find the test coverage output in the job trace. Leave blank to disable" +msgstr "" + msgid "A user with write access to the source branch selected this option" msgstr "" @@ -237,6 +243,9 @@ msgstr "" msgid "Access to failing storages has been temporarily disabled to allow the mount to recover. Reset storage information after the issue has been resolved to allow access again." msgstr "" +msgid "Access your runner token, customize your pipeline configuration, and view your pipeline status and coverage report." +msgstr "" + msgid "Account" msgstr "" @@ -345,6 +354,9 @@ msgstr "" msgid "Allow commits from members who can merge to the target branch." msgstr "" +msgid "Allow public access to pipelines and job details, including output logs and artifacts" +msgstr "" + msgid "Allow rendering of PlantUML diagrams in Asciidoc documents." msgstr "" @@ -357,6 +369,27 @@ msgstr "" msgid "Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import." msgstr "" +msgid "An error occured creating the new branch." +msgstr "" + +msgid "An error occured whilst loading all the files." +msgstr "" + +msgid "An error occured whilst loading the file content." +msgstr "" + +msgid "An error occured whilst loading the file." +msgstr "" + +msgid "An error occured whilst loading the merge request changes." +msgstr "" + +msgid "An error occured whilst loading the merge request version data." +msgstr "" + +msgid "An error occured whilst loading the merge request." +msgstr "" + msgid "An error occurred previewing the blob" msgstr "" @@ -438,6 +471,9 @@ msgstr "" msgid "Are you sure you want to delete this pipeline schedule?" msgstr "" +msgid "Are you sure you want to remove this identity?" +msgstr "" + msgid "Are you sure you want to reset registration token?" msgstr "" @@ -516,6 +552,9 @@ msgstr "" msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly." msgstr "" +msgid "Auto-cancel redundant, pending pipelines" +msgstr "" + msgid "AutoDevOps|Auto DevOps" msgstr "" @@ -555,6 +594,9 @@ msgstr "" msgid "Average per day: %{average}" msgstr "" +msgid "Background color" +msgstr "" + msgid "Background jobs" msgstr "" @@ -630,6 +672,15 @@ msgstr "" msgid "Begin with the selected commit" msgstr "" +msgid "Below are examples of regex for existing tools:" +msgstr "" + +msgid "Boards" +msgstr "" + +msgid "Branch %{branchName} was not found in this project's repository." +msgstr "" + msgid "Branch (%{branch_count})" msgid_plural "Branches (%{branch_count})" msgstr[0] "" @@ -785,6 +836,9 @@ msgstr "" msgid "CI / CD" msgstr "" +msgid "CI / CD Settings" +msgstr "" + msgid "CI/CD configuration" msgstr "" @@ -836,6 +890,9 @@ msgstr "" msgid "CICD|You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages." msgstr "" +msgid "Can't find HEAD commit for this branch" +msgstr "" + msgid "Cancel" msgstr "" @@ -899,6 +956,12 @@ msgstr "" msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request." msgstr "" +msgid "Choose any color." +msgstr "" + +msgid "Choose between <code>clone</code> or <code>fetch</code> to get the recent application code" +msgstr "" + msgid "Choose file..." msgstr "" @@ -1094,7 +1157,7 @@ msgstr "" msgid "ClusterIntegration|Environment scope" msgstr "" -msgid "ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for new GCP accounts to get started with GitLab's Google Kubernetes Engine Integration." +msgid "ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab's Google Kubernetes Engine Integration." msgstr "" msgid "ClusterIntegration|Fetching machine types" @@ -1443,6 +1506,9 @@ msgstr "" msgid "Committed by" msgstr "" +msgid "Commit…" +msgstr "" + msgid "Compare" msgstr "" @@ -1560,6 +1626,9 @@ msgstr "" msgid "Continuous Integration and Deployment" msgstr "" +msgid "Contribute to GitLab" +msgstr "" + msgid "Contribution" msgstr "" @@ -1692,6 +1761,9 @@ msgstr "" msgid "CurrentUser|Settings" msgstr "" +msgid "Custom CI config path" +msgstr "" + msgid "Custom notification events" msgstr "" @@ -1743,6 +1815,9 @@ msgstr "" msgid "Delete" msgstr "" +msgid "Delete list" +msgstr "" + msgid "Deploy" msgid_plural "Deploys" msgstr[0] "" @@ -1955,12 +2030,18 @@ msgstr "" msgid "Edit" msgstr "" +msgid "Edit Label" +msgstr "" + msgid "Edit Pipeline Schedule %{id}" msgstr "" msgid "Edit files in the editor and commit changes here" msgstr "" +msgid "Edit identity for %{user_name}" +msgstr "" + msgid "Email" msgstr "" @@ -2177,6 +2258,9 @@ msgstr "" msgid "Failure" msgstr "" +msgid "Faster as it re-uses the project workspace (falling back to clone if it doesn't exist)" +msgstr "" + msgid "Feb" msgstr "" @@ -2210,6 +2294,15 @@ msgstr "" msgid "FirstPushedBy|pushed by" msgstr "" +msgid "For internal projects, any logged in user can view pipelines and access job details (output logs and artifacts)" +msgstr "" + +msgid "For private projects, any member (guest or higher) can view pipelines and access job details (output logs and artifacts)" +msgstr "" + +msgid "For public projects, anyone can view pipelines and access job details (output logs and artifacts)" +msgstr "" + msgid "Fork" msgid_plural "Forks" msgstr[0] "" @@ -2248,6 +2341,9 @@ msgstr "" msgid "General" msgstr "" +msgid "General pipelines" +msgstr "" + msgid "Generate a default set of labels" msgstr "" @@ -2260,6 +2356,9 @@ msgstr "" msgid "Git storage health information has been reset" msgstr "" +msgid "Git strategy for pipelines" +msgstr "" + msgid "Git version" msgstr "" @@ -2341,6 +2440,9 @@ msgstr "" msgid "GroupSettings|remove the share with group lock from %{ancestor_group_name}" msgstr "" +msgid "Groups can also be nested by creating %{subgroup_docs_link_start}subgroups%{subgroup_docs_link_end}." +msgstr "" + msgid "GroupsEmptyState|A group is a collection of several projects." msgstr "" @@ -2427,6 +2529,9 @@ msgstr "" msgid "I accept the|Terms of Service and Privacy Policy" msgstr "" +msgid "ID" +msgstr "" + msgid "IDE|Commit" msgstr "" @@ -2442,6 +2547,18 @@ msgstr "" msgid "IDE|Review" msgstr "" +msgid "Identifier" +msgstr "" + +msgid "Identities" +msgstr "" + +msgid "If disabled, the access level will depend on the user's permissions in the project." +msgstr "" + +msgid "If enabled" +msgstr "" + msgid "If you already have files you can push them using the %{link_to_cli} below." msgstr "" @@ -2490,6 +2607,9 @@ msgstr "" msgid "Integrations" msgstr "" +msgid "Integrations Settings" +msgstr "" + msgid "Interested parties can even contribute by pushing commits if they want to." msgstr "" @@ -2505,6 +2625,9 @@ msgstr "" msgid "Introducing Cycle Analytics" msgstr "" +msgid "Issue Board" +msgstr "" + msgid "Issue events" msgstr "" @@ -2523,6 +2646,9 @@ msgstr "" msgid "January" msgstr "" +msgid "Job" +msgstr "" + msgid "Job has been erased" msgstr "" @@ -2837,6 +2963,9 @@ msgstr "" msgid "Name new label" msgstr "" +msgid "Name your individual key via a title" +msgstr "" + msgid "Nav|Help" msgstr "" @@ -2849,6 +2978,9 @@ msgstr "" msgid "Nav|Sign out and sign in with a different account" msgstr "" +msgid "New Identity" +msgstr "" + msgid "New Issue" msgid_plural "New Issues" msgstr[0] "" @@ -2860,6 +2992,9 @@ msgstr "" msgid "New Kubernetes cluster" msgstr "" +msgid "New Label" +msgstr "" + msgid "New Pipeline Schedule" msgstr "" @@ -2878,6 +3013,9 @@ msgstr "" msgid "New group" msgstr "" +msgid "New identity" +msgstr "" + msgid "New issue" msgstr "" @@ -2887,6 +3025,9 @@ msgstr "" msgid "New merge request" msgstr "" +msgid "New pipelines will cancel older, pending pipelines on the same branch" +msgstr "" + msgid "New project" msgstr "" @@ -3076,6 +3217,9 @@ msgstr "" msgid "Options" msgstr "" +msgid "Or you can choose one of the suggested colors below" +msgstr "" + msgid "Other Labels" msgstr "" @@ -3112,12 +3256,18 @@ msgstr "" msgid "Password" msgstr "" +msgid "Paste your public SSH key, which is usually contained in the file '~/.ssh/id_rsa.pub' and begins with 'ssh-rsa'. Don't use your private SSH key." +msgstr "" + msgid "Pause" msgstr "" msgid "Pending" msgstr "" +msgid "Per job. If a job passes this threshold, it will be marked as failed" +msgstr "" + msgid "Perform advanced options such as changing path, transferring, or removing the group." msgstr "" @@ -3142,6 +3292,9 @@ msgstr "" msgid "Pipeline Schedules" msgstr "" +msgid "Pipeline triggers" +msgstr "" + msgid "PipelineCharts|Failed:" msgstr "" @@ -3298,6 +3451,9 @@ msgstr "" msgid "Please solve the reCAPTCHA" msgstr "" +msgid "Please try again" +msgstr "" + msgid "Please wait while we import the repository for you. Refresh at will." msgstr "" @@ -3553,12 +3709,18 @@ msgstr "" msgid "Protip:" msgstr "" +msgid "Provider" +msgstr "" + msgid "Public - The group and any public projects can be viewed without any authentication." msgstr "" msgid "Public - The project can be accessed without any authentication." msgstr "" +msgid "Public pipelines" +msgstr "" + msgid "Push events" msgstr "" @@ -3571,6 +3733,9 @@ msgstr "" msgid "Quick actions can be used in the issues description and comment boxes." msgstr "" +msgid "Re-deploy" +msgstr "" + msgid "Read more" msgstr "" @@ -3589,6 +3754,9 @@ msgstr "" msgid "Register and see your runners for this group." msgstr "" +msgid "Register and see your runners for this project." +msgstr "" + msgid "Registry" msgstr "" @@ -3634,6 +3802,9 @@ msgstr "" msgid "Repository" msgstr "" +msgid "Repository Settings" +msgstr "" + msgid "Repository maintenance" msgstr "" @@ -3699,6 +3870,12 @@ msgstr "" msgid "Reviewing (merge request !%{mergeRequestId})" msgstr "" +msgid "Rollback" +msgstr "" + +msgid "Runner token" +msgstr "" + msgid "Runners" msgstr "" @@ -3714,6 +3891,12 @@ msgstr "" msgid "SSH Keys" msgstr "" +msgid "SSL Verification" +msgstr "" + +msgid "Save" +msgstr "" + msgid "Save changes" msgstr "" @@ -3905,6 +4088,9 @@ msgstr "" msgid "Size and domain settings for static websites" msgstr "" +msgid "Slower but makes sure the project workspace is pristine as it clones the repository from scratch for every job" +msgstr "" + msgid "Snippets" msgstr "" @@ -4058,6 +4244,9 @@ msgstr "" msgid "Stage" msgstr "" +msgid "Stage & Commit" +msgstr "" + msgid "Stage all changes" msgstr "" @@ -4222,6 +4411,9 @@ msgstr "" msgid "Terms of Service and Privacy Policy" msgstr "" +msgid "Test coverage parsing" +msgstr "" + msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project" msgstr "" @@ -4252,6 +4444,9 @@ msgstr "" msgid "The number of failures of after which GitLab will completely prevent access to the storage. The number of failures can be reset in the admin interface: %{link_to_health_page} or using the %{api_documentation_link}." msgstr "" +msgid "The path to CI config file. Defaults to <code>.gitlab-ci.yml</code>" +msgstr "" + msgid "The phase of the development lifecycle." msgstr "" @@ -4279,6 +4474,9 @@ msgstr "" msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." msgstr "" +msgid "The secure token used by the Runner to checkout the project" +msgstr "" + msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time." msgstr "" @@ -4303,6 +4501,9 @@ msgstr "" msgid "There are no issues to show" msgstr "" +msgid "There are no labels yet" +msgstr "" + msgid "There are no merge requests to show" msgstr "" @@ -4417,6 +4618,9 @@ msgstr "" msgid "This source diff could not be displayed because it is too large." msgstr "" +msgid "This user has no identities" +msgstr "" + msgid "Time before an issue gets scheduled" msgstr "" @@ -4573,6 +4777,9 @@ msgstr "" msgid "Timeago|right now" msgstr "" +msgid "Timeout" +msgstr "" + msgid "Time|hr" msgid_plural "Time|hrs" msgstr[0] "" @@ -4592,6 +4799,9 @@ msgstr "" msgid "To GitLab" msgstr "" +msgid "To add an SSH key you need to %{generate_link_start}generate one%{link_end} or use an %{existing_link_start}existing key%{link_end}." +msgstr "" + msgid "To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import." msgstr "" @@ -4643,6 +4853,9 @@ msgstr "" msgid "Trigger this manual action" msgstr "" +msgid "Triggers can force a specific branch or tag to get rebuilt with an API call. These tokens will impersonate their associated user including their access to projects and their project permissions." +msgstr "" + msgid "Try again" msgstr "" @@ -4691,6 +4904,11 @@ msgstr "" msgid "Up to date" msgstr "" +msgid "Update %{files}" +msgid_plural "Update %{files} files" +msgstr[0] "" +msgstr[1] "" + msgid "Update your group name, description, avatar, and other general settings." msgstr "" @@ -4724,6 +4942,9 @@ msgstr "" msgid "User and IP Rate Limits" msgstr "" +msgid "Users" +msgstr "" + msgid "Variables" msgstr "" @@ -4940,9 +5161,6 @@ msgstr "" msgid "Withdraw Access Request" msgstr "" -msgid "Write a commit message..." -msgstr "" - msgid "Yes" msgstr "" diff --git a/package.json b/package.json index c42bbbb0351..6980416503e 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "d3-shape": "^1.2.0", "d3-time": "^1.0.8", "d3-time-format": "^2.1.1", + "dateformat": "^3.0.3", "deckar01-task_list": "^2.0.0", "diff": "^3.4.0", "document-register-element": "1.3.0", diff --git a/qa/qa/page/project/settings/ci_cd.rb b/qa/qa/page/project/settings/ci_cd.rb index 1466bc2e0bf..0f739f61db9 100644 --- a/qa/qa/page/project/settings/ci_cd.rb +++ b/qa/qa/page/project/settings/ci_cd.rb @@ -16,7 +16,7 @@ module QA # rubocop:disable Naming/FileName element :domain_field, 'text_field :domain' element :enable_auto_devops_button, "%strong= s_('CICD|Enable Auto DevOps')" element :domain_input, "%strong= _('Domain')" - element :save_changes_button, "submit 'Save changes'" + element :save_changes_button, "submit _('Save changes')" end def expand_runners_settings(&block) diff --git a/spec/controllers/oauth/authorizations_controller_spec.rb b/spec/controllers/oauth/authorizations_controller_spec.rb index 149b690ff70..8c10ea53a7a 100644 --- a/spec/controllers/oauth/authorizations_controller_spec.rb +++ b/spec/controllers/oauth/authorizations_controller_spec.rb @@ -2,19 +2,12 @@ require 'spec_helper' describe Oauth::AuthorizationsController do let(:user) { create(:user) } - - let(:doorkeeper) do - Doorkeeper::Application.create( - name: "MyApp", - redirect_uri: 'http://example.com', - scopes: "") - end - + let!(:application) { create(:oauth_application, scopes: 'api read_user', redirect_uri: 'http://example.com') } let(:params) do { response_type: "code", - client_id: doorkeeper.uid, - redirect_uri: doorkeeper.redirect_uri, + client_id: application.uid, + redirect_uri: application.redirect_uri, state: 'state' } end @@ -44,7 +37,7 @@ describe Oauth::AuthorizationsController do end it 'deletes session.user_return_to and redirects when skip authorization' do - doorkeeper.update(trusted: true) + application.update(trusted: true) request.session['user_return_to'] = 'http://example.com' get :new, params @@ -52,6 +45,25 @@ describe Oauth::AuthorizationsController do expect(request.session['user_return_to']).to be_nil expect(response).to have_gitlab_http_status(302) end + + context 'when there is already an access token for the application' do + context 'when the request scope matches any of the created token scopes' do + before do + scopes = Doorkeeper::OAuth::Scopes.from_string('api') + + allow(Doorkeeper.configuration).to receive(:scopes).and_return(scopes) + + create :oauth_access_token, application: application, resource_owner_id: user.id, scopes: scopes + end + + it 'authorizes the request and redirects' do + get :new, params + + expect(request.session['user_return_to']).to be_nil + expect(response).to have_gitlab_http_status(302) + end + end + end end end end diff --git a/spec/controllers/projects/milestones_controller_spec.rb b/spec/controllers/projects/milestones_controller_spec.rb index 02b30f9bc6d..b1d83246238 100644 --- a/spec/controllers/projects/milestones_controller_spec.rb +++ b/spec/controllers/projects/milestones_controller_spec.rb @@ -124,7 +124,7 @@ describe Projects::MilestonesController do it 'shows group milestone' do post :promote, namespace_id: project.namespace.id, project_id: project.id, id: milestone.iid - expect(flash[:notice]).to eq("#{milestone.title} promoted to <a href=\"#{group_milestone_path(project.group, milestone.iid)}\">group milestone</a>.") + expect(flash[:notice]).to eq("#{milestone.title} promoted to <a href=\"#{group_milestone_path(project.group, milestone.iid)}\"><u>group milestone</u></a>.") expect(response).to redirect_to(project_milestones_path(project)) end end diff --git a/spec/features/groups/empty_states_spec.rb b/spec/features/groups/empty_states_spec.rb index 04217fec06c..5828d833ae9 100644 --- a/spec/features/groups/empty_states_spec.rb +++ b/spec/features/groups/empty_states_spec.rb @@ -59,6 +59,18 @@ feature 'Group empty states' do end end + shared_examples "no projects" do + it 'displays an empty state' do + expect(page).to have_selector('.empty-state') + end + + it "does not show a new #{issuable_name} button" do + within '.empty-state' do + expect(page).not_to have_link("create #{issuable_name}") + end + end + end + context 'group without a project' do context 'group has a subgroup', :nested_groups do let(:subgroup) { create(:group, parent: group) } @@ -92,16 +104,18 @@ feature 'Group empty states' do visit path end - it 'displays an empty state' do - expect(page).to have_selector('.empty-state') - end + it_behaves_like "no projects" + end + end - it "shows a new #{issuable_name} button" do - within '.empty-state' do - expect(page).not_to have_link("create #{issuable_name}") - end - end + context 'group has only a project with issues disabled' do + let(:project_with_issues_disabled) { create(:empty_project, :issues_disabled, group: group) } + + before do + visit path end + + it_behaves_like "no projects" end end end diff --git a/spec/features/groups/issues_spec.rb b/spec/features/groups/issues_spec.rb index 111a24c0d94..e131ded3688 100644 --- a/spec/features/groups/issues_spec.rb +++ b/spec/features/groups/issues_spec.rb @@ -5,6 +5,7 @@ feature 'Group issues page' do let(:group) { create(:group) } let(:project) { create(:project, :public, group: group)} + let(:project_with_issues_disabled) { create(:project, :issues_disabled, group: group) } let(:path) { issues_group_path(group) } context 'with shared examples' do @@ -76,4 +77,25 @@ feature 'Group issues page' do end end end + + context 'projects with issues disabled' do + describe 'issue dropdown' do + let(:user_in_group) { create(:group_member, :master, user: create(:user), group: group ).user } + + before do + [project, project_with_issues_disabled].each { |project| project.add_master(user_in_group) } + sign_in(user_in_group) + visit issues_group_path(group) + end + + it 'shows projects only with issues feature enabled', :js do + find('.new-project-item-link').click + + page.within('.select2-results') do + expect(page).to have_content(project.full_name) + expect(page).not_to have_content(project_with_issues_disabled.full_name) + end + end + end + end end diff --git a/spec/features/groups/labels/index_spec.rb b/spec/features/groups/labels/index_spec.rb new file mode 100644 index 00000000000..6c1b43a9013 --- /dev/null +++ b/spec/features/groups/labels/index_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' + +feature 'Group labels' do + let(:user) { create(:user) } + let(:group) { create(:group) } + let!(:label) { create(:group_label, group: group) } + + background do + group.add_owner(user) + sign_in(user) + visit group_labels_path(group) + end + + scenario 'label has edit button', :js do + expect(page).to have_selector('.label-action.edit') + end +end diff --git a/spec/features/groups/merge_requests_spec.rb b/spec/features/groups/merge_requests_spec.rb index 672ae785c2d..921a447f6ee 100644 --- a/spec/features/groups/merge_requests_spec.rb +++ b/spec/features/groups/merge_requests_spec.rb @@ -56,4 +56,21 @@ feature 'Group merge requests page' do expect(find('#js-dropdown-assignee .filter-dropdown')).not_to have_content(user2.name) end end + + describe 'new merge request dropdown' do + let(:project_with_merge_requests_disabled) { create(:project, :merge_requests_disabled, group: group) } + + before do + visit path + end + + it 'shows projects only with merge requests feature enabled', :js do + find('.new-project-item-link').click + + page.within('.select2-results') do + expect(page).to have_content(project.name_with_namespace) + expect(page).not_to have_content(project_with_merge_requests_disabled.name_with_namespace) + end + end + end end diff --git a/spec/features/groups/milestone_spec.rb b/spec/features/groups/milestone_spec.rb index 20337f1d3b0..2108d763028 100644 --- a/spec/features/groups/milestone_spec.rb +++ b/spec/features/groups/milestone_spec.rb @@ -107,19 +107,6 @@ feature 'Group milestones' do expect(page).to have_selector("#milestone_#{legacy_milestone.milestones.first.id}", count: 1) end - it 'updates milestone' do - page.within(".milestones #milestone_#{active_group_milestone.id}") do - click_link('Edit') - end - - page.within('.milestone-form') do - fill_in 'milestone_title', with: 'new title' - click_button('Update milestone') - end - - expect(find('#content-body h2')).to have_content('new title') - end - it 'shows milestone detail and supports its edit' do page.within(".milestones #milestone_#{active_group_milestone.id}") do click_link(active_group_milestone.title) diff --git a/spec/features/ics/dashboard_issues_spec.rb b/spec/features/ics/dashboard_issues_spec.rb index 90d02f7e40f..a4d05c25a90 100644 --- a/spec/features/ics/dashboard_issues_spec.rb +++ b/spec/features/ics/dashboard_issues_spec.rb @@ -5,6 +5,7 @@ describe 'Dashboard Issues Calendar Feed' do let!(:user) { create(:user, email: 'private1@example.com', public_email: 'public1@example.com') } let!(:assignee) { create(:user, email: 'private2@example.com', public_email: 'public2@example.com') } let!(:project) { create(:project) } + let(:milestone) { create(:milestone, project_id: project.id, title: 'v1.0') } before do project.add_master(user) @@ -14,7 +15,9 @@ describe 'Dashboard Issues Calendar Feed' do context 'with no referer' do it 'renders calendar feed' do sign_in user - visit issues_dashboard_path(:ics) + visit issues_dashboard_path(:ics, + due_date: Issue::DueNextMonthAndPreviousTwoWeeks.name, + sort: 'closest_future_date') expect(response_headers['Content-Type']).to have_content('text/calendar') expect(body).to have_text('BEGIN:VCALENDAR') @@ -25,19 +28,37 @@ describe 'Dashboard Issues Calendar Feed' do it 'renders calendar feed as text/plain' do sign_in user page.driver.header('Referer', issues_dashboard_url(host: Settings.gitlab.base_url)) - visit issues_dashboard_path(:ics) + visit issues_dashboard_path(:ics, + due_date: Issue::DueNextMonthAndPreviousTwoWeeks.name, + sort: 'closest_future_date') expect(response_headers['Content-Type']).to have_content('text/plain') expect(body).to have_text('BEGIN:VCALENDAR') end end + + context 'when filtered by milestone' do + it 'renders calendar feed' do + sign_in user + visit issues_dashboard_path(:ics, + due_date: Issue::DueNextMonthAndPreviousTwoWeeks.name, + sort: 'closest_future_date', + milestone_title: milestone.title) + + expect(response_headers['Content-Type']).to have_content('text/calendar') + expect(body).to have_text('BEGIN:VCALENDAR') + end + end end context 'when authenticated via personal access token' do it 'renders calendar feed' do personal_access_token = create(:personal_access_token, user: user) - visit issues_dashboard_path(:ics, private_token: personal_access_token.token) + visit issues_dashboard_path(:ics, + due_date: Issue::DueNextMonthAndPreviousTwoWeeks.name, + sort: 'closest_future_date', + private_token: personal_access_token.token) expect(response_headers['Content-Type']).to have_content('text/calendar') expect(body).to have_text('BEGIN:VCALENDAR') @@ -46,7 +67,10 @@ describe 'Dashboard Issues Calendar Feed' do context 'when authenticated via feed token' do it 'renders calendar feed' do - visit issues_dashboard_path(:ics, feed_token: user.feed_token) + visit issues_dashboard_path(:ics, + due_date: Issue::DueNextMonthAndPreviousTwoWeeks.name, + sort: 'closest_future_date', + feed_token: user.feed_token) expect(response_headers['Content-Type']).to have_content('text/calendar') expect(body).to have_text('BEGIN:VCALENDAR') @@ -60,7 +84,10 @@ describe 'Dashboard Issues Calendar Feed' do end it 'renders issue fields' do - visit issues_dashboard_path(:ics, feed_token: user.feed_token) + visit issues_dashboard_path(:ics, + due_date: Issue::DueNextMonthAndPreviousTwoWeeks.name, + sort: 'closest_future_date', + feed_token: user.feed_token) expect(body).to have_text("SUMMARY:test title (in #{project.full_path})") # line length for ics is 75 chars diff --git a/spec/features/milestones/user_deletes_milestone_spec.rb b/spec/features/milestones/user_deletes_milestone_spec.rb index 414702daba4..9d4a68239d3 100644 --- a/spec/features/milestones/user_deletes_milestone_spec.rb +++ b/spec/features/milestones/user_deletes_milestone_spec.rb @@ -13,6 +13,7 @@ describe "User deletes milestone", :js do end it "deletes milestone" do + click_link(milestone.title) click_button("Delete") click_button("Delete milestone") diff --git a/spec/features/projects/clusters/gcp_spec.rb b/spec/features/projects/clusters/gcp_spec.rb index c85b82b2090..3db384e5b65 100644 --- a/spec/features/projects/clusters/gcp_spec.rb +++ b/spec/features/projects/clusters/gcp_spec.rb @@ -157,6 +157,19 @@ feature 'Gcp Cluster', :js do end end + context 'when a user cannot edit the environment scope' do + before do + visit project_clusters_path(project) + + click_link 'Add Kubernetes cluster' + click_link 'Add an existing Kubernetes cluster' + end + + it 'user does not see the "Environment scope" field' do + expect(page).not_to have_css('#cluster_environment_scope') + end + end + context 'when user has not dismissed GCP signup offer' do before do visit project_clusters_path(project) diff --git a/spec/features/projects/import_export/test_project_export.tar.gz b/spec/features/projects/import_export/test_project_export.tar.gz Binary files differindex 72ab2d71f35..ceba4dfec57 100644 --- a/spec/features/projects/import_export/test_project_export.tar.gz +++ b/spec/features/projects/import_export/test_project_export.tar.gz diff --git a/spec/features/projects/milestones/new_spec.rb b/spec/features/projects/milestones/new_spec.rb index f7900210fe6..6595bff549b 100644 --- a/spec/features/projects/milestones/new_spec.rb +++ b/spec/features/projects/milestones/new_spec.rb @@ -9,9 +9,9 @@ feature 'Creating a new project milestone', :js do visit new_project_milestone_path(project) end - it 'description has autocomplete' do + it 'description has emoji autocomplete' do find('#milestone_description').native.send_keys('') - fill_in 'milestone_description', with: '@' + fill_in 'milestone_description', with: ':' expect(page).to have_selector('.atwho-view') end diff --git a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb index 706894f4b32..733e6c89de7 100644 --- a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb +++ b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb @@ -242,7 +242,7 @@ describe "User creates wiki page" do end end - it "shows the autocompletion dropdown" do + it "shows the emoji autocompletion dropdown" do click_link("New page") page.within("#modal-new-wiki") do @@ -254,7 +254,7 @@ describe "User creates wiki page" do page.within(".wiki-form") do find("#wiki_content").native.send_keys("") - fill_in(:wiki_content, with: "@") + fill_in(:wiki_content, with: ":") end expect(page).to have_selector(".atwho-view") diff --git a/spec/features/projects/wiki/user_updates_wiki_page_spec.rb b/spec/features/projects/wiki/user_updates_wiki_page_spec.rb index 272dac127dd..2ccbc15b6da 100644 --- a/spec/features/projects/wiki/user_updates_wiki_page_spec.rb +++ b/spec/features/projects/wiki/user_updates_wiki_page_spec.rb @@ -96,11 +96,11 @@ describe 'User updates wiki page' do expect(find('textarea#wiki_content').value).to eq('') end - it 'shows the autocompletion dropdown', :js do + it 'shows the emoji autocompletion dropdown', :js do click_link('Edit') find('#wiki_content').native.send_keys('') - fill_in(:wiki_content, with: '@') + fill_in(:wiki_content, with: ':') expect(page).to have_selector('.atwho-view') end diff --git a/spec/features/tags/master_creates_tag_spec.rb b/spec/features/tags/master_creates_tag_spec.rb index 8a8f6933fa5..6701f575a23 100644 --- a/spec/features/tags/master_creates_tag_spec.rb +++ b/spec/features/tags/master_creates_tag_spec.rb @@ -75,9 +75,9 @@ feature 'Master creates tag' do visit new_project_tag_path(project) end - it 'description has autocomplete', :js do + it 'description has emoji autocomplete', :js do find('#release_description').native.send_keys('') - fill_in 'release_description', with: '@' + fill_in 'release_description', with: ':' expect(page).to have_selector('.atwho-view') end diff --git a/spec/features/tags/master_updates_tag_spec.rb b/spec/features/tags/master_updates_tag_spec.rb index 1c370a99b13..26f51bee887 100644 --- a/spec/features/tags/master_updates_tag_spec.rb +++ b/spec/features/tags/master_updates_tag_spec.rb @@ -25,13 +25,13 @@ feature 'Master updates tag' do expect(page).to have_content 'Awesome release notes' end - scenario 'description has autocomplete', :js do + scenario 'description has emoji autocomplete', :js do page.within(first('.content-list .controls')) do click_link 'Edit release notes' end find('#release_description').native.send_keys('') - fill_in 'release_description', with: '@' + fill_in 'release_description', with: ':' expect(page).to have_selector('.atwho-view') end diff --git a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb index 52003bb0859..766bb4f09cd 100644 --- a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb +++ b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb @@ -1,17 +1,16 @@ require 'rails_helper' feature 'User uploads avatar to profile' do - scenario 'they see their new avatar' do - user = create(:user) - sign_in(user) + let!(:user) { create(:user) } + let(:avatar_file_path) { Rails.root.join('spec', 'fixtures', 'dk.png') } + before do + sign_in user visit profile_path - attach_file( - 'user_avatar', - Rails.root.join('spec', 'fixtures', 'dk.png'), - visible: false - ) + end + scenario 'they see their new avatar on their profile' do + attach_file('user_avatar', avatar_file_path, visible: false) click_button 'Update profile settings' visit user_path(user) @@ -21,4 +20,16 @@ feature 'User uploads avatar to profile' do # Cheating here to verify something that isn't user-facing, but is important expect(user.reload.avatar.file).to exist end + + scenario 'their new avatar is immediately visible in the header', :js do + find('.js-user-avatar-input', visible: false).set(avatar_file_path) + + click_button 'Set new profile picture' + click_button 'Update profile settings' + + wait_for_all_requests + + data_uri = find('.avatar-image .avatar')['src'] + expect(page.find('.header-user-avatar')['src']).to eq data_uri + end end diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb index c8a43ddf410..669ec602f11 100644 --- a/spec/finders/merge_requests_finder_spec.rb +++ b/spec/finders/merge_requests_finder_spec.rb @@ -19,7 +19,7 @@ describe MergeRequestsFinder do let!(:merge_request1) { create(:merge_request, :simple, author: user, source_project: project2, target_project: project1) } let!(:merge_request2) { create(:merge_request, :conflict, author: user, source_project: project2, target_project: project1, state: 'closed') } - let!(:merge_request3) { create(:merge_request, :simple, author: user, source_project: project2, target_project: project2) } + let!(:merge_request3) { create(:merge_request, :simple, author: user, source_project: project2, target_project: project2, state: 'locked') } let!(:merge_request4) { create(:merge_request, :simple, author: user, source_project: project3, target_project: project3) } let!(:merge_request5) { create(:merge_request, :simple, author: user, source_project: project4, target_project: project4) } @@ -35,7 +35,7 @@ describe MergeRequestsFinder do it 'filters by scope' do params = { scope: 'authored', state: 'opened' } merge_requests = described_class.new(user, params).execute - expect(merge_requests.size).to eq(4) + expect(merge_requests.size).to eq(3) end it 'filters by project' do @@ -90,6 +90,14 @@ describe MergeRequestsFinder do expect(merge_requests).to contain_exactly(merge_request2) end + it 'filters by state' do + params = { state: 'locked' } + + merge_requests = described_class.new(user, params).execute + + expect(merge_requests).to contain_exactly(merge_request3) + end + context 'filtering by group milestone' do let!(:group) { create(:group, :public) } let(:group_milestone) { create(:milestone, group: group) } @@ -199,7 +207,7 @@ describe MergeRequestsFinder do it 'returns the number of rows for the default state' do finder = described_class.new(user) - expect(finder.row_count).to eq(4) + expect(finder.row_count).to eq(3) end it 'returns the number of rows for a given state' do diff --git a/spec/fixtures/exported-project.gz b/spec/fixtures/exported-project.gz Binary files differdeleted file mode 100644 index bef7e2ff8ee..00000000000 --- a/spec/fixtures/exported-project.gz +++ /dev/null diff --git a/spec/graphql/types/merge_request_type_spec.rb b/spec/graphql/types/merge_request_type_spec.rb new file mode 100644 index 00000000000..6e57122867a --- /dev/null +++ b/spec/graphql/types/merge_request_type_spec.rb @@ -0,0 +1,5 @@ +require 'spec_helper' + +describe Types::MergeRequestType do + it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::MergeRequest) } +end diff --git a/spec/graphql/types/permission_types/base_permission_type_spec.rb b/spec/graphql/types/permission_types/base_permission_type_spec.rb new file mode 100644 index 00000000000..a7e51797047 --- /dev/null +++ b/spec/graphql/types/permission_types/base_permission_type_spec.rb @@ -0,0 +1,47 @@ +require 'spec_helper' + +describe Types::PermissionTypes::BasePermissionType do + let(:permitable) { double('permittable') } + let(:current_user) { build(:user) } + let(:context) { { current_user: current_user } } + subject(:test_type) do + Class.new(described_class) do + graphql_name 'TestClass' + + permission_field :do_stuff, resolve: -> (_, _, _) { true } + ability_field(:read_issue) + abilities :admin_issue + end + end + + describe '.permission_field' do + it 'adds a field for the required permission' do + is_expected.to have_graphql_field(:do_stuff) + end + end + + describe '.ability_field' do + it 'adds a field for the required permission' do + is_expected.to have_graphql_field(:read_issue) + end + + it 'does not add a resolver block if another resolving param is passed' do + expected_keywords = { + name: :resolve_using_hash, + hash_key: :the_key, + type: GraphQL::BOOLEAN_TYPE, + description: "custom description", + null: false + } + expect(test_type).to receive(:field).with(expected_keywords) + + test_type.ability_field :resolve_using_hash, hash_key: :the_key, description: "custom description" + end + end + + describe '.abilities' do + it 'adds a field for the passed permissions' do + is_expected.to have_graphql_field(:admin_issue) + end + end +end diff --git a/spec/graphql/types/permission_types/merge_request_spec.rb b/spec/graphql/types/permission_types/merge_request_spec.rb new file mode 100644 index 00000000000..e1026b01a74 --- /dev/null +++ b/spec/graphql/types/permission_types/merge_request_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +describe Types::PermissionTypes::MergeRequest do + it do + expected_permissions = [ + :read_merge_request, :admin_merge_request, :update_merge_request, + :create_note, :push_to_source_branch, :remove_source_branch, + :cherry_pick_on_current_merge_request, :revert_on_current_merge_request + ] + + expect(described_class).to have_graphql_fields(expected_permissions) + end +end diff --git a/spec/graphql/types/permission_types/merge_request_type_spec.rb b/spec/graphql/types/permission_types/merge_request_type_spec.rb new file mode 100644 index 00000000000..6e57122867a --- /dev/null +++ b/spec/graphql/types/permission_types/merge_request_type_spec.rb @@ -0,0 +1,5 @@ +require 'spec_helper' + +describe Types::MergeRequestType do + it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::MergeRequest) } +end diff --git a/spec/graphql/types/permission_types/project_spec.rb b/spec/graphql/types/permission_types/project_spec.rb new file mode 100644 index 00000000000..89eecef096e --- /dev/null +++ b/spec/graphql/types/permission_types/project_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe Types::PermissionTypes::Project do + it do + expected_permissions = [ + :change_namespace, :change_visibility_level, :rename_project, :remove_project, :archive_project, + :remove_fork_project, :remove_pages, :read_project, :create_merge_request_in, + :read_wiki, :read_project_member, :create_issue, :upload_file, :read_cycle_analytics, + :download_code, :download_wiki_code, :fork_project, :create_project_snippet, + :read_commit_status, :request_access, :create_pipeline, :create_pipeline_schedule, + :create_merge_request_from, :create_wiki, :push_code, :create_deployment, :push_to_delete_protected_branch, + :admin_wiki, :admin_project, :update_pages, :admin_remote_mirror, :create_label, + :update_wiki, :destroy_wiki, :create_pages, :destroy_pages + ] + + expect(described_class).to have_graphql_fields(expected_permissions) + end +end diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb index b4eeca2e3f1..7b5bc335511 100644 --- a/spec/graphql/types/project_type_spec.rb +++ b/spec/graphql/types/project_type_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe GitlabSchema.types['Project'] do + it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Project) } + it { expect(described_class.graphql_name).to eq('Project') } describe 'nested merge request' do diff --git a/spec/helpers/merge_requests_helper_spec.rb b/spec/helpers/merge_requests_helper_spec.rb index 3008528e60c..885204062fe 100644 --- a/spec/helpers/merge_requests_helper_spec.rb +++ b/spec/helpers/merge_requests_helper_spec.rb @@ -54,7 +54,7 @@ describe MergeRequestsHelper do let(:options) { { force_link: true } } it 'removes the data-toggle attributes' do - is_expected.not_to match(/data-toggle="tab"/) + is_expected.not_to match(/data-toggle="tabvue"/) end end end diff --git a/spec/javascripts/api_spec.js b/spec/javascripts/api_spec.js index e8435116221..54cb6d84109 100644 --- a/spec/javascripts/api_spec.js +++ b/spec/javascripts/api_spec.js @@ -242,7 +242,7 @@ describe('Api', () => { }, ]); - Api.groupProjects(groupId, query, response => { + Api.groupProjects(groupId, query, {}, response => { expect(response.length).toBe(1); expect(response[0].name).toBe('test'); done(); @@ -362,4 +362,29 @@ describe('Api', () => { .catch(done.fail); }); }); + + describe('createBranch', () => { + it('creates new branch', done => { + const ref = 'master'; + const branch = 'new-branch-name'; + const dummyProjectPath = 'gitlab-org/gitlab-ce'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${encodeURIComponent( + dummyProjectPath, + )}/repository/branches`; + + spyOn(axios, 'post').and.callThrough(); + + mock.onPost(expectedUrl).replyOnce(200, { + name: branch, + }); + + Api.createBranch(dummyProjectPath, { ref, branch }) + .then(({ data }) => { + expect(data.name).toBe(branch); + expect(axios.post).toHaveBeenCalledWith(expectedUrl, { ref, branch }); + }) + .then(done) + .catch(done.fail); + }); + }); }); diff --git a/spec/javascripts/boards/issue_card_spec.js b/spec/javascripts/boards/issue_card_spec.js index 72e961c8a10..7a32e84bced 100644 --- a/spec/javascripts/boards/issue_card_spec.js +++ b/spec/javascripts/boards/issue_card_spec.js @@ -255,7 +255,7 @@ describe('Issue card component', () => { it('renders label', () => { const nodes = []; component.$el.querySelectorAll('.badge').forEach((label) => { - nodes.push(label.title); + nodes.push(label.getAttribute('data-original-title')); }); expect( @@ -265,7 +265,7 @@ describe('Issue card component', () => { it('sets label description as title', () => { expect( - component.$el.querySelector('.badge').getAttribute('title'), + component.$el.querySelector('.badge').getAttribute('data-original-title'), ).toContain(label1.description); }); diff --git a/spec/javascripts/diffs/components/diff_line_gutter_content_spec.js b/spec/javascripts/diffs/components/diff_line_gutter_content_spec.js index cce10c4083c..2d136a63c52 100644 --- a/spec/javascripts/diffs/components/diff_line_gutter_content_spec.js +++ b/spec/javascripts/diffs/components/diff_line_gutter_content_spec.js @@ -2,12 +2,6 @@ import Vue from 'vue'; import DiffLineGutterContent from '~/diffs/components/diff_line_gutter_content.vue'; import store from '~/mr_notes/stores'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; -import { - MATCH_LINE_TYPE, - CONTEXT_LINE_TYPE, - OLD_NO_NEW_LINE_TYPE, - NEW_NO_NEW_LINE_TYPE, -} from '~/diffs/constants'; import discussionsMockData from '../mock_data/diff_discussions'; import diffFileMockData from '../mock_data/diff_file'; @@ -31,45 +25,6 @@ describe('DiffLineGutterContent', () => { }; describe('computed', () => { - describe('isMatchLine', () => { - it('should return true for match line type', () => { - const component = createComponent({ lineType: MATCH_LINE_TYPE }); - expect(component.isMatchLine).toEqual(true); - }); - - it('should return false for non-match line type', () => { - const component = createComponent({ lineType: CONTEXT_LINE_TYPE }); - expect(component.isMatchLine).toEqual(false); - }); - }); - - describe('isContextLine', () => { - it('should return true for context line type', () => { - const component = createComponent({ lineType: CONTEXT_LINE_TYPE }); - expect(component.isContextLine).toEqual(true); - }); - - it('should return false for non-context line type', () => { - const component = createComponent({ lineType: MATCH_LINE_TYPE }); - expect(component.isContextLine).toEqual(false); - }); - }); - - describe('isMetaLine', () => { - it('should return true for meta line type', () => { - const component = createComponent({ lineType: NEW_NO_NEW_LINE_TYPE }); - expect(component.isMetaLine).toEqual(true); - - const component2 = createComponent({ lineType: OLD_NO_NEW_LINE_TYPE }); - expect(component2.isMetaLine).toEqual(true); - }); - - it('should return false for non-meta line type', () => { - const component = createComponent({ lineType: MATCH_LINE_TYPE }); - expect(component.isMetaLine).toEqual(false); - }); - }); - describe('lineHref', () => { it('should prepend # to lineCode', () => { const lineCode = 'LC_42'; @@ -109,7 +64,7 @@ describe('DiffLineGutterContent', () => { describe('template', () => { it('should render three dots for context lines', () => { const component = createComponent({ - lineType: MATCH_LINE_TYPE, + isMatchLine: true, }); expect(component.$el.querySelector('span').classList.contains('context-cell')).toEqual(true); diff --git a/spec/javascripts/diffs/components/inline_diff_view_spec.js b/spec/javascripts/diffs/components/inline_diff_view_spec.js index 0d5a3576204..e1adf60962e 100644 --- a/spec/javascripts/diffs/components/inline_diff_view_spec.js +++ b/spec/javascripts/diffs/components/inline_diff_view_spec.js @@ -1,7 +1,6 @@ import Vue from 'vue'; import InlineDiffView from '~/diffs/components/inline_diff_view.vue'; import store from '~/mr_notes/stores'; -import * as constants from '~/diffs/constants'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import diffFileMockData from '../mock_data/diff_file'; import discussionsMockData from '../mock_data/diff_discussions'; @@ -14,58 +13,13 @@ describe('InlineDiffView', () => { beforeEach(() => { const diffFile = getDiffFileMock(); + store.dispatch('setInlineDiffViewType'); component = createComponentWithStore(Vue.extend(InlineDiffView), store, { diffFile, diffLines: diffFile.highlightedDiffLines, }).$mount(); }); - describe('methods', () => { - describe('handleMouse', () => { - it('should set hoveredLineCode', () => { - expect(component.hoveredLineCode).toEqual(null); - - component.handleMouse('lineCode1', true); - expect(component.hoveredLineCode).toEqual('lineCode1'); - - component.handleMouse('lineCode1', false); - expect(component.hoveredLineCode).toEqual(null); - }); - }); - - describe('getLineClass', () => { - it('should return line class object', () => { - const { LINE_HOVER_CLASS_NAME, LINE_UNFOLD_CLASS_NAME } = constants; - const { MATCH_LINE_TYPE, NEW_LINE_TYPE } = constants; - - expect(component.getLineClass(component.diffLines[0])).toEqual({ - [NEW_LINE_TYPE]: NEW_LINE_TYPE, - [LINE_UNFOLD_CLASS_NAME]: false, - [LINE_HOVER_CLASS_NAME]: false, - }); - - component.handleMouse(component.diffLines[0].lineCode, true); - Object.defineProperty(component, 'isLoggedIn', { - get() { - return true; - }, - }); - - expect(component.getLineClass(component.diffLines[0])).toEqual({ - [NEW_LINE_TYPE]: NEW_LINE_TYPE, - [LINE_UNFOLD_CLASS_NAME]: false, - [LINE_HOVER_CLASS_NAME]: true, - }); - - expect(component.getLineClass(component.diffLines[5])).toEqual({ - [MATCH_LINE_TYPE]: MATCH_LINE_TYPE, - [LINE_UNFOLD_CLASS_NAME]: true, - [LINE_HOVER_CLASS_NAME]: false, - }); - }); - }); - }); - describe('template', () => { it('should have rendered diff lines', () => { const el = component.$el; @@ -89,23 +43,5 @@ describe('InlineDiffView', () => { done(); }); }); - - it('should render new discussion forms', done => { - const el = component.$el; - const lines = getDiffFileMock().highlightedDiffLines; - - component.handleShowCommentForm({ lineCode: lines[0].lineCode }); - component.handleShowCommentForm({ lineCode: lines[1].lineCode }); - - Vue.nextTick(() => { - expect(el.querySelectorAll('.js-vue-markdown-field').length).toEqual(2); - expect(el.querySelectorAll('tr')[1].classList.contains('notes_holder')).toEqual(true); - expect(el.querySelectorAll('tr')[3].classList.contains('notes_holder')).toEqual(true); - - store.state.diffs.diffLineCommentForms = {}; - - done(); - }); - }); }); }); diff --git a/spec/javascripts/diffs/components/parallel_diff_view_spec.js b/spec/javascripts/diffs/components/parallel_diff_view_spec.js index cab533217c0..165e4b69b6c 100644 --- a/spec/javascripts/diffs/components/parallel_diff_view_spec.js +++ b/spec/javascripts/diffs/components/parallel_diff_view_spec.js @@ -4,12 +4,10 @@ import store from '~/mr_notes/stores'; import * as constants from '~/diffs/constants'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import diffFileMockData from '../mock_data/diff_file'; -import discussionsMockData from '../mock_data/diff_discussions'; describe('ParallelDiffView', () => { let component; const getDiffFileMock = () => Object.assign({}, diffFileMockData); - const getDiscussionsMockData = () => [Object.assign({}, discussionsMockData)]; beforeEach(() => { const diffFile = getDiffFileMock(); @@ -28,197 +26,4 @@ describe('ParallelDiffView', () => { }); }); }); - - describe('methods', () => { - describe('hasDiscussion', () => { - it('it should return true if there is a discussion either for left or right section', () => { - Object.defineProperty(component, 'discussionsByLineCode', { - get() { - return { line_42: true }; - }, - }); - - expect(component.hasDiscussion({ left: {}, right: {} })).toEqual(undefined); - expect(component.hasDiscussion({ left: { lineCode: 'line_42' }, right: {} })).toEqual(true); - expect(component.hasDiscussion({ left: {}, right: { lineCode: 'line_42' } })).toEqual(true); - }); - }); - - describe('getClassName', () => { - it('should return line class object', () => { - const { LINE_HOVER_CLASS_NAME, LINE_UNFOLD_CLASS_NAME } = constants; - const { MATCH_LINE_TYPE, NEW_LINE_TYPE, LINE_POSITION_RIGHT } = constants; - - expect(component.getClassName(component.diffLines[1], LINE_POSITION_RIGHT)).toEqual({ - [NEW_LINE_TYPE]: NEW_LINE_TYPE, - [LINE_UNFOLD_CLASS_NAME]: false, - [LINE_HOVER_CLASS_NAME]: false, - }); - - const eventMock = { - target: component.$refs.rightLines[1], - }; - - component.handleMouse(eventMock, component.diffLines[1], true); - Object.defineProperty(component, 'isLoggedIn', { - get() { - return true; - }, - }); - - expect(component.getClassName(component.diffLines[1], LINE_POSITION_RIGHT)).toEqual({ - [NEW_LINE_TYPE]: NEW_LINE_TYPE, - [LINE_UNFOLD_CLASS_NAME]: false, - [LINE_HOVER_CLASS_NAME]: true, - }); - - expect(component.getClassName(component.diffLines[5], LINE_POSITION_RIGHT)).toEqual({ - [MATCH_LINE_TYPE]: MATCH_LINE_TYPE, - [LINE_UNFOLD_CLASS_NAME]: true, - [LINE_HOVER_CLASS_NAME]: false, - }); - }); - }); - - describe('handleMouse', () => { - it('should set hovered line code and line section to null when isHover is false', () => { - const rightLineEventMock = { target: component.$refs.rightLines[1] }; - expect(component.hoveredLineCode).toEqual(null); - expect(component.hoveredSection).toEqual(null); - - component.handleMouse(rightLineEventMock, null, false); - expect(component.hoveredLineCode).toEqual(null); - expect(component.hoveredSection).toEqual(null); - }); - - it('should set hovered line code and line section for right section', () => { - const rightLineEventMock = { target: component.$refs.rightLines[1] }; - component.handleMouse(rightLineEventMock, component.diffLines[1], true); - expect(component.hoveredLineCode).toEqual(component.diffLines[1].right.lineCode); - expect(component.hoveredSection).toEqual(constants.LINE_POSITION_RIGHT); - }); - - it('should set hovered line code and line section for left section', () => { - const leftLineEventMock = { target: component.$refs.leftLines[2] }; - component.handleMouse(leftLineEventMock, component.diffLines[2], true); - expect(component.hoveredLineCode).toEqual(component.diffLines[2].left.lineCode); - expect(component.hoveredSection).toEqual(constants.LINE_POSITION_LEFT); - }); - }); - - describe('shouldRenderDiscussions', () => { - it('should return true if there is a discussion on left side and it is expanded', () => { - const line = { left: { lineCode: 'lineCode1' } }; - spyOn(component, 'isDiscussionExpanded').and.returnValue(true); - Object.defineProperty(component, 'discussionsByLineCode', { - get() { - return { - [line.left.lineCode]: true, - }; - }, - }); - - expect(component.shouldRenderDiscussions(line, constants.LINE_POSITION_LEFT)).toEqual(true); - expect(component.isDiscussionExpanded).toHaveBeenCalledWith(line.left.lineCode); - }); - - it('should return false if there is a discussion on left side but it is collapsed', () => { - const line = { left: { lineCode: 'lineCode1' } }; - spyOn(component, 'isDiscussionExpanded').and.returnValue(false); - Object.defineProperty(component, 'discussionsByLineCode', { - get() { - return { - [line.left.lineCode]: true, - }; - }, - }); - - expect(component.shouldRenderDiscussions(line, constants.LINE_POSITION_LEFT)).toEqual( - false, - ); - }); - - it('should return false for discussions on the right side if there is no line type', () => { - const CUSTOM_RIGHT_LINE_TYPE = 'CUSTOM_RIGHT_LINE_TYPE'; - const line = { right: { lineCode: 'lineCode1', type: CUSTOM_RIGHT_LINE_TYPE } }; - spyOn(component, 'isDiscussionExpanded').and.returnValue(true); - Object.defineProperty(component, 'discussionsByLineCode', { - get() { - return { - [line.right.lineCode]: true, - }; - }, - }); - - expect(component.shouldRenderDiscussions(line, constants.LINE_POSITION_RIGHT)).toEqual( - CUSTOM_RIGHT_LINE_TYPE, - ); - }); - }); - - describe('hasAnyExpandedDiscussion', () => { - const LINE_CODE_LEFT = 'LINE_CODE_LEFT'; - const LINE_CODE_RIGHT = 'LINE_CODE_RIGHT'; - - it('should return true if there is a discussion either on the left or the right side', () => { - const mockLineOne = { - right: { lineCode: LINE_CODE_RIGHT }, - left: {}, - }; - const mockLineTwo = { - left: { lineCode: LINE_CODE_LEFT }, - right: {}, - }; - - spyOn(component, 'isDiscussionExpanded').and.callFake(lc => lc === LINE_CODE_RIGHT); - expect(component.hasAnyExpandedDiscussion(mockLineOne)).toEqual(true); - expect(component.hasAnyExpandedDiscussion(mockLineTwo)).toEqual(false); - }); - }); - }); - - describe('template', () => { - it('should have rendered diff lines', () => { - const el = component.$el; - - expect(el.querySelectorAll('tr.line_holder.parallel').length).toEqual(6); - expect(el.querySelectorAll('td.empty-cell').length).toEqual(4); - expect(el.querySelectorAll('td.line_content.parallel.right-side').length).toEqual(6); - expect(el.querySelectorAll('td.line_content.parallel.left-side').length).toEqual(6); - expect(el.querySelectorAll('td.match').length).toEqual(4); - expect(el.textContent.indexOf('Bad dates') > -1).toEqual(true); - }); - - it('should render discussions', done => { - const el = component.$el; - component.$store.dispatch('setInitialNotes', getDiscussionsMockData()); - - Vue.nextTick(() => { - expect(el.querySelectorAll('.notes_holder').length).toEqual(1); - expect(el.querySelectorAll('.notes_holder .note-discussion li').length).toEqual(5); - expect(el.innerText.indexOf('comment 5') > -1).toEqual(true); - component.$store.dispatch('setInitialNotes', []); - - done(); - }); - }); - - it('should render new discussion forms', done => { - const el = component.$el; - const lines = getDiffFileMock().parallelDiffLines; - - component.handleShowCommentForm({ lineCode: lines[0].lineCode }); - component.handleShowCommentForm({ lineCode: lines[1].lineCode }); - - Vue.nextTick(() => { - expect(el.querySelectorAll('.js-vue-markdown-field').length).toEqual(2); - expect(el.querySelectorAll('tr')[1].classList.contains('notes_holder')).toEqual(true); - expect(el.querySelectorAll('tr')[3].classList.contains('notes_holder')).toEqual(true); - - store.state.diffs.diffLineCommentForms = {}; - - done(); - }); - }); - }); }); diff --git a/spec/javascripts/diffs/store/actions_spec.js b/spec/javascripts/diffs/store/actions_spec.js index f0bd098f698..6829c1e956a 100644 --- a/spec/javascripts/diffs/store/actions_spec.js +++ b/spec/javascripts/diffs/store/actions_spec.js @@ -5,7 +5,6 @@ import { INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE, } from '~/diffs/constants'; -import store from '~/diffs/store'; import * as actions from '~/diffs/store/actions'; import * as types from '~/diffs/store/mutation_types'; import axios from '~/lib/utils/axios_utils'; @@ -28,22 +27,6 @@ describe('DiffsStoreActions', () => { }); }); - describe('setLoadingState', () => { - it('should set loading state', done => { - expect(store.state.diffs.isLoading).toEqual(true); - const loadingState = false; - - testAction( - actions.setLoadingState, - loadingState, - {}, - [{ type: types.SET_LOADING, payload: loadingState }], - [], - done, - ); - }); - }); - describe('fetchDiffFiles', () => { it('should fetch diff files', done => { const endpoint = '/fetch/diff/files'; diff --git a/spec/javascripts/helpers/init_vue_mr_page_helper.js b/spec/javascripts/helpers/init_vue_mr_page_helper.js index 05c6d587e9c..fc4288eb15b 100644 --- a/spec/javascripts/helpers/init_vue_mr_page_helper.js +++ b/spec/javascripts/helpers/init_vue_mr_page_helper.js @@ -5,12 +5,16 @@ import { userDataMock, notesDataMock, noteableDataMock } from '../notes/mock_dat import diffFileMockData from '../diffs/mock_data/diff_file'; export default function initVueMRPage() { + const mrTestEl = document.createElement('div'); + mrTestEl.className = 'js-merge-request-test'; + document.body.appendChild(mrTestEl); + const diffsAppEndpoint = '/diffs/app/endpoint'; const diffsAppProjectPath = 'testproject'; const mrEl = document.createElement('div'); mrEl.className = 'merge-request fixture-mr'; mrEl.setAttribute('data-mr-action', 'diffs'); - document.body.appendChild(mrEl); + mrTestEl.appendChild(mrEl); const mrDiscussionsEl = document.createElement('div'); mrDiscussionsEl.id = 'js-vue-mr-discussions'; @@ -18,18 +22,18 @@ export default function initVueMRPage() { mrDiscussionsEl.setAttribute('data-noteable-data', JSON.stringify(noteableDataMock)); mrDiscussionsEl.setAttribute('data-notes-data', JSON.stringify(notesDataMock)); mrDiscussionsEl.setAttribute('data-noteable-type', 'merge-request'); - document.body.appendChild(mrDiscussionsEl); + mrTestEl.appendChild(mrDiscussionsEl); const discussionCounterEl = document.createElement('div'); discussionCounterEl.id = 'js-vue-discussion-counter'; - document.body.appendChild(discussionCounterEl); + mrTestEl.appendChild(discussionCounterEl); const diffsAppEl = document.createElement('div'); diffsAppEl.id = 'js-diffs-app'; diffsAppEl.setAttribute('data-endpoint', diffsAppEndpoint); diffsAppEl.setAttribute('data-project-path', diffsAppProjectPath); diffsAppEl.setAttribute('data-current-user-data', JSON.stringify(userDataMock)); - document.body.appendChild(diffsAppEl); + mrTestEl.appendChild(diffsAppEl); const mock = new MockAdapter(axios); mock.onGet(diffsAppEndpoint).reply(200, { diff --git a/spec/javascripts/ide/components/commit_sidebar/form_spec.js b/spec/javascripts/ide/components/commit_sidebar/form_spec.js index 8b47a365582..b7a7afe4db4 100644 --- a/spec/javascripts/ide/components/commit_sidebar/form_spec.js +++ b/spec/javascripts/ide/components/commit_sidebar/form_spec.js @@ -16,6 +16,7 @@ describe('IDE commit form', () => { store.state.changedFiles.push('test'); store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; Vue.set(store.state.projects, 'abcproject', { ...projectData }); vm = createComponentWithStore(Component, store).$mount(); @@ -146,4 +147,16 @@ describe('IDE commit form', () => { }); }); }); + + describe('commitButtonText', () => { + it('returns commit text when staged files exist', () => { + vm.$store.state.stagedFiles.push('testing'); + + expect(vm.commitButtonText).toBe('Commit'); + }); + + it('returns stage & commit text when staged files do not exist', () => { + expect(vm.commitButtonText).toBe('Stage & Commit'); + }); + }); }); diff --git a/spec/javascripts/ide/components/commit_sidebar/message_field_spec.js b/spec/javascripts/ide/components/commit_sidebar/message_field_spec.js index d62d58101d6..942cc19f46d 100644 --- a/spec/javascripts/ide/components/commit_sidebar/message_field_spec.js +++ b/spec/javascripts/ide/components/commit_sidebar/message_field_spec.js @@ -13,6 +13,7 @@ describe('IDE commit message field', () => { Component, { text: '', + placeholder: 'testing', }, '#app', ); diff --git a/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js b/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js index 21bfe4be52f..ffc2a4c9ddb 100644 --- a/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js +++ b/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js @@ -114,4 +114,19 @@ describe('IDE commit sidebar radio group', () => { }); }); }); + + describe('tooltipTitle', () => { + it('returns title when disabled', () => { + vm.title = 'test title'; + vm.disabled = true; + + expect(vm.tooltipTitle).toBe('test title'); + }); + + it('returns blank when not disabled', () => { + vm.title = 'test title'; + + expect(vm.tooltipTitle).not.toBe('test title'); + }); + }); }); diff --git a/spec/javascripts/ide/components/error_message_spec.js b/spec/javascripts/ide/components/error_message_spec.js new file mode 100644 index 00000000000..430e8e2baa3 --- /dev/null +++ b/spec/javascripts/ide/components/error_message_spec.js @@ -0,0 +1,106 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import ErrorMessage from '~/ide/components/error_message.vue'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { resetStore } from '../helpers'; + +describe('IDE error message component', () => { + const Component = Vue.extend(ErrorMessage); + let vm; + + beforeEach(() => { + vm = createComponentWithStore(Component, store, { + message: { + text: 'error message', + action: null, + actionText: null, + }, + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + resetStore(vm.$store); + }); + + it('renders error message', () => { + expect(vm.$el.textContent).toContain('error message'); + }); + + it('clears error message on click', () => { + spyOn(vm, 'setErrorMessage'); + + vm.$el.click(); + + expect(vm.setErrorMessage).toHaveBeenCalledWith(null); + }); + + describe('with action', () => { + let actionSpy; + + beforeEach(done => { + actionSpy = jasmine.createSpy('action').and.returnValue(Promise.resolve()); + + vm.message.action = actionSpy; + vm.message.actionText = 'test action'; + vm.message.actionPayload = 'testActionPayload'; + + vm.$nextTick(done); + }); + + it('renders action button', () => { + expect(vm.$el.querySelector('.flash-action')).not.toBe(null); + expect(vm.$el.textContent).toContain('test action'); + }); + + it('does not clear error message on click', () => { + spyOn(vm, 'setErrorMessage'); + + vm.$el.click(); + + expect(vm.setErrorMessage).not.toHaveBeenCalled(); + }); + + it('dispatches action', done => { + vm.$el.querySelector('.flash-action').click(); + + vm.$nextTick(() => { + expect(actionSpy).toHaveBeenCalledWith('testActionPayload'); + + done(); + }); + }); + + it('does not dispatch action when already loading', () => { + vm.isLoading = true; + + vm.$el.querySelector('.flash-action').click(); + + expect(actionSpy).not.toHaveBeenCalledWith(); + }); + + it('resets isLoading after click', done => { + vm.$el.querySelector('.flash-action').click(); + + expect(vm.isLoading).toBe(true); + + vm.$nextTick(() => { + expect(vm.isLoading).toBe(false); + + done(); + }); + }); + + it('shows loading icon when isLoading is true', done => { + expect(vm.$el.querySelector('.loading-container').style.display).not.toBe(''); + + vm.isLoading = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.loading-container').style.display).toBe(''); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_spec.js b/spec/javascripts/ide/components/ide_spec.js index 045a60e56a0..708c9fe69af 100644 --- a/spec/javascripts/ide/components/ide_spec.js +++ b/spec/javascripts/ide/components/ide_spec.js @@ -114,4 +114,18 @@ describe('ide component', () => { expect(vm.mousetrapStopCallback(null, document.querySelector('.inputarea'), 't')).toBe(true); }); }); + + it('shows error message when set', done => { + expect(vm.$el.querySelector('.flash-container')).toBe(null); + + vm.$store.state.errorMessage = { + text: 'error', + }; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.flash-container')).not.toBe(null); + + done(); + }); + }); }); diff --git a/spec/javascripts/ide/components/repo_commit_section_spec.js b/spec/javascripts/ide/components/repo_commit_section_spec.js index 6bf309fb4bf..30cd92b2ca4 100644 --- a/spec/javascripts/ide/components/repo_commit_section_spec.js +++ b/spec/javascripts/ide/components/repo_commit_section_spec.js @@ -1,6 +1,5 @@ import Vue from 'vue'; import store from '~/ide/stores'; -import service from '~/ide/services'; import router from '~/ide/ide_router'; import repoCommitSection from '~/ide/components/repo_commit_section.vue'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; @@ -68,23 +67,6 @@ describe('RepoCommitSection', () => { vm.$mount(); - spyOn(service, 'getTreeData').and.returnValue( - Promise.resolve({ - headers: { - 'page-title': 'test', - }, - json: () => - Promise.resolve({ - last_commit_path: 'last_commit_path', - parent_tree_url: 'parent_tree_url', - path: '/', - trees: [{ name: 'tree' }], - blobs: [{ name: 'blob' }], - submodules: [{ name: 'submodule' }], - }), - }), - ); - Vue.nextTick(done); }); diff --git a/spec/javascripts/ide/mock_data.js b/spec/javascripts/ide/mock_data.js index dd87a43f370..80bf664d491 100644 --- a/spec/javascripts/ide/mock_data.js +++ b/spec/javascripts/ide/mock_data.js @@ -8,6 +8,7 @@ export const projectData = { branches: { master: { treeId: 'abcproject/master', + can_push: true, }, }, mergeRequests: {}, diff --git a/spec/javascripts/ide/stores/actions/file_spec.js b/spec/javascripts/ide/stores/actions/file_spec.js index 5746683917e..58d3ffc6d94 100644 --- a/spec/javascripts/ide/stores/actions/file_spec.js +++ b/spec/javascripts/ide/stores/actions/file_spec.js @@ -1,4 +1,6 @@ import Vue from 'vue'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; import store from '~/ide/stores'; import * as actions from '~/ide/stores/actions/file'; import * as types from '~/ide/stores/mutation_types'; @@ -9,11 +11,16 @@ import { file, resetStore } from '../../helpers'; import testAction from '../../../helpers/vuex_action_helper'; describe('IDE store file actions', () => { + let mock; + beforeEach(() => { + mock = new MockAdapter(axios); + spyOn(router, 'push'); }); afterEach(() => { + mock.restore(); resetStore(store); }); @@ -183,94 +190,125 @@ describe('IDE store file actions', () => { let localFile; beforeEach(() => { - spyOn(service, 'getFileData').and.returnValue( - Promise.resolve({ - headers: { - 'page-title': 'testing getFileData', - }, - json: () => - Promise.resolve({ - blame_path: 'blame_path', - commits_path: 'commits_path', - permalink: 'permalink', - raw_path: 'raw_path', - binary: false, - html: '123', - render_error: '', - }), - }), - ); + spyOn(service, 'getFileData').and.callThrough(); localFile = file(`newCreate-${Math.random()}`); - localFile.url = 'getFileDataURL'; + localFile.url = `${gl.TEST_HOST}/getFileDataURL`; store.state.entries[localFile.path] = localFile; }); - it('calls the service', done => { - store - .dispatch('getFileData', { path: localFile.path }) - .then(() => { - expect(service.getFileData).toHaveBeenCalledWith('getFileDataURL'); + describe('success', () => { + beforeEach(() => { + mock.onGet(`${gl.TEST_HOST}/getFileDataURL`).replyOnce( + 200, + { + blame_path: 'blame_path', + commits_path: 'commits_path', + permalink: 'permalink', + raw_path: 'raw_path', + binary: false, + html: '123', + render_error: '', + }, + { + 'page-title': 'testing getFileData', + }, + ); + }); - done(); - }) - .catch(done.fail); - }); + it('calls the service', done => { + store + .dispatch('getFileData', { path: localFile.path }) + .then(() => { + expect(service.getFileData).toHaveBeenCalledWith(`${gl.TEST_HOST}/getFileDataURL`); - it('sets the file data', done => { - store - .dispatch('getFileData', { path: localFile.path }) - .then(() => { - expect(localFile.blamePath).toBe('blame_path'); + done(); + }) + .catch(done.fail); + }); - done(); - }) - .catch(done.fail); - }); + it('sets the file data', done => { + store + .dispatch('getFileData', { path: localFile.path }) + .then(() => { + expect(localFile.blamePath).toBe('blame_path'); - it('sets document title', done => { - store - .dispatch('getFileData', { path: localFile.path }) - .then(() => { - expect(document.title).toBe('testing getFileData'); + done(); + }) + .catch(done.fail); + }); - done(); - }) - .catch(done.fail); - }); + it('sets document title', done => { + store + .dispatch('getFileData', { path: localFile.path }) + .then(() => { + expect(document.title).toBe('testing getFileData'); - it('sets the file as active', done => { - store - .dispatch('getFileData', { path: localFile.path }) - .then(() => { - expect(localFile.active).toBeTruthy(); + done(); + }) + .catch(done.fail); + }); - done(); - }) - .catch(done.fail); - }); + it('sets the file as active', done => { + store + .dispatch('getFileData', { path: localFile.path }) + .then(() => { + expect(localFile.active).toBeTruthy(); - it('sets the file not as active if we pass makeFileActive false', done => { - store - .dispatch('getFileData', { path: localFile.path, makeFileActive: false }) - .then(() => { - expect(localFile.active).toBeFalsy(); + done(); + }) + .catch(done.fail); + }); - done(); - }) - .catch(done.fail); + it('sets the file not as active if we pass makeFileActive false', done => { + store + .dispatch('getFileData', { path: localFile.path, makeFileActive: false }) + .then(() => { + expect(localFile.active).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + + it('adds the file to open files', done => { + store + .dispatch('getFileData', { path: localFile.path }) + .then(() => { + expect(store.state.openFiles.length).toBe(1); + expect(store.state.openFiles[0].name).toBe(localFile.name); + + done(); + }) + .catch(done.fail); + }); }); - it('adds the file to open files', done => { - store - .dispatch('getFileData', { path: localFile.path }) - .then(() => { - expect(store.state.openFiles.length).toBe(1); - expect(store.state.openFiles[0].name).toBe(localFile.name); + describe('error', () => { + beforeEach(() => { + mock.onGet(`${gl.TEST_HOST}/getFileDataURL`).networkError(); + }); - done(); - }) - .catch(done.fail); + it('dispatches error action', done => { + const dispatch = jasmine.createSpy('dispatch'); + + actions + .getFileData({ state: store.state, commit() {}, dispatch }, { path: localFile.path }) + .then(() => { + expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { + text: 'An error occured whilst loading the file.', + action: jasmine.any(Function), + actionText: 'Please try again', + actionPayload: { + path: localFile.path, + makeFileActive: true, + }, + }); + + done(); + }) + .catch(done.fail); + }); }); }); @@ -278,48 +316,84 @@ describe('IDE store file actions', () => { let tmpFile; beforeEach(() => { - spyOn(service, 'getRawFileData').and.returnValue(Promise.resolve('raw')); + spyOn(service, 'getRawFileData').and.callThrough(); tmpFile = file('tmpFile'); store.state.entries[tmpFile.path] = tmpFile; }); - it('calls getRawFileData service method', done => { - store - .dispatch('getRawFileData', { path: tmpFile.path }) - .then(() => { - expect(service.getRawFileData).toHaveBeenCalledWith(tmpFile); + describe('success', () => { + beforeEach(() => { + mock.onGet(/(.*)/).replyOnce(200, 'raw'); + }); - done(); - }) - .catch(done.fail); - }); + it('calls getRawFileData service method', done => { + store + .dispatch('getRawFileData', { path: tmpFile.path }) + .then(() => { + expect(service.getRawFileData).toHaveBeenCalledWith(tmpFile); - it('updates file raw data', done => { - store - .dispatch('getRawFileData', { path: tmpFile.path }) - .then(() => { - expect(tmpFile.raw).toBe('raw'); + done(); + }) + .catch(done.fail); + }); - done(); - }) - .catch(done.fail); - }); + it('updates file raw data', done => { + store + .dispatch('getRawFileData', { path: tmpFile.path }) + .then(() => { + expect(tmpFile.raw).toBe('raw'); - it('calls also getBaseRawFileData service method', done => { - spyOn(service, 'getBaseRawFileData').and.returnValue(Promise.resolve('baseraw')); + done(); + }) + .catch(done.fail); + }); - tmpFile.mrChange = { new_file: false }; + it('calls also getBaseRawFileData service method', done => { + spyOn(service, 'getBaseRawFileData').and.returnValue(Promise.resolve('baseraw')); - store - .dispatch('getRawFileData', { path: tmpFile.path, baseSha: 'SHA' }) - .then(() => { - expect(service.getBaseRawFileData).toHaveBeenCalledWith(tmpFile, 'SHA'); - expect(tmpFile.baseRaw).toBe('baseraw'); + tmpFile.mrChange = { new_file: false }; - done(); - }) - .catch(done.fail); + store + .dispatch('getRawFileData', { path: tmpFile.path, baseSha: 'SHA' }) + .then(() => { + expect(service.getBaseRawFileData).toHaveBeenCalledWith(tmpFile, 'SHA'); + expect(tmpFile.baseRaw).toBe('baseraw'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onGet(/(.*)/).networkError(); + }); + + it('dispatches error action', done => { + const dispatch = jasmine.createSpy('dispatch'); + + actions + .getRawFileData( + { state: store.state, commit() {}, dispatch }, + { path: tmpFile.path, baseSha: tmpFile.baseSha }, + ) + .then(done.fail) + .catch(() => { + expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { + text: 'An error occured whilst loading the file content.', + action: jasmine.any(Function), + actionText: 'Please try again', + actionPayload: { + path: tmpFile.path, + baseSha: tmpFile.baseSha, + }, + }); + + done(); + }); + }); }); }); diff --git a/spec/javascripts/ide/stores/actions/merge_request_spec.js b/spec/javascripts/ide/stores/actions/merge_request_spec.js index b4ec4a0b173..c99ccc70c6a 100644 --- a/spec/javascripts/ide/stores/actions/merge_request_spec.js +++ b/spec/javascripts/ide/stores/actions/merge_request_spec.js @@ -1,110 +1,239 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; import store from '~/ide/stores'; +import { + getMergeRequestData, + getMergeRequestChanges, + getMergeRequestVersions, +} from '~/ide/stores/actions/merge_request'; import service from '~/ide/services'; import { resetStore } from '../../helpers'; describe('IDE store merge request actions', () => { + let mock; + beforeEach(() => { + mock = new MockAdapter(axios); + store.state.projects.abcproject = { mergeRequests: {}, }; }); afterEach(() => { + mock.restore(); resetStore(store); }); describe('getMergeRequestData', () => { - beforeEach(() => { - spyOn(service, 'getProjectMergeRequestData').and.returnValue( - Promise.resolve({ data: { title: 'mergerequest' } }), - ); + describe('success', () => { + beforeEach(() => { + spyOn(service, 'getProjectMergeRequestData').and.callThrough(); + + mock + .onGet(/api\/(.*)\/projects\/abcproject\/merge_requests\/1/) + .reply(200, { title: 'mergerequest' }); + }); + + it('calls getProjectMergeRequestData service method', done => { + store + .dispatch('getMergeRequestData', { projectId: 'abcproject', mergeRequestId: 1 }) + .then(() => { + expect(service.getProjectMergeRequestData).toHaveBeenCalledWith('abcproject', 1); + + done(); + }) + .catch(done.fail); + }); + + it('sets the Merge Request Object', done => { + store + .dispatch('getMergeRequestData', { projectId: 'abcproject', mergeRequestId: 1 }) + .then(() => { + expect(store.state.projects.abcproject.mergeRequests['1'].title).toBe('mergerequest'); + expect(store.state.currentMergeRequestId).toBe(1); + + done(); + }) + .catch(done.fail); + }); }); - it('calls getProjectMergeRequestData service method', done => { - store - .dispatch('getMergeRequestData', { projectId: 'abcproject', mergeRequestId: 1 }) - .then(() => { - expect(service.getProjectMergeRequestData).toHaveBeenCalledWith('abcproject', 1); - - done(); - }) - .catch(done.fail); - }); - - it('sets the Merge Request Object', done => { - store - .dispatch('getMergeRequestData', { projectId: 'abcproject', mergeRequestId: 1 }) - .then(() => { - expect(store.state.projects.abcproject.mergeRequests['1'].title).toBe('mergerequest'); - expect(store.state.currentMergeRequestId).toBe(1); - - done(); - }) - .catch(done.fail); + describe('error', () => { + beforeEach(() => { + mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests\/1/).networkError(); + }); + + it('dispatches error action', done => { + const dispatch = jasmine.createSpy('dispatch'); + + getMergeRequestData( + { + commit() {}, + dispatch, + state: store.state, + }, + { projectId: 'abcproject', mergeRequestId: 1 }, + ) + .then(done.fail) + .catch(() => { + expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { + text: 'An error occured whilst loading the merge request.', + action: jasmine.any(Function), + actionText: 'Please try again', + actionPayload: { + projectId: 'abcproject', + mergeRequestId: 1, + force: false, + }, + }); + + done(); + }); + }); }); }); describe('getMergeRequestChanges', () => { beforeEach(() => { - spyOn(service, 'getProjectMergeRequestChanges').and.returnValue( - Promise.resolve({ data: { title: 'mergerequest' } }), - ); - store.state.projects.abcproject.mergeRequests['1'] = { changes: [] }; }); - it('calls getProjectMergeRequestChanges service method', done => { - store - .dispatch('getMergeRequestChanges', { projectId: 'abcproject', mergeRequestId: 1 }) - .then(() => { - expect(service.getProjectMergeRequestChanges).toHaveBeenCalledWith('abcproject', 1); - - done(); - }) - .catch(done.fail); + describe('success', () => { + beforeEach(() => { + spyOn(service, 'getProjectMergeRequestChanges').and.callThrough(); + + mock + .onGet(/api\/(.*)\/projects\/abcproject\/merge_requests\/1\/changes/) + .reply(200, { title: 'mergerequest' }); + }); + + it('calls getProjectMergeRequestChanges service method', done => { + store + .dispatch('getMergeRequestChanges', { projectId: 'abcproject', mergeRequestId: 1 }) + .then(() => { + expect(service.getProjectMergeRequestChanges).toHaveBeenCalledWith('abcproject', 1); + + done(); + }) + .catch(done.fail); + }); + + it('sets the Merge Request Changes Object', done => { + store + .dispatch('getMergeRequestChanges', { projectId: 'abcproject', mergeRequestId: 1 }) + .then(() => { + expect(store.state.projects.abcproject.mergeRequests['1'].changes.title).toBe( + 'mergerequest', + ); + done(); + }) + .catch(done.fail); + }); }); - it('sets the Merge Request Changes Object', done => { - store - .dispatch('getMergeRequestChanges', { projectId: 'abcproject', mergeRequestId: 1 }) - .then(() => { - expect(store.state.projects.abcproject.mergeRequests['1'].changes.title).toBe( - 'mergerequest', - ); - done(); - }) - .catch(done.fail); + describe('error', () => { + beforeEach(() => { + mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests\/1\/changes/).networkError(); + }); + + it('dispatches error action', done => { + const dispatch = jasmine.createSpy('dispatch'); + + getMergeRequestChanges( + { + commit() {}, + dispatch, + state: store.state, + }, + { projectId: 'abcproject', mergeRequestId: 1 }, + ) + .then(done.fail) + .catch(() => { + expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { + text: 'An error occured whilst loading the merge request changes.', + action: jasmine.any(Function), + actionText: 'Please try again', + actionPayload: { + projectId: 'abcproject', + mergeRequestId: 1, + force: false, + }, + }); + + done(); + }); + }); }); }); describe('getMergeRequestVersions', () => { beforeEach(() => { - spyOn(service, 'getProjectMergeRequestVersions').and.returnValue( - Promise.resolve({ data: [{ id: 789 }] }), - ); - store.state.projects.abcproject.mergeRequests['1'] = { versions: [] }; }); - it('calls getProjectMergeRequestVersions service method', done => { - store - .dispatch('getMergeRequestVersions', { projectId: 'abcproject', mergeRequestId: 1 }) - .then(() => { - expect(service.getProjectMergeRequestVersions).toHaveBeenCalledWith('abcproject', 1); - - done(); - }) - .catch(done.fail); + describe('success', () => { + beforeEach(() => { + mock + .onGet(/api\/(.*)\/projects\/abcproject\/merge_requests\/1\/versions/) + .reply(200, [{ id: 789 }]); + spyOn(service, 'getProjectMergeRequestVersions').and.callThrough(); + }); + + it('calls getProjectMergeRequestVersions service method', done => { + store + .dispatch('getMergeRequestVersions', { projectId: 'abcproject', mergeRequestId: 1 }) + .then(() => { + expect(service.getProjectMergeRequestVersions).toHaveBeenCalledWith('abcproject', 1); + + done(); + }) + .catch(done.fail); + }); + + it('sets the Merge Request Versions Object', done => { + store + .dispatch('getMergeRequestVersions', { projectId: 'abcproject', mergeRequestId: 1 }) + .then(() => { + expect(store.state.projects.abcproject.mergeRequests['1'].versions.length).toBe(1); + done(); + }) + .catch(done.fail); + }); }); - it('sets the Merge Request Versions Object', done => { - store - .dispatch('getMergeRequestVersions', { projectId: 'abcproject', mergeRequestId: 1 }) - .then(() => { - expect(store.state.projects.abcproject.mergeRequests['1'].versions.length).toBe(1); - done(); - }) - .catch(done.fail); + describe('error', () => { + beforeEach(() => { + mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests\/1\/versions/).networkError(); + }); + + it('dispatches error action', done => { + const dispatch = jasmine.createSpy('dispatch'); + + getMergeRequestVersions( + { + commit() {}, + dispatch, + state: store.state, + }, + { projectId: 'abcproject', mergeRequestId: 1 }, + ) + .then(done.fail) + .catch(() => { + expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { + text: 'An error occured whilst loading the merge request version data.', + action: jasmine.any(Function), + actionText: 'Please try again', + actionPayload: { + projectId: 'abcproject', + mergeRequestId: 1, + force: false, + }, + }); + + done(); + }); + }); }); }); }); diff --git a/spec/javascripts/ide/stores/actions/project_spec.js b/spec/javascripts/ide/stores/actions/project_spec.js index d71fc0e035e..ca79edafb7e 100644 --- a/spec/javascripts/ide/stores/actions/project_spec.js +++ b/spec/javascripts/ide/stores/actions/project_spec.js @@ -1,15 +1,32 @@ -import { refreshLastCommitData } from '~/ide/stores/actions'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import { + refreshLastCommitData, + showBranchNotFoundError, + createNewBranchFromDefault, + getBranchData, +} from '~/ide/stores/actions'; import store from '~/ide/stores'; import service from '~/ide/services'; +import api from '~/api'; +import router from '~/ide/ide_router'; import { resetStore } from '../../helpers'; import testAction from '../../../helpers/vuex_action_helper'; describe('IDE store project actions', () => { + let mock; + beforeEach(() => { - store.state.projects['abc/def'] = {}; + mock = new MockAdapter(axios); + + store.state.projects['abc/def'] = { + branches: {}, + }; }); afterEach(() => { + mock.restore(); + resetStore(store); }); @@ -80,4 +97,138 @@ describe('IDE store project actions', () => { ); }); }); + + describe('showBranchNotFoundError', () => { + it('dispatches setErrorMessage', done => { + testAction( + showBranchNotFoundError, + 'master', + null, + [], + [ + { + type: 'setErrorMessage', + payload: { + text: "Branch <strong>master</strong> was not found in this project's repository.", + action: jasmine.any(Function), + actionText: 'Create branch', + actionPayload: 'master', + }, + }, + ], + done, + ); + }); + }); + + describe('createNewBranchFromDefault', () => { + it('calls API', done => { + spyOn(api, 'createBranch').and.returnValue(Promise.resolve()); + spyOn(router, 'push'); + + createNewBranchFromDefault( + { + state: { + currentProjectId: 'project-path', + }, + getters: { + currentProject: { + default_branch: 'master', + }, + }, + dispatch() {}, + }, + 'new-branch-name', + ) + .then(() => { + expect(api.createBranch).toHaveBeenCalledWith('project-path', { + ref: 'master', + branch: 'new-branch-name', + }); + }) + .then(done) + .catch(done.fail); + }); + + it('clears error message', done => { + const dispatchSpy = jasmine.createSpy('dispatch'); + spyOn(api, 'createBranch').and.returnValue(Promise.resolve()); + spyOn(router, 'push'); + + createNewBranchFromDefault( + { + state: { + currentProjectId: 'project-path', + }, + getters: { + currentProject: { + default_branch: 'master', + }, + }, + dispatch: dispatchSpy, + }, + 'new-branch-name', + ) + .then(() => { + expect(dispatchSpy).toHaveBeenCalledWith('setErrorMessage', null); + }) + .then(done) + .catch(done.fail); + }); + + it('reloads window', done => { + spyOn(api, 'createBranch').and.returnValue(Promise.resolve()); + spyOn(router, 'push'); + + createNewBranchFromDefault( + { + state: { + currentProjectId: 'project-path', + }, + getters: { + currentProject: { + default_branch: 'master', + }, + }, + dispatch() {}, + }, + 'new-branch-name', + ) + .then(() => { + expect(router.push).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('getBranchData', () => { + describe('error', () => { + it('dispatches branch not found action when response is 404', done => { + const dispatch = jasmine.createSpy('dispatchSpy'); + + mock.onGet(/(.*)/).replyOnce(404); + + getBranchData( + { + commit() {}, + dispatch, + state: store.state, + }, + { + projectId: 'abc/def', + branchId: 'master-testing', + }, + ) + .then(done.fail) + .catch(() => { + expect(dispatch.calls.argsFor(0)).toEqual([ + 'showBranchNotFoundError', + 'master-testing', + ]); + done(); + }); + }); + }); + }); }); diff --git a/spec/javascripts/ide/stores/actions/tree_spec.js b/spec/javascripts/ide/stores/actions/tree_spec.js index cefed9ddb43..6860e6cdb91 100644 --- a/spec/javascripts/ide/stores/actions/tree_spec.js +++ b/spec/javascripts/ide/stores/actions/tree_spec.js @@ -1,7 +1,8 @@ -import Vue from 'vue'; +import MockAdapter from 'axios-mock-adapter'; import testAction from 'spec/helpers/vuex_action_helper'; -import { showTreeEntry } from '~/ide/stores/actions/tree'; +import { showTreeEntry, getFiles } from '~/ide/stores/actions/tree'; import * as types from '~/ide/stores/mutation_types'; +import axios from '~/lib/utils/axios_utils'; import store from '~/ide/stores'; import service from '~/ide/services'; import router from '~/ide/ide_router'; @@ -9,6 +10,7 @@ import { file, resetStore, createEntriesFromPaths } from '../../helpers'; describe('Multi-file store tree actions', () => { let projectTree; + let mock; const basicCallParameters = { endpoint: 'rootEndpoint', @@ -20,6 +22,8 @@ describe('Multi-file store tree actions', () => { beforeEach(() => { spyOn(router, 'push'); + mock = new MockAdapter(axios); + store.state.currentProjectId = 'abcproject'; store.state.currentBranchId = 'master'; store.state.projects.abcproject = { @@ -33,49 +37,119 @@ describe('Multi-file store tree actions', () => { }); afterEach(() => { + mock.restore(); resetStore(store); }); describe('getFiles', () => { - beforeEach(() => { - spyOn(service, 'getFiles').and.returnValue( - Promise.resolve({ - json: () => - Promise.resolve([ - 'file.txt', - 'folder/fileinfolder.js', - 'folder/subfolder/fileinsubfolder.js', - ]), - }), - ); + describe('success', () => { + beforeEach(() => { + spyOn(service, 'getFiles').and.callThrough(); + + mock + .onGet(/(.*)/) + .replyOnce(200, [ + 'file.txt', + 'folder/fileinfolder.js', + 'folder/subfolder/fileinsubfolder.js', + ]); + }); + + it('calls service getFiles', done => { + store + .dispatch('getFiles', basicCallParameters) + .then(() => { + expect(service.getFiles).toHaveBeenCalledWith('', 'master'); + + done(); + }) + .catch(done.fail); + }); + + it('adds data into tree', done => { + store + .dispatch('getFiles', basicCallParameters) + .then(() => { + projectTree = store.state.trees['abcproject/master']; + expect(projectTree.tree.length).toBe(2); + expect(projectTree.tree[0].type).toBe('tree'); + expect(projectTree.tree[0].tree[1].name).toBe('fileinfolder.js'); + expect(projectTree.tree[1].type).toBe('blob'); + expect(projectTree.tree[0].tree[0].tree[0].type).toBe('blob'); + expect(projectTree.tree[0].tree[0].tree[0].name).toBe('fileinsubfolder.js'); + + done(); + }) + .catch(done.fail); + }); }); - it('calls service getFiles', done => { - store - .dispatch('getFiles', basicCallParameters) - .then(() => { - expect(service.getFiles).toHaveBeenCalledWith('', 'master'); + describe('error', () => { + it('dispatches branch not found actions when response is 404', done => { + const dispatch = jasmine.createSpy('dispatchSpy'); - done(); - }) - .catch(done.fail); - }); + store.state.projects = { + 'abc/def': { + web_url: `${gl.TEST_HOST}/files`, + }, + }; - it('adds data into tree', done => { - store - .dispatch('getFiles', basicCallParameters) - .then(() => { - projectTree = store.state.trees['abcproject/master']; - expect(projectTree.tree.length).toBe(2); - expect(projectTree.tree[0].type).toBe('tree'); - expect(projectTree.tree[0].tree[1].name).toBe('fileinfolder.js'); - expect(projectTree.tree[1].type).toBe('blob'); - expect(projectTree.tree[0].tree[0].tree[0].type).toBe('blob'); - expect(projectTree.tree[0].tree[0].tree[0].name).toBe('fileinsubfolder.js'); + mock.onGet(/(.*)/).replyOnce(404); - done(); - }) - .catch(done.fail); + getFiles( + { + commit() {}, + dispatch, + state: store.state, + }, + { + projectId: 'abc/def', + branchId: 'master-testing', + }, + ) + .then(done.fail) + .catch(() => { + expect(dispatch.calls.argsFor(0)).toEqual([ + 'showBranchNotFoundError', + 'master-testing', + ]); + done(); + }); + }); + + it('dispatches error action', done => { + const dispatch = jasmine.createSpy('dispatchSpy'); + + store.state.projects = { + 'abc/def': { + web_url: `${gl.TEST_HOST}/files`, + }, + }; + + mock.onGet(/(.*)/).replyOnce(500); + + getFiles( + { + commit() {}, + dispatch, + state: store.state, + }, + { + projectId: 'abc/def', + branchId: 'master-testing', + }, + ) + .then(done.fail) + .catch(() => { + expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { + text: 'An error occured whilst loading all the files.', + action: jasmine.any(Function), + actionText: 'Please try again', + actionPayload: { projectId: 'abc/def', branchId: 'master-testing' }, + }); + done(); + }); + }); }); }); @@ -122,79 +196,9 @@ describe('Multi-file store tree actions', () => { { type: types.SET_TREE_OPEN, payload: 'grandparent/parent' }, { type: types.SET_TREE_OPEN, payload: 'grandparent' }, ], - [ - { type: 'showTreeEntry' }, - ], + [{ type: 'showTreeEntry' }], done, ); }); }); - - describe('getLastCommitData', () => { - beforeEach(() => { - spyOn(service, 'getTreeLastCommit').and.returnValue( - Promise.resolve({ - headers: { - 'more-logs-url': null, - }, - json: () => - Promise.resolve([ - { - type: 'tree', - file_name: 'testing', - commit: { - message: 'commit message', - authored_date: '123', - }, - }, - ]), - }), - ); - - store.state.trees['abcproject/mybranch'] = { - tree: [], - }; - - projectTree = store.state.trees['abcproject/mybranch']; - projectTree.tree.push(file('testing', '1', 'tree')); - projectTree.lastCommitPath = 'lastcommitpath'; - }); - - it('calls service with lastCommitPath', done => { - store - .dispatch('getLastCommitData', projectTree) - .then(() => { - expect(service.getTreeLastCommit).toHaveBeenCalledWith('lastcommitpath'); - - done(); - }) - .catch(done.fail); - }); - - it('updates trees last commit data', done => { - store - .dispatch('getLastCommitData', projectTree) - .then(Vue.nextTick) - .then(() => { - expect(projectTree.tree[0].lastCommit.message).toBe('commit message'); - - done(); - }) - .catch(done.fail); - }); - - it('does not update entry if not found', done => { - projectTree.tree[0].name = 'a'; - - store - .dispatch('getLastCommitData', projectTree) - .then(Vue.nextTick) - .then(() => { - expect(projectTree.tree[0].lastCommit.message).not.toBe('commit message'); - - done(); - }) - .catch(done.fail); - }); - }); }); diff --git a/spec/javascripts/ide/stores/actions_spec.js b/spec/javascripts/ide/stores/actions_spec.js index 062c3497623..8b665a6d79e 100644 --- a/spec/javascripts/ide/stores/actions_spec.js +++ b/spec/javascripts/ide/stores/actions_spec.js @@ -6,6 +6,7 @@ import actions, { setEmptyStateSvgs, updateActivityBarView, updateTempFlagForEntry, + setErrorMessage, } from '~/ide/stores/actions'; import store from '~/ide/stores'; import * as types from '~/ide/stores/mutation_types'; @@ -443,4 +444,17 @@ describe('Multi-file store actions', () => { ); }); }); + + describe('setErrorMessage', () => { + it('commis error messsage', done => { + testAction( + setErrorMessage, + 'error', + null, + [{ type: types.SET_ERROR_MESSAGE, payload: 'error' }], + [], + done, + ); + }); + }); }); diff --git a/spec/javascripts/ide/stores/getters_spec.js b/spec/javascripts/ide/stores/getters_spec.js index 4833ba3edfd..70883e16b0d 100644 --- a/spec/javascripts/ide/stores/getters_spec.js +++ b/spec/javascripts/ide/stores/getters_spec.js @@ -147,12 +147,11 @@ describe('IDE store getters', () => { const commitTitle = 'Example commit title'; const localGetters = { currentProject: { - branches: { - 'example-branch': { - commit: { - title: commitTitle, - }, - }, + name: 'test-project', + }, + currentBranch: { + commit: { + title: commitTitle, }, }, }; @@ -161,4 +160,23 @@ describe('IDE store getters', () => { expect(getters.lastCommit(localState, localGetters).title).toBe(commitTitle); }); }); + + describe('currentBranch', () => { + it('returns current projects branch', () => { + const localGetters = { + currentProject: { + branches: { + master: { + name: 'master', + }, + }, + }, + }; + localState.currentBranchId = 'master'; + + expect(getters.currentBranch(localState, localGetters)).toEqual({ + name: 'master', + }); + }); + }); }); diff --git a/spec/javascripts/ide/stores/modules/commit/getters_spec.js b/spec/javascripts/ide/stores/modules/commit/getters_spec.js index 55580f046ad..44c941d6dbb 100644 --- a/spec/javascripts/ide/stores/modules/commit/getters_spec.js +++ b/spec/javascripts/ide/stores/modules/commit/getters_spec.js @@ -29,46 +29,6 @@ describe('IDE commit module getters', () => { }); }); - describe('commitButtonDisabled', () => { - const localGetters = { - discardDraftButtonDisabled: false, - }; - const rootState = { - stagedFiles: ['a'], - }; - - it('returns false when discardDraftButtonDisabled is false & stagedFiles is not empty', () => { - expect( - getters.commitButtonDisabled(state, localGetters, rootState), - ).toBeFalsy(); - }); - - it('returns true when discardDraftButtonDisabled is false & stagedFiles is empty', () => { - rootState.stagedFiles.length = 0; - - expect( - getters.commitButtonDisabled(state, localGetters, rootState), - ).toBeTruthy(); - }); - - it('returns true when discardDraftButtonDisabled is true', () => { - localGetters.discardDraftButtonDisabled = true; - - expect( - getters.commitButtonDisabled(state, localGetters, rootState), - ).toBeTruthy(); - }); - - it('returns true when discardDraftButtonDisabled is false & changedFiles is not empty', () => { - localGetters.discardDraftButtonDisabled = false; - rootState.stagedFiles.length = 0; - - expect( - getters.commitButtonDisabled(state, localGetters, rootState), - ).toBeTruthy(); - }); - }); - describe('newBranchName', () => { it('includes username, currentBranchId, patch & random number', () => { gon.current_username = 'username'; @@ -108,9 +68,7 @@ describe('IDE commit module getters', () => { }); it('uses newBranchName when not empty', () => { - expect(getters.branchName(state, localGetters, rootState)).toBe( - 'state-newBranchName', - ); + expect(getters.branchName(state, localGetters, rootState)).toBe('state-newBranchName'); }); it('uses getters newBranchName when state newBranchName is empty', () => { @@ -118,11 +76,53 @@ describe('IDE commit module getters', () => { newBranchName: '', }); - expect(getters.branchName(state, localGetters, rootState)).toBe( - 'newBranchName', - ); + expect(getters.branchName(state, localGetters, rootState)).toBe('newBranchName'); }); }); }); }); + + describe('preBuiltCommitMessage', () => { + let rootState = {}; + + beforeEach(() => { + rootState.changedFiles = []; + rootState.stagedFiles = []; + }); + + afterEach(() => { + rootState = {}; + }); + + it('returns commitMessage when set', () => { + state.commitMessage = 'test commit message'; + + expect(getters.preBuiltCommitMessage(state, null, rootState)).toBe('test commit message'); + }); + + ['changedFiles', 'stagedFiles'].forEach(key => { + it('returns commitMessage with updated file', () => { + rootState[key].push({ + path: 'test-file', + }); + + expect(getters.preBuiltCommitMessage(state, null, rootState)).toBe('Update test-file'); + }); + + it('returns commitMessage with updated files', () => { + rootState[key].push( + { + path: 'test-file', + }, + { + path: 'index.js', + }, + ); + + expect(getters.preBuiltCommitMessage(state, null, rootState)).toBe( + 'Update test-file, index.js files', + ); + }); + }); + }); }); diff --git a/spec/javascripts/ide/stores/mutations_spec.js b/spec/javascripts/ide/stores/mutations_spec.js index 972713c5ad2..98016f593aa 100644 --- a/spec/javascripts/ide/stores/mutations_spec.js +++ b/spec/javascripts/ide/stores/mutations_spec.js @@ -148,4 +148,12 @@ describe('Multi-file store mutations', () => { expect(localState.unusedSeal).toBe(false); }); }); + + describe('SET_ERROR_MESSAGE', () => { + it('updates error message', () => { + mutations.SET_ERROR_MESSAGE(localState, 'error'); + + expect(localState.errorMessage).toBe('error'); + }); + }); }); diff --git a/spec/javascripts/ide/stores/utils_spec.js b/spec/javascripts/ide/stores/utils_spec.js index a7bd443af51..6c5980cfae4 100644 --- a/spec/javascripts/ide/stores/utils_spec.js +++ b/spec/javascripts/ide/stores/utils_spec.js @@ -94,6 +94,7 @@ describe('Multi-file store utils', () => { newBranch: false, state, rootState, + getters: {}, }); expect(payload).toEqual({ @@ -118,5 +119,58 @@ describe('Multi-file store utils', () => { start_branch: undefined, }); }); + + it('uses prebuilt commit message when commit message is empty', () => { + const rootState = { + stagedFiles: [ + { + ...file('staged'), + path: 'staged', + content: 'updated file content', + lastCommitSha: '123456789', + }, + { + ...file('newFile'), + path: 'added', + tempFile: true, + content: 'new file content', + base64: true, + lastCommitSha: '123456789', + }, + ], + currentBranchId: 'master', + }; + const payload = utils.createCommitPayload({ + branch: 'master', + newBranch: false, + state: {}, + rootState, + getters: { + preBuiltCommitMessage: 'prebuilt test commit message', + }, + }); + + expect(payload).toEqual({ + branch: 'master', + commit_message: 'prebuilt test commit message', + actions: [ + { + action: 'update', + file_path: 'staged', + content: 'updated file content', + encoding: 'text', + last_commit_id: '123456789', + }, + { + action: 'create', + file_path: 'added', + content: 'new file content', + encoding: 'base64', + last_commit_id: '123456789', + }, + ], + start_branch: undefined, + }); + }); }); }); diff --git a/spec/javascripts/merge_request_spec.js b/spec/javascripts/merge_request_spec.js index 22eb0ad7143..7502f1fa2e1 100644 --- a/spec/javascripts/merge_request_spec.js +++ b/spec/javascripts/merge_request_spec.js @@ -19,9 +19,11 @@ import IssuablesHelper from '~/helpers/issuables_helper'; spyOn(axios, 'patch').and.callThrough(); mock = new MockAdapter(axios); - mock.onPatch(`${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`).reply(200, {}); + mock + .onPatch(`${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`) + .reply(200, {}); - return this.merge = new MergeRequest(); + return (this.merge = new MergeRequest()); }); afterEach(() => { @@ -32,17 +34,22 @@ import IssuablesHelper from '~/helpers/issuables_helper'; spyOn($, 'ajax').and.stub(); const changeEvent = document.createEvent('HTMLEvents'); changeEvent.initEvent('change', true, true); - $('input[type=checkbox]').attr('checked', true)[0].dispatchEvent(changeEvent); + $('input[type=checkbox]') + .attr('checked', true)[0] + .dispatchEvent(changeEvent); return expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); }); - it('submits an ajax request on tasklist:changed', (done) => { + it('submits an ajax request on tasklist:changed', done => { $('.js-task-list-field').trigger('tasklist:changed'); setTimeout(() => { - expect(axios.patch).toHaveBeenCalledWith(`${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`, { - merge_request: { description: '- [ ] Task List Item' }, - }); + expect(axios.patch).toHaveBeenCalledWith( + `${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`, + { + merge_request: { description: '- [ ] Task List Item' }, + }, + ); done(); }); }); @@ -119,4 +126,4 @@ import IssuablesHelper from '~/helpers/issuables_helper'; }); }); }); -}).call(window); +}.call(window)); diff --git a/spec/javascripts/merge_request_tabs_spec.js b/spec/javascripts/merge_request_tabs_spec.js index 08928e13985..7251ce19a90 100644 --- a/spec/javascripts/merge_request_tabs_spec.js +++ b/spec/javascripts/merge_request_tabs_spec.js @@ -40,6 +40,7 @@ describe('MergeRequestTabs', function() { this.class.unbindEvents(); this.class.destroyPipelinesView(); mrPageMock.restore(); + $('.js-merge-request-test').remove(); }); describe('opensInNewTab', function() { diff --git a/spec/javascripts/notes/stores/actions_spec.js b/spec/javascripts/notes/stores/actions_spec.js index 985c2f81ef3..71ef3aa9b03 100644 --- a/spec/javascripts/notes/stores/actions_spec.js +++ b/spec/javascripts/notes/stores/actions_spec.js @@ -291,4 +291,17 @@ describe('Actions Notes Store', () => { .catch(done.fail); }); }); + + describe('setNotesFetchedState', () => { + it('should set notes fetched state', done => { + testAction( + actions.setNotesFetchedState, + true, + {}, + [{ type: 'SET_NOTES_FETCHED_STATE', payload: true }], + [], + done, + ); + }); + }); }); diff --git a/spec/javascripts/notes/stores/getters_spec.js b/spec/javascripts/notes/stores/getters_spec.js index 5501e50e97b..815cc09621f 100644 --- a/spec/javascripts/notes/stores/getters_spec.js +++ b/spec/javascripts/notes/stores/getters_spec.js @@ -15,6 +15,7 @@ describe('Getters Notes Store', () => { discussions: [individualNote], targetNoteHash: 'hash', lastFetchedAt: 'timestamp', + isNotesFetched: false, notesData: notesDataMock, userData: userDataMock, @@ -84,4 +85,10 @@ describe('Getters Notes Store', () => { expect(getters.openState(state)).toEqual(noteableDataMock.state); }); }); + + describe('isNotesFetched', () => { + it('should return the state for the fetching notes', () => { + expect(getters.isNotesFetched(state)).toBeFalsy(); + }); + }); }); diff --git a/spec/javascripts/notes/stores/mutation_spec.js b/spec/javascripts/notes/stores/mutation_spec.js index 556a1c244c0..ccc7328447b 100644 --- a/spec/javascripts/notes/stores/mutation_spec.js +++ b/spec/javascripts/notes/stores/mutation_spec.js @@ -318,4 +318,15 @@ describe('Notes Store mutations', () => { expect(state.isToggleStateButtonLoading).toEqual(false); }); }); + + describe('SET_NOTES_FETCHING_STATE', () => { + it('should set the given state', () => { + const state = { + isNotesFetched: false, + }; + + mutations.SET_NOTES_FETCHED_STATE(state, true); + expect(state.isNotesFetched).toEqual(true); + }); + }); }); diff --git a/spec/javascripts/pipelines/graph/job_component_spec.js b/spec/javascripts/pipelines/graph/job_component_spec.js index 073dae56c25..9c55a19ebc7 100644 --- a/spec/javascripts/pipelines/graph/job_component_spec.js +++ b/spec/javascripts/pipelines/graph/job_component_spec.js @@ -135,4 +135,34 @@ describe('pipeline graph job component', () => { expect(component.$el.querySelector('.js-job-component-tooltip').getAttribute('data-original-title')).toEqual('test - success'); }); }); + + describe('tooltip placement', () => { + const tooltipBoundary = 'a[data-boundary="viewport"]'; + + it('does not set tooltip boundary by default', () => { + component = mountComponent(JobComponent, { + job: mockJob, + }); + + expect(component.$el.querySelector(tooltipBoundary)).toBeNull(); + }); + + it('sets tooltip boundary to viewport for small dropdowns', () => { + component = mountComponent(JobComponent, { + job: mockJob, + dropdownLength: 1, + }); + + expect(component.$el.querySelector(tooltipBoundary)).not.toBeNull(); + }); + + it('does not set tooltip boundary for large lists', () => { + component = mountComponent(JobComponent, { + job: mockJob, + dropdownLength: 7, + }); + + expect(component.$el.querySelector(tooltipBoundary)).toBeNull(); + }); + }); }); diff --git a/spec/lib/banzai/filter/emoji_filter_spec.rb b/spec/lib/banzai/filter/emoji_filter_spec.rb index 10910f22d4a..85a4619e33d 100644 --- a/spec/lib/banzai/filter/emoji_filter_spec.rb +++ b/spec/lib/banzai/filter/emoji_filter_spec.rb @@ -3,15 +3,6 @@ require 'spec_helper' describe Banzai::Filter::EmojiFilter do include FilterSpecHelper - before do - @original_asset_host = ActionController::Base.asset_host - ActionController::Base.asset_host = 'https://foo.com' - end - - after do - ActionController::Base.asset_host = @original_asset_host - end - it 'replaces supported name emoji' do doc = filter('<p>:heart:</p>') expect(doc.css('gl-emoji').first.text).to eq '❤' diff --git a/spec/lib/gitlab/background_migration/delete_diff_files_spec.rb b/spec/lib/gitlab/background_migration/delete_diff_files_spec.rb new file mode 100644 index 00000000000..a251ab323d8 --- /dev/null +++ b/spec/lib/gitlab/background_migration/delete_diff_files_spec.rb @@ -0,0 +1,69 @@ +require 'spec_helper' + +describe Gitlab::BackgroundMigration::DeleteDiffFiles, :migration, schema: 20180619121030 do + describe '#perform' do + context 'when diff files can be deleted' do + let(:merge_request) { create(:merge_request, :merged) } + let(:merge_request_diff) do + merge_request.create_merge_request_diff + merge_request.merge_request_diffs.first + end + + it 'deletes all merge request diff files' do + expect { described_class.new.perform(merge_request_diff.id) } + .to change { merge_request_diff.merge_request_diff_files.count } + .from(20).to(0) + end + + it 'updates state to without_files' do + expect { described_class.new.perform(merge_request_diff.id) } + .to change { merge_request_diff.reload.state } + .from('collected').to('without_files') + end + + it 'rollsback if something goes wrong' do + expect(MergeRequestDiffFile).to receive_message_chain(:where, :delete_all) + .and_raise + + expect { described_class.new.perform(merge_request_diff.id) } + .to raise_error + + merge_request_diff.reload + + expect(merge_request_diff.state).to eq('collected') + expect(merge_request_diff.merge_request_diff_files.count).to eq(20) + end + end + + it 'deletes no merge request diff files when MR is not merged' do + merge_request = create(:merge_request, :opened) + merge_request.create_merge_request_diff + merge_request_diff = merge_request.merge_request_diffs.first + + expect { described_class.new.perform(merge_request_diff.id) } + .not_to change { merge_request_diff.merge_request_diff_files.count } + .from(20) + end + + it 'deletes no merge request diff files when diff is marked as "without_files"' do + merge_request = create(:merge_request, :merged) + merge_request.create_merge_request_diff + merge_request_diff = merge_request.merge_request_diffs.first + + merge_request_diff.clean! + + expect { described_class.new.perform(merge_request_diff.id) } + .not_to change { merge_request_diff.merge_request_diff_files.count } + .from(20) + end + + it 'deletes no merge request diff files when diff is the latest' do + merge_request = create(:merge_request, :merged) + merge_request_diff = merge_request.merge_request_diff + + expect { described_class.new.perform(merge_request_diff.id) } + .not_to change { merge_request_diff.merge_request_diff_files.count } + .from(20) + end + end +end diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index fa5327c26f0..e73cdc54a15 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -5,16 +5,6 @@ module Gitlab describe YamlProcessor do subject { described_class.new(config) } - describe 'our current .gitlab-ci.yml' do - let(:config) { File.read("#{Rails.root}/.gitlab-ci.yml") } - - it 'is valid' do - error_message = described_class.validation_message(config) - - expect(error_message).to be_nil - end - end - describe '#build_attributes' do subject { described_class.new(config).build_attributes(:rspec) } diff --git a/spec/lib/gitlab/data_builder/note_spec.rb b/spec/lib/gitlab/data_builder/note_spec.rb index 4f8412108ba..b236c1a9c49 100644 --- a/spec/lib/gitlab/data_builder/note_spec.rb +++ b/spec/lib/gitlab/data_builder/note_spec.rb @@ -52,7 +52,7 @@ describe Gitlab::DataBuilder::Note do expect(data[:issue].except('updated_at')) .to eq(issue.reload.hook_attrs.except('updated_at')) expect(data[:issue]['updated_at']) - .to be > issue.hook_attrs['updated_at'] + .to be >= issue.hook_attrs['updated_at'] end context 'with confidential issue' do @@ -84,7 +84,7 @@ describe Gitlab::DataBuilder::Note do expect(data[:merge_request].except('updated_at')) .to eq(merge_request.reload.hook_attrs.except('updated_at')) expect(data[:merge_request]['updated_at']) - .to be > merge_request.hook_attrs['updated_at'] + .to be >= merge_request.hook_attrs['updated_at'] end include_examples 'project hook data' @@ -107,7 +107,7 @@ describe Gitlab::DataBuilder::Note do expect(data[:merge_request].except('updated_at')) .to eq(merge_request.reload.hook_attrs.except('updated_at')) expect(data[:merge_request]['updated_at']) - .to be > merge_request.hook_attrs['updated_at'] + .to be >= merge_request.hook_attrs['updated_at'] end include_examples 'project hook data' @@ -130,7 +130,7 @@ describe Gitlab::DataBuilder::Note do expect(data[:snippet].except('updated_at')) .to eq(snippet.reload.hook_attrs.except('updated_at')) expect(data[:snippet]['updated_at']) - .to be > snippet.hook_attrs['updated_at'] + .to be >= snippet.hook_attrs['updated_at'] end include_examples 'project hook data' diff --git a/spec/lib/gitlab/favicon_spec.rb b/spec/lib/gitlab/favicon_spec.rb index 122dcd9634c..68abcb3520a 100644 --- a/spec/lib/gitlab/favicon_spec.rb +++ b/spec/lib/gitlab/favicon_spec.rb @@ -32,7 +32,7 @@ RSpec.describe Gitlab::Favicon, :request_store do end it 'returns a full url when the asset host is configured' do - allow(Gitlab::Application.config).to receive(:asset_host).and_return('http://assets.local') + allow(ActionController::Base).to receive(:asset_host).and_return('http://assets.local') expect(described_class.main).to match %r{^http://localhost/assets/favicon-(?:\h+).png$} end end diff --git a/spec/lib/gitlab/git/commit_spec.rb b/spec/lib/gitlab/git/commit_spec.rb index ae69a362dda..ee74c2769eb 100644 --- a/spec/lib/gitlab/git/commit_spec.rb +++ b/spec/lib/gitlab/git/commit_spec.rb @@ -309,7 +309,7 @@ describe Gitlab::Git::Commit, seed_helper: true do it { is_expected.not_to include(SeedRepo::FirstCommit::ID) } end - shared_examples '.shas_with_signatures' do + describe '.shas_with_signatures' do let(:signed_shas) { %w[5937ac0a7beb003549fc5fd26fc247adbce4a52e 570e7b2abdd848b95f2f578043fc23bd6f6fd24d] } let(:unsigned_shas) { %w[19e2e9b4ef76b422ce1154af39a91323ccc57434 c642fe9b8b9f28f9225d7ea953fe14e74748d53b] } let(:first_signed_shas) { %w[5937ac0a7beb003549fc5fd26fc247adbce4a52e c642fe9b8b9f28f9225d7ea953fe14e74748d53b] } @@ -330,93 +330,55 @@ describe Gitlab::Git::Commit, seed_helper: true do end end - describe '.shas_with_signatures with gitaly on' do - it_should_behave_like '.shas_with_signatures' - end - - describe '.shas_with_signatures with gitaly disabled', :disable_gitaly do - it_should_behave_like '.shas_with_signatures' - end - describe '.find_all' do - shared_examples 'finding all commits' do - it 'should return a return a collection of commits' do - commits = described_class.find_all(repository) - - expect(commits).to all( be_a_kind_of(described_class) ) - end - - context 'max_count' do - subject do - commits = described_class.find_all( - repository, - max_count: 50 - ) + it 'should return a return a collection of commits' do + commits = described_class.find_all(repository) - commits.map(&:id) - end + expect(commits).to all( be_a_kind_of(described_class) ) + end - it 'has 34 elements' do - expect(subject.size).to eq(34) - end + context 'max_count' do + subject do + commits = described_class.find_all( + repository, + max_count: 50 + ) - it 'includes the expected commits' do - expect(subject).to include( - SeedRepo::Commit::ID, - SeedRepo::Commit::PARENT_ID, - SeedRepo::FirstCommit::ID - ) - end + commits.map(&:id) end - context 'ref + max_count + skip' do - subject do - commits = described_class.find_all( - repository, - ref: 'master', - max_count: 50, - skip: 1 - ) - - commits.map(&:id) - end - - it 'has 24 elements' do - expect(subject.size).to eq(24) - end - - it 'includes the expected commits' do - expect(subject).to include(SeedRepo::Commit::ID, SeedRepo::FirstCommit::ID) - expect(subject).not_to include(SeedRepo::LastCommit::ID) - end + it 'has 34 elements' do + expect(subject.size).to eq(34) end - end - context 'when Gitaly find_all_commits feature is enabled' do - it_behaves_like 'finding all commits' + it 'includes the expected commits' do + expect(subject).to include( + SeedRepo::Commit::ID, + SeedRepo::Commit::PARENT_ID, + SeedRepo::FirstCommit::ID + ) + end end - context 'when Gitaly find_all_commits feature is disabled', :skip_gitaly_mock do - it_behaves_like 'finding all commits' - - context 'while applying a sort order based on the `order` option' do - it "allows ordering topologically (no parents shown before their children)" do - expect_any_instance_of(Rugged::Walker).to receive(:sorting).with(Rugged::SORT_TOPO) - - described_class.find_all(repository, order: :topo) - end - - it "allows ordering by date" do - expect_any_instance_of(Rugged::Walker).to receive(:sorting).with(Rugged::SORT_DATE | Rugged::SORT_TOPO) + context 'ref + max_count + skip' do + subject do + commits = described_class.find_all( + repository, + ref: 'master', + max_count: 50, + skip: 1 + ) - described_class.find_all(repository, order: :date) - end + commits.map(&:id) + end - it "applies no sorting by default" do - expect_any_instance_of(Rugged::Walker).to receive(:sorting).with(Rugged::SORT_NONE) + it 'has 24 elements' do + expect(subject.size).to eq(24) + end - described_class.find_all(repository) - end + it 'includes the expected commits' do + expect(subject).to include(SeedRepo::Commit::ID, SeedRepo::FirstCommit::ID) + expect(subject).not_to include(SeedRepo::LastCommit::ID) end end end @@ -498,7 +460,7 @@ describe Gitlab::Git::Commit, seed_helper: true do end describe '.extract_signature_lazily' do - shared_examples 'loading signatures in batch once' do + describe 'loading signatures in batch once' do it 'fetches signatures in batch once' do commit_ids = %w[0b4bc9a49b562e85de7cc9e834518ea6828729b9 4b4918a572fa86f9771e5ba40fbd48e1eb03e2c6] signatures = commit_ids.map do |commit_id| @@ -516,27 +478,13 @@ describe Gitlab::Git::Commit, seed_helper: true do subject { described_class.extract_signature_lazily(repository, commit_id).itself } - context 'with Gitaly extract_commit_signature_in_batch feature enabled' do - it_behaves_like 'extracting commit signature' - it_behaves_like 'loading signatures in batch once' - end - - context 'with Gitaly extract_commit_signature_in_batch feature disabled', :disable_gitaly do - it_behaves_like 'extracting commit signature' - it_behaves_like 'loading signatures in batch once' - end + it_behaves_like 'extracting commit signature' end describe '.extract_signature' do subject { described_class.extract_signature(repository, commit_id) } - context 'with gitaly' do - it_behaves_like 'extracting commit signature' - end - - context 'without gitaly', :disable_gitaly do - it_behaves_like 'extracting commit signature' - end + it_behaves_like 'extracting commit signature' end end diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index b78fe4ba310..6ec4b90d70c 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -996,46 +996,6 @@ describe Gitlab::Git::Repository, seed_helper: true do end end - describe "#rugged_commits_between" do - around do |example| - # TODO #rugged_commits_between will be removed, has been migrated to gitaly - Gitlab::GitalyClient::StorageSettings.allow_disk_access do - example.run - end - end - - context 'two SHAs' do - let(:first_sha) { 'b0e52af38d7ea43cf41d8a6f2471351ac036d6c9' } - let(:second_sha) { '0e50ec4d3c7ce42ab74dda1d422cb2cbffe1e326' } - - it 'returns the number of commits between' do - expect(repository.rugged_commits_between(first_sha, second_sha).count).to eq(3) - end - end - - context 'SHA and master branch' do - let(:sha) { 'b0e52af38d7ea43cf41d8a6f2471351ac036d6c9' } - let(:branch) { 'master' } - - it 'returns the number of commits between a sha and a branch' do - expect(repository.rugged_commits_between(sha, branch).count).to eq(5) - end - - it 'returns the number of commits between a branch and a sha' do - expect(repository.rugged_commits_between(branch, sha).count).to eq(0) # sha is before branch - end - end - - context 'two branches' do - let(:first_branch) { 'feature' } - let(:second_branch) { 'master' } - - it 'returns the number of commits between' do - expect(repository.rugged_commits_between(first_branch, second_branch).count).to eq(17) - end - end - end - describe '#count_commits_between' do subject { repository.count_commits_between('feature', 'master') } diff --git a/spec/lib/gitlab/github_import/importer/pull_requests_importer_spec.rb b/spec/lib/gitlab/github_import/importer/pull_requests_importer_spec.rb index 44695acbe7d..51fad6c6838 100644 --- a/spec/lib/gitlab/github_import/importer/pull_requests_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/pull_requests_importer_spec.rb @@ -164,7 +164,7 @@ describe Gitlab::GithubImport::Importer::PullRequestsImporter do Timecop.freeze do importer.update_repository - expect(project.last_repository_updated_at).to eq(Time.zone.now) + expect(project.last_repository_updated_at).to be_like_time(Time.zone.now) end end end diff --git a/spec/lib/gitlab/import_export/group_project_object_builder_spec.rb b/spec/lib/gitlab/import_export/group_project_object_builder_spec.rb new file mode 100644 index 00000000000..6a803c48b34 --- /dev/null +++ b/spec/lib/gitlab/import_export/group_project_object_builder_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +describe Gitlab::ImportExport::GroupProjectObjectBuilder do + let(:project) do + create(:project, + :builds_disabled, + :issues_disabled, + name: 'project', + path: 'project', + group: create(:group)) + end + + context 'labels' do + it 'finds the right group label' do + group_label = create(:group_label, 'name': 'group label', 'group': project.group) + + expect(described_class.build(Label, + 'title' => 'group label', + 'project' => project, + 'group' => project.group)).to eq(group_label) + end + + it 'creates a new label' do + label = described_class.build(Label, + 'title' => 'group label', + 'project' => project, + 'group' => project.group) + + expect(label.persisted?).to be true + end + end + + context 'milestones' do + it 'finds the right group milestone' do + milestone = create(:milestone, 'name' => 'group milestone', 'group' => project.group) + + expect(described_class.build(Milestone, + 'title' => 'group milestone', + 'project' => project, + 'group' => project.group)).to eq(milestone) + end + + it 'creates a new milestone' do + milestone = described_class.build(Milestone, + 'title' => 'group milestone', + 'project' => project, + 'group' => project.group) + + expect(milestone.persisted?).to be true + end + end +end diff --git a/spec/lib/gitlab/import_export/importer_spec.rb b/spec/lib/gitlab/import_export/importer_spec.rb index 991e354f499..c074e61da26 100644 --- a/spec/lib/gitlab/import_export/importer_spec.rb +++ b/spec/lib/gitlab/import_export/importer_spec.rb @@ -4,14 +4,14 @@ describe Gitlab::ImportExport::Importer do let(:user) { create(:user) } let(:test_path) { "#{Dir.tmpdir}/importer_spec" } let(:shared) { project.import_export_shared } - let(:project) { create(:project, import_source: File.join(test_path, 'exported-project.gz')) } + let(:project) { create(:project, import_source: File.join(test_path, 'test_project_export.tar.gz')) } subject(:importer) { described_class.new(project) } before do allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(test_path) FileUtils.mkdir_p(shared.export_path) - FileUtils.cp(Rails.root.join('spec', 'fixtures', 'exported-project.gz'), test_path) + FileUtils.cp(Rails.root.join('spec/features/projects/import_export/test_project_export.tar.gz'), test_path) allow(subject).to receive(:remove_import_file) end diff --git a/spec/lib/gitlab/import_export/project.light.json b/spec/lib/gitlab/import_export/project.light.json index c13cf4a0507..ba2248073f5 100644 --- a/spec/lib/gitlab/import_export/project.light.json +++ b/spec/lib/gitlab/import_export/project.light.json @@ -7,7 +7,7 @@ "milestones": [ { "id": 1, - "title": "Project milestone", + "title": "A milestone", "project_id": 8, "description": "Project-level milestone", "due_date": null, @@ -66,8 +66,8 @@ "group_milestone_id": null, "milestone": { "id": 1, - "title": "Project milestone", - "project_id": 8, + "title": "A milestone", + "group_id": 8, "description": "Project-level milestone", "due_date": null, "created_at": "2016-06-14T15:02:04.415Z", @@ -86,7 +86,7 @@ "updated_at": "2017-08-15T18:37:40.795Z", "label": { "id": 6, - "title": "Another project label", + "title": "Another label", "color": "#A8D695", "project_id": null, "created_at": "2017-08-15T18:37:19.698Z", diff --git a/spec/lib/gitlab/import_export/project.milestone-iid.json b/spec/lib/gitlab/import_export/project.milestone-iid.json new file mode 100644 index 00000000000..b028147b5eb --- /dev/null +++ b/spec/lib/gitlab/import_export/project.milestone-iid.json @@ -0,0 +1,80 @@ +{ + "description": "Nisi et repellendus ut enim quo accusamus vel magnam.", + "import_type": "gitlab_project", + "creator_id": 123, + "visibility_level": 10, + "archived": false, + "issues": [ + { + "id": 1, + "title": "Fugiat est minima quae maxime non similique.", + "assignee_id": null, + "project_id": 8, + "author_id": 1, + "created_at": "2017-07-07T18:13:01.138Z", + "updated_at": "2017-08-15T18:37:40.807Z", + "branch_name": null, + "description": "Quam totam fuga numquam in eveniet.", + "state": "opened", + "iid": 20, + "updated_by_id": 1, + "confidential": false, + "due_date": null, + "moved_to_id": null, + "lock_version": null, + "time_estimate": 0, + "closed_at": null, + "last_edited_at": null, + "last_edited_by_id": null, + "group_milestone_id": null, + "milestone": { + "id": 1, + "title": "Group-level milestone", + "description": "Group-level milestone", + "due_date": null, + "created_at": "2016-06-14T15:02:04.415Z", + "updated_at": "2016-06-14T15:02:04.415Z", + "state": "active", + "iid": 1, + "group_id": 8 + } + }, + { + "id": 2, + "title": "est minima quae maxime non similique.", + "assignee_id": null, + "project_id": 8, + "author_id": 1, + "created_at": "2017-07-07T18:13:01.138Z", + "updated_at": "2017-08-15T18:37:40.807Z", + "branch_name": null, + "description": "Quam totam fuga numquam in eveniet.", + "state": "opened", + "iid": 21, + "updated_by_id": 1, + "confidential": false, + "due_date": null, + "moved_to_id": null, + "lock_version": null, + "time_estimate": 0, + "closed_at": null, + "last_edited_at": null, + "last_edited_by_id": null, + "group_milestone_id": null, + "milestone": { + "id": 2, + "title": "Another milestone", + "project_id": 8, + "description": "milestone", + "due_date": null, + "created_at": "2016-06-14T15:02:04.415Z", + "updated_at": "2016-06-14T15:02:04.415Z", + "state": "active", + "iid": 1, + "group_id": null + } + } + ], + "snippets": [], + "hooks": [] +} diff --git a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb index 68ddc947e02..bac5693c830 100644 --- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb @@ -189,8 +189,8 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do @project.pipelines.zip([2, 2, 2, 2, 2]) .each do |(pipeline, expected_status_size)| - expect(pipeline.statuses.size).to eq(expected_status_size) - end + expect(pipeline.statuses.size).to eq(expected_status_size) + end end end @@ -246,13 +246,6 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do expect(project.issues.size).to eq(results.fetch(:issues, 0)) end - it 'has issue with group label and project label' do - labels = project.issues.first.labels - - expect(labels.where(type: "ProjectLabel").count).to eq(results.fetch(:first_issue_labels, 0)) - expect(labels.where(type: "ProjectLabel").where.not(group_id: nil).count).to eq(0) - end - it 'does not set params that are excluded from import_export settings' do expect(project.import_type).to be_nil expect(project.creator_id).not_to eq 123 @@ -268,12 +261,6 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do it 'has group milestone' do expect(project.group.milestones.size).to eq(results.fetch(:milestones, 0)) end - - it 'has issue with group label' do - labels = project.issues.first.labels - - expect(labels.where(type: "GroupLabel").count).to eq(results.fetch(:first_issue_labels, 0)) - end end context 'Light JSON' do @@ -360,13 +347,72 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do it_behaves_like 'restores project correctly', issues: 2, labels: 1, - milestones: 1, + milestones: 2, first_issue_labels: 1 it_behaves_like 'restores group correctly', - labels: 1, - milestones: 1, + labels: 0, + milestones: 0, first_issue_labels: 1 end + + context 'with existing group models' do + let!(:project) do + create(:project, + :builds_disabled, + :issues_disabled, + name: 'project', + path: 'project', + group: create(:group)) + end + + before do + project_tree_restorer.instance_variable_set(:@path, "spec/lib/gitlab/import_export/project.light.json") + end + + it 'imports labels' do + create(:group_label, name: 'Another label', group: project.group) + + expect_any_instance_of(Gitlab::ImportExport::Shared).not_to receive(:error) + + restored_project_json + + expect(project.labels.count).to eq(1) + end + + it 'imports milestones' do + create(:milestone, name: 'A milestone', group: project.group) + + expect_any_instance_of(Gitlab::ImportExport::Shared).not_to receive(:error) + + restored_project_json + + expect(project.group.milestones.count).to eq(1) + expect(project.milestones.count).to eq(0) + end + end + + context 'with clashing milestones on IID' do + let!(:project) do + create(:project, + :builds_disabled, + :issues_disabled, + name: 'project', + path: 'project', + group: create(:group)) + end + + it 'preserves the project milestone IID' do + project_tree_restorer.instance_variable_set(:@path, "spec/lib/gitlab/import_export/project.milestone-iid.json") + + expect_any_instance_of(Gitlab::ImportExport::Shared).not_to receive(:error) + + restored_project_json + + expect(project.milestones.count).to eq(2) + expect(Milestone.find_by_title('Another milestone').iid).to eq(1) + expect(Milestone.find_by_title('Group-level milestone').iid).to eq(2) + end + end end end diff --git a/spec/lib/gitlab/middleware/read_only_spec.rb b/spec/lib/gitlab/middleware/read_only_spec.rb index 39ec2f37a83..5c398bc2063 100644 --- a/spec/lib/gitlab/middleware/read_only_spec.rb +++ b/spec/lib/gitlab/middleware/read_only_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' describe Gitlab::Middleware::ReadOnly do include Rack::Test::Methods + using RSpec::Parameterized::TableSyntax RSpec::Matchers.define :be_a_redirect do match do |response| @@ -117,39 +118,41 @@ describe Gitlab::Middleware::ReadOnly do context 'whitelisted requests' do it 'expects a POST internal request to be allowed' do expect(Rails.application.routes).not_to receive(:recognize_path) - response = request.post("/api/#{API::API.version}/internal") expect(response).not_to be_a_redirect expect(subject).not_to disallow_request end - it 'expects a POST LFS request to batch URL to be allowed' do - expect(Rails.application.routes).to receive(:recognize_path).and_call_original - response = request.post('/root/rouge.git/info/lfs/objects/batch') + it 'expects requests to sidekiq admin to be allowed' do + response = request.post('/admin/sidekiq') expect(response).not_to be_a_redirect expect(subject).not_to disallow_request - end - it 'expects a POST request to git-upload-pack URL to be allowed' do - expect(Rails.application.routes).to receive(:recognize_path).and_call_original - response = request.post('/root/rouge.git/git-upload-pack') + response = request.get('/admin/sidekiq') expect(response).not_to be_a_redirect expect(subject).not_to disallow_request end - it 'expects requests to sidekiq admin to be allowed' do - response = request.post('/admin/sidekiq') - - expect(response).not_to be_a_redirect - expect(subject).not_to disallow_request + where(:description, :path) do + 'LFS request to batch' | '/root/rouge.git/info/lfs/objects/batch' + 'LFS request to locks verify' | '/root/rouge.git/info/lfs/locks/verify' + 'LFS request to locks create' | '/root/rouge.git/info/lfs/locks' + 'LFS request to locks unlock' | '/root/rouge.git/info/lfs/locks/1/unlock' + 'request to git-upload-pack' | '/root/rouge.git/git-upload-pack' + 'request to git-receive-pack' | '/root/rouge.git/git-receive-pack' + end - response = request.get('/admin/sidekiq') + with_them do + it "expects a POST #{description} URL to be allowed" do + expect(Rails.application.routes).to receive(:recognize_path).and_call_original + response = request.post(path) - expect(response).not_to be_a_redirect - expect(subject).not_to disallow_request + expect(response).not_to be_a_redirect + expect(subject).not_to disallow_request + end end end end diff --git a/spec/lib/gitlab/repository_cache_adapter_spec.rb b/spec/lib/gitlab/repository_cache_adapter_spec.rb index 85971f2a7ef..5bd4d6c6a48 100644 --- a/spec/lib/gitlab/repository_cache_adapter_spec.rb +++ b/spec/lib/gitlab/repository_cache_adapter_spec.rb @@ -67,10 +67,18 @@ describe Gitlab::RepositoryCacheAdapter do describe '#expire_method_caches' do it 'expires the caches of the given methods' do - expect(cache).to receive(:expire).with(:readme) + expect(cache).to receive(:expire).with(:rendered_readme) expect(cache).to receive(:expire).with(:gitignore) - repository.expire_method_caches(%i(readme gitignore)) + repository.expire_method_caches(%i(rendered_readme gitignore)) + end + + it 'does not expire caches for non-existent methods' do + expect(cache).not_to receive(:expire).with(:nonexistent) + expect(Rails.logger).to( + receive(:error).with("Requested to expire non-existent method 'nonexistent' for Repository")) + + repository.expire_method_caches(%i(nonexistent)) end end end diff --git a/spec/lib/gitlab/shard_health_cache_spec.rb b/spec/lib/gitlab/shard_health_cache_spec.rb new file mode 100644 index 00000000000..e1a69261939 --- /dev/null +++ b/spec/lib/gitlab/shard_health_cache_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +describe Gitlab::ShardHealthCache, :clean_gitlab_redis_cache do + let(:shards) { %w(foo bar) } + + before do + described_class.update(shards) + end + + describe '.clear' do + it 'leaves no shards around' do + described_class.clear + + expect(described_class.healthy_shard_count).to eq(0) + end + end + + describe '.update' do + it 'returns the healthy shards' do + expect(described_class.cached_healthy_shards).to match_array(shards) + end + + it 'replaces the existing set' do + new_set = %w(test me more) + described_class.update(new_set) + + expect(described_class.cached_healthy_shards).to match_array(new_set) + end + end + + describe '.healthy_shard_count' do + it 'returns the healthy shard count' do + expect(described_class.healthy_shard_count).to eq(2) + end + + it 'returns 0 if no shards are available' do + described_class.update([]) + + expect(described_class.healthy_shard_count).to eq(0) + end + end + + describe '.healthy_shard?' do + it 'returns true for a healthy shard' do + expect(described_class.healthy_shard?('foo')).to be_truthy + end + + it 'returns false for an unknown shard' do + expect(described_class.healthy_shard?('unknown')).to be_falsey + end + end +end diff --git a/spec/lib/mattermost/session_spec.rb b/spec/lib/mattermost/session_spec.rb index 5410bfbeb31..b7687d48c68 100644 --- a/spec/lib/mattermost/session_spec.rb +++ b/spec/lib/mattermost/session_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Mattermost::Session, type: :request do + include ExclusiveLeaseHelpers + let(:user) { create(:user) } let(:gitlab_url) { "http://gitlab.com" } @@ -97,26 +99,20 @@ describe Mattermost::Session, type: :request do end end - context 'with lease' do - before do - allow(subject).to receive(:lease_try_obtain).and_return('aldkfjsldfk') - end + context 'exclusive lease' do + let(:lease_key) { 'mattermost:session' } it 'tries to obtain a lease' do - expect(subject).to receive(:lease_try_obtain) - expect(Gitlab::ExclusiveLease).to receive(:cancel) + expect_to_obtain_exclusive_lease(lease_key, 'uuid') + expect_to_cancel_exclusive_lease(lease_key, 'uuid') # Cannot setup a session, but we should still cancel the lease expect { subject.with_session }.to raise_error(Mattermost::NoSessionError) end - end - context 'without lease' do - before do - allow(subject).to receive(:lease_try_obtain).and_return(nil) - end + it 'returns a NoSessionError error without lease' do + stub_exclusive_lease_taken(lease_key) - it 'returns a NoSessionError error' do expect { subject.with_session }.to raise_error(Mattermost::NoSessionError) end end diff --git a/spec/migrations/cleanup_stages_position_migration_spec.rb b/spec/migrations/cleanup_stages_position_migration_spec.rb new file mode 100644 index 00000000000..dde5a777487 --- /dev/null +++ b/spec/migrations/cleanup_stages_position_migration_spec.rb @@ -0,0 +1,67 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20180604123514_cleanup_stages_position_migration.rb') + +describe CleanupStagesPositionMigration, :migration, :sidekiq, :redis do + let(:migration) { spy('migration') } + + before do + allow(Gitlab::BackgroundMigration::MigrateStageIndex) + .to receive(:new).and_return(migration) + end + + context 'when there are pending background migrations' do + it 'processes pending jobs synchronously' do + Sidekiq::Testing.disable! do + BackgroundMigrationWorker + .perform_in(2.minutes, 'MigrateStageIndex', [1, 1]) + BackgroundMigrationWorker + .perform_async('MigrateStageIndex', [1, 1]) + + migrate! + + expect(migration).to have_received(:perform).with(1, 1).twice + end + end + end + + context 'when there are no background migrations pending' do + it 'does nothing' do + Sidekiq::Testing.disable! do + migrate! + + expect(migration).not_to have_received(:perform) + end + end + end + + context 'when there are still unmigrated stages present' do + let(:stages) { table('ci_stages') } + let(:builds) { table('ci_builds') } + + let!(:entities) do + %w[build test broken].map do |name| + stages.create(name: name) + end + end + + before do + stages.update_all(position: nil) + + builds.create(name: 'unit', stage_id: entities.first.id, stage_idx: 1, ref: 'master') + builds.create(name: 'unit', stage_id: entities.second.id, stage_idx: 1, ref: 'master') + end + + it 'migrates stages sequentially for every stage' do + expect(stages.all).to all(have_attributes(position: nil)) + + migrate! + + expect(migration).to have_received(:perform) + .with(entities.first.id, entities.first.id) + expect(migration).to have_received(:perform) + .with(entities.second.id, entities.second.id) + expect(migration).not_to have_received(:perform) + .with(entities.third.id, entities.third.id) + end + end +end diff --git a/spec/migrations/enqueue_delete_diff_files_workers_spec.rb b/spec/migrations/enqueue_delete_diff_files_workers_spec.rb new file mode 100644 index 00000000000..686027822b8 --- /dev/null +++ b/spec/migrations/enqueue_delete_diff_files_workers_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20180619121030_enqueue_delete_diff_files_workers.rb') + +describe EnqueueDeleteDiffFilesWorkers, :migration, :sidekiq do + let(:merge_request_diffs) { table(:merge_request_diffs) } + let(:merge_requests) { table(:merge_requests) } + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + + before do + stub_const("#{described_class.name}::BATCH_SIZE", 2) + + namespaces.create!(id: 1, name: 'gitlab', path: 'gitlab') + projects.create!(id: 1, namespace_id: 1, name: 'gitlab', path: 'gitlab') + + merge_requests.create!(id: 1, target_project_id: 1, source_project_id: 1, target_branch: 'feature', source_branch: 'master', state: 'merged') + + merge_request_diffs.create!(id: 1, merge_request_id: 1, state: 'collected') + merge_request_diffs.create!(id: 2, merge_request_id: 1, state: 'without_files') + merge_request_diffs.create!(id: 3, merge_request_id: 1, state: 'collected') + merge_request_diffs.create!(id: 4, merge_request_id: 1, state: 'collected') + merge_request_diffs.create!(id: 5, merge_request_id: 1, state: 'empty') + merge_request_diffs.create!(id: 6, merge_request_id: 1, state: 'collected') + + merge_requests.update(1, latest_merge_request_diff_id: 6) + end + + it 'correctly schedules diff file deletion workers' do + Sidekiq::Testing.fake! do + Timecop.freeze do + migrate! + + # 1st batch + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(8.minutes, 1) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(9.minutes, 3) + # 2nd batch + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(16.minutes, 4) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(17.minutes, 6) + expect(BackgroundMigrationWorker.jobs.size).to eq(4) + end + end + end + + it 'migrates the data' do + expect { migrate! }.to change { merge_request_diffs.where(state: 'without_files').count } + .from(1).to(4) + end +end diff --git a/spec/models/ci/build_trace_chunk_spec.rb b/spec/models/ci/build_trace_chunk_spec.rb index c5d550cba1b..464897de306 100644 --- a/spec/models/ci/build_trace_chunk_spec.rb +++ b/spec/models/ci/build_trace_chunk_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do + include ExclusiveLeaseHelpers + set(:build) { create(:ci_build, :running) } let(:chunk_index) { 0 } let(:data_store) { :redis } @@ -322,7 +324,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do describe 'ExclusiveLock' do before do - allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain) { nil } + stub_exclusive_lease_taken stub_const('Ci::BuildTraceChunk::WRITE_LOCK_RETRY', 1) end diff --git a/spec/models/concerns/reactive_caching_spec.rb b/spec/models/concerns/reactive_caching_spec.rb index f2a3df50c1a..0f156619e9e 100644 --- a/spec/models/concerns/reactive_caching_spec.rb +++ b/spec/models/concerns/reactive_caching_spec.rb @@ -1,6 +1,7 @@ require 'spec_helper' describe ReactiveCaching, :use_clean_rails_memory_store_caching do + include ExclusiveLeaseHelpers include ReactiveCachingHelpers class CacheTest @@ -106,8 +107,8 @@ describe ReactiveCaching, :use_clean_rails_memory_store_caching do end it 'takes and releases the lease' do - expect_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain).and_return("000000") - expect(Gitlab::ExclusiveLease).to receive(:cancel).with(cache_key, "000000") + expect_to_obtain_exclusive_lease(cache_key, 'uuid') + expect_to_cancel_exclusive_lease(cache_key, 'uuid') go! end @@ -153,11 +154,9 @@ describe ReactiveCaching, :use_clean_rails_memory_store_caching do end context 'when the lease is already taken' do - before do - expect_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain).and_return(nil) - end - it 'skips the calculation' do + stub_exclusive_lease_taken(cache_key) + expect(instance).to receive(:calculate_reactive_cache).never go! diff --git a/spec/models/concerns/resolvable_discussion_spec.rb b/spec/models/concerns/resolvable_discussion_spec.rb index 2a2ef5a304d..2f9f63ce7e0 100644 --- a/spec/models/concerns/resolvable_discussion_spec.rb +++ b/spec/models/concerns/resolvable_discussion_spec.rb @@ -534,11 +534,18 @@ describe Discussion, ResolvableDiscussion do describe "#last_resolved_note" do let(:current_user) { create(:user) } + let(:time) { Time.now.utc } before do - first_note.resolve!(current_user) - third_note.resolve!(current_user) - second_note.resolve!(current_user) + Timecop.freeze(time - 1.second) do + first_note.resolve!(current_user) + end + Timecop.freeze(time) do + third_note.resolve!(current_user) + end + Timecop.freeze(time + 1.second) do + second_note.resolve!(current_user) + end end it "returns the last note that was resolved" do diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index ec72fefd137..8c6b411ec9a 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -2190,6 +2190,22 @@ describe MergeRequest do end end end + + context 'source branch is missing' do + subject { create(:merge_request, :invalid, :opened, merge_status: :unchecked, target_branch: 'master') } + + before do + allow(subject.project.repository).to receive(:can_be_merged?).and_call_original + end + + it 'does not raise error' do + expect(notification_service).not_to receive(:merge_request_unmergeable) + expect(todo_service).not_to receive(:merge_request_became_unmergeable) + + expect { subject.mark_as_unmergeable }.not_to raise_error + expect(subject.cannot_be_merged?).to eq(true) + end + end end describe 'check_state?' do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index a2f8fac2f38..abdc65336ca 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -571,13 +571,13 @@ describe Project do last_activity_at: timestamp, last_repository_updated_at: timestamp - 1.hour) - expect(project.last_activity_date).to eq(timestamp) + expect(project.last_activity_date).to be_like_time(timestamp) project.update_attributes(updated_at: timestamp, last_activity_at: timestamp - 1.hour, last_repository_updated_at: nil) - expect(project.last_activity_date).to eq(timestamp) + expect(project.last_activity_date).to be_like_time(timestamp) end end end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index c7e751130d8..cfa78c4472c 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -479,6 +479,14 @@ describe Repository do end end + context 'when ref is not specified' do + it 'is using a root ref' do + expect(repository).to receive(:find_commit).with('master') + + repository.commit + end + end + context 'when ref is not valid' do context 'when preceding tree element exists' do it 'returns nil' do @@ -664,7 +672,7 @@ describe Repository do end end - shared_examples "search_files_by_content" do + describe "search_files_by_content" do let(:results) { repository.search_files_by_content('feature', 'master') } subject { results } @@ -711,7 +719,7 @@ describe Repository do end end - shared_examples "search_files_by_name" do + describe "search_files_by_name" do let(:results) { repository.search_files_by_name('files', 'master') } it 'returns result' do @@ -751,16 +759,6 @@ describe Repository do end end - describe 'with gitaly enabled' do - it_behaves_like 'search_files_by_content' - it_behaves_like 'search_files_by_name' - end - - describe 'with gitaly disabled', :disable_gitaly do - it_behaves_like 'search_files_by_content' - it_behaves_like 'search_files_by_name' - end - describe '#async_remove_remote' do before do masterrev = repository.find_branch('master').dereferenced_target @@ -1699,19 +1697,29 @@ describe Repository do end describe '#after_change_head' do - it 'flushes the readme cache' do + it 'flushes the method caches' do expect(repository).to receive(:expire_method_caches).with([ - :readme, + :size, + :commit_count, + :rendered_readme, + :contribution_guide, :changelog, - :license, - :contributing, + :license_blob, + :license_key, :gitignore, - :koding, - :gitlab_ci, + :koding_yml, + :gitlab_ci_yml, + :branch_names, + :tag_names, + :branch_count, + :tag_count, :avatar, - :issue_template, - :merge_request_template, - :xcode_config + :exists?, + :root_ref, + :has_visible_content?, + :issue_template_names, + :merge_request_template_names, + :xcode_project? ]) repository.after_change_head diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb index d8fdfd6dee1..4bc5d3ee899 100644 --- a/spec/requests/api/files_spec.rb +++ b/spec/requests/api/files_spec.rb @@ -21,6 +21,89 @@ describe API::Files do "/projects/#{project.id}/repository/files/#{file_path}" end + describe "HEAD /projects/:id/repository/files/:file_path" do + shared_examples_for 'repository files' do + it 'returns file attributes in headers' do + head api(route(file_path), current_user), params + + expect(response).to have_gitlab_http_status(200) + expect(response.headers['X-Gitlab-File-Path']).to eq(CGI.unescape(file_path)) + expect(response.headers['X-Gitlab-File-Name']).to eq('popen.rb') + expect(response.headers['X-Gitlab-Last-Commit-Id']).to eq('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') + expect(response.headers['X-Gitlab-Content-Sha256']).to eq('c440cd09bae50c4632cc58638ad33c6aa375b6109d811e76a9cc3a613c1e8887') + end + + it 'returns file by commit sha' do + # This file is deleted on HEAD + file_path = "files%2Fjs%2Fcommit%2Ejs%2Ecoffee" + params[:ref] = "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9" + + head api(route(file_path), current_user), params + + expect(response).to have_gitlab_http_status(200) + expect(response.headers['X-Gitlab-File-Name']).to eq('commit.js.coffee') + expect(response.headers['X-Gitlab-Content-Sha256']).to eq('08785f04375b47f81f46e68cc125d5ef368aa20576ddb53f91f4d83f1d04b929') + end + + context 'when mandatory params are not given' do + it "responds with a 400 status" do + head api(route("any%2Ffile"), current_user) + + expect(response).to have_gitlab_http_status(400) + end + end + + context 'when file_path does not exist' do + it "responds with a 404 status" do + params[:ref] = 'master' + + head api(route('app%2Fmodels%2Fapplication%2Erb'), current_user), params + + expect(response).to have_gitlab_http_status(404) + end + end + + context 'when file_path does not exist' do + include_context 'disabled repository' + + it "responds with a 403 status" do + head api(route(file_path), current_user), params + + expect(response).to have_gitlab_http_status(403) + end + end + end + + context 'when unauthenticated', 'and project is public' do + it_behaves_like 'repository files' do + let(:project) { create(:project, :public, :repository) } + let(:current_user) { nil } + end + end + + context 'when unauthenticated', 'and project is private' do + it "responds with a 404 status" do + current_user = nil + + head api(route(file_path), current_user), params + + expect(response).to have_gitlab_http_status(404) + end + end + + context 'when authenticated', 'as a developer' do + it_behaves_like 'repository files' do + let(:current_user) { user } + end + end + + context 'when authenticated', 'as a guest' do + it_behaves_like '403 response' do + let(:request) { head api(route(file_path), guest), params } + end + end + end + describe "GET /projects/:id/repository/files/:file_path" do shared_examples_for 'repository files' do it 'returns file attributes as json' do @@ -30,6 +113,7 @@ describe API::Files do expect(json_response['file_path']).to eq(CGI.unescape(file_path)) expect(json_response['file_name']).to eq('popen.rb') expect(json_response['last_commit_id']).to eq('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') + expect(json_response['content_sha256']).to eq('c440cd09bae50c4632cc58638ad33c6aa375b6109d811e76a9cc3a613c1e8887') expect(Base64.decode64(json_response['content']).lines.first).to eq("require 'fileutils'\n") end @@ -51,6 +135,7 @@ describe API::Files do expect(response).to have_gitlab_http_status(200) expect(json_response['file_name']).to eq('commit.js.coffee') + expect(json_response['content_sha256']).to eq('08785f04375b47f81f46e68cc125d5ef368aa20576ddb53f91f4d83f1d04b929') expect(Base64.decode64(json_response['content']).lines.first).to eq("class Commit\n") end diff --git a/spec/requests/api/graphql/project/merge_request_spec.rb b/spec/requests/api/graphql/project/merge_request_spec.rb new file mode 100644 index 00000000000..ad57c43bc87 --- /dev/null +++ b/spec/requests/api/graphql/project/merge_request_spec.rb @@ -0,0 +1,70 @@ +require 'spec_helper' + +describe 'getting merge request information nested in a project' do + include GraphqlHelpers + + let(:project) { create(:project, :repository, :public) } + let(:current_user) { create(:user) } + let(:merge_request_graphql_data) { graphql_data['project']['mergeRequest'] } + let!(:merge_request) { create(:merge_request, source_project: project) } + + let(:query) do + graphql_query_for( + 'project', + { 'fullPath' => project.full_path }, + query_graphql_field('mergeRequest', iid: merge_request.iid) + ) + end + + it_behaves_like 'a working graphql query' do + before do + post_graphql(query, current_user: current_user) + end + end + + it 'contains merge request information' do + post_graphql(query, current_user: current_user) + + expect(merge_request_graphql_data).not_to be_nil + end + + # This is a field coming from the `MergeRequestPresenter` + it 'includes a web_url' do + post_graphql(query, current_user: current_user) + + expect(merge_request_graphql_data['webUrl']).to be_present + end + + context 'permissions on the merge request' do + it 'includes the permissions for the current user on a public project' do + expected_permissions = { + 'readMergeRequest' => true, + 'adminMergeRequest' => false, + 'createNote' => true, + 'pushToSourceBranch' => false, + 'removeSourceBranch' => false, + 'cherryPickOnCurrentMergeRequest' => false, + 'revertOnCurrentMergeRequest' => false, + 'updateMergeRequest' => false + } + post_graphql(query, current_user: current_user) + + permission_data = merge_request_graphql_data['userPermissions'] + + expect(permission_data).to be_present + expect(permission_data).to eq(expected_permissions) + end + end + + context 'when the user does not have access to the merge request' do + let(:project) { create(:project, :public, :repository) } + + it 'returns nil' do + project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE) + + post_graphql(query) + + expect(merge_request_graphql_data).to be_nil + end + end +end diff --git a/spec/requests/api/graphql/project_query_spec.rb b/spec/requests/api/graphql/project_query_spec.rb index 796ffc9d569..a2b3dc5d121 100644 --- a/spec/requests/api/graphql/project_query_spec.rb +++ b/spec/requests/api/graphql/project_query_spec.rb @@ -26,50 +26,6 @@ describe 'getting project information' do post_graphql(query, current_user: current_user) end end - - context 'when requesting a nested merge request' do - let(:merge_request) { create(:merge_request, source_project: project) } - let(:merge_request_graphql_data) { graphql_data['project']['mergeRequest'] } - - let(:query) do - graphql_query_for( - 'project', - { 'fullPath' => project.full_path }, - query_graphql_field('mergeRequest', iid: merge_request.iid) - ) - end - - it_behaves_like 'a working graphql query' do - before do - post_graphql(query, current_user: current_user) - end - end - - it 'contains merge request information' do - post_graphql(query, current_user: current_user) - - expect(merge_request_graphql_data).not_to be_nil - end - - # This is a field coming from the `MergeRequestPresenter` - it 'includes a web_url' do - post_graphql(query, current_user: current_user) - - expect(merge_request_graphql_data['webUrl']).to be_present - end - - context 'when the user does not have access to the merge request' do - let(:project) { create(:project, :public, :repository) } - - it 'returns nil' do - project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE) - - post_graphql(query) - - expect(merge_request_graphql_data).to be_nil - end - end - end end context 'when the user does not have access to the project' do diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index a15d60aafe0..95eff029f98 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -1679,7 +1679,7 @@ describe API::Issues do let!(:user_agent_detail) { create(:user_agent_detail, subject: issue) } context 'when unauthenticated' do - it "returns unautorized" do + it "returns unauthorized" do get api("/projects/#{project.id}/issues/#{issue.iid}/user_agent_detail") expect(response).to have_gitlab_http_status(401) @@ -1695,7 +1695,7 @@ describe API::Issues do expect(json_response['akismet_submitted']).to eq(user_agent_detail.submitted) end - it "returns unautorized for non-admin users" do + it "returns unauthorized for non-admin users" do get api("/projects/#{project.id}/issues/#{issue.iid}/user_agent_detail", user) expect(response).to have_gitlab_http_status(403) diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index d4ebfc3f782..eba39bb6ccc 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -14,6 +14,7 @@ describe API::MergeRequests do let!(:merge_request) { create(:merge_request, :simple, milestone: milestone1, author: user, assignee: user, source_project: project, target_project: project, title: "Test", created_at: base_time) } let!(:merge_request_closed) { create(:merge_request, state: "closed", milestone: milestone1, author: user, assignee: user, source_project: project, target_project: project, title: "Closed test", created_at: base_time + 1.second) } let!(:merge_request_merged) { create(:merge_request, state: "merged", author: user, assignee: user, source_project: project, target_project: project, title: "Merged test", created_at: base_time + 2.seconds, merge_commit_sha: '9999999999999999999999999999999999999999') } + let!(:merge_request_locked) { create(:merge_request, state: "locked", milestone: milestone1, author: user, assignee: user, source_project: project, target_project: project, title: "Locked test", created_at: base_time + 1.second) } let!(:note) { create(:note_on_merge_request, author: user, project: project, noteable: merge_request, note: "a comment on a MR") } let!(:note2) { create(:note_on_merge_request, author: user, project: project, noteable: merge_request, note: "another comment on a MR") } let!(:label) do @@ -85,7 +86,7 @@ describe API::MergeRequests do get api('/merge_requests', user), scope: :all - expect_response_contain_exactly(merge_request2, merge_request_merged, merge_request_closed, merge_request) + expect_response_contain_exactly(merge_request2, merge_request_merged, merge_request_closed, merge_request, merge_request_locked) expect(json_response.map { |mr| mr['id'] }).not_to include(merge_request3.id) end @@ -158,7 +159,7 @@ describe API::MergeRequests do it 'returns merge requests with the given source branch' do get api('/merge_requests', user), source_branch: merge_request_closed.source_branch, state: 'all' - expect_response_contain_exactly(merge_request_closed, merge_request_merged) + expect_response_contain_exactly(merge_request_closed, merge_request_merged, merge_request_locked) end end @@ -166,7 +167,7 @@ describe API::MergeRequests do it 'returns merge requests with the given target branch' do get api('/merge_requests', user), target_branch: merge_request_closed.target_branch, state: 'all' - expect_response_contain_exactly(merge_request_closed, merge_request_merged) + expect_response_contain_exactly(merge_request_closed, merge_request_merged, merge_request_locked) end end @@ -219,6 +220,14 @@ describe API::MergeRequests do expect_response_ordered_exactly(merge_request) end end + + context 'state param' do + it 'returns merge requests with the given state' do + get api('/merge_requests', user), state: 'locked' + + expect_response_contain_exactly(merge_request_locked) + end + end end end diff --git a/spec/requests/api/project_snippets_spec.rb b/spec/requests/api/project_snippets_spec.rb index 4a2289ca137..a3b5e8c6223 100644 --- a/spec/requests/api/project_snippets_spec.rb +++ b/spec/requests/api/project_snippets_spec.rb @@ -25,7 +25,7 @@ describe API::ProjectSnippets do expect(response).to have_gitlab_http_status(404) end - it "returns unautorized for non-admin users" do + it "returns unauthorized for non-admin users" do get api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/user_agent_detail", user) expect(response).to have_gitlab_http_status(403) diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 99103039f77..abf9ad738bd 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -1990,6 +1990,38 @@ describe API::Projects do end end + describe 'PUT /projects/:id/transfer' do + context 'when authenticated as owner' do + let(:group) { create :group } + + it 'transfers the project to the new namespace' do + group.add_owner(user) + + put api("/projects/#{project.id}/transfer", user), namespace: group.id + + expect(response).to have_gitlab_http_status(200) + end + + it 'fails when transferring to a non owned namespace' do + put api("/projects/#{project.id}/transfer", user), namespace: group.id + + expect(response).to have_gitlab_http_status(404) + end + + it 'fails when transferring to an unknown namespace' do + put api("/projects/#{project.id}/transfer", user), namespace: 'unknown' + + expect(response).to have_gitlab_http_status(404) + end + + it 'fails on missing namespace' do + put api("/projects/#{project.id}/transfer", user) + + expect(response).to have_gitlab_http_status(400) + end + end + end + it_behaves_like 'custom attributes endpoints', 'projects' do let(:attributable) { project } let(:other_attributable) { project2 } diff --git a/spec/requests/api/repositories_spec.rb b/spec/requests/api/repositories_spec.rb index cd135dfc32a..28f8564ae92 100644 --- a/spec/requests/api/repositories_spec.rb +++ b/spec/requests/api/repositories_spec.rb @@ -288,6 +288,9 @@ describe API::Repositories do shared_examples_for 'repository compare' do it "compares branches" do + expect(::Gitlab::Git::Compare).to receive(:new).with(anything, anything, anything, { + straight: false + }).and_call_original get api(route, current_user), from: 'master', to: 'feature' expect(response).to have_gitlab_http_status(200) @@ -295,6 +298,28 @@ describe API::Repositories do expect(json_response['diffs']).to be_present end + it "compares branches with explicit merge-base mode" do + expect(::Gitlab::Git::Compare).to receive(:new).with(anything, anything, anything, { + straight: false + }).and_call_original + get api(route, current_user), from: 'master', to: 'feature', straight: false + + expect(response).to have_gitlab_http_status(200) + expect(json_response['commits']).to be_present + expect(json_response['diffs']).to be_present + end + + it "compares branches with explicit straight mode" do + expect(::Gitlab::Git::Compare).to receive(:new).with(anything, anything, anything, { + straight: true + }).and_call_original + get api(route, current_user), from: 'master', to: 'feature', straight: true + + expect(response).to have_gitlab_http_status(200) + expect(json_response['commits']).to be_present + expect(json_response['diffs']).to be_present + end + it "compares tags" do get api(route, current_user), from: 'v1.0.0', to: 'v1.1.0' diff --git a/spec/requests/api/snippets_spec.rb b/spec/requests/api/snippets_spec.rb index c5456977b60..6da769cb3ed 100644 --- a/spec/requests/api/snippets_spec.rb +++ b/spec/requests/api/snippets_spec.rb @@ -314,7 +314,7 @@ describe API::Snippets do expect(json_response['akismet_submitted']).to eq(user_agent_detail.submitted) end - it "returns unautorized for non-admin users" do + it "returns unauthorized for non-admin users" do get api("/snippets/#{snippet.id}/user_agent_detail", user) expect(response).to have_gitlab_http_status(403) diff --git a/spec/requests/oauth_tokens_spec.rb b/spec/requests/oauth_tokens_spec.rb new file mode 100644 index 00000000000..000c3a2b868 --- /dev/null +++ b/spec/requests/oauth_tokens_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' + +describe 'OAuth Tokens requests' do + let(:user) { create :user } + let(:application) { create :oauth_application, scopes: 'api' } + + def request_access_token(user) + post '/oauth/token', + grant_type: 'authorization_code', + code: generate_access_grant(user).token, + redirect_uri: application.redirect_uri, + client_id: application.uid, + client_secret: application.secret + end + + def generate_access_grant(user) + create :oauth_access_grant, application: application, resource_owner_id: user.id + end + + context 'when there is already a token for the application' do + let!(:existing_token) { create :oauth_access_token, application: application, resource_owner_id: user.id } + + context 'and the request is done by the resource owner' do + it 'reuses and returns the stored token' do + expect do + request_access_token(user) + end.not_to change { Doorkeeper::AccessToken.count } + + expect(json_response['access_token']).to eq existing_token.token + end + end + + context 'and the request is done by a different user' do + let(:other_user) { create :user } + + it 'generates and returns a different token for a different owner' do + expect do + request_access_token(other_user) + end.to change { Doorkeeper::AccessToken.count }.by(1) + + expect(json_response['access_token']).not_to be_nil + end + end + end + + context 'when there is no token stored for the application' do + it 'generates and returns a new token' do + expect do + request_access_token(user) + end.to change { Doorkeeper::AccessToken.count }.by(1) + + expect(json_response['access_token']).not_to be_nil + end + end +end diff --git a/spec/requests/openid_connect_spec.rb b/spec/requests/openid_connect_spec.rb index bcb8d6c2bfc..b14d4b8fb6e 100644 --- a/spec/requests/openid_connect_spec.rb +++ b/spec/requests/openid_connect_spec.rb @@ -1,11 +1,49 @@ require 'spec_helper' describe 'OpenID Connect requests' do - let(:user) { create :user } + let(:user) do + create( + :user, + name: 'Alice', + username: 'alice', + email: 'private@example.com', + emails: [public_email], + public_email: public_email.email, + website_url: 'https://example.com', + avatar: fixture_file_upload('spec/fixtures/dk.png') + ) + end + + let(:public_email) { build :email, email: 'public@example.com' } + let(:access_grant) { create :oauth_access_grant, application: application, resource_owner_id: user.id } let(:access_token) { create :oauth_access_token, application: application, resource_owner_id: user.id } - def request_access_token + let(:hashed_subject) do + Digest::SHA256.hexdigest("#{user.id}-#{Rails.application.secrets.secret_key_base}") + end + + let(:id_token_claims) do + { + 'sub' => user.id.to_s, + 'sub_legacy' => hashed_subject + } + end + + let(:user_info_claims) do + { + 'name' => 'Alice', + 'nickname' => 'alice', + 'email' => 'public@example.com', + 'email_verified' => true, + 'website' => 'https://example.com', + 'profile' => 'http://localhost/alice', + 'picture' => "http://localhost/uploads/-/system/user/avatar/#{user.id}/dk.png", + 'groups' => kind_of(Array) + } + end + + def request_access_token! login_as user post '/oauth/token', @@ -16,26 +54,22 @@ describe 'OpenID Connect requests' do client_secret: application.secret end - def request_user_info + def request_user_info! get '/oauth/userinfo', nil, 'Authorization' => "Bearer #{access_token.token}" end - def hashed_subject - Digest::SHA256.hexdigest("#{user.id}-#{Rails.application.secrets.secret_key_base}") - end - context 'Application without OpenID scope' do let(:application) { create :oauth_application, scopes: 'api' } it 'token response does not include an ID token' do - request_access_token + request_access_token! expect(json_response).to include 'access_token' expect(json_response).not_to include 'id_token' end it 'userinfo response is unauthorized' do - request_user_info + request_user_info! expect(response).to have_gitlab_http_status 403 expect(response.body).to be_blank @@ -46,28 +80,12 @@ describe 'OpenID Connect requests' do let(:application) { create :oauth_application, scopes: 'openid' } it 'token response includes an ID token' do - request_access_token + request_access_token! expect(json_response).to include 'id_token' end context 'UserInfo payload' do - let(:user) do - create( - :user, - name: 'Alice', - username: 'alice', - emails: [private_email, public_email], - email: private_email.email, - public_email: public_email.email, - website_url: 'https://example.com', - avatar: fixture_file_upload('spec/fixtures/dk.png') - ) - end - - let!(:public_email) { build :email, email: 'public@example.com' } - let!(:private_email) { build :email, email: 'private@example.com' } - let!(:group1) { create :group } let!(:group2) { create :group } let!(:group3) { create :group, parent: group2 } @@ -76,41 +94,35 @@ describe 'OpenID Connect requests' do before do group1.add_user(user, GroupMember::OWNER) group3.add_user(user, Gitlab::Access::DEVELOPER) + + request_user_info! end it 'includes all user information and group memberships' do - request_user_info - - expect(json_response).to match(a_hash_including({ - 'sub' => hashed_subject, - 'name' => 'Alice', - 'nickname' => 'alice', - 'email' => 'public@example.com', - 'email_verified' => true, - 'website' => 'https://example.com', - 'profile' => 'http://localhost/alice', - 'picture' => "http://localhost/uploads/-/system/user/avatar/#{user.id}/dk.png", - 'groups' => anything - })) + expect(json_response).to match(id_token_claims.merge(user_info_claims)) expected_groups = [group1.full_path, group3.full_path] expected_groups << group4.full_path if Group.supports_nested_groups? expect(json_response['groups']).to match_array(expected_groups) end + + it 'does not include any unknown claims' do + expect(json_response.keys).to eq %w[sub sub_legacy] + user_info_claims.keys + end end context 'ID token payload' do before do - request_access_token + request_access_token! @payload = JSON::JWT.decode(json_response['id_token'], :skip_verification) end - it 'includes the Gitlab root URL' do - expect(@payload['iss']).to eq Gitlab.config.gitlab.url + it 'includes the subject claims' do + expect(@payload).to match(a_hash_including(id_token_claims)) end - it 'includes the hashed user ID' do - expect(@payload['sub']).to eq hashed_subject + it 'includes the Gitlab root URL' do + expect(@payload['iss']).to eq Gitlab.config.gitlab.url end it 'includes the time of the last authentication', :clean_gitlab_redis_shared_state do @@ -118,7 +130,7 @@ describe 'OpenID Connect requests' do end it 'does not include any unknown properties' do - expect(@payload.keys).to eq %w[iss sub aud exp iat auth_time] + expect(@payload.keys).to eq %w[iss sub aud exp iat auth_time sub_legacy] end end @@ -134,10 +146,10 @@ describe 'OpenID Connect requests' do context 'when user is blocked' do it 'returns authentication error' do access_grant - user.block + user.block! expect do - request_access_token + request_access_token! end.to raise_error UncaughtThrowError end end @@ -145,10 +157,10 @@ describe 'OpenID Connect requests' do context 'when user is ldap_blocked' do it 'returns authentication error' do access_grant - user.ldap_block + user.ldap_block! expect do - request_access_token + request_access_token! end.to raise_error UncaughtThrowError end end diff --git a/spec/services/clusters/applications/check_ingress_ip_address_service_spec.rb b/spec/services/clusters/applications/check_ingress_ip_address_service_spec.rb index bf038595a4d..eb0bdb61ee3 100644 --- a/spec/services/clusters/applications/check_ingress_ip_address_service_spec.rb +++ b/spec/services/clusters/applications/check_ingress_ip_address_service_spec.rb @@ -1,11 +1,13 @@ require 'spec_helper' describe Clusters::Applications::CheckIngressIpAddressService do + include ExclusiveLeaseHelpers + let(:application) { create(:clusters_applications_ingress, :installed) } let(:service) { described_class.new(application) } let(:kubeclient) { double(::Kubeclient::Client, get_service: kube_service) } let(:ingress) { [{ ip: '111.222.111.222' }] } - let(:exclusive_lease) { instance_double(Gitlab::ExclusiveLease, try_obtain: true) } + let(:lease_key) { "check_ingress_ip_address_service:#{application.id}" } let(:kube_service) do ::Kubeclient::Resource.new( @@ -22,11 +24,8 @@ describe Clusters::Applications::CheckIngressIpAddressService do subject { service.execute } before do + stub_exclusive_lease(lease_key, timeout: 15.seconds.to_i) allow(application.cluster).to receive(:kubeclient).and_return(kubeclient) - allow(Gitlab::ExclusiveLease) - .to receive(:new) - .with("check_ingress_ip_address_service:#{application.id}", timeout: 15.seconds.to_i) - .and_return(exclusive_lease) end describe '#execute' do @@ -47,13 +46,9 @@ describe Clusters::Applications::CheckIngressIpAddressService do end context 'when the exclusive lease cannot be obtained' do - before do - allow(exclusive_lease) - .to receive(:try_obtain) - .and_return(false) - end - it 'does not call kubeclient' do + stub_exclusive_lease_taken(lease_key, timeout: 15.seconds.to_i) + subject expect(kubeclient).not_to have_received(:get_service) diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index a9aee9e100f..609eef76d2c 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -5,8 +5,11 @@ describe Issues::MoveService do let(:author) { create(:user) } let(:title) { 'Some issue' } let(:description) { 'Some issue description' } - let(:old_project) { create(:project) } - let(:new_project) { create(:project) } + let(:group) { create(:group, :private) } + let(:sub_group_1) { create(:group, :private, parent: group) } + let(:sub_group_2) { create(:group, :private, parent: group) } + let(:old_project) { create(:project, namespace: sub_group_1) } + let(:new_project) { create(:project, namespace: sub_group_2) } let(:milestone1) { create(:milestone, project_id: old_project.id, title: 'v9.0') } let(:old_issue) do @@ -14,7 +17,7 @@ describe Issues::MoveService do project: old_project, author: author, milestone: milestone1) end - let(:move_service) do + subject(:move_service) do described_class.new(old_project, user) end @@ -102,6 +105,23 @@ describe Issues::MoveService do end end + context 'issue with group labels', :nested_groups do + it 'assigns group labels to new issue' do + label = create(:group_label, group: group) + label_issue = create(:labeled_issue, description: description, project: old_project, + milestone: milestone1, labels: [label]) + old_project.add_reporter(user) + new_project.add_reporter(user) + + new_issue = move_service.execute(label_issue, new_project) + + expect(new_issue).to have_attributes( + project: new_project, + labels: include(label) + ) + end + end + context 'generic issue' do include_context 'issue move executed' diff --git a/spec/services/keys/last_used_service_spec.rb b/spec/services/keys/last_used_service_spec.rb index bb0fb6acf39..8e553c2f1fa 100644 --- a/spec/services/keys/last_used_service_spec.rb +++ b/spec/services/keys/last_used_service_spec.rb @@ -8,7 +8,7 @@ describe Keys::LastUsedService do Timecop.freeze(time) { described_class.new(key).execute } - expect(key.last_used_at).to eq(time) + expect(key.reload.last_used_at).to be_like_time(time) end it 'does not update the key when it has been used recently' do @@ -17,7 +17,7 @@ describe Keys::LastUsedService do described_class.new(key).execute - expect(key.last_used_at).to eq(time) + expect(key.last_used_at).to be_like_time(time) end it 'does not update the updated_at field' do diff --git a/spec/services/merge_requests/post_merge_service_spec.rb b/spec/services/merge_requests/post_merge_service_spec.rb index 790ecce8ded..46e4e3559dc 100644 --- a/spec/services/merge_requests/post_merge_service_spec.rb +++ b/spec/services/merge_requests/post_merge_service_spec.rb @@ -47,5 +47,18 @@ describe MergeRequests::PostMergeService do expect(diff_removal_service).to have_received(:execute) end + + it 'marks MR as merged regardless of errors when closing issues' do + merge_request.update(target_branch: 'foo') + allow(project).to receive(:default_branch).and_return('foo') + + issue = create(:issue, project: project) + allow(merge_request).to receive(:closes_issues).and_return([issue]) + allow_any_instance_of(Issues::CloseService).to receive(:execute).with(issue, commit: merge_request).and_raise + + expect { described_class.new(project, user, {}).execute(merge_request) }.to raise_error + + expect(merge_request.reload).to be_merged + end end end diff --git a/spec/services/update_merge_request_metrics_service_spec.rb b/spec/services/update_merge_request_metrics_service_spec.rb index b5fb999381d..812dd42934d 100644 --- a/spec/services/update_merge_request_metrics_service_spec.rb +++ b/spec/services/update_merge_request_metrics_service_spec.rb @@ -12,7 +12,7 @@ describe MergeRequestMetricsService do service.merge(event) expect(metrics.merged_by).to eq(user) - expect(metrics.merged_at).to eq(event.created_at) + expect(metrics.merged_at).to be_like_time(event.created_at) end end @@ -25,7 +25,7 @@ describe MergeRequestMetricsService do service.close(event) expect(metrics.latest_closed_by).to eq(user) - expect(metrics.latest_closed_at).to eq(event.created_at) + expect(metrics.latest_closed_at).to be_like_time(event.created_at) end end diff --git a/spec/services/users/refresh_authorized_projects_service_spec.rb b/spec/services/users/refresh_authorized_projects_service_spec.rb index 08fd26d67fd..e5fde07a6eb 100644 --- a/spec/services/users/refresh_authorized_projects_service_spec.rb +++ b/spec/services/users/refresh_authorized_projects_service_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Users::RefreshAuthorizedProjectsService do + include ExclusiveLeaseHelpers + # We're using let! here so that any expectations for the service class are not # triggered twice. let!(:project) { create(:project) } @@ -10,12 +12,10 @@ describe Users::RefreshAuthorizedProjectsService do describe '#execute', :clean_gitlab_redis_shared_state do it 'refreshes the authorizations using a lease' do - expect_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain) - .and_return('foo') - - expect(Gitlab::ExclusiveLease).to receive(:cancel) - .with(an_instance_of(String), 'foo') + lease_key = "refresh_authorized_projects:#{user.id}" + expect_to_obtain_exclusive_lease(lease_key, 'uuid') + expect_to_cancel_exclusive_lease(lease_key, 'uuid') expect(service).to receive(:execute_without_lease) service.execute diff --git a/spec/support/helpers/exclusive_lease_helpers.rb b/spec/support/helpers/exclusive_lease_helpers.rb new file mode 100644 index 00000000000..383cc7dee81 --- /dev/null +++ b/spec/support/helpers/exclusive_lease_helpers.rb @@ -0,0 +1,36 @@ +module ExclusiveLeaseHelpers + def stub_exclusive_lease(key = nil, uuid = 'uuid', renew: false, timeout: nil) + key ||= instance_of(String) + timeout ||= instance_of(Integer) + + lease = instance_double( + Gitlab::ExclusiveLease, + try_obtain: uuid, + exists?: true, + renew: renew + ) + + allow(Gitlab::ExclusiveLease) + .to receive(:new) + .with(key, timeout: timeout) + .and_return(lease) + + lease + end + + def stub_exclusive_lease_taken(key = nil, timeout: nil) + stub_exclusive_lease(key, nil, timeout: timeout) + end + + def expect_to_obtain_exclusive_lease(key, uuid = 'uuid', timeout: nil) + lease = stub_exclusive_lease(key, uuid, timeout: timeout) + + expect(lease).to receive(:try_obtain) + end + + def expect_to_cancel_exclusive_lease(key, uuid) + expect(Gitlab::ExclusiveLease) + .to receive(:cancel) + .with(key, uuid) + end +end diff --git a/spec/support/matchers/graphql_matchers.rb b/spec/support/matchers/graphql_matchers.rb index d23cbaf4beb..be6fa4c71a0 100644 --- a/spec/support/matchers/graphql_matchers.rb +++ b/spec/support/matchers/graphql_matchers.rb @@ -7,9 +7,24 @@ RSpec::Matchers.define :require_graphql_authorizations do |*expected| end RSpec::Matchers.define :have_graphql_fields do |*expected| + def expected_field_names + expected.map { |name| GraphqlHelpers.fieldnamerize(name) } + end + match do |kls| - field_names = expected.map { |name| GraphqlHelpers.fieldnamerize(name) } - expect(kls.fields.keys).to contain_exactly(*field_names) + expect(kls.fields.keys).to contain_exactly(*expected_field_names) + end + + failure_message do |kls| + missing = expected_field_names - kls.fields.keys + extra = kls.fields.keys - expected_field_names + + message = [] + + message << "is missing fields: <#{missing.inspect}>" if missing.any? + message << "contained unexpected fields: <#{extra.inspect}>" if extra.any? + + message.join("\n") end end @@ -44,3 +59,13 @@ RSpec::Matchers.define :have_graphql_resolver do |expected| end end end + +RSpec::Matchers.define :expose_permissions_using do |expected| + match do |type| + permission_field = type.fields['userPermissions'] + + expect(permission_field).not_to be_nil + expect(permission_field.type).to be_non_null + expect(permission_field.type.of_type.graphql_name).to eq(expected.graphql_name) + end +end diff --git a/spec/support/shared_examples/ci_trace_shared_examples.rb b/spec/support/shared_examples/ci_trace_shared_examples.rb index 6dbe0f6f980..db723a323f8 100644 --- a/spec/support/shared_examples/ci_trace_shared_examples.rb +++ b/spec/support/shared_examples/ci_trace_shared_examples.rb @@ -247,8 +247,10 @@ shared_examples_for 'common trace features' do end context 'when another process has already been archiving', :clean_gitlab_redis_shared_state do + include ExclusiveLeaseHelpers + before do - Gitlab::ExclusiveLease.new("trace:archive:#{trace.job.id}", timeout: 1.hour).try_obtain + stub_exclusive_lease_taken("trace:archive:#{trace.job.id}", timeout: 1.hour) end it 'blocks concurrent archiving' do diff --git a/spec/support/shared_examples/requests/api/merge_requests_list.rb b/spec/support/shared_examples/requests/api/merge_requests_list.rb index d5e22b8cb56..a401f7541f0 100644 --- a/spec/support/shared_examples/requests/api/merge_requests_list.rb +++ b/spec/support/shared_examples/requests/api/merge_requests_list.rb @@ -29,7 +29,7 @@ shared_examples 'merge requests list' do expect(response).to have_gitlab_http_status(200) expect(response).to include_pagination_headers expect(json_response).to be_an Array - expect(json_response.length).to eq(3) + expect(json_response.length).to eq(4) expect(json_response.last['title']).to eq(merge_request.title) expect(json_response.last).to have_key('web_url') expect(json_response.last['sha']).to eq(merge_request.diff_head_sha) @@ -53,7 +53,7 @@ shared_examples 'merge requests list' do expect(response).to include_pagination_headers expect(json_response.last.keys).to match_array(%w(id iid title web_url created_at description project_id state updated_at)) expect(json_response).to be_an Array - expect(json_response.length).to eq(3) + expect(json_response.length).to eq(4) expect(json_response.last['iid']).to eq(merge_request.iid) expect(json_response.last['title']).to eq(merge_request.title) expect(json_response.last).to have_key('web_url') @@ -70,7 +70,7 @@ shared_examples 'merge requests list' do expect(response).to have_gitlab_http_status(200) expect(response).to include_pagination_headers expect(json_response).to be_an Array - expect(json_response.length).to eq(3) + expect(json_response.length).to eq(4) expect(json_response.last['title']).to eq(merge_request.title) end @@ -216,7 +216,7 @@ shared_examples 'merge requests list' do expect(response).to have_gitlab_http_status(200) expect(response).to include_pagination_headers expect(json_response).to be_an Array - expect(json_response.length).to eq(3) + expect(json_response.length).to eq(4) response_dates = json_response.map { |merge_request| merge_request['created_at'] } expect(response_dates).to eq(response_dates.sort) end @@ -229,7 +229,7 @@ shared_examples 'merge requests list' do expect(response).to have_gitlab_http_status(200) expect(response).to include_pagination_headers expect(json_response).to be_an Array - expect(json_response.length).to eq(3) + expect(json_response.length).to eq(4) response_dates = json_response.map { |merge_request| merge_request['created_at'] } expect(response_dates).to eq(response_dates.sort.reverse) end @@ -242,7 +242,7 @@ shared_examples 'merge requests list' do expect(response).to have_gitlab_http_status(200) expect(response).to include_pagination_headers expect(json_response).to be_an Array - expect(json_response.length).to eq(3) + expect(json_response.length).to eq(4) response_dates = json_response.map { |merge_request| merge_request['updated_at'] } expect(response_dates).to eq(response_dates.sort.reverse) end @@ -255,7 +255,7 @@ shared_examples 'merge requests list' do expect(response).to have_gitlab_http_status(200) expect(response).to include_pagination_headers expect(json_response).to be_an Array - expect(json_response.length).to eq(3) + expect(json_response.length).to eq(4) response_dates = json_response.map { |merge_request| merge_request['created_at'] } expect(response_dates).to eq(response_dates.sort) end @@ -265,7 +265,7 @@ shared_examples 'merge requests list' do it 'returns merge requests with the given source branch' do get api(endpoint_path, user), source_branch: merge_request_closed.source_branch, state: 'all' - expect_response_contain_exactly(merge_request_closed, merge_request_merged) + expect_response_contain_exactly(merge_request_closed, merge_request_merged, merge_request_locked) end end @@ -273,7 +273,7 @@ shared_examples 'merge requests list' do it 'returns merge requests with the given target branch' do get api(endpoint_path, user), target_branch: merge_request_closed.target_branch, state: 'all' - expect_response_contain_exactly(merge_request_closed, merge_request_merged) + expect_response_contain_exactly(merge_request_closed, merge_request_merged, merge_request_locked) end end end diff --git a/spec/support/shared_examples/requests/graphql_shared_examples.rb b/spec/support/shared_examples/requests/graphql_shared_examples.rb index 9b2b74593a5..fe7b7bc306f 100644 --- a/spec/support/shared_examples/requests/graphql_shared_examples.rb +++ b/spec/support/shared_examples/requests/graphql_shared_examples.rb @@ -3,8 +3,8 @@ require 'spec_helper' shared_examples 'a working graphql query' do include GraphqlHelpers - it 'is returns a successfull response', :aggregate_failures do - expect(response).to be_success + it 'returns a successful response', :aggregate_failures do + expect(response).to have_gitlab_http_status(:success) expect(graphql_errors['errors']).to be_nil expect(json_response.keys).to include('data') end diff --git a/spec/support/shared_examples/throttled_touch.rb b/spec/support/shared_examples/throttled_touch.rb index 4a25bb9b750..eba990d4037 100644 --- a/spec/support/shared_examples/throttled_touch.rb +++ b/spec/support/shared_examples/throttled_touch.rb @@ -3,7 +3,7 @@ shared_examples_for 'throttled touch' do it 'updates the updated_at timestamp' do Timecop.freeze do subject.touch - expect(subject.updated_at).to eq(Time.zone.now) + expect(subject.updated_at).to be_like_time(Time.zone.now) end end @@ -14,7 +14,7 @@ shared_examples_for 'throttled touch' do Timecop.freeze(first_updated_at) { subject.touch } Timecop.freeze(second_updated_at) { subject.touch } - expect(subject.updated_at).to eq(first_updated_at) + expect(subject.updated_at).to be_like_time(first_updated_at) end end end diff --git a/spec/support/shared_examples/uploaders/object_storage_shared_examples.rb b/spec/support/shared_examples/uploaders/object_storage_shared_examples.rb index 19800c6638f..1bd176280c5 100644 --- a/spec/support/shared_examples/uploaders/object_storage_shared_examples.rb +++ b/spec/support/shared_examples/uploaders/object_storage_shared_examples.rb @@ -76,8 +76,10 @@ shared_examples "migrates" do |to_store:, from_store: nil| end context 'when migrate! is occupied by another process' do + include ExclusiveLeaseHelpers + before do - @uuid = Gitlab::ExclusiveLease.new(subject.exclusive_lease_key, timeout: 1.hour.to_i).try_obtain + stub_exclusive_lease_taken(subject.exclusive_lease_key, timeout: 1.hour.to_i) end it 'does not execute migrate!' do @@ -91,10 +93,6 @@ shared_examples "migrates" do |to_store:, from_store: nil| expect { subject.use_file }.to raise_error(ObjectStorage::ExclusiveLeaseTaken) end - - after do - Gitlab::ExclusiveLease.cancel(subject.exclusive_lease_key, @uuid) - end end context 'migration is unsuccessful' do diff --git a/spec/tasks/gitlab/db_rake_spec.rb b/spec/tasks/gitlab/db_rake_spec.rb index fc52c04e78d..b81aea23306 100644 --- a/spec/tasks/gitlab/db_rake_spec.rb +++ b/spec/tasks/gitlab/db_rake_spec.rb @@ -20,7 +20,7 @@ describe 'gitlab:db namespace rake task' do describe 'configure' do it 'invokes db:migrate when schema has already been loaded' do - allow(ActiveRecord::Base.connection).to receive(:tables).and_return(['default']) + allow(ActiveRecord::Base.connection).to receive(:tables).and_return(%w[table1 table2]) expect(Rake::Task['db:migrate']).to receive(:invoke) expect(Rake::Task['db:schema:load']).not_to receive(:invoke) expect(Rake::Task['db:seed_fu']).not_to receive(:invoke) @@ -35,6 +35,14 @@ describe 'gitlab:db namespace rake task' do expect { run_rake_task('gitlab:db:configure') }.not_to raise_error end + it 'invokes db:shema:load and db:seed_fu when there is only a single table present' do + allow(ActiveRecord::Base.connection).to receive(:tables).and_return(['default']) + expect(Rake::Task['db:schema:load']).to receive(:invoke) + expect(Rake::Task['db:seed_fu']).to receive(:invoke) + expect(Rake::Task['db:migrate']).not_to receive(:invoke) + expect { run_rake_task('gitlab:db:configure') }.not_to raise_error + end + it 'does not invoke any other rake tasks during an error' do allow(ActiveRecord::Base).to receive(:connection).and_raise(RuntimeError, 'error') expect(Rake::Task['db:migrate']).not_to receive(:invoke) diff --git a/spec/workers/project_cache_worker_spec.rb b/spec/workers/project_cache_worker_spec.rb index 6b1f2ff3227..8c4daac5f80 100644 --- a/spec/workers/project_cache_worker_spec.rb +++ b/spec/workers/project_cache_worker_spec.rb @@ -1,49 +1,58 @@ require 'spec_helper' describe ProjectCacheWorker do + include ExclusiveLeaseHelpers + let(:worker) { described_class.new } let(:project) { create(:project, :repository) } let(:statistics) { project.statistics } + let(:lease_key) { "project_cache_worker:#{project.id}:update_statistics" } + let(:lease_timeout) { ProjectCacheWorker::LEASE_TIMEOUT } - describe '#perform' do - before do - allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain) - .and_return(true) - end + before do + stub_exclusive_lease(lease_key, timeout: lease_timeout) + allow(Project).to receive(:find_by) + .with(id: project.id) + .and_return(project) + end + + describe '#perform' do context 'with a non-existing project' do - it 'does nothing' do - expect(worker).not_to receive(:update_statistics) + it 'does not update statistic' do + allow(Project).to receive(:find_by).with(id: -1).and_return(nil) - worker.perform(-1) + expect(subject).not_to receive(:update_statistics) + + subject.perform(-1) end end context 'with an existing project without a repository' do - it 'does nothing' do - allow_any_instance_of(Repository).to receive(:exists?).and_return(false) + it 'does not update statistics' do + allow(project.repository).to receive(:exists?).and_return(false) - expect(worker).not_to receive(:update_statistics) + expect(subject).not_to receive(:update_statistics) - worker.perform(project.id) + subject.perform(project.id) end end context 'with an existing project' do it 'updates the project statistics' do - expect(worker).to receive(:update_statistics) - .with(kind_of(Project), %i(repository_size)) - .and_call_original + expect(subject).to receive(:update_statistics) + .with(%w(repository_size)) + .and_call_original - worker.perform(project.id, [], %w(repository_size)) + subject.perform(project.id, [], %w(repository_size)) end it 'refreshes the method caches' do - expect_any_instance_of(Repository).to receive(:refresh_method_caches) - .with(%i(readme)) - .and_call_original + expect(project.repository).to receive(:refresh_method_caches) + .with(%i(readme)) + .and_call_original - worker.perform(project.id, %w(readme)) + subject.perform(project.id, %w(readme)) end context 'with plain readme' do @@ -51,39 +60,40 @@ describe ProjectCacheWorker do allow(MarkupHelper).to receive(:gitlab_markdown?).and_return(false) allow(MarkupHelper).to receive(:plain?).and_return(true) - expect_any_instance_of(Repository).to receive(:refresh_method_caches) - .with(%i(readme)) - .and_call_original - worker.perform(project.id, %w(readme)) + expect(project.repository).to receive(:refresh_method_caches) + .with(%i(readme)) + .and_call_original + + subject.perform(project.id, %w(readme)) end end end - end - describe '#update_statistics' do context 'when a lease could not be obtained' do it 'does not update the repository size' do - allow(worker).to receive(:try_obtain_lease_for) - .with(project.id, :update_statistics) - .and_return(false) + stub_exclusive_lease_taken(lease_key, timeout: lease_timeout) - expect(statistics).not_to receive(:refresh!) + expect(project.statistics).not_to receive(:refresh!) - worker.update_statistics(project) + subject.perform(project.id, [], %w(repository_size)) end end context 'when a lease could be obtained' do it 'updates the project statistics' do - allow(worker).to receive(:try_obtain_lease_for) - .with(project.id, :update_statistics) - .and_return(true) + stub_exclusive_lease(lease_key, timeout: lease_timeout) + + expect(project.statistics).to receive(:refresh!) + .with(only: %i(repository_size)) + .and_call_original + + subject.perform(project.id, [], %i(repository_size)) + end - expect(statistics).to receive(:refresh!) - .with(only: %i(repository_size)) - .and_call_original + it 'cancels the lease after statistics has been updated' do + expect(subject).to receive(:release_lease).with('uuid') - worker.update_statistics(project, %i(repository_size)) + subject.perform(project.id, [], %i(repository_size)) end end end diff --git a/spec/workers/project_migrate_hashed_storage_worker_spec.rb b/spec/workers/project_migrate_hashed_storage_worker_spec.rb index 2e3951e7afc..9551e358af1 100644 --- a/spec/workers/project_migrate_hashed_storage_worker_spec.rb +++ b/spec/workers/project_migrate_hashed_storage_worker_spec.rb @@ -1,53 +1,47 @@ require 'spec_helper' describe ProjectMigrateHashedStorageWorker, :clean_gitlab_redis_shared_state do + include ExclusiveLeaseHelpers + describe '#perform' do let(:project) { create(:project, :empty_repo) } - let(:pending_delete_project) { create(:project, :empty_repo, pending_delete: true) } + let(:lease_key) { "project_migrate_hashed_storage_worker:#{project.id}" } + let(:lease_timeout) { ProjectMigrateHashedStorageWorker::LEASE_TIMEOUT } + + it 'skips when project no longer exists' do + expect(::Projects::HashedStorageMigrationService).not_to receive(:new) + + subject.perform(-1) + end - context 'when have exclusive lease' do - before do - lease = subject.lease_for(project.id) + it 'skips when project is pending delete' do + pending_delete_project = create(:project, :empty_repo, pending_delete: true) - allow(Gitlab::ExclusiveLease).to receive(:new).and_return(lease) - allow(lease).to receive(:try_obtain).and_return(true) - end + expect(::Projects::HashedStorageMigrationService).not_to receive(:new) - it 'skips when project no longer exists' do - nonexistent_id = 999999999999 + subject.perform(pending_delete_project.id) + end - expect(::Projects::HashedStorageMigrationService).not_to receive(:new) - subject.perform(nonexistent_id) - end + it 'delegates removal to service class when have exclusive lease' do + stub_exclusive_lease(lease_key, 'uuid', timeout: lease_timeout) - it 'skips when project is pending delete' do - expect(::Projects::HashedStorageMigrationService).not_to receive(:new) + migration_service = spy - subject.perform(pending_delete_project.id) - end + allow(::Projects::HashedStorageMigrationService) + .to receive(:new).with(project, subject.logger) + .and_return(migration_service) - it 'delegates removal to service class' do - service = double('service') - expect(::Projects::HashedStorageMigrationService).to receive(:new).with(project, subject.logger).and_return(service) - expect(service).to receive(:execute) + subject.perform(project.id) - subject.perform(project.id) - end + expect(migration_service).to have_received(:execute) end - context 'when dont have exclusive lease' do - before do - lease = subject.lease_for(project.id) - - allow(Gitlab::ExclusiveLease).to receive(:new).and_return(lease) - allow(lease).to receive(:try_obtain).and_return(false) - end + it 'skips when dont have lease when dont have exclusive lease' do + stub_exclusive_lease_taken(lease_key, timeout: lease_timeout) - it 'skips when dont have lease' do - expect(::Projects::HashedStorageMigrationService).not_to receive(:new) + expect(::Projects::HashedStorageMigrationService).not_to receive(:new) - subject.perform(project.id) - end + subject.perform(project.id) end end end diff --git a/spec/workers/propagate_service_template_worker_spec.rb b/spec/workers/propagate_service_template_worker_spec.rb index b8b65ead9b3..af1fb80a51d 100644 --- a/spec/workers/propagate_service_template_worker_spec.rb +++ b/spec/workers/propagate_service_template_worker_spec.rb @@ -1,29 +1,29 @@ require 'spec_helper' describe PropagateServiceTemplateWorker do - let!(:service_template) do - PushoverService.create( - template: true, - active: true, - properties: { - device: 'MyDevice', - sound: 'mic', - priority: 4, - user_key: 'asdf', - api_key: '123456789' - }) - end - - before do - allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain) - .and_return(true) - end + include ExclusiveLeaseHelpers describe '#perform' do it 'calls the propagate service with the template' do - expect(Projects::PropagateServiceTemplate).to receive(:propagate).with(service_template) + template = PushoverService.create( + template: true, + active: true, + properties: { + device: 'MyDevice', + sound: 'mic', + priority: 4, + user_key: 'asdf', + api_key: '123456789' + }) + + stub_exclusive_lease("propagate_service_template_worker:#{template.id}", + timeout: PropagateServiceTemplateWorker::LEASE_TIMEOUT) + + expect(Projects::PropagateServiceTemplate) + .to receive(:propagate) + .with(template) - subject.perform(service_template.id) + subject.perform(template.id) end end end diff --git a/spec/workers/repository_check/batch_worker_spec.rb b/spec/workers/repository_check/batch_worker_spec.rb index 6cd27d2fafb..6bc551be9ad 100644 --- a/spec/workers/repository_check/batch_worker_spec.rb +++ b/spec/workers/repository_check/batch_worker_spec.rb @@ -1,14 +1,19 @@ require 'spec_helper' describe RepositoryCheck::BatchWorker do + let(:shard_name) { 'default' } subject { described_class.new } + before do + Gitlab::ShardHealthCache.update([shard_name]) + end + it 'prefers projects that have never been checked' do projects = create_list(:project, 3, created_at: 1.week.ago) projects[0].update_column(:last_repository_check_at, 4.months.ago) projects[2].update_column(:last_repository_check_at, 3.months.ago) - expect(subject.perform).to eq(projects.values_at(1, 0, 2).map(&:id)) + expect(subject.perform(shard_name)).to eq(projects.values_at(1, 0, 2).map(&:id)) end it 'sorts projects by last_repository_check_at' do @@ -17,7 +22,7 @@ describe RepositoryCheck::BatchWorker do projects[1].update_column(:last_repository_check_at, 4.months.ago) projects[2].update_column(:last_repository_check_at, 3.months.ago) - expect(subject.perform).to eq(projects.values_at(1, 2, 0).map(&:id)) + expect(subject.perform(shard_name)).to eq(projects.values_at(1, 2, 0).map(&:id)) end it 'excludes projects that were checked recently' do @@ -26,7 +31,14 @@ describe RepositoryCheck::BatchWorker do projects[1].update_column(:last_repository_check_at, 2.months.ago) projects[2].update_column(:last_repository_check_at, 3.days.ago) - expect(subject.perform).to eq([projects[1].id]) + expect(subject.perform(shard_name)).to eq([projects[1].id]) + end + + it 'excludes projects on another shard' do + projects = create_list(:project, 2, created_at: 1.week.ago) + projects[0].update_column(:repository_storage, 'other') + + expect(subject.perform(shard_name)).to eq([projects[1].id]) end it 'does nothing when repository checks are disabled' do @@ -34,13 +46,20 @@ describe RepositoryCheck::BatchWorker do stub_application_setting(repository_checks_enabled: false) - expect(subject.perform).to eq(nil) + expect(subject.perform(shard_name)).to eq(nil) + end + + it 'does nothing when shard is unhealthy' do + shard_name = 'broken' + create(:project, created_at: 1.week.ago, repository_storage: shard_name) + + expect(subject.perform(shard_name)).to eq(nil) end it 'skips projects created less than 24 hours ago' do project = create(:project) project.update_column(:created_at, 23.hours.ago) - expect(subject.perform).to eq([]) + expect(subject.perform(shard_name)).to eq([]) end end diff --git a/spec/workers/repository_check/dispatch_worker_spec.rb b/spec/workers/repository_check/dispatch_worker_spec.rb new file mode 100644 index 00000000000..20a4f1f5344 --- /dev/null +++ b/spec/workers/repository_check/dispatch_worker_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe RepositoryCheck::DispatchWorker do + subject { described_class.new } + + it 'does nothing when repository checks are disabled' do + stub_application_setting(repository_checks_enabled: false) + + expect(RepositoryCheck::BatchWorker).not_to receive(:perform_async) + + subject.perform + end + + it 'dispatches work to RepositoryCheck::BatchWorker' do + expect(RepositoryCheck::BatchWorker).to receive(:perform_async).at_least(:once) + + subject.perform + end + + context 'with unhealthy shard' do + let(:default_shard_name) { 'default' } + let(:unhealthy_shard_name) { 'unhealthy' } + let(:default_shard) { Gitlab::HealthChecks::Result.new(true, nil, shard: default_shard_name) } + let(:unhealthy_shard) { Gitlab::HealthChecks::Result.new(false, '14:Connect Failed', shard: unhealthy_shard_name) } + + before do + allow(Gitlab::HealthChecks::GitalyCheck).to receive(:readiness).and_return([default_shard, unhealthy_shard]) + end + + it 'only triggers RepositoryCheck::BatchWorker for healthy shards' do + expect(RepositoryCheck::BatchWorker).to receive(:perform_async).with('default') + + subject.perform + end + end +end diff --git a/spec/workers/repository_remove_remote_worker_spec.rb b/spec/workers/repository_remove_remote_worker_spec.rb index 5968c5da3c9..a653f6f926c 100644 --- a/spec/workers/repository_remove_remote_worker_spec.rb +++ b/spec/workers/repository_remove_remote_worker_spec.rb @@ -1,44 +1,50 @@ require 'rails_helper' describe RepositoryRemoveRemoteWorker do - subject(:worker) { described_class.new } + include ExclusiveLeaseHelpers describe '#perform' do - let(:remote_name) { 'joe'} let!(:project) { create(:project, :repository) } + let(:remote_name) { 'joe'} + let(:lease_key) { "remove_remote_#{project.id}_#{remote_name}" } + let(:lease_timeout) { RepositoryRemoveRemoteWorker::LEASE_TIMEOUT } - context 'when it cannot obtain lease' do - it 'logs error' do - allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain) { nil } - - expect_any_instance_of(Repository).not_to receive(:remove_remote) - expect(worker).to receive(:log_error).with('Cannot obtain an exclusive lease. There must be another instance already in execution.') - - worker.perform(project.id, remote_name) - end + it 'returns nil when project does not exist' do + expect(subject.perform(-1, 'remote_name')).to be_nil end - context 'when it gets the lease' do + context 'when project exists' do before do - allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain).and_return(true) + allow(Project) + .to receive(:find_by) + .with(id: project.id) + .and_return(project) end - context 'when project does not exist' do - it 'returns nil' do - expect(worker.perform(-1, 'remote_name')).to be_nil - end - end + it 'does not remove remote when cannot obtain lease' do + stub_exclusive_lease_taken(lease_key, timeout: lease_timeout) + + expect(project.repository) + .not_to receive(:remove_remote) - context 'when project exists' do - it 'removes remote from repository' do - masterrev = project.repository.find_branch('master').dereferenced_target + expect(subject) + .to receive(:log_error) + .with('Cannot obtain an exclusive lease. There must be another instance already in execution.') - create_remote_branch(remote_name, 'remote_branch', masterrev) + subject.perform(project.id, remote_name) + end - expect_any_instance_of(Repository).to receive(:remove_remote).with(remote_name).and_call_original + it 'removes remote from repository when obtain a lease' do + stub_exclusive_lease(lease_key, timeout: lease_timeout) + masterrev = project.repository.find_branch('master').dereferenced_target + create_remote_branch(remote_name, 'remote_branch', masterrev) - worker.perform(project.id, remote_name) - end + expect(project.repository) + .to receive(:remove_remote) + .with(remote_name) + .and_call_original + + subject.perform(project.id, remote_name) end end end @@ -47,6 +53,7 @@ describe RepositoryRemoveRemoteWorker do rugged = Gitlab::GitalyClient::StorageSettings.allow_disk_access do project.repository.rugged end + rugged.references.create("refs/remotes/#{remote_name}/#{branch_name}", target.id) end end diff --git a/spec/workers/stuck_ci_jobs_worker_spec.rb b/spec/workers/stuck_ci_jobs_worker_spec.rb index 2605c14334f..856886e3df5 100644 --- a/spec/workers/stuck_ci_jobs_worker_spec.rb +++ b/spec/workers/stuck_ci_jobs_worker_spec.rb @@ -1,14 +1,21 @@ require 'spec_helper' describe StuckCiJobsWorker do + include ExclusiveLeaseHelpers + let!(:runner) { create :ci_runner } let!(:job) { create :ci_build, runner: runner } - let(:worker) { described_class.new } - let(:exclusive_lease_uuid) { SecureRandom.uuid } + let(:trace_lease_key) { "trace:archive:#{job.id}" } + let(:trace_lease_uuid) { SecureRandom.uuid } + let(:worker_lease_key) { StuckCiJobsWorker::EXCLUSIVE_LEASE_KEY } + let(:worker_lease_uuid) { SecureRandom.uuid } + + subject(:worker) { described_class.new } before do + stub_exclusive_lease(worker_lease_key, worker_lease_uuid) + stub_exclusive_lease(trace_lease_key, trace_lease_uuid) job.update!(status: status, updated_at: updated_at) - allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain).and_return(exclusive_lease_uuid) end shared_examples 'job is dropped' do @@ -44,16 +51,19 @@ describe StuckCiJobsWorker do context 'when job was not updated for more than 1 day ago' do let(:updated_at) { 2.days.ago } + it_behaves_like 'job is dropped' end context 'when job was updated in less than 1 day ago' do let(:updated_at) { 6.hours.ago } + it_behaves_like 'job is unchanged' end context 'when job was not updated for more than 1 hour ago' do let(:updated_at) { 2.hours.ago } + it_behaves_like 'job is unchanged' end end @@ -65,11 +75,14 @@ describe StuckCiJobsWorker do context 'when job was not updated for more than 1 hour ago' do let(:updated_at) { 2.hours.ago } + it_behaves_like 'job is dropped' end - context 'when job was updated in less than 1 hour ago' do + context 'when job was updated in less than 1 + hour ago' do let(:updated_at) { 30.minutes.ago } + it_behaves_like 'job is unchanged' end end @@ -80,11 +93,13 @@ describe StuckCiJobsWorker do context 'when job was not updated for more than 1 hour ago' do let(:updated_at) { 2.hours.ago } + it_behaves_like 'job is dropped' end context 'when job was updated in less than 1 hour ago' do let(:updated_at) { 30.minutes.ago } + it_behaves_like 'job is unchanged' end end @@ -93,6 +108,7 @@ describe StuckCiJobsWorker do context "when job is #{status}" do let(:status) { status } let(:updated_at) { 2.days.ago } + it_behaves_like 'job is unchanged' end end @@ -119,23 +135,27 @@ describe StuckCiJobsWorker do it 'is guard by exclusive lease when executed concurrently' do expect(worker).to receive(:drop).at_least(:once).and_call_original expect(worker2).not_to receive(:drop) + worker.perform - allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain).and_return(false) + + stub_exclusive_lease_taken(worker_lease_key) + worker2.perform end it 'can be executed in sequence' do expect(worker).to receive(:drop).at_least(:once).and_call_original expect(worker2).to receive(:drop).at_least(:once).and_call_original + worker.perform worker2.perform end - it 'cancels exclusive lease after worker perform' do - worker.perform + it 'cancels exclusive leases after worker perform' do + expect_to_cancel_exclusive_lease(trace_lease_key, trace_lease_uuid) + expect_to_cancel_exclusive_lease(worker_lease_key, worker_lease_uuid) - expect(Gitlab::ExclusiveLease.new(described_class::EXCLUSIVE_LEASE_KEY, timeout: 1.hour)) - .not_to be_exists + worker.perform end end end diff --git a/vendor/assets/javascripts/date.format.js b/vendor/assets/javascripts/date.format.js deleted file mode 100644 index 2c9b4825443..00000000000 --- a/vendor/assets/javascripts/date.format.js +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Date Format 1.2.3 - * (c) 2007-2009 Steven Levithan <stevenlevithan.com> - * MIT license - * - * Includes enhancements by Scott Trenda <scott.trenda.net> - * and Kris Kowal <cixar.com/~kris.kowal/> - * - * Accepts a date, a mask, or a date and a mask. - * Returns a formatted version of the given date. - * The date defaults to the current date/time. - * The mask defaults to dateFormat.masks.default. - */ - (function (global, factory) { - typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : - typeof define === 'function' && define.amd ? define(factory) : - (global.dateFormat = factory()); - }(this, (function () { 'use strict'; - var dateFormat = function () { - var token = /d{1,4}|m{1,4}|yy(?:yy)?|([HhMsTt])\1?|[LloSZ]|"[^"]*"|'[^']*'/g, - timezone = /\b(?:[PMCEA][SDP]T|(?:Pacific|Mountain|Central|Eastern|Atlantic) (?:Standard|Daylight|Prevailing) Time|(?:GMT|UTC)(?:[-+]\d{4})?)\b/g, - timezoneClip = /[^-+\dA-Z]/g, - pad = function (val, len) { - val = String(val); - len = len || 2; - while (val.length < len) val = "0" + val; - return val; - }; - - // Regexes and supporting functions are cached through closure - return function (date, mask, utc) { - var dF = dateFormat; - - // You can't provide utc if you skip other args (use the "UTC:" mask prefix) - if (arguments.length == 1 && Object.prototype.toString.call(date) == "[object String]" && !/\d/.test(date)) { - mask = date; - date = undefined; - } - - // Passing date through Date applies Date.parse, if necessary - date = date ? new Date(date) : new Date; - if (isNaN(date)) throw SyntaxError("invalid date"); - - mask = String(dF.masks[mask] || mask || dF.masks["default"]); - - // Allow setting the utc argument via the mask - if (mask.slice(0, 4) == "UTC:") { - mask = mask.slice(4); - utc = true; - } - - var _ = utc ? "getUTC" : "get", - d = date[_ + "Date"](), - D = date[_ + "Day"](), - m = date[_ + "Month"](), - y = date[_ + "FullYear"](), - H = date[_ + "Hours"](), - M = date[_ + "Minutes"](), - s = date[_ + "Seconds"](), - L = date[_ + "Milliseconds"](), - o = utc ? 0 : date.getTimezoneOffset(), - flags = { - d: d, - dd: pad(d), - ddd: dF.i18n.dayNames[D], - dddd: dF.i18n.dayNames[D + 7], - m: m + 1, - mm: pad(m + 1), - mmm: dF.i18n.monthNames[m], - mmmm: dF.i18n.monthNames[m + 12], - yy: String(y).slice(2), - yyyy: y, - h: H % 12 || 12, - hh: pad(H % 12 || 12), - H: H, - HH: pad(H), - M: M, - MM: pad(M), - s: s, - ss: pad(s), - l: pad(L, 3), - L: pad(L > 99 ? Math.round(L / 10) : L), - t: H < 12 ? "a" : "p", - tt: H < 12 ? "am" : "pm", - T: H < 12 ? "A" : "P", - TT: H < 12 ? "AM" : "PM", - Z: utc ? "UTC" : (String(date).match(timezone) || [""]).pop().replace(timezoneClip, ""), - o: (o > 0 ? "-" : "+") + pad(Math.floor(Math.abs(o) / 60) * 100 + Math.abs(o) % 60, 4), - S: ["th", "st", "nd", "rd"][d % 10 > 3 ? 0 : (d % 100 - d % 10 != 10) * d % 10] - }; - - return mask.replace(token, function ($0) { - return $0 in flags ? flags[$0] : $0.slice(1, $0.length - 1); - }); - }; - }(); - - // Some common format strings - dateFormat.masks = { - "default": "ddd mmm dd yyyy HH:MM:ss", - shortDate: "m/d/yy", - mediumDate: "mmm d, yyyy", - longDate: "mmmm d, yyyy", - fullDate: "dddd, mmmm d, yyyy", - shortTime: "h:MM TT", - mediumTime: "h:MM:ss TT", - longTime: "h:MM:ss TT Z", - isoDate: "yyyy-mm-dd", - isoTime: "HH:MM:ss", - isoDateTime: "yyyy-mm-dd'T'HH:MM:ss", - isoUtcDateTime: "UTC:yyyy-mm-dd'T'HH:MM:ss'Z'" - }; - - // Internationalization strings - dateFormat.i18n = { - dayNames: [ - "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", - "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" - ], - monthNames: [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", - "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" - ] - }; - - // For convenience... - Date.prototype.format = function (mask, utc) { - return dateFormat(this, mask, utc); - }; - - return dateFormat; -}))); diff --git a/vendor/project_templates/express.tar.gz b/vendor/project_templates/express.tar.gz Binary files differindex 8dd5fa36987..fb357639a69 100644 --- a/vendor/project_templates/express.tar.gz +++ b/vendor/project_templates/express.tar.gz diff --git a/vendor/project_templates/rails.tar.gz b/vendor/project_templates/rails.tar.gz Binary files differindex 89337dc5c31..8454d2fc03b 100644 --- a/vendor/project_templates/rails.tar.gz +++ b/vendor/project_templates/rails.tar.gz diff --git a/vendor/project_templates/spring.tar.gz b/vendor/project_templates/spring.tar.gz Binary files differindex 31c90d0820f..55e25fdbe7c 100644 --- a/vendor/project_templates/spring.tar.gz +++ b/vendor/project_templates/spring.tar.gz diff --git a/yarn.lock b/yarn.lock index ef7fa659d6e..30d49ad276a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2348,6 +2348,10 @@ date-now@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" +dateformat@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" + de-indent@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d" |