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; // eslint-disable-line @typescript-eslint/no-explicit-any /** Which host sent it, from its event header. */ export function gitProviderOf( headers: Record, ): { 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, 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 []; }