feat: link commits, branches and pull requests to tickets

A project can turn on a webhook for GitHub, Gitea or GitLab. Pushes and
pull or merge requests that name a ticket key show on that ticket under
Desarrollo, with the state of each pull request; the secret is shown and
rotated from the project settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 15:28:12 +02:00
parent 24b37b6628
commit 046d77c813
48 changed files with 2638 additions and 12 deletions

View File

@@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { GitIntegrationsRepository } from '@task-stream/api/tracker/application';
import { Model } from 'mongoose';
import { GitIntegrationDoc, ReadModels } from './read-model.schemas';
@Injectable()
export class MongoGitIntegrationsRepository extends GitIntegrationsRepository {
constructor(
@InjectModel(ReadModels.GitIntegration)
private readonly integrations: Model<GitIntegrationDoc>,
) {
super();
}
async secretOf(projectId: string): Promise<string | null> {
const doc = await this.integrations
.findById(projectId)
.lean<GitIntegrationDoc>();
return doc?.secret ?? null;
}
async save(projectId: string, secret: string): Promise<void> {
await this.integrations.updateOne(
{ _id: projectId },
{ $set: { secret, createdAt: new Date().toISOString() } },
{ upsert: true },
);
}
async remove(projectId: string): Promise<void> {
await this.integrations.deleteOne({ _id: projectId });
}
}

View File

@@ -30,6 +30,11 @@ export type ActivityDoc = Doc<ActivityReadModel>;
export type TimelineDoc = Doc<IssueTimelineEntry>;
export type NotificationDoc = Doc<NotificationReadModel>;
export type SavedFilterDoc = Doc<SavedFilterReadModel>;
export interface GitIntegrationDoc {
_id: string;
secret: string;
createdAt: string;
}
export interface NotificationSettingsDoc {
_id: string;
email: boolean;
@@ -46,6 +51,7 @@ export const ReadModels = {
NotificationSettings: 'NotificationSettings',
SavedFilter: 'SavedFilter',
Sprint: 'SprintReadModel',
GitIntegration: 'GitIntegration',
} as const;
const userRef = { _id: false, id: String, name: String };
@@ -192,12 +198,18 @@ export const SavedFilterSchema = new Schema<SavedFilterDoc>(
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 },
{ ...loose, collection: 'git_integrations' },
);
export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.Project, schema: ProjectSchema },
{ name: ReadModels.Issue, schema: IssueSchema },
{ name: ReadModels.Comment, schema: CommentSchema },
{ name: ReadModels.Release, schema: ReleaseSchema },
{ name: ReadModels.Sprint, schema: SprintSchema },
{ name: ReadModels.GitIntegration, schema: GitIntegrationSchema },
{ name: ReadModels.Activity, schema: ActivitySchema },
{ name: ReadModels.Timeline, schema: TimelineSchema },
{ name: ReadModels.Notification, schema: NotificationSchema },
@@ -225,6 +237,7 @@ 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 ?? [],
checklist: rest.checklist ?? [],
};
}

View File

@@ -12,6 +12,8 @@ import {
ActivityReadModel,
AttachmentReadModel,
ChecklistItemReadModel,
DevLink,
MAX_DEV_LINKS,
CommentReadModel,
IssueLinkReadModel,
IssueReadModel,
@@ -30,6 +32,7 @@ import {
ReadModels,
ReleaseDoc,
SavedFilterDoc,
GitIntegrationDoc,
TimelineDoc,
} from './read-model.schemas';
import { upsertIfNewer } from './upsert-if-newer';
@@ -47,6 +50,8 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
private readonly timeline: Model<TimelineDoc>,
@InjectModel(ReadModels.SavedFilter)
private readonly savedFilters: Model<SavedFilterDoc>,
@InjectModel(ReadModels.GitIntegration)
private readonly gitIntegrations: Model<GitIntegrationDoc>,
) {
super();
}
@@ -58,6 +63,7 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
this.timeline.deleteMany({ projectId }),
// Views of the board of a project that is gone.
this.savedFilters.deleteMany({ projectId }),
this.gitIntegrations.deleteOne({ _id: projectId }),
]);
}
@@ -168,6 +174,18 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
);
}
async putDevLink(issueId: string, link: DevLink) {
// Same URL: the newer report replaces the older one.
await this.issues.updateOne(
{ _id: issueId },
{ $pull: { devLinks: { url: link.url } } },
);
await this.issues.updateOne(
{ _id: issueId },
{ $push: { devLinks: { $each: [link], $slice: -MAX_DEV_LINKS } } },
);
}
async renameParent(parentId: string, title: string) {
await this.issues.updateMany(
{ 'parent.id': parentId },