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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user