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:
178
libs/api/tracker/application/src/lib/git/git-payloads.ts
Normal file
178
libs/api/tracker/application/src/lib/git/git-payloads.ts
Normal 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 [];
|
||||
}
|
||||
Reference in New Issue
Block a user