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