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

195 lines
6.6 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { dispatch, type RunModel } from '../src/app.js';
import { GitlabClient, type FetchLike } from '../src/gitlab-api.js';
import { loadConfig, type Config } from '../src/config.js';
const config: Config = loadConfig({
GITLAB_TOKEN: 'glpat-abc',
GITLAB_WEBHOOK_SECRET: 's',
HANZO_MODEL: 'zen5',
} as NodeJS.ProcessEnv);
interface Call {
url: string;
method: string;
body?: unknown;
}
/** Fake fetch scripting responses per (url, method), recording every call. */
function fakeClient(dataFor: (url: string, method: string) => unknown): {
client: GitlabClient;
calls: Call[];
} {
const calls: Call[] = [];
const fetch: FetchLike = async (url, init) => {
const method = init?.method ?? 'GET';
calls.push({ url, method, body: init?.body ? JSON.parse(init.body) : undefined });
const data = dataFor(url, method);
return { ok: true, status: 200, text: async () => (data === undefined ? '' : JSON.stringify(data)) };
};
return { client: new GitlabClient({ baseURL: 'https://gitlab.com', token: 't', fetch }), calls };
}
const stubRun: RunModel = async () => 'MODEL OUTPUT';
const project = { id: 100, path_with_namespace: 'hanzoai/extension' };
describe('dispatch: Merge Request Hook → review', () => {
it('fetches changes and posts a review note', async () => {
const { client, calls } = fakeClient((url) =>
url.endsWith('/changes')
? { changes: [{ old_path: 'x.ts', new_path: 'x.ts', diff: '+a' }] }
: {},
);
const captured: Array<{ system: string; user: string }> = [];
const run: RunModel = async (i) => {
captured.push(i);
return 'Looks good.';
};
await dispatch(
'Merge Request Hook',
{ object_kind: 'merge_request', project, object_attributes: { action: 'open', iid: 12, title: 'T', description: 'B' } },
client,
config,
run,
);
const changesCall = calls.find((c) => c.url.endsWith('/changes'));
expect(changesCall?.url).toContain('/projects/100/merge_requests/12/changes');
const note = calls.find((c) => c.method === 'POST' && c.url.endsWith('/merge_requests/12/notes'));
expect((note?.body as { body: string }).body).toContain('Looks good.');
// The reconstructed diff reached the prompt.
expect(captured[0].user).toContain('diff --git a/x.ts');
});
it('ignores a closed MR (no calls)', async () => {
const { client, calls } = fakeClient(() => ({}));
await dispatch(
'Merge Request Hook',
{ project, object_attributes: { action: 'close', iid: 12 } },
client,
config,
stubRun,
);
expect(calls).toHaveLength(0);
});
});
describe('dispatch: Issue Hook → triage', () => {
it('lists labels, notes, and applies only real labels', async () => {
const { client, calls } = fakeClient((url, method) =>
url.includes('/labels') && method === 'GET' ? [{ name: 'bug' }, { name: 'crash' }] : {},
);
const run: RunModel = async () =>
['Summary: Boot crash', 'Severity: high', 'Labels: bug, wontfix', 'Response:', 'Thanks!'].join(
'\n',
);
await dispatch(
'Issue Hook',
{ project, object_attributes: { action: 'open', iid: 7, title: 'Crash', description: 'boom' } },
client,
config,
run,
);
expect(calls.find((c) => c.url.includes('/labels') && c.method === 'GET')).toBeDefined();
const note = calls.find((c) => c.method === 'POST' && c.url.endsWith('/issues/7/notes'));
expect((note?.body as { body: string }).body).toContain('Boot crash');
// "wontfix" is not a real project label — only bug survives, applied via PUT add_labels.
const put = calls.find((c) => c.method === 'PUT' && c.url.includes('/issues/7'));
expect(put?.url).toContain('add_labels=bug');
expect(put?.url).not.toContain('wontfix');
});
it('applies no labels when the model picks none real (no PUT)', async () => {
const { client, calls } = fakeClient((url, method) =>
url.includes('/labels') && method === 'GET' ? [{ name: 'bug' }] : {},
);
const run: RunModel = async () => 'Summary: x\nSeverity: low\nLabels: wontfix\nResponse:\nHi';
await dispatch(
'Issue Hook',
{ project, object_attributes: { action: 'open', iid: 8, title: 'T', description: 'B' } },
client,
config,
run,
);
expect(calls.find((c) => c.method === 'PUT')).toBeUndefined();
});
});
describe('dispatch: Note Hook → mention', () => {
it('answers a @hanzo question on an MR using the thread', async () => {
const { client, calls } = fakeClient((url, method) =>
url.includes('/notes') && method === 'GET'
? [{ author: { username: 'alice' }, body: 'context here', system: false }]
: {},
);
const captured: Array<{ user: string }> = [];
const run: RunModel = async (i) => {
captured.push(i);
return 'The answer.';
};
await dispatch(
'Note Hook',
{
project,
object_attributes: { noteable_type: 'MergeRequest', note: '@hanzo what does this do?' },
merge_request: { iid: 3, title: 'Q', description: 'body' },
},
client,
config,
run,
);
const post = calls.find((c) => c.method === 'POST' && c.url.endsWith('/merge_requests/3/notes'));
expect((post?.body as { body: string }).body).toContain('The answer.');
expect(captured[0].user).toContain('what does this do?');
expect(captured[0].user).toContain('@alice: context here');
});
it('answers a @hanzo question on an issue', async () => {
const { client, calls } = fakeClient((url, method) =>
url.includes('/notes') && method === 'GET' ? [] : {},
);
await dispatch(
'Note Hook',
{
project,
object_attributes: { noteable_type: 'Issue', note: '@hanzo help' },
issue: { iid: 4, title: 'Q', description: 'body' },
},
client,
config,
stubRun,
);
const post = calls.find((c) => c.method === 'POST' && c.url.endsWith('/issues/4/notes'));
expect(post).toBeDefined();
});
it('ignores a note without @hanzo', async () => {
const { client, calls } = fakeClient(() => ({}));
await dispatch(
'Note Hook',
{
project,
object_attributes: { noteable_type: 'Issue', note: 'nice' },
issue: { iid: 4 },
},
client,
config,
stubRun,
);
expect(calls).toHaveLength(0);
});
});
describe('dispatch: unknown event', () => {
it('does nothing', async () => {
const { client, calls } = fakeClient(() => ({}));
await dispatch('Push Hook', { project, object_attributes: { action: 'open' } }, client, config, stubRun);
expect(calls).toHaveLength(0);
});
});