diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index 4aba633e182..738150dbd2e 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -101,6 +101,13 @@ ul.unstyled-list > li { border-bottom: 0; } +ul.list-items-py-2 { + > li { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + } +} + // Generic content list ul.content-list { @include basic-list; diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 1e2556aecc1..3464cc1ea07 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -290,6 +290,8 @@ = render_if_exists 'layouts/nav/sidebar/project_packages_link' + = render_if_exists 'layouts/nav/sidebar/project_analytics_link' # EE-specific + - if project_nav_tab? :wiki - wiki_url = project_wiki_path(@project, :home) = nav_link(controller: :wikis) do diff --git a/changelogs/unreleased/backward-compatibility-for-background-migrations.yml b/changelogs/unreleased/backward-compatibility-for-background-migrations.yml new file mode 100644 index 00000000000..c3a62fafd3f --- /dev/null +++ b/changelogs/unreleased/backward-compatibility-for-background-migrations.yml @@ -0,0 +1,5 @@ +--- +title: Make BackgroundMigrationWorker backward compatible +merge_request: 22271 +author: +type: fixed diff --git a/doc/user/application_security/dependency_scanning/index.md b/doc/user/application_security/dependency_scanning/index.md index 59f241db7de..2a11f7a1b7c 100644 --- a/doc/user/application_security/dependency_scanning/index.md +++ b/doc/user/application_security/dependency_scanning/index.md @@ -136,6 +136,9 @@ using environment variables. | `DS_PYTHON_VERSION` | Version of Python. If set to 2, dependencies are installed using Python 2.7 instead of Python 3.6. ([Introduced](https://gitlab.com/gitlab-org/gitlab/issues/12296) in GitLab 12.1)| | `DS_PIP_VERSION` | Force the install of a specific pip version (example: `"19.3"`), otherwise the pip installed in the docker image is used. | | `DS_PIP_DEPENDENCY_PATH` | Path to load Python pip dependencies from. ([Introduced](https://gitlab.com/gitlab-org/gitlab/issues/12412) in GitLab 12.2) | +| `GEMNASIUM_DB_LOCAL_PATH` | Path to local gemnasium database (default `/gemnasium-db`). +| `GEMNASIUM_DB_REMOTE_URL` | Repository URL for fetching the gemnasium database (default `https://gitlab.com/gitlab-org/security-products/gemnasium-db.git`). +| `GEMNASIUM_DB_REF_NAME` | Branch name for remote repository database (default `master`). `GEMNASIUM_DB_REMOTE_URL` is required. | `DS_DEFAULT_ANALYZERS` | Override the names of the official default images. Read more about [customizing analyzers](analyzers.md). | | `DS_DISABLE_DIND` | Disable Docker in Docker and run analyzers [individually](#disabling-docker-in-docker-for-dependency-scanning).| | `DS_PULL_ANALYZER_IMAGES` | Pull the images from the Docker registry (set to `0` to disable). | diff --git a/lib/gitlab/background_migration.rb b/lib/gitlab/background_migration.rb index 61e0a075018..ddd6b11eebb 100644 --- a/lib/gitlab/background_migration.rb +++ b/lib/gitlab/background_migration.rb @@ -78,6 +78,20 @@ module Gitlab end def self.migration_class_for(class_name) + # We don't pass class name with Gitlab::BackgroundMigration:: prefix anymore + # but some jobs could be already spawned so we need to have some backward compatibility period. + # Can be removed since 13.x + full_class_name_prefix_regexp = /\A(::)?Gitlab::BackgroundMigration::/ + + if class_name.match(full_class_name_prefix_regexp) + Gitlab::ErrorTracking.track_and_raise_for_dev_exception( + StandardError.new("Full class name is used"), + class_name: class_name + ) + + class_name = class_name.sub(full_class_name_prefix_regexp, '') + end + const_get(class_name, false) end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index bdd05157cef..c0c905d5b23 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -4514,6 +4514,9 @@ msgstr "" msgid "Code Owners to the merge request changes." msgstr "" +msgid "Code Review" +msgstr "" + msgid "Code owner approval is required" msgstr "" @@ -13905,6 +13908,9 @@ msgstr "" msgid "Project '%{project_name}' will be deleted on %{date}" msgstr "" +msgid "Project Analytics" +msgstr "" + msgid "Project Badges" msgstr "" @@ -15568,6 +15574,9 @@ msgstr "" msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"." msgstr "" +msgid "Review time is defined as the time it takes from first comment until merged." +msgstr "" + msgid "Reviewing" msgstr "" diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js new file mode 100644 index 00000000000..0d429778a44 --- /dev/null +++ b/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js @@ -0,0 +1,138 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import createState from '~/create_cluster/gke_cluster/store/state'; +import { selectedProjectMock, gapiProjectsResponseMock } from '../mock_data'; +import GkeProjectIdDropdown from '~/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue'; +import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; +import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; + +const componentConfig = { + docsUrl: 'https://console.cloud.google.com/home/dashboard', + fieldId: 'cluster_provider_gcp_attributes_gcp_project_id', + fieldName: 'cluster[provider_gcp_attributes][gcp_project_id]', +}; + +const LABELS = { + LOADING: 'Fetching projects', + VALIDATING_PROJECT_BILLING: 'Validating project billing status', + DEFAULT: 'Select project', + EMPTY: 'No projects found', +}; + +const localVue = createLocalVue(); + +localVue.use(Vuex); + +describe('GkeProjectIdDropdown', () => { + let wrapper; + let vuexStore; + let setProject; + + beforeEach(() => { + setProject = jest.fn(); + }); + + const createStore = (initialState = {}, getters = {}) => + new Vuex.Store({ + state: { + ...createState(), + ...initialState, + }, + actions: { + fetchProjects: jest.fn().mockResolvedValueOnce([]), + setProject, + }, + getters: { + hasProject: () => false, + ...getters, + }, + }); + + const createComponent = (store, propsData = componentConfig) => + shallowMount(GkeProjectIdDropdown, { + propsData, + store, + localVue, + }); + + const bootstrap = (initialState, getters) => { + vuexStore = createStore(initialState, getters); + wrapper = createComponent(vuexStore); + }; + + const dropdownButtonLabel = () => wrapper.find(DropdownButton).props('toggleText'); + const dropdownHiddenInputValue = () => wrapper.find(DropdownHiddenInput).props('value'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('toggleText', () => { + it('returns loading toggle text', () => { + bootstrap(); + + expect(dropdownButtonLabel()).toBe(LABELS.LOADING); + }); + + it('returns project billing validation text', () => { + bootstrap({ isValidatingProjectBilling: true }); + + expect(dropdownButtonLabel()).toBe(LABELS.VALIDATING_PROJECT_BILLING); + }); + + it('returns default toggle text', () => { + bootstrap(); + + wrapper.setData({ isLoading: false }); + + return wrapper.vm.$nextTick().then(() => { + expect(dropdownButtonLabel()).toBe(LABELS.DEFAULT); + }); + }); + + it('returns project name if project selected', () => { + bootstrap( + { + selectedProject: selectedProjectMock, + }, + { + hasProject: () => true, + }, + ); + wrapper.setData({ isLoading: false }); + + return wrapper.vm.$nextTick().then(() => { + expect(dropdownButtonLabel()).toBe(selectedProjectMock.name); + }); + }); + + it('returns empty toggle text', () => { + bootstrap({ + projects: null, + }); + wrapper.setData({ isLoading: false }); + + return wrapper.vm.$nextTick().then(() => { + expect(dropdownButtonLabel()).toBe(LABELS.EMPTY); + }); + }); + }); + + describe('selectItem', () => { + it('reflects new value when dropdown item is clicked', () => { + bootstrap({ projects: gapiProjectsResponseMock.projects }); + + expect(dropdownHiddenInputValue()).toBe(''); + + wrapper.find('.dropdown-content button').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(setProject).toHaveBeenCalledWith( + expect.anything(), + gapiProjectsResponseMock.projects[0], + undefined, + ); + }); + }); + }); +}); diff --git a/spec/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js b/spec/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js deleted file mode 100644 index 4c89124454e..00000000000 --- a/spec/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js +++ /dev/null @@ -1,115 +0,0 @@ -import Vue from 'vue'; -import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; -import GkeProjectIdDropdown from '~/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue'; -import { createStore } from '~/create_cluster/gke_cluster/store'; -import { SET_PROJECTS } from '~/create_cluster/gke_cluster/store/mutation_types'; -import { emptyProjectMock, selectedProjectMock } from '../mock_data'; -import { gapi } from '../helpers'; - -const componentConfig = { - docsUrl: 'https://console.cloud.google.com/home/dashboard', - fieldId: 'cluster_provider_gcp_attributes_gcp_project_id', - fieldName: 'cluster[provider_gcp_attributes][gcp_project_id]', -}; - -const LABELS = { - LOADING: 'Fetching projects', - VALIDATING_PROJECT_BILLING: 'Validating project billing status', - DEFAULT: 'Select project', - EMPTY: 'No projects found', -}; - -const createComponent = (store, props = componentConfig) => { - const Component = Vue.extend(GkeProjectIdDropdown); - - return mountComponentWithStore(Component, { - el: null, - props, - store, - }); -}; - -describe('GkeProjectIdDropdown', () => { - let vm; - let store; - - let originalGapi; - beforeAll(() => { - originalGapi = window.gapi; - window.gapi = gapi(); - }); - - afterAll(() => { - window.gapi = originalGapi; - }); - - beforeEach(() => { - store = createStore(); - vm = createComponent(store); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('toggleText', () => { - it('returns loading toggle text', () => { - expect(vm.toggleText).toBe(LABELS.LOADING); - }); - - it('returns project billing validation text', () => { - vm.setIsValidatingProjectBilling(true); - - expect(vm.toggleText).toBe(LABELS.VALIDATING_PROJECT_BILLING); - }); - - it('returns default toggle text', done => - setTimeout(() => { - vm.setItem(emptyProjectMock); - - expect(vm.toggleText).toBe(LABELS.DEFAULT); - - done(); - })); - - it('returns project name if project selected', done => - setTimeout(() => { - vm.isLoading = false; - - expect(vm.toggleText).toBe(selectedProjectMock.name); - - done(); - })); - - it('returns empty toggle text', done => - setTimeout(() => { - vm.$store.commit(SET_PROJECTS, null); - vm.setItem(emptyProjectMock); - - expect(vm.toggleText).toBe(LABELS.EMPTY); - - done(); - })); - }); - - describe('selectItem', () => { - it('reflects new value when dropdown item is clicked', done => { - expect(vm.$el.querySelector('input').value).toBe(''); - - return vm - .$nextTick() - .then(() => { - vm.$el.querySelector('.dropdown-content button').click(); - - return vm - .$nextTick() - .then(() => { - expect(vm.$el.querySelector('input').value).toBe(selectedProjectMock.projectId); - done(); - }) - .catch(done.fail); - }) - .catch(done.fail); - }); - }); -}); diff --git a/spec/lib/gitlab/background_migration_spec.rb b/spec/lib/gitlab/background_migration_spec.rb index 8960ac706e6..66a0b11606f 100644 --- a/spec/lib/gitlab/background_migration_spec.rb +++ b/spec/lib/gitlab/background_migration_spec.rb @@ -152,6 +152,17 @@ describe Gitlab::BackgroundMigration do described_class.perform('Foo', [10, 20]) end + + context 'backward compatibility' do + it 'performs a background migration for fully-qualified job classes' do + expect(migration).to receive(:perform).with(10, 20).once + expect(Gitlab::ErrorTracking) + .to receive(:track_and_raise_for_dev_exception) + .with(instance_of(StandardError), hash_including(:class_name)) + + described_class.perform('Gitlab::BackgroundMigration::Foo', [10, 20]) + end + end end describe '.exists?' do diff --git a/spec/requests/self_monitoring_project_spec.rb b/spec/requests/self_monitoring_project_spec.rb index be701d74675..f3ffa8e0ea4 100644 --- a/spec/requests/self_monitoring_project_spec.rb +++ b/spec/requests/self_monitoring_project_spec.rb @@ -22,44 +22,9 @@ describe 'Self-Monitoring project requests' do end context 'with feature flag enabled' do - it 'returns sidekiq job_id of expected length' do - subject + let(:status_api) { status_create_self_monitoring_project_admin_application_settings_path } - job_id = json_response['job_id'] - - aggregate_failures do - expect(job_id).to be_present - expect(job_id.length).to be <= Admin::ApplicationSettingsController::PARAM_JOB_ID_MAX_SIZE - end - end - - it 'triggers async worker' do - expect(worker_class).to receive(:perform_async) - - subject - end - - it 'returns accepted response' do - subject - - aggregate_failures do - expect(response).to have_gitlab_http_status(:accepted) - expect(json_response.keys).to contain_exactly('job_id', 'monitor_status') - expect(json_response).to include( - 'monitor_status' => status_create_self_monitoring_project_admin_application_settings_path - ) - end - end - - it 'returns job_id' do - fake_job_id = 'b5b28910d97563e58c2fe55f' - expect(worker_class).to receive(:perform_async).and_return(fake_job_id) - - subject - response_job_id = json_response['job_id'] - - expect(response_job_id).to eq fake_job_id - end + it_behaves_like 'triggers async worker, returns sidekiq job_id with response accepted' end end end @@ -85,15 +50,32 @@ describe 'Self-Monitoring project requests' do end context 'with feature flag enabled' do - context 'with invalid job_id' do - it 'returns bad_request if job_id too long' do - get status_create_self_monitoring_project_admin_application_settings_path, - params: { job_id: 'a' * 51 } + it_behaves_like 'handles invalid job_id' + + context 'when job is in progress' do + before do + allow(worker_class).to receive(:in_progress?) + .with(job_id) + .and_return(true) + end + + it_behaves_like 'sets polling header and returns accepted' do + let(:in_progress_message) { 'Job is in progress' } + end + end + + context 'when self-monitoring project and job do not exist' do + let(:job_id) { nil } + + it 'returns bad_request' do + subject aggregate_failures do expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response).to eq('message' => 'Parameter "job_id" cannot ' \ - "exceed length of #{Admin::ApplicationSettingsController::PARAM_JOB_ID_MAX_SIZE}") + expect(json_response).to eq( + 'message' => 'Self-monitoring project does not exist. Please check logs ' \ + 'for any error messages' + ) end end end @@ -118,7 +100,7 @@ describe 'Self-Monitoring project requests' do end end - it 'returns success' do + it 'returns success with job_id' do subject aggregate_failures do @@ -130,45 +112,6 @@ describe 'Self-Monitoring project requests' do end end end - - context 'when job is in progress' do - before do - allow(worker_class).to receive(:in_progress?) - .with(job_id) - .and_return(true) - end - - it 'sets polling header' do - expect(::Gitlab::PollingInterval).to receive(:set_header) - - subject - end - - it 'returns accepted' do - subject - - aggregate_failures do - expect(response).to have_gitlab_http_status(:accepted) - expect(json_response).to eq('message' => 'Job is in progress') - end - end - end - - context 'when self-monitoring project and job do not exist' do - let(:job_id) { nil } - - it 'returns bad_request' do - subject - - aggregate_failures do - expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response).to eq( - 'message' => 'Self-monitoring project does not exist. Please check ' \ - 'logs for any error messages' - ) - end - end - end end end end diff --git a/spec/support/shared_examples/requests/self_monitoring_shared_examples.rb b/spec/support/shared_examples/requests/self_monitoring_shared_examples.rb index 6dea7fcda3c..949aa079435 100644 --- a/spec/support/shared_examples/requests/self_monitoring_shared_examples.rb +++ b/spec/support/shared_examples/requests/self_monitoring_shared_examples.rb @@ -39,3 +39,94 @@ RSpec.shared_examples 'not accessible to non-admin users' do end end end + +# Requires subject and worker_class and status_api to be defined +# let(:worker_class) { SelfMonitoringProjectCreateWorker } +# let(:status_api) { status_create_self_monitoring_project_admin_application_settings_path } +# subject { post create_self_monitoring_project_admin_application_settings_path } +RSpec.shared_examples 'triggers async worker, returns sidekiq job_id with response accepted' do + it 'returns sidekiq job_id of expected length' do + subject + + job_id = json_response['job_id'] + + aggregate_failures do + expect(job_id).to be_present + expect(job_id.length).to be <= Admin::ApplicationSettingsController::PARAM_JOB_ID_MAX_SIZE + end + end + + it 'triggers async worker' do + expect(worker_class).to receive(:perform_async) + + subject + end + + it 'returns accepted response' do + subject + + aggregate_failures do + expect(response).to have_gitlab_http_status(:accepted) + expect(json_response.keys).to contain_exactly('job_id', 'monitor_status') + expect(json_response).to include( + 'monitor_status' => status_api + ) + end + end + + it 'returns job_id' do + fake_job_id = 'b5b28910d97563e58c2fe55f' + allow(worker_class).to receive(:perform_async).and_return(fake_job_id) + + subject + + expect(json_response).to include('job_id' => fake_job_id) + end +end + +# Requires job_id and subject to be defined +# let(:job_id) { 'job_id' } +# subject do +# get status_create_self_monitoring_project_admin_application_settings_path, +# params: { job_id: job_id } +# end +RSpec.shared_examples 'handles invalid job_id' do + context 'with invalid job_id' do + let(:job_id) { 'a' * 51 } + + it 'returns bad_request if job_id too long' do + subject + + aggregate_failures do + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response).to eq('message' => 'Parameter "job_id" cannot ' \ + "exceed length of #{Admin::ApplicationSettingsController::PARAM_JOB_ID_MAX_SIZE}") + end + end + end +end + +# Requires in_progress_message and subject to be defined +# let(:in_progress_message) { 'Job to create self-monitoring project is in progress' } +# subject do +# get status_create_self_monitoring_project_admin_application_settings_path, +# params: { job_id: job_id } +# end +RSpec.shared_examples 'sets polling header and returns accepted' do + it 'sets polling header' do + expect(::Gitlab::PollingInterval).to receive(:set_header) + + subject + end + + it 'returns accepted' do + subject + + aggregate_failures do + expect(response).to have_gitlab_http_status(:accepted) + expect(json_response).to eq( + 'message' => in_progress_message + ) + end + end +end