Skip to main content

track_core/
review_repository.rs

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}