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,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,
];