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,88 @@
import { createHmac } from 'node:crypto';
import { DevLinkKind, DevLinkState } from '@task-stream/api/tracker/domain';
import {
gitMentionsOf,
gitProviderOf,
verifyGitSignature,
} from './git-payloads';
describe('git payloads', () => {
const body = Buffer.from('{"a":1}');
const hmac = createHmac('sha256', 's3cret').update(body).digest('hex');
it('tells the host apart and checks its signature', () => {
expect(gitProviderOf({ 'x-gitea-event': 'push' })).toEqual({
provider: 'gitea',
event: 'push',
});
expect(gitProviderOf({})).toBeNull();
expect(
verifyGitSignature(
'github',
{ 'x-hub-signature-256': `sha256=${hmac}` },
body,
's3cret',
),
).toBe(true);
expect(
verifyGitSignature('gitea', { 'x-gitea-signature': hmac }, body, 'other'),
).toBe(false);
expect(
verifyGitSignature(
'gitlab',
{ 'x-gitlab-token': 's3cret' },
body,
's3cret',
),
).toBe(true);
});
it('reads the commits and the branch of a push', () => {
const mentions = gitMentionsOf('gitea', 'push', {
ref: 'refs/heads/web-7-checkout',
repository: { html_url: 'https://git.example.org/acme/web/' },
pusher: { login: 'ana' },
commits: [
{
id: 'abcdef1234567',
message: 'WEB-7: card form\n\nlong text',
url: 'https://git.example.org/acme/web/commit/abcdef1234567',
author: { name: 'Ana' },
timestamp: '2026-10-08T10:00:00Z',
},
],
});
expect(mentions.map((m) => [m.link.kind, m.link.ref, m.text])).toEqual([
[DevLinkKind.COMMIT, 'abcdef12', 'WEB-7: card form\n\nlong text'],
[DevLinkKind.BRANCH, 'web-7-checkout', 'web-7-checkout'],
]);
expect(mentions[1].link.url).toBe(
'https://git.example.org/acme/web/src/branch/web-7-checkout',
);
});
it('reads pull and merge requests with their state', () => {
const [pr] = gitMentionsOf('github', 'pull_request', {
pull_request: {
html_url: 'https://github.com/acme/web/pull/12',
number: 12,
title: 'Pay by card',
head: { ref: 'web-7' },
state: 'closed',
merged: true,
},
});
expect(pr.link).toMatchObject({ ref: '#12', state: DevLinkState.MERGED });
expect(pr.text).toContain('web-7');
const [mr] = gitMentionsOf('gitlab', 'Merge Request Hook', {
object_attributes: {
url: 'https://gitlab.com/a/b/-/merge_requests/3',
iid: 3,
title: 'WEB-9',
state: 'opened',
},
});
expect(mr.link).toMatchObject({ ref: '!3', state: DevLinkState.OPEN });
expect(gitMentionsOf('github', 'ping', {})).toEqual([]);
});
});

View File

@@ -0,0 +1,178 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import {
DevLink,
DevLinkKind,
DevLinkState,
} from '@task-stream/api/tracker/domain';
export type GitProvider = 'github' | 'gitea' | 'gitlab';
/** Code the host told us about, and the text where ticket keys may appear. */
export interface GitMention {
text: string;
link: DevLink;
}
type Json = Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
/** Which host sent it, from its event header. */
export function gitProviderOf(
headers: Record<string, string | undefined>,
): { provider: GitProvider; event: string } | null {
if (headers['x-gitea-event'])
return { provider: 'gitea', event: headers['x-gitea-event'] };
if (headers['x-github-event'])
return { provider: 'github', event: headers['x-github-event'] };
if (headers['x-gitlab-event'])
return { provider: 'gitlab', event: headers['x-gitlab-event'] };
return null;
}
const same = (a: string, b: string) =>
a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
/**
* GitHub and Gitea sign the raw body (HMAC-SHA256, hex); GitLab sends the
* secret itself as a token.
*/
export function verifyGitSignature(
provider: GitProvider,
headers: Record<string, string | undefined>,
rawBody: Buffer | undefined,
secret: string,
): boolean {
if (provider === 'gitlab')
return (
!!headers['x-gitlab-token'] && same(headers['x-gitlab-token'], secret)
);
const received = (
provider === 'github'
? headers['x-hub-signature-256']
: headers['x-gitea-signature']
)?.replace(/^sha256=/, '');
if (!received || !rawBody) return false;
return same(
received,
createHmac('sha256', secret).update(rawBody).digest('hex'),
);
}
const branchOf = (ref: unknown) =>
typeof ref === 'string' && ref.startsWith('refs/heads/')
? ref.slice('refs/heads/'.length)
: null;
/** Commits of a push, and the branch they went to (all three hosts). */
function fromPush(
provider: GitProvider,
body: Json,
repoUrl: string,
): GitMention[] {
const mentions: GitMention[] = [];
const branch = branchOf(body['ref']);
for (const commit of (body['commits'] ?? []) as Json[]) {
if (!commit?.['url'] || !commit['id']) continue;
const message = String(commit['message'] ?? '');
mentions.push({
text: message,
link: {
kind: DevLinkKind.COMMIT,
url: String(commit['url']),
title: message,
ref: String(commit['id']).slice(0, 8),
state: null,
provider,
author: commit['author']?.['name'] ?? null,
at: String(commit['timestamp'] ?? new Date().toISOString()),
},
});
}
if (branch && repoUrl) {
const path =
provider === 'gitlab'
? '/-/tree/'
: provider === 'gitea'
? '/src/branch/'
: '/tree/';
mentions.push({
text: branch,
link: {
kind: DevLinkKind.BRANCH,
url: `${repoUrl}${path}${branch}`,
title: branch,
ref: branch,
state: null,
provider,
author:
body['pusher']?.['name'] ??
body['pusher']?.['login'] ??
body['user_name'] ??
null,
at: new Date().toISOString(),
},
});
}
return mentions;
}
function prState(merged: boolean, state: string): DevLinkState {
if (merged || state === 'merged') return DevLinkState.MERGED;
return state === 'closed' ? DevLinkState.CLOSED : DevLinkState.OPEN;
}
/**
* What a webhook delivery is about: commits and branches of a push, or a
* pull/merge request. Anything else (pings, tags, issues…) is nothing.
*/
export function gitMentionsOf(
provider: GitProvider,
event: string,
body: Json,
): GitMention[] {
const repoUrl = String(
body['repository']?.['html_url'] ?? body['project']?.['web_url'] ?? '',
).replace(/\/$/, '');
if (event === 'push' || event === 'Push Hook')
return fromPush(provider, body, repoUrl);
if (provider !== 'gitlab' && event === 'pull_request') {
const pr = body['pull_request'] as Json | undefined;
if (!pr?.['html_url']) return [];
const title = String(pr['title'] ?? '');
return [
{
text: `${title} ${pr['head']?.['ref'] ?? ''} ${pr['body'] ?? ''}`,
link: {
kind: DevLinkKind.PULL_REQUEST,
url: String(pr['html_url']),
title,
ref: `#${pr['number'] ?? ''}`,
state: prState(!!pr['merged'], String(pr['state'] ?? 'open')),
provider,
author: pr['user']?.['login'] ?? null,
at: String(pr['updated_at'] ?? new Date().toISOString()),
},
},
];
}
if (provider === 'gitlab' && event === 'Merge Request Hook') {
const mr = body['object_attributes'] as Json | undefined;
if (!mr?.['url']) return [];
const title = String(mr['title'] ?? '');
return [
{
text: `${title} ${mr['source_branch'] ?? ''} ${mr['description'] ?? ''}`,
link: {
kind: DevLinkKind.PULL_REQUEST,
url: String(mr['url']),
title,
ref: `!${mr['iid'] ?? ''}`,
state: prState(false, String(mr['state'] ?? 'opened')),
provider,
author: body['user']?.['username'] ?? null,
at: String(mr['updated_at'] ?? new Date().toISOString()),
},
},
];
}
return [];
}

View 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,
];