1use std::collections::{BTreeSet, HashMap};
4use std::path::{Path, PathBuf};
5
6use anyhow::Result;
7
8use seal_core::core::{CoreContext, SealServices};
9use seal_core::events::CodeSelection;
10use seal_core::scm::{resolve_backend, ScmPreference};
11use seal_core::sealignore::SealIgnore;
12
13use crate::db::{
14 Comment, FileContentData, FileData, ReviewData, ReviewDetail, ReviewSummary, SealClient,
15 ThreadSummary,
16};
17
18pub struct CoreClient {
20 ctx: CoreContext,
21 repo_root: PathBuf,
22}
23
24impl CoreClient {
25 pub fn new(ctx: CoreContext, repo_root: &Path) -> Self {
26 Self {
27 ctx,
28 repo_root: repo_root.to_path_buf(),
29 }
30 }
31
32 fn services(&self) -> Result<SealServices> {
34 self.ctx.services().map_err(|e| anyhow::anyhow!("{e}"))
35 }
36
37 fn comment_agent() -> String {
38 std::env::var("USER")
39 .ok()
40 .filter(|value| !value.trim().is_empty())
41 .unwrap_or_else(|| "unknown".to_string())
42 }
43}
44
45fn convert_review_summary(r: &seal_core::projection::ReviewSummary) -> ReviewSummary {
48 ReviewSummary {
49 review_id: r.review_id.clone(),
50 title: r.title.clone(),
51 author: r.author.clone(),
52 status: r.status.clone(),
53 thread_count: r.thread_count,
54 open_thread_count: r.open_thread_count,
55 reviewers: r.reviewers.clone(),
56 }
57}
58
59fn convert_review_detail(r: &seal_core::projection::ReviewDetail) -> ReviewDetail {
60 ReviewDetail {
61 review_id: r.review_id.clone(),
62 jj_change_id: r.jj_change_id.clone(),
63 scm_kind: r.scm_kind.clone(),
64 scm_anchor: r.scm_anchor.clone(),
65 initial_commit: r.initial_commit.clone(),
66 final_commit: r.final_commit.clone(),
67 title: r.title.clone(),
68 description: r.description.clone(),
69 author: r.author.clone(),
70 created_at: r.created_at.clone(),
71 status: r.status.clone(),
72 status_changed_at: r.status_changed_at.clone(),
73 status_changed_by: r.status_changed_by.clone(),
74 abandon_reason: r.abandon_reason.clone(),
75 thread_count: r.thread_count,
76 open_thread_count: r.open_thread_count,
77 }
78}
79
80fn convert_thread_summary(t: &seal_core::projection::ThreadSummary) -> ThreadSummary {
81 ThreadSummary {
82 thread_id: t.thread_id.clone(),
83 file_path: t.file_path.clone(),
84 selection_start: t.selection_start,
85 selection_end: t.selection_end,
86 status: t.status.clone(),
87 comment_count: t.comment_count,
88 }
89}
90
91fn convert_comment(c: &seal_core::projection::Comment) -> Comment {
92 Comment {
93 comment_id: c.comment_id.clone(),
94 author: c.author.clone(),
95 body: c.body.clone(),
96 created_at: c.created_at.clone(),
97 }
98}
99
100impl SealClient for CoreClient {
101 fn list_reviews(&self, status: Option<&str>) -> Result<Vec<ReviewSummary>> {
102 let services = self.services()?;
103 let reviews = services
104 .reviews()
105 .list(status, None)
106 .map_err(|e| anyhow::anyhow!("{e}"))?;
107 Ok(reviews.iter().map(convert_review_summary).collect())
108 }
109
110 fn load_review_data(&self, review_id: &str) -> Result<Option<ReviewData>> {
111 let services = self.services()?;
112
113 let detail = match services
114 .reviews()
115 .get_optional(review_id)
116 .map_err(|e| anyhow::anyhow!("{e}"))?
117 {
118 Some(d) => d,
119 None => return Ok(None),
120 };
121
122 let core_threads = services
123 .threads()
124 .list(review_id, None, None)
125 .map_err(|e| anyhow::anyhow!("{e}"))?;
126 let sealignore = SealIgnore::load(&self.repo_root);
127 let visible_threads: Vec<_> = core_threads
128 .into_iter()
129 .filter(|thread| !sealignore.is_ignored(&thread.file_path))
130 .collect();
131
132 let mut threads = Vec::with_capacity(visible_threads.len());
133 let mut comments: HashMap<String, Vec<Comment>> = HashMap::new();
134
135 for t in &visible_threads {
136 threads.push(convert_thread_summary(t));
137
138 let core_comments = services
139 .comments()
140 .list(&t.thread_id)
141 .map_err(|e| anyhow::anyhow!("{e}"))?;
142
143 if !core_comments.is_empty() {
144 comments.insert(
145 t.thread_id.clone(),
146 core_comments.iter().map(convert_comment).collect(),
147 );
148 }
149 }
150
151 let review_detail = convert_review_detail(&detail);
152
153 let files = self.build_file_diffs(&detail, &visible_threads);
155
156 Ok(Some(ReviewData {
157 detail: review_detail,
158 threads,
159 comments,
160 files,
161 }))
162 }
163
164 fn comment(
165 &self,
166 review_id: &str,
167 file_path: &str,
168 start_line: i64,
169 end_line: Option<i64>,
170 body: &str,
171 ) -> Result<()> {
172 let services = self.services()?;
173 let agent = Self::comment_agent();
174
175 let review = services
177 .reviews()
178 .get(review_id)
179 .map_err(|e| anyhow::anyhow!("{e}"))?;
180
181 #[allow(clippy::cast_sign_loss)]
182 let selection = match end_line {
183 Some(end) if end != start_line => CodeSelection::range(start_line as u32, end as u32),
184 _ => CodeSelection::line(start_line as u32),
185 };
186
187 services
188 .comments()
189 .add_to_review(
190 review_id,
191 file_path,
192 selection,
193 body,
194 review.initial_commit.clone(),
195 Some(&agent),
196 )
197 .map_err(|e| anyhow::anyhow!("{e}"))?;
198
199 Ok(())
200 }
201
202 fn reply(&self, thread_id: &str, body: &str) -> Result<()> {
203 let services = self.services()?;
204 let agent = Self::comment_agent();
205
206 services
207 .comments()
208 .add_to_thread(thread_id, body, Some(&agent))
209 .map_err(|e| anyhow::anyhow!("{e}"))?;
210
211 Ok(())
212 }
213}
214
215impl CoreClient {
218 fn build_file_diffs(
219 &self,
220 review: &seal_core::projection::ReviewDetail,
221 threads: &[seal_core::projection::ThreadSummary],
222 ) -> Vec<FileData> {
223 let scm = match resolve_backend(&self.repo_root, ScmPreference::Auto) {
224 Ok(s) => s,
225 Err(_) => return Vec::new(),
226 };
227
228 let target_commit = review
230 .final_commit
231 .clone()
232 .or_else(|| scm.commit_for_anchor(&review.scm_anchor).ok())
233 .or_else(|| scm.commit_for_anchor(&review.jj_change_id).ok())
234 .unwrap_or_else(|| review.initial_commit.clone());
235
236 let base_commit = scm
237 .parent_commit(&target_commit)
238 .unwrap_or_else(|_| review.initial_commit.clone());
239
240 let full_diff = scm
242 .diff_git(&base_commit, &target_commit)
243 .unwrap_or_default();
244 let diffs_by_file = split_diff_by_file(&full_diff);
245
246 let sealignore = SealIgnore::load(&self.repo_root);
248 let files_with_threads: BTreeSet<String> =
249 threads.iter().map(|t| t.file_path.clone()).collect();
250 let mut all_files: BTreeSet<String> = files_with_threads;
251 for key in diffs_by_file.keys() {
252 all_files.insert((*key).to_string());
253 }
254
255 let mut file_cache: HashMap<String, String> = HashMap::new();
257 for thread in threads {
258 if !file_cache.contains_key(&thread.file_path) {
259 if let Ok(contents) = scm.show_file(&target_commit, &thread.file_path) {
260 file_cache.insert(thread.file_path.clone(), contents);
261 }
262 }
263 }
264
265 let mut result = Vec::new();
266 for file_path in &all_files {
267 if sealignore.is_ignored(file_path) {
268 continue;
269 }
270
271 let diff = diffs_by_file.get(file_path.as_str()).map(|s| s.to_string());
272
273 let file_threads: Vec<&seal_core::projection::ThreadSummary> = threads
275 .iter()
276 .filter(|t| &t.file_path == file_path)
277 .collect();
278
279 let content = if !file_threads.is_empty() {
280 if let Some(ref diff_text) = diff {
281 let hunks = parse_hunk_ranges(diff_text);
282 let has_orphan = file_threads.iter().any(|t| {
283 let line = t.selection_start as u32;
284 !hunks.iter().any(|h| line >= h.0 && line <= h.1)
285 });
286 if has_orphan {
287 build_content_window(&file_cache, file_path, &file_threads)
288 } else {
289 None
290 }
291 } else {
292 build_content_window(&file_cache, file_path, &file_threads)
293 }
294 } else {
295 None
296 };
297
298 result.push(FileData {
299 path: file_path.clone(),
300 diff,
301 content,
302 });
303 }
304
305 result
306 }
307}
308
309fn split_diff_by_file(full_diff: &str) -> HashMap<&str, &str> {
311 let mut result = HashMap::new();
312 let mut current_file: Option<&str> = None;
313 let mut current_start: usize = 0;
314 let mut offset = 0;
315
316 for line in full_diff.lines() {
317 let byte_offset = offset;
318 offset += line.len() + 1; if line.starts_with("diff --git") {
321 if let Some(file) = current_file {
322 let section = &full_diff[current_start..byte_offset];
323 if !section.trim().is_empty() {
324 result.insert(file, section);
325 }
326 }
327 current_file = line
328 .split_whitespace()
329 .nth(3)
330 .map(|s| s.trim_start_matches("b/"));
331 current_start = byte_offset;
332 }
333 }
334
335 if let Some(file) = current_file {
336 let section = &full_diff[current_start..];
337 if !section.trim().is_empty() {
338 result.insert(file, section);
339 }
340 }
341
342 result
343}
344
345fn parse_hunk_ranges(diff: &str) -> Vec<(u32, u32)> {
347 let mut ranges = Vec::new();
348 for line in diff.lines() {
349 if !line.starts_with("@@") {
350 continue;
351 }
352 if let Some(plus_pos) = line.find('+') {
353 let after_plus = &line[plus_pos + 1..];
354 let end = after_plus
355 .find(|c: char| c == ' ' || c == '@')
356 .unwrap_or(after_plus.len());
357 let range_str = &after_plus[..end];
358
359 if let Some((start_str, count_str)) = range_str.split_once(',') {
360 if let (Ok(start), Ok(count)) = (start_str.parse::<u32>(), count_str.parse::<u32>())
361 {
362 if count > 0 {
363 ranges.push((start, start + count - 1));
364 }
365 }
366 } else if let Ok(start) = range_str.parse::<u32>() {
367 ranges.push((start, start));
368 }
369 }
370 }
371 ranges
372}
373
374fn build_content_window(
376 file_cache: &HashMap<String, String>,
377 file_path: &str,
378 threads: &[&seal_core::projection::ThreadSummary],
379) -> Option<FileContentData> {
380 let contents = file_cache.get(file_path)?;
381 let lines: Vec<&str> = contents.lines().collect();
382 if lines.is_empty() {
383 return None;
384 }
385
386 let context = 5u32;
388 let mut min_line = u32::MAX;
389 let mut max_line = 0u32;
390
391 for t in threads {
392 let start = t.selection_start as u32;
393 let end = t.selection_end.unwrap_or(t.selection_start) as u32;
394 min_line = min_line.min(start.saturating_sub(context));
395 max_line = max_line.max(end + context);
396 }
397
398 let start_line = min_line.max(1);
400 let end_line = max_line.min(lines.len() as u32);
401
402 if start_line > end_line {
403 return None;
404 }
405
406 let window_lines: Vec<String> = lines[(start_line as usize - 1)..=(end_line as usize - 1)]
407 .iter()
408 .map(|l| (*l).to_string())
409 .collect();
410
411 Some(FileContentData {
412 start_line: i64::from(start_line),
413 lines: window_lines,
414 })
415}