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:
@@ -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, {
|
||||
|
||||
43
libs/api/tracker/presenters/src/lib/graphql/git.resolver.ts
Normal file
43
libs/api/tracker/presenters/src/lib/graphql/git.resolver.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -136,6 +136,42 @@ export class ChecklistItemDto {
|
||||
@ApiProperty() done!: boolean;
|
||||
}
|
||||
|
||||
export class DevLinkDto {
|
||||
@ApiProperty({
|
||||
enum: ['COMMIT', 'BRANCH', 'PULL_REQUEST'],
|
||||
enumName: 'DevLinkKind',
|
||||
})
|
||||
kind!: string;
|
||||
@ApiProperty() url!: string;
|
||||
@ApiProperty() title!: string;
|
||||
@ApiProperty({ description: 'Short SHA, branch or PR number' }) ref!: string;
|
||||
@ApiProperty({
|
||||
enum: ['OPEN', 'MERGED', 'CLOSED'],
|
||||
enumName: 'DevLinkState',
|
||||
nullable: true,
|
||||
})
|
||||
state!: string | null;
|
||||
@ApiProperty({ example: 'gitea' }) provider!: string;
|
||||
@ApiProperty({ type: String, nullable: true }) author!: string | null;
|
||||
@ApiProperty({ format: 'date-time' }) at!: string;
|
||||
}
|
||||
|
||||
export class GitIntegrationDto {
|
||||
@ApiProperty() enabled!: boolean;
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
description: 'Webhook secret; only owners and admins see it',
|
||||
})
|
||||
secret!: string | null;
|
||||
@ApiProperty({ example: '/api/webhooks/git/3f2c…' }) webhookPath!: string;
|
||||
}
|
||||
|
||||
export class GitDeliveryResultDto {
|
||||
@ApiProperty({ description: 'Tickets that got something new' })
|
||||
recorded!: number;
|
||||
}
|
||||
|
||||
export class IssueParentDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ example: 'WEB-3' }) key!: string;
|
||||
@@ -206,6 +242,11 @@ export class IssueDto {
|
||||
})
|
||||
rank!: string;
|
||||
@ApiProperty({ type: [ChecklistItemDto] }) checklist!: ChecklistItemDto[];
|
||||
@ApiProperty({
|
||||
type: [DevLinkDto],
|
||||
description: 'Commits, branches and pull requests that mention it',
|
||||
})
|
||||
devLinks!: DevLinkDto[];
|
||||
@ApiProperty() commentCount!: number;
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
@ApiProperty({ format: 'date-time' }) updatedAt!: string;
|
||||
|
||||
126
libs/api/tracker/presenters/src/lib/http/git.controller.ts
Normal file
126
libs/api/tracker/presenters/src/lib/http/git.controller.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
Param,
|
||||
Post,
|
||||
RawBodyRequest,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiCreatedResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import {
|
||||
ApiPireonAuth,
|
||||
CurrentUser,
|
||||
Public,
|
||||
} from '@task-stream/api/identity/presenters';
|
||||
import {
|
||||
ApiCommandErrors,
|
||||
ApiReadErrors,
|
||||
} 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 { Refs } from './refs';
|
||||
|
||||
const PROJECT = ApiParam({
|
||||
name: 'project',
|
||||
description: 'Project id or key',
|
||||
example: 'WEB',
|
||||
});
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* show on that ticket.
|
||||
*/
|
||||
@ApiTags('Git')
|
||||
@Controller()
|
||||
export class GitController {
|
||||
constructor(
|
||||
private readonly git: GitService,
|
||||
private readonly refs: Refs,
|
||||
) {}
|
||||
|
||||
@Get('projects/:project/git-integration')
|
||||
@ApiPireonAuth()
|
||||
@PROJECT
|
||||
@ApiOperation({
|
||||
summary: 'Whether the Git webhook is on (and its secret, for admins)',
|
||||
})
|
||||
@ApiOkResponse({ type: GitIntegrationDto })
|
||||
@ApiReadErrors()
|
||||
async integration(
|
||||
@CurrentUser() me: User,
|
||||
@Param('project') project: string,
|
||||
) {
|
||||
const projectId = await this.refs.projectId(me.id, project);
|
||||
return {
|
||||
...(await this.git.integration(me.id, projectId)),
|
||||
webhookPath: `/api/webhooks/git/${projectId}`,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('projects/:project/git-integration')
|
||||
@ApiPireonAuth()
|
||||
@PROJECT
|
||||
@ApiOperation({
|
||||
summary: 'Turn the Git webhook on with a new secret (or rotate it)',
|
||||
})
|
||||
@ApiCreatedResponse({ type: GitIntegrationDto })
|
||||
@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}`,
|
||||
};
|
||||
}
|
||||
|
||||
@Delete('projects/:project/git-integration')
|
||||
@ApiPireonAuth()
|
||||
@PROJECT
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Turn the Git webhook off' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiCommandErrors()
|
||||
async disable(@CurrentUser() me: User, @Param('project') project: string) {
|
||||
await this.git.disable(me.id, await this.refs.projectId(me.id, project));
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('webhooks/git/:project')
|
||||
@HttpCode(200)
|
||||
@PROJECT
|
||||
@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.',
|
||||
})
|
||||
@ApiOkResponse({ type: GitDeliveryResultDto })
|
||||
@ApiForbiddenResponse({
|
||||
description: 'Unknown project, integration off or bad signature',
|
||||
})
|
||||
receive(
|
||||
@Param('project') project: string,
|
||||
@Req() req: RawBodyRequest<Request>,
|
||||
@Headers() headers: Record<string, string | undefined>,
|
||||
) {
|
||||
return this.git.receive({
|
||||
project,
|
||||
headers,
|
||||
rawBody: req.rawBody,
|
||||
body: (req.body ?? {}) as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import { SavedFiltersController } from './http/saved-filters.controller';
|
||||
import { SavedFilterResolver } from './graphql/saved-filter.resolver';
|
||||
import { SprintResolver } from './graphql/sprint.resolver';
|
||||
import { SprintsController } from './http/sprints.controller';
|
||||
import { GitController } from './http/git.controller';
|
||||
import { GitResolver } from './graphql/git.resolver';
|
||||
|
||||
/**
|
||||
* Driving adapters of the tracker context: GraphQL (queries, mutations,
|
||||
@@ -51,6 +53,7 @@ import { SprintsController } from './http/sprints.controller';
|
||||
NotificationsController,
|
||||
SavedFiltersController,
|
||||
SprintsController,
|
||||
GitController,
|
||||
],
|
||||
providers: [
|
||||
Refs,
|
||||
@@ -62,6 +65,7 @@ import { SprintsController } from './http/sprints.controller';
|
||||
NotificationResolver,
|
||||
SavedFilterResolver,
|
||||
SprintResolver,
|
||||
GitResolver,
|
||||
],
|
||||
})
|
||||
export class TrackerPresentersModule {
|
||||
|
||||
Reference in New Issue
Block a user