Files
zeekay b801fd42d5 feat(gitlab): Hanzo AI for GitLab — MR review, issue triage, @hanzo mentions
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.
2026-07-03 21:42:29 -07:00

145 lines
4.5 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { EventEmitter } from 'node:events';
import { handleRequest, WEBHOOK_PATH } from '../src/server.js';
import { GitlabClient } from '../src/gitlab-api.js';
import { loadConfig } from '../src/config.js';
import type { Service, RunModel } from '../src/app.js';
import type { IncomingMessage, ServerResponse } from 'node:http';
const config = loadConfig({
GITLAB_TOKEN: 't',
GITLAB_WEBHOOK_SECRET: 'whsec',
HANZO_MODEL: 'zen5',
} as NodeJS.ProcessEnv);
/** Build a Service whose client never actually fetches and whose run is a stub. */
function service(run: RunModel = async () => 'out'): { service: Service; dispatched: string[] } {
const dispatched: string[] = [];
const client = new GitlabClient({
baseURL: 'https://gitlab.com',
token: 't',
fetch: async (url) => {
dispatched.push(url);
return { ok: true, status: 200, text: async () => '{}' };
},
});
return { service: { config, client, run }, dispatched };
}
/** A fake IncomingMessage that streams `body` then ends. */
function fakeReq(opts: {
method: string;
url: string;
headers: Record<string, string>;
body?: string;
}): IncomingMessage {
const emitter = new EventEmitter();
const req = emitter as unknown as IncomingMessage;
req.method = opts.method;
req.url = opts.url;
req.headers = opts.headers;
(req as unknown as { destroy: () => void }).destroy = () => {};
// Emit the body on the next tick so the handler can attach listeners first.
queueMicrotask(() => {
if (opts.body) emitter.emit('data', Buffer.from(opts.body, 'utf8'));
emitter.emit('end');
});
return req;
}
/** A fake ServerResponse capturing status + JSON body. */
function fakeRes(): ServerResponse & { statusCode: number; payload: string } {
const res = {
statusCode: 0,
payload: '',
headersSent: false,
writeHead(status: number) {
this.statusCode = status;
this.headersSent = true;
return this;
},
end(body?: string) {
if (body) this.payload = body;
return this;
},
};
return res as unknown as ServerResponse & { statusCode: number; payload: string };
}
const validHeaders = {
'x-gitlab-token': 'whsec',
'x-gitlab-event': 'Issue Hook',
};
describe('handleRequest', () => {
it('answers /health with the model', async () => {
const { service: svc } = service();
const res = fakeRes();
await handleRequest(svc, fakeReq({ method: 'GET', url: '/health', headers: {} }), res);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.payload)).toMatchObject({ status: 'ok', model: 'zen5' });
});
it('404s an unknown path', async () => {
const { service: svc } = service();
const res = fakeRes();
await handleRequest(svc, fakeReq({ method: 'GET', url: '/nope', headers: {} }), res);
expect(res.statusCode).toBe(404);
});
it('rejects a missing/invalid X-Gitlab-Token with 401 and does not dispatch', async () => {
const { service: svc, dispatched } = service();
const res = fakeRes();
await handleRequest(
svc,
fakeReq({
method: 'POST',
url: WEBHOOK_PATH,
headers: { 'x-gitlab-token': 'wrong', 'x-gitlab-event': 'Issue Hook' },
body: '{}',
}),
res,
);
expect(res.statusCode).toBe(401);
expect(dispatched).toHaveLength(0);
});
it('400s an invalid JSON body after a valid token', async () => {
const { service: svc } = service();
const res = fakeRes();
await handleRequest(
svc,
fakeReq({ method: 'POST', url: WEBHOOK_PATH, headers: validHeaders, body: 'not json' }),
res,
);
expect(res.statusCode).toBe(400);
});
it('ACKs 202 and dispatches a valid, verified issue-open event', async () => {
let ran = false;
const { service: svc } = service(async () => {
ran = true;
return 'Summary: x\nSeverity: low\nLabels: none\nResponse:\nHi';
});
const res = fakeRes();
const body = JSON.stringify({
object_kind: 'issue',
project: { id: 1, path_with_namespace: 'g/p' },
object_attributes: { action: 'open', iid: 5, title: 'T', description: 'B' },
});
await handleRequest(
svc,
fakeReq({
method: 'POST',
url: WEBHOOK_PATH,
headers: { 'x-gitlab-token': 'whsec', 'x-gitlab-event': 'Issue Hook' },
body,
}),
res,
);
expect(res.statusCode).toBe(202);
// dispatch runs inline within handleRequest before it resolves.
expect(ran).toBe(true);
});
});