feat: delegate code comments to KenShell
Comments are review artifacts on Git objects, which KenShell already hosts, and its service supports the same lifecycle. Read and write through the CodeCommentService RPCs. Pagination stays local: the remote listing returns the whole anchor set, and slicing here avoids an extra RPC contract for a table that is small per commit. Ownership checks stay in this service so admins can still moderate, and the update is issued as the comment author because the remote enforces it.
ZhenYi committed
Modified lib/infra/proto/proto/kenshell/v1/comment.proto lib/infra/proto/proto/kenshell/v1/comment.proto +10 −0 View lib/infra/proto/proto/kenshell/v1/comment.proto
@@ -28,6 +28,11 @@
2828 int64 user_id = 7;
2929 google.protobuf.Timestamp created_at = 8;
3030 google.protobuf.Timestamp updated_at = 9;
31 // 回复线程:指向同仓库的另一条评论。
32 string parent_id = 10;
33 // 非空表示已解决。
34 google.protobuf.Timestamp resolved_at = 11;
35 int64 resolved_by = 12;
3136}
3237
3338message ListCodeCommentsRequest {
@@ -36,6 +41,8 @@
3641 string path = 3;
3742 bytes path_bytes = 4;
3843 optional uint32 line_number = 5;
44 // 不传表示不按解决状态过滤。
45 optional bool resolved = 6;
3946}
4047
4148message ListCodeCommentsResponse {
@@ -52,6 +59,7 @@
5259 string body = 5;
5360 int64 user_id = 6;
5461 bytes path_bytes = 7;
62 string parent_id = 8;
5563}
5664
5765message CreateCodeCommentResponse {
@@ -64,6 +72,8 @@
6472 string id = 2;
6573 int64 user_id = 3;
6674 string body = 4;
75 // 不传表示保持原状。
76 optional bool resolved = 5;
6777}
6878
6979message UpdateCodeCommentResponse {
Modified lib/service/git/src/service/code_comments.rs lib/service/git/src/service/code_comments.rs +106 −133 View lib/service/git/src/service/code_comments.rs
@@ -1,18 +1,13 @@
11use crate::context::facade::GitContext;
22use api::pagination::Page;
33use api::users::UserDto;
4use models::repo::code_comment;
5use sea_orm::ActiveValue::Set;
6use sea_orm::{
7 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
8 QuerySelect,
9};
104use serde::{Deserialize, Serialize};
115use time::OffsetDateTime;
126use utoipa::ToSchema;
137use uuid::Uuid;
148
159use crate::share::errors::GitError;
10use proto::kenshell::v1 as pb;
1611
1712const MAX_BODY_LEN: usize = 65_536;
1813
@@ -77,45 +72,39 @@
7772 query: ListCodeCommentsQuery,
7873 ) -> Result<CodeCommentPage, GitError> {
7974 let (limit, offset) = api::pagination::page_bounds(query.limit, query.offset, 50, 200)?;
80 let mut select =
81 code_comment::Entity::find().filter(code_comment::Column::RepoId.eq(ctx.model.id));
82 if let Some(path) = query
83 .path
84 .as_deref()
85 .filter(|value| !value.trim().is_empty())
86 {
87 select = select.filter(code_comment::Column::Path.eq(path.trim()));
88 }
89 if let Some(revision) = query
75 let commit_sha = match query
9076 .revision
9177 .as_deref()
92 .filter(|value| !value.trim().is_empty())
78 .map(str::trim)
79 .filter(|value| !value.is_empty())
9380 {
94 let sha = self
95 .baby(&ctx)
81 Some(revision) => self
82 .baby(ctx)
9683 .rev_parse(revision)
9784 .await
9885 .map_err(crate::share::errors::map_baby)?
99 .to_string();
100 select = select.filter(code_comment::Column::CommitSha.eq(sha));
101 }
102 if let Some(resolved) = query.resolved {
103 if resolved {
104 select = select.filter(code_comment::Column::ResolvedAt.is_not_null());
105 } else {
106 select = select.filter(code_comment::Column::ResolvedAt.is_null());
107 }
108 }
109 let total = select.clone().count(self.db_read()).await?;
110 let rows = select
111 .order_by_asc(code_comment::Column::Path)
112 .order_by_asc(code_comment::Column::Line)
113 .order_by_asc(code_comment::Column::CreatedAt)
114 .offset(offset)
115 .limit(limit)
116 .all(self.db_read())
117 .await?;
118 let items = self.code_comment_dtos(&ctx, rows).await?;
86 .to_string(),
87 None => String::new(),
88 };
89 let path = query
90 .path
91 .as_deref()
92 .map(str::trim)
93 .unwrap_or_default()
94 .to_string();
95 let views = self
96 .baby(ctx)
97 .list_code_comments(&commit_sha, &path, query.resolved)
98 .await
99 .map_err(crate::share::errors::map_baby)?;
100 // KenShell 不分页:返回整个锚点集合,这里就地切片。
101 let total = views.len() as u64;
102 let page: Vec<_> = views
103 .into_iter()
104 .skip(offset as usize)
105 .take(limit as usize)
106 .collect();
107 let items = self.comment_dtos(ctx, page).await?;
119108 Ok(Page {
120109 items,
121110 total,
@@ -147,44 +136,25 @@
147136 .filter(|value| !value.trim().is_empty())
148137 {
149138 Some(sha) => sha.trim().to_string(),
150 None => {
151 let revision = cmd.revision.as_deref().unwrap_or("HEAD");
152 self.baby(&ctx)
153 .rev_parse(revision)
154 .await
155 .map_err(crate::share::errors::map_baby)?
156 .to_string()
157 }
139 None => cmd.revision.as_deref().unwrap_or("HEAD").to_string(),
158140 };
159 if let Some(parent_id) = cmd.parent_id {
160 let parent = code_comment::Entity::find_by_id(parent_id)
161 .filter(code_comment::Column::RepoId.eq(ctx.model.id))
162 .one(self.db_read())
163 .await?;
164 if parent.is_none() {
165 return Err(GitError::InvalidArgument(
166 "parent comment not found".to_string(),
167 ));
168 }
169 }
170 let now = OffsetDateTime::now_utc();
171 let row = code_comment::ActiveModel {
172 id: Set(Uuid::now_v7()),
173 repo_id: Set(ctx.model.id),
174 path: Set(cmd.path.trim().to_string()),
175 line: Set(cmd.line),
176 commit_sha: Set(anchor),
177 author_id: Set(author),
178 parent_id: Set(cmd.parent_id),
179 body: Set(cmd.body),
180 resolved_at: Set(None),
181 resolved_by: Set(None),
182 created_at: Set(now),
183 updated_at: Set(None),
184 }
185 .insert(self.db_write())
186 .await?;
187 let mut out = self.code_comment_dtos(&ctx, vec![row]).await?;
141 let parent = cmd
142 .parent_id
143 .map(|id| id.to_string())
144 .unwrap_or_default();
145 let view = self
146 .baby(ctx)
147 .create_code_comment(
148 &anchor,
149 cmd.path.trim(),
150 cmd.line as u32,
151 &cmd.body,
152 author,
153 &parent,
154 )
155 .await
156 .map_err(crate::share::errors::map_baby)?;
157 let mut out = self.comment_dtos(ctx, vec![view]).await?;
188158 out.pop().ok_or(GitError::NotFound)
189159 }
190160
@@ -199,15 +169,17 @@
199169 if cmd.body.trim().is_empty() || cmd.body.chars().count() > MAX_BODY_LEN {
200170 return Err(GitError::InvalidArgument("invalid body".to_string()));
201171 }
202 let row = self.find_code_comment(ctx.model.id, comment_id).await?;
203 if row.author_id != actor && !admin {
172 let current = self.find_comment_view(ctx, comment_id).await?;
173 if current.user_id != actor && !admin {
204174 return Err(GitError::Forbidden);
205175 }
206 let mut model: code_comment::ActiveModel = row.into();
207 model.body = Set(cmd.body);
208 model.updated_at = Set(Some(OffsetDateTime::now_utc()));
209 let updated = model.update(self.db_write()).await?;
210 let mut out = self.code_comment_dtos(&ctx, vec![updated]).await?;
176 // 以评论作者身份提交,KenShell 侧按 user_id 校验归属。
177 let view = self
178 .baby(ctx)
179 .update_code_comment(¤t.id, current.user_id, &cmd.body, None)
180 .await
181 .map_err(crate::share::errors::map_baby)?;
182 let mut out = self.comment_dtos(ctx, vec![view]).await?;
211183 out.pop().ok_or(GitError::NotFound)
212184 }
213185
@@ -218,20 +190,14 @@
218190 actor: i64,
219191 admin: bool,
220192 ) -> Result<(), GitError> {
221 let row = self.find_code_comment(ctx.model.id, comment_id).await?;
222 if row.author_id != actor && !admin {
193 let current = self.find_comment_view(ctx, comment_id).await?;
194 if current.user_id != actor && !admin {
223195 return Err(GitError::Forbidden);
224196 }
225 // 连同其回复一并删除,与既有评论模型一致。
226 use sea_orm::Condition;
227 code_comment::Entity::delete_many()
228 .filter(
229 Condition::any()
230 .add(code_comment::Column::Id.eq(comment_id))
231 .add(code_comment::Column::ParentId.eq(comment_id)),
232 )
233 .exec(self.db_write())
234 .await?;
197 self.baby(ctx)
198 .delete_code_comment(¤t.id, current.user_id)
199 .await
200 .map_err(crate::share::errors::map_baby)?;
235201 Ok(())
236202 }
237203
@@ -242,65 +208,72 @@
242208 actor: i64,
243209 resolved: bool,
244210 ) -> Result<CodeCommentDto, GitError> {
245 let row = self.find_code_comment(ctx.model.id, comment_id).await?;
246 let mut model: code_comment::ActiveModel = row.into();
247 model.resolved_at = Set(if resolved {
248 Some(OffsetDateTime::now_utc())
249 } else {
250 None
251 });
252 model.resolved_by = Set(if resolved { Some(actor) } else { None });
253 let updated = model.update(self.db_write()).await?;
254 let mut out = self.code_comment_dtos(&ctx, vec![updated]).await?;
211 let current = self.find_comment_view(ctx, comment_id).await?;
212 let view = self
213 .baby(ctx)
214 .update_code_comment(¤t.id, actor, "", Some(resolved))
215 .await
216 .map_err(crate::share::errors::map_baby)?;
217 let mut out = self.comment_dtos(ctx, vec![view]).await?;
255218 out.pop().ok_or(GitError::NotFound)
256219 }
257220
258 async fn find_code_comment(
221 async fn find_comment_view(
259222 &self,
260 repo_id: Uuid,
223 ctx: &GitContext,
261224 comment_id: Uuid,
262 ) -> Result<code_comment::Model, GitError> {
263 code_comment::Entity::find_by_id(comment_id)
264 .filter(code_comment::Column::RepoId.eq(repo_id))
265 .one(self.db_read())
266 .await?
225 ) -> Result<pb::CodeCommentView, GitError> {
226 let views = self
227 .baby(ctx)
228 .list_code_comments("", "", None)
229 .await
230 .map_err(crate::share::errors::map_baby)?;
231 views
232 .into_iter()
233 .find(|view| view.id == comment_id.to_string())
267234 .ok_or(GitError::NotFound)
268235 }
269236
270 async fn code_comment_dtos(
237 async fn comment_dtos(
271238 &self,
272239 ctx: &GitContext,
273 rows: Vec<code_comment::Model>,
240 views: Vec<pb::CodeCommentView>,
274241 ) -> Result<Vec<CodeCommentDto>, GitError> {
275 if rows.is_empty() {
242 if views.is_empty() {
276243 return Ok(Vec::new());
277244 }
278 let uids: Vec<i64> = rows.iter().map(|row| row.author_id).collect();
245 let uids: Vec<i64> = views.iter().map(|view| view.user_id).collect();
279246 let users = self.user_dto_map(&uids).await?;
280 // outdated 判定:锚定提交不是当前默认分支提示即视为过期。
247 // outdated:锚定提交不是当前 HEAD 即视为过期。
281248 let head = self
282249 .baby(ctx)
283 .rev_parse(&format!("refs/heads/{}", ctx.model.default_branch))
250 .rev_parse("HEAD")
284251 .await
285252 .map(|oid| oid.to_string())
286253 .ok();
287 Ok(rows
254 Ok(views
288255 .into_iter()
289 .map(|row| CodeCommentDto {
290 id: row.id,
291 repo_id: row.repo_id,
292 path: row.path,
293 line: row.line,
294 commit_sha: row.commit_sha.clone(),
295 author: users.get(&row.author_id).cloned(),
296 parent_id: row.parent_id,
297 body: row.body,
298 resolved: row.resolved_at.is_some(),
299 resolved_by: row.resolved_by,
300 outdated: head.as_deref().is_some_and(|head| head != row.commit_sha),
301 created_at: row.created_at,
302 updated_at: row.updated_at,
256 .map(|view| CodeCommentDto {
257 id: Uuid::parse_str(&view.id).unwrap_or_default(),
258 repo_id: ctx.model.id,
259 path: view.path,
260 line: view.line_number as i32,
261 commit_sha: view.commit_sha.clone(),
262 author: users.get(&view.user_id).cloned(),
263 parent_id: Uuid::parse_str(view.parent_id.trim()).ok(),
264 body: view.body,
265 resolved: view.resolved_at.is_some(),
266 resolved_by: (view.resolved_by > 0).then_some(view.resolved_by),
267 outdated: head.as_deref().is_some_and(|head| head != view.commit_sha),
268 created_at: at(view.created_at),
269 updated_at: view.updated_at.and_then(|at| OffsetDateTime::from_unix_timestamp(at.seconds).ok()),
303270 })
304271 .collect())
305272 }
306273}
274
275fn at(value: Option<prost_types::Timestamp>) -> OffsetDateTime {
276 value
277 .and_then(|at| OffsetDateTime::from_unix_timestamp(at.seconds).ok())
278 .unwrap_or(OffsetDateTime::UNIX_EPOCH)
279}