diff --git a/.env.example b/.env.example
index 1eb8f17..882dddc 100644
--- a/.env.example
+++ b/.env.example
@@ -2,11 +2,15 @@
DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres"
# GitHub App Integration
+# For local dev, you can often use a raw PEM string for the private key.
+# For server environments (Vercel, Docker), provide the key as a Base64-encoded string - see README for details.
+# The app will handle decoding automatically.
NEXT_PUBLIC_GITHUB_APP_ID="123456"
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nYOUR_PRIVATE_KEY_HERE\n-----END RSA PRIVATE KEY-----"
GITHUB_APP_WEBHOOK_SECRET="your-github-app-webhook-secret-here"
GITHUB_APP_CLIENT_ID="Iv1.your-client-id-here"
GITHUB_APP_CLIENT_SECRET="your-client-secret-here"
+GITHUB_APP_CALLBACK_URL="http://localhost:3000/api/auth/callback/github"
NEXT_PUBLIC_GITHUB_APP_NAME="jules-task-queue"
# Cron job verification token (for Firebase you also need to add this in ./functions/.env as well)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 47c267e..3dabfcf 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -12,13 +12,13 @@ There are many ways to contribute, from writing tutorials or blog posts, improvi
### Reporting Bugs
-- **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/iHildy/jules-task-queue/issues).
-- If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/iHildy/jules-task-queue/issues/new?assignees=&labels=bug&template=bug_report.md&title=). Be sure to include a **title and clear description**, as much relevant information as possible, and a **code sample** or an **executable test case** demonstrating the expected behavior that is not occurring.
+- **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/ihildy/jules-task-queue/issues).
+- If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/ihildy/jules-task-queue/issues/new?assignees=&labels=bug&template=bug_report.md&title=). Be sure to include a **title and clear description**, as much relevant information as possible, and a **code sample** or an **executable test case** demonstrating the expected behavior that is not occurring.
### Suggesting Enhancements
-- **Ensure the enhancement was not already suggested** by searching on GitHub under [Issues](https://github.com/iHildy/jules-task-queue/issues).
-- If you're unable to find an open issue, [open a new one](https://github.com/iHildy/jules-task-queue/issues/new?assignees=&labels=enhancement&template=feature_request.md&title=). Provide a clear description of the enhancement and its potential benefits.
+- **Ensure the enhancement was not already suggested** by searching on GitHub under [Issues](https://github.com/ihildy/jules-task-queue/issues).
+- If you're unable to find an open issue, [open a new one](https://github.com/ihildy/jules-task-queue/issues/new?assignees=&labels=enhancement&template=feature_request.md&title=). Provide a clear description of the enhancement and its potential benefits.
### Pull Requests
@@ -74,7 +74,7 @@ Ready to start coding? Here's how to get the project running locally.
You will need to set `DATABASE_URL` to point to your local Docker container:
`DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"`
- You will also need to [create a GitHub App](https://github.com/iHildy/jules-task-queue/blob/main/GITHUB_APP_SETUP.md) and fill in the corresponding `GITHUB_APP_*` variables.
+ You will also need to [create a GitHub App](https://github.com/ihildy/jules-task-queue/blob/main/GITHUB_APP_SETUP.md) and fill in the corresponding `GITHUB_APP_*` variables.
5. **Run Database Migrations:**
diff --git a/FIREBASE.md b/FIREBASE.md
index 511b56d..f2acf64 100644
--- a/FIREBASE.md
+++ b/FIREBASE.md
@@ -65,7 +65,7 @@ You can either fork the repository or clone it directly:
```bash
# Clone the repository
-git clone https://github.com/iHildy/jules-task-queue.git
+git clone https://github.com/ihildy/jules-task-queue.git
cd jules-task-queue
# Install dependencies
@@ -632,7 +632,7 @@ firebase functions:log --only retryTasks
- **Firebase Documentation**: [firebase.google.com/docs/app-hosting](https://firebase.google.com/docs/app-hosting)
- **Community Support**: [Firebase Discord](https://discord.gg/firebase)
-- **Project Issues**: [GitHub Issues](https://github.com/iHildy/jules-task-queue/issues)
+- **Project Issues**: [GitHub Issues](https://github.com/ihildy/jules-task-queue/issues)
## Migration from Other Platforms
diff --git a/GITHUB_APP_SETUP.md b/GITHUB_APP_SETUP.md
index 212bc90..1424d5e 100644
--- a/GITHUB_APP_SETUP.md
+++ b/GITHUB_APP_SETUP.md
@@ -45,7 +45,7 @@ Automated task queue management for Jules interactions. Monitors issue labels an
**Homepage URL:**
```
-https://github.com/iHildy/jules-task-queue
+https://github.com/ihildy/jules-task-queue
```
**User authorization callback URL:**
diff --git a/README.md b/README.md
index 8170c11..1f2e040 100644
--- a/README.md
+++ b/README.md
@@ -6,14 +6,14 @@
Visit Site
·
- Report Bug
+ Report Bug
·
- Request Feature
+ Request Feature
-
-
-
+
+
+
@@ -120,7 +120,7 @@ graph TD
## 🤝 Contributing
-Contributions, issues, and feature requests are welcome! Feel free to check the [issues page](https://github.com/iHildy/jules-task-queue/issues).
+Contributions, issues, and feature requests are welcome! Feel free to check the [issues page](https://github.com/ihildy/jules-task-queue/issues).
Please read the [**CONTRIBUTING.md**](./CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md
index 395271e..5a7a1f2 100644
--- a/SELF_HOSTING.md
+++ b/SELF_HOSTING.md
@@ -14,7 +14,7 @@ The easiest way to self-host is using the provided Docker Compose setup:
### 2. Clone the repository
```bash
-git clone https://github.com/iHildy/jules-task-queue.git
+git clone https://github.com/ihildy/jules-task-queue.git
cd jules-task-queue
```
diff --git a/convert-to-access-token.md b/convert-to-access-token.md
deleted file mode 100644
index 3034fd4..0000000
--- a/convert-to-access-token.md
+++ /dev/null
@@ -1,184 +0,0 @@
-# Converting to GitHub App User Access Token (OAuth During Installation)
-
-## Overview
-
-This document outlines the steps to convert our Jules Task Queue system to use GitHub App user access tokens that are generated automatically when users install the app. This approach ensures Jules bot responds to our automated label changes since these tokens act as the user who authorized the app.
-
-NOTE: WE DO NOT NEED TO BE BACKWARDS COMPATIBLE WITH THE CURRENT APPROACH. WE CAN JUST USE THE USER ACCESS TOKEN APPROACH.
-
-## Security Considerations
-
-- User access tokens expire after 8 hours (configurable)
-- Refresh tokens expire after 6 months
-- Tokens must be stored securely (environment variables, encrypted database)
-- Implement token refresh logic for long-running operations
-
-## TODO List
-
-### Phase 1: GitHub App Configuration
-
-- [ ] **Enable OAuth during installation** (Manual step: Go to GitHub App settings, check "Request user authorization (OAuth) during installation", set callback URL to `https://your-domain.com/api/auth/callback/github`, and save changes.)
-
-- [ ] **Configure token expiration (Recommended)** (Manual step: In GitHub App settings → Optional Features, enable "User-to-server token expiration".)
-
-### Phase 2: Database Schema Updates
-
-- [x] **Add user token storage to database - VIA THE PRISMA SCHEMA FILE**
-
- ```sql
- -- Add to existing tables or create new table
- ALTER TABLE github_app_installations ADD COLUMN user_access_token TEXT;
- ALTER TABLE github_app_installations ADD COLUMN refresh_token TEXT;
- ALTER TABLE github_app_installations ADD COLUMN token_expires_at TIMESTAMP;
- ALTER TABLE github_app_installations ADD COLUMN refresh_token_expires_at TIMESTAMP;
- ```
-
-- [x] **Create Prisma migration**
- ```bash
- pnpm prisma migrate dev --name add_user_tokens
- ```
-
-### Phase 3: OAuth Callback Implementation
-
-- [x] **Create OAuth callback endpoint**
- - File: `src/app/api/auth/callback/github/route.ts`
- - Handle the `code` parameter from GitHub
- - Exchange code for user access token
- - Store tokens in database with installation ID
- - Redirect user to success page
-
-- [x] **Implement token exchange logic**
- ```typescript
- // Exchange code for tokens
- const response = await fetch("https://github.com/login/oauth/access_token", {
- method: "POST",
- headers: { Accept: "application/json" },
- body: new URLSearchParams({
- client_id: env.GITHUB_APP_CLIENT_ID,
- client_secret: env.GITHUB_APP_CLIENT_SECRET,
- code: code,
- redirect_uri: "https://your-domain.com/api/auth/callback/github",
- }),
- });
- ```
-
-### Phase 4: Token Management System
-
-- [x] **Create token manager service**
- - File: `src/lib/token-manager.ts`
- - Methods for storing, retrieving, and refreshing tokens
- - Automatic token refresh when expired
- - Secure token encryption/decryption
-
-- [x] **Implement token refresh logic**
- ```typescript
- async function refreshUserToken(refreshToken: string): Promise<{
- access_token: string;
- refresh_token: string;
- expires_in: number;
- refresh_token_expires_in: number;
- }> {
- const response = await fetch(
- "https://github.com/login/oauth/access_token",
- {
- method: "POST",
- headers: { Accept: "application/json" },
- body: new URLSearchParams({
- client_id: env.GITHUB_APP_CLIENT_ID,
- client_secret: env.GITHUB_APP_CLIENT_SECRET,
- grant_type: "refresh_token",
- refresh_token: refreshToken,
- }),
- },
- );
- return response.json();
- }
- ```
-
-### Phase 5: Update Jules Retry System
-
-- [x] **Modify retry logic to use user tokens**
- - Update `src/lib/jules.ts` `processTaskRetry` function
- - Get user token from database for installation
- - Use user token for label operations instead of installation token
- - Handle token refresh if expired
-
-- [x] **Update GitHub client**
- - Modify `src/lib/github.ts` to accept user tokens
- - Create methods that use user tokens for label operations
- - Maintain backward compatibility with installation tokens
-
-### Phase 6: Installation Webhook Updates
-
-- [x] **Update installation webhook handler**
- - File: `src/app/api/webhooks/github-app/route.ts`
- - Store installation ID when app is installed
- - Note: User tokens will be generated via OAuth callback, not webhook
-
-- [x] **Handle installation removal**
- - Clean up stored tokens when app is uninstalled
- - Remove from database
-
-### Phase 7: Error Handling & Fallbacks
-
-- [x] **Implement graceful fallbacks**
- - If user token is missing/expired, fall back to installation token
- - Log warnings when falling back (Jules may not respond)
- - Provide clear error messages for token issues
-
-- [x] **Add token validation**
- - Validate tokens before use
- - Check expiration times
- - Handle 401/403 errors from GitHub API
-
-### Phase 8: Testing & Validation
-
-- [ ] **Test OAuth flow** (Manual step: Install app and verify OAuth redirect works, confirm tokens are stored in database, test token refresh functionality)
-
-- [ ] **Test Jules integration** (Manual step: Create test issue with `jules-queue` label, run retry process with user token, verify Jules responds to label changes)
-
-- [ ] **Test token expiration** (Manual step: Simulate expired tokens, verify refresh logic works, test fallback to installation tokens)
-
-### Phase 9: Security & Monitoring
-
-- [x] **Add token encryption**
-
-- [ ] **Add token cleanup** (Partially done: Handled reactively when refresh token is bad. Proactive cleanup of truly expired refresh tokens (e.g., via a cron job) is a potential missing piece.)\*\*
- - Encrypt tokens before storing in database
- - Use environment variables for encryption keys
- - Implement secure token rotation
-
-- [ ] **Add token cleanup**
- - Remove expired refresh tokens
-
-### Phase 10: Documentation & Deployment
-
-- [ ] **Update documentation** (Manual step: Update `README.md` with new setup instructions, document OAuth flow for users, add troubleshooting guide)
-
-- [ ] **Environment variables** (Manual step: Add `GITHUB_APP_CLIENT_ID`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_CALLBACK_URL`, `TOKEN_ENCRYPTION_KEY` to `.env.local`)
-
-- [ ] **Deploy and test** (Manual step: Deploy to production, test full OAuth flow, monitor for issues)
-
-## Files to Create/Modify
-
-### New Files:
-
-- `src/app/api/auth/callback/github/route.ts` - OAuth callback handler
-- `src/lib/token-manager.ts` - Token management service
-- `prisma/migrations/[timestamp]_add_user_tokens.sql` - Database migration
-
-### Files to Modify:
-
-- `src/lib/jules.ts` - Update retry logic
-- `src/lib/github.ts` - Add user token support
-- `src/app/api/webhooks/github-app/route.ts` - Handle installations
-- `prisma/schema.prisma` - Add token fields
-- `README.md` - Update documentation
-
-## Success Criteria
-
-- [ ] Users can install app and automatically get user access tokens (Manual verification required)
-- [ ] Jules bot responds to automated label changes (Manual verification required)
-- [x] Token refresh works automatically (Logic implemented, manual verification required)
-- [x] System gracefully handles token expiration (Logic implemented, manual verification required)
-- [ ] No manual intervention required for token management (Logic implemented, but proactive cleanup for expired refresh tokens is a potential missing piece, manual verification required)
diff --git a/package.json b/package.json
index 9beeb36..df4e51c 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,8 @@
"db:studio": "prisma studio",
"type-check": "tsc --noEmit",
"cron:run": "curl -X POST http://localhost:3000/api/cron/retry --header 'Authorization: Bearer $CRON_SECRET'",
- "ngrok": "ngrok http 3000"
+ "ngrok": "ngrok http 3000",
+ "tree": "tree -f -I \".next|node_modules|migrations\" -L 3"
},
"dependencies": {
"@icons-pack/react-simple-icons": "^13.3.0",
diff --git a/src/app/api/auth/authorize/github/route.ts b/src/app/api/auth/authorize/github/route.ts
index 04cb766..c878494 100644
--- a/src/app/api/auth/authorize/github/route.ts
+++ b/src/app/api/auth/authorize/github/route.ts
@@ -29,6 +29,14 @@ function isValidRedirectUrl(redirectTo: string): boolean {
}
export async function GET(request: NextRequest) {
+ // Validate required env at runtime
+ if (!env.GITHUB_APP_CALLBACK_URL) {
+ logger.error("Missing GITHUB_APP_CALLBACK_URL env variable");
+ return NextResponse.json(
+ { error: "Server misconfiguration: missing callback URL" },
+ { status: 500 },
+ );
+ }
const { searchParams } = new URL(request.url);
const installationId = searchParams.get("installation_id");
const redirectTo = searchParams.get("redirect_to") || "/github-app/success";
diff --git a/src/app/api/auth/callback/github/route.ts b/src/app/api/auth/callback/github/route.ts
index 381cebf..b369a48 100644
--- a/src/app/api/auth/callback/github/route.ts
+++ b/src/app/api/auth/callback/github/route.ts
@@ -6,166 +6,19 @@ import * as crypto from "crypto";
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
-// Global fallback rate limiting storage
-declare global {
- var fallbackRateLimits:
- | Map
- | undefined;
-}
-
-// Database-based rate limiter for production use
-async function checkRateLimit(
- identifier: string,
- maxRequests: number = 10,
- windowMs: number = 60 * 1000, // 1 minute default
-) {
- const now = new Date();
- const windowStart = new Date(now.getTime() - windowMs);
- const endpoint = "/api/auth/callback/github";
-
- try {
- // Clean up expired rate limit entries first
- await db.rateLimit.deleteMany({
- where: {
- expiresAt: {
- lt: now,
- },
- },
- });
-
- // Get existing rate limit record
- const existingLimit = await db.rateLimit.findUnique({
- where: {
- identifier_endpoint: {
- identifier,
- endpoint,
- },
- },
- });
-
- if (!existingLimit) {
- // First request in window - create new record
- await db.rateLimit.create({
- data: {
- identifier,
- endpoint,
- requests: 1,
- windowStart: now,
- expiresAt: new Date(now.getTime() + windowMs),
- },
- });
-
- return {
- allowed: true,
- remaining: maxRequests - 1,
- resetTime: new Date(now.getTime() + windowMs),
- };
- }
-
- // Check if window has expired
- if (existingLimit.windowStart < windowStart) {
- // Window expired - reset counter
- await db.rateLimit.update({
- where: { id: existingLimit.id },
- data: {
- requests: 1,
- windowStart: now,
- expiresAt: new Date(now.getTime() + windowMs),
- },
- });
-
- return {
- allowed: true,
- remaining: maxRequests - 1,
- resetTime: new Date(now.getTime() + windowMs),
- };
- }
-
- // Window is still active
- if (existingLimit.requests >= maxRequests) {
- return {
- allowed: false,
- remaining: 0,
- resetTime: existingLimit.expiresAt,
- };
- }
-
- // Increment counter
- await db.rateLimit.update({
- where: { id: existingLimit.id },
- data: {
- requests: existingLimit.requests + 1,
- },
- });
-
- return {
- allowed: true,
- remaining: maxRequests - existingLimit.requests - 1,
- resetTime: existingLimit.expiresAt,
- };
- } catch (error) {
- logger.error({ error, identifier, endpoint }, "Rate limit check failed");
-
- // SECURITY FIX: Do NOT allow all requests on error
- // Instead use a restrictive fallback rate limiter
-
- // Simple in-memory fallback with strict limits
- const fallbackKey = `${identifier}:fallback`;
- const now = Date.now();
- const fallbackWindowMs = 60 * 1000; // 1 minute
- const fallbackMaxRequests = 2; // Very restrictive
-
- // Get or create fallback entry
- if (!global.fallbackRateLimits) {
- global.fallbackRateLimits = new Map();
- }
-
- const existing = global.fallbackRateLimits.get(fallbackKey);
-
- // Clean expired entries
- if (existing && now - existing.windowStart > fallbackWindowMs) {
- global.fallbackRateLimits.delete(fallbackKey);
- }
+import { checkRateLimit } from "@/lib/rate-limiter";
- const current = global.fallbackRateLimits.get(fallbackKey) || {
- count: 0,
- windowStart: now,
- };
-
- // Check if limit exceeded
- if (current.count >= fallbackMaxRequests) {
- logger.warn(
- { identifier, count: current.count, maxRequests: fallbackMaxRequests },
- "Fallback rate limit exceeded - denying request",
- );
- return {
- allowed: false,
- remaining: 0,
- resetTime: new Date(current.windowStart + fallbackWindowMs),
- };
- }
-
- // Increment counter
- current.count++;
- global.fallbackRateLimits.set(fallbackKey, current);
-
- logger.warn(
- { identifier, count: current.count, maxRequests: fallbackMaxRequests },
- "Using fallback rate limiter due to database error",
+export async function GET(request: NextRequest) {
+ if (!env.GITHUB_APP_CALLBACK_URL) {
+ logger.error("Missing GITHUB_APP_CALLBACK_URL env variable");
+ return NextResponse.json(
+ { error: "Server misconfiguration: missing callback URL" },
+ { status: 500 },
);
-
- return {
- allowed: true,
- remaining: fallbackMaxRequests - current.count,
- resetTime: new Date(current.windowStart + fallbackWindowMs),
- };
}
-}
-
-export async function GET(request: NextRequest) {
// Apply database-based rate limiting
const ip = request.headers.get("x-forwarded-for") || "unknown";
- const rateLimitResult = await checkRateLimit(ip);
+ const rateLimitResult = await checkRateLimit(ip, "/api/auth/callback/github");
if (!rateLimitResult.allowed) {
logger.warn({ ip }, "Rate limit exceeded");
diff --git a/src/app/api/cron/retry/route.ts b/src/app/api/cron/retry/route.ts
index 313f235..aace904 100644
--- a/src/app/api/cron/retry/route.ts
+++ b/src/app/api/cron/retry/route.ts
@@ -85,7 +85,7 @@ export async function POST(req: NextRequest) {
try {
// Retry all flagged tasks
- const retryStats = await retryAllFlaggedTasks();
+ const retryStats = await retryAllFlaggedTasks(5);
// Also perform housekeeping - cleanup old completed tasks
const configuredDays = Number(env.TASK_CLEANUP_DAYS);
diff --git a/src/app/api/github-app/installations/[installationId]/repositories/route.ts b/src/app/api/github-app/installations/[installationId]/repositories/route.ts
index ea59b99..16c8b85 100644
--- a/src/app/api/github-app/installations/[installationId]/repositories/route.ts
+++ b/src/app/api/github-app/installations/[installationId]/repositories/route.ts
@@ -6,7 +6,7 @@ type RouteContext = {
params: Promise<{ installationId: string }>;
};
-export async function GET(request: NextRequest, context: RouteContext) {
+export async function GET(_request: NextRequest, context: RouteContext) {
try {
const { installationId: installationIdStr } = await context.params;
const installationId = parseInt(installationIdStr);
diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts
index a780682..6309b30 100644
--- a/src/app/api/health/route.ts
+++ b/src/app/api/health/route.ts
@@ -6,9 +6,24 @@ import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
-type Status = "ok" | "error" | "not_configured";
+type HealthStatus = "ok" | "error" | "not_configured";
-async function checkDatabase(): Promise {
+interface HealthCheck {
+ database: HealthStatus;
+ githubApp: HealthStatus;
+ webhook: HealthStatus;
+}
+
+interface HealthResponse {
+ status: "healthy" | "unhealthy" | "ok" | "error";
+ timestamp?: string;
+ version?: string;
+ uptime?: number;
+ environment?: string;
+ checks?: HealthCheck;
+}
+
+async function checkDatabase(): Promise {
try {
await db.$queryRaw`SELECT 1`;
return "ok";
@@ -18,7 +33,7 @@ async function checkDatabase(): Promise {
}
}
-async function checkGitHubApp(): Promise {
+async function checkGitHubApp(): Promise {
if (!githubAppClient.isConfigured()) {
return "not_configured";
}
@@ -31,11 +46,11 @@ async function checkGitHubApp(): Promise {
}
}
-function checkWebhook(): Status {
+function checkWebhook(): HealthStatus {
return env.GITHUB_APP_WEBHOOK_SECRET ? "ok" : "not_configured";
}
-export async function GET() {
+export async function GET(): Promise> {
// Minimal mode for public environments: only status code and generic body
if (env.NODE_ENV === "production") {
const dbStatus = await checkDatabase();
@@ -48,7 +63,7 @@ export async function GET() {
);
}
- const checks = {
+ const checks: HealthCheck = {
database: await checkDatabase(),
githubApp: await checkGitHubApp(),
webhook: checkWebhook(),
@@ -58,7 +73,7 @@ export async function GET() {
const overallStatus = hasError ? "unhealthy" : "healthy";
const httpStatus = hasError ? 503 : 200;
- const responsePayload = {
+ const responsePayload: HealthResponse = {
status: overallStatus,
timestamp: new Date().toISOString(),
version: process.env.npm_package_version || "0.1.0",
diff --git a/src/app/api/webhooks/github-app/route.ts b/src/app/api/webhooks/github-app/route.ts
index 06f9f23..9cd6f3b 100644
--- a/src/app/api/webhooks/github-app/route.ts
+++ b/src/app/api/webhooks/github-app/route.ts
@@ -7,81 +7,15 @@ import { GitHubLabelEventSchema } from "@/types";
import { createHmac, timingSafeEqual } from "crypto";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
-import logger from "@/lib/logger";
-
-// GitHub webhook payload interfaces
-interface GitHubAccount {
- id: number;
- login: string;
- type: string;
-}
-
-interface GitHubInstallation {
- id: number;
- account: GitHubAccount;
- target_type: string;
- permissions: Record;
- events: string[];
- single_file_name?: string;
- repository_selection: string;
- suspended_at?: string;
- suspended_by?: { login: string };
-}
-
-interface GitHubRepository {
- id: number;
- name: string;
- full_name: string;
- owner?: GitHubAccount; // Optional for installation webhooks
- private: boolean;
- html_url?: string; // Optional for installation webhooks
- description?: string;
-}
-
-interface GitHubInstallationEvent {
- action: string;
- installation: GitHubInstallation;
- repositories?: GitHubRepository[];
-}
-
-interface GitHubInstallationRepositoriesEvent {
- action: string;
- installation: GitHubInstallation;
- repositories_added?: GitHubRepository[];
- repositories_removed?: GitHubRepository[];
-}
-
-interface GitHubLabel {
- name: string;
-}
-
-interface GitHubUser {
- login: string;
-}
-
-interface GitHubIssue {
- number: number;
- state: string;
- labels: GitHubLabel[];
-}
-
-interface GitHubComment {
- user: GitHubUser;
-}
-
-interface GitHubIssueCommentEvent {
- action: string;
- issue: GitHubIssue;
- comment: GitHubComment;
- repository: GitHubRepository;
- installation?: { id: number };
-}
-interface GitHubWebhookEvent {
- action: string;
- installation?: { id: number };
- [key: string]: unknown;
-}
+import {
+ GitHubInstallationEvent,
+ GitHubInstallationRepositoriesEvent,
+ GitHubIssueCommentEvent,
+ GitHubLabel,
+ GitHubWebhookRepository,
+ GitHubWebhookEvent,
+} from "@/types/github";
/**
* Verify GitHub App webhook signature
@@ -119,158 +53,7 @@ function verifyGitHubAppSignature(payload: string, signature: string): boolean {
}
}
-/**
- * Minimal rate limiting for webhook endpoint to prevent DB flooding
- */
-async function checkRateLimit(
- identifierRaw: string,
- maxRequests: number = 30,
- windowMs: number = 60 * 1000,
-) {
- // Normalize/shorten identifier to avoid oversized keys
- const identifier = identifierRaw.slice(0, 64);
- const now = new Date();
- const endpoint = "/api/webhooks/github-app";
-
- try {
- // Throttle cleanup (only 1% of requests trigger it) to reduce contention
- if (Math.random() < 0.01) {
- void db.rateLimit.deleteMany({ where: { expiresAt: { lt: now } } });
- }
-
- // Atomic upsert with conditional increment to avoid race conditions
- // 1. Try to increment if window active and under limit
- const updated = await db.$executeRawUnsafe(
- `UPDATE rate_limits
- SET requests = requests + 1
- WHERE identifier = $1 AND endpoint = $2 AND "expiresAt" > $3 AND requests < $4`,
- identifier,
- endpoint,
- now,
- maxRequests,
- );
-
- if (updated && updated > 0) {
- // Fetch remaining in a lightweight way
- const row = await db.rateLimit.findUnique({
- where: { identifier_endpoint: { identifier, endpoint } },
- select: { requests: true },
- });
- const remaining = Math.max(
- 0,
- maxRequests - (row?.requests ?? maxRequests),
- );
- return { allowed: true, remaining } as const;
- }
-
- // 2. Either new window or first request: try insert
- try {
- // Use upsert to avoid unique constraint races
- const record = await db.rateLimit.upsert({
- where: { identifier_endpoint: { identifier, endpoint } },
- update: {},
- create: {
- identifier,
- endpoint,
- requests: 1,
- windowStart: now,
- expiresAt: new Date(now.getTime() + windowMs),
- },
- });
- // If upsert hit existing row (update no-op), decide based on expiry/requests
- if (record.expiresAt <= now) {
- const reset = await db.rateLimit.update({
- where: { id: record.id },
- data: {
- requests: 1,
- windowStart: now,
- expiresAt: new Date(now.getTime() + windowMs),
- },
- });
- return {
- allowed: true,
- remaining: maxRequests - reset.requests,
- } as const;
- }
- if (record.requests >= maxRequests) {
- return { allowed: false, remaining: 0 } as const;
- }
- const updatedRecord = await db.rateLimit.update({
- where: { id: record.id },
- data: { requests: { increment: 1 } },
- select: { requests: true },
- });
- return {
- allowed: true,
- remaining: Math.max(0, maxRequests - updatedRecord.requests),
- } as const;
- } catch {
- // 3. If upsert failed (rare), reset window if expired, else check limit
- const record = await db.rateLimit.findUnique({
- where: { identifier_endpoint: { identifier, endpoint } },
- });
- if (!record) {
- return { allowed: true, remaining: maxRequests - 1 } as const;
- }
- if (record.expiresAt <= now) {
- await db.rateLimit.update({
- where: { id: record.id },
- data: {
- requests: 1,
- windowStart: now,
- expiresAt: new Date(now.getTime() + windowMs),
- },
- });
- return { allowed: true, remaining: maxRequests - 1 } as const;
- }
- if (record.requests >= maxRequests) {
- return { allowed: false, remaining: 0 } as const;
- }
- const newCount = record.requests + 1;
- await db.rateLimit.update({
- where: { id: record.id },
- data: { requests: newCount },
- });
- return {
- allowed: true,
- remaining: Math.max(0, maxRequests - newCount),
- } as const;
- }
- } catch {
- // Fallback to a very restrictive in-memory limiter
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const g: any = global as unknown as {
- __webhookFallbackLimits?: Map<
- string,
- { count: number; windowStart: number }
- >;
- };
- if (!g.__webhookFallbackLimits) g.__webhookFallbackLimits = new Map();
- const key = `${identifier}:webhook`;
- const nowMs = Date.now();
- const fallbackWindowMs = 60 * 1000;
- const fallbackMax = 10; // extra strict fallback
- const current = g.__webhookFallbackLimits.get(key) || {
- count: 0,
- windowStart: nowMs,
- };
- if (nowMs - current.windowStart > fallbackWindowMs) {
- current.count = 0;
- current.windowStart = nowMs;
- }
- if (current.count >= fallbackMax) {
- logger.warn({ identifier }, "Webhook fallback rate limit exceeded");
- return { allowed: false, remaining: 0 } as const;
- }
- current.count++;
- g.__webhookFallbackLimits.set(key, current);
- logger.warn(
- { identifier, count: current.count },
- "Using webhook fallback rate limiter",
- );
- return { allowed: true, remaining: fallbackMax - current.count } as const;
- }
-}
+import { checkRateLimit } from "@/lib/rate-limiter";
/**
* Log webhook event to database
@@ -345,7 +128,7 @@ async function handleInstallationEvent(
// Add all repositories if "all" selection
if (installation.repository_selection === "all" && payload.repositories) {
await Promise.all(
- payload.repositories.map((repo: GitHubRepository) => {
+ payload.repositories.map((repo: GitHubWebhookRepository) => {
// Extract owner from full_name since installation webhooks don't include owner object
const owner = repo.full_name.split("/")[0] || "unknown";
@@ -463,7 +246,7 @@ async function handleInstallationRepositoriesEvent(
if (action === "added") {
await db.$transaction(async (prisma) => {
await Promise.all(
- repositories.map((repo: GitHubRepository) => {
+ repositories.map((repo: GitHubWebhookRepository) => {
// Extract owner from full_name since installation repository webhooks may not include owner object
const owner =
repo.owner?.login || repo.full_name.split("/")[0] || "unknown";
@@ -548,7 +331,7 @@ async function handleInstallationRepositoriesEvent(
} else if (action === "removed") {
await db.$transaction(async (prisma) => {
await Promise.all(
- repositories.map((repo: GitHubRepository) =>
+ repositories.map((repo: GitHubWebhookRepository) =>
prisma.installationRepository.updateMany({
where: {
installationId: installation.id,
@@ -592,7 +375,7 @@ export async function POST(req: NextRequest) {
const identifier = userAgent
? `${normalizedIp}|${userAgent}`
: normalizedIp;
- const rate = await checkRateLimit(identifier);
+ const rate = await checkRateLimit(identifier, "/api/webhooks/github-app");
if (!rate.allowed) {
await logWebhookEvent(eventType, payload, false, "Rate limit exceeded");
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
@@ -723,9 +506,6 @@ export async function POST(req: NextRequest) {
`New comment on Jules-labeled issue ${commentEvent.repository.full_name}#${commentEvent.issue.number} by ${commentEvent.comment.user.login}`,
);
- // TODO: In the future, we could implement real-time comment processing here
- // For now, just log the event for monitoring purposes
-
await logWebhookEvent(eventType, payload, true);
return NextResponse.json({
diff --git a/src/app/error.tsx b/src/app/error.tsx
new file mode 100644
index 0000000..fd27826
--- /dev/null
+++ b/src/app/error.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { AlertTriangle, Home, RefreshCw } from "lucide-react";
+import { useEffect } from "react";
+
+interface ErrorProps {
+ error: Error & { digest?: string };
+ reset: () => void;
+}
+
+export default function Error({ error, reset }: ErrorProps) {
+ useEffect(() => {
+ console.error("Route error:", error);
+ }, [error]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Oops! Something went wrong
+
+
+ We encountered an error while loading this page. Please try again.
+