Skip to main content

difflore_core/reviews/
queries.rs

1use uuid::Uuid;
2
3use super::rows::{ReviewCommentRow, ReviewItemRow, stored_review_comment_line_number};
4use super::types::{
5    AddCommentInput, EnsureItemInput, ListWithCommentsInput, ReviewCommentIdInput,
6    ReviewCommentRecord, ReviewItemIdInput, ReviewItemRecord, ReviewItemWithComments,
7    ReviewProjectInput, ReviewSourceInput, UpdateItemStatusInput,
8};
9
10// ── helper: fetch comments for a set of item IDs via json_each IN ────────────
11
12pub(super) async fn fetch_comments_for_items(
13    pool: &sqlx::SqlitePool,
14    items: &[ReviewItemRecord],
15) -> crate::Result<std::collections::HashMap<String, Vec<ReviewCommentRecord>>> {
16    if items.is_empty() {
17        return Ok(std::collections::HashMap::new());
18    }
19
20    let ids: Vec<String> = items.iter().map(|i| i.id.clone()).collect();
21    let ids_json = serde_json::to_string(&ids).map_err(|e| {
22        crate::errors::CoreError::Internal(format!("failed to encode review item ids: {e}"))
23    })?;
24    let comments: Vec<ReviewCommentRecord> = sqlx::query_as!(
25        ReviewCommentRow,
26        "SELECT id, review_item_id, external_comment_id, line_number, content, author, comment_url, \
27         thread_id, metadata, created_at FROM review_comments \
28         WHERE review_item_id IN (SELECT value FROM json_each(?1)) \
29         ORDER BY created_at ASC",
30        ids_json
31    )
32    .fetch_all(pool)
33    .await?
34    .into_iter()
35    .map(ReviewCommentRecord::from)
36    .collect();
37
38    let mut by_item: std::collections::HashMap<String, Vec<ReviewCommentRecord>> =
39        std::collections::HashMap::new();
40    for c in comments {
41        by_item.entry(c.review_item_id.clone()).or_default().push(c);
42    }
43    Ok(by_item)
44}
45
46pub(super) fn attach_comments(
47    items: Vec<ReviewItemRecord>,
48    mut by_item: std::collections::HashMap<String, Vec<ReviewCommentRecord>>,
49) -> Vec<ReviewItemWithComments> {
50    items
51        .into_iter()
52        .map(|item| {
53            let comments = by_item.remove(&item.id).unwrap_or_default();
54            ReviewItemWithComments { item, comments }
55        })
56        .collect()
57}
58
59// ── public functions ─────────────────────────────────────────────────────────
60
61pub async fn list_by_project(
62    db: &sqlx::SqlitePool,
63    input: ReviewProjectInput,
64) -> crate::Result<Vec<ReviewItemRecord>> {
65    let rows = sqlx::query_as!(
66        ReviewItemRow,
67        "SELECT id, session_id, project_id, file_path, diff_content, status, source, source_kind, \
68         external_review_id, repo_full_name, pr_number, author, synced_at, metadata, created_at, reviewed_at \
69         FROM review_items WHERE project_id = ? ORDER BY created_at DESC",
70        input.project_id
71    )
72    .fetch_all(db)
73    .await?;
74    Ok(rows.into_iter().map(ReviewItemRecord::from).collect())
75}
76
77/// List the most recent review items across all sources. Used by the
78/// TUI Reviews tab where the user wants a cross-source activity feed,
79/// not a per-source filter.
80pub async fn list_recent(
81    db: &sqlx::SqlitePool,
82    limit: i64,
83) -> crate::Result<Vec<ReviewItemRecord>> {
84    let rows = sqlx::query_as!(
85        ReviewItemRow,
86        "SELECT id, session_id, project_id, file_path, diff_content, status, source, source_kind, \
87         external_review_id, repo_full_name, pr_number, author, synced_at, metadata, created_at, reviewed_at \
88         FROM review_items ORDER BY created_at DESC LIMIT ?",
89        limit
90    )
91    .fetch_all(db)
92    .await?;
93    Ok(rows.into_iter().map(ReviewItemRecord::from).collect())
94}
95
96pub async fn list_by_source(
97    db: &sqlx::SqlitePool,
98    input: ReviewSourceInput,
99) -> crate::Result<Vec<ReviewItemRecord>> {
100    let rows = sqlx::query_as!(
101        ReviewItemRow,
102        "SELECT id, session_id, project_id, file_path, diff_content, status, source, source_kind, \
103         external_review_id, repo_full_name, pr_number, author, synced_at, metadata, created_at, reviewed_at \
104         FROM review_items WHERE source = ? ORDER BY created_at DESC",
105        input.source
106    )
107    .fetch_all(db)
108    .await?;
109    Ok(rows.into_iter().map(ReviewItemRecord::from).collect())
110}
111
112pub async fn list_by_source_with_comments(
113    db: &sqlx::SqlitePool,
114    input: ReviewSourceInput,
115) -> crate::Result<Vec<ReviewItemWithComments>> {
116    let items: Vec<ReviewItemRecord> = sqlx::query_as!(
117        ReviewItemRow,
118        "SELECT id, session_id, project_id, file_path, diff_content, status, source, source_kind, \
119         external_review_id, repo_full_name, pr_number, author, synced_at, metadata, created_at, reviewed_at \
120         FROM review_items WHERE source = ? ORDER BY created_at DESC",
121        input.source
122    )
123    .fetch_all(db)
124    .await?
125    .into_iter()
126    .map(ReviewItemRecord::from)
127    .collect();
128
129    let by_item = fetch_comments_for_items(db, &items).await?;
130    Ok(attach_comments(items, by_item))
131}
132
133pub async fn list_with_comments(
134    db: &sqlx::SqlitePool,
135    input: ListWithCommentsInput,
136) -> crate::Result<Vec<ReviewItemWithComments>> {
137    let items: Vec<ReviewItemRecord> = sqlx::query_as!(
138        ReviewItemRow,
139        "SELECT id, session_id, project_id, file_path, diff_content, status, source, source_kind, \
140         external_review_id, repo_full_name, pr_number, author, synced_at, metadata, created_at, reviewed_at \
141         FROM review_items WHERE project_id = ? ORDER BY created_at DESC",
142        input.project_id
143    )
144    .fetch_all(db)
145    .await?
146    .into_iter()
147    .map(ReviewItemRecord::from)
148    .collect();
149
150    let by_item = fetch_comments_for_items(db, &items).await?;
151    Ok(attach_comments(items, by_item))
152}
153
154pub async fn add_comment(
155    db: &sqlx::SqlitePool,
156    input: AddCommentInput,
157) -> crate::Result<ReviewCommentRecord> {
158    let id = Uuid::new_v4().to_string();
159    let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
160    let stored_line_number = stored_review_comment_line_number(input.line_number);
161
162    sqlx::query!(
163        "INSERT INTO review_comments (id, review_item_id, external_comment_id, line_number, content, \
164         author, comment_url, thread_id, metadata, created_at) \
165         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
166        id,
167        input.review_item_id,
168        input.external_comment_id,
169        stored_line_number,
170        input.content,
171        input.author,
172        input.comment_url,
173        input.thread_id,
174        input.metadata,
175        now
176    )
177    .execute(db)
178    .await?;
179
180    let row = sqlx::query_as!(
181        ReviewCommentRow,
182        "SELECT id, review_item_id, external_comment_id, line_number, content, author, comment_url, \
183         thread_id, metadata, created_at FROM review_comments WHERE id = ?",
184        id
185    )
186    .fetch_one(db)
187    .await?;
188    Ok(ReviewCommentRecord::from(row))
189}
190
191pub async fn ensure_item(
192    db: &sqlx::SqlitePool,
193    input: EnsureItemInput,
194) -> crate::Result<ReviewItemRecord> {
195    let id = input
196        .id
197        .clone()
198        .unwrap_or_else(|| Uuid::new_v4().to_string());
199
200    let exists: bool =
201        sqlx::query_scalar!("SELECT EXISTS(SELECT 1 FROM review_items WHERE id = ?)", id)
202            .fetch_one(db)
203            .await?
204            != 0;
205
206    if exists {
207        sqlx::query!(
208            "UPDATE review_items SET session_id = ?, project_id = ?, file_path = ?, diff_content = ?, \
209             status = ?, source = ?, source_kind = ?, external_review_id = ?, repo_full_name = ?, \
210             pr_number = ?, author = ?, synced_at = ?, metadata = ?, reviewed_at = ? \
211             WHERE id = ?",
212            input.session_id,
213            input.project_id,
214            input.file_path,
215            input.diff_content,
216            input.status,
217            input.source,
218            input.source_kind,
219            input.external_review_id,
220            input.repo_full_name,
221            input.pr_number,
222            input.author,
223            input.synced_at,
224            input.metadata,
225            input.reviewed_at,
226            id
227        )
228        .execute(db)
229        .await?;
230    } else {
231        let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
232        sqlx::query!(
233            "INSERT INTO review_items (id, session_id, project_id, file_path, diff_content, status, source, \
234             source_kind, external_review_id, repo_full_name, pr_number, author, synced_at, metadata, created_at, reviewed_at) \
235             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
236            id,
237            input.session_id,
238            input.project_id,
239            input.file_path,
240            input.diff_content,
241            input.status,
242            input.source,
243            input.source_kind,
244            input.external_review_id,
245            input.repo_full_name,
246            input.pr_number,
247            input.author,
248            input.synced_at,
249            input.metadata,
250            now,
251            input.reviewed_at
252        )
253        .execute(db)
254        .await?;
255    }
256
257    let row = sqlx::query_as!(
258        ReviewItemRow,
259        "SELECT id, session_id, project_id, file_path, diff_content, status, source, source_kind, \
260         external_review_id, repo_full_name, pr_number, author, synced_at, metadata, created_at, reviewed_at \
261         FROM review_items WHERE id = ?",
262        id
263    )
264    .fetch_one(db)
265    .await?;
266    Ok(ReviewItemRecord::from(row))
267}
268
269pub async fn update_item_status(
270    db: &sqlx::SqlitePool,
271    input: UpdateItemStatusInput,
272) -> crate::Result<()> {
273    let result = sqlx::query!(
274        "UPDATE review_items SET status = ? WHERE id = ?",
275        input.status,
276        input.id
277    )
278    .execute(db)
279    .await?;
280    if result.rows_affected() == 0 {
281        return Err(crate::errors::CoreError::NotFound(format!(
282            "review item '{}' not found.",
283            input.id
284        )));
285    }
286    Ok(())
287}
288
289pub async fn remove_item(db: &sqlx::SqlitePool, input: ReviewItemIdInput) -> crate::Result<()> {
290    // First clear dependent comments (zero-row case is fine — item might
291    // have had no comments). Then delete the item itself; that's the row
292    // count we care about for "did we actually find anything?".
293    sqlx::query!(
294        "DELETE FROM review_comments WHERE review_item_id = ?",
295        input.id
296    )
297    .execute(db)
298    .await?;
299    let result = sqlx::query!("DELETE FROM review_items WHERE id = ?", input.id)
300        .execute(db)
301        .await?;
302    if result.rows_affected() == 0 {
303        return Err(crate::errors::CoreError::NotFound(format!(
304            "review item '{}' not found.",
305            input.id
306        )));
307    }
308    Ok(())
309}
310
311pub async fn remove_comment(
312    db: &sqlx::SqlitePool,
313    input: ReviewCommentIdInput,
314) -> crate::Result<()> {
315    let result = sqlx::query!("DELETE FROM review_comments WHERE id = ?", input.id)
316        .execute(db)
317        .await?;
318    if result.rows_affected() == 0 {
319        return Err(crate::errors::CoreError::NotFound(format!(
320            "review comment '{}' not found.",
321            input.id
322        )));
323    }
324    Ok(())
325}
326
327pub async fn list_comments(
328    db: &sqlx::SqlitePool,
329    input: ReviewItemIdInput,
330) -> crate::Result<Vec<ReviewCommentRecord>> {
331    let rows = sqlx::query_as!(
332        ReviewCommentRow,
333        "SELECT id, review_item_id, external_comment_id, line_number, content, author, comment_url, \
334         thread_id, metadata, created_at FROM review_comments WHERE review_item_id = ? ORDER BY created_at ASC",
335        input.id
336    )
337    .fetch_all(db)
338    .await?;
339    Ok(rows.into_iter().map(ReviewCommentRecord::from).collect())
340}