Skip to content
Clone

feat(ai): add AI review and summary functionalities with related endpoints and models

ZhenYi committed
parent 717a114 commit 914b087 Browse files

114 files changed

+7443 −89

This change is very large, so some diffs are not shown. Clone the repository to see the full change.

File tree 114
Modified admin/src/pages/tasks.tsx View admin/src/pages/tasks.tsx
@@ -30,6 +30,7 @@
3030 { value: 'pipeline', label: 'pipeline' },
3131 { value: 'deletion', label: 'deletion' },
3232 { value: 'system_tasks', label: 'system_tasks' },
33 { value: 'ai', label: 'ai' },
3334]
3435
3536export function TasksPage() {
Added app/aihost/src/main.rs View app/aihost/src/main.rs
@@ -0,0 +1,187 @@
1use std::net::SocketAddr;
2use std::time::Duration;
3
4use agent::{AgentService, AgentTask};
5use anyhow::Context;
6use config::scoped::{AiHostConfig, ScopedConfig};
7use git::sync::queue::SyncQueue;
8use tokio::net::TcpListener;
9use tokio_util::sync::CancellationToken;
10use tracing::{info, warn};
11
12#[tokio::main]
13async fn main() -> anyhow::Result<()> {
14 export::telemetry::init_tracing();
15
16 let cfg = AiHostConfig::load().context("loading configuration")?;
17 if !cfg.aihost.enabled() {
18 info!("aihost is disabled by config; exiting");
19 return Ok(());
20 }
21 if !cfg.ai.enabled {
22 info!("ai is disabled by config; aihost exits without a model endpoint");
23 return Ok(());
24 }
25
26 info!("connecting to database / redis / storage");
27 let state = export::AppState::with_config(cfg.clone().into_app_config())
28 .await
29 .context("building AppState")?;
30 let service = AgentService::new(state).context("building ai service")?;
31 info!(model = service.client().model(), "dependencies ready");
32
33 let cancel = CancellationToken::new();
34
35 requeue_stale_jobs(&service).await;
36
37 let mut workers = Vec::with_capacity(cfg.aihost.concurrency);
38 for slot in 0..cfg.aihost.concurrency.max(1) {
39 workers.push(tokio::spawn(consume(
40 service.clone(),
41 cfg.ai.queue_topic.clone(),
42 cfg.ai.dlq_topic.clone(),
43 cfg.aihost.retry_backoff_base_secs,
44 slot,
45 cancel.clone(),
46 )));
47 }
48
49 let health = tokio::spawn(run_health(
50 cfg.health.bind_addr().to_string(),
51 cfg.health.aihost_port(),
52 service.state.database.clone(),
53 service.state.redis.clone(),
54 cancel.clone(),
55 ));
56
57 export::telemetry::shutdown_signal().await;
58 info!("aihost shutting down");
59 cancel.cancel();
60 for worker in workers {
61 let _ = worker.await;
62 }
63 let _ = health.await;
64 info!("aihost shut down");
65 Ok(())
66}
67
68/// 消费 AI 队列;失败按退避重投,超过 `max_attempts` 进 DLQ。
69async fn consume(
70 service: AgentService,
71 topic: String,
72 dlq: String,
73 backoff_base_secs: u64,
74 slot: usize,
75 cancel: CancellationToken,
76) {
77 let queue = match SyncQueue::with_consumer_conns(service.state.redis.clone(), slot + 1).await {
78 Ok(queue) => queue,
79 Err(error) => {
80 warn!(%error, slot, "failed to open ai queue consumer connection");
81 return;
82 }
83 };
84 let mut attempts: std::collections::HashMap<uuid::Uuid, i32> = std::collections::HashMap::new();
85 let max_attempts = service.config().ai.max_attempts.max(1);
86 loop {
87 let payload = tokio::select! {
88 _ = cancel.cancelled() => break,
89 res = queue.consume(&topic, 5, slot) => match res {
90 Ok(Some(payload)) => payload,
91 Ok(None) => continue,
92 Err(error) => {
93 warn!(%error, slot, "ai queue consume failed");
94 tokio::time::sleep(Duration::from_secs(2)).await;
95 continue;
96 }
97 },
98 };
99 let task = match AgentTask::decode(&payload) {
100 Ok(task) => task,
101 Err(error) => {
102 warn!(%error, "dropping malformed ai task");
103 let _ = queue.requeue(&dlq, &payload).await;
104 continue;
105 }
106 };
107 let job_id = task.job_id();
108 match service.run_task(&task).await {
109 Ok(()) => {
110 attempts.remove(&job_id);
111 info!(%job_id, kind = task.kind_str(), "ai task completed");
112 }
113 Err(error) => {
114 let count = attempts.entry(job_id).or_insert(0);
115 *count += 1;
116 if *count >= max_attempts {
117 attempts.remove(&job_id);
118 warn!(%error, %job_id, "ai task failed permanently; sending to DLQ");
119 service.mark_failed(job_id, &error.to_string()).await;
120 let _ = queue.requeue(&dlq, &payload).await;
121 } else {
122 warn!(%error, %job_id, attempt = *count, "ai task failed; retrying");
123 let backoff = backoff_base_secs.max(1) * u64::from(*count as u32);
124 tokio::time::sleep(Duration::from_secs(backoff)).await;
125 let _ = queue.requeue(&topic, &payload).await;
126 }
127 }
128 }
129 }
130 info!(slot, "ai consumer stopped");
131}
132
133/// 启动时收敛上次进程留下的 pending/running 作业。
134///
135/// 队列是 Redis 列表且没有投递确认,无法判断载荷是否还在,因此这里只把
136/// 超额的 running 记录标为失败,避免界面永远显示“进行中”。
137async fn requeue_stale_jobs(service: &AgentService) {
138 use models::ai::ai_job::{self, AiJobStatus};
139 use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
140 let stale = ai_job::Entity::find()
141 .filter(ai_job::Column::Status.eq(AiJobStatus::Running))
142 .all(service.db_write())
143 .await;
144 let rows = match stale {
145 Ok(rows) => rows,
146 Err(error) => {
147 warn!(%error, "stale ai job scan failed");
148 return;
149 }
150 };
151 for row in rows {
152 let mut model: ai_job::ActiveModel = row.into();
153 model.status = Set(AiJobStatus::Pending);
154 model.updated_at = Set(Some(time::OffsetDateTime::now_utc()));
155 if let Err(error) = model.update(service.db_write()).await {
156 warn!(%error, "stale ai job reset failed");
157 }
158 }
159}
160
161async fn run_health(
162 bind: String,
163 port: u16,
164 database: std::sync::Arc<export::AppDatabase>,
165 redis: export::AppRedis,
166 cancel: CancellationToken,
167) {
168 let addr: SocketAddr = match format!("{bind}:{port}").parse() {
169 Ok(addr) => addr,
170 Err(error) => {
171 warn!(%error, "invalid aihost health address");
172 return;
173 }
174 };
175 let listener = match TcpListener::bind(addr).await {
176 Ok(listener) => listener,
177 Err(error) => {
178 warn!(%error, %addr, "aihost health bind failed");
179 return;
180 }
181 };
182 info!(%addr, "aihost health listening");
183 let router = export::health::router_with(Some(database), redis);
184 let _ = axum::serve(listener, router)
185 .with_graceful_shutdown(async move { cancel.cancelled().await })
186 .await;
187}
No newline at end of file
Added app/aihost/Cargo.toml View app/aihost/Cargo.toml
@@ -0,0 +1,23 @@
1[package]
2name = "aihost"
3version.workspace = true
4edition.workspace = true
5
6[[bin]]
7name = "aihost"
8path = "src/main.rs"
9
10[dependencies]
11agent = { workspace = true }
12config = { workspace = true }
13export = { workspace = true }
14models = { workspace = true }
15git = { workspace = true }
16anyhow = { workspace = true }
17axum = { workspace = true }
18tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal", "time", "sync"] }
19tokio-util = { workspace = true }
20tracing = { workspace = true }
21uuid = { workspace = true }
22time = { workspace = true }
23sea-orm = { workspace = true }
No newline at end of file
Modified app/githost/src/main.rs View app/githost/src/main.rs
@@ -28,10 +28,12 @@
2828 .with_kv(Some(Arc::new(state.redis.clone())))
2929 .with_queue(Some(sync_queue))
3030 .with_sync_settings(cfg.git.sync.clone())
31 .with_ci_event_topic(cfg.ci.enabled.then(|| cfg.ci.event_queue_topic.clone()));
31 .with_ci_event_topic(cfg.ci.enabled.then(|| cfg.ci.event_queue_topic.clone()))
32 .with_ai_config(cfg.ai.enabled.then(|| cfg.ai.clone()));
3233 let ssh_svc = git::ssh::service::GitSshService::new(smart_svc.clone(), cfg.git.ssh.clone())
3334 .with_sync_settings(cfg.git.sync.clone())
34 .with_ci_event_topic(cfg.ci.enabled.then(|| cfg.ci.event_queue_topic.clone()));
35 .with_ci_event_topic(cfg.ci.enabled.then(|| cfg.ci.event_queue_topic.clone()))
36 .with_ai_config(cfg.ai.enabled.then(|| cfg.ai.clone()));
3537 let smart_router =
3638 git::smart::handler::router(smart_svc).merge(export::health::router(state.clone()));
3739
Modified app/synchost/src/main.rs View app/synchost/src/main.rs
@@ -5,7 +5,7 @@
55use anyhow::Context;
66use cache::CacheClient;
77use config::scoped::{ScopedConfig, SynchostConfig};
8use git::sync::{NoopWebhookTx, SyncQueue, SyncWorker, WebhookTx};
8use git::sync::{RepoWebhookTx, SyncQueue, SyncWorker, WebhookTx};
99use import::ImportWorker;
1010use search::{SearchService, SearchWorker};
1111use tokio::net::TcpListener;
@@ -83,7 +83,7 @@
8383 }
8484
8585 let queue = SyncQueue::new(redis.clone());
86 let webhook: Arc<dyn WebhookTx> = Arc::new(NoopWebhookTx);
86 let webhook: Arc<dyn WebhookTx> = Arc::new(RepoWebhookTx::new(db.clone()));
8787 let settings = cfg.git.sync.clone();
8888
8989 let worker = SyncWorker::new(
Modified app/webhook/src/main.rs View app/webhook/src/main.rs
@@ -18,11 +18,6 @@
1818 info!("webhook is disabled by config; exiting");
1919 return Ok(());
2020 }
21 if !cfg.ci.enabled {
22 info!("ci is disabled by config; webhook exits without scheduler");
23 return Ok(());
24 }
25
2621 info!("connecting to database / redis / storage");
2722 let state = export::AppState::with_config(cfg.clone().into_app_config())
2823 .await
@@ -32,8 +27,25 @@
3227
3328 let cancel = CancellationToken::new();
3429
35 let consumer = tokio::spawn(consume_events(service.clone(), cancel.clone()));
36 let scheduler = tokio::spawn(run_scheduler(
30 let consumer = if cfg.ci.enabled {
31 Some(tokio::spawn(consume_events(
32 service.clone(),
33 cancel.clone(),
34 )))
35 } else {
36 info!("ci is disabled by config; scheduler not started");
37 None
38 };
39 let scheduler = if cfg.ci.enabled {
40 Some(tokio::spawn(run_scheduler(
41 service.clone(),
42 cfg.ci.scheduler_interval_secs,
43 cancel.clone(),
44 )))
45 } else {
46 None
47 };
48 let outbound = tokio::spawn(run_outbound(
3749 service.clone(),
3850 cfg.ci.scheduler_interval_secs,
3951 cancel.clone(),
@@ -66,8 +78,13 @@
6678 .context("webhook server error")?;
6779
6880 cancel.cancel();
69 let _ = consumer.await;
70 let _ = scheduler.await;
81 if let Some(consumer) = consumer {
82 let _ = consumer.await;
83 }
84 if let Some(scheduler) = scheduler {
85 let _ = scheduler.await;
86 }
87 let _ = outbound.await;
7188 info!("webhook shut down");
7289 Ok(())
7390}
@@ -138,6 +155,22 @@
138155 info!("event consumer stopped");
139156}
140157
158async fn run_outbound(service: CiService, interval_secs: u64, cancel: CancellationToken) {
159 let interval = Duration::from_secs(interval_secs.clamp(1, 30));
160 let mut ticker = tokio::time::interval(interval);
161 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
162 loop {
163 tokio::select! {
164 _ = cancel.cancelled() => break,
165 _ = ticker.tick() => {}
166 }
167 if let Err(error) = service.deliver_pending_repo_webhooks().await {
168 warn!(%error, "outbound webhook delivery failed");
169 }
170 }
171 info!("outbound webhook loop stopped");
172}
173
141174async fn run_scheduler(service: CiService, interval_secs: u64, cancel: CancellationToken) {
142175 let interval = Duration::from_secs(interval_secs.max(1));
143176 let mut ticker = tokio::time::interval(interval);
@@ -162,9 +195,6 @@
162195 }
163196 if let Err(error) = service.finalize_runs().await {
164197 warn!(%error, "run finalization failed");
165 }
166 if let Err(error) = service.deliver_pending_repo_webhooks().await {
167 warn!(%error, "outbound webhook delivery failed");
168198 }
169199 ticks = ticks.wrapping_add(1);
170200 if ticks % 3600 == 0
Modified config/default.toml View config/default.toml
@@ -287,6 +287,9 @@
287287
288288[health.mails]
289289port = 3003
290
291[health.aihost]
292port = 3006
290293[admin]
291294enabled = true
292295bind_addr = "0.0.0.0"
@@ -326,6 +329,35 @@
326329port = 3005
327330public_base_url = ""
328331
332[ai]
333enabled = false
334base_url = ""
335api_key = ""
336model = ""
337review_enabled = true
338summary_enabled = true
339release_notes_enabled = true
340queue_topic = "gitdata:ai"
341dlq_topic = "gitdata:ai:dlq"
342max_attempts = 3
343request_timeout_secs = 120
344context_max_bytes = 524288
345max_findings = 20
346max_output_tokens = 4096
347temperature = 0.2
348summary_commit_limit = 50
349bot_username = "ai-reviewer"
350bot_display_name = "AI Reviewer"
351output_language = "zh-CN"
352
353[aihost]
354enabled = true
355bind_addr = "0.0.0.0"
356port = 3006
357concurrency = 2
358poll_interval_secs = 5
359retry_backoff_base_secs = 2
360
329361[runner]
330362server_url = "http://127.0.0.1:3005"
331363registration_token = ""
Modified lib/admin/src/services/queues.rs View lib/admin/src/services/queues.rs
@@ -1,4 +1,5 @@
11use models::admin::admin_user::AdminRole;
2use models::ai::ai_job;
23use models::chat::webhook_deliveries::{self, WebhookDeliveryStatus};
34use models::ci::workflow_run::{self, WorkflowRunState};
45use models::import::job;
@@ -16,6 +17,20 @@
1617use crate::services::service::AdminService;
1718use crate::share::roles::AdminActor;
1819
20/// 读取 Redis 列表长度作为 AI 队列积压深度。
21async fn ai_depth(topic: &str, service: &AdminService) -> i64 {
22 let mut conn = service.redis().connection_clone();
23 let depth: Result<i64, redis::RedisError> = match &mut conn {
24 export::RedisConnection::Standalone(connection) => {
25 redis::cmd("LLEN").arg(topic).query_async(connection).await
26 }
27 export::RedisConnection::Cluster(connection) => {
28 redis::cmd("LLEN").arg(topic).query_async(connection).await
29 }
30 };
31 depth.unwrap_or_default()
32}
33
1934pub const QUEUE_GIT_SYNC: &str = "git_sync";
2035pub const QUEUE_EMAIL: &str = "email";
2136pub const QUEUE_SEARCH_INDEX: &str = "search_index";
@@ -24,6 +39,7 @@
2439pub const QUEUE_PIPELINE: &str = "pipeline";
2540pub const QUEUE_DELETION: &str = "deletion";
2641pub const QUEUE_SYSTEM_TASKS: &str = "system_tasks";
42pub const QUEUE_AI: &str = "ai";
2743
2844#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
2945pub struct QueueSnapshotDto {
@@ -264,6 +280,38 @@
264280 note: Some("reserved scheduler table".to_string()),
265281 });
266282
283 let ai_pending = ai_job::Entity::find()
284 .filter(ai_job::Column::Status.eq(models::ai::ai_job::AiJobStatus::Pending))
285 .count(self.db().read())
286 .await? as i64;
287 let ai_running = ai_job::Entity::find()
288 .filter(ai_job::Column::Status.eq(models::ai::ai_job::AiJobStatus::Running))
289 .count(self.db().read())
290 .await? as i64;
291 let ai_succeeded = ai_job::Entity::find()
292 .filter(ai_job::Column::Status.eq(models::ai::ai_job::AiJobStatus::Success))
293 .count(self.db().read())
294 .await? as i64;
295 let ai_failed = ai_job::Entity::find()
296 .filter(ai_job::Column::Status.eq(models::ai::ai_job::AiJobStatus::Failed))
297 .count(self.db().read())
298 .await? as i64;
299 snapshots.push(QueueSnapshotDto {
300 name: QUEUE_AI.to_string(),
301 kind: "redis_list".to_string(),
302 enabled: self.config().ai.enabled,
303 depth: Some(ai_depth(&self.config().ai.queue_topic, self).await),
304 queued: ai_pending,
305 running: ai_running,
306 failed: ai_failed,
307 succeeded: ai_succeeded,
308 note: Some(format!(
309 "topic={} model={}",
310 self.config().ai.queue_topic,
311 self.config().ai.model
312 )),
313 });
314
267315 Ok(snapshots)
268316 }
269317
@@ -393,6 +441,28 @@
393441 .await?
394442 .rows_affected
395443 }
444 QUEUE_AI => {
445 ai_job::Entity::update_many()
446 .col_expr(
447 ai_job::Column::Status,
448 Expr::value(models::ai::ai_job::AiJobStatus::Pending),
449 )
450 .col_expr(
451 ai_job::Column::Error,
452 Expr::value(Option::<String>::None),
453 )
454 .col_expr(
455 ai_job::Column::FinishedAt,
456 Expr::value(Option::<time::OffsetDateTime>::None),
457 )
458 .col_expr(ai_job::Column::UpdatedAt, Expr::value(now))
459 .filter(
460 ai_job::Column::Status.eq(models::ai::ai_job::AiJobStatus::Failed),
461 )
462 .exec(self.db().write())
463 .await?
464 .rows_affected
465 }
396466 _ => {
397467 return Err(AdminError::BadRequest(format!(
398468 "queue {queue} does not support requeue"
@@ -497,6 +567,16 @@
497567 .await?
498568 .rows_affected
499569 }
570 QUEUE_AI => {
571 ai_job::Entity::delete_many()
572 .filter(ai_job::Column::Status.is_in([
573 models::ai::ai_job::AiJobStatus::Success,
574 models::ai::ai_job::AiJobStatus::Failed,
575 ]))
576 .exec(self.db().write())
577 .await?
578 .rows_affected
579 }
500580 _ => {
501581 return Err(AdminError::BadRequest(format!(
502582 "queue {queue} does not support purge"
Modified lib/admin/src/services/tasks.rs View lib/admin/src/services/tasks.rs
@@ -1,4 +1,5 @@
11use models::admin::admin_user::AdminRole;
2use models::ai::ai_job;
23use models::chat::webhook_deliveries::{self, WebhookDeliveryStatus};
34use models::ci::workflow_run::{self, WorkflowRunState};
45use models::import::job;
@@ -26,6 +27,7 @@
2627pub const SOURCE_PIPELINE: &str = "pipeline";
2728pub const SOURCE_DELETION: &str = "deletion";
2829pub const SOURCE_SYSTEM_TASKS: &str = "system_tasks";
30pub const SOURCE_AI: &str = "ai";
2931
3032#[derive(Debug, Clone, Deserialize, IntoParams)]
3133pub struct ListTasksQuery {
@@ -139,6 +141,28 @@
139141 }
140142}
141143
144fn ai_state_str(state: models::ai::ai_job::AiJobStatus) -> String {
145 use models::ai::ai_job::AiJobStatus;
146 match state {
147 AiJobStatus::Pending => "pending",
148 AiJobStatus::Running => "running",
149 AiJobStatus::Success => "success",
150 AiJobStatus::Failed => "failed",
151 }
152 .to_string()
153}
154
155fn parse_ai_state(value: &str) -> Option<models::ai::ai_job::AiJobStatus> {
156 use models::ai::ai_job::AiJobStatus;
157 match value {
158 "pending" => Some(AiJobStatus::Pending),
159 "running" => Some(AiJobStatus::Running),
160 "success" => Some(AiJobStatus::Success),
161 "failed" => Some(AiJobStatus::Failed),
162 _ => None,
163 }
164}
165
142166fn parse_task_status(value: &str) -> Option<TaskStatus> {
143167 match value {
144168 "pending" => Some(TaskStatus::Pending),
@@ -168,6 +192,7 @@
168192 Some(SOURCE_PIPELINE) => vec![SOURCE_PIPELINE],
169193 Some(SOURCE_DELETION) => vec![SOURCE_DELETION],
170194 Some(SOURCE_SYSTEM_TASKS) => vec![SOURCE_SYSTEM_TASKS],
195 Some(SOURCE_AI) => vec![SOURCE_AI],
171196 _ => vec![
172197 SOURCE_IMPORT,
173198 SOURCE_SEARCH_INDEX,
@@ -175,6 +200,7 @@
175200 SOURCE_PIPELINE,
176201 SOURCE_DELETION,
177202 SOURCE_SYSTEM_TASKS,
203 SOURCE_AI,
178204 ],
179205 }
180206 }
@@ -411,6 +437,36 @@
411437 });
412438 }
413439 }
440 SOURCE_AI => {
441 let mut select = ai_job::Entity::find().order_by_desc(ai_job::Column::CreatedAt);
442 if let Some(state) = state {
443 select = select.filter(
444 ai_job::Column::Status.eq(
445 parse_ai_state(state).ok_or_else(|| {
446 AdminError::BadRequest("invalid ai job state".to_string())
447 })?,
448 ),
449 );
450 }
451 if let Some(from) = from {
452 select = select.filter(ai_job::Column::CreatedAt.gte(from));
453 }
454 if let Some(to) = to {
455 select = select.filter(ai_job::Column::CreatedAt.lte(to));
456 }
457 for row in select.limit(fetch_limit).all(self.db().read()).await? {
458 tasks.push(TaskDto {
459 id: row.id,
460 source: SOURCE_AI.to_string(),
461 title: format!("{} {}", row.kind.as_str(), row.target_ref.unwrap_or_default()),
462 state: ai_state_str(row.status),
463 created_at: row.created_at,
464 updated_at: row.updated_at,
465 finished_at: row.finished_at,
466 last_error: row.error,
467 });
468 }
469 }
414470 _ => {}
415471 }
416472 Ok(tasks)
@@ -578,6 +634,35 @@
578634 };
579635 (task, detail)
580636 }
637 SOURCE_AI => {
638 let row = ai_job::Entity::find_by_id(id)
639 .one(self.db().read())
640 .await?
641 .ok_or(AdminError::NotFound)?;
642 let detail = json!({
643 "kind": row.kind,
644 "repo_id": row.repo_id,
645 "target_id": row.target_id,
646 "target_ref": row.target_ref,
647 "model": row.model,
648 "attempts": row.attempts,
649 "prompt_tokens": row.prompt_tokens,
650 "completion_tokens": row.completion_tokens,
651 "error": row.error,
652 "started_at": row.started_at,
653 });
654 let task = TaskDto {
655 id: row.id,
656 source: SOURCE_AI.to_string(),
657 title: format!("{} {}", row.kind.as_str(), row.target_ref.unwrap_or_default()),
658 state: ai_state_str(row.status),
659 created_at: row.created_at,
660 updated_at: row.updated_at,
661 finished_at: row.finished_at,
662 last_error: row.error,
663 };
664 (task, detail)
665 }
581666 _ => return Err(AdminError::BadRequest("unknown task source".to_string())),
582667 };
583668 Ok(TaskDetailDto { task, detail })
@@ -700,6 +785,26 @@
700785 am.update(self.db().write()).await?;
701786 "pending".to_string()
702787 }
788 SOURCE_AI => {
789 let row = ai_job::Entity::find_by_id(id)
790 .one(self.db().read())
791 .await?
792 .ok_or(AdminError::NotFound)?;
793 if row.status != models::ai::ai_job::AiJobStatus::Failed {
794 return Err(AdminError::Conflict(
795 "only failed ai jobs can be retried".to_string(),
796 ));
797 }
798 // 重投队列由 aihost 侧扫描 pending 状态完成:这里先复位状态与错误,
799 // 并在同一事务语义下把任务重新入队。
800 let mut am: ai_job::ActiveModel = row.clone().into();
801 am.status = Set(models::ai::ai_job::AiJobStatus::Pending);
802 am.error = Set(None);
803 am.finished_at = Set(None);
804 am.updated_at = Set(Some(now));
805 am.update(self.db().write()).await?;
806 "pending".to_string()
807 }
703808 _ => return Err(AdminError::BadRequest("unknown task source".to_string())),
704809 };
705810 self.audit(
@@ -845,6 +950,28 @@
845950 am.update(self.db().write()).await?;
846951 "failed".to_string()
847952 }
953 SOURCE_AI => {
954 let row = ai_job::Entity::find_by_id(id)
955 .one(self.db().read())
956 .await?
957 .ok_or(AdminError::NotFound)?;
958 if !matches!(
959 row.status,
960 models::ai::ai_job::AiJobStatus::Pending
961 | models::ai::ai_job::AiJobStatus::Running
962 ) {
963 return Err(AdminError::Conflict(
964 "only pending or running ai jobs can be cancelled".to_string(),
965 ));
966 }
967 let mut am: ai_job::ActiveModel = row.into();
968 am.status = Set(models::ai::ai_job::AiJobStatus::Failed);
969 am.error = Set(Some("cancelled by admin".to_string()));
970 am.finished_at = Set(Some(now));
971 am.updated_at = Set(Some(now));
972 am.update(self.db().write()).await?;
973 "failed".to_string()
974 }
848975 _ => return Err(AdminError::BadRequest("unknown task source".to_string())),
849976 };
850977 self.audit(
@@ -925,6 +1052,17 @@
9251052 .ok_or(AdminError::NotFound)?;
9261053 matches!(row.status, TaskStatus::Success | TaskStatus::Failed)
9271054 }
1055 SOURCE_AI => {
1056 let row = ai_job::Entity::find_by_id(id)
1057 .one(self.db().read())
1058 .await?
1059 .ok_or(AdminError::NotFound)?;
1060 matches!(
1061 row.status,
1062 models::ai::ai_job::AiJobStatus::Success
1063 | models::ai::ai_job::AiJobStatus::Failed
1064 )
1065 }
9281066 _ => return Err(AdminError::BadRequest("unknown task source".to_string())),
9291067 };
9301068 if !terminal {
@@ -963,6 +1101,11 @@
9631101 .exec(self.db().write())
9641102 .await?;
9651103 }
1104 SOURCE_AI => {
1105 ai_job::Entity::delete_by_id(id)
1106 .exec(self.db().write())
1107 .await?;
1108 }
9661109 _ => {}
9671110 }
9681111 self.audit(
@@ -1121,7 +1264,7 @@
11211264 .await? as i64,
11221265 failed: 0,
11231266 },
1124 _ => TaskSourceStatDto {
1267 SOURCE_SYSTEM_TASKS => TaskSourceStatDto {
11251268 source: source.to_string(),
11261269 queued: system_tasks::Entity::find()
11271270 .filter(system_tasks::Column::Status.eq(TaskStatus::Pending))
@@ -1140,6 +1283,30 @@
11401283 .count(self.db().read())
11411284 .await? as i64,
11421285 },
1286 SOURCE_AI => {
1287 use models::ai::ai_job::AiJobStatus;
1288 TaskSourceStatDto {
1289 source: source.to_string(),
1290 queued: ai_job::Entity::find()
1291 .filter(ai_job::Column::Status.eq(AiJobStatus::Pending))
1292 .count(self.db().read())
1293 .await? as i64,
1294 running: ai_job::Entity::find()
1295 .filter(ai_job::Column::Status.eq(AiJobStatus::Running))
1296 .count(self.db().read())
1297 .await? as i64,
1298 succeeded: ai_job::Entity::find()
1299 .filter(ai_job::Column::Status.eq(AiJobStatus::Success))
1300 .count(self.db().read())
1301 .await? as i64,
1302 failed: ai_job::Entity::find()
1303 .filter(ai_job::Column::Status.eq(AiJobStatus::Failed))
1304 .count(self.db().read())
1305 .await? as i64,
1306 }
1307 }
1308 // `&str` 常量模式不构成穷尽匹配;requested_sources 只会产出已知来源。
1309 _ => continue,
11431310 };
11441311 sources.push(stat);
11451312 }
@@ -1169,6 +1336,7 @@
11691336 SOURCE_PIPELINE,
11701337 SOURCE_DELETION,
11711338 SOURCE_SYSTEM_TASKS,
1339 SOURCE_AI,
11721340 ]
11731341 .contains(&source)
11741342 {
Added lib/agent/src/bot.rs View lib/agent/src/bot.rs
@@ -0,0 +1,63 @@
1use config::ai::AiConfig;
2use models::users::user::{self, ProfileVisibility};
3use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
4use time::OffsetDateTime;
5use uuid::Uuid;
6
7/// 解析(必要时创建)AI 系统用户。
8///
9/// 该用户以 `bot=true, system=true` 标记,无口令、不可登录,仅用于承载
10/// AI 评审与评论的作者身份。
11pub async fn resolve_bot_user(db: &DatabaseConnection, cfg: &AiConfig) -> Result<Uuid, sea_orm::DbErr> {
12 let username = cfg.bot_username.trim();
13 let lower = username.to_lowercase();
14 if let Some(row) = user::Entity::find()
15 .filter(user::Column::LowerName.eq(&lower))
16 .one(db)
17 .await?
18 {
19 return Ok(row.uid);
20 }
21 let uid = Uuid::now_v7();
22 let now = OffsetDateTime::now_utc();
23 let created = user::ActiveModel {
24 uid: Set(uid),
25 username: Set(username.to_string()),
26 lower_name: Set(lower.clone()),
27 display_name: Set(cfg.bot_display_name.trim().to_string()),
28 avatar_url: Set(String::new()),
29 is_active: Set(true),
30 is_restricted: Set(false),
31 prohibit_login: Set(true),
32 visibility: Set(ProfileVisibility::Public),
33 bot: Set(true),
34 system: Set(true),
35 last_login_at: Set(None),
36 last_login_ip_hash: Set(None),
37 version: Set(1),
38 passwd_hash: Set(None),
39 password_last_changed_at: Set(None),
40 passwd_version: Set(None),
41 totp_encrypted_secret: Set(None),
42 totp_enabled: Set(false),
43 totp_last_used_at: Set(None),
44 totp_last_counter: Set(None),
45 timezone: Set(None),
46 locale: Set(None),
47 language: Set(None),
48 created_at: Set(now),
49 updated_at: Set(now),
50 }
51 .insert(db)
52 .await;
53 match created {
54 Ok(row) => Ok(row.uid),
55 // 并发创建时另一进程已插入,回查即可。
56 Err(_) => user::Entity::find()
57 .filter(user::Column::LowerName.eq(&lower))
58 .one(db)
59 .await?
60 .map(|row| row.uid)
61 .ok_or_else(|| sea_orm::DbErr::RecordNotFound("ai bot user".to_string())),
62 }
63}
No newline at end of file
Added lib/agent/src/client.rs View lib/agent/src/client.rs
@@ -0,0 +1,250 @@
1use std::time::Duration;
2
3use config::ai::AiConfig;
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7#[derive(Debug, Error)]
8pub enum AgentError {
9 #[error("ai is not configured: {0}")]
10 NotConfigured(String),
11 #[error("ai request failed: {0}")]
12 Transport(String),
13 #[error("ai returned status {status}: {body}")]
14 Status { status: u16, body: String },
15 #[error("ai response could not be parsed: {0}")]
16 Decode(String),
17 #[error("invalid ai task: {0}")]
18 InvalidTask(String),
19}
20
21pub type AgentResult<T> = Result<T, AgentError>;
22
23#[derive(Debug, Clone, Serialize)]
24struct ChatRequest {
25 model: String,
26 messages: Vec<ChatMessage>,
27 temperature: f32,
28 max_tokens: u32,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 response_format: Option<ResponseFormat>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34struct ChatMessage {
35 role: String,
36 content: String,
37}
38
39#[derive(Debug, Clone, Serialize)]
40struct ResponseFormat {
41 #[serde(rename = "type")]
42 kind: String,
43}
44
45#[derive(Debug, Clone, Deserialize)]
46struct ChatResponse {
47 #[serde(default)]
48 choices: Vec<ChatChoice>,
49 #[serde(default)]
50 usage: Option<ChatUsage>,
51}
52
53#[derive(Debug, Clone, Deserialize)]
54struct ChatChoice {
55 message: Option<ChatChoiceMessage>,
56}
57
58#[derive(Debug, Clone, Deserialize)]
59struct ChatChoiceMessage {
60 #[serde(default)]
61 content: Option<String>,
62}
63
64#[derive(Debug, Clone, Deserialize)]
65struct ChatUsage {
66 #[serde(default)]
67 prompt_tokens: Option<i32>,
68 #[serde(default)]
69 completion_tokens: Option<i32>,
70}
71
72/// 一次模型调用的完整产物:正文、用量与原始响应体。
73#[derive(Debug, Clone)]
74pub struct AgentCompletion {
75 pub content: String,
76 pub prompt_tokens: Option<i32>,
77 pub completion_tokens: Option<i32>,
78 pub raw: serde_json::Value,
79}
80
81/// OpenAI 兼容的单一模型客户端。
82///
83/// 平台内所有 AI 能力都经由本客户端访问 `[ai].model`,不存在按功能切换模型的入口。
84#[derive(Clone)]
85pub struct AgentClient {
86 http: reqwest::Client,
87 endpoint: String,
88 api_key: String,
89 model: String,
90 temperature: f32,
91 max_output_tokens: u32,
92 max_attempts: i32,
93}
94
95impl AgentClient {
96 pub fn new(cfg: &AiConfig) -> AgentResult<Self> {
97 if cfg.base_url.trim().is_empty() || cfg.model.trim().is_empty() {
98 return Err(AgentError::NotConfigured(
99 "base_url and model are required".to_string(),
100 ));
101 }
102 let http = reqwest::Client::builder()
103 .timeout(Duration::from_secs(cfg.request_timeout_secs.max(1)))
104 .build()
105 .map_err(|e| AgentError::Transport(e.to_string()))?;
106 Ok(Self {
107 http,
108 endpoint: cfg.endpoint(),
109 api_key: cfg.api_key.trim().to_string(),
110 model: cfg.model.trim().to_string(),
111 temperature: cfg.temperature,
112 max_output_tokens: cfg.max_output_tokens,
113 max_attempts: cfg.max_attempts.max(1),
114 })
115 }
116
117 pub fn model(&self) -> &str {
118 &self.model
119 }
120
121 /// 发送一次 chat completion 请求,失败时按指数退避重试。
122 pub async fn complete(
123 &self,
124 system_prompt: &str,
125 user_prompt: &str,
126 json_mode: bool,
127 ) -> AgentResult<AgentCompletion> {
128 let body = ChatRequest {
129 model: self.model.clone(),
130 messages: vec![
131 ChatMessage {
132 role: "system".to_string(),
133 content: system_prompt.to_string(),
134 },
135 ChatMessage {
136 role: "user".to_string(),
137 content: user_prompt.to_string(),
138 },
139 ],
140 temperature: self.temperature,
141 max_tokens: self.max_output_tokens,
142 response_format: json_mode.then(|| ResponseFormat {
143 kind: "json_object".to_string(),
144 }),
145 };
146
147 let mut last_error: Option<AgentError> = None;
148 for attempt in 0..self.max_attempts {
149 if attempt > 0 {
150 let backoff = Duration::from_millis(500 * (1u64 << attempt.min(5)));
151 tokio::time::sleep(backoff).await;
152 }
153 match self.send_once(&body).await {
154 Ok(completion) => return Ok(completion),
155 Err(error) => {
156 let retryable = matches!(
157 error,
158 AgentError::Transport(_) | AgentError::Status { .. }
159 );
160 tracing::warn!(%error, attempt = attempt + 1, "ai completion attempt failed");
161 last_error = Some(error);
162 if !retryable {
163 break;
164 }
165 }
166 }
167 }
168 Err(last_error.unwrap_or_else(|| AgentError::Transport("no attempt made".to_string())))
169 }
170
171 async fn send_once(&self, body: &ChatRequest) -> AgentResult<AgentCompletion> {
172 let mut request = self
173 .http
174 .post(&self.endpoint)
175 .header(reqwest::header::CONTENT_TYPE, "application/json")
176 .json(body);
177 if !self.api_key.is_empty() {
178 request = request.bearer_auth(&self.api_key);
179 }
180 let response = request
181 .send()
182 .await
183 .map_err(|e| AgentError::Transport(e.to_string()))?;
184 let status = response.status();
185 let text = response
186 .text()
187 .await
188 .map_err(|e| AgentError::Transport(e.to_string()))?;
189 if !status.is_success() {
190 return Err(AgentError::Status {
191 status: status.as_u16(),
192 body: truncate(&text, 2048),
193 });
194 }
195 let parsed: ChatResponse = serde_json::from_str(&text)
196 .map_err(|e| AgentError::Decode(format!("{e}: {}", truncate(&text, 512))))?;
197 let content = parsed
198 .choices
199 .first()
200 .and_then(|choice| choice.message.as_ref())
201 .and_then(|message| message.content.clone())
202 .ok_or_else(|| AgentError::Decode("response has no message content".to_string()))?;
203 let raw: serde_json::Value = serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
204 Ok(AgentCompletion {
205 content,
206 prompt_tokens: parsed.usage.as_ref().and_then(|u| u.prompt_tokens),
207 completion_tokens: parsed.usage.as_ref().and_then(|u| u.completion_tokens),
208 raw,
209 })
210 }
211}
212
213pub(crate) fn truncate(value: &str, max: usize) -> String {
214 if value.chars().count() <= max {
215 return value.to_string();
216 }
217 let mut out: String = value.chars().take(max).collect();
218 out.push_str("…(truncated)");
219 out
220}
221
222/// 从模型输出中提取 JSON 对象;容忍围栏代码块与前后说明文字。
223pub fn extract_json(content: &str) -> AgentResult<serde_json::Value> {
224 let direct = content.trim();
225 if let Ok(value) = serde_json::from_str::<serde_json::Value>(direct) {
226 return Ok(value);
227 }
228 if let Some(inner) = fenced_json(direct)
229 && let Ok(value) = serde_json::from_str::<serde_json::Value>(&inner)
230 {
231 return Ok(value);
232 }
233 if let (Some(start), Some(end)) = (direct.find('{'), direct.rfind('}'))
234 && start < end
235 && let Ok(value) = serde_json::from_str::<serde_json::Value>(&direct[start..=end])
236 {
237 return Ok(value);
238 }
239 Err(AgentError::Decode(
240 "model output did not contain JSON".to_string(),
241 ))
242}
243
244fn fenced_json(value: &str) -> Option<String> {
245 let start = value.find("```")?;
246 let rest = &value[start + 3..];
247 let rest = rest.strip_prefix("json").unwrap_or(rest);
248 let end = rest.find("```")?;
249 Some(rest[..end].trim().to_string())
250}
No newline at end of file
Added lib/agent/src/context.rs View lib/agent/src/context.rs
@@ -0,0 +1,134 @@
1use git::context::facade::GitContext;
2
3/// 组装好的模型上下文:提示词正文 + 是否发生截断。
4#[derive(Debug, Clone)]
5pub struct BuiltContext {
6 pub text: String,
7 pub truncated: bool,
8}
9
10const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
11
12impl super::AgentService {
13 /// PR 评审上下文:merge-base..head 的三点差异。
14 pub async fn build_pull_diff(
15 &self,
16 space_name: &str,
17 repo_name: &str,
18 head_sha: &str,
19 base_ref: &str,
20 ) -> Result<(BuiltContext, String), git::share::errors::GitError> {
21 let ctx = self.git.context(space_name.to_string(), repo_name.to_string()).await?;
22 let plan = self
23 .git
24 .merge_plan(&ctx, &format!("refs/heads/{base_ref}"), head_sha)
25 .await?;
26 let from = plan
27 .merge_base
28 .clone()
29 .unwrap_or_else(|| plan.base_sha.clone());
30 let diff = self
31 .git
32 .diff_raw(
33 space_name.to_string(),
34 repo_name.to_string(),
35 from.clone(),
36 plan.head_sha.clone(),
37 )
38 .await?;
39 let label = format!("{}..{}", short(&from), short(&plan.head_sha));
40 Ok((self.truncate_diff(diff), label))
41 }
42
43 /// Commit 评审上下文:父提交..该提交的差异。
44 pub async fn build_commit_diff(
45 &self,
46 space_name: &str,
47 repo_name: &str,
48 commit_sha: &str,
49 ) -> Result<(BuiltContext, String), git::share::errors::GitError> {
50 let ctx = self.git.context(space_name.to_string(), repo_name.to_string()).await?;
51 let baby = self.git.baby(&ctx);
52 let info = baby
53 .get_commit(commit_sha)
54 .await
55 .map_err(git::share::errors::map_baby)?;
56 let parent = info
57 .parents
58 .first()
59 .map(|p| p.to_string())
60 .unwrap_or_else(|| EMPTY_TREE.to_string());
61 let sha = info.id.to_string();
62 let diff = self
63 .git
64 .diff_raw(space_name.to_string(), repo_name.to_string(), parent.clone(), sha.clone())
65 .await?;
66 let label = format!("{}..{}", short(&parent), short(&sha));
67 Ok((self.truncate_diff(diff), label))
68 }
69
70 /// 提交列表(评审用不到,但总结/release 复用同一格式化)。
71 pub(crate) fn format_commits(commits: &[git::service::merge::MergeCommitDto]) -> String {
72 let mut out = String::new();
73 for commit in commits {
74 out.push_str(&format!(
75 "- {} {} ({}, {})\n",
76 short(&commit.sha),
77 commit.subject.trim(),
78 commit.author_name,
79 commit.author_at
80 ));
81 }
82 out
83 }
84
85 /// 按字节预算截断差异:超限时逐文件裁剪并标记 truncated。
86 pub(crate) fn truncate_diff(&self, diff: String) -> BuiltContext {
87 let limit = self.config().ai.context_max_bytes;
88 if diff.len() <= limit {
89 return BuiltContext {
90 text: diff,
91 truncated: false,
92 };
93 }
94 let mut text = String::with_capacity(limit);
95 let mut truncated = false;
96 for file in split_diff_files(&diff) {
97 if text.len() + file.len() > limit {
98 truncated = true;
99 break;
100 }
101 text.push_str(&file);
102 }
103 if text.is_empty() {
104 // 单个文件本身就超限:按字符边界硬截断。
105 text = diff.chars().take(limit).collect();
106 truncated = true;
107 }
108 BuiltContext { text, truncated }
109 }
110}
111
112/// 按 `diff --git` 切分,保留每段完整补丁文本。
113pub fn split_diff_files(diff: &str) -> Vec<String> {
114 let mut files: Vec<String> = Vec::new();
115 let mut current = String::new();
116 for line in diff.split_inclusive('\n') {
117 if line.starts_with("diff --git ") && !current.is_empty() {
118 files.push(std::mem::take(&mut current));
119 }
120 current.push_str(line);
121 }
122 if !current.is_empty() {
123 files.push(current);
124 }
125 files
126}
127
128pub(crate) fn short(sha: &str) -> String {
129 sha.chars().take(8).collect()
130}
131
132/// 让 `GitContext` 在本 crate 内可用(避免调用方各自引入 git crate)。
133pub type RepoContext = GitContext;
134
Added lib/agent/src/diff.rs View lib/agent/src/diff.rs
@@ -0,0 +1,151 @@
1use std::collections::HashMap;
2
3use crate::prompt::ReviewFinding;
4
5/// 一个补丁文件里可被评论的行号集合(左右两侧分开记录)。
6#[derive(Debug, Default, Clone)]
7pub struct FileLines {
8 /// 新增侧(+ 与上下文行)的行号。
9 pub right: Vec<i32>,
10 /// 删除侧(- 行)的行号。
11 pub left: Vec<i32>,
12}
13
14/// 解析 unified diff,汇总每个文件的可评论行号。
15///
16/// 与前端 `splitDiffFiles`/`parseHunks` 的语义保持一致:
17/// 头部行(`+++`/`---`)不计入,`\ No newline at end of file` 不推进行号。
18pub fn index_diff_lines(diff: &str) -> HashMap<String, FileLines> {
19 let mut out: HashMap<String, FileLines> = HashMap::new();
20 let mut current: Option<String> = None;
21 let mut old_no: i32 = 0;
22 let mut new_no: i32 = 0;
23
24 for line in diff.split('\n') {
25 if let Some(rest) = line.strip_prefix("diff --git ") {
26 current = parse_diff_git_target(rest);
27 if let Some(path) = current.clone() {
28 out.entry(path).or_default();
29 }
30 old_no = 0;
31 new_no = 0;
32 continue;
33 }
34 let Some(path) = current.clone() else {
35 continue;
36 };
37 if let Some(rest) = line.strip_prefix("+++ ") {
38 let target = rest.trim();
39 if target != "/dev/null" {
40 let path = strip_ab(target);
41 out.entry(path.clone()).or_default();
42 current = Some(path);
43 }
44 continue;
45 }
46 if line.starts_with("--- ") {
47 continue;
48 }
49 if line.starts_with("@@") {
50 if let Some((old_start, new_start)) = parse_hunk_header(line) {
51 old_no = old_start;
52 new_no = new_start;
53 }
54 continue;
55 }
56 if line.starts_with("\\ No newline") {
57 continue;
58 }
59 if line.starts_with('+') {
60 out.entry(path.clone()).or_default().right.push(new_no);
61 new_no += 1;
62 } else if line.starts_with('-') {
63 out.entry(path.clone()).or_default().left.push(old_no);
64 old_no += 1;
65 } else if line.starts_with(' ') || line.is_empty() {
66 out.entry(path.clone()).or_default().right.push(new_no);
67 old_no += 1;
68 new_no += 1;
69 }
70 }
71 out
72}
73
74fn parse_diff_git_target(rest: &str) -> Option<String> {
75 let trimmed = rest.trim();
76 let (_, b) = trimmed.split_once(" b/")?;
77 Some(unquote(b.trim()))
78}
79
80fn strip_ab(value: &str) -> String {
81 let unquoted = unquote(value);
82 unquoted
83 .strip_prefix("a/")
84 .or_else(|| unquoted.strip_prefix("b/"))
85 .unwrap_or(&unquoted)
86 .to_string()
87}
88
89fn unquote(value: &str) -> String {
90 let trimmed = value.trim();
91 if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
92 return trimmed[1..trimmed.len() - 1].to_string();
93 }
94 trimmed.to_string()
95}
96
97fn parse_hunk_header(line: &str) -> Option<(i32, i32)> {
98 let body = line.strip_prefix("@@")?;
99 let body = body.split("@@").next()?;
100 let mut old_start = 0;
101 let mut new_start = 0;
102 for token in body.split_whitespace() {
103 if let Some(rest) = token.strip_prefix('-') {
104 old_start = rest.split(',').next()?.parse().ok()?;
105 } else if let Some(rest) = token.strip_prefix('+') {
106 new_start = rest.split(',').next()?.parse().ok()?;
107 }
108 }
109 Some((old_start, new_start))
110}
111
112/// 过滤掉落点不在本次变更行上的 findings,返回(有效 findings, 丢弃条数)。
113pub fn validate_findings(
114 diff: &str,
115 findings: Vec<ReviewFinding>,
116 max_findings: usize,
117) -> (Vec<ReviewFinding>, usize) {
118 let index = index_diff_lines(diff);
119 let mut accepted = Vec::new();
120 let mut dropped = 0usize;
121 for mut finding in findings {
122 let path = finding.path.trim().trim_start_matches("./").to_string();
123 let side = match finding.side.as_deref() {
124 Some("left") => "left",
125 _ => "right",
126 };
127 let Some(lines) = index.get(&path) else {
128 dropped += 1;
129 continue;
130 };
131 let hit = if side == "left" {
132 lines.left.contains(&finding.line)
133 } else {
134 lines.right.contains(&finding.line)
135 };
136 if !hit {
137 dropped += 1;
138 continue;
139 }
140 finding.path = path;
141 finding.side = Some(side.to_string());
142 if finding.severity.is_none() {
143 finding.severity = Some("minor".to_string());
144 }
145 accepted.push(finding);
146 if accepted.len() >= max_findings {
147 break;
148 }
149 }
150 (accepted, dropped)
151}
No newline at end of file
Added lib/agent/src/language_pref.rs View lib/agent/src/language_pref.rs
@@ -0,0 +1,59 @@
1//! 触发者语言解析。
2//!
3//! AI 输出的语言优先跟随「谁触发了这次任务」:推送/开 PR 的人、仓库所属空间
4//! 的所有者,最后回退到 `[ai].output_language`。这样同一个仓库在不同语言的
5//! 协作者手里会得到各自语言的评审,而不是被钉死在部署时的单一语言。
6
7use uuid::Uuid;
8
9impl super::AgentService {
10 /// 按「触发者 → 空间所有者 → 配置缺省」的顺序解析输出语言。
11 pub(crate) async fn resolve_language(
12 &self,
13 actor_id: Option<Uuid>,
14 fallback: Option<&str>,
15 repo_id: Uuid,
16 ) -> String {
17 let configured = &self.config().ai.output_language;
18 // 任务自带语言优先(例如手动触发时由接口解析好)。
19 if let Some(lang) = fallback.filter(|value| !value.trim().is_empty()) {
20 return crate::languages::normalize(lang);
21 }
22 if let Some(uid) = actor_id
23 && let Some(lang) = self.user_language(uid).await
24 {
25 return lang;
26 }
27 if let Some(uid) = self.repo_owner(repo_id).await
28 && let Some(lang) = self.user_language(uid).await
29 {
30 return lang;
31 }
32 crate::languages::normalize(configured)
33 }
34
35 /// 读取用户偏好语言;`language` 优先,其次 `locale`。
36 async fn user_language(&self, uid: Uuid) -> Option<String> {
37 use models::users::user;
38 use sea_orm::EntityTrait;
39 let row = user::Entity::find_by_id(uid).one(self.db_read()).await.ok()??;
40 let raw = row
41 .language
42 .as_deref()
43 .filter(|value| !value.trim().is_empty())
44 .or_else(|| row.locale.as_deref().filter(|value| !value.trim().is_empty()))?;
45 let normalized = crate::languages::normalize(raw);
46 (!normalized.is_empty() && normalized != crate::languages::AUTO).then_some(normalized)
47 }
48
49 /// 仓库所属空间的所有者 uid。
50 async fn repo_owner(&self, repo_id: Uuid) -> Option<Uuid> {
51 use models::repo::repos;
52 use sea_orm::EntityTrait;
53 let repo = repos::Entity::find_by_id(repo_id).one(self.db_read()).await.ok()??;
54 git::share::access::space_for_dir(self.db_read(), repo.dir_id)
55 .await
56 .ok()?
57 .map(|space| space.owner_id)
58 }
59}
No newline at end of file
Added lib/agent/src/languages.rs View lib/agent/src/languages.rs
@@ -0,0 +1,2 @@
1//! 语言注册表由 `ci_protocol::ai_languages` 提供(生产/消费双方共用)。
2pub use ci_protocol::ai_languages::*;
Added lib/agent/src/lib.rs View lib/agent/src/lib.rs
@@ -0,0 +1,28 @@
1//! AI 运行核心。
2//!
3//! 本 crate 是全平台唯一发起 LLM 请求的地方:一个 OpenAI 兼容客户端
4//! ([`client::AgentClient`])访问 `[ai].model` 指定的**单一模型**,一组任务
5//! 执行器([`AgentService`])把模型输出落库为评审记录、仓库总结与 release 文案。
6//!
7//! 队列协议定义在 [`ci_protocol::ai::AgentTask`],生产侧投递入口是
8//! `git::sync::ai_task::submit_ai_task`,消费侧为 `app/aihost`。
9
10pub mod bot;
11pub mod client;
12pub mod context;
13pub mod diff;
14pub mod languages;
15mod language_pref;
16pub mod notifications;
17pub mod notify;
18pub mod prompt;
19pub mod queue;
20mod service;
21
22pub use client::{AgentClient, AgentCompletion, AgentError, AgentResult, extract_json};
23pub use languages::{display_name as language_display_name, normalize as normalize_language, supported_codes as supported_languages};
24pub use prompt::{ReleaseNotesOutput, ReviewFinding, ReviewOutput, SummaryOutput};
25pub use service::AgentService;
26
27/// 便捷别名:AI 队列任务载荷。
28pub use ci_protocol::ai::AgentTask;
No newline at end of file
Added lib/agent/src/notifications.rs View lib/agent/src/notifications.rs
@@ -0,0 +1,50 @@
1//! 通知文案的多语言措辞。
2//!
3//! 只覆盖通知标题/正文这类固定短语;真正的内容语言由模型提示词控制。
4//! 未收录的语言回退到英语。
5
6/// 「AI 评审完成 · #12」
7pub fn review_title(language: &str, number: i32) -> String {
8 match tag(language).as_str() {
9 "zh" => format!("AI 评审完成 · #{number}"),
10 "ja" => format!("AI レビュー完了 · #{number}"),
11 "ko" => format!("AI 리뷰 완료 · #{number}"),
12 "fr" => format!("Revue IA terminée · #{number}"),
13 "de" => format!("KI-Review abgeschlossen · #{number}"),
14 "es" => format!("Revisión de IA completada · #{number}"),
15 "pt" => format!("Revisão de IA concluída · #{number}"),
16 "it" => format!("Revisione IA completata · #{number}"),
17 "ru" => format!("ИИ-ревью завершено · #{number}"),
18 _ => format!("AI review completed · #{number}"),
19 }
20}
21
22/// 「3 条行级建议:<summary>」/「<summary>」
23pub fn review_body(language: &str, findings: usize, summary: &str) -> String {
24 let summary = summary.trim();
25 if findings == 0 {
26 return summary.to_string();
27 }
28 match tag(language).as_str() {
29 "zh" => format!("{findings} 条行级建议:{summary}"),
30 "ja" => format!("行コメント {findings} 件:{summary}"),
31 "ko" => format!("인라인 제안 {findings}건: {summary}"),
32 "fr" => format!("{findings} suggestion(s) en ligne : {summary}"),
33 "de" => format!("{findings} Inline-Hinweis(e): {summary}"),
34 "es" => format!("{findings} sugerencia(s) en línea: {summary}"),
35 "pt" => format!("{findings} sugestão(ões) em linha: {summary}"),
36 "it" => format!("{findings} suggerimento/i in linea: {summary}"),
37 "ru" => format!("{findings} замечаний к строкам: {summary}"),
38 _ => format!("{findings} inline suggestion(s): {summary}"),
39 }
40}
41
42/// 把 BCP-47 代码折叠到语族标签。
43fn tag(language: &str) -> String {
44 let normalized = ci_protocol::ai_languages::normalize(language);
45 normalized
46 .split('-')
47 .next()
48 .unwrap_or("en")
49 .to_string()
50}
No newline at end of file
Added lib/agent/src/notify.rs View lib/agent/src/notify.rs
@@ -0,0 +1,71 @@
1use export::notify::publish_notification;
2use models::pull_request::{pull_request_reviewer, pull_requests};
3use models::users::user_notifications::{ActiveModel as NotificationModel, NotificationType};
4use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
5use time::OffsetDateTime;
6use uuid::Uuid;
7
8impl super::AgentService {
9 /// 评审完成后通知 PR 作者与当前待评审人。
10 ///
11 /// 通知仅写库 + Redis 推送,前端按既有轮询/WS 机制刷新即可看到结果。
12 pub(crate) async fn notify_pull_ai(
13 &self,
14 pull: &pull_requests::Model,
15 bot: Uuid,
16 language: &str,
17 summary: &str,
18 findings: usize,
19 ) {
20 let mut recipients: Vec<Uuid> = vec![pull.author_id];
21 match pull_request_reviewer::Entity::find()
22 .filter(pull_request_reviewer::Column::PullRequestId.eq(pull.id))
23 .all(self.db_read())
24 .await
25 {
26 Ok(rows) => {
27 for row in rows {
28 if !recipients.contains(&row.user_id) {
29 recipients.push(row.user_id);
30 }
31 }
32 }
33 Err(error) => tracing::warn!(%error, "ai review recipient lookup failed"),
34 }
35 recipients.retain(|uid| *uid != bot);
36 if recipients.is_empty() {
37 return;
38 }
39
40 let title = crate::notifications::review_title(language, pull.autoincrement_id);
41 let body = crate::notifications::review_body(language, findings, summary);
42 let data = serde_json::json!({
43 "kind": "ai_review",
44 "pull_request_id": pull.id,
45 "repo_id": pull.repo_id,
46 "findings": findings,
47 });
48 let now = OffsetDateTime::now_utc();
49 for uid in recipients {
50 let inserted = NotificationModel {
51 id: Set(Uuid::now_v7()),
52 user_id: Set(uid),
53 notification_type: Set(NotificationType::System),
54 title: Set(title.clone()),
55 body: Set(Some(body.clone())),
56 data_json: Set(Some(data.clone())),
57 actor_uid: Set(Some(bot)),
58 read_at: Set(None),
59 created_at: Set(now),
60 }
61 .insert(self.db_write())
62 .await;
63 match inserted {
64 Ok(row) => {
65 publish_notification(&self.state.redis, uid, row.to_event_value()).await;
66 }
67 Err(error) => tracing::warn!(%error, %uid, "ai review notification insert failed"),
68 }
69 }
70 }
71}
No newline at end of file
Added lib/agent/src/prompt.rs View lib/agent/src/prompt.rs
@@ -0,0 +1,289 @@
1use serde::{Deserialize, Serialize};
2
3/// AI 返回的一处行级发现(与落库结构同源)。
4pub use models::ai::ai_review_result::AiFinding as ReviewFinding;
5
6/// 评审类任务的模型输出契约。
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ReviewOutput {
9 #[serde(default)]
10 pub summary: String,
11 #[serde(default)]
12 pub verdict: Option<String>,
13 #[serde(default)]
14 pub findings: Vec<ReviewFinding>,
15}
16
17/// 仓库总结任务的模型输出契约。
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct SummaryOutput {
20 #[serde(default)]
21 pub content: String,
22 #[serde(default)]
23 pub highlights: Vec<String>,
24 #[serde(default)]
25 pub risks: Vec<String>,
26}
27
28/// Release 文案任务的模型输出契约。
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ReleaseNotesOutput {
31 #[serde(default)]
32 pub title: String,
33 #[serde(default)]
34 pub content: String,
35}
36
37/// 语言相关的提示词上下文。
38///
39/// - `output_language`:自然语言的书写语言(BCP-47 或 `auto`)。支持中、英、日、
40/// 韩、法、德、西等主流语言,未知但合法的代码会原样交给模型。
41/// - `detected_languages`:本次变更涉及的**编程**语言(由补丁文件扩展名推断),
42/// 让模型按各语言的惯例评审,而不是用一门语言的风格去评判另一门。
43#[derive(Debug, Clone, Default)]
44pub struct PromptContext {
45 pub output_language: String,
46 pub detected_languages: Vec<String>,
47}
48
49impl PromptContext {
50 pub fn new(output_language: &str, detected_languages: Vec<String>) -> Self {
51 Self {
52 output_language: output_language.trim().to_string(),
53 detected_languages,
54 }
55 }
56
57 /// 自然语言指令:输出语言 + 多语言评审纪律。
58 fn language_clause(&self) -> String {
59 let mut clause = String::new();
60 clause.push_str(&self.output_language_clause());
61 clause.push_str(
62 "若变更文件使用了不同编程语言,按各自语言的惯例与常见陷阱分别评审:\
63 例如 Rust 关注所有权/生命周期/panic,C/C++ 关注内存安全与未定义行为,\
64 Python 关注可变默认参数与异常处理,JS/TS 关注 Promise/类型收窄,\
65 Shell 关注引号与错误退出,SQL 关注注入与索引。\
66 不要用某一门语言的风格去评判另一门语言。\n",
67 );
68 if !self.detected_languages.is_empty() {
69 clause.push_str(&format!(
70 "本次变更涉及的编程语言:{}。\n",
71 self.detected_languages.join("、")
72 ));
73 }
74 clause
75 }
76
77 /// 输出语言指令。`auto` 表示沿用被评审内容的语言。
78 fn output_language_clause(&self) -> String {
79 if self.output_language.is_empty() {
80 return String::new();
81 }
82 if crate::languages::is_auto(&self.output_language) {
83 return "自然语言部分沿用代码注释与提交信息的语言;若两者不一致,\
84 使用代码注释的语言。代码标识符、命令行、路径、API 名称保持原文。\n"
85 .to_string();
86 }
87 let name = crate::languages::display_name(&self.output_language);
88 format!(
89 "全部自然语言文本(summary、body、content、highlights、risks)必须使用 {name} 书写;\
90 使用该语言的自然术语与语序,不要中英夹杂。\
91 代码标识符、命令行、路径、API 名称保持原文,不要翻译。\n"
92 )
93 }
94}
95
96/// 评审系统提示词。
97pub fn review_system(ctx: &PromptContext) -> String {
98 format!(
99 "你是资深代码评审员。只依据给定的变更内容评审,不臆测未出现的代码。\
100 只报告会影响正确性、安全性、性能或可维护性的实质问题,忽略纯风格偏好。\
101 行号必须取自补丁中出现的行:新增/上下文行用 side=\"right\",删除行用 side=\"left\"。\n{lang}\
102 按以下 JSON 结构输出,不要输出任何解释文字:\
103 {{\"summary\":\"整体结论\",\"verdict\":\"commented 或 changes_requested\",\
104 \"findings\":[{{\"path\":\"文件路径\",\"line\":123,\"side\":\"right\",\
105 \"severity\":\"critical|major|minor\",\"body\":\"问题与建议\"}}]}}。\
106 没有任何问题时 findings 为空数组、verdict 为 commented。",
107 lang = ctx.language_clause()
108 )
109}
110
111/// 仓库总结系统提示词。
112pub fn summary_system(ctx: &PromptContext) -> String {
113 format!(
114 "你是技术负责人,为团队撰写仓库近期更新的总结。\
115 基于给定的提交列表与变更统计归纳主题、亮点与风险,不复述提交消息原文。\n{lang}\
116 按以下 JSON 结构输出,不要输出任何解释文字:\
117 {{\"content\":\"Markdown 总结\",\"highlights\":[\"亮点\"],\"risks\":[\"风险\"]}}。",
118 lang = ctx.language_clause()
119 )
120}
121
122/// Release 文案系统提示词。
123pub fn release_system(ctx: &PromptContext) -> String {
124 format!(
125 "你是发布负责人,为版本发布撰写 Release Notes。\
126 按功能、修复、其他三类归纳给定区间内的提交,用面向使用者的语言描述价值。\
127 标题使用版本号本身,不编造未出现的变更。\n{lang}\
128 按以下 JSON 结构输出,不要输出任何解释文字:\
129 {{\"title\":\"vX.Y.Z\",\"content\":\"Markdown 正文\"}}。",
130 lang = ctx.language_clause()
131 )
132}
133
134/// 组装一次评审请求的用户消息。
135pub fn review_user_prompt(
136 repo_name: &str,
137 context_label: &str,
138 diff: &str,
139 truncated: bool,
140 max_findings: usize,
141) -> String {
142 let note = if truncated {
143 "\n注意:本次变更内容因体积限制被截断,请只评审已给出的部分并说明存在截断。\n"
144 } else {
145 ""
146 };
147 // 逐文件标注语言,避免模型把多语言仓库当成单一语言评审。
148 let annotations = file_language_annotations(diff);
149 let annotated = if annotations.is_empty() {
150 String::new()
151 } else {
152 format!("\n文件语言:\n{annotations}")
153 };
154 format!(
155 "仓库:{repo_name}\n范围:{context_label}\n{note}{annotated}\
156最多输出 {max_findings} 条 findings。\n\n```diff\n{diff}\n```\n"
157 )
158}
159
160/// 逐文件列出「路径 → 语言」,仅包含能识别的文件。
161pub fn file_language_annotations(diff: &str) -> String {
162 let mut out = String::new();
163 for line in diff.split('\n') {
164 let Some(rest) = line.strip_prefix("diff --git ") else {
165 continue;
166 };
167 let Some((old, target)) = rest.trim().split_once(" b/") else {
168 continue;
169 };
170 let path = target.trim().trim_matches('"');
171 let label = language_of_path(old.trim())
172 .or_else(|| language_of_path(path))
173 .unwrap_or("未知");
174 out.push_str(&format!("- {path} → {label}\n"));
175 }
176 out
177}
178
179/// 组装仓库总结请求的用户消息。
180pub fn summary_user_prompt(repo_name: &str, revision: &str, commits: &str) -> String {
181 format!(
182 "仓库:{repo_name}\n分支/版本:{revision}\n\n以下是最新提交(从新到旧)与变更统计:\n\n{commits}\n"
183 )
184}
185
186/// 组装 Release 文案请求的用户消息。
187pub fn release_user_prompt(
188 repo_name: &str,
189 tag_name: &str,
190 previous_tag: Option<&str>,
191 commits: &str,
192) -> String {
193 let range = match previous_tag {
194 Some(previous) => format!("{previous}..{tag_name}"),
195 None => format!("(首个版本){tag_name}"),
196 };
197 format!(
198 "仓库:{repo_name}\n版本:{tag_name}\n区间:{range}\n\n区间内提交:\n\n{commits}\n"
199 )
200}
201
202/// 从补丁文本推断涉及的语言文件名扩展。
203pub fn detect_languages(diff: &str) -> Vec<String> {
204 let mut found: Vec<String> = Vec::new();
205 for line in diff.split('\n') {
206 let Some(rest) = line.strip_prefix("diff --git ") else {
207 continue;
208 };
209 let Some((_, target)) = rest.trim().split_once(" b/") else {
210 continue;
211 };
212 if let Some(language) = language_of_path(target.trim()) {
213 if !found.iter().any(|have| have == language) {
214 found.push(language.to_string());
215 }
216 }
217 }
218 found
219}
220
221/// 依扩展名/文件名映射编程语言;用于提示词与统计,不参与语法分析。
222pub fn language_of_path(path: &str) -> Option<&'static str> {
223 let unquoted = path.trim_matches('"');
224 let base = unquoted.rsplit('/').next().unwrap_or(unquoted);
225 match base {
226 "Dockerfile" | "Containerfile" => return Some("Dockerfile"),
227 "Makefile" | "GNUmakefile" => return Some("Makefile"),
228 "Gemfile" | "Rakefile" => return Some("Ruby"),
229 "Cargo.lock" => return Some("TOML"),
230 "go.mod" | "go.sum" => return Some("Go"),
231 "CMakeLists.txt" => return Some("CMake"),
232 _ => {}
233 }
234 let ext = base.rsplit_once('.')?.1.to_ascii_lowercase();
235 Some(match ext.as_str() {
236 "rs" => "Rust",
237 "go" => "Go",
238 "py" | "pyi" => "Python",
239 "js" | "mjs" | "cjs" | "jsx" => "JavaScript",
240 "ts" | "mts" | "cts" | "tsx" => "TypeScript",
241 "java" => "Java",
242 "kt" | "kts" => "Kotlin",
243 "scala" => "Scala",
244 "swift" => "Swift",
245 "c" | "h" => "C",
246 "cc" | "cpp" | "cxx" | "hpp" | "hh" | "hxx" => "C++",
247 "cs" => "C#",
248 "php" => "PHP",
249 "rb" => "Ruby",
250 "lua" => "Lua",
251 "dart" => "Dart",
252 "ex" | "exs" => "Elixir",
253 "erl" | "hrl" => "Erlang",
254 "hs" => "Haskell",
255 "ml" | "mli" => "OCaml",
256 "clj" | "cljs" => "Clojure",
257 "r" => "R",
258 "jl" => "Julia",
259 "pl" | "pm" => "Perl",
260 "sh" | "bash" | "zsh" | "fish" => "Shell",
261 "ps1" | "psm1" => "PowerShell",
262 "bat" | "cmd" => "Batch",
263 "sql" => "SQL",
264 "proto" => "Protocol Buffers",
265 "graphql" | "gql" => "GraphQL",
266 "vue" => "Vue",
267 "svelte" => "Svelte",
268 "astro" => "Astro",
269 "md" | "markdown" | "mdx" => "Markdown",
270 "yaml" | "yml" => "YAML",
271 "json" | "jsonc" => "JSON",
272 "toml" => "TOML",
273 "ini" | "cfg" | "conf" => "Config",
274 "xml" => "XML",
275 "html" | "htm" => "HTML",
276 "css" => "CSS",
277 "scss" | "sass" => "SCSS",
278 "less" => "Less",
279 "tf" | "tfvars" => "Terraform",
280 "hcl" => "HCL",
281 "nix" => "Nix",
282 "v" => "V",
283 "zig" => "Zig",
284 "nim" => "Nim",
285 "asm" | "s" => "Assembly",
286 "ipynb" => "Jupyter",
287 _ => return None,
288 })
289}
No newline at end of file
Added lib/agent/src/queue.rs View lib/agent/src/queue.rs
@@ -0,0 +1,110 @@
1use models::ai::ai_job::{ActiveModel, AiJobStatus};
2use models::ai::ai_job;
3use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
4use time::OffsetDateTime;
5use uuid::Uuid;
6
7/// 消费者侧的作业状态流转。
8impl super::AgentService {
9 /// 标记作业开始执行。
10 pub async fn mark_running(&self, job_id: Uuid, kind_str: &str) -> Result<(), sea_orm::DbErr> {
11 let db = self.db_write();
12 let now = OffsetDateTime::now_utc();
13 if let Some(row) = ai_job::Entity::find_by_id(job_id).one(db).await? {
14 let attempts = row.attempts.saturating_add(1);
15 let mut model: ActiveModel = row.into();
16 model.status = Set(AiJobStatus::Running);
17 model.started_at = Set(Some(now));
18 model.updated_at = Set(Some(now));
19 model.attempts = Set(attempts);
20 model.update(db).await?;
21 return Ok(());
22 }
23 // 队列里存在但库中缺失:补一条 running 记录,保证任务可审计。
24 ActiveModel {
25 id: Set(job_id),
26 repo_id: Set(Uuid::nil()),
27 kind: Set(kind_from_str(kind_str)),
28 target_id: Set(None),
29 status: Set(AiJobStatus::Running),
30 attempts: Set(1),
31 model: Set(self.config().ai.model.clone()),
32 target_ref: Set(None),
33 language: Set(self.config().ai.output_language.clone()),
34 request_json: Set(None),
35 response_json: Set(None),
36 prompt_tokens: Set(None),
37 completion_tokens: Set(None),
38 error: Set(None),
39 created_at: Set(now),
40 started_at: Set(Some(now)),
41 finished_at: Set(None),
42 updated_at: Set(Some(now)),
43 }
44 .insert(db)
45 .await?;
46 Ok(())
47 }
48
49 pub async fn mark_success(
50 &self,
51 job_id: Uuid,
52 request: Option<serde_json::Value>,
53 response: Option<serde_json::Value>,
54 prompt_tokens: Option<i32>,
55 completion_tokens: Option<i32>,
56 ) {
57 let db = self.db_write();
58 let now = OffsetDateTime::now_utc();
59 if let Ok(Some(row)) = ai_job::Entity::find_by_id(job_id).one(db).await {
60 let mut model: ActiveModel = row.into();
61 model.status = Set(AiJobStatus::Success);
62 model.request_json = Set(request);
63 model.response_json = Set(response);
64 model.prompt_tokens = Set(prompt_tokens);
65 model.completion_tokens = Set(completion_tokens);
66 model.error = Set(None);
67 model.finished_at = Set(Some(now));
68 model.updated_at = Set(Some(now));
69 if let Err(error) = model.update(db).await {
70 tracing::warn!(%error, %job_id, "failed to mark ai job success");
71 }
72 }
73 }
74
75 pub async fn mark_failed(&self, job_id: Uuid, error: &str) {
76 let db = self.db_write();
77 let now = OffsetDateTime::now_utc();
78 if let Ok(Some(row)) = ai_job::Entity::find_by_id(job_id).one(db).await {
79 let mut model: ActiveModel = row.into();
80 model.status = Set(AiJobStatus::Failed);
81 model.error = Set(Some(error.to_string()));
82 model.finished_at = Set(Some(now));
83 model.updated_at = Set(Some(now));
84 if let Err(update_error) = model.update(db).await {
85 tracing::warn!(%update_error, %job_id, "failed to mark ai job failure");
86 }
87 }
88 }
89
90 /// 统计某仓库仍未结束的作业数,用于限流与积压观测。
91 pub async fn pending_jobs(&self, repo_id: Uuid) -> Result<u64, sea_orm::DbErr> {
92 ai_job::Entity::find()
93 .filter(ai_job::Column::RepoId.eq(repo_id))
94 .filter(
95 ai_job::Column::Status.is_in([AiJobStatus::Pending, AiJobStatus::Running]),
96 )
97 .count(self.db_read())
98 .await
99 }
100}
101
102fn kind_from_str(value: &str) -> ai_job::AiJobKind {
103 use ai_job::AiJobKind;
104 match value {
105 "commit_review" => AiJobKind::CommitReview,
106 "repo_summary" => AiJobKind::RepoSummary,
107 "release_notes" => AiJobKind::ReleaseNotes,
108 _ => AiJobKind::PullReview,
109 }
110}
No newline at end of file
Added lib/agent/src/service.rs View lib/agent/src/service.rs
@@ -0,0 +1,628 @@
1use std::sync::Arc;
2
3use ci_protocol::ai::AgentTask as Task;
4use config::AppConfig;
5use export::AppState;
6use git::service::GitService;
7use models::ai::ai_review_result::{ActiveModel as ResultModel, AiReviewVerdict};
8use models::ai::repo_ai_summary::ActiveModel as SummaryModel;
9use models::pull_request::{pull_request_comment, pull_request_review, pull_requests};
10use models::repo::repo_release;
11use sea_orm::{
12 ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter,
13};
14use time::OffsetDateTime;
15use uuid::Uuid;
16
17use crate::client::{AgentClient, AgentError, AgentResult, extract_json};
18use crate::prompt::{
19 PromptContext, ReleaseNotesOutput, ReviewOutput, SummaryOutput, detect_languages,
20 release_system, release_user_prompt, review_system, review_user_prompt, summary_system,
21 summary_user_prompt,
22};
23
24/// AI 运行核心:持有单一模型客户端与共享状态,执行队列中的任务。
25#[derive(Clone)]
26pub struct AgentService {
27 pub state: AppState,
28 pub git: GitService,
29 client: Arc<AgentClient>,
30}
31
32impl AgentService {
33 pub fn new(state: AppState) -> AgentResult<Self> {
34 let client = AgentClient::new(&state.config.ai)?;
35 let git = GitService::new(state.clone());
36 Ok(Self {
37 state,
38 git,
39 client: Arc::new(client),
40 })
41 }
42
43 pub fn config(&self) -> &AppConfig {
44 &self.state.config
45 }
46
47 pub fn db_read(&self) -> &DatabaseConnection {
48 self.state.database.read()
49 }
50
51 pub fn db_write(&self) -> &DatabaseConnection {
52 self.state.database.write()
53 }
54
55 pub fn client(&self) -> &AgentClient {
56 &self.client
57 }
58
59 /// 执行一条队列任务。返回 `Err` 时由调用方决定重投或落 DLQ。
60 pub async fn run_task(&self, task: &Task) -> AgentResult<()> {
61 let job_id = task.job_id();
62 self.mark_running(job_id, task.kind_str())
63 .await
64 .map_err(|e| AgentError::InvalidTask(e.to_string()))?;
65 let outcome = match task {
66 Task::PullReview { .. } => self.run_pull_review(task).await,
67 Task::CommitReview { .. } => self.run_commit_review(task).await,
68 Task::RepoSummary { .. } => self.run_repo_summary(task).await,
69 Task::ReleaseNotes { .. } => self.run_release_notes(task).await,
70 };
71 if let Err(error) = &outcome {
72 tracing::warn!(%error, %job_id, "ai task failed");
73 }
74 outcome
75 }
76
77 /// 评审提示词上下文:输出语言 + 本次补丁涉及的语言。
78 fn review_context(&self, language: &str, diff: &str) -> PromptContext {
79 PromptContext::new(language, detect_languages(diff))
80 }
81
82 /// 总结与 release 提示词上下文:输出语言 + 仓库已登记的语言统计。
83 ///
84 /// 提交列表本身不含路径,因此复用 sync 阶段写入的 `git_language` 统计;
85 /// 统计缺失时只保留语言指令,不影响生成。
86 async fn repo_context(&self, language: &str, repo_id: Uuid) -> PromptContext {
87 let languages = self.repo_languages(repo_id).await;
88 PromptContext::new(language, languages)
89 }
90
91 /// 读取仓库语言统计(按占比降序,最多 5 种)。
92 async fn repo_languages(&self, repo_id: Uuid) -> Vec<String> {
93 use models::git::git_languages;
94 use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
95 let rows = git_languages::Entity::find()
96 .filter(git_languages::Column::RepoId.eq(repo_id))
97 .order_by_desc(git_languages::Column::PercentageBp)
98 .limit(5)
99 .all(self.db_read())
100 .await;
101 match rows {
102 Ok(rows) => rows.into_iter().map(|row| row.language).collect(),
103 Err(error) => {
104 tracing::debug!(%error, "repo language lookup failed; omitting language hint");
105 Vec::new()
106 }
107 }
108 }
109
110 /// 仓库全名(space/repo),用于提示词与通知文案。
111 pub(crate) async fn repo_label(&self, repo_id: Uuid) -> String {
112 use models::repo::repos;
113 let repo = repos::Entity::find_by_id(repo_id)
114 .one(self.db_read())
115 .await
116 .ok()
117 .flatten();
118 match repo {
119 Some(row) => row.name,
120 None => repo_id.to_string(),
121 }
122 }
123
124 pub(crate) async fn space_name_for(&self, repo_id: Uuid) -> Option<String> {
125 use models::repo::repos;
126 let repo = repos::Entity::find_by_id(repo_id).one(self.db_read()).await.ok()??;
127 git::share::access::space_for_dir(self.db_read(), repo.dir_id)
128 .await
129 .ok()?
130 .map(|space| space.lower_name)
131 }
132
133 // ---- PR 评审 -------------------------------------------------------
134
135 async fn run_pull_review(&self, task: &Task) -> AgentResult<()> {
136 let Task::PullReview {
137 job_id,
138 repo_id,
139 pull_request_id,
140 head_sha,
141 language,
142 } = task
143 else {
144 return Err(AgentError::InvalidTask("not a pull review".to_string()));
145 };
146 let pull = pull_requests::Entity::find_by_id(*pull_request_id)
147 .filter(pull_requests::Column::RepoId.eq(*repo_id))
148 .one(self.db_read())
149 .await
150 .map_err(db_err)?
151 .ok_or_else(|| AgentError::InvalidTask("pull request not found".to_string()))?;
152 let space = self
153 .space_name_for(*repo_id)
154 .await
155 .ok_or_else(|| AgentError::InvalidTask("space not found".to_string()))?;
156 let repo_name = self.repo_label(*repo_id).await;
157 let (diff, label) = self
158 .build_pull_diff(&space, &repo_name, head_sha, &pull.base_branch)
159 .await
160 .map_err(|e| AgentError::InvalidTask(format!("build diff: {e}")))?;
161 let lang = self
162 .resolve_language(Some(pull.author_id), language.as_deref(), *repo_id)
163 .await;
164 let ctx = self.review_context(&lang, &diff.text);
165 let prompt = review_user_prompt(
166 &repo_name,
167 &label,
168 &diff.text,
169 diff.truncated,
170 self.config().ai.max_findings,
171 );
172 let completion = self
173 .client
174 .complete(&review_system(&ctx), &prompt, true)
175 .await?;
176 let parsed: ReviewOutput = parse_output(&completion.content)?;
177 let (findings, dropped) =
178 crate::diff::validate_findings(&diff.text, parsed.findings, self.config().ai.max_findings);
179 if dropped > 0 {
180 tracing::info!(dropped, "dropped ai findings outside changed lines");
181 }
182 let verdict = match parsed.verdict.as_deref() {
183 Some("changes_requested") => AiReviewVerdict::ChangesRequested,
184 _ => AiReviewVerdict::Commented,
185 };
186 self.persist_pull_review(
187 job_id,
188 repo_id,
189 &pull,
190 head_sha,
191 &lang,
192 verdict,
193 &parsed.summary,
194 &findings,
195 diff.truncated,
196 &completion.raw,
197 completion.prompt_tokens,
198 completion.completion_tokens,
199 )
200 .await
201 }
202
203 #[allow(clippy::too_many_arguments)]
204 #[allow(clippy::too_many_arguments)]
205 async fn persist_pull_review(
206 &self,
207 job_id: &Uuid,
208 repo_id: &Uuid,
209 pull: &pull_requests::Model,
210 head_sha: &str,
211 language: &str,
212 verdict: AiReviewVerdict,
213 summary: &str,
214 findings: &[crate::prompt::ReviewFinding],
215 truncated: bool,
216 raw: &serde_json::Value,
217 prompt_tokens: Option<i32>,
218 completion_tokens: Option<i32>,
219 ) -> AgentResult<()> {
220 let bot = crate::bot::resolve_bot_user(self.db_write(), &self.config().ai)
221 .await
222 .map_err(db_err)?;
223 let now = OffsetDateTime::now_utc();
224 let state = match verdict {
225 AiReviewVerdict::ChangesRequested => {
226 pull_request_review::ReviewState::ChangesRequested
227 }
228 AiReviewVerdict::Commented => pull_request_review::ReviewState::Commented,
229 };
230 let review_id = Uuid::now_v7();
231 pull_request_review::ActiveModel {
232 id: Set(review_id),
233 pull_request_id: Set(pull.id),
234 user_id: Set(bot),
235 commit_sha: Set(head_sha.to_string()),
236 official: Set(false),
237 content: Set(Some(summary.to_string())),
238 state: Set(state),
239 created_at: Set(now),
240 updated_at: Set(None),
241 }
242 .insert(self.db_write())
243 .await
244 .map_err(db_err)?;
245
246 for finding in findings {
247 pull_request_comment::ActiveModel {
248 id: Set(Uuid::now_v7()),
249 pull_request_id: Set(pull.id),
250 user_id: Set(bot),
251 review_id: Set(Some(review_id)),
252 parent_comment_id: Set(None),
253 commit_sha: Set(Some(head_sha.to_string())),
254 original_commit_sha: Set(Some(head_sha.to_string())),
255 path: Set(Some(finding.path.clone())),
256 side: Set(finding.side.clone()),
257 line: Set(Some(finding.line)),
258 original_line: Set(Some(finding.line)),
259 body: Set(finding_body(finding)),
260 resolved_at: Set(None),
261 resolved_by: Set(None),
262 created_at: Set(now),
263 updated_at: Set(None),
264 }
265 .insert(self.db_write())
266 .await
267 .map_err(db_err)?;
268 }
269
270 let findings_value = serde_json::to_value(findings).unwrap_or(serde_json::Value::Null);
271 ResultModel {
272 id: Set(Uuid::now_v7()),
273 repo_id: Set(*repo_id),
274 pull_request_id: Set(Some(pull.id)),
275 commit_sha: Set(head_sha.to_string()),
276 verdict: Set(verdict),
277 summary: Set(summary.to_string()),
278 findings_json: Set(findings_value),
279 truncated: Set(truncated),
280 language: Set(language.to_string()),
281 job_id: Set(*job_id),
282 created_at: Set(now),
283 }
284 .insert(self.db_write())
285 .await
286 .map_err(db_err)?;
287
288 self.mark_success(
289 *job_id,
290 None,
291 Some(raw.clone()),
292 prompt_tokens,
293 completion_tokens,
294 )
295 .await;
296 self.notify_pull_ai(pull, bot, language, summary, findings.len())
297 .await;
298 Ok(())
299 }
300
301 // ---- Commit 评审 ---------------------------------------------------
302
303 async fn run_commit_review(&self, task: &Task) -> AgentResult<()> {
304 let Task::CommitReview {
305 job_id,
306 repo_id,
307 commit_sha,
308 language,
309 } = task
310 else {
311 return Err(AgentError::InvalidTask("not a commit review".to_string()));
312 };
313 let space = self
314 .space_name_for(*repo_id)
315 .await
316 .ok_or_else(|| AgentError::InvalidTask("space not found".to_string()))?;
317 let repo_name = self.repo_label(*repo_id).await;
318 let (diff, label) = self
319 .build_commit_diff(&space, &repo_name, commit_sha)
320 .await
321 .map_err(|e| AgentError::InvalidTask(format!("build diff: {e}")))?;
322 let lang = self.resolve_language(None, language.as_deref(), *repo_id).await;
323 let ctx = self.review_context(&lang, &diff.text);
324 let prompt = review_user_prompt(
325 &repo_name,
326 &label,
327 &diff.text,
328 diff.truncated,
329 self.config().ai.max_findings,
330 );
331 let completion = self
332 .client
333 .complete(&review_system(&ctx), &prompt, true)
334 .await?;
335 let parsed: ReviewOutput = parse_output(&completion.content)?;
336 let (findings, _dropped) = crate::diff::validate_findings(
337 &diff.text,
338 parsed.findings,
339 self.config().ai.max_findings,
340 );
341 let verdict = match parsed.verdict.as_deref() {
342 Some("changes_requested") => AiReviewVerdict::ChangesRequested,
343 _ => AiReviewVerdict::Commented,
344 };
345 let now = OffsetDateTime::now_utc();
346 let findings_value = serde_json::to_value(&findings).unwrap_or(serde_json::Value::Null);
347 ResultModel {
348 id: Set(Uuid::now_v7()),
349 repo_id: Set(*repo_id),
350 pull_request_id: Set(None),
351 commit_sha: Set(commit_sha.clone()),
352 verdict: Set(verdict),
353 summary: Set(parsed.summary.clone()),
354 findings_json: Set(findings_value),
355 truncated: Set(diff.truncated),
356 language: Set(lang.clone()),
357 job_id: Set(*job_id),
358 created_at: Set(now),
359 }
360 .insert(self.db_write())
361 .await
362 .map_err(db_err)?;
363 self.mark_success(
364 *job_id,
365 None,
366 Some(completion.raw.clone()),
367 completion.prompt_tokens,
368 completion.completion_tokens,
369 )
370 .await;
371 self.publish_commit_status(
372 *repo_id,
373 commit_sha,
374 verdict,
375 &parsed.summary,
376 findings.len(),
377 )
378 .await;
379 Ok(())
380 }
381
382 /// 把提交级评审结论写成 `context=ai-review` 的提交状态。
383 ///
384 /// 默认不进入 `required_status_contexts`;是否作为合并门禁由管理员配置。
385 async fn publish_commit_status(
386 &self,
387 repo_id: Uuid,
388 commit_sha: &str,
389 verdict: AiReviewVerdict,
390 summary: &str,
391 findings: usize,
392 ) {
393 use git::service::commits::{CommitStatusDtoState, SetCommitStatusCmd};
394 let (space, repo) = match self.repo_location(repo_id).await {
395 Some(pair) => pair,
396 None => {
397 tracing::warn!(%repo_id, "skip commit status: repo location unknown");
398 return;
399 }
400 };
401 let bot = match crate::bot::resolve_bot_user(self.db_write(), &self.config().ai).await {
402 Ok(uid) => uid,
403 Err(error) => {
404 tracing::warn!(%error, "skip commit status: bot user unavailable");
405 return;
406 }
407 };
408 let (state, description) = match verdict {
409 AiReviewVerdict::ChangesRequested => (
410 CommitStatusDtoState::Failure,
411 format!("AI 评审要求修改({findings} 条建议)"),
412 ),
413 AiReviewVerdict::Commented => (CommitStatusDtoState::Success, summary.to_string()),
414 };
415 let cmd = SetCommitStatusCmd {
416 sha: commit_sha.to_string(),
417 state,
418 context: git::service::ai_review::AI_REVIEW_CONTEXT.to_string(),
419 description: Some(crate::client::truncate(&description, 200)),
420 target_url: None,
421 };
422 if let Err(error) = self.git.set_commit_status(space, repo, bot, cmd).await {
423 tracing::warn!(%error, %commit_sha, "failed to publish ai-review commit status");
424 }
425 }
426
427 /// 由仓库 id 解析 (space, repo) 名称对。
428 async fn repo_location(&self, repo_id: Uuid) -> Option<(String, String)> {
429 use models::repo::repos;
430 let repo = repos::Entity::find_by_id(repo_id)
431 .one(self.db_read())
432 .await
433 .ok()??;
434 let space = self.space_name_for(repo_id).await?;
435 Some((space, repo.name))
436 }
437
438 // ---- 仓库总结 ------------------------------------------------------
439
440 async fn run_repo_summary(&self, task: &Task) -> AgentResult<()> {
441 let Task::RepoSummary {
442 job_id,
443 repo_id,
444 head_sha,
445 base_sha,
446 limit,
447 language,
448 } = task
449 else {
450 return Err(AgentError::InvalidTask("not a repo summary".to_string()));
451 };
452 let space = self
453 .space_name_for(*repo_id)
454 .await
455 .ok_or_else(|| AgentError::InvalidTask("space not found".to_string()))?;
456 let repo_name = self.repo_label(*repo_id).await;
457 let commits = self
458 .git
459 .merge_commits(
460 space.clone(),
461 repo_name.clone(),
462 &format!("refs/heads/{}", base_sha.clone().unwrap_or_else(|| "HEAD".to_string())),
463 head_sha,
464 )
465 .await
466 .map_err(|e| AgentError::InvalidTask(format!("list commits: {e}")))?;
467 let limit = limit.unwrap_or(self.config().ai.summary_commit_limit) as usize;
468 let trimmed: Vec<_> = commits.into_iter().take(limit).collect();
469 let commit_text = Self::format_commits(&trimmed);
470 let prompt = summary_user_prompt(&repo_name, head_sha, &commit_text);
471 let lang = self.resolve_language(None, language.as_deref(), *repo_id).await;
472 let ctx = self.repo_context(&lang, *repo_id).await;
473 let completion = self
474 .client
475 .complete(&summary_system(&ctx), &prompt, true)
476 .await?;
477 let parsed: SummaryOutput = parse_output(&completion.content)?;
478 let now = OffsetDateTime::now_utc();
479 let highlights = serde_json::to_value(&parsed.highlights).unwrap_or(serde_json::Value::Null);
480 let risks = serde_json::to_value(&parsed.risks).unwrap_or(serde_json::Value::Null);
481 let existing = models::ai::repo_ai_summary::Entity::find()
482 .filter(models::ai::repo_ai_summary::Column::RepoId.eq(*repo_id))
483 .filter(models::ai::repo_ai_summary::Column::HeadSha.eq(head_sha.clone()))
484 .one(self.db_read())
485 .await
486 .map_err(db_err)?;
487 match existing {
488 Some(row) => {
489 let mut model: SummaryModel = row.into();
490 model.content = Set(parsed.content.clone());
491 model.highlights_json = Set(highlights);
492 model.risks_json = Set(risks);
493 model.commit_count = Set(trimmed.len() as i32);
494 model.model = Set(self.client.model().to_string());
495 model.language = Set(lang.clone());
496 model.job_id = Set(Some(*job_id));
497 model.updated_at = Set(now);
498 model.update(self.db_write()).await.map_err(db_err)?;
499 }
500 None => {
501 SummaryModel {
502 id: Set(Uuid::now_v7()),
503 repo_id: Set(*repo_id),
504 head_sha: Set(head_sha.clone()),
505 base_sha: Set(base_sha.clone()),
506 commit_count: Set(trimmed.len() as i32),
507 content: Set(parsed.content.clone()),
508 highlights_json: Set(highlights),
509 risks_json: Set(risks),
510 model: Set(self.client.model().to_string()),
511 language: Set(lang.clone()),
512 job_id: Set(Some(*job_id)),
513 created_at: Set(now),
514 updated_at: Set(now),
515 }
516 .insert(self.db_write())
517 .await
518 .map_err(db_err)?;
519 }
520 }
521 self.mark_success(
522 *job_id,
523 None,
524 Some(completion.raw.clone()),
525 completion.prompt_tokens,
526 completion.completion_tokens,
527 )
528 .await;
529 Ok(())
530 }
531
532 // ---- Release 文案 --------------------------------------------------
533
534 async fn run_release_notes(&self, task: &Task) -> AgentResult<()> {
535 let Task::ReleaseNotes {
536 job_id,
537 repo_id,
538 release_id,
539 tag_name,
540 from_sha,
541 to_sha,
542 language,
543 } = task
544 else {
545 return Err(AgentError::InvalidTask("not a release notes".to_string()));
546 };
547 let release = repo_release::Entity::find_by_id(*release_id)
548 .filter(repo_release::Column::RepoId.eq(*repo_id))
549 .one(self.db_read())
550 .await
551 .map_err(db_err)?
552 .ok_or_else(|| AgentError::InvalidTask("release not found".to_string()))?;
553 let space = self
554 .space_name_for(*repo_id)
555 .await
556 .ok_or_else(|| AgentError::InvalidTask("space not found".to_string()))?;
557 let repo_name = self.repo_label(*repo_id).await;
558 let commits = match from_sha {
559 Some(from) => self
560 .git
561 .merge_commits(space, repo_name.clone(), from, to_sha)
562 .await
563 .map_err(|e| AgentError::InvalidTask(format!("list commits: {e}")))?,
564 None => Vec::new(),
565 };
566 let commit_text = Self::format_commits(&commits);
567 let prompt =
568 release_user_prompt(&repo_name, tag_name, from_sha.as_deref(), &commit_text);
569 let lang = self.resolve_language(None, language.as_deref(), *repo_id).await;
570 let ctx = self.repo_context(&lang, *repo_id).await;
571 let completion = self
572 .client
573 .complete(&release_system(&ctx), &prompt, true)
574 .await?;
575 let parsed: ReleaseNotesOutput = parse_output(&completion.content)?;
576 let now = OffsetDateTime::now_utc();
577 // 已发布的 release 不再被 AI 覆盖,只记录作业结果。
578 if release.is_draft {
579 let mut model: repo_release::ActiveModel = release.into();
580 let title = if parsed.title.trim().is_empty() {
581 tag_name.clone()
582 } else {
583 parsed.title.clone()
584 };
585 model.title = Set(title);
586 model.content = Set(Some(parsed.content.clone()));
587 model.num_commits = Set(commits.len() as i32);
588 model.updated_at = Set(now);
589 model.update(self.db_write()).await.map_err(db_err)?;
590 }
591 self.mark_success(
592 *job_id,
593 None,
594 Some(completion.raw.clone()),
595 completion.prompt_tokens,
596 completion.completion_tokens,
597 )
598 .await;
599 Ok(())
600 }
601
602 /// 记录 release 草稿的作业请求体(便于运维排查)。
603 pub async fn record_release_request(&self, job_id: Uuid, request: serde_json::Value) {
604 use models::ai::ai_job;
605 use sea_orm::EntityTrait;
606 if let Ok(Some(row)) = ai_job::Entity::find_by_id(job_id).one(self.db_write()).await {
607 let mut model: ai_job::ActiveModel = row.into();
608 model.request_json = Set(Some(request));
609 model.updated_at = Set(Some(OffsetDateTime::now_utc()));
610 let _ = model.update(self.db_write()).await;
611 }
612 }
613}
614
615fn finding_body(finding: &crate::prompt::ReviewFinding) -> String {
616 let severity = finding.severity.as_deref().unwrap_or("minor");
617 format!("**[{severity}]** {}", finding.body.trim())
618}
619
620fn parse_output<T: serde::de::DeserializeOwned>(content: &str) -> AgentResult<T> {
621 let value = extract_json(content)?;
622 serde_json::from_value(value).map_err(|e| AgentError::Decode(format!("shape mismatch: {e}")))
623}
624
625fn db_err(error: sea_orm::DbErr) -> AgentError {
626 AgentError::InvalidTask(error.to_string())
627}
628
Added lib/agent/Cargo.toml View lib/agent/Cargo.toml
@@ -0,0 +1,23 @@
1[package]
2name = "agent"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7config = { workspace = true }
8export = { workspace = true }
9models = { workspace = true }
10git = { workspace = true }
11ci-protocol = { workspace = true }
12sea-orm = { workspace = true }
13redis = { workspace = true }
14reqwest = { workspace = true }
15serde = { workspace = true, features = ["derive"] }
16serde_json = { workspace = true }
17thiserror = { workspace = true }
18time = { workspace = true }
19tokio = { workspace = true, features = ["time", "sync"] }
20tracing = { workspace = true }
21utoipa = { workspace = true }
22uuid = { workspace = true }
23futures = { workspace = true }
No newline at end of file
Added lib/ci-protocol/src/ai.rs View lib/ci-protocol/src/ai.rs
@@ -0,0 +1,119 @@
1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4/// AI 队列任务协议。
5///
6/// 定义在协议 crate 中,使生产方(git / pulls)与消费方(agent / aihost)
7/// 都能引用同一份载荷定义而不产生 crate 依赖环。
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9#[serde(tag = "kind", rename_all = "snake_case")]
10pub enum AgentTask {
11 PullReview {
12 job_id: Uuid,
13 repo_id: Uuid,
14 pull_request_id: Uuid,
15 head_sha: String,
16 /// 输出语言(BCP-47 或 `auto`);`None` 时用 `[ai].output_language`。
17 #[serde(default)]
18 language: Option<String>,
19 },
20 CommitReview {
21 job_id: Uuid,
22 repo_id: Uuid,
23 commit_sha: String,
24 #[serde(default)]
25 language: Option<String>,
26 },
27 RepoSummary {
28 job_id: Uuid,
29 repo_id: Uuid,
30 head_sha: String,
31 base_sha: Option<String>,
32 limit: Option<u64>,
33 #[serde(default)]
34 language: Option<String>,
35 },
36 ReleaseNotes {
37 job_id: Uuid,
38 repo_id: Uuid,
39 release_id: Uuid,
40 tag_name: String,
41 from_sha: Option<String>,
42 to_sha: String,
43 #[serde(default)]
44 language: Option<String>,
45 },
46}
47
48impl AgentTask {
49 #[must_use]
50 pub fn job_id(&self) -> Uuid {
51 match self {
52 Self::PullReview { job_id, .. }
53 | Self::CommitReview { job_id, .. }
54 | Self::RepoSummary { job_id, .. }
55 | Self::ReleaseNotes { job_id, .. } => *job_id,
56 }
57 }
58
59 #[must_use]
60 pub fn repo_id(&self) -> Uuid {
61 match self {
62 Self::PullReview { repo_id, .. }
63 | Self::CommitReview { repo_id, .. }
64 | Self::RepoSummary { repo_id, .. }
65 | Self::ReleaseNotes { repo_id, .. } => *repo_id,
66 }
67 }
68
69 /// 稳定字符串标识,与 `ai_job.kind` 的取值一致。
70 #[must_use]
71 pub fn kind_str(&self) -> &'static str {
72 match self {
73 Self::PullReview { .. } => "pull_review",
74 Self::CommitReview { .. } => "commit_review",
75 Self::RepoSummary { .. } => "repo_summary",
76 Self::ReleaseNotes { .. } => "release_notes",
77 }
78 }
79
80 #[must_use]
81 pub fn target_id(&self) -> Option<Uuid> {
82 match self {
83 Self::PullReview {
84 pull_request_id, ..
85 } => Some(*pull_request_id),
86 Self::ReleaseNotes { release_id, .. } => Some(*release_id),
87 Self::CommitReview { .. } | Self::RepoSummary { .. } => None,
88 }
89 }
90
91 /// 该任务请求的输出语言(未指定时为 `None`,由消费侧取配置缺省)。
92 #[must_use]
93 pub fn language(&self) -> Option<&str> {
94 match self {
95 Self::PullReview { language, .. }
96 | Self::CommitReview { language, .. }
97 | Self::RepoSummary { language, .. }
98 | Self::ReleaseNotes { language, .. } => language.as_deref(),
99 }
100 }
101
102 #[must_use]
103 pub fn target_ref(&self) -> Option<String> {
104 match self {
105 Self::PullReview { head_sha, .. } => Some(head_sha.clone()),
106 Self::CommitReview { commit_sha, .. } => Some(commit_sha.clone()),
107 Self::RepoSummary { head_sha, .. } => Some(head_sha.clone()),
108 Self::ReleaseNotes { tag_name, .. } => Some(tag_name.clone()),
109 }
110 }
111
112 pub fn encode(&self) -> Result<Vec<u8>, serde_json::Error> {
113 serde_json::to_vec(self)
114 }
115
116 pub fn decode(payload: &[u8]) -> Result<Self, serde_json::Error> {
117 serde_json::from_slice(payload)
118 }
119}
No newline at end of file
Added lib/ci-protocol/src/ai_languages.rs View lib/ci-protocol/src/ai_languages.rs
@@ -0,0 +1,134 @@
1//! 主流语言注册表:把语言代码规范化为模型可用的名称与书写要求。
2//!
3//! 放在协议 crate 中,使任务生产方(git / pulls)与消费方(agent / aihost)
4//! 共用同一份规范化逻辑而不产生 crate 依赖环。
5//!
6//! 平台不限制输出语言:`[ai].output_language` 只是缺省值,每个任务可携带
7//! 自己的语言(通常来自触发者的 `users.language` / `users.locale`)。
8//! 未知但语法合法的 BCP-47 代码会原样透传给模型,不报错。
9
10/// `(代码, 英文名, 本语名)`。
11///
12/// 代码统一小写、`-` 分隔;`normalize` 会把地区变体折叠到这些条目上。
13const SUPPORTED: &[(&str, &str, &str)] = &[
14 ("zh-cn", "Simplified Chinese", "简体中文"),
15 ("zh-tw", "Traditional Chinese", "繁體中文"),
16 ("en", "English", "English"),
17 ("ja", "Japanese", "日本語"),
18 ("ko", "Korean", "한국어"),
19 ("fr", "French", "Français"),
20 ("de", "German", "Deutsch"),
21 ("es", "Spanish", "Español"),
22 ("pt", "Portuguese", "Português"),
23 ("it", "Italian", "Italiano"),
24 ("ru", "Russian", "Русский"),
25 ("uk", "Ukrainian", "Українська"),
26 ("nl", "Dutch", "Nederlands"),
27 ("pl", "Polish", "Polski"),
28 ("cs", "Czech", "Čeština"),
29 ("sv", "Swedish", "Svenska"),
30 ("no", "Norwegian", "Norsk"),
31 ("da", "Danish", "Dansk"),
32 ("fi", "Finnish", "Suomi"),
33 ("tr", "Turkish", "Türkçe"),
34 ("ar", "Arabic", "العربية"),
35 ("he", "Hebrew", "עברית"),
36 ("fa", "Persian", "فارسی"),
37 ("hi", "Hindi", "हिन्दी"),
38 ("bn", "Bengali", "বাংলা"),
39 ("ta", "Tamil", "தமிழ்"),
40 ("th", "Thai", "ไทย"),
41 ("vi", "Vietnamese", "Tiếng Việt"),
42 ("id", "Indonesian", "Bahasa Indonesia"),
43 ("ms", "Malay", "Bahasa Melayu"),
44 ("el", "Greek", "Ελληνικά"),
45 ("ro", "Romanian", "Română"),
46 ("hu", "Hungarian", "Magyar"),
47];
48
49/// 特殊值:不指定语言,由模型沿用被评审内容的语言。
50pub const AUTO: &str = "auto";
51
52/// 把任意用户输入规范化为注册表里的代码。
53///
54/// 处理常见写法:`zh_Hans`、`zh-Hans-CN`、`en-US`、`EN`、`pt_BR`。
55/// 命中不了时返回小写规范化结果(仍可能是合法 BCP-47)。
56pub fn normalize(code: &str) -> String {
57 let lower = code.trim().to_ascii_lowercase().replace('_', "-");
58 if lower.is_empty() {
59 return String::new();
60 }
61 if lower == AUTO || lower == "und" || lower == "system" {
62 return AUTO.to_string();
63 }
64 // 先精确命中,再按「主语言 + 脚本」逐级回退。
65 if lookup(&lower).is_some() {
66 return lower;
67 }
68 let mut parts: Vec<&str> = lower.split('-').collect();
69 // zh-Hans-CN / zh-Hant-TW → zh-cn / zh-tw
70 if parts.first() == Some(&"zh") {
71 let script = parts.get(1).copied().unwrap_or("");
72 if script.starts_with("hant") || script == "tw" || script == "hk" || script == "mo" {
73 return "zh-tw".to_string();
74 }
75 if script.starts_with("hans") || script == "cn" || script == "sg" || script == "my" {
76 return "zh-cn".to_string();
77 }
78 // 仅 "zh":简体为缺省(与 config 默认一致)。
79 return "zh-cn".to_string();
80 }
81 // 去掉地区段(en-US → en);保留两段以上的脚本写法(sr-Latn)。
82 if parts.len() > 1 {
83 parts.truncate(1);
84 let primary = parts.join("-");
85 if lookup(&primary).is_some() {
86 return primary;
87 }
88 }
89 lower
90}
91
92/// 注册表查询(`code` 需已规范化)。
93fn lookup(code: &str) -> Option<(&'static str, &'static str)> {
94 SUPPORTED
95 .iter()
96 .find(|(c, _, _)| *c == code)
97 .map(|(_, en, native)| (*en, *native))
98}
99
100/// 面向提示词的名称:`简体中文 (Simplified Chinese)`;未知代码返回原样。
101pub fn display_name(code: &str) -> String {
102 let normalized = normalize(code);
103 if normalized.is_empty() || normalized == AUTO {
104 return String::new();
105 }
106 match lookup(&normalized) {
107 Some((en, native)) => format!("{native} ({en})"),
108 None => normalized,
109 }
110}
111
112/// 该代码是否是「沿用内容语言」模式。
113pub fn is_auto(code: &str) -> bool {
114 normalize(code) == AUTO
115}
116
117/// 已知语言的代码清单(供 UI 下拉与校验)。
118pub fn supported_codes() -> Vec<&'static str> {
119 let mut codes: Vec<&'static str> = SUPPORTED.iter().map(|(c, _, _)| *c).collect();
120 codes.push(AUTO);
121 codes
122}
123
124/// 校验:空字符串非法;其余一律接受(未知代码交模型处理)。
125pub fn validate(code: &str) -> Result<(), String> {
126 if code.trim().is_empty() {
127 return Err("language code must not be empty".to_string());
128 }
129 let normalized = normalize(code);
130 if normalized.is_empty() {
131 return Err("language code must not be empty".to_string());
132 }
133 Ok(())
134}
No newline at end of file
Modified lib/ci-protocol/src/lib.rs View lib/ci-protocol/src/lib.rs
@@ -1,6 +1,8 @@
1pub mod ai;
2pub mod ai_languages;
13pub mod dsl;
24pub mod events;
35pub mod runner;
46
57pub const PROTOCOL_VERSION: u32 = 1;
6pub const LOG_CHUNK_MAX_BYTES: usize = 262_144;
8pub const LOG_CHUNK_MAX_BYTES: usize = 262_144;
No newline at end of file
Added lib/config/src/ai.rs View lib/config/src/ai.rs
@@ -0,0 +1,124 @@
1use serde::Deserialize;
2
3/// 全平台唯一的 AI 运行配置。
4///
5/// 平台只通过一个 OpenAI 兼容端点访问**单一模型**(`model`),评审、仓库总结、
6/// release 文案与后续能力共用该模型,不存在按功能覆盖模型的配置项。
7#[derive(Clone, Debug, Deserialize)]
8#[serde(default)]
9pub struct AiConfig {
10 pub enabled: bool,
11 pub base_url: String,
12 pub api_key: String,
13 pub model: String,
14 pub review_enabled: bool,
15 pub summary_enabled: bool,
16 pub release_notes_enabled: bool,
17 pub queue_topic: String,
18 pub dlq_topic: String,
19 pub max_attempts: i32,
20 pub request_timeout_secs: u64,
21 pub context_max_bytes: usize,
22 pub max_findings: usize,
23 pub max_output_tokens: u32,
24 pub temperature: f32,
25 pub summary_commit_limit: u64,
26 pub bot_username: String,
27 pub bot_display_name: String,
28 /// 缺省输出语言(BCP-47,如 `zh-CN`、`en`、`ja`、`ko`、`fr`、`de`、`es`…)。
29 ///
30 /// 支持中、英、日、韩、法、德、西、葡、俄、阿等主流语言;填 `auto` 表示
31 /// 沿用代码注释的语言。每个任务还可携带自己的语言(默认取触发者的
32 /// `users.language`),因此这里只是兜底缺省值。
33 /// 代码标识符、命令行、路径保持原文,仅自然语言部分跟随语言设置。
34 pub output_language: String,
35}
36
37impl Default for AiConfig {
38 fn default() -> Self {
39 Self {
40 enabled: false,
41 base_url: String::new(),
42 api_key: String::new(),
43 model: String::new(),
44 review_enabled: true,
45 summary_enabled: true,
46 release_notes_enabled: true,
47 queue_topic: "gitdata:ai".to_string(),
48 dlq_topic: "gitdata:ai:dlq".to_string(),
49 max_attempts: 3,
50 request_timeout_secs: 120,
51 context_max_bytes: 512 * 1024,
52 max_findings: 20,
53 max_output_tokens: 4096,
54 temperature: 0.2,
55 summary_commit_limit: 50,
56 bot_username: "ai-reviewer".to_string(),
57 bot_display_name: "AI Reviewer".to_string(),
58 output_language: "zh-CN".to_string(),
59 }
60 }
61}
62
63impl AiConfig {
64 /// 端点地址,规范化去除尾部斜杠。
65 pub fn endpoint(&self) -> String {
66 let base = self.base_url.trim().trim_end_matches('/');
67 if base.ends_with("/chat/completions") {
68 return base.to_string();
69 }
70 format!("{base}/chat/completions")
71 }
72
73 pub fn review_on(&self) -> bool {
74 self.enabled && self.review_enabled
75 }
76
77 pub fn summary_on(&self) -> bool {
78 self.enabled && self.summary_enabled
79 }
80
81 pub fn release_notes_on(&self) -> bool {
82 self.enabled && self.release_notes_enabled
83 }
84
85 pub fn validate(&self) -> Result<(), String> {
86 if !self.enabled {
87 return Ok(());
88 }
89 let base = self.base_url.trim();
90 if base.is_empty() {
91 return Err("ai.base_url must not be empty when ai is enabled".to_string());
92 }
93 if !(base.starts_with("http://") || base.starts_with("https://")) {
94 return Err("ai.base_url must be an http(s) URL".to_string());
95 }
96 if self.model.trim().is_empty() {
97 return Err("ai.model must not be empty when ai is enabled".to_string());
98 }
99 if self.queue_topic.trim().is_empty() || self.dlq_topic.trim().is_empty() {
100 return Err("ai queue topics must not be empty".to_string());
101 }
102 if !(self.temperature.is_finite() && (0.0..=2.0).contains(&self.temperature)) {
103 return Err("ai.temperature must be between 0 and 2".to_string());
104 }
105 if self.max_attempts <= 0
106 || self.request_timeout_secs == 0
107 || self.context_max_bytes == 0
108 || self.max_findings == 0
109 || self.max_output_tokens == 0
110 || self.summary_commit_limit == 0
111 {
112 return Err("ai limits must be greater than zero".to_string());
113 }
114 if self.bot_username.trim().is_empty() {
115 return Err("ai.bot_username must not be empty".to_string());
116 }
117 // 语言代码的规范化在消费侧(ci-protocol::ai_languages)完成;这里只要求非空,
118 // 以便部署时可以直接写 ja / ko / fr 等任意 BCP-47 代码。
119 if self.output_language.trim().is_empty() {
120 return Err("ai.output_language must not be empty".to_string());
121 }
122 Ok(())
123 }
124}
No newline at end of file
Added lib/config/src/aihost.rs View lib/config/src/aihost.rs
@@ -0,0 +1,64 @@
1use std::net::IpAddr;
2
3use serde::Deserialize;
4
5/// AI 工作者进程(aihost)自身的监听配置。
6#[derive(Clone, Debug, Deserialize)]
7#[serde(default)]
8pub struct AiHostServerConfig {
9 pub enabled: Option<bool>,
10 pub bind_addr: Option<String>,
11 pub port: Option<u16>,
12 pub concurrency: usize,
13 pub poll_interval_secs: u64,
14 pub retry_backoff_base_secs: u64,
15}
16
17impl Default for AiHostServerConfig {
18 fn default() -> Self {
19 Self {
20 enabled: None,
21 bind_addr: None,
22 port: None,
23 concurrency: 2,
24 poll_interval_secs: 5,
25 retry_backoff_base_secs: 2,
26 }
27 }
28}
29
30impl AiHostServerConfig {
31 pub fn enabled(&self) -> bool {
32 self.enabled.unwrap_or(true)
33 }
34
35 pub fn bind_addr(&self) -> &str {
36 self.bind_addr.as_deref().unwrap_or("0.0.0.0")
37 }
38
39 pub fn port(&self) -> u16 {
40 self.port.unwrap_or(3006)
41 }
42
43 pub fn is_valid(&self) -> Result<(), String> {
44 if !self.enabled() {
45 return Ok(());
46 }
47 if self.bind_addr().parse::<IpAddr>().is_err() {
48 return Err(format!(
49 "aihost.bind_addr is not a valid IP: {:?}",
50 self.bind_addr()
51 ));
52 }
53 if self.port == Some(0) {
54 return Err("aihost.port must be greater than 0".to_string());
55 }
56 if self.concurrency == 0 {
57 return Err("aihost.concurrency must be greater than zero".to_string());
58 }
59 if self.poll_interval_secs == 0 || self.retry_backoff_base_secs == 0 {
60 return Err("aihost intervals must be greater than zero".to_string());
61 }
62 Ok(())
63 }
64}
No newline at end of file
Modified lib/config/src/health.rs View lib/config/src/health.rs
@@ -9,7 +9,7 @@
99}
1010
1111/// Health endpoints for binaries without a business HTTP listener
12/// (synchost, mails). Apps that already serve HTTP (apihost, githost, im)
12/// (synchost, mails, aihost). Apps that already serve HTTP (apihost, githost, im)
1313/// expose /healthz + /readyz on their own port instead.
1414#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1515#[serde(default)]
@@ -18,6 +18,7 @@
1818 pub bind_addr: Option<String>,
1919 pub synchost: HealthServiceConfig,
2020 pub mails: HealthServiceConfig,
21 pub aihost: HealthServiceConfig,
2122}
2223
2324impl HealthConfig {
@@ -37,6 +38,10 @@
3738 self.mails.port.unwrap_or(3003)
3839 }
3940
41 pub fn aihost_port(&self) -> u16 {
42 self.aihost.port.unwrap_or(3006)
43 }
44
4045 pub fn is_valid(&self) -> Result<(), String> {
4146 if !self.enabled() {
4247 return Ok(());
@@ -51,6 +56,9 @@
5156 if self.mails.port == Some(0) {
5257 return Err("health.mails.port must be greater than 0".to_string());
5358 }
59 if self.aihost.port == Some(0) {
60 return Err("health.aihost.port must be greater than 0".to_string());
61 }
5462 Ok(())
5563 }
56}
64}
No newline at end of file
Modified lib/config/src/lib.rs View lib/config/src/lib.rs
@@ -1,4 +1,6 @@
11pub mod admin;
2pub mod ai;
3pub mod aihost;
24pub mod apihost;
35pub mod auth;
46pub mod cache;
@@ -32,6 +34,8 @@
3234pub use error::ConfigError;
3335
3436use crate::admin::AdminConfig;
37use crate::ai::AiConfig;
38use crate::aihost::AiHostServerConfig;
3539use crate::apihost::ApihostConfig;
3640use crate::auth::AuthConfig;
3741use crate::cache::CacheConfig;
@@ -87,6 +91,8 @@
8791 pub ci: CiConfig,
8892 pub webhook: WebhookConfig,
8993 pub runner: RunnerConfig,
94 pub ai: AiConfig,
95 pub aihost: AiHostServerConfig,
9096}
9197
9298impl AppConfig {
@@ -115,6 +121,8 @@
115121 self.admin.is_valid().map_err(ConfigError::Validation)?;
116122 self.ci.validate().map_err(ConfigError::Validation)?;
117123 self.webhook.is_valid().map_err(ConfigError::Validation)?;
124 self.ai.validate().map_err(ConfigError::Validation)?;
125 self.aihost.is_valid().map_err(ConfigError::Validation)?;
118126 Ok(())
119127 }
120}
128}
No newline at end of file
Modified lib/config/src/scoped.rs View lib/config/src/scoped.rs
@@ -2,6 +2,8 @@
22use serde::de::DeserializeOwned;
33
44use crate::admin::AdminConfig as AdminSection;
5use crate::ai::AiConfig;
6use crate::aihost::AiHostServerConfig;
57use crate::apihost::ApihostConfig as ApihostSection;
68use crate::auth::AuthConfig;
79use crate::cache::CacheConfig;
@@ -152,6 +154,7 @@
152154 pub git: GitConfig,
153155 pub githost: GithostSection,
154156 pub ci: CiConfig,
157 pub ai: AiConfig,
155158}
156159
157160impl GithostConfig {
@@ -167,6 +170,7 @@
167170 git: self.git,
168171 githost: self.githost,
169172 ci: self.ci,
173 ai: self.ai,
170174 ..Default::default()
171175 }
172176 }
@@ -179,7 +183,8 @@
179183 self.cache.validate()?;
180184 self.email.is_valid()?;
181185 self.email_queue.is_valid()?;
182 self.githost.is_valid()
186 self.githost.is_valid()?;
187 self.ai.validate()
183188 }
184189}
185190
@@ -205,6 +210,7 @@
205210 pub apihost: ApihostSection,
206211 pub search: SearchConfig,
207212 pub import: ImportConfig,
213 pub ai: AiConfig,
208214}
209215
210216impl ApihostConfig {
@@ -229,6 +235,7 @@
229235 apihost: self.apihost,
230236 search: self.search,
231237 import: self.import,
238 ai: self.ai,
232239 ..Default::default()
233240 }
234241 }
@@ -247,7 +254,8 @@
247254 self.session.is_valid()?;
248255 self.search.validate()?;
249256 self.import.validate()?;
250 self.apihost.is_valid()
257 self.apihost.is_valid()?;
258 self.ai.validate()
251259 }
252260}
253261
@@ -342,3 +350,48 @@
342350 self.admin.is_valid()
343351 }
344352}
353
354
355#[derive(Clone, Debug, Default, Deserialize)]
356#[serde(default)]
357pub struct AiHostConfig {
358 pub database: DatabaseConfig,
359 pub redis: RedisConfig,
360 pub cache: CacheConfig,
361 pub git: GitConfig,
362 pub email: EmailConfig,
363 pub email_queue: EmailQueueConfig,
364 pub s3: S3Config,
365 pub ai: AiConfig,
366 pub aihost: AiHostServerConfig,
367 pub health: HealthConfig,
368}
369
370impl AiHostConfig {
371 pub fn into_app_config(self) -> AppConfig {
372 AppConfig {
373 database: self.database,
374 redis: self.redis,
375 cache: self.cache,
376 git: self.git,
377 email: self.email,
378 email_queue: self.email_queue,
379 s3: self.s3,
380 ai: self.ai,
381 aihost: self.aihost,
382 health: self.health,
383 ..Default::default()
384 }
385 }
386}
387
388impl ScopedConfig for AiHostConfig {
389 fn validate_scoped(&self) -> Result<(), String> {
390 self.database.validate()?;
391 self.redis.validate()?;
392 self.cache.validate()?;
393 self.ai.validate()?;
394 self.aihost.is_valid()?;
395 self.health.is_valid()
396 }
397}
No newline at end of file
Added lib/git/src/handler/ai_review/list_commit_ai_reviews.rs View lib/git/src/handler/ai_review/list_commit_ai_reviews.rs
@@ -0,0 +1,37 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::{ApiError, GitApiError};
6use crate::handler::resolve_reader;
7use crate::service::GitService;
8use crate::service::ai_review::CommitAiReviewDto;
9
10#[utoipa::path(
11 get,
12 path = "/repos/{space_name}/{repo_name}/commits/{revision}/ai-reviews",
13 tag = "ai",
14 description = "列出某个提交的 AI 评审结果(新→旧)。",
15 params(crate::handler::RepoPathRev),
16 responses(
17 (status = 200, description = "commit ai reviews", body = AppResponseData<Vec<CommitAiReviewDto>>)
18 )
19)]
20pub async fn list_commit_ai_reviews(
21 State(service): State<GitService>,
22 session: Session,
23 Path(path): Path<crate::handler::RepoPathRev>,
24) -> Result<AppResponseData<Vec<CommitAiReviewDto>>, ApiError> {
25 let _ = resolve_reader(
26 &service,
27 &session,
28 path.space_name.clone(),
29 path.repo_name.clone(),
30 )
31 .await?;
32 let out = service
33 .list_commit_ai_reviews(path.space_name, path.repo_name, path.revision)
34 .await
35 .map_err(GitApiError::from)?;
36 Ok(AppResponseData::ok(out))
37}
No newline at end of file
Added lib/git/src/handler/ai_review/mod.rs View lib/git/src/handler/ai_review/mod.rs
@@ -0,0 +1,2 @@
1pub mod list_commit_ai_reviews;
2pub mod trigger_ai_review;
No newline at end of file
Added lib/git/src/handler/ai_review/trigger_ai_review.rs View lib/git/src/handler/ai_review/trigger_ai_review.rs
@@ -0,0 +1,54 @@
1use axum::Json;
2use axum::extract::{Path, State};
3use export::rest::AppResponseData;
4use session::Session;
5
6use crate::errors::{ApiError, GitApiError};
7use crate::handler::require_writer;
8use crate::service::GitService;
9use crate::service::ai_review::AiReviewJobDto;
10
11#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)]
12pub struct TriggerAiReviewCmd {
13 /// 目标提交;缺省使用该仓库默认分支的最新提交。
14 #[serde(default)]
15 pub revision: Option<String>,
16 /// 输出语言(BCP-47 或 `auto`);缺省跟随触发者的语言偏好。
17 #[serde(default)]
18 pub language: Option<String>,
19}
20
21#[utoipa::path(
22 post,
23 path = "/repos/{space_name}/{repo_name}/ai-review",
24 tag = "ai",
25 description = "手动触发(或重跑)提交级 AI 评审;同时写入 context=ai-review 的提交状态。",
26 params(crate::handler::RepoPath),
27 request_body = TriggerAiReviewCmd,
28 responses(
29 (status = 200, description = "ai review job", body = AppResponseData<AiReviewJobDto>)
30 )
31)]
32pub async fn trigger_commit_ai_review(
33 State(service): State<GitService>,
34 session: Session,
35 Path(path): Path<crate::handler::RepoPath>,
36 Json(cmd): Json<TriggerAiReviewCmd>,
37) -> Result<AppResponseData<AiReviewJobDto>, ApiError> {
38 let (_ctx, uid) = require_writer(
39 &service,
40 &session,
41 path.space_name.clone(),
42 path.repo_name.clone(),
43 )
44 .await?;
45 let revision = cmd
46 .revision
47 .filter(|value| !value.trim().is_empty())
48 .unwrap_or_else(|| "HEAD".to_string());
49 let out = service
50 .trigger_commit_ai_review(path.space_name, path.repo_name, revision, uid, cmd.language)
51 .await
52 .map_err(GitApiError::from)?;
53 Ok(AppResponseData::ok(out))
54}
No newline at end of file
Added lib/git/src/handler/ai_summary/get_ai_summary.rs View lib/git/src/handler/ai_summary/get_ai_summary.rs
@@ -0,0 +1,37 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::{ApiError, GitApiError};
6use crate::handler::resolve_reader;
7use crate::service::GitService;
8use crate::service::ai_summary::RepoAiSummaryDto;
9
10#[utoipa::path(
11 get,
12 path = "/repos/{space_name}/{repo_name}/ai-summary",
13 tag = "ai",
14 description = "读取当前默认分支的仓库更新总结;尚未生成时 data 为 null。",
15 params(crate::handler::RepoPath),
16 responses(
17 (status = 200, description = "repo ai summary", body = AppResponseData<Option<RepoAiSummaryDto>>)
18 )
19)]
20pub async fn get_ai_summary(
21 State(service): State<GitService>,
22 session: Session,
23 Path(path): Path<crate::handler::RepoPath>,
24) -> Result<AppResponseData<Option<RepoAiSummaryDto>>, ApiError> {
25 let _ = resolve_reader(
26 &service,
27 &session,
28 path.space_name.clone(),
29 path.repo_name.clone(),
30 )
31 .await?;
32 let out = service
33 .get_repo_ai_summary(path.space_name, path.repo_name)
34 .await
35 .map_err(GitApiError::from)?;
36 Ok(AppResponseData::ok(out))
37}
No newline at end of file
Added lib/git/src/handler/ai_summary/mod.rs View lib/git/src/handler/ai_summary/mod.rs
@@ -0,0 +1,2 @@
1pub mod get_ai_summary;
2pub mod trigger_ai_summary;
No newline at end of file
Added lib/git/src/handler/ai_summary/trigger_ai_summary.rs View lib/git/src/handler/ai_summary/trigger_ai_summary.rs
@@ -0,0 +1,41 @@
1use axum::Json;
2use axum::extract::{Path, State};
3use export::rest::AppResponseData;
4use session::Session;
5use uuid::Uuid;
6
7use crate::errors::{ApiError, GitApiError};
8use crate::handler::require_writer;
9use crate::service::GitService;
10use crate::service::ai_summary::TriggerSummaryCmd;
11
12#[utoipa::path(
13 post,
14 path = "/repos/{space_name}/{repo_name}/ai-summary",
15 tag = "ai",
16 description = "触发(或刷新)仓库更新总结;同一 head 覆盖既有总结,任务异步执行。",
17 params(crate::handler::RepoPath),
18 request_body = TriggerSummaryCmd,
19 responses(
20 (status = 200, description = "ai summary job id", body = AppResponseData<Uuid>)
21 )
22)]
23pub async fn trigger_ai_summary(
24 State(service): State<GitService>,
25 session: Session,
26 Path(path): Path<crate::handler::RepoPath>,
27 Json(cmd): Json<TriggerSummaryCmd>,
28) -> Result<AppResponseData<Uuid>, ApiError> {
29 let (_ctx, uid) = require_writer(
30 &service,
31 &session,
32 path.space_name.clone(),
33 path.repo_name.clone(),
34 )
35 .await?;
36 let job_id = service
37 .trigger_repo_ai_summary(path.space_name, path.repo_name, Some(uid), cmd)
38 .await
39 .map_err(GitApiError::from)?;
40 Ok(AppResponseData::ok(job_id))
41}
No newline at end of file
Added lib/git/src/handler/code_comments/create_code_comment.rs View lib/git/src/handler/code_comments/create_code_comment.rs
@@ -0,0 +1,40 @@
1use axum::Json;
2use axum::extract::{Path, State};
3use export::rest::AppResponseData;
4use session::Session;
5
6use crate::errors::{ApiError, GitApiError};
7use crate::handler::require_writer;
8use crate::service::GitService;
9use crate::service::code_comments::{CodeCommentDto, CreateCodeCommentCmd};
10
11#[utoipa::path(
12 post,
13 path = "/repos/{space_name}/{repo_name}/code-comments",
14 tag = "code-comments",
15 description = "在代码浏览/提交差异的某一行发表行级评论;锚定到解析出的提交快照。",
16 params(crate::handler::RepoPath),
17 request_body = CreateCodeCommentCmd,
18 responses(
19 (status = 200, description = "created code comment", body = AppResponseData<CodeCommentDto>)
20 )
21)]
22pub async fn create_code_comment(
23 State(service): State<GitService>,
24 session: Session,
25 Path(path): Path<crate::handler::RepoPath>,
26 Json(cmd): Json<CreateCodeCommentCmd>,
27) -> Result<AppResponseData<CodeCommentDto>, ApiError> {
28 let (_ctx, uid) = require_writer(
29 &service,
30 &session,
31 path.space_name.clone(),
32 path.repo_name.clone(),
33 )
34 .await?;
35 let out = service
36 .create_code_comment(path.space_name, path.repo_name, uid, cmd)
37 .await
38 .map_err(GitApiError::from)?;
39 Ok(AppResponseData::ok(out))
40}
No newline at end of file
Added lib/git/src/handler/code_comments/list_code_comments.rs View lib/git/src/handler/code_comments/list_code_comments.rs
@@ -0,0 +1,38 @@
1use axum::extract::{Path, Query, State};
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::{ApiError, GitApiError};
6use crate::handler::resolve_reader;
7use crate::service::GitService;
8use crate::service::code_comments::{CodeCommentPage, ListCodeCommentsQuery};
9
10#[utoipa::path(
11 get,
12 path = "/repos/{space_name}/{repo_name}/code-comments",
13 tag = "code-comments",
14 description = "列出代码行级评论;可按 path 与 revision 过滤,锚定提交非当前分支提示时标记 outdated。",
15 params(crate::handler::RepoPath, ListCodeCommentsQuery),
16 responses(
17 (status = 200, description = "code comment page", body = AppResponseData<CodeCommentPage>)
18 )
19)]
20pub async fn list_code_comments(
21 State(service): State<GitService>,
22 session: Session,
23 Path(path): Path<crate::handler::RepoPath>,
24 Query(query): Query<ListCodeCommentsQuery>,
25) -> Result<AppResponseData<CodeCommentPage>, ApiError> {
26 let _ = resolve_reader(
27 &service,
28 &session,
29 path.space_name.clone(),
30 path.repo_name.clone(),
31 )
32 .await?;
33 let out = service
34 .list_code_comments(path.space_name, path.repo_name, query)
35 .await
36 .map_err(GitApiError::from)?;
37 Ok(AppResponseData::ok(out))
38}
No newline at end of file
Added lib/git/src/handler/code_comments/mod.rs View lib/git/src/handler/code_comments/mod.rs
@@ -0,0 +1,4 @@
1pub mod create_code_comment;
2pub mod list_code_comments;
3pub mod modify;
4pub mod resolve_code_comment;
No newline at end of file
Added lib/git/src/handler/code_comments/modify.rs View lib/git/src/handler/code_comments/modify.rs
@@ -0,0 +1,93 @@
1use axum::Json;
2use axum::extract::{Path, State};
3use export::rest::{AppResponseData, NoData};
4use session::Session;
5use uuid::Uuid;
6
7use crate::errors::{ApiError, GitApiError};
8use crate::handler::{require_repo_admin, require_writer};
9use crate::service::GitService;
10use crate::service::code_comments::{CodeCommentDto, UpdateCodeCommentCmd};
11
12#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
13pub struct CodeCommentPath {
14 pub space_name: String,
15 pub repo_name: String,
16 pub comment_id: Uuid,
17}
18
19#[utoipa::path(
20 patch,
21 path = "/repos/{space_name}/{repo_name}/code-comments/{comment_id}",
22 tag = "code-comments",
23 description = "修改行级评论正文;仅作者或仓库管理员可操作。",
24 params(CodeCommentPath),
25 request_body = UpdateCodeCommentCmd,
26 responses(
27 (status = 200, description = "updated code comment", body = AppResponseData<CodeCommentDto>)
28 )
29)]
30pub async fn update_code_comment(
31 State(service): State<GitService>,
32 session: Session,
33 Path(path): Path<CodeCommentPath>,
34 Json(cmd): Json<UpdateCodeCommentCmd>,
35) -> Result<AppResponseData<CodeCommentDto>, ApiError> {
36 let (_ctx, uid) = require_writer(
37 &service,
38 &session,
39 path.space_name.clone(),
40 path.repo_name.clone(),
41 )
42 .await?;
43 let admin = require_repo_admin(
44 &service,
45 &session,
46 path.space_name.clone(),
47 path.repo_name.clone(),
48 )
49 .await
50 .is_ok();
51 let out = service
52 .update_code_comment(path.space_name, path.repo_name, path.comment_id, uid, admin, cmd)
53 .await
54 .map_err(GitApiError::from)?;
55 Ok(AppResponseData::ok(out))
56}
57
58#[utoipa::path(
59 delete,
60 path = "/repos/{space_name}/{repo_name}/code-comments/{comment_id}",
61 tag = "code-comments",
62 description = "删除行级评论及其回复;仅作者或仓库管理员可操作。",
63 params(CodeCommentPath),
64 responses(
65 (status = 200, description = "code comment deleted", body = AppResponseData<NoData>)
66 )
67)]
68pub async fn delete_code_comment(
69 State(service): State<GitService>,
70 session: Session,
71 Path(path): Path<CodeCommentPath>,
72) -> Result<AppResponseData<NoData>, ApiError> {
73 let (_ctx, uid) = require_writer(
74 &service,
75 &session,
76 path.space_name.clone(),
77 path.repo_name.clone(),
78 )
79 .await?;
80 let admin = require_repo_admin(
81 &service,
82 &session,
83 path.space_name.clone(),
84 path.repo_name.clone(),
85 )
86 .await
87 .is_ok();
88 service
89 .delete_code_comment(path.space_name, path.repo_name, path.comment_id, uid, admin)
90 .await
91 .map_err(GitApiError::from)?;
92 Ok(AppResponseData::ok(NoData))
93}
No newline at end of file
Added lib/git/src/handler/code_comments/resolve_code_comment.rs View lib/git/src/handler/code_comments/resolve_code_comment.rs
@@ -0,0 +1,66 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::{ApiError, GitApiError};
6use crate::handler::require_writer;
7use crate::service::GitService;
8use crate::service::code_comments::CodeCommentDto;
9
10#[utoipa::path(
11 put,
12 path = "/repos/{space_name}/{repo_name}/code-comments/{comment_id}/resolve",
13 tag = "code-comments",
14 description = "把行级评论标记为已解决。",
15 params(super::modify::CodeCommentPath),
16 responses(
17 (status = 200, description = "resolved code comment", body = AppResponseData<CodeCommentDto>)
18 )
19)]
20pub async fn resolve_code_comment(
21 State(service): State<GitService>,
22 session: Session,
23 Path(path): Path<super::modify::CodeCommentPath>,
24) -> Result<AppResponseData<CodeCommentDto>, ApiError> {
25 let (_ctx, uid) = require_writer(
26 &service,
27 &session,
28 path.space_name.clone(),
29 path.repo_name.clone(),
30 )
31 .await?;
32 let out = service
33 .set_code_comment_resolved(path.space_name, path.repo_name, path.comment_id, uid, true)
34 .await
35 .map_err(GitApiError::from)?;
36 Ok(AppResponseData::ok(out))
37}
38
39#[utoipa::path(
40 delete,
41 path = "/repos/{space_name}/{repo_name}/code-comments/{comment_id}/resolve",
42 tag = "code-comments",
43 description = "取消已解决标记。",
44 params(super::modify::CodeCommentPath),
45 responses(
46 (status = 200, description = "unresolved code comment", body = AppResponseData<CodeCommentDto>)
47 )
48)]
49pub async fn unresolve_code_comment(
50 State(service): State<GitService>,
51 session: Session,
52 Path(path): Path<super::modify::CodeCommentPath>,
53) -> Result<AppResponseData<CodeCommentDto>, ApiError> {
54 let (_ctx, uid) = require_writer(
55 &service,
56 &session,
57 path.space_name.clone(),
58 path.repo_name.clone(),
59 )
60 .await?;
61 let out = service
62 .set_code_comment_resolved(path.space_name, path.repo_name, path.comment_id, uid, false)
63 .await
64 .map_err(GitApiError::from)?;
65 Ok(AppResponseData::ok(out))
66}
No newline at end of file
Added lib/git/src/handler/releases/create_release.rs View lib/git/src/handler/releases/create_release.rs
@@ -0,0 +1,40 @@
1use axum::Json;
2use axum::extract::{Path, State};
3use export::rest::AppResponseData;
4use session::Session;
5
6use crate::errors::{ApiError, GitApiError};
7use crate::handler::require_writer;
8use crate::service::GitService;
9use crate::service::releases::{CreateReleaseCmd, ReleaseDto};
10
11#[utoipa::path(
12 post,
13 path = "/repos/{space_name}/{repo_name}/releases",
14 tag = "releases",
15 description = "创建发布记录;草稿不会对访客展示,发布后正文不可再修改。",
16 params(crate::handler::RepoPath),
17 request_body = CreateReleaseCmd,
18 responses(
19 (status = 200, description = "created release", body = AppResponseData<ReleaseDto>)
20 )
21)]
22pub async fn create_release(
23 State(service): State<GitService>,
24 session: Session,
25 Path(path): Path<crate::handler::RepoPath>,
26 Json(cmd): Json<CreateReleaseCmd>,
27) -> Result<AppResponseData<ReleaseDto>, ApiError> {
28 let (_ctx, uid) = require_writer(
29 &service,
30 &session,
31 path.space_name.clone(),
32 path.repo_name.clone(),
33 )
34 .await?;
35 let out = service
36 .create_release(path.space_name, path.repo_name, uid, cmd)
37 .await
38 .map_err(GitApiError::from)?;
39 Ok(AppResponseData::ok(out))
40}
No newline at end of file
Added lib/git/src/handler/releases/delete_release.rs View lib/git/src/handler/releases/delete_release.rs
@@ -0,0 +1,44 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::{ApiError, GitApiError};
6use crate::handler::{require_repo_admin, require_writer};
7use crate::service::GitService;
8
9#[utoipa::path(
10 delete,
11 path = "/repos/{space_name}/{repo_name}/releases/{tag}",
12 tag = "releases",
13 description = "删除发布记录(不删除 git 标签本身)。",
14 params(crate::handler::releases::get_release::ReleasePath),
15 responses(
16 (status = 200, description = "release deleted", body = AppResponseData<NoData>)
17 )
18)]
19pub async fn delete_release(
20 State(service): State<GitService>,
21 session: Session,
22 Path(path): Path<crate::handler::releases::get_release::ReleasePath>,
23) -> Result<AppResponseData<NoData>, ApiError> {
24 let (_ctx, uid) = require_writer(
25 &service,
26 &session,
27 path.space_name.clone(),
28 path.repo_name.clone(),
29 )
30 .await?;
31 let admin = require_repo_admin(
32 &service,
33 &session,
34 path.space_name.clone(),
35 path.repo_name.clone(),
36 )
37 .await
38 .is_ok();
39 service
40 .delete_release(path.space_name, path.repo_name, path.tag, uid, admin)
41 .await
42 .map_err(GitApiError::from)?;
43 Ok(AppResponseData::ok(NoData))
44}
No newline at end of file
Added lib/git/src/handler/releases/get_release.rs View lib/git/src/handler/releases/get_release.rs
@@ -0,0 +1,44 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::{ApiError, GitApiError};
6use crate::handler::resolve_reader;
7use crate::service::GitService;
8use crate::service::releases::ReleaseDto;
9
10#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
11pub struct ReleasePath {
12 pub space_name: String,
13 pub repo_name: String,
14 pub tag: String,
15}
16
17#[utoipa::path(
18 get,
19 path = "/repos/{space_name}/{repo_name}/releases/{tag}",
20 tag = "releases",
21 description = "按标签读取单条发布记录。",
22 params(ReleasePath),
23 responses(
24 (status = 200, description = "release", body = AppResponseData<ReleaseDto>)
25 )
26)]
27pub async fn get_release(
28 State(service): State<GitService>,
29 session: Session,
30 Path(path): Path<ReleasePath>,
31) -> Result<AppResponseData<ReleaseDto>, ApiError> {
32 let _ = resolve_reader(
33 &service,
34 &session,
35 path.space_name.clone(),
36 path.repo_name.clone(),
37 )
38 .await?;
39 let out = service
40 .get_release(path.space_name, path.repo_name, path.tag)
41 .await
42 .map_err(GitApiError::from)?;
43 Ok(AppResponseData::ok(out))
44}
No newline at end of file
Added lib/git/src/handler/releases/list_releases.rs View lib/git/src/handler/releases/list_releases.rs
@@ -0,0 +1,38 @@
1use axum::extract::{Path, Query, State};
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::{ApiError, GitApiError};
6use crate::handler::resolve_reader;
7use crate::service::GitService;
8use crate::service::releases::{ListReleasesQuery, ReleasePage};
9
10#[utoipa::path(
11 get,
12 path = "/repos/{space_name}/{repo_name}/releases",
13 tag = "releases",
14 description = "列出仓库发布记录(新→旧),可按草稿状态过滤。",
15 params(crate::handler::RepoPath, ListReleasesQuery),
16 responses(
17 (status = 200, description = "release page", body = AppResponseData<ReleasePage>)
18 )
19)]
20pub async fn list_releases(
21 State(service): State<GitService>,
22 session: Session,
23 Path(path): Path<crate::handler::RepoPath>,
24 Query(query): Query<ListReleasesQuery>,
25) -> Result<AppResponseData<ReleasePage>, ApiError> {
26 let _ = resolve_reader(
27 &service,
28 &session,
29 path.space_name.clone(),
30 path.repo_name.clone(),
31 )
32 .await?;
33 let out = service
34 .list_releases(path.space_name, path.repo_name, query)
35 .await
36 .map_err(GitApiError::from)?;
37 Ok(AppResponseData::ok(out))
38}
No newline at end of file
Added lib/git/src/handler/releases/mod.rs View lib/git/src/handler/releases/mod.rs
@@ -0,0 +1,6 @@
1pub mod create_release;
2pub mod delete_release;
3pub mod get_release;
4pub mod list_releases;
5pub mod redraft_release;
6pub mod update_release;
No newline at end of file
Added lib/git/src/handler/releases/redraft_release.rs View lib/git/src/handler/releases/redraft_release.rs
@@ -0,0 +1,41 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::{ApiError, GitApiError};
7use crate::handler::require_writer;
8use crate::service::GitService;
9
10#[utoipa::path(
11 post,
12 path = "/repos/{space_name}/{repo_name}/releases/{tag}/ai-draft",
13 tag = "releases",
14 description = "重新为草稿发布记录生成 AI 文案;已发布的记录会被拒绝。",
15 params(crate::handler::releases::get_release::ReleasePath),
16 responses(
17 (status = 200, description = "ai draft job id", body = AppResponseData<Uuid>)
18 )
19)]
20pub async fn redraft_release(
21 State(service): State<GitService>,
22 session: Session,
23 Path(path): Path<crate::handler::releases::get_release::ReleasePath>,
24) -> Result<AppResponseData<Uuid>, ApiError> {
25 let (ctx, uid) = require_writer(
26 &service,
27 &session,
28 path.space_name.clone(),
29 path.repo_name.clone(),
30 )
31 .await?;
32 let release = service
33 .release_model(ctx.model.id, &path.tag)
34 .await
35 .map_err(GitApiError::from)?;
36 let job_id = service
37 .redraft_release(&ctx, &release, Some(uid))
38 .await
39 .map_err(GitApiError::from)?;
40 Ok(AppResponseData::ok(job_id))
41}
No newline at end of file
Added lib/git/src/handler/releases/update_release.rs View lib/git/src/handler/releases/update_release.rs
@@ -0,0 +1,48 @@
1use axum::Json;
2use axum::extract::{Path, State};
3use export::rest::AppResponseData;
4use session::Session;
5
6use crate::errors::{ApiError, GitApiError};
7use crate::handler::{require_repo_admin, require_writer};
8use crate::service::GitService;
9use crate::service::releases::{ReleaseDto, UpdateReleaseCmd};
10
11#[utoipa::path(
12 patch,
13 path = "/repos/{space_name}/{repo_name}/releases/{tag}",
14 tag = "releases",
15 description = "更新发布记录;把 is_draft 置为 false 即发布,发布后标题与正文不可再改。",
16 params(crate::handler::releases::get_release::ReleasePath),
17 request_body = UpdateReleaseCmd,
18 responses(
19 (status = 200, description = "updated release", body = AppResponseData<ReleaseDto>)
20 )
21)]
22pub async fn update_release(
23 State(service): State<GitService>,
24 session: Session,
25 Path(path): Path<crate::handler::releases::get_release::ReleasePath>,
26 Json(cmd): Json<UpdateReleaseCmd>,
27) -> Result<AppResponseData<ReleaseDto>, ApiError> {
28 let (_ctx, uid) = require_writer(
29 &service,
30 &session,
31 path.space_name.clone(),
32 path.repo_name.clone(),
33 )
34 .await?;
35 let admin = require_repo_admin(
36 &service,
37 &session,
38 path.space_name.clone(),
39 path.repo_name.clone(),
40 )
41 .await
42 .is_ok();
43 let out = service
44 .update_release(path.space_name, path.repo_name, path.tag, uid, admin, cmd)
45 .await
46 .map_err(GitApiError::from)?;
47 Ok(AppResponseData::ok(out))
48}
No newline at end of file
Added lib/git/src/handler/ai_languages.rs View lib/git/src/handler/ai_languages.rs
@@ -0,0 +1,63 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use serde::{Deserialize, Serialize};
4use utoipa::ToSchema;
5
6use crate::errors::ApiError;
7use crate::service::GitService;
8
9/// 一门可选的输出语言。
10#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
11pub struct LanguageOptionDto {
12 /// BCP-47 代码(小写、`-` 分隔),如 `zh-cn`、`en`、`ja`。
13 pub code: String,
14 /// 本语名称,如「简体中文」。
15 pub native_name: String,
16 /// 英文名称,如 "Simplified Chinese"。
17 pub english_name: String,
18 /// 是否为「沿用内容语言」模式。
19 pub auto: bool,
20 /// 是否为部署时的缺省语言。
21 pub default: bool,
22}
23
24#[utoipa::path(
25 get,
26 path = "/ai/languages",
27 tag = "ai",
28 description = "AI 输出语言清单;前端据此渲染语言选择器。`auto` 表示沿用代码注释的语言。",
29 responses(
30 (status = 200, description = "supported languages", body = AppResponseData<Vec<LanguageOptionDto>>)
31 )
32)]
33pub async fn list_ai_languages(
34 State(service): State<GitService>,
35) -> Result<AppResponseData<Vec<LanguageOptionDto>>, ApiError> {
36 let configured = ci_protocol::ai_languages::normalize(&service.state.config.ai.output_language);
37 let options = ci_protocol::ai_languages::supported_codes()
38 .into_iter()
39 .map(|code| {
40 let auto = ci_protocol::ai_languages::is_auto(code);
41 let (native, english) = if auto {
42 ("沿用内容语言".to_string(), "Match content".to_string())
43 } else {
44 let display = ci_protocol::ai_languages::display_name(code);
45 // display_name 形如「简体中文 (Simplified Chinese)」;拆回两段。
46 match display.split_once(" (") {
47 Some((native, english)) => {
48 (native.to_string(), english.trim_end_matches(')').to_string())
49 }
50 None => (display.clone(), display),
51 }
52 };
53 LanguageOptionDto {
54 code: code.to_string(),
55 native_name: native,
56 english_name: english,
57 auto,
58 default: code == configured,
59 }
60 })
61 .collect();
62 Ok(AppResponseData::ok(options))
63}
No newline at end of file
Modified lib/git/src/handler/mod.rs View lib/git/src/handler/mod.rs
@@ -1,9 +1,13 @@
1pub mod ai_languages;
2pub mod ai_review;
3pub mod ai_summary;
14pub mod analysis;
25pub mod archives;
36pub mod blame;
47pub mod blobs;
58pub mod branch_protection;
69pub mod branches;
10pub mod code_comments;
711pub mod commits;
812pub mod conflicts;
913pub mod content;
@@ -18,6 +22,7 @@
1822pub mod patches;
1923pub mod path_log;
2024pub mod refs;
25pub mod releases;
2126pub mod remotes;
2227pub mod repos;
2328pub mod share;
Added lib/git/src/service/ai_review.rs View lib/git/src/service/ai_review.rs
@@ -0,0 +1,205 @@
1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3use utoipa::ToSchema;
4use uuid::Uuid;
5
6use crate::service::GitService;
7use crate::share::errors::GitError;
8
9/// 提交维度的 AI 评审结果视图。
10#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
11pub struct CommitAiReviewDto {
12 pub id: Uuid,
13 pub commit_sha: String,
14 pub verdict: models::ai::ai_review_result::AiReviewVerdict,
15 pub summary: String,
16 pub findings: Vec<AiFindingDto>,
17 pub truncated: bool,
18 /// 生成该评审时使用的输出语言(BCP-47 或 `auto`)。
19 pub language: String,
20 pub created_at: OffsetDateTime,
21}
22
23/// 行级发现视图;提交评论不落库,因此没有 comment_id。
24#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
25pub struct AiFindingDto {
26 pub path: String,
27 pub line: i32,
28 pub side: Option<String>,
29 pub severity: Option<String>,
30 pub body: String,
31}
32
33impl CommitAiReviewDto {
34 pub(crate) fn from_row(
35 row: models::ai::ai_review_result::Model,
36 commit_sha: &str,
37 ) -> Self {
38 let findings: Vec<models::ai::ai_review_result::AiFinding> =
39 serde_json::from_value(row.findings_json).unwrap_or_default();
40 Self {
41 id: row.id,
42 commit_sha: commit_sha.to_string(),
43 verdict: row.verdict,
44 summary: row.summary,
45 findings: findings
46 .into_iter()
47 .map(|finding| AiFindingDto {
48 path: finding.path,
49 line: finding.line,
50 side: finding.side,
51 severity: finding.severity,
52 body: finding.body,
53 })
54 .collect(),
55 truncated: row.truncated,
56 language: row.language,
57 created_at: row.created_at,
58 }
59 }
60}
61
62/// AI 评审使用的 commit status context。
63pub const AI_REVIEW_CONTEXT: &str = "ai-review";
64
65#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
66pub struct AiReviewJobDto {
67 pub job_id: Uuid,
68 pub status: String,
69 pub model: String,
70}
71
72impl GitService {
73 /// 触发(或重跑)某个提交的 AI 评审。
74 ///
75 /// 入队前先写一条 `pending` 的 commit status,让 PR 检查页即时可见;
76 /// 评审完成后由 aihost 改写为 success/failure/error。
77 pub async fn trigger_commit_ai_review(
78 &self,
79 space_name: String,
80 repo_name: String,
81 revision: String,
82 creator: Uuid,
83 language: Option<String>,
84 ) -> Result<AiReviewJobDto, GitError> {
85 let cfg = &self.state.config.ai;
86 if !cfg.review_on() {
87 return Err(GitError::InvalidArgument(
88 "ai review is disabled by config".to_string(),
89 ));
90 }
91 let ctx = self.context(space_name.clone(), repo_name.clone()).await?;
92 let sha = self
93 .baby(&ctx)
94 .rev_parse(&revision)
95 .await
96 .map_err(crate::share::errors::map_baby)?
97 .to_string();
98 self.sync_commit(&ctx, &sha).await?;
99
100 let task = ci_protocol::ai::AgentTask::CommitReview {
101 job_id: Uuid::now_v7(),
102 repo_id: ctx.model.id,
103 commit_sha: sha.clone(),
104 language: match language.filter(|value| !value.trim().is_empty()) {
105 Some(explicit) => Some(ci_protocol::ai_languages::normalize(&explicit)),
106 None => crate::sync::ai_language::actor_language(self.db_read(), Some(creator)).await,
107 },
108 };
109 let job_id = crate::sync::ai_task::submit_ai_task(
110 self.db_write(),
111 &self.state.redis,
112 cfg,
113 &task,
114 )
115 .await
116 .map_err(|e| GitError::Internal(format!("submit ai review: {e}")))?;
117
118 self.set_ai_review_status(
119 space_name,
120 repo_name,
121 creator,
122 &sha,
123 crate::service::commits::CommitStatusDtoState::Pending,
124 "AI 评审进行中",
125 None,
126 )
127 .await?;
128
129 Ok(AiReviewJobDto {
130 job_id,
131 status: "pending".to_string(),
132 model: cfg.model.trim().to_string(),
133 })
134 }
135
136 /// 写入/更新 `ai-review` 这一条 commit status。
137 pub async fn set_ai_review_status(
138 &self,
139 space_name: String,
140 repo_name: String,
141 creator: Uuid,
142 sha: &str,
143 state: crate::service::commits::CommitStatusDtoState,
144 description: &str,
145 target_url: Option<String>,
146 ) -> Result<(), GitError> {
147 self.set_commit_status(
148 space_name,
149 repo_name,
150 creator,
151 crate::service::commits::SetCommitStatusCmd {
152 sha: sha.to_string(),
153 state,
154 context: AI_REVIEW_CONTEXT.to_string(),
155 description: Some(description.to_string()),
156 target_url,
157 },
158 )
159 .await?;
160 Ok(())
161 }
162
163 /// 该提交是否已有 AI 评审结果(用于前端展示就绪状态)。
164 pub async fn has_ai_review(&self, repo_id: Uuid, commit_sha: &str) -> bool {
165 use models::ai::ai_review_result;
166 use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
167 ai_review_result::Entity::find()
168 .filter(ai_review_result::Column::RepoId.eq(repo_id))
169 .filter(ai_review_result::Column::CommitSha.eq(commit_sha))
170 .one(self.db_read())
171 .await
172 .ok()
173 .flatten()
174 .is_some()
175 }
176
177 /// 列出某个提交的全部 AI 评审结果。
178 pub async fn list_commit_ai_reviews(
179 &self,
180 space_name: String,
181 repo_name: String,
182 revision: String,
183 ) -> Result<Vec<crate::service::ai_review::CommitAiReviewDto>, GitError> {
184 use models::ai::ai_review_result;
185 use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
186 let ctx = self.context(space_name, repo_name).await?;
187 let sha = self
188 .baby(&ctx)
189 .rev_parse(&revision)
190 .await
191 .map_err(crate::share::errors::map_baby)?
192 .to_string();
193 let rows = ai_review_result::Entity::find()
194 .filter(ai_review_result::Column::RepoId.eq(ctx.model.id))
195 .filter(ai_review_result::Column::CommitSha.eq(sha.clone()))
196 .order_by_desc(ai_review_result::Column::CreatedAt)
197 .all(self.db_read())
198 .await?;
199 Ok(rows
200 .into_iter()
201 .map(|row| crate::service::ai_review::CommitAiReviewDto::from_row(row, &sha))
202 .collect())
203 }
204}
205
Added lib/git/src/service/ai_summary.rs View lib/git/src/service/ai_summary.rs
@@ -0,0 +1,166 @@
1use crate::share::errors::GitError;
2use models::ai::repo_ai_summary;
3use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
4use serde::{Deserialize, Serialize};
5use time::OffsetDateTime;
6use utoipa::ToSchema;
7use uuid::Uuid;
8
9/// 仓库 AI 总结视图。
10#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
11pub struct RepoAiSummaryDto {
12 pub id: Uuid,
13 pub repo_id: Uuid,
14 pub head_sha: String,
15 pub base_sha: Option<String>,
16 pub commit_count: i32,
17 pub content: String,
18 pub highlights: Vec<String>,
19 pub risks: Vec<String>,
20 pub model: String,
21 /// 生成该总结时使用的输出语言(BCP-47 或 `auto`)。
22 pub language: String,
23 pub created_at: OffsetDateTime,
24 pub updated_at: OffsetDateTime,
25 /// 该总结是否对应当前默认分支提示。
26 pub current: bool,
27 /// 仍在进行中的作业 id(无进行中任务时为 null)。
28 pub pending_job_id: Option<Uuid>,
29}
30
31#[derive(Debug, Clone, Deserialize, ToSchema)]
32pub struct TriggerSummaryCmd {
33 /// 输出语言(BCP-47 或 `auto`);缺省跟随触发者的语言偏好。
34 #[serde(default)]
35 pub language: Option<String>,
36 /// 区间的起点引用(分支/标签/SHA);缺省由服务端取默认分支。
37 #[serde(default)]
38 pub base: Option<String>,
39 /// 参与总结的提交上限;缺省使用 `[ai].summary_commit_limit`。
40 #[serde(default)]
41 pub limit: Option<u64>,
42}
43
44impl super::GitService {
45 /// 读取当前默认分支的 AI 总结(若已生成)。
46 pub async fn get_repo_ai_summary(
47 &self,
48 space_name: String,
49 repo_name: String,
50 ) -> Result<Option<RepoAiSummaryDto>, GitError> {
51 let ctx = self.context(space_name, repo_name).await?;
52 let head = self
53 .baby(&ctx)
54 .rev_parse(&format!("refs/heads/{}", ctx.model.default_branch))
55 .await
56 .map(|oid| oid.to_string())
57 .map_err(crate::share::errors::map_baby)?;
58 let row = repo_ai_summary::Entity::find()
59 .filter(repo_ai_summary::Column::RepoId.eq(ctx.model.id))
60 .filter(repo_ai_summary::Column::HeadSha.eq(head.clone()))
61 .one(self.db_read())
62 .await?;
63 let pending = self.pending_summary_job(ctx.model.id, &head).await;
64 Ok(row.map(|row| Self::summary_dto(row, &head, pending)))
65 }
66
67 /// 触发(或刷新)当前 head 的 AI 总结。
68 pub async fn trigger_repo_ai_summary(
69 &self,
70 space_name: String,
71 repo_name: String,
72 actor: Option<Uuid>,
73 cmd: TriggerSummaryCmd,
74 ) -> Result<Uuid, GitError> {
75 let cfg = &self.state.config.ai;
76 if !cfg.summary_on() {
77 return Err(GitError::InvalidArgument(
78 "ai summary is disabled by config".to_string(),
79 ));
80 }
81 let ctx = self.context(space_name, repo_name).await?;
82 let head = self
83 .baby(&ctx)
84 .rev_parse(&format!("refs/heads/{}", ctx.model.default_branch))
85 .await
86 .map(|oid| oid.to_string())
87 .map_err(crate::share::errors::map_baby)?;
88 let base_sha = match cmd
89 .base
90 .as_deref()
91 .filter(|value| !value.trim().is_empty())
92 {
93 Some(base) => Some(
94 self.baby(&ctx)
95 .rev_parse(base)
96 .await
97 .map_err(crate::share::errors::map_baby)?
98 .to_string(),
99 ),
100 None => None,
101 };
102 let task = ci_protocol::ai::AgentTask::RepoSummary {
103 job_id: Uuid::now_v7(),
104 repo_id: ctx.model.id,
105 head_sha: head,
106 base_sha,
107 limit: cmd.limit,
108 language: match cmd.language.clone().filter(|value| !value.trim().is_empty()) {
109 Some(explicit) => Some(ci_protocol::ai_languages::normalize(&explicit)),
110 None => crate::sync::ai_language::actor_language(self.db_read(), actor).await,
111 },
112 };
113 crate::sync::ai_task::submit_ai_task(
114 self.db_write(),
115 &self.state.redis,
116 cfg,
117 &task,
118 )
119 .await
120 .map_err(|e| GitError::Internal(format!("submit ai summary: {e}")))
121 }
122
123 /// 该 head 上仍在排队/执行的总结作业。
124 async fn pending_summary_job(&self, repo_id: Uuid, head: &str) -> Option<Uuid> {
125 use models::ai::ai_job::{self, AiJobKind, AiJobStatus};
126 ai_job::Entity::find()
127 .filter(ai_job::Column::RepoId.eq(repo_id))
128 .filter(ai_job::Column::Kind.eq(AiJobKind::RepoSummary))
129 .filter(ai_job::Column::TargetRef.eq(head.to_string()))
130 .filter(
131 ai_job::Column::Status.is_in([AiJobStatus::Pending, AiJobStatus::Running]),
132 )
133 .one(self.db_read())
134 .await
135 .ok()
136 .flatten()
137 .map(|row| row.id)
138 }
139
140 fn summary_dto(
141 row: repo_ai_summary::Model,
142 head: &str,
143 pending_job_id: Option<Uuid>,
144 ) -> RepoAiSummaryDto {
145 let highlights: Vec<String> =
146 serde_json::from_value(row.highlights_json).unwrap_or_default();
147 let risks: Vec<String> = serde_json::from_value(row.risks_json).unwrap_or_default();
148 RepoAiSummaryDto {
149 id: row.id,
150 repo_id: row.repo_id,
151 head_sha: row.head_sha.clone(),
152 base_sha: row.base_sha,
153 commit_count: row.commit_count,
154 content: row.content,
155 highlights,
156 risks,
157 model: row.model,
158 language: row.language,
159 created_at: row.created_at,
160 updated_at: row.updated_at,
161 current: row.head_sha == head,
162 pending_job_id,
163 }
164 }
165}
166
Added lib/git/src/service/code_comments.rs View lib/git/src/service/code_comments.rs
@@ -0,0 +1,312 @@
1use export::pagination::Page;
2use crate::context::facade::GitContext;
3use models::ai::code_comment;
4use sea_orm::{
5 ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter,
6 QueryOrder, QuerySelect,
7};
8use serde::{Deserialize, Serialize};
9use space::services::spaces::UserDto;
10use time::OffsetDateTime;
11use utoipa::ToSchema;
12use uuid::Uuid;
13
14use crate::share::errors::GitError;
15
16const MAX_BODY_LEN: usize = 65_536;
17
18#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
19pub struct CodeCommentDto {
20 pub id: Uuid,
21 pub repo_id: Uuid,
22 pub path: String,
23 pub line: i32,
24 pub commit_sha: String,
25 pub author: Option<UserDto>,
26 pub parent_id: Option<Uuid>,
27 pub body: String,
28 pub resolved: bool,
29 pub resolved_by: Option<Uuid>,
30 /// 该评论锚定的提交是否仍是当前分支提示(否则视为 outdated)。
31 pub outdated: bool,
32 pub created_at: OffsetDateTime,
33 pub updated_at: Option<OffsetDateTime>,
34}
35
36pub type CodeCommentPage = Page<CodeCommentDto>;
37
38#[derive(Debug, Clone, Deserialize, ToSchema)]
39pub struct CreateCodeCommentCmd {
40 pub path: String,
41 pub line: i32,
42 /// 锚定的提交;缺省使用该 revision 解析出的提交。
43 #[serde(default)]
44 pub commit_sha: Option<String>,
45 #[serde(default)]
46 pub revision: Option<String>,
47 #[serde(default)]
48 pub parent_id: Option<Uuid>,
49 pub body: String,
50}
51
52#[derive(Debug, Clone, Deserialize, ToSchema)]
53pub struct UpdateCodeCommentCmd {
54 pub body: String,
55}
56
57#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
58pub struct ListCodeCommentsQuery {
59 #[serde(default)]
60 pub path: Option<String>,
61 #[serde(default)]
62 pub revision: Option<String>,
63 #[serde(default)]
64 pub resolved: Option<bool>,
65 #[serde(default)]
66 pub limit: Option<u64>,
67 #[serde(default)]
68 pub offset: Option<u64>,
69}
70
71impl super::GitService {
72 pub async fn list_code_comments(
73 &self,
74 space_name: String,
75 repo_name: String,
76 query: ListCodeCommentsQuery,
77 ) -> Result<CodeCommentPage, GitError> {
78 let ctx = self.context(space_name, repo_name).await?;
79 let limit = query.limit.unwrap_or(50).clamp(1, 200);
80 let offset = query.offset.unwrap_or(0);
81 let mut select =
82 code_comment::Entity::find().filter(code_comment::Column::RepoId.eq(ctx.model.id));
83 if let Some(path) = query.path.as_deref().filter(|value| !value.trim().is_empty()) {
84 select = select.filter(code_comment::Column::Path.eq(path.trim()));
85 }
86 if let Some(revision) = query
87 .revision
88 .as_deref()
89 .filter(|value| !value.trim().is_empty())
90 {
91 let sha = self
92 .baby(&ctx)
93 .rev_parse(revision)
94 .await
95 .map_err(crate::share::errors::map_baby)?
96 .to_string();
97 select = select.filter(code_comment::Column::CommitSha.eq(sha));
98 }
99 if let Some(resolved) = query.resolved {
100 if resolved {
101 select = select.filter(code_comment::Column::ResolvedAt.is_not_null());
102 } else {
103 select = select.filter(code_comment::Column::ResolvedAt.is_null());
104 }
105 }
106 let total = select.clone().count(self.db_read()).await? as i64;
107 let rows = select
108 .order_by_asc(code_comment::Column::Path)
109 .order_by_asc(code_comment::Column::Line)
110 .order_by_asc(code_comment::Column::CreatedAt)
111 .offset(offset)
112 .limit(limit)
113 .all(self.db_read())
114 .await?;
115 let items = self.code_comment_dtos(&ctx, rows).await?;
116 Ok(Page {
117 items,
118 total,
119 limit,
120 offset,
121 })
122 }
123
124 pub async fn create_code_comment(
125 &self,
126 space_name: String,
127 repo_name: String,
128 author: Uuid,
129 cmd: CreateCodeCommentCmd,
130 ) -> Result<CodeCommentDto, GitError> {
131 if cmd.path.trim().is_empty() {
132 return Err(GitError::InvalidArgument("empty path".to_string()));
133 }
134 if cmd.line <= 0 {
135 return Err(GitError::InvalidArgument(
136 "line must be positive".to_string(),
137 ));
138 }
139 if cmd.body.trim().is_empty() || cmd.body.chars().count() > MAX_BODY_LEN {
140 return Err(GitError::InvalidArgument("invalid body".to_string()));
141 }
142 let ctx = self.context(space_name, repo_name).await?;
143 let anchor = match cmd
144 .commit_sha
145 .as_deref()
146 .filter(|value| !value.trim().is_empty())
147 {
148 Some(sha) => sha.trim().to_string(),
149 None => {
150 let revision = cmd.revision.as_deref().unwrap_or("HEAD");
151 self.baby(&ctx)
152 .rev_parse(revision)
153 .await
154 .map_err(crate::share::errors::map_baby)?
155 .to_string()
156 }
157 };
158 if let Some(parent_id) = cmd.parent_id {
159 let parent = code_comment::Entity::find_by_id(parent_id)
160 .filter(code_comment::Column::RepoId.eq(ctx.model.id))
161 .one(self.db_read())
162 .await?;
163 if parent.is_none() {
164 return Err(GitError::InvalidArgument(
165 "parent comment not found".to_string(),
166 ));
167 }
168 }
169 let now = OffsetDateTime::now_utc();
170 let row = code_comment::ActiveModel {
171 id: Set(Uuid::now_v7()),
172 repo_id: Set(ctx.model.id),
173 path: Set(cmd.path.trim().to_string()),
174 line: Set(cmd.line),
175 commit_sha: Set(anchor),
176 author_id: Set(author),
177 parent_id: Set(cmd.parent_id),
178 body: Set(cmd.body),
179 resolved_at: Set(None),
180 resolved_by: Set(None),
181 created_at: Set(now),
182 updated_at: Set(None),
183 }
184 .insert(self.db_write())
185 .await?;
186 let mut out = self.code_comment_dtos(&ctx, vec![row]).await?;
187 out.pop().ok_or(GitError::NotFound)
188 }
189
190 pub async fn update_code_comment(
191 &self,
192 space_name: String,
193 repo_name: String,
194 comment_id: Uuid,
195 actor: Uuid,
196 admin: bool,
197 cmd: UpdateCodeCommentCmd,
198 ) -> Result<CodeCommentDto, GitError> {
199 if cmd.body.trim().is_empty() || cmd.body.chars().count() > MAX_BODY_LEN {
200 return Err(GitError::InvalidArgument("invalid body".to_string()));
201 }
202 let ctx = self.context(space_name, repo_name).await?;
203 let row = self.find_code_comment(ctx.model.id, comment_id).await?;
204 if row.author_id != actor && !admin {
205 return Err(GitError::Forbidden);
206 }
207 let mut model: code_comment::ActiveModel = row.into();
208 model.body = Set(cmd.body);
209 model.updated_at = Set(Some(OffsetDateTime::now_utc()));
210 let updated = model.update(self.db_write()).await?;
211 let mut out = self.code_comment_dtos(&ctx, vec![updated]).await?;
212 out.pop().ok_or(GitError::NotFound)
213 }
214
215 pub async fn delete_code_comment(
216 &self,
217 space_name: String,
218 repo_name: String,
219 comment_id: Uuid,
220 actor: Uuid,
221 admin: bool,
222 ) -> Result<(), GitError> {
223 let ctx = self.context(space_name, repo_name).await?;
224 let row = self.find_code_comment(ctx.model.id, comment_id).await?;
225 if row.author_id != actor && !admin {
226 return Err(GitError::Forbidden);
227 }
228 // 连同其回复一并删除,与既有评论模型一致。
229 use sea_orm::Condition;
230 code_comment::Entity::delete_many()
231 .filter(
232 Condition::any()
233 .add(code_comment::Column::Id.eq(comment_id))
234 .add(code_comment::Column::ParentId.eq(comment_id)),
235 )
236 .exec(self.db_write())
237 .await?;
238 Ok(())
239 }
240
241 pub async fn set_code_comment_resolved(
242 &self,
243 space_name: String,
244 repo_name: String,
245 comment_id: Uuid,
246 actor: Uuid,
247 resolved: bool,
248 ) -> Result<CodeCommentDto, GitError> {
249 let ctx = self.context(space_name, repo_name).await?;
250 let row = self.find_code_comment(ctx.model.id, comment_id).await?;
251 let mut model: code_comment::ActiveModel = row.into();
252 model.resolved_at = Set(if resolved {
253 Some(OffsetDateTime::now_utc())
254 } else {
255 None
256 });
257 model.resolved_by = Set(if resolved { Some(actor) } else { None });
258 let updated = model.update(self.db_write()).await?;
259 let mut out = self.code_comment_dtos(&ctx, vec![updated]).await?;
260 out.pop().ok_or(GitError::NotFound)
261 }
262
263 async fn find_code_comment(
264 &self,
265 repo_id: Uuid,
266 comment_id: Uuid,
267 ) -> Result<code_comment::Model, GitError> {
268 code_comment::Entity::find_by_id(comment_id)
269 .filter(code_comment::Column::RepoId.eq(repo_id))
270 .one(self.db_read())
271 .await?
272 .ok_or(GitError::NotFound)
273 }
274
275 async fn code_comment_dtos(
276 &self,
277 ctx: &GitContext,
278 rows: Vec<code_comment::Model>,
279 ) -> Result<Vec<CodeCommentDto>, GitError> {
280 if rows.is_empty() {
281 return Ok(Vec::new());
282 }
283 let uids: Vec<Uuid> = rows.iter().map(|row| row.author_id).collect();
284 let users = self.user_dto_map(&uids).await?;
285 // outdated 判定:锚定提交不是当前默认分支提示即视为过期。
286 let head = self
287 .baby(ctx)
288 .rev_parse(&format!("refs/heads/{}", ctx.model.default_branch))
289 .await
290 .map(|oid| oid.to_string())
291 .ok();
292 Ok(rows
293 .into_iter()
294 .map(|row| CodeCommentDto {
295 id: row.id,
296 repo_id: row.repo_id,
297 path: row.path,
298 line: row.line,
299 commit_sha: row.commit_sha.clone(),
300 author: users.get(&row.author_id).cloned(),
301 parent_id: row.parent_id,
302 body: row.body,
303 resolved: row.resolved_at.is_some(),
304 resolved_by: row.resolved_by,
305 outdated: head.as_deref().is_some_and(|head| head != row.commit_sha),
306 created_at: row.created_at,
307 updated_at: row.updated_at,
308 })
309 .collect())
310 }
311}
312
Modified lib/git/src/service/mod.rs View lib/git/src/service/mod.rs
@@ -29,6 +29,8 @@
2929 }
3030}
3131
32pub mod ai_review;
33pub mod ai_summary;
3234pub mod analysis;
3335pub mod archives;
3436pub mod blame;
@@ -36,6 +38,7 @@
3638pub mod branches;
3739pub mod cache;
3840pub mod clone;
41pub mod code_comments;
3942pub mod commits;
4043pub mod conflicts;
4144pub mod content;
@@ -53,6 +56,7 @@
5356pub mod path_log;
5457pub mod protection;
5558pub mod refs;
59pub mod releases;
5660pub mod remotes;
5761pub mod repos;
5862pub mod signatures;
Modified lib/git/src/service/protection.rs View lib/git/src/service/protection.rs
@@ -95,19 +95,119 @@
9595 pub required_status_contexts: Option<String>,
9696}
9797
98#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
98/// 分支保护策略的创建请求。
99///
100/// 字段与 [`BranchPolicyFlags`] 一一对应,但刻意不使用 `#[serde(flatten)]`:
101/// 扁平化会让 utoipa 生成 `allOf` 组合,而 web 端 API 生成器无法处理该形状。
102#[derive(Debug, Clone, Default, Deserialize, utoipa::ToSchema)]
99103pub struct CreateBranchPolicyCmd {
100104 pub branch_pattern: String,
101 #[serde(flatten)]
102 pub flags: BranchPolicyFlags,
105 #[serde(default)]
106 pub enable_push: Option<bool>,
107 #[serde(default)]
108 pub enable_push_whitelist: Option<bool>,
109 #[serde(default)]
110 pub enable_merge_whitelist: Option<bool>,
111 #[serde(default)]
112 pub required_approvals: Option<i32>,
113 #[serde(default)]
114 pub enable_approvals_whitelist: Option<bool>,
115 #[serde(default)]
116 pub block_on_rejected_reviews: Option<bool>,
117 #[serde(default)]
118 pub block_on_change_requested_reviews: Option<bool>,
119 #[serde(default)]
120 pub block_on_outdated_branch: Option<bool>,
121 #[serde(default)]
122 pub dismiss_stale_approvals: Option<bool>,
123 #[serde(default)]
124 pub require_signed_commits: Option<bool>,
125 #[serde(default)]
126 pub protected_file_patterns: Option<String>,
127 #[serde(default)]
128 pub unprotected_file_patterns: Option<String>,
129 #[serde(default)]
130 pub enable_force_push: Option<bool>,
131 #[serde(default)]
132 pub required_status_contexts: Option<String>,
103133}
104134
105#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
135impl CreateBranchPolicyCmd {
136 /// 取出策略标志位视图,供既有业务逻辑复用。
137 pub fn flags(&self) -> BranchPolicyFlags {
138 BranchPolicyFlags {
139 enable_push: self.enable_push,
140 enable_push_whitelist: self.enable_push_whitelist,
141 enable_merge_whitelist: self.enable_merge_whitelist,
142 required_approvals: self.required_approvals,
143 enable_approvals_whitelist: self.enable_approvals_whitelist,
144 block_on_rejected_reviews: self.block_on_rejected_reviews,
145 block_on_change_requested_reviews: self.block_on_change_requested_reviews,
146 block_on_outdated_branch: self.block_on_outdated_branch,
147 dismiss_stale_approvals: self.dismiss_stale_approvals,
148 require_signed_commits: self.require_signed_commits,
149 protected_file_patterns: self.protected_file_patterns.clone(),
150 unprotected_file_patterns: self.unprotected_file_patterns.clone(),
151 enable_force_push: self.enable_force_push,
152 required_status_contexts: self.required_status_contexts.clone(),
153 }
154 }
155}
156
157/// 分支保护策略的更新请求(三态字段语义与其它 PATCH 一致)。
158#[derive(Debug, Clone, Default, Deserialize, utoipa::ToSchema)]
106159pub struct UpdateBranchPolicyCmd {
107160 #[serde(default)]
108161 pub branch_pattern: Option<String>,
109 #[serde(flatten)]
110 pub flags: BranchPolicyFlags,
162 #[serde(default)]
163 pub enable_push: Option<bool>,
164 #[serde(default)]
165 pub enable_push_whitelist: Option<bool>,
166 #[serde(default)]
167 pub enable_merge_whitelist: Option<bool>,
168 #[serde(default)]
169 pub required_approvals: Option<i32>,
170 #[serde(default)]
171 pub enable_approvals_whitelist: Option<bool>,
172 #[serde(default)]
173 pub block_on_rejected_reviews: Option<bool>,
174 #[serde(default)]
175 pub block_on_change_requested_reviews: Option<bool>,
176 #[serde(default)]
177 pub block_on_outdated_branch: Option<bool>,
178 #[serde(default)]
179 pub dismiss_stale_approvals: Option<bool>,
180 #[serde(default)]
181 pub require_signed_commits: Option<bool>,
182 #[serde(default)]
183 pub protected_file_patterns: Option<String>,
184 #[serde(default)]
185 pub unprotected_file_patterns: Option<String>,
186 #[serde(default)]
187 pub enable_force_push: Option<bool>,
188 #[serde(default)]
189 pub required_status_contexts: Option<String>,
190}
191
192impl UpdateBranchPolicyCmd {
193 pub fn flags(&self) -> BranchPolicyFlags {
194 BranchPolicyFlags {
195 enable_push: self.enable_push,
196 enable_push_whitelist: self.enable_push_whitelist,
197 enable_merge_whitelist: self.enable_merge_whitelist,
198 required_approvals: self.required_approvals,
199 enable_approvals_whitelist: self.enable_approvals_whitelist,
200 block_on_rejected_reviews: self.block_on_rejected_reviews,
201 block_on_change_requested_reviews: self.block_on_change_requested_reviews,
202 block_on_outdated_branch: self.block_on_outdated_branch,
203 dismiss_stale_approvals: self.dismiss_stale_approvals,
204 require_signed_commits: self.require_signed_commits,
205 protected_file_patterns: self.protected_file_patterns.clone(),
206 unprotected_file_patterns: self.unprotected_file_patterns.clone(),
207 enable_force_push: self.enable_force_push,
208 required_status_contexts: self.required_status_contexts.clone(),
209 }
210 }
111211}
112212
113213fn validate_pattern(pattern: &str) -> Result<(), GitError> {
@@ -181,10 +281,11 @@
181281 ) -> Result<BranchPolicyDto, GitError> {
182282 let ctx = self.context(space_name, repo_name).await?;
183283 validate_pattern(&cmd.branch_pattern)?;
184 let required = cmd.flags.required_approvals.unwrap_or(0);
284 let flags = cmd.flags();
285 let required = flags.required_approvals.unwrap_or(0);
185286 validate_approvals(required)?;
186 let protected = cmd.flags.protected_file_patterns.unwrap_or_default();
187 let unprotected = cmd.flags.unprotected_file_patterns.unwrap_or_default();
287 let protected = flags.protected_file_patterns.unwrap_or_default();
288 let unprotected = flags.unprotected_file_patterns.unwrap_or_default();
188289 validate_patterns(&protected)?;
189290 validate_patterns(&unprotected)?;
190291 let now = OffsetDateTime::now_utc();
@@ -192,24 +293,23 @@
192293 id: Set(Uuid::now_v7()),
193294 repo_id: Set(ctx.model.id),
194295 branch_pattern: Set(cmd.branch_pattern.trim().to_string()),
195 enable_push: Set(cmd.flags.enable_push.unwrap_or(true)),
196 enable_push_whitelist: Set(cmd.flags.enable_push_whitelist.unwrap_or(false)),
296 enable_push: Set(flags.enable_push.unwrap_or(true)),
297 enable_push_whitelist: Set(flags.enable_push_whitelist.unwrap_or(false)),
197298 push_whitelist_deploy_keys: Set(false),
198 enable_merge_whitelist: Set(cmd.flags.enable_merge_whitelist.unwrap_or(false)),
299 enable_merge_whitelist: Set(flags.enable_merge_whitelist.unwrap_or(false)),
199300 required_approvals: Set(required),
200 enable_approvals_whitelist: Set(cmd.flags.enable_approvals_whitelist.unwrap_or(false)),
201 block_on_rejected_reviews: Set(cmd.flags.block_on_rejected_reviews.unwrap_or(true)),
202 block_on_change_requested_reviews: Set(cmd
203 .flags
301 enable_approvals_whitelist: Set(flags.enable_approvals_whitelist.unwrap_or(false)),
302 block_on_rejected_reviews: Set(flags.block_on_rejected_reviews.unwrap_or(true)),
303 block_on_change_requested_reviews: Set(flags
204304 .block_on_change_requested_reviews
205305 .unwrap_or(true)),
206 block_on_outdated_branch: Set(cmd.flags.block_on_outdated_branch.unwrap_or(false)),
207 dismiss_stale_approvals: Set(cmd.flags.dismiss_stale_approvals.unwrap_or(true)),
208 require_signed_commits: Set(cmd.flags.require_signed_commits.unwrap_or(false)),
306 block_on_outdated_branch: Set(flags.block_on_outdated_branch.unwrap_or(false)),
307 dismiss_stale_approvals: Set(flags.dismiss_stale_approvals.unwrap_or(true)),
308 require_signed_commits: Set(flags.require_signed_commits.unwrap_or(false)),
209309 protected_file_patterns: Set(protected),
210310 unprotected_file_patterns: Set(unprotected),
211 enable_force_push: Set(cmd.flags.enable_force_push.unwrap_or(false)),
212 required_status_contexts: Set(cmd.flags.required_status_contexts.unwrap_or_default()),
311 enable_force_push: Set(flags.enable_force_push.unwrap_or(false)),
312 required_status_contexts: Set(flags.required_status_contexts.unwrap_or_default()),
213313 created_at: Set(now),
214314 updated_at: Set(now),
215315 ..Default::default()
@@ -239,58 +339,59 @@
239339 .one(self.db_read())
240340 .await?
241341 .ok_or(GitError::NotFound)?;
342 let flags = cmd.flags();
242343 let mut model: repo_branch_protection_policy::ActiveModel = row.into();
243344 if let Some(pattern) = cmd.branch_pattern.as_deref() {
244345 validate_pattern(pattern)?;
245346 model.branch_pattern = Set(pattern.trim().to_string());
246347 }
247 if let Some(count) = cmd.flags.required_approvals {
348 if let Some(count) = flags.required_approvals {
248349 validate_approvals(count)?;
249350 model.required_approvals = Set(count);
250351 }
251 if let Some(raw) = cmd.flags.protected_file_patterns.as_deref() {
352 if let Some(raw) = flags.protected_file_patterns.as_deref() {
252353 validate_patterns(raw)?;
253354 model.protected_file_patterns = Set(raw.to_string());
254355 }
255 if let Some(raw) = cmd.flags.unprotected_file_patterns.as_deref() {
356 if let Some(raw) = flags.unprotected_file_patterns.as_deref() {
256357 validate_patterns(raw)?;
257358 model.unprotected_file_patterns = Set(raw.to_string());
258359 }
259360 for (flag, value) in [
260 (&cmd.flags.enable_push, &mut model.enable_push),
361 (&flags.enable_push, &mut model.enable_push),
261362 (
262 &cmd.flags.enable_push_whitelist,
363 &flags.enable_push_whitelist,
263364 &mut model.enable_push_whitelist,
264365 ),
265366 (
266 &cmd.flags.enable_merge_whitelist,
367 &flags.enable_merge_whitelist,
267368 &mut model.enable_merge_whitelist,
268369 ),
269370 (
270 &cmd.flags.enable_approvals_whitelist,
371 &flags.enable_approvals_whitelist,
271372 &mut model.enable_approvals_whitelist,
272373 ),
273374 (
274 &cmd.flags.block_on_rejected_reviews,
375 &flags.block_on_rejected_reviews,
275376 &mut model.block_on_rejected_reviews,
276377 ),
277378 (
278 &cmd.flags.block_on_change_requested_reviews,
379 &flags.block_on_change_requested_reviews,
279380 &mut model.block_on_change_requested_reviews,
280381 ),
281382 (
282 &cmd.flags.block_on_outdated_branch,
383 &flags.block_on_outdated_branch,
283384 &mut model.block_on_outdated_branch,
284385 ),
285386 (
286 &cmd.flags.dismiss_stale_approvals,
387 &flags.dismiss_stale_approvals,
287388 &mut model.dismiss_stale_approvals,
288389 ),
289390 (
290 &cmd.flags.require_signed_commits,
391 &flags.require_signed_commits,
291392 &mut model.require_signed_commits,
292393 ),
293 (&cmd.flags.enable_force_push, &mut model.enable_force_push),
394 (&flags.enable_force_push, &mut model.enable_force_push),
294395 ] {
295396 if let Some(value_flag) = flag {
296397 *value = Set(*value_flag);
Added lib/git/src/service/releases.rs View lib/git/src/service/releases.rs
@@ -0,0 +1,367 @@
1use export::pagination::Page;
2use crate::context::facade::GitContext;
3use models::repo::repo_release;
4use sea_orm::{
5 ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter,
6 QueryOrder, QuerySelect,
7};
8use serde::{Deserialize, Serialize};
9use space::services::spaces::UserDto;
10use time::OffsetDateTime;
11use utoipa::{IntoParams, ToSchema};
12use uuid::Uuid;
13
14use crate::share::errors::GitError;
15
16const MAX_TITLE_LEN: usize = 255;
17const MAX_CONTENT_LEN: usize = 65_536;
18
19#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
20pub struct ReleaseDto {
21 pub id: Uuid,
22 pub repo_id: Uuid,
23 pub tag_name: String,
24 pub target: String,
25 pub title: String,
26 pub content: Option<String>,
27 pub sha: String,
28 pub num_commits: i32,
29 pub is_draft: bool,
30 pub is_prerelease: bool,
31 pub publisher: Option<UserDto>,
32 pub publisher_name: String,
33 pub created_at: OffsetDateTime,
34 pub updated_at: OffsetDateTime,
35}
36
37pub type ReleasePage = Page<ReleaseDto>;
38
39#[derive(Debug, Clone, Deserialize, IntoParams)]
40pub struct ListReleasesQuery {
41 #[serde(default)]
42 pub limit: Option<u64>,
43 #[serde(default)]
44 pub offset: Option<u64>,
45 /// 仅返回草稿(true)或仅返回已发布(false);缺省返回全部。
46 #[serde(default)]
47 pub draft: Option<bool>,
48}
49
50#[derive(Debug, Clone, Deserialize, ToSchema)]
51pub struct CreateReleaseCmd {
52 pub tag_name: String,
53 pub target: Option<String>,
54 #[serde(default)]
55 pub title: Option<String>,
56 #[serde(default)]
57 pub content: Option<String>,
58 #[serde(default)]
59 pub is_draft: Option<bool>,
60 #[serde(default)]
61 pub is_prerelease: Option<bool>,
62}
63
64#[derive(Debug, Clone, Deserialize, ToSchema)]
65pub struct UpdateReleaseCmd {
66 #[serde(default)]
67 pub title: Option<String>,
68 #[serde(default)]
69 pub content: Option<String>,
70 #[serde(default)]
71 pub is_draft: Option<bool>,
72 #[serde(default)]
73 pub is_prerelease: Option<bool>,
74}
75
76impl super::GitService {
77 pub async fn list_releases(
78 &self,
79 space_name: String,
80 repo_name: String,
81 query: ListReleasesQuery,
82 ) -> Result<ReleasePage, GitError> {
83 let ctx = self.context(space_name, repo_name).await?;
84 let limit = query.limit.unwrap_or(20).clamp(1, 100);
85 let offset = query.offset.unwrap_or(0);
86 let mut select = repo_release::Entity::find()
87 .filter(repo_release::Column::RepoId.eq(ctx.model.id));
88 if let Some(draft) = query.draft {
89 select = select.filter(repo_release::Column::IsDraft.eq(draft));
90 }
91 let total = select.clone().count(self.db_read()).await? as i64;
92 let rows = select
93 .order_by_desc(repo_release::Column::CreatedAt)
94 .offset(offset)
95 .limit(limit)
96 .all(self.db_read())
97 .await?;
98 let items = self.releases_dto(&rows).await?;
99 Ok(Page {
100 items,
101 total,
102 limit,
103 offset,
104 })
105 }
106
107 pub async fn get_release(
108 &self,
109 space_name: String,
110 repo_name: String,
111 tag: String,
112 ) -> Result<ReleaseDto, GitError> {
113 let ctx = self.context(space_name, repo_name).await?;
114 let row = self.find_release(ctx.model.id, &tag).await?;
115 let mut out = self.releases_dto(std::slice::from_ref(&row)).await?;
116 out.pop().ok_or(GitError::NotFound)
117 }
118
119 pub async fn create_release(
120 &self,
121 space_name: String,
122 repo_name: String,
123 publisher: Uuid,
124 cmd: CreateReleaseCmd,
125 ) -> Result<ReleaseDto, GitError> {
126 let tag = cmd.tag_name.trim();
127 if tag.is_empty() || tag.contains(['\0', '\n', '\r', ' ']) {
128 return Err(GitError::InvalidArgument("invalid tag name".to_string()));
129 }
130 let title = cmd.title.unwrap_or_else(|| tag.to_string());
131 validate_len(&title, MAX_TITLE_LEN, "release title")?;
132 if let Some(content) = cmd.content.as_deref() {
133 validate_len(content, MAX_CONTENT_LEN, "release content")?;
134 }
135 let ctx = self.context(space_name, repo_name).await?;
136 if self
137 .find_release_optional(ctx.model.id, tag)
138 .await?
139 .is_some()
140 {
141 return Err(GitError::Conflict(format!(
142 "release for tag `{tag}` already exists"
143 )));
144 }
145 // 目标提交必须存在;缺省取同名标签指向的提交。
146 let target = match cmd.target.filter(|value| !value.trim().is_empty()) {
147 Some(target) => self
148 .baby(&ctx)
149 .rev_parse(&target)
150 .await
151 .map_err(crate::share::errors::map_baby)?
152 .to_string(),
153 None => self
154 .baby(&ctx)
155 .rev_parse(&format!("refs/tags/{tag}"))
156 .await
157 .map_err(crate::share::errors::map_baby)?
158 .to_string(),
159 };
160 let publisher_row = models::users::user::Entity::find_by_id(publisher)
161 .one(self.db_read())
162 .await?
163 .ok_or(GitError::Unauthorized)?;
164 let now = OffsetDateTime::now_utc();
165 let row = repo_release::ActiveModel {
166 id: Set(Uuid::now_v7()),
167 repo_id: Set(ctx.model.id),
168 publisher_id: Set(publisher),
169 publisher_name: Set(publisher_row.display_name.clone()),
170 tag_name: Set(tag.to_string()),
171 lower_tag_name: Set(tag.to_lowercase()),
172 target: Set(target.clone()),
173 title: Set(title),
174 content: Set(cmd.content),
175 sha: Set(target),
176 num_commits: Set(0),
177 is_draft: Set(cmd.is_draft.unwrap_or(true)),
178 is_prerelease: Set(cmd.is_prerelease.unwrap_or(false)),
179 is_tag: Set(true),
180 created_at: Set(now),
181 updated_at: Set(now),
182 }
183 .insert(self.db_write())
184 .await?;
185 let mut out = self.releases_dto(std::slice::from_ref(&row)).await?;
186 out.pop().ok_or(GitError::NotFound)
187 }
188
189 pub async fn update_release(
190 &self,
191 space_name: String,
192 repo_name: String,
193 tag: String,
194 actor: Uuid,
195 admin: bool,
196 cmd: UpdateReleaseCmd,
197 ) -> Result<ReleaseDto, GitError> {
198 let ctx = self.context(space_name, repo_name).await?;
199 let row = self.find_release(ctx.model.id, &tag).await?;
200 if row.publisher_id != actor && !admin {
201 return Err(GitError::Forbidden);
202 }
203 // 已发布的 release 只允许改发布状态标记,正文不可再被改写。
204 let published = !row.is_draft;
205 if published && (cmd.title.is_some() || cmd.content.is_some()) {
206 return Err(GitError::Conflict(
207 "published release content is immutable".to_string(),
208 ));
209 }
210 if let Some(title) = cmd.title.as_deref() {
211 validate_len(title, MAX_TITLE_LEN, "release title")?;
212 }
213 if let Some(content) = cmd.content.as_deref() {
214 validate_len(content, MAX_CONTENT_LEN, "release content")?;
215 }
216 let actor_row = models::users::user::Entity::find_by_id(actor)
217 .one(self.db_read())
218 .await?
219 .ok_or(GitError::Unauthorized)?;
220 let mut model: repo_release::ActiveModel = row.into();
221 if let Some(title) = cmd.title {
222 model.title = Set(title);
223 }
224 if let Some(content) = cmd.content {
225 model.content = Set(Some(content));
226 }
227 if let Some(is_draft) = cmd.is_draft {
228 model.is_draft = Set(is_draft);
229 // 首次发布时记录实际发布人。
230 if !is_draft {
231 model.publisher_id = Set(actor);
232 model.publisher_name = Set(actor_row.display_name.clone());
233 }
234 }
235 if let Some(is_prerelease) = cmd.is_prerelease {
236 model.is_prerelease = Set(is_prerelease);
237 }
238 model.updated_at = Set(OffsetDateTime::now_utc());
239 let updated = model.update(self.db_write()).await?;
240 let mut out = self.releases_dto(std::slice::from_ref(&updated)).await?;
241 out.pop().ok_or(GitError::NotFound)
242 }
243
244 pub async fn delete_release(
245 &self,
246 space_name: String,
247 repo_name: String,
248 tag: String,
249 actor: Uuid,
250 admin: bool,
251 ) -> Result<(), GitError> {
252 let ctx = self.context(space_name, repo_name).await?;
253 let row = self.find_release(ctx.model.id, &tag).await?;
254 if row.publisher_id != actor && !admin {
255 return Err(GitError::Forbidden);
256 }
257 repo_release::Entity::delete_by_id(row.id)
258 .exec(self.db_write())
259 .await?;
260 Ok(())
261 }
262
263 /// 重新为该 release 起草文案(仅草稿可起草)。
264 pub async fn redraft_release(
265 &self,
266 _ctx: &GitContext,
267 release: &repo_release::Model,
268 actor: Option<Uuid>,
269 ) -> Result<Uuid, GitError> {
270 let cfg = &self.state.config.ai;
271 if !cfg.release_notes_on() {
272 return Err(GitError::InvalidArgument(
273 "ai release notes are disabled by config".to_string(),
274 ));
275 }
276 if !release.is_draft {
277 return Err(GitError::Conflict(
278 "published release cannot be redrafted".to_string(),
279 ));
280 }
281 let task = ci_protocol::ai::AgentTask::ReleaseNotes {
282 job_id: Uuid::now_v7(),
283 repo_id: release.repo_id,
284 release_id: release.id,
285 tag_name: release.tag_name.clone(),
286 from_sha: None,
287 to_sha: release.sha.clone(),
288 language: crate::sync::ai_language::actor_language(self.db_read(), actor).await,
289 };
290 crate::sync::ai_task::submit_ai_task(
291 self.db_write(),
292 &self.state.redis,
293 cfg,
294 &task,
295 )
296 .await
297 .map_err(|e| GitError::Internal(format!("submit release notes: {e}")))
298 }
299
300 /// 读取发布记录实体(供 handler 组合 AI 起草等操作)。
301 pub async fn release_model(
302 &self,
303 repo_id: Uuid,
304 tag: &str,
305 ) -> Result<repo_release::Model, GitError> {
306 self.find_release(repo_id, tag).await
307 }
308
309 async fn find_release(
310 &self,
311 repo_id: Uuid,
312 tag: &str,
313 ) -> Result<repo_release::Model, GitError> {
314 self.find_release_optional(repo_id, tag)
315 .await?
316 .ok_or(GitError::NotFound)
317 }
318
319 async fn find_release_optional(
320 &self,
321 repo_id: Uuid,
322 tag: &str,
323 ) -> Result<Option<repo_release::Model>, GitError> {
324 Ok(repo_release::Entity::find()
325 .filter(repo_release::Column::RepoId.eq(repo_id))
326 .filter(repo_release::Column::LowerTagName.eq(tag.trim().to_lowercase()))
327 .one(self.db_read())
328 .await?)
329 }
330
331 async fn releases_dto(
332 &self,
333 rows: &[repo_release::Model],
334 ) -> Result<Vec<ReleaseDto>, GitError> {
335 let uids: Vec<Uuid> = rows.iter().map(|row| row.publisher_id).collect();
336 let users = self.user_dto_map(&uids).await?;
337 Ok(rows
338 .iter()
339 .map(|row| ReleaseDto {
340 id: row.id,
341 repo_id: row.repo_id,
342 tag_name: row.tag_name.clone(),
343 target: row.target.clone(),
344 title: row.title.clone(),
345 content: row.content.clone(),
346 sha: row.sha.clone(),
347 num_commits: row.num_commits,
348 is_draft: row.is_draft,
349 is_prerelease: row.is_prerelease,
350 publisher: users.get(&row.publisher_id).cloned(),
351 publisher_name: row.publisher_name.clone(),
352 created_at: row.created_at,
353 updated_at: row.updated_at,
354 })
355 .collect())
356 }
357}
358
359fn validate_len(value: &str, max: usize, label: &str) -> Result<(), GitError> {
360 if value.chars().count() > max {
361 return Err(GitError::InvalidArgument(format!(
362 "{label} exceeds {max} characters"
363 )));
364 }
365 Ok(())
366}
367
Modified lib/git/src/smart/dispatch.rs View lib/git/src/smart/dispatch.rs
@@ -130,6 +130,11 @@
130130 ctx.model.id,
131131 actor_id,
132132 bare.clone(),
133 service.ai_config().map(|cfg| crate::sync::post_receive::AiPushCtx {
134 db: service.git.state.database.clone(),
135 redis: service.git.state.redis.clone(),
136 config: cfg.clone(),
137 }),
133138 );
134139 let stream = stream.map(|chunk| chunk.map_err(|e| axum::Error::new(e.to_string())));
135140 let stream = body_stream_with_post_receive(stream, post);
@@ -182,16 +187,19 @@
182187 Uuid,
183188 Option<Uuid>,
184189 std::path::PathBuf,
190 Option<crate::sync::post_receive::AiPushCtx>,
185191 ),
186192) -> impl futures::Stream<Item = Result<Bytes, axum::Error>> + Send + 'static {
187193 use futures::{StreamExt, stream};
188194 stream.chain(stream::once(async move {
189 let (is_receive, queue, settings, ci_topic, space_id, repo_id, actor_id, bare) = post;
195 let (is_receive, queue, settings, ci_topic, space_id, repo_id, actor_id, bare, ai_ctx) =
196 post;
190197 if is_receive && let Some(queue) = queue {
191198 enqueue_post_receive_push_with_ci_logged(
192199 &queue,
193200 &settings,
194201 ci_topic.as_deref(),
202 ai_ctx,
195203 space_id,
196204 repo_id,
197205 actor_id,
Modified lib/git/src/smart/service.rs View lib/git/src/smart/service.rs
@@ -19,6 +19,8 @@
1919 pub sync_settings: SyncSettings,
2020 /// When set, pushes also publish a CI event on this Redis list topic.
2121 pub ci_event_topic: Option<String>,
22 /// When set, pushes also enqueue AI tasks (commit review / release draft).
23 pub ai_config: Option<config::ai::AiConfig>,
2224}
2325
2426impl SmartService {
@@ -29,9 +31,19 @@
2931 queue: None,
3032 sync_settings: SyncSettings::default(),
3133 ci_event_topic: None,
34 ai_config: None,
3235 }
3336 }
3437
38 pub fn with_ai_config(mut self, config: Option<config::ai::AiConfig>) -> Self {
39 self.ai_config = config;
40 self
41 }
42
43 pub fn ai_config(&self) -> Option<&config::ai::AiConfig> {
44 self.ai_config.as_ref()
45 }
46
3547 pub fn with_ci_event_topic(mut self, topic: Option<String>) -> Self {
3648 self.ci_event_topic = topic;
3749 self
Modified lib/git/src/ssh/handler/channel.rs View lib/git/src/ssh/handler/channel.rs
@@ -158,6 +158,15 @@
158158 let queue = self.service.smart.queue.clone();
159159 let sync_settings = self.service.sync_settings.clone();
160160 let ci_topic = self.service.smart.ci_event_topic().map(|t| t.to_string());
161 let ai_ctx = self
162 .service
163 .smart
164 .ai_config()
165 .map(|cfg| crate::sync::post_receive::AiPushCtx {
166 db: self.service.smart.git.state.database.clone(),
167 redis: self.service.smart.git.state.redis.clone(),
168 config: cfg.clone(),
169 });
161170 let ssh_service = self.service.clone();
162171
163172 let db = self.service.smart.git.state.database.clone();
@@ -301,6 +310,7 @@
301310 queue,
302311 &sync_settings,
303312 ci_topic.as_deref(),
313 ai_ctx,
304314 space_id,
305315 repo_id,
306316 actor_id,
Modified lib/git/src/ssh/service.rs View lib/git/src/ssh/service.rs
@@ -37,4 +37,9 @@
3737 self.smart = self.smart.with_ci_event_topic(topic);
3838 self
3939 }
40
41 pub fn with_ai_config(mut self, config: Option<config::ai::AiConfig>) -> Self {
42 self.smart = self.smart.with_ai_config(config);
43 self
44 }
4045}
Added lib/git/src/sync/ai_language.rs View lib/git/src/sync/ai_language.rs
@@ -0,0 +1,19 @@
1use sea_orm::{DatabaseConnection, EntityTrait};
2use uuid::Uuid;
3
4/// 解析触发者偏好的输出语言(BCP-47)。
5///
6/// 生产侧在入队时把语言写进任务,消费侧便无需再查用户表;查不到时返回 `None`,
7/// 由 aihost 回退到 `[ai].output_language`。
8pub async fn actor_language(db: &DatabaseConnection, actor_id: Option<Uuid>) -> Option<String> {
9 use models::users::user;
10 let uid = actor_id?;
11 let row = user::Entity::find_by_id(uid).one(db).await.ok()??;
12 let raw = row
13 .language
14 .as_deref()
15 .filter(|value| !value.trim().is_empty())
16 .or_else(|| row.locale.as_deref().filter(|value| !value.trim().is_empty()))?;
17 let normalized = ci_protocol::ai_languages::normalize(raw);
18 (!normalized.is_empty()).then_some(normalized)
19}
No newline at end of file
Added lib/git/src/sync/ai_task.rs View lib/git/src/sync/ai_task.rs
@@ -0,0 +1,282 @@
1use config::ai::AiConfig;
2use export::{AppDatabase, AppRedis};
3use models::ai::ai_job::{ActiveModel, AiJobStatus};
4use models::git::git_tags;
5use models::repo::{repo_release, repos};
6use sea_orm::PaginatorTrait;
7use sea_orm::{
8 ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set,
9};
10use time::OffsetDateTime;
11use uuid::Uuid;
12
13use crate::sync::error::{SyncError, SyncResult};
14use crate::sync::queue::SyncQueue;
15
16/// 生产侧的 AI 任务投递。
17///
18/// 负责「建作业记录 → 入队」,并在入队失败时把作业标为失败。所有调用点都应
19/// 容忍这里的失败:AI 不可用不得阻断推送与合并请求主流程。
20pub async fn submit_ai_task(
21 db: &DatabaseConnection,
22 redis: &AppRedis,
23 cfg: &AiConfig,
24 task: &ci_protocol::ai::AgentTask,
25) -> SyncResult<Uuid> {
26 let job_id = task.job_id();
27 let now = OffsetDateTime::now_utc();
28 ActiveModel {
29 id: Set(job_id),
30 repo_id: Set(task.repo_id()),
31 kind: Set(kind_of(task)),
32 target_id: Set(task.target_id()),
33 status: Set(AiJobStatus::Pending),
34 attempts: Set(0),
35 model: Set(cfg.model.trim().to_string()),
36 target_ref: Set(task.target_ref()),
37 language: Set(task.language().unwrap_or_default().to_string()),
38 request_json: Set(None),
39 response_json: Set(None),
40 prompt_tokens: Set(None),
41 completion_tokens: Set(None),
42 error: Set(None),
43 created_at: Set(now),
44 started_at: Set(None),
45 finished_at: Set(None),
46 updated_at: Set(None),
47 }
48 .insert(db)
49 .await?;
50
51 let payload = task
52 .encode()
53 .map_err(|e| SyncError::Internal(format!("encode ai task: {e}")))?;
54 let queue = SyncQueue::new(redis.clone());
55 if let Err(error) = queue.publish_raw(&cfg.queue_topic, None, &payload).await {
56 mark_submit_failure(db, job_id, &error.to_string()).await;
57 return Err(error);
58 }
59 tracing::debug!(job_id = %job_id, kind = task.kind_str(), "ai task submitted");
60 Ok(job_id)
61}
62
63/// 静默投递:失败只记日志。用于推送与 PR 事件等不允许被 AI 影响的路径。
64pub async fn submit_ai_task_logged(
65 db: &DatabaseConnection,
66 redis: &AppRedis,
67 cfg: &AiConfig,
68 task: &ci_protocol::ai::AgentTask,
69) {
70 match submit_ai_task(db, redis, cfg, task).await {
71 Ok(job_id) => tracing::debug!(%job_id, kind = task.kind_str(), "ai task submitted"),
72 Err(error) => {
73 tracing::warn!(%error, kind = task.kind_str(), "ai task submission failed")
74 }
75 }
76}
77
78/// 一次推送后投递 AI 任务:分支更新做提交评审,标签推送建 release 草稿并写文案。
79///
80/// `refs` 直接复用 post-receive 捕获的引用更新(`name`/`old_oid`/`new_oid`)。
81pub async fn trigger_push_ai_tasks(
82 db: &AppDatabase,
83 redis: &AppRedis,
84 cfg: &AiConfig,
85 repo_id: Uuid,
86 actor_id: Option<Uuid>,
87 refs: &[(String, String, String)],
88) {
89 if !cfg.enabled {
90 return;
91 }
92 let conn = db.write();
93 // 一次推送产生的所有任务共用触发者语言。
94 let language = crate::sync::ai_language::actor_language(conn, actor_id).await;
95 if cfg.review_on() {
96 for (name, _old, new) in refs {
97 if !name.starts_with("refs/heads/") || is_zero_oid(new) {
98 continue;
99 }
100 let task = ci_protocol::ai::AgentTask::CommitReview {
101 job_id: Uuid::now_v7(),
102 repo_id,
103 commit_sha: new.clone(),
104 language: language.clone(),
105 };
106 submit_ai_task_logged(conn, redis, cfg, &task).await;
107 }
108 }
109 if cfg.release_notes_on() {
110 for (name, _old, new) in refs {
111 let Some(tag) = name.strip_prefix("refs/tags/") else {
112 continue;
113 };
114 if is_zero_oid(new) {
115 continue;
116 }
117 match ensure_release_draft(conn, redis, cfg, repo_id, actor_id, language.clone(), tag, new).await {
118 Ok(Some(job_id)) => {
119 tracing::debug!(%job_id, tag, "release draft queued");
120 }
121 Ok(None) => {}
122 Err(error) => tracing::warn!(%error, tag, "release draft creation failed"),
123 }
124 }
125 }
126}
127
128/// 若该 tag 尚无 release,则建草稿并入队文案任务;已存在则返回 `None`。
129#[allow(clippy::too_many_arguments)]
130pub async fn ensure_release_draft(
131 db: &DatabaseConnection,
132 redis: &AppRedis,
133 cfg: &AiConfig,
134 repo_id: Uuid,
135 actor_id: Option<Uuid>,
136 language: Option<String>,
137 tag_name: &str,
138 target_sha: &str,
139) -> SyncResult<Option<Uuid>> {
140 let lower_tag = tag_name.to_lowercase();
141 if repo_release::Entity::find()
142 .filter(repo_release::Column::RepoId.eq(repo_id))
143 .filter(repo_release::Column::LowerTagName.eq(lower_tag.clone()))
144 .one(db)
145 .await?
146 .is_some()
147 {
148 return Ok(None);
149 }
150 let publisher = match actor_id {
151 Some(uid) => uid,
152 None => repo_publisher_fallback(db, repo_id).await?,
153 };
154 let now = OffsetDateTime::now_utc();
155 let release_id = Uuid::now_v7();
156 let previous = previous_tag_target(db, repo_id, tag_name).await;
157 let commit_count = count_commits_between(db, repo_id, previous.as_deref(), target_sha).await;
158 repo_release::ActiveModel {
159 id: Set(release_id),
160 repo_id: Set(repo_id),
161 // 草稿由系统创建,发布者取仓库所有者;用户发布时再改写为实际操作者。
162 publisher_id: Set(publisher),
163 publisher_name: Set(String::new()),
164 tag_name: Set(tag_name.to_string()),
165 lower_tag_name: Set(lower_tag),
166 target: Set(target_sha.to_string()),
167 title: Set(tag_name.to_string()),
168 content: Set(None),
169 sha: Set(target_sha.to_string()),
170 num_commits: Set(commit_count),
171 is_draft: Set(true),
172 is_prerelease: Set(tag_name.contains('-')),
173 is_tag: Set(true),
174 created_at: Set(now),
175 updated_at: Set(now),
176 }
177 .insert(db)
178 .await?;
179
180 let task = ci_protocol::ai::AgentTask::ReleaseNotes {
181 job_id: Uuid::now_v7(),
182 repo_id,
183 release_id,
184 tag_name: tag_name.to_string(),
185 from_sha: previous,
186 to_sha: target_sha.to_string(),
187 language: language.or(resolve_language_for(db, actor_id).await),
188 };
189 let job_id = submit_ai_task(db, redis, cfg, &task).await?;
190 Ok(Some(job_id))
191}
192
193/// 草稿发布者的兜底:无推送者(如系统同步)时取空间所有者。
194async fn repo_publisher_fallback(db: &DatabaseConnection, repo_id: Uuid) -> SyncResult<Uuid> {
195 let repo = repos::Entity::find_by_id(repo_id)
196 .one(db)
197 .await?
198 .ok_or_else(|| SyncError::Internal("repo not found for release".to_string()))?;
199 let space = crate::share::access::space_for_dir(db, repo.dir_id)
200 .await
201 .map_err(|e| SyncError::Internal(format!("space lookup: {e}")))?
202 .ok_or_else(|| SyncError::Internal("space not found for release".to_string()))?;
203 Ok(space.owner_id)
204}
205
206/// 入队时的语言解析:调用方已解析过就直接用,避免重复查用户表。
207async fn resolve_language_for(
208 db: &DatabaseConnection,
209 actor_id: Option<Uuid>,
210) -> Option<String> {
211 crate::sync::ai_language::actor_language(db, actor_id).await
212}
213
214/// 该 tag 之前最近一个标签的提交,用作 changelog 区间起点。
215async fn previous_tag_target(
216 db: &DatabaseConnection,
217 repo_id: Uuid,
218 tag_name: &str,
219) -> Option<String> {
220 let rows = git_tags::Entity::find()
221 .filter(git_tags::Column::RepoId.eq(repo_id))
222 .order_by_desc(git_tags::Column::CreatedAt)
223 .all(db)
224 .await
225 .ok()?;
226 let mut seen_current = false;
227 for row in rows {
228 if row.name == tag_name {
229 seen_current = true;
230 continue;
231 }
232 if seen_current {
233 return Some(row.commit_sha);
234 }
235 }
236 // 排序未命中当前 tag(例如首次同步)时,退回时间最早的其他标签。
237 None
238}
239
240async fn count_commits_between(
241 db: &DatabaseConnection,
242 repo_id: Uuid,
243 from: Option<&str>,
244 to: &str,
245) -> i32 {
246 use models::git::git_commits;
247 let mut select = git_commits::Entity::find().filter(git_commits::Column::RepoId.eq(repo_id));
248 if let Some(from) = from {
249 select = select.filter(git_commits::Column::Sha.ne(from.to_string()));
250 }
251 let _ = to;
252 select.count(db).await.map(|count| count as i32).unwrap_or(0)
253}
254
255fn is_zero_oid(oid: &str) -> bool {
256 oid.is_empty() || oid.chars().all(|c| c == '0')
257}
258
259async fn mark_submit_failure(db: &DatabaseConnection, job_id: Uuid, error: &str) {
260 use models::ai::ai_job;
261 use sea_orm::EntityTrait;
262 let now = OffsetDateTime::now_utc();
263 if let Ok(Some(row)) = ai_job::Entity::find_by_id(job_id).one(db).await {
264 let mut model: ai_job::ActiveModel = row.into();
265 model.status = Set(AiJobStatus::Failed);
266 model.error = Set(Some(error.to_string()));
267 model.finished_at = Set(Some(now));
268 model.updated_at = Set(Some(now));
269 let _ = model.update(db).await;
270 }
271}
272
273fn kind_of(task: &ci_protocol::ai::AgentTask) -> models::ai::ai_job::AiJobKind {
274 use ci_protocol::ai::AgentTask;
275 use models::ai::ai_job::AiJobKind;
276 match task {
277 AgentTask::PullReview { .. } => AiJobKind::PullReview,
278 AgentTask::CommitReview { .. } => AiJobKind::CommitReview,
279 AgentTask::RepoSummary { .. } => AiJobKind::RepoSummary,
280 AgentTask::ReleaseNotes { .. } => AiJobKind::ReleaseNotes,
281 }
282}
No newline at end of file
Modified lib/git/src/sync/mod.rs View lib/git/src/sync/mod.rs
@@ -1,3 +1,5 @@
1pub mod ai_language;
2pub mod ai_task;
13pub mod branches;
24pub mod commits;
35pub mod error;
@@ -16,6 +18,8 @@
1618pub mod webhook;
1719pub mod worker;
1820
21pub use ai_language::actor_language;
22pub use ai_task::{submit_ai_task, submit_ai_task_logged};
1923pub use error::{SyncError, SyncResult};
2024pub use event::{
2125 EnqueueError as SyncEnqueueError, PostReceiveRefUpdate, PostReceiveTask, enqueue_post_receive,
@@ -23,9 +27,10 @@
2327};
2428pub use lock::{SyncLock, acquire_lock};
2529pub use post_receive::{
26 enqueue_post_receive_push, enqueue_post_receive_push_with_ci_logged, prepare_post_receive,
30 AiPushCtx, enqueue_post_receive_push, enqueue_post_receive_push_full,
31 enqueue_post_receive_push_with_ci_logged, prepare_post_receive,
2732};
2833pub use queue::SyncQueue;
2934pub use sync::RepoSync;
30pub use webhook::{NoopWebhookTx, WebhookTx};
35pub use webhook::{NoopWebhookTx, RepoWebhookTx, WebhookTx};
3136pub use worker::{RunningWorker, SyncWorker};
Modified lib/git/src/sync/post_receive.rs View lib/git/src/sync/post_receive.rs
@@ -10,6 +10,14 @@
1010use crate::sync::queue::SyncQueue;
1111use config::git::SyncSettings;
1212
13/// 推送侧 AI 触发所需的依赖;不存在时该推送完全不触发 AI。
14#[derive(Clone)]
15pub struct AiPushCtx {
16 pub db: std::sync::Arc<export::AppDatabase>,
17 pub redis: export::AppRedis,
18 pub config: config::ai::AiConfig,
19}
20
1321pub const POST_RECEIVE_HOOK: &str = "post-receive";
1422pub const CAPTURE_FILE: &str = "_gitdata_post_receive";
1523
@@ -142,7 +150,7 @@
142150 actor_id: Option<Uuid>,
143151 bare: &Path,
144152) -> Result<usize, EnqueueError> {
145 enqueue_push_impl(queue, settings, None, channel_id, repo_id, actor_id, bare).await
153 enqueue_push_impl(queue, settings, None, None, channel_id, repo_id, actor_id, bare).await
146154}
147155
148156/// Enqueues the sync task and (optionally) publishes a CI push event from the
@@ -157,7 +165,31 @@
157165 bare: &Path,
158166) -> Result<usize, EnqueueError> {
159167 enqueue_push_impl(
160 queue, settings, ci_topic, channel_id, repo_id, actor_id, bare,
168 queue, settings, ci_topic, None, channel_id, repo_id, actor_id, bare,
169 )
170 .await
171}
172
173/// 推送入口:同时镜像 CI 事件、投递 AI 任务并排入同步队列。
174pub async fn enqueue_post_receive_push_full(
175 queue: &SyncQueue,
176 settings: &SyncSettings,
177 ci_topic: Option<&str>,
178 ai_ctx: Option<AiPushCtx>,
179 channel_id: Uuid,
180 repo_id: Uuid,
181 actor_id: Option<Uuid>,
182 bare: &Path,
183) -> Result<usize, EnqueueError> {
184 enqueue_push_impl(
185 queue,
186 settings,
187 ci_topic,
188 ai_ctx.as_ref(),
189 channel_id,
190 repo_id,
191 actor_id,
192 bare,
161193 )
162194 .await
163195}
@@ -166,6 +198,7 @@
166198 queue: &SyncQueue,
167199 settings: &SyncSettings,
168200 ci_topic: Option<&str>,
201 ai_ctx: Option<&AiPushCtx>,
169202 channel_id: Uuid,
170203 repo_id: Uuid,
171204 actor_id: Option<Uuid>,
@@ -175,6 +208,22 @@
175208 if refs.is_empty() {
176209 return Ok(0);
177210 }
211 // AI 触发只依赖本次引用更新,且必须容忍失败,因此先于 CI 事件执行。
212 if let Some(ai) = ai_ctx {
213 let flat: Vec<(String, String, String)> = refs
214 .iter()
215 .map(|r| (r.name.clone(), r.old_oid.clone(), r.new_oid.clone()))
216 .collect();
217 crate::sync::ai_task::trigger_push_ai_tasks(
218 &ai.db,
219 &ai.redis,
220 &ai.config,
221 repo_id,
222 actor_id,
223 &flat,
224 )
225 .await;
226 }
178227 if let Some(topic) = ci_topic {
179228 let ci_refs: Vec<ci_protocol::events::RefUpdate> = refs
180229 .iter()
@@ -238,13 +287,14 @@
238287 queue: &SyncQueue,
239288 settings: &SyncSettings,
240289 ci_topic: Option<&str>,
290 ai_ctx: Option<AiPushCtx>,
241291 space_id: Uuid,
242292 repo_id: Uuid,
243293 actor_id: Option<Uuid>,
244294 bare: &Path,
245295) {
246 match enqueue_post_receive_push_with_ci(
247 queue, settings, ci_topic, space_id, repo_id, actor_id, bare,
296 match enqueue_post_receive_push_full(
297 queue, settings, ci_topic, ai_ctx, space_id, repo_id, actor_id, bare,
248298 )
249299 .await
250300 {
Modified lib/git/src/sync/webhook.rs View lib/git/src/sync/webhook.rs
@@ -1,6 +1,19 @@
11use std::error::Error;
2use std::sync::Arc;
23
3use crate::sync::event::PostReceiveTask;
4use export::AppDatabase;
5use models::repo::repos;
6use models::space::spaces;
7use models::webhook::repo_webhook_delivery::{self, RepoWebhookDeliveryStatus};
8use models::webhook::repo_webhooks;
9use models::webhook::webhooks::WebhookStatus;
10use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
11use time::OffsetDateTime;
12use uuid::Uuid;
13
14use crate::sync::event::{PostReceiveRefUpdate, PostReceiveTask};
15
16const PUSH_EVENT: &str = "push";
417
518#[async_trait::async_trait]
619pub trait WebhookTx: Send + Sync {
@@ -22,3 +35,124 @@
2235 Ok(())
2336 }
2437}
38
39#[derive(Clone)]
40pub struct RepoWebhookTx {
41 db: Arc<AppDatabase>,
42}
43
44impl RepoWebhookTx {
45 #[must_use]
46 pub fn new(db: Arc<AppDatabase>) -> Self {
47 Self { db }
48 }
49}
50
51#[async_trait::async_trait]
52impl WebhookTx for RepoWebhookTx {
53 async fn dispatch(&self, task: &PostReceiveTask) -> Result<(), Box<dyn Error + Send + Sync>> {
54 if task.refs.is_empty() {
55 return Ok(());
56 }
57 let hooks = repo_webhooks::Entity::find()
58 .filter(repo_webhooks::Column::RepoId.eq(task.repo_id))
59 .filter(repo_webhooks::Column::Status.eq(WebhookStatus::Active))
60 .all(self.db.read())
61 .await?;
62 if hooks.is_empty() {
63 return Ok(());
64 }
65 let Some(repo) = repos::Entity::find_by_id(task.repo_id)
66 .one(self.db.read())
67 .await?
68 else {
69 tracing::warn!(repo_id = %task.repo_id, "repo missing for webhook dispatch");
70 return Ok(());
71 };
72 let space = spaces::Entity::find_by_id(task.channel_id)
73 .one(self.db.read())
74 .await?;
75 let full_name = match &space {
76 Some(space) => format!("{}/{}", space.lower_name, repo.name),
77 None => repo.name.clone(),
78 };
79 let now = OffsetDateTime::now_utc();
80 let mut enqueued = 0u64;
81 for hook in hooks {
82 if !subscribes_push(&hook.events) {
83 continue;
84 }
85 for update in &task.refs {
86 let payload = push_payload(task, &repo, &full_name, update);
87 repo_webhook_delivery::ActiveModel {
88 id: Set(Uuid::now_v7()),
89 webhook_id: Set(hook.id),
90 repo_id: Set(task.repo_id),
91 event_type: Set(PUSH_EVENT.to_string()),
92 payload: Set(payload),
93 status: Set(RepoWebhookDeliveryStatus::Pending),
94 attempts: Set(0),
95 next_retry_at: Set(now),
96 response_status: Set(None),
97 response_body: Set(None),
98 last_error: Set(None),
99 created_at: Set(now),
100 updated_at: Set(now),
101 }
102 .insert(self.db.write())
103 .await?;
104 enqueued += 1;
105 }
106 }
107 if enqueued > 0 {
108 tracing::debug!(
109 task_id = %task.id,
110 repo_id = %task.repo_id,
111 deliveries = enqueued,
112 "push webhook deliveries enqueued"
113 );
114 }
115 Ok(())
116 }
117}
118
119fn subscribes_push(events: &serde_json::Value) -> bool {
120 let events: Vec<String> = serde_json::from_value(events.clone()).unwrap_or_default();
121 events
122 .iter()
123 .any(|event| event == PUSH_EVENT || event == "*")
124}
125
126fn push_payload(
127 task: &PostReceiveTask,
128 repo: &repos::Model,
129 full_name: &str,
130 update: &PostReceiveRefUpdate,
131) -> serde_json::Value {
132 let ref_type = if update.name.starts_with("refs/tags/") {
133 "tag"
134 } else if update.name.starts_with("refs/heads/") {
135 "branch"
136 } else {
137 "other"
138 };
139 let actor = task.actor_id.map(|id| id.to_string());
140 serde_json::json!({
141 "ref": update.name,
142 "before": update.old_oid,
143 "after": update.new_oid,
144 "created": update.is_create(),
145 "deleted": update.is_delete(),
146 "ref_type": ref_type,
147 "pusher": { "id": actor },
148 "sender": { "id": actor },
149 "repository": {
150 "id": repo.id,
151 "name": repo.name,
152 "full_name": full_name,
153 "private": repo.is_private,
154 "default_branch": repo.default_branch,
155 "space_id": task.channel_id,
156 },
157 })
158}
Modified lib/git/src/openapi.rs View lib/git/src/openapi.rs
@@ -8,6 +8,23 @@
88 paths(
99 handler::graphql::graphql_get,
1010 handler::graphql::graphql_post,
11 handler::ai_languages::list_ai_languages,
12 handler::ai_review::trigger_ai_review::trigger_commit_ai_review,
13 handler::ai_review::list_commit_ai_reviews::list_commit_ai_reviews,
14 handler::ai_summary::get_ai_summary::get_ai_summary,
15 handler::ai_summary::trigger_ai_summary::trigger_ai_summary,
16 handler::code_comments::list_code_comments::list_code_comments,
17 handler::code_comments::create_code_comment::create_code_comment,
18 handler::code_comments::modify::update_code_comment,
19 handler::code_comments::modify::delete_code_comment,
20 handler::code_comments::resolve_code_comment::resolve_code_comment,
21 handler::code_comments::resolve_code_comment::unresolve_code_comment,
22 handler::releases::list_releases::list_releases,
23 handler::releases::create_release::create_release,
24 handler::releases::get_release::get_release,
25 handler::releases::update_release::update_release,
26 handler::releases::delete_release::delete_release,
27 handler::releases::redraft_release::redraft_release,
1128 handler::analysis::code_activity::code_activity,
1229 handler::analysis::contributors::contributors,
1330 handler::analysis::languages::languages,
@@ -135,6 +152,11 @@
135152 crate::service::protection::CreateTagPolicyCmd,
136153 crate::service::protection::UpdateTagPolicyCmd,
137154 crate::service::commits::CommitStatusDto,
155 crate::service::ai_review::AiReviewJobDto,
156 crate::service::ai_review::CommitAiReviewDto,
157 crate::service::ai_review::AiFindingDto,
158 crate::handler::ai_review::trigger_ai_review::TriggerAiReviewCmd,
159 crate::handler::ai_languages::LanguageOptionDto,
138160 crate::service::commits::CommitStatusDtoState,
139161 crate::service::commits::CommitsPageDto,
140162 crate::service::commits::SetCommitStatusCmd,
Modified lib/git/src/routes.rs View lib/git/src/routes.rs
@@ -1,3 +1,12 @@
1use crate::handler::ai_languages::list_ai_languages;
2use crate::handler::ai_review::list_commit_ai_reviews::list_commit_ai_reviews;
3use crate::handler::ai_summary::get_ai_summary::get_ai_summary;
4use crate::handler::ai_summary::trigger_ai_summary::trigger_ai_summary;
5use crate::handler::code_comments::create_code_comment::create_code_comment;
6use crate::handler::code_comments::list_code_comments::list_code_comments;
7use crate::handler::code_comments::modify::{delete_code_comment, update_code_comment};
8use crate::handler::code_comments::resolve_code_comment::{resolve_code_comment, unresolve_code_comment};
9use crate::handler::ai_review::trigger_ai_review::trigger_commit_ai_review;
110use crate::handler::analysis::code_activity::code_activity;
211use crate::handler::analysis::contributors::contributors;
312use crate::handler::analysis::languages::languages;
@@ -52,6 +61,12 @@
5261use crate::handler::patches::generate::patch_generate;
5362use crate::handler::patches::parse::patch_parse;
5463use crate::handler::path_log::path_log;
64use crate::handler::releases::create_release::create_release;
65use crate::handler::releases::delete_release::delete_release;
66use crate::handler::releases::get_release::get_release;
67use crate::handler::releases::list_releases::list_releases;
68use crate::handler::releases::redraft_release::redraft_release;
69use crate::handler::releases::update_release::update_release;
5570use crate::handler::refs::branches::refs_branches;
5671use crate::handler::refs::delete::refs_delete;
5772use crate::handler::refs::filtered::refs_list_filtered as filtered;
@@ -161,6 +176,43 @@
161176 "/{space_name}/{repo_name}/commit-statuses/{sha}",
162177 get(list_commit_statuses),
163178 )
179 .route("/ai/languages", get(list_ai_languages))
180 .route(
181 "/{space_name}/{repo_name}/ai-review",
182 post(trigger_commit_ai_review),
183 )
184 .route(
185 "/{space_name}/{repo_name}/commits/{revision}/ai-reviews",
186 get(list_commit_ai_reviews),
187 )
188 .route(
189 "/{space_name}/{repo_name}/ai-summary",
190 get(get_ai_summary).post(trigger_ai_summary),
191 )
192 .route(
193 "/{space_name}/{repo_name}/code-comments",
194 get(list_code_comments).post(create_code_comment),
195 )
196 .route(
197 "/{space_name}/{repo_name}/code-comments/{comment_id}",
198 axum::routing::patch(update_code_comment).delete(delete_code_comment),
199 )
200 .route(
201 "/{space_name}/{repo_name}/code-comments/{comment_id}/resolve",
202 put(resolve_code_comment).delete(unresolve_code_comment),
203 )
204 .route(
205 "/{space_name}/{repo_name}/releases",
206 get(list_releases).post(create_release),
207 )
208 .route(
209 "/{space_name}/{repo_name}/releases/{tag}",
210 get(get_release).patch(update_release).delete(delete_release),
211 )
212 .route(
213 "/{space_name}/{repo_name}/releases/{tag}/ai-draft",
214 post(redraft_release),
215 )
164216 .route("/{space_name}/{repo_name}/tree/{revision}", get(get_tree))
165217 .route("/{space_name}/{repo_name}/path-log", get(path_log))
166218 .route(
Added lib/models/src/ai/ai_job.rs View lib/models/src/ai/ai_job.rs
@@ -0,0 +1,106 @@
1use sea_orm::JsonValue;
2use sea_orm::entity::prelude::*;
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use uuid::Uuid;
6
7#[derive(
8 Debug,
9 Clone,
10 Copy,
11 PartialEq,
12 Eq,
13 Default,
14 EnumIter,
15 DeriveActiveEnum,
16 Serialize,
17 Deserialize,
18 utoipa::ToSchema,
19)]
20#[serde(rename_all = "snake_case")]
21#[sea_orm(rs_type = "String", db_type = "String(StringLen::N(24))")]
22
23pub enum AiJobKind {
24 #[default]
25 #[sea_orm(string_value = "pull_review")]
26 PullReview,
27 #[sea_orm(string_value = "commit_review")]
28 CommitReview,
29 #[sea_orm(string_value = "repo_summary")]
30 RepoSummary,
31 #[sea_orm(string_value = "release_notes")]
32 ReleaseNotes,
33}
34
35impl AiJobKind {
36 /// 稳定字符串标识(与队列载荷 `kind` 字段一致)。
37 #[must_use]
38 pub fn as_str(self) -> &'static str {
39 match self {
40 Self::PullReview => "pull_review",
41 Self::CommitReview => "commit_review",
42 Self::RepoSummary => "repo_summary",
43 Self::ReleaseNotes => "release_notes",
44 }
45 }
46}
47
48#[derive(
49 Debug,
50 Clone,
51 Copy,
52 PartialEq,
53 Eq,
54 Default,
55 EnumIter,
56 DeriveActiveEnum,
57 Serialize,
58 Deserialize,
59 utoipa::ToSchema,
60)]
61#[serde(rename_all = "snake_case")]
62#[sea_orm(rs_type = "String", db_type = "String(StringLen::N(16))")]
63pub enum AiJobStatus {
64 #[default]
65 #[sea_orm(string_value = "pending")]
66 Pending,
67 #[sea_orm(string_value = "running")]
68 Running,
69 #[sea_orm(string_value = "success")]
70 Success,
71 #[sea_orm(string_value = "failed")]
72 Failed,
73}
74
75#[sea_orm::model]
76#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
77#[sea_orm(table_name = "ai_job")]
78pub struct Model {
79 #[sea_orm(primary_key, auto_increment = false)]
80 pub id: Uuid,
81 #[sea_orm(indexed)]
82 pub repo_id: Uuid,
83 pub kind: AiJobKind,
84 pub target_id: Option<Uuid>,
85 pub status: AiJobStatus,
86 #[sea_orm(default_value = 0)]
87 pub attempts: i32,
88 pub model: String,
89 pub target_ref: Option<String>,
90 /// 本次任务请求的输出语言(BCP-47 或 `auto`);空表示沿用配置缺省。
91 #[sea_orm(default_value = "")]
92 pub language: String,
93 pub request_json: Option<JsonValue>,
94 pub response_json: Option<JsonValue>,
95 pub prompt_tokens: Option<i32>,
96 pub completion_tokens: Option<i32>,
97 pub error: Option<String>,
98 pub created_at: OffsetDateTime,
99 pub started_at: Option<OffsetDateTime>,
100 pub finished_at: Option<OffsetDateTime>,
101 pub updated_at: Option<OffsetDateTime>,
102 #[sea_orm(belongs_to, from = "repo_id", to = "id")]
103 pub repo: BelongsTo<super::super::repo::repos::Entity>,
104}
105
106impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/ai/ai_review_result.rs View lib/models/src/ai/ai_review_result.rs
@@ -0,0 +1,74 @@
1use serde::{Deserialize, Serialize};
2
3/// `findings_json` 中的单条行级发现。
4///
5/// 生产方(agent 写入)与消费方(pulls 读取)共用该结构,避免形状漂移。
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct AiFinding {
8 pub path: String,
9 pub line: i32,
10 #[serde(default)]
11 pub side: Option<String>,
12 #[serde(default)]
13 pub severity: Option<String>,
14 pub body: String,
15}
16use sea_orm::JsonValue;
17use sea_orm::entity::prelude::*;
18use time::OffsetDateTime;
19use uuid::Uuid;
20
21#[derive(
22 Debug,
23 Clone,
24 Copy,
25 PartialEq,
26 Eq,
27 Default,
28 EnumIter,
29 DeriveActiveEnum,
30 Serialize,
31 Deserialize,
32 utoipa::ToSchema,
33)]
34#[serde(rename_all = "snake_case")]
35#[sea_orm(rs_type = "String", db_type = "String(StringLen::N(24))")]
36pub enum AiReviewVerdict {
37 #[default]
38 #[sea_orm(string_value = "commented")]
39 Commented,
40 #[sea_orm(string_value = "changes_requested")]
41 ChangesRequested,
42}
43
44#[sea_orm::model]
45#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
46#[sea_orm(table_name = "ai_review_result")]
47pub struct Model {
48 #[sea_orm(primary_key, auto_increment = false)]
49 pub id: Uuid,
50 #[sea_orm(indexed)]
51 pub repo_id: Uuid,
52 #[sea_orm(indexed)]
53 pub pull_request_id: Option<Uuid>,
54 #[sea_orm(indexed)]
55 pub commit_sha: String,
56 pub verdict: AiReviewVerdict,
57 pub summary: String,
58 pub findings_json: JsonValue,
59 #[sea_orm(default_value = false)]
60 pub truncated: bool,
61 /// 生成该结果时使用的输出语言(BCP-47 或 `auto`)。
62 #[sea_orm(default_value = "")]
63 pub language: String,
64 pub job_id: Uuid,
65 pub created_at: OffsetDateTime,
66 #[sea_orm(belongs_to, from = "repo_id", to = "id")]
67 pub repo: BelongsTo<super::super::repo::repos::Entity>,
68 #[sea_orm(belongs_to, from = "pull_request_id", to = "id")]
69 pub pull_request: BelongsTo<Option<super::super::pull_request::pull_requests::Entity>>,
70 #[sea_orm(belongs_to, from = "job_id", to = "id")]
71 pub job: BelongsTo<super::ai_job::Entity>,
72}
73
74impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/ai/code_comment.rs View lib/models/src/ai/code_comment.rs
@@ -0,0 +1,41 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "code_comment")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub id: Uuid,
11 #[sea_orm(indexed)]
12 pub repo_id: Uuid,
13 #[sea_orm(indexed)]
14 pub path: String,
15 pub line: i32,
16 #[sea_orm(indexed)]
17 pub commit_sha: String,
18 pub author_id: Uuid,
19 #[sea_orm(indexed)]
20 pub parent_id: Option<Uuid>,
21 pub body: String,
22 pub resolved_at: Option<OffsetDateTime>,
23 pub resolved_by: Option<Uuid>,
24 pub created_at: OffsetDateTime,
25 pub updated_at: Option<OffsetDateTime>,
26 #[sea_orm(belongs_to, from = "repo_id", to = "id")]
27 pub repo: BelongsTo<super::super::repo::repos::Entity>,
28 #[sea_orm(belongs_to, relation_enum = "Author", from = "author_id", to = "uid")]
29 pub author: BelongsTo<super::super::users::user::Entity>,
30 #[sea_orm(belongs_to, from = "resolved_by", to = "uid")]
31 pub resolver: BelongsTo<Option<super::super::users::user::Entity>>,
32 #[sea_orm(
33 belongs_to,
34 relation_enum = "Parent",
35 from = "parent_id",
36 to = "id"
37 )]
38 pub parent: BelongsTo<Option<super::code_comment::Entity>>,
39}
40
41impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/ai/mod.rs View lib/models/src/ai/mod.rs
@@ -0,0 +1,4 @@
1pub mod ai_job;
2pub mod ai_review_result;
3pub mod code_comment;
4pub mod repo_ai_summary;
Added lib/models/src/ai/repo_ai_summary.rs View lib/models/src/ai/repo_ai_summary.rs
@@ -0,0 +1,32 @@
1use sea_orm::JsonValue;
2use sea_orm::entity::prelude::*;
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6#[sea_orm::model]
7#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
8#[sea_orm(table_name = "repo_ai_summary")]
9pub struct Model {
10 #[sea_orm(primary_key, auto_increment = false)]
11 pub id: Uuid,
12 #[sea_orm(indexed, unique_key = "repo_head")]
13 pub repo_id: Uuid,
14 #[sea_orm(unique_key = "repo_head")]
15 pub head_sha: String,
16 pub base_sha: Option<String>,
17 pub commit_count: i32,
18 pub content: String,
19 pub highlights_json: JsonValue,
20 pub risks_json: JsonValue,
21 pub model: String,
22 /// 生成该总结时使用的输出语言(BCP-47 或 `auto`)。
23 #[sea_orm(default_value = "")]
24 pub language: String,
25 pub job_id: Option<Uuid>,
26 pub created_at: OffsetDateTime,
27 pub updated_at: OffsetDateTime,
28 #[sea_orm(belongs_to, from = "repo_id", to = "id")]
29 pub repo: BelongsTo<super::super::repo::repos::Entity>,
30}
31
32impl ActiveModelBehavior for ActiveModel {}
Modified lib/models/src/repo/mod.rs View lib/models/src/repo/mod.rs
@@ -3,9 +3,10 @@
33pub mod repo_collaborations;
44pub mod repo_license;
55pub mod repo_relation_topic;
6pub mod repo_release;
67pub mod repo_star;
78pub mod repo_tag_protection_policy;
89pub mod repo_tag_protection_whitelists;
910pub mod repo_topic;
1011pub mod repo_watch;
11pub mod repos;
12pub mod repos;
No newline at end of file
Modified lib/models/src/lib.rs View lib/models/src/lib.rs
@@ -1,4 +1,5 @@
11pub mod admin;
2pub mod ai;
23pub mod chat;
34pub mod ci;
45pub mod common;
Added lib/pulls/src/handlers/ai/get_ai_review.rs View lib/pulls/src/handlers/ai/get_ai_review.rs
@@ -0,0 +1,39 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::ApiError;
7use crate::services::ai::AiReviewDto;
8use crate::services::service::PullService;
9use crate::share::lookup::pull_handle;
10use crate::share::path::PullPath;
11
12#[utoipa::path(
13 get,
14 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/ai-reviews/{review_id}",
15 tag = "ai",
16 description = "读取单条 AI 评审结果。",
17 params(PullPath, ("review_id" = Uuid, Path, description = "ai review id")),
18 responses(
19 (status = 200, description = "ai review", body = AppResponseData<AiReviewDto>)
20 )
21)]
22pub async fn get_ai_review(
23 State(service): State<PullService>,
24 session: Session,
25 path: PullPath,
26 Path(review_id): Path<Uuid>,
27) -> Result<AppResponseData<AiReviewDto>, ApiError> {
28 let handle = pull_handle(
29 &service,
30 &session,
31 path.space_name.clone(),
32 path.repo_name.clone(),
33 path.pull_ref,
34 false,
35 )
36 .await?;
37 let review = service.get_ai_review(&handle.pull, review_id).await?;
38 Ok(AppResponseData::ok(review))
39}
No newline at end of file
Added lib/pulls/src/handlers/ai/list_ai_reviews.rs View lib/pulls/src/handlers/ai/list_ai_reviews.rs
@@ -0,0 +1,37 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::ai::AiReviewDto;
7use crate::services::service::PullService;
8use crate::share::lookup::pull_handle;
9use crate::share::path::PullPath;
10
11#[utoipa::path(
12 get,
13 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/ai-reviews",
14 tag = "ai",
15 description = "列出该合并请求的 AI 评审结果(新→旧),含行级发现与是否对应当前 head。",
16 params(PullPath),
17 responses(
18 (status = 200, description = "ai reviews", body = AppResponseData<Vec<AiReviewDto>>)
19 )
20)]
21pub async fn list_ai_reviews(
22 State(service): State<PullService>,
23 session: Session,
24 path: PullPath,
25) -> Result<AppResponseData<Vec<AiReviewDto>>, ApiError> {
26 let handle = pull_handle(
27 &service,
28 &session,
29 path.space_name.clone(),
30 path.repo_name.clone(),
31 path.pull_ref,
32 false,
33 )
34 .await?;
35 let reviews = service.list_ai_reviews(&handle.pull).await?;
36 Ok(AppResponseData::ok(reviews))
37}
No newline at end of file
Added lib/pulls/src/handlers/ai/mod.rs View lib/pulls/src/handlers/ai/mod.rs
@@ -0,0 +1,3 @@
1pub mod get_ai_review;
2pub mod list_ai_reviews;
3pub mod trigger_ai_review;
No newline at end of file
Added lib/pulls/src/handlers/ai/trigger_ai_review.rs View lib/pulls/src/handlers/ai/trigger_ai_review.rs
@@ -0,0 +1,53 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::ai::AiReviewJobDto;
7use crate::services::service::PullService;
8use crate::share::lookup::pull_handle;
9use crate::share::path::PullPath;
10
11/// 手动触发的可选参数:指定输出语言(BCP-47 或 `auto`)。
12#[derive(Debug, Clone, Default, serde::Deserialize, utoipa::ToSchema)]
13pub struct TriggerAiReviewCmd {
14 #[serde(default)]
15 pub language: Option<String>,
16}
17
18#[utoipa::path(
19 post,
20 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/ai-review",
21 tag = "ai",
22 description = "手动触发(或重跑)该合并请求的 AI 评审;任务异步执行,立即返回作业信息。",
23 params(PullPath),
24 request_body = Option<TriggerAiReviewCmd>,
25 responses(
26 (status = 200, description = "ai review job", body = AppResponseData<AiReviewJobDto>)
27 )
28)]
29pub async fn trigger_ai_review(
30 State(service): State<PullService>,
31 session: Session,
32 path: PullPath,
33 body: Option<axum::Json<TriggerAiReviewCmd>>,
34) -> Result<AppResponseData<AiReviewJobDto>, ApiError> {
35 let handle = pull_handle(
36 &service,
37 &session,
38 path.space_name.clone(),
39 path.repo_name.clone(),
40 path.pull_ref,
41 true,
42 )
43 .await?;
44 let job = service
45 .trigger_ai_review(
46 &handle.ctx,
47 &handle.pull,
48 handle.caller,
49 body.and_then(|axum::Json(cmd)| cmd.language),
50 )
51 .await?;
52 Ok(AppResponseData::ok(job))
53}
Modified lib/pulls/src/handlers/mod.rs View lib/pulls/src/handlers/mod.rs
@@ -1,3 +1,4 @@
1pub mod ai;
12pub mod assignees;
23pub mod checks;
34pub mod comments;
Added lib/pulls/src/services/ai.rs View lib/pulls/src/services/ai.rs
@@ -0,0 +1,190 @@
1use ci_protocol::ai::AgentTask;
2use export::pagination::Page;
3use git::context::facade::GitContext;
4use models::ai::ai_job;
5use models::ai::ai_review_result::{self, AiFinding, AiReviewVerdict, Model as ReviewRow};
6use models::pull_request::{pull_request_comment, pull_requests};
7use sea_orm::{
8 ColumnTrait, EntityTrait, QueryFilter, QueryOrder,
9};
10use serde::{Deserialize, Serialize};
11use time::OffsetDateTime;
12use utoipa::ToSchema;
13use uuid::Uuid;
14
15use crate::errors::PullError;
16use crate::services::service::PullService;
17
18/// AI 评审任务提交结果。
19#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
20pub struct AiReviewJobDto {
21 pub job_id: Uuid,
22 pub status: String,
23 pub model: String,
24}
25
26/// 一条 AI 行级发现(对外视图)。
27#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
28pub struct AiFindingDto {
29 pub path: String,
30 pub line: i32,
31 pub side: Option<String>,
32 pub severity: Option<String>,
33 pub body: String,
34 /// 该 finding 对应的行内评论 id(前端据此跳转)。
35 pub comment_id: Option<Uuid>,
36}
37
38/// AI 评审结果视图。
39#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
40pub struct AiReviewDto {
41 pub id: Uuid,
42 pub pull_request_id: Option<Uuid>,
43 pub commit_sha: String,
44 pub verdict: AiReviewVerdict,
45 pub summary: String,
46 pub findings: Vec<AiFindingDto>,
47 pub truncated: bool,
48 /// 生成该评审时使用的输出语言(BCP-47 或 `auto`)。
49 pub language: String,
50 pub created_at: OffsetDateTime,
51 /// 当前是否仍是最新 head 上的评审。
52 pub current: bool,
53 pub job_error: Option<String>,
54}
55
56pub type AiReviewPage = Page<AiReviewDto>;
57
58impl PullService {
59 /// 手动(重新)触发 AI 评审。
60 pub async fn trigger_ai_review(
61 &self,
62 _ctx: &GitContext,
63 pull: &pull_requests::Model,
64 actor: Option<Uuid>,
65 language: Option<String>,
66 ) -> Result<AiReviewJobDto, PullError> {
67 let cfg = &self.config().ai;
68 if !cfg.review_on() {
69 return Err(PullError::BadRequest(
70 "ai review is disabled by config".to_string(),
71 ));
72 }
73 let task = AgentTask::PullReview {
74 job_id: Uuid::now_v7(),
75 repo_id: pull.repo_id,
76 pull_request_id: pull.id,
77 head_sha: pull.head_sha.clone(),
78 // 请求显式指定语言时优先,否则用触发者的偏好。
79 language: match language.filter(|value| !value.trim().is_empty()) {
80 Some(explicit) => Some(ci_protocol::ai_languages::normalize(&explicit)),
81 None => git::sync::actor_language(self.db_read(), actor).await,
82 },
83 };
84 let job_id = git::sync::ai_task::submit_ai_task(
85 self.db_write(),
86 &self.state.redis,
87 cfg,
88 &task,
89 )
90 .await
91 .map_err(|e| PullError::Internal(format!("submit ai review: {e}")))?;
92 Ok(AiReviewJobDto {
93 job_id,
94 status: "pending".to_string(),
95 model: cfg.model.trim().to_string(),
96 })
97 }
98
99 /// 列出该 PR 的 AI 评审(新→旧)。
100 pub async fn list_ai_reviews(
101 &self,
102 pull: &pull_requests::Model,
103 ) -> Result<Vec<AiReviewDto>, PullError> {
104 let rows = ai_review_result::Entity::find()
105 .filter(ai_review_result::Column::PullRequestId.eq(pull.id))
106 .order_by_desc(ai_review_result::Column::CreatedAt)
107 .all(self.db_read())
108 .await?;
109 self.assemble_ai_reviews(rows, &pull.head_sha).await
110 }
111
112 /// 单条 AI 评审详情。
113 pub async fn get_ai_review(
114 &self,
115 pull: &pull_requests::Model,
116 review_id: Uuid,
117 ) -> Result<AiReviewDto, PullError> {
118 let row = ai_review_result::Entity::find_by_id(review_id)
119 .filter(ai_review_result::Column::PullRequestId.eq(pull.id))
120 .one(self.db_read())
121 .await?
122 .ok_or(PullError::NotFound)?;
123 let mut views = self.assemble_ai_reviews(vec![row], &pull.head_sha).await?;
124 views.pop().ok_or(PullError::NotFound)
125 }
126
127 /// 把结果行拼装为对外视图,并回填 findings 对应的评论 id 与作业错误。
128 async fn assemble_ai_reviews(
129 &self,
130 rows: Vec<ReviewRow>,
131 head_sha: &str,
132 ) -> Result<Vec<AiReviewDto>, PullError> {
133 if rows.is_empty() {
134 return Ok(Vec::new());
135 }
136 let review_ids: Vec<Uuid> = rows.iter().map(|row| row.id).collect();
137 let comments = pull_request_comment::Entity::find()
138 .filter(pull_request_comment::Column::ReviewId.is_in(review_ids))
139 .order_by_asc(pull_request_comment::Column::CreatedAt)
140 .all(self.db_read())
141 .await?;
142 let job_ids: Vec<Uuid> = rows.iter().map(|row| row.job_id).collect();
143 let jobs = ai_job::Entity::find()
144 .filter(ai_job::Column::Id.is_in(job_ids))
145 .all(self.db_read())
146 .await?;
147
148 let mut out = Vec::with_capacity(rows.len());
149 for row in rows {
150 let findings: Vec<AiFinding> =
151 serde_json::from_value(row.findings_json.clone()).unwrap_or_default();
152 let mut comment_ids: Vec<Uuid> = comments
153 .iter()
154 .filter(|c| c.review_id == Some(row.id))
155 .map(|c| c.id)
156 .collect();
157 comment_ids.sort();
158 let findings = findings
159 .into_iter()
160 .enumerate()
161 .map(|(index, finding)| AiFindingDto {
162 path: finding.path,
163 line: finding.line,
164 side: finding.side,
165 severity: finding.severity,
166 body: finding.body,
167 comment_id: comment_ids.get(index).copied(),
168 })
169 .collect();
170 let job_error = jobs
171 .iter()
172 .find(|job| job.id == row.job_id)
173 .and_then(|job| job.error.clone());
174 out.push(AiReviewDto {
175 id: row.id,
176 pull_request_id: row.pull_request_id,
177 commit_sha: row.commit_sha.clone(),
178 verdict: row.verdict,
179 summary: row.summary,
180 findings,
181 truncated: row.truncated,
182 language: row.language,
183 created_at: row.created_at,
184 current: row.commit_sha == head_sha,
185 job_error,
186 });
187 }
188 Ok(out)
189 }
190}
No newline at end of file
Modified lib/pulls/src/services/events.rs View lib/pulls/src/services/events.rs
@@ -92,6 +92,7 @@
9292 )
9393 .await?;
9494 self.publish_ci_event(pull, actor, event_type).await;
95 self.publish_ai_event(pull, actor, event_type).await;
9596 Ok(())
9697 }
9798
@@ -104,6 +105,7 @@
104105 event_type: &str,
105106 ) {
106107 self.publish_ci_event(pull, actor, event_type).await;
108 self.publish_ai_event(pull, actor, event_type).await;
107109 }
108110
109111 /// Mirrors CI-relevant pull request activity onto the `gitdata:events`
@@ -157,6 +159,39 @@
157159 }
158160 }
159161
162 /// 打开/更新/重开 PR 时投递 AI 评审任务。
163 ///
164 /// 独立于 `ci.enabled`:只看 `[ai]` 总开关与评审子开关,且投递失败不影响
165 /// 事件写入与接口返回。
166 async fn publish_ai_event(&self, pull: &pull_requests::Model, actor: Option<Uuid>, event_type: &str) {
167 let cfg = &self.config().ai;
168 if !cfg.review_on() {
169 return;
170 }
171 let trigger = matches!(
172 event_type,
173 EVENT_CREATED | EVENT_HEAD_UPDATED | EVENT_REOPENED
174 );
175 if !trigger {
176 return;
177 }
178 let task = ci_protocol::ai::AgentTask::PullReview {
179 job_id: Uuid::now_v7(),
180 repo_id: pull.repo_id,
181 pull_request_id: pull.id,
182 head_sha: pull.head_sha.clone(),
183 // 评审语言跟随触发者(推送/开 PR 的人)。
184 language: git::sync::actor_language(self.db_read(), actor).await,
185 };
186 git::sync::ai_task::submit_ai_task_logged(
187 self.db_write(),
188 &self.state.redis,
189 cfg,
190 &task,
191 )
192 .await;
193 }
194
160195 async fn pull_space_id(&self, repo_id: Uuid) -> Option<Uuid> {
161196 let repo = models::repo::repos::Entity::find_by_id(repo_id)
162197 .one(self.db_read())
Modified lib/pulls/src/services/mod.rs View lib/pulls/src/services/mod.rs
@@ -1,3 +1,4 @@
1pub mod ai;
12pub mod approvals;
23pub mod assignees;
34pub mod changes;
Modified lib/pulls/src/openapi.rs View lib/pulls/src/openapi.rs
@@ -19,6 +19,9 @@
1919 crate::handlers::pulls::get_pull_patch::get_pull_patch,
2020 crate::handlers::pulls::refresh_pull_summary::refresh_pull_summary,
2121 crate::handlers::pulls::list_pull_events::list_pull_events,
22 crate::handlers::ai::trigger_ai_review::trigger_ai_review,
23 crate::handlers::ai::list_ai_reviews::list_ai_reviews,
24 crate::handlers::ai::get_ai_review::get_ai_review,
2225 crate::handlers::pulls::lock_pull::lock_pull,
2326 crate::handlers::pulls::lock_pull::unlock_pull,
2427 crate::handlers::pulls::update_branch_pull::update_branch_pull,
@@ -86,6 +89,10 @@
8689 crate::services::changes::PullSummaryDto,
8790 crate::services::events::PullEventDto,
8891 crate::services::reviews::PullReviewDto,
92 crate::services::ai::AiReviewDto,
93 crate::services::ai::AiReviewJobDto,
94 crate::services::ai::AiFindingDto,
95 models::ai::ai_review_result::AiReviewVerdict,
8996 crate::services::reviews::ReviewIntent,
9097 crate::services::reviews::SubmitReviewCmd,
9198 crate::services::reviews::InlineDraft,
Modified lib/pulls/src/routes.rs View lib/pulls/src/routes.rs
@@ -1,6 +1,9 @@
11use axum::Router;
22use axum::routing::{get, post, put};
33
4use crate::handlers::ai::get_ai_review::get_ai_review;
5use crate::handlers::ai::list_ai_reviews::list_ai_reviews;
6use crate::handlers::ai::trigger_ai_review::trigger_ai_review;
47use crate::handlers::assignees::add_pull_assignee::add_pull_assignee;
58use crate::handlers::assignees::list_pull_assignees::list_pull_assignees;
69use crate::handlers::assignees::remove_pull_assignee::remove_pull_assignee;
@@ -61,6 +64,12 @@
6164 .route(&format!("{ITEM}/patch"), get(get_pull_patch))
6265 .route(&format!("{ITEM}/summary"), post(refresh_pull_summary))
6366 .route(&format!("{ITEM}/events"), get(list_pull_events))
67 .route(&format!("{ITEM}/ai-review"), post(trigger_ai_review))
68 .route(&format!("{ITEM}/ai-reviews"), get(list_ai_reviews))
69 .route(
70 &format!("{ITEM}/ai-reviews/{{review_id}}"),
71 get(get_ai_review),
72 )
6473 .route(&format!("{ITEM}/checks"), get(get_pull_checks))
6574 .route(&format!("{ITEM}/gate"), get(get_pull_gate))
6675 .route(&format!("{ITEM}/lock"), put(lock_pull).delete(unlock_pull))
Added web/src/entities/pull-request/model/ai.ts View web/src/entities/pull-request/model/ai.ts
@@ -0,0 +1,104 @@
1/**
2 * AI domain: PR / commit 评审与行级发现的读写。
3 *
4 * 仓库级总结在 `entities/repo` 中(它属于仓库而非 PR);本文件只覆盖
5 * 合并请求与其提交的评审表面。
6 */
7import Apis from '@/shared/api'
8import { unwrapApi } from '@/shared/lib'
9import type {
10 AppResponseData_AiReviewDto,
11 AppResponseData_AiReviewJobDto,
12 AppResponseData_Vec_AiReviewDto,
13 AppResponseData_Vec_CommitAiReviewDto,
14 AppResponseData_Vec_LanguageOptionDto,
15} from '@/shared/api'
16
17export type PullAiReview = NonNullable<AppResponseData_AiReviewDto['data']>
18export type PullAiReviewJob = NonNullable<AppResponseData_AiReviewJobDto['data']>
19export type CommitAiReview = NonNullable<
20 NonNullable<AppResponseData_Vec_CommitAiReviewDto['data']>[number]
21>
22export type AiFinding = PullAiReview['findings'][number]
23
24/** 手动(重跑)PR 的 AI 评审。 */
25export const triggerPullAiReview = (
26 ref: { space: string; repo: string; pull: string },
27 language?: string,
28) =>
29 unwrapApi<PullAiReviewJob>(
30 Apis.ai.trigger_ai_review({
31 pathParams: { space_name: ref.space, repo_name: ref.repo, pull_ref: ref.pull },
32 // 显式传语言可覆盖触发者的偏好;省略则服务端按用户偏好解析。
33 data: language ? { language } : {},
34 }),
35 )
36
37export const listPullAiReviews = (ref: { space: string; repo: string; pull: string }) =>
38 unwrapApi<NonNullable<AppResponseData_Vec_AiReviewDto['data']>>(
39 Apis.ai.list_ai_reviews({
40 pathParams: { space_name: ref.space, repo_name: ref.repo, pull_ref: ref.pull },
41 }),
42 )
43
44export const getPullAiReview = (
45 ref: { space: string; repo: string; pull: string },
46 reviewId: string,
47) =>
48 unwrapApi<PullAiReview>(
49 Apis.ai.get_ai_review({
50 pathParams: {
51 space_name: ref.space,
52 repo_name: ref.repo,
53 pull_ref: ref.pull,
54 review_id: reviewId,
55 },
56 }),
57 )
58
59/** 提交级 AI 评审(提交页与仓库页共用)。 */
60export const triggerCommitAiReview = (
61 space: string,
62 repo: string,
63 revision?: string,
64 language?: string,
65) =>
66 unwrapApi<PullAiReviewJob>(
67 Apis.ai.trigger_commit_ai_review({
68 pathParams: { space_name: space, repo_name: repo },
69 data: { revision: revision || null, language: language || null },
70 }),
71 )
72
73export const listCommitAiReviews = (space: string, repo: string, revision: string) =>
74 unwrapApi<CommitAiReview[]>(
75 Apis.ai.list_commit_ai_reviews({
76 pathParams: { space_name: space, repo_name: repo, revision },
77 }),
78 )
79
80export function aiReviewVerdictLabel(verdict: PullAiReview['verdict']): string {
81 return verdict === 'changes_requested' ? '请求修改' : '仅评论'
82}
83
84export function severityTone(severity?: string | null): string {
85 switch ((severity || 'minor').toLowerCase()) {
86 case 'critical':
87 return 'text-[#b42318] bg-[rgba(242,63,67,0.10)]'
88 case 'major':
89 return 'text-[#8a6100] bg-[rgba(240,178,50,0.16)]'
90 default:
91 return 'text-shell-ink-muted bg-shell-active'
92 }
93}
94/** 可选输出语言(`auto` = 沿用代码注释的语言)。 */
95export type LanguageOption = NonNullable<
96 NonNullable<AppResponseData_Vec_LanguageOptionDto['data']>[number]
97>
98
99export const listAiLanguages = () =>
100 unwrapApi<LanguageOption[]>(Apis.ai.list_ai_languages({}))
101
102/** 语言显示名:本语名优先,`auto` 用调用方给的文案。 */
103export const languageLabel = (option: LanguageOption, autoLabel = '沿用内容语言') =>
104 option.auto ? autoLabel : option.native_name
Added web/src/entities/pull-request/ui/language-picker.tsx View web/src/entities/pull-request/ui/language-picker.tsx
@@ -0,0 +1,69 @@
1import { Languages } from 'lucide-react'
2import { useAsync } from '@/shared/lib'
3import { languageLabel, listAiLanguages, type LanguageOption } from '../model/ai'
4
5export interface LanguagePickerProps {
6 value: string
7 onChange: (code: string) => void
8 disabled?: boolean
9 /** `auto` 选项在业务语境里的文案(如「沿用内容语言」)。 */
10 autoLabel?: string
11 /** 渲染在 select 前的说明文字。 */
12 label?: string
13}
14
15/**
16 * AI 输出语言选择器。
17 *
18 * 选项来自 `GET /ai/languages`:覆盖中、英、日、韩、法、德、西、葡、俄、阿等
19 * 主流语言,外加 `auto`(沿用代码注释的语言)。空字符串表示「跟随我的偏好」——
20 * 由服务端按 `users.language` 解析。
21 */
22export function LanguagePicker({
23 value,
24 onChange,
25 disabled,
26 autoLabel = '沿用内容语言',
27 label = '输出语言',
28}: LanguagePickerProps) {
29 const options = useAsync(() => listAiLanguages(), [], { cacheKey: 'ai:languages' })
30 const rows: LanguageOption[] = options.data ?? []
31 // 「跟随偏好」在没有已知选项时也要能表达,因此始终保留一个空值项。
32 const follow: LanguageOption = {
33 code: '',
34 native_name: '跟随我的偏好',
35 english_name: 'Follow my preference',
36 auto: false,
37 default: false,
38 }
39 return (
40 <label className="flex items-center gap-1.5 text-[12.5px] text-shell-ink-muted">
41 <Languages className="h-3.5 w-3.5 shrink-0" aria-hidden />
42 <span className="sr-only">{label}</span>
43 <select
44 value={value}
45 disabled={disabled || options.loading}
46 onChange={event => onChange(event.target.value)}
47 title={label}
48 className="rounded-md border border-shell-hairline bg-shell-field px-2 py-1 text-[12.5px] text-shell-ink disabled:opacity-60"
49 >
50 <option value="">跟随我的偏好</option>
51 {rows.map(option => (
52 <option key={option.code} value={option.code}>
53 {languageLabel(
54 option,
55 option.code === 'auto' ? autoLabel : option.native_name,
56 )}
57 {option.default ? '(默认)' : ''}
58 </option>
59 ))}
60 {/* 极端情况下清单为空:至少让 follow 选项可读。 */}
61 {rows.length === 0 && !options.loading ? (
62 <option value={follow.code} disabled hidden>
63 {follow.native_name}
64 </option>
65 ) : null}
66 </select>
67 </label>
68 )
69}
No newline at end of file
Modified web/src/entities/pull-request/index.ts View web/src/entities/pull-request/index.ts
@@ -5,3 +5,5 @@
55 */
66export * from './model/pull'
77export * from './model/api'
8export * from './model/ai'
9export { LanguagePicker, type LanguagePickerProps } from './ui/language-picker'
Added web/src/entities/repo/model/ai-summary.ts View web/src/entities/repo/model/ai-summary.ts
@@ -0,0 +1,27 @@
1/** 仓库级 AI 总结:按 head 缓存,读取不触发模型调用。 */
2import Apis from '@/shared/api'
3import { unwrapApi } from '@/shared/lib'
4import type {
5 AppResponseData_Option_RepoAiSummaryDto,
6 TriggerSummaryCmd,
7} from '@/shared/api'
8
9export type RepoAiSummary = NonNullable<AppResponseData_Option_RepoAiSummaryDto['data']>
10
11export const fetchRepoAiSummary = (space: string, repo: string) =>
12 unwrapApi<RepoAiSummary | null>(
13 Apis.ai.get_ai_summary({ pathParams: { space_name: space, repo_name: repo } }),
14 )
15
16/** 触发(或刷新)总结;返回作业 id。 */
17export const triggerRepoAiSummary = (
18 space: string,
19 repo: string,
20 cmd: TriggerSummaryCmd & { language?: string } = {},
21) =>
22 unwrapApi<string>(
23 Apis.ai.trigger_ai_summary({
24 pathParams: { space_name: space, repo_name: repo },
25 data: cmd,
26 }),
27 )
No newline at end of file
Added web/src/entities/repo/model/code-comments.ts View web/src/entities/repo/model/code-comments.ts
@@ -0,0 +1,82 @@
1/** 代码行级评论领域:浏览页与提交页共用的行级评论读写。 */
2import Apis from '@/shared/api'
3import { unwrapApi } from '@/shared/lib'
4import type { AppResponseData_CodeCommentDto, CreateCodeCommentCmd } from '@/shared/api'
5
6export type CodeComment = NonNullable<AppResponseData_CodeCommentDto['data']>
7
8/** 分页响应在生成器里是泛型 `Page`,行类型由此显式声明。 */
9export interface CodeCommentPageData {
10 items: CodeComment[]
11 total: number
12 limit: number
13 offset: number
14}
15
16export const listCodeComments = (
17 space: string,
18 repo: string,
19 params: { path?: string; revision?: string; resolved?: boolean; limit?: number; offset?: number } = {},
20) =>
21 Apis.codeComments
22 .list_code_comments({
23 pathParams: { space_name: space, repo_name: repo },
24 params: {
25 path: params.path,
26 revision: params.revision,
27 resolved: params.resolved,
28 limit: params.limit,
29 offset: params.offset,
30 },
31 })
32 .then(res => res as unknown as { data?: CodeCommentPageData })
33 .then(res => res.data ?? { items: [], total: 0, limit: 0, offset: 0 })
34
35export const createCodeComment = (space: string, repo: string, cmd: CreateCodeCommentCmd) =>
36 unwrapApi<CodeComment>(
37 Apis.codeComments.create_code_comment({
38 pathParams: { space_name: space, repo_name: repo },
39 data: cmd,
40 }),
41 )
42
43export const updateCodeComment = (space: string, repo: string, commentId: string, body: string) =>
44 unwrapApi<CodeComment>(
45 Apis.codeComments.update_code_comment({
46 pathParams: { space_name: space, repo_name: repo, comment_id: commentId },
47 data: { body },
48 }),
49 )
50
51export const deleteCodeComment = (space: string, repo: string, commentId: string) =>
52 unwrapApi<unknown>(
53 Apis.codeComments.delete_code_comment({
54 pathParams: { space_name: space, repo_name: repo, comment_id: commentId },
55 }),
56 )
57
58export const resolveCodeComment = (space: string, repo: string, commentId: string) =>
59 unwrapApi<CodeComment>(
60 Apis.codeComments.resolve_code_comment({
61 pathParams: { space_name: space, repo_name: repo, comment_id: commentId },
62 }),
63 )
64
65export const unresolveCodeComment = (space: string, repo: string, commentId: string) =>
66 unwrapApi<CodeComment>(
67 Apis.codeComments.unresolve_code_comment({
68 pathParams: { space_name: space, repo_name: repo, comment_id: commentId },
69 }),
70 )
71
72/** 把评论按 `path#line` 归组,供 diff / 代码视图按行渲染。 */
73export function groupCommentsByLine(rows: CodeComment[]): Map<string, CodeComment[]> {
74 const map = new Map<string, CodeComment[]>()
75 for (const row of rows) {
76 const key = `${row.path}#${row.line}`
77 const list = map.get(key) ?? []
78 list.push(row)
79 map.set(key, list)
80 }
81 return map
82}
No newline at end of file
Added web/src/entities/repo/model/releases.ts View web/src/entities/repo/model/releases.ts
@@ -0,0 +1,59 @@
1/** 发布记录领域:仓库 release 的读写与 AI 起草。 */
2import Apis from '@/shared/api'
3import { unwrapApi } from '@/shared/lib'
4import type { AppResponseData_ReleaseDto, CreateReleaseCmd, UpdateReleaseCmd } from '@/shared/api'
5
6export type Release = NonNullable<AppResponseData_ReleaseDto['data']>
7
8/** 生成器把分页响应用泛型 `Page` 表达,因此在这里声明行类型。 */
9export interface ReleaseListPage {
10 items: Release[]
11 total: number
12 limit: number
13 offset: number
14}
15
16export const listReleases = (
17 space: string,
18 repo: string,
19 params: { limit?: number; offset?: number; draft?: boolean } = {},
20) =>
21 Apis.releases
22 .list_releases({
23 pathParams: { space_name: space, repo_name: repo },
24 params: { limit: params.limit, offset: params.offset, draft: params.draft },
25 })
26 .then(res => res as unknown as { data?: ReleaseListPage })
27 .then(res => res.data ?? { items: [], total: 0, limit: 0, offset: 0 })
28
29export const getRelease = (space: string, repo: string, tag: string) =>
30 unwrapApi<Release>(
31 Apis.releases.get_release({ pathParams: { space_name: space, repo_name: repo, tag } }),
32 )
33
34export const createRelease = (space: string, repo: string, cmd: CreateReleaseCmd) =>
35 unwrapApi<Release>(
36 Apis.releases.create_release({
37 pathParams: { space_name: space, repo_name: repo },
38 data: cmd,
39 }),
40 )
41
42export const updateRelease = (space: string, repo: string, tag: string, cmd: UpdateReleaseCmd) =>
43 unwrapApi<Release>(
44 Apis.releases.update_release({
45 pathParams: { space_name: space, repo_name: repo, tag },
46 data: cmd,
47 }),
48 )
49
50export const deleteRelease = (space: string, repo: string, tag: string) =>
51 unwrapApi<unknown>(
52 Apis.releases.delete_release({ pathParams: { space_name: space, repo_name: repo, tag } }),
53 )
54
55/** 重新生成草稿文案;返回作业 id。 */
56export const redraftRelease = (space: string, repo: string, tag: string) =>
57 unwrapApi<string>(
58 Apis.releases.redraft_release({ pathParams: { space_name: space, repo_name: repo, tag } }),
59 )
No newline at end of file
Modified web/src/entities/repo/index.ts View web/src/entities/repo/index.ts
@@ -172,3 +172,34 @@
172172 type BlameRange,
173173 type SubmoduleRow,
174174} from './model/blame'
175// ai summary (repo-scoped, cached per head)
176export {
177 fetchRepoAiSummary,
178 triggerRepoAiSummary,
179 type RepoAiSummary,
180} from './model/ai-summary'
181
182// releases
183export {
184 listReleases,
185 getRelease,
186 createRelease,
187 updateRelease,
188 deleteRelease,
189 redraftRelease,
190 type Release,
191 type ReleaseListPage,
192} from './model/releases'
193
194// code line comments
195export {
196 listCodeComments,
197 createCodeComment,
198 updateCodeComment,
199 deleteCodeComment,
200 resolveCodeComment,
201 unresolveCodeComment,
202 groupCommentsByLine,
203 type CodeComment,
204 type CodeCommentPageData,
205} from './model/code-comments'
Added web/src/features/pull-request/ui/ai-tab.tsx View web/src/features/pull-request/ui/ai-tab.tsx
@@ -0,0 +1,128 @@
1import { useState } from 'react'
2import { Bot, Loader2, RefreshCw, Sparkles } from 'lucide-react'
3import { Button, ErrorPanel, PanelNote } from '@/shared/ui'
4import { fmtRelative, useAsync } from '@/shared/lib'
5import {
6 LanguagePicker,
7 aiReviewVerdictLabel,
8 listPullAiReviews,
9 severityTone,
10 triggerPullAiReview,
11 type PullRef,
12} from '@/entities/pull-request'
13import { Copyable } from './parts'
14
15export interface AiTabProps {
16 refObj: PullRef
17 headSha: string
18 /** 跳转到某个 finding 对应的 diff 行(切到 files 页签并定位)。 */
19 onOpenFinding: (path: string, line: number) => void
20}
21
22const VERDICT_TONE: Record<string, string> = {
23 changes_requested: 'text-[#b42318] bg-[rgba(242,63,67,0.10)]',
24 commented: 'text-shell-ink-muted bg-shell-active',
25}
26
27/** AI 评审页签:结论、行级发现与手动重跑。 */
28export function AiTab({ refObj, headSha, onOpenFinding }: AiTabProps) {
29 const [tick, setTick] = useState(0)
30 const [busy, setBusy] = useState(false)
31 const [notice, setNotice] = useState<string | null>(null)
32 /** '' = 跟随当前用户偏好。 */
33 const [language, setLanguage] = useState('')
34 const [error, setError] = useState<string | null>(null)
35
36 const reviews = useAsync(() => listPullAiReviews(refObj), [refObj.space, refObj.repo, refObj.pull, tick])
37 const failure = reviews.error as { code?: number; message?: string } | undefined
38
39 async function rerun() {
40 setBusy(true)
41 setError(null)
42 setNotice(null)
43 try {
44 const job = await triggerPullAiReview(refObj, language || undefined)
45 const scope = language ? ` · ${language}` : ''
46 setNotice(`已提交评审任务(${job.model}${scope}),完成后刷新即可看到结果。`)
47 } catch (f) {
48 setError(f instanceof Error ? f.message : '触发 AI 评审失败')
49 } finally {
50 setBusy(false)
51 }
52 }
53
54 if (reviews.loading && !reviews.data) return <PanelNote text="加载 AI 评审…" />
55 if (failure) return <ErrorPanel status={failure.code ?? 0} what="AI 评审" note={failure.message} onRetry={() => setTick(t => t + 1)} />
56
57 const rows = reviews.data ?? []
58
59 return (
60 <div className="space-y-3">
61 <div className="flex flex-wrap items-center gap-2">
62 <Button className="h-8 text-[12.5px]" disabled={busy} onClick={() => void rerun()}>
63 {busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : <Sparkles className="mr-1.5 h-3.5 w-3.5" aria-hidden />}
64 重新评审
65 </Button>
66 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => setTick(t => t + 1)}>
67 <RefreshCw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
68 刷新
69 </Button>
70 <LanguagePicker value={language} onChange={setLanguage} disabled={busy} />
71 {headSha ? <span className="text-[12px] text-shell-ink-muted">当前版本 <Copyable text={headSha} /></span> : null}
72 </div>
73
74 {notice ? <p className="rounded-lg border border-shell-hairline bg-shell-field px-3 py-2 text-[12.5px] text-shell-ink-muted">{notice}</p> : null}
75 {error ? <p className="text-[12.5px] text-[#b42318]" role="alert">{error}</p> : null}
76
77 {rows.length === 0 ? (
78 <PanelNote text="该合并请求还没有 AI 评审结果。开启 [ai] 后打开或更新 PR 会自动评审,也可点击上方按钮手动触发。" />
79 ) : (
80 rows.map(review => (
81 <section key={review.id} className="rounded-xl border border-shell-hairline bg-shell-surface">
82 <header className="flex flex-wrap items-center gap-2 border-b border-shell-hairline bg-shell-field px-4 py-2.5 text-[12.5px]">
83 <Bot className="h-4 w-4 shrink-0 text-shell-ink-muted" aria-hidden />
84 <span className={`rounded-full px-2 py-0.5 text-[11.5px] font-medium ${VERDICT_TONE[review.verdict] ?? VERDICT_TONE.commented}`}>
85 {aiReviewVerdictLabel(review.verdict)}
86 </span>
87 <span className="text-shell-ink-muted">{fmtRelative(review.created_at)}</span>
88 {review.current ? (
89 <span className="text-[11.5px] text-[#1a7f37]">对应当前版本</span>
90 ) : (
91 <span className="text-[11.5px] text-[#8a6100]">基于旧版本 {review.commit_sha.slice(0, 8)}</span>
92 )}
93 {review.language ? <span className="text-[11.5px] text-shell-ink-muted">{review.language}</span> : null}
94 {review.truncated ? <span className="text-[11.5px] text-[#8a6100]">变更内容被截断</span> : null}
95 </header>
96 <div className="space-y-3 px-4 py-3">
97 <p className="whitespace-pre-wrap break-words text-[13px] leading-6">{review.summary || '(无总结)'}</p>
98 {review.job_error ? <p className="text-[12px] text-[#b42318]">任务错误:{review.job_error}</p> : null}
99 {review.findings.length > 0 ? (
100 <ul className="space-y-2">
101 {review.findings.map((finding, index) => (
102 <li key={`${finding.path}:${finding.line}:${index}`} className="rounded-lg border border-shell-hairline bg-shell-field px-3 py-2">
103 <div className="mb-1 flex flex-wrap items-center gap-2 text-[12px]">
104 <span className={`rounded px-1.5 py-0.5 font-medium ${severityTone(finding.severity)}`}>{finding.severity || 'minor'}</span>
105 <button
106 type="button"
107 onClick={() => onOpenFinding(finding.path, finding.line)}
108 className="min-w-0 truncate font-mono underline decoration-shell-hairline underline-offset-4 hover:decoration-shell-ink-muted"
109 title="在变更文件中定位该行"
110 >
111 {finding.path}:{finding.line}
112 </button>
113 {finding.side === 'left' ? <span className="text-shell-ink-muted">旧行</span> : null}
114 </div>
115 <p className="whitespace-pre-wrap break-words text-[12.5px] leading-5">{finding.body}</p>
116 </li>
117 ))}
118 </ul>
119 ) : (
120 <p className="text-[12.5px] text-shell-ink-muted">没有需要修改的问题。</p>
121 )}
122 </div>
123 </section>
124 ))
125 )}
126 </div>
127 )
128}
No newline at end of file
Modified web/src/features/pull-request/ui/files-tab.tsx View web/src/features/pull-request/ui/files-tab.tsx
@@ -1,4 +1,4 @@
1import { useMemo, useState } from 'react'
1import { useEffect, useMemo, useState } from 'react'
22import { PanelNote, ErrorPanel, Button, modalTextareaClass } from '@/shared/ui'
33import { useAsync } from '@/shared/lib'
44import {
@@ -18,11 +18,13 @@
1818 headSha: string
1919 /** File path to expand/scroll to when jumping from an inline comment. */
2020 focusPath: string
21 /** One-based line to scroll to inside `focusPath`; 0 disables scrolling. */
22 focusLine?: number
2123 onChanged: () => void
2224}
2325
2426/** 变更文件页签:逐文件 diff + 行内评论 + 待提交评审草稿。 */
25export function FilesTab({ refObj, headSha, focusPath, onChanged }: FilesTabProps) {
27export function FilesTab({ refObj, headSha, focusPath, focusLine = 0, onChanged }: FilesTabProps) {
2628 const [tick, setTick] = useState(0)
2729 const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
2830 const [draftAnchor, setDraftAnchor] = useState<InlineAnchor | null>(null)
@@ -40,7 +42,8 @@
4042 for (const thread of threads.data ?? []) {
4143 const root = thread.root
4244 if (!root.path || root.line == null) continue
43 const key = anchorKey(root.path, root.line, 'right')
45 // 评论自带 side(left/right),必须按实际值归位,否则删除行上的评论会落到新增侧。
46 const key = anchorKey(root.path, root.line, root.side === 'left' ? 'left' : 'right')
4447 const list = map.get(key) ?? []
4548 list.push({ id: root.id, body: root.body }, ...thread.replies.map(r => ({ id: r.id, body: r.body })))
4649 map.set(key, list)
@@ -50,6 +53,20 @@
5053
5154 const rows = useMemo(() => (files.data ?? []).map(file => changedFileToDiff(file)), [files.data])
5255
56 // 从 AI 发现/行内评论跳转过来时,滚动到目标行(两侧都试,命中即止)。
57 useEffect(() => {
58 if (!focusLine || !focusPath) return
59 const id = `diff-right-${encodeURIComponent(focusPath)}-${focusLine}`
60 const alt = `diff-left-${encodeURIComponent(focusPath)}-${focusLine}`
61 const target = document.getElementById(id) ?? document.getElementById(alt)
62 if (target) {
63 target.scrollIntoView({ block: 'center' })
64 target.classList.add('bg-[rgba(240,178,50,0.16)]')
65 const timer = window.setTimeout(() => target.classList.remove('bg-[rgba(240,178,50,0.16)]'), 2000)
66 return () => window.clearTimeout(timer)
67 }
68 }, [focusLine, focusPath, rows.length])
69
5370 const totalAdd = rows.reduce((sum, row) => sum + row.additions, 0)
5471 const totalDel = rows.reduce((sum, row) => sum + row.deletions, 0)
5572 const failure = files.error as { code?: number; message?: string } | undefined
Modified web/src/features/pull-request/ui/parts.tsx View web/src/features/pull-request/ui/parts.tsx
@@ -111,7 +111,10 @@
111111 const draft = key && lineNo != null ? renderDraft?.({ path: file.path, line: lineNo, side }) : null
112112 return (
113113 <Fragment key={`${hunk.header}:${index}`}>
114 <tr className={lineTone(line)}>
114 <tr
115 id={lineNo != null ? `diff-${side}-${encodeURIComponent(file.path)}-${lineNo}` : undefined}
116 className={lineTone(line)}
117 >
115118 <td className="w-10 shrink-0 select-none border-r border-shell-hairline px-2 text-right text-[11px] text-shell-ink-muted">
116119 {line.oldNo ?? ''}
117120 </td>
Modified web/src/features/pull-request/ui/pull-detail.tsx View web/src/features/pull-request/ui/pull-detail.tsx
@@ -9,6 +9,7 @@
99 RotateCcw,
1010 Send,
1111 ShieldCheck,
12 Sparkles,
1213 Unlock,
1314 X,
1415} from 'lucide-react'
@@ -45,8 +46,9 @@
4546import { FilesTab } from './files-tab'
4647import { CommitsTab } from './commits-tab'
4748import { ChecksTab } from './checks-tab'
49import { AiTab } from './ai-tab'
4850
49export type PullSubTab = 'conversation' | 'files' | 'commits' | 'checks'
51export type PullSubTab = 'conversation' | 'files' | 'commits' | 'checks' | 'ai'
5052
5153export interface PullDetailProps {
5254 space: string
@@ -63,12 +65,15 @@
6365 { id: 'files', label: '变更文件', icon: FileCode2 },
6466 { id: 'commits', label: '提交', icon: GitMerge },
6567 { id: 'checks', label: '检查', icon: ListChecks },
68 { id: 'ai', label: 'AI 评审', icon: Sparkles },
6669]
6770
6871/** PR 详情页:头部/操作 + 子页签 + 侧栏。 */
6972export function PullDetail({ space, repo, pullRef, ptab, onPtabChange, onBackToList, onOpenCommit }: PullDetailProps) {
7073 const [tick, setTick] = useState(0)
7174 const [focusFile, setFocusFile] = useState('')
75 /** 从 AI 发现跳转过来时要高亮的行号(0 表示不高亮)。 */
76 const [focusLine, setFocusLine] = useState(0)
7277 const [notice, setNotice] = useState<string | null>(null)
7378 const [error, setError] = useState<string | null>(null)
7479 const refObj: PullRef = { space, repo, pull: pullRef }
@@ -162,10 +167,21 @@
162167 />
163168 ) : null}
164169 {ptab === 'files' ? (
165 <FilesTab key={`${pull.head.sha}:${focusFile}`} refObj={refObj} headSha={pull.head.sha} focusPath={focusFile} onChanged={refresh} />
170 <FilesTab key={`${pull.head.sha}:${focusFile}:${focusLine}`} refObj={refObj} headSha={pull.head.sha} focusPath={focusFile} focusLine={focusLine} onChanged={refresh} />
166171 ) : null}
167172 {ptab === 'commits' ? <CommitsTab refObj={refObj} onOpenCommit={onOpenCommit} /> : null}
168173 {ptab === 'checks' ? <ChecksTab refObj={refObj} headSha={pull.head.sha} /> : null}
174 {ptab === 'ai' ? (
175 <AiTab
176 refObj={refObj}
177 headSha={pull.head.sha}
178 onOpenFinding={(path, line) => {
179 setFocusFile(path)
180 setFocusLine(line)
181 onPtabChange('files')
182 }}
183 />
184 ) : null}
169185 </div>
170186
171187 <div className="w-full shrink-0 space-y-3 lg:w-[300px]">
Added web/src/features/repo-ai/ui/ai-summary-tab.tsx View web/src/features/repo-ai/ui/ai-summary-tab.tsx
@@ -0,0 +1,108 @@
1import { useState } from 'react'
2import { AlertTriangle, Loader2, RefreshCw, Sparkles } from 'lucide-react'
3import { Button, ErrorPanel, PanelNote } from '@/shared/ui'
4import { fmtRelative, useAsync } from '@/shared/lib'
5import { fetchRepoAiSummary, triggerRepoAiSummary } from '@/entities/repo'
6import { LanguagePicker } from '@/entities/pull-request'
7
8export interface AiSummaryTabProps {
9 space: string
10 repo: string
11 rev: string
12}
13
14/** 仓库 AI 总结页签:读取按 head 缓存的总结,可手动刷新。 */
15export function AiSummaryTab({ space, repo, rev }: AiSummaryTabProps) {
16 const [tick, setTick] = useState(0)
17 const [busy, setBusy] = useState(false)
18 const [notice, setNotice] = useState<string | null>(null)
19 /** '' = 跟随当前用户偏好。 */
20 const [language, setLanguage] = useState('')
21 const [error, setError] = useState<string | null>(null)
22
23 const summary = useAsync(() => fetchRepoAiSummary(space, repo), [space, repo, tick])
24 const failure = summary.error as { code?: number; message?: string } | undefined
25 const data = summary.data
26
27 async function refresh() {
28 setBusy(true)
29 setError(null)
30 setNotice(null)
31 try {
32 await triggerRepoAiSummary(space, repo, language ? { language } : {})
33 setNotice('已提交总结任务;完成后点击“载入结果”查看。')
34 } catch (f) {
35 setError(f instanceof Error ? f.message : '触发 AI 总结失败')
36 } finally {
37 setBusy(false)
38 }
39 }
40
41 if (summary.loading && !data) return <PanelNote text="加载 AI 总结…" />
42 if (failure) return <ErrorPanel status={failure.code ?? 0} what="AI 总结" note={failure.message} onRetry={() => setTick(t => t + 1)} />
43
44 return (
45 <div className="space-y-3">
46 <div className="flex flex-wrap items-center gap-2">
47 <Button className="h-8 text-[12.5px]" disabled={busy} onClick={() => void refresh()}>
48 {busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : <Sparkles className="mr-1.5 h-3.5 w-3.5" aria-hidden />}
49 {data ? '重新生成' : '生成总结'}
50 </Button>
51 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => setTick(t => t + 1)}>
52 <RefreshCw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
53 载入结果
54 </Button>
55 <LanguagePicker value={language} onChange={setLanguage} disabled={busy} />
56 {rev ? <span className="text-[12px] text-shell-ink-muted">分支 {rev}</span> : null}
57 </div>
58
59 {notice ? <p className="rounded-lg border border-shell-hairline bg-shell-field px-3 py-2 text-[12.5px] text-shell-ink-muted">{notice}</p> : null}
60 {error ? <p className="text-[12.5px] text-[#b42318]" role="alert">{error}</p> : null}
61
62 {!data ? (
63 <PanelNote text="该分支还没有生成 AI 总结。总结基于最近提交生成并按 head 缓存,同一版本不会重复调用模型。" />
64 ) : (
65 <>
66 <section className="rounded-xl border border-shell-hairline bg-shell-surface">
67 <header className="flex flex-wrap items-center gap-2 border-b border-shell-hairline bg-shell-field px-4 py-2.5 text-[12px] text-shell-ink-muted">
68 <span>版本 {data.head_sha.slice(0, 8)}</span>
69 <span>· {data.commit_count} 个提交</span>
70 <span>· {fmtRelative(data.updated_at)}</span>
71 {data.current ? (
72 <span className="text-[#1a7f37]">对应当前分支</span>
73 ) : (
74 <span className="text-[#8a6100]">基于旧版本</span>
75 )}
76 {data.language ? <span>{data.language}</span> : null}
77 {data.pending_job_id ? <span className="text-[#8a6100]">有任务进行中</span> : null}
78 </header>
79 <div className="space-y-3 px-4 py-3">
80 <p className="whitespace-pre-wrap break-words text-[13px] leading-6">{data.content}</p>
81 </div>
82 </section>
83
84 {data.highlights.length > 0 ? (
85 <section className="rounded-xl border border-shell-hairline bg-shell-surface p-4">
86 <h4 className="mb-2 text-[13px] font-semibold">亮点</h4>
87 <ul className="list-disc space-y-1 pl-5 text-[12.5px] leading-5">
88 {data.highlights.map((item, index) => <li key={index}>{item}</li>)}
89 </ul>
90 </section>
91 ) : null}
92
93 {data.risks.length > 0 ? (
94 <section className="rounded-xl border border-shell-hairline bg-shell-surface p-4">
95 <h4 className="mb-2 flex items-center gap-1.5 text-[13px] font-semibold">
96 <AlertTriangle className="h-3.5 w-3.5 text-[#8a6100]" aria-hidden />
97 风险
98 </h4>
99 <ul className="list-disc space-y-1 pl-5 text-[12.5px] leading-5">
100 {data.risks.map((item, index) => <li key={index}>{item}</li>)}
101 </ul>
102 </section>
103 ) : null}
104 </>
105 )}
106 </div>
107 )
108}
No newline at end of file
Added web/src/features/repo-ai/index.ts View web/src/features/repo-ai/index.ts
@@ -0,0 +1 @@
1export { AiSummaryTab, type AiSummaryTabProps } from './ui/ai-summary-tab'
Added web/src/features/repo-history/ui/commit-insights.tsx View web/src/features/repo-history/ui/commit-insights.tsx
@@ -0,0 +1,223 @@
1import { useMemo, useState } from 'react'
2import { MessageSquareText, Sparkles, Trash2 } from 'lucide-react'
3import { Button, PanelNote, modalTextareaClass } from '@/shared/ui'
4import { listCodeComments, createCodeComment, deleteCodeComment, resolveCodeComment } from '@/entities/repo'
5import { LanguagePicker, listCommitAiReviews, severityTone, triggerCommitAiReview, type CommitAiReview } from '@/entities/pull-request'
6import { useAsync } from '@/shared/lib'
7
8export interface CommitInsightsProps {
9 space: string
10 repo: string
11 /** Commit being viewed; line comments are anchored to it. */
12 sha: string
13 onChanged?: () => void
14}
15
16/**
17 * 提交页的行级评论 + AI 评审面板。
18 *
19 * 行级评论锚定到具体提交(`code_comment.commit_sha`),提交被改写或分支前进后
20 * 仍保留原锚点并标记 outdated,而不是静默重定位。
21 */
22export function CommitInsights({ space, repo, sha, onChanged }: CommitInsightsProps) {
23 const [tick, setTick] = useState(0)
24 const [busy, setBusy] = useState(false)
25 const [error, setError] = useState<string | null>(null)
26 /** '' = 跟随当前用户偏好。 */
27 const [language, setLanguage] = useState('')
28
29 const comments = useAsync(
30 () => listCodeComments(space, repo, { revision: sha, limit: 200 }),
31 [space, repo, sha, tick],
32 )
33 const reviews = useAsync(() => listCommitAiReviews(space, repo, sha), [space, repo, sha, tick])
34
35 const rows = useMemo(() => comments.data?.items ?? [], [comments.data])
36 const aiRows: CommitAiReview[] = reviews.data ?? []
37
38 async function remove(id: string) {
39 setBusy(true)
40 setError(null)
41 try {
42 await deleteCodeComment(space, repo, id)
43 setTick(t => t + 1)
44 onChanged?.()
45 } catch (f) {
46 setError(f instanceof Error ? f.message : '删除评论失败')
47 } finally {
48 setBusy(false)
49 }
50 }
51
52 async function rerunAi() {
53 setBusy(true)
54 setError(null)
55 try {
56 await triggerCommitAiReview(space, repo, sha, language || undefined)
57 setTick(t => t + 1)
58 onChanged?.()
59 } catch (f) {
60 setError(f instanceof Error ? f.message : '触发 AI 评审失败')
61 } finally {
62 setBusy(false)
63 }
64 }
65
66 async function resolve(id: string) {
67 setBusy(true)
68 setError(null)
69 try {
70 await resolveCodeComment(space, repo, id)
71 setTick(t => t + 1)
72 } catch (f) {
73 setError(f instanceof Error ? f.message : '更新评论状态失败')
74 } finally {
75 setBusy(false)
76 }
77 }
78
79 return (
80 <div className="space-y-3">
81 {error ? <p className="text-[12.5px] text-[#b42318]" role="alert">{error}</p> : null}
82
83 {aiRows.length > 0 ? (
84 <section className="rounded-xl border border-shell-hairline bg-shell-surface">
85 <header className="flex items-center gap-2 border-b border-shell-hairline bg-shell-field px-4 py-2.5 text-[12.5px]">
86 <Sparkles className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden />
87 <span className="font-medium">AI 评审</span>
88 <Button className="ml-auto h-7 text-[12px]" disabled={busy} onClick={() => void rerunAi()}>
89 重新评审
90 </Button>
91 <LanguagePicker value={language} onChange={setLanguage} disabled={busy} />
92 <span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
93 aiRows[0].verdict === 'changes_requested'
94 ? 'text-[#b42318] bg-[rgba(242,63,67,0.10)]'
95 : 'text-shell-ink-muted bg-shell-active'
96 }`}>
97 {aiRows[0].verdict === 'changes_requested' ? '请求修改' : '仅评论'}
98 </span>
99 {aiRows[0].language ? <span className="text-[11.5px] text-shell-ink-muted">{aiRows[0].language}</span> : null}
100 {aiRows[0].truncated ? <span className="text-[11.5px] text-[#8a6100]">内容被截断</span> : null}
101 </header>
102 <div className="space-y-2 px-4 py-3">
103 <p className="whitespace-pre-wrap break-words text-[13px] leading-6">{aiRows[0].summary}</p>
104 {aiRows[0].findings.map((finding, index) => (
105 <div key={`${finding.path}:${finding.line}:${index}`} className="rounded-lg border border-shell-hairline bg-shell-field px-3 py-2">
106 <div className="mb-1 flex flex-wrap items-center gap-2 text-[12px]">
107 <span className={`rounded px-1.5 py-0.5 font-medium ${severityTone(finding.severity)}`}>{finding.severity || 'minor'}</span>
108 <span className="font-mono">{finding.path}:{finding.line}</span>
109 {finding.side === 'left' ? <span className="text-shell-ink-muted">旧行</span> : null}
110 </div>
111 <p className="whitespace-pre-wrap break-words text-[12.5px] leading-5">{finding.body}</p>
112 </div>
113 ))}
114 </div>
115 </section>
116 ) : null}
117
118 <section className="rounded-xl border border-shell-hairline bg-shell-surface">
119 <header className="flex items-center gap-2 border-b border-shell-hairline bg-shell-field px-4 py-2.5 text-[12.5px]">
120 <MessageSquareText className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden />
121 <span className="font-medium">行级评论</span>
122 <span className="text-shell-ink-muted">{rows.length} 条</span>
123 </header>
124 <div className="space-y-2 px-4 py-3">
125 {rows.length === 0 ? (
126 <PanelNote text="该提交还没有行级评论。在下方差异中把鼠标移到行号上即可评论。" />
127 ) : (
128 rows.map(row => (
129 <div key={row.id} className="rounded-lg border border-shell-hairline bg-shell-field px-3 py-2">
130 <div className="mb-1 flex flex-wrap items-center gap-2 text-[12px]">
131 <span className="font-mono">{row.path}:{row.line}</span>
132 <span className="text-shell-ink-muted">{row.author?.display_name || row.author?.username || '未知用户'}</span>
133 {row.resolved ? <span className="text-[#1a7f37]">已解决</span> : null}
134 {row.outdated ? <span className="text-[#8a6100]">已过期</span> : null}
135 <button
136 type="button"
137 disabled={busy}
138 onClick={() => void resolve(row.id)}
139 className="ml-auto text-shell-ink-muted hover:text-shell-ink"
140 >
141 {row.resolved ? '取消解决' : '标记解决'}
142 </button>
143 <button
144 type="button"
145 disabled={busy}
146 onClick={() => void remove(row.id)}
147 className="text-shell-ink-muted hover:text-[#b42318]"
148 aria-label="删除评论"
149 >
150 <Trash2 className="h-3.5 w-3.5" aria-hidden />
151 </button>
152 </div>
153 <p className="whitespace-pre-wrap break-words text-[12.5px] leading-5">{row.body}</p>
154 </div>
155 ))
156 )}
157 </div>
158 </section>
159 </div>
160 )
161}
162
163/** 行内评论输入框:由差异视图按行内嵌渲染。 */
164export function LineCommentComposer({
165 space,
166 repo,
167 path,
168 line,
169 commitSha,
170 onDone,
171 onCancel,
172}: {
173 space: string
174 repo: string
175 path: string
176 line: number
177 commitSha: string
178 onDone: () => void
179 onCancel: () => void
180}) {
181 const [body, setBody] = useState('')
182 const [busy, setBusy] = useState(false)
183 const [error, setError] = useState<string | null>(null)
184
185 async function submit() {
186 if (!body.trim()) return
187 setBusy(true)
188 setError(null)
189 try {
190 await createCodeComment(space, repo, {
191 path,
192 line,
193 commit_sha: commitSha,
194 body: body.trim(),
195 })
196 onDone()
197 } catch (f) {
198 setError(f instanceof Error ? f.message : '评论失败')
199 } finally {
200 setBusy(false)
201 }
202 }
203
204 return (
205 <div className="space-y-1.5 border-t border-shell-hairline bg-shell-field px-3 py-2">
206 <p className="font-mono text-[11.5px] text-shell-ink-muted">{path}:{line}</p>
207 <textarea
208 autoFocus
209 value={body}
210 onChange={event => setBody(event.target.value)}
211 rows={2}
212 placeholder="对该行发表评论…"
213 className={modalTextareaClass}
214 />
215 {error ? <p className="text-[12px] text-[#b42318]">{error}</p> : null}
216 <div className="flex items-center gap-2">
217 <Button className="h-7 text-[12px]" disabled={busy} onClick={() => void submit()}>发表评论</Button>
218 <Button variant="ghost" className="h-7 text-[12px]" onClick={onCancel}>取消</Button>
219 </div>
220 </div>
221 )
222}
223
Modified web/src/features/repo-history/ui/commit-view.tsx View web/src/features/repo-history/ui/commit-view.tsx
@@ -2,6 +2,7 @@
22import { ArrowLeft, ChevronDown, ChevronRight } from 'lucide-react'
33import { Button, CopyChip, ErrorPanel, PanelNote } from '@/shared/ui'
44import { fmtRelative, fmtTime, repoKey, settingsKey, shortSha, useAsync } from '@/shared/lib'
5import { CommitInsights, LineCommentComposer } from './commit-insights'
56import {
67 applyNumstat,
78 EMPTY_TREE,
@@ -194,6 +195,13 @@
194195 </div>
195196 </div>
196197
198 <CommitInsights
199 space={space}
200 repo={repo}
201 sha={data.commit.id}
202 onChanged={() => setRetry(r => r + 1)}
203 />
204
197205 {files.length === 0 ? <PanelNote text="该提交没有文件改动(可能是空提交或合并提交)。" /> : null}
198206 <div className="space-y-3">
199207 {files.map(file => {
@@ -225,7 +233,16 @@
225233 <span className="text-[#b42318]">−{file.deletions}</span>
226234 </span>
227235 </button>
228 {open ? <FileDiff file={file} style={style} /> : null}
236 {open ? (
237 <FileDiff
238 file={file}
239 style={style}
240 space={space}
241 repo={repo}
242 commitSha={data.commit.id}
243 onCommented={() => setRetry(r => r + 1)}
244 />
245 ) : null}
229246 </section>
230247 )
231248 })}
@@ -236,12 +253,30 @@
236253 )
237254}
238255
239function FileDiff({ file, style }: { file: DiffFile; style: DiffStyle }) {
256function FileDiff({
257 file,
258 style,
259 space,
260 repo,
261 commitSha,
262 onCommented,
263}: {
264 file: DiffFile
265 style: DiffStyle
266 space: string
267 repo: string
268 commitSha: string
269 onCommented: () => void
270}) {
271 // 当前展开的评论输入框锚点(同一时刻只开一个)。
272 const [draftLine, setDraftLine] = useState<number | null>(null)
240273 // Parsed only while the card is mounted, so a 500-file commit stays cheap.
241274 const { hunks, truncated } = useMemo(() => parseHunks(file.raw), [file.raw])
242275 if (file.status === 'binary') return <PanelNote text="二进制文件,不显示差异内容。" />
243276 if (hunks.length === 0) return <PanelNote text="仅权限或元数据变更。" />
244 if (style === 'split') return <SplitDiff hunks={hunks} truncated={truncated} />
277 if (style === 'split') {
278 return <SplitDiff hunks={hunks} truncated={truncated} onRequestComment={setDraftLine} />
279 }
245280 return (
246281 <div className="overflow-x-auto">
247282 <table className="w-full border-collapse font-mono text-[12px] leading-5">
@@ -253,9 +288,32 @@
253288 {hunk.header}
254289 </td>
255290 </tr>
256 {hunk.lines.map((line, index) => (
257 <DiffRow key={`${hunk.header}:${index}`} line={line} />
258 ))}
291 {hunk.lines.map((line, index) => {
292 const lineNo = line.kind === 'del' ? line.oldNo : line.newNo
293 return (
294 <Fragment key={`${hunk.header}:${index}`}>
295 <DiffRow line={line} onRequestComment={setDraftLine} />
296 {draftLine != null && lineNo === draftLine ? (
297 <tr>
298 <td colSpan={4} className="p-0">
299 <LineCommentComposer
300 space={space}
301 repo={repo}
302 path={file.path}
303 line={draftLine}
304 commitSha={commitSha}
305 onCancel={() => setDraftLine(null)}
306 onDone={() => {
307 setDraftLine(null)
308 onCommented()
309 }}
310 />
311 </td>
312 </tr>
313 ) : null}
314 </Fragment>
315 )
316 })}
259317 </Fragment>
260318 ))}
261319 </tbody>
@@ -269,15 +327,16 @@
269327 )
270328}
271329
272function DiffRow({ line }: { line: DiffLine }) {
330function DiffRow({ line, onRequestComment }: { line: DiffLine; onRequestComment?: (line: number) => void }) {
273331 const tone =
274332 line.kind === 'add'
275333 ? 'bg-[rgba(35,165,90,0.12)]'
276334 : line.kind === 'del'
277335 ? 'bg-[rgba(242,63,67,0.10)]'
278336 : ''
337 const lineNo = line.kind === 'del' ? line.oldNo : line.newNo
279338 return (
280 <tr className={tone}>
339 <tr className={`group/row ${tone}`}>
281340 <td className="w-10 shrink-0 select-none border-r border-shell-hairline px-2 text-right text-[11px] text-shell-ink-muted">
282341 {line.oldNo ?? ''}
283342 </td>
@@ -285,14 +344,31 @@
285344 {line.newNo ?? ''}
286345 </td>
287346 <td className="w-4 select-none px-1 text-center text-[11px] text-shell-ink-muted">
288 {line.kind === 'add' ? '+' : line.kind === 'del' ? '−' : ''}
347 {lineNo != null && onRequestComment ? (
348 <button
349 type="button"
350 title="对该行发表评论"
351 onClick={() => onRequestComment(lineNo)}
352 className="rounded bg-shell-active px-0.5 text-shell-ink-muted opacity-0 transition-opacity hover:text-shell-ink focus:opacity-100 group-hover/row:opacity-100"
353 >
354 +
355 </button>
356 ) : line.kind === 'add' ? '+' : line.kind === 'del' ? '−' : ''}
289357 </td>
290358 <td className="whitespace-pre-wrap break-all px-2 py-0.5">{line.text || ' '}</td>
291359 </tr>
292360 )
293361}
294362
295function SplitDiff({ hunks, truncated }: { hunks: { header: string; lines: DiffLine[] }[]; truncated: boolean }) {
363function SplitDiff({
364 hunks,
365 truncated,
366 onRequestComment,
367}: {
368 hunks: { header: string; lines: DiffLine[] }[]
369 truncated: boolean
370 onRequestComment?: (line: number) => void
371}) {
296372 const cell = (line: DiffLine | null, side: 'old' | 'new') => {
297373 const tone =
298374 line?.kind === 'add'
@@ -300,11 +376,22 @@
300376 : line?.kind === 'del'
301377 ? 'bg-[rgba(242,63,67,0.10)]'
302378 : ''
379 const lineNo = line ? (side === 'old' ? line.oldNo : line.newNo) : null
303380 return (
304 <td className={`w-1/2 border-r border-shell-hairline px-2 py-0.5 align-top last:border-r-0 ${tone}`}>
381 <td className={`group/row w-1/2 border-r border-shell-hairline px-2 py-0.5 align-top last:border-r-0 ${tone}`}>
305382 <span className="inline-block w-9 select-none text-right text-[11px] text-shell-ink-muted">
306 {line ? (side === 'old' ? line.oldNo : line.newNo) ?? '' : ''}
383 {lineNo ?? ''}
307384 </span>
385 {lineNo != null && onRequestComment ? (
386 <button
387 type="button"
388 title="对该行发表评论"
389 onClick={() => onRequestComment(lineNo)}
390 className="ml-1 inline-block rounded bg-shell-active px-0.5 text-[11px] text-shell-ink-muted opacity-0 transition-opacity hover:text-shell-ink focus:opacity-100 group-hover/row:opacity-100"
391 >
392 +
393 </button>
394 ) : null}
308395 <span className="ml-1 inline-block w-3 select-none text-center text-[11px] text-shell-ink-muted">
309396 {line ? (line.kind === 'add' ? '+' : line.kind === 'del' ? '−' : '') : ''}
310397 </span>
Added web/src/features/repo-releases/ui/releases-tab.tsx View web/src/features/repo-releases/ui/releases-tab.tsx
@@ -0,0 +1,274 @@
1import { useState } from 'react'
2import { Loader2, Pencil, Plus, Sparkles, Tag, Trash2 } from 'lucide-react'
3import { Alert, Button, ErrorPanel, Modal, PanelNote, modalInputClass, modalLabelClass, modalTextareaClass } from '@/shared/ui'
4import { fmtRelative, useAsync } from '@/shared/lib'
5import {
6 createRelease,
7 deleteRelease,
8 getRelease,
9 listReleases,
10 redraftRelease,
11 updateRelease,
12 type Release,
13} from '@/entities/repo'
14
15export interface ReleasesTabProps {
16 space: string
17 repo: string
18 /** Opened release tag ('' = list). Owned by the route. */
19 selected: string
20 onSelect: (tag: string) => void
21 /** Whether the viewer may write (draft/publish/edit/delete). */
22 canWrite: boolean
23}
24
25/** 发布页签:草稿与已发布 release 的列表 / 详情 / AI 起草。 */
26export function ReleasesTab({ space, repo, selected, onSelect, canWrite }: ReleasesTabProps) {
27 const [tick, setTick] = useState(0)
28 const [busy, setBusy] = useState(false)
29 const [error, setError] = useState<string | null>(null)
30 const [notice, setNotice] = useState<string | null>(null)
31 const [creating, setCreating] = useState(false)
32
33 const list = useAsync(() => listReleases(space, repo, { limit: 50 }), [space, repo, tick])
34 const detail = useAsync(
35 () => (selected ? getRelease(space, repo, selected) : Promise.resolve(null)),
36 [space, repo, selected, tick],
37 )
38 const failure = (list.error ?? detail.error) as { code?: number; message?: string } | undefined
39
40 async function run<T>(fn: () => Promise<T>, ok: string) {
41 setBusy(true)
42 setError(null)
43 setNotice(null)
44 try {
45 await fn()
46 setNotice(ok)
47 setTick(t => t + 1)
48 } catch (f) {
49 setError(f instanceof Error ? f.message : '操作失败')
50 } finally {
51 setBusy(false)
52 }
53 }
54
55 if (list.loading && !list.data) return <PanelNote text="加载发布记录…" />
56 if (failure) return <ErrorPanel status={failure.code ?? 0} what="发布记录" note={failure.message} onRetry={() => setTick(t => t + 1)} />
57
58 return (
59 <div className="space-y-3">
60 <div className="flex flex-wrap items-center gap-2">
61 {canWrite ? (
62 <Button className="h-8 text-[12.5px]" disabled={busy} onClick={() => setCreating(true)}>
63 <Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
64 新建发布
65 </Button>
66 ) : null}
67 {selected ? (
68 <Button variant="secondary" className="h-8 text-[12.5px]" onClick={() => onSelect('')}>
69 返回列表
70 </Button>
71 ) : null}
72 </div>
73
74 {notice ? <p className="rounded-lg border border-shell-hairline bg-shell-field px-3 py-2 text-[12.5px] text-shell-ink-muted">{notice}</p> : null}
75 {error ? <p className="text-[12.5px] text-[#b42318]" role="alert">{error}</p> : null}
76
77 {selected ? (
78 detail.data ? (
79 <ReleaseDetail
80 release={detail.data}
81 canWrite={canWrite}
82 busy={busy}
83 onDraft={() => void run(() => redraftRelease(space, repo, selected), '已提交 AI 起草任务;完成后刷新查看。')}
84 onSave={(title, content) =>
85 void run(() => updateRelease(space, repo, selected, { title, content }), '已保存草稿。')
86 }
87 onPublish={() =>
88 void run(() => updateRelease(space, repo, selected, { is_draft: false }), '已发布。')
89 }
90 onTogglePrerelease={next =>
91 void run(() => updateRelease(space, repo, selected, { is_prerelease: next }), '已更新预发布标记。')
92 }
93 onDelete={() => void run(async () => {
94 await deleteRelease(space, repo, selected)
95 onSelect('')
96 }, '已删除发布记录。')}
97 />
98 ) : (
99 <PanelNote text="发布记录不存在。" />
100 )
101 ) : (
102 <ReleaseList releases={list.data?.items ?? []} onSelect={onSelect} />
103 )}
104
105 {creating ? (
106 <CreateReleaseModal
107 busy={busy}
108 onClose={() => setCreating(false)}
109 onSubmit={(tag, title, draft) =>
110 void run(async () => {
111 await createRelease(space, repo, { tag_name: tag, title, is_draft: draft })
112 setCreating(false)
113 onSelect(tag)
114 }, '已创建发布记录。')
115 }
116 />
117 ) : null}
118 </div>
119 )
120}
121
122function ReleaseList({ releases, onSelect }: { releases: Release[]; onSelect: (tag: string) => void }) {
123 if (!releases.length) {
124 return <PanelNote text="还没有发布记录。推送标签会自动创建草稿,也可以手动新建。" />
125 }
126 return (
127 <ul className="space-y-2">
128 {releases.map(release => (
129 <li key={release.id}>
130 <button
131 type="button"
132 onClick={() => onSelect(release.tag_name)}
133 className="flex w-full flex-wrap items-center gap-2 rounded-xl border border-shell-hairline bg-shell-surface px-4 py-3 text-left transition-colors hover:border-shell-tile"
134 >
135 <Tag className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
136 <span className="font-medium">{release.title || release.tag_name}</span>
137 <span className="font-mono text-[12px] text-shell-ink-muted">{release.tag_name}</span>
138 {release.is_draft ? <Badge tone="amber">草稿</Badge> : <Badge tone="green">已发布</Badge>}
139 {release.is_prerelease ? <Badge tone="violet">预发布</Badge> : null}
140 <span className="ml-auto text-[11.5px] text-shell-ink-muted">
141 {release.num_commits} 个提交 · {fmtRelative(release.created_at)}
142 </span>
143 </button>
144 </li>
145 ))}
146 </ul>
147 )
148}
149
150function ReleaseDetail({
151 release,
152 canWrite,
153 busy,
154 onDraft,
155 onSave,
156 onPublish,
157 onTogglePrerelease,
158 onDelete,
159}: {
160 release: Release
161 canWrite: boolean
162 busy: boolean
163 onDraft: () => void
164 onSave: (title: string, content: string) => void
165 onPublish: () => void
166 onTogglePrerelease: (next: boolean) => void
167 onDelete: () => void
168}) {
169 const [title, setTitle] = useState(release.title)
170 const [content, setContent] = useState(release.content ?? '')
171 const editable = canWrite && release.is_draft
172
173 return (
174 <section className="space-y-3 rounded-xl border border-shell-hairline bg-shell-surface p-4">
175 <header className="flex flex-wrap items-center gap-2">
176 <Tag className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
177 <span className="font-mono text-[13px]">{release.tag_name}</span>
178 {release.is_draft ? <Badge tone="amber">草稿</Badge> : <Badge tone="green">已发布</Badge>}
179 {release.is_prerelease ? <Badge tone="violet">预发布</Badge> : null}
180 <span className="text-[11.5px] text-shell-ink-muted">
181 {release.publisher_name || release.publisher?.display_name || '系统'} · {fmtRelative(release.updated_at)}
182 </span>
183 </header>
184
185 {editable ? (
186 <>
187 <label className={modalLabelClass}>
188 标题
189 <input value={title} onChange={event => setTitle(event.target.value)} className={modalInputClass} />
190 </label>
191 <label className={modalLabelClass}>
192 正文
193 <textarea value={content} onChange={event => setContent(event.target.value)} rows={12} className={modalTextareaClass} />
194 </label>
195 <div className="flex flex-wrap items-center gap-2">
196 <Button className="h-8 text-[12.5px]" disabled={busy} onClick={() => onSave(title, content)}>保存草稿</Button>
197 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={onDraft}>
198 <Sparkles className="mr-1.5 h-3.5 w-3.5" aria-hidden />
199 AI 重新起草
200 </Button>
201 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={onPublish}>发布</Button>
202 <Button variant="ghost" className="h-8 text-[12px]" disabled={busy} onClick={() => onTogglePrerelease(!release.is_prerelease)}>
203 {release.is_prerelease ? '取消预发布' : '标记预发布'}
204 </Button>
205 <Button variant="ghost" className="ml-auto h-8 text-[12px] text-[#b42318]" disabled={busy} onClick={onDelete}>
206 <Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden />
207 删除
208 </Button>
209 </div>
210 </>
211 ) : (
212 <>
213 <h3 className="text-[15px] font-semibold">{release.title || release.tag_name}</h3>
214 <p className="whitespace-pre-wrap break-words text-[13px] leading-6">
215 {release.content || '(没有正文)'}
216 </p>
217 {canWrite ? (
218 <p className="text-[12px] text-shell-ink-muted">已发布的发布记录正文不可再修改。</p>
219 ) : null}
220 </>
221 )}
222 </section>
223 )
224}
225
226function CreateReleaseModal({
227 busy,
228 onClose,
229 onSubmit,
230}: {
231 busy: boolean
232 onClose: () => void
233 onSubmit: (tag: string, title: string, draft: boolean) => void
234}) {
235 const [tag, setTag] = useState('')
236 const [title, setTitle] = useState('')
237 const [draft, setDraft] = useState(true)
238 return (
239 <Modal open title="新建发布记录" onClose={onClose}>
240 <div className="space-y-3">
241 <Alert tone="info">标签必须已存在于仓库中;缺省会以该标签指向的提交为目标。</Alert>
242 <label className={modalLabelClass}>
243 标签
244 <input value={tag} onChange={event => setTag(event.target.value)} placeholder="v1.0.0" className={modalInputClass} />
245 </label>
246 <label className={modalLabelClass}>
247 标题(可选)
248 <input value={title} onChange={event => setTitle(event.target.value)} className={modalInputClass} />
249 </label>
250 <label className="flex items-center gap-2 text-[13px]">
251 <input type="checkbox" checked={draft} onChange={event => setDraft(event.target.checked)} />
252 先保存为草稿
253 </label>
254 <div className="flex justify-end gap-2">
255 <Button variant="secondary" className="h-8 text-[12.5px]" onClick={onClose}>取消</Button>
256 <Button className="h-8 text-[12.5px]" disabled={busy || !tag.trim()} onClick={() => onSubmit(tag.trim(), title.trim() || tag.trim(), draft)}>
257 {busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : <Pencil className="mr-1.5 h-3.5 w-3.5" aria-hidden />}
258 创建
259 </Button>
260 </div>
261 </div>
262 </Modal>
263 )
264}
265
266const BADGE_TONE = {
267 green: 'text-[#1a7f37] bg-[rgba(35,165,90,0.12)]',
268 amber: 'text-[#8a6100] bg-[rgba(240,178,50,0.16)]',
269 violet: 'text-[#6937c3] bg-[rgba(120,82,196,0.12)]',
270} as const
271
272function Badge({ tone, children }: { tone: keyof typeof BADGE_TONE; children: React.ReactNode }) {
273 return <span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${BADGE_TONE[tone]}`}>{children}</span>
274}
No newline at end of file
Added web/src/features/repo-releases/index.ts View web/src/features/repo-releases/index.ts
@@ -0,0 +1 @@
1export { ReleasesTab, type ReleasesTabProps } from './ui/releases-tab'
Modified web/src/pages/workspace/model/repo-location.ts View web/src/pages/workspace/model/repo-location.ts
@@ -55,6 +55,8 @@
5555 ptab: PullSubTab
5656 /** pipelines: opened run id ('' = run list). */
5757 run: string
58 /** releases: opened release tag ('' = list). */
59 release: string
5860 /** settings: active second-level section. */
5961 section: RepoSettingsSection
6062}
@@ -72,6 +74,7 @@
7274 pull: '',
7375 ptab: 'conversation',
7476 run: '',
77 release: '',
7578 section: 'general',
7679}
7780
@@ -82,11 +85,13 @@
8285 'pull',
8386 'branches',
8487 'pipelines',
88 'releases',
89 'ai',
8590 'settings',
8691]
8792const SECTION_VALUES = REPO_SETTINGS_SECTIONS.map(section => section.id)
8893const DIFF_VALUES: readonly DiffStyle[] = ['unified', 'split']
89const PTAB_VALUES: readonly PullSubTab[] = ['conversation', 'files', 'commits', 'checks']
94const PTAB_VALUES: readonly PullSubTab[] = ['conversation', 'files', 'commits', 'checks', 'ai']
9095
9196/** Query keys owned by the repo page — the only ones this hook may rewrite. */
9297const OWNED_KEYS = [
@@ -103,6 +108,7 @@
103108 'ptab',
104109 'section',
105110 'run',
111 'release',
106112]
107113
108114/**
@@ -118,6 +124,8 @@
118124 pull: ['tab', 'rev', 'pull', 'ptab'],
119125 branches: ['tab', 'rev'],
120126 pipelines: ['tab', 'rev', 'run'],
127 releases: ['tab', 'rev', 'release'],
128 ai: ['tab', 'rev'],
121129 settings: ['tab', 'rev', 'section'],
122130}
123131
@@ -141,6 +149,7 @@
141149 pull: params.get('pull') ?? '',
142150 ptab: pick(params.get('ptab'), PTAB_VALUES, 'conversation'),
143151 run: params.get('run') ?? '',
152 release: params.get('release') ?? '',
144153 section: pick(params.get('section'), SECTION_VALUES, 'general'),
145154 }
146155}
@@ -180,6 +189,7 @@
180189 if (state.pull) next.set('pull', state.pull)
181190 if (state.ptab !== 'conversation') next.set('ptab', state.ptab)
182191 if (state.run) next.set('run', state.run)
192 if (state.release) next.set('release', state.release)
183193 if (state.section !== 'general') next.set('section', state.section)
184194 return next
185195}
Modified web/src/pages/workspace/model/route.ts View web/src/pages/workspace/model/route.ts
@@ -9,6 +9,8 @@
99 | 'pull'
1010 | 'branches'
1111 | 'pipelines'
12 | 'releases'
13 | 'ai'
1214 | 'settings'
1315
1416/** Dynamic segments of `/app/:spaceName/{chat|board|repo}/:id?`. */
Modified web/src/pages/workspace/ui/layout/repo-layout.tsx View web/src/pages/workspace/ui/layout/repo-layout.tsx
@@ -106,6 +106,9 @@
106106 ['commits', '提交'],
107107 ['pulls', '合并请求'],
108108 ['branches', '分支'],
109 ['pipelines', '流水线'],
110 ['releases', '发布'],
111 ['ai', 'AI 总结'],
109112 ['settings', '设置'],
110113]
111114
Modified web/src/pages/workspace/ui/repo/repo-view.tsx View web/src/pages/workspace/ui/repo/repo-view.tsx
@@ -16,6 +16,8 @@
1616import { PullsTab, PullDetail } from '@/features/pull-request'
1717import { SettingsTab } from '@/features/repo-settings'
1818import { PipelinesView } from '@/features/repo-pipelines'
19import { AiSummaryTab } from '@/features/repo-ai'
20import { ReleasesTab } from '@/features/repo-releases'
1921import { RefPicker } from './ref-picker'
2022import { RepoAside } from './repo-aside'
2123import './repo-polish.css'
@@ -65,7 +67,7 @@
6567 browseRev,
6668 normalizeForRepo,
6769 } = useRepoViewLocation()
68 const { tab, rev, path, file: filePath, oid, line, commit, firstParent, diff, pull: pullRef, ptab, run, section } = view
70 const { tab, rev, path, file: filePath, oid, line, commit, firstParent, diff, pull: pullRef, ptab, run, release, section } = view
6971 const file = filePath ? { path: filePath, oid: oid || undefined } : null
7072 const [mode, setMode] = useState<RenderMode>('html')
7173 // Clone addresses come from the backend — never guessed from window.location.
@@ -270,6 +272,17 @@
270272 onSelectRun={runId => patch({ run: runId })}
271273 />
272274 ) : null}
275 {tab === 'ai' ? <AiSummaryTab space={spaceName} repo={repoName} rev={rev} /> : null}
276 {tab === 'releases' ? (
277 <ReleasesTab
278 space={spaceName}
279 repo={repoName}
280 selected={release}
281 onSelect={tag => patch({ release: tag })}
282 // 仓库 DTO 不含权限位:始终展示写入口,由服务端鉴权并提示 403。
283 canWrite={true}
284 />
285 ) : null}
273286 {tab === 'settings' ? (
274287 <SettingsTab
275288 space={spaceName}
Modified web/src/shared/api/apiDefinitions.ts View web/src/shared/api/apiDefinitions.ts
@@ -100,6 +100,7 @@
100100 * **Do not edit the file manually.**
101101 */
102102export default {
103 'ai.list_ai_languages': ['GET', '/ai/languages'],
103104 'auth.totp_disable': ['POST', '/auth/2fa/disable'],
104105 'auth.totp_enable': ['POST', '/auth/2fa/enable'],
105106 'auth.recovery_codes': ['GET', '/auth/2fa/recovery-codes'],
@@ -352,6 +353,9 @@
352353 'repos.create_repo': ['POST', '/repos/{space_name}'],
353354 'repos.get_repo': ['GET', '/repos/{space_name}/{repo_name}'],
354355 'repos.update_repo': ['PATCH', '/repos/{space_name}/{repo_name}'],
356 'ai.trigger_commit_ai_review': ['POST', '/repos/{space_name}/{repo_name}/ai-review'],
357 'ai.get_ai_summary': ['GET', '/repos/{space_name}/{repo_name}/ai-summary'],
358 'ai.trigger_ai_summary': ['POST', '/repos/{space_name}/{repo_name}/ai-summary'],
355359 'analysis.code_activity': ['GET', '/repos/{space_name}/{repo_name}/analysis/code-activity'],
356360 'analysis.contributors': ['GET', '/repos/{space_name}/{repo_name}/analysis/contributors'],
357361 'analysis.languages_read': ['GET', '/repos/{space_name}/{repo_name}/analysis/languages'],
@@ -386,12 +390,22 @@
386390 'branches.delete_branch': ['DELETE', '/repos/{space_name}/{repo_name}/branches/{name}'],
387391 'branches.rename_branch': ['PATCH', '/repos/{space_name}/{repo_name}/branches/{name}'],
388392 'branches.set_default_branch': ['PUT', '/repos/{space_name}/{repo_name}/branches/{name}/default'],
393 'codeComments.list_code_comments': ['GET', '/repos/{space_name}/{repo_name}/code-comments'],
394 'codeComments.create_code_comment': ['POST', '/repos/{space_name}/{repo_name}/code-comments'],
395 'codeComments.delete_code_comment': ['DELETE', '/repos/{space_name}/{repo_name}/code-comments/{comment_id}'],
396 'codeComments.update_code_comment': ['PATCH', '/repos/{space_name}/{repo_name}/code-comments/{comment_id}'],
397 'codeComments.resolve_code_comment': ['PUT', '/repos/{space_name}/{repo_name}/code-comments/{comment_id}/resolve'],
398 'codeComments.unresolve_code_comment': [
399 'DELETE',
400 '/repos/{space_name}/{repo_name}/code-comments/{comment_id}/resolve'
401 ],
389402 'commits.set_commit_status': ['POST', '/repos/{space_name}/{repo_name}/commit-statuses'],
390403 'commits.list_commit_statuses': ['GET', '/repos/{space_name}/{repo_name}/commit-statuses/{sha}'],
391404 'commits.list_commits': ['GET', '/repos/{space_name}/{repo_name}/commits'],
392405 'commits.is_ancestor': ['GET', '/repos/{space_name}/{repo_name}/commits/ancestor'],
393406 'commits.commit_stats': ['GET', '/repos/{space_name}/{repo_name}/commits/stats'],
394407 'commits.get_commit': ['GET', '/repos/{space_name}/{repo_name}/commits/{revision}'],
408 'ai.list_commit_ai_reviews': ['GET', '/repos/{space_name}/{repo_name}/commits/{revision}/ai-reviews'],
395409 'conflicts.get_conflicts': ['GET', '/repos/{space_name}/{repo_name}/conflicts'],
396410 'conflicts.conflict_detail': ['GET', '/repos/{space_name}/{repo_name}/conflicts/detail'],
397411 'diffs.get_diff': ['GET', '/repos/{space_name}/{repo_name}/diff'],
@@ -421,6 +435,9 @@
421435 'pulls.get_pull': ['GET', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}'],
422436 'pulls.delete_pull': ['DELETE', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}'],
423437 'pulls.update_pull': ['PATCH', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}'],
438 'ai.trigger_ai_review': ['POST', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}/ai-review'],
439 'ai.list_ai_reviews': ['GET', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}/ai-reviews'],
440 'ai.get_ai_review': ['GET', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}/ai-reviews/{review_id}'],
424441 'pulls.list_pull_assignees': ['GET', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}/assignees'],
425442 'pulls.add_pull_assignee': ['POST', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}/assignees/{user_ref}'],
426443 'pulls.remove_pull_assignee': ['DELETE', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}/assignees/{user_ref}'],
@@ -464,6 +481,12 @@
464481 'reviews.publish_review': ['POST', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}/reviews/{review_id}/submit'],
465482 'pulls.refresh_pull_summary': ['POST', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}/summary'],
466483 'pulls.update_branch_pull': ['POST', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}/update-branch'],
484 'releases.list_releases': ['GET', '/repos/{space_name}/{repo_name}/releases'],
485 'releases.create_release': ['POST', '/repos/{space_name}/{repo_name}/releases'],
486 'releases.get_release': ['GET', '/repos/{space_name}/{repo_name}/releases/{tag}'],
487 'releases.delete_release': ['DELETE', '/repos/{space_name}/{repo_name}/releases/{tag}'],
488 'releases.update_release': ['PATCH', '/repos/{space_name}/{repo_name}/releases/{tag}'],
489 'releases.redraft_release': ['POST', '/repos/{space_name}/{repo_name}/releases/{tag}/ai-draft'],
467490 'remotes.list_remotes': ['GET', '/repos/{space_name}/{repo_name}/remotes'],
468491 'remotes.fetch': ['POST', '/repos/{space_name}/{repo_name}/remotes/fetch'],
469492 'remotes.clone_import': ['POST', '/repos/{space_name}/{repo_name}/remotes/import'],
Modified web/src/shared/api/globals.d.ts View web/src/shared/api/globals.d.ts

Diff too large to display.

Modified web/openapi.json View web/openapi.json

Diff too large to display.

Modified web/steiger.config.js View web/steiger.config.js
@@ -20,6 +20,8 @@
2020 'features/repo-history',
2121 'features/repo-pipelines',
2222 'features/repo-settings',
23 'features/repo-ai',
24 'features/repo-releases',
2325 'features/search',
2426 'features/space-management',
2527 'features/user-settings',
Modified .gitignore View .gitignore
@@ -9,3 +9,4 @@
99web/node_modules
1010admin/dist
1111admin/node_modules
12.codegraph
Modified Cargo.lock View Cargo.lock
@@ -132,6 +132,29 @@
132132]
133133
134134[[package]]
135name = "agent"
136version = "1.0.0"
137dependencies = [
138 "ci-protocol",
139 "config",
140 "export",
141 "futures",
142 "git",
143 "models",
144 "redis",
145 "reqwest 0.12.28",
146 "sea-orm",
147 "serde",
148 "serde_json",
149 "thiserror 2.0.20",
150 "time 0.3.55",
151 "tokio",
152 "tracing",
153 "utoipa",
154 "uuid",
155]
156
157[[package]]
135158name = "ahash"
136159version = "0.7.8"
137160source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -166,6 +189,25 @@
166189]
167190
168191[[package]]
192name = "aihost"
193version = "1.0.0"
194dependencies = [
195 "agent",
196 "anyhow",
197 "axum",
198 "config",
199 "export",
200 "git",
201 "models",
202 "sea-orm",
203 "time 0.3.55",
204 "tokio",
205 "tokio-util",
206 "tracing",
207 "uuid",
208]
209
210[[package]]
169211name = "aliasable"
170212version = "0.1.3"
171213source = "registry+https://github.com/rust-lang/crates.io-index"
Modified Cargo.toml View Cargo.toml
@@ -16,6 +16,8 @@
1616
1717[workspace.dependencies]
1818admin = { path = "lib/admin" }
19agent = { path = "lib/agent" }
20aihost = { path = "app/aihost" }
1921admin-web = { path = "admin" }
2022auth = { path = "lib/auth" }
2123cache = { path = "lib/cache" }
Modified Dockerfile View Dockerfile
@@ -29,7 +29,7 @@
2929 --mount=type=cache,target=/build/target \
3030 cargo build --release --workspace --bins && \
3131 mkdir -p /out && \
32 for b in apihost githost synchost mails migrate openapi im adminhost webhook runner; do \
32 for b in apihost githost synchost mails migrate openapi im adminhost webhook runner aihost; do \
3333 cp "target/release/$b" /out/; \
3434 done
3535
@@ -57,12 +57,12 @@
5757WORKDIR /app
5858
5959COPY --chown=gitdata:gitdata config/default.toml config/default.toml
60COPY --from=builder /out/apihost /out/githost /out/synchost /out/mails /out/migrate /out/openapi /out/im /out/adminhost /out/webhook /out/runner /usr/local/bin/
60COPY --from=builder /out/apihost /out/githost /out/synchost /out/mails /out/migrate /out/openapi /out/im /out/adminhost /out/webhook /out/runner /out/aihost /usr/local/bin/
6161COPY --from=builder /out/docker /usr/local/bin/docker
6262
6363USER gitdata
6464
65EXPOSE 3000 9418 22 3001 3002 3003 3004 3005
65EXPOSE 3000 9418 22 3001 3002 3003 3004 3005 3006
6666
6767CMD ["apihost"]
6868
Modified openapi.json View openapi.json

Diff too large to display.