Merge pull request #1690 from grafana/dev

v1.2.7
This commit is contained in:
Joey Orlando 2023-04-03 11:51:43 +02:00 committed by GitHub
commit 5a32defe99
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 231 additions and 91 deletions

View file

@ -373,16 +373,14 @@ jobs:
uses: actions/cache@v3
with:
path: "~/.cache/ms-playwright"
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}-chromium-firefox-webkit
- name: Install Playwright binaries/dependencies
if: steps.playwright-cache.outputs.cache-hit != 'true'
# if more browsers are added, will need to modify the "npx playwright install" command
# https://stackoverflow.com/questions/65900299/install-single-dependency-from-package-json-with-yarn
run: |
yarn add "@playwright/test@${{ env.PLAYWRIGHT_VERSION }}"
npx playwright install --with-deps chromium firefox
npx playwright install-deps
npx playwright install --with-deps chromium firefox webkit
- name: Await k8s pods and other resources up
uses: jupyterhub/action-k8s-await-workloads@v1

View file

@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## v1.2.7 (2023-04-03)
### Added
- Save selected teams filter in local storage ([1611](https://github.com/grafana/oncall/issues/1611))
### Changed
- Renamed routes from /incidents to /alert-groups ([#1678](https://github.com/grafana/oncall/pull/1678))
### Fixed
- Fix team search when filtering resources by @vadimkerr ([#1680](https://github.com/grafana/oncall/pull/1680))
- Fix issue when trying to scroll in Safari ([#415](https://github.com/grafana/oncall/issues/415))
## v1.2.6 (2023-03-30)
### Fixed

View file

@ -45,6 +45,49 @@ def test_list_teams(
assert response.json() == expected_payload
@pytest.mark.django_db
@pytest.mark.parametrize(
"search,team_names",
[
("", [GENERAL_TEAM.name, "team 1", "team 2"]),
("team", [GENERAL_TEAM.name, "team 1", "team 2"]),
("no team", [GENERAL_TEAM.name]),
("team ", [GENERAL_TEAM.name, "team 1", "team 2"]),
("team 1", [GENERAL_TEAM.name, "team 1"]),
],
)
def test_list_teams_search_by_name(
make_organization,
make_team,
make_user_for_organization,
make_token_for_organization,
make_user_auth_headers,
search,
team_names,
):
organization = make_organization()
user = make_user_for_organization(organization)
_, token = make_token_for_organization(organization)
for team_name in team_names:
if team_name != GENERAL_TEAM.name:
make_team(organization, name=team_name)
client = APIClient()
url = reverse("api-internal:team-list") + f"?search={search}"
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
expected_json = [
get_payload_from_team(organization.teams.get(name=team_name))
if team_name != GENERAL_TEAM.name
else get_payload_from_team(GENERAL_TEAM)
for team_name in team_names
]
assert response.json() == expected_json
@pytest.mark.django_db
def test_list_teams_for_non_member(
make_organization,

View file

@ -1,6 +1,6 @@
from rest_framework import mixins, viewsets
from rest_framework.filters import SearchFilter
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from apps.api.permissions import RBACPermission
from apps.api.serializers.team import TeamSerializer
@ -23,17 +23,15 @@ class TeamViewSet(PublicPrimaryKeyMixin, mixins.ListModelMixin, mixins.UpdateMod
}
serializer_class = TeamSerializer
filter_backends = [SearchFilter]
search_fields = ["name"]
def get_queryset(self):
return self.request.user.available_teams
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
def filter_queryset(self, queryset):
"""
Adds general team to the queryset in a way that it always shows up first (even when not searched for).
"""
general_team = Team(public_primary_key="null", name="No team", email=None, avatar_url=None)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer([general_team] + list(page), many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer([general_team] + list(queryset), many=True)
return Response(serializer.data)
return [general_team] + list(super().filter_queryset(queryset))

View file

@ -6,6 +6,9 @@ import { createIntegrationAndSendDemoAlert } from '../utils/integrations';
import { createOnCallSchedule } from '../utils/schedule';
test('we can create an oncall schedule + receive an alert', async ({ page }) => {
// this test does a lot of stuff, lets give it adequate time to do its thing
test.slow();
const escalationChainName = generateRandomValue();
const integrationName = generateRandomValue();
const onCallScheduleName = generateRandomValue();

View file

@ -39,7 +39,7 @@ export const clickButton = async ({
dataTestId,
}: ClickButtonArgs): Promise<void> => {
const baseLocator = dataTestId ? `button[data-testid="${dataTestId}"]` : 'button';
const button = (startingLocator || page).locator(`${baseLocator} >> text=${buttonText}`);
const button = (startingLocator || page).locator(`${baseLocator}:not([disabled]) >> text=${buttonText}`);
await button.waitFor({ state: 'visible' });
await button.click();

View file

@ -14,7 +14,6 @@ const config: PlaywrightTestConfig = {
testDir: './integration-tests',
globalSetup: './integration-tests/globalSetup.ts',
/* Maximum time one test can run for. */
// TODO: set this back to 60 when GSelect component is refactored
timeout: 90 * 1000,
expect: {
/**
@ -28,10 +27,7 @@ const config: PlaywrightTestConfig = {
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 1 : 0,
// TODO: when GSelect component is refactored, run using 3 workers
// locally use one worker, on CI use 3
// workers: process.env.CI ? 3 : 1,
retries: process.env.CI ? 3 : 0,
workers: 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
@ -64,14 +60,12 @@ const config: PlaywrightTestConfig = {
...devices['Desktop Firefox'],
},
},
// TODO: enable tests on Safari once the scroll bug when creating an integration is patched
// {
// name: 'webkit',
// use: {
// ...devices['Desktop Safari'],
// },
// },
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
},
/* Test against mobile viewports. */
// {

View file

@ -66,7 +66,7 @@ const Tutorial: FC<TutorialProps> = (props) => {
</div>
<Arrow />
<div className={cx('step')}>
<PluginLink query={{ page: 'incidents' }}>
<PluginLink query={{ page: 'alert-groups' }}>
<div className={cx('icon', { icon_active: step === TutorialStep.Incidents })}>
<img src={bellIcon} />
</div>

View file

@ -65,7 +65,7 @@ const AlertReceiveChannelCard = observer((props: AlertReceiveChannelCardProps) =
</Text>
{alertReceiveChannelCounter && (
<PluginLink
query={{ page: 'incidents', integration: alertReceiveChannel.id }}
query={{ page: 'alert-groups', integration: alertReceiveChannel.id }}
className={cx('alertsInfoText')}
>
<Badge

View file

@ -70,7 +70,7 @@ interface AlertRulesState {
const Notification: React.FC = () => (
<div>
Demo alert was generated. Find it on the
<PluginLink query={{ page: 'incidents' }}> "Alert Groups" </PluginLink>
<PluginLink query={{ page: 'alert-groups' }}> "Alert Groups" </PluginLink>
page and make sure it didn't freak out your colleagues 😉
</div>
);

View file

@ -63,15 +63,15 @@ const AttachIncidentForm = observer(({ id, onUpdate, onHide }: AttachIncidentFor
title={
<HorizontalGroup>
<Icon size="lg" name="link" />
<Text.Title level={4}>Attach to another incident</Text.Title>
<Text.Title level={4}>Attach to another alert group</Text.Title>
</HorizontalGroup>
}
className={cx('root')}
onDismiss={onHide}
>
<Field
label="Incident to be attached with"
description="Linking incidents together can help the team investigate the underlying issue."
label="Alert group to be attached with"
description="Linking alert groups together can help the team investigate the underlying issue."
>
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
<GSelect

View file

@ -101,7 +101,7 @@ const IncidentMatcher = observer((props: IncidentMatcherProps) => {
{selectedAlertItem ? (
<SourceCode noMaxHeight>{JSON.stringify(selectedAlertItem, null, 2)}</SourceCode>
) : (
<Text type="secondary"> Select incident first</Text>
<Text type="secondary"> Select alert group first</Text>
)}
</div>
</div>

View file

@ -59,6 +59,7 @@ const IntegrationSettings = observer((props: IntegrationSettingsProps) => {
const [expanded, _setExpanded] = useState(false);
const handleSwitchToTemplate = (templateName: string) => {
setActiveTab(IntegrationSettingsTab.Templates);
setSelectedTemplate(templateName);
};

View file

@ -34,7 +34,12 @@
padding: 4px 8px;
margin-top: 8px;
min-width: 500px;
width: 520px;
width: 620px;
}
.autoresolve-div {
display: flex;
align-items: baseline;
}
.warning-icon-color {

View file

@ -150,7 +150,7 @@ const Autoresolve = ({ alertReceiveChannelId, onSwitchToTemplate, alertGroupId }
<Label>
<div className={cx('settings-label')}>
Autoresolve
<Text type="secondary">How should this integration resolve incidents?</Text>
<Text type="secondary">How should this integration resolve alert groups?</Text>
</div>
</Label>
<div className={cx('team-select')}>
@ -172,9 +172,9 @@ const Autoresolve = ({ alertReceiveChannelId, onSwitchToTemplate, alertGroupId }
{autoresolveSelected && (
<>
<Block shadowed bordered className={cx('autoresolve-block')}>
<div>
<div className={cx('autoresolve-div')}>
<Text type="secondary" size="small">
<Icon name="info-circle" /> Incident will be automatically resolved when it matches{' '}
<Icon name="info-circle" /> Alert group will be automatically resolved when it matches{' '}
</Text>
<Button fill="text" size="sm" onClick={handleGoToTemplateSettingsCllick}>
autoresolve condition

View file

@ -36,7 +36,7 @@ export const form: { name: string; fields: FormItem[] } = {
},
{
value: MaintenanceMode.Maintenance,
label: 'Maintenance (collect everything in one incident)',
label: 'Maintenance (collect everything in one alert group)',
},
],
},

View file

@ -12,7 +12,8 @@
}
.draggable {
top: 0;
top: 10%;
position: absolute;
/* transition: transform 300ms ease; */
}

View file

@ -1,6 +1,7 @@
/* Navigation/Layout */
.drawer-content {
.drawer-content,
.rc-drawer-content {
overflow: auto !important; /* fix https://github.com/grafana/oncall/issues/415 */
}

View file

@ -3,10 +3,13 @@ import { action, observable } from 'mobx';
import BaseStore from 'models/base_store';
import { makeRequest } from 'network';
import { RootStore } from 'state';
import { getItem, setItem } from 'utils/localStorage';
import { getApiPathByPage } from './filters.helpers';
import { FilterOption, FiltersValues } from './filters.types';
const LOCAL_STORAGE_FILTERS_KEY = 'grafana.oncall.global-filters';
export class FiltersStore extends BaseStore {
@observable.shallow
public options: { [page: string]: FilterOption[] } = {};
@ -14,10 +17,25 @@ export class FiltersStore extends BaseStore {
@observable.shallow
public values: { [page: string]: FiltersValues } = {};
public globalValues: FiltersValues = {};
private _globalValues: FiltersValues = {};
constructor(rootStore: RootStore) {
super(rootStore);
const savedFilters = getItem(LOCAL_STORAGE_FILTERS_KEY);
if (savedFilters) {
this._globalValues = { ...savedFilters };
}
}
set globalValues(value: any) {
this._globalValues = value;
setItem(LOCAL_STORAGE_FILTERS_KEY, value);
}
get globalValues() {
return this._globalValues;
}
@action

View file

@ -72,6 +72,7 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
.loadItem(id, true)
.catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }));
await escalationChainStore.updateEscalationChainDetails(id);
if (!escalationChain) {
return;
}
@ -286,12 +287,7 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
return (
<>
<Block withBackground className={cx('header')}>
<Text
size="large"
editable
onTextChange={this.handleEscalationChainNameChange}
data-testid="escalation-chain-name"
>
<Text size="large" onTextChange={this.handleEscalationChainNameChange} data-testid="escalation-chain-name">
{escalationChain.name}
</Text>
<div className={cx('buttons')}>

View file

@ -144,7 +144,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
<VerticalGroup spacing="lg" align="center">
<Text.Title level={1}>404</Text.Title>
<Text.Title level={4}>Alert group not found</Text.Title>
<PluginLink query={{ page: 'incidents', cursor, start, perpage }}>
<PluginLink query={{ page: 'alert-groups', cursor, start, perpage }}>
<Button variant="secondary" icon="arrow-left" size="md">
Go to Alert Groups page
</Button>
@ -244,7 +244,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
<VerticalGroup>
<HorizontalGroup justify="space-between">
<HorizontalGroup className={cx('title')}>
<PluginLink query={{ page: 'incidents', cursor, start, perpage }}>
<PluginLink query={{ page: 'alert-groups', cursor, start, perpage }}>
<IconButton name="arrow-left" size="xxl" />
</PluginLink>
{/* @ts-ignore*/}
@ -256,12 +256,12 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
{incident.root_alert_group && (
<Text type="secondary">
Attached to{' '}
<PluginLink query={{ page: 'incident', id: incident.root_alert_group.pk }}>
<PluginLink query={{ page: 'alert-groups', id: incident.root_alert_group.pk }}>
#{incident.root_alert_group.inside_organization_number}{' '}
{incident.root_alert_group.render_for_web.title}
</PluginLink>{' '}
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
<Button variant="secondary" onClick={this.getUnattachClickHandler(incident.pk)} size="sm">
<Button variant="secondary" onClick={() => this.getUnattachClickHandler(incident.pk)} size="sm">
Unattach
</Button>
</WithPermissionControlTooltip>
@ -421,9 +421,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
getUnattachClickHandler = (pk: Alert['pk']) => {
const { store } = this.props;
return () => {
store.alertGroupStore.unattachAlert(pk).then(this.update);
};
return store.alertGroupStore.unattachAlert(pk).then(this.update);
};
renderTimeline = () => {
@ -762,7 +760,7 @@ function AttachedIncidentsList({
{alerts.map((incident) => {
return (
<HorizontalGroup key={incident.pk} justify={'space-between'}>
<PluginLink query={{ page: 'incident', id: incident.pk }}>
<PluginLink query={{ page: 'alert-groups', id: incident.pk }}>
#{incident.inside_organization_number} {incident.render_for_web.title}
</PluginLink>
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>

View file

@ -127,7 +127,7 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
this.setState({ showAddAlertGroupForm: false });
}}
onCreate={(id: Alert['pk']) => {
history.push(`${PLUGIN_ROOT}/incidents/${id}`);
history.push(`${PLUGIN_ROOT}/alert-groups/${id}`);
}}
/>
)}
@ -557,7 +557,13 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
<VerticalGroup spacing="none" justify="center">
<div className={'table__wrap-column'}>
<PluginLink
query={{ page: 'incidents', id: record.pk, cursor: incidentsCursor, perpage: incidentsItemsPerPage, start }}
query={{
page: 'alert-groups',
id: record.pk,
cursor: incidentsCursor,
perpage: incidentsItemsPerPage,
start,
}}
>
<Tooltip placement="top" content={record.render_for_web.title}>
<span>{record.render_for_web.title}</span>

View file

@ -27,24 +27,11 @@ function getPath(name = '') {
export const pages: { [id: string]: PageDefinition } = [
{
icon: 'bell',
id: 'incidents',
id: 'alert-groups',
hideFromBreadcrumbs: true,
text: 'Alert Groups',
hideTitle: true,
path: getPath('incidents'),
action: UserActions.AlertGroupsRead,
},
{
icon: 'bell',
id: 'incident',
text: '',
hideFromTabs: true,
hideFromBreadcrumbs: true,
parentItem: {
text: 'Incident',
url: `${PLUGIN_ROOT}/incidents`,
},
path: getPath('incident'),
path: getPath('alert-groups'),
action: UserActions.AlertGroupsRead,
},
{
@ -189,8 +176,8 @@ export const pages: { [id: string]: PageDefinition } = [
}, {});
export const ROUTES = {
incidents: ['incidents'],
incident: ['incidents/:id'],
'alert-groups': ['alert-groups'],
'alert-group': ['alert-groups/:id'],
users: ['users', 'users/:id'],
integrations: ['integrations', 'integrations/:id'],
escalations: ['escalations', 'escalations/:id'],
@ -205,6 +192,10 @@ export const ROUTES = {
'live-settings': ['live-settings'],
cloud: ['cloud'],
test: ['test'],
// backwards compatible to redirect to new alert-groups
incident: ['incidents/:id'],
incidents: ['incidents'],
};
export const getRoutesForPage = (name: string) => {

View file

@ -41,7 +41,7 @@
{
"type": "page",
"name": "Alert Groups",
"path": "/a/grafana-oncall-app/incidents",
"path": "/a/grafana-oncall-app/alert-groups",
"role": "Viewer",
"action": "grafana-oncall-app.alert-groups:read",
"addToNav": true

View file

@ -14,7 +14,7 @@ import weekday from 'dayjs/plugin/weekday';
import { observer, Provider } from 'mobx-react';
import Header from 'navbar/Header/Header';
import LegacyNavTabsBar from 'navbar/LegacyNavTabsBar';
import { Route, Switch, useLocation } from 'react-router-dom';
import { Redirect, Route, Switch, useLocation } from 'react-router-dom';
import { AppRootProps } from 'types';
import Unauthorized from 'components/Unauthorized';
@ -138,10 +138,10 @@ export const Root = observer((props: AppRootProps) => {
>
{userHasAccess ? (
<Switch>
<Route path={getRoutesForPage('incidents')} exact>
<Route path={getRoutesForPage('alert-groups')} exact>
<Incidents query={query} />
</Route>
<Route path={getRoutesForPage('incident')} exact>
<Route path={getRoutesForPage('alert-group')} exact>
<Incident query={query} />
</Route>
<Route path={getRoutesForPage('users')} exact>
@ -183,6 +183,33 @@ export const Root = observer((props: AppRootProps) => {
<Route path={getRoutesForPage('cloud')} exact>
<CloudPage />
</Route>
{/* Backwards compatibility redirect routes */}
<Route
path={getRoutesForPage('incident')}
exact
render={({ location }) => (
<Redirect
to={{
...location,
pathname: location.pathname.replace(/incident/, 'alert-group'),
}}
></Redirect>
)}
></Route>
<Route
path={getRoutesForPage('incidents')}
exact
render={({ location }) => (
<Redirect
to={{
...location,
pathname: location.pathname.replace(/incidents/, 'alert-groups'),
}}
></Redirect>
)}
></Route>
<Route path="*">
<NoMatch />
</Route>

View file

@ -11,7 +11,7 @@ export const GRAFANA_LICENSE_OSS = 'OpenSource';
export const BREAKPOINT_TABS = 1024;
// Default redirect page
export const DEFAULT_PAGE = 'incidents';
export const DEFAULT_PAGE = 'alert-groups';
export const PLUGIN_ROOT = '/a/grafana-oncall-app';

View file

@ -86,7 +86,7 @@ pd-oncall-migrator
It's possible to specify a default contact method type for user notification rules that cannot be migrated as-is by
changing the `ONCALL_DEFAULT_CONTACT_METHOD` env variable.
Options are: `email`, `sms`, `phone_call`, `slack`, `telegram` (default is `email`).
Options are: `email`, `sms`, `phone_call`, `slack`, `telegram`, `mobile_app` (default is `email`).
### After migration

View file

@ -126,7 +126,13 @@ def main() -> None:
if rulesets is not None:
for ruleset in rulesets:
match_ruleset(ruleset, oncall_integrations, escalation_policies, services)
match_ruleset(
ruleset,
oncall_integrations,
escalation_policies,
services,
integrations,
)
if MODE == MODE_PLAN:
print(user_report(users), end="\n\n")

View file

@ -21,7 +21,7 @@ PAGERDUTY_TO_ONCALL_CONTACT_METHOD_MAP = {
"sms_contact_method": "notify_by_sms",
"phone_contact_method": "notify_by_phone_call",
"email_contact_method": "notify_by_email",
"push_notification_contact_method": ONCALL_DEFAULT_CONTACT_METHOD,
"push_notification_contact_method": "notify_by_mobile_app",
}
PAGERDUTY_TO_ONCALL_VENDOR_MAP = {
"Datadog": "datadog",
@ -46,3 +46,7 @@ SCHEDULE_MIGRATION_MODE = os.getenv(
EXPERIMENTAL_MIGRATE_EVENT_RULES = (
os.getenv("EXPERIMENTAL_MIGRATE_EVENT_RULES", "false").lower() == "true"
)
# Set to true to include service & integration names in the ruleset name
EXPERIMENTAL_MIGRATE_EVENT_RULES_LONG_NAMES = (
os.getenv("EXPERIMENTAL_MIGRATE_EVENT_RULES_LONG_NAMES", "false").lower() == "true"
)

View file

@ -189,10 +189,8 @@ def ruleset_report(rulesets: list[dict]) -> str:
):
result += "\n" + TAB + format_ruleset(ruleset)
if not ruleset["flawed_escalation_policies"] and ruleset["oncall_integration"]:
result += (
" (existing integration with name '{} Ruleset' will be deleted)".format(
ruleset["name"]
)
result += " (existing integration with name '{}' will be deleted)".format(
ruleset["oncall_name"]
)
return result

View file

@ -1,4 +1,5 @@
from migrator import oncall_api_client
from migrator.config import EXPERIMENTAL_MIGRATE_EVENT_RULES_LONG_NAMES
from migrator.utils import find_by_id
@ -7,14 +8,16 @@ def match_ruleset(
oncall_integrations: list[dict],
escalation_policies: list[dict],
services: list[dict],
integrations: list[dict],
) -> None:
# Find existing integration with the same name
oncall_integration = None
name = "{} Ruleset".format(ruleset["name"]).lower().strip()
name = _generate_ruleset_name(ruleset, services, integrations)
for candidate in oncall_integrations:
if candidate["name"].lower().strip() == name:
if candidate["name"].lower().strip() == name.lower().strip():
oncall_integration = candidate
ruleset["oncall_integration"] = oncall_integration
ruleset["oncall_name"] = name
# Find services that use escalation policies that cannot be migrated
service_ids = [
@ -52,7 +55,7 @@ def migrate_ruleset(
# Create new integration with type "webhook"
integration_payload = {
"name": "{} Ruleset".format(ruleset["name"]),
"name": ruleset["oncall_name"],
"type": "webhook",
"team_id": None,
}
@ -163,3 +166,37 @@ def _pd_service_id_to_oncall_escalation_chain_id(
escalation_chain_id = escalation_policy["oncall_escalation_chain"]["id"]
return escalation_chain_id
def _generate_ruleset_name(ruleset, services, integrations):
result = "{} Ruleset".format(ruleset["name"])
if not EXPERIMENTAL_MIGRATE_EVENT_RULES_LONG_NAMES:
return result
service_ids = [
r["actions"]["route"]["value"]
for r in sorted(ruleset["rules"], key=lambda r: r["position"])
if not r["disabled"] and r["actions"]["route"]
]
ruleset_services = [find_by_id(services, service_id) for service_id in service_ids]
ruleset_services = [s for s in ruleset_services if s is not None]
if not ruleset_services:
return result
service_names = []
for service in ruleset_services:
service_name = service["name"]
service_integrations = [
integration
for integration in integrations
if integration["service"]["id"] == service["id"]
]
if service_integrations:
service_name += " ({})".format(
", ".join([integration["name"] for integration in service_integrations])
)
service_names.append(service_name)
# OnCall limit for integration name is 150 chars
return "{}: {}".format(result, ", ".join(service_names))[:150]