18087d6
commit 18087d6
34 files changed
+784 -178
Showing 34 changed files+784-178
scripts/drills/backup-restore-rehearsal.shscripts/drills/postgres-reconnect-drill.shscripts/drills/redis-outage-drill.shscripts/perf/load-baseline.mjsscripts/security/install-hooks.shscripts/security/pre-push-secret-scan.shsrc/db/adapter/types.tssrc/lib/analytics-advanced.tssrc/lib/api.tssrc/lib/artifacts.tssrc/lib/ci-logs.tssrc/lib/env-config.tssrc/lib/env-validation.tssrc/lib/epics.tssrc/lib/external-ci.tssrc/lib/inbox-shortcuts.tssrc/lib/metrics.tssrc/lib/packages.tssrc/lib/pipeline.tssrc/lib/pr-issue-linking.tssrc/lib/pull-requests.tssrc/lib/realtime-client.tssrc/lib/security-advanced.tssrc/pages/[owner]/[repo]/pulls/[number].astrosrc/pages/[owner]/[repo]/pulls/index.astrosrc/pages/[owner]/[repo]/settings/workflow.astrosrc/pages/api/repos/[owner]/[repo]/pr/[id]/stack-graph.tssrc/pages/api/repos/[owner]/[repo]/pulls/bulk-queue-manage.tssrc/pages/api/repos/[owner]/[repo]/queue.tssrc/pages/api/repos/[owner]/[repo]/stacks/submit.tssrc/pages/inbox/index.astrosrc/pages/insights.astrosrc/pages/merge-queue.astro
▼scripts/drills/backup-restore-rehearsal.sh
+30-0
| @@ -0,0 +1,30 @@ | ||
1 | +#!/usr/bin/env bash | |
2 | +set -euo pipefail | |
3 | + | |
4 | +ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" | |
5 | +OUT_DIR="${OUT_DIR:-$ROOT_DIR/test-results/drills}" | |
6 | +mkdir -p "$OUT_DIR" | |
7 | + | |
8 | +echo "[backup-restore-drill] Running backup..." | |
9 | +cd "$ROOT_DIR" | |
10 | +bunx tsx scripts/backup.ts | tee "$OUT_DIR/backup-drill.log" | |
11 | + | |
12 | +LATEST_BACKUP="$(ls -1t backups/backup-*.tar.gz backups/backup-*.tar.gz.enc 2>/dev/null | head -n1 || true)" | |
13 | +if [[ -z "$LATEST_BACKUP" ]]; then | |
14 | + echo "[backup-restore-drill] No backup artifact found after backup run." | |
15 | + exit 1 | |
16 | +fi | |
17 | + | |
18 | +echo "[backup-restore-drill] Verifying backup artifact: $LATEST_BACKUP" | |
19 | +bunx tsx scripts/backup-verify.ts "$LATEST_BACKUP" | tee "$OUT_DIR/backup-verify-drill.log" | |
20 | + | |
21 | +RESTORE_MODE="${RESTORE_MODE:-safe}" | |
22 | +if [[ "$RESTORE_MODE" == "full" ]]; then | |
23 | + echo "[backup-restore-drill] Running FULL restore rehearsal (RESTORE_MODE=full)." | |
24 | + bunx tsx scripts/restore.ts "$LATEST_BACKUP" | tee "$OUT_DIR/restore-drill.log" | |
25 | +else | |
26 | + echo "[backup-restore-drill] Running SAFE restore rehearsal (non-destructive)." | |
27 | + RESTORE_DB=false RESTORE_REPOS=false RESTORE_ENV=false bunx tsx scripts/restore.ts "$LATEST_BACKUP" | tee "$OUT_DIR/restore-drill.log" | |
28 | +fi | |
29 | + | |
30 | +echo "[backup-restore-drill] ✅ Drill complete. Artifacts in $OUT_DIR" | |
0 | 31 | |
▼scripts/drills/postgres-reconnect-drill.sh
+41-0
| @@ -0,0 +1,41 @@ | ||
1 | +#!/usr/bin/env bash | |
2 | +set -euo pipefail | |
3 | + | |
4 | +COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}" | |
5 | +APP_URL="${APP_URL:-http://localhost:4321/api/health}" | |
6 | +OUT_DIR="${OUT_DIR:-./test-results/drills}" | |
7 | +mkdir -p "$OUT_DIR" | |
8 | + | |
9 | +if docker compose version >/dev/null 2>&1; then | |
10 | + DC=(docker compose) | |
11 | +elif docker-compose version >/dev/null 2>&1; then | |
12 | + DC=(docker-compose) | |
13 | +else | |
14 | + echo "[postgres-drill] Docker Compose is required." | |
15 | + exit 1 | |
16 | +fi | |
17 | + | |
18 | +echo "[postgres-drill] Starting services (postgres, redis, app)..." | |
19 | +"${DC[@]}" -f "$COMPOSE_FILE" up -d postgres redis app | |
20 | + | |
21 | +echo "[postgres-drill] Capturing baseline health..." | |
22 | +curl -sS "$APP_URL" | tee "$OUT_DIR/postgres-drill-baseline-health.json" >/dev/null | |
23 | + | |
24 | +echo "[postgres-drill] Stopping postgres to simulate DB outage..." | |
25 | +"${DC[@]}" -f "$COMPOSE_FILE" stop postgres | |
26 | +sleep 8 | |
27 | + | |
28 | +echo "[postgres-drill] Capturing degraded health..." | |
29 | +curl -sS "$APP_URL" | tee "$OUT_DIR/postgres-drill-degraded-health.json" >/dev/null || true | |
30 | + | |
31 | +echo "[postgres-drill] Starting postgres again..." | |
32 | +"${DC[@]}" -f "$COMPOSE_FILE" start postgres | |
33 | +sleep 10 | |
34 | + | |
35 | +echo "[postgres-drill] Capturing recovered health..." | |
36 | +curl -sS "$APP_URL" | tee "$OUT_DIR/postgres-drill-recovered-health.json" >/dev/null | |
37 | + | |
38 | +echo "[postgres-drill] Collecting app logs (last 5m)..." | |
39 | +"${DC[@]}" -f "$COMPOSE_FILE" logs --since 5m app > "$OUT_DIR/postgres-drill-app.log" || true | |
40 | + | |
41 | +echo "[postgres-drill] ✅ Drill complete. Artifacts in $OUT_DIR" | |
0 | 42 | |
▼scripts/drills/redis-outage-drill.sh
+41-0
| @@ -0,0 +1,41 @@ | ||
1 | +#!/usr/bin/env bash | |
2 | +set -euo pipefail | |
3 | + | |
4 | +COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}" | |
5 | +APP_URL="${APP_URL:-http://localhost:4321/api/health}" | |
6 | +OUT_DIR="${OUT_DIR:-./test-results/drills}" | |
7 | +mkdir -p "$OUT_DIR" | |
8 | + | |
9 | +if docker compose version >/dev/null 2>&1; then | |
10 | + DC=(docker compose) | |
11 | +elif docker-compose version >/dev/null 2>&1; then | |
12 | + DC=(docker-compose) | |
13 | +else | |
14 | + echo "[redis-drill] Docker Compose is required." | |
15 | + exit 1 | |
16 | +fi | |
17 | + | |
18 | +echo "[redis-drill] Starting services (postgres, redis, app)..." | |
19 | +"${DC[@]}" -f "$COMPOSE_FILE" up -d postgres redis app | |
20 | + | |
21 | +echo "[redis-drill] Capturing baseline health..." | |
22 | +curl -sS "$APP_URL" | tee "$OUT_DIR/redis-drill-baseline-health.json" >/dev/null | |
23 | + | |
24 | +echo "[redis-drill] Stopping redis to simulate outage..." | |
25 | +"${DC[@]}" -f "$COMPOSE_FILE" stop redis | |
26 | +sleep 8 | |
27 | + | |
28 | +echo "[redis-drill] Capturing degraded health..." | |
29 | +curl -sS "$APP_URL" | tee "$OUT_DIR/redis-drill-degraded-health.json" >/dev/null || true | |
30 | + | |
31 | +echo "[redis-drill] Bringing redis back..." | |
32 | +"${DC[@]}" -f "$COMPOSE_FILE" start redis | |
33 | +sleep 8 | |
34 | + | |
35 | +echo "[redis-drill] Capturing recovered health..." | |
36 | +curl -sS "$APP_URL" | tee "$OUT_DIR/redis-drill-recovered-health.json" >/dev/null | |
37 | + | |
38 | +echo "[redis-drill] Collecting app logs (last 5m)..." | |
39 | +"${DC[@]}" -f "$COMPOSE_FILE" logs --since 5m app > "$OUT_DIR/redis-drill-app.log" || true | |
40 | + | |
41 | +echo "[redis-drill] ✅ Drill complete. Artifacts in $OUT_DIR" | |
0 | 42 | |
▼scripts/perf/load-baseline.mjs
+173-0
| @@ -0,0 +1,173 @@ | ||
1 | +#!/usr/bin/env node | |
2 | +import { writeFileSync } from "node:fs"; | |
3 | +import { performance } from "node:perf_hooks"; | |
4 | + | |
5 | +const baseUrl = (process.env.PERF_BASE_URL || "http://127.0.0.1:3000").replace( | |
6 | + /\/$/, | |
7 | + "", | |
8 | +); | |
9 | +const paths = (process.env.PERF_PATHS || "/api/health,/api/metrics") | |
10 | + .split(",") | |
11 | + .map((path) => path.trim()) | |
12 | + .filter(Boolean); | |
13 | +const concurrency = Math.max( | |
14 | + 1, | |
15 | + Number.parseInt(process.env.PERF_CONCURRENCY || "8", 10) || 8, | |
16 | +); | |
17 | +const requestsPerPath = Math.max( | |
18 | + 1, | |
19 | + Number.parseInt(process.env.PERF_REQUESTS || "100", 10) || 100, | |
20 | +); | |
21 | +const timeoutMs = Math.max( | |
22 | + 1000, | |
23 | + Number.parseInt(process.env.PERF_TIMEOUT_MS || "10000", 10) || 10000, | |
24 | +); | |
25 | +const maxP95Ms = Math.max( | |
26 | + 1, | |
27 | + Number.parseFloat(process.env.PERF_MAX_P95_MS || "500") || 500, | |
28 | +); | |
29 | +const outputPath = process.env.PERF_OUTPUT || ""; | |
30 | + | |
31 | +function percentile(values, pct) { | |
32 | + if (!values.length) return 0; | |
33 | + const sorted = [...values].sort((a, b) => a - b); | |
34 | + const index = Math.min( | |
35 | + sorted.length - 1, | |
36 | + Math.max(0, Math.ceil((pct / 100) * sorted.length) - 1), | |
37 | + ); | |
38 | + return sorted[index]; | |
39 | +} | |
40 | + | |
41 | +function stats(samples) { | |
42 | + const avg = samples.reduce((sum, value) => sum + value, 0) / samples.length; | |
43 | + return { | |
44 | + count: samples.length, | |
45 | + avgMs: Number(avg.toFixed(2)), | |
46 | + p50Ms: Number(percentile(samples, 50).toFixed(2)), | |
47 | + p95Ms: Number(percentile(samples, 95).toFixed(2)), | |
48 | + p99Ms: Number(percentile(samples, 99).toFixed(2)), | |
49 | + minMs: Number(Math.min(...samples).toFixed(2)), | |
50 | + maxMs: Number(Math.max(...samples).toFixed(2)), | |
51 | + }; | |
52 | +} | |
53 | + | |
54 | +async function fetchWithTimeout(url) { | |
55 | + const controller = new AbortController(); | |
56 | + const timer = setTimeout( | |
57 | + () => controller.abort(new Error(`Timed out after ${timeoutMs}ms`)), | |
58 | + timeoutMs, | |
59 | + ); | |
60 | + const started = performance.now(); | |
61 | + | |
62 | + try { | |
63 | + const response = await fetch(url, { | |
64 | + method: "GET", | |
65 | + headers: { | |
66 | + Accept: "application/json, text/plain, */*", | |
67 | + }, | |
68 | + signal: controller.signal, | |
69 | + }); | |
70 | + | |
71 | + await response.arrayBuffer(); | |
72 | + return { | |
73 | + ok: response.ok, | |
74 | + status: response.status, | |
75 | + durationMs: performance.now() - started, | |
76 | + }; | |
77 | + } finally { | |
78 | + clearTimeout(timer); | |
79 | + } | |
80 | +} | |
81 | + | |
82 | +async function benchmarkPath(path) { | |
83 | + const url = `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`; | |
84 | + const durations = []; | |
85 | + const statuses = new Map(); | |
86 | + const failures = []; | |
87 | + let nextIndex = 0; | |
88 | + | |
89 | + async function worker() { | |
90 | + while (nextIndex < requestsPerPath) { | |
91 | + const current = nextIndex++; | |
92 | + try { | |
93 | + const result = await fetchWithTimeout(url); | |
94 | + durations.push(result.durationMs); | |
95 | + statuses.set(result.status, (statuses.get(result.status) || 0) + 1); | |
96 | + if (!result.ok) { | |
97 | + failures.push({ index: current, status: result.status }); | |
98 | + } | |
99 | + } catch (error) { | |
100 | + failures.push({ | |
101 | + index: current, | |
102 | + error: error instanceof Error ? error.message : String(error), | |
103 | + }); | |
104 | + } | |
105 | + } | |
106 | + } | |
107 | + | |
108 | + const workers = Array.from({ length: concurrency }, () => worker()); | |
109 | + await Promise.all(workers); | |
110 | + | |
111 | + const summary = stats(durations); | |
112 | + return { | |
113 | + path, | |
114 | + url, | |
115 | + concurrency, | |
116 | + requests: requestsPerPath, | |
117 | + statuses: Object.fromEntries(statuses.entries()), | |
118 | + failures, | |
119 | + ...summary, | |
120 | + thresholdMs: maxP95Ms, | |
121 | + passed: failures.length === 0 && summary.p95Ms <= maxP95Ms, | |
122 | + }; | |
123 | +} | |
124 | + | |
125 | +async function main() { | |
126 | + console.log(`[perf] Base URL: ${baseUrl}`); | |
127 | + console.log(`[perf] Paths: ${paths.join(", ")}`); | |
128 | + console.log( | |
129 | + `[perf] Concurrency: ${concurrency}, requests/path: ${requestsPerPath}, timeout: ${timeoutMs}ms`, | |
130 | + ); | |
131 | + console.log(`[perf] P95 threshold: ${maxP95Ms}ms`); | |
132 | + | |
133 | + const results = []; | |
134 | + for (const path of paths) { | |
135 | + const result = await benchmarkPath(path); | |
136 | + results.push(result); | |
137 | + const verdict = result.passed ? "PASS" : "FAIL"; | |
138 | + console.log( | |
139 | + `[perf] ${verdict} ${path} p95=${result.p95Ms}ms avg=${result.avgMs}ms max=${result.maxMs}ms statuses=${JSON.stringify(result.statuses)}`, | |
140 | + ); | |
141 | + if (result.failures.length > 0) { | |
142 | + console.log(`[perf] failures: ${result.failures.length}`); | |
143 | + } | |
144 | + } | |
145 | + | |
146 | + const report = { | |
147 | + baseUrl, | |
148 | + concurrency, | |
149 | + requestsPerPath, | |
150 | + timeoutMs, | |
151 | + maxP95Ms, | |
152 | + generatedAt: new Date().toISOString(), | |
153 | + results, | |
154 | + }; | |
155 | + | |
156 | + if (outputPath) { | |
157 | + writeFileSync(outputPath, JSON.stringify(report, null, 2)); | |
158 | + console.log(`[perf] Report written to ${outputPath}`); | |
159 | + } | |
160 | + | |
161 | + const failed = results.filter((result) => !result.passed); | |
162 | + if (failed.length > 0) { | |
163 | + console.error( | |
164 | + `[perf] ${failed.length} path(s) exceeded the threshold or returned errors.`, | |
165 | + ); | |
166 | + process.exitCode = 1; | |
167 | + } | |
168 | +} | |
169 | + | |
170 | +main().catch((error) => { | |
171 | + console.error("[perf] Benchmark failed:", error); | |
172 | + process.exitCode = 1; | |
173 | +}); | |
0 | 174 | |
▼scripts/security/install-hooks.sh
+12-0
| @@ -0,0 +1,12 @@ | ||
1 | +#!/usr/bin/env bash | |
2 | +set -euo pipefail | |
3 | + | |
4 | +ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" | |
5 | +cd "$ROOT_DIR" | |
6 | + | |
7 | +chmod +x .githooks/pre-push scripts/security/pre-push-secret-scan.sh | |
8 | + | |
9 | +git config core.hooksPath .githooks | |
10 | + | |
11 | +echo "[hooks] configured core.hooksPath=.githooks" | |
12 | +echo "[hooks] installed pre-push secret scan guard" | |
0 | 13 | |
▼scripts/security/pre-push-secret-scan.sh
+91-0
| @@ -0,0 +1,91 @@ | ||
1 | +#!/usr/bin/env bash | |
2 | +set -euo pipefail | |
3 | + | |
4 | +ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" | |
5 | +cd "$ROOT_DIR" | |
6 | + | |
7 | +RANGES=("$@") | |
8 | +if [[ ${#RANGES[@]} -eq 0 ]]; then | |
9 | + RANGES=("HEAD~20..HEAD") | |
10 | +fi | |
11 | + | |
12 | +if [[ "${OCH_SECRET_SCAN_BYPASS:-false}" == "true" ]]; then | |
13 | + echo "[secret-scan] bypass enabled via OCH_SECRET_SCAN_BYPASS=true" | |
14 | + exit 0 | |
15 | +fi | |
16 | + | |
17 | +run_gitleaks_for_range() { | |
18 | + local range="$1" | |
19 | + echo "[secret-scan] gitleaks range: $range" | |
20 | + gitleaks detect \ | |
21 | + --source . \ | |
22 | + --config .gitleaks.toml \ | |
23 | + --no-banner \ | |
24 | + --redact \ | |
25 | + --log-opts "$range" \ | |
26 | + --exit-code 1 | |
27 | +} | |
28 | + | |
29 | +run_gitleaks_full_repo() { | |
30 | + echo "[secret-scan] gitleaks full-repo fallback" | |
31 | + gitleaks detect \ | |
32 | + --source . \ | |
33 | + --config .gitleaks.toml \ | |
34 | + --no-banner \ | |
35 | + --redact \ | |
36 | + --exit-code 1 | |
37 | +} | |
38 | + | |
39 | +run_heuristic_scan() { | |
40 | + local range="$1" | |
41 | + local diff_out | |
42 | + if ! diff_out="$(git diff "$range" 2>/dev/null || true)"; then | |
43 | + diff_out="" | |
44 | + fi | |
45 | + | |
46 | + if [[ -z "$diff_out" ]]; then | |
47 | + return 0 | |
48 | + fi | |
49 | + | |
50 | + # Lightweight high-risk pattern scan for environments where gitleaks is unavailable. | |
51 | + if echo "$diff_out" | grep -E -i \ | |
52 | + '(AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|-----BEGIN (RSA|EC|OPENSSH|PRIVATE) KEY-----|GOCSPX-[A-Za-z0-9_-]{10,}|AIza[0-9A-Za-z\-_]{20,}|glpat-[A-Za-z0-9\-_]{20,}|postgres://[^[:space:]]+:[^[:space:]]+@|mongodb(\+srv)?://[^[:space:]]+:[^[:space:]]+@|redis://[^[:space:]]+:[^[:space:]]+@)' >/dev/null; then | |
53 | + echo "[secret-scan] possible secret detected in range: $range" | |
54 | + echo "[secret-scan] install gitleaks for precise scanning: https://github.com/gitleaks/gitleaks" | |
55 | + return 1 | |
56 | + fi | |
57 | + | |
58 | + return 0 | |
59 | +} | |
60 | + | |
61 | +main() { | |
62 | + local has_gitleaks=false | |
63 | + if command -v gitleaks >/dev/null 2>&1; then | |
64 | + has_gitleaks=true | |
65 | + fi | |
66 | + | |
67 | + if [[ "$has_gitleaks" == true ]]; then | |
68 | + for range in "${RANGES[@]}"; do | |
69 | + if ! run_gitleaks_for_range "$range"; then | |
70 | + echo "[secret-scan] blocked push because secret findings were detected." | |
71 | + echo "[secret-scan] if this is a false positive, use an allowlist rule and re-run scan." | |
72 | + exit 1 | |
73 | + fi | |
74 | + done | |
75 | + | |
76 | + echo "[secret-scan] passed (gitleaks)" | |
77 | + exit 0 | |
78 | + fi | |
79 | + | |
80 | + echo "[secret-scan] gitleaks not found, running heuristic fallback scan" | |
81 | + for range in "${RANGES[@]}"; do | |
82 | + if ! run_heuristic_scan "$range"; then | |
83 | + echo "[secret-scan] blocked push due to high-risk secret pattern(s)." | |
84 | + exit 1 | |
85 | + fi | |
86 | + done | |
87 | + | |
88 | + echo "[secret-scan] passed (heuristic fallback)" | |
89 | +} | |
90 | + | |
91 | +main | |
0 | 92 | |
▼src/db/adapter/types.ts
+10-0
| @@ -8,6 +8,16 @@ export type DatabaseDriver = | ||
8 | 8 | | "postgres" |
9 | 9 | | "turso" |
10 | 10 | | "cockroachdb" |
11 | + | "mysql" | |
12 | + | "mariadb" | |
13 | + | "mongodb" | |
14 | + | "redis" | |
15 | + | "firestore" | |
16 | + | "dynamodb" | |
17 | + | "neo4j" | |
18 | + | "cassandra" | |
19 | + | "scylladb" | |
20 | + | "surrealdb" | |
11 | 21 | | "custom"; |
12 | 22 | |
13 | 23 | export interface DatabaseConfig { |
14 | 24 | |
▼src/lib/analytics-advanced.ts
+3-4
| @@ -9,6 +9,7 @@ import { users } from "@/db/schema/users"; | ||
9 | 9 | import { compareBranches } from "@/lib/git"; |
10 | 10 | import { resolveRepoPath } from "@/lib/git-storage"; |
11 | 11 | import { and, asc, desc, eq, gte, inArray, lte } from "drizzle-orm"; |
12 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
12 | 13 | import { |
13 | 14 | boolean, |
14 | 15 | integer, |
| @@ -127,8 +128,7 @@ export async function calculateFileHotspots( | ||
127 | 128 | > = new Map(); |
128 | 129 | |
129 | 130 | // Replace previous snapshot for this repository |
130 | - // @ts-expect-error - Drizzle multi-db union type issue | |
131 | - await db | |
131 | + await (db as NodePgDatabase<typeof schema>) | |
132 | 132 | .delete(schema.fileHotspots) |
133 | 133 | .where(eq(schema.fileHotspots.repositoryId, repositoryId)); |
134 | 134 | |
| @@ -191,8 +191,7 @@ export async function calculateFileHotspots( | ||
191 | 191 | calculatedAt: new Date(), |
192 | 192 | }; |
193 | 193 | |
194 | - // @ts-expect-error - Drizzle multi-db union type issue | |
195 | - await db.insert(schema.fileHotspots).values(hotspot); | |
194 | + await (db as NodePgDatabase<typeof schema>).insert(schema.fileHotspots).values(hotspot); | |
196 | 195 | hotspots.push(hotspot as FileHotspot); |
197 | 196 | } |
198 | 197 | |
199 | 198 | |
▼src/lib/api.ts
+1-1
| @@ -211,7 +211,7 @@ export const withErrorHandler = (handler: APIRoute): APIRoute => { | ||
211 | 211 | try { |
212 | 212 | return await handler(context); |
213 | 213 | } catch (err) { |
214 | - logger.error(err); | |
214 | + logger.error(err instanceof Error ? err.message : String(err)); | |
215 | 215 | return serverError(err instanceof Error ? err.message : "Unknown error"); |
216 | 216 | } |
217 | 217 | }; |
218 | 218 | |
▼src/lib/artifacts.ts
+3-4
| @@ -5,6 +5,7 @@ | ||
5 | 5 | |
6 | 6 | import { getDatabase, schema } from "@/db"; |
7 | 7 | import { and, eq } from "drizzle-orm"; |
8 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
8 | 9 | import { createReadStream, createWriteStream } from "fs"; |
9 | 10 | import fs from "fs/promises"; |
10 | 11 | import path from "path"; |
| @@ -207,8 +208,7 @@ export async function downloadArtifact( | ||
207 | 208 | } |
208 | 209 | |
209 | 210 | // Increment download count |
210 | - // @ts-expect-error - Drizzle multi-db union type issue | |
211 | - await db | |
211 | + await (db as NodePgDatabase<typeof schema>) | |
212 | 212 | .update(schema.workflowArtifacts) |
213 | 213 | .set({ downloadCount: (record.downloadCount ?? 0) + 1 }) |
214 | 214 | .where(eq(schema.workflowArtifacts.id, record.id)); |
| @@ -289,8 +289,7 @@ export async function deleteArtifact(artifactId: string): Promise<void> { | ||
289 | 289 | } |
290 | 290 | |
291 | 291 | // Remove DB record |
292 | - // @ts-expect-error - Drizzle multi-db union type issue | |
293 | - await db | |
292 | + await (db as NodePgDatabase<typeof schema>) | |
294 | 293 | .delete(schema.workflowArtifacts) |
295 | 294 | .where(eq(schema.workflowArtifacts.id, artifactId)); |
296 | 295 | |
297 | 296 | |
▼src/lib/ci-logs.ts
+2-2
| @@ -5,6 +5,7 @@ | ||
5 | 5 | |
6 | 6 | import { getDatabase, schema } from "@/db"; |
7 | 7 | import { eq } from "drizzle-orm"; |
8 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
8 | 9 | import fs from "fs/promises"; |
9 | 10 | import path from "path"; |
10 | 11 | import { logger } from "./logger"; |
| @@ -92,8 +93,7 @@ export class LogPersister { | ||
92 | 93 | async persistStep(step: StepRecord): Promise<void> { |
93 | 94 | try { |
94 | 95 | const db = getDatabase(); |
95 | - // @ts-expect-error - Drizzle multi-db union type issue | |
96 | - await db | |
96 | + await (db as NodePgDatabase<typeof schema>) | |
97 | 97 | .insert(schema.workflowSteps) |
98 | 98 | .values({ |
99 | 99 | id: step.id, |
100 | 100 | |
▼src/lib/env-config.ts
+83-0
| @@ -0,0 +1,83 @@ | ||
1 | +import { getDatabase, schema } from "@/db"; | |
2 | +import { eq } from "drizzle-orm"; | |
3 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
4 | + | |
5 | +// Short-lived cache so we don't query the DB on every call, | |
6 | +// but updates in the dashboard propagate quickly (TTL 30s). | |
7 | +let configCache: Record<string, string> | null = null; | |
8 | +let lastCacheTime = 0; | |
9 | +const CACHE_TTL = 30000; | |
10 | + | |
11 | +export async function fetchSystemConfig(): Promise<Record<string, string>> { | |
12 | + const now = Date.now(); | |
13 | + if (configCache && now - lastCacheTime < CACHE_TTL) { | |
14 | + return configCache; | |
15 | + } | |
16 | + | |
17 | + try { | |
18 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
19 | + const configs = await db.query.systemConfig.findMany({ | |
20 | + where: eq(schema.systemConfig.key, "general_config"), | |
21 | + }); | |
22 | + | |
23 | + if (configs.length > 0 && configs[0].value) { | |
24 | + try { | |
25 | + configCache = JSON.parse(configs[0].value); | |
26 | + lastCacheTime = now; | |
27 | + return configCache as Record<string, string>; | |
28 | + } catch { | |
29 | + configCache = null; | |
30 | + lastCacheTime = now; | |
31 | + } | |
32 | + } | |
33 | + | |
34 | + configCache = {}; | |
35 | + lastCacheTime = now; | |
36 | + return configCache; | |
37 | + } catch { | |
38 | + configCache = null; | |
39 | + return {}; | |
40 | + } | |
41 | +} | |
42 | + | |
43 | +/** | |
44 | + * Supercharges standard process.env to check the Database Admin Settings first. | |
45 | + * If the environment setting (e.g. OPENAI_API_KEY) is declared in the Admin UI, | |
46 | + * it safely overrides the .env file. | |
47 | + */ | |
48 | +export async function getEnv( | |
49 | + key: | |
50 | + | "OPENAI_API_KEY" | |
51 | + | "ANTHROPIC_API_KEY" | |
52 | + | "GROQ_API_KEY" | |
53 | + | "SMTP_HOST" | |
54 | + | "SMTP_PORT" | |
55 | + | "SMTP_USER" | |
56 | + | "SMTP_PASSWORD" | |
57 | + | "SMTP_FROM" | |
58 | + | string, | |
59 | +): Promise<string | undefined> { | |
60 | + const dbConfig = await fetchSystemConfig(); | |
61 | + | |
62 | + // Mapping standard env keys to the JSON shape we use in settings.astro | |
63 | + const dbMappedKeys: Record<string, string> = { | |
64 | + OPENAI_API_KEY: "openaiKey", | |
65 | + ANTHROPIC_API_KEY: "anthropicKey", | |
66 | + GROQ_API_KEY: "groqKey", | |
67 | + SMTP_HOST: "smtpHost", | |
68 | + SMTP_PORT: "smtpPort", | |
69 | + SMTP_USER: "smtpUser", | |
70 | + SMTP_PASSWORD: "smtpPass", | |
71 | + SMTP_FROM: "smtpFrom", | |
72 | + }; | |
73 | + | |
74 | + const dashboardKey = dbMappedKeys[key]; | |
75 | + | |
76 | + // 1. Fallback to App Settings Override | |
77 | + if (dashboardKey && dbConfig[dashboardKey]) { | |
78 | + return dbConfig[dashboardKey] as string; | |
79 | + } | |
80 | + | |
81 | + // 2. Primary .env Standard | |
82 | + return process.env[key]; | |
83 | +} | |
0 | 84 | |
▼src/lib/env-validation.ts
+2-0
| @@ -3,6 +3,8 @@ | ||
3 | 3 | * Run this at application startup to ensure all required configuration is present |
4 | 4 | */ |
5 | 5 | |
6 | +import { logger } from "@/lib/logger"; | |
7 | + | |
6 | 8 | interface EnvConfig { |
7 | 9 | name: string; |
8 | 10 | required: boolean; |
9 | 11 | |
▼src/lib/epics.ts
+5-8
| @@ -5,6 +5,7 @@ | ||
5 | 5 | |
6 | 6 | import { getDatabase, schema } from "@/db"; |
7 | 7 | import { and, eq, inArray } from "drizzle-orm"; |
8 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
8 | 9 | import { logger } from "./logger"; |
9 | 10 | |
10 | 11 | export interface EpicInfo { |
| @@ -202,8 +203,7 @@ export async function addToEpic( | ||
202 | 203 | |
203 | 204 | if (!epic) return false; |
204 | 205 | |
205 | - // @ts-expect-error - Drizzle multi-db union type issue | |
206 | - await db | |
206 | + await (db as NodePgDatabase<typeof schema>) | |
207 | 207 | .update(schema.issues) |
208 | 208 | .set({ parentId: epicId, updatedAt: new Date() }) |
209 | 209 | .where(eq(schema.issues.id, issueId)); |
| @@ -217,8 +217,7 @@ export async function addToEpic( | ||
217 | 217 | export async function removeFromEpic(issueId: string): Promise<boolean> { |
218 | 218 | const db = getDatabase(); |
219 | 219 | |
220 | - // @ts-expect-error - Drizzle multi-db union type issue | |
221 | - await db | |
220 | + await (db as NodePgDatabase<typeof schema>) | |
222 | 221 | .update(schema.issues) |
223 | 222 | .set({ parentId: null, updatedAt: new Date() }) |
224 | 223 | .where(eq(schema.issues.id, issueId)); |
| @@ -257,8 +256,7 @@ export async function getIssueHierarchy( | ||
257 | 256 | export async function convertToEpic(issueId: string): Promise<boolean> { |
258 | 257 | const db = getDatabase(); |
259 | 258 | |
260 | - // @ts-expect-error - Drizzle multi-db union type issue | |
261 | - await db | |
259 | + await (db as NodePgDatabase<typeof schema>) | |
262 | 260 | .update(schema.issues) |
263 | 261 | .set({ type: "epic", parentId: null, updatedAt: new Date() }) |
264 | 262 | .where(eq(schema.issues.id, issueId)); |
| @@ -275,8 +273,7 @@ export async function convertToTask( | ||
275 | 273 | ): Promise<boolean> { |
276 | 274 | const db = getDatabase(); |
277 | 275 | |
278 | - // @ts-expect-error - Drizzle multi-db union type issue | |
279 | - await db | |
276 | + await (db as NodePgDatabase<typeof schema>) | |
280 | 277 | .update(schema.issues) |
281 | 278 | .set({ |
282 | 279 | type: "task", |
283 | 280 | |
▼src/lib/external-ci.ts
+5-10
| @@ -6,6 +6,7 @@ | ||
6 | 6 | import { getDatabase, schema } from "@/db"; |
7 | 7 | import { externalBuilds, externalCIConfigs } from "@/db/schema/integrations"; |
8 | 8 | import { and, eq } from "drizzle-orm"; |
9 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
9 | 10 | import { assertOnlineFeature } from "./air-gapped"; |
10 | 11 | import { logger } from "./logger"; |
11 | 12 | |
| @@ -283,8 +284,7 @@ export async function syncBuildStatus( | ||
283 | 284 | const completedAt = |
284 | 285 | status !== "pending" && status !== "running" ? new Date() : null; |
285 | 286 | |
286 | - // @ts-expect-error - Drizzle multi-db union type issue | |
287 | - await db | |
287 | + await (db as NodePgDatabase<typeof schema>) | |
288 | 288 | .update(schema.externalBuilds) |
289 | 289 | .set({ |
290 | 290 | status, |
| @@ -405,19 +405,16 @@ async function syncStatusToPR( | ||
405 | 405 | }); |
406 | 406 | |
407 | 407 | if (existing) { |
408 | - // @ts-expect-error - Drizzle multi-db union type issue | |
409 | - await db | |
408 | + await (db as NodePgDatabase<typeof schema>) | |
410 | 409 | .update(schema.pullRequestChecks) |
411 | 410 | .set({ |
412 | 411 | headSha: pr.headSha, |
413 | 412 | status: status === "running" ? "in_progress" : "completed", |
414 | 413 | conclusion, |
415 | - updatedAt: new Date(), | |
416 | 414 | }) |
417 | 415 | .where(eq(schema.pullRequestChecks.id, existing.id)); |
418 | 416 | } else { |
419 | - // @ts-expect-error - Drizzle multi-db union type issue | |
420 | - await db.insert(schema.pullRequestChecks).values({ | |
417 | + await (db as NodePgDatabase<typeof schema>).insert(schema.pullRequestChecks).values({ | |
421 | 418 | id: crypto.randomUUID(), |
422 | 419 | pullRequestId: prId, |
423 | 420 | name: checkName, |
| @@ -425,7 +422,6 @@ async function syncStatusToPR( | ||
425 | 422 | status: status === "running" ? "in_progress" : "completed", |
426 | 423 | conclusion, |
427 | 424 | createdAt: new Date(), |
428 | - updatedAt: new Date(), | |
429 | 425 | }); |
430 | 426 | } |
431 | 427 | } |
| @@ -469,8 +465,7 @@ export async function handleExternalCIWebhook( | ||
469 | 465 | if (build) { |
470 | 466 | const normalizedStatus = normalizeStatus(config.provider, String(status)); |
471 | 467 | |
472 | - // @ts-expect-error - Drizzle multi-db union type issue | |
473 | - await db | |
468 | + await (db as NodePgDatabase<typeof schema>) | |
474 | 469 | .update(schema.externalBuilds) |
475 | 470 | .set({ |
476 | 471 | status: normalizedStatus, |
477 | 472 | |
▼src/lib/inbox-shortcuts.ts
+2-0
| @@ -3,6 +3,8 @@ | ||
3 | 3 | * Graphite-style keyboard navigation for PR inbox |
4 | 4 | */ |
5 | 5 | |
6 | +import { logger } from "@/lib/logger"; | |
7 | + | |
6 | 8 | // Keyboard shortcut handler |
7 | 9 | export function initInboxKeyboardShortcuts() { |
8 | 10 | let selectedIndex = 0; |
9 | 11 | |
▼src/lib/metrics.ts
+1-1
| @@ -180,7 +180,7 @@ const allMetrics = [ | ||
180 | 180 | ]; |
181 | 181 | |
182 | 182 | for (const metric of allMetrics) { |
183 | - register.registerMetric(metric); | |
183 | + register.registerMetric(metric as typeof httpRequestsTotal); | |
184 | 184 | } |
185 | 185 | |
186 | 186 | export { register }; |
187 | 187 | |
▼src/lib/packages.ts
+7-11
| @@ -6,6 +6,7 @@ | ||
6 | 6 | import { getDatabase, schema } from "@/db"; |
7 | 7 | import type { Package, PackageVersion } from "@/db/schema/packages"; |
8 | 8 | import { and, desc, eq, ilike, sql } from "drizzle-orm"; |
9 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
9 | 10 | import { logger } from "./logger"; |
10 | 11 | |
11 | 12 | // ============================================================================ |
| @@ -109,7 +110,7 @@ export async function listPackages(options: { | ||
109 | 110 | })) || []; |
110 | 111 | |
111 | 112 | // Count total |
112 | - const countResult = await db | |
113 | + const countResult = await (db as NodePgDatabase<typeof schema>) | |
113 | 114 | .select({ count: sql<number>`count(*)` }) |
114 | 115 | .from(schema.packages) |
115 | 116 | .where(and(...conditions)); |
| @@ -158,12 +159,10 @@ export async function publishVersion(options: { | ||
158 | 159 | createdAt: new Date(), |
159 | 160 | }; |
160 | 161 | |
161 | - // @ts-expect-error - Drizzle multi-db union type issue | |
162 | - await db.insert(schema.packageVersions).values(ver); | |
162 | + await (db as NodePgDatabase<typeof schema>).insert(schema.packageVersions).values(ver); | |
163 | 163 | |
164 | 164 | // Update package timestamp & handle "latest" tag demotion |
165 | - // @ts-expect-error - Drizzle multi-db union type issue | |
166 | - await db | |
165 | + await (db as NodePgDatabase<typeof schema>) | |
167 | 166 | .update(schema.packages) |
168 | 167 | .set({ updatedAt: new Date() }) |
169 | 168 | .where(eq(schema.packages.id, options.packageId)); |
| @@ -206,8 +205,7 @@ export async function yankVersion( | ||
206 | 205 | version: string, |
207 | 206 | ): Promise<void> { |
208 | 207 | const db = getDatabase(); |
209 | - // @ts-expect-error - Drizzle multi-db union type issue | |
210 | - await db | |
208 | + await (db as NodePgDatabase<typeof schema>) | |
211 | 209 | .update(schema.packageVersions) |
212 | 210 | .set({ yanked: true }) |
213 | 211 | .where( |
| @@ -224,13 +222,11 @@ export async function recordDownload( | ||
224 | 222 | versionId: string, |
225 | 223 | ): Promise<void> { |
226 | 224 | const db = getDatabase(); |
227 | - // @ts-expect-error - Drizzle multi-db union type issue | |
228 | - await db | |
225 | + await (db as NodePgDatabase<typeof schema>) | |
229 | 226 | .update(schema.packageVersions) |
230 | 227 | .set({ downloadCount: sql`${schema.packageVersions.downloadCount} + 1` }) |
231 | 228 | .where(eq(schema.packageVersions.id, versionId)); |
232 | - // @ts-expect-error - Drizzle multi-db union type issue | |
233 | - await db | |
229 | + await (db as NodePgDatabase<typeof schema>) | |
234 | 230 | .update(schema.packages) |
235 | 231 | .set({ downloadCount: sql`${schema.packages.downloadCount} + 1` }) |
236 | 232 | .where(eq(schema.packages.id, packageId)); |
237 | 233 | |
▼src/lib/pipeline.ts
+1-0
| @@ -11,6 +11,7 @@ import fs from "fs/promises"; | ||
11 | 11 | import path from "path"; |
12 | 12 | import { parse as parseYaml } from "yaml"; |
13 | 13 | import { generateId } from "./utils"; |
14 | +import { logger } from "@/lib/logger"; | |
14 | 15 | |
15 | 16 | // Types |
16 | 17 | export interface WorkflowConfig { |
17 | 18 | |
▼src/lib/pr-issue-linking.ts
+4-6
| @@ -7,6 +7,7 @@ import { getDatabase, schema } from "@/db"; | ||
7 | 7 | import { issues } from "@/db/schema/issues"; |
8 | 8 | import { pullRequests } from "@/db/schema/pull-requests"; |
9 | 9 | import { and, eq, inArray, or } from "drizzle-orm"; |
10 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
10 | 11 | import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; |
11 | 12 | import { logger } from "./logger"; |
12 | 13 | |
| @@ -119,8 +120,7 @@ export async function linkPRToIssue(options: { | ||
119 | 120 | |
120 | 121 | if (existing) { |
121 | 122 | // Update link type |
122 | - // @ts-expect-error - Drizzle multi-db union type issue | |
123 | - await db | |
123 | + await (db as NodePgDatabase<typeof schema>) | |
124 | 124 | .update(schema.prIssueLinks) |
125 | 125 | .set({ linkType: options.linkType }) |
126 | 126 | .where(eq(schema.prIssueLinks.id, existing.id)); |
| @@ -286,8 +286,7 @@ export async function closeLinkedIssuesOnMerge( | ||
286 | 286 | })) || []; |
287 | 287 | |
288 | 288 | for (const link of links) { |
289 | - // @ts-expect-error - Drizzle multi-db union type issue | |
290 | - await db | |
289 | + await (db as NodePgDatabase<typeof schema>) | |
291 | 290 | .update(schema.issues) |
292 | 291 | .set({ |
293 | 292 | state: "closed", |
| @@ -320,8 +319,7 @@ export async function unlinkPRFromIssue(linkId: string): Promise<boolean> { | ||
320 | 319 | const db = getDatabase(); |
321 | 320 | |
322 | 321 | try { |
323 | - // @ts-expect-error - Drizzle multi-db union type issue | |
324 | - await db | |
322 | + await (db as NodePgDatabase<typeof schema>) | |
325 | 323 | .delete(schema.prIssueLinks) |
326 | 324 | .where(eq(schema.prIssueLinks.id, linkId)); |
327 | 325 | return true; |
328 | 326 | |
▼src/lib/pull-requests.ts
+78-0
| @@ -0,0 +1,78 @@ | ||
1 | +import { getDatabase, schema } from "@/db"; | |
2 | +import { eq } from "drizzle-orm"; | |
3 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
4 | +import { evaluateGates } from "./ci-gates"; | |
5 | +import { mergeBranch } from "./git"; | |
6 | +import { resolveRepoPath } from "./git-storage"; | |
7 | +import { closeLinkedIssuesOnMerge } from "./pr-issue-linking"; | |
8 | + | |
9 | +export async function mergePullRequest( | |
10 | + pullRequestId: string, | |
11 | + mergedById: string, | |
12 | + mergeMethod: "merge" | "squash" | "rebase" = "merge", | |
13 | +) { | |
14 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
15 | + | |
16 | + const pr = await db.query.pullRequests.findFirst({ | |
17 | + where: eq(schema.pullRequests.id, pullRequestId), | |
18 | + }); | |
19 | + | |
20 | + if (!pr) { | |
21 | + throw new Error("Pull request not found"); | |
22 | + } | |
23 | + | |
24 | + if (pr.state !== "open") { | |
25 | + throw new Error("Pull request is not open"); | |
26 | + } | |
27 | + | |
28 | + const gateResult = await evaluateGates(pr.id); | |
29 | + if (!gateResult.canMerge) { | |
30 | + const failed = gateResult.results | |
31 | + .filter((result) => !result.passed) | |
32 | + .map((result) => result.message) | |
33 | + .join("; "); | |
34 | + throw new Error(`Merge blocked: ${failed}`); | |
35 | + } | |
36 | + | |
37 | + const repo = await db.query.repositories.findFirst({ | |
38 | + where: eq(schema.repositories.id, pr.repositoryId), | |
39 | + }); | |
40 | + | |
41 | + if (!repo) { | |
42 | + throw new Error("Repository not found"); | |
43 | + } | |
44 | + | |
45 | + const repoPath = await resolveRepoPath(repo.diskPath); | |
46 | + const commitTitle = `Merge pull request #${pr.number} from ${pr.headBranch}`; | |
47 | + const mergeResult = await mergeBranch( | |
48 | + repoPath, | |
49 | + pr.baseBranch, | |
50 | + pr.headBranch, | |
51 | + commitTitle, | |
52 | + ); | |
53 | + | |
54 | + if (!mergeResult.success) { | |
55 | + throw new Error(mergeResult.message || "Merge failed"); | |
56 | + } | |
57 | + | |
58 | + const now = new Date(); | |
59 | + await db | |
60 | + .update(schema.pullRequests) | |
61 | + .set({ | |
62 | + state: "merged", | |
63 | + isMerged: true, | |
64 | + mergedAt: now, | |
65 | + mergedById, | |
66 | + mergeCommitSha: mergeResult.sha || null, | |
67 | + mergeSha: mergeResult.sha || null, | |
68 | + mergeMethod, | |
69 | + updatedAt: now, | |
70 | + }) | |
71 | + .where(eq(schema.pullRequests.id, pullRequestId)); | |
72 | + | |
73 | + await closeLinkedIssuesOnMerge(pr.id, mergedById); | |
74 | + | |
75 | + return db.query.pullRequests.findFirst({ | |
76 | + where: eq(schema.pullRequests.id, pullRequestId), | |
77 | + }); | |
78 | +} | |
0 | 79 | |
▼src/lib/realtime-client.ts
+2-0
| @@ -1,3 +1,5 @@ | ||
1 | +import { logger } from "@/lib/logger"; | |
2 | + | |
1 | 3 | export class RealtimeClient { |
2 | 4 | private eventSource: EventSource | null = null; |
3 | 5 | private listeners: Map<string, Function[]> = new Map(); |
4 | 6 | |
▼src/lib/security-advanced.ts
+5-8
| @@ -12,6 +12,7 @@ import { | ||
12 | 12 | secretScanResults, |
13 | 13 | } from "@/db/schema/integrations"; |
14 | 14 | import { and, eq, gt, gte, lte } from "drizzle-orm"; |
15 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
15 | 16 | import { logger } from "./logger"; |
16 | 17 | |
17 | 18 | // Re-export types from schema for consumers |
| @@ -168,8 +169,7 @@ export async function resolveSecretAlert( | ||
168 | 169 | const db = getDatabase(); |
169 | 170 | |
170 | 171 | try { |
171 | - // @ts-expect-error - Drizzle multi-db union type issue | |
172 | - await db | |
172 | + await (db as NodePgDatabase<typeof schema>) | |
173 | 173 | .update(schema.secretScanResults) |
174 | 174 | .set({ |
175 | 175 | status: resolution, |
| @@ -366,8 +366,7 @@ export async function checkRateLimit(options: { | ||
366 | 366 | ); |
367 | 367 | |
368 | 368 | // Increment counter |
369 | - // @ts-expect-error - Drizzle multi-db union type issue | |
370 | - await db | |
369 | + await (db as NodePgDatabase<typeof schema>) | |
371 | 370 | .update(schema.rateLimitLogs) |
372 | 371 | .set({ |
373 | 372 | requestCount: existing.requestCount + 1, |
| @@ -378,8 +377,7 @@ export async function checkRateLimit(options: { | ||
378 | 377 | return { allowed, remaining: remaining - 1, resetAt }; |
379 | 378 | } else { |
380 | 379 | // Create new window |
381 | - // @ts-expect-error - Drizzle multi-db union type issue | |
382 | - await db.insert(schema.rateLimitLogs).values({ | |
380 | + await (db as NodePgDatabase<typeof schema>).insert(schema.rateLimitLogs).values({ | |
383 | 381 | id: crypto.randomUUID(), |
384 | 382 | ruleId: matchingRule.id, |
385 | 383 | key: options.key, |
| @@ -672,8 +670,7 @@ export async function syncSAMLGroupsToTeams( | ||
672 | 670 | syncedTeams.push(team.name); |
673 | 671 | } else if (!isGroupMember && existingMembership) { |
674 | 672 | // Remove from team (SAML is authoritative source) |
675 | - // @ts-expect-error - Drizzle multi-db union type issue | |
676 | - await db | |
673 | + await (db as NodePgDatabase<typeof schema>) | |
677 | 674 | .delete(schema.teamMembers) |
678 | 675 | .where( |
679 | 676 | and( |
680 | 677 | |
▼src/pages/[owner]/[repo]/pulls/[number].astro
+3-2
| @@ -91,7 +91,7 @@ const queueItem = await db.query.mergeQueueItems.findFirst({ | ||
91 | 91 | }); |
92 | 92 | |
93 | 93 | // Fetch Stack Info |
94 | -let stackInfo = null; | |
94 | +let stackInfo: { baseBranch: string; stackId: string; entries: { order: number; pr: any }[] } | null = null; | |
95 | 95 | try { |
96 | 96 | const stackEntry = await db.query.prStackEntries.findFirst({ |
97 | 97 | where: eq(schema.prStackEntries.pullRequestId, pr.id), |
| @@ -111,6 +111,7 @@ try { | ||
111 | 111 | |
112 | 112 | stackInfo = { |
113 | 113 | baseBranch: stackEntry.stack.baseBranch, |
114 | + stackId: stackEntry.stackId, | |
114 | 115 | entries: entries.map((e) => ({ |
115 | 116 | order: e.stackOrder, |
116 | 117 | pr: e.pullRequest, |
| @@ -792,7 +793,7 @@ try { | ||
792 | 793 | isMerged: entry.pr.isMerged, |
793 | 794 | order: entry.order, |
794 | 795 | })) : [], |
795 | - stackId: stackInfo ? stackInfo.stack.id : null, | |
796 | + stackId: stackInfo ? stackInfo.stackId : null, | |
796 | 797 | currentBuiltinState: pr.state, |
797 | 798 | currentCustomStateId: pr.stateId || null, |
798 | 799 | }} |
799 | 800 | |
▼src/pages/[owner]/[repo]/pulls/index.astro
+1-1
| @@ -93,7 +93,7 @@ const formattedPRs = prsList.map((pr) => ({ | ||
93 | 93 | number: pr.number, |
94 | 94 | title: pr.title, |
95 | 95 | state: pr.state as "open" | "closed" | "merged", |
96 | - createdAt: pr.createdAt, | |
96 | + createdAt: pr.createdAt.toISOString(), | |
97 | 97 | commentCount: pr.commentCount || 0, |
98 | 98 | sourceBranch: pr.headBranch || "", |
99 | 99 | targetBranch: pr.baseBranch || "", |
100 | 100 | |
▼src/pages/[owner]/[repo]/settings/workflow.astro
+1-1
| @@ -109,7 +109,7 @@ const repo = { | ||
109 | 109 | repo={repo.name} |
110 | 110 | /> |
111 | 111 | |
112 | - <div className="my-8"></div> | |
112 | + <div class="my-8"></div> | |
113 | 113 | |
114 | 114 | <IssueWorkflowSettings |
115 | 115 | client:load |
116 | 116 | |
▼src/pages/api/repos/[owner]/[repo]/pr/[id]/stack-graph.ts
+12-8
| @@ -1,24 +1,28 @@ | ||
1 | 1 | import type { APIRoute } from "astro"; |
2 | -import { db } from "../../../../../../../db/index"; | |
3 | -import { stackedPrs } from "../../../../../../../db/schema/stacked-prs"; | |
2 | +import { getDatabase, schema } from "@/db"; | |
4 | 3 | import { eq } from "drizzle-orm"; |
5 | 4 | |
6 | 5 | export const GET: APIRoute = async ({ params }) => { |
7 | - const prId = Number(params.id); | |
6 | + const prId = params.id; | |
8 | 7 | |
9 | - if (isNaN(prId)) { | |
8 | + if (!prId) { | |
10 | 9 | return new Response("Invalid PR ID", { status: 400 }); |
11 | 10 | } |
12 | 11 | |
12 | + const db = getDatabase(); | |
13 | 13 | try { |
14 | - const selfPr = await db.select().from(stackedPrs).where(eq(stackedPrs.prId, prId)).limit(1); | |
14 | + const selfPr = await db.query.prStackEntries.findFirst({ | |
15 | + where: eq(schema.prStackEntries.pullRequestId, prId), | |
16 | + }); | |
15 | 17 | |
16 | - if (selfPr.length === 0) { | |
18 | + if (!selfPr) { | |
17 | 19 | return new Response(JSON.stringify({ isStacked: false }), { status: 200, headers: { "Content-Type": "application/json" } }); |
18 | 20 | } |
19 | 21 | |
20 | - const stackId = selfPr[0].stackId; | |
21 | - const fullStack = await db.select().from(stackedPrs).where(eq(stackedPrs.stackId, stackId)); | |
22 | + const stackId = selfPr.stackId; | |
23 | + const fullStack = await db.query.prStackEntries.findMany({ | |
24 | + where: eq(schema.prStackEntries.stackId, stackId), | |
25 | + }); | |
22 | 26 | |
23 | 27 | return new Response(JSON.stringify({ isStacked: true, stackId, items: fullStack }), { |
24 | 28 | status: 200, |
25 | 29 | |
▼src/pages/api/repos/[owner]/[repo]/pulls/bulk-queue-manage.ts
+2-2
| @@ -7,7 +7,7 @@ import { | ||
7 | 7 | unauthorized, |
8 | 8 | } from "@/lib/api"; |
9 | 9 | import { withErrorHandler } from "@/lib/errors"; |
10 | -import { removeFromMergeQueue, reprioritizeQueue } from "@/lib/merge-queue"; | |
10 | +import { removeFromMergeQueue, updateQueuePriority } from "@/lib/merge-queue"; | |
11 | 11 | import { canWriteRepo } from "@/lib/permissions"; |
12 | 12 | import type { APIRoute } from "astro"; |
13 | 13 | import { and, eq, inArray } from "drizzle-orm"; |
| @@ -112,7 +112,7 @@ export const POST: APIRoute = withErrorHandler( | ||
112 | 112 | if (parsed.data.action === "remove") { |
113 | 113 | await removeFromMergeQueue(pr.id); |
114 | 114 | } else { |
115 | - await reprioritizeQueue(entry.id, parsed.data.priority!); | |
115 | + await updateQueuePriority(entry.id, parsed.data.priority!); | |
116 | 116 | } |
117 | 117 | |
118 | 118 | updated.push({ |
119 | 119 | |
▼src/pages/api/repos/[owner]/[repo]/queue.ts
+2-2
| @@ -29,7 +29,7 @@ import { logger } from "@/lib/logger"; | ||
29 | 29 | import { |
30 | 30 | processNextInQueue, |
31 | 31 | removeFromMergeQueue, |
32 | - reprioritizeQueue, | |
32 | + updateQueuePriority, | |
33 | 33 | } from "@/lib/merge-queue"; |
34 | 34 | import { canWriteRepo } from "@/lib/permissions"; |
35 | 35 | |
| @@ -281,7 +281,7 @@ export const PATCH: APIRoute = withErrorHandler(async ({ params, request }) => { | ||
281 | 281 | return badRequest("Priority is required"); |
282 | 282 | } |
283 | 283 | |
284 | - await reprioritizeQueue(entry.id, priority); | |
284 | + await updateQueuePriority(entry.id, priority); | |
285 | 285 | logger.info( |
286 | 286 | { userId: actor.id, repoId: repository.id, entryId, priority }, |
287 | 287 | "Reprioritized merge queue entry", |
288 | 288 | |
▼src/pages/api/repos/[owner]/[repo]/stacks/submit.ts
+153-0
| @@ -0,0 +1,153 @@ | ||
1 | +import type { APIRoute } from "astro"; | |
2 | +import { getDatabase, schema } from "@/db"; | |
3 | +import { badRequest, notFound, success, unauthorized } from "@/lib/api"; | |
4 | +import { getUserFromRequest } from "@/lib/auth"; | |
5 | +import { createStack } from "@/lib/stacks"; | |
6 | +import { generateId } from "@/lib/utils"; | |
7 | +import { and, desc, eq } from "drizzle-orm"; | |
8 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
9 | +import { z } from "zod"; | |
10 | + | |
11 | +const SubmitStackSchema = z.object({ | |
12 | + branches: z.array( | |
13 | + z.object({ | |
14 | + name: z.string(), | |
15 | + parent: z.string(), | |
16 | + commitHash: z.string(), | |
17 | + title: z.string().optional(), | |
18 | + description: z.string().optional(), | |
19 | + isDraft: z.boolean().default(false), | |
20 | + }), | |
21 | + ), | |
22 | +}); | |
23 | + | |
24 | +export const POST: APIRoute = async ({ params, request }) => { | |
25 | + const { owner, repo } = params; | |
26 | + if (!owner || !repo) return badRequest("Missing repo info"); | |
27 | + | |
28 | + try { | |
29 | + const actor = await getUserFromRequest(request); | |
30 | + if (!actor) return unauthorized(); | |
31 | + | |
32 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
33 | + | |
34 | + const ownerUser = await db.query.users.findFirst({ | |
35 | + where: eq(schema.users.username, owner), | |
36 | + columns: { id: true }, | |
37 | + }); | |
38 | + | |
39 | + if (!ownerUser) return notFound("Repository owner not found"); | |
40 | + | |
41 | + const repository = await db.query.repositories.findFirst({ | |
42 | + where: and( | |
43 | + eq(schema.repositories.ownerId, ownerUser.id), | |
44 | + eq(schema.repositories.name, repo), | |
45 | + ), | |
46 | + }); | |
47 | + | |
48 | + if (!repository) return notFound("Repository not found"); | |
49 | + | |
50 | + const body = await request.json(); | |
51 | + const parsed = SubmitStackSchema.parse(body); | |
52 | + | |
53 | + if (parsed.branches.length === 0) { | |
54 | + return badRequest("At least one branch is required"); | |
55 | + } | |
56 | + | |
57 | + const dbPr = db; | |
58 | + | |
59 | + const latestPr = await dbPr | |
60 | + .select({ number: schema.pullRequests.number }) | |
61 | + .from(schema.pullRequests) | |
62 | + .where(eq(schema.pullRequests.repositoryId, repository.id)) | |
63 | + .orderBy(desc(schema.pullRequests.number)) | |
64 | + .limit(1); | |
65 | + | |
66 | + let nextNumber = (latestPr[0]?.number ?? 0) + 1; | |
67 | + const stack = await createStack({ | |
68 | + repositoryId: repository.id, | |
69 | + baseBranch: parsed.branches[0]?.parent || repository.defaultBranch, | |
70 | + name: `Submitted stack (${parsed.branches.length} PRs)`, | |
71 | + createdById: actor.userId, | |
72 | + }); | |
73 | + | |
74 | + const createdPrs: Array<{ prId: string; branch: string }> = []; | |
75 | + | |
76 | + await db.transaction(async (tx) => { | |
77 | + for (let i = 0; i < parsed.branches.length; i++) { | |
78 | + const branchData = parsed.branches[i]; | |
79 | + | |
80 | + const existingPr = await tx.query.pullRequests.findFirst({ | |
81 | + where: and( | |
82 | + eq(schema.pullRequests.repositoryId, repository.id), | |
83 | + eq(schema.pullRequests.headBranch, branchData.name), | |
84 | + ), | |
85 | + }); | |
86 | + | |
87 | + let prId = existingPr?.id; | |
88 | + | |
89 | + if (existingPr) { | |
90 | + await tx | |
91 | + .update(schema.pullRequests) | |
92 | + .set({ | |
93 | + baseBranch: branchData.parent, | |
94 | + headSha: branchData.commitHash, | |
95 | + baseSha: branchData.commitHash, | |
96 | + title: branchData.title || existingPr.title, | |
97 | + body: branchData.description || existingPr.body, | |
98 | + isDraft: branchData.isDraft, | |
99 | + updatedAt: new Date(), | |
100 | + }) | |
101 | + .where(eq(schema.pullRequests.id, existingPr.id)); | |
102 | + } else { | |
103 | + const inserted = await tx | |
104 | + .insert(schema.pullRequests) | |
105 | + .values({ | |
106 | + id: generateId(), | |
107 | + repositoryId: repository.id, | |
108 | + number: nextNumber++, | |
109 | + title: branchData.title || `Update ${branchData.name}`, | |
110 | + body: branchData.description || "", | |
111 | + state: "open", | |
112 | + authorId: actor.userId, | |
113 | + headBranch: branchData.name, | |
114 | + headSha: branchData.commitHash, | |
115 | + baseBranch: branchData.parent, | |
116 | + baseSha: branchData.commitHash, | |
117 | + isDraft: branchData.isDraft, | |
118 | + createdAt: new Date(), | |
119 | + updatedAt: new Date(), | |
120 | + }) | |
121 | + .returning({ id: schema.pullRequests.id }); | |
122 | + | |
123 | + prId = inserted[0]?.id; | |
124 | + } | |
125 | + | |
126 | + if (!prId) { | |
127 | + throw new Error( | |
128 | + `Failed to resolve pull request for branch ${branchData.name}`, | |
129 | + ); | |
130 | + } | |
131 | + | |
132 | + await tx | |
133 | + .delete(schema.prStackEntries) | |
134 | + .where(eq(schema.prStackEntries.pullRequestId, prId)); | |
135 | + | |
136 | + await tx.insert(schema.prStackEntries).values({ | |
137 | + id: generateId(), | |
138 | + stackId: stack.id, | |
139 | + pullRequestId: prId, | |
140 | + stackOrder: i + 1, | |
141 | + parentPrId: i > 0 ? (createdPrs[i - 1]?.prId ?? null) : null, | |
142 | + createdAt: new Date(), | |
143 | + }); | |
144 | + | |
145 | + createdPrs.push({ prId, branch: branchData.name }); | |
146 | + } | |
147 | + }); | |
148 | + | |
149 | + return success({ success: true, stackId: stack.id, createdPrs }); | |
150 | + } catch (error: any) { | |
151 | + return badRequest(error?.message || "Failed to submit stack"); | |
152 | + } | |
153 | +}; | |
0 | 154 | |
▼
+0-99
| @@ -1,99 +0,0 @@ | ||
1 | -import type { APIRoute } from "astro"; | |
2 | -import { db } from "../../../../../../../db/index"; | |
3 | -import { pullRequests } from "../../../../../../../db/schema/pull-requests"; | |
4 | -import { stackedPrs } from "../../../../../../../db/schema/stacked-prs"; | |
5 | -import { repositories } from "../../../../../../../db/schema/repositories"; | |
6 | -import { eq, and } from "drizzle-orm"; | |
7 | -import { z } from "zod"; | |
8 | - | |
9 | -const SubmitStackSchema = z.object({ | |
10 | - branches: z.array(z.object({ | |
11 | - name: z.string(), | |
12 | - parent: z.string(), | |
13 | - commitHash: z.string(), | |
14 | - title: z.string().optional(), | |
15 | - description: z.string().optional(), | |
16 | - isDraft: z.boolean().default(false) | |
17 | - })) | |
18 | -}); | |
19 | - | |
20 | -export const POST: APIRoute = async ({ params, request }) => { | |
21 | - const { owner, repo } = params; | |
22 | - if (!owner || !repo) return new Response("Missing repo info", { status: 400 }); | |
23 | - | |
24 | - try { | |
25 | - const body = await request.json(); | |
26 | - const parsed = SubmitStackSchema.parse(body); | |
27 | - | |
28 | - const repository = await db.query.repositories.findFirst({ | |
29 | - where: and(eq(repositories.owner, owner), eq(repositories.name, repo)) | |
30 | - }); | |
31 | - | |
32 | - if (!repository) return new Response("Repository not found", { status: 404 }); | |
33 | - | |
34 | - // Perform the entire stack creation transactionally | |
35 | - const result = await db.transaction(async (tx) => { | |
36 | - let createdPrs = []; | |
37 | - let stackIdentifier = \`stack_$\{Date.now()\}\`; // Unique ID for this push session | |
38 | - | |
39 | - for (let i = 0; i < parsed.branches.length; i++) { | |
40 | - const branchData = parsed.branches[i]; | |
41 | - | |
42 | - // 1. Create or Find the Pull Request | |
43 | - let prId; | |
44 | - const existingPr = await tx.select().from(pullRequests).where( | |
45 | - and(eq(pullRequests.repositoryId, repository.id), eq(pullRequests.headBranch, branchData.name)) | |
46 | - ).limit(1); | |
47 | - | |
48 | - if (existingPr.length > 0) { | |
49 | - prId = existingPr[0].id; | |
50 | - // Update PR if needed | |
51 | - await tx.update(pullRequests).set({ | |
52 | - baseBranch: branchData.parent, | |
53 | - title: branchData.title || existingPr[0].title, | |
54 | - body: branchData.description || existingPr[0].body | |
55 | - }).where(eq(pullRequests.id, prId)); | |
56 | - } else { | |
57 | - // Insert new PR | |
58 | - const newPr = await tx.insert(pullRequests).values({ | |
59 | - repositoryId: repository.id, | |
60 | - number: Math.floor(Math.random() * 10000), // Should use standard auto-increment or sequence | |
61 | - headBranch: branchData.name, | |
62 | - baseBranch: branchData.parent, | |
63 | - title: branchData.title || \`Update \$\{branchData.name\}\`, | |
64 | - body: branchData.description || "", | |
65 | - status: "OPEN", | |
66 | - isDraft: branchData.isDraft, | |
67 | - authorId: 1 // Stubbed to admin | |
68 | - }).returning(); | |
69 | - prId = newPr[0].id; | |
70 | - } | |
71 | - | |
72 | - createdPrs.push({ prId, branch: branchData.name }); | |
73 | - | |
74 | - // 2. Manage Stack Tracking Relation | |
75 | - // We delete any old stack relation for this PR and re-insert | |
76 | - // to handle re-ordering or moving branches dynamically. | |
77 | - await tx.delete(stackedPrs).where(eq(stackedPrs.prId, prId)); | |
78 | - | |
79 | - await tx.insert(stackedPrs).values({ | |
80 | - prId: prId, | |
81 | - stackId: stackIdentifier, | |
82 | - parentBranch: branchData.parent, | |
83 | - childBranch: branchData.name, | |
84 | - position: i + 1 | |
85 | - }); | |
86 | - } | |
87 | - | |
88 | - return createdPrs; | |
89 | - }); | |
90 | - | |
91 | - return new Response(JSON.stringify({ success: true, createdPrs: result }), { | |
92 | - status: 200, | |
93 | - headers: { "Content-Type": "application/json" } | |
94 | - }); | |
95 | - | |
96 | - } catch (error: any) { | |
97 | - return new Response(JSON.stringify({ error: error.message }), { status: 400 }); | |
98 | - } | |
99 | -}; | |
100 | 0 | |
▼src/pages/inbox/index.astro
+3-3
| @@ -1,5 +1,5 @@ | ||
1 | 1 | --- |
2 | -import Layout from "../../layouts/Layout.astro"; | |
2 | +import BaseLayout from "../../layouts/BaseLayout.astro"; | |
3 | 3 | import { KeyboardReviewInbox } from "../../components/inbox/KeyboardReviewInbox"; |
4 | 4 | |
5 | 5 | // Typically we'd fetch the first PR needing review from the DB here |
| @@ -13,9 +13,9 @@ const mockInitialPr = { | ||
13 | 13 | }; |
14 | 14 | --- |
15 | 15 | |
16 | -<Layout title="Inbox - OpenCodeHub"> | |
16 | +<BaseLayout title="Inbox - OpenCodeHub"> | |
17 | 17 | <main class="h-screen w-full bg-slate-950 overflow-hidden pt-16"> |
18 | 18 | <!-- React Island loaded instantly for client interaction --> |
19 | 19 | <KeyboardReviewInbox client:only="react" initialPr={mockInitialPr} /> |
20 | 20 | </main> |
21 | -</Layout> | |
21 | +</BaseLayout> | |
22 | 22 | |
▼src/pages/insights.astro
+3-3
| @@ -60,7 +60,7 @@ try { | ||
60 | 60 | const myPrs = await db.query.pullRequests.findMany({ |
61 | 61 | where: and( |
62 | 62 | eq(schema.pullRequests.authorId, user.id), |
63 | - gte(schema.pullRequests.createdAt, startDate.toISOString()) | |
63 | + gte(schema.pullRequests.createdAt, startDate) | |
64 | 64 | ), |
65 | 65 | }); |
66 | 66 | |
| @@ -82,7 +82,7 @@ try { | ||
82 | 82 | const myReviews = await db.query.pullRequestReviews.findMany({ |
83 | 83 | where: and( |
84 | 84 | eq(schema.pullRequestReviews.reviewerId, user.id), |
85 | - gte(schema.pullRequestReviews.createdAt, startDate.toISOString()) | |
85 | + gte(schema.pullRequestReviews.createdAt, startDate) | |
86 | 86 | ), |
87 | 87 | }); |
88 | 88 | |
| @@ -124,7 +124,7 @@ try { | ||
124 | 124 | // Top contributors (if viewing team) |
125 | 125 | if (view === "team") { |
126 | 126 | const allPrs = await db.query.pullRequests.findMany({ |
127 | - where: gte(schema.pullRequests.createdAt, startDate.toISOString()), | |
127 | + where: gte(schema.pullRequests.createdAt, startDate), | |
128 | 128 | with: { author: true }, |
129 | 129 | limit: 100, |
130 | 130 | }); |
131 | 131 | |
▼src/pages/merge-queue.astro
+2-2
| @@ -138,9 +138,9 @@ try { | ||
138 | 138 | id: item.id, |
139 | 139 | position: currentPosition, |
140 | 140 | status: item.status, |
141 | - priority: item.priority, | |
141 | + priority: item.priority ?? 0, | |
142 | 142 | mergeMethod: item.mergeMethod || "merge", |
143 | - addedAt: item.addedAt, | |
143 | + addedAt: item.addedAt?.toISOString() ?? new Date().toISOString(), | |
144 | 144 | pr: item.pullRequest |
145 | 145 | ? { |
146 | 146 | number: item.pullRequest.number, |
147 | 147 | |