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

@@ -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 };
}
}