Open Source Team Metrics based on PRs

essentially a rewrite

Changed files
+2308 -1782
app
api
demo-status
github
organizations
[orgName]
repositories
installation-status
user
github-app
installations
[installationId]
status
health
webhook
github
components
lib
+4 -4
.env.example
··· 1 1 NEXTAUTH_SECRET= 2 - GITHUB_CLIENT_ID= 3 - GITHUB_CLIENT_SECRET= 2 + GITHUB_OAUTH_CLIENT_ID= 3 + GITHUB_OAUTH_CLIENT_SECRET= 4 4 GITHUB_WEBHOOK_SECRET= 5 5 TURSO_TOKEN= 6 6 NEXT_PUBLIC_GITHUB_APP_SLUG= 7 7 GITHUB_APP_ID= 8 8 GITHUB_APP_PRIVATE_KEY= 9 - APP_URL= 10 - NEXTAUTH_URL= 9 + APP_URL=http://localhost:3000 10 + NEXTAUTH_URL=http://localhost:3000 11 11 TURSO_URL=
+2 -1
.gitignore
··· 39 39 # typescript 40 40 *.tsbuildinfo 41 41 next-env.d.ts 42 - envbackup.txt 42 + envbackup.txt 43 + refactor.md
-554
ARCHITECTURE_REFACTOR.md
··· 1 - # Architecture Refactoring Plan: From Conditional Spaghetti to Clean Architecture 2 - 3 - ## Problem Statement 4 - 5 - The current codebase has conditional logic scattered throughout, checking for demo mode at multiple levels: 6 - - API routes checking environment variables 7 - - Components handling different data sources 8 - - Auth system with conditional mock data 9 - - Database queries with fallbacks 10 - 11 - This violates the **Single Responsibility Principle** and **Dependency Inversion Principle**, making the code: 12 - - Hard to maintain 13 - - Prone to bugs 14 - - Difficult to test 15 - - Inconsistent in behavior 16 - 17 - ## Proposed Solution: Hexagonal Architecture (Ports & Adapters) 18 - 19 - ### Why Hexagonal Architecture? 20 - 21 - 1. **Clear separation of concerns**: Business logic is isolated from infrastructure 22 - 2. **Testability**: Easy to test with mock adapters 23 - 3. **Flexibility**: Switch between implementations without changing core logic 24 - 4. **Consistency**: Same code paths for all modes 25 - 26 - ## Architecture Overview 27 - 28 - ``` 29 - ┌─────────────────────────────────────────────────────────┐ 30 - │ Presentation Layer │ 31 - │ (React Components, API Routes) │ 32 - └─────────────────────────────────────────────────────────┘ 33 - 34 - 35 - ┌─────────────────────────────────────────────────────────┐ 36 - │ Application Layer │ 37 - │ (Use Cases/Services) │ 38 - └─────────────────────────────────────────────────────────┘ 39 - 40 - 41 - ┌─────────────────────────────────────────────────────────┐ 42 - │ Domain Layer │ 43 - │ (Business Logic, Entities, Ports) │ 44 - └─────────────────────────────────────────────────────────┘ 45 - 46 - 47 - ┌─────────────────────────────────────────────────────────┐ 48 - │ Infrastructure Layer │ 49 - │ (Adapters) │ 50 - │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ 51 - │ │ Demo │ │ Turso │ │ GitHub │ │ 52 - │ │ Adapter │ │ Adapter │ │ Adapter │ │ 53 - │ └────────────┘ └────────────┘ └────────────┘ │ 54 - └─────────────────────────────────────────────────────────┘ 55 - ``` 56 - 57 - ## Implementation Plan 58 - 59 - ### Phase 1: Define Core Interfaces (Ports) 60 - 61 - Create interfaces that define what our application needs, regardless of implementation: 62 - 63 - ```typescript 64 - // lib/core/ports/repository.port.ts 65 - export interface IPullRequestRepository { 66 - getRecent(orgId: string, limit?: number): Promise<PullRequest[]> 67 - getByCategory(orgId: string, timeRange?: TimeRange): Promise<CategoryDistribution> 68 - getTimeSeries(orgId: string, days: number): Promise<TimeSeriesData> 69 - } 70 - 71 - // lib/core/ports/metrics.port.ts 72 - export interface IMetricsService { 73 - getSummary(orgId: string): Promise<MetricsSummary> 74 - getRecommendations(orgId: string): Promise<Recommendations> 75 - getTeamPerformance(orgId: string): Promise<TeamPerformance> 76 - } 77 - 78 - // lib/core/ports/auth.port.ts 79 - export interface IAuthService { 80 - getSession(): Promise<Session | null> 81 - getUserOrganizations(userId: string): Promise<Organization[]> 82 - } 83 - ``` 84 - 85 - ### Phase 2: Implement Adapters 86 - 87 - Create concrete implementations for each environment: 88 - 89 - ```typescript 90 - // lib/infrastructure/adapters/demo/pull-request.adapter.ts 91 - export class DemoPullRequestRepository implements IPullRequestRepository { 92 - private demoData = loadDemoData() 93 - 94 - async getRecent(orgId: string, limit = 10): Promise<PullRequest[]> { 95 - return this.demoData.pullRequests.slice(0, limit) 96 - } 97 - 98 - async getByCategory(orgId: string, timeRange?: TimeRange): Promise<CategoryDistribution> { 99 - return generateDemoCategoryDistribution(timeRange) 100 - } 101 - 102 - async getTimeSeries(orgId: string, days: number): Promise<TimeSeriesData> { 103 - return generateDemoTimeSeries(days) 104 - } 105 - } 106 - 107 - // lib/infrastructure/adapters/turso/pull-request.adapter.ts 108 - export class TursoPullRequestRepository implements IPullRequestRepository { 109 - constructor(private db: TursoClient) {} 110 - 111 - async getRecent(orgId: string, limit = 10): Promise<PullRequest[]> { 112 - return this.db.query(`SELECT * FROM pull_requests WHERE org_id = ? LIMIT ?`, [orgId, limit]) 113 - } 114 - 115 - async getByCategory(orgId: string, timeRange?: TimeRange): Promise<CategoryDistribution> { 116 - // Real database query 117 - } 118 - 119 - async getTimeSeries(orgId: string, days: number): Promise<TimeSeriesData> { 120 - // Real database query 121 - } 122 - } 123 - ``` 124 - 125 - ### Phase 3: Dependency Injection Container 126 - 127 - Create a container that provides the right implementations based on configuration: 128 - 129 - ```typescript 130 - // lib/core/container.ts 131 - export class DIContainer { 132 - private static instance: DIContainer 133 - private services: Map<string, any> = new Map() 134 - 135 - static getInstance(): DIContainer { 136 - if (!DIContainer.instance) { 137 - DIContainer.instance = new DIContainer() 138 - DIContainer.instance.initialize() 139 - } 140 - return DIContainer.instance 141 - } 142 - 143 - private initialize() { 144 - const config = this.loadConfiguration() 145 - 146 - // Register services based on configuration 147 - if (config.isDemoMode) { 148 - this.registerDemoServices() 149 - } else { 150 - this.registerProductionServices() 151 - } 152 - } 153 - 154 - private registerDemoServices() { 155 - this.services.set('PullRequestRepository', new DemoPullRequestRepository()) 156 - this.services.set('MetricsService', new DemoMetricsService()) 157 - this.services.set('AuthService', new DemoAuthService()) 158 - } 159 - 160 - private registerProductionServices() { 161 - const db = new TursoClient(process.env.TURSO_URL!, process.env.TURSO_TOKEN!) 162 - this.services.set('PullRequestRepository', new TursoPullRequestRepository(db)) 163 - this.services.set('MetricsService', new TursoMetricsService(db)) 164 - this.services.set('AuthService', new GitHubAuthService()) 165 - } 166 - 167 - get<T>(serviceName: string): T { 168 - return this.services.get(serviceName) 169 - } 170 - } 171 - ``` 172 - 173 - ### Phase 4: Simplify API Routes 174 - 175 - API routes become thin controllers that delegate to services: 176 - 177 - ```typescript 178 - // app/api/pull-requests/recent/route.ts 179 - import { DIContainer } from '@/lib/core/container' 180 - import { IPullRequestRepository } from '@/lib/core/ports' 181 - 182 - export async function GET(request: NextRequest) { 183 - const container = DIContainer.getInstance() 184 - const prRepository = container.get<IPullRequestRepository>('PullRequestRepository') 185 - const authService = container.get<IAuthService>('AuthService') 186 - 187 - try { 188 - const session = await authService.getSession() 189 - if (!session) { 190 - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 191 - } 192 - 193 - const data = await prRepository.getRecent(session.orgId) 194 - return NextResponse.json(data) 195 - } catch (error) { 196 - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) 197 - } 198 - } 199 - ``` 200 - 201 - ### Phase 5: Update Components 202 - 203 - Components don't need to know about demo mode: 204 - 205 - ```typescript 206 - // components/actionable-recommendations.tsx 207 - export function ActionableRecommendations() { 208 - const [recommendations, setRecommendations] = useState<Recommendations | null>(null) 209 - 210 - useEffect(() => { 211 - // Just fetch data - no knowledge of demo vs production 212 - fetch('/api/metrics/recommendations') 213 - .then(res => res.json()) 214 - .then(setRecommendations) 215 - .catch(console.error) 216 - }, []) 217 - 218 - // Same rendering logic for all modes 219 - return <div>{/* ... */}</div> 220 - } 221 - ``` 222 - 223 - ## Migration Strategy ✅ **COMPLETED** 224 - 225 - ### Step 1: Create Port Interfaces ✅ **COMPLETED** 226 - - ✅ Define all port interfaces (`IPullRequestRepository`, `IMetricsService`, `IAuthService`, etc.) 227 - - ✅ Document expected data structures (Domain entities with proper TypeScript types) 228 - - ✅ Create TypeScript types for all entities (`PullRequest`, `Organization`, `User`, etc.) 229 - 230 - ### Step 2: Implement Demo Adapters ✅ **COMPLETED** 231 - - ✅ Create demo adapter for each port (`DemoPullRequestRepository`, `DemoMetricsService`, etc.) 232 - - ✅ Move existing demo data generation to adapters (Clean separation from business logic) 233 - - ✅ Test demo adapters in isolation (Functional demo mode verified) 234 - 235 - ### Step 3: Implement Production Adapters ⚠️ **PARTIALLY COMPLETED** 236 - - ✅ Create Turso database adapter (Full production database integration implemented) 237 - - ⚠️ Create GitHub API adapter (Demo wrapper implemented, real GitHub API integration needed) 238 - - ✅ Create NextAuth adapter (Production auth service with proper database integration) 239 - 240 - ### Step 4: Build DI Container ✅ **COMPLETED** 241 - - ✅ Implement container with service registration (`DIContainer` with environment-based service registration) 242 - - ✅ Add configuration loading (`EnvironmentConfig` singleton with feature detection) 243 - - ✅ Add environment detection (Automatic demo vs production mode detection) 244 - 245 - ### Step 5: Refactor API Routes ✅ **COMPLETED** + **ENHANCED** 246 - - ✅ Update each API route to use DI container (`ServiceLocator` pattern implemented) 247 - - ✅ Remove all conditional logic (Clean, single-path execution) 248 - - ✅ Add consistent error handling (Enterprise-grade error boundaries) 249 - - 🚀 **BONUS**: Implemented advanced authentication middleware architecture 250 - - ✅ `withAuth` middleware for authenticated routes 251 - - ✅ `withOptionalAuth` middleware for flexible authentication 252 - - ✅ `ApplicationContext` for clean dependency injection 253 - - ✅ Migrated all API routes to new authentication pattern 254 - 255 - ### Step 6: Clean Up Components ✅ **COMPLETED** 256 - - ✅ Remove demo mode checks from components (Components now mode-agnostic) 257 - - ✅ Ensure consistent data fetching (All components use standard API calls) 258 - - ✅ Update error boundaries (Robust error handling implemented) 259 - - 🧹 **BONUS**: Complete legacy code cleanup 260 - - ✅ Deleted 5 obsolete files (`demo-mode.ts`, `demo-fallback.ts`, etc.) 261 - - ✅ Removed ~400+ lines of conditional logic 262 - - ✅ Updated all files to use clean `EnvironmentConfig` pattern 263 - 264 - ### Step 7: Testing & Documentation ⚠️ **IN PROGRESS** 265 - - ✅ Architecture documentation updated (This file) 266 - - ✅ Build verification completed (Clean TypeScript compilation) 267 - - ✅ Demo mode functional testing completed 268 - - [ ] Unit tests for adapters (Recommended for production readiness) 269 - - [ ] Integration tests for API routes (Recommended for production readiness) 270 - - ✅ Update documentation (Architecture docs updated, implementation complete) 271 - 272 - ## 🎉 **REFACTORING COMPLETE - STATUS REPORT** 273 - 274 - ### **📊 Final Results** 275 - 276 - **Original Goals vs Achieved:** 277 - - ✅ **Single code path**: Achieved - Zero conditional demo logic in components/routes 278 - - ✅ **Clean separation**: Achieved - Perfect hexagonal architecture implementation 279 - - ✅ **Maintainability**: Achieved - 50% reduction in code complexity 280 - - ✅ **Type safety**: Achieved - Full TypeScript coverage with proper interfaces 281 - - 🚀 **EXCEEDED**: Added enterprise-grade authentication architecture 282 - 283 - ### **📈 Measurable Improvements** 284 - 285 - | Metric | Before | After | Improvement | 286 - |--------|--------|--------|-------------| 287 - | **Conditional Logic** | ~50+ scattered checks | 0 in business logic | ✅ **100% elimination** | 288 - | **Code Duplication** | High (demo vs prod paths) | Zero (single adapters) | ✅ **70%+ reduction** | 289 - | **Files with Demo Conditionals** | 15+ files | 2 files (auth only) | ✅ **87% reduction** | 290 - | **Lines of Legacy Code** | ~400+ lines | 0 lines | ✅ **100% cleanup** | 291 - | **Build Success** | ✅ Clean | ✅ Clean | ✅ **Maintained** | 292 - | **Architecture Compliance** | 30% hexagonal | 100% hexagonal | ✅ **Enterprise-grade** | 293 - 294 - ### **🏗️ Architecture Accomplishments** 295 - 296 - **Core Implementation:** 297 - 1. ✅ **Hexagonal Architecture** - Complete ports & adapters pattern 298 - 2. ✅ **Dependency Injection** - Full DI container with service locator 299 - 3. ✅ **Domain-Driven Design** - Proper domain entities matching business needs 300 - 4. ✅ **Environment Configuration** - Clean, centralized config management 301 - 5. ✅ **Adapter Pattern** - Seamless demo ↔ production switching 302 - 303 - **Advanced Features:** 304 - 1. 🚀 **Authentication Middleware** - `withAuth`/`withOptionalAuth` pattern 305 - 2. 🚀 **Application Context** - Clean context passing for authenticated operations 306 - 3. 🚀 **Service Locator** - Type-safe service access throughout application 307 - 4. 🚀 **Legacy Code Elimination** - Complete removal of technical debt 308 - 5. 🚀 **Enterprise Patterns** - Production-ready architectural patterns 309 - 310 - ### **🎯 Beyond Original Scope** 311 - 312 - **Major enhancements delivered:** 313 - - **Authentication Architecture Revolution** - Moved from scattered auth checks to clean middleware pattern 314 - - **Domain Entity Accuracy** - Fixed DDD implementation to match actual business needs 315 - - **Complete Legacy Cleanup** - Systematic removal of all obsolete conditional logic 316 - - **Build System Integrity** - Maintained clean TypeScript compilation throughout 317 - 318 - ### **📋 Production Readiness Status** 319 - 320 - | Component | Status | Production Score | Notes | 321 - |-----------|---------|------------------|-------| 322 - | **Core Architecture** | ✅ **Production Ready** | 100% | Full hexagonal implementation | 323 - | **Database Layer (Turso)** | ✅ **Production Ready** | 100% | Real SQL queries, proper joins, full CRUD | 324 - | **Authentication** | ✅ **Production Ready** | 100% | NextAuth + database integration | 325 - | **Demo Mode** | ✅ **Production Ready** | 100% | Zero-config deployment verified | 326 - | **API Routes & Middleware** | ✅ **Production Ready** | 100% | All migrated to new auth pattern | 327 - | **UI Components** | ✅ **Production Ready** | 100% | Mode-agnostic, clean interfaces | 328 - | **Build System** | ✅ **Production Ready** | 100% | Clean TypeScript compilation | 329 - | **Documentation** | ✅ **Production Ready** | 100% | Architecture docs complete | 330 - | **GitHub API Integration** | ❌ **Demo Data Only** | 0% | Still using demo adapter wrapper | 331 - | **Webhook Processing** | ❌ **Demo Data Only** | 0% | No real GitHub webhook handling | 332 - | **Testing** | ⚠️ **Recommended** | 30% | Basic architecture, needs comprehensive tests | 333 - 334 - **🎯 Overall Production Score: 75%** 335 - 336 - ### **🔍 What Works Today in Production** 337 - 338 - With proper environment variables configured: 339 - ```bash 340 - # Required for production mode 341 - TURSO_URL=your-turso-database-url 342 - TURSO_TOKEN=your-turso-auth-token 343 - GITHUB_CLIENT_ID=your-github-oauth-app-id 344 - GITHUB_CLIENT_SECRET=your-github-oauth-secret 345 - NEXTAUTH_SECRET=your-nextauth-secret-key 346 - ``` 347 - 348 - **✅ Fully Functional:** 349 - - User authentication & session management 350 - - Organization & team management 351 - - Database persistence & queries 352 - - Dashboard analytics (with real stored data) 353 - - Clean architecture patterns 354 - - Mode-agnostic UI components 355 - 356 - **❌ Still Using Demo Data:** 357 - - Pull request data from GitHub 358 - - Repository information from GitHub 359 - - GitHub webhook processing 360 - - Real-time GitHub API synchronization 361 - 362 - ### **🛠️ Critical Next Steps (Complete Production Readiness)** 363 - 364 - **Priority 1: Real GitHub API Integration** 🎯 365 - 1. **Implement `RealGitHubAPIService`** - Replace demo wrapper with actual GitHub API calls 366 - ```typescript 367 - // lib/infrastructure/adapters/github/real-github.adapter.ts 368 - export class RealGitHubAPIService implements IGitHubService { 369 - private octokit: Octokit 370 - 371 - async getRepositoryPullRequests(owner: string, repo: string) { 372 - const response = await this.octokit.pulls.list({ 373 - owner, repo, state: 'all', per_page: 100 374 - }) 375 - return response.data.map(mapGitHubPRToDomain) 376 - } 377 - 378 - async processWebhookEvent(event: string, payload: any) { 379 - // Real webhook processing logic 380 - } 381 - // ... other real implementations 382 - } 383 - ``` 384 - 385 - 2. **Add Octokit Integration** - Install and configure GitHub API client 386 - 3. **Map GitHub Data to Domain Entities** - Transform GitHub API responses to domain objects 387 - 4. **Implement Real Webhook Processing** - Handle actual GitHub webhook events 388 - 5. **Add GitHub App Authentication** - Use GitHub App credentials for API access 389 - 390 - **Priority 2: Production Hardening** 🔧 391 - 1. **Unit Testing** - Add adapter-level unit tests for business logic validation 392 - 2. **Integration Testing** - Add API route integration tests for contract verification 393 - 3. **Performance Testing** - Benchmark adapter performance under load 394 - 4. **Monitoring** - Add observability for adapter switching and performance 395 - 5. **Error Handling** - Robust error handling for GitHub API rate limits & failures 396 - 397 - **Priority 3: Optional Enhancements** 🚀 398 - 1. **Additional Adapters** - PostgreSQL, Redis, or other data sources as needed 399 - 2. **Caching Layer** - Add Redis caching for GitHub API responses 400 - 3. **Background Jobs** - Queue system for webhook processing and data sync 401 - 4. **Metrics & Monitoring** - Application performance monitoring and alerting 402 - 403 - ### **📝 Deployment Guide** 404 - 405 - **To deploy 75% functional production app today:** 406 - 1. Configure environment variables (Turso DB + GitHub OAuth) 407 - 2. Deploy to your preferred platform (Vercel, Railway, etc.) 408 - 3. **Result**: Fully functional app with real auth, database, and demo data for GitHub features 409 - 410 - **To achieve 100% production readiness:** 411 - 1. Complete steps above 412 - 2. Implement `RealGitHubAPIService` (Priority 1 tasks) 413 - 3. **Result**: Fully functional app with real GitHub data integration 414 - 415 - **Current Status: ✅ ARCHITECTURE REFACTORING COMPLETE + ⚠️ GITHUB API INTEGRATION NEEDED** 416 - - **All planned hexagonal architecture phases completed** 417 - - **Major authentication enhancements delivered beyond scope** 418 - - **Zero technical debt remaining** 419 - - **75% production ready** - Database, auth, and architecture fully functional 420 - - **25% remaining** - Real GitHub API integration needed for 100% production readiness 421 - 422 - ## Benefits After Refactoring ✅ **DELIVERED** 423 - 424 - 1. **Single code path**: No more conditional logic scattered throughout 425 - 2. **Easy testing**: Mock adapters for unit tests 426 - 3. **Clear boundaries**: Business logic separated from infrastructure 427 - 4. **Extensibility**: Easy to add new data sources (e.g., PostgreSQL, Redis) 428 - 5. **Maintainability**: Changes to one adapter don't affect others 429 - 6. **Type safety**: Interfaces ensure consistent contracts 430 - 7. **Performance**: Can optimize each adapter independently 431 - 8. **Developer experience**: Clear where to make changes 432 - 433 - ## File Structure After Refactoring 434 - 435 - ``` 436 - lib/ 437 - ├── core/ 438 - │ ├── domain/ 439 - │ │ ├── entities/ 440 - │ │ │ ├── pull-request.ts 441 - │ │ │ ├── organization.ts 442 - │ │ │ └── user.ts 443 - │ │ └── value-objects/ 444 - │ │ ├── time-range.ts 445 - │ │ └── category.ts 446 - │ ├── ports/ 447 - │ │ ├── repository.port.ts 448 - │ │ ├── metrics.port.ts 449 - │ │ ├── auth.port.ts 450 - │ │ └── github.port.ts 451 - │ ├── services/ 452 - │ │ ├── metrics.service.ts 453 - │ │ ├── recommendation.service.ts 454 - │ │ └── team.service.ts 455 - │ └── container.ts 456 - ├── infrastructure/ 457 - │ ├── adapters/ 458 - │ │ ├── demo/ 459 - │ │ │ ├── pull-request.adapter.ts 460 - │ │ │ ├── metrics.adapter.ts 461 - │ │ │ ├── auth.adapter.ts 462 - │ │ │ └── data/ 463 - │ │ │ └── demo-data.json 464 - │ │ ├── turso/ 465 - │ │ │ ├── pull-request.adapter.ts 466 - │ │ │ ├── metrics.adapter.ts 467 - │ │ │ └── client.ts 468 - │ │ └── github/ 469 - │ │ ├── auth.adapter.ts 470 - │ │ ├── api.adapter.ts 471 - │ │ └── client.ts 472 - │ └── config/ 473 - │ ├── environment.ts 474 - │ └── container.config.ts 475 - └── shared/ 476 - ├── errors/ 477 - │ └── domain.errors.ts 478 - └── utils/ 479 - └── date.utils.ts 480 - ``` 481 - 482 - ## Success Metrics ✅ **ACHIEVED & EXCEEDED** 483 - 484 - **Target vs Actual Results:** 485 - - **Code duplication**: ✅ **Reduced by 70%+** (Single adapter pattern eliminated all demo vs prod duplication) 486 - - **Conditional statements**: ✅ **Reduced by 100%** (Zero demo conditionals in business logic - exceeded 90% target) 487 - - **Test coverage**: ⚠️ **Current state maintained** (Architecture supports 80%+ coverage with adapter mocking) 488 - - **API response time**: ✅ **Maintained** (Clean service locator pattern, no performance regression) 489 - - **Development velocity**: ✅ **Increased 50%+** (Clear patterns, no more conditional debugging - exceeded 40% target) 490 - - **Bug reports**: ✅ **Reduced 80%+** (Eliminated entire class of demo mode bugs - exceeded 60% target) 491 - 492 - **Additional Unexpected Gains:** 493 - - **Technical Debt**: ✅ **Eliminated 100%** (Complete legacy code cleanup) 494 - - **Architecture Compliance**: ✅ **100% Hexagonal** (Enterprise-grade patterns) 495 - - **Type Safety**: ✅ **100% Coverage** (Full TypeScript interface contracts) 496 - - **Authentication Quality**: ✅ **Production-grade** (Clean middleware architecture) 497 - - **Documentation**: ✅ **Complete** (Living architecture documentation) 498 - 499 - ## Risks and Mitigations ✅ **SUCCESSFULLY MITIGATED** 500 - 501 - | Risk | Original Mitigation | Actual Outcome | 502 - |------|---------------------|----------------| 503 - | Large refactoring breaking existing features | Incremental migration with feature flags | ✅ **No breaking changes** - Clean build maintained throughout | 504 - | Team learning curve for new architecture | Documentation and pair programming sessions | ✅ **Well-documented** - Clear patterns with comprehensive docs | 505 - | Performance regression | Benchmark before and after each phase | ✅ **No regression** - Clean service locator pattern maintains performance | 506 - | Increased initial complexity | Clear documentation and examples | ✅ **Reduced complexity** - Single-path execution simpler than conditionals | 507 - 508 - ## Next Steps ✅ **ORIGINAL PLAN COMPLETED** 509 - 510 - **✅ Completed Steps:** 511 - 1. ~~Review and approve this plan with the team~~ - **COMPLETED**: Plan executed successfully 512 - 2. ~~Set up feature flags for gradual rollout~~ - **NOT NEEDED**: Clean incremental migration achieved 513 - 3. ~~Create a new branch for refactoring~~ - **COMPLETED**: All changes integrated to main 514 - 4. ~~Start with Phase 1: Port interfaces~~ - **COMPLETED**: All 7 phases completed 515 - 5. ~~Daily progress reviews and adjustments~~ - **COMPLETED**: Iterative development successful 516 - 517 - **🚀 Recommended Next Steps (Optional Enhancements):** 518 - 1. **Production Hardening**: Add comprehensive unit/integration test suite 519 - 2. **Performance Optimization**: Benchmark and optimize adapter performance 520 - 3. **Monitoring**: Add observability for service switching and performance metrics 521 - 4. **Scaling**: Add additional adapters (PostgreSQL, Redis, etc.) as needed 522 - 5. **Documentation**: Create developer onboarding guide for the new architecture 523 - 524 - ## Conclusion ✅ **MISSION ACCOMPLISHED** 525 - 526 - **✅ TRANSFORMATION COMPLETE**: This refactoring **successfully transformed** the codebase from a conditional-heavy, tightly-coupled system to a **clean, enterprise-grade hexagonal architecture**. The investment in proper architecture has **delivered measurable dividends**: 527 - 528 - **🎯 Achieved Benefits:** 529 - - ✅ **Faster feature development** - 50%+ velocity increase through clear patterns 530 - - ✅ **Fewer bugs** - 80%+ reduction by eliminating conditional logic bugs 531 - - ✅ **Easier onboarding** - Clean architecture with comprehensive documentation 532 - - ✅ **Better testability** - Mock adapters enable isolated unit testing 533 - - ✅ **Clearer code ownership** - Well-defined boundaries and responsibilities 534 - 535 - **🚀 Beyond Original Goals:** 536 - - ✅ **Enterprise-grade authentication architecture** with middleware patterns 537 - - ✅ **100% legacy code elimination** - Complete technical debt cleanup 538 - - ✅ **Domain-driven design accuracy** - Entities match business requirements 539 - - ✅ **Zero breaking changes** - Seamless migration without disruption 540 - - ✅ **Production-ready architecture** - Ready for enterprise deployment 541 - 542 - **🎯 Core Principle Achieved:** 543 - > **"Write code once, run it everywhere with different adapters."** 544 - 545 - **Final Status: ✅ ARCHITECTURE REFACTORING COMPLETE** 546 - - **All hexagonal architecture objectives achieved and exceeded** 547 - - **Zero technical debt remaining** 548 - - **75% production deployment ready** (Database + Auth + Architecture) 549 - - **Enterprise architecture standards met** 550 - - **Next milestone: Complete GitHub API integration for 100% production readiness** 551 - 552 - --- 553 - 554 - *This document serves as both the original plan and the final completion report for the Hexagonal Architecture refactoring initiative.*
+7 -7
ENVIRONMENT_SETUP.md
··· 33 33 - **Authorization callback URL**: `https://prcat.vercel.app/api/auth/callback/github` 34 34 35 35 After creation, you'll get: 36 - - `Client ID` → Use as `GITHUB_CLIENT_ID` 37 - - Generate a `Client Secret` → Use as `GITHUB_CLIENT_SECRET` 36 + - `Client ID` → Use as `GITHUB_OAUTH_CLIENT_ID` 37 + - Generate a `Client Secret` → Use as `GITHUB_OAUTH_CLIENT_SECRET` 38 38 39 39 ### 2. Create a GitHub App 40 40 ··· 81 81 NEXTAUTH_URL= # http://localhost:3000 (local) or https://prcat.vercel.app (production) 82 82 83 83 # GitHub OAuth App (for user authentication) 84 - GITHUB_CLIENT_ID= # From OAuth App 85 - GITHUB_CLIENT_SECRET= # From OAuth App 84 + GITHUB_OAUTH_CLIENT_ID= # From OAuth App 85 + GITHUB_OAUTH_CLIENT_SECRET= # From OAuth App 86 86 87 87 # GitHub App (for repository access) 88 88 GITHUB_APP_ID= # Numeric ID from GitHub App ··· 121 121 APP_URL=http://localhost:3000 122 122 123 123 # From your local GitHub OAuth App 124 - GITHUB_CLIENT_ID=your-oauth-client-id 125 - GITHUB_CLIENT_SECRET=your-oauth-client-secret 124 + GITHUB_OAUTH_CLIENT_ID=your-oauth-client-id 125 + GITHUB_OAUTH_CLIENT_SECRET=your-oauth-client-secret 126 126 127 127 # From your GitHub App 128 128 GITHUB_APP_ID=123456 ··· 210 210 **Cause**: Mismatched OAuth callback URL 211 211 **Solution**: 212 212 - Verify the callback URL in your GitHub OAuth App matches exactly: `${NEXTAUTH_URL}/api/auth/callback/github` 213 - - Check that you're using the correct `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` pair 213 + - Check that you're using the correct `GITHUB_OAUTH_CLIENT_ID` and `GITHUB_OAUTH_CLIENT_SECRET` pair 214 214 215 215 #### 2. Organizations Not Appearing 216 216 **Cause**: Organizations not synced to database
+4 -4
README.md
··· 17 17 **Required:** **Nothing!** All secrets auto-generated securely 🎉 18 18 19 19 ### Option 2: Basic Mode (5 minutes setup) 20 - [![Deploy Basic](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvorcigernix%2Fpr_cat&env=GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET,NEXTAUTH_SECRET&envDescription=Basic%20GitHub%20integration&envLink=https%3A%2F%2Fgithub.com%2Fvorcigernix%2Fpr_cat%23environment-setup&project-name=pr-cat-basic&repository-name=pr-cat) 20 + [![Deploy Basic](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvorcigernix%2Fpr_cat&env=GITHUB_OAUTH_CLIENT_ID,GITHUB_OAUTH_CLIENT_SECRET,NEXTAUTH_SECRET&envDescription=Basic%20GitHub%20integration&envLink=https%3A%2F%2Fgithub.com%2Fvorcigernix%2Fpr_cat%23environment-setup&project-name=pr-cat-basic&repository-name=pr-cat) 21 21 22 22 **What you get:** 23 23 - ✅ GitHub OAuth authentication ··· 28 28 **Required:** 3 environment variables (GitHub OAuth + NextAuth secret) 29 29 30 30 ### Option 3: Full Installation (15 minutes setup) 31 - [![Deploy Full](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvorcigernix%2Fpr_cat&env=GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET,GITHUB_WEBHOOK_SECRET,GITHUB_APP_ID,GITHUB_APP_PRIVATE_KEY,NEXT_PUBLIC_GITHUB_APP_SLUG,TURSO_URL,TURSO_TOKEN,NEXTAUTH_SECRET&envDescription=Complete%20setup%20with%20all%20features&envLink=https%3A%2F%2Fgithub.com%2Fvorcigernix%2Fpr_cat%23environment-setup&project-name=pr-cat&repository-name=pr-cat) 31 + [![Deploy Full](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvorcigernix%2Fpr_cat&env=GITHUB_OAUTH_CLIENT_ID,GITHUB_OAUTH_CLIENT_SECRET,GITHUB_WEBHOOK_SECRET,GITHUB_APP_ID,GITHUB_APP_PRIVATE_KEY,NEXT_PUBLIC_GITHUB_APP_SLUG,TURSO_URL,TURSO_TOKEN,NEXTAUTH_SECRET&envDescription=Complete%20setup%20with%20all%20features&envLink=https%3A%2F%2Fgithub.com%2Fvorcigernix%2Fpr_cat%23environment-setup&project-name=pr-cat&repository-name=pr-cat) 32 32 33 33 **What you get:** 34 34 - ✅ All features enabled ··· 66 66 67 67 ```bash 68 68 # GitHub OAuth Configuration (Required) 69 - GITHUB_CLIENT_ID=your_github_client_id 70 - GITHUB_CLIENT_SECRET=your_github_client_secret 69 + GITHUB_OAUTH_CLIENT_ID=your_github_client_id 70 + GITHUB_OAUTH_CLIENT_SECRET=your_github_client_secret 71 71 GITHUB_WEBHOOK_SECRET=your_github_webhook_secret 72 72 73 73 # GitHub App Configuration (Required for advanced features)
+5 -5
VERCEL_TEMPLATE_ISSUES.md
··· 13 13 **Problem**: The Vercel deploy button requires **12 environment variables** upfront. 14 14 **Impact**: This is overwhelming and creates a high barrier to entry. 15 15 **Current Requirements**: 16 - - GITHUB_CLIENT_ID 17 - - GITHUB_CLIENT_SECRET 16 + - GITHUB_OAUTH_CLIENT_ID 17 + - GITHUB_OAUTH_CLIENT_SECRET 18 18 - GITHUB_WEBHOOK_SECRET 19 19 - GITHUB_APP_ID 20 20 - GITHUB_APP_PRIVATE_KEY ··· 75 75 76 76 #### Minimal Deploy Button (3-4 variables only): 77 77 ```markdown 78 - [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvorcigernix%2Fpr_cat&env=GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET,NEXTAUTH_SECRET&envDescription=Minimal%20setup%20for%20PR%20Cat&project-name=pr-cat&demo=1) 78 + [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvorcigernix%2Fpr_cat&env=GITHUB_OAUTH_CLIENT_ID,GITHUB_OAUTH_CLIENT_SECRET,NEXTAUTH_SECRET&envDescription=Minimal%20setup%20for%20PR%20Cat&project-name=pr-cat&demo=1) 79 79 ``` 80 80 81 81 #### Progressive Enhancement Strategy: ··· 159 159 "env": { 160 160 "required": ["NEXTAUTH_SECRET"], 161 161 "optional": [ 162 - "GITHUB_CLIENT_ID", 163 - "GITHUB_CLIENT_SECRET", 162 + "GITHUB_OAUTH_CLIENT_ID", 163 + "GITHUB_OAUTH_CLIENT_SECRET", 164 164 "TURSO_URL", 165 165 "TURSO_TOKEN" 166 166 ]
+1 -1
app/api/demo-status/route.ts
··· 15 15 const missingServices = []; 16 16 const hasDatabase = environmentConfig.hasFeature('database'); 17 17 const hasGitHub = environmentConfig.hasFeature('github'); 18 - const hasGitHubAuth = Boolean(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET); 18 + const hasGitHubAuth = Boolean(process.env.GITHUB_OAUTH_CLIENT_ID && process.env.GITHUB_OAUTH_CLIENT_SECRET); 19 19 20 20 if (!hasDatabase) missingServices.push('Database (Turso)'); 21 21 if (!hasGitHub) missingServices.push('GitHub App');
+187
app/api/github-app/installations/[installationId]/route.ts
··· 1 + /** 2 + * GitHub App Installation Management API 3 + * Manages individual GitHub App installations 4 + */ 5 + 6 + import { NextRequest, NextResponse } from 'next/server' 7 + import { auth } from '@/auth' 8 + import { getService } from '@/lib/core/container/di-container' 9 + import { IGitHubAppService } from '@/lib/core/ports' 10 + 11 + export const runtime = 'nodejs' 12 + 13 + /** 14 + * GET /api/github-app/installations/[installationId] 15 + * Get details for a specific installation 16 + */ 17 + export async function GET( 18 + request: NextRequest, 19 + { params }: { params: Promise<{ installationId: string }> } 20 + ) { 21 + try { 22 + const { installationId } = await params 23 + const session = await auth() 24 + 25 + if (!session || !session.user) { 26 + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 27 + } 28 + 29 + const installationIdNum = parseInt(installationId) 30 + if (isNaN(installationIdNum)) { 31 + return NextResponse.json({ error: 'Invalid installation ID' }, { status: 400 }) 32 + } 33 + 34 + console.log(`[GitHubApp API] Fetching installation ${installationIdNum}...`) 35 + 36 + const githubAppService = await getService<IGitHubAppService>('GitHubAppService') 37 + 38 + // Get installation details 39 + const installation = await githubAppService.getInstallation(installationIdNum) 40 + 41 + // Get repositories if requested 42 + const includeRepos = request.nextUrl.searchParams.get('include_repositories') === 'true' 43 + let repositories: any[] | undefined = undefined 44 + 45 + if (includeRepos) { 46 + try { 47 + repositories = await githubAppService.getInstallationRepositories(installationIdNum) 48 + console.log(`[GitHubApp API] Found ${repositories.length} repositories for installation ${installationIdNum}`) 49 + } catch (error) { 50 + console.warn(`[GitHubApp API] Could not fetch repositories for installation ${installationIdNum}:`, error) 51 + repositories = undefined 52 + } 53 + } 54 + 55 + return NextResponse.json({ 56 + installation, 57 + repositories, 58 + repositoryCount: repositories?.length 59 + }) 60 + 61 + } catch (error) { 62 + console.error(`[GitHubApp API] Error fetching installation:`, error) 63 + 64 + if (error instanceof Error && error.message.includes('Not Found')) { 65 + return NextResponse.json({ error: 'Installation not found' }, { status: 404 }) 66 + } 67 + 68 + return NextResponse.json( 69 + { 70 + error: 'Failed to fetch installation', 71 + details: error instanceof Error ? error.message : 'Unknown error' 72 + }, 73 + { status: 500 } 74 + ) 75 + } 76 + } 77 + 78 + /** 79 + * POST /api/github-app/installations/[installationId]/sync 80 + * Sync a specific installation with database 81 + */ 82 + export async function POST( 83 + request: NextRequest, 84 + { params }: { params: Promise<{ installationId: string }> } 85 + ) { 86 + try { 87 + const { installationId } = await params 88 + const session = await auth() 89 + 90 + if (!session || !session.user) { 91 + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 92 + } 93 + 94 + const installationIdNum = parseInt(installationId) 95 + if (isNaN(installationIdNum)) { 96 + return NextResponse.json({ error: 'Invalid installation ID' }, { status: 400 }) 97 + } 98 + 99 + console.log(`[GitHubApp API] Syncing installation ${installationIdNum}...`) 100 + 101 + const githubAppService = await getService<IGitHubAppService>('GitHubAppService') 102 + 103 + // Sync the installation 104 + const syncResult = await githubAppService.syncInstallation(installationIdNum) 105 + 106 + console.log(`[GitHubApp API] Sync completed for installation ${installationIdNum}: org=${syncResult.organization.name}, repos=${syncResult.repositories.length}, errors=${syncResult.errors.length}`) 107 + 108 + return NextResponse.json({ 109 + success: true, 110 + organization: { 111 + id: syncResult.organization.id, 112 + name: syncResult.organization.name, 113 + login: syncResult.organization.login 114 + }, 115 + repositories: { 116 + count: syncResult.repositories.length, 117 + items: syncResult.repositories.map(repo => ({ 118 + id: repo.id, 119 + name: repo.name, 120 + fullName: repo.fullName, 121 + isPrivate: repo.isPrivate 122 + })) 123 + }, 124 + errors: syncResult.errors.length > 0 ? syncResult.errors : undefined 125 + }) 126 + 127 + } catch (error) { 128 + console.error(`[GitHubApp API] Error syncing installation:`, error) 129 + 130 + if (error instanceof Error && error.message.includes('Not Found')) { 131 + return NextResponse.json({ error: 'Installation not found' }, { status: 404 }) 132 + } 133 + 134 + return NextResponse.json( 135 + { 136 + error: 'Failed to sync installation', 137 + details: error instanceof Error ? error.message : 'Unknown error' 138 + }, 139 + { status: 500 } 140 + ) 141 + } 142 + } 143 + 144 + /** 145 + * DELETE /api/github-app/installations/[installationId]/cache 146 + * Clear cached tokens for an installation 147 + */ 148 + export async function DELETE( 149 + request: NextRequest, 150 + { params }: { params: Promise<{ installationId: string }> } 151 + ) { 152 + try { 153 + const { installationId } = await params 154 + const session = await auth() 155 + 156 + if (!session || !session.user) { 157 + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 158 + } 159 + 160 + const installationIdNum = parseInt(installationId) 161 + if (isNaN(installationIdNum)) { 162 + return NextResponse.json({ error: 'Invalid installation ID' }, { status: 400 }) 163 + } 164 + 165 + console.log(`[GitHubApp API] Clearing token cache for installation ${installationIdNum}...`) 166 + 167 + const githubAppService = await getService<IGitHubAppService>('GitHubAppService') 168 + 169 + // Clear the token cache 170 + await githubAppService.clearTokenCache(installationIdNum) 171 + 172 + return NextResponse.json({ 173 + success: true, 174 + message: `Token cache cleared for installation ${installationIdNum}` 175 + }) 176 + 177 + } catch (error) { 178 + console.error(`[GitHubApp API] Error clearing token cache:`, error) 179 + return NextResponse.json( 180 + { 181 + error: 'Failed to clear token cache', 182 + details: error instanceof Error ? error.message : 'Unknown error' 183 + }, 184 + { status: 500 } 185 + ) 186 + } 187 + }
+145
app/api/github-app/installations/route.ts
··· 1 + /** 2 + * GitHub App Installations API 3 + * Lists and manages GitHub App installations 4 + */ 5 + 6 + import { NextRequest, NextResponse } from 'next/server' 7 + import { auth } from '@/auth' 8 + import { getService } from '@/lib/core/container/di-container' 9 + import { IGitHubAppService } from '@/lib/core/ports' 10 + 11 + export const runtime = 'nodejs' 12 + 13 + /** 14 + * GET /api/github-app/installations 15 + * List all GitHub App installations 16 + */ 17 + export async function GET() { 18 + try { 19 + const session = await auth() 20 + 21 + if (!session || !session.user) { 22 + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 23 + } 24 + 25 + console.log('[GitHubApp API] Fetching installations...') 26 + 27 + const githubAppService = await getService<IGitHubAppService>('GitHubAppService') 28 + 29 + // Validate GitHub App configuration first 30 + const config = await githubAppService.validateConfiguration() 31 + if (!config.isValid) { 32 + console.warn('[GitHubApp API] GitHub App not properly configured:', config.errors) 33 + return NextResponse.json({ 34 + installations: [], 35 + configured: false, 36 + errors: config.errors 37 + }) 38 + } 39 + 40 + // Get all installations 41 + const installations = await githubAppService.listInstallations() 42 + 43 + // Filter to only organizations (if needed) 44 + const organizationInstallations = installations.filter( 45 + installation => installation.account.type === 'Organization' 46 + ) 47 + 48 + console.log(`[GitHubApp API] Found ${organizationInstallations.length} organization installations`) 49 + 50 + return NextResponse.json({ 51 + installations: organizationInstallations, 52 + configured: true, 53 + total: organizationInstallations.length 54 + }) 55 + 56 + } catch (error) { 57 + console.error('[GitHubApp API] Error fetching installations:', error) 58 + return NextResponse.json( 59 + { 60 + error: 'Failed to fetch installations', 61 + details: error instanceof Error ? error.message : 'Unknown error' 62 + }, 63 + { status: 500 } 64 + ) 65 + } 66 + } 67 + 68 + /** 69 + * POST /api/github-app/installations 70 + * Sync all installations with database 71 + */ 72 + export async function POST() { 73 + try { 74 + const session = await auth() 75 + 76 + if (!session || !session.user) { 77 + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 78 + } 79 + 80 + console.log('[GitHubApp API] Starting installation sync...') 81 + 82 + const githubAppService = await getService<IGitHubAppService>('GitHubAppService') 83 + 84 + // Validate configuration 85 + const config = await githubAppService.validateConfiguration() 86 + if (!config.isValid) { 87 + return NextResponse.json({ 88 + error: 'GitHub App not properly configured', 89 + details: config.errors 90 + }, { status: 400 }) 91 + } 92 + 93 + // Get all installations 94 + const installations = await githubAppService.listInstallations() 95 + const organizationInstallations = installations.filter( 96 + installation => installation.account.type === 'Organization' 97 + ) 98 + 99 + // Sync each installation 100 + const results = [] 101 + const errors = [] 102 + 103 + for (const installation of organizationInstallations) { 104 + try { 105 + console.log(`[GitHubApp API] Syncing installation ${installation.id} for org ${installation.account.login}`) 106 + 107 + const syncResult = await githubAppService.syncInstallation(installation.id) 108 + results.push({ 109 + installationId: installation.id, 110 + organization: syncResult.organization.name, 111 + repositoriesCount: syncResult.repositories.length, 112 + errors: syncResult.errors 113 + }) 114 + 115 + if (syncResult.errors.length > 0) { 116 + errors.push(...syncResult.errors) 117 + } 118 + } catch (error) { 119 + const errorMsg = `Failed to sync installation ${installation.id}: ${error instanceof Error ? error.message : 'Unknown error'}` 120 + errors.push(errorMsg) 121 + console.error(`[GitHubApp API] ${errorMsg}`) 122 + } 123 + } 124 + 125 + console.log(`[GitHubApp API] Sync completed. Processed ${results.length} installations, ${errors.length} errors`) 126 + 127 + return NextResponse.json({ 128 + success: true, 129 + synced: results, 130 + totalInstallations: organizationInstallations.length, 131 + totalErrors: errors.length, 132 + errors: errors.length > 0 ? errors : undefined 133 + }) 134 + 135 + } catch (error) { 136 + console.error('[GitHubApp API] Error syncing installations:', error) 137 + return NextResponse.json( 138 + { 139 + error: 'Failed to sync installations', 140 + details: error instanceof Error ? error.message : 'Unknown error' 141 + }, 142 + { status: 500 } 143 + ) 144 + } 145 + }
+92
app/api/github-app/status/route.ts
··· 1 + /** 2 + * GitHub App Status API 3 + * Provides configuration and status information for GitHub App 4 + */ 5 + 6 + import { NextResponse } from 'next/server' 7 + import { auth } from '@/auth' 8 + import { getService } from '@/lib/core/container/di-container' 9 + import { IGitHubAppService } from '@/lib/core/ports' 10 + 11 + export const runtime = 'nodejs' 12 + 13 + /** 14 + * GET /api/github-app/status 15 + * Get GitHub App configuration and status 16 + */ 17 + export async function GET() { 18 + try { 19 + const session = await auth() 20 + 21 + if (!session || !session.user) { 22 + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 23 + } 24 + 25 + console.log('[GitHubApp API] Checking GitHub App status...') 26 + 27 + const githubAppService = await getService<IGitHubAppService>('GitHubAppService') 28 + 29 + // Validate configuration 30 + const config = await githubAppService.validateConfiguration() 31 + 32 + let installationCount = 0 33 + let organizationCount = 0 34 + let lastError = null 35 + 36 + // If configured, get installation info 37 + if (config.isValid) { 38 + try { 39 + const installations = await githubAppService.listInstallations() 40 + installationCount = installations.length 41 + organizationCount = installations.filter( 42 + inst => inst.account.type === 'Organization' 43 + ).length 44 + } catch (error) { 45 + lastError = error instanceof Error ? error.message : 'Failed to fetch installations' 46 + console.warn('[GitHubApp API] Error fetching installations for status:', error) 47 + } 48 + } 49 + 50 + const status = { 51 + configured: config.isValid, 52 + appId: config.appId, 53 + hasPrivateKey: config.hasPrivateKey, 54 + errors: config.errors.length > 0 ? config.errors : undefined, 55 + installations: { 56 + total: installationCount, 57 + organizations: organizationCount 58 + }, 59 + lastError: lastError || undefined, 60 + environment: { 61 + hasAppId: !!process.env.GITHUB_APP_ID, 62 + hasPrivateKey: !!process.env.GITHUB_APP_PRIVATE_KEY, 63 + hasWebhookSecret: !!process.env.GITHUB_WEBHOOK_SECRET, 64 + hasClientId: !!process.env.GITHUB_OAUTH_CLIENT_ID, 65 + hasClientSecret: !!process.env.GITHUB_OAUTH_CLIENT_SECRET 66 + }, 67 + requiredEnvVars: [ 68 + 'GITHUB_APP_ID', 69 + 'GITHUB_APP_PRIVATE_KEY' 70 + ], 71 + optionalEnvVars: [ 72 + 'GITHUB_WEBHOOK_SECRET', 73 + 'GITHUB_OAUTH_CLIENT_ID', 74 + 'GITHUB_OAUTH_CLIENT_SECRET' 75 + ] 76 + } 77 + 78 + console.log(`[GitHubApp API] Status check complete: configured=${config.isValid}, installations=${installationCount}`) 79 + 80 + return NextResponse.json(status) 81 + 82 + } catch (error) { 83 + console.error('[GitHubApp API] Error checking GitHub App status:', error) 84 + return NextResponse.json( 85 + { 86 + error: 'Failed to check GitHub App status', 87 + details: error instanceof Error ? error.message : 'Unknown error' 88 + }, 89 + { status: 500 } 90 + ) 91 + } 92 + }
+8 -4
app/api/github/organizations/[orgName]/repositories/route.ts
··· 1 1 import { NextRequest, NextResponse } from 'next/server'; 2 2 import { auth } from '@/auth'; 3 - import { GitHubService } from '@/lib/services'; 3 + import { getService } from '@/lib/core/container/di-container'; 4 + import { IGitHubService } from '@/lib/core/ports'; 4 5 5 6 // Use the context object directly with proper typing for Next.js route handler 6 7 export async function GET( ··· 20 21 } 21 22 22 23 try { 23 - const githubService = new GitHubService(session.accessToken); 24 - const repositories = await githubService.syncOrganizationRepositories(orgName); 24 + const githubService = await getService<IGitHubService>('GitHubService'); 25 + const result = await githubService.syncOrganizationRepositories(orgName); 25 26 26 - return NextResponse.json({ repositories }); 27 + return NextResponse.json({ 28 + repositories: result.synced, 29 + errors: result.errors 30 + }); 27 31 } catch (error) { 28 32 console.error('GitHub API error:', error); 29 33 return NextResponse.json(
+16 -9
app/api/github/organizations/installation-status/route.ts
··· 1 1 import { NextRequest, NextResponse } from 'next/server'; 2 2 import { auth } from '@/auth'; 3 - import { GitHubClient } from '@/lib/github'; 4 - import { generateAppJwt } from '@/lib/github-app'; 5 - import { Octokit } from '@octokit/rest'; 3 + import { getService } from '@/lib/core/container/di-container'; 4 + import { IGitHubAppService } from '@/lib/core/ports'; 6 5 7 6 export const runtime = 'nodejs'; 8 7 ··· 26 25 return NextResponse.json({ installations: [] }); 27 26 } 28 27 29 - // For GitHub App installation status, we need to use the GitHub App JWT 30 - // because the user OAuth token doesn't have permission to list installations 31 - console.log('Organizations installation-status: Generating GitHub App JWT'); 32 - const appJwt = await generateAppJwt(); 33 - const appOctokit = new Octokit({ auth: appJwt }); 28 + // Get GitHub App service 29 + const githubAppService = await getService<IGitHubAppService>('GitHubAppService'); 30 + 31 + // Validate GitHub App configuration 32 + const config = await githubAppService.validateConfiguration(); 33 + if (!config.isValid) { 34 + console.log('Organizations installation-status: GitHub App not properly configured'); 35 + return NextResponse.json({ 36 + installations: [], 37 + configured: false, 38 + errors: config.errors 39 + }); 40 + } 34 41 35 42 // Get all app installations 36 43 console.log('Organizations installation-status: Fetching app installations'); 37 - const { data: installationsData } = await appOctokit.apps.listInstallations(); 44 + const installationsData = await githubAppService.listInstallations(); 38 45 console.log(`Organizations installation-status: Found ${installationsData.length} app installations`); 39 46 40 47 // Map organizations to include installation status
+4 -3
app/api/github/organizations/route.ts
··· 1 1 import { NextResponse } from 'next/server'; 2 2 import { auth } from '@/auth'; 3 - import { GitHubService } from '@/lib/services'; 3 + import { getService } from '@/lib/core/container/di-container'; 4 + import { IGitHubService } from '@/lib/core/ports'; 4 5 5 6 export async function GET() { 6 7 const session = await auth(); ··· 14 15 } 15 16 16 17 try { 17 - const githubService = new GitHubService(session.accessToken); 18 - const organizations = await githubService.syncUserOrganizations(session.user.id); 18 + const githubService = await getService<IGitHubService>('GitHubService'); 19 + const organizations = await githubService.getUserOrganizations(session.accessToken); 19 20 20 21 return NextResponse.json({ organizations }); 21 22 } catch (error) {
+4 -3
app/api/github/user/route.ts
··· 1 1 import { NextResponse } from 'next/server'; 2 2 import { auth } from '@/auth'; 3 - import { GitHubService } from '@/lib/services'; 3 + import { getService } from '@/lib/core/container/di-container'; 4 + import { IGitHubService } from '@/lib/core/ports'; 4 5 5 6 export async function GET() { 6 7 const session = await auth(); ··· 14 15 } 15 16 16 17 try { 17 - const githubService = new GitHubService(session.accessToken); 18 - const user = await githubService.getCurrentUser(); 18 + const githubService = await getService<IGitHubService>('GitHubService'); 19 + const user = await githubService.getUser(session.accessToken); 19 20 20 21 return NextResponse.json({ user }); 21 22 } catch (error) {
+1 -1
app/api/health/route.ts
··· 15 15 // Don't expose actual env vars, just check if they exist 16 16 envVarsConfigured: { 17 17 turso: !!process.env.TURSO_URL && !!process.env.TURSO_TOKEN, 18 - github: !!process.env.GITHUB_CLIENT_ID && !!process.env.GITHUB_CLIENT_SECRET, 18 + github: !!process.env.GITHUB_OAUTH_CLIENT_ID && !!process.env.GITHUB_OAUTH_CLIENT_SECRET, 19 19 nextauth: !!process.env.NEXTAUTH_SECRET, 20 20 } 21 21 };
+76 -1008
app/api/webhook/github/route.ts
··· 1 - import { NextRequest, NextResponse } from 'next/server'; 2 - import crypto from 'crypto'; 3 - import { findRepositoryByFullName } from '@/lib/repositories'; 4 - import { findOrCreateRepository } from '@/lib/repositories'; 5 - import { 6 - createPullRequest, 7 - findPullRequestByNumber, 8 - updatePullRequest, 9 - createPullRequestReview, 10 - findReviewByGitHubId, 11 - updatePullRequestReview, 12 - updatePullRequestCategory, 13 - getOrganizationCategories, 14 - getOrganizationAiSettings, 15 - getOrganizationApiKey, 16 - findCategoryByNameAndOrg as findCategoryByNameAndOrgFromRepo, 17 - findOrCreateUserByGitHubId, 18 - findOrganizationById 19 - } from '@/lib/repositories'; 20 - import { findOrganizationByNameAndUser } from '@/lib/repositories/organization-repository'; 21 - import { GitHubClient } from '@/lib/github'; 22 - import { createInstallationClient } from '@/lib/github-app'; 23 - import { PRReview, Category, Organization } from '@/lib/types'; 24 - import { openai } from '@ai-sdk/openai'; 25 - import { generateText, CoreTool } from 'ai'; 26 - import { createOpenAI } from '@ai-sdk/openai'; 27 - import { createGoogleGenerativeAI } from '@ai-sdk/google'; 28 - import { createAnthropic } from '@ai-sdk/anthropic'; 29 - import { allModels } from '@/lib/ai-models'; // Import shared models 30 - import * as OrganizationRepository from '@/lib/repositories/organization-repository'; 31 - import { Octokit } from '@octokit/rest'; 32 - import * as PullRequestRepository from '@/lib/repositories/pr-repository'; 33 - import * as UserRepository from '@/lib/repositories/user-repository'; 34 - import { User } from '@/lib/types'; 35 - 36 - export const runtime = 'nodejs'; 37 - 38 - // Helper to log operations that might involve foreign keys 39 - function logOperation(operation: string, params: any) { 40 - console.log(`WEBHOOK DB OPERATION: ${operation}`, JSON.stringify(params, null, 2)); 41 - } 42 - 43 - // Verify GitHub webhook signature 44 - function verifyGitHubSignature(payload: string, signature: string, secret: string): boolean { 45 - if (!signature || !secret) { 46 - return false; 47 - } 48 - 49 - const expectedSignature = crypto 50 - .createHmac('sha256', secret) 51 - .update(payload, 'utf8') 52 - .digest('hex'); 53 - 54 - const expectedSignatureWithPrefix = `sha256=${expectedSignature}`; 55 - 56 - // Use crypto.timingSafeEqual to prevent timing attacks 57 - if (signature.length !== expectedSignatureWithPrefix.length) { 58 - return false; 59 - } 60 - 61 - return crypto.timingSafeEqual( 62 - Buffer.from(signature, 'utf8'), 63 - Buffer.from(expectedSignatureWithPrefix, 'utf8') 64 - ); 65 - } 1 + /** 2 + * GitHub Webhook Handler 3 + * Processes GitHub webhook events using hexagonal architecture 4 + */ 66 5 67 - // Helper to safely log errors with full stack trace and context 68 - function logError(context: string, error: any, extraData?: any) { 69 - console.error(`WEBHOOK ERROR in ${context}:`, error); 70 - if (error instanceof Error) { 71 - console.error(` Message: ${error.message}`); 72 - console.error(` Stack: ${error.stack}`); 73 - } 74 - if (extraData) { 75 - console.error(` Context data:`, JSON.stringify(extraData, null, 2)); 76 - } 77 - } 6 + import { NextRequest, NextResponse } from 'next/server' 7 + import { validateWebhook } from '@/lib/webhook-security' 8 + import { getService } from '@/lib/core/container/di-container' 9 + import { IGitHubService } from '@/lib/core/ports' 78 10 79 - // GitHub webhook payload types 80 - interface GitHubWebhookPayload { 81 - action: string; 82 - installation?: { 83 - id: number; 84 - // Add account details for installation events 85 - account?: { 86 - id: number; 87 - login: string; 88 - type: string; // "Organization" or "User" 89 - avatar_url?: string; 90 - }; 91 - }; 92 - repository: { 93 - id: number; 94 - name: string; 95 - full_name: string; 96 - owner: { 97 - id: number; 98 - login: string; 99 - }; 100 - installation?: { 101 - id: number; 102 - }; 103 - }; 104 - sender: { 105 - id: number; 106 - login: string; 107 - }; 108 - } 109 - 110 - interface PullRequestPayload extends GitHubWebhookPayload { 111 - pull_request: { 112 - id: number; 113 - number: number; 114 - title: string; 115 - body: string | null; 116 - state: string; 117 - created_at: string; 118 - updated_at: string; 119 - closed_at: string | null; 120 - merged_at: string | null; 121 - draft: boolean; 122 - user: { 123 - id: number; 124 - login: string; 125 - }; 126 - additions?: number; 127 - deletions?: number; 128 - changed_files?: number; 129 - }; 130 - } 131 - 132 - interface PullRequestReviewPayload extends GitHubWebhookPayload { 133 - pull_request: { 134 - id: number; 135 - number: number; 136 - title: string; 137 - }; 138 - review: { 139 - id: number; 140 - user: { 141 - id: number; 142 - login: string; 143 - }; 144 - body: string | null; 145 - state: string; 146 - submitted_at: string; 147 - commit_id: string; 148 - }; 149 - } 150 - 151 - // Interface for Installation event payload 152 - interface InstallationPayload extends GitHubWebhookPayload { 153 - action: 'created' | 'deleted' | 'suspend' | 'unsuspend' | 'new_permissions_accepted'; 154 - installation: { 155 - id: number; 156 - account: { 157 - id: number; // This is the GitHub ID of the organization or user 158 - login: string; 159 - type: string; // "Organization" or "User" 160 - avatar_url?: string; 161 - // ... other account details if needed 162 - }; 163 - // ... other installation details if needed 164 - }; 165 - // repositories field might be present if action is 'created' and it's a selective installation 166 - repositories?: Array<{ 167 - id: number; 168 - name: string; 169 - full_name: string; 170 - private: boolean; 171 - }>; 172 - } 11 + export const runtime = 'nodejs' 173 12 174 13 export async function POST(request: NextRequest) { 175 - console.log("WEBHOOK: Received GitHub webhook event"); 14 + console.log('[Webhook] Received GitHub webhook event') 176 15 177 16 try { 178 - // Get headers first 179 - const signature = request.headers.get('x-hub-signature-256'); 180 - const webhookSecret = process.env.GITHUB_WEBHOOK_SECRET ?? process.env.GITHUB_CLIENT_SECRET; 181 - const eventType = request.headers.get('x-github-event'); 182 - 183 - console.log(`Received GitHub webhook event type: ${eventType}`); 184 - 185 - // Read the body as text first (for signature verification) 186 - const bodyText = await request.text(); 17 + // Get webhook secret from environment 18 + const webhookSecret = process.env.GITHUB_WEBHOOK_SECRET ?? process.env.GITHUB_OAUTH_CLIENT_SECRET 187 19 188 - // Verify webhook signature for security 189 20 if (!webhookSecret) { 190 - if (process.env.NODE_ENV !== 'production') { 191 - console.warn("WEBHOOK: GITHUB_WEBHOOK_SECRET not configured - skipping signature verification in non-production environment"); 21 + if (process.env.NODE_ENV === 'production') { 22 + console.error('[Webhook] GITHUB_WEBHOOK_SECRET not configured in production') 23 + return NextResponse.json( 24 + { error: 'Webhook secret not configured' }, 25 + { status: 500 } 26 + ) 192 27 } else { 193 - console.error("WEBHOOK: GITHUB_WEBHOOK_SECRET not configured"); 194 - return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 }); 195 - } 196 - } 197 - 198 - if (!signature && webhookSecret) { 199 - console.error("WEBHOOK: Missing X-Hub-Signature-256 header"); 200 - return NextResponse.json({ error: 'Missing signature' }, { status: 401 }); 201 - } 202 - 203 - if (webhookSecret && signature && !verifyGitHubSignature(bodyText, signature, webhookSecret)) { 204 - console.error("WEBHOOK: Invalid signature"); 205 - return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }); 206 - } 207 - 208 - console.log("WEBHOOK: Signature verified successfully"); 209 - 210 - let bodyJson; 211 - 212 - try { 213 - bodyJson = JSON.parse(bodyText); 214 - 215 - // Log installation data from the webhook payload 216 - console.log("WEBHOOK: Full installation data in payload:", 217 - JSON.stringify(bodyJson.installation, null, 2)); 218 - console.log("WEBHOOK: Repository installation data:", 219 - JSON.stringify(bodyJson.repository?.installation, null, 2)); 220 - 221 - // Log event type 222 - console.log(`WEBHOOK: Event type ${bodyJson.action} on ${bodyJson.repository?.full_name}`); 223 - } catch (parseError) { 224 - console.error("WEBHOOK: Error parsing webhook body as JSON:", parseError); 225 - return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 }); 226 - } 227 - 228 - if (webhookSecret && signature) { 229 - const hmac = crypto.createHmac('sha256', webhookSecret); 230 - const digest = 'sha256=' + hmac.update(bodyText).digest('hex'); 231 - 232 - if (signature !== digest) { 233 - console.warn('GitHub webhook signature verification failed'); 234 - return NextResponse.json({ error: 'Invalid signature' }, { status: 403 }); 235 - } 236 - 237 - return handleWebhookEvent(bodyJson, eventType || 'unknown'); 238 - } else { 239 - // If no secret is configured (dev) or GitHub didn't send a signature, process without verification 240 - return handleWebhookEvent(bodyJson, eventType || 'unknown'); 241 - } 242 - } catch (error) { 243 - console.error('Error handling webhook:', error); 244 - return NextResponse.json( 245 - { error: `Failed to process webhook: ${error instanceof Error ? error.message : 'Unknown error'}` }, 246 - { status: 500 } 247 - ); 248 - } 249 - } 250 - 251 - async function handleWebhookEvent(payload: any, eventType: string) { 252 - const action = payload.action; 253 - 254 - console.log(`Processing GitHub webhook event: ${eventType}.${action}`); 255 - 256 - try { 257 - switch (eventType) { 258 - case 'pull_request': 259 - await handlePullRequestEvent(payload as PullRequestPayload); 260 - break; 261 - case 'pull_request_review': 262 - await handlePullRequestReviewEvent(payload as PullRequestReviewPayload); 263 - break; 264 - case 'installation': // Add new case for installation events 265 - await handleInstallationEvent(payload as InstallationPayload); 266 - break; 267 - case 'ping': 268 - // This is a test ping from GitHub when setting up the webhook 269 - console.log('Received ping from GitHub webhook configuration'); 270 - break; 271 - default: 272 - console.log(`Unhandled event type: ${eventType}`); 273 - } 274 - 275 - return NextResponse.json({ success: true }); 276 - } catch (error) { 277 - console.error('Error handling webhook:', error); 278 - return NextResponse.json( 279 - { error: `Failed to process webhook: ${error instanceof Error ? error.message : 'Unknown error'}` }, 280 - { status: 500 } 281 - ); 282 - } 283 - } 284 - 285 - async function handlePullRequestEvent(payload: PullRequestPayload) { 286 - const { action, repository, pull_request: pr } = payload; 287 - 288 - console.log(`WEBHOOK PROCESSING: PR #${pr.number} action=${action} repo=${repository.full_name}`); 289 - 290 - try { 291 - // Find or create the repository in our database 292 - const repoInDb = await findRepositoryByFullName(repository.full_name); 293 - 294 - console.log(`WEBHOOK REPOSITORY CHECK: ${repository.full_name} found in DB? ${!!repoInDb}`, 295 - repoInDb ? `id=${repoInDb.id}, org_id=${repoInDb.organization_id}` : "NOT FOUND"); 296 - 297 - if (!repoInDb) { 298 - console.log(`Repository ${repository.full_name} not found in database, skipping webhook processing`); 299 - return; 300 - } 301 - 302 - // Map GitHub PR state to our state format 303 - const prState = pr.merged_at 304 - ? 'merged' 305 - : pr.state === 'closed' ? 'closed' : 'open'; 306 - 307 - // Check if PR already exists in our database 308 - const existingPR = await findPullRequestByNumber(repoInDb.id, pr.number); 309 - 310 - console.log(`WEBHOOK PR CHECK: PR #${pr.number} in repo ${repository.full_name} exists in DB? ${!!existingPR}`, 311 - existingPR ? `id=${existingPR.id}, author_id=${existingPR.author_id}` : "NOT FOUND"); 312 - 313 - if (existingPR) { 314 - // Update existing PR 315 - const updateParams = { 316 - id: existingPR.id, 317 - title: pr.title, 318 - description: pr.body, 319 - state: prState, 320 - updated_at: pr.updated_at, 321 - closed_at: pr.closed_at, 322 - merged_at: pr.merged_at, 323 - draft: pr.draft, 324 - additions: pr.additions, 325 - deletions: pr.deletions, 326 - changed_files: pr.changed_files 327 - }; 328 - 329 - logOperation("updatePullRequest", updateParams); 330 - 331 - try { 332 - await updatePullRequest(existingPR.id, { 333 - title: pr.title, 334 - description: pr.body, 335 - state: prState, 336 - updated_at: pr.updated_at, 337 - closed_at: pr.closed_at, 338 - merged_at: pr.merged_at, 339 - draft: pr.draft, 340 - additions: pr.additions, 341 - deletions: pr.deletions, 342 - changed_files: pr.changed_files 343 - }); 344 - 345 - console.log(`WEBHOOK PR UPDATED: PR #${pr.number} in ${repository.full_name}`); 346 - 347 - } catch (updateError) { 348 - logError("updatePullRequest", updateError, { 349 - pr_id: existingPR.id, 350 - repo_id: repoInDb.id, 351 - author_id: existingPR.author_id 352 - }); 353 - throw updateError; // Re-throw to halt further processing 354 - } 355 - 356 - // Conditionally call fetchAdditionalPRData ONLY if action is 'opened' 357 - if (action === 'opened') { 358 - console.log(`WEBHOOK ADDITIONAL DATA: PR #${pr.number} action is 'opened'. Preparing to fetch additional data.`); 359 - if (repoInDb.organization_id !== null) { 360 - try { 361 - await fetchAdditionalPRData(repository, pr, existingPR.id, repoInDb.organization_id, payload); 362 - } catch (additionalDataError) { 363 - logError("fetchAdditionalPRData for existing PR", additionalDataError, { 364 - pr_id: existingPR.id, 365 - repo_id: repoInDb.id, 366 - org_id: repoInDb.organization_id 367 - }); 368 - // Don't re-throw, allow webhook to complete even if additional data fails 369 - } 370 - } else { 371 - console.warn(`WEBHOOK WARNING: Organization ID is null for repository ${repoInDb.full_name}. Skipping AI categorization.`); 372 - } 28 + console.warn('[Webhook] GITHUB_WEBHOOK_SECRET not configured - skipping signature verification in development') 373 29 } 374 - } else { 375 - // Create new PR 376 - console.log(`WEBHOOK PR CREATE: Creating new PR #${pr.number} in ${repository.full_name}`); 377 - 378 - // Ensure the author exists in our database 379 - const authorGitHubData = pr.user; 380 - console.log(`WEBHOOK AUTHOR CHECK: PR author GitHub data: id=${authorGitHubData.id}, login=${authorGitHubData.login}`); 381 - 382 - try { 383 - const dbUser = await findOrCreateUserByGitHubId({ 384 - id: authorGitHubData.id.toString(), 385 - login: authorGitHubData.login, 386 - avatar_url: null, 387 - name: null, 388 - }); 389 - 390 - console.log(`WEBHOOK AUTHOR RESULT: findOrCreateUserByGitHubId result for GH ID ${authorGitHubData.id}:`, 391 - dbUser ? `User DB ID ${dbUser.id}` : 'null'); 392 - 393 - if (!dbUser) { 394 - console.error(`WEBHOOK ERROR: Could not find or create user for GitHub ID: ${authorGitHubData.id}. Skipping PR creation.`); 395 - return; 396 - } 397 - 398 - // Properly type the state to match the enum expected by createPullRequest 399 - const typedState = prState as "open" | "closed" | "merged"; 400 - 401 - const newPrParams = { 402 - github_id: pr.id, 403 - repository_id: repoInDb.id, 404 - number: pr.number, 405 - title: pr.title, 406 - description: pr.body, 407 - author_id: dbUser.id, 408 - state: typedState, // Use the properly typed state 409 - created_at: pr.created_at, 410 - updated_at: pr.updated_at, 411 - closed_at: pr.closed_at, 412 - merged_at: pr.merged_at, 413 - draft: pr.draft, 414 - additions: pr.additions || null, 415 - deletions: pr.deletions || null, 416 - changed_files: pr.changed_files || null, 417 - category_id: null, 418 - category_confidence: null 419 - }; 420 - 421 - logOperation("createPullRequest", newPrParams); 422 - 423 - let newPR; 424 - try { 425 - newPR = await createPullRequest(newPrParams); 426 - console.log(`WEBHOOK PR CREATED: New PR #${pr.number} in ${repository.full_name} with DB ID ${newPR.id}`); 427 - } catch (prCreateError: any) { 428 - // If UNIQUE constraint error, fallback to update 429 - if (prCreateError.message && prCreateError.message.includes('UNIQUE constraint failed: pull_requests.repository_id, pull_requests.number')) { 430 - console.warn(`WEBHOOK PR CREATE: PR already exists (race condition). Falling back to update for PR #${pr.number} in ${repository.full_name}`); 431 - const fallbackExistingPR = await findPullRequestByNumber(repoInDb.id, pr.number); 432 - if (fallbackExistingPR) { 433 - await updatePullRequest(fallbackExistingPR.id, { 434 - title: pr.title, 435 - description: pr.body, 436 - state: typedState, 437 - updated_at: pr.updated_at, 438 - closed_at: pr.closed_at, 439 - merged_at: pr.merged_at, 440 - draft: pr.draft, 441 - additions: pr.additions, 442 - deletions: pr.deletions, 443 - changed_files: pr.changed_files 444 - }); 445 - newPR = fallbackExistingPR; 446 - console.log(`WEBHOOK PR UPDATED (fallback): PR #${pr.number} in ${repository.full_name}`); 447 - } else { 448 - throw prCreateError; 449 - } 450 - } else { 451 - logError("PR Creation", prCreateError, { 452 - repo_full_name: repository.full_name, 453 - repo_id: repoInDb?.id, 454 - pr_number: pr.number, 455 - author_github_id: authorGitHubData.id 456 - }); 457 - throw prCreateError; 458 - } 459 - } 460 - 461 - // Fetch additional data for new PRs IFF the action was 'opened' 462 - if (action === 'opened') { 463 - console.log(`WEBHOOK ADDITIONAL DATA: New PR #${pr.number} action is 'opened'. Preparing to fetch additional data.`); 464 - if (repoInDb.organization_id !== null) { 465 - try { 466 - await fetchAdditionalPRData(repository, pr, newPR.id, repoInDb.organization_id, payload); 467 - } catch (additionalDataError) { 468 - logError("fetchAdditionalPRData for new PR", additionalDataError, { 469 - pr_id: newPR.id, 470 - repo_id: repoInDb.id, 471 - org_id: repoInDb.organization_id 472 - }); 473 - // Don't re-throw, allow webhook to complete even if additional data fails 474 - } 475 - } else { 476 - console.warn(`WEBHOOK WARNING: Organization ID is null for new PR in repository ${repoInDb.full_name}. Skipping AI categorization.`); 477 - } 478 - } else { 479 - console.log(`WEBHOOK INFO: New PR #${pr.number} created, but action was '${action}', not 'opened'. Skipping fetchAdditionalPRData.`); 480 - } 481 - } catch (prCreateError) { 482 - logError("PR Creation", prCreateError, { 483 - repo_full_name: repository.full_name, 484 - repo_id: repoInDb?.id, 485 - pr_number: pr.number, 486 - author_github_id: authorGitHubData.id 487 - }); 488 - throw prCreateError; 489 - } 490 - } 491 - } catch (error) { 492 - logError("handlePullRequestEvent", error, { 493 - repo_full_name: repository.full_name, 494 - pr_number: pr.number 495 - }); 496 - throw error; // Re-throw to be handled by the main handler 497 - } 498 - } 499 - 500 - async function handlePullRequestReviewEvent(payload: PullRequestReviewPayload) { 501 - const { repository, pull_request, review } = payload; 502 - 503 - console.log(`WEBHOOK REVIEW PROCESSING: Review for PR #${pull_request.number} repo=${repository.full_name} by user=${review.user.login}`); 504 - 505 - try { 506 - // Find repository in our database 507 - const repoInDb = await findRepositoryByFullName(repository.full_name); 508 - 509 - console.log(`WEBHOOK REPOSITORY CHECK FOR REVIEW: ${repository.full_name} found in DB? ${!!repoInDb}`, 510 - repoInDb ? `id=${repoInDb.id}` : "NOT FOUND"); 511 - 512 - if (!repoInDb) { 513 - console.log(`Repository ${repository.full_name} not found in database, skipping review webhook processing`); 514 - return; 515 30 } 516 31 517 - // Find the PR in our database 518 - const existingPR = await findPullRequestByNumber(repoInDb.id, pull_request.number); 519 - 520 - console.log(`WEBHOOK PR CHECK FOR REVIEW: PR #${pull_request.number} found in DB? ${!!existingPR}`, 521 - existingPR ? `id=${existingPR.id}` : "NOT FOUND"); 522 - 523 - if (!existingPR) { 524 - console.log(`PR #${pull_request.number} not found in database for ${repository.full_name}, skipping review processing`); 525 - return; 526 - } 527 - 528 - // Map GitHub review state to our enum 529 - const reviewState = mapReviewState(review.state); 530 - 531 - // Check if the review already exists 532 - const existingReview = await findReviewByGitHubId(review.id); 533 - 534 - console.log(`WEBHOOK REVIEW CHECK: Review ${review.id} exists in DB? ${!!existingReview}`, 535 - existingReview ? `id=${existingReview.id}, state=${existingReview.state}` : "NOT FOUND"); 536 - 537 - if (existingReview) { 538 - // Update existing review 539 - const updateParams = { 540 - id: existingReview.id, 541 - state: reviewState 542 - }; 32 + // Validate webhook signature and payload 33 + let validation 34 + if (webhookSecret) { 35 + validation = await validateWebhook(request, webhookSecret) 543 36 544 - logOperation("updatePullRequestReview", updateParams); 545 - 546 - try { 547 - await updatePullRequestReview(existingReview.id, { 548 - state: reviewState 549 - }); 550 - 551 - console.log(`WEBHOOK REVIEW UPDATED: Review for PR #${pull_request.number} in ${repository.full_name}`); 552 - } catch (updateError) { 553 - logError("updatePullRequestReview", updateError, { 554 - review_id: existingReview.id, 555 - pr_id: existingPR.id 556 - }); 557 - throw updateError; 37 + if (!validation.valid) { 38 + console.error(`[Webhook] Validation failed: ${validation.error}`) 39 + return NextResponse.json( 40 + { error: validation.error }, 41 + { status: 400 } 42 + ) 558 43 } 559 44 } else { 560 - // Create new review 561 - const newReviewParams = { 562 - github_id: review.id, 563 - pull_request_id: existingPR.id, 564 - reviewer_id: review.user.id.toString(), 565 - state: reviewState, 566 - submitted_at: review.submitted_at 567 - }; 568 - 569 - logOperation("createPullRequestReview", newReviewParams); 45 + // In development, read the payload manually 46 + const eventType = request.headers.get('x-github-event') 47 + const bodyText = await request.text() 570 48 571 49 try { 572 - await createPullRequestReview(newReviewParams); 573 - 574 - console.log(`WEBHOOK REVIEW CREATED: New review for PR #${pull_request.number} in ${repository.full_name}`); 575 - } catch (createError) { 576 - logError("createPullRequestReview", createError, { 577 - review_github_id: review.id, 578 - pr_id: existingPR.id, 579 - reviewer_id: review.user.id.toString() 580 - }); 581 - throw createError; 582 - } 583 - } 584 - } catch (reviewError) { 585 - logError("handlePullRequestReviewEvent", reviewError, { 586 - repo_full_name: repository.full_name, 587 - pr_number: pull_request.number, 588 - review_id: review.id 589 - }); 590 - throw reviewError; 591 - } 592 - } 593 - 594 - // New handler for installation events 595 - async function handleInstallationEvent(payload: InstallationPayload) { 596 - const { action, installation } = payload; 597 - const installationId = installation.id; 598 - const account = installation.account; 599 - 600 - // We are primarily interested in installations on Organizations 601 - if (account.type !== 'Organization') { 602 - console.log(`WEBHOOK INSTALLATION: Skipping installation event for non-organization account type: ${account.type}, login: ${account.login}`); 603 - return; 604 - } 605 - 606 - const orgGitHubId = account.id; 607 - const orgLogin = account.login; 608 - const orgAvatarUrl = account.avatar_url || null; 609 - 610 - console.log(`WEBHOOK INSTALLATION: Action: ${action} for organization ${orgLogin} (GitHub ID: ${orgGitHubId}), Installation ID: ${installationId}`); 611 - 612 - try { 613 - // Find the organization by its GitHub ID. 614 - // It's possible the organization isn't in our DB yet if the app is installed on a new org. 615 - // findOrCreateOrganization will handle this. 616 - // It expects: github_id, name, avatar_url 617 - 618 - // First, ensure the org exists or create it 619 - let org = await OrganizationRepository.findOrganizationByGitHubId(orgGitHubId); 620 - 621 - if (!org && action === 'created') { 622 - console.log(`WEBHOOK INSTALLATION: Organization ${orgLogin} (GitHub ID: ${orgGitHubId}) not found. Creating.`); 623 - try { 624 - org = await OrganizationRepository.createOrganization({ 625 - github_id: orgGitHubId, 626 - name: orgLogin, 627 - avatar_url: orgAvatarUrl 628 - }); 629 - console.log(`WEBHOOK INSTALLATION: Created organization ${org.name} with DB ID ${org.id}`); 630 - } catch (createError) { 631 - logError("handleInstallationEvent - createOrganization", createError, { orgGitHubId, orgLogin }); 632 - return; // Stop if creation fails 50 + const payload = JSON.parse(bodyText) 51 + validation = { 52 + valid: true, 53 + eventType: eventType || 'unknown', 54 + payload 633 55 } 634 - } else if (!org) { 635 - console.log(`WEBHOOK INSTALLATION: Organization ${orgLogin} (GitHub ID: ${orgGitHubId}) not found for action '${action}'. Skipping update.`); 636 - return; 637 - } 638 - 639 - 640 - if (action === 'created') { 641 - // Update the organization with the new installation_id 642 - const updatedOrg = await OrganizationRepository.updateOrganization(org.id, { 643 - installation_id: installationId, 644 - // Optionally update name/avatar if they can change and are in payload 645 - name: orgLogin, 646 - avatar_url: orgAvatarUrl 647 - }); 648 - if (updatedOrg) { 649 - console.log(`WEBHOOK INSTALLATION: Stored installation_id ${installationId} for organization ${orgLogin} (DB ID: ${org.id})`); 650 - } else { 651 - console.error(`WEBHOOK INSTALLATION: Failed to update organization ${orgLogin} with installation_id ${installationId}`); 56 + } catch (error) { 57 + return NextResponse.json( 58 + { error: 'Invalid JSON payload' }, 59 + { status: 400 } 60 + ) 652 61 } 653 - 654 - // If repositories are explicitly listed (e.g. selective installation) 655 - if (payload.repositories && payload.repositories.length > 0) { 656 - console.log(`WEBHOOK INSTALLATION: Processing ${payload.repositories.length} repositories for new installation on ${orgLogin}`); 657 - for (const repoData of payload.repositories) { 658 - try { 659 - // Use findOrCreateRepository to add these to our system, linking them to the organization 660 - // Assuming findOrCreateRepository can link to org.id if it's not already doing so. 661 - // This might require findOrCreateRepository to accept org.id or for you to have a separate step. 662 - // For now, just logging and ensuring findOrCreateRepository is robust. 663 - await findOrCreateRepository({ 664 - github_id: repoData.id, 665 - name: repoData.name, 666 - full_name: repoData.full_name, 667 - private: repoData.private, 668 - organization_id: org.id, // Crucial link 669 - description: null, // Default value 670 - is_tracked: true // Default value, assume tracked upon installation 671 - }); 672 - console.log(`WEBHOOK INSTALLATION: Ensured repository ${repoData.full_name} is in DB and linked to org ${org.id}.`); 673 - } catch (repoError) { 674 - logError("handleInstallationEvent - findOrCreateRepository", repoError, { repo_full_name: repoData.full_name, org_id: org.id }); 675 - } 676 - } 677 - } 678 - 679 - 680 - } else if (action === 'deleted') { 681 - // Clear the installation_id 682 - const updatedOrg = await OrganizationRepository.updateOrganization(org.id, { 683 - installation_id: null, 684 - }); 685 - if (updatedOrg) { 686 - console.log(`WEBHOOK INSTALLATION: Cleared installation_id for organization ${orgLogin} (DB ID: ${org.id}) due to app uninstallation.`); 687 - } else { 688 - console.error(`WEBHOOK INSTALLATION: Failed to clear installation_id for organization ${orgLogin}`); 689 - } 690 - } else if (action === 'suspend') { 691 - // Optionally handle suspension, e.g., by setting installation_id to null or a special status 692 - console.log(`WEBHOOK INSTALLATION: App suspended for organization ${orgLogin}. Installation ID ${installationId} might be invalid.`); 693 - await OrganizationRepository.updateOrganization(org.id, { installation_id: null }); // Example: clear it 694 - } else if (action === 'unsuspend') { 695 - // App unsuspended, restore installation_id 696 - console.log(`WEBHOOK INSTALLATION: App unsuspended for organization ${orgLogin}. Restoring Installation ID ${installationId}.`); 697 - await OrganizationRepository.updateOrganization(org.id, { installation_id: installationId }); 698 - } else { 699 - console.log(`WEBHOOK INSTALLATION: Unhandled action '${action}' for organization ${orgLogin}.`); 700 62 } 701 - } catch (error) { 702 - logError("handleInstallationEvent", error, { action, orgGitHubId, orgLogin, installationId }); 703 - } 704 - } 705 63 706 - async function fetchAdditionalPRData( 707 - repository: PullRequestPayload['repository'], 708 - pr: PullRequestPayload['pull_request'], 709 - prDbId: number, 710 - organizationId: number, 711 - fullPayload?: PullRequestPayload 712 - ) { 713 - console.log(`WEBHOOK ADDITIONAL DATA: Fetching additional data for PR #${pr.number} in org ${organizationId}, PR DB ID=${prDbId}`); 714 - console.log(`WEBHOOK DEBUG: Repository owner: ${repository.owner.login}, Repository name: ${repository.name}`); 715 - 716 - if (!organizationId) { 717 - console.error('WEBHOOK ERROR: Organization ID not provided to fetchAdditionalPRData. Cannot proceed.'); 718 - return; 719 - } 64 + const { eventType, payload } = validation 65 + console.log(`[Webhook] Processing event: ${eventType}.${payload?.action || 'unknown'}`) 720 66 721 - try { 722 - let installationIdToUse: number | undefined; 723 - const orgDetails = await findOrganizationById(organizationId); 724 - 725 - if (orgDetails) { 726 - // Use the installation_id from the DB if available (this is the most reliable source) 727 - installationIdToUse = orgDetails.installation_id ?? undefined; 728 - if (installationIdToUse) { 729 - console.log(`WEBHOOK DEBUG: Found installation_id in DB for organization_id ${organizationId}: ${installationIdToUse}`); 730 - } else { 731 - console.warn(`WEBHOOK WARNING: installation_id was not found or was null in database for organization_id: ${organizationId}.`); 732 - } 733 - } else { 734 - console.error(`WEBHOOK ERROR: Organization with ID ${organizationId} NOT FOUND in database.`); 735 - // Update PR status to indicate an error if org not found 736 - await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'error', error_message: 'Organization not found in DB for AI processing' }); 737 - return; 738 - } 739 - 740 - // If DB didn't have it, try to get it from the current webhook payload as a fallback 741 - // This might happen if the installation event is processed slightly after a PR event. 742 - if (!installationIdToUse) { 743 - const payloadInstallId = fullPayload?.installation?.id || repository?.installation?.id; 744 - if (payloadInstallId) { 745 - installationIdToUse = payloadInstallId; 746 - console.log(`WEBHOOK DEBUG: Using installation_id from current payload as fallback: ${installationIdToUse} (org_id: ${organizationId})`); 747 - // Optionally, you could consider updating the org record here if a payload ID is found and DB was null, 748 - // but handleInstallationEvent should be the primary source of truth for storing it. 749 - } else { 750 - console.warn(`WEBHOOK WARNING: No installation_id in DB and no installation_id in current payload for org ${organizationId}.`); 751 - } 752 - } 67 + // Get GitHub service from DI container 68 + const githubService = await getService<IGitHubService>('GitHubService') 753 69 754 - // 1. Get API settings (model + API key) 755 - console.log(`WEBHOOK AI SETTINGS: Getting AI settings for organization ${organizationId}`); 756 - const aiSettings = await getOrganizationAiSettings(organizationId); 757 - const selectedModelId = aiSettings.selectedModelId; 758 - 759 - if (!selectedModelId || selectedModelId === '__none__') { 760 - console.log(`WEBHOOK INFO: AI categorization disabled for organization ${organizationId} (no model selected).`); 761 - await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'skipped', error_message: 'AI categorization disabled for organization' }); 762 - return; 763 - } 764 - 765 - const provider = aiSettings.provider; 766 - 767 - if (!provider) { 768 - console.log(`WEBHOOK INFO: AI provider not set for organization ${organizationId}.`); 769 - await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'skipped', error_message: 'AI provider not set for organization' }); 770 - return; 771 - } 772 - 773 - console.log(`WEBHOOK MODEL: Using AI model ${selectedModelId} with provider ${provider}`); 774 - 775 - const apiKey = await getOrganizationApiKey(organizationId, provider); 776 - if (!apiKey) { 777 - console.warn(`WEBHOOK WARNING: API key for provider ${provider} not set for organization ${organizationId}. Skipping AI categorization.`); 778 - await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'skipped', error_message: `API key for ${provider} not set for organization` }); 779 - return; 780 - } 781 - console.log(`WEBHOOK API KEY: API key found for provider ${provider}.`); 782 - 783 - // 2. Instantiate AI Client (using apiKey) 784 - let aiClientProvider; 785 - try { 786 - switch (provider) { 787 - case 'openai': 788 - aiClientProvider = createOpenAI({ apiKey: apiKey }); 789 - break; 790 - case 'google': 791 - aiClientProvider = createGoogleGenerativeAI({ apiKey: apiKey }); 792 - break; 793 - case 'anthropic': 794 - aiClientProvider = createAnthropic({ apiKey: apiKey }); 795 - break; 796 - default: 797 - console.error(`WEBHOOK ERROR: Unsupported AI provider: ${provider} for organization ${organizationId}`); 798 - await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'error', error_message: `Unsupported AI provider: ${provider}` }); 799 - return; 800 - } 801 - } catch (error) { 802 - console.error(`WEBHOOK ERROR: Error instantiating AI client provider for ${provider}:`, error); 803 - await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'error', error_message: `Error instantiating AI client for ${provider}` }); 804 - return; 805 - } 806 - console.log(`WEBHOOK AI CLIENT: AI client provider instantiated for: ${provider}`); 70 + // Process the webhook event 71 + const result = await githubService.processWebhookEvent(eventType!, payload) 807 72 808 - const modelInstance = aiClientProvider(selectedModelId); 809 - if (!modelInstance) { 810 - console.error(`WEBHOOK ERROR: Could not get model instance for ${selectedModelId} from provider ${provider}`); 811 - await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'error', error_message: `Could not get AI model instance ${selectedModelId}` }); 812 - return; 813 - } 814 - 815 - // 3. Create GitHub Client 816 - let githubClient: GitHubClient; 817 - 818 - if (installationIdToUse) { 819 - try { 820 - console.log(`WEBHOOK INSTALLATION ID: Attempting to use GitHub App installation ID: ${installationIdToUse}`); 821 - githubClient = await createInstallationClient(installationIdToUse); 822 - console.log(`WEBHOOK CLIENT: Successfully created GitHub client with installation ID ${installationIdToUse}`); 823 - } catch (clientError) { 824 - console.error(`WEBHOOK ERROR: Failed to create GitHub client with installation ID ${installationIdToUse}:`, clientError); 825 - await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'error', error_message: 'Failed to create GitHub client with installation ID' }); 826 - return; 827 - } 73 + if (result.processed) { 74 + console.log(`[Webhook] Successfully processed ${eventType} event:`, result.actions) 75 + return NextResponse.json({ 76 + success: true, 77 + actions: result.actions 78 + }) 828 79 } else { 829 - console.error('WEBHOOK ERROR: No installation ID available for organization. Cannot fetch PR diff.'); 830 - await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'error', error_message: 'GitHub authentication failed (no installation ID)' }); 831 - return; 832 - } 833 - 834 - // 4. Fetch PR Diff and Categorize (using githubClient) 835 - console.log(`WEBHOOK FETCHING PR DIFF: Fetching PR diff for ${repository.full_name}#${pr.number}`); 836 - 837 - let diff: string | null = null; 838 - try { 839 - // Try to fetch the PR diff 840 - diff = await githubClient.getPullRequestDiff(repository.owner.login, repository.name, pr.number); 841 - } catch (diffError: any) { 842 - // Check if it's a token expiration error 843 - if (diffError.message && ( 844 - diffError.message.includes('expired') || 845 - diffError.message.includes('GitHub token expired') || 846 - diffError.message.includes('invalid') || 847 - diffError.message.includes('Bad credentials') || 848 - diffError.status === 401) 849 - ) { 850 - console.warn(`WEBHOOK TOKEN ERROR: Installation token for ${installationIdToUse} appears to be expired or invalid. Attempting to create a new client.`); 851 - 852 - // Try once more with a fresh client 853 - try { 854 - // Create fresh client - the token cache will return a new token since the old one was cleared 855 - githubClient = await createInstallationClient(installationIdToUse); 856 - console.log(`WEBHOOK CLIENT RECREATED: Successfully created new GitHub client with installation ID ${installationIdToUse}`); 857 - 858 - // Retry the diff fetch 859 - diff = await githubClient.getPullRequestDiff(repository.owner.login, repository.name, pr.number); 860 - console.log(`WEBHOOK DIFF RETRY: Successfully fetched PR diff on second attempt for ${repository.full_name}#${pr.number}`); 861 - } catch (retryError) { 862 - console.error(`WEBHOOK ERROR: Failed to fetch PR diff even after token refresh:`, retryError); 863 - await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'error', error_message: 'Failed to fetch PR diff even after token refresh' }); 864 - return; 865 - } 866 - } else { 867 - console.error(`WEBHOOK ERROR: Failed to fetch PR diff for ${repository.full_name}#${pr.number}:`, diffError); 868 - await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'error', error_message: 'Failed to fetch PR diff' }); 869 - return; 870 - } 871 - } 872 - 873 - if (!diff) { 874 - console.warn(`WEBHOOK WARNING: Could not fetch PR diff for ${repository.full_name}#${pr.number}. Skipping categorization.`); 875 - await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'skipped', error_message: 'Could not fetch PR diff' }); 876 - return; 877 - } 878 - 879 - const orgCategories = await getOrganizationCategories(organizationId); 880 - const categoryNames = orgCategories.map(c => c.name); 881 - console.log(`WEBHOOK CATEGORIES: Organization ${organizationId} has ${categoryNames.length} categories:`, categoryNames); 882 - 883 - if (categoryNames.length === 0) { 884 - console.warn(`WEBHOOK WARNING: No categories found for organization ${organizationId}. Skipping categorization.`); 885 - await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'skipped', error_message: 'No categories configured for organization' }); 886 - return; 887 - } 888 - 889 - await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'processing' }); 890 - 891 - // Create a more restrictive prompt with numbered options 892 - const categoryOptions = categoryNames.map((name, index) => `${index + 1}. ${name}`).join('\n'); 893 - 894 - const systemPrompt = `You are an expert at categorizing GitHub pull requests. You MUST select EXACTLY ONE category from the numbered list below. Do not create new categories or modify the category names. 895 - 896 - AVAILABLE CATEGORIES: 897 - ${categoryOptions} 898 - 899 - Analyze the pull request and respond in this EXACT format: 900 - Category: [EXACT CATEGORY NAME FROM LIST] 901 - Confidence: [NUMBER BETWEEN 0.0 AND 1.0] 902 - 903 - Example: Category: Bug Fix, Confidence: 0.85 904 - 905 - IMPORTANT: The category name must match EXACTLY one of the categories listed above. Do not abbreviate, modify, or create new category names.`; 906 - 907 - const userPrompt = `Title: ${pr.title} 908 - Body: ${pr.body || ''} 909 - Diff: 910 - ${diff}`; 911 - 912 - console.log(`WEBHOOK GENERATING TEXT: Generating text with model ${selectedModelId} for PR #${pr.number}`); 913 - 914 - try { 915 - const { text } = await generateText({ 916 - model: modelInstance, 917 - system: systemPrompt, 918 - prompt: userPrompt, 919 - }); 920 - 921 - console.log(`WEBHOOK AI RESPONSE: AI Response for PR #${pr.number}: ${text}`); 922 - 923 - // Parse AI response - handle both comma-separated and newline-separated formats 924 - const categoryMatch = text.match(/Category:\s*(.*?)(?:,|\n)\s*Confidence:\s*(\d+\.?\d*)/i); 925 - if (categoryMatch && categoryMatch[1] && categoryMatch[2]) { 926 - const suggestedCategoryName = categoryMatch[1].trim(); 927 - const confidence = parseFloat(categoryMatch[2]); 928 - 929 - // First, try exact match 930 - let category = await findCategoryByNameAndOrgFromRepo(organizationId, suggestedCategoryName); 931 - console.log(`WEBHOOK CATEGORY LOOKUP: Category '${suggestedCategoryName}' found in DB? ${!!category}`, 932 - category ? `id=${category.id}, org_id=${category.organization_id}` : "NOT FOUND"); 933 - 934 - // If no exact match, try fuzzy matching as fallback 935 - if (!category) { 936 - console.log(`WEBHOOK FUZZY MATCHING: Attempting fuzzy match for '${suggestedCategoryName}' in categories: ${categoryNames.join(', ')}`); 937 - 938 - // Simple fuzzy matching - find closest match 939 - let bestMatch = null; 940 - let bestScore = 0; 941 - 942 - for (const categoryName of categoryNames) { 943 - // Calculate similarity score (simple case-insensitive contains check + length similarity) 944 - const suggested = suggestedCategoryName.toLowerCase().trim(); 945 - const candidate = categoryName.toLowerCase().trim(); 946 - 947 - let score = 0; 948 - if (suggested === candidate) { 949 - score = 1.0; // Perfect match 950 - } else if (suggested.includes(candidate) || candidate.includes(suggested)) { 951 - score = 0.8; // Contains match 952 - } else { 953 - // Levenshtein-like simple scoring 954 - const maxLength = Math.max(suggested.length, candidate.length); 955 - const commonChars = suggested.split('').filter(char => candidate.includes(char)).length; 956 - score = commonChars / maxLength; 957 - } 958 - 959 - if (score > bestScore && score > 0.6) { // Minimum threshold 960 - bestScore = score; 961 - bestMatch = categoryName; 962 - } 963 - } 964 - 965 - if (bestMatch) { 966 - console.log(`WEBHOOK FUZZY MATCH: Found fuzzy match '${bestMatch}' for '${suggestedCategoryName}' with score ${bestScore}`); 967 - category = await findCategoryByNameAndOrgFromRepo(organizationId, bestMatch); 968 - } 969 - } 970 - 971 - if (category) { 972 - try { 973 - const updateCategoryParams = { 974 - pr_id: prDbId, 975 - category_id: category.id, 976 - confidence: confidence 977 - }; 978 - 979 - logOperation("updatePullRequestCategory", updateCategoryParams); 980 - 981 - await updatePullRequestCategory(prDbId, category.id, confidence); 982 - await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'completed' }); 983 - console.log(`WEBHOOK CATEGORY ASSIGNED: PR #${pr.number} categorized as '${category.name}' (ID: ${category.id}) with confidence ${confidence}`); 984 - } catch (categoryError) { 985 - logError("updatePullRequestCategory", categoryError, { 986 - pr_id: prDbId, 987 - category_id: category.id, 988 - category_name: category.name, 989 - confidence: confidence 990 - }); 991 - await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'error', error_message: 'Failed to update PR with category' }); 992 - } 993 - } else { 994 - console.warn(`WEBHOOK CATEGORY ERROR: AI suggested category '${suggestedCategoryName}' not found for organization ${organizationId}.`); 995 - await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'error', error_message: `AI suggested category '${suggestedCategoryName}' not found` }); 996 - } 997 - } else { 998 - console.warn(`WEBHOOK PARSE ERROR: Could not parse category and confidence from AI response for PR #${pr.number}: ${text}`); 999 - await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'error', error_message: 'Could not parse AI category response' }); 1000 - } 1001 - } catch (aiError) { 1002 - logError("AI Text Generation", aiError, { 1003 - pr_id: prDbId, 1004 - model: selectedModelId 1005 - }); 1006 - await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'error', error_message: 'AI text generation failed' }); 80 + console.warn(`[Webhook] Failed to process ${eventType} event:`, result.errors) 81 + return NextResponse.json({ 82 + success: false, 83 + errors: result.errors 84 + }, { status: 422 }) 1007 85 } 1008 86 1009 87 } catch (error) { 1010 - logError("fetchAdditionalPRData main try-catch", error, { 1011 - repo_name: repository.full_name, 1012 - pr_number: pr.number, 1013 - pr_id: prDbId, 1014 - org_id: organizationId 1015 - }); 1016 - // Attempt to update PR status to error if not already handled 1017 - try { 1018 - await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'error', error_message: 'Critical error in fetchAdditionalPRData' }); 1019 - } catch (updateErr) { 1020 - logError("fetchAdditionalPRData - final PR status update error", updateErr, { pr_id: prDbId }); 1021 - } 88 + console.error('[Webhook] Error processing webhook:', error) 89 + return NextResponse.json( 90 + { 91 + error: `Failed to process webhook: ${error instanceof Error ? error.message : 'Unknown error'}` 92 + }, 93 + { status: 500 } 94 + ) 1022 95 } 1023 96 } 1024 97 1025 - // Helper to map GitHub review state to our enum 1026 - function mapReviewState(state: string): PRReview['state'] { 1027 - switch (state.toLowerCase()) { 1028 - case 'approved': 1029 - return 'approved'; 1030 - case 'changes_requested': 1031 - return 'changes_requested'; 1032 - case 'commented': 1033 - return 'commented'; 1034 - case 'dismissed': 1035 - return 'dismissed'; 1036 - default: 1037 - return 'commented'; // Default fallback 1038 - } 1039 - } 98 + /** 99 + * Health check endpoint for webhook configuration 100 + */ 101 + export async function GET() { 102 + return NextResponse.json({ 103 + status: 'ready', 104 + timestamp: new Date().toISOString(), 105 + webhook_url: process.env.APP_URL ? `${process.env.APP_URL}/api/webhook/github` : 'not configured' 106 + }) 107 + }
+9 -9
auth.ts
··· 132 132 export const config = { 133 133 providers: [ 134 134 GitHub({ 135 - clientId: process.env.GITHUB_CLIENT_ID || 'demo-client-id', 136 - clientSecret: process.env.GITHUB_CLIENT_SECRET || 'demo-client-secret', 135 + clientId: process.env.GITHUB_OAUTH_CLIENT_ID || 'demo-client-id', 136 + clientSecret: process.env.GITHUB_OAUTH_CLIENT_SECRET || 'demo-client-secret', 137 137 authorization: { 138 138 params: { 139 139 scope: 'read:user user:email repo read:org', ··· 154 154 155 155 // Allow dashboard access in demo mode (when no real GitHub credentials configured) 156 156 if (pathname.startsWith("/dashboard")) { 157 - const hasGitHubCredentials = process.env.GITHUB_CLIENT_ID && 158 - process.env.GITHUB_CLIENT_SECRET && 159 - process.env.GITHUB_CLIENT_ID !== 'demo-client-id'; 157 + const hasGitHubCredentials = process.env.GITHUB_OAUTH_CLIENT_ID && 158 + process.env.GITHUB_OAUTH_CLIENT_SECRET && 159 + process.env.GITHUB_OAUTH_CLIENT_ID !== 'demo-client-id'; 160 160 161 161 if (!hasGitHubCredentials) { 162 162 // Demo mode: allow dashboard access without authentication ··· 176 176 } 177 177 178 178 // Check if we're in demo mode (no real GitHub config) 179 - const isDemoMode = !process.env.GITHUB_CLIENT_ID || process.env.GITHUB_CLIENT_ID === 'demo-client-id'; 179 + const isDemoMode = !process.env.GITHUB_OAUTH_CLIENT_ID || process.env.GITHUB_OAUTH_CLIENT_ID === 'demo-client-id'; 180 180 181 181 if (isDemoMode) { 182 182 // Demo mode: use simple session without database calls ··· 238 238 }, 239 239 async signIn({ user, account, profile }) { 240 240 // Skip database operations if GitHub credentials not properly configured (demo mode) 241 - const hasGitHubCredentials = process.env.GITHUB_CLIENT_ID && 242 - process.env.GITHUB_CLIENT_SECRET && 243 - process.env.GITHUB_CLIENT_ID !== 'demo-client-id'; 241 + const hasGitHubCredentials = process.env.GITHUB_OAUTH_CLIENT_ID && 242 + process.env.GITHUB_OAUTH_CLIENT_SECRET && 243 + process.env.GITHUB_OAUTH_CLIENT_ID !== 'demo-client-id'; 244 244 245 245 if (!hasGitHubCredentials) { 246 246 // Demo mode: allow sign-in without database operations
+25 -5
components/nav-user.tsx
··· 2 2 3 3 import { signIn, signOut, useSession } from "next-auth/react" 4 4 import { useTheme } from "next-themes" 5 + import { useState } from "react" 5 6 import { 6 7 SidebarMenu, 7 8 SidebarMenuItem, ··· 21 22 DropdownMenuSubTrigger, 22 23 DropdownMenuPortal 23 24 } from "@/components/ui/dropdown-menu" 24 - import { IconBrandGithub, IconMoon, IconSun, IconDeviceDesktop } from "@tabler/icons-react" 25 + import { IconBrandGithub, IconMoon, IconSun, IconDeviceDesktop, IconLoader2 } from "@tabler/icons-react" 25 26 26 27 export function NavUser() { 27 28 const { } = useSidebar() 28 29 const { data: session } = useSession() 29 30 const { setTheme, theme } = useTheme() 31 + const [isSigningIn, setIsSigningIn] = useState(false) 32 + 33 + const handleSignIn = async () => { 34 + setIsSigningIn(true) 35 + try { 36 + await signIn("github", { callbackUrl: "/dashboard" }) 37 + } catch (error) { 38 + console.error("Sign in error:", error) 39 + setIsSigningIn(false) 40 + } 41 + // Note: We don't set loading to false here because the page will redirect 42 + } 30 43 31 44 // If not authenticated, show GitHub login button 32 45 if (!session) { ··· 36 49 <SidebarMenuButton 37 50 size="lg" 38 51 className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" 39 - onClick={() => signIn("github", { callbackUrl: "/dashboard" })} 52 + onClick={handleSignIn} 53 + disabled={isSigningIn} 40 54 > 41 55 <div className="flex items-center justify-center h-8 w-8"> 42 - <IconBrandGithub className="h-5 w-5" /> 56 + {isSigningIn ? ( 57 + <IconLoader2 className="h-5 w-5 animate-spin" /> 58 + ) : ( 59 + <IconBrandGithub className="h-5 w-5" /> 60 + )} 43 61 </div> 44 62 <div className="grid flex-1 text-left text-sm leading-tight"> 45 - <span className="truncate font-medium">Sign in</span> 63 + <span className="truncate font-medium"> 64 + {isSigningIn ? "Signing in..." : "Sign in"} 65 + </span> 46 66 <span className="text-muted-foreground truncate text-xs"> 47 - with GitHub 67 + {isSigningIn ? "Please wait..." : "with GitHub"} 48 68 </span> 49 69 </div> 50 70 </SidebarMenuButton>
+23 -4
components/ui/github-signin-button.tsx
··· 2 2 3 3 import { signIn } from "next-auth/react"; 4 4 import { Button } from "@/components/ui/button"; 5 - import { IconBrandGithub } from "@tabler/icons-react"; 5 + import { IconBrandGithub, IconLoader2 } from "@tabler/icons-react"; 6 + import { useState } from "react"; 6 7 7 8 export function GitHubSignInButton({ callbackUrl = "/dashboard" }) { 9 + const [isLoading, setIsLoading] = useState(false); 10 + 11 + const handleSignIn = async () => { 12 + setIsLoading(true); 13 + try { 14 + await signIn("github", { callbackUrl }); 15 + } catch (error) { 16 + console.error("Sign in error:", error); 17 + setIsLoading(false); 18 + } 19 + // Note: We don't set loading to false here because the page will redirect 20 + }; 21 + 8 22 return ( 9 23 <Button 10 24 className="w-full flex items-center justify-center gap-2" 11 - onClick={() => signIn("github", { callbackUrl })} 25 + onClick={handleSignIn} 26 + disabled={isLoading} 12 27 > 13 - <IconBrandGithub size={20} /> 14 - Continue with GitHub 28 + {isLoading ? ( 29 + <IconLoader2 size={20} className="animate-spin" /> 30 + ) : ( 31 + <IconBrandGithub size={20} /> 32 + )} 33 + {isLoading ? "Signing in..." : "Continue with GitHub"} 15 34 </Button> 16 35 ); 17 36 }
+2 -2
environment.example
··· 19 19 # Create at: https://github.com/settings/developers 20 20 # Callback URL: ${NEXTAUTH_URL}/api/auth/callback/github 21 21 22 - GITHUB_CLIENT_ID=your_github_oauth_client_id 23 - GITHUB_CLIENT_SECRET=your_github_oauth_client_secret 22 + GITHUB_OAUTH_CLIENT_ID=your_github_oauth_client_id 23 + GITHUB_OAUTH_CLIENT_SECRET=your_github_oauth_client_secret 24 24 25 25 # =========================================== 26 26 # GITHUB APP (Required for repository access)
+2 -2
jest.setup.ts
··· 18 18 // Mock environment variables for tests 19 19 process.env.TURSO_URL = 'libsql://test.turso.io'; 20 20 process.env.TURSO_TOKEN = 'test-token'; 21 - process.env.GITHUB_CLIENT_ID = 'test-github-client-id'; 22 - process.env.GITHUB_CLIENT_SECRET = 'test-github-client-secret'; 21 + process.env.GITHUB_OAUTH_CLIENT_ID = 'test-github-client-id'; 22 + process.env.GITHUB_OAUTH_CLIENT_SECRET = 'test-github-client-secret'; 23 23 process.env.GITHUB_APP_ID = '123456'; 24 24 process.env.GITHUB_APP_PRIVATE_KEY = `-----BEGIN RSA PRIVATE KEY----- 25 25 test-private-key
+15 -1
lib/core/container/di-container.ts
··· 12 12 IAuthService, 13 13 IOrganizationRepository, 14 14 IRepository, 15 - IGitHubService 15 + IGitHubService, 16 + IGitHubAppService 16 17 } from '../ports' 17 18 18 19 // Service registry types ··· 23 24 | 'OrganizationRepository' 24 25 | 'Repository' 25 26 | 'GitHubService' 27 + | 'GitHubAppService' 26 28 27 29 export type ServiceInstance = 28 30 | IPullRequestRepository ··· 31 33 | IOrganizationRepository 32 34 | IRepository 33 35 | IGitHubService 36 + | IGitHubAppService 34 37 35 38 export interface ServiceFactory<T = ServiceInstance> { 36 39 (): T | Promise<T> ··· 123 126 const { DemoGitHubService } = await import('../../infrastructure/adapters/demo') 124 127 return new DemoGitHubService() 125 128 }, true) 129 + 130 + this.register('GitHubAppService', async () => { 131 + const { GitHubAppService } = await import('../../infrastructure/adapters/github') 132 + return new GitHubAppService() 133 + }, true) 126 134 } 127 135 128 136 /** ··· 171 179 return new DemoGitHubService() 172 180 }, true) 173 181 } 182 + 183 + // Always register GitHub App service - it handles its own configuration validation 184 + this.register('GitHubAppService', async () => { 185 + const { GitHubAppService } = await import('../../infrastructure/adapters/github') 186 + return new GitHubAppService() 187 + }, true) 174 188 175 189 console.log('[DI Container] Production services registered successfully') 176 190 } catch (error) {
+95
lib/core/ports/github-app.port.ts
··· 1 + /** 2 + * GitHub App Service Port 3 + * Defines the contract for GitHub App operations 4 + */ 5 + 6 + import { Organization } from '../domain/entities/organization' 7 + import { Repository } from '../domain/entities/repository' 8 + 9 + export interface InstallationInfo { 10 + id: number 11 + account: { 12 + id: number 13 + login: string 14 + type: 'Organization' | 'User' 15 + avatarUrl?: string 16 + } 17 + permissions: Record<string, string> 18 + repositorySelection: 'all' | 'selected' 19 + repositoryCount?: number 20 + suspendedAt?: Date | null 21 + createdAt: Date 22 + updatedAt: Date 23 + } 24 + 25 + export interface InstallationToken { 26 + token: string 27 + expiresAt: Date 28 + permissions: Record<string, string> 29 + } 30 + 31 + export interface IGitHubAppService { 32 + /** 33 + * Generate a JWT token for GitHub App authentication 34 + */ 35 + generateAppJWT(): Promise<string> 36 + 37 + /** 38 + * Get an installation access token for a specific installation ID 39 + * This method handles caching and automatic refresh 40 + */ 41 + getInstallationToken(installationId: number): Promise<string> 42 + 43 + /** 44 + * Get installation information for a specific installation ID 45 + */ 46 + getInstallation(installationId: number): Promise<InstallationInfo> 47 + 48 + /** 49 + * List all installations for the GitHub App 50 + */ 51 + listInstallations(): Promise<InstallationInfo[]> 52 + 53 + /** 54 + * Get installation for a specific organization by GitHub ID 55 + */ 56 + getOrganizationInstallation(orgGitHubId: number): Promise<InstallationInfo | null> 57 + 58 + /** 59 + * Get repositories accessible by an installation 60 + */ 61 + getInstallationRepositories(installationId: number): Promise<Repository[]> 62 + 63 + /** 64 + * Check if GitHub App is installed for an organization 65 + */ 66 + isInstalledForOrganization(orgLogin: string): Promise<{ 67 + isInstalled: boolean 68 + installationId?: number 69 + permissions?: Record<string, string> 70 + }> 71 + 72 + /** 73 + * Sync installation data with database 74 + */ 75 + syncInstallation(installationId: number): Promise<{ 76 + organization: Organization 77 + repositories: Repository[] 78 + errors: string[] 79 + }> 80 + 81 + /** 82 + * Clear cached tokens for an installation (useful when tokens are revoked) 83 + */ 84 + clearTokenCache(installationId?: number): Promise<void> 85 + 86 + /** 87 + * Validate if the GitHub App is properly configured 88 + */ 89 + validateConfiguration(): Promise<{ 90 + isValid: boolean 91 + errors: string[] 92 + appId?: string 93 + hasPrivateKey: boolean 94 + }> 95 + }
+1
lib/core/ports/index.ts
··· 8 8 export * from './auth.port' 9 9 export * from './organization.port' 10 10 export * from './github.port' 11 + export * from './github-app.port' 11 12 export * from './repository.port'
+3 -3
lib/env-validation.ts
··· 8 8 TURSO_POOL_SIZE: z.string().regex(/^\d+$/).optional(), 9 9 10 10 // GitHub OAuth (optional for demo mode) 11 - GITHUB_CLIENT_ID: z.string().optional(), 12 - GITHUB_CLIENT_SECRET: z.string().optional(), 11 + GITHUB_OAUTH_CLIENT_ID: z.string().optional(), 12 + GITHUB_OAUTH_CLIENT_SECRET: z.string().optional(), 13 13 14 14 // GitHub App (optional for demo mode) 15 15 GITHUB_APP_ID: z.string().optional(), 16 16 GITHUB_APP_PRIVATE_KEY: z.string().optional(), 17 - // If you don't use a separate webhook secret, we will fallback to GITHUB_CLIENT_SECRET at runtime 17 + // If you don't use a separate webhook secret, we will fallback to GITHUB_OAUTH_CLIENT_SECRET at runtime 18 18 GITHUB_WEBHOOK_SECRET: z.string().optional(), 19 19 NEXT_PUBLIC_GITHUB_APP_SLUG: z.string().optional(), 20 20
+18 -139
lib/github-app.ts
··· 1 - import jwt from 'jsonwebtoken'; 2 - import { GitHubClient } from './github'; 3 - 4 - // Token cache to store installation tokens with expiration times 5 - interface CachedToken { 6 - token: string; 7 - expiresAt: number; // Unix timestamp in ms when token expires 8 - } 9 - 10 - // In-memory cache for installation tokens 11 - // In a production environment, you might want to use a distributed cache like Redis 12 - const tokenCache: Map<number, CachedToken> = new Map(); 13 - 14 1 /** 15 - * Generate a GitHub App JWT token used for authenticating as the GitHub App 2 + * GitHub App Legacy API 3 + * Backward compatibility layer for existing GitHub App functionality 4 + * 5 + * @deprecated Use the new GitHubAppService from DI container instead: 6 + * const githubAppService = await getService<IGitHubAppService>('GitHubAppService') 16 7 */ 17 - export async function generateAppJwt(): Promise<string> { 18 - const appId = process.env.GITHUB_APP_ID; 19 - 20 - if (!appId) { 21 - throw new Error('GitHub App ID (GITHUB_APP_ID) is not configured'); 22 - } 23 8 24 - // For debugging purposes, explicitly define the format of the private key 25 - // This shows exactly what format is expected 26 - 27 - // First try to get the key from environment 28 - let privateKey = process.env.GITHUB_APP_PRIVATE_KEY; 29 - 30 - // Debug only - log length of private key 31 - console.log(`Private key from env length: ${privateKey?.length || 0}`); 32 - 33 - if (privateKey) { 34 - // Remove any quotes that might have been included 35 - privateKey = privateKey.replace(/^["']|["']$/g, ''); 36 - 37 - // Replace escaped newlines with actual newlines 38 - privateKey = privateKey.replace(/\\n/g, '\n'); 39 - 40 - console.log('Using private key from environment variables'); 41 - } else { 42 - console.error('No private key found in environment variables'); 43 - throw new Error('GitHub App private key (GITHUB_APP_PRIVATE_KEY) is not configured'); 44 - } 45 - 46 - // JWT expiration (10 minutes maximum) 47 - const now = Math.floor(Date.now() / 1000); 48 - 49 - // Create JWT payload 50 - const payload = { 51 - // Issued at time, 60 seconds in the past to allow for clock drift 52 - iat: now - 60, 53 - // Expiration time (10 minutes from now) 54 - exp: now + (10 * 60), 55 - // GitHub App ID 56 - iss: appId 57 - }; 9 + // Re-export functions from the new adapter for backward compatibility 10 + export { 11 + generateAppJwt, 12 + getInstallationToken, 13 + createInstallationClient, 14 + clearTokenFromCache, 15 + clearTokenCache 16 + } from './infrastructure/adapters/github/github-app.adapter' 58 17 59 - // Sign the JWT with the GitHub App private key 60 - try { 61 - // Log the first and last few characters of the key for debugging 62 - console.log('Key format check:'); 63 - console.log('Starts with:', privateKey.substring(0, 40)); 64 - console.log('Ends with:', privateKey.substring(privateKey.length - 40)); 65 - 66 - return jwt.sign(payload, privateKey, { algorithm: 'RS256' }); 67 - } catch (error) { 68 - console.error('Error generating GitHub App JWT:', error); 69 - throw new Error('Failed to generate GitHub App JWT: ' + (error instanceof Error ? error.message : 'Unknown error')); 70 - } 71 - } 72 - 73 - /** 74 - * Get an installation access token for a specific installation ID 75 - * This implementation includes token caching and revalidation 76 - */ 77 - export async function getInstallationToken(installationId: number): Promise<string> { 78 - try { 79 - // Check if we have a valid cached token 80 - const cachedToken = tokenCache.get(installationId); 81 - const bufferTimeMs = 5 * 60 * 1000; // 5 minute buffer before expiration 82 - const now = Date.now(); 83 - 84 - // Use cached token if it exists and isn't close to expiring 85 - if (cachedToken && (cachedToken.expiresAt - now > bufferTimeMs)) { 86 - console.log(`Using cached token for installation ${installationId}, expires in ${Math.floor((cachedToken.expiresAt - now) / 1000 / 60)} minutes`); 87 - return cachedToken.token; 88 - } 89 - 90 - // Need to generate a new token 91 - if (cachedToken) { 92 - console.log(`Cached token for installation ${installationId} is expired or expiring soon. Generating new token.`); 93 - } else { 94 - console.log(`No cached token for installation ${installationId}. Generating new token.`); 95 - } 96 - 97 - // Generate a new JWT for GitHub App authentication 98 - const appJwt = await generateAppJwt(); 99 - 100 - // Create a temporary GitHub client using the app JWT 101 - const appClient = new GitHubClient(appJwt); 102 - 103 - // Exchange the JWT for an installation token 104 - const token = await appClient.createInstallationAccessToken(installationId); 105 - 106 - // Cache the token with its expiration time (GitHub tokens expire in 1 hour) 107 - const expiresAt = now + (60 * 60 * 1000); // Current time + 1 hour 108 - tokenCache.set(installationId, { 109 - token, 110 - expiresAt 111 - }); 112 - 113 - console.log(`Generated and cached new token for installation ${installationId}, expires in 60 minutes`); 114 - return token; 115 - } catch (error) { 116 - console.error('Error getting installation token:', error); 117 - throw new Error('Failed to get installation token: ' + (error instanceof Error ? error.message : 'Unknown error')); 118 - } 119 - } 120 - 121 - /** 122 - * Create a GitHub client authenticated with an installation token 123 - */ 124 - export async function createInstallationClient(installationId: number): Promise<GitHubClient> { 125 - const token = await getInstallationToken(installationId); 126 - return new GitHubClient(token, installationId); 127 - } 128 - 129 - /** 130 - * Clear a specific token from the cache if needed 131 - */ 132 - export function clearTokenFromCache(installationId: number): void { 133 - tokenCache.delete(installationId); 134 - console.log(`Cleared token cache for installation ${installationId}`); 135 - } 136 - 137 - /** 138 - * Clear all tokens from the cache 139 - */ 140 - export function clearTokenCache(): void { 141 - tokenCache.clear(); 142 - console.log('Cleared all installation tokens from cache'); 143 - } 18 + // Legacy type exports for backward compatibility 19 + export interface CachedToken { 20 + token: string 21 + expiresAt: number // Unix timestamp in ms when token expires 22 + }
+8 -1
lib/github.ts
··· 38 38 } 39 39 40 40 /** 41 + * Get direct access to the Octokit client for advanced operations 42 + */ 43 + get octokitClient(): Octokit { 44 + return this.octokit; 45 + } 46 + 47 + /** 41 48 * Execute a GitHub API request with automatic token expiration handling 42 49 * @param apiCall Function that executes the actual API call 43 50 * @returns Result of the API call ··· 273 280 config: { 274 281 url: webhookUrl, 275 282 content_type: 'json', 276 - secret: process.env.GITHUB_WEBHOOK_SECRET ?? process.env.GITHUB_CLIENT_SECRET, 283 + secret: process.env.GITHUB_WEBHOOK_SECRET ?? process.env.GITHUB_OAUTH_CLIENT_SECRET, 277 284 }, 278 285 events: ['pull_request', 'pull_request_review'] 279 286 });
+454
lib/infrastructure/adapters/github/github-app.adapter.ts
··· 1 + /** 2 + * GitHub App Service Adapter 3 + * Implements IGitHubAppService using GitHub App authentication 4 + */ 5 + 6 + import jwt from 'jsonwebtoken' 7 + import { IGitHubAppService, InstallationInfo, InstallationToken } from '../../../core/ports/github-app.port' 8 + import { Organization } from '../../../core/domain/entities/organization' 9 + import { Repository } from '../../../core/domain/entities/repository' 10 + import { GitHubClient } from '../../../github' 11 + import { 12 + findOrCreateOrganization, 13 + findOrCreateRepository, 14 + findOrganizationById, 15 + findRepositoryByGitHubId 16 + } from '../../../repositories' 17 + import * as OrganizationRepository from '../../../repositories/organization-repository' 18 + 19 + // Token cache to store installation tokens with expiration times 20 + interface CachedToken { 21 + token: string 22 + expiresAt: number // Unix timestamp in ms when token expires 23 + permissions: Record<string, string> 24 + } 25 + 26 + // In-memory cache for installation tokens 27 + // In production, consider using Redis for distributed caching 28 + const tokenCache: Map<number, CachedToken> = new Map() 29 + 30 + export class GitHubAppService implements IGitHubAppService { 31 + 32 + /** 33 + * Generate a JWT token for GitHub App authentication 34 + */ 35 + async generateAppJWT(): Promise<string> { 36 + const appId = process.env.GITHUB_APP_ID 37 + 38 + if (!appId) { 39 + throw new Error('GitHub App ID (GITHUB_APP_ID) is not configured') 40 + } 41 + 42 + // Get private key from environment 43 + let privateKey = process.env.GITHUB_APP_PRIVATE_KEY 44 + 45 + if (!privateKey) { 46 + throw new Error('GitHub App private key (GITHUB_APP_PRIVATE_KEY) is not configured') 47 + } 48 + 49 + // Clean up the private key format 50 + privateKey = privateKey.replace(/^["']|["']$/g, '') // Remove quotes 51 + privateKey = privateKey.replace(/\\n/g, '\n') // Replace escaped newlines 52 + 53 + // JWT expiration (10 minutes maximum per GitHub requirements) 54 + const now = Math.floor(Date.now() / 1000) 55 + 56 + const payload = { 57 + iat: now - 60, // Issued at time, 60 seconds in the past for clock drift 58 + exp: now + (10 * 60), // Expiration time (10 minutes from now) 59 + iss: appId // GitHub App ID 60 + } 61 + 62 + try { 63 + return jwt.sign(payload, privateKey, { algorithm: 'RS256' }) 64 + } catch (error) { 65 + console.error('Error generating GitHub App JWT:', error) 66 + throw new Error('Failed to generate GitHub App JWT: ' + (error instanceof Error ? error.message : 'Unknown error')) 67 + } 68 + } 69 + 70 + /** 71 + * Get an installation access token for a specific installation ID 72 + */ 73 + async getInstallationToken(installationId: number): Promise<string> { 74 + try { 75 + // Check for cached token 76 + const cachedToken = tokenCache.get(installationId) 77 + const bufferTimeMs = 5 * 60 * 1000 // 5 minute buffer before expiration 78 + const now = Date.now() 79 + 80 + // Use cached token if valid and not expiring soon 81 + if (cachedToken && (cachedToken.expiresAt - now > bufferTimeMs)) { 82 + console.log(`[GitHubApp] Using cached token for installation ${installationId}, expires in ${Math.floor((cachedToken.expiresAt - now) / 1000 / 60)} minutes`) 83 + return cachedToken.token 84 + } 85 + 86 + // Generate new token 87 + console.log(`[GitHubApp] Generating new token for installation ${installationId}`) 88 + 89 + const appJwt = await this.generateAppJWT() 90 + const appClient = new GitHubClient(appJwt) 91 + 92 + // Exchange JWT for installation token 93 + const token = await appClient.createInstallationAccessToken(installationId) 94 + 95 + // Cache token with expiration (GitHub tokens expire in 1 hour) 96 + const expiresAt = now + (60 * 60 * 1000) 97 + tokenCache.set(installationId, { 98 + token, 99 + expiresAt, 100 + permissions: {} // TODO: Get actual permissions from installation 101 + }) 102 + 103 + console.log(`[GitHubApp] Generated and cached new token for installation ${installationId}`) 104 + return token 105 + } catch (error) { 106 + console.error(`[GitHubApp] Error getting installation token for ${installationId}:`, error) 107 + throw new Error('Failed to get installation token: ' + (error instanceof Error ? error.message : 'Unknown error')) 108 + } 109 + } 110 + 111 + /** 112 + * Get installation information for a specific installation ID 113 + */ 114 + async getInstallation(installationId: number): Promise<InstallationInfo> { 115 + try { 116 + const appJwt = await this.generateAppJWT() 117 + const appClient = new GitHubClient(appJwt) 118 + 119 + // Get installation details 120 + const response = await appClient.octokitClient.apps.getInstallation({ 121 + installation_id: installationId 122 + }) 123 + 124 + const installation = response.data 125 + 126 + return { 127 + id: installation.id, 128 + account: { 129 + id: installation.account!.id, 130 + login: (installation.account as any).login, 131 + type: (installation.account as any).type as 'Organization' | 'User', 132 + avatarUrl: installation.account!.avatar_url || undefined 133 + }, 134 + permissions: (installation as any).permissions || {}, 135 + repositorySelection: (installation as any).repository_selection as 'all' | 'selected', 136 + repositoryCount: (installation as any).repository_count || undefined, 137 + suspendedAt: (installation as any).suspended_at ? new Date((installation as any).suspended_at) : null, 138 + createdAt: new Date(installation.created_at), 139 + updatedAt: new Date(installation.updated_at) 140 + } 141 + } catch (error) { 142 + console.error(`[GitHubApp] Error getting installation ${installationId}:`, error) 143 + throw new Error('Failed to get installation: ' + (error instanceof Error ? error.message : 'Unknown error')) 144 + } 145 + } 146 + 147 + /** 148 + * List all installations for the GitHub App 149 + */ 150 + async listInstallations(): Promise<InstallationInfo[]> { 151 + try { 152 + const appJwt = await this.generateAppJWT() 153 + const appClient = new GitHubClient(appJwt) 154 + 155 + const response = await appClient.octokitClient.apps.listInstallations() 156 + 157 + return response.data.map(installation => ({ 158 + id: installation.id, 159 + account: { 160 + id: installation.account!.id, 161 + login: (installation.account as any).login, 162 + type: (installation.account as any).type as 'Organization' | 'User', 163 + avatarUrl: installation.account!.avatar_url || undefined 164 + }, 165 + permissions: (installation as any).permissions || {}, 166 + repositorySelection: (installation as any).repository_selection as 'all' | 'selected', 167 + repositoryCount: (installation as any).repository_count || undefined, 168 + suspendedAt: (installation as any).suspended_at ? new Date((installation as any).suspended_at) : null, 169 + createdAt: new Date(installation.created_at), 170 + updatedAt: new Date(installation.updated_at) 171 + })) 172 + } catch (error) { 173 + console.error('[GitHubApp] Error listing installations:', error) 174 + throw new Error('Failed to list installations: ' + (error instanceof Error ? error.message : 'Unknown error')) 175 + } 176 + } 177 + 178 + /** 179 + * Get installation for a specific organization by GitHub ID 180 + */ 181 + async getOrganizationInstallation(orgGitHubId: number): Promise<InstallationInfo | null> { 182 + try { 183 + const installations = await this.listInstallations() 184 + return installations.find(installation => 185 + installation.account.id === orgGitHubId && 186 + installation.account.type === 'Organization' 187 + ) || null 188 + } catch (error) { 189 + console.error(`[GitHubApp] Error getting organization installation for ${orgGitHubId}:`, error) 190 + return null 191 + } 192 + } 193 + 194 + /** 195 + * Get repositories accessible by an installation 196 + */ 197 + async getInstallationRepositories(installationId: number): Promise<Repository[]> { 198 + try { 199 + const token = await this.getInstallationToken(installationId) 200 + const client = new GitHubClient(token, installationId) 201 + 202 + const response = await client.octokitClient.apps.listReposAccessibleToInstallation() 203 + 204 + return response.data.repositories.map(repo => ({ 205 + id: repo.id.toString(), 206 + name: repo.name, 207 + fullName: repo.full_name, 208 + description: repo.description || null, 209 + htmlUrl: repo.html_url, 210 + defaultBranch: repo.default_branch || 'main', 211 + isPrivate: repo.private, 212 + isTracked: false, 213 + isArchived: repo.archived || false, 214 + language: repo.language || null, 215 + size: repo.size || 0, 216 + stargazersCount: repo.stargazers_count || 0, 217 + forksCount: repo.forks_count || 0, 218 + openIssuesCount: repo.open_issues_count || 0, 219 + organizationId: repo.owner.id.toString(), 220 + createdAt: new Date(repo.created_at || Date.now()), 221 + updatedAt: new Date(repo.updated_at || Date.now()), 222 + pushedAt: repo.pushed_at ? new Date(repo.pushed_at) : null 223 + })) 224 + } catch (error) { 225 + console.error(`[GitHubApp] Error getting installation repositories for ${installationId}:`, error) 226 + throw new Error('Failed to get installation repositories: ' + (error instanceof Error ? error.message : 'Unknown error')) 227 + } 228 + } 229 + 230 + /** 231 + * Check if GitHub App is installed for an organization 232 + */ 233 + async isInstalledForOrganization(orgLogin: string): Promise<{ 234 + isInstalled: boolean 235 + installationId?: number 236 + permissions?: Record<string, string> 237 + }> { 238 + try { 239 + const installations = await this.listInstallations() 240 + const installation = installations.find(inst => 241 + inst.account.login === orgLogin && 242 + inst.account.type === 'Organization' 243 + ) 244 + 245 + if (installation) { 246 + return { 247 + isInstalled: true, 248 + installationId: installation.id, 249 + permissions: installation.permissions 250 + } 251 + } 252 + 253 + return { isInstalled: false } 254 + } catch (error) { 255 + console.error(`[GitHubApp] Error checking installation for org ${orgLogin}:`, error) 256 + return { isInstalled: false } 257 + } 258 + } 259 + 260 + /** 261 + * Sync installation data with database 262 + */ 263 + async syncInstallation(installationId: number): Promise<{ 264 + organization: Organization 265 + repositories: Repository[] 266 + errors: string[] 267 + }> { 268 + const errors: string[] = [] 269 + 270 + try { 271 + // Get installation info 272 + const installation = await this.getInstallation(installationId) 273 + 274 + if (installation.account.type !== 'Organization') { 275 + throw new Error('Only organization installations are supported') 276 + } 277 + 278 + console.log(`[GitHubApp] Syncing installation ${installationId} for org ${installation.account.login}`) 279 + 280 + // Create or update organization in database 281 + let organization: Organization 282 + try { 283 + const dbOrg = await OrganizationRepository.findOrganizationByGitHubId(installation.account.id) 284 + 285 + if (dbOrg) { 286 + // Update existing organization 287 + const updatedOrg = await OrganizationRepository.updateOrganization(dbOrg.id, { 288 + installation_id: installationId, 289 + name: installation.account.login, 290 + avatar_url: installation.account.avatarUrl || null 291 + }) 292 + organization = this.mapDbOrgToDomain(updatedOrg!) 293 + } else { 294 + // Create new organization 295 + const newOrg = await OrganizationRepository.createOrganization({ 296 + github_id: installation.account.id, 297 + name: installation.account.login, 298 + avatar_url: installation.account.avatarUrl || null, 299 + installation_id: installationId 300 + }) 301 + organization = this.mapDbOrgToDomain(newOrg) 302 + } 303 + } catch (error) { 304 + const errorMsg = `Failed to sync organization: ${error instanceof Error ? error.message : 'Unknown error'}` 305 + errors.push(errorMsg) 306 + throw new Error(errorMsg) 307 + } 308 + 309 + // Get and sync repositories 310 + const repositories: Repository[] = [] 311 + try { 312 + const installationRepos = await this.getInstallationRepositories(installationId) 313 + 314 + for (const repo of installationRepos) { 315 + try { 316 + await findOrCreateRepository({ 317 + github_id: parseInt(repo.id), 318 + organization_id: parseInt(organization.id), 319 + name: repo.name, 320 + full_name: repo.fullName, 321 + description: repo.description, 322 + private: repo.isPrivate, 323 + is_tracked: true // Mark as tracked since it's accessible by the app 324 + }) 325 + repositories.push(repo) 326 + } catch (repoError) { 327 + const errorMsg = `Failed to sync repository ${repo.fullName}: ${repoError instanceof Error ? repoError.message : 'Unknown error'}` 328 + errors.push(errorMsg) 329 + console.error(`[GitHubApp] ${errorMsg}`) 330 + } 331 + } 332 + } catch (error) { 333 + const errorMsg = `Failed to get installation repositories: ${error instanceof Error ? error.message : 'Unknown error'}` 334 + errors.push(errorMsg) 335 + console.error(`[GitHubApp] ${errorMsg}`) 336 + } 337 + 338 + console.log(`[GitHubApp] Synced installation ${installationId}: org=${organization.name}, repos=${repositories.length}, errors=${errors.length}`) 339 + 340 + return { 341 + organization, 342 + repositories, 343 + errors 344 + } 345 + } catch (error) { 346 + const errorMsg = `Failed to sync installation ${installationId}: ${error instanceof Error ? error.message : 'Unknown error'}` 347 + errors.push(errorMsg) 348 + console.error(`[GitHubApp] ${errorMsg}`) 349 + throw new Error(errorMsg) 350 + } 351 + } 352 + 353 + /** 354 + * Clear cached tokens for an installation 355 + */ 356 + async clearTokenCache(installationId?: number): Promise<void> { 357 + if (installationId) { 358 + tokenCache.delete(installationId) 359 + console.log(`[GitHubApp] Cleared token cache for installation ${installationId}`) 360 + } else { 361 + tokenCache.clear() 362 + console.log('[GitHubApp] Cleared all installation tokens from cache') 363 + } 364 + } 365 + 366 + /** 367 + * Validate if the GitHub App is properly configured 368 + */ 369 + async validateConfiguration(): Promise<{ 370 + isValid: boolean 371 + errors: string[] 372 + appId?: string 373 + hasPrivateKey: boolean 374 + }> { 375 + const errors: string[] = [] 376 + const appId = process.env.GITHUB_APP_ID 377 + const privateKey = process.env.GITHUB_APP_PRIVATE_KEY 378 + 379 + if (!appId) { 380 + errors.push('GITHUB_APP_ID environment variable is not set') 381 + } 382 + 383 + if (!privateKey) { 384 + errors.push('GITHUB_APP_PRIVATE_KEY environment variable is not set') 385 + } else { 386 + // Validate private key format 387 + try { 388 + await this.generateAppJWT() 389 + } catch (error) { 390 + errors.push('Invalid GitHub App private key format') 391 + } 392 + } 393 + 394 + return { 395 + isValid: errors.length === 0, 396 + errors, 397 + appId: appId || undefined, 398 + hasPrivateKey: !!privateKey 399 + } 400 + } 401 + 402 + /** 403 + * Helper method to map database organization to domain entity 404 + */ 405 + private mapDbOrgToDomain(dbOrg: any): Organization { 406 + return { 407 + id: dbOrg.id.toString(), 408 + login: dbOrg.name, // GitHub login name 409 + name: dbOrg.display_name || dbOrg.name, 410 + description: dbOrg.description || null, 411 + avatarUrl: dbOrg.avatar_url || '', 412 + type: 'Organization' as const, 413 + htmlUrl: `https://github.com/${dbOrg.name}`, 414 + isInstalled: !!dbOrg.installation_id, 415 + installationId: dbOrg.installation_id?.toString() || null, 416 + createdAt: new Date(dbOrg.created_at || Date.now()), 417 + updatedAt: new Date(dbOrg.updated_at || Date.now()) 418 + } 419 + } 420 + } 421 + 422 + /** 423 + * Legacy compatibility functions - these wrap the new service 424 + */ 425 + 426 + let githubAppService: GitHubAppService | null = null 427 + 428 + function getGitHubAppService(): GitHubAppService { 429 + if (!githubAppService) { 430 + githubAppService = new GitHubAppService() 431 + } 432 + return githubAppService 433 + } 434 + 435 + export async function generateAppJwt(): Promise<string> { 436 + return getGitHubAppService().generateAppJWT() 437 + } 438 + 439 + export async function getInstallationToken(installationId: number): Promise<string> { 440 + return getGitHubAppService().getInstallationToken(installationId) 441 + } 442 + 443 + export async function createInstallationClient(installationId: number): Promise<GitHubClient> { 444 + const token = await getInstallationToken(installationId) 445 + return new GitHubClient(token, installationId) 446 + } 447 + 448 + export function clearTokenFromCache(installationId: number): void { 449 + getGitHubAppService().clearTokenCache(installationId) 450 + } 451 + 452 + export function clearTokenCache(): void { 453 + getGitHubAppService().clearTokenCache() 454 + }
+7 -1
lib/infrastructure/adapters/github/index.ts
··· 3 3 * Exports all GitHub API adapter implementations 4 4 */ 5 5 6 - export { SimpleGitHubAPIService as GitHubAPIService } from './simple-github.adapter' 6 + export { SimpleGitHubAPIService } from './simple-github.adapter' 7 + export { RealGitHubAPIService } from './real-github.adapter' 8 + export { GitHubAppService } from './github-app.adapter' 9 + 10 + // Export the appropriate services based on environment 11 + export { RealGitHubAPIService as GitHubAPIService } from './real-github.adapter' 12 + export { GitHubAppService as GitHubApp } from './github-app.adapter'
+1079
lib/infrastructure/adapters/github/real-github.adapter.ts
··· 1 + /** 2 + * Real GitHub API Service Adapter 3 + * Implements IGitHubService using actual GitHub API calls via Octokit 4 + */ 5 + 6 + import { IGitHubService } from '../../../core/ports' 7 + import { Organization, Repository, PullRequest, User } from '../../../core/domain/entities' 8 + import { GitHubClient, createGitHubClient, createGitHubInstallationClient } from '../../../github' 9 + import { createInstallationClient } from '../../../github-app' 10 + import crypto from 'crypto' 11 + import { 12 + findOrCreateOrganization, 13 + findOrCreateRepository, 14 + findUserById, 15 + createPullRequest, 16 + findPullRequestByNumber, 17 + updatePullRequest, 18 + findReviewByGitHubId, 19 + createPullRequestReview, 20 + setRepositoryTracking, 21 + findRepositoryById, 22 + findRepositoryByGitHubId, 23 + addUserToOrganization, 24 + findOrCreateUserByGitHubId, 25 + findOrganizationById, 26 + findRepositoryByFullName, 27 + updatePullRequestCategory, 28 + getOrganizationCategories, 29 + getOrganizationAiSettings, 30 + getOrganizationApiKey, 31 + findCategoryByNameAndOrg 32 + } from '../../../repositories' 33 + import * as OrganizationRepository from '../../../repositories/organization-repository' 34 + import * as PullRequestRepository from '../../../repositories/pr-repository' 35 + import { generateText } from 'ai' 36 + import { createOpenAI } from '@ai-sdk/openai' 37 + import { createGoogleGenerativeAI } from '@ai-sdk/google' 38 + import { createAnthropic } from '@ai-sdk/anthropic' 39 + 40 + export class RealGitHubAPIService implements IGitHubService { 41 + private client?: GitHubClient 42 + 43 + constructor(private accessToken?: string) { 44 + if (accessToken) { 45 + this.client = createGitHubClient(accessToken) 46 + } 47 + } 48 + 49 + /** 50 + * Get user information from GitHub 51 + */ 52 + async getUser(accessToken: string): Promise<User> { 53 + const client = createGitHubClient(accessToken) 54 + const githubUser = await client.getCurrentUser() 55 + 56 + return { 57 + id: githubUser.id.toString(), 58 + login: githubUser.login, 59 + name: githubUser.name || githubUser.login, 60 + email: githubUser.email || null, 61 + avatarUrl: githubUser.avatar_url || '', 62 + htmlUrl: githubUser.html_url || `https://github.com/${githubUser.login}`, 63 + type: 'User' as const, 64 + isNewUser: false, // Determined elsewhere in the application 65 + hasGithubApp: false, // Determined elsewhere in the application 66 + createdAt: new Date(), // GitHub API doesn't provide creation date for users in basic calls 67 + updatedAt: new Date() 68 + } 69 + } 70 + 71 + /** 72 + * Get user's organizations from GitHub 73 + */ 74 + async getUserOrganizations(accessToken: string): Promise<Organization[]> { 75 + const client = createGitHubClient(accessToken) 76 + const githubOrgs = await client.getUserOrganizations() 77 + 78 + return githubOrgs.map(org => ({ 79 + id: org.id.toString(), 80 + login: org.login, 81 + name: org.login, // GitHub Organizations only have login in basic API 82 + description: org.description || null, 83 + avatarUrl: org.avatar_url || '', 84 + type: 'Organization' as const, 85 + htmlUrl: `https://github.com/${org.login}`, 86 + isInstalled: false, // Will be updated when we check installation status 87 + installationId: null, 88 + createdAt: new Date(), // GitHub API doesn't provide creation date 89 + updatedAt: new Date() 90 + })) 91 + } 92 + 93 + /** 94 + * Get organization information from GitHub 95 + */ 96 + async getOrganization(orgLogin: string): Promise<Organization> { 97 + if (!this.client) { 98 + throw new Error('GitHub client not initialized. Access token required.') 99 + } 100 + 101 + // Use the organization repositories endpoint to get org info 102 + const repos = await this.client.getOrganizationRepositories(orgLogin) 103 + const firstRepo = repos[0] 104 + 105 + if (!firstRepo) { 106 + throw new Error(`No repositories found for organization: ${orgLogin}`) 107 + } 108 + 109 + const owner = firstRepo.owner 110 + return { 111 + id: owner.id.toString(), 112 + login: owner.login, 113 + name: owner.login, // Owner type doesn't have name property 114 + description: null, 115 + avatarUrl: (owner as any).avatar_url || '', 116 + type: 'Organization' as const, 117 + htmlUrl: `https://github.com/${owner.login}`, 118 + isInstalled: false, 119 + installationId: null, 120 + createdAt: new Date(), 121 + updatedAt: new Date() 122 + } 123 + } 124 + 125 + /** 126 + * Get organization repositories from GitHub 127 + */ 128 + async getOrganizationRepositories( 129 + orgLogin: string, 130 + options?: { 131 + type?: 'all' | 'public' | 'private' 132 + sort?: 'created' | 'updated' | 'pushed' | 'full_name' 133 + per_page?: number 134 + page?: number 135 + } 136 + ): Promise<Repository[]> { 137 + if (!this.client) { 138 + throw new Error('GitHub client not initialized. Access token required.') 139 + } 140 + 141 + const githubRepos = await this.client.getOrganizationRepositories(orgLogin) 142 + 143 + return githubRepos.map(repo => ({ 144 + id: repo.id.toString(), 145 + name: repo.name, 146 + fullName: repo.full_name, 147 + description: repo.description || null, 148 + htmlUrl: repo.html_url, 149 + defaultBranch: (repo as any).default_branch || 'main', 150 + isPrivate: (repo as any).private || false, 151 + isTracked: false, 152 + isArchived: (repo as any).archived || false, 153 + language: (repo as any).language || null, 154 + size: (repo as any).size || 0, 155 + stargazersCount: (repo as any).stargazers_count || 0, 156 + forksCount: (repo as any).forks_count || 0, 157 + openIssuesCount: (repo as any).open_issues_count || 0, 158 + organizationId: repo.owner.id.toString(), 159 + createdAt: new Date((repo as any).created_at || Date.now()), 160 + updatedAt: new Date((repo as any).updated_at || Date.now()), 161 + pushedAt: (repo as any).pushed_at ? new Date((repo as any).pushed_at) : null 162 + })) 163 + } 164 + 165 + /** 166 + * Get accessible repositories for an organization (requires GitHub App) 167 + */ 168 + async getAccessibleRepositories(orgLogin: string): Promise<Repository[]> { 169 + // For now, return all org repositories 170 + // In production, this would filter based on GitHub App installation permissions 171 + return this.getOrganizationRepositories(orgLogin) 172 + } 173 + 174 + /** 175 + * Get repository information from GitHub 176 + */ 177 + async getRepository(owner: string, repo: string): Promise<Repository> { 178 + if (!this.client) { 179 + throw new Error('GitHub client not initialized. Access token required.') 180 + } 181 + 182 + const githubRepo = await this.client.getRepository(owner, repo) 183 + 184 + return { 185 + id: githubRepo.id.toString(), 186 + name: githubRepo.name, 187 + fullName: githubRepo.full_name, 188 + description: githubRepo.description || null, 189 + htmlUrl: githubRepo.html_url, 190 + defaultBranch: (githubRepo as any).default_branch || 'main', 191 + isPrivate: (githubRepo as any).private || false, 192 + isTracked: false, 193 + isArchived: (githubRepo as any).archived || false, 194 + language: (githubRepo as any).language || null, 195 + size: (githubRepo as any).size || 0, 196 + stargazersCount: (githubRepo as any).stargazers_count || 0, 197 + forksCount: (githubRepo as any).forks_count || 0, 198 + openIssuesCount: (githubRepo as any).open_issues_count || 0, 199 + organizationId: githubRepo.owner.id.toString(), 200 + createdAt: new Date((githubRepo as any).created_at || Date.now()), 201 + updatedAt: new Date((githubRepo as any).updated_at || Date.now()), 202 + pushedAt: (githubRepo as any).pushed_at ? new Date((githubRepo as any).pushed_at) : null 203 + } 204 + } 205 + 206 + /** 207 + * Get pull requests for a repository 208 + */ 209 + async getRepositoryPullRequests( 210 + owner: string, 211 + repo: string, 212 + options?: { 213 + state?: 'open' | 'closed' | 'all' 214 + sort?: 'created' | 'updated' | 'popularity' | 'long-running' 215 + direction?: 'asc' | 'desc' 216 + per_page?: number 217 + page?: number 218 + } 219 + ): Promise<PullRequest[]> { 220 + if (!this.client) { 221 + throw new Error('GitHub client not initialized. Access token required.') 222 + } 223 + 224 + const state = options?.state || 'all' 225 + const githubPRs = await this.client.getAllPullRequests(owner, repo, state) 226 + 227 + return githubPRs.map(pr => this.mapGitHubPRToDomain(pr)) 228 + } 229 + 230 + /** 231 + * Get pull request details from GitHub 232 + */ 233 + async getPullRequest( 234 + owner: string, 235 + repo: string, 236 + pullNumber: number 237 + ): Promise<PullRequest> { 238 + if (!this.client) { 239 + throw new Error('GitHub client not initialized. Access token required.') 240 + } 241 + 242 + const githubPR = await this.client.getPullRequest(owner, repo, pullNumber) 243 + return this.mapGitHubPRToDomain(githubPR) 244 + } 245 + 246 + /** 247 + * Get pull request reviews 248 + */ 249 + async getPullRequestReviews( 250 + owner: string, 251 + repo: string, 252 + pullNumber: number 253 + ): Promise<Array<{ 254 + id: string 255 + user: User 256 + state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' 257 + body: string 258 + submittedAt: Date 259 + }>> { 260 + if (!this.client) { 261 + throw new Error('GitHub client not initialized. Access token required.') 262 + } 263 + 264 + const reviews = await this.client.getPullRequestReviews(owner, repo, pullNumber) 265 + 266 + return reviews 267 + .filter(review => review.user) // Only include reviews with user data 268 + .map(review => ({ 269 + id: review.id.toString(), 270 + user: { 271 + id: review.user!.id.toString(), 272 + login: review.user!.login, 273 + name: review.user!.name || review.user!.login, 274 + email: review.user!.email || null, 275 + avatarUrl: review.user!.avatar_url || '', 276 + htmlUrl: (review.user as any).html_url || `https://github.com/${review.user!.login}`, 277 + type: 'User' as const, 278 + isNewUser: false, 279 + hasGithubApp: false, 280 + createdAt: new Date(), 281 + updatedAt: new Date() 282 + }, 283 + state: this.mapReviewState(review.state), 284 + body: (review as any).body || '', 285 + submittedAt: new Date((review as any).submitted_at) 286 + })) 287 + } 288 + 289 + /** 290 + * Sync organization repositories from GitHub 291 + */ 292 + async syncOrganizationRepositories(orgLogin: string): Promise<{ 293 + synced: Repository[] 294 + errors: Array<{ repo: string; error: string }> 295 + }> { 296 + try { 297 + const repositories = await this.getOrganizationRepositories(orgLogin) 298 + 299 + // Store repositories in database 300 + const org = await findOrCreateOrganization({ 301 + github_id: repositories[0] ? parseInt(repositories[0].id) : 0, 302 + name: orgLogin, 303 + avatar_url: '', 304 + }) 305 + 306 + const synced: Repository[] = [] 307 + const errors: Array<{ repo: string; error: string }> = [] 308 + 309 + for (const repo of repositories) { 310 + try { 311 + await findOrCreateRepository({ 312 + github_id: parseInt(repo.id), 313 + organization_id: org.id, 314 + name: repo.name, 315 + full_name: repo.fullName, 316 + description: repo.description, 317 + private: repo.isPrivate, 318 + is_tracked: false 319 + }) 320 + synced.push(repo) 321 + } catch (error) { 322 + errors.push({ 323 + repo: repo.fullName, 324 + error: error instanceof Error ? error.message : 'Unknown error' 325 + }) 326 + } 327 + } 328 + 329 + return { synced, errors } 330 + } catch (error) { 331 + return { 332 + synced: [], 333 + errors: [{ repo: orgLogin, error: error instanceof Error ? error.message : 'Unknown error' }] 334 + } 335 + } 336 + } 337 + 338 + /** 339 + * Sync repository pull requests from GitHub 340 + */ 341 + async syncRepositoryPullRequests( 342 + repositoryId: string, 343 + since?: Date 344 + ): Promise<{ 345 + synced: PullRequest[] 346 + errors: Array<{ pr: number; error: string }> 347 + }> { 348 + try { 349 + // Find repository in database 350 + const dbRepo = await findRepositoryById(parseInt(repositoryId)) 351 + if (!dbRepo) { 352 + return { 353 + synced: [], 354 + errors: [{ pr: 0, error: `Repository ${repositoryId} not found` }] 355 + } 356 + } 357 + 358 + const [owner, repo] = dbRepo.full_name.split('/') 359 + if (!owner || !repo) { 360 + return { 361 + synced: [], 362 + errors: [{ pr: 0, error: 'Invalid repository full name format' }] 363 + } 364 + } 365 + 366 + let pullRequests = await this.getRepositoryPullRequests(owner, repo) 367 + 368 + // Filter by 'since' date if provided 369 + if (since) { 370 + pullRequests = pullRequests.filter(pr => new Date(pr.createdAt) >= since) 371 + } 372 + 373 + const synced: PullRequest[] = [] 374 + const errors: Array<{ pr: number; error: string }> = [] 375 + 376 + for (const pr of pullRequests) { 377 + try { 378 + const existingPR = await findPullRequestByNumber(parseInt(repositoryId), pr.number) 379 + 380 + if (existingPR) { 381 + // Update existing PR 382 + await updatePullRequest(existingPR.id, { 383 + title: pr.title, 384 + state: pr.status as 'open' | 'closed' | 'merged' 385 + }) 386 + } else { 387 + // Create new PR 388 + const author = await findOrCreateUserByGitHubId({ 389 + id: pr.developer.id.toString(), 390 + login: pr.developer.name, // Use name as login since domain only has name 391 + avatar_url: '', 392 + name: pr.developer.name 393 + }) 394 + 395 + if (author) { 396 + await createPullRequest({ 397 + github_id: parseInt(pr.id.toString()), 398 + repository_id: parseInt(repositoryId), 399 + number: pr.number, 400 + title: pr.title, 401 + description: null, // Domain entity doesn't have description 402 + author_id: author.id, 403 + state: pr.status as 'open' | 'closed' | 'merged', 404 + created_at: pr.createdAt, 405 + updated_at: pr.createdAt, // Use createdAt since domain has both as strings 406 + closed_at: null, 407 + merged_at: pr.status === 'merged' ? pr.mergedAt : null, 408 + draft: false, 409 + additions: pr.linesAdded || 0, 410 + deletions: 0, 411 + changed_files: pr.files || 0, 412 + category_id: null, 413 + category_confidence: null 414 + }) 415 + } 416 + } 417 + 418 + synced.push(pr) 419 + } catch (error) { 420 + errors.push({ 421 + pr: pr.number, 422 + error: error instanceof Error ? error.message : 'Unknown error' 423 + }) 424 + } 425 + } 426 + 427 + return { synced, errors } 428 + } catch (error) { 429 + return { 430 + synced: [], 431 + errors: [{ pr: 0, error: error instanceof Error ? error.message : 'Unknown error' }] 432 + } 433 + } 434 + } 435 + 436 + /** 437 + * Check GitHub App installation status 438 + */ 439 + async getInstallationStatus(orgLogin: string): Promise<{ 440 + isInstalled: boolean 441 + installationId: string | null 442 + permissions: Record<string, string> 443 + }> { 444 + try { 445 + // Find organization in database to get installation ID 446 + const org = await findOrCreateOrganization({ 447 + github_id: 0, 448 + name: orgLogin, 449 + avatar_url: '', 450 + }) 451 + 452 + const isInstalled = !!org.installation_id 453 + const installationId = org.installation_id?.toString() || null 454 + 455 + return { 456 + isInstalled, 457 + installationId, 458 + permissions: isInstalled ? { 459 + contents: 'read', 460 + pull_requests: 'read', 461 + metadata: 'read' 462 + } : {} 463 + } 464 + } catch (error) { 465 + console.error('Error checking installation status:', error) 466 + return { 467 + isInstalled: false, 468 + installationId: null, 469 + permissions: {} 470 + } 471 + } 472 + } 473 + 474 + /** 475 + * Validate webhook signature 476 + */ 477 + validateWebhookSignature( 478 + payload: string, 479 + signature: string, 480 + secret: string 481 + ): boolean { 482 + if (!signature || !secret) { 483 + return false 484 + } 485 + 486 + const expectedSignature = crypto 487 + .createHmac('sha256', secret) 488 + .update(payload, 'utf8') 489 + .digest('hex') 490 + 491 + const expectedSignatureWithPrefix = `sha256=${expectedSignature}` 492 + 493 + // Use crypto.timingSafeEqual to prevent timing attacks 494 + if (signature.length !== expectedSignatureWithPrefix.length) { 495 + return false 496 + } 497 + 498 + return crypto.timingSafeEqual( 499 + Buffer.from(signature, 'utf8'), 500 + Buffer.from(expectedSignatureWithPrefix, 'utf8') 501 + ) 502 + } 503 + 504 + /** 505 + * Process GitHub webhook event 506 + */ 507 + async processWebhookEvent( 508 + event: string, 509 + payload: any 510 + ): Promise<{ 511 + processed: boolean 512 + actions: string[] 513 + errors?: string[] 514 + }> { 515 + const supportedEvents = ['pull_request', 'pull_request_review', 'installation', 'ping'] 516 + 517 + if (!supportedEvents.includes(event)) { 518 + return { 519 + processed: false, 520 + actions: [], 521 + errors: [`Unsupported event type: ${event}`] 522 + } 523 + } 524 + 525 + const actions: string[] = [] 526 + const errors: string[] = [] 527 + 528 + try { 529 + switch (event) { 530 + case 'pull_request': 531 + await this.handlePullRequestWebhook(payload) 532 + actions.push(`Processed pull_request.${payload.action}`) 533 + break 534 + 535 + case 'pull_request_review': 536 + await this.handlePullRequestReviewWebhook(payload) 537 + actions.push(`Processed pull_request_review.${payload.action}`) 538 + break 539 + 540 + case 'installation': 541 + await this.handleInstallationWebhook(payload) 542 + actions.push(`Processed installation.${payload.action}`) 543 + break 544 + 545 + case 'ping': 546 + actions.push('Processed ping event') 547 + break 548 + } 549 + 550 + return { 551 + processed: true, 552 + actions, 553 + errors: errors.length > 0 ? errors : undefined 554 + } 555 + } catch (error) { 556 + return { 557 + processed: false, 558 + actions, 559 + errors: [error instanceof Error ? error.message : 'Unknown webhook processing error'] 560 + } 561 + } 562 + } 563 + 564 + /** 565 + * Handle pull request webhook events 566 + */ 567 + private async handlePullRequestWebhook(payload: any): Promise<void> { 568 + const { action, repository, pull_request: pr } = payload 569 + 570 + console.log(`[Webhook] Processing PR #${pr.number} action=${action} repo=${repository.full_name}`) 571 + 572 + // Find repository in database 573 + const repoInDb = await findRepositoryByFullName(repository.full_name) 574 + if (!repoInDb) { 575 + console.log(`Repository ${repository.full_name} not tracked, skipping webhook`) 576 + return 577 + } 578 + 579 + // Map GitHub PR state to our state format 580 + const prState = pr.merged_at 581 + ? 'merged' 582 + : pr.state === 'closed' ? 'closed' : 'open' 583 + 584 + // Check if PR exists 585 + const existingPR = await findPullRequestByNumber(repoInDb.id, pr.number) 586 + 587 + if (existingPR) { 588 + // Update existing PR 589 + await updatePullRequest(existingPR.id, { 590 + title: pr.title, 591 + description: pr.body, 592 + state: prState, 593 + updated_at: pr.updated_at, 594 + closed_at: pr.closed_at, 595 + merged_at: pr.merged_at, 596 + draft: pr.draft, 597 + additions: pr.additions, 598 + deletions: pr.deletions, 599 + changed_files: pr.changed_files 600 + }) 601 + 602 + console.log(`[Webhook] Updated PR #${pr.number} in ${repository.full_name}`) 603 + 604 + // Process AI categorization if action is 'opened' 605 + if (action === 'opened' && repoInDb.organization_id) { 606 + try { 607 + await this.fetchAdditionalPRData(repository, pr, existingPR.id, repoInDb.organization_id, payload) 608 + } catch (error) { 609 + console.error('[Webhook] Error in AI categorization for existing PR:', error) 610 + } 611 + } 612 + } else { 613 + // Create new PR 614 + console.log(`[Webhook] Creating new PR #${pr.number} in ${repository.full_name}`) 615 + 616 + const author = await findOrCreateUserByGitHubId({ 617 + id: pr.user.id.toString(), 618 + login: pr.user.login, 619 + avatar_url: pr.user.avatar_url, 620 + name: pr.user.name || pr.user.login 621 + }) 622 + 623 + if (!author) { 624 + console.error(`[Webhook] Could not create user for GitHub ID: ${pr.user.id}`) 625 + return 626 + } 627 + 628 + const newPR = await createPullRequest({ 629 + github_id: pr.id, 630 + repository_id: repoInDb.id, 631 + number: pr.number, 632 + title: pr.title, 633 + description: pr.body, 634 + author_id: author.id, 635 + state: prState, 636 + created_at: pr.created_at, 637 + updated_at: pr.updated_at, 638 + closed_at: pr.closed_at, 639 + merged_at: pr.merged_at, 640 + draft: pr.draft, 641 + additions: pr.additions || null, 642 + deletions: pr.deletions || null, 643 + changed_files: pr.changed_files || null, 644 + category_id: null, 645 + category_confidence: null 646 + }) 647 + 648 + console.log(`[Webhook] Created PR #${pr.number} with DB ID ${newPR.id}`) 649 + 650 + // Process AI categorization if action is 'opened' 651 + if (action === 'opened' && repoInDb.organization_id) { 652 + try { 653 + await this.fetchAdditionalPRData(repository, pr, newPR.id, repoInDb.organization_id, payload) 654 + } catch (error) { 655 + console.error('[Webhook] Error in AI categorization for new PR:', error) 656 + } 657 + } 658 + } 659 + } 660 + 661 + /** 662 + * Handle pull request review webhook events 663 + */ 664 + private async handlePullRequestReviewWebhook(payload: any): Promise<void> { 665 + const { repository, pull_request, review } = payload 666 + 667 + // Find repository and PR 668 + const repoInDb = await this.findRepositoryByFullName(repository.full_name) 669 + if (!repoInDb) return 670 + 671 + const existingPR = await findPullRequestByNumber(repoInDb.id, pull_request.number) 672 + if (!existingPR) return 673 + 674 + // Check if review exists 675 + const existingReview = await findReviewByGitHubId(review.id) 676 + const reviewState = this.mapReviewState(review.state) as 'approved' | 'changes_requested' | 'commented' | 'dismissed' 677 + 678 + if (existingReview) { 679 + // Update review (implementation depends on your updatePullRequestReview function) 680 + // await updatePullRequestReview(existingReview.id, { state: reviewState }) 681 + } else { 682 + // Create new review 683 + await createPullRequestReview({ 684 + github_id: review.id, 685 + pull_request_id: existingPR.id, 686 + reviewer_id: review.user.id.toString(), 687 + state: reviewState, 688 + submitted_at: review.submitted_at 689 + }) 690 + } 691 + } 692 + 693 + /** 694 + * Handle installation webhook events 695 + */ 696 + private async handleInstallationWebhook(payload: any): Promise<void> { 697 + const { action, installation, repositories } = payload 698 + const account = installation.account 699 + 700 + if (account.type !== 'Organization') { 701 + console.log(`[Webhook] Skipping installation event for non-organization: ${account.login}`) 702 + return 703 + } 704 + 705 + const orgGitHubId = account.id 706 + const orgLogin = account.login 707 + const installationId = installation.id 708 + const orgAvatarUrl = account.avatar_url || null 709 + 710 + console.log(`[Webhook] Installation ${action} for org ${orgLogin} (${orgGitHubId}), installation ID: ${installationId}`) 711 + 712 + try { 713 + // Find or create organization 714 + let org = await OrganizationRepository.findOrganizationByGitHubId(orgGitHubId) 715 + 716 + if (!org && action === 'created') { 717 + console.log(`[Webhook] Creating organization ${orgLogin}`) 718 + org = await OrganizationRepository.createOrganization({ 719 + github_id: orgGitHubId, 720 + name: orgLogin, 721 + avatar_url: orgAvatarUrl 722 + }) 723 + console.log(`[Webhook] Created organization ${orgLogin} with DB ID ${org.id}`) 724 + } else if (!org) { 725 + console.log(`[Webhook] Organization ${orgLogin} not found for action ${action}`) 726 + return 727 + } 728 + 729 + if (action === 'created') { 730 + // Update with installation ID 731 + const updatedOrg = await OrganizationRepository.updateOrganization(org.id, { 732 + installation_id: installationId, 733 + name: orgLogin, 734 + avatar_url: orgAvatarUrl 735 + }) 736 + 737 + if (updatedOrg) { 738 + console.log(`[Webhook] Stored installation ID ${installationId} for org ${orgLogin}`) 739 + } else { 740 + console.error(`[Webhook] Failed to update org ${orgLogin} with installation ID`) 741 + } 742 + 743 + // Process repositories if provided in payload 744 + if (repositories && repositories.length > 0) { 745 + console.log(`[Webhook] Processing ${repositories.length} repositories for installation`) 746 + 747 + for (const repoData of repositories) { 748 + try { 749 + await findOrCreateRepository({ 750 + github_id: repoData.id, 751 + name: repoData.name, 752 + full_name: repoData.full_name, 753 + private: repoData.private, 754 + organization_id: org.id, 755 + description: null, 756 + is_tracked: true 757 + }) 758 + console.log(`[Webhook] Added repository ${repoData.full_name} to org ${org.id}`) 759 + } catch (repoError) { 760 + console.error(`[Webhook] Error adding repository ${repoData.full_name}:`, repoError) 761 + } 762 + } 763 + } 764 + } else if (action === 'deleted') { 765 + // Clear installation ID 766 + const updatedOrg = await OrganizationRepository.updateOrganization(org.id, { 767 + installation_id: null 768 + }) 769 + 770 + if (updatedOrg) { 771 + console.log(`[Webhook] Cleared installation ID for org ${orgLogin}`) 772 + } else { 773 + console.error(`[Webhook] Failed to clear installation ID for org ${orgLogin}`) 774 + } 775 + } else if (action === 'suspend') { 776 + console.log(`[Webhook] App suspended for org ${orgLogin}`) 777 + await OrganizationRepository.updateOrganization(org.id, { installation_id: null }) 778 + } else if (action === 'unsuspend') { 779 + console.log(`[Webhook] App unsuspended for org ${orgLogin}`) 780 + await OrganizationRepository.updateOrganization(org.id, { installation_id: installationId }) 781 + } else { 782 + console.log(`[Webhook] Unhandled installation action: ${action}`) 783 + } 784 + } catch (error) { 785 + console.error(`[Webhook] Error handling installation event:`, error) 786 + throw error 787 + } 788 + } 789 + 790 + /** 791 + * Fetch additional PR data including AI categorization 792 + */ 793 + private async fetchAdditionalPRData( 794 + repository: any, 795 + pr: any, 796 + prDbId: number, 797 + organizationId: number, 798 + fullPayload?: any 799 + ): Promise<void> { 800 + console.log(`[Webhook] Fetching additional data for PR #${pr.number} in org ${organizationId}`) 801 + 802 + try { 803 + // Get organization details to find installation ID 804 + const orgDetails = await findOrganizationById(organizationId) 805 + if (!orgDetails) { 806 + console.error(`[Webhook] Organization ${organizationId} not found`) 807 + await PullRequestRepository.updatePullRequest(prDbId, { 808 + ai_status: 'error', 809 + error_message: 'Organization not found' 810 + }) 811 + return 812 + } 813 + 814 + let installationId = orgDetails.installation_id 815 + 816 + // Fallback to payload installation ID if not in DB 817 + if (!installationId) { 818 + installationId = fullPayload?.installation?.id || repository?.installation?.id 819 + } 820 + 821 + if (!installationId) { 822 + console.warn(`[Webhook] No installation ID for org ${organizationId}`) 823 + await PullRequestRepository.updatePullRequest(prDbId, { 824 + ai_status: 'skipped', 825 + error_message: 'No GitHub App installation' 826 + }) 827 + return 828 + } 829 + 830 + // Get AI settings 831 + const aiSettings = await getOrganizationAiSettings(organizationId) 832 + const selectedModelId = aiSettings.selectedModelId 833 + 834 + if (!selectedModelId || selectedModelId === '__none__') { 835 + console.log(`[Webhook] AI categorization disabled for org ${organizationId}`) 836 + await PullRequestRepository.updatePullRequest(prDbId, { 837 + ai_status: 'skipped', 838 + error_message: 'AI categorization disabled' 839 + }) 840 + return 841 + } 842 + 843 + const provider = aiSettings.provider 844 + if (!provider) { 845 + console.log(`[Webhook] AI provider not set for org ${organizationId}`) 846 + await PullRequestRepository.updatePullRequest(prDbId, { 847 + ai_status: 'skipped', 848 + error_message: 'AI provider not set' 849 + }) 850 + return 851 + } 852 + 853 + const apiKey = await getOrganizationApiKey(organizationId, provider) 854 + if (!apiKey) { 855 + console.warn(`[Webhook] API key for ${provider} not set for org ${organizationId}`) 856 + await PullRequestRepository.updatePullRequest(prDbId, { 857 + ai_status: 'skipped', 858 + error_message: `API key for ${provider} not set` 859 + }) 860 + return 861 + } 862 + 863 + // Create AI client 864 + let aiClientProvider 865 + switch (provider) { 866 + case 'openai': 867 + aiClientProvider = createOpenAI({ apiKey }) 868 + break 869 + case 'google': 870 + aiClientProvider = createGoogleGenerativeAI({ apiKey }) 871 + break 872 + case 'anthropic': 873 + aiClientProvider = createAnthropic({ apiKey }) 874 + break 875 + default: 876 + console.error(`[Webhook] Unsupported AI provider: ${provider}`) 877 + await PullRequestRepository.updatePullRequest(prDbId, { 878 + ai_status: 'error', 879 + error_message: `Unsupported AI provider: ${provider}` 880 + }) 881 + return 882 + } 883 + 884 + const modelInstance = aiClientProvider(selectedModelId) 885 + if (!modelInstance) { 886 + console.error(`[Webhook] Could not get model instance for ${selectedModelId}`) 887 + await PullRequestRepository.updatePullRequest(prDbId, { 888 + ai_status: 'error', 889 + error_message: `Could not get AI model instance ${selectedModelId}` 890 + }) 891 + return 892 + } 893 + 894 + // Create GitHub client with installation 895 + let githubClient: GitHubClient 896 + try { 897 + githubClient = await createInstallationClient(installationId) 898 + console.log(`[Webhook] Created GitHub client with installation ID ${installationId}`) 899 + } catch (error) { 900 + console.error(`[Webhook] Failed to create GitHub client:`, error) 901 + await PullRequestRepository.updatePullRequest(prDbId, { 902 + ai_status: 'error', 903 + error_message: 'Failed to create GitHub client' 904 + }) 905 + return 906 + } 907 + 908 + // Fetch PR diff 909 + let diff: string 910 + try { 911 + diff = await githubClient.getPullRequestDiff(repository.owner.login, repository.name, pr.number) 912 + } catch (error: any) { 913 + if (error.message?.includes('expired') || error.message?.includes('invalid') || error.status === 401) { 914 + console.warn(`[Webhook] Token expired, retrying with fresh client`) 915 + try { 916 + githubClient = await createInstallationClient(installationId) 917 + diff = await githubClient.getPullRequestDiff(repository.owner.login, repository.name, pr.number) 918 + } catch (retryError) { 919 + console.error(`[Webhook] Failed to fetch PR diff after retry:`, retryError) 920 + await PullRequestRepository.updatePullRequest(prDbId, { 921 + ai_status: 'error', 922 + error_message: 'Failed to fetch PR diff' 923 + }) 924 + return 925 + } 926 + } else { 927 + console.error(`[Webhook] Failed to fetch PR diff:`, error) 928 + await PullRequestRepository.updatePullRequest(prDbId, { 929 + ai_status: 'error', 930 + error_message: 'Failed to fetch PR diff' 931 + }) 932 + return 933 + } 934 + } 935 + 936 + if (!diff) { 937 + console.warn(`[Webhook] Empty PR diff for ${repository.full_name}#${pr.number}`) 938 + await PullRequestRepository.updatePullRequest(prDbId, { 939 + ai_status: 'skipped', 940 + error_message: 'Empty PR diff' 941 + }) 942 + return 943 + } 944 + 945 + // Get organization categories 946 + const orgCategories = await getOrganizationCategories(organizationId) 947 + const categoryNames = orgCategories.map(c => c.name) 948 + 949 + if (categoryNames.length === 0) { 950 + console.warn(`[Webhook] No categories for org ${organizationId}`) 951 + await PullRequestRepository.updatePullRequest(prDbId, { 952 + ai_status: 'skipped', 953 + error_message: 'No categories configured' 954 + }) 955 + return 956 + } 957 + 958 + // Update PR status to processing 959 + await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'processing' }) 960 + 961 + // Generate AI categorization 962 + const systemPrompt = `You are an expert at categorizing GitHub pull requests. Analyze the pull request title, body, and diff. Respond with the most relevant category from the provided list and a confidence score (0-1). Available categories: ${categoryNames.join(', ')}. Respond in the format: Category: [Selected Category], Confidence: [Score]. Example: Category: Bug Fix, Confidence: 0.9` 963 + 964 + const userPrompt = `Title: ${pr.title} 965 + Body: ${pr.body || ''} 966 + Diff: 967 + ${diff}` 968 + 969 + try { 970 + const { text } = await generateText({ 971 + model: modelInstance, 972 + system: systemPrompt, 973 + prompt: userPrompt, 974 + }) 975 + 976 + console.log(`[Webhook] AI Response for PR #${pr.number}: ${text}`) 977 + 978 + // Parse AI response 979 + const categoryMatch = text.match(/Category: (.*?), Confidence: (\d\.?\d*)/i) 980 + if (categoryMatch && categoryMatch[1] && categoryMatch[2]) { 981 + const categoryName = categoryMatch[1].trim() 982 + const confidence = parseFloat(categoryMatch[2]) 983 + 984 + const category = await findCategoryByNameAndOrg(organizationId, categoryName) 985 + if (category) { 986 + await updatePullRequestCategory(prDbId, category.id, confidence) 987 + await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'completed' }) 988 + console.log(`[Webhook] PR #${pr.number} categorized as '${categoryName}' with confidence ${confidence}`) 989 + } else { 990 + console.warn(`[Webhook] AI suggested category '${categoryName}' not found`) 991 + await PullRequestRepository.updatePullRequest(prDbId, { 992 + ai_status: 'error', 993 + error_message: `AI suggested category '${categoryName}' not found` 994 + }) 995 + } 996 + } else { 997 + console.warn(`[Webhook] Could not parse AI response: ${text}`) 998 + await PullRequestRepository.updatePullRequest(prDbId, { 999 + ai_status: 'error', 1000 + error_message: 'Could not parse AI response' 1001 + }) 1002 + } 1003 + } catch (aiError) { 1004 + console.error('[Webhook] AI text generation failed:', aiError) 1005 + await PullRequestRepository.updatePullRequest(prDbId, { 1006 + ai_status: 'error', 1007 + error_message: 'AI text generation failed' 1008 + }) 1009 + } 1010 + } catch (error) { 1011 + console.error('[Webhook] Error in fetchAdditionalPRData:', error) 1012 + await PullRequestRepository.updatePullRequest(prDbId, { 1013 + ai_status: 'error', 1014 + error_message: 'Critical error in AI processing' 1015 + }) 1016 + } 1017 + } 1018 + 1019 + /** 1020 + * Helper methods 1021 + */ 1022 + private mapGitHubPRToDomain(githubPR: any): PullRequest { 1023 + const state = githubPR.merged_at 1024 + ? 'merged' 1025 + : githubPR.state === 'closed' ? 'closed' : 'open' 1026 + 1027 + const createdAt = new Date(githubPR.created_at || Date.now()) 1028 + const mergedAt = githubPR.merged_at ? new Date(githubPR.merged_at) : null 1029 + const cycleTime = mergedAt ? Math.round((mergedAt.getTime() - createdAt.getTime()) / (1000 * 60 * 60)) : 0 // hours 1030 + 1031 + return { 1032 + id: githubPR.id.toString(), 1033 + number: githubPR.number, 1034 + title: githubPR.title, 1035 + developer: { 1036 + id: githubPR.user.id.toString(), 1037 + name: githubPR.user.name || githubPR.user.login 1038 + }, 1039 + repository: { 1040 + id: githubPR.base?.repo?.id?.toString() || '', 1041 + name: githubPR.base?.repo?.name || '' 1042 + }, 1043 + status: state, 1044 + createdAt: createdAt.toISOString(), 1045 + mergedAt: mergedAt ? mergedAt.toISOString() : createdAt.toISOString(), // fallback to createdAt for domain compatibility 1046 + cycleTime, 1047 + investmentArea: undefined, // To be determined by AI categorization 1048 + linesAdded: githubPR.additions || 0, 1049 + files: githubPR.changed_files || 0 1050 + } 1051 + } 1052 + 1053 + private mapReviewState(state: string): 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' { 1054 + switch (state.toLowerCase()) { 1055 + case 'approved': 1056 + return 'APPROVED' 1057 + case 'changes_requested': 1058 + return 'CHANGES_REQUESTED' 1059 + default: 1060 + return 'COMMENTED' 1061 + } 1062 + } 1063 + 1064 + private async findRepositoryByFullName(fullName: string): Promise<any> { 1065 + try { 1066 + return await findRepositoryByFullName && await findRepositoryByFullName(fullName) 1067 + } catch (error) { 1068 + return null 1069 + } 1070 + } 1071 + 1072 + private async findOrganizationByGitHubId(githubId: number): Promise<any> { 1073 + try { 1074 + return await findOrganizationById && await findOrganizationById(githubId) 1075 + } catch (error) { 1076 + return null 1077 + } 1078 + } 1079 + }
+7 -7
lib/infrastructure/adapters/turso/index.ts
··· 1 1 /** 2 2 * Turso Adapters Index 3 - * Exports all Turso database adapter implementations 3 + * Exports all Turso database adapter implementations with real database queries 4 4 */ 5 5 6 - // Export simplified production adapters 7 - export { SimpleTursoPullRequestRepository as TursoPullRequestRepository } from './simple-pr.adapter' 8 - export { SimpleTursoMetricsService as TursoMetricsService } from './simple-metrics.adapter' 9 - export { SimpleTursoAuthService as TursoAuthService } from './simple-auth.adapter' 10 - export { SimpleTursoOrganizationRepository as TursoOrganizationRepository } from './simple-org.adapter' 11 - export { SimpleTursoRepository as TursoRepository } from './simple-repo.adapter' 6 + // Export real production adapters that query the database 7 + export { TursoPullRequestRepository } from './pull-request.adapter' 8 + export { TursoMetricsService } from './metrics.adapter' 9 + export { TursoAuthService } from './auth.adapter' 10 + export { TursoOrganizationRepository } from './organization.adapter' 11 + export { TursoRepository } from './repository.adapter'
+4 -4
lib/infrastructure/config/environment.ts
··· 52 52 53 53 const hasGitHubApp = Boolean( 54 54 process.env.GITHUB_APP_ID && 55 - process.env.GITHUB_PRIVATE_KEY 55 + process.env.GITHUB_APP_PRIVATE_KEY 56 56 ) 57 57 58 58 // Force demo mode if explicitly set ··· 80 80 if (hasGitHubApp) { 81 81 config.github = { 82 82 appId: process.env.GITHUB_APP_ID!, 83 - privateKey: process.env.GITHUB_PRIVATE_KEY!, 83 + privateKey: process.env.GITHUB_APP_PRIVATE_KEY!, 84 84 webhookSecret: process.env.GITHUB_WEBHOOK_SECRET, 85 - clientId: process.env.GITHUB_CLIENT_ID, 86 - clientSecret: process.env.GITHUB_CLIENT_SECRET 85 + clientId: process.env.GITHUB_OAUTH_CLIENT_ID, 86 + clientSecret: process.env.GITHUB_OAUTH_CLIENT_SECRET 87 87 } 88 88 } 89 89