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