Commit 4114424a authored by David O'Regan's avatar David O'Regan Committed by Peter Leitzen

Add alert assignee table, model, API support

Adds table and model for AlertManagement::AlertAssignee, which
will enable the association of users with alerts. This will
aide in triaging incoming alerts for a project.

This commit also adds the ability to read assignees for alerts
to the GraphQL API.
parent 7f0febfc
...@@ -15,7 +15,8 @@ import { s__ } from '~/locale'; ...@@ -15,7 +15,8 @@ import { s__ } from '~/locale';
import query from '../graphql/queries/details.query.graphql'; import query from '../graphql/queries/details.query.graphql';
import { fetchPolicies } from '~/lib/graphql'; import { fetchPolicies } from '~/lib/graphql';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
import initUserPopovers from '~/user_popovers';
import { ALERTS_SEVERITY_LABELS, trackAlertsDetailsViewsOptions } from '../constants'; import { ALERTS_SEVERITY_LABELS, trackAlertsDetailsViewsOptions } from '../constants';
import createIssueQuery from '../graphql/mutations/create_issue_from_alert.graphql'; import createIssueQuery from '../graphql/mutations/create_issue_from_alert.graphql';
import { visitUrl, joinPaths } from '~/lib/utils/url_utility'; import { visitUrl, joinPaths } from '~/lib/utils/url_utility';
...@@ -51,7 +52,6 @@ export default { ...@@ -51,7 +52,6 @@ export default {
AlertSidebar, AlertSidebar,
SystemNote, SystemNote,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
alertId: { alertId: {
type: String, type: String,
...@@ -116,6 +116,12 @@ export default { ...@@ -116,6 +116,12 @@ export default {
'right-sidebar-expanded': true, 'right-sidebar-expanded': true,
}); });
}, },
updated() {
this.$nextTick(() => {
highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member'));
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
});
},
methods: { methods: {
dismissError() { dismissError() {
this.isErrorDismissed = true; this.isErrorDismissed = true;
...@@ -187,7 +193,7 @@ export default { ...@@ -187,7 +193,7 @@ export default {
<div <div
v-if="alert" v-if="alert"
class="alert-management-details gl-relative" class="alert-management-details gl-relative"
:class="{ 'pr-8': sidebarCollapsed }" :class="{ 'pr-sm-8': sidebarCollapsed }"
> >
<div <div
class="gl-display-flex gl-justify-content-space-between gl-align-items-baseline gl-px-1 py-3 py-md-4 gl-border-b-1 gl-border-b-gray-200 gl-border-b-solid flex-column flex-sm-row" class="gl-display-flex gl-justify-content-space-between gl-align-items-baseline gl-px-1 py-3 py-md-4 gl-border-b-1 gl-border-b-gray-200 gl-border-b-solid flex-column flex-sm-row"
...@@ -294,8 +300,8 @@ export default { ...@@ -294,8 +300,8 @@ export default {
</div> </div>
<div class="gl-pl-2" data-testid="service">{{ alert.service }}</div> <div class="gl-pl-2" data-testid="service">{{ alert.service }}</div>
</div> </div>
<template v-if="glFeatures.alertAssignee"> <template>
<div v-if="alert.notes" class="issuable-discussion"> <div v-if="alert.notes.nodes" class="issuable-discussion py-5">
<ul class="notes main-notes-list timeline"> <ul class="notes main-notes-list timeline">
<system-note v-for="note in alert.notes.nodes" :key="note.id" :note="note" /> <system-note v-for="note in alert.notes.nodes" :key="note.id" :note="note" />
</ul> </ul>
......
<script> <script>
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SidebarHeader from './sidebar/sidebar_header.vue'; import SidebarHeader from './sidebar/sidebar_header.vue';
import SidebarTodo from './sidebar/sidebar_todo.vue'; import SidebarTodo from './sidebar/sidebar_todo.vue';
import SidebarStatus from './sidebar/sidebar_status.vue'; import SidebarStatus from './sidebar/sidebar_status.vue';
...@@ -12,7 +11,6 @@ export default { ...@@ -12,7 +11,6 @@ export default {
SidebarTodo, SidebarTodo,
SidebarStatus, SidebarStatus,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
sidebarCollapsed: { sidebarCollapsed: {
type: Boolean, type: Boolean,
...@@ -50,14 +48,13 @@ export default { ...@@ -50,14 +48,13 @@ export default {
@alert-sidebar-error="$emit('alert-sidebar-error', $event)" @alert-sidebar-error="$emit('alert-sidebar-error', $event)"
/> />
<sidebar-assignees <sidebar-assignees
v-if="glFeatures.alertAssignee"
:project-path="projectPath" :project-path="projectPath"
:alert="alert" :alert="alert"
:sidebar-collapsed="sidebarCollapsed"
@alert-refresh="$emit('alert-refresh')" @alert-refresh="$emit('alert-refresh')"
@toggle-sidebar="$emit('toggle-sidebar')" @toggle-sidebar="$emit('toggle-sidebar')"
@alert-sidebar-error="$emit('alert-sidebar-error', $event)" @alert-sidebar-error="$emit('alert-sidebar-error', $event)"
/> />
<!-- TODO: Remove after adding extra attribute blocks to sidebar -->
<div class="block"></div> <div class="block"></div>
</div> </div>
</aside> </aside>
......
...@@ -51,6 +51,10 @@ export default { ...@@ -51,6 +51,10 @@ export default {
required: false, required: false,
default: true, default: true,
}, },
sidebarCollapsed: {
type: Boolean,
required: false,
},
}, },
data() { data() {
return { return {
...@@ -62,10 +66,19 @@ export default { ...@@ -62,10 +66,19 @@ export default {
}; };
}, },
computed: { computed: {
assignedUsers() { currentUser() {
return this.alert.assignees.nodes.length > 0 return gon?.current_username;
? this.alert.assignees.nodes[0].username },
: s__('AlertManagement|Unassigned'); userName() {
return this.alert?.assignees?.nodes[0]?.username;
},
assignedUser() {
return this.userName || s__('AlertManagement|None');
},
sortedUsers() {
return this.users
.map(user => ({ ...user, active: this.isActive(user.username) }))
.sort((a, b) => (a.active === b.active ? 0 : a.active ? -1 : 1)); // eslint-disable-line no-nested-ternary
}, },
dropdownClass() { dropdownClass() {
return this.isDropdownShowing ? 'show' : 'gl-display-none'; return this.isDropdownShowing ? 'show' : 'gl-display-none';
...@@ -115,7 +128,7 @@ export default { ...@@ -115,7 +128,7 @@ export default {
per_page: 20, per_page: 20,
active: true, active: true,
current_user: true, current_user: true,
project_id: gon.current_project_id, project_id: gon?.current_project_id,
}, },
}) })
.then(({ data }) => { .then(({ data }) => {
...@@ -159,12 +172,11 @@ export default { ...@@ -159,12 +172,11 @@ export default {
<div ref="status" class="sidebar-collapsed-icon" @click="$emit('toggle-sidebar')"> <div ref="status" class="sidebar-collapsed-icon" @click="$emit('toggle-sidebar')">
<gl-icon name="user" :size="14" /> <gl-icon name="user" :size="14" />
<gl-loading-icon v-if="isUpdating" /> <gl-loading-icon v-if="isUpdating" />
<p v-else class="collapse-truncated-title px-1">{{ assignedUsers }}</p>
</div> </div>
<gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left"> <gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left">
<gl-sprintf :message="s__('AlertManagement|Alert assignee(s): %{assignees}')"> <gl-sprintf :message="s__('AlertManagement|Alert assignee(s): %{assignees}')">
<template #assignees> <template #assignees>
{{ assignedUsers }} {{ assignedUser }}
</template> </template>
</gl-sprintf> </gl-sprintf>
</gl-tooltip> </gl-tooltip>
...@@ -187,7 +199,7 @@ export default { ...@@ -187,7 +199,7 @@ export default {
<div class="dropdown dropdown-menu-selectable" :class="dropdownClass"> <div class="dropdown dropdown-menu-selectable" :class="dropdownClass">
<gl-dropdown <gl-dropdown
ref="dropdown" ref="dropdown"
:text="assignedUsers" :text="assignedUser"
class="w-100" class="w-100"
toggle-class="dropdown-menu-toggle" toggle-class="dropdown-menu-toggle"
variant="outline-default" variant="outline-default"
...@@ -195,7 +207,7 @@ export default { ...@@ -195,7 +207,7 @@ export default {
@hide="hideDropdown" @hide="hideDropdown"
> >
<div class="dropdown-title"> <div class="dropdown-title">
<span class="alert-title">{{ s__('AlertManagement|Assign Assignees') }}</span> <span class="alert-title">{{ s__('AlertManagement|Assign To') }}</span>
<gl-button <gl-button
:aria-label="__('Close')" :aria-label="__('Close')"
variant="link" variant="link"
...@@ -215,34 +227,25 @@ export default { ...@@ -215,34 +227,25 @@ export default {
</div> </div>
<div class="dropdown-content dropdown-body"> <div class="dropdown-content dropdown-body">
<template v-if="userListValid"> <template v-if="userListValid">
<gl-dropdown-item @click="updateAlertAssignees('')"> <gl-dropdown-item
:active="!userName"
active-class="is-active"
@click="updateAlertAssignees('')"
>
{{ s__('AlertManagement|Unassigned') }} {{ s__('AlertManagement|Unassigned') }}
</gl-dropdown-item> </gl-dropdown-item>
<gl-dropdown-divider /> <gl-dropdown-divider />
<gl-dropdown-header class="mt-0"> <gl-dropdown-header class="mt-0">
{{ s__('AlertManagement|Assignee(s)') }} {{ s__('AlertManagement|Assignee') }}
</gl-dropdown-header> </gl-dropdown-header>
<sidebar-assignee
<template v-for="user in users"> v-for="user in sortedUsers"
<sidebar-assignee :key="user.username"
v-if="isActive(user.username)" :user="user"
:key="user.username" :active="user.active"
:user="user" @update-alert-assignees="updateAlertAssignees"
:active="true" />
@update-alert-assignees="updateAlertAssignees"
/>
</template>
<gl-dropdown-divider />
<template v-for="user in users">
<sidebar-assignee
v-if="!isActive(user.username)"
:key="user.username"
:user="user"
:active="false"
@update-alert-assignees="updateAlertAssignees"
/>
</template>
</template> </template>
<gl-dropdown-item v-else-if="userListEmpty"> <gl-dropdown-item v-else-if="userListEmpty">
{{ s__('AlertManagement|No Matching Results') }} {{ s__('AlertManagement|No Matching Results') }}
...@@ -253,16 +256,21 @@ export default { ...@@ -253,16 +256,21 @@ export default {
</div> </div>
<gl-loading-icon v-if="isUpdating" :inline="true" /> <gl-loading-icon v-if="isUpdating" :inline="true" />
<p <p v-else-if="!isDropdownShowing" class="value gl-m-0" :class="{ 'no-value': !userName }">
v-else-if="!isDropdownShowing" <span v-if="userName" class="gl-text-gray-700" data-testid="assigned-users">{{
class="value gl-m-0" assignedUser
:class="{ 'no-value': !alert.assignees.nodes }"
>
<span v-if="alert.assignees.nodes" class="gl-text-gray-700" data-testid="assigned-users">{{
assignedUsers
}}</span> }}</span>
<span v-else> <span v-else class="gl-display-flex gl-align-items-center">
{{ s__('AlertManagement|None') }} {{ s__('AlertManagement|None -') }}
<gl-button
class="gl-pl-2"
href="#"
variant="link"
data-testid="unassigned-users"
@click="updateAlertAssignees(currentUser)"
>
{{ s__('AlertManagement| assign yourself') }}
</gl-button>
</span> </span>
</p> </p>
</div> </div>
......
...@@ -16,6 +16,13 @@ export default { ...@@ -16,6 +16,13 @@ export default {
noteAnchorId() { noteAnchorId() {
return `note_${this.note?.id?.split('/').pop()}`; return `note_${this.note?.id?.split('/').pop()}`;
}, },
noteAuthor() {
const {
author,
author: { id },
} = this.note;
return { ...author, id: id?.split('/').pop() };
},
iconHtml() { iconHtml() {
return spriteIcon('user'); return spriteIcon('user');
}, },
...@@ -29,7 +36,7 @@ export default { ...@@ -29,7 +36,7 @@ export default {
<div class="timeline-icon" v-html="iconHtml"></div> <div class="timeline-icon" v-html="iconHtml"></div>
<div class="timeline-content"> <div class="timeline-content">
<div class="note-header"> <div class="note-header">
<note-header :author="note.author" :created-at="note.createdAt" :note-id="note.id"> <note-header :author="noteAuthor" :created-at="note.createdAt" :note-id="note.id">
<span v-html="note.bodyHtml"></span> <span v-html="note.bodyHtml"></span>
</note-header> </note-header>
</div> </div>
......
...@@ -51,13 +51,23 @@ ...@@ -51,13 +51,23 @@
} }
.assignee-dropdown-item { .assignee-dropdown-item {
button { .dropdown-item {
display: flex; display: flex;
align-items: center; align-items: center;
&::before { &::before {
top: 50% !important; top: 50% !important;
} }
&.is-active {
&:last-child {
border-bottom: 1px solid $gray-200;
}
}
} }
} }
.note-header-info {
margin-top: 1px;
}
} }
...@@ -2,9 +2,6 @@ ...@@ -2,9 +2,6 @@
class Projects::AlertManagementController < Projects::ApplicationController class Projects::AlertManagementController < Projects::ApplicationController
before_action :authorize_read_alert_management_alert! before_action :authorize_read_alert_management_alert!
before_action do
push_frontend_feature_flag(:alert_assignee, project)
end
def index def index
end end
......
...@@ -33,7 +33,8 @@ module Resolvers ...@@ -33,7 +33,8 @@ module Resolvers
def preloads def preloads
{ {
assignees: [:assignees] assignees: [:assignees],
notes: [:ordered_notes, { ordered_notes: [:system_note_metadata, :project, :noteable] }]
} }
end end
end end
......
...@@ -91,10 +91,8 @@ module Types ...@@ -91,10 +91,8 @@ module Types
null: true, null: true,
description: 'Assignees of the alert' description: 'Assignees of the alert'
def assignees def notes
return User.none unless Feature.enabled?(:alert_assignee, object.project) object.ordered_notes
object.assignees
end end
end end
end end
......
...@@ -32,6 +32,7 @@ module AlertManagement ...@@ -32,6 +32,7 @@ module AlertManagement
has_many :assignees, through: :alert_assignees has_many :assignees, through: :alert_assignees
has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :ordered_notes, -> { fresh }, as: :noteable, class_name: 'Note'
has_many :user_mentions, class_name: 'AlertManagement::AlertUserMention', foreign_key: :alert_management_alert_id has_many :user_mentions, class_name: 'AlertManagement::AlertUserMention', foreign_key: :alert_management_alert_id
has_internal_id :iid, scope: :project, init: ->(s) { s.project.alert_management_alerts.maximum(:iid) } has_internal_id :iid, scope: :project, init: ->(s) { s.project.alert_management_alerts.maximum(:iid) }
......
---
title: Enable ability to assign alerts to users with corresponding system notes and todos
merge_request: 34360
author:
type: added
...@@ -1852,6 +1852,9 @@ msgid_plural "Alerts" ...@@ -1852,6 +1852,9 @@ msgid_plural "Alerts"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "AlertManagement| assign yourself"
msgstr ""
msgid "AlertManagement|Acknowledged" msgid "AlertManagement|Acknowledged"
msgstr "" msgstr ""
...@@ -1876,7 +1879,7 @@ msgstr "" ...@@ -1876,7 +1879,7 @@ msgstr ""
msgid "AlertManagement|All alerts" msgid "AlertManagement|All alerts"
msgstr "" msgstr ""
msgid "AlertManagement|Assign Assignees" msgid "AlertManagement|Assign To"
msgstr "" msgstr ""
msgid "AlertManagement|Assign status" msgid "AlertManagement|Assign status"
...@@ -1885,9 +1888,6 @@ msgstr "" ...@@ -1885,9 +1888,6 @@ msgstr ""
msgid "AlertManagement|Assignee" msgid "AlertManagement|Assignee"
msgstr "" msgstr ""
msgid "AlertManagement|Assignee(s)"
msgstr ""
msgid "AlertManagement|Assignees" msgid "AlertManagement|Assignees"
msgstr "" msgstr ""
...@@ -1942,6 +1942,9 @@ msgstr "" ...@@ -1942,6 +1942,9 @@ msgstr ""
msgid "AlertManagement|None" msgid "AlertManagement|None"
msgstr "" msgstr ""
msgid "AlertManagement|None -"
msgstr ""
msgid "AlertManagement|Open" msgid "AlertManagement|Open"
msgstr "" msgstr ""
......
...@@ -127,7 +127,7 @@ describe('Alert Details Sidebar Assignees', () => { ...@@ -127,7 +127,7 @@ describe('Alert Details Sidebar Assignees', () => {
it('stops updating and cancels loading when the request fails', () => { it('stops updating and cancels loading when the request fails', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error()));
wrapper.vm.updateAlertAssignees('root'); wrapper.vm.updateAlertAssignees('root');
expect(wrapper.find('[data-testid="assigned-users"]').text()).toBe('Unassigned'); expect(wrapper.find('[data-testid="unassigned-users"]').text()).toBe('assign yourself');
}); });
}); });
}); });
...@@ -14,7 +14,6 @@ describe('Alert Details Sidebar', () => { ...@@ -14,7 +14,6 @@ describe('Alert Details Sidebar', () => {
function mountComponent({ function mountComponent({
sidebarCollapsed = true, sidebarCollapsed = true,
mountMethod = shallowMount, mountMethod = shallowMount,
alertAssignee = false,
stubs = {}, stubs = {},
alert = {}, alert = {},
} = {}) { } = {}) {
...@@ -24,9 +23,6 @@ describe('Alert Details Sidebar', () => { ...@@ -24,9 +23,6 @@ describe('Alert Details Sidebar', () => {
sidebarCollapsed, sidebarCollapsed,
projectPath: 'projectPath', projectPath: 'projectPath',
}, },
provide: {
glFeatures: { alertAssignee },
},
stubs, stubs,
}); });
} }
...@@ -48,14 +44,9 @@ describe('Alert Details Sidebar', () => { ...@@ -48,14 +44,9 @@ describe('Alert Details Sidebar', () => {
expect(wrapper.props('sidebarCollapsed')).toBe(true); expect(wrapper.props('sidebarCollapsed')).toBe(true);
}); });
it('should not render side bar assignee dropdown by default', () => { it('should render side bar assignee dropdown', () => {
expect(wrapper.find(SidebarAssignees).exists()).toBe(false);
});
it('should render side bar assignee dropdown if feature flag enabled', () => {
mountComponent({ mountComponent({
mountMethod: mount, mountMethod: mount,
alertAssignee: true,
alert: mockAlert, alert: mockAlert,
}); });
expect(wrapper.find(SidebarAssignees).exists()).toBe(true); expect(wrapper.find(SidebarAssignees).exists()).toBe(true);
......
...@@ -8,6 +8,7 @@ describe AlertManagement::Alert do ...@@ -8,6 +8,7 @@ describe AlertManagement::Alert do
it { is_expected.to belong_to(:issue) } it { is_expected.to belong_to(:issue) }
it { is_expected.to have_many(:assignees).through(:alert_assignees) } it { is_expected.to have_many(:assignees).through(:alert_assignees) }
it { is_expected.to have_many(:notes) } it { is_expected.to have_many(:notes) }
it { is_expected.to have_many(:ordered_notes) }
it { is_expected.to have_many(:user_mentions) } it { is_expected.to have_many(:user_mentions) }
end end
......
...@@ -75,17 +75,4 @@ describe 'getting Alert Management Alert Assignees' do ...@@ -75,17 +75,4 @@ describe 'getting Alert Management Alert Assignees' do
expect(third_assignees.length).to eq(1) expect(third_assignees.length).to eq(1)
expect(third_assignees.first).to include('username' => current_user.username) expect(third_assignees.first).to include('username' => current_user.username)
end end
context 'with alert_assignee flag disabled' do
before do
stub_feature_flags(alert_assignee: false)
end
it 'excludes assignees' do
post_graphql(query, current_user: current_user)
expect(first_assignees).to be_empty
expect(second_assignees).to be_empty
end
end
end end
# frozen_string_literal: true
require 'spec_helper'
describe 'getting Alert Management Alert Notes' do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:current_user) { create(:user) }
let_it_be(:first_alert) { create(:alert_management_alert, project: project, assignees: [current_user]) }
let_it_be(:second_alert) { create(:alert_management_alert, project: project) }
let_it_be(:first_system_note) { create(:note_on_alert, noteable: first_alert, project: project) }
let_it_be(:second_system_note) { create(:note_on_alert, noteable: first_alert, project: project) }
let(:params) { {} }
let(:fields) do
<<~QUERY
nodes {
iid
notes {
nodes {
id
}
}
}
QUERY
end
let(:query) do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
query_graphql_field('alertManagementAlerts', params, fields)
)
end
let(:alerts_result) { graphql_data.dig('project', 'alertManagementAlerts', 'nodes') }
let(:notes_result) { alerts_result.map { |alert| [alert['iid'], alert['notes']['nodes']] }.to_h }
let(:first_notes_result) { notes_result[first_alert.iid.to_s] }
let(:second_notes_result) { notes_result[second_alert.iid.to_s] }
before do
project.add_developer(current_user)
end
it 'returns the notes ordered by createdAt' do
post_graphql(query, current_user: current_user)
expect(first_notes_result.length).to eq(2)
expect(first_notes_result.first).to include('id' => first_system_note.to_global_id.to_s)
expect(first_notes_result.second).to include('id' => second_system_note.to_global_id.to_s)
expect(second_notes_result).to be_empty
end
it 'avoids N+1 queries' do
base_count = ActiveRecord::QueryRecorder.new do
post_graphql(query, current_user: current_user)
end
# An N+1 would mean a new alert would increase the query count
create(:alert_management_alert, project: project)
expect { post_graphql(query, current_user: current_user) }.not_to exceed_query_limit(base_count)
expect(alerts_result.length).to eq(3)
end
end
...@@ -10,7 +10,6 @@ describe 'getting Alert Management Alerts' do ...@@ -10,7 +10,6 @@ describe 'getting Alert Management Alerts' do
let_it_be(:resolved_alert) { create(:alert_management_alert, :all_fields, :resolved, project: project, issue: nil, severity: :low) } let_it_be(:resolved_alert) { create(:alert_management_alert, :all_fields, :resolved, project: project, issue: nil, severity: :low) }
let_it_be(:triggered_alert) { create(:alert_management_alert, :all_fields, project: project, severity: :critical, payload: payload) } let_it_be(:triggered_alert) { create(:alert_management_alert, :all_fields, project: project, severity: :critical, payload: payload) }
let_it_be(:other_project_alert) { create(:alert_management_alert, :all_fields) } let_it_be(:other_project_alert) { create(:alert_management_alert, :all_fields) }
let_it_be(:system_note) { create(:note_on_alert, noteable: triggered_alert, project: project) }
let(:params) { {} } let(:params) { {} }
...@@ -77,8 +76,6 @@ describe 'getting Alert Management Alerts' do ...@@ -77,8 +76,6 @@ describe 'getting Alert Management Alerts' do
'updatedAt' => triggered_alert.updated_at.strftime('%Y-%m-%dT%H:%M:%SZ') 'updatedAt' => triggered_alert.updated_at.strftime('%Y-%m-%dT%H:%M:%SZ')
) )
expect(first_alert['notes']['nodes'].first).to include('id' => system_note.to_global_id.to_s)
expect(second_alert).to include( expect(second_alert).to include(
'iid' => resolved_alert.iid.to_s, 'iid' => resolved_alert.iid.to_s,
'issueIid' => nil, 'issueIid' => nil,
......
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