Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/backend/src/routes/api/webhooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ import { getSandbox } from '@cloudflare/sandbox';
import { sanitizeRepoName } from '@/ai/mcp/tools/sandbox-sdk';
import { AutomationRegistry } from '@/automations/core/AutomationRegistry';
import { JulesService } from '@/services/jules/service';
import { forwardPullRequestToMaestro } from '@/utils/maestro-fanout';
import { forwardPullRequestToMaestro, forwardRawEventToMaestro } from '@/utils/maestro-fanout';

const webhooksApi = new Hono<{ Bindings: Env }>();

Expand Down Expand Up @@ -181,6 +181,12 @@ export async function webhookHandler(c: Context<{ Bindings: Env }>): Promise<Res
forwardPullRequestToMaestro(c.env as never, deliveryId ?? '', payload as never)
);
}
// Repo lifecycle (repository / installation_repositories / push): forwarded
// raw and signed, because colby-maestro verifies the signature itself. The
// helper ignores every other event.
c.executionCtx.waitUntil(
forwardRawEventToMaestro(c.env as never, { event: eventName, deliveryId, signature }, rawBody)
);

// PR Reviewer (Sandbox + AI Gateway)
if (eventName === 'pull_request' && (action === 'opened' || action === 'reopened')) {
Expand Down
45 changes: 45 additions & 0 deletions src/backend/src/utils/maestro-fanout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,48 @@ export async function forwardPullRequestToMaestro(
console.error("[maestro-fanout] forward failed:", error);
}
}

/**
* Repository lifecycle events colby-maestro acts on itself: a new repo, a repo
* added to the App installation, a push (Maestro filters to the default branch).
*
* Unlike the pull-request forward, these go through UNCHANGED - raw body plus
* GitHub's signature headers - because colby-maestro's `/api/github/webhook`
* verifies `x-hub-signature-256` itself. Re-serialising the body would break
* that signature.
*/
export const MAESTRO_RAW_EVENTS = new Set(["repository", "installation_repositories", "push"]);

export async function forwardRawEventToMaestro(
env: MaestroEnv,
headers: { event: string; deliveryId: string; signature: string },
rawBody: string,
): Promise<void> {
if (!MAESTRO_RAW_EVENTS.has(headers.event)) return;
if (!env.MAESTRO) {
console.log(`[maestro-fanout] no MAESTRO binding; ${headers.event} not forwarded`);
return;
}
try {
const response = await env.MAESTRO.fetch(
new Request("https://colby-maestro/api/github/webhook", {
method: "POST",
headers: {
"content-type": "application/json",
"x-github-event": headers.event,
"x-github-delivery": headers.deliveryId,
"x-hub-signature-256": headers.signature,
},
body: rawBody,
}),
);
if (!response.ok) {
console.error(
`[maestro-fanout] ${headers.event} ${headers.deliveryId} -> ${response.status}: ${(await response.text()).slice(0, 200)}`,
);
}
} catch (error) {
// Same rule as above: logged, never thrown.
console.error(`[maestro-fanout] ${headers.event} forward failed:`, error);
}
}
50 changes: 50 additions & 0 deletions tests/unit/maestro-raw-forward.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { forwardRawEventToMaestro } from '../../src/backend/src/utils/maestro-fanout';

// A body whose exact bytes matter: whitespace and key order that JSON.parse +
// JSON.stringify would normalise away, breaking the HMAC Maestro re-checks.
const RAW = '{ "action":"created",\n "repository": {"full_name":"jmbish04/x"} }';

function fakeMaestro(status = 200) {
const calls: Request[] = [];
return {
calls,
env: { MAESTRO: { fetch: async (r: Request) => (calls.push(r), new Response('{}', { status })) } },
};
}

describe('forwardRawEventToMaestro', () => {
for (const event of ['repository', 'installation_repositories', 'push']) {
it(`forwards ${event} byte-for-byte with GitHub's signature headers`, async () => {
const m = fakeMaestro();
await forwardRawEventToMaestro(m.env, { event, deliveryId: 'd-1', signature: 'sha256=abc' }, RAW);
expect(m.calls).toHaveLength(1);
const req = m.calls[0];
expect(req.method).toBe('POST');
expect(new URL(req.url).pathname).toBe('/api/github/webhook');
expect(req.headers.get('x-github-event')).toBe(event);
expect(req.headers.get('x-github-delivery')).toBe('d-1');
expect(req.headers.get('x-hub-signature-256')).toBe('sha256=abc');
expect(req.headers.get('content-type')).toBe('application/json');
expect(await req.text()).toBe(RAW);
});
}

for (const event of ['issues', 'pull_request', 'check_run']) {
it(`does not forward ${event}`, async () => {
const m = fakeMaestro();
await forwardRawEventToMaestro(m.env, { event, deliveryId: 'd-2', signature: 'sha256=abc' }, RAW);
expect(m.calls).toHaveLength(0);
});
}

it('never throws when Maestro errors or is unreachable', async () => {
await expect(
forwardRawEventToMaestro(fakeMaestro(401).env, { event: 'push', deliveryId: 'd', signature: 's' }, RAW),
).resolves.toBeUndefined();
const boom = { MAESTRO: { fetch: async () => { throw new Error('down'); } } };
await expect(
forwardRawEventToMaestro(boom, { event: 'push', deliveryId: 'd', signature: 's' }, RAW),
).resolves.toBeUndefined();
});
});
Loading