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

154 lines
5.5 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
GitlabClient,
getMRChanges,
createMRNote,
createIssueNote,
addIssueLabels,
listLabels,
getNotes,
encodeProject,
type FetchLike,
} from '../src/gitlab-api.js';
interface Call {
url: string;
method?: string;
headers?: Record<string, string>;
body?: string;
}
/** A fake fetch recording every call and returning a scripted JSON body. */
function fakeFetch(dataFor: (url: string, method: string) => unknown): {
fetch: FetchLike;
calls: Call[];
} {
const calls: Call[] = [];
const fetch: FetchLike = async (url, init) => {
const method = init?.method ?? 'GET';
calls.push({ url, method, headers: init?.headers, body: init?.body });
const data = dataFor(url, method);
return { ok: true, status: 200, text: async () => (data === undefined ? '' : JSON.stringify(data)) };
};
return { fetch, calls };
}
function client(fetch: FetchLike, baseURL = 'https://gitlab.com'): GitlabClient {
return new GitlabClient({ baseURL, token: 'glpat-secret', fetch });
}
describe('encodeProject', () => {
it('passes a numeric id through', () => {
expect(encodeProject(42)).toBe('42');
});
it('url-encodes a namespace/project path', () => {
expect(encodeProject('group/sub/proj')).toBe('group%2Fsub%2Fproj');
});
});
describe('GitlabClient base URL normalization', () => {
it('strips a trailing slash and an accidental /api/v4 suffix', async () => {
const { fetch, calls } = fakeFetch(() => []);
const c = new GitlabClient({ baseURL: 'https://gl.example.com/api/v4/', token: 't', fetch });
await listLabels(c, 5);
expect(calls[0].url).toBe('https://gl.example.com/api/v4/projects/5/labels?per_page=100');
});
it('sends the token as PRIVATE-TOKEN', async () => {
const { fetch, calls } = fakeFetch(() => []);
await listLabels(client(fetch), 5);
expect(calls[0].headers?.['PRIVATE-TOKEN']).toBe('glpat-secret');
});
it('throws on a non-2xx response with status and body', async () => {
const failing: FetchLike = async () => ({
ok: false,
status: 403,
text: async () => '{"message":"403 Forbidden"}',
});
await expect(listLabels(client(failing), 5)).rejects.toThrow(/403/);
});
});
describe('getMRChanges', () => {
it('hits the changes endpoint and returns the changes array', async () => {
const { fetch, calls } = fakeFetch(() => ({
changes: [{ old_path: 'a.ts', new_path: 'a.ts', diff: '+a' }],
}));
const changes = await getMRChanges(client(fetch), 'group/proj', 12);
expect(changes).toHaveLength(1);
expect(changes[0].new_path).toBe('a.ts');
expect(calls[0].url).toBe(
'https://gitlab.com/api/v4/projects/group%2Fproj/merge_requests/12/changes',
);
expect(calls[0].method).toBe('GET');
});
it('returns [] when the response lacks changes', async () => {
const { fetch } = fakeFetch(() => ({}));
expect(await getMRChanges(client(fetch), 1, 3)).toEqual([]);
});
});
describe('createMRNote / createIssueNote', () => {
it('POSTs a JSON note body to the MR notes endpoint', async () => {
const { fetch, calls } = fakeFetch(() => ({}));
await createMRNote(client(fetch), 7, 5, 'hello MR');
expect(calls[0].method).toBe('POST');
expect(calls[0].url).toBe('https://gitlab.com/api/v4/projects/7/merge_requests/5/notes');
expect(calls[0].headers?.['content-type']).toBe('application/json');
expect(JSON.parse(calls[0].body ?? '{}')).toEqual({ body: 'hello MR' });
});
it('POSTs to the issue notes endpoint', async () => {
const { fetch, calls } = fakeFetch(() => ({}));
await createIssueNote(client(fetch), 7, 5, 'hello issue');
expect(calls[0].url).toBe('https://gitlab.com/api/v4/projects/7/issues/5/notes');
expect(JSON.parse(calls[0].body ?? '{}')).toEqual({ body: 'hello issue' });
});
});
describe('addIssueLabels', () => {
it('is a no-op for an empty list', async () => {
const { fetch, calls } = fakeFetch(() => ({}));
await addIssueLabels(client(fetch), 7, 5, []);
expect(calls).toHaveLength(0);
});
it('PUTs add_labels as a comma-joined query, not clobbering existing', async () => {
const { fetch, calls } = fakeFetch(() => ({}));
await addIssueLabels(client(fetch), 7, 5, ['bug', 'crash']);
expect(calls[0].method).toBe('PUT');
expect(calls[0].url).toBe(
'https://gitlab.com/api/v4/projects/7/issues/5?add_labels=bug%2Ccrash',
);
});
});
describe('listLabels', () => {
it('returns label names only, fetching a single page of 100', async () => {
const { fetch, calls } = fakeFetch(() => [{ name: 'bug' }, { name: 'crash' }, { id: 1 }]);
expect(await listLabels(client(fetch), 3)).toEqual(['bug', 'crash']);
expect(calls[0].url).toContain('per_page=100');
});
});
describe('getNotes', () => {
it('drops system notes and empties, returns author+body oldest-first', async () => {
const { fetch, calls } = fakeFetch(() => [
{ author: { username: 'alice' }, body: 'first', system: false },
{ author: { username: 'root' }, body: 'changed the labels', system: true },
{ author: { username: 'bob' }, body: ' ', system: false },
{ author: { username: 'carol' }, body: 'third', system: false },
]);
const out = await getNotes(client(fetch), 'group/proj', 'merge_requests', 5);
expect(out).toEqual([
{ author: 'alice', body: 'first' },
{ author: 'carol', body: 'third' },
]);
expect(calls[0].url).toContain('sort=asc');
expect(calls[0].url).toContain('/merge_requests/5/notes');
});
});