diff options
246 files changed, 4616 insertions, 1174 deletions
diff --git a/.gitlab/issue_templates/Security developer workflow.md b/.gitlab/issue_templates/Security developer workflow.md index 4bc4215d21b..aaa16145399 100644 --- a/.gitlab/issue_templates/Security developer workflow.md +++ b/.gitlab/issue_templates/Security developer workflow.md @@ -20,10 +20,9 @@ Set the title to: `Description of the original issue` - [ ] Once the MR is ready to be merged, create MRs targetting the last 3 releases, plus the current RC if between the 7th and 22nd of the month. - [ ] At this point, it might be easy to squash the commits from the MR into one - You can use the script `bin/secpick` instead of the following steps, to help you cherry-picking. See the [secpick documentation] - - [ ] Create the branch `security-X-Y` from `X-Y-stable` if it doesn't exist (and make sure it's up to date with stable) - - [ ] Create each MR targetting the security branch `security-X-Y` - - [ ] Add the ~security label and prefix with the version `WIP: [X.Y]` the title of the MR -- [ ] Add the ~"Merge into Security" label to all of the MRs. + - [ ] Create each MR targetting the stable branch `X-Y-stable`, using the "Security Release" merge request template. + - Every merge request will have its own set of TODOs, so make sure to + complete those. - [ ] Make sure all MRs have a link in the [links section](#links) [secpick documentation]: https://gitlab.com/gitlab-org/release/docs/blob/master/general/security/developer.md#secpick-script diff --git a/.gitlab/merge_request_templates/Security Release.md b/.gitlab/merge_request_templates/Security Release.md index 9a0979f27a7..246f2dae009 100644 --- a/.gitlab/merge_request_templates/Security Release.md +++ b/.gitlab/merge_request_templates/Security Release.md @@ -4,6 +4,9 @@ This MR should be created on `dev.gitlab.org`. See [the general developer security release guidelines](https://gitlab.com/gitlab-org/release/docs/blob/master/general/security/developer.md). +This merge request _must not_ close the corresponding security issue _unless_ it +targets master. + --> ## Related issues @@ -12,7 +15,7 @@ See [the general developer security release guidelines](https://gitlab.com/gitla ## Developer checklist - [ ] Link to the developer security workflow issue on `dev.gitlab.org` -- [ ] MR targets `master` or `security-X-Y` for backports +- [ ] MR targets `master`, or `X-Y-stable` for backports - [ ] Milestone is set for the version this MR applies to - [ ] Title of this MR is the same as for all backports - [ ] A [CHANGELOG entry](https://docs.gitlab.com/ee/development/changelog.html) is added without a `merge_request` value, with `type` set to `security` @@ -25,4 +28,4 @@ See [the general developer security release guidelines](https://gitlab.com/gitla - [ ] Correct milestone is applied and the title is matching across all backports - [ ] Assigned to `@gitlab-release-tools-bot` with passing CI pipelines -/label ~security ~"Merge into Security" +/label ~security diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 092afa15df4..744068368fb 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -1.17.0 +1.18.0
\ No newline at end of file diff --git a/GITLAB_WORKHORSE_VERSION b/GITLAB_WORKHORSE_VERSION index fbb9ea12de3..2bf50aaf17a 100644 --- a/GITLAB_WORKHORSE_VERSION +++ b/GITLAB_WORKHORSE_VERSION @@ -1 +1 @@ -8.2.0 +8.3.0 @@ -422,7 +422,7 @@ group :ed25519 do end # Gitaly GRPC client -gem 'gitaly-proto', '~> 1.5.0', require: 'gitaly' +gem 'gitaly-proto', '~> 1.10.0', require: 'gitaly' gem 'grpc', '~> 1.15.0' gem 'google-protobuf', '~> 3.6' diff --git a/Gemfile.lock b/Gemfile.lock index f661da41507..0b2bd2c96bd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -278,7 +278,7 @@ GEM gettext_i18n_rails (>= 0.7.1) po_to_json (>= 1.0.0) rails (>= 3.2.0) - gitaly-proto (1.5.0) + gitaly-proto (1.10.0) grpc (~> 1.0) github-markup (1.7.0) gitlab-default_value_for (3.1.1) @@ -1020,7 +1020,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.3) - gitaly-proto (~> 1.5.0) + gitaly-proto (~> 1.10.0) github-markup (~> 1.7.0) gitlab-default_value_for (~> 3.1.1) gitlab-markup (~> 1.6.5) diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js index 947d019c725..52d9f2f0322 100644 --- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js @@ -1,8 +1,5 @@ import $ from 'jquery'; -import { DOMParser } from 'prosemirror-model'; import { getSelectedFragment } from '~/lib/utils/common_utils'; -import schema from './schema'; -import markdownSerializer from './serializer'; export class CopyAsGFM { constructor() { @@ -39,9 +36,13 @@ export class CopyAsGFM { div.appendChild(el.cloneNode(true)); const html = div.innerHTML; - clipboardData.setData('text/plain', el.textContent); - clipboardData.setData('text/x-gfm', this.nodeToGFM(el)); - clipboardData.setData('text/html', html); + CopyAsGFM.nodeToGFM(el) + .then(res => { + clipboardData.setData('text/plain', el.textContent); + clipboardData.setData('text/x-gfm', res); + clipboardData.setData('text/html', html); + }) + .catch(() => {}); } static pasteGFM(e) { @@ -137,11 +138,21 @@ export class CopyAsGFM { } static nodeToGFM(node) { - const wrapEl = document.createElement('div'); - wrapEl.appendChild(node.cloneNode(true)); - const doc = DOMParser.fromSchema(schema).parse(wrapEl); - - return markdownSerializer.serialize(doc); + return Promise.all([ + import(/* webpackChunkName: 'gfm_copy_extra' */ 'prosemirror-model'), + import(/* webpackChunkName: 'gfm_copy_extra' */ './schema'), + import(/* webpackChunkName: 'gfm_copy_extra' */ './serializer'), + ]) + .then(([prosemirrorModel, schema, markdownSerializer]) => { + const { DOMParser } = prosemirrorModel; + const wrapEl = document.createElement('div'); + wrapEl.appendChild(node.cloneNode(true)); + const doc = DOMParser.fromSchema(schema.default).parse(wrapEl); + + const res = markdownSerializer.default.serialize(doc); + return res; + }) + .catch(() => {}); } } diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index 0eb067d4963..680f2031409 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js @@ -64,26 +64,30 @@ export default class ShortcutsIssuable extends Shortcuts { const el = CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true)); const blockquoteEl = document.createElement('blockquote'); blockquoteEl.appendChild(el); - const text = CopyAsGFM.nodeToGFM(blockquoteEl); - - if (text.trim() === '') { - return false; - } - - // If replyField already has some content, add a newline before our quote - const separator = ($replyField.val().trim() !== '' && '\n\n') || ''; - $replyField - .val((a, current) => `${current}${separator}${text}\n\n`) - .trigger('input') - .trigger('change'); - - // Trigger autosize - const event = document.createEvent('Event'); - event.initEvent('autosize:update', true, false); - $replyField.get(0).dispatchEvent(event); + CopyAsGFM.nodeToGFM(blockquoteEl) + .then(text => { + if (text.trim() === '') { + return false; + } + + // If replyField already has some content, add a newline before our quote + const separator = ($replyField.val().trim() !== '' && '\n\n') || ''; + $replyField + .val((a, current) => `${current}${separator}${text}\n\n`) + .trigger('input') + .trigger('change'); + + // Trigger autosize + const event = document.createEvent('Event'); + event.initEvent('autosize:update', true, false); + $replyField.get(0).dispatchEvent(event); + + // Focus the input field + $replyField.focus(); - // Focus the input field - $replyField.focus(); + return false; + }) + .catch(() => {}); return false; } diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index 3ef54752436..0bf2dde8b96 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -6,6 +6,7 @@ import { polyfillSticky } from '~/lib/utils/sticky'; import Icon from '~/vue_shared/components/icon.vue'; import CompareVersionsDropdown from './compare_versions_dropdown.vue'; import SettingsDropdown from './settings_dropdown.vue'; +import DiffStats from './diff_stats.vue'; export default { components: { @@ -14,6 +15,7 @@ export default { GlLink, GlButton, SettingsDropdown, + DiffStats, }, directives: { GlTooltip: GlTooltipDirective, @@ -35,8 +37,15 @@ export default { }, }, computed: { - ...mapState('diffs', ['commit', 'showTreeList', 'startVersion', 'latestVersionPath']), - ...mapGetters('diffs', ['hasCollapsedFile']), + ...mapGetters('diffs', ['hasCollapsedFile', 'diffFilesLength']), + ...mapState('diffs', [ + 'commit', + 'showTreeList', + 'startVersion', + 'latestVersionPath', + 'addedLines', + 'removedLines', + ]), comparableDiffs() { return this.mergeRequestDiffs.slice(1); }, @@ -104,6 +113,11 @@ export default { <gl-link :href="commit.commit_url" class="monospace">{{ commit.short_id }}</gl-link> </div> <div class="inline-parallel-buttons d-none d-md-flex ml-auto"> + <diff-stats + :diff-files-length="diffFilesLength" + :added-lines="addedLines" + :removed-lines="removedLines" + /> <gl-button v-if="commit || startVersion" :href="latestVersionPath" diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index b58f704bebb..60586d4a607 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -9,6 +9,7 @@ import { GlTooltipDirective } from '@gitlab/ui'; import { truncateSha } from '~/lib/utils/text_utility'; import { __, s__, sprintf } from '~/locale'; import EditButton from './edit_button.vue'; +import DiffStats from './diff_stats.vue'; export default { components: { @@ -16,6 +17,7 @@ export default { EditButton, Icon, FileIcon, + DiffStats, }, directives: { GlTooltip: GlTooltipDirective, @@ -202,6 +204,7 @@ export default { v-if="!diffFile.submodule && addMergeRequestButtons" class="file-actions d-none d-sm-block" > + <diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" /> <template v-if="diffFile.blob && diffFile.blob.readable_text"> <button :disabled="!diffHasDiscussions(diffFile)" diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue new file mode 100644 index 00000000000..2e5855380af --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_stats.vue @@ -0,0 +1,52 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import { n__ } from '~/locale'; + +export default { + components: { Icon }, + props: { + addedLines: { + type: Number, + required: true, + }, + removedLines: { + type: Number, + required: true, + }, + diffFilesLength: { + type: Number, + required: false, + default: null, + }, + }, + computed: { + filesText() { + return n__('File', 'Files', this.diffFilesLength); + }, + isCompareVersionsHeader() { + return Boolean(this.diffFilesLength); + }, + }, +}; +</script> + +<template> + <div + class="diff-stats" + :class="{ + 'is-compare-versions-header d-none d-lg-inline-flex': isCompareVersionsHeader, + 'd-inline-flex': !isCompareVersionsHeader, + }" + > + <div v-if="diffFilesLength !== null" class="diff-stats-group"> + <icon name="doc-code" class="diff-stats-icon text-secondary" /> + <strong>{{ diffFilesLength }} {{ filesText }}</strong> + </div> + <div class="diff-stats-group cgreen"> + <icon name="file-addition" class="diff-stats-icon" /> <strong>{{ addedLines }}</strong> + </div> + <div class="diff-stats-group cred"> + <icon name="file-deletion" class="diff-stats-icon" /> <strong>{{ removedLines }}</strong> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index a0f09932593..96ae197d8b8 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -14,8 +14,8 @@ export default { FileRow, }, computed: { - ...mapState('diffs', ['tree', 'addedLines', 'removedLines', 'renderTreeList']), - ...mapGetters('diffs', ['allBlobs', 'diffFilesLength']), + ...mapState('diffs', ['tree', 'renderTreeList']), + ...mapGetters('diffs', ['allBlobs']), filteredTreeList() { return this.renderTreeList ? this.tree : this.allBlobs; }, @@ -64,13 +64,6 @@ export default { {{ s__('MergeRequest|No files found') }} </p> </div> - <div v-once class="pt-3 pb-3 text-center"> - {{ n__('%d changed file', '%d changed files', diffFilesLength) }} - <div> - <span class="cgreen"> {{ n__('%d addition', '%d additions', addedLines) }} </span> - <span class="cred"> {{ n__('%d deleted', '%d deletions', removedLines) }} </span> - </div> - </div> </div> </template> diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index 6ee33d9fc6d..47f78a5db54 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -11,6 +11,8 @@ const storedTreeShow = localStorage.getItem(MR_TREE_SHOW_KEY); export default () => ({ isLoading: true, + addedLines: null, + removedLines: null, endpoint: '', basePath: '', commit: null, diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index dbfcf8cc921..cb1b1173190 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -64,6 +64,7 @@ class DueDateSelect { this.saveDueDate(true); } }, + firstDay: gon.first_day_of_week, }); calendar.setDate(parsePikadayDate($dueDateInput.val())); @@ -183,6 +184,7 @@ export default class DueDateSelectors { onSelect(dateText) { $datePicker.val(calendar.toString(dateText)); }, + firstDay: gon.first_day_of_week, }); calendar.setDate(parsePikadayDate(datePickerVal)); diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index 4d2533d01f1..9336b71cfd7 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -44,6 +44,7 @@ export default class IssuableForm { parse: dateString => parsePikadayDate(dateString), toString: date => pikadayToString(date), onSelect: dateText => $issuableDueDate.val(calendar.toString(dateText)), + firstDay: gon.first_day_of_week, }); calendar.setDate(parsePikadayDate($issuableDueDate.val())); } diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index e664269b199..58f14bac8c8 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -1,6 +1,7 @@ <script> import $ from 'jquery'; -import { __ } from '~/locale'; +import { s__, sprintf } from '~/locale'; +import createFlash from '~/flash'; import animateMixin from '../mixins/animate'; import TaskList from '../../task_list'; import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor'; @@ -91,9 +92,14 @@ export default { }, taskListUpdateError() { - window.Flash( - __( - 'Someone edited this issue at the same time you did. The description has been updated and you will need to make your changes again.', + createFlash( + sprintf( + s__( + 'Someone edited this %{issueType} at the same time you did. The description has been updated and you will need to make your changes again.', + ), + { + issueType: this.issuableType, + }, ), ); diff --git a/app/assets/javascripts/lib/utils/file_upload.js b/app/assets/javascripts/lib/utils/file_upload.js index b41ffb44971..82ee83e4348 100644 --- a/app/assets/javascripts/lib/utils/file_upload.js +++ b/app/assets/javascripts/lib/utils/file_upload.js @@ -1,6 +1,9 @@ export default (buttonSelector, fileSelector) => { const btn = document.querySelector(buttonSelector); const fileInput = document.querySelector(fileSelector); + + if (!btn || !fileInput) return; + const form = btn.closest('form'); btn.addEventListener('click', () => { diff --git a/app/assets/javascripts/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js index 0beedcacf33..0dabb28ea66 100644 --- a/app/assets/javascripts/member_expiration_date.js +++ b/app/assets/javascripts/member_expiration_date.js @@ -33,6 +33,7 @@ export default function memberExpirationDate(selector = '.js-access-expiration-d toggleClearInput.call($input); }, + firstDay: gon.first_day_of_week, }); calendar.setDate(parsePikadayDate($input.val())); diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index ac3b47cd218..3b42a154af8 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import { __ } from '~/locale'; +import createFlash from '~/flash'; import TaskList from './task_list'; import MergeRequestTabs from './merge_request_tabs'; import IssuablesHelper from './helpers/issuables_helper'; @@ -40,6 +41,13 @@ function MergeRequest(opts) { document.querySelector('#task_status').innerText = result.task_status; document.querySelector('#task_status_short').innerText = result.task_status_short; }, + onError: () => { + createFlash( + __( + 'Someone edited this merge request at the same time you did. Please refresh the page to see changes.', + ), + ); + }, }); } } diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue index ec0e33a1927..14c02db7bcc 100644 --- a/app/assets/javascripts/monitoring/components/charts/area.vue +++ b/app/assets/javascripts/monitoring/components/charts/area.vue @@ -189,8 +189,8 @@ export default { <template> <div class="prometheus-graph col-12 col-lg-6"> <div class="prometheus-graph-header"> - <h5 class="prometheus-graph-title">{{ graphData.title }}</h5> - <div class="prometheus-graph-widgets"><slot></slot></div> + <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5> + <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div> </div> <gl-area-chart ref="areaChart" diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 9c5fd93f7d1..895a57785bc 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -160,7 +160,8 @@ export default { {{ s__('Metrics|Environment') }} <div class="dropdown prepend-left-10"> <button class="dropdown-menu-toggle" data-toggle="dropdown" type="button"> - <span> {{ currentEnvironmentName }} </span> <icon name="chevron-down" /> + <span>{{ currentEnvironmentName }}</span> + <icon name="chevron-down" /> </button> <div v-if="store.environmentsData.length > 0" @@ -172,9 +173,8 @@ export default { :href="environment.metrics_path" :class="{ 'is-active': environment.name == currentEnvironmentName }" class="dropdown-item" + >{{ environment.name }}</a > - {{ environment.name }} - </a> </li> </ul> </div> diff --git a/app/assets/javascripts/pages/dashboard/projects/index.js b/app/assets/javascripts/pages/dashboard/projects/index.js index 8f98be79640..01001d4f3ff 100644 --- a/app/assets/javascripts/pages/dashboard/projects/index.js +++ b/app/assets/javascripts/pages/dashboard/projects/index.js @@ -1,7 +1,5 @@ import ProjectsList from '~/projects_list'; -import Star from '../../../star'; document.addEventListener('DOMContentLoaded', () => { new ProjectsList(); // eslint-disable-line no-new - new Star('.project-row'); // eslint-disable-line no-new }); diff --git a/app/assets/javascripts/pages/explore/projects/index.js b/app/assets/javascripts/pages/explore/projects/index.js index 8f98be79640..01001d4f3ff 100644 --- a/app/assets/javascripts/pages/explore/projects/index.js +++ b/app/assets/javascripts/pages/explore/projects/index.js @@ -1,7 +1,5 @@ import ProjectsList from '~/projects_list'; -import Star from '../../../star'; document.addEventListener('DOMContentLoaded', () => { new ProjectsList(); // eslint-disable-line no-new - new Star('.project-row'); // eslint-disable-line no-new }); diff --git a/app/assets/javascripts/pages/projects/environments/metrics/index.js b/app/assets/javascripts/pages/projects/environments/metrics/index.js index 0b644780ad4..0d69a689316 100644 --- a/app/assets/javascripts/pages/projects/environments/metrics/index.js +++ b/app/assets/javascripts/pages/projects/environments/metrics/index.js @@ -1,3 +1,3 @@ -import monitoringBundle from '~/monitoring/monitoring_bundle'; +import monitoringBundle from 'ee_else_ce/monitoring/monitoring_bundle'; document.addEventListener('DOMContentLoaded', monitoringBundle); diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js index 3ccad513c05..26d7fa7371d 100644 --- a/app/assets/javascripts/pages/projects/graphs/charts/index.js +++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js @@ -43,10 +43,26 @@ document.addEventListener('DOMContentLoaded', () => { ], }); + const reorderWeekDays = (weekDays, firstDayOfWeek = 0) => { + if (firstDayOfWeek === 0) { + return weekDays; + } + + return Object.keys(weekDays).reduce((acc, dayName, idx, arr) => { + const reorderedDayName = arr[(idx + firstDayOfWeek) % arr.length]; + + return { + ...acc, + [reorderedDayName]: weekDays[reorderedDayName], + }; + }, {}); + }; + const hourData = chartData(projectChartData.hour); responsiveChart($('#hour-chart'), hourData); - const dayData = chartData(projectChartData.weekDays); + const weekDays = reorderWeekDays(projectChartData.weekDays, gon.first_day_of_week); + const dayData = chartData(weekDays); responsiveChart($('#weekday-chart'), dayData); const monthData = chartData(projectChartData.month); diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index 8a84ac37dab..afa099d0e0b 100644 --- a/app/assets/javascripts/pages/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js @@ -159,7 +159,7 @@ export default class ActivityCalendar { .append('g') .attr('transform', (group, i) => { _.each(group, (stamp, a) => { - if (a === 0 && stamp.day === 0) { + if (a === 0 && stamp.day === this.firstDayOfWeek) { const month = stamp.date.getMonth(); const x = this.daySizeWithSpace * i + 1 + this.daySizeWithSpace; const lastMonth = _.last(this.months); @@ -205,6 +205,14 @@ export default class ActivityCalendar { y: 29 + this.dayYPos(5), }, ]; + + if (this.firstDayOfWeek === 1) { + days.push({ + text: 'S', + y: 29 + this.dayYPos(7), + }); + } + this.svg .append('g') .selectAll('text') diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js index 1c3fd58ca74..39cd891c111 100644 --- a/app/assets/javascripts/pages/users/user_tabs.js +++ b/app/assets/javascripts/pages/users/user_tabs.js @@ -234,7 +234,7 @@ export default class UserTabs { data, calendarActivitiesPath, utcOffset, - 0, + gon.first_day_of_week, monthsAgo, ); } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue new file mode 100644 index 00000000000..a38f25cce35 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue @@ -0,0 +1,40 @@ +<script> +export default { + props: { + value: { + type: String, + required: true, + }, + label: { + type: String, + required: true, + }, + inputId: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <li> + <div class="commit-message-editor"> + <div class="d-flex flex-wrap align-items-center justify-content-between"> + <label class="col-form-label" :for="inputId"> + <strong>{{ label }}</strong> + </label> + <slot name="header"></slot> + </div> + <textarea + :id="inputId" + :value="value" + class="form-control js-gfm-input append-bottom-default commit-message-edit" + required="required" + rows="7" + @input="$emit('input', $event.target.value)" + ></textarea> + <slot name="checkbox"></slot> + </div> + </li> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue new file mode 100644 index 00000000000..b3c1c0e329d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue @@ -0,0 +1,38 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; + +export default { + components: { + GlDropdown, + GlDropdownItem, + }, + props: { + commits: { + type: Array, + required: true, + default: () => [], + }, + }, +}; +</script> + +<template> + <div> + <gl-dropdown + right + no-caret + text="Use an existing commit message" + variant="link" + class="mr-commit-dropdown" + > + <gl-dropdown-item + v-for="commit in commits" + :key="commit.short_id" + class="text-nowrap text-truncate" + @click="$emit('input', commit.message)" + > + <span class="monospace mr-2">{{ commit.short_id }}</span> {{ commit.title }} + </gl-dropdown-item> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue new file mode 100644 index 00000000000..a1d3a09cca4 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue @@ -0,0 +1,91 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import _ from 'underscore'; +import { __, n__, sprintf, s__ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + Icon, + GlButton, + }, + props: { + isSquashEnabled: { + type: Boolean, + required: true, + }, + commitsCount: { + type: Number, + required: false, + default: 0, + }, + targetBranch: { + type: String, + required: true, + }, + }, + data() { + return { + expanded: false, + }; + }, + computed: { + collapseIcon() { + return this.expanded ? 'chevron-down' : 'chevron-right'; + }, + commitsCountMessage() { + return n__(__('%d commit'), __('%d commits'), this.isSquashEnabled ? 1 : this.commitsCount); + }, + modifyLinkMessage() { + return this.isSquashEnabled ? __('Modify commit messages') : __('Modify merge commit'); + }, + ariaLabel() { + return this.expanded ? __('Collapse') : __('Expand'); + }, + message() { + return sprintf( + s__( + 'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}.', + ), + { + commitCount: `<strong class="commits-count-message">${this.commitsCountMessage}</strong>`, + mergeCommitCount: `<strong>${s__('mrWidgetCommitsAdded|1 merge commit')}</strong>`, + targetBranch: `<span class="label-branch">${_.escape(this.targetBranch)}</span>`, + }, + false, + ); + }, + }, + methods: { + toggle() { + this.expanded = !this.expanded; + }, + }, +}; +</script> + +<template> + <div> + <div + class="js-mr-widget-commits-count mr-widget-extension clickable d-flex align-items-center px-3 py-2" + @click="toggle()" + > + <gl-button + :aria-label="ariaLabel" + variant="blank" + class="commit-edit-toggle mr-2" + @click.stop="toggle()" + > + <icon :name="collapseIcon" :size="16" /> + </gl-button> + <span v-if="expanded">{{ __('Collapse') }}</span> + <span v-else> + <span v-html="message"></span> + <gl-button variant="link" class="modify-message-button"> + {{ modifyLinkMessage }} + </gl-button> + </span> + </div> + <div v-show="expanded"><slot></slot></div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index b8f29649eb5..ce4207864ea 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -2,17 +2,24 @@ import successSvg from 'icons/_icon_status_success.svg'; import warningSvg from 'icons/_icon_status_warning.svg'; import simplePoll from '~/lib/utils/simple_poll'; +import { __ } from '~/locale'; import MergeRequest from '../../../merge_request'; import Flash from '../../../flash'; import statusIcon from '../mr_widget_status_icon.vue'; import eventHub from '../../event_hub'; import SquashBeforeMerge from './squash_before_merge.vue'; +import CommitsHeader from './commits_header.vue'; +import CommitEdit from './commit_edit.vue'; +import CommitMessageDropdown from './commit_message_dropdown.vue'; export default { name: 'ReadyToMerge', components: { statusIcon, SquashBeforeMerge, + CommitsHeader, + CommitEdit, + CommitMessageDropdown, }, props: { mr: { type: Object, required: true }, @@ -22,27 +29,20 @@ export default { return { removeSourceBranch: this.mr.shouldRemoveSourceBranch, mergeWhenBuildSucceeds: false, - useCommitMessageWithDescription: false, setToMergeWhenPipelineSucceeds: false, - showCommitMessageEditor: false, isMakingRequest: false, isMergingImmediately: false, commitMessage: this.mr.commitMessage, squashBeforeMerge: this.mr.squash, successSvg, warningSvg, + squashCommitMessage: this.mr.squashCommitMessage, }; }, computed: { shouldShowMergeWhenPipelineSucceedsText() { return this.mr.isPipelineActive; }, - commitMessageLinkTitle() { - const withDesc = 'Include description in commit message'; - const withoutDesc = "Don't include description in commit message"; - - return this.useCommitMessageWithDescription ? withoutDesc : withDesc; - }, status() { const { pipeline, isPipelineActive, isPipelineFailed, hasCI, ciStatus } = this.mr; @@ -84,9 +84,9 @@ export default { }, mergeButtonText() { if (this.isMergingImmediately) { - return 'Merge in progress'; + return __('Merge in progress'); } else if (this.shouldShowMergeWhenPipelineSucceedsText) { - return 'Merge when pipeline succeeds'; + return __('Merge when pipeline succeeds'); } return 'Merge'; @@ -98,7 +98,7 @@ export default { const { commitMessage } = this; return Boolean( !commitMessage.length || - !this.shouldShowMergeControls() || + !this.shouldShowMergeControls || this.isMakingRequest || this.mr.preventMerge, ); @@ -110,18 +110,14 @@ export default { const { commitsCount, enableSquashBeforeMerge } = this.mr; return enableSquashBeforeMerge && commitsCount > 1; }, - }, - methods: { shouldShowMergeControls() { return this.mr.isMergeAllowed || this.shouldShowMergeWhenPipelineSucceedsText; }, - updateCommitMessage() { - const cmwd = this.mr.commitMessageWithDescription; - this.useCommitMessageWithDescription = !this.useCommitMessageWithDescription; - this.commitMessage = this.useCommitMessageWithDescription ? cmwd : this.mr.commitMessage; - }, - toggleCommitMessageEditor() { - this.showCommitMessageEditor = !this.showCommitMessageEditor; + }, + methods: { + updateMergeCommitMessage(includeDescription) { + const { commitMessageWithDescription, commitMessage } = this.mr; + this.commitMessage = includeDescription ? commitMessageWithDescription : commitMessage; }, handleMergeButtonClick(mergeWhenBuildSucceeds, mergeImmediately) { // TODO: Remove no-param-reassign @@ -139,6 +135,7 @@ export default { merge_when_pipeline_succeeds: this.setToMergeWhenPipelineSucceeds, should_remove_source_branch: this.removeSourceBranch === true, squash: this.squashBeforeMerge, + squash_commit_message: this.squashCommitMessage, }; this.isMakingRequest = true; @@ -158,7 +155,7 @@ export default { }) .catch(() => { this.isMakingRequest = false; - new Flash('Something went wrong. Please try again.'); // eslint-disable-line + new Flash(__('Something went wrong. Please try again.')); // eslint-disable-line }); }, initiateMergePolling() { @@ -194,7 +191,7 @@ export default { } }) .catch(() => { - new Flash('Something went wrong while merging this merge request. Please try again.'); // eslint-disable-line + new Flash(__('Something went wrong while merging this merge request. Please try again.')); // eslint-disable-line }); }, initiateRemoveSourceBranchPolling() { @@ -223,7 +220,7 @@ export default { } }) .catch(() => { - new Flash('Something went wrong while deleting the source branch. Please try again.'); // eslint-disable-line + new Flash(__('Something went wrong while deleting the source branch. Please try again.')); // eslint-disable-line }); }, }, @@ -231,127 +228,136 @@ export default { </script> <template> - <div class="mr-widget-body media"> - <status-icon :status="iconClass" /> - <div class="media-body"> - <div class="mr-widget-body-controls media space-children"> - <span class="btn-group"> - <button - :disabled="isMergeButtonDisabled" - :class="mergeButtonClass" - type="button" - class="qa-merge-button" - @click="handleMergeButtonClick()" - > - <i v-if="isMakingRequest" class="fa fa-spinner fa-spin" aria-hidden="true"></i> - {{ mergeButtonText }} - </button> - <button - v-if="shouldShowMergeOptionsDropdown" - :disabled="isMergeButtonDisabled" - type="button" - class="btn btn-sm btn-info dropdown-toggle js-merge-moment" - data-toggle="dropdown" - aria-label="Select merge moment" - > - <i class="fa fa-chevron-down qa-merge-moment-dropdown" aria-hidden="true"></i> - </button> - <ul - v-if="shouldShowMergeOptionsDropdown" - class="dropdown-menu dropdown-menu-right" - role="menu" - > - <li> - <a - class="merge_when_pipeline_succeeds qa-merge-when-pipeline-succeeds-option" - href="#" - @click.prevent="handleMergeButtonClick(true)" - > - <span class="media"> - <span class="merge-opt-icon" aria-hidden="true" v-html="successSvg"></span> - <span class="media-body merge-opt-title">Merge when pipeline succeeds</span> - </span> - </a> - </li> - <li> - <a - class="accept-merge-request qa-merge-immediately-option" - href="#" - @click.prevent="handleMergeButtonClick(false, true)" - > - <span class="media"> - <span class="merge-opt-icon" aria-hidden="true" v-html="warningSvg"></span> - <span class="media-body merge-opt-title">Merge immediately</span> - </span> - </a> - </li> - </ul> - </span> - <div class="media-body-wrap space-children"> - <template v-if="shouldShowMergeControls()"> - <label v-if="mr.canRemoveSourceBranch"> - <input - id="remove-source-branch-input" - v-model="removeSourceBranch" - :disabled="isRemoveSourceBranchButtonDisabled" - class="js-remove-source-branch-checkbox" - type="checkbox" - /> - Delete source branch - </label> - - <!-- Placeholder for EE extension of this component --> - <squash-before-merge - v-if="shouldShowSquashBeforeMerge" - v-model="squashBeforeMerge" - :help-path="mr.squashBeforeMergeHelpPath" - :is-disabled="isMergeButtonDisabled" - /> - - <span v-if="mr.ffOnlyEnabled" class="js-fast-forward-message"> - Fast-forward merge without a merge commit - </span> + <div> + <div class="mr-widget-body media"> + <status-icon :status="iconClass" /> + <div class="media-body"> + <div class="mr-widget-body-controls media space-children"> + <span class="btn-group"> <button - v-else :disabled="isMergeButtonDisabled" - class="js-modify-commit-message-button btn btn-default btn-sm" + :class="mergeButtonClass" type="button" - @click="toggleCommitMessageEditor" + class="qa-merge-button" + @click="handleMergeButtonClick()" > - Modify commit message + <i v-if="isMakingRequest" class="fa fa-spinner fa-spin" aria-hidden="true"></i> + {{ mergeButtonText }} </button> - </template> - <template v-else> - <span class="bold js-resolve-mr-widget-items-message"> - You can only merge once the items above are resolved - </span> - </template> - </div> - </div> - <div v-if="showCommitMessageEditor" class="prepend-top-default commit-message-editor"> - <div class="form-group clearfix"> - <label class="col-form-label" for="commit-message"> Commit message </label> - <div class="col-sm-10"> - <div class="commit-message-container"> - <div class="max-width-marker"></div> - <textarea - id="commit-message" - v-model="commitMessage" - class="form-control js-commit-message" - required="required" - rows="14" - name="Commit message" - ></textarea> - </div> - <p class="hint"> - Try to keep the first line under 52 characters and the others under 72 - </p> - <div class="hint"> - <a href="#" @click.prevent="updateCommitMessage"> {{ commitMessageLinkTitle }} </a> - </div> + <button + v-if="shouldShowMergeOptionsDropdown" + :disabled="isMergeButtonDisabled" + type="button" + class="btn btn-sm btn-info dropdown-toggle js-merge-moment" + data-toggle="dropdown" + aria-label="Select merge moment" + > + <i class="fa fa-chevron-down qa-merge-moment-dropdown" aria-hidden="true"></i> + </button> + <ul + v-if="shouldShowMergeOptionsDropdown" + class="dropdown-menu dropdown-menu-right" + role="menu" + > + <li> + <a + class="merge_when_pipeline_succeeds qa-merge-when-pipeline-succeeds-option" + href="#" + @click.prevent="handleMergeButtonClick(true)" + > + <span class="media"> + <span class="merge-opt-icon" aria-hidden="true" v-html="successSvg"></span> + <span class="media-body merge-opt-title">{{ + __('Merge when pipeline succeeds') + }}</span> + </span> + </a> + </li> + <li> + <a + class="accept-merge-request qa-merge-immediately-option" + href="#" + @click.prevent="handleMergeButtonClick(false, true)" + > + <span class="media"> + <span class="merge-opt-icon" aria-hidden="true" v-html="warningSvg"></span> + <span class="media-body merge-opt-title">{{ __('Merge immediately') }}</span> + </span> + </a> + </li> + </ul> + </span> + <div class="media-body-wrap space-children"> + <template v-if="shouldShowMergeControls"> + <label v-if="mr.canRemoveSourceBranch"> + <input + id="remove-source-branch-input" + v-model="removeSourceBranch" + :disabled="isRemoveSourceBranchButtonDisabled" + class="js-remove-source-branch-checkbox" + type="checkbox" + /> + {{ __('Delete source branch') }} + </label> + + <!-- Placeholder for EE extension of this component --> + <squash-before-merge + v-if="shouldShowSquashBeforeMerge" + v-model="squashBeforeMerge" + :help-path="mr.squashBeforeMergeHelpPath" + :is-disabled="isMergeButtonDisabled" + /> + </template> + <template v-else> + <span class="bold js-resolve-mr-widget-items-message"> + {{ __('You can only merge once the items above are resolved') }} + </span> + </template> </div> </div> </div> </div> + <template v-if="shouldShowMergeControls"> + <div v-if="mr.ffOnlyEnabled" class="mr-fast-forward-message"> + {{ __('Fast-forward merge without a merge commit') }} + </div> + <template v-else> + <commits-header + :is-squash-enabled="squashBeforeMerge" + :commits-count="mr.commitsCount" + :target-branch="mr.targetBranch" + > + <ul class="border-top content-list commits-list flex-list"> + <commit-edit + v-if="squashBeforeMerge" + v-model="squashCommitMessage" + :label="__('Squash commit message')" + input-id="squash-message-edit" + squash + > + <commit-message-dropdown + slot="header" + v-model="squashCommitMessage" + :commits="mr.commits" + /> + </commit-edit> + <commit-edit + v-model="commitMessage" + :label="__('Merge commit message')" + input-id="merge-message-edit" + > + <label slot="checkbox"> + <input + id="include-description" + type="checkbox" + @change="updateMergeCommitMessage($event.target.checked)" + /> + {{ __('Include merge request description') }} + </label> + </commit-edit> + </ul> + </commits-header> + </template> + </template> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 57c4dfbe3b7..abbbe19c5ef 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -315,7 +315,7 @@ export default { :endpoint="mr.testResultsPath" /> - <div class="mr-widget-section"> + <div class="mr-widget-section p-0"> <component :is="componentName" :mr="mr" :service="service" /> <section v-if="shouldRenderCollaborationStatus" class="mr-info-list mr-links"> diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 36cac230d9d..58363f632a9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -42,6 +42,8 @@ export default class MergeRequestStore { this.mergePipeline = data.merge_pipeline || {}; this.deployments = this.deployments || data.deployments || []; this.postMergeDeployments = this.postMergeDeployments || []; + this.commits = data.commits_without_merge_commits || []; + this.squashCommitMessage = data.default_squash_commit_message; this.initRebase(data); if (data.issues_links) { diff --git a/app/assets/javascripts/vue_shared/components/pikaday.vue b/app/assets/javascripts/vue_shared/components/pikaday.vue index 8bdb5bf22c2..13eb46437dd 100644 --- a/app/assets/javascripts/vue_shared/components/pikaday.vue +++ b/app/assets/javascripts/vue_shared/components/pikaday.vue @@ -40,6 +40,7 @@ export default { toString: date => pikadayToString(date), onSelect: this.selected.bind(this), onClose: this.toggled.bind(this), + firstDay: gon.first_day_of_week, }); this.$el.append(this.calendar.el); diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index cb449b642e7..c5c3b66438c 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -391,6 +391,11 @@ img.emoji { .flex-no-shrink { flex-shrink: 0; } .ws-initial { white-space: initial; } .overflow-auto { overflow: auto; } +.d-flex-center { + display: flex; + align-items: center; + justify-content: center; +} /** COMMON SIZING CLASSES **/ .w-0 { width: 0; } @@ -402,6 +407,10 @@ img.emoji { .min-height-0 { min-height: 0; } +.w-3 { width: #{3 * $grid-size}; } + +.h-3 { width: #{3 * $grid-size}; } + /** COMMON SPACING CLASSES **/ .gl-pl-0 { padding-left: 0; } .gl-pl-1 { padding-left: #{0.5 * $grid-size}; } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 037a5adfb7e..6108eaa1ad0 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -4,6 +4,7 @@ */ .file-holder { border: 1px solid $border-color; + border-top: 0; border-radius: $border-radius-default; &.file-holder-no-border { @@ -51,6 +52,7 @@ position: absolute; top: 5px; right: 15px; + margin-left: auto; .btn { padding: 0 10px; @@ -324,10 +326,12 @@ span.idiff { &, .file-holder & { display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; background-color: $gray-light; border-bottom: 1px solid $border-color; + border-top: 1px solid $border-color; padding: 5px $gl-padding; margin: 0; border-radius: $border-radius-default $border-radius-default 0 0; @@ -365,16 +369,12 @@ span.idiff { margin: 0 10px 0 0; } - .file-actions { - white-space: nowrap; - - .btn { - padding: 0 10px; - font-size: 13px; - line-height: 28px; - display: inline-block; - float: none; - } + .file-actions .btn { + padding: 0 10px; + font-size: 13px; + line-height: 28px; + display: inline-block; + float: none; } @include media-breakpoint-down(xs) { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 9eae9a831fa..96dab609a13 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -243,6 +243,7 @@ $gl-padding-8: 8px; $gl-padding: 16px; $gl-padding-24: 24px; $gl-padding-32: 32px; +$gl-padding-50: 50px; $gl-col-padding: 15px; $gl-input-padding: 10px; $gl-vert-padding: 6px; @@ -490,6 +491,7 @@ $builds-trace-bg: #111; */ $commit-max-width-marker-color: rgba(0, 0, 0, 0); $commit-message-text-area-bg: rgba(0, 0, 0, 0); +$commit-stat-summary-height: 36px; /* * Common @@ -664,8 +666,14 @@ $priority-label-empty-state-width: 114px; Issues Analytics */ $issues-analytics-popover-boarder-color: rgba(0, 0, 0, 0.15); + /* Merge Requests */ $mr-tabs-height: 51px; $mr-version-controls-height: 56px; + +/* +Compare Branches +*/ +$compare-branches-sticky-header-height: 68px; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 02aac58a475..e3b98b26a11 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -7,22 +7,13 @@ cursor: pointer; @media (min-width: map-get($grid-breakpoints, md)) { + $mr-file-header-top: $mr-version-controls-height + $header-height + $mr-tabs-height; + position: -webkit-sticky; position: sticky; - top: $mr-version-controls-height + $header-height + $mr-tabs-height; - margin-left: -1px; - border-left: 1px solid $border-color; + top: $mr-file-header-top; z-index: 102; - &.is-commit { - top: $header-height + 36px; - - .with-performance-bar & { - top: $header-height + 36px + $performance-bar-height; - - } - } - &::before { content: ''; position: absolute; @@ -35,7 +26,23 @@ } .with-performance-bar & { - top: $header-height + $performance-bar-height + $mr-version-controls-height + $mr-tabs-height; + top: $mr-file-header-top + $performance-bar-height; + } + + &.is-commit { + top: $header-height + $commit-stat-summary-height; + + .with-performance-bar & { + top: $header-height + $commit-stat-summary-height + $performance-bar-height; + } + } + + &.is-compare { + top: $header-height + $compare-branches-sticky-header-height; + + .with-performance-bar & { + top: $performance-bar-height + $header-height + $compare-branches-sticky-header-height; + } } } @@ -501,6 +508,25 @@ } } +.diff-stats { + align-items: center; + padding: 0 .25rem; + + .diff-stats-group { + padding: 0 .25rem; + } + + svg.diff-stats-icon { + vertical-align: text-bottom; + } + + &.is-compare-versions-header { + .diff-stats-group { + padding: 0 .5rem; + } + } +} + .file-content .diff-file { margin: 0; border: 0; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 38a7e199c6a..135730d71e9 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -38,9 +38,7 @@ } .mr-widget-section { - .media { - align-items: center; - } + border-radius: $border-radius-default $border-radius-default 0 0; .code-text { flex: 1; @@ -56,6 +54,11 @@ .mr-widget-extension { border-top: 1px solid $border-color; background-color: $gray-light; + + &.clickable:hover { + background-color: $gl-gray-200; + cursor: pointer; + } } .mr-widget-workflow { @@ -78,6 +81,7 @@ border-top: 0; } +.mr-widget-body, .mr-widget-section, .mr-widget-content, .mr-widget-footer { @@ -87,11 +91,38 @@ .mr-state-widget { color: $gl-text-color; + .commit-message-edit { + border-radius: $border-radius-default; + } + .mr-widget-section, .mr-widget-footer { border-top: solid 1px $border-color; } + .mr-fast-forward-message { + padding-left: $gl-padding-50; + padding-bottom: $gl-padding; + } + + .commits-list { + > li { + padding: $gl-padding; + + @include media-breakpoint-up(md) { + padding-left: $gl-padding-50; + } + } + } + + .mr-commit-dropdown { + .dropdown-menu { + @include media-breakpoint-up(md) { + width: 150%; + } + } + } + .mr-widget-footer { padding: 0; } @@ -405,7 +436,7 @@ } .mr-widget-help { - padding: 10px 16px 10px 48px; + padding: 10px 16px 10px $gl-padding-50; font-style: italic; } @@ -423,10 +454,6 @@ } } -.mr-widget-body-controls { - flex-wrap: wrap; -} - .mr_source_commit, .mr_target_commit { margin-bottom: 0; diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 2342c284a5e..3eb02cd4358 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -946,6 +946,11 @@ pre.light-well { .flex-wrapper { min-width: 0; margin-top: -$gl-padding-8; // negative margin required for flex-wrap + flex: 1 1 100%; + + .project-title { + line-height: 20px; + } } p, @@ -984,14 +989,16 @@ pre.light-well { } .controls { - margin-top: $gl-padding-8; + @include media-breakpoint-down(xs) { + margin-top: $gl-padding-8; + } - @include media-breakpoint-down(md) { + @include media-breakpoint-up(sm) { margin-top: 0; } - @include media-breakpoint-down(xs) { - margin-top: $gl-padding-8; + @include media-breakpoint-up(lg) { + flex: 1 1 40%; } .icon-wrapper { @@ -1041,7 +1048,7 @@ pre.light-well { min-height: 40px; min-width: 40px; - .identicon.s64 { + .identicon.s48 { font-size: 16px; } } diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index b9717b97640..3bd91b71d92 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -127,6 +127,7 @@ class Clusters::ClustersController < Clusters::BaseController params.require(:cluster).permit( :enabled, :environment_scope, + :base_domain, platform_kubernetes_attributes: [ :namespace ] @@ -136,6 +137,7 @@ class Clusters::ClustersController < Clusters::BaseController :enabled, :name, :environment_scope, + :base_domain, platform_kubernetes_attributes: [ :api_url, :token, diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 97120273d6b..cc2bb99f55b 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -116,8 +116,12 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController session[:service_tickets][provider] = ticket end + def build_auth_user(auth_user_class) + auth_user_class.new(oauth) + end + def sign_in_user_flow(auth_user_class) - auth_user = auth_user_class.new(oauth) + auth_user = build_auth_user(auth_user_class) user = auth_user.find_and_update! if auth_user.valid_sign_in? diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index 37ac11dc6a1..94002095739 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -33,12 +33,10 @@ class Profiles::PreferencesController < Profiles::ApplicationController end def preferences_params - params.require(:user).permit( - :color_scheme_id, - :layout, - :dashboard, - :project_view, - :theme_id - ) + params.require(:user).permit(preferences_param_names) + end + + def preferences_param_names + [:color_scheme_id, :layout, :dashboard, :project_view, :theme_id, :first_day_of_week] end end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 79685e8b675..e9cd475a199 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -11,11 +11,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action :verify_api_request!, only: :terminal_websocket_authorize before_action :expire_etag_cache, only: [:index] - before_action do - push_frontend_feature_flag(:area_chart, project) - end - - # Returns all environments or all folders based on the :nested param def index @environments = project.environments .with_state(params[:scope] || :available) diff --git a/app/controllers/projects/error_tracking_controller.rb b/app/controllers/projects/error_tracking_controller.rb index 9e403e1d25b..88d0755f41f 100644 --- a/app/controllers/projects/error_tracking_controller.rb +++ b/app/controllers/projects/error_tracking_controller.rb @@ -15,6 +15,14 @@ class Projects::ErrorTrackingController < Projects::ApplicationController end end + def list_projects + respond_to do |format| + format.json do + render_project_list_json + end + end + end + private def render_index_json @@ -32,6 +40,32 @@ class Projects::ErrorTrackingController < Projects::ApplicationController } end + def render_project_list_json + service = ErrorTracking::ListProjectsService.new( + project, + current_user, + list_projects_params + ) + result = service.execute + + if result[:status] == :success + render json: { + projects: serialize_projects(result[:projects]) + } + else + return render( + status: result[:http_status] || :bad_request, + json: { + message: result[:message] + } + ) + end + end + + def list_projects_params + params.require(:error_tracking_setting).permit([:api_host, :token]) + end + def set_polling_interval Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) end @@ -41,4 +75,10 @@ class Projects::ErrorTrackingController < Projects::ApplicationController .new(project: project, user: current_user) .represent(errors) end + + def serialize_projects(projects) + ErrorTracking::ProjectSerializer + .new(project: project, user: current_user) + .represent(projects) + end end diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index 54ff7ded8e5..6045ee4e171 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -34,7 +34,8 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont :task_num, :title, :discussion_locked, - label_ids: [] + label_ids: [], + update_task: [:index, :checked, :line_number, :line_source] ] end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 3e08c0ccd8f..23af2e0521c 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -305,7 +305,7 @@ class IssuableFinder def use_subquery_for_search? strong_memoize(:use_subquery_for_search) do attempt_group_search_optimizations? && - Feature.enabled?(:use_subquery_for_group_issues_search, default_enabled: false) + Feature.enabled?(:use_subquery_for_group_issues_search, default_enabled: true) end end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index c8e4e2e3df9..e635f608237 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -150,6 +150,7 @@ module ApplicationSettingsHelper :email_author_in_body, :enabled_git_access_protocol, :enforce_terms, + :first_day_of_week, :gitaly_timeout_default, :gitaly_timeout_medium, :gitaly_timeout_fast, @@ -231,7 +232,8 @@ module ApplicationSettingsHelper :web_ide_clientside_preview_enabled, :diff_max_patch_bytes, :commit_email_hostname, - :protected_ci_variables + :protected_ci_variables, + :local_markdown_version ] end diff --git a/app/helpers/auto_devops_helper.rb b/app/helpers/auto_devops_helper.rb index 516c8a353ea..67e7e475920 100644 --- a/app/helpers/auto_devops_helper.rb +++ b/app/helpers/auto_devops_helper.rb @@ -9,41 +9,4 @@ module AutoDevopsHelper !project.repository.gitlab_ci_yml && !project.ci_service end - - def auto_devops_warning_message(project) - if missing_auto_devops_service?(project) - params = { - kubernetes: link_to('Kubernetes cluster', project_clusters_path(project)) - } - - if missing_auto_devops_domain?(project) - _('Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly.') % params - else - _('Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly.') % params - end - elsif missing_auto_devops_domain?(project) - _('Auto Review Apps and Auto Deploy need a domain name to work correctly.') - end - end - - # rubocop: disable CodeReuse/ActiveRecord - def cluster_ingress_ip(project) - project - .cluster_ingresses - .where("external_ip is not null") - .limit(1) - .pluck(:external_ip) - .first - end - # rubocop: enable CodeReuse/ActiveRecord - - private - - def missing_auto_devops_domain?(project) - !(project.auto_devops || project.build_auto_devops)&.has_domain? - end - - def missing_auto_devops_service?(project) - !project.deployment_platform&.active? - end end diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index f4f46b0fe96..bc1742e8167 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -43,6 +43,17 @@ module PreferencesHelper ] end + def first_day_of_week_choices + [ + [_('Sunday'), 0], + [_('Monday'), 1] + ] + end + + def first_day_of_week_choices_with_default + first_day_of_week_choices.unshift([_('System default (%{default})') % { default: default_first_day_of_week }, nil]) + end + def user_application_theme @user_application_theme ||= Gitlab::Themes.for_user(current_user).css_class end @@ -66,4 +77,8 @@ module PreferencesHelper def excluded_dashboard_choices ['operations'] end + + def default_first_day_of_week + first_day_of_week_choices.rassoc(Gitlab::CurrentSettings.first_day_of_week).first + end end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index c4e310e638d..a3d662d8250 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -7,6 +7,12 @@ class ApplicationRecord < ActiveRecord::Base where(id: ids) end + def self.safe_find_or_create_by!(*args) + safe_find_or_create_by(*args).tap do |record| + record.validate! unless record.persisted? + end + end + def self.safe_find_or_create_by(*args) transaction(requires_new: true) do find_or_create_by(*args) diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 88746375c67..daadf9427ba 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -193,6 +193,10 @@ class ApplicationSetting < ActiveRecord::Base allow_nil: true, numericality: { only_integer: true, greater_than_or_equal_to: 1.day.seconds } + validates :local_markdown_version, + allow_nil: true, + numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than: 65536 } + SUPPORTED_KEY_TYPES.each do |type| validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type } end @@ -246,6 +250,7 @@ class ApplicationSetting < ActiveRecord::Base dsa_key_restriction: 0, ecdsa_key_restriction: 0, ed25519_key_restriction: 0, + first_day_of_week: 0, gitaly_timeout_default: 55, gitaly_timeout_fast: 10, gitaly_timeout_medium: 30, @@ -303,7 +308,8 @@ class ApplicationSetting < ActiveRecord::Base usage_stats_set_by_user_id: nil, diff_max_patch_bytes: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES, commit_email_hostname: default_commit_email_hostname, - protected_ci_variables: false + protected_ci_variables: false, + local_markdown_version: 0 } end diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index a2c48973fa5..f2f5b89e3bb 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -18,6 +18,7 @@ module Clusters Applications::Knative.application_name => Applications::Knative }.freeze DEFAULT_ENVIRONMENT = '*'.freeze + KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN'.freeze belongs_to :user @@ -49,7 +50,7 @@ module Clusters validates :name, cluster_name: true validates :cluster_type, presence: true - validates :domain, allow_nil: true, hostname: { allow_numeric_hostname: true, require_valid_tld: true } + validates :domain, allow_blank: true, hostname: { allow_numeric_hostname: true, require_valid_tld: true } validate :restrict_modification, on: :update validate :no_groups, unless: :group_type? @@ -65,6 +66,9 @@ module Clusters delegate :available?, to: :application_ingress, prefix: true, allow_nil: true delegate :available?, to: :application_prometheus, prefix: true, allow_nil: true delegate :available?, to: :application_knative, prefix: true, allow_nil: true + delegate :external_ip, to: :application_ingress, prefix: true, allow_nil: true + + alias_attribute :base_domain, :domain enum cluster_type: { instance_type: 1, @@ -193,8 +197,41 @@ module Clusters project_type? end + def kube_ingress_domain + @kube_ingress_domain ||= domain.presence || instance_domain || legacy_auto_devops_domain + end + + def predefined_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + break variables unless kube_ingress_domain + + variables.append(key: KUBE_INGRESS_BASE_DOMAIN, value: kube_ingress_domain) + end + end + private + def instance_domain + @instance_domain ||= Gitlab::CurrentSettings.auto_devops_domain + end + + # To keep backward compatibility with AUTO_DEVOPS_DOMAIN + # environment variable, we need to ensure KUBE_INGRESS_BASE_DOMAIN + # is set if AUTO_DEVOPS_DOMAIN is set on any of the following options: + # ProjectAutoDevops#Domain, project variables or group variables, + # as the AUTO_DEVOPS_DOMAIN is needed for CI_ENVIRONMENT_URL + # + # This method should be removed on 12.0 + def legacy_auto_devops_domain + if project_type? + project&.auto_devops&.domain.presence || + project.variables.find_by(key: 'AUTO_DEVOPS_DOMAIN')&.value.presence || + project.group&.variables&.find_by(key: 'AUTO_DEVOPS_DOMAIN')&.value.presence + elsif group_type? + group.variables.find_by(key: 'AUTO_DEVOPS_DOMAIN')&.value.presence + end + end + def restrict_modification if provider&.on_creation? errors.add(:base, "cannot modify during creation") diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index 8f3424db295..c8969351ed9 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -98,6 +98,8 @@ module Clusters .append(key: 'KUBE_NAMESPACE', value: actual_namespace) .append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true) end + + variables.concat(cluster.predefined_variables) end end diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 5fa6f79bdaa..1a8570b80c3 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -115,7 +115,28 @@ module CacheMarkdownField end def latest_cached_markdown_version - CacheMarkdownField::CACHE_COMMONMARK_VERSION + @latest_cached_markdown_version ||= (CacheMarkdownField::CACHE_COMMONMARK_VERSION << 16) | local_version + end + + def local_version + # because local_markdown_version is stored in application_settings which + # uses cached_markdown_version too, we check explicitly to avoid + # endless loop + return local_markdown_version if has_attribute?(:local_markdown_version) + + settings = Gitlab::CurrentSettings.current_application_settings + + # Following migrations are not properly isolated and + # use real models (by calling .ghost method), in these migrations + # local_markdown_version attribute doesn't exist yet, so we + # use a default value: + # db/migrate/20170825104051_migrate_issues_to_ghost_user.rb + # db/migrate/20171114150259_merge_requests_author_id_foreign_key.rb + if settings.respond_to?(:local_markdown_version) + settings.local_markdown_version + else + 0 + end end included do diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb index 0816778deae..7f9ff7bbda6 100644 --- a/app/models/gpg_signature.rb +++ b/app/models/gpg_signature.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class GpgSignature < ActiveRecord::Base +class GpgSignature < ApplicationRecord include ShaAttribute sha_attribute :commit_sha @@ -33,6 +33,11 @@ class GpgSignature < ActiveRecord::Base ) end + def self.safe_create!(attributes) + create_with(attributes) + .safe_find_or_create_by!(commit_sha: attributes[:commit_sha]) + end + def gpg_key=(model) case model when GpgKey diff --git a/app/models/pool_repository.rb b/app/models/pool_repository.rb index 34220c1b450..4635fc72dc7 100644 --- a/app/models/pool_repository.rb +++ b/app/models/pool_repository.rb @@ -96,7 +96,9 @@ class PoolRepository < ActiveRecord::Base @object_pool ||= Gitlab::Git::ObjectPool.new( shard.name, disk_path + '.git', - source_project.repository.raw) + source_project.repository.raw, + source_project.full_path + ) end def inspect diff --git a/app/models/project.rb b/app/models/project.rb index d4e2ed883bc..8f746f6e094 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1288,7 +1288,7 @@ class Project < ActiveRecord::Base # Forked import is handled asynchronously return if forked? && !force - if gitlab_shell.create_repository(repository_storage, disk_path) + if gitlab_shell.create_project_repository(self) repository.after_create true else diff --git a/app/models/project_auto_devops.rb b/app/models/project_auto_devops.rb index 2253ad7b543..b6c5c7c4c87 100644 --- a/app/models/project_auto_devops.rb +++ b/app/models/project_auto_devops.rb @@ -24,6 +24,11 @@ class ProjectAutoDevops < ActiveRecord::Base domain.present? || instance_domain.present? end + # From 11.8, AUTO_DEVOPS_DOMAIN has been replaced by KUBE_INGRESS_BASE_DOMAIN. + # See Clusters::Cluster#predefined_variables and https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/24580 + # for more info. + # Support for AUTO_DEVOPS_DOMAIN support will be dropped on 12.0 on + # https://gitlab.com/gitlab-org/gitlab-ce/issues/52363 def predefined_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| if has_domain? diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index 559e4f99294..c43bd45a62f 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -60,7 +60,7 @@ class ProjectWiki def wiki @wiki ||= begin gl_repository = Gitlab::GlRepository.gl_repository(project, true) - raw_repository = Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', gl_repository) + raw_repository = Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', gl_repository, full_path) create_repo!(raw_repository) unless raw_repository.exists? @@ -175,7 +175,7 @@ class ProjectWiki private def create_repo!(raw_repository) - gitlab_shell.create_repository(project.repository_storage, disk_path) + gitlab_shell.create_wiki_repository(project) raise CouldNotCreateWikiError unless raw_repository.exists? diff --git a/app/models/repository.rb b/app/models/repository.rb index bfd2608bed4..7c50b4488e5 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -1104,6 +1104,9 @@ class Repository end def initialize_raw_repository - Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', Gitlab::GlRepository.gl_repository(project, is_wiki)) + Gitlab::Git::Repository.new(project.repository_storage, + disk_path + '.git', + Gitlab::GlRepository.gl_repository(project, is_wiki), + project.full_path) end end diff --git a/app/models/user.rb b/app/models/user.rb index 9c091ac366c..24101eda0b1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -228,6 +228,9 @@ class User < ApplicationRecord delegate :path, to: :namespace, allow_nil: true, prefix: true delegate :notes_filter_for, to: :user_preference delegate :set_notes_filter, to: :user_preference + delegate :first_day_of_week, :first_day_of_week=, to: :user_preference + + accepts_nested_attributes_for :user_preference, update_only: true state_machine :state, initial: :active do event :block do diff --git a/app/serializers/error_tracking/project_serializer.rb b/app/serializers/error_tracking/project_serializer.rb index b2406f4d631..68724088fff 100644 --- a/app/serializers/error_tracking/project_serializer.rb +++ b/app/serializers/error_tracking/project_serializer.rb @@ -2,6 +2,6 @@ module ErrorTracking class ProjectSerializer < BaseSerializer - entity ProjectEntity + entity ErrorTracking::ProjectEntity end end diff --git a/app/services/error_tracking/list_projects_service.rb b/app/services/error_tracking/list_projects_service.rb new file mode 100644 index 00000000000..c6e8be0f2be --- /dev/null +++ b/app/services/error_tracking/list_projects_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module ErrorTracking + class ListProjectsService < ::BaseService + def execute + return error('access denied') unless can_read? + + setting = project_error_tracking_setting + + unless setting.valid? + return error(setting.errors.full_messages.join(', '), :bad_request) + end + + begin + result = setting.list_sentry_projects + rescue Sentry::Client::Error => e + return error(e.message, :bad_request) + rescue Sentry::Client::SentryError => e + return error(e.message, :unprocessable_entity) + end + + success(projects: result[:projects]) + end + + private + + def project_error_tracking_setting + (project.error_tracking_setting || project.build_error_tracking_setting).tap do |setting| + setting.api_url = ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from( + api_host: params[:api_host], + organization_slug: nil, + project_slug: nil + ) + + setting.token = params[:token] + setting.enabled = true + end + end + + def can_read? + can?(current_user, :read_sentry_issue, project) + end + end +end diff --git a/app/services/labels/update_service.rb b/app/services/labels/update_service.rb index e563447c64c..be33947d0eb 100644 --- a/app/services/labels/update_service.rb +++ b/app/services/labels/update_service.rb @@ -8,6 +8,7 @@ module Labels # returns the updated label def execute(label) + params[:name] = params.delete(:new_name) if params.key?(:new_name) params[:color] = convert_color_name_to_hex if params[:color].present? label.update(params) diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 86a04587f79..8112c2a4299 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -21,7 +21,7 @@ module MergeRequests end handle_wip_event(merge_request) - update(merge_request) + update_task_event(merge_request) || update(merge_request) end # rubocop:disable Metrics/AbcSize @@ -83,6 +83,11 @@ module MergeRequests end # rubocop:enable Metrics/AbcSize + def handle_task_changes(merge_request) + todo_service.mark_pending_todos_as_done(merge_request, current_user) + todo_service.update_merge_request(merge_request, current_user) + end + def merge_from_quick_action(merge_request) last_diff_sha = params.delete(:merge) return unless merge_request.mergeable_with_quick_action?(current_user, last_diff_sha: last_diff_sha) diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index 5861b803996..7214e9efaf6 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -73,7 +73,7 @@ module Projects project.ensure_repository project.repository.fetch_as_mirror(project.import_url, refmap: refmap) else - gitlab_shell.import_repository(project.repository_storage, project.disk_path, project.import_url) + gitlab_shell.import_project_repository(project) end rescue Gitlab::Shell::Error => e # Expire cache to prevent scenarios such as: diff --git a/app/services/task_list_toggle_service.rb b/app/services/task_list_toggle_service.rb index b5c4cd3235d..cfe187d9b12 100644 --- a/app/services/task_list_toggle_service.rb +++ b/app/services/task_list_toggle_service.rb @@ -32,7 +32,8 @@ class TaskListToggleService source_line_index = line_number - 1 markdown_task = source_lines[source_line_index] - return unless markdown_task == line_source + # The source in the DB could be using either \n or \r\n line endings + return unless markdown_task == line_source || markdown_task == line_source + "\r" return unless source_checkbox = Taskable::ITEM_PATTERN.match(markdown_task) currently_checked = TaskList::Item.new(source_checkbox[1]).complete? diff --git a/app/views/admin/application_settings/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml new file mode 100644 index 00000000000..95d016a94a5 --- /dev/null +++ b/app/views/admin/application_settings/_localization.html.haml @@ -0,0 +1,11 @@ += form_for @application_setting, url: admin_application_settings_path(anchor: 'js-localization-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :first_day_of_week, _('Default first day of the week'), class: 'label-bold' + = f.select :first_day_of_week, first_day_of_week_choices, {}, class: 'form-control' + .form-text.text-muted + = _('Default first day of the week in calendars and date pickers.') + + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/preferences.html.haml b/app/views/admin/application_settings/preferences.html.haml index 00000b86ab7..c468d69d7b8 100644 --- a/app/views/admin/application_settings/preferences.html.haml +++ b/app/views/admin/application_settings/preferences.html.haml @@ -56,3 +56,14 @@ = _('Configure Gitaly timeouts.') .settings-content = render 'gitaly' + +%section.settings.as-localization.no-animate#js-localization-settings{ class: ('expanded' if expanded_by_default?) } + .settings-header + %h4 + = _('Localization') + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded_by_default? ? _('Collapse') : _('Expand') + %p + = _('Various localization settings.') + .settings-content + = render 'localization' diff --git a/app/views/clusters/clusters/_form.html.haml b/app/views/clusters/clusters/_form.html.haml index 4c47e11927e..7acd9ce0562 100644 --- a/app/views/clusters/clusters/_form.html.haml +++ b/app/views/clusters/clusters/_form.html.haml @@ -20,12 +20,27 @@ .form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster.") - else = text_field_tag :environment_scope, '*', class: 'col-md-6 form-control disabled', placeholder: s_('ClusterIntegration|Environment scope'), disabled: true - - environment_scope_url = 'https://docs.gitlab.com/ee/user/project/clusters/#setting-the-environment-scope-premium' + - environment_scope_url = help_page_path('user/project/clusters/index', anchor: 'base-domain') - environment_scope_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: environment_scope_url } .form-text.text-muted %code * = s_("ClusterIntegration| is the default environment scope for this cluster. This means that all jobs, regardless of their environment, will use this cluster. %{environment_scope_start}More information%{environment_scope_end}").html_safe % { environment_scope_start: environment_scope_start, environment_scope_end: '</a>'.html_safe } + .form-group + %h5= s_('ClusterIntegration|Base domain') + = field.text_field :base_domain, class: 'col-md-6 form-control js-select-on-focus' + .form-text.text-muted + - auto_devops_url = help_page_path('topics/autodevops/index') + - auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url } + = s_('ClusterIntegration|Specifying a domain will allow you to use Auto Review Apps and Auto Deploy stages for %{auto_devops_start}Auto DevOps%{auto_devops_end}. The domain should have a wildcard DNS configured matching the domain.').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe } + - if @cluster.application_ingress_external_ip.present? + = s_('ClusterIntegration|Alternatively') + %code #{@cluster.application_ingress_external_ip}.nip.io + = s_('ClusterIntegration| can be used instead of a custom domain.') + - custom_domain_url = help_page_path('user/project/clusters/index', anchor: 'pointing-your-dns-at-the-cluster-ip') + - custom_domain_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: custom_domain_url } + = s_('ClusterIntegration| %{custom_domain_start}More information%{custom_domain_end}.').html_safe % { custom_domain_start: custom_domain_start, custom_domain_end: '</a>'.html_safe } + - if can?(current_user, :update_cluster, @cluster) .form-group = field.submit _('Save changes'), class: 'btn btn-success' diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index bf475c07711..3fbaaafe89e 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -36,7 +36,7 @@ %span = _('Activity') - = render_if_exists 'groups/sidebar/security_dashboard' + = render_if_exists 'groups/sidebar/security_dashboard' # EE-specific - if group_sidebar_link?(:contribution_analytics) = nav_link(path: 'analytics#show') do diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index 7c378633667..1a9aca1f6bf 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -51,11 +51,30 @@ = f.label :dashboard, class: 'label-bold' do Default dashboard = f.select :dashboard, dashboard_choices, {}, class: 'form-control' + + = render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific + .form-group = f.label :project_view, class: 'label-bold' do Project overview content = f.select :project_view, project_view_choices, {}, class: 'form-control' .form-text.text-muted Choose what content you want to see on a project’s overview page. + + .col-sm-12 + %hr + + .col-lg-4.profile-settings-sidebar + %h4.prepend-top-0 + = _('Localization') + %p + = _('Customize language and region related settings.') + = succeed '.' do + = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'localization'), target: '_blank' + .col-lg-8 + .form-group + = f.label :first_day_of_week, class: 'label-bold' do + = _('First day of the week') + = f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'form-control' .form-group - = f.submit 'Save changes', class: 'btn btn-success' + = f.submit _('Save changes'), class: 'btn btn-success' diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml index 06f0cd9675e..fe9a8ac4182 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -10,7 +10,7 @@ .container-fluid{ class: [limited_container_width, container_class] } = render "commit_box" = render "ci_menu" - = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, is_commit: true + = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, diff_page_context: "is-commit" .limited-width-notes = render "shared/notes/notes_with_form", :autocomplete => true diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml index b6bebbabed0..5774b48a054 100644 --- a/app/views/projects/compare/show.html.haml +++ b/app/views/projects/compare/show.html.haml @@ -8,7 +8,7 @@ - if @commits.present? = render "projects/commits/commit_list" - = render "projects/diffs/diffs", diffs: @diffs, environment: @environment + = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, diff_page_context: "is-compare" - else .card.bg-light .center diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index cc2d0d3b2d8..2dba3fcd664 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -2,7 +2,7 @@ - show_whitespace_toggle = local_assigns.fetch(:show_whitespace_toggle, true) - can_create_note = !@diff_notes_disabled && can?(current_user, :create_note, diffs.project) - diff_files = diffs.diff_files -- is_commit = local_assigns.fetch(:is_commit, false) +- diff_page_context = local_assigns.fetch(:diff_page_context, nil) .content-block.oneline-block.files-changed.diff-files-changed.js-diff-files-changed .files-changed-inner @@ -25,4 +25,4 @@ = render 'projects/diffs/warning', diff_files: diffs .files{ data: { can_create_note: can_create_note } } - = render partial: 'projects/diffs/file', collection: diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment, is_commit: is_commit } + = render partial: 'projects/diffs/file', collection: diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment, diff_page_context: diff_page_context } diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index 5565ae1d98b..855b719dc45 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -1,11 +1,11 @@ - environment = local_assigns.fetch(:environment, nil) -- is_commit = local_assigns.fetch(:is_commit, false) +- diff_page_context = local_assigns.fetch(:diff_page_context, nil) - file_hash = hexdigest(diff_file.file_path) - image_diff = diff_file.rich_viewer && diff_file.rich_viewer.partial_name == 'image' - image_replaced = diff_file.old_content_sha && diff_file.old_content_sha != diff_file.content_sha .diff-file.file-holder{ id: file_hash, data: diff_file_html_data(project, diff_file.file_path, diff_file.content_sha) } - .js-file-title.file-title-flex-parent{ class: is_commit ? "is-commit" : "" } + .js-file-title.file-title-flex-parent{ class: diff_page_context } .file-header-content = render "projects/diffs/file_header", diff_file: diff_file, url: "##{file_hash}" 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 5ec5a06396e..8c4d1c32ebe 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -4,10 +4,6 @@ = form_errors(@project) %fieldset.builds-feature.js-auto-devops-settings .form-group - - message = auto_devops_warning_message(@project) - - if message - %p.auto-devops-warning-message.settings-message.text-center - = message.html_safe = f.fields_for :auto_devops_attributes, @auto_devops do |form| .card.auto-devops-card .card-body @@ -21,19 +17,12 @@ = s_('CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found.') = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank' .card-footer.js-extra-settings{ class: @project.auto_devops_enabled? || 'hidden' } - = form.label :domain do - %strong= _('Domain') - = form.text_field :domain, class: 'form-control', placeholder: 'domain.com' - .form-text.text-muted - = s_('CICD|You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages.') - - if cluster_ingress_ip = cluster_ingress_ip(@project) - = s_('%{nip_domain} can be used as an alternative to a custom domain.').html_safe % { nip_domain: "<code>#{cluster_ingress_ip}.nip.io</code>".html_safe } - = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'auto-devops-base-domain'), target: '_blank' - + %p.settings-message.text-center + - kubernetes_cluster_link = help_page_path('user/project/clusters/index') + - kubernetes_cluster_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: kubernetes_cluster_link } + = s_('CICD|You must add a %{kubernetes_cluster_start}Kubernetes cluster integration%{kubernetes_cluster_end} to this project with a domain in order for your deployment strategy to work correctly.').html_safe % { kubernetes_cluster_start: kubernetes_cluster_start, kubernetes_cluster_end: '</a>'.html_safe } %label.prepend-top-10 %strong= s_('CICD|Deployment strategy') - %p.settings-message.text-center - = s_('CICD|Deployment strategy needs a domain name to work correctly.') .form-check = form.radio_button :deploy_strategy, 'continuous', class: 'form-check-input' = form.label :deploy_strategy_continuous, class: 'form-check-label' do diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index e1564d57426..df17ae95e2a 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -12,21 +12,20 @@ - css_class += " no-description" if project.description.blank? && !show_last_commit_as_description - cache_key = project_list_cache_key(project) - updated_tooltip = time_ago_with_tooltip(project.last_activity_date) -- css_details_class = compact_mode ? "d-flex flex-column flex-sm-row flex-md-row align-items-sm-center" : "align-items-center flex-md-fill flex-lg-column d-sm-flex d-lg-block" -- css_controls_class = compact_mode ? "" : "align-items-md-end align-items-lg-center flex-lg-row" +- css_controls_class = compact_mode ? "" : "flex-lg-row justify-content-lg-between" %li.project-row.d-flex{ class: css_class } = cache(cache_key) do - if avatar - .avatar-container.s64.flex-grow-0.flex-shrink-0 + .avatar-container.s48.flex-grow-0.flex-shrink-0 = link_to project_path(project), class: dom_class(project) do - if project.creator && use_creator_avatar - = image_tag avatar_icon_for_user(project.creator, 64), class: "avatar s65", alt:'' + = image_tag avatar_icon_for_user(project.creator, 48), class: "avatar s65", alt:'' - else - = project_icon(project, alt: '', class: 'avatar project-avatar s64', width: 64, height: 64) - .project-details.flex-sm-fill{ class: css_details_class } - .flex-wrapper.flex-fill - .d-flex.align-items-center.flex-wrap + = project_icon(project, alt: '', class: 'avatar project-avatar s48', width: 48, height: 48) + .project-details.d-sm-flex.flex-sm-fill.align-items-center + .flex-wrapper + .d-flex.align-items-center.flex-wrap.project-title %h2.d-flex.prepend-top-8 = link_to project_path(project), class: 'text-plain' do %span.project-full-name.append-right-8>< @@ -52,13 +51,13 @@ %span.user-access-role.d-block= Gitlab::Access.human_access(access) - if show_last_commit_as_description - .description.d-none.d-sm-block.prepend-top-8.append-right-default + .description.d-none.d-sm-block.append-right-default = link_to_markdown(project.commit.title, project_commit_path(project, project.commit), class: "commit-row-message") - elsif project.description.present? - .description.d-none.d-sm-block.prepend-top-8.append-right-default + .description.d-none.d-sm-block.append-right-default = markdown_field(project, :description) - .controls.d-flex.flex-row.flex-sm-column.flex-md-column.align-items-center.align-items-sm-end.flex-wrap.flex-shrink-0{ class: css_controls_class } + .controls.d-flex.flex-sm-column.align-items-center.align-items-sm-end.flex-wrap.flex-shrink-0{ class: css_controls_class } .icon-container.d-flex.align-items-center - if project.archived %span.d-flex.icon-wrapper.badge.badge-warning archived @@ -74,13 +73,13 @@ = number_with_delimiter(project.forks_count) - if show_merge_request_count?(disabled: !merge_requests, compact_mode: compact_mode) = link_to project_merge_requests_path(project), - class: "d-none d-lg-flex align-items-center icon-wrapper merge-requests has-tooltip", + class: "d-none d-xl-flex align-items-center icon-wrapper merge-requests has-tooltip", title: _('Merge Requests'), data: { container: 'body', placement: 'top' } do = sprite_icon('git-merge', size: 14, css_class: 'append-right-4') = number_with_delimiter(project.open_merge_requests_count) - if show_issue_count?(disabled: !issues, compact_mode: compact_mode) = link_to project_issues_path(project), - class: "d-none d-lg-flex align-items-center icon-wrapper issues has-tooltip", + class: "d-none d-xl-flex align-items-center icon-wrapper issues has-tooltip", title: _('Issues'), data: { container: 'body', placement: 'top' } do = sprite_icon('issues', size: 14, css_class: 'append-right-4') = number_with_delimiter(project.open_issues_count) @@ -89,19 +88,3 @@ = render_project_pipeline_status(project.pipeline_status, tooltip_placement: 'top') .updated-note %span Updated #{updated_tooltip} - - .d-none.d-lg-flex.align-item-stretch - - unless compact_mode - - if current_user - %button.star-button.btn.btn-default.d-flex.align-items-center.star-btn.toggle-star{ type: "button", data: { endpoint: toggle_star_project_path(project, :json) } } - - if current_user.starred?(project) - = sprite_icon('star', { css_class: 'icon' }) - %span.starred= s_('ProjectOverview|Unstar') - - else - = sprite_icon('star-o', { css_class: 'icon' }) - %span= s_('ProjectOverview|Star') - - - else - = link_to new_user_session_path, class: 'btn btn-default has-tooltip count-badge-button d-flex align-items-center star-btn', title: s_('ProjectOverview|You must sign in to star a project') do - = sprite_icon('star-o', { css_class: 'icon' }) - %span= s_('ProjectOverview|Star') diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index 7eae07d3f6b..a9b88a133be 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -15,19 +15,19 @@ class RepositoryForkWorker return target_project.import_state.mark_as_failed(_('Source project cannot be found.')) end - fork_repository(target_project, source_project.repository_storage, source_project.disk_path) + fork_repository(target_project, source_project) end private - def fork_repository(target_project, source_repository_storage_name, source_disk_path) + def fork_repository(target_project, source_project) return unless start_fork(target_project) Gitlab::Metrics.add_event(:fork_repository) - result = gitlab_shell.fork_repository(source_repository_storage_name, source_disk_path, - target_project.repository_storage, target_project.disk_path) - raise "Unable to fork project #{target_project.id} for repository #{source_disk_path} -> #{target_project.disk_path}" unless result + result = gitlab_shell.fork_repository(source_project, target_project) + + raise "Unable to fork project #{target_project.id} for repository #{source_project.disk_path} -> #{target_project.disk_path}" unless result target_project.after_import end diff --git a/bin/secpick b/bin/secpick index be120a304c9..8f956d300a7 100755 --- a/bin/secpick +++ b/bin/secpick @@ -10,6 +10,7 @@ using Rainbow module Secpick BRANCH_PREFIX = 'security'.freeze + STABLE_SUFFIX = 'stable'.freeze DEFAULT_REMOTE = 'dev'.freeze NEW_MR_URL = 'https://dev.gitlab.org/gitlab/gitlabhq/merge_requests/new'.freeze @@ -36,16 +37,16 @@ module Secpick branch.freeze end - def security_branch - "#{BRANCH_PREFIX}-#{@options[:version]}".tap do |name| + def stable_branch + "#{@options[:version]}-#{STABLE_SUFFIX}".tap do |name| name << "-ee" if ee? end.freeze end def git_commands - ["git fetch #{@options[:remote]} #{security_branch}", - "git checkout #{security_branch}", - "git pull #{@options[:remote]} #{security_branch}", + ["git fetch #{@options[:remote]} #{stable_branch}", + "git checkout #{stable_branch}", + "git pull #{@options[:remote]} #{stable_branch}", "git checkout -B #{source_branch}", "git cherry-pick #{@options[:sha]}", "git push #{@options[:remote]} #{source_branch}", @@ -56,9 +57,8 @@ module Secpick { merge_request: { source_branch: source_branch, - target_branch: security_branch, - title: "[#{@options[:version].tr('-', '.')}] ", - description: '/label ~security ~"Merge into Security"' + target_branch: stable_branch, + description: '/label ~security' } } end diff --git a/changelogs/unreleased/2105-add-setting-for-first-day-of-the-week.yml b/changelogs/unreleased/2105-add-setting-for-first-day-of-the-week.yml new file mode 100644 index 00000000000..f4a52b1aacd --- /dev/null +++ b/changelogs/unreleased/2105-add-setting-for-first-day-of-the-week.yml @@ -0,0 +1,5 @@ +--- +title: Add setting for first day of the week +merge_request: 22755 +author: Fabian Schneider @fabsrc +type: added diff --git a/changelogs/unreleased/44332-add-openid-profile-scopes.yml b/changelogs/unreleased/44332-add-openid-profile-scopes.yml new file mode 100644 index 00000000000..b554fab5139 --- /dev/null +++ b/changelogs/unreleased/44332-add-openid-profile-scopes.yml @@ -0,0 +1,5 @@ +--- +title: GitLab now supports the profile and email scopes from OpenID Connect +merge_request: 24335 +author: Goten Xiao +type: added diff --git a/changelogs/unreleased/52347-lines-changed-statistics-is-not-easily-visible-in-mr-changes-view.yml b/changelogs/unreleased/52347-lines-changed-statistics-is-not-easily-visible-in-mr-changes-view.yml new file mode 100644 index 00000000000..cf1c4378f18 --- /dev/null +++ b/changelogs/unreleased/52347-lines-changed-statistics-is-not-easily-visible-in-mr-changes-view.yml @@ -0,0 +1,5 @@ +--- +title: Show MR statistics in diff comparisons +merge_request: !24569 +author: +type: changed diff --git a/changelogs/unreleased/52363-ui-changes-to-cluster-and-ado-pages.yml b/changelogs/unreleased/52363-ui-changes-to-cluster-and-ado-pages.yml new file mode 100644 index 00000000000..eb4851971fb --- /dev/null +++ b/changelogs/unreleased/52363-ui-changes-to-cluster-and-ado-pages.yml @@ -0,0 +1,5 @@ +--- +title: Moves domain setting from Auto DevOps to Cluster's page +merge_request: 24580 +author: +type: added diff --git a/changelogs/unreleased/56014-api-merge-request-squash-commit-messages.yml b/changelogs/unreleased/56014-api-merge-request-squash-commit-messages.yml new file mode 100644 index 00000000000..e324baa94a3 --- /dev/null +++ b/changelogs/unreleased/56014-api-merge-request-squash-commit-messages.yml @@ -0,0 +1,5 @@ +--- +title: API allows setting the squash commit message when squashing a merge request +merge_request: 24784 +author: +type: added diff --git a/changelogs/unreleased/56014-better-squash-commit-messages.yml b/changelogs/unreleased/56014-better-squash-commit-messages.yml deleted file mode 100644 index b08d584ac0a..00000000000 --- a/changelogs/unreleased/56014-better-squash-commit-messages.yml +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: Default squash commit message is now selected from the longest commit when - squashing merge requests -merge_request: 24518 -author: -type: changed diff --git a/changelogs/unreleased/56543-project-lists-further-iteration-improvements.yml b/changelogs/unreleased/56543-project-lists-further-iteration-improvements.yml new file mode 100644 index 00000000000..388ff1d062a --- /dev/null +++ b/changelogs/unreleased/56543-project-lists-further-iteration-improvements.yml @@ -0,0 +1,5 @@ +--- +title: Project list UI improvements +merge_request: 24855 +author: +type: other diff --git a/changelogs/unreleased/56788-unicorn-metric-labels.yml b/changelogs/unreleased/56788-unicorn-metric-labels.yml new file mode 100644 index 00000000000..824c981780c --- /dev/null +++ b/changelogs/unreleased/56788-unicorn-metric-labels.yml @@ -0,0 +1,5 @@ +--- +title: Clean up unicorn sampler metric labels +merge_request: 24626 +author: bjk-gitlab +type: fixed diff --git a/changelogs/unreleased/56938-diff-file-headers-on-compare-not-quite-right.yml b/changelogs/unreleased/56938-diff-file-headers-on-compare-not-quite-right.yml new file mode 100644 index 00000000000..f619a009a63 --- /dev/null +++ b/changelogs/unreleased/56938-diff-file-headers-on-compare-not-quite-right.yml @@ -0,0 +1,5 @@ +--- +title: Correct spacing for comparison page +merge_request: !24783 +author: +type: fixed diff --git a/changelogs/unreleased/adriel-remove-feature-flag.yml b/changelogs/unreleased/adriel-remove-feature-flag.yml new file mode 100644 index 00000000000..d442e120d60 --- /dev/null +++ b/changelogs/unreleased/adriel-remove-feature-flag.yml @@ -0,0 +1,5 @@ +--- +title: Update metrics dashboard graph design +merge_request: 24653 +author: +type: changed diff --git a/changelogs/unreleased/api-group-labels.yml b/changelogs/unreleased/api-group-labels.yml new file mode 100644 index 00000000000..0df6f15a9b6 --- /dev/null +++ b/changelogs/unreleased/api-group-labels.yml @@ -0,0 +1,5 @@ +--- +title: 'API: Add support for group labels' +merge_request: 21368 +author: Robert Schilling +type: added diff --git a/changelogs/unreleased/bvl-fix-race-condition-creating-signature.yml b/changelogs/unreleased/bvl-fix-race-condition-creating-signature.yml new file mode 100644 index 00000000000..307b4f526bb --- /dev/null +++ b/changelogs/unreleased/bvl-fix-race-condition-creating-signature.yml @@ -0,0 +1,5 @@ +--- +title: Avoid race conditions when creating GpgSignature +merge_request: 24939 +author: +type: fixed diff --git a/changelogs/unreleased/fix-repo-settings-file-upload-error.yml b/changelogs/unreleased/fix-repo-settings-file-upload-error.yml new file mode 100644 index 00000000000..b219fdfaa1e --- /dev/null +++ b/changelogs/unreleased/fix-repo-settings-file-upload-error.yml @@ -0,0 +1,5 @@ +--- +title: Fix bug causing repository mirror settings UI to break +merge_request: 23712 +author: +type: fixed diff --git a/changelogs/unreleased/gitaly-update-1.18.0.yml b/changelogs/unreleased/gitaly-update-1.18.0.yml new file mode 100644 index 00000000000..392527f5e5d --- /dev/null +++ b/changelogs/unreleased/gitaly-update-1.18.0.yml @@ -0,0 +1,5 @@ +--- +title: Upgrade gitaly to 1.18.0 +merge_request: 24981 +author: +type: other diff --git a/changelogs/unreleased/jlenny-NewAndroidTemplate.yml b/changelogs/unreleased/jlenny-NewAndroidTemplate.yml new file mode 100644 index 00000000000..ae8c58da859 --- /dev/null +++ b/changelogs/unreleased/jlenny-NewAndroidTemplate.yml @@ -0,0 +1,5 @@ +--- +title: Add template for Android with Fastlane +merge_request: 24722 +author: +type: changed diff --git a/changelogs/unreleased/local-markdown-version-bkp3.yml b/changelogs/unreleased/local-markdown-version-bkp3.yml new file mode 100644 index 00000000000..ce5bff6ae6b --- /dev/null +++ b/changelogs/unreleased/local-markdown-version-bkp3.yml @@ -0,0 +1,5 @@ +--- +title: Allow admins to invalidate markdown texts by setting local markdown version. +merge_request: +author: +type: added diff --git a/changelogs/unreleased/support-chunking-in-client.yml b/changelogs/unreleased/support-chunking-in-client.yml new file mode 100644 index 00000000000..e50648ea4b2 --- /dev/null +++ b/changelogs/unreleased/support-chunking-in-client.yml @@ -0,0 +1,5 @@ +--- +title: Fix code search when text is larger than max gRPC message size +merge_request: 24111 +author: +type: changed diff --git a/changelogs/unreleased/workhorse-8-3-0.yml b/changelogs/unreleased/workhorse-8-3-0.yml new file mode 100644 index 00000000000..6ae01d64ae5 --- /dev/null +++ b/changelogs/unreleased/workhorse-8-3-0.yml @@ -0,0 +1,5 @@ +--- +title: Update Workhorse to v8.3.0 +merge_request: 24959 +author: +type: other diff --git a/config/initializers/doorkeeper_openid_connect.rb b/config/initializers/doorkeeper_openid_connect.rb index e97c0fcbd6b..fd5a62c39c6 100644 --- a/config/initializers/doorkeeper_openid_connect.rb +++ b/config/initializers/doorkeeper_openid_connect.rb @@ -31,8 +31,27 @@ Doorkeeper::OpenidConnect.configure do o.claim(:name) { |user| user.name } o.claim(:nickname) { |user| user.username } - o.claim(:email) { |user| user.public_email } - o.claim(:email_verified) { |user| true if user.public_email? } + + # Check whether the application has access to the email scope, and grant + # access to the user's primary email address if so, otherwise their + # public email address (if present) + # This allows existing solutions built for GitLab's old behavior to keep + # working without modification. + o.claim(:email) do |user, scopes| + scopes.exists?(:email) ? user.email : user.public_email + end + o.claim(:email_verified) do |user, scopes| + if scopes.exists?(:email) + user.primary_email_verified? + elsif user.public_email? + user.verified_email?(user.public_email) + else + # If there is no public email set, tell doorkicker-openid-connect to + # exclude the email_verified claim by returning nil. + nil + end + end + o.claim(:website) { |user| user.full_website_url if user.website_url? } o.claim(:profile) { |user| Gitlab::Routing.url_helpers.user_url user } o.claim(:picture) { |user| user.avatar_url(only_path: false) } diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml index 9f451046462..a2dff92908e 100644 --- a/config/locales/doorkeeper.en.yml +++ b/config/locales/doorkeeper.en.yml @@ -64,6 +64,8 @@ en: read_registry: Grants permission to read container registry images openid: Authenticate using OpenID Connect sudo: Perform API actions as any user in the system + profile: Allows read-only access to the user's personal information using OpenID Connect + email: Allows read-only access to the user's primary email address using OpenID Connect scope_desc: api: Grants complete read/write access to the API, including all groups and projects. @@ -77,6 +79,10 @@ en: Grants permission to authenticate with GitLab using OpenID Connect. Also gives read-only access to the user's profile and group memberships. sudo: Grants permission to perform API actions as any user in the system, when authenticated as an admin user. + profile: + Grants read-only access to the user's profile data using OpenID Connect. + email: + Grants read-only access to the user's primary email address using OpenID Connect. flash: applications: create: diff --git a/config/routes/project.rb b/config/routes/project.rb index d730479cf2b..b4ebc7df4fe 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -444,7 +444,11 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do end end - resources :error_tracking, only: [:index], controller: :error_tracking + resources :error_tracking, only: [:index], controller: :error_tracking do + collection do + post :list_projects + end + end # Since both wiki and repository routing contains wildcard characters # its preferable to keep it below all other project routes diff --git a/db/migrate/20140502125220_migrate_repo_size.rb b/db/migrate/20140502125220_migrate_repo_size.rb index a69b02cddc4..bff1f01c654 100644 --- a/db/migrate/20140502125220_migrate_repo_size.rb +++ b/db/migrate/20140502125220_migrate_repo_size.rb @@ -11,7 +11,7 @@ class MigrateRepoSize < ActiveRecord::Migration[4.2] path = File.join(namespace_path, project['project_path'] + '.git') begin - repo = Gitlab::Git::Repository.new('default', path, '') + repo = Gitlab::Git::Repository.new('default', path, '', '') if repo.empty? print '-' else diff --git a/db/migrate/20181027114222_add_first_day_of_week_to_user_preferences.rb b/db/migrate/20181027114222_add_first_day_of_week_to_user_preferences.rb new file mode 100644 index 00000000000..a0e76c2186e --- /dev/null +++ b/db/migrate/20181027114222_add_first_day_of_week_to_user_preferences.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddFirstDayOfWeekToUserPreferences < ActiveRecord::Migration + DOWNTIME = false + + def change + add_column :user_preferences, :first_day_of_week, :integer + end +end diff --git a/db/migrate/20181028120717_add_first_day_of_week_to_application_settings.rb b/db/migrate/20181028120717_add_first_day_of_week_to_application_settings.rb new file mode 100644 index 00000000000..53cfaa289f6 --- /dev/null +++ b/db/migrate/20181028120717_add_first_day_of_week_to_application_settings.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AddFirstDayOfWeekToApplicationSettings < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + disable_ddl_transaction! + + DOWNTIME = false + + def up + add_column_with_default(:application_settings, :first_day_of_week, :integer, default: 0) + end + + def down + remove_column(:application_settings, :first_day_of_week) + end +end diff --git a/db/migrate/20190130091630_add_local_cached_markdown_version.rb b/db/migrate/20190130091630_add_local_cached_markdown_version.rb new file mode 100644 index 00000000000..00570e6458c --- /dev/null +++ b/db/migrate/20190130091630_add_local_cached_markdown_version.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddLocalCachedMarkdownVersion < ActiveRecord::Migration[5.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :application_settings, :local_markdown_version, :integer, default: 0, null: false + end +end diff --git a/db/post_migrate/20190204115450_migrate_auto_dev_ops_domain_to_cluster_domain.rb b/db/post_migrate/20190204115450_migrate_auto_dev_ops_domain_to_cluster_domain.rb new file mode 100644 index 00000000000..392e64eeade --- /dev/null +++ b/db/post_migrate/20190204115450_migrate_auto_dev_ops_domain_to_cluster_domain.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class MigrateAutoDevOpsDomainToClusterDomain < ActiveRecord::Migration[5.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + execute(update_clusters_domain_query) + end + + def down + # no-op + end + + private + + def update_clusters_domain_query + if Gitlab::Database.mysql? + mysql_query + else + postgresql_query + end + end + + def mysql_query + <<~HEREDOC + UPDATE clusters, project_auto_devops, cluster_projects + SET + clusters.domain = project_auto_devops.domain + WHERE + cluster_projects.cluster_id = clusters.id + AND project_auto_devops.project_id = cluster_projects.project_id + AND project_auto_devops.domain != '' + HEREDOC + end + + def postgresql_query + <<~HEREDOC + UPDATE clusters + SET domain = project_auto_devops.domain + FROM cluster_projects, project_auto_devops + WHERE + cluster_projects.cluster_id = clusters.id + AND project_auto_devops.project_id = cluster_projects.project_id + AND project_auto_devops.domain != '' + HEREDOC + end +end diff --git a/db/schema.rb b/db/schema.rb index 20c8dab4c3e..023eee5f33e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20190131122559) do +ActiveRecord::Schema.define(version: 20190204115450) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -168,6 +168,8 @@ ActiveRecord::Schema.define(version: 20190131122559) do t.string "commit_email_hostname" t.boolean "protected_ci_variables", default: false, null: false t.string "runners_registration_token_encrypted" + t.integer "local_markdown_version", default: 0, null: false + t.integer "first_day_of_week", default: 0, null: false t.index ["usage_stats_set_by_user_id"], name: "index_application_settings_on_usage_stats_set_by_user_id", using: :btree end @@ -2153,6 +2155,7 @@ ActiveRecord::Schema.define(version: 20190131122559) do t.integer "merge_request_notes_filter", limit: 2, default: 0, null: false t.datetime_with_timezone "created_at", null: false t.datetime_with_timezone "updated_at", null: false + t.integer "first_day_of_week" t.string "issues_sort" t.string "merge_requests_sort" t.index ["user_id"], name: "index_user_preferences_on_user_id", unique: true, using: :btree diff --git a/doc/administration/index.md b/doc/administration/index.md index 184754cd467..12fec2753bf 100644 --- a/doc/administration/index.md +++ b/doc/administration/index.md @@ -65,6 +65,7 @@ Learn how to install, configure, update, and maintain your GitLab instance. - [Backup and restore](../raketasks/backup_restore.md): Backup and restore your GitLab instance. - [Operations](operations/index.md): Keeping GitLab up and running (clean up Redis sessions, moving repositories, Sidekiq MemoryKiller, Unicorn). - [Restart GitLab](restart_gitlab.md): Learn how to restart GitLab and its components. +- [Invalidate markdown cache](invalidate_markdown_cache.md): Invalidate any cached markdown. #### Updating GitLab diff --git a/doc/administration/invalidate_markdown_cache.md b/doc/administration/invalidate_markdown_cache.md new file mode 100644 index 00000000000..ad64cb077c1 --- /dev/null +++ b/doc/administration/invalidate_markdown_cache.md @@ -0,0 +1,16 @@ +# Invalidate Markdown Cache + +For performance reasons, GitLab caches the HTML version of markdown text +(e.g. issue and merge request descriptions, comments). It's possible +that these cached versions become outdated, for example +when the `external_url` configuration option is changed - causing links +in the cached text to refer to the old URL. + +To avoid this problem, the administrator can invalidate the existing cache by +increasing the `local_markdown_version` setting in application settings. This can +be done by [changing the application settings through +the API](../api/settings.md#change-application-settings): + +```bash +curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/application/settings?local_markdown_version=<increased_number> +``` diff --git a/doc/administration/monitoring/prometheus/gitlab_metrics.md b/doc/administration/monitoring/prometheus/gitlab_metrics.md index c9a2778b3a4..6ea0ac0d495 100644 --- a/doc/administration/monitoring/prometheus/gitlab_metrics.md +++ b/doc/administration/monitoring/prometheus/gitlab_metrics.md @@ -48,6 +48,8 @@ The following metrics are available: | upload_file_does_not_exist | Counter | 10.7 in EE, 11.5 in CE | Number of times an upload record could not find its file | | failed_login_captcha_total | Gauge | 11.0 | Counter of failed CAPTCHA attempts during login | | successful_login_captcha_total | Gauge | 11.0 | Counter of successful CAPTCHA attempts during login | +| unicorn_active_connections | Gauge | 11.0 | The number of active Unicorn connections (workers) | +| unicorn_queued_connections | Gauge | 11.0 | The number of queued Unicorn connections | ### Ruby metrics diff --git a/doc/api/README.md b/doc/api/README.md index 692f63a400c..a060e0481bf 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -29,6 +29,7 @@ The following API resources are available: - [Group access requests](access_requests.md) - [Group badges](group_badges.md) - [Group issue boards](group_boards.md) + - [Group labels](group_labels.md) - [Group-level variables](group_level_variables.md) - [Group members](members.md) - [Group milestones](group_milestones.md) diff --git a/doc/api/group_labels.md b/doc/api/group_labels.md new file mode 100644 index 00000000000..c36d34b4af1 --- /dev/null +++ b/doc/api/group_labels.md @@ -0,0 +1,201 @@ +# Group Label API + +>**Note:** This feature was [introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/21368) in GitLab 11.8. + +This API supports managing of [group labels](../user/project/labels.md#project-labels-and-group-labels). It allows to list, create, update, and delete group labels. Furthermore, users can subscribe and unsubscribe to and from group labels. + +## List group labels + +Get all labels for a given group. + +``` +GET /groups/:id/labels +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | + +```bash +curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/groups/5/labels +``` + +Example response: + +```json +[ + { + "id": 7, + "name": "bug", + "color": "#FF0000", + "description": null, + "open_issues_count": 0, + "closed_issues_count": 0, + "open_merge_requests_count": 0, + "subscribed": false + }, + { + "id": 4, + "name": "feature", + "color": "#228B22", + "description": null, + "open_issues_count": 0, + "closed_issues_count": 0, + "open_merge_requests_count": 0, + "subscribed": false + } +] +``` + +## Create a new group label + +Create a new group label for a given group. + +``` +POST /groups/:id/labels +``` + +| Attribute | Type | Required | Description | +| ------------- | ------- | -------- | ---------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | +| `name` | string | yes | The name of the label | +| `color` | string | yes | The color of the label given in 6-digit hex notation with leading '#' sign (e.g. #FFAABB) or one of the [CSS color names](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#Color_keywords) | +| `description` | string | no | The description of the label | + +```bash +curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --header "Content-Type: application/json" --data '{"name": "Feature Proposal", "color": "#FFA500", "description": "Describes new ideas" }' https://gitlab.example.com/api/v4/groups/5/labels +``` + +Example response: + +```json +{ + "id": 9, + "name": "Feature Proposal", + "color": "#FFA500", + "description": "Describes new ideas", + "open_issues_count": 0, + "closed_issues_count": 0, + "open_merge_requests_count": 0, + "subscribed": false +} +``` + +## Update a group label + +Updates an existing group label. At least one parameter is required, to update the group label. + +``` +PUT /groups/:id/labels +``` + +| Attribute | Type | Required | Description | +| ------------- | ------- | -------- | ---------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | +| `name` | string | yes | The name of the label | +| `new_name` | string | no | The new name of the label | +| `color` | string | no | The color of the label given in 6-digit hex notation with leading '#' sign (e.g. #FFAABB) or one of the [CSS color names](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#Color_keywords) | +| `description` | string | no | The description of the label | + +```bash +curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" --header "Content-Type: application/json" --data '{"name": "Feature Proposal", "new_name": "Feature Idea" }' https://gitlab.example.com/api/v4/groups/5/labels +``` + +Example response: + +```json +{ + "id": 9, + "name": "Feature Idea", + "color": "#FFA500", + "description": "Describes new ideas", + "open_issues_count": 0, + "closed_issues_count": 0, + "open_merge_requests_count": 0, + "subscribed": false +} +``` + +## Delete a group label + +Deletes a group label with a given name. + +``` +DELETE /groups/:id/labels +``` + +| Attribute | Type | Required | Description | +| --------- | ------- | -------- | --------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | +| `name` | string | yes | The name of the label | + +```bash +curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/groups/5/labels?name=bug +``` + +## Subscribe to a group label + +Subscribes the authenticated user to a group label to receive notifications. If +the user is already subscribed to the label, the status code `304` is returned. + +``` +POST /groups/:id/labels/:label_id/subscribe +``` + +| Attribute | Type | Required | Description | +| ---------- | ----------------- | -------- | ------------------------------------ | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | +| `label_id` | integer or string | yes | The ID or title of a group's label | + +```bash +curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/groups/5/labels/9/subscribe +``` + +Example response: + +```json +{ + "id": 9, + "name": "Feature Idea", + "color": "#FFA500", + "description": "Describes new ideas", + "open_issues_count": 0, + "closed_issues_count": 0, + "open_merge_requests_count": 0, + "subscribed": true +} +``` + +## Unsubscribe from a group label + +Unsubscribes the authenticated user from a group label to not receive +notifications from it. If the user is not subscribed to the label, the status +code `304` is returned. + +``` +POST /groups/:id/labels/:label_id/unsubscribe +``` + +| Attribute | Type | Required | Description | +| ---------- | ----------------- | -------- | ------------------------------------ | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | +| `label_id` | integer or string | yes | The ID or title of a group's label | + +```bash +curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/groups/5/labels/9/unsubscribe +``` + +Example response: + +```json +{ + "id": 9, + "name": "Feature Idea", + "color": "#FFA500", + "description": "Describes new ideas", + "open_issues_count": 0, + "closed_issues_count": 0, + "open_merge_requests_count": 0, + "subscribed": false +} +``` diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index 802ff1d1df9..d58cd45538d 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -994,6 +994,8 @@ Parameters: - `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user - `merge_request_iid` (required) - Internal ID of MR - `merge_commit_message` (optional) - Custom merge commit message +- `squash_commit_message` (optional) - Custom squash commit message +- `squash` (optional) - if `true` the commits will be squashed into a single commit on merge - `should_remove_source_branch` (optional) - if `true` removes the source branch - `merge_when_pipeline_succeeds` (optional) - if `true` the MR is merged when the pipeline succeeds - `sha` (optional) - if present, then this SHA must match the HEAD of the source branch, otherwise the merge will fail diff --git a/doc/api/settings.md b/doc/api/settings.md index c329e3cdf24..2e0a2a09133 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -57,11 +57,13 @@ Example response: "dsa_key_restriction": 0, "ecdsa_key_restriction": 0, "ed25519_key_restriction": 0, + "first_day_of_week": 0, "enforce_terms": true, "terms": "Hello world!", "performance_bar_allowed_group_id": 42, "instance_statistics_visibility_private": false, - "user_show_add_ssh_key_message": true + "user_show_add_ssh_key_message": true, + "local_markdown_version": 0 } ``` @@ -113,11 +115,13 @@ Example response: "dsa_key_restriction": 0, "ecdsa_key_restriction": 0, "ed25519_key_restriction": 0, + "first_day_of_week": 0, "enforce_terms": true, "terms": "Hello world!", "performance_bar_allowed_group_id": 42, "instance_statistics_visibility_private": false, - "user_show_add_ssh_key_message": true + "user_show_add_ssh_key_message": true, + "local_markdown_version": 0 } ``` @@ -157,6 +161,7 @@ are listed in the descriptions of the relevant settings. | `email_author_in_body` | boolean | no | Some email servers do not support overriding the email sender name. Enable this option to include the name of the author of the issue, merge request or comment in the email body instead. | | `enabled_git_access_protocol` | string | no | Enabled protocols for Git access. Allowed values are: `ssh`, `http`, and `nil` to allow both protocols. | | `enforce_terms` | boolean | no | (**If enabled, requires:** `terms`) Enforce application ToS to all users. | +| `first_day_of_week` | integer | no | Start day of the week for calendar views and date pickers. Valid values are `0` (default) for Sunday and `1` for Monday. | | `gitaly_timeout_default` | integer | no | Default Gitaly timeout, in seconds. This timeout is not enforced for git fetch/push operations or Sidekiq jobs. Set to `0` to disable timeouts. | | `gitaly_timeout_fast` | integer | no | Gitaly fast operation timeout, in seconds. Some Gitaly operations are expected to be fast. If they exceed this threshold, there may be a problem with a storage shard and 'failing fast' can help maintain the stability of the GitLab instance. Set to `0` to disable timeouts. | | `gitaly_timeout_medium` | integer | no | Medium Gitaly timeout, in seconds. This should be a value between the Fast and the Default timeout. Set to `0` to disable timeouts. | @@ -235,3 +240,4 @@ are listed in the descriptions of the relevant settings. | `user_oauth_applications` | boolean | no | Allow users to register any application to use GitLab as an OAuth provider. | | `user_show_add_ssh_key_message` | boolean | no | When set to `false` disable the "You won't be able to pull or push project code via SSH" warning shown to users with no uploaded SSH key. | | `version_check_enabled` | boolean | no | Let GitLab inform you when an update is available. | +| `local_markdown_version` | integer | no | Increase this value when any cached markdown should be invalidated. | diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index 97e133a2e2f..32c73c4f398 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -98,7 +98,7 @@ future GitLab releases.** | **CI_PIPELINE_SOURCE** | 10.0 | all | Indicates how the pipeline was triggered. Possible options are: `push`, `web`, `trigger`, `schedule`, `api`, and `pipeline`. For pipelines created before GitLab 9.5, this will show as `unknown` | | **CI_PIPELINE_TRIGGERED** | all | all | The flag to indicate that job was [triggered] | | **CI_PIPELINE_URL** | 11.1 | 0.5 | Pipeline details URL | -| **CI_PROJECT_DIR** | all | all | The full path where the repository is cloned and where the job is run | +| **CI_PROJECT_DIR** | all | all | The full path where the repository is cloned and where the job is run. If the GitLab Runner `builds_dir` parameter is set, this variable is set relative to the value of `builds_dir`. For more information, see [Advanced configuration](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runners-section) for GitLab Runner. | | **CI_PROJECT_ID** | all | all | The unique id of the current project that GitLab CI uses internally | | **CI_PROJECT_NAME** | 8.10 | 0.5 | The project name that is currently being built (actually it is project folder name) | | **CI_PROJECT_NAMESPACE** | 8.10 | 0.5 | The project namespace (username or groupname) that is currently being built | diff --git a/doc/development/README.md b/doc/development/README.md index 05715274a81..d5829e31343 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -123,3 +123,7 @@ description: 'Learn how to contribute to GitLab.' ## Compliance - [Licensing](licensing.md) for ensuring license compliance + +## Go guides + +- [Go Guidelines](go_guide/index.md) diff --git a/doc/development/contributing/issue_workflow.md b/doc/development/contributing/issue_workflow.md index 24feb1378a1..c5344139ab4 100644 --- a/doc/development/contributing/issue_workflow.md +++ b/doc/development/contributing/issue_workflow.md @@ -20,7 +20,7 @@ All labels, their meaning and priority are defined on the If you come across an issue that has none of these, and you're allowed to set labels, you can _always_ add the team and type, and often also the subject. -[milestones-page]: https://gitlab.com/gitlab-org/gitlab-ce/milestones +[milestones-page]: https://gitlab.com/groups/gitlab-org/-/milestones ## Type labels diff --git a/doc/development/contributing/merge_request_workflow.md b/doc/development/contributing/merge_request_workflow.md index 9bef0635e3f..19b6181c9a2 100644 --- a/doc/development/contributing/merge_request_workflow.md +++ b/doc/development/contributing/merge_request_workflow.md @@ -86,6 +86,9 @@ request is as follows: guidelines](../merge_request_performance_guidelines.md). 1. For tests that use Capybara or PhantomJS, see this [article on how to write reliable asynchronous tests](https://robots.thoughtbot.com/write-reliable-asynchronous-integration-tests-with-capybara). +1. If your merge request introduces changes that require additional steps when + installing GitLab from source, add them to `doc/install/installation.md` in + the same merge request. Please keep the change in a single MR **as small as possible**. If you want to contribute a large feature think very hard what the minimum viable change is. diff --git a/doc/development/contributing/style_guides.md b/doc/development/contributing/style_guides.md index 6f1ba5d62a5..0eedef5e14f 100644 --- a/doc/development/contributing/style_guides.md +++ b/doc/development/contributing/style_guides.md @@ -21,6 +21,7 @@ of _prohibited this user from being saved due to the following errors:_ the text should be _sorry, we could not create your account because:_ 1. Code should be written in [US English][us-english] +1. [Go](../go_guide/index.md) This is also the style used by linting tools such as [RuboCop](https://github.com/bbatsov/rubocop) and [Hound CI](https://houndci.com). diff --git a/doc/development/go_guide/index.md b/doc/development/go_guide/index.md new file mode 100644 index 00000000000..cdc806a2d31 --- /dev/null +++ b/doc/development/go_guide/index.md @@ -0,0 +1,216 @@ +# Go standards and style guidelines + +This document describes various guidelines and best practices for GitLab +projects using the [Go language](https://golang.org). + +## Overview + +GitLab is built on top of [Ruby on Rails](https://rubyonrails.org/), but we're +also using Go for projects where it makes sense. Go is a very powerful +language, with many advantages, and is best suited for projects with a lot of +IO (disk/network access), HTTP requests, parallel processing, etc. Since we +have both Ruby on Rails and Go at GitLab, we should evaluate carefully which of +the two is best for the job. + +This page aims to define and organize our Go guidelines, based on our various +experiences. Several projects were started with different standards and they +can still have specifics. They will be described in their respective +`README.md` or `PROCESS.md` files. + +## Code Review + +We follow the common principles of +[Go Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments). + +Reviewers and maintainers should pay attention to: + +- `defer` functions: ensure the presence when needed, and after `err` check. +- Inject dependencies as parameters. +- Void structs when marshalling to JSON (generates `null` instead of `[]`). + +### Security + +Security is our top priority at GitLab. During code reviews, we must take care +of possible security breaches in our code: + +- XSS when using text/template +- CSRF Protection using Gorilla +- Use a Go version without known vulnerabilities +- Don't leak secret tokens +- SQL injections + +Remember to run +[SAST](https://docs.gitlab.com/ee/user/project/merge_requests/sast.html) +**[ULTIMATE]** on your project (or at least the [gosec +analyzer](https://gitlab.com/gitlab-org/security-products/analyzers/gosec)), +and to follow our [Security +requirements](../code_review.md#security-requirements). + +Web servers can take advantages of middlewares like [Secure](https://github.com/unrolled/secure). + +### Finding a reviewer + +Many of our projects are too small to have full-time maintainers. That's why we +have a shared pool of Go reviewers at GitLab. To find a reviewer, use the +[Engineering Projects](https://about.gitlab.com/handbook/engineering/projects/) +page in the handbook. "GitLab Community Edition (CE)" and "GitLab Community +Edition (EE)" both have a "Go" section with its list of reviewers. + +To add yourself to this list, add the following to your profile in the +[team.yml](https://gitlab.com/gitlab-com/www-gitlab-com/blob/master/data/team.yml) +file and ask your manager to review and merge. + +```yaml +projects: + gitlab-ee: reviewer go + gitlab-ce: reviewer go +``` + +## Code style and format + +- Avoid global variables, even in packages. By doing so you will introduce side + effects if the package is included multiple times. +- Use `go fmt` before committing ([Gofmt](https://golang.org/cmd/gofmt/) is a + tool that automatically formats Go source code). + +### Automatic linting + +All Go projects should include these GitLab CI/CD jobs: + +```yaml +go lint: + image: golang:1.11 + script: + - go get -u golang.org/x/lint/golint + - golint -set_exit_status +``` + +Once [recursive includes](https://gitlab.com/gitlab-org/gitlab-ce/issues/56836) +become available, you will be able to share job templates like this +[analyzer](https://gitlab.com/gitlab-org/security-products/ci-templates/raw/master/includes-dev/analyzer.yml). + +## Dependencies + +Dependencies should be kept to the minimum. The introduction of a new +dependency should be argued in the merge request, as per our [Approval +Guidelines](../code_review.html#approval-guidelines). Both [License +Management](https://docs.gitlab.com/ee/user/project/merge_requests/license_management.html) +**[ULTIMATE]** and [Dependency +Scanning](https://docs.gitlab.com/ee/user/project/merge_requests/dependency_scanning.html) +**[ULTIMATE]** should be activated on all projects to ensure new dependencies +security status and license compatibility. + +### Modules + +Since Go 1.11, a standard dependency system is available behind the name [Go +Modules](https://github.com/golang/go/wiki/Modules). It provides a way to +define and lock dependencies for reproducible builds. It should be used +whenever possible. + +There was a [bug on modules +checksums](https://github.com/golang/go/issues/29278) in Go < v1.11.4, so make +sure to use at least this version to avoid `checksum mismatch` errors. + +### ORM + +We don't use object-relational mapping libraries (ORMs) at GitLab (except +[ActiveRecord](https://guides.rubyonrails.org/active_record_basics.html) in +Ruby on Rails). Projects can be structured with services to avoid them. +[PQ](https://github.com/lib/pq) should be enough to interact with PostgreSQL +databases. + +### Migrations + +In the rare event of managing a hosted database, it's necessary to use a +migration system like ActiveRecord is providing. A simple library like +[Journey](https://github.com/db-journey/journey), designed to be used in +`postgres` containers, can be deployed as long-running pods. New versions will +deploy a new pod, migrating the data automatically. + +## Testing + +We should not use any specific library or framework for testing, as the +[standard library](https://golang.org/pkg/) provides already everything to get +started. For example, some external dependencies might be worth considering in +case we decide to use a specific library or framework: + +- [Testify](https://github.com/stretchr/testify) +- [httpexpect](https://github.com/gavv/httpexpect) + +Use [subtests](https://blog.golang.org/subtests) whenever possible to improve +code readability and test output. + +### Benchmarks + +Programs handling a lot of IO or complex operations should always include +[benchmarks](https://golang.org/pkg/testing/#hdr-Benchmarks), to ensure +performance consistency over time. + +## CLIs + +Every Go program is launched from the command line. +[cli](https://github.com/urfave/cli) is a convenient package to create command +line apps. It should be used whether the project is a daemon or a simple cli +tool. Flags can be mapped to [environment +variables](https://github.com/urfave/cli#values-from-the-environment) directly, +which documents and centralizes at the same time all the possible command line +interactions with the program. Don't use `os.GetEnv`, it hides variables deep +in the code. + +## Daemons + +### Logging + +The usage of a logging library is strongly recommended for daemons. Even though +there is a `log` package in the standard library, we generally use +[logrus](https://github.com/sirupsen/logrus). Its plugin ("hooks") system +makes it a powerful logging library, with the ability to add notifiers and +formatters at the logger level directly. + +### Tracing and Correlation + +[LabKit](https://gitlab.com/gitlab-org/labkit) is a place to keep common +libraries for Go services. Currently it's vendored into two projects: +Workhorse and Gitaly, and it exports two main (but related) pieces of +functionality: + +- [`gitlab.com/gitlab-org/labkit/correlation`](https://gitlab.com/gitlab-org/labkit/tree/master/correlation): + for propagating and extracting correlation ids between services. +- [`gitlab.com/gitlab-org/labkit/tracing`](https://gitlab.com/gitlab-org/labkit/tree/master/tracing): + for instrumenting Go libraries for distributed tracing. + +This gives us a thin abstraction over underlying implementations that is +consistent across Workhorse, Gitaly, and, in future, other Go servers. For +example, in the case of `gitlab.com/gitlab-org/labkit/tracing` we can switch +from using Opentracing directly to using Zipkin or Gokit's own tracing wrapper +without changes to the application code, while still keeping the same +consistent configuration mechanism (i.e. the `GITLAB_TRACING` environment +variable). + +### Context + +Since daemons are long-running applications, they should have mechanisms to +manage cancellations, and avoid unnecessary resources consumption (which could +lead to DDOS vulnerabilities). [Go +Context](https://github.com/golang/go/wiki/CodeReviewComments#contexts) should +be used in functions that can block and passed as the first parameter. + +## Dockerfiles + +Every project should have a `Dockerfile` at the root of their repository, to +build and run the project. Since Go program are static binaries, they should +not require any external dependency, and shells in the final image are useless. +We encourage [Multistage +builds](https://docs.docker.com/develop/develop-images/multistage-build/): + +- They let the user build the project with the right Go version and + dependencies. +- They generate a small, self-contained image, derived from `Scratch`. + +Generated docker images should have the program at their `Entrypoint` to create +portable commands. That way, anyone can run the image, and without parameters +it will display its help message (if `cli` has been used). + +--- + +[Return to Development documentation](../README.md). diff --git a/doc/install/installation.md b/doc/install/installation.md index 1f65e3415d1..a8064ae046e 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -139,8 +139,8 @@ Then select 'Internet Site' and press enter to confirm the hostname. The Ruby interpreter is required to run GitLab. -**Note:** The current supported Ruby (MRI) version is 2.3.x. GitLab 9.0 dropped -support for Ruby 2.1.x. +**Note:** The current supported Ruby (MRI) version is 2.5.x. GitLab 11.6 + dropped support for Ruby 2.4.x. The use of Ruby version managers such as [RVM], [rbenv] or [chruby] with GitLab in production, frequently leads to hard to diagnose problems. For example, @@ -345,11 +345,15 @@ cd /home/git ```sh # Clone GitLab repository -sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 11-7-stable gitlab +sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b X-Y-stable gitlab ``` +Make sure to replace `X-Y-stable` with the stable branch that matches the +version you want to install. For example, if you want to install 11.8 you would +use the branch name `11-8-stable`. + CAUTION: **Caution:** -You can change `11-7-stable` to `master` if you want the *bleeding edge* version, but never install `master` on a production server! +You can change `X-Y-stable` to `master` if you want the *bleeding edge* version, but never install `master` on a production server! ### Configure It @@ -691,6 +695,11 @@ sudo nginx -t You should receive `syntax is okay` and `test is successful` messages. If you receive errors check your `gitlab` or `gitlab-ssl` Nginx config file for typos, etc. as indicated in the error message given. +NOTE: **Note:** +Verify that the installed version is greater than 1.12.1 by running `nginx -v`. If it's lower, you may receive the error below: +`nginx: [emerg] unknown "start$temp=[filtered]$rest" variable +nginx: configuration file /etc/nginx/nginx.conf test failed` + ### Restart ```sh diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md index 325de50cab0..463bdd59282 100644 --- a/doc/topics/autodevops/index.md +++ b/doc/topics/autodevops/index.md @@ -126,14 +126,22 @@ Auto Deploy, and Auto Monitoring will be silently skipped. ## Auto DevOps base domain +NOTE: **Note** +`AUTO_DEVOPS_DOMAIN` environment variable is deprecated and +[is scheduled to be removed](https://gitlab.com/gitlab-org/gitlab-ce/issues/56959) in GitLab 12.0. + The Auto DevOps base domain is required if you want to make use of [Auto Review Apps](#auto-review-apps) and [Auto Deploy](#auto-deploy). It can be defined -in three places: +in any of the following places: -- either under the project's CI/CD settings while [enabling Auto DevOps](#enabling-auto-devops) +- either under the cluster's settings, whether for [projects](../../user/project/clusters/index.md#base-domain) or [groups](../../user/group/clusters/index.md#base-domain) - or in instance-wide settings in the **admin area > Settings** under the "Continuous Integration and Delivery" section -- or at the project as a variable: `AUTO_DEVOPS_DOMAIN` (required if you want to use [multiple clusters](#using-multiple-kubernetes-clusters)) -- or at the group level as a variable: `AUTO_DEVOPS_DOMAIN` +- or at the project level as a variable: `KUBE_INGRESS_BASE_DOMAIN` +- or at the group level as a variable: `KUBE_INGRESS_BASE_DOMAIN`. + +NOTE: **Note** +The Auto DevOps base domain variable (`KUBE_INGRESS_BASE_DOMAIN`) follows the same order of precedence +as other environment [variables](../../ci/variables/README.md#priority-of-variables). A wildcard DNS A record matching the base domain(s) is required, for example, given a base domain of `example.com`, you'd need a DNS entry like: @@ -170,13 +178,13 @@ In the [Auto DevOps template](https://gitlab.com/gitlab-org/gitlab-ce/blob/maste Those environments are tied to jobs that use [Auto Deploy](#auto-deploy), so except for the environment scope, they would also need to have a different domain they would be deployed to. This is why you need to define a separate -`AUTO_DEVOPS_DOMAIN` variable for all the above +`KUBE_INGRESS_BASE_DOMAIN` variable for all the above [based on the environment](../../ci/variables/README.md#limiting-environment-scopes-of-variables). The following table is an example of how the three different clusters would be configured. -| Cluster name | Cluster environment scope | `AUTO_DEVOPS_DOMAIN` variable value | Variable environment scope | Notes | +| Cluster name | Cluster environment scope | `KUBE_INGRESS_BASE_DOMAIN` variable value | Variable environment scope | Notes | | ------------ | -------------- | ----------------------------- | ------------- | ------ | | review | `review/*` | `review.example.com` | `review/*` | The review cluster which will run all [Review Apps](../../ci/review_apps/index.md). `*` is a wildcard, which means it will be used by every environment name starting with `review/`. | | staging | `staging` | `staging.example.com` | `staging` | (Optional) The staging cluster which will run the deployments of the staging environments. You need to [enable it first](#deploy-policy-for-staging-and-production-environments). | @@ -190,14 +198,11 @@ To add a different cluster for each environment: ![Auto DevOps multiple clusters](img/autodevops_multiple_clusters.png) 1. After the clusters are created, navigate to each one and install Helm Tiller - and Ingress. + and Ingress. Wait for the Ingress IP address to be assigned. 1. Make sure you have [configured your DNS](#auto-devops-base-domain) with the specified Auto DevOps domains. -1. Navigate to your project's **Settings > CI/CD > Environment variables** and add - the `AUTO_DEVOPS_DOMAIN` variables with their respective environment - scope. - - ![Auto DevOps domain variables](img/autodevops_domain_variables.png) +1. Navigate to each cluster's page, through **Operations > Kubernetes**, + and add the domain based on its Ingress IP address. Now that all is configured, you can test your setup by creating a merge request and verifying that your app is deployed as a review app in the Kubernetes @@ -205,10 +210,9 @@ cluster with the `review/*` environment scope. Similarly, you can check the other environments. NOTE: **Note:** -Auto DevOps is not supported for a group with multiple clusters, as it -is not possible to set `AUTO_DEVOPS_DOMAIN` per environment on the group -level. This will be resolved in the future with the [following issue]( -https://gitlab.com/gitlab-org/gitlab-ce/issues/52363). +From GitLab 11.8, `KUBE_INGRESS_BASE_DOMAIN` replaces `AUTO_DEVOPS_DOMAIN`. +`AUTO_DEVOPS_DOMAIN` [is scheduled to be removed](https://gitlab.com/gitlab-org/gitlab-ce/issues/56959) +in GitLab 12.0. ## Enabling/Disabling Auto DevOps @@ -681,7 +685,7 @@ also be customized, and you can easily use a [custom buildpack](#custom-buildpac | **Variable** | **Description** | | ------------ | --------------- | -| `AUTO_DEVOPS_DOMAIN` | The [Auto DevOps domain](#auto-devops-domain); by default set automatically by the [Auto DevOps setting](#enabling-auto-devops). | +| `AUTO_DEVOPS_DOMAIN` | The [Auto DevOps domain](#auto-devops-domain). By default, set automatically by the [Auto DevOps setting](#enabling-auto-devops). This variable is deprecated and [is scheduled to be removed](https://gitlab.com/gitlab-org/gitlab-ce/issues/56959) in GitLab 12.0. Use `KUBE_INGRESS_BASE_DOMAIN` instead. | | `AUTO_DEVOPS_CHART` | The Helm Chart used to deploy your apps; defaults to the one [provided by GitLab](https://gitlab.com/charts/auto-deploy-app). | | `AUTO_DEVOPS_CHART_REPOSITORY` | The Helm Chart repository used to search for charts; defaults to `https://charts.gitlab.io`. | | `REPLICAS` | The number of replicas to deploy; defaults to 1. | @@ -711,6 +715,7 @@ also be customized, and you can easily use a [custom buildpack](#custom-buildpac | `DAST_DISABLED` | From GitLab 11.0, this variable can be used to disable the `dast` job. If the variable is present, the job will not be created. | | `PERFORMANCE_DISABLED` | From GitLab 11.0, this variable can be used to disable the `performance` job. If the variable is present, the job will not be created. | | `K8S_SECRET_*` | From GitLab 11.7, any variable prefixed with [`K8S_SECRET_`](#application-secret-variables) will be made available by Auto DevOps as environment variables to the deployed application. | +| `KUBE_INGRESS_BASE_DOMAIN` | From GitLab 11.8, this variable can be used to set a domain per cluster. See [cluster domains](../../user/project/clusters/index.md#base-domain) for more information. | TIP: **Tip:** Set up the replica variables using a diff --git a/doc/user/group/clusters/index.md b/doc/user/group/clusters/index.md index 9f9b2da23e1..9fc50741407 100644 --- a/doc/user/group/clusters/index.md +++ b/doc/user/group/clusters/index.md @@ -59,11 +59,16 @@ Add another cluster similar to the first one and make sure to [set an environment scope](#environment-scopes) that will differentiate the new cluster from the rest. -NOTE: **Note:** -Auto DevOps is not supported for a group with multiple clusters, as it -is not possible to set `AUTO_DEVOPS_DOMAIN` per environment on the group -level. This will be resolved in the future with the [following issue]( -https://gitlab.com/gitlab-org/gitlab-ce/issues/52363). +## Base domain + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/24580) in GitLab 11.8. + +Domains at the cluster level permit support for multiple domains +per [multiple Kubernetes clusters](#multiple-kubernetes-clusters-premium). When specifying a domain, +this will be automatically set as an environment variable (`KUBE_INGRESS_BASE_DOMAIN`) during +the [Auto DevOps](../../../topics/autodevops/index.md) stages. + +The domain should have a wildcard DNS configured to the Ingress IP address. ## Environment scopes **[PREMIUM]** diff --git a/doc/user/profile/preferences.md b/doc/user/profile/preferences.md index eb2d731343e..363d3db8db1 100644 --- a/doc/user/profile/preferences.md +++ b/doc/user/profile/preferences.md @@ -87,3 +87,11 @@ You can choose between 3 options: - Files and Readme (default) - Readme - Activity + +## Localization + +### First day of the week + +The first day of the week can be customised for calendar views and date pickers. + +You can choose **Sunday** or **Monday** as the first day of the week. If you select **System Default**, the system-wide default setting will be used. diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md index bb815695cb1..85a4af24dc5 100644 --- a/doc/user/project/clusters/index.md +++ b/doc/user/project/clusters/index.md @@ -172,6 +172,17 @@ functionalities needed to successfully build and deploy a containerized application. Bear in mind that the same credentials are used for all the applications running on the cluster. +## Base domain + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/24580) in GitLab 11.8. + +Domains at the cluster level permit support for multiple domains +per [multiple Kubernetes clusters](#multiple-kubernetes-clusters-premium). When specifying a domain, +this will be automatically set as an environment variable (`KUBE_INGRESS_BASE_DOMAIN`) during +the [Auto DevOps](../../../topics/autodevops/index.md) stages. + +The domain should have a wildcard DNS configured to the Ingress IP address. + ## Access controls When creating a cluster in GitLab, you will be asked if you would like to create an @@ -254,6 +265,12 @@ install it manually. ## Installing applications +NOTE: **Note:** +Before starting the installation of applications, make sure that time is synchronized +between your GitLab server and your Kubernetes cluster. Otherwise, installation could fail +and you may get errors like `Error: remote error: tls: bad certificate` +in the `stdout` of pods created by GitLab in your Kubernetes cluster. + GitLab provides a one-click install for various applications which can be added directly to your configured cluster. Those applications are needed for [Review Apps](../../../ci/review_apps/index.md) and @@ -449,6 +466,7 @@ GitLab CI/CD build environment. | `KUBE_CA_PEM_FILE` | Path to a file containing PEM data. Only present if a custom CA bundle was specified. | | `KUBE_CA_PEM` | (**deprecated**) Raw PEM data. Only if a custom CA bundle was specified. | | `KUBECONFIG` | Path to a file containing `kubeconfig` for this deployment. CA bundle would be embedded if specified. This config also embeds the same token defined in `KUBE_TOKEN` so you likely will only need this variable. This variable name is also automatically picked up by `kubectl` so you won't actually need to reference it explicitly if using `kubectl`. | +| `KUBE_INGRESS_BASE_DOMAIN` | From GitLab 11.8, this variable can be used to set a domain per cluster. See [cluster domains](#base-domain) for more information. | NOTE: **NOTE:** Prior to GitLab 11.5, `KUBE_TOKEN` was the Kubernetes token of the main diff --git a/doc/user/project/merge_requests/img/squash_mr_message.png b/doc/user/project/merge_requests/img/squash_mr_message.png Binary files differnew file mode 100644 index 00000000000..8734cab29aa --- /dev/null +++ b/doc/user/project/merge_requests/img/squash_mr_message.png diff --git a/doc/user/project/merge_requests/squash_and_merge.md b/doc/user/project/merge_requests/squash_and_merge.md index 34cba867e2c..4ff8ec3a7e6 100644 --- a/doc/user/project/merge_requests/squash_and_merge.md +++ b/doc/user/project/merge_requests/squash_and_merge.md @@ -23,11 +23,14 @@ The squashed commit's commit message will be either: - Taken from the first multi-line commit message in the merge. - The merge request's title if no multi-line commit message is found. -Note that the squashed commit is still followed by a merge commit, -as the merge method for this example repository uses a merge commit. -Squashing also works with the fast-forward merge strategy, see -[squashing and fast-forward merge](#squash-and-fast-forward-merge) for more -details. +It can be customized before merging a merge request. + +![A squash commit message editor](img/squash_mr_message.png) + +NOTE: **Note:** +The squashed commit in this example is followed by a merge commit, as the merge method for this example repository uses a merge commit. + +Squashing also works with the fast-forward merge strategy, see [squashing and fast-forward merge](#squash-and-fast-forward-merge) for more details. ## Use cases @@ -60,7 +63,7 @@ This can then be overridden at the time of accepting the merge request: The squashed commit has the following metadata: -- Message: the message of the squash commit. +- Message: the message of the squash commit, or a customized message. - Author: the author of the merge request. - Committer: the user who initiated the squash. diff --git a/doc/user/project/pages/getting_started_part_three.md b/doc/user/project/pages/getting_started_part_three.md index 68dd3330d7a..b2da1c85c62 100644 --- a/doc/user/project/pages/getting_started_part_three.md +++ b/doc/user/project/pages/getting_started_part_three.md @@ -79,11 +79,14 @@ running on your instance). ![DNS A record pointing to GitLab.com Pages server](img/dns_add_new_a_record_example_updated_2018.png) -NOTE: **Note:** -Note that if you use your root domain for your GitLab Pages website **only**, and if -your domain registrar supports this feature, you can add a DNS apex `CNAME` -record instead of an `A` record. The main advantage of doing so is that when GitLab Pages -IP on GitLab.com changes for whatever reason, you don't need to update your `A` record. +CAUTION: **Caution:** +Note that if you use your root domain for your GitLab Pages website +**only**, and if your domain registrar supports this feature, you can +add a DNS apex `CNAME` record instead of an `A` record. The main +advantage of doing so is that when GitLab Pages IP on GitLab.com +changes for whatever reason, you don't need to update your `A` record. +There may be a few exceptions, but **this method is not recommended** +as it most likely won't work if you set an `MX` record for your root domain. #### DNS CNAME record @@ -114,14 +117,16 @@ co-exist, so you need to place the TXT record in a special subdomain of its own. #### TL;DR -If the domain has multiple uses (e.g., you host email on it as well): +For root domains (`domain.com`), set a DNS `A` record and verify your +domain's ownership with a TXT record: | From | DNS Record | To | | ---- | ---------- | -- | | domain.com | A | 35.185.44.232 | | domain.com | TXT | gitlab-pages-verification-code=00112233445566778899aabbccddeeff | -If the domain is dedicated to GitLab Pages use and no other services run on it: +For subdomains (`subdomain.domain.com`), set a DNS `CNAME` record and +verify your domain's ownership with a TXT record: | From | DNS Record | To | | ---- | ---------- | -- | diff --git a/doc/workflow/repository_mirroring.md b/doc/workflow/repository_mirroring.md index 1213474b7d8..8a2f4e1b40e 100644 --- a/doc/workflow/repository_mirroring.md +++ b/doc/workflow/repository_mirroring.md @@ -88,6 +88,14 @@ The mirrored repository will be listed. For example, `https://*****:*****@github The repository will push soon. To force a push, click the appropriate button. +## Setting up a push mirror to another GitLab instance with 2FA activated + +1. On the destination GitLab instance, create a [personal access token](../user/profile/personal_access_tokens.md) with `API` scope. +1. On the source GitLab instance: + 1. Fill in the **Git repository URL** field using this format: `https://oauth2@<destination host>/<your_gitlab_group_or_name>/<your_gitlab_project>.git`. + 1. Fill in **Password** field with the GitLab personal access token created on the destination GitLab instance. + 1. Click the **Mirror repository** button. + ## Pulling from a remote repository **[STARTER]** > [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/51) in GitLab Enterprise Edition 8.2. diff --git a/lib/api/api.rb b/lib/api/api.rb index 9cbfc0e35ff..4dd1b459554 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -109,6 +109,7 @@ module API mount ::API::Features mount ::API::Files mount ::API::GroupBoards + mount ::API::GroupLabels mount ::API::GroupMilestones mount ::API::Groups mount ::API::GroupVariables diff --git a/lib/api/entities.rb b/lib/api/entities.rb index a1f0efa3c68..beb8ce349b4 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -1019,12 +1019,17 @@ module API label.open_merge_requests_count(options[:current_user]) end - expose :priority do |label, options| - label.priority(options[:project]) + expose :subscribed do |label, options| + label.subscribed?(options[:current_user], options[:parent]) end + end - expose :subscribed do |label, options| - label.subscribed?(options[:current_user], options[:project]) + class GroupLabel < Label + end + + class ProjectLabel < Label + expose :priority do |label, options| + label.priority(options[:parent]) end end diff --git a/lib/api/group_labels.rb b/lib/api/group_labels.rb new file mode 100644 index 00000000000..0dbc5f45a68 --- /dev/null +++ b/lib/api/group_labels.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module API + class GroupLabels < Grape::API + include PaginationParams + helpers ::API::Helpers::LabelHelpers + + before { authenticate! } + + params do + requires :id, type: String, desc: 'The ID of a group' + end + resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + desc 'Get all labels of the group' do + detail 'This feature was added in GitLab 11.8' + success Entities::GroupLabel + end + params do + use :pagination + end + get ':id/labels' do + get_labels(user_group, Entities::GroupLabel) + end + + desc 'Create a new label' do + detail 'This feature was added in GitLab 11.8' + success Entities::GroupLabel + end + params do + use :label_create_params + end + post ':id/labels' do + create_label(user_group, Entities::GroupLabel) + end + + desc 'Update an existing label. At least one optional parameter is required.' do + detail 'This feature was added in GitLab 11.8' + success Entities::GroupLabel + end + params do + requires :name, type: String, desc: 'The name of the label to be updated' + optional :new_name, type: String, desc: 'The new name of the label' + optional :color, type: String, desc: "The new color of the label given in 6-digit hex notation with leading '#' sign (e.g. #FFAABB) or one of the allowed CSS color names" + optional :description, type: String, desc: 'The new description of label' + at_least_one_of :new_name, :color, :description + end + put ':id/labels' do + update_label(user_group, Entities::GroupLabel) + end + + desc 'Delete an existing label' do + detail 'This feature was added in GitLab 11.8' + success Entities::GroupLabel + end + params do + requires :name, type: String, desc: 'The name of the label to be deleted' + end + delete ':id/labels' do + delete_label(user_group) + end + end + end +end diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index e3d0b981065..2eb7b04711a 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -84,8 +84,8 @@ module API page || not_found!('Wiki Page') end - def available_labels_for(label_parent) - search_params = { include_ancestor_groups: true } + def available_labels_for(label_parent, include_ancestor_groups: true) + search_params = { include_ancestor_groups: include_ancestor_groups } if label_parent.is_a?(Project) search_params[:project_id] = label_parent.id @@ -170,13 +170,6 @@ module API end end - def find_project_label(id) - labels = available_labels_for(user_project) - label = labels.find_by_id(id) || labels.find_by_title(id) - - label || not_found!('Label') - end - # rubocop: disable CodeReuse/ActiveRecord def find_project_issue(iid) IssuesFinder.new(current_user, project_id: user_project.id).find_by!(iid: iid) diff --git a/lib/api/helpers/label_helpers.rb b/lib/api/helpers/label_helpers.rb new file mode 100644 index 00000000000..c11e7d614ab --- /dev/null +++ b/lib/api/helpers/label_helpers.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module API + module Helpers + module LabelHelpers + extend Grape::API::Helpers + + params :label_create_params do + requires :name, type: String, desc: 'The name of the label to be created' + requires :color, type: String, desc: "The color of the label given in 6-digit hex notation with leading '#' sign (e.g. #FFAABB) or one of the allowed CSS color names" + optional :description, type: String, desc: 'The description of label to be created' + end + + def find_label(parent, id, include_ancestor_groups: true) + labels = available_labels_for(parent, include_ancestor_groups: include_ancestor_groups) + label = labels.find_by_id(id) || labels.find_by_title(id) + + label || not_found!('Label') + end + + def get_labels(parent, entity) + present paginate(available_labels_for(parent)), with: entity, current_user: current_user, parent: parent + end + + def create_label(parent, entity) + authorize! :admin_label, parent + + label = available_labels_for(parent).find_by_title(params[:name]) + conflict!('Label already exists') if label + + priority = params.delete(:priority) + label_params = declared_params(include_missing: false) + + label = + if parent.is_a?(Project) + ::Labels::CreateService.new(label_params).execute(project: parent) + else + ::Labels::CreateService.new(label_params).execute(group: parent) + end + + if label.persisted? + if parent.is_a?(Project) + label.prioritize!(parent, priority) if priority + end + + present label, with: entity, current_user: current_user, parent: parent + else + render_validation_error!(label) + end + end + + def update_label(parent, entity) + authorize! :admin_label, parent + + label = find_label(parent, params[:name], include_ancestor_groups: false) + update_priority = params.key?(:priority) + priority = params.delete(:priority) + + label = ::Labels::UpdateService.new(declared_params(include_missing: false)).execute(label) + render_validation_error!(label) unless label.valid? + + if parent.is_a?(Project) && update_priority + if priority.nil? + label.unprioritize!(parent) + else + label.prioritize!(parent, priority) + end + end + + present label, with: entity, current_user: current_user, parent: parent + end + + def delete_label(parent) + authorize! :admin_label, parent + + label = find_label(parent, params[:name], include_ancestor_groups: false) + + destroy_conditionally!(label) + end + end + end +end diff --git a/lib/api/labels.rb b/lib/api/labels.rb index d5eb2b94669..d729d3ee625 100644 --- a/lib/api/labels.rb +++ b/lib/api/labels.rb @@ -3,6 +3,7 @@ module API class Labels < Grape::API include PaginationParams + helpers ::API::Helpers::LabelHelpers before { authenticate! } @@ -11,62 +12,28 @@ module API end resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Get all labels of the project' do - success Entities::Label + success Entities::ProjectLabel end params do use :pagination end get ':id/labels' do - present paginate(available_labels_for(user_project)), with: Entities::Label, current_user: current_user, project: user_project + get_labels(user_project, Entities::ProjectLabel) end desc 'Create a new label' do - success Entities::Label + success Entities::ProjectLabel end params do - requires :name, type: String, desc: 'The name of the label to be created' - requires :color, type: String, desc: "The color of the label given in 6-digit hex notation with leading '#' sign (e.g. #FFAABB) or one of the allowed CSS color names" - optional :description, type: String, desc: 'The description of label to be created' + use :label_create_params optional :priority, type: Integer, desc: 'The priority of the label', allow_blank: true end - # rubocop: disable CodeReuse/ActiveRecord post ':id/labels' do - authorize! :admin_label, user_project - - label = available_labels_for(user_project).find_by(title: params[:name]) - conflict!('Label already exists') if label - - priority = params.delete(:priority) - label = ::Labels::CreateService.new(declared_params(include_missing: false)).execute(project: user_project) - - if label.valid? - label.prioritize!(user_project, priority) if priority - present label, with: Entities::Label, current_user: current_user, project: user_project - else - render_validation_error!(label) - end - end - # rubocop: enable CodeReuse/ActiveRecord - - desc 'Delete an existing label' do - success Entities::Label - end - params do - requires :name, type: String, desc: 'The name of the label to be deleted' - end - # rubocop: disable CodeReuse/ActiveRecord - delete ':id/labels' do - authorize! :admin_label, user_project - - label = user_project.labels.find_by(title: params[:name]) - not_found!('Label') unless label - - destroy_conditionally!(label) + create_label(user_project, Entities::ProjectLabel) end - # rubocop: enable CodeReuse/ActiveRecord desc 'Update an existing label. At least one optional parameter is required.' do - success Entities::Label + success Entities::ProjectLabel end params do requires :name, type: String, desc: 'The name of the label to be updated' @@ -76,33 +43,19 @@ module API optional :priority, type: Integer, desc: 'The priority of the label', allow_blank: true at_least_one_of :new_name, :color, :description, :priority end - # rubocop: disable CodeReuse/ActiveRecord put ':id/labels' do - authorize! :admin_label, user_project - - label = user_project.labels.find_by(title: params[:name]) - not_found!('Label not found') unless label - - update_priority = params.key?(:priority) - priority = params.delete(:priority) - label_params = declared_params(include_missing: false) - # Rename new name to the actual label attribute name - label_params[:name] = label_params.delete(:new_name) if label_params.key?(:new_name) - - label = ::Labels::UpdateService.new(label_params).execute(label) - render_validation_error!(label) unless label.valid? - - if update_priority - if priority.nil? - label.unprioritize!(user_project) - else - label.prioritize!(user_project, priority) - end - end + update_label(user_project, Entities::ProjectLabel) + end - present label, with: Entities::Label, current_user: current_user, project: user_project + desc 'Delete an existing label' do + success Entities::ProjectLabel + end + params do + requires :name, type: String, desc: 'The name of the label to be deleted' + end + delete ':id/labels' do + delete_label(user_project) end - # rubocop: enable CodeReuse/ActiveRecord end end end diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index 4179aaa93a0..df46b4446ff 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -343,6 +343,7 @@ module API end params do optional :merge_commit_message, type: String, desc: 'Custom merge commit message' + optional :squash_commit_message, type: String, desc: 'Custom squash commit message' optional :should_remove_source_branch, type: Boolean, desc: 'When true, the source branch will be deleted if possible' optional :merge_when_pipeline_succeeds, type: Boolean, @@ -370,6 +371,7 @@ module API merge_params = { commit_message: params[:merge_commit_message], + squash_commit_message: params[:squash_commit_message], should_remove_source_branch: params[:should_remove_source_branch] } diff --git a/lib/api/settings.rb b/lib/api/settings.rb index 95371961398..b16faffe335 100644 --- a/lib/api/settings.rb +++ b/lib/api/settings.rb @@ -121,6 +121,7 @@ module API optional :terminal_max_session_time, type: Integer, desc: 'Maximum time for web terminal websocket connection (in seconds). Set to 0 for unlimited time.' optional :usage_ping_enabled, type: Boolean, desc: 'Every week GitLab will report license usage back to GitLab, Inc.' optional :instance_statistics_visibility_private, type: Boolean, desc: 'When set to `true` Instance statistics will only be available to admins' + optional :local_markdown_version, type: Integer, desc: "Local markdown version, increase this value when any cached markdown should be invalidated" ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type| optional :"#{type}_key_restriction", diff --git a/lib/api/subscriptions.rb b/lib/api/subscriptions.rb index 74ad3c35a61..dfb54446ddf 100644 --- a/lib/api/subscriptions.rb +++ b/lib/api/subscriptions.rb @@ -2,51 +2,88 @@ module API class Subscriptions < Grape::API + helpers ::API::Helpers::LabelHelpers + before { authenticate! } - subscribable_types = { - 'merge_requests' => proc { |id| find_merge_request_with_access(id, :update_merge_request) }, - 'issues' => proc { |id| find_project_issue(id) }, - 'labels' => proc { |id| find_project_label(id) } - } + subscribables = [ + { + type: 'merge_requests', + entity: Entities::MergeRequest, + source: Project, + finder: ->(id) { find_merge_request_with_access(id, :update_merge_request) } + }, + { + type: 'issues', + entity: Entities::Issue, + source: Project, + finder: ->(id) { find_project_issue(id) } + }, + { + type: 'labels', + entity: Entities::ProjectLabel, + source: Project, + finder: ->(id) { find_label(user_project, id) } + }, + { + type: 'labels', + entity: Entities::GroupLabel, + source: Group, + finder: ->(id) { find_label(user_group, id) } + } + ] - params do - requires :id, type: String, desc: 'The ID of a project' - requires :subscribable_id, type: String, desc: 'The ID of a resource' - end - resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do - subscribable_types.each do |type, finder| - type_singularized = type.singularize - entity_class = Entities.const_get(type_singularized.camelcase) + subscribables.each do |subscribable| + source_type = subscribable[:source].name.underscore + params do + requires :id, type: String, desc: "The #{source_type} ID" + requires :subscribable_id, type: String, desc: 'The ID of a resource' + end + resource source_type.pluralize, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Subscribe to a resource' do - success entity_class + success subscribable[:entity] end - post ":id/#{type}/:subscribable_id/subscribe" do - resource = instance_exec(params[:subscribable_id], &finder) + post ":id/#{subscribable[:type]}/:subscribable_id/subscribe" do + parent = parent_resource(source_type) + resource = instance_exec(params[:subscribable_id], &subscribable[:finder]) - if resource.subscribed?(current_user, user_project) + if resource.subscribed?(current_user, parent) not_modified! else - resource.subscribe(current_user, user_project) - present resource, with: entity_class, current_user: current_user, project: user_project + resource.subscribe(current_user, parent) + present resource, with: subscribable[:entity], current_user: current_user, project: parent, parent: parent end end desc 'Unsubscribe from a resource' do - success entity_class + success subscribable[:entity] end - post ":id/#{type}/:subscribable_id/unsubscribe" do - resource = instance_exec(params[:subscribable_id], &finder) + post ":id/#{subscribable[:type]}/:subscribable_id/unsubscribe" do + parent = parent_resource(source_type) + resource = instance_exec(params[:subscribable_id], &subscribable[:finder]) - if !resource.subscribed?(current_user, user_project) + if !resource.subscribed?(current_user, parent) not_modified! else - resource.unsubscribe(current_user, user_project) - present resource, with: entity_class, current_user: current_user, project: user_project + resource.unsubscribe(current_user, parent) + present resource, with: subscribable[:entity], current_user: current_user, project: parent, parent: parent end end end end + + private + + helpers do + def parent_resource(source_type) + case source_type + when 'project' + user_project + else + nil + end + end + end end end diff --git a/lib/backup/repository.rb b/lib/backup/repository.rb index 184c7418e75..22ed1d8e7b4 100644 --- a/lib/backup/repository.rb +++ b/lib/backup/repository.rb @@ -93,7 +93,7 @@ module Backup progress.puts "Error: #{e}".color(:red) end else - restore_repo_success = gitlab_shell.create_repository(project.repository_storage, project.disk_path) + restore_repo_success = gitlab_shell.create_project_repository(project) end if restore_repo_success diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index 7aa02009aa0..b2ef04d23d7 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -12,6 +12,9 @@ module Gitlab # Scopes used for OpenID Connect OPENID_SCOPES = [:openid].freeze + # OpenID Connect profile scopes + PROFILE_SCOPES = [:profile, :email].freeze + # Default scopes for OAuth applications that don't define their own DEFAULT_SCOPES = [:api].freeze @@ -284,7 +287,7 @@ module Gitlab # Other available scopes def optional_scopes - available_scopes + OPENID_SCOPES - DEFAULT_SCOPES + available_scopes + OPENID_SCOPES + PROFILE_SCOPES - DEFAULT_SCOPES end def registry_scopes diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index eaead41a720..75a3f17f549 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -65,9 +65,9 @@ module Gitlab def import_wiki return if project.wiki.repository_exists? - disk_path = project.wiki.disk_path - import_url = project.import_url.sub(/\.git\z/, ".git/wiki") - gitlab_shell.import_repository(project.repository_storage, disk_path, import_url) + wiki = WikiFormatter.new(project) + + gitlab_shell.import_wiki_repository(project, wiki) rescue StandardError => e errors << { type: :wiki, errors: e.message } end diff --git a/lib/gitlab/bitbucket_import/wiki_formatter.rb b/lib/gitlab/bitbucket_import/wiki_formatter.rb new file mode 100644 index 00000000000..b8ff43b777b --- /dev/null +++ b/lib/gitlab/bitbucket_import/wiki_formatter.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Gitlab + module BitbucketImport + class WikiFormatter + attr_reader :project + + def initialize(project) + @project = project + end + + def disk_path + project.wiki.disk_path + end + + def full_path + project.wiki.full_path + end + + def import_url + project.import_url.sub(/\.git\z/, ".git/wiki") + end + end + end +end diff --git a/lib/gitlab/ci/templates/Android-Fastlane.gitlab-ci.yml b/lib/gitlab/ci/templates/Android-Fastlane.gitlab-ci.yml new file mode 100644 index 00000000000..9c534b2b8e7 --- /dev/null +++ b/lib/gitlab/ci/templates/Android-Fastlane.gitlab-ci.yml @@ -0,0 +1,121 @@ +# Read more about how to use this script on this blog post https://about.gitlab.com/2019/01/28/android-publishing-with-gitlab-and-fastlane/ +# You will also need to configure your build.gradle, Dockerfile, and fastlane configuration to make this work. +# If you are looking for a simpler template that does not publish, see the Android template. + +stages: + - environment + - build + - test + - internal + - alpha + - beta + - production + + +.updateContainerJob: + image: docker:stable + stage: environment + services: + - docker:dind + script: + - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY + - docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG || true + - docker build --cache-from $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG -t $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG . + - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG + +updateContainer: + extends: .updateContainerJob + only: + changes: + - Dockerfile + +ensureContainer: + extends: .updateContainerJob + allow_failure: true + before_script: + - "mkdir -p ~/.docker && echo '{\"experimental\": \"enabled\"}' > ~/.docker/config.json" + - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY + # Skip update container `script` if the container already exists + # via https://gitlab.com/gitlab-org/gitlab-ce/issues/26866#note_97609397 -> https://stackoverflow.com/a/52077071/796832 + - docker manifest inspect $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG > /dev/null && exit || true + + +.build_job: + image: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG + stage: build + before_script: + # We store this binary file in a variable as hex with this command: `xxd -p android-app.jks` + # Then we convert the hex back to a binary file + - echo "$signing_jks_file_hex" | xxd -r -p - > android-signing-keystore.jks + - "export VERSION_CODE=$CI_PIPELINE_IID && echo $VERSION_CODE" + - "export VERSION_SHA=`echo ${CI_COMMIT_SHA:0:8}` && echo $VERSION_SHA" + after_script: + - rm -f android-signing-keystore.jks || true + artifacts: + paths: + - app/build/outputs + +buildDebug: + extends: .build_job + script: + - bundle exec fastlane buildDebug + +buildRelease: + extends: .build_job + script: + - bundle exec fastlane buildRelease + environment: + name: production + +testDebug: + image: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG + stage: test + dependencies: + - buildDebug + script: + - bundle exec fastlane test + +publishInternal: + image: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG + stage: internal + dependencies: + - buildRelease + when: manual + before_script: + - echo $google_play_service_account_api_key_json > ~/google_play_api_key.json + after_script: + - rm ~/google_play_api_key.json + script: + - bundle exec fastlane internal + +.promote_job: + image: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG + when: manual + dependencies: [] + before_script: + - echo $google_play_service_account_api_key_json > ~/google_play_api_key.json + after_script: + - rm ~/google_play_api_key.json + +promoteAlpha: + extends: .promote_job + stage: alpha + script: + - bundle exec fastlane promote_internal_to_alpha + +promoteBeta: + extends: .promote_job + stage: beta + script: + - bundle exec fastlane promote_alpha_to_beta + +promoteProduction: + extends: .promote_job + stage: production + # We only allow production promotion on `master` because + # it has its own production scoped secret variables + only: + - master + script: + - bundle exec fastlane promote_beta_to_production +
\ No newline at end of file diff --git a/lib/gitlab/ci/templates/Android.gitlab-ci.yml b/lib/gitlab/ci/templates/Android.gitlab-ci.yml index 6e138639b71..c169e3f7686 100644 --- a/lib/gitlab/ci/templates/Android.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Android.gitlab-ci.yml @@ -1,4 +1,6 @@ # Read more about this script on this blog post https://about.gitlab.com/2018/10/24/setting-up-gitlab-ci-for-android-projects/, by Jason Lenny +# If you are interested in using Android with FastLane for publishing take a look at the Android-Fastlane template. + image: openjdk:8-jdk variables: diff --git a/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml b/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml index 75a5bf142d2..e369d26f22f 100644 --- a/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml @@ -21,8 +21,8 @@ # # In order to deploy, you must have a Kubernetes cluster configured either # via a project integration, or via group/project variables. -# AUTO_DEVOPS_DOMAIN must also be set as a variable at the group or project -# level, or manually added below. +# KUBE_INGRESS_BASE_DOMAIN must also be set on the cluster settings, +# as a variable at the group or project level, or manually added below. # # Continuous deployment to production is enabled by default. # If you want to deploy to staging first, set STAGING_ENABLED environment variable. @@ -41,8 +41,8 @@ image: alpine:latest variables: - # AUTO_DEVOPS_DOMAIN is the application deployment domain and should be set as a variable at the group or project level. - # AUTO_DEVOPS_DOMAIN: domain.example.com + # KUBE_INGRESS_BASE_DOMAIN is the application deployment domain and should be set as a variable at the group or project level. + # KUBE_INGRESS_BASE_DOMAIN: domain.example.com POSTGRES_USER: user POSTGRES_PASSWORD: testing-password @@ -251,7 +251,7 @@ review: - persist_environment_url environment: name: review/$CI_COMMIT_REF_NAME - url: http://$CI_PROJECT_PATH_SLUG-$CI_ENVIRONMENT_SLUG.$AUTO_DEVOPS_DOMAIN + url: http://$CI_PROJECT_PATH_SLUG-$CI_ENVIRONMENT_SLUG.$KUBE_INGRESS_BASE_DOMAIN on_stop: stop_review artifacts: paths: [environment_url.txt] @@ -306,7 +306,7 @@ staging: - deploy environment: name: staging - url: http://$CI_PROJECT_PATH_SLUG-staging.$AUTO_DEVOPS_DOMAIN + url: http://$CI_PROJECT_PATH_SLUG-staging.$KUBE_INGRESS_BASE_DOMAIN only: refs: - master @@ -330,7 +330,7 @@ canary: - deploy canary environment: name: production - url: http://$CI_PROJECT_PATH_SLUG.$AUTO_DEVOPS_DOMAIN + url: http://$CI_PROJECT_PATH_SLUG.$KUBE_INGRESS_BASE_DOMAIN when: manual only: refs: @@ -354,7 +354,7 @@ canary: - persist_environment_url environment: name: production - url: http://$CI_PROJECT_PATH_SLUG.$AUTO_DEVOPS_DOMAIN + url: http://$CI_PROJECT_PATH_SLUG.$KUBE_INGRESS_BASE_DOMAIN artifacts: paths: [environment_url.txt] @@ -403,7 +403,7 @@ production_manual: - persist_environment_url environment: name: production - url: http://$CI_PROJECT_PATH_SLUG.$AUTO_DEVOPS_DOMAIN + url: http://$CI_PROJECT_PATH_SLUG.$KUBE_INGRESS_BASE_DOMAIN artifacts: paths: [environment_url.txt] @@ -689,7 +689,7 @@ rollout 100%: --set application.database_url="$DATABASE_URL" \ --set application.secretName="$APPLICATION_SECRET_NAME" \ --set application.secretChecksum="$APPLICATION_SECRET_CHECKSUM" \ - --set service.commonName="le.$AUTO_DEVOPS_DOMAIN" \ + --set service.commonName="le.$KUBE_INGRESS_BASE_DOMAIN" \ --set service.url="$CI_ENVIRONMENT_URL" \ --set service.additionalHosts="$additional_hosts" \ --set replicaCount="$replicas" \ @@ -725,7 +725,7 @@ rollout 100%: --set application.database_url="$DATABASE_URL" \ --set application.secretName="$APPLICATION_SECRET_NAME" \ --set application.secretChecksum="$APPLICATION_SECRET_CHECKSUM" \ - --set service.commonName="le.$AUTO_DEVOPS_DOMAIN" \ + --set service.commonName="le.$KUBE_INGRESS_BASE_DOMAIN" \ --set service.url="$CI_ENVIRONMENT_URL" \ --set service.additionalHosts="$additional_hosts" \ --set replicaCount="$replicas" \ @@ -823,11 +823,24 @@ rollout 100%: kubectl describe namespace "$KUBE_NAMESPACE" || kubectl create namespace "$KUBE_NAMESPACE" } + + # Function to ensure backwards compatibility with AUTO_DEVOPS_DOMAIN + function ensure_kube_ingress_base_domain() { + if [ -z ${KUBE_INGRESS_BASE_DOMAIN+x} ]; then + export KUBE_INGRESS_BASE_DOMAIN=$AUTO_DEVOPS_DOMAIN + fi + } + function check_kube_domain() { - if [ -z ${AUTO_DEVOPS_DOMAIN+x} ]; then - echo "In order to deploy or use Review Apps, AUTO_DEVOPS_DOMAIN variable must be set" - echo "You can do it in Auto DevOps project settings or defining a variable at group or project level" + ensure_kube_ingress_base_domain + + if [ -z ${KUBE_INGRESS_BASE_DOMAIN+x} ]; then + echo "In order to deploy or use Review Apps," + echo "AUTO_DEVOPS_DOMAIN or KUBE_INGRESS_BASE_DOMAIN variables must be set" + echo "From 11.8, you can set KUBE_INGRESS_BASE_DOMAIN in cluster settings" + echo "or by defining a variable at group or project level." echo "You can also manually add it in .gitlab-ci.yml" + echo "AUTO_DEVOPS_DOMAIN support will be dropped on 12.0" false else true diff --git a/lib/gitlab/git/object_pool.rb b/lib/gitlab/git/object_pool.rb index 1c6242b444a..e93ca3e11f8 100644 --- a/lib/gitlab/git/object_pool.rb +++ b/lib/gitlab/git/object_pool.rb @@ -10,12 +10,13 @@ module Gitlab delegate :exists?, :size, to: :repository delegate :unlink_repository, :delete, to: :object_pool_service - attr_reader :storage, :relative_path, :source_repository + attr_reader :storage, :relative_path, :source_repository, :gl_project_path - def initialize(storage, relative_path, source_repository) + def initialize(storage, relative_path, source_repository, gl_project_path) @storage = storage @relative_path = relative_path @source_repository = source_repository + @gl_project_path = gl_project_path end def create @@ -31,12 +32,12 @@ module Gitlab end def to_gitaly_repository - Gitlab::GitalyClient::Util.repository(storage, relative_path, GL_REPOSITORY) + Gitlab::GitalyClient::Util.repository(storage, relative_path, GL_REPOSITORY, gl_project_path) end # Allows for reusing other RPCs by 'tricking' Gitaly to think its a repository def repository - @repository ||= Gitlab::Git::Repository.new(storage, relative_path, GL_REPOSITORY) + @repository ||= Gitlab::Git::Repository.new(storage, relative_path, GL_REPOSITORY, gl_project_path) end private diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 786c90f9272..54bbd531398 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -67,7 +67,7 @@ module Gitlab # Relative path of repo attr_reader :relative_path - attr_reader :storage, :gl_repository, :relative_path + attr_reader :storage, :gl_repository, :relative_path, :gl_project_path # This remote name has to be stable for all types of repositories that # can join an object pool. If it's structure ever changes, a migration @@ -78,10 +78,11 @@ module Gitlab # This initializer method is only used on the client side (gitlab-ce). # Gitaly-ruby uses a different initializer. - def initialize(storage, relative_path, gl_repository) + def initialize(storage, relative_path, gl_repository, gl_project_path) @storage = storage @relative_path = relative_path @gl_repository = gl_repository + @gl_project_path = gl_project_path @name = @relative_path.split("/").last end @@ -872,7 +873,7 @@ module Gitlab end def gitaly_repository - Gitlab::GitalyClient::Util.repository(@storage, @relative_path, @gl_repository) + Gitlab::GitalyClient::Util.repository(@storage, @relative_path, @gl_repository, @gl_project_path) end def gitaly_ref_client diff --git a/lib/gitlab/gitaly_client/repository_service.rb b/lib/gitlab/gitaly_client/repository_service.rb index 8a1abfbf874..a7e20d9429e 100644 --- a/lib/gitlab/gitaly_client/repository_service.rb +++ b/lib/gitlab/gitaly_client/repository_service.rb @@ -324,13 +324,40 @@ module Gitlab GitalyClient.call(@storage, :repository_service, :search_files_by_name, request, timeout: GitalyClient.fast_timeout).flat_map(&:files) end - def search_files_by_content(ref, query) - request = Gitaly::SearchFilesByContentRequest.new(repository: @gitaly_repo, ref: ref, query: query) - GitalyClient.call(@storage, :repository_service, :search_files_by_content, request).flat_map(&:matches) + def search_files_by_content(ref, query, chunked_response: true) + request = Gitaly::SearchFilesByContentRequest.new(repository: @gitaly_repo, ref: ref, query: query, chunked_response: chunked_response) + response = GitalyClient.call(@storage, :repository_service, :search_files_by_content, request) + + search_results_from_response(response) end private + def search_results_from_response(gitaly_response) + matches = [] + current_match = +"" + + gitaly_response.each do |message| + next if message.nil? + + # Old client will ignore :chunked_response flag + # and return messages with `matches` key. + # This code path will be removed post 12.0 release + if message.matches.any? + matches += message.matches + else + current_match << message.match_data + + if message.end_of_match + matches << current_match + current_match = +"" + end + end + end + + matches + end + def gitaly_fetch_stream_to_file(save_path, rpc_name, request_class, timeout) request = request_class.new(repository: @gitaly_repo) response = GitalyClient.call( diff --git a/lib/gitlab/gitaly_client/util.rb b/lib/gitlab/gitaly_client/util.rb index dce5d6a8ad0..899921f76e4 100644 --- a/lib/gitlab/gitaly_client/util.rb +++ b/lib/gitlab/gitaly_client/util.rb @@ -4,7 +4,7 @@ module Gitlab module GitalyClient module Util class << self - def repository(repository_storage, relative_path, gl_repository) + def repository(repository_storage, relative_path, gl_repository, gl_project_path) git_env = Gitlab::Git::HookEnv.all(gl_repository) git_object_directory = git_env['GIT_OBJECT_DIRECTORY_RELATIVE'].presence git_alternate_object_directories = Array.wrap(git_env['GIT_ALTERNATE_OBJECT_DIRECTORIES_RELATIVE']) @@ -14,14 +14,16 @@ module Gitlab relative_path: relative_path, gl_repository: gl_repository.to_s, git_object_directory: git_object_directory.to_s, - git_alternate_object_directories: git_alternate_object_directories + git_alternate_object_directories: git_alternate_object_directories, + gl_project_path: gl_project_path ) end def git_repository(gitaly_repository) Gitlab::Git::Repository.new(gitaly_repository.storage_name, gitaly_repository.relative_path, - gitaly_repository.gl_repository) + gitaly_repository.gl_repository, + gitaly_repository.gl_project_path) end end end diff --git a/lib/gitlab/github_import/importer/repository_importer.rb b/lib/gitlab/github_import/importer/repository_importer.rb index bc3ea9e9226..e2dfb00dcc5 100644 --- a/lib/gitlab/github_import/importer/repository_importer.rb +++ b/lib/gitlab/github_import/importer/repository_importer.rb @@ -6,11 +6,12 @@ module Gitlab class RepositoryImporter include Gitlab::ShellAdapter - attr_reader :project, :client + attr_reader :project, :client, :wiki_formatter def initialize(project, client) @project = project @client = client + @wiki_formatter = ::Gitlab::LegacyGithubImport::WikiFormatter.new(project) end # Returns true if we should import the wiki for the project. @@ -57,9 +58,7 @@ module Gitlab end def import_wiki_repository - wiki_path = "#{project.disk_path}.wiki" - - gitlab_shell.import_repository(project.repository_storage, wiki_path, wiki_url) + gitlab_shell.import_wiki_repository(project, wiki_formatter) true rescue Gitlab::Shell::Error => e @@ -72,7 +71,7 @@ module Gitlab end def wiki_url - project.import_url.sub(/\.git\z/, '.wiki.git') + wiki_formatter.import_url end def update_clone_time diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index 9b1794eec91..3235d3ccc4e 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -24,6 +24,7 @@ module Gitlab gon.emoji_sprites_css_path = ActionController::Base.helpers.stylesheet_path('emoji_sprites') gon.test_env = Rails.env.test? gon.suggested_label_colors = LabelsHelper.suggested_colors + gon.first_day_of_week = current_user&.first_day_of_week || Gitlab::CurrentSettings.first_day_of_week if current_user gon.current_user_id = current_user.id diff --git a/lib/gitlab/gpg/commit.rb b/lib/gitlab/gpg/commit.rb index 4fbb87385c3..5ff415b6126 100644 --- a/lib/gitlab/gpg/commit.rb +++ b/lib/gitlab/gpg/commit.rb @@ -88,9 +88,10 @@ module Gitlab def create_cached_signature! using_keychain do |gpg_key| - signature = GpgSignature.new(attributes(gpg_key)) - signature.save! unless Gitlab::Database.read_only? - signature + attributes = attributes(gpg_key) + break GpgSignature.new(attributes) if Gitlab::Database.read_only? + + GpgSignature.safe_create!(attributes) end end diff --git a/lib/gitlab/legacy_github_import/importer.rb b/lib/gitlab/legacy_github_import/importer.rb index c526d31a591..f3323c98af2 100644 --- a/lib/gitlab/legacy_github_import/importer.rb +++ b/lib/gitlab/legacy_github_import/importer.rb @@ -267,7 +267,7 @@ module Gitlab def import_wiki unless project.wiki.repository_exists? wiki = WikiFormatter.new(project) - gitlab_shell.import_repository(project.repository_storage, wiki.disk_path, wiki.import_url) + gitlab_shell.import_wiki_repository(project, wiki) end rescue Gitlab::Shell::Error => e # GitHub error message when the wiki repo has not been created, diff --git a/lib/gitlab/legacy_github_import/wiki_formatter.rb b/lib/gitlab/legacy_github_import/wiki_formatter.rb index ea52be5ee0f..cf1e21ad1e1 100644 --- a/lib/gitlab/legacy_github_import/wiki_formatter.rb +++ b/lib/gitlab/legacy_github_import/wiki_formatter.rb @@ -13,6 +13,10 @@ module Gitlab project.wiki.disk_path end + def full_path + project.wiki.full_path + end + def import_url project.import_url.sub(/\.git\z/, ".wiki.git") end diff --git a/lib/gitlab/metrics/samplers/unicorn_sampler.rb b/lib/gitlab/metrics/samplers/unicorn_sampler.rb index 4c4ec026823..4c5b849cc51 100644 --- a/lib/gitlab/metrics/samplers/unicorn_sampler.rb +++ b/lib/gitlab/metrics/samplers/unicorn_sampler.rb @@ -23,13 +23,13 @@ module Gitlab def sample Raindrops::Linux.tcp_listener_stats(tcp_listeners).each do |addr, stats| - unicorn_active_connections.set({ type: 'tcp', address: addr }, stats.active) - unicorn_queued_connections.set({ type: 'tcp', address: addr }, stats.queued) + unicorn_active_connections.set({ socket_type: 'tcp', socket_address: addr }, stats.active) + unicorn_queued_connections.set({ socket_type: 'tcp', socket_address: addr }, stats.queued) end Raindrops::Linux.unix_listener_stats(unix_listeners).each do |addr, stats| - unicorn_active_connections.set({ type: 'unix', address: addr }, stats.active) - unicorn_queued_connections.set({ type: 'unix', address: addr }, stats.queued) + unicorn_active_connections.set({ socket_type: 'unix', socket_address: addr }, stats.active) + unicorn_queued_connections.set({ socket_type: 'unix', socket_address: addr }, stats.queued) end end diff --git a/lib/gitlab/shell.rb b/lib/gitlab/shell.rb index bdf21cf3134..1153e69d3de 100644 --- a/lib/gitlab/shell.rb +++ b/lib/gitlab/shell.rb @@ -64,27 +64,48 @@ module Gitlab end end + # Convenience methods for initializing a new repository with a Project model. + def create_project_repository(project) + create_repository(project.repository_storage, project.disk_path, project.full_path) + end + + def create_wiki_repository(project) + create_repository(project.repository_storage, project.wiki.disk_path, project.wiki.full_path) + end + # Init new repository # # storage - the shard key - # name - project disk path + # disk_path - project disk path + # gl_project_path - project name # # Ex. - # create_repository("default", "gitlab/gitlab-ci") + # create_repository("default", "path/to/gitlab-ci", "gitlab/gitlab-ci") # - def create_repository(storage, name) - relative_path = name.dup + def create_repository(storage, disk_path, gl_project_path) + relative_path = disk_path.dup relative_path << '.git' unless relative_path.end_with?('.git') - repository = Gitlab::Git::Repository.new(storage, relative_path, '') + # During creation of a repository, gl_repository may not be known + # because that depends on a yet-to-be assigned project ID in the + # database (e.g. project-1234), so for now it is blank. + repository = Gitlab::Git::Repository.new(storage, relative_path, '', gl_project_path) wrapped_gitaly_errors { repository.gitaly_repository_client.create_repository } true rescue => err # Once the Rugged codes gets removes this can be improved - Rails.logger.error("Failed to add repository #{storage}/#{name}: #{err}") + Rails.logger.error("Failed to add repository #{storage}/#{disk_path}: #{err}") false end + def import_wiki_repository(project, wiki_formatter) + import_repository(project.repository_storage, wiki_formatter.disk_path, wiki_formatter.import_url, project.wiki.full_path) + end + + def import_project_repository(project) + import_repository(project.repository_storage, project.disk_path, project.import_url, project.full_path) + end + # Import repository # # storage - project's storage name @@ -94,13 +115,13 @@ module Gitlab # Ex. # import_repository("nfs-file06", "gitlab/gitlab-ci", "https://gitlab.com/gitlab-org/gitlab-test.git") # - def import_repository(storage, name, url) + def import_repository(storage, name, url, gl_project_path) if url.start_with?('.', '/') raise Error.new("don't use disk paths with import_repository: #{url.inspect}") end relative_path = "#{name}.git" - cmd = GitalyGitlabProjects.new(storage, relative_path) + cmd = GitalyGitlabProjects.new(storage, relative_path, gl_project_path) success = cmd.import_project(url, git_timeout) raise Error, cmd.output unless success @@ -125,18 +146,13 @@ module Gitlab end # Fork repository to new path - # forked_from_storage - forked-from project's storage name - # forked_from_disk_path - project disk relative path - # forked_to_storage - forked-to project's storage name - # forked_to_disk_path - forked project disk relative path - # - # Ex. - # fork_repository("nfs-file06", "gitlab/gitlab-ci", "nfs-file07", "new-namespace/gitlab-ci") - def fork_repository(forked_from_storage, forked_from_disk_path, forked_to_storage, forked_to_disk_path) - forked_from_relative_path = "#{forked_from_disk_path}.git" - fork_args = [forked_to_storage, "#{forked_to_disk_path}.git"] + # source_project - forked-from Project + # target_project - forked-to Project + def fork_repository(source_project, target_project) + forked_from_relative_path = "#{source_project.disk_path}.git" + fork_args = [target_project.repository_storage, "#{target_project.disk_path}.git", target_project.full_path] - GitalyGitlabProjects.new(forked_from_storage, forked_from_relative_path).fork_repository(*fork_args) + GitalyGitlabProjects.new(source_project.repository_storage, forked_from_relative_path, source_project.full_path).fork_repository(*fork_args) end # Removes a repository from file system, using rm_diretory which is an alias @@ -397,16 +413,17 @@ module Gitlab end class GitalyGitlabProjects - attr_reader :shard_name, :repository_relative_path, :output + attr_reader :shard_name, :repository_relative_path, :output, :gl_project_path - def initialize(shard_name, repository_relative_path) + def initialize(shard_name, repository_relative_path, gl_project_path) @shard_name = shard_name @repository_relative_path = repository_relative_path @output = '' + @gl_project_path = gl_project_path end def import_project(source, _timeout) - raw_repository = Gitlab::Git::Repository.new(shard_name, repository_relative_path, nil) + raw_repository = Gitlab::Git::Repository.new(shard_name, repository_relative_path, nil, gl_project_path) Gitlab::GitalyClient::RepositoryService.new(raw_repository).import_repository(source) true @@ -415,9 +432,9 @@ module Gitlab false end - def fork_repository(new_shard_name, new_repository_relative_path) - target_repository = Gitlab::Git::Repository.new(new_shard_name, new_repository_relative_path, nil) - raw_repository = Gitlab::Git::Repository.new(shard_name, repository_relative_path, nil) + def fork_repository(new_shard_name, new_repository_relative_path, new_project_name) + target_repository = Gitlab::Git::Repository.new(new_shard_name, new_repository_relative_path, nil, new_project_name) + raw_repository = Gitlab::Git::Repository.new(shard_name, repository_relative_path, nil, gl_project_path) Gitlab::GitalyClient::RepositoryService.new(target_repository).fork_repository(raw_repository) rescue GRPC::BadStatus => e diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9ec590f90d8..937db5b7305 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -22,16 +22,6 @@ msgstr "" msgid " or " msgstr "" -msgid "%d addition" -msgid_plural "%d additions" -msgstr[0] "" -msgstr[1] "" - -msgid "%d changed file" -msgid_plural "%d changed files" -msgstr[0] "" -msgstr[1] "" - msgid "%d commit" msgid_plural "%d commits" msgstr[0] "" @@ -42,10 +32,8 @@ msgid_plural "%d commits behind" msgstr[0] "" msgstr[1] "" -msgid "%d deleted" -msgid_plural "%d deletions" -msgstr[0] "" -msgstr[1] "" +msgid "%d commits" +msgstr "" msgid "%d exporter" msgid_plural "%d exporters" @@ -138,9 +126,6 @@ msgstr "" msgid "%{lock_path} is locked by GitLab User %{lock_user_id}" msgstr "" -msgid "%{nip_domain} can be used as an alternative to a custom domain." -msgstr "" - msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead" msgstr "" @@ -900,15 +885,6 @@ msgstr "" msgid "Auto DevOps, runners and job artifacts" msgstr "" -msgid "Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly." -msgstr "" - -msgid "Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly." -msgstr "" - -msgid "Auto Review Apps and Auto Deploy need a domain name to work correctly." -msgstr "" - msgid "Auto-cancel redundant, pending pipelines" msgstr "" @@ -1281,9 +1257,6 @@ msgstr "" msgid "CICD|Deployment strategy" msgstr "" -msgid "CICD|Deployment strategy needs a domain name to work correctly." -msgstr "" - msgid "CICD|Jobs" msgstr "" @@ -1293,7 +1266,7 @@ msgstr "" msgid "CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found." msgstr "" -msgid "CICD|You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages." +msgid "CICD|You must add a %{kubernetes_cluster_start}Kubernetes cluster integration%{kubernetes_cluster_end} to this project with a domain in order for your deployment strategy to work correctly." msgstr "" msgid "CICD|instance enabled" @@ -1566,6 +1539,12 @@ msgstr "" msgid "Closed (moved)" msgstr "" +msgid "ClusterIntegration| %{custom_domain_start}More information%{custom_domain_end}." +msgstr "" + +msgid "ClusterIntegration| can be used instead of a custom domain." +msgstr "" + msgid "ClusterIntegration| is the default environment scope for this cluster. This means that all jobs, regardless of their environment, will use this cluster. %{environment_scope_start}More information%{environment_scope_end}" msgstr "" @@ -1596,6 +1575,9 @@ msgstr "" msgid "ClusterIntegration|After installing Ingress, you will need to point your wildcard DNS at the generated external IP address in order to view your app after it is deployed. %{ingressHelpLink}" msgstr "" +msgid "ClusterIntegration|Alternatively" +msgstr "" + msgid "ClusterIntegration|An error occured while trying to fetch project zones: %{error}" msgstr "" @@ -1617,6 +1599,9 @@ msgstr "" msgid "ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster." msgstr "" +msgid "ClusterIntegration|Base domain" +msgstr "" + msgid "ClusterIntegration|CA Certificate" msgstr "" @@ -1941,6 +1926,9 @@ msgstr "" msgid "ClusterIntegration|Something went wrong while installing %{title}" msgstr "" +msgid "ClusterIntegration|Specifying a domain will allow you to use Auto Review Apps and Auto Deploy stages for %{auto_devops_start}Auto DevOps%{auto_devops_end}. The domain should have a wildcard DNS configured matching the domain." +msgstr "" + msgid "ClusterIntegration|The IP address is in the process of being assigned. Please check your Kubernetes cluster or Quotas on Google Kubernetes Engine if it takes a long time." msgstr "" @@ -2444,6 +2432,9 @@ msgstr "" msgid "Customize how Google Code email addresses and usernames are imported into GitLab. In the next step, you'll be able to select the projects you want to import." msgstr "" +msgid "Customize language and region related settings." +msgstr "" + msgid "Customize your pipeline configuration, view your pipeline status and coverage report." msgstr "" @@ -2507,6 +2498,12 @@ msgstr "" msgid "Default Branch" msgstr "" +msgid "Default first day of the week" +msgstr "" + +msgid "Default first day of the week in calendars and date pickers." +msgstr "" + msgid "Default: Directly import the Google Code email address or username" msgstr "" @@ -2546,6 +2543,9 @@ msgstr "" msgid "Delete list" msgstr "" +msgid "Delete source branch" +msgstr "" + msgid "Delete this attachment" msgstr "" @@ -3271,6 +3271,9 @@ msgstr "" msgid "Failure" msgstr "" +msgid "Fast-forward merge without a merge commit" +msgstr "" + msgid "Faster as it re-uses the project workspace (falling back to clone if it doesn't exist)" msgstr "" @@ -3283,6 +3286,11 @@ msgstr "" msgid "Fields on this page are now uneditable, you can configure" msgstr "" +msgid "File" +msgid_plural "Files" +msgstr[0] "" +msgstr[1] "" + msgid "File added" msgstr "" @@ -3355,6 +3363,9 @@ msgstr "" msgid "Finished" msgstr "" +msgid "First day of the week" +msgstr "" + msgid "FirstPushedBy|First" msgstr "" @@ -3915,6 +3926,9 @@ msgstr "" msgid "Include a Terms of Service agreement and Privacy Policy that all users must accept." msgstr "" +msgid "Include merge request description" +msgstr "" + msgid "Include the username in the URL if required: <code>https://username@gitlab.company.com/group/project.git</code>." msgstr "" @@ -4297,6 +4311,9 @@ msgstr "" msgid "Loading…" msgstr "" +msgid "Localization" +msgstr "" + msgid "Lock" msgstr "" @@ -4447,9 +4464,18 @@ msgstr "" msgid "Merge Requests" msgstr "" +msgid "Merge commit message" +msgstr "" + msgid "Merge events" msgstr "" +msgid "Merge immediately" +msgstr "" + +msgid "Merge in progress" +msgstr "" + msgid "Merge request" msgstr "" @@ -4459,6 +4485,9 @@ msgstr "" msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others" msgstr "" +msgid "Merge when pipeline succeeds" +msgstr "" + msgid "MergeRequests|Add a reply" msgstr "" @@ -4615,6 +4644,15 @@ msgstr "" msgid "Modal|Close" msgstr "" +msgid "Modify commit messages" +msgstr "" + +msgid "Modify merge commit" +msgstr "" + +msgid "Monday" +msgstr "" + msgid "Monitor your errors by integrating with Sentry" msgstr "" @@ -6605,7 +6643,10 @@ msgstr "" msgid "Snippets" msgstr "" -msgid "Someone edited this issue at the same time you did. The description has been updated and you will need to make your changes again." +msgid "Someone edited this %{issueType} at the same time you did. The description has been updated and you will need to make your changes again." +msgstr "" + +msgid "Someone edited this merge request at the same time you did. Please refresh the page to see changes." msgstr "" msgid "Something went wrong on our end" @@ -6632,6 +6673,9 @@ msgstr "" msgid "Something went wrong while closing the %{issuable}. Please try again later" msgstr "" +msgid "Something went wrong while deleting the source branch. Please try again." +msgstr "" + msgid "Something went wrong while fetching comments. Please try again." msgstr "" @@ -6644,6 +6688,9 @@ msgstr "" msgid "Something went wrong while fetching the registry list." msgstr "" +msgid "Something went wrong while merging this merge request. Please try again." +msgstr "" + msgid "Something went wrong while reopening the %{issuable}. Please try again later" msgstr "" @@ -6788,6 +6835,9 @@ msgstr "" msgid "Specify the following URL during the Runner setup:" msgstr "" +msgid "Squash commit message" +msgstr "" + msgid "Squash commits" msgstr "" @@ -6926,6 +6976,9 @@ msgstr "" msgid "Suggested change" msgstr "" +msgid "Sunday" +msgstr "" + msgid "Support for custom certificates is disabled. Ask your system's administrator to enable it." msgstr "" @@ -6938,6 +6991,9 @@ msgstr "" msgid "System Info" msgstr "" +msgid "System default (%{default})" +msgstr "" + msgid "System metrics (Custom)" msgstr "" @@ -7977,6 +8033,9 @@ msgstr "" msgid "Various email settings." msgstr "" +msgid "Various localization settings." +msgstr "" + msgid "Various settings that affect GitLab performance." msgstr "" @@ -8304,6 +8363,9 @@ msgstr "" msgid "You can only edit files when you are on a branch" msgstr "" +msgid "You can only merge once the items above are resolved" +msgstr "" + msgid "You can resolve the merge conflict using either the Interactive mode, by choosing %{use_ours} or %{use_theirs} buttons, or by editing the files directly. Commit these changes into %{branch_name}" msgstr "" @@ -8598,6 +8660,12 @@ msgstr[1] "" msgid "missing" msgstr "" +msgid "mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}." +msgstr "" + +msgid "mrWidgetCommitsAdded|1 merge commit" +msgstr "" + msgid "mrWidget| Please restore it or use a different %{missingBranchName} branch" msgstr "" diff --git a/qa/qa/page/project/settings/ci_cd.rb b/qa/qa/page/project/settings/ci_cd.rb index 12c2409a5a7..2de39b8ebf5 100644 --- a/qa/qa/page/project/settings/ci_cd.rb +++ b/qa/qa/page/project/settings/ci_cd.rb @@ -13,9 +13,7 @@ module QA # rubocop:disable Naming/FileName view 'app/views/projects/settings/ci_cd/_autodevops_form.html.haml' do element :enable_auto_devops_field, 'check_box :enabled' # rubocop:disable QA/ElementWithPattern - element :domain_field, 'text_field :domain' # rubocop:disable QA/ElementWithPattern element :enable_auto_devops_button, "%strong= s_('CICD|Default to Auto DevOps pipeline')" # rubocop:disable QA/ElementWithPattern - element :domain_input, "%strong= _('Domain')" # rubocop:disable QA/ElementWithPattern element :save_changes_button, "submit _('Save changes')" # rubocop:disable QA/ElementWithPattern end @@ -31,10 +29,9 @@ module QA # rubocop:disable Naming/FileName end end - def enable_auto_devops_with_domain(domain) + def enable_auto_devops expand_section(:autodevops_settings) do check 'Default to Auto DevOps pipeline' - fill_in 'Domain', with: domain click_on 'Save changes' end end diff --git a/qa/qa/resource/kubernetes_cluster.rb b/qa/qa/resource/kubernetes_cluster.rb index d67e5f6da20..986b31da528 100644 --- a/qa/qa/resource/kubernetes_cluster.rb +++ b/qa/qa/resource/kubernetes_cluster.rb @@ -6,12 +6,16 @@ module QA module Resource class KubernetesCluster < Base attr_writer :project, :cluster, - :install_helm_tiller, :install_ingress, :install_prometheus, :install_runner + :install_helm_tiller, :install_ingress, :install_prometheus, :install_runner, :domain attribute :ingress_ip do Page::Project::Operations::Kubernetes::Show.perform(&:ingress_ip) end + attribute :domain do + "#{ingress_ip}.nip.io" + end + def fabricate! @project.visit! diff --git a/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb b/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb index b0ff83db86b..5c8ec465143 100644 --- a/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb +++ b/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb @@ -52,13 +52,13 @@ module QA end kubernetes_cluster.populate(:ingress_ip) - @project.visit! Page::Project::Menu.act { click_ci_cd_settings } Page::Project::Settings::CICD.perform do |p| - p.enable_auto_devops_with_domain( - "#{kubernetes_cluster.ingress_ip}.nip.io") + p.enable_auto_devops end + + kubernetes_cluster.populate(:domain) end after(:all) do diff --git a/spec/controllers/groups/clusters_controller_spec.rb b/spec/controllers/groups/clusters_controller_spec.rb index 0f28499194e..360030102e0 100644 --- a/spec/controllers/groups/clusters_controller_spec.rb +++ b/spec/controllers/groups/clusters_controller_spec.rb @@ -429,12 +429,14 @@ describe Groups::ClustersController do end let(:cluster) { create(:cluster, :provided_by_user, cluster_type: :group_type, groups: [group]) } + let(:domain) { 'test-domain.com' } let(:params) do { cluster: { enabled: false, - name: 'my-new-cluster-name' + name: 'my-new-cluster-name', + base_domain: domain } } end @@ -447,6 +449,20 @@ describe Groups::ClustersController do expect(flash[:notice]).to eq('Kubernetes cluster was successfully updated.') expect(cluster.enabled).to be_falsey expect(cluster.name).to eq('my-new-cluster-name') + expect(cluster.domain).to eq('test-domain.com') + end + + context 'when domain is invalid' do + let(:domain) { 'not-a-valid-domain' } + + it 'should not update cluster attributes' do + go + + cluster.reload + expect(response).to render_template(:show) + expect(cluster.name).not_to eq('my-new-cluster-name') + expect(cluster.domain).not_to eq('test-domain.com') + end end context 'when format is json' do @@ -456,7 +472,8 @@ describe Groups::ClustersController do { cluster: { enabled: false, - name: 'my-new-cluster-name' + name: 'my-new-cluster-name', + domain: domain } } end diff --git a/spec/controllers/profiles/preferences_controller_spec.rb b/spec/controllers/profiles/preferences_controller_spec.rb index 012f016b091..760c0fab130 100644 --- a/spec/controllers/profiles/preferences_controller_spec.rb +++ b/spec/controllers/profiles/preferences_controller_spec.rb @@ -42,7 +42,8 @@ describe Profiles::PreferencesController do prefs = { color_scheme_id: '1', dashboard: 'stars', - theme_id: '2' + theme_id: '2', + first_day_of_week: '1' }.with_indifferent_access expect(user).to receive(:assign_attributes).with(ActionController::Parameters.new(prefs).permit!) diff --git a/spec/controllers/projects/error_tracking_controller_spec.rb b/spec/controllers/projects/error_tracking_controller_spec.rb index 6464398cea1..844c61f1ace 100644 --- a/spec/controllers/projects/error_tracking_controller_spec.rb +++ b/spec/controllers/projects/error_tracking_controller_spec.rb @@ -107,8 +107,11 @@ describe Projects::ErrorTrackingController do let(:http_status) { :no_content } before do - expect(list_issues_service).to receive(:execute) - .and_return(status: :error, message: error_message, http_status: http_status) + expect(list_issues_service).to receive(:execute).and_return( + status: :error, + message: error_message, + http_status: http_status + ) end it 'returns http_status with message' do @@ -122,6 +125,113 @@ describe Projects::ErrorTrackingController do end end + describe 'POST #list_projects' do + context 'with insufficient permissions' do + before do + project.add_guest(user) + end + + it 'returns 404' do + post :list_projects, params: list_projects_params + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'with an anonymous user' do + before do + sign_out(user) + end + + it 'redirects to sign-in page' do + post :list_projects, params: list_projects_params + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'with authorized user' do + let(:list_projects_service) { spy(:list_projects_service) } + let(:sentry_project) { build(:error_tracking_project) } + + let(:permitted_params) do + ActionController::Parameters.new( + list_projects_params[:error_tracking_setting] + ).permit! + end + + before do + allow(ErrorTracking::ListProjectsService) + .to receive(:new).with(project, user, permitted_params) + .and_return(list_projects_service) + end + + context 'service result is successful' do + before do + expect(list_projects_service).to receive(:execute) + .and_return(status: :success, projects: [sentry_project]) + end + + it 'returns a list of projects' do + post :list_projects, params: list_projects_params + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('error_tracking/list_projects') + expect(json_response['projects']).to eq([sentry_project].as_json) + end + end + + context 'service result is erroneous' do + let(:error_message) { 'error message' } + + context 'without http_status' do + before do + expect(list_projects_service).to receive(:execute) + .and_return(status: :error, message: error_message) + end + + it 'returns 400 with message' do + get :list_projects, params: list_projects_params + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']).to eq(error_message) + end + end + + context 'with explicit http_status' do + let(:http_status) { :no_content } + + before do + expect(list_projects_service).to receive(:execute).and_return( + status: :error, + message: error_message, + http_status: http_status + ) + end + + it 'returns http_status with message' do + get :list_projects, params: list_projects_params + + expect(response).to have_gitlab_http_status(http_status) + expect(json_response['message']).to eq(error_message) + end + end + end + end + + private + + def list_projects_params(opts = {}) + project_params( + format: :json, + error_tracking_setting: { + api_host: 'gitlab.com', + token: 'token' + } + ) + end + end + private def project_params(opts = {}) diff --git a/spec/features/clusters/cluster_detail_page_spec.rb b/spec/features/clusters/cluster_detail_page_spec.rb new file mode 100644 index 00000000000..0a9c4bcaf12 --- /dev/null +++ b/spec/features/clusters/cluster_detail_page_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Clusterable > Show page' do + let(:current_user) { create(:user) } + + before do + sign_in(current_user) + end + + shared_examples 'editing domain' do + before do + clusterable.add_maintainer(current_user) + end + + it 'allow the user to set domain' do + visit cluster_path + + within '#cluster-integration' do + fill_in('cluster_base_domain', with: 'test.com') + click_on 'Save changes' + end + + expect(page.status_code).to eq(200) + expect(page).to have_content('Kubernetes cluster was successfully updated.') + end + + context 'when there is a cluster with ingress and external ip' do + before do + cluster.create_application_ingress!(external_ip: '192.168.1.100') + + visit cluster_path + end + + it 'shows help text with the domain as an alternative to custom domain' do + within '#cluster-integration' do + expect(page).to have_content('Alternatively 192.168.1.100.nip.io can be used instead of a custom domain') + end + end + end + + context 'when there is no ingress' do + it 'alternative to custom domain is not shown' do + visit cluster_path + + within '#cluster-integration' do + expect(page).not_to have_content('can be used instead of a custom domain.') + end + end + end + end + + context 'when clusterable is a project' do + it_behaves_like 'editing domain' do + let(:clusterable) { create(:project) } + let(:cluster) { create(:cluster, :provided_by_gcp, :project, projects: [clusterable]) } + let(:cluster_path) { project_cluster_path(clusterable, cluster) } + end + end + + context 'when clusterable is a group' do + it_behaves_like 'editing domain' do + let(:clusterable) { create(:group) } + let(:cluster) { create(:cluster, :provided_by_gcp, :group, groups: [clusterable]) } + let(:cluster_path) { group_cluster_path(clusterable, cluster) } + end + end +end diff --git a/spec/features/markdown/copy_as_gfm_spec.rb b/spec/features/markdown/copy_as_gfm_spec.rb index 16754035076..60ddb02da2c 100644 --- a/spec/features/markdown/copy_as_gfm_spec.rb +++ b/spec/features/markdown/copy_as_gfm_spec.rb @@ -843,6 +843,7 @@ describe 'Copy as GFM', :js do def verify(selector, gfm, target: nil) html = html_for_selector(selector) output_gfm = html_to_gfm(html, 'transformCodeSelection', target: target) + wait_for_requests expect(output_gfm.strip).to eq(gfm.strip) end end @@ -861,6 +862,9 @@ describe 'Copy as GFM', :js do def html_to_gfm(html, transformer = 'transformGFMSelection', target: nil) js = <<~JS (function(html) { + // Setting it off so the import already starts + window.CopyAsGFM.nodeToGFM(document.createElement('div')); + var transformer = window.CopyAsGFM[#{transformer.inspect}]; var node = document.createElement('div'); @@ -875,9 +879,18 @@ describe 'Copy as GFM', :js do node = transformer(node, target); if (!node) return null; - return window.CopyAsGFM.nodeToGFM(node); + + window.gfmCopytestRes = null; + window.CopyAsGFM.nodeToGFM(node) + .then((res) => { + window.gfmCopytestRes = res; + }); })("#{escape_javascript(html)}") JS - page.evaluate_script(js) + page.execute_script(js) + + loop until page.evaluate_script('window.gfmCopytestRes !== null') + + page.evaluate_script('window.gfmCopytestRes') end end diff --git a/spec/features/merge_request/user_accepts_merge_request_spec.rb b/spec/features/merge_request/user_accepts_merge_request_spec.rb index 00ac7c72a11..5fa23dbb998 100644 --- a/spec/features/merge_request/user_accepts_merge_request_spec.rb +++ b/spec/features/merge_request/user_accepts_merge_request_spec.rb @@ -80,8 +80,8 @@ describe 'User accepts a merge request', :js do end it 'accepts a merge request' do - click_button('Modify commit message') - fill_in('Commit message', with: 'wow such merge') + find('.js-mr-widget-commits-count').click + fill_in('merge-message-edit', with: 'wow such merge') click_button('Merge') diff --git a/spec/features/merge_request/user_customizes_merge_commit_message_spec.rb b/spec/features/merge_request/user_customizes_merge_commit_message_spec.rb index 8d2d4279d3c..c6b11fce388 100644 --- a/spec/features/merge_request/user_customizes_merge_commit_message_spec.rb +++ b/spec/features/merge_request/user_customizes_merge_commit_message_spec.rb @@ -13,7 +13,7 @@ describe 'Merge request < User customizes merge commit message', :js do description: "Description\n\nclosing #{issue_1.to_reference}, #{issue_2.to_reference}" ) end - let(:textbox) { page.find(:css, '.js-commit-message', visible: false) } + let(:textbox) { page.find(:css, '#merge-message-edit', visible: false) } let(:default_message) do [ "Merge branch 'feature' into 'master'", @@ -38,16 +38,16 @@ describe 'Merge request < User customizes merge commit message', :js do end it 'toggles commit message between message with description and without description' do - expect(page).not_to have_selector('.js-commit-message') - click_button "Modify commit message" + expect(page).not_to have_selector('#merge-message-edit') + first('.js-mr-widget-commits-count').click expect(textbox).to be_visible expect(textbox.value).to eq(default_message) - click_link "Include description in commit message" + check('Include merge request description') expect(textbox.value).to eq(message_with_description) - click_link "Don't include description in commit message" + uncheck('Include merge request description') expect(textbox.value).to eq(default_message) end diff --git a/spec/features/merge_request/user_resolves_conflicts_spec.rb b/spec/features/merge_request/user_resolves_conflicts_spec.rb index 50c723776a3..16c058ab6bd 100644 --- a/spec/features/merge_request/user_resolves_conflicts_spec.rb +++ b/spec/features/merge_request/user_resolves_conflicts_spec.rb @@ -37,6 +37,8 @@ describe 'Merge request > User resolves conflicts', :js do click_on 'Changes' wait_for_requests + find('.js-toggle-tree-list').click + within find('.diff-file', text: 'files/ruby/popen.rb') do expect(page).to have_selector('.line_content.new', text: "vars = { 'PWD' => path }") expect(page).to have_selector('.line_content.new', text: "options = { chdir: path }") diff --git a/spec/features/merge_request/user_sees_versions_spec.rb b/spec/features/merge_request/user_sees_versions_spec.rb index 63d8decc2d2..aa91ade46ca 100644 --- a/spec/features/merge_request/user_sees_versions_spec.rb +++ b/spec/features/merge_request/user_sees_versions_spec.rb @@ -42,7 +42,7 @@ describe 'Merge request > User sees versions', :js do expect(page).to have_content 'latest version' end - expect(page).to have_content '8 changed files' + expect(page).to have_content '8 Files' end it_behaves_like 'allows commenting', @@ -76,7 +76,7 @@ describe 'Merge request > User sees versions', :js do end it 'shows comments that were last relevant at that version' do - expect(page).to have_content '5 changed files' + expect(page).to have_content '5 Files' position = Gitlab::Diff::Position.new( old_path: ".gitmodules", @@ -120,8 +120,15 @@ describe 'Merge request > User sees versions', :js do diff_id: merge_request_diff3.id, start_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9' ) - expect(page).to have_content '4 changed files' - expect(page).to have_content '15 additions 6 deletions' + expect(page).to have_content '4 Files' + + additions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group svg.ic-file-addition') + .ancestor('.diff-stats-group').text + deletions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group svg.ic-file-deletion') + .ancestor('.diff-stats-group').text + + expect(additions_content).to eq '15' + expect(deletions_content).to eq '6' position = Gitlab::Diff::Position.new( old_path: ".gitmodules", @@ -141,8 +148,14 @@ describe 'Merge request > User sees versions', :js do end it 'show diff between new and old version' do - expect(page).to have_content '4 changed files' - expect(page).to have_content '15 additions 6 deletions' + additions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group svg.ic-file-addition') + .ancestor('.diff-stats-group').text + deletions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group svg.ic-file-deletion') + .ancestor('.diff-stats-group').text + + expect(page).to have_content '4 Files' + expect(additions_content).to eq '15' + expect(deletions_content).to eq '6' end it 'returns to latest version when "Show latest version" button is clicked' do @@ -150,7 +163,7 @@ describe 'Merge request > User sees versions', :js do page.within '.mr-version-dropdown' do expect(page).to have_content 'latest version' end - expect(page).to have_content '8 changed files' + expect(page).to have_content '8 Files' end it_behaves_like 'allows commenting', @@ -176,7 +189,7 @@ describe 'Merge request > User sees versions', :js do find('.btn-default').click click_link 'version 1' end - expect(page).to have_content '0 changed files' + expect(page).to have_content '0 Files' end end @@ -202,7 +215,7 @@ describe 'Merge request > User sees versions', :js do expect(page).to have_content 'version 1' end - expect(page).to have_content '0 changed files' + expect(page).to have_content '0 Files' end end diff --git a/spec/features/projects/settings/pipelines_settings_spec.rb b/spec/features/projects/settings/pipelines_settings_spec.rb index 6f8ec0015ad..4c85abe9971 100644 --- a/spec/features/projects/settings/pipelines_settings_spec.rb +++ b/spec/features/projects/settings/pipelines_settings_spec.rb @@ -98,14 +98,12 @@ describe "Projects > Settings > Pipelines settings" do expect(page).not_to have_content('instance enabled') expect(find_field('project_auto_devops_attributes_enabled')).not_to be_checked check 'Default to Auto DevOps pipeline' - fill_in('project_auto_devops_attributes_domain', with: 'test.com') click_on 'Save changes' end expect(page.status_code).to eq(200) expect(project.auto_devops).to be_present expect(project.auto_devops).to be_enabled - expect(project.auto_devops.domain).to eq('test.com') page.within '#autodevops-settings' do expect(find_field('project_auto_devops_attributes_enabled')).to be_checked @@ -113,29 +111,6 @@ describe "Projects > Settings > Pipelines settings" do end end end - - context 'when there is a cluster with ingress and external_ip' do - before do - cluster = create(:cluster, projects: [project]) - cluster.create_application_ingress!(external_ip: '192.168.1.100') - end - - it 'shows the help text with the nip.io domain as an alternative to custom domain' do - visit project_settings_ci_cd_path(project) - expect(page).to have_content('192.168.1.100.nip.io can be used as an alternative to a custom domain') - end - end - - context 'when there is no ingress' do - before do - create(:cluster, projects: [project]) - end - - it 'alternative to custom domain is not shown' do - visit project_settings_ci_cd_path(project) - expect(page).not_to have_content('can be used as an alternative to a custom domain') - end - end end describe 'runners registration token' do diff --git a/spec/features/snippets/show_spec.rb b/spec/features/snippets/show_spec.rb index eb974c7c7fd..74fdfcf492e 100644 --- a/spec/features/snippets/show_spec.rb +++ b/spec/features/snippets/show_spec.rb @@ -79,14 +79,6 @@ describe 'Snippet', :js do expect(page).not_to have_xpath("//ol//li//ul") end end - - context 'with cached CommonMark html' do - let(:snippet) { create(:personal_snippet, :public, file_name: file_name, content: content, cached_markdown_version: CacheMarkdownField::CACHE_COMMONMARK_VERSION) } - - it 'renders correctly' do - expect(page).not_to have_xpath("//ol//li//ul") - end - end end context 'switching to the simple viewer' do diff --git a/spec/fixtures/api/schemas/public_api/v4/group_labels.json b/spec/fixtures/api/schemas/public_api/v4/group_labels.json new file mode 100644 index 00000000000..f6c327abfdd --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/group_labels.json @@ -0,0 +1,18 @@ +{ + "type": "array", + "items": { + "type": "object", + "properties" : { + "id" : { "type": "integer" }, + "name" : { "type": "string "}, + "color" : { "type": "string "}, + "description" : { "type": "string "}, + "open_issues_count" : { "type": "integer "}, + "closed_issues_count" : { "type": "integer "}, + "open_merge_requests_count" : { "type": "integer "}, + "subscribed" : { "type": "boolean" }, + "priority" : { "type": "null" } + }, + "additionalProperties": false + } +} diff --git a/spec/helpers/auto_devops_helper_spec.rb b/spec/helpers/auto_devops_helper_spec.rb index 75c30dbfe48..223e562238d 100644 --- a/spec/helpers/auto_devops_helper_spec.rb +++ b/spec/helpers/auto_devops_helper_spec.rb @@ -90,39 +90,4 @@ describe AutoDevopsHelper do it { is_expected.to eq(false) } end end - - describe '.auto_devops_warning_message' do - subject { helper.auto_devops_warning_message(project) } - - context 'when the service is missing' do - before do - allow(helper).to receive(:missing_auto_devops_service?).and_return(true) - end - - context 'when the domain is missing' do - before do - allow(helper).to receive(:missing_auto_devops_domain?).and_return(true) - end - - it { is_expected.to match(/Auto Review Apps and Auto Deploy need a domain name and a .* to work correctly./) } - end - - context 'when the domain is not missing' do - before do - allow(helper).to receive(:missing_auto_devops_domain?).and_return(false) - end - - it { is_expected.to match(/Auto Review Apps and Auto Deploy need a .* to work correctly./) } - end - end - - context 'when the domain is missing' do - before do - allow(helper).to receive(:missing_auto_devops_service?).and_return(false) - allow(helper).to receive(:missing_auto_devops_domain?).and_return(true) - end - - it { is_expected.to eq('Auto Review Apps and Auto Deploy need a domain name to work correctly.') } - end - end end diff --git a/spec/helpers/preferences_helper_spec.rb b/spec/helpers/preferences_helper_spec.rb index c112c8ed633..4c395248644 100644 --- a/spec/helpers/preferences_helper_spec.rb +++ b/spec/helpers/preferences_helper_spec.rb @@ -35,6 +35,30 @@ describe PreferencesHelper do end end + describe '#first_day_of_week_choices' do + it 'returns Sunday and Monday as choices' do + expect(helper.first_day_of_week_choices).to eq [ + ['Sunday', 0], + ['Monday', 1] + ] + end + end + + describe '#first_day_of_week_choices_with_default' do + it 'returns choices including system default' do + expect(helper.first_day_of_week_choices_with_default).to eq [ + ['System default (Sunday)', nil], ['Sunday', 0], ['Monday', 1] + ] + end + + it 'returns choices including system default set to Monday' do + stub_application_setting(first_day_of_week: 1) + expect(helper.first_day_of_week_choices_with_default).to eq [ + ['System default (Monday)', nil], ['Sunday', 0], ['Monday', 1] + ] + end + end + describe '#user_application_theme' do context 'with a user' do it "returns user's theme's css_class" do diff --git a/spec/javascripts/behaviors/copy_as_gfm_spec.js b/spec/javascripts/behaviors/copy_as_gfm_spec.js index 6179a02ce16..ca849f75860 100644 --- a/spec/javascripts/behaviors/copy_as_gfm_spec.js +++ b/spec/javascripts/behaviors/copy_as_gfm_spec.js @@ -1,4 +1,4 @@ -import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm'; +import initCopyAsGFM, { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm'; describe('CopyAsGFM', () => { describe('CopyAsGFM.pasteGFM', () => { @@ -79,27 +79,46 @@ describe('CopyAsGFM', () => { return clipboardData; }; + beforeAll(done => { + initCopyAsGFM(); + + // Fake call to nodeToGfm so the import of lazy bundle happened + CopyAsGFM.nodeToGFM(document.createElement('div')) + .then(() => { + done(); + }) + .catch(done.fail); + }); + beforeEach(() => spyOn(clipboardData, 'setData')); describe('list handling', () => { - it('uses correct gfm for unordered lists', () => { + it('uses correct gfm for unordered lists', done => { const selection = stubSelection('<li>List Item1</li><li>List Item2</li>\n', 'UL'); + spyOn(window, 'getSelection').and.returnValue(selection); simulateCopy(); - const expectedGFM = '* List Item1\n\n* List Item2'; + setTimeout(() => { + const expectedGFM = '* List Item1\n\n* List Item2'; - expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM); + expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM); + done(); + }); }); - it('uses correct gfm for ordered lists', () => { + it('uses correct gfm for ordered lists', done => { const selection = stubSelection('<li>List Item1</li><li>List Item2</li>\n', 'OL'); + spyOn(window, 'getSelection').and.returnValue(selection); simulateCopy(); - const expectedGFM = '1. List Item1\n\n1. List Item2'; + setTimeout(() => { + const expectedGFM = '1. List Item1\n\n1. List Item2'; - expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM); + expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM); + done(); + }); }); }); }); diff --git a/spec/javascripts/behaviors/shortcuts/shortcuts_issuable_spec.js b/spec/javascripts/behaviors/shortcuts/shortcuts_issuable_spec.js index fe827bb1e18..4843a0386b5 100644 --- a/spec/javascripts/behaviors/shortcuts/shortcuts_issuable_spec.js +++ b/spec/javascripts/behaviors/shortcuts/shortcuts_issuable_spec.js @@ -3,17 +3,26 @@ */ import $ from 'jquery'; -import initCopyAsGFM from '~/behaviors/markdown/copy_as_gfm'; +import initCopyAsGFM, { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; -initCopyAsGFM(); - const FORM_SELECTOR = '.js-main-target-form .js-vue-comment-form'; describe('ShortcutsIssuable', function() { const fixtureName = 'snippets/show.html.raw'; preloadFixtures(fixtureName); + beforeAll(done => { + initCopyAsGFM(); + + // Fake call to nodeToGfm so the import of lazy bundle happened + CopyAsGFM.nodeToGFM(document.createElement('div')) + .then(() => { + done(); + }) + .catch(done.fail); + }); + beforeEach(() => { loadFixtures(fixtureName); $('body').append( @@ -63,17 +72,22 @@ describe('ShortcutsIssuable', function() { stubSelection('<p>Selected text.</p>'); }); - it('leaves existing input intact', () => { + it('leaves existing input intact', done => { $(FORM_SELECTOR).val('This text was already here.'); expect($(FORM_SELECTOR).val()).toBe('This text was already here.'); ShortcutsIssuable.replyWithSelectedText(true); - expect($(FORM_SELECTOR).val()).toBe('This text was already here.\n\n> Selected text.\n\n'); + setTimeout(() => { + expect($(FORM_SELECTOR).val()).toBe( + 'This text was already here.\n\n> Selected text.\n\n', + ); + done(); + }); }); - it('triggers `input`', () => { + it('triggers `input`', done => { let triggered = false; $(FORM_SELECTOR).on('input', () => { triggered = true; @@ -81,36 +95,48 @@ describe('ShortcutsIssuable', function() { ShortcutsIssuable.replyWithSelectedText(true); - expect(triggered).toBe(true); + setTimeout(() => { + expect(triggered).toBe(true); + done(); + }); }); - it('triggers `focus`', () => { + it('triggers `focus`', done => { const spy = spyOn(document.querySelector(FORM_SELECTOR), 'focus'); ShortcutsIssuable.replyWithSelectedText(true); - expect(spy).toHaveBeenCalled(); + setTimeout(() => { + expect(spy).toHaveBeenCalled(); + done(); + }); }); }); describe('with a one-line selection', () => { - it('quotes the selection', () => { + it('quotes the selection', done => { stubSelection('<p>This text has been selected.</p>'); ShortcutsIssuable.replyWithSelectedText(true); - expect($(FORM_SELECTOR).val()).toBe('> This text has been selected.\n\n'); + setTimeout(() => { + expect($(FORM_SELECTOR).val()).toBe('> This text has been selected.\n\n'); + done(); + }); }); }); describe('with a multi-line selection', () => { - it('quotes the selected lines as a group', () => { + it('quotes the selected lines as a group', done => { stubSelection( '<p>Selected line one.</p>\n<p>Selected line two.</p>\n<p>Selected line three.</p>', ); ShortcutsIssuable.replyWithSelectedText(true); - expect($(FORM_SELECTOR).val()).toBe( - '> Selected line one.\n>\n> Selected line two.\n>\n> Selected line three.\n\n', - ); + setTimeout(() => { + expect($(FORM_SELECTOR).val()).toBe( + '> Selected line one.\n>\n> Selected line two.\n>\n> Selected line three.\n\n', + ); + done(); + }); }); }); @@ -119,17 +145,23 @@ describe('ShortcutsIssuable', function() { stubSelection('<p>Selected text.</p>', true); }); - it('does not add anything to the input', () => { + it('does not add anything to the input', done => { ShortcutsIssuable.replyWithSelectedText(true); - expect($(FORM_SELECTOR).val()).toBe(''); + setTimeout(() => { + expect($(FORM_SELECTOR).val()).toBe(''); + done(); + }); }); - it('triggers `focus`', () => { + it('triggers `focus`', done => { const spy = spyOn(document.querySelector(FORM_SELECTOR), 'focus'); ShortcutsIssuable.replyWithSelectedText(true); - expect(spy).toHaveBeenCalled(); + setTimeout(() => { + expect(spy).toHaveBeenCalled(); + done(); + }); }); }); @@ -138,20 +170,26 @@ describe('ShortcutsIssuable', function() { stubSelection('<div class="md">Selected text.</div><p>Invalid selected text.</p>', true); }); - it('only adds the valid part to the input', () => { + it('only adds the valid part to the input', done => { ShortcutsIssuable.replyWithSelectedText(true); - expect($(FORM_SELECTOR).val()).toBe('> Selected text.\n\n'); + setTimeout(() => { + expect($(FORM_SELECTOR).val()).toBe('> Selected text.\n\n'); + done(); + }); }); - it('triggers `focus`', () => { + it('triggers `focus`', done => { const spy = spyOn(document.querySelector(FORM_SELECTOR), 'focus'); ShortcutsIssuable.replyWithSelectedText(true); - expect(spy).toHaveBeenCalled(); + setTimeout(() => { + expect(spy).toHaveBeenCalled(); + done(); + }); }); - it('triggers `input`', () => { + it('triggers `input`', done => { let triggered = false; $(FORM_SELECTOR).on('input', () => { triggered = true; @@ -159,7 +197,10 @@ describe('ShortcutsIssuable', function() { ShortcutsIssuable.replyWithSelectedText(true); - expect(triggered).toBe(true); + setTimeout(() => { + expect(triggered).toBe(true); + done(); + }); }); }); @@ -183,20 +224,26 @@ describe('ShortcutsIssuable', function() { }); }); - it('adds the quoted selection to the input', () => { + it('adds the quoted selection to the input', done => { ShortcutsIssuable.replyWithSelectedText(true); - expect($(FORM_SELECTOR).val()).toBe('> *Selected text.*\n\n'); + setTimeout(() => { + expect($(FORM_SELECTOR).val()).toBe('> *Selected text.*\n\n'); + done(); + }); }); - it('triggers `focus`', () => { + it('triggers `focus`', done => { const spy = spyOn(document.querySelector(FORM_SELECTOR), 'focus'); ShortcutsIssuable.replyWithSelectedText(true); - expect(spy).toHaveBeenCalled(); + setTimeout(() => { + expect(spy).toHaveBeenCalled(); + done(); + }); }); - it('triggers `input`', () => { + it('triggers `input`', done => { let triggered = false; $(FORM_SELECTOR).on('input', () => { triggered = true; @@ -204,7 +251,10 @@ describe('ShortcutsIssuable', function() { ShortcutsIssuable.replyWithSelectedText(true); - expect(triggered).toBe(true); + setTimeout(() => { + expect(triggered).toBe(true); + done(); + }); }); }); @@ -228,17 +278,23 @@ describe('ShortcutsIssuable', function() { }); }); - it('does not add anything to the input', () => { + it('does not add anything to the input', done => { ShortcutsIssuable.replyWithSelectedText(true); - expect($(FORM_SELECTOR).val()).toBe(''); + setTimeout(() => { + expect($(FORM_SELECTOR).val()).toBe(''); + done(); + }); }); - it('triggers `focus`', () => { + it('triggers `focus`', done => { const spy = spyOn(document.querySelector(FORM_SELECTOR), 'focus'); ShortcutsIssuable.replyWithSelectedText(true); - expect(spy).toHaveBeenCalled(); + setTimeout(() => { + expect(spy).toHaveBeenCalled(); + done(); + }); }); }); }); diff --git a/spec/javascripts/diffs/components/compare_versions_spec.js b/spec/javascripts/diffs/components/compare_versions_spec.js index 2f0385454d7..e886f962d2f 100644 --- a/spec/javascripts/diffs/components/compare_versions_spec.js +++ b/spec/javascripts/diffs/components/compare_versions_spec.js @@ -10,6 +10,10 @@ describe('CompareVersions', () => { const targetBranch = { branchName: 'tmp-wine-dev', versionIndex: -1 }; beforeEach(() => { + store.state.diffs.addedLines = 10; + store.state.diffs.removedLines = 20; + store.state.diffs.diffFiles.push('test'); + vm = createComponentWithStore(Vue.extend(CompareVersionsComponent), store, { mergeRequestDiffs: diffsMockData, mergeRequestDiff: diffsMockData[0], diff --git a/spec/javascripts/diffs/components/diff_file_header_spec.js b/spec/javascripts/diffs/components/diff_file_header_spec.js index b77907ff26f..787a81fd88f 100644 --- a/spec/javascripts/diffs/components/diff_file_header_spec.js +++ b/spec/javascripts/diffs/components/diff_file_header_spec.js @@ -24,6 +24,10 @@ describe('diff_file_header', () => { beforeEach(() => { const diffFile = diffDiscussionMock.diff_file; + + diffFile.added_lines = 2; + diffFile.removed_lines = 1; + props = { diffFile: { ...diffFile }, canCurrentUserFork: false, diff --git a/spec/javascripts/diffs/components/diff_stats_spec.js b/spec/javascripts/diffs/components/diff_stats_spec.js new file mode 100644 index 00000000000..984b3026209 --- /dev/null +++ b/spec/javascripts/diffs/components/diff_stats_spec.js @@ -0,0 +1,33 @@ +import { shallowMount } from '@vue/test-utils'; +import DiffStats from '~/diffs/components/diff_stats.vue'; + +describe('diff_stats', () => { + it('does not render a group if diffFileLengths is not passed in', () => { + const wrapper = shallowMount(DiffStats, { + propsData: { + addedLines: 1, + removedLines: 2, + }, + }); + const groups = wrapper.findAll('.diff-stats-group'); + + expect(groups.length).toBe(2); + }); + + it('shows amount of files changed, lines added and lines removed when passed all props', () => { + const wrapper = shallowMount(DiffStats, { + propsData: { + addedLines: 100, + removedLines: 200, + diffFilesLength: 300, + }, + }); + const additions = wrapper.find('icon-stub[name="file-addition"]').element.parentNode; + const deletions = wrapper.find('icon-stub[name="file-deletion"]').element.parentNode; + const filesChanged = wrapper.find('icon-stub[name="doc-code"]').element.parentNode; + + expect(additions.textContent).toContain('100'); + expect(deletions.textContent).toContain('200'); + expect(filesChanged.textContent).toContain('300'); + }); +}); diff --git a/spec/javascripts/diffs/components/tree_list_spec.js b/spec/javascripts/diffs/components/tree_list_spec.js index c5ef48a81e9..9e556698f34 100644 --- a/spec/javascripts/diffs/components/tree_list_spec.js +++ b/spec/javascripts/diffs/components/tree_list_spec.js @@ -35,12 +35,6 @@ describe('Diffs tree list component', () => { vm.$destroy(); }); - it('renders diff stats', () => { - expect(vm.$el.textContent).toContain('1 changed file'); - expect(vm.$el.textContent).toContain('10 additions'); - expect(vm.$el.textContent).toContain('20 deletions'); - }); - it('renders empty text', () => { expect(vm.$el.textContent).toContain('No files found'); }); diff --git a/spec/javascripts/helpers/vue_test_utils_helper.js b/spec/javascripts/helpers/vue_test_utils_helper.js new file mode 100644 index 00000000000..19e27388eeb --- /dev/null +++ b/spec/javascripts/helpers/vue_test_utils_helper.js @@ -0,0 +1,19 @@ +/* eslint-disable import/prefer-default-export */ + +const vNodeContainsText = (vnode, text) => + (vnode.text && vnode.text.includes(text)) || + (vnode.children && vnode.children.filter(child => vNodeContainsText(child, text)).length); + +/** + * Determines whether a `shallowMount` Wrapper contains text + * within one of it's slots. This will also work on Wrappers + * acquired with `find()`, but only if it's parent Wrapper + * was shallowMounted. + * NOTE: Prefer checking the rendered output of a component + * wherever possible using something like `text()` instead. + * @param {Wrapper} shallowWrapper - Vue test utils wrapper (shallowMounted) + * @param {String} slotName + * @param {String} text + */ +export const shallowWrapperContainsSlotText = (shallowWrapper, slotName, text) => + !!shallowWrapper.vm.$slots[slotName].filter(vnode => vNodeContainsText(vnode, text)).length; diff --git a/spec/javascripts/helpers/vue_test_utils_helper_spec.js b/spec/javascripts/helpers/vue_test_utils_helper_spec.js new file mode 100644 index 00000000000..41714066da5 --- /dev/null +++ b/spec/javascripts/helpers/vue_test_utils_helper_spec.js @@ -0,0 +1,48 @@ +import { shallowMount } from '@vue/test-utils'; +import { shallowWrapperContainsSlotText } from './vue_test_utils_helper'; + +describe('Vue test utils helpers', () => { + describe('shallowWrapperContainsSlotText', () => { + const mockText = 'text'; + const mockSlot = `<div>${mockText}</div>`; + let mockComponent; + + beforeEach(() => { + mockComponent = shallowMount( + { + render(h) { + h(`<div>mockedComponent</div>`); + }, + }, + { + slots: { + default: mockText, + namedSlot: mockSlot, + }, + }, + ); + }); + + it('finds text within shallowWrapper default slot', () => { + expect(shallowWrapperContainsSlotText(mockComponent, 'default', mockText)).toBe(true); + }); + + it('finds text within shallowWrapper named slot', () => { + expect(shallowWrapperContainsSlotText(mockComponent, 'namedSlot', mockText)).toBe(true); + }); + + it('returns false when text is not present', () => { + const searchText = 'absent'; + + expect(shallowWrapperContainsSlotText(mockComponent, 'default', searchText)).toBe(false); + expect(shallowWrapperContainsSlotText(mockComponent, 'namedSlot', searchText)).toBe(false); + }); + + it('searches with case-sensitivity', () => { + const searchText = mockText.toUpperCase(); + + expect(shallowWrapperContainsSlotText(mockComponent, 'default', searchText)).toBe(false); + expect(shallowWrapperContainsSlotText(mockComponent, 'namedSlot', searchText)).toBe(false); + }); + }); +}); diff --git a/spec/javascripts/issue_show/components/description_spec.js b/spec/javascripts/issue_show/components/description_spec.js index 72716b97f5f..2eeed6770be 100644 --- a/spec/javascripts/issue_show/components/description_spec.js +++ b/spec/javascripts/issue_show/components/description_spec.js @@ -21,7 +21,8 @@ describe('Description component', () => { if (!document.querySelector('.issuable-meta')) { const metaData = document.createElement('div'); metaData.classList.add('issuable-meta'); - metaData.innerHTML = '<span id="task_status"></span><span id="task_status_short"></span>'; + metaData.innerHTML = + '<div class="flash-container"></div><span id="task_status"></span><span id="task_status_short"></span>'; document.body.appendChild(metaData); } @@ -33,6 +34,10 @@ describe('Description component', () => { vm.$destroy(); }); + afterAll(() => { + $('.issuable-meta .flash-container').remove(); + }); + it('animates description changes', done => { vm.descriptionHtml = 'changed'; @@ -192,12 +197,11 @@ describe('Description component', () => { it('should create flash notification and emit an event to parent', () => { const msg = 'Someone edited this issue at the same time you did. The description has been updated and you will need to make your changes again.'; - spyOn(window, 'Flash'); spyOn(vm, '$emit'); vm.taskListUpdateError(); - expect(window.Flash).toHaveBeenCalledWith(msg); + expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(msg); expect(vm.$emit).toHaveBeenCalledWith('taskListUpdateFailed'); }); }); diff --git a/spec/javascripts/lib/utils/file_upload_spec.js b/spec/javascripts/lib/utils/file_upload_spec.js index 92c9cc70aaf..8f7092f63de 100644 --- a/spec/javascripts/lib/utils/file_upload_spec.js +++ b/spec/javascripts/lib/utils/file_upload_spec.js @@ -9,28 +9,56 @@ describe('File upload', () => { <span class="js-filename"></span> </form> `); + }); + + describe('when there is a matching button and input', () => { + beforeEach(() => { + fileUpload('.js-button', '.js-input'); + }); + + it('clicks file input after clicking button', () => { + const btn = document.querySelector('.js-button'); + const input = document.querySelector('.js-input'); + + spyOn(input, 'click'); + + btn.click(); + + expect(input.click).toHaveBeenCalled(); + }); + + it('updates file name text', () => { + const input = document.querySelector('.js-input'); - fileUpload('.js-button', '.js-input'); + input.value = 'path/to/file/index.js'; + + input.dispatchEvent(new CustomEvent('change')); + + expect(document.querySelector('.js-filename').textContent).toEqual('index.js'); + }); }); - it('clicks file input after clicking button', () => { - const btn = document.querySelector('.js-button'); + it('fails gracefully when there is no matching button', () => { const input = document.querySelector('.js-input'); + const btn = document.querySelector('.js-button'); + fileUpload('.js-not-button', '.js-input'); spyOn(input, 'click'); btn.click(); - expect(input.click).toHaveBeenCalled(); + expect(input.click).not.toHaveBeenCalled(); }); - it('updates file name text', () => { + it('fails gracefully when there is no matching input', () => { const input = document.querySelector('.js-input'); + const btn = document.querySelector('.js-button'); + fileUpload('.js-button', '.js-not-input'); - input.value = 'path/to/file/index.js'; + spyOn(input, 'click'); - input.dispatchEvent(new CustomEvent('change')); + btn.click(); - expect(document.querySelector('.js-filename').textContent).toEqual('index.js'); + expect(input.click).not.toHaveBeenCalled(); }); }); diff --git a/spec/javascripts/merge_request_spec.js b/spec/javascripts/merge_request_spec.js index 32623d1781a..ab809930804 100644 --- a/spec/javascripts/merge_request_spec.js +++ b/spec/javascripts/merge_request_spec.js @@ -40,30 +40,51 @@ describe('MergeRequest', function() { expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); }); - it('submits an ajax request on tasklist:changed', done => { + describe('tasklist', () => { const lineNumber = 8; const lineSource = '- [ ] item 8'; const index = 3; const checked = true; - $('.js-task-list-field').trigger({ - type: 'tasklist:changed', - detail: { lineNumber, lineSource, index, checked }, + it('submits an ajax request on tasklist:changed', done => { + $('.js-task-list-field').trigger({ + type: 'tasklist:changed', + detail: { lineNumber, lineSource, index, checked }, + }); + + setTimeout(() => { + expect(axios.patch).toHaveBeenCalledWith( + `${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`, + { + merge_request: { + description: '- [ ] Task List Item', + lock_version: undefined, + update_task: { line_number: lineNumber, line_source: lineSource, index, checked }, + }, + }, + ); + + done(); + }); }); - setTimeout(() => { - expect(axios.patch).toHaveBeenCalledWith( - `${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`, - { - merge_request: { - description: '- [ ] Task List Item', - lock_version: undefined, - update_task: { line_number: lineNumber, line_source: lineSource, index, checked }, - }, - }, - ); + it('shows an error notification when tasklist update failed', done => { + mock + .onPatch(`${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`) + .reply(409, {}); + + $('.js-task-list-field').trigger({ + type: 'tasklist:changed', + detail: { lineNumber, lineSource, index, checked }, + }); + + setTimeout(() => { + expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe( + 'Someone edited this merge request at the same time you did. Please refresh the page to see changes.', + ); - done(); + done(); + }); }); }); }); diff --git a/spec/javascripts/monitoring/charts/area_spec.js b/spec/javascripts/monitoring/charts/area_spec.js new file mode 100644 index 00000000000..0b36fc9f5f7 --- /dev/null +++ b/spec/javascripts/monitoring/charts/area_spec.js @@ -0,0 +1,220 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import { shallowWrapperContainsSlotText } from 'spec/helpers/vue_test_utils_helper'; +import Area from '~/monitoring/components/charts/area.vue'; +import MonitoringStore from '~/monitoring/stores/monitoring_store'; +import MonitoringMock, { deploymentData } from '../mock_data'; + +describe('Area component', () => { + const mockWidgets = 'mockWidgets'; + let mockGraphData; + let areaChart; + let spriteSpy; + + beforeEach(() => { + const store = new MonitoringStore(); + store.storeMetrics(MonitoringMock.data); + store.storeDeploymentData(deploymentData); + + [mockGraphData] = store.groups[0].metrics; + + areaChart = shallowMount(Area, { + propsData: { + graphData: mockGraphData, + containerWidth: 0, + deploymentData: store.deploymentData, + }, + slots: { + default: mockWidgets, + }, + }); + + spriteSpy = spyOnDependency(Area, 'getSvgIconPathContent').and.callFake( + () => new Promise(resolve => resolve()), + ); + }); + + afterEach(() => { + areaChart.destroy(); + }); + + it('renders chart title', () => { + expect(areaChart.find({ ref: 'graphTitle' }).text()).toBe(mockGraphData.title); + }); + + it('contains graph widgets from slot', () => { + expect(areaChart.find({ ref: 'graphWidgets' }).text()).toBe(mockWidgets); + }); + + describe('wrapped components', () => { + describe('GitLab UI area chart', () => { + let glAreaChart; + + beforeEach(() => { + glAreaChart = areaChart.find(GlAreaChart); + }); + + it('is a Vue instance', () => { + expect(glAreaChart.isVueInstance()).toBe(true); + }); + + it('receives data properties needed for proper chart render', () => { + const props = glAreaChart.props(); + + expect(props.data).toBe(areaChart.vm.chartData); + expect(props.option).toBe(areaChart.vm.chartOptions); + expect(props.formatTooltipText).toBe(areaChart.vm.formatTooltipText); + expect(props.thresholds).toBe(areaChart.props('alertData')); + }); + + it('recieves a tooltip title', () => { + const mockTitle = 'mockTitle'; + areaChart.vm.tooltip.title = mockTitle; + + expect(shallowWrapperContainsSlotText(glAreaChart, 'tooltipTitle', mockTitle)).toBe(true); + }); + + it('recieves tooltip content', () => { + const mockContent = 'mockContent'; + areaChart.vm.tooltip.content = mockContent; + + expect(shallowWrapperContainsSlotText(glAreaChart, 'tooltipContent', mockContent)).toBe( + true, + ); + }); + + describe('when tooltip is showing deployment data', () => { + beforeEach(() => { + areaChart.vm.tooltip.isDeployment = true; + }); + + it('uses deployment title', () => { + expect(shallowWrapperContainsSlotText(glAreaChart, 'tooltipTitle', 'Deployed')).toBe( + true, + ); + }); + + it('renders commit sha in tooltip content', () => { + const mockSha = 'mockSha'; + areaChart.vm.tooltip.sha = mockSha; + + expect(shallowWrapperContainsSlotText(glAreaChart, 'tooltipContent', mockSha)).toBe(true); + }); + }); + }); + }); + + describe('methods', () => { + describe('formatTooltipText', () => { + const mockDate = deploymentData[0].created_at; + const generateSeriesData = type => ({ + seriesData: [ + { + componentSubType: type, + value: [mockDate, 5.55555], + }, + ], + value: mockDate, + }); + + describe('series is of line type', () => { + beforeEach(() => { + areaChart.vm.formatTooltipText(generateSeriesData('line')); + }); + + it('formats tooltip title', () => { + expect(areaChart.vm.tooltip.title).toBe('31 May 2017, 9:23PM'); + }); + + it('formats tooltip content', () => { + expect(areaChart.vm.tooltip.content).toBe('CPU (Cores) 5.556'); + }); + }); + + describe('series is of scatter type', () => { + beforeEach(() => { + areaChart.vm.formatTooltipText(generateSeriesData('scatter')); + }); + + it('formats tooltip title', () => { + expect(areaChart.vm.tooltip.title).toBe('31 May 2017, 9:23PM'); + }); + + it('formats tooltip sha', () => { + expect(areaChart.vm.tooltip.sha).toBe('f5bcd1d9'); + }); + }); + }); + + describe('getScatterSymbol', () => { + beforeEach(() => { + areaChart.vm.getScatterSymbol(); + }); + + it('gets rocket svg path content for use as deployment data symbol', () => { + expect(spriteSpy).toHaveBeenCalledWith('rocket'); + }); + }); + + describe('onResize', () => { + const mockWidth = 233; + const mockHeight = 144; + + beforeEach(() => { + spyOn(Element.prototype, 'getBoundingClientRect').and.callFake(() => ({ + width: mockWidth, + height: mockHeight, + })); + areaChart.vm.onResize(); + }); + + it('sets area chart width', () => { + expect(areaChart.vm.width).toBe(mockWidth); + }); + + it('sets area chart height', () => { + expect(areaChart.vm.height).toBe(mockHeight); + }); + }); + }); + + describe('computed', () => { + describe('chartData', () => { + it('utilizes all data points', () => { + expect(Object.keys(areaChart.vm.chartData)).toEqual(['Cores']); + expect(areaChart.vm.chartData.Cores.length).toBe(297); + }); + + it('creates valid data', () => { + const data = areaChart.vm.chartData.Cores; + + expect( + data.filter(([time, value]) => new Date(time).getTime() > 0 && typeof value === 'number') + .length, + ).toBe(data.length); + }); + }); + + describe('scatterSeries', () => { + it('utilizes deployment data', () => { + expect(areaChart.vm.scatterSeries.data).toEqual([ + ['2017-05-31T21:23:37.881Z', 0], + ['2017-05-30T20:08:04.629Z', 0], + ['2017-05-30T17:42:38.409Z', 0], + ]); + }); + }); + + describe('xAxisLabel', () => { + it('constructs a label for the chart x-axis', () => { + expect(areaChart.vm.xAxisLabel).toBe('Core Usage'); + }); + }); + + describe('yAxisLabel', () => { + it('constructs a label for the chart y-axis', () => { + expect(areaChart.vm.yAxisLabel).toBe('CPU (Cores)'); + }); + }); + }); +}); diff --git a/spec/javascripts/monitoring/dashboard_spec.js b/spec/javascripts/monitoring/dashboard_spec.js index 97b9671c809..b1778029a77 100644 --- a/spec/javascripts/monitoring/dashboard_spec.js +++ b/spec/javascripts/monitoring/dashboard_spec.js @@ -25,15 +25,22 @@ export default propsData; describe('Dashboard', () => { let DashboardComponent; + let mock; beforeEach(() => { setFixtures(` <div class="prometheus-graphs"></div> <div class="layout-page"></div> `); + + mock = new MockAdapter(axios); DashboardComponent = Vue.extend(Dashboard); }); + afterEach(() => { + mock.restore(); + }); + describe('no metrics are available yet', () => { it('shows a getting started empty state when no metrics are present', () => { const component = new DashboardComponent({ @@ -47,16 +54,10 @@ describe('Dashboard', () => { }); describe('requests information to the server', () => { - let mock; beforeEach(() => { - mock = new MockAdapter(axios); mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); }); - afterEach(() => { - mock.restore(); - }); - it('shows up a loading state', done => { const component = new DashboardComponent({ el: document.querySelector('.prometheus-graphs'), @@ -152,15 +153,12 @@ describe('Dashboard', () => { }); describe('when the window resizes', () => { - let mock; beforeEach(() => { - mock = new MockAdapter(axios); mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); jasmine.clock().install(); }); afterEach(() => { - mock.restore(); jasmine.clock().uninstall(); }); diff --git a/spec/javascripts/monitoring/mock_data.js b/spec/javascripts/monitoring/mock_data.js index b4e2cd75d47..ffc7148fde2 100644 --- a/spec/javascripts/monitoring/mock_data.js +++ b/spec/javascripts/monitoring/mock_data.js @@ -326,6 +326,7 @@ export const metricsGroupsAPIResponse = { { id: 6, title: 'CPU usage', + y_label: 'CPU', weight: 1, queries: [ { diff --git a/spec/javascripts/notes/components/noteable_note_spec.js b/spec/javascripts/notes/components/noteable_note_spec.js index 4a143ef089a..8ade6fc2ced 100644 --- a/spec/javascripts/notes/components/noteable_note_spec.js +++ b/spec/javascripts/notes/components/noteable_note_spec.js @@ -1,105 +1,64 @@ +import $ from 'jquery'; import _ from 'underscore'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vue from 'vue'; import createStore from '~/notes/stores'; import issueNote from '~/notes/components/noteable_note.vue'; -import NoteHeader from '~/notes/components/note_header.vue'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import NoteActions from '~/notes/components/note_actions.vue'; -import NoteBody from '~/notes/components/note_body.vue'; import { noteableDataMock, notesDataMock, note } from '../mock_data'; describe('issue_note', () => { let store; - let wrapper; + let vm; beforeEach(() => { + const Component = Vue.extend(issueNote); + store = createStore(); store.dispatch('setNoteableData', noteableDataMock); store.dispatch('setNotesData', notesDataMock); - const localVue = createLocalVue(); - wrapper = shallowMount(issueNote, { + vm = new Component({ store, propsData: { note, }, - sync: false, - localVue, - }); + }).$mount(); }); afterEach(() => { - wrapper.destroy(); + vm.$destroy(); }); it('should render user information', () => { - const { author } = note; - const avatar = wrapper.find(UserAvatarLink); - const avatarProps = avatar.props(); - - expect(avatarProps.linkHref).toBe(author.path); - expect(avatarProps.imgSrc).toBe(author.avatar_url); - expect(avatarProps.imgAlt).toBe(author.name); - expect(avatarProps.imgSize).toBe(40); + expect(vm.$el.querySelector('.user-avatar-link img').getAttribute('src')).toEqual( + note.author.avatar_url, + ); }); it('should render note header content', () => { - const noteHeader = wrapper.find(NoteHeader); - const noteHeaderProps = noteHeader.props(); + const el = vm.$el.querySelector('.note-header .note-header-author-name'); - expect(noteHeaderProps.author).toEqual(note.author); - expect(noteHeaderProps.createdAt).toEqual(note.created_at); - expect(noteHeaderProps.noteId).toEqual(note.id); + expect(el.textContent.trim()).toEqual(note.author.name); }); it('should render note actions', () => { - const { author } = note; - const noteActions = wrapper.find(NoteActions); - const noteActionsProps = noteActions.props(); - - expect(noteActionsProps.authorId).toBe(author.id); - expect(noteActionsProps.noteId).toBe(note.id); - expect(noteActionsProps.noteUrl).toBe(note.noteable_note_url); - expect(noteActionsProps.accessLevel).toBe(note.human_access); - expect(noteActionsProps.canEdit).toBe(note.current_user.can_edit); - expect(noteActionsProps.canAwardEmoji).toBe(note.current_user.can_award_emoji); - expect(noteActionsProps.canDelete).toBe(note.current_user.can_edit); - expect(noteActionsProps.canReportAsAbuse).toBe(true); - expect(noteActionsProps.canResolve).toBe(false); - expect(noteActionsProps.reportAbusePath).toBe(note.report_abuse_path); - expect(noteActionsProps.resolvable).toBe(false); - expect(noteActionsProps.isResolved).toBe(false); - expect(noteActionsProps.isResolving).toBe(false); - expect(noteActionsProps.resolvedBy).toEqual({}); + expect(vm.$el.querySelector('.note-actions')).toBeDefined(); }); it('should render issue body', () => { - const noteBody = wrapper.find(NoteBody); - const noteBodyProps = noteBody.props(); - - expect(noteBodyProps.note).toEqual(note); - expect(noteBodyProps.line).toBe(null); - expect(noteBodyProps.canEdit).toBe(note.current_user.can_edit); - expect(noteBodyProps.isEditing).toBe(false); - expect(noteBodyProps.helpPagePath).toBe(''); + expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html); }); it('prevents note preview xss', done => { const imgSrc = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; const noteBody = `<img src="${imgSrc}" onload="alert(1)" />`; const alertSpy = spyOn(window, 'alert'); - store.hotUpdate({ - actions: { - updateNote() {}, - }, - }); - const noteBodyComponent = wrapper.find(NoteBody); + vm.updateNote = () => new Promise($.noop); - noteBodyComponent.vm.$emit('handleFormUpdate', noteBody, null, () => {}); + vm.formUpdateHandler(noteBody, null, $.noop); setTimeout(() => { expect(alertSpy).not.toHaveBeenCalled(); - expect(wrapper.vm.note.note_html).toEqual(_.escape(noteBody)); + expect(vm.note.note_html).toEqual(_.escape(noteBody)); done(); }, 0); }); @@ -107,23 +66,17 @@ describe('issue_note', () => { describe('cancel edit', () => { it('restores content of updated note', done => { const noteBody = 'updated note text'; - store.hotUpdate({ - actions: { - updateNote() {}, - }, - }); - const noteBodyComponent = wrapper.find(NoteBody); - noteBodyComponent.vm.resetAutoSave = () => {}; + vm.updateNote = () => Promise.resolve(); - noteBodyComponent.vm.$emit('handleFormUpdate', noteBody, null, () => {}); + vm.formUpdateHandler(noteBody, null, $.noop); setTimeout(() => { - expect(wrapper.vm.note.note_html).toEqual(noteBody); + expect(vm.note.note_html).toEqual(noteBody); - noteBodyComponent.vm.$emit('cancelForm'); + vm.formCancelHandler(); setTimeout(() => { - expect(wrapper.vm.note.note_html).toEqual(noteBody); + expect(vm.note.note_html).toEqual(noteBody); done(); }); diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js index 7ae45c40c28..348743081eb 100644 --- a/spec/javascripts/notes/mock_data.js +++ b/spec/javascripts/notes/mock_data.js @@ -165,7 +165,6 @@ export const note = { report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_546&user_id=1', path: '/gitlab-org/gitlab-ce/notes/546', - cached_markdown_version: 11, }; export const discussionMock = { diff --git a/spec/javascripts/vue_mr_widget/components/states/commit_edit_spec.js b/spec/javascripts/vue_mr_widget/components/states/commit_edit_spec.js new file mode 100644 index 00000000000..994d6255324 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/commit_edit_spec.js @@ -0,0 +1,85 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import CommitEdit from '~/vue_merge_request_widget/components/states/commit_edit.vue'; + +const localVue = createLocalVue(); +const testCommitMessage = 'Test commit message'; +const testLabel = 'Test label'; +const testInputId = 'test-input-id'; + +describe('Commits edit component', () => { + let wrapper; + + const createComponent = (slots = {}) => { + wrapper = shallowMount(localVue.extend(CommitEdit), { + localVue, + sync: false, + propsData: { + value: testCommitMessage, + label: testLabel, + inputId: testInputId, + }, + slots: { + ...slots, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findTextarea = () => wrapper.find('.form-control'); + + it('has a correct label', () => { + const labelElement = wrapper.find('.col-form-label'); + + expect(labelElement.text()).toBe(testLabel); + }); + + describe('textarea', () => { + it('has a correct ID', () => { + expect(findTextarea().attributes('id')).toBe(testInputId); + }); + + it('has a correct value', () => { + expect(findTextarea().element.value).toBe(testCommitMessage); + }); + + it('emits an input event and receives changed value', () => { + const changedCommitMessage = 'Changed commit message'; + + findTextarea().element.value = changedCommitMessage; + findTextarea().trigger('input'); + + expect(wrapper.emitted().input[0]).toEqual([changedCommitMessage]); + expect(findTextarea().element.value).toBe(changedCommitMessage); + }); + }); + + describe('when slots are present', () => { + beforeEach(() => { + createComponent({ + header: `<div class="test-header">${testCommitMessage}</div>`, + checkbox: `<label slot="checkbox" class="test-checkbox">${testLabel}</label >`, + }); + }); + + it('renders header slot correctly', () => { + const headerSlotElement = wrapper.find('.test-header'); + + expect(headerSlotElement.exists()).toBe(true); + expect(headerSlotElement.text()).toBe(testCommitMessage); + }); + + it('renders checkbox slot correctly', () => { + const checkboxSlotElement = wrapper.find('.test-checkbox'); + + expect(checkboxSlotElement.exists()).toBe(true); + expect(checkboxSlotElement.text()).toBe(testLabel); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js new file mode 100644 index 00000000000..daf1cc2d98b --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js @@ -0,0 +1,61 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlDropdownItem } from '@gitlab/ui'; +import CommitMessageDropdown from '~/vue_merge_request_widget/components/states/commit_message_dropdown.vue'; + +const localVue = createLocalVue(); +const commits = [ + { + title: 'Commit 1', + short_id: '78d5b7', + message: 'Update test.txt', + }, + { + title: 'Commit 2', + short_id: '34cbe28b', + message: 'Fixed test', + }, + { + title: 'Commit 3', + short_id: 'fa42932a', + message: 'Added changelog', + }, +]; + +describe('Commits message dropdown component', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(localVue.extend(CommitMessageDropdown), { + localVue, + sync: false, + propsData: { + commits, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findDropdownElements = () => wrapper.findAll(GlDropdownItem); + const findFirstDropdownElement = () => findDropdownElements().at(0); + + it('should have 3 elements in dropdown list', () => { + expect(findDropdownElements().length).toBe(3); + }); + + it('should have correct message for the first dropdown list element', () => { + expect(findFirstDropdownElement().text()).toBe('78d5b7 Commit 1'); + }); + + it('should emit a commit title on selecting commit', () => { + findFirstDropdownElement().vm.$emit('click'); + + expect(wrapper.emitted().input[0]).toEqual(['Update test.txt']); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_commits_header_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_commits_header_spec.js new file mode 100644 index 00000000000..5cf6408cf34 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_commits_header_spec.js @@ -0,0 +1,110 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import CommitsHeader from '~/vue_merge_request_widget/components/states/commits_header.vue'; +import Icon from '~/vue_shared/components/icon.vue'; + +const localVue = createLocalVue(); + +describe('Commits header component', () => { + let wrapper; + + const createComponent = props => { + wrapper = shallowMount(localVue.extend(CommitsHeader), { + localVue, + sync: false, + propsData: { + isSquashEnabled: false, + targetBranch: 'master', + commitsCount: 5, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findHeaderWrapper = () => wrapper.find('.js-mr-widget-commits-count'); + const findCommitToggle = () => wrapper.find('.commit-edit-toggle'); + const findIcon = () => wrapper.find(Icon); + const findCommitsCountMessage = () => wrapper.find('.commits-count-message'); + const findTargetBranchMessage = () => wrapper.find('.label-branch'); + const findModifyButton = () => wrapper.find('.modify-message-button'); + + describe('when collapsed', () => { + it('toggle has aria-label equal to Expand', () => { + createComponent(); + + expect(findCommitToggle().attributes('aria-label')).toBe('Expand'); + }); + + it('has a chevron-right icon', () => { + createComponent(); + wrapper.setData({ expanded: false }); + + expect(findIcon().props('name')).toBe('chevron-right'); + }); + + describe('when squash is disabled', () => { + beforeEach(() => { + createComponent(); + }); + + it('has commits count message showing correct amount of commits', () => { + expect(findCommitsCountMessage().text()).toBe('5 commits'); + }); + + it('has button with modify merge commit message', () => { + expect(findModifyButton().text()).toBe('Modify merge commit'); + }); + }); + + describe('when squash is enabled', () => { + beforeEach(() => { + createComponent({ isSquashEnabled: true }); + }); + + it('has commits count message showing one commit when squash is enabled', () => { + expect(findCommitsCountMessage().text()).toBe('1 commit'); + }); + + it('has button with modify commit messages text', () => { + expect(findModifyButton().text()).toBe('Modify commit messages'); + }); + }); + + it('has correct target branch displayed', () => { + createComponent(); + + expect(findTargetBranchMessage().text()).toBe('master'); + }); + }); + + describe('when expanded', () => { + beforeEach(() => { + createComponent(); + wrapper.setData({ expanded: true }); + }); + + it('toggle has aria-label equal to collapse', done => { + wrapper.vm.$nextTick(() => { + expect(findCommitToggle().attributes('aria-label')).toBe('Collapse'); + done(); + }); + }); + + it('has a chevron-down icon', done => { + wrapper.vm.$nextTick(() => { + expect(findIcon().props('name')).toBe('chevron-down'); + done(); + }); + }); + + it('has a collapse text', done => { + wrapper.vm.$nextTick(() => { + expect(findHeaderWrapper().text()).toBe('Collapse'); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js index e387367d1a2..631da202d1d 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -1,10 +1,14 @@ import Vue from 'vue'; import ReadyToMerge from '~/vue_merge_request_widget/components/states/ready_to_merge.vue'; import SquashBeforeMerge from '~/vue_merge_request_widget/components/states/squash_before_merge.vue'; +import CommitsHeader from '~/vue_merge_request_widget/components/states/commits_header.vue'; +import CommitEdit from '~/vue_merge_request_widget/components/states/commit_edit.vue'; +import CommitMessageDropdown from '~/vue_merge_request_widget/components/states/commit_message_dropdown.vue'; import eventHub from '~/vue_merge_request_widget/event_hub'; import { createLocalVue, shallowMount } from '@vue/test-utils'; const commitMessage = 'This is the commit message'; +const squashCommitMessage = 'This is the squash commit message'; const commitMessageWithDescription = 'This is the commit message description'; const createTestMr = customConfig => { const mr = { @@ -19,9 +23,11 @@ const createTestMr = customConfig => { sha: '12345678', squash: false, commitMessage, + squashCommitMessage, commitMessageWithDescription, shouldRemoveSourceBranch: true, canRemoveSourceBranch: false, + targetBranch: 'master', }; Object.assign(mr, customConfig.mr); @@ -98,21 +104,6 @@ describe('ReadyToMerge', () => { }); }); - describe('commitMessageLinkTitle', () => { - const withDesc = 'Include description in commit message'; - const withoutDesc = "Don't include description in commit message"; - - it('should return message with description', () => { - expect(vm.commitMessageLinkTitle).toEqual(withDesc); - }); - - it('should return message without description', () => { - vm.useCommitMessageWithDescription = true; - - expect(vm.commitMessageLinkTitle).toEqual(withoutDesc); - }); - }); - describe('status', () => { it('defaults to success', () => { vm.mr.pipeline = true; @@ -279,55 +270,43 @@ describe('ReadyToMerge', () => { vm.mr.isMergeAllowed = false; vm.mr.isPipelineActive = false; - expect(vm.shouldShowMergeControls()).toBeFalsy(); + expect(vm.shouldShowMergeControls).toBeFalsy(); }); it('should return true when the build succeeded or build not required to succeed', () => { vm.mr.isMergeAllowed = true; vm.mr.isPipelineActive = false; - expect(vm.shouldShowMergeControls()).toBeTruthy(); + expect(vm.shouldShowMergeControls).toBeTruthy(); }); it('should return true when showing the MWPS button and a pipeline is running that needs to be successful', () => { vm.mr.isMergeAllowed = false; vm.mr.isPipelineActive = true; - expect(vm.shouldShowMergeControls()).toBeTruthy(); + expect(vm.shouldShowMergeControls).toBeTruthy(); }); it('should return true when showing the MWPS button but not required for the pipeline to succeed', () => { vm.mr.isMergeAllowed = true; vm.mr.isPipelineActive = true; - expect(vm.shouldShowMergeControls()).toBeTruthy(); + expect(vm.shouldShowMergeControls).toBeTruthy(); }); }); - describe('updateCommitMessage', () => { + describe('updateMergeCommitMessage', () => { it('should revert flag and change commitMessage', () => { - expect(vm.useCommitMessageWithDescription).toBeFalsy(); expect(vm.commitMessage).toEqual(commitMessage); - vm.updateCommitMessage(); + vm.updateMergeCommitMessage(true); - expect(vm.useCommitMessageWithDescription).toBeTruthy(); expect(vm.commitMessage).toEqual(commitMessageWithDescription); - vm.updateCommitMessage(); + vm.updateMergeCommitMessage(false); - expect(vm.useCommitMessageWithDescription).toBeFalsy(); expect(vm.commitMessage).toEqual(commitMessage); }); }); - describe('toggleCommitMessageEditor', () => { - it('should toggle showCommitMessageEditor flag', () => { - expect(vm.showCommitMessageEditor).toBeFalsy(); - vm.toggleCommitMessageEditor(); - - expect(vm.showCommitMessageEditor).toBeTruthy(); - }); - }); - describe('handleMergeButtonClick', () => { const returnPromise = status => new Promise(resolve => { @@ -623,7 +602,7 @@ describe('ReadyToMerge', () => { }); }); - describe('Squash checkbox component', () => { + describe('render children components', () => { let wrapper; const localVue = createLocalVue(); @@ -642,25 +621,101 @@ describe('ReadyToMerge', () => { }); const findCheckboxElement = () => wrapper.find(SquashBeforeMerge); + const findCommitsHeaderElement = () => wrapper.find(CommitsHeader); + const findCommitEditElements = () => wrapper.findAll(CommitEdit); + const findCommitDropdownElement = () => wrapper.find(CommitMessageDropdown); + + describe('squash checkbox', () => { + it('should be rendered when squash before merge is enabled and there is more than 1 commit', () => { + createLocalComponent({ + mr: { commitsCount: 2, enableSquashBeforeMerge: true }, + }); - it('should be rendered when squash before merge is enabled and there is more than 1 commit', () => { - createLocalComponent({ - mr: { commitsCount: 2, enableSquashBeforeMerge: true }, + expect(findCheckboxElement().exists()).toBeTruthy(); }); - expect(findCheckboxElement().exists()).toBeTruthy(); + it('should not be rendered when squash before merge is disabled', () => { + createLocalComponent({ mr: { commitsCount: 2, enableSquashBeforeMerge: false } }); + + expect(findCheckboxElement().exists()).toBeFalsy(); + }); + + it('should not be rendered when there is only 1 commit', () => { + createLocalComponent({ mr: { commitsCount: 1, enableSquashBeforeMerge: true } }); + + expect(findCheckboxElement().exists()).toBeFalsy(); + }); }); - it('should not be rendered when squash before merge is disabled', () => { - createLocalComponent({ mr: { commitsCount: 2, enableSquashBeforeMerge: false } }); + describe('commits count collapsible header', () => { + it('should be rendered if fast-forward is disabled', () => { + createLocalComponent(); - expect(findCheckboxElement().exists()).toBeFalsy(); + expect(findCommitsHeaderElement().exists()).toBeTruthy(); + }); + + it('should not be rendered if fast-forward is enabled', () => { + createLocalComponent({ mr: { ffOnlyEnabled: true } }); + + expect(findCommitsHeaderElement().exists()).toBeFalsy(); + }); }); - it('should not be rendered when there is only 1 commit', () => { - createLocalComponent({ mr: { commitsCount: 1, enableSquashBeforeMerge: true } }); + describe('commits edit components', () => { + it('should have one edit component when squash is disabled', () => { + createLocalComponent(); + + expect(findCommitEditElements().length).toBe(1); + }); - expect(findCheckboxElement().exists()).toBeFalsy(); + const findFirstCommitEditLabel = () => + findCommitEditElements() + .at(0) + .props('label'); + + it('should have two edit components when squash is enabled', () => { + createLocalComponent({ + mr: { + commitsCount: 2, + squash: true, + enableSquashBeforeMerge: true, + }, + }); + + expect(findCommitEditElements().length).toBe(2); + }); + + it('should have correct edit merge commit label', () => { + createLocalComponent(); + + expect(findFirstCommitEditLabel()).toBe('Merge commit message'); + }); + + it('should have correct edit squash commit label', () => { + createLocalComponent({ + mr: { + commitsCount: 2, + squash: true, + enableSquashBeforeMerge: true, + }, + }); + + expect(findFirstCommitEditLabel()).toBe('Squash commit message'); + }); + }); + + describe('commits dropdown', () => { + it('should not be rendered if squash is disabled', () => { + createLocalComponent(); + + expect(findCommitDropdownElement().exists()).toBeFalsy(); + }); + + it('should be rendered if squash is enabled', () => { + createLocalComponent({ mr: { squash: true } }); + + expect(findCommitDropdownElement().exists()).toBeTruthy(); + }); }); }); @@ -696,10 +751,6 @@ describe('ReadyToMerge', () => { expect(vm.$el.querySelector('.js-remove-source-branch-checkbox')).toBeNull(); }); - it('does not show modify commit message button', () => { - expect(vm.$el.querySelector('.js-modify-commit-message-button')).toBeNull(); - }); - it('shows message to resolve all items before being allowed to merge', () => { expect(vm.$el.querySelector('.js-resolve-mr-widget-items-message')).toBeDefined(); }); @@ -712,7 +763,7 @@ describe('ReadyToMerge', () => { mr: { ffOnlyEnabled: false }, }); - expect(customVm.$el.querySelector('.js-fast-forward-message')).toBeNull(); + expect(customVm.$el.querySelector('.mr-fast-forward-message')).toBeNull(); expect(customVm.$el.querySelector('.js-modify-commit-message-button')).toBeDefined(); }); @@ -721,7 +772,7 @@ describe('ReadyToMerge', () => { mr: { ffOnlyEnabled: true }, }); - expect(customVm.$el.querySelector('.js-fast-forward-message')).toBeDefined(); + expect(customVm.$el.querySelector('.mr-fast-forward-message')).toBeDefined(); expect(customVm.$el.querySelector('.js-modify-commit-message-button')).toBeNull(); }); }); diff --git a/spec/javascripts/vue_mr_widget/mock_data.js b/spec/javascripts/vue_mr_widget/mock_data.js index 75b197fb2ba..6ef07f81705 100644 --- a/spec/javascripts/vue_mr_widget/mock_data.js +++ b/spec/javascripts/vue_mr_widget/mock_data.js @@ -215,12 +215,14 @@ export default { project_archived: false, default_merge_commit_message_with_description: "Merge branch 'daaaa' into 'master'\n\nUpdate README.md\n\nSee merge request !22", + default_squash_commit_message: 'Test squash commit message', diverged_commits_count: 0, only_allow_merge_if_pipeline_succeeds: false, commit_change_content_path: '/root/acets-app/merge_requests/22/commit_change_content', merge_commit_path: 'http://localhost:3000/root/acets-app/commit/53027d060246c8f47e4a9310fb332aa52f221775', troubleshooting_docs_path: 'help', + squash: true, }; export const mockStore = { diff --git a/spec/lib/banzai/object_renderer_spec.rb b/spec/lib/banzai/object_renderer_spec.rb index 209a547c3b3..3b52f6666d0 100644 --- a/spec/lib/banzai/object_renderer_spec.rb +++ b/spec/lib/banzai/object_renderer_spec.rb @@ -11,7 +11,7 @@ describe Banzai::ObjectRenderer do ) end - let(:object) { Note.new(note: 'hello', note_html: '<p dir="auto">hello</p>', cached_markdown_version: CacheMarkdownField::CACHE_COMMONMARK_VERSION) } + let(:object) { Note.new(note: 'hello', note_html: '<p dir="auto">hello</p>', cached_markdown_version: CacheMarkdownField::CACHE_COMMONMARK_VERSION << 16) } describe '#render' do context 'with cache' do diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb index 236808c0b69..a4a6338961e 100644 --- a/spec/lib/gitlab/auth_spec.rb +++ b/spec/lib/gitlab/auth_spec.rb @@ -19,7 +19,7 @@ describe Gitlab::Auth do it 'optional_scopes contains all non-default scopes' do stub_container_registry_config(enabled: true) - expect(subject.optional_scopes).to eq %i[read_user sudo read_repository read_registry openid] + expect(subject.optional_scopes).to eq %i[read_user sudo read_repository read_registry openid profile email] end context 'registry_scopes' do diff --git a/spec/lib/gitlab/bare_repository_import/repository_spec.rb b/spec/lib/gitlab/bare_repository_import/repository_spec.rb index afd8f5da39f..a07c5371134 100644 --- a/spec/lib/gitlab/bare_repository_import/repository_spec.rb +++ b/spec/lib/gitlab/bare_repository_import/repository_spec.rb @@ -61,7 +61,7 @@ describe ::Gitlab::BareRepositoryImport::Repository do let(:wiki_path) { File.join(root_path, "#{hashed_path}.wiki.git") } before do - gitlab_shell.create_repository(repository_storage, hashed_path) + gitlab_shell.create_repository(repository_storage, hashed_path, 'group/project') Gitlab::GitalyClient::StorageSettings.allow_disk_access do repository = Rugged::Repository.new(repo_path) repository.config['gitlab.fullpath'] = 'to/repo' diff --git a/spec/lib/gitlab/bitbucket_import/importer_spec.rb b/spec/lib/gitlab/bitbucket_import/importer_spec.rb index 0def685f177..c432cc223b9 100644 --- a/spec/lib/gitlab/bitbucket_import/importer_spec.rb +++ b/spec/lib/gitlab/bitbucket_import/importer_spec.rb @@ -218,7 +218,7 @@ describe Gitlab::BitbucketImport::Importer do describe 'wiki import' do it 'is skipped when the wiki exists' do expect(project.wiki).to receive(:repository_exists?) { true } - expect(importer.gitlab_shell).not_to receive(:import_repository) + expect(importer.gitlab_shell).not_to receive(:import_wiki_repository) importer.execute @@ -227,11 +227,7 @@ describe Gitlab::BitbucketImport::Importer do it 'imports to the project disk_path' do expect(project.wiki).to receive(:repository_exists?) { false } - expect(importer.gitlab_shell).to receive(:import_repository).with( - project.repository_storage, - project.wiki.disk_path, - project.import_url + '/wiki' - ) + expect(importer.gitlab_shell).to receive(:import_wiki_repository) importer.execute diff --git a/spec/lib/gitlab/bitbucket_import/wiki_formatter_spec.rb b/spec/lib/gitlab/bitbucket_import/wiki_formatter_spec.rb new file mode 100644 index 00000000000..795fd069ab2 --- /dev/null +++ b/spec/lib/gitlab/bitbucket_import/wiki_formatter_spec.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +describe Gitlab::BitbucketImport::WikiFormatter do + let(:project) do + create(:project, + namespace: create(:namespace, path: 'gitlabhq'), + import_url: 'https://xxx@bitbucket.org/gitlabhq/sample.gitlabhq.git') + end + + subject(:wiki) { described_class.new(project) } + + describe '#disk_path' do + it 'appends .wiki to disk path' do + expect(wiki.disk_path).to eq project.wiki.disk_path + end + end + + describe '#full_path' do + it 'appends .wiki to project path' do + expect(wiki.full_path).to eq project.wiki.full_path + end + end + + describe '#import_url' do + it 'returns URL of the wiki repository' do + expect(wiki.import_url).to eq 'https://xxx@bitbucket.org/gitlabhq/sample.gitlabhq.git/wiki' + end + end +end diff --git a/spec/lib/gitlab/git/blame_spec.rb b/spec/lib/gitlab/git/blame_spec.rb index e704d1c673c..0010c0304eb 100644 --- a/spec/lib/gitlab/git/blame_spec.rb +++ b/spec/lib/gitlab/git/blame_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" describe Gitlab::Git::Blame, :seed_helper do - let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') } + let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') } let(:blame) do Gitlab::Git::Blame.new(repository, SeedRepo::Commit::ID, "CONTRIBUTING.md") end diff --git a/spec/lib/gitlab/git/blob_spec.rb b/spec/lib/gitlab/git/blob_spec.rb index 1bcec04d28f..a1b5cea88c0 100644 --- a/spec/lib/gitlab/git/blob_spec.rb +++ b/spec/lib/gitlab/git/blob_spec.rb @@ -3,7 +3,7 @@ require "spec_helper" describe Gitlab::Git::Blob, :seed_helper do - let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') } + let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') } let(:rugged) do Rugged::Repository.new(File.join(TestEnv.repos_path, TEST_REPO_PATH)) end diff --git a/spec/lib/gitlab/git/branch_spec.rb b/spec/lib/gitlab/git/branch_spec.rb index 0df282d0ae3..0764e525ede 100644 --- a/spec/lib/gitlab/git/branch_spec.rb +++ b/spec/lib/gitlab/git/branch_spec.rb @@ -1,7 +1,7 @@ require "spec_helper" describe Gitlab::Git::Branch, :seed_helper do - let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') } + let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') } let(:rugged) do Rugged::Repository.new(File.join(TestEnv.repos_path, repository.relative_path)) end @@ -64,7 +64,7 @@ describe Gitlab::Git::Branch, :seed_helper do context 'with active, stale and future branches' do let(:repository) do - Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '') + Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '', 'group/project') end let(:user) { create(:user) } diff --git a/spec/lib/gitlab/git/commit_spec.rb b/spec/lib/gitlab/git/commit_spec.rb index db68062e433..2611ebed25b 100644 --- a/spec/lib/gitlab/git/commit_spec.rb +++ b/spec/lib/gitlab/git/commit_spec.rb @@ -3,7 +3,7 @@ require "spec_helper" describe Gitlab::Git::Commit, :seed_helper do include GitHelpers - let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') } + let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') } let(:rugged_repo) do Rugged::Repository.new(File.join(TestEnv.repos_path, TEST_REPO_PATH)) end @@ -146,7 +146,7 @@ describe Gitlab::Git::Commit, :seed_helper do end context 'with broken repo' do - let(:repository) { Gitlab::Git::Repository.new('default', TEST_BROKEN_REPO_PATH, '') } + let(:repository) { Gitlab::Git::Repository.new('default', TEST_BROKEN_REPO_PATH, '', 'group/project') } it 'returns nil' do expect(described_class.find(repository, SeedRepo::Commit::ID)).to be_nil diff --git a/spec/lib/gitlab/git/compare_spec.rb b/spec/lib/gitlab/git/compare_spec.rb index 771c71a16a9..65dfb93d0db 100644 --- a/spec/lib/gitlab/git/compare_spec.rb +++ b/spec/lib/gitlab/git/compare_spec.rb @@ -1,7 +1,7 @@ require "spec_helper" describe Gitlab::Git::Compare, :seed_helper do - let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') } + let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') } let(:compare) { Gitlab::Git::Compare.new(repository, SeedRepo::BigCommit::ID, SeedRepo::Commit::ID, straight: false) } let(:compare_straight) { Gitlab::Git::Compare.new(repository, SeedRepo::BigCommit::ID, SeedRepo::Commit::ID, straight: true) } diff --git a/spec/lib/gitlab/git/diff_spec.rb b/spec/lib/gitlab/git/diff_spec.rb index 8a4415506c4..1d22329b670 100644 --- a/spec/lib/gitlab/git/diff_spec.rb +++ b/spec/lib/gitlab/git/diff_spec.rb @@ -1,7 +1,7 @@ require "spec_helper" describe Gitlab::Git::Diff, :seed_helper do - let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') } + let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') } let(:gitaly_diff) do Gitlab::GitalyClient::Diff.new( from_path: '.gitmodules', diff --git a/spec/lib/gitlab/git/remote_repository_spec.rb b/spec/lib/gitlab/git/remote_repository_spec.rb index 53ed7c5a13a..e166628d4ca 100644 --- a/spec/lib/gitlab/git/remote_repository_spec.rb +++ b/spec/lib/gitlab/git/remote_repository_spec.rb @@ -1,15 +1,15 @@ require 'spec_helper' describe Gitlab::Git::RemoteRepository, :seed_helper do - let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') } + let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') } subject { described_class.new(repository) } describe '#empty?' do using RSpec::Parameterized::TableSyntax where(:repository, :result) do - Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') | false - Gitlab::Git::Repository.new('default', 'does-not-exist.git', '') | true + Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') | false + Gitlab::Git::Repository.new('default', 'does-not-exist.git', '', 'group/project') | true end with_them do @@ -44,11 +44,11 @@ describe Gitlab::Git::RemoteRepository, :seed_helper do using RSpec::Parameterized::TableSyntax where(:other_repository, :result) do - repository | true - Gitlab::Git::Repository.new(repository.storage, repository.relative_path, '') | true - Gitlab::Git::Repository.new('broken', TEST_REPO_PATH, '') | false - Gitlab::Git::Repository.new(repository.storage, 'wrong/relative-path.git', '') | false - Gitlab::Git::Repository.new('broken', 'wrong/relative-path.git', '') | false + repository | true + Gitlab::Git::Repository.new(repository.storage, repository.relative_path, '', 'group/project') | true + Gitlab::Git::Repository.new('broken', TEST_REPO_PATH, '', 'group/project') | false + Gitlab::Git::Repository.new(repository.storage, 'wrong/relative-path.git', '', 'group/project') | false + Gitlab::Git::Repository.new('broken', 'wrong/relative-path.git', '', 'group/project') | false end with_them do diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index a19e3e84f83..cf9e0cccc71 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -19,8 +19,10 @@ describe Gitlab::Git::Repository, :seed_helper do end end - let(:mutable_repository) { Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '') } - let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') } + let(:mutable_repository) { Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '', 'group/project') } + let(:mutable_repository_path) { File.join(TestEnv.repos_path, mutable_repository.relative_path) } + let(:mutable_repository_rugged) { Rugged::Repository.new(mutable_repository_path) } + let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') } let(:repository_path) { File.join(TestEnv.repos_path, repository.relative_path) } let(:repository_rugged) { Rugged::Repository.new(repository_path) } let(:storage_path) { TestEnv.repos_path } @@ -434,13 +436,13 @@ describe Gitlab::Git::Repository, :seed_helper do describe '#fetch_repository_as_mirror' do let(:new_repository) do - Gitlab::Git::Repository.new('default', 'my_project.git', '') + Gitlab::Git::Repository.new('default', 'my_project.git', '', 'group/project') end subject { new_repository.fetch_repository_as_mirror(repository) } before do - Gitlab::Shell.new.create_repository('default', 'my_project') + Gitlab::Shell.new.create_repository('default', 'my_project', 'group/project') end after do @@ -497,6 +499,48 @@ describe Gitlab::Git::Repository, :seed_helper do end end + describe '#search_files_by_content' do + let(:repository) { mutable_repository } + let(:repository_rugged) { mutable_repository_rugged } + + before do + repository.create_branch('search-files-by-content-branch', 'master') + new_commit_edit_new_file_on_branch(repository_rugged, 'encoding/CHANGELOG', 'search-files-by-content-branch', 'committing something', 'search-files-by-content change') + new_commit_edit_new_file_on_branch(repository_rugged, 'anotherfile', 'search-files-by-content-branch', 'committing something', 'search-files-by-content change') + end + + after do + ensure_seeds + end + + shared_examples 'search files by content' do + it 'should have 2 items' do + expect(search_results.size).to eq(2) + end + + it 'should have the correct matching line' do + expect(search_results).to contain_exactly("search-files-by-content-branch:encoding/CHANGELOG\u00001\u0000search-files-by-content change\n", + "search-files-by-content-branch:anotherfile\u00001\u0000search-files-by-content change\n") + end + end + + it_should_behave_like 'search files by content' do + let(:search_results) do + repository.search_files_by_content('search-files-by-content', 'search-files-by-content-branch') + end + end + + it_should_behave_like 'search files by content' do + let(:search_results) do + repository.gitaly_repository_client.search_files_by_content( + 'search-files-by-content-branch', + 'search-files-by-content', + chunked_response: false + ) + end + end + end + describe '#find_remote_root_ref' do it 'gets the remote root ref from GitalyClient' do expect_any_instance_of(Gitlab::GitalyClient::RemoteService) @@ -544,7 +588,7 @@ describe Gitlab::Git::Repository, :seed_helper do # Add new commits so that there's a renamed file in the commit history @commit_with_old_name_id = new_commit_edit_old_file(repository_rugged).oid @rename_commit_id = new_commit_move_file(repository_rugged).oid - @commit_with_new_name_id = new_commit_edit_new_file(repository_rugged).oid + @commit_with_new_name_id = new_commit_edit_new_file(repository_rugged, "encoding/CHANGELOG", "Edit encoding/CHANGELOG", "I'm a new changelog with different text").oid end after do @@ -1230,7 +1274,7 @@ describe Gitlab::Git::Repository, :seed_helper do end describe '#gitattribute' do - let(:repository) { Gitlab::Git::Repository.new('default', TEST_GITATTRIBUTES_REPO_PATH, '') } + let(:repository) { Gitlab::Git::Repository.new('default', TEST_GITATTRIBUTES_REPO_PATH, '', 'group/project') } after do ensure_seeds @@ -1249,7 +1293,7 @@ describe Gitlab::Git::Repository, :seed_helper do end context 'without gitattributes file' do - let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') } + let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') } it 'returns nil' do expect(repository.gitattribute("README.md", 'gitlab-language')).to eq(nil) @@ -1513,7 +1557,7 @@ describe Gitlab::Git::Repository, :seed_helper do context 'repository does not exist' do it 'raises NoRepository and does not call Gitaly WriteConfig' do - repository = Gitlab::Git::Repository.new('default', 'does/not/exist.git', '') + repository = Gitlab::Git::Repository.new('default', 'does/not/exist.git', '', 'group/project') expect(repository.gitaly_repository_client).not_to receive(:write_config) @@ -1803,7 +1847,7 @@ describe Gitlab::Git::Repository, :seed_helper do out: '/dev/null', err: '/dev/null') - empty_repo = described_class.new('default', 'empty-repo.git', '') + empty_repo = described_class.new('default', 'empty-repo.git', '', 'group/empty-repo') expect(empty_repo.checksum).to eq '0000000000000000000000000000000000000000' end @@ -1818,13 +1862,13 @@ describe Gitlab::Git::Repository, :seed_helper do File.truncate(File.join(storage_path, 'non-valid.git/HEAD'), 0) - non_valid = described_class.new('default', 'non-valid.git', '') + non_valid = described_class.new('default', 'non-valid.git', '', 'a/non-valid') expect { non_valid.checksum }.to raise_error(Gitlab::Git::Repository::InvalidRepository) end it 'raises Gitlab::Git::Repository::NoRepository error when there is no repo' do - broken_repo = described_class.new('default', 'a/path.git', '') + broken_repo = described_class.new('default', 'a/path.git', '', 'a/path') expect { broken_repo.checksum }.to raise_error(Gitlab::Git::Repository::NoRepository) end @@ -1964,7 +2008,7 @@ describe Gitlab::Git::Repository, :seed_helper do end # Build the options hash that's passed to Rugged::Commit#create - def commit_options(repo, index, message) + def commit_options(repo, index, target, ref, message) options = {} options[:tree] = index.write_tree(repo) options[:author] = { @@ -1978,8 +2022,8 @@ describe Gitlab::Git::Repository, :seed_helper do time: Time.gm(2014, "mar", 3, 20, 15, 1) } options[:message] ||= message - options[:parents] = repo.empty? ? [] : [repo.head.target].compact - options[:update_ref] = "HEAD" + options[:parents] = repo.empty? ? [] : [target].compact + options[:update_ref] = ref options end @@ -1995,6 +2039,8 @@ describe Gitlab::Git::Repository, :seed_helper do options = commit_options( repo, index, + repo.head.target, + "HEAD", "Edit CHANGELOG in its original location" ) @@ -2003,19 +2049,24 @@ describe Gitlab::Git::Repository, :seed_helper do end # Writes a new commit to the repo and returns a Rugged::Commit. Replaces the - # contents of encoding/CHANGELOG with new text. - def new_commit_edit_new_file(repo) - oid = repo.write("I'm a new changelog with different text", :blob) + # contents of the specified file_path with new text. + def new_commit_edit_new_file(repo, file_path, commit_message, text, branch = repo.head) + oid = repo.write(text, :blob) index = repo.index - index.read_tree(repo.head.target.tree) - index.add(path: "encoding/CHANGELOG", oid: oid, mode: 0100644) - - options = commit_options(repo, index, "Edit encoding/CHANGELOG") - + index.read_tree(branch.target.tree) + index.add(path: file_path, oid: oid, mode: 0100644) + options = commit_options(repo, index, branch.target, branch.canonical_name, commit_message) sha = Rugged::Commit.create(repo, options) repo.lookup(sha) end + # Writes a new commit to the repo and returns a Rugged::Commit. Replaces the + # contents of encoding/CHANGELOG with new text. + def new_commit_edit_new_file_on_branch(repo, file_path, branch_name, commit_message, text) + branch = repo.branches[branch_name] + new_commit_edit_new_file(repo, file_path, commit_message, text, branch) + end + # Writes a new commit to the repo and returns a Rugged::Commit. Moves the # CHANGELOG file to the encoding/ directory. def new_commit_move_file(repo) @@ -2027,7 +2078,7 @@ describe Gitlab::Git::Repository, :seed_helper do index.add(path: "encoding/CHANGELOG", oid: oid, mode: 0100644) index.remove("CHANGELOG") - options = commit_options(repo, index, "Move CHANGELOG to encoding/") + options = commit_options(repo, index, repo.head.target, "HEAD", "Move CHANGELOG to encoding/") sha = Rugged::Commit.create(repo, options) repo.lookup(sha) diff --git a/spec/lib/gitlab/git/tag_spec.rb b/spec/lib/gitlab/git/tag_spec.rb index b51e3879f49..4c0291f64f0 100644 --- a/spec/lib/gitlab/git/tag_spec.rb +++ b/spec/lib/gitlab/git/tag_spec.rb @@ -1,7 +1,7 @@ require "spec_helper" describe Gitlab::Git::Tag, :seed_helper do - let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') } + let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') } describe '#tags' do describe 'first tag' do diff --git a/spec/lib/gitlab/git/tree_spec.rb b/spec/lib/gitlab/git/tree_spec.rb index bec875fb03d..4a4d69490a3 100644 --- a/spec/lib/gitlab/git/tree_spec.rb +++ b/spec/lib/gitlab/git/tree_spec.rb @@ -1,7 +1,7 @@ require "spec_helper" describe Gitlab::Git::Tree, :seed_helper do - let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') } + let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') } context :repo do let(:tree) { Gitlab::Git::Tree.where(repository, SeedRepo::Commit::ID) } diff --git a/spec/lib/gitlab/gitaly_client/remote_service_spec.rb b/spec/lib/gitlab/gitaly_client/remote_service_spec.rb index aff47599ad6..d5508dbff5d 100644 --- a/spec/lib/gitlab/gitaly_client/remote_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/remote_service_spec.rb @@ -33,7 +33,7 @@ describe Gitlab::GitalyClient::RemoteService do end describe '#fetch_internal_remote' do - let(:remote_repository) { Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '') } + let(:remote_repository) { Gitlab::Git::Repository.new('default', TEST_MUTABLE_REPO_PATH, '', 'group/project') } it 'sends an fetch_internal_remote message and returns the result value' do expect_any_instance_of(Gitaly::RemoteService::Stub) diff --git a/spec/lib/gitlab/gitaly_client/util_spec.rb b/spec/lib/gitlab/gitaly_client/util_spec.rb index 550db6db6d9..78a5e195ad1 100644 --- a/spec/lib/gitlab/gitaly_client/util_spec.rb +++ b/spec/lib/gitlab/gitaly_client/util_spec.rb @@ -7,6 +7,7 @@ describe Gitlab::GitalyClient::Util do let(:gl_repository) { 'project-1' } let(:git_object_directory) { '.git/objects' } let(:git_alternate_object_directory) { ['/dir/one', '/dir/two'] } + let(:gl_project_path) { 'namespace/myproject' } let(:git_env) do { 'GIT_OBJECT_DIRECTORY_RELATIVE' => git_object_directory, @@ -15,7 +16,7 @@ describe Gitlab::GitalyClient::Util do end subject do - described_class.repository(repository_storage, relative_path, gl_repository) + described_class.repository(repository_storage, relative_path, gl_repository, gl_project_path) end it 'creates a Gitaly::Repository with the given data' do @@ -27,6 +28,7 @@ describe Gitlab::GitalyClient::Util do expect(subject.gl_repository).to eq(gl_repository) expect(subject.git_object_directory).to eq(git_object_directory) expect(subject.git_alternate_object_directories).to eq(git_alternate_object_directory) + expect(subject.gl_project_path).to eq(gl_project_path) end end end diff --git a/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb b/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb index 77f5b2ffa37..47233ea6ee2 100644 --- a/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb @@ -5,6 +5,14 @@ describe Gitlab::GithubImport::Importer::RepositoryImporter do let(:import_state) { double(:import_state) } let(:client) { double(:client) } + let(:wiki) do + double( + :wiki, + disk_path: 'foo.wiki', + full_path: 'group/foo.wiki' + ) + end + let(:project) do double( :project, @@ -15,7 +23,9 @@ describe Gitlab::GithubImport::Importer::RepositoryImporter do repository: repository, create_wiki: true, import_state: import_state, - lfs_enabled?: true + full_path: 'group/foo', + lfs_enabled?: true, + wiki: wiki ) end @@ -195,7 +205,7 @@ describe Gitlab::GithubImport::Importer::RepositoryImporter do it 'imports the wiki repository' do expect(importer.gitlab_shell) .to receive(:import_repository) - .with('foo', 'foo.wiki', 'foo.wiki.git') + .with('foo', 'foo.wiki', 'foo.wiki.git', 'group/foo.wiki') expect(importer.import_wiki_repository).to eq(true) end diff --git a/spec/lib/gitlab/legacy_github_import/wiki_formatter_spec.rb b/spec/lib/gitlab/legacy_github_import/wiki_formatter_spec.rb index 7723533aee2..7519707293c 100644 --- a/spec/lib/gitlab/legacy_github_import/wiki_formatter_spec.rb +++ b/spec/lib/gitlab/legacy_github_import/wiki_formatter_spec.rb @@ -10,11 +10,17 @@ describe Gitlab::LegacyGithubImport::WikiFormatter do subject(:wiki) { described_class.new(project) } describe '#disk_path' do - it 'appends .wiki to project path' do + it 'appends .wiki to disk path' do expect(wiki.disk_path).to eq project.wiki.disk_path end end + describe '#full_path' do + it 'appends .wiki to project path' do + expect(wiki.full_path).to eq project.wiki.full_path + end + end + describe '#import_url' do it 'returns URL of the wiki repository' do expect(wiki.import_url).to eq 'https://xxx@github.com/gitlabhq/sample.gitlabhq.wiki.git' diff --git a/spec/lib/gitlab/metrics/samplers/unicorn_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/unicorn_sampler_spec.rb index 771b633a2b9..4b03f3c2532 100644 --- a/spec/lib/gitlab/metrics/samplers/unicorn_sampler_spec.rb +++ b/spec/lib/gitlab/metrics/samplers/unicorn_sampler_spec.rb @@ -37,7 +37,7 @@ describe Gitlab::Metrics::Samplers::UnicornSampler do end it 'updates metrics type unix and with addr' do - labels = { type: 'unix', address: socket_address } + labels = { socket_type: 'unix', socket_address: socket_address } expect(subject).to receive_message_chain(:unicorn_active_connections, :set).with(labels, 'active') expect(subject).to receive_message_chain(:unicorn_queued_connections, :set).with(labels, 'queued') @@ -69,7 +69,7 @@ describe Gitlab::Metrics::Samplers::UnicornSampler do end it 'updates metrics type unix and with addr' do - labels = { type: 'tcp', address: tcp_socket_address } + labels = { socket_type: 'tcp', socket_address: tcp_socket_address } expect(subject).to receive_message_chain(:unicorn_active_connections, :set).with(labels, 'active') expect(subject).to receive_message_chain(:unicorn_queued_connections, :set).with(labels, 'queued') diff --git a/spec/lib/gitlab/shell_spec.rb b/spec/lib/gitlab/shell_spec.rb index 6ce9d515a0f..033e1bf81a1 100644 --- a/spec/lib/gitlab/shell_spec.rb +++ b/spec/lib/gitlab/shell_spec.rb @@ -412,7 +412,7 @@ describe Gitlab::Shell do end it 'creates a repository' do - expect(gitlab_shell.create_repository(repository_storage, repo_name)).to be_truthy + expect(gitlab_shell.create_repository(repository_storage, repo_name, repo_name)).to be_truthy expect(File.stat(created_path).mode & 0o777).to eq(0o770) @@ -427,7 +427,7 @@ describe Gitlab::Shell do # should cause #create_repository to fail. FileUtils.touch(created_path) - expect(gitlab_shell.create_repository(repository_storage, repo_name)).to be_falsy + expect(gitlab_shell.create_repository(repository_storage, repo_name, repo_name)).to be_falsy end end @@ -474,13 +474,10 @@ describe Gitlab::Shell do end describe '#fork_repository' do + let(:target_project) { create(:project) } + subject do - gitlab_shell.fork_repository( - project.repository_storage, - project.disk_path, - 'nfs-file05', - 'fork/path' - ) + gitlab_shell.fork_repository(project, target_project) end it 'returns true when the command succeeds' do @@ -505,7 +502,7 @@ describe Gitlab::Shell do it 'returns true when the command succeeds' do expect_any_instance_of(Gitlab::GitalyClient::RepositoryService).to receive(:import_repository).with(import_url) - result = gitlab_shell.import_repository(project.repository_storage, project.disk_path, import_url) + result = gitlab_shell.import_repository(project.repository_storage, project.disk_path, import_url, project.full_path) expect(result).to be_truthy end @@ -516,7 +513,7 @@ describe Gitlab::Shell do expect_any_instance_of(Gitlab::Shell::GitalyGitlabProjects).to receive(:output) { 'error'} expect do - gitlab_shell.import_repository(project.repository_storage, project.disk_path, import_url) + gitlab_shell.import_repository(project.repository_storage, project.disk_path, import_url, project.full_path) end.to raise_error(Gitlab::Shell::Error, "error") end end diff --git a/spec/migrations/clean_up_for_members_spec.rb b/spec/migrations/clean_up_for_members_spec.rb index 7876536cb3e..1a79f94cf0d 100644 --- a/spec/migrations/clean_up_for_members_spec.rb +++ b/spec/migrations/clean_up_for_members_spec.rb @@ -2,6 +2,10 @@ require 'spec_helper' require Rails.root.join('db', 'migrate', '20171216111734_clean_up_for_members.rb') describe CleanUpForMembers, :migration do + before do + stub_feature_flags(enforced_sso: false) + end + let(:migration) { described_class.new } let(:groups) { table(:namespaces) } let!(:group_member) { create_group_member } diff --git a/spec/migrations/migrate_auto_dev_ops_domain_to_cluster_domain_spec.rb b/spec/migrations/migrate_auto_dev_ops_domain_to_cluster_domain_spec.rb new file mode 100644 index 00000000000..2ffc0e65fee --- /dev/null +++ b/spec/migrations/migrate_auto_dev_ops_domain_to_cluster_domain_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20190204115450_migrate_auto_dev_ops_domain_to_cluster_domain.rb') + +describe MigrateAutoDevOpsDomainToClusterDomain, :migration do + include MigrationHelpers::ClusterHelpers + + let(:migration) { described_class.new } + let(:project_auto_devops_table) { table(:project_auto_devops) } + let(:clusters_table) { table(:clusters) } + let(:cluster_projects_table) { table(:cluster_projects) } + + # Following lets are needed by MigrationHelpers::ClusterHelpers + let(:cluster_kubernetes_namespaces_table) { table(:clusters_kubernetes_namespaces) } + let(:projects_table) { table(:projects) } + let(:namespaces_table) { table(:namespaces) } + let(:provider_gcp_table) { table(:cluster_providers_gcp) } + let(:platform_kubernetes_table) { table(:cluster_platforms_kubernetes) } + + before do + setup_cluster_projects_with_domain(quantity: 20, domain: domain) + end + + context 'with ProjectAutoDevOps with no domain' do + let(:domain) { nil } + + it 'should not update cluster project' do + migrate! + + expect(clusters_without_domain.count).to eq(clusters_table.count) + end + end + + context 'with ProjectAutoDevOps with domain' do + let(:domain) { 'example-domain.com' } + + it 'should update all cluster projects' do + migrate! + + expect(clusters_with_domain.count).to eq(clusters_table.count) + end + end + + context 'when only some ProjectAutoDevOps have domain set' do + let(:domain) { 'example-domain.com' } + + before do + setup_cluster_projects_with_domain(quantity: 25, domain: nil) + end + + it 'should only update specific cluster projects' do + migrate! + + expect(clusters_with_domain.count).to eq(20) + + project_auto_devops_with_domain.each do |project_auto_devops| + cluster_project = Clusters::Project.find_by(project_id: project_auto_devops.project_id) + cluster = Clusters::Cluster.find(cluster_project.cluster_id) + + expect(cluster.domain).to be_present + end + + expect(clusters_without_domain.count).to eq(25) + + project_auto_devops_without_domain.each do |project_auto_devops| + cluster_project = Clusters::Project.find_by(project_id: project_auto_devops.project_id) + cluster = Clusters::Cluster.find(cluster_project.cluster_id) + + expect(cluster.domain).not_to be_present + end + end + end + + def setup_cluster_projects_with_domain(quantity:, domain:) + create_cluster_project_list(quantity) + + cluster_projects = cluster_projects_table.last(quantity) + + cluster_projects.each do |cluster_project| + specific_domain = "#{cluster_project.id}-#{domain}" if domain + + project_auto_devops_table.create( + project_id: cluster_project.project_id, + enabled: true, + domain: specific_domain + ) + end + end + + def project_auto_devops_with_domain + project_auto_devops_table.where.not("domain IS NULL OR domain = ''") + end + + def project_auto_devops_without_domain + project_auto_devops_table.where("domain IS NULL OR domain = ''") + end + + def clusters_with_domain + clusters_table.where.not("domain IS NULL OR domain = ''") + end + + def clusters_without_domain + clusters_table.where("domain IS NULL OR domain = ''") + end +end diff --git a/spec/models/application_record_spec.rb b/spec/models/application_record_spec.rb index ca23f581fdc..fd25132ed3a 100644 --- a/spec/models/application_record_spec.rb +++ b/spec/models/application_record_spec.rb @@ -11,7 +11,7 @@ describe ApplicationRecord do end end - describe '#safe_find_or_create_by' do + describe '.safe_find_or_create_by' do it 'creates the user avoiding race conditions' do expect(Suggestion).to receive(:find_or_create_by).and_raise(ActiveRecord::RecordNotUnique) allow(Suggestion).to receive(:find_or_create_by).and_call_original @@ -20,4 +20,17 @@ describe ApplicationRecord do .to change { Suggestion.count }.by(1) end end + + describe '.safe_find_or_create_by!' do + it 'creates a record using safe_find_or_create_by' do + expect(Suggestion).to receive(:find_or_create_by).and_call_original + + expect(Suggestion.safe_find_or_create_by!(build(:suggestion).attributes)) + .to be_a(Suggestion) + end + + it 'raises a validation error if the record was not persisted' do + expect { Suggestion.find_or_create_by!(note: nil) }.to raise_error(ActiveRecord::RecordInvalid) + end + end end diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index 96aa9a82b71..789e14e8a20 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -70,6 +70,13 @@ describe ApplicationSetting do .is_greater_than(0) end + it do + is_expected.to validate_numericality_of(:local_markdown_version) + .only_integer + .is_greater_than_or_equal_to(0) + .is_less_than(65536) + end + context 'key restrictions' do it 'supports all key types' do expect(described_class::SUPPORTED_KEY_TYPES).to contain_exactly(:rsa, :dsa, :ecdsa, :ed25519) diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb index 0161db740ee..92ce2b0999a 100644 --- a/spec/models/clusters/cluster_spec.rb +++ b/spec/models/clusters/cluster_spec.rb @@ -30,6 +30,7 @@ describe Clusters::Cluster do it { is_expected.to delegate_method(:available?).to(:application_ingress).with_prefix } it { is_expected.to delegate_method(:available?).to(:application_prometheus).with_prefix } it { is_expected.to delegate_method(:available?).to(:application_knative).with_prefix } + it { is_expected.to delegate_method(:external_ip).to(:application_ingress).with_prefix } it { is_expected.to respond_to :project } @@ -514,4 +515,108 @@ describe Clusters::Cluster do it { is_expected.to be_falsey } end end + + describe '#kube_ingress_domain' do + let(:cluster) { create(:cluster, :provided_by_gcp) } + + subject { cluster.kube_ingress_domain } + + context 'with domain set in cluster' do + let(:cluster) { create(:cluster, :provided_by_gcp, :with_domain) } + + it { is_expected.to eq(cluster.domain) } + end + + context 'with no domain on cluster' do + context 'with a project cluster' do + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } + + context 'with domain set at instance level' do + before do + stub_application_setting(auto_devops_domain: 'global_domain.com') + + it { is_expected.to eq('global_domain.com') } + end + end + + context 'with domain set on ProjectAutoDevops' do + before do + auto_devops = project.build_auto_devops(domain: 'legacy-ado-domain.com') + auto_devops.save + end + + it { is_expected.to eq('legacy-ado-domain.com') } + end + + context 'with domain set as environment variable on project' do + before do + variable = project.variables.build(key: 'AUTO_DEVOPS_DOMAIN', value: 'project-ado-domain.com') + variable.save + end + + it { is_expected.to eq('project-ado-domain.com') } + end + + context 'with domain set as environment variable on the group project' do + let(:group) { create(:group) } + + before do + project.update(parent_id: group.id) + variable = group.variables.build(key: 'AUTO_DEVOPS_DOMAIN', value: 'group-ado-domain.com') + variable.save + end + + it { is_expected.to eq('group-ado-domain.com') } + end + end + + context 'with a group cluster' do + let(:cluster) { create(:cluster, :group, :provided_by_gcp) } + + context 'with domain set as environment variable for the group' do + let(:group) { cluster.group } + + before do + variable = group.variables.build(key: 'AUTO_DEVOPS_DOMAIN', value: 'group-ado-domain.com') + variable.save + end + + it { is_expected.to eq('group-ado-domain.com') } + end + end + end + end + + describe '#predefined_variables' do + subject { cluster.predefined_variables } + + context 'with an instance domain' do + let(:cluster) { create(:cluster, :provided_by_gcp) } + + before do + stub_application_setting(auto_devops_domain: 'global_domain.com') + end + + it 'should include KUBE_INGRESS_BASE_DOMAIN' do + expect(subject.to_hash).to include(KUBE_INGRESS_BASE_DOMAIN: 'global_domain.com') + end + end + + context 'with a cluster domain' do + let(:cluster) { create(:cluster, :provided_by_gcp, domain: 'example.com') } + + it 'should include KUBE_INGRESS_BASE_DOMAIN' do + expect(subject.to_hash).to include(KUBE_INGRESS_BASE_DOMAIN: 'example.com') + end + end + + context 'with no domain' do + let(:cluster) { create(:cluster, :provided_by_gcp, :project) } + + it 'should return an empty array' do + expect(subject.to_hash).to be_empty + end + end + end end diff --git a/spec/models/clusters/platforms/kubernetes_spec.rb b/spec/models/clusters/platforms/kubernetes_spec.rb index 6c8a223092e..c273fa7e164 100644 --- a/spec/models/clusters/platforms/kubernetes_spec.rb +++ b/spec/models/clusters/platforms/kubernetes_spec.rb @@ -297,6 +297,19 @@ describe Clusters::Platforms::Kubernetes, :use_clean_rails_memory_store_caching end end end + + context 'with a domain' do + let!(:cluster) do + create(:cluster, :provided_by_gcp, :with_domain, + platform_kubernetes: kubernetes) + end + + it 'sets KUBE_INGRESS_BASE_DOMAIN' do + expect(subject).to include( + { key: 'KUBE_INGRESS_BASE_DOMAIN', value: cluster.domain, public: true } + ) + end + end end describe '#terminals' do diff --git a/spec/models/concerns/cache_markdown_field_spec.rb b/spec/models/concerns/cache_markdown_field_spec.rb index 29197ef372e..447279f19a8 100644 --- a/spec/models/concerns/cache_markdown_field_spec.rb +++ b/spec/models/concerns/cache_markdown_field_spec.rb @@ -60,6 +60,10 @@ describe CacheMarkdownField do changes_applied end end + + def has_attribute?(attr_name) + attribute_names.include?(attr_name) + end end def thing_subclass(new_attr) @@ -72,8 +76,8 @@ describe CacheMarkdownField do let(:updated_markdown) { '`Bar`' } let(:updated_html) { '<p dir="auto"><code>Bar</code></p>' } - let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: CacheMarkdownField::CACHE_COMMONMARK_VERSION) } - let(:cache_version) { CacheMarkdownField::CACHE_COMMONMARK_VERSION } + let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) } + let(:cache_version) { CacheMarkdownField::CACHE_COMMONMARK_VERSION << 16 } before do stub_commonmark_sourcepos_disabled @@ -94,11 +98,11 @@ describe CacheMarkdownField do it { expect(thing.foo).to eq(markdown) } it { expect(thing.foo_html).to eq(html) } it { expect(thing.foo_html_changed?).not_to be_truthy } - it { expect(thing.cached_markdown_version).to eq(CacheMarkdownField::CACHE_COMMONMARK_VERSION) } + it { expect(thing.cached_markdown_version).to eq(cache_version) } end context 'a changed markdown field' do - let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) } + let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version - 1) } before do thing.foo = updated_markdown @@ -139,7 +143,7 @@ describe CacheMarkdownField do end context 'a non-markdown field changed' do - let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version) } + let(:thing) { ThingWithMarkdownFields.new(foo: markdown, foo_html: html, cached_markdown_version: cache_version - 1) } before do thing.bar = 'OK' @@ -160,7 +164,7 @@ describe CacheMarkdownField do end it { expect(thing.foo_html).to eq(updated_html) } - it { expect(thing.cached_markdown_version).to eq(CacheMarkdownField::CACHE_COMMONMARK_VERSION) } + it { expect(thing.cached_markdown_version).to eq(cache_version) } end describe '#cached_html_up_to_date?' do @@ -174,21 +178,35 @@ describe CacheMarkdownField do is_expected.to be_falsy end - it 'returns false when the version is too early' do - thing.cached_markdown_version -= 1 + it 'returns false when the cached version is too old' do + thing.cached_markdown_version = cache_version - 1 is_expected.to be_falsy end - it 'returns false when the version is too late' do - thing.cached_markdown_version += 1 + it 'returns false when the cached version is in future' do + thing.cached_markdown_version = cache_version + 1 is_expected.to be_falsy end - it 'returns true when the version is just right' do + it 'returns false when the local version was bumped' do + allow(Gitlab::CurrentSettings.current_application_settings).to receive(:local_markdown_version).and_return(2) thing.cached_markdown_version = cache_version + is_expected.to be_falsy + end + + it 'returns true when the local version is default' do + thing.cached_markdown_version = cache_version + + is_expected.to be_truthy + end + + it 'returns true when the cached version is just right' do + allow(Gitlab::CurrentSettings.current_application_settings).to receive(:local_markdown_version).and_return(2) + thing.cached_markdown_version = cache_version + 2 + is_expected.to be_truthy end @@ -221,14 +239,9 @@ describe CacheMarkdownField do describe '#latest_cached_markdown_version' do subject { thing.latest_cached_markdown_version } - it 'returns commonmark version' do - thing.cached_markdown_version = CacheMarkdownField::CACHE_COMMONMARK_VERSION_START + 1 - is_expected.to eq(CacheMarkdownField::CACHE_COMMONMARK_VERSION) - end - - it 'returns default version when version is nil' do + it 'returns default version' do thing.cached_markdown_version = nil - is_expected.to eq(CacheMarkdownField::CACHE_COMMONMARK_VERSION) + is_expected.to eq(cache_version) end end @@ -255,7 +268,7 @@ describe CacheMarkdownField do thing.cached_markdown_version = nil thing.refresh_markdown_cache - expect(thing.cached_markdown_version).to eq(CacheMarkdownField::CACHE_COMMONMARK_VERSION) + expect(thing.cached_markdown_version).to eq(cache_version) end end @@ -336,7 +349,7 @@ describe CacheMarkdownField do expect(thing.foo_html).to eq(updated_html) expect(thing.baz_html).to eq(updated_html) - expect(thing.cached_markdown_version).to eq(CacheMarkdownField::CACHE_COMMONMARK_VERSION) + expect(thing.cached_markdown_version).to eq(cache_version) end end @@ -356,7 +369,7 @@ describe CacheMarkdownField do expect(thing.foo_html).to eq(updated_html) expect(thing.baz_html).to eq(updated_html) - expect(thing.cached_markdown_version).to eq(CacheMarkdownField::CACHE_COMMONMARK_VERSION) + expect(thing.cached_markdown_version).to eq(cache_version) end end end diff --git a/spec/models/gpg_signature_spec.rb b/spec/models/gpg_signature_spec.rb index cdd7dea2064..e90319c39b1 100644 --- a/spec/models/gpg_signature_spec.rb +++ b/spec/models/gpg_signature_spec.rb @@ -23,6 +23,41 @@ RSpec.describe GpgSignature do it { is_expected.to validate_presence_of(:gpg_key_primary_keyid) } end + describe '.safe_create!' do + let(:attributes) do + { + commit_sha: commit_sha, + project: project, + gpg_key_primary_keyid: gpg_key.keyid + } + end + + it 'finds a signature by commit sha if it existed' do + gpg_signature + + expect(described_class.safe_create!(commit_sha: commit_sha)).to eq(gpg_signature) + end + + it 'creates a new signature if it was not found' do + expect { described_class.safe_create!(attributes) }.to change { described_class.count }.by(1) + end + + it 'assigns the correct attributes when creating' do + signature = described_class.safe_create!(attributes) + + expect(signature.project).to eq(project) + expect(signature.commit_sha).to eq(commit_sha) + expect(signature.gpg_key_primary_keyid).to eq(gpg_key.keyid) + end + + it 'does not raise an error in case of a race condition' do + expect(described_class).to receive(:find_or_create_by).and_raise(ActiveRecord::RecordNotUnique) + allow(described_class).to receive(:find_or_create_by).and_call_original + + described_class.safe_create!(attributes) + end + end + describe '#commit' do it 'fetches the commit through the project' do expect_any_instance_of(Project).to receive(:commit).with(commit_sha).and_return(commit) diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index ae137aa7b78..c1767ed0535 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -1765,7 +1765,7 @@ describe Project do context 'using a regular repository' do it 'creates the repository' do expect(shell).to receive(:create_repository) - .with(project.repository_storage, project.disk_path) + .with(project.repository_storage, project.disk_path, project.full_path) .and_return(true) expect(project.repository).to receive(:after_create) @@ -1775,7 +1775,7 @@ describe Project do it 'adds an error if the repository could not be created' do expect(shell).to receive(:create_repository) - .with(project.repository_storage, project.disk_path) + .with(project.repository_storage, project.disk_path, project.full_path) .and_return(false) expect(project.repository).not_to receive(:after_create) @@ -1808,7 +1808,7 @@ describe Project do .and_return(false) allow(shell).to receive(:create_repository) - .with(project.repository_storage, project.disk_path) + .with(project.repository_storage, project.disk_path, project.full_path) .and_return(true) expect(project).to receive(:create_repository).with(force: true) @@ -1832,7 +1832,7 @@ describe Project do .and_return(false) expect(shell).to receive(:create_repository) - .with(project.repository_storage, project.disk_path) + .with(project.repository_storage, project.disk_path, project.full_path) .and_return(true) project.ensure_repository diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb index 48a43801b9f..3ccc706edf2 100644 --- a/spec/models/project_wiki_spec.rb +++ b/spec/models/project_wiki_spec.rb @@ -7,7 +7,7 @@ describe ProjectWiki do let(:repository) { project.repository } let(:gitlab_shell) { Gitlab::Shell.new } let(:project_wiki) { described_class.new(project, user) } - let(:raw_repository) { Gitlab::Git::Repository.new(project.repository_storage, subject.disk_path + '.git', 'foo') } + let(:raw_repository) { Gitlab::Git::Repository.new(project.repository_storage, subject.disk_path + '.git', 'foo', 'group/project.wiki') } let(:commit) { project_wiki.repository.head_commit } subject { project_wiki } @@ -75,7 +75,7 @@ describe ProjectWiki do # Create a fresh project which will not have a wiki project_wiki = described_class.new(create(:project), user) gitlab_shell = double(:gitlab_shell) - allow(gitlab_shell).to receive(:create_repository) + allow(gitlab_shell).to receive(:create_wiki_repository) allow(project_wiki).to receive(:gitlab_shell).and_return(gitlab_shell) expect { project_wiki.send(:wiki) }.to raise_exception(ProjectWiki::CouldNotCreateWikiError) diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 4978c43c9b5..f78760bf567 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -2291,6 +2291,7 @@ describe Repository do expect(subject).to be_a(Gitlab::Git::Repository) expect(subject.relative_path).to eq(project.disk_path + '.git') expect(subject.gl_repository).to eq("project-#{project.id}") + expect(subject.gl_project_path).to eq(project.full_path) end context 'with a wiki repository' do @@ -2300,6 +2301,7 @@ describe Repository do expect(subject).to be_a(Gitlab::Git::Repository) expect(subject.relative_path).to eq(project.disk_path + '.wiki.git') expect(subject.gl_repository).to eq("wiki-#{project.id}") + expect(subject.gl_project_path).to eq(project.full_path) end end end diff --git a/spec/models/resource_label_event_spec.rb b/spec/models/resource_label_event_spec.rb index 3797960ac3d..7eeb2fae57d 100644 --- a/spec/models/resource_label_event_spec.rb +++ b/spec/models/resource_label_event_spec.rb @@ -81,14 +81,14 @@ RSpec.describe ResourceLabelEvent, type: :model do expect(subject.outdated_markdown?).to be true end - it 'returns true markdown is outdated' do - subject.attributes = { cached_markdown_version: 0 } + it 'returns true if markdown is outdated' do + subject.attributes = { cached_markdown_version: ((CacheMarkdownField::CACHE_COMMONMARK_VERSION - 1) << 16) | 0 } expect(subject.outdated_markdown?).to be true end it 'returns false if label and reference are set' do - subject.attributes = { reference: 'whatever', cached_markdown_version: CacheMarkdownField::CACHE_COMMONMARK_VERSION } + subject.attributes = { reference: 'whatever', cached_markdown_version: CacheMarkdownField::CACHE_COMMONMARK_VERSION << 16 } expect(subject.outdated_markdown?).to be false end diff --git a/spec/presenters/blob_presenter_spec.rb b/spec/presenters/blob_presenter_spec.rb index e85e7a41017..bb1db9a3d51 100644 --- a/spec/presenters/blob_presenter_spec.rb +++ b/spec/presenters/blob_presenter_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe BlobPresenter, :seed_helper do - let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') } + let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') } let(:git_blob) do Gitlab::Git::Blob.find( diff --git a/spec/requests/api/group_labels_spec.rb b/spec/requests/api/group_labels_spec.rb new file mode 100644 index 00000000000..3769f8b78e4 --- /dev/null +++ b/spec/requests/api/group_labels_spec.rb @@ -0,0 +1,258 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe API::GroupLabels do + let(:user) { create(:user) } + let(:group) { create(:group) } + let!(:group_member) { create(:group_member, group: group, user: user) } + let!(:label1) { create(:group_label, title: 'feature', group: group) } + let!(:label2) { create(:group_label, title: 'bug', group: group) } + + describe 'GET :id/labels' do + it 'returns all available labels for the group' do + get api("/groups/#{group.id}/labels", user) + + expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/group_labels') + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(2) + expect(json_response.map {|r| r['name'] }).to contain_exactly('feature', 'bug') + end + end + + describe 'POST /groups/:id/labels' do + it 'returns created label when all params are given' do + post api("/groups/#{group.id}/labels", user), + params: { + name: 'Foo', + color: '#FFAABB', + description: 'test' + } + + expect(response).to have_gitlab_http_status(201) + expect(json_response['name']).to eq('Foo') + expect(json_response['color']).to eq('#FFAABB') + expect(json_response['description']).to eq('test') + end + + it 'returns created label when only required params are given' do + post api("/groups/#{group.id}/labels", user), + params: { + name: 'Foo & Bar', + color: '#FFAABB' + } + + expect(response.status).to eq(201) + expect(json_response['name']).to eq('Foo & Bar') + expect(json_response['color']).to eq('#FFAABB') + expect(json_response['description']).to be_nil + end + + it 'returns a 400 bad request if name not given' do + post api("/groups/#{group.id}/labels", user), params: { color: '#FFAABB' } + + expect(response).to have_gitlab_http_status(400) + end + + it 'returns a 400 bad request if color is not given' do + post api("/groups/#{group.id}/labels", user), params: { name: 'Foobar' } + + expect(response).to have_gitlab_http_status(400) + end + + it 'returns 409 if label already exists' do + post api("/groups/#{group.id}/labels", user), + params: { + name: label1.name, + color: '#FFAABB' + } + + expect(response).to have_gitlab_http_status(409) + expect(json_response['message']).to eq('Label already exists') + end + end + + describe 'DELETE /groups/:id/labels' do + it 'returns 204 for existing label' do + delete api("/groups/#{group.id}/labels", user), params: { name: label1.name } + + expect(response).to have_gitlab_http_status(204) + end + + it 'returns 404 for non existing label' do + delete api("/groups/#{group.id}/labels", user), params: { name: 'label2' } + + expect(response).to have_gitlab_http_status(404) + expect(json_response['message']).to eq('404 Label Not Found') + end + + it 'returns 400 for wrong parameters' do + delete api("/groups/#{group.id}/labels", user) + + expect(response).to have_gitlab_http_status(400) + end + + it "does not delete parent's group labels", :nested_groups do + subgroup = create(:group, parent: group) + subgroup_label = create(:group_label, title: 'feature', group: subgroup) + + delete api("/groups/#{subgroup.id}/labels", user), params: { name: subgroup_label.name } + + expect(response).to have_gitlab_http_status(204) + expect(subgroup.labels.size).to eq(0) + expect(group.labels).to include(label1) + end + + it_behaves_like '412 response' do + let(:request) { api("/groups/#{group.id}/labels", user) } + let(:params) { { name: label1.name } } + end + end + + describe 'PUT /groups/:id/labels' do + it 'returns 200 if name and colors and description are changed' do + put api("/groups/#{group.id}/labels", user), + params: { + name: label1.name, + new_name: 'New Label', + color: '#FFFFFF', + description: 'test' + } + + expect(response).to have_gitlab_http_status(200) + expect(json_response['name']).to eq('New Label') + expect(json_response['color']).to eq('#FFFFFF') + expect(json_response['description']).to eq('test') + end + + it "does not update parent's group label", :nested_groups do + subgroup = create(:group, parent: group) + subgroup_label = create(:group_label, title: 'feature', group: subgroup) + + put api("/groups/#{subgroup.id}/labels", user), + params: { + name: subgroup_label.name, + new_name: 'New Label' + } + + expect(response).to have_gitlab_http_status(200) + expect(subgroup.labels[0].name).to eq('New Label') + expect(label1.name).to eq('feature') + end + + it 'returns 404 if label does not exist' do + put api("/groups/#{group.id}/labels", user), + params: { + name: 'label2', + new_name: 'label3' + } + + expect(response).to have_gitlab_http_status(404) + end + + it 'returns 400 if no label name given' do + put api("/groups/#{group.id}/labels", user), params: { new_name: label1.name } + + expect(response).to have_gitlab_http_status(400) + expect(json_response['error']).to eq('name is missing') + end + + it 'returns 400 if no new parameters given' do + put api("/groups/#{group.id}/labels", user), params: { name: label1.name } + + expect(response).to have_gitlab_http_status(400) + expect(json_response['error']).to eq('new_name, color, description are missing, '\ + 'at least one parameter must be provided') + end + end + + describe 'POST /groups/:id/labels/:label_id/subscribe' do + context 'when label_id is a label title' do + it 'subscribes to the label' do + post api("/groups/#{group.id}/labels/#{label1.title}/subscribe", user) + + expect(response).to have_gitlab_http_status(201) + expect(json_response['name']).to eq(label1.title) + expect(json_response['subscribed']).to be_truthy + end + end + + context 'when label_id is a label ID' do + it 'subscribes to the label' do + post api("/groups/#{group.id}/labels/#{label1.id}/subscribe", user) + + expect(response).to have_gitlab_http_status(201) + expect(json_response['name']).to eq(label1.title) + expect(json_response['subscribed']).to be_truthy + end + end + + context 'when user is already subscribed to label' do + before do + label1.subscribe(user) + end + + it 'returns 304' do + post api("/groups/#{group.id}/labels/#{label1.id}/subscribe", user) + + expect(response).to have_gitlab_http_status(304) + end + end + + context 'when label ID is not found' do + it 'returns 404 error' do + post api("/groups/#{group.id}/labels/1234/subscribe", user) + + expect(response).to have_gitlab_http_status(404) + end + end + end + + describe 'POST /groups/:id/labels/:label_id/unsubscribe' do + before do + label1.subscribe(user) + end + + context 'when label_id is a label title' do + it 'unsubscribes from the label' do + post api("/groups/#{group.id}/labels/#{label1.title}/unsubscribe", user) + + expect(response).to have_gitlab_http_status(201) + expect(json_response['name']).to eq(label1.title) + expect(json_response['subscribed']).to be_falsey + end + end + + context 'when label_id is a label ID' do + it 'unsubscribes from the label' do + post api("/groups/#{group.id}/labels/#{label1.id}/unsubscribe", user) + + expect(response).to have_gitlab_http_status(201) + expect(json_response['name']).to eq(label1.title) + expect(json_response['subscribed']).to be_falsey + end + end + + context 'when user is already unsubscribed from label' do + before do + label1.unsubscribe(user) + end + + it 'returns 304' do + post api("/groups/#{group.id}/labels/#{label1.id}/unsubscribe", user) + + expect(response).to have_gitlab_http_status(304) + end + end + + context 'when label ID is not found' do + it 'returns 404 error' do + post api("/groups/#{group.id}/labels/1234/unsubscribe", user) + + expect(response).to have_gitlab_http_status(404) + end + end + end +end diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 51343287a13..0f5f6e38819 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -951,6 +951,29 @@ describe API::MergeRequests do expect(response).to have_gitlab_http_status(404) end + + describe "the squash_commit_message param" do + let(:squash_commit) do + project.repository.commits_between(json_response['diff_refs']['start_sha'], json_response['merge_commit_sha']).first + end + + it "results in a specific squash commit message when set" do + squash_commit_message = 'My custom squash commit message' + + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user), params: { + squash: true, + squash_commit_message: squash_commit_message + } + + expect(squash_commit.message.chomp).to eq(squash_commit_message) + end + + it "results in a default squash commit message when not set" do + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user), params: { squash: true } + + expect(squash_commit.message).to eq(merge_request.default_squash_commit_message) + end + end end describe "PUT /projects/:id/merge_requests/:merge_request_iid" do diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb index 45fb1562e84..f33eb5b9e02 100644 --- a/spec/requests/api/settings_spec.rb +++ b/spec/requests/api/settings_spec.rb @@ -64,7 +64,8 @@ describe API::Settings, 'Settings' do performance_bar_allowed_group_path: group.full_path, instance_statistics_visibility_private: true, diff_max_patch_bytes: 150_000, - default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_MERGE + default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_MERGE, + local_markdown_version: 3 } expect(response).to have_gitlab_http_status(200) @@ -90,6 +91,7 @@ describe API::Settings, 'Settings' do expect(json_response['instance_statistics_visibility_private']).to be(true) expect(json_response['diff_max_patch_bytes']).to eq(150_000) expect(json_response['default_branch_protection']).to eq(Gitlab::Access::PROTECTION_DEV_CAN_MERGE) + expect(json_response['local_markdown_version']).to eq(3) end end diff --git a/spec/requests/openid_connect_spec.rb b/spec/requests/openid_connect_spec.rb index 2b148c1b563..2a455523e2c 100644 --- a/spec/requests/openid_connect_spec.rb +++ b/spec/requests/openid_connect_spec.rb @@ -35,7 +35,7 @@ describe 'OpenID Connect requests' do 'name' => 'Alice', 'nickname' => 'alice', 'email' => 'public@example.com', - 'email_verified' => true, + 'email_verified' => false, 'website' => 'https://example.com', 'profile' => 'http://localhost/alice', 'picture' => "http://localhost/uploads/-/system/user/avatar/#{user.id}/dk.png", @@ -111,6 +111,18 @@ describe 'OpenID Connect requests' do it 'does not include any unknown claims' do expect(json_response.keys).to eq %w[sub sub_legacy] + user_info_claims.keys end + + it 'includes email and email_verified claims' do + expect(json_response.keys).to include('email', 'email_verified') + end + + it 'has public email in email claim' do + expect(json_response['email']).to eq(user.public_email) + end + + it 'has false in email_verified claim' do + expect(json_response['email_verified']).to eq(false) + end end context 'ID token payload' do @@ -175,7 +187,35 @@ describe 'OpenID Connect requests' do expect(response).to have_gitlab_http_status(200) expect(json_response['issuer']).to eq('http://localhost') expect(json_response['jwks_uri']).to eq('http://www.example.com/oauth/discovery/keys') - expect(json_response['scopes_supported']).to eq(%w[api read_user sudo read_repository openid]) + expect(json_response['scopes_supported']).to eq(%w[api read_user sudo read_repository openid profile email]) + end + end + + context 'Application with OpenID and email scopes' do + let(:application) { create :oauth_application, scopes: 'openid email' } + + it 'token response includes an ID token' do + request_access_token! + + expect(json_response).to include 'id_token' + end + + context 'UserInfo payload' do + before do + request_user_info! + end + + it 'includes the email and email_verified claims' do + expect(json_response.keys).to include('email', 'email_verified') + end + + it 'has private email in email claim' do + expect(json_response['email']).to eq(user.email) + end + + it 'has true in email_verified claim' do + expect(json_response['email_verified']).to eq(true) + end end end end diff --git a/spec/services/error_tracking/list_projects_service_spec.rb b/spec/services/error_tracking/list_projects_service_spec.rb new file mode 100644 index 00000000000..ee9c59e3f65 --- /dev/null +++ b/spec/services/error_tracking/list_projects_service_spec.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ErrorTracking::ListProjectsService do + set(:user) { create(:user) } + set(:project) { create(:project) } + + let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' } + let(:token) { 'test-token' } + let(:new_api_host) { 'https://gitlab.com/' } + let(:new_token) { 'new-token' } + let(:params) { ActionController::Parameters.new(api_host: new_api_host, token: new_token) } + + let(:error_tracking_setting) do + create(:project_error_tracking_setting, api_url: sentry_url, token: token, project: project) + end + + subject { described_class.new(project, user, params) } + + before do + project.add_reporter(user) + end + + describe '#execute' do + let(:result) { subject.execute } + + context 'with authorized user' do + before do + expect(project).to receive(:error_tracking_setting).at_least(:once) + .and_return(error_tracking_setting) + end + + context 'set model attributes to new values' do + let(:new_api_url) { new_api_host + 'api/0/projects/' } + + before do + expect(error_tracking_setting).to receive(:list_sentry_projects) + .and_return({ projects: [] }) + end + + it 'uses new api_url and token' do + subject.execute + + expect(error_tracking_setting.api_url).to eq(new_api_url) + expect(error_tracking_setting.token).to eq(new_token) + error_tracking_setting.reload + expect(error_tracking_setting.api_url).to eq(sentry_url) + expect(error_tracking_setting.token).to eq(token) + end + end + + context 'sentry client raises exception' do + before do + expect(error_tracking_setting).to receive(:list_sentry_projects) + .and_raise(Sentry::Client::Error, 'Sentry response error: 500') + end + + it 'returns error response' do + expect(result[:message]).to eq('Sentry response error: 500') + expect(result[:http_status]).to eq(:bad_request) + end + end + + context 'with invalid url' do + let(:params) do + ActionController::Parameters.new( + api_host: 'https://localhost', + token: new_token + ) + end + + before do + error_tracking_setting.enabled = false + end + + it 'returns error' do + expect(result[:message]).to start_with('Api url is blocked') + expect(error_tracking_setting).not_to be_valid + end + end + + context 'when list_sentry_projects returns projects' do + let(:projects) { [:list, :of, :projects] } + + before do + expect(error_tracking_setting) + .to receive(:list_sentry_projects).and_return(projects: projects) + end + + it 'returns the projects' do + expect(result).to eq(status: :success, projects: projects) + end + end + end + + context 'with unauthorized user' do + before do + project.add_guest(user) + end + + it 'returns error' do + expect(result).to include(status: :error, message: 'access denied') + end + end + + context 'with error tracking disabled' do + before do + expect(project).to receive(:error_tracking_setting).at_least(:once) + .and_return(error_tracking_setting) + expect(error_tracking_setting) + .to receive(:list_sentry_projects).and_return(projects: []) + + error_tracking_setting.enabled = false + end + + it 'ignores enabled flag' do + expect(result).to include(status: :success, projects: []) + end + end + + context 'error_tracking_setting is nil' do + let(:error_tracking_setting) { build(:project_error_tracking_setting) } + let(:new_api_url) { new_api_host + 'api/0/projects/' } + + before do + expect(project).to receive(:build_error_tracking_setting).once + .and_return(error_tracking_setting) + + expect(error_tracking_setting).to receive(:list_sentry_projects) + .and_return(projects: [:project1, :project2]) + end + + it 'builds a new error_tracking_setting' do + expect(project.error_tracking_setting).to be_nil + + expect(result[:projects]).to eq([:project1, :project2]) + + expect(error_tracking_setting.api_url).to eq(new_api_url) + expect(error_tracking_setting.token).to eq(new_token) + expect(error_tracking_setting.enabled).to be true + expect(error_tracking_setting.persisted?).to be false + expect(error_tracking_setting.project_id).not_to be_nil + + expect(project.error_tracking_setting).to be_nil + end + end + end +end diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index ef76e2311b1..931e47d3a77 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -471,6 +471,8 @@ describe Issues::UpdateService, :mailer do it { expect(issue.tasks?).to eq(true) } + it_behaves_like 'updating a single task' + context 'when tasks are marked as completed' do before do update_issue(description: "- [x] Task 1\n- [X] Task 2") @@ -543,76 +545,6 @@ describe Issues::UpdateService, :mailer do end end - context 'when updating a single task' do - before do - update_issue(description: "- [ ] Task 1\n- [ ] Task 2") - end - - it { expect(issue.tasks?).to eq(true) } - - context 'when a task is marked as completed' do - before do - update_issue(update_task: { index: 1, checked: true, line_source: '- [ ] Task 1', line_number: 1 }) - end - - it 'creates system note about task status change' do - note1 = find_note('marked the task **Task 1** as completed') - - expect(note1).not_to be_nil - - description_notes = find_notes('description') - expect(description_notes.length).to eq(1) - end - end - - context 'when a task is marked as incomplete' do - before do - update_issue(description: "- [x] Task 1\n- [X] Task 2") - update_issue(update_task: { index: 2, checked: false, line_source: '- [X] Task 2', line_number: 2 }) - end - - it 'creates system note about task status change' do - note1 = find_note('marked the task **Task 2** as incomplete') - - expect(note1).not_to be_nil - - description_notes = find_notes('description') - expect(description_notes.length).to eq(1) - end - end - - context 'when the task position has been modified' do - before do - update_issue(description: "- [ ] Task 1\n- [ ] Task 3\n- [ ] Task 2") - end - - it 'raises an exception' do - expect(Note.count).to eq(2) - expect do - update_issue(update_task: { index: 2, checked: true, line_source: '- [ ] Task 2', line_number: 2 }) - end.to raise_error(ActiveRecord::StaleObjectError) - expect(Note.count).to eq(2) - end - end - - context 'when the content changes but not task line number' do - before do - update_issue(description: "Paragraph\n\n- [ ] Task 1\n- [x] Task 2") - update_issue(description: "Paragraph with more words\n\n- [ ] Task 1\n- [x] Task 2") - update_issue(update_task: { index: 2, checked: false, line_source: '- [x] Task 2', line_number: 4 }) - end - - it 'creates system note about task status change' do - note1 = find_note('marked the task **Task 2** as incomplete') - - expect(note1).not_to be_nil - - description_notes = find_notes('description') - expect(description_notes.length).to eq(2) - end - end - end - context 'updating labels' do let(:label3) { create(:label, project: project) } let(:result) { described_class.new(project, user, params).execute(issue).reload } diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index be5ad849ba7..20580bf14b9 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -466,6 +466,8 @@ describe MergeRequests::UpdateService, :mailer do it { expect(@merge_request.tasks?).to eq(true) } + it_behaves_like 'updating a single task' + context 'when tasks are marked as completed' do before do update_merge_request({ description: "- [x] Task 1\n- [X] Task 2" }) diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index 54ce33dd103..d1b110b9806 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -116,7 +116,7 @@ describe Projects::CreateService, '#execute' do def wiki_repo(project) relative_path = ProjectWiki.new(project).disk_path + '.git' - Gitlab::Git::Repository.new(project.repository_storage, relative_path, 'foobar') + Gitlab::Git::Repository.new(project.repository_storage, relative_path, 'foobar', project.full_path) end end @@ -198,7 +198,7 @@ describe Projects::CreateService, '#execute' do context 'with legacy storage' do before do - gitlab_shell.create_repository(repository_storage, "#{user.namespace.full_path}/existing") + gitlab_shell.create_repository(repository_storage, "#{user.namespace.full_path}/existing", 'group/project') end after do @@ -234,7 +234,7 @@ describe Projects::CreateService, '#execute' do end before do - gitlab_shell.create_repository(repository_storage, hashed_path) + gitlab_shell.create_repository(repository_storage, hashed_path, 'group/project') end after do diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb index 26e8d829345..23ec29cce7b 100644 --- a/spec/services/projects/fork_service_spec.rb +++ b/spec/services/projects/fork_service_spec.rb @@ -119,7 +119,7 @@ describe Projects::ForkService do let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage].legacy_disk_path } before do - gitlab_shell.create_repository(repository_storage, "#{@to_user.namespace.full_path}/#{@from_project.path}") + gitlab_shell.create_repository(repository_storage, "#{@to_user.namespace.full_path}/#{@from_project.path}", "#{@to_user.namespace.full_path}/#{@from_project.path}") end after do diff --git a/spec/services/projects/transfer_service_spec.rb b/spec/services/projects/transfer_service_spec.rb index 766276fdba3..aae50d5307f 100644 --- a/spec/services/projects/transfer_service_spec.rb +++ b/spec/services/projects/transfer_service_spec.rb @@ -201,7 +201,7 @@ describe Projects::TransferService do before do group.add_owner(user) - unless gitlab_shell.create_repository(repository_storage, "#{group.full_path}/#{project.path}") + unless gitlab_shell.create_repository(repository_storage, "#{group.full_path}/#{project.path}", project.full_path) raise 'failed to add repository' end diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index 8adfc63222e..90eaea9c872 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -232,7 +232,7 @@ describe Projects::UpdateService do let(:project) { create(:project, :legacy_storage, :repository, creator: user, namespace: user.namespace) } before do - gitlab_shell.create_repository(repository_storage, "#{user.namespace.full_path}/existing") + gitlab_shell.create_repository(repository_storage, "#{user.namespace.full_path}/existing", user.namespace.full_path) end after do diff --git a/spec/services/task_list_toggle_service_spec.rb b/spec/services/task_list_toggle_service_spec.rb index cc64dd25085..7c5480d382f 100644 --- a/spec/services/task_list_toggle_service_spec.rb +++ b/spec/services/task_list_toggle_service_spec.rb @@ -67,6 +67,17 @@ describe TaskListToggleService do expect(toggler.execute).to be_falsey end + it 'tolerates \r\n line endings' do + rn_markdown = markdown.gsub("\n", "\r\n") + toggler = described_class.new(rn_markdown, markdown_html, + toggle_as_checked: true, + line_source: '* [ ] Task 1', line_number: 1) + + expect(toggler.execute).to be_truthy + expect(toggler.updated_markdown.lines[0]).to eq "* [x] Task 1\r\n" + expect(toggler.updated_markdown_html).to include('disabled checked> Task 1') + end + it 'returns false if markdown is nil' do toggler = described_class.new(nil, markdown_html, toggle_as_checked: false, diff --git a/spec/support/shared_examples/issuable_shared_examples.rb b/spec/support/shared_examples/issuable_shared_examples.rb index 42f3b4db23c..c3d40c5b231 100644 --- a/spec/support/shared_examples/issuable_shared_examples.rb +++ b/spec/support/shared_examples/issuable_shared_examples.rb @@ -36,3 +36,76 @@ shared_examples 'system notes for milestones' do end end end + +shared_examples 'updating a single task' do + def update_issuable(opts) + issuable = try(:issue) || try(:merge_request) + described_class.new(project, user, opts).execute(issuable) + end + + before do + update_issuable(description: "- [ ] Task 1\n- [ ] Task 2") + end + + context 'when a task is marked as completed' do + before do + update_issuable(update_task: { index: 1, checked: true, line_source: '- [ ] Task 1', line_number: 1 }) + end + + it 'creates system note about task status change' do + note1 = find_note('marked the task **Task 1** as completed') + + expect(note1).not_to be_nil + + description_notes = find_notes('description') + expect(description_notes.length).to eq(1) + end + end + + context 'when a task is marked as incomplete' do + before do + update_issuable(description: "- [x] Task 1\n- [X] Task 2") + update_issuable(update_task: { index: 2, checked: false, line_source: '- [X] Task 2', line_number: 2 }) + end + + it 'creates system note about task status change' do + note1 = find_note('marked the task **Task 2** as incomplete') + + expect(note1).not_to be_nil + + description_notes = find_notes('description') + expect(description_notes.length).to eq(1) + end + end + + context 'when the task position has been modified' do + before do + update_issuable(description: "- [ ] Task 1\n- [ ] Task 3\n- [ ] Task 2") + end + + it 'raises an exception' do + expect(Note.count).to eq(2) + expect do + update_issuable(update_task: { index: 2, checked: true, line_source: '- [ ] Task 2', line_number: 2 }) + end.to raise_error(ActiveRecord::StaleObjectError) + expect(Note.count).to eq(2) + end + end + + context 'when the content changes but not task line number' do + before do + update_issuable(description: "Paragraph\n\n- [ ] Task 1\n- [x] Task 2") + update_issuable(description: "Paragraph with more words\n\n- [ ] Task 1\n- [x] Task 2") + update_issuable(update_task: { index: 2, checked: false, line_source: '- [x] Task 2', line_number: 4 }) + end + + it 'creates system note about task status change' do + note1 = find_note('marked the task **Task 2** as incomplete') + + expect(note1).not_to be_nil + + description_notes = find_notes('description') + expect(description_notes.length).to eq(2) + end + end +end diff --git a/spec/views/projects/settings/ci_cd/_autodevops_form.html.haml_spec.rb b/spec/views/projects/settings/ci_cd/_autodevops_form.html.haml_spec.rb index cb1b9e6f5fb..2a2539c80b5 100644 --- a/spec/views/projects/settings/ci_cd/_autodevops_form.html.haml_spec.rb +++ b/spec/views/projects/settings/ci_cd/_autodevops_form.html.haml_spec.rb @@ -7,56 +7,9 @@ describe 'projects/settings/ci_cd/_autodevops_form' do assign :project, project end - context 'when kubernetes is not active' do - context 'when auto devops domain is not defined' do - it 'shows warning message' do - render + it 'shows a warning message about Kubernetes cluster' do + render - expect(rendered).to have_css('.auto-devops-warning-message') - expect(rendered).to have_text('Auto Review Apps and Auto Deploy need a domain name and a') - expect(rendered).to have_link('Kubernetes cluster') - end - end - - context 'when auto devops domain is defined' do - before do - project.build_auto_devops(domain: 'example.com') - end - - it 'shows warning message' do - render - - expect(rendered).to have_css('.auto-devops-warning-message') - expect(rendered).to have_text('Auto Review Apps and Auto Deploy need a') - expect(rendered).to have_link('Kubernetes cluster') - end - end - end - - context 'when kubernetes is active' do - before do - create(:kubernetes_service, project: project) - end - - context 'when auto devops domain is not defined' do - it 'shows warning message' do - render - - expect(rendered).to have_css('.auto-devops-warning-message') - expect(rendered).to have_text('Auto Review Apps and Auto Deploy need a domain name to work correctly.') - end - end - - context 'when auto devops domain is defined' do - before do - project.build_auto_devops(domain: 'example.com') - end - - it 'does not show warning message' do - render - - expect(rendered).not_to have_css('.auto-devops-warning-message') - end - end + expect(rendered).to have_text('You must add a Kubernetes cluster integration to this project with a domain in order for your deployment strategy to work correctly.') end end diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb index 9176eb12b12..caae46a3175 100644 --- a/spec/workers/post_receive_spec.rb +++ b/spec/workers/post_receive_spec.rb @@ -141,11 +141,18 @@ describe PostReceive do let(:gl_repository) { "wiki-#{project.id}" } it 'updates project activity' do - described_class.new.perform(gl_repository, key_id, base64_changes) + # Force Project#set_timestamps_for_create to initialize timestamps + project - expect { project.reload } - .to change(project, :last_activity_at) - .and change(project, :last_repository_updated_at) + # MySQL drops milliseconds in the timestamps, so advance at least + # a second to ensure we see changes. + Timecop.freeze(1.second.from_now) do + expect do + described_class.new.perform(gl_repository, key_id, base64_changes) + project.reload + end.to change(project, :last_activity_at) + .and change(project, :last_repository_updated_at) + end end end diff --git a/spec/workers/repository_fork_worker_spec.rb b/spec/workers/repository_fork_worker_spec.rb index 781f91ac9ca..31bfe88d0bd 100644 --- a/spec/workers/repository_fork_worker_spec.rb +++ b/spec/workers/repository_fork_worker_spec.rb @@ -24,12 +24,7 @@ describe RepositoryForkWorker do end def expect_fork_repository - expect(shell).to receive(:fork_repository).with( - 'default', - project.disk_path, - forked_project.repository_storage, - forked_project.disk_path - ) + expect(shell).to receive(:fork_repository).with(project, forked_project) end describe 'when a worker was reset without cleanup' do |