branch:
server.ts
11641 bytesRaw
import { Agent, callable, getAgentByName, routeAgentRequest } from "agents";
import type {
  GitHubEventType,
  GitHubForkPayload,
  GitHubIssueCommentPayload,
  GitHubIssuesPayload,
  GitHubPingPayload,
  GitHubPullRequestPayload,
  GitHubPushPayload,
  GitHubReleasePayload,
  GitHubRepository,
  GitHubStarPayload,
  GitHubWebhookPayload,
  StoredEvent
} from "./github-types";

// State stored in memory (updated on each webhook)
export type RepoState = {
  repoFullName: string;
  stats: {
    stars: number;
    forks: number;
    openIssues: number;
  };
  lastUpdated: string | null;
  webhookConfigured: boolean;
};

export class RepoAgent extends Agent<Env, RepoState> {
  initialState: RepoState = {
    repoFullName: "",
    stats: {
      stars: 0,
      forks: 0,
      openIssues: 0
    },
    lastUpdated: null,
    webhookConfigured: false
  };

  async onStart(): Promise<void> {
    // Initialize the events table if it doesn't exist
    this.sql`
      CREATE TABLE IF NOT EXISTS events (
        id TEXT PRIMARY KEY,
        type TEXT NOT NULL,
        action TEXT,
        title TEXT NOT NULL,
        description TEXT,
        url TEXT,
        actor_login TEXT,
        actor_avatar TEXT,
        timestamp TEXT NOT NULL
      )
    `;

    // Create index for faster queries
    this.sql`
      CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp DESC)
    `;
  }

  // Handle incoming webhook requests
  async onRequest(request: Request): Promise<Response> {
    if (request.method !== "POST") {
      return new Response("Method not allowed", { status: 405 });
    }

    // Get the event type from headers
    const eventType = request.headers.get("X-GitHub-Event") as GitHubEventType;
    if (!eventType) {
      return new Response("Missing X-GitHub-Event header", { status: 400 });
    }

    // Verify the signature
    const signature = request.headers.get("X-Hub-Signature-256");
    const body = await request.text();

    if (this.env.GITHUB_WEBHOOK_SECRET) {
      const isValid = await this.verifySignature(
        body,
        signature,
        this.env.GITHUB_WEBHOOK_SECRET
      );
      if (!isValid) {
        return new Response("Invalid signature", { status: 401 });
      }
    }

    // Parse and process the payload
    const payload = JSON.parse(body) as GitHubWebhookPayload;
    await this.processWebhook(eventType, payload);

    return new Response("OK", { status: 200 });
  }

  private async verifySignature(
    payload: string,
    signature: string | null,
    secret: string
  ): Promise<boolean> {
    if (!signature) return false;

    const encoder = new TextEncoder();
    const key = await crypto.subtle.importKey(
      "raw",
      encoder.encode(secret),
      { name: "HMAC", hash: "SHA-256" },
      false,
      ["sign"]
    );

    const signatureBytes = await crypto.subtle.sign(
      "HMAC",
      key,
      encoder.encode(payload)
    );

    const expectedSignature = `sha256=${Array.from(
      new Uint8Array(signatureBytes)
    )
      .map((b) => b.toString(16).padStart(2, "0"))
      .join("")}`;

    return signature === expectedSignature;
  }

  private async processWebhook(
    eventType: GitHubEventType,
    payload: GitHubWebhookPayload
  ): Promise<void> {
    // Extract repository info
    const repo = this.getRepository(payload);
    if (!repo) return;

    // Update stats from repository data
    this.setState({
      ...this.state,
      repoFullName: repo.full_name,
      stats: {
        stars: repo.stargazers_count,
        forks: repo.forks_count,
        openIssues: repo.open_issues_count
      },
      lastUpdated: new Date().toISOString(),
      webhookConfigured: true
    });

    // Create and store the event
    const event = this.createEvent(eventType, payload);
    if (event) {
      this.sql`
        INSERT OR REPLACE INTO events (id, type, action, title, description, url, actor_login, actor_avatar, timestamp)
        VALUES (${event.id}, ${event.type}, ${event.action || null}, ${event.title}, ${event.description}, ${event.url}, ${event.actor.login}, ${event.actor.avatar_url}, ${event.timestamp})
      `;

      // Cleanup old events (keep last 100)
      this.sql`
        DELETE FROM events WHERE id NOT IN (
          SELECT id FROM events ORDER BY timestamp DESC LIMIT 100
        )
      `;
    }
  }

  private getRepository(
    payload: GitHubWebhookPayload
  ): GitHubRepository | null {
    if ("repository" in payload && payload.repository) {
      return payload.repository;
    }
    return null;
  }

  private createEvent(
    eventType: GitHubEventType,
    payload: GitHubWebhookPayload
  ): StoredEvent | null {
    const id = crypto.randomUUID();
    const timestamp = new Date().toISOString();

    switch (eventType) {
      case "ping": {
        const p = payload as GitHubPingPayload;
        return {
          id,
          type: "ping",
          title: "Webhook configured",
          description: p.zen,
          url: p.repository?.html_url || "",
          actor: {
            login: p.sender?.login || "github",
            avatar_url: p.sender?.avatar_url || ""
          },
          timestamp
        };
      }

      case "push": {
        const p = payload as GitHubPushPayload;
        const branch = p.ref.replace("refs/heads/", "");
        const commitCount = p.commits?.length || 0;
        return {
          id,
          type: "push",
          title: `Pushed ${commitCount} commit${commitCount !== 1 ? "s" : ""} to ${branch}`,
          description:
            p.commits?.[0]?.message?.split("\n")[0] || "No commit message",
          url: p.commits?.[0]?.url || p.repository.html_url,
          actor: {
            login: p.sender.login,
            avatar_url: p.sender.avatar_url
          },
          timestamp
        };
      }

      case "pull_request": {
        const p = payload as GitHubPullRequestPayload;
        return {
          id,
          type: "pull_request",
          action: p.action,
          title: `PR #${p.number}: ${p.pull_request.title}`,
          description: `${p.action} by ${p.sender.login}`,
          url: p.pull_request.html_url,
          actor: {
            login: p.sender.login,
            avatar_url: p.sender.avatar_url
          },
          timestamp
        };
      }

      case "issues": {
        const p = payload as GitHubIssuesPayload;
        return {
          id,
          type: "issues",
          action: p.action,
          title: `Issue #${p.issue.number}: ${p.issue.title}`,
          description: `${p.action} by ${p.sender.login}`,
          url: p.issue.html_url,
          actor: {
            login: p.sender.login,
            avatar_url: p.sender.avatar_url
          },
          timestamp
        };
      }

      case "issue_comment": {
        const p = payload as GitHubIssueCommentPayload;
        return {
          id,
          type: "issue_comment",
          action: p.action,
          title: `Comment on #${p.issue.number}`,
          description:
            p.comment.body.slice(0, 100) +
            (p.comment.body.length > 100 ? "..." : ""),
          url: p.comment.html_url,
          actor: {
            login: p.sender.login,
            avatar_url: p.sender.avatar_url
          },
          timestamp
        };
      }

      case "star": {
        const p = payload as GitHubStarPayload;
        return {
          id,
          type: "star",
          action: p.action,
          title: p.action === "created" ? "Repository starred" : "Star removed",
          description: `by ${p.sender.login}`,
          url: p.repository.html_url,
          actor: {
            login: p.sender.login,
            avatar_url: p.sender.avatar_url
          },
          timestamp
        };
      }

      case "fork": {
        const p = payload as GitHubForkPayload;
        return {
          id,
          type: "fork",
          title: "Repository forked",
          description: `Forked to ${p.forkee.full_name}`,
          url: p.forkee.html_url,
          actor: {
            login: p.sender.login,
            avatar_url: p.sender.avatar_url
          },
          timestamp
        };
      }

      case "release": {
        const p = payload as GitHubReleasePayload;
        return {
          id,
          type: "release",
          action: p.action,
          title: `Release ${p.release.tag_name}`,
          description: p.release.name || `${p.action} by ${p.sender.login}`,
          url: p.release.html_url,
          actor: {
            login: p.sender.login,
            avatar_url: p.sender.avatar_url
          },
          timestamp
        };
      }

      default:
        return null;
    }
  }

  @callable()
  getEvents(limit = 20): StoredEvent[] {
    const rows = [
      ...this.sql<{
        id: string;
        type: string;
        action: string | null;
        title: string;
        description: string;
        url: string;
        actor_login: string;
        actor_avatar: string;
        timestamp: string;
      }>`SELECT * FROM events ORDER BY timestamp DESC LIMIT ${limit}`
    ];

    return rows.map((row) => ({
      id: row.id,
      type: row.type as GitHubEventType,
      action: row.action || undefined,
      title: row.title,
      description: row.description,
      url: row.url,
      actor: {
        login: row.actor_login,
        avatar_url: row.actor_avatar
      },
      timestamp: row.timestamp
    }));
  }

  @callable()
  getStats(): RepoState["stats"] {
    return this.state.stats;
  }

  @callable()
  clearEvents(): void {
    this.sql`DELETE FROM events`;
    this.setState({
      ...this.state,
      lastUpdated: new Date().toISOString()
    });
  }
}

// Helper to sanitize repo name for use as agent name
function sanitizeRepoName(fullName: string): string {
  // Replace "/" with "-" and remove any other problematic characters
  return fullName
    .toLowerCase()
    .replace(/\//g, "-")
    .replace(/[^a-z0-9-]/g, "");
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    // Webhook endpoint: POST /webhooks/github/:owner/:repo
    if (
      url.pathname.startsWith("/webhooks/github/") &&
      request.method === "POST"
    ) {
      // Clone the request so we can read the body twice
      const clonedRequest = request.clone();
      const payload = (await clonedRequest.json()) as {
        repository?: { full_name?: string };
      };

      // Get repo name from payload
      const repoFullName = payload.repository?.full_name;
      if (!repoFullName) {
        return new Response("Missing repository in payload", { status: 400 });
      }

      // Get the agent for this specific repository
      const agentName = sanitizeRepoName(repoFullName);
      const agent = await getAgentByName(env.RepoAgent, agentName);

      // Forward the original request to the agent
      return agent.fetch(request);
    }

    // API endpoint to get agent name from repo name
    if (url.pathname === "/api/agent-name" && request.method === "GET") {
      const repo = url.searchParams.get("repo");
      if (!repo) {
        return new Response("Missing repo parameter", { status: 400 });
      }
      return new Response(
        JSON.stringify({ agentName: sanitizeRepoName(repo) }),
        {
          headers: { "Content-Type": "application/json" }
        }
      );
    }

    // Default agent routing for WebSocket connections
    return (
      (await routeAgentRequest(request, env)) ||
      new Response("Not found", { status: 404 })
    );
  }
} satisfies ExportedHandler<Env>;