diff options
311 files changed, 5443 insertions, 1177 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a97414cbba8..46604317232 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -949,6 +949,8 @@ no_ee_check: # GitLab Review apps review-deploy: <<: *review-base + retry: 2 + allow_failure: true variables: GIT_DEPTH: "1" HOST_SUFFIX: "${CI_ENVIRONMENT_SLUG}" @@ -978,6 +980,8 @@ review-deploy: .review-qa-base: &review-qa-base <<: *review-docker + retry: 2 + allow_failure: true variables: <<: *review-docker-variables API_TOKEN: "${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}" @@ -1005,10 +1009,8 @@ review-deploy: review-qa-smoke: <<: *review-qa-base - # retry: 2 script: - gitlab-qa Test::Instance::Smoke "${QA_IMAGE}" "${CI_ENVIRONMENT_URL}" - allow_failure: true review-qa-all: <<: *review-qa-base diff --git a/CHANGELOG.md b/CHANGELOG.md index d41e5c8642f..d1e324c5518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 11.5.3 (2018-12-06) + +### Security (1 change) + +- Prevent a path traversal attack on global file templates. + + ## 11.5.2 (2018-12-03) ### Removed (1 change) @@ -621,6 +628,13 @@ entry. - Check frozen string in style builds. (gfyoung) +## 11.3.12 (2018-12-06) + +### Security (1 change) + +- Prevent a path traversal attack on global file templates. + + ## 11.3.11 (2018-11-26) ### Security (33 changes) diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 26aaba0e866..bd8bf882d06 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -1.2.0 +1.7.0 diff --git a/GITLAB_WORKHORSE_VERSION b/GITLAB_WORKHORSE_VERSION index ba7f754d0c3..18bb4182dd0 100644 --- a/GITLAB_WORKHORSE_VERSION +++ b/GITLAB_WORKHORSE_VERSION @@ -1 +1 @@ -7.4.0 +7.5.0 @@ -5,7 +5,7 @@ end gem_versions = {} gem_versions['activerecord_sane_schema_dumper'] = rails5? ? '1.0' : '0.2' -gem_versions['rails'] = rails5? ? '5.0.7' : '4.2.10' +gem_versions['rails'] = rails5? ? '5.0.7' : '4.2.11' gem_versions['rails-i18n'] = rails5? ? '~> 5.1' : '~> 4.0.9' # The 2.0.6 version of rack requires monkeypatch to be present in @@ -263,6 +263,9 @@ gem 'ace-rails-ap', '~> 4.1.0' # Detect and convert string character encoding gem 'charlock_holmes', '~> 0.7.5' +# Detect mime content type from content +gem 'mimemagic', '~> 0.3.2' + # Faster blank gem 'fast_blank' @@ -432,7 +435,7 @@ group :ed25519 do end # Gitaly GRPC client -gem 'gitaly-proto', '~> 1.2.0', require: 'gitaly' +gem 'gitaly-proto', '~> 1.3.0', require: 'gitaly' gem 'grpc', '~> 1.15.0' gem 'google-protobuf', '~> 3.6' diff --git a/Gemfile.lock b/Gemfile.lock index 699d77615aa..608d1814127 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -82,6 +82,7 @@ GEM erubi (>= 1.0.0) rack (>= 0.9.0) bindata (2.4.3) + binding_ninja (0.2.2) binding_of_caller (0.8.0) debug_inspector (>= 0.0.1) bootsnap (1.3.2) @@ -273,7 +274,7 @@ GEM gettext_i18n_rails (>= 0.7.1) po_to_json (>= 1.0.0) rails (>= 3.2.0) - gitaly-proto (1.2.0) + gitaly-proto (1.3.0) grpc (~> 1.0) github-markup (1.7.0) gitlab-default_value_for (3.1.1) @@ -458,7 +459,7 @@ GEM mime-types (3.2.2) mime-types-data (~> 3.2015) mime-types-data (3.2018.0812) - mimemagic (0.3.0) + mimemagic (0.3.2) mini_magick (4.8.0) mini_mime (1.0.1) mini_portile2 (2.3.0) @@ -724,8 +725,8 @@ GEM rspec-mocks (3.7.0) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.7.0) - rspec-parameterized (0.4.0) - binding_of_caller + rspec-parameterized (0.4.1) + binding_ninja (>= 0.2.1) parser proc_to_ast rspec (>= 2.13, < 4) @@ -895,7 +896,7 @@ GEM get_process_mem (~> 0) unicorn (>= 4, < 6) uniform_notifier (1.10.0) - unparser (0.2.7) + unparser (0.4.2) abstract_type (~> 0.0.7) adamantium (~> 0.2.0) concord (~> 0.1.5) @@ -1006,7 +1007,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.3) - gitaly-proto (~> 1.2.0) + gitaly-proto (~> 1.3.0) github-markup (~> 1.7.0) gitlab-default_value_for (~> 3.1.1) gitlab-markup (~> 1.6.5) @@ -1050,6 +1051,7 @@ DEPENDENCIES loofah (~> 2.2) mail_room (~> 0.9.1) method_source (~> 0.8) + mimemagic (~> 0.3.2) mini_magick minitest (~> 5.7.0) mysql2 (~> 0.4.10) diff --git a/Gemfile.rails4.lock b/Gemfile.rails4.lock index 15e0b782d5b..9e7bae84299 100644 --- a/Gemfile.rails4.lock +++ b/Gemfile.rails4.lock @@ -79,6 +79,7 @@ GEM erubi (>= 1.0.0) rack (>= 0.9.0) bindata (2.4.3) + binding_ninja (0.2.2) binding_of_caller (0.8.0) debug_inspector (>= 0.0.1) bootsnap (1.3.2) @@ -272,7 +273,7 @@ GEM gettext_i18n_rails (>= 0.7.1) po_to_json (>= 1.0.0) rails (>= 3.2.0) - gitaly-proto (1.2.0) + gitaly-proto (1.3.0) grpc (~> 1.0) github-markup (1.7.0) gitlab-markup (1.6.5) @@ -455,7 +456,7 @@ GEM mime-types (3.2.2) mime-types-data (~> 3.2015) mime-types-data (3.2018.0812) - mimemagic (0.3.0) + mimemagic (0.3.2) mini_magick (4.8.0) mini_mime (1.0.1) mini_portile2 (2.3.0) @@ -618,16 +619,16 @@ GEM rack rack-test (0.6.3) rack (>= 1.0) - rails (4.2.10) - actionmailer (= 4.2.10) - actionpack (= 4.2.10) - actionview (= 4.2.10) - activejob (= 4.2.10) - activemodel (= 4.2.10) - activerecord (= 4.2.10) - activesupport (= 4.2.10) + rails (4.2.11) + actionmailer (= 4.2.11) + actionpack (= 4.2.11) + actionview (= 4.2.11) + activejob (= 4.2.11) + activemodel (= 4.2.11) + activerecord (= 4.2.11) + activesupport (= 4.2.11) bundler (>= 1.3.0, < 2.0) - railties (= 4.2.10) + railties (= 4.2.11) sprockets-rails rails-deprecated_sanitizer (1.0.3) activesupport (>= 4.2.0.alpha) @@ -715,8 +716,8 @@ GEM rspec-mocks (3.7.0) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.7.0) - rspec-parameterized (0.4.0) - binding_of_caller + rspec-parameterized (0.4.1) + binding_ninja (>= 0.2.1) parser proc_to_ast rspec (>= 2.13, < 4) @@ -889,7 +890,7 @@ GEM get_process_mem (~> 0) unicorn (>= 4, < 6) uniform_notifier (1.10.0) - unparser (0.2.7) + unparser (0.4.2) abstract_type (~> 0.0.7) adamantium (~> 0.2.0) concord (~> 0.1.5) @@ -998,7 +999,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.3) - gitaly-proto (~> 1.2.0) + gitaly-proto (~> 1.3.0) github-markup (~> 1.7.0) gitlab-markup (~> 1.6.5) gitlab-sidekiq-fetcher @@ -1041,6 +1042,7 @@ DEPENDENCIES loofah (~> 2.2) mail_room (~> 0.9.1) method_source (~> 0.8) + mimemagic (~> 0.3.2) mini_magick minitest (~> 5.7.0) mysql2 (~> 0.4.10) @@ -1084,7 +1086,7 @@ DEPENDENCIES rack-cors (~> 1.0.0) rack-oauth2 (~> 1.2.1) rack-proxy (~> 0.6.0) - rails (= 4.2.10) + rails (= 4.2.11) rails-deprecated_sanitizer (~> 1.0.3) rails-i18n (~> 4.0.9) rainbow (~> 3.0) diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index f8dbe412f80..de003e70e61 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -14,6 +14,7 @@ const Api = { projectMergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid', projectMergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes', projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions', + projectRunnersPath: '/api/:version/projects/:id/runners', mergeRequestsPath: '/api/:version/merge_requests', groupLabelsPath: '/groups/:namespace_path/-/labels', issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key', @@ -124,6 +125,15 @@ const Api = { return axios.get(url); }, + projectRunners(projectPath, config = {}) { + const url = Api.buildUrl(Api.projectRunnersPath).replace( + ':id', + encodeURIComponent(projectPath), + ); + + return axios.get(url, config); + }, + mergeRequests(params = {}) { const url = Api.buildUrl(Api.mergeRequestsPath); diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js index c09d9ccddd6..d8056e48d4e 100644 --- a/app/assets/javascripts/behaviors/requires_input.js +++ b/app/assets/javascripts/behaviors/requires_input.js @@ -50,10 +50,11 @@ function hideOrShowHelpBlock(form) { } $(() => { - const $form = $('form.js-requires-input'); - if ($form) { + $('form.js-requires-input').each((i, el) => { + const $form = $(el); + $form.requiresInput(); hideOrShowHelpBlock($form); $('.select2.js-select-namespace').change(() => hideOrShowHelpBlock($form)); - } + }); }); diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 31651658fe6..d899b7fbd8c 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -92,20 +92,7 @@ export default { {{ selectedProjectName }} <icon name="chevron-down" /> </button> <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width"> - <div class="dropdown-title"> - <span>Projects</span> - <button - aria-label="Close" - type="button" - class="dropdown-title-button dropdown-menu-close" - > - <icon - name="merge-request-close-m" - data-hidden="true" - class="dropdown-menu-close-icon" - /> - </button> - </div> + <div class="dropdown-title">Projects</div> <div class="dropdown-input"> <input class="dropdown-input-field" type="search" placeholder="Search projects" /> <icon name="search" class="dropdown-input-search" data-hidden="true" /> diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index aff32d95db1..cf70a48f076 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -1,6 +1,6 @@ import Visibility from 'visibilityjs'; import Vue from 'vue'; -import PersistentUserCallout from '../persistent_user_callout'; +import initDismissableCallout from '~/dismissable_callout'; import { s__, sprintf } from '../locale'; import Flash from '../flash'; import Poll from '../lib/utils/poll'; @@ -67,7 +67,7 @@ export default class Clusters { this.showTokenButton = document.querySelector('.js-show-cluster-token'); this.tokenField = document.querySelector('.js-cluster-token'); - Clusters.initDismissableCallout(); + initDismissableCallout('.js-cluster-security-warning'); initSettingsPanels(); setupToggleButtons(document.querySelector('.js-cluster-enable-toggle-area')); this.initApplications(clusterType); @@ -108,12 +108,6 @@ export default class Clusters { }); } - static initDismissableCallout() { - const callout = document.querySelector('.js-cluster-security-warning'); - - if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new - } - addListeners() { if (this.showTokenButton) this.showTokenButton.addEventListener('click', this.showToken); eventHub.$on('installApplication', this.installApplication); diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 9a96d0fa6d7..665a9c77822 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -296,7 +296,6 @@ export default { :request-status="applications.cert_manager.requestStatus" :request-reason="applications.cert_manager.requestReason" :disabled="!helmInstalled" - class="hide-bottom-border rounded-bottom" title-link="https://cert-manager.readthedocs.io/en/latest/#" > <div slot="description" v-html="certManagerDescription"></div> @@ -396,6 +395,7 @@ export default { </div> </application-row> <application-row + v-if="isProjectCluster" id="knative" :logo-url="knativeLogo" :title="applications.knative.title" @@ -405,7 +405,6 @@ export default { :request-reason="applications.knative.requestReason" :install-application-request-params="{ hostname: applications.knative.hostname }" :disabled="!helmInstalled" - class="hide-bottom-border rounded-bottom" title-link="https://github.com/knative/docs" > <div slot="description"> @@ -432,7 +431,7 @@ export default { /> </div> </template> - <template v-else> + <template v-else-if="helmInstalled"> <div class="form-group"> <label for="knative-domainname"> {{ s__('ClusterIntegration|Knative Domain Name:') }} diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index e405d8b20ae..11cc4c09fed 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -90,6 +90,8 @@ export default { :old-sha="diffFile.diff_refs.base_sha" :file-hash="diffFile.file_hash" :project-path="projectPath" + :a-mode="diffFile.a_mode" + :b-mode="diffFile.b_mode" > <image-diff-overlay slot="image-overlay" diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index f7e3655ea40..3b2a0d156ca 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -52,7 +52,9 @@ export default { (!this.file.highlighted_diff_lines && !this.isLoadingCollapsedDiff && !this.file.too_large && - this.file.text) + this.file.text && + !this.file.renamed_file && + !this.file.mode_changed) ); }, showLoadingIcon() { @@ -143,9 +145,8 @@ export default { <a :href="file.fork_path" class="js-fork-suggestion-button btn btn-grouped btn-inverted btn-success" + >Fork</a > - Fork - </a> <button class="js-cancel-fork-suggestion-button btn btn-grouped" type="button" @@ -163,9 +164,9 @@ export default { <gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" /> <div v-else-if="showExpandMessage" class="nothing-here-block diff-collapsed"> {{ __('This diff is collapsed.') }} - <a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle"> - {{ __('Click to expand it.') }} - </a> + <a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle">{{ + __('Click to expand it.') + }}</a> </div> <div v-if="file.too_large" class="nothing-here-block diff-collapsed js-too-large-diff"> {{ __('This source diff could not be displayed because it is too large.') }} diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index c0456c18e44..952963e0711 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -192,8 +192,9 @@ export const toggleFileDiscussions = ({ getters, dispatch }, diff) => { }); }; -export const saveDiffDiscussion = ({ dispatch }, { note, formData }) => { +export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => { const postData = getNoteFormData({ + commit: state.commit, note, ...formData, }); diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 54b9ee4d2d6..cbaa0e26395 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -27,6 +27,7 @@ export const getReversePosition = linePosition => { export function getFormData(params) { const { + commit, note, noteableType, noteableData, @@ -66,7 +67,7 @@ export function getFormData(params) { position, noteable_type: noteableType, noteable_id: noteableData.id, - commit_id: '', + commit_id: commit && commit.id, type: diffFile.diff_refs.start_sha && diffFile.diff_refs.head_sha ? DIFF_NOTE_TYPE @@ -324,5 +325,9 @@ export const generateTreeList = files => export const getDiffMode = diffFile => { const diffModeKey = Object.keys(diffModes).find(key => diffFile[`${key}_file`]); - return diffModes[diffModeKey] || diffModes.replaced; + return ( + diffModes[diffModeKey] || + (diffFile.mode_changed && diffModes.mode_changed) || + diffModes.replaced + ); }; diff --git a/app/assets/javascripts/dismissable_callout.js b/app/assets/javascripts/dismissable_callout.js new file mode 100644 index 00000000000..5185b019376 --- /dev/null +++ b/app/assets/javascripts/dismissable_callout.js @@ -0,0 +1,27 @@ +import $ from 'jquery'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import Flash from '~/flash'; + +export default function initDismissableCallout(alertSelector) { + const alertEl = document.querySelector(alertSelector); + if (!alertEl) { + return; + } + + const closeButtonEl = alertEl.getElementsByClassName('close')[0]; + const { dismissEndpoint, featureId } = closeButtonEl.dataset; + + closeButtonEl.addEventListener('click', () => { + axios + .post(dismissEndpoint, { + feature_name: featureId, + }) + .then(() => { + $(alertEl).alert('close'); + }) + .catch(() => { + Flash(__('An error occurred while dismissing the alert. Refresh the page and try again.')); + }); + }); +} diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 5164d87c5fa..533e90e2222 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -70,7 +70,7 @@ export default { <template v-if="shouldRenderFolderContent(model)"> <div v-if="model.isLoadingFolderContent" :key="`loading-item-${i}`"> - <gl-loading-icon :size="2" /> + <gl-loading-icon :size="2" class="prepend-top-16" /> </div> <template v-else> diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 3b201f006aa..09245ed0296 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -26,6 +26,7 @@ export const diffModes = { new: 'new', deleted: 'deleted', renamed: 'renamed', + mode_changed: 'mode_changed', }; export const rightSidebarViews = { diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index fbf944499d5..6351948f750 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import { mapActions } from 'vuex'; +import _ from 'underscore'; import Translate from '~/vue_shared/translate'; import ide from './components/ide.vue'; import store from './stores'; @@ -13,19 +14,19 @@ Vue.use(Translate); * * @param {Element} el - The element that will contain the IDE. * @param {Object} options - Extra options for the IDE (Used by EE). - * @param {(e:Element) => Object} options.extraInitialData - - * Function that returns extra properties to seed initial data. * @param {Component} options.rootComponent - * Component that overrides the root component. + * @param {(store:Vuex.Store, el:Element) => Vuex.Store} options.extendStore - + * Function that receives the default store and returns an extended one. */ export function initIde(el, options = {}) { if (!el) return null; - const { extraInitialData = () => ({}), rootComponent = ide } = options; + const { rootComponent = ide, extendStore = _.identity } = options; return new Vue({ el, - store, + store: extendStore(store, el), router, created() { this.setEmptyStateSvgs({ @@ -41,7 +42,6 @@ export function initIde(el, options = {}) { }); this.setInitialData({ clientsidePreviewEnabled: parseBoolean(el.dataset.clientsidePreviewEnabled), - ...extraInitialData(el), }); }, methods: { diff --git a/app/assets/javascripts/lib/utils/file_upload.js b/app/assets/javascripts/lib/utils/file_upload.js new file mode 100644 index 00000000000..b41ffb44971 --- /dev/null +++ b/app/assets/javascripts/lib/utils/file_upload.js @@ -0,0 +1,13 @@ +export default (buttonSelector, fileSelector) => { + const btn = document.querySelector(buttonSelector); + const fileInput = document.querySelector(fileSelector); + const form = btn.closest('form'); + + btn.addEventListener('click', () => { + fileInput.click(); + }); + + fileInput.addEventListener('change', () => { + form.querySelector('.js-filename').textContent = fileInput.value.replace(/^.*[\\\/]/, ''); // eslint-disable-line no-useless-escape + }); +}; diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js index e4852c85378..14c02218990 100644 --- a/app/assets/javascripts/lib/utils/http_status.js +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -16,7 +16,9 @@ const httpStatusCodes = { IM_USED: 226, MULTIPLE_CHOICES: 300, BAD_REQUEST: 400, + FORBIDDEN: 403, NOT_FOUND: 404, + UNPROCESSABLE_ENTITY: 422, }; export const successCodes = [ diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index d32f39881dd..75c18a9b6a0 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -155,7 +155,7 @@ export default class MilestoneSelect { const { $el, e } = clickEvent; let selected = clickEvent.selectedObj; - let data, boardsStore; + let data, modalStoreFilter; if (!selected) return; if (options.handleClick) { @@ -179,11 +179,11 @@ export default class MilestoneSelect { } if ($dropdown.closest('.add-issues-modal').length) { - boardsStore = ModalStore.store.filter; + modalStoreFilter = ModalStore.store.filter; } - if (boardsStore) { - boardsStore[$dropdown.data('fieldName')] = selected.name; + if (modalStoreFilter) { + modalStoreFilter[$dropdown.data('fieldName')] = selected.name; e.preventDefault(); } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { return Issuable.filterResults($dropdown.closest('form')); diff --git a/app/assets/javascripts/pages/groups/clusters/index/index.js b/app/assets/javascripts/pages/groups/clusters/index/index.js index 21efc4f6d00..845a5f7042c 100644 --- a/app/assets/javascripts/pages/groups/clusters/index/index.js +++ b/app/assets/javascripts/pages/groups/clusters/index/index.js @@ -1,7 +1,5 @@ -import PersistentUserCallout from '~/persistent_user_callout'; +import initDismissableCallout from '~/dismissable_callout'; document.addEventListener('DOMContentLoaded', () => { - const callout = document.querySelector('.gcp-signup-offer'); - - if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new + initDismissableCallout('.gcp-signup-offer'); }); diff --git a/app/assets/javascripts/pages/groups/index.js b/app/assets/javascripts/pages/groups/index.js index 00e2d7fc998..bf80d8b8193 100644 --- a/app/assets/javascripts/pages/groups/index.js +++ b/app/assets/javascripts/pages/groups/index.js @@ -1,12 +1,6 @@ -import PersistentUserCallout from '~/persistent_user_callout'; +import initDismissableCallout from '~/dismissable_callout'; import initGkeDropdowns from '~/projects/gke_cluster_dropdowns'; -function initCallout() { - const callout = document.querySelector('.gcp-signup-offer'); - - if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new -} - document.addEventListener('DOMContentLoaded', () => { const { page } = document.body.dataset; const newClusterViews = [ @@ -16,7 +10,7 @@ document.addEventListener('DOMContentLoaded', () => { ]; if (newClusterViews.indexOf(page) > -1) { - initCallout(); + initDismissableCallout('.gcp-signup-offer'); initGkeDropdowns(); } }); diff --git a/app/assets/javascripts/pages/projects/clusters/index/index.js b/app/assets/javascripts/pages/projects/clusters/index/index.js index 21efc4f6d00..845a5f7042c 100644 --- a/app/assets/javascripts/pages/projects/clusters/index/index.js +++ b/app/assets/javascripts/pages/projects/clusters/index/index.js @@ -1,7 +1,5 @@ -import PersistentUserCallout from '~/persistent_user_callout'; +import initDismissableCallout from '~/dismissable_callout'; document.addEventListener('DOMContentLoaded', () => { - const callout = document.querySelector('.gcp-signup-offer'); - - if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new + initDismissableCallout('.gcp-signup-offer'); }); diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js index f5b1cf85e68..899d5925956 100644 --- a/app/assets/javascripts/pages/projects/edit/index.js +++ b/app/assets/javascripts/pages/projects/edit/index.js @@ -3,8 +3,8 @@ import initSettingsPanels from '~/settings_panels'; import setupProjectEdit from '~/project_edit'; import initConfirmDangerModal from '~/confirm_danger_modal'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; +import fileUpload from '~/lib/utils/file_upload'; import initProjectLoadingSpinner from '../shared/save_project_loader'; -import projectAvatar from '../shared/project_avatar'; import initProjectPermissionsSettings from '../shared/permissions'; document.addEventListener('DOMContentLoaded', () => { @@ -12,7 +12,7 @@ document.addEventListener('DOMContentLoaded', () => { setupProjectEdit(); // Initialize expandable settings panels initSettingsPanels(); - projectAvatar(); + fileUpload('.js-choose-project-avatar-button', '.js-project-avatar-input'); initProjectPermissionsSettings(); initConfirmDangerModal(); mountBadgeSettings(PROJECT_BADGE); diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index b0345b4e50d..5659e13981a 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -1,5 +1,5 @@ +import initDismissableCallout from '~/dismissable_callout'; import initGkeDropdowns from '~/projects/gke_cluster_dropdowns'; -import PersistentUserCallout from '../../persistent_user_callout'; import Project from './project'; import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation'; @@ -12,9 +12,7 @@ document.addEventListener('DOMContentLoaded', () => { ]; if (newClusterViews.indexOf(page) > -1) { - const callout = document.querySelector('.gcp-signup-offer'); - if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new - + initDismissableCallout('.gcp-signup-offer'); initGkeDropdowns(); } diff --git a/app/assets/javascripts/pages/projects/serverless/index.js b/app/assets/javascripts/pages/projects/serverless/index.js new file mode 100644 index 00000000000..7b08620773c --- /dev/null +++ b/app/assets/javascripts/pages/projects/serverless/index.js @@ -0,0 +1,5 @@ +import ServerlessBundle from '~/serverless/serverless_bundle'; + +document.addEventListener('DOMContentLoaded', () => { + new ServerlessBundle(); // eslint-disable-line no-new +}); diff --git a/app/assets/javascripts/pages/projects/settings/repository/form.js b/app/assets/javascripts/pages/projects/settings/repository/form.js index a52861c9efa..3e02893f24c 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/form.js +++ b/app/assets/javascripts/pages/projects/settings/repository/form.js @@ -7,6 +7,7 @@ import initDeployKeys from '~/deploy_keys'; import ProtectedBranchCreate from '~/protected_branches/protected_branch_create'; import ProtectedBranchEditList from '~/protected_branches/protected_branch_edit_list'; import DueDateSelectors from '~/due_date_select'; +import fileUpload from '~/lib/utils/file_upload'; export default () => { new ProtectedTagCreate(); @@ -16,4 +17,5 @@ export default () => { new ProtectedBranchCreate(); new ProtectedBranchEditList(); new DueDateSelectors(); + fileUpload('.js-choose-file', '.js-object-map-input'); }; diff --git a/app/assets/javascripts/pages/projects/shared/project_avatar.js b/app/assets/javascripts/pages/projects/shared/project_avatar.js deleted file mode 100644 index 1e69ecb481d..00000000000 --- a/app/assets/javascripts/pages/projects/shared/project_avatar.js +++ /dev/null @@ -1,16 +0,0 @@ -import $ from 'jquery'; - -export default function projectAvatar() { - $('.js-choose-project-avatar-button').bind('click', function onClickAvatar() { - const form = $(this).closest('form'); - return form.find('.js-project-avatar-input').click(); - }); - - $('.js-project-avatar-input').bind('change', function onClickAvatarInput() { - const form = $(this).closest('form'); - const filename = $(this) - .val() - .replace(/^.*[\\\/]/, ''); // eslint-disable-line no-useless-escape - return form.find('.js-avatar-filename').text(filename); - }); -} diff --git a/app/assets/javascripts/pages/root/index.js b/app/assets/javascripts/pages/root/index.js deleted file mode 100644 index 09f8185d3b5..00000000000 --- a/app/assets/javascripts/pages/root/index.js +++ /dev/null @@ -1,5 +0,0 @@ -// if the "projects dashboard" is a user's default dashboard, when they visit the -// instance root index, the dashboard will be served by the root controller instead -// of a dashboard controller. The root index redirects for all other default dashboards. - -import '../dashboard/projects/index'; diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js deleted file mode 100644 index 1e34e74a152..00000000000 --- a/app/assets/javascripts/persistent_user_callout.js +++ /dev/null @@ -1,34 +0,0 @@ -import axios from './lib/utils/axios_utils'; -import { __ } from './locale'; -import Flash from './flash'; - -export default class PersistentUserCallout { - constructor(container) { - const { dismissEndpoint, featureId } = container.dataset; - this.container = container; - this.dismissEndpoint = dismissEndpoint; - this.featureId = featureId; - - this.init(); - } - - init() { - const closeButton = this.container.querySelector('.js-close'); - closeButton.addEventListener('click', event => this.dismiss(event)); - } - - dismiss(event) { - event.preventDefault(); - - axios - .post(this.dismissEndpoint, { - feature_name: this.featureId, - }) - .then(() => { - this.container.remove(); - }) - .catch(() => { - Flash(__('An error occurred while dismissing the alert. Refresh the page and try again.')); - }); - } -} diff --git a/app/assets/javascripts/serverless/components/empty_state.vue b/app/assets/javascripts/serverless/components/empty_state.vue new file mode 100644 index 00000000000..2683805f2f7 --- /dev/null +++ b/app/assets/javascripts/serverless/components/empty_state.vue @@ -0,0 +1,40 @@ +<script> +export default { + props: { + clustersPath: { + type: String, + required: true, + }, + helpPath: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div class="row empty-state js-empty-state"> + <div class="col-12"> + <div class="text-content"> + <h4 class="state-title text-center"> + {{ s__('Serverless|Getting started with serverless') }} + </h4> + <p class="state-description"> + {{ + s__(`Serverless| In order to start using functions as a service, + you must first install Knative on your Kubernetes cluster.`) + }} + + <a :href="helpPath"> {{ __('More information') }} </a> + </p> + + <div class="text-center"> + <a :href="clustersPath" class="btn btn-success"> + {{ s__('Serverless|Install Knative') }} + </a> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue new file mode 100644 index 00000000000..31f5427c771 --- /dev/null +++ b/app/assets/javascripts/serverless/components/function_row.vue @@ -0,0 +1,40 @@ +<script> +import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + components: { + Timeago, + }, + props: { + func: { + type: Object, + required: true, + }, + }, + computed: { + name() { + return this.func.name; + }, + url() { + return this.func.url; + }, + image() { + return this.func.image; + }, + timestamp() { + return this.func.created_at; + }, + }, +}; +</script> + +<template> + <div class="gl-responsive-table-row"> + <div class="table-section section-20">{{ name }}</div> + <div class="table-section section-50"> + <a :href="url">{{ url }}</a> + </div> + <div class="table-section section-20">{{ image }}</div> + <div class="table-section section-10"><timeago :time="timestamp" /></div> + </div> +</template> diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue new file mode 100644 index 00000000000..7874a7b6b6a --- /dev/null +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -0,0 +1,123 @@ +<script> +import { GlSkeletonLoading } from '@gitlab/ui'; +import FunctionRow from './function_row.vue'; +import EmptyState from './empty_state.vue'; + +export default { + components: { + FunctionRow, + EmptyState, + GlSkeletonLoading, + }, + props: { + functions: { + type: Array, + required: true, + default: () => [], + }, + installed: { + type: Boolean, + required: true, + }, + clustersPath: { + type: String, + required: true, + }, + helpPath: { + type: String, + required: true, + }, + loadingData: { + type: Boolean, + required: false, + default: true, + }, + hasFunctionData: { + type: Boolean, + required: false, + default: true, + }, + }, +}; +</script> + +<template> + <section id="serverless-functions"> + <div v-if="installed"> + <div v-if="hasFunctionData"> + <div class="ci-table js-services-list function-element"> + <div class="gl-responsive-table-row table-row-header" role="row"> + <div class="table-section section-20" role="rowheader"> + {{ s__('Serverless|Function') }} + </div> + <div class="table-section section-50" role="rowheader"> + {{ s__('Serverless|Domain') }} + </div> + <div class="table-section section-20" role="rowheader"> + {{ s__('Serverless|Runtime') }} + </div> + <div class="table-section section-10" role="rowheader"> + {{ s__('Serverless|Last Update') }} + </div> + </div> + <template v-if="loadingData"> + <div v-for="j in 3" :key="j" class="gl-responsive-table-row"> + <gl-skeleton-loading /> + </div> + </template> + <template v-else> + <function-row v-for="f in functions" :key="f.name" :func="f" /> + </template> + </div> + </div> + <div v-else class="empty-state js-empty-state"> + <div class="text-content"> + <h4 class="state-title text-center">{{ s__('Serverless|No functions available') }}</h4> + <p class="state-description"> + {{ + s__(`Serverless|There is currently no function data available from Knative. + This could be for a variety of reasons including:`) + }} + </p> + <ul> + <li>Your repository does not have a corresponding <code>serverless.yml</code> file.</li> + <li>Your <code>gitlab-ci.yml</code> file is not properly configured.</li> + <li> + The functions listed in the <code>serverless.yml</code> file don't match the namespace + of your cluster. + </li> + <li>The deploy job has not finished.</li> + </ul> + + <p> + {{ + s__(`Serverless|If you believe none of these apply, please check + back later as the function data may be in the process of becoming + available.`) + }} + </p> + <div class="text-center"> + <a :href="helpPath" class="btn btn-success"> + {{ s__('Serverless|Learn more about Serverless') }} + </a> + </div> + </div> + </div> + </div> + + <empty-state v-else :clusters-path="clustersPath" :help-path="helpPath" /> + </section> +</template> + +<style> +.top-area { + border-bottom: 0; +} + +.function-element { + border-bottom: 1px solid #e5e5e5; + border-bottom-color: rgb(229, 229, 229); + border-bottom-style: solid; + border-bottom-width: 1px; +} +</style> diff --git a/app/assets/javascripts/serverless/event_hub.js b/app/assets/javascripts/serverless/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/serverless/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/serverless/serverless_bundle.js b/app/assets/javascripts/serverless/serverless_bundle.js new file mode 100644 index 00000000000..3e3b81ba247 --- /dev/null +++ b/app/assets/javascripts/serverless/serverless_bundle.js @@ -0,0 +1,106 @@ +import Visibility from 'visibilityjs'; +import Vue from 'vue'; +import { s__ } from '../locale'; +import Flash from '../flash'; +import Poll from '../lib/utils/poll'; +import ServerlessStore from './stores/serverless_store'; +import GetFunctionsService from './services/get_functions_service'; +import Functions from './components/functions.vue'; + +export default class Serverless { + constructor() { + const { statusPath, clustersPath, helpPath, installed } = document.querySelector( + '.js-serverless-functions-page', + ).dataset; + + this.service = new GetFunctionsService(statusPath); + this.knativeInstalled = installed !== undefined; + this.store = new ServerlessStore(this.knativeInstalled, clustersPath, helpPath); + this.initServerless(); + this.functionLoadCount = 0; + + if (statusPath && this.knativeInstalled) { + this.initPolling(); + } + } + + initServerless() { + const { store } = this; + const el = document.querySelector('#js-serverless-functions'); + + this.functions = new Vue({ + el, + data() { + return { + state: store.state, + }; + }, + render(createElement) { + return createElement(Functions, { + props: { + functions: this.state.functions, + installed: this.state.installed, + clustersPath: this.state.clustersPath, + helpPath: this.state.helpPath, + loadingData: this.state.loadingData, + hasFunctionData: this.state.hasFunctionData, + }, + }); + }, + }); + } + + initPolling() { + this.poll = new Poll({ + resource: this.service, + method: 'fetchData', + successCallback: data => this.handleSuccess(data), + errorCallback: () => this.handleError(), + }); + + if (!Visibility.hidden()) { + this.poll.makeRequest(); + } else { + this.service + .fetchData() + .then(data => this.handleSuccess(data)) + .catch(() => this.handleError()); + } + + Visibility.change(() => { + if (!Visibility.hidden() && !this.destroyed) { + this.poll.restart(); + } else { + this.poll.stop(); + } + }); + } + + handleSuccess(data) { + if (data.status === 200) { + this.store.updateFunctionsFromServer(data.data); + this.store.updateLoadingState(false); + } else if (data.status === 204) { + /* Time out after 3 attempts to retrieve data */ + this.functionLoadCount += 1; + if (this.functionLoadCount === 3) { + this.poll.stop(); + this.store.toggleNoFunctionData(); + } + } + } + + static handleError() { + Flash(s__('Serverless|An error occurred while retrieving serverless components')); + } + + destroy() { + this.destroyed = true; + + if (this.poll) { + this.poll.stop(); + } + + this.functions.$destroy(); + } +} diff --git a/app/assets/javascripts/serverless/services/get_functions_service.js b/app/assets/javascripts/serverless/services/get_functions_service.js new file mode 100644 index 00000000000..303b42dc66c --- /dev/null +++ b/app/assets/javascripts/serverless/services/get_functions_service.js @@ -0,0 +1,11 @@ +import axios from '~/lib/utils/axios_utils'; + +export default class GetFunctionsService { + constructor(endpoint) { + this.endpoint = endpoint; + } + + fetchData() { + return axios.get(this.endpoint); + } +} diff --git a/app/assets/javascripts/serverless/stores/serverless_store.js b/app/assets/javascripts/serverless/stores/serverless_store.js new file mode 100644 index 00000000000..774c15b5b12 --- /dev/null +++ b/app/assets/javascripts/serverless/stores/serverless_store.js @@ -0,0 +1,24 @@ +export default class ServerlessStore { + constructor(knativeInstalled = false, clustersPath, helpPath) { + this.state = { + functions: [], + hasFunctionData: true, + loadingData: true, + installed: knativeInstalled, + clustersPath, + helpPath, + }; + } + + updateFunctionsFromServer(functions = []) { + this.state.functions = functions; + } + + updateLoadingState(loadingData) { + this.state.loadingData = loadingData; + } + + toggleNoFunctionData() { + this.state.hasFunctionData = false; + } +} diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue index bb2e0e12c11..75c66ed850b 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue @@ -1,7 +1,10 @@ <script> +import { diffModes } from '~/ide/constants'; import { viewerInformationForPath } from '../content_viewer/lib/viewer_utils'; import ImageDiffViewer from './viewers/image_diff_viewer.vue'; import DownloadDiffViewer from './viewers/download_diff_viewer.vue'; +import RenamedFile from './viewers/renamed.vue'; +import ModeChanged from './viewers/mode_changed.vue'; export default { props: { @@ -30,9 +33,25 @@ export default { required: false, default: '', }, + aMode: { + type: String, + required: false, + default: null, + }, + bMode: { + type: String, + required: false, + default: null, + }, }, computed: { viewer() { + if (this.diffMode === diffModes.renamed) { + return RenamedFile; + } else if (this.diffMode === diffModes.mode_changed) { + return ModeChanged; + } + if (!this.newPath) return null; const previewInfo = viewerInformationForPath(this.newPath); @@ -67,8 +86,10 @@ export default { :new-path="fullNewPath" :old-path="fullOldPath" :project-path="projectPath" + :a-mode="aMode" + :b-mode="bMode" > - <slot slot="image-overlay" name="image-overlay"> </slot> + <slot slot="image-overlay" name="image-overlay"></slot> </component> <slot></slot> </div> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/mode_changed.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/mode_changed.vue new file mode 100644 index 00000000000..3c7a4ea6183 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/mode_changed.vue @@ -0,0 +1,30 @@ +<script> +import { sprintf, __ } from '~/locale'; + +export default { + props: { + aMode: { + type: String, + required: false, + default: null, + }, + bMode: { + type: String, + required: false, + default: null, + }, + }, + computed: { + outputText() { + return sprintf(__('File mode changed from %{a_mode} to %{b_mode}'), { + a_mode: this.aMode, + b_mode: this.bMode, + }); + }, + }, +}; +</script> + +<template> + <div class="nothing-here-block">{{ outputText }}</div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue new file mode 100644 index 00000000000..5c1ea59b471 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue @@ -0,0 +1,3 @@ +<template> + <div class="nothing-here-block">{{ __('File moved') }}</div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue index e742900dbcb..373794fb1f2 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue @@ -44,6 +44,7 @@ export default { class="sidebar-collapsed-icon" data-placement="left" data-container="body" + data-boundary="viewport" @click="handleClick" > <i aria-hidden="true" data-hidden="true" class="fa fa-tags"> </i> diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 4041f2b4479..834e7ffce81 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -65,3 +65,4 @@ @import 'framework/feature_highlight'; @import 'framework/terms'; @import 'framework/read_more'; +@import 'framework/flex_grid'; diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index 6f103e4e89a..8b6a7017c47 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -261,7 +261,7 @@ height: 1px; margin: 4px -1px; padding: 0; - background-color: $dropdown-divider-color; + background-color: $dropdown-divider-bg; } > .active { diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index ce5d36a340f..f3c44f32d6f 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -294,10 +294,10 @@ height: 1px; margin: #{$grid-size / 2} 0; padding: 0; - background-color: $dropdown-divider-color; + background-color: $dropdown-divider-bg; &:hover { - background-color: $dropdown-divider-color; + background-color: $dropdown-divider-bg; } } @@ -306,7 +306,7 @@ height: 1px; margin-top: 8px; margin-bottom: 8px; - background-color: $dropdown-divider-color; + background-color: $dropdown-divider-bg; } .dropdown-menu-empty-item a { @@ -542,7 +542,7 @@ text-align: center; text-overflow: ellipsis; white-space: nowrap; - border-bottom: 1px solid $dropdown-divider-color; + border-bottom: 1px solid $dropdown-divider-bg; overflow: hidden; } @@ -621,7 +621,7 @@ padding: 0 7px; color: $gl-gray-700; line-height: 30px; - border: 1px solid $dropdown-divider-color; + border: 1px solid $dropdown-divider-bg; border-radius: 2px; outline: 0; @@ -656,7 +656,7 @@ padding-top: 10px; margin-top: 10px; font-size: 13px; - border-top: 1px solid $dropdown-divider-color; + border-top: 1px solid $dropdown-divider-bg; } .dropdown-footer-content { diff --git a/app/assets/stylesheets/framework/flex_grid.scss b/app/assets/stylesheets/framework/flex_grid.scss new file mode 100644 index 00000000000..10537fd5549 --- /dev/null +++ b/app/assets/stylesheets/framework/flex_grid.scss @@ -0,0 +1,52 @@ +.flex-grid { + .grid-row { + border-bottom: 1px solid $border-color; + padding: 0; + + &:last-child { + border-bottom: 0; + } + + @include media-breakpoint-down(md) { + border-bottom: 0; + border-right: 1px solid $border-color; + + &:last-child { + border-right: 0; + } + } + + @include media-breakpoint-down(xs) { + border-right: 0; + border-bottom: 1px solid $border-color; + + &:last-child { + border-bottom: 0; + } + } + } + + .grid-cell { + padding: 10px $gl-padding; + border-right: 1px solid $border-color; + + &:last-child { + border-right: 0; + } + + @include media-breakpoint-up(md) { + flex: 1; + } + + @include media-breakpoint-down(md) { + border-right: 0; + flex: none; + } + } +} + +.card { + .card-body.flex-grid { + padding: 0; + } +} diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 39410ac56af..c0cda29e239 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -383,6 +383,16 @@ top: 1px; } } + + .dropdown-menu li a .identicon { + width: 17px; + height: 17px; + font-size: $gl-font-size-xs; + vertical-align: middle; + text-indent: 0; + line-height: $gl-font-size-xs + 2px; + display: inline-block; + } } .breadcrumbs-list { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index bf2868710eb..4fcdb862b6d 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -332,7 +332,6 @@ $dropdown-max-height: 312px; $dropdown-vertical-offset: 4px; $dropdown-empty-row-bg: rgba(#000, 0.04); $dropdown-shadow-color: rgba(#000, 0.1); -$dropdown-divider-color: rgba(#000, 0.1); $dropdown-title-btn-color: #bfbfbf; $dropdown-input-fa-color: #c7c7c7; $dropdown-input-focus-shadow: rgba($blue-300, 0.4); diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss index 711de02cd39..fab1b361f14 100644 --- a/app/assets/stylesheets/framework/variables_overrides.scss +++ b/app/assets/stylesheets/framework/variables_overrides.scss @@ -20,3 +20,4 @@ $warning: $orange-500; $danger: $red-500; $zindex-modal-backdrop: 1040; $nav-divider-margin-y: ($grid-size / 2); +$dropdown-divider-bg: $theme-gray-200; diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index c6074eb9df4..37984a8666f 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -41,7 +41,7 @@ .issue-board-dropdown-content { margin: 0 8px 10px; padding-bottom: 10px; - border-bottom: 1px solid $dropdown-divider-color; + border-bottom: 1px solid $dropdown-divider-bg; > p { margin: 0; diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 8ea34f5d19d..bb6b6f84849 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -259,6 +259,16 @@ ul.related-merge-requests > li { display: block; } +.issue-sort-dropdown { + .btn-group { + width: 100%; + } + + .reverse-sort-btn { + color: $gl-text-color-secondary; + } +} + @include media-breakpoint-up(sm) { .emoji-block .row { display: flex; diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 0837599977f..a597996a362 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -102,7 +102,7 @@ module IssuableCollections elsif @group options[:group_id] = @group.id options[:include_subgroups] = true - options[:use_cte_for_search] = true + options[:attempt_group_search_optimizations] = true end params.permit(finder_type.valid_params).merge(options) @@ -167,12 +167,6 @@ module IssuableCollections case value when 'id_asc' then sort_value_oldest_created when 'id_desc' then sort_value_recently_created - when 'created_asc' then sort_value_created_date - when 'created_desc' then sort_value_created_date - when 'due_date_asc' then sort_value_due_date - when 'due_date_desc' then sort_value_due_date - when 'milestone_due_asc' then sort_value_milestone - when 'milestone_due_desc' then sort_value_milestone when 'downvotes_asc' then sort_value_popularity when 'downvotes_desc' then sort_value_popularity else value diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb index 8c22490700c..014232a7d05 100644 --- a/app/controllers/concerns/snippets_actions.rb +++ b/app/controllers/concerns/snippets_actions.rb @@ -10,6 +10,8 @@ module SnippetsActions def raw disposition = params[:inline] == 'false' ? 'attachment' : 'inline' + workhorse_set_content_type! + send_data( convert_line_endings(@snippet.content), type: 'text/plain; charset=utf-8', diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb index 5912fffc058..0eea0cdd50f 100644 --- a/app/controllers/concerns/uploads_actions.rb +++ b/app/controllers/concerns/uploads_actions.rb @@ -38,6 +38,7 @@ module UploadsActions return render_404 unless uploader + workhorse_set_content_type! send_upload(uploader, attachment: uploader.filename, disposition: disposition) end diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 3ecf94c008e..c58b30eace7 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -140,15 +140,22 @@ class Projects::JobsController < Projects::ApplicationController def raw if trace_artifact_file + workhorse_set_content_type! send_upload(trace_artifact_file, send_params: raw_send_params, redirect_params: raw_redirect_params) else build.trace.read do |stream| if stream.file? + workhorse_set_content_type! send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline' else - send_data stream.raw, type: 'text/plain; charset=utf-8', disposition: 'inline', filename: 'job.log' + # In this case we can't use workhorse_set_content_type! and let + # Workhorse handle the response because the data is streamed directly + # to the user but, because we have the trace content, we can calculate + # the proper content type and disposition here. + raw_data = stream.raw + send_data raw_data, type: 'text/plain; charset=utf-8', disposition: raw_trace_content_disposition(raw_data), filename: 'job.log' end end end @@ -201,4 +208,13 @@ class Projects::JobsController < Projects::ApplicationController def build_path(build) project_job_path(build.project, build) end + + def raw_trace_content_disposition(raw_data) + mime_type = MimeMagic.by_magic(raw_data) + + # if mime_type is nil can also represent 'text/plain' + return 'inline' if mime_type.nil? || mime_type.type == 'text/plain' + + 'attachment' + end end diff --git a/app/controllers/projects/serverless/functions_controller.rb b/app/controllers/projects/serverless/functions_controller.rb new file mode 100644 index 00000000000..0af2b7ef343 --- /dev/null +++ b/app/controllers/projects/serverless/functions_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Projects + module Serverless + class FunctionsController < Projects::ApplicationController + include ProjectUnauthorized + + before_action :authorize_read_cluster! + + INDEX_PRIMING_INTERVAL = 10_000 + INDEX_POLLING_INTERVAL = 30_000 + + def index + finder = Projects::Serverless::FunctionsFinder.new(project.clusters) + + respond_to do |format| + format.json do + functions = finder.execute + + if functions.any? + Gitlab::PollingInterval.set_header(response, interval: INDEX_POLLING_INTERVAL) + render json: Projects::Serverless::ServiceSerializer.new(current_user: @current_user).represent(functions) + else + Gitlab::PollingInterval.set_header(response, interval: INDEX_PRIMING_INTERVAL) + head :no_content + end + end + + format.html do + @installed = finder.installed? + render + end + end + end + end + end +end diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb index 1d76c90d4eb..30724de7f6a 100644 --- a/app/controllers/projects/settings/repository_controller.rb +++ b/app/controllers/projects/settings/repository_controller.rb @@ -5,6 +5,7 @@ module Projects class RepositoryController < Projects::ApplicationController before_action :authorize_admin_project! before_action :remote_mirror, only: [:show] + before_action :check_cleanup_feature_flag!, only: :cleanup def show render_show @@ -20,8 +21,26 @@ module Projects render_show end + def cleanup + cleanup_params = params.require(:project).permit(:bfg_object_map) + result = Projects::UpdateService.new(project, current_user, cleanup_params).execute + + if result[:status] == :success + RepositoryCleanupWorker.perform_async(project.id, current_user.id) + flash[:notice] = _('Repository cleanup has started. You will receive an email once the cleanup operation is complete.') + else + flash[:alert] = _('Failed to upload object map file') + end + + redirect_to project_settings_repository_path(project) + end + private + def check_cleanup_feature_flag! + render_404 unless ::Feature.enabled?(:project_cleanup, project) + end + def render_show @deploy_keys = DeployKeysPresenter.new(@project, current_user: current_user) @deploy_tokens = @project.deploy_tokens.active diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index e04e3a2a7e0..b73a3fa6e01 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -27,12 +27,13 @@ # created_before: datetime # updated_after: datetime # updated_before: datetime -# use_cte_for_search: boolean +# attempt_group_search_optimizations: boolean # class IssuableFinder prepend FinderWithCrossProjectAccess include FinderMethods include CreatedAtFilter + include Gitlab::Utils::StrongMemoize requires_cross_project_access unless: -> { project? } @@ -75,8 +76,9 @@ class IssuableFinder items = init_collection items = filter_items(items) - # This has to be last as we may use a CTE as an optimization fence by - # passing the use_cte_for_search param + # This has to be last as we may use a CTE as an optimization fence + # by passing the attempt_group_search_optimizations param and + # enabling the use_cte_for_group_issues_search feature flag # https://www.postgresql.org/docs/current/static/queries-with.html items = by_search(items) @@ -85,6 +87,8 @@ class IssuableFinder def filter_items(items) items = by_project(items) + items = by_group(items) + items = by_subquery(items) items = by_scope(items) items = by_created_at(items) items = by_updated_at(items) @@ -282,12 +286,31 @@ class IssuableFinder end # rubocop: enable CodeReuse/ActiveRecord + 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) + end + end + + def use_cte_for_search? + strong_memoize(:use_cte_for_search) do + attempt_group_search_optimizations? && + !use_subquery_for_search? && + Feature.enabled?(:use_cte_for_group_issues_search, default_enabled: true) + end + end + private def init_collection klass.all end + def attempt_group_search_optimizations? + search && Gitlab::Database.postgresql? && params[:attempt_group_search_optimizations] + end + def count_key(value) Array(value).last.to_sym end @@ -351,12 +374,13 @@ class IssuableFinder end # rubocop: enable CodeReuse/ActiveRecord - def use_cte_for_search? - return false unless search - return false unless Gitlab::Database.postgresql? - return false unless Feature.enabled?(:use_cte_for_group_issues_search, default_enabled: true) - - params[:use_cte_for_search] + # Wrap projects and groups in a subquery if the conditions are met. + def by_subquery(items) + if use_subquery_for_search? + klass.where(id: items.select(:id)) # rubocop: disable CodeReuse/ActiveRecord + else + items + end end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/finders/projects/serverless/functions_finder.rb b/app/finders/projects/serverless/functions_finder.rb new file mode 100644 index 00000000000..2b5d67e79d7 --- /dev/null +++ b/app/finders/projects/serverless/functions_finder.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Projects + module Serverless + class FunctionsFinder + def initialize(clusters) + @clusters = clusters + end + + def execute + knative_services.flatten.compact + end + + def installed? + clusters_with_knative_installed.exists? + end + + private + + def knative_services + clusters_with_knative_installed.preload_knative.map do |cluster| + cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace) + end + end + + def clusters_with_knative_installed + @clusters.with_knative_installed + end + end + end +end diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index ed13c5cfdd6..3f69af50f25 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -2,7 +2,12 @@ module AppearancesHelper def brand_title - current_appearance&.title.presence || 'GitLab Community Edition' + current_appearance&.title.presence || default_brand_title + end + + def default_brand_title + # This resides in a separate method so that EE can easily redefine it. + 'GitLab Community Edition' end def brand_image diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 638744a1426..bd42f00944f 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -140,6 +140,8 @@ module BlobHelper Gitlab::Sanitizers::SVG.clean(data) end + # Remove once https://gitlab.com/gitlab-org/gitlab-ce/issues/36103 is closed + # and :workhorse_set_content_type flag is removed # If we blindly set the 'real' content type when serving a Git blob we # are enabling XSS attacks. An attacker could upload e.g. a Javascript # file to a Git repository, trick the browser of a victim into @@ -161,6 +163,8 @@ module BlobHelper end def content_disposition(blob, inline) + # Remove the following line when https://gitlab.com/gitlab-org/gitlab-ce/issues/36103 + # is closed and :workhorse_set_content_type flag is removed return 'attachment' if blob.extension == 'svg' inline ? 'inline' : 'attachment' diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb new file mode 100644 index 00000000000..8e50bbc6c04 --- /dev/null +++ b/app/helpers/ide_helper.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module IdeHelper + def ide_data + { + "empty-state-svg-path" => image_path('illustrations/multi_file_editor_empty.svg'), + "no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'), + "committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'), + "pipelines-empty-state-svg-path": image_path('illustrations/pipelines_empty.svg'), + "promotion-svg-path": image_path('illustrations/web-ide_promotion.svg'), + "ci-help-page-path" => help_page_path('ci/quick_start/README'), + "web-ide-help-page-path" => help_page_path('user/project/web_ide/index.html'), + "clientside-preview-enabled": Gitlab::CurrentSettings.current_application_settings.web_ide_clientside_preview_enabled.to_s + } + end +end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 0a7f930110a..7ce6b04df7e 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -257,6 +257,10 @@ module ProjectsHelper "xcode://clone?repo=#{CGI.escape(default_url_to_repo(project))}" end + def link_to_bfg + link_to 'BFG', 'https://rtyley.github.io/bfg-repo-cleaner/', target: '_blank', rel: 'noopener noreferrer' + end + def legacy_render_context(params) params[:legacy_render] ? { markdown_engine: :redcarpet } : {} end @@ -307,6 +311,7 @@ module ProjectsHelper settings: :admin_project, builds: :read_build, clusters: :read_cluster, + serverless: :read_cluster, labels: :read_label, issues: :read_issue, project_members: :read_project_member, @@ -545,6 +550,7 @@ module ProjectsHelper %w[ environments clusters + functions user gcp ] diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 74113aee89d..f51b96ba8ce 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -136,6 +136,53 @@ module SortingHelper link_to item, path, class: sorted_by == item ? 'is-active' : '' end + def issuable_sort_option_overrides + { + sort_value_oldest_created => sort_value_created_date, + sort_value_oldest_updated => sort_value_recently_updated, + sort_value_milestone_later => sort_value_milestone + } + end + + def issuable_reverse_sort_order_hash + { + sort_value_created_date => sort_value_oldest_created, + sort_value_recently_created => sort_value_oldest_created, + sort_value_recently_updated => sort_value_oldest_updated, + sort_value_milestone => sort_value_milestone_later + }.merge(issuable_sort_option_overrides) + end + + def issuable_sort_option_title(sort_value) + sort_value = issuable_sort_option_overrides[sort_value] || sort_value + + sort_options_hash[sort_value] + end + + def issuable_sort_direction_button(sort_value) + link_class = 'btn btn-default has-tooltip reverse-sort-btn qa-reverse-sort' + reverse_sort = issuable_reverse_sort_order_hash[sort_value] + + if reverse_sort + reverse_url = page_filter_path(sort: reverse_sort) + else + reverse_url = '#' + link_class += ' disabled' + end + + link_to(reverse_url, type: 'button', class: link_class, title: 'Sort direction') do + icon_suffix = + case sort_value + when sort_value_milestone, sort_value_due_date, /_asc\z/ + 'lowest' + else + 'highest' + end + + sprite_icon("sort-#{icon_suffix}", size: 16) + end + end + # Titles. def sort_title_access_level_asc s_('SortOptions|Access level, ascending') diff --git a/app/helpers/workhorse_helper.rb b/app/helpers/workhorse_helper.rb index 49c08dce96c..e9fc39e451b 100644 --- a/app/helpers/workhorse_helper.rb +++ b/app/helpers/workhorse_helper.rb @@ -6,8 +6,13 @@ module WorkhorseHelper # Send a Git blob through Workhorse def send_git_blob(repository, blob, inline: true) headers.store(*Gitlab::Workhorse.send_git_blob(repository, blob)) + headers['Content-Disposition'] = content_disposition(blob, inline) headers['Content-Type'] = safe_content_type(blob) + + # If enabled, this will override the values set above + workhorse_set_content_type! + render plain: "" end @@ -40,4 +45,8 @@ module WorkhorseHelper def set_workhorse_internal_api_content_type headers['Content-Type'] = Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE end + + def workhorse_set_content_type! + headers[Gitlab::Workhorse::DETECT_HEADER] = "true" if Feature.enabled?(:workhorse_set_content_type) + end end diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index d7e6c2ba7b2..2500622caa7 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -24,6 +24,21 @@ module Emails subject: subject("Project export error")) end + def repository_cleanup_success_email(project, user) + @project = project + @user = user + + mail(to: user.notification_email, subject: subject("Project cleanup has completed")) + end + + def repository_cleanup_failure_email(project, user, error) + @project = project + @user = user + @error = error + + mail(to: user.notification_email, subject: subject("Project cleanup failure")) + end + def repository_push_email(project_id, opts = {}) @message = Gitlab::Email::Message::RepositoryPush.new(self, project_id, opts) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index d60861dc95f..d86a6eceb59 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -120,7 +120,7 @@ module Ci acts_as_taggable - add_authentication_token_field :token + add_authentication_token_field :token, encrypted: true, fallback: true before_save :update_artifacts_size, if: :artifacts_file_changed? before_save :ensure_token diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 60ff2181a95..d06022a0fb7 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -605,13 +605,18 @@ module Ci end def predefined_variables - Gitlab::Ci::Variables::Collection.new - .append(key: 'CI_PIPELINE_IID', value: iid.to_s) - .append(key: 'CI_CONFIG_PATH', value: ci_yaml_file_path) - .append(key: 'CI_PIPELINE_SOURCE', value: source.to_s) - .append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message.to_s) - .append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title.to_s) - .append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description.to_s) + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI_PIPELINE_IID', value: iid.to_s) + variables.append(key: 'CI_CONFIG_PATH', value: ci_yaml_file_path) + variables.append(key: 'CI_PIPELINE_SOURCE', value: source.to_s) + variables.append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message.to_s) + variables.append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title.to_s) + variables.append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description.to_s) + + if merge_request? && merge_request + variables.concat(merge_request.predefined_variables) + end + end end def queued_duration diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb index c0aaa8dce20..168a24da738 100644 --- a/app/models/clusters/applications/knative.rb +++ b/app/models/clusters/applications/knative.rb @@ -15,6 +15,9 @@ module Clusters include ::Clusters::Concerns::ApplicationVersion include ::Clusters::Concerns::ApplicationData include AfterCommitQueue + include ReactiveCaching + + self.reactive_cache_key = ->(knative) { [knative.class.model_name.singular, knative.id] } state_machine :status do before_transition any => [:installed] do |application| @@ -29,6 +32,8 @@ module Clusters validates :hostname, presence: true, hostname: true + scope :for_cluster, -> (cluster) { where(cluster: cluster) } + def chart 'knative/knative' end @@ -55,12 +60,39 @@ module Clusters ClusterWaitForIngressIpAddressWorker.perform_async(name, id) end + def client + cluster.kubeclient.knative_client + end + + def services + with_reactive_cache do |data| + data[:services] + end + end + + def calculate_reactive_cache + { services: read_services } + end + def ingress_service cluster.kubeclient.get_service('knative-ingressgateway', 'istio-system') end - def client - cluster.platform_kubernetes.kubeclient.knative_client + def services_for(ns: namespace) + return unless services + return [] unless ns + + services.select do |service| + service.dig('metadata', 'namespace') == ns + end + end + + private + + def read_services + client.get_services.as_json + rescue Kubeclient::ResourceNotFoundError + [] end end end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 67746e34913..c931b340b24 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ActiveRecord::Base - VERSION = '0.1.38'.freeze + VERSION = '0.1.39'.freeze self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index c9bd1728dbd..7fe43cd2de0 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -93,6 +93,16 @@ module Clusters where('NOT EXISTS (?)', subquery) end + scope :with_knative_installed, -> { joins(:application_knative).merge(Clusters::Applications::Knative.installed) } + + scope :preload_knative, -> { + preload( + :kubernetes_namespace, + :platform_kubernetes, + :application_knative + ) + } + def self.ancestor_clusters_for_clusterable(clusterable, hierarchy_order: :asc) hierarchy_groups = clusterable.ancestors_upto(hierarchy_order: hierarchy_order).eager_load(:clusters) hierarchy_groups = hierarchy_groups.merge(current_scope) if current_scope diff --git a/app/models/commit.rb b/app/models/commit.rb index 2c89da88b9b..a422a0995ff 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -177,7 +177,9 @@ class Commit def title return full_title if full_title.length < 100 - full_title.truncate(81, separator: ' ', omission: '…') + # Use three dots instead of the ellipsis Unicode character because + # some clients show the raw Unicode value in the merge commit. + full_title.truncate(81, separator: ' ', omission: '...') end # Returns the full commits title diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb index 60b7ec2815c..14bc56f0eee 100644 --- a/app/models/concerns/awardable.rb +++ b/app/models/concerns/awardable.rb @@ -43,14 +43,19 @@ module Awardable end def order_upvotes_desc - order_votes_desc(AwardEmoji::UPVOTE_NAME) + order_votes(AwardEmoji::UPVOTE_NAME, 'DESC') + end + + def order_upvotes_asc + order_votes(AwardEmoji::UPVOTE_NAME, 'ASC') end def order_downvotes_desc - order_votes_desc(AwardEmoji::DOWNVOTE_NAME) + order_votes(AwardEmoji::DOWNVOTE_NAME, 'DESC') end - def order_votes_desc(emoji_name) + # Order votes by emoji, optional sort order param `descending` defaults to true + def order_votes(emoji_name, direction) awardable_table = self.arel_table awards_table = AwardEmoji.arel_table @@ -62,7 +67,7 @@ module Awardable ) ).join_sources - joins(join_clause).group(awardable_table[:id]).reorder("COUNT(award_emoji.id) DESC") + joins(join_clause).group(awardable_table[:id]).reorder("COUNT(award_emoji.id) #{direction}") end end diff --git a/app/models/concerns/fast_destroy_all.rb b/app/models/concerns/fast_destroy_all.rb index 2bfa7da6c1c..1e3afd641ed 100644 --- a/app/models/concerns/fast_destroy_all.rb +++ b/app/models/concerns/fast_destroy_all.rb @@ -70,13 +70,14 @@ module FastDestroyAll module Helpers extend ActiveSupport::Concern + include AfterCommitQueue class_methods do ## # This method is to be defined on models which have fast destroyable models as children, # and let us avoid to use `dependent: :destroy` hook - def use_fast_destroy(relation) - before_destroy(prepend: true) do + def use_fast_destroy(relation, opts = {}) + set_callback :destroy, :before, opts.merge(prepend: true) do perform_fast_destroy(public_send(relation)) # rubocop:disable GitlabSecurity/PublicSend end end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 5080fe03cc8..0d363ec68b7 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -145,14 +145,16 @@ module Issuable def sort_by_attribute(method, excluded_labels: []) sorted = case method.to_s - when 'downvotes_desc' then order_downvotes_desc - when 'label_priority' then order_labels_priority(excluded_labels: excluded_labels) - when 'milestone' then order_milestone_due_asc - when 'milestone_due_asc' then order_milestone_due_asc - when 'milestone_due_desc' then order_milestone_due_desc - when 'popularity' then order_upvotes_desc - when 'priority' then order_due_date_and_labels_priority(excluded_labels: excluded_labels) - when 'upvotes_desc' then order_upvotes_desc + when 'downvotes_desc' then order_downvotes_desc + when 'label_priority' then order_labels_priority(excluded_labels: excluded_labels) + when 'label_priority_desc' then order_labels_priority('DESC', excluded_labels: excluded_labels) + when 'milestone', 'milestone_due_asc' then order_milestone_due_asc + when 'milestone_due_desc' then order_milestone_due_desc + when 'popularity', 'popularity_desc' then order_upvotes_desc + when 'popularity_asc' then order_upvotes_asc + when 'priority', 'priority_asc' then order_due_date_and_labels_priority(excluded_labels: excluded_labels) + when 'priority_desc' then order_due_date_and_labels_priority('DESC', excluded_labels: excluded_labels) + when 'upvotes_desc' then order_upvotes_desc else order_by(method) end @@ -160,7 +162,7 @@ module Issuable sorted.with_order_id_desc end - def order_due_date_and_labels_priority(excluded_labels: []) + def order_due_date_and_labels_priority(direction = 'ASC', excluded_labels: []) # The order_ methods also modify the query in other ways: # # - For milestones, we add a JOIN. @@ -177,11 +179,11 @@ module Issuable order_milestone_due_asc .order_labels_priority(excluded_labels: excluded_labels, extra_select_columns: [milestones_due_date]) - .reorder(Gitlab::Database.nulls_last_order(milestones_due_date, 'ASC'), - Gitlab::Database.nulls_last_order('highest_priority', 'ASC')) + .reorder(Gitlab::Database.nulls_last_order(milestones_due_date, direction), + Gitlab::Database.nulls_last_order('highest_priority', direction)) end - def order_labels_priority(excluded_labels: [], extra_select_columns: []) + def order_labels_priority(direction = 'ASC', excluded_labels: [], extra_select_columns: []) params = { target_type: name, target_column: "#{table_name}.id", @@ -198,7 +200,7 @@ module Issuable select(select_columns.join(', ')) .group(arel_table[:id]) - .reorder(Gitlab::Database.nulls_last_order('highest_priority', 'ASC')) + .reorder(Gitlab::Database.nulls_last_order('highest_priority', direction)) end def with_label(title, sort = nil) diff --git a/app/models/concerns/with_uploads.rb b/app/models/concerns/with_uploads.rb index 2bdef2a40e4..d79c0eae77e 100644 --- a/app/models/concerns/with_uploads.rb +++ b/app/models/concerns/with_uploads.rb @@ -17,6 +17,8 @@ module WithUploads extend ActiveSupport::Concern + include FastDestroyAll::Helpers + include FeatureGate # Currently there is no simple way how to select only not-mounted # uploads, it should be all FileUploaders so we select them by @@ -25,21 +27,40 @@ module WithUploads included do has_many :uploads, as: :model + has_many :file_uploads, -> { where(uploader: FILE_UPLOADERS) }, class_name: 'Upload', as: :model - before_destroy :destroy_file_uploads + # TODO: when feature flag is removed, we can use just dependent: destroy + # option on :file_uploads + before_destroy :remove_file_uploads + + use_fast_destroy :file_uploads, if: :fast_destroy_enabled? + end + + def retrieve_upload(_identifier, paths) + uploads.find_by(path: paths) end + private + # mounted uploads are deleted in carrierwave's after_commit hook, # but FileUploaders which are not mounted must be deleted explicitly and # it can not be done in after_commit because FileUploader requires loads # associated model on destroy (which is already deleted in after_commit) - def destroy_file_uploads - self.uploads.where(uploader: FILE_UPLOADERS).find_each do |upload| + def remove_file_uploads + fast_destroy_enabled? ? delete_uploads : destroy_uploads + end + + def delete_uploads + file_uploads.delete_all(:delete_all) + end + + def destroy_uploads + file_uploads.find_each do |upload| upload.destroy end end - def retrieve_upload(_identifier, paths) - uploads.find_by(path: paths) + def fast_destroy_enabled? + Feature.enabled?(:fast_destroy_uploads, self) end end diff --git a/app/models/member.rb b/app/models/member.rb index bc8ac14d148..9fc95ea00c3 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -7,6 +7,7 @@ class Member < ActiveRecord::Base include Expirable include Gitlab::Access include Presentable + include Gitlab::Utils::StrongMemoize attr_accessor :raw_invite_token @@ -22,6 +23,7 @@ class Member < ActiveRecord::Base message: "already exists in source", allow_nil: true } validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true + validate :higher_access_level_than_group, unless: :importing? validates :invite_email, presence: { if: :invite? @@ -364,6 +366,15 @@ class Member < ActiveRecord::Base end # rubocop: enable CodeReuse/ServiceClass + # Find the user's group member with a highest access level + def highest_group_member + strong_memoize(:highest_group_member) do + next unless user_id && source&.ancestors&.any? + + GroupMember.where(source: source.ancestors, user_id: user_id).order(:access_level).last + end + end + private def send_invite @@ -430,4 +441,12 @@ class Member < ActiveRecord::Base def notifiable_options {} end + + def higher_access_level_than_group + if highest_group_member && highest_group_member.access_level >= access_level + error_parameters = { access: highest_group_member.human_access, group_name: highest_group_member.group.name } + + errors.add(:access_level, s_("should be higher than %{access} inherited membership from group %{group_name}") % error_parameters) + end + end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index f40dff7c1bd..d0811a715bc 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -1070,6 +1070,42 @@ class MergeRequest < ActiveRecord::Base actual_head_pipeline&.has_test_reports? end + def predefined_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI_MERGE_REQUEST_ID', value: id.to_s) + variables.append(key: 'CI_MERGE_REQUEST_IID', value: iid.to_s) + + variables.append(key: 'CI_MERGE_REQUEST_REF_PATH', + value: ref_path.to_s) + + variables.append(key: 'CI_MERGE_REQUEST_PROJECT_ID', + value: project.id.to_s) + + variables.append(key: 'CI_MERGE_REQUEST_PROJECT_PATH', + value: project.full_path) + + variables.append(key: 'CI_MERGE_REQUEST_PROJECT_URL', + value: project.web_url) + + variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_NAME', + value: target_branch.to_s) + + if source_project + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_ID', + value: source_project.id.to_s) + + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_PATH', + value: source_project.full_path) + + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_URL', + value: source_project.web_url) + + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_NAME', + value: source_branch.to_s) + end + end + end + # rubocop: disable CodeReuse/ServiceClass def compare_test_reports unless has_test_reports? diff --git a/app/models/project.rb b/app/models/project.rb index 587bada469e..9e736a3b03c 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -339,6 +339,7 @@ class Project < ActiveRecord::Base presence: true, inclusion: { in: ->(_object) { Gitlab.config.repositories.storages.keys } } validates :variables, variable_duplicates: { scope: :environment_scope } + validates :bfg_object_map, file_size: { maximum: :max_attachment_size } # Scopes scope :pending_delete, -> { where(pending_delete: true) } @@ -412,6 +413,9 @@ class Project < ActiveRecord::Base only_integer: true, message: 'needs to be beetween 10 minutes and 1 month' } + # Used by Projects::CleanupService to hold a map of rewritten object IDs + mount_uploader :bfg_object_map, AttachmentUploader + # Returns a project, if it is not about to be removed. # # id - The ID of the project to retrieve. @@ -570,6 +574,8 @@ class Project < ActiveRecord::Base .base_and_ancestors(upto: top, hierarchy_order: hierarchy_order) end + alias_method :ancestors, :ancestors_upto + def lfs_enabled? return namespace.lfs_enabled? if self[:lfs_enabled].nil? @@ -1971,6 +1977,10 @@ class Project < ActiveRecord::Base Ability.allowed?(user, :read_project_snippet, self) end + def max_attachment_size + Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i + end + private def use_hashed_storage diff --git a/app/models/upload.rb b/app/models/upload.rb index e01e9c6a4f0..20860f14b83 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -25,6 +25,25 @@ class Upload < ActiveRecord::Base Digest::SHA256.file(path).hexdigest end + class << self + ## + # FastDestroyAll concerns + def begin_fast_destroy + { + Uploads::Local => Uploads::Local.new.keys(with_files_stored_locally), + Uploads::Fog => Uploads::Fog.new.keys(with_files_stored_remotely) + } + end + + ## + # FastDestroyAll concerns + def finalize_fast_destroy(keys) + keys.each do |store_class, paths| + store_class.new.delete_keys_async(paths) + end + end + end + def absolute_path raise ObjectStorage::RemoteStoreError, "Remote object has no absolute path." unless local? return path unless relative_path? diff --git a/app/models/uploads/base.rb b/app/models/uploads/base.rb new file mode 100644 index 00000000000..f9814159958 --- /dev/null +++ b/app/models/uploads/base.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Uploads + class Base + BATCH_SIZE = 100 + + attr_reader :logger + + def initialize(logger: nil) + @logger ||= Rails.logger + end + + def delete_keys_async(keys_to_delete) + keys_to_delete.each_slice(BATCH_SIZE) do |batch| + DeleteStoredFilesWorker.perform_async(self.class, batch) + end + end + end +end diff --git a/app/models/uploads/fog.rb b/app/models/uploads/fog.rb new file mode 100644 index 00000000000..b44e273e9ab --- /dev/null +++ b/app/models/uploads/fog.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Uploads + class Fog < Base + include ::Gitlab::Utils::StrongMemoize + + def available? + object_store.enabled + end + + def keys(relation) + return [] unless available? + + relation.pluck(:path) + end + + def delete_keys(keys) + keys.each do |key| + connection.delete_object(bucket_name, key) + end + end + + private + + def object_store + Gitlab.config.uploads.object_store + end + + def bucket_name + return unless available? + + object_store.remote_directory + end + + def connection + return unless available? + + strong_memoize(:connection) do + ::Fog::Storage.new(object_store.connection.to_hash.deep_symbolize_keys) + end + end + end +end diff --git a/app/models/uploads/local.rb b/app/models/uploads/local.rb new file mode 100644 index 00000000000..2901c33c359 --- /dev/null +++ b/app/models/uploads/local.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Uploads + class Local < Base + def keys(relation) + relation.includes(:model).find_each.map(&:absolute_path) + end + + def delete_keys(keys) + keys.each do |path| + delete_file(path) + end + end + + private + + def delete_file(path) + unless exists?(path) + logger.warn("File '#{path}' doesn't exist, skipping") + return + end + + unless in_uploads?(path) + message = "Path '#{path}' is not in uploads dir, skipping" + logger.warn(message) + Gitlab::Sentry.track_exception(RuntimeError.new(message), extra: { uploads_dir: storage_dir }) + return + end + + FileUtils.rm(path) + delete_dir!(File.dirname(path)) + end + + def exists?(path) + path.present? && File.exist?(path) + end + + def in_uploads?(path) + path.start_with?(storage_dir) + end + + def delete_dir!(path) + Dir.rmdir(path) + rescue Errno::ENOENT + # Ignore: path does not exist + rescue Errno::ENOTDIR + # Ignore: path is not a dir + rescue Errno::ENOTEMPTY, Errno::EEXIST + # Ignore: dir is not empty + end + + def storage_dir + @storage_dir ||= File.realpath(Gitlab.config.uploads.storage_path) + end + end +end diff --git a/app/presenters/group_clusterable_presenter.rb b/app/presenters/group_clusterable_presenter.rb index d963c188559..ef6bbc0d109 100644 --- a/app/presenters/group_clusterable_presenter.rb +++ b/app/presenters/group_clusterable_presenter.rb @@ -31,6 +31,6 @@ class GroupClusterablePresenter < ClusterablePresenter override :learn_more_link def learn_more_link - link_to(s_('ClusterIntegration|Learn more about group Kubernetes clusters'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer') + link_to(s_('ClusterIntegration|Learn more about group Kubernetes clusters'), help_page_path('user/group/clusters/index'), target: '_blank', rel: 'noopener noreferrer') end end diff --git a/app/presenters/member_presenter.rb b/app/presenters/member_presenter.rb index 2497bea4aff..9e9b6973b8e 100644 --- a/app/presenters/member_presenter.rb +++ b/app/presenters/member_presenter.rb @@ -7,6 +7,14 @@ class MemberPresenter < Gitlab::View::Presenter::Delegated member.class.access_level_roles end + def valid_level_roles + return access_level_roles unless member.highest_group_member + + access_level_roles.reject do |_name, level| + member.highest_group_member.access_level > level + end + end + def can_resend_invite? invite? && can?(current_user, admin_member_permission, source) diff --git a/app/serializers/diff_file_base_entity.rb b/app/serializers/diff_file_base_entity.rb new file mode 100644 index 00000000000..06a8db78476 --- /dev/null +++ b/app/serializers/diff_file_base_entity.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +class DiffFileBaseEntity < Grape::Entity + include RequestAwareEntity + include BlobHelper + include SubmoduleHelper + include DiffHelper + include TreeHelper + include ChecksCollaboration + include Gitlab::Utils::StrongMemoize + + expose :content_sha + expose :submodule?, as: :submodule + + expose :submodule_link do |diff_file| + memoized_submodule_links(diff_file).first + end + + expose :submodule_tree_url do |diff_file| + memoized_submodule_links(diff_file).last + end + + expose :edit_path, if: -> (_, options) { options[:merge_request] } do |diff_file| + merge_request = options[:merge_request] + + options = merge_request.persisted? ? { from_merge_request_iid: merge_request.iid } : {} + + next unless merge_request.source_project + + project_edit_blob_path(merge_request.source_project, + tree_join(merge_request.source_branch, diff_file.new_path), + options) + end + + expose :old_path_html do |diff_file| + old_path, _ = mark_inline_diffs(diff_file.old_path, diff_file.new_path) + old_path + end + + expose :new_path_html do |diff_file| + _, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path) + new_path + end + + expose :formatted_external_url, if: -> (_, options) { options[:environment] } do |diff_file| + options[:environment].formatted_external_url + end + + expose :external_url, if: -> (_, options) { options[:environment] } do |diff_file| + options[:environment].external_url_for(diff_file.new_path, diff_file.content_sha) + end + + expose :blob, using: BlobEntity + + expose :can_modify_blob do |diff_file| + merge_request = options[:merge_request] + + next unless diff_file.blob + + if merge_request&.source_project && current_user + can_modify_blob?(diff_file.blob, merge_request.source_project, merge_request.source_branch) + else + false + end + end + + expose :file_hash do |diff_file| + Digest::SHA1.hexdigest(diff_file.file_path) + end + + expose :file_path + expose :old_path + expose :new_path + expose :new_file?, as: :new_file + expose :collapsed?, as: :collapsed + expose :text?, as: :text + expose :diff_refs + expose :stored_externally?, as: :stored_externally + expose :external_storage + expose :renamed_file?, as: :renamed_file + expose :deleted_file?, as: :deleted_file + expose :mode_changed?, as: :mode_changed + expose :a_mode + expose :b_mode + + private + + def memoized_submodule_links(diff_file) + strong_memoize(:submodule_links) do + if diff_file.submodule? + submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository) + else + [] + end + end + end + + def current_user + request.current_user + end +end diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb index 63ea8e8f95f..f0881829efd 100644 --- a/app/serializers/diff_file_entity.rb +++ b/app/serializers/diff_file_entity.rb @@ -1,63 +1,12 @@ # frozen_string_literal: true -class DiffFileEntity < Grape::Entity - include RequestAwareEntity +class DiffFileEntity < DiffFileBaseEntity include CommitsHelper - include DiffHelper - include SubmoduleHelper - include BlobHelper include IconsHelper - include TreeHelper - include ChecksCollaboration - include Gitlab::Utils::StrongMemoize - expose :submodule?, as: :submodule - - expose :submodule_link do |diff_file| - memoized_submodule_links(diff_file).first - end - - expose :submodule_tree_url do |diff_file| - memoized_submodule_links(diff_file).last - end - - expose :blob, using: BlobEntity - - expose :can_modify_blob do |diff_file| - merge_request = options[:merge_request] - - next unless diff_file.blob - - if merge_request&.source_project && current_user - can_modify_blob?(diff_file.blob, merge_request.source_project, merge_request.source_branch) - else - false - end - end - - expose :file_hash do |diff_file| - Digest::SHA1.hexdigest(diff_file.file_path) - end - - expose :file_path expose :too_large?, as: :too_large - expose :collapsed?, as: :collapsed - expose :new_file?, as: :new_file - - expose :deleted_file?, as: :deleted_file - expose :renamed_file?, as: :renamed_file - expose :old_path - expose :new_path - expose :mode_changed?, as: :mode_changed - expose :a_mode - expose :b_mode - expose :text?, as: :text expose :added_lines expose :removed_lines - expose :diff_refs - expose :content_sha - expose :stored_externally?, as: :stored_externally - expose :external_storage expose :load_collapsed_diff_url, if: -> (diff_file, options) { diff_file.text? && options[:merge_request] } do |diff_file| merge_request = options[:merge_request] @@ -75,36 +24,6 @@ class DiffFileEntity < Grape::Entity ) end - expose :formatted_external_url, if: -> (_, options) { options[:environment] } do |diff_file| - options[:environment].formatted_external_url - end - - expose :external_url, if: -> (_, options) { options[:environment] } do |diff_file| - options[:environment].external_url_for(diff_file.new_path, diff_file.content_sha) - end - - expose :old_path_html do |diff_file| - old_path, _ = mark_inline_diffs(diff_file.old_path, diff_file.new_path) - old_path - end - - expose :new_path_html do |diff_file| - _, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path) - new_path - end - - expose :edit_path, if: -> (_, options) { options[:merge_request] } do |diff_file| - merge_request = options[:merge_request] - - options = merge_request.persisted? ? { from_merge_request_iid: merge_request.iid } : {} - - next unless merge_request.source_project - - project_edit_blob_path(merge_request.source_project, - tree_join(merge_request.source_branch, diff_file.new_path), - options) - end - expose :view_path, if: -> (_, options) { options[:merge_request] } do |diff_file| merge_request = options[:merge_request] @@ -145,18 +64,4 @@ class DiffFileEntity < Grape::Entity # Used for parallel diffs expose :parallel_diff_lines, using: DiffLineParallelEntity, if: -> (diff_file, _) { diff_file.text? } - - def current_user - request.current_user - end - - def memoized_submodule_links(diff_file) - strong_memoize(:submodule_links) do - if diff_file.submodule? - submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository) - else - [] - end - end - end end diff --git a/app/serializers/discussion_diff_file_entity.rb b/app/serializers/discussion_diff_file_entity.rb new file mode 100644 index 00000000000..419e7edf94f --- /dev/null +++ b/app/serializers/discussion_diff_file_entity.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class DiscussionDiffFileEntity < DiffFileBaseEntity +end diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb index b6786a0d597..b2d9d52bd22 100644 --- a/app/serializers/discussion_entity.rb +++ b/app/serializers/discussion_entity.rb @@ -36,7 +36,7 @@ class DiscussionEntity < Grape::Entity new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id) end - expose :diff_file, using: DiffFileEntity, if: -> (d, _) { d.diff_discussion? } + expose :diff_file, using: DiscussionDiffFileEntity, if: -> (d, _) { d.diff_discussion? } expose :diff_discussion?, as: :diff_discussion @@ -46,19 +46,6 @@ class DiscussionEntity < Grape::Entity expose :truncated_diff_lines, using: DiffLineEntity, if: -> (d, _) { d.diff_discussion? && d.on_text? && (d.expanded? || render_truncated_diff_lines?) } - expose :image_diff_html, if: -> (d, _) { d.diff_discussion? && d.on_image? } do |discussion| - diff_file = discussion.diff_file - partial = diff_file.new_file? || diff_file.deleted_file? ? 'single_image_diff' : 'replaced_image_diff' - options[:context].render_to_string( - partial: "projects/diffs/#{partial}", - locals: { diff_file: diff_file, - position: discussion.position.to_json, - click_to_comment: false }, - layout: false, - formats: [:html] - ) - end - expose :for_commit?, as: :for_commit expose :commit_id diff --git a/app/serializers/projects/serverless/service_entity.rb b/app/serializers/projects/serverless/service_entity.rb new file mode 100644 index 00000000000..4f1f62d145b --- /dev/null +++ b/app/serializers/projects/serverless/service_entity.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Projects + module Serverless + class ServiceEntity < Grape::Entity + include RequestAwareEntity + + expose :name do |service| + service.dig('metadata', 'name') + end + + expose :namespace do |service| + service.dig('metadata', 'namespace') + end + + expose :created_at do |service| + service.dig('metadata', 'creationTimestamp') + end + + expose :url do |service| + "http://#{service.dig('status', 'domain')}" + end + + expose :description do |service| + service.dig('spec', 'runLatest', 'configuration', 'revisionTemplate', 'metadata', 'annotations', 'Description') + end + + expose :image do |service| + service.dig('spec', 'runLatest', 'configuration', 'build', 'template', 'name') + end + end + end +end diff --git a/app/serializers/projects/serverless/service_serializer.rb b/app/serializers/projects/serverless/service_serializer.rb new file mode 100644 index 00000000000..adfd48a8c7d --- /dev/null +++ b/app/serializers/projects/serverless/service_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Projects + module Serverless + class ServiceSerializer < BaseSerializer + entity Projects::Serverless::ServiceEntity + end + end +end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 5904bfbf88d..e24ef7f9c87 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -466,6 +466,14 @@ class NotificationService end end + def repository_cleanup_success(project, user) + mailer.send(:repository_cleanup_success_email, project, user).deliver_later + end + + def repository_cleanup_failure(project, user, error) + mailer.send(:repository_cleanup_failure_email, project, user, error).deliver_later + end + protected def new_resource_email(target, method) diff --git a/app/services/projects/cleanup_service.rb b/app/services/projects/cleanup_service.rb new file mode 100644 index 00000000000..12103ea34b5 --- /dev/null +++ b/app/services/projects/cleanup_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Projects + # The CleanupService removes data from the project repository following a + # BFG rewrite: https://rtyley.github.io/bfg-repo-cleaner/ + # + # Before executing this service, all refs rewritten by BFG should have been + # pushed to the repository + class CleanupService < BaseService + NoUploadError = StandardError.new("Couldn't find uploaded object map") + + include Gitlab::Utils::StrongMemoize + + # Attempt to clean up the project following the push. Warning: this is + # destructive! + # + # path is the path of an upload of a BFG object map file. It contains a line + # per rewritten object, with the old and new SHAs space-separated. It can be + # used to update or remove content that references the objects that BFG has + # altered + # + # Currently, only the project repository is modified by this service, but we + # may wish to modify other data sources in the future. + def execute + apply_bfg_object_map! + + # Remove older objects that are no longer referenced + GitGarbageCollectWorker.new.perform(project.id, :gc) + + # The cache may now be inaccurate, and holding onto it could prevent + # bugs assuming the presence of some object from manifesting for some + # time. Better to feel the pain immediately. + project.repository.expire_all_method_caches + + project.bfg_object_map.remove! + end + + private + + def apply_bfg_object_map! + raise NoUploadError unless project.bfg_object_map.exists? + + project.bfg_object_map.open do |io| + repository_cleaner.apply_bfg_object_map(io) + end + end + + def repository_cleaner + @repository_cleaner ||= Gitlab::Git::RepositoryCleaner.new(repository.raw) + end + end +end diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml index 85d1002243b..73b11d509d3 100644 --- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml +++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml @@ -1,6 +1,6 @@ - link = link_to(s_('ClusterIntegration|sign up'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer') -.bs-callout.gcp-signup-offer.alert.alert-block.alert-dismissable.prepend-top-default.append-bottom-default{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } } - %button.close.js-close{ type: "button" } × +.bs-callout.gcp-signup-offer.alert.alert-block.alert-dismissable.prepend-top-default.append-bottom-default{ role: 'alert' } + %button.close{ type: "button", data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } } × .gcp-signup-offer--content .gcp-signup-offer--icon.append-right-8 = sprite_icon("information", size: 16) diff --git a/app/views/dashboard/activity.html.haml b/app/views/dashboard/activity.html.haml index 4dbda5c754b..31d4b3da4f1 100644 --- a/app/views/dashboard/activity.html.haml +++ b/app/views/dashboard/activity.html.haml @@ -4,9 +4,6 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity") - -= render_if_exists "shared/gold_trial_callout" - - page_title "Activity" - header_title "Activity", activity_dashboard_path diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml index 2f7add600e4..50f39f93283 100644 --- a/app/views/dashboard/groups/index.html.haml +++ b/app/views/dashboard/groups/index.html.haml @@ -1,8 +1,6 @@ - @hide_top_links = true - page_title "Groups" - header_title "Groups", dashboard_groups_path - -= render_if_exists "shared/gold_trial_callout" = render 'dashboard/groups_head' - if params[:filter].blank? && @groups.empty? diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml index afd46412fab..fdd5c19d562 100644 --- a/app/views/dashboard/issues.html.haml +++ b/app/views/dashboard/issues.html.haml @@ -4,8 +4,6 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{current_user.name} issues") -= render_if_exists "shared/gold_trial_callout" - .page-title-holder %h1.page-title= _('Issues') diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml index 3e5f13b92e3..77cfa1271df 100644 --- a/app/views/dashboard/merge_requests.html.haml +++ b/app/views/dashboard/merge_requests.html.haml @@ -2,8 +2,6 @@ - page_title _("Merge Requests") - @breadcrumb_link = merge_requests_dashboard_path(assignee_username: current_user.username) -= render_if_exists "shared/gold_trial_callout" - .page-title-holder %h1.page-title= _('Merge Requests') diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml index 446b4715b2d..deed774a4a5 100644 --- a/app/views/dashboard/projects/index.html.haml +++ b/app/views/dashboard/projects/index.html.haml @@ -4,8 +4,6 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity") -= render_if_exists "shared/gold_trial_callout" - - page_title "Projects" - header_title "Projects", dashboard_projects_path diff --git a/app/views/dashboard/projects/starred.html.haml b/app/views/dashboard/projects/starred.html.haml index ad08409c8fe..8933d9e31ff 100644 --- a/app/views/dashboard/projects/starred.html.haml +++ b/app/views/dashboard/projects/starred.html.haml @@ -4,8 +4,6 @@ - page_title "Starred Projects" - header_title "Projects", dashboard_projects_path -= render_if_exists "shared/gold_trial_callout" - %div{ class: container_class } = render "projects/last_push" = render 'dashboard/projects_head' diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index 47729321961..d2593179f17 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -2,8 +2,6 @@ - page_title "Todos" - header_title "Todos", dashboard_todos_path -= render_if_exists "shared/gold_trial_callout" - .page-title-holder %h1.page-title= _('Todos') diff --git a/app/views/errors/access_denied.html.haml b/app/views/errors/access_denied.html.haml index 8ae29b9d337..46931b5932d 100644 --- a/app/views/errors/access_denied.html.haml +++ b/app/views/errors/access_denied.html.haml @@ -9,7 +9,7 @@ %p = message %p - = s_('403|Please contact your GitLab administrator to get the permission.') + = s_('403|Please contact your GitLab administrator to get permission.') .action-container.js-go-back{ style: 'display: none' } %a{ href: 'javascript:history.back()', class: 'btn btn-success' } = s_('Go Back') diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml index 869be4e8581..a3eafc61d0a 100644 --- a/app/views/explore/groups/index.html.haml +++ b/app/views/explore/groups/index.html.haml @@ -2,8 +2,6 @@ - page_title _("Groups") - header_title _("Groups"), dashboard_groups_path -= render_if_exists "shared/gold_trial_callout" - - if current_user = render 'dashboard/groups_head' - else diff --git a/app/views/explore/projects/index.html.haml b/app/views/explore/projects/index.html.haml index d18dec7bd8e..452f390695c 100644 --- a/app/views/explore/projects/index.html.haml +++ b/app/views/explore/projects/index.html.haml @@ -2,8 +2,6 @@ - page_title _("Projects") - header_title _("Projects"), dashboard_projects_path -= render_if_exists "shared/gold_trial_callout" - - if current_user = render 'dashboard/projects_head' - else diff --git a/app/views/explore/projects/starred.html.haml b/app/views/explore/projects/starred.html.haml index d18dec7bd8e..452f390695c 100644 --- a/app/views/explore/projects/starred.html.haml +++ b/app/views/explore/projects/starred.html.haml @@ -2,8 +2,6 @@ - page_title _("Projects") - header_title _("Projects"), dashboard_projects_path -= render_if_exists "shared/gold_trial_callout" - - if current_user = render 'dashboard/projects_head' - else diff --git a/app/views/explore/projects/trending.html.haml b/app/views/explore/projects/trending.html.haml index d18dec7bd8e..452f390695c 100644 --- a/app/views/explore/projects/trending.html.haml +++ b/app/views/explore/projects/trending.html.haml @@ -2,8 +2,6 @@ - page_title _("Projects") - header_title _("Projects"), dashboard_projects_path -= render_if_exists "shared/gold_trial_callout" - - if current_user = render 'dashboard/projects_head' - else diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml new file mode 100644 index 00000000000..b24d6e27536 --- /dev/null +++ b/app/views/ide/_show.html.haml @@ -0,0 +1,10 @@ +- @body_class = 'ide-layout' +- page_title 'IDE' + +- content_for :page_specific_javascripts do + = stylesheet_link_tag 'page_bundles/ide' + +#ide.ide-loading{ data: ide_data() } + .text-center + = icon('spinner spin 2x') + %h2.clgray= _('Loading the GitLab IDE...') diff --git a/app/views/ide/index.html.haml b/app/views/ide/index.html.haml index d8bd37fe986..0323f9d093d 100644 --- a/app/views/ide/index.html.haml +++ b/app/views/ide/index.html.haml @@ -1,17 +1 @@ -- @body_class = 'ide-layout' -- page_title 'IDE' - -- content_for :page_specific_javascripts do - = stylesheet_link_tag 'page_bundles/ide' - -#ide.ide-loading{ data: {"empty-state-svg-path" => image_path('illustrations/multi_file_editor_empty.svg'), - "no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'), - "committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'), - "pipelines-empty-state-svg-path": image_path('illustrations/pipelines_empty.svg'), - "promotion-svg-path": image_path('illustrations/web-ide_promotion.svg'), - "ci-help-page-path" => help_page_path('ci/quick_start/README'), - "web-ide-help-page-path" => help_page_path('user/project/web_ide/index.html'), - "clientside-preview-enabled": Gitlab::CurrentSettings.current_application_settings.web_ide_clientside_preview_enabled.to_s } } - .text-center - = icon('spinner spin 2x') - %h2.clgray= _('Loading the GitLab IDE...') += render 'ide/show' diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index ab15889a465..b89541a3c9f 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -222,6 +222,12 @@ %span = _('Environments') + - if project_nav_tab? :serverless + = nav_link(controller: :functions) do + = link_to project_serverless_functions_path(@project), title: _('Serverless') do + %span + = _('Serverless') + - if project_nav_tab? :clusters - show_cluster_hint = show_gke_cluster_integration_callout?(@project) = nav_link(controller: [:clusters, :user, :gcp]) do diff --git a/app/views/notify/repository_cleanup_failure_email.text.erb b/app/views/notify/repository_cleanup_failure_email.text.erb new file mode 100644 index 00000000000..f5a426a51d1 --- /dev/null +++ b/app/views/notify/repository_cleanup_failure_email.text.erb @@ -0,0 +1,3 @@ +Repository cleanup failed on <%= @project.web_url %> + +<%= @error %> diff --git a/app/views/notify/repository_cleanup_success_email.text.erb b/app/views/notify/repository_cleanup_success_email.text.erb new file mode 100644 index 00000000000..e6e95da2fcc --- /dev/null +++ b/app/views/notify/repository_cleanup_success_email.text.erb @@ -0,0 +1,3 @@ +Repository cleanup succeeded on <%= @project.web_url %> + +Repository size is now <%= "%.1f" % (@project.repository.size || 0) %> MiB diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml new file mode 100644 index 00000000000..778d27fc61d --- /dev/null +++ b/app/views/projects/cleanup/_show.html.haml @@ -0,0 +1,31 @@ +- return unless Feature.enabled?(:project_cleanup, @project) + +- expanded = Rails.env.test? + +%section.settings.no-animate#cleanup{ class: ('expanded' if expanded) } + .settings-header + %h4= _('Repository cleanup') + %button.btn.js-settings-toggle + = expanded ? _('Collapse') : _('Expand') + %p + = _("Clean up after running %{bfg} on the repository" % { bfg: link_to_bfg }).html_safe + = link_to icon('question-circle'), + help_page_path('user/project/repository/reducing_the_repo_size_using_git.md'), + target: '_blank', rel: 'noopener noreferrer' + + .settings-content + - url = cleanup_namespace_project_settings_repository_path(@project.namespace, @project) + = form_for @project, url: url, method: :post, authenticity_token: true, html: { class: 'js-requires-input' } do |f| + %fieldset.prepend-top-0.append-bottom-10 + .append-bottom-10 + %h5.prepend-top-0 + = _("Upload object map") + %button.btn.btn-default.js-choose-file{ type: "button" } + = _("Choose a file") + %span.prepend-left-default.js-filename + = _("No file selected") + = f.file_field :bfg_object_map, accept: 'text/plain', class: "hidden js-object-map-input", required: true + .form-text.text-muted + = _("The maximum file size allowed is %{max_attachment_size}mb") % { max_attachment_size: Gitlab::CurrentSettings.max_attachment_size } + = f.submit _('Start cleanup'), class: 'btn btn-success' + diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index f376df29878..1b52821af15 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -53,7 +53,7 @@ = _("Project avatar in repository: %{link}").html_safe % { link: @project.avatar_in_git } .prepend-top-5.append-bottom-10 %button.btn.js-choose-project-avatar-button{ type: 'button' }= _("Choose file...") - %span.file_name.prepend-left-default.js-avatar-filename= _("No file chosen") + %span.file_name.prepend-left-default.js-filename= _("No file chosen") = f.file_field :avatar, class: "js-project-avatar-input hidden" .form-text.text-muted= _("The maximum file size allowed is 200KB.") - if @project.avatar? diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 2c6484c2c99..56b06374d6d 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -5,7 +5,7 @@ - subscribed = params[:subscribed] - labels_or_filters = @labels.exists? || @prioritized_labels.exists? || search.present? || subscribed.present? -- if @labels.present? && can_admin_label +- if labels_or_filters && can_admin_label - content_for(:header_content) do .nav-controls = link_to _('New label'), new_project_label_path(@project), class: "btn btn-success qa-label-create-new" diff --git a/app/views/projects/mirrors/_authentication_method.html.haml b/app/views/projects/mirrors/_authentication_method.html.haml index 3effdf934fb..293a2e3ebfe 100644 --- a/app/views/projects/mirrors/_authentication_method.html.haml +++ b/app/views/projects/mirrors/_authentication_method.html.haml @@ -8,14 +8,14 @@ = f.label :auth_method, _('Authentication method'), class: 'label-bold' = f.select :auth_method, options_for_select(auth_options, mirror.auth_method), - {}, { class: "form-control js-mirror-auth-type" } + {}, { class: "form-control js-mirror-auth-type qa-authentication-method" } .form-group .collapse.js-well-changing-auth .changing-auth-method= icon('spinner spin lg') .well-password-auth.collapse.js-well-password-auth = f.label :password, _("Password"), class: "label-bold" - = f.password_field :password, value: mirror.password, class: 'form-control', autocomplete: 'new-password' + = f.password_field :password, value: mirror.password, class: 'form-control qa-password', autocomplete: 'new-password' - unless is_push .well-ssh-auth.collapse.js-well-ssh-auth %p.js-ssh-public-key-present{ class: ('collapse' unless ssh_public_key_present) } diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index dde0fae740b..21b105e6f80 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -1,7 +1,7 @@ - expanded = Rails.env.test? - protocols = Gitlab::UrlSanitizer::ALLOWED_SCHEMES.join('|') -%section.settings.project-mirror-settings.js-mirror-settings.no-animate#js-push-remote-settings{ class: ('expanded' if expanded) } +%section.settings.project-mirror-settings.js-mirror-settings.no-animate.qa-mirroring-repositories-settings#js-push-remote-settings{ class: ('expanded' if expanded) } .settings-header %h4= _('Mirroring repositories') %button.btn.js-settings-toggle @@ -20,7 +20,7 @@ .form-group.has-feedback = label_tag :url, _('Git repository URL'), class: 'label-light' - = text_field_tag :url, nil, class: 'form-control js-mirror-url js-repo-url', placeholder: _('Input your repository URL'), required: true, pattern: "(#{protocols}):\/\/.+" + = text_field_tag :url, nil, class: 'form-control js-mirror-url js-repo-url qa-mirror-repository-url-input', placeholder: _('Input your repository URL'), required: true, pattern: "(#{protocols}):\/\/.+" = render 'projects/mirrors/instructions' @@ -32,7 +32,7 @@ = link_to icon('question-circle'), help_page_path('user/project/protected_branches') .panel-footer - = f.submit _('Mirror repository'), class: 'btn btn-success js-mirror-submit', name: :update_remote_mirror + = f.submit _('Mirror repository'), class: 'btn btn-success js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror .panel.panel-default .table-responsive @@ -50,10 +50,10 @@ = render_if_exists 'projects/mirrors/table_pull_row' - @project.remote_mirrors.each_with_index do |mirror, index| - if mirror.enabled - %tr - %td= mirror.safe_url + %tr.qa-mirrored-repository-row + %td.qa-mirror-repository-url= mirror.safe_url %td= _('Push') - %td= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never') + %td.qa-mirror-last-update-at= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never') %td - if mirror.last_error.present? .badge.mirror-error-badge{ data: { toggle: 'tooltip', html: 'true' }, title: html_escape(mirror.last_error.try(:strip)) }= _('Error') diff --git a/app/views/projects/mirrors/_mirror_repos_form.html.haml b/app/views/projects/mirrors/_mirror_repos_form.html.haml index a2cce83bfab..b49f1d9315e 100644 --- a/app/views/projects/mirrors/_mirror_repos_form.html.haml +++ b/app/views/projects/mirrors/_mirror_repos_form.html.haml @@ -1,5 +1,5 @@ .form-group = label_tag :mirror_direction, _('Mirror direction'), class: 'label-light' - = select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control js-mirror-direction', disabled: true + = select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control js-mirror-direction qa-mirror-direction', disabled: true = render partial: "projects/mirrors/mirror_repos_push", locals: { f: f } diff --git a/app/views/projects/serverless/functions/index.html.haml b/app/views/projects/serverless/functions/index.html.haml new file mode 100644 index 00000000000..f650fa0f38f --- /dev/null +++ b/app/views/projects/serverless/functions/index.html.haml @@ -0,0 +1,15 @@ +- @no_container = true +- @content_class = "limit-container-width" unless fluid_layout +- breadcrumb_title 'Serverless' +- page_title 'Serverless' +- status_path = project_serverless_functions_path(@project, format: :json) +- clusters_path = project_clusters_path(@project) + +.serverless-functions-page.js-serverless-functions-page{ data: { status_path: status_path, installed: @installed, clusters_path: clusters_path, help_path: help_page_path('user/project/clusters/serverless/index') } } + +%div{ class: [container_class, ('limit-container-width' unless fluid_layout)] } + .js-serverless-functions-notice + .flash-container + + .top-area.adjust + .serverless-functions-table#js-serverless-functions diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index c14e95a382c..cb3a035c49e 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -13,3 +13,4 @@ = render "projects/protected_tags/index" = render @deploy_keys = render "projects/deploy_tokens/index" += render "projects/cleanup/show" diff --git a/app/views/search/results/_blob.html.haml b/app/views/search/results/_blob.html.haml index a8d4d4af93a..2a602095845 100644 --- a/app/views/search/results/_blob.html.haml +++ b/app/views/search/results/_blob.html.haml @@ -1,7 +1,7 @@ - project = find_project_for_result_blob(blob) - return unless project -- file_name, blob = parse_search_result(blob) -- blob_link = project_blob_path(project, tree_join(blob.ref, file_name)) +- blob = parse_search_result(blob) +- blob_link = project_blob_path(project, tree_join(blob.ref, blob.filename)) -= render partial: 'search/results/blob_data', locals: { blob: blob, project: project, file_name: file_name, blob_link: blob_link } += render partial: 'search/results/blob_data', locals: { blob: blob, project: project, file_name: blob.filename, blob_link: blob_link } diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml index 4346217c230..389e4cc75b9 100644 --- a/app/views/search/results/_wiki_blob.html.haml +++ b/app/views/search/results/_wiki_blob.html.haml @@ -1,5 +1,5 @@ - project = find_project_for_result_blob(wiki_blob) -- file_name, wiki_blob = parse_search_result(wiki_blob) +- wiki_blob = parse_search_result(wiki_blob) - wiki_blob_link = project_wiki_path(project, wiki_blob.basename) -= render partial: 'search/results/blob_data', locals: { blob: wiki_blob, project: project, file_name: file_name, blob_link: wiki_blob_link } += render partial: 'search/results/blob_data', locals: { blob: wiki_blob, project: project, file_name: wiki_blob.filename, blob_link: wiki_blob_link } diff --git a/app/views/shared/_remote_mirror_update_button.html.haml b/app/views/shared/_remote_mirror_update_button.html.haml index f32cff18fa8..721a2af8069 100644 --- a/app/views/shared/_remote_mirror_update_button.html.haml +++ b/app/views/shared/_remote_mirror_update_button.html.haml @@ -2,5 +2,5 @@ %button.btn.disabled{ type: 'button', data: { toggle: 'tooltip', container: 'body' }, title: _('Updating') } = icon("refresh spin") - else - = link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn", data: { toggle: 'tooltip', container: 'body' }, title: _('Update now') do + = link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn qa-update-now-button", data: { toggle: 'tooltip', container: 'body' }, title: _('Update now') do = icon("refresh") diff --git a/app/views/shared/_sort_dropdown.html.haml b/app/views/shared/_sort_dropdown.html.haml deleted file mode 100644 index e4463c1e0d8..00000000000 --- a/app/views/shared/_sort_dropdown.html.haml +++ /dev/null @@ -1,16 +0,0 @@ -- sorted_by = sort_options_hash[@sort] -- viewing_issues = controller.controller_name == 'issues' || controller.action_name == 'issues' - -.dropdown.inline.prepend-left-10 - %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } } - = sorted_by - = icon('chevron-down') - %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort - %li - = sortable_item(sort_title_priority, page_filter_path(sort: sort_value_priority, label: true), sorted_by) - = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date, label: true), sorted_by) - = sortable_item(sort_title_recently_updated, page_filter_path(sort: sort_value_recently_updated, label: true), sorted_by) - = sortable_item(sort_title_milestone, page_filter_path(sort: sort_value_milestone, label: true), sorted_by) - = sortable_item(sort_title_due_date, page_filter_path(sort: sort_value_due_date, label: true), sorted_by) if viewing_issues - = sortable_item(sort_title_popularity, page_filter_path(sort: sort_value_popularity, label: true), sorted_by) - = sortable_item(sort_title_label_priority, page_filter_path(sort: sort_value_label_priority, label: true), sorted_by) diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml new file mode 100644 index 00000000000..2ca4657851c --- /dev/null +++ b/app/views/shared/issuable/_filter.html.haml @@ -0,0 +1,32 @@ +.issues-filters + .issues-details-filters.row-content-block.second-block + = form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do + - if params[:search].present? + = hidden_field_tag :search, params[:search] + .issues-other-filters + .filter-item.inline + - if params[:author_id].present? + = hidden_field_tag(:author_id, params[:author_id]) + = dropdown_tag(user_dropdown_label(params[:author_id], "Author"), options: { toggle_class: "js-user-search js-filter-submit js-author-search", title: "Filter by author", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit", + placeholder: "Search authors", data: { any_user: "Any Author", first_user: current_user&.username, current_user: true, project_id: @project&.id, group_id: @group&.id, selected: params[:author_id], field_name: "author_id", default_label: "Author" } }) + + .filter-item.inline + - if params[:assignee_id].present? + = hidden_field_tag(:assignee_id, params[:assignee_id]) + = dropdown_tag(user_dropdown_label(params[:assignee_id], "Assignee"), options: { toggle_class: "js-user-search js-filter-submit js-assignee-search", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", + placeholder: "Search assignee", data: { any_user: "Any Assignee", first_user: current_user&.username, null_user: true, current_user: true, project_id: @project&.id, group_id: @group&.id, selected: params[:assignee_id], field_name: "assignee_id", default_label: "Assignee" } }) + + .filter-item.inline.milestone-filter + = render "shared/issuable/milestone_dropdown", selected: finder.milestones.try(:first), name: :milestone_title, show_any: true, show_upcoming: true, show_started: true + + .filter-item.inline.labels-filter + = render "shared/issuable/label_dropdown", selected: selected_labels, use_id: false, selected_toggle: params[:label_name], data_options: { field_name: "label_name[]" } + + - unless @no_filters_set + .float-right + = render 'shared/issuable/sort_dropdown' + + - has_labels = @labels && @labels.any? + .row-content-block.second-block.filtered-labels{ class: ("hidden" unless has_labels) } + - if has_labels + = render 'shared/labels_row', labels: @labels diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 7c5af0b9775..46634693067 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -2,7 +2,6 @@ - board = local_assigns.fetch(:board, nil) - block_css_class = type != :boards_modal ? 'row-content-block second-block' : '' - user_can_admin_list = board && can?(current_user, :admin_list, board.parent) -- show_sorting_dropdown = local_assigns.fetch(:show_sorting_dropdown, true) .issues-filters .issues-details-filters.filtered-search-block{ class: block_css_class, "v-pre" => type == :boards_modal } @@ -142,5 +141,5 @@ - if @project #js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } } #js-toggle-focus-btn - - elsif show_sorting_dropdown - = render 'shared/sort_dropdown' + - elsif type != :boards_modal + = render 'shared/issuable/sort_dropdown' diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml new file mode 100644 index 00000000000..c211b9fcaa2 --- /dev/null +++ b/app/views/shared/issuable/_sort_dropdown.html.haml @@ -0,0 +1,20 @@ +- sort_value = @sort +- sort_title = issuable_sort_option_title(sort_value) +- viewing_issues = controller.controller_name == 'issues' || controller.action_name == 'issues' + +.dropdown.inline.prepend-left-10.issue-sort-dropdown + .btn-group{ role: 'group' } + .btn-group{ role: 'group' } + %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' } + = sort_title + = icon('chevron-down') + %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort + %li + = sortable_item(sort_title_priority, page_filter_path(sort: sort_value_priority, label: true), sort_title) + = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date, label: true), sort_title) + = sortable_item(sort_title_recently_updated, page_filter_path(sort: sort_value_recently_updated, label: true), sort_title) + = sortable_item(sort_title_milestone, page_filter_path(sort: sort_value_milestone, label: true), sort_title) + = sortable_item(sort_title_due_date, page_filter_path(sort: sort_value_due_date, label: true), sort_title) if viewing_issues + = sortable_item(sort_title_popularity, page_filter_path(sort: sort_value_popularity, label: true), sort_title) + = sortable_item(sort_title_label_priority, page_filter_path(sort: sort_value_label_priority, label: true), sort_title) + = issuable_sort_direction_button(sort_value) diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index a7fd75d85d7..6b3841ebbc4 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -75,7 +75,7 @@ = dropdown_title(_("Change permissions")) .dropdown-content %ul - - member.access_level_roles.each do |role, role_id| + - member.valid_level_roles.each do |role, role_id| %li = link_to role, "javascript:void(0)", class: ("is-active" if member.access_level == role_id), diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index e51da79c6b5..d9fd395c5ec 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -133,3 +133,5 @@ - create_note_diff_file - delete_diff_files - detect_repository_languages +- repository_cleanup +- delete_stored_files diff --git a/app/workers/delete_stored_files_worker.rb b/app/workers/delete_stored_files_worker.rb new file mode 100644 index 00000000000..ff7931849d8 --- /dev/null +++ b/app/workers/delete_stored_files_worker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class DeleteStoredFilesWorker + include ApplicationWorker + + def perform(class_name, keys) + klass = begin + class_name.constantize + rescue NameError + nil + end + + unless klass + message = "Unknown class '#{class_name}'" + logger.error(message) + Gitlab::Sentry.track_exception(RuntimeError.new(message)) + return + end + + klass.new(logger: logger).delete_keys(keys) + end +end diff --git a/app/workers/repository_cleanup_worker.rb b/app/workers/repository_cleanup_worker.rb new file mode 100644 index 00000000000..aa26c173a72 --- /dev/null +++ b/app/workers/repository_cleanup_worker.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class RepositoryCleanupWorker + include ApplicationWorker + + sidekiq_options retry: 3 + + sidekiq_retries_exhausted do |msg, err| + next if err.is_a?(ActiveRecord::RecordNotFound) + + args = msg['args'] + [msg['error_message']] + + new.perform_failure(*args) + end + + def perform(project_id, user_id) + project = Project.find(project_id) + user = User.find(user_id) + + Projects::CleanupService.new(project, user).execute + + notification_service.repository_cleanup_success(project, user) + end + + def perform_failure(project_id, user_id, error) + project = Project.find(project_id) + user = User.find(user_id) + + # Ensure the file is removed + project.bfg_object_map.remove! + notification_service.repository_cleanup_failure(project, user, error) + end + + private + + def notification_service + @notification_service ||= NotificationService.new + end +end diff --git a/changelogs/unreleased/19376-post-bfg-cleanup.yml b/changelogs/unreleased/19376-post-bfg-cleanup.yml new file mode 100644 index 00000000000..fc1bcc30db9 --- /dev/null +++ b/changelogs/unreleased/19376-post-bfg-cleanup.yml @@ -0,0 +1,5 @@ +--- +title: Use BFG object maps to clean projects +merge_request: 23189 +author: +type: added diff --git a/changelogs/unreleased/39849_controller_sorts.yml b/changelogs/unreleased/39849_controller_sorts.yml new file mode 100644 index 00000000000..5fad0cb4ede --- /dev/null +++ b/changelogs/unreleased/39849_controller_sorts.yml @@ -0,0 +1,5 @@ +--- +title: Allow sorting issues and MRs in reverse order +merge_request: 21438 +author: +type: changed diff --git a/changelogs/unreleased/51101-can-add-an-existing-group-member-into-a-group-project-with-new-permissions-but-permissions-are-not-overridde.yml b/changelogs/unreleased/51101-can-add-an-existing-group-member-into-a-group-project-with-new-permissions-but-permissions-are-not-overridde.yml new file mode 100644 index 00000000000..96f33a72cc5 --- /dev/null +++ b/changelogs/unreleased/51101-can-add-an-existing-group-member-into-a-group-project-with-new-permissions-but-permissions-are-not-overridde.yml @@ -0,0 +1,5 @@ +--- +title: Restrict member access level to be higher than that of any parent group +merge_request: 23226 +author: +type: fixed diff --git a/changelogs/unreleased/51138-54026-breadcrumb-subgroups-ellipsis.yml b/changelogs/unreleased/51138-54026-breadcrumb-subgroups-ellipsis.yml new file mode 100644 index 00000000000..f695d5aeff8 --- /dev/null +++ b/changelogs/unreleased/51138-54026-breadcrumb-subgroups-ellipsis.yml @@ -0,0 +1,5 @@ +--- +title: "Make auto-generated icons for subgroups in the breadcrumb dropdown display as a circle" +merge_request: 23062 +author: Thomas Pathier +type: fix
\ No newline at end of file diff --git a/changelogs/unreleased/52285-omniauth-jwt-ppk-support.yml b/changelogs/unreleased/52285-omniauth-jwt-ppk-support.yml new file mode 100644 index 00000000000..3ef564238c5 --- /dev/null +++ b/changelogs/unreleased/52285-omniauth-jwt-ppk-support.yml @@ -0,0 +1,5 @@ +--- +title: Support RSA and ECDSA algorithms in Omniauth JWT provider +merge_request: 23411 +author: Michael Tsyganov +type: fixed diff --git a/changelogs/unreleased/53994-add-missing-ci_builds-partial-indices.yml b/changelogs/unreleased/53994-add-missing-ci_builds-partial-indices.yml new file mode 100644 index 00000000000..4673ba38bae --- /dev/null +++ b/changelogs/unreleased/53994-add-missing-ci_builds-partial-indices.yml @@ -0,0 +1,5 @@ +--- +title: Add partial index for ci_builds on project_id and status +merge_request: 23268 +author: +type: performance diff --git a/changelogs/unreleased/54857-fix-templates-path-traversal.yml b/changelogs/unreleased/54857-fix-templates-path-traversal.yml new file mode 100644 index 00000000000..0da02432c60 --- /dev/null +++ b/changelogs/unreleased/54857-fix-templates-path-traversal.yml @@ -0,0 +1,5 @@ +--- +title: Prevent a path traversal attack on global file templates +merge_request: +author: +type: security diff --git a/changelogs/unreleased/54975-fix-web-hooks-rake-task.yml b/changelogs/unreleased/54975-fix-web-hooks-rake-task.yml new file mode 100644 index 00000000000..107a93e5b12 --- /dev/null +++ b/changelogs/unreleased/54975-fix-web-hooks-rake-task.yml @@ -0,0 +1,5 @@ +--- +title: Fix gitlab:web_hook tasks +merge_request: 23635 +author: +type: fixed diff --git a/changelogs/unreleased/deprecated-instance-find.yml b/changelogs/unreleased/deprecated-instance-find.yml new file mode 100644 index 00000000000..d2ba821e124 --- /dev/null +++ b/changelogs/unreleased/deprecated-instance-find.yml @@ -0,0 +1,5 @@ +--- +title: 'Fix deprecation: You are passing an instance of ActiveRecord::Base to' +merge_request: 23369 +author: Jasper Maes +type: other diff --git a/changelogs/unreleased/expose-mr-pipeline-variables.yml b/changelogs/unreleased/expose-mr-pipeline-variables.yml new file mode 100644 index 00000000000..b77b9a69d5c --- /dev/null +++ b/changelogs/unreleased/expose-mr-pipeline-variables.yml @@ -0,0 +1,5 @@ +--- +title: Expose merge request pipeline variables +merge_request: 23398 +author: +type: changed diff --git a/changelogs/unreleased/fix-gb-encrypt-ci-build-token.yml b/changelogs/unreleased/fix-gb-encrypt-ci-build-token.yml new file mode 100644 index 00000000000..04fc88bc3d3 --- /dev/null +++ b/changelogs/unreleased/fix-gb-encrypt-ci-build-token.yml @@ -0,0 +1,5 @@ +--- +title: Encrypt CI/CD builds authentication tokens +merge_request: 23436 +author: +type: security diff --git a/changelogs/unreleased/fj-clean-content-headers.yml b/changelogs/unreleased/fj-clean-content-headers.yml new file mode 100644 index 00000000000..59e25ca6578 --- /dev/null +++ b/changelogs/unreleased/fj-clean-content-headers.yml @@ -0,0 +1,5 @@ +--- +title: Added feature flag to signal content headers detection by Workhorse +merge_request: 22667 +author: +type: added diff --git a/changelogs/unreleased/gt-add-top-padding-for-nested-environment-items-loading-icon.yml b/changelogs/unreleased/gt-add-top-padding-for-nested-environment-items-loading-icon.yml new file mode 100644 index 00000000000..606314b5780 --- /dev/null +++ b/changelogs/unreleased/gt-add-top-padding-for-nested-environment-items-loading-icon.yml @@ -0,0 +1,5 @@ +--- +title: Add top padding for nested environment items loading icon +merge_request: 23580 +author: George Tsiolis +type: fixed diff --git a/changelogs/unreleased/gt-show-primary-button-when-all-labels-are-prioritized.yml b/changelogs/unreleased/gt-show-primary-button-when-all-labels-are-prioritized.yml new file mode 100644 index 00000000000..eed31950a76 --- /dev/null +++ b/changelogs/unreleased/gt-show-primary-button-when-all-labels-are-prioritized.yml @@ -0,0 +1,5 @@ +--- +title: Show primary button when all labels are prioritized +merge_request: 23648 +author: George Tsiolis +type: other diff --git a/changelogs/unreleased/mg-fix-knative-application-row.yml b/changelogs/unreleased/mg-fix-knative-application-row.yml new file mode 100644 index 00000000000..95142d380a4 --- /dev/null +++ b/changelogs/unreleased/mg-fix-knative-application-row.yml @@ -0,0 +1,5 @@ +--- +title: Hide Knative from group cluster applications until supported +merge_request: 23577 +author: +type: fixed diff --git a/changelogs/unreleased/move-group-issues-search-cte-up-the-chain.yml b/changelogs/unreleased/move-group-issues-search-cte-up-the-chain.yml new file mode 100644 index 00000000000..0269e7b6196 --- /dev/null +++ b/changelogs/unreleased/move-group-issues-search-cte-up-the-chain.yml @@ -0,0 +1,5 @@ +--- +title: Fix error when searching for group issues with priority or popularity sort +merge_request: 23445 +author: +type: fixed diff --git a/changelogs/unreleased/osw-remove-unnused-data-from-diff-discussions.yml b/changelogs/unreleased/osw-remove-unnused-data-from-diff-discussions.yml new file mode 100644 index 00000000000..58d9a19d038 --- /dev/null +++ b/changelogs/unreleased/osw-remove-unnused-data-from-diff-discussions.yml @@ -0,0 +1,5 @@ +--- +title: Remove unused data from discussions endpoint +merge_request: 23570 +author: +type: performance diff --git a/changelogs/unreleased/remove-blob-search-limit.yml b/changelogs/unreleased/remove-blob-search-limit.yml new file mode 100644 index 00000000000..5bad3a83dbb --- /dev/null +++ b/changelogs/unreleased/remove-blob-search-limit.yml @@ -0,0 +1,5 @@ +--- +title: Remove limit of 100 when searching repository code. +merge_request: 8671 +author: +type: fixed diff --git a/changelogs/unreleased/sh-handle-invalid-gpg-sig.yml b/changelogs/unreleased/sh-handle-invalid-gpg-sig.yml new file mode 100644 index 00000000000..185e2547e16 --- /dev/null +++ b/changelogs/unreleased/sh-handle-invalid-gpg-sig.yml @@ -0,0 +1,5 @@ +--- +title: Gracefully handle unknown/invalid GPG keys +merge_request: 23492 +author: +type: fixed diff --git a/changelogs/unreleased/sh-truncate-with-periods.yml b/changelogs/unreleased/sh-truncate-with-periods.yml new file mode 100644 index 00000000000..b1c6b4f9cbd --- /dev/null +++ b/changelogs/unreleased/sh-truncate-with-periods.yml @@ -0,0 +1,5 @@ +--- +title: Truncate merge request titles with periods instead of ellipsis +merge_request: 23558 +author: +type: changed diff --git a/changelogs/unreleased/triggermesh-phase2-serverless-list.yml b/changelogs/unreleased/triggermesh-phase2-serverless-list.yml new file mode 100644 index 00000000000..22e1a35dd90 --- /dev/null +++ b/changelogs/unreleased/triggermesh-phase2-serverless-list.yml @@ -0,0 +1,5 @@ +--- +title: Introduce Knative and Serverless Components +merge_request: 23174 +author: Chris Baumbauer +type: added diff --git a/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-1-39.yml b/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-1-39.yml new file mode 100644 index 00000000000..dffcdb0bb5a --- /dev/null +++ b/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-1-39.yml @@ -0,0 +1,5 @@ +--- +title: Update used version of Runner Helm Chart to 0.1.39 +merge_request: 23633 +author: +type: other diff --git a/changelogs/unreleased/usage-count.yml b/changelogs/unreleased/usage-count.yml new file mode 100644 index 00000000000..efff2615ce4 --- /dev/null +++ b/changelogs/unreleased/usage-count.yml @@ -0,0 +1,5 @@ +--- +title: Use approximate count for big tables for usage statistics. +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/winh-dropdown-divider-color.yml b/changelogs/unreleased/winh-dropdown-divider-color.yml new file mode 100644 index 00000000000..6b6ecd831b8 --- /dev/null +++ b/changelogs/unreleased/winh-dropdown-divider-color.yml @@ -0,0 +1,5 @@ +--- +title: Change dropdown divider color to gray-200 (#dfdfdf) +merge_request: 23592 +author: +type: changed diff --git a/changelogs/unreleased/winh-issue-boards-project-dropdown-close.yml b/changelogs/unreleased/winh-issue-boards-project-dropdown-close.yml new file mode 100644 index 00000000000..18f7da56edb --- /dev/null +++ b/changelogs/unreleased/winh-issue-boards-project-dropdown-close.yml @@ -0,0 +1,5 @@ +--- +title: Remove close icon from projects dropdown in issue boards +merge_request: 23567 +author: +type: changed diff --git a/changelogs/unreleased/winh-merge-request-diff-discussion-commit-id.yml b/changelogs/unreleased/winh-merge-request-diff-discussion-commit-id.yml new file mode 100644 index 00000000000..2ce16a2b6b7 --- /dev/null +++ b/changelogs/unreleased/winh-merge-request-diff-discussion-commit-id.yml @@ -0,0 +1,5 @@ +--- +title: Pass commit when posting diff discussions +merge_request: 23371 +author: +type: fixed diff --git a/changelogs/unreleased/winh-milestone-select.yml b/changelogs/unreleased/winh-milestone-select.yml new file mode 100644 index 00000000000..8464fc6c541 --- /dev/null +++ b/changelogs/unreleased/winh-milestone-select.yml @@ -0,0 +1,5 @@ +--- +title: Fix milestone select in issue sidebar of issue boards +merge_request: 23625 +author: +type: fixed diff --git a/config/dependency_decisions.yml b/config/dependency_decisions.yml index 84d47bd52ad..6e4f7ce30a0 100644 --- a/config/dependency_decisions.yml +++ b/config/dependency_decisions.yml @@ -470,8 +470,8 @@ - - :license - pikaday - MIT - - :who: - :why: + - :who: Filipa Lacerda + :why: MIT License :versions: [] :when: 2017-10-17 17:46:12.367554000 Z - - :license @@ -592,3 +592,9 @@ in compiled/distributed product so attribution not needed. :versions: [] :when: 2018-10-02 19:23:54.840151000 Z +- - :approve + - echarts + - :who: Mike Greiling + :why: https://github.com/apache/incubator-echarts/blob/master/LICENSE + :versions: [] + :when: 2018-12-05 22:12:30.550027000 Z diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 58b7c248aaf..1c16b999e55 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -548,15 +548,15 @@ production: &base # app_id: 'YOUR_APP_ID', # app_secret: 'YOUR_APP_SECRET' } # - { name: 'jwt', - # app_secret: 'YOUR_APP_SECRET', # args: { - # algorithm: 'HS256', - # uid_claim: 'email', - # required_claims: ["name", "email"], - # info_map: { name: "name", email: "email" }, - # auth_url: 'https://example.com/', - # valid_within: null, - # } + # secret: 'YOUR_APP_SECRET', + # algorithm: 'HS256', # Supported algorithms: 'RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'HS256', 'HS384', 'HS512' + # uid_claim: 'email', + # required_claims: ['name', 'email'], + # info_map: { name: 'name', email: 'email' }, + # auth_url: 'https://example.com/', + # valid_within: 3600 # 1 hour + # } # } # - { name: 'saml', # label: 'Our SAML Provider', diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 4210be2c701..f20ea488d9c 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -39,7 +39,7 @@ Sidekiq.configure_server do |config| ActiveRecord::Base.clear_all_connections! end - if Feature.enabled?(:gitlab_sidekiq_reliable_fetcher) + if Feature::FlipperFeature.table_exists? && Feature.enabled?(:gitlab_sidekiq_reliable_fetcher) Sidekiq::ReliableFetcher.setup_reliable_fetch!(config) end diff --git a/config/routes/project.rb b/config/routes/project.rb index 3f1ad90dfca..7d0623cb904 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -245,6 +245,10 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do end end + namespace :serverless do + resources :functions, only: [:index] + end + scope '-' do get 'archive/*id', constraints: { format: Gitlab::PathRegex.archive_formats_regex, id: /.+?/ }, to: 'repositories#archive', as: 'archive' @@ -432,6 +436,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do resource :integrations, only: [:show] resource :repository, only: [:show], controller: :repository do post :create_deploy_token, path: 'deploy_token/create' + post :cleanup end end diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 53e1c8778b6..4782a223561 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -81,3 +81,5 @@ - [delete_diff_files, 1] - [detect_repository_languages, 1] - [auto_devops, 2] + - [repository_cleanup, 1] + - [delete_stored_files, 1] diff --git a/danger/documentation/Dangerfile b/danger/documentation/Dangerfile index 87c61d6e90d..be7b301866d 100644 --- a/danger/documentation/Dangerfile +++ b/danger/documentation/Dangerfile @@ -24,23 +24,24 @@ The following files require a review from the Documentation team: * #{docs_paths_to_review.map { |path| "`#{path}`" }.join("\n* ")} -When your content is ready for review, mention a technical writer in a separate -comment and explain what needs to be reviewed. - -You are welcome to mention them sooner if you have questions about writing or updating -the documentation. GitLabbers are also welcome to use the [#docs](https://gitlab.slack.com/archives/C16HYA2P5) channel on Slack. - -Who to ping [based on DevOps stages](https://about.gitlab.com/handbook/product/categories/#devops-stages): +When your content is ready for review, assign the MR to a technical writer +according to the [DevOps stages](https://about.gitlab.com/handbook/product/categories/#devops-stages) +in the table below. If necessary, mention them in a comment explaining what needs +to be reviewed. | Tech writer | Stage(s) | | ------------ | ------------------------------------------------------------ | -| `@marcia` | ~Create ~Release | +| `@marcia` | ~Create ~Release + ~"development guidelines" | | `@axil` | ~Distribution ~Gitaly ~Gitter ~Monitoring ~Packaging ~Secure | | `@eread` | ~Manage ~Configure ~Geo ~Verify | | `@mikelewis` | ~Plan | +You are welcome to mention them sooner if you have questions about writing or +updating the documentation. GitLabbers are also welcome to use the +[#docs](https://gitlab.slack.com/archives/C16HYA2P5) channel on Slack. + If you are not sure which category the change falls within, or the change is not -part of one of these categories, you can mention one of the usernames above. +part of one of these categories, mention one of the usernames above. MARKDOWN unless gitlab.mr_labels.include?('Documentation') diff --git a/db/fixtures/development/04_project.rb b/db/fixtures/development/04_project.rb index 089de211380..aa8686ac7d8 100644 --- a/db/fixtures/development/04_project.rb +++ b/db/fixtures/development/04_project.rb @@ -71,13 +71,17 @@ Sidekiq::Testing.inline! do params[:storage_version] = Project::LATEST_STORAGE_VERSION end - project = Projects::CreateService.new(User.first, params).execute - # Seed-Fu runs this entire fixture in a transaction, so the `after_commit` - # hook won't run until after the fixture is loaded. That is too late - # since the Sidekiq::Testing block has already exited. Force clearing - # the `after_commit` queue to ensure the job is run now. + project = nil + Sidekiq::Worker.skipping_transaction_check do + project = Projects::CreateService.new(User.first, params).execute + + # Seed-Fu runs this entire fixture in a transaction, so the `after_commit` + # hook won't run until after the fixture is loaded. That is too late + # since the Sidekiq::Testing block has already exited. Force clearing + # the `after_commit` queue to ensure the job is run now. project.send(:_run_after_commit_queue) + project.import_state.send(:_run_after_commit_queue) end if project.valid? && project.valid_repo? diff --git a/db/fixtures/development/10_merge_requests.rb b/db/fixtures/development/10_merge_requests.rb index bcfdd058a1c..8bdc7c6556c 100644 --- a/db/fixtures/development/10_merge_requests.rb +++ b/db/fixtures/development/10_merge_requests.rb @@ -25,7 +25,9 @@ Gitlab::Seeder.quiet do developer = project.team.developers.sample break unless developer - MergeRequests::CreateService.new(project, developer, params).execute + Sidekiq::Worker.skipping_transaction_check do + MergeRequests::CreateService.new(project, developer, params).execute + end print '.' end end @@ -39,7 +41,9 @@ Gitlab::Seeder.quiet do target_branch: 'master', title: 'Can be automatically merged' } - MergeRequests::CreateService.new(project, User.admins.first, params).execute + Sidekiq::Worker.skipping_transaction_check do + MergeRequests::CreateService.new(project, User.admins.first, params).execute + end print '.' params = { @@ -47,6 +51,8 @@ Gitlab::Seeder.quiet do target_branch: 'feature', title: 'Cannot be automatically merged' } - MergeRequests::CreateService.new(project, User.admins.first, params).execute + Sidekiq::Worker.skipping_transaction_check do + MergeRequests::CreateService.new(project, User.admins.first, params).execute + end print '.' end diff --git a/db/fixtures/development/24_forks.rb b/db/fixtures/development/24_forks.rb new file mode 100644 index 00000000000..61e39c871e6 --- /dev/null +++ b/db/fixtures/development/24_forks.rb @@ -0,0 +1,16 @@ +require './spec/support/sidekiq' + +Sidekiq::Testing.inline! do + Gitlab::Seeder.quiet do + User.all.sample(10).each do |user| + source_project = Project.public_only.sample + fork_project = Projects::ForkService.new(source_project, user, namespace: user.namespace).execute + + if fork_project.valid? + puts '.' + else + puts 'F' + end + end + end +end diff --git a/db/fixtures/production/001_application_settings.rb b/db/fixtures/production/001_application_settings.rb new file mode 100644 index 00000000000..ab15717e9a9 --- /dev/null +++ b/db/fixtures/production/001_application_settings.rb @@ -0,0 +1,2 @@ +puts "Creating the default ApplicationSetting record.".color(:green) +Gitlab::CurrentSettings.current_application_settings diff --git a/db/fixtures/production/001_admin.rb b/db/fixtures/production/002_admin.rb index 1c7c89f7bbd..1c7c89f7bbd 100644 --- a/db/fixtures/production/001_admin.rb +++ b/db/fixtures/production/002_admin.rb diff --git a/db/migrate/20181120091639_add_foreign_key_to_ci_pipelines_merge_requests.rb b/db/migrate/20181120091639_add_foreign_key_to_ci_pipelines_merge_requests.rb index c2b5b239279..03f677a4678 100644 --- a/db/migrate/20181120091639_add_foreign_key_to_ci_pipelines_merge_requests.rb +++ b/db/migrate/20181120091639_add_foreign_key_to_ci_pipelines_merge_requests.rb @@ -8,7 +8,7 @@ class AddForeignKeyToCiPipelinesMergeRequests < ActiveRecord::Migration disable_ddl_transaction! def up - add_concurrent_index :ci_pipelines, :merge_request_id + add_concurrent_index :ci_pipelines, :merge_request_id, where: 'merge_request_id IS NOT NULL' add_concurrent_foreign_key :ci_pipelines, :merge_requests, column: :merge_request_id, on_delete: :cascade end @@ -17,6 +17,6 @@ class AddForeignKeyToCiPipelinesMergeRequests < ActiveRecord::Migration remove_foreign_key :ci_pipelines, :merge_requests end - remove_concurrent_index :ci_pipelines, :merge_request_id + remove_concurrent_index :ci_pipelines, :merge_request_id, where: 'merge_request_id IS NOT NULL' end end diff --git a/db/migrate/20181121101842_add_ci_builds_partial_index_on_project_id_and_status.rb b/db/migrate/20181121101842_add_ci_builds_partial_index_on_project_id_and_status.rb new file mode 100644 index 00000000000..5b47a279438 --- /dev/null +++ b/db/migrate/20181121101842_add_ci_builds_partial_index_on_project_id_and_status.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddCiBuildsPartialIndexOnProjectIdAndStatus < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index(*index_arguments) + end + + def down + remove_concurrent_index(*index_arguments) + end + + private + + def index_arguments + [ + :ci_builds, + [:project_id, :status], + { + name: 'index_ci_builds_project_id_and_status_for_live_jobs_partial2', + where: "(((type)::text = 'Ci::Build'::text) AND ((status)::text = ANY (ARRAY[('running'::character varying)::text, ('pending'::character varying)::text, ('created'::character varying)::text])))" + } + ] + end +end diff --git a/db/migrate/20181121101843_remove_redundant_ci_builds_partial_index.rb b/db/migrate/20181121101843_remove_redundant_ci_builds_partial_index.rb new file mode 100644 index 00000000000..a0a02e81323 --- /dev/null +++ b/db/migrate/20181121101843_remove_redundant_ci_builds_partial_index.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RemoveRedundantCiBuildsPartialIndex < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + remove_concurrent_index(*index_arguments) + end + + def down + add_concurrent_index(*index_arguments) + end + + private + + def index_arguments + [ + :ci_builds, + [:project_id, :status], + { + name: 'index_ci_builds_project_id_and_status_for_live_jobs_partial', + where: "((status)::text = ANY (ARRAY[('running'::character varying)::text, ('pending'::character varying)::text, ('created'::character varying)::text]))" + } + ] + end +end diff --git a/db/migrate/20181129104854_add_token_encrypted_to_ci_builds.rb b/db/migrate/20181129104854_add_token_encrypted_to_ci_builds.rb new file mode 100644 index 00000000000..11b98203793 --- /dev/null +++ b/db/migrate/20181129104854_add_token_encrypted_to_ci_builds.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddTokenEncryptedToCiBuilds < ActiveRecord::Migration[5.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :ci_builds, :token_encrypted, :string + end +end diff --git a/db/migrate/20181129104944_add_index_to_ci_builds_token_encrypted.rb b/db/migrate/20181129104944_add_index_to_ci_builds_token_encrypted.rb new file mode 100644 index 00000000000..f90aca008e5 --- /dev/null +++ b/db/migrate/20181129104944_add_index_to_ci_builds_token_encrypted.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddIndexToCiBuildsTokenEncrypted < ActiveRecord::Migration[5.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :ci_builds, :token_encrypted, unique: true, where: 'token_encrypted IS NOT NULL' + end + + def down + remove_concurrent_index :ci_builds, :token_encrypted + end +end diff --git a/db/migrate/20181203002526_add_project_bfg_object_map_column.rb b/db/migrate/20181203002526_add_project_bfg_object_map_column.rb new file mode 100644 index 00000000000..8b42cd6f941 --- /dev/null +++ b/db/migrate/20181203002526_add_project_bfg_object_map_column.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddProjectBfgObjectMapColumn < ActiveRecord::Migration[5.0] + DOWNTIME = false + + def change + add_column :projects, :bfg_object_map, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 65a69c2850c..d7124100621 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: 20181126153547) do +ActiveRecord::Schema.define(version: 20181203002526) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -345,6 +345,7 @@ ActiveRecord::Schema.define(version: 20181126153547) do t.boolean "protected" t.integer "failure_reason" t.datetime_with_timezone "scheduled_at" + t.string "token_encrypted" t.index ["artifacts_expire_at"], name: "index_ci_builds_on_artifacts_expire_at", where: "(artifacts_file <> ''::text)", using: :btree t.index ["auto_canceled_by_id"], name: "index_ci_builds_on_auto_canceled_by_id", using: :btree t.index ["commit_id", "stage_idx", "created_at"], name: "index_ci_builds_on_commit_id_and_stage_idx_and_created_at", using: :btree @@ -353,6 +354,7 @@ ActiveRecord::Schema.define(version: 20181126153547) do t.index ["commit_id", "type", "ref"], name: "index_ci_builds_on_commit_id_and_type_and_ref", using: :btree t.index ["id"], name: "partial_index_ci_builds_on_id_with_legacy_artifacts", where: "(artifacts_file <> ''::text)", using: :btree t.index ["project_id", "id"], name: "index_ci_builds_on_project_id_and_id", using: :btree + t.index ["project_id", "status"], name: "index_ci_builds_project_id_and_status_for_live_jobs_partial2", where: "(((type)::text = 'Ci::Build'::text) AND ((status)::text = ANY (ARRAY[('running'::character varying)::text, ('pending'::character varying)::text, ('created'::character varying)::text])))", using: :btree t.index ["protected"], name: "index_ci_builds_on_protected", using: :btree t.index ["runner_id"], name: "index_ci_builds_on_runner_id", using: :btree t.index ["scheduled_at"], name: "partial_index_ci_builds_on_scheduled_at_with_scheduled_jobs", where: "((scheduled_at IS NOT NULL) AND ((type)::text = 'Ci::Build'::text) AND ((status)::text = 'scheduled'::text))", using: :btree @@ -360,6 +362,7 @@ ActiveRecord::Schema.define(version: 20181126153547) do t.index ["stage_id"], name: "index_ci_builds_on_stage_id", using: :btree t.index ["status", "type", "runner_id"], name: "index_ci_builds_on_status_and_type_and_runner_id", using: :btree t.index ["token"], name: "index_ci_builds_on_token", unique: true, using: :btree + t.index ["token_encrypted"], name: "index_ci_builds_on_token_encrypted", unique: true, where: "(token_encrypted IS NOT NULL)", using: :btree t.index ["updated_at"], name: "index_ci_builds_on_updated_at", using: :btree t.index ["user_id"], name: "index_ci_builds_on_user_id", using: :btree end @@ -476,7 +479,7 @@ ActiveRecord::Schema.define(version: 20181126153547) do t.integer "iid" t.integer "merge_request_id" t.index ["auto_canceled_by_id"], name: "index_ci_pipelines_on_auto_canceled_by_id", using: :btree - t.index ["merge_request_id"], name: "index_ci_pipelines_on_merge_request_id", using: :btree + t.index ["merge_request_id"], name: "index_ci_pipelines_on_merge_request_id", where: "(merge_request_id IS NOT NULL)", using: :btree t.index ["pipeline_schedule_id"], name: "index_ci_pipelines_on_pipeline_schedule_id", using: :btree t.index ["project_id", "iid"], name: "index_ci_pipelines_on_project_id_and_iid", unique: true, where: "(iid IS NOT NULL)", using: :btree t.index ["project_id", "ref", "status", "id"], name: "index_ci_pipelines_on_project_id_and_ref_and_status_and_id", using: :btree @@ -1681,6 +1684,7 @@ ActiveRecord::Schema.define(version: 20181126153547) do t.boolean "remote_mirror_available_overridden" t.bigint "pool_repository_id" t.string "runners_token_encrypted" + t.string "bfg_object_map" t.index ["ci_id"], name: "index_projects_on_ci_id", using: :btree t.index ["created_at"], name: "index_projects_on_created_at", using: :btree t.index ["creator_id"], name: "index_projects_on_creator_id", using: :btree diff --git a/doc/administration/auth/README.md b/doc/administration/auth/README.md index 373d4239f71..54be7b616cc 100644 --- a/doc/administration/auth/README.md +++ b/doc/administration/auth/README.md @@ -10,7 +10,7 @@ providers. - [LDAP](ldap.md) Includes Active Directory, Apple Open Directory, Open LDAP, and 389 Server - [OmniAuth](../../integration/omniauth.md) Sign in via Twitter, GitHub, GitLab.com, Google, - Bitbucket, Facebook, Shibboleth, Crowd, Azure and Authentiq ID + Bitbucket, Facebook, Shibboleth, Crowd, Azure, Authentiq ID, and JWT - [CAS](../../integration/cas.md) Configure GitLab to sign in using CAS - [SAML](../../integration/saml.md) Configure GitLab as a SAML 2.0 Service Provider - [Okta](okta.md) Configure GitLab to sign in using Okta diff --git a/doc/administration/auth/jwt.md b/doc/administration/auth/jwt.md index 8b00f52ffc1..497298503ad 100644 --- a/doc/administration/auth/jwt.md +++ b/doc/administration/auth/jwt.md @@ -26,15 +26,15 @@ JWT will provide you with a secret key for you to use. ```ruby gitlab_rails['omniauth_providers'] = [ { name: 'jwt', - app_secret: 'YOUR_APP_SECRET', args: { - algorithm: 'HS256', - uid_claim: 'email', - required_claims: ["name", "email"], - info_maps: { name: "name", email: "email" }, - auth_url: 'https://example.com/', - valid_within: nil, - } + secret: 'YOUR_APP_SECRET', + algorithm: 'HS256', # Supported algorithms: 'RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'HS256', 'HS384', 'HS512' + uid_claim: 'email', + required_claims: ['name', 'email'], + info_maps: { name: 'name', email: 'email' }, + auth_url: 'https://example.com/', + valid_within: 3600 # 1 hour + } } ] ``` @@ -43,15 +43,15 @@ JWT will provide you with a secret key for you to use. ``` - { name: 'jwt', - app_secret: 'YOUR_APP_SECRET', args: { - algorithm: 'HS256', - uid_claim: 'email', - required_claims: ["name", "email"], - info_map: { name: "name", email: "email" }, - auth_url: 'https://example.com/', - valid_within: null, - } + secret: 'YOUR_APP_SECRET', + algorithm: 'HS256', # Supported algorithms: 'RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'HS256', 'HS384', 'HS512' + uid_claim: 'email', + required_claims: ['name', 'email'], + info_map: { name: 'name', email: 'email' }, + auth_url: 'https://example.com/', + valid_within: 3600 # 1 hour + } } ``` @@ -60,7 +60,7 @@ JWT will provide you with a secret key for you to use. 1. Change `YOUR_APP_SECRET` to the client secret and set `auth_url` to your redirect URL. 1. Save the configuration file. -1. [Reconfigure GitLab][] or [restart GitLab][] for the changes to take effect if you +1. [Reconfigure][] or [restart GitLab][] for the changes to take effect if you installed GitLab via Omnibus or from source respectively. On the sign in page there should now be a JWT icon below the regular sign in form. @@ -68,5 +68,5 @@ Click the icon to begin the authentication process. JWT will ask the user to sign in and authorize the GitLab application. If everything goes well, the user will be redirected to GitLab and will be signed in. -[reconfigure GitLab]: ../restart_gitlab.md#omnibus-gitlab-reconfigure +[reconfigure]: ../restart_gitlab.md#omnibus-gitlab-reconfigure [restart GitLab]: ../restart_gitlab.md#installations-from-source diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index fc03cf6cc39..9ff6c73b1b6 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -974,10 +974,9 @@ curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://git Merge changes submitted with MR using this API. +If merge request is unable to be accepted (ie: Work in Progress, Closed, Pipeline Pending Completion, or Failed while requiring Success) - you'll get a `405` and the error message 'Method Not Allowed' -If it has some conflicts and can not be merged - you'll get a `405` and the error message 'Branch cannot be merged' - -If merge request is already merged or closed - you'll get a `406` and the error message 'Method Not Allowed' +If it has some conflicts and can not be merged - you'll get a `406` and the error message 'Branch cannot be merged' If the `sha` parameter is passed and does not match the HEAD of the source - you'll get a `409` and the error message 'SHA does not match HEAD of source branch' diff --git a/doc/api/search.md b/doc/api/search.md index 9716f682ace..7e3ae7404a3 100644 --- a/doc/api/search.md +++ b/doc/api/search.md @@ -722,6 +722,23 @@ Example response: ### Scope: wiki_blobs +Filters are available for this scope: + +- filename +- path +- extension + +To use a filter simply include it in your query like: `a query filename:some_name*`. +You may use wildcards (`*`) to use glob matching. + +Wiki blobs searches are performed on both filenames and contents. Search +results: + +- Found in filenames are displayed before results found in contents. +- May contain multiple matches for the same blob because the search string + might be found in both the filename and content, or might appear multiple + times in the content. + ```bash curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/search?scope=wiki_blobs&search=bye ``` @@ -777,14 +794,21 @@ Example response: ### Scope: blobs Filters are available for this scope: + - filename - path - extension -to use a filter simply include it in your query like so: `a query filename:some_name*`. - +To use a filter simply include it in your query like: `a query filename:some_name*`. You may use wildcards (`*`) to use glob matching. +Blobs searches are performed on both filenames and contents. Search results: + +- Found in filenames are displayed before results found in contents. +- May contain multiple matches for the same blob because the search string + might be found in both the filename and content, or might appear multiple + times in the content. + ```bash curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/search?scope=blobs&search=installation ``` diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index 73b9ea4bc35..87799be8ab4 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -119,7 +119,7 @@ future GitLab releases.** | **GITLAB_USER_NAME** | 10.0 | all | The real name of the user who started the job | | **RESTORE_CACHE_ATTEMPTS** | 8.15 | 1.9 | Number of attempts to restore the cache running a job | -## 9.0 Renaming +## GitLab 9.0 renaming To follow conventions of naming across GitLab, and to further move away from the `build` term and toward `job` CI variables have been renamed for the 9.0 @@ -148,7 +148,7 @@ future GitLab releases.** ## `.gitlab-ci.yml` defined variables NOTE **Note:** -This feature requires GitLab Runner 0.5.0 or higher and GitLab CI 7.14 or higher. +This feature requires GitLab Runner 0.5.0 or higher and GitLab 7.14 or higher. GitLab CI allows you to add to `.gitlab-ci.yml` variables that are set in the build environment. The variables are hence saved in the repository, and they @@ -187,8 +187,7 @@ script: ## Variables -NOTE: **Note:** -Group-level variables were added in GitLab 9.4. +> Group-level variables were introduced in GitLab 9.4. CAUTION: **Important:** Be aware that variables are not masked, and their values can be shown @@ -217,8 +216,7 @@ Once you set them, they will be available for all subsequent pipelines. You can ### Protected variables ->**Notes:** -This feature requires GitLab 9.3 or higher. +> Introduced in GitLab 9.3. Variables could be protected. Whenever a variable is protected, it would only be securely passed to pipelines running on the @@ -239,8 +237,7 @@ Variables can be specified for a single pipeline run when a [manual pipeline](.. ## Deployment variables -NOTE: **Note:** -This feature requires GitLab CI 8.15 or higher. +> Introduced in GitLab 8.15. [Project services](../../user/project/integrations/project_services.md) that are responsible for deployment configuration may define their own variables that @@ -501,7 +498,7 @@ export CI_REGISTRY_PASSWORD="longalfanumstring" ## Variables expressions -> Variables expressions were added in GitLab 10.7. +> Introduced in GitLab 10.7. It is possible to use variables expressions with only / except policies in `.gitlab-ci.yml`. By using this approach you can limit what jobs are going to diff --git a/doc/development/documentation/index.md b/doc/development/documentation/index.md index b7990e1b558..55aed023325 100644 --- a/doc/development/documentation/index.md +++ b/doc/development/documentation/index.md @@ -368,6 +368,16 @@ You can combine one or more of the following: = link_to 'Help page', help_page_path('user/permissions') ``` +### GitLab `/help` tests + +Several [rspec tests](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/spec/features/help_pages_spec.rb) +are run to ensure GitLab documentation renders and works correctly. In particular, that [main docs landing page](../../README.md) will work correctly from `/help`. +For example, [GitLab.com's `/help`](https://gitlab.com/help). + +CAUTION: **Caution:** +Because the rspec tests only run in a full pipeline, and not a special [docs-only pipeline](#branch-naming), it is possible +to merge changes that will break `master` from a merge request with a successful docs-only pipeline run. + ## General Documentation vs Technical Articles ### General documentation @@ -552,6 +562,7 @@ Currently, the following tests are in place: As CE is merged into EE once a day, it's important to avoid merge conflicts. Submitting an EE-equivalent merge request cherry-picking all commits from CE to EE is essential to avoid them. +1. In a full pipeline, tests for [`/help`](#gitlab-help-tests). ### Linting diff --git a/doc/development/feature_flags.md b/doc/development/feature_flags.md index 1019a1fd0e2..b6161cd6163 100644 --- a/doc/development/feature_flags.md +++ b/doc/development/feature_flags.md @@ -113,7 +113,15 @@ feature flag. You can stub a feature flag as follows: stub_feature_flags(my_feature_flag: false) ``` -## Enabling a feature flag +## Enabling a feature flag (in development) + +In the rails console (`rails c`), enter the following command to enable your feature flag + +```ruby +Feature.enable(:feature_flag_name) +``` + +## Enabling a feature flag (in production) Check how to [roll out changes using feature flags](rolling_out_changes_using_feature_flags.md). diff --git a/doc/development/profiling.md b/doc/development/profiling.md index 0ca8bb67a77..0b0c6dfc8cf 100644 --- a/doc/development/profiling.md +++ b/doc/development/profiling.md @@ -77,8 +77,11 @@ that builds on this to add some additional niceties, such as allowing configuration with a single Yaml file for multiple URLs, and uploading of the profile and log output to S3. -For GitLab.com, you can find the latest results here: -<http://redash.gitlab.com/dashboard/gitlab-profiler-statistics> +For GitLab.com, currently the latest profiling data has been [moved from +Redash to Looker](https://gitlab.com/gitlab-com/Product/issues/5#note_121194467). +We are [currently investigating how to make this data +public](https://gitlab.com/meltano/looker/issues/294). + ## Sherlock diff --git a/doc/development/testing_guide/ci.md b/doc/development/testing_guide/ci.md index 8d9706a9501..d685cacf9ea 100644 --- a/doc/development/testing_guide/ci.md +++ b/doc/development/testing_guide/ci.md @@ -31,11 +31,7 @@ After that, the next pipeline will use the up-to-date The GitLab test suite is [monitored] for the `master` branch, and any branch that includes `rspec-profile` in their name. -A [public dashboard] is available for everyone to see. Feel free to look at the -slowest test files and try to improve them. - [monitored]: ../performance.md#rspec-profiling -[public dashboard]: https://redash.gitlab.com/public/dashboards/l1WhHXaxrCWM5Ai9D7YDqHKehq6OU3bx5gssaiWe?org_slug=default ## CI setup diff --git a/doc/development/testing_guide/review_apps.md b/doc/development/testing_guide/review_apps.md index a6ed9e85a41..309babb5f94 100644 --- a/doc/development/testing_guide/review_apps.md +++ b/doc/development/testing_guide/review_apps.md @@ -62,6 +62,41 @@ You can also manually start the `review-qa-all`: it runs the full QA suite. Note that both jobs first wait for the `review-deploy` job to be finished. +## How to? + +### Find my Review App slug? + +1. Open the `review-deploy` job. +1. Look for `Checking for previous deployment of review-*`. +1. For instance for `Checking for previous deployment of review-qa-raise-e-12chm0`, + your Review App slug would be `review-qa-raise-e-12chm0` in this case. + +### Run a Rails console? + +1. [Filter Workloads by your Review App slug](https://console.cloud.google.com/kubernetes/workload?project=gitlab-review-apps) + , e.g. `review-29951-issu-id2qax`. +1. Find and open the `task-runner` Deployment, e.g. `review-29951-issu-id2qax-task-runner`. +1. Click on the Pod in the "Managed pods" section, e.g. `review-29951-issu-id2qax-task-runner-d5455cc8-2lsvz`. +1. Click on the `KUBECTL` dropdown, then `Exec` -> `task-runner`. +1. Replace `-c task-runner -- ls` with `-- /srv/gitlab/bin/rails c` from the + default command or + - Run `kubectl exec --namespace review-apps-ce -it review-29951-issu-id2qax-task-runner-d5455cc8-2lsvz -- /srv/gitlab/bin/rails c` + and + - Replace `review-apps-ce` with `review-apps-ee` if the Review App + is running EE, and + - Replace `review-29951-issu-id2qax-task-runner-d5455cc8-2lsvz` + with your Pod's name. + +### Dig into a Pod's logs? + +1. [Filter Workloads by your Review App slug](https://console.cloud.google.com/kubernetes/workload?project=gitlab-review-apps) + , e.g. `review-1979-1-mul-dnvlhv`. +1. Find and open the `migrations` Deployment, e.g. + `review-1979-1-mul-dnvlhv-migrations.1`. +1. Click on the Pod in the "Managed pods" section, e.g. + `review-1979-1-mul-dnvlhv-migrations.1-nqwtx`. +1. Click on the `Container logs` link. + ## Frequently Asked Questions **Isn't it too much to trigger CNG image builds on every test run? This creates diff --git a/doc/raketasks/web_hooks.md b/doc/raketasks/web_hooks.md index 5f3143f76cd..df3dab118b2 100644 --- a/doc/raketasks/web_hooks.md +++ b/doc/raketasks/web_hooks.md @@ -38,8 +38,6 @@ ## List the webhooks from projects in a given **NAMESPACE**: # omnibus-gitlab - sudo gitlab-rake gitlab:web_hook:list NAMESPACE=/ + sudo gitlab-rake gitlab:web_hook:list NAMESPACE=acme # source installations - bundle exec rake gitlab:web_hook:list NAMESPACE=/ RAILS_ENV=production - -> Note: `/` is the global namespace. + bundle exec rake gitlab:web_hook:list NAMESPACE=acme RAILS_ENV=production diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md index 63e7497cbbc..7885cffd107 100644 --- a/doc/topics/autodevops/index.md +++ b/doc/topics/autodevops/index.md @@ -132,7 +132,8 @@ in three places: - either under the project's CI/CD settings while [enabling Auto DevOps](#enabling-auto-devops) - or in instance-wide settings in the **admin area > Settings** under the "Continuous Integration and Delivery" section -- or at the project or group level as a variable: `AUTO_DEVOPS_DOMAIN` (required if you want to use [multiple clusters](#using-multiple-kubernetes-clusters)) +- 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` 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: @@ -203,6 +204,12 @@ and verifying that your app is deployed as a review app in the Kubernetes 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). + ## Enabling/Disabling Auto DevOps When first using Auto Devops, review the [requirements](#requirements) to ensure all necessary components to make diff --git a/doc/user/group/clusters/index.md b/doc/user/group/clusters/index.md new file mode 100644 index 00000000000..adc43921d47 --- /dev/null +++ b/doc/user/group/clusters/index.md @@ -0,0 +1,126 @@ +# Group-level Kubernetes clusters + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/34758) in GitLab 11.6. + +CAUTION: **Warning:** +Group Cluster integration is currently in **Beta**. + +## Overview + +Similar to [project Kubernetes +clusters](../../project/clusters/index.md), Group-level Kubernetes +clusters allow you to connect a Kubernetes cluster to your group, +enabling you to use the same cluster across multiple projects. + +## Installing applications + +GitLab provides a one-click install for various applications that can be +added directly to your cluster. + +NOTE: **Note:** +Applications will be installed in a dedicated namespace called +`gitlab-managed-apps`. If you have added an existing Kubernetes cluster +with Tiller already installed, you should be careful as GitLab cannot +detect it. In this event, installing Tiller via the applications will +result in the cluster having it twice. This can lead to confusion during +deployments. + +| Application | GitLab version | Description | Helm Chart | +| ----------- | -------------- | ----------- | ---------- | +| [Helm Tiller](https://docs.helm.sh) | 10.2+ | Helm is a package manager for Kubernetes and is required to install all the other applications. It is installed in its own pod inside the cluster which can run the `helm` CLI in a safe environment. | n/a | +| [Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress) | 10.2+ | Ingress can provide load balancing, SSL termination, and name-based virtual hosting. It acts as a web proxy for your applications and is useful if you want to use [Auto DevOps](../../../topics/autodevops/index.md) or deploy your own web apps. | [stable/nginx-ingress](https://github.com/helm/charts/tree/master/stable/nginx-ingress) | + +## RBAC compatibility + +For each project under a group with a Kubernetes cluster, GitLab will +create a restricted service account with [`edit` +privileges](https://kubernetes.io/docs/reference/access-authn-authz/rbac/#user-facing-roles) +in the project namespace. + +NOTE: **Note:** +RBAC support was introduced in +[GitLab 11.4](https://gitlab.com/gitlab-org/gitlab-ce/issues/29398), and +Project namespace restriction was introduced in +[GitLab 11.5](https://gitlab.com/gitlab-org/gitlab-ce/issues/51716). + +## Cluster precedence + +GitLab will use the project's cluster before using any cluster belonging +to the group containing the project if the project's cluster is available and not disabled. + +In the case of sub-groups, GitLab will use the cluster of the closest ancestor group +to the project, provided the cluster is not disabled. + +## Multiple Kubernetes clusters **[PREMIUM]** + +With GitLab Premium, you can associate more than one Kubernetes clusters to your +group. That way you can have different clusters for different environments, +like dev, staging, production, etc. + +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). + +## Environment scopes **[PREMIUM]** + +When adding more than one Kubernetes cluster to your project, you need +to differentiate them with an environment scope. The environment scope +associates clusters with [environments](../../../ci/environments.md) +similar to how the [environment-specific +variables](../../../ci/variables/README.md#limiting-environment-scopes-of-variables) +work. + +While evaluating which environment matches the environment scope of a +cluster, [cluster precedence](#cluster-precedence) will take +effect. The cluster at the project level will take precedence, followed +by the closest ancestor group, followed by that groups' parent and so +on. + +For example, let's say we have the following Kubernetes clusters: + +| Cluster | Environment scope | Where | +| ---------- | ------------------- | ----------| +| Project | `*` | Project | +| Staging | `staging/*` | Project | +| Production | `production/*` | Project | +| Test | `test` | Group | +| Development| `*` | Group | + + +And the following environments are set in [`.gitlab-ci.yml`](../../../ci/yaml/README.md): + +```yaml +stages: +- test +- deploy + +test: + stage: test + script: sh test + +deploy to staging: + stage: deploy + script: make deploy + environment: + name: staging/$CI_COMMIT_REF_NAME + url: https://staging.example.com/ + +deploy to production: + stage: deploy + script: make deploy + environment: + name: production/$CI_COMMIT_REF_NAME + url: https://example.com/ +``` + +The result will then be: + +- The Project cluster will be used for the `test` job. +- The Staging cluster will be used for the `deploy to staging` job. +- The Production cluster will be used for the `deploy to production` job. diff --git a/doc/user/group/index.md b/doc/user/group/index.md index 36b9318c0e0..5fea683a7fd 100644 --- a/doc/user/group/index.md +++ b/doc/user/group/index.md @@ -269,6 +269,7 @@ Define project templates at a group-level by setting a group as a template sourc - **Projects**: view all projects within that group, add members to each project, access each project's settings, and remove any project from the same screen. - **Webhooks**: configure [webhooks](../project/integrations/webhooks.md) to your group. +- **Kubernetes cluster integration**: connect your GitLab group with [Kubernetes clusters](clusters/index.md). - **Audit Events**: view [Audit Events](https://docs.gitlab.com/ee/administration/audit_events.html#audit-events) for the group. **[STARTER ONLY]** -- **Pipelines quota**: keep track of the [pipeline quota](../admin_area/settings/continuous_integration.md) for the group +- **Pipelines quota**: keep track of the [pipeline quota](../admin_area/settings/continuous_integration.md) for the group. diff --git a/doc/user/group/subgroups/index.md b/doc/user/group/subgroups/index.md index 8db36c4a0e8..943b0c693c0 100644 --- a/doc/user/group/subgroups/index.md +++ b/doc/user/group/subgroups/index.md @@ -167,7 +167,6 @@ Here's a list of what you can't do with subgroups: - [GitLab Pages](../../project/pages/index.md) are not currently working for projects hosted under a subgroup. That means that only projects hosted under the first parent group will work. -- Group level labels don't work in subgroups / sub projects - It is not possible to share a project with a group that's an ancestor of the group the project is in. That means you can only share as you walk down the hierarchy. For example, `group/subgroup01/project` **cannot** be shared diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md index 66ad1843e93..6d05e2feeec 100644 --- a/doc/user/project/clusters/index.md +++ b/doc/user/project/clusters/index.md @@ -17,6 +17,11 @@ your account with Google Kubernetes Engine (GKE) so that you can [create new clusters](#adding-and-creating-a-new-gke-cluster-via-gitlab) from within GitLab, or provide the credentials to an [existing Kubernetes cluster](#adding-an-existing-kubernetes-cluster). +NOTE: **Note:** +From [GitLab 11.6](https://gitlab.com/gitlab-org/gitlab-ce/issues/34758) you +can also associate a Kubernetes cluster to your groups. Learn more about +[group Kubernetes clusters](../../group/clusters/index.md). + ## Adding and creating a new GKE cluster via GitLab TIP: **Tip:** @@ -245,16 +250,18 @@ install it manually. ## Installing applications -GitLab provides a one-click install for various applications which will be -added directly to your configured cluster. Those applications are needed for -[Review Apps](../../../ci/review_apps/index.md) and [deployments](../../../ci/environments.md). +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 +[deployments](../../../ci/environments.md). NOTE: **Note:** With the exception of Knative, the applications will be installed in a dedicated namespace called `gitlab-managed-apps`. In case you have added an existing Kubernetes cluster with Tiller already installed, you should be careful as GitLab cannot -detect it. By installing it via the applications will result into having it -twice, which can lead to confusion during deployments. +detect it. In this event, installing Tiller via the applications will +result in the cluster having it twice. This can lead to confusion during +deployments. | Application | GitLab version | Description | Helm Chart | | ----------- | :------------: | ----------- | --------------- | @@ -347,17 +354,13 @@ to reach your apps. This heavily depends on your domain provider, but in case you aren't sure, just create an A record with a wildcard host like `*.example.com.`. -## Setting the environment scope +## Setting the environment scope **[PREMIUM]** -NOTE: **Note:** -This is only available for [GitLab Premium][ee] where you can add more than -one Kubernetes cluster. - -When adding more than one Kubernetes clusters to your project, you need to -differentiate them with an environment scope. The environment scope associates -clusters and [environments](../../../ci/environments.md) in an 1:1 relationship -similar to how the -[environment-specific variables](../../../ci/variables/README.md#limiting-environment-scopes-of-variables) +When adding more than one Kubernetes clusters to your project, you need +to differentiate them with an environment scope. The environment scope +associates clusters with [environments](../../../ci/environments.md) +similar to how the [environment-specific +variables](../../../ci/variables/README.md#limiting-environment-scopes-of-variables) work. The default environment scope is `*`, which means all jobs, regardless of their diff --git a/doc/user/project/clusters/serverless/img/install-knative.png b/doc/user/project/clusters/serverless/img/install-knative.png Binary files differindex dd576a9df35..a9fcc127240 100644 --- a/doc/user/project/clusters/serverless/img/install-knative.png +++ b/doc/user/project/clusters/serverless/img/install-knative.png diff --git a/doc/user/project/clusters/serverless/img/serverless-page.png b/doc/user/project/clusters/serverless/img/serverless-page.png Binary files differnew file mode 100644 index 00000000000..473ee801f10 --- /dev/null +++ b/doc/user/project/clusters/serverless/img/serverless-page.png diff --git a/doc/user/project/repository/img/repository_cleanup.png b/doc/user/project/repository/img/repository_cleanup.png Binary files differnew file mode 100644 index 00000000000..2749392ffa4 --- /dev/null +++ b/doc/user/project/repository/img/repository_cleanup.png diff --git a/doc/user/project/repository/reducing_the_repo_size_using_git.md b/doc/user/project/repository/reducing_the_repo_size_using_git.md index d534c8cbe4b..672567a8d7d 100644 --- a/doc/user/project/repository/reducing_the_repo_size_using_git.md +++ b/doc/user/project/repository/reducing_the_repo_size_using_git.md @@ -1,43 +1,105 @@ # Reducing the repository size using Git A GitLab Enterprise Edition administrator can set a [repository size limit][admin-repo-size] -which will prevent you to exceed it. +which will prevent you from exceeding it. When a project has reached its size limit, you will not be able to push to it, create a new merge request, or merge existing ones. You will still be able to create new issues, and clone the project though. Uploading LFS objects will also be denied. -In order to lift these restrictions, the administrator of the GitLab instance -needs to increase the limit on the particular project that exceeded it or you -need to instruct Git to rewrite changes. - If you exceed the repository size limit, your first thought might be to remove -some data, make a new commit and push back to the repository. Unfortunately, -it's not so easy and that workflow won't work. Deleting files in a commit doesn't -actually reduce the size of the repo since the earlier commits and blobs are -still around. What you need to do is rewrite history with Git's -[`filter-branch` option][gitscm]. +some data, make a new commit and push back to the repository. Perhaps you can +move some blobs to LFS, or remove some old dependency updates from history. +Unfortunately, it's not so easy and that workflow won't work. Deleting files in +a commit doesn't actually reduce the size of the repo since the earlier commits +and blobs are still around. What you need to do is rewrite history with Git's +[`filter-branch` option][gitscm], or a tool like the [BFG Repo-Cleaner][bfg]. Note that even with that method, until `git gc` runs on the GitLab side, the -"removed" commits and blobs will still be around. And if a commit was ever -included in an MR, or if a build was run for a commit, or if a user commented -on it, it will be kept around too. So, in these cases the size will not decrease. - -The only fool proof way to actually decrease the repository size is to prune all -the unneeded stuff locally, and then create a new project on GitLab and start -using that instead. +"removed" commits and blobs will still be around. You also need to be able to +push the rewritten history to GitLab, which may be impossible if you've already +exceeded the maximum size limit. -With that being said, you can try reducing your repository size with the -following method. - -## Using `git filter-branch` to purge files +In order to lift these restrictions, the administrator of the GitLab instance +needs to increase the limit on the particular project that exceeded it, so it's +always better to spot that you're approaching the limit and act proactively to +stay underneath it. If you hit the limit, and your admin can't - or won't - +temporarily increase it for you, your only option is to prune all the unneeded +stuff locally, and then create a new project on GitLab and start using that +instead. + +If you can continue to use the original project, we recommend [using the +BFG Repo-Cleaner](#using-the-bfg-repo-cleaner). It's faster and simpler than +`git filter-branch`, and GitLab can use its account of what has changed to clean +up its own internal state, maximizing the space saved. > **Warning:** > Make sure to first make a copy of your repository since rewriting history will > purge the files and information you are about to delete. Also make sure to > inform any collaborators to not use `pull` after your changes, but use `rebase`. +> **Warning:** +> This process is not suitable for removing sensitive data like password or keys +> from your repository. Information about commits, including file content, is +> cached in the database, and will remain visible even after they have been +> removed from the repository. + +## Using the BFG Repo-Cleaner + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/19376) in GitLab 11.6. + +1. [Install BFG](https://rtyley.github.io/bfg-repo-cleaner/). + +1. Navigate to your repository: + + ``` + cd my_repository/ + ``` + +1. Change to the branch you want to remove the big file from: + + ``` + git checkout master + ``` + +1. Create a commit removing the large file from the branch, if it still exists: + + ``` + git rm path/to/big_file.mpg + git commit -m 'Remove unneeded large file' + ``` + +1. Rewrite history: + + ``` + bfg --delete-files path/to/big_file.mpg + ``` + + An object map file will be written to `object-id-map.old-new.txt`. Keep it + around - you'll need it for the final step! + +1. Force-push the changes to GitLab: + + ``` + git push --force-with-lease origin master + ``` + + If this step fails, someone has changed the `master` branch while you were + rewriting history. You could restore the branch and re-run BFG to preserve + their changes, or use `git push --force` to overwrite their changes. + +1. Navigate to **Project > Settings > Repository > Repository Cleanup**: + + ![Repository settings cleanup form](img/repository_cleanup.png) + + Upload the `object-id-map.old-new.txt` file and press **Start cleanup**. + This will remove any internal git references to the old commits, and run + `git gc` against the repository. You will receive an email once it has + completed. + +## Using `git filter-branch` + 1. Navigate to your repository: ``` @@ -70,11 +132,6 @@ following method. Your repository should now be below the size limit. -> **Note:** -> As an alternative to `filter-branch`, you can use the `bfg` tool with a -> command like: `bfg --delete-files path/to/big_file.mpg`. Read the -> [BFG Repo-Cleaner][bfg] documentation for more information. - [admin-repo-size]: https://docs.gitlab.com/ee/user/admin_area/settings/account_and_limit_settings.html#repository-size-limit [bfg]: https://rtyley.github.io/bfg-repo-cleaner/ [gitscm]: https://git-scm.com/book/en/v2/Git-Tools-Rewriting-History#The-Nuclear-Option:-filter-branch diff --git a/lib/api/search.rb b/lib/api/search.rb index 5900e1cccc2..f5db692afe5 100644 --- a/lib/api/search.rb +++ b/lib/api/search.rb @@ -35,12 +35,7 @@ module API end def process_results(results) - case params[:scope] - when 'blobs', 'wiki_blobs' - paginate(results).map { |blob| blob[1] } - else - paginate(results) - end + paginate(results) end def snippets? diff --git a/lib/api/templates.rb b/lib/api/templates.rb index 8dab19d50c2..51f357d9477 100644 --- a/lib/api/templates.rb +++ b/lib/api/templates.rb @@ -82,7 +82,7 @@ module API params do requires :name, type: String, desc: 'The name of the template' end - get "templates/#{template_type}/:name" do + get "templates/#{template_type}/:name", requirements: { name: /[\w\.-]+/ } do finder = TemplateFinder.build(template_type, nil, name: declared(params)[:name]) new_template = finder.execute diff --git a/lib/gitlab/database/count.rb b/lib/gitlab/database/count.rb index c996d786909..f3d37ccd72a 100644 --- a/lib/gitlab/database/count.rb +++ b/lib/gitlab/database/count.rb @@ -40,7 +40,7 @@ module Gitlab if strategy.enabled? models_with_missing_counts = models - counts_by_model.keys - break if models_with_missing_counts.empty? + break counts_by_model if models_with_missing_counts.empty? counts = strategy.new(models_with_missing_counts).count diff --git a/lib/gitlab/database/count/exact_count_strategy.rb b/lib/gitlab/database/count/exact_count_strategy.rb index 0276fe2b54f..fa6951eda22 100644 --- a/lib/gitlab/database/count/exact_count_strategy.rb +++ b/lib/gitlab/database/count/exact_count_strategy.rb @@ -20,6 +20,8 @@ module Gitlab models.each_with_object({}) do |model, data| data[model] = model.count end + rescue *CONNECTION_ERRORS + {} end def self.enabled? diff --git a/lib/gitlab/file_finder.rb b/lib/gitlab/file_finder.rb index b4db3f93c9c..3958814208c 100644 --- a/lib/gitlab/file_finder.rb +++ b/lib/gitlab/file_finder.rb @@ -4,8 +4,6 @@ # the result is joined and sorted by file name module Gitlab class FileFinder - BATCH_SIZE = 100 - attr_reader :project, :ref delegate :repository, to: :project @@ -16,60 +14,35 @@ module Gitlab end def find(query) - query = Gitlab::Search::Query.new(query) do - filter :filename, matcher: ->(filter, blob) { blob.filename =~ /#{filter[:regex_value]}$/i } - filter :path, matcher: ->(filter, blob) { blob.filename =~ /#{filter[:regex_value]}/i } - filter :extension, matcher: ->(filter, blob) { blob.filename =~ /\.#{filter[:regex_value]}$/i } + query = Gitlab::Search::Query.new(query, encode_binary: true) do + filter :filename, matcher: ->(filter, blob) { blob.binary_filename =~ /#{filter[:regex_value]}$/i } + filter :path, matcher: ->(filter, blob) { blob.binary_filename =~ /#{filter[:regex_value]}/i } + filter :extension, matcher: ->(filter, blob) { blob.binary_filename =~ /\.#{filter[:regex_value]}$/i } end - by_content = find_by_content(query.term) - - already_found = Set.new(by_content.map(&:filename)) - by_filename = find_by_filename(query.term, except: already_found) + files = find_by_filename(query.term) + find_by_content(query.term) - files = (by_content + by_filename) - .sort_by(&:filename) + files = query.filter_results(files) if query.filters.any? - query.filter_results(files).map { |blob| [blob.filename, blob] } + files end private def find_by_content(query) - results = repository.search_files_by_content(query, ref).first(BATCH_SIZE) - results.map { |result| Gitlab::ProjectSearchResults.parse_search_result(result, project) } - end - - def find_by_filename(query, except: []) - filenames = search_filenames(query, except) - - blobs(filenames).map do |blob| - Gitlab::SearchResults::FoundBlob.new( - id: blob.id, - filename: blob.path, - basename: File.basename(blob.path, File.extname(blob.path)), - ref: ref, - startline: 1, - data: blob.data, - project: project - ) + repository.search_files_by_content(query, ref).map do |result| + Gitlab::Search::FoundBlob.new(content_match: result, project: project, ref: ref, repository: repository) end end - def search_filenames(query, except) - filenames = repository.search_files_by_name(query, ref).first(BATCH_SIZE) - - filenames.delete_if { |filename| except.include?(filename) } unless except.empty? - - filenames - end - - def blob_refs(filenames) - filenames.map { |filename| [ref, filename] } + def find_by_filename(query) + search_filenames(query).map do |filename| + Gitlab::Search::FoundBlob.new(blob_filename: filename, project: project, ref: ref, repository: repository) + end end - def blobs(filenames) - Gitlab::Git::Blob.batch(repository, blob_refs(filenames), blob_size_limit: 1024) + def search_filenames(query) + repository.search_files_by_name(query, ref) end end end diff --git a/lib/gitlab/git/repository_cleaner.rb b/lib/gitlab/git/repository_cleaner.rb new file mode 100644 index 00000000000..2d1d8435cf3 --- /dev/null +++ b/lib/gitlab/git/repository_cleaner.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Gitlab + module Git + class RepositoryCleaner + include Gitlab::Git::WrapsGitalyErrors + + attr_reader :repository + + # 'repository' is a Gitlab::Git::Repository + def initialize(repository) + @repository = repository + end + + def apply_bfg_object_map(io) + wrapped_gitaly_errors do + gitaly_cleanup_client.apply_bfg_object_map(io) + end + end + + private + + def gitaly_cleanup_client + @gitaly_cleanup_client ||= Gitlab::GitalyClient::CleanupService.new(repository) + end + end + end +end diff --git a/lib/gitlab/gitaly_client/cleanup_service.rb b/lib/gitlab/gitaly_client/cleanup_service.rb new file mode 100644 index 00000000000..8e412a9b3ef --- /dev/null +++ b/lib/gitlab/gitaly_client/cleanup_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Gitlab + module GitalyClient + class CleanupService + attr_reader :repository, :gitaly_repo, :storage + + # 'repository' is a Gitlab::Git::Repository + def initialize(repository) + @repository = repository + @gitaly_repo = repository.gitaly_repository + @storage = repository.storage + end + + def apply_bfg_object_map(io) + first_request = Gitaly::ApplyBfgObjectMapRequest.new(repository: gitaly_repo) + + enum = Enumerator.new do |y| + y.yield first_request + + while data = io.read(RepositoryService::MAX_MSG_SIZE) + y.yield Gitaly::ApplyBfgObjectMapRequest.new(object_map: data) + end + end + + GitalyClient.call( + storage, + :cleanup_service, + :apply_bfg_object_map, + enum, + timeout: GitalyClient.no_timeout + ) + end + end + end +end diff --git a/lib/gitlab/gpg/commit.rb b/lib/gitlab/gpg/commit.rb index 31bab20b044..4fbb87385c3 100644 --- a/lib/gitlab/gpg/commit.rb +++ b/lib/gitlab/gpg/commit.rb @@ -44,9 +44,8 @@ module Gitlab def update_signature!(cached_signature) using_keychain do |gpg_key| cached_signature.update!(attributes(gpg_key)) + @signature = cached_signature end - - @signature = cached_signature end private @@ -59,11 +58,15 @@ module Gitlab # the proper signature. # NOTE: the invoked method is #fingerprint but it's only returning # 16 characters (the format used by keyid) instead of 40. - gpg_key = find_gpg_key(verified_signature.fingerprint) + fingerprint = verified_signature&.fingerprint + + break unless fingerprint + + gpg_key = find_gpg_key(fingerprint) if gpg_key Gitlab::Gpg::CurrentKeyChain.add(gpg_key.key) - @verified_signature = nil + clear_memoization(:verified_signature) end yield gpg_key @@ -71,9 +74,16 @@ module Gitlab end def verified_signature - @verified_signature ||= GPGME::Crypto.new.verify(signature_text, signed_text: signed_text) do |verified_signature| + strong_memoize(:verified_signature) { gpgme_signature } + end + + def gpgme_signature + GPGME::Crypto.new.verify(signature_text, signed_text: signed_text) do |verified_signature| + # Return the first signature for now: https://gitlab.com/gitlab-org/gitlab-ce/issues/54932 break verified_signature end + rescue GPGME::Error + nil end def create_cached_signature! @@ -92,7 +102,7 @@ module Gitlab commit_sha: @commit.sha, project: @commit.project, gpg_key: gpg_key, - gpg_key_primary_keyid: gpg_key&.keyid || verified_signature.fingerprint, + gpg_key_primary_keyid: gpg_key&.keyid || verified_signature&.fingerprint, gpg_key_user_name: user_infos[:name], gpg_key_user_email: user_infos[:email], verification_status: verification_status @@ -102,7 +112,7 @@ module Gitlab def verification_status(gpg_key) return :unknown_key unless gpg_key return :unverified_key unless gpg_key.verified? - return :unverified unless verified_signature.valid? + return :unverified unless verified_signature&.valid? if gpg_key.verified_and_belongs_to_email?(@commit.committer_email) :verified diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index 93065879ec6..d10d4f2f746 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -115,6 +115,7 @@ excluded_attributes: - :remote_mirror_available_overridden - :description_html - :repository_languages + - :bfg_object_map namespaces: - :runners_token - :runners_token_encrypted @@ -142,6 +143,7 @@ excluded_attributes: statuses: - :trace - :token + - :token_encrypted - :when - :artifacts_file - :artifacts_metadata diff --git a/lib/gitlab/project_search_results.rb b/lib/gitlab/project_search_results.rb index 04df881bf03..a68f8801c2a 100644 --- a/lib/gitlab/project_search_results.rb +++ b/lib/gitlab/project_search_results.rb @@ -17,9 +17,9 @@ module Gitlab when 'notes' notes.page(page).per(per_page) when 'blobs' - Kaminari.paginate_array(blobs).page(page).per(per_page) + paginated_blobs(blobs, page) when 'wiki_blobs' - Kaminari.paginate_array(wiki_blobs).page(page).per(per_page) + paginated_blobs(wiki_blobs, page) when 'commits' Kaminari.paginate_array(commits).page(page).per(per_page) else @@ -55,37 +55,6 @@ module Gitlab @commits_count ||= commits.count end - def self.parse_search_result(result, project = nil) - ref = nil - filename = nil - basename = nil - - data = [] - startline = 0 - - result.each_line.each_with_index do |line, index| - prefix ||= line.match(/^(?<ref>[^:]*):(?<filename>[^\x00]*)\x00(?<startline>\d+)\x00/)&.tap do |matches| - ref = matches[:ref] - filename = matches[:filename] - startline = matches[:startline] - startline = startline.to_i - index - extname = Regexp.escape(File.extname(filename)) - basename = filename.sub(/#{extname}$/, '') - end - - data << line.sub(prefix.to_s, '') - end - - FoundBlob.new( - filename: filename, - basename: basename, - ref: ref, - startline: startline, - data: data.join, - project: project - ) - end - def single_commit_result? return false if commits_count != 1 @@ -97,6 +66,14 @@ module Gitlab private + def paginated_blobs(blobs, page) + results = Kaminari.paginate_array(blobs).page(page).per(per_page) + + Gitlab::Search::FoundBlob.preload_blobs(results) + + results + end + def blobs return [] unless Ability.allowed?(@current_user, :download_code, @project) diff --git a/lib/gitlab/search/found_blob.rb b/lib/gitlab/search/found_blob.rb new file mode 100644 index 00000000000..a62ab1521a7 --- /dev/null +++ b/lib/gitlab/search/found_blob.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +module Gitlab + module Search + class FoundBlob + include EncodingHelper + include Presentable + include BlobLanguageFromGitAttributes + include Gitlab::Utils::StrongMemoize + + attr_reader :project, :content_match, :blob_filename + + FILENAME_REGEXP = /\A(?<ref>[^:]*):(?<filename>[^\x00]*)\x00/.freeze + CONTENT_REGEXP = /^(?<ref>[^:]*):(?<filename>[^\x00]*)\x00(?<startline>\d+)\x00/.freeze + + def self.preload_blobs(blobs) + to_fetch = blobs.select { |blob| blob.is_a?(self) && blob.blob_filename } + + to_fetch.each { |blob| blob.fetch_blob } + end + + def initialize(opts = {}) + @id = opts.fetch(:id, nil) + @binary_filename = opts.fetch(:filename, nil) + @binary_basename = opts.fetch(:basename, nil) + @ref = opts.fetch(:ref, nil) + @startline = opts.fetch(:startline, nil) + @binary_data = opts.fetch(:data, nil) + @per_page = opts.fetch(:per_page, 20) + @project = opts.fetch(:project, nil) + # Some caller does not have project object (e.g. elastic search), + # yet they can trigger many calls in one go, + # causing duplicated queries. + # Allow those to just pass project_id instead. + @project_id = opts.fetch(:project_id, nil) + @content_match = opts.fetch(:content_match, nil) + @blob_filename = opts.fetch(:blob_filename, nil) + @repository = opts.fetch(:repository, nil) + end + + def id + @id ||= parsed_content[:id] + end + + def ref + @ref ||= parsed_content[:ref] + end + + def startline + @startline ||= parsed_content[:startline] + end + + # binary_filename is used for running filters on all matches, + # for grepped results (which use content_match), we get + # filename from the beginning of the grepped result which is faster + # then parsing whole snippet + def binary_filename + @binary_filename ||= content_match ? search_result_filename : parsed_content[:binary_filename] + end + + def filename + @filename ||= encode_utf8(@binary_filename || parsed_content[:binary_filename]) + end + + def basename + @basename ||= encode_utf8(@binary_basename || parsed_content[:binary_basename]) + end + + def data + @data ||= encode_utf8(@binary_data || parsed_content[:binary_data]) + end + + def path + filename + end + + def project_id + @project_id || @project&.id + end + + def present + super(presenter_class: BlobPresenter) + end + + def fetch_blob + path = [ref, blob_filename] + missing_blob = { binary_filename: blob_filename } + + BatchLoader.for(path).batch(default_value: missing_blob) do |refs, loader| + Gitlab::Git::Blob.batch(repository, refs, blob_size_limit: 1024).each do |blob| + # if the blob couldn't be fetched for some reason, + # show at least the blob filename + data = { + id: blob.id, + binary_filename: blob.path, + binary_basename: File.basename(blob.path, File.extname(blob.path)), + ref: ref, + startline: 1, + binary_data: blob.data, + project: project + } + + loader.call([ref, blob.path], data) + end + end + end + + private + + def search_result_filename + content_match.match(FILENAME_REGEXP) { |matches| matches[:filename] } + end + + def parsed_content + strong_memoize(:parsed_content) do + if content_match + parse_search_result + elsif blob_filename + fetch_blob + else + {} + end + end + end + + def parse_search_result + ref = nil + filename = nil + basename = nil + + data = [] + startline = 0 + + content_match.each_line.each_with_index do |line, index| + prefix ||= line.match(CONTENT_REGEXP)&.tap do |matches| + ref = matches[:ref] + filename = matches[:filename] + startline = matches[:startline] + startline = startline.to_i - index + extname = Regexp.escape(File.extname(filename)) + basename = filename.sub(/#{extname}$/, '') + end + + data << line.sub(prefix.to_s, '') + end + + { + binary_filename: filename, + binary_basename: basename, + ref: ref, + startline: startline, + binary_data: data.join, + project: project + } + end + + def repository + @repository ||= project.repository + end + end + end +end diff --git a/lib/gitlab/search/query.rb b/lib/gitlab/search/query.rb index 7f69083a492..ba0e16607a6 100644 --- a/lib/gitlab/search/query.rb +++ b/lib/gitlab/search/query.rb @@ -3,6 +3,8 @@ module Gitlab module Search class Query < SimpleDelegator + include EncodingHelper + def initialize(query, filter_opts = {}, &block) @raw_query = query.dup @filters = [] @@ -50,7 +52,9 @@ module Gitlab end def parse_filter(filter, input) - filter[:parser].call(input) + result = filter[:parser].call(input) + + @filter_options[:encode_binary] ? encode_binary(result) : result end end end diff --git a/lib/gitlab/search_results.rb b/lib/gitlab/search_results.rb index 458737f31eb..491148ec1a6 100644 --- a/lib/gitlab/search_results.rb +++ b/lib/gitlab/search_results.rb @@ -2,42 +2,6 @@ module Gitlab class SearchResults - class FoundBlob - include EncodingHelper - include Presentable - include BlobLanguageFromGitAttributes - - attr_reader :id, :filename, :basename, :ref, :startline, :data, :project - - def initialize(opts = {}) - @id = opts.fetch(:id, nil) - @filename = encode_utf8(opts.fetch(:filename, nil)) - @basename = encode_utf8(opts.fetch(:basename, nil)) - @ref = opts.fetch(:ref, nil) - @startline = opts.fetch(:startline, nil) - @data = encode_utf8(opts.fetch(:data, nil)) - @per_page = opts.fetch(:per_page, 20) - @project = opts.fetch(:project, nil) - # Some caller does not have project object (e.g. elastic search), - # yet they can trigger many calls in one go, - # causing duplicated queries. - # Allow those to just pass project_id instead. - @project_id = opts.fetch(:project_id, nil) - end - - def path - filename - end - - def project_id - @project_id || @project&.id - end - - def present - super(presenter_class: BlobPresenter) - end - end - attr_reader :current_user, :query, :per_page # Limit search results by passed projects diff --git a/lib/gitlab/template/finders/global_template_finder.rb b/lib/gitlab/template/finders/global_template_finder.rb index 76bb9eb611e..2dd4b7a4092 100644 --- a/lib/gitlab/template/finders/global_template_finder.rb +++ b/lib/gitlab/template/finders/global_template_finder.rb @@ -18,6 +18,10 @@ module Gitlab def find(key) file_name = "#{key}#{@extension}" + # The key is untrusted input, so ensure we can't be directed outside + # of base_dir + Gitlab::Utils.check_path_traversal!(file_name) + directory = select_directory(file_name) directory ? File.join(category_directory(directory), file_name) : nil end diff --git a/lib/gitlab/template/finders/repo_template_finder.rb b/lib/gitlab/template/finders/repo_template_finder.rb index b92cefefb8f..8e234148a63 100644 --- a/lib/gitlab/template/finders/repo_template_finder.rb +++ b/lib/gitlab/template/finders/repo_template_finder.rb @@ -26,6 +26,11 @@ module Gitlab def find(key) file_name = "#{key}#{@extension}" + + # The key is untrusted input, so ensure we can't be directed outside + # of base_dir inside the repository + Gitlab::Utils.check_path_traversal!(file_name) + directory = select_directory(file_name) raise FileNotFoundError if directory.nil? diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb index bfcc8efdc96..008e9cd1d24 100644 --- a/lib/gitlab/usage_data.rb +++ b/lib/gitlab/usage_data.rb @@ -2,6 +2,8 @@ module Gitlab class UsageData + APPROXIMATE_COUNT_MODELS = [Label, MergeRequest, Note, Todo].freeze + class << self def data(force_refresh: false) Rails.cache.fetch('usage_data', force: force_refresh, expires_in: 2.weeks) { uncached_data } @@ -73,12 +75,9 @@ module Gitlab issues: count(Issue), keys: count(Key), label_lists: count(List.label), - labels: count(Label), lfs_objects: count(LfsObject), - merge_requests: count(MergeRequest), milestone_lists: count(List.milestone), milestones: count(Milestone), - notes: count(Note), pages_domains: count(PagesDomain), projects: count(Project), projects_imported_from_github: count(Project.where(import_type: 'github')), @@ -86,10 +85,9 @@ module Gitlab releases: count(Release), remote_mirrors: count(RemoteMirror), snippets: count(Snippet), - todos: count(Todo), uploads: count(Upload), web_hooks: count(WebHook) - }.merge(services_usage) + }.merge(services_usage).merge(approximate_counts) } end # rubocop: enable CodeReuse/ActiveRecord @@ -164,6 +162,16 @@ module Gitlab fallback end # rubocop: enable CodeReuse/ActiveRecord + + def approximate_counts + approx_counts = Gitlab::Database::Count.approximate_counts(APPROXIMATE_COUNT_MODELS) + + APPROXIMATE_COUNT_MODELS.each_with_object({}) do |model, result| + key = model.name.underscore.pluralize.to_sym + + result[key] = approx_counts[model] || -1 + end + end end end end diff --git a/lib/gitlab/utils.rb b/lib/gitlab/utils.rb index e0e8f598ba4..26fc56227a2 100644 --- a/lib/gitlab/utils.rb +++ b/lib/gitlab/utils.rb @@ -4,6 +4,15 @@ module Gitlab module Utils extend self + # Ensure that the relative path will not traverse outside the base directory + def check_path_traversal!(path) + raise StandardError.new("Invalid path") if path.start_with?("..#{File::SEPARATOR}") || + path.include?("#{File::SEPARATOR}..#{File::SEPARATOR}") || + path.end_with?("#{File::SEPARATOR}..") + + path + end + # Run system command without outputting to stdout. # # @param cmd [Array<String>] diff --git a/lib/gitlab/wiki_file_finder.rb b/lib/gitlab/wiki_file_finder.rb index a00cd65594c..5303b3582ab 100644 --- a/lib/gitlab/wiki_file_finder.rb +++ b/lib/gitlab/wiki_file_finder.rb @@ -2,6 +2,8 @@ module Gitlab class WikiFileFinder < FileFinder + BATCH_SIZE = 100 + attr_reader :repository def initialize(project, ref) @@ -12,13 +14,11 @@ module Gitlab private - def search_filenames(query, except) + def search_filenames(query) safe_query = Regexp.escape(query.tr(' ', '-')) safe_query = Regexp.new(safe_query, Regexp::IGNORECASE) filenames = repository.ls_files(ref) - filenames.delete_if { |filename| except.include?(filename) } unless except.empty? - filenames.grep(safe_query).first(BATCH_SIZE) end end diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb index e1f777e9cd1..da22ea9cf5c 100644 --- a/lib/gitlab/workhorse.rb +++ b/lib/gitlab/workhorse.rb @@ -13,6 +13,7 @@ module Gitlab INTERNAL_API_REQUEST_HEADER = 'Gitlab-Workhorse-Api-Request'.freeze NOTIFICATION_CHANNEL = 'workhorse:notifications'.freeze ALLOWED_GIT_HTTP_ACTIONS = %w[git_receive_pack git_upload_pack info_refs].freeze + DETECT_HEADER = 'Gitlab-Workhorse-Detect-Content-Type'.freeze # Supposedly the effective key size for HMAC-SHA256 is 256 bits, i.e. 32 # bytes https://tools.ietf.org/html/rfc4868#section-2.6 diff --git a/lib/omni_auth/strategies/jwt.rb b/lib/omni_auth/strategies/jwt.rb index a792903fde7..2f3d477a591 100644 --- a/lib/omni_auth/strategies/jwt.rb +++ b/lib/omni_auth/strategies/jwt.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'omniauth' +require 'openssl' require 'jwt' module OmniAuth @@ -37,7 +38,19 @@ module OmniAuth end def decoded - @decoded ||= ::JWT.decode(request.params['jwt'], options.secret, options.algorithm).first + secret = + case options.algorithm + when *%w[RS256 RS384 RS512] + OpenSSL::PKey::RSA.new(options.secret).public_key + when *%w[ES256 ES384 ES512] + OpenSSL::PKey::EC.new(options.secret).tap { |key| key.private_key = nil } + when *%w(HS256 HS384 HS512) + options.secret + else + raise NotImplementedError, "Unsupported algorithm: #{options.algorithm}" + end + + @decoded ||= ::JWT.decode(request.params['jwt'], secret, true, { algorithm: options.algorithm }).first (options.required_claims || []).each do |field| raise ClaimInvalid, "Missing required '#{field}' claim" unless @decoded.key?(field.to_s) @@ -45,7 +58,7 @@ module OmniAuth raise ClaimInvalid, "Missing required 'iat' claim" if options.valid_within && !@decoded["iat"] - if options.valid_within && (Time.now.to_i - @decoded["iat"]).abs > options.valid_within + if options.valid_within && (Time.now.to_i - @decoded["iat"]).abs > options.valid_within.to_i raise ClaimInvalid, "'iat' timestamp claim is too skewed from present" end diff --git a/lib/tasks/gitlab/web_hook.rake b/lib/tasks/gitlab/web_hook.rake index 5a1c8006052..15cec80b6a6 100644 --- a/lib/tasks/gitlab/web_hook.rake +++ b/lib/tasks/gitlab/web_hook.rake @@ -25,11 +25,22 @@ namespace :gitlab do web_hook_url = ENV['URL'] namespace_path = ENV['NAMESPACE'] - projects = find_projects(namespace_path) - project_ids = projects.pluck(:id) + web_hooks = find_web_hooks(namespace_path) puts "Removing webhooks with the url '#{web_hook_url}' ... " - count = WebHook.where(url: web_hook_url, project_id: project_ids, type: 'ProjectHook').delete_all + + # FIXME: Hook URLs are now encrypted, so there is no way to efficiently + # find them all in SQL. For now, check them in Ruby. If this is too slow, + # we could consider storing a hash of the URL alongside the encrypted + # value to speed up searches + count = 0 + web_hooks.find_each do |hook| + next unless hook.url == web_hook_url + + hook.destroy! + count += 1 + end + puts "#{count} webhooks were removed." end @@ -37,29 +48,37 @@ namespace :gitlab do task list: :environment do namespace_path = ENV['NAMESPACE'] - projects = find_projects(namespace_path) - web_hooks = projects.all.map(&:hooks).flatten - web_hooks.each do |hook| + web_hooks = find_web_hooks(namespace_path) + web_hooks.find_each do |hook| puts "#{hook.project.name.truncate(20).ljust(20)} -> #{hook.url}" end - puts "\n#{web_hooks.size} webhooks found." + puts "\n#{web_hooks.count} webhooks found." end end def find_projects(namespace_path) if namespace_path.blank? Project - elsif namespace_path == '/' - Project.in_namespace(nil) else - namespace = Namespace.where(path: namespace_path).first - if namespace - Project.in_namespace(namespace.id) - else + namespace = Namespace.find_by_full_path(namespace_path) + + unless namespace puts "Namespace not found: #{namespace_path}".color(:red) exit 2 end + + Project.in_namespace(namespace.id) + end + end + + def find_web_hooks(namespace_path) + if namespace_path.blank? + ProjectHook + else + project_ids = find_projects(namespace_path).select(:id) + + ProjectHook.where(project_id: project_ids) end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index f023a9be3eb..fc923bf1554 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -225,7 +225,7 @@ msgstr "" msgid "2FA enabled" msgstr "" -msgid "403|Please contact your GitLab administrator to get the permission." +msgid "403|Please contact your GitLab administrator to get permission." msgstr "" msgid "403|You don't have the permission to access this page." @@ -1244,6 +1244,9 @@ msgstr "" msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request." msgstr "" +msgid "Choose a file" +msgstr "" + msgid "Choose a template..." msgstr "" @@ -2939,6 +2942,9 @@ msgstr "" msgid "Failed to update issues, please try again." msgstr "" +msgid "Failed to upload object map file" +msgstr "" + msgid "Failure" msgstr "" @@ -4333,6 +4339,9 @@ msgstr "" msgid "No file chosen" msgstr "" +msgid "No file selected" +msgstr "" + msgid "No files found." msgstr "" @@ -5489,6 +5498,12 @@ msgstr "" msgid "Repository URL" msgstr "" +msgid "Repository cleanup" +msgstr "" + +msgid "Repository cleanup has started. You will receive an email once the cleanup operation is complete." +msgstr "" + msgid "Repository maintenance" msgstr "" @@ -5815,6 +5830,45 @@ msgstr "" msgid "Server version" msgstr "" +msgid "Serverless" +msgstr "" + +msgid "Serverless| In order to start using functions as a service, you must first install Knative on your Kubernetes cluster." +msgstr "" + +msgid "Serverless|An error occurred while retrieving serverless components" +msgstr "" + +msgid "Serverless|Domain" +msgstr "" + +msgid "Serverless|Function" +msgstr "" + +msgid "Serverless|Getting started with serverless" +msgstr "" + +msgid "Serverless|If you believe none of these apply, please check back later as the function data may be in the process of becoming available." +msgstr "" + +msgid "Serverless|Install Knative" +msgstr "" + +msgid "Serverless|Last Update" +msgstr "" + +msgid "Serverless|Learn more about Serverless" +msgstr "" + +msgid "Serverless|No functions available" +msgstr "" + +msgid "Serverless|Runtime" +msgstr "" + +msgid "Serverless|There is currently no function data available from Knative. This could be for a variety of reasons including:" +msgstr "" + msgid "Service Templates" msgstr "" @@ -6162,6 +6216,9 @@ msgstr "" msgid "Start and due date" msgstr "" +msgid "Start cleanup" +msgstr "" + msgid "Start date" msgstr "" @@ -6377,6 +6434,9 @@ msgstr "" msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." msgstr "" +msgid "The maximum file size allowed is %{max_attachment_size}mb" +msgstr "" + msgid "The maximum file size allowed is 200KB." msgstr "" @@ -7059,6 +7119,9 @@ msgstr "" msgid "Upload file" msgstr "" +msgid "Upload object map" +msgstr "" + msgid "UploadLink|click to upload" msgstr "" @@ -7932,6 +7995,9 @@ msgid_plural "replies" msgstr[0] "" msgstr[1] "" +msgid "should be higher than %{access} inherited membership from group %{group_name}" +msgstr "" + msgid "source" msgstr "" diff --git a/package.json b/package.json index 680a5bb1cde..ac4d5174610 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "@babel/plugin-syntax-import-meta": "^7.0.0", "@babel/preset-env": "^7.1.0", "@gitlab/svgs": "^1.40.0", - "@gitlab/ui": "^1.11.0", + "@gitlab/ui": "^1.14.0", "apollo-boost": "^0.1.20", "apollo-client": "^2.4.5", "autosize": "^4.0.0", @@ -184,6 +184,7 @@ module QA autoload :Runners, 'qa/page/project/settings/runners' autoload :MergeRequest, 'qa/page/project/settings/merge_request' autoload :Members, 'qa/page/project/settings/members' + autoload :MirroringRepositories, 'qa/page/project/settings/mirroring_repositories' end module Issue diff --git a/qa/qa/page/base.rb b/qa/qa/page/base.rb index 91e229c4c8c..f4bba3c9560 100644 --- a/qa/qa/page/base.rb +++ b/qa/qa/page/base.rb @@ -15,7 +15,7 @@ module QA def_delegators :evaluator, :view, :views def refresh - visit current_url + page.refresh end def wait(max: 60, time: 0.1, reload: true) @@ -80,8 +80,8 @@ module QA page.evaluate_script('xhr.status') == 200 end - def find_element(name) - find(element_selector_css(name)) + def find_element(name, wait: Capybara.default_max_wait_time) + find(element_selector_css(name), wait: wait) end def all_elements(name) @@ -100,6 +100,14 @@ module QA find_element(name).set(content) end + def select_element(name, value) + element = find_element(name) + + return if element.text.downcase.to_s == value.to_s + + element.select value.to_s.capitalize + end + def has_element?(name) has_css?(element_selector_css(name)) end @@ -110,6 +118,12 @@ module QA end end + def within_element_by_index(name, index) + page.within all_elements(name)[index] do + yield + end + end + def scroll_to_element(name, *args) scroll_to(element_selector_css(name), *args) end diff --git a/qa/qa/page/project/settings/mirroring_repositories.rb b/qa/qa/page/project/settings/mirroring_repositories.rb new file mode 100644 index 00000000000..a73be7dfeda --- /dev/null +++ b/qa/qa/page/project/settings/mirroring_repositories.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module QA + module Page + module Project + module Settings + class MirroringRepositories < Page::Base + view 'app/views/projects/mirrors/_authentication_method.html.haml' do + element :authentication_method + element :password + end + + view 'app/views/projects/mirrors/_mirror_repos.html.haml' do + element :mirror_repository_url_input + element :mirror_repository_button + element :mirror_repository_url + element :mirror_last_update_at + element :mirrored_repository_row + end + + view 'app/views/projects/mirrors/_mirror_repos_form.html.haml' do + element :mirror_direction + end + + view 'app/views/shared/_remote_mirror_update_button.html.haml' do + element :update_now_button + end + + def repository_url=(value) + fill_element :mirror_repository_url_input, value + end + + def password=(value) + fill_element :password, value + end + + def mirror_direction=(value) + raise ArgumentError, "Mirror direction must be :push or :pull" unless [:push, :pull].include? value + + select_element(:mirror_direction, value) + end + + def authentication_method=(value) + raise ArgumentError, "Authentication method must be :password or :none" unless [:password, :none].include? value + + select_element(:authentication_method, value) + end + + def mirror_repository + click_element :mirror_repository_button + end + + def update(url) + row_index = find_repository_row_index url + + within_element_by_index(:mirrored_repository_row, row_index) do + click_element :update_now_button + end + + # Wait a few seconds for the sync to occur and then refresh the page + # so that 'last update' shows 'just now' or a period in seconds + sleep 5 + refresh + + wait(time: 1) do + within_element_by_index(:mirrored_repository_row, row_index) do + last_update = find_element(:mirror_last_update_at, wait: 0) + last_update.has_text?('just now') || last_update.has_text?('seconds') + end + end + + # Fail early if the page still shows that there has been no update + within_element_by_index(:mirrored_repository_row, row_index) do + find_element(:mirror_last_update_at, wait: 0).assert_no_text('Never') + end + end + + private + + def find_repository_row_index(target_url) + all_elements(:mirror_repository_url).index do |url| + # The url might be a sanitized url but the target_url won't be so + # we compare just the paths instead of the full url + URI.parse(url.text).path == target_url.path + end + end + end + end + end + end +end diff --git a/qa/qa/page/project/settings/repository.rb b/qa/qa/page/project/settings/repository.rb index 53ebe28970b..ac0b87aca5e 100644 --- a/qa/qa/page/project/settings/repository.rb +++ b/qa/qa/page/project/settings/repository.rb @@ -13,6 +13,10 @@ module QA element :protected_branches_settings end + view 'app/views/projects/mirrors/_mirror_repos.html.haml' do + element :mirroring_repositories_settings + end + def expand_deploy_keys(&block) expand_section(:deploy_keys_settings) do DeployKeys.perform(&block) @@ -30,6 +34,12 @@ module QA DeployTokens.perform(&block) end end + + def expand_mirroring_repositories(&block) + expand_section(:mirroring_repositories_settings) do + MirroringRepositories.perform(&block) + end + end end end end diff --git a/qa/qa/specs/features/browser_ui/3_create/repository/push_mirroring_over_http_spec.rb b/qa/qa/specs/features/browser_ui/3_create/repository/push_mirroring_over_http_spec.rb new file mode 100644 index 00000000000..2d0e281ab59 --- /dev/null +++ b/qa/qa/specs/features/browser_ui/3_create/repository/push_mirroring_over_http_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module QA + context 'Create' do + describe 'Push mirror a repository over HTTP' do + it 'configures and syncs a (push) mirrored repository' do + Runtime::Browser.visit(:gitlab, Page::Main::Login) + Page::Main::Login.perform(&:sign_in_using_credentials) + + target_project = Resource::Project.fabricate! do |project| + project.name = 'push-mirror-target-project' + end + target_project_uri = target_project.repository_http_location.uri + target_project_uri.user = Runtime::User.username + + source_project_push = Resource::Repository::ProjectPush.fabricate! do |push| + push.file_name = 'README.md' + push.file_content = '# This is a test project' + push.commit_message = 'Add README.md' + end + source_project_push.project.visit! + + Page::Project::Show.perform(&:wait_for_push) + + Page::Project::Menu.perform(&:click_repository_settings) + Page::Project::Settings::Repository.perform do |settings| + settings.expand_mirroring_repositories do |mirror_settings| + # Configure the source project to push to the target project + mirror_settings.repository_url = target_project_uri + mirror_settings.mirror_direction = :push + mirror_settings.authentication_method = :password + mirror_settings.password = Runtime::User.password + mirror_settings.mirror_repository + mirror_settings.update target_project_uri + end + end + + # Check that the target project has the commit from the source + target_project.visit! + expect(page).to have_content('README.md') + expect(page).to have_content('This is a test project') + end + end + end +end diff --git a/qa/qa/support/page/logging.rb b/qa/qa/support/page/logging.rb index cf5cd3a79f8..43bc16d8c9a 100644 --- a/qa/qa/support/page/logging.rb +++ b/qa/qa/support/page/logging.rb @@ -37,8 +37,8 @@ module QA exists end - def find_element(name) - log("finding :#{name}") + def find_element(name, wait: Capybara.default_max_wait_time) + log("finding :#{name} (wait: #{wait})") element = super @@ -71,6 +71,12 @@ module QA super end + def select_element(name, value) + log(%Q(selecting "#{value}" in :#{name})) + + super + end + def has_element?(name) found = super @@ -89,6 +95,16 @@ module QA element end + def within_element_by_index(name, index) + log("within elements :#{name} at index #{index}") + + element = super + + log("end within elements :#{name} at index #{index}") + + element + end + private def log(msg) diff --git a/scripts/review_apps/review-apps.sh b/scripts/review_apps/review-apps.sh index f3f788e0217..b50bf2161cb 100755 --- a/scripts/review_apps/review-apps.sh +++ b/scripts/review_apps/review-apps.sh @@ -289,8 +289,8 @@ function get_job_id() { local url="https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/pipelines/${CI_PIPELINE_ID}/jobs?per_page=100&page=${page}${query_string}" echoerr "GET ${url}" - local job_id=$(curl --silent --show-error --header "PRIVATE-TOKEN: ${API_TOKEN}" "${url}" | jq ".[] | select(.name == \"${job_name}\") | .id") - [[ "${job_id}" == "" && "${page}" -lt "$max_page" ]] || break + local job_id=$(curl --silent --show-error --header "PRIVATE-TOKEN: ${API_TOKEN}" "${url}" | jq "map(select(.name == \"${job_name}\")) | map(.id) | last") + [[ "${job_id}" == "null" && "${page}" -lt "$max_page" ]] || break ((page++)) done @@ -328,17 +328,18 @@ function wait_for_job_to_be_done() { # In case the job hasn't finished yet. Keep trying until the job times out. local interval=30 - local elapsed=0 + local elapsed_seconds=0 while true; do local job_status=$(curl --silent --show-error --header "PRIVATE-TOKEN: ${API_TOKEN}" "${url}" | jq ".status" | sed -e s/\"//g) [[ "${job_status}" == "pending" || "${job_status}" == "running" ]] || break printf "." - ((elapsed+=$interval)) + ((elapsed_seconds+=$interval)) sleep ${interval} done - echoerr "Waited '${job_name}' for ${elapsed} seconds." + local elapsed_minutes=$((elapsed_seconds / 60)) + echoerr "Waited '${job_name}' for ${elapsed_minutes} minutes." if [[ "${job_status}" == "failed" ]]; then echo "The '${job_name}' failed." diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index f6c85102830..4b0dc4c9b69 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -226,9 +226,10 @@ describe GroupsController do end context 'searching' do - # Remove as part of https://gitlab.com/gitlab-org/gitlab-ce/issues/52271 before do + # Remove in https://gitlab.com/gitlab-org/gitlab-ce/issues/54643 stub_feature_flags(use_cte_for_group_issues_search: false) + stub_feature_flags(use_subquery_for_group_issues_search: true) end it 'works with popularity sort' do diff --git a/spec/controllers/projects/avatars_controller_spec.rb b/spec/controllers/projects/avatars_controller_spec.rb index 14059cff74c..5a77a7ac06f 100644 --- a/spec/controllers/projects/avatars_controller_spec.rb +++ b/spec/controllers/projects/avatars_controller_spec.rb @@ -26,12 +26,37 @@ describe Projects::AvatarsController do context 'when the avatar is stored in the repository' do let(:filepath) { 'files/images/logo-white.png' } - it 'sends the avatar' do - subject + context 'when feature flag workhorse_set_content_type is' do + before do + stub_feature_flags(workhorse_set_content_type: flag_value) + end - expect(response).to have_gitlab_http_status(200) - expect(response.header['Content-Type']).to eq('image/png') - expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') + context 'enabled' do + let(:flag_value) { true } + + it 'sends the avatar' do + subject + + expect(response).to have_gitlab_http_status(200) + expect(response.header['Content-Disposition']).to eq('inline') + expect(response.header['Content-Type']).to eq 'image/png' + expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') + expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to eq "true" + end + end + + context 'disabled' do + let(:flag_value) { false } + + it 'sends the avatar' do + subject + + expect(response).to have_gitlab_http_status(200) + expect(response.header['Content-Type']).to eq('image/png') + expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') + expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to eq nil + end + end end end diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb index da3d658d061..51a7cc63cef 100644 --- a/spec/controllers/projects/jobs_controller_spec.rb +++ b/spec/controllers/projects/jobs_controller_spec.rb @@ -838,23 +838,48 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do context "when job has a trace artifact" do let(:job) { create(:ci_build, :trace_artifact, pipeline: pipeline) } - it 'returns a trace' do - response = subject + context 'when feature flag workhorse_set_content_type is' do + before do + stub_feature_flags(workhorse_set_content_type: flag_value) + end - expect(response).to have_gitlab_http_status(:ok) - expect(response.headers["Content-Type"]).to eq("text/plain; charset=utf-8") - expect(response.body).to eq(job.job_artifacts_trace.open.read) + context 'enabled' do + let(:flag_value) { true } + + it "sets #{Gitlab::Workhorse::DETECT_HEADER} header" do + response = subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers["Content-Type"]).to eq("text/plain; charset=utf-8") + expect(response.body).to eq(job.job_artifacts_trace.open.read) + expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to eq "true" + end + end + + context 'disabled' do + let(:flag_value) { false } + + it 'returns a trace' do + response = subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers["Content-Type"]).to eq("text/plain; charset=utf-8") + expect(response.body).to eq(job.job_artifacts_trace.open.read) + expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to be nil + end + end end end context "when job has a trace file" do let(:job) { create(:ci_build, :trace_live, pipeline: pipeline) } - it "send a trace file" do + it 'sends a trace file' do response = subject expect(response).to have_gitlab_http_status(:ok) expect(response.headers["Content-Type"]).to eq("text/plain; charset=utf-8") + expect(response.headers["Content-Disposition"]).to match(/^inline/) expect(response.body).to eq("BUILD TRACE") end end @@ -866,12 +891,27 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do job.update_column(:trace, "Sample trace") end - it "send a trace file" do + it 'sends a trace file' do response = subject expect(response).to have_gitlab_http_status(:ok) - expect(response.headers["Content-Type"]).to eq("text/plain; charset=utf-8") - expect(response.body).to eq("Sample trace") + expect(response.headers['Content-Type']).to eq('text/plain; charset=utf-8') + expect(response.headers['Content-Disposition']).to match(/^inline/) + expect(response.body).to eq('Sample trace') + end + + context 'when trace format is not text/plain' do + before do + job.update_column(:trace, '<html></html>') + end + + it 'sets content disposition to attachment' do + response = subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers['Content-Type']).to eq('text/plain; charset=utf-8') + expect(response.headers['Content-Disposition']).to match(/^attachment/) + end end end diff --git a/spec/controllers/projects/raw_controller_spec.rb b/spec/controllers/projects/raw_controller_spec.rb index 6b658bf5295..d3cd15fbcd7 100644 --- a/spec/controllers/projects/raw_controller_spec.rb +++ b/spec/controllers/projects/raw_controller_spec.rb @@ -14,26 +14,74 @@ describe Projects::RawController do context 'regular filename' do let(:filepath) { 'master/README.md' } - it 'delivers ASCII file' do - subject - - expect(response).to have_gitlab_http_status(200) - expect(response.header['Content-Type']).to eq('text/plain; charset=utf-8') - expect(response.header['Content-Disposition']) - .to eq('inline') - expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') + context 'when feature flag workhorse_set_content_type is' do + before do + stub_feature_flags(workhorse_set_content_type: flag_value) + + subject + end + + context 'enabled' do + let(:flag_value) { true } + + it 'delivers ASCII file' do + expect(response).to have_gitlab_http_status(200) + expect(response.header['Content-Type']).to eq('text/plain; charset=utf-8') + expect(response.header['Content-Disposition']).to eq('inline') + expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to eq "true" + expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') + end + end + + context 'disabled' do + let(:flag_value) { false } + + it 'delivers ASCII file' do + expect(response).to have_gitlab_http_status(200) + expect(response.header['Content-Type']).to eq('text/plain; charset=utf-8') + expect(response.header['Content-Disposition']).to eq('inline') + expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to eq nil + expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') + end + end end end context 'image header' do let(:filepath) { 'master/files/images/6049019_460s.jpg' } - it 'sets image content type header' do - subject + context 'when feature flag workhorse_set_content_type is' do + before do + stub_feature_flags(workhorse_set_content_type: flag_value) + end + + context 'enabled' do + let(:flag_value) { true } + + it 'leaves image content disposition' do + subject + + expect(response).to have_gitlab_http_status(200) + expect(response.header['Content-Type']).to eq('image/jpeg') + expect(response.header['Content-Disposition']).to eq('inline') + expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to eq "true" + expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') + end + end + + context 'disabled' do + let(:flag_value) { false } + + it 'sets image content type header' do + subject - expect(response).to have_gitlab_http_status(200) - expect(response.header['Content-Type']).to eq('image/jpeg') - expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') + expect(response).to have_gitlab_http_status(200) + expect(response.header['Content-Type']).to eq('image/jpeg') + expect(response.header['Content-Disposition']).to eq('inline') + expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to eq nil + expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') + end + end end end diff --git a/spec/controllers/projects/serverless/functions_controller_spec.rb b/spec/controllers/projects/serverless/functions_controller_spec.rb new file mode 100644 index 00000000000..284b582b1f5 --- /dev/null +++ b/spec/controllers/projects/serverless/functions_controller_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Projects::Serverless::FunctionsController do + include KubernetesHelpers + include ReactiveCachingHelpers + + let(:user) { create(:user) } + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) } + let(:service) { cluster.platform_kubernetes } + let(:project) { cluster.project} + + let(:namespace) do + create(:cluster_kubernetes_namespace, + cluster: cluster, + cluster_project: cluster.cluster_project, + project: cluster.cluster_project.project) + end + + before do + project.add_maintainer(user) + sign_in(user) + end + + def params(opts = {}) + opts.reverse_merge(namespace_id: project.namespace.to_param, + project_id: project.to_param) + end + + describe 'GET #index' do + context 'empty cache' do + it 'has no data' do + get :index, params({ format: :json }) + + expect(response).to have_gitlab_http_status(204) + end + + it 'renders an html page' do + get :index, params + + expect(response).to have_gitlab_http_status(200) + end + end + end + + describe 'GET #index with data', :use_clean_rails_memory_store_caching do + before do + stub_reactive_cache(knative, services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"]) + end + + it 'has data' do + get :index, params({ format: :json }) + + expect(response).to have_gitlab_http_status(200) + + expect(json_response).to contain_exactly( + a_hash_including( + "name" => project.name, + "url" => "http://#{project.name}.#{namespace.namespace}.example.com" + ) + ) + end + + it 'has data in html' do + get :index, params + + expect(response).to have_gitlab_http_status(200) + end + end +end diff --git a/spec/controllers/projects/settings/repository_controller_spec.rb b/spec/controllers/projects/settings/repository_controller_spec.rb index 9cee40b7553..70f79a47e63 100644 --- a/spec/controllers/projects/settings/repository_controller_spec.rb +++ b/spec/controllers/projects/settings/repository_controller_spec.rb @@ -17,4 +17,37 @@ describe Projects::Settings::RepositoryController do expect(response).to render_template(:show) end end + + describe 'PUT cleanup' do + before do + allow(RepositoryCleanupWorker).to receive(:perform_async) + end + + def do_put! + object_map = fixture_file_upload('spec/fixtures/bfg_object_map.txt') + + put :cleanup, namespace_id: project.namespace, project_id: project, project: { object_map: object_map } + end + + context 'feature enabled' do + it 'enqueues a RepositoryCleanupWorker' do + stub_feature_flags(project_cleanup: true) + + do_put! + + expect(response).to redirect_to project_settings_repository_path(project) + expect(RepositoryCleanupWorker).to have_received(:perform_async).once + end + end + + context 'feature disabled' do + it 'shows a 404 error' do + stub_feature_flags(project_cleanup: false) + + do_put! + + expect(response).to have_gitlab_http_status(404) + end + end + end end diff --git a/spec/controllers/projects/wikis_controller_spec.rb b/spec/controllers/projects/wikis_controller_spec.rb index 6d75152857b..b974d927856 100644 --- a/spec/controllers/projects/wikis_controller_spec.rb +++ b/spec/controllers/projects/wikis_controller_spec.rb @@ -52,24 +52,56 @@ describe Projects::WikisController do let(:path) { upload_file_to_wiki(project, user, file_name) } - before do - subject - end - subject { get :show, namespace_id: project.namespace, project_id: project, id: path } context 'when file is an image' do let(:file_name) { 'dk.png' } - it 'renders the content inline' do - expect(response.headers['Content-Disposition']).to match(/^inline/) - end + context 'when feature flag workhorse_set_content_type is' do + before do + stub_feature_flags(workhorse_set_content_type: flag_value) + + subject + end - context 'when file is a svg' do - let(:file_name) { 'unsanitized.svg' } + context 'enabled' do + let(:flag_value) { true } - it 'renders the content as an attachment' do - expect(response.headers['Content-Disposition']).to match(/^attachment/) + it 'delivers the image' do + expect(response.headers['Content-Type']).to eq('image/png') + expect(response.headers['Content-Disposition']).to match(/^inline/) + expect(response.headers[Gitlab::Workhorse::DETECT_HEADER]).to eq "true" + end + + context 'when file is a svg' do + let(:file_name) { 'unsanitized.svg' } + + it 'delivers the image' do + expect(response.headers['Content-Type']).to eq('image/svg+xml') + expect(response.headers['Content-Disposition']).to match(/^attachment/) + expect(response.headers[Gitlab::Workhorse::DETECT_HEADER]).to eq "true" + end + end + end + + context 'disabled' do + let(:flag_value) { false } + + it 'renders the content inline' do + expect(response.headers['Content-Type']).to eq('image/png') + expect(response.headers['Content-Disposition']).to match(/^inline/) + expect(response.headers[Gitlab::Workhorse::DETECT_HEADER]).to eq nil + end + + context 'when file is a svg' do + let(:file_name) { 'unsanitized.svg' } + + it 'renders the content as an attachment' do + expect(response.headers['Content-Type']).to eq('image/svg+xml') + expect(response.headers['Content-Disposition']).to match(/^attachment/) + expect(response.headers[Gitlab::Workhorse::DETECT_HEADER]).to eq nil + end + end end end end @@ -77,8 +109,32 @@ describe Projects::WikisController do context 'when file is a pdf' do let(:file_name) { 'git-cheat-sheet.pdf' } - it 'sets the content type to application/octet-stream' do - expect(response.headers['Content-Type']).to eq 'application/octet-stream' + context 'when feature flag workhorse_set_content_type is' do + before do + stub_feature_flags(workhorse_set_content_type: flag_value) + + subject + end + + context 'enabled' do + let(:flag_value) { true } + + it 'sets the content type to sets the content response headers' do + expect(response.headers['Content-Type']).to eq 'application/octet-stream' + expect(response.headers['Content-Disposition']).to match(/^inline/) + expect(response.headers[Gitlab::Workhorse::DETECT_HEADER]).to eq "true" + end + end + + context 'disabled' do + let(:flag_value) { false } + + it 'sets the content response headers' do + expect(response.headers['Content-Type']).to eq 'application/octet-stream' + expect(response.headers['Content-Disposition']).to match(/^inline/) + expect(response.headers[Gitlab::Workhorse::DETECT_HEADER]).to eq nil + end + end end end end diff --git a/spec/controllers/snippets_controller_spec.rb b/spec/controllers/snippets_controller_spec.rb index 9effe47ab05..957bab638b1 100644 --- a/spec/controllers/snippets_controller_spec.rb +++ b/spec/controllers/snippets_controller_spec.rb @@ -437,7 +437,10 @@ describe SnippetsController do end context 'when signed in user is the author' do + let(:flag_value) { false } + before do + stub_feature_flags(workhorse_set_content_type: flag_value) get :raw, id: personal_snippet.to_param end @@ -451,6 +454,24 @@ describe SnippetsController do expect(response.header['Content-Disposition']).to match(/inline/) end + + context 'when feature flag workhorse_set_content_type is' do + context 'enabled' do + let(:flag_value) { true } + + it "sets #{Gitlab::Workhorse::DETECT_HEADER} header" do + expect(response).to have_gitlab_http_status(200) + expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to eq "true" + end + end + + context 'disabled' do + it "does not set #{Gitlab::Workhorse::DETECT_HEADER} header" do + expect(response).to have_gitlab_http_status(200) + expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to be nil + end + end + end end end diff --git a/spec/features/dashboard/merge_requests_spec.rb b/spec/features/dashboard/merge_requests_spec.rb index 9ffa75aee47..282bf542e77 100644 --- a/spec/features/dashboard/merge_requests_spec.rb +++ b/spec/features/dashboard/merge_requests_spec.rb @@ -6,7 +6,6 @@ describe 'Dashboard Merge Requests' do include ProjectForksHelper let(:current_user) { create :user } - let(:user) { current_user } let(:project) { create(:project) } let(:public_project) { create(:project, :public, :repository) } diff --git a/spec/features/issuables/default_sort_order_spec.rb b/spec/features/issuables/default_sort_order_spec.rb deleted file mode 100644 index caee7a67aec..00000000000 --- a/spec/features/issuables/default_sort_order_spec.rb +++ /dev/null @@ -1,179 +0,0 @@ -require 'spec_helper' - -describe 'Projects > Issuables > Default sort order' do - let(:project) { create(:project, :public) } - - let(:first_created_issuable) { issuables.order_created_asc.first } - let(:last_created_issuable) { issuables.order_created_desc.first } - - let(:first_updated_issuable) { issuables.order_updated_asc.first } - let(:last_updated_issuable) { issuables.order_updated_desc.first } - - context 'for merge requests' do - include MergeRequestHelpers - - let!(:issuables) do - timestamps = [{ created_at: 3.minutes.ago, updated_at: 20.seconds.ago }, - { created_at: 2.minutes.ago, updated_at: 30.seconds.ago }, - { created_at: 4.minutes.ago, updated_at: 10.seconds.ago }] - - timestamps.each_with_index do |ts, i| - create issuable_type, { title: "#{issuable_type}_#{i}", - source_branch: "#{issuable_type}_#{i}", - source_project: project }.merge(ts) - end - - MergeRequest.all - end - - context 'in the "merge requests" tab', :js do - let(:issuable_type) { :merge_request } - - it 'is "last created"' do - visit_merge_requests project - - expect(first_merge_request).to include(last_created_issuable.title) - expect(last_merge_request).to include(first_created_issuable.title) - end - end - - context 'in the "merge requests / open" tab', :js do - let(:issuable_type) { :merge_request } - - it 'is "created date"' do - visit_merge_requests_with_state(project, 'open') - - expect(selected_sort_order).to eq('created date') - expect(first_merge_request).to include(last_created_issuable.title) - expect(last_merge_request).to include(first_created_issuable.title) - end - end - - context 'in the "merge requests / merged" tab', :js do - let(:issuable_type) { :merged_merge_request } - - it 'is "last updated"' do - visit_merge_requests_with_state(project, 'merged') - - expect(find('.issues-other-filters')).to have_content('Last updated') - expect(first_merge_request).to include(last_updated_issuable.title) - expect(last_merge_request).to include(first_updated_issuable.title) - end - end - - context 'in the "merge requests / closed" tab', :js do - let(:issuable_type) { :closed_merge_request } - - it 'is "last updated"' do - visit_merge_requests_with_state(project, 'closed') - - expect(find('.issues-other-filters')).to have_content('Last updated') - expect(first_merge_request).to include(last_updated_issuable.title) - expect(last_merge_request).to include(first_updated_issuable.title) - end - end - - context 'in the "merge requests / all" tab', :js do - let(:issuable_type) { :merge_request } - - it 'is "created date"' do - visit_merge_requests_with_state(project, 'all') - - expect(find('.issues-other-filters')).to have_content('Created date') - expect(first_merge_request).to include(last_created_issuable.title) - expect(last_merge_request).to include(first_created_issuable.title) - end - end - end - - context 'for issues' do - include IssueHelpers - - let!(:issuables) do - timestamps = [{ created_at: 3.minutes.ago, updated_at: 20.seconds.ago }, - { created_at: 2.minutes.ago, updated_at: 30.seconds.ago }, - { created_at: 4.minutes.ago, updated_at: 10.seconds.ago }] - - timestamps.each_with_index do |ts, i| - create issuable_type, { title: "#{issuable_type}_#{i}", - project: project }.merge(ts) - end - - Issue.all - end - - context 'in the "issues" tab', :js do - let(:issuable_type) { :issue } - - it 'is "created date"' do - visit_issues project - - expect(find('.issues-other-filters')).to have_content('Created date') - expect(first_issue).to include(last_created_issuable.title) - expect(last_issue).to include(first_created_issuable.title) - end - end - - context 'in the "issues / open" tab', :js do - let(:issuable_type) { :issue } - - it 'is "created date"' do - visit_issues_with_state(project, 'open') - - expect(find('.issues-other-filters')).to have_content('Created date') - expect(first_issue).to include(last_created_issuable.title) - expect(last_issue).to include(first_created_issuable.title) - end - end - - context 'in the "issues / closed" tab', :js do - let(:issuable_type) { :closed_issue } - - it 'is "last updated"' do - visit_issues_with_state(project, 'closed') - - expect(find('.issues-other-filters')).to have_content('Last updated') - expect(first_issue).to include(last_updated_issuable.title) - expect(last_issue).to include(first_updated_issuable.title) - end - end - - context 'in the "issues / all" tab', :js do - let(:issuable_type) { :issue } - - it 'is "created date"' do - visit_issues_with_state(project, 'all') - - expect(find('.issues-other-filters')).to have_content('Created date') - expect(first_issue).to include(last_created_issuable.title) - expect(last_issue).to include(first_created_issuable.title) - end - end - - context 'when the sort in the URL is id_desc' do - let(:issuable_type) { :issue } - - before do - visit_issues(project, sort: 'id_desc') - end - - it 'shows the sort order as created date' do - expect(find('.issues-other-filters')).to have_content('Created date') - expect(first_issue).to include(last_created_issuable.title) - expect(last_issue).to include(first_created_issuable.title) - end - end - end - - def selected_sort_order - find('.filter-dropdown-container .dropdown button').text.downcase - end - - def visit_merge_requests_with_state(project, state) - visit_merge_requests project, state: state - end - - def visit_issues_with_state(project, state) - visit_issues project, state: state - end -end diff --git a/spec/features/issuables/sorting_list_spec.rb b/spec/features/issuables/sorting_list_spec.rb new file mode 100644 index 00000000000..0601dd47c03 --- /dev/null +++ b/spec/features/issuables/sorting_list_spec.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe 'Sort Issuable List' do + let(:project) { create(:project, :public) } + + let(:first_created_issuable) { issuables.order_created_asc.first } + let(:last_created_issuable) { issuables.order_created_desc.first } + + let(:first_updated_issuable) { issuables.order_updated_asc.first } + let(:last_updated_issuable) { issuables.order_updated_desc.first } + + context 'for merge requests' do + include MergeRequestHelpers + + let!(:issuables) do + timestamps = [{ created_at: 3.minutes.ago, updated_at: 20.seconds.ago }, + { created_at: 2.minutes.ago, updated_at: 30.seconds.ago }, + { created_at: 4.minutes.ago, updated_at: 10.seconds.ago }] + + timestamps.each_with_index do |ts, i| + create issuable_type, { title: "#{issuable_type}_#{i}", + source_branch: "#{issuable_type}_#{i}", + source_project: project }.merge(ts) + end + + MergeRequest.all + end + + context 'default sort order' do + context 'in the "merge requests" tab', :js do + let(:issuable_type) { :merge_request } + + it 'is "last created"' do + visit_merge_requests project + + expect(first_merge_request).to include(last_created_issuable.title) + expect(last_merge_request).to include(first_created_issuable.title) + end + end + + context 'in the "merge requests / open" tab', :js do + let(:issuable_type) { :merge_request } + + it 'is "created date"' do + visit_merge_requests_with_state(project, 'open') + + expect(selected_sort_order).to eq('created date') + expect(first_merge_request).to include(last_created_issuable.title) + expect(last_merge_request).to include(first_created_issuable.title) + end + end + + context 'in the "merge requests / merged" tab', :js do + let(:issuable_type) { :merged_merge_request } + + it 'is "last updated"' do + visit_merge_requests_with_state(project, 'merged') + + expect(find('.issues-other-filters')).to have_content('Last updated') + expect(first_merge_request).to include(last_updated_issuable.title) + expect(last_merge_request).to include(first_updated_issuable.title) + end + end + + context 'in the "merge requests / closed" tab', :js do + let(:issuable_type) { :closed_merge_request } + + it 'is "last updated"' do + visit_merge_requests_with_state(project, 'closed') + + expect(find('.issues-other-filters')).to have_content('Last updated') + expect(first_merge_request).to include(last_updated_issuable.title) + expect(last_merge_request).to include(first_updated_issuable.title) + end + end + + context 'in the "merge requests / all" tab', :js do + let(:issuable_type) { :merge_request } + + it 'is "created date"' do + visit_merge_requests_with_state(project, 'all') + + expect(find('.issues-other-filters')).to have_content('Created date') + expect(first_merge_request).to include(last_created_issuable.title) + expect(last_merge_request).to include(first_created_issuable.title) + end + end + + context 'custom sorting' do + let(:issuable_type) { :merge_request } + + it 'supports sorting in asc and desc order' do + visit_merge_requests_with_state(project, 'open') + + page.within('.issues-other-filters') do + click_button('Created date') + click_link('Last updated') + end + + expect(first_merge_request).to include(last_updated_issuable.title) + expect(last_merge_request).to include(first_updated_issuable.title) + + find('.issues-other-filters .filter-dropdown-container .qa-reverse-sort').click + + expect(first_merge_request).to include(first_updated_issuable.title) + expect(last_merge_request).to include(last_updated_issuable.title) + end + end + end + end + + context 'for issues' do + include IssueHelpers + + let!(:issuables) do + timestamps = [{ created_at: 3.minutes.ago, updated_at: 20.seconds.ago }, + { created_at: 2.minutes.ago, updated_at: 30.seconds.ago }, + { created_at: 4.minutes.ago, updated_at: 10.seconds.ago }] + + timestamps.each_with_index do |ts, i| + create issuable_type, { title: "#{issuable_type}_#{i}", + project: project }.merge(ts) + end + + Issue.all + end + + context 'default sort order' do + context 'in the "issues" tab', :js do + let(:issuable_type) { :issue } + + it 'is "created date"' do + visit_issues project + + expect(find('.issues-other-filters')).to have_content('Created date') + expect(first_issue).to include(last_created_issuable.title) + expect(last_issue).to include(first_created_issuable.title) + end + end + + context 'in the "issues / open" tab', :js do + let(:issuable_type) { :issue } + + it 'is "created date"' do + visit_issues_with_state(project, 'open') + + expect(find('.issues-other-filters')).to have_content('Created date') + expect(first_issue).to include(last_created_issuable.title) + expect(last_issue).to include(first_created_issuable.title) + end + end + + context 'in the "issues / closed" tab', :js do + let(:issuable_type) { :closed_issue } + + it 'is "last updated"' do + visit_issues_with_state(project, 'closed') + + expect(find('.issues-other-filters')).to have_content('Last updated') + expect(first_issue).to include(last_updated_issuable.title) + expect(last_issue).to include(first_updated_issuable.title) + end + end + + context 'in the "issues / all" tab', :js do + let(:issuable_type) { :issue } + + it 'is "created date"' do + visit_issues_with_state(project, 'all') + + expect(find('.issues-other-filters')).to have_content('Created date') + expect(first_issue).to include(last_created_issuable.title) + expect(last_issue).to include(first_created_issuable.title) + end + end + + context 'when the sort in the URL is id_desc' do + let(:issuable_type) { :issue } + + before do + visit_issues(project, sort: 'id_desc') + end + + it 'shows the sort order as created date' do + expect(find('.issues-other-filters')).to have_content('Created date') + expect(first_issue).to include(last_created_issuable.title) + expect(last_issue).to include(first_created_issuable.title) + end + end + end + + context 'custom sorting' do + let(:issuable_type) { :issue } + + it 'supports sorting in asc and desc order' do + visit_issues_with_state(project, 'open') + + page.within('.issues-other-filters') do + click_button('Created date') + click_link('Last updated') + end + + expect(first_issue).to include(last_updated_issuable.title) + expect(last_issue).to include(first_updated_issuable.title) + + find('.issues-other-filters .filter-dropdown-container .qa-reverse-sort').click + + expect(first_issue).to include(first_updated_issuable.title) + expect(last_issue).to include(last_updated_issuable.title) + end + end + end + + def selected_sort_order + find('.filter-dropdown-container .dropdown button').text.downcase + end + + def visit_merge_requests_with_state(project, state) + visit_merge_requests project, state: state + end + + def visit_issues_with_state(project, state) + visit_issues project, state: state + end +end diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb index 4d9b8262f21..a29380a180e 100644 --- a/spec/features/issues/filtered_search/filter_issues_spec.rb +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -430,7 +430,7 @@ describe 'Filter issues', :js do expect_issues_list_count(2) - sort_toggle = find('.filter-dropdown-container .dropdown-menu-toggle') + sort_toggle = find('.filter-dropdown-container .dropdown') sort_toggle.click find('.filter-dropdown-container .dropdown-menu li a', text: 'Created date').click diff --git a/spec/features/issues/user_sorts_issues_spec.rb b/spec/features/issues/user_sorts_issues_spec.rb index 3bc93933183..eebd2d57cca 100644 --- a/spec/features/issues/user_sorts_issues_spec.rb +++ b/spec/features/issues/user_sorts_issues_spec.rb @@ -20,9 +20,9 @@ describe "User sorts issues" do end it 'keeps the sort option' do - find('.filter-dropdown-container button.dropdown-menu-toggle').click + find('.filter-dropdown-container .dropdown').click - page.within('.content ul.dropdown-menu.dropdown-menu-right li') do + page.within('ul.dropdown-menu.dropdown-menu-right li') do click_link('Milestone') end @@ -40,9 +40,9 @@ describe "User sorts issues" do end it "sorts by popularity" do - find(".filter-dropdown-container button.dropdown-menu-toggle").click + find('.filter-dropdown-container .dropdown').click - page.within(".content ul.dropdown-menu.dropdown-menu-right li") do + page.within('ul.dropdown-menu.dropdown-menu-right li') do click_link("Popularity") end diff --git a/spec/features/merge_request/user_expands_diff_spec.rb b/spec/features/merge_request/user_expands_diff_spec.rb index 02fe6352a0f..3560b8d90bb 100644 --- a/spec/features/merge_request/user_expands_diff_spec.rb +++ b/spec/features/merge_request/user_expands_diff_spec.rb @@ -2,16 +2,19 @@ require 'spec_helper' describe 'User expands diff', :js do let(:project) { create(:project, :public, :repository) } - let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } + let(:merge_request) { create(:merge_request, source_branch: 'expand-collapse-files', source_project: project, target_project: project) } before do + allow(Gitlab::Git::Diff).to receive(:size_limit).and_return(100.kilobytes) + allow(Gitlab::Git::Diff).to receive(:collapse_limit).and_return(10.kilobytes) + visit(diffs_project_merge_request_path(project, merge_request)) wait_for_requests end it 'allows user to expand diff' do - page.within find('[id="19763941ab80e8c09871c0a425f0560d9053bcb3"]') do + page.within find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd"]') do click_link 'Click to expand it.' wait_for_requests diff --git a/spec/features/merge_requests/user_sorts_merge_requests_spec.rb b/spec/features/merge_requests/user_sorts_merge_requests_spec.rb index 61e8f1c4662..fa887110c13 100644 --- a/spec/features/merge_requests/user_sorts_merge_requests_spec.rb +++ b/spec/features/merge_requests/user_sorts_merge_requests_spec.rb @@ -19,9 +19,9 @@ describe 'User sorts merge requests' do end it 'keeps the sort option' do - find('.filter-dropdown-container button.dropdown-menu-toggle').click + find('.filter-dropdown-container .dropdown').click - page.within('.content ul.dropdown-menu.dropdown-menu-right li') do + page.within('ul.dropdown-menu.dropdown-menu-right li') do click_link('Milestone') end @@ -49,9 +49,9 @@ describe 'User sorts merge requests' do it 'separates remember sorting with issues' do create(:issue, project: project) - find('.filter-dropdown-container button.dropdown-menu-toggle').click + find('.filter-dropdown-container .dropdown').click - page.within('.content ul.dropdown-menu.dropdown-menu-right li') do + page.within('ul.dropdown-menu.dropdown-menu-right li') do click_link('Milestone') end @@ -70,9 +70,9 @@ describe 'User sorts merge requests' do end it 'sorts by popularity' do - find('.filter-dropdown-container button.dropdown-menu-toggle').click + find('.filter-dropdown-container .dropdown').click - page.within('.content ul.dropdown-menu.dropdown-menu-right li') do + page.within('ul.dropdown-menu.dropdown-menu-right li') do click_link('Popularity') end diff --git a/spec/features/projects/labels/issues_sorted_by_priority_spec.rb b/spec/features/projects/labels/issues_sorted_by_priority_spec.rb index b778c72bc76..25417cf4955 100644 --- a/spec/features/projects/labels/issues_sorted_by_priority_spec.rb +++ b/spec/features/projects/labels/issues_sorted_by_priority_spec.rb @@ -32,7 +32,7 @@ describe 'Issue prioritization' do visit project_issues_path(project, sort: 'label_priority') # Ensure we are indicating that issues are sorted by priority - expect(page).to have_selector('.dropdown-menu-toggle', text: 'Label priority') + expect(page).to have_selector('.dropdown', text: 'Label priority') page.within('.issues-holder') do issue_titles = all('.issues-list .issue-title-text').map(&:text) @@ -70,7 +70,7 @@ describe 'Issue prioritization' do sign_in user visit project_issues_path(project, sort: 'label_priority') - expect(page).to have_selector('.dropdown-menu-toggle', text: 'Label priority') + expect(page).to have_selector('.dropdown', text: 'Label priority') page.within('.issues-holder') do issue_titles = all('.issues-list .issue-title-text').map(&:text) diff --git a/spec/features/projects/labels/update_prioritization_spec.rb b/spec/features/projects/labels/update_prioritization_spec.rb index 996040fde02..055a0c83a11 100644 --- a/spec/features/projects/labels/update_prioritization_spec.rb +++ b/spec/features/projects/labels/update_prioritization_spec.rb @@ -115,6 +115,21 @@ describe 'Prioritize labels' do end end + it 'user can see a primary button when there are only prioritized labels', :js do + visit project_labels_path(project) + + page.within('.other-labels') do + all('.js-toggle-priority').each do |el| + el.click + end + wait_for_requests + end + + page.within('.breadcrumbs-container') do + expect(page).to have_link('New label') + end + end + it 'shows a help message about prioritized labels' do visit project_labels_path(project) diff --git a/spec/features/projects/serverless/functions_spec.rb b/spec/features/projects/serverless/functions_spec.rb new file mode 100644 index 00000000000..766c63725b3 --- /dev/null +++ b/spec/features/projects/serverless/functions_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +describe 'Functions', :js do + let(:project) { create(:project) } + let(:user) { create(:user) } + + before do + project.add_maintainer(user) + gitlab_sign_in(user) + end + + context 'when user does not have a cluster and visits the serverless page' do + before do + visit project_serverless_functions_path(project) + end + + it 'sees an empty state' do + expect(page).to have_link('Install Knative') + expect(page).to have_selector('.empty-state') + end + end + + context 'when the user does have a cluster and visits the serverless page' do + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + + before do + visit project_serverless_functions_path(project) + end + + it 'sees an empty state' do + expect(page).to have_link('Install Knative') + expect(page).to have_selector('.empty-state') + end + end + + context 'when the user has a cluster and knative installed and visits the serverless page' do + let!(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) } + let(:project) { knative.cluster.project } + + before do + visit project_serverless_functions_path(project) + end + + it 'sees an empty listing of serverless functions' do + expect(page).to have_selector('.gl-responsive-table-row') + end + end +end diff --git a/spec/features/projects/settings/repository_settings_spec.rb b/spec/features/projects/settings/repository_settings_spec.rb index b7a22316d26..418e22f8c35 100644 --- a/spec/features/projects/settings/repository_settings_spec.rb +++ b/spec/features/projects/settings/repository_settings_spec.rb @@ -196,5 +196,40 @@ describe 'Projects > Settings > Repository settings' do end end end + + context 'repository cleanup settings' do + let(:object_map_file) { Rails.root.join('spec', 'fixtures', 'bfg_object_map.txt') } + + context 'feature enabled' do + it 'uploads an object map file', :js do + stub_feature_flags(project_cleanup: true) + + visit project_settings_repository_path(project) + + expect(page).to have_content('Repository cleanup') + + page.within('#cleanup') do + attach_file('project[bfg_object_map]', object_map_file, visible: false) + + Sidekiq::Testing.fake! do + click_button 'Start cleanup' + end + end + + expect(page).to have_content('Repository cleanup has started') + expect(RepositoryCleanupWorker.jobs.count).to eq(1) + end + end + + context 'feature disabled' do + it 'does not show the settings' do + stub_feature_flags(project_cleanup: false) + + visit project_settings_repository_path(project) + + expect(page).not_to have_content('Repository cleanup') + end + end + end end end diff --git a/spec/finders/group_members_finder_spec.rb b/spec/finders/group_members_finder_spec.rb index f545da3aee4..8975ea0f063 100644 --- a/spec/finders/group_members_finder_spec.rb +++ b/spec/finders/group_members_finder_spec.rb @@ -19,7 +19,7 @@ describe GroupMembersFinder, '#execute' do end it 'returns members for nested group', :nested_groups do - group.add_maintainer(user2) + group.add_developer(user2) nested_group.request_access(user4) member1 = group.add_maintainer(user1) member3 = nested_group.add_maintainer(user2) diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index 515f6f70b99..80f7232f282 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -640,4 +640,131 @@ describe IssuesFinder do end end end + + describe '#use_subquery_for_search?' do + let(:finder) { described_class.new(nil, params) } + + before do + allow(Gitlab::Database).to receive(:postgresql?).and_return(true) + stub_feature_flags(use_subquery_for_group_issues_search: true) + end + + context 'when there is no search param' do + let(:params) { { attempt_group_search_optimizations: true } } + + it 'returns false' do + expect(finder.use_subquery_for_search?).to be_falsey + end + end + + context 'when the database is not Postgres' do + let(:params) { { search: 'foo', attempt_group_search_optimizations: true } } + + before do + allow(Gitlab::Database).to receive(:postgresql?).and_return(false) + end + + it 'returns false' do + expect(finder.use_subquery_for_search?).to be_falsey + end + end + + context 'when the attempt_group_search_optimizations param is falsey' do + let(:params) { { search: 'foo' } } + + it 'returns false' do + expect(finder.use_subquery_for_search?).to be_falsey + end + end + + context 'when the use_subquery_for_group_issues_search flag is disabled' do + let(:params) { { search: 'foo', attempt_group_search_optimizations: true } } + + before do + stub_feature_flags(use_subquery_for_group_issues_search: false) + end + + it 'returns false' do + expect(finder.use_subquery_for_search?).to be_falsey + end + end + + context 'when all conditions are met' do + let(:params) { { search: 'foo', attempt_group_search_optimizations: true } } + + it 'returns true' do + expect(finder.use_subquery_for_search?).to be_truthy + end + end + end + + describe '#use_cte_for_search?' do + let(:finder) { described_class.new(nil, params) } + + before do + allow(Gitlab::Database).to receive(:postgresql?).and_return(true) + stub_feature_flags(use_cte_for_group_issues_search: true) + stub_feature_flags(use_subquery_for_group_issues_search: false) + end + + context 'when there is no search param' do + let(:params) { { attempt_group_search_optimizations: true } } + + it 'returns false' do + expect(finder.use_cte_for_search?).to be_falsey + end + end + + context 'when the database is not Postgres' do + let(:params) { { search: 'foo', attempt_group_search_optimizations: true } } + + before do + allow(Gitlab::Database).to receive(:postgresql?).and_return(false) + end + + it 'returns false' do + expect(finder.use_cte_for_search?).to be_falsey + end + end + + context 'when the attempt_group_search_optimizations param is falsey' do + let(:params) { { search: 'foo' } } + + it 'returns false' do + expect(finder.use_cte_for_search?).to be_falsey + end + end + + context 'when the use_cte_for_group_issues_search flag is disabled' do + let(:params) { { search: 'foo', attempt_group_search_optimizations: true } } + + before do + stub_feature_flags(use_cte_for_group_issues_search: false) + end + + it 'returns false' do + expect(finder.use_cte_for_search?).to be_falsey + end + end + + context 'when use_subquery_for_search? is true' do + let(:params) { { search: 'foo', attempt_group_search_optimizations: true } } + + before do + stub_feature_flags(use_subquery_for_group_issues_search: true) + end + + it 'returns false' do + expect(finder.use_cte_for_search?).to be_falsey + end + end + + context 'when all conditions are met' do + let(:params) { { search: 'foo', attempt_group_search_optimizations: true } } + + it 'returns true' do + expect(finder.use_cte_for_search?).to be_truthy + end + end + end end diff --git a/spec/finders/projects/serverless/functions_finder_spec.rb b/spec/finders/projects/serverless/functions_finder_spec.rb new file mode 100644 index 00000000000..60d02b12054 --- /dev/null +++ b/spec/finders/projects/serverless/functions_finder_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Projects::Serverless::FunctionsFinder do + include KubernetesHelpers + include ReactiveCachingHelpers + + let(:user) { create(:user) } + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:service) { cluster.platform_kubernetes } + let(:project) { cluster.project} + + let(:namespace) do + create(:cluster_kubernetes_namespace, + cluster: cluster, + cluster_project: cluster.cluster_project, + project: cluster.cluster_project.project) + end + + before do + project.add_maintainer(user) + end + + describe 'retrieve data from knative' do + it 'does not have knative installed' do + expect(described_class.new(project.clusters).execute).to be_empty + end + + context 'has knative installed' do + let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) } + + it 'there are no functions' do + expect(described_class.new(project.clusters).execute).to be_empty + end + + it 'there are functions', :use_clean_rails_memory_store_caching do + stub_reactive_cache(knative, services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"]) + + expect(described_class.new(project.clusters).execute).not_to be_empty + end + end + end + + describe 'verify if knative is installed' do + context 'knative is not installed' do + it 'does not have knative installed' do + expect(described_class.new(project.clusters).installed?).to be false + end + end + + context 'knative is installed' do + let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) } + + it 'does have knative installed' do + expect(described_class.new(project.clusters).installed?).to be true + end + end + end +end diff --git a/spec/fixtures/bfg_object_map.txt b/spec/fixtures/bfg_object_map.txt new file mode 100644 index 00000000000..c60171d8770 --- /dev/null +++ b/spec/fixtures/bfg_object_map.txt @@ -0,0 +1 @@ +f1d2d2f924e986ac86fdf7b36c94bcdf32beec15 e242ed3bffccdf271b7fbaf34ed72d089537b42f diff --git a/spec/fixtures/security-reports/feature-branch/gl-dependency-scanning-report.json b/spec/fixtures/security-reports/feature-branch/gl-dependency-scanning-report.json index 314f04107eb..ce66f562175 100644 --- a/spec/fixtures/security-reports/feature-branch/gl-dependency-scanning-report.json +++ b/spec/fixtures/security-reports/feature-branch/gl-dependency-scanning-report.json @@ -11,7 +11,13 @@ "name": "Gemnasium" }, "location": { - "file": "app/pom.xml" + "file": "app/pom.xml", + "dependency": { + "package": { + "name": "io.netty/netty" + }, + "version": "3.9.1.Final" + } }, "identifiers": [ { @@ -55,7 +61,13 @@ "name": "Gemnasium" }, "location": { - "file": "app/requirements.txt" + "file": "app/requirements.txt", + "dependency": { + "package": { + "name": "Django" + }, + "version": "1.11.3" + } }, "identifiers": [ { @@ -93,7 +105,13 @@ "name": "Gemnasium" }, "location": { - "file": "rails/Gemfile.lock" + "file": "rails/Gemfile.lock", + "dependency": { + "package": { + "name": "nokogiri" + }, + "version": "1.8.0" + } }, "identifiers": [ { @@ -131,7 +149,13 @@ "name": "bundler-audit" }, "location": { - "file": "sast-sample-rails/Gemfile.lock" + "file": "sast-sample-rails/Gemfile.lock", + "dependency": { + "package": { + "name": "ffi" + }, + "version": "1.9.18" + } }, "identifiers": [ { diff --git a/spec/fixtures/security-reports/master/gl-dependency-scanning-report.json b/spec/fixtures/security-reports/master/gl-dependency-scanning-report.json index 314f04107eb..ce66f562175 100644 --- a/spec/fixtures/security-reports/master/gl-dependency-scanning-report.json +++ b/spec/fixtures/security-reports/master/gl-dependency-scanning-report.json @@ -11,7 +11,13 @@ "name": "Gemnasium" }, "location": { - "file": "app/pom.xml" + "file": "app/pom.xml", + "dependency": { + "package": { + "name": "io.netty/netty" + }, + "version": "3.9.1.Final" + } }, "identifiers": [ { @@ -55,7 +61,13 @@ "name": "Gemnasium" }, "location": { - "file": "app/requirements.txt" + "file": "app/requirements.txt", + "dependency": { + "package": { + "name": "Django" + }, + "version": "1.11.3" + } }, "identifiers": [ { @@ -93,7 +105,13 @@ "name": "Gemnasium" }, "location": { - "file": "rails/Gemfile.lock" + "file": "rails/Gemfile.lock", + "dependency": { + "package": { + "name": "nokogiri" + }, + "version": "1.8.0" + } }, "identifiers": [ { @@ -131,7 +149,13 @@ "name": "bundler-audit" }, "location": { - "file": "sast-sample-rails/Gemfile.lock" + "file": "sast-sample-rails/Gemfile.lock", + "dependency": { + "package": { + "name": "ffi" + }, + "version": "1.9.18" + } }, "identifiers": [ { diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index 976b6c312b4..a857b7646b2 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -471,6 +471,31 @@ describe ProjectsHelper do end end + describe 'link_to_bfg' do + subject { helper.link_to_bfg } + + it 'generates a hardcoded link to the BFG Repo-Cleaner' do + result = helper.link_to_bfg + doc = Nokogiri::HTML.fragment(result) + + expect(doc.children.size).to eq(1) + + link = doc.children.first + + aggregate_failures do + expect(result).to be_html_safe + + expect(link.name).to eq('a') + expect(link[:target]).to eq('_blank') + expect(link[:rel]).to eq('noopener noreferrer') + expect(link[:href]).to eq('https://rtyley.github.io/bfg-repo-cleaner/') + expect(link.inner_html).to eq('BFG') + + expect(result).to be_html_safe + end + end + end + describe '#legacy_render_context' do it 'returns the redcarpet engine' do params = { legacy_render: '1' } diff --git a/spec/helpers/sorting_helper_spec.rb b/spec/helpers/sorting_helper_spec.rb new file mode 100644 index 00000000000..cba0d93e144 --- /dev/null +++ b/spec/helpers/sorting_helper_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe SortingHelper do + include ApplicationHelper + include IconsHelper + + describe '#issuable_sort_option_title' do + it 'returns correct title for issuable_sort_option_overrides key' do + expect(issuable_sort_option_title('created_asc')).to eq('Created date') + end + + it 'returns correct title for a valid sort value' do + expect(issuable_sort_option_title('priority')).to eq('Priority') + end + + it 'returns nil for invalid sort value' do + expect(issuable_sort_option_title('invalid_key')).to eq(nil) + end + end + + describe '#issuable_sort_direction_button' do + before do + allow(self).to receive(:request).and_return(double(path: 'http://test.com', query_parameters: {})) + end + + it 'returns icon with sort-highest when sort is created_date' do + expect(issuable_sort_direction_button('created_date')).to include('sort-highest') + end + + it 'returns icon with sort-lowest when sort is asc' do + expect(issuable_sort_direction_button('created_asc')).to include('sort-lowest') + end + + it 'returns icon with sort-lowest when sorting by milestone' do + expect(issuable_sort_direction_button('milestone')).to include('sort-lowest') + end + + it 'returns icon with sort-lowest when sorting by due_date' do + expect(issuable_sort_direction_button('due_date')).to include('sort-lowest') + end + end +end diff --git a/spec/javascripts/api_spec.js b/spec/javascripts/api_spec.js index 7de38913bae..46f72214831 100644 --- a/spec/javascripts/api_spec.js +++ b/spec/javascripts/api_spec.js @@ -180,6 +180,23 @@ describe('Api', () => { }); }); + describe('projectRunners', () => { + it('fetches the runners of a project', done => { + const projectPath = 7; + const params = { scope: 'active' }; + const mockData = [{ id: 4 }]; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/runners`; + mock.onGet(expectedUrl, { params }).reply(200, mockData); + + Api.projectRunners(projectPath, { params }) + .then(({ data }) => { + expect(data).toEqual(mockData); + }) + .then(done) + .catch(done.fail); + }); + }); + describe('newLabel', () => { it('creates a new label', done => { const namespace = 'some namespace'; diff --git a/spec/javascripts/clusters/components/applications_spec.js b/spec/javascripts/clusters/components/applications_spec.js index 928bf70f3a2..e46edec9abb 100644 --- a/spec/javascripts/clusters/components/applications_spec.js +++ b/spec/javascripts/clusters/components/applications_spec.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import applications from '~/clusters/components/applications.vue'; +import { CLUSTER_TYPE } from '~/clusters/constants'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Applications', () => { @@ -14,9 +15,10 @@ describe('Applications', () => { vm.$destroy(); }); - describe('', () => { + describe('Project cluster applications', () => { beforeEach(() => { vm = mountComponent(Applications, { + type: CLUSTER_TYPE.PROJECT, applications: { helm: { title: 'Helm Tiller' }, ingress: { title: 'Ingress' }, @@ -30,31 +32,76 @@ describe('Applications', () => { }); it('renders a row for Helm Tiller', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-helm')).toBeDefined(); + expect(vm.$el.querySelector('.js-cluster-application-row-helm')).not.toBeNull(); }); it('renders a row for Ingress', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-ingress')).toBeDefined(); + expect(vm.$el.querySelector('.js-cluster-application-row-ingress')).not.toBeNull(); }); it('renders a row for Cert-Manager', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-cert_manager')).toBeDefined(); + expect(vm.$el.querySelector('.js-cluster-application-row-cert_manager')).not.toBeNull(); }); it('renders a row for Prometheus', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-prometheus')).toBeDefined(); + expect(vm.$el.querySelector('.js-cluster-application-row-prometheus')).not.toBeNull(); }); it('renders a row for GitLab Runner', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-runner')).toBeDefined(); + expect(vm.$el.querySelector('.js-cluster-application-row-runner')).not.toBeNull(); }); it('renders a row for Jupyter', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-jupyter')).not.toBe(null); + expect(vm.$el.querySelector('.js-cluster-application-row-jupyter')).not.toBeNull(); }); it('renders a row for Knative', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-knative')).not.toBe(null); + expect(vm.$el.querySelector('.js-cluster-application-row-knative')).not.toBeNull(); + }); + }); + + describe('Group cluster applications', () => { + beforeEach(() => { + vm = mountComponent(Applications, { + type: CLUSTER_TYPE.GROUP, + applications: { + helm: { title: 'Helm Tiller' }, + ingress: { title: 'Ingress' }, + cert_manager: { title: 'Cert-Manager' }, + runner: { title: 'GitLab Runner' }, + prometheus: { title: 'Prometheus' }, + jupyter: { title: 'JupyterHub' }, + knative: { title: 'Knative' }, + }, + }); + }); + + it('renders a row for Helm Tiller', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-helm')).not.toBeNull(); + }); + + it('renders a row for Ingress', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-ingress')).not.toBeNull(); + }); + + it('renders a row for Cert-Manager', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-cert_manager')).not.toBeNull(); + }); + + it('renders a row for Prometheus', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-prometheus')).toBeNull(); + }); + + it('renders a row for GitLab Runner', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-runner')).toBeNull(); + }); + + it('renders a row for Jupyter', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-jupyter')).toBeNull(); + }); + + it('renders a row for Knative', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-knative')).toBeNull(); }); }); diff --git a/spec/javascripts/diffs/components/diff_file_spec.js b/spec/javascripts/diffs/components/diff_file_spec.js index 51bb4807960..1af49282c36 100644 --- a/spec/javascripts/diffs/components/diff_file_spec.js +++ b/spec/javascripts/diffs/components/diff_file_spec.js @@ -74,6 +74,32 @@ describe('DiffFile', () => { }); }); + it('should be collapsed for renamed files', done => { + vm.file.renderIt = true; + vm.file.collapsed = false; + vm.file.highlighted_diff_lines = null; + vm.file.renamed_file = true; + + vm.$nextTick(() => { + expect(vm.$el.innerText).not.toContain('This diff is collapsed'); + + done(); + }); + }); + + it('should be collapsed for mode changed files', done => { + vm.file.renderIt = true; + vm.file.collapsed = false; + vm.file.highlighted_diff_lines = null; + vm.file.mode_changed = true; + + vm.$nextTick(() => { + expect(vm.$el.innerText).not.toContain('This diff is collapsed'); + + done(); + }); + }); + it('should have loading icon while loading a collapsed diffs', done => { vm.file.collapsed = true; vm.isLoadingCollapsedDiff = true; diff --git a/spec/javascripts/diffs/mock_data/diff_discussions.js b/spec/javascripts/diffs/mock_data/diff_discussions.js index 5ffe5a366ba..44313caba29 100644 --- a/spec/javascripts/diffs/mock_data/diff_discussions.js +++ b/spec/javascripts/diffs/mock_data/diff_discussions.js @@ -489,8 +489,6 @@ export default { diff_discussion: true, truncated_diff_lines: '<tr class="line_holder new" id="">\n<td class="diff-line-num new old_line" data-linenumber="1">\n \n</td>\n<td class="diff-line-num new new_line" data-linenumber="1">\n1\n</td>\n<td class="line_content new noteable_line"><span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n</td>\n</tr>\n<tr class="line_holder new" id="">\n<td class="diff-line-num new old_line" data-linenumber="1">\n \n</td>\n<td class="diff-line-num new new_line" data-linenumber="2">\n2\n</td>\n<td class="line_content new noteable_line"><span id="LC2" class="line" lang="plaintext"></span>\n</td>\n</tr>\n', - image_diff_html: - '<div class="image js-replaced-image" data="">\n<div class="two-up view">\n<div class="wrap">\n<div class="frame deleted">\n<img alt="CHANGELOG" src="http://localhost:3000/gitlab-org/gitlab-test/raw/e63f41fe459e62e1228fcef60d7189127aeba95a/CHANGELOG" />\n</div>\n<p class="image-info hide">\n<span class="meta-filesize">22.3 KB</span>\n|\n<strong>W:</strong>\n<span class="meta-width"></span>\n|\n<strong>H:</strong>\n<span class="meta-height"></span>\n</p>\n</div>\n<div class="wrap">\n<div class="added frame js-image-frame" data-note-type="DiffNote" data-position="{"base_sha":"e63f41fe459e62e1228fcef60d7189127aeba95a","start_sha":"d9eaefe5a676b820c57ff18cf5b68316025f7962","head_sha":"c48ee0d1bf3b30453f5b32250ce03134beaa6d13","old_path":"CHANGELOG","new_path":"CHANGELOG","position_type":"text","old_line":null,"new_line":2}">\n<img alt="CHANGELOG" draggable="false" src="http://localhost:3000/gitlab-org/gitlab-test/raw/c48ee0d1bf3b30453f5b32250ce03134beaa6d13/CHANGELOG" />\n</div>\n\n<p class="image-info hide">\n<span class="meta-filesize">22.3 KB</span>\n|\n<strong>W:</strong>\n<span class="meta-width"></span>\n|\n<strong>H:</strong>\n<span class="meta-height"></span>\n</p>\n</div>\n</div>\n<div class="swipe view hide">\n<div class="swipe-frame">\n<div class="frame deleted">\n<img alt="CHANGELOG" src="http://localhost:3000/gitlab-org/gitlab-test/raw/e63f41fe459e62e1228fcef60d7189127aeba95a/CHANGELOG" />\n</div>\n<div class="swipe-wrap">\n<div class="added frame js-image-frame" data-note-type="DiffNote" data-position="{"base_sha":"e63f41fe459e62e1228fcef60d7189127aeba95a","start_sha":"d9eaefe5a676b820c57ff18cf5b68316025f7962","head_sha":"c48ee0d1bf3b30453f5b32250ce03134beaa6d13","old_path":"CHANGELOG","new_path":"CHANGELOG","position_type":"text","old_line":null,"new_line":2}">\n<img alt="CHANGELOG" draggable="false" src="http://localhost:3000/gitlab-org/gitlab-test/raw/c48ee0d1bf3b30453f5b32250ce03134beaa6d13/CHANGELOG" />\n</div>\n\n</div>\n<span class="swipe-bar">\n<span class="top-handle"></span>\n<span class="bottom-handle"></span>\n</span>\n</div>\n</div>\n<div class="onion-skin view hide">\n<div class="onion-skin-frame">\n<div class="frame deleted">\n<img alt="CHANGELOG" src="http://localhost:3000/gitlab-org/gitlab-test/raw/e63f41fe459e62e1228fcef60d7189127aeba95a/CHANGELOG" />\n</div>\n<div class="added frame js-image-frame" data-note-type="DiffNote" data-position="{"base_sha":"e63f41fe459e62e1228fcef60d7189127aeba95a","start_sha":"d9eaefe5a676b820c57ff18cf5b68316025f7962","head_sha":"c48ee0d1bf3b30453f5b32250ce03134beaa6d13","old_path":"CHANGELOG","new_path":"CHANGELOG","position_type":"text","old_line":null,"new_line":2}">\n<img alt="CHANGELOG" draggable="false" src="http://localhost:3000/gitlab-org/gitlab-test/raw/c48ee0d1bf3b30453f5b32250ce03134beaa6d13/CHANGELOG" />\n</div>\n\n<div class="controls">\n<div class="transparent"></div>\n<div class="drag-track">\n<div class="dragger" style="left: 0px;"></div>\n</div>\n<div class="opaque"></div>\n</div>\n</div>\n</div>\n</div>\n<div class="view-modes hide">\n<ul class="view-modes-menu">\n<li class="two-up" data-mode="two-up">2-up</li>\n<li class="swipe" data-mode="swipe">Swipe</li>\n<li class="onion-skin" data-mode="onion-skin">Onion skin</li>\n</ul>\n</div>\n', }; export const imageDiffDiscussions = [ diff --git a/spec/javascripts/diffs/store/actions_spec.js b/spec/javascripts/diffs/store/actions_spec.js index 4b339a0553f..55ce19927e0 100644 --- a/spec/javascripts/diffs/store/actions_spec.js +++ b/spec/javascripts/diffs/store/actions_spec.js @@ -29,6 +29,7 @@ import actions, { } from '~/diffs/store/actions'; import * as types from '~/diffs/store/mutation_types'; import axios from '~/lib/utils/axios_utils'; +import mockDiffFile from 'spec/diffs/mock_data/diff_file'; import testAction from '../../helpers/vuex_action_helper'; describe('DiffsStoreActions', () => { @@ -607,11 +608,18 @@ describe('DiffsStoreActions', () => { }); describe('saveDiffDiscussion', () => { - beforeEach(() => { - spyOnDependency(actions, 'getNoteFormData').and.returnValue('testData'); - }); - it('dispatches actions', done => { + const commitId = 'something'; + const formData = { + diffFile: { ...mockDiffFile }, + noteableData: {}, + }; + const note = {}; + const state = { + commit: { + id: commitId, + }, + }; const dispatch = jasmine.createSpy('dispatch').and.callFake(name => { switch (name) { case 'saveNote': @@ -625,11 +633,19 @@ describe('DiffsStoreActions', () => { } }); - saveDiffDiscussion({ dispatch }, { note: {}, formData: {} }) + saveDiffDiscussion({ state, dispatch }, { note, formData }) .then(() => { - expect(dispatch.calls.argsFor(0)).toEqual(['saveNote', 'testData', { root: true }]); - expect(dispatch.calls.argsFor(1)).toEqual(['updateDiscussion', 'test', { root: true }]); - expect(dispatch.calls.argsFor(2)).toEqual(['assignDiscussionsToDiff', ['discussion']]); + const { calls } = dispatch; + + expect(calls.count()).toBe(5); + expect(calls.argsFor(0)).toEqual(['saveNote', jasmine.any(Object), { root: true }]); + + const postData = calls.argsFor(0)[1]; + + expect(postData.data.note.commit_id).toBe(commitId); + + expect(calls.argsFor(1)).toEqual(['updateDiscussion', 'test', { root: true }]); + expect(calls.argsFor(2)).toEqual(['assignDiscussionsToDiff', ['discussion']]); }) .then(done) .catch(done.fail); diff --git a/spec/javascripts/diffs/store/utils_spec.js b/spec/javascripts/diffs/store/utils_spec.js index d4ef17c5ef8..f096638e3d6 100644 --- a/spec/javascripts/diffs/store/utils_spec.js +++ b/spec/javascripts/diffs/store/utils_spec.js @@ -150,7 +150,7 @@ describe('DiffsStoreUtils', () => { note: { noteable_type: options.noteableType, noteable_id: options.noteableData.id, - commit_id: '', + commit_id: undefined, type: DIFF_NOTE_TYPE, line_code: options.noteTargetLine.line_code, note: options.note, @@ -209,7 +209,7 @@ describe('DiffsStoreUtils', () => { note: { noteable_type: options.noteableType, noteable_id: options.noteableData.id, - commit_id: '', + commit_id: undefined, type: LEGACY_DIFF_NOTE_TYPE, line_code: options.noteTargetLine.line_code, note: options.note, @@ -559,4 +559,26 @@ describe('DiffsStoreUtils', () => { ]); }); }); + + describe('getDiffMode', () => { + it('returns mode when matched in file', () => { + expect( + utils.getDiffMode({ + renamed_file: true, + }), + ).toBe('renamed'); + }); + + it('returns mode_changed if key has no match', () => { + expect( + utils.getDiffMode({ + mode_changed: true, + }), + ).toBe('mode_changed'); + }); + + it('defaults to replaced', () => { + expect(utils.getDiffMode({})).toBe('replaced'); + }); + }); }); diff --git a/spec/javascripts/lib/utils/file_upload_spec.js b/spec/javascripts/lib/utils/file_upload_spec.js new file mode 100644 index 00000000000..92c9cc70aaf --- /dev/null +++ b/spec/javascripts/lib/utils/file_upload_spec.js @@ -0,0 +1,36 @@ +import fileUpload from '~/lib/utils/file_upload'; + +describe('File upload', () => { + beforeEach(() => { + setFixtures(` + <form> + <button class="js-button" type="button">Click me!</button> + <input type="text" class="js-input" /> + <span class="js-filename"></span> + </form> + `); + + 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'); + + input.value = 'path/to/file/index.js'; + + input.dispatchEvent(new CustomEvent('change')); + + expect(document.querySelector('.js-filename').textContent).toEqual('index.js'); + }); +}); diff --git a/spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js b/spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js index 67a3a2e08bc..6add6cdac4d 100644 --- a/spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js +++ b/spec/javascripts/vue_shared/components/diff_viewer/diff_viewer_spec.js @@ -68,4 +68,30 @@ describe('DiffViewer', () => { done(); }); }); + + it('renders renamed component', () => { + createComponent({ + diffMode: 'renamed', + newPath: 'test.abc', + newSha: 'ABC', + oldPath: 'testold.abc', + oldSha: 'DEF', + }); + + expect(vm.$el.textContent).toContain('File moved'); + }); + + it('renders mode changed component', () => { + createComponent({ + diffMode: 'mode_changed', + newPath: 'test.abc', + newSha: 'ABC', + oldPath: 'testold.abc', + oldSha: 'DEF', + aMode: '123', + bMode: '321', + }); + + expect(vm.$el.textContent).toContain('File mode changed from 123 to 321'); + }); }); diff --git a/spec/javascripts/vue_shared/components/diff_viewer/viewers/mode_changed_spec.js b/spec/javascripts/vue_shared/components/diff_viewer/viewers/mode_changed_spec.js new file mode 100644 index 00000000000..c4358f0d9cb --- /dev/null +++ b/spec/javascripts/vue_shared/components/diff_viewer/viewers/mode_changed_spec.js @@ -0,0 +1,23 @@ +import { shallowMount } from '@vue/test-utils'; +import ModeChanged from '~/vue_shared/components/diff_viewer/viewers/mode_changed.vue'; + +describe('Diff viewer mode changed component', () => { + let vm; + + beforeEach(() => { + vm = shallowMount(ModeChanged, { + propsData: { + aMode: '123', + bMode: '321', + }, + }); + }); + + afterEach(() => { + vm.destroy(); + }); + + it('renders aMode & bMode', () => { + expect(vm.text()).toContain('File mode changed from 123 to 321'); + }); +}); diff --git a/spec/lib/gitlab/database/count/exact_count_strategy_spec.rb b/spec/lib/gitlab/database/count/exact_count_strategy_spec.rb index f518bb3dc3e..3991c737a26 100644 --- a/spec/lib/gitlab/database/count/exact_count_strategy_spec.rb +++ b/spec/lib/gitlab/database/count/exact_count_strategy_spec.rb @@ -16,6 +16,12 @@ describe Gitlab::Database::Count::ExactCountStrategy do expect(subject).to eq({ Project => 3, Identity => 1 }) end + + it 'returns default value if count times out' do + allow(models.first).to receive(:count).and_raise(ActiveRecord::StatementInvalid.new('')) + + expect(subject).to eq({}) + end end describe '.enabled?' do diff --git a/spec/lib/gitlab/git/repository_cleaner_spec.rb b/spec/lib/gitlab/git/repository_cleaner_spec.rb new file mode 100644 index 00000000000..a9d9e67ef94 --- /dev/null +++ b/spec/lib/gitlab/git/repository_cleaner_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' + +describe Gitlab::Git::RepositoryCleaner do + let(:project) { create(:project, :repository) } + let(:repository) { project.repository } + let(:head_sha) { repository.head_commit.id } + + let(:object_map) { StringIO.new("#{head_sha} #{'0' * 40}") } + + subject(:cleaner) { described_class.new(repository.raw) } + + describe '#apply_bfg_object_map' do + it 'removes internal references pointing at SHAs in the object map' do + # Create some refs we expect to be removed + repository.keep_around(head_sha) + repository.create_ref(head_sha, 'refs/environments/1') + repository.create_ref(head_sha, 'refs/merge-requests/1') + repository.create_ref(head_sha, 'refs/heads/_keep') + repository.create_ref(head_sha, 'refs/tags/_keep') + + cleaner.apply_bfg_object_map(object_map) + + aggregate_failures do + expect(repository.kept_around?(head_sha)).to be_falsy + expect(repository.ref_exists?('refs/environments/1')).to be_falsy + expect(repository.ref_exists?('refs/merge-requests/1')).to be_falsy + expect(repository.ref_exists?('refs/heads/_keep')).to be_truthy + expect(repository.ref_exists?('refs/tags/_keep')).to be_truthy + end + end + end +end diff --git a/spec/lib/gitlab/gitaly_client/cleanup_service_spec.rb b/spec/lib/gitlab/gitaly_client/cleanup_service_spec.rb new file mode 100644 index 00000000000..369deff732a --- /dev/null +++ b/spec/lib/gitlab/gitaly_client/cleanup_service_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe Gitlab::GitalyClient::CleanupService do + let(:project) { create(:project) } + let(:storage_name) { project.repository_storage } + let(:relative_path) { project.disk_path + '.git' } + let(:client) { described_class.new(project.repository) } + + describe '#apply_bfg_object_map' do + it 'sends an apply_bfg_object_map message' do + expect_any_instance_of(Gitaly::CleanupService::Stub) + .to receive(:apply_bfg_object_map) + .with(kind_of(Enumerator), kind_of(Hash)) + .and_return(double) + + client.apply_bfg_object_map(StringIO.new) + end + end +end diff --git a/spec/lib/gitlab/gpg/commit_spec.rb b/spec/lib/gitlab/gpg/commit_spec.rb index 8c6d673391b..8229f0eb794 100644 --- a/spec/lib/gitlab/gpg/commit_spec.rb +++ b/spec/lib/gitlab/gpg/commit_spec.rb @@ -26,6 +26,28 @@ describe Gitlab::Gpg::Commit do end end + context 'invalid signature' do + let!(:commit) { create :commit, project: project, sha: commit_sha, committer_email: GpgHelpers::User1.emails.first } + + let!(:user) { create(:user, email: GpgHelpers::User1.emails.first) } + + before do + allow(Gitlab::Git::Commit).to receive(:extract_signature_lazily) + .with(Gitlab::Git::Repository, commit_sha) + .and_return( + [ + # Corrupt the key + GpgHelpers::User1.signed_commit_signature.tr('=', 'a'), + GpgHelpers::User1.signed_commit_base_data + ] + ) + end + + it 'returns nil' do + expect(described_class.new(commit).signature).to be_nil + end + end + context 'known key' do context 'user matches the key uid' do context 'user email matches the email committer' do diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 7df129da95a..bae5b21c26f 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -287,6 +287,7 @@ project: - statistics - container_repositories - uploads +- file_uploads - import_state - members_and_requesters - build_trace_section_names diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index 4a0dc3686ec..6831274d37c 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -54,11 +54,18 @@ describe Gitlab::ProjectSearchResults do end it 'finds by name' do - expect(results.map(&:first)).to include(expected_file_by_name) + expect(results.map(&:filename)).to include(expected_file_by_name) + end + + it "loads all blobs for filename matches in single batch" do + expect(Gitlab::Git::Blob).to receive(:batch).once.and_call_original + + expected = project.repository.search_files_by_name(query, 'master') + expect(results.map(&:filename)).to include(*expected) end it 'finds by content' do - blob = results.select { |result| result.first == expected_file_by_content }.flatten.last + blob = results.select { |result| result.filename == expected_file_by_content }.flatten.last expect(blob.filename).to eq(expected_file_by_content) end @@ -122,126 +129,6 @@ describe Gitlab::ProjectSearchResults do let(:blob_type) { 'blobs' } let(:entity) { project } end - - describe 'parsing results' do - let(:results) { project.repository.search_files_by_content('feature', 'master') } - let(:search_result) { results.first } - - subject { described_class.parse_search_result(search_result) } - - it "returns a valid FoundBlob" do - is_expected.to be_an Gitlab::SearchResults::FoundBlob - expect(subject.id).to be_nil - expect(subject.path).to eq('CHANGELOG') - expect(subject.filename).to eq('CHANGELOG') - expect(subject.basename).to eq('CHANGELOG') - expect(subject.ref).to eq('master') - expect(subject.startline).to eq(188) - expect(subject.data.lines[2]).to eq(" - Feature: Replace teams with group membership\n") - end - - context 'when the matching filename contains a colon' do - let(:search_result) { "master:testdata/project::function1.yaml\x001\x00---\n" } - - it 'returns a valid FoundBlob' do - expect(subject.filename).to eq('testdata/project::function1.yaml') - expect(subject.basename).to eq('testdata/project::function1') - expect(subject.ref).to eq('master') - expect(subject.startline).to eq(1) - expect(subject.data).to eq("---\n") - end - end - - context 'when the matching content contains a number surrounded by colons' do - let(:search_result) { "master:testdata/foo.txt\x001\x00blah:9:blah" } - - it 'returns a valid FoundBlob' do - expect(subject.filename).to eq('testdata/foo.txt') - expect(subject.basename).to eq('testdata/foo') - expect(subject.ref).to eq('master') - expect(subject.startline).to eq(1) - expect(subject.data).to eq('blah:9:blah') - end - end - - context 'when the matching content contains multiple null bytes' do - let(:search_result) { "master:testdata/foo.txt\x001\x00blah\x001\x00foo" } - - it 'returns a valid FoundBlob' do - expect(subject.filename).to eq('testdata/foo.txt') - expect(subject.basename).to eq('testdata/foo') - expect(subject.ref).to eq('master') - expect(subject.startline).to eq(1) - expect(subject.data).to eq("blah\x001\x00foo") - end - end - - context 'when the search result ends with an empty line' do - let(:results) { project.repository.search_files_by_content('Role models', 'master') } - - it 'returns a valid FoundBlob that ends with an empty line' do - expect(subject.filename).to eq('files/markdown/ruby-style-guide.md') - expect(subject.basename).to eq('files/markdown/ruby-style-guide') - expect(subject.ref).to eq('master') - expect(subject.startline).to eq(1) - expect(subject.data).to eq("# Prelude\n\n> Role models are important. <br/>\n> -- Officer Alex J. Murphy / RoboCop\n\n") - end - end - - context 'when the search returns non-ASCII data' do - context 'with UTF-8' do - let(:results) { project.repository.search_files_by_content('файл', 'master') } - - it 'returns results as UTF-8' do - expect(subject.filename).to eq('encoding/russian.rb') - expect(subject.basename).to eq('encoding/russian') - expect(subject.ref).to eq('master') - expect(subject.startline).to eq(1) - expect(subject.data).to eq("Хороший файл\n") - end - end - - context 'with UTF-8 in the filename' do - let(:results) { project.repository.search_files_by_content('webhook', 'master') } - - it 'returns results as UTF-8' do - expect(subject.filename).to eq('encoding/テスト.txt') - expect(subject.basename).to eq('encoding/テスト') - expect(subject.ref).to eq('master') - expect(subject.startline).to eq(3) - expect(subject.data).to include('WebHookの確認') - end - end - - context 'with ISO-8859-1' do - let(:search_result) { "master:encoding/iso8859.txt\x001\x00\xC4\xFC\nmaster:encoding/iso8859.txt\x002\x00\nmaster:encoding/iso8859.txt\x003\x00foo\n".force_encoding(Encoding::ASCII_8BIT) } - - it 'returns results as UTF-8' do - expect(subject.filename).to eq('encoding/iso8859.txt') - expect(subject.basename).to eq('encoding/iso8859') - expect(subject.ref).to eq('master') - expect(subject.startline).to eq(1) - expect(subject.data).to eq("Äü\n\nfoo\n") - end - end - end - - context "when filename has extension" do - let(:search_result) { "master:CONTRIBUTE.md\x005\x00- [Contribute to GitLab](#contribute-to-gitlab)\n" } - - it { expect(subject.path).to eq('CONTRIBUTE.md') } - it { expect(subject.filename).to eq('CONTRIBUTE.md') } - it { expect(subject.basename).to eq('CONTRIBUTE') } - end - - context "when file under directory" do - let(:search_result) { "master:a/b/c.md\x005\x00a b c\n" } - - it { expect(subject.path).to eq('a/b/c.md') } - it { expect(subject.filename).to eq('a/b/c.md') } - it { expect(subject.basename).to eq('a/b/c') } - end - end end describe 'wiki search' do diff --git a/spec/lib/gitlab/search/found_blob_spec.rb b/spec/lib/gitlab/search/found_blob_spec.rb new file mode 100644 index 00000000000..74157e5c67c --- /dev/null +++ b/spec/lib/gitlab/search/found_blob_spec.rb @@ -0,0 +1,138 @@ +# coding: utf-8 + +require 'spec_helper' + +describe Gitlab::Search::FoundBlob do + describe 'parsing results' do + let(:project) { create(:project, :public, :repository) } + let(:results) { project.repository.search_files_by_content('feature', 'master') } + let(:search_result) { results.first } + + subject { described_class.new(content_match: search_result, project: project) } + + it "returns a valid FoundBlob" do + is_expected.to be_an described_class + expect(subject.id).to be_nil + expect(subject.path).to eq('CHANGELOG') + expect(subject.filename).to eq('CHANGELOG') + expect(subject.basename).to eq('CHANGELOG') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(188) + expect(subject.data.lines[2]).to eq(" - Feature: Replace teams with group membership\n") + end + + it "doesn't parses content if not needed" do + expect(subject).not_to receive(:parse_search_result) + expect(subject.project_id).to eq(project.id) + expect(subject.binary_filename).to eq('CHANGELOG') + end + + it "parses content only once when needed" do + expect(subject).to receive(:parse_search_result).once.and_call_original + expect(subject.filename).to eq('CHANGELOG') + expect(subject.startline).to eq(188) + end + + context 'when the matching filename contains a colon' do + let(:search_result) { "master:testdata/project::function1.yaml\x001\x00---\n" } + + it 'returns a valid FoundBlob' do + expect(subject.filename).to eq('testdata/project::function1.yaml') + expect(subject.basename).to eq('testdata/project::function1') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(1) + expect(subject.data).to eq("---\n") + end + end + + context 'when the matching content contains a number surrounded by colons' do + let(:search_result) { "master:testdata/foo.txt\x001\x00blah:9:blah" } + + it 'returns a valid FoundBlob' do + expect(subject.filename).to eq('testdata/foo.txt') + expect(subject.basename).to eq('testdata/foo') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(1) + expect(subject.data).to eq('blah:9:blah') + end + end + + context 'when the matching content contains multiple null bytes' do + let(:search_result) { "master:testdata/foo.txt\x001\x00blah\x001\x00foo" } + + it 'returns a valid FoundBlob' do + expect(subject.filename).to eq('testdata/foo.txt') + expect(subject.basename).to eq('testdata/foo') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(1) + expect(subject.data).to eq("blah\x001\x00foo") + end + end + + context 'when the search result ends with an empty line' do + let(:results) { project.repository.search_files_by_content('Role models', 'master') } + + it 'returns a valid FoundBlob that ends with an empty line' do + expect(subject.filename).to eq('files/markdown/ruby-style-guide.md') + expect(subject.basename).to eq('files/markdown/ruby-style-guide') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(1) + expect(subject.data).to eq("# Prelude\n\n> Role models are important. <br/>\n> -- Officer Alex J. Murphy / RoboCop\n\n") + end + end + + context 'when the search returns non-ASCII data' do + context 'with UTF-8' do + let(:results) { project.repository.search_files_by_content('файл', 'master') } + + it 'returns results as UTF-8' do + expect(subject.filename).to eq('encoding/russian.rb') + expect(subject.basename).to eq('encoding/russian') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(1) + expect(subject.data).to eq("Хороший файл\n") + end + end + + context 'with UTF-8 in the filename' do + let(:results) { project.repository.search_files_by_content('webhook', 'master') } + + it 'returns results as UTF-8' do + expect(subject.filename).to eq('encoding/テスト.txt') + expect(subject.basename).to eq('encoding/テスト') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(3) + expect(subject.data).to include('WebHookの確認') + end + end + + context 'with ISO-8859-1' do + let(:search_result) { "master:encoding/iso8859.txt\x001\x00\xC4\xFC\nmaster:encoding/iso8859.txt\x002\x00\nmaster:encoding/iso8859.txt\x003\x00foo\n".force_encoding(Encoding::ASCII_8BIT) } + + it 'returns results as UTF-8' do + expect(subject.filename).to eq('encoding/iso8859.txt') + expect(subject.basename).to eq('encoding/iso8859') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(1) + expect(subject.data).to eq("Äü\n\nfoo\n") + end + end + end + + context "when filename has extension" do + let(:search_result) { "master:CONTRIBUTE.md\x005\x00- [Contribute to GitLab](#contribute-to-gitlab)\n" } + + it { expect(subject.path).to eq('CONTRIBUTE.md') } + it { expect(subject.filename).to eq('CONTRIBUTE.md') } + it { expect(subject.basename).to eq('CONTRIBUTE') } + end + + context "when file under directory" do + let(:search_result) { "master:a/b/c.md\x005\x00a b c\n" } + + it { expect(subject.path).to eq('a/b/c.md') } + it { expect(subject.filename).to eq('a/b/c.md') } + it { expect(subject.basename).to eq('a/b/c') } + end + end +end diff --git a/spec/lib/gitlab/template/finders/global_template_finder_spec.rb b/spec/lib/gitlab/template/finders/global_template_finder_spec.rb new file mode 100644 index 00000000000..c7f58fbd2a5 --- /dev/null +++ b/spec/lib/gitlab/template/finders/global_template_finder_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +describe Gitlab::Template::Finders::GlobalTemplateFinder do + let(:base_dir) { Dir.mktmpdir } + + def create_template!(name_with_category) + full_path = File.join(base_dir, name_with_category) + FileUtils.mkdir_p(File.dirname(full_path)) + FileUtils.touch(full_path) + end + + after do + FileUtils.rm_rf(base_dir) + end + + subject(:finder) { described_class.new(base_dir, '', 'Foo' => '', 'Bar' => 'bar') } + + describe '.find' do + it 'finds a template in the Foo category' do + create_template!('test-template') + + expect(finder.find('test-template')).to be_present + end + + it 'finds a template in the Bar category' do + create_template!('bar/test-template') + + expect(finder.find('test-template')).to be_present + end + + it 'does not permit path traversal requests' do + expect { finder.find('../foo') }.to raise_error(/Invalid path/) + end + end +end diff --git a/spec/lib/gitlab/template/finders/repo_template_finders_spec.rb b/spec/lib/gitlab/template/finders/repo_template_finders_spec.rb index 2eabccd5dff..e329d55d837 100644 --- a/spec/lib/gitlab/template/finders/repo_template_finders_spec.rb +++ b/spec/lib/gitlab/template/finders/repo_template_finders_spec.rb @@ -25,6 +25,10 @@ describe Gitlab::Template::Finders::RepoTemplateFinder do expect(result).to eq('files/html/500.html') end + + it 'does not permit path traversal requests' do + expect { finder.find('../foo') }.to raise_error(/Invalid path/) + end end describe '#list_files_for' do diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index e2de612ff46..deb19fe1a4b 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -213,4 +213,29 @@ describe Gitlab::UsageData do expect(described_class.count(relation, fallback: 15)).to eq(15) end end + + describe '#approximate_counts' do + it 'gets approximate counts for selected models' do + create(:label) + + expect(Gitlab::Database::Count).to receive(:approximate_counts) + .with(described_class::APPROXIMATE_COUNT_MODELS).once.and_call_original + + counts = described_class.approximate_counts.values + + expect(counts.count).to eq(described_class::APPROXIMATE_COUNT_MODELS.count) + expect(counts.any? { |count| count < 0 }).to be_falsey + end + + it 'returns default values if counts can not be retrieved' do + described_class::APPROXIMATE_COUNT_MODELS.map do |model| + model.name.underscore.pluralize.to_sym + end + + expect(Gitlab::Database::Count).to receive(:approximate_counts) + .and_return({}) + + expect(described_class.approximate_counts.values.uniq).to eq([-1]) + end + end end diff --git a/spec/lib/gitlab/utils_spec.rb b/spec/lib/gitlab/utils_spec.rb index 3579ed9a759..47a5fd0bdb4 100644 --- a/spec/lib/gitlab/utils_spec.rb +++ b/spec/lib/gitlab/utils_spec.rb @@ -2,7 +2,33 @@ require 'spec_helper' describe Gitlab::Utils do delegate :to_boolean, :boolean_to_yes_no, :slugify, :random_string, :which, :ensure_array_from_string, - :bytes_to_megabytes, :append_path, to: :described_class + :bytes_to_megabytes, :append_path, :check_path_traversal!, to: :described_class + + describe '.check_path_traversal!' do + it 'detects path traversal at the start of the string' do + expect { check_path_traversal!('../foo') }.to raise_error(/Invalid path/) + end + + it 'detects path traversal at the start of the string, even to just the subdirectory' do + expect { check_path_traversal!('../') }.to raise_error(/Invalid path/) + end + + it 'detects path traversal in the middle of the string' do + expect { check_path_traversal!('foo/../../bar') }.to raise_error(/Invalid path/) + end + + it 'detects path traversal at the end of the string when slash-terminates' do + expect { check_path_traversal!('foo/../') }.to raise_error(/Invalid path/) + end + + it 'detects path traversal at the end of the string' do + expect { check_path_traversal!('foo/..') }.to raise_error(/Invalid path/) + end + + it 'does nothing for a safe string' do + expect(check_path_traversal!('./foo')).to eq('./foo') + end + end describe '.slugify' do { diff --git a/spec/lib/omni_auth/strategies/jwt_spec.rb b/spec/lib/omni_auth/strategies/jwt_spec.rb index 88d6d0b559a..c2e2db27362 100644 --- a/spec/lib/omni_auth/strategies/jwt_spec.rb +++ b/spec/lib/omni_auth/strategies/jwt_spec.rb @@ -4,12 +4,10 @@ describe OmniAuth::Strategies::Jwt do include Rack::Test::Methods include DeviseHelpers - context '.decoded' do - let(:strategy) { described_class.new({}) } + context '#decoded' do + subject { described_class.new({}) } let(:timestamp) { Time.now.to_i } let(:jwt_config) { Devise.omniauth_configs[:jwt] } - let(:key) { JWT.encode(claims, jwt_config.strategy.secret) } - let(:claims) do { id: 123, @@ -18,19 +16,55 @@ describe OmniAuth::Strategies::Jwt do iat: timestamp } end + let(:algorithm) { 'HS256' } + let(:secret) { jwt_config.strategy.secret } + let(:private_key) { secret } + let(:payload) { JWT.encode(claims, private_key, algorithm) } before do - allow_any_instance_of(OmniAuth::Strategy).to receive(:options).and_return(jwt_config.strategy) - allow_any_instance_of(Rack::Request).to receive(:params).and_return({ 'jwt' => key }) + subject.options[:secret] = secret + subject.options[:algorithm] = algorithm + + expect_next_instance_of(Rack::Request) do |rack_request| + expect(rack_request).to receive(:params).and_return('jwt' => payload) + end end - it 'decodes the user information' do - result = strategy.decoded + ECDSA_NAMED_CURVES = { + 'ES256' => 'prime256v1', + 'ES384' => 'secp384r1', + 'ES512' => 'secp521r1' + }.freeze - expect(result["id"]).to eq(123) - expect(result["name"]).to eq("user_example") - expect(result["email"]).to eq("user@example.com") - expect(result["iat"]).to eq(timestamp) + { + OpenSSL::PKey::RSA => %w[RS256 RS384 RS512], + OpenSSL::PKey::EC => %w[ES256 ES384 ES512], + String => %w[HS256 HS384 HS512] + }.each do |private_key_class, algorithms| + algorithms.each do |algorithm| + context "when the #{algorithm} algorithm is used" do + let(:algorithm) { algorithm } + let(:secret) do + if private_key_class == OpenSSL::PKey::RSA + private_key_class.generate(2048) + .to_pem + elsif private_key_class == OpenSSL::PKey::EC + private_key_class.new(ECDSA_NAMED_CURVES[algorithm]) + .tap { |key| key.generate_key! } + .to_pem + else + private_key_class.new(jwt_config.strategy.secret) + end + end + let(:private_key) { private_key_class ? private_key_class.new(secret) : secret } + + it 'decodes the user information' do + result = subject.decoded + + expect(result).to eq(claims.stringify_keys) + end + end + end end context 'required claims is missing' do @@ -43,7 +77,7 @@ describe OmniAuth::Strategies::Jwt do end it 'raises error' do - expect { strategy.decoded }.to raise_error(OmniAuth::Strategies::Jwt::ClaimInvalid) + expect { subject.decoded }.to raise_error(OmniAuth::Strategies::Jwt::ClaimInvalid) end end @@ -57,11 +91,12 @@ describe OmniAuth::Strategies::Jwt do end before do - jwt_config.strategy.valid_within = Time.now.to_i + # Omniauth config values are always strings! + subject.options[:valid_within] = 2.days.to_s end it 'raises error' do - expect { strategy.decoded }.to raise_error(OmniAuth::Strategies::Jwt::ClaimInvalid) + expect { subject.decoded }.to raise_error(OmniAuth::Strategies::Jwt::ClaimInvalid) end end @@ -76,11 +111,12 @@ describe OmniAuth::Strategies::Jwt do end before do - jwt_config.strategy.valid_within = 2.seconds + # Omniauth config values are always strings! + subject.options[:valid_within] = 2.seconds.to_s end it 'raises error' do - expect { strategy.decoded }.to raise_error(OmniAuth::Strategies::Jwt::ClaimInvalid) + expect { subject.decoded }.to raise_error(OmniAuth::Strategies::Jwt::ClaimInvalid) end end end diff --git a/spec/models/appearance_spec.rb b/spec/models/appearance_spec.rb index 77b07cf1ac9..35415030154 100644 --- a/spec/models/appearance_spec.rb +++ b/spec/models/appearance_spec.rb @@ -20,7 +20,7 @@ describe Appearance do end context 'with uploads' do - it_behaves_like 'model with mounted uploader', false do + it_behaves_like 'model with uploads', false do let(:model_object) { create(:appearance, :with_logo) } let(:upload_attribute) { :logo } let(:uploader_class) { AttachmentUploader } diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 4cdcae5f670..89f78f629d4 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -1925,7 +1925,7 @@ describe Ci::Build do context 'when token is empty' do before do - build.token = nil + build.update_columns(token: nil, token_encrypted: nil) end it { is_expected.to be_nil} @@ -2141,7 +2141,7 @@ describe Ci::Build do end before do - build.token = 'my-token' + build.set_token('my-token') build.yaml_variables = [] end diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index ba9540c84d4..b67c6a4cffa 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -350,6 +350,50 @@ describe Ci::Pipeline, :mailer do CI_COMMIT_TITLE CI_COMMIT_DESCRIPTION] end + + context 'when source is merge request' do + let(:pipeline) do + create(:ci_pipeline, source: :merge_request, merge_request: merge_request) + end + + let(:merge_request) do + create(:merge_request, + source_project: project, + source_branch: 'feature', + target_project: project, + target_branch: 'master') + end + + it 'exposes merge request pipeline variables' do + expect(subject.to_hash) + .to include( + 'CI_MERGE_REQUEST_ID' => merge_request.id.to_s, + 'CI_MERGE_REQUEST_IID' => merge_request.iid.to_s, + 'CI_MERGE_REQUEST_REF_PATH' => merge_request.ref_path.to_s, + 'CI_MERGE_REQUEST_PROJECT_ID' => merge_request.project.id.to_s, + 'CI_MERGE_REQUEST_PROJECT_PATH' => merge_request.project.full_path, + 'CI_MERGE_REQUEST_PROJECT_URL' => merge_request.project.web_url, + 'CI_MERGE_REQUEST_TARGET_BRANCH_NAME' => merge_request.target_branch.to_s, + 'CI_MERGE_REQUEST_SOURCE_PROJECT_ID' => merge_request.source_project.id.to_s, + 'CI_MERGE_REQUEST_SOURCE_PROJECT_PATH' => merge_request.source_project.full_path, + 'CI_MERGE_REQUEST_SOURCE_PROJECT_URL' => merge_request.source_project.web_url, + 'CI_MERGE_REQUEST_SOURCE_BRANCH_NAME' => merge_request.source_branch.to_s) + end + + context 'when source project does not exist' do + before do + merge_request.update_column(:source_project_id, nil) + end + + it 'does not expose source project related variables' do + expect(subject.to_hash.keys).not_to include( + %w[CI_MERGE_REQUEST_SOURCE_PROJECT_ID + CI_MERGE_REQUEST_SOURCE_PROJECT_PATH + CI_MERGE_REQUEST_SOURCE_PROJECT_URL + CI_MERGE_REQUEST_SOURCE_BRANCH_NAME]) + end + end + end end describe '#protected_ref?' do diff --git a/spec/models/clusters/applications/knative_spec.rb b/spec/models/clusters/applications/knative_spec.rb index d43d88c2924..a1579b90436 100644 --- a/spec/models/clusters/applications/knative_spec.rb +++ b/spec/models/clusters/applications/knative_spec.rb @@ -1,6 +1,9 @@ require 'rails_helper' describe Clusters::Applications::Knative do + include KubernetesHelpers + include ReactiveCachingHelpers + let(:knative) { create(:clusters_applications_knative) } include_examples 'cluster application core specs', :clusters_applications_knative @@ -121,4 +124,43 @@ describe Clusters::Applications::Knative do describe 'validations' do it { is_expected.to validate_presence_of(:hostname) } end + + describe '#services' do + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:service) { cluster.platform_kubernetes } + let(:knative) { create(:clusters_applications_knative, cluster: cluster) } + + let(:namespace) do + create(:cluster_kubernetes_namespace, + cluster: cluster, + cluster_project: cluster.cluster_project, + project: cluster.cluster_project.project) + end + + subject { knative.services } + + before do + stub_kubeclient_discover(service.api_url) + stub_kubeclient_knative_services + end + + it 'should have an unintialized cache' do + is_expected.to be_nil + end + + context 'when using synchronous reactive cache' do + before do + stub_reactive_cache(knative, services: kube_response(kube_knative_services_body)) + synchronous_reactive_cache(knative) + end + + it 'should have cached services' do + is_expected.not_to be_nil + end + + it 'should match our namespace' do + expect(knative.services_for(ns: namespace)).not_to be_nil + end + end + end end diff --git a/spec/models/clusters/applications/runner_spec.rb b/spec/models/clusters/applications/runner_spec.rb index 97e50809647..47daa79873e 100644 --- a/spec/models/clusters/applications/runner_spec.rb +++ b/spec/models/clusters/applications/runner_spec.rb @@ -18,7 +18,7 @@ describe Clusters::Applications::Runner do let(:application) { create(:clusters_applications_runner, :scheduled, version: '0.1.30') } it 'updates the application version' do - expect(application.reload.version).to eq('0.1.38') + expect(application.reload.version).to eq('0.1.39') end end end @@ -46,7 +46,7 @@ describe Clusters::Applications::Runner do it 'should be initialized with 4 arguments' do expect(subject.name).to eq('runner') expect(subject.chart).to eq('runner/gitlab-runner') - expect(subject.version).to eq('0.1.38') + expect(subject.version).to eq('0.1.39') expect(subject).not_to be_rbac expect(subject.repository).to eq('https://charts.gitlab.io') expect(subject.files).to eq(gitlab_runner.files) @@ -64,7 +64,7 @@ describe Clusters::Applications::Runner do let(:gitlab_runner) { create(:clusters_applications_runner, :errored, runner: ci_runner, version: '0.1.13') } it 'should be initialized with the locked version' do - expect(subject.version).to eq('0.1.38') + expect(subject.version).to eq('0.1.39') end end end diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index 2a0039a0635..a2d2d77746d 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -204,7 +204,7 @@ describe Commit do message = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec sodales id felis id blandit. Vivamus egestas lacinia lacus, sed rutrum mauris.' allow(commit).to receive(:safe_message).and_return(message) - expect(commit.title).to eq('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec sodales id felis…') + expect(commit.title).to eq('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec sodales id...') end it "truncates a message with a newline before 80 characters at the newline" do diff --git a/spec/models/concerns/token_authenticatable_spec.rb b/spec/models/concerns/token_authenticatable_spec.rb index 0cdf430e9ab..55d83bc3a6b 100644 --- a/spec/models/concerns/token_authenticatable_spec.rb +++ b/spec/models/concerns/token_authenticatable_spec.rb @@ -351,3 +351,89 @@ describe PersonalAccessToken, 'TokenAuthenticatable' do end end end + +describe Ci::Build, 'TokenAuthenticatable' do + let(:token_field) { :token } + let(:build) { FactoryBot.build(:ci_build) } + + it_behaves_like 'TokenAuthenticatable' + + describe 'generating new token' do + context 'token is not generated yet' do + describe 'token field accessor' do + it 'makes it possible to access token' do + expect(build.token).to be_nil + + build.save! + + expect(build.token).to be_present + end + end + + describe "ensure_token" do + subject { build.ensure_token } + + it { is_expected.to be_a String } + it { is_expected.not_to be_blank } + + it 'does not persist token' do + expect(build).not_to be_persisted + end + end + + describe 'ensure_token!' do + it 'persists a new token' do + expect(build.ensure_token!).to eq build.reload.token + expect(build).to be_persisted + end + + it 'persists new token as an encrypted string' do + build.ensure_token! + + encrypted = Gitlab::CryptoHelper.aes256_gcm_encrypt(build.token) + + expect(build.read_attribute('token_encrypted')).to eq encrypted + end + + it 'does not persist a token in a clear text' do + build.ensure_token! + + expect(build.read_attribute('token')).to be_nil + end + end + end + + describe '#reset_token!' do + it 'persists a new token' do + build.save! + + build.token.yield_self do |previous_token| + build.reset_token! + + expect(build.token).not_to eq previous_token + expect(build.token).to be_a String + end + end + end + end + + describe 'setting a new token' do + subject { build.set_token('0123456789') } + + it 'returns the token' do + expect(subject).to eq '0123456789' + end + + it 'writes a new encrypted token' do + expect(build.read_attribute('token_encrypted')).to be_nil + expect(subject).to eq '0123456789' + expect(build.read_attribute('token_encrypted')).to be_present + end + + it 'does not write a new cleartext token' do + expect(build.read_attribute('token')).to be_nil + expect(subject).to eq '0123456789' + expect(build.read_attribute('token')).to be_nil + end + end +end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 0c3a49cd0f2..e63881242f6 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -76,7 +76,7 @@ describe Group do before do group.add_developer(user) - sub_group.add_developer(user) + sub_group.add_maintainer(user) end it 'also gets notification settings from parent groups' do @@ -498,7 +498,7 @@ describe Group do it 'returns member users on every nest level without duplication' do group.add_developer(user_a) nested_group.add_developer(user_b) - deep_nested_group.add_developer(user_a) + deep_nested_group.add_maintainer(user_a) expect(group.users_with_descendants).to contain_exactly(user_a, user_b) expect(nested_group.users_with_descendants).to contain_exactly(user_a, user_b) @@ -739,7 +739,7 @@ describe Group do end context 'with uploads' do - it_behaves_like 'model with mounted uploader', true do + it_behaves_like 'model with uploads', true do let(:model_object) { create(:group, :with_avatar) } let(:upload_attribute) { :avatar } let(:uploader_class) { AttachmentUploader } diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index fca1b1f90d9..188beac1582 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -53,6 +53,29 @@ describe Member do expect(member).to be_valid end end + + context "when a child member inherits its access level" do + let(:user) { create(:user) } + let(:member) { create(:group_member, :developer, user: user) } + let(:child_group) { create(:group, parent: member.group) } + let(:child_member) { build(:group_member, group: child_group, user: user) } + + it "requires a higher level" do + child_member.access_level = GroupMember::REPORTER + + child_member.validate + + expect(child_member).not_to be_valid + end + + it "is valid with a higher level" do + child_member.access_level = GroupMember::MAINTAINER + + child_member.validate + + expect(child_member).to be_valid + end + end end describe 'Scopes & finders' do diff --git a/spec/models/members/group_member_spec.rb b/spec/models/members/group_member_spec.rb index 97959ed4304..a3451c67bd8 100644 --- a/spec/models/members/group_member_spec.rb +++ b/spec/models/members/group_member_spec.rb @@ -50,4 +50,26 @@ describe GroupMember do group_member.destroy end end + + context 'access levels', :nested_groups do + context 'with parent group' do + it_behaves_like 'inherited access level as a member of entity' do + let(:entity) { create(:group, parent: parent_entity) } + end + end + + context 'with parent group and a sub subgroup' do + it_behaves_like 'inherited access level as a member of entity' do + let(:subgroup) { create(:group, parent: parent_entity) } + let(:entity) { create(:group, parent: subgroup) } + end + + context 'when only the subgroup has the member' do + it_behaves_like 'inherited access level as a member of entity' do + let(:parent_entity) { create(:group, parent: create(:group)) } + let(:entity) { create(:group, parent: parent_entity) } + end + end + end + end end diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb index 334d4f95f53..097b1bb30dc 100644 --- a/spec/models/members/project_member_spec.rb +++ b/spec/models/members/project_member_spec.rb @@ -124,4 +124,19 @@ describe ProjectMember do end it_behaves_like 'members notifications', :project + + context 'access levels' do + context 'with parent group' do + it_behaves_like 'inherited access level as a member of entity' do + let(:entity) { create(:project, group: parent_entity) } + end + end + + context 'with parent group and a subgroup', :nested_groups do + it_behaves_like 'inherited access level as a member of entity' do + let(:subgroup) { create(:group, parent: parent_entity) } + let(:entity) { create(:project, group: subgroup) } + end + end + end end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 6ee19c0ddf4..96561dab1c9 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -538,7 +538,7 @@ describe Namespace do it 'returns member users on every nest level without duplication' do group.add_developer(user_a) nested_group.add_developer(user_b) - deep_nested_group.add_developer(user_a) + deep_nested_group.add_maintainer(user_a) expect(group.users_with_descendants).to contain_exactly(user_a, user_b) expect(nested_group.users_with_descendants).to contain_exactly(user_a, user_b) diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 50920d9d1fc..93c83fd21fd 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -3898,7 +3898,7 @@ describe Project do end context 'with uploads' do - it_behaves_like 'model with mounted uploader', true do + it_behaves_like 'model with uploads', true do let(:model_object) { create(:project, :with_avatar) } let(:upload_attribute) { :avatar } let(:uploader_class) { AttachmentUploader } diff --git a/spec/models/uploads/fog_spec.rb b/spec/models/uploads/fog_spec.rb new file mode 100644 index 00000000000..4a44cf5ab0f --- /dev/null +++ b/spec/models/uploads/fog_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Uploads::Fog do + let(:data_store) { described_class.new } + + before do + stub_uploads_object_storage(FileUploader) + end + + describe '#available?' do + subject { data_store.available? } + + context 'when object storage is enabled' do + it { is_expected.to be_truthy } + end + + context 'when object storage is disabled' do + before do + stub_uploads_object_storage(FileUploader, enabled: false) + end + + it { is_expected.to be_falsy } + end + end + + context 'model with uploads' do + let(:project) { create(:project) } + let(:relation) { project.uploads } + + describe '#keys' do + let!(:uploads) { create_list(:upload, 2, :object_storage, uploader: FileUploader, model: project) } + subject { data_store.keys(relation) } + + it 'returns keys' do + is_expected.to match_array(relation.pluck(:path)) + end + end + + describe '#delete_keys' do + let(:keys) { data_store.keys(relation) } + let!(:uploads) { create_list(:upload, 2, :with_file, :issuable_upload, model: project) } + subject { data_store.delete_keys(keys) } + + before do + uploads.each { |upload| upload.build_uploader.migrate!(2) } + end + + it 'deletes multiple data' do + paths = relation.pluck(:path) + + ::Fog::Storage.new(FileUploader.object_store_credentials).tap do |connection| + paths.each do |path| + expect(connection.get_object('uploads', path)[:body]).not_to be_nil + end + end + + subject + + ::Fog::Storage.new(FileUploader.object_store_credentials).tap do |connection| + paths.each do |path| + expect { connection.get_object('uploads', path)[:body] }.to raise_error(Excon::Error::NotFound) + end + end + end + end + end +end diff --git a/spec/models/uploads/local_spec.rb b/spec/models/uploads/local_spec.rb new file mode 100644 index 00000000000..3468399f370 --- /dev/null +++ b/spec/models/uploads/local_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Uploads::Local do + let(:data_store) { described_class.new } + + before do + stub_uploads_object_storage(FileUploader) + end + + context 'model with uploads' do + let(:project) { create(:project) } + let(:relation) { project.uploads } + + describe '#keys' do + let!(:uploads) { create_list(:upload, 2, uploader: FileUploader, model: project) } + subject { data_store.keys(relation) } + + it 'returns keys' do + is_expected.to match_array(relation.map(&:absolute_path)) + end + end + + describe '#delete_keys' do + let(:keys) { data_store.keys(relation) } + let!(:uploads) { create_list(:upload, 2, :with_file, :issuable_upload, model: project) } + subject { data_store.delete_keys(keys) } + + it 'deletes multiple data' do + paths = relation.map(&:absolute_path) + + paths.each do |path| + expect(File.exist?(path)).to be_truthy + end + + subject + + paths.each do |path| + expect(File.exist?(path)).to be_falsey + end + end + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index e5490e0a156..ff075e65c76 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -2325,11 +2325,11 @@ describe User do context 'user is member of all groups' do before do - group.add_owner(user) - nested_group_1.add_owner(user) - nested_group_1_1.add_owner(user) - nested_group_2.add_owner(user) - nested_group_2_1.add_owner(user) + group.add_reporter(user) + nested_group_1.add_developer(user) + nested_group_1_1.add_maintainer(user) + nested_group_2.add_developer(user) + nested_group_2_1.add_maintainer(user) end it 'returns all groups' do @@ -3231,7 +3231,7 @@ describe User do end context 'with uploads' do - it_behaves_like 'model with mounted uploader', false do + it_behaves_like 'model with uploads', false do let(:model_object) { create(:user, :with_avatar) } let(:upload_attribute) { :avatar } let(:uploader_class) { AttachmentUploader } diff --git a/spec/presenters/group_member_presenter_spec.rb b/spec/presenters/group_member_presenter_spec.rb index c00e41725d9..bb66523a83d 100644 --- a/spec/presenters/group_member_presenter_spec.rb +++ b/spec/presenters/group_member_presenter_spec.rb @@ -135,4 +135,12 @@ describe GroupMemberPresenter do end end end + + it_behaves_like '#valid_level_roles', :group do + let(:expected_roles) { { 'Developer' => 30, 'Maintainer' => 40, 'Owner' => 50, 'Reporter' => 20 } } + + before do + entity.parent = group + end + end end diff --git a/spec/presenters/project_member_presenter_spec.rb b/spec/presenters/project_member_presenter_spec.rb index 83db5c56cdf..73ef113a1c5 100644 --- a/spec/presenters/project_member_presenter_spec.rb +++ b/spec/presenters/project_member_presenter_spec.rb @@ -135,4 +135,10 @@ describe ProjectMemberPresenter do end end end + + it_behaves_like '#valid_level_roles', :project do + before do + entity.group = group + end + end end diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb index 93e1c3a2294..bb32d581176 100644 --- a/spec/requests/api/members_spec.rb +++ b/spec/requests/api/members_spec.rb @@ -224,6 +224,37 @@ describe API::Members do end end + context 'access levels' do + it 'does not create the member if group level is higher', :nested_groups do + parent = create(:group) + + group.update(parent: parent) + project.update(group: group) + parent.add_developer(stranger) + + post api("/#{source_type.pluralize}/#{source.id}/members", maintainer), + user_id: stranger.id, access_level: Member::REPORTER + + expect(response).to have_gitlab_http_status(400) + expect(json_response['message']['access_level']).to eq(["should be higher than Developer inherited membership from group #{parent.name}"]) + end + + it 'creates the member if group level is lower', :nested_groups do + parent = create(:group) + + group.update(parent: parent) + project.update(group: group) + parent.add_developer(stranger) + + post api("/#{source_type.pluralize}/#{source.id}/members", maintainer), + user_id: stranger.id, access_level: Member::MAINTAINER + + expect(response).to have_gitlab_http_status(201) + expect(json_response['id']).to eq(stranger.id) + expect(json_response['access_level']).to eq(Member::MAINTAINER) + end + end + it "returns 409 if member already exists" do post api("/#{source_type.pluralize}/#{source.id}/members", maintainer), user_id: maintainer.id, access_level: Member::MAINTAINER diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 62b6a3ce42e..e40db55cd20 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -1906,7 +1906,7 @@ describe API::Projects do let(:group) { create(:group) } let(:group2) do group = create(:group, name: 'group2_name') - group.add_owner(user2) + group.add_maintainer(user2) group end diff --git a/spec/serializers/diff_file_entity_spec.rb b/spec/serializers/diff_file_entity_spec.rb index 7497b8f27bd..073c13c2cbb 100644 --- a/spec/serializers/diff_file_entity_spec.rb +++ b/spec/serializers/diff_file_entity_spec.rb @@ -13,39 +13,6 @@ describe DiffFileEntity do subject { entity.as_json } - shared_examples 'diff file entity' do - it 'exposes correct attributes' do - expect(subject).to include( - :submodule, :submodule_link, :submodule_tree_url, :file_path, - :deleted_file, :old_path, :new_path, :mode_changed, - :a_mode, :b_mode, :text, :old_path_html, - :new_path_html, :highlighted_diff_lines, :parallel_diff_lines, - :blob, :file_hash, :added_lines, :removed_lines, :diff_refs, :content_sha, - :stored_externally, :external_storage, :too_large, :collapsed, :new_file, - :context_lines_path - ) - end - - it 'includes viewer' do - expect(subject[:viewer].with_indifferent_access) - .to match_schema('entities/diff_viewer') - end - - # Converted diff files from GitHub import does not contain blob file - # and content sha. - context 'when diff file does not have a blob and content sha' do - it 'exposes some attributes as nil' do - allow(diff_file).to receive(:content_sha).and_return(nil) - allow(diff_file).to receive(:blob).and_return(nil) - - expect(subject[:context_lines_path]).to be_nil - expect(subject[:view_path]).to be_nil - expect(subject[:highlighted_diff_lines]).to be_nil - expect(subject[:can_modify_blob]).to be_nil - end - end - end - context 'when there is no merge request' do it_behaves_like 'diff file entity' end diff --git a/spec/serializers/discussion_diff_file_entity_spec.rb b/spec/serializers/discussion_diff_file_entity_spec.rb new file mode 100644 index 00000000000..101ac918a98 --- /dev/null +++ b/spec/serializers/discussion_diff_file_entity_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe DiscussionDiffFileEntity do + include RepoHelpers + + let(:project) { create(:project, :repository) } + let(:repository) { project.repository } + let(:commit) { project.commit(sample_commit.id) } + let(:diff_refs) { commit.diff_refs } + let(:diff) { commit.raw_diffs.first } + let(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: diff_refs, repository: repository) } + let(:entity) { described_class.new(diff_file, request: {}) } + + subject { entity.as_json } + + context 'when there is no merge request' do + it_behaves_like 'diff file discussion entity' + end + + context 'when there is a merge request' do + let(:user) { create(:user) } + let(:request) { EntityRequest.new(project: project, current_user: user) } + let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } + let(:entity) { described_class.new(diff_file, request: request, merge_request: merge_request) } + + it_behaves_like 'diff file discussion entity' + + it 'exposes additional attributes' do + expect(subject).to include(:edit_path) + end + + it 'exposes no diff lines' do + expect(subject).not_to include(:highlighted_diff_lines, + :parallel_diff_lines) + end + end +end diff --git a/spec/serializers/discussion_entity_spec.rb b/spec/serializers/discussion_entity_spec.rb index 0590304e832..138749b0fdf 100644 --- a/spec/serializers/discussion_entity_spec.rb +++ b/spec/serializers/discussion_entity_spec.rb @@ -74,13 +74,5 @@ describe DiscussionEntity do :active ) end - - context 'when diff file is a image' do - it 'exposes image attributes' do - allow(discussion).to receive(:on_image?).and_return(true) - - expect(subject.keys).to include(:image_diff_html) - end - end end end diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb index e779675744c..87185891470 100644 --- a/spec/services/ci/retry_build_service_spec.rb +++ b/spec/services/ci/retry_build_service_spec.rb @@ -20,9 +20,9 @@ describe Ci::RetryBuildService do CLONE_ACCESSORS = described_class::CLONE_ACCESSORS REJECT_ACCESSORS = - %i[id status user token coverage trace runner artifacts_expire_at - artifacts_file artifacts_metadata artifacts_size created_at - updated_at started_at finished_at queued_at erased_by + %i[id status user token token_encrypted coverage trace runner + artifacts_expire_at artifacts_file artifacts_metadata artifacts_size + created_at updated_at started_at finished_at queued_at erased_by erased_at auto_canceled_by job_artifacts job_artifacts_archive job_artifacts_metadata job_artifacts_trace job_artifacts_junit job_artifacts_sast job_artifacts_dependency_scanning diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 2d8da7673dc..0f6c2604984 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -2146,6 +2146,27 @@ describe NotificationService, :mailer do end end + describe 'Repository cleanup' do + let(:user) { create(:user) } + let(:project) { create(:project) } + + describe '#repository_cleanup_success' do + it 'emails the specified user only' do + notification.repository_cleanup_success(project, user) + + should_email(user) + end + end + + describe '#repository_cleanup_failure' do + it 'emails the specified user only' do + notification.repository_cleanup_failure(project, user, 'Some error') + + should_email(user) + end + end + end + def build_team(project) @u_watcher = create_global_setting_for(create(:user), :watch) @u_participating = create_global_setting_for(create(:user), :participating) diff --git a/spec/services/projects/cleanup_service_spec.rb b/spec/services/projects/cleanup_service_spec.rb new file mode 100644 index 00000000000..3d4587ce2a1 --- /dev/null +++ b/spec/services/projects/cleanup_service_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +describe Projects::CleanupService do + let(:project) { create(:project, :repository, bfg_object_map: fixture_file_upload('spec/fixtures/bfg_object_map.txt')) } + let(:object_map) { project.bfg_object_map } + + subject(:service) { described_class.new(project) } + + describe '#execute' do + it 'runs the apply_bfg_object_map gitaly RPC' do + expect_next_instance_of(Gitlab::Git::RepositoryCleaner) do |cleaner| + expect(cleaner).to receive(:apply_bfg_object_map).with(kind_of(IO)) + end + + service.execute + end + + it 'runs garbage collection on the repository' do + expect_next_instance_of(GitGarbageCollectWorker) do |worker| + expect(worker).to receive(:perform) + end + + service.execute + end + + it 'clears the repository cache' do + expect(project.repository).to receive(:expire_all_method_caches) + + service.execute + end + + it 'removes the object map file' do + service.execute + + expect(object_map.exists?).to be_falsy + end + + it 'raises an error if no object map can be found' do + object_map.remove! + + expect { service.execute }.to raise_error(described_class::NoUploadError) + end + end +end diff --git a/spec/support/helpers/features/sorting_helpers.rb b/spec/support/helpers/features/sorting_helpers.rb index a1ae428586e..003ecb251fe 100644 --- a/spec/support/helpers/features/sorting_helpers.rb +++ b/spec/support/helpers/features/sorting_helpers.rb @@ -13,9 +13,9 @@ module Spec module Features module SortingHelpers def sort_by(value) - find('.filter-dropdown-container button.dropdown-menu-toggle').click + find('.filter-dropdown-container .dropdown').click - page.within('.content ul.dropdown-menu.dropdown-menu-right li') do + page.within('ul.dropdown-menu.dropdown-menu-right li') do click_link(value) end end diff --git a/spec/support/helpers/kubernetes_helpers.rb b/spec/support/helpers/kubernetes_helpers.rb index bef951e1517..39bd305d88a 100644 --- a/spec/support/helpers/kubernetes_helpers.rb +++ b/spec/support/helpers/kubernetes_helpers.rb @@ -34,6 +34,17 @@ module KubernetesHelpers WebMock.stub_request(:get, deployments_url).to_return(response || kube_deployments_response) end + def stub_kubeclient_knative_services(**options) + options[:name] ||= "kubetest" + options[:namespace] ||= "default" + options[:domain] ||= "example.com" + + stub_kubeclient_discover(service.api_url) + knative_url = service.api_url + "/apis/serving.knative.dev/v1alpha1/services" + + WebMock.stub_request(:get, knative_url).to_return(kube_response(kube_knative_services_body(options))) + end + def stub_kubeclient_get_secret(api_url, **options) options[:metadata_name] ||= "default-token-1" options[:namespace] ||= "default" @@ -181,6 +192,13 @@ module KubernetesHelpers } end + def kube_knative_services_body(**options) + { + "kind" => "List", + "items" => [kube_service(options)] + } + end + # This is a partial response, it will have many more elements in reality but # these are the ones we care about at the moment def kube_pod(name: "kube-pod", app: "valid-pod-label", status: "Running", track: nil) @@ -224,6 +242,54 @@ module KubernetesHelpers } end + def kube_service(name: "kubetest", namespace: "default", domain: "example.com") + { + "metadata" => { + "creationTimestamp" => "2018-11-21T06:16:33Z", + "name" => name, + "namespace" => namespace, + "selfLink" => "/apis/serving.knative.dev/v1alpha1/namespaces/#{namespace}/services/#{name}" + }, + "spec" => { + "generation" => 2 + }, + "status" => { + "domain" => "#{name}.#{namespace}.#{domain}", + "domainInternal" => "#{name}.#{namespace}.svc.cluster.local", + "latestCreatedRevisionName" => "#{name}-00002", + "latestReadyRevisionName" => "#{name}-00002", + "observedGeneration" => 2 + } + } + end + + def kube_service_full(name: "kubetest", namespace: "kube-ns", domain: "example.com") + { + "metadata" => { + "creationTimestamp" => "2018-11-21T06:16:33Z", + "name" => name, + "namespace" => namespace, + "selfLink" => "/apis/serving.knative.dev/v1alpha1/namespaces/#{namespace}/services/#{name}", + "annotation" => { + "description" => "This is a test description" + } + }, + "spec" => { + "generation" => 2, + "build" => { + "template" => "go-1.10.3" + } + }, + "status" => { + "domain" => "#{name}.#{namespace}.#{domain}", + "domainInternal" => "#{name}.#{namespace}.svc.cluster.local", + "latestCreatedRevisionName" => "#{name}-00002", + "latestReadyRevisionName" => "#{name}-00002", + "observedGeneration" => 2 + } + } + end + def kube_terminals(service, pod) pod_name = pod['metadata']['name'] containers = pod['spec']['containers'] diff --git a/spec/support/shared_examples/file_finder.rb b/spec/support/shared_examples/file_finder.rb index ef144bdf61c..0dc351b5149 100644 --- a/spec/support/shared_examples/file_finder.rb +++ b/spec/support/shared_examples/file_finder.rb @@ -3,18 +3,19 @@ shared_examples 'file finder' do let(:search_results) { subject.find(query) } it 'finds by name' do - filename, blob = search_results.find { |_, blob| blob.filename == expected_file_by_name } - expect(filename).to eq(expected_file_by_name) - expect(blob).to be_a(Gitlab::SearchResults::FoundBlob) + blob = search_results.find { |blob| blob.filename == expected_file_by_name } + + expect(blob.filename).to eq(expected_file_by_name) + expect(blob).to be_a(Gitlab::Search::FoundBlob) expect(blob.ref).to eq(subject.ref) expect(blob.data).not_to be_empty end it 'finds by content' do - filename, blob = search_results.find { |_, blob| blob.filename == expected_file_by_content } + blob = search_results.find { |blob| blob.filename == expected_file_by_content } - expect(filename).to eq(expected_file_by_content) - expect(blob).to be_a(Gitlab::SearchResults::FoundBlob) + expect(blob.filename).to eq(expected_file_by_content) + expect(blob).to be_a(Gitlab::Search::FoundBlob) expect(blob.ref).to eq(subject.ref) expect(blob.data).not_to be_empty end diff --git a/spec/support/shared_examples/models/member_shared_examples.rb b/spec/support/shared_examples/models/member_shared_examples.rb new file mode 100644 index 00000000000..77376496854 --- /dev/null +++ b/spec/support/shared_examples/models/member_shared_examples.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +shared_examples_for 'inherited access level as a member of entity' do + let(:parent_entity) { create(:group) } + let(:user) { create(:user) } + let(:member) { entity.is_a?(Group) ? entity.group_member(user) : entity.project_member(user) } + + context 'with root parent_entity developer member' do + before do + parent_entity.add_developer(user) + end + + it 'is allowed to be a maintainer of the entity' do + entity.add_maintainer(user) + + expect(member.access_level).to eq(Gitlab::Access::MAINTAINER) + end + + it 'is not allowed to be a reporter of the entity' do + entity.add_reporter(user) + + expect(member).to be_nil + end + + it 'is allowed to change to be a developer of the entity' do + entity.add_maintainer(user) + + expect { member.update(access_level: Gitlab::Access::DEVELOPER) } + .to change { member.access_level }.to(Gitlab::Access::DEVELOPER) + end + + it 'is not allowed to change to be a guest of the entity' do + entity.add_maintainer(user) + + expect { member.update(access_level: Gitlab::Access::GUEST) } + .not_to change { member.reload.access_level } + end + + it "shows an error if the member can't be updated" do + entity.add_maintainer(user) + + member.update(access_level: Gitlab::Access::REPORTER) + + expect(member.errors.full_messages).to eq(["Access level should be higher than Developer inherited membership from group #{parent_entity.name}"]) + end + + it 'allows changing the level from a non existing member' do + non_member_user = create(:user) + + entity.add_maintainer(non_member_user) + + non_member = entity.is_a?(Group) ? entity.group_member(non_member_user) : entity.project_member(non_member_user) + + expect { non_member.update(access_level: Gitlab::Access::GUEST) } + .to change { non_member.reload.access_level } + end + end +end + +shared_examples_for '#valid_level_roles' do |entity_name| + let(:member_user) { create(:user) } + let(:group) { create(:group) } + let(:entity) { create(entity_name) } + let(:entity_member) { create("#{entity_name}_member", :developer, source: entity, user: member_user) } + let(:presenter) { described_class.new(entity_member, current_user: member_user) } + let(:expected_roles) { { 'Developer' => 30, 'Maintainer' => 40, 'Reporter' => 20 } } + + it 'returns all roles when no parent member is present' do + expect(presenter.valid_level_roles).to eq(entity_member.class.access_level_roles) + end + + it 'returns higher roles when a parent member is present' do + group.add_reporter(member_user) + + expect(presenter.valid_level_roles).to eq(expected_roles) + end +end diff --git a/spec/support/shared_examples/models/with_uploads_shared_examples.rb b/spec/support/shared_examples/models/with_uploads_shared_examples.rb index 47ad0c6345d..1d11b855459 100644 --- a/spec/support/shared_examples/models/with_uploads_shared_examples.rb +++ b/spec/support/shared_examples/models/with_uploads_shared_examples.rb @@ -1,6 +1,6 @@ require 'spec_helper' -shared_examples_for 'model with mounted uploader' do |supports_fileuploads| +shared_examples_for 'model with uploads' do |supports_fileuploads| describe '.destroy' do before do stub_uploads_object_storage(uploader_class) @@ -8,16 +8,62 @@ shared_examples_for 'model with mounted uploader' do |supports_fileuploads| model_object.public_send(upload_attribute).migrate!(ObjectStorage::Store::REMOTE) end - it 'deletes remote uploads' do - expect_any_instance_of(CarrierWave::Storage::Fog::File).to receive(:delete).and_call_original + context 'with mounted uploader' do + it 'deletes remote uploads' do + expect_any_instance_of(CarrierWave::Storage::Fog::File).to receive(:delete).and_call_original - expect { model_object.destroy }.to change { Upload.count }.by(-1) + expect { model_object.destroy }.to change { Upload.count }.by(-1) + end end - it 'deletes any FileUploader uploads which are not mounted', skip: !supports_fileuploads do - create(:upload, uploader: FileUploader, model: model_object) + context 'with not mounted uploads', :sidekiq, skip: !supports_fileuploads do + context 'with local files' do + let!(:uploads) { create_list(:upload, 2, uploader: FileUploader, model: model_object) } - expect { model_object.destroy }.to change { Upload.count }.by(-2) + it 'deletes any FileUploader uploads which are not mounted' do + expect { model_object.destroy }.to change { Upload.count }.by(-3) + end + + it 'deletes local files' do + expect_any_instance_of(Uploads::Local).to receive(:delete_keys).with(uploads.map(&:absolute_path)) + + model_object.destroy + end + end + + context 'with remote files' do + let!(:uploads) { create_list(:upload, 2, :object_storage, uploader: FileUploader, model: model_object) } + + it 'deletes any FileUploader uploads which are not mounted' do + expect { model_object.destroy }.to change { Upload.count }.by(-3) + end + + it 'deletes remote files' do + expect_any_instance_of(Uploads::Fog).to receive(:delete_keys).with(uploads.map(&:path)) + + model_object.destroy + end + end + + describe 'destroy strategy depending on feature flag' do + let!(:upload) { create(:upload, uploader: FileUploader, model: model_object) } + + it 'does not destroy uploads by default' do + expect(model_object).to receive(:delete_uploads) + expect(model_object).not_to receive(:destroy_uploads) + + model_object.destroy + end + + it 'uses before destroy callback if feature flag is disabled' do + stub_feature_flags(fast_destroy_uploads: false) + + expect(model_object).to receive(:destroy_uploads) + expect(model_object).not_to receive(:delete_uploads) + + model_object.destroy + end + end end end end diff --git a/spec/support/shared_examples/serializers/diff_file_entity_examples.rb b/spec/support/shared_examples/serializers/diff_file_entity_examples.rb new file mode 100644 index 00000000000..b8065886c42 --- /dev/null +++ b/spec/support/shared_examples/serializers/diff_file_entity_examples.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +shared_examples 'diff file base entity' do + it 'exposes essential attributes' do + expect(subject).to include(:content_sha, :submodule, :submodule_link, + :submodule_tree_url, :old_path_html, + :new_path_html, :blob, :can_modify_blob, + :file_hash, :file_path, :old_path, :new_path, + :collapsed, :text, :diff_refs, :stored_externally, + :external_storage, :renamed_file, :deleted_file, + :mode_changed, :a_mode, :b_mode, :new_file) + end + + # Converted diff files from GitHub import does not contain blob file + # and content sha. + context 'when diff file does not have a blob and content sha' do + it 'exposes some attributes as nil' do + allow(diff_file).to receive(:content_sha).and_return(nil) + allow(diff_file).to receive(:blob).and_return(nil) + + expect(subject[:context_lines_path]).to be_nil + expect(subject[:view_path]).to be_nil + expect(subject[:highlighted_diff_lines]).to be_nil + expect(subject[:can_modify_blob]).to be_nil + end + end +end + +shared_examples 'diff file entity' do + it_behaves_like 'diff file base entity' + + it 'exposes correct attributes' do + expect(subject).to include(:too_large, :added_lines, :removed_lines, + :context_lines_path, :highlighted_diff_lines, + :parallel_diff_lines) + end + + it 'includes viewer' do + expect(subject[:viewer].with_indifferent_access) + .to match_schema('entities/diff_viewer') + end +end + +shared_examples 'diff file discussion entity' do + it_behaves_like 'diff file base entity' +end diff --git a/spec/tasks/gitlab/web_hook_rake_spec.rb b/spec/tasks/gitlab/web_hook_rake_spec.rb new file mode 100644 index 00000000000..7bdf33ff6b0 --- /dev/null +++ b/spec/tasks/gitlab/web_hook_rake_spec.rb @@ -0,0 +1,92 @@ +require 'rake_helper' + +describe 'gitlab:web_hook namespace rake tasks' do + set(:group) { create(:group) } + + set(:project1) { create(:project, namespace: group) } + set(:project2) { create(:project, namespace: group) } + set(:other_group_project) { create(:project) } + + let(:url) { 'http://example.com' } + let(:hook_urls) { (project1.hooks + project2.hooks).map(&:url) } + let(:other_group_hook_urls) { other_group_project.hooks.map(&:url) } + + before do + Rake.application.rake_require 'tasks/gitlab/web_hook' + end + + describe 'gitlab:web_hook:add' do + it 'adds a web hook to all projects' do + stub_env('URL' => url) + run_rake_task('gitlab:web_hook:add') + + expect(hook_urls).to contain_exactly(url, url) + expect(other_group_hook_urls).to contain_exactly(url) + end + + it 'adds a web hook to projects in the specified namespace' do + stub_env('URL' => url, 'NAMESPACE' => group.full_path) + run_rake_task('gitlab:web_hook:add') + + expect(hook_urls).to contain_exactly(url, url) + expect(other_group_hook_urls).to be_empty + end + + it 'raises an error if an unknown namespace is specified' do + stub_env('URL' => url, 'NAMESPACE' => group.full_path) + + group.destroy + + expect { run_rake_task('gitlab:web_hook:add') }.to raise_error(SystemExit) + end + end + + describe 'gitlab:web_hook:rm' do + let!(:hook1) { create(:project_hook, project: project1, url: url) } + let!(:hook2) { create(:project_hook, project: project2, url: url) } + let!(:other_group_hook) { create(:project_hook, project: other_group_project, url: url) } + let!(:other_url_hook) { create(:project_hook, url: other_url, project: project1) } + + let(:other_url) { 'http://other.example.com' } + + it 'removes a web hook from all projects by URL' do + stub_env('URL' => url) + run_rake_task('gitlab:web_hook:rm') + + expect(hook_urls).to contain_exactly(other_url) + expect(other_group_hook_urls).to be_empty + end + + it 'removes a web hook from projects in the specified namespace by URL' do + stub_env('NAMESPACE' => group.full_path, 'URL' => url) + run_rake_task('gitlab:web_hook:rm') + + expect(hook_urls).to contain_exactly(other_url) + expect(other_group_hook_urls).to contain_exactly(url) + end + + it 'raises an error if an unknown namespace is specified' do + stub_env('URL' => url, 'NAMESPACE' => group.full_path) + + group.destroy + + expect { run_rake_task('gitlab:web_hook:rm') }.to raise_error(SystemExit) + end + end + + describe 'gitlab:web_hook:list' do + let!(:hook1) { create(:project_hook, project: project1) } + let!(:hook2) { create(:project_hook, project: project2) } + let!(:other_group_hook) { create(:project_hook, project: other_group_project) } + + it 'lists all web hooks' do + expect { run_rake_task('gitlab:web_hook:list') }.to output(/3 webhooks found/).to_stdout + end + + it 'lists web hooks in a particular namespace' do + stub_env('NAMESPACE', group.full_path) + + expect { run_rake_task('gitlab:web_hook:list') }.to output(/2 webhooks found/).to_stdout + end + end +end diff --git a/spec/workers/rebase_worker_spec.rb b/spec/workers/rebase_worker_spec.rb index 936b9deaecc..900332ed6b3 100644 --- a/spec/workers/rebase_worker_spec.rb +++ b/spec/workers/rebase_worker_spec.rb @@ -19,7 +19,7 @@ describe RebaseWorker, '#perform' do expect(MergeRequests::RebaseService) .to receive(:new).with(forked_project, merge_request.author).and_call_original - subject.perform(merge_request, merge_request.author) + subject.perform(merge_request.id, merge_request.author.id) end end end diff --git a/spec/workers/repository_cleanup_worker_spec.rb b/spec/workers/repository_cleanup_worker_spec.rb new file mode 100644 index 00000000000..3adae0b6cfa --- /dev/null +++ b/spec/workers/repository_cleanup_worker_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' + +describe RepositoryCleanupWorker do + let(:project) { create(:project) } + let(:user) { create(:user) } + + subject(:worker) { described_class.new } + + describe '#perform' do + it 'executes the cleanup service and sends a success notification' do + expect_next_instance_of(Projects::CleanupService) do |service| + expect(service.project).to eq(project) + expect(service.current_user).to eq(user) + + expect(service).to receive(:execute) + end + + expect_next_instance_of(NotificationService) do |service| + expect(service).to receive(:repository_cleanup_success).with(project, user) + end + + worker.perform(project.id, user.id) + end + + it 'raises an error if the project cannot be found' do + project.destroy + + expect { worker.perform(project.id, user.id) }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'raises an error if the user cannot be found' do + user.destroy + + expect { worker.perform(project.id, user.id) }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + describe '#sidekiq_retries_exhausted' do + let(:job) { { 'args' => [project.id, user.id], 'error_message' => 'Error' } } + + it 'does not send a failure notification for a RecordNotFound error' do + expect(NotificationService).not_to receive(:new) + + described_class.sidekiq_retries_exhausted_block.call(job, ActiveRecord::RecordNotFound.new) + end + + it 'sends a failure notification' do + expect_next_instance_of(NotificationService) do |service| + expect(service).to receive(:repository_cleanup_failure).with(project, user, 'Error') + end + + described_class.sidekiq_retries_exhausted_block.call(job, StandardError.new) + end + end +end diff --git a/yarn.lock b/yarn.lock index d4906a6a212..d7d2b89a881 100644 --- a/yarn.lock +++ b/yarn.lock @@ -634,14 +634,15 @@ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.41.0.tgz#f80e3a0e259f3550af00685556ea925e471276d3" integrity sha512-tKUXyqe54efWBsjQBUcvNF0AvqmE2NI2No3Bnix/gKDRImzIlcgIkM67Y8zoJv1D0w4CO87WcaG5GLpIFIT1Pg== -"@gitlab/ui@^1.11.0": - version "1.11.0" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-1.11.0.tgz#b771c2c3d627cf9efbe98c71ee5739624f2ff51f" - integrity sha512-hGMHM45kcv9725R6G+n/HxvF3KfVb9oBGRNf1+4n3xAGmtXJ2NlPdIXIsDaye3EeVF9PTOtjLuaqrcp6AGNqZg== +"@gitlab/ui@^1.14.0": + version "1.14.0" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-1.14.0.tgz#f0fd7c0e6c45a36ab3be18d00e2908a8cb405f90" + integrity sha512-jkBTN8qO41A894kcLo6b/mfLIgL8YNn+ZzjgzEXaZ3PyeQ3mKBdrBoSYkzH556qviroBvk/+3yyZz96VUo08qQ== dependencies: babel-standalone "^6.26.0" bootstrap-vue "^2.0.0-rc.11" copy-to-clipboard "^3.0.8" + echarts "^4.2.0-rc.2" highlight.js "^9.13.1" js-beautify "^1.8.8" lodash "^4.17.11" @@ -3278,6 +3279,13 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" +echarts@^4.2.0-rc.2: + version "4.2.0-rc.2" + resolved "https://registry.yarnpkg.com/echarts/-/echarts-4.2.0-rc.2.tgz#6a98397aafa81b65cbf0bc15d9afdbfb244df91e" + integrity sha512-5Y4Kyi4eNsRM9Cnl7Q8C6PFVjznBJv1VIiMm/VSQ9zyqeo+ce1695GqUd9v4zfVx+Ow1gnwMJX67h0FNvarScw== + dependencies: + zrender "4.0.5" + editions@^1.3.3: version "1.3.4" resolved "https://registry.yarnpkg.com/editions/-/editions-1.3.4.tgz#3662cb592347c3168eb8e498a0ff73271d67f50b" @@ -10296,3 +10304,8 @@ zen-observable@^0.8.0: version "0.8.11" resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.11.tgz#d3415885eeeb42ee5abb9821c95bb518fcd6d199" integrity sha512-N3xXQVr4L61rZvGMpWe8XoCGX8vhU35dPyQ4fm5CY/KDlG0F75un14hjbckPXTDuKUY6V0dqR2giT6xN8Y4GEQ== + +zrender@4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/zrender/-/zrender-4.0.5.tgz#6e8f738971ce2cd624aac82b2156729b1c0e5a82" + integrity sha512-SintgipGEJPT9Sz2ABRoE4ZD7Yzy7oR7j7KP6H+C9FlbHWnLUfGVK7E8UV27pGwlxAMB0EsnrqhXx5XjAfv/KA== |