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:
157
libs/api/tracker/application/src/lib/git/git.ts
Normal file
157
libs/api/tracker/application/src/lib/git/git.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import {
|
||||
Command,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
IQueryHandler,
|
||||
Query,
|
||||
QueryHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import {
|
||||
Issue,
|
||||
ProjectRole,
|
||||
SYSTEM_ACTOR_ID,
|
||||
issueKeysIn,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { WriteAccess } from '../commands/write-access';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
|
||||
import { ReadAccess } from '../queries/read-access';
|
||||
import { GitMention } from './git-payloads';
|
||||
|
||||
export interface GitIntegration {
|
||||
enabled: boolean;
|
||||
/** Only for whoever manages the project. */
|
||||
secret: string | null;
|
||||
}
|
||||
|
||||
/** Turns the integration on with a new secret (or rotates it). */
|
||||
export class EnableGitIntegrationCommand extends Command<GitIntegration> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class DisableGitIntegrationCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Webhook only: code from a verified delivery, linked to the tickets it names. */
|
||||
export class RecordGitMentionsCommand extends Command<number> {
|
||||
constructor(
|
||||
public readonly projectId: string,
|
||||
public readonly mentions: GitMention[],
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GetGitIntegrationQuery extends Query<GitIntegration> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(EnableGitIntegrationCommand)
|
||||
export class EnableGitIntegrationCommandHandler
|
||||
implements ICommandHandler<EnableGitIntegrationCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly integrations: GitIntegrationsRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: EnableGitIntegrationCommand): Promise<GitIntegration> {
|
||||
(await this.access.project(command.projectId)).assertCanManage(
|
||||
command.actorId,
|
||||
);
|
||||
const secret = randomBytes(24).toString('hex');
|
||||
await this.integrations.save(command.projectId, secret);
|
||||
return { enabled: true, secret };
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(DisableGitIntegrationCommand)
|
||||
export class DisableGitIntegrationCommandHandler
|
||||
implements ICommandHandler<DisableGitIntegrationCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly integrations: GitIntegrationsRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: DisableGitIntegrationCommand): Promise<void> {
|
||||
(await this.access.project(command.projectId)).assertCanManage(
|
||||
command.actorId,
|
||||
);
|
||||
await this.integrations.remove(command.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(RecordGitMentionsCommand)
|
||||
export class RecordGitMentionsCommandHandler
|
||||
implements ICommandHandler<RecordGitMentionsCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
) {}
|
||||
|
||||
/** How many tickets got something new. Unknown keys are ignored. */
|
||||
async execute(command: RecordGitMentionsCommand): Promise<number> {
|
||||
const project = await this.access.project(command.projectId);
|
||||
let recorded = 0;
|
||||
for (const mention of command.mentions) {
|
||||
for (const key of issueKeysIn(mention.text, project.key)) {
|
||||
const found = await this.findIssues.findByKey(key);
|
||||
if (!found || found.projectId !== project.id) continue;
|
||||
const issue = await this.access.live(found.id, Issue);
|
||||
if (!issue) continue;
|
||||
const before = issue.getUncommittedEvents().length;
|
||||
issue.recordDevLink(SYSTEM_ACTOR_ID, mention.link);
|
||||
if (issue.getUncommittedEvents().length === before) continue;
|
||||
await issue.commitAndWait();
|
||||
recorded++;
|
||||
}
|
||||
}
|
||||
return recorded;
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetGitIntegrationQuery)
|
||||
export class GetGitIntegrationQueryHandler
|
||||
implements IQueryHandler<GetGitIntegrationQuery, GitIntegration>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: ReadAccess,
|
||||
private readonly integrations: GitIntegrationsRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetGitIntegrationQuery): Promise<GitIntegration> {
|
||||
const role = await this.access.assertCanView(
|
||||
query.projectId,
|
||||
query.viewerId,
|
||||
);
|
||||
const secret = await this.integrations.secretOf(query.projectId);
|
||||
const manages = role === ProjectRole.OWNER || role === ProjectRole.ADMIN;
|
||||
return { enabled: !!secret, secret: manages ? secret : null };
|
||||
}
|
||||
}
|
||||
|
||||
export const GIT_HANDLERS = [
|
||||
EnableGitIntegrationCommandHandler,
|
||||
DisableGitIntegrationCommandHandler,
|
||||
RecordGitMentionsCommandHandler,
|
||||
GetGitIntegrationQueryHandler,
|
||||
];
|
||||
Reference in New Issue
Block a user