diff --git a/.gitignore b/.gitignore index 25c42cdb56d..8a47cc8d20b 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,7 @@ eslint-report.html /.gitlab_pages_secret /.gitlab_kas_secret /webpack-report/ +/crystalball/ /knapsack/ /rspec_flaky/ /locale/**/LC_MESSAGES diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d56ff9bab7a..2156ca19c73 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -59,6 +59,8 @@ variables: GET_SOURCES_ATTEMPTS: "3" KNAPSACK_RSPEC_SUITE_REPORT_PATH: knapsack/report-master.json FLAKY_RSPEC_SUITE_REPORT_PATH: rspec_flaky/report-suite.json + RSPEC_TESTS_MAPPING_PATH: crystalball/mapping.json + RSPEC_PACKED_TESTS_MAPPING_PATH: crystalball/packed-mapping.json BUILD_ASSETS_IMAGE: "false" ES_JAVA_OPTS: "-Xms256m -Xmx256m" ELASTIC_URL: "http://elastic:changeme@elasticsearch:9200" diff --git a/.gitlab/ci/rails.gitlab-ci.yml b/.gitlab/ci/rails.gitlab-ci.yml index f44ab9deb08..5a8f2651b6f 100644 --- a/.gitlab/ci/rails.gitlab-ci.yml +++ b/.gitlab/ci/rails.gitlab-ci.yml @@ -20,6 +20,7 @@ variables: RUBY_GC_MALLOC_LIMIT: 67108864 RUBY_GC_MALLOC_LIMIT_MAX: 134217728 + CRYSTALBALL: "true" needs: ["setup-test-env", "retrieve-tests-metadata", "compile-test-assets"] script: - *base-script @@ -29,6 +30,7 @@ when: always paths: - coverage/ + - crystalball/ - knapsack/ - rspec_flaky/ - rspec_profiling/ diff --git a/.gitlab/ci/test-metadata.gitlab-ci.yml b/.gitlab/ci/test-metadata.gitlab-ci.yml index 08c793120ab..ba5b3f98689 100644 --- a/.gitlab/ci/test-metadata.gitlab-ci.yml +++ b/.gitlab/ci/test-metadata.gitlab-ci.yml @@ -9,6 +9,7 @@ - knapsack/ - rspec_flaky/ - rspec_profiling/ + - crystalball/ retrieve-tests-metadata: extends: @@ -41,3 +42,4 @@ update-tests-metadata: - run_timed_command "retry gem install bundler:1.17.3 fog-aws mime-types activesupport rspec_profiling postgres-copy --no-document" - source ./scripts/rspec_helpers.sh - update_tests_metadata + - update_tests_mapping diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 69664070920..eeeae75d501 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -bee8517ab043ff98c283a5f191e68e2bd75eb9de +cf8e99ccc104f0a43f41e54896ee46a5e1b15a0a diff --git a/Gemfile b/Gemfile index d3671cac4f5..f845e9ccd77 100644 --- a/Gemfile +++ b/Gemfile @@ -386,6 +386,7 @@ group :development, :test do gem 'benchmark-ips', '~> 2.3.0', require: false gem 'knapsack', '~> 1.17' + gem 'crystalball', '~> 0.7.0', require: false gem 'simple_po_parser', '~> 1.1.2', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 7b7223af8b6..401286d900d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -199,6 +199,8 @@ GEM safe_yaml (~> 1.0.0) crass (1.0.6) creole (0.5.0) + crystalball (0.7.0) + git css_parser (1.7.0) addressable daemons (1.2.6) @@ -1291,6 +1293,7 @@ DEPENDENCIES connection_pool (~> 2.0) countries (~> 3.0) creole (~> 0.5.0) + crystalball (~> 0.7.0) danger (~> 8.0.6) database_cleaner (~> 1.7.0) deckar01-task_list (= 2.3.1) diff --git a/app/assets/javascripts/admin/dev_ops_report/components/devops_adoption_app.vue b/app/assets/javascripts/admin/dev_ops_report/components/devops_adoption_app.vue new file mode 100644 index 00000000000..ee2fe00fe02 --- /dev/null +++ b/app/assets/javascripts/admin/dev_ops_report/components/devops_adoption_app.vue @@ -0,0 +1,13 @@ + + diff --git a/app/assets/javascripts/admin/dev_ops_report/components/devops_adoption_empty_state.vue b/app/assets/javascripts/admin/dev_ops_report/components/devops_adoption_empty_state.vue new file mode 100644 index 00000000000..0fff9beb435 --- /dev/null +++ b/app/assets/javascripts/admin/dev_ops_report/components/devops_adoption_empty_state.vue @@ -0,0 +1,25 @@ + + diff --git a/app/assets/javascripts/admin/dev_ops_report/constants.js b/app/assets/javascripts/admin/dev_ops_report/constants.js new file mode 100644 index 00000000000..4f3b7879332 --- /dev/null +++ b/app/assets/javascripts/admin/dev_ops_report/constants.js @@ -0,0 +1,11 @@ +import { s__ } from '~/locale'; + +export const DEVOPS_ADOPTION_STRINGS = { + emptyState: { + title: s__('DevopsAdoption|Add a segment to get started'), + description: s__( + 'DevopsAdoption|DevOps adoption uses segments to track adoption across key features. Segments are a way to track multiple related projects and groups at once. For example, you could create a segment for the engineering department or a particular product team.', + ), + button: s__('DevopsAdoption|Add new segment'), + }, +}; diff --git a/app/assets/javascripts/admin/dev_ops_report/devops_adoption.js b/app/assets/javascripts/admin/dev_ops_report/devops_adoption.js new file mode 100644 index 00000000000..45901a5634f --- /dev/null +++ b/app/assets/javascripts/admin/dev_ops_report/devops_adoption.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import DevopsAdoptionApp from './components/devops_adoption_app.vue'; + +export default () => { + const el = document.querySelector('.js-devops-adoption'); + + if (!el) return false; + + const { emptyStateSvgPath } = el.dataset; + + return new Vue({ + el, + provide: { + emptyStateSvgPath, + }, + render(h) { + return h(DevopsAdoptionApp); + }, + }); +}; diff --git a/app/assets/javascripts/admin/dev_ops_report/devops_score_empty_state.js b/app/assets/javascripts/admin/dev_ops_report/devops_score_empty_state.js new file mode 100644 index 00000000000..0cb8d9be0e4 --- /dev/null +++ b/app/assets/javascripts/admin/dev_ops_report/devops_score_empty_state.js @@ -0,0 +1,27 @@ +import Vue from 'vue'; +import UserCallout from '~/user_callout'; +import UsagePingDisabled from './components/usage_ping_disabled.vue'; + +export default () => { + // eslint-disable-next-line no-new + new UserCallout(); + + const emptyStateContainer = document.getElementById('js-devops-empty-state'); + + if (!emptyStateContainer) return false; + + const { emptyStateSvgPath, enableUsagePingLink, docsLink, isAdmin } = emptyStateContainer.dataset; + + return new Vue({ + el: emptyStateContainer, + provide: { + isAdmin: Boolean(isAdmin), + svgPath: emptyStateSvgPath, + primaryButtonPath: enableUsagePingLink, + docsLink, + }, + render(h) { + return h(UsagePingDisabled); + }, + }); +}; diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index e1f9d858f2b..6f9b05c08ab 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -69,9 +69,12 @@ export default { { key: 'incidentSla', label: s__('IncidentManagement|Time to SLA'), - thClass: `gl-pointer-events-none gl-text-right gl-w-eighth`, + thClass: `gl-text-right gl-w-eighth`, tdClass: `${tdClass} gl-text-right`, thAttr: TH_INCIDENT_SLA_TEST_ID, + sortKey: 'SLA_DUE_AT', + sortable: true, + sortDirection: 'asc', }, { key: 'assignees', @@ -253,13 +256,22 @@ export default { this.redirecting = true; }, fetchSortedData({ sortBy, sortDesc }) { + let sortKey; + // In bootstrap-vue v2.17.0, sortKey becomes natively supported and we can eliminate this function + const field = this.availableFields.find(({ key }) => key === sortBy); const sortingDirection = sortDesc ? 'DESC' : 'ASC'; - const sortingColumn = convertToSnakeCase(sortBy) - .replace(/_.*/, '') - .toUpperCase(); + + // Use `sortKey` if provided, otherwise fall back to existing algorithm + if (field?.sortKey) { + sortKey = field.sortKey; + } else { + sortKey = convertToSnakeCase(sortBy) + .replace(/_.*/, '') + .toUpperCase(); + } this.pagination = initialPaginationState; - this.sort = `${sortingColumn}_${sortingDirection}`; + this.sort = `${sortKey}_${sortingDirection}`; }, getSeverity(severity) { return INCIDENT_SEVERITY[severity]; diff --git a/app/assets/javascripts/pages/admin/dev_ops_report/index.js b/app/assets/javascripts/pages/admin/dev_ops_report/index.js index 643497003ba..325b74a414d 100644 --- a/app/assets/javascripts/pages/admin/dev_ops_report/index.js +++ b/app/assets/javascripts/pages/admin/dev_ops_report/index.js @@ -1,27 +1,5 @@ -import Vue from 'vue'; -import UserCallout from '~/user_callout'; -import UsagePingDisabled from '~/admin/dev_ops_report/components/usage_ping_disabled.vue'; +import initDevOpsScoreEmptyState from '~/admin/dev_ops_report/devops_score_empty_state'; +import initDevopAdoption from '~/admin/dev_ops_report/devops_adoption'; -document.addEventListener('DOMContentLoaded', () => { - // eslint-disable-next-line no-new - new UserCallout(); - - const emptyStateContainer = document.getElementById('js-devops-empty-state'); - - if (!emptyStateContainer) return false; - - const { emptyStateSvgPath, enableUsagePingLink, docsLink, isAdmin } = emptyStateContainer.dataset; - - return new Vue({ - el: emptyStateContainer, - provide: { - isAdmin: Boolean(isAdmin), - svgPath: emptyStateSvgPath, - primaryButtonPath: enableUsagePingLink, - docsLink, - }, - render(h) { - return h(UsagePingDisabled); - }, - }); -}); +initDevOpsScoreEmptyState(); +initDevopAdoption(); diff --git a/app/assets/stylesheets/framework/editor-lite.scss b/app/assets/stylesheets/framework/editor-lite.scss index 20fea7a82ca..c3b287a6c3d 100644 --- a/app/assets/stylesheets/framework/editor-lite.scss +++ b/app/assets/stylesheets/framework/editor-lite.scss @@ -1,3 +1,21 @@ +[data-editor-loading] { + @include gl-relative; + @include gl-display-flex; + @include gl-justify-content-center; + @include gl-align-items-center; + + &::before { + content: ''; + @include spinner(32px, 3px); + @include gl-absolute; + @include gl-z-index-1; + } + + pre { + opacity: 0; + } +} + [id^='editor-lite-'] { height: 500px; } diff --git a/app/assets/stylesheets/framework/spinner.scss b/app/assets/stylesheets/framework/spinner.scss index 581b7c37b5f..2aa0ab6c1eb 100644 --- a/app/assets/stylesheets/framework/spinner.scss +++ b/app/assets/stylesheets/framework/spinner.scss @@ -20,7 +20,7 @@ } } -.spinner { +@mixin spinner($size: 16px, $border-width: 2px, $color: $orange-400) { border-radius: 50%; position: relative; margin: 0 auto; @@ -30,8 +30,12 @@ animation-iteration-count: infinite; border-style: solid; display: inline-flex; - @include spinner-size(16px, 2px); - @include spinner-color($orange-400); + @include spinner-size($size, $border-width); + @include spinner-color($color); +} + +.spinner { + @include spinner; &.spinner-md { @include spinner-size(32px, 3px); diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 06a52457fd6..c9ad48fb1fc 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -174,6 +174,10 @@ module GroupsHelper !multiple_members?(group) end + def show_thanks_for_purchase_banner? + params.key?(:purchased_quantity) && params[:purchased_quantity].to_i > 0 + end + private def just_created? diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb index 856f86201ec..a8325e98095 100644 --- a/app/models/broadcast_message.rb +++ b/app/models/broadcast_message.rb @@ -103,6 +103,7 @@ class BroadcastMessage < ApplicationRecord end def matches_current_path(current_path) + return false if current_path.blank? && target_path.present? return true if current_path.blank? || target_path.blank? escaped = Regexp.escape(target_path).gsub('\\*', '.*') diff --git a/app/models/concerns/issue_available_features.rb b/app/models/concerns/issue_available_features.rb index 6efb8103b7b..886db133a94 100644 --- a/app/models/concerns/issue_available_features.rb +++ b/app/models/concerns/issue_available_features.rb @@ -6,18 +6,25 @@ module IssueAvailableFeatures extend ActiveSupport::Concern - # EE only features are listed on EE::IssueAvailableFeatures - def available_features_for_issue_types - {}.with_indifferent_access + class_methods do + # EE only features are listed on EE::IssueAvailableFeatures + def available_features_for_issue_types + {}.with_indifferent_access + end + end + + included do + scope :with_feature, ->(feature) { where(issue_type: available_features_for_issue_types[feature]) } end def issue_type_supports?(feature) - unless available_features_for_issue_types.has_key?(feature) + unless self.class.available_features_for_issue_types.has_key?(feature) raise ArgumentError, 'invalid feature' end - available_features_for_issue_types[feature].include?(issue_type) + self.class.available_features_for_issue_types[feature].include?(issue_type) end end IssueAvailableFeatures.prepend_if_ee('EE::IssueAvailableFeatures') +IssueAvailableFeatures::ClassMethods.prepend_if_ee('EE::IssueAvailableFeatures::ClassMethods') diff --git a/app/models/concerns/triggerable_hooks.rb b/app/models/concerns/triggerable_hooks.rb index b64a9e4f70b..e01da3b3f36 100644 --- a/app/models/concerns/triggerable_hooks.rb +++ b/app/models/concerns/triggerable_hooks.rb @@ -13,7 +13,8 @@ module TriggerableHooks job_hooks: :job_events, pipeline_hooks: :pipeline_events, wiki_page_hooks: :wiki_page_events, - deployment_hooks: :deployment_events + deployment_hooks: :deployment_events, + feature_flag_hooks: :feature_flag_events }.freeze extend ActiveSupport::Concern diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index 2d1bdecc770..fa3578cda18 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -18,7 +18,8 @@ class ProjectHook < WebHook :job_hooks, :pipeline_hooks, :wiki_page_hooks, - :deployment_hooks + :deployment_hooks, + :feature_flag_hooks ] belongs_to :project diff --git a/app/models/operations/feature_flag.rb b/app/models/operations/feature_flag.rb index 104338b80d1..c9e52fe51f2 100644 --- a/app/models/operations/feature_flag.rb +++ b/app/models/operations/feature_flag.rb @@ -2,6 +2,7 @@ module Operations class FeatureFlag < ApplicationRecord + include AfterCommitQueue include AtomicInternalId include IidRoutes include Limitable @@ -77,6 +78,22 @@ module Operations Ability.issues_readable_by_user(issues, current_user) end + def execute_hooks(current_user) + run_after_commit do + feature_flag_data = Gitlab::DataBuilder::FeatureFlag.build(self, current_user) + project.execute_hooks(feature_flag_data, :feature_flag_hooks) + end + end + + def hook_attrs + { + id: id, + name: name, + description: description, + active: active + } + end + private def version_associations diff --git a/app/services/feature_flags/update_service.rb b/app/services/feature_flags/update_service.rb index c837e50b104..ed5e2e794b4 100644 --- a/app/services/feature_flags/update_service.rb +++ b/app/services/feature_flags/update_service.rb @@ -22,6 +22,10 @@ module FeatureFlags audit_event = audit_event(feature_flag) + if feature_flag.active_changed? + feature_flag.execute_hooks(current_user) + end + if feature_flag.save save_audit_event(audit_event) diff --git a/app/views/admin/dev_ops_report/show.html.haml b/app/views/admin/dev_ops_report/show.html.haml index c8209bf5099..4ffae7897a3 100644 --- a/app/views/admin/dev_ops_report/show.html.haml +++ b/app/views/admin/dev_ops_report/show.html.haml @@ -14,7 +14,7 @@ .tab-pane.active#devops_score_pane = render 'report' .tab-pane#devops_adoption_pane - .js-devops-adoption + .js-devops-adoption{ data: { empty_state_svg_path: image_path('illustrations/monitoring/getting_started.svg') } } - else = render 'report' diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index a1210bf2df4..9d5ec5008dc 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -1,6 +1,9 @@ - breadcrumb_title _("Details") - @content_class = "limit-container-width" unless fluid_layout +- if show_thanks_for_purchase_banner? + = render_if_exists 'shared/thanks_for_purchase_banner', plan_title: plan_title, quantity: params[:purchased_quantity].to_i + - if show_invite_banner?(@group) = content_for :group_invite_members_banner do .container-fluid.container-limited{ class: "gl-pb-2! gl-pt-6! #{@content_class}" } diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index 9c60201412c..e9ce443782a 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -78,6 +78,12 @@ %strong= s_('Webhooks|Deployment events') %p.text-muted.ml-1 = s_('Webhooks|This URL is triggered when a deployment starts, finishes, fails, or is canceled') + %li + = form.check_box :feature_flag_events, class: 'form-check-input' + = form.label :feature_flag_events, class: 'list-label form-check-label ml-1' do + %strong= s_('Webhooks|Feature Flag events') + %p.text-muted.ml-1 + = s_('Webhooks|This URL is triggered when a feature flag is turned on or off') .form-group = form.label :enable_ssl_verification, s_('Webhooks|SSL verification'), class: 'label-bold checkbox' .form-check diff --git a/changelogs/unreleased/272986-fj-disallow-webide-route-in-robots.yml b/changelogs/unreleased/272986-fj-disallow-webide-route-in-robots.yml new file mode 100644 index 00000000000..29f2cfb1111 --- /dev/null +++ b/changelogs/unreleased/272986-fj-disallow-webide-route-in-robots.yml @@ -0,0 +1,5 @@ +--- +title: Disallow WebIDE route in robots.txt +merge_request: 46117 +author: +type: changed diff --git a/changelogs/unreleased/rmay-216344.yml b/changelogs/unreleased/rmay-216344.yml new file mode 100644 index 00000000000..d69abcf35e2 --- /dev/null +++ b/changelogs/unreleased/rmay-216344.yml @@ -0,0 +1,5 @@ +--- +title: Don't return target-specific broadcasts without a current path supplied +merge_request: 46322 +author: +type: fixed diff --git a/changelogs/unreleased/sk-220898-feature-flag-webhook.yml b/changelogs/unreleased/sk-220898-feature-flag-webhook.yml new file mode 100644 index 00000000000..0146fd60e2b --- /dev/null +++ b/changelogs/unreleased/sk-220898-feature-flag-webhook.yml @@ -0,0 +1,5 @@ +--- +title: Add webhooks for feature flag +merge_request: 41863 +author: Sashi +type: added diff --git a/db/migrate/20200908212414_add_feature_flag_events_to_web_hooks.rb b/db/migrate/20200908212414_add_feature_flag_events_to_web_hooks.rb new file mode 100644 index 00000000000..40e2b37b390 --- /dev/null +++ b/db/migrate/20200908212414_add_feature_flag_events_to_web_hooks.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddFeatureFlagEventsToWebHooks < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def change + add_column :web_hooks, :feature_flag_events, :boolean, null: false, default: false + end +end diff --git a/db/migrate/20201012073022_remove_not_null_constraint_on_framework.rb b/db/migrate/20201012073022_remove_not_null_constraint_on_framework.rb new file mode 100644 index 00000000000..b8cc8984575 --- /dev/null +++ b/db/migrate/20201012073022_remove_not_null_constraint_on_framework.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class RemoveNotNullConstraintOnFramework < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + GDPR_FRAMEWORK_ID = 1 + + disable_ddl_transaction! + + class TmpComplianceProjectFrameworkSetting < ActiveRecord::Base + self.table_name = 'project_compliance_framework_settings' + self.primary_key = :project_id + + include EachBatch + end + + def up + change_column_null :project_compliance_framework_settings, :framework, true + end + + def down + # Custom frameworks cannot be rolled back easily since we don't have enum for them. + # To make the database consistent, we mark them as GDPR framework. + # Note: framework customization will be implemented in the next 1-3 releases so data + # corruption due to the rollback is unlikely. + TmpComplianceProjectFrameworkSetting.each_batch(of: 100) do |query| + query.where(framework: nil).update_all(framework: GDPR_FRAMEWORK_ID) + end + + change_column_null :project_compliance_framework_settings, :framework, false + end +end diff --git a/db/schema_migrations/20200908212414 b/db/schema_migrations/20200908212414 new file mode 100644 index 00000000000..208f9affc91 --- /dev/null +++ b/db/schema_migrations/20200908212414 @@ -0,0 +1 @@ +a9605126178d887bbf526a4a33b7060b072eff7a8d6712e3552099f7e615f88b \ No newline at end of file diff --git a/db/schema_migrations/20201012073022 b/db/schema_migrations/20201012073022 new file mode 100644 index 00000000000..b7ce136a7e3 --- /dev/null +++ b/db/schema_migrations/20201012073022 @@ -0,0 +1 @@ +234711b96d3869fe826dfd71ae29e0f75e50302bc29a4e60f436ec76b4be3efb \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index c117864ff81..7c4f5088069 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -14837,7 +14837,7 @@ ALTER SEQUENCE project_ci_cd_settings_id_seq OWNED BY project_ci_cd_settings.id; CREATE TABLE project_compliance_framework_settings ( project_id bigint NOT NULL, - framework smallint NOT NULL, + framework smallint, framework_id bigint, CONSTRAINT check_d348de9e2d CHECK ((framework_id IS NOT NULL)) ); @@ -17295,7 +17295,8 @@ CREATE TABLE web_hooks ( encrypted_token_iv character varying, encrypted_url character varying, encrypted_url_iv character varying, - deployment_events boolean DEFAULT false NOT NULL + deployment_events boolean DEFAULT false NOT NULL, + feature_flag_events boolean DEFAULT false NOT NULL ); CREATE SEQUENCE web_hooks_id_seq diff --git a/doc/api/README.md b/doc/api/README.md index c15687ce6ed..e077424da13 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # API Docs Automate GitLab via a simple and powerful API. diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index f5ed40ad802..83fd2e31e20 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -2803,7 +2803,7 @@ type ComplianceFramework { """ Name of the compliance framework """ - name: ProjectSettingEnum! + name: String! } """ @@ -6908,6 +6908,11 @@ type EpicIssue implements CurrentUserTodos & Noteable { """ blocked: Boolean! + """ + Count of issues blocking this issue + """ + blockedByCount: Int + """ Timestamp of when the issue was closed """ @@ -9176,6 +9181,11 @@ type Issue implements CurrentUserTodos & Noteable { """ blocked: Boolean! + """ + Count of issues blocking this issue + """ + blockedByCount: Int + """ Timestamp of when the issue was closed """ @@ -10206,6 +10216,16 @@ enum IssueSort { """ SEVERITY_DESC + """ + Issues with earliest SLA due time shown first + """ + SLA_DUE_AT_ASC + + """ + Issues with latest SLA due time shown first + """ + SLA_DUE_AT_DESC + """ Updated at ascending order """ @@ -15701,17 +15721,6 @@ type ProjectPermissions { uploadFile: Boolean! } -""" -Names of compliance frameworks that can be assigned to a Project -""" -enum ProjectSettingEnum { - gdpr - hipaa - pci_dss - soc_2 - sox -} - type ProjectStatistics { """ Build artifacts size of the project diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 58568bd7ea6..93c5968e64e 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -7545,8 +7545,8 @@ "kind": "NON_NULL", "name": null, "ofType": { - "kind": "ENUM", - "name": "ProjectSettingEnum", + "kind": "SCALAR", + "name": "String", "ofType": null } }, @@ -19041,6 +19041,20 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "blockedByCount", + "description": "Count of issues blocking this issue", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "closedAt", "description": "Timestamp of when the issue was closed", @@ -24983,6 +24997,20 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "blockedByCount", + "description": "Count of issues blocking this issue", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "closedAt", "description": "Timestamp of when the issue was closed", @@ -27868,6 +27896,18 @@ "description": "Published issues shown first", "isDeprecated": false, "deprecationReason": null + }, + { + "name": "SLA_DUE_AT_ASC", + "description": "Issues with earliest SLA due time shown first", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SLA_DUE_AT_DESC", + "description": "Issues with latest SLA due time shown first", + "isDeprecated": false, + "deprecationReason": null } ], "possibleTypes": null @@ -45522,47 +45562,6 @@ "enumValues": null, "possibleTypes": null }, - { - "kind": "ENUM", - "name": "ProjectSettingEnum", - "description": "Names of compliance frameworks that can be assigned to a Project", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "gdpr", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "hipaa", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pci_dss", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "soc_2", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "sox", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, { "kind": "OBJECT", "name": "ProjectStatistics", diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index d73972a4622..099779ec3b3 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -436,7 +436,7 @@ Represents a ComplianceFramework associated with a Project. | Field | Type | Description | | ----- | ---- | ----------- | -| `name` | ProjectSettingEnum! | Name of the compliance framework | +| `name` | String! | Name of the compliance framework | ### ConfigureSastPayload @@ -1115,6 +1115,7 @@ Relationship between an epic and an issue. | `alertManagementAlert` | AlertManagementAlert | Alert associated to this issue | | `author` | User! | User that created the issue | | `blocked` | Boolean! | Indicates the issue is blocked | +| `blockedByCount` | Int | Count of issues blocking this issue | | `closedAt` | Time | Timestamp of when the issue was closed | | `confidential` | Boolean! | Indicates the issue is confidential | | `createdAt` | Time! | Timestamp of when the issue was created | @@ -1311,6 +1312,7 @@ Represents a recorded measurement (object count) for the Admins. | `alertManagementAlert` | AlertManagementAlert | Alert associated to this issue | | `author` | User! | User that created the issue | | `blocked` | Boolean! | Indicates the issue is blocked | +| `blockedByCount` | Int | Count of issues blocking this issue | | `closedAt` | Time | Timestamp of when the issue was closed | | `confidential` | Boolean! | Indicates the issue is confidential | | `createdAt` | Time! | Timestamp of when the issue was created | @@ -3487,6 +3489,8 @@ Values for sorting issues. | `RELATIVE_POSITION_ASC` | Relative position by ascending order | | `SEVERITY_ASC` | Severity from less critical to more critical | | `SEVERITY_DESC` | Severity from more critical to less critical | +| `SLA_DUE_AT_ASC` | Issues with earliest SLA due time shown first | +| `SLA_DUE_AT_DESC` | Issues with latest SLA due time shown first | | `UPDATED_ASC` | Updated at ascending order | | `UPDATED_DESC` | Updated at descending order | | `WEIGHT_ASC` | Weight by ascending order | @@ -3677,18 +3681,6 @@ Values for sorting projects. | `SUCCESS` | | | `WAITING_FOR_RESOURCE` | | -### ProjectSettingEnum - -Names of compliance frameworks that can be assigned to a Project. - -| Value | Description | -| ----- | ----------- | -| `gdpr` | | -| `hipaa` | | -| `pci_dss` | | -| `soc_2` | | -| `sox` | | - ### RegistryState State of a Geo registry. diff --git a/doc/api/group_level_variables.md b/doc/api/group_level_variables.md index aa5f0b3db72..6997ebdede4 100644 --- a/doc/api/group_level_variables.md +++ b/doc/api/group_level_variables.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Group-level Variables API > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/34519) in GitLab 9.5 diff --git a/doc/api/groups.md b/doc/api/groups.md index 53c92cf85ec..be2d9993ca0 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Groups API ## List groups diff --git a/doc/api/import.md b/doc/api/import.md index 691e042084e..27f5915b206 100644 --- a/doc/api/import.md +++ b/doc/api/import.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Import API ## Import repository from GitHub diff --git a/doc/api/instance_clusters.md b/doc/api/instance_clusters.md index 1108550eee7..45bfc11c03b 100644 --- a/doc/api/instance_clusters.md +++ b/doc/api/instance_clusters.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Instance clusters API > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36001) in GitLab 13.2. diff --git a/doc/api/issue_links.md b/doc/api/issue_links.md index 757910d0946..41e2dd7c147 100644 --- a/doc/api/issue_links.md +++ b/doc/api/issue_links.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Issue links API **(CORE)** > The simple "relates to" relationship [moved](https://gitlab.com/gitlab-org/gitlab/-/issues/212329) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.4. diff --git a/doc/api/issues_statistics.md b/doc/api/issues_statistics.md index 8e2dcc07af8..ed95cbae3a9 100644 --- a/doc/api/issues_statistics.md +++ b/doc/api/issues_statistics.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Issues Statistics API Every API call to issues_statistics must be authenticated. diff --git a/doc/api/job_artifacts.md b/doc/api/job_artifacts.md index f5510f6ee91..54085e6f508 100644 --- a/doc/api/job_artifacts.md +++ b/doc/api/job_artifacts.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Job Artifacts API ## Get job artifacts diff --git a/doc/api/license.md b/doc/api/license.md index dcdf019059b..8c92a46a975 100644 --- a/doc/api/license.md +++ b/doc/api/license.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # License **(CORE ONLY)** To interact with license endpoints, you need to authenticate yourself as an diff --git a/doc/api/managed_licenses.md b/doc/api/managed_licenses.md index 984cfa92d3a..f7f6fbfbc47 100644 --- a/doc/api/managed_licenses.md +++ b/doc/api/managed_licenses.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Managed Licenses API **(ULTIMATE)** ## List managed licenses diff --git a/doc/api/members.md b/doc/api/members.md index 4440b70c512..d616dfdd85c 100644 --- a/doc/api/members.md +++ b/doc/api/members.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Group and project members API ## Valid access levels diff --git a/doc/api/namespaces.md b/doc/api/namespaces.md index 0792c6d4a3b..f61400dfddb 100644 --- a/doc/api/namespaces.md +++ b/doc/api/namespaces.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Namespaces API Usernames and groupnames fall under a special category called namespaces. diff --git a/doc/api/notes.md b/doc/api/notes.md index aaff28757bb..4416ce11db2 100644 --- a/doc/api/notes.md +++ b/doc/api/notes.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Notes API Notes are comments on: diff --git a/doc/api/notification_settings.md b/doc/api/notification_settings.md index 8442e371a56..cbe5aa46a5d 100644 --- a/doc/api/notification_settings.md +++ b/doc/api/notification_settings.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Notification settings API > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/5632) in GitLab 8.12. diff --git a/doc/api/personal_access_tokens.md b/doc/api/personal_access_tokens.md index 43310570fe8..76c9338e4a0 100644 --- a/doc/api/personal_access_tokens.md +++ b/doc/api/personal_access_tokens.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Personal access tokens API **(ULTIMATE)** You can read more about [personal access tokens](../user/profile/personal_access_tokens.md#personal-access-tokens). diff --git a/doc/api/resource_label_events.md b/doc/api/resource_label_events.md index 275614a1449..b088c06b342 100644 --- a/doc/api/resource_label_events.md +++ b/doc/api/resource_label_events.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Resource label events API Resource label events keep track about who, when, and which label was added to, or removed from, an issuable. diff --git a/doc/api/scim.md b/doc/api/scim.md index 350f992779e..0c4ca5e898f 100644 --- a/doc/api/scim.md +++ b/doc/api/scim.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # SCIM API **(SILVER ONLY)** > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/9388) in [GitLab Silver](https://about.gitlab.com/pricing/) 11.10. diff --git a/doc/api/services.md b/doc/api/services.md index 7c01e43a4d8..02814146417 100644 --- a/doc/api/services.md +++ b/doc/api/services.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Services API NOTE: **Note:** diff --git a/doc/api/settings.md b/doc/api/settings.md index 236cd10a30e..3885d236a72 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Application settings API **(CORE ONLY)** These API calls allow you to read and modify GitLab instance diff --git a/doc/api/sidekiq_metrics.md b/doc/api/sidekiq_metrics.md index caa02412a28..914f5fbf42a 100644 --- a/doc/api/sidekiq_metrics.md +++ b/doc/api/sidekiq_metrics.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Sidekiq Metrics API **(CORE ONLY)** > Introduced in GitLab 8.9. diff --git a/doc/api/statistics.md b/doc/api/statistics.md index 890c6f68898..6a41a960eba 100644 --- a/doc/api/statistics.md +++ b/doc/api/statistics.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Application statistics API ## Get current application statistics diff --git a/doc/api/system_hooks.md b/doc/api/system_hooks.md index 3e0d2151428..00cd88c88dd 100644 --- a/doc/api/system_hooks.md +++ b/doc/api/system_hooks.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # System hooks API All methods require administrator authorization. diff --git a/doc/api/templates/dockerfiles.md b/doc/api/templates/dockerfiles.md index e579300a2fd..fd0edfce8e5 100644 --- a/doc/api/templates/dockerfiles.md +++ b/doc/api/templates/dockerfiles.md @@ -1,4 +1,7 @@ --- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers type: reference --- diff --git a/doc/api/templates/gitignores.md b/doc/api/templates/gitignores.md index 3acd666ad66..b957c582755 100644 --- a/doc/api/templates/gitignores.md +++ b/doc/api/templates/gitignores.md @@ -1,4 +1,7 @@ --- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers type: reference --- diff --git a/doc/api/templates/licenses.md b/doc/api/templates/licenses.md index 4eb3c0f6111..d1044b23306 100644 --- a/doc/api/templates/licenses.md +++ b/doc/api/templates/licenses.md @@ -1,4 +1,7 @@ --- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers type: reference --- diff --git a/doc/api/users.md b/doc/api/users.md index beaea689fb7..31e8bb67bd3 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Users API ## List users diff --git a/doc/api/v3_to_v4.md b/doc/api/v3_to_v4.md index c351c14e24c..2dd4376413b 100644 --- a/doc/api/v3_to_v4.md +++ b/doc/api/v3_to_v4.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # API V3 to API V4 In GitLab 9.0 and later, API V4 is the preferred version to be used. diff --git a/doc/api/version.md b/doc/api/version.md index 3c6feaae071..d1582cf63cd 100644 --- a/doc/api/version.md +++ b/doc/api/version.md @@ -1,3 +1,9 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + # Version API > Introduced in GitLab 8.13. diff --git a/doc/development/feature_flags/development.md b/doc/development/feature_flags/development.md index 6c672663bae..40b086d12ff 100644 --- a/doc/development/feature_flags/development.md +++ b/doc/development/feature_flags/development.md @@ -188,6 +188,30 @@ if Feature.disabled?(:my_feature_flag, project, type: :ops) end ``` +DANGER: **Warning:** +Don't use feature flags at application load time. For example, using the `Feature` class in +`config/initializers/*` or at the class level could cause an unexpected error. This error occurs +because a database that a feature flag adapter might depend on doesn't exist at load time +(especially for fresh installations). Checking for the database's existence at the caller isn't +recommended, as some adapters don't require a database at all (for example, the HTTP adapter). The +feature flag setup check must be abstracted in the `Feature` namespace. This approach also requires +application reload when the feature flag changes. You must therefore ask SREs to reload the +Web/API/Sidekiq fleet on production, which takes time to fully rollout/rollback the changes. For +these reasons, use environment variables (for example, `ENV['YOUR_FEATURE_NAME']`) or `gitlab.yml` +instead. + +Here's an example of a pattern that you should avoid: + +```ruby +class MyClass + if Feature.enabled?(:...) + new_process + else + legacy_process + end +end +``` + ### Frontend Use the `push_frontend_feature_flag` method for frontend code, which is diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md index 7adea5ebcd6..94ed45d053e 100644 --- a/doc/user/project/integrations/webhooks.md +++ b/doc/user/project/integrations/webhooks.md @@ -1358,6 +1358,55 @@ X-Gitlab-Event: Deployment Hook Note that `deployable_id` is the ID of the CI job. +### Feature Flag events + +Triggered when a feature flag is turned on or off. + +**Request Header**: + +```plaintext +X-Gitlab-Event: Feature Flag Hook +``` + +**Request Body**: + +```json +{ + "object_kind": "feature_flag", + "project": { + "id": 1, + "name":"Gitlab Test", + "description":"Aut reprehenderit ut est.", + "web_url":"http://example.com/gitlabhq/gitlab-test", + "avatar_url":null, + "git_ssh_url":"git@example.com:gitlabhq/gitlab-test.git", + "git_http_url":"http://example.com/gitlabhq/gitlab-test.git", + "namespace":"GitlabHQ", + "visibility_level":20, + "path_with_namespace":"gitlabhq/gitlab-test", + "default_branch":"master", + "ci_config_path": null, + "homepage":"http://example.com/gitlabhq/gitlab-test", + "url":"http://example.com/gitlabhq/gitlab-test.git", + "ssh_url":"git@example.com:gitlabhq/gitlab-test.git", + "http_url":"http://example.com/gitlabhq/gitlab-test.git" + }, + "user": { + "name": "Administrator", + "username": "root", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "email": "admin@example.com" + }, + "user_url": "http://example.com/root", + "object_attributes": { + "id": 6, + "name": "test-feature-flag", + "description": "test-feature-flag-description", + "active": true + } +} +``` + ## Image URL rewriting From GitLab 11.2, simple image references are rewritten to use an absolute URL diff --git a/lib/api/access_requests.rb b/lib/api/access_requests.rb index 7e3d70a210a..e6ce62a1c6e 100644 --- a/lib/api/access_requests.rb +++ b/lib/api/access_requests.rb @@ -8,6 +8,8 @@ module API helpers ::API::Helpers::MembersHelpers + feature_category :authentication_and_authorization + %w[group project].each do |source_type| params do requires :id, type: String, desc: "The #{source_type} ID" diff --git a/lib/api/admin/ci/variables.rb b/lib/api/admin/ci/variables.rb index 44c389d6f94..654d3a48162 100644 --- a/lib/api/admin/ci/variables.rb +++ b/lib/api/admin/ci/variables.rb @@ -8,6 +8,8 @@ module API before { authenticated_as_admin! } + feature_category :continuous_integration + namespace 'admin' do namespace 'ci' do namespace 'variables' do diff --git a/lib/api/admin/instance_clusters.rb b/lib/api/admin/instance_clusters.rb index ce1bdd65eff..679e231b283 100644 --- a/lib/api/admin/instance_clusters.rb +++ b/lib/api/admin/instance_clusters.rb @@ -5,6 +5,8 @@ module API class InstanceClusters < ::API::Base include PaginationParams + feature_category :kubernetes_management + before do authenticated_as_admin! end diff --git a/lib/api/admin/sidekiq.rb b/lib/api/admin/sidekiq.rb index c2e9de5fb4e..7e561783685 100644 --- a/lib/api/admin/sidekiq.rb +++ b/lib/api/admin/sidekiq.rb @@ -5,6 +5,8 @@ module API class Sidekiq < ::API::Base before { authenticated_as_admin! } + feature_category :not_owned + namespace 'admin' do namespace 'sidekiq' do namespace 'queues' do diff --git a/lib/api/appearance.rb b/lib/api/appearance.rb index 00b495bbc1e..fe498bf611b 100644 --- a/lib/api/appearance.rb +++ b/lib/api/appearance.rb @@ -4,6 +4,8 @@ module API class Appearance < ::API::Base before { authenticated_as_admin! } + feature_category :navigation + helpers do def current_appearance @current_appearance ||= (::Appearance.current || ::Appearance.new) diff --git a/lib/api/applications.rb b/lib/api/applications.rb index 2afe8763d9d..8b14e16b495 100644 --- a/lib/api/applications.rb +++ b/lib/api/applications.rb @@ -5,6 +5,8 @@ module API class Applications < ::API::Base before { authenticated_as_admin! } + feature_category :authentication_and_authorization + resource :applications do helpers do def validate_redirect_uri(value) diff --git a/lib/api/avatar.rb b/lib/api/avatar.rb index 5a9b9940fcf..a42d89ddf83 100644 --- a/lib/api/avatar.rb +++ b/lib/api/avatar.rb @@ -2,6 +2,8 @@ module API class Avatar < ::API::Base + feature_category :users + resource :avatar do desc 'Return avatar url for a user' do success Entities::Avatar diff --git a/lib/api/award_emoji.rb b/lib/api/award_emoji.rb index 6d40ae8f5ff..8ea4f32d3eb 100644 --- a/lib/api/award_emoji.rb +++ b/lib/api/award_emoji.rb @@ -6,9 +6,9 @@ module API before { authenticate! } AWARDABLES = [ - { type: 'issue', find_by: :iid }, - { type: 'merge_request', find_by: :iid }, - { type: 'snippet', find_by: :id } + { type: 'issue', find_by: :iid, feature_category: :issue_tracking }, + { type: 'merge_request', find_by: :iid, feature_category: :code_review }, + { type: 'snippet', find_by: :id, feature_category: :snippets } ].freeze params do @@ -34,7 +34,7 @@ module API params do use :pagination end - get endpoint do + get endpoint, feature_category: awardable_params[:feature_category] do if can_read_awardable? awards = awardable.award_emoji present paginate(awards), with: Entities::AwardEmoji @@ -50,7 +50,7 @@ module API params do requires :award_id, type: Integer, desc: 'The ID of the award' end - get "#{endpoint}/:award_id" do + get "#{endpoint}/:award_id", feature_category: awardable_params[:feature_category] do if can_read_awardable? present awardable.award_emoji.find(params[:award_id]), with: Entities::AwardEmoji else @@ -65,7 +65,7 @@ module API params do requires :name, type: String, desc: 'The name of a award_emoji (without colons)' end - post endpoint do + post endpoint, feature_category: awardable_params[:feature_category] do not_found!('Award Emoji') unless can_read_awardable? && can_award_awardable? service = AwardEmojis::AddService.new(awardable, params[:name], current_user).execute @@ -84,7 +84,7 @@ module API params do requires :award_id, type: Integer, desc: 'The ID of an award emoji' end - delete "#{endpoint}/:award_id" do + delete "#{endpoint}/:award_id", feature_category: awardable_params[:feature_category] do award = awardable.award_emoji.find(params[:award_id]) unauthorized! unless award.user == current_user || current_user.admin? diff --git a/lib/gitlab/data_builder/feature_flag.rb b/lib/gitlab/data_builder/feature_flag.rb new file mode 100644 index 00000000000..2f675ace7e1 --- /dev/null +++ b/lib/gitlab/data_builder/feature_flag.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Gitlab + module DataBuilder + module FeatureFlag + extend self + + def build(feature_flag, user) + { + object_kind: 'feature_flag', + project: feature_flag.project.hook_attrs, + user: user.hook_attrs, + user_url: Gitlab::UrlBuilder.build(user), + object_attributes: feature_flag.hook_attrs + } + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 87240e5a50f..c3925914ca0 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -9223,6 +9223,15 @@ msgstr "" msgid "DevOps Score" msgstr "" +msgid "DevopsAdoption|Add a segment to get started" +msgstr "" + +msgid "DevopsAdoption|Add new segment" +msgstr "" + +msgid "DevopsAdoption|DevOps adoption uses segments to track adoption across key features. Segments are a way to track multiple related projects and groups at once. For example, you could create a segment for the engineering department or a particular product team." +msgstr "" + msgid "Diff content limits" msgstr "" @@ -29704,6 +29713,9 @@ msgstr "" msgid "Webhooks|Enable SSL verification" msgstr "" +msgid "Webhooks|Feature Flag events" +msgstr "" + msgid "Webhooks|Issues events" msgstr "" @@ -29731,6 +29743,9 @@ msgstr "" msgid "Webhooks|This URL is triggered when a deployment starts, finishes, fails, or is canceled" msgstr "" +msgid "Webhooks|This URL is triggered when a feature flag is turned on or off" +msgstr "" + msgid "Webhooks|This URL will be triggered by a push to the repository" msgstr "" diff --git a/public/robots.txt b/public/robots.txt index d4183c5cafb..9b943e6a1cb 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -23,6 +23,7 @@ Disallow: /users Disallow: /help Disallow: /s/ Disallow: /-/profile +Disallow: /-/ide/ # Only specifically allow the Sign In page to avoid very ugly search results Allow: /users/sign_in diff --git a/scripts/generate-test-mapping b/scripts/generate-test-mapping new file mode 100755 index 00000000000..eabe6a5b513 --- /dev/null +++ b/scripts/generate-test-mapping @@ -0,0 +1,19 @@ +#!/usr/bin/env ruby + +require 'json' +require_relative '../tooling/lib/tooling/test_map_generator' + +test_mapping_json = ARGV.shift +crystalball_yamls = ARGV + +unless test_mapping_json && !crystalball_yamls.empty? + puts "usage: #{__FILE__} [crystalball_yamls...]" + exit 1 +end + +map_generator = Tooling::TestMapGenerator.new +map_generator.parse(crystalball_yamls) +mapping = map_generator.mapping + +File.write(test_mapping_json, JSON.pretty_generate(mapping)) +puts "Saved #{test_mapping_json}." diff --git a/scripts/pack-test-mapping b/scripts/pack-test-mapping new file mode 100755 index 00000000000..58ace3eca67 --- /dev/null +++ b/scripts/pack-test-mapping @@ -0,0 +1,19 @@ +#!/usr/bin/env ruby + +require 'json' +require_relative '../tooling/lib/tooling/test_map_packer' + +unpacked_json_mapping, packed_json_mapping = ARGV.shift(2) +unless packed_json_mapping && unpacked_json_mapping + puts "usage: #{__FILE__} " + exit 1 +end + +puts "Compressing #{unpacked_json_mapping}" + +mapping = JSON.parse(File.read(unpacked_json_mapping)) +packed_mapping = Tooling::TestMapPacker.new.pack(mapping) + +puts "Writing packed #{packed_json_mapping}" +File.write(packed_json_mapping, JSON.generate(packed_mapping)) +puts "Saved #{packed_json_mapping}." diff --git a/scripts/rspec_helpers.sh b/scripts/rspec_helpers.sh index 3812a8b8ef7..9fe7d089d93 100644 --- a/scripts/rspec_helpers.sh +++ b/scripts/rspec_helpers.sh @@ -48,6 +48,43 @@ function update_tests_metadata() { fi } +function retrieve_tests_mapping() { + mkdir -p crystalball/ + + if [[ ! -f "${RSPEC_PACKED_TESTS_MAPPING_PATH}" ]]; then + (wget -O "${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz" "http://${TESTS_METADATA_S3_BUCKET}.s3.amazonaws.com/${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz" && gzip -d "${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz") || echo "{}" > "${RSPEC_PACKED_TESTS_MAPPING_PATH}" + fi + + scripts/unpack-test-mapping "${RSPEC_PACKED_TESTS_MAPPING_PATH}" "${RSPEC_TESTS_MAPPING_PATH}" +} + +function update_tests_mapping() { + if ! crystalball_rspec_data_exists; then + echo "No crystalball rspec data found." + return 0 + fi + + scripts/generate-test-mapping "${RSPEC_TESTS_MAPPING_PATH}" crystalball/rspec*.yml + + scripts/pack-test-mapping "${RSPEC_TESTS_MAPPING_PATH}" "${RSPEC_PACKED_TESTS_MAPPING_PATH}" + + gzip "${RSPEC_PACKED_TESTS_MAPPING_PATH}" + + if [[ -n "${TESTS_METADATA_S3_BUCKET}" ]]; then + if [[ "$CI_PIPELINE_SOURCE" == "schedule" ]]; then + scripts/sync-reports put "${TESTS_METADATA_S3_BUCKET}" "${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz" + else + echo "Not uploading report to S3 as the pipeline is not a scheduled one." + fi + fi + + rm -f crystalball/rspec*.yml +} + +function crystalball_rspec_data_exists() { + compgen -G "crystalball/rspec*.yml" > /dev/null; +} + function rspec_simple_job() { local rspec_opts="${1}" diff --git a/scripts/unpack-test-mapping b/scripts/unpack-test-mapping new file mode 100755 index 00000000000..c0f706c3f9f --- /dev/null +++ b/scripts/unpack-test-mapping @@ -0,0 +1,17 @@ +#!/usr/bin/env ruby + +require 'json' +require_relative '../tooling/lib/tooling/test_map_packer' + +packed_json_mapping, unpacked_json_mapping = ARGV.shift(2) +unless packed_json_mapping && unpacked_json_mapping + puts "usage: #{__FILE__} " + exit 1 +end + +packed_mapping = JSON.parse(File.read(packed_json_mapping)) +mapping = Tooling::TestMapPacker.new.unpack(packed_mapping) + +puts "Writing unpacked #{unpacked_json_mapping}" +File.write(unpacked_json_mapping, JSON.generate(mapping)) +puts "Saved #{unpacked_json_mapping}." diff --git a/spec/crystalball_env.rb b/spec/crystalball_env.rb new file mode 100644 index 00000000000..56498f07f85 --- /dev/null +++ b/spec/crystalball_env.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module CrystalballEnv + EXCLUDED_PREFIXES = %w[vendor/ruby].freeze + + extend self + + def start! + return unless ENV['CRYSTALBALL'] && ENV['CI_PIPELINE_SOURCE'] == 'schedule' + + require 'crystalball' + require_relative '../tooling/lib/tooling/crystalball/coverage_lines_execution_detector' + require_relative '../tooling/lib/tooling/crystalball/coverage_lines_strategy' + + map_storage_path_base = ENV['CI_JOB_NAME'] || 'crystalball_data' + map_storage_path = "crystalball/#{map_storage_path_base.gsub(%r{[/ ]}, '_')}.yml" + + execution_detector = Tooling::Crystalball::CoverageLinesExecutionDetector.new(exclude_prefixes: EXCLUDED_PREFIXES) + + Crystalball::MapGenerator.start! do |config| + config.map_storage_path = map_storage_path + config.register Tooling::Crystalball::CoverageLinesStrategy.new(execution_detector) + end + end +end diff --git a/spec/factories/project_hooks.rb b/spec/factories/project_hooks.rb index 15b240acba4..88b5ff936fe 100644 --- a/spec/factories/project_hooks.rb +++ b/spec/factories/project_hooks.rb @@ -22,6 +22,7 @@ FactoryBot.define do pipeline_events { true } wiki_page_events { true } deployment_events { true } + feature_flag_events { true } end end end diff --git a/spec/frontend/admin/dev_ops_report/components/devops_adoption_app_spec.js b/spec/frontend/admin/dev_ops_report/components/devops_adoption_app_spec.js new file mode 100644 index 00000000000..978a358af43 --- /dev/null +++ b/spec/frontend/admin/dev_ops_report/components/devops_adoption_app_spec.js @@ -0,0 +1,21 @@ +import { shallowMount } from '@vue/test-utils'; +import DevopsAdoptionApp from '~/admin/dev_ops_report/components/devops_adoption_app.vue'; +import DevopsAdoptionEmptyState from '~/admin/dev_ops_report/components/devops_adoption_empty_state.vue'; + +describe('DevopsAdoptionApp', () => { + let wrapper; + + const createComponent = () => { + return shallowMount(DevopsAdoptionApp); + }; + + beforeEach(() => { + wrapper = createComponent(); + }); + + describe('default behaviour', () => { + it('displays the empty state', () => { + expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/admin/dev_ops_report/components/devops_adoption_empty_state_spec.js b/spec/frontend/admin/dev_ops_report/components/devops_adoption_empty_state_spec.js new file mode 100644 index 00000000000..91e99e6dffa --- /dev/null +++ b/spec/frontend/admin/dev_ops_report/components/devops_adoption_empty_state_spec.js @@ -0,0 +1,52 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlEmptyState, GlButton } from '@gitlab/ui'; +import DevopsAdoptionEmptyState from '~/admin/dev_ops_report/components/devops_adoption_empty_state.vue'; +import { DEVOPS_ADOPTION_STRINGS } from '~/admin/dev_ops_report/constants'; + +const emptyStateSvgPath = 'illustrations/monitoring/getting_started.svg'; + +describe('DevopsAdoptionEmptyState', () => { + let wrapper; + + const createComponent = (options = {}) => { + const { stubs = {} } = options; + return shallowMount(DevopsAdoptionEmptyState, { + provide: { + emptyStateSvgPath, + }, + stubs, + }); + }; + + const findEmptyState = () => wrapper.find(GlEmptyState); + const findEmptyStateAction = () => findEmptyState().find(GlButton); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('contains the correct svg', () => { + wrapper = createComponent(); + + expect(findEmptyState().props('svgPath')).toBe(emptyStateSvgPath); + }); + + it('contains the correct text', () => { + wrapper = createComponent(); + + const emptyState = findEmptyState(); + + expect(emptyState.props('title')).toBe(DEVOPS_ADOPTION_STRINGS.emptyState.title); + expect(emptyState.props('description')).toBe(DEVOPS_ADOPTION_STRINGS.emptyState.description); + }); + + it('contains an overridden action button', () => { + wrapper = createComponent({ stubs: { GlEmptyState } }); + + const actionButton = findEmptyStateAction(); + + expect(actionButton.exists()).toBe(true); + expect(actionButton.text()).toBe(DEVOPS_ADOPTION_STRINGS.emptyState.button); + }); +}); diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js index 709f66bb352..6329a84ff6e 100644 --- a/spec/frontend/incidents/components/incidents_list_spec.js +++ b/spec/frontend/incidents/components/incidents_list_spec.js @@ -10,6 +10,7 @@ import { TH_CREATED_AT_TEST_ID, TH_SEVERITY_TEST_ID, TH_PUBLISHED_TEST_ID, + TH_INCIDENT_SLA_TEST_ID, trackIncidentCreateNewOptions, trackIncidentListViewsOptions, } from '~/incidents/constants'; @@ -277,10 +278,11 @@ describe('Incidents List', () => { const noneSort = 'none'; it.each` - selector | initialSort | firstSort | nextSort - ${TH_CREATED_AT_TEST_ID} | ${descSort} | ${ascSort} | ${descSort} - ${TH_SEVERITY_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} - ${TH_PUBLISHED_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} + selector | initialSort | firstSort | nextSort + ${TH_CREATED_AT_TEST_ID} | ${descSort} | ${ascSort} | ${descSort} + ${TH_SEVERITY_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} + ${TH_PUBLISHED_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} + ${TH_INCIDENT_SLA_TEST_ID} | ${noneSort} | ${ascSort} | ${descSort} `('updates sort with new direction', async ({ selector, initialSort, firstSort, nextSort }) => { const [[attr, value]] = Object.entries(selector); const columnHeader = () => wrapper.find(`[${attr}="${value}"]`); diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb index 08b25d64b43..dde8f3b587f 100644 --- a/spec/helpers/groups_helper_spec.rb +++ b/spec/helpers/groups_helper_spec.rb @@ -370,6 +370,26 @@ RSpec.describe GroupsHelper do end end + describe '#show_thanks_for_purchase_banner?' do + subject { helper.show_thanks_for_purchase_banner? } + + it 'returns true with purchased_quantity present in params' do + allow(controller).to receive(:params) { { purchased_quantity: '1' } } + + is_expected.to be_truthy + end + + it 'returns false with purchased_quantity not present in params' do + is_expected.to be_falsey + end + + it 'returns false with purchased_quantity is empty in params' do + allow(controller).to receive(:params) { { purchased_quantity: '' } } + + is_expected.to be_falsey + end + end + describe '#show_invite_banner?' do let_it_be(:current_user) { create(:user) } let_it_be_with_refind(:group) { create(:group) } diff --git a/spec/lib/api/every_api_endpoint_spec.rb b/spec/lib/api/every_api_endpoint_spec.rb index a0657b5fbed..2fcb09578c4 100644 --- a/spec/lib/api/every_api_endpoint_spec.rb +++ b/spec/lib/api/every_api_endpoint_spec.rb @@ -17,8 +17,14 @@ RSpec.describe 'Every API endpoint' do let_it_be(:routes_without_category) do api_endpoints.map do |(klass, path)| next if klass.try(:feature_category_for_action, path) + # We'll add the rest in https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/463 - next unless klass == ::API::Users || klass == ::API::Issues + completed_classes = [ + ::API::Users, ::API::Issues, ::API::AccessRequests, ::API::Admin::Ci::Variables, + ::API::Admin::InstanceClusters, ::API::Admin::Sidekiq, ::API::Appearance, + ::API::Applications, ::API::Avatar, ::API::AwardEmoji + ] + next unless completed_classes.include?(klass) "#{klass}##{path}" end.compact.uniq diff --git a/spec/lib/gitlab/data_builder/feature_flag_spec.rb b/spec/lib/gitlab/data_builder/feature_flag_spec.rb new file mode 100644 index 00000000000..75511fcf9f5 --- /dev/null +++ b/spec/lib/gitlab/data_builder/feature_flag_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::DataBuilder::FeatureFlag do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:feature_flag) { create(:operations_feature_flag, project: project) } + + describe '.build' do + let(:data) { described_class.build(feature_flag, user) } + + it { expect(data).to be_a(Hash) } + it { expect(data[:object_kind]).to eq('feature_flag') } + + it 'contains the correct object attributes' do + object_attributes = data[:object_attributes] + + expect(object_attributes[:id]).to eq(feature_flag.id) + expect(object_attributes[:name]).to eq(feature_flag.name) + expect(object_attributes[:description]).to eq(feature_flag.description) + expect(object_attributes[:active]).to eq(feature_flag.active) + end + end +end diff --git a/spec/models/broadcast_message_spec.rb b/spec/models/broadcast_message_spec.rb index fc463c6af52..c4d17905637 100644 --- a/spec/models/broadcast_message_spec.rb +++ b/spec/models/broadcast_message_spec.rb @@ -161,6 +161,12 @@ RSpec.describe BroadcastMessage do expect(subject.call('/group/issues/test').length).to eq(1) end + + it "does not return message if the target path is set but no current path is provided" do + create(:broadcast_message, target_path: "*/issues/*", broadcast_type: broadcast_type) + + expect(subject.call.length).to eq(0) + end end describe '.current', :use_clean_rails_memory_store_caching do diff --git a/spec/models/operations/feature_flag_spec.rb b/spec/models/operations/feature_flag_spec.rb index b4e941f2856..93dd7d4f0bb 100644 --- a/spec/models/operations/feature_flag_spec.rb +++ b/spec/models/operations/feature_flag_spec.rb @@ -261,4 +261,38 @@ RSpec.describe Operations::FeatureFlag do expect(flags.map(&:id)).to eq([feature_flag.id, feature_flag_b.id]) end end + + describe '#hook_attrs' do + it 'includes expected attributes' do + hook_attrs = { + id: subject.id, + name: subject.name, + description: subject.description, + active: subject.active + } + expect(subject.hook_attrs).to eq(hook_attrs) + end + end + + describe "#execute_hooks" do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:feature_flag) { create(:operations_feature_flag, project: project) } + + it 'does not execute the hook when feature_flag event is disabled' do + create(:project_hook, project: project, feature_flag_events: false) + expect(WebHookWorker).not_to receive(:perform_async) + + feature_flag.execute_hooks(user) + feature_flag.touch + end + + it 'executes hook when feature_flag event is enabled' do + hook = create(:project_hook, project: project, feature_flag_events: true) + expect(WebHookWorker).to receive(:perform_async).with(hook.id, an_instance_of(Hash), 'feature_flag_hooks') + + feature_flag.execute_hooks(user) + feature_flag.touch + end + end end diff --git a/spec/requests/robots_txt_spec.rb b/spec/requests/robots_txt_spec.rb index 61a5dc68fdd..e3f279af3c8 100644 --- a/spec/requests/robots_txt_spec.rb +++ b/spec/requests/robots_txt_spec.rb @@ -37,6 +37,7 @@ RSpec.describe 'Robots.txt Requests', :aggregate_failures do '/help', '/s/', '/-/profile', + '/-/ide/project', '/foo/bar/new', '/foo/bar/edit', '/foo/bar/raw', diff --git a/spec/services/feature_flags/update_service_spec.rb b/spec/services/feature_flags/update_service_spec.rb index a982dd5166b..66a75a2c24e 100644 --- a/spec/services/feature_flags/update_service_spec.rb +++ b/spec/services/feature_flags/update_service_spec.rb @@ -100,6 +100,13 @@ RSpec.describe FeatureFlags::UpdateService do include('Updated active from "true" to "false".') ) end + + it 'executes hooks' do + hook = create(:project_hook, :all_events_enabled, project: project) + expect(WebHookWorker).to receive(:perform_async).with(hook.id, an_instance_of(Hash), 'feature_flag_hooks') + + subject + end end context 'when scope active state is changed' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 11a45e005b8..98ce765100b 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,6 +3,9 @@ require './spec/simplecov_env' SimpleCovEnv.start! +require './spec/crystalball_env' +CrystalballEnv.start! + ENV["RAILS_ENV"] = 'test' ENV["IN_MEMORY_APPLICATION_SETTINGS"] = 'true' ENV["RSPEC_ALLOW_INVALID_URLS"] = 'true' diff --git a/spec/tooling/lib/tooling/crystalball/coverage_lines_execution_detector_spec.rb b/spec/tooling/lib/tooling/crystalball/coverage_lines_execution_detector_spec.rb new file mode 100644 index 00000000000..6b7373cb3c7 --- /dev/null +++ b/spec/tooling/lib/tooling/crystalball/coverage_lines_execution_detector_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require_relative '../../../../../tooling/lib/tooling/crystalball/coverage_lines_execution_detector' + +RSpec.describe Tooling::Crystalball::CoverageLinesExecutionDetector do + subject(:detector) { described_class.new(root, exclude_prefixes: %w[vendor/ruby]) } + + let(:root) { '/tmp' } + let(:before_map) { { path => { lines: [0, 2, nil] } } } + let(:after_map) { { path => { lines: [0, 3, nil] } } } + let(:path) { '/tmp/file.rb' } + + describe '#detect' do + subject { detector.detect(before_map, after_map) } + + it { is_expected.to eq(%w[file.rb]) } + + context 'with no changes' do + let(:after_map) { { path => { lines: [0, 2, nil] } } } + + it { is_expected.to eq([]) } + end + + context 'with previously uncovered file' do + let(:before_map) { {} } + + it { is_expected.to eq(%w[file.rb]) } + end + + context 'with path outside of root' do + let(:path) { '/abc/file.rb' } + + it { is_expected.to eq([]) } + end + + context 'with path in excluded prefix' do + let(:path) { '/tmp/vendor/ruby/dependency.rb' } + + it { is_expected.to eq([]) } + end + end +end diff --git a/spec/tooling/lib/tooling/crystalball/coverage_lines_strategy_spec.rb b/spec/tooling/lib/tooling/crystalball/coverage_lines_strategy_spec.rb new file mode 100644 index 00000000000..fd8fc4114a1 --- /dev/null +++ b/spec/tooling/lib/tooling/crystalball/coverage_lines_strategy_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require_relative '../../../../../tooling/lib/tooling/crystalball/coverage_lines_strategy' + +RSpec.describe Tooling::Crystalball::CoverageLinesStrategy do + subject { described_class.new(execution_detector) } + + let(:execution_detector) { instance_double('Tooling::Crystalball::CoverageLinesExecutionDetector') } + + describe '#after_register' do + it 'starts coverage' do + expect(Coverage).to receive(:start).with(lines: true) + subject.after_register + end + end +end diff --git a/spec/tooling/lib/tooling/test_map_generator_spec.rb b/spec/tooling/lib/tooling/test_map_generator_spec.rb new file mode 100644 index 00000000000..7f3b2807162 --- /dev/null +++ b/spec/tooling/lib/tooling/test_map_generator_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require_relative '../../../../tooling/lib/tooling/test_map_generator' + +RSpec.describe Tooling::TestMapGenerator do + subject { described_class.new } + + describe '#parse' do + let(:yaml1) do + <<~YAML + --- + :type: Crystalball::ExecutionMap + :commit: a7d57d333042f3b0334b2f8a282354eef7365976 + :timestamp: 1602668405 + :version: + --- + "./spec/factories_spec.rb[1]": + - lib/gitlab/current_settings.rb + - lib/feature.rb + - lib/gitlab/marginalia.rb + YAML + end + + let(:yaml2) do + <<~YAML + --- + :type: Crystalball::ExecutionMap + :commit: 74056e8d9cf3773f43faa1cf5416f8779c8284c8 + :timestamp: 1602671965 + :version: + --- + "./spec/models/project_spec.rb[1]": + - lib/gitlab/current_settings.rb + - lib/feature.rb + - lib/gitlab/marginalia.rb + YAML + end + + let(:pathname) { instance_double(Pathname) } + + before do + allow(File).to receive(:read).with('yaml1.yml').and_return(yaml1) + allow(File).to receive(:read).with('yaml2.yml').and_return(yaml2) + end + + context 'with single yaml' do + let(:expected_mapping) do + { + 'lib/gitlab/current_settings.rb' => [ + './spec/factories_spec.rb' + ], + 'lib/feature.rb' => [ + './spec/factories_spec.rb' + ], + 'lib/gitlab/marginalia.rb' => [ + './spec/factories_spec.rb' + ] + } + end + + it 'parses crystalball data into test mapping' do + subject.parse('yaml1.yml') + + expect(subject.mapping.keys).to match_array(expected_mapping.keys) + end + + it 'stores test files without example uid' do + subject.parse('yaml1.yml') + + expected_mapping.each do |file, tests| + expect(subject.mapping[file]).to match_array(tests) + end + end + end + + context 'with multiple yamls' do + let(:expected_mapping) do + { + 'lib/gitlab/current_settings.rb' => [ + './spec/factories_spec.rb', + './spec/models/project_spec.rb' + ], + 'lib/feature.rb' => [ + './spec/factories_spec.rb', + './spec/models/project_spec.rb' + ], + 'lib/gitlab/marginalia.rb' => [ + './spec/factories_spec.rb', + './spec/models/project_spec.rb' + ] + } + end + + it 'parses crystalball data into test mapping' do + subject.parse(%w[yaml1.yml yaml2.yml]) + + expect(subject.mapping.keys).to match_array(expected_mapping.keys) + end + + it 'stores test files without example uid' do + subject.parse(%w[yaml1.yml yaml2.yml]) + + expected_mapping.each do |file, tests| + expect(subject.mapping[file]).to match_array(tests) + end + end + end + end +end diff --git a/spec/tooling/lib/tooling/test_map_packer_spec.rb b/spec/tooling/lib/tooling/test_map_packer_spec.rb new file mode 100644 index 00000000000..233134d2524 --- /dev/null +++ b/spec/tooling/lib/tooling/test_map_packer_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require_relative '../../../../tooling/lib/tooling/test_map_packer' + +RSpec.describe Tooling::TestMapPacker do + subject { described_class.new } + + let(:map) do + { + 'file1.rb' => [ + './a/b/c/test_1.rb', + './a/b/test_2.rb', + './a/b/test_3.rb', + './a/test_4.rb', + './test_5.rb' + ], + 'file2.rb' => [ + './a/b/c/test_1.rb', + './a/test_4.rb', + './test_5.rb' + ] + } + end + + let(:compact_map) do + { + 'file1.rb' => { + '.' => { + 'a' => { + 'b' => { + 'c' => { + 'test_1.rb' => 1 + }, + 'test_2.rb' => 1, + 'test_3.rb' => 1 + }, + 'test_4.rb' => 1 + }, + 'test_5.rb' => 1 + } + }, + 'file2.rb' => { + '.' => { + 'a' => { + 'b' => { + 'c' => { + 'test_1.rb' => 1 + } + }, + 'test_4.rb' => 1 + }, + 'test_5.rb' => 1 + } + } + } + end + + describe '#pack' do + it 'compacts list of test files into a prefix tree' do + expect(subject.pack(map)).to eq(compact_map) + end + + it 'does nothing to empty hash' do + expect(subject.pack({})).to eq({}) + end + end + + describe '#unpack' do + it 'unpack prefix tree into list of test files' do + expect(subject.unpack(compact_map)).to eq(map) + end + + it 'does nothing to empty hash' do + expect(subject.unpack({})).to eq({}) + end + end +end diff --git a/tooling/lib/tooling/crystalball/coverage_lines_execution_detector.rb b/tooling/lib/tooling/crystalball/coverage_lines_execution_detector.rb new file mode 100644 index 00000000000..47ddf568fe4 --- /dev/null +++ b/tooling/lib/tooling/crystalball/coverage_lines_execution_detector.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'crystalball/map_generator/helpers/path_filter' + +module Tooling + module Crystalball + # Class for detecting code execution path based on coverage information diff + class CoverageLinesExecutionDetector + include ::Crystalball::MapGenerator::Helpers::PathFilter + + attr_reader :exclude_prefixes + + def initialize(*args, exclude_prefixes: []) + super(*args) + @exclude_prefixes = exclude_prefixes + end + + # Detects files affected during example execution based on line coverage. + # Transforms absolute paths to relative. + # Exclude paths outside of repository and in excluded prefixes + # + # @param[Hash] hash of files affected before example execution + # @param[Hash] hash of files affected after example execution + # @return [Array] + def detect(before, after) + file_names = after.keys + covered_files = file_names.reject { |file_name| same_coverage?(before, after, file_name) } + filter(covered_files) + end + + private + + def same_coverage?(before, after, file_name) + before[file_name] && before[file_name][:lines] == after[file_name][:lines] + end + + def filter(paths) + super.reject do |file_name| + exclude_prefixes.any? { |prefix| file_name.start_with?(prefix) } + end + end + end + end +end diff --git a/tooling/lib/tooling/crystalball/coverage_lines_strategy.rb b/tooling/lib/tooling/crystalball/coverage_lines_strategy.rb new file mode 100644 index 00000000000..ebcaab0b8d8 --- /dev/null +++ b/tooling/lib/tooling/crystalball/coverage_lines_strategy.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'coverage' +require 'crystalball/map_generator/coverage_strategy' +require_relative './coverage_lines_execution_detector' + +module Tooling + module Crystalball + # Crystalball map generator strategy based on Crystalball::MapGenerator::CoverageStrategy, + # modified to use Coverage.start(lines: true) + # This maintains compatibility with SimpleCov on Ruby >= 2.5 with start arguments + # and SimpleCov.start uses Coverage.start(lines: true) by default + class CoverageLinesStrategy < ::Crystalball::MapGenerator::CoverageStrategy + def initialize(execution_detector = CoverageLinesExecutionDetector) + super(execution_detector) + end + + def after_register + Coverage.start(lines: true) + end + end + end +end diff --git a/tooling/lib/tooling/test_map_generator.rb b/tooling/lib/tooling/test_map_generator.rb new file mode 100644 index 00000000000..bd0415f6e67 --- /dev/null +++ b/tooling/lib/tooling/test_map_generator.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'set' +require 'yaml' + +module Tooling + class TestMapGenerator + def initialize + @mapping = Hash.new { |h, k| h[k] = Set.new } + end + + def parse(yaml_files) + Array(yaml_files).each do |yaml_file| + data = File.read(yaml_file) + _metadata, example_groups = data.split("---\n").reject(&:empty?).map { |yml| YAML.safe_load(yml, [Symbol]) } + + example_groups.each do |example_id, files| + files.each do |file| + spec_file = strip_example_uid(example_id) + @mapping[file] << spec_file + end + end + end + end + + def mapping + @mapping.transform_values { |set| set.to_a } + end + + private + + def strip_example_uid(example_id) + example_id.gsub(/\[.+\]/, '') + end + end +end diff --git a/tooling/lib/tooling/test_map_packer.rb b/tooling/lib/tooling/test_map_packer.rb new file mode 100644 index 00000000000..520d69610eb --- /dev/null +++ b/tooling/lib/tooling/test_map_packer.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Tooling + class TestMapPacker + SEPARATOR = '/'.freeze + MARKER = 1 + + def pack(map) + map.transform_values(&method(:create_tree_from_tests)) + end + + def unpack(compact_map) + compact_map.transform_values(&method(:retrieve_tests_from_tree)) + end + + private + + def create_tree_from_tests(tests) + tests.inject({}) do |tree, test| + segments = test.split(SEPARATOR) + branch = create_branch_from_segments(segments) + deep_merge(tree, branch) + end + end + + def create_branch_from_segments(segments) + segments.reverse.inject(MARKER) { |node, parent| { parent => node } } + end + + def deep_merge(hash, other) + hash.merge(other) do |_, this_val, other_val| + if this_val.is_a?(Hash) && other_val.is_a?(Hash) + deep_merge(this_val, other_val) + else + other_val + end + end + end + + def retrieve_tests_from_tree(tree) + traverse(tree).inject([]) do |tests, test| + tests << test + end + end + + def traverse(tree, segments = [], &block) + return to_enum(__method__, tree, segments) unless block_given? + + if tree == MARKER + return yield segments.join(SEPARATOR) + end + + tree.each do |key, value| + traverse(value, segments + [key], &block) + end + end + end +end