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
▼drizzle.config.ts
+14-10
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
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
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
-
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
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
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
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
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
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
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
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
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
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
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
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
+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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
260
fields: [pullRequests.stateId],
217
261
references: [prStateDefinitions.id],
218
262
}),
219
- })
263
+ }),
220
264
);
221
265
222
266
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
293
fields: [pullRequestComments.authorId],
250
294
references: [users.id],
251
295
}),
252
- })
296
+ }),
253
297
);
254
298
255
299
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
321
fields: [pullRequestAssignees.userId],
278
322
references: [users.id],
279
323
}),
280
- })
324
+ }),
281
325
);
282
326
283
327
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
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
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
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
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
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
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
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
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
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
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
142
},
126
143
(t) => ({
127
144
pk: primaryKey({ columns: [t.followerId, t.followeeId] }),
128
- })
145
+ }),
129
146
);
130
147
131
148
// Relations
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
188
fields: [emailVerificationTokens.userId],
168
189
references: [users.id],
169
190
}),
170
- })
191
+ }),
171
192
);
172
193
173
194
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
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
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
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
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
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
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
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
251
}),
227
252
jobs: many(workflowJobs),
228
253
artifacts: many(workflowArtifacts),
229
- })
254
+ }),
230
255
);
231
256
232
257
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
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
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
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
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
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
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
526
${diff}
410
527
`;
411
528
412
- return prompt;
529
+ return prompt;
413
530
}
414
531
415
532
/**
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
-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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
+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
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
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
-
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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