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,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 [];
}