diff --git a/app/graphql/mutations/issues/link_alerts.rb b/app/graphql/mutations/issues/link_alerts.rb
new file mode 100644
index 0000000000000000000000000000000000000000..c45e90c598f4435c230a0b953e5a718e1b982d39
--- /dev/null
+++ b/app/graphql/mutations/issues/link_alerts.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Issues
+ class LinkAlerts < Base
+ graphql_name 'IssueLinkAlerts'
+
+ argument :alert_references, [GraphQL::Types::String],
+ required: true,
+ description: 'Alerts references to be linked to the incident.'
+
+ authorize :admin_issue
+
+ def resolve(project_path:, iid:, alert_references:)
+ issue = authorized_find!(project_path: project_path, iid: iid)
+
+ ::IncidentManagement::LinkAlerts::CreateService.new(issue, current_user, alert_references).execute
+
+ {
+ issue: issue,
+ errors: errors_on_object(issue)
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 1cbb2ede5441a8eed1d46b91d86331300c73ed61..1a44093ff582773281cf128a6fe24f4cadb7856c 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -63,6 +63,7 @@ class MutationType < BaseObject
mount_mutation Mutations::Issues::SetEscalationStatus
mount_mutation Mutations::Issues::Update
mount_mutation Mutations::Issues::Move
+ mount_mutation Mutations::Issues::LinkAlerts
mount_mutation Mutations::Labels::Create
mount_mutation Mutations::MergeRequests::Accept
mount_mutation Mutations::MergeRequests::Create
diff --git a/app/services/incident_management/link_alerts/create_service.rb b/app/services/incident_management/link_alerts/create_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..36f3474fc68047f10925fd49f75a787adaedbfff
--- /dev/null
+++ b/app/services/incident_management/link_alerts/create_service.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module IncidentManagement
+ module LinkAlerts
+ class CreateService < ::BaseProjectService
+ # @param incident [Issue] an incident to link alerts
+ # @param current_user [User]
+ # @param alert_references [[String]] a list of alert references. Can be either a short reference or URL
+ # Examples:
+ # "^alert#IID"
+ # "https://gitlab.com/company/project/-/alert_management/IID/details"
+ def initialize(incident, current_user, alert_references)
+ @incident = incident
+ @current_user = current_user
+ @alert_references = alert_references
+
+ super(project: incident.project, current_user: current_user)
+ end
+
+ def execute
+ return error_no_permissions unless allowed?
+
+ references = extract_alerts_from_references
+ incident.alert_management_alerts << references if references.present?
+
+ success
+ end
+
+ private
+
+ attr_reader :incident, :current_user, :alert_references
+
+ def extract_alerts_from_references
+ text = alert_references.join(' ')
+ extractor = Gitlab::ReferenceExtractor.new(project, current_user)
+ extractor.analyze(text, {})
+
+ extractor.alerts
+ end
+
+ def allowed?
+ current_user&.can?(:admin_issue, project)
+ end
+
+ def success
+ ServiceResponse.success(payload: { incident: incident })
+ end
+
+ def error(message)
+ ServiceResponse.error(message: message)
+ end
+
+ def error_no_permissions
+ error(_('You have insufficient permissions to manage alerts for this project'))
+ end
+ end
+ end
+end
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 7feaddf3e2655bbb388599cc60d2038e9f23eca4..0cba4c4547f0ed0b6e924431aa679abbd7628b36 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -3151,6 +3151,27 @@ Input type: `IssuableResourceLinkDestroyInput`
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| `issuableResourceLink` | [`IssuableResourceLink`](#issuableresourcelink) | Issuable resource link. |
+### `Mutation.issueLinkAlerts`
+
+Input type: `IssueLinkAlertsInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `alertReferences` | [`[String!]!`](#string) | Alerts references to be linked to the incident. |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `iid` | [`String!`](#string) | IID of the issue to mutate. |
+| `projectPath` | [`ID!`](#id) | Project the issue to mutate is in. |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+| `issue` | [`Issue`](#issue) | Issue after mutation. |
+
### `Mutation.issueMove`
Input type: `IssueMoveInput`
diff --git a/lib/banzai/reference_parser/alert_parser.rb b/lib/banzai/reference_parser/alert_parser.rb
index e6ed6498cf0c4c4b45986bc2e28b6bde224f4a32..676c0ac40efbf220ae63e00928ef7879e3654ee0 100644
--- a/lib/banzai/reference_parser/alert_parser.rb
+++ b/lib/banzai/reference_parser/alert_parser.rb
@@ -15,8 +15,8 @@ def references_relation
private
- def can_read_reference?(user, alert, node)
- can?(user, :read_alert_management_alert, alert)
+ def can_read_reference?(user, project, node)
+ can?(user, :read_alert_management_alert, project)
end
end
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 26784602b0b5e0fb54744939a37330585be338a9..35a8aa80b73c5dbd8e18cf15c9b1dc73b55fb79c 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -47383,6 +47383,9 @@ msgstr ""
msgid "You have insufficient permissions to create an on-call schedule for this project"
msgstr ""
+msgid "You have insufficient permissions to manage alerts for this project"
+msgstr ""
+
msgid "You have insufficient permissions to manage resource links for this incident"
msgstr ""
diff --git a/spec/graphql/mutations/issues/link_alerts_spec.rb b/spec/graphql/mutations/issues/link_alerts_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..a6ce5cdd7ab6dfce9a48c94b57f01b6bc066498a
--- /dev/null
+++ b/spec/graphql/mutations/issues/link_alerts_spec.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Issues::LinkAlerts, feature_category: :incident_management do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:guest) { create(:user) }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:issue) { create(:incident, project: project) }
+ let_it_be(:alert1) { create(:alert_management_alert, project: project) }
+ let_it_be(:alert2) { create(:alert_management_alert, project: project) }
+
+ let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
+
+ specify { expect(described_class).to require_graphql_authorizations(:update_issue, :admin_issue) }
+
+ before_all do
+ project.add_guest(guest)
+ project.add_developer(developer)
+ end
+
+ describe '#resolve' do
+ let(:alert_references) { [alert1.to_reference, alert2.details_url, 'invalid-reference'] }
+
+ subject(:resolve) do
+ mutation.resolve(
+ project_path: issue.project.full_path,
+ iid: issue.iid,
+ alert_references: alert_references
+ )
+ end
+
+ context 'when the user is a guest' do
+ let(:user) { guest }
+
+ it 'raises an error' do
+ expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+
+ context 'when a user is also an author' do
+ let!(:issue) { create(:incident, project: project, author: user) }
+
+ it 'raises an error' do
+ expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'when a user is also an assignee' do
+ let!(:issue) { create(:incident, project: project, assignee_ids: [user.id]) }
+
+ it 'raises an error' do
+ expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+ end
+
+ context 'when the user is a developer' do
+ let(:user) { developer }
+
+ context 'when issue type is an incident' do
+ it 'calls LinkAlerts::CreateService with correct arguments' do
+ expect(::IncidentManagement::LinkAlerts::CreateService)
+ .to receive(:new)
+ .with(issue, user, alert_references)
+ .and_call_original
+
+ resolve
+ end
+
+ it 'returns no errors' do
+ expect(resolve[:errors]).to be_empty
+ end
+ end
+
+ context 'when issue type is not an incident' do
+ let!(:issue) { create(:issue, project: project) }
+
+ it 'does not update alert_management_alerts' do
+ expect { resolve }.not_to change { issue.alert_management_alerts }
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/issues/link_alerts_spec.rb b/spec/requests/api/graphql/mutations/issues/link_alerts_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..85e21952f47495f95f83e10c0d75e2c88cd4c402
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/issues/link_alerts_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Link alerts to an incident', feature_category: :incident_management do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:linked_alert) { create(:alert_management_alert, project: project) }
+ let_it_be(:alert1) { create(:alert_management_alert, project: project) }
+ let_it_be(:alert2) { create(:alert_management_alert, project: project) }
+ let_it_be(:incident) { create(:incident, project: project, alert_management_alerts: [linked_alert]) }
+
+ let(:mutation) do
+ variables = {
+ project_path: project.full_path,
+ iid: incident.iid.to_s,
+ alert_references: [alert1.to_reference, alert2.details_url]
+ }
+
+ graphql_mutation(:issue_link_alerts, variables,
+ <<-QL.strip_heredoc
+ clientMutationId
+ errors
+ issue {
+ iid
+ alertManagementAlerts {
+ nodes {
+ iid
+ }
+ }
+ }
+ QL
+ )
+ end
+
+ def mutation_response
+ graphql_mutation_response(:issue_link_alerts)
+ end
+
+ context 'when the user is not allowed to update the incident' do
+ it 'returns an error' do
+ error = Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(graphql_errors).to include(a_hash_including('message' => error))
+ end
+ end
+
+ context 'when the user is allowed to update the incident' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'links alerts to the incident' do
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expected_response = [linked_alert, alert1, alert2].map { |a| { 'iid' => a.iid.to_s } }
+ expect(mutation_response.dig('issue', 'alertManagementAlerts', 'nodes')).to match_array(expected_response)
+ end
+ end
+end
diff --git a/spec/services/incident_management/link_alerts/create_service_spec.rb b/spec/services/incident_management/link_alerts/create_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..51cdb0d18a693099cce3535f5a12e42eba69d203
--- /dev/null
+++ b/spec/services/incident_management/link_alerts/create_service_spec.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IncidentManagement::LinkAlerts::CreateService, feature_category: :incident_management do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:another_project) { create(:project) }
+ let_it_be(:linked_alert) { create(:alert_management_alert, project: project) }
+ let_it_be(:alert1) { create(:alert_management_alert, project: project) }
+ let_it_be(:alert2) { create(:alert_management_alert, project: project) }
+ let_it_be(:external_alert) { create(:alert_management_alert, project: another_project) }
+ let_it_be(:incident) { create(:incident, project: project, alert_management_alerts: [linked_alert]) }
+ let_it_be(:guest) { create(:user) }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:another_developer) { create(:user) }
+
+ before_all do
+ project.add_guest(guest)
+ project.add_developer(developer)
+ project.add_developer(another_developer)
+
+ another_project.add_guest(guest)
+ another_project.add_developer(developer)
+ end
+
+ describe '#execute' do
+ subject(:execute) { described_class.new(incident, current_user, alert_references).execute }
+
+ let(:alert_references) { [alert1.to_reference, alert2.details_url] }
+
+ context 'when current user is a guest' do
+ let(:current_user) { guest }
+
+ it 'responds with error', :aggregate_failures do
+ response = execute
+
+ expect(response).to be_error
+ expect(response.message).to eq('You have insufficient permissions to manage alerts for this project')
+ end
+
+ it 'does not link alerts to the incident' do
+ expect { execute }.not_to change { incident.reload.alert_management_alerts.to_a }
+ end
+ end
+
+ context 'when current user is a developer' do
+ let(:current_user) { developer }
+
+ it 'responds with success', :aggregate_failures do
+ response = execute
+
+ expect(response).to be_success
+ expect(response.payload[:incident]).to eq(incident)
+ end
+
+ it 'links alerts to the incident' do
+ expect { execute }
+ .to change { incident.reload.alert_management_alerts.to_a }
+ .from([linked_alert])
+ .to([linked_alert, alert1, alert2])
+ end
+
+ context 'when linking an already linked alert' do
+ let(:alert_references) { [linked_alert.details_url] }
+
+ it 'does not change incident alerts list' do
+ expect { execute }.not_to change { incident.reload.alert_management_alerts.to_a }
+ end
+ end
+
+ context 'when linking an alert from another project' do
+ let(:alert_references) { [external_alert.details_url] }
+
+ it 'links an external alert to the incident' do
+ expect { execute }
+ .to change { incident.reload.alert_management_alerts.to_a }
+ .from([linked_alert])
+ .to([linked_alert, external_alert])
+ end
+ end
+ end
+
+ context 'when current user does not have permission to read alerts on external project' do
+ let(:current_user) { another_developer }
+
+ context 'when linking alerts from current and external projects' do
+ let(:alert_references) { [alert1.details_url, external_alert.details_url] }
+
+ it 'links only alerts the current user can read' do
+ expect { execute }
+ .to change { incident.reload.alert_management_alerts.to_a }
+ .from([linked_alert])
+ .to([linked_alert, alert1])
+ end
+ end
+ end
+ end
+end