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

@@ -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 });
}
}

View File

@@ -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 ?? [],
};