Files
zeekay 4ae120919c feat(github): Hanzo AI GitHub App — PR review, issue triage, @hanzo mentions
A GitHub App (Octokit + webhooks) built on @hanzo/ai (headless model client)
and @hanzo/iam (identity). Reviews pull requests, triages new issues, and
answers @hanzo mentions in issue/PR comments.

Pure cores (network-free, exhaustively unit-tested):
- webhook.ts  X-Hub-Signature-256 HMAC verify (constant time) + event routing
- review.ts   diff windowing/truncation, prompt assembly, review formatting
- triage.ts   issue prompt, severity/label parsing (labels ∩ repo labels)
- mention.ts  thread-context answer prompt

Impure edges compose them: config (env boundary), hanzo (thin @hanzo/ai — the
one model path), github-api (thin Octokit .request wrappers), app (App +
handlers → route → dispatch), server (health + graceful shutdown).

84 vitest tests green; tsc --noEmit clean; build succeeds.
Webhook path /v1/github/webhooks (Hanzo /v1, never /api/). Secrets env-only.
2026-07-03 21:24:23 -07:00

116 lines
3.7 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { createHmac } from 'node:crypto';
import {
sign,
verifySignature,
parseMention,
routeEvent,
SIGNATURE_HEADER,
type RoutablePayload,
} from '../src/webhook.js';
const SECRET = 'topsecret';
const BODY = JSON.stringify({ action: 'opened', hello: 'world' });
describe('sign / verifySignature (HMAC)', () => {
it('sign matches a manual sha256 HMAC in GitHub format', () => {
const expected = 'sha256=' + createHmac('sha256', SECRET).update(BODY, 'utf8').digest('hex');
expect(sign(BODY, SECRET)).toBe(expected);
});
it('accepts a valid signature', () => {
expect(verifySignature(BODY, sign(BODY, SECRET), SECRET)).toBe(true);
});
it('rejects a tampered body', () => {
const sig = sign(BODY, SECRET);
expect(verifySignature(BODY + 'x', sig, SECRET)).toBe(false);
});
it('rejects the wrong secret', () => {
expect(verifySignature(BODY, sign(BODY, 'other'), SECRET)).toBe(false);
});
it('rejects a missing signature or secret without throwing', () => {
expect(verifySignature(BODY, undefined, SECRET)).toBe(false);
expect(verifySignature(BODY, sign(BODY, SECRET), '')).toBe(false);
});
it('rejects a wrong-length signature (constant-time guard)', () => {
expect(verifySignature(BODY, 'sha256=deadbeef', SECRET)).toBe(false);
});
it('exposes the header name it verifies', () => {
expect(SIGNATURE_HEADER).toBe('x-hub-signature-256');
});
});
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 pull_request opened/synchronize/reopened to review', () => {
for (const action of ['opened', 'synchronize', 'reopened']) {
const p: RoutablePayload = { action, pull_request: {} };
expect(routeEvent('pull_request', p)).toEqual({ kind: 'review_pr' });
}
});
it('ignores other pull_request actions', () => {
const r = routeEvent('pull_request', { action: 'closed', pull_request: {} });
expect(r.kind).toBe('ignore');
});
it('routes issues opened to triage', () => {
expect(routeEvent('issues', { action: 'opened', issue: {} })).toEqual({ kind: 'triage_issue' });
});
it('ignores non-opened issues actions', () => {
expect(routeEvent('issues', { action: 'edited', issue: {} }).kind).toBe('ignore');
});
it('routes issue_comment created with @hanzo to answer_mention', () => {
const r = routeEvent('issue_comment', {
action: 'created',
comment: { body: '@hanzo what is this?' },
});
expect(r).toEqual({ kind: 'answer_mention', question: 'what is this?' });
});
it('ignores an issue_comment without @hanzo', () => {
const r = routeEvent('issue_comment', { action: 'created', comment: { body: 'nice work' } });
expect(r.kind).toBe('ignore');
});
it('ignores edited/deleted comments', () => {
const r = routeEvent('issue_comment', {
action: 'edited',
comment: { body: '@hanzo hi' },
});
expect(r.kind).toBe('ignore');
});
it('ignores unknown events', () => {
expect(routeEvent('star', {}).kind).toBe('ignore');
});
});