Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
		
							parent
							
								
									6520b1366e
								
							
						
					
					
						commit
						defde9698e
					
				|  | @ -318,7 +318,7 @@ export default { | |||
| </script> | ||||
| <template> | ||||
|   <div> | ||||
|     <div class="alert-management-list"> | ||||
|     <div class="incident-management-list"> | ||||
|       <gl-alert v-if="showNoAlertsMsg" @dismiss="isAlertDismissed = true"> | ||||
|         <gl-sprintf :message="$options.i18n.noAlertsMsg"> | ||||
|           <template #link="{ content }"> | ||||
|  |  | |||
|  | @ -180,6 +180,10 @@ export class CopyAsGFM { | |||
|       }) | ||||
|       .catch(() => {}); | ||||
|   } | ||||
| 
 | ||||
|   static quoted(markdown) { | ||||
|     return `> ${markdown.split('\n').join('\n> ')}`; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // Export CopyAsGFM as a global for rspec to access
 | ||||
|  |  | |||
|  | @ -0,0 +1,129 @@ | |||
| <script> | ||||
| import { GlLoadingIcon, GlTable, GlAlert } from '@gitlab/ui'; | ||||
| import { s__ } from '~/locale'; | ||||
| import getIncidents from '../graphql/queries/get_incidents.query.graphql'; | ||||
| import { I18N } from '../constants'; | ||||
| 
 | ||||
| const tdClass = | ||||
|   'table-col gl-display-flex d-md-table-cell gl-align-items-center gl-white-space-nowrap'; | ||||
| const thClass = 'gl-hover-bg-blue-50'; | ||||
| const bodyTrClass = | ||||
|   'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-bg-blue-50 gl-hover-border-b-solid gl-hover-border-blue-200'; | ||||
| 
 | ||||
| export default { | ||||
|   i18n: I18N, | ||||
|   fields: [ | ||||
|     { | ||||
|       key: 'title', | ||||
|       label: s__('IncidentManagement|Incident'), | ||||
|       thClass: `gl-pointer-events-none gl-w-half`, | ||||
|       tdClass, | ||||
|     }, | ||||
|     { | ||||
|       key: 'createdAt', | ||||
|       label: s__('IncidentManagement|Date created'), | ||||
|       thClass: `${thClass} gl-pointer-events-none`, | ||||
|       tdClass, | ||||
|     }, | ||||
|     { | ||||
|       key: 'assignees', | ||||
|       label: s__('IncidentManagement|Assignees'), | ||||
|       thClass: 'gl-pointer-events-none', | ||||
|       tdClass, | ||||
|     }, | ||||
|   ], | ||||
|   components: { | ||||
|     GlLoadingIcon, | ||||
|     GlTable, | ||||
|     GlAlert, | ||||
|   }, | ||||
|   inject: ['projectPath'], | ||||
|   apollo: { | ||||
|     incidents: { | ||||
|       query: getIncidents, | ||||
|       variables() { | ||||
|         return { | ||||
|           projectPath: this.projectPath, | ||||
|           labelNames: ['incident'], | ||||
|         }; | ||||
|       }, | ||||
|       update: ({ project: { issues: { nodes = [] } = {} } = {} }) => nodes, | ||||
|       error() { | ||||
|         this.errored = true; | ||||
|       }, | ||||
|     }, | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       errored: false, | ||||
|       isErrorAlertDismissed: false, | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     showErrorMsg() { | ||||
|       return this.errored && !this.isErrorAlertDismissed; | ||||
|     }, | ||||
|     loading() { | ||||
|       return this.$apollo.queries.incidents.loading; | ||||
|     }, | ||||
|     hasIncidents() { | ||||
|       return this.incidents?.length; | ||||
|     }, | ||||
|     tbodyTrClass() { | ||||
|       return { | ||||
|         [bodyTrClass]: !this.loading && this.hasIncidents, | ||||
|       }; | ||||
|     }, | ||||
|   }, | ||||
|   methods: { | ||||
|     getAssignees(assignees) { | ||||
|       return assignees.nodes?.length > 0 | ||||
|         ? assignees.nodes[0]?.username | ||||
|         : s__('IncidentManagement|Unassigned'); | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| <template> | ||||
|   <div class="incident-management-list"> | ||||
|     <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="isErrorAlertDismissed = true"> | ||||
|       {{ $options.i18n.errorMsg }} | ||||
|     </gl-alert> | ||||
| 
 | ||||
|     <h4 class="gl-display-block d-md-none my-3"> | ||||
|       {{ s__('IncidentManagement|Incidents') }} | ||||
|     </h4> | ||||
|     <gl-table | ||||
|       :items="incidents" | ||||
|       :fields="$options.fields" | ||||
|       :show-empty="true" | ||||
|       :busy="loading" | ||||
|       stacked="md" | ||||
|       :tbody-tr-class="tbodyTrClass" | ||||
|       :no-local-sorting="true" | ||||
|       fixed | ||||
|     > | ||||
|       <template #cell(title)="{ item }"> | ||||
|         <div class="gl-max-w-full text-truncate" :title="item.title">{{ item.title }}</div> | ||||
|       </template> | ||||
| 
 | ||||
|       <template #cell(createdAt)="{ item }"> | ||||
|         {{ item.createdAt }} | ||||
|       </template> | ||||
| 
 | ||||
|       <template #cell(assignees)="{ item }"> | ||||
|         <div class="gl-max-w-full text-truncate" data-testid="assigneesField"> | ||||
|           {{ getAssignees(item.assignees) }} | ||||
|         </div> | ||||
|       </template> | ||||
| 
 | ||||
|       <template #table-busy> | ||||
|         <gl-loading-icon size="lg" color="dark" class="mt-3" /> | ||||
|       </template> | ||||
| 
 | ||||
|       <template #empty> | ||||
|         {{ $options.i18n.noIncidents }} | ||||
|       </template> | ||||
|     </gl-table> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -0,0 +1,7 @@ | |||
| /* eslint-disable import/prefer-default-export  */ | ||||
| import { s__ } from '~/locale'; | ||||
| 
 | ||||
| export const I18N = { | ||||
|   errorMsg: s__('IncidentManagement|There was an error displaying the incidents.'), | ||||
|   noIncidents: s__('IncidentManagement|No incidents to display.'), | ||||
| }; | ||||
|  | @ -0,0 +1,22 @@ | |||
| query getIncidents($projectPath: ID!, $labelNames: [String], $state: IssuableState) { | ||||
|   project(fullPath: $projectPath) { | ||||
|     issues(state: $state, labelName: $labelNames) { | ||||
|       nodes { | ||||
|         iid | ||||
|         title | ||||
|         createdAt | ||||
|         labels { | ||||
|           nodes { | ||||
|             title | ||||
|             color | ||||
|           } | ||||
|         } | ||||
|         assignees { | ||||
|           nodes { | ||||
|             username | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,34 @@ | |||
| import Vue from 'vue'; | ||||
| import VueApollo from 'vue-apollo'; | ||||
| import createDefaultClient from '~/lib/graphql'; | ||||
| import IncidentsList from './components/incidents_list.vue'; | ||||
| 
 | ||||
| Vue.use(VueApollo); | ||||
| export default () => { | ||||
|   const selector = '#js-incidents'; | ||||
| 
 | ||||
|   const domEl = document.querySelector(selector); | ||||
|   const { projectPath } = domEl.dataset; | ||||
| 
 | ||||
|   const apolloProvider = new VueApollo({ | ||||
|     defaultClient: createDefaultClient(), | ||||
|   }); | ||||
| 
 | ||||
|   return new Vue({ | ||||
|     el: selector, | ||||
|     provide: { | ||||
|       projectPath, | ||||
|     }, | ||||
|     apolloProvider, | ||||
|     components: { | ||||
|       IncidentsList, | ||||
|     }, | ||||
|     render(createElement) { | ||||
|       return createElement('incidents-list', { | ||||
|         props: { | ||||
|           projectPath, | ||||
|         }, | ||||
|       }); | ||||
|     }, | ||||
|   }); | ||||
| }; | ||||
|  | @ -308,9 +308,11 @@ export function addMarkdownListeners(form) { | |||
|     .off('click') | ||||
|     .on('click', function() { | ||||
|       const $this = $(this); | ||||
|       const tag = this.dataset.mdTag; | ||||
| 
 | ||||
|       return updateText({ | ||||
|         textArea: $this.closest('.md-area').find('textarea'), | ||||
|         tag: $this.data('mdTag'), | ||||
|         tag, | ||||
|         cursorOffset: $this.data('mdCursorOffset'), | ||||
|         blockTag: $this.data('mdBlock'), | ||||
|         wrap: !$this.data('mdPrepend'), | ||||
|  |  | |||
|  | @ -0,0 +1,5 @@ | |||
| import IncidentsList from '~/incidents/list'; | ||||
| 
 | ||||
| document.addEventListener('DOMContentLoaded', () => { | ||||
|   IncidentsList(); | ||||
| }); | ||||
|  | @ -1,6 +1,8 @@ | |||
| <script> | ||||
| import $ from 'jquery'; | ||||
| import { GlPopover, GlButton, GlTooltipDirective } from '@gitlab/ui'; | ||||
| import { getSelectedFragment } from '~/lib/utils/common_utils'; | ||||
| import { CopyAsGFM } from '../../../behaviors/markdown/copy_as_gfm'; | ||||
| import ToolbarButton from './toolbar_button.vue'; | ||||
| import Icon from '../icon.vue'; | ||||
| 
 | ||||
|  | @ -35,6 +37,11 @@ export default { | |||
|       default: false, | ||||
|     }, | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       tag: '> ', | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     mdTable() { | ||||
|       return [ | ||||
|  | @ -81,6 +88,24 @@ export default { | |||
|     handleSuggestDismissed() { | ||||
|       this.$emit('handleSuggestDismissed'); | ||||
|     }, | ||||
|     handleQuote() { | ||||
|       const documentFragment = getSelectedFragment(); | ||||
| 
 | ||||
|       if (!documentFragment || !documentFragment.textContent) { | ||||
|         this.tag = '> '; | ||||
|         return; | ||||
|       } | ||||
|       this.tag = ''; | ||||
| 
 | ||||
|       const transformed = CopyAsGFM.transformGFMSelection(documentFragment); | ||||
|       const area = this.$el.parentNode.querySelector('textarea'); | ||||
| 
 | ||||
|       CopyAsGFM.nodeToGFM(transformed) | ||||
|         .then(gfm => { | ||||
|           CopyAsGFM.insertPastedText(area, documentFragment.textContent, CopyAsGFM.quoted(gfm)); | ||||
|         }) | ||||
|         .catch(() => {}); | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | @ -108,9 +133,10 @@ export default { | |||
|           <toolbar-button tag="*" :button-title="__('Add italic text')" icon="italic" /> | ||||
|           <toolbar-button | ||||
|             :prepend="true" | ||||
|             tag="> " | ||||
|             :tag="tag" | ||||
|             :button-title="__('Insert a quote')" | ||||
|             icon="quote" | ||||
|             @click="handleQuote" | ||||
|           /> | ||||
|         </div> | ||||
|         <div class="d-inline-block ml-md-2 ml-0"> | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| .alert-management-list, | ||||
| .incident-management-list, | ||||
| .alert-management-details { | ||||
|   .icon-critical { | ||||
|     color: $red-800; | ||||
|  |  | |||
|  | @ -1,11 +1,11 @@ | |||
| .alert-management-list { | ||||
| .incident-management-list { | ||||
|   .new-alert { | ||||
|     background-color: $issues-today-bg; | ||||
|   } | ||||
| 
 | ||||
|   // these styles need to be deleted once GlTable component looks in GitLab same as in @gitlab/ui | ||||
|   table { | ||||
|     color: $gray-700; | ||||
|     @include gl-text-gray-700; | ||||
| 
 | ||||
|     tr { | ||||
|       &:focus { | ||||
|  | @ -24,9 +24,9 @@ | |||
|       } | ||||
| 
 | ||||
|       th { | ||||
|         background-color: transparent; | ||||
|         font-weight: $gl-font-weight-bold; | ||||
|         color: $gl-gray-600; | ||||
|         @include gl-bg-transparent; | ||||
|         @include gl-font-weight-bold; | ||||
|         @include gl-text-gray-600; | ||||
| 
 | ||||
|         &[aria-sort='none']:hover { | ||||
|           background-image: url('data:image/svg+xml, %3csvg   xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="4 0 8 16"%3e   %3cpath style="fill: %23BABABA;" fill-rule="evenodd" d="M11.707085,11.7071   L7.999975,15.4142 L4.292875,11.7071 C3.902375,11.3166 3.902375,   10.6834 4.292875,10.2929 C4.683375,9.90237   5.316575,9.90237 5.707075,10.2929 L6.999975,   11.5858 L6.999975,2 C6.999975,1.44771   7.447695,1 7.999975,1 C8.552255,1 8.999975,1.44771   8.999975,2 L8.999975,11.5858 L10.292865,10.2929 C10.683395   ,9.90237 11.316555,9.90237 11.707085,10.2929   C12.097605,10.6834 12.097605,11.3166 11.707085,11.7071 Z"/%3e   %3c/svg%3e'); | ||||
|  | @ -46,15 +46,24 @@ | |||
|   } | ||||
| 
 | ||||
|   @include media-breakpoint-down(sm) { | ||||
|     .alert-management-table { | ||||
|     table { | ||||
|       tr { | ||||
|         border-top: 0; | ||||
|         @include gl-border-t-0; | ||||
| 
 | ||||
|         .table-col { | ||||
|           min-height: 68px; | ||||
|         } | ||||
| 
 | ||||
|         &:hover { | ||||
|           @include gl-bg-white; | ||||
|           @include gl-border-none; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       &.alert-management-table { | ||||
|         .table-col { | ||||
|           &:last-child { | ||||
|             background-color: $gray-10; | ||||
|             @include gl-bg-gray-10; | ||||
| 
 | ||||
|             &::before { | ||||
|               content: none !important; | ||||
|  | @ -66,12 +75,6 @@ | |||
|             } | ||||
|           } | ||||
|         } | ||||
| 
 | ||||
|         &:hover { | ||||
|           background-color: $white; | ||||
|           border-color: $white; | ||||
|           border-bottom-style: none; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | @ -0,0 +1,8 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class Projects::IncidentsController < Projects::ApplicationController | ||||
|   before_action :authorize_read_incidents! | ||||
| 
 | ||||
|   def index | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1,9 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Projects::IncidentsHelper | ||||
|   def incidents_data(project) | ||||
|     { | ||||
|       'project-path' => project.full_path | ||||
|     } | ||||
|   end | ||||
| end | ||||
|  | @ -465,6 +465,7 @@ module ProjectsHelper | |||
|       serverless:         :read_cluster, | ||||
|       error_tracking:     :read_sentry_issue, | ||||
|       alert_management:   :read_alert_management_alert, | ||||
|       incidents:          :read_incidents, | ||||
|       labels:             :read_label, | ||||
|       issues:             :read_issue, | ||||
|       project_members:    :read_project_member, | ||||
|  | @ -732,6 +733,8 @@ module ProjectsHelper | |||
|       functions | ||||
|       error_tracking | ||||
|       alert_management | ||||
|       incidents | ||||
|       incident_management | ||||
|       user | ||||
|       gcp | ||||
|       logs | ||||
|  |  | |||
|  | @ -28,10 +28,7 @@ module Ci | |||
|       end | ||||
| 
 | ||||
|       def size(model) | ||||
|         connection.head_object(bucket_name, key(model)) | ||||
|           .get_header('Content-Length') | ||||
|       rescue Excon::Error::NotFound | ||||
|         0 | ||||
|         data(model).to_s.bytesize | ||||
|       end | ||||
| 
 | ||||
|       def delete_data(model) | ||||
|  |  | |||
|  | @ -152,10 +152,6 @@ class Issue < ApplicationRecord | |||
|       issue.closed_at = nil | ||||
|       issue.closed_by = nil | ||||
|     end | ||||
| 
 | ||||
|     after_transition any => :closed do |issue| | ||||
|       issue.resolve_associated_alert_management_alert | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   # Alias to state machine .with_state_id method | ||||
|  | @ -369,18 +365,6 @@ class Issue < ApplicationRecord | |||
|     @design_collection ||= ::DesignManagement::DesignCollection.new(self) | ||||
|   end | ||||
| 
 | ||||
|   def resolve_associated_alert_management_alert | ||||
|     return unless alert_management_alert | ||||
|     return if alert_management_alert.resolve | ||||
| 
 | ||||
|     Gitlab::AppLogger.warn( | ||||
|       message: 'Cannot resolve an associated Alert Management alert', | ||||
|       issue_id: id, | ||||
|       alert_id: alert_management_alert.id, | ||||
|       alert_errors: alert_management_alert.errors.messages | ||||
|     ) | ||||
|   end | ||||
| 
 | ||||
|   def from_service_desk? | ||||
|     author.id == User.support_bot.id | ||||
|   end | ||||
|  |  | |||
|  | @ -451,6 +451,16 @@ class Project < ApplicationRecord | |||
|   # Sometimes queries (e.g. using CTEs) require explicit disambiguation with table name | ||||
|   scope :projects_order_id_desc, -> { reorder(self.arel_table['id'].desc) } | ||||
| 
 | ||||
|   scope :sorted_by_similarity_desc, -> (search) do | ||||
|     order_expression = Gitlab::Database::SimilarityScore.build_expression(search: search, rules: [ | ||||
|       { column: arel_table["path"], multiplier: 1 }, | ||||
|       { column: arel_table["name"], multiplier: 0.7 }, | ||||
|       { column: arel_table["description"], multiplier: 0.2 } | ||||
|     ]) | ||||
| 
 | ||||
|     reorder(order_expression.desc, arel_table['id'].desc) | ||||
|   end | ||||
| 
 | ||||
|   scope :with_packages, -> { joins(:packages) } | ||||
|   scope :in_namespace, ->(namespace_ids) { where(namespace_id: namespace_ids) } | ||||
|   scope :personal, ->(user) { where(namespace_id: user.namespace_id) } | ||||
|  |  | |||
|  | @ -258,6 +258,7 @@ class ProjectPolicy < BasePolicy | |||
|     enable :read_merge_request | ||||
|     enable :read_sentry_issue | ||||
|     enable :update_sentry_issue | ||||
|     enable :read_incidents | ||||
|     enable :read_prometheus | ||||
|     enable :read_metrics_dashboard_annotation | ||||
|     enable :metrics_dashboard | ||||
|  |  | |||
|  | @ -33,6 +33,7 @@ module Issues | |||
| 
 | ||||
|         notification_service.async.close_issue(issue, current_user, closed_via: closed_via) if notifications | ||||
|         todo_service.close_issue(issue, current_user) | ||||
|         resolve_alert(issue) | ||||
|         execute_hooks(issue, 'close') | ||||
|         invalidate_cache_counts(issue, users: issue.assignees) | ||||
|         issue.update_project_counter_caches | ||||
|  | @ -58,6 +59,22 @@ module Issues | |||
|       SystemNoteService.change_status(issue, issue.project, current_user, issue.state, current_commit) | ||||
|     end | ||||
| 
 | ||||
|     def resolve_alert(issue) | ||||
|       return unless alert = issue.alert_management_alert | ||||
|       return if alert.resolved? | ||||
| 
 | ||||
|       if alert.resolve | ||||
|         SystemNotes::AlertManagementService.new(noteable: alert, project: alert.project, author: current_user).closed_alert_issue(issue) | ||||
|       else | ||||
|         Gitlab::AppLogger.warn( | ||||
|           message: 'Cannot resolve an associated Alert Management alert', | ||||
|           issue_id: issue.id, | ||||
|           alert_id: alert.id, | ||||
|           alert_errors: alert.errors.messages | ||||
|         ) | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     def store_first_mentioned_in_commit_at(issue, merge_request) | ||||
|       metrics = issue.metrics | ||||
|       return if metrics.nil? || metrics.first_mentioned_in_commit_at | ||||
|  |  | |||
|  | @ -297,7 +297,7 @@ module SystemNoteService | |||
|   end | ||||
| 
 | ||||
|   def new_alert_issue(alert, issue, author) | ||||
|     ::SystemNotes::AlertManagementService.new(noteable: alert, project: alert.project, author: author).new_alert_issue(alert, issue) | ||||
|     ::SystemNotes::AlertManagementService.new(noteable: alert, project: alert.project, author: author).new_alert_issue(issue) | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
|  |  | |||
|  | @ -12,7 +12,7 @@ module SystemNotes | |||
|     # | ||||
|     # Returns the created Note object | ||||
|     def change_alert_status(alert) | ||||
|       status = AlertManagement::Alert::STATUSES.key(alert.status).to_s.titleize | ||||
|       status = alert.state.to_s.titleize | ||||
|       body = "changed the status to **#{status}**" | ||||
| 
 | ||||
|       create_note(NoteSummary.new(noteable, project, author, body, action: 'status')) | ||||
|  | @ -20,7 +20,6 @@ module SystemNotes | |||
| 
 | ||||
|     # Called when an issue is created based on an AlertManagement::Alert | ||||
|     # | ||||
|     # alert - AlertManagement::Alert object. | ||||
|     # issue - Issue object. | ||||
|     # | ||||
|     # Example Note text: | ||||
|  | @ -28,10 +27,25 @@ module SystemNotes | |||
|     #   "created issue #17 for this alert" | ||||
|     # | ||||
|     # Returns the created Note object | ||||
|     def new_alert_issue(alert, issue) | ||||
|     def new_alert_issue(issue) | ||||
|       body = "created issue #{issue.to_reference(project)} for this alert" | ||||
| 
 | ||||
|       create_note(NoteSummary.new(noteable, project, author, body, action: 'alert_issue_added')) | ||||
|     end | ||||
| 
 | ||||
|     # Called when an AlertManagement::Alert is resolved due to the associated issue being closed | ||||
|     # | ||||
|     # issue - Issue object. | ||||
|     # | ||||
|     # Example Note text: | ||||
|     # | ||||
|     #   "changed the status to Resolved by closing issue #17" | ||||
|     # | ||||
|     # Returns the created Note object | ||||
|     def closed_alert_issue(issue) | ||||
|       body = "changed the status to **Resolved** by closing issue #{issue.to_reference(project)}" | ||||
| 
 | ||||
|       create_note(NoteSummary.new(noteable, project, author, body, action: 'status')) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -228,10 +228,16 @@ | |||
| 
 | ||||
|             - if project_nav_tab?(:alert_management) | ||||
|               = nav_link(controller: :alert_management) do | ||||
|                 = link_to project_alert_management_index_path(@project), title: _('Alerts'), class: 'shortcuts-tracking qa-operations-tracking-link' do | ||||
|                 = link_to project_alert_management_index_path(@project), title: _('Alerts') do | ||||
|                   %span | ||||
|                     = _('Alerts') | ||||
| 
 | ||||
|               - if project_nav_tab?(:incidents) | ||||
|                 = nav_link(controller: :incidents) do | ||||
|                   = link_to project_incidents_path(@project), title: _('Incidents') do | ||||
|                     %span | ||||
|                       = _('Incidents') | ||||
| 
 | ||||
|             - if project_nav_tab? :environments | ||||
|               = render_if_exists "layouts/nav/sidebar/tracing_link" | ||||
| 
 | ||||
|  | @ -242,7 +248,7 @@ | |||
| 
 | ||||
|             - if project_nav_tab?(:error_tracking) | ||||
|               = nav_link(controller: :error_tracking) do | ||||
|                 = link_to project_error_tracking_index_path(@project), title: _('Error Tracking'), class: 'shortcuts-tracking qa-operations-tracking-link' do | ||||
|                 = link_to project_error_tracking_index_path(@project), title: _('Error Tracking') do | ||||
|                   %span | ||||
|                     = _('Error Tracking') | ||||
| 
 | ||||
|  |  | |||
|  | @ -0,0 +1,3 @@ | |||
| - page_title _('Incidents') | ||||
| 
 | ||||
| #js-incidents{ data: incidents_data(@project) } | ||||
|  | @ -0,0 +1,5 @@ | |||
| --- | ||||
| title: Add system note to alert when corresponding issue is closed | ||||
| merge_request: 37039 | ||||
| author: | ||||
| type: added | ||||
|  | @ -0,0 +1,5 @@ | |||
| --- | ||||
| title: Add basic incidents list | ||||
| merge_request: 37314 | ||||
| author: | ||||
| type: added | ||||
|  | @ -300,6 +300,8 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do | |||
| 
 | ||||
|         post 'incidents/integrations/pagerduty', to: 'incident_management/pager_duty_incidents#create' | ||||
| 
 | ||||
|         resources :incidents, only: [:index] | ||||
| 
 | ||||
|         namespace :error_tracking do | ||||
|           resources :projects, only: :index | ||||
|         end | ||||
|  |  | |||
|  | @ -184,7 +184,7 @@ Parameters: | |||
| | `id`                          | integer/string | yes      | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | | ||||
| | `archived`                    | boolean        | no       | Limit by archived status | | ||||
| | `visibility`                  | string         | no       | Limit by visibility `public`, `internal`, or `private` | | ||||
| | `order_by`                    | string         | no       | Return projects ordered by `id`, `name`, `path`, `created_at`, `updated_at`, or `last_activity_at` fields. Default is `created_at` | | ||||
| | `order_by`                    | string         | no       | Return projects ordered by `id`, `name`, `path`, `created_at`, `updated_at`, `similarity` (1), or `last_activity_at` fields. Default is `created_at` | | ||||
| | `sort`                        | string         | no       | Return projects sorted in `asc` or `desc` order. Default is `desc` | | ||||
| | `search`                      | string         | no       | Return list of authorized projects matching the search criteria | | ||||
| | `simple`                      | boolean        | no       | Return only the ID, URL, name, and path of each project | | ||||
|  | @ -198,6 +198,13 @@ Parameters: | |||
| | `with_custom_attributes`      | boolean        | no       | Include [custom attributes](custom_attributes.md) in response (admins only) | | ||||
| | `with_security_reports`       | boolean        | no       | **(ULTIMATE)** Return only projects that have security reports artifacts present in any of their builds. This means "projects with security reports enabled". Default is `false` | | ||||
| 
 | ||||
| 1. Order by similarity: Orders the results by a similarity score calculated from the provided `search` | ||||
| URL parameter. This is an [alpha](https://about.gitlab.com/handbook/product/gitlab-the-product/#alpha) feature [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/221043) in GitLab 13.3. | ||||
| 
 | ||||
|    The feature is behind a feature flag, you can [enable it](../administration/feature_flags.md#enable-or-disable-the-feature) | ||||
| with the `similarity_search` flag. When using `order_by=similarity` the `sort` parameter is | ||||
| ignored. When the `search` parameter is not provided, the API returns the projects ordered by `name`. | ||||
| 
 | ||||
| Example response: | ||||
| 
 | ||||
| ```json | ||||
|  |  | |||
|  | @ -76,7 +76,7 @@ module API | |||
|           params: project_finder_params, | ||||
|           options: finder_options | ||||
|         ).execute | ||||
|         projects = reorder_projects(projects) | ||||
|         projects = reorder_projects_with_similarity_order_support(group, projects) | ||||
|         paginate(projects) | ||||
|       end | ||||
| 
 | ||||
|  | @ -112,6 +112,24 @@ module API | |||
| 
 | ||||
|         accepted! | ||||
|       end | ||||
| 
 | ||||
|       def reorder_projects_with_similarity_order_support(group, projects) | ||||
|         return handle_similarity_order(group, projects) if params[:order_by] == 'similarity' | ||||
| 
 | ||||
|         reorder_projects(projects) | ||||
|       end | ||||
| 
 | ||||
|       # rubocop: disable CodeReuse/ActiveRecord | ||||
|       def handle_similarity_order(group, projects) | ||||
|         if params[:search].present? && Feature.enabled?(:similarity_search, group) | ||||
|           projects.sorted_by_similarity_desc(params[:search]) | ||||
|         else | ||||
|           order_options = { name: :asc } | ||||
|           order_options['id'] ||= params[:sort] || 'asc' | ||||
|           projects.reorder(order_options) | ||||
|         end | ||||
|       end | ||||
|       # rubocop: enable CodeReuse/ActiveRecord | ||||
|     end | ||||
| 
 | ||||
|     resource :groups do | ||||
|  | @ -222,7 +240,7 @@ module API | |||
|         optional :visibility, type: String, values: Gitlab::VisibilityLevel.string_values, | ||||
|                               desc: 'Limit by visibility' | ||||
|         optional :search, type: String, desc: 'Return list of authorized projects matching the search criteria' | ||||
|         optional :order_by, type: String, values: %w[id name path created_at updated_at last_activity_at], | ||||
|         optional :order_by, type: String, values: %w[id name path created_at updated_at last_activity_at similarity], | ||||
|                             default: 'created_at', desc: 'Return projects ordered by field' | ||||
|         optional :sort, type: String, values: %w[asc desc], default: 'desc', | ||||
|                         desc: 'Return projects sorted in ascending and descending order' | ||||
|  |  | |||
|  | @ -0,0 +1,110 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Gitlab | ||||
|   module Database | ||||
|     class SimilarityScore | ||||
|       EMPTY_STRING = Arel.sql("''").freeze | ||||
|       EXPRESSION_ON_INVALID_INPUT = Arel::Nodes::NamedFunction.new('CAST', [Arel.sql('0').as('integer')]).freeze | ||||
|       DEFAULT_MULTIPLIER = 1 | ||||
| 
 | ||||
|       # This method returns an Arel expression that can be used in an ActiveRecord query to order the resultset by similarity. | ||||
|       # | ||||
|       # Note: Calculating similarity score for large volume of records is inefficient. use SimilarityScore only for smaller | ||||
|       # resultset which is already filtered by other conditions (< 10_000 records). | ||||
|       # | ||||
|       # ==== Parameters | ||||
|       # * +search+ - [String] the user provided search string | ||||
|       # * +rules+ - [{ column: COLUMN, multiplier: 1 }, { column: COLUMN_2, multiplier: 0.5 }] rules for the scoring. | ||||
|       #   * +column+ - Arel column expression, example: Project.arel_table["name"] | ||||
|       #   * +multiplier+ - Integer or Float to increase or decrease the score (optional, defaults to 1) | ||||
|       # | ||||
|       # ==== Use case | ||||
|       # | ||||
|       # We'd like to search for projects by path, name and description. We want to rank higher the path and name matches, since | ||||
|       # it's more likely that the user was remembering the path or the name of the project. | ||||
|       # | ||||
|       # Rules: | ||||
|       #   [ | ||||
|       #     { column: Project.arel_table['path'], multiplier: 1 }, | ||||
|       #     { column: Project.arel_table['name'], multiplier: 1 }, | ||||
|       #     { column: Project.arel_table['description'], multiplier: 0.5 } | ||||
|       #   ] | ||||
|       # | ||||
|       # ==== Examples | ||||
|       # | ||||
|       #  Similarity calculation based on one column: | ||||
|       # | ||||
|       #  Gitlab::Database::SimilarityScore.build_expession(search: 'my input', rules: [{ column: Project.arel_table['name'] }]) | ||||
|       # | ||||
|       #  Similarity calculation based on two column, where the second column has lower priority: | ||||
|       # | ||||
|       #  Gitlab::Database::SimilarityScore.build_expession(search: 'my input', rules: [ | ||||
|       #    { column: Project.arel_table['name'], multiplier: 1 }, | ||||
|       #    { column: Project.arel_table['description'], multiplier: 0.5 } | ||||
|       #  ]) | ||||
|       # | ||||
|       #  Integration with an ActiveRecord query: | ||||
|       # | ||||
|       #  table = Project.arel_table | ||||
|       # | ||||
|       #  order_expression = Gitlab::Database::SimilarityScore.build_expession(search: 'input', rules: [ | ||||
|       #    { column: table['name'], multiplier: 1 }, | ||||
|       #    { column: table['description'], multiplier: 0.5 } | ||||
|       #  ]) | ||||
|       # | ||||
|       #  Project.where("name LIKE ?", '%' + 'input' + '%').order(order_expression.desc) | ||||
|       # | ||||
|       #  The expression can be also used in SELECT: | ||||
|       # | ||||
|       #  results = Project.select(order_expression.as('similarity')).where("name LIKE ?", '%' + 'input' + '%').order(similarity: :desc) | ||||
|       #  puts results.map(&:similarity) | ||||
|       # | ||||
|       def self.build_expression(search:, rules:) | ||||
|         return EXPRESSION_ON_INVALID_INPUT if search.blank? || rules.empty? | ||||
| 
 | ||||
|         quoted_search = ActiveRecord::Base.connection.quote(search.to_s) | ||||
| 
 | ||||
|         first_expression, *expressions = rules.map do |rule| | ||||
|           rule_to_arel(quoted_search, rule) | ||||
|         end | ||||
| 
 | ||||
|         # (SIMILARITY ...) + (SIMILARITY ...) | ||||
|         expressions.inject(first_expression) do |expression1, expression2| | ||||
|           Arel::Nodes::Addition.new(expression1, expression2) | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       # (SIMILARITY(COALESCE(column, ''), 'search_string') * CAST(multiplier AS numeric)) | ||||
|       def self.rule_to_arel(search, rule) | ||||
|         Arel::Nodes::Grouping.new( | ||||
|           Arel::Nodes::Multiplication.new( | ||||
|             similarity_function_call(search, column_expression(rule)), | ||||
|             multiplier_expression(rule) | ||||
|           ) | ||||
|         ) | ||||
|       end | ||||
| 
 | ||||
|       # COALESCE(column, '') | ||||
|       def self.column_expression(rule) | ||||
|         Arel::Nodes::NamedFunction.new('COALESCE', [rule.fetch(:column), EMPTY_STRING]) | ||||
|       end | ||||
| 
 | ||||
|       # SIMILARITY(COALESCE(column, ''), 'search_string') | ||||
|       def self.similarity_function_call(search, column) | ||||
|         Arel::Nodes::NamedFunction.new('SIMILARITY', [column, Arel.sql(search)]) | ||||
|       end | ||||
| 
 | ||||
|       # CAST(multiplier AS numeric) | ||||
|       def self.multiplier_expression(rule) | ||||
|         quoted_multiplier = ActiveRecord::Base.connection.quote(rule.fetch(:multiplier, DEFAULT_MULTIPLIER).to_s) | ||||
| 
 | ||||
|         Arel::Nodes::NamedFunction.new('CAST', [Arel.sql(quoted_multiplier).as('numeric')]) | ||||
|       end | ||||
| 
 | ||||
|       private_class_method :rule_to_arel | ||||
|       private_class_method :column_expression | ||||
|       private_class_method :similarity_function_call | ||||
|       private_class_method :multiplier_expression | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1,14 @@ | |||
| { | ||||
|   "type": "object", | ||||
|   "properties": { | ||||
|     "name": { "type": "string" }, | ||||
|     "format": { | ||||
|       "type": "string", | ||||
|       "default": "engineering" | ||||
|     }, | ||||
|     "precision": { | ||||
|       "type": "number", | ||||
|       "default": 2 | ||||
|      } | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,18 @@ | |||
| { | ||||
|   "type": "object", | ||||
|   "required": ["dashboard", "panel_groups"], | ||||
|   "properties": { | ||||
|     "dashboard": { "type": "string" }, | ||||
|     "panel_groups": { | ||||
|       "type": "array", | ||||
|       "items": { "$ref": "./panel_group.json" } | ||||
|     }, | ||||
|     "templating": { | ||||
|       "$ref": "./templating.json" | ||||
|     }, | ||||
|     "links": { | ||||
|       "type": "array", | ||||
|       "items": { "$ref": "./link.json" } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,12 @@ | |||
| { | ||||
|   "type": "object", | ||||
|   "required": ["url"], | ||||
|   "properties": { | ||||
|     "url": { "type": "string" }, | ||||
|     "title": { "type": "string" }, | ||||
|     "type": { | ||||
|       "type": "string", | ||||
|       "enum": ["grafana"] | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,16 @@ | |||
| { | ||||
| 	"type": "object", | ||||
| 	"required": ["unit"], | ||||
| 	"oneOf": [{ "required": ["query"] }, { "required": ["query_range"] }], | ||||
| 	"properties": { | ||||
| 		"id": { | ||||
| 			"type": "string", | ||||
| 			"format": "add_to_metric_id_cache" | ||||
| 		 }, | ||||
| 		"unit": { "type": "string" }, | ||||
| 		"label": { "type": "string" }, | ||||
| 		"query": { "type": "string" }, | ||||
| 		"query_range": { "type": "string" }, | ||||
| 		"step": { "type": "number" } | ||||
| 	} | ||||
| } | ||||
|  | @ -0,0 +1,24 @@ | |||
| { | ||||
|   "type": "object", | ||||
|   "required": ["title", "metrics"], | ||||
|   "properties": { | ||||
|     "type": { | ||||
|       "type": "string", | ||||
|       "enum": ["area-chart", "anomaly-chart", "bar", "column", "stacked-column", "single-stat", "heatmap"], | ||||
|       "default": "area-chart" | ||||
|     }, | ||||
|     "title": { "type": "string" }, | ||||
|     "y_label": { "type": "string" }, | ||||
|     "y_axis": { "$ref": "./axis.json" }, | ||||
|     "max_value": { "type": "number" }, | ||||
|     "weight": { "type": "number" }, | ||||
|     "metrics": { | ||||
|       "type": "array", | ||||
|       "items": { "$ref": "./metric.json" } | ||||
|     }, | ||||
|     "links": { | ||||
|       "type": "array", | ||||
|       "items": { "$ref": "./link.json" } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,12 @@ | |||
| { | ||||
|   "type": "object", | ||||
|   "required": ["group", "panels"], | ||||
|   "properties": { | ||||
|     "group": { "type": "string" }, | ||||
|     "priority": { "type": "number" }, | ||||
|     "panels": { | ||||
|       "type": "array", | ||||
|       "items": { "$ref": "./panel.json" } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,7 @@ | |||
| { | ||||
|   "type": "object", | ||||
|   "required": ["variables"], | ||||
|   "properties": { | ||||
|     "variables": { "type": "object" } | ||||
|   } | ||||
| } | ||||
|  | @ -6860,6 +6860,9 @@ msgstr "" | |||
| msgid "Create Project" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Create Value Stream" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Create a GitLab account first, and then connect it to your %{label} account." | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -6956,6 +6959,9 @@ msgstr "" | |||
| msgid "Create new" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Create new Value Stream" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Create new board" | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -6977,9 +6983,6 @@ msgstr "" | |||
| msgid "Create new label" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Create new value stream" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Create new..." | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -6995,9 +6998,6 @@ msgstr "" | |||
| msgid "Create snippet" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Create value stream" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Create wildcard: %{searchTerm}" | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -7456,21 +7456,36 @@ msgstr "" | |||
| msgid "DastProfiles|Do you want to discard this site profile?" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "DastProfiles|Manage Profiles" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "DastProfiles|Manage profiles" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "DastProfiles|New Site Profile" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "DastProfiles|New site profile" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "DastProfiles|No profiles created yet" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "DastProfiles|Please enter a valid URL format, ex: http://www.example.com/home" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "DastProfiles|Profile name" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "DastProfiles|Save commonly used configurations for target sites and scan specifications as profiles. Use these with an on-demand scan." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "DastProfiles|Save profile" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "DastProfiles|Site Profiles" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "DastProfiles|Target URL" | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -9709,7 +9724,7 @@ msgstr "" | |||
| msgid "Example: @sub\\.company\\.com$" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Example: My value stream" | ||||
| msgid "Example: My Value Stream" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Example: Usage = single query. (Requested) / (Capacity) = multiple queries combined into a formula." | ||||
|  | @ -12641,6 +12656,27 @@ msgstr "" | |||
| msgid "Incident Management Limits" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "IncidentManagement|Assignees" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "IncidentManagement|Date created" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "IncidentManagement|Incident" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "IncidentManagement|Incidents" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "IncidentManagement|No incidents to display." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "IncidentManagement|There was an error displaying the incidents." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "IncidentManagement|Unassigned" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "IncidentSettings|Alert integration" | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -12656,6 +12692,9 @@ msgstr "" | |||
| msgid "IncidentSettings|Set up integrations with external tools to help better manage incidents." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Incidents" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Include a Terms of Service agreement and Privacy Policy that all users must accept." | ||||
| msgstr "" | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										25
									
								
								qa/README.md
								
								
								
								
							
							
						
						
									
										25
									
								
								qa/README.md
								
								
								
								
							|  | @ -178,11 +178,13 @@ another test has `:ldap` and `:quarantine` metadata. If the tests are run with | |||
| `--tag smoke --tag quarantine`, only the first test will run. The test with | ||||
| `:ldap` will not run even though it also has `:quarantine`. | ||||
| 
 | ||||
| ### Running tests with a feature flag enabled | ||||
| ### Running tests with a feature flag enabled or disabled | ||||
| 
 | ||||
| Tests can be run with with a feature flag enabled by using the command-line | ||||
| option `--enable-feature FEATURE_FLAG`. For example, to enable the feature flag | ||||
| that enforces Gitaly request limits, you would use the command: | ||||
| Tests can be run with with a feature flag enabled or disabled by using the command-line | ||||
| option `--enable-feature FEATURE_FLAG` or `--disable-feature FEATURE_FLAG`. | ||||
| 
 | ||||
| For example, to enable the feature flag that enforces Gitaly request limits, | ||||
| you would use the command: | ||||
| 
 | ||||
| ``` | ||||
| bundle exec bin/qa Test::Instance::All http://localhost:3000 --enable-feature gitaly_enforce_requests_limits | ||||
|  | @ -193,9 +195,20 @@ feature flag ([via the API](https://docs.gitlab.com/ee/api/features.html)), run | |||
| all the tests in the `Test::Instance::All` scenario, and then disable the | ||||
| feature flag again. | ||||
| 
 | ||||
| Similarly, to disable the feature flag that enforces Gitaly request limits, | ||||
| you would use the command: | ||||
| 
 | ||||
| ``` | ||||
| bundle exec bin/qa Test::Instance::All http://localhost:3000 --disable-feature gitaly_enforce_requests_limits | ||||
| ``` | ||||
| This will instruct the QA framework to disable the `gitaly_enforce_requests_limits` | ||||
| feature flag ([via the API](https://docs.gitlab.com/ee/api/features.html)) if not already disabled, | ||||
| run all the tests in the `Test::Instance::All` scenario, and then enable the | ||||
| feature flag again if it was enabled earlier. | ||||
| 
 | ||||
| Note: the QA framework doesn't currently allow you to easily toggle a feature | ||||
| flag during a single test, [as you can in unit tests](https://docs.gitlab.com/ee/development/feature_flags.html#specs), | ||||
| but [that capability is planned](https://gitlab.com/gitlab-org/quality/team-tasks/issues/77). | ||||
| 
 | ||||
| Note also that the `--` separator isn't used because `--enable-feature` is a QA | ||||
| framework option, not an `rspec` option. | ||||
| Note also that the `--` separator isn't used because `--enable-feature` and `--disable-feature` | ||||
| are QA framework options, not `rspec` options. | ||||
|  |  | |||
|  | @ -7,6 +7,7 @@ module QA | |||
| 
 | ||||
|       attribute :gitlab_address, '--address URL', 'Address of the instance to test' | ||||
|       attribute :enable_feature, '--enable-feature FEATURE_FLAG', 'Enable a feature before running tests' | ||||
|       attribute :disable_feature, '--disable-feature FEATURE_FLAG', 'Disable a feature before running tests' | ||||
|       attribute :parallel, '--parallel', 'Execute tests in parallel' | ||||
|       attribute :loop, '--loop', 'Execute test repeatedly' | ||||
|     end | ||||
|  |  | |||
|  | @ -30,6 +30,8 @@ module QA | |||
| 
 | ||||
|         Runtime::Feature.enable(options[:enable_feature]) if options.key?(:enable_feature) | ||||
| 
 | ||||
|         Runtime::Feature.disable(options[:disable_feature]) if options.key?(:disable_feature) && (@feature_enabled = Runtime::Feature.enabled?(options[:disable_feature])) | ||||
| 
 | ||||
|         Specs::Runner.perform do |specs| | ||||
|           specs.tty = true | ||||
|           specs.tags = self.class.focus | ||||
|  | @ -37,6 +39,7 @@ module QA | |||
|         end | ||||
|       ensure | ||||
|         Runtime::Feature.disable(options[:enable_feature]) if options.key?(:enable_feature) | ||||
|         Runtime::Feature.enable(options[:disable_feature]) if options.key?(:disable_feature) && @feature_enabled | ||||
|       end | ||||
| 
 | ||||
|       def extract_option(name, options, args) | ||||
|  |  | |||
|  | @ -17,6 +17,24 @@ describe QA::Scenario::Template do | |||
|     expect(feature).to have_received(:enable).with('a-feature') | ||||
|   end | ||||
| 
 | ||||
|   it 'allows a feature to be disabled' do | ||||
|     allow(QA::Runtime::Feature).to receive(:enabled?) | ||||
|                                      .with('another-feature').and_return(true) | ||||
| 
 | ||||
|     subject.perform({ disable_feature: 'another-feature' }) | ||||
| 
 | ||||
|     expect(feature).to have_received(:disable).with('another-feature') | ||||
|   end | ||||
| 
 | ||||
|   it 'does not disable a feature if already disabled' do | ||||
|     allow(QA::Runtime::Feature).to receive(:enabled?) | ||||
|                                      .with('another-feature').and_return(false) | ||||
| 
 | ||||
|     subject.perform({ disable_feature: 'another-feature' }) | ||||
| 
 | ||||
|     expect(feature).not_to have_received(:disable).with('another-feature') | ||||
|   end | ||||
| 
 | ||||
|   it 'ensures an enabled feature is disabled afterwards' do | ||||
|     allow(QA::Specs::Runner).to receive(:perform).and_raise('failed test') | ||||
| 
 | ||||
|  | @ -25,4 +43,28 @@ describe QA::Scenario::Template do | |||
|     expect(feature).to have_received(:enable).with('a-feature') | ||||
|     expect(feature).to have_received(:disable).with('a-feature') | ||||
|   end | ||||
| 
 | ||||
|   it 'ensures a disabled feature is enabled afterwards' do | ||||
|     allow(QA::Specs::Runner).to receive(:perform).and_raise('failed test') | ||||
| 
 | ||||
|     allow(QA::Runtime::Feature).to receive(:enabled?) | ||||
|                                      .with('another-feature').and_return(true) | ||||
| 
 | ||||
|     expect { subject.perform({ disable_feature: 'another-feature' }) }.to raise_error('failed test') | ||||
| 
 | ||||
|     expect(feature).to have_received(:disable).with('another-feature') | ||||
|     expect(feature).to have_received(:enable).with('another-feature') | ||||
|   end | ||||
| 
 | ||||
|   it 'ensures a disabled feature is not enabled afterwards if it was disabled earlier' do | ||||
|     allow(QA::Specs::Runner).to receive(:perform).and_raise('failed test') | ||||
| 
 | ||||
|     allow(QA::Runtime::Feature).to receive(:enabled?) | ||||
|                                      .with('another-feature').and_return(false) | ||||
| 
 | ||||
|     expect { subject.perform({ disable_feature: 'another-feature' }) }.to raise_error('failed test') | ||||
| 
 | ||||
|     expect(feature).not_to have_received(:disable).with('another-feature') | ||||
|     expect(feature).not_to have_received(:enable).with('another-feature') | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -0,0 +1,46 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'spec_helper' | ||||
| 
 | ||||
| RSpec.describe Projects::IncidentsController do | ||||
|   let_it_be(:project) { create(:project) } | ||||
|   let_it_be(:developer) { create(:user) } | ||||
|   let_it_be(:guest) { create(:user) } | ||||
| 
 | ||||
|   before_all do | ||||
|     project.add_developer(developer) | ||||
|     project.add_guest(guest) | ||||
|   end | ||||
| 
 | ||||
|   describe 'GET #index' do | ||||
|     def make_request | ||||
|       get :index, params: { namespace_id: project.namespace, project_id: project } | ||||
|     end | ||||
| 
 | ||||
|     it 'shows the page for user with developer role' do | ||||
|       sign_in(developer) | ||||
|       make_request | ||||
| 
 | ||||
|       expect(response).to have_gitlab_http_status(:ok) | ||||
|       expect(response).to render_template(:index) | ||||
|     end | ||||
| 
 | ||||
|     context 'when user is unauthorized' do | ||||
|       it 'redirects to the login page' do | ||||
|         sign_out(developer) | ||||
|         make_request | ||||
| 
 | ||||
|         expect(response).to redirect_to(new_user_session_path) | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'when user is a guest' do | ||||
|       it 'shows 404' do | ||||
|         sign_in(guest) | ||||
|         make_request | ||||
| 
 | ||||
|         expect(response).to have_gitlab_http_status(:not_found) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -123,4 +123,14 @@ describe('CopyAsGFM', () => { | |||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('CopyAsGFM.quoted', () => { | ||||
|     const sampleGFM = '* List 1\n* List 2\n\n`Some code`'; | ||||
| 
 | ||||
|     it('adds quote char `> ` to each line', done => { | ||||
|       const expectedQuotedGFM = '> * List 1\n> * List 2\n> \n> `Some code`'; | ||||
|       expect(CopyAsGFM.quoted(sampleGFM)).toEqual(expectedQuotedGFM); | ||||
|       done(); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -0,0 +1,78 @@ | |||
| import { mount } from '@vue/test-utils'; | ||||
| import { GlAlert, GlLoadingIcon, GlTable } from '@gitlab/ui'; | ||||
| import IncidentsList from '~/incidents/components/incidents_list.vue'; | ||||
| import { I18N } from '~/incidents/constants'; | ||||
| 
 | ||||
| describe('Incidents List', () => { | ||||
|   let wrapper; | ||||
| 
 | ||||
|   const findTable = () => wrapper.find(GlTable); | ||||
|   const findTableRows = () => wrapper.findAll('table tbody tr'); | ||||
|   const findAlert = () => wrapper.find(GlAlert); | ||||
|   const findLoader = () => wrapper.find(GlLoadingIcon); | ||||
| 
 | ||||
|   function mountComponent({ data = { incidents: [] }, loading = false }) { | ||||
|     wrapper = mount(IncidentsList, { | ||||
|       data() { | ||||
|         return data; | ||||
|       }, | ||||
|       mocks: { | ||||
|         $apollo: { | ||||
|           queries: { | ||||
|             incidents: { | ||||
|               loading, | ||||
|             }, | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|       provide: { | ||||
|         projectPath: '/project/path', | ||||
|       }, | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     if (wrapper) { | ||||
|       wrapper.destroy(); | ||||
|       wrapper = null; | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   it('shows the loading state', () => { | ||||
|     mountComponent({ | ||||
|       props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, | ||||
|       loading: true, | ||||
|     }); | ||||
|     expect(findLoader().exists()).toBe(true); | ||||
|   }); | ||||
| 
 | ||||
|   it('shows empty state', () => { | ||||
|     mountComponent({ | ||||
|       data: { incidents: [] }, | ||||
|       loading: false, | ||||
|     }); | ||||
|     expect(findTable().text()).toContain(I18N.noIncidents); | ||||
|   }); | ||||
| 
 | ||||
|   it('shows error state', () => { | ||||
|     mountComponent({ | ||||
|       data: { incidents: [], errored: true }, | ||||
|       loading: false, | ||||
|     }); | ||||
|     expect(findTable().text()).toContain(I18N.noIncidents); | ||||
|     expect(findAlert().exists()).toBe(true); | ||||
|   }); | ||||
| 
 | ||||
|   it('displays basic list', () => { | ||||
|     const incidents = [ | ||||
|       { title: 1, assignees: [] }, | ||||
|       { title: 2, assignees: [] }, | ||||
|       { title: 3, assignees: [] }, | ||||
|     ]; | ||||
|     mountComponent({ | ||||
|       data: { incidents }, | ||||
|       loading: false, | ||||
|     }); | ||||
|     expect(findTableRows().length).toBe(incidents.length); | ||||
|   }); | ||||
| }); | ||||
|  | @ -0,0 +1,18 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'spec_helper' | ||||
| 
 | ||||
| RSpec.describe Projects::IncidentsHelper do | ||||
|   include Gitlab::Routing.url_helpers | ||||
| 
 | ||||
|   let(:project) { create(:project) } | ||||
|   let(:project_path) { project.full_path } | ||||
| 
 | ||||
|   describe '#incidents_data' do | ||||
|     subject(:data) { helper.incidents_data(project) } | ||||
| 
 | ||||
|     it 'returns frontend configuration' do | ||||
|       expect(data).to match('project-path' => project_path) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1,93 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'spec_helper' | ||||
| 
 | ||||
| RSpec.describe Gitlab::Database::SimilarityScore do | ||||
|   let(:search) { '' } | ||||
|   let(:query_result) { ActiveRecord::Base.connection.execute(query).to_a } | ||||
| 
 | ||||
|   let(:query) do | ||||
|     # In memory query, with the id as the tie breaker. | ||||
|     <<-SQL | ||||
|       SELECT *, #{order_expression} AS similarity | ||||
|         FROM ( | ||||
|           VALUES (1,   'Git',            'git',            'git source code mirror. this is a publish-only repository.'), | ||||
|                  (2,   'GitLab Runner',  'gitlab-runner',  'official helm chart for the gitlab runner'), | ||||
|                  (3,   'gitaly',         'gitaly',         'gitaly is a git rpc service for handling all the git calls made by gitlab'), | ||||
|                  (4,   'GitLab',         'gitlab',         'gitlab is an open source end-to-end software development platform with built-in version control'), | ||||
|                  (5,   'Gitlab Danger',  'gitlab-danger',  'this gem provides common dangerfile and plugins for gitlab projects'), | ||||
|                  (6,   'different',      'same',           'same'), | ||||
|                  (7,   'same',           'different',      'same'), | ||||
|                  (8,   'gitlab-styles',  'gitlab-styles',  'gitlab style guides and shared style configs.'), | ||||
|                  (9,   '🔒 gitaly',      'gitaly-sec',     'security mirror for gitaly') | ||||
|         ) tbl    (id,  name,             path,             descrption) ORDER BY #{order_expression} DESC, id DESC; | ||||
|     SQL | ||||
|   end | ||||
| 
 | ||||
|   let(:order_expression) do | ||||
|     Gitlab::Database::SimilarityScore.build_expression(search: search, rules: [{ column: Arel.sql('path') }]).to_sql | ||||
|   end | ||||
| 
 | ||||
|   subject { query_result.take(3).map { |row| row['path'] } } | ||||
| 
 | ||||
|   context 'when passing empty values' do | ||||
|     context 'when search is nil' do | ||||
|       let(:search) { nil } | ||||
| 
 | ||||
|       it 'orders by a constant 0 value' do | ||||
|         expect(query).to include('ORDER BY CAST(0 AS integer) DESC') | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'when rules are empty' do | ||||
|       let(:search) { 'text' } | ||||
| 
 | ||||
|       let(:order_expression) do | ||||
|         Gitlab::Database::SimilarityScore.build_expression(search: search, rules: []).to_sql | ||||
|       end | ||||
| 
 | ||||
|       it 'orders by a constant 0 value' do | ||||
|         expect(query).to include('ORDER BY CAST(0 AS integer) DESC') | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   context 'when similarity scoring based on the path' do | ||||
|     let(:search) { 'git' } | ||||
| 
 | ||||
|     context 'when searching for `git`' do | ||||
|       let(:search) { 'git' } | ||||
| 
 | ||||
|       it { expect(subject).to eq(%w[git gitlab gitaly]) } | ||||
|     end | ||||
| 
 | ||||
|     context 'when searching for `gitlab`' do | ||||
|       let(:search) { 'gitlab' } | ||||
| 
 | ||||
|       it { expect(subject).to eq(%w[gitlab gitlab-styles gitlab-danger]) } | ||||
|     end | ||||
| 
 | ||||
|     context 'when searching for something unrelated' do | ||||
|       let(:search) { 'xyz' } | ||||
| 
 | ||||
|       it 'results have 0 similarity score' do | ||||
|         expect(query_result.map { |row| row['similarity'] }).to all(eq(0)) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe 'score multiplier' do | ||||
|     let(:order_expression) do | ||||
|       Gitlab::Database::SimilarityScore.build_expression(search: search, rules: [ | ||||
|         { column: Arel.sql('path'), multiplier: 1 }, | ||||
|         { column: Arel.sql('name'), multiplier: 0.8 } | ||||
|       ]).to_sql | ||||
|     end | ||||
| 
 | ||||
|     let(:search) { 'different' } | ||||
| 
 | ||||
|     it 'ranks `path` matches higher' do | ||||
|       expect(subject).to eq(%w[different same gitlab-danger]) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -5,6 +5,8 @@ require 'spec_helper' | |||
| RSpec.describe Issue do | ||||
|   include ExternalAuthorizationServiceHelpers | ||||
| 
 | ||||
|   let_it_be(:user) { create(:user) } | ||||
| 
 | ||||
|   describe "Associations" do | ||||
|     it { is_expected.to belong_to(:milestone) } | ||||
|     it { is_expected.to belong_to(:iteration) } | ||||
|  | @ -166,39 +168,9 @@ RSpec.describe Issue do | |||
| 
 | ||||
|       expect { issue.close }.to change { issue.state_id }.from(open_state).to(closed_state) | ||||
|     end | ||||
| 
 | ||||
|     context 'when there is an associated Alert Management Alert' do | ||||
|       context 'when alert can be resolved' do | ||||
|         let!(:alert) { create(:alert_management_alert, project: issue.project, issue: issue) } | ||||
| 
 | ||||
|         it 'resolves an alert' do | ||||
|           expect { issue.close }.to change { alert.reload.resolved? }.to(true) | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'when alert cannot be resolved' do | ||||
|         let!(:alert) { create(:alert_management_alert, :with_validation_errors, project: issue.project, issue: issue) } | ||||
| 
 | ||||
|         before do | ||||
|           allow(Gitlab::AppLogger).to receive(:warn).and_call_original | ||||
|         end | ||||
| 
 | ||||
|         it 'writes a warning into the log' do | ||||
|           issue.close | ||||
| 
 | ||||
|           expect(Gitlab::AppLogger).to have_received(:warn).with( | ||||
|             message: 'Cannot resolve an associated Alert Management alert', | ||||
|             issue_id: issue.id, | ||||
|             alert_id: alert.id, | ||||
|             alert_errors: { hosts: ['hosts array is over 255 chars'] } | ||||
|           ) | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#reopen' do | ||||
|     let(:user) { create(:user) } | ||||
|     let(:issue) { create(:issue, state: 'closed', closed_at: Time.current, closed_by: user) } | ||||
| 
 | ||||
|     it 'sets closed_at to nil when an issue is reopend' do | ||||
|  | @ -282,7 +254,6 @@ RSpec.describe Issue do | |||
|   end | ||||
| 
 | ||||
|   describe '#assignee_or_author?' do | ||||
|     let(:user) { create(:user) } | ||||
|     let(:issue) { create(:issue) } | ||||
| 
 | ||||
|     it 'returns true for a user that is assigned to an issue' do | ||||
|  | @ -303,7 +274,6 @@ RSpec.describe Issue do | |||
|   end | ||||
| 
 | ||||
|   describe '#can_move?' do | ||||
|     let(:user) { create(:user) } | ||||
|     let(:issue) { create(:issue) } | ||||
| 
 | ||||
|     subject { issue.can_move?(user) } | ||||
|  |  | |||
|  | @ -860,6 +860,66 @@ RSpec.describe API::Groups do | |||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'with similarity ordering' do | ||||
|         let_it_be(:group_with_projects) { create(:group) } | ||||
|         let_it_be(:project_1) { create(:project, name: 'Project', path: 'project', group: group_with_projects) } | ||||
|         let_it_be(:project_2) { create(:project, name: 'Test Project', path: 'test-project', group: group_with_projects) } | ||||
|         let_it_be(:project_3) { create(:project, name: 'Test', path: 'test', group: group_with_projects) } | ||||
| 
 | ||||
|         let(:params) { { order_by: 'similarity', search: 'test' } } | ||||
| 
 | ||||
|         subject { get api("/groups/#{group_with_projects.id}/projects", user1), params: params } | ||||
| 
 | ||||
|         before do | ||||
|           group_with_projects.add_owner(user1) | ||||
|         end | ||||
| 
 | ||||
|         it 'returns items based ordered by similarity' do | ||||
|           subject | ||||
| 
 | ||||
|           expect(response).to have_gitlab_http_status(:ok) | ||||
|           expect(response).to include_pagination_headers | ||||
|           expect(json_response.length).to eq(2) | ||||
| 
 | ||||
|           project_names = json_response.map { |proj| proj['name'] } | ||||
|           expect(project_names).to eq(['Test', 'Test Project']) | ||||
|         end | ||||
| 
 | ||||
|         context 'when `search` parameter is not given' do | ||||
|           before do | ||||
|             params.delete(:search) | ||||
|           end | ||||
| 
 | ||||
|           it 'returns items ordered by name' do | ||||
|             subject | ||||
| 
 | ||||
|             expect(response).to have_gitlab_http_status(:ok) | ||||
|             expect(response).to include_pagination_headers | ||||
|             expect(json_response.length).to eq(3) | ||||
| 
 | ||||
|             project_names = json_response.map { |proj| proj['name'] } | ||||
|             expect(project_names).to eq(['Project', 'Test', 'Test Project']) | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         context 'when `similarity_search` feature flag is off' do | ||||
|           before do | ||||
|             stub_feature_flags(similarity_search: false) | ||||
|           end | ||||
| 
 | ||||
|           it 'returns items ordered by name' do | ||||
|             subject | ||||
| 
 | ||||
|             expect(response).to have_gitlab_http_status(:ok) | ||||
|             expect(response).to include_pagination_headers | ||||
|             expect(json_response.length).to eq(2) | ||||
| 
 | ||||
|             project_names = json_response.map { |proj| proj['name'] } | ||||
|             expect(project_names).to eq(['Test', 'Test Project']) | ||||
|           end | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       it "returns the group's projects with simple representation" do | ||||
|         get api("/groups/#{group1.id}/projects", user1), params: { simple: true } | ||||
| 
 | ||||
|  |  | |||
|  | @ -252,6 +252,41 @@ RSpec.describe Issues::CloseService do | |||
|         expect(todo.reload).to be_done | ||||
|       end | ||||
| 
 | ||||
|       context 'when there is an associated Alert Management Alert' do | ||||
|         context 'when alert can be resolved' do | ||||
|           let!(:alert) { create(:alert_management_alert, issue: issue, project: project) } | ||||
| 
 | ||||
|           it 'resolves an alert and sends a system note' do | ||||
|             expect_next_instance_of(SystemNotes::AlertManagementService) do |notes_service| | ||||
|               expect(notes_service).to receive(:closed_alert_issue).with(issue) | ||||
|             end | ||||
| 
 | ||||
|             close_issue | ||||
| 
 | ||||
|             expect(alert.reload.resolved?).to eq(true) | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         context 'when alert cannot be resolved' do | ||||
|           let!(:alert) { create(:alert_management_alert, :with_validation_errors, issue: issue, project: project) } | ||||
| 
 | ||||
|           before do | ||||
|             allow(Gitlab::AppLogger).to receive(:warn).and_call_original | ||||
|           end | ||||
| 
 | ||||
|           it 'writes a warning into the log' do | ||||
|             close_issue | ||||
| 
 | ||||
|             expect(Gitlab::AppLogger).to have_received(:warn).with( | ||||
|               message: 'Cannot resolve an associated Alert Management alert', | ||||
|               issue_id: issue.id, | ||||
|               alert_id: alert.id, | ||||
|               alert_errors: { hosts: ['hosts array is over 255 chars'] } | ||||
|             ) | ||||
|           end | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       it 'deletes milestone issue counters cache' do | ||||
|         issue.update(milestone: create(:milestone, project: project)) | ||||
| 
 | ||||
|  |  | |||
|  | @ -699,7 +699,7 @@ RSpec.describe SystemNoteService do | |||
| 
 | ||||
|     it 'calls AlertManagementService' do | ||||
|       expect_next_instance_of(SystemNotes::AlertManagementService) do |service| | ||||
|         expect(service).to receive(:new_alert_issue).with(alert, alert.issue) | ||||
|         expect(service).to receive(:new_alert_issue).with(alert.issue) | ||||
|       end | ||||
| 
 | ||||
|       described_class.new_alert_issue(alert, alert.issue, author) | ||||
|  |  | |||
|  | @ -22,7 +22,7 @@ RSpec.describe ::SystemNotes::AlertManagementService do | |||
|   describe '#new_alert_issue' do | ||||
|     let_it_be(:issue) { noteable.issue } | ||||
| 
 | ||||
|     subject { described_class.new(noteable: noteable, project: project, author: author).new_alert_issue(noteable, issue) } | ||||
|     subject { described_class.new(noteable: noteable, project: project, author: author).new_alert_issue(issue) } | ||||
| 
 | ||||
|     it_behaves_like 'a system note' do | ||||
|       let(:action) { 'alert_issue_added' } | ||||
|  | @ -32,4 +32,18 @@ RSpec.describe ::SystemNotes::AlertManagementService do | |||
|       expect(subject.note).to eq("created issue #{issue.to_reference(project)} for this alert") | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#closed_alert_issue' do | ||||
|     let_it_be(:issue) { noteable.issue } | ||||
| 
 | ||||
|     subject { described_class.new(noteable: noteable, project: project, author: author).closed_alert_issue(issue) } | ||||
| 
 | ||||
|     it_behaves_like 'a system note' do | ||||
|       let(:action) { 'status' } | ||||
|     end | ||||
| 
 | ||||
|     it 'has the appropriate message' do | ||||
|       expect(subject.note).to eq("changed the status to **Resolved** by closing issue #{issue.to_reference(project)}") | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -64,6 +64,7 @@ RSpec.shared_context 'project navbar structure' do | |||
|         nav_sub_items: [ | ||||
|           _('Metrics'), | ||||
|           _('Alerts'), | ||||
|           _('Incidents'), | ||||
|           _('Environments'), | ||||
|           _('Error Tracking'), | ||||
|           _('Serverless'), | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue