Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-07-23 09:09:18 +00:00
parent 6520b1366e
commit defde9698e
53 changed files with 1051 additions and 94 deletions

View File

@ -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 }">

View File

@ -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

View File

@ -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>

View File

@ -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.'),
};

View File

@ -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
}
}
}
}
}
}

View File

@ -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,
},
});
},
});
};

View File

@ -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'),

View File

@ -0,0 +1,5 @@
import IncidentsList from '~/incidents/list';
document.addEventListener('DOMContentLoaded', () => {
IncidentsList();
});

View File

@ -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">

View File

@ -1,4 +1,4 @@
.alert-management-list,
.incident-management-list,
.alert-management-details {
.icon-critical {
color: $red-800;

View File

@ -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;
}
}
}
}

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class Projects::IncidentsController < Projects::ApplicationController
before_action :authorize_read_incidents!
def index
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
module Projects::IncidentsHelper
def incidents_data(project)
{
'project-path' => project.full_path
}
end
end

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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) }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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')

View File

@ -0,0 +1,3 @@
- page_title _('Incidents')
#js-incidents{ data: incidents_data(@project) }

View File

@ -0,0 +1,5 @@
---
title: Add system note to alert when corresponding issue is closed
merge_request: 37039
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Add basic incidents list
merge_request: 37314
author:
type: added

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -0,0 +1,14 @@
{
"type": "object",
"properties": {
"name": { "type": "string" },
"format": {
"type": "string",
"default": "engineering"
},
"precision": {
"type": "number",
"default": 2
}
}
}

View File

@ -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" }
}
}
}

View File

@ -0,0 +1,12 @@
{
"type": "object",
"required": ["url"],
"properties": {
"url": { "type": "string" },
"title": { "type": "string" },
"type": {
"type": "string",
"enum": ["grafana"]
}
}
}

View File

@ -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" }
}
}

View File

@ -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" }
}
}
}

View File

@ -0,0 +1,12 @@
{
"type": "object",
"required": ["group", "panels"],
"properties": {
"group": { "type": "string" },
"priority": { "type": "number" },
"panels": {
"type": "array",
"items": { "$ref": "./panel.json" }
}
}
}

View File

@ -0,0 +1,7 @@
{
"type": "object",
"required": ["variables"],
"properties": {
"variables": { "type": "object" }
}
}

View File

@ -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 ""

View File

@ -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.

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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();
});
});
});

View File

@ -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);
});
});

View File

@ -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

View File

@ -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

View File

@ -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) }

View File

@ -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 }

View File

@ -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))

View File

@ -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)

View File

@ -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

View File

@ -64,6 +64,7 @@ RSpec.shared_context 'project navbar structure' do
nav_sub_items: [
_('Metrics'),
_('Alerts'),
_('Incidents'),
_('Environments'),
_('Error Tracking'),
_('Serverless'),