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>
265 lines
7.7 KiB
TypeScript
265 lines
7.7 KiB
TypeScript
import { randomBytes, randomUUID } from 'node:crypto';
|
|
import {
|
|
Command,
|
|
CommandHandler,
|
|
ICommandHandler,
|
|
IQueryHandler,
|
|
Query,
|
|
QueryHandler,
|
|
} from '@nestjs/cqrs';
|
|
import { NotFoundError, assertThat } from '@task-stream/api/shared/domain';
|
|
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,
|
|
GitRepositoryConfig,
|
|
} from '../ports/git-integrations.repository';
|
|
import { ReadAccess } from '../queries/read-access';
|
|
import { GitMention } from './git-payloads';
|
|
|
|
export interface GitRepository {
|
|
id: string;
|
|
/** The project's name for it: api, web, admin… */
|
|
name: string;
|
|
/** Only for whoever manages the project. */
|
|
secret: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface GitIntegration {
|
|
/** On while there is at least one repository. */
|
|
enabled: boolean;
|
|
repositories: GitRepository[];
|
|
}
|
|
|
|
/** A project takes this many repositories at most. */
|
|
export const MAX_GIT_REPOSITORIES = 20;
|
|
|
|
const newSecret = () => randomBytes(24).toString('hex');
|
|
|
|
/** Adds a repository with its own secret (turning the integration on). */
|
|
export class AddGitRepositoryCommand extends Command<GitRepository> {
|
|
constructor(
|
|
public readonly actorId: string,
|
|
public readonly projectId: string,
|
|
public readonly name: string,
|
|
) {
|
|
super();
|
|
}
|
|
}
|
|
|
|
/** A new secret for one repository; the old one stops working at once. */
|
|
export class RotateGitRepositorySecretCommand extends Command<GitRepository> {
|
|
constructor(
|
|
public readonly actorId: string,
|
|
public readonly projectId: string,
|
|
public readonly repositoryId: string,
|
|
) {
|
|
super();
|
|
}
|
|
}
|
|
|
|
/** Its webhooks are refused from then on; what it linked stays. */
|
|
export class RemoveGitRepositoryCommand extends Command<void> {
|
|
constructor(
|
|
public readonly actorId: string,
|
|
public readonly projectId: string,
|
|
public readonly repositoryId: 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,
|
|
/** Name of the repository whose secret signed the delivery. */
|
|
public readonly repository: string,
|
|
public readonly mentions: GitMention[],
|
|
) {
|
|
super();
|
|
}
|
|
}
|
|
|
|
export class GetGitIntegrationQuery extends Query<GitIntegration> {
|
|
constructor(
|
|
public readonly viewerId: string,
|
|
public readonly projectId: string,
|
|
) {
|
|
super();
|
|
}
|
|
}
|
|
|
|
@CommandHandler(AddGitRepositoryCommand)
|
|
export class AddGitRepositoryCommandHandler
|
|
implements ICommandHandler<AddGitRepositoryCommand>
|
|
{
|
|
constructor(
|
|
private readonly access: WriteAccess,
|
|
private readonly integrations: GitIntegrationsRepository,
|
|
) {}
|
|
|
|
async execute(command: AddGitRepositoryCommand): Promise<GitRepository> {
|
|
(await this.access.project(command.projectId)).assertCanManage(
|
|
command.actorId,
|
|
);
|
|
const name = command.name.trim();
|
|
assertThat(
|
|
name.length > 0 && name.length <= 40,
|
|
'A repository needs a name of 1 to 40 characters',
|
|
);
|
|
const repositories = await this.integrations.repositoriesOf(
|
|
command.projectId,
|
|
);
|
|
assertThat(
|
|
repositories.length < MAX_GIT_REPOSITORIES,
|
|
`A project takes ${MAX_GIT_REPOSITORIES} repositories at most`,
|
|
);
|
|
assertThat(
|
|
!repositories.some((r) => r.name.toLowerCase() === name.toLowerCase()),
|
|
`There is already a repository named "${name}"`,
|
|
);
|
|
const added: GitRepositoryConfig = {
|
|
id: randomUUID(),
|
|
name,
|
|
secret: newSecret(),
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
await this.integrations.saveAll(command.projectId, [
|
|
...repositories,
|
|
added,
|
|
]);
|
|
return added;
|
|
}
|
|
}
|
|
|
|
@CommandHandler(RotateGitRepositorySecretCommand)
|
|
export class RotateGitRepositorySecretCommandHandler
|
|
implements ICommandHandler<RotateGitRepositorySecretCommand>
|
|
{
|
|
constructor(
|
|
private readonly access: WriteAccess,
|
|
private readonly integrations: GitIntegrationsRepository,
|
|
) {}
|
|
|
|
async execute(
|
|
command: RotateGitRepositorySecretCommand,
|
|
): Promise<GitRepository> {
|
|
(await this.access.project(command.projectId)).assertCanManage(
|
|
command.actorId,
|
|
);
|
|
const repositories = await this.integrations.repositoriesOf(
|
|
command.projectId,
|
|
);
|
|
const found = repositories.find((r) => r.id === command.repositoryId);
|
|
if (!found) throw new NotFoundError('Repository', command.repositoryId);
|
|
const rotated = { ...found, secret: newSecret() };
|
|
await this.integrations.saveAll(
|
|
command.projectId,
|
|
repositories.map((r) => (r.id === found.id ? rotated : r)),
|
|
);
|
|
return rotated;
|
|
}
|
|
}
|
|
|
|
@CommandHandler(RemoveGitRepositoryCommand)
|
|
export class RemoveGitRepositoryCommandHandler
|
|
implements ICommandHandler<RemoveGitRepositoryCommand>
|
|
{
|
|
constructor(
|
|
private readonly access: WriteAccess,
|
|
private readonly integrations: GitIntegrationsRepository,
|
|
) {}
|
|
|
|
async execute(command: RemoveGitRepositoryCommand): Promise<void> {
|
|
(await this.access.project(command.projectId)).assertCanManage(
|
|
command.actorId,
|
|
);
|
|
const repositories = await this.integrations.repositoriesOf(
|
|
command.projectId,
|
|
);
|
|
if (!repositories.some((r) => r.id === command.repositoryId))
|
|
throw new NotFoundError('Repository', command.repositoryId);
|
|
await this.integrations.saveAll(
|
|
command.projectId,
|
|
repositories.filter((r) => r.id !== command.repositoryId),
|
|
);
|
|
}
|
|
}
|
|
|
|
@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,
|
|
repository: command.repository,
|
|
});
|
|
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 repositories = await this.integrations.repositoriesOf(
|
|
query.projectId,
|
|
);
|
|
const manages = role === ProjectRole.OWNER || role === ProjectRole.ADMIN;
|
|
return {
|
|
enabled: repositories.length > 0,
|
|
repositories: repositories.map((r) => ({
|
|
...r,
|
|
secret: manages ? r.secret : null,
|
|
})),
|
|
};
|
|
}
|
|
}
|
|
|
|
export const GIT_HANDLERS = [
|
|
AddGitRepositoryCommandHandler,
|
|
RotateGitRepositorySecretCommandHandler,
|
|
RemoveGitRepositoryCommandHandler,
|
|
RecordGitMentionsCommandHandler,
|
|
GetGitIntegrationQueryHandler,
|
|
];
|