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

@@ -8,6 +8,8 @@ import {
ReleaseStatus,
SprintStatus,
StatusCategory,
DevLinkKind,
DevLinkState,
} from '@task-stream/api/tracker/domain';
export enum LinkDirection {
@@ -22,6 +24,8 @@ registerEnumType(StatusCategory, {
'What a status means, whatever each project calls it. DONE and CANCELLED are finished.',
});
registerEnumType(IssueLinkType, { name: 'IssueLinkType' });
registerEnumType(DevLinkKind, { name: 'DevLinkKind' });
registerEnumType(DevLinkState, { name: 'DevLinkState' });
registerEnumType(ProjectRole, { name: 'ProjectRole' });
registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' });
registerEnumType(SprintStatus, {

View File

@@ -0,0 +1,43 @@
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';
const webhookPath = (projectId: string) => `/api/webhooks/git/${projectId}`;
/** The project's Git integration (owners and admins configure it). */
@Resolver(() => GitIntegrationGql)
export class GitResolver {
constructor(private readonly git: GitService) {}
@Query(() => GitIntegrationGql)
async gitIntegration(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
) {
const integration = await this.git.integration(me.id, projectId);
return { ...integration, webhookPath: webhookPath(projectId) };
}
@Mutation(() => GitIntegrationGql, {
description:
'Turn the Git webhook on, with a new secret (also to rotate it)',
})
async enableGitIntegration(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
) {
const integration = await this.git.enable(me.id, projectId);
return { ...integration, webhookPath: webhookPath(projectId) };
}
@Mutation(() => Boolean)
async disableGitIntegration(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
) {
await this.git.disable(me.id, projectId);
return true;
}
}

View File

@@ -7,6 +7,8 @@ import {
ReleaseStatus,
SprintStatus,
StatusCategory,
DevLinkKind,
DevLinkState,
} from '@task-stream/api/tracker/domain';
import { LinkDirection } from './enums';
@@ -139,6 +141,35 @@ export class ChecklistItemGql {
@Field() done!: boolean;
}
@ObjectType('DevLink', {
description: 'A commit, branch or pull request that mentions the ticket',
})
export class DevLinkGql {
@Field(() => DevLinkKind) kind!: DevLinkKind;
@Field() url!: string;
@Field() title!: string;
@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 }) 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',
})
export class GitIntegrationGql {
@Field() enabled!: boolean;
@Field(() => String, {
nullable: true,
description: 'Webhook secret; only owners and admins see it',
})
secret!: string | null;
@Field({ description: 'Path of the webhook on this API' })
webhookPath!: string;
}
@ObjectType('IssueParent', { description: 'The ticket another one hangs from' })
export class IssueParentGql {
@Field(() => ID) id!: string;
@@ -192,6 +223,10 @@ export class IssueGql {
})
rank!: string;
@Field(() => [ChecklistItemGql]) checklist!: ChecklistItemGql[];
@Field(() => [DevLinkGql], {
description: 'Commits, branches and pull requests that mention it',
})
devLinks!: DevLinkGql[];
@Field(() => Int) commentCount!: number;
@Field() createdAt!: string;
@Field() updatedAt!: string;