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
10pub(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
59pub 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
77pub 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 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}