diff options
272 files changed, 4979 insertions, 1073 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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 a7d43fb742b..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: 20181129104944) do +ActiveRecord::Schema.define(version: 20181203002526) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -354,6 +354,7 @@ ActiveRecord::Schema.define(version: 20181129104944) 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 @@ -1683,6 +1684,7 @@ ActiveRecord::Schema.define(version: 20181129104944) 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/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..a9369930003 100644 --- a/doc/api/search.md +++ b/doc/api/search.md @@ -722,6 +722,17 @@ Example response: ### Scope: wiki_blobs +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, and matches of the different +types are displayed separately. +- May contain multiple matches for the same blob because the search string + might be found if the search string appears 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 ``` @@ -783,6 +794,15 @@ Filters are available for this scope: to use a filter simply include it in your query like so: `a query filename:some_name*`. +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, and matches of the different +types are displayed separately. +- May contain multiple matches for the same blob because the search string + might be found if the search string appears multiple times in the content. + You may use wildcards (`*`) to use glob matching. ```bash diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index bdbcf8c9435..fd81a67dca0 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -108,7 +108,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 @@ -137,7 +137,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 @@ -176,8 +176,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 @@ -206,8 +205,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 @@ -228,8 +226,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 @@ -490,7 +487,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/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 7cdea9d1ce4..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 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/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..69ec971bb75 100644 --- a/spec/controllers/projects/settings/repository_controller_spec.rb +++ b/spec/controllers/projects/settings/repository_controller_spec.rb @@ -17,4 +17,35 @@ describe Projects::Settings::RepositoryController do expect(response).to render_template(:show) end end + + describe 'PUT cleanup' do + def do_put! + object_map = fixture_file_upload('spec/fixtures/bfg_object_map.txt') + + Sidekiq::Testing.fake! do + put :cleanup, namespace_id: project.namespace, project_id: project, project: { object_map: object_map } + end + 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.jobs.count).to eq(1) + 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/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/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/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/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/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/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/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== |