From e5981fccc034d792e16b2b909e7a25b3652f635a Mon Sep 17 00:00:00 2001 From: iHildy Date: Fri, 15 Aug 2025 16:37:16 -0500 Subject: [PATCH 01/10] env: accept PEM or base64 GITHUB_APP_PRIVATE_KEY; docs: add callback URL and clarify .env example; chore: add BigInt/number interop utils; fix: remove duplicate imports and annotate safe issueNumber conversions --- .env.example | 4 ++++ src/lib/env.ts | 31 ++++++++++++++++++++++--------- src/lib/jules.ts | 2 +- src/lib/number.ts | 29 +++++++++++++++++++++++++++++ src/lib/webhook-processor.ts | 1 + 5 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 src/lib/number.ts 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/src/lib/env.ts b/src/lib/env.ts index d05414f..91c2fbe 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -12,7 +12,27 @@ export const env = createEnv({ // GitHub App Integration GITHUB_APP_PRIVATE_KEY: z .string() - .min(1, "GITHUB_APP_PRIVATE_KEY is required"), + .min(1, "GITHUB_APP_PRIVATE_KEY is required") + .transform((val) => { + if ( + val.startsWith("-----BEGIN RSA PRIVATE KEY-----") || + val.includes("\\n") + ) { + return val.replace(/\\n/g, "\n"); + } + // It's not a raw PEM, assume it's base64 encoded + try { + const decoded = Buffer.from(val, "base64").toString("utf-8"); + if (!decoded.startsWith("-----BEGIN RSA PRIVATE KEY-----")) { + throw new Error("Invalid Base64-decoded private key format"); + } + return decoded; + } catch { + throw new Error( + "Failed to decode GITHUB_APP_PRIVATE_KEY. Ensure it is a valid PEM or Base64-encoded string.", + ); + } + }), GITHUB_APP_WEBHOOK_SECRET: z .string() .min(1, "GITHUB_APP_WEBHOOK_SECRET is required"), @@ -74,14 +94,7 @@ export const env = createEnv({ runtimeEnv: { // Server DATABASE_URL: process.env.DATABASE_URL, - GITHUB_APP_PRIVATE_KEY: - typeof window === "undefined" - ? process.env.GITHUB_APP_PRIVATE_KEY - ? Buffer.from(process.env.GITHUB_APP_PRIVATE_KEY, "base64").toString( - "utf-8", - ) - : undefined - : process.env.GITHUB_APP_PRIVATE_KEY, + GITHUB_APP_PRIVATE_KEY: process.env.GITHUB_APP_PRIVATE_KEY, GITHUB_APP_WEBHOOK_SECRET: process.env.GITHUB_APP_WEBHOOK_SECRET, GITHUB_APP_CLIENT_ID: process.env.GITHUB_APP_CLIENT_ID, GITHUB_APP_CLIENT_SECRET: process.env.GITHUB_APP_CLIENT_SECRET, diff --git a/src/lib/jules.ts b/src/lib/jules.ts index 082d247..7c031ab 100644 --- a/src/lib/jules.ts +++ b/src/lib/jules.ts @@ -2,7 +2,6 @@ import { githubClient } from "@/lib/github"; import logger from "@/lib/logger"; import { getUserAccessToken } from "@/lib/token-manager"; import { db } from "@/server/db"; -import logger from "@/lib/logger"; import type { CommentAnalysis, CommentClassification, @@ -604,6 +603,7 @@ export async function processTaskRetry(taskId: number): Promise { } const { repoOwner, repoName, githubIssueNumber, installationId } = task; + // githubIssueNumber is stored as BigInt; convert safely for GitHub API which expects number const issueNumber = Number(githubIssueNumber); logger.info( diff --git a/src/lib/number.ts b/src/lib/number.ts new file mode 100644 index 0000000..5472cc6 --- /dev/null +++ b/src/lib/number.ts @@ -0,0 +1,29 @@ +// Utility helpers for safe number/BigInt interop +// Keep IDs as BigInt; only convert issue numbers or bounded small integers. + +/** Max safe integer as BigInt for comparisons */ +export const MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); + +/** + * Safely convert a BigInt to number when guaranteed to fit. + * Throws if the value exceeds Number.MAX_SAFE_INTEGER. + */ +export function toSafeNumber(bi: bigint): number { + if (bi > MAX_SAFE_INTEGER_BIGINT || bi < BigInt(Number.MIN_SAFE_INTEGER)) { + throw new Error("BigInt value is out of safe number range"); + } + return Number(bi); +} + +/** Whether a bigint can be safely represented as a JS number */ +export function isSafeNumberBigInt(bi: bigint): boolean { + return bi <= MAX_SAFE_INTEGER_BIGINT && bi >= BigInt(Number.MIN_SAFE_INTEGER); +} + +/** Parse possibly string/number/bigint into bigint. */ +export function toBigInt(value: string | number | bigint): bigint { + if (typeof value === "bigint") return value; + if (typeof value === "number") return BigInt(value); + // value could be decimal string + return BigInt(value); +} diff --git a/src/lib/webhook-processor.ts b/src/lib/webhook-processor.ts index 7806f63..76dc80e 100644 --- a/src/lib/webhook-processor.ts +++ b/src/lib/webhook-processor.ts @@ -329,6 +329,7 @@ export async function triggerCommentCheck( // With enhanced schema, we now have stored repo information const { repoOwner, repoName, githubIssueNumber } = task; + // githubIssueNumber stored as BigInt; safe to convert to number for API const issueNumber = Number(githubIssueNumber); logger.info( From c570d75723e8b078bdb2877d99da4bf44e24e23b Mon Sep 17 00:00:00 2001 From: iHildy Date: Fri, 15 Aug 2025 16:52:09 -0500 Subject: [PATCH 02/10] safety: use toSafeNumber for issueNumber conversions; oauth: validate callback URL at runtime; cron: add concurrency limit to retries and default TASK_CLEANUP_DAYS via env --- src/app/api/auth/authorize/github/route.ts | 8 ++++ src/app/api/auth/callback/github/route.ts | 7 ++++ src/app/api/cron/retry/route.ts | 2 +- src/lib/jules.ts | 44 ++++++++++++++++------ src/lib/webhook-processor.ts | 3 +- src/server/api/routers/admin.ts | 5 ++- 6 files changed, 53 insertions(+), 16 deletions(-) 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..b627b4e 100644 --- a/src/app/api/auth/callback/github/route.ts +++ b/src/app/api/auth/callback/github/route.ts @@ -163,6 +163,13 @@ async function checkRateLimit( } 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 }, + ); + } // Apply database-based rate limiting const ip = request.headers.get("x-forwarded-for") || "unknown"; const rateLimitResult = await checkRateLimit(ip); 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/lib/jules.ts b/src/lib/jules.ts index 7c031ab..07808ab 100644 --- a/src/lib/jules.ts +++ b/src/lib/jules.ts @@ -2,6 +2,7 @@ import { githubClient } from "@/lib/github"; import logger from "@/lib/logger"; import { getUserAccessToken } from "@/lib/token-manager"; import { db } from "@/server/db"; +import { toSafeNumber } from "@/lib/number"; import type { CommentAnalysis, CommentClassification, @@ -604,7 +605,7 @@ export async function processTaskRetry(taskId: number): Promise { const { repoOwner, repoName, githubIssueNumber, installationId } = task; // githubIssueNumber is stored as BigInt; convert safely for GitHub API which expects number - const issueNumber = Number(githubIssueNumber); + const issueNumber = toSafeNumber(githubIssueNumber); logger.info( `Processing retry for task ${taskId}: ${repoOwner}/${repoName}#${issueNumber}`, @@ -683,7 +684,12 @@ export async function getFlaggedTasks() { /** * Bulk retry all flagged tasks */ -export async function retryAllFlaggedTasks(): Promise<{ +// Limit concurrency to avoid rate limits +const DEFAULT_RETRY_CONCURRENCY = 5; + +export async function retryAllFlaggedTasks( + concurrency: number = DEFAULT_RETRY_CONCURRENCY, +): Promise<{ attempted: number; successful: number; failed: number; @@ -697,19 +703,33 @@ export async function retryAllFlaggedTasks(): Promise<{ skipped: 0, }; - for (const task of flaggedTasks) { - try { - const success = await processTaskRetry(task.id); - if (success) { - stats.successful++; - } else { - stats.skipped++; + // Concurrency-limited processing + const queue = [...flaggedTasks]; + const workers: Promise[] = []; + + const runWorker = async () => { + while (queue.length > 0) { + const task = queue.shift(); + if (!task) break; + try { + const success = await processTaskRetry(task.id); + if (success) { + stats.successful++; + } else { + stats.skipped++; + } + } catch (error) { + logger.error(`Failed to retry task ${task.id}:`, error); + stats.failed++; } - } catch (error) { - logger.error(`Failed to retry task ${task.id}:`, error); - stats.failed++; } + }; + + const workerCount = Math.max(1, concurrency); + for (let i = 0; i < workerCount; i++) { + workers.push(runWorker()); } + await Promise.all(workers); logger.info(`Retry batch complete:`, stats); return stats; diff --git a/src/lib/webhook-processor.ts b/src/lib/webhook-processor.ts index 76dc80e..e05528e 100644 --- a/src/lib/webhook-processor.ts +++ b/src/lib/webhook-processor.ts @@ -7,6 +7,7 @@ import { } from "@/lib/jules"; import logger from "@/lib/logger"; import { db } from "@/server/db"; +import { toSafeNumber } from "@/lib/number"; import type { GitHubLabelEvent, ProcessingResult } from "@/types"; /** @@ -330,7 +331,7 @@ export async function triggerCommentCheck( // With enhanced schema, we now have stored repo information const { repoOwner, repoName, githubIssueNumber } = task; // githubIssueNumber stored as BigInt; safe to convert to number for API - const issueNumber = Number(githubIssueNumber); + const issueNumber = toSafeNumber(githubIssueNumber); logger.info( `Manually triggering comment check for task ${taskId}: ${repoOwner}/${repoName}#${issueNumber}`, diff --git a/src/server/api/routers/admin.ts b/src/server/api/routers/admin.ts index 9065450..f1cee03 100644 --- a/src/server/api/routers/admin.ts +++ b/src/server/api/routers/admin.ts @@ -8,6 +8,7 @@ import logger from "@/lib/logger"; import { getProcessingStats } from "@/lib/webhook-processor"; import { adminProcedure, createTRPCRouter } from "@/server/api/trpc"; import { z } from "zod"; +import { toSafeNumber } from "@/lib/number"; // Type definitions for installation data interface InstallationWithCounts { @@ -110,7 +111,7 @@ export const adminRouter = createTRPCRouter({ updatedAt: Date; }) => ({ id: task.id, - githubIssueNumber: Number(task.githubIssueNumber), + githubIssueNumber: toSafeNumber(task.githubIssueNumber), repoOwner: task.repoOwner, repoName: task.repoName, retryCount: task.retryCount, @@ -371,7 +372,7 @@ export const adminRouter = createTRPCRouter({ ), tasks: installation.tasks.map((task: InstallationTask) => ({ id: task.id, - githubIssueNumber: Number(task.githubIssueNumber), + githubIssueNumber: toSafeNumber(task.githubIssueNumber), repoOwner: task.repoOwner, repoName: task.repoName, flaggedForRetry: task.flaggedForRetry, From 89b2815f589b71db5356c566df05c7c6ed59305c Mon Sep 17 00:00:00 2001 From: iHildy Date: Fri, 15 Aug 2025 17:42:07 -0500 Subject: [PATCH 03/10] centralized rate-limtits / added ADMIN_SECRET / standard api errors / central types / improved json parsing / removed unused code. --- audit-plan.md | 75 ++++++ convert-to-access-token.md | 184 ------------- src/app/api/auth/callback/github/route.ts | 158 +----------- .../[installationId]/repositories/route.ts | 2 +- src/app/api/webhooks/github-app/route.ts | 243 +----------------- src/lib/env.ts | 4 + src/lib/rate-limiter.ts | 161 ++++++++++++ src/server/api/routers/admin.ts | 160 ++++++------ src/server/api/routers/tasks.ts | 3 +- src/server/api/trpc.ts | 18 +- src/types/api.ts | 41 ++- src/types/github.ts | 88 +++++-- 12 files changed, 461 insertions(+), 676 deletions(-) create mode 100644 audit-plan.md delete mode 100644 convert-to-access-token.md create mode 100644 src/lib/rate-limiter.ts diff --git a/audit-plan.md b/audit-plan.md new file mode 100644 index 0000000..3c03c85 --- /dev/null +++ b/audit-plan.md @@ -0,0 +1,75 @@ +# Engineering Competition Review Plan + +This document tracks a systematic, senior-level audit of the codebase with a focus on correctness, security, maintainability, performance, and production readiness. No new features—only improvements. + +## Scope and Approach + +- One section per pass; ordered by highest impact on reliability and security. +- For each section: identify issues, explain why they matter, and propose concise, actionable improvements. +- Keep changes minimal, type-safe, and aligned with project standards. + +## Sections + +1. Configuration and Environment + - Files: `.env.example`, `src/lib/env.ts`, `src/types/environment.ts`, `next.config.ts`, `vercel.json`, `Dockerfile`, `docker-compose*`, `supabase/*`, `prisma/schema.prisma` + - Focus: missing/unused env vars, strict parsing/validation, runtime vs build-time exposure, safe defaults. + +2. Database and Migrations (Prisma) + - Files: `prisma/schema.prisma`, `prisma/migrations/*`, `src/server/db.ts` + - Focus: schema correctness, indexes, relations, naming consistency, migration safety. + +3. Backend: Domain Services and Utilities + - Files: `src/lib/*.ts` + - Focus: separation of concerns, token handling, idempotency, timeouts/retries, error typing, rate limiting. + +4. API Routers and HTTP Endpoints + - Files: `src/server/api/*`, `src/app/api/**/*` + - Focus: Zod validation, auth boundaries, webhook verification, logging, resilience. + +5. Frontend App Router and Pages + - Files: `src/app/**/*` + - Focus: server vs client boundaries, data fetching, caching, error boundaries, metadata. + +6. Frontend Components + - Files: `src/components/**/*` + - Focus: accessibility, prop typing, state isolation, render perf, Tailwind quality. + +7. Types and Schemas + - Files: `src/types/**/*`, `src/app/types/*`, shared schemas in `src/types/schemas.ts` + - Focus: duplication, drift from DB, strictness, reuse of primitives. + +8. Functions Package + - Files: `functions/*` + - Focus: dead code, env use, logging, error handling. + +9. CI/CD and Tooling + - Files: `.github/workflows/*`, `eslint.config.mjs`, `tsconfig.json`, Husky hooks + - Focus: typecheck/lint/test in CI, secret safety, reproducibility. + +10. Security/Compliance Sweep + +- Cross-cut: secret handling, GitHub App signatures, JWT/crypto, rate limiting, replay protection. + +## Deliverables Per Section + +- Findings: ranked list (critical → low) +- Improvements: small, safe diffs with rationale +- Verification: lint/build/tests steps + +## Status + +- [ ] 1. Configuration and Environment +- [ ] 2. Database and Migrations (Prisma) +- [ ] 3. Backend: Domain Services and Utilities +- [ ] 4. API Routers and HTTP Endpoints +- [ ] 5. Frontend App Router and Pages +- [ ] 6. Frontend Components +- [ ] 7. Types and Schemas +- [ ] 8. Functions Package +- [ ] 9. CI/CD and Tooling +- [ ] 10. Security/Compliance Sweep + +## Notes + +- After each change batch: run `pnpm lint` and `pnpm build` and iterate until zero errors/warnings for touched files. +- Favor explicit return types, Zod schemas, and minimal, testable changes. 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/src/app/api/auth/callback/github/route.ts b/src/app/api/auth/callback/github/route.ts index b627b4e..b369a48 100644 --- a/src/app/api/auth/callback/github/route.ts +++ b/src/app/api/auth/callback/github/route.ts @@ -6,161 +6,7 @@ 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); - } - - 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", - ); - - return { - allowed: true, - remaining: fallbackMaxRequests - current.count, - resetTime: new Date(current.windowStart + fallbackWindowMs), - }; - } -} +import { checkRateLimit } from "@/lib/rate-limiter"; export async function GET(request: NextRequest) { if (!env.GITHUB_APP_CALLBACK_URL) { @@ -172,7 +18,7 @@ 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/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/webhooks/github-app/route.ts b/src/app/api/webhooks/github-app/route.ts index 06f9f23..6135192 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 }); diff --git a/src/lib/env.ts b/src/lib/env.ts index 91c2fbe..96d6190 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -49,6 +49,9 @@ export const env = createEnv({ .enum(["development", "test", "production"]) .default("development"), + // Admin Security + ADMIN_SECRET: z.string().optional(), + // Cron Job Security CRON_SECRET: z.string().optional(), @@ -93,6 +96,7 @@ export const env = createEnv({ */ runtimeEnv: { // Server + ADMIN_SECRET: process.env.ADMIN_SECRET, DATABASE_URL: process.env.DATABASE_URL, GITHUB_APP_PRIVATE_KEY: process.env.GITHUB_APP_PRIVATE_KEY, GITHUB_APP_WEBHOOK_SECRET: process.env.GITHUB_APP_WEBHOOK_SECRET, diff --git a/src/lib/rate-limiter.ts b/src/lib/rate-limiter.ts new file mode 100644 index 0000000..97c110b --- /dev/null +++ b/src/lib/rate-limiter.ts @@ -0,0 +1,161 @@ +import { db } from "@/server/db"; +import logger from "./logger"; + +// Simple in-memory fallback cache for rate limiting if the database is unavailable. +const fallbackCache = new Map(); + +/** + * Checks if a request is allowed under the rate limit policy. + * + * @param identifier - A unique identifier for the entity being rate-limited (e.g., IP address, API key). + * @param endpoint - The API endpoint being accessed. + * @param maxRequests - The maximum number of requests allowed in the time window. + * @param windowMs - The time window in milliseconds. + * @returns An object indicating whether the request is allowed, the number of remaining requests, and when the limit resets. + */ +export async function checkRateLimit( + identifier: string, + endpoint: string, + maxRequests: number = 30, + windowMs: number = 60 * 1000, +) { + const now = new Date(); + const windowStart = new Date(now.getTime() - windowMs); + + try { + // Clean up expired rate limit entries first. + // This is a non-blocking call to avoid delaying the response. + if (Math.random() < 0.01) { + // Only run cleanup on 1% of requests to reduce DB load. + db.rateLimit + .deleteMany({ + where: { + expiresAt: { + lt: now, + }, + }, + }) + .catch((err) => + logger.error(err, "Failed to cleanup expired rate limits"), + ); + } + + const existingLimit = await db.rateLimit.findUnique({ + where: { + identifier_endpoint: { + identifier, + endpoint, + }, + }, + }); + + if (!existingLimit || existingLimit.windowStart < windowStart) { + // If no record exists or the window has expired, create/reset the record. + await db.rateLimit.upsert({ + where: { + identifier_endpoint: { + identifier, + endpoint, + }, + }, + create: { + identifier, + endpoint, + requests: 1, + windowStart: now, + expiresAt: new Date(now.getTime() + windowMs), + }, + update: { + requests: 1, + windowStart: now, + expiresAt: new Date(now.getTime() + windowMs), + }, + }); + + return { + allowed: true, + remaining: maxRequests - 1, + resetTime: new Date(now.getTime() + windowMs), + }; + } + + if (existingLimit.requests >= maxRequests) { + // Limit exceeded. + return { + allowed: false, + remaining: 0, + resetTime: existingLimit.expiresAt, + }; + } + + // Increment the request count. + const updatedLimit = await db.rateLimit.update({ + where: { + id: existingLimit.id, + }, + data: { + requests: { + increment: 1, + }, + }, + }); + + return { + allowed: true, + remaining: maxRequests - updatedLimit.requests, + resetTime: existingLimit.expiresAt, + }; + } catch (error) { + logger.error({ error, identifier, endpoint }, "Rate limit check failed"); + // Fallback to in-memory rate limiter if the database fails. + return checkRateLimitFallback(identifier, 5, windowMs); // More restrictive fallback + } +} + +/** + * A fallback in-memory rate limiter to use when the primary database-based one fails. + * + * @param identifier - A unique identifier for the entity being rate-limited. + * @param maxRequests - The maximum number of requests allowed. + * @param windowMs - The time window in milliseconds. + * @returns An object indicating if the request is allowed. + */ +function checkRateLimitFallback( + identifier: string, + maxRequests: number, + windowMs: number, +) { + const now = Date.now(); + const entry = fallbackCache.get(identifier); + + if (!entry || now - entry.windowStart > windowMs) { + // If no entry or the window has expired, create a new one. + fallbackCache.set(identifier, { count: 1, windowStart: now }); + return { + allowed: true, + remaining: maxRequests - 1, + resetTime: new Date(now + windowMs), + }; + } + + if (entry.count >= maxRequests) { + // Limit exceeded. + logger.warn( + { identifier, count: entry.count, maxRequests }, + "Fallback rate limit exceeded", + ); + return { + allowed: false, + remaining: 0, + resetTime: new Date(entry.windowStart + windowMs), + }; + } + + // Increment count and allow the request. + entry.count++; + return { + allowed: true, + remaining: maxRequests - entry.count, + resetTime: new Date(entry.windowStart + windowMs), + }; +} diff --git a/src/server/api/routers/admin.ts b/src/server/api/routers/admin.ts index f1cee03..f6014a3 100644 --- a/src/server/api/routers/admin.ts +++ b/src/server/api/routers/admin.ts @@ -7,46 +7,15 @@ import { import logger from "@/lib/logger"; import { getProcessingStats } from "@/lib/webhook-processor"; import { adminProcedure, createTRPCRouter } from "@/server/api/trpc"; +import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { toSafeNumber } from "@/lib/number"; -// Type definitions for installation data -interface InstallationWithCounts { - id: number; - accountLogin: string; - accountType: string; - repositorySelection: string; - createdAt: Date; - updatedAt: Date; - suspendedAt: Date | null; - suspendedBy: string | null; - _count: { - repositories: number; - tasks: number; - }; -} - -interface InstallationRepository { - id: number; - name: string; - fullName: string; - owner: string; - private: boolean; - htmlUrl: string; - description: string | null; - addedAt: Date; -} - -interface InstallationTask { - id: number; - githubIssueNumber: bigint; - repoOwner: string; - repoName: string; - flaggedForRetry: boolean; - retryCount: number; - createdAt: Date; - updatedAt: Date; -} +import { + InstallationRepository, + InstallationTask, + InstallationWithCounts, +} from "@/types/api"; export const adminRouter = createTRPCRouter({ // Manually trigger retry for all flagged tasks @@ -61,11 +30,13 @@ export const adminRouter = createTRPCRouter({ }; } catch (error) { logger.error({ error }, "Failed to retry all tasks"); - throw new Error( - `Retry failed: ${ + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Retry failed: ${ error instanceof Error ? error.message : "Unknown error" }`, - ); + cause: error, + }); } }), @@ -86,11 +57,13 @@ export const adminRouter = createTRPCRouter({ }; } catch (error) { logger.error({ error }, `Failed to retry task ${input.taskId}`); - throw new Error( - `Retry failed: ${ + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Retry failed: ${ error instanceof Error ? error.message : "Unknown error" }`, - ); + cause: error, + }); } }), @@ -164,14 +137,22 @@ export const adminRouter = createTRPCRouter({ error: string | null; createdAt: Date; payload: string | null; - }) => ({ - id: log.id, - eventType: log.eventType, - success: log.success, - error: log.error, - createdAt: log.createdAt, - payload: log.payload ? JSON.parse(log.payload) : null, - }), + }) => { + let payload; + try { + payload = log.payload ? JSON.parse(log.payload) : null; + } catch { + payload = { error: "Failed to parse payload" }; + } + return { + id: log.id, + eventType: log.eventType, + success: log.success, + error: log.error, + createdAt: log.createdAt, + payload, + }; + }, ), nextCursor, }; @@ -196,11 +177,13 @@ export const adminRouter = createTRPCRouter({ }; } catch (error) { logger.error({ error }, "Failed to get admin health stats"); - throw new Error( - `Health check failed: ${ + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Health check failed: ${ error instanceof Error ? error.message : "Unknown error" }`, - ); + cause: error, + }); } }), @@ -223,11 +206,13 @@ export const adminRouter = createTRPCRouter({ }; } catch (error) { logger.error({ error }, "Failed to cleanup old tasks"); - throw new Error( - `Cleanup failed: ${ + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Cleanup failed: ${ error instanceof Error ? error.message : "Unknown error" }`, - ); + cause: error, + }); } }), @@ -300,11 +285,13 @@ export const adminRouter = createTRPCRouter({ }; } catch (error) { logger.error({ error }, "Failed to get admin metrics"); - throw new Error( - `Metrics failed: ${ + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Metrics failed: ${ error instanceof Error ? error.message : "Unknown error" }`, - ); + cause: error, + }); } }), @@ -342,7 +329,24 @@ export const adminRouter = createTRPCRouter({ ); if (!installation) { - throw new Error(`Installation ${input.installationId} not found`); + throw new TRPCError({ + code: "NOT_FOUND", + message: `Installation ${input.installationId} not found`, + }); + } + + let permissions; + try { + permissions = JSON.parse(installation.permissions); + } catch { + permissions = { error: "Failed to parse permissions" }; + } + + let events; + try { + events = JSON.parse(installation.events); + } catch { + events = { error: "Failed to parse events" }; } return { @@ -351,8 +355,8 @@ export const adminRouter = createTRPCRouter({ accountType: installation.accountType, targetType: installation.targetType, repositorySelection: installation.repositorySelection, - permissions: JSON.parse(installation.permissions), - events: JSON.parse(installation.events), + permissions, + events, singleFileName: installation.singleFileName, createdAt: installation.createdAt, updatedAt: installation.updatedAt, @@ -405,9 +409,13 @@ export const adminRouter = createTRPCRouter({ { error }, `Failed to sync installation ${input.installationId}`, ); - throw new Error( - `Sync failed: ${error instanceof Error ? error.message : "Unknown error"}`, - ); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Sync failed: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + cause: error, + }); } }), @@ -426,9 +434,13 @@ export const adminRouter = createTRPCRouter({ }; } catch (error) { logger.error({ error }, "Failed to sync all installations"); - throw new Error( - `Sync all failed: ${error instanceof Error ? error.message : "Unknown error"}`, - ); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Sync all failed: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + cause: error, + }); } }), @@ -463,9 +475,13 @@ export const adminRouter = createTRPCRouter({ }; } catch (error) { logger.error({ error }, "Failed to cleanup suspended installations"); - throw new Error( - `Cleanup failed: ${error instanceof Error ? error.message : "Unknown error"}`, - ); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Cleanup failed: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + cause: error, + }); } }), }), diff --git a/src/server/api/routers/tasks.ts b/src/server/api/routers/tasks.ts index 5d4f53a..a462707 100644 --- a/src/server/api/routers/tasks.ts +++ b/src/server/api/routers/tasks.ts @@ -1,4 +1,5 @@ import { createTRPCRouter, publicProcedure } from "@/server/api/trpc"; +import { TRPCError } from "@trpc/server"; import { z } from "zod"; export const tasksRouter = createTRPCRouter({ @@ -88,7 +89,7 @@ export const tasksRouter = createTRPCRouter({ }); if (!task) { - throw new Error("Task not found"); + throw new TRPCError({ code: "NOT_FOUND", message: "Task not found" }); } // Update task to be retried diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index cd6ed46..d8fc2aa 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -98,18 +98,18 @@ export const publicProcedure = t.procedure; /** * Protected procedure - for admin operations * - * In a production environment, you would want to add proper authentication here. - * For now, we'll use a simple environment variable check. + * For production, we check for a secret token from the request headers. + * This allows for secure access to the admin API in a live environment. */ export const adminProcedure = t.procedure.use(({ ctx, next }) => { - // In production, you would want to implement proper auth here - // For now, we'll just check if we're in development mode if (env.NODE_ENV === "production") { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: - "Admin endpoints are not available in production without proper auth", - }); + const adminSecret = ctx.headers.get("x-admin-secret"); + if (!env.ADMIN_SECRET || adminSecret !== env.ADMIN_SECRET) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to perform this action.", + }); + } } return next({ diff --git a/src/types/api.ts b/src/types/api.ts index 361b4e2..6d0d43c 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -48,9 +48,40 @@ export interface HealthCheckResult { uptime: number; } -// API Error types -export interface ApiError { - code: string; - message: string; - details?: Record; +// Admin router types +export interface InstallationWithCounts { + id: number; + accountLogin: string; + accountType: string; + repositorySelection: string; + createdAt: Date; + updatedAt: Date; + suspendedAt: Date | null; + suspendedBy: string | null; + _count: { + repositories: number; + tasks: number; + }; +} + +export interface InstallationRepository { + id: number; + name: string; + fullName: string; + owner: string; + private: boolean; + htmlUrl: string; + description: string | null; + addedAt: Date; +} + +export interface InstallationTask { + id: number; + githubIssueNumber: bigint; + repoOwner: string; + repoName: string; + flaggedForRetry: boolean; + retryCount: number; + createdAt: Date; + updatedAt: Date; } diff --git a/src/types/github.ts b/src/types/github.ts index 34438fc..45efed4 100644 --- a/src/types/github.ts +++ b/src/types/github.ts @@ -26,26 +26,78 @@ export interface GitHubLabelEvent { }; } +// GitHub webhook payload interfaces +export interface GitHubAccount { + id: number; + login: string; + type: string; +} + +export 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 }; +} + +export interface GitHubWebhookRepository { + id: number; + name: string; + full_name: string; + owner?: GitHubAccount; // Optional for installation webhooks + private: boolean; + html_url?: string; // Optional for installation webhooks + description?: string; +} + +export interface GitHubInstallationEvent { + action: string; + installation: GitHubInstallation; + repositories?: GitHubWebhookRepository[]; +} + +export interface GitHubInstallationRepositoriesEvent { + action: string; + installation: GitHubInstallation; + repositories_added?: GitHubWebhookRepository[]; + repositories_removed?: GitHubWebhookRepository[]; +} + +export interface GitHubLabel { + name: string; +} + +export interface GitHubUser { + login: string; +} + +export interface GitHubIssue { + number: number; + state: string; + labels: GitHubLabel[]; +} + +export interface GitHubWebhookComment { + user: GitHubUser; +} + +export interface GitHubIssueCommentEvent { + action: string; + issue: GitHubIssue; + comment: GitHubWebhookComment; + repository: GitHubWebhookRepository; + installation?: { id: number }; +} + export interface GitHubWebhookEvent { action: string; - issue?: { - id: number; - number: number; - state: string; - labels?: Array<{ name: string }>; - }; - repository: { - id: number; - name: string; - full_name: string; - owner: { - login: string; - }; - }; - sender: { - login: string; - type: string; - }; + installation?: { id: number }; + [key: string]: unknown; } // GitHub API response types From 676c2924fd1513a553c20c97110544a3cfaae535 Mon Sep 17 00:00:00 2001 From: iHildy Date: Fri, 15 Aug 2025 17:56:16 -0500 Subject: [PATCH 04/10] Added gobal error boundries / added seo metadata / styled not-found page / synced loading states / removed use client from files that didnt need / added interfaces to health route --- src/app/api/health/route.ts | 29 ++++++++--- src/app/error.tsx | 67 ++++++++++++++++++++++++ src/app/github-app/label-setup/page.tsx | 10 +--- src/app/github-app/layout.tsx | 18 +++++++ src/app/github-app/limbo/page.tsx | 9 +--- src/app/github-app/loading.tsx | 5 ++ src/app/github-app/success/page.tsx | 10 +--- src/app/global-error.tsx | 69 +++++++++++++++++++++++++ src/app/loading.tsx | 5 ++ src/app/not-found.tsx | 62 +++++++++++++++++++--- src/components/ui/page-loading.tsx | 13 +++++ 11 files changed, 261 insertions(+), 36 deletions(-) create mode 100644 src/app/error.tsx create mode 100644 src/app/github-app/layout.tsx create mode 100644 src/app/github-app/loading.tsx create mode 100644 src/app/global-error.tsx create mode 100644 src/app/loading.tsx create mode 100644 src/components/ui/page-loading.tsx 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/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. +

+
+ +
+ + +
+ + {process.env.NODE_ENV === "development" && ( +
+ + Error details (development only) + +
+              {error.message}
+              {error.stack && `\n\n${error.stack}`}
+            
+
+ )} +
+
+ ); +} diff --git a/src/app/github-app/label-setup/page.tsx b/src/app/github-app/label-setup/page.tsx index 34a4b9a..db969c1 100644 --- a/src/app/github-app/label-setup/page.tsx +++ b/src/app/github-app/label-setup/page.tsx @@ -1,7 +1,7 @@ "use client"; import { LabelSetupHandler } from "@/components/label-setup"; -import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { PageLoading } from "@/components/ui/page-loading"; import { Suspense } from "react"; function LabelSetupContent() { @@ -10,13 +10,7 @@ function LabelSetupContent() { export default function GitHubAppLabelSetupPage() { return ( - - - - } - > + }> ); diff --git a/src/app/github-app/layout.tsx b/src/app/github-app/layout.tsx new file mode 100644 index 0000000..950824a --- /dev/null +++ b/src/app/github-app/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "GitHub App Setup | Jules Task Queue", + description: "Setting up your GitHub App installation for Jules Task Queue", + robots: { + index: false, + follow: false, + }, +}; + +export default function GitHubAppLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/src/app/github-app/limbo/page.tsx b/src/app/github-app/limbo/page.tsx index 1f615ab..509a28f 100644 --- a/src/app/github-app/limbo/page.tsx +++ b/src/app/github-app/limbo/page.tsx @@ -2,6 +2,7 @@ import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { PageLoading } from "@/components/ui/page-loading"; import { ExternalLink, Home, RefreshCw, Star } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useCallback, useState } from "react"; @@ -171,13 +172,7 @@ function LimboPageContent() { export default function LimboPage() { return ( - -
Loading...
- - } - > + }> ); diff --git a/src/app/github-app/loading.tsx b/src/app/github-app/loading.tsx new file mode 100644 index 0000000..b63f8a5 --- /dev/null +++ b/src/app/github-app/loading.tsx @@ -0,0 +1,5 @@ +import { PageLoading } from "@/components/ui/page-loading"; + +export default function GitHubAppLoading() { + return ; +} diff --git a/src/app/github-app/success/page.tsx b/src/app/github-app/success/page.tsx index e98f54c..0d1b7a1 100644 --- a/src/app/github-app/success/page.tsx +++ b/src/app/github-app/success/page.tsx @@ -1,7 +1,7 @@ "use client"; import { InstallationStatusHandler } from "@/components/success"; -import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { PageLoading } from "@/components/ui/page-loading"; import { Suspense } from "react"; function SuccessContent() { @@ -10,13 +10,7 @@ function SuccessContent() { export default function GitHubAppSuccessPage() { return ( - - - - } - > + }> ); diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 0000000..46188d4 --- /dev/null +++ b/src/app/global-error.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { AlertTriangle, Home, RefreshCw } from "lucide-react"; +import { useEffect } from "react"; + +interface GlobalErrorProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function GlobalError({ error, reset }: GlobalErrorProps) { + useEffect(() => { + console.error("Global error:", error); + }, [error]); + + return ( + + +
+
+
+ +
+
+ +
+

+ Something went wrong! +

+

+ An unexpected error occurred. Our team has been notified. +

+
+ +
+ + +
+ + {process.env.NODE_ENV === "development" && ( +
+ + Error details + +
+                {error.message}
+                {error.stack && `\n\n${error.stack}`}
+              
+
+ )} +
+ + + ); +} diff --git a/src/app/loading.tsx b/src/app/loading.tsx new file mode 100644 index 0000000..154251a --- /dev/null +++ b/src/app/loading.tsx @@ -0,0 +1,5 @@ +import { PageLoading } from "@/components/ui/page-loading"; + +export default function Loading() { + return ; +} diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 66f8bc4..6c001b8 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,13 +1,63 @@ import Link from "next/link"; +import type { Metadata } from "next"; +import { Button } from "@/components/ui/button"; +import { Home, Search } from "lucide-react"; + +export const metadata: Metadata = { + title: "Page Not Found | Jules Task Queue", + description: + "The page you're looking for doesn't exist. Return to the Jules Task Queue homepage.", + robots: { + index: false, + follow: false, + }, +}; export default function NotFound() { return ( -
-

Not Found

-

Could not find requested resource

-

- View home page -

+
+
+
+
+ +
+
+ +
+

Page Not Found

+

+ The page you're looking for doesn't exist or has been + moved. +

+
+ +
+ +
+ +
+

+ If you believe this is an error, please{" "} + + report it on GitHub + + . +

+
+
); } diff --git a/src/components/ui/page-loading.tsx b/src/components/ui/page-loading.tsx new file mode 100644 index 0000000..db7c78d --- /dev/null +++ b/src/components/ui/page-loading.tsx @@ -0,0 +1,13 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; + +interface PageLoadingProps { + text?: string; +} + +export function PageLoading({ text = "Loading..." }: PageLoadingProps) { + return ( +
+ +
+ ); +} From e2f811639c32bcd133d9b1e37056bd2cd2a15967 Mon Sep 17 00:00:00 2001 From: iHildy Date: Fri, 15 Aug 2025 18:22:23 -0500 Subject: [PATCH 05/10] Refactor LabelSetupHandler to use useReducer for state management, improving clarity and maintainability. Removed unused interfaces and centralized repository selection logic. Updated error handling and loading states for better user experience. --- .../label-setup/label-setup-handler.tsx | 210 ++++++++++-------- .../repository-selection-modal.tsx | 43 ++-- .../landing/github-install-button.tsx | 12 +- src/components/landing/hero-section.tsx | 8 +- src/components/success/error-state.tsx | 17 +- .../success/installation-status-handler.tsx | 10 +- src/components/success/success-state.tsx | 19 +- src/components/success/unknown-status.tsx | 2 +- src/components/ui/error-boundary.tsx | 71 ++++++ src/components/ui/loading-spinner.tsx | 25 ++- src/components/ui/page-loading.tsx | 16 +- src/types/components.ts | 134 +++++++++++ src/types/index.ts | 3 + 13 files changed, 402 insertions(+), 168 deletions(-) create mode 100644 src/components/ui/error-boundary.tsx create mode 100644 src/types/components.ts diff --git a/src/components/label-setup/label-setup-handler.tsx b/src/components/label-setup/label-setup-handler.tsx index d331b91..49f6fdb 100644 --- a/src/components/label-setup/label-setup-handler.tsx +++ b/src/components/label-setup/label-setup-handler.tsx @@ -18,24 +18,13 @@ import { Zap, } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useReducer, useMemo, useState } from "react"; import { RepositorySelectionModal } from "./repository-selection-modal"; - -interface Repository { - id: number; - name: string; - fullName: string; - private: boolean; - description?: string; -} - -interface SetupOption { - id: "all" | "selected" | "manual"; - title: string; - description: string; - icon: React.ReactNode; - recommended?: boolean; -} +import type { + SetupOption, + LabelSetupState, + LabelSetupAction, +} from "@/types/components"; const SETUP_OPTIONS: SetupOption[] = [ { @@ -62,32 +51,58 @@ const SETUP_OPTIONS: SetupOption[] = [ }, ]; -export function LabelSetupHandler() { +function labelSetupReducer( + state: LabelSetupState, + action: LabelSetupAction, +): LabelSetupState { + switch (action.type) { + case "SET_LOADING": + return { ...state, isLoading: action.payload }; + case "SET_ERROR": + return { ...state, error: action.payload }; + case "SET_REPOSITORIES": + return { ...state, repositories: action.payload }; + case "SET_SELECTED_OPTION": + return { ...state, selectedOption: action.payload }; + case "SET_SELECTED_REPOS": + return { ...state, selectedRepos: action.payload }; + case "SET_MODAL_OPEN": + return { ...state, isModalOpen: action.payload }; + case "SET_PROCESSING": + return { ...state, isProcessing: action.payload }; + default: + return state; + } +} + +const initialState: LabelSetupState = { + isLoading: true, + isProcessing: false, + error: null, + repositories: [], + selectedOption: null, + selectedRepos: new Set(), + isModalOpen: false, +}; + +export function LabelSetupHandler(): React.JSX.Element { const searchParams = useSearchParams(); const router = useRouter(); + const [state, dispatch] = useReducer(labelSetupReducer, initialState); const [installationId, setInstallationId] = useState(null); - const [repositories, setRepositories] = useState([]); - const [selectedOption, setSelectedOption] = useState< - "all" | "selected" | "manual" | null - >(null); - const [selectedRepos, setSelectedRepos] = useState>(new Set()); - const [isModalOpen, setIsModalOpen] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [isProcessing, setIsProcessing] = useState(false); - const [error, setError] = useState(null); useEffect(() => { const id = searchParams.get("installation_id"); if (!id) { - setError("Missing installation ID"); - setIsLoading(false); + dispatch({ type: "SET_ERROR", payload: "Missing installation ID" }); + dispatch({ type: "SET_LOADING", payload: false }); return; } setInstallationId(id); fetchRepositories(id); }, [searchParams]); - const fetchRepositories = async (installationId: string) => { + const fetchRepositories = async (installationId: string): Promise => { try { const response = await fetch( `/api/github-app/installations/${installationId}/repositories`, @@ -98,27 +113,32 @@ export function LabelSetupHandler() { } const data = await response.json(); - setRepositories(data.repositories || []); + dispatch({ type: "SET_REPOSITORIES", payload: data.repositories || [] }); } catch (error) { console.error("Failed to fetch repositories:", error); - setError( - error instanceof Error ? error.message : "Failed to load repositories", - ); + dispatch({ + type: "SET_ERROR", + payload: + error instanceof Error + ? error.message + : "Failed to load repositories", + }); } finally { - setIsLoading(false); + dispatch({ type: "SET_LOADING", payload: false }); } }; - const handleOptionSelect = (option: "all" | "selected" | "manual") => { - setSelectedOption(option); + const handleOptionSelect = (option: "all" | "selected" | "manual"): void => { + dispatch({ type: "SET_SELECTED_OPTION", payload: option }); if (option === "all") { - setSelectedRepos(new Set(repositories.map((repo) => repo.id))); + const allRepoIds = new Set(state.repositories.map((repo) => repo.id)); + dispatch({ type: "SET_SELECTED_REPOS", payload: allRepoIds }); } else if (option === "manual") { - setSelectedRepos(new Set()); + dispatch({ type: "SET_SELECTED_REPOS", payload: new Set() }); } else if (option === "selected") { // Keep current selection or open modal - if (selectedRepos.size === 0) { - setIsModalOpen(true); + if (state.selectedRepos.size === 0) { + dispatch({ type: "SET_MODAL_OPEN", payload: true }); } } }; @@ -126,33 +146,43 @@ export function LabelSetupHandler() { const handleRepositorySelectionChange = ( repoId: number, selected: boolean, - ) => { - const newSelected = new Set(selectedRepos); + ): void => { + const newSelected = new Set(state.selectedRepos); if (selected) { newSelected.add(repoId); } else { newSelected.delete(repoId); } - setSelectedRepos(newSelected); + dispatch({ type: "SET_SELECTED_REPOS", payload: newSelected }); }; - const handleSelectAll = () => { - setSelectedRepos(new Set(repositories.map((repo) => repo.id))); + const handleSelectAll = (): void => { + const allRepoIds = new Set(state.repositories.map((repo) => repo.id)); + dispatch({ type: "SET_SELECTED_REPOS", payload: allRepoIds }); }; - const handleClearAll = () => { - setSelectedRepos(new Set()); + const handleClearAll = (): void => { + dispatch({ type: "SET_SELECTED_REPOS", payload: new Set() }); }; - const handleContinue = async () => { - if (!selectedOption || !installationId) return; + const canContinue = useMemo( + () => + state.selectedOption && + (state.selectedOption === "manual" || + state.selectedOption === "all" || + (state.selectedOption === "selected" && state.selectedRepos.size > 0)), + [state.selectedOption, state.selectedRepos.size], + ); + + const handleContinue = async (): Promise => { + if (!state.selectedOption || !installationId) return; - setIsProcessing(true); + dispatch({ type: "SET_PROCESSING", payload: true }); try { const repositoryIds = - selectedOption === "all" - ? repositories.map((repo) => repo.id) - : Array.from(selectedRepos); + state.selectedOption === "all" + ? state.repositories.map((repo) => repo.id) + : Array.from(state.selectedRepos); const response = await fetch("/api/github-app/label-setup", { method: "POST", @@ -161,7 +191,7 @@ export function LabelSetupHandler() { }, body: JSON.stringify({ installationId: parseInt(installationId), - setupType: selectedOption, + setupType: state.selectedOption, repositoryIds: repositoryIds.length > 0 ? repositoryIds : undefined, }), }); @@ -199,17 +229,19 @@ export function LabelSetupHandler() { router.push(`/github-app/success?${params.toString()}`); } catch (error) { console.error("Failed to setup labels:", error); - setError( - error instanceof Error - ? error.message - : "Failed to setup labels. Please try again.", - ); + dispatch({ + type: "SET_ERROR", + payload: + error instanceof Error + ? error.message + : "Failed to setup labels. Please try again.", + }); } finally { - setIsProcessing(false); + dispatch({ type: "SET_PROCESSING", payload: false }); } }; - if (isLoading) { + if (state.isLoading) { return (
@@ -231,7 +263,7 @@ export function LabelSetupHandler() { ); } - if (error) { + if (state.error) { return (
@@ -239,7 +271,7 @@ export function LabelSetupHandler() { - {error} + {state.error}
@@ -258,12 +290,6 @@ export function LabelSetupHandler() { ); } - const canContinue = - selectedOption && - (selectedOption === "manual" || - selectedOption === "all" || - (selectedOption === "selected" && selectedRepos.size > 0)); - return ( <>
@@ -327,7 +353,7 @@ export function LabelSetupHandler() {
{/* Repository count for selected option */} - {selectedOption === option.id && + {state.selectedOption === option.id && option.id === "selected" && (
- {selectedRepos.size > 0 && ( + {state.selectedRepos.size > 0 && ( - {selectedRepos.size} selected + {state.selectedRepos.size} selected )}
)} - {selectedOption === option.id && option.id === "all" && ( -
- - {repositories.length} repositories - -
- )} + {state.selectedOption === option.id && + option.id === "all" && ( +
+ + {state.repositories.length} repositories + +
+ )}
- {selectedOption === option.id && ( + {state.selectedOption === option.id && (
@@ -421,11 +451,11 @@ export function LabelSetupHandler() { @@ -94,7 +96,7 @@ export function HeroSection() {
@@ -316,7 +318,7 @@ firebase deploy --only functions`} including database configuration and GitHub webhooks are available in the{" "}