diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index 2df7a84ead0..0fb02ca5965 100644 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -117,11 +117,7 @@ export default class FileTemplateMediator { selector.hide(); } }); - - if (this.editor.getValue() !== '') { - this.setTypeSelectorToggleText(item.name); - } - + this.setTypeSelectorToggleText(item.name); this.cacheToggleText(); } diff --git a/app/assets/javascripts/code_navigation/store/actions.js b/app/assets/javascripts/code_navigation/store/actions.js index 10483abfb23..cb6d30c775d 100644 --- a/app/assets/javascripts/code_navigation/store/actions.js +++ b/app/assets/javascripts/code_navigation/store/actions.js @@ -16,7 +16,7 @@ export default { commit(types.REQUEST_DATA); api - .lsifData(state.projectPath, state.commitId, state.path) + .lsifData(state.projectPath, state.commitId, state.blobPath) .then(({ data }) => { const normalizedData = data.reduce((acc, d) => { if (d.hover) { diff --git a/app/models/container_expiration_policy.rb b/app/models/container_expiration_policy.rb index c929a78a7f9..ccb0a0f8acd 100644 --- a/app/models/container_expiration_policy.rb +++ b/app/models/container_expiration_policy.rb @@ -14,7 +14,7 @@ class ContainerExpirationPolicy < ApplicationRecord validates :keep_n, inclusion: { in: ->(_) { self.keep_n_options.keys } }, allow_nil: true scope :active, -> { where(enabled: true) } - scope :preloaded, -> { preload(:project) } + scope :preloaded, -> { preload(project: [:route]) } def self.keep_n_options { diff --git a/app/models/incident_management/project_incident_management_setting.rb b/app/models/incident_management/project_incident_management_setting.rb new file mode 100644 index 00000000000..bf57c5b883f --- /dev/null +++ b/app/models/incident_management/project_incident_management_setting.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module IncidentManagement + class ProjectIncidentManagementSetting < ApplicationRecord + include Gitlab::Utils::StrongMemoize + + belongs_to :project + + validate :issue_template_exists, if: :create_issue? + + def available_issue_templates + Gitlab::Template::IssueTemplate.all(project) + end + + def issue_template_content + strong_memoize(:issue_template_content) do + issue_template&.content if issue_template_key.present? + end + end + + private + + def issue_template_exists + return unless issue_template_key.present? + + errors.add(:issue_template_key, 'not found') unless issue_template + end + + def issue_template + Gitlab::Template::IssueTemplate.find(issue_template_key, project) + rescue Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError + end + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 8454ece814f..b5639039bb6 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -187,6 +187,7 @@ class Project < ApplicationRecord has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_one :project_repository, inverse_of: :project + has_one :incident_management_setting, inverse_of: :project, class_name: 'IncidentManagement::ProjectIncidentManagementSetting' has_one :error_tracking_setting, inverse_of: :project, class_name: 'ErrorTracking::ProjectErrorTrackingSetting' has_one :metrics_setting, inverse_of: :project, class_name: 'ProjectMetricsSetting' has_one :grafana_integration, inverse_of: :project @@ -316,6 +317,7 @@ class Project < ApplicationRecord allow_destroy: true, reject_if: ->(attrs) { attrs[:id].blank? && attrs[:url].blank? } + accepts_nested_attributes_for :incident_management_setting, update_only: true accepts_nested_attributes_for :error_tracking_setting, update_only: true accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true accepts_nested_attributes_for :grafana_integration, update_only: true, allow_destroy: true diff --git a/app/services/incident_management/create_issue_service.rb b/app/services/incident_management/create_issue_service.rb new file mode 100644 index 00000000000..94b6f037924 --- /dev/null +++ b/app/services/incident_management/create_issue_service.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module IncidentManagement + class CreateIssueService < BaseService + include Gitlab::Utils::StrongMemoize + + INCIDENT_LABEL = { + title: 'incident', + color: '#CC0033', + description: <<~DESCRIPTION.chomp + Denotes a disruption to IT services and \ + the associated issues require immediate attention + DESCRIPTION + }.freeze + + def initialize(project, params) + super(project, User.alert_bot, params) + end + + def execute + return error_with('setting disabled') unless incident_management_setting.create_issue? + return error_with('invalid alert') unless alert.valid? + + issue = create_issue + return error_with(issue_errors(issue)) unless issue.valid? + + success(issue: issue) + end + + private + + def create_issue + issue = do_create_issue(label_ids: issue_label_ids) + + # Create an unlabelled issue if we couldn't create the issue + # due to labels errors. + # See https://gitlab.com/gitlab-org/gitlab-foss/issues/65042 + if issue.errors.include?(:labels) + log_label_error(issue) + issue = do_create_issue + end + + issue + end + + def do_create_issue(**params) + Issues::CreateService.new( + project, + current_user, + title: issue_title, + description: issue_description, + **params + ).execute + end + + def issue_title + alert.full_title + end + + def issue_description + horizontal_line = "\n---\n\n" + + [ + alert_summary, + alert_markdown, + issue_template_content + ].compact.join(horizontal_line) + end + + def issue_label_ids + [ + find_or_create_label(**INCIDENT_LABEL) + ].compact.map(&:id) + end + + def find_or_create_label(**params) + Labels::FindOrCreateService + .new(current_user, project, **params) + .execute + end + + def alert_summary + alert.issue_summary_markdown + end + + def alert_markdown + alert.alert_markdown + end + + def alert + strong_memoize(:alert) do + Gitlab::Alerting::Alert.new(project: project, payload: params).present + end + end + + def issue_template_content + incident_management_setting.issue_template_content + end + + def incident_management_setting + strong_memoize(:incident_management_setting) do + project.incident_management_setting || + project.build_incident_management_setting + end + end + + def issue_errors(issue) + issue.errors.full_messages.to_sentence + end + + def log_label_error(issue) + log_info <<~TEXT.chomp + Cannot create incident issue with labels \ + #{issue.labels.map(&:title).inspect} \ + for "#{project.full_name}": #{issue.errors.full_messages.to_sentence}. + Retrying without labels. + TEXT + end + + def error_with(message) + log_error(%{Cannot create incident issue for "#{project.full_name}": #{message}}) + + error(message) + end + end +end diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index 9803d65c4fb..91d1fc06a41 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -10,7 +10,7 @@ #blob-content-holder.blob-content-holder - if native_code_navigation_enabled?(@project) - #js-code-navigation{ data: { commit_id: blob.commit_id, path: blob.path, project_path: @project.full_path } } + #js-code-navigation{ data: { commit_id: blob.commit_id, blob_path: blob.path, project_path: @project.full_path } } %article.file-holder = render 'projects/blob/header', blob: blob = render 'projects/blob/content', blob: blob diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index e836dd92770..22dd7f5843f 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -429,6 +429,12 @@ :latency_sensitive: :resource_boundary: :unknown :weight: 1 +- :name: incident_management:incident_management_process_alert + :feature_category: :incident_management + :has_external_dependencies: + :latency_sensitive: + :resource_boundary: :unknown + :weight: 2 - :name: mail_scheduler:mail_scheduler_issue_due :feature_category: :issue_tracking :has_external_dependencies: diff --git a/app/workers/concerns/worker_context.rb b/app/workers/concerns/worker_context.rb index ca006eaad5d..f2ff3ecfb6b 100644 --- a/app/workers/concerns/worker_context.rb +++ b/app/workers/concerns/worker_context.rb @@ -60,6 +60,6 @@ module WorkerContext end def with_context(context, &block) - Gitlab::ApplicationContext.new(context).use(&block) + Gitlab::ApplicationContext.new(context).use { yield(**context) } end end diff --git a/app/workers/container_expiration_policy_worker.rb b/app/workers/container_expiration_policy_worker.rb index a930dfa0186..e07a6546e2d 100644 --- a/app/workers/container_expiration_policy_worker.rb +++ b/app/workers/container_expiration_policy_worker.rb @@ -2,15 +2,17 @@ class ContainerExpirationPolicyWorker include ApplicationWorker - include CronjobQueue # rubocop:disable Scalability/CronWorkerContext + include CronjobQueue feature_category :container_registry def perform ContainerExpirationPolicy.runnable_schedules.preloaded.find_each do |container_expiration_policy| - ContainerExpirationPolicyService.new( - container_expiration_policy.project, container_expiration_policy.project.owner - ).execute(container_expiration_policy) + with_context(project: container_expiration_policy.project, + user: container_expiration_policy.project.owner) do |project:, user:| + ContainerExpirationPolicyService.new(project, user) + .execute(container_expiration_policy) + end end end end diff --git a/app/workers/incident_management/process_alert_worker.rb b/app/workers/incident_management/process_alert_worker.rb new file mode 100644 index 00000000000..f3d5bc5c66b --- /dev/null +++ b/app/workers/incident_management/process_alert_worker.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module IncidentManagement + class ProcessAlertWorker + include ApplicationWorker + + queue_namespace :incident_management + feature_category :incident_management + + def perform(project_id, alert) + project = find_project(project_id) + return unless project + + create_issue(project, alert) + end + + private + + def find_project(project_id) + Project.find_by_id(project_id) + end + + def create_issue(project, alert) + IncidentManagement::CreateIssueService + .new(project, alert) + .execute + end + end +end diff --git a/changelogs/unreleased/show_selected_template_type.yml b/changelogs/unreleased/show_selected_template_type.yml new file mode 100644 index 00000000000..018438fb2ca --- /dev/null +++ b/changelogs/unreleased/show_selected_template_type.yml @@ -0,0 +1,5 @@ +--- +title: Show selected template type when clicked +merge_request: 24596 +author: +type: fixed diff --git a/config/prometheus/self_monitoring_default.yml b/config/prometheus/self_monitoring_default.yml new file mode 100644 index 00000000000..dc2361fb3bc --- /dev/null +++ b/config/prometheus/self_monitoring_default.yml @@ -0,0 +1,39 @@ +dashboard: 'Default dashboard' +priority: 1 +panel_groups: +- group: Web Service + panels: + - title: Web Service - Error Ratio + type: line-chart + y_label: "Unhandled Exceptions (%)" + metrics: + - id: wser_web_service + query_range: 'max(max_over_time(gitlab_service_errors:ratio{environment="{{ci_environment_slug}}", type="web", stage="main"}[1m])) by (type) * 100' + unit: "%" + label: "Error Ratio" + - id: wser_degradation_slo + query_range: 'avg(slo:max:gitlab_service_errors:ratio{environment="{{ci_environment_slug}}", type="web", stage="main"}) or avg(slo:max:gitlab_service_errors:ratio{type="web"}) * 100' + unit: "%" + label: "Degradation SLO" + - id: wser_outage_slo + query_range: '2 * (avg(slo:max:gitlab_service_errors:ratio{environment="{{ci_environment_slug}}", type="web", stage="main"}) or avg(slo:max:gitlab_service_errors:ratio{type="web"})) * 100' + unit: "%" + label: "Outage SLO" +- group: API Service + panels: + - title: API Service - Error Ratio + type: line-chart + y_label: "Unhandled Exceptions (%)" + metrics: + - id: aser_web_service + query_range: 'max(max_over_time(gitlab_service_errors:ratio{environment="{{ci_environment_slug}}", type="api", stage="main"}[1m])) by (type) * 100' + unit: "%" + label: "Error Ratio" + - id: aser_degradation_slo + query_range: 'avg(slo:max:gitlab_service_errors:ratio{environment="{{ci_environment_slug}}", type="api", stage="main"}) or avg(slo:max:gitlab_service_errors:ratio{type="web"}) * 100' + unit: "%" + label: "Degradation SLO" + - id: aser_outage_slo + query_range: '2 * (avg(slo:max:gitlab_service_errors:ratio{environment="{{ci_environment_slug}}", type="api", stage="main"}) or avg(slo:max:gitlab_service_errors:ratio{type="web"})) * 100' + unit: "%" + label: "Outage SLO" diff --git a/doc/ci/environments.md b/doc/ci/environments.md index 61f09bf6e00..eddda2031b0 100644 --- a/doc/ci/environments.md +++ b/doc/ci/environments.md @@ -115,11 +115,14 @@ With this configuration, we: - Lastly we deploy to the staging server. NOTE: **Note:** -The `environment` keyword is just a hint for GitLab that this job actually -deploys to the `name` environment. It can also have a `url` that is -exposed in various places within GitLab. Each time a job that -has an environment specified succeeds, a deployment is recorded, storing -the Git SHA and environment name. +The `environment` keyword defines where the app is deployed. +The environment `name` and `url` is exposed in various places +within GitLab. Each time a job that has an environment specified +succeeds, a deployment is recorded, along with the Git SHA and environment name. + +CAUTION: **Caution**: +Some characters are not allowed in environment names. Use only letters, +numbers, spaces, and `-`, `_`, `/`, `{`, `}`, or `.`. Also, it must not start nor end with `/`. In summary, with the above `.gitlab-ci.yml` we have achieved the following: diff --git a/doc/user/project/deploy_boards.md b/doc/user/project/deploy_boards.md index c4d357e24f0..fe2c8bbf4ed 100644 --- a/doc/user/project/deploy_boards.md +++ b/doc/user/project/deploy_boards.md @@ -27,7 +27,7 @@ deploy rolling out. The percentage is the percent of the pods that are updated to the latest release. Since Deploy Boards are tightly coupled with Kubernetes, there is some required -knowledge. In particular you should be familiar with: +knowledge. In particular, you should be familiar with: - [Kubernetes pods](https://kubernetes.io/docs/concepts/workloads/pods/pod/) - [Kubernetes labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) @@ -37,7 +37,7 @@ knowledge. In particular you should be familiar with: ## Use cases Since the Deploy Board is a visual representation of the Kubernetes pods for a -specific environment, there are lot of uses cases. To name a few: +specific environment, there are a lot of use cases. To name a few: - You want to promote what's running in staging, to production. You go to the environments list, verify that what's running in staging is what you think is @@ -65,7 +65,7 @@ To display the Deploy Boards for a specific [environment] you should: NOTE: **Running on OpenShift:** If you are using OpenShift, ensure that you're using the `Deployment` resource - instead of `DeploymentConfiguration`, otherwise the Deploy Boards won't render + instead of `DeploymentConfiguration`. Otherwise, the Deploy Boards won't render correctly. For more information, read the [OpenShift docs](https://docs.openshift.com/container-platform/3.7/dev_guide/deployments/kubernetes_deployments.html#kubernetes-deployments-vs-deployment-configurations) and [GitLab issue #4584](https://gitlab.com/gitlab-org/gitlab/issues/4584). diff --git a/spec/factories/incident_management/project_incident_management_settings.rb b/spec/factories/incident_management/project_incident_management_settings.rb new file mode 100644 index 00000000000..5b6a71d87d5 --- /dev/null +++ b/spec/factories/incident_management/project_incident_management_settings.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :project_incident_management_setting, class: 'IncidentManagement::ProjectIncidentManagementSetting' do + project + create_issue { false } + issue_template_key { nil } + send_email { false } + end +end diff --git a/spec/features/issues/issue_detail_spec.rb b/spec/features/issues/issue_detail_spec.rb index a1b53718577..0d24b02a64c 100644 --- a/spec/features/issues/issue_detail_spec.rb +++ b/spec/features/issues/issue_detail_spec.rb @@ -23,16 +23,18 @@ describe 'Issue Detail', :js do context 'when issue description has xss snippet' do before do issue.update!(description: '![xss" onload=alert(1);//](a)') + sign_in(user) visit project_issue_path(project, issue) - wait_for_requests end it 'encodes the description to prevent xss issues' do page.within('.issuable-details .detail-page-description') do + image = find('img.js-lazy-loaded') + expect(page).to have_selector('img', count: 1) - expect(find('img')['onerror']).to be_nil - expect(find('img')['src']).to end_with('/a') + expect(image['onerror']).to be_nil + expect(image['src']).to end_with('/a') end end end diff --git a/spec/features/projects/files/template_type_dropdown_spec.rb b/spec/features/projects/files/template_type_dropdown_spec.rb index ba52a7e7deb..03b4b9b4517 100644 --- a/spec/features/projects/files/template_type_dropdown_spec.rb +++ b/spec/features/projects/files/template_type_dropdown_spec.rb @@ -75,6 +75,11 @@ describe 'Projects > Files > Template type dropdown selector', :js do check_type_selector_toggle_text('.gitignore') end + it 'sets the toggle text when selecting the template type' do + select_template_type('.gitignore') + check_type_selector_toggle_text('.gitignore') + end + it 'selects every template type correctly' do try_selecting_all_types end diff --git a/spec/lib/gitlab/graphql/connections/keyset/connection_spec.rb b/spec/lib/gitlab/graphql/connections/keyset/connection_spec.rb index f617e8b3ce7..c193ab2b50f 100644 --- a/spec/lib/gitlab/graphql/connections/keyset/connection_spec.rb +++ b/spec/lib/gitlab/graphql/connections/keyset/connection_spec.rb @@ -103,7 +103,75 @@ describe Gitlab::Graphql::Connections::Keyset::Connection do end end - context 'when multiple orders are defined' do + shared_examples 'nodes are in ascending order' do + context 'when no cursor is passed' do + let(:arguments) { {} } + + it 'returns projects in ascending order' do + expect(subject.sliced_nodes).to eq(ascending_nodes) + end + end + + context 'when before cursor value is not NULL' do + let(:arguments) { { before: encoded_cursor(ascending_nodes[2]) } } + + it 'returns all projects before the cursor' do + expect(subject.sliced_nodes).to eq(ascending_nodes.first(2)) + end + end + + context 'when after cursor value is not NULL' do + let(:arguments) { { after: encoded_cursor(ascending_nodes[1]) } } + + it 'returns all projects after the cursor' do + expect(subject.sliced_nodes).to eq(ascending_nodes.last(3)) + end + end + + context 'when before and after cursor' do + let(:arguments) { { before: encoded_cursor(ascending_nodes.last), after: encoded_cursor(ascending_nodes.first) } } + + it 'returns all projects after the cursor' do + expect(subject.sliced_nodes).to eq(ascending_nodes[1..3]) + end + end + end + + shared_examples 'nodes are in descending order' do + context 'when no cursor is passed' do + let(:arguments) { {} } + + it 'only returns projects in descending order' do + expect(subject.sliced_nodes).to eq(descending_nodes) + end + end + + context 'when before cursor value is not NULL' do + let(:arguments) { { before: encoded_cursor(descending_nodes[2]) } } + + it 'returns all projects before the cursor' do + expect(subject.sliced_nodes).to eq(descending_nodes.first(2)) + end + end + + context 'when after cursor value is not NULL' do + let(:arguments) { { after: encoded_cursor(descending_nodes[1]) } } + + it 'returns all projects after the cursor' do + expect(subject.sliced_nodes).to eq(descending_nodes.last(3)) + end + end + + context 'when before and after cursor' do + let(:arguments) { { before: encoded_cursor(descending_nodes.last), after: encoded_cursor(descending_nodes.first) } } + + it 'returns all projects after the cursor' do + expect(subject.sliced_nodes).to eq(descending_nodes[1..3]) + end + end + end + + context 'when multiple orders with nil values are defined' do let!(:project1) { create(:project, last_repository_check_at: 10.days.ago) } # Asc: project5 Desc: project3 let!(:project2) { create(:project, last_repository_check_at: nil) } # Asc: project1 Desc: project1 let!(:project3) { create(:project, last_repository_check_at: 5.days.ago) } # Asc: project3 Desc: project5 @@ -114,14 +182,9 @@ describe Gitlab::Graphql::Connections::Keyset::Connection do let(:nodes) do Project.order(Arel.sql('projects.last_repository_check_at IS NULL')).order(last_repository_check_at: :asc).order(id: :asc) end + let(:ascending_nodes) { [project5, project1, project3, project2, project4] } - context 'when no cursor is passed' do - let(:arguments) { {} } - - it 'returns projects in ascending order' do - expect(subject.sliced_nodes).to eq([project5, project1, project3, project2, project4]) - end - end + it_behaves_like 'nodes are in ascending order' context 'when before cursor value is NULL' do let(:arguments) { { before: encoded_cursor(project4) } } @@ -131,14 +194,6 @@ describe Gitlab::Graphql::Connections::Keyset::Connection do end end - context 'when before cursor value is not NULL' do - let(:arguments) { { before: encoded_cursor(project3) } } - - it 'returns all projects before the cursor' do - expect(subject.sliced_nodes).to eq([project5, project1]) - end - end - context 'when after cursor value is NULL' do let(:arguments) { { after: encoded_cursor(project2) } } @@ -146,36 +201,15 @@ describe Gitlab::Graphql::Connections::Keyset::Connection do expect(subject.sliced_nodes).to eq([project4]) end end - - context 'when after cursor value is not NULL' do - let(:arguments) { { after: encoded_cursor(project1) } } - - it 'returns all projects after the cursor' do - expect(subject.sliced_nodes).to eq([project3, project2, project4]) - end - end - - context 'when before and after cursor' do - let(:arguments) { { before: encoded_cursor(project4), after: encoded_cursor(project5) } } - - it 'returns all projects after the cursor' do - expect(subject.sliced_nodes).to eq([project1, project3, project2]) - end - end end context 'when descending' do let(:nodes) do Project.order(Arel.sql('projects.last_repository_check_at IS NULL')).order(last_repository_check_at: :desc).order(id: :asc) end + let(:descending_nodes) { [project3, project1, project5, project2, project4] } - context 'when no cursor is passed' do - let(:arguments) { {} } - - it 'only returns projects in descending order' do - expect(subject.sliced_nodes).to eq([project3, project1, project5, project2, project4]) - end - end + it_behaves_like 'nodes are in descending order' context 'when before cursor value is NULL' do let(:arguments) { { before: encoded_cursor(project4) } } @@ -185,14 +219,6 @@ describe Gitlab::Graphql::Connections::Keyset::Connection do end end - context 'when before cursor value is not NULL' do - let(:arguments) { { before: encoded_cursor(project5) } } - - it 'returns all projects before the cursor' do - expect(subject.sliced_nodes).to eq([project3, project1]) - end - end - context 'when after cursor value is NULL' do let(:arguments) { { after: encoded_cursor(project2) } } @@ -200,22 +226,32 @@ describe Gitlab::Graphql::Connections::Keyset::Connection do expect(subject.sliced_nodes).to eq([project4]) end end + end + end - context 'when after cursor value is not NULL' do - let(:arguments) { { after: encoded_cursor(project1) } } + context 'when ordering uses LOWER' do + let!(:project1) { create(:project, name: 'A') } # Asc: project1 Desc: project4 + let!(:project2) { create(:project, name: 'c') } # Asc: project5 Desc: project2 + let!(:project3) { create(:project, name: 'b') } # Asc: project3 Desc: project3 + let!(:project4) { create(:project, name: 'd') } # Asc: project2 Desc: project5 + let!(:project5) { create(:project, name: 'a') } # Asc: project4 Desc: project1 - it 'returns all projects after the cursor' do - expect(subject.sliced_nodes).to eq([project5, project2, project4]) - end + context 'when ascending' do + let(:nodes) do + Project.order(Arel::Table.new(:projects)['name'].lower.asc).order(id: :asc) end + let(:ascending_nodes) { [project1, project5, project3, project2, project4] } - context 'when before and after cursor' do - let(:arguments) { { before: encoded_cursor(project4), after: encoded_cursor(project3) } } + it_behaves_like 'nodes are in ascending order' + end - it 'returns all projects after the cursor' do - expect(subject.sliced_nodes).to eq([project1, project5, project2]) - end + context 'when descending' do + let(:nodes) do + Project.order(Arel::Table.new(:projects)['name'].lower.desc).order(id: :desc) end + let(:descending_nodes) { [project4, project2, project3, project5, project1] } + + it_behaves_like 'nodes are in descending order' end end diff --git a/spec/models/container_expiration_policy_spec.rb b/spec/models/container_expiration_policy_spec.rb index 1bce4c3b20a..c22362ed5d4 100644 --- a/spec/models/container_expiration_policy_spec.rb +++ b/spec/models/container_expiration_policy_spec.rb @@ -49,9 +49,9 @@ RSpec.describe ContainerExpirationPolicy, type: :model do it 'preloads the associations' do subject - query = ActiveRecord::QueryRecorder.new { subject.each(&:project) } + query = ActiveRecord::QueryRecorder.new { subject.map(&:project).map(&:full_path) } - expect(query.count).to eq(2) + expect(query.count).to eq(3) end end diff --git a/spec/models/incident_management/project_incident_management_setting_spec.rb b/spec/models/incident_management/project_incident_management_setting_spec.rb new file mode 100644 index 00000000000..ac3f97e2d89 --- /dev/null +++ b/spec/models/incident_management/project_incident_management_setting_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe IncidentManagement::ProjectIncidentManagementSetting do + let_it_be(:project) { create(:project, :repository, create_templates: :issue) } + + describe 'Associations' do + it { is_expected.to belong_to(:project) } + end + + describe 'Validations' do + describe 'validate issue_template_exists' do + subject { build(:project_incident_management_setting, project: project) } + + context 'with create_issue enabled' do + before do + subject.create_issue = true + end + + context 'with valid issue_template_key' do + before do + subject.issue_template_key = 'bug' + end + + it { is_expected.to be_valid } + end + + context 'with empty issue_template_key' do + before do + subject.issue_template_key = '' + end + + it { is_expected.to be_valid } + end + + context 'with nil issue_template_key' do + before do + subject.issue_template_key = nil + end + + it { is_expected.to be_valid } + end + + context 'with invalid issue_template_key' do + before do + subject.issue_template_key = 'unknown' + end + + it { is_expected.to be_invalid } + + it 'returns error' do + subject.valid? + + expect(subject.errors[:issue_template_key]).to eq(['not found']) + end + end + end + + context 'with create_issue disabled' do + before do + subject.create_issue = false + end + + context 'with unknown issue_template_key' do + before do + subject.issue_template_key = 'unknown' + end + + it { is_expected.to be_valid } + end + end + end + end + + describe '#issue_template_content' do + subject { build(:project_incident_management_setting, project: project) } + + shared_examples 'no content' do + it 'returns no content' do + expect(subject.issue_template_content).to be_nil + end + end + + context 'with valid issue_template_key' do + before do + subject.issue_template_key = 'bug' + end + + it 'returns issue content' do + expect(subject.issue_template_content).to eq('something valid') + end + end + + context 'with unknown issue_template_key' do + before do + subject.issue_template_key = 'unknown' + end + + it_behaves_like 'no content' + end + + context 'without issue_template_key' do + before do + subject.issue_template_key = nil + end + + it_behaves_like 'no content' + end + end +end diff --git a/spec/services/incident_management/create_issue_service_spec.rb b/spec/services/incident_management/create_issue_service_spec.rb new file mode 100644 index 00000000000..e720aafb897 --- /dev/null +++ b/spec/services/incident_management/create_issue_service_spec.rb @@ -0,0 +1,311 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe IncidentManagement::CreateIssueService do + let(:project) { create(:project, :repository, :private) } + let(:user) { User.alert_bot } + let(:service) { described_class.new(project, alert_payload) } + let(:alert_starts_at) { Time.now } + let(:alert_title) { 'TITLE' } + let(:alert_annotations) { { title: alert_title } } + + let(:alert_payload) do + build_alert_payload( + annotations: alert_annotations, + starts_at: alert_starts_at + ) + end + + let(:alert_presenter) do + Gitlab::Alerting::Alert.new(project: project, payload: alert_payload).present + end + + let!(:setting) do + create(:project_incident_management_setting, project: project) + end + + subject { service.execute } + + context 'when create_issue enabled' do + let(:issue) { subject[:issue] } + let(:summary_separator) { "\n---\n\n" } + + before do + setting.update!(create_issue: true) + end + + context 'without issue_template_content' do + it 'creates an issue with alert summary only' do + expect(subject).to include(status: :success) + + expect(issue.author).to eq(user) + expect(issue.title).to eq(alert_title) + expect(issue.description).to include(alert_presenter.issue_summary_markdown.strip) + expect(separator_count(issue.description)).to eq 0 + end + end + + context 'with erroneous issue service' do + let(:invalid_issue) do + build(:issue, project: project, title: nil).tap(&:valid?) + end + + let(:issue_error) { invalid_issue.errors.full_messages.to_sentence } + + it 'returns and logs the issue error' do + expect_next_instance_of(Issues::CreateService) do |issue_service| + expect(issue_service).to receive(:execute).and_return(invalid_issue) + end + + expect(service) + .to receive(:log_error) + .with(error_message(issue_error)) + + expect(subject).to include(status: :error, message: issue_error) + end + end + + shared_examples 'GFM template' do + context 'plain content' do + let(:template_content) { 'some content' } + + it 'creates an issue appending issue template' do + expect(subject).to include(status: :success) + + expect(issue.description).to include(alert_presenter.issue_summary_markdown) + expect(separator_count(issue.description)).to eq 1 + expect(issue.description).to include(template_content) + end + end + + context 'quick actions' do + let(:user) { create(:user) } + let(:plain_text) { 'some content' } + + let(:template_content) do + <<~CONTENT + #{plain_text} + /due tomorrow + /assign @#{user.username} + CONTENT + end + + before do + project.add_maintainer(user) + end + + it 'creates an issue interpreting quick actions' do + expect(subject).to include(status: :success) + + expect(issue.description).to include(plain_text) + expect(issue.due_date).to be_present + expect(issue.assignees).to eq([user]) + end + end + end + + context 'with gitlab_incident_markdown' do + let(:alert_annotations) do + { title: alert_title, gitlab_incident_markdown: template_content } + end + + it_behaves_like 'GFM template' + end + + context 'with issue_template_content' do + before do + create_issue_template('bug', template_content) + setting.update!(issue_template_key: 'bug') + end + + it_behaves_like 'GFM template' + + context 'and gitlab_incident_markdown' do + let(:template_content) { 'plain text'} + let(:alt_template) { 'alternate text' } + let(:alert_annotations) do + { title: alert_title, gitlab_incident_markdown: alt_template } + end + + it 'includes both templates' do + expect(subject).to include(status: :success) + + expect(issue.description).to include(alert_presenter.issue_summary_markdown) + expect(issue.description).to include(template_content) + expect(issue.description).to include(alt_template) + expect(separator_count(issue.description)).to eq 2 + end + end + + private + + def create_issue_template(name, content) + project.repository.create_file( + project.creator, + ".gitlab/issue_templates/#{name}.md", + content, + message: 'message', + branch_name: 'master' + ) + end + end + + context 'with gitlab alert' do + let(:gitlab_alert) { create(:prometheus_alert, project: project) } + + before do + alert_payload['labels'] = { + 'gitlab_alert_id' => gitlab_alert.prometheus_metric_id.to_s + } + end + + it 'creates an issue' do + query_title = "#{gitlab_alert.title} #{gitlab_alert.computed_operator} #{gitlab_alert.threshold}" + + expect(subject).to include(status: :success) + + expect(issue.author).to eq(user) + expect(issue.title).to eq(alert_presenter.full_title) + expect(issue.title).to include(gitlab_alert.environment.name) + expect(issue.title).to include(query_title) + expect(issue.title).to include('for 5 minutes') + expect(issue.description).to include(alert_presenter.issue_summary_markdown.strip) + expect(separator_count(issue.description)).to eq 0 + end + end + + describe 'with invalid alert payload' do + shared_examples 'invalid alert' do + it 'does not create an issue' do + expect(service) + .to receive(:log_error) + .with(error_message('invalid alert')) + + expect(subject).to eq(status: :error, message: 'invalid alert') + end + end + + context 'without title' do + let(:alert_annotations) { {} } + + it_behaves_like 'invalid alert' + end + + context 'without startsAt' do + let(:alert_starts_at) { nil } + + it_behaves_like 'invalid alert' + end + end + + describe "label `incident`" do + let(:title) { 'incident' } + let(:color) { '#CC0033' } + let(:description) do + <<~DESCRIPTION.chomp + Denotes a disruption to IT services and \ + the associated issues require immediate attention + DESCRIPTION + end + + shared_examples 'existing label' do + it 'adds the existing label' do + expect { subject }.not_to change(Label, :count) + + expect(issue.labels).to eq([label]) + end + end + + shared_examples 'new label' do + it 'adds newly created label' do + expect { subject }.to change(Label, :count).by(1) + + label = project.reload.labels.last + expect(issue.labels).to eq([label]) + expect(label.title).to eq(title) + expect(label.color).to eq(color) + expect(label.description).to eq(description) + end + end + + context 'with predefined project label' do + it_behaves_like 'existing label' do + let!(:label) { create(:label, project: project, title: title) } + end + end + + context 'with predefined group label' do + let(:project) { create(:project, group: group) } + let(:group) { create(:group) } + + it_behaves_like 'existing label' do + let!(:label) { create(:group_label, group: group, title: title) } + end + end + + context 'without label' do + it_behaves_like 'new label' + end + + context 'with duplicate labels', issue: 'https://gitlab.com/gitlab-org/gitlab-foss/issues/65042' do + before do + # Replicate race condition to create duplicates + build(:label, project: project, title: title).save!(validate: false) + build(:label, project: project, title: title).save!(validate: false) + end + + it 'create an issue without labels' do + # Verify we have duplicates + expect(project.labels.size).to eq(2) + expect(project.labels.map(&:title)).to all(eq(title)) + + message = <<~MESSAGE.chomp + Cannot create incident issue with labels ["#{title}"] for \ + "#{project.full_name}": Labels is invalid. + Retrying without labels. + MESSAGE + + expect(service) + .to receive(:log_info) + .with(message) + + expect(subject).to include(status: :success) + expect(issue.labels).to be_empty + end + end + end + end + + context 'when create_issue disabled' do + before do + setting.update!(create_issue: false) + end + + it 'returns an error' do + expect(service) + .to receive(:log_error) + .with(error_message('setting disabled')) + + expect(subject).to eq(status: :error, message: 'setting disabled') + end + end + + private + + def build_alert_payload(annotations: {}, starts_at: Time.now) + { + 'annotations' => annotations.stringify_keys + }.tap do |payload| + payload['startsAt'] = starts_at.rfc3339 if starts_at + end + end + + def error_message(message) + %{Cannot create incident issue for "#{project.full_name}": #{message}} + end + + def separator_count(text) + text.scan(summary_separator).size + end +end diff --git a/spec/workers/concerns/worker_context_spec.rb b/spec/workers/concerns/worker_context_spec.rb index 97a88eecd73..4e8c81c57dc 100644 --- a/spec/workers/concerns/worker_context_spec.rb +++ b/spec/workers/concerns/worker_context_spec.rb @@ -106,5 +106,15 @@ describe WorkerContext do expect(Labkit::Context.current.to_h).to include('meta.user' => 'jane-doe') end end + + it 'yields the arguments to the block' do + a_user = build_stubbed(:user) + a_project = build_stubbed(:project) + + worker.new.with_context(user: a_user, project: a_project) do |user:, project:| + expect(user).to eq(a_user) + expect(project).to eq(a_project) + end + end end end diff --git a/spec/workers/incident_management/process_alert_worker_spec.rb b/spec/workers/incident_management/process_alert_worker_spec.rb new file mode 100644 index 00000000000..9f40833dfd7 --- /dev/null +++ b/spec/workers/incident_management/process_alert_worker_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe IncidentManagement::ProcessAlertWorker do + let_it_be(:project) { create(:project) } + + describe '#perform' do + let(:alert) { :alert } + let(:create_issue_service) { spy(:create_issue_service) } + + subject { described_class.new.perform(project.id, alert) } + + it 'calls create issue service' do + expect(Project).to receive(:find_by_id).and_call_original + + expect(IncidentManagement::CreateIssueService) + .to receive(:new).with(project, :alert) + .and_return(create_issue_service) + + expect(create_issue_service).to receive(:execute) + + subject + end + + context 'with invalid project' do + let(:invalid_project_id) { 0 } + + subject { described_class.new.perform(invalid_project_id, alert) } + + it 'does not create issues' do + expect(Project).to receive(:find_by_id).and_call_original + expect(IncidentManagement::CreateIssueService).not_to receive(:new) + + subject + end + end + end +end