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:
@@ -42,6 +42,7 @@ describe('automation rules', () => {
|
||||
ref: '#1',
|
||||
state: DevLinkState.MERGED,
|
||||
provider: 'github',
|
||||
repository: null,
|
||||
author: null,
|
||||
at: '',
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ConcurrencyError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
Comment,
|
||||
@@ -40,7 +41,14 @@ import {
|
||||
StartSprintCommand,
|
||||
} from './sprint.commands';
|
||||
import { IssueTree } from './issue-tree';
|
||||
import { GIT_HANDLERS, RecordGitMentionsCommand } from '../git/git';
|
||||
import {
|
||||
AddGitRepositoryCommand,
|
||||
GIT_HANDLERS,
|
||||
GitRepository,
|
||||
RecordGitMentionsCommand,
|
||||
RemoveGitRepositoryCommand,
|
||||
RotateGitRepositorySecretCommand,
|
||||
} from '../git/git';
|
||||
import { RunAutomationCommand } from '../automations/automations';
|
||||
import { RunAutomationCommandHandler } from '../automations/automations';
|
||||
import {
|
||||
@@ -48,7 +56,10 @@ import {
|
||||
AutomationTriggerKind,
|
||||
} from '../automations/automation-rules';
|
||||
import { Automation } from '../ports/automations.repository';
|
||||
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
|
||||
import {
|
||||
GitIntegrationsRepository,
|
||||
GitRepositoryConfig,
|
||||
} from '../ports/git-integrations.repository';
|
||||
import { DevLinkKind } from '@task-stream/api/tracker/domain';
|
||||
import { SetIssueParentCommand } from './set-issue-parent.command';
|
||||
import { RankIssueCommand } from './rank-issue.command';
|
||||
@@ -86,6 +97,14 @@ import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
|
||||
async function setup() {
|
||||
const store = new InMemoryEventStore();
|
||||
// The project's Git repositories, as Mongo would keep them.
|
||||
const gitRepositories = new Map<string, GitRepositoryConfig[]>();
|
||||
const git: GitIntegrationsRepository = {
|
||||
repositoriesOf: async (projectId) => gitRepositories.get(projectId) ?? [],
|
||||
saveAll: async (projectId, list) => {
|
||||
gitRepositories.set(projectId, list);
|
||||
},
|
||||
};
|
||||
const keys = new Map<string, { id: string; projectId: string }>();
|
||||
const sprints = {
|
||||
active: null as { id: string; name: string } | null,
|
||||
@@ -112,7 +131,7 @@ async function setup() {
|
||||
provide: FindProjectsRepository,
|
||||
useValue: { findRole: async () => null, findById: async () => null },
|
||||
},
|
||||
{ provide: GitIntegrationsRepository, useValue: {} },
|
||||
{ provide: GitIntegrationsRepository, useValue: git },
|
||||
AggregateRehydrator,
|
||||
{ provide: EventStore, useValue: store },
|
||||
{ provide: AttachmentStorage, useValue: files },
|
||||
@@ -157,6 +176,7 @@ async function setup() {
|
||||
files,
|
||||
sprints,
|
||||
keys,
|
||||
git,
|
||||
run,
|
||||
rehydrator: moduleRef.get(AggregateRehydrator),
|
||||
};
|
||||
@@ -520,16 +540,52 @@ describe('tracker commands (event sourced)', () => {
|
||||
ref: 'abc',
|
||||
state: null,
|
||||
provider: 'gitea',
|
||||
repository: null,
|
||||
author: 'Ana',
|
||||
at: '2026-10-08T10:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
await run(new RecordGitMentionsCommand(projectId, [commit, commit])),
|
||||
await run(
|
||||
new RecordGitMentionsCommand(projectId, 'web', [commit, commit]),
|
||||
),
|
||||
).toBe(1);
|
||||
const { devLinks } = await rehydrator.rehydrate(issue.id, Issue);
|
||||
expect(devLinks.map((l) => l.ref)).toEqual(['abc']);
|
||||
expect(devLinks.map((l) => [l.ref, l.repository])).toEqual([
|
||||
['abc', 'web'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps several Git repositories, each with its own secret', async () => {
|
||||
const { run, git } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const add = (actor: string, name: string) =>
|
||||
run(
|
||||
new AddGitRepositoryCommand(actor, projectId, name),
|
||||
) as unknown as Promise<GitRepository>;
|
||||
|
||||
const api = await add('owner', 'api');
|
||||
const web = await add('owner', ' web ');
|
||||
expect(web.name).toBe('web');
|
||||
expect(api.secret).toMatch(/^[0-9a-f]{48}$/);
|
||||
expect(web.secret).not.toBe(api.secret);
|
||||
await expect(add('owner', 'API')).rejects.toThrow(/already/);
|
||||
await expect(add('owner', '')).rejects.toThrow(ValidationError);
|
||||
await expect(add('dev', 'admin')).rejects.toThrow(ForbiddenError);
|
||||
|
||||
const rotated = (await run(
|
||||
new RotateGitRepositorySecretCommand('owner', projectId, api.id),
|
||||
)) as unknown as GitRepository;
|
||||
expect(rotated.secret).not.toBe(api.secret);
|
||||
await run(new RemoveGitRepositoryCommand('owner', projectId, web.id));
|
||||
await expect(
|
||||
run(new RemoveGitRepositoryCommand('owner', projectId, web.id)),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
|
||||
expect(await git.repositoriesOf(projectId)).toEqual([
|
||||
expect.objectContaining({ name: 'api', secret: rotated.secret }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('runs automation actions as TaskStream, within the workflow', async () => {
|
||||
|
||||
81
libs/api/tracker/application/src/lib/git.service.spec.ts
Normal file
81
libs/api/tracker/application/src/lib/git.service.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { ForbiddenError } from '@task-stream/api/shared/domain';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { RecordGitMentionsCommand } from './git/git';
|
||||
import { GitService } from './git.service';
|
||||
import { FindProjectsRepository } from './ports/find-projects.repository';
|
||||
import { GitIntegrationsRepository } from './ports/git-integrations.repository';
|
||||
|
||||
const push = {
|
||||
ref: 'refs/heads/main',
|
||||
repository: { html_url: 'https://git.example.org/acme/api' },
|
||||
commits: [
|
||||
{
|
||||
id: 'abc1234',
|
||||
message: 'WEB-7: pay by card',
|
||||
url: 'https://git.example.org/acme/api/commit/abc1234',
|
||||
timestamp: '2026-10-09T10:00:00Z',
|
||||
author: { name: 'Ana', username: 'ana' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('GitService', () => {
|
||||
const executed: object[] = [];
|
||||
const git = new GitService(
|
||||
{
|
||||
execute: async (command: object) => {
|
||||
executed.push(command);
|
||||
return 1;
|
||||
},
|
||||
} as unknown as CommandBus,
|
||||
{} as QueryBus,
|
||||
{
|
||||
findById: async (id: string) => (id === 'p1' ? { id: 'p1' } : null),
|
||||
findByKey: async (key: string) => (key === 'WEB' ? { id: 'p1' } : null),
|
||||
} as unknown as FindProjectsRepository,
|
||||
{
|
||||
repositoriesOf: async () => [
|
||||
{ id: 'r1', name: 'web', secret: 'web-secret', createdAt: '' },
|
||||
{ id: 'r2', name: 'api', secret: 'api-secret', createdAt: '' },
|
||||
],
|
||||
saveAll: async () => undefined,
|
||||
} as GitIntegrationsRepository,
|
||||
);
|
||||
|
||||
/** A Gitea delivery signed with `secret`. */
|
||||
const delivery = (secret: string, project = 'WEB') => {
|
||||
const rawBody = Buffer.from(JSON.stringify(push));
|
||||
return {
|
||||
project,
|
||||
rawBody,
|
||||
body: push,
|
||||
headers: {
|
||||
'x-gitea-event': 'push',
|
||||
'x-gitea-signature': createHmac('sha256', secret)
|
||||
.update(rawBody)
|
||||
.digest('hex'),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => (executed.length = 0));
|
||||
|
||||
it('tells the repository by the secret its webhook was signed with', async () => {
|
||||
expect(await git.receive(delivery('api-secret'))).toEqual({ recorded: 1 });
|
||||
expect(executed).toEqual([
|
||||
expect.objectContaining({ projectId: 'p1', repository: 'api' }),
|
||||
]);
|
||||
expect(executed[0]).toBeInstanceOf(RecordGitMentionsCommand);
|
||||
});
|
||||
|
||||
it('refuses a secret no repository of the project has, or another project', async () => {
|
||||
await expect(git.receive(delivery('stolen'))).rejects.toThrow(
|
||||
ForbiddenError,
|
||||
);
|
||||
await expect(git.receive(delivery('api-secret', 'NOPE'))).rejects.toThrow(
|
||||
ForbiddenError,
|
||||
);
|
||||
expect(executed).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,13 @@ import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { ForbiddenError } from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
DisableGitIntegrationCommand,
|
||||
EnableGitIntegrationCommand,
|
||||
AddGitRepositoryCommand,
|
||||
GetGitIntegrationQuery,
|
||||
GitIntegration,
|
||||
GitRepository,
|
||||
RecordGitMentionsCommand,
|
||||
RemoveGitRepositoryCommand,
|
||||
RotateGitRepositorySecretCommand,
|
||||
} from './git/git';
|
||||
import {
|
||||
gitMentionsOf,
|
||||
@@ -40,41 +42,59 @@ export class GitService {
|
||||
);
|
||||
}
|
||||
|
||||
enable(actorId: string, projectId: string): Promise<GitIntegration> {
|
||||
addRepository(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
name: string,
|
||||
): Promise<GitRepository> {
|
||||
return this.commandBus.execute(
|
||||
new EnableGitIntegrationCommand(actorId, projectId),
|
||||
new AddGitRepositoryCommand(actorId, projectId, name),
|
||||
);
|
||||
}
|
||||
|
||||
disable(actorId: string, projectId: string): Promise<void> {
|
||||
rotateSecret(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
repositoryId: string,
|
||||
): Promise<GitRepository> {
|
||||
return this.commandBus.execute(
|
||||
new DisableGitIntegrationCommand(actorId, projectId),
|
||||
new RotateGitRepositorySecretCommand(actorId, projectId, repositoryId),
|
||||
);
|
||||
}
|
||||
|
||||
removeRepository(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
repositoryId: string,
|
||||
): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
new RemoveGitRepositoryCommand(actorId, projectId, repositoryId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A webhook delivery. Unknown project, integration off or a bad signature
|
||||
* all look the same from outside (403): nothing to learn by probing.
|
||||
* A webhook delivery. Every repository of the project shares the URL; the
|
||||
* secret that verifies the signature says which one it came from. Unknown
|
||||
* project, integration off or a bad signature all look the same from
|
||||
* outside (403): nothing to learn by probing.
|
||||
*/
|
||||
async receive(delivery: GitDelivery): Promise<{ recorded: number }> {
|
||||
const project =
|
||||
(await this.findProjects.findById(delivery.project)) ??
|
||||
(await this.findProjects.findByKey(delivery.project.toUpperCase()));
|
||||
const secret = project
|
||||
? await this.integrations.secretOf(project.id)
|
||||
: null;
|
||||
const source = gitProviderOf(delivery.headers);
|
||||
if (
|
||||
!project ||
|
||||
!secret ||
|
||||
!source ||
|
||||
!verifyGitSignature(
|
||||
source.provider,
|
||||
delivery.headers,
|
||||
delivery.rawBody,
|
||||
secret,
|
||||
)
|
||||
)
|
||||
const repository =
|
||||
project && source
|
||||
? (await this.integrations.repositoriesOf(project.id)).find((r) =>
|
||||
verifyGitSignature(
|
||||
source.provider,
|
||||
delivery.headers,
|
||||
delivery.rawBody,
|
||||
r.secret,
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
if (!project || !source || !repository)
|
||||
throw new ForbiddenError(
|
||||
'Unknown project, integration off or bad signature',
|
||||
);
|
||||
@@ -85,7 +105,7 @@ export class GitService {
|
||||
);
|
||||
if (!mentions.length) return { recorded: 0 };
|
||||
const recorded: number = await this.commandBus.execute(
|
||||
new RecordGitMentionsCommand(project.id, mentions),
|
||||
new RecordGitMentionsCommand(project.id, repository.name, mentions),
|
||||
);
|
||||
return { recorded };
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ function fromPush(
|
||||
ref: String(commit['id']).slice(0, 8),
|
||||
state: null,
|
||||
provider,
|
||||
repository: null,
|
||||
author: commit['author']?.['name'] ?? null,
|
||||
at: String(commit['timestamp'] ?? new Date().toISOString()),
|
||||
},
|
||||
@@ -103,6 +104,7 @@ function fromPush(
|
||||
ref: branch,
|
||||
state: null,
|
||||
provider,
|
||||
repository: null,
|
||||
author:
|
||||
body['pusher']?.['name'] ??
|
||||
body['pusher']?.['login'] ??
|
||||
@@ -148,6 +150,7 @@ export function gitMentionsOf(
|
||||
ref: `#${pr['number'] ?? ''}`,
|
||||
state: prState(!!pr['merged'], String(pr['state'] ?? 'open')),
|
||||
provider,
|
||||
repository: null,
|
||||
author: pr['user']?.['login'] ?? null,
|
||||
at: String(pr['updated_at'] ?? new Date().toISOString()),
|
||||
},
|
||||
@@ -168,6 +171,7 @@ export function gitMentionsOf(
|
||||
ref: `!${mr['iid'] ?? ''}`,
|
||||
state: prState(false, String(mr['state'] ?? 'opened')),
|
||||
provider,
|
||||
repository: null,
|
||||
author: body['user']?.['username'] ?? null,
|
||||
at: String(mr['updated_at'] ?? new Date().toISOString()),
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import {
|
||||
Command,
|
||||
CommandHandler,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Query,
|
||||
QueryHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import { NotFoundError, assertThat } from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
Issue,
|
||||
ProjectRole,
|
||||
@@ -15,30 +16,61 @@ import {
|
||||
} 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 {
|
||||
GitIntegrationsRepository,
|
||||
GitRepositoryConfig,
|
||||
} from '../ports/git-integrations.repository';
|
||||
import { ReadAccess } from '../queries/read-access';
|
||||
import { GitMention } from './git-payloads';
|
||||
|
||||
export interface GitIntegration {
|
||||
enabled: boolean;
|
||||
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;
|
||||
}
|
||||
|
||||
/** Turns the integration on with a new secret (or rotates it). */
|
||||
export class EnableGitIntegrationCommand extends Command<GitIntegration> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
export class DisableGitIntegrationCommand extends Command<void> {
|
||||
/** 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();
|
||||
}
|
||||
@@ -48,6 +80,8 @@ export class DisableGitIntegrationCommand extends Command<void> {
|
||||
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();
|
||||
@@ -63,39 +97,100 @@ export class GetGitIntegrationQuery extends Query<GitIntegration> {
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(EnableGitIntegrationCommand)
|
||||
export class EnableGitIntegrationCommandHandler
|
||||
implements ICommandHandler<EnableGitIntegrationCommand>
|
||||
@CommandHandler(AddGitRepositoryCommand)
|
||||
export class AddGitRepositoryCommandHandler
|
||||
implements ICommandHandler<AddGitRepositoryCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly integrations: GitIntegrationsRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: EnableGitIntegrationCommand): Promise<GitIntegration> {
|
||||
async execute(command: AddGitRepositoryCommand): Promise<GitRepository> {
|
||||
(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 };
|
||||
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(DisableGitIntegrationCommand)
|
||||
export class DisableGitIntegrationCommandHandler
|
||||
implements ICommandHandler<DisableGitIntegrationCommand>
|
||||
@CommandHandler(RotateGitRepositorySecretCommand)
|
||||
export class RotateGitRepositorySecretCommandHandler
|
||||
implements ICommandHandler<RotateGitRepositorySecretCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly integrations: GitIntegrationsRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: DisableGitIntegrationCommand): Promise<void> {
|
||||
async execute(
|
||||
command: RotateGitRepositorySecretCommand,
|
||||
): Promise<GitRepository> {
|
||||
(await this.access.project(command.projectId)).assertCanManage(
|
||||
command.actorId,
|
||||
);
|
||||
await this.integrations.remove(command.projectId);
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +214,10 @@ export class RecordGitMentionsCommandHandler
|
||||
const issue = await this.access.live(found.id, Issue);
|
||||
if (!issue) continue;
|
||||
const before = issue.getUncommittedEvents().length;
|
||||
issue.recordDevLink(SYSTEM_ACTOR_ID, mention.link);
|
||||
issue.recordDevLink(SYSTEM_ACTOR_ID, {
|
||||
...mention.link,
|
||||
repository: command.repository,
|
||||
});
|
||||
if (issue.getUncommittedEvents().length === before) continue;
|
||||
await issue.commitAndWait();
|
||||
recorded++;
|
||||
@@ -143,15 +241,24 @@ export class GetGitIntegrationQueryHandler
|
||||
query.projectId,
|
||||
query.viewerId,
|
||||
);
|
||||
const secret = await this.integrations.secretOf(query.projectId);
|
||||
const repositories = await this.integrations.repositoriesOf(
|
||||
query.projectId,
|
||||
);
|
||||
const manages = role === ProjectRole.OWNER || role === ProjectRole.ADMIN;
|
||||
return { enabled: !!secret, secret: manages ? secret : null };
|
||||
return {
|
||||
enabled: repositories.length > 0,
|
||||
repositories: repositories.map((r) => ({
|
||||
...r,
|
||||
secret: manages ? r.secret : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const GIT_HANDLERS = [
|
||||
EnableGitIntegrationCommandHandler,
|
||||
DisableGitIntegrationCommandHandler,
|
||||
AddGitRepositoryCommandHandler,
|
||||
RotateGitRepositorySecretCommandHandler,
|
||||
RemoveGitRepositoryCommandHandler,
|
||||
RecordGitMentionsCommandHandler,
|
||||
GetGitIntegrationQueryHandler,
|
||||
];
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
/** A repository of a project: its name there (api, web…) and its secret. */
|
||||
export interface GitRepositoryConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
/** What the Git host signs its webhooks with. */
|
||||
secret: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-project secret that Git hosts sign their webhooks with. Configuration,
|
||||
* not history: kept outside the event store.
|
||||
* The repositories whose webhooks a project accepts. Configuration, not
|
||||
* history: kept outside the event store.
|
||||
*/
|
||||
export abstract class GitIntegrationsRepository {
|
||||
abstract secretOf(projectId: string): Promise<string | null>;
|
||||
abstract save(projectId: string, secret: string): Promise<void>;
|
||||
abstract remove(projectId: string): Promise<void>;
|
||||
/** Oldest first; empty when the integration is off. */
|
||||
abstract repositoriesOf(projectId: string): Promise<GitRepositoryConfig[]>;
|
||||
/** Replaces the whole list; an empty one turns the integration off. */
|
||||
abstract saveAll(
|
||||
projectId: string,
|
||||
repositories: GitRepositoryConfig[],
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const pr = (state: DevLinkState, title = 'WEB-7: pay by card') => ({
|
||||
ref: '#12',
|
||||
state,
|
||||
provider: 'gitea',
|
||||
repository: 'web',
|
||||
author: 'ana',
|
||||
at: '2026-10-08T10:00:00Z',
|
||||
});
|
||||
|
||||
@@ -27,6 +27,11 @@ export interface DevLink {
|
||||
state: DevLinkState | null;
|
||||
/** github, gitea, gitlab. */
|
||||
provider: string;
|
||||
/**
|
||||
* The project's name for the repository that reported it (api, web…);
|
||||
* null on links recorded before projects had several.
|
||||
*/
|
||||
repository: string | null;
|
||||
author: string | null;
|
||||
at: string;
|
||||
}
|
||||
@@ -55,6 +60,7 @@ export function normalizeDevLink(link: DevLink): DevLink {
|
||||
...link,
|
||||
title: link.title.split('\n')[0].trim().slice(0, 200) || link.ref,
|
||||
ref: link.ref.slice(0, 120),
|
||||
repository: link.repository?.slice(0, 40) ?? null,
|
||||
author: link.author?.slice(0, 120) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { GitIntegrationsRepository } from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
GitIntegrationsRepository,
|
||||
GitRepositoryConfig,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { Model } from 'mongoose';
|
||||
import { GitIntegrationDoc, ReadModels } from './read-model.schemas';
|
||||
|
||||
/** What a project's single webhook of before is called once it is one of many. */
|
||||
export const LEGACY_REPOSITORY_NAME = 'principal';
|
||||
|
||||
@Injectable()
|
||||
export class MongoGitIntegrationsRepository extends GitIntegrationsRepository {
|
||||
constructor(
|
||||
@@ -13,22 +19,38 @@ export class MongoGitIntegrationsRepository extends GitIntegrationsRepository {
|
||||
super();
|
||||
}
|
||||
|
||||
async secretOf(projectId: string): Promise<string | null> {
|
||||
async repositoriesOf(projectId: string): Promise<GitRepositoryConfig[]> {
|
||||
const doc = await this.integrations
|
||||
.findById(projectId)
|
||||
.lean<GitIntegrationDoc>();
|
||||
return doc?.secret ?? null;
|
||||
if (!doc) return [];
|
||||
if (doc.repositories) return doc.repositories;
|
||||
// Saved before projects had several repositories: its secret keeps
|
||||
// working, now as one of them.
|
||||
return doc.secret
|
||||
? [
|
||||
{
|
||||
id: projectId,
|
||||
name: LEGACY_REPOSITORY_NAME,
|
||||
secret: doc.secret,
|
||||
createdAt: doc.createdAt ?? new Date(0).toISOString(),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
async save(projectId: string, secret: string): Promise<void> {
|
||||
async saveAll(
|
||||
projectId: string,
|
||||
repositories: GitRepositoryConfig[],
|
||||
): Promise<void> {
|
||||
if (!repositories.length) {
|
||||
await this.integrations.deleteOne({ _id: projectId });
|
||||
return;
|
||||
}
|
||||
await this.integrations.updateOne(
|
||||
{ _id: projectId },
|
||||
{ $set: { secret, createdAt: new Date().toISOString() } },
|
||||
{ $set: { repositories }, $unset: { secret: '', createdAt: '' } },
|
||||
{ upsert: true },
|
||||
);
|
||||
}
|
||||
|
||||
async remove(projectId: string): Promise<void> {
|
||||
await this.integrations.deleteOne({ _id: projectId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,11 +41,22 @@ export interface AutomationRunDoc {
|
||||
_id: string;
|
||||
at: Date;
|
||||
}
|
||||
export interface GitIntegrationDoc {
|
||||
_id: string;
|
||||
export interface GitRepositoryDoc {
|
||||
id: string;
|
||||
name: string;
|
||||
secret: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* One per project. `secret` alone is how the single webhook of before was
|
||||
* kept: it reads as a repository named "principal" until the list is saved.
|
||||
*/
|
||||
export interface GitIntegrationDoc {
|
||||
_id: string;
|
||||
repositories?: GitRepositoryDoc[];
|
||||
secret?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
export interface NotificationSettingsDoc {
|
||||
_id: string;
|
||||
email: boolean;
|
||||
@@ -214,7 +225,7 @@ SavedFilterSchema.index({ projectId: 1, 'owner.id': 1 });
|
||||
SavedFilterSchema.index({ projectId: 1, shared: 1 });
|
||||
|
||||
export const GitIntegrationSchema = new Schema<GitIntegrationDoc>(
|
||||
{ _id: String, secret: String, createdAt: String },
|
||||
{ _id: String, repositories: [Object], secret: String, createdAt: String },
|
||||
{ ...loose, collection: 'git_integrations' },
|
||||
);
|
||||
|
||||
@@ -285,7 +296,11 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
|
||||
rank: rest.rank ?? implicitRank(rest.number),
|
||||
startDate: rest.startDate ?? null,
|
||||
sprint: rest.sprint?.id ? rest.sprint : null,
|
||||
devLinks: rest.devLinks ?? [],
|
||||
// Links recorded before projects had several repositories name none.
|
||||
devLinks: (rest.devLinks ?? []).map((link) => ({
|
||||
...link,
|
||||
repository: link.repository ?? null,
|
||||
})),
|
||||
trashedAt: rest.trashedAt ?? null,
|
||||
checklist: rest.checklist ?? [],
|
||||
};
|
||||
|
||||
@@ -2,11 +2,11 @@ import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import { CurrentUser } from '@task-stream/api/identity/presenters';
|
||||
import { GitService } from '@task-stream/api/tracker/application';
|
||||
import { GitIntegrationGql } from './types';
|
||||
import { GitIntegrationGql, GitRepositoryGql } from './types';
|
||||
|
||||
const webhookPath = (projectId: string) => `/api/webhooks/git/${projectId}`;
|
||||
|
||||
/** The project's Git integration (owners and admins configure it). */
|
||||
/** The project's Git repositories (owners and admins configure them). */
|
||||
@Resolver(() => GitIntegrationGql)
|
||||
export class GitResolver {
|
||||
constructor(private readonly git: GitService) {}
|
||||
@@ -20,24 +20,38 @@ export class GitResolver {
|
||||
return { ...integration, webhookPath: webhookPath(projectId) };
|
||||
}
|
||||
|
||||
@Mutation(() => GitIntegrationGql, {
|
||||
@Mutation(() => GitRepositoryGql, {
|
||||
description:
|
||||
'Turn the Git webhook on, with a new secret (also to rotate it)',
|
||||
'Add a repository with its own secret (the first turns the integration on)',
|
||||
})
|
||||
async enableGitIntegration(
|
||||
addGitRepository(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
@Args('name') name: string,
|
||||
) {
|
||||
const integration = await this.git.enable(me.id, projectId);
|
||||
return { ...integration, webhookPath: webhookPath(projectId) };
|
||||
return this.git.addRepository(me.id, projectId, name);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async disableGitIntegration(
|
||||
@Mutation(() => GitRepositoryGql, {
|
||||
description: 'A new secret for a repository; the old one stops working',
|
||||
})
|
||||
rotateGitRepositorySecret(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
@Args('repositoryId', { type: () => ID }) repositoryId: string,
|
||||
) {
|
||||
await this.git.disable(me.id, projectId);
|
||||
return this.git.rotateSecret(me.id, projectId, repositoryId);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Remove a repository; what it linked stays on the tickets',
|
||||
})
|
||||
async removeGitRepository(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
@Args('repositoryId', { type: () => ID }) repositoryId: string,
|
||||
) {
|
||||
await this.git.removeRepository(me.id, projectId, repositoryId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,23 +170,45 @@ export class DevLinkGql {
|
||||
@Field({ description: 'Short SHA, branch or PR number' }) ref!: string;
|
||||
@Field(() => DevLinkState, { nullable: true }) state!: DevLinkState | null;
|
||||
@Field({ description: 'github, gitea or gitlab' }) provider!: string;
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: "The project's name for the repository it came from",
|
||||
})
|
||||
repository!: string | null;
|
||||
@Field(() => String, { nullable: true }) author!: string | null;
|
||||
@Field() at!: string;
|
||||
}
|
||||
|
||||
@ObjectType('GitIntegration', {
|
||||
description:
|
||||
'Webhook from GitHub, Gitea or GitLab: commits, branches and pull requests naming a ticket key are linked to it',
|
||||
@ObjectType('GitRepository', {
|
||||
description: 'A repository whose webhooks the project accepts',
|
||||
})
|
||||
export class GitIntegrationGql {
|
||||
@Field() enabled!: boolean;
|
||||
export class GitRepositoryGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field({ description: "The project's name for it: api, web, admin…" })
|
||||
name!: string;
|
||||
@Field(() => 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;
|
||||
@Field({ description: 'Path of the webhook on this API' })
|
||||
@Field() createdAt!: string;
|
||||
}
|
||||
|
||||
@ObjectType('GitIntegration', {
|
||||
description:
|
||||
'Webhooks from GitHub, Gitea or GitLab: commits, branches and pull requests naming a ticket key are linked to it',
|
||||
})
|
||||
export class GitIntegrationGql {
|
||||
@Field({ description: 'On while there is at least one repository' })
|
||||
enabled!: boolean;
|
||||
@Field({
|
||||
description:
|
||||
'Path of the webhook on this API, the same for every repository',
|
||||
})
|
||||
webhookPath!: string;
|
||||
@Field(() => [GitRepositoryGql])
|
||||
repositories!: GitRepositoryGql[];
|
||||
}
|
||||
|
||||
@ObjectType('AutomationTriggerSpec')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user