feat(api): add runner, analytics, packages, and security services
Swadhin Biswas committed 7 months ago
28f7954
1 parent a4239f1
commit 28f7954
151 files changed
+18868 -6736
Showing 44 changed files+5711-2920
This diff is too large and has been truncated. Displaying first 44 files.
drizzle.config.tspackage.jsonscripts/worker.tssrc/db/adapter/index.tssrc/db/adapter/types.tssrc/db/index.tssrc/db/schema/activity.tssrc/db/schema/automations.tssrc/db/schema/index.tssrc/db/schema/integrations.tssrc/db/schema/issues.tssrc/db/schema/packages.tssrc/db/schema/pipeline-runners.tssrc/db/schema/pr-states.tssrc/db/schema/pull-requests.tssrc/db/schema/repositories.tssrc/db/schema/security-policies.tssrc/db/schema/teams.tssrc/db/schema/users.tssrc/db/schema/webhooks.tssrc/db/schema/workflows.tssrc/lib/activity.tssrc/lib/ai-review.tssrc/lib/ai/index.tssrc/lib/ai/ollama.tssrc/lib/ai/types.tssrc/lib/air-gapped.tssrc/lib/analytics-advanced.tssrc/lib/api.tssrc/lib/artifacts.tssrc/lib/audit.tssrc/lib/auth-basic.tssrc/lib/auth.tssrc/lib/auto-merge-rules.tssrc/lib/automations.tssrc/lib/bulk-merge.tssrc/lib/chat-notifications.tssrc/lib/ci-logs.tssrc/lib/cloud-hooks.tssrc/lib/distributed-lock.tssrc/lib/env-validation.tssrc/lib/epics.tssrc/lib/exec.tssrc/lib/external-ci.ts
▼drizzle.config.ts
+14-10
| @@ -1,19 +1,23 @@ | ||
1 | 1 | import { defineConfig } from "drizzle-kit"; |
2 | 2 | |
3 | 3 | // Get driver type from environment |
4 | -const driver = process.env.DATABASE_DRIVER || "sqlite"; | |
5 | -const url = process.env.DATABASE_URL || "./data/opencodehub.db"; | |
4 | +// Default to PostgreSQL since all schemas use pgTable | |
5 | +const driver = process.env.DATABASE_DRIVER || "postgres"; | |
6 | +const url = | |
7 | + process.env.DATABASE_URL || | |
8 | + "postgresql://opencodehub:opencodehub@localhost:5432/opencodehub"; | |
6 | 9 | |
7 | 10 | // Map driver to Drizzle dialect |
8 | -const dialectMap: Record<string, "sqlite" | "postgresql" | "mysql" | "turso"> = { | |
9 | - sqlite: "sqlite", | |
10 | - postgres: "postgresql", | |
11 | - mysql: "mysql", | |
12 | - libsql: "turso", | |
13 | - turso: "turso", | |
14 | -}; | |
11 | +const dialectMap: Record<string, "sqlite" | "postgresql" | "mysql" | "turso"> = | |
12 | + { | |
13 | + sqlite: "sqlite", | |
14 | + postgres: "postgresql", | |
15 | + mysql: "mysql", | |
16 | + libsql: "turso", | |
17 | + turso: "turso", | |
18 | + }; | |
15 | 19 | |
16 | -const dialect = dialectMap[driver] || "sqlite"; | |
20 | +const dialect = dialectMap[driver] || "postgresql"; | |
17 | 21 | |
18 | 22 | // Build credentials based on driver |
19 | 23 | function getCredentials() { |
20 | 24 | |
▼package.json
+3-1
| @@ -29,7 +29,8 @@ | ||
29 | 29 | "typecheck": "tsc --noEmit", |
30 | 30 | "test": "vitest run", |
31 | 31 | "test:coverage": "vitest run --coverage", |
32 | - "backup": "bun scripts/backup.ts" | |
32 | + "backup": "bun scripts/backup.ts", | |
33 | + "docs:parity": "node scripts/check-docs-parity.mjs" | |
33 | 34 | }, |
34 | 35 | "dependencies": { |
35 | 36 | "@astrojs/check": "^0.9.6", |
| @@ -140,6 +141,7 @@ | ||
140 | 141 | "zustand": "^4.5.4" |
141 | 142 | }, |
142 | 143 | "devDependencies": { |
144 | + "@axe-core/playwright": "^4.10.0", | |
143 | 145 | "@faker-js/faker": "^10.1.0", |
144 | 146 | "@playwright/test": "^1.57.0", |
145 | 147 | "@tailwindcss/typography": "^0.5.19", |
146 | 148 | |
▼scripts/worker.ts
+214-58
| @@ -1,77 +1,233 @@ | ||
1 | - | |
2 | 1 | import { getDatabase, schema } from "@/db"; |
3 | -import { queueWorker } from "@/lib/queue-worker"; | |
4 | -import { syncAllMirrors } from "@/lib/mirror-sync"; | |
5 | 2 | import { cleanupAllRepos } from "@/lib/cron/cleanup-branches"; |
6 | 3 | import { logger } from "@/lib/logger"; |
7 | -import { eq, and, sql } from "drizzle-orm"; | |
4 | +import { syncAllMirrors } from "@/lib/mirror-sync"; | |
5 | +import { queueWorker } from "@/lib/queue-worker"; | |
6 | +import { and, eq, lt } from "drizzle-orm"; | |
7 | +import { createServer } from "http"; | |
8 | + | |
9 | +// ── Configuration ───────────────────────────────────────────────────────────── | |
10 | +const WORKER_INTERVAL = parseInt(process.env.WORKER_INTERVAL || "5000", 10); | |
11 | +const MIRROR_SYNC_INTERVAL = parseInt( | |
12 | + process.env.MIRROR_SYNC_INTERVAL || "60000", | |
13 | + 10, | |
14 | +); | |
15 | +const CLEANUP_INTERVAL = parseInt( | |
16 | + process.env.CLEANUP_INTERVAL || "3600000", | |
17 | + 10, | |
18 | +); | |
19 | +const HEALTH_PORT = parseInt(process.env.WORKER_HEALTH_PORT || "9090", 10); | |
20 | +const MAX_RETRIES = parseInt(process.env.WORKER_MAX_RETRIES || "3", 10); | |
21 | +const STALE_JOB_TIMEOUT_MS = parseInt( | |
22 | + process.env.WORKER_STALE_TIMEOUT || "300000", | |
23 | + 10, | |
24 | +); | |
8 | 25 | |
9 | -const WORKER_INTERVAL = 5000; // 5 seconds for queue polling | |
10 | -const MIRROR_SYNC_INTERVAL = 60 * 1000; // 1 minute | |
11 | -const CLEANUP_INTERVAL = 60 * 60 * 1000; // 1 hour | |
26 | +// ── State ───────────────────────────────────────────────────────────────────── | |
27 | +let isShuttingDown = false; | |
28 | +let isHealthy = true; | |
29 | +let lastQueueRun = Date.now(); | |
30 | +let lastMirrorRun = 0; | |
31 | +let lastCleanupRun = 0; | |
32 | +let consecutiveErrors = 0; | |
33 | +const MAX_CONSECUTIVE_ERRORS = 10; | |
12 | 34 | |
35 | +// ── Queue Processor ─────────────────────────────────────── ──────────────────── | |
13 | 36 | async function runQueueProcessor() { |
14 | - const db = getDatabase(); | |
37 | + const db = getDatabase(); | |
15 | 38 | |
39 | + try { | |
40 | + // 1. Reclaim stale jobs (stuck in "processing" too long) | |
41 | + const staleThreshold = new Date(Date.now() - STALE_JOB_TIMEOUT_MS); | |
16 | 42 | try { |
17 | - // Find repositories with queued items | |
18 | - // We group by repositoryId to process per-repo queues | |
19 | - // Actually QueueWorker handles locking per repo? | |
20 | - // Let's just find distinct repositoryIds that have queued items. | |
21 | - // Drizzle distinct on unsupported? Let's Fetch all pending items and get unique repoIds. | |
22 | - | |
23 | - const pendingItems = await db.query.mergeQueueItems.findMany({ | |
24 | - where: eq(schema.mergeQueueItems.status, "queued"), | |
25 | - columns: { repositoryId: true }, | |
26 | - // limit: 100 ?? | |
27 | - }); | |
28 | - | |
29 | - const repoIds = [...new Set(pendingItems.map(item => item.repositoryId))]; | |
30 | - | |
31 | - if (repoIds.length > 0) { | |
32 | - logger.info({ repoCount: repoIds.length }, "Processing merge queues"); | |
33 | - | |
34 | - // Process concurrently? Or sequentially to avoid load? | |
35 | - // Concurrently is fine as they are independent locks. | |
36 | - await Promise.all(repoIds.map(repoId => queueWorker.processQueue(repoId))); | |
43 | + await db | |
44 | + .update(schema.mergeQueueItems) | |
45 | + .set({ status: "queued" }) | |
46 | + .where( | |
47 | + and( | |
48 | + eq(schema.mergeQueueItems.status, "processing"), | |
49 | + lt(schema.mergeQueueItems.updatedAt, staleThreshold), | |
50 | + ), | |
51 | + ); | |
52 | + } catch { | |
53 | + // retryCount/updatedAt columns may not exist yet — skip stale reclaim | |
54 | + } | |
55 | + | |
56 | + // 2. Process pending items | |
57 | + const pendingItems = await db.query.mergeQueueItems.findMany({ | |
58 | + where: eq(schema.mergeQueueItems.status, "queued"), | |
59 | + columns: { repositoryId: true }, | |
60 | + }); | |
61 | + | |
62 | + const repoIds = [...new Set(pendingItems.map((item) => item.repositoryId))]; | |
63 | + | |
64 | + if (repoIds.length > 0) { | |
65 | + logger.info({ repoCount: repoIds.length }, "Processing merge queues"); | |
66 | + // Process sequentially to avoid race conditions | |
67 | + for (const repoId of repoIds) { | |
68 | + if (isShuttingDown) break; | |
69 | + try { | |
70 | + await queueWorker.processQueue(repoId); | |
71 | + } catch (error) { | |
72 | + logger.error( | |
73 | + { err: error, repoId }, | |
74 | + "Failed to process queue for repo", | |
75 | + ); | |
37 | 76 | } |
77 | + } | |
78 | + } | |
38 | 79 | |
39 | - } catch (error) { | |
40 | - logger.error({ err: error }, "Error in queue processor loop"); | |
80 | + consecutiveErrors = 0; | |
81 | + lastQueueRun = Date.now(); | |
82 | + } catch (error) { | |
83 | + consecutiveErrors++; | |
84 | + logger.error( | |
85 | + { err: error, consecutiveErrors }, | |
86 | + "Error in queue processor loop", | |
87 | + ); | |
88 | + if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { | |
89 | + isHealthy = false; | |
90 | + logger.error("Too many consecutive errors. Worker marked unhealthy."); | |
41 | 91 | } |
92 | + } | |
42 | 93 | } |
43 | 94 | |
44 | -async function startWorker() { | |
45 | - logger.info("Starting OpenCodeHub Background Worker..."); | |
95 | +// ── Controlled Loop (replaces setInterval) ──────────────────────────────────── | |
96 | +async function runLoop( | |
97 | + name: string, | |
98 | + fn: () => Promise<void>, | |
99 | + intervalMs: number, | |
100 | + getLastRun: () => number, | |
101 | + setLastRun: (t: number) => void, | |
102 | +) { | |
103 | + while (!isShuttingDown) { | |
104 | + const elapsed = Date.now() - getLastRun(); | |
105 | + if (elapsed >= intervalMs) { | |
106 | + try { | |
107 | + await fn(); | |
108 | + } catch (error) { | |
109 | + logger.error({ err: error, task: name }, `${name} failed`); | |
110 | + } | |
111 | + setLastRun(Date.now()); | |
112 | + } | |
113 | + await sleep(Math.min(intervalMs, 1000)); | |
114 | + } | |
115 | +} | |
46 | 116 | |
47 | - // 1. Merge Queue Loop | |
48 | - setInterval(runQueueProcessor, WORKER_INTERVAL); | |
49 | - // Initial run | |
50 | - runQueueProcessor(); | |
117 | +function sleep(ms: number): Promise<void> { | |
118 | + return new Promise((resolve) => setTimeout(resolve, ms)); | |
119 | +} | |
51 | 120 | |
52 | - // 2. Mirror Sync Loop | |
53 | - setInterval(async () => { | |
54 | - try { | |
55 | - await syncAllMirrors(); | |
56 | - } catch (e) { | |
57 | - logger.error({ err: e }, "Mirror sync failed"); | |
58 | - } | |
59 | - }, MIRROR_SYNC_INTERVAL); | |
121 | +// ── Health Check Server ─────────────────────────────────────────────────────── | |
122 | +function startHealthServer() { | |
123 | + const server = createServer((req, res) => { | |
124 | + if (req.url === "/healthz" || req.url === "/health") { | |
125 | + const timeSinceLastRun = Date.now() - lastQueueRun; | |
126 | + const healthy = isHealthy && timeSinceLastRun < WORKER_INTERVAL * 5; | |
127 | + res.writeHead(healthy ? 200 : 503, { | |
128 | + "Content-Type": "application/json", | |
129 | + }); | |
130 | + res.end( | |
131 | + JSON.stringify({ | |
132 | + status: healthy ? "healthy" : "unhealthy", | |
133 | + uptime: process.uptime(), | |
134 | + lastQueueRun: new Date(lastQueueRun).toISOString(), | |
135 | + consecutiveErrors, | |
136 | + shuttingDown: isShuttingDown, | |
137 | + }), | |
138 | + ); | |
139 | + } else if (req.url === "/ready") { | |
140 | + res.writeHead(isHealthy ? 200 : 503); | |
141 | + res.end(isHealthy ? "ready" : "not ready"); | |
142 | + } else { | |
143 | + res.writeHead(404); | |
144 | + res.end("Not Found"); | |
145 | + } | |
146 | + }); | |
60 | 147 | |
61 | - // 3. Cleanup Loop | |
62 | - setInterval(async () => { | |
63 | - try { | |
64 | - await cleanupAllRepos(); | |
65 | - } catch (e) { | |
66 | - logger.error({ err: e }, "Cleanup failed"); | |
67 | - } | |
68 | - }, CLEANUP_INTERVAL); | |
148 | + server.listen(HEALTH_PORT, () => { | |
149 | + logger.info({ port: HEALTH_PORT }, "Worker health server started"); | |
150 | + }); | |
151 | + return server; | |
152 | +} | |
69 | 153 | |
70 | - // Keep process alive | |
71 | - process.on('SIGINT', () => { | |
72 | - logger.info("Worker shutting down..."); | |
73 | - process.exit(0); | |
74 | - }); | |
154 | +// ── Graceful Shutdown ───────────────────────────────────────────────────────── | |
155 | +function setupGracefulShutdown(healthServer: ReturnType<typeof createServer>) { | |
156 | + const shutdown = async (signal: string) => { | |
157 | + if (isShuttingDown) return; | |
158 | + isShuttingDown = true; | |
159 | + logger.info({ signal }, "Worker shutting down gracefully..."); | |
160 | + healthServer.close(); | |
161 | + | |
162 | + // Give in-flight jobs up to 30s to complete | |
163 | + await sleep(2000); | |
164 | + logger.info("Worker shutdown complete."); | |
165 | + process.exit(0); | |
166 | + }; | |
167 | + | |
168 | + process.on("SIGINT", () => shutdown("SIGINT")); | |
169 | + process.on("SIGTERM", () => shutdown("SIGTERM")); | |
170 | + process.on("uncaughtException", (err) => { | |
171 | + logger.error({ err }, "Uncaught exception in worker"); | |
172 | + isHealthy = false; | |
173 | + }); | |
174 | + process.on("unhandledRejection", (reason) => { | |
175 | + logger.error({ reason }, "Unhandled rejection in worker"); | |
176 | + }); | |
177 | +} | |
178 | + | |
179 | +// ── Main ──────────────────────────────────────────────────────── ────────────── | |
180 | +async function startWorker() { | |
181 | + logger.info( | |
182 | + { | |
183 | + queueInterval: WORKER_INTERVAL, | |
184 | + mirrorInterval: MIRROR_SYNC_INTERVAL, | |
185 | + cleanupInterval: CLEANUP_INTERVAL, | |
186 | + healthPort: HEALTH_PORT, | |
187 | + maxRetries: MAX_RETRIES, | |
188 | + }, | |
189 | + "Starting OpenCodeHub Background Worker...", | |
190 | + ); | |
191 | + | |
192 | + const healthServer = startHealthServer(); | |
193 | + setupGracefulShutdown(healthServer); | |
194 | + | |
195 | + // Run initial processing immediately | |
196 | + await runQueueProcessor(); | |
197 | + | |
198 | + // Start concurrent task loops | |
199 | + await Promise.all([ | |
200 | + runLoop( | |
201 | + "queue-processor", | |
202 | + runQueueProcessor, | |
203 | + WORKER_INTERVAL, | |
204 | + () => lastQueueRun, | |
205 | + (t) => { | |
206 | + lastQueueRun = t; | |
207 | + }, | |
208 | + ), | |
209 | + runLoop( | |
210 | + "mirror-sync", | |
211 | + () => syncAllMirrors(), | |
212 | + MIRROR_SYNC_INTERVAL, | |
213 | + () => lastMirrorRun, | |
214 | + (t) => { | |
215 | + lastMirrorRun = t; | |
216 | + }, | |
217 | + ), | |
218 | + runLoop( | |
219 | + "cleanup", | |
220 | + () => cleanupAllRepos(), | |
221 | + CLEANUP_INTERVAL, | |
222 | + () => lastCleanupRun, | |
223 | + (t) => { | |
224 | + lastCleanupRun = t; | |
225 | + }, | |
226 | + ), | |
227 | + ]); | |
75 | 228 | } |
76 | 229 | |
77 | -startWorker().catch(console.error); | |
230 | +startWorker().catch((err) => { | |
231 | + logger.error({ err }, "Worker failed to start"); | |
232 | + process.exit(1); | |
233 | +}); | |
78 | 234 | |
▼src/db/adapter/index.ts
+4-51
| @@ -11,7 +11,7 @@ import { DatabaseAdapter, DatabaseConfig, DatabaseDriver } from "./types"; | ||
11 | 11 | |
12 | 12 | // Lazy-loaded adapters for optional dependencies |
13 | 13 | const adapterLoaders: Record< |
14 | - DatabaseDriver, | |
14 | + string, | |
15 | 15 | () => Promise<new (config: DatabaseConfig) => DatabaseAdapter> |
16 | 16 | > = { |
17 | 17 | sqlite: async () => SQLiteAdapter, |
| @@ -19,69 +19,22 @@ const adapterLoaders: Record< | ||
19 | 19 | const { PostgresAdapter } = await import("./postgres"); |
20 | 20 | return PostgresAdapter; |
21 | 21 | }, |
22 | - mysql: async () => { | |
23 | - const { MySQLAdapter } = await import("./mysql"); | |
24 | - return MySQLAdapter; | |
25 | - }, | |
26 | - mongodb: async () => { | |
27 | - const { MongoDBAdapter } = await import("./mongodb"); | |
28 | - return MongoDBAdapter; | |
29 | - }, | |
30 | 22 | turso: async () => { |
31 | 23 | const { TursoAdapter } = await import("./turso"); |
32 | 24 | return TursoAdapter; |
33 | 25 | }, |
34 | - planetscale: async () => { | |
35 | - const { MySQLAdapter } = await import("./mysql"); | |
36 | - return MySQLAdapter; | |
37 | - }, | |
38 | - redis: async () => { | |
39 | - const { RedisAdapter } = await import("./redis"); | |
40 | - return RedisAdapter; | |
41 | - }, | |
42 | - firestore: async () => { | |
43 | - const { FirestoreAdapter } = await import("./firestore"); | |
44 | - return FirestoreAdapter; | |
45 | - }, | |
46 | - dynamodb: async () => { | |
47 | - const { DynamoDBAdapter } = await import("./dynamodb"); | |
48 | - return DynamoDBAdapter; | |
49 | - }, | |
50 | - neo4j: async () => { | |
51 | - const { Neo4jAdapter } = await import("./neo4j"); | |
52 | - return Neo4jAdapter; | |
53 | - }, | |
54 | 26 | cockroachdb: async () => { |
27 | + // CockroachDB uses the Postgres wire protocol | |
55 | 28 | const { PostgresAdapter } = await import("./postgres"); |
56 | 29 | return PostgresAdapter; |
57 | 30 | }, |
58 | - cassandra: async () => { | |
59 | - const { CassandraAdapter } = await import("./cassandra"); | |
60 | - return CassandraAdapter; | |
61 | - }, | |
62 | - scylladb: async () => { | |
63 | - const { ScyllaDBAdapter } = await import("./scylladb"); | |
64 | - return ScyllaDBAdapter; | |
65 | - }, | |
66 | - surrealdb: async () => { | |
67 | - const { SurrealDBAdapter } = await import("./surrealdb"); | |
68 | - return SurrealDBAdapter; | |
69 | - }, | |
70 | - tidb: async () => { | |
71 | - const { MySQLAdapter } = await import("./mysql"); | |
72 | - return MySQLAdapter; | |
73 | - }, | |
74 | - mariadb: async () => { | |
75 | - const { MySQLAdapter } = await import("./mysql"); | |
76 | - return MySQLAdapter; | |
77 | - }, | |
78 | 31 | custom: async () => { |
79 | 32 | throw new Error("Custom adapter must be provided explicitly"); |
80 | 33 | }, |
81 | 34 | }; |
82 | 35 | |
83 | 36 | export async function createAdapter( |
84 | - config: DatabaseConfig | |
37 | + config: DatabaseConfig, | |
85 | 38 | ): Promise<DatabaseAdapter> { |
86 | 39 | const loader = adapterLoaders[config.driver]; |
87 | 40 | |
| @@ -164,7 +117,7 @@ export class DatabaseManager { | ||
164 | 117 | private adapter: DatabaseAdapter | null = null; |
165 | 118 | private config: DatabaseConfig | null = null; |
166 | 119 | |
167 | - private constructor() { } | |
120 | + private constructor() {} | |
168 | 121 | |
169 | 122 | static getInstance(): DatabaseManager { |
170 | 123 | if (!DatabaseManager.instance) { |
171 | 124 | |
▼src/db/adapter/types.ts
+32-44
| @@ -6,20 +6,8 @@ | ||
6 | 6 | export type DatabaseDriver = |
7 | 7 | | "sqlite" |
8 | 8 | | "postgres" |
9 | - | "mysql" | |
10 | - | "mongodb" | |
11 | 9 | | "turso" |
12 | - | "planetscale" | |
13 | - | "redis" | |
14 | - | "firestore" | |
15 | - | "dynamodb" | |
16 | - | "neo4j" | |
17 | 10 | | "cockroachdb" |
18 | - | "cassandra" | |
19 | - | "scylladb" | |
20 | - | "surrealdb" | |
21 | - | "tidb" | |
22 | - | "mariadb" | |
23 | 11 | | "custom"; |
24 | 12 | |
25 | 13 | export interface DatabaseConfig { |
| @@ -46,26 +34,26 @@ export interface QueryResult<T = unknown> { | ||
46 | 34 | export interface WhereClause { |
47 | 35 | field: string; |
48 | 36 | operator: |
49 | - | "=" | |
50 | - | "!=" | |
51 | - | ">" | |
52 | - | "<" | |
53 | - | ">=" | |
54 | - | "<=" | |
55 | - | "in" | |
56 | - | "not in" | |
57 | - | "like" | |
58 | - | "ilike" | |
59 | - | "is null" | |
60 | - | "is not null" | |
61 | - | "eq" | |
62 | - | "ne" | |
63 | - | "lt" | |
64 | - | "lte" | |
65 | - | "gt" | |
66 | - | "gte" | |
67 | - | "isNull" | |
68 | - | "isNotNull"; | |
37 | + | "=" | |
38 | + | "!=" | |
39 | + | ">" | |
40 | + | "<" | |
41 | + | ">=" | |
42 | + | "<=" | |
43 | + | "in" | |
44 | + | "not in" | |
45 | + | "like" | |
46 | + | "ilike" | |
47 | + | "is null" | |
48 | + | "is not null" | |
49 | + | "eq" | |
50 | + | "ne" | |
51 | + | "lt" | |
52 | + | "lte" | |
53 | + | "gt" | |
54 | + | "gte" | |
55 | + | "isNull" | |
56 | + | "isNotNull"; | |
69 | 57 | value: unknown; |
70 | 58 | } |
71 | 59 | |
| @@ -100,12 +88,12 @@ export interface DatabaseAdapter { | ||
100 | 88 | update<T>( |
101 | 89 | table: string, |
102 | 90 | id: string | number, |
103 | - data: Partial<T> | |
91 | + data: Partial<T>, | |
104 | 92 | ): Promise<T | null>; |
105 | 93 | updateMany<T>( |
106 | 94 | table: string, |
107 | 95 | options: QueryOptions, |
108 | - data: Partial<T> | |
96 | + data: Partial<T>, | |
109 | 97 | ): Promise<number>; |
110 | 98 | |
111 | 99 | delete(table: string, id: string | number): Promise<boolean>; |
| @@ -188,11 +176,11 @@ export interface ForeignKeyDefinition { | ||
188 | 176 | |
189 | 177 | export interface TableAlterCommand { |
190 | 178 | type: |
191 | - | "add_column" | |
192 | - | "drop_column" | |
193 | - | "modify_column" | |
194 | - | "add_index" | |
195 | - | "drop_index"; | |
179 | + | "add_column" | |
180 | + | "drop_column" | |
181 | + | "modify_column" | |
182 | + | "add_index" | |
183 | + | "drop_index"; | |
196 | 184 | column?: ColumnDefinition; |
197 | 185 | columnName?: string; |
198 | 186 | index?: IndexDefinition; |
| @@ -244,12 +232,12 @@ export abstract class BaseDatabaseAdapter implements DatabaseAdapter { | ||
244 | 232 | abstract update<T>( |
245 | 233 | table: string, |
246 | 234 | id: string | number, |
247 | - data: Partial<T> | |
235 | + data: Partial<T>, | |
248 | 236 | ): Promise<T | null>; |
249 | 237 | abstract updateMany<T>( |
250 | 238 | table: string, |
251 | 239 | options: QueryOptions, |
252 | - data: Partial<T> | |
240 | + data: Partial<T>, | |
253 | 241 | ): Promise<number>; |
254 | 242 | abstract delete(table: string, id: string | number): Promise<boolean>; |
255 | 243 | abstract deleteMany(table: string, options?: QueryOptions): Promise<number>; |
| @@ -263,7 +251,7 @@ export abstract class BaseDatabaseAdapter implements DatabaseAdapter { | ||
263 | 251 | |
264 | 252 | abstract rawQuery<T>( |
265 | 253 | query: string, |
266 | - params?: unknown[] | |
254 | + params?: unknown[], | |
267 | 255 | ): Promise<QueryResult<T>>; |
268 | 256 | |
269 | 257 | abstract beginTransaction(): Promise<void>; |
| @@ -287,7 +275,7 @@ export abstract class BaseDatabaseAdapter implements DatabaseAdapter { | ||
287 | 275 | abstract dropTable(table: string): Promise<void>; |
288 | 276 | abstract alterTable( |
289 | 277 | table: string, |
290 | - changes: TableAlterCommand[] | |
278 | + changes: TableAlterCommand[], | |
291 | 279 | ): Promise<void>; |
292 | 280 | |
293 | 281 | abstract runMigration(migration: Migration): Promise<void>; |
| @@ -318,7 +306,7 @@ export abstract class BaseDatabaseAdapter implements DatabaseAdapter { | ||
318 | 306 | const values = clause.value as unknown[]; |
319 | 307 | const placeholders = values.map(() => "?").join(", "); |
320 | 308 | conditions.push( |
321 | - `${clause.field} ${clause.operator.toUpperCase()} (${placeholders})` | |
309 | + `${clause.field} ${clause.operator.toUpperCase()} (${placeholders})`, | |
322 | 310 | ); |
323 | 311 | params.push(...values); |
324 | 312 | break; |
325 | 313 | |
▼src/db/index.ts
+45-27
| @@ -1,30 +1,44 @@ | ||
1 | +import { logger } from "@/lib/logger"; | |
2 | +import { createClient } from "@libsql/client"; | |
1 | 3 | import Database from "better-sqlite3"; |
2 | 4 | import { BetterSQLite3Database, drizzle } from "drizzle-orm/better-sqlite3"; |
3 | 5 | import { drizzle as drizzleLibSQL, LibSQLDatabase } from "drizzle-orm/libsql"; |
4 | -import { drizzle as drizzlePg, NodePgDatabase } from "drizzle-orm/node-postgres"; | |
5 | -import { createClient } from "@libsql/client"; | |
6 | -import pg from "pg"; | |
6 | +import { | |
7 | + drizzle as drizzlePg, | |
8 | + NodePgDatabase, | |
9 | +} from "drizzle-orm/node-postgres"; | |
7 | 10 | import { existsSync, mkdirSync } from "fs"; |
8 | 11 | import { dirname } from "path"; |
9 | -import { logger } from "@/lib/logger"; | |
12 | +import pg from "pg"; | |
10 | 13 | import * as schema from "./schema"; |
11 | 14 | |
12 | 15 | // Database driver types |
13 | -export type DatabaseDriver = "sqlite" | "postgres" | "mysql" | "libsql" | "turso"; | |
14 | - | |
15 | -let db: BetterSQLite3Database<typeof schema> | LibSQLDatabase<typeof schema> | NodePgDatabase<typeof schema> | null = | |
16 | - null; | |
16 | +export type DatabaseDriver = | |
17 | + | "sqlite" | |
18 | + | "postgres" | |
19 | + | "mysql" | |
20 | + | "libsql" | |
21 | + | "turso"; | |
22 | + | |
23 | +let db: | |
24 | + | BetterSQLite3Database<typeof schema> | |
25 | + | LibSQLDatabase<typeof schema> | |
26 | + | NodePgDatabase<typeof schema> | |
27 | + | null = null; | |
17 | 28 | |
18 | 29 | /** |
19 | 30 | * Get database configuration from environment |
20 | 31 | */ |
21 | 32 | function getDbConfig(): { driver: DatabaseDriver; url: string } { |
22 | - // Use import.meta.env for Astro/Vite, fallback to process.env for scripts | |
23 | - const envDriver = import.meta.env?.DATABASE_DRIVER || process.env.DATABASE_DRIVER; | |
33 | + // Prefer Astro/Vite env (loaded from .env), then fallback to process.env for scripts. | |
34 | + const envDriver = | |
35 | + import.meta.env?.DATABASE_DRIVER || process.env.DATABASE_DRIVER; | |
24 | 36 | const envUrl = import.meta.env?.DATABASE_URL || process.env.DATABASE_URL; |
25 | 37 | |
26 | - const driver = (envDriver || "sqlite") as DatabaseDriver; | |
27 | - const url = envUrl || "./data/opencodehub.db"; | |
38 | + // Default to PostgreSQL since all schemas use pgTable | |
39 | + const driver = (envDriver || "postgres") as DatabaseDriver; | |
40 | + const url = | |
41 | + envUrl || "postgresql://opencodehub:opencodehub@localhost:5432/opencodehub"; | |
28 | 42 | |
29 | 43 | return { driver, url }; |
30 | 44 | } |
| @@ -51,7 +65,8 @@ export function getDatabase(): | ||
51 | 65 | |
52 | 66 | // Support for Turso/LibSQL |
53 | 67 | if (driver === "libsql" || driver === "turso") { |
54 | - const authToken = import.meta.env?.DATABASE_AUTH_TOKEN || process.env.DATABASE_AUTH_TOKEN; | |
68 | + const authToken = | |
69 | + import.meta.env?.DATABASE_AUTH_TOKEN || process.env.DATABASE_AUTH_TOKEN; | |
55 | 70 | const client = createClient({ |
56 | 71 | url, |
57 | 72 | authToken, |
| @@ -64,26 +79,28 @@ export function getDatabase(): | ||
64 | 79 | // Support for PostgreSQL |
65 | 80 | if (driver === "postgres") { |
66 | 81 | const sslEnabled = |
67 | - isTruthy(process.env.DATABASE_SSL) || | |
68 | - /[?&]sslmode=require/i.test(url); | |
82 | + isTruthy(process.env.DATABASE_SSL) || /[?&]sslmode=require/i.test(url); | |
69 | 83 | const rejectUnauthorized = shouldRejectUnauthorized( |
70 | - process.env.DATABASE_SSL_REJECT_UNAUTHORIZED | |
84 | + process.env.DATABASE_SSL_REJECT_UNAUTHORIZED, | |
71 | 85 | ); |
72 | 86 | |
73 | - const cleanUrl = url.replace(/([?&])sslmode=require(&|$)/i, (_, prefix, suffix) => | |
74 | - suffix === "&" ? prefix : "" | |
87 | + const cleanUrl = url.replace( | |
88 | + /([?&])sslmode=require(&|$)/i, | |
89 | + (_, prefix, suffix) => (suffix === "&" ? prefix : ""), | |
75 | 90 | ); |
76 | 91 | const pool = new pg.Pool({ |
77 | 92 | connectionString: cleanUrl, |
78 | - ssl: sslEnabled | |
79 | - ? { rejectUnauthorized } | |
80 | - : undefined, | |
93 | + ssl: sslEnabled ? { rejectUnauthorized } : undefined, | |
81 | 94 | max: parseInt(process.env.DATABASE_POOL_SIZE || "10", 10), |
82 | 95 | }); |
83 | 96 | db = drizzlePg(pool, { schema }); |
84 | 97 | logger.info( |
85 | - { driver: "postgres", sslEnabled, rejectUnauthorized: sslEnabled ? rejectUnauthorized : undefined }, | |
86 | - "Database connected (PostgreSQL)" | |
98 | + { | |
99 | + driver: "postgres", | |
100 | + sslEnabled, | |
101 | + rejectUnauthorized: sslEnabled ? rejectUnauthorized : undefined, | |
102 | + }, | |
103 | + "Database connected (PostgreSQL)", | |
87 | 104 | ); |
88 | 105 | return db; |
89 | 106 | } |
| @@ -92,11 +109,12 @@ export function getDatabase(): | ||
92 | 109 | if (driver === "mysql") { |
93 | 110 | logger.warn( |
94 | 111 | { driver, url }, |
95 | - "MySQL driver validation pending. Falling back to SQLite." | |
112 | + "MySQL driver validation pending. Falling back to SQLite.", | |
96 | 113 | ); |
97 | 114 | } |
98 | 115 | |
99 | - const dbPath = (driver === "sqlite" || driver === "mysql") ? url : "./data/opencodehub.db"; | |
116 | + const dbPath = | |
117 | + driver === "sqlite" || driver === "mysql" ? url : "./data/opencodehub.db"; | |
100 | 118 | |
101 | 119 | // Ensure directory exists for local SQLite |
102 | 120 | if (!url.includes("://")) { |
| @@ -136,12 +154,12 @@ export function isDatabaseConnected(): boolean { | ||
136 | 154 | */ |
137 | 155 | export async function closeDatabase(): Promise<void> { |
138 | 156 | if (db) { |
139 | - if ('close' in db) { | |
157 | + if ("close" in db) { | |
140 | 158 | // Handle SQLite close |
141 | 159 | // @ts-ignore |
142 | 160 | db.close(); |
143 | 161 | } |
144 | - // For PG pool, we might need to close the pool if we had access to it, | |
162 | + // For PG pool, we might need to close the pool if we had access to it, | |
145 | 163 | // but Drizzle doesn't expose it directly on the db instance easily without type casting. |
146 | 164 | // In serverless/long-running app, closing might not be strictly necessary unless ensuring graceful shutdown. |
147 | 165 | |
148 | 166 | |
▼src/db/schema/activity.ts
+79-55
| @@ -4,68 +4,92 @@ | ||
4 | 4 | */ |
5 | 5 | |
6 | 6 | import { relations } from "drizzle-orm"; |
7 | -import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core"; | |
7 | +import { boolean, index, pgTable, text, timestamp } from "drizzle-orm/pg-core"; | |
8 | 8 | // import { organizations } from "./organizations"; // Removed to prevent circular dependency |
9 | 9 | import { repositories } from "./repositories"; |
10 | 10 | import { users } from "./users"; |
11 | 11 | |
12 | -export const activities = pgTable("activities", { | |
13 | - id: text("id").primaryKey(), | |
14 | - userId: text("user_id") | |
15 | - .notNull() | |
16 | - .references(() => users.id, { onDelete: "cascade" }), | |
17 | - repositoryId: text("repository_id").references(() => repositories.id, { | |
18 | - onDelete: "cascade", | |
12 | +export const activities = pgTable( | |
13 | + "activities", | |
14 | + { | |
15 | + id: text("id").primaryKey(), | |
16 | + userId: text("user_id") | |
17 | + .notNull() | |
18 | + .references(() => users.id, { onDelete: "cascade" }), | |
19 | + repositoryId: text("repository_id").references(() => repositories.id, { | |
20 | + onDelete: "cascade", | |
21 | + }), | |
22 | + type: text("type").notNull(), // push, create_repo, create_branch, open_issue, open_pr, comment, star, fork, etc. | |
23 | + action: text("action").notNull(), // created, updated, deleted, opened, closed, merged, etc. | |
24 | + refType: text("ref_type"), // branch, tag | |
25 | + refName: text("ref_name"), | |
26 | + targetType: text("target_type"), // repository, issue, pull_request, comment, etc. | |
27 | + targetId: text("target_id"), | |
28 | + payload: text("payload"), // JSON - additional data | |
29 | + isPublic: boolean("is_public").default(true), | |
30 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
31 | + }, | |
32 | + (t) => ({ | |
33 | + userIdx: index("activities_user_idx").on(t.userId), | |
34 | + repoIdx: index("activities_repo_idx").on(t.repositoryId), | |
35 | + createdAtIdx: index("activities_created_idx").on(t.createdAt), | |
19 | 36 | }), |
20 | - type: text("type").notNull(), // push, create_repo, create_branch, open_issue, open_pr, comment, star, fork, etc. | |
21 | - action: text("action").notNull(), // created, updated, deleted, opened, closed, merged, etc. | |
22 | - refType: text("ref_type"), // branch, tag | |
23 | - refName: text("ref_name"), | |
24 | - targetType: text("target_type"), // repository, issue, pull_request, comment, etc. | |
25 | - targetId: text("target_id"), | |
26 | - payload: text("payload"), // JSON - additional data | |
27 | - isPublic: boolean("is_public").default(true), | |
28 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
29 | -}); | |
37 | +); | |
30 | 38 | |
31 | -export const notifications = pgTable("notifications", { | |
32 | - id: text("id").primaryKey(), | |
33 | - userId: text("user_id") | |
34 | - .notNull() | |
35 | - .references(() => users.id, { onDelete: "cascade" }), | |
36 | - repositoryId: text("repository_id").references(() => repositories.id), | |
37 | - type: text("type").notNull(), // mention, assign, review_request, ci_failed, etc. | |
38 | - title: text("title").notNull(), | |
39 | - body: text("body"), | |
40 | - url: text("url"), | |
41 | - actorId: text("actor_id").references(() => users.id), | |
42 | - subjectType: text("subject_type"), // issue, pull_request, commit, etc. | |
43 | - subjectId: text("subject_id"), | |
44 | - reason: text("reason").notNull(), // mention, assign, subscribed, review_requested, etc. | |
45 | - isRead: boolean("is_read").default(false), | |
46 | - readAt: timestamp("read_at"), | |
47 | - isArchived: boolean("is_archived").default(false), | |
48 | - archivedAt: timestamp("archived_at"), | |
49 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
50 | - updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
51 | -}); | |
39 | +export const notifications = pgTable( | |
40 | + "notifications", | |
41 | + { | |
42 | + id: text("id").primaryKey(), | |
43 | + userId: text("user_id") | |
44 | + .notNull() | |
45 | + .references(() => users.id, { onDelete: "cascade" }), | |
46 | + repositoryId: text("repository_id").references(() => repositories.id), | |
47 | + type: text("type").notNull(), // mention, assign, review_request, ci_failed, etc. | |
48 | + title: text("title").notNull(), | |
49 | + body: text("body"), | |
50 | + url: text("url"), | |
51 | + actorId: text("actor_id").references(() => users.id), | |
52 | + subjectType: text("subject_type"), // issue, pull_request, commit, etc. | |
53 | + subjectId: text("subject_id"), | |
54 | + reason: text("reason").notNull(), // mention, assign, subscribed, review_requested, etc. | |
55 | + isRead: boolean("is_read").default(false), | |
56 | + readAt: timestamp("read_at"), | |
57 | + isArchived: boolean("is_archived").default(false), | |
58 | + archivedAt: timestamp("archived_at"), | |
59 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
60 | + updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
61 | + }, | |
62 | + (t) => ({ | |
63 | + userReadIdx: index("notifications_user_read_idx").on(t.userId, t.isRead), | |
64 | + userIdx: index("notifications_user_idx").on(t.userId), | |
65 | + }), | |
66 | +); | |
52 | 67 | |
53 | -export const auditLogs = pgTable("audit_logs", { | |
54 | - id: text("id").primaryKey(), | |
55 | - userId: text("user_id").references(() => users.id), | |
56 | - repositoryId: text("repository_id").references(() => repositories.id), | |
57 | - organizationId: text("organization_id"), | |
58 | - action: text("action").notNull(), | |
59 | - actorType: text("actor_type").notNull().default("user"), // user, system, oauth_app | |
60 | - actorId: text("actor_id"), | |
61 | - actorIp: text("actor_ip"), | |
62 | - actorUserAgent: text("actor_user_agent"), | |
63 | - targetType: text("target_type"), | |
64 | - targetId: text("target_id"), | |
65 | - targetName: text("target_name"), | |
66 | - data: text("data"), // JSON - before/after data | |
67 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
68 | -}); | |
68 | +export const auditLogs = pgTable( | |
69 | + "audit_logs", | |
70 | + { | |
71 | + id: text("id").primaryKey(), | |
72 | + userId: text("user_id").references(() => users.id), | |
73 | + repositoryId: text("repository_id").references(() => repositories.id), | |
74 | + organizationId: text("organization_id"), | |
75 | + action: text("action").notNull(), | |
76 | + actorType: text("actor_type").notNull().default("user"), // user, system, oauth_app | |
77 | + actorId: text("actor_id"), | |
78 | + actorIp: text("actor_ip"), | |
79 | + actorUserAgent: text("actor_user_agent"), | |
80 | + targetType: text("target_type"), | |
81 | + targetId: text("target_id"), | |
82 | + targetName: text("target_name"), | |
83 | + data: text("data"), // JSON - before/after data | |
84 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
85 | + }, | |
86 | + (t) => ({ | |
87 | + actionIdx: index("audit_logs_action_idx").on(t.action), | |
88 | + actorIdx: index("audit_logs_actor_idx").on(t.actorId), | |
89 | + createdAtIdx: index("audit_logs_created_idx").on(t.createdAt), | |
90 | + targetIdx: index("audit_logs_target_idx").on(t.targetType, t.targetId), | |
91 | + }), | |
92 | +); | |
69 | 93 | |
70 | 94 | // Relations |
71 | 95 | export const activitiesRelations = relations(activities, ({ one }) => ({ |
72 | 96 | |
▼src/db/schema/automations.ts
+5-1
| @@ -64,6 +64,8 @@ export const automationRules = pgTable("automation_rules", { | ||
64 | 64 | * - notify_slack: Send Slack notification |
65 | 65 | * - close_pr: Close the pull request |
66 | 66 | * - request_changes: Request changes programmatically |
67 | + * - trigger_iac_hook: Trigger IaC hook (Terraform plan/apply) | |
68 | + * - trigger_cloud_deploy: Trigger cloud deploy hook (AWS/GCP/Azure/K8s) | |
67 | 69 | */ |
68 | 70 | |
69 | 71 | /** |
| @@ -138,7 +140,9 @@ export type AutomationActionType = | ||
138 | 140 | | "notify_discord" |
139 | 141 | | "notify_teams" |
140 | 142 | | "close_pr" |
141 | - | "request_changes"; | |
143 | + | "request_changes" | |
144 | + | "trigger_iac_hook" | |
145 | + | "trigger_cloud_deploy"; | |
142 | 146 | |
143 | 147 | export interface AutomationCondition { |
144 | 148 | field: string; // e.g., "pr.author", "pr.labels", "pr.changedFiles" |
145 | 149 | |
▼src/db/schema/index.ts
+9-7
| @@ -7,8 +7,8 @@ | ||
7 | 7 | export * from "./users"; |
8 | 8 | |
9 | 9 | // Repositories and git |
10 | -export * from "./repositories"; | |
11 | 10 | export * from "./deploy-keys"; |
11 | +export * from "./repositories"; | |
12 | 12 | |
13 | 13 | // Issues and project management |
14 | 14 | export * from "./issues"; |
| @@ -17,24 +17,23 @@ export * from "./issues"; | ||
17 | 17 | export * from "./pull-requests"; |
18 | 18 | |
19 | 19 | // CI/CD workflows |
20 | -export * from "./workflows"; | |
21 | 20 | export * from "./projects"; |
22 | - | |
23 | - | |
21 | +export * from "./workflows"; | |
24 | 22 | |
25 | 23 | // Wiki |
26 | 24 | export * from "./wiki"; |
27 | 25 | |
28 | 26 | // Organizations and teams |
29 | 27 | export * from "./organizations"; |
30 | -export * from "./teams"; | |
31 | 28 | export * from "./roles"; |
29 | +export * from "./teams"; | |
32 | 30 | |
33 | 31 | // Activity and notifications |
34 | 32 | export * from "./activity"; |
35 | 33 | |
36 | 34 | // Security |
37 | 35 | export * from "./security"; |
36 | +export * from "./security-policies"; | |
38 | 37 | |
39 | 38 | // Pipeline Runners |
40 | 39 | export * from "./pipeline-runners"; |
| @@ -103,5 +102,8 @@ export * from "./external-ci"; | ||
103 | 102 | // Review requirements and reviewer rules |
104 | 103 | export * from "./review-rules"; |
105 | 104 | |
106 | -// Missing schemas - resolves pending feature references | |
107 | -export * from "./missing-schemas"; | |
105 | +// Integrations & extended features (code quality, dashboards, deployments, etc.) | |
106 | +export * from "./integrations"; | |
107 | + | |
108 | +// Package Registry | |
109 | +export * from "./packages"; | |
108 | 110 | |
▼src/db/schema/missing-schemas.ts → src/db/schema/integrations.ts
+0-0
0 | 0 |
▼src/db/schema/issues.ts
+87-55
| @@ -4,66 +4,98 @@ | ||
4 | 4 | */ |
5 | 5 | |
6 | 6 | import { relations } from "drizzle-orm"; |
7 | -import { boolean, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; | |
7 | +import { | |
8 | + boolean, | |
9 | + index, | |
10 | + integer, | |
11 | + pgTable, | |
12 | + text, | |
13 | + timestamp, | |
14 | + uniqueIndex, | |
15 | +} from "drizzle-orm/pg-core"; | |
16 | +import { issueCustomFieldValues } from "./custom-fields"; | |
17 | +import { issueStatuses } from "./issue-statuses"; | |
8 | 18 | import { repositories } from "./repositories"; |
9 | 19 | import { users } from "./users"; |
10 | -import { issueStatuses } from "./issue-statuses"; | |
11 | -import { issueCustomFieldValues } from "./custom-fields"; | |
12 | 20 | |
13 | -export const issues = pgTable("issues", { | |
14 | - id: text("id").primaryKey(), | |
15 | - repositoryId: text("repository_id") | |
16 | - .notNull() | |
17 | - .references(() => repositories.id, { onDelete: "cascade" }), | |
18 | - number: integer("number").notNull(), | |
19 | - title: text("title").notNull(), | |
20 | - body: text("body"), | |
21 | - state: text("state").notNull().default("open"), // open, closed | |
22 | - statusId: text("status_id").references(() => issueStatuses.id), // Custom status | |
23 | - type: text("type").notNull().default("issue"), // issue, epic, task | |
24 | - parentId: text("parent_id"), // For sub-tasks or issues belonging to an epic | |
25 | - authorId: text("author_id") | |
26 | - .notNull() | |
27 | - .references(() => users.id), | |
28 | - assigneeId: text("assignee_id").references(() => users.id), | |
29 | - milestoneId: text("milestone_id"), // Reference added later in relations | |
30 | - isPinned: boolean("is_pinned").default(false), | |
31 | - isLocked: boolean("is_locked").default(false), | |
32 | - lockReason: text("lock_reason"), | |
33 | - commentCount: integer("comment_count").default(0), | |
34 | - reactions: text("reactions"), // JSON { +1, -1, laugh, heart, etc. } | |
35 | - closedAt: timestamp("closed_at"), | |
36 | - closedById: text("closed_by_id").references(() => users.id), | |
37 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
38 | - updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
39 | -}); | |
21 | +export const issues = pgTable( | |
22 | + "issues", | |
23 | + { | |
24 | + id: text("id").primaryKey(), | |
25 | + repositoryId: text("repository_id") | |
26 | + .notNull() | |
27 | + .references(() => repositories.id, { onDelete: "cascade" }), | |
28 | + number: integer("number").notNull(), | |
29 | + title: text("title").notNull(), | |
30 | + body: text("body"), | |
31 | + state: text("state").notNull().default("open"), // open, closed | |
32 | + statusId: text("status_id").references(() => issueStatuses.id), // Custom status | |
33 | + type: text("type").notNull().default("issue"), // issue, epic, task | |
34 | + parentId: text("parent_id"), // For sub-tasks or issues belonging to an epic | |
35 | + authorId: text("author_id") | |
36 | + .notNull() | |
37 | + .references(() => users.id), | |
38 | + assigneeId: text("assignee_id").references(() => users.id), | |
39 | + milestoneId: text("milestone_id"), // Reference added later in relations | |
40 | + isPinned: boolean("is_pinned").default(false), | |
41 | + isLocked: boolean("is_locked").default(false), | |
42 | + lockReason: text("lock_reason"), | |
43 | + commentCount: integer("comment_count").default(0), | |
44 | + reactions: text("reactions"), // JSON { +1, -1, laugh, heart, etc. } | |
45 | + closedAt: timestamp("closed_at"), | |
46 | + closedById: text("closed_by_id").references(() => users.id), | |
47 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
48 | + updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
49 | + }, | |
50 | + (t) => ({ | |
51 | + repoNumberIdx: uniqueIndex("issues_repo_number_idx").on( | |
52 | + t.repositoryId, | |
53 | + t.number, | |
54 | + ), | |
55 | + repoStateIdx: index("issues_repo_state_idx").on(t.repositoryId, t.state), | |
56 | + authorIdx: index("issues_author_idx").on(t.authorId), | |
57 | + assigneeIdx: index("issues_assignee_idx").on(t.assigneeId), | |
58 | + }), | |
59 | +); | |
40 | 60 | |
41 | -export const issueComments = pgTable("issue_comments", { | |
42 | - id: text("id").primaryKey(), | |
43 | - issueId: text("issue_id") | |
44 | - .notNull() | |
45 | - .references(() => issues.id, { onDelete: "cascade" }), | |
46 | - authorId: text("author_id") | |
47 | - .notNull() | |
48 | - .references(() => users.id), | |
49 | - body: text("body").notNull(), | |
50 | - reactions: text("reactions"), // JSON | |
51 | - isEdited: boolean("is_edited").default(false), | |
52 | - editedAt: timestamp("edited_at"), | |
53 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
54 | - updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
55 | -}); | |
61 | +export const issueComments = pgTable( | |
62 | + "issue_comments", | |
63 | + { | |
64 | + id: text("id").primaryKey(), | |
65 | + issueId: text("issue_id") | |
66 | + .notNull() | |
67 | + .references(() => issues.id, { onDelete: "cascade" }), | |
68 | + authorId: text("author_id") | |
69 | + .notNull() | |
70 | + .references(() => users.id), | |
71 | + body: text("body").notNull(), | |
72 | + reactions: text("reactions"), // JSON | |
73 | + isEdited: boolean("is_edited").default(false), | |
74 | + editedAt: timestamp("edited_at"), | |
75 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
76 | + updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
77 | + }, | |
78 | + (t) => ({ | |
79 | + issueIdx: index("issue_comments_issue_idx").on(t.issueId), | |
80 | + }), | |
81 | +); | |
56 | 82 | |
57 | -export const labels = pgTable("labels", { | |
58 | - id: text("id").primaryKey(), | |
59 | - repositoryId: text("repository_id") | |
60 | - .notNull() | |
61 | - .references(() => repositories.id, { onDelete: "cascade" }), | |
62 | - name: text("name").notNull(), | |
63 | - color: text("color").notNull().default("#6b7280"), | |
64 | - description: text("description"), | |
65 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
66 | -}); | |
83 | +export const labels = pgTable( | |
84 | + "labels", | |
85 | + { | |
86 | + id: text("id").primaryKey(), | |
87 | + repositoryId: text("repository_id") | |
88 | + .notNull() | |
89 | + .references(() => repositories.id, { onDelete: "cascade" }), | |
90 | + name: text("name").notNull(), | |
91 | + color: text("color").notNull().default("#6b7280"), | |
92 | + description: text("description"), | |
93 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
94 | + }, | |
95 | + (t) => ({ | |
96 | + repoNameIdx: uniqueIndex("labels_repo_name_idx").on(t.repositoryId, t.name), | |
97 | + }), | |
98 | +); | |
67 | 99 | |
68 | 100 | export const issueLabels = pgTable("issue_labels", { |
69 | 101 | id: text("id").primaryKey(), |
70 | 102 | |
▼src/db/schema/packages.ts
+145-0
| @@ -0,0 +1,145 @@ | ||
1 | +/** | |
2 | + * Package Registry Schema | |
3 | + * Supports npm, Docker/OCI, Maven, PyPI, RubyGems, NuGet package types | |
4 | + */ | |
5 | + | |
6 | +import { relations } from "drizzle-orm"; | |
7 | +import { | |
8 | + boolean, | |
9 | + index, | |
10 | + integer, | |
11 | + jsonb, | |
12 | + pgTable, | |
13 | + text, | |
14 | + timestamp, | |
15 | + uniqueIndex, | |
16 | +} from "drizzle-orm/pg-core"; | |
17 | +import { organizations } from "./organizations"; | |
18 | +import { repositories } from "./repositories"; | |
19 | +import { users } from "./users"; | |
20 | + | |
21 | +// ============================================================================ | |
22 | +// PACKAGES | |
23 | +// ============================================================================ | |
24 | + | |
25 | +export const packages = pgTable( | |
26 | + "packages", | |
27 | + { | |
28 | + id: text("id").primaryKey(), | |
29 | + /** Organization-scoped or user-scoped */ | |
30 | + organizationId: text("organization_id").references(() => organizations.id, { | |
31 | + onDelete: "cascade", | |
32 | + }), | |
33 | + /** Link to a repository (optional — packages can be org-level) */ | |
34 | + repositoryId: text("repository_id").references(() => repositories.id, { | |
35 | + onDelete: "set null", | |
36 | + }), | |
37 | + /** Package type: npm, docker, maven, pypi, rubygems, nuget, generic */ | |
38 | + type: text("type").notNull(), | |
39 | + /** Scoped package name e.g. "@scope/name" or "my-image" */ | |
40 | + name: text("name").notNull(), | |
41 | + /** Human-readable description */ | |
42 | + description: text("description"), | |
43 | + /** Public or private */ | |
44 | + visibility: text("visibility").default("private").notNull(), // public, private | |
45 | + /** Total downloads across all versions */ | |
46 | + downloadCount: integer("download_count").default(0).notNull(), | |
47 | + /** Who published the first version */ | |
48 | + createdById: text("created_by_id").references(() => users.id), | |
49 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
50 | + updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
51 | + }, | |
52 | + (table) => ({ | |
53 | + uniqueName: uniqueIndex("pkg_org_type_name_idx").on( | |
54 | + table.organizationId, | |
55 | + table.type, | |
56 | + table.name, | |
57 | + ), | |
58 | + typeIdx: index("pkg_type_idx").on(table.type), | |
59 | + }), | |
60 | +); | |
61 | + | |
62 | +// ============================================================================ | |
63 | +// PACKAGE VERSIONS | |
64 | +// ============================================================================ | |
65 | + | |
66 | +export const packageVersions = pgTable( | |
67 | + "package_versions", | |
68 | + { | |
69 | + id: text("id").primaryKey(), | |
70 | + packageId: text("package_id") | |
71 | + .notNull() | |
72 | + .references(() => packages.id, { onDelete: "cascade" }), | |
73 | + /** Semantic version string */ | |
74 | + version: text("version").notNull(), | |
75 | + /** Content digest / hash (sha256) */ | |
76 | + digest: text("digest"), | |
77 | + /** Size in bytes */ | |
78 | + sizeBytes: integer("size_bytes"), | |
79 | + /** Storage path/key in the storage adapter */ | |
80 | + storagePath: text("storage_path").notNull(), | |
81 | + /** Package-type-specific metadata (e.g. npm package.json fields) */ | |
82 | + metadata: jsonb("metadata"), | |
83 | + /** Tag labels: latest, next, beta, etc. */ | |
84 | + tags: jsonb("tags").$type<string[]>().default([]), | |
85 | + /** Associated git tag or commit SHA */ | |
86 | + gitRef: text("git_ref"), | |
87 | + /** Who published this version */ | |
88 | + publishedById: text("published_by_id").references(() => users.id), | |
89 | + /** Download count for this specific version */ | |
90 | + downloadCount: integer("download_count").default(0).notNull(), | |
91 | + /** Whether this version is yanked/deprecated */ | |
92 | + yanked: boolean("yanked").default(false), | |
93 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
94 | + }, | |
95 | + (table) => ({ | |
96 | + uniqueVersion: uniqueIndex("pkg_version_idx").on( | |
97 | + table.packageId, | |
98 | + table.version, | |
99 | + ), | |
100 | + digestIdx: index("pkg_digest_idx").on(table.digest), | |
101 | + }), | |
102 | +); | |
103 | + | |
104 | +// ============================================================================ | |
105 | +// RELATIONS | |
106 | +// ============================================================================ | |
107 | + | |
108 | +export const packagesRelations = relations(packages, ({ one, many }) => ({ | |
109 | + organization: one(organizations, { | |
110 | + fields: [packages.organizationId], | |
111 | + references: [organizations.id], | |
112 | + }), | |
113 | + repository: one(repositories, { | |
114 | + fields: [packages.repositoryId], | |
115 | + references: [repositories.id], | |
116 | + }), | |
117 | + createdBy: one(users, { | |
118 | + fields: [packages.createdById], | |
119 | + references: [users.id], | |
120 | + }), | |
121 | + versions: many(packageVersions), | |
122 | +})); | |
123 | + | |
124 | +export const packageVersionsRelations = relations( | |
125 | + packageVersions, | |
126 | + ({ one }) => ({ | |
127 | + package: one(packages, { | |
128 | + fields: [packageVersions.packageId], | |
129 | + references: [packages.id], | |
130 | + }), | |
131 | + publishedBy: one(users, { | |
132 | + fields: [packageVersions.publishedById], | |
133 | + references: [users.id], | |
134 | + }), | |
135 | + }), | |
136 | +); | |
137 | + | |
138 | +// ============================================================================ | |
139 | +// TYPES | |
140 | +// ============================================================================ | |
141 | + | |
142 | +export type Package = typeof packages.$inferSelect; | |
143 | +export type NewPackage = typeof packages.$inferInsert; | |
144 | +export type PackageVersion = typeof packageVersions.$inferSelect; | |
145 | +export type NewPackageVersion = typeof packageVersions.$inferInsert; | |
0 | 146 | |
▼src/db/schema/pipeline-runners.ts
+1-1
| @@ -6,7 +6,7 @@ import { repositories } from "./repositories"; | ||
6 | 6 | export const pipelineRunners = pgTable("pipeline_runners", { |
7 | 7 | id: text("id").primaryKey(), |
8 | 8 | repositoryId: text("repository_id").references(() => repositories.id, { onDelete: "cascade" }), // If null, global runner (future) |
9 | - token: text("token").notNull(), // Authentication token (hashed or raw? raw for MVP simplicity, highly secured in real) | |
9 | + token: text("token").notNull(), // Stored as a one-way hash | |
10 | 10 | name: text("name").notNull(), |
11 | 11 | os: text("os"), |
12 | 12 | arch: text("arch"), |
13 | 13 | |
▼src/db/schema/pr-states.ts
+1-0
| @@ -31,6 +31,7 @@ export const prStateDefinitions = pgTable("pr_state_definitions", { | ||
31 | 31 | isDefault: boolean("is_default").default(false), // Default state for new PRs |
32 | 32 | isFinal: boolean("is_final").default(false), // States like "merged" or "closed" |
33 | 33 | allowMerge: boolean("allow_merge").default(false), // Can merge from this state |
34 | + requireCodeOwner: boolean("require_code_owner").default(false), // Must satisfy CODEOWNERS before entering this state | |
34 | 35 | |
35 | 36 | createdAt: timestamp("created_at").notNull().defaultNow(), |
36 | 37 | updatedAt: timestamp("updated_at").notNull().defaultNow(), |
37 | 38 | |
▼src/db/schema/pull-requests.ts
+176-132
| @@ -4,129 +4,166 @@ | ||
4 | 4 | */ |
5 | 5 | |
6 | 6 | import { relations } from "drizzle-orm"; |
7 | -import { boolean, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; | |
8 | -import { labels, milestones } from "./issues"; | |
7 | +import { | |
8 | + boolean, | |
9 | + index, | |
10 | + integer, | |
11 | + pgTable, | |
12 | + text, | |
13 | + timestamp, | |
14 | + uniqueIndex, | |
15 | +} from "drizzle-orm/pg-core"; | |
16 | +import { labels } from "./issues"; | |
17 | +import { prStateDefinitions } from "./pr-states"; | |
9 | 18 | import { repositories } from "./repositories"; |
10 | 19 | import { users } from "./users"; |
11 | -import { prStateDefinitions } from "./pr-states"; | |
12 | 20 | |
13 | -export const pullRequests = pgTable("pull_requests", { | |
14 | - id: text("id").primaryKey(), | |
15 | - repositoryId: text("repository_id") | |
16 | - .notNull() | |
17 | - .references(() => repositories.id, { onDelete: "cascade" }), | |
18 | - number: integer("number").notNull(), | |
19 | - title: text("title").notNull(), | |
20 | - body: text("body"), | |
21 | - state: text("state").notNull().default("open"), // open, closed, merged | |
22 | - authorId: text("author_id") | |
23 | - .notNull() | |
24 | - .references(() => users.id), | |
25 | - assigneeId: text("assignee_id").references(() => users.id), | |
26 | - milestoneId: text("milestone_id"), // Reference handled in relations | |
21 | +export const pullRequests = pgTable( | |
22 | + "pull_requests", | |
23 | + { | |
24 | + id: text("id").primaryKey(), | |
25 | + repositoryId: text("repository_id") | |
26 | + .notNull() | |
27 | + .references(() => repositories.id, { onDelete: "cascade" }), | |
28 | + number: integer("number").notNull(), | |
29 | + title: text("title").notNull(), | |
30 | + body: text("body"), | |
31 | + state: text("state").notNull().default("open"), // open, closed, merged | |
32 | + authorId: text("author_id") | |
33 | + .notNull() | |
34 | + .references(() => users.id), | |
35 | + assigneeId: text("assignee_id").references(() => users.id), | |
36 | + milestoneId: text("milestone_id"), // Reference handled in relations | |
27 | 37 | |
28 | - // Source and target | |
29 | - headBranch: text("head_branch").notNull(), | |
30 | - headSha: text("head_sha").notNull(), | |
31 | - headRepositoryId: text("head_repository_id").references( | |
32 | - () => repositories.id | |
33 | - ), | |
34 | - baseBranch: text("base_branch").notNull(), | |
35 | - baseSha: text("base_sha").notNull(), | |
38 | + // Source and target | |
39 | + headBranch: text("head_branch").notNull(), | |
40 | + headSha: text("head_sha").notNull(), | |
41 | + headRepositoryId: text("head_repository_id").references( | |
42 | + () => repositories.id, | |
43 | + ), | |
44 | + baseBranch: text("base_branch").notNull(), | |
45 | + baseSha: text("base_sha").notNull(), | |
36 | 46 | |
37 | - // Merge info | |
38 | - isDraft: boolean("is_draft").default(false), | |
39 | - isMerged: boolean("is_merged").default(false), | |
40 | - mergedAt: timestamp("merged_at"), | |
41 | - mergedById: text("merged_by_id").references(() => users.id), | |
42 | - mergeCommitSha: text("merge_commit_sha"), | |
43 | - mergeSha: text("merge_sha"), | |
44 | - mergeMethod: text("merge_method"), // merge, squash, rebase | |
47 | + // Merge info | |
48 | + isDraft: boolean("is_draft").default(false), | |
49 | + isMerged: boolean("is_merged").default(false), | |
50 | + mergedAt: timestamp("merged_at"), | |
51 | + mergedById: text("merged_by_id").references(() => users.id), | |
52 | + mergeCommitSha: text("merge_commit_sha"), | |
53 | + mergeSha: text("merge_sha"), | |
54 | + mergeMethod: text("merge_method"), // merge, squash, rebase | |
45 | 55 | |
46 | - // Stats | |
47 | - additions: integer("additions").default(0), | |
48 | - deletions: integer("deletions").default(0), | |
49 | - changedFiles: integer("changed_files").default(0), | |
50 | - commentCount: integer("comment_count").default(0), | |
51 | - reviewCount: integer("review_count").default(0), | |
56 | + // Stats | |
57 | + additions: integer("additions").default(0), | |
58 | + deletions: integer("deletions").default(0), | |
59 | + changedFiles: integer("changed_files").default(0), | |
60 | + commentCount: integer("comment_count").default(0), | |
61 | + reviewCount: integer("review_count").default(0), | |
52 | 62 | |
53 | - // Status | |
54 | - mergeable: boolean("mergeable"), | |
55 | - mergeableState: text("mergeable_state"), // clean, dirty, blocked, unknown | |
56 | - rebaseable: boolean("rebaseable"), | |
63 | + // Status | |
64 | + mergeable: boolean("mergeable"), | |
65 | + mergeableState: text("mergeable_state"), // clean, dirty, blocked, unknown | |
66 | + rebaseable: boolean("rebaseable"), | |
57 | 67 | |
58 | - // Settings | |
59 | - maintainerCanModify: boolean("maintainer_can_modify").default(true), | |
60 | - allowAutoMerge: boolean("allow_auto_merge").default(false), | |
61 | - autoMergeMethod: text("auto_merge_method"), | |
62 | - autoMergeEnabledById: text("auto_merge_enabled_by_id").references(() => users.id), | |
63 | - autoMergeEnabledAt: timestamp("auto_merge_enabled_at"), | |
68 | + // Settings | |
69 | + maintainerCanModify: boolean("maintainer_can_modify").default(true), | |
70 | + allowAutoMerge: boolean("allow_auto_merge").default(false), | |
71 | + autoMergeMethod: text("auto_merge_method"), | |
72 | + autoMergeEnabledById: text("auto_merge_enabled_by_id").references( | |
73 | + () => users.id, | |
74 | + ), | |
75 | + autoMergeEnabledAt: timestamp("auto_merge_enabled_at"), | |
64 | 76 | |
65 | - // Custom workflow state | |
66 | - stateId: text("state_id").references(() => prStateDefinitions.id), | |
67 | - customStateChangedAt: timestamp("custom_state_changed_at"), | |
77 | + // Custom workflow state | |
78 | + stateId: text("state_id").references(() => prStateDefinitions.id), | |
79 | + customStateChangedAt: timestamp("custom_state_changed_at"), | |
68 | 80 | |
69 | - closedAt: timestamp("closed_at"), | |
70 | - closedById: text("closed_by_id").references(() => users.id), | |
71 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
72 | - updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
73 | -}); | |
81 | + closedAt: timestamp("closed_at"), | |
82 | + closedById: text("closed_by_id").references(() => users.id), | |
83 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
84 | + updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
85 | + }, | |
86 | + (t) => ({ | |
87 | + repoNumberIdx: uniqueIndex("prs_repo_number_idx").on( | |
88 | + t.repositoryId, | |
89 | + t.number, | |
90 | + ), | |
91 | + repoStateIdx: index("prs_repo_state_idx").on(t.repositoryId, t.state), | |
92 | + authorIdx: index("prs_author_idx").on(t.authorId), | |
93 | + }), | |
94 | +); | |
74 | 95 | |
75 | -export const pullRequestReviews = pgTable("pull_request_reviews", { | |
76 | - id: text("id").primaryKey(), | |
77 | - pullRequestId: text("pull_request_id") | |
78 | - .notNull() | |
79 | - .references(() => pullRequests.id, { onDelete: "cascade" }), | |
80 | - reviewerId: text("reviewer_id") | |
81 | - .notNull() | |
82 | - .references(() => users.id), | |
83 | - state: text("state").notNull(), // pending, approved, changes_requested, commented, dismissed | |
84 | - body: text("body"), | |
85 | - commitSha: text("commit_sha"), | |
86 | - submittedAt: timestamp("submitted_at"), | |
87 | - dismissedAt: timestamp("dismissed_at"), | |
88 | - dismissedById: text("dismissed_by_id").references(() => users.id), | |
89 | - dismissalReason: text("dismissal_reason"), | |
90 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
91 | - updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
92 | -}); | |
96 | +export const pullRequestReviews = pgTable( | |
97 | + "pull_request_reviews", | |
98 | + { | |
99 | + id: text("id").primaryKey(), | |
100 | + pullRequestId: text("pull_request_id") | |
101 | + .notNull() | |
102 | + .references(() => pullRequests.id, { onDelete: "cascade" }), | |
103 | + reviewerId: text("reviewer_id") | |
104 | + .notNull() | |
105 | + .references(() => users.id), | |
106 | + state: text("state").notNull(), // pending, approved, changes_requested, commented, dismissed | |
107 | + body: text("body"), | |
108 | + commitSha: text("commit_sha"), | |
109 | + submittedAt: timestamp("submitted_at"), | |
110 | + dismissedAt: timestamp("dismissed_at"), | |
111 | + dismissedById: text("dismissed_by_id").references(() => users.id), | |
112 | + dismissalReason: text("dismissal_reason"), | |
113 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
114 | + updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
115 | + }, | |
116 | + (t) => ({ | |
117 | + prIdx: index("pr_reviews_pr_idx").on(t.pullRequestId), | |
118 | + }), | |
119 | +); | |
93 | 120 | |
94 | -export const pullRequestComments = pgTable("pull_request_comments", { | |
95 | - id: text("id").primaryKey(), | |
96 | - pullRequestId: text("pull_request_id") | |
97 | - .notNull() | |
98 | - .references(() => pullRequests.id, { onDelete: "cascade" }), | |
99 | - reviewId: text("review_id").references(() => pullRequestReviews.id), | |
100 | - authorId: text("author_id") | |
101 | - .notNull() | |
102 | - .references(() => users.id), | |
103 | - body: text("body").notNull(), | |
104 | - path: text("path"), // File path for inline comments | |
105 | - line: integer("line"), // Line number | |
106 | - side: text("side"), // LEFT, RIGHT | |
107 | - startLine: integer("start_line"), | |
108 | - startSide: text("start_side"), | |
109 | - commitSha: text("commit_sha"), | |
110 | - originalCommitSha: text("original_commit_sha"), | |
111 | - originalLine: integer("original_line"), | |
112 | - inReplyToId: text("in_reply_to_id").references((): any => pullRequestComments.id), | |
113 | - reactions: text("reactions"), // JSON | |
114 | - isResolved: boolean("is_resolved").default(false), | |
115 | - resolvedById: text("resolved_by_id").references(() => users.id), | |
116 | - resolvedAt: timestamp("resolved_at"), | |
117 | - isEdited: boolean("is_edited").default(false), | |
118 | - editedAt: timestamp("edited_at"), | |
121 | +export const pullRequestComments = pgTable( | |
122 | + "pull_request_comments", | |
123 | + { | |
124 | + id: text("id").primaryKey(), | |
125 | + pullRequestId: text("pull_request_id") | |
126 | + .notNull() | |
127 | + .references(() => pullRequests.id, { onDelete: "cascade" }), | |
128 | + reviewId: text("review_id").references(() => pullRequestReviews.id), | |
129 | + authorId: text("author_id") | |
130 | + .notNull() | |
131 | + .references(() => users.id), | |
132 | + body: text("body").notNull(), | |
133 | + path: text("path"), // File path for inline comments | |
134 | + line: integer("line"), // Line number | |
135 | + side: text("side"), // LEFT, RIGHT | |
136 | + startLine: integer("start_line"), | |
137 | + startSide: text("start_side"), | |
138 | + commitSha: text("commit_sha"), | |
139 | + originalCommitSha: text("original_commit_sha"), | |
140 | + originalLine: integer("original_line"), | |
141 | + inReplyToId: text("in_reply_to_id").references( | |
142 | + (): any => pullRequestComments.id, | |
143 | + ), | |
144 | + reactions: text("reactions"), // JSON | |
145 | + isResolved: boolean("is_resolved").default(false), | |
146 | + resolvedById: text("resolved_by_id").references(() => users.id), | |
147 | + resolvedAt: timestamp("resolved_at"), | |
148 | + isEdited: boolean("is_edited").default(false), | |
149 | + editedAt: timestamp("edited_at"), | |
119 | 150 | |
120 | - // Suggested changes | |
121 | - suggestionContent: text("suggestion_content"), // The suggested code replacement | |
122 | - suggestionApplied: boolean("suggestion_applied").default(false), | |
123 | - suggestionAppliedById: text("suggestion_applied_by_id").references(() => users.id), | |
124 | - suggestionAppliedAt: timestamp("suggestion_applied_at"), | |
125 | - suggestionCommitSha: text("suggestion_commit_sha"), // Commit where suggestion was applied | |
151 | + // Suggested changes | |
152 | + suggestionContent: text("suggestion_content"), // The suggested code replacement | |
153 | + suggestionApplied: boolean("suggestion_applied").default(false), | |
154 | + suggestionAppliedById: text("suggestion_applied_by_id").references( | |
155 | + () => users.id, | |
156 | + ), | |
157 | + suggestionAppliedAt: timestamp("suggestion_applied_at"), | |
158 | + suggestionCommitSha: text("suggestion_commit_sha"), // Commit where suggestion was applied | |
126 | 159 | |
127 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
128 | - updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
129 | -}); | |
160 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
161 | + updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
162 | + }, | |
163 | + (t) => ({ | |
164 | + prIdx: index("pr_comments_pr_idx").on(t.pullRequestId), | |
165 | + }), | |
166 | +); | |
130 | 167 | |
131 | 168 | export const pullRequestLabels = pgTable("pull_request_labels", { |
132 | 169 | id: text("id").primaryKey(), |
| @@ -161,22 +198,29 @@ export const pullRequestReviewers = pgTable("pull_request_reviewers", { | ||
161 | 198 | requestedAt: timestamp("requested_at").notNull().defaultNow(), |
162 | 199 | }); |
163 | 200 | |
164 | -export const pullRequestChecks = pgTable("pull_request_checks", { | |
165 | - id: text("id").primaryKey(), | |
166 | - pullRequestId: text("pull_request_id") | |
167 | - .notNull() | |
168 | - .references(() => pullRequests.id, { onDelete: "cascade" }), | |
169 | - name: text("name").notNull(), | |
170 | - status: text("status").notNull(), // queued, in_progress, completed | |
171 | - conclusion: text("conclusion"), // success, failure, neutral, cancelled, timed_out, action_required | |
172 | - headSha: text("head_sha").notNull(), | |
173 | - externalId: text("external_id"), | |
174 | - detailsUrl: text("details_url"), | |
175 | - output: text("output"), // JSON { title, summary, text } | |
176 | - startedAt: timestamp("started_at"), | |
177 | - completedAt: timestamp("completed_at"), | |
178 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
179 | -}); | |
201 | +export const pullRequestChecks = pgTable( | |
202 | + "pull_request_checks", | |
203 | + { | |
204 | + id: text("id").primaryKey(), | |
205 | + pullRequestId: text("pull_request_id") | |
206 | + .notNull() | |
207 | + .references(() => pullRequests.id, { onDelete: "cascade" }), | |
208 | + name: text("name").notNull(), | |
209 | + status: text("status").notNull(), // queued, in_progress, completed | |
210 | + conclusion: text("conclusion"), // success, failure, neutral, cancelled, timed_out, action_required | |
211 | + headSha: text("head_sha").notNull(), | |
212 | + externalId: text("external_id"), | |
213 | + detailsUrl: text("details_url"), | |
214 | + output: text("output"), // JSON { title, summary, text } | |
215 | + startedAt: timestamp("started_at"), | |
216 | + completedAt: timestamp("completed_at"), | |
217 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
218 | + }, | |
219 | + (t) => ({ | |
220 | + prIdx: index("pr_checks_pr_idx").on(t.pullRequestId), | |
221 | + headShaIdx: index("pr_checks_head_sha_idx").on(t.headSha), | |
222 | + }), | |
223 | +); | |
180 | 224 | |
181 | 225 | // Relations |
182 | 226 | export const pullRequestsRelations = relations( |
| @@ -216,7 +260,7 @@ export const pullRequestsRelations = relations( | ||
216 | 260 | fields: [pullRequests.stateId], |
217 | 261 | references: [prStateDefinitions.id], |
218 | 262 | }), |
219 | - }) | |
263 | + }), | |
220 | 264 | ); |
221 | 265 | |
222 | 266 | export const pullRequestReviewsRelations = relations( |
| @@ -231,7 +275,7 @@ export const pullRequestReviewsRelations = relations( | ||
231 | 275 | references: [users.id], |
232 | 276 | }), |
233 | 277 | comments: many(pullRequestComments), |
234 | - }) | |
278 | + }), | |
235 | 279 | ); |
236 | 280 | |
237 | 281 | export const pullRequestCommentsRelations = relations( |
| @@ -249,7 +293,7 @@ export const pullRequestCommentsRelations = relations( | ||
249 | 293 | fields: [pullRequestComments.authorId], |
250 | 294 | references: [users.id], |
251 | 295 | }), |
252 | - }) | |
296 | + }), | |
253 | 297 | ); |
254 | 298 | |
255 | 299 | export const pullRequestLabelsRelations = relations( |
| @@ -263,7 +307,7 @@ export const pullRequestLabelsRelations = relations( | ||
263 | 307 | fields: [pullRequestLabels.labelId], |
264 | 308 | references: [labels.id], |
265 | 309 | }), |
266 | - }) | |
310 | + }), | |
267 | 311 | ); |
268 | 312 | |
269 | 313 | export const pullRequestAssigneesRelations = relations( |
| @@ -277,7 +321,7 @@ export const pullRequestAssigneesRelations = relations( | ||
277 | 321 | fields: [pullRequestAssignees.userId], |
278 | 322 | references: [users.id], |
279 | 323 | }), |
280 | - }) | |
324 | + }), | |
281 | 325 | ); |
282 | 326 | |
283 | 327 | export const pullRequestReviewersRelations = relations( |
| @@ -291,7 +335,7 @@ export const pullRequestReviewersRelations = relations( | ||
291 | 335 | fields: [pullRequestReviewers.userId], |
292 | 336 | references: [users.id], |
293 | 337 | }), |
294 | - }) | |
338 | + }), | |
295 | 339 | ); |
296 | 340 | |
297 | 341 | export const pullRequestChecksRelations = relations( |
| @@ -301,7 +345,7 @@ export const pullRequestChecksRelations = relations( | ||
301 | 345 | fields: [pullRequestChecks.pullRequestId], |
302 | 346 | references: [pullRequests.id], |
303 | 347 | }), |
304 | - }) | |
348 | + }), | |
305 | 349 | ); |
306 | 350 | |
307 | 351 | // Types |
308 | 352 | |
▼src/db/schema/repositories.ts
+224-151
| @@ -4,102 +4,146 @@ | ||
4 | 4 | */ |
5 | 5 | |
6 | 6 | import { relations } from "drizzle-orm"; |
7 | -import { boolean, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; | |
7 | +import { | |
8 | + boolean, | |
9 | + index, | |
10 | + integer, | |
11 | + pgTable, | |
12 | + text, | |
13 | + timestamp, | |
14 | + uniqueIndex, | |
15 | +} from "drizzle-orm/pg-core"; | |
8 | 16 | import { users } from "./users"; |
9 | 17 | |
10 | -export const repositories = pgTable("repositories", { | |
11 | - id: text("id").primaryKey(), | |
12 | - name: text("name").notNull(), | |
13 | - slug: text("slug").notNull(), | |
14 | - description: text("description"), | |
15 | - ownerId: text("owner_id") | |
16 | - .notNull() | |
17 | - .references(() => users.id, { onDelete: "cascade" }), | |
18 | - ownerType: text("owner_type").notNull().default("user"), // user, organization | |
19 | - visibility: text("visibility").notNull().default("public"), // public, private, internal | |
20 | - defaultBranch: text("default_branch").notNull().default("main"), | |
21 | - diskPath: text("disk_path").notNull(), | |
22 | - sshCloneUrl: text("ssh_clone_url"), | |
23 | - httpCloneUrl: text("http_clone_url"), | |
24 | - website: text("website"), | |
25 | - starCount: integer("star_count").default(0).notNull(), | |
26 | - forkCount: integer("fork_count").default(0).notNull(), | |
27 | - watchCount: integer("watch_count").default(0).notNull(), | |
28 | - openIssueCount: integer("open_issue_count").default(0).notNull(), | |
29 | - openPrCount: integer("open_pr_count").default(0).notNull(), | |
30 | - size: integer("size").default(0).notNull(), // Size in KB | |
31 | - isFork: boolean("is_fork").default(false), | |
32 | - forkedFromId: text("forked_from_id").references((): any => repositories.id), | |
33 | - isArchived: boolean("is_archived").default(false), | |
34 | - isTemplate: boolean("is_template").default(false), | |
35 | - isMirror: boolean("is_mirror").default(false), | |
36 | - mirrorUrl: text("mirror_url"), | |
37 | - lastMirrorSyncAt: timestamp("last_mirror_sync_at"), | |
38 | - mirrorSyncStatus: text("mirror_sync_status"), // pending, syncing, success, failed | |
39 | - hasIssues: boolean("has_issues").default(true), | |
40 | - hasWiki: boolean("has_wiki").default(true), | |
41 | - hasActions: boolean("has_actions").default(true), | |
42 | - allowForking: boolean("allow_forking").default(true), | |
43 | - licenseType: text("license_type"), | |
44 | - topics: text("topics"), // JSON array | |
45 | - language: text("language"), // Primary language | |
46 | - languages: text("languages"), // JSON object { lang: percentage } | |
47 | - lastActivityAt: timestamp("last_activity_at"), | |
48 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
49 | - updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
50 | -}); | |
18 | +export const repositories = pgTable( | |
19 | + "repositories", | |
20 | + { | |
21 | + id: text("id").primaryKey(), | |
22 | + name: text("name").notNull(), | |
23 | + slug: text("slug").notNull(), | |
24 | + description: text("description"), | |
25 | + ownerId: text("owner_id") | |
26 | + .notNull() | |
27 | + .references(() => users.id, { onDelete: "cascade" }), | |
28 | + ownerType: text("owner_type").notNull().default("user"), // user, organization | |
29 | + visibility: text("visibility").notNull().default("public"), // public, private, internal | |
30 | + defaultBranch: text("default_branch").notNull().default("main"), | |
31 | + diskPath: text("disk_path").notNull(), | |
32 | + sshCloneUrl: text("ssh_clone_url"), | |
33 | + httpCloneUrl: text("http_clone_url"), | |
34 | + website: text("website"), | |
35 | + starCount: integer("star_count").default(0).notNull(), | |
36 | + forkCount: integer("fork_count").default(0).notNull(), | |
37 | + watchCount: integer("watch_count").default(0).notNull(), | |
38 | + openIssueCount: integer("open_issue_count").default(0).notNull(), | |
39 | + openPrCount: integer("open_pr_count").default(0).notNull(), | |
40 | + size: integer("size").default(0).notNull(), // Size in KB | |
41 | + isFork: boolean("is_fork").default(false), | |
42 | + forkedFromId: text("forked_from_id").references((): any => repositories.id), | |
43 | + isArchived: boolean("is_archived").default(false), | |
44 | + isTemplate: boolean("is_template").default(false), | |
45 | + isMirror: boolean("is_mirror").default(false), | |
46 | + mirrorUrl: text("mirror_url"), | |
47 | + lastMirrorSyncAt: timestamp("last_mirror_sync_at"), | |
48 | + mirrorSyncStatus: text("mirror_sync_status"), // pending, syncing, success, failed | |
49 | + hasIssues: boolean("has_issues").default(true), | |
50 | + hasWiki: boolean("has_wiki").default(true), | |
51 | + hasActions: boolean("has_actions").default(true), | |
52 | + allowForking: boolean("allow_forking").default(true), | |
53 | + licenseType: text("license_type"), | |
54 | + topics: text("topics"), // JSON array | |
55 | + language: text("language"), // Primary language | |
56 | + languages: text("languages"), // JSON object { lang: percentage } | |
57 | + lastActivityAt: timestamp("last_activity_at"), | |
58 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
59 | + updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
60 | + }, | |
61 | + (t) => ({ | |
62 | + ownerNameIdx: uniqueIndex("repositories_owner_name_idx").on( | |
63 | + t.ownerId, | |
64 | + t.name, | |
65 | + ), | |
66 | + ownerSlugIdx: uniqueIndex("repositories_owner_slug_idx").on( | |
67 | + t.ownerId, | |
68 | + t.slug, | |
69 | + ), | |
70 | + ownerIdx: index("repositories_owner_idx").on(t.ownerId), | |
71 | + }), | |
72 | +); | |
51 | 73 | |
52 | -export const branches = pgTable("branches", { | |
53 | - id: text("id").primaryKey(), | |
54 | - repositoryId: text("repository_id") | |
55 | - .notNull() | |
56 | - .references(() => repositories.id, { onDelete: "cascade" }), | |
57 | - name: text("name").notNull(), | |
58 | - commitSha: text("commit_sha").notNull(), | |
59 | - isProtected: boolean("is_protected").default(false), | |
60 | - isDefault: boolean("is_default").default(false), | |
61 | - protectionRules: text("protection_rules"), // JSON | |
62 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
63 | - updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
64 | -}); | |
74 | +export const branches = pgTable( | |
75 | + "branches", | |
76 | + { | |
77 | + id: text("id").primaryKey(), | |
78 | + repositoryId: text("repository_id") | |
79 | + .notNull() | |
80 | + .references(() => repositories.id, { onDelete: "cascade" }), | |
81 | + name: text("name").notNull(), | |
82 | + commitSha: text("commit_sha").notNull(), | |
83 | + isProtected: boolean("is_protected").default(false), | |
84 | + isDefault: boolean("is_default").default(false), | |
85 | + protectionRules: text("protection_rules"), // JSON | |
86 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
87 | + updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
88 | + }, | |
89 | + (t) => ({ | |
90 | + repoNameIdx: uniqueIndex("branches_repo_name_idx").on( | |
91 | + t.repositoryId, | |
92 | + t.name, | |
93 | + ), | |
94 | + }), | |
95 | +); | |
65 | 96 | |
66 | -export const commits = pgTable("commits", { | |
67 | - id: text("id").primaryKey(), | |
68 | - repositoryId: text("repository_id") | |
69 | - .notNull() | |
70 | - .references(() => repositories.id, { onDelete: "cascade" }), | |
71 | - sha: text("sha").notNull(), | |
72 | - message: text("message").notNull(), | |
73 | - authorName: text("author_name").notNull(), | |
74 | - authorEmail: text("author_email").notNull(), | |
75 | - authorDate: timestamp("author_date").notNull(), | |
76 | - committerName: text("committer_name").notNull(), | |
77 | - committerEmail: text("committer_email").notNull(), | |
78 | - committerDate: timestamp("committer_date").notNull(), | |
79 | - parentShas: text("parent_shas"), // JSON array | |
80 | - treesha: text("tree_sha"), | |
81 | - userId: text("user_id").references(() => users.id), | |
82 | - stats: text("stats"), // JSON { additions, deletions, files_changed } | |
83 | - signature: text("signature"), // GPG signature | |
84 | - isVerified: boolean("is_verified").default(false), | |
85 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
86 | -}); | |
97 | +export const commits = pgTable( | |
98 | + "commits", | |
99 | + { | |
100 | + id: text("id").primaryKey(), | |
101 | + repositoryId: text("repository_id") | |
102 | + .notNull() | |
103 | + .references(() => repositories.id, { onDelete: "cascade" }), | |
104 | + sha: text("sha").notNull(), | |
105 | + message: text("message").notNull(), | |
106 | + authorName: text("author_name").notNull(), | |
107 | + authorEmail: text("author_email").notNull(), | |
108 | + authorDate: timestamp("author_date").notNull(), | |
109 | + committerName: text("committer_name").notNull(), | |
110 | + committerEmail: text("committer_email").notNull(), | |
111 | + committerDate: timestamp("committer_date").notNull(), | |
112 | + parentShas: text("parent_shas"), // JSON array | |
113 | + treesha: text("tree_sha"), | |
114 | + userId: text("user_id").references(() => users.id), | |
115 | + stats: text("stats"), // JSON { additions, deletions, files_changed } | |
116 | + signature: text("signature"), // GPG signature | |
117 | + isVerified: boolean("is_verified").default(false), | |
118 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
119 | + }, | |
120 | + (t) => ({ | |
121 | + repoShaIdx: uniqueIndex("commits_repo_sha_idx").on(t.repositoryId, t.sha), | |
122 | + repoIdx: index("commits_repo_idx").on(t.repositoryId), | |
123 | + }), | |
124 | +); | |
87 | 125 | |
88 | -export const tags = pgTable("tags", { | |
89 | - id: text("id").primaryKey(), | |
90 | - repositoryId: text("repository_id") | |
91 | - .notNull() | |
92 | - .references(() => repositories.id, { onDelete: "cascade" }), | |
93 | - name: text("name").notNull(), | |
94 | - commitSha: text("commit_sha").notNull(), | |
95 | - message: text("message"), | |
96 | - taggerName: text("tagger_name"), | |
97 | - taggerEmail: text("tagger_email"), | |
98 | - taggedAt: timestamp("tagged_at"), | |
99 | - isRelease: boolean("is_release").default(false), | |
100 | - releaseId: text("release_id"), | |
101 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
102 | -}); | |
126 | +export const tags = pgTable( | |
127 | + "tags", | |
128 | + { | |
129 | + id: text("id").primaryKey(), | |
130 | + repositoryId: text("repository_id") | |
131 | + .notNull() | |
132 | + .references(() => repositories.id, { onDelete: "cascade" }), | |
133 | + name: text("name").notNull(), | |
134 | + commitSha: text("commit_sha").notNull(), | |
135 | + message: text("message"), | |
136 | + taggerName: text("tagger_name"), | |
137 | + taggerEmail: text("tagger_email"), | |
138 | + taggedAt: timestamp("tagged_at"), | |
139 | + isRelease: boolean("is_release").default(false), | |
140 | + releaseId: text("release_id"), | |
141 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
142 | + }, | |
143 | + (t) => ({ | |
144 | + repoNameIdx: uniqueIndex("tags_repo_name_idx").on(t.repositoryId, t.name), | |
145 | + }), | |
146 | +); | |
103 | 147 | |
104 | 148 | export const releases = pgTable("releases", { |
105 | 149 | id: text("id").primaryKey(), |
| @@ -119,45 +163,68 @@ export const releases = pgTable("releases", { | ||
119 | 163 | updatedAt: timestamp("updated_at").notNull().defaultNow(), |
120 | 164 | }); |
121 | 165 | |
122 | -export const repositoryStars = pgTable("repository_stars", { | |
123 | - id: text("id").primaryKey(), | |
124 | - repositoryId: text("repository_id") | |
125 | - .notNull() | |
126 | - .references(() => repositories.id, { onDelete: "cascade" }), | |
127 | - userId: text("user_id") | |
128 | - .notNull() | |
129 | - .references(() => users.id, { onDelete: "cascade" }), | |
130 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
131 | -}); | |
132 | - | |
133 | -export const repositoryWatchers = pgTable("repository_watchers", { | |
134 | - id: text("id").primaryKey(), | |
135 | - repositoryId: text("repository_id") | |
136 | - .notNull() | |
137 | - .references(() => repositories.id, { onDelete: "cascade" }), | |
138 | - userId: text("user_id") | |
139 | - .notNull() | |
140 | - .references(() => users.id, { onDelete: "cascade" }), | |
141 | - watchLevel: text("watch_level").notNull().default("watching"), // watching, releases_only, ignoring | |
142 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
143 | -}); | |
144 | - | |
145 | -export const repositoryCollaborators = pgTable("repository_collaborators", { | |
146 | - id: text("id").primaryKey(), | |
147 | - repositoryId: text("repository_id") | |
148 | - .notNull() | |
149 | - .references(() => repositories.id, { onDelete: "cascade" }), | |
150 | - userId: text("user_id") | |
151 | - .notNull() | |
152 | - .references(() => users.id, { onDelete: "cascade" }), | |
153 | - role: text("role").notNull().default("developer"), // owner, maintainer, developer, guest | |
154 | - addedById: text("added_by_id").references(() => users.id), | |
155 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
156 | -}); | |
157 | - | |
158 | - | |
166 | +export const repositoryStars = pgTable( | |
167 | + "repository_stars", | |
168 | + { | |
169 | + id: text("id").primaryKey(), | |
170 | + repositoryId: text("repository_id") | |
171 | + .notNull() | |
172 | + .references(() => repositories.id, { onDelete: "cascade" }), | |
173 | + userId: text("user_id") | |
174 | + .notNull() | |
175 | + .references(() => users.id, { onDelete: "cascade" }), | |
176 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
177 | + }, | |
178 | + (t) => ({ | |
179 | + starRepoUserIdx: uniqueIndex("stars_repo_user_idx").on( | |
180 | + t.repositoryId, | |
181 | + t.userId, | |
182 | + ), | |
183 | + }), | |
184 | +); | |
159 | 185 | |
186 | +export const repositoryWatchers = pgTable( | |
187 | + "repository_watchers", | |
188 | + { | |
189 | + id: text("id").primaryKey(), | |
190 | + repositoryId: text("repository_id") | |
191 | + .notNull() | |
192 | + .references(() => repositories.id, { onDelete: "cascade" }), | |
193 | + userId: text("user_id") | |
194 | + .notNull() | |
195 | + .references(() => users.id, { onDelete: "cascade" }), | |
196 | + watchLevel: text("watch_level").notNull().default("watching"), // watching, releases_only, ignoring | |
197 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
198 | + }, | |
199 | + (t) => ({ | |
200 | + watcherRepoUserIdx: uniqueIndex("watchers_repo_user_idx").on( | |
201 | + t.repositoryId, | |
202 | + t.userId, | |
203 | + ), | |
204 | + }), | |
205 | +); | |
160 | 206 | |
207 | +export const repositoryCollaborators = pgTable( | |
208 | + "repository_collaborators", | |
209 | + { | |
210 | + id: text("id").primaryKey(), | |
211 | + repositoryId: text("repository_id") | |
212 | + .notNull() | |
213 | + .references(() => repositories.id, { onDelete: "cascade" }), | |
214 | + userId: text("user_id") | |
215 | + .notNull() | |
216 | + .references(() => users.id, { onDelete: "cascade" }), | |
217 | + role: text("role").notNull().default("developer"), // owner, maintainer, developer, guest | |
218 | + addedById: text("added_by_id").references(() => users.id), | |
219 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
220 | + }, | |
221 | + (t) => ({ | |
222 | + collabRepoUserIdx: uniqueIndex("collaborators_repo_user_idx").on( | |
223 | + t.repositoryId, | |
224 | + t.userId, | |
225 | + ), | |
226 | + }), | |
227 | +); | |
161 | 228 | |
162 | 229 | // Relations |
163 | 230 | export const repositoriesRelations = relations( |
| @@ -180,7 +247,7 @@ export const repositoriesRelations = relations( | ||
180 | 247 | collaborators: many(repositoryCollaborators), |
181 | 248 | // deployKeys relation moved to deploy-keys.ts |
182 | 249 | // webhooks: many(webhooks), // Removing relation here to avoid circular dependency or import issues for now, or I need to import it. |
183 | - }) | |
250 | + }), | |
184 | 251 | ); |
185 | 252 | |
186 | 253 | export const branchesRelations = relations(branches, ({ one }) => ({ |
| @@ -201,31 +268,37 @@ export const commitsRelations = relations(commits, ({ one }) => ({ | ||
201 | 268 | }), |
202 | 269 | })); |
203 | 270 | |
204 | -export const repositoryCollaboratorsRelations = relations(repositoryCollaborators, ({ one }) => ({ | |
205 | - repository: one(repositories, { | |
206 | - fields: [repositoryCollaborators.repositoryId], | |
207 | - references: [repositories.id], | |
208 | - }), | |
209 | - user: one(users, { | |
210 | - fields: [repositoryCollaborators.userId], | |
211 | - references: [users.id], | |
212 | - }), | |
213 | - addedBy: one(users, { | |
214 | - fields: [repositoryCollaborators.addedById], | |
215 | - references: [users.id], | |
271 | +export const repositoryCollaboratorsRelations = relations( | |
272 | + repositoryCollaborators, | |
273 | + ({ one }) => ({ | |
274 | + repository: one(repositories, { | |
275 | + fields: [repositoryCollaborators.repositoryId], | |
276 | + references: [repositories.id], | |
277 | + }), | |
278 | + user: one(users, { | |
279 | + fields: [repositoryCollaborators.userId], | |
280 | + references: [users.id], | |
281 | + }), | |
282 | + addedBy: one(users, { | |
283 | + fields: [repositoryCollaborators.addedById], | |
284 | + references: [users.id], | |
285 | + }), | |
216 | 286 | }), |
217 | -})); | |
287 | +); | |
218 | 288 | |
219 | -export const repositoryStarsRelations = relations(repositoryStars, ({ one }) => ({ | |
220 | - repository: one(repositories, { | |
221 | - fields: [repositoryStars.repositoryId], | |
222 | - references: [repositories.id], | |
223 | - }), | |
224 | - user: one(users, { | |
225 | - fields: [repositoryStars.userId], | |
226 | - references: [users.id], | |
289 | +export const repositoryStarsRelations = relations( | |
290 | + repositoryStars, | |
291 | + ({ one }) => ({ | |
292 | + repository: one(repositories, { | |
293 | + fields: [repositoryStars.repositoryId], | |
294 | + references: [repositories.id], | |
295 | + }), | |
296 | + user: one(users, { | |
297 | + fields: [repositoryStars.userId], | |
298 | + references: [users.id], | |
299 | + }), | |
227 | 300 | }), |
228 | -})); | |
301 | +); | |
229 | 302 | |
230 | 303 | // Types |
231 | 304 | export type Repository = typeof repositories.$inferSelect; |
232 | 305 | |
▼src/db/schema/security-policies.ts
+22-0
| @@ -0,0 +1,22 @@ | ||
1 | +import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core"; | |
2 | +import { repositories } from "./repositories"; | |
3 | +import { users } from "./users"; | |
4 | + | |
5 | +export const securityPolicies = pgTable("security_policies", { | |
6 | + id: text("id").primaryKey(), | |
7 | + repositoryId: text("repository_id") | |
8 | + .notNull() | |
9 | + .references(() => repositories.id, { onDelete: "cascade" }), | |
10 | + enforcementMode: text("enforcement_mode").notNull().default("warn"), // warn | block | |
11 | + secretBlockedTypes: text("secret_blocked_types").notNull().default("[]"), // JSON string[] | |
12 | + secretMinSeverity: text("secret_min_severity").notNull().default("HIGH"), // CRITICAL | HIGH | MEDIUM | LOW | UNKNOWN | |
13 | + licenseAllowedTypes: text("license_allowed_types").notNull().default("[\"permissive\"]"), // JSON string[] | |
14 | + licenseBlockedLicenses: text("license_blocked_licenses").notNull().default("[]"), // JSON string[] | |
15 | + isEnabled: boolean("is_enabled").notNull().default(true), | |
16 | + updatedById: text("updated_by_id").references(() => users.id), | |
17 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
18 | + updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
19 | +}); | |
20 | + | |
21 | +export type SecurityPolicy = typeof securityPolicies.$inferSelect; | |
22 | + | |
0 | 23 | |
▼src/db/schema/teams.ts
+70-29
| @@ -4,63 +4,104 @@ | ||
4 | 4 | */ |
5 | 5 | |
6 | 6 | import { relations } from "drizzle-orm"; |
7 | -import { pgTable, text, timestamp, primaryKey } from "drizzle-orm/pg-core"; | |
7 | +import { pgTable, primaryKey, text, timestamp } from "drizzle-orm/pg-core"; | |
8 | 8 | import { organizations } from "./organizations"; |
9 | +import { repositories } from "./repositories"; | |
9 | 10 | import { users } from "./users"; |
10 | 11 | |
11 | 12 | /** |
12 | 13 | * Teams table - groups within organizations |
13 | 14 | */ |
14 | 15 | export const teams = pgTable("teams", { |
15 | - id: text("id").primaryKey(), | |
16 | - organizationId: text("organization_id") | |
17 | - .notNull() | |
18 | - .references(() => organizations.id, { onDelete: "cascade" }), | |
19 | - name: text("name").notNull(), | |
20 | - slug: text("slug").notNull(), | |
21 | - description: text("description"), | |
22 | - privacy: text("privacy").default("visible"), // visible, secret | |
23 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
24 | - updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
16 | + id: text("id").primaryKey(), | |
17 | + organizationId: text("organization_id") | |
18 | + .notNull() | |
19 | + .references(() => organizations.id, { onDelete: "cascade" }), | |
20 | + name: text("name").notNull(), | |
21 | + slug: text("slug").notNull(), | |
22 | + description: text("description"), | |
23 | + privacy: text("privacy").default("visible"), // visible, secret | |
24 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
25 | + updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
25 | 26 | }); |
26 | 27 | |
27 | 28 | /** |
28 | 29 | * Team members - users belonging to teams |
29 | 30 | */ |
30 | -export const teamMembers = pgTable("team_members", { | |
31 | +export const teamMembers = pgTable( | |
32 | + "team_members", | |
33 | + { | |
31 | 34 | teamId: text("team_id") |
32 | - .notNull() | |
33 | - .references(() => teams.id, { onDelete: "cascade" }), | |
35 | + .notNull() | |
36 | + .references(() => teams.id, { onDelete: "cascade" }), | |
34 | 37 | userId: text("user_id") |
35 | - .notNull() | |
36 | - .references(() => users.id, { onDelete: "cascade" }), | |
38 | + .notNull() | |
39 | + .references(() => users.id, { onDelete: "cascade" }), | |
37 | 40 | role: text("role").notNull().default("member"), // maintainer, member |
38 | 41 | createdAt: timestamp("created_at").notNull().defaultNow(), |
39 | -}, (t) => ({ | |
42 | + }, | |
43 | + (t) => ({ | |
40 | 44 | pk: primaryKey({ columns: [t.teamId, t.userId] }), |
41 | -})); | |
45 | + }), | |
46 | +); | |
47 | + | |
48 | +/** | |
49 | + * Team-Repository mappings — controls team access to repositories | |
50 | + */ | |
51 | +export const teamRepositories = pgTable( | |
52 | + "team_repositories", | |
53 | + { | |
54 | + teamId: text("team_id") | |
55 | + .notNull() | |
56 | + .references(() => teams.id, { onDelete: "cascade" }), | |
57 | + repositoryId: text("repository_id") | |
58 | + .notNull() | |
59 | + .references(() => repositories.id, { onDelete: "cascade" }), | |
60 | + permission: text("permission").notNull().default("read"), // admin, write, read | |
61 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
62 | + }, | |
63 | + (t) => ({ | |
64 | + pk: primaryKey({ columns: [t.teamId, t.repositoryId] }), | |
65 | + }), | |
66 | +); | |
42 | 67 | |
43 | 68 | // Relations |
44 | 69 | export const teamsRelations = relations(teams, ({ one, many }) => ({ |
45 | - organization: one(organizations, { | |
46 | - fields: [teams.organizationId], | |
47 | - references: [organizations.id], | |
48 | - }), | |
49 | - members: many(teamMembers), | |
70 | + organization: one(organizations, { | |
71 | + fields: [teams.organizationId], | |
72 | + references: [organizations.id], | |
73 | + }), | |
74 | + members: many(teamMembers), | |
75 | + repositories: many(teamRepositories), | |
50 | 76 | })); |
51 | 77 | |
52 | 78 | export const teamMembersRelations = relations(teamMembers, ({ one }) => ({ |
79 | + team: one(teams, { | |
80 | + fields: [teamMembers.teamId], | |
81 | + references: [teams.id], | |
82 | + }), | |
83 | + user: one(users, { | |
84 | + fields: [teamMembers.userId], | |
85 | + references: [users.id], | |
86 | + }), | |
87 | +})); | |
88 | + | |
89 | +export const teamRepositoriesRelations = relations( | |
90 | + teamRepositories, | |
91 | + ({ one }) => ({ | |
53 | 92 | team: one(teams, { |
54 | - fields: [teamMembers.teamId], | |
55 | - references: [teams.id], | |
93 | + fields: [teamRepositories.teamId], | |
94 | + references: [teams.id], | |
56 | 95 | }), |
57 | - user: one(users, { | |
58 | - fields: [teamMembers.userId], | |
59 | - references: [users.id], | |
96 | + repository: one(repositories, { | |
97 | + fields: [teamRepositories.repositoryId], | |
98 | + references: [repositories.id], | |
60 | 99 | }), |
61 | -})); | |
100 | + }), | |
101 | +); | |
62 | 102 | |
63 | 103 | // Types |
64 | 104 | export type Team = typeof teams.$inferSelect; |
65 | 105 | export type NewTeam = typeof teams.$inferInsert; |
66 | 106 | export type TeamMember = typeof teamMembers.$inferSelect; |
107 | +export type TeamRepository = typeof teamRepositories.$inferSelect; | |
67 | 108 | |
▼src/db/schema/users.ts
+70-46
| @@ -4,7 +4,14 @@ | ||
4 | 4 | */ |
5 | 5 | |
6 | 6 | import { relations } from "drizzle-orm"; |
7 | -import { boolean, pgTable, text, timestamp, primaryKey } from "drizzle-orm/pg-core"; | |
7 | +import { | |
8 | + boolean, | |
9 | + index, | |
10 | + pgTable, | |
11 | + primaryKey, | |
12 | + text, | |
13 | + timestamp, | |
14 | +} from "drizzle-orm/pg-core"; | |
8 | 15 | |
9 | 16 | export const users = pgTable("users", { |
10 | 17 | id: text("id").primaryKey(), |
| @@ -28,30 +35,43 @@ export const users = pgTable("users", { | ||
28 | 35 | aiConfig: text("ai_config"), // JSON: { provider, apiKeys: { openai, groq, bytez } } |
29 | 36 | }); |
30 | 37 | |
31 | -export const sessions = pgTable("sessions", { | |
32 | - id: text("id").primaryKey(), | |
33 | - userId: text("user_id") | |
34 | - .notNull() | |
35 | - .references(() => users.id, { onDelete: "cascade" }), | |
36 | - token: text("token").notNull().unique(), | |
37 | - userAgent: text("user_agent"), | |
38 | - ipAddress: text("ip_address"), | |
39 | - expiresAt: timestamp("expires_at").notNull(), | |
40 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
41 | -}); | |
38 | +export const sessions = pgTable( | |
39 | + "sessions", | |
40 | + { | |
41 | + id: text("id").primaryKey(), | |
42 | + userId: text("user_id") | |
43 | + .notNull() | |
44 | + .references(() => users.id, { onDelete: "cascade" }), | |
45 | + token: text("token").notNull().unique(), | |
46 | + userAgent: text("user_agent"), | |
47 | + ipAddress: text("ip_address"), | |
48 | + expiresAt: timestamp("expires_at").notNull(), | |
49 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
50 | + }, | |
51 | + (t) => ({ | |
52 | + userIdx: index("sessions_user_idx").on(t.userId), | |
53 | + expiresIdx: index("sessions_expires_idx").on(t.expiresAt), | |
54 | + }), | |
55 | +); | |
42 | 56 | |
43 | -export const sshKeys = pgTable("ssh_keys", { | |
44 | - id: text("id").primaryKey(), | |
45 | - userId: text("user_id") | |
46 | - .notNull() | |
47 | - .references(() => users.id, { onDelete: "cascade" }), | |
48 | - title: text("title").notNull(), | |
49 | - fingerprint: text("fingerprint").notNull().unique(), | |
50 | - publicKey: text("public_key").notNull(), | |
51 | - keyType: text("key_type").notNull(), // ssh-rsa, ssh-ed25519, etc. | |
52 | - lastUsedAt: timestamp("last_used_at"), | |
53 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
54 | -}); | |
57 | +export const sshKeys = pgTable( | |
58 | + "ssh_keys", | |
59 | + { | |
60 | + id: text("id").primaryKey(), | |
61 | + userId: text("user_id") | |
62 | + .notNull() | |
63 | + .references(() => users.id, { onDelete: "cascade" }), | |
64 | + title: text("title").notNull(), | |
65 | + fingerprint: text("fingerprint").notNull().unique(), | |
66 | + publicKey: text("public_key").notNull(), | |
67 | + keyType: text("key_type").notNull(), // ssh-rsa, ssh-ed25519, etc. | |
68 | + lastUsedAt: timestamp("last_used_at"), | |
69 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
70 | + }, | |
71 | + (t) => ({ | |
72 | + userIdx: index("ssh_keys_user_idx").on(t.userId), | |
73 | + }), | |
74 | +); | |
55 | 75 | |
56 | 76 | export const oauthAccounts = pgTable("oauth_accounts", { |
57 | 77 | id: text("id").primaryKey(), |
| @@ -66,18 +86,15 @@ export const oauthAccounts = pgTable("oauth_accounts", { | ||
66 | 86 | createdAt: timestamp("created_at").notNull().defaultNow(), |
67 | 87 | }); |
68 | 88 | |
69 | -export const emailVerificationTokens = pgTable( | |
70 | - "email_verification_tokens", | |
71 | - { | |
72 | - id: text("id").primaryKey(), | |
73 | - userId: text("user_id") | |
74 | - .notNull() | |
75 | - .references(() => users.id, { onDelete: "cascade" }), | |
76 | - token: text("token").notNull().unique(), | |
77 | - expiresAt: timestamp("expires_at").notNull(), | |
78 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
79 | - } | |
80 | -); | |
89 | +export const emailVerificationTokens = pgTable("email_verification_tokens", { | |
90 | + id: text("id").primaryKey(), | |
91 | + userId: text("user_id") | |
92 | + .notNull() | |
93 | + .references(() => users.id, { onDelete: "cascade" }), | |
94 | + token: text("token").notNull().unique(), | |
95 | + expiresAt: timestamp("expires_at").notNull(), | |
96 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
97 | +}); | |
81 | 98 | |
82 | 99 | export const passwordResetTokens = pgTable("password_reset_tokens", { |
83 | 100 | id: text("id").primaryKey(), |
| @@ -125,7 +142,7 @@ export const userFollowers = pgTable( | ||
125 | 142 | }, |
126 | 143 | (t) => ({ |
127 | 144 | pk: primaryKey({ columns: [t.followerId, t.followeeId] }), |
128 | - }) | |
145 | + }), | |
129 | 146 | ); |
130 | 147 | |
131 | 148 | // Relations |
| @@ -135,8 +152,12 @@ export const usersRelations = relations(users, ({ many }) => ({ | ||
135 | 152 | oauthAccounts: many(oauthAccounts), |
136 | 153 | gpgKeys: many(gpgKeys), |
137 | 154 | personalAccessTokens: many(personalAccessTokens), |
138 | - followers: many(userFollowers, { relationName: "user_followers_followee_id_users_id" }), | |
139 | - following: many(userFollowers, { relationName: "user_followers_follower_id_users_id" }), | |
155 | + followers: many(userFollowers, { | |
156 | + relationName: "user_followers_followee_id_users_id", | |
157 | + }), | |
158 | + following: many(userFollowers, { | |
159 | + relationName: "user_followers_follower_id_users_id", | |
160 | + }), | |
140 | 161 | })); |
141 | 162 | |
142 | 163 | export const sessionsRelations = relations(sessions, ({ one }) => ({ |
| @@ -167,7 +188,7 @@ export const emailVerificationTokensRelations = relations( | ||
167 | 188 | fields: [emailVerificationTokens.userId], |
168 | 189 | references: [users.id], |
169 | 190 | }), |
170 | - }) | |
191 | + }), | |
171 | 192 | ); |
172 | 193 | |
173 | 194 | export const passwordResetTokensRelations = relations( |
| @@ -177,7 +198,7 @@ export const passwordResetTokensRelations = relations( | ||
177 | 198 | fields: [passwordResetTokens.userId], |
178 | 199 | references: [users.id], |
179 | 200 | }), |
180 | - }) | |
201 | + }), | |
181 | 202 | ); |
182 | 203 | |
183 | 204 | export const gpgKeysRelations = relations(gpgKeys, ({ one }) => ({ |
| @@ -187,12 +208,15 @@ export const gpgKeysRelations = relations(gpgKeys, ({ one }) => ({ | ||
187 | 208 | }), |
188 | 209 | })); |
189 | 210 | |
190 | -export const personalAccessTokensRelations = relations(personalAccessTokens, ({ one }) => ({ | |
191 | - user: one(users, { | |
192 | - fields: [personalAccessTokens.userId], | |
193 | - references: [users.id], | |
211 | +export const personalAccessTokensRelations = relations( | |
212 | + personalAccessTokens, | |
213 | + ({ one }) => ({ | |
214 | + user: one(users, { | |
215 | + fields: [personalAccessTokens.userId], | |
216 | + references: [users.id], | |
217 | + }), | |
194 | 218 | }), |
195 | -})); | |
219 | +); | |
196 | 220 | |
197 | 221 | export const userFollowersRelations = relations(userFollowers, ({ one }) => ({ |
198 | 222 | follower: one(users, { |
199 | 223 | |
▼src/db/schema/webhooks.ts
+45-25
| @@ -4,15 +4,24 @@ | ||
4 | 4 | */ |
5 | 5 | |
6 | 6 | import { relations } from "drizzle-orm"; |
7 | -import { boolean, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; | |
7 | +import { | |
8 | + boolean, | |
9 | + index, | |
10 | + integer, | |
11 | + pgTable, | |
12 | + text, | |
13 | + timestamp, | |
14 | +} from "drizzle-orm/pg-core"; | |
8 | 15 | import { repositories } from "./repositories"; |
9 | 16 | import { users } from "./users"; |
10 | 17 | |
11 | -export const webhooks = pgTable("webhooks", { | |
18 | +export const webhooks = pgTable( | |
19 | + "webhooks", | |
20 | + { | |
12 | 21 | id: text("id").primaryKey(), |
13 | 22 | repositoryId: text("repository_id") |
14 | - .notNull() | |
15 | - .references(() => repositories.id, { onDelete: "cascade" }), | |
23 | + .notNull() | |
24 | + .references(() => repositories.id, { onDelete: "cascade" }), | |
16 | 25 | |
17 | 26 | // Provider and configuration |
18 | 27 | provider: text("provider").default("generic"), // teams, discord, slack, generic |
| @@ -32,13 +41,19 @@ export const webhooks = pgTable("webhooks", { | ||
32 | 41 | createdAt: timestamp("created_at").notNull().defaultNow(), |
33 | 42 | updatedAt: timestamp("updated_at").notNull().defaultNow(), |
34 | 43 | createdById: text("created_by_id").references(() => users.id), |
35 | -}); | |
44 | + }, | |
45 | + (t) => ({ | |
46 | + repoIdx: index("webhooks_repo_idx").on(t.repositoryId), | |
47 | + }), | |
48 | +); | |
36 | 49 | |
37 | -export const webhookDeliveries = pgTable("webhook_deliveries", { | |
50 | +export const webhookDeliveries = pgTable( | |
51 | + "webhook_deliveries", | |
52 | + { | |
38 | 53 | id: text("id").primaryKey(), |
39 | 54 | webhookId: text("webhook_id") |
40 | - .notNull() | |
41 | - .references(() => webhooks.id, { onDelete: "cascade" }), | |
55 | + .notNull() | |
56 | + .references(() => webhooks.id, { onDelete: "cascade" }), | |
42 | 57 | event: text("event").notNull(), |
43 | 58 | payload: text("payload").notNull(), // JSON |
44 | 59 | |
| @@ -52,26 +67,31 @@ export const webhookDeliveries = pgTable("webhook_deliveries", { | ||
52 | 67 | responseHeaders: text("response_headers"), // JSON |
53 | 68 | |
54 | 69 | createdAt: timestamp("created_at").notNull().defaultNow(), |
55 | -}); | |
70 | + }, | |
71 | + (t) => ({ | |
72 | + webhookIdx: index("webhook_deliveries_webhook_idx").on(t.webhookId), | |
73 | + createdAtIdx: index("webhook_deliveries_created_idx").on(t.createdAt), | |
74 | + }), | |
75 | +); | |
56 | 76 | |
57 | 77 | export const webhooksRelations = relations(webhooks, ({ one, many }) => ({ |
58 | - repository: one(repositories, { | |
59 | - fields: [webhooks.repositoryId], | |
60 | - references: [repositories.id], | |
61 | - }), | |
62 | - createdBy: one(users, { | |
63 | - fields: [webhooks.createdById], | |
64 | - references: [users.id], | |
65 | - }), | |
66 | - deliveries: many(webhookDeliveries), | |
78 | + repository: one(repositories, { | |
79 | + fields: [webhooks.repositoryId], | |
80 | + references: [repositories.id], | |
81 | + }), | |
82 | + createdBy: one(users, { | |
83 | + fields: [webhooks.createdById], | |
84 | + references: [users.id], | |
85 | + }), | |
86 | + deliveries: many(webhookDeliveries), | |
67 | 87 | })); |
68 | 88 | |
69 | 89 | export const webhookDeliveriesRelations = relations( |
70 | - webhookDeliveries, | |
71 | - ({ one }) => ({ | |
72 | - webhook: one(webhooks, { | |
73 | - fields: [webhookDeliveries.webhookId], | |
74 | - references: [webhooks.id], | |
75 | - }), | |
76 | - }) | |
90 | + webhookDeliveries, | |
91 | + ({ one }) => ({ | |
92 | + webhook: one(webhooks, { | |
93 | + fields: [webhookDeliveries.webhookId], | |
94 | + references: [webhooks.id], | |
95 | + }), | |
96 | + }), | |
77 | 97 | ); |
78 | 98 | |
▼src/db/schema/workflows.ts
+105-80
| @@ -4,97 +4,124 @@ | ||
4 | 4 | */ |
5 | 5 | |
6 | 6 | import { relations } from "drizzle-orm"; |
7 | -import { boolean, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; | |
7 | +import { | |
8 | + boolean, | |
9 | + index, | |
10 | + integer, | |
11 | + pgTable, | |
12 | + text, | |
13 | + timestamp, | |
14 | +} from "drizzle-orm/pg-core"; | |
8 | 15 | import { pullRequests } from "./pull-requests"; |
9 | 16 | import { repositories } from "./repositories"; |
10 | 17 | import { users } from "./users"; |
11 | 18 | |
12 | -export const workflows = pgTable("workflows", { | |
13 | - id: text("id").primaryKey(), | |
14 | - repositoryId: text("repository_id") | |
15 | - .notNull() | |
16 | - .references(() => repositories.id, { onDelete: "cascade" }), | |
17 | - name: text("name").notNull(), | |
18 | - path: text("path").notNull(), // .github/workflows/ci.yml | |
19 | - state: text("state").notNull().default("active"), // active, disabled, deleted | |
20 | - badgeUrl: text("badge_url"), | |
21 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
22 | - updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
23 | -}); | |
19 | +export const workflows = pgTable( | |
20 | + "workflows", | |
21 | + { | |
22 | + id: text("id").primaryKey(), | |
23 | + repositoryId: text("repository_id") | |
24 | + .notNull() | |
25 | + .references(() => repositories.id, { onDelete: "cascade" }), | |
26 | + name: text("name").notNull(), | |
27 | + path: text("path").notNull(), // .github/workflows/ci.yml | |
28 | + state: text("state").notNull().default("active"), // active, disabled, deleted | |
29 | + badgeUrl: text("badge_url"), | |
30 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
31 | + updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
32 | + }, | |
33 | + (t) => ({ | |
34 | + repoIdx: index("workflows_repo_idx").on(t.repositoryId), | |
35 | + }), | |
36 | +); | |
24 | 37 | |
25 | -export const workflowRuns = pgTable("workflow_runs", { | |
26 | - id: text("id").primaryKey(), | |
27 | - workflowId: text("workflow_id") | |
28 | - .notNull() | |
29 | - .references(() => workflows.id, { onDelete: "cascade" }), | |
30 | - repositoryId: text("repository_id") | |
31 | - .notNull() | |
32 | - .references(() => repositories.id, { onDelete: "cascade" }), | |
33 | - runNumber: integer("run_number").notNull(), | |
34 | - runAttempt: integer("run_attempt").notNull().default(1), | |
35 | - name: text("name").notNull(), | |
36 | - displayTitle: text("display_title"), | |
37 | - status: text("status").notNull().default("queued"), // queued, in_progress, completed | |
38 | - conclusion: text("conclusion"), // success, failure, cancelled, skipped, neutral, timed_out, action_required | |
39 | - event: text("event").notNull(), // push, pull_request, schedule, workflow_dispatch, etc. | |
38 | +export const workflowRuns = pgTable( | |
39 | + "workflow_runs", | |
40 | + { | |
41 | + id: text("id").primaryKey(), | |
42 | + workflowId: text("workflow_id") | |
43 | + .notNull() | |
44 | + .references(() => workflows.id, { onDelete: "cascade" }), | |
45 | + repositoryId: text("repository_id") | |
46 | + .notNull() | |
47 | + .references(() => repositories.id, { onDelete: "cascade" }), | |
48 | + runNumber: integer("run_number").notNull(), | |
49 | + runAttempt: integer("run_attempt").notNull().default(1), | |
50 | + name: text("name").notNull(), | |
51 | + displayTitle: text("display_title"), | |
52 | + status: text("status").notNull().default("queued"), // queued, in_progress, completed | |
53 | + conclusion: text("conclusion"), // success, failure, cancelled, skipped, neutral, timed_out, action_required | |
54 | + event: text("event").notNull(), // push, pull_request, schedule, workflow_dispatch, etc. | |
40 | 55 | |
41 | - // Git context | |
42 | - headBranch: text("head_branch"), | |
43 | - headSha: text("head_sha").notNull(), | |
44 | - baseBranch: text("base_branch"), | |
45 | - baseSha: text("base_sha"), | |
56 | + // Git context | |
57 | + headBranch: text("head_branch"), | |
58 | + headSha: text("head_sha").notNull(), | |
59 | + baseBranch: text("base_branch"), | |
60 | + baseSha: text("base_sha"), | |
46 | 61 | |
47 | - // References | |
48 | - pullRequestId: text("pull_request_id").references(() => pullRequests.id), | |
49 | - triggeredById: text("triggered_by_id").references(() => users.id), | |
62 | + // References | |
63 | + pullRequestId: text("pull_request_id").references(() => pullRequests.id), | |
64 | + triggeredById: text("triggered_by_id").references(() => users.id), | |
50 | 65 | |
51 | - // Timing | |
52 | - startedAt: timestamp("started_at"), | |
53 | - completedAt: timestamp("completed_at"), | |
66 | + // Timing | |
67 | + startedAt: timestamp("started_at"), | |
68 | + completedAt: timestamp("completed_at"), | |
54 | 69 | |
55 | - // Config from YAML | |
56 | - workflowConfig: text("workflow_config"), // JSON - parsed workflow YAML | |
70 | + // Config from YAML | |
71 | + workflowConfig: text("workflow_config"), // JSON - parsed workflow YAML | |
57 | 72 | |
58 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
59 | - updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
60 | -}); | |
73 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
74 | + updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
75 | + }, | |
76 | + (t) => ({ | |
77 | + workflowIdx: index("workflow_runs_workflow_idx").on(t.workflowId), | |
78 | + repoIdx: index("workflow_runs_repo_idx").on(t.repositoryId), | |
79 | + statusIdx: index("workflow_runs_status_idx").on(t.status), | |
80 | + }), | |
81 | +); | |
61 | 82 | |
62 | -export const workflowJobs = pgTable("workflow_jobs", { | |
63 | - id: text("id").primaryKey(), | |
64 | - runId: text("run_id") | |
65 | - .notNull() | |
66 | - .references(() => workflowRuns.id, { onDelete: "cascade" }), | |
67 | - name: text("name").notNull(), | |
68 | - status: text("status").notNull().default("queued"), // queued, in_progress, completed | |
69 | - conclusion: text("conclusion"), // success, failure, cancelled, skipped | |
83 | +export const workflowJobs = pgTable( | |
84 | + "workflow_jobs", | |
85 | + { | |
86 | + id: text("id").primaryKey(), | |
87 | + runId: text("run_id") | |
88 | + .notNull() | |
89 | + .references(() => workflowRuns.id, { onDelete: "cascade" }), | |
90 | + name: text("name").notNull(), | |
91 | + status: text("status").notNull().default("queued"), // queued, in_progress, completed | |
92 | + conclusion: text("conclusion"), // success, failure, cancelled, skipped | |
70 | 93 | |
71 | - // Runner info | |
72 | - runnerId: text("runner_id"), | |
73 | - runnerName: text("runner_name"), | |
74 | - runnerGroupId: text("runner_group_id"), | |
75 | - runnerGroupName: text("runner_group_name"), | |
94 | + // Runner info | |
95 | + runnerId: text("runner_id"), | |
96 | + runnerName: text("runner_name"), | |
97 | + runnerGroupId: text("runner_group_id"), | |
98 | + runnerGroupName: text("runner_group_name"), | |
76 | 99 | |
77 | - // Container info | |
78 | - containerId: text("container_id"), | |
79 | - containerImage: text("container_image"), | |
100 | + // Container info | |
101 | + containerId: text("container_id"), | |
102 | + containerImage: text("container_image"), | |
80 | 103 | |
81 | - // Dependencies | |
82 | - needs: text("needs"), // JSON array of job names this depends on | |
104 | + // Dependencies | |
105 | + needs: text("needs"), // JSON array of job names this depends on | |
83 | 106 | |
84 | - // Environment | |
85 | - environment: text("environment"), | |
86 | - environmentUrl: text("environment_url"), | |
107 | + // Environment | |
108 | + environment: text("environment"), | |
109 | + environmentUrl: text("environment_url"), | |
87 | 110 | |
88 | - // Matrix | |
89 | - matrix: text("matrix"), // JSON - matrix configuration for this job instance | |
111 | + // Matrix | |
112 | + matrix: text("matrix"), // JSON - matrix configuration for this job instance | |
90 | 113 | |
91 | - // Timing | |
92 | - startedAt: timestamp("started_at"), | |
93 | - completedAt: timestamp("completed_at"), | |
114 | + // Timing | |
115 | + startedAt: timestamp("started_at"), | |
116 | + completedAt: timestamp("completed_at"), | |
94 | 117 | |
95 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
96 | - updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
97 | -}); | |
118 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
119 | + updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
120 | + }, | |
121 | + (t) => ({ | |
122 | + runIdx: index("workflow_jobs_run_idx").on(t.runId), | |
123 | + }), | |
124 | +); | |
98 | 125 | |
99 | 126 | export const workflowSteps = pgTable("workflow_steps", { |
100 | 127 | id: text("id").primaryKey(), |
| @@ -116,9 +143,7 @@ export const workflowSteps = pgTable("workflow_steps", { | ||
116 | 143 | |
117 | 144 | // Conditionals |
118 | 145 | if: text("if"), |
119 | - continueOnError: boolean("continue_on_error").default( | |
120 | - false | |
121 | - ), | |
146 | + continueOnError: boolean("continue_on_error").default(false), | |
122 | 147 | timeoutMinutes: integer("timeout_minutes"), |
123 | 148 | |
124 | 149 | // Timing |
| @@ -162,7 +187,7 @@ export const workflowSecrets = pgTable("workflow_secrets", { | ||
162 | 187 | .notNull() |
163 | 188 | .references(() => repositories.id, { onDelete: "cascade" }), |
164 | 189 | name: text("name").notNull(), |
165 | - encryptedValue: text("encrypted_value").notNull(), | |
190 | + encryptedValue: text("encrypted_value").notNull(), // AES-GCM encrypted at rest | |
166 | 191 | environment: text("environment"), // null for repo-level, or environment name |
167 | 192 | createdById: text("created_by_id").references(() => users.id), |
168 | 193 | createdAt: timestamp("created_at").notNull().defaultNow(), |
| @@ -226,7 +251,7 @@ export const workflowRunsRelations = relations( | ||
226 | 251 | }), |
227 | 252 | jobs: many(workflowJobs), |
228 | 253 | artifacts: many(workflowArtifacts), |
229 | - }) | |
254 | + }), | |
230 | 255 | ); |
231 | 256 | |
232 | 257 | export const workflowJobsRelations = relations( |
| @@ -239,7 +264,7 @@ export const workflowJobsRelations = relations( | ||
239 | 264 | steps: many(workflowSteps), |
240 | 265 | logs: many(workflowLogs), |
241 | 266 | artifacts: many(workflowArtifacts), |
242 | - }) | |
267 | + }), | |
243 | 268 | ); |
244 | 269 | |
245 | 270 | export const workflowStepsRelations = relations( |
| @@ -250,7 +275,7 @@ export const workflowStepsRelations = relations( | ||
250 | 275 | references: [workflowJobs.id], |
251 | 276 | }), |
252 | 277 | logs: many(workflowLogs), |
253 | - }) | |
278 | + }), | |
254 | 279 | ); |
255 | 280 | |
256 | 281 | // Types |
257 | 282 | |
▼src/lib/activity.ts
+2-1
| @@ -1,4 +1,5 @@ | ||
1 | 1 | |
2 | +import { logger } from "@/lib/logger"; | |
2 | 3 | import { getDatabase, schema } from "@/db"; |
3 | 4 | import { generateId } from "@/lib/utils"; |
4 | 5 | import type { NodePgDatabase } from "drizzle-orm/node-postgres"; |
| @@ -38,7 +39,7 @@ export async function logActivity( | ||
38 | 39 | createdAt: new Date(), |
39 | 40 | }); |
40 | 41 | } catch (error) { |
41 | - console.error("Failed to log activity:", error); | |
42 | + logger.error("Failed to log activity:", error); | |
42 | 43 | // We don't throw here to avoid failing the main request |
43 | 44 | } |
44 | 45 | } |
45 | 46 | |
▼src/lib/ai-review.ts
+518-394
| @@ -3,361 +3,474 @@ | ||
3 | 3 | * LLM-powered code review with stack context and inline suggestions |
4 | 4 | */ |
5 | 5 | |
6 | -import { eq, and, desc } from "drizzle-orm"; | |
7 | -import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
8 | 6 | import { getDatabase, schema } from "@/db"; |
9 | 7 | import { logger } from "@/lib/logger"; |
10 | -import { generateId } from "./utils"; | |
11 | -import { getStackForPr } from "./stacks"; | |
12 | -import { acquireRepo, releaseRepo } from "./git-storage"; | |
8 | +import { desc, eq } from "drizzle-orm"; | |
9 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
13 | 10 | import { simpleGit } from "simple-git"; |
14 | -import OpenAI from "openai"; | |
11 | +import { acquireRepo, releaseRepo } from "./git-storage"; | |
12 | +import { getStackForPr } from "./stacks"; | |
13 | +import { generateId } from "./utils"; | |
15 | 14 | |
16 | -// Supported AI providers | |
17 | 15 | // Supported AI providers |
18 | 16 | export type AIProvider = |
19 | - | "openai" | |
20 | - | "anthropic" | |
21 | - | "groq" | |
22 | - | "bytez" | |
23 | - | "openrouter" | |
24 | - | "together" | |
25 | - | "google" | |
26 | - | "external_agent" | |
27 | - | "local"; | |
17 | + | "openai" | |
18 | + | "anthropic" | |
19 | + | "groq" | |
20 | + | "bytez" | |
21 | + | "openrouter" | |
22 | + | "together" | |
23 | + | "google" | |
24 | + | "external_agent" | |
25 | + | "local" | |
26 | + | "ollama"; | |
28 | 27 | export type AIModel = string; // Allow any string for model flexibility |
29 | 28 | |
29 | +// Cost per 1M tokens by provider (USD). Used for cost tracking. | |
30 | +const COST_PER_MILLION_TOKENS: Record< | |
31 | + string, | |
32 | + { input: number; output: number } | |
33 | +> = { | |
34 | + "openai:gpt-4-turbo": { input: 10, output: 30 }, | |
35 | + "openai:gpt-4o": { input: 5, output: 15 }, | |
36 | + "openai:gpt-4o-mini": { input: 0.15, output: 0.6 }, | |
37 | + "anthropic:claude-3-5-sonnet-latest": { input: 3, output: 15 }, | |
38 | + "anthropic:claude-3-haiku": { input: 0.25, output: 1.25 }, | |
39 | + "groq:llama-3.1-70b": { input: 0.59, output: 0.79 }, | |
40 | + "ollama:*": { input: 0, output: 0 }, // Local — free | |
41 | + "local:*": { input: 0, output: 0 }, | |
42 | +}; | |
43 | + | |
44 | +/** | |
45 | + * Estimate cost in USD for a review based on token usage. | |
46 | + */ | |
47 | +export function estimateReviewCost( | |
48 | + provider: string, | |
49 | + model: string, | |
50 | + promptTokens: number, | |
51 | + completionTokens: number, | |
52 | +): number { | |
53 | + const key = `${provider}:${model}`; | |
54 | + const wildcardKey = `${provider}:*`; | |
55 | + const pricing = | |
56 | + COST_PER_MILLION_TOKENS[key] || COST_PER_MILLION_TOKENS[wildcardKey]; | |
57 | + if (!pricing) return 0; | |
58 | + return ( | |
59 | + (promptTokens * pricing.input + completionTokens * pricing.output) / | |
60 | + 1_000_000 | |
61 | + ); | |
62 | +} | |
63 | + | |
64 | +// Rate limiting: max reviews per hour per repository | |
65 | +const REVIEW_RATE_LIMIT = parseInt( | |
66 | + process.env.AI_REVIEW_RATE_LIMIT || "20", | |
67 | + 10, | |
68 | +); | |
69 | +const reviewRateTracker = new Map< | |
70 | + string, | |
71 | + { count: number; windowStart: number } | |
72 | +>(); | |
73 | + | |
74 | +function checkReviewRateLimit(repositoryId: string): void { | |
75 | + const now = Date.now(); | |
76 | + const windowMs = 60 * 60 * 1000; // 1 hour | |
77 | + const tracker = reviewRateTracker.get(repositoryId); | |
78 | + if (!tracker || now - tracker.windowStart > windowMs) { | |
79 | + reviewRateTracker.set(repositoryId, { count: 1, windowStart: now }); | |
80 | + return; | |
81 | + } | |
82 | + if (tracker.count >= REVIEW_RATE_LIMIT) { | |
83 | + throw new Error( | |
84 | + `AI review rate limit exceeded: ${REVIEW_RATE_LIMIT} reviews/hour for this repository`, | |
85 | + ); | |
86 | + } | |
87 | + tracker.count++; | |
88 | +} | |
89 | + | |
30 | 90 | export interface AIReviewConfig { |
31 | - provider: AIProvider; | |
32 | - model: AIModel; | |
33 | - apiKey?: string; | |
34 | - baseUrl?: string; // For local models | |
35 | - includeStackContext?: boolean; | |
91 | + provider: AIProvider; | |
92 | + model: AIModel; | |
93 | + apiKey?: string; | |
94 | + baseUrl?: string; // For local models | |
95 | + includeStackContext?: boolean; | |
36 | 96 | } |
37 | 97 | |
38 | 98 | type ProviderCallResult = { |
39 | - content: string; | |
40 | - summary: string; | |
41 | - tokensUsed: number; | |
42 | - promptTokens: number; | |
43 | - completionTokens: number; | |
99 | + content: string; | |
100 | + summary: string; | |
101 | + tokensUsed: number; | |
102 | + promptTokens: number; | |
103 | + completionTokens: number; | |
44 | 104 | }; |
45 | 105 | |
46 | 106 | export interface ReviewSuggestion { |
47 | - path: string; | |
48 | - line?: number; | |
49 | - endLine?: number; | |
50 | - severity: "info" | "warning" | "error" | "critical"; | |
51 | - type: "bug" | "security" | "performance" | "style" | "documentation" | "suggestion"; | |
52 | - title: string; | |
53 | - message: string; | |
54 | - suggestedFix?: string; | |
55 | - explanation?: string; | |
107 | + path: string; | |
108 | + line?: number; | |
109 | + endLine?: number; | |
110 | + severity: "info" | "warning" | "error" | "critical"; | |
111 | + type: | |
112 | + | "bug" | |
113 | + | "security" | |
114 | + | "performance" | |
115 | + | "style" | |
116 | + | "documentation" | |
117 | + | "suggestion"; | |
118 | + title: string; | |
119 | + message: string; | |
120 | + suggestedFix?: string; | |
121 | + explanation?: string; | |
122 | + confidence?: number; // 0.0 to 1.0 — AI confidence in the finding | |
123 | + category?: string; // Grouping for UI: "Security", "Performance", "Best Practices", etc. | |
56 | 124 | } |
57 | 125 | |
58 | 126 | export interface AIReviewResult { |
59 | - reviewId: string; | |
60 | - summary: string; | |
61 | - overallSeverity: "info" | "warning" | "error" | "critical"; | |
62 | - suggestions: ReviewSuggestion[]; | |
63 | - tokensUsed: number; | |
127 | + reviewId: string; | |
128 | + summary: string; | |
129 | + overallSeverity: "info" | "warning" | "error" | "critical"; | |
130 | + suggestions: ReviewSuggestion[]; | |
131 | + tokensUsed: number; | |
64 | 132 | } |
65 | 133 | |
66 | 134 | /** |
67 | 135 | * Trigger an AI review for a pull request |
68 | 136 | */ |
69 | 137 | export async function triggerAIReview( |
70 | - pullRequestId: string, | |
71 | - triggeredById: string, | |
72 | - config: AIReviewConfig | |
138 | + pullRequestId: string, | |
139 | + triggeredById: string, | |
140 | + config: AIReviewConfig, | |
141 | + repositoryId?: string, | |
73 | 142 | ): Promise<typeof schema.aiReviews.$inferSelect> { |
74 | - const db = getDatabase() as NodePgDatabase<typeof schema>; | |
75 | - | |
76 | - // Create review record | |
77 | - const reviewId = generateId(); | |
78 | - const review = { | |
79 | - id: reviewId, | |
80 | - pullRequestId, | |
81 | - status: "pending", | |
82 | - model: config.model, | |
83 | - provider: config.provider, | |
84 | - includesStackContext: config.includeStackContext || false, | |
85 | - triggeredById, | |
86 | - createdAt: new Date(), | |
87 | - }; | |
88 | - | |
89 | - await db.insert(schema.aiReviews).values(review); | |
90 | - | |
91 | - // Start the review asynchronously | |
92 | - // In production, this would be queued to a background job | |
93 | - runAIReview(reviewId, pullRequestId, config).catch(console.error); | |
94 | - | |
95 | - return review as typeof schema.aiReviews.$inferSelect; | |
143 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
144 | + | |
145 | + // Rate limit check (per repository) | |
146 | + if (repositoryId) { | |
147 | + checkReviewRateLimit(repositoryId); | |
148 | + } | |
149 | + | |
150 | + // Create review record | |
151 | + const reviewId = generateId(); | |
152 | + const review = { | |
153 | + id: reviewId, | |
154 | + pullRequestId, | |
155 | + status: "pending", | |
156 | + model: config.model, | |
157 | + provider: config.provider, | |
158 | + includesStackContext: config.includeStackContext || false, | |
159 | + triggeredById, | |
160 | + createdAt: new Date(), | |
161 | + }; | |
162 | + | |
163 | + await db.insert(schema.aiReviews).values(review); | |
164 | + | |
165 | + // Start the review asynchronously | |
166 | + // In production, this would be queued to a background job | |
167 | + runAIReview(reviewId, pullRequestId, config).catch(console.error); | |
168 | + | |
169 | + return review as typeof schema.aiReviews.$inferSelect; | |
96 | 170 | } |
97 | 171 | |
98 | 172 | /** |
99 | 173 | * Run the AI review (called asynchronously) |
100 | 174 | */ |
101 | 175 | async function runAIReview( |
102 | - reviewId: string, | |
103 | - pullRequestId: string, | |
104 | - config: AIReviewConfig | |
176 | + reviewId: string, | |
177 | + pullRequestId: string, | |
178 | + config: AIReviewConfig, | |
105 | 179 | ): Promise<void> { |
106 | - const db = getDatabase() as NodePgDatabase<typeof schema>; | |
180 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
181 | + | |
182 | + try { | |
183 | + // Mark as running | |
184 | + await db | |
185 | + .update(schema.aiReviews) | |
186 | + .set({ status: "running", startedAt: new Date() }) | |
187 | + .where(eq(schema.aiReviews.id, reviewId)); | |
188 | + | |
189 | + // Get PR and diff | |
190 | + const pr = await db.query.pullRequests.findFirst({ | |
191 | + where: eq(schema.pullRequests.id, pullRequestId), | |
192 | + with: { | |
193 | + repository: { | |
194 | + with: { owner: true }, | |
195 | + }, | |
196 | + }, | |
197 | + }); | |
198 | + | |
199 | + if (!pr) { | |
200 | + throw new Error("PR not found"); | |
201 | + } | |
107 | 202 | |
203 | + // Fetch Diff | |
204 | + let diff = ""; | |
108 | 205 | try { |
109 | - // Mark as running | |
110 | - await db.update(schema.aiReviews) | |
111 | - .set({ status: "running", startedAt: new Date() }) | |
112 | - .where(eq(schema.aiReviews.id, reviewId)); | |
113 | - | |
114 | - // Get PR and diff | |
115 | - const pr = await db.query.pullRequests.findFirst({ | |
116 | - where: eq(schema.pullRequests.id, pullRequestId), | |
117 | - with: { | |
118 | - repository: { | |
119 | - with: { owner: true } | |
120 | - } | |
121 | - } | |
122 | - }); | |
206 | + const repoPath = await acquireRepo( | |
207 | + pr.repository.owner.username, | |
208 | + pr.repository.name, | |
209 | + ); | |
210 | + const git = simpleGit(repoPath); | |
211 | + await git.fetch(); | |
212 | + // Get diff between base and head | |
213 | + // Ideally we want the merge base to head | |
214 | + const mergeBase = await git.raw([ | |
215 | + "merge-base", | |
216 | + `origin/${pr.baseBranch}`, | |
217 | + `origin/${pr.headBranch}`, | |
218 | + ]); | |
219 | + diff = await git.diff([mergeBase.trim(), `origin/${pr.headBranch}`]); | |
220 | + await releaseRepo( | |
221 | + pr.repository.owner.username, | |
222 | + pr.repository.name, | |
223 | + false, | |
224 | + ); | |
225 | + } catch (e) { | |
226 | + logger.error("Failed to fetch diff for AI review", e); | |
227 | + throw new Error("Could not fetch diff from git"); | |
228 | + } | |
123 | 229 | |
124 | - if (!pr) { | |
125 | - throw new Error("PR not found"); | |
126 | - } | |
127 | - | |
128 | - // Fetch Diff | |
129 | - let diff = ""; | |
130 | - try { | |
131 | - const repoPath = await acquireRepo(pr.repository.owner.username, pr.repository.name); | |
132 | - const git = simpleGit(repoPath); | |
133 | - await git.fetch(); | |
134 | - // Get diff between base and head | |
135 | - // Ideally we want the merge base to head | |
136 | - const mergeBase = await git.raw(["merge-base", `origin/${pr.baseBranch}`, `origin/${pr.headBranch}`]); | |
137 | - diff = await git.diff([mergeBase.trim(), `origin/${pr.headBranch}`]); | |
138 | - await releaseRepo(pr.repository.owner.username, pr.repository.name, false); | |
139 | - } catch (e) { | |
140 | - logger.error("Failed to fetch diff for AI review", e); | |
141 | - throw new Error("Could not fetch diff from git"); | |
142 | - } | |
143 | - | |
144 | - if (!diff) { | |
145 | - diff = "No changes detected."; | |
146 | - } | |
147 | - | |
148 | - // Truncate diff if too large (poor man's token limit) | |
149 | - if (diff.length > 50000) { | |
150 | - diff = diff.substring(0, 50000) + "\n\n...[Diff truncated due to length]..."; | |
151 | - } | |
152 | - | |
153 | - // Get stack context if enabled | |
154 | - let stackContext = null; | |
155 | - if (config.includeStackContext) { | |
156 | - const stackInfo = await getStackForPr(pullRequestId); | |
157 | - if (stackInfo) { | |
158 | - stackContext = JSON.stringify({ | |
159 | - stackName: stackInfo.stack.name, | |
160 | - baseBranch: stackInfo.stack.baseBranch, | |
161 | - prsInStack: stackInfo.entries.map(e => ({ | |
162 | - number: e.pr.number, | |
163 | - title: e.pr.title, | |
164 | - state: e.pr.state, | |
165 | - })), | |
166 | - }); | |
167 | - } | |
168 | - } | |
169 | - | |
170 | - // Generate review prompt | |
171 | - const prompt = generateReviewPrompt(pr, stackContext, diff); | |
172 | - | |
173 | - let result: ProviderCallResult | null = null; | |
174 | - | |
175 | - // External agents may run asynchronously and callback later. | |
176 | - if (config.provider === "external_agent") { | |
177 | - const external = await dispatchExternalAgentReview({ | |
178 | - config, | |
179 | - prompt, | |
180 | - reviewId, | |
181 | - pullRequestId, | |
182 | - pr, | |
183 | - diff, | |
184 | - stackContext, | |
185 | - }); | |
186 | - | |
187 | - if (external.acceptedAsync) { | |
188 | - await db.update(schema.aiReviews) | |
189 | - .set({ | |
190 | - status: "running", | |
191 | - summary: "External agent accepted review request; awaiting callback", | |
192 | - stackContext, | |
193 | - completedAt: null, | |
194 | - }) | |
195 | - .where(eq(schema.aiReviews.id, reviewId)); | |
196 | - return; | |
197 | - } | |
198 | - | |
199 | - result = external.result; | |
200 | - } else { | |
201 | - // Call built-in AI provider synchronously | |
202 | - result = await callAIProvider(config, prompt); | |
203 | - } | |
204 | - | |
205 | - if (!result) { | |
206 | - throw new Error("AI provider returned no result"); | |
207 | - } | |
208 | - | |
209 | - // Parse and save suggestions | |
210 | - const suggestions = parseAIResponse(result.content); | |
211 | - | |
212 | - for (const suggestion of suggestions) { | |
213 | - await db.insert(schema.aiReviewSuggestions).values({ | |
214 | - id: generateId(), | |
215 | - aiReviewId: reviewId, | |
216 | - path: suggestion.path, | |
217 | - line: suggestion.line, | |
218 | - endLine: suggestion.endLine, | |
219 | - severity: suggestion.severity, | |
220 | - type: suggestion.type, | |
221 | - title: suggestion.title, | |
222 | - message: suggestion.message, | |
223 | - suggestedFix: suggestion.suggestedFix, | |
224 | - explanation: suggestion.explanation, | |
225 | - createdAt: new Date(), | |
226 | - }); | |
227 | - } | |
228 | - | |
229 | - // Determine overall severity | |
230 | - const severityOrder: Array<"info" | "warning" | "error" | "critical"> = ["info", "warning", "error", "critical"]; | |
231 | - let maxSeverity: "info" | "warning" | "error" | "critical" = "info"; | |
232 | - for (const s of suggestions) { | |
233 | - if (severityOrder.indexOf(s.severity) > severityOrder.indexOf(maxSeverity)) { | |
234 | - maxSeverity = s.severity; | |
235 | - } | |
236 | - } | |
237 | - | |
238 | - // Update review with results | |
239 | - await db.update(schema.aiReviews) | |
240 | - .set({ | |
241 | - status: "completed", | |
242 | - summary: result.summary, | |
243 | - overallSeverity: maxSeverity, | |
244 | - suggestionsCount: suggestions.length, | |
245 | - tokensUsed: result.tokensUsed, | |
246 | - promptTokens: result.promptTokens, | |
247 | - completionTokens: result.completionTokens, | |
248 | - stackContext, | |
249 | - completedAt: new Date(), | |
250 | - }) | |
251 | - .where(eq(schema.aiReviews.id, reviewId)); | |
252 | - | |
253 | - } catch (error) { | |
254 | - console.error("AI review failed:", error); | |
255 | - await db.update(schema.aiReviews) | |
256 | - .set({ | |
257 | - status: "failed", | |
258 | - errorMessage: error instanceof Error ? error.message : "Unknown error", | |
259 | - completedAt: new Date(), | |
260 | - }) | |
261 | - .where(eq(schema.aiReviews.id, reviewId)); | |
230 | + if (!diff) { | |
231 | + diff = "No changes detected."; | |
262 | 232 | } |
263 | -} | |
264 | 233 | |
265 | -async function dispatchExternalAgentReview(options: { | |
266 | - config: AIReviewConfig; | |
267 | - prompt: string; | |
268 | - reviewId: string; | |
269 | - pullRequestId: string; | |
270 | - pr: any; | |
271 | - diff: string; | |
272 | - stackContext: string | null; | |
273 | -}): Promise<{ | |
274 | - acceptedAsync: boolean; | |
275 | - result: ProviderCallResult | null; | |
276 | -}> { | |
277 | - const webhookUrl = options.config.baseUrl || process.env.EXTERNAL_AGENT_WEBHOOK_URL; | |
278 | - if (!webhookUrl) { | |
279 | - throw new Error("External agent webhook URL not configured"); | |
234 | + // Truncate diff if too large (poor man's token limit) | |
235 | + if (diff.length > 50000) { | |
236 | + diff = | |
237 | + diff.substring(0, 50000) + "\n\n...[Diff truncated due to length]..."; | |
280 | 238 | } |
281 | 239 | |
282 | - const callbackSecret = | |
283 | - process.env.EXTERNAL_AGENT_CALLBACK_SECRET || process.env.INTERNAL_HOOK_SECRET; | |
284 | - const siteUrl = process.env.SITE_URL; | |
285 | - const callbackUrl = | |
286 | - callbackSecret && siteUrl | |
287 | - ? `${siteUrl.replace(/\/$/, "")}/api/repos/${encodeURIComponent(options.pr.repository.owner.username)}/${encodeURIComponent(options.pr.repository.name)}/pulls/${options.pr.number}/ai-review/callback` | |
288 | - : undefined; | |
289 | - | |
290 | - const response = await fetch(webhookUrl, { | |
291 | - method: "POST", | |
292 | - headers: { | |
293 | - "Content-Type": "application/json", | |
294 | - ...(options.config.apiKey ? { Authorization: `Bearer ${options.config.apiKey}` } : {}), | |
295 | - }, | |
296 | - body: JSON.stringify({ | |
297 | - source: "opencodehub", | |
298 | - reviewId: options.reviewId, | |
299 | - pullRequestId: options.pullRequestId, | |
300 | - repository: { | |
301 | - owner: options.pr.repository.owner.username, | |
302 | - name: options.pr.repository.name, | |
303 | - id: options.pr.repository.id, | |
304 | - }, | |
305 | - pullRequest: { | |
306 | - number: options.pr.number, | |
307 | - title: options.pr.title, | |
308 | - headBranch: options.pr.headBranch, | |
309 | - baseBranch: options.pr.baseBranch, | |
310 | - }, | |
311 | - prompt: options.prompt, | |
312 | - diff: options.diff, | |
313 | - stackContext: options.stackContext, | |
314 | - callback: callbackUrl | |
315 | - ? { | |
316 | - url: callbackUrl, | |
317 | - token: callbackSecret, | |
318 | - } | |
319 | - : undefined, | |
320 | - }), | |
321 | - }); | |
240 | + // Get stack context if enabled | |
241 | + let stackContext = null; | |
242 | + if (config.includeStackContext) { | |
243 | + const stackInfo = await getStackForPr(pullRequestId); | |
244 | + if (stackInfo) { | |
245 | + stackContext = JSON.stringify({ | |
246 | + stackName: stackInfo.stack.name, | |
247 | + baseBranch: stackInfo.stack.baseBranch, | |
248 | + prsInStack: stackInfo.entries.map((e) => ({ | |
249 | + number: e.pr.number, | |
250 | + title: e.pr.title, | |
251 | + state: e.pr.state, | |
252 | + })), | |
253 | + }); | |
254 | + } | |
255 | + } | |
256 | + | |
257 | + // Generate review prompt | |
258 | + const prompt = generateReviewPrompt(pr, stackContext, diff); | |
259 | + | |
260 | + let result: ProviderCallResult | null = null; | |
261 | + | |
262 | + // External agents may run asynchronously and callback later. | |
263 | + if (config.provider === "external_agent") { | |
264 | + const external = await dispatchExternalAgentReview({ | |
265 | + config, | |
266 | + prompt, | |
267 | + reviewId, | |
268 | + pullRequestId, | |
269 | + pr, | |
270 | + diff, | |
271 | + stackContext, | |
272 | + }); | |
273 | + | |
274 | + if (external.acceptedAsync) { | |
275 | + await db | |
276 | + .update(schema.aiReviews) | |
277 | + .set({ | |
278 | + status: "running", | |
279 | + summary: | |
280 | + "External agent accepted review request; awaiting callback", | |
281 | + stackContext, | |
282 | + completedAt: null, | |
283 | + }) | |
284 | + .where(eq(schema.aiReviews.id, reviewId)); | |
285 | + return; | |
286 | + } | |
287 | + | |
288 | + result = external.result; | |
289 | + } else { | |
290 | + // Call built-in AI provider synchronously | |
291 | + result = await callAIProvider(config, prompt); | |
292 | + } | |
322 | 293 | |
323 | - if (response.status === 202) { | |
324 | - return { acceptedAsync: true, result: null }; | |
294 | + if (!result) { | |
295 | + throw new Error("AI provider returned no result"); | |
325 | 296 | } |
326 | 297 | |
327 | - if (!response.ok) { | |
328 | - throw new Error(`External agent error: ${response.status} ${await response.text()}`); | |
298 | + // Parse and save suggestions | |
299 | + const suggestions = parseAIResponse(result.content); | |
300 | + | |
301 | + for (const suggestion of suggestions) { | |
302 | + await db.insert(schema.aiReviewSuggestions).values({ | |
303 | + id: generateId(), | |
304 | + aiReviewId: reviewId, | |
305 | + path: suggestion.path, | |
306 | + line: suggestion.line, | |
307 | + endLine: suggestion.endLine, | |
308 | + severity: suggestion.severity, | |
309 | + type: suggestion.type, | |
310 | + title: suggestion.title, | |
311 | + message: suggestion.message, | |
312 | + suggestedFix: suggestion.suggestedFix, | |
313 | + explanation: suggestion.explanation, | |
314 | + createdAt: new Date(), | |
315 | + }); | |
329 | 316 | } |
330 | 317 | |
331 | - const payload = await response.json(); | |
332 | - const content = | |
333 | - typeof payload?.content === "string" | |
334 | - ? payload.content | |
335 | - : JSON.stringify({ | |
336 | - summary: payload?.summary || "External agent review completed", | |
337 | - suggestions: payload?.suggestions || [], | |
338 | - }); | |
318 | + // Determine overall severity | |
319 | + const severityOrder: Array<"info" | "warning" | "error" | "critical"> = [ | |
320 | + "info", | |
321 | + "warning", | |
322 | + "error", | |
323 | + "critical", | |
324 | + ]; | |
325 | + let maxSeverity: "info" | "warning" | "error" | "critical" = "info"; | |
326 | + for (const s of suggestions) { | |
327 | + if ( | |
328 | + severityOrder.indexOf(s.severity) > severityOrder.indexOf(maxSeverity) | |
329 | + ) { | |
330 | + maxSeverity = s.severity; | |
331 | + } | |
332 | + } | |
339 | 333 | |
340 | - return { | |
341 | - acceptedAsync: false, | |
342 | - result: { | |
343 | - content, | |
344 | - summary: payload?.summary || "External agent review completed", | |
345 | - tokensUsed: Number(payload?.usage?.totalTokens || payload?.tokensUsed || 0), | |
346 | - promptTokens: Number(payload?.usage?.inputTokens || 0), | |
347 | - completionTokens: Number(payload?.usage?.outputTokens || 0), | |
348 | - }, | |
349 | - }; | |
334 | + // Update review with results | |
335 | + const estimatedCost = estimateReviewCost( | |
336 | + config.provider, | |
337 | + config.model, | |
338 | + result.promptTokens, | |
339 | + result.completionTokens, | |
340 | + ); | |
341 | + | |
342 | + await db | |
343 | + .update(schema.aiReviews) | |
344 | + .set({ | |
345 | + status: "completed", | |
346 | + summary: result.summary, | |
347 | + overallSeverity: maxSeverity, | |
348 | + suggestionsCount: suggestions.length, | |
349 | + tokensUsed: result.tokensUsed, | |
350 | + promptTokens: result.promptTokens, | |
351 | + completionTokens: result.completionTokens, | |
352 | + costCents: Math.round(estimatedCost * 100), | |
353 | + stackContext, | |
354 | + completedAt: new Date(), | |
355 | + }) | |
356 | + .where(eq(schema.aiReviews.id, reviewId)); | |
357 | + } catch (error) { | |
358 | + logger.error("AI review failed:", error); | |
359 | + await db | |
360 | + .update(schema.aiReviews) | |
361 | + .set({ | |
362 | + status: "failed", | |
363 | + errorMessage: error instanceof Error ? error.message : "Unknown error", | |
364 | + completedAt: new Date(), | |
365 | + }) | |
366 | + .where(eq(schema.aiReviews.id, reviewId)); | |
367 | + } | |
368 | +} | |
369 | + | |
370 | +async function dispatchExternalAgentReview(options: { | |
371 | + config: AIReviewConfig; | |
372 | + prompt: string; | |
373 | + reviewId: string; | |
374 | + pullRequestId: string; | |
375 | + pr: any; | |
376 | + diff: string; | |
377 | + stackContext: string | null; | |
378 | +}): Promise<{ | |
379 | + acceptedAsync: boolean; | |
380 | + result: ProviderCallResult | null; | |
381 | +}> { | |
382 | + const webhookUrl = | |
383 | + options.config.baseUrl || process.env.EXTERNAL_AGENT_WEBHOOK_URL; | |
384 | + if (!webhookUrl) { | |
385 | + throw new Error("External agent webhook URL not configured"); | |
386 | + } | |
387 | + | |
388 | + const callbackSecret = | |
389 | + process.env.EXTERNAL_AGENT_CALLBACK_SECRET || | |
390 | + process.env.INTERNAL_HOOK_SECRET; | |
391 | + const siteUrl = process.env.SITE_URL; | |
392 | + const callbackUrl = | |
393 | + callbackSecret && siteUrl | |
394 | + ? `${siteUrl.replace(/\/$/, "")}/api/repos/${encodeURIComponent(options.pr.repository.owner.username)}/${encodeURIComponent(options.pr.repository.name)}/pulls/${options.pr.number}/ai-review/callback` | |
395 | + : undefined; | |
396 | + | |
397 | + const response = await fetch(webhookUrl, { | |
398 | + method: "POST", | |
399 | + headers: { | |
400 | + "Content-Type": "application/json", | |
401 | + ...(options.config.apiKey | |
402 | + ? { Authorization: `Bearer ${options.config.apiKey}` } | |
403 | + : {}), | |
404 | + }, | |
405 | + body: JSON.stringify({ | |
406 | + source: "opencodehub", | |
407 | + reviewId: options.reviewId, | |
408 | + pullRequestId: options.pullRequestId, | |
409 | + repository: { | |
410 | + owner: options.pr.repository.owner.username, | |
411 | + name: options.pr.repository.name, | |
412 | + id: options.pr.repository.id, | |
413 | + }, | |
414 | + pullRequest: { | |
415 | + number: options.pr.number, | |
416 | + title: options.pr.title, | |
417 | + headBranch: options.pr.headBranch, | |
418 | + baseBranch: options.pr.baseBranch, | |
419 | + }, | |
420 | + prompt: options.prompt, | |
421 | + diff: options.diff, | |
422 | + stackContext: options.stackContext, | |
423 | + callback: callbackUrl | |
424 | + ? { | |
425 | + url: callbackUrl, | |
426 | + token: callbackSecret, | |
427 | + } | |
428 | + : undefined, | |
429 | + }), | |
430 | + }); | |
431 | + | |
432 | + if (response.status === 202) { | |
433 | + return { acceptedAsync: true, result: null }; | |
434 | + } | |
435 | + | |
436 | + if (!response.ok) { | |
437 | + throw new Error( | |
438 | + `External agent error: ${response.status} ${await response.text()}`, | |
439 | + ); | |
440 | + } | |
441 | + | |
442 | + const payload = await response.json(); | |
443 | + const content = | |
444 | + typeof payload?.content === "string" | |
445 | + ? payload.content | |
446 | + : JSON.stringify({ | |
447 | + summary: payload?.summary || "External agent review completed", | |
448 | + suggestions: payload?.suggestions || [], | |
449 | + }); | |
450 | + | |
451 | + return { | |
452 | + acceptedAsync: false, | |
453 | + result: { | |
454 | + content, | |
455 | + summary: payload?.summary || "External agent review completed", | |
456 | + tokensUsed: Number( | |
457 | + payload?.usage?.totalTokens || payload?.tokensUsed || 0, | |
458 | + ), | |
459 | + promptTokens: Number(payload?.usage?.inputTokens || 0), | |
460 | + completionTokens: Number(payload?.usage?.outputTokens || 0), | |
461 | + }, | |
462 | + }; | |
350 | 463 | } |
351 | 464 | |
352 | 465 | /** |
353 | 466 | * Generate the review prompt |
354 | 467 | */ |
355 | 468 | function generateReviewPrompt( |
356 | - pr: typeof schema.pullRequests.$inferSelect, | |
357 | - stackContext: string | null, | |
358 | - diff: string | |
469 | + pr: typeof schema.pullRequests.$inferSelect, | |
470 | + stackContext: string | null, | |
471 | + diff: string, | |
359 | 472 | ): string { |
360 | - let prompt = `You are a senior code reviewer. Review the following pull request and provide actionable feedback. | |
473 | + let prompt = `You are a senior code reviewer. Review the following pull request and provide actionable feedback. | |
361 | 474 | |
362 | 475 | ## Pull Request |
363 | 476 | - Title: ${pr.title} |
| @@ -366,21 +479,23 @@ function generateReviewPrompt( | ||
366 | 479 | - Changes: +${pr.additions} -${pr.deletions} in ${pr.changedFiles} files |
367 | 480 | `; |
368 | 481 | |
369 | - if (stackContext) { | |
370 | - prompt += ` | |
482 | + if (stackContext) { | |
483 | + prompt += ` | |
371 | 484 | ## Stack Context |
372 | 485 | This PR is part of a stack: |
373 | 486 | ${stackContext} |
374 | 487 | `; |
375 | - } | |
488 | + } | |
376 | 489 | |
377 | - prompt += ` | |
490 | + prompt += ` | |
378 | 491 | ## Review Instructions |
379 | 492 | 1. Identify bugs, security issues, performance problems, and style issues |
380 | 493 | 2. For each issue, provide: |
381 | 494 | - File path and line number (if applicable) |
382 | 495 | - Severity: info, warning, error, or critical |
383 | 496 | - Type: bug, security, performance, style, documentation, or suggestion |
497 | + - Category: Security, Performance, Best Practices, Code Style, Documentation, Bug Prevention | |
498 | + - Confidence: a score from 0.0 to 1.0 indicating how confident you are in the finding | |
384 | 499 | - Clear explanation |
385 | 500 | - Suggested fix (code snippet if applicable) |
386 | 501 | 3. Provide a brief summary at the end |
| @@ -395,6 +510,8 @@ Respond in JSON format only: | ||
395 | 510 | "line": 42, |
396 | 511 | "severity": "warning", |
397 | 512 | "type": "bug", |
513 | + "category": "Bug Prevention", | |
514 | + "confidence": 0.85, | |
398 | 515 | "title": "Potential null pointer", |
399 | 516 | "message": "Variable may be null here", |
400 | 517 | "suggestedFix": "if (value !== null) { ... }", |
| @@ -409,7 +526,7 @@ Review the code carefully: | ||
409 | 526 | ${diff} |
410 | 527 | `; |
411 | 528 | |
412 | - return prompt; | |
529 | + return prompt; | |
413 | 530 | } |
414 | 531 | |
415 | 532 | /** |
| @@ -419,127 +536,134 @@ ${diff} | ||
419 | 536 | * Call the AI provider |
420 | 537 | */ |
421 | 538 | async function callAIProvider( |
422 | - config: AIReviewConfig, | |
423 | - prompt: string | |
539 | + config: AIReviewConfig, | |
540 | + prompt: string, | |
424 | 541 | ): Promise<{ |
425 | - content: string; | |
426 | - summary: string; | |
427 | - tokensUsed: number; | |
428 | - promptTokens: number; | |
429 | - completionTokens: number; | |
542 | + content: string; | |
543 | + summary: string; | |
544 | + tokensUsed: number; | |
545 | + promptTokens: number; | |
546 | + completionTokens: number; | |
430 | 547 | }> { |
548 | + // Import dynamically to avoid circular dependencies if any | |
549 | + const { getAIAdapter } = await import("./ai"); | |
550 | + | |
551 | + try { | |
552 | + const adapter = getAIAdapter(config.provider); | |
431 | 553 | |
432 | - // Import dynamically to avoid circular dependencies if any | |
433 | - const { getAIAdapter } = await import("./ai"); | |
554 | + const systemPrompt = | |
555 | + "You are an expert code reviewer. Response MUST be valid JSON matching the requested format."; | |
556 | + | |
557 | + const result = await adapter.complete( | |
558 | + { | |
559 | + system: systemPrompt, | |
560 | + user: prompt, | |
561 | + }, | |
562 | + { | |
563 | + provider: config.provider, | |
564 | + model: config.model, | |
565 | + apiKey: config.apiKey, | |
566 | + baseUrl: config.baseUrl, | |
567 | + }, | |
568 | + ); | |
434 | 569 | |
570 | + // Parse summary from content | |
571 | + let summary = "AI Review Completed"; | |
435 | 572 | try { |
436 | - const adapter = getAIAdapter(config.provider); | |
437 | - | |
438 | - const systemPrompt = "You are an expert code reviewer. Response MUST be valid JSON matching the requested format."; | |
439 | - | |
440 | - const result = await adapter.complete({ | |
441 | - system: systemPrompt, | |
442 | - user: prompt | |
443 | - }, { | |
444 | - provider: config.provider, | |
445 | - model: config.model, | |
446 | - apiKey: config.apiKey, | |
447 | - baseUrl: config.baseUrl | |
448 | - }); | |
573 | + const parsed = JSON.parse(result.content); | |
574 | + if (parsed.summary) summary = parsed.summary; | |
575 | + } catch (e) {} | |
449 | 576 | |
450 | - // Parse summary from content | |
451 | - let summary = "AI Review Completed"; | |
452 | - try { | |
453 | - const parsed = JSON.parse(result.content); | |
454 | - if (parsed.summary) summary = parsed.summary; | |
455 | - } catch (e) { } | |
456 | - | |
457 | - return { | |
458 | - content: result.content, | |
459 | - summary, | |
460 | - tokensUsed: result.usage.totalTokens, | |
461 | - promptTokens: result.usage.inputTokens, | |
462 | - completionTokens: result.usage.outputTokens, | |
463 | - }; | |
464 | - | |
465 | - } catch (error: any) { | |
466 | - logger.error({ error: error.message, provider: config.provider }, "AI Provider call failed"); | |
467 | - | |
468 | - // Fallback or rethrow? Rethrow to fail the review properly. | |
469 | - throw error; | |
470 | - } | |
577 | + return { | |
578 | + content: result.content, | |
579 | + summary, | |
580 | + tokensUsed: result.usage.totalTokens, | |
581 | + promptTokens: result.usage.inputTokens, | |
582 | + completionTokens: result.usage.outputTokens, | |
583 | + }; | |
584 | + } catch (error: any) { | |
585 | + logger.error( | |
586 | + { error: error.message, provider: config.provider }, | |
587 | + "AI Provider call failed", | |
588 | + ); | |
589 | + | |
590 | + // Fallback or rethrow? Rethrow to fail the review properly. | |
591 | + throw error; | |
592 | + } | |
471 | 593 | } |
472 | 594 | |
473 | 595 | /** |
474 | 596 | * Parse AI response into suggestions |
475 | 597 | */ |
476 | 598 | function parseAIResponse(content: string): ReviewSuggestion[] { |
477 | - try { | |
478 | - const parsed = JSON.parse(content); | |
479 | - return parsed.suggestions || []; | |
480 | - } catch { | |
481 | - console.error("Failed to parse AI response:", content); | |
482 | - return []; | |
483 | - } | |
599 | + try { | |
600 | + const parsed = JSON.parse(content); | |
601 | + return parsed.suggestions || []; | |
602 | + } catch { | |
603 | + logger.error("Failed to parse AI response:", content); | |
604 | + return []; | |
605 | + } | |
484 | 606 | } |
485 | 607 | |
486 | 608 | /** |
487 | 609 | * Get the latest AI review for a PR |
488 | 610 | */ |
489 | 611 | export async function getLatestAIReview(pullRequestId: string): Promise<{ |
490 | - review: typeof schema.aiReviews.$inferSelect; | |
491 | - suggestions: typeof schema.aiReviewSuggestions.$inferSelect[]; | |
612 | + review: typeof schema.aiReviews.$inferSelect; | |
613 | + suggestions: (typeof schema.aiReviewSuggestions.$inferSelect)[]; | |
492 | 614 | } | null> { |
493 | - const db = getDatabase() as NodePgDatabase<typeof schema>; | |
615 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
494 | 616 | |
495 | - const review = await db.query.aiReviews.findFirst({ | |
496 | - where: eq(schema.aiReviews.pullRequestId, pullRequestId), | |
497 | - orderBy: [desc(schema.aiReviews.createdAt)], | |
498 | - }); | |
617 | + const review = await db.query.aiReviews.findFirst({ | |
618 | + where: eq(schema.aiReviews.pullRequestId, pullRequestId), | |
619 | + orderBy: [desc(schema.aiReviews.createdAt)], | |
620 | + }); | |
499 | 621 | |
500 | - if (!review) return null; | |
622 | + if (!review) return null; | |
501 | 623 | |
502 | - const suggestions = await db.query.aiReviewSuggestions.findMany({ | |
503 | - where: eq(schema.aiReviewSuggestions.aiReviewId, review.id), | |
504 | - }); | |
624 | + const suggestions = await db.query.aiReviewSuggestions.findMany({ | |
625 | + where: eq(schema.aiReviewSuggestions.aiReviewId, review.id), | |
626 | + }); | |
505 | 627 | |
506 | - return { review, suggestions }; | |
628 | + return { review, suggestions }; | |
507 | 629 | } |
508 | 630 | |
509 | 631 | /** |
510 | 632 | * Apply an AI suggestion (mark as applied) |
511 | 633 | */ |
512 | 634 | export async function applyAISuggestion( |
513 | - suggestionId: string, | |
514 | - appliedById: string | |
635 | + suggestionId: string, | |
636 | + appliedById: string, | |
515 | 637 | ): Promise<void> { |
516 | - const db = getDatabase() as NodePgDatabase<typeof schema>; | |
517 | - | |
518 | - await db.update(schema.aiReviewSuggestions) | |
519 | - .set({ | |
520 | - isApplied: true, | |
521 | - appliedAt: new Date(), | |
522 | - appliedById, | |
523 | - }) | |
524 | - .where(eq(schema.aiReviewSuggestions.id, suggestionId)); | |
638 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
639 | + | |
640 | + await db | |
641 | + .update(schema.aiReviewSuggestions) | |
642 | + .set({ | |
643 | + isApplied: true, | |
644 | + appliedAt: new Date(), | |
645 | + appliedById, | |
646 | + }) | |
647 | + .where(eq(schema.aiReviewSuggestions.id, suggestionId)); | |
525 | 648 | } |
526 | 649 | |
527 | 650 | /** |
528 | 651 | * Dismiss an AI suggestion |
529 | 652 | */ |
530 | 653 | export async function dismissAISuggestion( |
531 | - suggestionId: string, | |
532 | - dismissedById: string, | |
533 | - reason?: string | |
654 | + suggestionId: string, | |
655 | + dismissedById: string, | |
656 | + reason?: string, | |
534 | 657 | ): Promise<void> { |
535 | - const db = getDatabase() as NodePgDatabase<typeof schema>; | |
536 | - | |
537 | - await db.update(schema.aiReviewSuggestions) | |
538 | - .set({ | |
539 | - isDismissed: true, | |
540 | - dismissedAt: new Date(), | |
541 | - dismissedById, | |
542 | - dismissReason: reason, | |
543 | - }) | |
544 | - .where(eq(schema.aiReviewSuggestions.id, suggestionId)); | |
658 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
659 | + | |
660 | + await db | |
661 | + .update(schema.aiReviewSuggestions) | |
662 | + .set({ | |
663 | + isDismissed: true, | |
664 | + dismissedAt: new Date(), | |
665 | + dismissedById, | |
666 | + dismissReason: reason, | |
667 | + }) | |
668 | + .where(eq(schema.aiReviewSuggestions.id, suggestionId)); | |
545 | 669 | } |
546 | 670 | |
▼src/lib/ai/index.ts
+31-28
| @@ -1,37 +1,40 @@ | ||
1 | -import { OpenAIAdapter } from "./openai"; | |
2 | -import { GroqAdapter } from "./groq"; | |
3 | -import { BytezAdapter } from "./bytez"; | |
4 | 1 | import { AnthropicAdapter } from "./anthropic"; |
5 | -import { OpenRouterAdapter } from "./openrouter"; | |
6 | -import { TogetherAdapter } from "./together"; | |
7 | -import { GoogleAdapter } from "./google"; | |
2 | +import { BytezAdapter } from "./bytez"; | |
8 | 3 | import { ExternalAgentAdapter } from "./external-agent"; |
4 | +import { GoogleAdapter } from "./google"; | |
5 | +import { GroqAdapter } from "./groq"; | |
9 | 6 | import { LocalAdapter } from "./local"; |
7 | +import { OllamaAdapter } from "./ollama"; | |
8 | +import { OpenAIAdapter } from "./openai"; | |
9 | +import { OpenRouterAdapter } from "./openrouter"; | |
10 | +import { TogetherAdapter } from "./together"; | |
10 | 11 | import type { AIAdapter } from "./types"; |
11 | 12 | |
12 | 13 | export function getAIAdapter(provider: string): AIAdapter { |
13 | - switch (provider) { | |
14 | - case "openai": | |
15 | - return new OpenAIAdapter(); | |
16 | - case "groq": | |
17 | - return new GroqAdapter(); | |
18 | - case "bytez": | |
19 | - return new BytezAdapter(); | |
20 | - case "openrouter": | |
21 | - return new OpenRouterAdapter(); | |
22 | - case "together": | |
23 | - return new TogetherAdapter(); | |
24 | - case "google": | |
25 | - return new GoogleAdapter(); | |
26 | - case "external_agent": | |
27 | - return new ExternalAgentAdapter(); | |
28 | - case "local": | |
29 | - return new LocalAdapter(); | |
30 | - case "anthropic": | |
31 | - return new AnthropicAdapter(); | |
32 | - default: | |
33 | - throw new Error(`Unknown AI provider: ${provider}`); | |
34 | - } | |
14 | + switch (provider) { | |
15 | + case "openai": | |
16 | + return new OpenAIAdapter(); | |
17 | + case "groq": | |
18 | + return new GroqAdapter(); | |
19 | + case "bytez": | |
20 | + return new BytezAdapter(); | |
21 | + case "openrouter": | |
22 | + return new OpenRouterAdapter(); | |
23 | + case "together": | |
24 | + return new TogetherAdapter(); | |
25 | + case "google": | |
26 | + return new GoogleAdapter(); | |
27 | + case "external_agent": | |
28 | + return new ExternalAgentAdapter(); | |
29 | + case "local": | |
30 | + return new LocalAdapter(); | |
31 | + case "anthropic": | |
32 | + return new AnthropicAdapter(); | |
33 | + case "ollama": | |
34 | + return new OllamaAdapter(); | |
35 | + default: | |
36 | + throw new Error(`Unknown AI provider: ${provider}`); | |
37 | + } | |
35 | 38 | } |
36 | 39 | |
37 | 40 | export * from "./types"; |
38 | 41 | |
▼src/lib/ai/ollama.ts
+58-0
| @@ -0,0 +1,58 @@ | ||
1 | +/** | |
2 | + * Ollama AI Adapter — Local LLM provider | |
3 | + * | |
4 | + * Connects to a locally-running Ollama instance for private, | |
5 | + * cost-free AI code reviews. No data leaves your infrastructure. | |
6 | + */ | |
7 | + | |
8 | +import type { | |
9 | + AIAdapter, | |
10 | + AICompletionResult, | |
11 | + AIConfig, | |
12 | + AIReviewPrompt, | |
13 | +} from "./types"; | |
14 | + | |
15 | +export class OllamaAdapter implements AIAdapter { | |
16 | + async complete( | |
17 | + prompt: AIReviewPrompt, | |
18 | + config: AIConfig, | |
19 | + ): Promise<AICompletionResult> { | |
20 | + const baseUrl = | |
21 | + config.baseUrl || process.env.OLLAMA_BASE_URL || "http://localhost:11434"; | |
22 | + const model = config.model || process.env.OLLAMA_MODEL || "codellama:13b"; | |
23 | + | |
24 | + const response = await fetch(`${baseUrl}/api/chat`, { | |
25 | + method: "POST", | |
26 | + headers: { "Content-Type": "application/json" }, | |
27 | + body: JSON.stringify({ | |
28 | + model, | |
29 | + messages: [ | |
30 | + { role: "system", content: prompt.system }, | |
31 | + { role: "user", content: prompt.user }, | |
32 | + ], | |
33 | + stream: false, | |
34 | + options: { | |
35 | + temperature: config.temperature ?? 0.1, | |
36 | + num_predict: config.maxTokens ?? 4096, | |
37 | + }, | |
38 | + format: "json", | |
39 | + }), | |
40 | + }); | |
41 | + | |
42 | + if (!response.ok) { | |
43 | + const errorText = await response.text(); | |
44 | + throw new Error(`Ollama error (${response.status}): ${errorText}`); | |
45 | + } | |
46 | + | |
47 | + const data = await response.json(); | |
48 | + | |
49 | + return { | |
50 | + content: data.message?.content || "", | |
51 | + usage: { | |
52 | + inputTokens: data.prompt_eval_count || 0, | |
53 | + outputTokens: data.eval_count || 0, | |
54 | + totalTokens: (data.prompt_eval_count || 0) + (data.eval_count || 0), | |
55 | + }, | |
56 | + }; | |
57 | + } | |
58 | +} | |
0 | 59 | |
▼src/lib/ai/types.ts
+28-24
| @@ -1,35 +1,39 @@ | ||
1 | 1 | export interface AIConfig { |
2 | - provider: | |
3 | - | "openai" | |
4 | - | "groq" | |
5 | - | "bytez" | |
6 | - | "openrouter" | |
7 | - | "together" | |
8 | - | "google" | |
9 | - | "external_agent" | |
10 | - | "local" | |
11 | - | "anthropic"; | |
12 | - model: string; | |
13 | - apiKey?: string; | |
14 | - baseUrl?: string; | |
15 | - maxTokens?: number; | |
16 | - temperature?: number; | |
2 | + provider: | |
3 | + | "openai" | |
4 | + | "groq" | |
5 | + | "bytez" | |
6 | + | "openrouter" | |
7 | + | "together" | |
8 | + | "google" | |
9 | + | "external_agent" | |
10 | + | "local" | |
11 | + | "anthropic" | |
12 | + | "ollama"; | |
13 | + model: string; | |
14 | + apiKey?: string; | |
15 | + baseUrl?: string; | |
16 | + maxTokens?: number; | |
17 | + temperature?: number; | |
17 | 18 | } |
18 | 19 | |
19 | 20 | export interface AIReviewPrompt { |
20 | - system: string; | |
21 | - user: string; | |
21 | + system: string; | |
22 | + user: string; | |
22 | 23 | } |
23 | 24 | |
24 | 25 | export interface AICompletionResult { |
25 | - content: string; | |
26 | - usage: { | |
27 | - inputTokens: number; | |
28 | - outputTokens: number; | |
29 | - totalTokens: number; | |
30 | - }; | |
26 | + content: string; | |
27 | + usage: { | |
28 | + inputTokens: number; | |
29 | + outputTokens: number; | |
30 | + totalTokens: number; | |
31 | + }; | |
31 | 32 | } |
32 | 33 | |
33 | 34 | export interface AIAdapter { |
34 | - complete(prompt: AIReviewPrompt, config: AIConfig): Promise<AICompletionResult>; | |
35 | + complete( | |
36 | + prompt: AIReviewPrompt, | |
37 | + config: AIConfig, | |
38 | + ): Promise<AICompletionResult>; | |
35 | 39 | } |
36 | 40 | |
▼src/lib/air-gapped.ts
+15-0
| @@ -0,0 +1,15 @@ | ||
1 | +export function isAirGappedMode(): boolean { | |
2 | + const value = (process.env.AIR_GAPPED_MODE || "").trim().toLowerCase(); | |
3 | + return value === "1" || value === "true" || value === "yes"; | |
4 | +} | |
5 | + | |
6 | +export function getAirGappedMessage(feature: string): string { | |
7 | + return `${feature} is disabled because AIR_GAPPED_MODE is enabled`; | |
8 | +} | |
9 | + | |
10 | +export function assertOnlineFeature(feature: string): void { | |
11 | + if (isAirGappedMode()) { | |
12 | + throw new Error(getAirGappedMessage(feature)); | |
13 | + } | |
14 | +} | |
15 | + | |
0 | 16 | |
▼src/lib/analytics-advanced.ts
+906-545
| @@ -3,67 +3,81 @@ | ||
3 | 3 | * Hotspot detection, export metrics, custom dashboards |
4 | 4 | */ |
5 | 5 | |
6 | -import { pgTable, text, timestamp, integer, real, jsonb, boolean } from "drizzle-orm/pg-core"; | |
7 | 6 | import { getDatabase, schema } from "@/db"; |
8 | -import { eq, and, gte, lte, sql, desc, inArray } from "drizzle-orm"; | |
9 | -import { logger } from "./logger"; | |
10 | 7 | import { repositories } from "@/db/schema/repositories"; |
11 | 8 | import { users } from "@/db/schema/users"; |
12 | 9 | import { compareBranches } from "@/lib/git"; |
13 | 10 | import { resolveRepoPath } from "@/lib/git-storage"; |
11 | +import { and, asc, desc, eq, gte, inArray, lte } from "drizzle-orm"; | |
12 | +import { | |
13 | + boolean, | |
14 | + integer, | |
15 | + jsonb, | |
16 | + pgTable, | |
17 | + real, | |
18 | + text, | |
19 | + timestamp, | |
20 | +} from "drizzle-orm/pg-core"; | |
21 | +import { logger } from "./logger"; | |
14 | 22 | |
15 | 23 | // ============================================================================ |
16 | 24 | // SCHEMA |
17 | 25 | // ============================================================================ |
18 | 26 | |
19 | 27 | export const fileHotspots = pgTable("file_hotspots", { |
20 | - id: text("id").primaryKey(), | |
21 | - repositoryId: text("repository_id") | |
22 | - .notNull() | |
23 | - .references(() => repositories.id, { onDelete: "cascade" }), | |
24 | - filePath: text("file_path").notNull(), | |
25 | - changeCount: integer("change_count").default(0), | |
26 | - bugCount: integer("bug_count").default(0), | |
27 | - reviewCount: integer("review_count").default(0), | |
28 | - complexityScore: real("complexity_score"), | |
29 | - lastModified: timestamp("last_modified"), | |
30 | - calculatedAt: timestamp("calculated_at").notNull().defaultNow(), | |
28 | + id: text("id").primaryKey(), | |
29 | + repositoryId: text("repository_id") | |
30 | + .notNull() | |
31 | + .references(() => repositories.id, { onDelete: "cascade" }), | |
32 | + filePath: text("file_path").notNull(), | |
33 | + changeCount: integer("change_count").default(0), | |
34 | + bugCount: integer("bug_count").default(0), | |
35 | + reviewCount: integer("review_count").default(0), | |
36 | + complexityScore: real("complexity_score"), | |
37 | + lastModified: timestamp("last_modified"), | |
38 | + calculatedAt: timestamp("calculated_at").notNull().defaultNow(), | |
31 | 39 | }); |
32 | 40 | |
33 | 41 | export const customDashboards = pgTable("custom_dashboards", { |
34 | - id: text("id").primaryKey(), | |
35 | - userId: text("user_id") | |
36 | - .notNull() | |
37 | - .references(() => users.id, { onDelete: "cascade" }), | |
38 | - name: text("name").notNull(), | |
39 | - description: text("description"), | |
40 | - isPublic: boolean("is_public").default(false), | |
41 | - layout: jsonb("layout").$type<DashboardLayout>(), | |
42 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
43 | - updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
42 | + id: text("id").primaryKey(), | |
43 | + userId: text("user_id") | |
44 | + .notNull() | |
45 | + .references(() => users.id, { onDelete: "cascade" }), | |
46 | + name: text("name").notNull(), | |
47 | + description: text("description"), | |
48 | + isPublic: boolean("is_public").default(false), | |
49 | + layout: jsonb("layout").$type<DashboardLayout>(), | |
50 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
51 | + updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
44 | 52 | }); |
45 | 53 | |
46 | 54 | export const dashboardWidgets = pgTable("dashboard_widgets", { |
47 | - id: text("id").primaryKey(), | |
48 | - dashboardId: text("dashboard_id") | |
49 | - .notNull() | |
50 | - .references(() => customDashboards.id, { onDelete: "cascade" }), | |
51 | - widgetType: text("widget_type").notNull(), | |
52 | - title: text("title").notNull(), | |
53 | - config: jsonb("config").$type<WidgetConfig>(), | |
54 | - position: jsonb("position").$type<{ x: number; y: number; w: number; h: number }>(), | |
55 | - createdAt: timestamp("created_at").notNull().defaultNow(), | |
55 | + id: text("id").primaryKey(), | |
56 | + dashboardId: text("dashboard_id") | |
57 | + .notNull() | |
58 | + .references(() => customDashboards.id, { onDelete: "cascade" }), | |
59 | + widgetType: text("widget_type").notNull(), | |
60 | + title: text("title").notNull(), | |
61 | + config: jsonb("config").$type<WidgetConfig>(), | |
62 | + position: jsonb("position").$type<{ | |
63 | + x: number; | |
64 | + y: number; | |
65 | + w: number; | |
66 | + h: number; | |
67 | + }>(), | |
68 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
56 | 69 | }); |
57 | 70 | |
58 | 71 | export const metricSnapshots = pgTable("metric_snapshots", { |
59 | - id: text("id").primaryKey(), | |
60 | - repositoryId: text("repository_id") | |
61 | - .references(() => repositories.id, { onDelete: "cascade" }), | |
62 | - organizationId: text("organization_id"), | |
63 | - metricType: text("metric_type").notNull(), | |
64 | - value: real("value").notNull(), | |
65 | - dimensions: jsonb("dimensions").$type<Record<string, string>>(), | |
66 | - timestamp: timestamp("timestamp").notNull().defaultNow(), | |
72 | + id: text("id").primaryKey(), | |
73 | + repositoryId: text("repository_id").references(() => repositories.id, { | |
74 | + onDelete: "cascade", | |
75 | + }), | |
76 | + organizationId: text("organization_id"), | |
77 | + metricType: text("metric_type").notNull(), | |
78 | + value: real("value").notNull(), | |
79 | + dimensions: jsonb("dimensions").$type<Record<string, string>>(), | |
80 | + timestamp: timestamp("timestamp").notNull().defaultNow(), | |
67 | 81 | }); |
68 | 82 | |
69 | 83 | export type FileHotspot = typeof fileHotspots.$inferSelect; |
| @@ -71,122 +85,145 @@ export type CustomDashboard = typeof customDashboards.$inferSelect; | ||
71 | 85 | export type MetricSnapshot = typeof metricSnapshots.$inferSelect; |
72 | 86 | |
73 | 87 | interface DashboardLayout { |
74 | - columns: number; | |
75 | - rows: number; | |
88 | + columns: number; | |
89 | + rows: number; | |
76 | 90 | } |
77 | 91 | |
78 | 92 | interface WidgetConfig { |
79 | - repositoryId?: string; | |
80 | - timeRange?: string; | |
81 | - metrics?: string[]; | |
82 | - filters?: Record<string, string>; | |
93 | + repositoryId?: string; | |
94 | + timeRange?: string; | |
95 | + metrics?: string[]; | |
96 | + filters?: Record<string, string>; | |
83 | 97 | } |
84 | 98 | |
85 | 99 | // ============================================================================ |
86 | 100 | // HOTSPOT FILE DETECTION |
87 | 101 | // ============================================================================ |
88 | 102 | |
89 | -export async function calculateFileHotspots(repositoryId: string): Promise<FileHotspot[]> { | |
90 | - const db = getDatabase(); | |
91 | - const repository = await db.query.repositories.findFirst({ | |
92 | - where: eq(schema.repositories.id, repositoryId), | |
93 | - }); | |
94 | - if (!repository) return []; | |
95 | - | |
96 | - const repoPath = await resolveRepoPath(repository.diskPath); | |
97 | - | |
98 | - // Get all PRs for this repository | |
99 | - const prs = await db.query.pullRequests.findMany({ | |
100 | - where: eq(schema.pullRequests.repositoryId, repositoryId), | |
101 | - }); | |
102 | - | |
103 | - const fileStats: Map<string, { | |
104 | - changeCount: number; | |
105 | - bugCount: number; | |
106 | - reviewCount: number; | |
107 | - lastModified: Date; | |
108 | - }> = new Map(); | |
109 | - | |
110 | - // Replace previous snapshot for this repository | |
111 | - // @ts-expect-error - Drizzle multi-db union type issue | |
112 | - await db.delete(schema.fileHotspots).where(eq(schema.fileHotspots.repositoryId, repositoryId)); | |
113 | - | |
114 | - // Analyze each PR's changed files | |
115 | - for (const pr of prs) { | |
116 | - let changedFiles: string[] = []; | |
117 | - try { | |
118 | - const { diffs } = await compareBranches(repoPath, pr.baseBranch, pr.headBranch); | |
119 | - changedFiles = diffs.map((d) => d.file).filter(Boolean); | |
120 | - } catch (error) { | |
121 | - logger.warn({ repositoryId, prId: pr.id, error }, "Failed to load changed files for hotspot analysis"); | |
122 | - continue; | |
123 | - } | |
124 | - const isBugFix = pr.title?.toLowerCase().includes("fix") || | |
125 | - pr.title?.toLowerCase().includes("bug"); | |
126 | - | |
127 | - for (const file of changedFiles) { | |
128 | - const current = fileStats.get(file) || { | |
129 | - changeCount: 0, | |
130 | - bugCount: 0, | |
131 | - reviewCount: 0, | |
132 | - lastModified: new Date(0), | |
133 | - }; | |
103 | +export async function calculateFileHotspots( | |
104 | + repositoryId: string, | |
105 | +): Promise<FileHotspot[]> { | |
106 | + const db = getDatabase(); | |
107 | + const repository = await db.query.repositories.findFirst({ | |
108 | + where: eq(schema.repositories.id, repositoryId), | |
109 | + }); | |
110 | + if (!repository) return []; | |
111 | + | |
112 | + const repoPath = await resolveRepoPath(repository.diskPath); | |
113 | + | |
114 | + // Get all PRs for this repository | |
115 | + const prs = await db.query.pullRequests.findMany({ | |
116 | + where: eq(schema.pullRequests.repositoryId, repositoryId), | |
117 | + }); | |
118 | + | |
119 | + const fileStats: Map< | |
120 | + string, | |
121 | + { | |
122 | + changeCount: number; | |
123 | + bugCount: number; | |
124 | + reviewCount: number; | |
125 | + lastModified: Date; | |
126 | + } | |
127 | + > = new Map(); | |
134 | 128 | |
135 | - current.changeCount++; | |
136 | - if (isBugFix) current.bugCount++; | |
137 | - current.reviewCount += pr.reviewCount || 0; | |
138 | - if (pr.mergedAt && pr.mergedAt > current.lastModified) { | |
139 | - current.lastModified = pr.mergedAt; | |
140 | - } | |
129 | + // Replace previous snapshot for this repository | |
130 | + // @ts-expect-error - Drizzle multi-db union type issue | |
131 | + await db | |
132 | + .delete(schema.fileHotspots) | |
133 | + .where(eq(schema.fileHotspots.repositoryId, repositoryId)); | |
141 | 134 | |
142 | - fileStats.set(file, current); | |
143 | - } | |
135 | + // Analyze each PR's changed files | |
136 | + for (const pr of prs) { | |
137 | + let changedFiles: string[] = []; | |
138 | + try { | |
139 | + const { diffs } = await compareBranches( | |
140 | + repoPath, | |
141 | + pr.baseBranch, | |
142 | + pr.headBranch, | |
143 | + ); | |
144 | + changedFiles = diffs.map((d) => d.file).filter(Boolean); | |
145 | + } catch (error) { | |
146 | + logger.warn( | |
147 | + { repositoryId, prId: pr.id, error }, | |
148 | + "Failed to load changed files for hotspot analysis", | |
149 | + ); | |
150 | + continue; | |
144 | 151 | } |
145 | - | |
146 | - // Calculate complexity scores and save hotspots | |
147 | - const hotspots: FileHotspot[] = []; | |
148 | - | |
149 | - for (const [filePath, stats] of fileStats) { | |
150 | - // Hotspot score = changes * 0.5 + bugs * 2 + reviews * 0.3 | |
151 | - const complexityScore = stats.changeCount * 0.5 + stats.bugCount * 2; | |
152 | - | |
153 | - const hotspot = { | |
154 | - id: crypto.randomUUID(), | |
155 | - repositoryId, | |
156 | - filePath, | |
157 | - changeCount: stats.changeCount, | |
158 | - bugCount: stats.bugCount, | |
159 | - reviewCount: stats.reviewCount, | |
160 | - complexityScore, | |
161 | - lastModified: stats.lastModified, | |
162 | - calculatedAt: new Date(), | |
163 | - }; | |
164 | - | |
165 | - // @ts-expect-error - Drizzle multi-db union type issue | |
166 | - await db.insert(schema.fileHotspots).values(hotspot); | |
167 | - hotspots.push(hotspot as FileHotspot); | |
152 | + const isBugFix = | |
153 | + pr.title?.toLowerCase().includes("fix") || | |
154 | + pr.title?.toLowerCase().includes("bug"); | |
155 | + | |
156 | + for (const file of changedFiles) { | |
157 | + const current = fileStats.get(file) || { | |
158 | + changeCount: 0, | |
159 | + bugCount: 0, | |
160 | + reviewCount: 0, | |
161 | + lastModified: new Date(0), | |
162 | + }; | |
163 | + | |
164 | + current.changeCount++; | |
165 | + if (isBugFix) current.bugCount++; | |
166 | + current.reviewCount += pr.reviewCount || 0; | |
167 | + if (pr.mergedAt && pr.mergedAt > current.lastModified) { | |
168 | + current.lastModified = pr.mergedAt; | |
169 | + } | |
170 | + | |
171 | + fileStats.set(file, current); | |
168 | 172 | } |
173 | + } | |
174 | + | |
175 | + // Calculate complexity scores and save hotspots | |
176 | + const hotspots: FileHotspot[] = []; | |
177 | + | |
178 | + for (const [filePath, stats] of fileStats) { | |
179 | + // Hotspot score = changes * 0.5 + bugs * 2 + reviews * 0.3 | |
180 | + const complexityScore = stats.changeCount * 0.5 + stats.bugCount * 2; | |
181 | + | |
182 | + const hotspot = { | |
183 | + id: crypto.randomUUID(), | |
184 | + repositoryId, | |
185 | + filePath, | |
186 | + changeCount: stats.changeCount, | |
187 | + bugCount: stats.bugCount, | |
188 | + reviewCount: stats.reviewCount, | |
189 | + complexityScore, | |
190 | + lastModified: stats.lastModified, | |
191 | + calculatedAt: new Date(), | |
192 | + }; | |
169 | 193 | |
170 | - // Sort by complexity score descending | |
171 | - hotspots.sort((a, b) => (b.complexityScore || 0) - (a.complexityScore || 0)); | |
194 | + // @ts-expect-error - Drizzle multi-db union type issue | |
195 | + await db.insert(schema.fileHotspots).values(hotspot); | |
196 | + hotspots.push(hotspot as FileHotspot); | |
197 | + } | |
172 | 198 | |
173 | - logger.info({ repoId: repositoryId, hotspotCount: hotspots.length }, "Hotspots calculated"); | |
199 | + // Sort by complexity score descending | |
200 | + hotspots.sort((a, b) => (b.complexityScore || 0) - (a.complexityScore || 0)); | |
174 | 201 | |
175 | - return hotspots.slice(0, 50); // Return top 50 | |
176 | -} | |
202 | + logger.info( | |
203 | + { repoId: repositoryId, hotspotCount: hotspots.length }, | |
204 | + "Hotspots calculated", | |
205 | + ); | |
177 | 206 | |
178 | -export async function getFileHotspots(repositoryId: string, limit = 20): Promise<FileHotspot[]> { | |
179 | - const db = getDatabase(); | |
207 | + return hotspots.slice(0, 50); // Return top 50 | |
208 | +} | |
180 | 209 | |
181 | - try { | |
182 | - return await db.query.fileHotspots?.findMany({ | |
183 | - where: eq(schema.fileHotspots.repositoryId, repositoryId), | |
184 | - orderBy: (h, { desc }) => [desc(h.complexityScore)], | |
185 | - limit, | |
186 | - }) || []; | |
187 | - } catch { | |
188 | - return []; | |
189 | - } | |
210 | +export async function getFileHotspots( | |
211 | + repositoryId: string, | |
212 | + limit = 20, | |
213 | +): Promise<FileHotspot[]> { | |
214 | + const db = getDatabase(); | |
215 | + | |
216 | + try { | |
217 | + return ( | |
218 | + (await db.query.fileHotspots?.findMany({ | |
219 | + where: eq(schema.fileHotspots.repositoryId, repositoryId), | |
220 | + orderBy: (h, { desc }) => [desc(h.complexityScore)], | |
221 | + limit, | |
222 | + })) || [] | |
223 | + ); | |
224 | + } catch { | |
225 | + return []; | |
226 | + } | |
190 | 227 | } |
191 | 228 | |
192 | 229 | // ============================================================================ |
| @@ -196,76 +233,83 @@ export async function getFileHotspots(repositoryId: string, limit = 20): Promise | ||
196 | 233 | export type ExportFormat = "json" | "csv" | "prometheus"; |
197 | 234 | |
198 | 235 | export interface MetricExportOptions { |
199 | - repositoryId?: string; | |
200 | - organizationId?: string; | |
201 | - metrics: string[]; | |
202 | - startDate: Date; | |
203 | - endDate: Date; | |
204 | - format: ExportFormat; | |
205 | - groupBy?: "day" | "week" | "month"; | |
236 | + repositoryId?: string; | |
237 | + organizationId?: string; | |
238 | + metrics: string[]; | |
239 | + startDate: Date; | |
240 | + endDate: Date; | |
241 | + format: ExportFormat; | |
242 | + groupBy?: "day" | "week" | "month"; | |
206 | 243 | } |
207 | 244 | |
208 | -export async function exportMetrics(options: MetricExportOptions): Promise<string> { | |
209 | - const db = getDatabase(); | |
210 | - | |
211 | - const snapshots = await db.query.metricSnapshots?.findMany({ | |
212 | - where: and( | |
213 | - options.repositoryId | |
214 | - ? eq(schema.metricSnapshots.repositoryId, options.repositoryId) | |
215 | - : undefined, | |
216 | - gte(schema.metricSnapshots.timestamp, options.startDate), | |
217 | - lte(schema.metricSnapshots.timestamp, options.endDate) | |
218 | - ), | |
219 | - orderBy: (m, { asc }) => [asc(m.timestamp)], | |
220 | - }) || []; | |
221 | - | |
222 | - // Filter by metric types | |
223 | - const filtered = snapshots.filter(s => options.metrics.includes(s.metricType)); | |
224 | - | |
225 | - switch (options.format) { | |
226 | - case "json": | |
227 | - return exportAsJSON(filtered); | |
228 | - case "csv": | |
229 | - return exportAsCSV(filtered); | |
230 | - case "prometheus": | |
231 | - return exportAsPrometheus(filtered); | |
232 | - default: | |
233 | - return exportAsJSON(filtered); | |
234 | - } | |
245 | +export async function exportMetrics( | |
246 | + options: MetricExportOptions, | |
247 | +): Promise<string> { | |
248 | + const db = getDatabase(); | |
249 | + | |
250 | + const snapshots = | |
251 | + (await db.query.metricSnapshots?.findMany({ | |
252 | + where: and( | |
253 | + options.repositoryId | |
254 | + ? eq(schema.metricSnapshots.repositoryId, options.repositoryId) | |
255 | + : undefined, | |
256 | + gte(schema.metricSnapshots.timestamp, options.startDate), | |
257 | + lte(schema.metricSnapshots.timestamp, options.endDate), | |
258 | + ), | |
259 | + orderBy: (m, { asc }) => [asc(m.timestamp)], | |
260 | + })) || []; | |
261 | + | |
262 | + // Filter by metric types | |
263 | + const filtered = snapshots.filter((s) => | |
264 | + options.metrics.includes(s.metricType), | |
265 | + ); | |
266 | + | |
267 | + switch (options.format) { | |
268 | + case "json": | |
269 | + return exportAsJSON(filtered); | |
270 | + case "csv": | |
271 | + return exportAsCSV(filtered); | |
272 | + case "prometheus": | |
273 | + return exportAsPrometheus(filtered); | |
274 | + default: | |
275 | + return exportAsJSON(filtered); | |
276 | + } | |
235 | 277 | } |
236 | 278 | |
237 | 279 | function exportAsJSON(snapshots: MetricSnapshot[]): string { |
238 | - return JSON.stringify(snapshots, null, 2); | |
280 | + return JSON.stringify(snapshots, null, 2); | |
239 | 281 | } |
240 | 282 | |
241 | 283 | function exportAsCSV(snapshots: MetricSnapshot[]): string { |
242 | - const headers = ["timestamp", "metricType", "value", "repositoryId"]; | |
243 | - const rows = snapshots.map(s => [ | |
244 | - s.timestamp.toISOString(), | |
245 | - s.metricType, | |
246 | - s.value.toString(), | |
247 | - s.repositoryId || "", | |
248 | - ]); | |
249 | - | |
250 | - return [headers.join(","), ...rows.map(r => r.join(","))].join("\n"); | |
284 | + const headers = ["timestamp", "metricType", "value", "repositoryId"]; | |
285 | + const rows = snapshots.map((s) => [ | |
286 | + s.timestamp.toISOString(), | |
287 | + s.metricType, | |
288 | + s.value.toString(), | |
289 | + s.repositoryId || "", | |
290 | + ]); | |
291 | + | |
292 | + return [headers.join(","), ...rows.map((r) => r.join(","))].join("\n"); | |
251 | 293 | } |
252 | 294 | |
253 | 295 | function exportAsPrometheus(snapshots: MetricSnapshot[]): string { |
254 | - const lines: string[] = []; | |
255 | - | |
256 | - for (const snapshot of snapshots) { | |
257 | - const labels = snapshot.dimensions | |
258 | - ? Object.entries(snapshot.dimensions) | |
259 | - .map(([k, v]) => `${k}="${v}"`) | |
260 | - .join(",") | |
261 | - : ""; | |
262 | - | |
263 | - const metricName = snapshot.metricType.replace(/[^a-zA-Z0-9_]/g, "_"); | |
264 | - lines.push(`# TYPE ${metricName} gauge`); | |
265 | - lines.push(`${metricName}{${labels}} ${snapshot.value} ${snapshot.timestamp.getTime()}`); | |
266 | - } | |
267 | - | |
268 | - return lines.join("\n"); | |
296 | + const lines: string[] = []; | |
297 | + | |
298 | + for (const snapshot of snapshots) { | |
299 | + const labels = snapshot.dimensions | |
300 | + ? Object.entries(snapshot.dimensions) | |
301 | + .map(([k, v]) => `${k}="${v}"`) | |
302 | + .join(",") | |
303 | + : ""; | |
304 | + | |
305 | + const metricName = snapshot.metricType.replace(/[^a-zA-Z0-9_]/g, "_"); | |
306 | + lines.push(`# TYPE ${metricName} gauge`); | |
307 | + lines.push( | |
308 | + `${metricName}{${labels}} ${snapshot.value} ${snapshot.timestamp.getTime()}`, | |
309 | + ); | |
310 | + } | |
311 | + | |
312 | + return lines.join("\n"); | |
269 | 313 | } |
270 | 314 | |
271 | 315 | // ============================================================================ |
| @@ -273,115 +317,116 @@ function exportAsPrometheus(snapshots: MetricSnapshot[]): string { | ||
273 | 317 | // ============================================================================ |
274 | 318 | |
275 | 319 | export async function createDashboard(options: { |
276 | - userId: string; | |
277 | - name: string; | |
278 | - description?: string; | |
279 | - isPublic?: boolean; | |
320 | + userId: string; | |
321 | + name: string; | |
322 | + description?: string; | |
323 | + isPublic?: boolean; | |
280 | 324 | }): Promise<CustomDashboard> { |
281 | - const db = getDatabase(); | |
282 | - | |
283 | - const dashboard = { | |
284 | - id: crypto.randomUUID(), | |
285 | - userId: options.userId, | |
286 | - name: options.name, | |
287 | - description: options.description || null, | |
288 | - isPublic: options.isPublic ?? false, | |
289 | - layout: { columns: 12, rows: 8 }, | |
290 | - createdAt: new Date(), | |
291 | - updatedAt: new Date(), | |
292 | - }; | |
293 | - | |
294 | - // @ts-expect-error - Drizzle multi-db union type issue | |
295 | - await db.insert(schema.customDashboards).values(dashboard); | |
296 | - | |
297 | - return dashboard as CustomDashboard; | |
325 | + const db = getDatabase(); | |
326 | + | |
327 | + const dashboard = { | |
328 | + id: crypto.randomUUID(), | |
329 | + userId: options.userId, | |
330 | + name: options.name, | |
331 | + description: options.description || null, | |
332 | + isPublic: options.isPublic ?? false, | |
333 | + layout: { columns: 12, rows: 8 }, | |
334 | + createdAt: new Date(), | |
335 | + updatedAt: new Date(), | |
336 | + }; | |
337 | + | |
338 | + // @ts-expect-error - Drizzle multi-db union type issue | |
339 | + await db.insert(schema.customDashboards).values(dashboard); | |
340 | + | |
341 | + return dashboard as CustomDashboard; | |
298 | 342 | } |
299 | 343 | |
300 | 344 | export async function addWidget(options: { |
301 | - dashboardId: string; | |
302 | - widgetType: string; | |
303 | - title: string; | |
304 | - config?: WidgetConfig; | |
305 | - position?: { x: number; y: number; w: number; h: number }; | |
345 | + dashboardId: string; | |
346 | + widgetType: string; | |
347 | + title: string; | |
348 | + config?: WidgetConfig; | |
349 | + position?: { x: number; y: number; w: number; h: number }; | |
306 | 350 | }): Promise<typeof dashboardWidgets.$inferSelect> { |
307 | - const db = getDatabase(); | |
308 | - | |
309 | - const widget = { | |
310 | - id: crypto.randomUUID(), | |
311 | - dashboardId: options.dashboardId, | |
312 | - widgetType: options.widgetType, | |
313 | - title: options.title, | |
314 | - config: options.config || {}, | |
315 | - position: options.position || { x: 0, y: 0, w: 4, h: 2 }, | |
316 | - createdAt: new Date(), | |
317 | - }; | |
318 | - | |
319 | - // @ts-expect-error - Drizzle multi-db union type issue | |
320 | - await db.insert(schema.dashboardWidgets).values(widget); | |
321 | - | |
322 | - return widget; | |
351 | + const db = getDatabase(); | |
352 | + | |
353 | + const widget = { | |
354 | + id: crypto.randomUUID(), | |
355 | + dashboardId: options.dashboardId, | |
356 | + widgetType: options.widgetType, | |
357 | + title: options.title, | |
358 | + config: options.config || {}, | |
359 | + position: options.position || { x: 0, y: 0, w: 4, h: 2 }, | |
360 | + createdAt: new Date(), | |
361 | + }; | |
362 | + | |
363 | + // @ts-expect-error - Drizzle multi-db union type issue | |
364 | + await db.insert(schema.dashboardWidgets).values(widget); | |
365 | + | |
366 | + return widget; | |
323 | 367 | } |
324 | 368 | |
325 | 369 | export const WIDGET_TYPES = { |
326 | - pr_velocity: { | |
327 | - name: "PR Velocity", | |
328 | - description: "PRs merged over time", | |
329 | - defaultConfig: { timeRange: "30d" }, | |
330 | - }, | |
331 | - review_time: { | |
332 | - name: "Review Time", | |
333 | - description: "Average time to first review", | |
334 | - defaultConfig: { timeRange: "30d" }, | |
335 | - }, | |
336 | - cycle_time: { | |
337 | - name: "Cycle Time", | |
338 | - description: "Time from PR open to merge", | |
339 | - defaultConfig: { timeRange: "30d" }, | |
340 | - }, | |
341 | - hotspot_files: { | |
342 | - name: "Hotspot Files", | |
343 | - description: "Most frequently changed files", | |
344 | - defaultConfig: { limit: 10 }, | |
345 | - }, | |
346 | - team_workload: { | |
347 | - name: "Team Workload", | |
348 | - description: "Open PRs per team member", | |
349 | - defaultConfig: {}, | |
350 | - }, | |
351 | - ci_success_rate: { | |
352 | - name: "CI Success Rate", | |
353 | - description: "Percentage of passing builds", | |
354 | - defaultConfig: { timeRange: "7d" }, | |
355 | - }, | |
356 | - deploy_frequency: { | |
357 | - name: "Deploy Frequency", | |
358 | - description: "Deployments per day/week", | |
359 | - defaultConfig: { timeRange: "30d" }, | |
360 | - }, | |
361 | - issue_burndown: { | |
362 | - name: "Issue Burndown", | |
363 | - description: "Issues opened vs closed", | |
364 | - defaultConfig: { timeRange: "30d" }, | |
365 | - }, | |
370 | + pr_velocity: { | |
371 | + name: "PR Velocity", | |
372 | + description: "PRs merged over time", | |
373 | + defaultConfig: { timeRange: "30d" }, | |
374 | + }, | |
375 | + review_time: { | |
376 | + name: "Review Time", | |
377 | + description: "Average time to first review", | |
378 | + defaultConfig: { timeRange: "30d" }, | |
379 | + }, | |
380 | + cycle_time: { | |
381 | + name: "Cycle Time", | |
382 | + description: "Time from PR open to merge", | |
383 | + defaultConfig: { timeRange: "30d" }, | |
384 | + }, | |
385 | + hotspot_files: { | |
386 | + name: "Hotspot Files", | |
387 | + description: "Most frequently changed files", | |
388 | + defaultConfig: { limit: 10 }, | |
389 | + }, | |
390 | + team_workload: { | |
391 | + name: "Team Workload", | |
392 | + description: "Open PRs per team member", | |
393 | + defaultConfig: {}, | |
394 | + }, | |
395 | + ci_success_rate: { | |
396 | + name: "CI Success Rate", | |
397 | + description: "Percentage of passing builds", | |
398 | + defaultConfig: { timeRange: "7d" }, | |
399 | + }, | |
400 | + deploy_frequency: { | |
401 | + name: "Deploy Frequency", | |
402 | + description: "Deployments per day/week", | |
403 | + defaultConfig: { timeRange: "30d" }, | |
404 | + }, | |
405 | + issue_burndown: { | |
406 | + name: "Issue Burndown", | |
407 | + description: "Issues opened vs closed", | |
408 | + defaultConfig: { timeRange: "30d" }, | |
409 | + }, | |
366 | 410 | } as const; |
367 | 411 | |
368 | 412 | export async function getDashboard(dashboardId: string): Promise<{ |
369 | - dashboard: CustomDashboard; | |
370 | - widgets: (typeof dashboardWidgets.$inferSelect)[]; | |
413 | + dashboard: CustomDashboard; | |
414 | + widgets: (typeof dashboardWidgets.$inferSelect)[]; | |
371 | 415 | } | null> { |
372 | - const db = getDatabase(); | |
416 | + const db = getDatabase(); | |
373 | 417 | |
374 | - const dashboard = await db.query.customDashboards?.findFirst({ | |
375 | - where: eq(schema.customDashboards.id, dashboardId), | |
376 | - }); | |
418 | + const dashboard = await db.query.customDashboards?.findFirst({ | |
419 | + where: eq(schema.customDashboards.id, dashboardId), | |
420 | + }); | |
377 | 421 | |
378 | - if (!dashboard) return null; | |
422 | + if (!dashboard) return null; | |
379 | 423 | |
380 | - const widgets = await db.query.dashboardWidgets?.findMany({ | |
381 | - where: eq(schema.dashboardWidgets.dashboardId, dashboardId), | |
382 | - }) || []; | |
424 | + const widgets = | |
425 | + (await db.query.dashboardWidgets?.findMany({ | |
426 | + where: eq(schema.dashboardWidgets.dashboardId, dashboardId), | |
427 | + })) || []; | |
383 | 428 | |
384 | - return { dashboard, widgets }; | |
429 | + return { dashboard, widgets }; | |
385 | 430 | } |
386 | 431 | |
387 | 432 | // ============================================================================ |
| @@ -389,51 +434,52 @@ export async function getDashboard(dashboardId: string): Promise<{ | ||
389 | 434 | // ============================================================================ |
390 | 435 | |
391 | 436 | export async function recordMetric(options: { |
392 | - repositoryId?: string; | |
393 | - organizationId?: string; | |
394 | - metricType: string; | |
395 | - value: number; | |
396 | - dimensions?: Record<string, string>; | |
437 | + repositoryId?: string; | |
438 | + organizationId?: string; | |
439 | + metricType: string; | |
440 | + value: number; | |
441 | + dimensions?: Record<string, string>; | |
397 | 442 | }): Promise<void> { |
398 | - const db = getDatabase(); | |
399 | - | |
400 | - // @ts-expect-error - Drizzle multi-db union type issue | |
401 | - await db.insert(schema.metricSnapshots).values({ | |
402 | - id: crypto.randomUUID(), | |
403 | - repositoryId: options.repositoryId || null, | |
404 | - organizationId: options.organizationId || null, | |
405 | - metricType: options.metricType, | |
406 | - value: options.value, | |
407 | - dimensions: options.dimensions || null, | |
408 | - timestamp: new Date(), | |
409 | - }); | |
443 | + const db = getDatabase(); | |
444 | + | |
445 | + // @ts-expect-error - Drizzle multi-db union type issue | |
446 | + await db.insert(schema.metricSnapshots).values({ | |
447 | + id: crypto.randomUUID(), | |
448 | + repositoryId: options.repositoryId || null, | |
449 | + organizationId: options.organizationId || null, | |
450 | + metricType: options.metricType, | |
451 | + value: options.value, | |
452 | + dimensions: options.dimensions || null, | |
453 | + timestamp: new Date(), | |
454 | + }); | |
410 | 455 | } |
411 | 456 | |
412 | 457 | export async function getMetricTimeSeries(options: { |
413 | - repositoryId?: string; | |
414 | - metricType: string; | |
415 | - startDate: Date; | |
416 | - endDate: Date; | |
417 | - granularity?: "hour" | "day" | "week"; | |
458 | + repositoryId?: string; | |
459 | + metricType: string; | |
460 | + startDate: Date; | |
461 | + endDate: Date; | |
462 | + granularity?: "hour" | "day" | "week"; | |
418 | 463 | }): Promise<{ timestamp: Date; value: number }[]> { |
419 | - const db = getDatabase(); | |
420 | - | |
421 | - const snapshots = await db.query.metricSnapshots?.findMany({ | |
422 | - where: and( | |
423 | - options.repositoryId | |
424 | - ? eq(schema.metricSnapshots.repositoryId, options.repositoryId) | |
425 | - : undefined, | |
426 | - eq(schema.metricSnapshots.metricType, options.metricType), | |
427 | - gte(schema.metricSnapshots.timestamp, options.startDate), | |
428 | - lte(schema.metricSnapshots.timestamp, options.endDate) | |
429 | - ), | |
430 | - orderBy: (m, { asc }) => [asc(m.timestamp)], | |
431 | - }) || []; | |
432 | - | |
433 | - return snapshots.map(s => ({ | |
434 | - timestamp: s.timestamp, | |
435 | - value: s.value, | |
436 | - })); | |
464 | + const db = getDatabase(); | |
465 | + | |
466 | + const snapshots = | |
467 | + (await db.query.metricSnapshots?.findMany({ | |
468 | + where: and( | |
469 | + options.repositoryId | |
470 | + ? eq(schema.metricSnapshots.repositoryId, options.repositoryId) | |
471 | + : undefined, | |
472 | + eq(schema.metricSnapshots.metricType, options.metricType), | |
473 | + gte(schema.metricSnapshots.timestamp, options.startDate), | |
474 | + lte(schema.metricSnapshots.timestamp, options.endDate), | |
475 | + ), | |
476 | + orderBy: (m, { asc }) => [asc(m.timestamp)], | |
477 | + })) || []; | |
478 | + | |
479 | + return snapshots.map((s) => ({ | |
480 | + timestamp: s.timestamp, | |
481 | + value: s.value, | |
482 | + })); | |
437 | 483 | } |
438 | 484 | |
439 | 485 | // ============================================================================ |
| @@ -441,55 +487,342 @@ export async function getMetricTimeSeries(options: { | ||
441 | 487 | // ============================================================================ |
442 | 488 | |
443 | 489 | export async function getDORAMetrics(options: { |
444 | - repositoryId?: string; | |
445 | - organizationId?: string; | |
446 | - startDate: Date; | |
447 | - endDate: Date; | |
490 | + repositoryId?: string; | |
491 | + organizationId?: string; | |
492 | + startDate: Date; | |
493 | + endDate: Date; | |
448 | 494 | }): Promise<{ |
449 | - deploymentFrequency: number; // per day | |
450 | - leadTimeForChanges: number; // hours | |
451 | - changeFailureRate: number; // percentage | |
452 | - timeToRestoreService: number; // hours | |
495 | + deploymentFrequency: number; // per day | |
496 | + leadTimeForChanges: number; // hours | |
497 | + changeFailureRate: number; // percentage | |
498 | + timeToRestoreService: number; // hours | |
453 | 499 | }> { |
454 | - const db = getDatabase(); | |
500 | + const db = getDatabase(); | |
501 | + | |
502 | + // Get deployments | |
503 | + const deployments = | |
504 | + (await db.query.deployments?.findMany({ | |
505 | + where: and( | |
506 | + gte(schema.deployments.createdAt, options.startDate), | |
507 | + lte(schema.deployments.createdAt, options.endDate), | |
508 | + ), | |
509 | + })) || []; | |
510 | + | |
511 | + const daysDiff = | |
512 | + (options.endDate.getTime() - options.startDate.getTime()) / | |
513 | + (1000 * 60 * 60 * 24); | |
514 | + | |
515 | + // Deployment Frequency | |
516 | + const deploymentFrequency = deployments.length / daysDiff; | |
517 | + | |
518 | + // Lead Time for Changes (time from commit to deploy) | |
519 | + const leadTimes = deployments | |
520 | + .filter((d) => d.startedAt && d.completedAt) | |
521 | + .map( | |
522 | + (d) => | |
523 | + (d.completedAt!.getTime() - d.startedAt!.getTime()) / (1000 * 60 * 60), | |
524 | + ); | |
525 | + | |
526 | + const leadTimeForChanges = | |
527 | + leadTimes.length > 0 | |
528 | + ? leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length | |
529 | + : 0; | |
530 | + | |
531 | + // Change Failure Rate | |
532 | + const failedDeploys = deployments.filter((d) => d.status === "failed").length; | |
533 | + const changeFailureRate = | |
534 | + deployments.length > 0 ? (failedDeploys / deployments.length) * 100 : 0; | |
535 | + | |
536 | + // Time to Restore (time between failure and next success) | |
537 | + let timeToRestoreService = 0; | |
538 | + const sortedDeploys = deployments | |
539 | + .filter((d) => d.completedAt) | |
540 | + .sort((a, b) => a.completedAt!.getTime() - b.completedAt!.getTime()); | |
541 | + | |
542 | + const restorationTimes: number[] = []; | |
543 | + for (let i = 0; i < sortedDeploys.length; i++) { | |
544 | + if (sortedDeploys[i].status === "failed") { | |
545 | + // Find next successful deployment after this failure | |
546 | + for (let j = i + 1; j < sortedDeploys.length; j++) { | |
547 | + if ( | |
548 | + sortedDeploys[j].status === "success" || | |
549 | + sortedDeploys[j].status === "completed" | |
550 | + ) { | |
551 | + const restoreHours = | |
552 | + (sortedDeploys[j].completedAt!.getTime() - | |
553 | + sortedDeploys[i].completedAt!.getTime()) / | |
554 | + (1000 * 60 * 60); | |
555 | + restorationTimes.push(restoreHours); | |
556 | + break; | |
557 | + } | |
558 | + } | |
559 | + } | |
560 | + } | |
561 | + if (restorationTimes.length > 0) { | |
562 | + timeToRestoreService = | |
563 | + restorationTimes.reduce((a, b) => a + b, 0) / restorationTimes.length; | |
564 | + } | |
565 | + | |
566 | + return { | |
567 | + deploymentFrequency, | |
568 | + leadTimeForChanges, | |
569 | + changeFailureRate, | |
570 | + timeToRestoreService, | |
571 | + }; | |
572 | +} | |
455 | 573 | |
456 | - // Get deployments | |
457 | - const deployments = await db.query.deployments?.findMany({ | |
458 | - where: and( | |
459 | - gte(schema.deployments.createdAt, options.startDate), | |
460 | - lte(schema.deployments.createdAt, options.endDate) | |
461 | - ), | |
462 | - }) || []; | |
574 | +/** | |
575 | + * PR Cycle Time breakdown — time spent in each phase | |
576 | + */ | |
577 | +export async function getPRCycleTime(options: { | |
578 | + repositoryId: string; | |
579 | + startDate: Date; | |
580 | + endDate: Date; | |
581 | +}): Promise<{ | |
582 | + avgDraftHours: number; | |
583 | + avgReviewHours: number; | |
584 | + avgApprovalToMergeHours: number; | |
585 | + avgTotalHours: number; | |
586 | + prCount: number; | |
587 | +}> { | |
588 | + const db = getDatabase(); | |
589 | + | |
590 | + const mergedPrs = | |
591 | + (await db.query.pullRequests.findMany({ | |
592 | + where: and( | |
593 | + eq(schema.pullRequests.repositoryId, options.repositoryId), | |
594 | + eq(schema.pullRequests.isMerged, true), | |
595 | + gte(schema.pullRequests.mergedAt, options.startDate), | |
596 | + lte(schema.pullRequests.mergedAt, options.endDate), | |
597 | + ), | |
598 | + })) || []; | |
599 | + | |
600 | + if (mergedPrs.length === 0) { | |
601 | + return { | |
602 | + avgDraftHours: 0, | |
603 | + avgReviewHours: 0, | |
604 | + avgApprovalToMergeHours: 0, | |
605 | + avgTotalHours: 0, | |
606 | + prCount: 0, | |
607 | + }; | |
608 | + } | |
609 | + | |
610 | + let totalDraft = 0, | |
611 | + totalReview = 0, | |
612 | + totalApprovalToMerge = 0, | |
613 | + totalTotal = 0; | |
614 | + let counted = 0; | |
615 | + | |
616 | + for (const pr of mergedPrs) { | |
617 | + const created = pr.createdAt?.getTime() || 0; | |
618 | + const merged = pr.mergedAt?.getTime() || 0; | |
619 | + if (!created || !merged) continue; | |
620 | + | |
621 | + const totalHours = (merged - created) / (1000 * 60 * 60); | |
622 | + totalTotal += totalHours; | |
623 | + | |
624 | + // Get first review for this PR | |
625 | + const firstReview = await db.query.pullRequestReviews.findFirst({ | |
626 | + where: eq(schema.pullRequestReviews.pullRequestId, pr.id), | |
627 | + orderBy: [asc(schema.pullRequestReviews.submittedAt)], | |
628 | + }); | |
463 | 629 | |
464 | - const daysDiff = (options.endDate.getTime() - options.startDate.getTime()) / (1000 * 60 * 60 * 24); | |
630 | + if (firstReview?.submittedAt) { | |
631 | + const reviewTime = firstReview.submittedAt.getTime(); | |
632 | + totalDraft += (reviewTime - created) / (1000 * 60 * 60); | |
633 | + totalReview += (reviewTime - created) / (1000 * 60 * 60); | |
634 | + } | |
465 | 635 | |
466 | - // Deployment Frequency | |
467 | - const deploymentFrequency = deployments.length / daysDiff; | |
636 | + // Get approval time | |
637 | + const approval = await db.query.pullRequestReviews.findFirst({ | |
638 | + where: and( | |
639 | + eq(schema.pullRequestReviews.pullRequestId, pr.id), | |
640 | + eq(schema.pullRequestReviews.state, "approved"), | |
641 | + ), | |
642 | + orderBy: [desc(schema.pullRequestReviews.submittedAt)], | |
643 | + }); | |
468 | 644 | |
469 | - // Lead Time for Changes (time from commit to deploy) | |
470 | - const leadTimes = deployments | |
471 | - .filter(d => d.startedAt && d.completedAt) | |
472 | - .map(d => (d.completedAt!.getTime() - d.startedAt!.getTime()) / (1000 * 60 * 60)); | |
645 | + if (approval?.submittedAt) { | |
646 | + totalApprovalToMerge += | |
647 | + (merged - approval.submittedAt.getTime()) / (1000 * 60 * 60); | |
648 | + } | |
473 | 649 | |
474 | - const leadTimeForChanges = leadTimes.length > 0 | |
475 | - ? leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length | |
476 | - : 0; | |
650 | + counted++; | |
651 | + } | |
652 | + | |
653 | + return { | |
654 | + avgDraftHours: | |
655 | + counted > 0 ? Math.round((totalDraft / counted) * 10) / 10 : 0, | |
656 | + avgReviewHours: | |
657 | + counted > 0 ? Math.round((totalReview / counted) * 10) / 10 : 0, | |
658 | + avgApprovalToMergeHours: | |
659 | + counted > 0 ? Math.round((totalApprovalToMerge / counted) * 10) / 10 : 0, | |
660 | + avgTotalHours: | |
661 | + counted > 0 ? Math.round((totalTotal / counted) * 10) / 10 : 0, | |
662 | + prCount: counted, | |
663 | + }; | |
664 | +} | |
477 | 665 | |
478 | - // Change Failure Rate | |
479 | - const failedDeploys = deployments.filter(d => d.status === "failed").length; | |
480 | - const changeFailureRate = deployments.length > 0 | |
481 | - ? (failedDeploys / deployments.length) * 100 | |
482 | - : 0; | |
666 | +/** | |
667 | + * Reviewer response time — identify bottleneck reviewers | |
668 | + */ | |
669 | +export async function getReviewerResponseTimes(options: { | |
670 | + repositoryId: string; | |
671 | + startDate: Date; | |
672 | + endDate: Date; | |
673 | +}): Promise< | |
674 | + Array<{ | |
675 | + userId: string; | |
676 | + username: string; | |
677 | + avgResponseHours: number; | |
678 | + reviewCount: number; | |
679 | + }> | |
680 | +> { | |
681 | + const db = getDatabase(); | |
682 | + | |
683 | + const reviews = | |
684 | + (await db.query.pullRequestReviews.findMany({ | |
685 | + where: and( | |
686 | + gte(schema.pullRequestReviews.submittedAt, options.startDate), | |
687 | + lte(schema.pullRequestReviews.submittedAt, options.endDate), | |
688 | + ), | |
689 | + })) || []; | |
690 | + | |
691 | + // Group reviews by reviewer | |
692 | + const reviewerMap = new Map<string, { totalHours: number; count: number }>(); | |
693 | + | |
694 | + for (const review of reviews) { | |
695 | + if (!review.submittedAt) continue; | |
696 | + | |
697 | + // Get the PR to calculate response time | |
698 | + const pr = await db.query.pullRequests.findFirst({ | |
699 | + where: eq(schema.pullRequests.id, review.pullRequestId), | |
700 | + }); | |
483 | 701 | |
484 | - // Time to Restore (time between failure and next success) | |
485 | - const timeToRestoreService = 0; // Would need incident tracking | |
702 | + if (!pr || pr.repositoryId !== options.repositoryId) continue; | |
486 | 703 | |
487 | - return { | |
488 | - deploymentFrequency, | |
489 | - leadTimeForChanges, | |
490 | - changeFailureRate, | |
491 | - timeToRestoreService, | |
704 | + // Check if this was a requested review | |
705 | + const requested = await db.query.pullRequestReviewers.findFirst({ | |
706 | + where: and( | |
707 | + eq(schema.pullRequestReviewers.pullRequestId, review.pullRequestId), | |
708 | + eq(schema.pullRequestReviewers.userId, review.reviewerId), | |
709 | + ), | |
710 | + }); | |
711 | + | |
712 | + const requestTime = | |
713 | + requested?.requestedAt?.getTime() || pr.createdAt?.getTime() || 0; | |
714 | + const responseHours = | |
715 | + (review.submittedAt.getTime() - requestTime) / (1000 * 60 * 60); | |
716 | + | |
717 | + const existing = reviewerMap.get(review.reviewerId) || { | |
718 | + totalHours: 0, | |
719 | + count: 0, | |
492 | 720 | }; |
721 | + existing.totalHours += Math.max(0, responseHours); | |
722 | + existing.count++; | |
723 | + reviewerMap.set(review.reviewerId, existing); | |
724 | + } | |
725 | + | |
726 | + // Fetch usernames | |
727 | + const userIds = [...reviewerMap.keys()]; | |
728 | + const users = | |
729 | + userIds.length > 0 | |
730 | + ? await db.query.users.findMany({ | |
731 | + where: inArray(schema.users.id, userIds), | |
732 | + }) | |
733 | + : []; | |
734 | + const userMap = new Map(users.map((u) => [u.id, u])); | |
735 | + | |
736 | + return [...reviewerMap.entries()] | |
737 | + .map(([userId, data]) => ({ | |
738 | + userId, | |
739 | + username: userMap.get(userId)?.username || "unknown", | |
740 | + avgResponseHours: Math.round((data.totalHours / data.count) * 10) / 10, | |
741 | + reviewCount: data.count, | |
742 | + })) | |
743 | + .sort((a, b) => b.avgResponseHours - a.avgResponseHours); | |
744 | +} | |
745 | + | |
746 | +/** | |
747 | + * Code churn — files modified most frequently | |
748 | + */ | |
749 | +export async function getCodeChurn(options: { | |
750 | + repositoryId: string; | |
751 | + startDate: Date; | |
752 | + endDate: Date; | |
753 | + limit?: number; | |
754 | +}): Promise<Array<{ file: string; changeCount: number; authors: string[] }>> { | |
755 | + const db = getDatabase(); | |
756 | + const limit = options.limit || 20; | |
757 | + | |
758 | + // Get merged PRs in the time range | |
759 | + const prs = | |
760 | + (await db.query.pullRequests.findMany({ | |
761 | + where: and( | |
762 | + eq(schema.pullRequests.repositoryId, options.repositoryId), | |
763 | + eq(schema.pullRequests.isMerged, true), | |
764 | + gte(schema.pullRequests.mergedAt, options.startDate), | |
765 | + lte(schema.pullRequests.mergedAt, options.endDate), | |
766 | + ), | |
767 | + })) || []; | |
768 | + | |
769 | + // Get changed files from each PR via git | |
770 | + const fileChanges = new Map< | |
771 | + string, | |
772 | + { count: number; authors: Set<string> } | |
773 | + >(); | |
774 | + | |
775 | + const repo = await db.query.repositories.findFirst({ | |
776 | + where: eq(schema.repositories.id, options.repositoryId), | |
777 | + with: { owner: true }, | |
778 | + }); | |
779 | + | |
780 | + if (repo && prs.length > 0) { | |
781 | + try { | |
782 | + const { simpleGit } = await import("simple-git"); | |
783 | + const { acquireRepo, releaseRepo } = await import("./git-storage"); | |
784 | + const repoPath = await acquireRepo(repo.owner.username, repo.name); | |
785 | + const git = simpleGit(repoPath); | |
786 | + | |
787 | + for (const pr of prs.slice(0, 100)) { | |
788 | + // Cap at 100 PRs for perf | |
789 | + try { | |
790 | + const author = await db.query.users.findFirst({ | |
791 | + where: eq(schema.users.id, pr.authorId), | |
792 | + }); | |
793 | + | |
794 | + const diffSummary = await git.diffSummary([ | |
795 | + `${pr.baseBranch}...${pr.mergeSha || pr.headSha || "HEAD"}`, | |
796 | + ]); | |
797 | + | |
798 | + for (const file of diffSummary.files) { | |
799 | + const existing = fileChanges.get(file.file) || { | |
800 | + count: 0, | |
801 | + authors: new Set<string>(), | |
802 | + }; | |
803 | + existing.count++; | |
804 | + if (author?.username) existing.authors.add(author.username); | |
805 | + fileChanges.set(file.file, existing); | |
806 | + } | |
807 | + } catch { | |
808 | + /* skip individual PR errors */ | |
809 | + } | |
810 | + } | |
811 | + | |
812 | + await releaseRepo(repo.owner.username, repo.name, false); | |
813 | + } catch { | |
814 | + /* repo access error */ | |
815 | + } | |
816 | + } | |
817 | + | |
818 | + return [...fileChanges.entries()] | |
819 | + .map(([file, data]) => ({ | |
820 | + file, | |
821 | + changeCount: data.count, | |
822 | + authors: [...data.authors], | |
823 | + })) | |
824 | + .sort((a, b) => b.changeCount - a.changeCount) | |
825 | + .slice(0, limit); | |
493 | 826 | } |
494 | 827 | |
495 | 828 | // ============================================================================ |
| @@ -497,199 +830,227 @@ export async function getDORAMetrics(options: { | ||
497 | 830 | // ============================================================================ |
498 | 831 | |
499 | 832 | export interface DeveloperWorkload { |
500 | - userId: string; | |
501 | - userName: string; | |
502 | - openPRs: number; | |
503 | - pendingReviews: number; | |
504 | - assignedIssues: number; | |
505 | - recentCommits: number; | |
506 | - avgReviewTime: number; // hours | |
507 | - workloadScore: number; // 0-100 | |
508 | - trend: "increasing" | "stable" | "decreasing"; | |
833 | + userId: string; | |
834 | + userName: string; | |
835 | + openPRs: number; | |
836 | + pendingReviews: number; | |
837 | + assignedIssues: number; | |
838 | + recentCommits: number; | |
839 | + avgReviewTime: number; // hours | |
840 | + workloadScore: number; // 0-100 | |
841 | + trend: "increasing" | "stable" | "decreasing"; | |
509 | 842 | } |
510 | 843 | |
511 | 844 | export async function getDeveloperWorkloads(options: { |
512 | - repositoryId?: string; | |
513 | - organizationId?: string; | |
514 | - days?: number; | |
845 | + repositoryId?: string; | |
846 | + organizationId?: string; | |
847 | + days?: number; | |
515 | 848 | }): Promise<DeveloperWorkload[]> { |
516 | - const db = getDatabase(); | |
517 | - const startDate = new Date(Date.now() - (options.days || 30) * 24 * 60 * 60 * 1000); | |
518 | - | |
519 | - // Get all users | |
520 | - const users = await db.query.users.findMany({}) || []; | |
521 | - | |
522 | - const workloads: DeveloperWorkload[] = []; | |
523 | - | |
524 | - for (const user of users) { | |
525 | - // Count open PRs authored (optionally repository-scoped) | |
526 | - const openPrConditions = [ | |
527 | - eq(schema.pullRequests.authorId, user.id), | |
528 | - eq(schema.pullRequests.state, "open"), | |
529 | - ]; | |
530 | - if (options.repositoryId) { | |
531 | - openPrConditions.push(eq(schema.pullRequests.repositoryId, options.repositoryId)); | |
532 | - } | |
533 | - const openPRs = await db.query.pullRequests.findMany({ | |
534 | - where: and(...openPrConditions), | |
535 | - }) || []; | |
536 | - | |
537 | - // Count pending reviews requested (optionally repository-scoped) | |
538 | - const pendingReviewRequests = await db.query.pullRequestReviewers?.findMany({ | |
539 | - where: eq(schema.pullRequestReviewers.userId, user.id), | |
540 | - }) || []; | |
541 | - let pendingReviews = pendingReviewRequests; | |
542 | - if (options.repositoryId && pendingReviewRequests.length > 0) { | |
543 | - const pullRequestIds = pendingReviewRequests.map((r) => r.pullRequestId); | |
544 | - const repoPullRequests = await db.query.pullRequests.findMany({ | |
545 | - where: and( | |
546 | - inArray(schema.pullRequests.id, pullRequestIds), | |
547 | - eq(schema.pullRequests.repositoryId, options.repositoryId) | |
548 | - ), | |
549 | - columns: { id: true }, | |
550 | - }); | |
551 | - const repoPullRequestIds = new Set(repoPullRequests.map((pr) => pr.id)); | |
552 | - pendingReviews = pendingReviewRequests.filter((r) => repoPullRequestIds.has(r.pullRequestId)); | |
553 | - } | |
554 | - | |
555 | - // Count assigned issues (optionally repository-scoped) | |
556 | - const assignedIssueLinks = await db.query.issueAssignees?.findMany({ | |
557 | - where: eq(schema.issueAssignees.userId, user.id), | |
558 | - }) || []; | |
559 | - let assignedIssues = assignedIssueLinks; | |
560 | - if (options.repositoryId && assignedIssueLinks.length > 0) { | |
561 | - const issueIds = assignedIssueLinks.map((i) => i.issueId); | |
562 | - const repoIssues = await db.query.issues.findMany({ | |
563 | - where: and( | |
564 | - inArray(schema.issues.id, issueIds), | |
565 | - eq(schema.issues.repositoryId, options.repositoryId) | |
566 | - ), | |
567 | - columns: { id: true }, | |
568 | - }); | |
569 | - const repoIssueIds = new Set(repoIssues.map((issue) => issue.id)); | |
570 | - assignedIssues = assignedIssueLinks.filter((i) => repoIssueIds.has(i.issueId)); | |
571 | - } | |
849 | + const db = getDatabase(); | |
850 | + const startDate = new Date( | |
851 | + Date.now() - (options.days || 30) * 24 * 60 * 60 * 1000, | |
852 | + ); | |
853 | + | |
854 | + // Get all users | |
855 | + const users = (await db.query.users.findMany({})) || []; | |
856 | + | |
857 | + const workloads: DeveloperWorkload[] = []; | |
858 | + | |
859 | + for (const user of users) { | |
860 | + // Count open PRs authored (optionally repository-scoped) | |
861 | + const openPrConditions = [ | |
862 | + eq(schema.pullRequests.authorId, user.id), | |
863 | + eq(schema.pullRequests.state, "open"), | |
864 | + ]; | |
865 | + if (options.repositoryId) { | |
866 | + openPrConditions.push( | |
867 | + eq(schema.pullRequests.repositoryId, options.repositoryId), | |
868 | + ); | |
869 | + } | |
870 | + const openPRs = | |
871 | + (await db.query.pullRequests.findMany({ | |
872 | + where: and(...openPrConditions), | |
873 | + })) || []; | |
874 | + | |
875 | + // Count pending reviews requested (optionally repository-scoped) | |
876 | + const pendingReviewRequests = | |
877 | + (await db.query.pullRequestReviewers?.findMany({ | |
878 | + where: eq(schema.pullRequestReviewers.userId, user.id), | |
879 | + })) || []; | |
880 | + let pendingReviews = pendingReviewRequests; | |
881 | + if (options.repositoryId && pendingReviewRequests.length > 0) { | |
882 | + const pullRequestIds = pendingReviewRequests.map((r) => r.pullRequestId); | |
883 | + const repoPullRequests = await db.query.pullRequests.findMany({ | |
884 | + where: and( | |
885 | + inArray(schema.pullRequests.id, pullRequestIds), | |
886 | + eq(schema.pullRequests.repositoryId, options.repositoryId), | |
887 | + ), | |
888 | + columns: { id: true }, | |
889 | + }); | |
890 | + const repoPullRequestIds = new Set(repoPullRequests.map((pr) => pr.id)); | |
891 | + pendingReviews = pendingReviewRequests.filter((r) => | |
892 | + repoPullRequestIds.has(r.pullRequestId), | |
893 | + ); | |
894 | + } | |
572 | 895 | |
573 | - // Calculate workload score (weighted) | |
574 | - const workloadScore = Math.min(100, | |
575 | - openPRs.length * 10 + | |
576 | - pendingReviews.length * 15 + | |
577 | - assignedIssues.length * 5 | |
578 | - ); | |
579 | - | |
580 | - // Determine trend (compare to previous period) | |
581 | - const trend = workloadScore > 70 ? "increasing" : | |
582 | - workloadScore < 30 ? "decreasing" : "stable"; | |
583 | - | |
584 | - workloads.push({ | |
585 | - userId: user.id, | |
586 | - userName: user.displayName || user.username, | |
587 | - openPRs: openPRs.length, | |
588 | - pendingReviews: pendingReviews.length, | |
589 | - assignedIssues: assignedIssues.length, | |
590 | - recentCommits: 0, // Would need commit data | |
591 | - avgReviewTime: 0, // Would need review timing data | |
592 | - workloadScore, | |
593 | - trend, | |
594 | - }); | |
896 | + // Count assigned issues (optionally repository-scoped) | |
897 | + const assignedIssueLinks = | |
898 | + (await db.query.issueAssignees?.findMany({ | |
899 | + where: eq(schema.issueAssignees.userId, user.id), | |
900 | + })) || []; | |
901 | + let assignedIssues = assignedIssueLinks; | |
902 | + if (options.repositoryId && assignedIssueLinks.length > 0) { | |
903 | + const issueIds = assignedIssueLinks.map((i) => i.issueId); | |
904 | + const repoIssues = await db.query.issues.findMany({ | |
905 | + where: and( | |
906 | + inArray(schema.issues.id, issueIds), | |
907 | + eq(schema.issues.repositoryId, options.repositoryId), | |
908 | + ), | |
909 | + columns: { id: true }, | |
910 | + }); | |
911 | + const repoIssueIds = new Set(repoIssues.map((issue) => issue.id)); | |
912 | + assignedIssues = assignedIssueLinks.filter((i) => | |
913 | + repoIssueIds.has(i.issueId), | |
914 | + ); | |
595 | 915 | } |
596 | 916 | |
597 | - // Sort by workload score descending | |
598 | - workloads.sort((a, b) => b.workloadScore - a.workloadScore); | |
917 | + // Calculate workload score (weighted) | |
918 | + const workloadScore = Math.min( | |
919 | + 100, | |
920 | + openPRs.length * 10 + | |
921 | + pendingReviews.length * 15 + | |
922 | + assignedIssues.length * 5, | |
923 | + ); | |
924 | + | |
925 | + // Determine trend (compare to previous period) | |
926 | + const trend = | |
927 | + workloadScore > 70 | |
928 | + ? "increasing" | |
929 | + : workloadScore < 30 | |
930 | + ? "decreasing" | |
931 | + : "stable"; | |
932 | + | |
933 | + workloads.push({ | |
934 | + userId: user.id, | |
935 | + userName: user.displayName || user.username, | |
936 | + openPRs: openPRs.length, | |
937 | + pendingReviews: pendingReviews.length, | |
938 | + assignedIssues: assignedIssues.length, | |
939 | + recentCommits: 0, // Would need commit data | |
940 | + avgReviewTime: 0, // Would need review timing data | |
941 | + workloadScore, | |
942 | + trend, | |
943 | + }); | |
944 | + } | |
945 | + | |
946 | + // Sort by workload score descending | |
947 | + workloads.sort((a, b) => b.workloadScore - a.workloadScore); | |
599 | 948 | |
600 | - return workloads; | |
949 | + return workloads; | |
601 | 950 | } |
602 | 951 | |
603 | 952 | export async function getTeamBalanceReport(organizationId: string): Promise<{ |
604 | - totalWorkload: number; | |
605 | - averageWorkload: number; | |
606 | - maxWorkload: number; | |
607 | - minWorkload: number; | |
608 | - imbalanceScore: number; // 0-100, higher = more imbalanced | |
609 | - recommendations: string[]; | |
953 | + totalWorkload: number; | |
954 | + averageWorkload: number; | |
955 | + maxWorkload: number; | |
956 | + minWorkload: number; | |
957 | + imbalanceScore: number; // 0-100, higher = more imbalanced | |
958 | + recommendations: string[]; | |
610 | 959 | }> { |
611 | - const workloads = await getDeveloperWorkloads({ organizationId }); | |
612 | - | |
613 | - if (workloads.length === 0) { | |
614 | - return { | |
615 | - totalWorkload: 0, | |
616 | - averageWorkload: 0, | |
617 | - maxWorkload: 0, | |
618 | - minWorkload: 0, | |
619 | - imbalanceScore: 0, | |
620 | - recommendations: [], | |
621 | - }; | |
622 | - } | |
623 | - | |
624 | - const scores = workloads.map(w => w.workloadScore); | |
625 | - const totalWorkload = scores.reduce((a, b) => a + b, 0); | |
626 | - const averageWorkload = totalWorkload / scores.length; | |
627 | - const maxWorkload = Math.max(...scores); | |
628 | - const minWorkload = Math.min(...scores); | |
629 | - | |
630 | - // Calculate standard deviation for imbalance | |
631 | - const variance = scores.reduce((sum, s) => sum + Math.pow(s - averageWorkload, 2), 0) / scores.length; | |
632 | - const stdDev = Math.sqrt(variance); | |
633 | - | |
634 | - // Imbalance score based on coefficient of variation | |
635 | - const imbalanceScore = averageWorkload > 0 | |
636 | - ? Math.min(100, (stdDev / averageWorkload) * 100) | |
637 | - : 0; | |
638 | - | |
639 | - // Generate recommendations | |
640 | - const recommendations: string[] = []; | |
641 | - | |
642 | - if (imbalanceScore > 50) { | |
643 | - recommendations.push("Consider redistributing work more evenly across team members"); | |
644 | - } | |
645 | - | |
646 | - const overloaded = workloads.filter(w => w.workloadScore > 80); | |
647 | - if (overloaded.length > 0) { | |
648 | - recommendations.push(`${overloaded.length} team member(s) appear overloaded`); | |
649 | - } | |
650 | - | |
651 | - const underutilized = workloads.filter(w => w.workloadScore < 20); | |
652 | - if (underutilized.length > 0 && overloaded.length > 0) { | |
653 | - recommendations.push("Redistribute reviews from overloaded to underutilized members"); | |
654 | - } | |
960 | + const workloads = await getDeveloperWorkloads({ organizationId }); | |
655 | 961 | |
962 | + if (workloads.length === 0) { | |
656 | 963 | return { |
657 | - totalWorkload, | |
658 | - averageWorkload, | |
659 | - maxWorkload, | |
660 | - minWorkload, | |
661 | - imbalanceScore, | |
662 | - recommendations, | |
964 | + totalWorkload: 0, | |
965 | + averageWorkload: 0, | |
966 | + maxWorkload: 0, | |
967 | + minWorkload: 0, | |
968 | + imbalanceScore: 0, | |
969 | + recommendations: [], | |
663 | 970 | }; |
971 | + } | |
972 | + | |
973 | + const scores = workloads.map((w) => w.workloadScore); | |
974 | + const totalWorkload = scores.reduce((a, b) => a + b, 0); | |
975 | + const averageWorkload = totalWorkload / scores.length; | |
976 | + const maxWorkload = Math.max(...scores); | |
977 | + const minWorkload = Math.min(...scores); | |
978 | + | |
979 | + // Calculate standard deviation for imbalance | |
980 | + const variance = | |
981 | + scores.reduce((sum, s) => sum + Math.pow(s - averageWorkload, 2), 0) / | |
982 | + scores.length; | |
983 | + const stdDev = Math.sqrt(variance); | |
984 | + | |
985 | + // Imbalance score based on coefficient of variation | |
986 | + const imbalanceScore = | |
987 | + averageWorkload > 0 ? Math.min(100, (stdDev / averageWorkload) * 100) : 0; | |
988 | + | |
989 | + // Generate recommendations | |
990 | + const recommendations: string[] = []; | |
991 | + | |
992 | + if (imbalanceScore > 50) { | |
993 | + recommendations.push( | |
994 | + "Consider redistributing work more evenly across team members", | |
995 | + ); | |
996 | + } | |
997 | + | |
998 | + const overloaded = workloads.filter((w) => w.workloadScore > 80); | |
999 | + if (overloaded.length > 0) { | |
1000 | + recommendations.push( | |
1001 | + `${overloaded.length} team member(s) appear overloaded`, | |
1002 | + ); | |
1003 | + } | |
1004 | + | |
1005 | + const underutilized = workloads.filter((w) => w.workloadScore < 20); | |
1006 | + if (underutilized.length > 0 && overloaded.length > 0) { | |
1007 | + recommendations.push( | |
1008 | + "Redistribute reviews from overloaded to underutilized members", | |
1009 | + ); | |
1010 | + } | |
1011 | + | |
1012 | + return { | |
1013 | + totalWorkload, | |
1014 | + averageWorkload, | |
1015 | + maxWorkload, | |
1016 | + minWorkload, | |
1017 | + imbalanceScore, | |
1018 | + recommendations, | |
1019 | + }; | |
664 | 1020 | } |
665 | 1021 | |
666 | 1022 | export async function suggestReviewerAssignment( |
667 | - pullRequestId: string, | |
668 | - excludeUserIds: string[] = [] | |
1023 | + pullRequestId: string, | |
1024 | + excludeUserIds: string[] = [], | |
669 | 1025 | ): Promise<{ userId: string; userName: string; reason: string }[]> { |
670 | - const db = getDatabase(); | |
671 | - | |
672 | - const pr = await db.query.pullRequests.findFirst({ | |
673 | - where: eq(schema.pullRequests.id, pullRequestId), | |
674 | - }); | |
675 | - | |
676 | - if (!pr) return []; | |
677 | - | |
678 | - // Get workloads excluding the PR author and excluded users | |
679 | - const workloads = await getDeveloperWorkloads({ repositoryId: pr.repositoryId }); | |
680 | - | |
681 | - const candidates = workloads | |
682 | - .filter(w => w.userId !== pr.authorId && !excludeUserIds.includes(w.userId)) | |
683 | - .filter(w => w.workloadScore < 80) // Not overloaded | |
684 | - .slice(0, 3); // Top 3 suggestions | |
685 | - | |
686 | - return candidates.map(c => ({ | |
687 | - userId: c.userId, | |
688 | - userName: c.userName, | |
689 | - reason: c.workloadScore < 30 | |
690 | - ? "Has low current workload" | |
691 | - : c.pendingReviews < 3 | |
692 | - ? "Has few pending reviews" | |
693 | - : "Balanced workload", | |
694 | - })); | |
1026 | + const db = getDatabase(); | |
1027 | + | |
1028 | + const pr = await db.query.pullRequests.findFirst({ | |
1029 | + where: eq(schema.pullRequests.id, pullRequestId), | |
1030 | + }); | |
1031 | + | |
1032 | + if (!pr) return []; | |
1033 | + | |
1034 | + // Get workloads excluding the PR author and excluded users | |
1035 | + const workloads = await getDeveloperWorkloads({ | |
1036 | + repositoryId: pr.repositoryId, | |
1037 | + }); | |
1038 | + | |
1039 | + const candidates = workloads | |
1040 | + .filter( | |
1041 | + (w) => w.userId !== pr.authorId && !excludeUserIds.includes(w.userId), | |
1042 | + ) | |
1043 | + .filter((w) => w.workloadScore < 80) // Not overloaded | |
1044 | + .slice(0, 3); // Top 3 suggestions | |
1045 | + | |
1046 | + return candidates.map((c) => ({ | |
1047 | + userId: c.userId, | |
1048 | + userName: c.userName, | |
1049 | + reason: | |
1050 | + c.workloadScore < 30 | |
1051 | + ? "Has low current workload" | |
1052 | + : c.pendingReviews < 3 | |
1053 | + ? "Has few pending reviews" | |
1054 | + : "Balanced workload", | |
1055 | + })); | |
695 | 1056 | } |
696 | 1057 | |
▼src/lib/api.ts
+2-1
| @@ -2,6 +2,7 @@ | ||
2 | 2 | * API Response helpers |
3 | 3 | */ |
4 | 4 | |
5 | +import { logger } from "@/lib/logger"; | |
5 | 6 | import { z } from "zod"; |
6 | 7 | |
7 | 8 | export interface ApiResponse<T = unknown> { |
| @@ -210,7 +211,7 @@ export const withErrorHandler = (handler: APIRoute): APIRoute => { | ||
210 | 211 | try { |
211 | 212 | return await handler(context); |
212 | 213 | } catch (err) { |
213 | - console.error(err); | |
214 | + logger.error(err); | |
214 | 215 | return serverError(err instanceof Error ? err.message : "Unknown error"); |
215 | 216 | } |
216 | 217 | }; |
217 | 218 | |
▼src/lib/artifacts.ts
+329-0
| @@ -0,0 +1,329 @@ | ||
1 | +/** | |
2 | + * CI Artifacts Library | |
3 | + * Upload, download, and manage workflow run artifacts | |
4 | + */ | |
5 | + | |
6 | +import { getDatabase, schema } from "@/db"; | |
7 | +import { and, eq } from "drizzle-orm"; | |
8 | +import { createReadStream, createWriteStream } from "fs"; | |
9 | +import fs from "fs/promises"; | |
10 | +import path from "path"; | |
11 | +import type { Readable } from "stream"; | |
12 | +import { pipeline } from "stream/promises"; | |
13 | +import { createGunzip, createGzip } from "zlib"; | |
14 | +import { logger } from "./logger"; | |
15 | + | |
16 | +// ─── Types ─────────────────────────────────────────────────────────────────── | |
17 | + | |
18 | +export interface Artifact { | |
19 | + id: string; | |
20 | + runId: string; | |
21 | + jobId: string | null; | |
22 | + name: string; | |
23 | + sizeBytes: number; | |
24 | + mimeType: string | null; | |
25 | + downloadCount: number; | |
26 | + expiresAt: Date | null; | |
27 | + createdAt: Date; | |
28 | +} | |
29 | + | |
30 | +export interface UploadArtifactOptions { | |
31 | + runId: string; | |
32 | + jobId?: string; | |
33 | + name: string; | |
34 | + /** Absolute path to the file or directory to upload */ | |
35 | + sourcePath: string; | |
36 | + mimeType?: string; | |
37 | + /** Artifact retention in days (default: 90) */ | |
38 | + retentionDays?: number; | |
39 | +} | |
40 | + | |
41 | +// ─── Configuration ─────────────────────────────────────────────────────────── | |
42 | + | |
43 | +const ARTIFACTS_BASE_DIR = | |
44 | + process.env.RUNNER_ARTIFACTS_DIR || "./data/actions/artifacts"; | |
45 | +const DEFAULT_RETENTION_DAYS = 90; | |
46 | +const MAX_ARTIFACT_SIZE_BYTES = 500 * 1024 * 1024; // 500MB | |
47 | + | |
48 | +// ─── Core Functions ────────────────────────────────────────────────────────── | |
49 | + | |
50 | +/** | |
51 | + * Upload an artifact from a workflow run. | |
52 | + * Stores the file compressed (gzip) on filesystem and records metadata in DB. | |
53 | + */ | |
54 | +export async function uploadArtifact( | |
55 | + options: UploadArtifactOptions, | |
56 | +): Promise<Artifact> { | |
57 | + const { runId, jobId, name, sourcePath, mimeType, retentionDays } = options; | |
58 | + const id = crypto.randomUUID(); | |
59 | + | |
60 | + // Validate source exists | |
61 | + const stat = await fs.stat(sourcePath); | |
62 | + if (stat.size > MAX_ARTIFACT_SIZE_BYTES) { | |
63 | + throw new Error( | |
64 | + `Artifact "${name}" exceeds maximum size of ${MAX_ARTIFACT_SIZE_BYTES / (1024 * 1024)}MB`, | |
65 | + ); | |
66 | + } | |
67 | + | |
68 | + // Calculate expiration | |
69 | + const expiresAt = new Date(); | |
70 | + expiresAt.setDate( | |
71 | + expiresAt.getDate() + (retentionDays ?? DEFAULT_RETENTION_DAYS), | |
72 | + ); | |
73 | + | |
74 | + // Determine storage path: artifacts/<runId>/<artifactId>/<name>.gz | |
75 | + const artifactDir = path.join(ARTIFACTS_BASE_DIR, runId, id); | |
76 | + await fs.mkdir(artifactDir, { recursive: true }); | |
77 | + | |
78 | + const storedFileName = `${name}.gz`; | |
79 | + const storagePath = path.join(artifactDir, storedFileName); | |
80 | + | |
81 | + // Compress and write | |
82 | + if (stat.isDirectory()) { | |
83 | + // For directories, tar+gzip them | |
84 | + const { execAsync } = await import("./exec"); | |
85 | + const tarPath = path.join(artifactDir, `${name}.tar.gz`); | |
86 | + await execAsync( | |
87 | + `tar -czf "${tarPath}" -C "${path.dirname(sourcePath)}" "${path.basename(sourcePath)}"`, | |
88 | + { | |
89 | + timeout: 120_000, | |
90 | + }, | |
91 | + ); | |
92 | + const finalStat = await fs.stat(tarPath); | |
93 | + // Record in DB | |
94 | + const db = getDatabase(); | |
95 | + // @ts-expect-error - Drizzle multi-db union type issue | |
96 | + await db.insert(schema.workflowArtifacts).values({ | |
97 | + id, | |
98 | + runId, | |
99 | + jobId: jobId ?? null, | |
100 | + name, | |
101 | + sizeBytes: finalStat.size, | |
102 | + storagePath: tarPath, | |
103 | + mimeType: "application/gzip", | |
104 | + downloadCount: 0, | |
105 | + expiresAt, | |
106 | + createdAt: new Date(), | |
107 | + }); | |
108 | + | |
109 | + logger.info( | |
110 | + { artifactId: id, name, runId, size: finalStat.size }, | |
111 | + "Artifact uploaded (directory)", | |
112 | + ); | |
113 | + | |
114 | + return { | |
115 | + id, | |
116 | + runId, | |
117 | + jobId: jobId ?? null, | |
118 | + name, | |
119 | + sizeBytes: finalStat.size, | |
120 | + mimeType: "application/gzip", | |
121 | + downloadCount: 0, | |
122 | + expiresAt, | |
123 | + createdAt: new Date(), | |
124 | + }; | |
125 | + } else { | |
126 | + // Single file — gzip it | |
127 | + await pipeline( | |
128 | + createReadStream(sourcePath), | |
129 | + createGzip(), | |
130 | + createWriteStream(storagePath), | |
131 | + ); | |
132 | + | |
133 | + const compressedStat = await fs.stat(storagePath); | |
134 | + | |
135 | + // Record in DB | |
136 | + const db = getDatabase(); | |
137 | + // @ts-expect-error - Drizzle multi-db union type issue | |
138 | + await db.insert(schema.workflowArtifacts).values({ | |
139 | + id, | |
140 | + runId, | |
141 | + jobId: jobId ?? null, | |
142 | + name, | |
143 | + sizeBytes: stat.size, // original size | |
144 | + storagePath, | |
145 | + mimeType: mimeType ?? "application/octet-stream", | |
146 | + downloadCount: 0, | |
147 | + expiresAt, | |
148 | + createdAt: new Date(), | |
149 | + }); | |
150 | + | |
151 | + logger.info( | |
152 | + { | |
153 | + artifactId: id, | |
154 | + name, | |
155 | + runId, | |
156 | + size: stat.size, | |
157 | + compressed: compressedStat.size, | |
158 | + }, | |
159 | + "Artifact uploaded", | |
160 | + ); | |
161 | + | |
162 | + return { | |
163 | + id, | |
164 | + runId, | |
165 | + jobId: jobId ?? null, | |
166 | + name, | |
167 | + sizeBytes: stat.size, | |
168 | + mimeType: mimeType ?? "application/octet-stream", | |
169 | + downloadCount: 0, | |
170 | + expiresAt, | |
171 | + createdAt: new Date(), | |
172 | + }; | |
173 | + } | |
174 | +} | |
175 | + | |
176 | +/** | |
177 | + * Download an artifact by run ID and name. | |
178 | + * Returns a readable stream of the decompressed content. | |
179 | + */ | |
180 | +export async function downloadArtifact( | |
181 | + runId: string, | |
182 | + name: string, | |
183 | +): Promise<{ stream: Readable; artifact: Artifact }> { | |
184 | + const db = getDatabase(); | |
185 | + | |
186 | + const record = await db.query.workflowArtifacts.findFirst({ | |
187 | + where: and( | |
188 | + eq(schema.workflowArtifacts.runId, runId), | |
189 | + eq(schema.workflowArtifacts.name, name), | |
190 | + ), | |
191 | + }); | |
192 | + | |
193 | + if (!record) { | |
194 | + throw new Error(`Artifact "${name}" not found for run ${runId}`); | |
195 | + } | |
196 | + | |
197 | + // Check if expired | |
198 | + if (record.expiresAt && new Date(record.expiresAt) < new Date()) { | |
199 | + throw new Error(`Artifact "${name}" has expired`); | |
200 | + } | |
201 | + | |
202 | + // Verify file exists | |
203 | + try { | |
204 | + await fs.access(record.storagePath); | |
205 | + } catch { | |
206 | + throw new Error(`Artifact file not found on disk: ${record.storagePath}`); | |
207 | + } | |
208 | + | |
209 | + // Increment download count | |
210 | + // @ts-expect-error - Drizzle multi-db union type issue | |
211 | + await db | |
212 | + .update(schema.workflowArtifacts) | |
213 | + .set({ downloadCount: (record.downloadCount ?? 0) + 1 }) | |
214 | + .where(eq(schema.workflowArtifacts.id, record.id)); | |
215 | + | |
216 | + // Return decompressed stream | |
217 | + const isGzip = | |
218 | + record.storagePath.endsWith(".gz") || | |
219 | + record.storagePath.endsWith(".tar.gz"); | |
220 | + const readStream = createReadStream(record.storagePath); | |
221 | + const stream = | |
222 | + isGzip && !record.storagePath.endsWith(".tar.gz") | |
223 | + ? readStream.pipe(createGunzip()) | |
224 | + : readStream; | |
225 | + | |
226 | + return { | |
227 | + stream: stream as Readable, | |
228 | + artifact: { | |
229 | + id: record.id, | |
230 | + runId: record.runId, | |
231 | + jobId: record.jobId, | |
232 | + name: record.name, | |
233 | + sizeBytes: record.sizeBytes, | |
234 | + mimeType: record.mimeType, | |
235 | + downloadCount: (record.downloadCount ?? 0) + 1, | |
236 | + expiresAt: record.expiresAt, | |
237 | + createdAt: record.createdAt, | |
238 | + }, | |
239 | + }; | |
240 | +} | |
241 | + | |
242 | +/** | |
243 | + * List all artifacts for a workflow run | |
244 | + */ | |
245 | +export async function listArtifacts(runId: string): Promise<Artifact[]> { | |
246 | + const db = getDatabase(); | |
247 | + | |
248 | + const records = await db.query.workflowArtifacts.findMany({ | |
249 | + where: eq(schema.workflowArtifacts.runId, runId), | |
250 | + orderBy: (artifacts, { desc }) => [desc(artifacts.createdAt)], | |
251 | + }); | |
252 | + | |
253 | + return records.map((r) => ({ | |
254 | + id: r.id, | |
255 | + runId: r.runId, | |
256 | + jobId: r.jobId, | |
257 | + name: r.name, | |
258 | + sizeBytes: r.sizeBytes, | |
259 | + mimeType: r.mimeType, | |
260 | + downloadCount: r.downloadCount ?? 0, | |
261 | + expiresAt: r.expiresAt, | |
262 | + createdAt: r.createdAt, | |
263 | + })); | |
264 | +} | |
265 | + | |
266 | +/** | |
267 | + * Delete an artifact (by ID) | |
268 | + */ | |
269 | +export async function deleteArtifact(artifactId: string): Promise<void> { | |
270 | + const db = getDatabase(); | |
271 | + | |
272 | + const record = await db.query.workflowArtifacts.findFirst({ | |
273 | + where: eq(schema.workflowArtifacts.id, artifactId), | |
274 | + }); | |
275 | + | |
276 | + if (!record) return; | |
277 | + | |
278 | + // Remove file from disk | |
279 | + try { | |
280 | + await fs.rm(path.dirname(record.storagePath), { | |
281 | + recursive: true, | |
282 | + force: true, | |
283 | + }); | |
284 | + } catch { | |
285 | + logger.warn( | |
286 | + { artifactId, path: record.storagePath }, | |
287 | + "Failed to delete artifact file", | |
288 | + ); | |
289 | + } | |
290 | + | |
291 | + // Remove DB record | |
292 | + // @ts-expect-error - Drizzle multi-db union type issue | |
293 | + await db | |
294 | + .delete(schema.workflowArtifacts) | |
295 | + .where(eq(schema.workflowArtifacts.id, artifactId)); | |
296 | + | |
297 | + logger.info({ artifactId, name: record.name }, "Artifact deleted"); | |
298 | +} | |
299 | + | |
300 | +/** | |
301 | + * Clean up expired artifacts (run periodically) | |
302 | + */ | |
303 | +export async function cleanupExpiredArtifacts(): Promise<number> { | |
304 | + const db = getDatabase(); | |
305 | + const { lt } = await import("drizzle-orm"); | |
306 | + | |
307 | + const expired = await db.query.workflowArtifacts.findMany({ | |
308 | + where: lt(schema.workflowArtifacts.expiresAt, new Date()), | |
309 | + }); | |
310 | + | |
311 | + let cleaned = 0; | |
312 | + for (const record of expired) { | |
313 | + try { | |
314 | + await deleteArtifact(record.id); | |
315 | + cleaned++; | |
316 | + } catch (error) { | |
317 | + logger.error( | |
318 | + { error, artifactId: record.id }, | |
319 | + "Failed to clean expired artifact", | |
320 | + ); | |
321 | + } | |
322 | + } | |
323 | + | |
324 | + if (cleaned > 0) { | |
325 | + logger.info({ count: cleaned }, "Expired artifacts cleaned up"); | |
326 | + } | |
327 | + | |
328 | + return cleaned; | |
329 | +} | |
0 | 330 | |
▼src/lib/audit.ts
+203-0
| @@ -0,0 +1,203 @@ | ||
1 | +/** | |
2 | + * Audit Log Library | |
3 | + * Structured audit logging for security-sensitive operations | |
4 | + */ | |
5 | + | |
6 | +import { getDatabase, schema } from "@/db"; | |
7 | +import { and, desc, eq, gte, lte } from "drizzle-orm"; | |
8 | +import { logger } from "./logger"; | |
9 | + | |
10 | +// ─── Types ─────────────────────────────────────────────────────────────────── | |
11 | + | |
12 | +export type AuditAction = | |
13 | + // User lifecycle | |
14 | + | "user.delete" | |
15 | + | "user.promote_admin" | |
16 | + | "user.demote_admin" | |
17 | + | "user.activate" | |
18 | + | "user.deactivate" | |
19 | + // Authentication | |
20 | + | "pat.create" | |
21 | + | "pat.delete" | |
22 | + | "ssh_key.create" | |
23 | + | "ssh_key.delete" | |
24 | + // Repository management | |
25 | + | "repo.transfer" | |
26 | + | "repo.delete" | |
27 | + | "repo.visibility_change" | |
28 | + // Branch protection | |
29 | + | "branch_protection.create" | |
30 | + | "branch_protection.update" | |
31 | + | "branch_protection.delete" | |
32 | + // Organization | |
33 | + | "org.member_role_change" | |
34 | + | "org.member_add" | |
35 | + | "org.member_remove" | |
36 | + | "org.role_create" | |
37 | + | "org.role_update" | |
38 | + | "org.role_delete" | |
39 | + // Admin | |
40 | + | "admin.config_update" | |
41 | + | "admin.storage_update" | |
42 | + | "admin.storage_reset" | |
43 | + | "admin.sync_trigger" | |
44 | + | "admin.plugin_register" | |
45 | + | "admin.plugin_update"; | |
46 | + | |
47 | +export type AuditActorType = "user" | "system" | "oauth_app"; | |
48 | +export type AuditTargetType = | |
49 | + | "user" | |
50 | + | "repository" | |
51 | + | "organization" | |
52 | + | "token" | |
53 | + | "ssh_key" | |
54 | + | "branch_protection" | |
55 | + | "role" | |
56 | + | "plugin" | |
57 | + | "config"; | |
58 | + | |
59 | +export interface AuditLogOptions { | |
60 | + /** User ID of who performed the action (null for system events) */ | |
61 | + userId?: string | null; | |
62 | + /** Repository context (if applicable) */ | |
63 | + repositoryId?: string | null; | |
64 | + /** Organization context (if applicable) */ | |
65 | + organizationId?: string | null; | |
66 | + /** The action that was performed */ | |
67 | + action: AuditAction; | |
68 | + /** Type of actor */ | |
69 | + actorType?: AuditActorType; | |
70 | + /** Actor's user ID (defaults to userId) */ | |
71 | + actorId?: string | null; | |
72 | + /** Client IP address */ | |
73 | + actorIp?: string | null; | |
74 | + /** Client user agent */ | |
75 | + actorUserAgent?: string | null; | |
76 | + /** Type of the target resource */ | |
77 | + targetType?: AuditTargetType; | |
78 | + /** ID of the target resource */ | |
79 | + targetId?: string | null; | |
80 | + /** Human-readable name of the target */ | |
81 | + targetName?: string | null; | |
82 | + /** Additional data (before/after values, metadata) */ | |
83 | + data?: Record<string, unknown> | null; | |
84 | +} | |
85 | + | |
86 | +export interface AuditLogQuery { | |
87 | + action?: string; | |
88 | + actorId?: string; | |
89 | + targetType?: string; | |
90 | + targetId?: string; | |
91 | + repositoryId?: string; | |
92 | + organizationId?: string; | |
93 | + since?: Date; | |
94 | + until?: Date; | |
95 | + limit?: number; | |
96 | + offset?: number; | |
97 | +} | |
98 | + | |
99 | +// ─── Core Functions ────────────────────────────────────────────────────────── | |
100 | + | |
101 | +/** | |
102 | + * Record an audit log entry for a security-sensitive operation. | |
103 | + * This never throws — audit failures are logged but don't break operations. | |
104 | + */ | |
105 | +export async function logAudit(options: AuditLogOptions): Promise<void> { | |
106 | + try { | |
107 | + const db = getDatabase(); | |
108 | + const id = crypto.randomUUID(); | |
109 | + | |
110 | + // @ts-expect-error - Drizzle multi-db union type issue | |
111 | + await db.insert(schema.auditLogs).values({ | |
112 | + id, | |
113 | + userId: options.userId ?? null, | |
114 | + repositoryId: options.repositoryId ?? null, | |
115 | + organizationId: options.organizationId ?? null, | |
116 | + action: options.action, | |
117 | + actorType: options.actorType ?? "user", | |
118 | + actorId: options.actorId ?? options.userId ?? null, | |
119 | + actorIp: options.actorIp ?? null, | |
120 | + actorUserAgent: options.actorUserAgent ?? null, | |
121 | + targetType: options.targetType ?? null, | |
122 | + targetId: options.targetId ?? null, | |
123 | + targetName: options.targetName ?? null, | |
124 | + data: options.data ? JSON.stringify(options.data) : null, | |
125 | + createdAt: new Date(), | |
126 | + }); | |
127 | + | |
128 | + logger.debug( | |
129 | + { action: options.action, targetId: options.targetId }, | |
130 | + "Audit log recorded", | |
131 | + ); | |
132 | + } catch (error) { | |
133 | + // Never let audit logging failures break the main operation | |
134 | + logger.error( | |
135 | + { error, action: options.action }, | |
136 | + "Failed to record audit log", | |
137 | + ); | |
138 | + } | |
139 | +} | |
140 | + | |
141 | +/** | |
142 | + * Query audit logs with filtering and pagination | |
143 | + */ | |
144 | +export async function queryAuditLogs(query: AuditLogQuery = {}) { | |
145 | + const db = getDatabase(); | |
146 | + const conditions = []; | |
147 | + | |
148 | + if (query.action) { | |
149 | + conditions.push(eq(schema.auditLogs.action, query.action)); | |
150 | + } | |
151 | + if (query.actorId) { | |
152 | + conditions.push(eq(schema.auditLogs.actorId, query.actorId)); | |
153 | + } | |
154 | + if (query.targetType) { | |
155 | + conditions.push(eq(schema.auditLogs.targetType, query.targetType)); | |
156 | + } | |
157 | + if (query.targetId) { | |
158 | + conditions.push(eq(schema.auditLogs.targetId, query.targetId)); | |
159 | + } | |
160 | + if (query.repositoryId) { | |
161 | + conditions.push(eq(schema.auditLogs.repositoryId, query.repositoryId)); | |
162 | + } | |
163 | + if (query.organizationId) { | |
164 | + conditions.push(eq(schema.auditLogs.organizationId, query.organizationId)); | |
165 | + } | |
166 | + if (query.since) { | |
167 | + conditions.push(gte(schema.auditLogs.createdAt, query.since)); | |
168 | + } | |
169 | + if (query.until) { | |
170 | + conditions.push(lte(schema.auditLogs.createdAt, query.until)); | |
171 | + } | |
172 | + | |
173 | + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; | |
174 | + | |
175 | + const logs = await db.query.auditLogs.findMany({ | |
176 | + where: whereClause, | |
177 | + orderBy: [desc(schema.auditLogs.createdAt)], | |
178 | + limit: query.limit ?? 50, | |
179 | + offset: query.offset ?? 0, | |
180 | + with: { | |
181 | + user: { columns: { id: true, username: true, email: true } }, | |
182 | + }, | |
183 | + }); | |
184 | + | |
185 | + return logs; | |
186 | +} | |
187 | + | |
188 | +// ─── Helpers ───────────────────────────────────────────────────────────────── | |
189 | + | |
190 | +/** | |
191 | + * Extract client IP and user agent from an Astro API request | |
192 | + */ | |
193 | +export function getRequestMeta(request: Request): { | |
194 | + ip: string | null; | |
195 | + userAgent: string | null; | |
196 | +} { | |
197 | + const ip = | |
198 | + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? | |
199 | + request.headers.get("x-real-ip") ?? | |
200 | + null; | |
201 | + const userAgent = request.headers.get("user-agent") ?? null; | |
202 | + return { ip, userAgent }; | |
203 | +} | |
0 | 204 | |
▼src/lib/auth-basic.ts
+3-2
| @@ -1,3 +1,4 @@ | ||
1 | +import { logger } from "@/lib/logger"; | |
1 | 2 | import { getDatabase, schema } from "@/db"; |
2 | 3 | import type { NodePgDatabase } from "drizzle-orm/node-postgres"; |
3 | 4 | import bcrypt from "bcryptjs"; |
| @@ -41,7 +42,7 @@ export async function validateBasicAuth( | ||
41 | 42 | .update(schema.personalAccessTokens) |
42 | 43 | .set({ lastUsedAt: new Date() }) |
43 | 44 | .where(eq(schema.personalAccessTokens.id, pat.id)); |
44 | - console.log(`[validateBasicAuth] Authenticated ${username} via PAT`); | |
45 | + logger.info(`[validateBasicAuth] Authenticated ${username} via PAT`); | |
45 | 46 | return user.id; |
46 | 47 | } |
47 | 48 | // Token format but not valid |
| @@ -53,7 +54,7 @@ export async function validateBasicAuth( | ||
53 | 54 | |
54 | 55 | const isValid = await bcrypt.compare(password, user.passwordHash); |
55 | 56 | if (isValid) { |
56 | - console.log(`[validateBasicAuth] Authenticated ${username} via password`); | |
57 | + logger.info(`[validateBasicAuth] Authenticated ${username} via password`); | |
57 | 58 | return user.id; |
58 | 59 | } |
59 | 60 | |
60 | 61 | |
▼src/lib/auth.ts
+36-25
| @@ -1,18 +1,21 @@ | ||
1 | - | |
2 | 1 | /** |
3 | 2 | * Authentication utilities |
4 | 3 | * JWT token management, password hashing, 2FA |
5 | 4 | */ |
6 | 5 | |
6 | +import { getDatabase, schema } from "@/db"; | |
7 | +import { logger } from "@/lib/logger"; | |
8 | +import { getRepoPermission } from "@/lib/permissions"; | |
9 | +import { | |
10 | + hashPersonalAccessToken, | |
11 | + verifyPersonalAccessTokenValue, | |
12 | +} from "@/lib/personal-access-token"; | |
7 | 13 | import bcrypt from "bcryptjs"; |
14 | +import { and, eq, or } from "drizzle-orm"; | |
15 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
8 | 16 | import { JWTPayload, SignJWT, jwtVerify } from "jose"; |
9 | 17 | import { authenticator } from "otplib"; |
10 | -import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
11 | -import { getDatabase, schema } from "@/db"; | |
12 | -import { eq, and, or } from "drizzle-orm"; | |
13 | -import { generateId, now } from "./utils"; | |
14 | -import { getRepoPermission } from "@/lib/permissions"; | |
15 | -import { hashPersonalAccessToken, verifyPersonalAccessTokenValue } from "@/lib/personal-access-token"; | |
18 | +import { generateId } from "./utils"; | |
16 | 19 | |
17 | 20 | // Types |
18 | 21 | export interface TokenPayload extends JWTPayload { |
| @@ -33,12 +36,18 @@ export interface SessionData { | ||
33 | 36 | createdAt: Date; |
34 | 37 | } |
35 | 38 | |
36 | -// JWT configuration | |
37 | -const jwtSecretRaw = process.env.JWT_SECRET; | |
38 | -if (!jwtSecretRaw) { | |
39 | - throw new Error("JWT_SECRET environment variable is required"); | |
39 | +// JWT configuration — lazy-initialized to avoid crashing at import time | |
40 | +let _jwtSecret: Uint8Array | null = null; | |
41 | +function getJwtSecret(): Uint8Array { | |
42 | + if (!_jwtSecret) { | |
43 | + const raw = process.env.JWT_SECRET; | |
44 | + if (!raw) { | |
45 | + throw new Error("JWT_SECRET environment variable is required"); | |
46 | + } | |
47 | + _jwtSecret = new TextEncoder().encode(raw); | |
48 | + } | |
49 | + return _jwtSecret; | |
40 | 50 | } |
41 | -const JWT_SECRET = new TextEncoder().encode(jwtSecretRaw); | |
42 | 51 | const JWT_ISSUER = "opencodehub"; |
43 | 52 | const JWT_AUDIENCE = "opencodehub-api"; |
44 | 53 | |
| @@ -46,7 +55,7 @@ const JWT_AUDIENCE = "opencodehub-api"; | ||
46 | 55 | * Create a JWT token |
47 | 56 | */ |
48 | 57 | export async function createToken( |
49 | - payload: Omit<TokenPayload, "iat" | "exp" | "iss" | "aud"> | |
58 | + payload: Omit<TokenPayload, "iat" | "exp" | "iss" | "aud">, | |
50 | 59 | ): Promise<string> { |
51 | 60 | const expiresIn = process.env.JWT_EXPIRES_IN || "7d"; |
52 | 61 | const expirationTime = parseExpirationTime(expiresIn); |
| @@ -57,7 +66,7 @@ export async function createToken( | ||
57 | 66 | .setIssuer(JWT_ISSUER) |
58 | 67 | .setAudience(JWT_AUDIENCE) |
59 | 68 | .setExpirationTime(expirationTime) |
60 | - .sign(JWT_SECRET); | |
69 | + .sign(getJwtSecret()); | |
61 | 70 | } |
62 | 71 | |
63 | 72 | /** |
| @@ -65,7 +74,7 @@ export async function createToken( | ||
65 | 74 | */ |
66 | 75 | export async function verifyToken(token: string): Promise<TokenPayload | null> { |
67 | 76 | try { |
68 | - const { payload } = await jwtVerify(token, JWT_SECRET, { | |
77 | + const { payload } = await jwtVerify(token, getJwtSecret(), { | |
69 | 78 | issuer: JWT_ISSUER, |
70 | 79 | audience: JWT_AUDIENCE, |
71 | 80 | }); |
| @@ -88,7 +97,7 @@ export async function hashPassword(password: string): Promise<string> { | ||
88 | 97 | */ |
89 | 98 | export async function verifyPassword( |
90 | 99 | password: string, |
91 | - hash: string | |
100 | + hash: string, | |
92 | 101 | ): Promise<boolean> { |
93 | 102 | return bcrypt.compare(password, hash); |
94 | 103 | } |
| @@ -118,7 +127,7 @@ export function verify2FAToken(token: string, secret: string): boolean { | ||
118 | 127 | export async function createSession( |
119 | 128 | userId: string, |
120 | 129 | userAgent?: string, |
121 | - ipAddress?: string | |
130 | + ipAddress?: string, | |
122 | 131 | ): Promise<SessionData> { |
123 | 132 | const expiresIn = process.env.JWT_EXPIRES_IN || "7d"; |
124 | 133 | const expirationDate = calculateExpirationDate(expiresIn); |
| @@ -249,7 +258,7 @@ export function extractBearerToken(authHeader: string | null): string | null { | ||
249 | 258 | * Supports both JWT tokens and Personal Access Tokens (PAT) |
250 | 259 | */ |
251 | 260 | export async function getUserFromRequest( |
252 | - request: Request | |
261 | + request: Request, | |
253 | 262 | ): Promise<TokenPayload | null> { |
254 | 263 | const authHeader = request.headers.get("Authorization"); |
255 | 264 | const token = extractBearerToken(authHeader); |
| @@ -282,7 +291,9 @@ export async function getUserFromRequest( | ||
282 | 291 | /** |
283 | 292 | * Verify a Personal Access Token |
284 | 293 | */ |
285 | -async function verifyPersonalAccessToken(token: string): Promise<TokenPayload | null> { | |
294 | +async function verifyPersonalAccessToken( | |
295 | + token: string, | |
296 | +): Promise<TokenPayload | null> { | |
286 | 297 | try { |
287 | 298 | const db = getDatabase() as NodePgDatabase<typeof schema>; |
288 | 299 | const { personalAccessTokens } = schema; |
| @@ -293,7 +304,7 @@ async function verifyPersonalAccessToken(token: string): Promise<TokenPayload | | ||
293 | 304 | const pat = await db.query.personalAccessTokens.findFirst({ |
294 | 305 | where: or( |
295 | 306 | eq(personalAccessTokens.token, hashedToken), |
296 | - eq(personalAccessTokens.token, token) | |
307 | + eq(personalAccessTokens.token, token), | |
297 | 308 | ), |
298 | 309 | with: { |
299 | 310 | user: true, |
| @@ -327,7 +338,7 @@ async function verifyPersonalAccessToken(token: string): Promise<TokenPayload | | ||
327 | 338 | isAdmin: pat.user.isAdmin || false, |
328 | 339 | }; |
329 | 340 | } catch (error) { |
330 | - console.error("PAT verification error:", error); | |
341 | + logger.error("PAT verification error:", error); | |
331 | 342 | return null; |
332 | 343 | } |
333 | 344 | } |
| @@ -355,7 +366,7 @@ function parseCookies(cookieHeader: string): Record<string, string> { | ||
355 | 366 | export async function getRepoAndUser( |
356 | 367 | request: Request, |
357 | 368 | ownerName: string, |
358 | - repoName: string | |
369 | + repoName: string, | |
359 | 370 | ) { |
360 | 371 | const db = getDatabase(); |
361 | 372 | const tokenUser = await getUserFromRequest(request); |
| @@ -372,7 +383,7 @@ export async function getRepoAndUser( | ||
372 | 383 | const repository = await db.query.repositories.findFirst({ |
373 | 384 | where: and( |
374 | 385 | eq(schema.repositories.ownerId, repoOwner.id), |
375 | - eq(schema.repositories.name, repoName) | |
386 | + eq(schema.repositories.name, repoName), | |
376 | 387 | ), |
377 | 388 | with: { |
378 | 389 | owner: true, |
| @@ -389,8 +400,8 @@ export async function getRepoAndUser( | ||
389 | 400 | |
390 | 401 | const requester = userId |
391 | 402 | ? await db.query.users.findFirst({ |
392 | - where: eq(schema.users.id, userId), | |
393 | - }) | |
403 | + where: eq(schema.users.id, userId), | |
404 | + }) | |
394 | 405 | : null; |
395 | 406 | |
396 | 407 | return { |
397 | 408 | |
▼src/lib/auto-merge-rules.ts
+78-4
| @@ -21,6 +21,26 @@ function unique(values: string[]): string[] { | ||
21 | 21 | return Array.from(new Set(values)); |
22 | 22 | } |
23 | 23 | |
24 | +export interface AutoMergeRuleEvaluation { | |
25 | + ruleId: string; | |
26 | + name: string; | |
27 | + matched: boolean; | |
28 | + passed: boolean; | |
29 | + reasons: string[]; | |
30 | + matchLabels: string[]; | |
31 | + requiredLabels: string[]; | |
32 | + requiredChecks: string[]; | |
33 | + minApprovals: number; | |
34 | + requireCodeOwner: boolean; | |
35 | + allowDraft: boolean; | |
36 | + minTimeInQueueMinutes: number; | |
37 | +} | |
38 | + | |
39 | +export interface AutoMergeRuleEvaluationResult { | |
40 | + blockers: string[]; | |
41 | + evaluations: AutoMergeRuleEvaluation[]; | |
42 | +} | |
43 | + | |
24 | 44 | async function getCodeOwnerBlockers( |
25 | 45 | db: NodePgDatabase<typeof schema>, |
26 | 46 | repository: typeof schema.repositories.$inferSelect, |
| @@ -94,7 +114,7 @@ async function getCodeOwnerBlockers( | ||
94 | 114 | return blockers; |
95 | 115 | } |
96 | 116 | |
97 | -export async function evaluateAutoMergeRules(prId: string): Promise<string[]> { | |
117 | +export async function evaluateAutoMergeRulesDetailed(prId: string): Promise<AutoMergeRuleEvaluationResult> { | |
98 | 118 | const db = getDatabase() as NodePgDatabase<typeof schema>; |
99 | 119 | |
100 | 120 | const pr = await db.query.pullRequests.findFirst({ |
| @@ -106,7 +126,12 @@ export async function evaluateAutoMergeRules(prId: string): Promise<string[]> { | ||
106 | 126 | }, |
107 | 127 | }); |
108 | 128 | |
109 | - if (!pr) return ["Pull request not found"]; | |
129 | + if (!pr) { | |
130 | + return { | |
131 | + blockers: ["Pull request not found"], | |
132 | + evaluations: [], | |
133 | + }; | |
134 | + } | |
110 | 135 | |
111 | 136 | const rules = await db.query.autoMergeRules.findMany({ |
112 | 137 | where: and( |
| @@ -115,7 +140,12 @@ export async function evaluateAutoMergeRules(prId: string): Promise<string[]> { | ||
115 | 140 | ), |
116 | 141 | }); |
117 | 142 | |
118 | - if (rules.length === 0) return []; | |
143 | + if (rules.length === 0) { | |
144 | + return { | |
145 | + blockers: [], | |
146 | + evaluations: [], | |
147 | + }; | |
148 | + } | |
119 | 149 | |
120 | 150 | const labelNames = unique((pr.labels || []).map((item) => item.label?.name).filter(Boolean) as string[]); |
121 | 151 | const approvals = (pr.reviews || []).filter((review) => review.state === "approved"); |
| @@ -126,13 +156,31 @@ export async function evaluateAutoMergeRules(prId: string): Promise<string[]> { | ||
126 | 156 | const pendingChecks = (pr.checks || []).filter((check) => check.status !== "completed"); |
127 | 157 | |
128 | 158 | const blockers: string[] = []; |
159 | + const evaluations: AutoMergeRuleEvaluation[] = []; | |
129 | 160 | |
130 | 161 | for (const rule of rules) { |
131 | 162 | const matchLabels = parseList(rule.matchLabels); |
132 | 163 | const requiredLabels = parseList(rule.requiredLabels); |
133 | 164 | const requiredChecks = parseList(rule.requiredChecks); |
165 | + const evaluation: AutoMergeRuleEvaluation = { | |
166 | + ruleId: rule.id, | |
167 | + name: rule.name, | |
168 | + matched: true, | |
169 | + passed: false, | |
170 | + reasons: [], | |
171 | + matchLabels, | |
172 | + requiredLabels, | |
173 | + requiredChecks, | |
174 | + minApprovals: rule.minApprovals || 0, | |
175 | + requireCodeOwner: !!rule.requireCodeOwner, | |
176 | + allowDraft: !!rule.allowDraft, | |
177 | + minTimeInQueueMinutes: rule.minTimeInQueueMinutes || 0, | |
178 | + }; | |
134 | 179 | |
135 | 180 | if (matchLabels.length > 0 && !matchLabels.every((label) => labelNames.includes(label))) { |
181 | + evaluation.matched = false; | |
182 | + evaluation.reasons.push(`Rule filter mismatch: requires labels ${matchLabels.join(", ")}`); | |
183 | + evaluations.push(evaluation); | |
136 | 184 | continue; |
137 | 185 | } |
138 | 186 | |
| @@ -148,7 +196,9 @@ export async function evaluateAutoMergeRules(prId: string): Promise<string[]> { | ||
148 | 196 | if (!rule.allowDraft && pr.isDraft) { |
149 | 197 | const reason = `Auto-merge rule "${rule.name}" blocks drafts`; |
150 | 198 | blockers.push(reason); |
199 | + evaluation.reasons.push(reason); | |
151 | 200 | await recordMismatch(reason); |
201 | + evaluations.push(evaluation); | |
152 | 202 | continue; |
153 | 203 | } |
154 | 204 | |
| @@ -157,7 +207,9 @@ export async function evaluateAutoMergeRules(prId: string): Promise<string[]> { | ||
157 | 207 | if (missingLabels.length > 0) { |
158 | 208 | const reason = `Rule "${rule.name}" missing labels: ${missingLabels.join(", ")}`; |
159 | 209 | blockers.push(reason); |
210 | + evaluation.reasons.push(reason); | |
160 | 211 | await recordMismatch(reason); |
212 | + evaluations.push(evaluation); | |
161 | 213 | continue; |
162 | 214 | } |
163 | 215 | } |
| @@ -168,21 +220,27 @@ export async function evaluateAutoMergeRules(prId: string): Promise<string[]> { | ||
168 | 220 | if (missingChecks.length > 0) { |
169 | 221 | const reason = `Rule "${rule.name}" missing checks: ${missingChecks.join(", ")}`; |
170 | 222 | blockers.push(reason); |
223 | + evaluation.reasons.push(reason); | |
171 | 224 | await recordMismatch(reason); |
225 | + evaluations.push(evaluation); | |
172 | 226 | continue; |
173 | 227 | } |
174 | 228 | |
175 | 229 | if (failingChecks.length > 0) { |
176 | 230 | const reason = `Rule "${rule.name}" has failing checks`; |
177 | 231 | blockers.push(reason); |
232 | + evaluation.reasons.push(reason); | |
178 | 233 | await recordMismatch(reason); |
234 | + evaluations.push(evaluation); | |
179 | 235 | continue; |
180 | 236 | } |
181 | 237 | |
182 | 238 | if (pendingChecks.length > 0) { |
183 | 239 | const reason = `Rule "${rule.name}" has pending checks`; |
184 | 240 | blockers.push(reason); |
241 | + evaluation.reasons.push(reason); | |
185 | 242 | await recordMismatch(reason); |
243 | + evaluations.push(evaluation); | |
186 | 244 | continue; |
187 | 245 | } |
188 | 246 | } |
| @@ -190,7 +248,9 @@ export async function evaluateAutoMergeRules(prId: string): Promise<string[]> { | ||
190 | 248 | if ((rule.minApprovals || 0) > approvalCount) { |
191 | 249 | const reason = `Rule "${rule.name}" needs ${rule.minApprovals} approvals (has ${approvalCount})`; |
192 | 250 | blockers.push(reason); |
251 | + evaluation.reasons.push(reason); | |
193 | 252 | await recordMismatch(reason); |
253 | + evaluations.push(evaluation); | |
194 | 254 | continue; |
195 | 255 | } |
196 | 256 | |
| @@ -198,7 +258,9 @@ export async function evaluateAutoMergeRules(prId: string): Promise<string[]> { | ||
198 | 258 | if (!pr.autoMergeEnabledAt) { |
199 | 259 | const reason = `Rule "${rule.name}" requires auto-merge delay before merge`; |
200 | 260 | blockers.push(reason); |
261 | + evaluation.reasons.push(reason); | |
201 | 262 | await recordMismatch(reason); |
263 | + evaluations.push(evaluation); | |
202 | 264 | continue; |
203 | 265 | } |
204 | 266 | |
| @@ -208,7 +270,9 @@ export async function evaluateAutoMergeRules(prId: string): Promise<string[]> { | ||
208 | 270 | if (elapsedMinutes < (rule.minTimeInQueueMinutes || 0)) { |
209 | 271 | const reason = `Rule "${rule.name}" requires ${rule.minTimeInQueueMinutes} minute delay (elapsed ${elapsedMinutes})`; |
210 | 272 | blockers.push(reason); |
273 | + evaluation.reasons.push(reason); | |
211 | 274 | await recordMismatch(reason); |
275 | + evaluations.push(evaluation); | |
212 | 276 | continue; |
213 | 277 | } |
214 | 278 | } |
| @@ -229,7 +293,9 @@ export async function evaluateAutoMergeRules(prId: string): Promise<string[]> { | ||
229 | 293 | if (codeOwnerBlockers.length > 0) { |
230 | 294 | const reasons = codeOwnerBlockers.map((message) => `Rule "${rule.name}": ${message}`); |
231 | 295 | blockers.push(...reasons); |
296 | + evaluation.reasons.push(...reasons); | |
232 | 297 | await recordMismatch(reasons[0]); |
298 | + evaluations.push(evaluation); | |
233 | 299 | continue; |
234 | 300 | } |
235 | 301 | } |
| @@ -243,7 +309,15 @@ export async function evaluateAutoMergeRules(prId: string): Promise<string[]> { | ||
243 | 309 | updatedAt: new Date(), |
244 | 310 | }) |
245 | 311 | .where(eq(schema.autoMergeRules.id, rule.id)); |
312 | + | |
313 | + evaluation.passed = true; | |
314 | + evaluations.push(evaluation); | |
246 | 315 | } |
247 | 316 | |
248 | - return blockers; | |
317 | + return { blockers, evaluations }; | |
318 | +} | |
319 | + | |
320 | +export async function evaluateAutoMergeRules(prId: string): Promise<string[]> { | |
321 | + const result = await evaluateAutoMergeRulesDetailed(prId); | |
322 | + return result.blockers; | |
249 | 323 | } |
250 | 324 | |
▼src/lib/automations.ts
+740-455
| @@ -3,568 +3,853 @@ | ||
3 | 3 | * Workflow automation with triggers, conditions, and actions |
4 | 4 | */ |
5 | 5 | |
6 | -import { eq, and, desc } from "drizzle-orm"; | |
7 | -import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
8 | 6 | import { getDatabase, schema } from "@/db"; |
9 | -import { logger } from "@/lib/logger"; | |
10 | -import { generateId } from "./utils"; | |
11 | 7 | import type { |
12 | - AutomationTrigger, | |
13 | - AutomationAction, | |
14 | - AutomationCondition | |
8 | + AutomationAction, | |
9 | + AutomationCondition, | |
10 | + AutomationTrigger, | |
15 | 11 | } from "@/db/schema/automations"; |
12 | +import { logger } from "@/lib/logger"; | |
13 | +import { and, desc, eq } from "drizzle-orm"; | |
14 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
15 | +import { generateId } from "./utils"; | |
16 | + | |
17 | +const RETRYABLE_ACTIONS = new Set<AutomationAction["type"]>([ | |
18 | + "notify_slack", | |
19 | + "notify_discord", | |
20 | + "notify_teams", | |
21 | + "trigger_ai_review", | |
22 | + "trigger_iac_hook", | |
23 | + "trigger_cloud_deploy", | |
24 | +]); | |
25 | + | |
26 | +const DEFAULT_MAX_ATTEMPTS = 1; | |
27 | +const DEFAULT_RETRYABLE_MAX_ATTEMPTS = 3; | |
28 | + | |
29 | +function sleep(ms: number): Promise<void> { | |
30 | + return new Promise((resolve) => setTimeout(resolve, ms)); | |
31 | +} | |
32 | + | |
33 | +function getMaxAttemptsForAction(action: AutomationAction): number { | |
34 | + const requested = Number(action.params.maxAttempts); | |
35 | + if (Number.isInteger(requested) && requested >= 1 && requested <= 5) { | |
36 | + return requested; | |
37 | + } | |
38 | + return RETRYABLE_ACTIONS.has(action.type) | |
39 | + ? DEFAULT_RETRYABLE_MAX_ATTEMPTS | |
40 | + : DEFAULT_MAX_ATTEMPTS; | |
41 | +} | |
42 | + | |
43 | +async function recordDeadLetterExecution(options: { | |
44 | + ruleId: string; | |
45 | + executionId: string; | |
46 | + repositoryId?: string | null; | |
47 | + triggeredById?: string; | |
48 | + triggeredByType: string; | |
49 | + triggerEvent: string; | |
50 | + errorMessage: string; | |
51 | + actionType?: string; | |
52 | +}) { | |
53 | + try { | |
54 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
55 | + await db.insert(schema.auditLogs).values({ | |
56 | + id: generateId(), | |
57 | + userId: null, | |
58 | + repositoryId: options.repositoryId || null, | |
59 | + organizationId: null, | |
60 | + action: "automation.dead_letter", | |
61 | + actorType: "system", | |
62 | + actorId: "automation-engine", | |
63 | + targetType: "automation_execution", | |
64 | + targetId: options.executionId, | |
65 | + targetName: options.ruleId, | |
66 | + data: JSON.stringify({ | |
67 | + ruleId: options.ruleId, | |
68 | + executionId: options.executionId, | |
69 | + triggeredById: options.triggeredById, | |
70 | + triggeredByType: options.triggeredByType, | |
71 | + triggerEvent: options.triggerEvent, | |
72 | + actionType: options.actionType || null, | |
73 | + errorMessage: options.errorMessage, | |
74 | + deadLetteredAt: new Date().toISOString(), | |
75 | + }), | |
76 | + createdAt: new Date(), | |
77 | + }); | |
78 | + } catch (error) { | |
79 | + logger.error( | |
80 | + { | |
81 | + error: error instanceof Error ? error.message : "Unknown error", | |
82 | + executionId: options.executionId, | |
83 | + }, | |
84 | + "Failed to record automation dead-letter audit log", | |
85 | + ); | |
86 | + } | |
87 | +} | |
16 | 88 | |
17 | 89 | /** |
18 | 90 | * Trigger an automation event |
19 | 91 | * This is the main entry point for automation processing |
20 | 92 | */ |
21 | 93 | export async function triggerAutomation( |
22 | - repositoryId: string, | |
23 | - event: AutomationTrigger, | |
24 | - context: { | |
25 | - pullRequestId?: string; | |
26 | - issueId?: string; | |
27 | - labelName?: string; | |
28 | - userId?: string; | |
29 | - metadata?: Record<string, unknown>; | |
30 | - } | |
94 | + repositoryId: string, | |
95 | + event: AutomationTrigger, | |
96 | + context: { | |
97 | + pullRequestId?: string; | |
98 | + issueId?: string; | |
99 | + labelName?: string; | |
100 | + userId?: string; | |
101 | + metadata?: Record<string, unknown>; | |
102 | + }, | |
31 | 103 | ) { |
32 | - const db = getDatabase() as NodePgDatabase<typeof schema>; | |
33 | - const startTime = Date.now(); | |
34 | - | |
35 | - try { | |
36 | - // Get all enabled rules for this trigger | |
37 | - const rules = await db.query.automationRules.findMany({ | |
38 | - where: and( | |
39 | - eq(schema.automationRules.repositoryId, repositoryId), | |
40 | - eq(schema.automationRules.trigger, event), | |
41 | - eq(schema.automationRules.isEnabled, true) | |
42 | - ), | |
43 | - orderBy: [desc(schema.automationRules.priority)], | |
44 | - }); | |
104 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
105 | + const startTime = Date.now(); | |
106 | + | |
107 | + try { | |
108 | + // Get all enabled rules for this trigger | |
109 | + const rules = await db.query.automationRules.findMany({ | |
110 | + where: and( | |
111 | + eq(schema.automationRules.repositoryId, repositoryId), | |
112 | + eq(schema.automationRules.trigger, event), | |
113 | + eq(schema.automationRules.isEnabled, true), | |
114 | + ), | |
115 | + orderBy: [desc(schema.automationRules.priority)], | |
116 | + }); | |
45 | 117 | |
46 | - if (rules.length === 0) { | |
47 | - return { triggered: 0, executed: 0 }; | |
48 | - } | |
118 | + if (rules.length === 0) { | |
119 | + return { triggered: 0, executed: 0 }; | |
120 | + } | |
49 | 121 | |
50 | - let executed = 0; | |
122 | + let executed = 0; | |
123 | + | |
124 | + for (const rule of rules) { | |
125 | + const executionId = generateId(); | |
126 | + | |
127 | + try { | |
128 | + // Check conditions | |
129 | + const conditions = rule.conditions | |
130 | + ? (JSON.parse(rule.conditions) as AutomationCondition[]) | |
131 | + : []; | |
132 | + const conditionsMatched = await evaluateConditions(conditions, context); | |
133 | + | |
134 | + // Log execution start | |
135 | + await db.insert(schema.automationExecutions).values({ | |
136 | + id: executionId, | |
137 | + ruleId: rule.id, | |
138 | + triggeredById: context.pullRequestId || context.issueId, | |
139 | + triggeredByType: context.pullRequestId | |
140 | + ? "pull_request" | |
141 | + : context.issueId | |
142 | + ? "issue" | |
143 | + : "other", | |
144 | + triggerEvent: event, | |
145 | + conditionsMatched, | |
146 | + status: "pending", | |
147 | + createdAt: new Date(), | |
148 | + }); | |
51 | 149 | |
52 | - for (const rule of rules) { | |
53 | - const executionId = generateId(); | |
150 | + if (!conditionsMatched) { | |
151 | + await db | |
152 | + .update(schema.automationExecutions) | |
153 | + .set({ status: "skipped" }) | |
154 | + .where(eq(schema.automationExecutions.id, executionId)); | |
155 | + continue; | |
156 | + } | |
54 | 157 | |
158 | + // Execute actions | |
159 | + const actions = JSON.parse(rule.actions) as AutomationAction[]; | |
160 | + const executedActions: Array<{ | |
161 | + type: string; | |
162 | + status: "success" | "failed"; | |
163 | + attempts: number; | |
164 | + error?: string; | |
165 | + }> = []; | |
166 | + | |
167 | + for (const action of actions) { | |
168 | + const maxAttempts = getMaxAttemptsForAction(action); | |
169 | + let lastError: Error | null = null; | |
170 | + let attempts = 0; | |
171 | + | |
172 | + for (let attempt = 1; attempt <= maxAttempts; attempt++) { | |
173 | + attempts = attempt; | |
55 | 174 | try { |
56 | - // Check conditions | |
57 | - const conditions = rule.conditions ? JSON.parse(rule.conditions) as AutomationCondition[] : []; | |
58 | - const conditionsMatched = await evaluateConditions(conditions, context); | |
59 | - | |
60 | - // Log execution start | |
61 | - await db.insert(schema.automationExecutions).values({ | |
62 | - id: executionId, | |
63 | - ruleId: rule.id, | |
64 | - triggeredById: context.pullRequestId || context.issueId, | |
65 | - triggeredByType: context.pullRequestId ? "pull_request" : context.issueId ? "issue" : "other", | |
66 | - triggerEvent: event, | |
67 | - conditionsMatched, | |
68 | - status: "pending", | |
69 | - createdAt: new Date(), | |
70 | - }); | |
71 | - | |
72 | - if (!conditionsMatched) { | |
73 | - await db | |
74 | - .update(schema.automationExecutions) | |
75 | - .set({ status: "skipped" }) | |
76 | - .where(eq(schema.automationExecutions.id, executionId)); | |
77 | - continue; | |
78 | - } | |
79 | - | |
80 | - // Execute actions | |
81 | - const actions = JSON.parse(rule.actions) as AutomationAction[]; | |
82 | - const executedActions: string[] = []; | |
83 | - | |
84 | - for (const action of actions) { | |
85 | - await executeAction(action, context); | |
86 | - executedActions.push(action.type); | |
87 | - } | |
88 | - | |
89 | - // Update execution record | |
90 | - const durationMs = Date.now() - startTime; | |
91 | - await db | |
92 | - .update(schema.automationExecutions) | |
93 | - .set({ | |
94 | - status: "success", | |
95 | - actionsExecuted: JSON.stringify(executedActions), | |
96 | - durationMs, | |
97 | - }) | |
98 | - .where(eq(schema.automationExecutions.id, executionId)); | |
99 | - | |
100 | - // Update rule stats | |
101 | - await db | |
102 | - .update(schema.automationRules) | |
103 | - .set({ | |
104 | - runCount: (rule.runCount || 0) + 1, | |
105 | - lastRunAt: new Date(), | |
106 | - }) | |
107 | - .where(eq(schema.automationRules.id, rule.id)); | |
108 | - | |
109 | - executed++; | |
110 | - | |
175 | + await executeAction(action, context); | |
176 | + lastError = null; | |
177 | + break; | |
111 | 178 | } catch (error) { |
112 | - logger.error("Automation execution failed", { | |
113 | - ruleId: rule.id, | |
114 | - error: error instanceof Error ? error.message : "Unknown error", | |
115 | - }); | |
116 | - | |
117 | - await db | |
118 | - .update(schema.automationExecutions) | |
119 | - .set({ | |
120 | - status: "failed", | |
121 | - errorMessage: error instanceof Error ? error.message : "Unknown error", | |
122 | - durationMs: Date.now() - startTime, | |
123 | - }) | |
124 | - .where(eq(schema.automationExecutions.id, executionId)); | |
179 | + lastError = | |
180 | + error instanceof Error | |
181 | + ? error | |
182 | + : new Error("Unknown action execution error"); | |
183 | + const isLastAttempt = attempt === maxAttempts; | |
184 | + if (!isLastAttempt) { | |
185 | + // Small bounded backoff to improve external API reliability without stalling workers. | |
186 | + await sleep(25 * attempt); | |
187 | + } | |
125 | 188 | } |
189 | + } | |
190 | + | |
191 | + if (lastError) { | |
192 | + executedActions.push({ | |
193 | + type: action.type, | |
194 | + status: "failed", | |
195 | + attempts, | |
196 | + error: lastError.message, | |
197 | + }); | |
198 | + | |
199 | + await recordDeadLetterExecution({ | |
200 | + ruleId: rule.id, | |
201 | + executionId, | |
202 | + repositoryId: rule.repositoryId, | |
203 | + triggeredById: context.pullRequestId || context.issueId, | |
204 | + triggeredByType: context.pullRequestId | |
205 | + ? "pull_request" | |
206 | + : context.issueId | |
207 | + ? "issue" | |
208 | + : "other", | |
209 | + triggerEvent: event, | |
210 | + errorMessage: lastError.message, | |
211 | + actionType: action.type, | |
212 | + }); | |
213 | + | |
214 | + throw lastError; | |
215 | + } | |
216 | + | |
217 | + executedActions.push({ | |
218 | + type: action.type, | |
219 | + status: "success", | |
220 | + attempts, | |
221 | + }); | |
126 | 222 | } |
127 | 223 | |
128 | - return { triggered: rules.length, executed }; | |
224 | + // Update execution record | |
225 | + const durationMs = Date.now() - startTime; | |
226 | + await db | |
227 | + .update(schema.automationExecutions) | |
228 | + .set({ | |
229 | + status: "success", | |
230 | + actionsExecuted: JSON.stringify(executedActions), | |
231 | + durationMs, | |
232 | + }) | |
233 | + .where(eq(schema.automationExecutions.id, executionId)); | |
234 | + | |
235 | + // Update rule stats | |
236 | + await db | |
237 | + .update(schema.automationRules) | |
238 | + .set({ | |
239 | + runCount: (rule.runCount || 0) + 1, | |
240 | + lastRunAt: new Date(), | |
241 | + }) | |
242 | + .where(eq(schema.automationRules.id, rule.id)); | |
243 | + | |
244 | + executed++; | |
245 | + } catch (error) { | |
246 | + logger.error("Automation execution failed", { | |
247 | + ruleId: rule.id, | |
248 | + error: error instanceof Error ? error.message : "Unknown error", | |
249 | + }); | |
129 | 250 | |
130 | - } catch (error) { | |
131 | - logger.error("Failed to trigger automation", { | |
132 | - repositoryId, | |
133 | - event, | |
134 | - error: error instanceof Error ? error.message : "Unknown error", | |
251 | + await db | |
252 | + .update(schema.automationExecutions) | |
253 | + .set({ | |
254 | + status: "failed", | |
255 | + errorMessage: | |
256 | + error instanceof Error ? error.message : "Unknown error", | |
257 | + durationMs: Date.now() - startTime, | |
258 | + }) | |
259 | + .where(eq(schema.automationExecutions.id, executionId)); | |
260 | + | |
261 | + await recordDeadLetterExecution({ | |
262 | + ruleId: rule.id, | |
263 | + executionId, | |
264 | + repositoryId: rule.repositoryId, | |
265 | + triggeredById: context.pullRequestId || context.issueId, | |
266 | + triggeredByType: context.pullRequestId | |
267 | + ? "pull_request" | |
268 | + : context.issueId | |
269 | + ? "issue" | |
270 | + : "other", | |
271 | + triggerEvent: event, | |
272 | + errorMessage: | |
273 | + error instanceof Error ? error.message : "Unknown error", | |
135 | 274 | }); |
136 | - throw error; | |
275 | + } | |
137 | 276 | } |
277 | + | |
278 | + return { triggered: rules.length, executed }; | |
279 | + } catch (error) { | |
280 | + logger.error("Failed to trigger automation", { | |
281 | + repositoryId, | |
282 | + event, | |
283 | + error: error instanceof Error ? error.message : "Unknown error", | |
284 | + }); | |
285 | + throw error; | |
286 | + } | |
138 | 287 | } |
139 | 288 | |
140 | 289 | /** |
141 | 290 | * Evaluate conditions against the context |
142 | 291 | */ |
143 | 292 | async function evaluateConditions( |
144 | - conditions: AutomationCondition[], | |
145 | - context: Record<string, unknown> | |
293 | + conditions: AutomationCondition[], | |
294 | + context: Record<string, unknown>, | |
146 | 295 | ): Promise<boolean> { |
147 | - if (conditions.length === 0) return true; | |
296 | + if (conditions.length === 0) return true; | |
148 | 297 | |
149 | - for (const condition of conditions) { | |
150 | - const value = getNestedValue(context, condition.field); | |
151 | - const matched = evaluateCondition(condition.operator, value, condition.value); | |
298 | + for (const condition of conditions) { | |
299 | + const value = getNestedValue(context, condition.field); | |
300 | + const matched = evaluateCondition( | |
301 | + condition.operator, | |
302 | + value, | |
303 | + condition.value, | |
304 | + ); | |
152 | 305 | |
153 | - if (!matched) return false; | |
154 | - } | |
306 | + if (!matched) return false; | |
307 | + } | |
155 | 308 | |
156 | - return true; | |
309 | + return true; | |
157 | 310 | } |
158 | 311 | |
159 | 312 | function getNestedValue(obj: Record<string, unknown>, path: string): unknown { |
160 | - return path.split(".").reduce<unknown>((acc, key) => | |
161 | - acc && typeof acc === "object" ? (acc as Record<string, unknown>)[key] : undefined | |
162 | - , obj as unknown); | |
313 | + return path | |
314 | + .split(".") | |
315 | + .reduce<unknown>( | |
316 | + (acc, key) => | |
317 | + acc && typeof acc === "object" | |
318 | + ? (acc as Record<string, unknown>)[key] | |
319 | + : undefined, | |
320 | + obj as unknown, | |
321 | + ); | |
163 | 322 | } |
164 | 323 | |
165 | 324 | function evaluateCondition( |
166 | - operator: string, | |
167 | - actual: unknown, | |
168 | - expected: unknown | |
325 | + operator: string, | |
326 | + actual: unknown, | |
327 | + expected: unknown, | |
169 | 328 | ): boolean { |
170 | - switch (operator) { | |
171 | - case "equals": | |
172 | - return actual === expected; | |
173 | - case "not_equals": | |
174 | - return actual !== expected; | |
175 | - case "contains": | |
176 | - if (Array.isArray(actual)) { | |
177 | - return actual.includes(expected); | |
178 | - } | |
179 | - if (typeof actual === "string" && typeof expected === "string") { | |
180 | - return actual.includes(expected); | |
181 | - } | |
182 | - return false; | |
183 | - case "not_contains": | |
184 | - if (Array.isArray(actual)) { | |
185 | - return !actual.includes(expected); | |
186 | - } | |
187 | - if (typeof actual === "string" && typeof expected === "string") { | |
188 | - return !actual.includes(expected); | |
189 | - } | |
190 | - return true; | |
191 | - case "matches": | |
192 | - if (typeof actual === "string" && typeof expected === "string") { | |
193 | - return new RegExp(expected).test(actual); | |
194 | - } | |
195 | - return false; | |
196 | - case "greater_than": | |
197 | - return typeof actual === "number" && typeof expected === "number" && actual > expected; | |
198 | - case "less_than": | |
199 | - return typeof actual === "number" && typeof expected === "number" && actual < expected; | |
200 | - default: | |
201 | - return false; | |
202 | - } | |
329 | + switch (operator) { | |
330 | + case "equals": | |
331 | + return actual === expected; | |
332 | + case "not_equals": | |
333 | + return actual !== expected; | |
334 | + case "contains": | |
335 | + if (Array.isArray(actual)) { | |
336 | + return actual.includes(expected); | |
337 | + } | |
338 | + if (typeof actual === "string" && typeof expected === "string") { | |
339 | + return actual.includes(expected); | |
340 | + } | |
341 | + return false; | |
342 | + case "not_contains": | |
343 | + if (Array.isArray(actual)) { | |
344 | + return !actual.includes(expected); | |
345 | + } | |
346 | + if (typeof actual === "string" && typeof expected === "string") { | |
347 | + return !actual.includes(expected); | |
348 | + } | |
349 | + return true; | |
350 | + case "matches": | |
351 | + if (typeof actual === "string" && typeof expected === "string") { | |
352 | + try { | |
353 | + // Safety: limit pattern length and apply timeout to prevent ReDoS | |
354 | + if (expected.length > 200) return false; | |
355 | + const regex = new RegExp(expected); | |
356 | + return regex.test(actual.slice(0, 10_000)); | |
357 | + } catch { | |
358 | + // Invalid regex pattern provided by user | |
359 | + return false; | |
360 | + } | |
361 | + } | |
362 | + return false; | |
363 | + case "greater_than": | |
364 | + return ( | |
365 | + typeof actual === "number" && | |
366 | + typeof expected === "number" && | |
367 | + actual > expected | |
368 | + ); | |
369 | + case "less_than": | |
370 | + return ( | |
371 | + typeof actual === "number" && | |
372 | + typeof expected === "number" && | |
373 | + actual < expected | |
374 | + ); | |
375 | + default: | |
376 | + return false; | |
377 | + } | |
203 | 378 | } |
204 | 379 | |
205 | 380 | /** |
206 | 381 | * Execute an automation action |
207 | 382 | */ |
208 | 383 | async function executeAction( |
209 | - action: AutomationAction, | |
210 | - context: { | |
211 | - pullRequestId?: string; | |
212 | - issueId?: string; | |
213 | - userId?: string; | |
214 | - metadata?: Record<string, unknown>; | |
215 | - } | |
384 | + action: AutomationAction, | |
385 | + context: { | |
386 | + pullRequestId?: string; | |
387 | + issueId?: string; | |
388 | + userId?: string; | |
389 | + metadata?: Record<string, unknown>; | |
390 | + }, | |
216 | 391 | ) { |
217 | - const db = getDatabase() as NodePgDatabase<typeof schema>; | |
218 | - | |
219 | - switch (action.type) { | |
220 | - case "add_label": { | |
221 | - if (context.pullRequestId && action.params.label) { | |
222 | - // Add label to PR | |
223 | - await db.insert(schema.pullRequestLabels).values({ | |
224 | - id: generateId(), | |
225 | - pullRequestId: context.pullRequestId, | |
226 | - labelId: action.params.label as string, | |
227 | - }).onConflictDoNothing(); | |
228 | - } | |
229 | - break; | |
230 | - } | |
392 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
393 | + | |
394 | + switch (action.type) { | |
395 | + case "add_label": { | |
396 | + if (context.pullRequestId && action.params.label) { | |
397 | + // Add label to PR | |
398 | + await db | |
399 | + .insert(schema.pullRequestLabels) | |
400 | + .values({ | |
401 | + id: generateId(), | |
402 | + pullRequestId: context.pullRequestId, | |
403 | + labelId: action.params.label as string, | |
404 | + }) | |
405 | + .onConflictDoNothing(); | |
406 | + } | |
407 | + break; | |
408 | + } | |
231 | 409 | |
232 | - case "remove_label": { | |
233 | - if (context.pullRequestId && action.params.label) { | |
234 | - await db | |
235 | - .delete(schema.pullRequestLabels) | |
236 | - .where(and( | |
237 | - eq(schema.pullRequestLabels.pullRequestId, context.pullRequestId), | |
238 | - eq(schema.pullRequestLabels.labelId, action.params.label as string) | |
239 | - )); | |
240 | - } | |
241 | - break; | |
242 | - } | |
410 | + case "remove_label": { | |
411 | + if (context.pullRequestId && action.params.label) { | |
412 | + await db | |
413 | + .delete(schema.pullRequestLabels) | |
414 | + .where( | |
415 | + and( | |
416 | + eq(schema.pullRequestLabels.pullRequestId, context.pullRequestId), | |
417 | + eq( | |
418 | + schema.pullRequestLabels.labelId, | |
419 | + action.params.label as string, | |
420 | + ), | |
421 | + ), | |
422 | + ); | |
423 | + } | |
424 | + break; | |
425 | + } | |
243 | 426 | |
244 | - case "assign_reviewer": { | |
245 | - if (context.pullRequestId && action.params.assignee) { | |
246 | - const assignee = action.params.assignee as string; | |
247 | - | |
248 | - // Handle @codeowners - assign reviewers based on CODEOWNERS file | |
249 | - if (assignee === "@codeowners") { | |
250 | - try { | |
251 | - // Get PR details with changed files | |
252 | - const pr = await db.query.pullRequests.findFirst({ | |
253 | - where: eq(schema.pullRequests.id, context.pullRequestId), | |
254 | - with: { repository: true }, | |
255 | - }); | |
256 | - | |
257 | - if (pr && pr.repository) { | |
258 | - // Dynamically import to avoid circular deps | |
259 | - const { resolveRepoPath } = await import("./git-storage"); | |
260 | - const { getFileContent } = await import("./git"); | |
261 | - const { CODEOWNERS_PATHS, getSuggestedReviewers } = await import("./codeowners"); | |
262 | - const { compareBranches } = await import("./git"); | |
263 | - | |
264 | - const repoPath = await resolveRepoPath(pr.repository.diskPath); | |
265 | - | |
266 | - // Try to find CODEOWNERS file | |
267 | - let codeOwnersContent: string | null = null; | |
268 | - for (const path of CODEOWNERS_PATHS) { | |
269 | - try { | |
270 | - const result = await getFileContent(repoPath, path, pr.baseBranch); | |
271 | - if (result && !result.isBinary) { | |
272 | - codeOwnersContent = result.content; | |
273 | - break; | |
274 | - } | |
275 | - } catch { | |
276 | - // File doesn't exist at this path | |
277 | - } | |
278 | - } | |
279 | - | |
280 | - if (codeOwnersContent) { | |
281 | - // Get changed files | |
282 | - const { diffs } = await compareBranches(repoPath, pr.baseBranch, pr.headBranch); | |
283 | - const changedFiles = diffs.map(d => d.file); | |
284 | - | |
285 | - // Get PR author username for exclusion | |
286 | - const prAuthor = await db.query.users.findFirst({ | |
287 | - where: eq(schema.users.id, pr.authorId), | |
288 | - }); | |
289 | - | |
290 | - // Get suggested reviewers | |
291 | - const { owners } = await getSuggestedReviewers( | |
292 | - codeOwnersContent, | |
293 | - changedFiles, | |
294 | - prAuthor ? [prAuthor.username] : [] | |
295 | - ); | |
296 | - | |
297 | - // Add each owner as reviewer | |
298 | - for (const owner of owners) { | |
299 | - const user = await db.query.users.findFirst({ | |
300 | - where: eq(schema.users.username, owner), | |
301 | - }); | |
302 | - | |
303 | - if (user) { | |
304 | - await db.insert(schema.pullRequestReviewers).values({ | |
305 | - id: generateId(), | |
306 | - pullRequestId: context.pullRequestId, | |
307 | - userId: user.id, | |
308 | - requestedAt: new Date(), | |
309 | - }).onConflictDoNothing(); | |
310 | - } | |
311 | - } | |
312 | - } | |
313 | - } | |
314 | - } catch (error) { | |
315 | - logger.warn({ error }, "Failed to assign CODEOWNERS reviewers"); | |
316 | - } | |
427 | + case "assign_reviewer": { | |
428 | + if (context.pullRequestId && action.params.assignee) { | |
429 | + const assignee = action.params.assignee as string; | |
430 | + | |
431 | + // Handle @codeowners - assign reviewers based on CODEOWNERS file | |
432 | + if (assignee === "@codeowners") { | |
433 | + try { | |
434 | + // Get PR details with changed files | |
435 | + const pr = await db.query.pullRequests.findFirst({ | |
436 | + where: eq(schema.pullRequests.id, context.pullRequestId), | |
437 | + with: { repository: true }, | |
438 | + }); | |
439 | + | |
440 | + if (pr && pr.repository) { | |
441 | + // Dynamically import to avoid circular deps | |
442 | + const { resolveRepoPath } = await import("./git-storage"); | |
443 | + const { getFileContent } = await import("./git"); | |
444 | + const { CODEOWNERS_PATHS, getSuggestedReviewers } = | |
445 | + await import("./codeowners"); | |
446 | + const { compareBranches } = await import("./git"); | |
447 | + | |
448 | + const repoPath = await resolveRepoPath(pr.repository.diskPath); | |
449 | + | |
450 | + // Try to find CODEOWNERS file | |
451 | + let codeOwnersContent: string | null = null; | |
452 | + for (const path of CODEOWNERS_PATHS) { | |
453 | + try { | |
454 | + const result = await getFileContent( | |
455 | + repoPath, | |
456 | + path, | |
457 | + pr.baseBranch, | |
458 | + ); | |
459 | + if (result && !result.isBinary) { | |
460 | + codeOwnersContent = result.content; | |
317 | 461 | break; |
462 | + } | |
463 | + } catch { | |
464 | + // File doesn't exist at this path | |
318 | 465 | } |
466 | + } | |
467 | + | |
468 | + if (codeOwnersContent) { | |
469 | + // Get changed files | |
470 | + const { diffs } = await compareBranches( | |
471 | + repoPath, | |
472 | + pr.baseBranch, | |
473 | + pr.headBranch, | |
474 | + ); | |
475 | + const changedFiles = diffs.map((d) => d.file); | |
319 | 476 | |
320 | - // Find user by username | |
321 | - const user = await db.query.users.findFirst({ | |
322 | - where: eq(schema.users.username, assignee.replace("@", "")), | |
477 | + // Get PR author username for exclusion | |
478 | + const prAuthor = await db.query.users.findFirst({ | |
479 | + where: eq(schema.users.id, pr.authorId), | |
323 | 480 | }); |
324 | 481 | |
325 | - if (user) { | |
326 | - await db.insert(schema.pullRequestReviewers).values({ | |
482 | + // Get suggested reviewers | |
483 | + const { owners } = await getSuggestedReviewers( | |
484 | + codeOwnersContent, | |
485 | + changedFiles, | |
486 | + prAuthor ? [prAuthor.username] : [], | |
487 | + ); | |
488 | + | |
489 | + // Add each owner as reviewer | |
490 | + for (const owner of owners) { | |
491 | + const user = await db.query.users.findFirst({ | |
492 | + where: eq(schema.users.username, owner), | |
493 | + }); | |
494 | + | |
495 | + if (user) { | |
496 | + await db | |
497 | + .insert(schema.pullRequestReviewers) | |
498 | + .values({ | |
327 | 499 | id: generateId(), |
328 | 500 | pullRequestId: context.pullRequestId, |
329 | 501 | userId: user.id, |
330 | 502 | requestedAt: new Date(), |
331 | - }).onConflictDoNothing(); | |
503 | + }) | |
504 | + .onConflictDoNothing(); | |
505 | + } | |
332 | 506 | } |
507 | + } | |
333 | 508 | } |
334 | - break; | |
509 | + } catch (error) { | |
510 | + logger.warn({ error }, "Failed to assign CODEOWNERS reviewers"); | |
511 | + } | |
512 | + break; | |
335 | 513 | } |
336 | 514 | |
337 | - case "add_comment": { | |
338 | - if (context.pullRequestId && action.params.body) { | |
339 | - await db.insert(schema.pullRequestComments).values({ | |
340 | - id: generateId(), | |
341 | - pullRequestId: context.pullRequestId, | |
342 | - authorId: context.userId || "system", | |
343 | - body: action.params.body as string, | |
344 | - createdAt: new Date(), | |
345 | - updatedAt: new Date(), | |
346 | - }); | |
347 | - } | |
348 | - break; | |
515 | + // Find user by username | |
516 | + const user = await db.query.users.findFirst({ | |
517 | + where: eq(schema.users.username, assignee.replace("@", "")), | |
518 | + }); | |
519 | + | |
520 | + if (user) { | |
521 | + await db | |
522 | + .insert(schema.pullRequestReviewers) | |
523 | + .values({ | |
524 | + id: generateId(), | |
525 | + pullRequestId: context.pullRequestId, | |
526 | + userId: user.id, | |
527 | + requestedAt: new Date(), | |
528 | + }) | |
529 | + .onConflictDoNothing(); | |
349 | 530 | } |
531 | + } | |
532 | + break; | |
533 | + } | |
350 | 534 | |
351 | - case "add_to_merge_queue": { | |
352 | - if (context.pullRequestId) { | |
353 | - const { addToMergeQueue } = await import("./merge-queue"); | |
354 | - const pr = await db.query.pullRequests.findFirst({ | |
355 | - where: eq(schema.pullRequests.id, context.pullRequestId), | |
356 | - }); | |
535 | + case "add_comment": { | |
536 | + if (context.pullRequestId && action.params.body) { | |
537 | + await db.insert(schema.pullRequestComments).values({ | |
538 | + id: generateId(), | |
539 | + pullRequestId: context.pullRequestId, | |
540 | + authorId: context.userId || "system", | |
541 | + body: action.params.body as string, | |
542 | + createdAt: new Date(), | |
543 | + updatedAt: new Date(), | |
544 | + }); | |
545 | + } | |
546 | + break; | |
547 | + } | |
357 | 548 | |
358 | - if (pr) { | |
359 | - await addToMergeQueue({ | |
360 | - repositoryId: pr.repositoryId, | |
361 | - pullRequestId: context.pullRequestId, | |
362 | - addedById: context.userId || "system", | |
363 | - mergeMethod: (action.params.method as "merge" | "squash" | "rebase") || "squash", | |
364 | - }); | |
365 | - } | |
366 | - } | |
367 | - break; | |
368 | - } | |
549 | + case "add_to_merge_queue": { | |
550 | + if (context.pullRequestId) { | |
551 | + const { addToMergeQueue } = await import("./merge-queue"); | |
552 | + const pr = await db.query.pullRequests.findFirst({ | |
553 | + where: eq(schema.pullRequests.id, context.pullRequestId), | |
554 | + }); | |
369 | 555 | |
370 | - case "trigger_ai_review": { | |
371 | - if (context.pullRequestId) { | |
372 | - const { triggerAIReview } = await import("./ai-review"); | |
373 | - await triggerAIReview( | |
374 | - context.pullRequestId, | |
375 | - context.userId || "system", | |
376 | - { | |
377 | - provider: "anthropic" as const, | |
378 | - model: "claude-3-sonnet" as const, | |
379 | - } | |
380 | - ); | |
381 | - } | |
382 | - break; | |
556 | + if (pr) { | |
557 | + await addToMergeQueue({ | |
558 | + repositoryId: pr.repositoryId, | |
559 | + pullRequestId: context.pullRequestId, | |
560 | + addedById: context.userId || "system", | |
561 | + mergeMethod: | |
562 | + (action.params.method as "merge" | "squash" | "rebase") || | |
563 | + "squash", | |
564 | + }); | |
383 | 565 | } |
566 | + } | |
567 | + break; | |
568 | + } | |
384 | 569 | |
385 | - case "notify_slack": { | |
386 | - if (context.pullRequestId && action.params.channel) { | |
387 | - const { notifyPrEvent } = await import("./slack-notifications"); | |
388 | - const pr = await db.query.pullRequests.findFirst({ | |
389 | - where: eq(schema.pullRequests.id, context.pullRequestId), | |
390 | - with: { repository: true, author: true }, | |
391 | - }); | |
570 | + case "trigger_ai_review": { | |
571 | + if (context.pullRequestId) { | |
572 | + const { triggerAIReview } = await import("./ai-review"); | |
573 | + await triggerAIReview( | |
574 | + context.pullRequestId, | |
575 | + context.userId || "system", | |
576 | + { | |
577 | + provider: "anthropic" as const, | |
578 | + model: "claude-3-sonnet" as const, | |
579 | + }, | |
580 | + ); | |
581 | + } | |
582 | + break; | |
583 | + } | |
392 | 584 | |
393 | - if (pr && pr.repository) { | |
394 | - // Would need org ID detection | |
395 | - // await notifyPrEvent(...) | |
396 | - } | |
397 | - } | |
398 | - break; | |
585 | + case "notify_slack": { | |
586 | + if (context.pullRequestId && action.params.channel) { | |
587 | + const { notifyPrEvent } = await import("./slack-notifications"); | |
588 | + const pr = await db.query.pullRequests.findFirst({ | |
589 | + where: eq(schema.pullRequests.id, context.pullRequestId), | |
590 | + with: { repository: true, author: true }, | |
591 | + }); | |
592 | + | |
593 | + if (pr && pr.repository) { | |
594 | + // Would need org ID detection | |
595 | + // await notifyPrEvent(...) | |
399 | 596 | } |
597 | + } | |
598 | + break; | |
599 | + } | |
400 | 600 | |
401 | - case "notify_discord": { | |
402 | - if (context.pullRequestId && action.params.webhookUrl) { | |
403 | - const { sendDiscordMessage } = await import("./integrations/discord"); | |
404 | - const pr = await db.query.pullRequests.findFirst({ | |
405 | - where: eq(schema.pullRequests.id, context.pullRequestId), | |
406 | - with: { repository: true, author: true }, | |
407 | - }); | |
601 | + case "notify_discord": { | |
602 | + if (context.pullRequestId && action.params.webhookUrl) { | |
603 | + const { sendDiscordMessage } = await import("./integrations/discord"); | |
604 | + const pr = await db.query.pullRequests.findFirst({ | |
605 | + where: eq(schema.pullRequests.id, context.pullRequestId), | |
606 | + with: { repository: true, author: true }, | |
607 | + }); | |
408 | 608 | |
409 | - if (pr && pr.repository) { | |
410 | - await sendDiscordMessage(action.params.webhookUrl as string, { | |
411 | - content: `**${pr.title}** (#${pr.number}) in ${pr.repository.name}\nState: ${pr.state}`, | |
412 | - username: "OpenCodeHub" | |
413 | - }); | |
414 | - } | |
415 | - } | |
416 | - break; | |
609 | + if (pr && pr.repository) { | |
610 | + await sendDiscordMessage(action.params.webhookUrl as string, { | |
611 | + content: `**${pr.title}** (#${pr.number}) in ${pr.repository.name}\nState: ${pr.state}`, | |
612 | + username: "OpenCodeHub", | |
613 | + }); | |
417 | 614 | } |
615 | + } | |
616 | + break; | |
617 | + } | |
418 | 618 | |
419 | - case "notify_teams": { | |
420 | - if (context.pullRequestId && action.params.webhookUrl) { | |
421 | - const { sendTeamsMessage } = await import("./integrations/teams"); | |
422 | - const pr = await db.query.pullRequests.findFirst({ | |
423 | - where: eq(schema.pullRequests.id, context.pullRequestId), | |
424 | - with: { repository: true, author: true }, | |
425 | - }); | |
619 | + case "notify_teams": { | |
620 | + if (context.pullRequestId && action.params.webhookUrl) { | |
621 | + const { sendTeamsMessage } = await import("./integrations/teams"); | |
622 | + const pr = await db.query.pullRequests.findFirst({ | |
623 | + where: eq(schema.pullRequests.id, context.pullRequestId), | |
624 | + with: { repository: true, author: true }, | |
625 | + }); | |
426 | 626 | |
427 | - if (pr && pr.repository) { | |
428 | - await sendTeamsMessage(action.params.webhookUrl as string, { | |
429 | - "@type": "MessageCard", | |
430 | - "@context": "http://schema.org/extensions", | |
431 | - themeColor: "0078d4", | |
432 | - summary: `PR Update: ${pr.title}`, | |
433 | - sections: [{ | |
434 | - text: `**${pr.title}** (#${pr.number}) in ${pr.repository.name}\nStatus: ${pr.state}` | |
435 | - }] | |
436 | - }); | |
437 | - } | |
438 | - } | |
439 | - break; | |
627 | + if (pr && pr.repository) { | |
628 | + await sendTeamsMessage(action.params.webhookUrl as string, { | |
629 | + "@type": "MessageCard", | |
630 | + "@context": "http://schema.org/extensions", | |
631 | + themeColor: "0078d4", | |
632 | + summary: `PR Update: ${pr.title}`, | |
633 | + sections: [ | |
634 | + { | |
635 | + text: `**${pr.title}** (#${pr.number}) in ${pr.repository.name}\nStatus: ${pr.state}`, | |
636 | + }, | |
637 | + ], | |
638 | + }); | |
440 | 639 | } |
640 | + } | |
641 | + break; | |
642 | + } | |
441 | 643 | |
442 | - case "close_pr": { | |
443 | - if (context.pullRequestId) { | |
444 | - await db | |
445 | - .update(schema.pullRequests) | |
446 | - .set({ | |
447 | - state: "closed", | |
448 | - closedAt: new Date(), | |
449 | - updatedAt: new Date(), | |
450 | - }) | |
451 | - .where(eq(schema.pullRequests.id, context.pullRequestId)); | |
452 | - } | |
453 | - break; | |
454 | - } | |
644 | + case "close_pr": { | |
645 | + if (context.pullRequestId) { | |
646 | + await db | |
647 | + .update(schema.pullRequests) | |
648 | + .set({ | |
649 | + state: "closed", | |
650 | + closedAt: new Date(), | |
651 | + updatedAt: new Date(), | |
652 | + }) | |
653 | + .where(eq(schema.pullRequests.id, context.pullRequestId)); | |
654 | + } | |
655 | + break; | |
656 | + } | |
455 | 657 | |
456 | - case "assign_user": { | |
457 | - if (context.pullRequestId && action.params.user) { | |
458 | - const username = action.params.user as string; | |
459 | - // Find user | |
460 | - const user = await db.query.users.findFirst({ | |
461 | - where: eq(schema.users.username, username.replace("@", "")), | |
462 | - }); | |
658 | + case "assign_user": { | |
659 | + if (context.pullRequestId && action.params.user) { | |
660 | + const username = action.params.user as string; | |
661 | + // Find user | |
662 | + const user = await db.query.users.findFirst({ | |
663 | + where: eq(schema.users.username, username.replace("@", "")), | |
664 | + }); | |
463 | 665 | |
464 | - if (user) { | |
465 | - const { generateId } = await import("./utils"); | |
466 | - await db.insert(schema.pullRequestAssignees).values({ | |
467 | - id: generateId(), | |
468 | - pullRequestId: context.pullRequestId, | |
469 | - userId: user.id | |
470 | - }).onConflictDoNothing(); | |
471 | - } | |
472 | - } | |
473 | - break; | |
666 | + if (user) { | |
667 | + const { generateId } = await import("./utils"); | |
668 | + await db | |
669 | + .insert(schema.pullRequestAssignees) | |
670 | + .values({ | |
671 | + id: generateId(), | |
672 | + pullRequestId: context.pullRequestId, | |
673 | + userId: user.id, | |
674 | + }) | |
675 | + .onConflictDoNothing(); | |
474 | 676 | } |
677 | + } | |
678 | + break; | |
679 | + } | |
475 | 680 | |
476 | - case "request_changes": { | |
477 | - if (context.pullRequestId && action.params.body) { | |
478 | - const { generateId } = await import("./utils"); | |
479 | - // Create a review | |
480 | - await db.insert(schema.pullRequestReviews).values({ | |
481 | - id: generateId(), | |
482 | - pullRequestId: context.pullRequestId, | |
483 | - reviewerId: context.userId || "system", // Or a bot user info if available | |
484 | - state: "changes_requested", | |
485 | - body: action.params.body as string, | |
486 | - submittedAt: new Date() | |
487 | - }); | |
488 | - } | |
489 | - break; | |
681 | + case "request_changes": { | |
682 | + if (context.pullRequestId && action.params.body) { | |
683 | + const { generateId } = await import("./utils"); | |
684 | + // Create a review | |
685 | + await db.insert(schema.pullRequestReviews).values({ | |
686 | + id: generateId(), | |
687 | + pullRequestId: context.pullRequestId, | |
688 | + reviewerId: context.userId || "system", // Or a bot user info if available | |
689 | + state: "changes_requested", | |
690 | + body: action.params.body as string, | |
691 | + submittedAt: new Date(), | |
692 | + }); | |
693 | + } | |
694 | + break; | |
695 | + } | |
696 | + | |
697 | + case "trigger_iac_hook": { | |
698 | + if (context.pullRequestId) { | |
699 | + const pr = await db.query.pullRequests.findFirst({ | |
700 | + where: eq(schema.pullRequests.id, context.pullRequestId), | |
701 | + }); | |
702 | + if (pr) { | |
703 | + const actionType = | |
704 | + (action.params.action as "plan" | "apply") || "plan"; | |
705 | + const { triggerIaCHooks } = await import("./iac-hooks"); | |
706 | + await triggerIaCHooks({ | |
707 | + repositoryId: pr.repositoryId, | |
708 | + action: actionType, | |
709 | + runId: | |
710 | + typeof action.params.runId === "string" | |
711 | + ? action.params.runId | |
712 | + : undefined, | |
713 | + message: | |
714 | + typeof action.params.message === "string" | |
715 | + ? action.params.message | |
716 | + : undefined, | |
717 | + configId: | |
718 | + typeof action.params.configId === "string" | |
719 | + ? action.params.configId | |
720 | + : undefined, | |
721 | + }); | |
490 | 722 | } |
723 | + } | |
724 | + break; | |
725 | + } | |
491 | 726 | |
492 | - default: | |
493 | - logger.warn("Unknown automation action", { type: action.type }); | |
727 | + case "trigger_cloud_deploy": { | |
728 | + if (context.pullRequestId) { | |
729 | + const pr = await db.query.pullRequests.findFirst({ | |
730 | + where: eq(schema.pullRequests.id, context.pullRequestId), | |
731 | + }); | |
732 | + if (pr) { | |
733 | + const { triggerCloudDeploy } = await import("./cloud-hooks"); | |
734 | + await triggerCloudDeploy({ | |
735 | + repositoryId: pr.repositoryId, | |
736 | + configId: | |
737 | + typeof action.params.configId === "string" | |
738 | + ? action.params.configId | |
739 | + : undefined, | |
740 | + imageTag: | |
741 | + typeof action.params.imageTag === "string" | |
742 | + ? action.params.imageTag | |
743 | + : undefined, | |
744 | + namespace: | |
745 | + typeof action.params.namespace === "string" | |
746 | + ? action.params.namespace | |
747 | + : undefined, | |
748 | + deploymentName: | |
749 | + typeof action.params.deploymentName === "string" | |
750 | + ? action.params.deploymentName | |
751 | + : undefined, | |
752 | + serviceName: | |
753 | + typeof action.params.serviceName === "string" | |
754 | + ? action.params.serviceName | |
755 | + : undefined, | |
756 | + appName: | |
757 | + typeof action.params.appName === "string" | |
758 | + ? action.params.appName | |
759 | + : undefined, | |
760 | + taskDefinition: | |
761 | + typeof action.params.taskDefinition === "string" | |
762 | + ? action.params.taskDefinition | |
763 | + : undefined, | |
764 | + clusterName: | |
765 | + typeof action.params.clusterName === "string" | |
766 | + ? action.params.clusterName | |
767 | + : undefined, | |
768 | + }); | |
769 | + } | |
770 | + } | |
771 | + break; | |
494 | 772 | } |
773 | + | |
774 | + default: | |
775 | + logger.warn("Unknown automation action", { type: action.type }); | |
776 | + } | |
495 | 777 | } |
496 | 778 | |
497 | 779 | /** |
498 | 780 | * Create an automation rule |
499 | 781 | */ |
500 | 782 | export async function createAutomationRule(options: { |
501 | - repositoryId?: string; | |
502 | - organizationId?: string; | |
503 | - createdById: string; | |
504 | - name: string; | |
505 | - description?: string; | |
506 | - trigger: AutomationTrigger; | |
507 | - conditions?: AutomationCondition[]; | |
508 | - actions: AutomationAction[]; | |
509 | - priority?: number; | |
783 | + repositoryId?: string; | |
784 | + organizationId?: string; | |
785 | + createdById: string; | |
786 | + name: string; | |
787 | + description?: string; | |
788 | + trigger: AutomationTrigger; | |
789 | + conditions?: AutomationCondition[]; | |
790 | + actions: AutomationAction[]; | |
791 | + priority?: number; | |
510 | 792 | }) { |
511 | - const db = getDatabase() as NodePgDatabase<typeof schema>; | |
512 | - | |
513 | - const rule = { | |
514 | - id: generateId(), | |
515 | - repositoryId: options.repositoryId, | |
516 | - organizationId: options.organizationId, | |
517 | - createdById: options.createdById, | |
518 | - name: options.name, | |
519 | - description: options.description, | |
520 | - trigger: options.trigger, | |
521 | - conditions: options.conditions ? JSON.stringify(options.conditions) : null, | |
522 | - actions: JSON.stringify(options.actions), | |
523 | - isEnabled: true, | |
524 | - priority: options.priority || 0, | |
525 | - createdAt: new Date(), | |
526 | - updatedAt: new Date(), | |
527 | - }; | |
528 | - | |
529 | - await db.insert(schema.automationRules).values(rule); | |
530 | - | |
531 | - return rule; | |
793 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
794 | + | |
795 | + const rule = { | |
796 | + id: generateId(), | |
797 | + repositoryId: options.repositoryId, | |
798 | + organizationId: options.organizationId, | |
799 | + createdById: options.createdById, | |
800 | + name: options.name, | |
801 | + description: options.description, | |
802 | + trigger: options.trigger, | |
803 | + conditions: options.conditions ? JSON.stringify(options.conditions) : null, | |
804 | + actions: JSON.stringify(options.actions), | |
805 | + isEnabled: true, | |
806 | + priority: options.priority || 0, | |
807 | + createdAt: new Date(), | |
808 | + updatedAt: new Date(), | |
809 | + }; | |
810 | + | |
811 | + await db.insert(schema.automationRules).values(rule); | |
812 | + | |
813 | + return rule; | |
532 | 814 | } |
533 | 815 | |
534 | 816 | /** |
535 | 817 | * Get automation rules for a repository |
536 | 818 | */ |
537 | 819 | export async function getAutomationRules(repositoryId: string) { |
538 | - const db = getDatabase() as NodePgDatabase<typeof schema>; | |
539 | - | |
540 | - return db.query.automationRules.findMany({ | |
541 | - where: eq(schema.automationRules.repositoryId, repositoryId), | |
542 | - orderBy: [desc(schema.automationRules.priority), desc(schema.automationRules.createdAt)], | |
543 | - }); | |
820 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
821 | + | |
822 | + return db.query.automationRules.findMany({ | |
823 | + where: eq(schema.automationRules.repositoryId, repositoryId), | |
824 | + orderBy: [ | |
825 | + desc(schema.automationRules.priority), | |
826 | + desc(schema.automationRules.createdAt), | |
827 | + ], | |
828 | + }); | |
544 | 829 | } |
545 | 830 | |
546 | 831 | /** |
547 | 832 | * Toggle automation rule enabled state |
548 | 833 | */ |
549 | 834 | export async function toggleAutomationRule(ruleId: string, enabled: boolean) { |
550 | - const db = getDatabase() as NodePgDatabase<typeof schema>; | |
551 | - | |
552 | - await db | |
553 | - .update(schema.automationRules) | |
554 | - .set({ | |
555 | - isEnabled: enabled, | |
556 | - updatedAt: new Date(), | |
557 | - }) | |
558 | - .where(eq(schema.automationRules.id, ruleId)); | |
835 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
836 | + | |
837 | + await db | |
838 | + .update(schema.automationRules) | |
839 | + .set({ | |
840 | + isEnabled: enabled, | |
841 | + updatedAt: new Date(), | |
842 | + }) | |
843 | + .where(eq(schema.automationRules.id, ruleId)); | |
559 | 844 | } |
560 | 845 | |
561 | 846 | /** |
562 | 847 | * Delete an automation rule |
563 | 848 | */ |
564 | 849 | export async function deleteAutomationRule(ruleId: string) { |
565 | - const db = getDatabase() as NodePgDatabase<typeof schema>; | |
850 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
566 | 851 | |
567 | - await db | |
568 | - .delete(schema.automationRules) | |
569 | - .where(eq(schema.automationRules.id, ruleId)); | |
852 | + await db | |
853 | + .delete(schema.automationRules) | |
854 | + .where(eq(schema.automationRules.id, ruleId)); | |
570 | 855 | } |
571 | 856 | |
▼src/lib/bulk-merge.ts
+156-145
| @@ -4,174 +4,185 @@ | ||
4 | 4 | */ |
5 | 5 | |
6 | 6 | import { getDatabase, schema } from "@/db"; |
7 | -import { eq } from "drizzle-orm"; | |
8 | -import { getStack } from "./stacks"; | |
9 | -import { canMergeStack } from "./stack-approvals"; | |
10 | -import { addToMergeQueue } from "./merge-queue"; | |
7 | +import { inArray } from "drizzle-orm"; | |
11 | 8 | import { logger } from "./logger"; |
9 | +import { addToMergeQueue } from "./merge-queue"; | |
10 | +import { canMergeStack } from "./stack-approvals"; | |
11 | +import { getStack } from "./stacks"; | |
12 | 12 | |
13 | 13 | export interface BulkMergeResult { |
14 | - success: boolean; | |
15 | - merged: { prId: string; prNumber: number }[]; | |
16 | - failed: { prId: string; prNumber: number; reason: string }[]; | |
17 | - skipped: { prId: string; prNumber: number; reason: string }[]; | |
14 | + success: boolean; | |
15 | + merged: { prId: string; prNumber: number }[]; | |
16 | + failed: { prId: string; prNumber: number; reason: string }[]; | |
17 | + skipped: { prId: string; prNumber: number; reason: string }[]; | |
18 | 18 | } |
19 | 19 | |
20 | 20 | /** |
21 | 21 | * Merge an entire stack in order |
22 | 22 | */ |
23 | 23 | export async function bulkMergeStack( |
24 | - stackId: string, | |
25 | - userId: string, | |
26 | - options?: { | |
27 | - mergeMethod?: "merge" | "squash" | "rebase"; | |
28 | - skipApprovalCheck?: boolean; | |
29 | - } | |
24 | + stackId: string, | |
25 | + userId: string, | |
26 | + options?: { | |
27 | + mergeMethod?: "merge" | "squash" | "rebase"; | |
28 | + skipApprovalCheck?: boolean; | |
29 | + }, | |
30 | 30 | ): Promise<BulkMergeResult> { |
31 | - const stack = await getStack(stackId); | |
32 | - | |
33 | - if (!stack) { | |
34 | - return { | |
35 | - success: false, | |
36 | - merged: [], | |
37 | - failed: [], | |
38 | - skipped: [{ prId: "", prNumber: 0, reason: "Stack not found" }], | |
39 | - }; | |
40 | - } | |
41 | - | |
42 | - // Check if stack can be merged | |
43 | - if (!options?.skipApprovalCheck) { | |
44 | - const { canMerge, blockers } = await canMergeStack(stackId); | |
45 | - if (!canMerge) { | |
46 | - return { | |
47 | - success: false, | |
48 | - merged: [], | |
49 | - failed: [], | |
50 | - skipped: stack.entries.map(e => ({ | |
51 | - prId: e.pr.id, | |
52 | - prNumber: e.pr.number, | |
53 | - reason: blockers.join(", "), | |
54 | - })), | |
55 | - }; | |
56 | - } | |
57 | - } | |
58 | - | |
59 | - const result: BulkMergeResult = { | |
60 | - success: true, | |
31 | + const stack = await getStack(stackId); | |
32 | + | |
33 | + if (!stack) { | |
34 | + return { | |
35 | + success: false, | |
36 | + merged: [], | |
37 | + failed: [], | |
38 | + skipped: [{ prId: "", prNumber: 0, reason: "Stack not found" }], | |
39 | + }; | |
40 | + } | |
41 | + | |
42 | + // Check if stack can be merged | |
43 | + if (!options?.skipApprovalCheck) { | |
44 | + const { canMerge, blockers } = await canMergeStack(stackId); | |
45 | + if (!canMerge) { | |
46 | + return { | |
47 | + success: false, | |
61 | 48 | merged: [], |
62 | 49 | failed: [], |
63 | - skipped: [], | |
64 | - }; | |
65 | - | |
66 | - // Process PRs in stack order (bottom to top) | |
67 | - for (const { entry, pr } of stack.entries) { | |
68 | - // Skip already merged PRs | |
69 | - if (pr.isMerged) { | |
70 | - result.skipped.push({ | |
71 | - prId: pr.id, | |
72 | - prNumber: pr.number, | |
73 | - reason: "Already merged", | |
74 | - }); | |
75 | - continue; | |
76 | - } | |
77 | - | |
78 | - // Skip closed PRs | |
79 | - if (pr.state === "closed") { | |
80 | - result.skipped.push({ | |
81 | - prId: pr.id, | |
82 | - prNumber: pr.number, | |
83 | - reason: "PR is closed", | |
84 | - }); | |
85 | - continue; | |
86 | - } | |
87 | - | |
88 | - // Add to merge queue | |
89 | - try { | |
90 | - await addToMergeQueue({ | |
91 | - repositoryId: stack.stack.repositoryId, | |
92 | - pullRequestId: pr.id, | |
93 | - addedById: userId, | |
94 | - mergeMethod: options?.mergeMethod || "merge", | |
95 | - }); | |
96 | - | |
97 | - result.merged.push({ | |
98 | - prId: pr.id, | |
99 | - prNumber: pr.number, | |
100 | - }); | |
101 | - } catch (error) { | |
102 | - const message = error instanceof Error ? error.message : "Unknown error"; | |
103 | - result.failed.push({ | |
104 | - prId: pr.id, | |
105 | - prNumber: pr.number, | |
106 | - reason: message, | |
107 | - }); | |
108 | - result.success = false; | |
109 | - } | |
50 | + skipped: stack.entries.map((e) => ({ | |
51 | + prId: e.pr.id, | |
52 | + prNumber: e.pr.number, | |
53 | + reason: blockers.join(", "), | |
54 | + })), | |
55 | + }; | |
56 | + } | |
57 | + } | |
58 | + | |
59 | + const result: BulkMergeResult = { | |
60 | + success: true, | |
61 | + merged: [], | |
62 | + failed: [], | |
63 | + skipped: [], | |
64 | + }; | |
65 | + | |
66 | + // Process PRs in stack order (bottom to top) | |
67 | + for (const { entry, pr } of stack.entries) { | |
68 | + // Skip already merged PRs | |
69 | + if (pr.isMerged) { | |
70 | + result.skipped.push({ | |
71 | + prId: pr.id, | |
72 | + prNumber: pr.number, | |
73 | + reason: "Already merged", | |
74 | + }); | |
75 | + continue; | |
110 | 76 | } |
111 | 77 | |
112 | - logger.info({ | |
113 | - stackId, | |
114 | - merged: result.merged.length, | |
115 | - failed: result.failed.length, | |
116 | - skipped: result.skipped.length, | |
117 | - }, "Bulk merge completed"); | |
78 | + // Skip closed PRs | |
79 | + if (pr.state === "closed") { | |
80 | + result.skipped.push({ | |
81 | + prId: pr.id, | |
82 | + prNumber: pr.number, | |
83 | + reason: "PR is closed", | |
84 | + }); | |
85 | + continue; | |
86 | + } | |
118 | 87 | |
119 | - return result; | |
88 | + // Add to merge queue | |
89 | + try { | |
90 | + await addToMergeQueue({ | |
91 | + repositoryId: stack.stack.repositoryId, | |
92 | + pullRequestId: pr.id, | |
93 | + addedById: userId, | |
94 | + mergeMethod: options?.mergeMethod || "merge", | |
95 | + }); | |
96 | + | |
97 | + result.merged.push({ | |
98 | + prId: pr.id, | |
99 | + prNumber: pr.number, | |
100 | + }); | |
101 | + } catch (error) { | |
102 | + const message = error instanceof Error ? error.message : "Unknown error"; | |
103 | + result.failed.push({ | |
104 | + prId: pr.id, | |
105 | + prNumber: pr.number, | |
106 | + reason: message, | |
107 | + }); | |
108 | + result.success = false; | |
109 | + } | |
110 | + } | |
111 | + | |
112 | + logger.info( | |
113 | + { | |
114 | + stackId, | |
115 | + merged: result.merged.length, | |
116 | + failed: result.failed.length, | |
117 | + skipped: result.skipped.length, | |
118 | + }, | |
119 | + "Bulk merge completed", | |
120 | + ); | |
121 | + | |
122 | + return result; | |
120 | 123 | } |
121 | 124 | |
122 | 125 | /** |
123 | 126 | * Merge multiple PRs (not necessarily in a stack) |
124 | 127 | */ |
125 | 128 | export async function bulkMergePRs( |
126 | - prIds: string[], | |
127 | - userId: string, | |
128 | - options?: { | |
129 | - mergeMethod?: "merge" | "squash" | "rebase"; | |
130 | - } | |
129 | + prIds: string[], | |
130 | + userId: string, | |
131 | + options?: { | |
132 | + mergeMethod?: "merge" | "squash" | "rebase"; | |
133 | + }, | |
131 | 134 | ): Promise<BulkMergeResult> { |
132 | - const db = getDatabase(); | |
133 | - const result: BulkMergeResult = { | |
134 | - success: true, | |
135 | - merged: [], | |
136 | - failed: [], | |
137 | - skipped: [], | |
138 | - }; | |
135 | + const db = getDatabase(); | |
136 | + const result: BulkMergeResult = { | |
137 | + success: true, | |
138 | + merged: [], | |
139 | + failed: [], | |
140 | + skipped: [], | |
141 | + }; | |
142 | + | |
143 | + // Batch fetch all PRs in one query instead of N individual lookups | |
144 | + const allPrs = await db.query.pullRequests.findMany({ | |
145 | + where: inArray(schema.pullRequests.id, prIds), | |
146 | + }); | |
147 | + const prMap = new Map(allPrs.map((pr) => [pr.id, pr])); | |
148 | + | |
149 | + for (const prId of prIds) { | |
150 | + const pr = prMap.get(prId); | |
151 | + | |
152 | + if (!pr) { | |
153 | + result.skipped.push({ prId, prNumber: 0, reason: "PR not found" }); | |
154 | + continue; | |
155 | + } | |
156 | + | |
157 | + if (pr.isMerged) { | |
158 | + result.skipped.push({ | |
159 | + prId, | |
160 | + prNumber: pr.number, | |
161 | + reason: "Already merged", | |
162 | + }); | |
163 | + continue; | |
164 | + } | |
165 | + | |
166 | + if (pr.state !== "open") { | |
167 | + result.skipped.push({ prId, prNumber: pr.number, reason: "PR not open" }); | |
168 | + continue; | |
169 | + } | |
139 | 170 | |
140 | - for (const prId of prIds) { | |
141 | - const pr = await db.query.pullRequests.findFirst({ | |
142 | - where: eq(schema.pullRequests.id, prId), | |
143 | - }); | |
144 | - | |
145 | - if (!pr) { | |
146 | - result.skipped.push({ prId, prNumber: 0, reason: "PR not found" }); | |
147 | - continue; | |
148 | - } | |
149 | - | |
150 | - if (pr.isMerged) { | |
151 | - result.skipped.push({ prId, prNumber: pr.number, reason: "Already merged" }); | |
152 | - continue; | |
153 | - } | |
154 | - | |
155 | - if (pr.state !== "open") { | |
156 | - result.skipped.push({ prId, prNumber: pr.number, reason: "PR not open" }); | |
157 | - continue; | |
158 | - } | |
159 | - | |
160 | - try { | |
161 | - await addToMergeQueue({ | |
162 | - repositoryId: pr.repositoryId, | |
163 | - pullRequestId: prId, | |
164 | - addedById: userId, | |
165 | - mergeMethod: options?.mergeMethod || "merge", | |
166 | - }); | |
167 | - | |
168 | - result.merged.push({ prId, prNumber: pr.number }); | |
169 | - } catch (error) { | |
170 | - const message = error instanceof Error ? error.message : "Unknown error"; | |
171 | - result.failed.push({ prId, prNumber: pr.number, reason: message }); | |
172 | - result.success = false; | |
173 | - } | |
171 | + try { | |
172 | + await addToMergeQueue({ | |
173 | + repositoryId: pr.repositoryId, | |
174 | + pullRequestId: prId, | |
175 | + addedById: userId, | |
176 | + mergeMethod: options?.mergeMethod || "merge", | |
177 | + }); | |
178 | + | |
179 | + result.merged.push({ prId, prNumber: pr.number }); | |
180 | + } catch (error) { | |
181 | + const message = error instanceof Error ? error.message : "Unknown error"; | |
182 | + result.failed.push({ prId, prNumber: pr.number, reason: message }); | |
183 | + result.success = false; | |
174 | 184 | } |
185 | + } | |
175 | 186 | |
176 | - return result; | |
187 | + return result; | |
177 | 188 | } |
178 | 189 | |
▼src/lib/chat-notifications.ts
+188-1
| @@ -9,7 +9,8 @@ import { eq, and, gte, inArray, desc } from "drizzle-orm"; | ||
9 | 9 | import type { NodePgDatabase } from "drizzle-orm/node-postgres"; |
10 | 10 | import { logger } from "./logger"; |
11 | 11 | import { repositories } from "@/db/schema/repositories"; |
12 | -import { sendEmail as sendPlatformEmail } from "./email"; | |
12 | +import { sendEmail as sendPlatformEmail, isSmtpConfigured } from "./email"; | |
13 | +import { generateId } from "./utils"; | |
13 | 14 | |
14 | 15 | // ============================================================================ |
15 | 16 | // SCHEMA |
| @@ -810,6 +811,50 @@ export interface DigestDeliveryAttemptResult { | ||
810 | 811 | lastError?: string; |
811 | 812 | } |
812 | 813 | |
814 | +async function logDigestDeliveryEvent(options: { | |
815 | + userId: string; | |
816 | + action: | |
817 | + | "notification_digest_sent" | |
818 | + | "notification_digest_retry" | |
819 | + | "notification_digest_dead_letter" | |
820 | + | "notification_digest_skipped"; | |
821 | + digestSettingId?: string; | |
822 | + period: "daily" | "weekly"; | |
823 | + itemCount: number; | |
824 | + attempts: number; | |
825 | + recovered?: boolean; | |
826 | + dryRun: boolean; | |
827 | + provider: "smtp" | "log"; | |
828 | + reason?: string; | |
829 | + error?: string; | |
830 | +}) { | |
831 | + try { | |
832 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
833 | + await db.insert(schema.auditLogs).values({ | |
834 | + id: generateId(), | |
835 | + userId: options.userId, | |
836 | + action: options.action, | |
837 | + actorType: "system", | |
838 | + actorId: options.userId, | |
839 | + targetType: "notification_digest", | |
840 | + targetId: options.digestSettingId || null, | |
841 | + data: JSON.stringify({ | |
842 | + period: options.period, | |
843 | + itemCount: options.itemCount, | |
844 | + attempts: options.attempts, | |
845 | + recovered: options.recovered ?? false, | |
846 | + dryRun: options.dryRun, | |
847 | + provider: options.provider, | |
848 | + reason: options.reason || null, | |
849 | + error: options.error || null, | |
850 | + }), | |
851 | + createdAt: new Date(), | |
852 | + }); | |
853 | + } catch (error) { | |
854 | + logger.error({ error }, "Failed to log digest delivery audit event"); | |
855 | + } | |
856 | +} | |
857 | + | |
813 | 858 | export async function sendDigestWithRetry( |
814 | 859 | send: () => Promise<boolean>, |
815 | 860 | maxRetries: number |
| @@ -849,11 +894,22 @@ export async function runUserDigest(options: UserDigestRunOptions): Promise<User | ||
849 | 894 | const now = options.now ?? new Date(); |
850 | 895 | const dryRun = options.dryRun ?? true; |
851 | 896 | const maxRetries = normalizeRetryCount(options.maxRetries); |
897 | + const provider: "smtp" | "log" = isSmtpConfigured() ? "smtp" : "log"; | |
852 | 898 | |
853 | 899 | const setting = await db.query.emailDigestSettings.findFirst({ |
854 | 900 | where: eq(schema.emailDigestSettings.userId, options.userId), |
855 | 901 | }); |
856 | 902 | if (!setting) { |
903 | + await logDigestDeliveryEvent({ | |
904 | + userId: options.userId, | |
905 | + action: "notification_digest_skipped", | |
906 | + period: options.period || "daily", | |
907 | + itemCount: 0, | |
908 | + attempts: 0, | |
909 | + dryRun, | |
910 | + provider, | |
911 | + reason: "missing_digest_settings", | |
912 | + }); | |
857 | 913 | return { |
858 | 914 | sent: false, |
859 | 915 | dryRun, |
| @@ -870,6 +926,17 @@ export async function runUserDigest(options: UserDigestRunOptions): Promise<User | ||
870 | 926 | columns: { id: true, email: true, isActive: true }, |
871 | 927 | }); |
872 | 928 | if (!user || !user.isActive || !user.email) { |
929 | + await logDigestDeliveryEvent({ | |
930 | + userId: options.userId, | |
931 | + action: "notification_digest_skipped", | |
932 | + digestSettingId: setting.id, | |
933 | + period, | |
934 | + itemCount: 0, | |
935 | + attempts: 0, | |
936 | + dryRun, | |
937 | + provider, | |
938 | + reason: "missing_user_or_email", | |
939 | + }); | |
873 | 940 | return { |
874 | 941 | sent: false, |
875 | 942 | dryRun, |
| @@ -896,6 +963,17 @@ export async function runUserDigest(options: UserDigestRunOptions): Promise<User | ||
896 | 963 | }); |
897 | 964 | |
898 | 965 | if (digest.itemCount === 0) { |
966 | + await logDigestDeliveryEvent({ | |
967 | + userId: options.userId, | |
968 | + action: "notification_digest_skipped", | |
969 | + digestSettingId: setting.id, | |
970 | + period, | |
971 | + itemCount: 0, | |
972 | + attempts: 0, | |
973 | + dryRun, | |
974 | + provider, | |
975 | + reason: "empty_digest", | |
976 | + }); | |
899 | 977 | return { |
900 | 978 | sent: false, |
901 | 979 | dryRun, |
| @@ -918,6 +996,18 @@ export async function runUserDigest(options: UserDigestRunOptions): Promise<User | ||
918 | 996 | }); |
919 | 997 | }, maxRetries); |
920 | 998 | if (!delivery.sent) { |
999 | + await logDigestDeliveryEvent({ | |
1000 | + userId: options.userId, | |
1001 | + action: "notification_digest_dead_letter", | |
1002 | + digestSettingId: setting.id, | |
1003 | + period, | |
1004 | + itemCount: digest.itemCount, | |
1005 | + attempts: delivery.attempts, | |
1006 | + dryRun, | |
1007 | + provider, | |
1008 | + reason: "send_failed", | |
1009 | + error: delivery.lastError, | |
1010 | + }); | |
921 | 1011 | return { |
922 | 1012 | sent: false, |
923 | 1013 | dryRun, |
| @@ -931,12 +1021,38 @@ export async function runUserDigest(options: UserDigestRunOptions): Promise<User | ||
931 | 1021 | attempts = delivery.attempts; |
932 | 1022 | recovered = delivery.recovered; |
933 | 1023 | |
1024 | + if (delivery.attempts > 1) { | |
1025 | + await logDigestDeliveryEvent({ | |
1026 | + userId: options.userId, | |
1027 | + action: "notification_digest_retry", | |
1028 | + digestSettingId: setting.id, | |
1029 | + period, | |
1030 | + itemCount: digest.itemCount, | |
1031 | + attempts: delivery.attempts, | |
1032 | + recovered: delivery.recovered, | |
1033 | + dryRun, | |
1034 | + provider, | |
1035 | + }); | |
1036 | + } | |
1037 | + | |
934 | 1038 | await db |
935 | 1039 | .update(schema.emailDigestSettings) |
936 | 1040 | .set({ lastSentAt: now, updatedAt: now }) |
937 | 1041 | .where(eq(schema.emailDigestSettings.id, setting.id)); |
938 | 1042 | } |
939 | 1043 | |
1044 | + await logDigestDeliveryEvent({ | |
1045 | + userId: options.userId, | |
1046 | + action: "notification_digest_sent", | |
1047 | + digestSettingId: setting.id, | |
1048 | + period, | |
1049 | + itemCount: digest.itemCount, | |
1050 | + attempts: dryRun ? 0 : attempts || 1, | |
1051 | + recovered, | |
1052 | + dryRun, | |
1053 | + provider, | |
1054 | + }); | |
1055 | + | |
940 | 1056 | return { |
941 | 1057 | sent: true, |
942 | 1058 | dryRun, |
| @@ -952,6 +1068,7 @@ export async function runDueDigests(options: DigestRunOptions = {}): Promise<Dig | ||
952 | 1068 | const now = options.now ?? new Date(); |
953 | 1069 | const dryRun = options.dryRun ?? false; |
954 | 1070 | const maxRetries = normalizeRetryCount(options.maxRetries); |
1071 | + const provider: "smtp" | "log" = isSmtpConfigured() ? "smtp" : "log"; | |
955 | 1072 | |
956 | 1073 | const result: DigestRunResult = { |
957 | 1074 | checked: 0, |
| @@ -994,6 +1111,17 @@ export async function runDueDigests(options: DigestRunOptions = {}): Promise<Dig | ||
994 | 1111 | |
995 | 1112 | if (!user || !user.isActive || !user.email) { |
996 | 1113 | result.skippedNoEmail++; |
1114 | + await logDigestDeliveryEvent({ | |
1115 | + userId: setting.userId, | |
1116 | + action: "notification_digest_skipped", | |
1117 | + digestSettingId: setting.id, | |
1118 | + period: digestType, | |
1119 | + itemCount: 0, | |
1120 | + attempts: 0, | |
1121 | + dryRun, | |
1122 | + provider, | |
1123 | + reason: "missing_user_or_email", | |
1124 | + }); | |
997 | 1125 | if (!dryRun) { |
998 | 1126 | await db |
999 | 1127 | .update(schema.emailDigestSettings) |
| @@ -1020,6 +1148,17 @@ export async function runDueDigests(options: DigestRunOptions = {}): Promise<Dig | ||
1020 | 1148 | |
1021 | 1149 | if (digest.itemCount === 0) { |
1022 | 1150 | result.skippedEmpty++; |
1151 | + await logDigestDeliveryEvent({ | |
1152 | + userId: setting.userId, | |
1153 | + action: "notification_digest_skipped", | |
1154 | + digestSettingId: setting.id, | |
1155 | + period: digestType, | |
1156 | + itemCount: 0, | |
1157 | + attempts: 0, | |
1158 | + dryRun, | |
1159 | + provider, | |
1160 | + reason: "empty_digest", | |
1161 | + }); | |
1023 | 1162 | if (!dryRun) { |
1024 | 1163 | await db |
1025 | 1164 | .update(schema.emailDigestSettings) |
| @@ -1043,6 +1182,18 @@ export async function runDueDigests(options: DigestRunOptions = {}): Promise<Dig | ||
1043 | 1182 | } |
1044 | 1183 | if (!delivery.sent) { |
1045 | 1184 | result.failed++; |
1185 | + await logDigestDeliveryEvent({ | |
1186 | + userId: setting.userId, | |
1187 | + action: "notification_digest_dead_letter", | |
1188 | + digestSettingId: setting.id, | |
1189 | + period: digestType, | |
1190 | + itemCount: digest.itemCount, | |
1191 | + attempts: delivery.attempts, | |
1192 | + dryRun, | |
1193 | + provider, | |
1194 | + reason: "send_failed", | |
1195 | + error: delivery.lastError, | |
1196 | + }); | |
1046 | 1197 | logger.error({ |
1047 | 1198 | userId: setting.userId, |
1048 | 1199 | digestSettingId: setting.id, |
| @@ -1054,6 +1205,19 @@ export async function runDueDigests(options: DigestRunOptions = {}): Promise<Dig | ||
1054 | 1205 | if (delivery.recovered) { |
1055 | 1206 | result.recovered++; |
1056 | 1207 | } |
1208 | + if (delivery.attempts > 1) { | |
1209 | + await logDigestDeliveryEvent({ | |
1210 | + userId: setting.userId, | |
1211 | + action: "notification_digest_retry", | |
1212 | + digestSettingId: setting.id, | |
1213 | + period: digestType, | |
1214 | + itemCount: digest.itemCount, | |
1215 | + attempts: delivery.attempts, | |
1216 | + recovered: delivery.recovered, | |
1217 | + dryRun, | |
1218 | + provider, | |
1219 | + }); | |
1220 | + } | |
1057 | 1221 | |
1058 | 1222 | await db |
1059 | 1223 | .update(schema.emailDigestSettings) |
| @@ -1062,8 +1226,31 @@ export async function runDueDigests(options: DigestRunOptions = {}): Promise<Dig | ||
1062 | 1226 | } |
1063 | 1227 | |
1064 | 1228 | result.sent++; |
1229 | + await logDigestDeliveryEvent({ | |
1230 | + userId: setting.userId, | |
1231 | + action: "notification_digest_sent", | |
1232 | + digestSettingId: setting.id, | |
1233 | + period: digestType, | |
1234 | + itemCount: digest.itemCount, | |
1235 | + attempts: dryRun ? 0 : 1, | |
1236 | + recovered: false, | |
1237 | + dryRun, | |
1238 | + provider, | |
1239 | + }); | |
1065 | 1240 | } catch (error) { |
1066 | 1241 | result.failed++; |
1242 | + await logDigestDeliveryEvent({ | |
1243 | + userId: setting.userId, | |
1244 | + action: "notification_digest_dead_letter", | |
1245 | + digestSettingId: setting.id, | |
1246 | + period: digestType, | |
1247 | + itemCount: 0, | |
1248 | + attempts: 1, | |
1249 | + dryRun, | |
1250 | + provider, | |
1251 | + reason: "send_failed", | |
1252 | + error: error instanceof Error ? error.message : "Unknown error", | |
1253 | + }); | |
1067 | 1254 | logger.error({ |
1068 | 1255 | userId: setting.userId, |
1069 | 1256 | digestSettingId: setting.id, |
1070 | 1257 | |
▼src/lib/ci-logs.ts
+230-0
| @@ -0,0 +1,230 @@ | ||
1 | +/** | |
2 | + * CI Log Persistence | |
3 | + * Buffers and flushes workflow logs to database + filesystem | |
4 | + */ | |
5 | + | |
6 | +import { getDatabase, schema } from "@/db"; | |
7 | +import { eq } from "drizzle-orm"; | |
8 | +import fs from "fs/promises"; | |
9 | +import path from "path"; | |
10 | +import { logger } from "./logger"; | |
11 | + | |
12 | +// ─── Types ─────────────────────────────────────────────────────────────────── | |
13 | + | |
14 | +interface LogEntry { | |
15 | + jobId: string; | |
16 | + stepId?: string; | |
17 | + level: "debug" | "info" | "warn" | "error"; | |
18 | + message: string; | |
19 | + lineNumber: number; | |
20 | +} | |
21 | + | |
22 | +interface StepRecord { | |
23 | + id: string; | |
24 | + jobId: string; | |
25 | + number: number; | |
26 | + name: string; | |
27 | + status: string; | |
28 | + conclusion?: string; | |
29 | + uses?: string; | |
30 | + run?: string; | |
31 | + startedAt?: Date; | |
32 | + completedAt?: Date; | |
33 | +} | |
34 | + | |
35 | +// ─── Log Persister ─────────────────────────────────────────────────────────── | |
36 | + | |
37 | +const FLUSH_INTERVAL_MS = 5_000; | |
38 | +const MAX_BUFFER_SIZE = 500; | |
39 | + | |
40 | +export class LogPersister { | |
41 | + private buffer: LogEntry[] = []; | |
42 | + private flushTimer: ReturnType<typeof setInterval> | null = null; | |
43 | + private lineCounter = 0; | |
44 | + private logDir: string; | |
45 | + | |
46 | + constructor( | |
47 | + private runId: string, | |
48 | + logBaseDir: string = "./data/actions/logs", | |
49 | + ) { | |
50 | + this.logDir = path.join(logBaseDir, runId); | |
51 | + this.startFlushTimer(); | |
52 | + } | |
53 | + | |
54 | + private startFlushTimer() { | |
55 | + this.flushTimer = setInterval(() => { | |
56 | + this.flush().catch((err) => { | |
57 | + logger.error( | |
58 | + { error: err, runId: this.runId }, | |
59 | + "Failed to flush CI logs", | |
60 | + ); | |
61 | + }); | |
62 | + }, FLUSH_INTERVAL_MS); | |
63 | + } | |
64 | + | |
65 | + /** | |
66 | + * Add a log line to the buffer | |
67 | + */ | |
68 | + addLog( | |
69 | + jobId: string, | |
70 | + stepId: string | undefined, | |
71 | + message: string, | |
72 | + level: "info" | "error" = "info", | |
73 | + ) { | |
74 | + this.lineCounter++; | |
75 | + this.buffer.push({ | |
76 | + jobId, | |
77 | + stepId, | |
78 | + level, | |
79 | + message, | |
80 | + lineNumber: this.lineCounter, | |
81 | + }); | |
82 | + | |
83 | + // Auto-flush if buffer is large | |
84 | + if (this.buffer.length >= MAX_BUFFER_SIZE) { | |
85 | + this.flush().catch(() => {}); | |
86 | + } | |
87 | + } | |
88 | + | |
89 | + /** | |
90 | + * Persist a step record to the database | |
91 | + */ | |
92 | + async persistStep(step: StepRecord): Promise<void> { | |
93 | + try { | |
94 | + const db = getDatabase(); | |
95 | + // @ts-expect-error - Drizzle multi-db union type issue | |
96 | + await db | |
97 | + .insert(schema.workflowSteps) | |
98 | + .values({ | |
99 | + id: step.id, | |
100 | + jobId: step.jobId, | |
101 | + number: step.number, | |
102 | + name: step.name, | |
103 | + status: step.status, | |
104 | + conclusion: step.conclusion ?? null, | |
105 | + uses: step.uses ?? null, | |
106 | + run: step.run ?? null, | |
107 | + startedAt: step.startedAt ?? null, | |
108 | + completedAt: step.completedAt ?? null, | |
109 | + createdAt: new Date(), | |
110 | + }) | |
111 | + .onConflictDoUpdate({ | |
112 | + target: schema.workflowSteps.id, | |
113 | + set: { | |
114 | + status: step.status, | |
115 | + conclusion: step.conclusion ?? null, | |
116 | + startedAt: step.startedAt ?? null, | |
117 | + completedAt: step.completedAt ?? null, | |
118 | + }, | |
119 | + }); | |
120 | + } catch (error) { | |
121 | + logger.error({ error, stepId: step.id }, "Failed to persist step record"); | |
122 | + } | |
123 | + } | |
124 | + | |
125 | + /** | |
126 | + * Flush buffered logs to database and filesystem | |
127 | + */ | |
128 | + async flush(): Promise<void> { | |
129 | + if (this.buffer.length === 0) return; | |
130 | + | |
131 | + const entries = this.buffer.splice(0); // drain buffer | |
132 | + | |
133 | + // 1. Write to filesystem (append mode) for durability | |
134 | + try { | |
135 | + await fs.mkdir(this.logDir, { recursive: true }); | |
136 | + const logLines = | |
137 | + entries | |
138 | + .map((e) => { | |
139 | + const ts = new Date().toISOString(); | |
140 | + return `[${ts}] [${e.level.toUpperCase()}] [job:${e.jobId}${e.stepId ? `:step:${e.stepId}` : ""}] ${e.message}`; | |
141 | + }) | |
142 | + .join("\n") + "\n"; | |
143 | + await fs.appendFile(path.join(this.logDir, "run.log"), logLines); | |
144 | + } catch (error) { | |
145 | + logger.error( | |
146 | + { error, runId: this.runId }, | |
147 | + "Failed to write CI logs to filesystem", | |
148 | + ); | |
149 | + } | |
150 | + | |
151 | + // 2. Batch insert into database | |
152 | + try { | |
153 | + const db = getDatabase(); | |
154 | + const values = entries.map((e) => ({ | |
155 | + id: crypto.randomUUID(), | |
156 | + jobId: e.jobId, | |
157 | + stepId: e.stepId ?? null, | |
158 | + logLevel: e.level, | |
159 | + message: e.message, | |
160 | + timestamp: new Date().toISOString(), | |
161 | + lineNumber: e.lineNumber, | |
162 | + createdAt: new Date(), | |
163 | + })); | |
164 | + | |
165 | + // Insert in chunks of 100 to avoid oversized queries | |
166 | + for (let i = 0; i < values.length; i += 100) { | |
167 | + const chunk = values.slice(i, i + 100); | |
168 | + // @ts-expect-error - Drizzle multi-db union type issue | |
169 | + await db.insert(schema.workflowLogs).values(chunk); | |
170 | + } | |
171 | + } catch (error) { | |
172 | + logger.error( | |
173 | + { error, runId: this.runId }, | |
174 | + "Failed to write CI logs to database", | |
175 | + ); | |
176 | + } | |
177 | + } | |
178 | + | |
179 | + /** | |
180 | + * Final cleanup — flush remaining logs and stop timer | |
181 | + */ | |
182 | + async close(): Promise<void> { | |
183 | + if (this.flushTimer) { | |
184 | + clearInterval(this.flushTimer); | |
185 | + this.flushTimer = null; | |
186 | + } | |
187 | + await this.flush(); | |
188 | + } | |
189 | +} | |
190 | + | |
191 | +// ─── Log Retrieval ─────────────────────────────────────────────────────────── | |
192 | + | |
193 | +/** | |
194 | + * Get logs for a workflow run from the database | |
195 | + */ | |
196 | +export async function getRunLogs(runId: string, jobId?: string) { | |
197 | + const db = getDatabase(); | |
198 | + | |
199 | + // Get all jobs for this run | |
200 | + const jobs = await db.query.workflowJobs.findMany({ | |
201 | + where: eq(schema.workflowJobs.runId, runId), | |
202 | + }); | |
203 | + | |
204 | + if (jobs.length === 0) return []; | |
205 | + | |
206 | + const targetJobIds = jobId ? [jobId] : jobs.map((j) => j.id); | |
207 | + | |
208 | + const { inArray } = await import("drizzle-orm"); | |
209 | + const logs = await db.query.workflowLogs.findMany({ | |
210 | + where: inArray(schema.workflowLogs.jobId, targetJobIds), | |
211 | + orderBy: (logs, { asc }) => [asc(logs.lineNumber)], | |
212 | + }); | |
213 | + | |
214 | + return logs; | |
215 | +} | |
216 | + | |
217 | +/** | |
218 | + * Get logs from filesystem (fallback if DB is unavailable) | |
219 | + */ | |
220 | +export async function getRunLogsFromFile( | |
221 | + runId: string, | |
222 | + logBaseDir = "./data/actions/logs", | |
223 | +): Promise<string | null> { | |
224 | + const logFile = path.join(logBaseDir, runId, "run.log"); | |
225 | + try { | |
226 | + return await fs.readFile(logFile, "utf-8"); | |
227 | + } catch { | |
228 | + return null; | |
229 | + } | |
230 | +} | |
0 | 231 | |
▼src/lib/cloud-hooks.ts
+130-0
| @@ -0,0 +1,130 @@ | ||
1 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
2 | +import { and, eq } from "drizzle-orm"; | |
3 | +import { getDatabase, schema } from "@/db"; | |
4 | +import { | |
5 | + awsDeployECS, | |
6 | + azureDeployAppService, | |
7 | + gcpDeployCloudRun, | |
8 | + k8sApplyDeployment, | |
9 | + type CloudConfig, | |
10 | +} from "@/lib/cloud-integrations"; | |
11 | +import { logger } from "@/lib/logger"; | |
12 | + | |
13 | +export interface CloudHookResult { | |
14 | + configId: string; | |
15 | + provider: string; | |
16 | + success: boolean; | |
17 | + deploymentId?: string; | |
18 | + message?: string; | |
19 | +} | |
20 | + | |
21 | +export async function triggerCloudDeploy(options: { | |
22 | + repositoryId: string; | |
23 | + configId?: string; | |
24 | + imageTag?: string; | |
25 | + clusterName?: string; | |
26 | + serviceName?: string; | |
27 | + taskDefinition?: string; | |
28 | + appName?: string; | |
29 | + namespace?: string; | |
30 | + deploymentName?: string; | |
31 | +}): Promise<CloudHookResult[]> { | |
32 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
33 | + const configs = | |
34 | + (await db.query.cloudConfigs?.findMany({ | |
35 | + where: and( | |
36 | + eq(schema.cloudConfigs.repositoryId, options.repositoryId), | |
37 | + eq(schema.cloudConfigs.isEnabled, true) | |
38 | + ), | |
39 | + })) || []; | |
40 | + | |
41 | + const targets = options.configId | |
42 | + ? configs.filter((config) => config.id === options.configId) | |
43 | + : configs.filter((config) => ["aws", "gcp", "azure", "kubernetes"].includes(config.provider)); | |
44 | + | |
45 | + const image = options.imageTag || "latest"; | |
46 | + const results: CloudHookResult[] = []; | |
47 | + | |
48 | + for (const config of targets) { | |
49 | + try { | |
50 | + if (config.provider === "aws") { | |
51 | + const deployment = await awsDeployECS({ | |
52 | + config: config as CloudConfig, | |
53 | + clusterName: options.clusterName || "default", | |
54 | + serviceName: options.serviceName || "app", | |
55 | + taskDefinition: options.taskDefinition || "app", | |
56 | + imageTag: image, | |
57 | + }); | |
58 | + results.push({ | |
59 | + configId: config.id, | |
60 | + provider: config.provider, | |
61 | + success: !!deployment, | |
62 | + deploymentId: deployment?.id, | |
63 | + message: deployment ? "AWS deployment triggered" : "AWS deployment failed", | |
64 | + }); | |
65 | + continue; | |
66 | + } | |
67 | + | |
68 | + if (config.provider === "gcp") { | |
69 | + const deployment = await gcpDeployCloudRun({ | |
70 | + config: config as CloudConfig, | |
71 | + serviceName: options.serviceName || "app", | |
72 | + region: config.region || "us-central1", | |
73 | + image, | |
74 | + }); | |
75 | + results.push({ | |
76 | + configId: config.id, | |
77 | + provider: config.provider, | |
78 | + success: !!deployment, | |
79 | + deploymentId: deployment?.id, | |
80 | + message: deployment ? "GCP deployment triggered" : "GCP deployment failed", | |
81 | + }); | |
82 | + continue; | |
83 | + } | |
84 | + | |
85 | + if (config.provider === "azure") { | |
86 | + const deployment = await azureDeployAppService({ | |
87 | + config: config as CloudConfig, | |
88 | + resourceGroup: options.namespace || "default", | |
89 | + appName: options.appName || "app", | |
90 | + image, | |
91 | + }); | |
92 | + results.push({ | |
93 | + configId: config.id, | |
94 | + provider: config.provider, | |
95 | + success: !!deployment, | |
96 | + deploymentId: deployment?.id, | |
97 | + message: deployment ? "Azure deployment triggered" : "Azure deployment failed", | |
98 | + }); | |
99 | + continue; | |
100 | + } | |
101 | + | |
102 | + if (config.provider === "kubernetes") { | |
103 | + const deployment = await k8sApplyDeployment({ | |
104 | + config: config as CloudConfig, | |
105 | + namespace: options.namespace || "default", | |
106 | + name: options.deploymentName || "app", | |
107 | + image, | |
108 | + replicas: 1, | |
109 | + }); | |
110 | + results.push({ | |
111 | + configId: config.id, | |
112 | + provider: config.provider, | |
113 | + success: !!deployment, | |
114 | + deploymentId: deployment?.id, | |
115 | + message: deployment ? "Kubernetes deployment applied" : "Kubernetes deployment failed", | |
116 | + }); | |
117 | + } | |
118 | + } catch (error) { | |
119 | + logger.error({ configId: config.id, provider: config.provider, error }, "Cloud deploy hook failed"); | |
120 | + results.push({ | |
121 | + configId: config.id, | |
122 | + provider: config.provider, | |
123 | + success: false, | |
124 | + message: error instanceof Error ? error.message : "Unknown cloud deploy error", | |
125 | + }); | |
126 | + } | |
127 | + } | |
128 | + | |
129 | + return results; | |
130 | +} | |
0 | 131 | |
▼src/lib/distributed-lock.ts
+7-2
| @@ -9,18 +9,23 @@ import { logger } from "./logger"; | ||
9 | 9 | // Try to import Redis - may not be configured |
10 | 10 | let redis: any = null; |
11 | 11 | let redisAvailable = false; |
12 | +const isProd = process.env.NODE_ENV === "production" || import.meta.env?.PROD === true; | |
12 | 13 | |
13 | 14 | try { |
14 | 15 | const redisModule = await import("./redis"); |
15 | 16 | redis = redisModule.redis; |
17 | + const isReady = await redisModule.waitForRedisReady(6000); | |
18 | + if (!isReady) { | |
19 | + throw new Error("Redis did not become ready within timeout"); | |
20 | + } | |
16 | 21 | // Test connection |
17 | 22 | await redis.ping(); |
18 | 23 | redisAvailable = true; |
19 | 24 | } catch (e) { |
20 | - if (import.meta.env.PROD) { | |
25 | + if (isProd) { | |
21 | 26 | throw new Error("CRITICAL: Redis is required in production for distributed locking. Please configure REDIS_URL."); |
22 | 27 | } |
23 | - logger.warn("Redis not available - distributed locks disabled. Using in-memory fallback. NOT SAFE for multi-instance deployments."); | |
28 | + logger.warn({ error: e }, "Redis not available - distributed locks disabled. Using in-memory fallback. NOT SAFE for multi-instance deployments."); | |
24 | 29 | } |
25 | 30 | |
26 | 31 | export interface LockOptions { |
27 | 32 | |
▼src/lib/env-validation.ts
+49-10
| @@ -43,12 +43,30 @@ const envVars: EnvConfig[] = [ | ||
43 | 43 | validator: (v) => v.length >= 32, |
44 | 44 | errorMessage: "RUNNER_SECRET must be at least 32 characters", |
45 | 45 | }, |
46 | + { | |
47 | + name: "RUNNER_REGISTRATION_TOKEN_TTL_DAYS", | |
48 | + required: false, | |
49 | + validator: (v) => Number.isFinite(Number(v)) && Number(v) > 0, | |
50 | + errorMessage: "RUNNER_REGISTRATION_TOKEN_TTL_DAYS must be a positive number", | |
51 | + }, | |
46 | 52 | { |
47 | 53 | name: "AI_CONFIG_ENCRYPTION_KEY", |
48 | 54 | required: false, |
49 | 55 | validator: (v) => v.length >= 32, |
50 | 56 | errorMessage: "AI_CONFIG_ENCRYPTION_KEY should be at least 32 characters", |
51 | 57 | }, |
58 | + { | |
59 | + name: "WORKFLOW_SECRET_ENCRYPTION_KEY", | |
60 | + required: false, | |
61 | + validator: (v) => v.length >= 32, | |
62 | + errorMessage: "WORKFLOW_SECRET_ENCRYPTION_KEY should be at least 32 characters", | |
63 | + }, | |
64 | + { | |
65 | + name: "REDIS_URL", | |
66 | + required: false, | |
67 | + validator: (v) => v.startsWith("redis://") || v.startsWith("rediss://"), | |
68 | + errorMessage: "REDIS_URL must be a valid redis:// or rediss:// URL", | |
69 | + }, | |
52 | 70 | |
53 | 71 | // Application URLs (Critical) |
54 | 72 | { |
| @@ -146,6 +164,13 @@ const envVars: EnvConfig[] = [ | ||
146 | 164 | required: false, |
147 | 165 | defaultValue: "true", |
148 | 166 | }, |
167 | + { | |
168 | + name: "AIR_GAPPED_MODE", | |
169 | + required: false, | |
170 | + defaultValue: "false", | |
171 | + validator: (v) => ["0", "1", "true", "false", "yes", "no"].includes(v.toLowerCase()), | |
172 | + errorMessage: "AIR_GAPPED_MODE must be one of: true/false/1/0/yes/no", | |
173 | + }, | |
149 | 174 | |
150 | 175 | // Grafana Cloud Loki |
151 | 176 | { |
| @@ -163,7 +188,7 @@ const envVars: EnvConfig[] = [ | ||
163 | 188 | ]; |
164 | 189 | |
165 | 190 | export function validateEnvironment(exitOnError: boolean = true): boolean { |
166 | - console.log("🔍 Validating environment configuration..."); | |
191 | + logger.info("🔍 Validating environment configuration..."); | |
167 | 192 | |
168 | 193 | const errors: string[] = []; |
169 | 194 | const warnings: string[] = []; |
| @@ -210,8 +235,18 @@ export function validateEnvironment(exitOnError: boolean = true): boolean { | ||
210 | 235 | if (process.env.CRON_SECRET?.includes("default") || process.env.CRON_SECRET?.includes("dev")) { |
211 | 236 | errors.push("❌ CRON_SECRET appears to contain default/weak value. Change it for production!"); |
212 | 237 | } |
213 | - if (!process.env.AI_CONFIG_ENCRYPTION_KEY) { | |
214 | - errors.push("❌ AI_CONFIG_ENCRYPTION_KEY is required in production for encrypted secret storage"); | |
238 | + if (!process.env.AI_CONFIG_ENCRYPTION_KEY && !process.env.APP_ENCRYPTION_KEY) { | |
239 | + errors.push("❌ AI_CONFIG_ENCRYPTION_KEY (or APP_ENCRYPTION_KEY) is required in production for encrypted AI config storage"); | |
240 | + } | |
241 | + if ( | |
242 | + !process.env.WORKFLOW_SECRET_ENCRYPTION_KEY && | |
243 | + !process.env.APP_ENCRYPTION_KEY && | |
244 | + !process.env.AI_CONFIG_ENCRYPTION_KEY | |
245 | + ) { | |
246 | + errors.push("❌ WORKFLOW_SECRET_ENCRYPTION_KEY (or APP_ENCRYPTION_KEY) is required in production for encrypted workflow secret storage"); | |
247 | + } | |
248 | + if (!process.env.REDIS_URL) { | |
249 | + errors.push("❌ REDIS_URL is required in production for distributed locking and rate limiting"); | |
215 | 250 | } |
216 | 251 | |
217 | 252 | // Ensure HTTPS in production |
| @@ -220,6 +255,10 @@ export function validateEnvironment(exitOnError: boolean = true): boolean { | ||
220 | 255 | } |
221 | 256 | } |
222 | 257 | |
258 | + if (["1", "true", "yes"].includes((process.env.AIR_GAPPED_MODE || "").toLowerCase())) { | |
259 | + warnings.push("⚠️ AIR_GAPPED_MODE is enabled: external integrations (AI/cloud/third-party webhooks) should be disabled"); | |
260 | + } | |
261 | + | |
223 | 262 | // Google Drive Validation |
224 | 263 | if (process.env.STORAGE_TYPE === "gdrive") { |
225 | 264 | if (!process.env.GOOGLE_CLIENT_ID) errors.push("❌ GOOGLE_CLIENT_ID is required when STORAGE_TYPE is 'gdrive'"); |
| @@ -243,15 +282,15 @@ export function validateEnvironment(exitOnError: boolean = true): boolean { | ||
243 | 282 | |
244 | 283 | // Print results |
245 | 284 | if (warnings.length > 0) { |
246 | - console.log("\n⚠️ Warnings:"); | |
247 | - warnings.forEach((w) => console.log(w)); | |
285 | + logger.info("\n⚠️ Warnings:"); | |
286 | + warnings.forEach((w) => logger.info(w)); | |
248 | 287 | } |
249 | 288 | |
250 | 289 | if (errors.length > 0) { |
251 | - console.error("\n❌ Environment validation failed:"); | |
252 | - errors.forEach((e) => console.error(e)); | |
253 | - console.error("\nPlease fix the above errors before starting the application."); | |
254 | - console.error("See .env.example for reference.\n"); | |
290 | + logger.error("\n❌ Environment validation failed:"); | |
291 | + errors.forEach((e) => logger.error(e)); | |
292 | + logger.error("\nPlease fix the above errors before starting the application."); | |
293 | + logger.error("See .env.example for reference.\n"); | |
255 | 294 | |
256 | 295 | if (exitOnError) { |
257 | 296 | process.exit(1); |
| @@ -259,7 +298,7 @@ export function validateEnvironment(exitOnError: boolean = true): boolean { | ||
259 | 298 | return false; |
260 | 299 | } |
261 | 300 | |
262 | - console.log("✅ Environment validation passed\n"); | |
301 | + logger.info("✅ Environment validation passed\n"); | |
263 | 302 | return true; |
264 | 303 | } |
265 | 304 | |
266 | 305 | |
▼src/lib/epics.ts
+209-175
| @@ -4,252 +4,286 @@ | ||
4 | 4 | */ |
5 | 5 | |
6 | 6 | import { getDatabase, schema } from "@/db"; |
7 | -import { eq, and, inArray, isNull } from "drizzle-orm"; | |
7 | +import { and, eq, inArray } from "drizzle-orm"; | |
8 | 8 | import { logger } from "./logger"; |
9 | 9 | |
10 | 10 | export interface EpicInfo { |
11 | - epic: typeof schema.issues.$inferSelect; | |
12 | - children: typeof schema.issues.$inferSelect[]; | |
13 | - progress: { | |
14 | - total: number; | |
15 | - closed: number; | |
16 | - percentage: number; | |
17 | - }; | |
11 | + epic: typeof schema.issues.$inferSelect; | |
12 | + children: (typeof schema.issues.$inferSelect)[]; | |
13 | + progress: { | |
14 | + total: number; | |
15 | + closed: number; | |
16 | + percentage: number; | |
17 | + }; | |
18 | 18 | } |
19 | 19 | |
20 | 20 | /** |
21 | 21 | * Create an epic |
22 | 22 | */ |
23 | 23 | export async function createEpic(options: { |
24 | - repositoryId: string; | |
25 | - title: string; | |
26 | - body?: string; | |
27 | - authorId: string; | |
24 | + repositoryId: string; | |
25 | + title: string; | |
26 | + body?: string; | |
27 | + authorId: string; | |
28 | 28 | }): Promise<typeof schema.issues.$inferSelect> { |
29 | - const db = getDatabase(); | |
30 | - | |
31 | - // Get next issue number | |
32 | - const lastIssue = await db.query.issues.findFirst({ | |
33 | - where: eq(schema.issues.repositoryId, options.repositoryId), | |
34 | - orderBy: (issues, { desc }) => [desc(issues.number)], | |
35 | - }); | |
36 | - | |
37 | - const number = (lastIssue?.number || 0) + 1; | |
38 | - | |
39 | - const epic = { | |
40 | - id: crypto.randomUUID(), | |
41 | - repositoryId: options.repositoryId, | |
42 | - number, | |
43 | - title: options.title, | |
44 | - body: options.body || null, | |
45 | - type: "epic", | |
46 | - state: "open", | |
47 | - authorId: options.authorId, | |
48 | - createdAt: new Date(), | |
49 | - updatedAt: new Date(), | |
50 | - }; | |
51 | - | |
52 | - // @ts-expect-error - Drizzle multi-db union type issue | |
53 | - await db.insert(schema.issues).values(epic); | |
54 | - | |
55 | - logger.info({ epicId: epic.id, number }, "Epic created"); | |
56 | - | |
57 | - return epic as typeof schema.issues.$inferSelect; | |
29 | + const db = getDatabase(); | |
30 | + | |
31 | + // Get next issue number | |
32 | + const lastIssue = await db.query.issues.findFirst({ | |
33 | + where: eq(schema.issues.repositoryId, options.repositoryId), | |
34 | + orderBy: (issues, { desc }) => [desc(issues.number)], | |
35 | + }); | |
36 | + | |
37 | + const number = (lastIssue?.number || 0) + 1; | |
38 | + | |
39 | + const epic = { | |
40 | + id: crypto.randomUUID(), | |
41 | + repositoryId: options.repositoryId, | |
42 | + number, | |
43 | + title: options.title, | |
44 | + body: options.body || null, | |
45 | + type: "epic", | |
46 | + state: "open", | |
47 | + authorId: options.authorId, | |
48 | + createdAt: new Date(), | |
49 | + updatedAt: new Date(), | |
50 | + }; | |
51 | + | |
52 | + // @ts-expect-error - Drizzle multi-db union type issue | |
53 | + await db.insert(schema.issues).values(epic); | |
54 | + | |
55 | + logger.info({ epicId: epic.id, number }, "Epic created"); | |
56 | + | |
57 | + return epic as typeof schema.issues.$inferSelect; | |
58 | 58 | } |
59 | 59 | |
60 | 60 | /** |
61 | 61 | * Create a sub-task under an epic or issue |
62 | 62 | */ |
63 | 63 | export async function createSubTask(options: { |
64 | - parentId: string; | |
65 | - title: string; | |
66 | - body?: string; | |
67 | - authorId: string; | |
64 | + parentId: string; | |
65 | + title: string; | |
66 | + body?: string; | |
67 | + authorId: string; | |
68 | 68 | }): Promise<typeof schema.issues.$inferSelect> { |
69 | - const db = getDatabase(); | |
70 | - | |
71 | - const parent = await db.query.issues.findFirst({ | |
72 | - where: eq(schema.issues.id, options.parentId), | |
73 | - }); | |
74 | - | |
75 | - if (!parent) { | |
76 | - throw new Error("Parent issue not found"); | |
77 | - } | |
78 | - | |
79 | - // Get next issue number for this repo | |
80 | - const lastIssue = await db.query.issues.findFirst({ | |
81 | - where: eq(schema.issues.repositoryId, parent.repositoryId), | |
82 | - orderBy: (issues, { desc }) => [desc(issues.number)], | |
83 | - }); | |
84 | - | |
85 | - const number = (lastIssue?.number || 0) + 1; | |
86 | - | |
87 | - const task = { | |
88 | - id: crypto.randomUUID(), | |
89 | - repositoryId: parent.repositoryId, | |
90 | - number, | |
91 | - title: options.title, | |
92 | - body: options.body || null, | |
93 | - type: "task", | |
94 | - parentId: options.parentId, | |
95 | - state: "open", | |
96 | - authorId: options.authorId, | |
97 | - createdAt: new Date(), | |
98 | - updatedAt: new Date(), | |
99 | - }; | |
100 | - | |
101 | - // @ts-expect-error - Drizzle multi-db union type issue | |
102 | - await db.insert(schema.issues).values(task); | |
103 | - | |
104 | - logger.info({ taskId: task.id, parentId: options.parentId }, "Sub-task created"); | |
105 | - | |
106 | - return task as typeof schema.issues.$inferSelect; | |
69 | + const db = getDatabase(); | |
70 | + | |
71 | + const parent = await db.query.issues.findFirst({ | |
72 | + where: eq(schema.issues.id, options.parentId), | |
73 | + }); | |
74 | + | |
75 | + if (!parent) { | |
76 | + throw new Error("Parent issue not found"); | |
77 | + } | |
78 | + | |
79 | + // Get next issue number for this repo | |
80 | + const lastIssue = await db.query.issues.findFirst({ | |
81 | + where: eq(schema.issues.repositoryId, parent.repositoryId), | |
82 | + orderBy: (issues, { desc }) => [desc(issues.number)], | |
83 | + }); | |
84 | + | |
85 | + const number = (lastIssue?.number || 0) + 1; | |
86 | + | |
87 | + const task = { | |
88 | + id: crypto.randomUUID(), | |
89 | + repositoryId: parent.repositoryId, | |
90 | + number, | |
91 | + title: options.title, | |
92 | + body: options.body || null, | |
93 | + type: "task", | |
94 | + parentId: options.parentId, | |
95 | + state: "open", | |
96 | + authorId: options.authorId, | |
97 | + createdAt: new Date(), | |
98 | + updatedAt: new Date(), | |
99 | + }; | |
100 | + | |
101 | + // @ts-expect-error - Drizzle multi-db union type issue | |
102 | + await db.insert(schema.issues).values(task); | |
103 | + | |
104 | + logger.info( | |
105 | + { taskId: task.id, parentId: options.parentId }, | |
106 | + "Sub-task created", | |
107 | + ); | |
108 | + | |
109 | + return task as typeof schema.issues.$inferSelect; | |
107 | 110 | } |
108 | 111 | |
109 | 112 | /** |
110 | 113 | * Get epic with all children and progress |
111 | 114 | */ |
112 | 115 | export async function getEpicInfo(epicId: string): Promise<EpicInfo | null> { |
113 | - const db = getDatabase(); | |
116 | + const db = getDatabase(); | |
114 | 117 | |
115 | - const epic = await db.query.issues.findFirst({ | |
116 | - where: and( | |
117 | - eq(schema.issues.id, epicId), | |
118 | - eq(schema.issues.type, "epic") | |
119 | - ), | |
120 | - }); | |
118 | + const epic = await db.query.issues.findFirst({ | |
119 | + where: and(eq(schema.issues.id, epicId), eq(schema.issues.type, "epic")), | |
120 | + }); | |
121 | 121 | |
122 | - if (!epic) return null; | |
122 | + if (!epic) return null; | |
123 | 123 | |
124 | - const children = await db.query.issues.findMany({ | |
125 | - where: eq(schema.issues.parentId, epicId), | |
126 | - }); | |
124 | + const children = await db.query.issues.findMany({ | |
125 | + where: eq(schema.issues.parentId, epicId), | |
126 | + }); | |
127 | 127 | |
128 | - const closed = children.filter(c => c.state === "closed").length; | |
129 | - const total = children.length; | |
128 | + const closed = children.filter((c) => c.state === "closed").length; | |
129 | + const total = children.length; | |
130 | 130 | |
131 | - return { | |
132 | - epic, | |
133 | - children, | |
134 | - progress: { | |
135 | - total, | |
136 | - closed, | |
137 | - percentage: total > 0 ? Math.round((closed / total) * 100) : 0, | |
138 | - }, | |
139 | - }; | |
131 | + return { | |
132 | + epic, | |
133 | + children, | |
134 | + progress: { | |
135 | + total, | |
136 | + closed, | |
137 | + percentage: total > 0 ? Math.round((closed / total) * 100) : 0, | |
138 | + }, | |
139 | + }; | |
140 | 140 | } |
141 | 141 | |
142 | 142 | /** |
143 | 143 | * Get all epics for a repository |
144 | 144 | */ |
145 | -export async function getRepositoryEpics(repositoryId: string): Promise<EpicInfo[]> { | |
146 | - const db = getDatabase(); | |
147 | - | |
148 | - const epics = await db.query.issues.findMany({ | |
149 | - where: and( | |
150 | - eq(schema.issues.repositoryId, repositoryId), | |
151 | - eq(schema.issues.type, "epic") | |
152 | - ), | |
153 | - }); | |
154 | - | |
155 | - const epicInfos: EpicInfo[] = []; | |
156 | - | |
157 | - for (const epic of epics) { | |
158 | - const info = await getEpicInfo(epic.id); | |
159 | - if (info) epicInfos.push(info); | |
160 | - } | |
161 | - | |
162 | - return epicInfos; | |
145 | +export async function getRepositoryEpics( | |
146 | + repositoryId: string, | |
147 | +): Promise<EpicInfo[]> { | |
148 | + const db = getDatabase(); | |
149 | + | |
150 | + const epics = await db.query.issues.findMany({ | |
151 | + where: and( | |
152 | + eq(schema.issues.repositoryId, repositoryId), | |
153 | + eq(schema.issues.type, "epic"), | |
154 | + ), | |
155 | + }); | |
156 | + | |
157 | + if (epics.length === 0) return []; | |
158 | + | |
159 | + // Batch fetch all children for all epics in one query | |
160 | + const epicIds = epics.map((e) => e.id); | |
161 | + const allChildren = await db.query.issues.findMany({ | |
162 | + where: inArray(schema.issues.parentId, epicIds), | |
163 | + }); | |
164 | + | |
165 | + // Group children by parent in memory | |
166 | + const childrenByEpic = new Map<string, typeof allChildren>(); | |
167 | + for (const child of allChildren) { | |
168 | + if (!child.parentId) continue; | |
169 | + const list = childrenByEpic.get(child.parentId) || []; | |
170 | + list.push(child); | |
171 | + childrenByEpic.set(child.parentId, list); | |
172 | + } | |
173 | + | |
174 | + return epics.map((epic) => { | |
175 | + const children = childrenByEpic.get(epic.id) || []; | |
176 | + const closed = children.filter((c) => c.state === "closed").length; | |
177 | + const total = children.length; | |
178 | + return { | |
179 | + epic, | |
180 | + children, | |
181 | + progress: { | |
182 | + total, | |
183 | + closed, | |
184 | + percentage: total > 0 ? Math.round((closed / total) * 100) : 0, | |
185 | + }, | |
186 | + }; | |
187 | + }); | |
163 | 188 | } |
164 | 189 | |
165 | 190 | /** |
166 | 191 | * Move issue to an epic |
167 | 192 | */ |
168 | -export async function addToEpic(issueId: string, epicId: string): Promise<boolean> { | |
169 | - const db = getDatabase(); | |
193 | +export async function addToEpic( | |
194 | + issueId: string, | |
195 | + epicId: string, | |
196 | +): Promise<boolean> { | |
197 | + const db = getDatabase(); | |
170 | 198 | |
171 | - const epic = await db.query.issues.findFirst({ | |
172 | - where: and( | |
173 | - eq(schema.issues.id, epicId), | |
174 | - eq(schema.issues.type, "epic") | |
175 | - ), | |
176 | - }); | |
199 | + const epic = await db.query.issues.findFirst({ | |
200 | + where: and(eq(schema.issues.id, epicId), eq(schema.issues.type, "epic")), | |
201 | + }); | |
177 | 202 | |
178 | - if (!epic) return false; | |
203 | + if (!epic) return false; | |
179 | 204 | |
180 | - // @ts-expect-error - Drizzle multi-db union type issue | |
181 | - await db.update(schema.issues) | |
182 | - .set({ parentId: epicId, updatedAt: new Date() }) | |
183 | - .where(eq(schema.issues.id, issueId)); | |
205 | + // @ts-expect-error - Drizzle multi-db union type issue | |
206 | + await db | |
207 | + .update(schema.issues) | |
208 | + .set({ parentId: epicId, updatedAt: new Date() }) | |
209 | + .where(eq(schema.issues.id, issueId)); | |
184 | 210 | |
185 | - return true; | |
211 | + return true; | |
186 | 212 | } |
187 | 213 | |
188 | 214 | /** |
189 | 215 | * Remove issue from epic |
190 | 216 | */ |
191 | 217 | export async function removeFromEpic(issueId: string): Promise<boolean> { |
192 | - const db = getDatabase(); | |
218 | + const db = getDatabase(); | |
193 | 219 | |
194 | - // @ts-expect-error - Drizzle multi-db union type issue | |
195 | - await db.update(schema.issues) | |
196 | - .set({ parentId: null, updatedAt: new Date() }) | |
197 | - .where(eq(schema.issues.id, issueId)); | |
220 | + // @ts-expect-error - Drizzle multi-db union type issue | |
221 | + await db | |
222 | + .update(schema.issues) | |
223 | + .set({ parentId: null, updatedAt: new Date() }) | |
224 | + .where(eq(schema.issues.id, issueId)); | |
198 | 225 | |
199 | - return true; | |
226 | + return true; | |
200 | 227 | } |
201 | 228 | |
202 | 229 | /** |
203 | 230 | * Get issue hierarchy (breadcrumb) |
204 | 231 | */ |
205 | -export async function getIssueHierarchy(issueId: string): Promise<typeof schema.issues.$inferSelect[]> { | |
206 | - const db = getDatabase(); | |
207 | - const hierarchy: typeof schema.issues.$inferSelect[] = []; | |
232 | +export async function getIssueHierarchy( | |
233 | + issueId: string, | |
234 | +): Promise<(typeof schema.issues.$inferSelect)[]> { | |
235 | + const db = getDatabase(); | |
236 | + const hierarchy: (typeof schema.issues.$inferSelect)[] = []; | |
208 | 237 | |
209 | - let currentId: string | null = issueId; | |
238 | + let currentId: string | null = issueId; | |
210 | 239 | |
211 | - while (currentId) { | |
212 | - const issue = await db.query.issues.findFirst({ | |
213 | - where: eq(schema.issues.id, currentId), | |
214 | - }) as typeof schema.issues.$inferSelect | undefined; | |
240 | + while (currentId) { | |
241 | + const issue = (await db.query.issues.findFirst({ | |
242 | + where: eq(schema.issues.id, currentId), | |
243 | + })) as typeof schema.issues.$inferSelect | undefined; | |
215 | 244 | |
216 | - if (!issue) break; | |
245 | + if (!issue) break; | |
217 | 246 | |
218 | - hierarchy.unshift(issue); | |
219 | - currentId = issue.parentId; | |
220 | - } | |
247 | + hierarchy.unshift(issue); | |
248 | + currentId = issue.parentId; | |
249 | + } | |
221 | 250 | |
222 | - return hierarchy; | |
251 | + return hierarchy; | |
223 | 252 | } |
224 | 253 | |
225 | 254 | /** |
226 | 255 | * Convert issue to epic |
227 | 256 | */ |
228 | 257 | export async function convertToEpic(issueId: string): Promise<boolean> { |
229 | - const db = getDatabase(); | |
258 | + const db = getDatabase(); | |
230 | 259 | |
231 | - // @ts-expect-error - Drizzle multi-db union type issue | |
232 | - await db.update(schema.issues) | |
233 | - .set({ type: "epic", parentId: null, updatedAt: new Date() }) | |
234 | - .where(eq(schema.issues.id, issueId)); | |
260 | + // @ts-expect-error - Drizzle multi-db union type issue | |
261 | + await db | |
262 | + .update(schema.issues) | |
263 | + .set({ type: "epic", parentId: null, updatedAt: new Date() }) | |
264 | + .where(eq(schema.issues.id, issueId)); | |
235 | 265 | |
236 | - return true; | |
266 | + return true; | |
237 | 267 | } |
238 | 268 | |
239 | 269 | /** |
240 | 270 | * Convert issue to task |
241 | 271 | */ |
242 | -export async function convertToTask(issueId: string, parentId?: string): Promise<boolean> { | |
243 | - const db = getDatabase(); | |
244 | - | |
245 | - // @ts-expect-error - Drizzle multi-db union type issue | |
246 | - await db.update(schema.issues) | |
247 | - .set({ | |
248 | - type: "task", | |
249 | - parentId: parentId || null, | |
250 | - updatedAt: new Date() | |
251 | - }) | |
252 | - .where(eq(schema.issues.id, issueId)); | |
253 | - | |
254 | - return true; | |
272 | +export async function convertToTask( | |
273 | + issueId: string, | |
274 | + parentId?: string, | |
275 | +): Promise<boolean> { | |
276 | + const db = getDatabase(); | |
277 | + | |
278 | + // @ts-expect-error - Drizzle multi-db union type issue | |
279 | + await db | |
280 | + .update(schema.issues) | |
281 | + .set({ | |
282 | + type: "task", | |
283 | + parentId: parentId || null, | |
284 | + updatedAt: new Date(), | |
285 | + }) | |
286 | + .where(eq(schema.issues.id, issueId)); | |
287 | + | |
288 | + return true; | |
255 | 289 | } |
256 | 290 | |
▼src/lib/exec.ts
+31-0
| @@ -0,0 +1,31 @@ | ||
1 | +/** | |
2 | + * Promisified exec utility | |
3 | + */ | |
4 | + | |
5 | +import { exec } from "child_process"; | |
6 | +import { promisify } from "util"; | |
7 | + | |
8 | +const execPromise = promisify(exec); | |
9 | + | |
10 | +export interface ExecOptions { | |
11 | + cwd?: string; | |
12 | + timeout?: number; | |
13 | + env?: NodeJS.ProcessEnv; | |
14 | + maxBuffer?: number; | |
15 | +} | |
16 | + | |
17 | +/** | |
18 | + * Execute a shell command and return stdout/stderr. | |
19 | + * Throws on non-zero exit code. | |
20 | + */ | |
21 | +export async function execAsync( | |
22 | + command: string, | |
23 | + options: ExecOptions = {}, | |
24 | +): Promise<{ stdout: string; stderr: string }> { | |
25 | + return execPromise(command, { | |
26 | + cwd: options.cwd, | |
27 | + timeout: options.timeout || 0, | |
28 | + env: options.env || process.env, | |
29 | + maxBuffer: options.maxBuffer || 50 * 1024 * 1024, // 50MB | |
30 | + }); | |
31 | +} | |
0 | 32 | |
▼src/lib/external-ci.ts
+411-335
| @@ -3,12 +3,11 @@ | ||
3 | 3 | * Official integrations with external CI/CD systems |
4 | 4 | */ |
5 | 5 | |
6 | -import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core"; | |
7 | 6 | import { getDatabase, schema } from "@/db"; |
8 | -import { eq, and } from "drizzle-orm"; | |
7 | +import { externalBuilds, externalCIConfigs } from "@/db/schema/integrations"; | |
8 | +import { and, eq } from "drizzle-orm"; | |
9 | +import { assertOnlineFeature } from "./air-gapped"; | |
9 | 10 | import { logger } from "./logger"; |
10 | -import { repositories } from "@/db/schema/repositories"; | |
11 | -import { externalCIConfigs, externalBuilds } from "@/db/schema/missing-schemas"; | |
12 | 11 | |
13 | 12 | /** |
14 | 13 | * External CI provider configurations |
| @@ -30,392 +29,469 @@ export type ExternalBuild = typeof externalBuilds.$inferSelect; | ||
30 | 29 | * Supported CI providers |
31 | 30 | */ |
32 | 31 | export const CI_PROVIDERS = { |
33 | - jenkins: { | |
34 | - name: "Jenkins", | |
35 | - icon: "jenkins", | |
36 | - triggerEndpoint: "/job/{project}/build", | |
37 | - statusEndpoint: "/job/{project}/{build}/api/json", | |
38 | - }, | |
39 | - circleci: { | |
40 | - name: "CircleCI", | |
41 | - icon: "circleci", | |
42 | - triggerEndpoint: "/api/v2/project/{project}/pipeline", | |
43 | - statusEndpoint: "/api/v2/pipeline/{pipeline}", | |
44 | - }, | |
45 | - travis: { | |
46 | - name: "Travis CI", | |
47 | - icon: "travis", | |
48 | - triggerEndpoint: "/repo/{project}/requests", | |
49 | - statusEndpoint: "/build/{build}", | |
50 | - }, | |
51 | - gitlab_ci: { | |
52 | - name: "GitLab CI", | |
53 | - icon: "gitlab", | |
54 | - triggerEndpoint: "/api/v4/projects/{project}/trigger/pipeline", | |
55 | - statusEndpoint: "/api/v4/projects/{project}/pipelines/{pipeline}", | |
56 | - }, | |
57 | - azure_devops: { | |
58 | - name: "Azure DevOps", | |
59 | - icon: "azure", | |
60 | - triggerEndpoint: "/{org}/{project}/_apis/build/builds", | |
61 | - statusEndpoint: "/{org}/{project}/_apis/build/builds/{build}", | |
62 | - }, | |
63 | - github_actions: { | |
64 | - name: "GitHub Actions", | |
65 | - icon: "github", | |
66 | - triggerEndpoint: "/repos/{owner}/{repo}/actions/workflows/{workflow}/dispatches", | |
67 | - statusEndpoint: "/repos/{owner}/{repo}/actions/runs/{run}", | |
68 | - }, | |
32 | + jenkins: { | |
33 | + name: "Jenkins", | |
34 | + icon: "jenkins", | |
35 | + triggerEndpoint: "/job/{project}/build", | |
36 | + statusEndpoint: "/job/{project}/{build}/api/json", | |
37 | + }, | |
38 | + circleci: { | |
39 | + name: "CircleCI", | |
40 | + icon: "circleci", | |
41 | + triggerEndpoint: "/api/v2/project/{project}/pipeline", | |
42 | + statusEndpoint: "/api/v2/pipeline/{pipeline}", | |
43 | + }, | |
44 | + buildkite: { | |
45 | + name: "Buildkite", | |
46 | + icon: "buildkite", | |
47 | + // Expect baseUrl to include org/pipeline, e.g. https://api.buildkite.com/v2/organizations/acme/pipelines/app | |
48 | + triggerEndpoint: "/builds", | |
49 | + statusEndpoint: "/builds/{build}", | |
50 | + }, | |
51 | + travis: { | |
52 | + name: "Travis CI", | |
53 | + icon: "travis", | |
54 | + triggerEndpoint: "/repo/{project}/requests", | |
55 | + statusEndpoint: "/build/{build}", | |
56 | + }, | |
57 | + gitlab_ci: { | |
58 | + name: "GitLab CI", | |
59 | + icon: "gitlab", | |
60 | + triggerEndpoint: "/api/v4/projects/{project}/trigger/pipeline", | |
61 | + statusEndpoint: "/api/v4/projects/{project}/pipelines/{pipeline}", | |
62 | + }, | |
63 | + gitlab: { | |
64 | + name: "GitLab CI", | |
65 | + icon: "gitlab", | |
66 | + triggerEndpoint: "/api/v4/projects/{project}/trigger/pipeline", | |
67 | + statusEndpoint: "/api/v4/projects/{project}/pipelines/{pipeline}", | |
68 | + }, | |
69 | + azure_devops: { | |
70 | + name: "Azure DevOps", | |
71 | + icon: "azure", | |
72 | + triggerEndpoint: "/{org}/{project}/_apis/build/builds", | |
73 | + statusEndpoint: "/{org}/{project}/_apis/build/builds/{build}", | |
74 | + }, | |
75 | + github_actions: { | |
76 | + name: "GitHub Actions", | |
77 | + icon: "github", | |
78 | + triggerEndpoint: | |
79 | + "/repos/{owner}/{repo}/actions/workflows/{workflow}/dispatches", | |
80 | + statusEndpoint: "/repos/{owner}/{repo}/actions/runs/{run}", | |
81 | + }, | |
69 | 82 | } as const; |
70 | 83 | |
71 | 84 | /** |
72 | 85 | * Configure external CI for a repository |
73 | 86 | */ |
74 | 87 | export async function configureExternalCI(options: { |
75 | - repositoryId: string; | |
76 | - provider: keyof typeof CI_PROVIDERS; | |
77 | - name: string; | |
78 | - baseUrl: string; | |
79 | - apiToken?: string; | |
80 | - projectId?: string; | |
88 | + repositoryId: string; | |
89 | + provider: keyof typeof CI_PROVIDERS; | |
90 | + name: string; | |
91 | + baseUrl: string; | |
92 | + apiToken?: string; | |
93 | + projectId?: string; | |
81 | 94 | }): Promise<ExternalCIConfig> { |
82 | - const db = getDatabase(); | |
83 | - | |
84 | - const config = { | |
85 | - id: crypto.randomUUID(), | |
86 | - repositoryId: options.repositoryId, | |
87 | - provider: options.provider, | |
88 | - name: options.name, | |
89 | - baseUrl: options.baseUrl, | |
90 | - apiToken: options.apiToken || null, | |
91 | - projectId: options.projectId || null, | |
92 | - webhookSecret: crypto.randomUUID(), | |
93 | - isEnabled: true, | |
94 | - syncStatus: true, | |
95 | - createdAt: new Date(), | |
96 | - updatedAt: new Date(), | |
97 | - }; | |
98 | - // @ts-expect-error - Drizzle multi-db union type issue | |
99 | - await db.insert(schema.externalCIConfigs).values(config); | |
100 | - | |
101 | - logger.info({ | |
102 | - repoId: options.repositoryId, | |
103 | - provider: options.provider | |
104 | - }, "External CI configured"); | |
95 | + const db = getDatabase(); | |
96 | + | |
97 | + const config = { | |
98 | + id: crypto.randomUUID(), | |
99 | + repositoryId: options.repositoryId, | |
100 | + provider: options.provider, | |
101 | + name: options.name, | |
102 | + baseUrl: options.baseUrl, | |
103 | + apiToken: options.apiToken || null, | |
104 | + projectId: options.projectId || null, | |
105 | + webhookSecret: crypto.randomUUID(), | |
106 | + isEnabled: true, | |
107 | + syncStatus: true, | |
108 | + createdAt: new Date(), | |
109 | + updatedAt: new Date(), | |
110 | + }; | |
111 | + // @ts-expect-error - Drizzle multi-db union type issue | |
112 | + await db.insert(schema.externalCIConfigs).values(config); | |
113 | + | |
114 | + logger.info( | |
115 | + { | |
116 | + repoId: options.repositoryId, | |
117 | + provider: options.provider, | |
118 | + }, | |
119 | + "External CI configured", | |
120 | + ); | |
105 | 121 | |
106 | - return config as ExternalCIConfig; | |
122 | + return config as ExternalCIConfig; | |
107 | 123 | } |
108 | 124 | |
109 | 125 | /** |
110 | 126 | * Get external CI configs for repository |
111 | 127 | */ |
112 | -export async function getExternalCIConfigs(repositoryId: string): Promise<ExternalCIConfig[]> { | |
113 | - const db = getDatabase(); | |
114 | - | |
115 | - try { | |
116 | - return await db.query.externalCIConfigs?.findMany({ | |
117 | - where: eq(schema.externalCIConfigs.repositoryId, repositoryId), | |
118 | - }) || []; | |
119 | - } catch { | |
120 | - return []; | |
121 | - } | |
128 | +export async function getExternalCIConfigs( | |
129 | + repositoryId: string, | |
130 | +): Promise<ExternalCIConfig[]> { | |
131 | + const db = getDatabase(); | |
132 | + | |
133 | + try { | |
134 | + return ( | |
135 | + (await db.query.externalCIConfigs?.findMany({ | |
136 | + where: eq(schema.externalCIConfigs.repositoryId, repositoryId), | |
137 | + })) || [] | |
138 | + ); | |
139 | + } catch { | |
140 | + return []; | |
141 | + } | |
122 | 142 | } |
123 | 143 | |
124 | 144 | /** |
125 | 145 | * Trigger an external CI build |
126 | 146 | */ |
127 | 147 | export async function triggerExternalBuild(options: { |
128 | - configId: string; | |
129 | - pullRequestId?: string; | |
130 | - branch?: string; | |
131 | - commitSha?: string; | |
148 | + configId: string; | |
149 | + pullRequestId?: string; | |
150 | + branch?: string; | |
151 | + commitSha?: string; | |
132 | 152 | }): Promise<ExternalBuild | null> { |
133 | - const db = getDatabase(); | |
134 | - | |
135 | - const config = await db.query.externalCIConfigs?.findFirst({ | |
136 | - where: eq(schema.externalCIConfigs.id, options.configId), | |
153 | + assertOnlineFeature("External CI build triggering"); | |
154 | + const db = getDatabase(); | |
155 | + | |
156 | + const config = await db.query.externalCIConfigs?.findFirst({ | |
157 | + where: eq(schema.externalCIConfigs.id, options.configId), | |
158 | + }); | |
159 | + | |
160 | + if (!config || !config.isEnabled) { | |
161 | + return null; | |
162 | + } | |
163 | + | |
164 | + const provider = CI_PROVIDERS[config.provider as keyof typeof CI_PROVIDERS]; | |
165 | + if (!provider) { | |
166 | + logger.error({ provider: config.provider }, "Unknown CI provider"); | |
167 | + return null; | |
168 | + } | |
169 | + | |
170 | + // Build the trigger URL | |
171 | + let triggerUrl = `${config.baseUrl}${provider.triggerEndpoint}`; | |
172 | + triggerUrl = triggerUrl.replace("{project}", config.projectId || ""); | |
173 | + | |
174 | + try { | |
175 | + // Make API call to trigger build | |
176 | + const response = await fetch(triggerUrl, { | |
177 | + method: "POST", | |
178 | + headers: { | |
179 | + "Content-Type": "application/json", | |
180 | + ...(config.apiToken && { Authorization: `Bearer ${config.apiToken}` }), | |
181 | + }, | |
182 | + body: JSON.stringify({ | |
183 | + ref: options.branch || "main", | |
184 | + sha: options.commitSha, | |
185 | + }), | |
137 | 186 | }); |
138 | 187 | |
139 | - if (!config || !config.isEnabled) { | |
140 | - return null; | |
188 | + if (!response.ok) { | |
189 | + logger.error( | |
190 | + { | |
191 | + provider: config.provider, | |
192 | + status: response.status, | |
193 | + }, | |
194 | + "Failed to trigger external build", | |
195 | + ); | |
196 | + return null; | |
141 | 197 | } |
142 | 198 | |
143 | - const provider = CI_PROVIDERS[config.provider as keyof typeof CI_PROVIDERS]; | |
144 | - if (!provider) { | |
145 | - logger.error({ provider: config.provider }, "Unknown CI provider"); | |
146 | - return null; | |
147 | - } | |
199 | + const data = await response.json(); | |
200 | + const externalBuildId = | |
201 | + data.id || data.build_id || data.number || crypto.randomUUID(); | |
202 | + | |
203 | + const build = { | |
204 | + id: crypto.randomUUID(), | |
205 | + configId: options.configId, | |
206 | + pullRequestId: options.pullRequestId || null, | |
207 | + externalBuildId: String(externalBuildId), | |
208 | + buildNumber: data.number || null, | |
209 | + status: "pending", | |
210 | + url: data.url || data.web_url || null, | |
211 | + startedAt: new Date(), | |
212 | + completedAt: null, | |
213 | + createdAt: new Date(), | |
214 | + }; | |
148 | 215 | |
149 | - // Build the trigger URL | |
150 | - let triggerUrl = `${config.baseUrl}${provider.triggerEndpoint}`; | |
151 | - triggerUrl = triggerUrl.replace("{project}", config.projectId || ""); | |
152 | - | |
153 | - try { | |
154 | - // Make API call to trigger build | |
155 | - const response = await fetch(triggerUrl, { | |
156 | - method: "POST", | |
157 | - headers: { | |
158 | - "Content-Type": "application/json", | |
159 | - ...(config.apiToken && { "Authorization": `Bearer ${config.apiToken}` }), | |
160 | - }, | |
161 | - body: JSON.stringify({ | |
162 | - ref: options.branch || "main", | |
163 | - sha: options.commitSha, | |
164 | - }), | |
165 | - }); | |
166 | - | |
167 | - if (!response.ok) { | |
168 | - logger.error({ | |
169 | - provider: config.provider, | |
170 | - status: response.status | |
171 | - }, "Failed to trigger external build"); | |
172 | - return null; | |
173 | - } | |
174 | - | |
175 | - const data = await response.json(); | |
176 | - const externalBuildId = data.id || data.build_id || data.number || crypto.randomUUID(); | |
177 | - | |
178 | - const build = { | |
179 | - id: crypto.randomUUID(), | |
180 | - configId: options.configId, | |
181 | - pullRequestId: options.pullRequestId || null, | |
182 | - externalBuildId: String(externalBuildId), | |
183 | - buildNumber: data.number || null, | |
184 | - status: "pending", | |
185 | - url: data.url || data.web_url || null, | |
186 | - startedAt: new Date(), | |
187 | - completedAt: null, | |
188 | - createdAt: new Date(), | |
189 | - }; | |
190 | - | |
191 | - // @ts-expect-error - Drizzle multi-db union type issue | |
192 | - await db.insert(schema.externalBuilds).values(build); | |
193 | - | |
194 | - logger.info({ | |
195 | - configId: options.configId, | |
196 | - buildId: externalBuildId | |
197 | - }, "External build triggered"); | |
198 | - | |
199 | - return build as ExternalBuild; | |
200 | - } catch (error) { | |
201 | - logger.error({ error, configId: options.configId }, "Error triggering external build"); | |
202 | - return null; | |
203 | - } | |
216 | + // @ts-expect-error - Drizzle multi-db union type issue | |
217 | + await db.insert(schema.externalBuilds).values(build); | |
218 | + | |
219 | + logger.info( | |
220 | + { | |
221 | + configId: options.configId, | |
222 | + buildId: externalBuildId, | |
223 | + }, | |
224 | + "External build triggered", | |
225 | + ); | |
226 | + | |
227 | + return build as ExternalBuild; | |
228 | + } catch (error) { | |
229 | + logger.error( | |
230 | + { error, configId: options.configId }, | |
231 | + "Error triggering external build", | |
232 | + ); | |
233 | + return null; | |
234 | + } | |
204 | 235 | } |
205 | 236 | |
206 | 237 | /** |
207 | 238 | * Sync build status from external CI |
208 | 239 | */ |
209 | -export async function syncBuildStatus(buildId: string): Promise<ExternalBuild | null> { | |
210 | - const db = getDatabase(); | |
211 | - | |
212 | - const build = await db.query.externalBuilds?.findFirst({ | |
213 | - where: eq(schema.externalBuilds.id, buildId), | |
240 | +export async function syncBuildStatus( | |
241 | + buildId: string, | |
242 | +): Promise<ExternalBuild | null> { | |
243 | + assertOnlineFeature("External CI status synchronization"); | |
244 | + const db = getDatabase(); | |
245 | + | |
246 | + const build = await db.query.externalBuilds?.findFirst({ | |
247 | + where: eq(schema.externalBuilds.id, buildId), | |
248 | + }); | |
249 | + | |
250 | + if (!build) return null; | |
251 | + | |
252 | + const config = await db.query.externalCIConfigs?.findFirst({ | |
253 | + where: eq(schema.externalCIConfigs.id, build.configId), | |
254 | + }); | |
255 | + | |
256 | + if (!config) return null; | |
257 | + | |
258 | + const provider = CI_PROVIDERS[config.provider as keyof typeof CI_PROVIDERS]; | |
259 | + if (!provider) return null; | |
260 | + | |
261 | + let statusUrl = `${config.baseUrl}${provider.statusEndpoint}`; | |
262 | + statusUrl = statusUrl | |
263 | + .replace("{project}", config.projectId || "") | |
264 | + .replace("{build}", build.externalBuildId) | |
265 | + .replace("{pipeline}", build.externalBuildId); | |
266 | + | |
267 | + try { | |
268 | + const response = await fetch(statusUrl, { | |
269 | + headers: { | |
270 | + ...(config.apiToken && { Authorization: `Bearer ${config.apiToken}` }), | |
271 | + }, | |
214 | 272 | }); |
215 | 273 | |
216 | - if (!build) return null; | |
274 | + if (!response.ok) return null; | |
217 | 275 | |
218 | - const config = await db.query.externalCIConfigs?.findFirst({ | |
219 | - where: eq(schema.externalCIConfigs.id, build.configId), | |
220 | - }); | |
276 | + const data = await response.json(); | |
277 | + | |
278 | + // Normalize status across providers | |
279 | + const status = normalizeStatus( | |
280 | + config.provider, | |
281 | + data.status || data.result || data.state, | |
282 | + ); | |
283 | + const completedAt = | |
284 | + status !== "pending" && status !== "running" ? new Date() : null; | |
221 | 285 | |
222 | - if (!config) return null; | |
223 | - | |
224 | - const provider = CI_PROVIDERS[config.provider as keyof typeof CI_PROVIDERS]; | |
225 | - if (!provider) return null; | |
226 | - | |
227 | - let statusUrl = `${config.baseUrl}${provider.statusEndpoint}`; | |
228 | - statusUrl = statusUrl | |
229 | - .replace("{project}", config.projectId || "") | |
230 | - .replace("{build}", build.externalBuildId) | |
231 | - .replace("{pipeline}", build.externalBuildId); | |
232 | - | |
233 | - try { | |
234 | - const response = await fetch(statusUrl, { | |
235 | - headers: { | |
236 | - ...(config.apiToken && { "Authorization": `Bearer ${config.apiToken}` }), | |
237 | - }, | |
238 | - }); | |
239 | - | |
240 | - if (!response.ok) return null; | |
241 | - | |
242 | - const data = await response.json(); | |
243 | - | |
244 | - // Normalize status across providers | |
245 | - const status = normalizeStatus(config.provider, data.status || data.result || data.state); | |
246 | - const completedAt = status !== "pending" && status !== "running" ? new Date() : null; | |
247 | - | |
248 | - // @ts-expect-error - Drizzle multi-db union type issue | |
249 | - await db.update(schema.externalBuilds) | |
250 | - .set({ | |
251 | - status, | |
252 | - completedAt, | |
253 | - url: data.url || data.web_url || build.url, | |
254 | - }) | |
255 | - .where(eq(schema.externalBuilds.id, buildId)); | |
256 | - | |
257 | - // If syncing to PR, update PR check | |
258 | - if (build.pullRequestId && config.syncStatus) { | |
259 | - await syncStatusToPR(build.pullRequestId, config.name, status); | |
260 | - } | |
261 | - | |
262 | - return { ...build, status, completedAt }; | |
263 | - } catch (error) { | |
264 | - logger.error({ error, buildId }, "Error syncing build status"); | |
265 | - return null; | |
286 | + // @ts-expect-error - Drizzle multi-db union type issue | |
287 | + await db | |
288 | + .update(schema.externalBuilds) | |
289 | + .set({ | |
290 | + status, | |
291 | + completedAt, | |
292 | + url: data.url || data.web_url || build.url, | |
293 | + }) | |
294 | + .where(eq(schema.externalBuilds.id, buildId)); | |
295 | + | |
296 | + // If syncing to PR, update PR check | |
297 | + if (build.pullRequestId && config.syncStatus) { | |
298 | + await syncStatusToPR(build.pullRequestId, config.name, status); | |
266 | 299 | } |
300 | + | |
301 | + return { ...build, status, completedAt }; | |
302 | + } catch (error) { | |
303 | + logger.error({ error, buildId }, "Error syncing build status"); | |
304 | + return null; | |
305 | + } | |
267 | 306 | } |
268 | 307 | |
269 | 308 | /** |
270 | 309 | * Normalize status across different CI providers |
271 | 310 | */ |
272 | 311 | function normalizeStatus(provider: string, rawStatus: string): string { |
273 | - const statusMap: Record<string, Record<string, string>> = { | |
274 | - jenkins: { | |
275 | - "SUCCESS": "success", | |
276 | - "FAILURE": "failure", | |
277 | - "UNSTABLE": "failure", | |
278 | - "ABORTED": "cancelled", | |
279 | - "BUILDING": "running", | |
280 | - "NOT_BUILT": "pending", | |
281 | - }, | |
282 | - circleci: { | |
283 | - "success": "success", | |
284 | - "failed": "failure", | |
285 | - "error": "failure", | |
286 | - "canceled": "cancelled", | |
287 | - "running": "running", | |
288 | - "pending": "pending", | |
289 | - }, | |
290 | - travis: { | |
291 | - "passed": "success", | |
292 | - "failed": "failure", | |
293 | - "errored": "failure", | |
294 | - "canceled": "cancelled", | |
295 | - "started": "running", | |
296 | - "created": "pending", | |
297 | - }, | |
298 | - gitlab_ci: { | |
299 | - "success": "success", | |
300 | - "failed": "failure", | |
301 | - "canceled": "cancelled", | |
302 | - "running": "running", | |
303 | - "pending": "pending", | |
304 | - "created": "pending", | |
305 | - }, | |
306 | - azure_devops: { | |
307 | - "succeeded": "success", | |
308 | - "failed": "failure", | |
309 | - "canceled": "cancelled", | |
310 | - "inProgress": "running", | |
311 | - "notStarted": "pending", | |
312 | - }, | |
313 | - }; | |
312 | + const statusMap: Record<string, Record<string, string>> = { | |
313 | + jenkins: { | |
314 | + SUCCESS: "success", | |
315 | + FAILURE: "failure", | |
316 | + UNSTABLE: "failure", | |
317 | + ABORTED: "cancelled", | |
318 | + BUILDING: "running", | |
319 | + NOT_BUILT: "pending", | |
320 | + }, | |
321 | + circleci: { | |
322 | + success: "success", | |
323 | + failed: "failure", | |
324 | + error: "failure", | |
325 | + canceled: "cancelled", | |
326 | + running: "running", | |
327 | + pending: "pending", | |
328 | + }, | |
329 | + travis: { | |
330 | + passed: "success", | |
331 | + failed: "failure", | |
332 | + errored: "failure", | |
333 | + canceled: "cancelled", | |
334 | + started: "running", | |
335 | + created: "pending", | |
336 | + }, | |
337 | + gitlab_ci: { | |
338 | + success: "success", | |
339 | + failed: "failure", | |
340 | + canceled: "cancelled", | |
341 | + running: "running", | |
342 | + pending: "pending", | |
343 | + created: "pending", | |
344 | + }, | |
345 | + gitlab: { | |
346 | + success: "success", | |
347 | + failed: "failure", | |
348 | + canceled: "cancelled", | |
349 | + running: "running", | |
350 | + pending: "pending", | |
351 | + created: "pending", | |
352 | + }, | |
353 | + buildkite: { | |
354 | + passed: "success", | |
355 | + failed: "failure", | |
356 | + blocked: "cancelled", | |
357 | + canceled: "cancelled", | |
358 | + canceling: "running", | |
359 | + running: "running", | |
360 | + scheduled: "pending", | |
361 | + creating: "pending", | |
362 | + }, | |
363 | + azure_devops: { | |
364 | + succeeded: "success", | |
365 | + failed: "failure", | |
366 | + canceled: "cancelled", | |
367 | + inProgress: "running", | |
368 | + notStarted: "pending", | |
369 | + }, | |
370 | + }; | |
314 | 371 | |
315 | - const providerMap = statusMap[provider] || {}; | |
316 | - return providerMap[rawStatus] || "pending"; | |
372 | + const providerMap = statusMap[provider] || {}; | |
373 | + return providerMap[rawStatus] || "pending"; | |
317 | 374 | } |
318 | 375 | |
319 | 376 | /** |
320 | 377 | * Sync external CI status to PR |
321 | 378 | */ |
322 | -async function syncStatusToPR(prId: string, checkName: string, status: string): Promise<void> { | |
323 | - const db = getDatabase(); | |
324 | - const pr = await db.query.pullRequests.findFirst({ | |
325 | - where: eq(schema.pullRequests.id, prId), | |
326 | - }); | |
327 | - if (!pr) return; | |
328 | - | |
329 | - const conclusion = status === "success" ? "success" | |
330 | - : status === "failure" ? "failure" | |
331 | - : status === "cancelled" ? "cancelled" | |
332 | - : null; | |
333 | - | |
334 | - // Upsert PR check | |
335 | - const existing = await db.query.pullRequestChecks.findFirst({ | |
336 | - where: and( | |
337 | - eq(schema.pullRequestChecks.pullRequestId, prId), | |
338 | - eq(schema.pullRequestChecks.name, checkName) | |
339 | - ), | |
379 | +async function syncStatusToPR( | |
380 | + prId: string, | |
381 | + checkName: string, | |
382 | + status: string, | |
383 | +): Promise<void> { | |
384 | + const db = getDatabase(); | |
385 | + const pr = await db.query.pullRequests.findFirst({ | |
386 | + where: eq(schema.pullRequests.id, prId), | |
387 | + }); | |
388 | + if (!pr) return; | |
389 | + | |
390 | + const conclusion = | |
391 | + status === "success" | |
392 | + ? "success" | |
393 | + : status === "failure" | |
394 | + ? "failure" | |
395 | + : status === "cancelled" | |
396 | + ? "cancelled" | |
397 | + : null; | |
398 | + | |
399 | + // Upsert PR check | |
400 | + const existing = await db.query.pullRequestChecks.findFirst({ | |
401 | + where: and( | |
402 | + eq(schema.pullRequestChecks.pullRequestId, prId), | |
403 | + eq(schema.pullRequestChecks.name, checkName), | |
404 | + ), | |
405 | + }); | |
406 | + | |
407 | + if (existing) { | |
408 | + // @ts-expect-error - Drizzle multi-db union type issue | |
409 | + await db | |
410 | + .update(schema.pullRequestChecks) | |
411 | + .set({ | |
412 | + headSha: pr.headSha, | |
413 | + status: status === "running" ? "in_progress" : "completed", | |
414 | + conclusion, | |
415 | + updatedAt: new Date(), | |
416 | + }) | |
417 | + .where(eq(schema.pullRequestChecks.id, existing.id)); | |
418 | + } else { | |
419 | + // @ts-expect-error - Drizzle multi-db union type issue | |
420 | + await db.insert(schema.pullRequestChecks).values({ | |
421 | + id: crypto.randomUUID(), | |
422 | + pullRequestId: prId, | |
423 | + name: checkName, | |
424 | + headSha: pr.headSha, | |
425 | + status: status === "running" ? "in_progress" : "completed", | |
426 | + conclusion, | |
427 | + createdAt: new Date(), | |
428 | + updatedAt: new Date(), | |
340 | 429 | }); |
341 | - | |
342 | - | |
343 | - if (existing) { | |
344 | - // @ts-expect-error - Drizzle multi-db union type issue | |
345 | - await db.update(schema.pullRequestChecks) | |
346 | - .set({ | |
347 | - headSha: pr.headSha, | |
348 | - status: status === "running" ? "in_progress" : "completed", | |
349 | - conclusion, | |
350 | - updatedAt: new Date(), | |
351 | - }) | |
352 | - .where(eq(schema.pullRequestChecks.id, existing.id)); | |
353 | - } else { | |
354 | - // @ts-expect-error - Drizzle multi-db union type issue | |
355 | - await db.insert(schema.pullRequestChecks).values({ | |
356 | - id: crypto.randomUUID(), | |
357 | - pullRequestId: prId, | |
358 | - name: checkName, | |
359 | - headSha: pr.headSha, | |
360 | - status: status === "running" ? "in_progress" : "completed", | |
361 | - conclusion, | |
362 | - createdAt: new Date(), | |
363 | - updatedAt: new Date(), | |
364 | - }); | |
365 | - } | |
430 | + } | |
366 | 431 | } |
367 | 432 | |
368 | 433 | /** |
369 | 434 | * Handle webhook from external CI |
370 | 435 | */ |
371 | 436 | export async function handleExternalCIWebhook( |
372 | - webhookSecret: string, | |
373 | - payload: Record<string, unknown> | |
437 | + webhookSecret: string, | |
438 | + payload: Record<string, unknown>, | |
374 | 439 | ): Promise<boolean> { |
375 | - const db = getDatabase(); | |
376 | - | |
377 | - // Find config by webhook secret | |
378 | - const config = await db.query.externalCIConfigs?.findFirst({ | |
379 | - where: eq(schema.externalCIConfigs.webhookSecret, webhookSecret), | |
380 | - }); | |
381 | - | |
382 | - if (!config) { | |
383 | - logger.warn({ webhookSecret: webhookSecret.slice(0, 8) }, "Unknown webhook secret"); | |
384 | - return false; | |
385 | - } | |
386 | - | |
387 | - // Extract build info from payload (provider-specific) | |
388 | - const buildId = payload.build_id || payload.id || (payload.build as any)?.id; | |
389 | - const status = payload.status || payload.state || payload.result; | |
390 | - | |
391 | - if (buildId && status) { | |
392 | - // Find and update build | |
393 | - const build = await db.query.externalBuilds?.findFirst({ | |
394 | - where: and( | |
395 | - eq(schema.externalBuilds.configId, config.id), | |
396 | - eq(schema.externalBuilds.externalBuildId, String(buildId)) | |
397 | - ), | |
398 | - }) as ExternalBuild | undefined; | |
399 | - | |
400 | - if (build) { | |
401 | - const normalizedStatus = normalizeStatus(config.provider, String(status)); | |
402 | - | |
403 | - // @ts-expect-error - Drizzle multi-db union type issue | |
404 | - await db.update(schema.externalBuilds) | |
405 | - .set({ | |
406 | - status: normalizedStatus, | |
407 | - completedAt: ["success", "failure", "cancelled"].includes(normalizedStatus) | |
408 | - ? new Date() | |
409 | - : null, | |
410 | - }) | |
411 | - .where(eq(schema.externalBuilds.id, build.id)); | |
412 | - | |
413 | - // Sync to PR | |
414 | - if (build.pullRequestId && config.syncStatus) { | |
415 | - await syncStatusToPR(build.pullRequestId, config.name, normalizedStatus); | |
416 | - } | |
417 | - } | |
440 | + assertOnlineFeature("External CI webhooks"); | |
441 | + const db = getDatabase(); | |
442 | + | |
443 | + // Find config by webhook secret | |
444 | + const config = await db.query.externalCIConfigs?.findFirst({ | |
445 | + where: eq(schema.externalCIConfigs.webhookSecret, webhookSecret), | |
446 | + }); | |
447 | + | |
448 | + if (!config) { | |
449 | + logger.warn( | |
450 | + { webhookSecret: webhookSecret.slice(0, 8) }, | |
451 | + "Unknown webhook secret", | |
452 | + ); | |
453 | + return false; | |
454 | + } | |
455 | + | |
456 | + // Extract build info from payload (provider-specific) | |
457 | + const buildId = payload.build_id || payload.id || (payload.build as any)?.id; | |
458 | + const status = payload.status || payload.state || payload.result; | |
459 | + | |
460 | + if (buildId && status) { | |
461 | + // Find and update build | |
462 | + const build = (await db.query.externalBuilds?.findFirst({ | |
463 | + where: and( | |
464 | + eq(schema.externalBuilds.configId, config.id), | |
465 | + eq(schema.externalBuilds.externalBuildId, String(buildId)), | |
466 | + ), | |
467 | + })) as ExternalBuild | undefined; | |
468 | + | |
469 | + if (build) { | |
470 | + const normalizedStatus = normalizeStatus(config.provider, String(status)); | |
471 | + | |
472 | + // @ts-expect-error - Drizzle multi-db union type issue | |
473 | + await db | |
474 | + .update(schema.externalBuilds) | |
475 | + .set({ | |
476 | + status: normalizedStatus, | |
477 | + completedAt: ["success", "failure", "cancelled"].includes( | |
478 | + normalizedStatus, | |
479 | + ) | |
480 | + ? new Date() | |
481 | + : null, | |
482 | + }) | |
483 | + .where(eq(schema.externalBuilds.id, build.id)); | |
484 | + | |
485 | + // Sync to PR | |
486 | + if (build.pullRequestId && config.syncStatus) { | |
487 | + await syncStatusToPR( | |
488 | + build.pullRequestId, | |
489 | + config.name, | |
490 | + normalizedStatus, | |
491 | + ); | |
492 | + } | |
418 | 493 | } |
494 | + } | |
419 | 495 | |
420 | - return true; | |
496 | + return true; | |
421 | 497 | } |
422 | 498 | |