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 { 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 { 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 { 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 { 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 { constructor( public readonly viewerId: string, public readonly projectId: string, ) { super(); } } @CommandHandler(AddGitRepositoryCommand) export class AddGitRepositoryCommandHandler implements ICommandHandler { constructor( private readonly access: WriteAccess, private readonly integrations: GitIntegrationsRepository, ) {} async execute(command: AddGitRepositoryCommand): Promise { (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 { constructor( private readonly access: WriteAccess, private readonly integrations: GitIntegrationsRepository, ) {} async execute( command: RotateGitRepositorySecretCommand, ): Promise { (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 { constructor( private readonly access: WriteAccess, private readonly integrations: GitIntegrationsRepository, ) {} async execute(command: RemoveGitRepositoryCommand): Promise { (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 { constructor( private readonly access: WriteAccess, private readonly findIssues: FindIssuesRepository, ) {} /** How many tickets got something new. Unknown keys are ignored. */ async execute(command: RecordGitMentionsCommand): Promise { 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 { constructor( private readonly access: ReadAccess, private readonly integrations: GitIntegrationsRepository, ) {} async execute(query: GetGitIntegrationQuery): Promise { 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, ];