1use std::path::PathBuf;
2
3use sqlx::Row;
4
5use crate::database::DatabaseContext;
6use crate::errors::{ErrorCode, TrackError};
7use crate::path_component::validate_single_normal_path_component;
8use crate::time_utils::{format_iso_8601_millis, parse_iso_8601_millis};
9use crate::types::{RemoteAgentPreferredTool, ReviewRecord};
10
11#[derive(Debug, Clone)]
12pub struct ReviewRepository {
13 database: DatabaseContext,
14}
15
16impl ReviewRepository {
17 pub fn new(database_path: Option<PathBuf>) -> Result<Self, TrackError> {
18 let database = DatabaseContext::new(database_path)?;
19 database.initialize()?;
20
21 Ok(Self { database })
22 }
23
24 pub fn reviews_dir(&self) -> &std::path::Path {
25 self.database.database_path()
26 }
27
28 pub fn save_review(&self, review: &ReviewRecord) -> Result<(), TrackError> {
29 let review = review.clone();
30 self.database.run(move |connection| {
31 Box::pin(async move {
32 sqlx::query(
33 r#"
34 INSERT INTO reviews (
35 id, pull_request_url, pull_request_number, pull_request_title,
36 repository_full_name, repo_url, git_url, base_branch, workspace_key,
37 preferred_tool, project, main_user, default_review_prompt,
38 extra_instructions, created_at, updated_at
39 )
40 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)
41 ON CONFLICT(id) DO UPDATE SET
42 pull_request_url = excluded.pull_request_url,
43 pull_request_number = excluded.pull_request_number,
44 pull_request_title = excluded.pull_request_title,
45 repository_full_name = excluded.repository_full_name,
46 repo_url = excluded.repo_url,
47 git_url = excluded.git_url,
48 base_branch = excluded.base_branch,
49 workspace_key = excluded.workspace_key,
50 preferred_tool = excluded.preferred_tool,
51 project = excluded.project,
52 main_user = excluded.main_user,
53 default_review_prompt = excluded.default_review_prompt,
54 extra_instructions = excluded.extra_instructions,
55 created_at = excluded.created_at,
56 updated_at = excluded.updated_at
57 "#,
58 )
59 .bind(&review.id)
60 .bind(&review.pull_request_url)
61 .bind(review.pull_request_number as i64)
62 .bind(&review.pull_request_title)
63 .bind(&review.repository_full_name)
64 .bind(&review.repo_url)
65 .bind(&review.git_url)
66 .bind(&review.base_branch)
67 .bind(&review.workspace_key)
68 .bind(review.preferred_tool.as_str())
69 .bind(review.project.as_deref())
70 .bind(&review.main_user)
71 .bind(review.default_review_prompt.as_deref())
72 .bind(review.extra_instructions.as_deref())
73 .bind(format_iso_8601_millis(review.created_at))
74 .bind(format_iso_8601_millis(review.updated_at))
75 .execute(&mut *connection)
76 .await
77 .map_err(|error| {
78 TrackError::new(
79 ErrorCode::TaskWriteFailed,
80 format!("Could not save review {}: {error}", review.id),
81 )
82 })?;
83
84 Ok(())
85 })
86 })
87 }
88
89 pub fn list_reviews(&self) -> Result<Vec<ReviewRecord>, TrackError> {
90 self.database.run(move |connection| {
91 Box::pin(async move {
92 let rows = sqlx::query(
93 r#"
94 SELECT
95 id, pull_request_url, pull_request_number, pull_request_title,
96 repository_full_name, repo_url, git_url, base_branch, workspace_key,
97 preferred_tool, project, main_user, default_review_prompt,
98 extra_instructions, created_at, updated_at
99 FROM reviews
100 ORDER BY updated_at DESC
101 "#,
102 )
103 .fetch_all(&mut *connection)
104 .await
105 .map_err(|error| {
106 TrackError::new(
107 ErrorCode::TaskWriteFailed,
108 format!("Could not list reviews from SQLite: {error}"),
109 )
110 })?;
111
112 rows.into_iter().map(review_from_row).collect()
113 })
114 })
115 }
116
117 pub fn get_review(&self, id: &str) -> Result<ReviewRecord, TrackError> {
118 let review_id = validate_single_normal_path_component(
119 id,
120 "Review id",
121 ErrorCode::InvalidPathComponent,
122 )?;
123
124 self.database.run(move |connection| {
125 Box::pin(async move {
126 let row = sqlx::query(
127 r#"
128 SELECT
129 id, pull_request_url, pull_request_number, pull_request_title,
130 repository_full_name, repo_url, git_url, base_branch, workspace_key,
131 preferred_tool, project, main_user, default_review_prompt,
132 extra_instructions, created_at, updated_at
133 FROM reviews
134 WHERE id = ?1
135 "#,
136 )
137 .bind(&review_id)
138 .fetch_optional(&mut *connection)
139 .await
140 .map_err(|error| {
141 TrackError::new(
142 ErrorCode::TaskWriteFailed,
143 format!("Could not load review {review_id}: {error}"),
144 )
145 })?
146 .ok_or_else(|| {
147 TrackError::new(
148 ErrorCode::TaskNotFound,
149 format!("Review {review_id} was not found."),
150 )
151 })?;
152
153 review_from_row(row)
154 })
155 })
156 }
157
158 pub fn delete_review(&self, id: &str) -> Result<(), TrackError> {
159 let review_id = validate_single_normal_path_component(
160 id,
161 "Review id",
162 ErrorCode::InvalidPathComponent,
163 )?;
164
165 self.database.run(move |connection| {
166 Box::pin(async move {
167 sqlx::query("DELETE FROM reviews WHERE id = ?1")
168 .bind(&review_id)
169 .execute(&mut *connection)
170 .await
171 .map_err(|error| {
172 TrackError::new(
173 ErrorCode::TaskWriteFailed,
174 format!("Could not delete review {review_id}: {error}"),
175 )
176 })?;
177
178 Ok(())
179 })
180 })
181 }
182}
183
184fn review_from_row(row: sqlx::sqlite::SqliteRow) -> Result<ReviewRecord, TrackError> {
185 let id = row.get::<String, _>("id");
186 let created_at =
187 parse_iso_8601_millis(&row.get::<String, _>("created_at")).map_err(|error| {
188 TrackError::new(
189 ErrorCode::TaskWriteFailed,
190 format!("Review {id} has an invalid created_at timestamp: {error}"),
191 )
192 })?;
193 let updated_at =
194 parse_iso_8601_millis(&row.get::<String, _>("updated_at")).map_err(|error| {
195 TrackError::new(
196 ErrorCode::TaskWriteFailed,
197 format!("Review {id} has an invalid updated_at timestamp: {error}"),
198 )
199 })?;
200
201 Ok(ReviewRecord {
202 id,
203 pull_request_url: row.get::<String, _>("pull_request_url"),
204 pull_request_number: row.get::<i64, _>("pull_request_number") as u64,
205 pull_request_title: row.get::<String, _>("pull_request_title"),
206 repository_full_name: row.get::<String, _>("repository_full_name"),
207 repo_url: row.get::<String, _>("repo_url"),
208 git_url: row.get::<String, _>("git_url"),
209 base_branch: row.get::<String, _>("base_branch"),
210 workspace_key: row.get::<String, _>("workspace_key"),
211 preferred_tool: parse_preferred_tool(
212 row.try_get::<String, _>("preferred_tool")
213 .unwrap_or_else(|_| "codex".to_owned())
214 .as_str(),
215 )?,
216 project: row.get::<Option<String>, _>("project"),
217 main_user: row.get::<String, _>("main_user"),
218 default_review_prompt: row.get::<Option<String>, _>("default_review_prompt"),
219 extra_instructions: row.get::<Option<String>, _>("extra_instructions"),
220 created_at,
221 updated_at,
222 })
223}
224
225fn parse_preferred_tool(value: &str) -> Result<RemoteAgentPreferredTool, TrackError> {
226 RemoteAgentPreferredTool::from_str(value).ok_or_else(|| {
227 TrackError::new(
228 ErrorCode::TaskWriteFailed,
229 format!("Remote agent preferred tool `{value}` is not valid."),
230 )
231 })
232}