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:
@@ -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);
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
92
libs/api/tracker/application/src/lib/git.service.ts
Normal file
92
libs/api/tracker/application/src/lib/git.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
178
libs/api/tracker/application/src/lib/git/git-payloads.ts
Normal file
178
libs/api/tracker/application/src/lib/git/git-payloads.ts
Normal 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 [];
|
||||
}
|
||||
157
libs/api/tracker/application/src/lib/git/git.ts
Normal file
157
libs/api/tracker/application/src/lib/git/git.ts
Normal 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,
|
||||
];
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user