Skip to content
Open
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
18 changes: 18 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,24 @@ ANTHROPIC_API_KEY=your_anthropic_api_key
# INNGEST_EVENT_KEY=your_inngest_event_key
# INNGEST_SIGNING_KEY=your_inngest_signing_key

# ──────────────────────────────────────────────
# GitHub Webhooks (optional)
# ──────────────────────────────────────────────
# Secret for verifying GitHub webhook signatures
# Configure in your GitHub repo/org webhook settings
# Generate with: openssl rand -hex 20
# GITHUB_WEBHOOK_SECRET=your_webhook_secret
Comment on lines +67 to +70
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yrs


# ──────────────────────────────────────────────
# GitHub App (optional — enables webhook-driven PR conflict detection)
# ──────────────────────────────────────────────
# Create a GitHub App: https://docs.github.com/en/apps/creating-github-apps
# Required permissions: Pull Requests (read)
# Subscribe to events: pull_request, installation, installation_repositories
# GITHUB_APP_ID=your_github_app_id
# GITHUB_APP_PRIVATE_KEY=base64_encoded_pem_private_key
# GITHUB_APP_WEBHOOK_SECRET=your_github_app_webhook_secret

# ──────────────────────────────────────────────
# Search & Memory (optional)
# ──────────────────────────────────────────────
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@better-auth/infra": "^0.1.7",
"@better-auth/utils": "^0.3.1",
"@mixedbread-ai/sdk": "^2.2.11",
"@octokit/auth-app": "^8.2.0",
"@octokit/rest": "^22.0.1",
"@openrouter/ai-sdk-provider": "^2.2.3",
"@prisma/adapter-pg": "^7.4.1",
Expand Down
56 changes: 56 additions & 0 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -234,3 +234,59 @@ model PinnedItem {
@@index([userId, owner, repo])
@@map("pinned_items")
}

model RepoSystemPin {
id String @id @default(cuid())
owner String
repo String
kind String // e.g. "pr_conflict"
resourceKey String // e.g. "pr:123"
url String
title String
status String @default("active") // "active" | "cleared"
payloadJson String?
createdAt String
updatedAt String
clearedAt String?

@@unique([owner, repo, kind, resourceKey])
@@index([owner, repo, status])
@@map("repo_system_pins")
}

model GitHubAppInstallation {
id String @id @default(cuid())
installationId Int @unique // GitHub's installation ID
accountLogin String // GitHub org or user login
accountType String // "Organization" | "User"
appSlug String
status String @default("active") // "active" | "suspended" | "removed"
permissions String? // JSON of granted permissions
events String? // JSON of subscribed events
createdAt String
updatedAt String

repos GitHubAppInstallationRepo[]

@@index([accountLogin, status])
@@map("github_app_installations")
}

model GitHubAppInstallationRepo {
id String @id @default(cuid())
installationId Int // FK to GitHubAppInstallation.installationId
owner String
repo String
status String @default("active") // "active" | "removed"
lastWebhookAt String?
lastPolledAt String?
createdAt String
updatedAt String

installation GitHubAppInstallation @relation(fields: [installationId], references: [installationId], onDelete: Cascade)

@@unique([installationId, owner, repo])
@@index([owner, repo])
@@index([status])
@@map("github_app_installation_repos")
}
18 changes: 18 additions & 0 deletions apps/web/src/app/(app)/repos/[owner]/[repo]/pin-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
getPinnedItems,
type PinnedItem,
} from "@/lib/pinned-items-store";
import {
getActiveSystemPins,
type SystemPin,
} from "@/lib/system-pins-store";
import { revalidatePath } from "next/cache";
import { invalidateRepoCache } from "@/lib/repo-data-cache-vc";

Expand Down Expand Up @@ -49,3 +53,17 @@ export async function getPinnedUrlsForRepo(owner: string, repo: string): Promise
if (!session?.user?.id) return [];
return getPinnedItemUrls(session.user.id, owner, repo);
}

// ── System Pins (repo-global, server-managed) ────────────────

export async function fetchSystemPinsForRepo(
owner: string,
repo: string,
kind?: "pr_conflict",
): Promise<SystemPin[]> {
// System pins are repo-global, no user scoping needed
// But still require auth to prevent unauthenticated reads
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) return [];
return getActiveSystemPins(owner, repo, kind);
}
243 changes: 243 additions & 0 deletions apps/web/src/app/api/github/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { redis } from "@/lib/redis";
import { inngest } from "@/lib/inngest";
import { getWebhookSecret } from "@/lib/github-app";
import {
upsertInstallation,
removeInstallation,
suspendInstallation,
unsuspendInstallation,
syncInstallationRepos,
addInstallationRepos,
removeInstallationRepos,
touchRepoWebhook,
} from "@/lib/github-app-store";

// ── Signature Verification ───────────────────────────────────

function verifySignature(payload: string, signature: string | null, secret: string): boolean {
if (!signature) return false;
const expected = `sha256=${crypto.createHmac("sha256", secret).update(payload).digest("hex")}`;
try {
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
} catch {
return false;
}
}

// ── Delivery Deduplication ───────────────────────────────────

const DEDUPE_TTL_SECONDS = 60 * 60; // 1 hour

async function isDuplicateDelivery(deliveryId: string): Promise<boolean> {
const result = await redis.set(`webhook:delivery:${deliveryId}`, "1", {
nx: true,
ex: DEDUPE_TTL_SECONDS,
});
return result === null;
}

// ── PR Actions We Care About ─────────────────────────────────

const PR_ACTIONS_EVALUATE = new Set([
"opened",
"reopened",
"synchronize",
"edited",
"ready_for_review",
]);
const PR_ACTIONS_CLOSE = new Set(["closed"]);

// ── Webhook Payload Types ────────────────────────────────────

interface WebhookRepo {
id: number;
name: string;
full_name: string;
}

// ── Route Handler ────────────────────────────────────────────

export async function POST(request: NextRequest) {
const secret = getWebhookSecret();
if (!secret) {
console.error("[webhook] GitHub App webhook secret not configured");
return NextResponse.json({ error: "Webhook secret not configured" }, { status: 500 });
}

// Read raw body for signature verification
const rawBody = await request.text();
const signature = request.headers.get("x-hub-signature-256");

if (!verifySignature(rawBody, signature, secret)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}

// Deduplicate
const deliveryId = request.headers.get("x-github-delivery");
if (deliveryId && (await isDuplicateDelivery(deliveryId))) {
return NextResponse.json({ status: "duplicate", deliveryId });
}

const eventType = request.headers.get("x-github-event");

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let payload: any;
try {
payload = JSON.parse(rawBody);
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}

// ── Installation lifecycle events ────────────────────────
if (eventType === "installation") {
return handleInstallationEvent(payload);
}

if (eventType === "installation_repositories") {
return handleInstallationRepositoriesEvent(payload);
}

// ── Pull request events ──────────────────────────────────
if (eventType === "pull_request") {
return handlePullRequestEvent(payload);
}

return NextResponse.json({ status: "ignored", event: eventType });
}

// ── Installation Event Handler ───────────────────────────────

// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function handleInstallationEvent(payload: any) {
const { action, installation, repositories } = payload;
const installationId: number = installation.id;
const accountLogin: string = installation.account.login;
const accountType: string = installation.account.type;
const appSlug: string = installation.app_slug;

if (action === "created") {
await upsertInstallation({
installationId,
accountLogin,
accountType,
appSlug,
permissions: installation.permissions,
events: installation.events,
});

// Sync initial repos
if (repositories && Array.isArray(repositories)) {
const repos = (repositories as WebhookRepo[]).map((r) => {
const [owner, repo] = r.full_name.split("/");
return { owner, repo };
});
await syncInstallationRepos(installationId, repos);
}

console.log(`[webhook] Installation created: ${installationId} for ${accountLogin}`);
return NextResponse.json({ status: "processed", action: "installation.created" });
}

if (action === "deleted") {
await removeInstallation(installationId);
console.log(`[webhook] Installation removed: ${installationId} for ${accountLogin}`);
return NextResponse.json({ status: "processed", action: "installation.deleted" });
}

if (action === "suspend") {
await suspendInstallation(installationId);
console.log(`[webhook] Installation suspended: ${installationId}`);
return NextResponse.json({ status: "processed", action: "installation.suspend" });
}

if (action === "unsuspend") {
await unsuspendInstallation(installationId);
console.log(`[webhook] Installation unsuspended: ${installationId}`);
return NextResponse.json({ status: "processed", action: "installation.unsuspend" });
}

return NextResponse.json({ status: "ignored", action: `installation.${action}` });
}

// ── Installation Repositories Event Handler ──────────────────

// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function handleInstallationRepositoriesEvent(payload: any) {
const { action, installation, repositories_added, repositories_removed } = payload;
const installationId: number = installation.id;

if (action === "added" && repositories_added) {
const repos = (repositories_added as WebhookRepo[]).map((r) => {
const [owner, repo] = r.full_name.split("/");
return { owner, repo };
});
await addInstallationRepos(installationId, repos);
console.log(`[webhook] Repos added to installation ${installationId}: ${repos.map((r) => `${r.owner}/${r.repo}`).join(", ")}`);
return NextResponse.json({ status: "processed", action: "repos.added", count: repos.length });
}

if (action === "removed" && repositories_removed) {
const repos = (repositories_removed as WebhookRepo[]).map((r) => {
const [owner, repo] = r.full_name.split("/");
return { owner, repo };
});
await removeInstallationRepos(installationId, repos);
console.log(`[webhook] Repos removed from installation ${installationId}: ${repos.map((r) => `${r.owner}/${r.repo}`).join(", ")}`);
return NextResponse.json({ status: "processed", action: "repos.removed", count: repos.length });
}

return NextResponse.json({ status: "ignored", action: `repos.${action}` });
}

// ── Pull Request Event Handler ───────────────────────────────

// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function handlePullRequestEvent(payload: any) {
const { action, number: pullNumber, pull_request: pr, repository: repo, installation } = payload;
const owner: string = repo.owner.login;
const repoName: string = repo.name;
const installationId: number | undefined = installation?.id;

// Track webhook activity
if (installationId) {
touchRepoWebhook(installationId, owner, repoName).catch((err) => {
console.error("[webhook] Failed to touch repo webhook timestamp:", err);
});
}

if (PR_ACTIONS_CLOSE.has(action)) {
await inngest.send({
name: "app/pr.conflict.clear",
data: {
owner,
repo: repoName,
pullNumber,
reason: pr.merged ? "merged" : "closed",
},
});
return NextResponse.json({ status: "processed", action: "clear", pullNumber });
}

if (PR_ACTIONS_EVALUATE.has(action)) {
await inngest.send({
name: "app/pr.conflict.evaluate",
data: {
owner,
repo: repoName,
pullNumber,
installationId: installationId ?? null,
title: pr.title,
url: pr.html_url,
headRef: pr.head.ref,
baseRef: pr.base.ref,
webhookAction: action,
source: "github_app_webhook",
},
});
return NextResponse.json({ status: "processed", action: "evaluate", pullNumber });
}

return NextResponse.json({ status: "ignored", action });
}
4 changes: 2 additions & 2 deletions apps/web/src/app/api/inngest/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { serve } from "inngest/next";
import { inngest, embedContent } from "@/lib/inngest";
import { inngest, embedContent, evaluatePRConflict, clearPRConflict, pollConflicts } from "@/lib/inngest";

export const { GET, POST, PUT } = serve({
client: inngest,
functions: [embedContent],
functions: [embedContent, evaluatePRConflict, clearPRConflict, pollConflicts],
});
Loading