Skip to content

Commit 0352d56

Browse files
haasonsaasclaude
andcommitted
Add PostgreSQL storage backend with StorageBackend trait abstraction
Introduces a pluggable storage layer that supports both JSON file (existing behavior) and PostgreSQL backends, selected at runtime via DATABASE_URL env var. Includes SQL schema migration, server-side event analytics endpoint (/api/events/stats), created_at timestamps on ReviewEvent, and auto-migration of existing JSON data to PG on first connection. - StorageBackend trait with JSON and PG implementations - SQL migration: reviews, comments, review_events, convention_patterns - New /api/events/stats endpoint with time-range filtering - Frontend types, client, and hooks updated for server-side queries - Review completion persists to storage backend - get_review falls back to PG for historical reviews Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9e51d1f commit 0352d56

12 files changed

Lines changed: 2039 additions & 64 deletions

File tree

Cargo.lock

Lines changed: 600 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ tokio = { version = "1.35", features = ["macros", "rt-multi-thread", "fs", "time
1515
serde = { version = "1.0", features = ["derive"] }
1616
serde_json = "1.0"
1717
serde_yaml = "0.9"
18+
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "json", "migrate"] }
1819
anyhow = "1.0"
1920
thiserror = "1.0"
2021
tracing = "0.1"
@@ -26,7 +27,7 @@ git2 = { version = "0.19", default-features = false }
2627
once_cell = "1.19"
2728
regex = "1.10"
2829
dirs = "5.0"
29-
chrono = "0.4"
30+
chrono = { version = "0.4", features = ["serde"] }
3031
glob = "0.3"
3132
ignore = "0.4"
3233
shell-words = "1.1"

migrations/001_initial_schema.sql

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
-- DiffScope PostgreSQL schema
2+
-- Reviews, comments, events, and conventions
3+
4+
CREATE TABLE IF NOT EXISTS reviews (
5+
id TEXT PRIMARY KEY,
6+
status TEXT NOT NULL CHECK (status IN ('Pending','Running','Complete','Failed')),
7+
diff_source TEXT NOT NULL,
8+
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
9+
completed_at TIMESTAMPTZ,
10+
files_reviewed INTEGER NOT NULL DEFAULT 0,
11+
error TEXT,
12+
pr_summary_text TEXT,
13+
summary_json JSONB,
14+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
15+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
16+
);
17+
18+
CREATE INDEX IF NOT EXISTS idx_reviews_status ON reviews(status);
19+
CREATE INDEX IF NOT EXISTS idx_reviews_started_at ON reviews(started_at DESC);
20+
CREATE INDEX IF NOT EXISTS idx_reviews_created_at ON reviews(created_at DESC);
21+
22+
CREATE TABLE IF NOT EXISTS comments (
23+
id TEXT PRIMARY KEY,
24+
review_id TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
25+
file_path TEXT NOT NULL,
26+
line_number INTEGER NOT NULL,
27+
content TEXT NOT NULL,
28+
rule_id TEXT,
29+
severity TEXT NOT NULL,
30+
category TEXT NOT NULL,
31+
suggestion TEXT,
32+
confidence REAL NOT NULL,
33+
code_suggestion JSONB,
34+
tags TEXT[] NOT NULL DEFAULT '{}',
35+
fix_effort TEXT NOT NULL DEFAULT 'Low',
36+
feedback TEXT,
37+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
38+
);
39+
40+
CREATE INDEX IF NOT EXISTS idx_comments_review_id ON comments(review_id);
41+
CREATE INDEX IF NOT EXISTS idx_comments_severity ON comments(severity);
42+
CREATE INDEX IF NOT EXISTS idx_comments_category ON comments(category);
43+
44+
CREATE TABLE IF NOT EXISTS review_events (
45+
review_id TEXT PRIMARY KEY REFERENCES reviews(id) ON DELETE CASCADE,
46+
event_type TEXT NOT NULL,
47+
diff_source TEXT NOT NULL,
48+
title TEXT,
49+
model TEXT NOT NULL,
50+
provider TEXT,
51+
base_url TEXT,
52+
duration_ms BIGINT NOT NULL DEFAULT 0,
53+
diff_fetch_ms BIGINT,
54+
llm_total_ms BIGINT,
55+
diff_bytes INTEGER NOT NULL DEFAULT 0,
56+
diff_files_total INTEGER NOT NULL DEFAULT 0,
57+
diff_files_reviewed INTEGER NOT NULL DEFAULT 0,
58+
diff_files_skipped INTEGER NOT NULL DEFAULT 0,
59+
comments_total INTEGER NOT NULL DEFAULT 0,
60+
comments_by_severity JSONB NOT NULL DEFAULT '{}',
61+
comments_by_category JSONB NOT NULL DEFAULT '{}',
62+
overall_score REAL,
63+
hotspots_detected INTEGER NOT NULL DEFAULT 0,
64+
high_risk_files INTEGER NOT NULL DEFAULT 0,
65+
tokens_prompt INTEGER,
66+
tokens_completion INTEGER,
67+
tokens_total INTEGER,
68+
file_metrics JSONB,
69+
hotspot_details JSONB,
70+
convention_suppressed INTEGER,
71+
comments_by_pass JSONB NOT NULL DEFAULT '{}',
72+
github_posted BOOLEAN NOT NULL DEFAULT FALSE,
73+
github_repo TEXT,
74+
github_pr INTEGER,
75+
error TEXT,
76+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
77+
);
78+
79+
CREATE INDEX IF NOT EXISTS idx_events_model ON review_events(model);
80+
CREATE INDEX IF NOT EXISTS idx_events_event_type ON review_events(event_type);
81+
CREATE INDEX IF NOT EXISTS idx_events_created_at ON review_events(created_at DESC);
82+
CREATE INDEX IF NOT EXISTS idx_events_diff_source ON review_events(diff_source);
83+
CREATE INDEX IF NOT EXISTS idx_events_github_repo ON review_events(github_repo) WHERE github_repo IS NOT NULL;
84+
85+
CREATE TABLE IF NOT EXISTS convention_patterns (
86+
id SERIAL PRIMARY KEY,
87+
pattern_text TEXT NOT NULL,
88+
category TEXT NOT NULL,
89+
accepted_count INTEGER NOT NULL DEFAULT 0,
90+
rejected_count INTEGER NOT NULL DEFAULT 0,
91+
file_patterns TEXT[] NOT NULL DEFAULT '{}',
92+
first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
93+
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
94+
UNIQUE(pattern_text, category)
95+
);

src/server/api.rs

Lines changed: 93 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -78,57 +78,60 @@ pub struct ListEventsParams {
7878
pub source: Option<String>,
7979
pub model: Option<String>,
8080
pub status: Option<String>,
81+
pub time_from: Option<String>,
82+
pub time_to: Option<String>,
83+
pub github_repo: Option<String>,
84+
pub limit: Option<i64>,
85+
pub offset: Option<i64>,
8186
}
8287

8388
use super::state::ReviewEvent;
89+
use super::storage::{EventFilters, EventStats};
90+
91+
impl ListEventsParams {
92+
fn into_filters(self) -> EventFilters {
93+
EventFilters {
94+
source: self.source,
95+
model: self.model,
96+
status: self.status,
97+
time_from: self.time_from.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok().map(|t| t.with_timezone(&chrono::Utc))),
98+
time_to: self.time_to.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok().map(|t| t.with_timezone(&chrono::Utc))),
99+
github_repo: self.github_repo,
100+
limit: self.limit,
101+
offset: self.offset,
102+
}
103+
}
104+
}
84105

85-
/// Returns all wide events from completed/failed reviews, sorted newest-first.
106+
/// Returns all wide events, filtered and sorted newest-first.
107+
/// Uses the storage backend (PostgreSQL or JSON) for querying.
86108
pub async fn list_events(
87109
State(state): State<Arc<AppState>>,
88110
Query(params): Query<ListEventsParams>,
89111
) -> Json<Vec<ReviewEvent>> {
90-
let reviews = state.reviews.read().await;
91-
let mut events: Vec<ReviewEvent> = reviews
92-
.values()
93-
.filter_map(|s| s.event.clone())
94-
.filter(|e| {
95-
let source_ok = params
96-
.source
97-
.as_ref()
98-
.map_or(true, |f| e.diff_source.eq_ignore_ascii_case(f));
99-
let model_ok = params
100-
.model
101-
.as_ref()
102-
.map_or(true, |f| e.model.eq_ignore_ascii_case(f));
103-
let status_ok = params.status.as_ref().map_or(true, |f| {
104-
e.event_type.eq_ignore_ascii_case(&format!("review.{}", f))
105-
});
106-
source_ok && model_ok && status_ok
107-
})
108-
.collect();
109-
events.sort_by(|a, b| b.review_id.cmp(&a.review_id));
110-
events.sort_by(|a, b| {
111-
b.duration_ms
112-
.cmp(&a.duration_ms)
113-
.then(b.review_id.cmp(&a.review_id))
114-
});
115-
// Sort by review start time proxy: we use completed reviews ordering
116-
// Re-sort by the review order (newest first) using the review map ordering
117-
let id_order: std::collections::HashMap<String, usize> = {
118-
let mut ordered: Vec<_> = reviews
119-
.values()
120-
.filter(|s| s.event.is_some())
121-
.map(|s| (s.id.clone(), s.started_at))
122-
.collect();
123-
ordered.sort_by(|a, b| b.1.cmp(&a.1));
124-
ordered
125-
.into_iter()
126-
.enumerate()
127-
.map(|(i, (id, _))| (id, i))
128-
.collect()
129-
};
130-
events.sort_by_key(|e| id_order.get(&e.review_id).copied().unwrap_or(usize::MAX));
131-
Json(events)
112+
let filters = params.into_filters();
113+
match state.storage.list_events(&filters).await {
114+
Ok(events) => Json(events),
115+
Err(e) => {
116+
warn!("Failed to list events from storage: {}", e);
117+
Json(Vec::new())
118+
}
119+
}
120+
}
121+
122+
/// Returns aggregated event statistics (server-side analytics).
123+
pub async fn get_event_stats(
124+
State(state): State<Arc<AppState>>,
125+
Query(params): Query<ListEventsParams>,
126+
) -> Json<EventStats> {
127+
let filters = params.into_filters();
128+
match state.storage.get_event_stats(&filters).await {
129+
Ok(stats) => Json(stats),
130+
Err(e) => {
131+
warn!("Failed to get event stats from storage: {}", e);
132+
Json(EventStats::default())
133+
}
134+
}
132135
}
133136

134137
// === Handlers ===
@@ -494,6 +497,21 @@ async fn run_review_task(
494497

495498
AppState::save_reviews_async(&state);
496499
AppState::prune_old_reviews(&state).await;
500+
501+
// Persist to storage backend (PostgreSQL or JSON)
502+
{
503+
let reviews = state.reviews.read().await;
504+
if let Some(session) = reviews.get(&review_id) {
505+
if let Err(e) = state.storage.save_review(session).await {
506+
warn!(review_id = %review_id, "Failed to persist review to storage: {}", e);
507+
}
508+
if let Some(ref event) = session.event {
509+
if let Err(e) = state.storage.save_event(event).await {
510+
warn!(review_id = %review_id, "Failed to persist event to storage: {}", e);
511+
}
512+
}
513+
}
514+
}
497515
}
498516

499517
fn get_diff_from_git(
@@ -533,12 +551,18 @@ pub async fn get_review(
533551
State(state): State<Arc<AppState>>,
534552
Path(id): Path<String>,
535553
) -> Result<Json<ReviewSession>, StatusCode> {
536-
let reviews = state.reviews.read().await;
537-
reviews
538-
.get(&id)
539-
.cloned()
540-
.map(Json)
541-
.ok_or(StatusCode::NOT_FOUND)
554+
// Check in-memory first (active reviews with progress tracking)
555+
{
556+
let reviews = state.reviews.read().await;
557+
if let Some(session) = reviews.get(&id) {
558+
return Ok(Json(session.clone()));
559+
}
560+
}
561+
// Fall back to storage backend (historical reviews in PostgreSQL)
562+
match state.storage.get_review(&id).await {
563+
Ok(Some(session)) => Ok(Json(session)),
564+
_ => Err(StatusCode::NOT_FOUND),
565+
}
542566
}
543567

544568
pub async fn list_reviews(
@@ -573,6 +597,8 @@ pub async fn submit_feedback(
573597
return Err(StatusCode::BAD_REQUEST);
574598
}
575599

600+
let comment_id_for_storage = request.comment_id.clone();
601+
576602
let mut reviews = state.reviews.write().await;
577603
let session = reviews.get_mut(&id).ok_or(StatusCode::NOT_FOUND)?;
578604

@@ -598,6 +624,9 @@ pub async fn submit_feedback(
598624

599625
AppState::save_reviews_async(&state);
600626

627+
// Persist feedback to storage backend
628+
let _ = state.storage.update_comment_feedback(&id, &comment_id_for_storage, if is_accepted { "accept" } else { "reject" }).await;
629+
601630
// Record in convention store for learned patterns
602631
let config = state.config.read().await;
603632
let convention_path = config
@@ -1854,6 +1883,21 @@ async fn run_pr_review_task(
18541883

18551884
AppState::save_reviews_async(&state);
18561885
AppState::prune_old_reviews(&state).await;
1886+
1887+
// Persist to storage backend (PostgreSQL or JSON)
1888+
{
1889+
let reviews = state.reviews.read().await;
1890+
if let Some(session) = reviews.get(&review_id) {
1891+
if let Err(e) = state.storage.save_review(session).await {
1892+
warn!(review_id = %review_id, "Failed to persist PR review to storage: {}", e);
1893+
}
1894+
if let Some(ref event) = session.event {
1895+
if let Err(e) = state.storage.save_event(event).await {
1896+
warn!(review_id = %review_id, "Failed to persist PR event to storage: {}", e);
1897+
}
1898+
}
1899+
}
1900+
}
18571901
}
18581902

18591903
/// Generate an AI-powered PR summary and store it in the review session.

src/server/mod.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
pub mod api;
22
pub mod github;
33
pub mod state;
4+
pub mod storage;
5+
pub mod storage_json;
6+
pub mod storage_pg;
47

58
pub mod metrics;
69

@@ -62,7 +65,7 @@ async fn serve_embedded(uri: axum::http::Uri) -> Response {
6265
}
6366

6467
pub async fn start_server(config: Config, host: &str, port: u16) -> anyhow::Result<()> {
65-
let state = Arc::new(state::AppState::new(config)?);
68+
let state = Arc::new(state::AppState::new(config).await?);
6669

6770
let origin_strings = [
6871
format!("http://localhost:{}", port),
@@ -94,6 +97,7 @@ pub async fn start_server(config: Config, host: &str, port: u16) -> anyhow::Resu
9497
.route("/review", post(api::start_review))
9598
.route("/reviews", get(api::list_reviews))
9699
.route("/events", get(api::list_events))
100+
.route("/events/stats", get(api::get_event_stats))
97101
.route("/review/{id}", get(api::get_review))
98102
.route("/review/{id}/feedback", post(api::submit_feedback))
99103
.route("/doctor", get(api::get_doctor))

0 commit comments

Comments
 (0)