feat: several Git repositories per project

A project keeps a list of repositories (api, web, admin…), each with its
own secret, instead of a single webhook secret. They all call the same
URL; the secret that verifies the signature tells which one it was, and
every commit, branch or pull request is linked with its repository's
name (shown on the ticket and in the MCP). Rotating or removing one
leaves the others alone, and what it linked stays.

A project that had turned the single webhook on keeps its secret, as a
repository named "principal".

API: the enable/disable endpoints and mutations give way to adding a
repository, rotating its secret and removing it (REST and GraphQL). The
web settings card lists the repositories. Docs, Word and PDF updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 10:39:59 +02:00
parent e6ac02d328
commit c2928d2385
31 changed files with 1451 additions and 301 deletions

View File

@@ -1106,3 +1106,17 @@ export class NotificationSettingsDto {
@IsBoolean()
email!: boolean;
}
// ---- Git --------------------------------------------------------------------
export class AddGitRepositoryDto {
@ApiProperty({
example: 'api',
maxLength: 40,
description: "The project's name for it (api, web, admin…); unique",
})
@IsString()
@MinLength(1)
@MaxLength(40)
name!: string;
}

View File

@@ -162,19 +162,40 @@ export class DevLinkDto {
})
state!: string | null;
@ApiProperty({ example: 'gitea' }) provider!: string;
@ApiProperty({
type: String,
nullable: true,
example: 'api',
description: "The project's name for the repository it came from",
})
repository!: string | null;
@ApiProperty({ type: String, nullable: true }) author!: string | null;
@ApiProperty({ format: 'date-time' }) at!: string;
}
export class GitIntegrationDto {
@ApiProperty() enabled!: boolean;
export class GitRepositoryDto {
@ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ example: 'api' }) name!: string;
@ApiProperty({
type: String,
nullable: true,
description: 'Webhook secret; only owners and admins see it',
description:
'Webhook secret of this repository; only owners and admins see it',
})
secret!: string | null;
@ApiProperty({ example: '/api/webhooks/git/3f2c…' }) webhookPath!: string;
@ApiProperty({ format: 'date-time' }) createdAt!: string;
}
export class GitIntegrationDto {
@ApiProperty({ description: 'On while there is at least one repository' })
enabled!: boolean;
@ApiProperty({
example: '/api/webhooks/git/3f2c…',
description: 'The same for every repository of the project',
})
webhookPath!: string;
@ApiProperty({ type: [GitRepositoryDto] })
repositories!: GitRepositoryDto[];
}
export class WebhookDeliveryDto {

View File

@@ -1,4 +1,5 @@
import {
Body,
Controller,
Delete,
Get,
@@ -30,7 +31,12 @@ import {
} 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 { AddGitRepositoryDto } from './dto/requests.dto';
import {
GitDeliveryResultDto,
GitIntegrationDto,
GitRepositoryDto,
} from './dto/responses.dto';
import { Refs } from './refs';
const PROJECT = ApiParam({
@@ -38,10 +44,16 @@ const PROJECT = ApiParam({
description: 'Project id or key',
example: 'WEB',
});
const REPOSITORY = ApiParam({
name: 'repository',
description: 'Repository id',
});
const webhookPath = (projectId: string) => `/api/webhooks/git/${projectId}`;
/**
* 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)
* Git hosts: the webhook they call, and the project's repositories (each
* with its own secret) for owners and admins. Commits, branches and pull requests that name a ticket key (WEB-12)
* show on that ticket.
*/
@ApiTags('Git')
@@ -56,7 +68,8 @@ export class GitController {
@ApiPireonAuth()
@PROJECT
@ApiOperation({
summary: 'Whether the Git webhook is on (and its secret, for admins)',
summary:
"The project's repositories and the webhook URL they share (secrets, for admins)",
})
@ApiOkResponse({ type: GitIntegrationDto })
@ApiReadErrors()
@@ -67,35 +80,74 @@ export class GitController {
const projectId = await this.refs.projectId(me.id, project);
return {
...(await this.git.integration(me.id, projectId)),
webhookPath: `/api/webhooks/git/${projectId}`,
webhookPath: webhookPath(projectId),
};
}
@Post('projects/:project/git-integration')
@Post('projects/:project/git-integration/repositories')
@ApiPireonAuth()
@PROJECT
@ApiOperation({
summary: 'Turn the Git webhook on with a new secret (or rotate it)',
summary: 'Add a repository, with its own secret (owners and admins)',
description:
'Turns the integration on with the first one. Every repository uses the same webhook URL; its secret tells them apart.',
})
@ApiCreatedResponse({ type: GitIntegrationDto })
@ApiCreatedResponse({ type: GitRepositoryDto })
@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}`,
};
async addRepository(
@CurrentUser() me: User,
@Param('project') project: string,
@Body() body: AddGitRepositoryDto,
) {
return this.git.addRepository(
me.id,
await this.refs.projectId(me.id, project),
body.name,
);
}
@Delete('projects/:project/git-integration')
@Post('projects/:project/git-integration/repositories/:repository/secret')
@ApiPireonAuth()
@PROJECT
@REPOSITORY
@ApiOperation({
summary: 'Give a repository a new secret (the old one stops working)',
})
@ApiCreatedResponse({ type: GitRepositoryDto })
@ApiCommandErrors()
async rotateSecret(
@CurrentUser() me: User,
@Param('project') project: string,
@Param('repository') repository: string,
) {
return this.git.rotateSecret(
me.id,
await this.refs.projectId(me.id, project),
repository,
);
}
@Delete('projects/:project/git-integration/repositories/:repository')
@ApiPireonAuth()
@PROJECT
@REPOSITORY
@HttpCode(204)
@ApiOperation({ summary: 'Turn the Git webhook off' })
@ApiOperation({
summary:
'Remove a repository: its webhooks are refused, what it linked stays',
})
@ApiNoContentResponse()
@ApiCommandErrors()
async disable(@CurrentUser() me: User, @Param('project') project: string) {
await this.git.disable(me.id, await this.refs.projectId(me.id, project));
async removeRepository(
@CurrentUser() me: User,
@Param('project') project: string,
@Param('repository') repository: string,
) {
await this.git.removeRepository(
me.id,
await this.refs.projectId(me.id, project),
repository,
);
}
@Public()
@@ -105,7 +157,7 @@ export class GitController {
@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.',
'Push and pull/merge request events from any repository of the project; the secret it signs with says which (GitHub and Gitea: HMAC-SHA256 of the body; GitLab: X-Gitlab-Token). Other events are acknowledged and ignored.',
})
@ApiOkResponse({ type: GitDeliveryResultDto })
@ApiForbiddenResponse({