Files
task-stream/libs/api/tracker/application/src/lib/git/git-payloads.ts
Josep Piguillem c2928d2385 feat: several Git repositories per project
A project keeps a list of repositories (api, web, admin…), each with its
own secret, instead of a single webhook secret. They all call the same
URL; the secret that verifies the signature tells which one it was, and
every commit, branch or pull request is linked with its repository's
name (shown on the ticket and in the MCP). Rotating or removing one
leaves the others alone, and what it linked stays.

A project that had turned the single webhook on keeps its secret, as a
repository named "principal".

API: the enable/disable endpoints and mutations give way to adding a
repository, rotating its secret and removing it (REST and GraphQL). The
web settings card lists the repositories. Docs, Word and PDF updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:39:59 +02:00

183 lines
5.4 KiB
TypeScript

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,
repository: null,
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,
repository: null,
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,
repository: null,
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,
repository: null,
author: body['user']?.['username'] ?? null,
at: String(mr['updated_at'] ?? new Date().toISOString()),
},
},
];
}
return [];
}