Commit dc333326 authored by Vitali Tatarintev's avatar Vitali Tatarintev Committed by Peter Leitzen

Add CreateAlertIssue GraphQL mutation

Adds a GraphQL mutation that allows to create a GitLab issue
from an Alert Management Alert
parent b5511297
......@@ -18,6 +18,11 @@ module Mutations
null: true,
description: "The alert after mutation"
field :issue,
Types::IssueType,
null: true,
description: "The issue created after mutation"
authorize :update_alert_management_alert
private
......
# frozen_string_literal: true
module Mutations
module AlertManagement
class CreateAlertIssue < Base
graphql_name 'CreateAlertIssue'
def resolve(args)
alert = authorized_find!(project_path: args[:project_path], iid: args[:iid])
result = create_alert_issue(alert, current_user)
prepare_response(alert, result)
end
private
def create_alert_issue(alert, user)
::AlertManagement::CreateAlertIssueService.new(alert, user).execute
end
def prepare_response(alert, result)
{
alert: alert,
issue: result.payload[:issue],
errors: Array(result.message)
}
end
end
end
end
......@@ -7,6 +7,7 @@ module Types
graphql_name 'Mutation'
mount_mutation Mutations::Admin::SidekiqQueues::DeleteJobs
mount_mutation Mutations::AlertManagement::CreateAlertIssue
mount_mutation Mutations::AlertManagement::UpdateAlertStatus
mount_mutation Mutations::AwardEmojis::Add
mount_mutation Mutations::AwardEmojis::Remove
......
......@@ -127,6 +127,10 @@ module AlertManagement
Gitlab::Utils::InlineHash.merge_keys(details_payload)
end
def prometheus?
monitoring_tool == Gitlab::AlertManagement::AlertParams::MONITORING_TOOLS[:prometheus]
end
private
def hosts_length
......
# frozen_string_literal: true
module AlertManagement
class CreateAlertIssueService
# @param alert [AlertManagement::Alert]
# @param user [User]
def initialize(alert, user)
@alert = alert
@user = user
end
def execute
return error_no_permissions unless allowed?
return error_issue_already_exists if alert.issue
result = create_issue(alert, user, alert_payload)
@issue = result[:issue]
return error(result[:message]) if result[:status] == :error
return error(alert.errors.full_messages.to_sentence) unless update_alert_issue_id
success
end
private
attr_reader :alert, :user, :issue
delegate :project, to: :alert
def allowed?
Feature.enabled?(:alert_management_create_alert_issue, project) &&
user.can?(:update_alert_management_alert, project)
end
def create_issue(alert, user, alert_payload)
::IncidentManagement::CreateIssueService
.new(project, alert_payload, user)
.execute(skip_settings_check: true)
end
def alert_payload
if alert.prometheus?
alert.payload
else
Gitlab::Alerting::NotificationPayloadParser.call(alert.payload.to_h)
end
end
def update_alert_issue_id
alert.update(issue_id: issue.id)
end
def success
ServiceResponse.success(payload: { issue: issue })
end
def error(message)
ServiceResponse.error(payload: { issue: issue }, message: message)
end
def error_issue_already_exists
error(_('An issue already exists'))
end
def error_no_permissions
error(_('You have no permissions'))
end
end
end
......@@ -13,12 +13,12 @@ module IncidentManagement
DESCRIPTION
}.freeze
def initialize(project, params)
super(project, User.alert_bot, params)
def initialize(project, params, user = User.alert_bot)
super(project, user, params)
end
def execute
return error_with('setting disabled') unless incident_management_setting.create_issue?
def execute(skip_settings_check: false)
return error_with('setting disabled') unless skip_settings_check || incident_management_setting.create_issue?
return error_with('invalid alert') unless alert.valid?
issue = create_issue
......
......@@ -900,6 +900,51 @@ type Commit {
webUrl: String!
}
"""
Autogenerated input type of CreateAlertIssue
"""
input CreateAlertIssueInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The iid of the alert to mutate
"""
iid: String!
"""
The project the alert to mutate is in
"""
projectPath: ID!
}
"""
Autogenerated return type of CreateAlertIssue
"""
type CreateAlertIssuePayload {
"""
The alert after mutation
"""
alert: AlertManagementAlert
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
The issue created after mutation
"""
issue: Issue
}
"""
Autogenerated input type of CreateBranch
"""
......@@ -6507,6 +6552,7 @@ type Mutation {
addProjectToSecurityDashboard(input: AddProjectToSecurityDashboardInput!): AddProjectToSecurityDashboardPayload
adminSidekiqQueuesDeleteJobs(input: AdminSidekiqQueuesDeleteJobsInput!): AdminSidekiqQueuesDeleteJobsPayload
boardListUpdateLimitMetrics(input: BoardListUpdateLimitMetricsInput!): BoardListUpdateLimitMetricsPayload
createAlertIssue(input: CreateAlertIssueInput!): CreateAlertIssuePayload
createBranch(input: CreateBranchInput!): CreateBranchPayload
createDiffNote(input: CreateDiffNoteInput!): CreateDiffNotePayload
createEpic(input: CreateEpicInput!): CreateEpicPayload
......@@ -10522,6 +10568,11 @@ type UpdateAlertStatusPayload {
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
The issue created after mutation
"""
issue: Issue
}
input UpdateDiffImagePositionInput {
......
......@@ -2418,6 +2418,136 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "CreateAlertIssueInput",
"description": "Autogenerated input type of CreateAlertIssue",
"fields": null,
"inputFields": [
{
"name": "projectPath",
"description": "The project the alert to mutate is in",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "iid",
"description": "The iid of the alert to mutate",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "CreateAlertIssuePayload",
"description": "Autogenerated return type of CreateAlertIssue",
"fields": [
{
"name": "alert",
"description": "The alert after mutation",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "AlertManagementAlert",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "issue",
"description": "The issue created after mutation",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Issue",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "CreateBranchInput",
......@@ -18394,6 +18524,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createAlertIssue",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "CreateAlertIssueInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "CreateAlertIssuePayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createBranch",
"description": null,
......@@ -31342,6 +31499,20 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "issue",
"description": "The issue created after mutation",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Issue",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
......@@ -164,6 +164,17 @@ Autogenerated return type of BoardListUpdateLimitMetrics
| `titleHtml` | String | The GitLab Flavored Markdown rendering of `title` |
| `webUrl` | String! | Web URL of the commit |
## CreateAlertIssuePayload
Autogenerated return type of CreateAlertIssue
| Name | Type | Description |
| --- | ---- | ---------- |
| `alert` | AlertManagementAlert | The alert after mutation |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `issue` | Issue | The issue created after mutation |
## CreateBranchPayload
Autogenerated return type of CreateBranch
......@@ -1596,6 +1607,7 @@ Autogenerated return type of UpdateAlertStatus
| `alert` | AlertManagementAlert | The alert after mutation |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `issue` | Issue | The issue created after mutation |
## UpdateEpicPayload
......
......@@ -2315,6 +2315,9 @@ msgstr ""
msgid "An instance-level serverless domain already exists."
msgstr ""
msgid "An issue already exists"
msgstr ""
msgid "An issue can be a bug, a todo or a feature request that needs to be discussed in a project. Besides, issues are searchable and filterable."
msgstr ""
......
......@@ -60,6 +60,10 @@ FactoryBot.define do
severity { 'low' }
end
trait :prometheus do
monitoring_tool { Gitlab::AlertManagement::AlertParams::MONITORING_TOOLS[:prometheus] }
end
trait :all_fields do
with_issue
with_fingerprint
......
# frozen_string_literal: true
require 'spec_helper'
describe Mutations::AlertManagement::CreateAlertIssue do
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:alert) { create(:alert_management_alert, project: project, status: 'triggered') }
let(:args) { { project_path: project.full_path, iid: alert.iid } }
specify { expect(described_class).to require_graphql_authorizations(:update_alert_management_alert) }
describe '#resolve' do
subject(:resolve) { mutation_for(project, current_user).resolve(args) }
context 'user has access to project' do
before do
project.add_developer(current_user)
end
context 'when CreateAlertIssueService responds with success' do
it 'returns the issue with no errors' do
expect(resolve).to eq(
alert: alert.reload,
issue: Issue.last!,
errors: []
)
end
end
context 'when CreateAlertIssue responds with an error' do
before do
allow_any_instance_of(::AlertManagement::CreateAlertIssueService)
.to receive(:execute)
.and_return(ServiceResponse.error(payload: { issue: nil }, message: 'An issue already exists'))
end
it 'returns errors' do
expect(resolve).to eq(
alert: alert,
issue: nil,
errors: ['An issue already exists']
)
end
end
end
context 'when resource is not accessible to the user' do
it 'raises an error if the resource is not accessible to the user' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
end
private
def mutation_for(project, user)
described_class.new(object: project, context: { current_user: user }, field: nil)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe AlertManagement::CreateAlertIssueService do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:payload) do
{
'annotations' => {
'title' => 'Alert title'
},
'startsAt' => '2020-04-27T10:10:22.265949279Z',
'generatorURL' => 'http://8d467bd4607a:9090/graph?g0.expr=vector%281%29&g0.tab=1'
}
end
let_it_be(:generic_alert, reload: true) { create(:alert_management_alert, :triggered, project: project, payload: payload) }
let_it_be(:prometheus_alert) { create(:alert_management_alert, :triggered, :prometheus, project: project, payload: payload) }
let(:alert) { generic_alert }
let(:created_issue) { Issue.last! }
describe '#execute' do
subject(:execute) { described_class.new(alert, user).execute }
before do
allow(user).to receive(:can?).and_call_original
allow(user).to receive(:can?)
.with(:update_alert_management_alert, project)
.and_return(can_create)
end
shared_examples 'creating an alert' do
it 'creates an issue' do
expect { execute }.to change { project.issues.count }.by(1)
end
it 'returns a created issue' do
expect(execute.payload).to eq(issue: created_issue)
end
it 'has a successful status' do
expect(execute).to be_success
end
it 'updates alert.issue_id' do
execute
expect(alert.reload.issue_id).to eq(created_issue.id)
end
it 'sets issue author to the current user' do
execute
expect(created_issue.author).to eq(user)
end
end
context 'when a user is allowed to create an issue' do
let(:can_create) { true }
before do
project.add_developer(user)
end
context 'when the alert is prometheus alert' do
let(:alert) { prometheus_alert }
it_behaves_like 'creating an alert'
end
context 'when the alert is generic' do
let(:alert) { generic_alert }
it_behaves_like 'creating an alert'
end
context 'when issue cannot be created' do
let(:alert) { prometheus_alert }
before do
# set invalid payload for Prometheus alert
alert.update!(payload: {})
end
it 'has an unsuccessful status' do
expect(execute).to be_error
expect(execute.message).to eq('invalid alert')
end
end
context 'when alert cannot be updated' do
before do
# invalidate alert
too_many_hosts = Array.new(AlertManagement::Alert::HOSTS_MAX_LENGTH + 1) { |_| 'host' }
alert.update_columns(hosts: too_many_hosts)
end
it 'responds with error' do
expect(execute).to be_error
expect(execute.message).to eq('Hosts hosts array is over 255 chars')
end
end
context 'when alert already has an attached issue' do
let!(:issue) { create(:issue, project: project) }
before do
alert.update!(issue_id: issue.id)
end
it 'does not create yet another issue' do
expect { execute }.not_to change(Issue, :count)
end
it 'responds with error' do
expect(execute).to be_error
expect(execute.message).to eq(_('An issue already exists'))
end
end
context 'when alert_management_create_alert_issue feature flag is disabled' do
before do
stub_feature_flags(alert_management_create_alert_issue: false)
end
it 'responds with error' do
expect(execute).to be_error
expect(execute.message).to eq(_('You have no permissions'))
end
end
end
context 'when a user is not allowed to create an issue' do
let(:can_create) { false }
it 'responds with error' do
expect(execute).to be_error
expect(execute.message).to eq(_('You have no permissions'))
end
end
end
end
......@@ -281,6 +281,7 @@ describe IncidentManagement::CreateIssueService do
setting.update!(create_issue: false)
end
context 'when skip_settings_check is false (default)' do
it 'returns an error' do
expect(service)
.to receive(:log_error)
......@@ -290,6 +291,15 @@ describe IncidentManagement::CreateIssueService do
end
end
context 'when skip_settings_check is true' do
subject { service.execute(skip_settings_check: true) }
it 'creates an issue' do
expect { subject }.to change(Issue, :count).by(1)
end
end
end
private
def build_alert_payload(annotations: {}, starts_at: Time.now)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment