+4
-4
.env.example
+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
+2
-1
.gitignore
-554
ARCHITECTURE_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
+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
+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
-
[](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
+
[](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
-
[](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
+
[](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
+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
-
[](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
+
[](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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+2
-2
environment.example
+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
+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
+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
+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
+1
lib/core/ports/index.ts
+3
-3
lib/env-validation.ts
+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
+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
+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
+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
+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
+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
+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
+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