diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-11-07 18:19:19 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-11-07 18:19:19 +0300 |
commit | d4fcd1794ea9fc10d83cdc75490f76a418e59d52 (patch) | |
tree | b072bfe2c59dc666ddaa28c11e0c04a7971014e0 | |
parent | dfa6eac07553d5a3f254ee904e4298bd666b410f (diff) |
Add latest changes from gitlab-org/gitlab@master
210 files changed, 2699 insertions, 953 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7ef143d5525..9a24f58ad73 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -105,6 +105,11 @@ workflow: <<: [*default-ruby-variables, *default-branch-pipeline-failure-variables] GITLAB_DEPENDENCY_PROXY_ADDRESS: "" PIPELINE_NAME: 'Ruby $RUBY_VERSION $CI_COMMIT_BRANCH branch pipeline (triggered by a project token)' + # For `$CI_DEFAULT_BRANCH` from wider community contributors, we don't want to run any pipelines on pushes, + # because normally we want to run merge request pipelines and scheduled pipelines, not for repository synchronization. + # This can avoid accidentally using up pipeline minutes quota while synchronizing the repository for wider community contributors. + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "push" && $CI_PROJECT_NAMESPACE !~ /^gitlab(-org|-cn)?($|\/)/' + when: never # For `$CI_DEFAULT_BRANCH` branch, create a pipeline (this includes on schedules, pushes, merges, etc.). - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' variables: diff --git a/.gitlab/ci/rails/shared.gitlab-ci.yml b/.gitlab/ci/rails/shared.gitlab-ci.yml index 6d6d99231ab..6046c672f7b 100644 --- a/.gitlab/ci/rails/shared.gitlab-ci.yml +++ b/.gitlab/ci/rails/shared.gitlab-ci.yml @@ -84,10 +84,10 @@ include: - echo -e "\e[0Ksection_start:`date +%s`:report_results_section[collapsed=true]\r\e[0KReport results" - | if [ "$CREATE_RAILS_TEST_FAILURE_ISSUES" == "true" ]; then - bundle exec relate-failure-issue --input-files "rspec/rspec-*.json" --system-log-files "log" --project "gitlab-org/gitlab" --token "${TEST_FAILURES_PROJECT_TOKEN}"; + bundle exec relate-failure-issue --input-files "rspec/rspec-*.json" --system-log-files "log" --project "gitlab-org/gitlab" --token "${TEST_FAILURES_PROJECT_TOKEN}" --related-issues-file "rspec/${CI_JOB_ID}-failed-test-issues.json"; fi if [ "$CREATE_RAILS_SLOW_TEST_ISSUES" == "true" ]; then - bundle exec slow-test-issues --input-files "rspec/rspec-*.json" --project "gitlab-org/gitlab" --token "${TEST_FAILURES_PROJECT_TOKEN}"; + bundle exec slow-test-issues --input-files "rspec/rspec-*.json" --project "gitlab-org/gitlab" --token "${TEST_FAILURES_PROJECT_TOKEN}" --related-issues-file "rspec/${CI_JOB_ID}-slow-test-issues.json"; fi if [ "$ADD_SLOW_TEST_NOTE_TO_MERGE_REQUEST" == "true" ]; then bundle exec slow-test-merge-request-report-note --input-files "rspec/rspec-*.json" --project "gitlab-org/gitlab" --merge_request_iid "$CI_MERGE_REQUEST_IID" --token "${TEST_SLOW_NOTE_PROJECT_TOKEN}"; diff --git a/.rubocop_todo/capybara/testid_finders.yml b/.rubocop_todo/capybara/testid_finders.yml index 0238bfbc86a..ddaf1e27beb 100644 --- a/.rubocop_todo/capybara/testid_finders.yml +++ b/.rubocop_todo/capybara/testid_finders.yml @@ -60,7 +60,6 @@ Capybara/TestidFinders: - 'spec/features/merge_request/user_views_open_merge_request_spec.rb' - 'spec/features/milestone_spec.rb' - 'spec/features/nav/new_nav_callout_spec.rb' - - 'spec/features/nav/new_nav_toggle_spec.rb' - 'spec/features/nav/pinned_nav_items_spec.rb' - 'spec/features/populate_new_pipeline_vars_with_params_spec.rb' - 'spec/features/profile_spec.rb' diff --git a/.rubocop_todo/layout/argument_alignment.yml b/.rubocop_todo/layout/argument_alignment.yml index e9a23d2ca7e..91c7f265f99 100644 --- a/.rubocop_todo/layout/argument_alignment.yml +++ b/.rubocop_todo/layout/argument_alignment.yml @@ -212,7 +212,6 @@ Layout/ArgumentAlignment: - 'app/graphql/resolvers/group_releases_resolver.rb' - 'app/graphql/resolvers/groups_resolver.rb' - 'app/graphql/resolvers/incident_management/timeline_events_resolver.rb' - - 'app/graphql/resolvers/issues/base_parent_resolver.rb' - 'app/graphql/resolvers/issues/base_resolver.rb' - 'app/graphql/resolvers/issues_resolver.rb' - 'app/graphql/resolvers/labels_resolver.rb' diff --git a/.rubocop_todo/rspec/feature_category.yml b/.rubocop_todo/rspec/feature_category.yml index 330cdc4e8e5..6a06ffef089 100644 --- a/.rubocop_todo/rspec/feature_category.yml +++ b/.rubocop_todo/rspec/feature_category.yml @@ -2846,7 +2846,6 @@ RSpec/FeatureCategory: - 'spec/lib/gitlab/background_migration/backfill_user_details_fields_spec.rb' - 'spec/lib/gitlab/background_migration/backfill_vulnerability_reads_cluster_agent_spec.rb' - 'spec/lib/gitlab/background_migration/base_job_spec.rb' - - 'spec/lib/gitlab/background_migration/batched_migration_job_spec.rb' - 'spec/lib/gitlab/background_migration/batching_strategies/backfill_project_statistics_with_container_registry_size_batching_strategy_spec.rb' - 'spec/lib/gitlab/background_migration/batching_strategies/base_strategy_spec.rb' - 'spec/lib/gitlab/background_migration/batching_strategies/dismissed_vulnerabilities_strategy_spec.rb' @@ -3262,7 +3261,6 @@ RSpec/FeatureCategory: - 'spec/lib/gitlab/database/count/reltuples_count_strategy_spec.rb' - 'spec/lib/gitlab/database/count/tablesample_count_strategy_spec.rb' - 'spec/lib/gitlab/database/count_spec.rb' - - 'spec/lib/gitlab/database/dynamic_model_helpers_spec.rb' - 'spec/lib/gitlab/database/each_database_spec.rb' - 'spec/lib/gitlab/database/grant_spec.rb' - 'spec/lib/gitlab/database/load_balancing/configuration_spec.rb' @@ -3285,7 +3283,6 @@ RSpec/FeatureCategory: - 'spec/lib/gitlab/database/migration_spec.rb' - 'spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb' - 'spec/lib/gitlab/database/migrations/base_background_runner_spec.rb' - - 'spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb' - 'spec/lib/gitlab/database/migrations/constraints_helpers_spec.rb' - 'spec/lib/gitlab/database/migrations/extension_helpers_spec.rb' - 'spec/lib/gitlab/database/migrations/instrumentation_spec.rb' diff --git a/GITLAB_SHELL_VERSION b/GITLAB_SHELL_VERSION index 2da0a2a4e91..f269bd38a5b 100644 --- a/GITLAB_SHELL_VERSION +++ b/GITLAB_SHELL_VERSION @@ -1 +1 @@ -14.29.0 +14.30.0 @@ -63,7 +63,7 @@ gem 'marginalia', '~> 1.11.1' # rubocop:todo Gemfile/MissingFeatureCategory gem 'declarative_policy', '~> 1.1.0' # rubocop:todo Gemfile/MissingFeatureCategory # Authentication libraries -gem 'devise', '~> 4.8.1' # rubocop:todo Gemfile/MissingFeatureCategory +gem 'devise', '~> 4.9.3', feature_category: :system_access gem 'devise-pbkdf2-encryptable', '~> 0.0.0', path: 'vendor/gems/devise-pbkdf2-encryptable' # rubocop:todo Gemfile/MissingFeatureCategory gem 'bcrypt', '~> 3.1', '>= 3.1.14' # rubocop:todo Gemfile/MissingFeatureCategory gem 'doorkeeper', '~> 5.6', '>= 5.6.6' # rubocop:todo Gemfile/MissingFeatureCategory @@ -208,7 +208,7 @@ gem 'deckar01-task_list', '2.3.3' # rubocop:todo Gemfile/MissingFeatureCategory gem 'gitlab-markup', '~> 1.9.0', require: 'github/markup' # rubocop:todo Gemfile/MissingFeatureCategory gem 'commonmarker', '~> 0.23.10' # rubocop:todo Gemfile/MissingFeatureCategory gem 'kramdown', '~> 2.3.1' # rubocop:todo Gemfile/MissingFeatureCategory -gem 'RedCloth', '~> 4.3.2' # rubocop:todo Gemfile/MissingFeatureCategory +gem 'RedCloth', '~> 4.3.3' # rubocop:todo Gemfile/MissingFeatureCategory gem 'org-ruby', '~> 0.9.12' # rubocop:todo Gemfile/MissingFeatureCategory gem 'creole', '~> 0.5.0' # rubocop:todo Gemfile/MissingFeatureCategory gem 'wikicloth', '0.8.1' # rubocop:todo Gemfile/MissingFeatureCategory diff --git a/Gemfile.checksum b/Gemfile.checksum index 8b2da2ba7c8..59a1324ce23 100644 --- a/Gemfile.checksum +++ b/Gemfile.checksum @@ -1,6 +1,6 @@ [ {"name":"CFPropertyList","version":"3.0.5","platform":"ruby","checksum":"a78551cd4768d78ebca98488c27e33652ef818be64697a54676d34e6434674a4"}, -{"name":"RedCloth","version":"4.3.2","platform":"ruby","checksum":"1ee7bc55c8dcec92cf7741a2132a9a6cd19e4b884fbc1b3aca23e1a4fcd92d55"}, +{"name":"RedCloth","version":"4.3.3","platform":"ruby","checksum":"d941b8ac96e2730d2d9326d97dda9fcf64cb73532b3f902d91c18970c5f4632d"}, {"name":"acme-client","version":"2.0.11","platform":"ruby","checksum":"edf6da9f3c5dbe3ab0c6738eb3b97978b7a60e3500445480d2a72fcc610089de"}, {"name":"actioncable","version":"7.0.8","platform":"ruby","checksum":"1f504ddb4ab6a34f7c52e9df924441a403e9f358bace330c36dcca6358ecfb84"}, {"name":"actionmailbox","version":"7.0.8","platform":"ruby","checksum":"9420037b801e44aa4e36cf113f4bd6eb25c17eb1b84d9c8865e8abf8846c14e5"}, @@ -115,7 +115,7 @@ {"name":"devfile","version":"0.0.24.pre.alpha1","platform":"ruby","checksum":"72bbfc26edb519902d5c68e07188e0a3d699a1866392fa1497e5b7f3abb36600"}, {"name":"devfile","version":"0.0.24.pre.alpha1","platform":"x86_64-linux","checksum":"d121b1094aa3a24c29592a83c629ee640920e0196711dd06f27b6fa9b1ced609"}, {"name":"device_detector","version":"1.0.0","platform":"ruby","checksum":"b800fb3150b00c23e87b6768011808ac1771fffaae74c3238ebaf2b782947a7d"}, -{"name":"devise","version":"4.8.1","platform":"ruby","checksum":"fdd48bbe79a89e7c1152236a70479842ede48bea4fa7f4f2d8da1f872559803e"}, +{"name":"devise","version":"4.9.3","platform":"ruby","checksum":"480638d6c51b97f56da6e28d4f3e2a1b8e606681b316aa594b87c6ab94923488"}, {"name":"devise-two-factor","version":"4.1.1","platform":"ruby","checksum":"c95f5b07533e62217aaed3c386874d94e2d472fb5f2b6598afe8600fc17a8b95"}, {"name":"diff-lcs","version":"1.5.0","platform":"ruby","checksum":"49b934001c8c6aedb37ba19daec5c634da27b318a7a3c654ae979d6ba1929b67"}, {"name":"diff_match_patch","version":"0.1.0","platform":"ruby","checksum":"b36057bfcfeaedf19dcb7b2c28c19ee625bd6ec6d0d182717d3ef22b3879c40e"}, diff --git a/Gemfile.lock b/Gemfile.lock index 6f94a4f275e..55d6fe0dd0e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -28,6 +28,7 @@ PATH specs: gitlab-http (0.1.0) activesupport (~> 7) + concurrent-ruby (~> 1.2) httparty (~> 0.21.0) ipaddress (~> 0.8.3) nokogiri (~> 1.15.4) @@ -161,7 +162,7 @@ GEM specs: CFPropertyList (3.0.5) rexml - RedCloth (4.3.2) + RedCloth (4.3.3) acme-client (2.0.11) faraday (>= 1.0, < 3.0.0) faraday-retry (~> 1.0) @@ -444,7 +445,7 @@ GEM thread_safe (~> 0.3, >= 0.3.1) devfile (0.0.24.pre.alpha1) device_detector (1.0.0) - devise (4.8.1) + devise (4.9.3) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) @@ -1743,7 +1744,7 @@ PLATFORMS DEPENDENCIES CFPropertyList (~> 3.0.0) - RedCloth (~> 4.3.2) + RedCloth (~> 4.3.3) acme-client (~> 2.0) activerecord-explain-analyze (~> 0.1) activerecord-gitlab! @@ -1799,7 +1800,7 @@ DEPENDENCIES derailed_benchmarks devfile (~> 0.0.24.pre.alpha1) device_detector - devise (~> 4.8.1) + devise (~> 4.9.3) devise-pbkdf2-encryptable (~> 0.0.0)! devise-two-factor (~> 4.1.1) diff_match_patch (~> 0.1.0) diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js index 070ce38c8aa..d97f11a0acd 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js @@ -72,22 +72,20 @@ export const fetchDrafts = ({ commit, getters, state, dispatch }) => }), ); -export const publishSingleDraft = ({ commit, dispatch, getters }, draftId) => { +export const publishSingleDraft = ({ commit, getters }, draftId) => { commit(types.REQUEST_PUBLISH_DRAFT, draftId); service .publishDraft(getters.getNotesData.draftsPublishPath, draftId) - .then(() => dispatch('updateDiscussionsAfterPublish')) .then(() => commit(types.RECEIVE_PUBLISH_DRAFT_SUCCESS, draftId)) .catch(() => commit(types.RECEIVE_PUBLISH_DRAFT_ERROR, draftId)); }; -export const publishReview = ({ commit, dispatch, getters }, noteData = {}) => { +export const publishReview = ({ commit, getters }, noteData = {}) => { commit(types.REQUEST_PUBLISH_REVIEW); return service .publish(getters.getNotesData.draftsPublishPath, noteData) - .then(() => dispatch('updateDiscussionsAfterPublish')) .then(() => commit(types.RECEIVE_PUBLISH_REVIEW_SUCCESS)) .catch((e) => { commit(types.RECEIVE_PUBLISH_REVIEW_ERROR); @@ -96,18 +94,6 @@ export const publishReview = ({ commit, dispatch, getters }, noteData = {}) => { }); }; -export const updateDiscussionsAfterPublish = async ({ dispatch, getters, rootGetters }) => { - await dispatch( - 'fetchDiscussions', - { path: getters.getNotesData.discussionsPath }, - { root: true }, - ); - - dispatch('diffs/assignDiscussionsToDiff', rootGetters.discussionsStructuredByLineCode, { - root: true, - }); -}; - export const updateDraft = ( { commit, getters }, { note, noteText, resolveDiscussion, position, flashContainer, callback, errorCallback }, diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/graph_component.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/graph_component.vue index fce7aabf0cf..3da2f27c1b9 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/graph_component.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/graph_component.vue @@ -248,7 +248,6 @@ export default { <template #downstream> <linked-pipelines-column v-if="showDownstreamPipelines" - class="gl-mr-5" :class="{ 'gl-sm-ml-3': isNewPipelineGraph }" :config-paths="configPaths" :linked-pipelines="downstreamPipelines" diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_graph_wrapper.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_graph_wrapper.vue index 9bd0ec6d793..0d72373a0f5 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_graph_wrapper.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_graph_wrapper.vue @@ -11,7 +11,10 @@ export default { }; </script> <template> - <div class="gl-display-flex" :class="{ 'gl-flex-wrap gl-w-full': isNewPipelineGraph }"> + <div + class="gl-display-flex" + :class="{ 'gl-flex-wrap gl-sm-flex-nowrap gl-w-full': isNewPipelineGraph }" + > <slot name="upstream"></slot> <slot name="main"></slot> <slot name="downstream"></slot> diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipelines_column.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipelines_column.vue index 67918ea8d1a..c715d6af28a 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipelines_column.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipelines_column.vue @@ -74,7 +74,7 @@ export default { left: 'gl-mx-6', }; const positionValues = { - right: 'gl-ml-5', + right: 'gl-mx-5', left: 'gl-mx-4 gl-flex-basis-full', }; const usePositionValues = this.isNewPipelineGraph ? positionValues : positionValuesOld; diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue index e144b9aab0c..01a9c6d030d 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue @@ -179,7 +179,7 @@ export default { <template #stages> <div data-testid="stage-column-title" - class="gl-display-flex gl-justify-content-space-between gl-relative" + class="stage-column-title gl-display-flex gl-justify-content-space-between gl-relative" :class="titleClasses" > <span :title="name" class="gl-text-truncate gl-pr-3 gl-w-85p"> diff --git a/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue b/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue index 5c1841615ab..dc4a2d91c84 100644 --- a/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue +++ b/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue @@ -403,12 +403,7 @@ export default { {{ commitTitle }} </h3> <div> - <ci-icon - :status="detailedStatus" - show-status-text - :show-link="false" - class="gl-display-inline-block gl-mb-3" - /> + <ci-icon :status="detailedStatus" show-status-text :show-link="false" class="gl-mb-3" /> <div class="gl-ml-2 gl-mb-3 gl-display-inline-block gl-h-6"> <gl-link v-if="user" diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 7c3d6dc8c42..9971d3bf7f8 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -366,22 +366,11 @@ export default { handleLocationHash(); this.autoScrolled = true; }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); - this.unwatchDiscussions = this.$watch( - () => `${this.flatBlobsList.length}:${this.$store.state.notes.discussions.length}`, - () => { - this.setDiscussions(); - - if (this.$store.state.notes.doneFetchingBatchDiscussions) { - this.unwatchDiscussions(); - } - }, - ); - - this.unwatchRetrievingBatches = this.$watch( - () => `${this.retrievingBatches}:${this.$store.state.notes.discussions.length}`, - () => { - if (!this.retrievingBatches && this.$store.state.notes.discussions.length) { - this.unwatchRetrievingBatches(); + this.$watch( + () => this.$store.state.notes.discussions.length, + (newVal, prevVal) => { + if (newVal > prevVal) { + this.setDiscussions(); } }, ); diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue index 8915f32eadf..556f72059c2 100644 --- a/app/assets/javascripts/diffs/components/diff_discussions.vue +++ b/app/assets/javascripts/diffs/components/diff_discussions.vue @@ -39,12 +39,6 @@ export default { }, methods: { ...mapActions(['toggleDiscussion']), - ...mapActions('diffs', ['removeDiscussionsFromDiff']), - deleteNoteHandler(discussion) { - if (discussion.notes.length <= 1) { - this.removeDiscussionsFromDiff(discussion); - } - }, isExpanded(discussion) { return this.shouldCollapseDiscussions ? discussion.expanded : true; }, @@ -90,7 +84,6 @@ export default { :line="line" :help-page-path="helpPagePath" :should-scroll-to-note="false" - @noteDeleted="deleteNoteHandler" > <template v-if="renderAvatarBadge" #avatar-badge> <design-note-pin diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index d86a88f97b8..756f76569dc 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -419,7 +419,11 @@ export const assignDiscussionsToDiff = ( }; export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => { - const { file_hash: fileHash, line_code: lineCode, id } = removeDiscussion; + const { + diff_file: { file_hash: fileHash }, + line_code: lineCode, + id, + } = removeDiscussion; commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash, lineCode, id }); }; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 31369b169f5..a9a2c35faa4 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -198,9 +198,10 @@ export default { return { ...line, discussionsExpanded: - line.discussions && line.discussions.length + line.discussionsExpanded || + (line.discussions && line.discussions.length ? line.discussions.some((disc) => !disc.resolved) || isLineNoteTargeted - : false, + : false), }; }; diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 15d2ab71bc8..fb467a606b9 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -338,7 +338,7 @@ function prepareLine(line, file) { problems.brokenSymlink || problems.fileOnlyMoved || problems.brokenLineCode, ), rich_text: cleanRichText(line.rich_text), - discussionsExpanded: true, + discussionsExpanded: false, discussions: [], hasForm: false, text: undefined, diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index 795cbf5327a..fd5fcb12cc5 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -112,6 +112,9 @@ export default { canSetupReviewApp() { return this.environmentApp?.reviewApp?.canSetupReviewApp; }, + hasReviewApp() { + return this.environmentApp?.reviewApp?.hasReviewApp; + }, canCleanUpEnvs() { return this.environmentApp?.canStopStaleEnvironments; }, @@ -157,7 +160,10 @@ export default { }; }, openReviewAppModal() { - if (!this.canSetupReviewApp) { + // we don't show the Enable review apps button + // if a user cannot setup a review app or review + // apps are already configured + if (!this.canSetupReviewApp || this.hasReviewApp) { return null; } diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue index 2ee7b604253..126a3a84d66 100644 --- a/app/assets/javascripts/issuable/components/related_issuable_item.vue +++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue @@ -194,7 +194,7 @@ export default { <div class="item-attributes-area gl-display-flex gl-align-items-center gl-flex-wrap gl-gap-3" > - <span v-if="hasPipeline" class="mr-ci-status order-md-last"> + <span v-if="hasPipeline" class="mr-ci-status order-md-last gl-md-ml-3 gl-mr-n2"> <a :href="pipelinePath"> <ci-icon :status="pipelineStatus" :title="pipelineStatusTooltip" /> </a> @@ -203,7 +203,7 @@ export default { <issue-milestone v-if="hasMilestone" :milestone="milestone" - class="item-milestone gl-font-sm gl-display-flex gl-align-items-center order-md-first" + class="item-milestone gl-font-sm gl-display-flex gl-align-items-center order-md-first gl-ml-2" /> <!-- Flex order for slots is defined in the parent component: e.g. related_issues_block.vue --> diff --git a/app/assets/javascripts/issuable/components/status_badge.vue b/app/assets/javascripts/issuable/components/status_badge.vue index 949fb3c1ce5..35f6446d582 100644 --- a/app/assets/javascripts/issuable/components/status_badge.vue +++ b/app/assets/javascripts/issuable/components/status_badge.vue @@ -14,29 +14,29 @@ import { const badgePropertiesMap = { [TYPE_EPIC]: { [STATUS_OPEN]: { - icon: 'epic', + icon: 'issue-open-m', text: __('Open'), variant: 'success', }, [STATUS_CLOSED]: { - icon: 'epic-closed', + icon: 'issue-close', text: __('Closed'), variant: 'info', }, }, [TYPE_ISSUE]: { [STATUS_OPEN]: { - icon: 'issues', + icon: 'issue-open-m', text: __('Open'), variant: 'success', }, [STATUS_CLOSED]: { - icon: 'issue-closed', + icon: 'issue-close', text: __('Closed'), variant: 'info', }, [STATUS_LOCKED]: { - icon: 'issues', + icon: 'issue-open-m', text: __('Open'), variant: 'success', }, diff --git a/app/assets/javascripts/issues/show/components/issue_header.vue b/app/assets/javascripts/issues/show/components/issue_header.vue index c205a6361c7..96eb8fbb3c7 100644 --- a/app/assets/javascripts/issues/show/components/issue_header.vue +++ b/app/assets/javascripts/issues/show/components/issue_header.vue @@ -82,7 +82,7 @@ export default { return this.issuableState === STATUS_OPEN || this.issuableState === STATUS_REOPENED; }, statusIcon() { - return this.isOpen ? 'issues' : 'issue-closed'; + return this.isOpen ? 'issue-open-m' : 'issue-close'; }, statusText() { if (this.isOpen) { diff --git a/app/assets/javascripts/issues/show/components/sticky_header.vue b/app/assets/javascripts/issues/show/components/sticky_header.vue index d75f6c75ba5..18e37c4216c 100644 --- a/app/assets/javascripts/issues/show/components/sticky_header.vue +++ b/app/assets/javascripts/issues/show/components/sticky_header.vue @@ -2,12 +2,7 @@ import { GlBadge, GlIcon, GlIntersectionObserver, GlLink } from '@gitlab/ui'; import HiddenBadge from '~/issuable/components/hidden_badge.vue'; import LockedBadge from '~/issuable/components/locked_badge.vue'; -import { - issuableStatusText, - STATUS_CLOSED, - TYPE_EPIC, - WORKSPACE_PROJECT, -} from '~/issues/constants'; +import { issuableStatusText, STATUS_CLOSED, WORKSPACE_PROJECT } from '~/issues/constants'; import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; export default { @@ -60,10 +55,7 @@ export default { return this.issuableStatus === STATUS_CLOSED; }, statusIcon() { - if (this.issuableType === TYPE_EPIC) { - return this.isClosed ? 'epic-closed' : 'epic'; - } - return this.isClosed ? 'issue-closed' : 'issues'; + return this.isClosed ? 'issue-close' : 'issue-open-m'; }, statusText() { return issuableStatusText[this.issuableStatus]; diff --git a/app/assets/javascripts/lib/utils/color_utils.js b/app/assets/javascripts/lib/utils/color_utils.js index a9f4257e28b..74c9f7de8c1 100644 --- a/app/assets/javascripts/lib/utils/color_utils.js +++ b/app/assets/javascripts/lib/utils/color_utils.js @@ -46,5 +46,5 @@ export function darkModeEnabled() { if (isWebIde) { return ideDarkThemes.includes(window.gon?.user_color_scheme); } - return document.body.classList.contains('gl-dark'); + return document.documentElement.classList.contains('gl-dark'); } diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index e0b1f7a8c6a..493beb8cea9 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -290,9 +290,6 @@ export default { parent: this.$el, }); }, - deleteNoteHandler(note) { - this.$emit('noteDeleted', this.discussion, note); - }, onStartReplying(discussionId) { if (this.discussion.id === discussionId) { this.showReplyForm(); @@ -329,7 +326,6 @@ export default { :is-overview-tab="isOverviewTab" :should-scroll-to-note="shouldScrollToNote" @startReplying="showReplyForm" - @deleteNote="deleteNoteHandler" > <template #avatar-badge> <slot name="avatar-badge"></slot> diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 966f4184780..a995b9fa214 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -318,11 +318,6 @@ export default { const note = noteData; const selectedDiscussion = state.discussions.find((disc) => disc.id === note.id); note.expanded = true; // override expand flag to prevent collapse - if (note.diff_file) { - Object.assign(note, { - file_hash: note.diff_file.file_hash, - }); - } Object.assign(selectedDiscussion, { ...note }); }, diff --git a/app/assets/javascripts/observability/client.js b/app/assets/javascripts/observability/client.js index da8837c21da..54ca8311621 100644 --- a/app/assets/javascripts/observability/client.js +++ b/app/assets/javascripts/observability/client.js @@ -246,22 +246,18 @@ async function fetchOperations(operationsUrl, serviceName) { } } -async function fetchMetrics() { - // TODO replace mocks with API calls https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2469 - /* eslint-disable @gitlab/require-i18n-strings */ - return { - metrics: [ - { name: 'metric A', description: 'a counter metric called A', type: 'COUNTER' }, - { name: 'metric B', description: 'a gauge metric called B', type: 'GAUGE' }, - { name: 'metric C', description: 'a histogram metric called C', type: 'HISTOGRAM' }, - { - name: 'metric D', - description: 'a exp histogram metric called D', - type: 'EXPONENTIAL HISTOGRAM', - }, - ], - }; - /* eslint-enable @gitlab/require-i18n-strings */ +async function fetchMetrics(metricsUrl) { + try { + const { data } = await axios.get(metricsUrl, { + withCredentials: true, + }); + if (!Array.isArray(data.metrics)) { + throw new Error('metrics are missing/invalid in the response'); // eslint-disable-line @gitlab/require-i18n-strings + } + return data; + } catch (e) { + return reportErrorAndThrow(e); + } } export function buildClient(options) { diff --git a/app/assets/javascripts/organizations/users/graphql/organization_users.query.graphql b/app/assets/javascripts/organizations/users/graphql/organization_users.query.graphql index 7b37186ba1a..a0b2a639401 100644 --- a/app/assets/javascripts/organizations/users/graphql/organization_users.query.graphql +++ b/app/assets/javascripts/organizations/users/graphql/organization_users.query.graphql @@ -3,7 +3,10 @@ query getOrganizationUsers($id: OrganizationsOrganizationID!) { id organizationUsers { nodes { - badges + badges { + text + variant + } id user { id diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index c03c00c06aa..bba8e1f7ba5 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -23,6 +23,7 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-geo-migrate-hashed-storage-callout', '.js-unlimited-members-during-trial-alert', '.js-branch-rules-info-callout', + '.js-new-nav-for-everyone-callout', '.js-namespace-over-storage-users-combined-alert', ]; diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue index aa30192b74b..2fc1f99c183 100644 --- a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue +++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue @@ -5,9 +5,9 @@ import { INTEGRATION_VIEW_CONFIGS, i18n } from '../constants'; import IntegrationView from './integration_view.vue'; function updateClasses(bodyClasses = '', applicationTheme, layout) { - // Remove body class for any previous theme, re-add current one - document.body.classList.remove(...bodyClasses.split(' ')); - document.body.classList.add(applicationTheme); + // Remove documentElement class for any previous theme, re-add current one + document.documentElement.classList.remove(...bodyClasses.split(' ')); + document.documentElement.classList.add(applicationTheme); // Toggle container-fluid class if (layout === 'fluid') { diff --git a/app/assets/javascripts/projects/settings/init_access_dropdown.js b/app/assets/javascripts/projects/settings/init_access_dropdown.js index 102b1846453..b02a33675ee 100644 --- a/app/assets/javascripts/projects/settings/init_access_dropdown.js +++ b/app/assets/javascripts/projects/settings/init_access_dropdown.js @@ -22,6 +22,7 @@ export const initAccessDropdown = (el, options) => { data() { return { preselected }; }, + disabled, methods: { setPreselectedItems(items) { this.preselected = items; diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js index 29034b3bc0e..66da3de516a 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_edit.js +++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js @@ -6,6 +6,10 @@ import { initToggle } from '~/toggles'; import { initAccessDropdown } from '~/projects/settings/init_access_dropdown'; import { ACCESS_LEVELS, LEVEL_TYPES } from './constants'; +const isDropdownDisabled = (dropdown) => { + return dropdown?.$options.disabled === ''; +}; + export default class ProtectedBranchEdit { constructor(options) { this.hasLicense = options.hasLicense; @@ -104,6 +108,9 @@ export default class ProtectedBranchEdit { } initSelectedItems(dropdown, accessLevel) { + if (isDropdownDisabled(dropdown)) { + return; + } this.selectedItems[accessLevel] = dropdown.preselected.map((item) => { if (item.type === LEVEL_TYPES.USER) return { id: item.id, user_id: item.user_id }; if (item.type === LEVEL_TYPES.ROLE) return { id: item.id, access_level: item.access_level }; @@ -183,7 +190,10 @@ export default class ProtectedBranchEdit { }; }); - this.selectedItems[accessLevel] = itemsToAdd; - this[`${accessLevel}_dropdown`]?.setPreselectedItems(itemsToAdd); + const dropdown = this[`${accessLevel}_dropdown`]; + if (!isDropdownDisabled(dropdown)) { + this.selectedItems[accessLevel] = itemsToAdd; + dropdown?.setPreselectedItems(itemsToAdd); + } } } diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue index 891e883b6c0..5712b716f48 100644 --- a/app/assets/javascripts/super_sidebar/components/user_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue @@ -8,7 +8,6 @@ import { } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__, __, sprintf } from '~/locale'; -import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; import Tracking from '~/tracking'; import PersistentUserCallout from '~/persistent_user_callout'; import { USER_MENU_TRACKING_DEFAULTS, DROPDOWN_Y_OFFSET, IMPERSONATING_OFFSET } from '../constants'; @@ -39,14 +38,13 @@ export default { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlButton, - NewNavToggle, UserMenuProfileItem, }, directives: { SafeHtml, }, mixins: [Tracking.mixin()], - inject: ['toggleNewNavEndpoint', 'isImpersonating'], + inject: ['isImpersonating'], props: { data: { required: true, @@ -301,13 +299,6 @@ export default { /> </gl-disclosure-dropdown-group> - <gl-disclosure-dropdown-group bordered> - <template #group-label> - <span class="gl-font-sm">{{ $options.i18n.newNavigation.sectionTitle }}</span> - </template> - <new-nav-toggle :endpoint="toggleNewNavEndpoint" enabled new-navigation /> - </gl-disclosure-dropdown-group> - <gl-disclosure-dropdown-group v-if="data.can_sign_out" bordered diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js index d0d98ef3808..9e540175b48 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js @@ -66,13 +66,7 @@ export const initSuperSidebar = () => { if (!el) return false; - const { - rootPath, - sidebar, - toggleNewNavEndpoint, - forceDesktopExpandedSidebar, - commandPalette, - } = el.dataset; + const { rootPath, sidebar, forceDesktopExpandedSidebar, commandPalette } = el.dataset; bindSuperSidebarCollapsedEvents(forceDesktopExpandedSidebar); initSuperSidebarCollapsedState(parseBoolean(forceDesktopExpandedSidebar)); @@ -98,7 +92,6 @@ export const initSuperSidebar = () => { name: 'SuperSidebarRoot', provide: { rootPath, - toggleNewNavEndpoint, isImpersonating, ...getTrialStatusWidgetData(sidebarData), commandPaletteCommands, diff --git a/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue b/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue index f62bfb551df..70daac311c7 100644 --- a/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue @@ -1,11 +1,5 @@ <script> -import { - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, - GlSprintf, -} from '@gitlab/ui'; +import { GlDisclosureDropdown, GlIcon, GlSearchBoxByType, GlSprintf } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { __, n__, s__, sprintf } from '~/locale'; @@ -16,12 +10,16 @@ export const i18n = { searchFiles: __('Search files'), }; +const variantCssColorMap = { + success: 'gl-text-green-500', + danger: 'gl-text-red-500', +}; + export default { i18n, components: { - GlDropdown, - GlDropdownItem, - GlDropdownText, + GlDisclosureDropdown, + GlIcon, GlSearchBoxByType, GlSprintf, }, @@ -54,6 +52,15 @@ export default { ? fuzzaldrinPlus.filter(this.files, this.search, { key: 'name' }) : this.files; }, + dropdownItems() { + return this.filteredFiles.map((file) => { + return { + ...file, + text: file.name || this.$options.i18n.noFileNameAvailable, + iconColor: variantCssColorMap[file.iconColor], + }; + }); + }, messageChanged() { return sprintf( n__( @@ -64,21 +71,21 @@ export default { { count: this.changed }, ); }, - - additionsText() { - return n__('Diffs|%d addition', 'Diffs|%d additions', this.added); - }, - deletionsText() { - return n__('Diffs|%d deletion', 'Diffs|%d deletions', this.deleted); - }, }, methods: { - jumpToFile(fileHash) { - window.location.hash = fileHash; - }, focusInput() { this.$refs.search.focusInput(); }, + focusFirstItem() { + if (!this.filteredFiles.length) return; + this.$el.querySelector('.gl-new-dropdown-item:first-child').focus(); + }, + additionsText(numberOfChanges = this.added) { + return n__('Diffs|%d addition', 'Diffs|%d additions', numberOfChanges); + }, + deletionsText(numberOfChanges = this.deleted) { + return n__('Diffs|%d deletion', 'Diffs|%d deletions', numberOfChanges); + }, }, }; </script> @@ -87,15 +94,15 @@ export default { <div> <gl-sprintf :message="messageChanged"> <template #dropdown="{ content: dropdownText }"> - <gl-dropdown + <gl-disclosure-dropdown + :toggle-text="dropdownText" + :items="dropdownItems" category="tertiary" variant="confirm" - :text="dropdownText" data-testid="diff-stats-dropdown" class="gl-vertical-align-baseline" toggle-class="gl-px-0! gl-font-weight-bold!" - menu-class="gl-w-auto!" - no-flip + fluid-width @shown="focusInput" > <template #header> @@ -103,35 +110,38 @@ export default { ref="search" v-model.trim="search" :placeholder="$options.i18n.searchFiles" + class="gl-mx-3 gl-my-4" + @keydown.down="focusFirstItem" /> + <span v-if="!filteredFiles.length" class="gl-mx-3"> + {{ $options.i18n.noFilesFound }} + </span> </template> - <gl-dropdown-item - v-for="file in filteredFiles" - :key="file.href" - :icon-name="file.icon" - :icon-color="file.iconColor" - @click="jumpToFile(file.href)" - > - <div class="gl-display-flex"> - <span v-if="file.name" class="gl-font-weight-bold gl-mr-3 gl-text-truncate">{{ - file.name - }}</span> - <span v-else class="gl-mr-3 gl-font-weight-bold gl-font-style-italic gl-gray-400">{{ - $options.i18n.noFileNameAvailable - }}</span> - <span class="gl-ml-auto gl-white-space-nowrap"> - <span class="gl-text-green-600">+{{ file.added }}</span> - <span class="gl-text-red-500">-{{ file.removed }}</span> - </span> + <template #list-item="{ item }"> + <div class="gl-display-flex gl-gap-3 gl-align-items-center"> + <gl-icon :name="item.icon" :class="item.iconColor" /> + <div class="gl-flex-grow-1"> + <div class="gl-display-flex"> + <span + class="gl-font-weight-bold gl-mr-3 gl-flex-grow-1" + :class="item.name ? 'gl-text-truncate' : 'gl-font-style-italic gl-gray-400'" + >{{ item.text }}</span + > + <span class="gl-ml-auto gl-white-space-nowrap" aria-hidden="true"> + <span class="gl-text-green-600">+{{ item.added }}</span> + <span class="gl-text-red-500">-{{ item.removed }}</span> + </span> + <span class="gl-sr-only" + >{{ additionsText(item.added) }}, {{ deletionsText(item.removed) }}</span + > + </div> + <div class="gl-text-gray-700 gl-overflow-hidden gl-text-overflow-ellipsis"> + {{ item.path }} + </div> + </div> </div> - <div class="gl-text-gray-700 gl-overflow-hidden gl-text-overflow-ellipsis"> - {{ file.path }} - </div> - </gl-dropdown-item> - <gl-dropdown-text v-if="!filteredFiles.length"> - {{ $options.i18n.noFilesFound }} - </gl-dropdown-text> - </gl-dropdown> + </template> + </gl-disclosure-dropdown> </template> </gl-sprintf> <span @@ -140,12 +150,20 @@ export default { > <gl-sprintf :message="$options.i18n.messageAdditionsDeletions"> <template #additions> - <span class="gl-text-green-600 gl-font-weight-bold">{{ additionsText }}</span> + <span class="gl-text-green-600 gl-font-weight-bold">{{ additionsText() }}</span> </template> <template #deletions> - <span class="gl-text-red-500 gl-font-weight-bold">{{ deletionsText }}</span> + <span class="gl-text-red-500 gl-font-weight-bold">{{ deletionsText() }}</span> </template> </gl-sprintf> </span> </div> </template> + +<style scoped> +/* TODO: Use max-height prop when gitlab-ui got updated. +See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2374 */ +::v-deep .gl-new-dropdown-inner { + max-height: 310px; +} +</style> diff --git a/app/assets/javascripts/vue_shared/components/list_selector/constants.js b/app/assets/javascripts/vue_shared/components/list_selector/constants.js index 2e58527a2ea..cff9c56a1c0 100644 --- a/app/assets/javascripts/vue_shared/components/list_selector/constants.js +++ b/app/assets/javascripts/vue_shared/components/list_selector/constants.js @@ -1,6 +1,6 @@ import { __ } from '~/locale'; export const CONFIG = { - users: { title: __('Users'), icon: 'user', filterKey: 'username' }, + users: { title: __('Users'), icon: 'user', filterKey: 'username', showNamespaceDropdown: true }, groups: { title: __('Groups'), icon: 'group', filterKey: 'name' }, }; diff --git a/app/assets/javascripts/vue_shared/components/list_selector/index.vue b/app/assets/javascripts/vue_shared/components/list_selector/index.vue index 6813d9ca077..b8480a0c496 100644 --- a/app/assets/javascripts/vue_shared/components/list_selector/index.vue +++ b/app/assets/javascripts/vue_shared/components/list_selector/index.vue @@ -1,13 +1,23 @@ <script> import { GlCard, GlIcon, GlCollapsibleListbox, GlSearchBoxByType } from '@gitlab/ui'; -import usersAutocompleteQuery from '~/graphql_shared/queries/users_autocomplete.query.graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { createAlert } from '~/alert'; +import { __ } from '~/locale'; import groupsAutocompleteQuery from '~/graphql_shared/queries/groups_autocomplete.query.graphql'; +import Api from '~/api'; import UserItem from './user_item.vue'; import GroupItem from './group_item.vue'; import { CONFIG } from './constants'; +const I18N = { + allGroups: __('All groups'), + projectGroups: __('Project groups'), + apiErrorMessage: __('An error occurred while fetching. Please try again.'), +}; + export default { name: 'ListSelector', + i18n: I18N, components: { GlCard, GlIcon, @@ -33,11 +43,16 @@ export default { required: false, default: null, }, + groupPath: { + type: String, + required: false, + default: null, + }, }, data() { return { searchValue: '', - isProject: true, // TODO: implement a way to distinguish between project/group + isProjectNamespace: 'true', selected: [], items: [], }; @@ -46,39 +61,44 @@ export default { config() { return CONFIG[this.type]; }, - searchItems() { - return this.items; - }, isUserVariant() { return this.type === 'users'; }, component() { return this.isUserVariant ? UserItem : GroupItem; }, + namespaceDropdownText() { + return parseBoolean(this.isProjectNamespace) + ? this.$options.i18n.projectGroups + : this.$options.i18n.allGroups; + }, }, methods: { async handleSearchInput(search) { this.$refs.results.open(); - if (this.isUserVariant) { - this.items = await this.fetchUsersBySearchTerm(search); - } else { - this.items = await this.fetchGroupsBySearchTerm(search); + + try { + if (this.isUserVariant) { + this.items = await this.fetchUsersBySearchTerm(search); + } else { + this.items = await this.fetchGroupsBySearchTerm(search); + } + } catch (e) { + createAlert({ + message: this.$options.i18n.apiErrorMessage, + }); } }, - fetchUsersBySearchTerm(search) { - const namespace = this.isProject ? 'project' : 'group'; - return this.$apollo - .query({ - query: usersAutocompleteQuery, - variables: { fullPath: this.projectPath, search, isProject: this.isProject }, - }) - .then(({ data }) => - data[namespace]?.autocompleteUsers.map((user) => ({ - text: user.name, - value: user.username, - ...user, - })), - ); + async fetchUsersBySearchTerm(search) { + let users = []; + if (parseBoolean(this.isProjectNamespace)) { + users = await Api.projectUsers(this.projectPath, search); + } else { + const groupMembers = await Api.groupMembers(this.groupPath, { query: search }); + users = groupMembers?.data || []; + } + + return users?.map((user) => ({ text: user.name, value: user.username, ...user })); }, fetchGroupsBySearchTerm(search) { return this.$apollo @@ -95,7 +115,7 @@ export default { ); }, getItemByKey(key) { - return this.searchItems.find((item) => item[this.config.filterKey] === key); + return this.items.find((item) => item[this.config.filterKey] === key); }, handleSelectItem(key) { this.$emit('select', this.getItemByKey(key)); @@ -103,7 +123,15 @@ export default { handleDeleteItem(key) { this.$emit('delete', key); }, + handleSelectNamespace() { + this.items = []; + this.searchValue = ''; + }, }, + namespaceOptions: [ + { text: I18N.projectGroups, value: 'true' }, + { text: I18N.allGroups, value: 'false' }, + ], }; </script> @@ -118,28 +146,38 @@ export default { ></template > - <gl-collapsible-listbox - ref="results" - v-model="selected" - class="list-selector gl-mb-4 gl-display-block" - :items="searchItems" - multiple - @shown="$refs.search.focusInput()" - > - <template #toggle> - <gl-search-box-by-type - ref="search" - v-model="searchValue" - autofocus - debounce="500" - @input="handleSearchInput" - /> - </template> + <div class="gl-display-flex gl-gap-3" :class="{ 'gl-mb-4': selectedItems.length }"> + <gl-collapsible-listbox + ref="results" + v-model="selected" + class="list-selector gl-display-block gl-flex-grow-1" + :items="items" + multiple + @shown="$refs.search.focusInput()" + > + <template #toggle> + <gl-search-box-by-type + ref="search" + v-model="searchValue" + autofocus + debounce="500" + @input="handleSearchInput" + /> + </template> + + <template #list-item="{ item }"> + <component :is="component" :data="item" @select="handleSelectItem" /> + </template> + </gl-collapsible-listbox> - <template #list-item="{ item }"> - <component :is="component" :data="item" @select="handleSelectItem" /> - </template> - </gl-collapsible-listbox> + <gl-collapsible-listbox + v-if="config.showNamespaceDropdown" + v-model="isProjectNamespace" + :toggle-text="namespaceDropdownText" + :items="$options.namespaceOptions" + @select="handleSelectNamespace" + /> + </div> <component :is="component" diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue index 45fde45f516..dae3ddfe016 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue @@ -74,6 +74,11 @@ export default { required: false, default: 0, }, + workspaceType: { + type: String, + required: false, + default: '', + }, }, computed: { isUpdated() { @@ -161,6 +166,7 @@ export default { :issuable="issuable" :status-icon="statusIcon" :enable-edit="enableEdit" + :workspace-type="workspaceType" @edit-issuable="$emit('edit-issuable', $event)" > <template #status-badge> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue index 3878c16c8d0..040f49c7c25 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue @@ -147,6 +147,7 @@ export default { :description-help-path="descriptionHelpPath" :task-list-update-path="taskListUpdatePath" :task-list-lock-version="taskListLockVersion" + :workspace-type="workspaceType" @edit-issuable="$emit('edit-issuable', $event)" @task-list-update-success="$emit('task-list-update-success', $event)" @task-list-update-failure="$emit('task-list-update-failure')" diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue index 3aef7d141e0..5387e39e3eb 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue @@ -1,5 +1,6 @@ <script> import { GlIcon, GlBadge, GlButton, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui'; +import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { STATUS_OPEN } from '~/issues/constants'; import { __ } from '~/locale'; @@ -13,6 +14,7 @@ export default { GlBadge, GlButton, GlIntersectionObserver, + ConfidentialityBadge, }, directives: { GlTooltip: GlTooltipDirective, @@ -31,6 +33,11 @@ export default { type: Boolean, required: true, }, + workspaceType: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -89,6 +96,12 @@ export default { <slot name="status-badge"></slot> </span> </gl-badge> + <confidentiality-badge + v-if="issuable.confidential" + class="gl-white-space-nowrap gl-mr-3 gl-align-self-center" + :issuable-type="issuable.type" + :workspace-type="workspaceType" + /> <p class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0" :title="issuable.title" diff --git a/app/assets/javascripts/work_items/components/work_item_type_icon.vue b/app/assets/javascripts/work_items/components/work_item_type_icon.vue index 76a73093206..5426f3965b3 100644 --- a/app/assets/javascripts/work_items/components/work_item_type_icon.vue +++ b/app/assets/javascripts/work_items/components/work_item_type_icon.vue @@ -36,11 +36,6 @@ export default { return this.workItemType.toUpperCase().split(' ').join('_'); }, iconName() { - // TODO Delete this conditional once we have an `issue-type-epic` icon - if (this.workItemIconName === 'issue-type-epic') { - return 'epic'; - } - return ( this.workItemIconName || WORK_ITEMS_TYPE_MAP[this.workItemTypeUppercase]?.icon || diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 2f2401bd9b3..e2dbfeb55a5 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -35,6 +35,7 @@ export const WORK_ITEM_TYPE_ENUM_TEST_CASE = 'TEST_CASE'; export const WORK_ITEM_TYPE_ENUM_REQUIREMENTS = 'REQUIREMENTS'; export const WORK_ITEM_TYPE_ENUM_OBJECTIVE = 'OBJECTIVE'; export const WORK_ITEM_TYPE_ENUM_KEY_RESULT = 'KEY_RESULT'; +export const WORK_ITEM_TYPE_ENUM_EPIC = 'EPIC'; export const WORK_ITEM_TYPE_VALUE_EPIC = 'Epic'; export const WORK_ITEM_TYPE_VALUE_INCIDENT = 'Incident'; @@ -185,6 +186,11 @@ export const WORK_ITEMS_TYPE_MAP = { name: s__('WorkItem|Key result'), value: WORK_ITEM_TYPE_VALUE_KEY_RESULT, }, + [WORK_ITEM_TYPE_ENUM_EPIC]: { + icon: `epic`, + name: s__('WorkItem|Epic'), + value: WORK_ITEM_TYPE_VALUE_EPIC, + }, }; export const WORK_ITEMS_TREE_TEXT_MAP = { diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 613e504c771..eb627b036fe 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -247,6 +247,7 @@ span.idiff { border-bottom: 1px solid $border-color; padding: $gl-padding-8 $gl-padding; margin: 0; + min-height: px-to-rem(42px); border-radius: $border-radius-default $border-radius-default 0 0; @include media-breakpoint-up(md) { diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss index dcd8f90ab1c..aaec277cf08 100644 --- a/app/assets/stylesheets/page_bundles/pipeline.scss +++ b/app/assets/stylesheets/page_bundles/pipeline.scss @@ -169,6 +169,10 @@ } } +.stage-column-title .gl-ci-action-icon-container { + right: 11px; +} + .split-report-section { border-bottom: 1px solid var(--gray-50, $gray-50); @@ -269,7 +273,12 @@ .stage-column, .stage-column.is-stage-view { + min-width: 1px; + @media (min-width: $breakpoint-sm) { + min-width: inherit; + max-width: $gl-spacing-scale-48; + &:first-of-type { margin-left: $gl-spacing-scale-6; } diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss index a4a2cd28d05..c0eced48171 100644 --- a/app/assets/stylesheets/themes/dark_mode_overrides.scss +++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss @@ -105,7 +105,7 @@ --svg-status-bg: #{$white}; } -body.gl-dark { +:root.gl-dark { // redefine some colors and values to prevent sourcegraph conflicts color-scheme: dark; --gray-10: #{$gray-10}; @@ -239,7 +239,7 @@ aside.right-sidebar:not(.right-sidebar-merge-requests) { border-left-color: $gray-50; } -body.gl-dark { +:root.gl-dark { @include gitlab-theme($gray-900, $gray-400, $gray-500, $gray-900, $white); .terms { diff --git a/app/assets/stylesheets/themes/theme_blue.scss b/app/assets/stylesheets/themes/theme_blue.scss index 06f3e13e99e..749120a0ecb 100644 --- a/app/assets/stylesheets/themes/theme_blue.scss +++ b/app/assets/stylesheets/themes/theme_blue.scss @@ -1,6 +1,6 @@ @import './theme_helper'; -body { +:root { &.ui-blue { @include gitlab-theme( $theme-blue-200, diff --git a/app/assets/stylesheets/themes/theme_gray.scss b/app/assets/stylesheets/themes/theme_gray.scss index 3112aaef227..70611e692cd 100644 --- a/app/assets/stylesheets/themes/theme_gray.scss +++ b/app/assets/stylesheets/themes/theme_gray.scss @@ -1,6 +1,6 @@ @import './theme_helper'; -body { +:root { &.ui-gray { @include gitlab-theme( $gray-200, diff --git a/app/assets/stylesheets/themes/theme_green.scss b/app/assets/stylesheets/themes/theme_green.scss index c9ea1162206..ae969873692 100644 --- a/app/assets/stylesheets/themes/theme_green.scss +++ b/app/assets/stylesheets/themes/theme_green.scss @@ -1,6 +1,6 @@ @import './theme_helper'; -body { +:root { &.ui-green { @include gitlab-theme( $theme-green-200, diff --git a/app/assets/stylesheets/themes/theme_indigo.scss b/app/assets/stylesheets/themes/theme_indigo.scss index 78ce96667d4..d7e8ddadf46 100644 --- a/app/assets/stylesheets/themes/theme_indigo.scss +++ b/app/assets/stylesheets/themes/theme_indigo.scss @@ -1,6 +1,6 @@ @import './theme_helper'; -body { +:root { &.ui-indigo { @include gitlab-theme( $indigo-200, diff --git a/app/assets/stylesheets/themes/theme_light_blue.scss b/app/assets/stylesheets/themes/theme_light_blue.scss index 73fe072393f..430960f563f 100644 --- a/app/assets/stylesheets/themes/theme_light_blue.scss +++ b/app/assets/stylesheets/themes/theme_light_blue.scss @@ -1,6 +1,6 @@ @import './theme_helper'; -body { +:root { &.ui-light-blue { @include gitlab-theme( $theme-light-blue-200, diff --git a/app/assets/stylesheets/themes/theme_light_gray.scss b/app/assets/stylesheets/themes/theme_light_gray.scss index e8357647f48..f63da3f22f1 100644 --- a/app/assets/stylesheets/themes/theme_light_gray.scss +++ b/app/assets/stylesheets/themes/theme_light_gray.scss @@ -1,6 +1,6 @@ @import './theme_helper'; -body { +:root { &.ui-light-gray { @include gitlab-theme( $gray-500, diff --git a/app/assets/stylesheets/themes/theme_light_green.scss b/app/assets/stylesheets/themes/theme_light_green.scss index 6b058b2dd7b..05adc56c36a 100644 --- a/app/assets/stylesheets/themes/theme_light_green.scss +++ b/app/assets/stylesheets/themes/theme_light_green.scss @@ -1,6 +1,6 @@ @import './theme_helper'; -body { +:root { &.ui-light-green { @include gitlab-theme( $theme-green-200, diff --git a/app/assets/stylesheets/themes/theme_light_indigo.scss b/app/assets/stylesheets/themes/theme_light_indigo.scss index ff12366466a..04bcfaf8366 100644 --- a/app/assets/stylesheets/themes/theme_light_indigo.scss +++ b/app/assets/stylesheets/themes/theme_light_indigo.scss @@ -1,6 +1,6 @@ @import './theme_helper'; -body { +:root { &.ui-light-indigo { @include gitlab-theme( $indigo-200, diff --git a/app/assets/stylesheets/themes/theme_light_red.scss b/app/assets/stylesheets/themes/theme_light_red.scss index 3ae67309014..c4952b8e155 100644 --- a/app/assets/stylesheets/themes/theme_light_red.scss +++ b/app/assets/stylesheets/themes/theme_light_red.scss @@ -1,6 +1,6 @@ @import './theme_helper'; -body { +:root { &.ui-light-red { @include gitlab-theme( $theme-light-red-200, diff --git a/app/assets/stylesheets/themes/theme_red.scss b/app/assets/stylesheets/themes/theme_red.scss index 82de30e8b0e..536963e12ef 100644 --- a/app/assets/stylesheets/themes/theme_red.scss +++ b/app/assets/stylesheets/themes/theme_red.scss @@ -1,6 +1,6 @@ @import './theme_helper'; -body { +:root { &.ui-red { @include gitlab-theme( $theme-red-200, diff --git a/app/controllers/groups/settings/applications_controller.rb b/app/controllers/groups/settings/applications_controller.rb index 3ae1ae824a0..5aea078db17 100644 --- a/app/controllers/groups/settings/applications_controller.rb +++ b/app/controllers/groups/settings/applications_controller.rb @@ -5,7 +5,7 @@ module Groups class ApplicationsController < Groups::ApplicationController include OauthApplications - prepend_before_action :authorize_admin_group! + before_action :authorize_admin_group! before_action :set_application, only: [:show, :edit, :update, :renew, :destroy] before_action :load_scopes, only: [:index, :create, :edit, :update] diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index 2828d17c36f..85bdeb07b00 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -62,7 +62,11 @@ class Projects::ArtifactsController < Projects::ApplicationController conditionally_expand_blob(blob) if blob.external_link?(build) - redirect_to external_file_project_job_artifacts_path(@project, @build, path: params[:path]) + if Gitlab::CurrentSettings.enable_artifact_external_redirect_warning_page + redirect_to external_file_project_job_artifacts_path(@project, @build, path: params[:path]) + else + redirect_to blob.external_url(build) + end else respond_to do |format| format.html do diff --git a/app/graphql/resolvers/concerns/work_items/shared_filter_arguments.rb b/app/graphql/resolvers/concerns/work_items/shared_filter_arguments.rb index ecb105a64d0..1982b458143 100644 --- a/app/graphql/resolvers/concerns/work_items/shared_filter_arguments.rb +++ b/app/graphql/resolvers/concerns/work_items/shared_filter_arguments.rb @@ -17,7 +17,12 @@ module WorkItems argument :state, Types::IssuableStateEnum, required: false, - description: 'Current state of the work item.' + description: 'Current state of the work item.', + prepare: ->(state, _ctx) { + return state unless state == 'locked' + + raise Gitlab::Graphql::Errors::ArgumentError, Types::IssuableStateEnum::INVALID_LOCKED_MESSAGE + } argument :types, [Types::IssueTypeEnum], as: :issue_types, diff --git a/app/graphql/resolvers/issues/base_parent_resolver.rb b/app/graphql/resolvers/issues/base_parent_resolver.rb index 6308e56f049..78ef4132baf 100644 --- a/app/graphql/resolvers/issues/base_parent_resolver.rb +++ b/app/graphql/resolvers/issues/base_parent_resolver.rb @@ -7,8 +7,13 @@ module Resolvers include ::Issues::SortArguments argument :state, Types::IssuableStateEnum, - required: false, - description: 'Current state of this issue.' + required: false, + description: 'Current state of this issue.', + prepare: ->(state, _ctx) { + return state unless state == 'locked' + + raise Gitlab::Graphql::Errors::ArgumentError, Types::IssuableStateEnum::INVALID_LOCKED_MESSAGE + } # see app/graphql/types/issue_connection.rb type 'Types::IssueConnection', null: true diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index 34f14eee0e5..bc0e7334303 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -14,7 +14,12 @@ module Resolvers description: 'Whether to include issues from archived projects. Defaults to `false`.' argument :state, Types::IssuableStateEnum, required: false, - description: 'Current state of this issue.' + description: 'Current state of this issue.', + prepare: ->(state, _ctx) { + return state unless state == 'locked' + + raise Gitlab::Graphql::Errors::ArgumentError, Types::IssuableStateEnum::INVALID_LOCKED_MESSAGE + } # see app/graphql/types/issue_connection.rb type 'Types::IssueConnection', null: true diff --git a/app/graphql/types/issuable_state_enum.rb b/app/graphql/types/issuable_state_enum.rb index 5a1b11b3bdc..8e3ed1d4bc8 100644 --- a/app/graphql/types/issuable_state_enum.rb +++ b/app/graphql/types/issuable_state_enum.rb @@ -1,10 +1,15 @@ # frozen_string_literal: true +# DO NOT use this ENUM with issues. We need to define a new enum in places where we +# need to filter by state. locked is not a valid state filter for issues. More info in +# https://gitlab.com/gitlab-org/gitlab/-/issues/420667#note_1605900474 module Types class IssuableStateEnum < BaseEnum graphql_name 'IssuableState' description 'State of a GitLab issue or merge request' + INVALID_LOCKED_MESSAGE = 'locked is not a valid state filter for issues.' + value 'opened', description: 'In open state.' value 'closed', description: 'In closed state.' value 'locked', description: 'Discussion has been locked.' diff --git a/app/graphql/types/organizations/organization_user_badge_type.rb b/app/graphql/types/organizations/organization_user_badge_type.rb new file mode 100644 index 00000000000..f4e18676dd1 --- /dev/null +++ b/app/graphql/types/organizations/organization_user_badge_type.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module Organizations + # rubocop: disable Graphql/AuthorizeTypes -- Already authorized in parent OrganizationUserType. + class OrganizationUserBadgeType < BaseObject + graphql_name 'OrganizationUserBadge' + description 'An organization user badge.' + + field :text, + GraphQL::Types::String, + null: false, + description: 'Badge text.' + + field :variant, + GraphQL::Types::String, + null: false, + description: 'Badge variant.' + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/organizations/organization_user_type.rb b/app/graphql/types/organizations/organization_user_type.rb index 41924586f38..ce036c7dd4a 100644 --- a/app/graphql/types/organizations/organization_user_type.rb +++ b/app/graphql/types/organizations/organization_user_type.rb @@ -13,7 +13,7 @@ module Types alias_method :organization_user, :object field :badges, - [GraphQL::Types::String], + [::Types::Organizations::OrganizationUserBadgeType], null: true, description: 'Badges describing the user within the organization.', alpha: { milestone: '16.4' } @@ -29,7 +29,7 @@ module Types alpha: { milestone: '16.4' } def badges - user_badges_in_admin_section(organization_user.user).pluck(:text) # rubocop:disable CodeReuse/ActiveRecord + user_badges_in_admin_section(organization_user.user) end end end diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb index 47d486265b0..ca1b6eaa900 100644 --- a/app/graphql/types/user_interface.rb +++ b/app/graphql/types/user_interface.rb @@ -197,6 +197,11 @@ module Types null: true, description: 'Timestamp of when the user was created.' + field :last_activity_on, + type: Types::DateType, + null: true, + description: 'Date the user last performed any actions.' + field :pronouns, type: ::GraphQL::Types::String, null: true, diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 58648a82487..0c6ab41004a 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -488,6 +488,7 @@ module ApplicationSettingsHelper :sidekiq_job_limiter_compression_threshold_bytes, :sidekiq_job_limiter_limit_bytes, :suggest_pipeline_enabled, + :enable_artifact_external_redirect_warning_page, :search_rate_limit, :search_rate_limit_unauthenticated, :search_rate_limit_allowlist_raw, diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index f1e05b43cd3..0c61749701e 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -78,15 +78,11 @@ module NavHelper %w[system_info background_migrations background_jobs health_check] end - def show_super_sidebar?(user = current_user) - # The new sidebar is not enabled for anonymous use - return true unless user - - # Users who get the new nav unless they explicitly - # opt-out via the toggle - return true if user.use_new_navigation.nil? - - !!user.use_new_navigation + def show_super_sidebar?(_user = current_user) + # The new navigation is now enabled for everyone. + # We are working on cleaning up the use of this helper and other related code. + # See https://gitlab.com/groups/gitlab-org/-/epics/11875 + true end private diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 94445564c22..a1afb0493d5 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -263,21 +263,6 @@ module SortingHelper sort_direction_button(url, reverse_sort, sort_value) end - def packages_sort_options_hash - { - sort_value_recently_created => sort_title_created_date, - sort_value_oldest_created => sort_title_created_date, - sort_value_name => sort_title_name, - sort_value_name_desc => sort_title_name, - sort_value_version_desc => sort_title_version, - sort_value_version_asc => sort_title_version, - sort_value_type_desc => sort_title_type, - sort_value_type_asc => sort_title_type, - sort_value_project_name_desc => sort_title_project_name, - sort_value_project_name_asc => sort_title_project_name - } - end - def packages_reverse_sort_order_hash { sort_value_recently_created => sort_value_oldest_created, diff --git a/app/helpers/users/callouts_helper.rb b/app/helpers/users/callouts_helper.rb index 8c26dd8ba9e..6f1d4db4349 100644 --- a/app/helpers/users/callouts_helper.rb +++ b/app/helpers/users/callouts_helper.rb @@ -15,6 +15,7 @@ module Users REGISTRATION_ENABLED_CALLOUT_ALLOWED_CONTROLLER_PATHS = [/^root/, /^dashboard\S*/, /^admin\S*/].freeze WEB_HOOK_DISABLED = 'web_hook_disabled' BRANCH_RULES_INFO_CALLOUT = 'branch_rules_info_callout' + NEW_NAV_FOR_EVERYONE_CALLOUT = 'new_nav_for_everyone_callout' def show_gke_cluster_integration_callout?(project) active_nav_link?(controller: sidebar_operations_paths) && @@ -74,6 +75,14 @@ module Users !user_dismissed?(BRANCH_RULES_INFO_CALLOUT) end + def show_new_nav_for_everyone_callout? + # The use_new_navigation user preference was controlled by the now removed "New navigation" toggle in the UI. + # We want to show this banner only to signed-in users who chose to disable the new nav (`false`). + # We don't want to show it for users who never touched the toggle and already had the new nav by default (`nil`) + user_had_new_nav_off = current_user && current_user.use_new_navigation == false + user_had_new_nav_off && !user_dismissed?(NEW_NAV_FOR_EVERYONE_CALLOUT) + end + private def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil, object: nil) diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 1bd15a56de5..a5ed402aa9a 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -57,6 +57,7 @@ module ApplicationSettingImplementation default_artifacts_expire_in: '30 days', default_branch_name: nil, default_branch_protection: Settings.gitlab['default_branch_protection'], + default_branch_protection_defaults: Settings.gitlab['default_branch_protection_defaults'], default_ci_config_path: nil, default_group_visibility: Settings.gitlab.default_projects_features['visibility_level'], default_project_creation: Settings.gitlab['default_project_creation'], diff --git a/app/models/ci/catalog/components_project.rb b/app/models/ci/catalog/components_project.rb index 783a2d358c5..02593d41bc2 100644 --- a/app/models/ci/catalog/components_project.rb +++ b/app/models/ci/catalog/components_project.rb @@ -9,7 +9,7 @@ module Ci TEMPLATE_FILE = 'template.yml' TEMPLATES_DIR = 'templates' - TEMPLATE_PATH_REGEX = '^templates\/\w+\-?\w+(?:\/template)?\.yml$' + TEMPLATE_PATH_REGEX = '^templates\/[\w-]+(?:\/template)?\.yml$' COMPONENTS_LIMIT = 10 ComponentData = Struct.new(:content, :path, keyword_init: true) diff --git a/app/models/ci/catalog/resource.rb b/app/models/ci/catalog/resource.rb index e3076e85d10..0da69e6ece0 100644 --- a/app/models/ci/catalog/resource.rb +++ b/app/models/ci/catalog/resource.rb @@ -13,7 +13,8 @@ module Ci self.table_name = 'catalog_resources' belongs_to :project - has_many :components, class_name: 'Ci::Catalog::Resources::Component', inverse_of: :catalog_resource + has_many :components, class_name: 'Ci::Catalog::Resources::Component', foreign_key: :catalog_resource_id, + inverse_of: :catalog_resource has_many :versions, class_name: 'Ci::Catalog::Resources::Version', inverse_of: :catalog_resource scope :for_projects, ->(project_ids) { where(project_id: project_ids) } @@ -44,6 +45,10 @@ module Ci update!(state: :draft) end + def publish! + update!(state: :published) + end + def sync_with_project! sync_with_project save! diff --git a/app/models/ci/catalog/resources/component.rb b/app/models/ci/catalog/resources/component.rb index 7b95c14ba7e..07d5404981b 100644 --- a/app/models/ci/catalog/resources/component.rb +++ b/app/models/ci/catalog/resources/component.rb @@ -6,6 +6,8 @@ module Ci # This class represents a CI/CD Catalog resource component. # The data will be used as metadata of a component. class Component < ::ApplicationRecord + include BulkInsertSafe + self.table_name = 'catalog_resource_components' belongs_to :project, inverse_of: :ci_components diff --git a/app/models/ci/catalog/resources/version.rb b/app/models/ci/catalog/resources/version.rb index 68f60e6a965..fae6d9846f9 100644 --- a/app/models/ci/catalog/resources/version.rb +++ b/app/models/ci/catalog/resources/version.rb @@ -6,6 +6,8 @@ module Ci # This class represents a CI/CD Catalog resource version. # Only versions which contain valid CI components are included in this table. class Version < ::ApplicationRecord + include BulkInsertableAssociations + self.table_name = 'catalog_resource_versions' belongs_to :release, inverse_of: :catalog_resource_version diff --git a/app/models/deployment.rb b/app/models/deployment.rb index ae970ca9e6b..f0093445ba8 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -15,7 +15,7 @@ class Deployment < ApplicationRecord ARCHIVABLE_OFFSET = 50_000 - ignore_column :cluster_id, remove_with: '16.8', remove_after: '2023-12-22' + ignore_column :cluster_id, remove_with: '16.8', remove_after: '2023-12-21' belongs_to :project, optional: false belongs_to :environment, optional: false diff --git a/app/models/group.rb b/app/models/group.rb index 492f4195931..51c26767569 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -594,40 +594,13 @@ class Group < Namespace end def authorizable_members_with_parents - source_ids = - if has_parent? - self_and_ancestors.reorder(nil).select(:id) - else - id - end - - group_hierarchy_members = GroupMember.where(source_id: source_ids).select(*GroupMember.cached_column_list) - - GroupMember.from_union([group_hierarchy_members, - members_from_self_and_ancestor_group_shares]).authorizable + Members::MembersWithParents.new(self).all_members.authorizable end def members_with_parents(only_active_users: true) - # Avoids an unnecessary SELECT when the group has no parents - source_ids = - if has_parent? - self_and_ancestors.reorder(nil).select(:id) - else - id - end - - group_hierarchy_members = GroupMember.non_minimal_access - .where(source_id: source_ids) - .select(*GroupMember.cached_column_list) - - group_hierarchy_members = if only_active_users - group_hierarchy_members.active_without_invites_and_requests - else - group_hierarchy_members.without_invites_and_requests - end - - GroupMember.from_union([group_hierarchy_members, - members_from_self_and_ancestor_group_shares]) + Members::MembersWithParents + .new(self) + .members(active_users: only_active_users) end def members_from_self_and_ancestors_with_effective_access_level @@ -988,48 +961,6 @@ class Group < Namespace errors.add(:require_two_factor_authentication, _('is forbidden by a top-level group')) end - def members_from_self_and_ancestor_group_shares - group_group_link_table = GroupGroupLink.arel_table - group_member_table = GroupMember.arel_table - - source_ids = - if has_parent? - self_and_ancestors.reorder(nil).select(:id) - else - id - end - - group_group_links_query = GroupGroupLink.where(shared_group_id: source_ids) - cte = Gitlab::SQL::CTE.new(:group_group_links_cte, group_group_links_query) - cte_alias = cte.table.alias(GroupGroupLink.table_name) - - # Instead of members.access_level, we need to maximize that access_level at - # the respective group_group_links.group_access. - member_columns = GroupMember.attribute_names.map do |column_name| - if column_name == 'access_level' - smallest_value_arel([cte_alias[:group_access], group_member_table[:access_level]], 'access_level') - else - group_member_table[column_name] - end - end - - GroupMember - .with(cte.to_arel) - .select(*member_columns) - .from([group_member_table, cte.alias_to(group_group_link_table)]) - .where(group_member_table[:requested_at].eq(nil)) - .where(group_member_table[:source_id].eq(group_group_link_table[:shared_with_group_id])) - .where(group_member_table[:source_type].eq('Namespace')) - .where(group_member_table[:state].eq(::Member::STATE_ACTIVE)) - .non_minimal_access - end - - def smallest_value_arel(args, column_alias) - Arel::Nodes::As.new( - Arel::Nodes::NamedFunction.new('LEAST', args), - Arel::Nodes::SqlLiteral.new(column_alias)) - end - def runners_token_prefix RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX end diff --git a/app/models/member.rb b/app/models/member.rb index 77e283044ea..9690e16fd7d 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -135,11 +135,12 @@ class Member < ApplicationRecord .reorder(nil) end - scope :without_invites_and_requests, -> do - active_state - .non_request - .non_invite - .non_minimal_access + scope :without_invites_and_requests, ->(minimal_access: false) do + result = active_state.non_request.non_invite + + result = result.non_minimal_access unless minimal_access + + result end scope :invite, -> { where.not(invite_token: nil) } diff --git a/app/models/members/members/members_with_parents.rb b/app/models/members/members/members_with_parents.rb new file mode 100644 index 00000000000..61ce99e1f3e --- /dev/null +++ b/app/models/members/members/members_with_parents.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module Members + class MembersWithParents + attr_reader :group + + def initialize(group) + @group = group + end + + # Returns all members for group and parents, with no filters + def all_members + GroupMember.from_union([ + members_from_self_and_ancestors, + members_from_self_and_ancestor_group_shares + ]) + end + + # Returns members based on filter options: + # + # - `active_users`. DEPRECATED. If true, returns only members for active users + # - `minimal_access`. Used only in EE (GitLab Premium). If true, returns + # members which has minimal access. If false (default), does not return + # members with minimal access + # + # NOTE : this method does not return pending invites, nor requests. + def members(active_users: false, minimal_access: false) + raise ArgumentError, 'active_users: is deprecated' if active_users && minimal_access + + group_hierarchy_members = members_from_self_and_ancestors + + group_hierarchy_members = + if active_users + group_hierarchy_members.active_without_invites_and_requests + else + filter_invites_and_requests(group_hierarchy_members, minimal_access) + end + + GroupMember.from_union([ + group_hierarchy_members, + members_from_self_and_ancestor_group_shares + ]) + end + + private + + # NOTE: minimal access is Premium, so in FOSS we will not include minimal access member + def filter_invites_and_requests(members, _minimal_access) + members.without_invites_and_requests(minimal_access: false) + end + + def source_ids + # Avoids an unnecessary SELECT when the group has no parents + @source_ids ||= + if group.has_parent? + group.self_and_ancestors.reorder(nil).select(:id) + else + group.id + end + end + + def members_from_self_and_ancestors + GroupMember + .with_source_id(source_ids) + .select(*GroupMember.cached_column_list) + end + + def members_from_self_and_ancestor_group_shares + group_group_link_table = GroupGroupLink.arel_table + group_member_table = GroupMember.arel_table + + group_group_links_query = GroupGroupLink.where(shared_group_id: source_ids) + cte = Gitlab::SQL::CTE.new(:group_group_links_cte, group_group_links_query) + cte_alias = cte.table.alias(GroupGroupLink.table_name) + + # Instead of members.access_level, we need to maximize that access_level at + # the respective group_group_links.group_access. + member_columns = GroupMember.attribute_names.map do |column_name| + if column_name == 'access_level' + smallest_value_arel([cte_alias[:group_access], group_member_table[:access_level]], 'access_level') + else + group_member_table[column_name] + end + end + + GroupMember + .with(cte.to_arel) + .select(*member_columns) + .from([group_member_table, cte.alias_to(group_group_link_table)]) + .where(group_member_table[:requested_at].eq(nil)) + .where(group_member_table[:source_id].eq(group_group_link_table[:shared_with_group_id])) + .where(group_member_table[:source_type].eq('Namespace')) + .where(group_member_table[:state].eq(::Member::STATE_ACTIVE)) + .non_minimal_access + end + + def smallest_value_arel(args, column_alias) + Arel::Nodes::As.new( + Arel::Nodes::NamedFunction.new('LEAST', args), + Arel::Nodes::SqlLiteral.new(column_alias)) + end + end +end + +Members::MembersWithParents.prepend_mod diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 5abda6196c1..1f2224bba09 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -301,6 +301,14 @@ class Namespace < ApplicationRecord super || Gitlab::CurrentSettings.default_branch_protection end + def default_branch_protection_settings + settings = default_branch_protection_defaults + + return settings unless settings.blank? + + Gitlab::CurrentSettings.default_branch_protection_defaults + end + def visibility_level_field :visibility_level end diff --git a/app/models/project_feature_usage.rb b/app/models/project_feature_usage.rb index dba81a6cb60..5e47ec6310d 100644 --- a/app/models/project_feature_usage.rb +++ b/app/models/project_feature_usage.rb @@ -19,19 +19,6 @@ class ProjectFeatureUsage < ApplicationRecord end end - def log_jira_dvcs_integration_usage(cloud: true) - ::Gitlab::Database::LoadBalancing::Session.without_sticky_writes do - integration_field = self.class.jira_dvcs_integration_field(cloud: cloud) - - # The feature usage is used only once later to query the feature usage in a - # long date range. Therefore, we just need to update the timestamp once per - # day - break if persisted? && updated_today?(integration_field) - - persist_jira_dvcs_usage(integration_field) - end - end - private def updated_today?(integration_field) diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb index aad763555bf..a9880e56e8c 100644 --- a/app/models/users/callout.rb +++ b/app/models/users/callout.rb @@ -76,7 +76,8 @@ module Users # 74 removed in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/132751 vsd_feedback_banner: 75, # EE-only security_policy_protected_branch_modification: 76, # EE-only - vulnerability_report_grouping: 77 # EE-only + vulnerability_report_grouping: 77, # EE-only + new_nav_for_everyone_callout: 78 } validates :feature_name, diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 3e81418c757..c983d8623d2 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -436,6 +436,10 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated count_of_extra_topics_not_shown > 0 end + def has_review_app? + !project.environments_for_scope('review/*').empty? + end + def can_setup_review_app? strong_memoize(:can_setup_review_app) do (can_instantiate_cluster? && all_clusters_empty?) || cicd_missing? diff --git a/app/serializers/review_app_setup_entity.rb b/app/serializers/review_app_setup_entity.rb index 3a21fe24d9e..1fde31bc847 100644 --- a/app/serializers/review_app_setup_entity.rb +++ b/app/serializers/review_app_setup_entity.rb @@ -13,6 +13,8 @@ class ReviewAppSetupEntity < Grape::Entity YAML.safe_load(File.read(Rails.root.join('lib', 'gitlab', 'ci', 'snippets', 'review_app_default.yml'))).to_s end + expose :has_review_app?, as: :has_review_app + private def current_user diff --git a/app/services/ci/catalog/resources/versions/create_service.rb b/app/services/ci/catalog/resources/versions/create_service.rb new file mode 100644 index 00000000000..863bad43271 --- /dev/null +++ b/app/services/ci/catalog/resources/versions/create_service.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +module Ci + module Catalog + module Resources + module Versions + class CreateService + def initialize(release) + @project = release.project + @release = release + @errors = [] + @version = nil + @components_project = Ci::Catalog::ComponentsProject.new(project) + end + + def execute + build_catalog_resource_version + fetch_and_build_components if Feature.enabled?(:ci_catalog_create_metadata, project) + publish_catalog_resource! + + if errors.empty? + ServiceResponse.success + else + ServiceResponse.error(message: errors.flatten.first(10).join(', ')) + end + end + + private + + attr_reader :project, :errors, :release, :components_project + + def build_catalog_resource_version + return error('Project is not a catalog resource') unless project.catalog_resource + + @version = Ci::Catalog::Resources::Version.new( + release: release, + catalog_resource: project.catalog_resource, + project: project + ) + end + + def fetch_and_build_components + return if errors.present? + + max_components = Ci::Catalog::ComponentsProject::COMPONENTS_LIMIT + component_paths = components_project.fetch_component_paths(release.sha, limit: max_components + 1) + + if component_paths.size > max_components + return error("Release cannot contain more than #{max_components} components") + end + + build_components(component_paths) + end + + def build_components(component_paths) + paths_with_oids = component_paths.map { |path| [release.sha, path] } + blobs = project.repository.blobs_at(paths_with_oids) + + blobs.each do |blob| + metadata = extract_metadata(blob) + build_catalog_resource_component(metadata) + end + rescue ::Gitlab::Config::Loader::FormatError => e + error(e) + end + + def extract_metadata(blob) + { + name: components_project.extract_component_name(blob.path), + inputs: components_project.extract_inputs(blob.data), + path: blob.path + } + end + + def build_catalog_resource_component(metadata) + return if errors.present? + + component = @version.components.build( + name: metadata[:name], + project: @version.project, + inputs: metadata[:inputs], + catalog_resource: @version.catalog_resource, + path: metadata[:path], + created_at: Time.current + ) + + return if component.valid? + + error("Build component error: #{component.errors.full_messages.join(', ')}") + end + + def publish_catalog_resource! + return if errors.present? + + ::Ci::Catalog::Resources::Version.transaction do + BulkInsertableAssociations.with_bulk_insert do + @version.save! + end + + project.catalog_resource.publish! + end + end + + def error(message) + errors << message + end + end + end + end + end +end diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index 83f9ccdfad6..8092299fb61 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -59,6 +59,8 @@ .form-group = f.gitlab_ui_checkbox_component :suggest_pipeline_enabled, s_('AdminSettings|Enable pipeline suggestion banner'), help_text: s_('AdminSettings|Display a banner on merge requests in projects with no pipelines to initiate steps to add a .gitlab-ci.yml file.') #js-runner-token-expiration-intervals{ data: runner_token_expiration_interval_attributes } + .form-group + = f.gitlab_ui_checkbox_component :enable_artifact_external_redirect_warning_page, s_('AdminSettings|Enable the external redirect warning page for job artifacts'), help_text: s_('AdminSettings|Show a redirect page that warns you about user-generated content in GitLab Pages.') = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index c56678d730d..fe2c2e968e8 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -7,7 +7,7 @@ - sidebar_panel = super_sidebar_nav_panel(nav: nav, user: current_user, group: group, project: @project, current_ref: current_ref, ref_type: @ref_type, viewed_user: @user, organization: @organization) - sidebar_data = super_sidebar_context(current_user, group: group, project: @project, panel: sidebar_panel, panel_type: nav).to_json - %aside.js-super-sidebar.super-sidebar.super-sidebar-loading{ data: { root_path: root_path, sidebar: sidebar_data, toggle_new_nav_endpoint: profile_preferences_path, force_desktop_expanded_sidebar: @force_desktop_expanded_sidebar.to_s, command_palette: command_palette_data(project: @project).to_json } } + %aside.js-super-sidebar.super-sidebar.super-sidebar-loading{ data: { root_path: root_path, sidebar: sidebar_data, force_desktop_expanded_sidebar: @force_desktop_expanded_sidebar.to_s, command_palette: command_palette_data(project: @project).to_json } } - if display_whats_new? #whats-new-app{ data: { version_digest: whats_new_version_digest } } @@ -20,6 +20,7 @@ .mobile-overlay = dispensable_render_if_exists 'layouts/header/verification_reminder' .alert-wrapper.gl-force-block-formatting-context + = dispensable_render 'shared/new_nav_for_everyone_announcement' = dispensable_render 'shared/outdated_browser' = dispensable_render_if_exists "layouts/header/licensed_user_count_threshold" = dispensable_render_if_exists "layouts/header/token_expiry_notification" diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 451c66b074b..5a66cc0ddb5 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,6 +1,6 @@ - page_classes = page_class << @html_class -- page_classes = page_classes.flatten.compact -- body_classes = [user_application_theme, user_tab_width, @body_class, client_class_list, *custom_diff_color_classes] +- page_classes = [user_application_theme, page_classes.flatten.compact] +- body_classes = [user_tab_width, @body_class, client_class_list, *custom_diff_color_classes] !!! 5 %html{ lang: I18n.locale, class: page_classes } diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 4e9ae7c7fd8..366a51ef29e 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -1,9 +1,9 @@ - add_page_specific_style 'page_bundles/login' - custom_text = custom_sign_in_description !!! 5 -%html.html-devise-layout{ lang: I18n.locale } +%html.html-devise-layout{ class: user_application_theme, lang: I18n.locale } = render "layouts/head", { startup_filename: 'signin' } - %body.gl-h-full.login-page.navless{ class: "#{system_message_class} #{user_application_theme} #{client_class_list}", data: { page: body_data_page, qa_selector: 'login_page' } } + %body.gl-h-full.login-page.navless{ class: "#{system_message_class} #{client_class_list}", data: { page: body_data_page, qa_selector: 'login_page' } } = header_message = render "layouts/init_client_detection_flags" - if Feature.enabled?(:restyle_login_page, @project) diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml index 3e969b866a6..6816a64ac8f 100644 --- a/app/views/layouts/devise_empty.html.haml +++ b/app/views/layouts/devise_empty.html.haml @@ -1,8 +1,8 @@ - add_page_specific_style 'page_bundles/login' !!! 5 -%html.html-devise-layout{ lang: I18n.locale } +%html.html-devise-layout{ class: user_application_theme, lang: I18n.locale } = render "layouts/head" - %body.gl-h-full.login-page.navless{ class: "#{system_message_class} #{user_application_theme} #{client_class_list}" } + %body.gl-h-full.login-page.navless{ class: "#{system_message_class} #{client_class_list}" } = header_message = render "layouts/init_client_detection_flags" = render "layouts/header/empty" diff --git a/app/views/layouts/fullscreen.html.haml b/app/views/layouts/fullscreen.html.haml index da192822902..f168c742085 100644 --- a/app/views/layouts/fullscreen.html.haml +++ b/app/views/layouts/fullscreen.html.haml @@ -1,8 +1,8 @@ - minimal = local_assigns.fetch(:minimal, false) !!! 5 -%html{ lang: I18n.locale, class: page_class } +%html{ class: [user_application_theme, page_class], lang: I18n.locale } = render "layouts/head" - %body{ class: "#{user_application_theme} #{user_tab_width} #{@body_class} fullscreen-layout", data: { page: body_data_page } } + %body{ class: "#{user_tab_width} #{@body_class} fullscreen-layout", data: { page: body_data_page } } = render 'peek/bar' = header_message - unless minimal diff --git a/app/views/layouts/signup_onboarding.html.haml b/app/views/layouts/signup_onboarding.html.haml index d440b543662..c8e15896b97 100644 --- a/app/views/layouts/signup_onboarding.html.haml +++ b/app/views/layouts/signup_onboarding.html.haml @@ -1,9 +1,9 @@ - add_page_specific_style 'page_bundles/signup' - add_page_specific_style 'page_bundles/login' !!! 5 -%html.html-devise-layout{ lang: I18n.locale } +%html.html-devise-layout{ class: user_application_theme, lang: I18n.locale } = render "layouts/head" - %body.signup-page.navless{ class: "#{system_message_class} #{user_application_theme} #{client_class_list}", data: { page: body_data_page } } + %body.signup-page.navless{ class: "#{system_message_class} #{client_class_list}", data: { page: body_data_page } } = header_message = render "layouts/init_client_detection_flags" = render "layouts/header/logo_with_title" diff --git a/app/views/layouts/terms.html.haml b/app/views/layouts/terms.html.haml index 32f00a4c0c6..09b5407ecdb 100644 --- a/app/views/layouts/terms.html.haml +++ b/app/views/layouts/terms.html.haml @@ -2,11 +2,10 @@ - add_page_specific_style 'page_bundles/terms' - @hide_top_bar = true - @hide_top_bar_padding = true -- body_classes = [user_application_theme] -%html{ lang: I18n.locale, class: page_class } +%html{ lang: I18n.locale, class: [user_application_theme, page_class] } = render "layouts/head" - %body{ class: body_classes, data: { page: body_data_page } } + %body{ data: { page: body_data_page } } .layout-page.terms{ class: page_class } .content-wrapper.gl-pb-5 .mobile-overlay diff --git a/app/views/projects/artifacts/_tree_file.html.haml b/app/views/projects/artifacts/_tree_file.html.haml index e120975a8f9..19db01a2df1 100644 --- a/app/views/projects/artifacts/_tree_file.html.haml +++ b/app/views/projects/artifacts/_tree_file.html.haml @@ -1,6 +1,6 @@ - blob = file.blob - external_link = blob.external_link?(@build) -- if external_link +- if external_link && Gitlab::CurrentSettings.enable_artifact_external_redirect_warning_page - path_to_file = external_file_project_job_artifacts_path(@project, @build, path: file.path) - else - path_to_file = file_project_job_artifacts_path(@project, @build, path: file.path) diff --git a/app/views/shared/_new_nav_for_everyone_announcement.html.haml b/app/views/shared/_new_nav_for_everyone_announcement.html.haml new file mode 100644 index 00000000000..fa870249596 --- /dev/null +++ b/app/views/shared/_new_nav_for_everyone_announcement.html.haml @@ -0,0 +1,18 @@ +- return unless show_new_nav_for_everyone_callout? + +- blog_url = 'https://about.gitlab.com/blog/2023/08/15/navigation-research-blog-post/' +- issues_url = 'https://about.gitlab.com/submit-feedback/#product-feedback' + +- blog_link_tags = tag_pair(link_to('', blog_url, rel: 'noopener noreferrer', target: '_blank'), :blog_link_start, :link_end) +- issues_link_tags = tag_pair(link_to('', issues_url, rel: 'noopener noreferrer', target: '_blank'), :issues_link_start, :link_end) + +- welcome_text = safe_format(_('GitLab has redesigned the left sidebar to address customer feedback. View details in %{blog_link_start}this blog post%{link_end}. Here\'s how to %{issues_link_start}file an issue%{link_end} with the GitLab product team.'), blog_link_tags, issues_link_tags) + += render Pajamas::AlertComponent.new(dismissible: true, + alert_options: { class: 'js-new-nav-for-everyone-callout', data: { feature_id: "new_nav_for_everyone_callout", dismiss_endpoint: callouts_path }}) do |c| + - c.with_body do + %p + = welcome_text + - c.with_actions do + = render Pajamas::ButtonComponent.new(variant: :confirm, href: blog_url, target: '_blank', button_options: { class: 'gl-alert-action' }) do |c| + = _('Learn more') diff --git a/app/workers/bulk_imports/entity_worker.rb b/app/workers/bulk_imports/entity_worker.rb index 1c426be4545..b35fa2ced3f 100644 --- a/app/workers/bulk_imports/entity_worker.rb +++ b/app/workers/bulk_imports/entity_worker.rb @@ -36,7 +36,12 @@ module BulkImports def perform_failure(exception, entity_id) @entity = ::BulkImports::Entity.find(entity_id) - log_and_fail(exception) + Gitlab::ErrorTracking.track_exception( + exception, + log_params(message: "Request to export #{entity.source_type} failed") + ) + + entity.fail_op! end private @@ -99,14 +104,5 @@ module BulkImports defaults.merge(extra) end - - def log_and_fail(exception) - Gitlab::ErrorTracking.track_exception( - exception, - log_params(message: "Request to export #{entity.source_type} failed") - ) - - entity.fail_op! - end end end diff --git a/config/feature_categories.yml b/config/feature_categories.yml index 0c36be00774..3eaf8b2b34d 100644 --- a/config/feature_categories.yml +++ b/config/feature_categories.yml @@ -93,6 +93,7 @@ - organization - package_registry - pages +- permissions - pipeline_composition - portfolio_management - product_analytics_data_management diff --git a/config/feature_flags/development/ci_catalog_create_metadata.yml b/config/feature_flags/development/ci_catalog_create_metadata.yml new file mode 100644 index 00000000000..a73f499554d --- /dev/null +++ b/config/feature_flags/development/ci_catalog_create_metadata.yml @@ -0,0 +1,8 @@ +--- +name: ci_catalog_create_metadata +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/134148 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/430120 +milestone: '16.6' +type: development +group: group::pipeline authoring +default_enabled: false diff --git a/config/feature_flags/development/manage_project_access_tokens.yml b/config/feature_flags/development/manage_project_access_tokens.yml index cf2dc9ab581..a6cf2cf4f9f 100644 --- a/config/feature_flags/development/manage_project_access_tokens.yml +++ b/config/feature_flags/development/manage_project_access_tokens.yml @@ -1,7 +1,7 @@ --- name: manage_project_access_tokens introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/132342 -rollout_issue_url: +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/430353 milestone: '16.5' type: development group: group::authorization diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 960caf1dc35..1e5fb17c971 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -183,6 +183,7 @@ Settings.gitlab['default_project_creation'] ||= ::Gitlab::Access::DEVELOPER_MAIN Settings.gitlab['default_project_deletion_protection'] ||= false Settings.gitlab['default_projects_limit'] ||= 100000 Settings.gitlab['default_branch_protection'] ||= 2 +Settings.gitlab['default_branch_protection_defaults'] ||= ::Gitlab::Access::BranchProtection.protected_fully # `default_can_create_group` is deprecated since GitLab 15.5 in favour of the `can_create_group` column on `ApplicationSetting`. Settings.gitlab['default_can_create_group'] = true if Settings.gitlab['default_can_create_group'].nil? Settings.gitlab['default_theme'] = Gitlab::Themes::APPLICATION_DEFAULT if Settings.gitlab['default_theme'].nil? diff --git a/config/metrics/counts_all/20210216180232_projects_jira_dvcs_cloud_active.yml b/config/metrics/counts_all/20210216180232_projects_jira_dvcs_cloud_active.yml index 5bf8e1d6e78..c9c85bca415 100644 --- a/config/metrics/counts_all/20210216180232_projects_jira_dvcs_cloud_active.yml +++ b/config/metrics/counts_all/20210216180232_projects_jira_dvcs_cloud_active.yml @@ -6,7 +6,7 @@ product_section: dev product_stage: manage product_group: integrations value_type: number -status: active +status: removed time_frame: all data_source: database instrumentation_class: CountProjectsWithJiraDvcsIntegrationMetric @@ -21,3 +21,5 @@ tier: - ultimate performance_indicator_type: [] milestone: "<13.9" +removed_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/135755 +milestone_removed: "<16.6" diff --git a/config/metrics/settings/20210204124920_web_ide_clientside_preview_enabled.yml b/config/metrics/settings/20210204124920_web_ide_clientside_preview_enabled.yml index a206d8ecd7a..a04e8d82686 100644 --- a/config/metrics/settings/20210204124920_web_ide_clientside_preview_enabled.yml +++ b/config/metrics/settings/20210204124920_web_ide_clientside_preview_enabled.yml @@ -6,7 +6,7 @@ product_section: dev product_stage: create product_group: ide value_type: boolean -status: active +status: removed time_frame: none data_source: database distribution: @@ -18,3 +18,5 @@ tier: - ultimate performance_indicator_type: [] milestone: "<13.9" +removed_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/136114 +milestone_removed: "16.6" diff --git a/danger/documentation/Dangerfile b/danger/documentation/Dangerfile index 150109eff51..78f8c87a528 100644 --- a/danger/documentation/Dangerfile +++ b/danger/documentation/Dangerfile @@ -19,6 +19,13 @@ MSG docs_paths_to_review = helper.changes_by_category[:docs] +# Some docs do not need a review from a technical writer +SKIP_TW_REVIEW_PATHS = ['doc/solutions'].freeze + +docs_paths_to_review.delete_if do |item| + SKIP_TW_REVIEW_PATHS.any? { |skip_path| item.start_with?(skip_path) } +end + # Documentation should be updated for feature::addition and feature::enhancement if docs_paths_to_review.empty? warn(DOCUMENTATION_UPDATE_MISSING) if feature_mr? diff --git a/data/deprecations/16-6-deprecation-legacy-geo-prometheus-metrics.yml b/data/deprecations/16-6-deprecation-legacy-geo-prometheus-metrics.yml new file mode 100644 index 00000000000..59db93293be --- /dev/null +++ b/data/deprecations/16-6-deprecation-legacy-geo-prometheus-metrics.yml @@ -0,0 +1,22 @@ +- title: "Legacy Geo Prometheus metrics" + removal_milestone: "17.0" + announcement_milestone: "16.6" + breaking_change: true + reporter: sranasinghe + stage: enablement + issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/430192 + body: | + Following the migration of projects to the [Geo self-service framework](https://docs.gitlab.com/ee/development/geo/framework.html) we have deprecated a number of [Prometheus](https://docs.gitlab.com/ee/administration/monitoring/prometheus/) metrics. + The following Geo-related Prometheus metrics are deprecated and will be removed in 17.0. + The table below lists the deprecated metrics and their respective replacements. The replacements are available in GitLab 16.3.0 and later. + + | Deprecated metric | Replacement metric | + | ---------------------------------------- | ---------------------------------------------- | + | `geo_repositories_synced` | `geo_project_repositories_synced` | + | `geo_repositories_failed` | `geo_project_repositories_failed` | + | `geo_repositories_checksummed` | `geo_project_repositories_checksummed` | + | `geo_repositories_checksum_failed` | `geo_project_repositories_checksum_failed` | + | `geo_repositories_verified` | `geo_project_repositories_verified` | + | `geo_repositories_verification_failed` | `geo_project_repositories_verification_failed` | + | `geo_repositories_checksum_mismatch` | None available | + | `geo_repositories_retrying_verification` | None available | diff --git a/db/click_house/main/20230707151359_create_ci_finished_builds.sql b/db/click_house/main/20230707151359_create_ci_finished_builds.sql index 5c2cc0e8eb3..9fd17e1968f 100644 --- a/db/click_house/main/20230707151359_create_ci_finished_builds.sql +++ b/db/click_house/main/20230707151359_create_ci_finished_builds.sql @@ -30,4 +30,4 @@ CREATE TABLE ci_finished_builds ) ENGINE = ReplacingMergeTree -- Using ReplacingMergeTree just in case we accidentally insert the same data twice ORDER BY (status, runner_type, project_id, finished_at, id) -PARTITION BY toYear(finished_at) +PARTITION BY toYear(finished_at); diff --git a/db/click_house/main/20230719101806_create_ci_finished_builds_aggregated_queueing_delay_percentiles.sql b/db/click_house/main/20230719101806_create_ci_finished_builds_aggregated_queueing_delay_percentiles.sql index 56889ffc0d4..0b05c3a37f6 100644 --- a/db/click_house/main/20230719101806_create_ci_finished_builds_aggregated_queueing_delay_percentiles.sql +++ b/db/click_house/main/20230719101806_create_ci_finished_builds_aggregated_queueing_delay_percentiles.sql @@ -8,4 +8,4 @@ CREATE TABLE ci_finished_builds_aggregated_queueing_delay_percentiles queueing_duration_quantile AggregateFunction(quantile, Int64) ) ENGINE = AggregatingMergeTree() -ORDER BY (started_at_bucket, status, runner_type) +ORDER BY (started_at_bucket, status, runner_type); diff --git a/db/migrate/20231002162941_add_enable_artifact_external_redirect_warning_page_to_application_settings.rb b/db/migrate/20231002162941_add_enable_artifact_external_redirect_warning_page_to_application_settings.rb new file mode 100644 index 00000000000..06fc4b6b313 --- /dev/null +++ b/db/migrate/20231002162941_add_enable_artifact_external_redirect_warning_page_to_application_settings.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddEnableArtifactExternalRedirectWarningPageToApplicationSettings < Gitlab::Database::Migration[2.1] + enable_lock_retries! + + def change + add_column(:application_settings, :enable_artifact_external_redirect_warning_page, :boolean, default: true, + null: false) + end +end diff --git a/db/migrate/20231024133234_add_source_package_name_to_sbom_component.rb b/db/migrate/20231024133234_add_source_package_name_to_sbom_component.rb new file mode 100644 index 00000000000..41970429ca9 --- /dev/null +++ b/db/migrate/20231024133234_add_source_package_name_to_sbom_component.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class AddSourcePackageNameToSbomComponent < Gitlab::Database::Migration[2.2] + milestone '16.6' + disable_ddl_transaction! + + INDEX = 'index_source_package_names_on_component_and_purl' + + def up + with_lock_retries do + add_column :sbom_components, :source_package_name, :text, if_not_exists: true + end + + add_text_limit :sbom_components, :source_package_name, 255 + add_concurrent_index :sbom_components, + [:component_type, :source_package_name, :purl_type], + name: INDEX, + unique: true + end + + def down + with_lock_retries do + remove_column :sbom_components, :source_package_name, if_exists: true + end + + remove_concurrent_index_by_name :sbom_components, name: INDEX + end +end diff --git a/db/post_migrate/20231102083539_backfill_p_ci_builds_pipeline_id.rb b/db/post_migrate/20231102083539_backfill_p_ci_builds_pipeline_id.rb new file mode 100644 index 00000000000..feada383fe4 --- /dev/null +++ b/db/post_migrate/20231102083539_backfill_p_ci_builds_pipeline_id.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class BackfillPCiBuildsPipelineId < Gitlab::Database::Migration[2.2] + restrict_gitlab_migration gitlab_schema: :gitlab_ci + milestone '16.6' + + TABLE_NAME = :ci_builds + COLUMN_NAMES = %i[ + auto_canceled_by_id + commit_id + erased_by_id + project_id + runner_id + trigger_request_id + upstream_pipeline_id + user_id + ] + SUB_BATCH_SIZE = 750 + BATCH_SIZE = 75_000 + PAUSE_MS = 0 + + def up + backfill_conversion_of_integer_to_bigint( + TABLE_NAME, COLUMN_NAMES, + sub_batch_size: SUB_BATCH_SIZE, + batch_size: BATCH_SIZE, + pause_ms: PAUSE_MS + ) + end + + def down + revert_backfill_conversion_of_integer_to_bigint(TABLE_NAME, COLUMN_NAMES) + end +end diff --git a/db/schema_migrations/20231002162941 b/db/schema_migrations/20231002162941 new file mode 100644 index 00000000000..a6842b3f677 --- /dev/null +++ b/db/schema_migrations/20231002162941 @@ -0,0 +1 @@ +ddf75326b9bb04275bf48e9a2eb6c15af7a9ca6c00864a636d5e179c5881b20b
\ No newline at end of file diff --git a/db/schema_migrations/20231024133234 b/db/schema_migrations/20231024133234 new file mode 100644 index 00000000000..fb536f574d3 --- /dev/null +++ b/db/schema_migrations/20231024133234 @@ -0,0 +1 @@ +0a92e23317e4fc12b9de9d15c0d3895afe211b543a0449834b9459616152680a
\ No newline at end of file diff --git a/db/schema_migrations/20231102083539 b/db/schema_migrations/20231102083539 new file mode 100644 index 00000000000..489269151bb --- /dev/null +++ b/db/schema_migrations/20231102083539 @@ -0,0 +1 @@ +1ac3716a5e014abe1828d648bd9f1014d770b40c4006944f341739728026fcd4
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 2de7aa4a6ab..bc15823a408 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -11877,6 +11877,7 @@ CREATE TABLE application_settings ( project_jobs_api_rate_limit integer DEFAULT 600 NOT NULL, math_rendering_limits_enabled boolean DEFAULT true NOT NULL, service_access_tokens_expiration_enforced boolean DEFAULT true NOT NULL, + enable_artifact_external_redirect_warning_page boolean DEFAULT true NOT NULL, CONSTRAINT app_settings_container_reg_cleanup_tags_max_list_size_positive CHECK ((container_registry_cleanup_tags_service_max_list_size >= 0)), CONSTRAINT app_settings_container_registry_pre_import_tags_rate_positive CHECK ((container_registry_pre_import_tags_rate >= (0)::numeric)), CONSTRAINT app_settings_dep_proxy_ttl_policies_worker_capacity_positive CHECK ((dependency_proxy_ttl_group_policy_worker_capacity >= 0)), @@ -22824,7 +22825,9 @@ CREATE TABLE sbom_components ( component_type smallint NOT NULL, name text NOT NULL, purl_type smallint, - CONSTRAINT check_91a8f6ad53 CHECK ((char_length(name) <= 255)) + source_package_name text, + CONSTRAINT check_91a8f6ad53 CHECK ((char_length(name) <= 255)), + CONSTRAINT check_e2dcb53709 CHECK ((char_length(source_package_name) <= 255)) ); CREATE SEQUENCE sbom_components_id_seq @@ -34396,6 +34399,8 @@ CREATE INDEX index_sop_schedules_on_sop_configuration_id ON security_orchestrati CREATE INDEX index_sop_schedules_on_user_id ON security_orchestration_policy_rule_schedules USING btree (user_id); +CREATE UNIQUE INDEX index_source_package_names_on_component_and_purl ON sbom_components USING btree (component_type, source_package_name, purl_type); + CREATE INDEX index_spam_logs_on_user_id ON spam_logs USING btree (user_id); CREATE INDEX index_sprints_iterations_cadence_id ON sprints USING btree (iterations_cadence_id); diff --git a/doc/administration/settings/continuous_integration.md b/doc/administration/settings/continuous_integration.md index 841b6e644eb..0e2a512302d 100644 --- a/doc/administration/settings/continuous_integration.md +++ b/doc/administration/settings/continuous_integration.md @@ -266,6 +266,22 @@ To enable or disable the banner: 1. Select or clear the **Enable pipeline suggestion banner** checkbox. 1. Select **Save changes**. +## Enable or disable the external redirect page for job artifacts + +By default, GitLab Pages shows an external redirect page when a user tries to view +a job artifact served by GitLab Pages. This page warns about the potential for +malicious user-generated content, as described in +[issue 352611](https://gitlab.com/gitlab-org/gitlab/-/issues/352611). + +Self-managed administrators can disable the external redirect warning page, +so you can view job artifact pages directly: + +1. On the left sidebar, select **Search or go to**. +1. Select **Admin Area**. +1. Select **Settings > CI/CD**. +1. Expand **Continuous Integration and Deployment**. +1. Deselect **Enable the external redirect page for job artifacts**. + ## Required pipeline configuration **(ULTIMATE SELF)** > - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/352316) from GitLab Premium to GitLab Ultimate in 15.0. diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 9ef51601468..6c22ffa5f22 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -7666,7 +7666,7 @@ Input type: `ValueStreamCreateInput` | Name | Type | Description | | ---- | ---- | ----------- | | <a id="mutationvaluestreamcreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | -| <a id="mutationvaluestreamcreatename"></a>`name` | [`String!`](#string) | Value stream description. | +| <a id="mutationvaluestreamcreatename"></a>`name` | [`String!`](#string) | Value stream name. | | <a id="mutationvaluestreamcreatenamespacepath"></a>`namespacePath` | [`ID!`](#id) | Full path of the namespace(project or group) the value stream is created in. | #### Fields @@ -7702,6 +7702,32 @@ Input type: `ValueStreamDestroyInput` | <a id="mutationvaluestreamdestroyerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | <a id="mutationvaluestreamdestroyvaluestream"></a>`valueStream` | [`ValueStream`](#valuestream) | Value stream deleted after mutation. | +### `Mutation.valueStreamUpdate` + +Updates a value stream. + +WARNING: +**Introduced** in 16.6. +This feature is an Experiment. It can be changed or removed at any time. + +Input type: `ValueStreamUpdateInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationvaluestreamupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationvaluestreamupdateid"></a>`id` | [`AnalyticsCycleAnalyticsValueStreamID!`](#analyticscycleanalyticsvaluestreamid) | Global ID of the value stream to update. | +| <a id="mutationvaluestreamupdatename"></a>`name` | [`String!`](#string) | Value stream name. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationvaluestreamupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationvaluestreamupdateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| <a id="mutationvaluestreamupdatevaluestream"></a>`valueStream` | [`ValueStream`](#valuestream) | Updated value stream. | + ### `Mutation.vulnerabilitiesDismiss` Input type: `VulnerabilitiesDismissInput` @@ -14012,6 +14038,7 @@ Core representation of a GitLab user. | <a id="autocompleteduserid"></a>`id` | [`ID!`](#id) | ID of the user. | | <a id="autocompleteduseride"></a>`ide` | [`Ide`](#ide) | IDE settings. | | <a id="autocompleteduserjobtitle"></a>`jobTitle` | [`String`](#string) | Job title of the user. | +| <a id="autocompleteduserlastactivityon"></a>`lastActivityOn` | [`Date`](#date) | Date the user last performed any actions. | | <a id="autocompleteduserlinkedin"></a>`linkedin` | [`String`](#string) | LinkedIn profile name of the user. | | <a id="autocompleteduserlocation"></a>`location` | [`String`](#string) | Location of the user. | | <a id="autocompletedusername"></a>`name` | [`String!`](#string) | Human-readable name of the user. Returns `****` if the user is a project bot and the requester does not have permission to view the project. | @@ -20447,6 +20474,7 @@ A user assigned to a merge request. | <a id="mergerequestassigneeid"></a>`id` | [`ID!`](#id) | ID of the user. | | <a id="mergerequestassigneeide"></a>`ide` | [`Ide`](#ide) | IDE settings. | | <a id="mergerequestassigneejobtitle"></a>`jobTitle` | [`String`](#string) | Job title of the user. | +| <a id="mergerequestassigneelastactivityon"></a>`lastActivityOn` | [`Date`](#date) | Date the user last performed any actions. | | <a id="mergerequestassigneelinkedin"></a>`linkedin` | [`String`](#string) | LinkedIn profile name of the user. | | <a id="mergerequestassigneelocation"></a>`location` | [`String`](#string) | Location of the user. | | <a id="mergerequestassigneemergerequestinteraction"></a>`mergeRequestInteraction` | [`UserMergeRequestInteraction`](#usermergerequestinteraction) | Details of this user's interactions with the merge request. | @@ -20727,6 +20755,7 @@ The author of the merge request. | <a id="mergerequestauthorid"></a>`id` | [`ID!`](#id) | ID of the user. | | <a id="mergerequestauthoride"></a>`ide` | [`Ide`](#ide) | IDE settings. | | <a id="mergerequestauthorjobtitle"></a>`jobTitle` | [`String`](#string) | Job title of the user. | +| <a id="mergerequestauthorlastactivityon"></a>`lastActivityOn` | [`Date`](#date) | Date the user last performed any actions. | | <a id="mergerequestauthorlinkedin"></a>`linkedin` | [`String`](#string) | LinkedIn profile name of the user. | | <a id="mergerequestauthorlocation"></a>`location` | [`String`](#string) | Location of the user. | | <a id="mergerequestauthormergerequestinteraction"></a>`mergeRequestInteraction` | [`UserMergeRequestInteraction`](#usermergerequestinteraction) | Details of this user's interactions with the merge request. | @@ -21070,6 +21099,7 @@ A user participating in a merge request. | <a id="mergerequestparticipantid"></a>`id` | [`ID!`](#id) | ID of the user. | | <a id="mergerequestparticipantide"></a>`ide` | [`Ide`](#ide) | IDE settings. | | <a id="mergerequestparticipantjobtitle"></a>`jobTitle` | [`String`](#string) | Job title of the user. | +| <a id="mergerequestparticipantlastactivityon"></a>`lastActivityOn` | [`Date`](#date) | Date the user last performed any actions. | | <a id="mergerequestparticipantlinkedin"></a>`linkedin` | [`String`](#string) | LinkedIn profile name of the user. | | <a id="mergerequestparticipantlocation"></a>`location` | [`String`](#string) | Location of the user. | | <a id="mergerequestparticipantmergerequestinteraction"></a>`mergeRequestInteraction` | [`UserMergeRequestInteraction`](#usermergerequestinteraction) | Details of this user's interactions with the merge request. | @@ -21386,6 +21416,7 @@ A user assigned to a merge request as a reviewer. | <a id="mergerequestreviewerid"></a>`id` | [`ID!`](#id) | ID of the user. | | <a id="mergerequestrevieweride"></a>`ide` | [`Ide`](#ide) | IDE settings. | | <a id="mergerequestreviewerjobtitle"></a>`jobTitle` | [`String`](#string) | Job title of the user. | +| <a id="mergerequestreviewerlastactivityon"></a>`lastActivityOn` | [`Date`](#date) | Date the user last performed any actions. | | <a id="mergerequestreviewerlinkedin"></a>`linkedin` | [`String`](#string) | LinkedIn profile name of the user. | | <a id="mergerequestreviewerlocation"></a>`location` | [`String`](#string) | Location of the user. | | <a id="mergerequestreviewermergerequestinteraction"></a>`mergeRequestInteraction` | [`UserMergeRequestInteraction`](#usermergerequestinteraction) | Details of this user's interactions with the merge request. | @@ -22099,10 +22130,21 @@ A user with access to the organization. | Name | Type | Description | | ---- | ---- | ----------- | -| <a id="organizationuserbadges"></a>`badges` **{warning-solid}** | [`[String!]`](#string) | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. Badges describing the user within the organization. | +| <a id="organizationuserbadges"></a>`badges` **{warning-solid}** | [`[OrganizationUserBadge!]`](#organizationuserbadge) | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. Badges describing the user within the organization. | | <a id="organizationuserid"></a>`id` **{warning-solid}** | [`ID!`](#id) | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. ID of the organization user. | | <a id="organizationuseruser"></a>`user` **{warning-solid}** | [`UserCore!`](#usercore) | **Introduced** in 16.4. This feature is an Experiment. It can be changed or removed at any time. User that is associated with the organization. | +### `OrganizationUserBadge` + +An organization user badge. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="organizationuserbadgetext"></a>`text` | [`String!`](#string) | Badge text. | +| <a id="organizationuserbadgevariant"></a>`variant` | [`String!`](#string) | Badge variant. | + ### `Package` Represents a package with pipelines in the Package Registry. @@ -26271,6 +26313,7 @@ Core representation of a GitLab user. | <a id="usercoreid"></a>`id` | [`ID!`](#id) | ID of the user. | | <a id="usercoreide"></a>`ide` | [`Ide`](#ide) | IDE settings. | | <a id="usercorejobtitle"></a>`jobTitle` | [`String`](#string) | Job title of the user. | +| <a id="usercorelastactivityon"></a>`lastActivityOn` | [`Date`](#date) | Date the user last performed any actions. | | <a id="usercorelinkedin"></a>`linkedin` | [`String`](#string) | LinkedIn profile name of the user. | | <a id="usercorelocation"></a>`location` | [`String`](#string) | Location of the user. | | <a id="usercorename"></a>`name` | [`String!`](#string) | Human-readable name of the user. Returns `****` if the user is a project bot and the requester does not have permission to view the project. | @@ -30097,6 +30140,7 @@ Name of the feature that the callout is for. | <a id="usercalloutfeaturenameenumnamespace_storage_limit_alert_error_threshold"></a>`NAMESPACE_STORAGE_LIMIT_ALERT_ERROR_THRESHOLD` | Callout feature name for namespace_storage_limit_alert_error_threshold. | | <a id="usercalloutfeaturenameenumnamespace_storage_limit_alert_warning_threshold"></a>`NAMESPACE_STORAGE_LIMIT_ALERT_WARNING_THRESHOLD` | Callout feature name for namespace_storage_limit_alert_warning_threshold. | | <a id="usercalloutfeaturenameenumnamespace_storage_pre_enforcement_banner"></a>`NAMESPACE_STORAGE_PRE_ENFORCEMENT_BANNER` | Callout feature name for namespace_storage_pre_enforcement_banner. | +| <a id="usercalloutfeaturenameenumnew_nav_for_everyone_callout"></a>`NEW_NAV_FOR_EVERYONE_CALLOUT` | Callout feature name for new_nav_for_everyone_callout. | | <a id="usercalloutfeaturenameenumnew_top_level_group_alert"></a>`NEW_TOP_LEVEL_GROUP_ALERT` | Callout feature name for new_top_level_group_alert. | | <a id="usercalloutfeaturenameenumnew_user_signups_cap_reached"></a>`NEW_USER_SIGNUPS_CAP_REACHED` | Callout feature name for new_user_signups_cap_reached. | | <a id="usercalloutfeaturenameenumpersonal_access_token_expiry"></a>`PERSONAL_ACCESS_TOKEN_EXPIRY` | Callout feature name for personal_access_token_expiry. | @@ -31792,6 +31836,7 @@ Implementations: | <a id="userid"></a>`id` | [`ID!`](#id) | ID of the user. | | <a id="useride"></a>`ide` | [`Ide`](#ide) | IDE settings. | | <a id="userjobtitle"></a>`jobTitle` | [`String`](#string) | Job title of the user. | +| <a id="userlastactivityon"></a>`lastActivityOn` | [`Date`](#date) | Date the user last performed any actions. | | <a id="userlinkedin"></a>`linkedin` | [`String`](#string) | LinkedIn profile name of the user. | | <a id="userlocation"></a>`location` | [`String`](#string) | Location of the user. | | <a id="username"></a>`name` | [`String!`](#string) | Human-readable name of the user. Returns `****` if the user is a project bot and the requester does not have permission to view the project. | diff --git a/doc/api/settings.md b/doc/api/settings.md index 670be23336c..0a74afe2abf 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -575,6 +575,7 @@ listed in the descriptions of the relevant settings. | `spam_check_endpoint_url` | string | no | URL of the external Spamcheck service endpoint. Valid URI schemes are `grpc` or `tls`. Specifying `tls` forces communication to be encrypted.| | `spam_check_api_key` | string | no | API key used by GitLab for accessing the Spam Check service endpoint. | | `suggest_pipeline_enabled` | boolean | no | Enable pipeline suggestion banner. | +| `enable_artifact_external_redirect_warning_page` | boolean | no | Show the external redirect page that warns you about user-generated content in GitLab Pages. | | `terminal_max_session_time` | integer | no | Maximum time for web terminal websocket connection (in seconds). Set to `0` for unlimited time. | | `terms` | text | required by: `enforce_terms` | (**Required by:** `enforce_terms`) Markdown content for the ToS. | | `throttle_authenticated_api_enabled` | boolean | no | (**If enabled, requires:** `throttle_authenticated_api_period_in_seconds` and `throttle_authenticated_api_requests_per_period`) Enable authenticated API request rate limit. Helps reduce request volume (for example, from crawlers or abusive bots). | diff --git a/doc/ci/components/index.md b/doc/ci/components/index.md index 9063b6d0378..338e4b2c205 100644 --- a/doc/ci/components/index.md +++ b/doc/ci/components/index.md @@ -29,6 +29,8 @@ A components repository is a GitLab project with a repository that hosts one or If a component requires different versioning from other components, the component should be migrated to its own components repository. +One component repository can have a maximum of 10 components. + ## Create a components repository To create a components repository, you must: @@ -65,17 +67,17 @@ the file structure should be similar to: ```plaintext ├── templates/ -│ └── only_template.yml +│ └── secret-detection.yml ├── README.md └── .gitlab-ci.yml ``` -This example component could be referenced with a path similar to `gitlab.com/my-username/my-component/only_template@<version>`, +This example component could be referenced with a path similar to `gitlab.com/my-namespace/my-project/secret-detection@<version>`, if the project is: - On GitLab.com -- Named `my-component` -- In a personal namespace named `my-username` +- Named `my-project` +- In a personal namespace or group named `my-namespace` The templates directory and the suffix of the configuration file should be excluded from the referenced path. @@ -85,26 +87,32 @@ If the project contains multiple components, then the file structure should be s ├── README.md ├── .gitlab-ci.yml └── templates/ - └── all-scans.yml + ├── all-scans.yml └── secret-detection.yml ``` These components would be referenced with these paths: -- `gitlab.com/my-username/my-component/all-scans` -- `gitlab.com/my-username/my-component/secret-detection` +- `gitlab.com/my-namespace/my-project/all-scans@<version>` +- `gitlab.com/my-namespace/my-project/secret-detection@<version>` + +You can also have components defined as a directory if you want to bundle together multiple related files. +In this case GitLab expects a `template.yml` file to be present: -You can omit the filename in the path if the configuration file is named `template.yml`. -For example, the following component could be referenced with `gitlab.com/my-username/my-component/dast`: +For example: ```plaintext ├── README.md ├── .gitlab-ci.yml -├── templates/ -│ └── dast -│ └── template.yml +└── templates/ + └── dast + ├── docs.md + ├── Dockerfile + └── template.yml ``` +In this example, the component could be referenced with `gitlab.com/my-namespace/my-project/dast@<version>`. + #### Component configurations saved in any directory (deprecated) WARNING: @@ -117,8 +125,8 @@ Components configurations can be saved through the following directory structure components, each file must be in a separate subdirectory. - `README.md`: A documentation file explaining the details of all the components in the repository. -For example, if the project is on GitLab.com, named `my-component`, and in a personal -namespace named `my-username`: +For example, if the project is on GitLab.com, named `my-project`, and in a personal +namespace named `my-namespace`: - Containing a single component and a simple pipeline to test the component, then the file structure might be: @@ -132,7 +140,7 @@ namespace named `my-username`: The `.gitlab-ci.yml` file is not required for a CI/CD component to work, but [testing the component](#test-the-component) in a pipeline in the project is recommended. - This component is referenced with the path `gitlab.com/my-username/my-component@<version>`. + This component is referenced with the path `gitlab.com/my-namespace/my-project@<version>`. - Containing one default component and multiple sub-components, then the file structure might be: @@ -149,9 +157,9 @@ namespace named `my-username`: These components are identified by these paths: - - `gitlab.com/my-username/my-component` - - `gitlab.com/my-username/my-component/unit` - - `gitlab.com/my-username/my-component/integration` + - `gitlab.com/my-namespace/my-project` + - `gitlab.com/my-namespace/my-project/unit` + - `gitlab.com/my-namespace/my-project/integration` It is possible to have a components repository with no default component, by having no `template.yml` in the root directory. @@ -205,7 +213,7 @@ For example: ```yaml include: - - component: gitlab.example.com/my-namespace/my-component@1.0 + - component: gitlab.example.com/my-namespace/my-project@1.0 inputs: stage: build ``` @@ -410,7 +418,7 @@ For example: ```yaml include: # include the component located in the current project from the current SHA - - component: gitlab.com/$CI_PROJECT_PATH/my-component@$CI_COMMIT_SHA + - component: gitlab.com/$CI_PROJECT_PATH/my-project@$CI_COMMIT_SHA inputs: stage: build diff --git a/doc/ci/yaml/inputs.md b/doc/ci/yaml/inputs.md index 9e084cf0020..cf5040408a2 100644 --- a/doc/ci/yaml/inputs.md +++ b/doc/ci/yaml/inputs.md @@ -14,7 +14,8 @@ and subject to change without notice. ## Define input parameters with `spec:inputs` -> `description` keyword [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/415637) in GitLab 16.5. +> - `description` keyword [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/415637) in GitLab 16.5. +> - `options` keyword [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/393401) in GitLab 16.6. Use `spec:inputs` to define input parameters for CI/CD configuration intended to be added to a pipeline with `include`. Use [`include:inputs`](#set-input-parameter-values-with-includeinputs) @@ -43,6 +44,8 @@ When using `spec:inputs`: - Defined inputs are mandatory by default. - Inputs can be made optional by specifying a `default`. Use `default: null` to have no default value. +- Inputs can use `options` to specify a list of allowed values for an input. The limit is 50 options per input. +- If an input uses both `default` and `options`, the default value must be one of the listed options. If not, the pipeline fails with a validation error. - You can optionally use `description` to give a description to a specific input. - A string containing an interpolation block must not exceed 1 MB. - The string inside an interpolation block must not exceed 1 KB. @@ -55,6 +58,7 @@ spec: website: user: default: 'test-user' + options: ['test-user', 'admin-user'] flags: default: null description: 'Sample description of the `flags` input detail.' @@ -66,7 +70,7 @@ spec: In this example: - `website` is mandatory and must be defined. -- `user` is optional. If not defined, the value is `test-user`. +- `user` is optional. If not defined, the value is `test-user`, which is one of the values specified in `options`. - `flags` is optional. If not defined, it has no value. The optional description should give details about the input. ## Set input parameter values with `include:inputs` diff --git a/doc/development/i18n/proofreader.md b/doc/development/i18n/proofreader.md index cea59bae41b..f24ebacab18 100644 --- a/doc/development/i18n/proofreader.md +++ b/doc/development/i18n/proofreader.md @@ -140,7 +140,6 @@ are very appreciative of the work done by translators and proofreaders! - Rıfat Ãœnalmış (Rifat Unalmis) - [GitLab](https://gitlab.com/runalmis), [Crowdin](https://crowdin.com/profile/runalmis) - Ä°smail Arılık - [GitLab](https://gitlab.com/ismailarilik), [Crowdin](https://crowdin.com/profile/ismailarilik) - Ukrainian - - Volodymyr Sobotovych - [GitLab](https://gitlab.com/wheleph), [Crowdin](https://crowdin.com/profile/wheleph) - Andrew Vityuk - [GitLab](https://gitlab.com/3_1_3_u), [Crowdin](https://crowdin.com/profile/andruwa13) - Welsh - Delyth Prys - [GitLab](https://gitlab.com/Delyth), [Crowdin](https://crowdin.com/profile/DelythPrys) diff --git a/doc/subscriptions/self_managed/index.md b/doc/subscriptions/self_managed/index.md index 05d00323e2a..3d6e2b9af5f 100644 --- a/doc/subscriptions/self_managed/index.md +++ b/doc/subscriptions/self_managed/index.md @@ -34,7 +34,8 @@ Prorated charges are not possible without a quarterly usage report. ## View user totals -You can view users for your license and determine if you've gone over your subscription. +View the amount of users in your instance to determine if they exceed the amount +paid for in your subscription. 1. On the left sidebar, select **Search or go to**. 1. Select **Admin Area**. @@ -44,17 +45,19 @@ The lists of users are displayed. ### Billable users -A _billable user_ counts against the number of subscription seats. Every user is considered a -billable user, with the following exceptions: - -- [Deactivated users](../../administration/moderate_users.md#deactivate-a-user) and - [blocked users](../../administration/moderate_users.md#block-a-user) don't count as billable users in the current subscription. When they are either deactivated or blocked they release a _billable user_ seat. However, they may - count toward overages in the subscribed seat count. -- Users who are [pending approval](../../administration/moderate_users.md#users-pending-approval). -- Users with only the [Minimal Access role](../../user/permissions.md#users-with-minimal-access) on self-managed Ultimate subscriptions or any GitLab.com subscriptions. -- Users with only the [Guest or Minimal Access roles on an Ultimate subscription](#free-guest-users). -- Users without project or group memberships on an Ultimate subscription. -- GitLab-created service accounts: +Billable users count toward the number of subscription seats purchased in your subscription. + +A user is not counted as a billable user if: + +- They are [deactivated](../../administration/moderate_users.md#deactivate-a-user) or + [blocked](../../administration/moderate_users.md#block-a-user). + If the user occupied a seat prior to being deactivated or blocked, + the user is included in the number of [maximum users](#maximum-users). +- They are [pending approval](../../administration/moderate_users.md#users-pending-approval). +- They have only the [Minimal Access role](../../user/permissions.md#users-with-minimal-access) on self-managed Ultimate subscriptions or any GitLab.com subscriptions. +- They have the [Guest or Minimal Access roles on an Ultimate subscription](#free-guest-users). +- They have project or group memberships on an Ultimate subscription. +- The account is a GitLab-created service account: - [Ghost User](../../user/profile/account/delete_account.md#associated-records). - Bots such as: - [Support Bot](../../user/project/service_desk/configure.md#support-bot-user). @@ -62,7 +65,7 @@ billable user, with the following exceptions: - [Bot users for groups](../../user/group/settings/group_access_tokens.md#bot-users-for-groups). - Other [internal users](../../development/internal_users.md#internal-users). -**Billable users** as reported in the `/admin` section is updated once per day. +The amount of **Billable users** is reported once a day in the Admin Area. ### Maximum users diff --git a/doc/update/deprecations.md b/doc/update/deprecations.md index c36203ee492..8c141559cd2 100644 --- a/doc/update/deprecations.md +++ b/doc/update/deprecations.md @@ -673,6 +673,33 @@ If you do access the internal container registry API and use the original tag de <div class="deprecation breaking-change" data-milestone="17.0"> +### Legacy Geo Prometheus metrics + +<div class="deprecation-notes"> +- Announced in GitLab <span class="milestone">16.6</span> +- Removal in GitLab <span class="milestone">17.0</span> ([breaking change](https://docs.gitlab.com/ee/update/terminology.html#breaking-change)) +- To discuss this change or learn more, see the [deprecation issue](https://gitlab.com/gitlab-org/gitlab/-/issues/430192). +</div> + +Following the migration of projects to the [Geo self-service framework](https://docs.gitlab.com/ee/development/geo/framework.html) we have deprecated a number of [Prometheus](https://docs.gitlab.com/ee/administration/monitoring/prometheus/) metrics. +The following Geo-related Prometheus metrics are deprecated and will be removed in 17.0. +The table below lists the deprecated metrics and their respective replacements. The replacements are available in GitLab 16.3.0 and later. + +| Deprecated metric | Replacement metric | +| ---------------------------------------- | ---------------------------------------------- | +| `geo_repositories_synced` | `geo_project_repositories_synced` | +| `geo_repositories_failed` | `geo_project_repositories_failed` | +| `geo_repositories_checksummed` | `geo_project_repositories_checksummed` | +| `geo_repositories_checksum_failed` | `geo_project_repositories_checksum_failed` | +| `geo_repositories_verified` | `geo_project_repositories_verified` | +| `geo_repositories_verification_failed` | `geo_project_repositories_verification_failed` | +| `geo_repositories_checksum_mismatch` | None available | +| `geo_repositories_retrying_verification` | None available | + +</div> + +<div class="deprecation breaking-change" data-milestone="17.0"> + ### Maintainer role providing the ability to change Package settings using GraphQL API <div class="deprecation-notes"> diff --git a/doc/user/group/index.md b/doc/user/group/index.md index ab8cba48dc4..1a4fa9df305 100644 --- a/doc/user/group/index.md +++ b/doc/user/group/index.md @@ -202,7 +202,7 @@ A table displays the member's: NOTE: The display of group members' **Source** might be inconsistent. -For more information, see [issue 414557](https://gitlab.com/gitlab-org/gitlab/-/issues/414557). +For more information, see [issue 23020](https://gitlab.com/gitlab-org/gitlab/-/issues/23020). ## Filter and sort members in a group diff --git a/doc/user/product_analytics/index.md b/doc/user/product_analytics/index.md index 6f5f4ed4db8..e9528a11a3e 100644 --- a/doc/user/product_analytics/index.md +++ b/doc/user/product_analytics/index.md @@ -101,7 +101,19 @@ Prerequisites: 1. Expand **Configure** and enter the configuration values. 1. Select **Save changes**. -## Instrument a GitLab project +## Onboard a GitLab project + +Onboarding a GitLab project means preparing it to receive events that are used for product analytics. + +To onboard a project: + +1. On the left sidebar, select **Search or go to** and find your project. +1. Select **Analyze > Analytics dashboards**. +1. Under **Product analytics**, select **Set up**. +1. Select **Set up product analytics**. +Your instance is being created, and the project onboarded. + +## Instrument your application To instrument code to collect data, use one or more of the existing SDKs: diff --git a/doc/user/project/members/index.md b/doc/user/project/members/index.md index 1fe6e3523b3..6df33a4fb06 100644 --- a/doc/user/project/members/index.md +++ b/doc/user/project/members/index.md @@ -190,7 +190,7 @@ To add a group to a project: 1. Select **Invite**. The members of the group are not displayed on the **Members** tab. -Private groups are masked from unauthenticated users. +Private groups are masked from unauthorized users. The **Members** tab shows: - Members who are directly assigned to the project. diff --git a/doc/user/project/members/share_project_with_groups.md b/doc/user/project/members/share_project_with_groups.md index 4474cb55929..94dbb922c0b 100644 --- a/doc/user/project/members/share_project_with_groups.md +++ b/doc/user/project/members/share_project_with_groups.md @@ -77,7 +77,7 @@ In addition: - On the group's page, the project is listed on the **Shared projects** tab. - On the project's **Members** page, the group is listed on the **Groups** tab. - From [GitLab 16.6](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/134623), - the invited group's name and membership source will be hidden unless: + the invited group's name and membership source will be masked unless: - the group is public, or - the current user is a member of the group, or - the current user is a member of the project. diff --git a/gems/gitlab-http/Gemfile.lock b/gems/gitlab-http/Gemfile.lock index 1023e12efd6..1f4910d1d57 100644 --- a/gems/gitlab-http/Gemfile.lock +++ b/gems/gitlab-http/Gemfile.lock @@ -20,6 +20,7 @@ PATH specs: gitlab-http (0.1.0) activesupport (~> 7) + concurrent-ruby (~> 1.2) httparty (~> 0.21.0) ipaddress (~> 0.8.3) nokogiri (~> 1.15.4) diff --git a/gems/gitlab-http/README.md b/gems/gitlab-http/README.md index 13ff330bb19..e717afbdb2c 100644 --- a/gems/gitlab-http/README.md +++ b/gems/gitlab-http/README.md @@ -24,16 +24,27 @@ end ### Actions -Basic examples; +Basic examples: ```ruby Gitlab::HTTP_V2.post(uri, body: body) Gitlab::HTTP_V2.try_get(uri, params) -response = Gitlab::HTTP_V2.head(project_url, verify: true) +response = Gitlab::HTTP_V2.head(project_url, verify: true) # returns an HTTParty::Response object -Gitlab::HTTP_V2.post(path, base_uri: base_uri, **params) +Gitlab::HTTP_V2.post(path, base_uri: base_uri, **params) # returns an HTTParty::Response object +``` + +Async usage examples: + +```ruby +lazy_response = Gitlab::HTTP_V2.get(location, async: true) + +lazy_response.execute # starts the request and returns the same LazyResponse object +lazy_response.wait # waits for the request to finish and returns the same LazyResponse object + +response = lazy_response.value # returns an HTTParty::Response object ``` ## Development diff --git a/gems/gitlab-http/gitlab-http.gemspec b/gems/gitlab-http/gitlab-http.gemspec index 6146ba7f78b..0033f17447b 100644 --- a/gems/gitlab-http/gitlab-http.gemspec +++ b/gems/gitlab-http/gitlab-http.gemspec @@ -20,6 +20,7 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_runtime_dependency 'activesupport', '~> 7' + spec.add_runtime_dependency 'concurrent-ruby', '~> 1.2' spec.add_runtime_dependency 'httparty', '~> 0.21.0' spec.add_runtime_dependency 'ipaddress', '~> 0.8.3' spec.add_runtime_dependency 'nokogiri', '~> 1.15.4' diff --git a/gems/gitlab-http/lib/gitlab/http_v2/client.rb b/gems/gitlab-http/lib/gitlab/http_v2/client.rb index c10197e0385..52c9ab897f5 100644 --- a/gems/gitlab-http/lib/gitlab/http_v2/client.rb +++ b/gems/gitlab-http/lib/gitlab/http_v2/client.rb @@ -4,7 +4,8 @@ require 'httparty' require 'net/http' require 'active_support/all' require_relative 'new_connection_adapter' -require_relative "exceptions" +require_relative 'exceptions' +require_relative 'lazy_response' module Gitlab module HTTP_V2 @@ -45,9 +46,12 @@ module Gitlab # TODO: This overwrites a method implemented by `HTTPParty` # The calls to `get/...` will call this method instead of `httparty_perform_request` def perform_request(http_method, path, options, &block) + raise_if_options_are_invalid(options) raise_if_blocked_by_silent_mode(http_method) if options.delete(:silent_mode_enabled) log_info = options.delete(:extra_log_info) + async = options.delete(:async) + options_with_timeouts = if !options.has_key?(:timeout) options.with_defaults(DEFAULT_TIMEOUT_OPTIONS) @@ -57,29 +61,57 @@ module Gitlab if options[:stream_body] httparty_perform_request(http_method, path, options_with_timeouts, &block) + elsif async + async_perform_request(http_method, path, options, options_with_timeouts, log_info, &block) else - begin - start_time = nil - read_total_timeout = options.fetch(:timeout, DEFAULT_READ_TOTAL_TIMEOUT) - - httparty_perform_request(http_method, path, options_with_timeouts) do |fragment| - start_time ||= system_monotonic_time - elapsed = system_monotonic_time - start_time - - raise ReadTotalTimeout, "Request timed out after #{elapsed} seconds" if elapsed > read_total_timeout - - yield fragment if block - end - rescue HTTParty::RedirectionTooDeep - raise RedirectionTooDeep - rescue *HTTP_ERRORS => e - extra_info = log_info || {} - extra_info = log_info.call(e, path, options) if log_info.respond_to?(:call) - configuration.log_exception(e, extra_info) - - raise e + sync_perform_request(http_method, path, options, options_with_timeouts, log_info, &block) + end + end + + def async_perform_request(http_method, path, options, options_with_timeouts, log_info, &block) + start_time = nil + read_total_timeout = options.fetch(:timeout, DEFAULT_READ_TOTAL_TIMEOUT) + + promise = Concurrent::Promise.new do + httparty_perform_request(http_method, path, options_with_timeouts) do |fragment| + start_time ||= system_monotonic_time + elapsed = system_monotonic_time - start_time + + raise ReadTotalTimeout, "Request timed out after #{elapsed} seconds" if elapsed > read_total_timeout + + yield fragment if block end end + + LazyResponse.new(promise, path, options, log_info) + end + + def sync_perform_request(http_method, path, options, options_with_timeouts, log_info, &block) + start_time = nil + read_total_timeout = options.fetch(:timeout, DEFAULT_READ_TOTAL_TIMEOUT) + + httparty_perform_request(http_method, path, options_with_timeouts) do |fragment| + start_time ||= system_monotonic_time + elapsed = system_monotonic_time - start_time + + raise ReadTotalTimeout, "Request timed out after #{elapsed} seconds" if elapsed > read_total_timeout + + yield fragment if block + end + rescue HTTParty::RedirectionTooDeep + raise RedirectionTooDeep + rescue *HTTP_ERRORS => e + extra_info = log_info || {} + extra_info = log_info.call(e, path, options) if log_info.respond_to?(:call) + configuration.log_exception(e, extra_info) + + raise e + end + + def raise_if_options_are_invalid(options) + return unless options[:async] && (options[:stream_body] || options[:silent_mode_enabled]) + + raise ArgumentError, '`async` cannot be used with `stream_body` or `silent_mode_enabled`' end def raise_if_blocked_by_silent_mode(http_method) diff --git a/gems/gitlab-http/lib/gitlab/http_v2/lazy_response.rb b/gems/gitlab-http/lib/gitlab/http_v2/lazy_response.rb new file mode 100644 index 00000000000..65d1ab96644 --- /dev/null +++ b/gems/gitlab-http/lib/gitlab/http_v2/lazy_response.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Gitlab + module HTTP_V2 + class LazyResponse + NotExecutedError = Class.new(StandardError) + + attr_reader :promise + + delegate :state, to: :promise + + def initialize(promise, path, options, log_info) + @promise = promise + @path = path + @options = options + @log_info = log_info + end + + def execute + @promise.execute + self + end + + def wait + @promise.wait + self + end + + def value + raise NotExecutedError, '`execute` must be called before `value`' if @promise.unscheduled? + + wait # wait for the promise to be completed + + raise @promise.reason if @promise.rejected? + + @promise.value + rescue HTTParty::RedirectionTooDeep + raise HTTP_V2::RedirectionTooDeep + rescue *HTTP_V2::HTTP_ERRORS => e + extra_info = @log_info || {} + extra_info = @log_info.call(e, @path, @options) if @log_info.respond_to?(:call) + Gitlab::HTTP_V2.configuration.log_exception(e, extra_info) + + raise e + end + end + end +end diff --git a/gems/gitlab-http/spec/gitlab/http_v2_spec.rb b/gems/gitlab-http/spec/gitlab/http_v2_spec.rb index bfa1dcd2633..3151761d375 100644 --- a/gems/gitlab-http/spec/gitlab/http_v2_spec.rb +++ b/gems/gitlab-http/spec/gitlab/http_v2_spec.rb @@ -450,4 +450,101 @@ RSpec.describe Gitlab::HTTP_V2, feature_category: :shared do end end end + + context 'when options[:async] is true' do + context 'when it is a valid request' do + before do + stub_full_request('http://example.org', method: :any).to_return(status: 200, body: 'hello world') + end + + it 'returns a LazyResponse' do + result = described_class.get('http://example.org', async: true) + + expect(result).to be_a(Gitlab::HTTP_V2::LazyResponse) + expect(result.state).to eq(:unscheduled) + + expect(result.execute).to be_a(Gitlab::HTTP_V2::LazyResponse) + expect(result.wait).to be_a(Gitlab::HTTP_V2::LazyResponse) + + expect(result.value).to be_a(HTTParty::Response) + expect(result.value.body).to eq('hello world') + end + end + + context 'when the URL is denied' do + let(:url) { 'http://localhost:3003' } + let(:error_class) { Gitlab::HTTP_V2::BlockedUrlError } + let(:opts) { {} } + + let(:result) do + described_class.get(url, allow_local_requests: false, async: true, **opts) + end + + it 'returns a LazyResponse with error value' do + expect(result).to be_a(Gitlab::HTTP_V2::LazyResponse) + + expect { result.execute.value }.to raise_error(error_class) + end + + it 'logs the exception' do + expect(described_class.configuration) + .to receive(:log_exception) + .with(instance_of(error_class), {}) + + expect { result.execute.value }.to raise_error(error_class) + end + + context 'with extra_log_info as hash' do + let(:opts) { { extra_log_info: { a: :b } } } + + it 'handles the request' do + expect(described_class.configuration) + .to receive(:log_exception) + .with(instance_of(error_class), { a: :b }) + + expect { result.execute.value }.to raise_error(error_class) + end + end + + context 'with extra_log_info as proc' do + let(:extra_log_info) do + proc do |error, url, options| + { klass: error.class, url: url, options: options } + end + end + + let(:opts) { { extra_log_info: extra_log_info } } + + it 'handles the request' do + expect(described_class.configuration) + .to receive(:log_exception) + .with(instance_of(error_class), { url: url, klass: error_class, options: { allow_local_requests: false } }) + + expect { result.execute.value }.to raise_error(error_class) + end + end + end + end + + context 'when options[:async] and options[:stream_body] are true' do + before do + stub_full_request('http://example.org', method: :any) + end + + it 'raises an ArgumentError' do + expect { described_class.get('http://example.org', async: true, stream_body: true) } + .to raise_error(ArgumentError, '`async` cannot be used with `stream_body` or `silent_mode_enabled`') + end + end + + context 'when options[:async] and options[:silent_mode_enabled] are true' do + before do + stub_full_request('http://example.org', method: :any) + end + + it 'raises an ArgumentError' do + expect { described_class.get('http://example.org', async: true, silent_mode_enabled: true) } + .to raise_error(ArgumentError, '`async` cannot be used with `stream_body` or `silent_mode_enabled`') + end + end end diff --git a/lib/api/settings.rb b/lib/api/settings.rb index 9120421fadf..7ad4ecd88b1 100644 --- a/lib/api/settings.rb +++ b/lib/api/settings.rb @@ -204,6 +204,7 @@ module API optional :floc_enabled, type: Grape::API::Boolean, desc: 'Enable FloC (Federated Learning of Cohorts)' optional :user_deactivation_emails_enabled, type: Boolean, desc: 'Send emails to users upon account deactivation' optional :suggest_pipeline_enabled, type: Boolean, desc: 'Enable pipeline suggestion banner' + optional :enable_artifact_external_redirect_warning_page, type: Boolean, desc: 'Show the external redirect page that warns you about user-generated content in GitLab Pages' optional :users_get_by_id_limit, type: Integer, desc: "Maximum number of calls to the /users/:id API per 10 minutes per user. Set to 0 for unlimited requests." optional :runner_token_expiration_interval, type: Integer, desc: 'Token expiration interval for shared runners, in seconds' optional :group_runner_token_expiration_interval, type: Integer, desc: 'Token expiration interval for group runners, in seconds' diff --git a/lib/gitlab/background_migration/batched_migration_job.rb b/lib/gitlab/background_migration/batched_migration_job.rb index 952e6d01f1a..9e9fc9b98b7 100644 --- a/lib/gitlab/background_migration/batched_migration_job.rb +++ b/lib/gitlab/background_migration/batched_migration_job.rb @@ -130,7 +130,7 @@ module Gitlab end def base_relation - define_batchable_model(batch_table, connection: connection) + define_batchable_model(batch_table, connection: connection, primary_key: batch_column) .where(batch_column => start_id..end_id) end diff --git a/lib/gitlab/database/dynamic_model_helpers.rb b/lib/gitlab/database/dynamic_model_helpers.rb index 83edf77f37e..18854530278 100644 --- a/lib/gitlab/database/dynamic_model_helpers.rb +++ b/lib/gitlab/database/dynamic_model_helpers.rb @@ -5,7 +5,7 @@ module Gitlab module DynamicModelHelpers BATCH_SIZE = 1_000 - def define_batchable_model(table_name, connection:) + def define_batchable_model(table_name, connection:, primary_key: nil) klass = Class.new(ActiveRecord::Base) do include EachBatch @@ -13,6 +13,7 @@ module Gitlab self.inheritance_column = :_type_disabled end + klass.primary_key = primary_key if connection.primary_keys(table_name).length > 1 klass.connection = connection klass end diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index efcceafda90..a57bce789c7 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -18,8 +18,8 @@ module Gitlab include AsyncConstraints::MigrationHelpers include WraparoundVacuumHelpers - def define_batchable_model(table_name, connection: self.connection) - super(table_name, connection: connection) + def define_batchable_model(table_name, connection: self.connection, primary_key: nil) + super(table_name, connection: connection, primary_key: primary_key) end def each_batch(table_name, connection: self.connection, **kwargs) @@ -821,6 +821,7 @@ module Gitlab primary_key: :id, batch_size: 20_000, sub_batch_size: 1000, + pause_ms: 100, interval: 2.minutes ) @@ -848,6 +849,7 @@ module Gitlab conversions.keys, conversions.values, job_interval: interval, + pause_ms: pause_ms, batch_size: batch_size, sub_batch_size: sub_batch_size) end diff --git a/lib/gitlab/database/migrations/batched_background_migration_helpers.rb b/lib/gitlab/database/migrations/batched_background_migration_helpers.rb index 64cde273a59..3d4ac113bf6 100644 --- a/lib/gitlab/database/migrations/batched_background_migration_helpers.rb +++ b/lib/gitlab/database/migrations/batched_background_migration_helpers.rb @@ -72,6 +72,7 @@ module Gitlab batch_max_value: nil, batch_class_name: BATCH_CLASS_NAME, batch_size: BATCH_SIZE, + pause_ms: 100, max_batch_size: nil, sub_batch_size: SUB_BATCH_SIZE, gitlab_schema: nil @@ -105,6 +106,7 @@ module Gitlab column_name: batch_column_name, job_arguments: job_arguments, interval: job_interval, + pause_ms: pause_ms, min_value: batch_min_value, max_value: batch_max_value, batch_class_name: batch_class_name, diff --git a/lib/gitlab/github_import/attachments_downloader.rb b/lib/gitlab/github_import/attachments_downloader.rb index 4db55a6aabb..df9c6c8342d 100644 --- a/lib/gitlab/github_import/attachments_downloader.rb +++ b/lib/gitlab/github_import/attachments_downloader.rb @@ -29,8 +29,8 @@ module Gitlab validate_content_length validate_filepath - redirection_url = get_assets_download_redirection_url - file = download_from(redirection_url) + download_url = get_assets_download_redirection_url + file = download_from(download_url) validate_symlink file @@ -60,16 +60,16 @@ module Gitlab options[:follow_redirects] = false response = Gitlab::HTTP.perform_request(Net::HTTP::Get, file_url, options) - raise_error("expected a redirect response, got #{response.code}") unless response.redirection? - redirection_url = response.headers[:location] - filename = URI.parse(redirection_url).path + download_url = if response.redirection? + response.headers[:location] + else + file_url + end - unless Gitlab::GithubImport::Markdown::Attachment::MEDIA_TYPES.any? { |type| filename.ends_with?(type) } - raise UnsupportedAttachmentError - end + file_type_valid?(URI.parse(download_url).path) - redirection_url + download_url end def github_assets_url_regex @@ -89,6 +89,12 @@ module Gitlab File.join(dir, filename) end end + + def file_type_valid?(file_url) + return if Gitlab::GithubImport::Markdown::Attachment::MEDIA_TYPES.any? { |type| file_url.ends_with?(type) } + + raise UnsupportedAttachmentError + end end end end diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb index b2027791e9d..5f819f060e4 100644 --- a/lib/gitlab/usage_data.rb +++ b/lib/gitlab/usage_data.rb @@ -174,7 +174,6 @@ module Gitlab prometheus_enabled: alt_usage_data(fallback: nil) { Gitlab::Prometheus::Internal.prometheus_enabled? }, prometheus_metrics_enabled: alt_usage_data(fallback: nil) { Gitlab::Metrics.prometheus_metrics_enabled? }, reply_by_email_enabled: alt_usage_data(fallback: nil) { Gitlab::Email::IncomingEmail.enabled? }, - web_ide_clientside_preview_enabled: alt_usage_data(fallback: nil) { false }, signup_enabled: alt_usage_data(fallback: nil) { Gitlab::CurrentSettings.allow_signup? }, grafana_link_enabled: alt_usage_data(fallback: nil) { Gitlab::CurrentSettings.grafana_enabled? }, gitpod_enabled: alt_usage_data(fallback: nil) { Gitlab::CurrentSettings.gitpod_enabled? } diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 2185dc9b941..afdc179ece9 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3537,6 +3537,9 @@ msgstr "" msgid "AdminSettings|Enable smartcn custom analyzer: Search" msgstr "" +msgid "AdminSettings|Enable the external redirect warning page for job artifacts" +msgstr "" + msgid "AdminSettings|Enabled" msgstr "" @@ -3738,6 +3741,9 @@ msgstr "" msgid "AdminSettings|Setting must be greater than 0." msgstr "" +msgid "AdminSettings|Show a redirect page that warns you about user-generated content in GitLab Pages." +msgstr "" + msgid "AdminSettings|Size and domain settings for Pages static sites." msgstr "" @@ -4797,6 +4803,9 @@ msgstr "" msgid "All environments" msgstr "" +msgid "All groups" +msgstr "" + msgid "All groups and projects" msgstr "" @@ -5190,6 +5199,9 @@ msgstr "" msgid "An error occurred while fetching this tab." msgstr "" +msgid "An error occurred while fetching. Please try again." +msgstr "" + msgid "An error occurred while getting files for - %{branchId}" msgstr "" @@ -21929,6 +21941,9 @@ msgstr "" msgid "GitLab group: %{source_link}" msgstr "" +msgid "GitLab has redesigned the left sidebar to address customer feedback. View details in %{blog_link_start}this blog post%{link_end}. Here's how to %{issues_link_start}file an issue%{link_end} with the GitLab product team." +msgstr "" + msgid "GitLab informs you if a new version is available. %{link_start}What information does GitLab Inc. collect?%{link_end}" msgstr "" @@ -36282,6 +36297,9 @@ msgstr "" msgid "ProductAnalytics|All Pages" msgstr "" +msgid "ProductAnalytics|All Returning Users Compared" +msgstr "" + msgid "ProductAnalytics|All Sessions Compared" msgstr "" @@ -36309,6 +36327,9 @@ msgstr "" msgid "ProductAnalytics|Compares all events against each other" msgstr "" +msgid "ProductAnalytics|Compares all returning users against each other" +msgstr "" + msgid "ProductAnalytics|Compares all user sessions against each other" msgstr "" @@ -36351,7 +36372,7 @@ msgstr "" msgid "ProductAnalytics|How many sessions a user has" msgstr "" -msgid "ProductAnalytics|How often sessions are repeated" +msgid "ProductAnalytics|How often users returned compared to all sessions" msgstr "" msgid "ProductAnalytics|Instrument your application" @@ -36372,6 +36393,9 @@ msgstr "" msgid "ProductAnalytics|Measure all or specific Page Views" msgstr "" +msgid "ProductAnalytics|Measure all returning users" +msgstr "" + msgid "ProductAnalytics|Measure all sessions" msgstr "" @@ -36387,10 +36411,13 @@ msgstr "" msgid "ProductAnalytics|Page Views" msgstr "" +msgid "ProductAnalytics|Percentage of Users Returning" +msgstr "" + msgid "ProductAnalytics|Product analytics onboarding" msgstr "" -msgid "ProductAnalytics|Repeat Visit Percentage" +msgid "ProductAnalytics|Returning Users" msgstr "" msgid "ProductAnalytics|SDK application ID" @@ -37071,6 +37098,9 @@ msgstr "" msgid "Project export started. A download link will be sent by email and made available on this page." msgstr "" +msgid "Project groups" +msgstr "" + msgid "Project has too many %{label_for_message} to search" msgstr "" @@ -43297,6 +43327,9 @@ msgstr "" msgid "SecurityOrchestration|You already have the maximum %{maximumAllowed} %{policyType} policies." msgstr "" +msgid "SecurityOrchestration|You can't unprotect this branch because its protection is enforced by one or more %{security_policies_link_start}security policies%{security_policies_link_end}. %{learn_more_link_start}Learn more%{learn_more_link_end}." +msgstr "" + msgid "SecurityOrchestration|You don't have any security policies yet" msgstr "" @@ -54361,6 +54394,9 @@ msgstr "" msgid "WorkItem|Due date" msgstr "" +msgid "WorkItem|Epic" +msgstr "" + msgid "WorkItem|Existing task" msgstr "" diff --git a/package.json b/package.json index 83ffbb04323..ce3c2819411 100644 --- a/package.json +++ b/package.json @@ -254,7 +254,7 @@ "chalk": "^2.4.1", "commander": "^2.20.3", "custom-jquery-matchers": "^2.1.0", - "eslint": "8.52.0", + "eslint": "8.53.0", "eslint-import-resolver-jest": "3.0.2", "eslint-import-resolver-webpack": "0.13.8", "eslint-plugin-import": "^2.29.0", diff --git a/qa/Gemfile b/qa/Gemfile index 06de6004742..3b0e8fa888c 100644 --- a/qa/Gemfile +++ b/qa/Gemfile @@ -2,7 +2,7 @@ source 'https://rubygems.org' -gem 'gitlab-qa', '~> 12', '>= 12.4.1', require: 'gitlab/qa' +gem 'gitlab-qa', '~> 12', '>= 12.5.0', require: 'gitlab/qa' gem 'gitlab_quality-test_tooling', '~> 0.9.3', require: false gem 'gitlab-utils', path: '../gems/gitlab-utils' gem 'activesupport', '~> 7.0.8' # This should stay in sync with the root's Gemfile diff --git a/qa/Gemfile.lock b/qa/Gemfile.lock index 81602f9ecce..a1563a7351e 100644 --- a/qa/Gemfile.lock +++ b/qa/Gemfile.lock @@ -121,7 +121,7 @@ GEM gitlab (4.19.0) httparty (~> 0.20) terminal-table (>= 1.5.1) - gitlab-qa (12.4.1) + gitlab-qa (12.5.0) activesupport (>= 6.1, < 7.1) gitlab (~> 4.19) http (~> 5.0) @@ -351,7 +351,7 @@ DEPENDENCIES faraday-retry (~> 2.2) fog-core (= 2.1.0) fog-google (~> 1.19) - gitlab-qa (~> 12, >= 12.4.1) + gitlab-qa (~> 12, >= 12.5.0) gitlab-utils! gitlab_quality-test_tooling (~> 0.9.3) influxdb-client (~> 2.9) diff --git a/spec/controllers/groups/settings/applications_controller_spec.rb b/spec/controllers/groups/settings/applications_controller_spec.rb index c398fd044c2..aa50ef9a92c 100644 --- a/spec/controllers/groups/settings/applications_controller_spec.rb +++ b/spec/controllers/groups/settings/applications_controller_spec.rb @@ -23,17 +23,55 @@ RSpec.describe Groups::Settings::ApplicationsController do expect(response).to render_template :index expect(assigns[:scopes]).to be_kind_of(Doorkeeper::OAuth::Scopes) end - end - context 'when user is not owner' do - before do - group.add_maintainer(user) + context 'when admin mode is enabled' do + let!(:user) { create(:user, :admin) } + + before do + Gitlab::Session.with_session(controller.session) do + controller.current_user_mode.request_admin_mode! + controller.current_user_mode.enable_admin_mode!(password: user.password) + end + end + + it 'renders the applications page' do + get :index, params: { group_id: group } + + expect(response).to render_template :index + expect(assigns[:scopes]).to be_kind_of(Doorkeeper::OAuth::Scopes) + end end + end - it 'renders a 404' do - get :index, params: { group_id: group } + %w[guest reporter developer maintainer].each do |role| + context "when user is a #{role}" do + before do + group.send("add_#{role}", user) + end + + it 'renders a 404' do + get :index, params: { group_id: group } + + expect(response).to have_gitlab_http_status(:not_found) + end + + context "when admin mode is enabled for the admin user who is a #{role} of a group" do + let!(:user) { create(:user, :admin) } + + before do + Gitlab::Session.with_session(controller.session) do + controller.current_user_mode.request_admin_mode! + controller.current_user_mode.enable_admin_mode!(password: user.password) + end + end + + it 'renders the applications page' do + get :index, params: { group_id: group } - expect(response).to have_gitlab_http_status(:not_found) + expect(response).to render_template :index + expect(assigns[:scopes]).to be_kind_of(Doorkeeper::OAuth::Scopes) + end + end end end end @@ -44,23 +82,61 @@ RSpec.describe Groups::Settings::ApplicationsController do group.add_owner(user) end - it 'renders the application form' do + it 'renders the edit application page' do get :edit, params: { group_id: group, id: application.id } expect(response).to render_template :edit expect(assigns[:scopes]).to be_kind_of(Doorkeeper::OAuth::Scopes) end - end - context 'when user is not owner' do - before do - group.add_maintainer(user) + context 'when admin mode is enabled' do + let!(:user) { create(:user, :admin) } + + before do + Gitlab::Session.with_session(controller.session) do + controller.current_user_mode.request_admin_mode! + controller.current_user_mode.enable_admin_mode!(password: user.password) + end + end + + it 'renders the edit application page' do + get :edit, params: { group_id: group, id: application.id } + + expect(response).to render_template :edit + expect(assigns[:scopes]).to be_kind_of(Doorkeeper::OAuth::Scopes) + end end + end - it 'renders a 404' do - get :edit, params: { group_id: group, id: application.id } + %w[guest reporter developer maintainer].each do |role| + context "when user is a #{role}" do + before do + group.send("add_#{role}", user) + end + + it 'renders a 404' do + get :edit, params: { group_id: group, id: application.id } + + expect(response).to have_gitlab_http_status(:not_found) + end + + context "when admin mode is enabled for the admin user who is a #{role} of a group" do + let!(:user) { create(:user, :admin) } - expect(response).to have_gitlab_http_status(:not_found) + before do + Gitlab::Session.with_session(controller.session) do + controller.current_user_mode.request_admin_mode! + controller.current_user_mode.enable_admin_mode!(password: user.password) + end + end + + it 'renders the edit application page' do + get :edit, params: { group_id: group, id: application.id } + + expect(response).to render_template :edit + expect(assigns[:scopes]).to be_kind_of(Doorkeeper::OAuth::Scopes) + end + end end end end @@ -121,19 +197,71 @@ RSpec.describe Groups::Settings::ApplicationsController do expect(response).to render_template :index end end - end - context 'when user is not owner' do - before do - group.add_maintainer(user) + context 'when admin mode is enabled' do + let!(:user) { create(:user, :admin) } + + before do + Gitlab::Session.with_session(controller.session) do + controller.current_user_mode.request_admin_mode! + controller.current_user_mode.enable_admin_mode!(password: user.password) + end + end + + it 'creates the application' do + create_params = attributes_for(:application, trusted: false, confidential: false, scopes: ['api']) + + expect do + post :create, params: { group_id: group, doorkeeper_application: create_params } + end.to change { Doorkeeper::Application.count }.by(1) + + application = Doorkeeper::Application.last + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template :show + expect(application).to have_attributes(create_params.except(:uid, :owner_type)) + end end + end + + %w[guest reporter developer maintainer].each do |role| + context "when user is a #{role}" do + let(:create_params) { attributes_for(:application, trusted: true, confidential: false, scopes: ['api']) } + + before do + group.send("add_#{role}", user) + end + + it 'renders a 404' do + post :create, params: { group_id: group, doorkeeper_application: create_params } - it 'renders a 404' do - create_params = attributes_for(:application, trusted: true, confidential: false, scopes: ['api']) + expect(response).to have_gitlab_http_status(:not_found) + end + + context "when admin mode is enabled for the admin user who is a #{role} of a group" do + let!(:user) { create(:user, :admin) } + + before do + Gitlab::Session.with_session(controller.session) do + controller.current_user_mode.request_admin_mode! + controller.current_user_mode.enable_admin_mode!(password: user.password) + end + end + + it 'creates the application' do + create_params = attributes_for(:application, trusted: false, confidential: false, scopes: ['api']) - post :create, params: { group_id: group, doorkeeper_application: create_params } + expect do + post :create, params: { group_id: group, doorkeeper_application: create_params } + end.to change { Doorkeeper::Application.count }.by(1) - expect(response).to have_gitlab_http_status(:not_found) + application = Doorkeeper::Application.last + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template :show + expect(application).to have_attributes(create_params.except(:uid, :owner_type)) + end + end end end end @@ -162,6 +290,26 @@ RSpec.describe Groups::Settings::ApplicationsController do expect(json_response['secret']).not_to be_nil end + context 'when admin mode is enabled' do + let!(:user) { create(:user, :admin) } + + before do + Gitlab::Session.with_session(controller.session) do + controller.current_user_mode.request_admin_mode! + controller.current_user_mode.enable_admin_mode!(password: user.password) + end + end + + it { is_expected.to have_gitlab_http_status(:ok) } + it { expect { subject }.to change { application.reload.secret } } + + it 'returns the secret in json format' do + subject + + expect(json_response['secret']).not_to be_nil + end + end + context 'when renew fails' do before do allow_next_found_instance_of(Doorkeeper::Application) do |application| @@ -174,21 +322,42 @@ RSpec.describe Groups::Settings::ApplicationsController do end end - context 'when user is not owner' do - before do - group.add_maintainer(user) - end + %w[guest reporter developer maintainer].each do |role| + context "when user is a #{role}" do + let(:oauth_params) do + { + group_id: group, + id: application.id + } + end - let(:oauth_params) do - { - group_id: group, - id: application.id - } - end + before do + group.send("add_#{role}", user) + end - it 'renders a 404' do - put :renew, params: oauth_params - expect(response).to have_gitlab_http_status(:not_found) + it 'renders a 404' do + put :renew, params: oauth_params + + expect(response).to have_gitlab_http_status(:not_found) + end + + context "when admin mode is enabled for the admin user who is a #{role} of a group" do + let!(:user) { create(:user, :admin) } + + before do + Gitlab::Session.with_session(controller.session) do + controller.current_user_mode.request_admin_mode! + controller.current_user_mode.enable_admin_mode!(password: user.password) + end + end + + it 'returns the secret in json format' do + put :renew, params: oauth_params + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['secret']).not_to be_nil + end + end end end end @@ -230,19 +399,67 @@ RSpec.describe Groups::Settings::ApplicationsController do expect(application).to be_confidential end end - end - context 'when user is not owner' do - before do - group.add_maintainer(user) + context 'when admin mode is enabled' do + let!(:user) { create(:user, :admin) } + + before do + Gitlab::Session.with_session(controller.session) do + controller.current_user_mode.request_admin_mode! + controller.current_user_mode.enable_admin_mode!(password: user.password) + end + end + + it 'updates the application' do + doorkeeper_params = { redirect_uri: 'http://example.com/', trusted: true, confidential: false } + + patch :update, params: { group_id: group, id: application.id, doorkeeper_application: doorkeeper_params } + + application.reload + + expect(response).to redirect_to(group_settings_application_path(group, application)) + expect(application) + .to have_attributes(redirect_uri: 'http://example.com/', trusted: false, confidential: false) + end end + end - it 'renders a 404' do - doorkeeper_params = { redirect_uri: 'http://example.com/', trusted: true, confidential: false } + %w[guest reporter developer maintainer].each do |role| + context "when user is a #{role}" do + before do + group.send("add_#{role}", user) + end - patch :update, params: { group_id: group, id: application.id, doorkeeper_application: doorkeeper_params } + it 'renders a 404' do + doorkeeper_params = { redirect_uri: 'http://example.com/', trusted: true, confidential: false } + + patch :update, params: { group_id: group, id: application.id, doorkeeper_application: doorkeeper_params } + + expect(response).to have_gitlab_http_status(:not_found) + end - expect(response).to have_gitlab_http_status(:not_found) + context "when admin mode is enabled for the admin user who is a #{role} of a group" do + let!(:user) { create(:user, :admin) } + + before do + Gitlab::Session.with_session(controller.session) do + controller.current_user_mode.request_admin_mode! + controller.current_user_mode.enable_admin_mode!(password: user.password) + end + end + + it 'updates the application' do + doorkeeper_params = { redirect_uri: 'http://example.com/', trusted: true, confidential: false } + + patch :update, params: { group_id: group, id: application.id, doorkeeper_application: doorkeeper_params } + + application.reload + + expect(response).to redirect_to(group_settings_application_path(group, application)) + expect(application) + .to have_attributes(redirect_uri: 'http://example.com/', trusted: false, confidential: false) + end + end end end end @@ -259,17 +476,55 @@ RSpec.describe Groups::Settings::ApplicationsController do expect(Doorkeeper::Application.exists?(application.id)).to be_falsy expect(response).to redirect_to(group_settings_applications_url(group)) end - end - context 'when user is not owner' do - before do - group.add_maintainer(user) + context 'when admin mode is enabled' do + let!(:user) { create(:user, :admin) } + + before do + Gitlab::Session.with_session(controller.session) do + controller.current_user_mode.request_admin_mode! + controller.current_user_mode.enable_admin_mode!(password: user.password) + end + end + + it 'deletes the application' do + delete :destroy, params: { group_id: group, id: application.id } + + expect(Doorkeeper::Application.exists?(application.id)).to be_falsy + expect(response).to redirect_to(group_settings_applications_url(group)) + end end + end - it 'renders a 404' do - delete :destroy, params: { group_id: group, id: application.id } + %w[guest reporter developer maintainer].each do |role| + context "when user is a #{role}" do + before do + group.send("add_#{role}", user) + end + + it 'renders a 404' do + delete :destroy, params: { group_id: group, id: application.id } + + expect(response).to have_gitlab_http_status(:not_found) + end + + context "when admin mode is enabled for the admin user who is a #{role} of a group" do + let!(:user) { create(:user, :admin) } - expect(response).to have_gitlab_http_status(:not_found) + before do + Gitlab::Session.with_session(controller.session) do + controller.current_user_mode.request_admin_mode! + controller.current_user_mode.enable_admin_mode!(password: user.password) + end + end + + it 'deletes the application' do + delete :destroy, params: { group_id: group, id: application.id } + + expect(Doorkeeper::Application.exists?(application.id)).to be_falsy + expect(response).to redirect_to(group_settings_applications_url(group)) + end + end end end end diff --git a/spec/controllers/projects/artifacts_controller_spec.rb b/spec/controllers/projects/artifacts_controller_spec.rb index 31e6d6ae5e6..a0548e847a0 100644 --- a/spec/controllers/projects/artifacts_controller_spec.rb +++ b/spec/controllers/projects/artifacts_controller_spec.rb @@ -324,12 +324,32 @@ RSpec.describe Projects::ArtifactsController, feature_category: :build_artifacts end context 'when the file exists' do - it 'renders the file view' do - path = 'ci_artifacts.txt' + context 'when the external redirect page is enabled' do + before do + stub_application_setting(enable_artifact_external_redirect_warning_page: true) + end + + it 'redirects to the user-generated content warning page' do + path = 'ci_artifacts.txt' - get :file, params: { namespace_id: project.namespace, project_id: project, job_id: job, path: path } + get :file, params: { namespace_id: project.namespace, project_id: project, job_id: job, path: path } + + expect(response).to redirect_to(external_file_project_job_artifacts_path(project, job, path: path)) + end + end - expect(response).to redirect_to(external_file_project_job_artifacts_path(project, job, path: path)) + context 'when the external redirect page is disabled' do + before do + stub_application_setting(enable_artifact_external_redirect_warning_page: false) + end + + it 'renders the file view' do + path = 'ci_artifacts.txt' + + get :file, params: { namespace_id: project.namespace, project_id: project, job_id: job, path: path } + + expect(response).to have_gitlab_http_status(:found) + end end end end diff --git a/spec/features/boards/board_filters_spec.rb b/spec/features/boards/board_filters_spec.rb index 386bb196504..a6d5d4926ff 100644 --- a/spec/features/boards/board_filters_spec.rb +++ b/spec/features/boards/board_filters_spec.rb @@ -25,7 +25,6 @@ RSpec.describe 'Issue board filters', :js, feature_category: :team_planning do let_it_be(:board) { create(:board, project: project) } before do - stub_feature_flags(apollo_boards: false) project.add_maintainer(user) sign_in(user) diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index 69019563e73..48b978f7245 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -34,7 +34,6 @@ RSpec.describe 'Project issue boards', :js, feature_category: :team_planning do context 'signed in user' do before do - stub_feature_flags(apollo_boards: false) project.add_maintainer(user) project.add_maintainer(user2) @@ -518,7 +517,6 @@ RSpec.describe 'Project issue boards', :js, feature_category: :team_planning do context 'signed out user' do before do - stub_feature_flags(apollo_boards: false) visit project_board_path(project, board) wait_for_requests end @@ -540,7 +538,6 @@ RSpec.describe 'Project issue boards', :js, feature_category: :team_planning do let_it_be(:user_guest, reload: true) { create(:user) } before do - stub_feature_flags(apollo_boards: false) project.add_guest(user_guest) sign_in(user_guest) visit project_board_path(project, board) diff --git a/spec/features/boards/issue_ordering_spec.rb b/spec/features/boards/issue_ordering_spec.rb index dd63fd8b80e..625a8ddad84 100644 --- a/spec/features/boards/issue_ordering_spec.rb +++ b/spec/features/boards/issue_ordering_spec.rb @@ -15,7 +15,6 @@ RSpec.describe 'Issue Boards', :js, feature_category: :team_planning do let!(:issue3) { create(:labeled_issue, project: project, title: 'testing 3', labels: [label], relative_position: 1) } before do - stub_feature_flags(apollo_boards: false) project.add_maintainer(user) sign_in(user) diff --git a/spec/features/boards/new_issue_spec.rb b/spec/features/boards/new_issue_spec.rb index 8f9c197e6ba..1e44e1d35f9 100644 --- a/spec/features/boards/new_issue_spec.rb +++ b/spec/features/boards/new_issue_spec.rb @@ -13,10 +13,6 @@ RSpec.describe 'Issue Boards new issue', :js, feature_category: :team_planning d let(:board_list_header) { first('[data-testid="board-list-header"]') } let(:project_select_dropdown) { find_by_testid('project-select-dropdown') } - before do - stub_feature_flags(apollo_boards: false) - end - context 'authorized user' do before do project.add_maintainer(user) diff --git a/spec/features/boards/reload_boards_on_browser_back_spec.rb b/spec/features/boards/reload_boards_on_browser_back_spec.rb index 036daee7655..0ca680c5ed5 100644 --- a/spec/features/boards/reload_boards_on_browser_back_spec.rb +++ b/spec/features/boards/reload_boards_on_browser_back_spec.rb @@ -9,8 +9,6 @@ RSpec.describe 'Ensure Boards do not show stale data on browser back', :js, feat context 'authorized user' do before do - stub_feature_flags(apollo_boards: false) - project.add_maintainer(user) sign_in(user) diff --git a/spec/features/boards/sidebar_labels_in_namespaces_spec.rb b/spec/features/boards/sidebar_labels_in_namespaces_spec.rb index 68c2b2587e7..da3dd6ba071 100644 --- a/spec/features/boards/sidebar_labels_in_namespaces_spec.rb +++ b/spec/features/boards/sidebar_labels_in_namespaces_spec.rb @@ -14,8 +14,6 @@ RSpec.describe 'Issue boards sidebar labels select', :js, feature_category: :tea let_it_be(:group_board) { create(:board, group: group) } before do - stub_feature_flags(apollo_boards: false) - load_board group_board_path(group, group_board) end diff --git a/spec/features/boards/sidebar_labels_spec.rb b/spec/features/boards/sidebar_labels_spec.rb index 460d0d232b3..0560cbbfae7 100644 --- a/spec/features/boards/sidebar_labels_spec.rb +++ b/spec/features/boards/sidebar_labels_spec.rb @@ -20,7 +20,6 @@ RSpec.describe 'Project issue boards sidebar labels', :js, feature_category: :te let(:card) { find('.board:nth-child(2)').first('.board-card') } before do - stub_feature_flags(apollo_boards: false) project.add_maintainer(user) sign_in(user) diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb index 71cc9a28575..893f1c246a0 100644 --- a/spec/features/boards/sidebar_spec.rb +++ b/spec/features/boards/sidebar_spec.rb @@ -15,7 +15,6 @@ RSpec.describe 'Project issue boards sidebar', :js, feature_category: :team_plan let_it_be(:issue, reload: true) { create(:issue, project: project, relative_position: 1) } before do - stub_feature_flags(apollo_boards: false) project.add_maintainer(user) sign_in(user) diff --git a/spec/features/boards/user_adds_lists_to_board_spec.rb b/spec/features/boards/user_adds_lists_to_board_spec.rb index cc2afca7657..d202c2a1f7d 100644 --- a/spec/features/boards/user_adds_lists_to_board_spec.rb +++ b/spec/features/boards/user_adds_lists_to_board_spec.rb @@ -29,7 +29,6 @@ RSpec.describe 'User adds lists', :js, feature_category: :team_planning do with_them do before do - stub_feature_flags(apollo_boards: false) sign_in(user) set_cookie('sidebar_collapsed', 'true') diff --git a/spec/features/boards/user_visits_board_spec.rb b/spec/features/boards/user_visits_board_spec.rb index 4741f58d883..cf8709b3a76 100644 --- a/spec/features/boards/user_visits_board_spec.rb +++ b/spec/features/boards/user_visits_board_spec.rb @@ -44,7 +44,6 @@ RSpec.describe 'User visits issue boards', :js, feature_category: :team_planning with_them do before do - stub_feature_flags(apollo_boards: false) visit board_path wait_for_requests @@ -60,7 +59,6 @@ RSpec.describe 'User visits issue boards', :js, feature_category: :team_planning end context "project boards" do - stub_feature_flags(apollo_boards: false) let_it_be(:board) { create_default(:board, project: project) } let(:board_path) { project_boards_path(project, params) } @@ -69,7 +67,6 @@ RSpec.describe 'User visits issue boards', :js, feature_category: :team_planning end context "group boards" do - stub_feature_flags(apollo_boards: false) let_it_be(:board) { create_default(:board, group: group) } let(:board_path) { group_boards_path(group, params) } diff --git a/spec/features/groups/board_sidebar_spec.rb b/spec/features/groups/board_sidebar_spec.rb index 6a1b7d20a25..3fe520ea2ea 100644 --- a/spec/features/groups/board_sidebar_spec.rb +++ b/spec/features/groups/board_sidebar_spec.rb @@ -19,7 +19,6 @@ RSpec.describe 'Group Issue Boards', :js, feature_category: :groups_and_projects let(:card) { find('.board:nth-child(1)').first('.board-card') } before do - stub_feature_flags(apollo_boards: false) sign_in(user) visit group_board_path(group, board) diff --git a/spec/features/groups/board_spec.rb b/spec/features/groups/board_spec.rb index c2d6b80b4c0..e6dc6055e27 100644 --- a/spec/features/groups/board_spec.rb +++ b/spec/features/groups/board_spec.rb @@ -14,8 +14,6 @@ RSpec.describe 'Group Boards', feature_category: :team_planning do let_it_be(:project) { create(:project_empty_repo, group: group) } before do - stub_feature_flags(apollo_boards: false) - group.add_maintainer(user) sign_in(user) @@ -61,8 +59,6 @@ RSpec.describe 'Group Boards', feature_category: :team_planning do let_it_be(:issue2) { create(:issue, title: 'issue2', project: project2) } before do - stub_feature_flags(apollo_boards: false) - project1.add_guest(user) project2.add_reporter(user) diff --git a/spec/features/nav/new_nav_for_everyone_callout_spec.rb b/spec/features/nav/new_nav_for_everyone_callout_spec.rb new file mode 100644 index 00000000000..ad0b57298d7 --- /dev/null +++ b/spec/features/nav/new_nav_for_everyone_callout_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'new navigation for everyone callout', :js, feature_category: :navigation do + let_it_be(:callout_title) { _('GitLab has redesigned the left sidebar to address customer feedback') } + + before do + sign_in(user) + visit root_path + end + + context 'with new navigation previously toggled on' do + let_it_be(:user) { create(:user, use_new_navigation: true) } + + it 'does not show the callout' do + expect(page).to have_css('[data-testid="super-sidebar"]') + expect(page).not_to have_content callout_title + end + end + + context 'with new navigation previously toggled off' do + let_it_be(:user) { create(:user, use_new_navigation: false) } + + it 'shows a callout about the new navigation now being active for everyone' do + expect(page).to have_css('[data-testid="super-sidebar"]') + expect(page).to have_content callout_title + end + + context 'when user dismisses callout' do + it 'hides callout' do + expect(page).to have_content callout_title + + page.within(find('[data-feature-id="new_nav_for_everyone_callout"]')) do + find_by_testid('close-icon').click + end + + wait_for_requests + + visit root_path + + expect(page).not_to have_content callout_title + end + end + end + + context 'with new navigation never toggled on or off' do + let_it_be(:user) { create(:user, use_new_navigation: nil) } + + it 'does not show the callout' do + expect(page).to have_css('[data-testid="super-sidebar"]') + expect(page).not_to have_content callout_title + end + end +end diff --git a/spec/features/nav/new_nav_toggle_spec.rb b/spec/features/nav/new_nav_toggle_spec.rb deleted file mode 100644 index 6872058be8e..00000000000 --- a/spec/features/nav/new_nav_toggle_spec.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'new navigation toggle', :js, feature_category: :navigation do - let_it_be(:user) { create(:user) } - - before do - user.update!(use_new_navigation: user_preference) - sign_in(user) - visit explore_projects_path - end - - context 'when user has new nav disabled' do - let(:user_preference) { false } - - it 'allows to enable new nav', :aggregate_failures do - within '.js-nav-user-dropdown' do - find('a[data-toggle="dropdown"]').click - expect(page).to have_content('Navigation redesign') - - toggle = page.find('.gl-toggle:not(.is-checked)') - toggle.click # reloads the page - end - - wait_for_requests - - expect(user.reload.use_new_navigation).to eq true - end - - it 'shows the old navigation' do - expect(page).to have_selector('.js-navbar') - expect(page).not_to have_selector('[data-testid="super-sidebar"]') - end - end - - context 'when user has new nav enabled' do - let(:user_preference) { true } - - it 'allows to disable new nav', :aggregate_failures do - within '[data-testid="super-sidebar"] [data-testid="user-dropdown"]' do - click_button "#{user.name} user’s menu" - expect(page).to have_content('Navigation redesign') - - toggle = page.find('.gl-toggle.is-checked') - toggle.click # reloads the page - end - - wait_for_requests - - expect(user.reload.use_new_navigation).to eq false - end - - it 'shows the new navigation' do - expect(page).not_to have_selector('.js-navbar') - expect(page).to have_selector('[data-testid="super-sidebar"]') - end - end -end diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js index 824b2a296c6..3f8083aa37d 100644 --- a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js +++ b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js @@ -191,8 +191,6 @@ describe('Batch comments store actions', () => { return actions.publishReview({ dispatch, commit, getters, rootGetters }).then(() => { expect(commit.mock.calls[0]).toEqual(['REQUEST_PUBLISH_REVIEW']); expect(commit.mock.calls[1]).toEqual(['RECEIVE_PUBLISH_REVIEW_SUCCESS']); - - expect(dispatch.mock.calls[0]).toEqual(['updateDiscussionsAfterPublish']); }); }); diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index 5299361a493..f09003edc0c 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -135,8 +135,6 @@ describe('diffs/components/app', () => { jest.spyOn(wrapper.vm, 'fetchDiffFilesBatch').mockImplementation(fetchResolver); jest.spyOn(wrapper.vm, 'fetchCoverageFiles').mockImplementation(fetchResolver); jest.spyOn(wrapper.vm, 'setDiscussions').mockImplementation(() => {}); - jest.spyOn(wrapper.vm, 'unwatchDiscussions').mockImplementation(() => {}); - jest.spyOn(wrapper.vm, 'unwatchRetrievingBatches').mockImplementation(() => {}); store.state.diffs.retrievingBatches = true; store.state.diffs.diffFiles = []; return nextTick(); @@ -151,9 +149,7 @@ describe('diffs/components/app', () => { expect(wrapper.vm.fetchDiffFilesMeta).toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesBatch).toHaveBeenCalled(); expect(wrapper.vm.fetchCoverageFiles).toHaveBeenCalled(); - expect(wrapper.vm.unwatchDiscussions).toHaveBeenCalled(); expect(wrapper.vm.diffFilesLength).toBe(100); - expect(wrapper.vm.unwatchRetrievingBatches).toHaveBeenCalled(); }); it('calls batch methods if diffsBatchLoad is enabled, and latest version', async () => { @@ -165,9 +161,7 @@ describe('diffs/components/app', () => { expect(wrapper.vm.fetchDiffFilesMeta).toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesBatch).toHaveBeenCalled(); expect(wrapper.vm.fetchCoverageFiles).toHaveBeenCalled(); - expect(wrapper.vm.unwatchDiscussions).toHaveBeenCalled(); expect(wrapper.vm.diffFilesLength).toBe(100); - expect(wrapper.vm.unwatchRetrievingBatches).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index 18e81232b5c..51f8f04fc11 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -841,7 +841,7 @@ describe('DiffsStoreActions', () => { }; const singleDiscussion = { id: '1', - file_hash: 'ABC', + diff_file: { file_hash: 'ABC' }, line_code: 'ABC_1_1', }; diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js index 720b72f4965..6331269d6e8 100644 --- a/spec/frontend/diffs/store/utils_spec.js +++ b/spec/frontend/diffs/store/utils_spec.js @@ -330,7 +330,7 @@ describe('DiffsStoreUtils', () => { old_line: 5, new_line: 5, rich_text: '<p>rich</p>', // Note no leading space - discussionsExpanded: true, + discussionsExpanded: false, discussions: [], hasForm: false, text: undefined, diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js index dc450eb2aa7..8c02a07994b 100644 --- a/spec/frontend/environments/environments_app_spec.js +++ b/spec/frontend/environments/environments_app_spec.js @@ -174,18 +174,25 @@ describe('~/environments/components/environments_app.vue', () => { expect(button.exists()).toBe(true); }); - it('should not show a button to open the review app modal if review apps are configured', async () => { - await createWrapperWithMocked({ - environmentsApp: { - ...resolvedEnvironmentsApp, - reviewApp: { canSetupReviewApp: false }, - }, - folder: resolvedFolder, - }); + it.each` + canSetupReviewApp | hasReviewApp + ${false} | ${true} + ${true} | ${true} + `( + 'should not show button to open the review app modal', + async ({ canSetupReviewApp, hasReviewApp }) => { + await createWrapperWithMocked({ + environmentsApp: { + ...resolvedEnvironmentsApp, + reviewApp: { canSetupReviewApp, hasReviewApp }, + }, + folder: resolvedFolder, + }); - const button = wrapper.findByRole('button', { name: s__('Environments|Enable review apps') }); - expect(button.exists()).toBe(false); - }); + const button = wrapper.findByRole('button', { name: s__('Environments|Enable review apps') }); + expect(button.exists()).toBe(false); + }, + ); it('should not show a button to clean up environments if the user has no permissions', async () => { await createWrapperWithMocked({ diff --git a/spec/frontend/environments/graphql/mock_data.js b/spec/frontend/environments/graphql/mock_data.js index fd97f19a6ab..b80b8508e8d 100644 --- a/spec/frontend/environments/graphql/mock_data.js +++ b/spec/frontend/environments/graphql/mock_data.js @@ -262,6 +262,7 @@ export const environmentsApp = { review_app: { can_setup_review_app: true, all_clusters_empty: true, + has_review_app: false, review_snippet: '{"deploy_review"=>{"stage"=>"deploy", "script"=>["echo \\"Deploy a review app\\""], "environment"=>{"name"=>"review/$CI_COMMIT_REF_NAME", "url"=>"https://$CI_ENVIRONMENT_SLUG.example.com"}, "only"=>["branches"]}}', }, @@ -471,6 +472,7 @@ export const resolvedEnvironmentsApp = { reviewApp: { canSetupReviewApp: true, allClustersEmpty: true, + hasReviewApp: false, reviewSnippet: '{"deploy_review"=>{"stage"=>"deploy", "script"=>["echo \\"Deploy a review app\\""], "environment"=>{"name"=>"review/$CI_COMMIT_REF_NAME", "url"=>"https://$CI_ENVIRONMENT_SLUG.example.com"}, "only"=>["branches"]}}', __typename: 'ReviewApp', diff --git a/spec/frontend/issuable/components/status_badge_spec.js b/spec/frontend/issuable/components/status_badge_spec.js index cdc848626c7..9ab5b4f7149 100644 --- a/spec/frontend/issuable/components/status_badge_spec.js +++ b/spec/frontend/issuable/components/status_badge_spec.js @@ -16,10 +16,10 @@ describe('StatusBadge component', () => { ${'merge_request'} | ${'Open'} | ${'opened'} | ${'success'} | ${'merge-request-open'} ${'merge_request'} | ${'Closed'} | ${'closed'} | ${'danger'} | ${'merge-request-close'} ${'merge_request'} | ${'Merged'} | ${'merged'} | ${'info'} | ${'merge'} - ${'issue'} | ${'Open'} | ${'opened'} | ${'success'} | ${'issues'} - ${'issue'} | ${'Closed'} | ${'closed'} | ${'info'} | ${'issue-closed'} - ${'epic'} | ${'Open'} | ${'opened'} | ${'success'} | ${'epic'} - ${'epic'} | ${'Closed'} | ${'closed'} | ${'info'} | ${'epic-closed'} + ${'issue'} | ${'Open'} | ${'opened'} | ${'success'} | ${'issue-open-m'} + ${'issue'} | ${'Closed'} | ${'closed'} | ${'info'} | ${'issue-close'} + ${'epic'} | ${'Open'} | ${'opened'} | ${'success'} | ${'issue-open-m'} + ${'epic'} | ${'Closed'} | ${'closed'} | ${'info'} | ${'issue-close'} `( 'when issuableType=$issuableType and state=$state', ({ issuableType, badgeText, state, badgeVariant, badgeIcon }) => { diff --git a/spec/frontend/issues/show/components/issue_header_spec.js b/spec/frontend/issues/show/components/issue_header_spec.js index 6acc7004576..6c4e357d722 100644 --- a/spec/frontend/issues/show/components/issue_header_spec.js +++ b/spec/frontend/issues/show/components/issue_header_spec.js @@ -47,7 +47,7 @@ describe('IssueHeader component', () => { issuableType: 'issue', serviceDeskReplyTo: '', showWorkItemTypeIcon: true, - statusIcon: 'issues', + statusIcon: 'issue-open-m', workspaceType: 'project', }); }); @@ -63,7 +63,7 @@ describe('IssueHeader component', () => { }); it('renders correct icon', () => { - expect(findIssuableHeader().props('statusIcon')).toBe('issues'); + expect(findIssuableHeader().props('statusIcon')).toBe('issue-open-m'); }); }); @@ -77,7 +77,7 @@ describe('IssueHeader component', () => { }); it('renders correct icon', () => { - expect(findIssuableHeader().props('statusIcon')).toBe('issue-closed'); + expect(findIssuableHeader().props('statusIcon')).toBe('issue-close'); }); describe('when issue is marked as duplicate', () => { diff --git a/spec/frontend/issues/show/components/sticky_header_spec.js b/spec/frontend/issues/show/components/sticky_header_spec.js index a909084956f..43d96f398b6 100644 --- a/spec/frontend/issues/show/components/sticky_header_spec.js +++ b/spec/frontend/issues/show/components/sticky_header_spec.js @@ -36,12 +36,12 @@ describe('StickyHeader component', () => { it.each` issuableType | issuableStatus | statusIcon - ${TYPE_INCIDENT} | ${STATUS_OPEN} | ${'issues'} - ${TYPE_INCIDENT} | ${STATUS_CLOSED} | ${'issue-closed'} - ${TYPE_ISSUE} | ${STATUS_OPEN} | ${'issues'} - ${TYPE_ISSUE} | ${STATUS_CLOSED} | ${'issue-closed'} - ${TYPE_EPIC} | ${STATUS_OPEN} | ${'epic'} - ${TYPE_EPIC} | ${STATUS_CLOSED} | ${'epic-closed'} + ${TYPE_INCIDENT} | ${STATUS_OPEN} | ${'issue-open-m'} + ${TYPE_INCIDENT} | ${STATUS_CLOSED} | ${'issue-close'} + ${TYPE_ISSUE} | ${STATUS_OPEN} | ${'issue-open-m'} + ${TYPE_ISSUE} | ${STATUS_CLOSED} | ${'issue-close'} + ${TYPE_EPIC} | ${STATUS_OPEN} | ${'issue-open-m'} + ${TYPE_EPIC} | ${STATUS_CLOSED} | ${'issue-close'} `( 'shows with state icon "$statusIcon" for $issuableType when status is $issuableStatus', ({ issuableType, issuableStatus, statusIcon }) => { diff --git a/spec/frontend/lib/utils/color_utils_spec.js b/spec/frontend/lib/utils/color_utils_spec.js index 92ac66c19f0..aac50a2c850 100644 --- a/spec/frontend/lib/utils/color_utils_spec.js +++ b/spec/frontend/lib/utils/color_utils_spec.js @@ -18,15 +18,17 @@ describe('Color utils', () => { describe('darkModeEnabled', () => { it.each` - page | bodyClass | ideTheme | expected + page | rootClass | ideTheme | expected ${'ide:index'} | ${'gl-dark'} | ${'monokai-light'} | ${false} ${'ide:index'} | ${'ui-light'} | ${'monokai'} | ${true} ${'groups:issues:index'} | ${'ui-light'} | ${'monokai'} | ${false} ${'groups:issues:index'} | ${'gl-dark'} | ${'monokai-light'} | ${true} `( - 'is $expected on $page with $bodyClass body class and $ideTheme IDE theme', - ({ page, bodyClass, ideTheme, expected }) => { - document.body.outerHTML = `<body class="${bodyClass}" data-page="${page}"></body>`; + 'is $expected on $page with $rootClass root class and $ideTheme IDE theme', + ({ page, rootClass, ideTheme, expected }) => { + document.documentElement.className = rootClass; + document.body.outerHTML = `<body data-page="${page}"></body>`; + window.gon = { user_color_scheme: ideTheme, }; diff --git a/spec/frontend/observability/client_spec.js b/spec/frontend/observability/client_spec.js index 31998053742..e33cfc5b4a4 100644 --- a/spec/frontend/observability/client_spec.js +++ b/spec/frontend/observability/client_spec.js @@ -384,4 +384,41 @@ describe('buildClient', () => { expectErrorToBeReported(new Error(e)); }); }); + + describe('fetchMetrics', () => { + const FETCHING_METRICS_ERROR = 'metrics are missing/invalid in the response'; + + it('fetches metrics from the metrics URL', async () => { + const mockResponse = { + metrics: [ + { name: 'metric A', description: 'a counter metric called A', type: 'COUNTER' }, + { name: 'metric B', description: 'a gauge metric called B', type: 'GAUGE' }, + ], + }; + + axiosMock.onGet(metricsUrl).reply(200, mockResponse); + + const result = await client.fetchMetrics(); + + expect(axios.get).toHaveBeenCalledTimes(1); + expect(axios.get).toHaveBeenCalledWith(metricsUrl, { + withCredentials: true, + }); + expect(result).toEqual(mockResponse); + }); + + it('rejects if metrics are missing', async () => { + axiosMock.onGet(metricsUrl).reply(200, {}); + + await expect(client.fetchMetrics()).rejects.toThrow(FETCHING_METRICS_ERROR); + expectErrorToBeReported(new Error(FETCHING_METRICS_ERROR)); + }); + + it('rejects if metrics are invalid', async () => { + axiosMock.onGet(metricsUrl).reply(200, { traces: 'invalid' }); + + await expect(client.fetchMetrics()).rejects.toThrow(FETCHING_METRICS_ERROR); + expectErrorToBeReported(new Error(FETCHING_METRICS_ERROR)); + }); + }); }); diff --git a/spec/frontend/organizations/users/mock_data.js b/spec/frontend/organizations/users/mock_data.js index 5503b063ad2..4f159c70c2c 100644 --- a/spec/frontend/organizations/users/mock_data.js +++ b/spec/frontend/organizations/users/mock_data.js @@ -12,7 +12,10 @@ export const MOCK_USERS = [ user: { id: 'gid://gitlab/User/2' }, }, { - badges: ['Admin', "It's you!"], + badges: [ + { text: 'Admin', variant: 'success' }, + { text: "It's you!", variant: 'muted' }, + ], id: 'gid://gitlab/Organizations::OrganizationUser/1', user: { id: 'gid://gitlab/User/1' }, }, diff --git a/spec/frontend/protected_branches/protected_branch_edit_spec.js b/spec/frontend/protected_branches/protected_branch_edit_spec.js index 6422856ba22..301b0e8e157 100644 --- a/spec/frontend/protected_branches/protected_branch_edit_spec.js +++ b/spec/frontend/protected_branches/protected_branch_edit_spec.js @@ -6,37 +6,96 @@ import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import ProtectedBranchEdit from '~/protected_branches/protected_branch_edit'; +import waitForPromises from 'helpers/wait_for_promises'; jest.mock('~/alert'); const TEST_URL = `${TEST_HOST}/url`; + +const response = { + project_id: 2, + name: 'release/*', + id: 30, + created_at: '2023-09-21T03:06:27.532Z', + updated_at: '2023-10-31T21:37:50.126Z', + code_owner_approval_required: false, + allow_force_push: false, + namespace_id: null, + merge_access_levels: [ + { + id: 37, + protected_branch_id: 30, + access_level: 40, + created_at: '2023-10-31T22:44:15.545Z', + updated_at: '2023-10-31T22:44:15.545Z', + user_id: null, + group_id: null, + }, + ], + push_access_levels: [ + { + id: 38, + protected_branch_id: 30, + access_level: 40, + created_at: '2023-10-31T22:43:53.105Z', + updated_at: '2023-10-31T22:43:53.105Z', + user_id: null, + group_id: null, + deploy_key_id: null, + }, + ], +}; + +// Toggles const FORCE_PUSH_TOGGLE_TESTID = 'force-push-toggle'; const CODE_OWNER_TOGGLE_TESTID = 'code-owner-toggle'; const IS_CHECKED_CLASS = 'is-checked'; const IS_DISABLED_CLASS = 'is-disabled'; const IS_LOADING_SELECTOR = '.toggle-loading'; +// Dropdowns +const MERGE_DROPDOWN_TESTID = 'protected-branch-allowed-to-merge'; +const PUSH_DROPDOWN_TESTID = 'protected-branch-allowed-to-push'; +const INIT_MERGE_DATA_TESTID = 'js-allowed-to-merge'; +const INIT_PUSH_DATA_TESTID = 'js-allowed-to-push'; + describe('ProtectedBranchEdit', () => { let mock; - beforeEach(() => { - jest.spyOn(ProtectedBranchEdit.prototype, 'initDropdowns').mockImplementation(); - - mock = new MockAdapter(axios); - }); - const findForcePushToggle = () => document.querySelector(`div[data-testid="${FORCE_PUSH_TOGGLE_TESTID}"] button`); const findCodeOwnerToggle = () => document.querySelector(`div[data-testid="${CODE_OWNER_TOGGLE_TESTID}"] button`); + const findMergeDropdown = () => + document.querySelector(`div[data-testid="${MERGE_DROPDOWN_TESTID}"]`); + const findPushDropdown = () => + document.querySelector(`div[data-testid="${PUSH_DROPDOWN_TESTID}"]`); const create = ({ forcePushToggleChecked = false, codeOwnerToggleChecked = false, + mergeClass = INIT_MERGE_DATA_TESTID, + mergeDisabled = false, + mergePreselected = [], + pushClass = INIT_PUSH_DATA_TESTID, + pushDisabled = false, + pushPreselected = [], hasLicense = true, } = {}) => { setHTMLFixture(`<div id="wrap" data-url="${TEST_URL}"> <span + class="${mergeClass}" + data-label="Dropdown allowed to merge" + data-disabled="${mergeDisabled}" + data-preselected-items='${mergePreselected}' + data-testid="${MERGE_DROPDOWN_TESTID}"></span> + <span + class="${pushClass}" + data-label="Dropdown allowed to push" + data-disabled="${pushDisabled}" + data-preselected-items='${pushPreselected}' + data-testid="${PUSH_DROPDOWN_TESTID}"></span> + <span class="js-force-push-toggle" data-label="Toggle allowed to force push" data-is-checked="${forcePushToggleChecked}" @@ -51,108 +110,261 @@ describe('ProtectedBranchEdit', () => { return new ProtectedBranchEdit({ $wrap: $('#wrap'), hasLicense }); }; + beforeEach(() => { + mock = new MockAdapter(axios); + }); + afterEach(() => { mock.restore(); resetHTMLFixture(); }); - describe('when license supports code owner approvals', () => { + describe('dropdowns', () => { + const accessLevels = [ + { + id: 40, + text: 'Maintainers', + before_divider: true, + }, + { + id: 30, + text: 'Developers + Maintainers', + before_divider: true, + }, + ]; + beforeEach(() => { - create(); - }); + window.gon = { + current_project_id: 1, + merge_access_levels: { roles: accessLevels }, + push_access_levels: { roles: accessLevels }, + }; - it('instantiates the code owner toggle', () => { - expect(findCodeOwnerToggle()).not.toBe(null); + jest.spyOn(ProtectedBranchEdit.prototype, 'initToggles').mockImplementation(); }); - }); - describe('when license does not support code owner approvals', () => { - beforeEach(() => { - create({ hasLicense: false }); - }); + describe('rendering', () => { + describe('merge dropdown', () => { + it('builds the merge dropdown when it has the proper class', () => { + create(); + expect(findMergeDropdown()).not.toBe(null); + }); - it('does not instantiate the code owner toggle', () => { - expect(findCodeOwnerToggle()).toBe(null); - }); - }); + it('does not build the merge dropdown when it does not have the proper class', () => { + create({ mergeClass: 'invalid-class' }); + expect(findMergeDropdown()).toBe(null); + }); + }); - describe('when toggles are not available in the DOM on page load', () => { - beforeEach(() => { - create({ hasLicense: true }); - setHTMLFixture(''); - }); + describe('push dropdown', () => { + it('builds the push dropdown when it has the proper class', () => { + create(); + expect(findPushDropdown()).not.toBe(null); + }); - afterEach(() => { - resetHTMLFixture(); + it('does not build the push dropdown when it does not have the proper class', () => { + create({ pushClass: 'invalid-class' }); + expect(findPushDropdown()).toBe(null); + }); + }); }); - it('does not instantiate the force push toggle', () => { - expect(findForcePushToggle()).toBe(null); + describe('preselected item', () => { + beforeEach(() => { + mock.onPatch(TEST_URL).reply(HTTP_STATUS_OK, response); + }); + + it('sets selected item on load', () => { + const preselected = [{ id: 38, access_level: 40, type: 'role' }]; + const ProtectedBranchEditInstance = create({ + pushPreselected: JSON.stringify(preselected), + }); + const dropdown = ProtectedBranchEditInstance.push_access_levels_dropdown; + expect(dropdown.preselected).toEqual(preselected); + }); + + it('updates selected item on save for enabled dropdowns', async () => { + const selectedValue = [{ access_level: 40 }]; + const ProtectedBranchEditInstance = create({}); + const dropdown = ProtectedBranchEditInstance.push_access_levels_dropdown; + dropdown.$emit('select', selectedValue); + dropdown.$emit('hidden'); + await waitForPromises(); + expect(dropdown.preselected[0].id).toBe(response.push_access_levels[0].id); + }); + + it('does not update selected item on save for disabled dropdowns', async () => { + const selectedValue = [{ access_level: 40 }]; + const ProtectedBranchEditInstance = create({ pushDisabled: '' }); + const dropdown = ProtectedBranchEditInstance.push_access_levels_dropdown; + dropdown.$emit('select', selectedValue); + dropdown.$emit('hidden'); + await waitForPromises(); + expect(dropdown.preselected).toEqual([]); + }); }); - it('does not instantiate the code owner toggle', () => { - expect(findCodeOwnerToggle()).toBe(null); + describe('on hidden', () => { + beforeEach(() => { + mock.onPatch(TEST_URL).reply(HTTP_STATUS_OK, {}); + }); + + it('does not update permissions on hidden if there are no changes', () => { + const ProtectedBranchEditInstance = create(); + const dropdown = ProtectedBranchEditInstance.merge_access_levels_dropdown; + dropdown.$emit('hidden'); + expect(mock.history.patch).toHaveLength(0); + }); + + it('updates permissions on hidden for enabled dropdowns with changes', async () => { + const preselectedData = { id: 38, access_level: 40 }; + const preselected = [{ ...preselectedData, type: 'role' }]; + const selectedValue = [{ access_level: 30 }]; + const ProtectedBranchEditInstance = create({ + pushPreselected: JSON.stringify(preselected), + }); + const dropdown = ProtectedBranchEditInstance.merge_access_levels_dropdown; + dropdown.$emit('select', selectedValue); + dropdown.$emit('hidden'); + await waitForPromises(); + expect(mock.history.patch).toHaveLength(1); + expect(mock.history.patch[0].data).toEqual( + JSON.stringify({ + protected_branch: { + merge_access_levels_attributes: selectedValue, + push_access_levels_attributes: [preselectedData], + }, + }), + ); + }); + + it('does not update permissions on hidden for disabled dropdowns', async () => { + const preselected = [{ id: 38, access_level: 0, type: 'role' }]; + const selectedValue = [{ access_level: 30 }]; + const ProtectedBranchEditInstance = create({ + mergeDisabled: '', + mergePreselected: JSON.stringify(preselected), + }); + const dropdown = ProtectedBranchEditInstance.push_access_levels_dropdown; + dropdown.$emit('select', selectedValue); + dropdown.$emit('hidden'); + await waitForPromises(); + expect(mock.history.patch).toHaveLength(1); + expect(mock.history.patch[0].data).toEqual( + JSON.stringify({ + protected_branch: { + merge_access_levels_attributes: [], + push_access_levels_attributes: selectedValue, + }, + }), + ); + }); }); }); - describe.each` - description | checkedOption | patchParam | finder - ${'force push'} | ${'forcePushToggleChecked'} | ${'allow_force_push'} | ${findForcePushToggle} - ${'code owner'} | ${'codeOwnerToggleChecked'} | ${'code_owner_approval_required'} | ${findCodeOwnerToggle} - `('when unchecked $description toggle button', ({ checkedOption, patchParam, finder }) => { - let toggle; - + describe('toggles', () => { beforeEach(() => { - create({ [checkedOption]: false }); + jest.spyOn(ProtectedBranchEdit.prototype, 'initDropdowns').mockImplementation(); + }); - toggle = finder(); + describe('when license supports code owner approvals', () => { + beforeEach(() => { + create(); + }); + + it('instantiates the code owner toggle', () => { + expect(findCodeOwnerToggle()).not.toBe(null); + }); }); - it('is not changed', () => { - expect(toggle).not.toHaveClass(IS_CHECKED_CLASS); - expect(toggle.querySelector(IS_LOADING_SELECTOR)).toBe(null); - expect(toggle).not.toHaveClass(IS_DISABLED_CLASS); + describe('when license does not support code owner approvals', () => { + beforeEach(() => { + create({ hasLicense: false }); + }); + + it('does not instantiate the code owner toggle', () => { + expect(findCodeOwnerToggle()).toBe(null); + }); }); - describe('when clicked', () => { + describe('when toggles are not available in the DOM on page load', () => { beforeEach(() => { - mock - .onPatch(TEST_URL, { protected_branch: { [patchParam]: true } }) - .replyOnce(HTTP_STATUS_OK, {}); + create({ hasLicense: true }); + setHTMLFixture(''); }); - it('checks and disables button', async () => { - await toggle.click(); + afterEach(() => { + resetHTMLFixture(); + }); - expect(toggle).toHaveClass(IS_CHECKED_CLASS); - expect(toggle.querySelector(IS_LOADING_SELECTOR)).not.toBe(null); - expect(toggle).toHaveClass(IS_DISABLED_CLASS); + it('does not instantiate the force push toggle', () => { + expect(findForcePushToggle()).toBe(null); }); - it('sends update to BE', async () => { - await toggle.click(); + it('does not instantiate the code owner toggle', () => { + expect(findCodeOwnerToggle()).toBe(null); + }); + }); - await axios.waitForAll(); + describe.each` + description | checkedOption | patchParam | finder + ${'force push'} | ${'forcePushToggleChecked'} | ${'allow_force_push'} | ${findForcePushToggle} + ${'code owner'} | ${'codeOwnerToggleChecked'} | ${'code_owner_approval_required'} | ${findCodeOwnerToggle} + `('when unchecked $description toggle button', ({ checkedOption, patchParam, finder }) => { + let toggle; - // Args are asserted in the `.onPatch` call - expect(mock.history.patch).toHaveLength(1); + beforeEach(() => { + create({ [checkedOption]: false }); - expect(toggle).not.toHaveClass(IS_DISABLED_CLASS); + toggle = finder(); + }); + + it('is not changed', () => { + expect(toggle).not.toHaveClass(IS_CHECKED_CLASS); expect(toggle.querySelector(IS_LOADING_SELECTOR)).toBe(null); - expect(createAlert).not.toHaveBeenCalled(); + expect(toggle).not.toHaveClass(IS_DISABLED_CLASS); }); - }); - describe('when clicked and BE error', () => { - beforeEach(() => { - mock.onPatch(TEST_URL).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); - toggle.click(); + describe('when clicked', () => { + beforeEach(() => { + mock + .onPatch(TEST_URL, { protected_branch: { [patchParam]: true } }) + .replyOnce(HTTP_STATUS_OK, {}); + }); + + it('checks and disables button', async () => { + await toggle.click(); + + expect(toggle).toHaveClass(IS_CHECKED_CLASS); + expect(toggle.querySelector(IS_LOADING_SELECTOR)).not.toBe(null); + expect(toggle).toHaveClass(IS_DISABLED_CLASS); + }); + + it('sends update to BE', async () => { + await toggle.click(); + + await axios.waitForAll(); + + // Args are asserted in the `.onPatch` call + expect(mock.history.patch).toHaveLength(1); + + expect(toggle).not.toHaveClass(IS_DISABLED_CLASS); + expect(toggle.querySelector(IS_LOADING_SELECTOR)).toBe(null); + expect(createAlert).not.toHaveBeenCalled(); + }); }); - it('alerts error', async () => { - await axios.waitForAll(); + describe('when clicked and BE error', () => { + beforeEach(() => { + mock.onPatch(TEST_URL).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); + toggle.click(); + }); + + it('alerts error', async () => { + await axios.waitForAll(); - expect(createAlert).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); + }); }); }); }); diff --git a/spec/frontend/super_sidebar/components/user_bar_spec.js b/spec/frontend/super_sidebar/components/user_bar_spec.js index b58b65f09f5..27d65f27007 100644 --- a/spec/frontend/super_sidebar/components/user_bar_spec.js +++ b/spec/frontend/super_sidebar/components/user_bar_spec.js @@ -49,7 +49,6 @@ describe('UserBar component', () => { sidebarData, }, provide: { - toggleNewNavEndpoint: '/-/profile/preferences', isImpersonating: false, ...provideOverrides, }, diff --git a/spec/frontend/super_sidebar/components/user_menu_spec.js b/spec/frontend/super_sidebar/components/user_menu_spec.js index 79a31492f3f..45a60fce00a 100644 --- a/spec/frontend/super_sidebar/components/user_menu_spec.js +++ b/spec/frontend/super_sidebar/components/user_menu_spec.js @@ -3,8 +3,6 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; import { stubComponent } from 'helpers/stub_component'; import UserMenu from '~/super_sidebar/components/user_menu.vue'; import UserMenuProfileItem from '~/super_sidebar/components/user_menu_profile_item.vue'; -import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; -import invalidUrl from '~/lib/utils/invalid_url'; import { mockTracking } from 'helpers/tracking_helper'; import PersistentUserCallout from '~/persistent_user_callout'; import { userMenuMockData, userMenuMockStatus, userMenuMockPipelineMinutes } from '../mock_data'; @@ -14,7 +12,6 @@ describe('UserMenu component', () => { let trackingSpy; const GlEmoji = { template: '<img/>' }; - const toggleNewNavEndpoint = invalidUrl; const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); const showDropdown = () => findDropdown().vm.$emit('shown'); @@ -34,7 +31,6 @@ describe('UserMenu component', () => { ...stubs, }, provide: { - toggleNewNavEndpoint, isImpersonating: false, ...provide, }, @@ -459,15 +455,6 @@ describe('UserMenu component', () => { }); }); - describe('New navigation toggle item', () => { - it('should render menu item with new navigation toggle', () => { - createWrapper(); - const toggleItem = wrapper.findComponent(NewNavToggle); - expect(toggleItem.exists()).toBe(true); - expect(toggleItem.props('endpoint')).toBe(toggleNewNavEndpoint); - }); - }); - describe('Sign out group', () => { const findSignOutGroup = () => wrapper.findByTestId('sign-out-group'); diff --git a/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js b/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js index 40232eb367a..810269257b6 100644 --- a/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js @@ -1,15 +1,16 @@ import { + GlDisclosureDropdown, + GlDisclosureDropdownItem, GlSprintf, - GlDropdown, - GlDropdownItem, - GlDropdownText, GlSearchBoxByType, + GlIcon, } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { nextTick } from 'vue'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { stubComponent } from 'helpers/stub_component'; import DiffStatsDropdown, { i18n } from '~/vue_shared/components/diff_stats_dropdown.vue'; +import { ARROW_DOWN_KEY } from '~/lib/utils/keys'; jest.mock('fuzzaldrin-plus', () => ({ filter: jest.fn().mockReturnValue([]), @@ -42,7 +43,7 @@ describe('Diff Stats Dropdown', () => { const focusInputMock = jest.fn(); const createComponent = ({ changed = 0, added = 0, deleted = 0, files = [] } = {}) => { - wrapper = shallowMountExtended(DiffStatsDropdown, { + wrapper = mountExtended(DiffStatsDropdown, { propsData: { changed, added, @@ -51,7 +52,6 @@ describe('Diff Stats Dropdown', () => { }, stubs: { GlSprintf, - GlDropdown, GlSearchBoxByType: stubComponent(GlSearchBoxByType, { methods: { focusInput: focusInputMock }, }), @@ -59,9 +59,8 @@ describe('Diff Stats Dropdown', () => { }); }; - const findChanged = () => wrapper.findComponent(GlDropdown); - const findChangedFiles = () => findChanged().findAllComponents(GlDropdownItem); - const findNoFilesText = () => findChanged().findComponent(GlDropdownText); + const findChanged = () => wrapper.findComponent(GlDisclosureDropdown); + const findChangedFiles = () => findChanged().findAllComponents(GlDisclosureDropdownItem); const findCollapsed = () => wrapper.findByTestId('diff-stats-additions-deletions-expanded'); const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); @@ -79,15 +78,14 @@ describe('Diff Stats Dropdown', () => { const fileText = findChangedFiles().at(1).text(); expect(fileText).toContain(mockFiles[1].name); expect(fileText).toContain(mockFiles[1].path); - expect(fileData.props()).toMatchObject({ - iconName: mockFiles[1].icon, - iconColor: mockFiles[1].iconColor, - }); + expect(fileData.findComponent(GlIcon).props('name')).toEqual(mockFiles[1].icon); + expect(fileData.findComponent(GlIcon).classes()).toContain('gl-text-red-500'); + expect(fileData.find('a').attributes('href')).toEqual(mockFiles[1].href); }); it('when no files changed', () => { createComponent({ files: [] }); - expect(findNoFilesText().text()).toContain(i18n.noFilesFound); + expect(findChanged().text()).toContain(i18n.noFilesFound); }); }); @@ -108,7 +106,7 @@ describe('Diff Stats Dropdown', () => { }); it(`dropdown header should be '${expectedDropdownHeader}'`, () => { - expect(findChanged().props('text')).toBe(expectedDropdownHeader); + expect(findChanged().props('toggleText')).toBe(expectedDropdownHeader); }); it(`added and deleted count in collapsed section should be '${expectedAddedDeletedCollapsed}'`, () => { @@ -137,27 +135,27 @@ describe('Diff Stats Dropdown', () => { }); }); - describe('selecting file dropdown item', () => { + describe('on dropdown open', () => { beforeEach(() => { - createComponent({ files: mockFiles }); + createComponent(); }); - it('updates the URL', () => { - findChangedFiles().at(0).vm.$emit('click'); - expect(window.location.hash).toBe(mockFiles[0].href); - findChangedFiles().at(1).vm.$emit('click'); - expect(window.location.hash).toBe(mockFiles[1].href); + it('should set the search input focus', () => { + findChanged().vm.$emit('shown'); + expect(focusInputMock).toHaveBeenCalled(); }); }); - describe('on dropdown open', () => { + describe('keyboard nav', () => { beforeEach(() => { - createComponent(); + createComponent({ files: mockFiles }); }); - it('should set the search input focus', () => { - findChanged().vm.$emit('shown'); - expect(focusInputMock).toHaveBeenCalled(); + it('focuses the first item when pressing the down key within the search box', () => { + const spy = jest.spyOn(wrapper.vm, 'focusFirstItem'); + findSearchBox().vm.$emit('keydown', new KeyboardEvent({ key: ARROW_DOWN_KEY })); + + expect(spy).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/vue_shared/components/list_selector/index_spec.js b/spec/frontend/vue_shared/components/list_selector/index_spec.js index 4222a28afe8..11e64a91eb0 100644 --- a/spec/frontend/vue_shared/components/list_selector/index_spec.js +++ b/spec/frontend/vue_shared/components/list_selector/index_spec.js @@ -1,16 +1,18 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { GlCard, GlIcon, GlCollapsibleListbox, GlSearchBoxByType } from '@gitlab/ui'; +import Api from '~/api'; +import { createAlert } from '~/alert'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import ListSelector from '~/vue_shared/components/list_selector/index.vue'; import UserItem from '~/vue_shared/components/list_selector/user_item.vue'; import GroupItem from '~/vue_shared/components/list_selector/group_item.vue'; -import usersAutocompleteQuery from '~/graphql_shared/queries/users_autocomplete.query.graphql'; import groupsAutocompleteQuery from '~/graphql_shared/queries/groups_autocomplete.query.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { USERS_RESPONSE_MOCK, GROUPS_RESPONSE_MOCK } from './mock_data'; +jest.mock('~/alert'); Vue.use(VueApollo); describe('List Selector spec', () => { @@ -20,6 +22,7 @@ describe('List Selector spec', () => { const USERS_MOCK_PROPS = { title: 'Users', projectPath: 'some/project/path', + groupPath: 'some/group/path', type: 'users', }; @@ -29,15 +32,10 @@ describe('List Selector spec', () => { type: 'groups', }; - const usersAutocompleteQuerySuccess = jest.fn().mockResolvedValue(USERS_RESPONSE_MOCK); const groupsAutocompleteQuerySuccess = jest.fn().mockResolvedValue(GROUPS_RESPONSE_MOCK); - const createComponent = async ( - props, - query = usersAutocompleteQuery, - queryResponse = usersAutocompleteQuerySuccess, - ) => { - fakeApollo = createMockApollo([[query, queryResponse]]); + const createComponent = async (props) => { + fakeApollo = createMockApollo([[groupsAutocompleteQuery, groupsAutocompleteQuerySuccess]]); wrapper = mountExtended(ListSelector, { apolloProvider: fakeApollo, @@ -52,12 +50,21 @@ describe('List Selector spec', () => { const findCard = () => wrapper.findComponent(GlCard); const findTitle = () => findCard().find('[data-testid="list-selector-title"]'); const findIcon = () => wrapper.findComponent(GlIcon); - const findListBox = () => wrapper.findComponent(GlCollapsibleListbox); + const findAllListBoxComponents = () => wrapper.findAllComponents(GlCollapsibleListbox); + const findSearchResultsDropdown = () => findAllListBoxComponents().at(0); + const findNamespaceDropdown = () => findAllListBoxComponents().at(1); const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); const findAllUserComponents = () => wrapper.findAllComponents(UserItem); const findAllGroupComponents = () => wrapper.findAllComponents(GroupItem); + beforeEach(() => { + jest.spyOn(Api, 'projectUsers').mockResolvedValue(USERS_RESPONSE_MOCK); + jest.spyOn(Api, 'groupMembers').mockResolvedValue({ data: USERS_RESPONSE_MOCK }); + }); + describe('Users type', () => { + const search = 'foo'; + beforeEach(() => createComponent(USERS_MOCK_PROPS)); it('renders a Card component', () => { @@ -77,47 +84,67 @@ describe('List Selector spec', () => { expect(findSearchBox().exists()).toBe(true); }); + it('renders two namespace dropdown items', () => { + expect(findNamespaceDropdown().props('items').length).toBe(2); + }); + it('does not call query when search box has not received an input', () => { - expect(usersAutocompleteQuerySuccess).not.toHaveBeenCalled(); + expect(Api.projectUsers).not.toHaveBeenCalled(); + expect(Api.groupMembers).not.toHaveBeenCalled(); expect(findAllUserComponents().length).toBe(0); }); - describe('searching', () => { - const searchResponse = USERS_RESPONSE_MOCK.data.project.autocompleteUsers; - const search = 'foo'; + describe.each` + dropdownItemValue | apiMethod | apiParams | searchResponse + ${'false'} | ${'groupMembers'} | ${[USERS_MOCK_PROPS.groupPath, { query: search }]} | ${USERS_RESPONSE_MOCK} + ${'true'} | ${'projectUsers'} | ${[USERS_MOCK_PROPS.projectPath, search]} | ${USERS_RESPONSE_MOCK} + `( + 'searching based on namespace dropdown selection', + ({ dropdownItemValue, apiMethod, apiParams, searchResponse }) => { + const emitSearchInput = async () => { + findSearchBox().vm.$emit('input', search); + await waitForPromises(); + }; + + beforeEach(async () => { + findNamespaceDropdown().vm.$emit('select', dropdownItemValue); + await emitSearchInput(); + }); - const emitSearchInput = async () => { - findSearchBox().vm.$emit('input', search); - await waitForPromises(); - }; + it('shows error alert when API fails', async () => { + jest.spyOn(Api, apiMethod).mockRejectedValueOnce(); + await emitSearchInput(); - beforeEach(() => emitSearchInput()); + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred while fetching. Please try again.', + }); + }); - it('calls query with correct variables when Search box receives an input', () => { - expect(usersAutocompleteQuerySuccess).toHaveBeenCalledWith({ - fullPath: USERS_MOCK_PROPS.projectPath, - isProject: true, - search, + it('calls query with correct variables when Search box receives an input', () => { + expect(Api[apiMethod]).toHaveBeenCalledWith(...apiParams); }); - }); - it('renders a List box component with the correct props', () => { - expect(findListBox().props()).toMatchObject({ multiple: true, items: searchResponse }); - }); + it('renders a List box component with the correct props', () => { + expect(findSearchResultsDropdown().props()).toMatchObject({ + multiple: true, + items: searchResponse, + }); + }); - it('renders a user component for each search result', () => { - expect(findAllUserComponents().length).toBe(searchResponse.length); - }); + it('renders a user component for each search result', () => { + expect(findAllUserComponents().length).toBe(searchResponse.length); + }); - it('emits an event when a search result is selected', () => { - const firstSearchResult = searchResponse[0]; - findAllUserComponents().at(0).vm.$emit('select', firstSearchResult.username); + it('emits an event when a search result is selected', () => { + const firstSearchResult = searchResponse[0]; + findAllUserComponents().at(0).vm.$emit('select', firstSearchResult.username); - expect(wrapper.emitted('select')).toEqual([ - [{ ...firstSearchResult, text: 'Administrator', value: 'root' }], - ]); - }); - }); + expect(wrapper.emitted('select')).toEqual([ + [{ ...firstSearchResult, text: 'Administrator', value: 'root' }], + ]); + }); + }, + ); describe('selected items', () => { const selectedUser = { username: 'root' }; @@ -147,9 +174,7 @@ describe('List Selector spec', () => { }); describe('Groups type', () => { - beforeEach(() => - createComponent(GROUPS_MOCK_PROPS, groupsAutocompleteQuery, groupsAutocompleteQuerySuccess), - ); + beforeEach(() => createComponent(GROUPS_MOCK_PROPS)); it('renders a correct title', () => { expect(findTitle().exists()).toBe(true); @@ -182,8 +207,11 @@ describe('List Selector spec', () => { }); }); - it('renders a List box component with the correct props', () => { - expect(findListBox().props()).toMatchObject({ multiple: true, items: searchResponse }); + it('renders a dropdown for the search results', () => { + expect(findSearchResultsDropdown().props()).toMatchObject({ + multiple: true, + items: searchResponse, + }); }); it('renders a group component for each search result', () => { diff --git a/spec/frontend/vue_shared/components/list_selector/mock_data.js b/spec/frontend/vue_shared/components/list_selector/mock_data.js index 25ecac9632b..5b44a0c2a83 100644 --- a/spec/frontend/vue_shared/components/list_selector/mock_data.js +++ b/spec/frontend/vue_shared/components/list_selector/mock_data.js @@ -1,28 +1,20 @@ -export const USERS_RESPONSE_MOCK = { - data: { - project: { - id: 'gid://gitlab/Project/20', - autocompleteUsers: [ - { - id: 'gid://gitlab/User/1', - avatarUrl: '/uploads/-/system/user/avatar/1/avatar.png', - name: 'Administrator', - username: 'root', - __typename: 'AutocompletedUser', - }, - { - id: 'gid://gitlab/User/15', - avatarUrl: - 'https://www.gravatar.com/avatar/c4ab964b90c3049c47882b319d3c5cc0?s=80\u0026d=identicon', - name: 'Corrine Rath', - username: 'laronda.graham', - __typename: 'AutocompletedUser', - }, - ], - __typename: 'Project', - }, +export const USERS_RESPONSE_MOCK = [ + { + id: 'gid://gitlab/User/1', + avatarUrl: '/uploads/-/system/user/avatar/1/avatar.png', + name: 'Administrator', + username: 'root', + __typename: 'AutocompletedUser', }, -}; + { + id: 'gid://gitlab/User/15', + avatarUrl: + 'https://www.gravatar.com/avatar/c4ab964b90c3049c47882b319d3c5cc0?s=80\u0026d=identicon', + name: 'Corrine Rath', + username: 'laronda.graham', + __typename: 'AutocompletedUser', + }, +]; export const GROUPS_RESPONSE_MOCK = { data: { diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js index 02e729a00bd..71ff5275063 100644 --- a/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js @@ -133,6 +133,7 @@ describe('IssuableBody', () => { issuable: issuableBodyProps.issuable, statusIcon: issuableBodyProps.statusIcon, enableEdit: issuableBodyProps.enableEdit, + workspaceType: issuableBodyProps.workspaceType, }); }); diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js index ad7afefff12..6d1d3773643 100644 --- a/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js @@ -52,6 +52,7 @@ describe('IssuableShowRoot', () => { descriptionPreviewPath, descriptionHelpPath, taskCompletionStatus, + workspaceType, } = mockIssuableShowProps; const { state, blocked, confidential, createdAt, author } = mockIssuable; @@ -92,6 +93,7 @@ describe('IssuableShowRoot', () => { editFormVisible, descriptionPreviewPath, descriptionHelpPath, + workspaceType, }); }); diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js index eefc9142064..0ea69bc27e5 100644 --- a/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js @@ -4,6 +4,7 @@ import { nextTick } from 'vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import IssuableTitle from '~/vue_shared/issuable/show/components/issuable_title.vue'; +import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; import { mockIssuableShowProps, mockIssuable } from '../mock_data'; @@ -86,19 +87,39 @@ describe('IssuableTitle', () => { expect(tooltip).toBeDefined(); }); - it('renders sticky header when `stickyTitleVisible` prop is true', async () => { - wrapper.findComponent(GlIntersectionObserver).vm.$emit('disappear'); - await nextTick(); + describe('sticky header', () => { + it('renders when `stickyTitleVisible` prop is true', async () => { + wrapper.findComponent(GlIntersectionObserver).vm.$emit('disappear'); + await nextTick(); - const stickyHeaderEl = findStickyHeader(); + const stickyHeaderEl = findStickyHeader(); - expect(stickyHeaderEl.exists()).toBe(true); - expect(stickyHeaderEl.findComponent(GlBadge).props('variant')).toBe('success'); - expect(stickyHeaderEl.findComponent(GlIcon).props('name')).toBe( - issuableTitleProps.statusIcon, - ); - expect(stickyHeaderEl.text()).toContain('Open'); - expect(stickyHeaderEl.text()).toContain(issuableTitleProps.issuable.title); + expect(stickyHeaderEl.exists()).toBe(true); + expect(stickyHeaderEl.findComponent(GlBadge).props('variant')).toBe('success'); + expect(stickyHeaderEl.findComponent(GlIcon).props('name')).toBe( + issuableTitleProps.statusIcon, + ); + expect(stickyHeaderEl.text()).toContain('Open'); + expect(stickyHeaderEl.findComponent(ConfidentialityBadge).exists()).toBe(false); + expect(stickyHeaderEl.text()).toContain(issuableTitleProps.issuable.title); + }); + + it('renders ConfidentialityBadge when issuable is confidential', async () => { + wrapper = createComponent({ + ...mockIssuableShowProps, + issuable: { + ...mockIssuable, + confidential: true, + }, + }); + + wrapper.findComponent(GlIntersectionObserver).vm.$emit('disappear'); + await nextTick(); + + const stickyHeaderEl = findStickyHeader(); + + expect(stickyHeaderEl.findComponent(ConfidentialityBadge).exists()).toBe(true); + }); }); }); }); diff --git a/spec/graphql/types/organizations/organization_user_badge_type_spec.rb b/spec/graphql/types/organizations/organization_user_badge_type_spec.rb new file mode 100644 index 00000000000..1ea9b3ad1df --- /dev/null +++ b/spec/graphql/types/organizations/organization_user_badge_type_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['OrganizationUserBadge'], feature_category: :cell do + let(:expected_fields) { %w[text variant] } + + specify { expect(described_class.graphql_name).to eq('OrganizationUserBadge') } + specify { expect(described_class).to have_graphql_fields(*expected_fields) } +end diff --git a/spec/graphql/types/user_type_spec.rb b/spec/graphql/types/user_type_spec.rb index af0f8a86b6c..457127f5bed 100644 --- a/spec/graphql/types/user_type_spec.rb +++ b/spec/graphql/types/user_type_spec.rb @@ -55,6 +55,7 @@ RSpec.describe GitlabSchema.types['User'], feature_category: :user_profile do organization jobTitle createdAt + lastActivityOn pronouns ide ] diff --git a/spec/helpers/nav_helper_spec.rb b/spec/helpers/nav_helper_spec.rb index 3e0fc1ffcb7..9a0f72838fb 100644 --- a/spec/helpers/nav_helper_spec.rb +++ b/spec/helpers/nav_helper_spec.rb @@ -150,7 +150,7 @@ RSpec.describe NavHelper, feature_category: :navigation do context 'when user has new nav disabled' do let(:user_preference) { false } - specify { expect(subject).to eq false } + specify { expect(subject).to eq true } end context 'when user has new nav enabled' do diff --git a/spec/lib/gitlab/background_migration/batched_migration_job_spec.rb b/spec/lib/gitlab/background_migration/batched_migration_job_spec.rb index 93a4d0ca602..da24e9b7978 100644 --- a/spec/lib/gitlab/background_migration/batched_migration_job_spec.rb +++ b/spec/lib/gitlab/background_migration/batched_migration_job_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::BatchedMigrationJob do +RSpec.describe Gitlab::BackgroundMigration::BatchedMigrationJob, feature_category: :database do let(:connection) { Gitlab::Database.database_base_models[:main].connection } describe '.generic_instance' do diff --git a/spec/lib/gitlab/database/dynamic_model_helpers_spec.rb b/spec/lib/gitlab/database/dynamic_model_helpers_spec.rb index fe423b3639b..7ab50d47408 100644 --- a/spec/lib/gitlab/database/dynamic_model_helpers_spec.rb +++ b/spec/lib/gitlab/database/dynamic_model_helpers_spec.rb @@ -2,28 +2,83 @@ require 'spec_helper' -RSpec.describe Gitlab::Database::DynamicModelHelpers do +RSpec.describe Gitlab::Database::DynamicModelHelpers, feature_category: :database do let(:including_class) { Class.new.include(described_class) } let(:table_name) { Project.table_name } let(:connection) { Project.connection } describe '#define_batchable_model' do - subject { including_class.new.define_batchable_model(table_name, connection: connection) } + subject(:model) { including_class.new.define_batchable_model(table_name, connection: connection) } it 'is an ActiveRecord model' do - expect(subject.ancestors).to include(ActiveRecord::Base) + expect(model.ancestors).to include(ActiveRecord::Base) end it 'includes EachBatch' do - expect(subject.included_modules).to include(EachBatch) + expect(model.included_modules).to include(EachBatch) end it 'has the correct table name' do - expect(subject.table_name).to eq(table_name) + expect(model.table_name).to eq(table_name) end it 'has the inheritance type column disable' do - expect(subject.inheritance_column).to eq('_type_disabled') + expect(model.inheritance_column).to eq('_type_disabled') + end + + context 'for primary key' do + subject(:model) do + including_class.new.define_batchable_model(table_name, connection: connection, primary_key: primary_key) + end + + context 'when table primary key is a single column' do + let(:primary_key) { nil } + + context 'when primary key is nil' do + it 'does not change the primary key from :id' do + expect(model.primary_key).to eq('id') + end + end + + context 'when primary key is not nil' do + let(:primary_key) { 'other_column' } + + it 'does not change the primary key from :id' do + expect(model.primary_key).to eq('id') + end + end + end + + context 'when table has composite primary key' do + let(:primary_key) { nil } + let(:table_name) { :_test_composite_primary_key } + + before do + connection.execute(<<~SQL) + DROP TABLE IF EXISTS #{table_name}; + + CREATE TABLE #{table_name} ( + id integer NOT NULL, + partition_id integer NOT NULL, + PRIMARY KEY (id, partition_id) + ); + SQL + end + + context 'when primary key is nil' do + it 'does not change the primary key from nil' do + expect(model.primary_key).to be_nil + end + end + + context 'when primary key is not nil' do + let(:primary_key) { 'id' } + + it 'changes the primary key' do + expect(model.primary_key).to eq('id') + end + end + end end end diff --git a/spec/lib/gitlab/github_import/attachments_downloader_spec.rb b/spec/lib/gitlab/github_import/attachments_downloader_spec.rb index 72d8a9c0403..65c5a7daeb2 100644 --- a/spec/lib/gitlab/github_import/attachments_downloader_spec.rb +++ b/spec/lib/gitlab/github_import/attachments_downloader_spec.rb @@ -94,9 +94,9 @@ RSpec.describe Gitlab::GithubImport::AttachmentsDownloader, feature_category: :i end end - context 'when attachment is behind a redirect' do - let_it_be(:file_url) { "https://github.com/test/project/assets/142635249/4b9f9c90-f060-4845-97cf-b24c558bcb11" } - let(:redirect_url) { "https://https://github-production-user-asset-6210df.s3.amazonaws.com/142635249/740edb05293e.jpg" } + context 'when attachment is behind a github asset endpoint' do + let(:file_url) { "https://github.com/test/project/assets/142635249/4b9f9c90-f060-4845-97cf-b24c558bcb11" } + let(:redirect_url) { "https://github-production-user-asset-6210df.s3.amazonaws.com/142635249/740edb05293e.jpg" } let(:sample_response) do instance_double(HTTParty::Response, redirection?: true, headers: { location: redirect_url }) end @@ -115,6 +115,8 @@ RSpec.describe Gitlab::GithubImport::AttachmentsDownloader, feature_category: :i end context 'when url is not a redirection' do + let(:file_url) { "https://github.com/test/project/assets/142635249/4b9f9c90-f060-4845-97cf-b24c558bcb11.jpg" } + let(:sample_response) do instance_double(HTTParty::Response, code: 200, redirection?: false) end @@ -125,8 +127,13 @@ RSpec.describe Gitlab::GithubImport::AttachmentsDownloader, feature_category: :i .and_return sample_response end - it 'raises upon unsuccessful redirection' do - expect { downloader.perform }.to raise_error("expected a redirect response, got #{sample_response.code}") + it 'queries with original file_url' do + expect(Gitlab::HTTP).to receive(:perform_request) + .with(Net::HTTP::Get, file_url, stream_body: true).and_yield(chunk_double) + + file = downloader.perform + + expect(File.exist?(file.path)).to eq(true) end end diff --git a/spec/lib/gitlab/other_markup_spec.rb b/spec/lib/gitlab/other_markup_spec.rb index 607d4cc9fd5..cdeaed6d368 100644 --- a/spec/lib/gitlab/other_markup_spec.rb +++ b/spec/lib/gitlab/other_markup_spec.rb @@ -111,6 +111,22 @@ RSpec.describe Gitlab::OtherMarkup, feature_category: :wiki do end end + context 'RedCloth markup' do + it 'renders textile correctly' do + test_text = '"This is *my* text."' + expected_res = "<p>“This is <strong>my</strong> text.”</p>" + expect(RedCloth.new(test_text).to_html).to eq(expected_res) + end + + it 'protects against malicious backtracking' do + test_text = '<A' + ('A' * 54773) + + expect do + Timeout.timeout(3.seconds) { RedCloth.new(test_text, [:sanitize_html]).to_html } + end.not_to raise_error + end + end + def render(...) described_class.render(...) end diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/count_projects_with_jira_dvcs_integration_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/count_projects_with_jira_dvcs_integration_metric_spec.rb deleted file mode 100644 index a2d86fc5044..00000000000 --- a/spec/lib/gitlab/usage/metrics/instrumentations/count_projects_with_jira_dvcs_integration_metric_spec.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Usage::Metrics::Instrumentations::CountProjectsWithJiraDvcsIntegrationMetric, - feature_category: :integrations do - describe 'metric value and query' do - let_it_be_with_reload(:project_1) { create(:project) } - let_it_be_with_reload(:project_2) { create(:project) } - let_it_be_with_reload(:project_3) { create(:project) } - - before do - project_1.feature_usage.log_jira_dvcs_integration_usage(cloud: false) - project_2.feature_usage.log_jira_dvcs_integration_usage(cloud: false) - project_3.feature_usage.log_jira_dvcs_integration_usage(cloud: true) - end - - context 'when counting cloud integrations' do - let(:expected_value) { 1 } - let(:expected_query) do - 'SELECT COUNT("project_feature_usages"."project_id") FROM "project_feature_usages" ' \ - 'WHERE "project_feature_usages"."jira_dvcs_cloud_last_sync_at" IS NOT NULL' - end - - it_behaves_like 'a correct instrumented metric value and query', { time_frame: 'all', options: { cloud: true } } - end - - context 'when counting non-cloud integrations' do - let(:expected_value) { 2 } - let(:expected_query) do - 'SELECT COUNT("project_feature_usages"."project_id") FROM "project_feature_usages" ' \ - 'WHERE "project_feature_usages"."jira_dvcs_server_last_sync_at" IS NOT NULL' - end - - it_behaves_like 'a correct instrumented metric value and query', { time_frame: 'all', options: { cloud: false } } - end - end - - it "raises an exception if option is not present" do - expect do - described_class.new(options: {}, time_frame: 'all') - end.to raise_error(ArgumentError, %r{must be a boolean}) - end - - it "raises an exception if option has invalid value" do - expect do - described_class.new(options: { cloud: 'yes' }, time_frame: 'all') - end.to raise_error(ArgumentError, %r{must be a boolean}) - end -end diff --git a/spec/models/ci/catalog/components_project_spec.rb b/spec/models/ci/catalog/components_project_spec.rb index 4a7182a24d6..79e1a113e47 100644 --- a/spec/models/ci/catalog/components_project_spec.rb +++ b/spec/models/ci/catalog/components_project_spec.rb @@ -39,7 +39,7 @@ RSpec.describe Ci::Catalog::ComponentsProject, feature_category: :pipeline_compo it 'does not fetch more paths than the limit' do paths = components_project.fetch_component_paths(project.default_branch, limit: 1) - expect(paths.size).to be(1) + expect(paths.size).to eq(1) end end @@ -53,7 +53,11 @@ RSpec.describe Ci::Catalog::ComponentsProject, feature_category: :pipeline_compo context 'with valid component paths' do where(:path, :name) do 'templates/secret-detection.yml' | 'secret-detection' + 'templates/secret_detection.yml' | 'secret_detection' + 'templates/secret_detection123.yml' | 'secret_detection123' + 'templates/secret-detection-123.yml' | 'secret-detection-123' 'templates/dast/template.yml' | 'dast' + 'templates/d-a-s_t/template.yml' | 'd-a-s_t' 'templates/template.yml' | 'template' 'templates/blank-yaml.yml' | 'blank-yaml' end diff --git a/spec/models/ci/catalog/resource_spec.rb b/spec/models/ci/catalog/resource_spec.rb index 9df6c9d663d..34268b92e1d 100644 --- a/spec/models/ci/catalog/resource_spec.rb +++ b/spec/models/ci/catalog/resource_spec.rb @@ -19,7 +19,13 @@ RSpec.describe Ci::Catalog::Resource, feature_category: :pipeline_composition do let_it_be(:release3) { create(:release, project: project, released_at: tomorrow) } it { is_expected.to belong_to(:project) } - it { is_expected.to have_many(:components).class_name('Ci::Catalog::Resources::Component') } + + it do + is_expected.to( + have_many(:components).class_name('Ci::Catalog::Resources::Component').with_foreign_key(:catalog_resource_id) + ) + end + it { is_expected.to have_many(:versions).class_name('Ci::Catalog::Resources::Version') } it { is_expected.to delegate_method(:avatar_path).to(:project) } @@ -122,6 +128,28 @@ RSpec.describe Ci::Catalog::Resource, feature_category: :pipeline_composition do end end + describe '#publish!' do + context 'when the catalog resource is in draft state' do + it 'updates the state of the catalog resource to published' do + expect(resource.state).to eq('draft') + + resource.publish! + + expect(resource.reload.state).to eq('published') + end + end + + context 'when a catalog resource already has a published state' do + it 'leaves the state as published' do + resource.update!(state: 'published') + + resource.publish! + + expect(resource.state).to eq('published') + end + end + end + describe '#unpublish!' do context 'when the catalog resource is in published state' do it 'updates the state to draft' do diff --git a/spec/models/ci/catalog/resources/component_spec.rb b/spec/models/ci/catalog/resources/component_spec.rb index e8c92ce0788..2ee91175920 100644 --- a/spec/models/ci/catalog/resources/component_spec.rb +++ b/spec/models/ci/catalog/resources/component_spec.rb @@ -9,6 +9,23 @@ RSpec.describe Ci::Catalog::Resources::Component, type: :model, feature_category it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:version).class_name('Ci::Catalog::Resources::Version') } + it_behaves_like 'a BulkInsertSafe model', described_class do + let_it_be(:project) { create(:project, :readme, description: 'project description') } + let_it_be(:catalog_resource) { create(:ci_catalog_resource, project: project) } + let_it_be(:version) { create(:ci_catalog_resource_version, project: project) } + + let(:valid_items_for_bulk_insertion) do + build_list(:ci_catalog_resource_component, 10) do |component| + component.catalog_resource = catalog_resource + component.version = version + component.project = project + component.created_at = Time.zone.now + end + end + + let(:invalid_items_for_bulk_insertion) { [] } + end + describe 'validations' do it { is_expected.to validate_presence_of(:catalog_resource) } it { is_expected.to validate_presence_of(:project) } diff --git a/spec/models/concerns/ci/partitionable/switch_spec.rb b/spec/models/concerns/ci/partitionable/switch_spec.rb index 0041a33e50e..c6e2ed265bd 100644 --- a/spec/models/concerns/ci/partitionable/switch_spec.rb +++ b/spec/models/concerns/ci/partitionable/switch_spec.rb @@ -31,8 +31,6 @@ RSpec.describe Ci::Partitionable::Switch, :aggregate_failures do end before do - allow(ActiveSupport::DescendantsTracker).to receive(:store_inherited) - create_tables(<<~SQL) CREATE TABLE _test_ci_jobs_metadata( id serial NOT NULL PRIMARY KEY, @@ -78,6 +76,15 @@ RSpec.describe Ci::Partitionable::Switch, :aggregate_failures do ) end + # the models defined here are leaked to other tests through + # `ActiveRecord::Base.descendants` and we need to counter the side effects + # from this. We redefine the method so that we don't check the FF existence + # outside of the examples here. + # `ActiveSupport::DescendantsTracker.clear` doesn't work with cached classes. + after do + model.define_singleton_method(:routing_table_enabled?) { false } + end + it { expect(model).not_to be_routing_class } it { expect(partitioned_model).to be_routing_class } diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 37b4a9011f4..2bca73545d0 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -1659,6 +1659,12 @@ RSpec.describe Group, feature_category: :groups_and_projects do end end + it 'returns true for a user in parent group' do + subgroup = create(:group, parent: group) + + expect(subgroup.member?(user)).to be_truthy + end + context 'in shared group' do let(:shared_group) { create(:group) } let(:member_shared) { create(:user) } diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index fdd8a610fe4..b4941c71d6a 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -591,6 +591,22 @@ RSpec.describe Member, feature_category: :groups_and_projects do it { is_expected.not_to include @member_with_minimal_access } it { is_expected.not_to include awaiting_group_member } it { is_expected.not_to include awaiting_project_member } + + context 'when minimal_access is true' do + subject { described_class.without_invites_and_requests(minimal_access: true) } + + it { is_expected.to include @owner } + it { is_expected.to include @maintainer } + it { is_expected.not_to include @invited_member } + it { is_expected.to include @accepted_invite_member } + it { is_expected.not_to include @requested_member } + it { is_expected.to include @accepted_request_member } + it { is_expected.to include @blocked_maintainer } + it { is_expected.to include @blocked_developer } + it { is_expected.to include @member_with_minimal_access } + it { is_expected.not_to include awaiting_group_member } + it { is_expected.not_to include awaiting_project_member } + end end describe '.connected_to_user' do diff --git a/spec/models/members/members/members_with_parents_spec.rb b/spec/models/members/members/members_with_parents_spec.rb new file mode 100644 index 00000000000..46c934c932f --- /dev/null +++ b/spec/models/members/members/members_with_parents_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Members::MembersWithParents, feature_category: :groups_and_projects do + let_it_be(:group) { create(:group, :nested) } + let_it_be(:maintainer) { group.parent.add_maintainer(create(:user)) } + let_it_be(:developer) { group.add_developer(create(:user)) } + let_it_be(:pending_maintainer) { create(:group_member, :awaiting, :maintainer, group: group.parent) } + let_it_be(:pending_developer) { create(:group_member, :awaiting, :developer, group: group) } + let_it_be(:invited_member) { create(:group_member, :invited, group: group) } + let_it_be(:inactive_developer) { group.add_developer(create(:user, :deactivated)) } + let_it_be(:minimal_access) { create(:group_member, :minimal_access, group: group) } + + describe '#all_members' do + subject(:all_members) { described_class.new(group).all_members } + + it 'returns all members for group and group parents' do + expect(all_members).to contain_exactly( + developer, + maintainer, + pending_maintainer, + pending_developer, + invited_member, + inactive_developer, + minimal_access + ) + end + end + + describe '#members' do + let(:arguments) { {} } + + subject(:members) { described_class.new(group).members(**arguments) } + + using Rspec::Parameterized::TableSyntax + + where(:arguments, :expected_members) do + [ + [ + {}, + lazy { [developer, maintainer, inactive_developer] } + ], + [ + # minimal access is Premium, so in FOSS we will not include minimal access member + { minimal_access: true }, + lazy { [developer, maintainer, inactive_developer] } + ], + [ + { active_users: true }, + lazy { [developer, maintainer] } + ] + ] + end + + with_them do + it 'returns expected members' do + expect(members).to contain_exactly(*expected_members) + expect(members).not_to include(*(group.members - expected_members)) + end + end + + context 'when active_users: true and minimal_access: true' do + let(:arguments) { { active_users: true, minimal_access: true } } + + it 'raises an error' do + expect { members }.to raise_error(ArgumentError) + end + end + + context 'with group sharing' do + let_it_be(:shared_with_group) { create(:group) } + + let_it_be(:shared_with_group_maintainer) do + shared_with_group.add_maintainer(create(:user)) + end + + let_it_be(:shared_with_group_developer) do + shared_with_group.add_developer(create(:user)) + end + + before do + create(:group_group_link, shared_group: group, shared_with_group: shared_with_group) + end + + it 'returns shared with group members' do + expect(members).to(include(shared_with_group_maintainer)) + expect(members).to(include(shared_with_group_developer)) + end + end + end +end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index a460e47a0e5..590abebb764 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -569,6 +569,48 @@ RSpec.describe Namespace, feature_category: :groups_and_projects do end end end + + describe "#default_branch_protection_settings" do + let(:default_branch_protection_defaults) { {} } + let(:namespace_setting) { create(:namespace_settings, default_branch_protection_defaults: default_branch_protection_defaults) } + let(:namespace) { create(:namespace, namespace_settings: namespace_setting) } + let(:group) { create(:group, namespace_settings: namespace_setting) } + + before do + stub_application_setting(default_branch_protection_defaults: Gitlab::Access::BranchProtection.protected_against_developer_pushes) + end + + context 'for a namespace' do + it 'returns the instance level setting' do + expected_settings = Gitlab::Access::BranchProtection.protected_against_developer_pushes.deep_stringify_keys + settings = namespace.default_branch_protection_settings.to_hash + + expect(settings).to eq(expected_settings) + end + end + + context 'for a group' do + context 'that has not altered the default value' do + it 'returns the instance level setting' do + expected_settings = Gitlab::Access::BranchProtection.protected_against_developer_pushes.deep_stringify_keys + settings = group.default_branch_protection_settings.to_hash + + expect(settings).to eq(expected_settings) + end + end + + context 'that has altered the default value' do + let(:default_branch_protection_defaults) { Gitlab::Access::BranchProtection.protected_fully.deep_stringify_keys } + + it 'returns the group level setting' do + expected_settings = default_branch_protection_defaults + settings = group.default_branch_protection_settings.to_hash + + expect(settings).to eq(expected_settings) + end + end + end + end end describe "Respond to" do diff --git a/spec/presenters/project_presenter_spec.rb b/spec/presenters/project_presenter_spec.rb index 42c43a59fe2..48db41ea8e3 100644 --- a/spec/presenters/project_presenter_spec.rb +++ b/spec/presenters/project_presenter_spec.rb @@ -872,4 +872,26 @@ RSpec.describe ProjectPresenter do end end end + + describe '#has_review_app?' do + subject { presenter.has_review_app? } + + let_it_be(:project) { create(:project, :repository) } + + context 'when review apps exist' do + let!(:environment) do + create(:environment, :with_review_app, project: project) + end + + it { is_expected.to be_truthy } + end + + context 'when review apps do not exist' do + let!(:environment) do + create(:environment, project: project) + end + + it { is_expected.to be_falsey } + end + end end diff --git a/spec/requests/api/graphql/organizations/organization_query_spec.rb b/spec/requests/api/graphql/organizations/organization_query_spec.rb index d02158382eb..c243e0613ad 100644 --- a/spec/requests/api/graphql/organizations/organization_query_spec.rb +++ b/spec/requests/api/graphql/organizations/organization_query_spec.rb @@ -79,7 +79,10 @@ RSpec.describe 'getting organization information', feature_category: :cell do <<~FIELDS organizationUsers { nodes { - badges + badges { + text + variant + } id user { id @@ -94,7 +97,7 @@ RSpec.describe 'getting organization information', feature_category: :cell do organization_user_node = graphql_data_at(:organization, :organizationUsers, :nodes).first expected_attributes = { - "badges" => ["It's you!"], + "badges" => [{ "text" => "It's you!", "variant" => 'muted' }], "id" => organization_user.to_global_id.to_s, "user" => { "id" => user.to_global_id.to_s } } diff --git a/spec/serializers/review_app_setup_entity_spec.rb b/spec/serializers/review_app_setup_entity_spec.rb index 9b068a2e9dd..9c6d54fd612 100644 --- a/spec/serializers/review_app_setup_entity_spec.rb +++ b/spec/serializers/review_app_setup_entity_spec.rb @@ -22,6 +22,10 @@ RSpec.describe ReviewAppSetupEntity do expect(subject).to include(:can_setup_review_app) end + it 'contains has_review_app' do + expect(subject).to include(:has_review_app) + end + context 'when the user can setup a review app' do before do allow(presenter).to receive(:can_setup_review_app?).and_return(true) diff --git a/spec/services/application_settings/update_service_spec.rb b/spec/services/application_settings/update_service_spec.rb index 1619e1db6c6..474d6ec4a9b 100644 --- a/spec/services/application_settings/update_service_spec.rb +++ b/spec/services/application_settings/update_service_spec.rb @@ -321,7 +321,9 @@ RSpec.describe ApplicationSettings::UpdateService, feature_category: :shared do let(:params) { { default_branch_protection: ::Gitlab::Access::PROTECTION_DEV_CAN_MERGE } } it "updates default_branch_protection_defaults from the default_branch_protection param" do - expect { subject.execute }.to change { application_settings.default_branch_protection_defaults }.from({}).to(expected) + default_value = ::Gitlab::Access::BranchProtection.protected_fully.deep_stringify_keys + + expect { subject.execute }.to change { application_settings.default_branch_protection_defaults }.from(default_value).to(expected) end end diff --git a/spec/services/ci/catalog/resources/versions/create_service_spec.rb b/spec/services/ci/catalog/resources/versions/create_service_spec.rb new file mode 100644 index 00000000000..e614a74a4a1 --- /dev/null +++ b/spec/services/ci/catalog/resources/versions/create_service_spec.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::Catalog::Resources::Versions::CreateService, feature_category: :pipeline_composition do + describe '#execute' do + let(:files) do + { + 'templates/secret-detection.yml' => "spec:\n inputs:\n website:\n---\nimage: alpine_1", + 'templates/dast/template.yml' => 'image: alpine_2', + 'templates/blank-yaml.yml' => '', + 'templates/dast/sub-folder/template.yml' => 'image: alpine_3', + 'templates/template.yml' => "spec:\n inputs:\n environment:\n---\nimage: alpine_6", + 'tests/test.yml' => 'image: alpine_7', + 'README.md' => 'Read me' + } + end + + let(:project) do + create( + :project, :custom_repo, + description: 'Simple and Complex components', + files: files + ) + end + + let(:release) { create(:release, project: project, sha: project.repository.root_ref_sha) } + let!(:catalog_resource) { create(:ci_catalog_resource, project: project) } + + context 'when the project is not a catalog resource' do + it 'does not create a version' do + project = create(:project, :repository) + release = create(:release, project: project, sha: project.repository.root_ref_sha) + + response = described_class.new(release).execute + + expect(response).to be_error + expect(response.message).to include('Project is not a catalog resource') + end + end + + context 'when the catalog resource has different types of components and a release' do + it 'creates a version for the release' do + response = described_class.new(release).execute + + expect(response).to be_success + + version = Ci::Catalog::Resources::Version.last + + expect(version.release).to eq(release) + expect(version.catalog_resource).to eq(catalog_resource) + expect(version.catalog_resource.project).to eq(project) + end + + it 'marks the catalog resource as published' do + described_class.new(release).execute + + expect(catalog_resource.reload.state).to eq('published') + end + + context 'when the ci_catalog_create_metadata feature flag is disabled' do + before do + stub_feature_flags(ci_catalog_create_metadata: false) + end + + it 'does not create components' do + expect(Ci::Catalog::Resources::Component).not_to receive(:bulk_insert!).and_call_original + expect(project.ci_components.count).to eq(0) + + response = described_class.new(release).execute + + expect(response).to be_success + expect(project.ci_components.count).to eq(0) + end + end + + context 'when the ci_catalog_create_metadata feature flag is enabled' do + context 'when there are more than 10 components' do + let(:files) do + { + 'templates/secret11.yml' => '', + 'templates/secret10.yml' => '', + 'templates/secret8.yml' => '', + 'templates/secret7.yml' => '', + 'templates/secret6.yml' => '', + 'templates/secret5.yml' => '', + 'templates/secret4.yml' => '', + 'templates/secret3.yml' => '', + 'templates/secret2.yml' => '', + 'templates/secret1.yml' => '', + 'templates/secret0.yml' => '', + 'README.md' => 'Read me' + } + end + + it 'does not create components' do + response = described_class.new(release).execute + + expect(response).to be_error + expect(response.message).to include('Release cannot contain more than 10 components') + expect(project.ci_components.count).to eq(0) + end + end + + it 'bulk inserts all the components' do + expect(Ci::Catalog::Resources::Component).to receive(:bulk_insert!).and_call_original + + described_class.new(release).execute + end + + it 'creates components for the catalog resource' do + expect(project.ci_components.count).to eq(0) + response = described_class.new(release).execute + + expect(response).to be_success + + version = Ci::Catalog::Resources::Version.last + + expect(project.ci_components.count).to eq(4) + expect(project.ci_components.first.name).to eq('blank-yaml') + expect(project.ci_components.first.project).to eq(version.project) + expect(project.ci_components.first.inputs).to eq({}) + expect(project.ci_components.first.catalog_resource).to eq(version.catalog_resource) + expect(project.ci_components.first.version).to eq(version) + expect(project.ci_components.first.path).to eq('templates/blank-yaml.yml') + expect(project.ci_components.second.name).to eq('dast') + expect(project.ci_components.second.project).to eq(version.project) + expect(project.ci_components.second.inputs).to eq({}) + expect(project.ci_components.second.catalog_resource).to eq(version.catalog_resource) + expect(project.ci_components.second.version).to eq(version) + expect(project.ci_components.second.path).to eq('templates/dast/template.yml') + expect(project.ci_components.third.name).to eq('secret-detection') + expect(project.ci_components.third.project).to eq(version.project) + expect(project.ci_components.third.inputs).to eq({ "website" => nil }) + expect(project.ci_components.third.catalog_resource).to eq(version.catalog_resource) + expect(project.ci_components.third.version).to eq(version) + expect(project.ci_components.third.path).to eq('templates/secret-detection.yml') + expect(project.ci_components.fourth.name).to eq('template') + expect(project.ci_components.fourth.project).to eq(version.project) + expect(project.ci_components.fourth.inputs).to eq({ "environment" => nil }) + expect(project.ci_components.fourth.catalog_resource).to eq(version.catalog_resource) + expect(project.ci_components.fourth.version).to eq(version) + expect(project.ci_components.fourth.path).to eq('templates/template.yml') + end + end + end + + context 'with invalid data' do + let_it_be(:files) do + { + 'templates/secret-detection.yml' => 'some: invalid: syntax', + 'README.md' => 'Read me' + } + end + + it 'returns an error' do + response = described_class.new(release).execute + + expect(response).to be_error + expect(response.message).to include('mapping values are not allowed in this context') + end + end + + context 'when one or more components are invalid' do + let_it_be(:files) do + { + 'templates/secret-detection.yml' => "spec:\n inputs:\n - website\n---\nimage: alpine_1", + 'README.md' => 'Read me' + } + end + + it 'returns an error' do + response = described_class.new(release).execute + + expect(response).to be_error + expect(response.message).to include('Inputs must be a valid json schema') + end + end + end +end diff --git a/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb b/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb index dde9498eb2c..9bd10d56d8c 100644 --- a/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb +++ b/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb @@ -5,7 +5,6 @@ RSpec.shared_examples 'multiple issue boards' do context 'authorized user' do before do - stub_feature_flags(apollo_boards: false) parent.add_maintainer(user) login_as(user) @@ -124,7 +123,6 @@ RSpec.shared_examples 'multiple issue boards' do context 'unauthorized user' do before do - stub_feature_flags(apollo_boards: false) visit boards_path wait_for_requests end diff --git a/spec/support/shared_examples/graphql/types/merge_request_interactions_type_shared_examples.rb b/spec/support/shared_examples/graphql/types/merge_request_interactions_type_shared_examples.rb index c32e758d921..b653b4e265a 100644 --- a/spec/support/shared_examples/graphql/types/merge_request_interactions_type_shared_examples.rb +++ b/spec/support/shared_examples/graphql/types/merge_request_interactions_type_shared_examples.rb @@ -50,6 +50,7 @@ RSpec.shared_examples "a user type with merge request interaction type" do organization jobTitle createdAt + lastActivityOn pronouns ide ] diff --git a/spec/support/shared_examples/requests/api/graphql/issue_list_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/issue_list_shared_examples.rb index 06875440cca..90fa0c98376 100644 --- a/spec/support/shared_examples/requests/api/graphql/issue_list_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/graphql/issue_list_shared_examples.rb @@ -26,6 +26,18 @@ RSpec.shared_examples 'graphql issue list request spec' do issue_b.assignee_ids = another_user.id end + context 'when filtering by state' do + context 'when filtering by locked state' do + let(:issue_filter_params) { { state: :locked } } + + it 'returns an error message' do + post_query + + expect_graphql_errors_to_include(Types::IssuableStateEnum::INVALID_LOCKED_MESSAGE) + end + end + end + context 'when filtering by assignees' do context 'when both assignee_username filters are provided' do let(:issue_filter_params) do diff --git a/spec/support/shared_examples/requests/api/graphql/work_item_list_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/work_item_list_shared_examples.rb index a9c422c8f2d..82f98b883dc 100644 --- a/spec/support/shared_examples/requests/api/graphql/work_item_list_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/graphql/work_item_list_shared_examples.rb @@ -46,6 +46,14 @@ RSpec.shared_examples 'graphql work item list request spec' do expect(work_item_ids).to include(closed_work_item.to_global_id.to_s) end end + + context 'when filtering by state locked' do + let(:item_filter_params) { { state: :locked } } + + it 'return an error message' do + expect_graphql_errors_to_include(Types::IssuableStateEnum::INVALID_LOCKED_MESSAGE) + end + end end context 'when filtering by type' do diff --git a/spec/support/shared_examples/requests/api/multiple_and_scoped_issue_boards_shared_examples.rb b/spec/support/shared_examples/requests/api/multiple_and_scoped_issue_boards_shared_examples.rb index d749479544d..fa111ca5811 100644 --- a/spec/support/shared_examples/requests/api/multiple_and_scoped_issue_boards_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/multiple_and_scoped_issue_boards_shared_examples.rb @@ -5,7 +5,6 @@ RSpec.shared_examples 'multiple and scoped issue boards' do |route_definition| context 'multiple issue boards' do before do - stub_feature_flags(apollo_boards: false) board_parent.add_reporter(user) stub_licensed_features(multiple_group_issue_boards: true) end diff --git a/spec/support/shared_examples/views/themed_layout_examples.rb b/spec/support/shared_examples/views/themed_layout_examples.rb index 599fd141dd7..ffbc9026240 100644 --- a/spec/support/shared_examples/views/themed_layout_examples.rb +++ b/spec/support/shared_examples/views/themed_layout_examples.rb @@ -8,7 +8,7 @@ RSpec.shared_examples "a layout which reflects the application theme setting" do it 'renders with the default theme' do render - expect(rendered).to have_selector("body.#{default_theme_class}") + expect(rendered).to have_selector("html.#{default_theme_class}") end end @@ -24,10 +24,10 @@ RSpec.shared_examples "a layout which reflects the application theme setting" do render if chosen_theme.css_class != default_theme_class - expect(rendered).not_to have_selector("body.#{default_theme_class}") + expect(rendered).not_to have_selector("html.#{default_theme_class}") end - expect(rendered).to have_selector("body.#{chosen_theme.css_class}") + expect(rendered).to have_selector("html.#{chosen_theme.css_class}") end end end diff --git a/spec/tooling/danger/project_helper_spec.rb b/spec/tooling/danger/project_helper_spec.rb index 00e528b0caf..2da90ddbd67 100644 --- a/spec/tooling/danger/project_helper_spec.rb +++ b/spec/tooling/danger/project_helper_spec.rb @@ -243,6 +243,7 @@ RSpec.describe Tooling::Danger::ProjectHelper do [:analytics_instrumentation] | '+data-track-action' | ['components/welcome.vue'] [:analytics_instrumentation] | '+ data: { track_label:' | ['admin/groups/_form.html.haml'] [:analytics_instrumentation] | '+ Gitlab::Tracking.event' | ['dashboard/todos_controller.rb', 'admin/groups/_form.html.haml'] + [:analytics_instrumentation] | '+ Gitlab::Tracking.event("c", "a")' | ['dashboard/todos_controller.rb', 'admin/groups/_form.html.haml'] [:database, :backend, :analytics_instrumentation] | '+ count(User.active)' | ['usage_data.rb', 'lib/gitlab/usage_data.rb', 'ee/lib/ee/gitlab/usage_data.rb'] [:database, :backend, :analytics_instrumentation] | '+ estimate_batch_distinct_count(User.active)' | ['usage_data.rb'] [:backend, :analytics_instrumentation] | '+ alt_usage_data(User.active)' | ['lib/gitlab/usage_data.rb'] diff --git a/tooling/danger/project_helper.rb b/tooling/danger/project_helper.rb index d0cea5516ac..2b781b58a64 100644 --- a/tooling/danger/project_helper.rb +++ b/tooling/danger/project_helper.rb @@ -131,7 +131,7 @@ module Tooling generator_templates/usage_metric_definition/metric_definition\.yml)\z}x => [:backend, :analytics_instrumentation], %r{gitlab/usage_data(_spec)?\.rb} => [:analytics_instrumentation], [%r{\.haml\z}, %r{data: \{ track}] => [:analytics_instrumentation], - [%r{\.(rb|haml)\z}, %r{Gitlab::Tracking\.(event|enabled\?|options)$}] => [:analytics_instrumentation], + [%r{\.(rb|haml)\z}, %r{Gitlab::Tracking\.(event|enabled\?|options)}] => [:analytics_instrumentation], [%r{\.(vue|js)\z}, %r{(Tracking.event|/\btrack\(/|data-track-action)}] => [:analytics_instrumentation], %r{\A((ee|jh)/)?app/(?!assets|views)[^/]+} => :backend, diff --git a/vendor/languages.yml b/vendor/languages.yml index 0a6f78ebe5d..4b1d9ffdb3b 100755 --- a/vendor/languages.yml +++ b/vendor/languages.yml @@ -2750,7 +2750,7 @@ Kit: language_id: 188 Kotlin: type: programming - color: "#F18E33" + color: "#7F52FF" extensions: - ".kt" - ".ktm" diff --git a/yarn.lock b/yarn.lock index 36380d7144d..ee37317309a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1179,10 +1179,10 @@ resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.6.2.tgz#1816b5f6948029c5eaacb0703b850ee0cb37d8f8" integrity sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw== -"@eslint/eslintrc@^2.1.2": - version "2.1.2" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.2.tgz#c6936b4b328c64496692f76944e755738be62396" - integrity sha512-+wvgpDsrB1YqAMdEUCcnTlpfVBH7Vqn6A/NT3D8WVXFIaKMlErPIZT3oCIAVCOtarRpMtelZLqJeU3t7WY6X6g== +"@eslint/eslintrc@^2.1.3": + version "2.1.3" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.3.tgz#797470a75fe0fbd5a53350ee715e85e87baff22d" + integrity sha512-yZzuIG+jnVu6hNSzFEN07e8BxF3uAzYtQb6uDkaYZLo6oYZDCq454c5kB8zxnzfCYyP4MIuyBn10L0DqwujTmA== dependencies: ajv "^6.12.4" debug "^4.3.2" @@ -1194,10 +1194,10 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@8.52.0": - version "8.52.0" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.52.0.tgz#78fe5f117840f69dc4a353adf9b9cd926353378c" - integrity sha512-mjZVbpaeMZludF2fsWLD0Z9gCref1Tk4i9+wddjRvpUNqqcndPkBD09N/Mapey0b3jaXbLm2kICwFv2E64QinA== +"@eslint/js@8.53.0": + version "8.53.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.53.0.tgz#bea56f2ed2b5baea164348ff4d5a879f6f81f20d" + integrity sha512-Kn7K8dx/5U6+cT1yEhpX1w4PCSg0M+XyRILPgvwcEBjerFWCwQj5sbr3/VmxqV0JGHCBCzyd6LxypEuehypY1w== "@floating-ui/core@^1.2.6": version "1.2.6" @@ -6244,15 +6244,15 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4 resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -eslint@8.52.0: - version "8.52.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.52.0.tgz#d0cd4a1fac06427a61ef9242b9353f36ea7062fc" - integrity sha512-zh/JHnaixqHZsolRB/w9/02akBk9EPrOs9JwcTP2ek7yL5bVvXuRariiaAjjoJ5DvuwQ1WAE/HsMz+w17YgBCg== +eslint@8.53.0: + version "8.53.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.53.0.tgz#14f2c8244298fcae1f46945459577413ba2697ce" + integrity sha512-N4VuiPjXDUa4xVeV/GC/RV3hQW9Nw+Y463lkWaKKXKYMvmRiRDAtfpuPFLN+E1/6ZhyR8J2ig+eVREnYgUsiag== dependencies: "@eslint-community/eslint-utils" "^4.2.0" "@eslint-community/regexpp" "^4.6.1" - "@eslint/eslintrc" "^2.1.2" - "@eslint/js" "8.52.0" + "@eslint/eslintrc" "^2.1.3" + "@eslint/js" "8.53.0" "@humanwhocodes/config-array" "^0.11.13" "@humanwhocodes/module-importer" "^1.0.1" "@nodelib/fs.walk" "^1.2.8" |