mirror of
https://github.com/hanzoai/extension.git
synced 2026-10-31 21:57:06 +00:00
New package @hanzo/gitlab mirrors @hanzo/github for the GitLab ecosystem (self-managed + gitlab.com). Webhook service over GitLab Project/Group webhooks + REST API v4, built on the published @hanzo/ai and @hanzo/iam. - webhook.ts: constant-time X-Gitlab-Token verify + X-Gitlab-Event routing (pure) - review.ts: GitLab changes → unified diff → windowing/truncation → note (pure) - triage.ts: issue → prompt; parse severity/labels ∩ project labels (pure) - mention.ts: MR/issue thread + @hanzo question → answer (pure) - gitlab-api.ts: thin REST v4 fetch wrappers (getMRChanges, listLabels, createMRNote/createIssueNote, addIssueLabels, getNotes) - hanzo.ts: one path to the model via @hanzo/ai (api.hanzo.ai /v1) - config.ts: env boundary (GITLAB_BASE_URL/TOKEN/WEBHOOK_SECRET, HANZO_API_KEY) - app.ts + server.ts: compose the pure cores; HTTP boundary with token gate, health probe, 202 ACK + graceful shutdown 97 vitest tests green, tsc --noEmit clean, build produces dist. Registered packages/gitlab in pnpm-workspace.yaml.
126 lines
3.9 KiB
TypeScript
126 lines
3.9 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
verifyToken,
|
|
parseMention,
|
|
routeEvent,
|
|
TOKEN_HEADER,
|
|
EVENT_HEADER,
|
|
type RoutablePayload,
|
|
} from '../src/webhook.js';
|
|
|
|
const SECRET = 'topsecret';
|
|
|
|
describe('verifyToken (constant-time secret compare)', () => {
|
|
it('accepts the exact secret', () => {
|
|
expect(verifyToken(SECRET, SECRET)).toBe(true);
|
|
});
|
|
|
|
it('rejects a wrong token', () => {
|
|
expect(verifyToken('wrong', SECRET)).toBe(false);
|
|
});
|
|
|
|
it('rejects a token of a different length without throwing', () => {
|
|
expect(verifyToken('short', SECRET)).toBe(false);
|
|
expect(verifyToken(SECRET + 'x', SECRET)).toBe(false);
|
|
});
|
|
|
|
it('rejects a missing token or secret', () => {
|
|
expect(verifyToken(undefined, SECRET)).toBe(false);
|
|
expect(verifyToken(SECRET, '')).toBe(false);
|
|
expect(verifyToken('', SECRET)).toBe(false);
|
|
});
|
|
|
|
it('exposes the header names it reads', () => {
|
|
expect(TOKEN_HEADER).toBe('x-gitlab-token');
|
|
expect(EVENT_HEADER).toBe('x-gitlab-event');
|
|
});
|
|
});
|
|
|
|
describe('parseMention', () => {
|
|
it('extracts the question after @hanzo', () => {
|
|
expect(parseMention('hey @hanzo why is this failing?')).toBe('why is this failing?');
|
|
});
|
|
|
|
it('is case-insensitive and matches at the start', () => {
|
|
expect(parseMention('@Hanzo explain the diff')).toBe('explain the diff');
|
|
});
|
|
|
|
it('returns empty string for a bare mention', () => {
|
|
expect(parseMention('@hanzo')).toBe('');
|
|
});
|
|
|
|
it('returns null when @hanzo is absent', () => {
|
|
expect(parseMention('this mentions nobody')).toBeNull();
|
|
});
|
|
|
|
it('does not match an email-like substring', () => {
|
|
expect(parseMention('mail me at [email protected]')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('routeEvent', () => {
|
|
it('routes Merge Request Hook open/update/reopen to review', () => {
|
|
for (const action of ['open', 'update', 'reopen']) {
|
|
const p: RoutablePayload = { object_kind: 'merge_request', object_attributes: { action } };
|
|
expect(routeEvent('Merge Request Hook', p)).toEqual({ kind: 'review_mr' });
|
|
}
|
|
});
|
|
|
|
it('ignores other merge_request actions', () => {
|
|
const r = routeEvent('Merge Request Hook', {
|
|
object_attributes: { action: 'close' },
|
|
});
|
|
expect(r.kind).toBe('ignore');
|
|
});
|
|
|
|
it('routes Issue Hook open/reopen to triage', () => {
|
|
expect(routeEvent('Issue Hook', { object_attributes: { action: 'open' } })).toEqual({
|
|
kind: 'triage_issue',
|
|
});
|
|
expect(routeEvent('Issue Hook', { object_attributes: { action: 'reopen' } })).toEqual({
|
|
kind: 'triage_issue',
|
|
});
|
|
});
|
|
|
|
it('ignores non-open issue actions', () => {
|
|
expect(routeEvent('Issue Hook', { object_attributes: { action: 'update' } }).kind).toBe(
|
|
'ignore',
|
|
);
|
|
});
|
|
|
|
it('routes a Note Hook on an MR with @hanzo to answer_mention', () => {
|
|
const r = routeEvent('Note Hook', {
|
|
object_attributes: { noteable_type: 'MergeRequest', note: '@hanzo what is this?' },
|
|
});
|
|
expect(r).toEqual({ kind: 'answer_mention', question: 'what is this?' });
|
|
});
|
|
|
|
it('routes a Note Hook on an Issue with @hanzo to answer_mention', () => {
|
|
const r = routeEvent('Note Hook', {
|
|
object_attributes: { noteable_type: 'Issue', note: '@hanzo summarize' },
|
|
});
|
|
expect(r).toEqual({ kind: 'answer_mention', question: 'summarize' });
|
|
});
|
|
|
|
it('ignores a note without @hanzo', () => {
|
|
const r = routeEvent('Note Hook', {
|
|
object_attributes: { noteable_type: 'Issue', note: 'nice work' },
|
|
});
|
|
expect(r.kind).toBe('ignore');
|
|
});
|
|
|
|
it('ignores notes on unsupported noteables (commit, snippet)', () => {
|
|
const r = routeEvent('Note Hook', {
|
|
object_attributes: { noteable_type: 'Commit', note: '@hanzo hi' },
|
|
});
|
|
expect(r.kind).toBe('ignore');
|
|
});
|
|
|
|
it('ignores unknown events (Push Hook, Pipeline Hook)', () => {
|
|
expect(routeEvent('Push Hook', {}).kind).toBe('ignore');
|
|
expect(routeEvent('Pipeline Hook', { object_attributes: { action: 'open' } }).kind).toBe(
|
|
'ignore',
|
|
);
|
|
});
|
|
});
|