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

@@ -136,6 +136,42 @@ export class ChecklistItemDto {
@ApiProperty() done!: boolean;
}
export class DevLinkDto {
@ApiProperty({
enum: ['COMMIT', 'BRANCH', 'PULL_REQUEST'],
enumName: 'DevLinkKind',
})
kind!: string;
@ApiProperty() url!: string;
@ApiProperty() title!: string;
@ApiProperty({ description: 'Short SHA, branch or PR number' }) ref!: string;
@ApiProperty({
enum: ['OPEN', 'MERGED', 'CLOSED'],
enumName: 'DevLinkState',
nullable: true,
})
state!: string | null;
@ApiProperty({ example: 'gitea' }) provider!: string;
@ApiProperty({ type: String, nullable: true }) author!: string | null;
@ApiProperty({ format: 'date-time' }) at!: string;
}
export class GitIntegrationDto {
@ApiProperty() enabled!: boolean;
@ApiProperty({
type: String,
nullable: true,
description: 'Webhook secret; only owners and admins see it',
})
secret!: string | null;
@ApiProperty({ example: '/api/webhooks/git/3f2c…' }) webhookPath!: string;
}
export class GitDeliveryResultDto {
@ApiProperty({ description: 'Tickets that got something new' })
recorded!: number;
}
export class IssueParentDto {
@ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ example: 'WEB-3' }) key!: string;
@@ -206,6 +242,11 @@ export class IssueDto {
})
rank!: string;
@ApiProperty({ type: [ChecklistItemDto] }) checklist!: ChecklistItemDto[];
@ApiProperty({
type: [DevLinkDto],
description: 'Commits, branches and pull requests that mention it',
})
devLinks!: DevLinkDto[];
@ApiProperty() commentCount!: number;
@ApiProperty({ format: 'date-time' }) createdAt!: string;
@ApiProperty({ format: 'date-time' }) updatedAt!: string;

View File

@@ -0,0 +1,126 @@
import {
Controller,
Delete,
Get,
Headers,
HttpCode,
Param,
Post,
RawBodyRequest,
Req,
} from '@nestjs/common';
import {
ApiCreatedResponse,
ApiForbiddenResponse,
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import { User } from '@task-stream/api/identity/domain';
import {
ApiPireonAuth,
CurrentUser,
Public,
} from '@task-stream/api/identity/presenters';
import {
ApiCommandErrors,
ApiReadErrors,
} from '@task-stream/api/shared/presenters';
import { GitService } from '@task-stream/api/tracker/application';
import { Request } from 'express';
import { GitDeliveryResultDto, GitIntegrationDto } from './dto/responses.dto';
import { Refs } from './refs';
const PROJECT = ApiParam({
name: 'project',
description: 'Project id or key',
example: 'WEB',
});
/**
* Git hosts: the webhook they call, and its configuration for owners and
* admins. Commits, branches and pull requests that name a ticket key (WEB-12)
* show on that ticket.
*/
@ApiTags('Git')
@Controller()
export class GitController {
constructor(
private readonly git: GitService,
private readonly refs: Refs,
) {}
@Get('projects/:project/git-integration')
@ApiPireonAuth()
@PROJECT
@ApiOperation({
summary: 'Whether the Git webhook is on (and its secret, for admins)',
})
@ApiOkResponse({ type: GitIntegrationDto })
@ApiReadErrors()
async integration(
@CurrentUser() me: User,
@Param('project') project: string,
) {
const projectId = await this.refs.projectId(me.id, project);
return {
...(await this.git.integration(me.id, projectId)),
webhookPath: `/api/webhooks/git/${projectId}`,
};
}
@Post('projects/:project/git-integration')
@ApiPireonAuth()
@PROJECT
@ApiOperation({
summary: 'Turn the Git webhook on with a new secret (or rotate it)',
})
@ApiCreatedResponse({ type: GitIntegrationDto })
@ApiCommandErrors()
async enable(@CurrentUser() me: User, @Param('project') project: string) {
const projectId = await this.refs.projectId(me.id, project);
return {
...(await this.git.enable(me.id, projectId)),
webhookPath: `/api/webhooks/git/${projectId}`,
};
}
@Delete('projects/:project/git-integration')
@ApiPireonAuth()
@PROJECT
@HttpCode(204)
@ApiOperation({ summary: 'Turn the Git webhook off' })
@ApiNoContentResponse()
@ApiCommandErrors()
async disable(@CurrentUser() me: User, @Param('project') project: string) {
await this.git.disable(me.id, await this.refs.projectId(me.id, project));
}
@Public()
@Post('webhooks/git/:project')
@HttpCode(200)
@PROJECT
@ApiOperation({
summary: 'Webhook for GitHub, Gitea or GitLab (called by the Git host)',
description:
'Push and pull/merge request events. GitHub and Gitea sign the body with the secret (HMAC-SHA256); GitLab sends it as X-Gitlab-Token. Other events are acknowledged and ignored.',
})
@ApiOkResponse({ type: GitDeliveryResultDto })
@ApiForbiddenResponse({
description: 'Unknown project, integration off or bad signature',
})
receive(
@Param('project') project: string,
@Req() req: RawBodyRequest<Request>,
@Headers() headers: Record<string, string | undefined>,
) {
return this.git.receive({
project,
headers,
rawBody: req.rawBody,
body: (req.body ?? {}) as Record<string, unknown>,
});
}
}