feat: link commits, branches and pull requests to tickets

A project can turn on a webhook for GitHub, Gitea or GitLab. Pushes and
pull or merge requests that name a ticket key show on that ticket under
Desarrollo, with the state of each pull request; the secret is shown and
rotated from the project settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 15:28:12 +02:00
parent 24b37b6628
commit 046d77c813
48 changed files with 2638 additions and 12 deletions

View File

@@ -39,6 +39,9 @@ import {
StartSprintCommand,
} from './sprint.commands';
import { IssueTree } from './issue-tree';
import { GIT_HANDLERS, RecordGitMentionsCommand } from '../git/git';
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
import { DevLinkKind } from '@task-stream/api/tracker/domain';
import { SetIssueParentCommand } from './set-issue-parent.command';
import { RankIssueCommand } from './rank-issue.command';
import {
@@ -67,9 +70,12 @@ import { DeleteCommentCommand } from './delete-comment.command';
import { PublishReleaseCommand } from './publish-release.command';
import { SetIssueReleaseCommand } from './set-issue-release.command';
import { WriteAccess } from './write-access';
import { ReadAccess } from '../queries/read-access';
import { FindProjectsRepository } from '../ports/find-projects.repository';
async function setup() {
const store = new InMemoryEventStore();
const keys = new Map<string, { id: string; projectId: string }>();
const sprints = {
active: null as { id: string; name: string } | null,
findActive: async () => sprints.active,
@@ -85,9 +91,16 @@ async function setup() {
imports: [CqrsModule.forRoot()],
providers: [
...COMMAND_HANDLERS,
...GIT_HANDLERS,
...TRACKER_FACTORIES,
WriteAccess,
IssueTree,
ReadAccess,
{
provide: FindProjectsRepository,
useValue: { findRole: async () => null, findById: async () => null },
},
{ provide: GitIntegrationsRepository, useValue: {} },
AggregateRehydrator,
{ provide: EventStore, useValue: store },
{ provide: AttachmentStorage, useValue: files },
@@ -108,6 +121,8 @@ async function setup() {
{
provide: FindIssuesRepository,
useValue: {
// Tickets by key, as the read model would answer (filled by tests).
findByKey: async (key: string) => keys.get(key) ?? null,
sprintProgress: async () => ({
total: 0,
finished: 0,
@@ -129,6 +144,7 @@ async function setup() {
store,
files,
sprints,
keys,
run,
rehydrator: moduleRef.get(AggregateRehydrator),
};
@@ -476,6 +492,34 @@ describe('tracker commands (event sourced)', () => {
).rejects.toThrow(ForbiddenError);
});
it('links commits and pull requests to the tickets they name', async () => {
const { run, rehydrator, keys } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Pay', {}),
);
keys.set(issue.key ?? '', { id: issue.id, projectId });
const commit = {
text: `${issue.key}: card form (refs TS-999, OTHER-1)`,
link: {
kind: DevLinkKind.COMMIT,
url: 'https://git.example.org/acme/web/commit/abc',
title: 'card form',
ref: 'abc',
state: null,
provider: 'gitea',
author: 'Ana',
at: '2026-10-08T10:00:00Z',
},
};
expect(
await run(new RecordGitMentionsCommand(projectId, [commit, commit])),
).toBe(1);
const { devLinks } = await rehydrator.rehydrate(issue.id, Issue);
expect(devLinks.map((l) => l.ref)).toEqual(['abc']);
});
it('does not let viewers modify tickets', async () => {
const { run } = await setup();
const projectId = await projectWithTeam(run);

View File

@@ -9,6 +9,7 @@ import {
IssueChecklistItemRemovedEvent,
IssueChecklistItemUpdatedEvent,
IssueParentChangedEvent,
IssueDevLinkRecordedEvent,
IssueCreatedEvent,
IssueLinkedEvent,
IssueLinkType,
@@ -162,6 +163,19 @@ export function describeEvent(
? `${actor} metió ${issueKey} en ${sprint}`
: `${actor} sacó ${issueKey} de ${sprint}`;
}
if (event instanceof IssueDevLinkRecordedEvent) {
const { kind, ref, state, author } = event.link;
const who = author ? ` (${author})` : '';
if (kind === 'COMMIT') return `Commit ${ref} sobre ${issueKey}${who}`;
if (kind === 'BRANCH') return `Rama ${ref} para ${issueKey}${who}`;
const verb =
state === 'MERGED'
? 'fusionada'
: state === 'CLOSED'
? 'cerrada'
: 'abierta';
return `Pull request ${ref} ${verb} para ${issueKey}${who}`;
}
if (event instanceof IssueParentChangedEvent) {
return event.parentKey
? `${actor} colocó ${issueKey} bajo ${event.parentKey}`

View File

@@ -11,6 +11,7 @@ import {
IssueChecklistItemRemovedEvent,
IssueChecklistItemUpdatedEvent,
IssueParentChangedEvent,
IssueDevLinkRecordedEvent,
IssueCreatedEvent,
IssueLinkedEvent,
IssueReleaseChangedEvent,
@@ -68,6 +69,7 @@ import { versionOf } from './read-model-notifier';
IssueAttachmentAddedEvent,
IssueAttachmentRemovedEvent,
IssueParentChangedEvent,
IssueDevLinkRecordedEvent,
IssueChecklistItemAddedEvent,
IssueChecklistItemUpdatedEvent,
IssueChecklistItemRemovedEvent,

View File

@@ -33,6 +33,7 @@ import { IssueChecklistEventHandler } from './issue-checklist.event-handler';
import { IssueRankedEventHandler } from './issue-ranked.event-handler';
import { SprintEventHandler } from './sprint.event-handler';
import { ReleaseNotesWrittenEventHandler } from './release-notes-written.event-handler';
import { IssueDevLinkRecordedEventHandler } from './issue-dev-link-recorded.event-handler';
import { IssueSprintChangedEventHandler } from './issue-sprint-changed.event-handler';
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
@@ -71,6 +72,7 @@ export const EVENT_HANDLERS = [
IssueRankedEventHandler,
SprintEventHandler,
ReleaseNotesWrittenEventHandler,
IssueDevLinkRecordedEventHandler,
IssueSprintChangedEventHandler,
];

View File

@@ -0,0 +1,27 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { IssueDevLinkRecordedEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(IssueDevLinkRecordedEvent)
export class IssueDevLinkRecordedEventHandler
implements IEventHandler<IssueDevLinkRecordedEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: IssueDevLinkRecordedEvent) {
// Code moving is not an edit of the ticket: `updatedAt` stays.
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
version: versionOf(event),
});
await this.upsertMaterializedIssueRepository.putDevLink(
event.aggregateId,
event.link,
);
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -0,0 +1,92 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { ForbiddenError } from '@task-stream/api/shared/domain';
import {
DisableGitIntegrationCommand,
EnableGitIntegrationCommand,
GetGitIntegrationQuery,
GitIntegration,
RecordGitMentionsCommand,
} from './git/git';
import {
gitMentionsOf,
gitProviderOf,
verifyGitSignature,
} from './git/git-payloads';
import { FindProjectsRepository } from './ports/find-projects.repository';
import { GitIntegrationsRepository } from './ports/git-integrations.repository';
export interface GitDelivery {
/** Project id or key, from the webhook URL. */
project: string;
headers: Record<string, string | undefined>;
rawBody: Buffer | undefined;
body: Record<string, unknown>;
}
/** Links commits, branches and pull requests to the tickets they name. */
@Injectable()
export class GitService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
private readonly findProjects: FindProjectsRepository,
private readonly integrations: GitIntegrationsRepository,
) {}
integration(viewerId: string, projectId: string): Promise<GitIntegration> {
return this.queryBus.execute(
new GetGitIntegrationQuery(viewerId, projectId),
);
}
enable(actorId: string, projectId: string): Promise<GitIntegration> {
return this.commandBus.execute(
new EnableGitIntegrationCommand(actorId, projectId),
);
}
disable(actorId: string, projectId: string): Promise<void> {
return this.commandBus.execute(
new DisableGitIntegrationCommand(actorId, projectId),
);
}
/**
* A webhook delivery. Unknown project, integration off or a bad signature
* all look the same from outside (403): nothing to learn by probing.
*/
async receive(delivery: GitDelivery): Promise<{ recorded: number }> {
const project =
(await this.findProjects.findById(delivery.project)) ??
(await this.findProjects.findByKey(delivery.project.toUpperCase()));
const secret = project
? await this.integrations.secretOf(project.id)
: null;
const source = gitProviderOf(delivery.headers);
if (
!project ||
!secret ||
!source ||
!verifyGitSignature(
source.provider,
delivery.headers,
delivery.rawBody,
secret,
)
)
throw new ForbiddenError(
'Unknown project, integration off or bad signature',
);
const mentions = gitMentionsOf(
source.provider,
source.event,
delivery.body,
);
if (!mentions.length) return { recorded: 0 };
const recorded: number = await this.commandBus.execute(
new RecordGitMentionsCommand(project.id, mentions),
);
return { recorded };
}
}

View File

@@ -0,0 +1,88 @@
import { createHmac } from 'node:crypto';
import { DevLinkKind, DevLinkState } from '@task-stream/api/tracker/domain';
import {
gitMentionsOf,
gitProviderOf,
verifyGitSignature,
} from './git-payloads';
describe('git payloads', () => {
const body = Buffer.from('{"a":1}');
const hmac = createHmac('sha256', 's3cret').update(body).digest('hex');
it('tells the host apart and checks its signature', () => {
expect(gitProviderOf({ 'x-gitea-event': 'push' })).toEqual({
provider: 'gitea',
event: 'push',
});
expect(gitProviderOf({})).toBeNull();
expect(
verifyGitSignature(
'github',
{ 'x-hub-signature-256': `sha256=${hmac}` },
body,
's3cret',
),
).toBe(true);
expect(
verifyGitSignature('gitea', { 'x-gitea-signature': hmac }, body, 'other'),
).toBe(false);
expect(
verifyGitSignature(
'gitlab',
{ 'x-gitlab-token': 's3cret' },
body,
's3cret',
),
).toBe(true);
});
it('reads the commits and the branch of a push', () => {
const mentions = gitMentionsOf('gitea', 'push', {
ref: 'refs/heads/web-7-checkout',
repository: { html_url: 'https://git.example.org/acme/web/' },
pusher: { login: 'ana' },
commits: [
{
id: 'abcdef1234567',
message: 'WEB-7: card form\n\nlong text',
url: 'https://git.example.org/acme/web/commit/abcdef1234567',
author: { name: 'Ana' },
timestamp: '2026-10-08T10:00:00Z',
},
],
});
expect(mentions.map((m) => [m.link.kind, m.link.ref, m.text])).toEqual([
[DevLinkKind.COMMIT, 'abcdef12', 'WEB-7: card form\n\nlong text'],
[DevLinkKind.BRANCH, 'web-7-checkout', 'web-7-checkout'],
]);
expect(mentions[1].link.url).toBe(
'https://git.example.org/acme/web/src/branch/web-7-checkout',
);
});
it('reads pull and merge requests with their state', () => {
const [pr] = gitMentionsOf('github', 'pull_request', {
pull_request: {
html_url: 'https://github.com/acme/web/pull/12',
number: 12,
title: 'Pay by card',
head: { ref: 'web-7' },
state: 'closed',
merged: true,
},
});
expect(pr.link).toMatchObject({ ref: '#12', state: DevLinkState.MERGED });
expect(pr.text).toContain('web-7');
const [mr] = gitMentionsOf('gitlab', 'Merge Request Hook', {
object_attributes: {
url: 'https://gitlab.com/a/b/-/merge_requests/3',
iid: 3,
title: 'WEB-9',
state: 'opened',
},
});
expect(mr.link).toMatchObject({ ref: '!3', state: DevLinkState.OPEN });
expect(gitMentionsOf('github', 'ping', {})).toEqual([]);
});
});

View File

@@ -0,0 +1,178 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import {
DevLink,
DevLinkKind,
DevLinkState,
} from '@task-stream/api/tracker/domain';
export type GitProvider = 'github' | 'gitea' | 'gitlab';
/** Code the host told us about, and the text where ticket keys may appear. */
export interface GitMention {
text: string;
link: DevLink;
}
type Json = Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
/** Which host sent it, from its event header. */
export function gitProviderOf(
headers: Record<string, string | undefined>,
): { provider: GitProvider; event: string } | null {
if (headers['x-gitea-event'])
return { provider: 'gitea', event: headers['x-gitea-event'] };
if (headers['x-github-event'])
return { provider: 'github', event: headers['x-github-event'] };
if (headers['x-gitlab-event'])
return { provider: 'gitlab', event: headers['x-gitlab-event'] };
return null;
}
const same = (a: string, b: string) =>
a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
/**
* GitHub and Gitea sign the raw body (HMAC-SHA256, hex); GitLab sends the
* secret itself as a token.
*/
export function verifyGitSignature(
provider: GitProvider,
headers: Record<string, string | undefined>,
rawBody: Buffer | undefined,
secret: string,
): boolean {
if (provider === 'gitlab')
return (
!!headers['x-gitlab-token'] && same(headers['x-gitlab-token'], secret)
);
const received = (
provider === 'github'
? headers['x-hub-signature-256']
: headers['x-gitea-signature']
)?.replace(/^sha256=/, '');
if (!received || !rawBody) return false;
return same(
received,
createHmac('sha256', secret).update(rawBody).digest('hex'),
);
}
const branchOf = (ref: unknown) =>
typeof ref === 'string' && ref.startsWith('refs/heads/')
? ref.slice('refs/heads/'.length)
: null;
/** Commits of a push, and the branch they went to (all three hosts). */
function fromPush(
provider: GitProvider,
body: Json,
repoUrl: string,
): GitMention[] {
const mentions: GitMention[] = [];
const branch = branchOf(body['ref']);
for (const commit of (body['commits'] ?? []) as Json[]) {
if (!commit?.['url'] || !commit['id']) continue;
const message = String(commit['message'] ?? '');
mentions.push({
text: message,
link: {
kind: DevLinkKind.COMMIT,
url: String(commit['url']),
title: message,
ref: String(commit['id']).slice(0, 8),
state: null,
provider,
author: commit['author']?.['name'] ?? null,
at: String(commit['timestamp'] ?? new Date().toISOString()),
},
});
}
if (branch && repoUrl) {
const path =
provider === 'gitlab'
? '/-/tree/'
: provider === 'gitea'
? '/src/branch/'
: '/tree/';
mentions.push({
text: branch,
link: {
kind: DevLinkKind.BRANCH,
url: `${repoUrl}${path}${branch}`,
title: branch,
ref: branch,
state: null,
provider,
author:
body['pusher']?.['name'] ??
body['pusher']?.['login'] ??
body['user_name'] ??
null,
at: new Date().toISOString(),
},
});
}
return mentions;
}
function prState(merged: boolean, state: string): DevLinkState {
if (merged || state === 'merged') return DevLinkState.MERGED;
return state === 'closed' ? DevLinkState.CLOSED : DevLinkState.OPEN;
}
/**
* What a webhook delivery is about: commits and branches of a push, or a
* pull/merge request. Anything else (pings, tags, issues…) is nothing.
*/
export function gitMentionsOf(
provider: GitProvider,
event: string,
body: Json,
): GitMention[] {
const repoUrl = String(
body['repository']?.['html_url'] ?? body['project']?.['web_url'] ?? '',
).replace(/\/$/, '');
if (event === 'push' || event === 'Push Hook')
return fromPush(provider, body, repoUrl);
if (provider !== 'gitlab' && event === 'pull_request') {
const pr = body['pull_request'] as Json | undefined;
if (!pr?.['html_url']) return [];
const title = String(pr['title'] ?? '');
return [
{
text: `${title} ${pr['head']?.['ref'] ?? ''} ${pr['body'] ?? ''}`,
link: {
kind: DevLinkKind.PULL_REQUEST,
url: String(pr['html_url']),
title,
ref: `#${pr['number'] ?? ''}`,
state: prState(!!pr['merged'], String(pr['state'] ?? 'open')),
provider,
author: pr['user']?.['login'] ?? null,
at: String(pr['updated_at'] ?? new Date().toISOString()),
},
},
];
}
if (provider === 'gitlab' && event === 'Merge Request Hook') {
const mr = body['object_attributes'] as Json | undefined;
if (!mr?.['url']) return [];
const title = String(mr['title'] ?? '');
return [
{
text: `${title} ${mr['source_branch'] ?? ''} ${mr['description'] ?? ''}`,
link: {
kind: DevLinkKind.PULL_REQUEST,
url: String(mr['url']),
title,
ref: `!${mr['iid'] ?? ''}`,
state: prState(false, String(mr['state'] ?? 'opened')),
provider,
author: body['user']?.['username'] ?? null,
at: String(mr['updated_at'] ?? new Date().toISOString()),
},
},
];
}
return [];
}

View File

@@ -0,0 +1,157 @@
import { randomBytes } from 'node:crypto';
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryHandler,
} from '@nestjs/cqrs';
import {
Issue,
ProjectRole,
SYSTEM_ACTOR_ID,
issueKeysIn,
} from '@task-stream/api/tracker/domain';
import { WriteAccess } from '../commands/write-access';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
import { ReadAccess } from '../queries/read-access';
import { GitMention } from './git-payloads';
export interface GitIntegration {
enabled: boolean;
/** Only for whoever manages the project. */
secret: string | null;
}
/** Turns the integration on with a new secret (or rotates it). */
export class EnableGitIntegrationCommand extends Command<GitIntegration> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
) {
super();
}
}
export class DisableGitIntegrationCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
) {
super();
}
}
/** Webhook only: code from a verified delivery, linked to the tickets it names. */
export class RecordGitMentionsCommand extends Command<number> {
constructor(
public readonly projectId: string,
public readonly mentions: GitMention[],
) {
super();
}
}
export class GetGitIntegrationQuery extends Query<GitIntegration> {
constructor(
public readonly viewerId: string,
public readonly projectId: string,
) {
super();
}
}
@CommandHandler(EnableGitIntegrationCommand)
export class EnableGitIntegrationCommandHandler
implements ICommandHandler<EnableGitIntegrationCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly integrations: GitIntegrationsRepository,
) {}
async execute(command: EnableGitIntegrationCommand): Promise<GitIntegration> {
(await this.access.project(command.projectId)).assertCanManage(
command.actorId,
);
const secret = randomBytes(24).toString('hex');
await this.integrations.save(command.projectId, secret);
return { enabled: true, secret };
}
}
@CommandHandler(DisableGitIntegrationCommand)
export class DisableGitIntegrationCommandHandler
implements ICommandHandler<DisableGitIntegrationCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly integrations: GitIntegrationsRepository,
) {}
async execute(command: DisableGitIntegrationCommand): Promise<void> {
(await this.access.project(command.projectId)).assertCanManage(
command.actorId,
);
await this.integrations.remove(command.projectId);
}
}
@CommandHandler(RecordGitMentionsCommand)
export class RecordGitMentionsCommandHandler
implements ICommandHandler<RecordGitMentionsCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly findIssues: FindIssuesRepository,
) {}
/** How many tickets got something new. Unknown keys are ignored. */
async execute(command: RecordGitMentionsCommand): Promise<number> {
const project = await this.access.project(command.projectId);
let recorded = 0;
for (const mention of command.mentions) {
for (const key of issueKeysIn(mention.text, project.key)) {
const found = await this.findIssues.findByKey(key);
if (!found || found.projectId !== project.id) continue;
const issue = await this.access.live(found.id, Issue);
if (!issue) continue;
const before = issue.getUncommittedEvents().length;
issue.recordDevLink(SYSTEM_ACTOR_ID, mention.link);
if (issue.getUncommittedEvents().length === before) continue;
await issue.commitAndWait();
recorded++;
}
}
return recorded;
}
}
@QueryHandler(GetGitIntegrationQuery)
export class GetGitIntegrationQueryHandler
implements IQueryHandler<GetGitIntegrationQuery, GitIntegration>
{
constructor(
private readonly access: ReadAccess,
private readonly integrations: GitIntegrationsRepository,
) {}
async execute(query: GetGitIntegrationQuery): Promise<GitIntegration> {
const role = await this.access.assertCanView(
query.projectId,
query.viewerId,
);
const secret = await this.integrations.secretOf(query.projectId);
const manages = role === ProjectRole.OWNER || role === ProjectRole.ADMIN;
return { enabled: !!secret, secret: manages ? secret : null };
}
}
export const GIT_HANDLERS = [
EnableGitIntegrationCommandHandler,
DisableGitIntegrationCommandHandler,
RecordGitMentionsCommandHandler,
GetGitIntegrationQueryHandler,
];

View File

@@ -0,0 +1,9 @@
/**
* Per-project secret that Git hosts sign their webhooks with. Configuration,
* not history: kept outside the event store.
*/
export abstract class GitIntegrationsRepository {
abstract secretOf(projectId: string): Promise<string | null>;
abstract save(projectId: string, secret: string): Promise<void>;
abstract remove(projectId: string): Promise<void>;
}

View File

@@ -23,3 +23,4 @@ export * from './notification-mailer';
export * from './saved-filters.repository';
export * from './find-sprints.repository';
export * from './upsert-materialized-sprint.repository';
export * from './git-integrations.repository';

View File

@@ -1,5 +1,6 @@
import {
AttachmentReadModel,
DevLink,
ChecklistItemReadModel,
IssueLinkReadModel,
IssueReadModel,
@@ -14,7 +15,12 @@ export abstract class UpsertMaterializedIssueRepository {
Partial<
Omit<
IssueReadModel,
'links' | 'commentCount' | 'attachments' | 'watchers' | 'checklist'
| 'links'
| 'commentCount'
| 'attachments'
| 'watchers'
| 'checklist'
| 'devLinks'
>
>,
): Promise<void>;
@@ -47,6 +53,8 @@ export abstract class UpsertMaterializedIssueRepository {
changes: Partial<Pick<ChecklistItemReadModel, 'text' | 'done'>>,
): Promise<void>;
abstract removeChecklistItem(issueId: string, itemId: string): Promise<void>;
/** Adds a commit, branch or pull request, or replaces it (same URL). */
abstract putDevLink(issueId: string, link: DevLink): Promise<void>;
/** Keeps the denormalised parent title of a ticket's children in sync. */
abstract renameParent(parentId: string, title: string): Promise<void>;
abstract setCommentCount(

View File

@@ -32,6 +32,8 @@ import {
import { SAVED_FILTERS } from './saved-filters/saved-filters';
import { SavedFiltersService } from './saved-filters.service';
import { SprintsService } from './sprints.service';
import { GitService } from './git.service';
import { GIT_HANDLERS } from './git/git';
/** Notifications: derived from events, kept and dismissed per person. */
const NOTIFICATIONS = [
@@ -53,6 +55,7 @@ const SERVICES = [
NotificationsService,
SavedFiltersService,
SprintsService,
GitService,
];
/**
@@ -75,6 +78,7 @@ const SERVICES = [
...SAGAS,
...NOTIFICATIONS,
...SAVED_FILTERS,
...GIT_HANDLERS,
],
exports: SERVICES,
})