Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
12 changes: 12 additions & 0 deletions infra/flue-review/.dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Local dev secrets for `flue dev --target cloudflare`. Copy to `.dev.vars`.
# With GITHUB_APP_PRIVATE_KEY left empty, the workflow skips posting and just
# returns the review result (safe for local testing without the App key).
# GITHUB_APP_ID and GITHUB_APP_INSTALLATION_ID are non-secret vars in
# wrangler.jsonc, not here.

# HMAC secret shared with the GitHub webhook config.
GITHUB_WEBHOOK_SECRET=

# emdashbot App private key, PKCS#8 PEM (leave empty in dev to skip posting).
# Convert GitHub's PKCS#1 download: openssl pkcs8 -topk8 -nocrypt -in key.pem
GITHUB_APP_PRIVATE_KEY=
61 changes: 61 additions & 0 deletions infra/flue-review/.flue/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Custom Flue application: the GitHub App webhook orchestrator.
//
// This is the ONLY public surface. We deliberately do NOT mount flue() at a
// public path, so the workflow/agent HTTP endpoints are not externally
// reachable; the workflow is admitted only via an internal request from this
// handler. The handler does no long-running work itself (a webhook must ack
// within seconds, and waitUntil caps at 30s): it verifies, gates, admits the
// durable workflow run, and returns. The review and the GitHub post happen
// inside the workflow's Durable Object, which is not bound by that budget.

import { flue } from "@flue/runtime/app";
import { Hono } from "hono";

import { verifyWebhookSignature, gatePullRequestEvent } from "./lib/webhook.js";

const flueApp = flue();

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

app.post("/webhook/github", async (c) => {
const raw = await c.req.text();
const secret = c.env.GITHUB_WEBHOOK_SECRET;
if (!secret) return c.text("webhook secret not configured", 500);
const valid = await verifyWebhookSignature(secret, raw, c.req.header("x-hub-signature-256"));
if (!valid) return c.text("invalid signature", 401);

const eventType = c.req.header("x-github-event");
if (eventType === "ping") return c.text("pong", 200);
if (eventType !== "pull_request") return c.text(`ignored event: ${eventType}`, 202);

let event: unknown;
try {
event = JSON.parse(raw);
} catch {
return c.text("invalid JSON", 400);
}

const decision = gatePullRequestEvent(event as Parameters<typeof gatePullRequestEvent>[0]);

Check warning on line 38 in infra/flue-review/.flue/app.ts

View workflow job for this annotation

GitHub Actions / Lint

typescript(no-unsafe-type-assertion)

Unsafe type assertion: type 'PullRequestEvent' is more narrow than the original type.
if (!decision.review) return c.text(`skipped: ${decision.reason}`, 202);

// Admit the durable workflow run (fast). The review + post run in the
// workflow DO independently of this request. No ?wait=result: we don't
// block the webhook on the (minutes-long) review.
const admit = await flueApp.fetch(
new Request("https://flue.internal/workflows/review", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(decision.pr),
}),
c.env,
c.executionCtx,
);
if (!admit.ok) {
console.error("[webhook] workflow admission failed:", admit.status, await admit.text());
return c.text("failed to admit review", 502);
}

return c.text(`review queued for PR #${decision.pr.prNumber}`, 202);
});

export default app;
257 changes: 257 additions & 0 deletions infra/flue-review/.flue/lib/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
// GitHub App helpers, used only by trusted Worker/Durable-Object code (the
// review workflow's run()). None of this runs in the agent's container, so the
// installation token is never reachable by the model-directed shell.
//
// Auth model: a GitHub App authenticates as an installation. We mint a short
// JWT signed with the app's private key (RS256), exchange it for an
// installation access token (valid ~1h, scoped to the installation's repos and
// the app's permissions), and use that token for reads and for posting the
// review. The app needs `pull_requests: write` and `contents: read`.

import type { ReviewResult } from "./review-schema.js";

const GITHUB_API = "https://api.github.com";
const USER_AGENT = "emdash-flue-review";

export interface GitHubAppCreds {
appId: string;
/** PKCS#8 PEM ("BEGIN PRIVATE KEY"). Convert a GitHub PKCS#1 key with `openssl pkcs8`. */
privateKeyPem: string;
installationId: string;
}

/** Returns creds if all three are present, else null (dev mode: skip posting). */
export function readAppCreds(env: Env): GitHubAppCreds | null {
const appId = env.GITHUB_APP_ID;
const privateKeyPem = env.GITHUB_APP_PRIVATE_KEY;
const installationId = env.GITHUB_APP_INSTALLATION_ID;
if (!appId || !privateKeyPem || !installationId) return null;
return { appId, privateKeyPem, installationId };
}

function base64UrlFromBytes(bytes: Uint8Array): string {
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");

Check failure on line 35 in infra/flue-review/.flue/lib/github.ts

View workflow job for this annotation

GitHub Actions / Lint

e18e(prefer-static-regex)

Move this regular expression to module scope to avoid re-compilation on every call.

Check failure on line 35 in infra/flue-review/.flue/lib/github.ts

View workflow job for this annotation

GitHub Actions / Lint

e18e(prefer-static-regex)

Move this regular expression to module scope to avoid re-compilation on every call.

Check failure on line 35 in infra/flue-review/.flue/lib/github.ts

View workflow job for this annotation

GitHub Actions / Lint

e18e(prefer-static-regex)

Move this regular expression to module scope to avoid re-compilation on every call.
}

function base64UrlFromString(input: string): string {
return base64UrlFromBytes(new TextEncoder().encode(input));
}

function pemToPkcs8(pem: string): ArrayBuffer {
const body = pem
.replace(/-----BEGIN [^-]+-----/g, "")

Check failure on line 44 in infra/flue-review/.flue/lib/github.ts

View workflow job for this annotation

GitHub Actions / Lint

e18e(prefer-static-regex)

Move this regular expression to module scope to avoid re-compilation on every call.
.replace(/-----END [^-]+-----/g, "")

Check failure on line 45 in infra/flue-review/.flue/lib/github.ts

View workflow job for this annotation

GitHub Actions / Lint

e18e(prefer-static-regex)

Move this regular expression to module scope to avoid re-compilation on every call.
.replace(/\s+/g, "");

Check failure on line 46 in infra/flue-review/.flue/lib/github.ts

View workflow job for this annotation

GitHub Actions / Lint

e18e(prefer-static-regex)

Move this regular expression to module scope to avoid re-compilation on every call.
const binary = atob(body);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}

async function signAppJwt(creds: GitHubAppCreds): Promise<string> {
const key = await crypto.subtle.importKey(
"pkcs8",
pemToPkcs8(creds.privateKeyPem),
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["sign"],
);
const now = Math.floor(Date.now() / 1000);
// iat backdated 60s for clock skew; GitHub caps exp at 10 minutes.
const header = { alg: "RS256", typ: "JWT" };
const payload = { iat: now - 60, exp: now + 540, iss: creds.appId };
const signingInput = `${base64UrlFromString(JSON.stringify(header))}.${base64UrlFromString(JSON.stringify(payload))}`;
const signature = await crypto.subtle.sign(
"RSASSA-PKCS1-v1_5",
key,
new TextEncoder().encode(signingInput),
);
return `${signingInput}.${base64UrlFromBytes(new Uint8Array(signature))}`;
}

/** Mint a short-lived installation access token. */
export async function mintInstallationToken(creds: GitHubAppCreds): Promise<string> {
const jwt = await signAppJwt(creds);
const res = await fetch(`${GITHUB_API}/app/installations/${creds.installationId}/access_tokens`, {
method: "POST",
headers: {
authorization: `Bearer ${jwt}`,
accept: "application/vnd.github+json",
"user-agent": USER_AGENT,
"x-github-api-version": "2022-11-28",
},
});
if (!res.ok) {
throw new Error(`installation token mint failed: ${res.status} ${await res.text()}`);
}
const json = (await res.json()) as { token?: string };

Check warning on line 89 in infra/flue-review/.flue/lib/github.ts

View workflow job for this annotation

GitHub Actions / Lint

typescript(no-unsafe-type-assertion)

Unsafe type assertion: type '{ token?: string | undefined; }' is more narrow than the original type.
if (!json.token) throw new Error("installation token response had no token");
return json.token;
}

/**
* Fetch the most recent emdashbot[bot] review body for a re-review, so the
* agent can avoid re-flagging already-addressed findings. Returns undefined on
* a first review or any failure (non-fatal: we just review fresh).
*/
export async function fetchPriorReview(
token: string,
owner: string,
repo: string,
prNumber: number,
): Promise<string | undefined> {
try {
const res = await fetch(
`${GITHUB_API}/repos/${owner}/${repo}/pulls/${prNumber}/reviews?per_page=100`,
{
headers: {
authorization: `Bearer ${token}`,
accept: "application/vnd.github+json",
"user-agent": USER_AGENT,
"x-github-api-version": "2022-11-28",
},
},
);
if (!res.ok) return undefined;
const reviews = (await res.json()) as Array<{
user?: { login?: string };
body?: string;
state?: string;
submitted_at?: string;
}>;

Check warning on line 123 in infra/flue-review/.flue/lib/github.ts

View workflow job for this annotation

GitHub Actions / Lint

typescript(no-unsafe-type-assertion)

Unsafe type assertion: type '{ user?: { login?: string | undefined; } | undefined; body?: string | undefined; state?: string | undefined; submitted_at?: string | undefined; }[]' is more narrow than the original type.
const ours = reviews
.filter((r) => r.user?.login === "emdashbot[bot]" && r.body)
.sort((a, b) => (a.submitted_at ?? "").localeCompare(b.submitted_at ?? ""));

Check warning on line 126 in infra/flue-review/.flue/lib/github.ts

View workflow job for this annotation

GitHub Actions / Lint

unicorn(no-array-sort)

Use `Array#toSorted()` instead of `Array#sort()`.
const latest = ours.at(-1);
if (!latest) return undefined;
return `Your previous review (state: ${latest.state ?? "unknown"}):\n\n${latest.body}`;
} catch {
return undefined;
}
}

/**
* Add an 👀 reaction to the PR to signal "review in progress". Returns the
* reaction id (to remove later) or undefined on failure. Non-fatal: a missing
* progress marker should never block a review.
*/
export async function addEyesReaction(
token: string,
owner: string,
repo: string,
prNumber: number,
): Promise<number | undefined> {
try {
const res = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/issues/${prNumber}/reactions`, {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
accept: "application/vnd.github+json",
"content-type": "application/json",
"user-agent": USER_AGENT,
"x-github-api-version": "2022-11-28",
},
body: JSON.stringify({ content: "eyes" }),
});
if (!res.ok) return undefined;
const json = (await res.json()) as { id?: number };

Check warning on line 159 in infra/flue-review/.flue/lib/github.ts

View workflow job for this annotation

GitHub Actions / Lint

typescript(no-unsafe-type-assertion)

Unsafe type assertion: type '{ id?: number | undefined; }' is more narrow than the original type.
return json.id;
} catch {
return undefined;
}
}

/** Remove a previously-added reaction (the in-progress marker). Non-fatal. */
export async function removeReaction(
token: string,
owner: string,
repo: string,
prNumber: number,
reactionId: number,
): Promise<void> {
try {
await fetch(`${GITHUB_API}/repos/${owner}/${repo}/issues/${prNumber}/reactions/${reactionId}`, {
method: "DELETE",
headers: {
authorization: `Bearer ${token}`,
accept: "application/vnd.github+json",
"user-agent": USER_AGENT,
"x-github-api-version": "2022-11-28",
},
});
} catch {
// Best-effort cleanup; leaving a stray reaction is harmless.
}
}

function verdictToEvent(
verdict: ReviewResult["verdict"],
): "APPROVE" | "REQUEST_CHANGES" | "COMMENT" {
switch (verdict) {
case "approve":
return "APPROVE";
case "request_changes":
return "REQUEST_CHANGES";
default:
return "COMMENT";
}
}

function findingToComment(finding: ReviewResult["findings"][number]) {
const label = finding.severity === "needs_fixing" ? "**[needs fixing]** " : "**[suggestion]** ";
const base: Record<string, unknown> = {
path: finding.path,
line: finding.line,
side: finding.side,
body: label + finding.body,
};
if (finding.startLine && finding.startLine < finding.line) {
base.start_line = finding.startLine;
base.start_side = finding.side;
}
return base;
}

/**
* Post the review. Maps verdict -> review event and findings -> line comments.
* If GitHub rejects the request because a comment anchors to a line outside the
* diff, retry body-only so the summary still lands.
*/
export async function postReview(
token: string,
owner: string,
repo: string,
prNumber: number,
result: ReviewResult,
): Promise<void> {
const url = `${GITHUB_API}/repos/${owner}/${repo}/pulls/${prNumber}/reviews`;
const event = verdictToEvent(result.verdict);
const headers = {
authorization: `Bearer ${token}`,
accept: "application/vnd.github+json",
"content-type": "application/json",
"user-agent": USER_AGENT,
"x-github-api-version": "2022-11-28",
};

const withComments = {
body: result.summary,
event,
comments: result.findings.map(findingToComment),
};
let res = await fetch(url, { method: "POST", headers, body: JSON.stringify(withComments) });
if (res.ok) return;

// Most likely cause: a comment line isn't part of the diff. Fall back to a
// body-only review so the summary is never lost.
const firstError = await res.text();
const bodyOnly = { body: `${result.summary}`, event };

Check warning on line 250 in infra/flue-review/.flue/lib/github.ts

View workflow job for this annotation

GitHub Actions / Lint

typescript(no-unnecessary-template-expression)

Template literal expression is unnecessary and can be simplified.
res = await fetch(url, { method: "POST", headers, body: JSON.stringify(bodyOnly) });
if (!res.ok) {
throw new Error(
`postReview failed (with comments: ${firstError}); body-only retry: ${res.status} ${await res.text()}`,
);
}
}
Loading
Loading