Skip to content
Clone

refactor: remove AI and agent features

- delete aihost/mcp binaries and the agent, agents, mcp-api, sandbox-rpc crates
- drop AI review/summary/fill/release-draft endpoints and jobs from git, pulls, webapi
- move code comments to models::repo and fall back to plain space-owner authz
- remove AI/agent routes, tabs, settings and livekit agent session code from web
- regenerate openapi.json and the web API client
ZhenYi committed
parent 194c44c commit 69f12aa Browse files

263 files changed

+152 −17434

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

File tree 263
Deleted app/aihost/src/main.rs
@@ -1,231 +0,0 @@
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::Instrument as _;
11use tracing::{info, warn};
12
13#[tokio::main]
14async fn main() -> anyhow::Result<()> {
15 export::telemetry::init_tracing();
16
17 let cfg = AiHostConfig::load().context("loading configuration")?;
18 if !cfg.aihost.enabled() {
19 info!("aihost is disabled by config; exiting");
20 return Ok(());
21 }
22 if !cfg.ai.enabled {
23 info!("ai is disabled by config; aihost exits without a model endpoint");
24 return Ok(());
25 }
26
27 info!("connecting to database / redis / storage");
28 let state = export::AppState::with_config(cfg.clone().into_app_config())
29 .await
30 .context("building AppState")?;
31 if let Err(err) = export::telemetry::init_meter_provider() {
32 warn!(error = %err, "failed to init meter provider; metrics disabled");
33 }
34 export::metrics::spawn_queue_depth_reporter(
35 state.redis.clone(),
36 &[
37 ("ai.jobs", cfg.ai.queue_topic.clone()),
38 ("ai.jobs.dlq", cfg.ai.dlq_topic.clone()),
39 ],
40 );
41 let service = AgentService::new(state).context("building ai service")?;
42 info!(model = service.client().model(), "dependencies ready");
43
44 let cancel = CancellationToken::new();
45
46 requeue_stale_jobs(&service).await;
47
48 let mut workers = Vec::with_capacity(cfg.aihost.concurrency);
49 for slot in 0..cfg.aihost.concurrency.max(1) {
50 workers.push(tokio::spawn(consume(
51 service.clone(),
52 cfg.ai.queue_topic.clone(),
53 cfg.ai.dlq_topic.clone(),
54 cfg.aihost.retry_backoff_base_secs,
55 slot,
56 cancel.clone(),
57 )));
58 }
59
60 let health = tokio::spawn(run_health(
61 cfg.health.bind_addr().to_string(),
62 cfg.health.aihost_port(),
63 service.state.database.clone(),
64 service.state.redis.clone(),
65 cancel.clone(),
66 ));
67
68 export::telemetry::shutdown_signal().await;
69 info!("aihost shutting down");
70 cancel.cancel();
71 for worker in workers {
72 let _ = worker.await;
73 }
74 let _ = health.await;
75 info!("aihost shut down");
76 Ok(())
77}
78
79/// 消费 AI 队列;失败按退避重投,超过 `max_attempts` 进 DLQ。
80async fn consume(
81 service: AgentService,
82 topic: String,
83 dlq: String,
84 backoff_base_secs: u64,
85 slot: usize,
86 cancel: CancellationToken,
87) {
88 let queue = match SyncQueue::with_consumer_conns(service.state.redis.clone(), slot + 1).await {
89 Ok(queue) => queue,
90 Err(error) => {
91 warn!(%error, slot, "failed to open ai queue consumer connection");
92 return;
93 }
94 };
95 let mut attempts: std::collections::HashMap<uuid::Uuid, i32> = std::collections::HashMap::new();
96 let max_attempts = service.config().ai.max_attempts.max(1);
97 loop {
98 let payload = tokio::select! {
99 _ = cancel.cancelled() => break,
100 res = queue.consume(&topic, 5, slot) => match res {
101 Ok(Some(payload)) => payload,
102 Ok(None) => continue,
103 Err(error) => {
104 warn!(%error, slot, "ai queue consume failed");
105 tokio::time::sleep(Duration::from_secs(2)).await;
106 continue;
107 }
108 },
109 };
110 let task = match AgentTask::decode(&payload) {
111 Ok(task) => task,
112 Err(error) => {
113 warn!(%error, "dropping malformed ai task");
114 let _ = queue.requeue(&dlq, &payload).await;
115 continue;
116 }
117 };
118 let job_id = task.job_id();
119 let span = tracing::info_span!(
120 "ai.task",
121 job_id = %job_id,
122 kind = task.kind_str(),
123 request_id = tracing::field::Empty
124 );
125 if let Some(request_id) = task.request_id() {
126 span.record("request_id", request_id);
127 }
128 export::telemetry::set_parent_from_traceparent(&span, task.trace());
129 let started = std::time::Instant::now();
130 match export::middleware::with_request_id(
131 task.request_id(),
132 service.run_task(&task).instrument(span),
133 )
134 .await
135 {
136 Ok(()) => {
137 attempts.remove(&job_id);
138 export::metrics::ai_job(task.kind_str(), "ok");
139 export::metrics::ai_job_duration(task.kind_str(), started.elapsed());
140 info!(%job_id, kind = task.kind_str(), "ai task completed");
141 }
142 Err(error) => {
143 if !error.is_retryable() {
144 attempts.remove(&job_id);
145 export::metrics::ai_job(task.kind_str(), "failed");
146 warn!(%error, %job_id, "ai task is not retryable; sending to DLQ");
147 service.mark_failed(job_id, &error.to_string()).await;
148 let _ = queue.requeue(&dlq, &payload).await;
149 continue;
150 }
151 let count = attempts.entry(job_id).or_insert(0);
152 *count += 1;
153 if *count >= max_attempts {
154 attempts.remove(&job_id);
155 export::metrics::ai_job(task.kind_str(), "failed");
156 warn!(%error, %job_id, "ai task failed permanently; sending to DLQ");
157 service.mark_failed(job_id, &error.to_string()).await;
158 let _ = queue.requeue(&dlq, &payload).await;
159 } else {
160 warn!(%error, %job_id, attempt = *count, "ai task failed; retrying");
161 let backoff = backoff_base_secs.max(1) * u64::from(*count as u32);
162 tokio::time::sleep(Duration::from_secs(backoff)).await;
163 let _ = queue.requeue(&topic, &payload).await;
164 }
165 }
166 }
167 }
168 info!(slot, "ai consumer stopped");
169}
170
171/// 启动时收敛上次进程留下的 running 作业。
172///
173/// 队列是 Redis 列表且没有投递确认:进程在 `BRPOP` 之后崩溃,载荷已经离开
174/// 队列,仅把数据库状态改回 pending 不会有任何消费者再取到它。这里用提交时
175/// 持久化的载荷重新投递,投不出去的保持 pending,等待管理端手动重试。
176async fn requeue_stale_jobs(service: &AgentService) {
177 use models::ai::ai_job::{self, AiJobStatus};
178 use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
179 let stale = ai_job::Entity::find()
180 .filter(ai_job::Column::Status.eq(AiJobStatus::Running))
181 .all(service.db_write())
182 .await;
183 let rows = match stale {
184 Ok(rows) => rows,
185 Err(error) => {
186 warn!(%error, "stale ai job scan failed");
187 return;
188 }
189 };
190 for row in rows {
191 match git::sync::retry_ai_job(
192 service.db_write(),
193 &service.state.redis,
194 &service.config().ai,
195 &row,
196 )
197 .await
198 {
199 Ok(()) => info!(job_id = %row.id, "stale ai job requeued"),
200 Err(error) => warn!(%error, job_id = %row.id, "stale ai job requeue failed"),
201 }
202 }
203}
204
205async fn run_health(
206 bind: String,
207 port: u16,
208 database: std::sync::Arc<export::AppDatabase>,
209 redis: export::AppRedis,
210 cancel: CancellationToken,
211) {
212 let addr: SocketAddr = match format!("{bind}:{port}").parse() {
213 Ok(addr) => addr,
214 Err(error) => {
215 warn!(%error, "invalid aihost health address");
216 return;
217 }
218 };
219 let listener = match TcpListener::bind(addr).await {
220 Ok(listener) => listener,
221 Err(error) => {
222 warn!(%error, %addr, "aihost health bind failed");
223 return;
224 }
225 };
226 info!(%addr, "aihost health listening");
227 let router = export::health::router_with(Some(database), redis, None);
228 let _ = axum::serve(listener, router)
229 .with_graceful_shutdown(async move { cancel.cancelled().await })
230 .await;
231}
Deleted app/aihost/Cargo.toml
@@ -1,23 +0,0 @@
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
@@ -32,12 +32,10 @@
3232 .with_kv(Some(Arc::new(state.redis.clone())))
3333 .with_queue(Some(sync_queue))
3434 .with_sync_settings(cfg.git.sync.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()));
35 .with_ci_event_topic(cfg.ci.enabled.then(|| cfg.ci.event_queue_topic.clone()));
3736 let ssh_svc = git::ssh::service::GitSshService::new(smart_svc.clone(), cfg.git.ssh.clone())
3837 .with_sync_settings(cfg.git.sync.clone())
39 .with_ci_event_topic(cfg.ci.enabled.then(|| cfg.ci.event_queue_topic.clone()))
40 .with_ai_config(cfg.ai.enabled.then(|| cfg.ai.clone()));
38 .with_ci_event_topic(cfg.ci.enabled.then(|| cfg.ci.event_queue_topic.clone()));
4139 let smart_router =
4240 git::smart::handler::router(smart_svc).merge(export::health::router(state.clone()));
4341
Deleted app/mcp/src/main.rs
@@ -1,74 +0,0 @@
1use std::net::SocketAddr;
2
3use anyhow::Context;
4use axum::Router;
5use axum::http::StatusCode;
6use config::scoped::{McpHostConfig, ScopedConfig};
7use tokio::net::TcpListener;
8use tower_http::limit::RequestBodyLimitLayer;
9use tower_http::trace::TraceLayer;
10use tracing::{info, warn};
11
12const MAX_REQUEST_BODY_BYTES: usize = 2 * 1024 * 1024;
13
14#[tokio::main]
15async fn main() -> anyhow::Result<()> {
16 export::telemetry::init_tracing();
17
18 let cfg = McpHostConfig::load().context("loading configuration")?;
19 if !cfg.mcp.enabled() {
20 info!("mcp host is disabled by config; exiting");
21 return Ok(());
22 }
23
24 info!("connecting to database / redis / storage / email");
25 let state = export::AppState::with_config(cfg.clone().into_app_config())
26 .await
27 .context("building AppState")?;
28 info!("dependencies ready");
29
30 if let Err(err) = export::telemetry::init_meter_provider() {
31 warn!(error = %err, "failed to init meter provider; metrics disabled");
32 }
33
34 let health = export::health::router_with(Some(state.database.clone()), state.redis.clone(), state.kenauth.clone());
35 let mcp = mcp_api::router(state).await;
36
37 let router: Router = Router::new()
38 .merge(health)
39 .nest("/v1", mcp)
40 .fallback(not_found)
41 .layer(RequestBodyLimitLayer::new(MAX_REQUEST_BODY_BYTES))
42 .layer(
43 TraceLayer::new_for_http()
44 .make_span_with(export::telemetry::HttpSpan)
45 .on_response(export::telemetry::HttpOnResponse),
46 )
47 .layer(export::telemetry::metrics_layer())
48 .layer(export::middleware::AppTraceLayer::new());
49
50 let bind_addr = cfg.mcp.bind_addr().to_string();
51 let port = cfg.mcp.port();
52 let addr: SocketAddr = format!("{bind_addr}:{port}")
53 .parse()
54 .with_context(|| format!("parsing bind address {bind_addr}:{port}"))?;
55 let listener = TcpListener::bind(addr)
56 .await
57 .with_context(|| format!("binding {addr}"))?;
58 info!(%addr, "mcp host listening");
59
60 axum::serve(
61 listener,
62 router.into_make_service_with_connect_info::<SocketAddr>(),
63 )
64 .with_graceful_shutdown(export::telemetry::shutdown_signal())
65 .await
66 .context("axum server error")?;
67
68 info!("mcp host shut down");
69 Ok(())
70}
71
72async fn not_found() -> StatusCode {
73 StatusCode::NOT_FOUND
74}
Deleted app/mcp/Cargo.toml
@@ -1,15 +0,0 @@
1[package]
2name = "mcp"
3version.workspace = true
4edition.workspace = true
5publish = false
6
7[dependencies]
8mcp-api = { workspace = true }
9config = { workspace = true }
10export = { workspace = true }
11axum = { workspace = true }
12tokio = { workspace = true, features = ["signal", "net", "macros", "rt-multi-thread"] }
13tower-http = { workspace = true }
14tracing = { workspace = true }
15anyhow = { workspace = true }
Modified charts/core/templates/_helpers.tpl View charts/core/templates/_helpers.tpl
@@ -73,7 +73,6 @@
7373- name: APP_HEALTH__SYNCHOST__PORT
7474 value: {{ .Values.health.synchostPort | quote }}
7575- name: APP_HEALTH__MAILS__PORT
76 value: {{ .Values.health.mailsPort | quote }}
7776- name: APP_HEALTH__AIHOST__PORT
7877 value: {{ .Values.health.aihostPort | quote }}
7978{{- if .Values.ai.enabled }}
Deleted charts/core/templates/aihost.yaml
@@ -1,81 +0,0 @@
1{{- if and .Values.aihost.enabled .Values.ai.enabled }}
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5 name: {{ printf "%s-aihost" (include "gitdata.fullname" .) }}
6 labels:
7 {{- include "gitdata.labels" . | nindent 4 }}
8 app.kubernetes.io/component: aihost
9spec:
10 replicas: {{ .Values.aihost.replicaCount }}
11 revisionHistoryLimit: 3
12 strategy:
13 # 队列消费无状态,但同一 job 不应被两个消费者同时取走;
14 # 滚动时先停旧实例再起新的,避免并发消费同一队列。
15 type: Recreate
16 selector:
17 matchLabels:
18 {{- include "gitdata.selectorLabels" . | nindent 6 }}
19 app.kubernetes.io/component: aihost
20 template:
21 metadata:
22 labels:
23 {{- include "gitdata.selectorLabels" . | nindent 8 }}
24 app.kubernetes.io/component: aihost
25 {{- with .Values.podLabels }}
26 {{- toYaml . | nindent 8 }}
27 {{- end }}
28 annotations:
29 checksum/env: {{ toYaml .Values.config.env | sha256sum }}
30 {{- with .Values.podAnnotations }}
31 {{- toYaml . | nindent 8 }}
32 {{- end }}
33 spec:
34 serviceAccountName: {{ include "gitdata.serviceAccountName" . }}
35 {{- include "gitdata.imagePullSecrets" . | nindent 6 }}
36 terminationGracePeriodSeconds: 60
37 securityContext:
38 {{- toYaml .Values.podSecurityContext | nindent 8 }}
39 containers:
40 - name: aihost
41 image: {{ include "gitdata.image" . }}
42 imagePullPolicy: {{ .Values.image.pullPolicy }}
43 args: ["aihost"]
44 {{- include "gitdata.envFrom" . | nindent 10 }}
45 env:
46 {{- include "gitdata.env" . | nindent 12 }}
47 {{- with .Values.aihost.extraEnv }}
48 {{- toYaml . | nindent 12 }}
49 {{- end }}
50 ports:
51 - name: health
52 containerPort: {{ .Values.health.aihostPort }}
53 livenessProbe:
54 httpGet: { path: /healthz, port: health }
55 initialDelaySeconds: 15
56 periodSeconds: 20
57 readinessProbe:
58 httpGet: { path: /readyz, port: health }
59 initialDelaySeconds: 10
60 periodSeconds: 10
61 failureThreshold: 3
62 securityContext:
63 {{- toYaml .Values.securityContext | nindent 12 }}
64 resources:
65 {{- toYaml .Values.aihost.resources | nindent 12 }}
66 # 评审需要直接读 bare 仓库(git diff / commit 上下文)
67 volumeMounts:
68 - name: repos
69 mountPath: {{ .Values.persistence.repos.mountPath }}
70 volumes:
71 - name: repos
72 {{- if .Values.persistence.repos.existingClaim }}
73 persistentVolumeClaim:
74 claimName: {{ .Values.persistence.repos.existingClaim }}
75 {{- else if .Values.persistence.repos.enabled }}
76 persistentVolumeClaim:
77 claimName: {{ printf "%s-repos" (include "gitdata.fullname" .) }}
78 {{- else }}
79 emptyDir: {}
80 {{- end }}
81{{- end }}
Deleted charts/core/templates/mcp.yaml
@@ -1,157 +0,0 @@
1{{- if .Values.mcp.enabled }}
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5 name: {{ printf "%s-mcp" (include "gitdata.fullname" .) }}
6 labels:
7 {{- include "gitdata.labels" . | nindent 4 }}
8 app.kubernetes.io/component: mcp
9spec:
10 replicas: {{ .Values.mcp.replicaCount }}
11 revisionHistoryLimit: 3
12 selector:
13 matchLabels:
14 {{- include "gitdata.selectorLabels" . | nindent 6 }}
15 app.kubernetes.io/component: mcp
16 template:
17 metadata:
18 labels:
19 {{- include "gitdata.selectorLabels" . | nindent 8 }}
20 app.kubernetes.io/component: mcp
21 {{- with .Values.podLabels }}
22 {{- toYaml . | nindent 8 }}
23 {{- end }}
24 annotations:
25 checksum/env: {{ toYaml .Values.config.env | sha256sum }}
26 {{- with .Values.podAnnotations }}
27 {{- toYaml . | nindent 8 }}
28 {{- end }}
29 spec:
30 serviceAccountName: {{ include "gitdata.serviceAccountName" . }}
31 {{- include "gitdata.imagePullSecrets" . | nindent 6 }}
32 terminationGracePeriodSeconds: 30
33 securityContext:
34 {{- toYaml .Values.podSecurityContext | nindent 8 }}
35 {{- with .Values.nodeSelector }}
36 nodeSelector:
37 {{- toYaml . | nindent 8 }}
38 {{- end }}
39 {{- with .Values.tolerations }}
40 tolerations:
41 {{- toYaml . | nindent 8 }}
42 {{- end }}
43 containers:
44 - name: mcp
45 image: {{ include "gitdata.image" . }}
46 imagePullPolicy: {{ .Values.image.pullPolicy }}
47 args: ["mcp"]
48 {{- include "gitdata.envFrom" . | nindent 10 }}
49 env:
50 {{- include "gitdata.env" . | nindent 12 }}
51 {{- with .Values.mcp.extraEnv }}
52 {{- toYaml . | nindent 12 }}
53 {{- end }}
54 ports:
55 - name: http
56 containerPort: {{ .Values.mcp.port }}
57 livenessProbe:
58 httpGet: { path: /healthz, port: http }
59 initialDelaySeconds: 10
60 periodSeconds: 15
61 timeoutSeconds: 3
62 readinessProbe:
63 httpGet: { path: /readyz, port: http }
64 initialDelaySeconds: 5
65 periodSeconds: 10
66 timeoutSeconds: 3
67 failureThreshold: 3
68 securityContext:
69 {{- toYaml .Values.securityContext | nindent 12 }}
70 resources:
71 {{- toYaml .Values.mcp.resources | nindent 12 }}
72 volumeMounts:
73 - name: repos
74 mountPath: {{ .Values.persistence.repos.mountPath }}
75 volumes:
76 - name: repos
77 {{- if .Values.persistence.repos.existingClaim }}
78 persistentVolumeClaim:
79 claimName: {{ .Values.persistence.repos.existingClaim }}
80 {{- else if .Values.persistence.repos.enabled }}
81 persistentVolumeClaim:
82 claimName: {{ printf "%s-repos" (include "gitdata.fullname" .) }}
83 {{- else }}
84 emptyDir: {}
85 {{- end }}
86---
87apiVersion: v1
88kind: Service
89metadata:
90 name: {{ printf "%s-mcp" (include "gitdata.fullname" .) }}
91 labels:
92 {{- include "gitdata.labels" . | nindent 4 }}
93 app.kubernetes.io/component: mcp
94spec:
95 type: ClusterIP
96 selector:
97 {{- include "gitdata.selectorLabels" . | nindent 4 }}
98 app.kubernetes.io/component: mcp
99 ports:
100 - name: http
101 port: {{ .Values.mcp.port }}
102 targetPort: http
103{{- if .Values.mcp.ingress.enabled }}
104---
105apiVersion: networking.k8s.io/v1
106kind: Ingress
107metadata:
108 name: {{ printf "%s-mcp" (include "gitdata.fullname" .) }}
109 labels:
110 {{- include "gitdata.labels" . | nindent 4 }}
111 app.kubernetes.io/component: mcp
112 annotations:
113 {{- with .Values.mcp.ingress.annotations }}
114 {{- toYaml . | nindent 4 }}
115 {{- end }}
116 {{- if and .Values.mcp.ingress.tls.enabled .Values.mcp.ingress.tls.clusterIssuer }}
117 cert-manager.io/cluster-issuer: {{ .Values.mcp.ingress.tls.clusterIssuer }}
118 {{- end }}
119spec:
120 {{- with .Values.mcp.ingress.className }}
121 ingressClassName: {{ . }}
122 {{- end }}
123 {{- if .Values.mcp.ingress.tls.enabled }}
124 tls:
125 - hosts:
126 - {{ .Values.mcp.ingress.host | quote }}
127 secretName: {{ default (printf "%s-mcp-tls" (include "gitdata.fullname" .)) .Values.mcp.ingress.tls.secretName }}
128 {{- end }}
129 rules:
130 - host: {{ .Values.mcp.ingress.host | quote }}
131 http:
132 paths:
133 - path: /
134 pathType: Prefix
135 backend:
136 service:
137 name: {{ printf "%s-mcp" (include "gitdata.fullname" .) }}
138 port:
139 name: http
140{{- end }}
141{{- if .Values.mcp.pdb.enabled }}
142---
143apiVersion: policy/v1
144kind: PodDisruptionBudget
145metadata:
146 name: {{ printf "%s-mcp" (include "gitdata.fullname" .) }}
147 labels:
148 {{- include "gitdata.labels" . | nindent 4 }}
149 app.kubernetes.io/component: mcp
150spec:
151 minAvailable: {{ .Values.mcp.pdb.minAvailable }}
152 selector:
153 matchLabels:
154 {{- include "gitdata.selectorLabels" . | nindent 6 }}
155 app.kubernetes.io/component: mcp
156{{- end }}
157{{- end }}
Modified charts/core/values.yaml View charts/core/values.yaml
@@ -30,8 +30,6 @@
3030 APP_S3__PATH_STYLE: "true"
3131 # 读写库/副本、邮件、配额等其余段均可在此覆盖,例如:
3232 # APP_DATABASE__REPLICAS: '[{url="postgres://readonly@pg:5432/gitdata"}]'
33 APP_EMAIL__ENABLED: "false"
34 APP_EMAIL_QUEUE__ENABLED: "false"
3533 APP_SEARCH__ENABLED: "false"
3634 APP_SEARCH__URL: "http://meilisearch:7700"
3735 APP_SEARCH__INDEX_PREFIX: "gitdata"
@@ -66,7 +64,6 @@
6664 APP_AUTH__TOTP_ENCRYPTION_KEY: "" # base64 >=32 字节(实测必填)
6765 APP_S3__ACCESS_KEY_ID: ""
6866 APP_S3__SECRET_ACCESS_KEY: ""
69 APP_EMAIL__SMTP_PASSWORD: ""
7067 APP_SEARCH__API_KEY: ""
7168 APP_IMPORT__TOKEN_SECRET: "" # >=32 字节,加密导入任务里的源站令牌
7269 APP_CI__SECRET_ENCRYPTION_KEY: "" # base64 >=32 字节,CI secrets / webhook 加密密钥
@@ -183,7 +180,6 @@
183180# sandbox 运行事件流(SSE)由 sandbox-agent 自己在 sandbox-agent.gitdata.ai 暴露,
184181# 平台侧只配置 APP_SANDBOX_RPC__FANOUT_ENDPOINT,因此这里不再有 chart 资源。
185182
186mails:
187183 enabled: true
188184 replicaCount: 1
189185 resources:
@@ -195,7 +191,6 @@
195191health:
196192 enabled: true
197193 synchostPort: 3002
198 mailsPort: 3003
199194 aihostPort: 3006
200195
201196im:
Modified charts/prod/values/prod.yaml View charts/prod/values/prod.yaml
@@ -29,13 +29,8 @@
2929 APP_OAUTH__COOKIE_SECURE: "true"
3030 APP_GIT__BASE_URL: "https://gitdata.ai"
3131 APP_LIVEKIT__RECORDING_ENABLED: "false"
32 APP_EMAIL__ENABLED: "true"
33 APP_EMAIL__SMTP_HOST: "smtpdm.aliyun.com"
34 APP_EMAIL__SMTP_PORT: "465"
3532 APP_EMAIL__SMTP_USERNAME: "bot@mail.gitdata.ai"
3633 APP_EMAIL__FROM_ADDRESS: "bot@mail.gitdata.ai"
37 APP_EMAIL__FROM_NAME: "GitData"
38 APP_EMAIL_QUEUE__ENABLED: "true"
3934 # CI 开关:runner 注册走 /api/v1/runner/register,该处理函数第一件事就是
4035 # ensure_enabled()(lib/ci/src/service.rs:46),关闭时直接返回 403 Forbidden。
4136 # 开启后必须同时提供 APP_CI__SECRET_ENCRYPTION_KEY(已在 gitdata-env Secret 中)。
@@ -218,7 +213,6 @@
218213 enabled: true
219214 replicaCount: 1
220215
221mails:
222216 enabled: true
223217
224218im:
Modified config/default.toml View config/default.toml
@@ -249,12 +249,12 @@
249249default_locale = "en"
250250template_dir = "config/email_templates"
251251
252[email_queue]
253252enabled = false
254253channel_prefix = "email"
255254subscriber_group_id = "email-worker"
256255max_retries = 3
257256retry_backoff_ms = 500
257[email.rpc]
258258
259259[apihost]
260260enabled = true
@@ -307,8 +307,6 @@
307307[health.synchost]
308308port = 3002
309309
310[health.mails]
311port = 3003
312310
313311[health.aihost]
314312port = 3006
Modified docs/oauth.md View docs/oauth.md
@@ -56,7 +56,7 @@
56561. 不迁移存量用户、口令、会话、Access Key、space 数据。
57572. 不把 `rooms`、`dir`、目录覆盖、space 图片、`space_warn` 迁入 KenOAuth。
58583. 不把仓库、issue、PR、CI、chat 消息迁入 KenOAuth。
594. 不改造 `mails` 服务(非认证邮件继续由 GitData 发送)。
594. GitData 已移除 `mails` 服务,非认证邮件(issue/PR/chat/space 邀请)改走 KenOAuth 内部 `EmailService.SendEmail`。
60605. 不在本期替换 CI 的 `ci_` job token。
61616. 不在本期实现 WebAuthn / SMS 多因子(KenOAuth 表结构已就绪)。
62627. 不在本期把 GitData 的资源管理能力迁入 KenAdmin(见 §16 能力缺口)。
Deleted lib/agent/src/bot.rs
@@ -1,109 +0,0 @@
1use config::ai::AiConfig;
2use export::AppState;
3use models::users::user::{self, ProfileVisibility};
4use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
5use time::OffsetDateTime;
6
7/// Resolve or create the AI bot account in KenOAuth, then project it locally.
8pub async fn resolve_bot_user(state: &AppState, cfg: &AiConfig) -> Result<i64, sea_orm::DbErr> {
9 let username = cfg.bot_username.trim();
10 if username.is_empty() {
11 return Err(sea_orm::DbErr::Custom(
12 "ai.bot_username must not be empty".to_string(),
13 ));
14 }
15 ensure_bot(
16 state,
17 username,
18 cfg.bot_display_name.trim(),
19 true,
20 )
21 .await
22}
23
24pub async fn ensure_bot(
25 state: &AppState,
26 username: &str,
27 displayname: &str,
28 system: bool,
29) -> Result<i64, sea_orm::DbErr> {
30 let client = state
31 .kenauth
32 .as_ref()
33 .ok_or_else(|| sea_orm::DbErr::Custom("kenoauth is not configured".to_string()))?;
34 let public = client
35 .ensure_bot_user(username, displayname, system)
36 .await
37 .map_err(|error| sea_orm::DbErr::Custom(error.to_string()))?;
38 upsert_local_bot(state, &public, system).await?;
39 Ok(public.user_id)
40}
41
42pub async fn upsert_local_bot(
43 state: &AppState,
44 public: &kenauth::proto::common::v1::UserPublic,
45 system: bool,
46) -> Result<user::Model, sea_orm::DbErr> {
47 let db = state.database.write();
48 let now = OffsetDateTime::now_utc();
49 let lower = public.username.to_lowercase();
50 let display_name = if public.displayname.trim().is_empty() {
51 public.username.clone()
52 } else {
53 public.displayname.clone()
54 };
55
56 let existing = user::Entity::find()
57 .filter(user::Column::LowerName.eq(&lower))
58 .one(db)
59 .await?;
60 let row = match existing {
61 Some(row) => {
62 let mut active: user::ActiveModel = row.into();
63 active.uid = Set(public.user_id);
64 active.username = Set(public.username.clone());
65 active.lower_name = Set(lower);
66 active.display_name = Set(display_name);
67 active.avatar_url = Set(public.avatar_url.clone());
68 active.is_active = Set(true);
69 active.prohibit_login = Set(true);
70 active.bot = Set(true);
71 active.system = Set(system);
72 active.updated_at = Set(now);
73 active.update(db).await?
74 }
75 None => {
76 user::ActiveModel {
77 uid: Set(public.user_id),
78 username: Set(public.username.clone()),
79 lower_name: Set(lower),
80 display_name: Set(display_name),
81 avatar_url: Set(public.avatar_url.clone()),
82 is_active: Set(true),
83 is_restricted: Set(false),
84 prohibit_login: Set(true),
85 visibility: Set(ProfileVisibility::Public),
86 bot: Set(true),
87 system: Set(system),
88 last_login_at: Set(None),
89 last_login_ip_hash: Set(None),
90 version: Set(1),
91 passwd_hash: Set(None),
92 password_last_changed_at: Set(None),
93 passwd_version: Set(None),
94 totp_encrypted_secret: Set(None),
95 totp_enabled: Set(false),
96 totp_last_used_at: Set(None),
97 totp_last_counter: Set(None),
98 timezone: Set(None),
99 locale: Set(None),
100 language: Set(None),
101 created_at: Set(now),
102 updated_at: Set(now),
103 }
104 .insert(db)
105 .await?
106 }
107 };
108 Ok(row)
109}
No newline at end of file
Deleted lib/agent/src/client.rs
@@ -1,407 +0,0 @@
1use std::{borrow::Cow, 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 rejected the request with status {status}: {body}")]
16 Rejected { status: u16, body: String },
17 #[error("ai response could not be parsed: {0}")]
18 Decode(String),
19 #[error("ai returned an incomplete response: {0}")]
20 Incomplete(String),
21 #[error("invalid ai task: {0}")]
22 InvalidTask(String),
23 #[error("ai task execution failed: {0}")]
24 Execution(String),
25}
26
27pub type AgentResult<T> = Result<T, AgentError>;
28
29const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
30
31fn transport_message(error: &reqwest::Error) -> String {
32 let kind = if error.is_timeout() {
33 "timed out"
34 } else if error.is_connect() {
35 "connect failed"
36 } else {
37 "request failed"
38 };
39 let mut message = format!("{kind}: {error}");
40 let mut cause = std::error::Error::source(error);
41 while let Some(next) = cause {
42 message = format!("{message}: {next}");
43 cause = next.source();
44 }
45 message
46}
47
48impl AgentError {
49 #[must_use]
50 pub fn is_retryable(&self) -> bool {
51 matches!(
52 self,
53 Self::Transport(_) | Self::Status { .. } | Self::Incomplete(_) | Self::Execution(_)
54 )
55 }
56}
57
58const DEEPSEEK_JSON_INSTRUCTION: &str = "\n\nJSON 输出要求:严格按照上方 JSON 示例返回一个完整的 JSON 对象。只输出 JSON,不要输出 Markdown 代码块、思考过程、解释、前缀或后缀。";
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61enum Provider {
62 DeepSeek,
63 Compatible,
64}
65
66impl Provider {
67 fn detect(endpoint: &str, model: &str) -> Self {
68 let deepseek_endpoint = reqwest::Url::parse(endpoint)
69 .ok()
70 .and_then(|url| url.host_str().map(str::to_owned))
71 .is_some_and(|host| {
72 host.eq_ignore_ascii_case("deepseek.com")
73 || host.to_ascii_lowercase().ends_with(".deepseek.com")
74 });
75 if deepseek_endpoint || model.trim().to_ascii_lowercase().starts_with("deepseek") {
76 Self::DeepSeek
77 } else {
78 Self::Compatible
79 }
80 }
81
82 fn name(self) -> &'static str {
83 match self {
84 Self::DeepSeek => "deepseek",
85 Self::Compatible => "openai-compatible",
86 }
87 }
88}
89
90#[derive(Debug, Clone, Serialize)]
91struct ChatRequest {
92 model: String,
93 messages: Vec<ChatMessage>,
94 temperature: f32,
95 max_tokens: u32,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 response_format: Option<ResponseFormat>,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 thinking_budget: Option<u32>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103struct ChatMessage {
104 role: String,
105 content: String,
106}
107
108#[derive(Debug, Clone, Serialize)]
109struct ResponseFormat {
110 #[serde(rename = "type")]
111 kind: String,
112}
113
114#[derive(Debug, Clone, Deserialize)]
115struct ChatResponse {
116 #[serde(default)]
117 choices: Vec<ChatChoice>,
118 #[serde(default)]
119 usage: Option<ChatUsage>,
120}
121
122#[derive(Debug, Clone, Deserialize)]
123struct ChatChoice {
124 #[serde(default)]
125 finish_reason: Option<String>,
126 message: Option<ChatChoiceMessage>,
127}
128
129#[derive(Debug, Clone, Deserialize)]
130struct ChatChoiceMessage {
131 #[serde(default)]
132 content: Option<String>,
133}
134
135#[derive(Debug, Clone, Deserialize)]
136struct ChatUsage {
137 #[serde(default)]
138 prompt_tokens: Option<i32>,
139 #[serde(default)]
140 completion_tokens: Option<i32>,
141}
142
143/// 一次模型调用的完整产物:正文、用量与原始响应体。
144#[derive(Debug, Clone)]
145pub struct AgentCompletion {
146 pub content: String,
147 pub prompt_tokens: Option<i32>,
148 pub completion_tokens: Option<i32>,
149 pub raw: serde_json::Value,
150}
151
152/// OpenAI 兼容的单一模型客户端。
153///
154/// 平台内所有 AI 能力都经由本客户端访问 `[ai].model`,不存在按功能切换模型的入口。
155#[derive(Clone)]
156pub struct AgentClient {
157 http: reqwest::Client,
158 endpoint: String,
159 provider: Provider,
160 api_key: String,
161 model: String,
162 temperature: f32,
163 max_output_tokens: u32,
164 max_attempts: i32,
165 thinking_budget: Option<u32>,
166}
167
168impl AgentClient {
169 pub fn new(cfg: &AiConfig) -> AgentResult<Self> {
170 if cfg.base_url.trim().is_empty() || cfg.model.trim().is_empty() {
171 return Err(AgentError::NotConfigured(
172 "base_url and model are required".to_string(),
173 ));
174 }
175 let http = reqwest::Client::builder()
176 .timeout(Duration::from_secs(cfg.request_timeout_secs.max(1)))
177 .connect_timeout(CONNECT_TIMEOUT)
178 .build()
179 .map_err(|e| AgentError::Transport(e.to_string()))?;
180 let endpoint = cfg.endpoint();
181 let model = cfg.model.trim().to_string();
182 Ok(Self {
183 http,
184 provider: Provider::detect(&endpoint, &model),
185 endpoint,
186 api_key: cfg.api_key.trim().to_string(),
187 model,
188 temperature: cfg.temperature,
189 max_output_tokens: cfg.max_output_tokens,
190 max_attempts: cfg.max_attempts.max(1),
191 thinking_budget: (cfg.thinking_budget > 0).then_some(cfg.thinking_budget),
192 })
193 }
194
195 pub fn model(&self) -> &str {
196 &self.model
197 }
198
199 /// 发送一次 chat completion 请求,失败时按指数退避重试。
200 pub async fn complete(
201 &self,
202 system_prompt: &str,
203 user_prompt: &str,
204 json_mode: bool,
205 ) -> AgentResult<AgentCompletion> {
206 let system_prompt = self.system_prompt(system_prompt, json_mode);
207 let body = ChatRequest {
208 model: self.model.clone(),
209 messages: vec![
210 ChatMessage {
211 role: "system".to_string(),
212 content: system_prompt.into_owned(),
213 },
214 ChatMessage {
215 role: "user".to_string(),
216 content: user_prompt.to_string(),
217 },
218 ],
219 temperature: self.temperature,
220 max_tokens: self.max_output_tokens,
221 response_format: json_mode.then(|| ResponseFormat {
222 kind: "json_object".to_string(),
223 }),
224 thinking_budget: self.thinking_budget,
225 };
226
227 let mut last_error: Option<AgentError> = None;
228 for attempt in 0..self.max_attempts {
229 if attempt > 0 {
230 let backoff = Duration::from_millis(500 * (1u64 << attempt.min(5)));
231 tokio::time::sleep(backoff).await;
232 }
233 match self.send_once(&body, json_mode).await {
234 Ok(completion) => return Ok(completion),
235 Err(error) => {
236 let retryable = self.should_retry(&error);
237 tracing::warn!(
238 %error,
239 provider = self.provider.name(),
240 model = %self.model,
241 attempt = attempt + 1,
242 "ai completion attempt failed"
243 );
244 last_error = Some(error);
245 if !retryable {
246 break;
247 }
248 }
249 }
250 }
251 Err(last_error.unwrap_or_else(|| AgentError::Transport("no attempt made".to_string())))
252 }
253
254 fn system_prompt<'a>(&self, prompt: &'a str, json_mode: bool) -> Cow<'a, str> {
255 if self.provider == Provider::DeepSeek && json_mode {
256 Cow::Owned(format!("{prompt}{DEEPSEEK_JSON_INSTRUCTION}"))
257 } else {
258 Cow::Borrowed(prompt)
259 }
260 }
261
262 fn should_retry(&self, error: &AgentError) -> bool {
263 if self.provider != Provider::DeepSeek {
264 return error.is_retryable();
265 }
266 match error {
267 AgentError::Status { status, .. } => deepseek_retryable_status(*status),
268 _ => error.is_retryable(),
269 }
270 }
271
272 async fn send_once(&self, body: &ChatRequest, json_mode: bool) -> AgentResult<AgentCompletion> {
273 let mut request = self
274 .http
275 .post(&self.endpoint)
276 .header(reqwest::header::CONTENT_TYPE, "application/json")
277 .json(body);
278 if !self.api_key.is_empty() {
279 request = request.bearer_auth(&self.api_key);
280 }
281 let span = export::telemetry::http_client_span("POST", &self.endpoint);
282 let response = export::telemetry::send(span, request)
283 .await
284 .map_err(|e| AgentError::Transport(transport_message(&e)))?;
285 let status = response.status();
286 let text = response
287 .text()
288 .await
289 .map_err(|e| AgentError::Transport(transport_message(&e)))?;
290 if !status.is_success() {
291 let status = status.as_u16();
292 let body = truncate(&text, 2048);
293 if self.provider == Provider::DeepSeek && !deepseek_retryable_status(status) {
294 return Err(AgentError::Rejected { status, body });
295 }
296 return Err(AgentError::Status { status, body });
297 }
298 let parsed: ChatResponse = serde_json::from_str(&text)
299 .map_err(|e| AgentError::Decode(format!("{e}: {}", truncate(&text, 512))))?;
300 let choice = parsed.choices.first().ok_or_else(|| {
301 if self.provider == Provider::DeepSeek && json_mode {
302 AgentError::Incomplete("deepseek JSON mode returned no choices".to_string())
303 } else {
304 AgentError::Decode("response has no choices".to_string())
305 }
306 })?;
307 let content = choice
308 .message
309 .as_ref()
310 .and_then(|message| message.content.as_deref());
311 if self.provider == Provider::DeepSeek && json_mode {
312 validate_deepseek_json(choice.finish_reason.as_deref(), content)?;
313 }
314 let content = content
315 .map(str::to_owned)
316 .ok_or_else(|| AgentError::Decode("response has no message content".to_string()))?;
317 let raw: serde_json::Value = serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
318 Ok(AgentCompletion {
319 content,
320 prompt_tokens: parsed.usage.as_ref().and_then(|u| u.prompt_tokens),
321 completion_tokens: parsed.usage.as_ref().and_then(|u| u.completion_tokens),
322 raw,
323 })
324 }
325}
326
327fn deepseek_retryable_status(status: u16) -> bool {
328 matches!(status, 408 | 429) || (500..=599).contains(&status)
329}
330
331fn validate_deepseek_json(finish_reason: Option<&str>, content: Option<&str>) -> AgentResult<()> {
332 match finish_reason {
333 Some("length") => {
334 return Err(AgentError::Decode(
335 "deepseek JSON output was truncated; increase ai.max_output_tokens or reduce the input"
336 .to_string(),
337 ));
338 }
339 Some("insufficient_system_resource" | "aborted") => {
340 return Err(AgentError::Incomplete(format!(
341 "deepseek stopped with finish_reason={}",
342 finish_reason.unwrap_or_default()
343 )));
344 }
345 Some("content_filter" | "tool_calls") => {
346 return Err(AgentError::Decode(format!(
347 "deepseek JSON output stopped with finish_reason={}",
348 finish_reason.unwrap_or_default()
349 )));
350 }
351 _ => {}
352 }
353 let content = content
354 .map(str::trim)
355 .filter(|value| !value.is_empty())
356 .ok_or_else(|| {
357 AgentError::Incomplete("deepseek JSON mode returned empty content".to_string())
358 })?;
359 let value: serde_json::Value = serde_json::from_str(content).map_err(|error| {
360 AgentError::Incomplete(format!("deepseek JSON mode returned invalid JSON: {error}"))
361 })?;
362 if !value.is_object() {
363 return Err(AgentError::Incomplete(
364 "deepseek JSON mode returned a non-object value".to_string(),
365 ));
366 }
367 Ok(())
368}
369
370pub(crate) fn truncate(value: &str, max: usize) -> String {
371 if value.chars().count() <= max {
372 return value.to_string();
373 }
374 let mut out: String = value.chars().take(max).collect();
375 out.push_str("…(truncated)");
376 out
377}
378
379/// 从模型输出中提取 JSON 对象;容忍围栏代码块与前后说明文字。
380pub fn extract_json(content: &str) -> AgentResult<serde_json::Value> {
381 let direct = content.trim();
382 if let Ok(value) = serde_json::from_str::<serde_json::Value>(direct) {
383 return Ok(value);
384 }
385 if let Some(inner) = fenced_json(direct)
386 && let Ok(value) = serde_json::from_str::<serde_json::Value>(&inner)
387 {
388 return Ok(value);
389 }
390 if let (Some(start), Some(end)) = (direct.find('{'), direct.rfind('}'))
391 && start < end
392 && let Ok(value) = serde_json::from_str::<serde_json::Value>(&direct[start..=end])
393 {
394 return Ok(value);
395 }
396 Err(AgentError::Decode(
397 "model output did not contain JSON".to_string(),
398 ))
399}
400
401fn fenced_json(value: &str) -> Option<String> {
402 let start = value.find("```")?;
403 let rest = &value[start + 3..];
404 let rest = rest.strip_prefix("json").unwrap_or(rest);
405 let end = rest.find("```")?;
406 Some(rest[..end].trim().to_string())
407}
Deleted lib/agent/src/context.rs
@@ -1,235 +0,0 @@
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
22 .git
23 .context(space_name.to_string(), repo_name.to_string())
24 .await?;
25 let plan = self
26 .git
27 .merge_plan(&ctx, &format!("refs/heads/{base_ref}"), head_sha)
28 .await?;
29 let from = plan
30 .merge_base
31 .clone()
32 .unwrap_or_else(|| plan.base_sha.clone());
33 let diff = self
34 .git
35 .diff_raw(
36 space_name.to_string(),
37 repo_name.to_string(),
38 from.clone(),
39 plan.head_sha.clone(),
40 )
41 .await?;
42 let label = format!("{}..{}", short(&from), short(&plan.head_sha));
43 Ok((self.truncate_diff(diff), label))
44 }
45
46 /// Commit 评审上下文:父提交..该提交的差异。
47 pub async fn build_commit_diff(
48 &self,
49 space_name: &str,
50 repo_name: &str,
51 commit_sha: &str,
52 ) -> Result<(BuiltContext, String), git::share::errors::GitError> {
53 let ctx = self
54 .git
55 .context(space_name.to_string(), repo_name.to_string())
56 .await?;
57 let baby = self.git.baby(&ctx);
58 let info = baby
59 .get_commit(commit_sha)
60 .await
61 .map_err(git::share::errors::map_baby)?;
62 let parent = info
63 .parents
64 .first()
65 .map(|p| p.to_string())
66 .unwrap_or_else(|| EMPTY_TREE.to_string());
67 let sha = info.id.to_string();
68 let diff = self
69 .git
70 .diff_raw(
71 space_name.to_string(),
72 repo_name.to_string(),
73 parent.clone(),
74 sha.clone(),
75 )
76 .await?;
77 let label = format!("{}..{}", short(&parent), short(&sha));
78 Ok((self.truncate_diff(diff), label))
79 }
80
81 /// 提交列表(评审用不到,但总结/release 复用同一格式化)。
82 ///
83 /// 附上正文首段:提交标题往往只有「fix bug」这类动作,真正说明意图、影响面
84 /// 与取舍的信息在正文里。总结与 release 文案靠这些内容才能写出实质差别,
85 /// 只喂标题时模型只能把标题换个说法复述一遍——那正是输出干瘪的直接原因。
86 /// 正文按提交截断,避免一条超长 message 把预算吃光。
87 pub(crate) fn format_commits(commits: &[git::service::merge::MergeCommitDto]) -> String {
88 let mut out = String::new();
89 for commit in commits {
90 out.push_str(&format!(
91 "- {} {} ({}, {})\n",
92 short(&commit.sha),
93 commit.subject.trim(),
94 commit.author_name,
95 commit.author_at
96 ));
97 if let Some(body) = commit_body(&commit.message, &commit.subject) {
98 out.push_str(&body);
99 }
100 }
101 out
102 }
103
104 /// 按字节预算截断差异:超限时逐文件裁剪并标记 truncated。
105 pub(crate) fn truncate_diff(&self, diff: String) -> BuiltContext {
106 let limit = self.config().ai.context_max_bytes;
107 if diff.len() <= limit {
108 return BuiltContext {
109 text: diff,
110 truncated: false,
111 };
112 }
113 let mut text = String::with_capacity(limit);
114 let mut truncated = false;
115 for file in split_diff_files(&diff) {
116 if text.len() + file.len() > limit {
117 truncated = true;
118 break;
119 }
120 text.push_str(&file);
121 }
122 if text.is_empty() {
123 // 单个文件本身就超限:按字符边界硬截断。
124 text = diff.chars().take(limit).collect();
125 truncated = true;
126 }
127 BuiltContext { text, truncated }
128 }
129}
130
131/// 按 `diff --git` 切分,保留每段完整补丁文本。
132pub fn split_diff_files(diff: &str) -> Vec<String> {
133 let mut files: Vec<String> = Vec::new();
134 let mut current = String::new();
135 for line in diff.split_inclusive('\n') {
136 if line.starts_with("diff --git ") && !current.is_empty() {
137 files.push(std::mem::take(&mut current));
138 }
139 current.push_str(line);
140 }
141 if !current.is_empty() {
142 files.push(current);
143 }
144 files
145}
146
147pub(crate) fn short(sha: &str) -> String {
148 sha.chars().take(8).collect()
149}
150
151/// 单条提交正文进入提示词的字符上限。
152const COMMIT_BODY_MAX_CHARS: usize = 400;
153
154/// 提交正文的摘要片段:跳过标题行、去掉签名等尾部噪音、保留首个实质段落。
155fn commit_body(message: &str, subject: &str) -> Option<String> {
156 let mut lines = message.lines();
157 // 第一行是标题,调用方已经输出过。
158 lines.next();
159 let mut body = String::new();
160 for line in lines {
161 let trimmed = line.trim();
162 if trimmed.is_empty() {
163 // 首个空行代表首个段落结束;段落之前的前导空行直接跳过。
164 if !body.is_empty() {
165 break;
166 }
167 continue;
168 }
169 // 「Signed-off-by:」「Co-authored-by:」这类 trailer 不描述变更本身。
170 let lower = trimmed.to_ascii_lowercase();
171 if lower.starts_with("signed-off-by:") || lower.starts_with("co-authored-by:") {
172 continue;
173 }
174 body.push_str(trimmed);
175 body.push(' ');
176 if body.chars().count() >= COMMIT_BODY_MAX_CHARS {
177 break;
178 }
179 }
180 let body = body.trim();
181 if body.is_empty() || body == subject.trim() {
182 return None;
183 }
184 Some(format!(
185 " 正文:{}\n",
186 truncate_chars(body, COMMIT_BODY_MAX_CHARS)
187 ))
188}
189
190fn truncate_chars(text: &str, limit: usize) -> String {
191 if text.chars().count() <= limit {
192 return text.to_string();
193 }
194 let mut out: String = text.chars().take(limit).collect();
195 out.push('…');
196 out
197}
198
199/// 让 `GitContext` 在本 crate 内可用(避免调用方各自引入 git crate)。
200pub type RepoContext = GitContext;
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 /// 提交正文是总结质量的来源:只喂标题时模型无从区分「一次重构」与
207 /// 「一行修复」,输出必然退化成标题复述。这条测试把正文必须进入提示词
208 /// 这件事钉住,同时确认签名之类的尾部噪音不会被带上。
209 #[test]
210 fn commit_body_keeps_prose_and_drops_trailers() {
211 let message = "fix: 修正接收器计数\n\nBufferedUdpMetricSink 在批量发送后少记一次,\n对监控口径有影响。\n\nSigned-off-by: Someone <s@example.com>\n";
212 let body = commit_body(message, "fix: 修正接收器计数").expect("正文应被保留");
213 assert!(body.contains("少记一次"), "正文要点必须保留: {body}");
214 assert!(
215 !body.contains("Signed-off-by"),
216 "trailer 不应进入提示词: {body}"
217 );
218 }
219
220 /// 没有正文、或正文与标题重复时不必占用预算。
221 #[test]
222 fn commit_body_is_skipped_when_empty_or_identical() {
223 assert!(commit_body("only a subject", "only a subject").is_none());
224 assert!(commit_body("subject\n\nsubject", "subject").is_none());
225 }
226
227 /// 超长正文按字符截断,避免一条 message 吃掉整段提示词预算。
228 #[test]
229 fn commit_body_is_bounded() {
230 let long = format!("subject\n\n{}", "改".repeat(COMMIT_BODY_MAX_CHARS * 2));
231 let body = commit_body(&long, "subject").expect("应有正文");
232 assert!(body.chars().count() < COMMIT_BODY_MAX_CHARS + 16);
233 assert!(body.ends_with("…\n"));
234 }
235}
Deleted lib/agent/src/diff.rs
@@ -1,151 +0,0 @@
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}
Deleted lib/agent/src/language_pref.rs
@@ -1,69 +0,0 @@
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<i64>,
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: i64) -> Option<String> {
37 use models::users::user;
38 use sea_orm::EntityTrait;
39 let row = user::Entity::find_by_id(uid)
40 .one(self.db_read())
41 .await
42 .ok()??;
43 let raw = row
44 .language
45 .as_deref()
46 .filter(|value| !value.trim().is_empty())
47 .or_else(|| {
48 row.locale
49 .as_deref()
50 .filter(|value| !value.trim().is_empty())
51 })?;
52 let normalized = crate::languages::normalize(raw);
53 (!normalized.is_empty() && normalized != crate::languages::AUTO).then_some(normalized)
54 }
55
56 /// 仓库所属空间的所有者 uid。
57 async fn repo_owner(&self, repo_id: Uuid) -> Option<i64> {
58 use models::repo::repos;
59 use sea_orm::EntityTrait;
60 let repo = repos::Entity::find_by_id(repo_id)
61 .one(self.db_read())
62 .await
63 .ok()??;
64 git::share::access::space_for_dir(self.db_read(), repo.dir_id)
65 .await
66 .ok()?
67 .map(|space| space.owner_id)
68 }
69}
Deleted lib/agent/src/languages.rs
@@ -1,2 +0,0 @@
1//! 语言注册表由 `ci_protocol::ai_languages` 提供(生产/消费双方共用)。
2pub use ci_protocol::ai_languages::*;
Deleted lib/agent/src/lib.rs
@@ -1,31 +0,0 @@
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;
14mod language_pref;
15pub mod languages;
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::{
24 display_name as language_display_name, normalize as normalize_language,
25 supported_codes as supported_languages,
26};
27pub use prompt::{ReleaseNotesOutput, ReviewFinding, ReviewOutput, SummaryOutput};
28pub use service::AgentService;
29
30/// 便捷别名:AI 队列任务载荷。
31pub use ci_protocol::ai::AgentTask;
Deleted lib/agent/src/notifications.rs
@@ -1,15 +0,0 @@
1//! 通知文案的多语言措辞。
2//!
3//! 实现已迁到 `i18n::catalog`:通知文案要和邮件、HTML 结果页共用一份目录,
4//! 否则同一个事件在收件箱和邮件里会出现两种语言。这里保留原有入口签名,
5//! 让 `notify.rs` 的调用点不必关心目录的位置。
6
7/// 「AI 评审完成 · #12」
8pub fn review_title(language: &str, number: i32) -> String {
9 i18n::catalog::ai_review_title(language, number)
10}
11
12/// 「3 条行级建议:<summary>」/「<summary>」
13pub fn review_body(language: &str, findings: usize, summary: &str) -> String {
14 i18n::catalog::ai_review_body(language, findings, summary)
15}
Deleted lib/agent/src/notify.rs
@@ -1,71 +0,0 @@
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: i64,
16 language: &str,
17 summary: &str,
18 findings: usize,
19 ) {
20 let mut recipients: Vec<i64> = 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}
Deleted lib/agent/src/prompt.rs
@@ -1,318 +0,0 @@
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 读者是维护者与下游使用者:他们要靠这份总结判断「这段时间仓库往哪走了、\
116 有没有需要立刻关注的风险」,而不是看一遍提交标题。\n\
117 \n\
118 写作要求:\n\
119 1. content 使用 Markdown,按主题组织而不是按提交罗列,篇幅以 600-1200 字为宜:\
120 先用 3-5 句概述本批更新的整体走向与动机,再用 `##` 小节分主题展开\
121 (例如「核心功能」「稳定性与修复」「工程与依赖」「文档与测试」——\
122 按实际内容取舍,不要为了凑齐而编造主题)。\
123 2. 每个主题下写清:改了什么、为什么这么改、对使用者的可观察影响、\
124 以及值得注意的取舍或兼容性变化。有 API/配置/行为变更时必须点名。\
125 3. 只依据给定材料归纳,不臆测未出现的代码、动机或时间线;\
126 材料不足时如实说明范围有限,不要用套话填充。\
127 4. 不复述提交消息原文,不逐条翻译标题;同一主题下多条提交合并叙述。\
128 5. highlights 写 4-8 条具体成果,每条一句话、含可验证的信息\
129 (改了哪个组件/行为,而非「改进了性能」这类空话)。\
130 6. risks 只在材料支持时给出,每条写清风险点、成立条件与建议关注方向;\
131 确实没有明显风险时给出空数组,不要为凑数制造问题。\n{lang}\
132 按以下 JSON 结构输出,不要输出任何解释文字:\
133 {{\"content\":\"Markdown 总结正文\",\"highlights\":[\"具体亮点\"],\"risks\":[\"风险点\"]}}。",
134 lang = ctx.language_clause()
135 )
136}
137
138/// Release 文案系统提示词。
139pub fn release_system(ctx: &PromptContext) -> String {
140 format!(
141 "你是发布负责人,为版本发布撰写 Release Notes。\
142 按功能、修复、其他三类归纳给定区间内的提交,用面向使用者的语言描述价值。\
143 标题使用版本号本身,不编造未出现的变更。\n{lang}\
144 按以下 JSON 结构输出,不要输出任何解释文字:\
145 {{\"title\":\"vX.Y.Z\",\"content\":\"Markdown 正文\"}}。",
146 lang = ctx.language_clause()
147 )
148}
149
150/// 组装一次评审请求的用户消息。
151pub fn review_user_prompt(
152 repo_name: &str,
153 context_label: &str,
154 diff: &str,
155 truncated: bool,
156 max_findings: usize,
157) -> String {
158 let note = if truncated {
159 "\n注意:本次变更内容因体积限制被截断,请只评审已给出的部分并说明存在截断。\n"
160 } else {
161 ""
162 };
163 // 逐文件标注语言,避免模型把多语言仓库当成单一语言评审。
164 let annotations = file_language_annotations(diff);
165 let annotated = if annotations.is_empty() {
166 String::new()
167 } else {
168 format!("\n文件语言:\n{annotations}")
169 };
170 format!(
171 "仓库:{repo_name}\n范围:{context_label}\n{note}{annotated}\
172最多输出 {max_findings} 条 findings。\n\n```diff\n{diff}\n```\n"
173 )
174}
175
176/// 逐文件列出「路径 → 语言」,仅包含能识别的文件。
177pub fn file_language_annotations(diff: &str) -> String {
178 let mut out = String::new();
179 for line in diff.split('\n') {
180 let Some(rest) = line.strip_prefix("diff --git ") else {
181 continue;
182 };
183 let Some((old, target)) = rest.trim().split_once(" b/") else {
184 continue;
185 };
186 let path = target.trim().trim_matches('"');
187 let label = language_of_path(old.trim())
188 .or_else(|| language_of_path(path))
189 .unwrap_or("未知");
190 out.push_str(&format!("- {path} → {label}\n"));
191 }
192 out
193}
194
195/// 组装仓库总结请求的用户消息。
196///
197/// `scale` 是区间级的改动规模(文件数 / 增删行),`commit_count` 是实际纳入的
198/// 提交条数:两者一起让模型知道自己看到的是一大批工作还是零星几次提交,
199/// 从而决定内容的详略,而不是对任何输入都写同样长短的一段话。
200pub fn summary_user_prompt(
201 repo_name: &str,
202 revision: &str,
203 commit_count: usize,
204 scale: Option<&str>,
205 commits: &str,
206) -> String {
207 let scale_line = match scale {
208 Some(value) => format!("改动规模:{value}\n"),
209 None => String::new(),
210 };
211 format!(
212 "仓库:{repo_name}\n分支/版本:{revision}\n提交条数:{commit_count}\n{scale_line}\n\
213 以下是最新提交(从新到旧),每条含标题与正文摘要:\n\n{commits}\n"
214 )
215}
216
217/// 组装 Release 文案请求的用户消息。
218pub fn release_user_prompt(
219 repo_name: &str,
220 tag_name: &str,
221 previous_tag: Option<&str>,
222 commits: &str,
223) -> String {
224 let range = match previous_tag {
225 Some(previous) => format!("{previous}..{tag_name}"),
226 None => format!("(首个版本){tag_name}"),
227 };
228 format!("仓库:{repo_name}\n版本:{tag_name}\n区间:{range}\n\n区间内提交:\n\n{commits}\n")
229}
230
231/// 从补丁文本推断涉及的语言文件名扩展。
232pub fn detect_languages(diff: &str) -> Vec<String> {
233 let mut found: Vec<String> = Vec::new();
234 for line in diff.split('\n') {
235 let Some(rest) = line.strip_prefix("diff --git ") else {
236 continue;
237 };
238 let Some((_, target)) = rest.trim().split_once(" b/") else {
239 continue;
240 };
241 if let Some(language) = language_of_path(target.trim())
242 && !found.iter().any(|have| have == language)
243 {
244 found.push(language.to_string());
245 }
246 }
247 found
248}
249
250/// 依扩展名/文件名映射编程语言;用于提示词与统计,不参与语法分析。
251pub fn language_of_path(path: &str) -> Option<&'static str> {
252 let unquoted = path.trim_matches('"');
253 let base = unquoted.rsplit('/').next().unwrap_or(unquoted);
254 match base {
255 "Dockerfile" | "Containerfile" => return Some("Dockerfile"),
256 "Makefile" | "GNUmakefile" => return Some("Makefile"),
257 "Gemfile" | "Rakefile" => return Some("Ruby"),
258 "Cargo.lock" => return Some("TOML"),
259 "go.mod" | "go.sum" => return Some("Go"),
260 "CMakeLists.txt" => return Some("CMake"),
261 _ => {}
262 }
263 let ext = base.rsplit_once('.')?.1.to_ascii_lowercase();
264 Some(match ext.as_str() {
265 "rs" => "Rust",
266 "go" => "Go",
267 "py" | "pyi" => "Python",
268 "js" | "mjs" | "cjs" | "jsx" => "JavaScript",
269 "ts" | "mts" | "cts" | "tsx" => "TypeScript",
270 "java" => "Java",
271 "kt" | "kts" => "Kotlin",
272 "scala" => "Scala",
273 "swift" => "Swift",
274 "c" | "h" => "C",
275 "cc" | "cpp" | "cxx" | "hpp" | "hh" | "hxx" => "C++",
276 "cs" => "C#",
277 "php" => "PHP",
278 "rb" => "Ruby",
279 "lua" => "Lua",
280 "dart" => "Dart",
281 "ex" | "exs" => "Elixir",
282 "erl" | "hrl" => "Erlang",
283 "hs" => "Haskell",
284 "ml" | "mli" => "OCaml",
285 "clj" | "cljs" => "Clojure",
286 "r" => "R",
287 "jl" => "Julia",
288 "pl" | "pm" => "Perl",
289 "sh" | "bash" | "zsh" | "fish" => "Shell",
290 "ps1" | "psm1" => "PowerShell",
291 "bat" | "cmd" => "Batch",
292 "sql" => "SQL",
293 "proto" => "Protocol Buffers",
294 "graphql" | "gql" => "GraphQL",
295 "vue" => "Vue",
296 "svelte" => "Svelte",
297 "astro" => "Astro",
298 "md" | "markdown" | "mdx" => "Markdown",
299 "yaml" | "yml" => "YAML",
300 "json" | "jsonc" => "JSON",
301 "toml" => "TOML",
302 "ini" | "cfg" | "conf" => "Config",
303 "xml" => "XML",
304 "html" | "htm" => "HTML",
305 "css" => "CSS",
306 "scss" | "sass" => "SCSS",
307 "less" => "Less",
308 "tf" | "tfvars" => "Terraform",
309 "hcl" => "HCL",
310 "nix" => "Nix",
311 "v" => "V",
312 "zig" => "Zig",
313 "nim" => "Nim",
314 "asm" | "s" => "Assembly",
315 "ipynb" => "Jupyter",
316 _ => return None,
317 })
318}
Deleted lib/agent/src/queue.rs
@@ -1,109 +0,0 @@
1use models::ai::ai_job;
2use models::ai::ai_job::{ActiveModel, AiJobStatus};
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 export::metrics::ai_tokens(prompt_tokens, completion_tokens);
58 let db = self.db_write();
59 let now = OffsetDateTime::now_utc();
60 if let Ok(Some(row)) = ai_job::Entity::find_by_id(job_id).one(db).await {
61 let mut model: ActiveModel = row.into();
62 model.status = Set(AiJobStatus::Success);
63 model.request_json = Set(request);
64 model.response_json = Set(response);
65 model.prompt_tokens = Set(prompt_tokens);
66 model.completion_tokens = Set(completion_tokens);
67 model.error = Set(None);
68 model.finished_at = Set(Some(now));
69 model.updated_at = Set(Some(now));
70 if let Err(error) = model.update(db).await {
71 tracing::warn!(%error, %job_id, "failed to mark ai job success");
72 }
73 }
74 }
75
76 pub async fn mark_failed(&self, job_id: Uuid, error: &str) {
77 let db = self.db_write();
78 let now = OffsetDateTime::now_utc();
79 if let Ok(Some(row)) = ai_job::Entity::find_by_id(job_id).one(db).await {
80 let mut model: ActiveModel = row.into();
81 model.status = Set(AiJobStatus::Failed);
82 model.error = Set(Some(error.to_string()));
83 model.finished_at = Set(Some(now));
84 model.updated_at = Set(Some(now));
85 if let Err(update_error) = model.update(db).await {
86 tracing::warn!(%update_error, %job_id, "failed to mark ai job failure");
87 }
88 }
89 }
90
91 /// 统计某仓库仍未结束的作业数,用于限流与积压观测。
92 pub async fn pending_jobs(&self, repo_id: Uuid) -> Result<u64, sea_orm::DbErr> {
93 ai_job::Entity::find()
94 .filter(ai_job::Column::RepoId.eq(repo_id))
95 .filter(ai_job::Column::Status.is_in([AiJobStatus::Pending, AiJobStatus::Running]))
96 .count(self.db_read())
97 .await
98 }
99}
100
101fn kind_from_str(value: &str) -> ai_job::AiJobKind {
102 use ai_job::AiJobKind;
103 match value {
104 "commit_review" => AiJobKind::CommitReview,
105 "repo_summary" => AiJobKind::RepoSummary,
106 "release_notes" => AiJobKind::ReleaseNotes,
107 _ => AiJobKind::PullReview,
108 }
109}
Deleted lib/agent/src/service.rs
@@ -1,714 +0,0 @@
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].summary_commit_limit` 取较小值)。
25const MAX_SUMMARY_COMMITS: u64 = 200;
26
27/// release 文案最多纳入的提交数。
28const MAX_RELEASE_COMMITS: u32 = 200;
29
30/// AI 运行核心:持有单一模型客户端与共享状态,执行队列中的任务。
31#[derive(Clone)]
32pub struct AgentService {
33 pub state: AppState,
34 pub git: GitService,
35 client: Arc<AgentClient>,
36}
37
38impl AgentService {
39 pub fn new(state: AppState) -> AgentResult<Self> {
40 let client = AgentClient::new(&state.config.ai)?;
41 let git = GitService::new(state.clone());
42 Ok(Self {
43 state,
44 git,
45 client: Arc::new(client),
46 })
47 }
48
49 pub fn config(&self) -> &AppConfig {
50 &self.state.config
51 }
52
53 pub fn db_read(&self) -> &DatabaseConnection {
54 self.state.database.read()
55 }
56
57 pub fn db_write(&self) -> &DatabaseConnection {
58 self.state.database.write()
59 }
60
61 pub fn client(&self) -> &AgentClient {
62 &self.client
63 }
64
65 /// 执行一条队列任务。返回 `Err` 时由调用方决定重投或落 DLQ。
66 pub async fn run_task(&self, task: &Task) -> AgentResult<()> {
67 let job_id = task.job_id();
68 self.mark_running(job_id, task.kind_str())
69 .await
70 .map_err(|e| AgentError::InvalidTask(e.to_string()))?;
71 let outcome = match task {
72 Task::PullReview { .. } => self.run_pull_review(task).await,
73 Task::CommitReview { .. } => self.run_commit_review(task).await,
74 Task::RepoSummary { .. } => self.run_repo_summary(task).await,
75 Task::ReleaseNotes { .. } => self.run_release_notes(task).await,
76 };
77 if let Err(error) = &outcome {
78 tracing::warn!(%error, %job_id, "ai task failed");
79 }
80 outcome
81 }
82
83 /// 评审提示词上下文:输出语言 + 本次补丁涉及的语言。
84 fn review_context(&self, language: &str, diff: &str) -> PromptContext {
85 PromptContext::new(language, detect_languages(diff))
86 }
87
88 /// 总结与 release 提示词上下文:输出语言 + 仓库已登记的语言统计。
89 ///
90 /// 提交列表本身不含路径,因此复用 sync 阶段写入的 `git_language` 统计;
91 /// 统计缺失时只保留语言指令,不影响生成。
92 async fn repo_context(&self, language: &str, repo_id: Uuid) -> PromptContext {
93 let languages = self.repo_languages(repo_id).await;
94 PromptContext::new(language, languages)
95 }
96
97 /// 读取仓库语言统计(按占比降序,最多 5 种)。
98 async fn repo_languages(&self, repo_id: Uuid) -> Vec<String> {
99 use models::git::git_languages;
100 use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
101 let rows = git_languages::Entity::find()
102 .filter(git_languages::Column::RepoId.eq(repo_id))
103 .order_by_desc(git_languages::Column::PercentageBp)
104 .limit(5)
105 .all(self.db_read())
106 .await;
107 match rows {
108 Ok(rows) => rows.into_iter().map(|row| row.language).collect(),
109 Err(error) => {
110 tracing::debug!(%error, "repo language lookup failed; omitting language hint");
111 Vec::new()
112 }
113 }
114 }
115
116 /// 仓库全名(space/repo),用于提示词与通知文案。
117 pub(crate) async fn repo_label(&self, repo_id: Uuid) -> String {
118 use models::repo::repos;
119 let repo = repos::Entity::find_by_id(repo_id)
120 .one(self.db_read())
121 .await
122 .ok()
123 .flatten();
124 match repo {
125 Some(row) => row.name,
126 None => repo_id.to_string(),
127 }
128 }
129
130 pub(crate) async fn space_name_for(&self, repo_id: Uuid) -> Option<String> {
131 use models::repo::repos;
132 let repo = repos::Entity::find_by_id(repo_id)
133 .one(self.db_read())
134 .await
135 .ok()??;
136 git::share::access::space_for_dir(self.db_read(), repo.dir_id)
137 .await
138 .ok()?
139 .map(|space| space.lower_name)
140 }
141
142 // ---- PR 评审 -------------------------------------------------------
143
144 async fn run_pull_review(&self, task: &Task) -> AgentResult<()> {
145 let Task::PullReview {
146 job_id,
147 repo_id,
148 pull_request_id,
149 head_sha,
150 language,
151 ..
152 } = task
153 else {
154 return Err(AgentError::InvalidTask("not a pull review".to_string()));
155 };
156 let pull = pull_requests::Entity::find_by_id(*pull_request_id)
157 .filter(pull_requests::Column::RepoId.eq(*repo_id))
158 .one(self.db_read())
159 .await
160 .map_err(db_err)?
161 .ok_or_else(|| AgentError::InvalidTask("pull request not found".to_string()))?;
162 let space = self
163 .space_name_for(*repo_id)
164 .await
165 .ok_or_else(|| AgentError::InvalidTask("space not found".to_string()))?;
166 let repo_name = self.repo_label(*repo_id).await;
167 let (diff, label) = self
168 .build_pull_diff(&space, &repo_name, head_sha, &pull.base_branch)
169 .await
170 .map_err(|e| AgentError::Execution(format!("build diff: {e}")))?;
171 let lang = self
172 .resolve_language(Some(pull.author_id), language.as_deref(), *repo_id)
173 .await;
174 let ctx = self.review_context(&lang, &diff.text);
175 let prompt = review_user_prompt(
176 &repo_name,
177 &label,
178 &diff.text,
179 diff.truncated,
180 self.config().ai.max_findings,
181 );
182 let completion = self
183 .client
184 .complete(&review_system(&ctx), &prompt, true)
185 .await?;
186 let parsed: ReviewOutput = parse_output(&completion.content)?;
187 let (findings, dropped) = crate::diff::validate_findings(
188 &diff.text,
189 parsed.findings,
190 self.config().ai.max_findings,
191 );
192 if dropped > 0 {
193 tracing::info!(dropped, "dropped ai findings outside changed lines");
194 }
195 let verdict = match parsed.verdict.as_deref() {
196 Some("changes_requested") => AiReviewVerdict::ChangesRequested,
197 _ => AiReviewVerdict::Commented,
198 };
199 self.persist_pull_review(
200 job_id,
201 repo_id,
202 &pull,
203 head_sha,
204 &lang,
205 verdict,
206 &parsed.summary,
207 &findings,
208 diff.truncated,
209 &completion.raw,
210 completion.prompt_tokens,
211 completion.completion_tokens,
212 )
213 .await
214 }
215
216 #[allow(clippy::too_many_arguments)]
217 #[allow(clippy::too_many_arguments)]
218 async fn persist_pull_review(
219 &self,
220 job_id: &Uuid,
221 repo_id: &Uuid,
222 pull: &pull_requests::Model,
223 head_sha: &str,
224 language: &str,
225 verdict: AiReviewVerdict,
226 summary: &str,
227 findings: &[crate::prompt::ReviewFinding],
228 truncated: bool,
229 raw: &serde_json::Value,
230 prompt_tokens: Option<i32>,
231 completion_tokens: Option<i32>,
232 ) -> AgentResult<()> {
233 let bot = crate::bot::resolve_bot_user(&self.state, &self.config().ai)
234 .await
235 .map_err(db_err)?;
236 let now = OffsetDateTime::now_utc();
237 let state = match verdict {
238 AiReviewVerdict::ChangesRequested => pull_request_review::ReviewState::ChangesRequested,
239 AiReviewVerdict::Commented => pull_request_review::ReviewState::Commented,
240 };
241 let review_id = Uuid::now_v7();
242 pull_request_review::ActiveModel {
243 id: Set(review_id),
244 pull_request_id: Set(pull.id),
245 user_id: Set(bot),
246 commit_sha: Set(head_sha.to_string()),
247 official: Set(false),
248 content: Set(Some(summary.to_string())),
249 state: Set(state),
250 created_at: Set(now),
251 updated_at: Set(None),
252 }
253 .insert(self.db_write())
254 .await
255 .map_err(db_err)?;
256
257 for finding in findings {
258 pull_request_comment::ActiveModel {
259 id: Set(Uuid::now_v7()),
260 pull_request_id: Set(pull.id),
261 user_id: Set(bot),
262 review_id: Set(Some(review_id)),
263 parent_comment_id: Set(None),
264 commit_sha: Set(Some(head_sha.to_string())),
265 original_commit_sha: Set(Some(head_sha.to_string())),
266 path: Set(Some(finding.path.clone())),
267 side: Set(finding.side.clone()),
268 line: Set(Some(finding.line)),
269 original_line: Set(Some(finding.line)),
270 body: Set(finding_body(finding)),
271 resolved_at: Set(None),
272 resolved_by: Set(None),
273 created_at: Set(now),
274 updated_at: Set(None),
275 }
276 .insert(self.db_write())
277 .await
278 .map_err(db_err)?;
279 }
280
281 let findings_value = serde_json::to_value(findings).unwrap_or(serde_json::Value::Null);
282 ResultModel {
283 id: Set(Uuid::now_v7()),
284 repo_id: Set(*repo_id),
285 pull_request_id: Set(Some(pull.id)),
286 commit_sha: Set(head_sha.to_string()),
287 verdict: Set(verdict),
288 summary: Set(summary.to_string()),
289 findings_json: Set(findings_value),
290 truncated: Set(truncated),
291 language: Set(language.to_string()),
292 job_id: Set(*job_id),
293 created_at: Set(now),
294 }
295 .insert(self.db_write())
296 .await
297 .map_err(db_err)?;
298
299 self.mark_success(
300 *job_id,
301 None,
302 Some(raw.clone()),
303 prompt_tokens,
304 completion_tokens,
305 )
306 .await;
307 self.notify_pull_ai(pull, bot, language, summary, findings.len())
308 .await;
309 Ok(())
310 }
311
312 // ---- Commit 评审 ---------------------------------------------------
313
314 async fn run_commit_review(&self, task: &Task) -> AgentResult<()> {
315 let Task::CommitReview {
316 job_id,
317 repo_id,
318 commit_sha,
319 language,
320 ..
321 } = task
322 else {
323 return Err(AgentError::InvalidTask("not a commit review".to_string()));
324 };
325 let space = self
326 .space_name_for(*repo_id)
327 .await
328 .ok_or_else(|| AgentError::InvalidTask("space not found".to_string()))?;
329 let repo_name = self.repo_label(*repo_id).await;
330 let (diff, label) = self
331 .build_commit_diff(&space, &repo_name, commit_sha)
332 .await
333 .map_err(|e| AgentError::Execution(format!("build diff: {e}")))?;
334 let lang = self
335 .resolve_language(None, language.as_deref(), *repo_id)
336 .await;
337 let ctx = self.review_context(&lang, &diff.text);
338 let prompt = review_user_prompt(
339 &repo_name,
340 &label,
341 &diff.text,
342 diff.truncated,
343 self.config().ai.max_findings,
344 );
345 let completion = self
346 .client
347 .complete(&review_system(&ctx), &prompt, true)
348 .await?;
349 let parsed: ReviewOutput = parse_output(&completion.content)?;
350 let (findings, _dropped) = crate::diff::validate_findings(
351 &diff.text,
352 parsed.findings,
353 self.config().ai.max_findings,
354 );
355 let verdict = match parsed.verdict.as_deref() {
356 Some("changes_requested") => AiReviewVerdict::ChangesRequested,
357 _ => AiReviewVerdict::Commented,
358 };
359 let now = OffsetDateTime::now_utc();
360 let findings_value = serde_json::to_value(&findings).unwrap_or(serde_json::Value::Null);
361 ResultModel {
362 id: Set(Uuid::now_v7()),
363 repo_id: Set(*repo_id),
364 pull_request_id: Set(None),
365 commit_sha: Set(commit_sha.clone()),
366 verdict: Set(verdict),
367 summary: Set(parsed.summary.clone()),
368 findings_json: Set(findings_value),
369 truncated: Set(diff.truncated),
370 language: Set(lang.clone()),
371 job_id: Set(*job_id),
372 created_at: Set(now),
373 }
374 .insert(self.db_write())
375 .await
376 .map_err(db_err)?;
377 self.mark_success(
378 *job_id,
379 None,
380 Some(completion.raw.clone()),
381 completion.prompt_tokens,
382 completion.completion_tokens,
383 )
384 .await;
385 self.publish_commit_status(
386 *repo_id,
387 commit_sha,
388 verdict,
389 &parsed.summary,
390 findings.len(),
391 )
392 .await;
393 Ok(())
394 }
395
396 /// 把提交级评审结论写成 `context=ai-review` 的提交状态。
397 ///
398 /// 默认不进入 `required_status_contexts`;是否作为合并门禁由管理员配置。
399 async fn publish_commit_status(
400 &self,
401 repo_id: Uuid,
402 commit_sha: &str,
403 verdict: AiReviewVerdict,
404 summary: &str,
405 findings: usize,
406 ) {
407 use git::service::commits::{CommitStatusDtoState, SetCommitStatusCmd};
408 let (space, repo) = match self.repo_location(repo_id).await {
409 Some(pair) => pair,
410 None => {
411 tracing::warn!(%repo_id, "skip commit status: repo location unknown");
412 return;
413 }
414 };
415 let bot = match crate::bot::resolve_bot_user(&self.state, &self.config().ai).await {
416 Ok(uid) => uid,
417 Err(error) => {
418 tracing::warn!(%error, "skip commit status: bot user unavailable");
419 return;
420 }
421 };
422 let (state, description) = match verdict {
423 AiReviewVerdict::ChangesRequested => (
424 CommitStatusDtoState::Failure,
425 format!("AI 评审要求修改({findings} 条建议)"),
426 ),
427 AiReviewVerdict::Commented => (CommitStatusDtoState::Success, summary.to_string()),
428 };
429 let cmd = SetCommitStatusCmd {
430 sha: commit_sha.to_string(),
431 state,
432 context: git::service::ai_review::AI_REVIEW_CONTEXT.to_string(),
433 description: Some(crate::client::truncate(&description, 200)),
434 target_url: None,
435 };
436 if let Err(error) = self.git.set_commit_status(space, repo, bot, cmd).await {
437 tracing::warn!(%error, %commit_sha, "failed to publish ai-review commit status");
438 }
439 }
440
441 /// 由仓库 id 解析 (space, repo) 名称对。
442 async fn repo_location(&self, repo_id: Uuid) -> Option<(String, String)> {
443 use models::repo::repos;
444 let repo = repos::Entity::find_by_id(repo_id)
445 .one(self.db_read())
446 .await
447 .ok()??;
448 let space = self.space_name_for(repo_id).await?;
449 Some((space, repo.name))
450 }
451
452 // ---- 仓库总结 ------------------------------------------------------
453
454 async fn run_repo_summary(&self, task: &Task) -> AgentResult<()> {
455 let Task::RepoSummary {
456 job_id,
457 repo_id,
458 head_sha,
459 base_sha,
460 limit,
461 language,
462 ..
463 } = task
464 else {
465 return Err(AgentError::InvalidTask("not a repo summary".to_string()));
466 };
467 let space = self
468 .space_name_for(*repo_id)
469 .await
470 .ok_or_else(|| AgentError::InvalidTask("space not found".to_string()))?;
471 let repo_name = self.repo_label(*repo_id).await;
472 // 总结的口径是「最近 N 个提交」,不是两点区间:
473 // 有显式 base 时按 base..head 取,否则直接从 head 起步。
474 let limit = limit
475 .unwrap_or(self.config().ai.summary_commit_limit)
476 .clamp(1, MAX_SUMMARY_COMMITS);
477 let trimmed = match base_sha {
478 Some(base) => self
479 .git
480 .commits_between(
481 space.clone(),
482 repo_name.clone(),
483 base,
484 head_sha,
485 limit as u32,
486 )
487 .await
488 .map_err(|e| AgentError::Execution(format!("list commits: {e}")))?,
489 None => self
490 .git
491 .recent_commits(
492 space.clone(),
493 repo_name.clone(),
494 Some(head_sha),
495 limit as u32,
496 0,
497 )
498 .await
499 .map_err(|e| AgentError::Execution(format!("list commits: {e}")))?,
500 };
501 let commit_text = Self::format_commits(&trimmed);
502 // 区间级的改动规模:让模型知道这批更新是「大量工作」还是「零星几次
503 // 提交」,据此决定详略。取不到就省略,不影响生成。
504 let scale = match base_sha {
505 Some(base) => self
506 .git
507 .diff_short_stat(
508 space.clone(),
509 repo_name.clone(),
510 base.to_string(),
511 head_sha.to_string(),
512 )
513 .await
514 .ok()
515 .map(|stat| {
516 format!(
517 "{} 个文件,+{} / -{} 行",
518 stat.files_changed, stat.additions, stat.deletions
519 )
520 }),
521 None => None,
522 };
523 let prompt = summary_user_prompt(
524 &repo_name,
525 head_sha,
526 trimmed.len(),
527 scale.as_deref(),
528 &commit_text,
529 );
530 let lang = self
531 .resolve_language(None, language.as_deref(), *repo_id)
532 .await;
533 let ctx = self.repo_context(&lang, *repo_id).await;
534 let completion = self
535 .client
536 .complete(&summary_system(&ctx), &prompt, true)
537 .await?;
538 let parsed: SummaryOutput = parse_output(&completion.content)?;
539 let now = OffsetDateTime::now_utc();
540 let highlights =
541 serde_json::to_value(&parsed.highlights).unwrap_or(serde_json::Value::Null);
542 let risks = serde_json::to_value(&parsed.risks).unwrap_or(serde_json::Value::Null);
543 let existing = models::ai::repo_ai_summary::Entity::find()
544 .filter(models::ai::repo_ai_summary::Column::RepoId.eq(*repo_id))
545 .filter(models::ai::repo_ai_summary::Column::HeadSha.eq(head_sha.clone()))
546 .one(self.db_read())
547 .await
548 .map_err(db_err)?;
549 match existing {
550 Some(row) => {
551 let mut model: SummaryModel = row.into();
552 model.content = Set(parsed.content.clone());
553 model.highlights_json = Set(highlights);
554 model.risks_json = Set(risks);
555 model.commit_count = Set(trimmed.len() as i32);
556 model.model = Set(self.client.model().to_string());
557 model.language = Set(lang.clone());
558 model.job_id = Set(Some(*job_id));
559 model.updated_at = Set(now);
560 model.update(self.db_write()).await.map_err(db_err)?;
561 }
562 None => {
563 SummaryModel {
564 id: Set(Uuid::now_v7()),
565 repo_id: Set(*repo_id),
566 head_sha: Set(head_sha.clone()),
567 base_sha: Set(base_sha.clone()),
568 commit_count: Set(trimmed.len() as i32),
569 content: Set(parsed.content.clone()),
570 highlights_json: Set(highlights),
571 risks_json: Set(risks),
572 model: Set(self.client.model().to_string()),
573 language: Set(lang.clone()),
574 job_id: Set(Some(*job_id)),
575 created_at: Set(now),
576 updated_at: Set(now),
577 }
578 .insert(self.db_write())
579 .await
580 .map_err(db_err)?;
581 }
582 }
583 self.mark_success(
584 *job_id,
585 None,
586 Some(completion.raw.clone()),
587 completion.prompt_tokens,
588 completion.completion_tokens,
589 )
590 .await;
591 Ok(())
592 }
593
594 // ---- Release 文案 --------------------------------------------------
595
596 async fn run_release_notes(&self, task: &Task) -> AgentResult<()> {
597 let Task::ReleaseNotes {
598 job_id,
599 repo_id,
600 release_id,
601 tag_name,
602 from_sha,
603 to_sha,
604 language,
605 ..
606 } = task
607 else {
608 return Err(AgentError::InvalidTask("not a release notes".to_string()));
609 };
610 let release = repo_release::Entity::find_by_id(*release_id)
611 .filter(repo_release::Column::RepoId.eq(*repo_id))
612 .one(self.db_read())
613 .await
614 .map_err(db_err)?
615 .ok_or_else(|| AgentError::InvalidTask("release not found".to_string()))?;
616 let space = self
617 .space_name_for(*repo_id)
618 .await
619 .ok_or_else(|| AgentError::InvalidTask("space not found".to_string()))?;
620 let repo_name = self.repo_label(*repo_id).await;
621 // 有上一个标签时按区间取;首个版本没有起点,则回退到「最近若干提交」,
622 // 而不是留空——否则首个 release 的文案会没有任何素材。
623 let commits = match from_sha {
624 Some(from) => self
625 .git
626 .commits_between(
627 space.clone(),
628 repo_name.clone(),
629 from,
630 to_sha,
631 MAX_RELEASE_COMMITS,
632 )
633 .await
634 .map_err(|e| AgentError::Execution(format!("list commits: {e}")))?,
635 None => self
636 .git
637 .recent_commits(
638 space,
639 repo_name.clone(),
640 Some(to_sha),
641 MAX_RELEASE_COMMITS,
642 0,
643 )
644 .await
645 .map_err(|e| AgentError::Execution(format!("list commits: {e}")))?,
646 };
647 let commit_text = Self::format_commits(&commits);
648 let prompt = release_user_prompt(&repo_name, tag_name, from_sha.as_deref(), &commit_text);
649 let lang = self
650 .resolve_language(None, language.as_deref(), *repo_id)
651 .await;
652 let ctx = self.repo_context(&lang, *repo_id).await;
653 let completion = self
654 .client
655 .complete(&release_system(&ctx), &prompt, true)
656 .await?;
657 let parsed: ReleaseNotesOutput = parse_output(&completion.content)?;
658 let now = OffsetDateTime::now_utc();
659 // 已发布的 release 不再被 AI 覆盖,只记录作业结果。
660 if release.is_draft {
661 let mut model: repo_release::ActiveModel = release.into();
662 let title = if parsed.title.trim().is_empty() {
663 tag_name.clone()
664 } else {
665 parsed.title.clone()
666 };
667 model.title = Set(title);
668 model.content = Set(Some(parsed.content.clone()));
669 model.num_commits = Set(commits.len() as i32);
670 model.updated_at = Set(now);
671 model.update(self.db_write()).await.map_err(db_err)?;
672 }
673 self.mark_success(
674 *job_id,
675 None,
676 Some(completion.raw.clone()),
677 completion.prompt_tokens,
678 completion.completion_tokens,
679 )
680 .await;
681 Ok(())
682 }
683
684 /// 记录 release 草稿的作业请求体(便于运维排查)。
685 pub async fn record_release_request(&self, job_id: Uuid, request: serde_json::Value) {
686 use models::ai::ai_job;
687 use sea_orm::EntityTrait;
688 if let Ok(Some(row)) = ai_job::Entity::find_by_id(job_id)
689 .one(self.db_write())
690 .await
691 {
692 let mut model: ai_job::ActiveModel = row.into();
693 model.request_json = Set(Some(request));
694 model.updated_at = Set(Some(OffsetDateTime::now_utc()));
695 let _ = model.update(self.db_write()).await;
696 }
697 }
698}
699
700fn finding_body(finding: &crate::prompt::ReviewFinding) -> String {
701 let severity = finding.severity.as_deref().unwrap_or("minor");
702 format!("**[{severity}]** {}", finding.body.trim())
703}
704
705fn parse_output<T: serde::de::DeserializeOwned>(content: &str) -> AgentResult<T> {
706 let value = extract_json(content)?;
707 serde_json::from_value(value).map_err(|e| AgentError::Decode(format!("shape mismatch: {e}")))
708}
709
710/// 数据库抖动是可重试的:连接中断、死锁等都会在下一轮恢复。
711/// 记录本身缺失另有专门的 `InvalidTask` 分支(`ok_or_else`),不走这里。
712fn db_err(error: sea_orm::DbErr) -> AgentError {
713 AgentError::Execution(format!("database error: {error}"))
714}
Deleted lib/agent/Cargo.toml
@@ -1,25 +0,0 @@
1[package]
2name = "agent"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7config = { workspace = true }
8export = { workspace = true }
9kenauth = { workspace = true }
10models = { workspace = true }
11git = { workspace = true }
12ci-protocol = { workspace = true }
13i18n = { workspace = true }
14sea-orm = { workspace = true }
15redis = { workspace = true }
16reqwest = { workspace = true }
17serde = { workspace = true, features = ["derive"] }
18serde_json = { workspace = true }
19thiserror = { workspace = true }
20time = { workspace = true }
21tokio = { workspace = true, features = ["time", "sync"] }
22tracing = { workspace = true }
23utoipa = { workspace = true }
24uuid = { workspace = true }
25futures = { workspace = true }
No newline at end of file
Deleted lib/agents/src/handlers/agents.rs
@@ -1,142 +0,0 @@
1use axum::extract::{Json, Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use jwt::Session;
4use jwt::share::session::require_uid;
5
6use crate::errors::ApiError;
7use crate::services::agents::{
8 AgentDto, AgentPage, AgentPath, CreateAgentCmd, ListAgentQuery, UpdateAgentCmd,
9};
10use crate::services::service::AgentsService;
11
12#[utoipa::path(
13 post,
14 path = "/agents/{space_name}/collections/{collection_id}/agents",
15 tag = "agents",
16 description = "在智能体集内创建智能体,并创建其运行时长期会话;服务商与模型由平台默认提供。",
17 params(crate::services::collections::AgentCollectionPath),
18 request_body = CreateAgentCmd,
19 responses(
20 (status = 200, description = "agent created", body = AppResponseData<AgentDto>),
21 (status = 400, description = "bad request", body = AppResponseData<NoData>),
22 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
23 (status = 404, description = "not found", body = AppResponseData<NoData>),
24 (status = 503, description = "agent runtime unavailable", body = AppResponseData<NoData>)
25 )
26)]
27pub async fn create_agent(
28 State(service): State<AgentsService>,
29 session: Session,
30 Path(path): Path<crate::services::collections::AgentCollectionPath>,
31 Json(cmd): Json<CreateAgentCmd>,
32) -> Result<AppResponseData<AgentDto>, ApiError> {
33 let uid = require_uid(&session)?;
34 let dto = service
35 .create_agent(uid, &path.space_name, path.collection_id, cmd)
36 .await?;
37 Ok(AppResponseData::ok(dto))
38}
39
40#[utoipa::path(
41 get,
42 path = "/agents/{space_name}/collections/{collection_id}/agents",
43 tag = "agents",
44 description = "列出智能体集内的智能体。",
45 params(crate::services::collections::AgentCollectionPath, ListAgentQuery),
46 responses(
47 (status = 200, description = "agent page", body = AppResponseData<AgentPage>),
48 (status = 404, description = "not found", body = AppResponseData<NoData>)
49 )
50)]
51pub async fn list_agents(
52 State(service): State<AgentsService>,
53 session: Session,
54 Path(path): Path<crate::services::collections::AgentCollectionPath>,
55 Query(query): Query<ListAgentQuery>,
56) -> Result<AppResponseData<AgentPage>, ApiError> {
57 let uid = require_uid(&session)?;
58 let page = service
59 .list_agents(uid, &path.space_name, path.collection_id, query)
60 .await?;
61 Ok(AppResponseData::ok(page))
62}
63
64#[utoipa::path(
65 get,
66 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}",
67 tag = "agents",
68 description = "读取单个智能体。",
69 params(AgentPath),
70 responses(
71 (status = 200, description = "agent", body = AppResponseData<AgentDto>),
72 (status = 404, description = "not found", body = AppResponseData<NoData>)
73 )
74)]
75pub async fn get_agent(
76 State(service): State<AgentsService>,
77 session: Session,
78 Path(path): Path<AgentPath>,
79) -> Result<AppResponseData<AgentDto>, ApiError> {
80 let uid = require_uid(&session)?;
81 let dto = service
82 .get_agent(uid, &path.space_name, path.collection_id, path.agent_id)
83 .await?;
84 Ok(AppResponseData::ok(dto))
85}
86
87#[utoipa::path(
88 patch,
89 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}",
90 tag = "agents",
91 description = "更新智能体名称或描述;instructions 在会话创建后不可变,服务商与模型由平台默认提供。",
92 params(AgentPath),
93 request_body = UpdateAgentCmd,
94 responses(
95 (status = 200, description = "agent updated", body = AppResponseData<AgentDto>),
96 (status = 400, description = "bad request", body = AppResponseData<NoData>),
97 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
98 (status = 404, description = "not found", body = AppResponseData<NoData>)
99 )
100)]
101pub async fn update_agent(
102 State(service): State<AgentsService>,
103 session: Session,
104 Path(path): Path<AgentPath>,
105 Json(cmd): Json<UpdateAgentCmd>,
106) -> Result<AppResponseData<AgentDto>, ApiError> {
107 let uid = require_uid(&session)?;
108 let dto = service
109 .update_agent(
110 uid,
111 &path.space_name,
112 path.collection_id,
113 path.agent_id,
114 cmd,
115 )
116 .await?;
117 Ok(AppResponseData::ok(dto))
118}
119
120#[utoipa::path(
121 delete,
122 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}",
123 tag = "agents",
124 description = "删除智能体,并尝试清理运行时长期会话。",
125 params(AgentPath),
126 responses(
127 (status = 200, description = "agent deleted", body = AppResponseData<NoData>),
128 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
129 (status = 404, description = "not found", body = AppResponseData<NoData>)
130 )
131)]
132pub async fn delete_agent(
133 State(service): State<AgentsService>,
134 session: Session,
135 Path(path): Path<AgentPath>,
136) -> Result<AppResponseData<NoData>, ApiError> {
137 let uid = require_uid(&session)?;
138 service
139 .delete_agent(uid, &path.space_name, path.collection_id, path.agent_id)
140 .await?;
141 Ok(AppResponseData::ok(NoData))
142}
Deleted lib/agents/src/handlers/avatar.rs
@@ -1,79 +0,0 @@
1use axum::body::Bytes;
2use axum::extract::{Path, State};
3use axum::http::HeaderMap;
4use axum::http::header::CONTENT_TYPE;
5use axum::response::Redirect;
6use export::rest::{AppResponseData, NoData};
7use jwt::Session;
8use jwt::share::session::require_uid;
9
10use crate::errors::ApiError;
11use crate::services::agents::{AgentDto, AgentPath};
12use crate::services::service::AgentsService;
13
14#[utoipa::path(
15 put,
16 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/avatar",
17 tag = "agents",
18 description = "上传原始图片字节作为智能体头像(png/jpeg/webp/gif,不超过 2 MB)。",
19 params(AgentPath),
20 request_body(
21 content = Vec<u8>,
22 description = "raw image bytes (image/png, image/jpeg, image/webp, image/gif)",
23 content_type = "image/png"
24 ),
25 responses(
26 (status = 200, description = "avatar uploaded", body = AppResponseData<AgentDto>),
27 (status = 400, description = "bad request", body = AppResponseData<NoData>),
28 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
29 (status = 404, description = "agent not found", body = AppResponseData<NoData>)
30 )
31)]
32pub async fn upload_agent_avatar(
33 State(service): State<AgentsService>,
34 session: Session,
35 Path(path): Path<AgentPath>,
36 headers: HeaderMap,
37 body: Bytes,
38) -> Result<AppResponseData<AgentDto>, ApiError> {
39 let uid = require_uid(&session)?;
40 let content_type = headers
41 .get(CONTENT_TYPE)
42 .and_then(|value| value.to_str().ok())
43 .unwrap_or("application/octet-stream")
44 .to_string();
45 let agent = service
46 .upload_agent_avatar(
47 uid,
48 &path.space_name,
49 path.collection_id,
50 path.agent_id,
51 &content_type,
52 &body,
53 )
54 .await?;
55 Ok(AppResponseData::ok(agent))
56}
57
58#[utoipa::path(
59 get,
60 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/avatar",
61 tag = "agents",
62 description = "307 重定向到智能体头像的临时下载地址。",
63 params(AgentPath),
64 responses(
65 (status = 307, description = "redirect to the presigned avatar url"),
66 (status = 404, description = "agent has no avatar", body = AppResponseData<NoData>)
67 )
68)]
69pub async fn agent_avatar(
70 State(service): State<AgentsService>,
71 session: Session,
72 Path(path): Path<AgentPath>,
73) -> Result<Redirect, ApiError> {
74 let uid = require_uid(&session)?;
75 let url = service
76 .agent_avatar_url(uid, &path.space_name, path.collection_id, path.agent_id)
77 .await?;
78 Ok(Redirect::temporary(&url))
79}
Deleted lib/agents/src/handlers/builder.rs
@@ -1,36 +0,0 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use jwt::Session;
4use jwt::share::session::require_uid;
5
6use crate::errors::ApiError;
7use crate::services::builder::{BuilderChatCmd, BuilderChatResult};
8use crate::services::collections::AgentCollectionPath;
9use crate::services::service::AgentsService;
10
11#[utoipa::path(
12 post,
13 path = "/agents/{space_name}/collections/{collection_id}/builder",
14 tag = "agents",
15 description = "智能体构建器对话:结合构建对话返回回复与更新后的智能体草稿。",
16 params(AgentCollectionPath),
17 request_body = BuilderChatCmd,
18 responses(
19 (status = 200, description = "builder reply and draft", body = AppResponseData<BuilderChatResult>),
20 (status = 400, description = "bad request", body = AppResponseData<NoData>),
21 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
22 (status = 503, description = "ai unavailable", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn builder_chat(
26 State(service): State<AgentsService>,
27 session: Session,
28 Path(path): Path<AgentCollectionPath>,
29 Json(cmd): Json<BuilderChatCmd>,
30) -> Result<AppResponseData<BuilderChatResult>, ApiError> {
31 let uid = require_uid(&session)?;
32 let result = service
33 .builder_chat(uid, &path.space_name, path.collection_id, cmd)
34 .await?;
35 Ok(AppResponseData::ok(result))
36}
Deleted lib/agents/src/handlers/chat.rs
@@ -1,303 +0,0 @@
1use axum::extract::{Json, Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use jwt::Session;
4use jwt::share::session::require_uid;
5
6use crate::errors::ApiError;
7use crate::services::agents::AgentPath;
8use crate::services::chat::{
9 AgentApprovalDto, AgentApprovalPath, AgentMessageDto, AgentQuestionDto, AgentQuestionPath,
10 AgentRunDto, AgentRunPath, AgentStreamTokenDto, AgentToolCallDto, AnswerAgentQuestionCmd,
11 DecideAgentApprovalCmd, ListAgentMessageQuery, ListAgentToolCallQuery, SendAgentMessageCmd,
12};
13use crate::services::service::AgentsService;
14
15#[utoipa::path(
16 post,
17 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/stream-token",
18 tag = "agents",
19 description = "签发浏览器直连 sandbox-agent fanout SSE 的短期流令牌(含 fanout 地址与会话 id)。",
20 params(AgentPath),
21 responses(
22 (status = 200, description = "stream token", body = AppResponseData<AgentStreamTokenDto>),
23 (status = 400, description = "bad request", body = AppResponseData<NoData>),
24 (status = 404, description = "not found", body = AppResponseData<NoData>),
25 (status = 503, description = "agent runtime unavailable", body = AppResponseData<NoData>)
26 )
27)]
28pub async fn create_agent_stream_token(
29 State(service): State<AgentsService>,
30 session: Session,
31 Path(path): Path<AgentPath>,
32) -> Result<AppResponseData<AgentStreamTokenDto>, ApiError> {
33 let uid = require_uid(&session)?;
34 let grant = service
35 .create_agent_stream_token(uid, &path.space_name, path.collection_id, path.agent_id)
36 .await?;
37 Ok(AppResponseData::ok(grant))
38}
39
40#[utoipa::path(
41 post,
42 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/messages",
43 tag = "agents",
44 description = "向智能体长期会话发送消息并启动一次运行(经 sandbox-agent RPC)。",
45 params(AgentPath),
46 request_body = SendAgentMessageCmd,
47 responses(
48 (status = 200, description = "run started", body = AppResponseData<AgentRunDto>),
49 (status = 400, description = "bad request", body = AppResponseData<NoData>),
50 (status = 404, description = "not found", body = AppResponseData<NoData>),
51 (status = 503, description = "agent runtime unavailable", body = AppResponseData<NoData>)
52 )
53)]
54pub async fn send_agent_message(
55 State(service): State<AgentsService>,
56 session: Session,
57 Path(path): Path<AgentPath>,
58 Json(cmd): Json<SendAgentMessageCmd>,
59) -> Result<AppResponseData<AgentRunDto>, ApiError> {
60 let uid = require_uid(&session)?;
61 let run = service
62 .send_agent_message(
63 uid,
64 &path.space_name,
65 path.collection_id,
66 path.agent_id,
67 cmd,
68 )
69 .await?;
70 Ok(AppResponseData::ok(run))
71}
72
73#[utoipa::path(
74 get,
75 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/messages",
76 tag = "agents",
77 description = "按序号增量拉取智能体会话消息。",
78 params(AgentPath, ListAgentMessageQuery),
79 responses(
80 (status = 200, description = "messages", body = AppResponseData<Vec<AgentMessageDto>>),
81 (status = 404, description = "not found", body = AppResponseData<NoData>),
82 (status = 503, description = "agent runtime unavailable", body = AppResponseData<NoData>)
83 )
84)]
85pub async fn list_agent_messages(
86 State(service): State<AgentsService>,
87 session: Session,
88 Path(path): Path<AgentPath>,
89 Query(query): Query<ListAgentMessageQuery>,
90) -> Result<AppResponseData<Vec<AgentMessageDto>>, ApiError> {
91 let uid = require_uid(&session)?;
92 let messages = service
93 .list_agent_messages(
94 uid,
95 &path.space_name,
96 path.collection_id,
97 path.agent_id,
98 query,
99 )
100 .await?;
101 Ok(AppResponseData::ok(messages))
102}
103
104#[utoipa::path(
105 get,
106 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/tool-calls",
107 tag = "agents",
108 description = "列出智能体会话的工具调用(名称、输入、耗时与状态),供消息渲染回填命令。",
109 params(AgentPath, ListAgentToolCallQuery),
110 responses(
111 (status = 200, description = "tool calls", body = AppResponseData<Vec<AgentToolCallDto>>),
112 (status = 404, description = "not found", body = AppResponseData<NoData>),
113 (status = 503, description = "agent runtime unavailable", body = AppResponseData<NoData>)
114 )
115)]
116pub async fn list_agent_tool_calls(
117 State(service): State<AgentsService>,
118 session: Session,
119 Path(path): Path<AgentPath>,
120 Query(query): Query<ListAgentToolCallQuery>,
121) -> Result<AppResponseData<Vec<AgentToolCallDto>>, ApiError> {
122 let uid = require_uid(&session)?;
123 let calls = service
124 .list_agent_tool_calls(
125 uid,
126 &path.space_name,
127 path.collection_id,
128 path.agent_id,
129 query,
130 )
131 .await?;
132 Ok(AppResponseData::ok(calls))
133}
134
135#[utoipa::path(
136 get,
137 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/runs",
138 tag = "agents",
139 description = "列出智能体会话的运行记录。",
140 params(AgentPath),
141 responses(
142 (status = 200, description = "runs", body = AppResponseData<Vec<AgentRunDto>>),
143 (status = 404, description = "not found", body = AppResponseData<NoData>),
144 (status = 503, description = "agent runtime unavailable", body = AppResponseData<NoData>)
145 )
146)]
147pub async fn list_agent_runs(
148 State(service): State<AgentsService>,
149 session: Session,
150 Path(path): Path<AgentPath>,
151) -> Result<AppResponseData<Vec<AgentRunDto>>, ApiError> {
152 let uid = require_uid(&session)?;
153 let runs = service
154 .list_agent_runs(uid, &path.space_name, path.collection_id, path.agent_id)
155 .await?;
156 Ok(AppResponseData::ok(runs))
157}
158
159#[utoipa::path(
160 post,
161 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/runs/{run_id}/cancel",
162 tag = "agents",
163 description = "取消一次运行。",
164 params(AgentRunPath),
165 responses(
166 (status = 200, description = "run canceled", body = AppResponseData<AgentRunDto>),
167 (status = 404, description = "not found", body = AppResponseData<NoData>),
168 (status = 503, description = "agent runtime unavailable", body = AppResponseData<NoData>)
169 )
170)]
171pub async fn cancel_agent_run(
172 State(service): State<AgentsService>,
173 session: Session,
174 Path(path): Path<AgentRunPath>,
175) -> Result<AppResponseData<AgentRunDto>, ApiError> {
176 let uid = require_uid(&session)?;
177 let run = service
178 .cancel_agent_run(
179 uid,
180 &path.space_name,
181 path.collection_id,
182 path.agent_id,
183 &path.run_id,
184 )
185 .await?;
186 Ok(AppResponseData::ok(run))
187}
188
189#[utoipa::path(
190 get,
191 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/approvals",
192 tag = "agents",
193 description = "列出智能体会话的审批请求。",
194 params(AgentPath),
195 responses(
196 (status = 200, description = "approvals", body = AppResponseData<Vec<AgentApprovalDto>>),
197 (status = 404, description = "not found", body = AppResponseData<NoData>),
198 (status = 503, description = "agent runtime unavailable", body = AppResponseData<NoData>)
199 )
200)]
201pub async fn list_agent_approvals(
202 State(service): State<AgentsService>,
203 session: Session,
204 Path(path): Path<AgentPath>,
205) -> Result<AppResponseData<Vec<AgentApprovalDto>>, ApiError> {
206 let uid = require_uid(&session)?;
207 let approvals = service
208 .list_agent_approvals(uid, &path.space_name, path.collection_id, path.agent_id)
209 .await?;
210 Ok(AppResponseData::ok(approvals))
211}
212
213#[utoipa::path(
214 post,
215 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/approvals/{approval_id}/decide",
216 tag = "agents",
217 description = "批准或拒绝一次工具调用审批。",
218 params(AgentApprovalPath),
219 request_body = DecideAgentApprovalCmd,
220 responses(
221 (status = 200, description = "approval decided", body = AppResponseData<AgentApprovalDto>),
222 (status = 400, description = "bad request", body = AppResponseData<NoData>),
223 (status = 404, description = "not found", body = AppResponseData<NoData>),
224 (status = 503, description = "agent runtime unavailable", body = AppResponseData<NoData>)
225 )
226)]
227pub async fn decide_agent_approval(
228 State(service): State<AgentsService>,
229 session: Session,
230 Path(path): Path<AgentApprovalPath>,
231 Json(cmd): Json<DecideAgentApprovalCmd>,
232) -> Result<AppResponseData<AgentApprovalDto>, ApiError> {
233 let uid = require_uid(&session)?;
234 let approval = service
235 .decide_agent_approval(
236 uid,
237 &path.space_name,
238 path.collection_id,
239 path.agent_id,
240 &path.approval_id,
241 cmd,
242 )
243 .await?;
244 Ok(AppResponseData::ok(approval))
245}
246
247#[utoipa::path(
248 get,
249 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/questions",
250 tag = "agents",
251 description = "列出智能体会话的提问请求。",
252 params(AgentPath),
253 responses(
254 (status = 200, description = "questions", body = AppResponseData<Vec<AgentQuestionDto>>),
255 (status = 404, description = "not found", body = AppResponseData<NoData>),
256 (status = 503, description = "agent runtime unavailable", body = AppResponseData<NoData>)
257 )
258)]
259pub async fn list_agent_questions(
260 State(service): State<AgentsService>,
261 session: Session,
262 Path(path): Path<AgentPath>,
263) -> Result<AppResponseData<Vec<AgentQuestionDto>>, ApiError> {
264 let uid = require_uid(&session)?;
265 let questions = service
266 .list_agent_questions(uid, &path.space_name, path.collection_id, path.agent_id)
267 .await?;
268 Ok(AppResponseData::ok(questions))
269}
270
271#[utoipa::path(
272 post,
273 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/questions/{question_id}/answer",
274 tag = "agents",
275 description = "回答智能体提出的问题。",
276 params(AgentQuestionPath),
277 request_body = AnswerAgentQuestionCmd,
278 responses(
279 (status = 200, description = "question answered", body = AppResponseData<AgentQuestionDto>),
280 (status = 400, description = "bad request", body = AppResponseData<NoData>),
281 (status = 404, description = "not found", body = AppResponseData<NoData>),
282 (status = 503, description = "agent runtime unavailable", body = AppResponseData<NoData>)
283 )
284)]
285pub async fn answer_agent_questions(
286 State(service): State<AgentsService>,
287 session: Session,
288 Path(path): Path<AgentQuestionPath>,
289 Json(cmd): Json<AnswerAgentQuestionCmd>,
290) -> Result<AppResponseData<AgentQuestionDto>, ApiError> {
291 let uid = require_uid(&session)?;
292 let question = service
293 .answer_agent_questions(
294 uid,
295 &path.space_name,
296 path.collection_id,
297 path.agent_id,
298 &path.question_id,
299 cmd,
300 )
301 .await?;
302 Ok(AppResponseData::ok(question))
303}
Deleted lib/agents/src/handlers/collections.rs
@@ -1,141 +0,0 @@
1use axum::extract::{Json, Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use jwt::Session;
4use jwt::share::session::require_uid;
5
6use crate::errors::ApiError;
7use crate::services::collections::{
8 AgentCollectionDto, AgentCollectionPage, AgentCollectionPath, CreateAgentCollectionCmd,
9 ListAgentCollectionQuery, UpdateAgentCollectionCmd,
10};
11use crate::services::service::AgentsService;
12use space::services::spaces::SpaceNamePath;
13
14#[utoipa::path(
15 post,
16 path = "/agents/{space_name}/collections",
17 tag = "agents",
18 description = "在空间内创建智能体集,并同步创建其目录树节点。",
19 params(SpaceNamePath),
20 request_body = CreateAgentCollectionCmd,
21 responses(
22 (status = 200, description = "collection created", body = AppResponseData<AgentCollectionDto>),
23 (status = 400, description = "bad request", body = AppResponseData<NoData>),
24 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
25 (status = 404, description = "not found", body = AppResponseData<NoData>),
26 (status = 409, description = "resource already exists", body = AppResponseData<NoData>),
27 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
28 )
29)]
30pub async fn create_agent_collection(
31 State(service): State<AgentsService>,
32 session: Session,
33 Path(path): Path<SpaceNamePath>,
34 Json(cmd): Json<CreateAgentCollectionCmd>,
35) -> Result<AppResponseData<AgentCollectionDto>, ApiError> {
36 let uid = require_uid(&session)?;
37 let dto = service
38 .create_agent_collection(uid, &path.space_name, cmd)
39 .await?;
40 Ok(AppResponseData::ok(dto))
41}
42
43#[utoipa::path(
44 get,
45 path = "/agents/{space_name}/collections",
46 tag = "agents",
47 description = "列出空间内的智能体集。",
48 params(SpaceNamePath, ListAgentCollectionQuery),
49 responses(
50 (status = 200, description = "collection page", body = AppResponseData<AgentCollectionPage>),
51 (status = 401, description = "authentication required", body = AppResponseData<NoData>),
52 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
53 )
54)]
55pub async fn list_agent_collections(
56 State(service): State<AgentsService>,
57 session: Session,
58 Path(path): Path<SpaceNamePath>,
59 Query(query): Query<ListAgentCollectionQuery>,
60) -> Result<AppResponseData<AgentCollectionPage>, ApiError> {
61 let uid = require_uid(&session)?;
62 let page = service
63 .list_agent_collections(uid, &path.space_name, query)
64 .await?;
65 Ok(AppResponseData::ok(page))
66}
67
68#[utoipa::path(
69 get,
70 path = "/agents/{space_name}/collections/{collection_id}",
71 tag = "agents",
72 description = "读取单个智能体集。",
73 params(AgentCollectionPath),
74 responses(
75 (status = 200, description = "collection", body = AppResponseData<AgentCollectionDto>),
76 (status = 404, description = "not found", body = AppResponseData<NoData>),
77 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
78 )
79)]
80pub async fn get_agent_collection(
81 State(service): State<AgentsService>,
82 session: Session,
83 Path(path): Path<AgentCollectionPath>,
84) -> Result<AppResponseData<AgentCollectionDto>, ApiError> {
85 let uid = require_uid(&session)?;
86 let dto = service
87 .get_agent_collection(uid, &path.space_name, path.collection_id)
88 .await?;
89 Ok(AppResponseData::ok(dto))
90}
91
92#[utoipa::path(
93 patch,
94 path = "/agents/{space_name}/collections/{collection_id}",
95 tag = "agents",
96 description = "更新智能体集名称、图标或描述。",
97 params(AgentCollectionPath),
98 request_body = UpdateAgentCollectionCmd,
99 responses(
100 (status = 200, description = "collection updated", body = AppResponseData<AgentCollectionDto>),
101 (status = 400, description = "bad request", body = AppResponseData<NoData>),
102 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
103 (status = 404, description = "not found", body = AppResponseData<NoData>)
104 )
105)]
106pub async fn update_agent_collection(
107 State(service): State<AgentsService>,
108 session: Session,
109 Path(path): Path<AgentCollectionPath>,
110 Json(cmd): Json<UpdateAgentCollectionCmd>,
111) -> Result<AppResponseData<AgentCollectionDto>, ApiError> {
112 let uid = require_uid(&session)?;
113 let dto = service
114 .update_agent_collection(uid, &path.space_name, path.collection_id, cmd)
115 .await?;
116 Ok(AppResponseData::ok(dto))
117}
118
119#[utoipa::path(
120 delete,
121 path = "/agents/{space_name}/collections/{collection_id}",
122 tag = "agents",
123 description = "删除智能体集及其下全部智能体,并尝试清理运行时会话。",
124 params(AgentCollectionPath),
125 responses(
126 (status = 200, description = "collection deleted", body = AppResponseData<NoData>),
127 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
128 (status = 404, description = "not found", body = AppResponseData<NoData>)
129 )
130)]
131pub async fn delete_agent_collection(
132 State(service): State<AgentsService>,
133 session: Session,
134 Path(path): Path<AgentCollectionPath>,
135) -> Result<AppResponseData<NoData>, ApiError> {
136 let uid = require_uid(&session)?;
137 service
138 .delete_agent_collection(uid, &path.space_name, path.collection_id)
139 .await?;
140 Ok(AppResponseData::ok(NoData))
141}
Deleted lib/agents/src/handlers/mod.rs
@@ -1,5 +0,0 @@
1pub mod agents;
2pub mod avatar;
3pub mod builder;
4pub mod chat;
5pub mod collections;
Deleted lib/agents/src/services/agents.rs
@@ -1,411 +0,0 @@
1use models::agent::agent_item as agent;
2use models::agent::agents;
3use sandbox_rpc::proto;
4use sea_orm::{
5 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
6 QuerySelect, Set, TransactionTrait,
7};
8use serde::{Deserialize, Serialize};
9use time::OffsetDateTime;
10use utoipa::{IntoParams, ToSchema};
11use uuid::Uuid;
12
13use crate::errors::AgentError;
14use crate::services::service::AgentsService;
15use crate::share::authz::require_collection_manage;
16use crate::share::lookup::{agent_context, collection_context, lookup_collection, space_context};
17
18#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
19pub struct AgentDto {
20 pub id: Uuid,
21 pub collection_id: Uuid,
22 pub number: i32,
23 pub author_id: i64,
24 pub author: Option<space::services::spaces::UserDto>,
25 pub name: String,
26 pub description: Option<String>,
27 pub instructions: Option<String>,
28 /// Session-scoped avatar URL when the agent has one uploaded.
29 pub avatar_url: Option<String>,
30 /// Conversation starters shown in the empty chat state (at most three).
31 pub starters: Vec<String>,
32 pub conversation_id: Option<String>,
33 pub last_run_id: Option<String>,
34 pub is_archived: bool,
35 pub created_at: OffsetDateTime,
36 pub updated_at: Option<OffsetDateTime>,
37}
38
39pub type AgentPage = export::pagination::Page<AgentDto>;
40
41#[derive(Debug, Clone, Deserialize, ToSchema)]
42pub struct CreateAgentCmd {
43 pub name: String,
44 #[serde(default)]
45 pub description: Option<String>,
46 #[serde(default)]
47 pub instructions: Option<String>,
48 #[serde(default)]
49 pub starters: Option<Vec<String>>,
50}
51
52#[derive(Debug, Clone, Deserialize, ToSchema)]
53pub struct UpdateAgentCmd {
54 #[serde(default)]
55 pub name: Option<String>,
56 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
57 pub description: Option<Option<String>>,
58 #[serde(default)]
59 pub starters: Option<Vec<String>>,
60}
61
62#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
63pub struct ListAgentQuery {
64 pub q: Option<String>,
65 pub limit: Option<u64>,
66 pub offset: Option<u64>,
67}
68
69#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
70pub struct AgentPath {
71 pub space_name: String,
72 pub collection_id: Uuid,
73 pub agent_id: Uuid,
74}
75
76impl AgentsService {
77 pub(crate) fn agent_dto(
78 row: agent::Model,
79 author: Option<space::services::spaces::UserDto>,
80 space_name: &str,
81 ) -> AgentDto {
82 let avatar_url = row
83 .avatar_url
84 .as_deref()
85 .filter(|url| !url.is_empty())
86 .map(|_| {
87 let version = row.updated_at.unwrap_or(row.created_at).unix_timestamp();
88 format!(
89 "/api/v1/agents/{space_name}/collections/{}/agents/{}/avatar?v={version}",
90 row.collection_id, row.id
91 )
92 });
93 let starters = starters_of(&row);
94 AgentDto {
95 id: row.id,
96 collection_id: row.collection_id,
97 number: row.autoincrement_id,
98 author_id: row.author_id,
99 author,
100 name: row.name,
101 description: row.description,
102 instructions: row.instructions,
103 avatar_url,
104 starters,
105 conversation_id: row.conversation_id,
106 last_run_id: row.last_run_id,
107 is_archived: row.is_archived,
108 created_at: row.created_at,
109 updated_at: row.updated_at,
110 }
111 }
112
113 #[tracing::instrument(
114 name = "agents.create_agent",
115 skip_all,
116 fields(caller = %caller, space_name = %space_name, collection_id = %collection_id)
117 )]
118 pub async fn create_agent(
119 &self,
120 caller: i64,
121 space_name: &str,
122 collection_id: Uuid,
123 cmd: CreateAgentCmd,
124 ) -> Result<AgentDto, AgentError> {
125 let space = space_context(self, caller, space_name).await?;
126 require_collection_manage(self, &space, caller).await?;
127 let collection = lookup_collection(self, &space, collection_id, caller).await?;
128
129 let name = cmd.name.trim();
130 if name.is_empty() || name.chars().count() > self.config().agents.max_agent_name_len {
131 return Err(AgentError::BadRequest(
132 "agent name is empty or too long".to_string(),
133 ));
134 }
135 let instructions = normalize_optional(cmd.instructions);
136 if let Some(ref text) = instructions
137 && text.chars().count() > self.config().agents.max_instructions_len
138 {
139 return Err(AgentError::BadRequest(
140 "agent instructions are too long".to_string(),
141 ));
142 }
143 let description = normalize_optional(cmd.description);
144 let starters = normalize_starters(cmd.starters)?;
145
146 let rpc = self.rpc()?;
147
148 let now = OffsetDateTime::now_utc();
149 let agent_id = Uuid::now_v7();
150 let inserted = {
151 let txn = self.db().write().begin().await?;
152 let _ = agents::Entity::find_by_id(collection.id)
153 .lock_exclusive()
154 .one(&txn)
155 .await?;
156 let max_row = agent::Entity::find()
157 .filter(agent::Column::CollectionId.eq(collection.id))
158 .order_by_desc(agent::Column::AutoincrementId)
159 .one(&txn)
160 .await?;
161 let next_number = max_row.map(|row| row.autoincrement_id + 1).unwrap_or(1);
162
163 let row = agent::ActiveModel {
164 id: Set(agent_id),
165 collection_id: Set(collection.id),
166 autoincrement_id: Set(next_number),
167 author_id: Set(caller),
168 name: Set(name.to_string()),
169 description: Set(description.clone()),
170 instructions: Set(instructions.clone()),
171 avatar_url: Set(None),
172 starters: Set(Some(serde_json::json!(starters))),
173 conversation_id: Set(None),
174 last_run_id: Set(None),
175 is_archived: Set(false),
176 created_at: Set(now),
177 updated_at: Set(None),
178 };
179 let inserted = row.insert(&txn).await?;
180 txn.commit().await?;
181 inserted
182 };
183
184 let spec = proto::AgentSpecMessage {
185 name: Some(name.to_string()),
186 description,
187 instructions,
188 space: Some(space.id.to_string()),
189 user_id: Some(caller.to_string()),
190 ..Default::default()
191 };
192
193 let session = match rpc
194 .create_session(&space.id.to_string(), &caller.to_string(), name, spec)
195 .await
196 {
197 Ok(session) => session,
198 Err(error) => {
199 if let Err(cleanup) = agent::Entity::delete_by_id(inserted.id)
200 .exec(self.db().write())
201 .await
202 {
203 tracing::warn!(%cleanup, agent_id = %inserted.id, "orphan agent cleanup failed");
204 }
205 return Err(error.into());
206 }
207 };
208
209 let mut am: agent::ActiveModel = inserted.into();
210 am.conversation_id = Set(Some(session.conversation_id));
211 am.updated_at = Set(Some(OffsetDateTime::now_utc()));
212 let updated = am.update(self.db().write()).await?;
213
214 let users = self.load_user_briefs(&[updated.author_id]).await?;
215 let author = users.get(&updated.author_id).cloned();
216 Ok(Self::agent_dto(updated, author, &space.lower_name))
217 }
218
219 pub async fn list_agents(
220 &self,
221 caller: i64,
222 space_name: &str,
223 collection_id: Uuid,
224 q: ListAgentQuery,
225 ) -> Result<AgentPage, AgentError> {
226 let (space, collection) =
227 collection_context(self, caller, space_name, collection_id).await?;
228 let (limit, offset) = self.paginate(q.limit, q.offset);
229
230 let mut select =
231 agent::Entity::find().filter(agent::Column::CollectionId.eq(collection.id));
232 if let Some(ref search) = q.q {
233 let kw = search.trim();
234 if !kw.is_empty() {
235 select = select.filter(agent::Column::Name.contains(kw));
236 }
237 }
238 select = select.order_by_asc(agent::Column::AutoincrementId);
239
240 let total = select.clone().count(self.db().read()).await? as i64;
241 let rows = select
242 .limit(limit)
243 .offset(offset)
244 .all(self.db().read())
245 .await?;
246
247 let author_ids: Vec<i64> = rows.iter().map(|row| row.author_id).collect();
248 let users = self.load_user_briefs(&author_ids).await?;
249 let items = rows
250 .into_iter()
251 .map(|row| {
252 let author = users.get(&row.author_id).cloned();
253 Self::agent_dto(row, author, &space.lower_name)
254 })
255 .collect();
256
257 Ok(AgentPage {
258 items,
259 total,
260 limit,
261 offset,
262 })
263 }
264
265 pub async fn get_agent(
266 &self,
267 caller: i64,
268 space_name: &str,
269 collection_id: Uuid,
270 agent_id: Uuid,
271 ) -> Result<AgentDto, AgentError> {
272 let (space, _, row) =
273 agent_context(self, caller, space_name, collection_id, agent_id).await?;
274 let users = self.load_user_briefs(&[row.author_id]).await?;
275 let author = users.get(&row.author_id).cloned();
276 Ok(Self::agent_dto(row, author, &space.lower_name))
277 }
278
279 #[tracing::instrument(
280 name = "agents.update_agent",
281 skip_all,
282 fields(
283 caller = %caller,
284 space_name = %space_name,
285 collection_id = %collection_id,
286 agent_id = %agent_id
287 )
288 )]
289 pub async fn update_agent(
290 &self,
291 caller: i64,
292 space_name: &str,
293 collection_id: Uuid,
294 agent_id: Uuid,
295 cmd: UpdateAgentCmd,
296 ) -> Result<AgentDto, AgentError> {
297 let space = space_context(self, caller, space_name).await?;
298 require_collection_manage(self, &space, caller).await?;
299 let collection = lookup_collection(self, &space, collection_id, caller).await?;
300 let row =
301 crate::share::lookup::lookup_agent(self, &collection, &agent_id.to_string()).await?;
302
303 let mut am: agent::ActiveModel = row.into();
304 if let Some(new_name) = cmd.name {
305 let name = new_name.trim();
306 if name.is_empty() || name.chars().count() > self.config().agents.max_agent_name_len {
307 return Err(AgentError::BadRequest(
308 "agent name is empty or too long".to_string(),
309 ));
310 }
311 am.name = Set(name.to_string());
312 }
313 if let Some(description) = cmd.description {
314 am.description = Set(normalize_optional(description));
315 }
316 if let Some(starters) = cmd.starters {
317 am.starters = Set(Some(serde_json::json!(normalize_starters(Some(starters))?)));
318 }
319 am.updated_at = Set(Some(OffsetDateTime::now_utc()));
320 let updated = am.update(self.db().write()).await?;
321
322 let users = self.load_user_briefs(&[updated.author_id]).await?;
323 let author = users.get(&updated.author_id).cloned();
324 Ok(Self::agent_dto(updated, author, &space.lower_name))
325 }
326
327 #[tracing::instrument(
328 name = "agents.delete_agent",
329 skip_all,
330 fields(
331 caller = %caller,
332 space_name = %space_name,
333 collection_id = %collection_id,
334 agent_id = %agent_id
335 )
336 )]
337 pub async fn delete_agent(
338 &self,
339 caller: i64,
340 space_name: &str,
341 collection_id: Uuid,
342 agent_id: Uuid,
343 ) -> Result<(), AgentError> {
344 let space = space_context(self, caller, space_name).await?;
345 require_collection_manage(self, &space, caller).await?;
346 let (_, _, row) = agent_context(self, caller, space_name, collection_id, agent_id).await?;
347
348 if let (Some(rpc), Some(conversation_id)) = (self.rpc_opt(), row.conversation_id.as_deref())
349 && let Err(error) = rpc
350 .delete_session(&space.id.to_string(), &caller.to_string(), conversation_id)
351 .await
352 {
353 tracing::warn!(%error, agent_id = %row.id, "sandbox session cleanup failed");
354 }
355
356 agent::Entity::delete_by_id(row.id)
357 .exec(self.db().write())
358 .await?;
359 Ok(())
360 }
361}
362
363/// Conversation starters are a UI affordance: keep at most three short lines.
364const MAX_STARTERS: usize = 3;
365const MAX_STARTER_CHARS: usize = 120;
366
367fn normalize_starters(raw: Option<Vec<String>>) -> Result<Vec<String>, AgentError> {
368 let mut starters = Vec::new();
369 for item in raw.unwrap_or_default() {
370 let text = item.trim();
371 if text.is_empty() {
372 continue;
373 }
374 if text.chars().count() > MAX_STARTER_CHARS {
375 return Err(AgentError::BadRequest(
376 "conversation starter is too long".to_string(),
377 ));
378 }
379 starters.push(text.to_string());
380 if starters.len() > MAX_STARTERS {
381 return Err(AgentError::BadRequest(
382 "at most three conversation starters are supported".to_string(),
383 ));
384 }
385 }
386 Ok(starters)
387}
388
389fn starters_of(row: &agent::Model) -> Vec<String> {
390 row.starters
391 .as_ref()
392 .and_then(|value| value.as_array())
393 .map(|items| {
394 items
395 .iter()
396 .filter_map(|item| item.as_str().map(str::to_string))
397 .collect()
398 })
399 .unwrap_or_default()
400}
401
402fn normalize_optional(value: Option<String>) -> Option<String> {
403 value.and_then(|text| {
404 let trimmed = text.trim();
405 if trimmed.is_empty() {
406 None
407 } else {
408 Some(trimmed.to_string())
409 }
410 })
411}
Deleted lib/agents/src/services/avatar.rs
@@ -1,136 +0,0 @@
1use sea_orm::{ActiveModelTrait, Set};
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5use models::agent::agent_item as agent;
6
7use crate::errors::AgentError;
8use crate::services::agents::AgentDto;
9use crate::services::service::AgentsService;
10use crate::share::authz::require_collection_manage;
11use crate::share::lookup::agent_context;
12
13pub const MAX_AGENT_AVATAR_BYTES: usize = 2 * 1024 * 1024;
14const AGENT_AVATAR_PREFIX: &str = "agent-avatars";
15const AGENT_AVATAR_URL_TTL_SECS: u32 = 6 * 60 * 60;
16
17fn avatar_cache_key(agent_id: Uuid) -> String {
18 format!("agent:avatar:url:{agent_id}")
19}
20
21impl AgentsService {
22 /// Stores the raw avatar bytes in object storage and points the agent row at
23 /// the new object; returns the refreshed agent DTO.
24 #[tracing::instrument(
25 name = "agents.upload_agent_avatar",
26 skip_all,
27 fields(
28 caller = %caller,
29 space_name = %space_name,
30 collection_id = %collection_id,
31 agent_id = %agent_id
32 )
33 )]
34 pub async fn upload_agent_avatar(
35 &self,
36 caller: i64,
37 space_name: &str,
38 collection_id: Uuid,
39 agent_id: Uuid,
40 content_type: &str,
41 bytes: &[u8],
42 ) -> Result<AgentDto, AgentError> {
43 let (space, _, row) =
44 agent_context(self, caller, space_name, collection_id, agent_id).await?;
45 require_collection_manage(self, &space, caller).await?;
46 if bytes.is_empty() {
47 return Err(AgentError::BadRequest("avatar image is empty".to_string()));
48 }
49 if bytes.len() > MAX_AGENT_AVATAR_BYTES {
50 return Err(AgentError::BadRequest(
51 "avatar image is larger than 2 MB".to_string(),
52 ));
53 }
54 let clean = content_type
55 .split(';')
56 .next()
57 .unwrap_or(content_type)
58 .trim()
59 .to_lowercase();
60 let extension = match clean.as_str() {
61 "image/jpeg" | "image/jpg" => "jpg",
62 "image/png" => "png",
63 "image/webp" => "webp",
64 "image/gif" => "gif",
65 _ => {
66 return Err(AgentError::BadRequest(
67 "unsupported avatar image type".to_string(),
68 ));
69 }
70 };
71 let key = format!("{AGENT_AVATAR_PREFIX}/{agent_id}.{extension}");
72 self.state
73 .storage
74 .client()
75 .put_with_content_type(&key, bytes, content_type)
76 .await
77 .map_err(|error| AgentError::Internal(error.to_string()))?;
78
79 let mut am: agent::ActiveModel = row.into();
80 am.avatar_url = Set(Some(format!("storage://{key}")));
81 am.updated_at = Set(Some(OffsetDateTime::now_utc()));
82 let updated = am.update(self.db().write()).await?;
83 if let Err(error) =
84 export::redis::delete(&self.state.redis, &avatar_cache_key(agent_id)).await
85 {
86 tracing::warn!(%error, "failed to drop cached agent avatar url");
87 }
88
89 let users = self.load_user_briefs(&[updated.author_id]).await?;
90 let author = users.get(&updated.author_id).cloned();
91 Ok(Self::agent_dto(updated, author, &space.lower_name))
92 }
93
94 /// Presigned URL for the agent avatar; cached so repeated avatar renders do
95 /// not re-sign on every request.
96 pub async fn agent_avatar_url(
97 &self,
98 caller: i64,
99 space_name: &str,
100 collection_id: Uuid,
101 agent_id: Uuid,
102 ) -> Result<String, AgentError> {
103 let (_, _, row) = agent_context(self, caller, space_name, collection_id, agent_id).await?;
104 let locator = row
105 .avatar_url
106 .filter(|url| !url.is_empty())
107 .ok_or(AgentError::NotFound)?;
108 let key = locator
109 .strip_prefix("storage://")
110 .ok_or(AgentError::NotFound)?;
111
112 let cache_key = avatar_cache_key(agent_id);
113 match export::redis::get_string(&self.state.redis, &cache_key).await {
114 Ok(Some(url)) => return Ok(url),
115 Ok(None) => {}
116 Err(error) => tracing::warn!(%error, "failed to read agent avatar url cache"),
117 }
118 let url = self
119 .state
120 .storage
121 .client()
122 .presign_get(key, AGENT_AVATAR_URL_TTL_SECS)
123 .map_err(|error| AgentError::Internal(error.to_string()))?;
124 if let Err(error) = export::redis::set_string(
125 &self.state.redis,
126 &cache_key,
127 &url,
128 AGENT_AVATAR_URL_TTL_SECS as u64,
129 )
130 .await
131 {
132 tracing::warn!(%error, "failed to cache agent avatar url");
133 }
134 Ok(url)
135 }
136}
Deleted lib/agents/src/services/builder.rs
@@ -1,351 +0,0 @@
1use serde::{Deserialize, Serialize};
2use utoipa::ToSchema;
3use uuid::Uuid;
4
5use crate::errors::AgentError;
6use crate::services::service::AgentsService;
7use crate::share::authz::require_collection_manage;
8use crate::share::lookup::{collection_context, space_context};
9
10/// One turn of the builder conversation (client-owned history).
11#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
12pub struct BuilderMessage {
13 pub role: String,
14 pub content: String,
15}
16
17#[derive(Debug, Clone, Deserialize, ToSchema)]
18pub struct BuilderChatCmd {
19 #[serde(default)]
20 pub messages: Vec<BuilderMessage>,
21}
22
23/// Draft the builder proposes; every field is optional and replaces the matching
24/// form field on the client when present.
25#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
26pub struct BuilderDraft {
27 #[serde(default)]
28 pub name: Option<String>,
29 #[serde(default)]
30 pub description: Option<String>,
31 #[serde(default)]
32 pub instructions: Option<String>,
33 #[serde(default)]
34 pub starters: Vec<String>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
38pub struct BuilderChatResult {
39 pub reply: String,
40 pub draft: BuilderDraft,
41}
42
43const MAX_BUILDER_MESSAGES: usize = 60;
44const MAX_BUILDER_CHARS: usize = 24_000;
45
46const BUILDER_SYSTEM_PROMPT: &str = r#"# 角色
47
48你是 GitData 平台的「智能体构建器(Agent Builder)」。
49
50你的职责是通过自然语言对话,帮助用户创建、完善和修改 AI 智能体,并持续维护一份结构化、可直接使用的智能体配置草稿。
51
52你不是用户正在创建的智能体,而是负责设计和配置该智能体的助手。
53
54# 核心目标
55
561. **理解需求**:识别用户希望智能体完成的任务、工作方式、行为偏好及限制。
572. **生成配置**:将自然语言需求转化为清晰、具体、可执行的智能体配置。
583. **增量编辑**:根据用户的最新要求修改现有草稿,保留未要求修改的内容。
594. **主动澄清**:只在关键信息不足时提问,避免不必要的反复确认。
605. **持续完善**:随着对话推进,逐步提高配置的准确性与完整性。
61
62# 工作流程
63
64## 1. 理解用户意图
65
66每次收到用户消息时,先判断其属于:
67
68- 创建:用户希望创建一个新智能体。
69- 修改:用户希望调整已有配置的某个部分。
70- 补充:用户提供新的职责、约束或工作要求。
71- 澄清:用户回答之前提出的问题。
72- 重构:用户明确要求重新设计整个智能体。
73
74结合对话上下文理解用户意图,支持省略、代词引用和非结构化描述。
75
76## 2. 维护配置草稿
77
78始终维护完整的 `draft` 对象。
79
80**首次创建时:**
81- 根据用户已提供的信息生成所有能够确定的字段。
82- 对非关键细节采用合理默认值,避免过度询问。
83- 不得虚构用户未提及的外部系统、工具权限或业务事实。
84- 如果连智能体的基本用途都无法判断,使用「待配置智能体」作为临时名称,提供通用描述和基础指令,并询问其核心用途。
85
86**后续对话时:**
87- 以上一版 `draft` 为基础,而不是重新生成整个配置。
88- 用户最新明确要求优先于历史配置。
89- 仅修改用户要求调整的字段。
90- 用户没有要求修改的字段,必须保留原有内容,不得擅自简化、覆盖、删除或重新措辞。
91- 对于新增要求,将其合理整合进对应字段,避免重复或产生矛盾。
92- 用户明确要求删除某项内容时,应从对应字段中移除。
93- 只有用户明确要求整体重构时,才可以重新生成全部字段。
94
95## 3. 判断是否需要澄清
96
97遵循「优先生成,必要时提问」原则。
98
99可以根据用户描述合理确定的内容,直接生成或更新配置,不需要确认。
100
101当缺少的信息会直接影响智能体的核心职责、工作边界或执行方式,且无法安全推断时,提出**一个最关键的问题**。
102
103要求:
104- 每次最多提出一个问题。
105- 问题必须具体、简短、容易回答。
106- 优先询问对配置影响最大的未知信息。
107- 不要重复询问用户已回答的问题。
108- 即使需要澄清,也要输出目前能够确定的完整草稿。
109- 不要为了补齐非关键细节而阻塞配置生成。
110
111# 字段规范
112
113## name
114
115智能体的名称。
116
117- 2–20 个字符。
118- 使用简洁、具体、易理解的名称。
119- 优先体现核心职责或专业领域。
120- 避免「超级」「万能」「最强」等空泛修饰。
121- 用户明确指定合法名称时,优先使用原名称。
122
123## description
124
125一句话说明智能体的核心用途。
126
127- 不超过 60 个字符。
128- 清楚表达「做什么」以及必要时「为谁服务」。
129- 优先描述实际能力和业务价值。
130- 避免宣传性、模糊或重复性描述。
131- 不要罗列过多功能。
132
133## instructions
134
135提供给最终智能体的系统提示词。
136
137使用中文编写,确保独立、明确、具体、可执行。
138
139根据实际需求,自然组织以下内容:
140
1411. **角色定位**:智能体的身份、专业领域和主要职责。
1422. **核心任务**:应该完成哪些任务,以及预期目标。
1433. **工作方式**:如何理解请求、处理任务、组织结果及处理异常。
1444. **输出要求**:回答风格、内容结构、详细程度或指定格式。
1455. **关注重点**:专业标准、质量要求及任务优先级。
1466. **行为边界**:哪些行为应避免,哪些情况需要澄清、说明限制或拒绝。
147
148编写原则:
149- 使用直接面向最终智能体的指令,例如「你是一名……」。
150- 将抽象需求转换为可执行的行为规则。
151- 根据任务复杂度调整提示词长度,避免不必要的冗长。
152- 不要机械添加与用户需求无关的规则。
153- 不得假设智能体拥有未明确提供的工具、数据源、外部服务或操作权限。
154- 对存在不确定性的信息,要求智能体明确说明,不得编造。
155- 避免指令冲突、职责重复以及过度限制。
156
157## starters
158
159提供用户可以直接点击或发送的对话开场建议。
160
161- 最多 3 条。
162- 每条是一句自然、具体的中文问句。
163- 使用最终智能体用户的视角提出任务。
164- 与智能体核心能力直接相关。
165- 尽量覆盖不同的典型使用场景。
166- 避免泛泛的「你能做什么?」等问题。
167- 如果尚无法确定智能体用途,可以返回空数组。
168
169# 回复规范
170
171`reply` 用于向用户说明本轮处理结果。
172
173要求:
174- 使用自然、简洁、友好的中文。
175- 总长度控制在 1–3 句。
176- 配置已更新时,简要说明主要修改内容。
177- 首次生成配置时,告知草稿已生成。
178- 需要澄清时,简述当前状态并提出一个最关键的问题。
179- 用户仅要求修改某字段时,不要声称修改了其他字段。
180- 不要重复展示 `draft` 中已有的大段内容。
181- 不要声称已经保存、发布或部署智能体,除非确实有相应操作结果。
182
183# 输出格式
184
185每次回复必须输出且仅输出一个合法 JSON 对象,结构如下:
186
187```json
188{
189 "reply": "面向用户的简短回复",
190 "draft": {
191 "name": "智能体名称",
192 "description": "智能体的一句话描述",
193 "instructions": "完整的系统提示词",
194 "starters": [
195 "示例问题一?",
196 "示例问题二?",
197 "示例问题三?"
198 ]
199 }
200}
201```
202
203严格遵守以下规则:
204
2051. 顶层必须且只能包含 `reply` 和 `draft`。
2062. `draft` 必须且只能包含 `name`、`description`、`instructions`、`starters`。
2073. 所有字段始终存在,不得省略。
2084. `reply`、`name`、`description`、`instructions` 必须是字符串。
2095. `starters` 必须是字符串数组,长度为 0–3。
2106. 输出必须是合法 JSON,正确转义引号、换行和特殊字符。
2117. 不得输出 Markdown 代码块、注释、思考过程或 JSON 以外的任何文字。
2128. 提交输出前,检查字段完整性、长度要求、内容一致性和 JSON 合法性。
213
214# 优先级原则
215
216发生冲突时,遵循以下优先级:
217
2181. 系统级约束及安全边界。
2192. 用户本轮明确提出的创建或修改要求。
2203. 已有草稿中未被修改的内容。
2214. 根据用户意图推断的合理默认值。
222
223**最终原则:以最少的必要提问,帮助用户持续构建高质量智能体;以最小的必要修改,维护稳定且完整的配置草稿。**"#;
224
225impl AgentsService {
226 /// One step of the agent builder: runs the platform model over the draft
227 /// conversation and returns the assistant reply plus the updated draft.
228 #[tracing::instrument(
229 name = "agents.builder_chat",
230 skip_all,
231 fields(caller = %caller, space_name = %space_name, collection_id = %collection_id)
232 )]
233 pub async fn builder_chat(
234 &self,
235 caller: i64,
236 space_name: &str,
237 collection_id: Uuid,
238 cmd: BuilderChatCmd,
239 ) -> Result<BuilderChatResult, AgentError> {
240 let space = space_context(self, caller, space_name).await?;
241 require_collection_manage(self, &space, caller).await?;
242 let _ = collection_context(self, caller, space_name, collection_id).await?;
243
244 if cmd
245 .messages
246 .iter()
247 .all(|message| message.content.trim().is_empty())
248 {
249 return Err(AgentError::BadRequest(
250 "builder conversation is empty".to_string(),
251 ));
252 }
253 if cmd.messages.len() > MAX_BUILDER_MESSAGES {
254 return Err(AgentError::BadRequest(
255 "builder conversation is too long".to_string(),
256 ));
257 }
258 let mut transcript = String::new();
259 for message in &cmd.messages {
260 let text = message.content.trim();
261 if text.is_empty() {
262 continue;
263 }
264 if transcript.chars().count() > MAX_BUILDER_CHARS {
265 return Err(AgentError::BadRequest(
266 "builder conversation is too long".to_string(),
267 ));
268 }
269 let who = if message.role == "assistant" {
270 "构建器"
271 } else {
272 "用户"
273 };
274 transcript.push_str(who);
275 transcript.push(':');
276 transcript.push_str(text);
277 transcript.push('\n');
278 }
279
280 if !self.config().ai.enabled {
281 return Err(AgentError::RuntimeUnavailable(
282 "ai is not configured".to_string(),
283 ));
284 }
285 let client = agent::client::AgentClient::new(&self.config().ai)
286 .map_err(|error| AgentError::RuntimeUnavailable(error.to_string()))?;
287 let user_prompt = format!(
288 "构建对话记录:\n{transcript}\n请回复用户(reply),并输出更新后的完整草稿(draft)。"
289 );
290 let completion = client
291 .complete(BUILDER_SYSTEM_PROMPT, &user_prompt, true)
292 .await
293 .map_err(builder_error)?;
294 let value = agent::client::extract_json(&completion.content).map_err(builder_error)?;
295
296 let reply = value
297 .get("reply")
298 .and_then(|item| item.as_str())
299 .map(str::trim)
300 .filter(|text| !text.is_empty())
301 .unwrap_or("已更新右侧配置,可以继续补充需求。")
302 .to_string();
303 Ok(BuilderChatResult {
304 reply,
305 draft: parse_draft(value.get("draft")),
306 })
307 }
308}
309
310fn parse_draft(value: Option<&serde_json::Value>) -> BuilderDraft {
311 let Some(map) = value.and_then(|item| item.as_object()) else {
312 return BuilderDraft::default();
313 };
314 let text = |key: &str| {
315 map.get(key)
316 .and_then(|item| item.as_str())
317 .map(str::trim)
318 .filter(|item| !item.is_empty())
319 .map(str::to_string)
320 };
321 let starters = map
322 .get("starters")
323 .and_then(|item| item.as_array())
324 .map(|items| {
325 items
326 .iter()
327 .filter_map(|item| item.as_str())
328 .map(str::trim)
329 .filter(|item| !item.is_empty())
330 .map(|item| item.chars().take(120).collect::<String>())
331 .take(3)
332 .collect()
333 })
334 .unwrap_or_default();
335 BuilderDraft {
336 name: text("name"),
337 description: text("description"),
338 instructions: text("instructions"),
339 starters,
340 }
341}
342
343fn builder_error(error: agent::client::AgentError) -> AgentError {
344 match error {
345 agent::client::AgentError::NotConfigured(message) => {
346 AgentError::RuntimeUnavailable(message)
347 }
348 agent::client::AgentError::Rejected { .. } => AgentError::BadRequest(error.to_string()),
349 other => AgentError::Internal(other.to_string()),
350 }
351}
Deleted lib/agents/src/services/chat.rs
@@ -1,740 +0,0 @@
1use std::collections::HashMap;
2
3use sandbox_rpc::proto;
4use sea_orm::{ActiveModelTrait, EntityTrait, Set};
5use serde::{Deserialize, Serialize};
6use time::OffsetDateTime;
7use utoipa::{IntoParams, ToSchema};
8use uuid::Uuid;
9
10use crate::errors::AgentError;
11use crate::services::service::AgentsService;
12use crate::share::lookup::agent_context;
13use models::agent::agent_item as agent;
14use models::space::spaces;
15
16#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
17pub struct AgentRunDto {
18 pub run_id: String,
19 pub conversation_id: String,
20 pub status: String,
21 pub output: Option<String>,
22 pub error: Option<String>,
23 pub input_tokens: i64,
24 pub output_tokens: i64,
25 pub total_tokens: i64,
26 pub created_at: i64,
27 pub finished_at: Option<i64>,
28}
29
30impl From<proto::Run> for AgentRunDto {
31 fn from(run: proto::Run) -> Self {
32 Self {
33 run_id: run.run_id,
34 conversation_id: run.conversation_id,
35 status: run.status,
36 output: run.output,
37 error: run.error,
38 input_tokens: run.input_tokens,
39 output_tokens: run.output_tokens,
40 total_tokens: run.total_tokens,
41 created_at: run.created_at,
42 finished_at: run.finished_at,
43 }
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
48pub struct AgentMessageDto {
49 pub message_id: String,
50 pub conversation_id: String,
51 pub run_id: Option<String>,
52 pub role: String,
53 pub kind: String,
54 pub status: String,
55 pub content: String,
56 pub sequence: i64,
57 pub created_at: i64,
58 /// Present on tool messages: links the rendered result back to the tool
59 /// call (command, duration, status) recorded by the runtime.
60 pub external_call_id: Option<String>,
61}
62
63impl From<proto::Message> for AgentMessageDto {
64 fn from(message: proto::Message) -> Self {
65 Self {
66 message_id: message.message_id,
67 conversation_id: message.conversation_id,
68 run_id: message.run_id,
69 role: message.role,
70 kind: message.kind,
71 status: message.status,
72 content: message.content,
73 sequence: message.sequence,
74 created_at: message.created_at,
75 external_call_id: message.external_call_id,
76 }
77 }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
81pub struct AgentToolCallDto {
82 pub tool_call_id: String,
83 pub conversation_id: String,
84 pub run_id: String,
85 pub call_id: String,
86 pub name: String,
87 pub kind: String,
88 pub status: String,
89 pub input: Option<String>,
90 pub duration_ms: Option<i64>,
91 pub created_at: i64,
92 pub finished_at: Option<i64>,
93}
94
95impl From<proto::ToolCall> for AgentToolCallDto {
96 fn from(call: proto::ToolCall) -> Self {
97 Self {
98 tool_call_id: call.tool_call_id,
99 conversation_id: call.conversation_id,
100 run_id: call.run_id,
101 call_id: call.call_id,
102 name: call.name,
103 kind: call.kind,
104 status: call.status,
105 input: call.input,
106 duration_ms: call.duration_ms,
107 created_at: call.created_at,
108 finished_at: call.finished_at,
109 }
110 }
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
114pub struct AgentApprovalDto {
115 pub approval_id: String,
116 pub run_id: String,
117 pub conversation_id: String,
118 pub action: String,
119 pub tool_name: Option<String>,
120 pub status: String,
121 pub scope: String,
122 pub reason: Option<String>,
123 pub decision_reason: Option<String>,
124 pub requested_at: i64,
125 pub decided_at: Option<i64>,
126}
127
128impl From<proto::Approval> for AgentApprovalDto {
129 fn from(approval: proto::Approval) -> Self {
130 Self {
131 approval_id: approval.approval_id,
132 run_id: approval.run_id,
133 conversation_id: approval.conversation_id,
134 action: approval.action,
135 tool_name: approval.tool_name,
136 status: approval.status,
137 scope: approval.scope,
138 reason: approval.reason,
139 decision_reason: approval.decision_reason,
140 requested_at: approval.requested_at,
141 decided_at: approval.decided_at,
142 }
143 }
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
147pub struct AgentQuestionOptionDto {
148 pub label: String,
149 pub description: Option<String>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
153pub struct AgentQuestionItemDto {
154 pub item_id: String,
155 pub question: String,
156 pub header: Option<String>,
157 pub options: Vec<AgentQuestionOptionDto>,
158 pub multiple: bool,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
162pub struct AgentQuestionAnswerDto {
163 pub item_id: String,
164 pub selected: Vec<String>,
165 pub text: Option<String>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
169pub struct AgentQuestionDto {
170 pub question_id: String,
171 pub run_id: String,
172 pub conversation_id: String,
173 pub status: String,
174 pub questions: Vec<AgentQuestionItemDto>,
175 pub answers: Vec<AgentQuestionAnswerDto>,
176 pub answered_by: Option<String>,
177 pub requested_at: i64,
178 pub answered_at: Option<i64>,
179}
180
181impl From<proto::Question> for AgentQuestionDto {
182 fn from(question: proto::Question) -> Self {
183 Self {
184 question_id: question.question_id,
185 run_id: question.run_id,
186 conversation_id: question.conversation_id,
187 status: question.status,
188 questions: question
189 .questions
190 .into_iter()
191 .map(|item| AgentQuestionItemDto {
192 item_id: item.item_id,
193 question: item.question,
194 header: item.header,
195 options: item
196 .options
197 .into_iter()
198 .map(|option| AgentQuestionOptionDto {
199 label: option.label,
200 description: option.description,
201 })
202 .collect(),
203 multiple: item.multiple,
204 })
205 .collect(),
206 answers: question
207 .answers
208 .into_iter()
209 .map(|answer| AgentQuestionAnswerDto {
210 item_id: answer.item_id,
211 selected: answer.selected,
212 text: answer.text,
213 })
214 .collect(),
215 answered_by: question.answered_by,
216 requested_at: question.requested_at,
217 answered_at: question.answered_at,
218 }
219 }
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
223pub struct AgentStreamTokenDto {
224 /// Short-lived JWT for the runtime's fanout SSE (`?token=`).
225 pub token: String,
226 pub expires_at: i64,
227 /// Browser-facing base of the fanout service, e.g. `/sandbox`.
228 pub fanout_endpoint: String,
229 pub conversation_id: String,
230}
231
232#[derive(Debug, Clone, Deserialize, ToSchema)]
233pub struct SendAgentMessageCmd {
234 pub text: String,
235 /// Attach the platform's own MCP endpoint (GitData tools) to this run.
236 #[serde(default)]
237 pub mcp: bool,
238 /// Bind a repository (name inside the space) to the run's context.
239 #[serde(default)]
240 pub repo: Option<String>,
241}
242
243#[derive(Debug, Clone, Deserialize, ToSchema)]
244pub struct DecideAgentApprovalCmd {
245 pub decision: String,
246 #[serde(default)]
247 pub reason: Option<String>,
248 #[serde(default)]
249 pub scope: Option<String>,
250}
251
252#[derive(Debug, Clone, Deserialize, ToSchema)]
253pub struct AgentQuestionAnswerCmd {
254 pub item_id: String,
255 #[serde(default)]
256 pub selected: Vec<String>,
257 #[serde(default)]
258 pub text: Option<String>,
259}
260
261#[derive(Debug, Clone, Deserialize, ToSchema)]
262pub struct AnswerAgentQuestionCmd {
263 pub answers: Vec<AgentQuestionAnswerCmd>,
264}
265
266#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
267pub struct ListAgentMessageQuery {
268 pub after_sequence: Option<i64>,
269 pub limit: Option<u64>,
270 pub run_id: Option<String>,
271}
272
273#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
274pub struct ListAgentToolCallQuery {
275 pub limit: Option<u64>,
276}
277
278#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
279pub struct AgentRunPath {
280 pub space_name: String,
281 pub collection_id: Uuid,
282 pub agent_id: Uuid,
283 pub run_id: String,
284}
285
286#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
287pub struct AgentApprovalPath {
288 pub space_name: String,
289 pub collection_id: Uuid,
290 pub agent_id: Uuid,
291 pub approval_id: String,
292}
293
294#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
295pub struct AgentQuestionPath {
296 pub space_name: String,
297 pub collection_id: Uuid,
298 pub agent_id: Uuid,
299 pub question_id: String,
300}
301
302impl AgentsService {
303 /// Mint the browser's fanout stream grant. The web client then connects to
304 /// the runtime's SSE endpoints directly (`/events`, `/tokens`) with `?token=`;
305 /// the main API is not in the data path.
306 #[tracing::instrument(
307 name = "agents.create_agent_stream_token",
308 skip_all,
309 fields(
310 caller = %caller,
311 space_name = %space_name,
312 collection_id = %collection_id,
313 agent_id = %agent_id
314 )
315 )]
316 pub async fn create_agent_stream_token(
317 &self,
318 caller: i64,
319 space_name: &str,
320 collection_id: Uuid,
321 agent_id: Uuid,
322 ) -> Result<AgentStreamTokenDto, AgentError> {
323 let (space, _, row) =
324 agent_context(self, caller, space_name, collection_id, agent_id).await?;
325 let conversation_id = row
326 .conversation_id
327 .ok_or_else(|| AgentError::BadRequest("agent has no runtime session".to_string()))?;
328
329 let rpc = self.rpc()?;
330 let fanout_endpoint = rpc.fanout_endpoint().trim();
331 if fanout_endpoint.is_empty() {
332 return Err(AgentError::RuntimeUnavailable(
333 "sandbox event stream is not configured".to_string(),
334 ));
335 }
336
337 let issued = rpc
338 .issue_token(
339 &space.id.to_string(),
340 &caller.to_string(),
341 vec![sandbox_rpc::SCOPE_STREAM.to_string()],
342 Some(rpc.stream_token_ttl_secs()),
343 )
344 .await?;
345
346 Ok(AgentStreamTokenDto {
347 token: issued.token,
348 expires_at: issued.expires_at,
349 fanout_endpoint: fanout_endpoint.to_string(),
350 conversation_id,
351 })
352 }
353
354 async fn conversation(
355 &self,
356 caller: i64,
357 space_name: &str,
358 collection_id: Uuid,
359 agent_id: Uuid,
360 ) -> Result<(String, String, String), AgentError> {
361 let (space, _, row) =
362 agent_context(self, caller, space_name, collection_id, agent_id).await?;
363 let conversation_id = row
364 .conversation_id
365 .ok_or_else(|| AgentError::BadRequest("agent has no runtime session".to_string()))?;
366 Ok((space.id.to_string(), caller.to_string(), conversation_id))
367 }
368
369 #[tracing::instrument(
370 name = "agents.send_agent_message",
371 skip_all,
372 fields(
373 caller = %caller,
374 space_name = %space_name,
375 collection_id = %collection_id,
376 agent_id = %agent_id
377 )
378 )]
379 pub async fn send_agent_message(
380 &self,
381 caller: i64,
382 space_name: &str,
383 collection_id: Uuid,
384 agent_id: Uuid,
385 cmd: SendAgentMessageCmd,
386 ) -> Result<AgentRunDto, AgentError> {
387 let text = cmd.text.trim();
388 if text.is_empty() {
389 return Err(AgentError::BadRequest("message text is empty".to_string()));
390 }
391 let (space, _, row) =
392 agent_context(self, caller, space_name, collection_id, agent_id).await?;
393 let conversation_id = row
394 .conversation_id
395 .ok_or_else(|| AgentError::BadRequest("agent has no runtime session".to_string()))?;
396
397 let mcp_servers = if cmd.mcp {
398 self.run_mcp_servers(&space, caller).await?
399 } else {
400 Vec::new()
401 };
402 let repo = match cmd
403 .repo
404 .as_deref()
405 .map(str::trim)
406 .filter(|repo| !repo.is_empty())
407 {
408 Some(repo) => Some(self.authorize_repo(&space, caller, repo).await?),
409 None => None,
410 };
411 let context = run_context(&space, repo.as_deref(), !mcp_servers.is_empty());
412
413 let rpc = self.rpc()?;
414 let run = rpc
415 .send_message(
416 &space.id.to_string(),
417 &caller.to_string(),
418 &conversation_id,
419 text,
420 mcp_servers,
421 Some(context),
422 )
423 .await?;
424
425 let mut am: agent::ActiveModel = agent::Entity::find_by_id(agent_id)
426 .one(self.db().read())
427 .await?
428 .ok_or(AgentError::NotFound)?
429 .into();
430 am.last_run_id = Set(Some(run.run_id.clone()));
431 am.updated_at = Set(Some(OffsetDateTime::now_utc()));
432 am.update(self.db().write()).await?;
433
434 Ok(run.into())
435 }
436
437 /// Inline MCP server attached to a single run: the platform's own MCP
438 /// endpoint, authenticated with a temporary token confined to this space.
439 async fn run_mcp_servers(
440 &self,
441 space: &spaces::Model,
442 caller: i64,
443 ) -> Result<Vec<proto::McpServerSpecMessage>, AgentError> {
444 if !crate::share::authz::can_manage_mcp(self, space, caller).await {
445 return Err(AgentError::Forbidden);
446 }
447 let url = self.config().mcp.runtime_url();
448 if !self.config().mcp.enabled() || url.is_empty() {
449 return Err(AgentError::BadRequest(
450 "mcp endpoint is not configured".to_string(),
451 ));
452 }
453
454 let issued = mcp_api::space_token::issue(
455 &self.state,
456 mcp_api::space_token::IssueSpaceTokenCmd {
457 space: space.lower_name.clone(),
458 ttl_secs: Some(self.config().agents.mcp_token_ttl_secs),
459 issued_by: Some(caller.to_string()),
460 },
461 )
462 .await
463 .map_err(space_token_error)?;
464
465 let headers = HashMap::from([(
466 "Authorization".to_string(),
467 format!("Bearer {}", issued.token),
468 )]);
469 Ok(vec![proto::McpServerSpecMessage {
470 name: "gitdata".to_string(),
471 url: url.to_string(),
472 description: Some("GitData 平台工具:仓库、工单、合并请求、空间与用户信息".to_string()),
473 headers,
474 tool_prefix: None,
475 }])
476 }
477
478 /// Validates a bound repository: it must exist inside the space and the
479 /// caller must be able to read it; returns the canonical repo name.
480 async fn authorize_repo(
481 &self,
482 space: &spaces::Model,
483 caller: i64,
484 repo: &str,
485 ) -> Result<String, AgentError> {
486 let git = git::service::GitService::new(self.state.clone());
487 let ctx = git
488 .context(space.lower_name.clone(), repo.to_string())
489 .await
490 .map_err(|_| AgentError::NotFound)?;
491 git::share::access::authorize_repo_access(&git, caller, &ctx, false, false)
492 .await
493 .map_err(|_| AgentError::Forbidden)?;
494 Ok(ctx.model.name.clone())
495 }
496
497 pub async fn list_agent_messages(
498 &self,
499 caller: i64,
500 space_name: &str,
501 collection_id: Uuid,
502 agent_id: Uuid,
503 q: ListAgentMessageQuery,
504 ) -> Result<Vec<AgentMessageDto>, AgentError> {
505 let (space_ref, user_ref, conversation_id) = self
506 .conversation(caller, space_name, collection_id, agent_id)
507 .await?;
508 let limit = q.limit.unwrap_or(200).clamp(1, 1000);
509 let rpc = self.rpc()?;
510 let messages = rpc
511 .list_messages(
512 &space_ref,
513 &user_ref,
514 &conversation_id,
515 q.after_sequence,
516 Some(limit),
517 q.run_id,
518 )
519 .await?;
520 Ok(messages.into_iter().map(AgentMessageDto::from).collect())
521 }
522
523 pub async fn list_agent_tool_calls(
524 &self,
525 caller: i64,
526 space_name: &str,
527 collection_id: Uuid,
528 agent_id: Uuid,
529 q: ListAgentToolCallQuery,
530 ) -> Result<Vec<AgentToolCallDto>, AgentError> {
531 let (space_ref, user_ref, conversation_id) = self
532 .conversation(caller, space_name, collection_id, agent_id)
533 .await?;
534 let limit = q.limit.unwrap_or(500).clamp(1, 2000);
535 let rpc = self.rpc()?;
536 let calls = rpc
537 .list_tool_calls(&space_ref, &user_ref, &conversation_id, Some(limit))
538 .await?;
539 Ok(calls.into_iter().map(AgentToolCallDto::from).collect())
540 }
541
542 pub async fn list_agent_runs(
543 &self,
544 caller: i64,
545 space_name: &str,
546 collection_id: Uuid,
547 agent_id: Uuid,
548 ) -> Result<Vec<AgentRunDto>, AgentError> {
549 let (space_ref, user_ref, conversation_id) = self
550 .conversation(caller, space_name, collection_id, agent_id)
551 .await?;
552 let rpc = self.rpc()?;
553 let runs = rpc
554 .list_runs(&space_ref, &user_ref, &conversation_id)
555 .await?;
556 Ok(runs.into_iter().map(AgentRunDto::from).collect())
557 }
558
559 #[tracing::instrument(
560 name = "agents.cancel_agent_run",
561 skip_all,
562 fields(
563 caller = %caller,
564 space_name = %space_name,
565 collection_id = %collection_id,
566 agent_id = %agent_id,
567 run_id = %run_id
568 )
569 )]
570 pub async fn cancel_agent_run(
571 &self,
572 caller: i64,
573 space_name: &str,
574 collection_id: Uuid,
575 agent_id: Uuid,
576 run_id: &str,
577 ) -> Result<AgentRunDto, AgentError> {
578 let (space_ref, user_ref, _) = self
579 .conversation(caller, space_name, collection_id, agent_id)
580 .await?;
581 let rpc = self.rpc()?;
582 let run = rpc.cancel_run(&space_ref, &user_ref, run_id).await?;
583 Ok(run.into())
584 }
585
586 pub async fn list_agent_approvals(
587 &self,
588 caller: i64,
589 space_name: &str,
590 collection_id: Uuid,
591 agent_id: Uuid,
592 ) -> Result<Vec<AgentApprovalDto>, AgentError> {
593 let (space_ref, user_ref, conversation_id) = self
594 .conversation(caller, space_name, collection_id, agent_id)
595 .await?;
596 let rpc = self.rpc()?;
597 let approvals = rpc
598 .list_approvals(&space_ref, &user_ref, &conversation_id)
599 .await?;
600 Ok(approvals.into_iter().map(AgentApprovalDto::from).collect())
601 }
602
603 #[tracing::instrument(
604 name = "agents.decide_agent_approval",
605 skip_all,
606 fields(
607 caller = %caller,
608 space_name = %space_name,
609 collection_id = %collection_id,
610 agent_id = %agent_id,
611 approval_id = %approval_id
612 )
613 )]
614 pub async fn decide_agent_approval(
615 &self,
616 caller: i64,
617 space_name: &str,
618 collection_id: Uuid,
619 agent_id: Uuid,
620 approval_id: &str,
621 cmd: DecideAgentApprovalCmd,
622 ) -> Result<AgentApprovalDto, AgentError> {
623 let decision = cmd.decision.trim();
624 if decision.is_empty() {
625 return Err(AgentError::BadRequest("decision is empty".to_string()));
626 }
627 let (space_ref, user_ref, _) = self
628 .conversation(caller, space_name, collection_id, agent_id)
629 .await?;
630 let rpc = self.rpc()?;
631 let approval = rpc
632 .decide_approval(
633 &space_ref,
634 &user_ref,
635 approval_id,
636 decision,
637 cmd.reason,
638 cmd.scope,
639 )
640 .await?;
641 Ok(approval.into())
642 }
643
644 pub async fn list_agent_questions(
645 &self,
646 caller: i64,
647 space_name: &str,
648 collection_id: Uuid,
649 agent_id: Uuid,
650 ) -> Result<Vec<AgentQuestionDto>, AgentError> {
651 let (space_ref, user_ref, conversation_id) = self
652 .conversation(caller, space_name, collection_id, agent_id)
653 .await?;
654 let rpc = self.rpc()?;
655 let questions = rpc
656 .list_questions(&space_ref, &user_ref, &conversation_id)
657 .await?;
658 Ok(questions.into_iter().map(AgentQuestionDto::from).collect())
659 }
660
661 #[tracing::instrument(
662 name = "agents.answer_agent_questions",
663 skip_all,
664 fields(
665 caller = %caller,
666 space_name = %space_name,
667 collection_id = %collection_id,
668 agent_id = %agent_id,
669 question_id = %question_id
670 )
671 )]
672 pub async fn answer_agent_questions(
673 &self,
674 caller: i64,
675 space_name: &str,
676 collection_id: Uuid,
677 agent_id: Uuid,
678 question_id: &str,
679 cmd: AnswerAgentQuestionCmd,
680 ) -> Result<AgentQuestionDto, AgentError> {
681 if cmd.answers.is_empty() {
682 return Err(AgentError::BadRequest("answers are empty".to_string()));
683 }
684 let (space_ref, user_ref, _) = self
685 .conversation(caller, space_name, collection_id, agent_id)
686 .await?;
687 let answers = cmd
688 .answers
689 .into_iter()
690 .map(|answer| proto::QuestionAnswer {
691 item_id: answer.item_id,
692 selected: answer.selected,
693 text: answer.text,
694 })
695 .collect();
696 let rpc = self.rpc()?;
697 let question = rpc
698 .answer_questions(&space_ref, &user_ref, question_id, answers)
699 .await?;
700 Ok(question.into())
701 }
702}
703
704/// System context injected by the control plane on every run: the agent knows
705/// which space it works in, the bound repository (if any), and how to address
706/// them from the platform MCP tools.
707fn run_context(space: &spaces::Model, repo: Option<&str>, mcp_enabled: bool) -> String {
708 let mut lines = vec![
709 "运行上下文(由 GitData 平台自动注入):".to_string(),
710 format!(
711 "- 当前空间:{}(space_id: {})。你只能访问该空间内的数据。",
712 space.lower_name, space.id
713 ),
714 ];
715 if let Some(repo) = repo {
716 lines.push(format!(
717 "- 当前绑定仓库:{}/{},涉及代码或仓库的操作默认针对该仓库。",
718 space.lower_name, repo
719 ));
720 }
721 if mcp_enabled {
722 let repo_hint = repo
723 .map(|repo| format!(",涉及仓库时 repo 参数使用「{repo}」"))
724 .unwrap_or_default();
725 lines.push(format!(
726 "- 平台 MCP 工具(gitdata)作用于当前空间,调用时 space 参数使用「{}」{repo_hint}。",
727 space.lower_name
728 ));
729 }
730 lines.join("\n")
731}
732
733fn space_token_error(error: mcp_api::space_token::SpaceTokenError) -> AgentError {
734 use mcp_api::space_token::SpaceTokenError;
735 match error {
736 SpaceTokenError::SpaceNotFound => AgentError::NotFound,
737 SpaceTokenError::BotNameConflict(message) => AgentError::Conflict(message),
738 other => AgentError::Internal(other.to_string()),
739 }
740}
Deleted lib/agents/src/services/collections.rs
@@ -1,421 +0,0 @@
1use std::collections::HashMap;
2
3use models::agent::agent_item as agent;
4use models::agent::agents;
5use models::dir::dirs::{self, DirKind};
6use sea_orm::{
7 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
8 QuerySelect, Set, TransactionTrait, Unchanged,
9};
10use serde::{Deserialize, Serialize};
11use time::OffsetDateTime;
12use utoipa::{IntoParams, ToSchema};
13use uuid::Uuid;
14
15use crate::errors::AgentError;
16use crate::services::service::AgentsService;
17use crate::share::authz::require_collection_manage;
18use crate::share::lookup::{collection_context, lookup_collection, space_context};
19
20#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
21pub struct AgentCollectionDto {
22 pub id: Uuid,
23 pub dir_id: Uuid,
24 pub name: String,
25 pub icon: Option<String>,
26 pub description: Option<String>,
27 pub agent_count: i64,
28 /// Caller may attach the platform MCP to this set's agent runs (space
29 /// management permission plus a runtime-reachable MCP endpoint).
30 pub can_enable_mcp: bool,
31 /// The sandbox runtime bridge is configured for this deployment.
32 pub runtime_online: bool,
33 pub created_at: OffsetDateTime,
34 pub updated_at: Option<OffsetDateTime>,
35}
36
37pub type AgentCollectionPage = export::pagination::Page<AgentCollectionDto>;
38
39#[derive(Debug, Clone, Deserialize, ToSchema)]
40pub struct CreateAgentCollectionCmd {
41 pub name: String,
42 #[serde(default, alias = "dir_id")]
43 pub parent_dir_id: Option<Uuid>,
44 #[serde(default)]
45 pub icon: Option<String>,
46 #[serde(default)]
47 pub description: Option<String>,
48}
49
50#[derive(Debug, Clone, Deserialize, ToSchema)]
51pub struct UpdateAgentCollectionCmd {
52 #[serde(default)]
53 pub name: Option<String>,
54 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
55 pub icon: Option<Option<String>>,
56 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
57 pub description: Option<Option<String>>,
58}
59
60#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
61pub struct ListAgentCollectionQuery {
62 pub dir_id: Option<Uuid>,
63 pub q: Option<String>,
64 pub limit: Option<u64>,
65 pub offset: Option<u64>,
66}
67
68#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
69pub struct AgentCollectionPath {
70 pub space_name: String,
71 pub collection_id: Uuid,
72}
73
74impl AgentsService {
75 fn collection_dto(
76 collection: agents::Model,
77 agent_count: i64,
78 can_enable_mcp: bool,
79 runtime_online: bool,
80 ) -> AgentCollectionDto {
81 AgentCollectionDto {
82 id: collection.id,
83 dir_id: collection.dir_id,
84 name: collection.name,
85 icon: collection.icon,
86 description: collection.description,
87 agent_count,
88 can_enable_mcp,
89 runtime_online,
90 created_at: collection.created_at,
91 updated_at: collection.updated_at,
92 }
93 }
94
95 async fn agent_counts(
96 &self,
97 collection_ids: &[Uuid],
98 ) -> Result<HashMap<Uuid, i64>, AgentError> {
99 if collection_ids.is_empty() {
100 return Ok(HashMap::new());
101 }
102 let rows: Vec<(Uuid, i64)> = agent::Entity::find()
103 .select_only()
104 .column(agent::Column::CollectionId)
105 .column_as(agent::Column::CollectionId.count(), "cnt")
106 .filter(agent::Column::CollectionId.is_in(collection_ids.to_vec()))
107 .group_by(agent::Column::CollectionId)
108 .into_tuple()
109 .all(self.db().read())
110 .await?;
111 Ok(rows.into_iter().collect())
112 }
113
114 #[tracing::instrument(
115 name = "agents.create_agent_collection",
116 skip_all,
117 fields(caller = %caller, space_name = %space_name)
118 )]
119 pub async fn create_agent_collection(
120 &self,
121 caller: i64,
122 space_name: &str,
123 cmd: CreateAgentCollectionCmd,
124 ) -> Result<AgentCollectionDto, AgentError> {
125 let space = space_context(self, caller, space_name).await?;
126 require_collection_manage(self, &space, caller).await?;
127
128 let name = cmd.name.trim();
129 if name.is_empty() || name.chars().count() > self.config().agents.max_collection_name_len {
130 return Err(AgentError::BadRequest(
131 "collection name is empty or too long".to_string(),
132 ));
133 }
134
135 if let Some(p) = cmd.parent_dir_id {
136 let parent = dirs::Entity::find_by_id(p)
137 .filter(dirs::Column::SpaceId.eq(Some(space.id)))
138 .one(self.db().read())
139 .await?
140 .ok_or(AgentError::NotFound)?;
141 if parent.kind != DirKind::Dir {
142 return Err(AgentError::BadRequest(
143 "parent node must be a dir".to_string(),
144 ));
145 }
146 }
147
148 let (dir, collection) = {
149 let txn = self.db().write().begin().await?;
150 let position = {
151 let max_node = dirs::Entity::find()
152 .filter(dirs::Column::SpaceId.eq(Some(space.id)))
153 .filter(dirs::Column::ParentDirId.eq(cmd.parent_dir_id))
154 .order_by_desc(dirs::Column::Position)
155 .lock(sea_orm::sea_query::LockType::Update)
156 .one(&txn)
157 .await?;
158 max_node.map(|n| n.position + 1).unwrap_or(0)
159 };
160 let now = OffsetDateTime::now_utc();
161 let dir_row = dirs::ActiveModel {
162 id: Set(Uuid::now_v7()),
163 space_id: Set(Some(space.id)),
164 name: Set(name.to_string()),
165 kind: Set(DirKind::Object),
166 parent_dir_id: Set(cmd.parent_dir_id),
167 color: Set(None),
168 position: Set(position),
169 icon: Set(cmd.icon.clone()),
170 topic: Set(None),
171 purpose: Set(cmd.description.clone()),
172 is_archived: Set(false),
173 archived_at: Set(None),
174 archived_by: Set(None),
175 created_at: Set(now),
176 updated_at: Set(now),
177 created_by_uid: Set(caller),
178 };
179 let dir = dir_row.insert(&txn).await?;
180
181 let collection_row = agents::ActiveModel {
182 id: Set(Uuid::now_v7()),
183 dir_id: Set(dir.id),
184 name: Set(name.to_string()),
185 icon: Set(cmd.icon),
186 description: Set(cmd.description),
187 created_at: Set(now),
188 updated_at: Set(None),
189 };
190 let collection = collection_row.insert(&txn).await?;
191 txn.commit().await?;
192 (dir, collection)
193 };
194 let _ = dir;
195
196 let can_enable_mcp = crate::share::authz::can_enable_mcp(self, &space, caller).await;
197 Ok(Self::collection_dto(
198 collection,
199 0,
200 can_enable_mcp,
201 self.rpc_opt().is_some(),
202 ))
203 }
204
205 pub async fn list_agent_collections(
206 &self,
207 caller: i64,
208 space_name: &str,
209 q: ListAgentCollectionQuery,
210 ) -> Result<AgentCollectionPage, AgentError> {
211 let space = space_context(self, caller, space_name).await?;
212 let (limit, offset) = self.paginate(q.limit, q.offset);
213
214 let dirs_in_space = dirs::Entity::find()
215 .filter(dirs::Column::SpaceId.eq(Some(space.id)))
216 .filter(dirs::Column::Kind.eq(DirKind::Object))
217 .all(self.db().read())
218 .await?;
219
220 let dir_ids: Vec<Uuid> = if let Some(target_dir) = q.dir_id {
221 dirs_in_space
222 .into_iter()
223 .filter(|d| d.parent_dir_id == Some(target_dir) || d.id == target_dir)
224 .map(|d| d.id)
225 .collect()
226 } else {
227 dirs_in_space.into_iter().map(|d| d.id).collect()
228 };
229
230 if dir_ids.is_empty() {
231 return Ok(AgentCollectionPage {
232 items: Vec::new(),
233 total: 0,
234 limit,
235 offset,
236 });
237 }
238
239 let mut select = agents::Entity::find().filter(agents::Column::DirId.is_in(dir_ids));
240 if let Some(ref search) = q.q {
241 let kw = search.trim();
242 if !kw.is_empty() {
243 select = select.filter(agents::Column::Name.contains(kw));
244 }
245 }
246 select = select.order_by_desc(agents::Column::CreatedAt);
247
248 let total = select.clone().count(self.db().read()).await? as i64;
249 let rows = select
250 .limit(limit)
251 .offset(offset)
252 .all(self.db().read())
253 .await?;
254
255 let can_enable_mcp = crate::share::authz::can_enable_mcp(self, &space, caller).await;
256 let runtime_online = self.rpc_opt().is_some();
257 let ids: Vec<Uuid> = rows.iter().map(|row| row.id).collect();
258 let counts = self.agent_counts(&ids).await?;
259 let items = rows
260 .into_iter()
261 .map(|row| {
262 let count = counts.get(&row.id).copied().unwrap_or(0);
263 Self::collection_dto(row, count, can_enable_mcp, runtime_online)
264 })
265 .collect();
266
267 Ok(AgentCollectionPage {
268 items,
269 total,
270 limit,
271 offset,
272 })
273 }
274
275 pub async fn get_agent_collection(
276 &self,
277 caller: i64,
278 space_name: &str,
279 collection_id: Uuid,
280 ) -> Result<AgentCollectionDto, AgentError> {
281 let (space, collection) =
282 collection_context(self, caller, space_name, collection_id).await?;
283 let counts = self.agent_counts(&[collection.id]).await?;
284 let count = counts.get(&collection.id).copied().unwrap_or(0);
285 let can_enable_mcp = crate::share::authz::can_enable_mcp(self, &space, caller).await;
286 Ok(Self::collection_dto(
287 collection,
288 count,
289 can_enable_mcp,
290 self.rpc_opt().is_some(),
291 ))
292 }
293
294 #[tracing::instrument(
295 name = "agents.update_agent_collection",
296 skip_all,
297 fields(caller = %caller, space_name = %space_name, collection_id = %collection_id)
298 )]
299 pub async fn update_agent_collection(
300 &self,
301 caller: i64,
302 space_name: &str,
303 collection_id: Uuid,
304 cmd: UpdateAgentCollectionCmd,
305 ) -> Result<AgentCollectionDto, AgentError> {
306 let space = space_context(self, caller, space_name).await?;
307 require_collection_manage(self, &space, caller).await?;
308 let collection = lookup_collection(self, &space, collection_id, caller).await?;
309
310 let now = OffsetDateTime::now_utc();
311 let mut am: agents::ActiveModel = collection.clone().into();
312 let mut dir_am = dirs::ActiveModel {
313 id: Unchanged(collection.dir_id),
314 ..Default::default()
315 };
316 let mut dir_changed = false;
317
318 if let Some(new_name) = cmd.name {
319 let name = new_name.trim();
320 if name.is_empty()
321 || name.chars().count() > self.config().agents.max_collection_name_len
322 {
323 return Err(AgentError::BadRequest(
324 "collection name is empty or too long".to_string(),
325 ));
326 }
327 am.name = Set(name.to_string());
328 dir_am.name = Set(name.to_string());
329 dir_changed = true;
330 }
331 if let Some(icon) = cmd.icon {
332 am.icon = Set(icon.clone());
333 dir_am.icon = Set(icon);
334 dir_changed = true;
335 }
336 if let Some(description) = cmd.description {
337 am.description = Set(description.clone());
338 dir_am.purpose = Set(description);
339 dir_changed = true;
340 }
341
342 am.updated_at = Set(Some(now));
343
344 let updated = {
345 let txn = self.db().write().begin().await?;
346 if dir_changed {
347 dir_am.updated_at = Set(now);
348 dir_am.update(&txn).await?;
349 }
350 let updated = am.update(&txn).await?;
351 txn.commit().await?;
352 updated
353 };
354
355 let counts = self.agent_counts(&[updated.id]).await?;
356 let count = counts.get(&updated.id).copied().unwrap_or(0);
357 let can_enable_mcp = crate::share::authz::can_enable_mcp(self, &space, caller).await;
358 Ok(Self::collection_dto(
359 updated,
360 count,
361 can_enable_mcp,
362 self.rpc_opt().is_some(),
363 ))
364 }
365
366 #[tracing::instrument(
367 name = "agents.delete_agent_collection",
368 skip_all,
369 fields(caller = %caller, space_name = %space_name, collection_id = %collection_id)
370 )]
371 pub async fn delete_agent_collection(
372 &self,
373 caller: i64,
374 space_name: &str,
375 collection_id: Uuid,
376 ) -> Result<(), AgentError> {
377 let space = space_context(self, caller, space_name).await?;
378 require_collection_manage(self, &space, caller).await?;
379 let collection = lookup_collection(self, &space, collection_id, caller).await?;
380
381 let rows = agent::Entity::find()
382 .filter(agent::Column::CollectionId.eq(collection.id))
383 .all(self.db().read())
384 .await?;
385
386 if let Some(rpc) = self.rpc_opt() {
387 let space_ref = space.id.to_string();
388 let user_ref = caller.to_string();
389 for row in &rows {
390 if let Some(conversation_id) = row.conversation_id.as_deref()
391 && let Err(error) = rpc
392 .delete_session(&space_ref, &user_ref, conversation_id)
393 .await
394 {
395 tracing::warn!(%error, agent_id = %row.id, "sandbox session cleanup failed");
396 }
397 }
398 }
399
400 self.db()
401 .primary()
402 .transaction::<_, (), AgentError>(|txn| {
403 Box::pin(async move {
404 agent::Entity::delete_many()
405 .filter(agent::Column::CollectionId.eq(collection.id))
406 .exec(txn)
407 .await?;
408 agents::Entity::delete_by_id(collection.id)
409 .exec(txn)
410 .await?;
411 dirs::Entity::delete_by_id(collection.dir_id)
412 .exec(txn)
413 .await?;
414 Ok(())
415 })
416 })
417 .await?;
418
419 Ok(())
420 }
421}
Deleted lib/agents/src/services/mod.rs
@@ -1,6 +0,0 @@
1pub mod agents;
2pub mod avatar;
3pub mod builder;
4pub mod chat;
5pub mod collections;
6pub mod service;
Deleted lib/agents/src/services/service.rs
@@ -1,80 +0,0 @@
1use std::collections::HashMap;
2use std::sync::Arc;
3
4use config::AppConfig;
5use export::AppState;
6use export::database::AppDatabase;
7use models::users::user;
8use sandbox_rpc::SandboxRpcClient;
9use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
10use space::services::spaces::UserDto;
11use uuid::Uuid;
12
13use crate::errors::AgentError;
14
15#[derive(Clone)]
16pub struct AgentsService {
17 pub state: AppState,
18 rpc: Option<SandboxRpcClient>,
19}
20
21impl AgentsService {
22 pub fn new(state: AppState) -> Self {
23 let rpc = match SandboxRpcClient::from_config(&state.config.sandbox_rpc) {
24 Ok(client) => client,
25 Err(error) => {
26 tracing::warn!(%error, "sandbox rpc disabled: invalid configuration");
27 None
28 }
29 };
30 Self { state, rpc }
31 }
32
33 pub fn config(&self) -> &AppConfig {
34 &self.state.config
35 }
36
37 pub fn db(&self) -> &Arc<AppDatabase> {
38 &self.state.database
39 }
40
41 pub fn rpc(&self) -> Result<&SandboxRpcClient, AgentError> {
42 self.rpc.as_ref().ok_or_else(|| {
43 AgentError::RuntimeUnavailable("sandbox runtime is not configured".to_string())
44 })
45 }
46
47 pub fn rpc_opt(&self) -> Option<&SandboxRpcClient> {
48 self.rpc.as_ref()
49 }
50
51 pub fn paginate(&self, limit: Option<u64>, offset: Option<u64>) -> (u64, u64) {
52 let max_pagination = self.config().agents.max_pagination.max(1);
53 let default_pagination = self.config().agents.default_pagination.max(1);
54 (
55 limit.unwrap_or(default_pagination).clamp(1, max_pagination),
56 offset.unwrap_or(0).clamp(0, 5000),
57 )
58 }
59
60 pub async fn load_user_briefs(
61 &self,
62 uids: &[i64],
63 ) -> Result<HashMap<i64, UserDto>, AgentError> {
64 if uids.is_empty() {
65 return Ok(HashMap::new());
66 }
67 let mut set: Vec<i64> = uids.to_vec();
68 set.sort();
69 set.dedup();
70 let users = user::Entity::find()
71 .filter(user::Column::Uid.is_in(set))
72 .all(self.db().read())
73 .await?;
74 Ok(users
75 .into_iter()
76 .map(|u| (u.uid, UserDto::from(u)))
77 .collect())
78 }
79}
80
Deleted lib/agents/src/share/authz.rs
@@ -1,46 +0,0 @@
1use models::space::spaces;
2use space::share::authz::{MANAGE_CHANNELS, effective_permissions};
3use uuid::Uuid;
4
5use crate::errors::AgentError;
6use crate::services::service::AgentsService;
7
8pub async fn require_collection_manage(
9 service: &AgentsService,
10 space: &spaces::Model,
11 caller: i64,
12) -> Result<(), AgentError> {
13 if space.owner_id == caller {
14 return Ok(());
15 }
16 let perms = effective_permissions(&service.state, space, caller)
17 .await
18 .map_err(AgentError::from)?;
19 if perms.has(MANAGE_CHANNELS) {
20 Ok(())
21 } else {
22 Err(AgentError::Forbidden)
23 }
24}
25
26/// Whether the caller may attach the platform MCP to an agent run: only space
27/// owners or members with MANAGE_CHANNELS, and only while an MCP endpoint the
28/// sandbox runtime can reach is configured.
29pub async fn can_enable_mcp(service: &AgentsService, space: &spaces::Model, caller: i64) -> bool {
30 mcp_configured(service) && can_manage_mcp(service, space, caller).await
31}
32
33/// Permission half of [`can_enable_mcp`], without the configuration check.
34pub async fn can_manage_mcp(service: &AgentsService, space: &spaces::Model, caller: i64) -> bool {
35 if space.owner_id == caller {
36 return true;
37 }
38 match effective_permissions(&service.state, space, caller).await {
39 Ok(perms) => perms.has(MANAGE_CHANNELS),
40 Err(_) => false,
41 }
42}
43
44fn mcp_configured(service: &AgentsService) -> bool {
45 service.config().mcp.enabled() && !service.config().mcp.runtime_url().is_empty()
46}
Deleted lib/agents/src/share/guard.rs
@@ -1,16 +0,0 @@
1use axum::extract::{Request, State};
2use axum::middleware::Next;
3use axum::response::Response;
4use jwt::Session;
5use jwt::share::guard::session_guard;
6
7use crate::services::service::AgentsService;
8
9pub async fn agents_guard(
10 State(service): State<AgentsService>,
11 session: Session,
12 request: Request,
13 next: Next,
14) -> Response {
15 session_guard::<AgentsService>(State(service), session, request, next).await
16}
Deleted lib/agents/src/share/lookup.rs
@@ -1,130 +0,0 @@
1use models::agent::agent_item as agent;
2use models::agent::agents;
3use models::dir::dirs::{self, DirKind};
4use models::space::spaces;
5use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
6use space::share::authz::{READ, VIEW_SPACE, effective_permissions};
7use uuid::Uuid;
8
9use crate::errors::AgentError;
10use crate::services::service::AgentsService;
11
12pub async fn lookup_space_by_name(
13 service: &AgentsService,
14 space_name: &str,
15) -> Result<spaces::Model, AgentError> {
16 let lower = space_name.trim().to_lowercase();
17 if lower.is_empty() {
18 return Err(AgentError::NotFound);
19 }
20 spaces::Entity::find()
21 .filter(spaces::Column::LowerName.eq(&lower))
22 .filter(spaces::Column::IsActive.eq(true))
23 .one(service.db().read())
24 .await?
25 .ok_or(AgentError::NotFound)
26}
27
28pub async fn require_member(
29 service: &AgentsService,
30 space: &spaces::Model,
31 caller: i64,
32) -> Result<(), AgentError> {
33 let perms = effective_permissions(&service.state, space, caller)
34 .await
35 .map_err(AgentError::from)?;
36 if perms.has(VIEW_SPACE) || perms.has(READ) {
37 Ok(())
38 } else {
39 Err(AgentError::Forbidden)
40 }
41}
42
43pub async fn space_context(
44 service: &AgentsService,
45 caller: i64,
46 space_name: &str,
47) -> Result<spaces::Model, AgentError> {
48 let space = lookup_space_by_name(service, space_name).await?;
49 require_member(service, &space, caller).await?;
50 Ok(space)
51}
52
53pub async fn lookup_collection(
54 service: &AgentsService,
55 space: &spaces::Model,
56 collection_id: Uuid,
57 caller: i64,
58) -> Result<agents::Model, AgentError> {
59 let collection = agents::Entity::find_by_id(collection_id)
60 .one(service.db().read())
61 .await?
62 .ok_or(AgentError::NotFound)?;
63
64 let dir = dirs::Entity::find_by_id(collection.dir_id)
65 .filter(dirs::Column::SpaceId.eq(Some(space.id)))
66 .one(service.db().read())
67 .await?
68 .ok_or(AgentError::NotFound)?;
69
70 if dir.kind != DirKind::Object {
71 return Err(AgentError::NotFound);
72 }
73
74 if !space::share::dir_visibility::is_dir_visible(
75 service.db().read(),
76 space,
77 collection.dir_id,
78 caller,
79 )
80 .await?
81 {
82 return Err(AgentError::NotFound);
83 }
84
85 Ok(collection)
86}
87
88pub async fn lookup_agent(
89 service: &AgentsService,
90 collection: &agents::Model,
91 agent_ref: &str,
92) -> Result<agent::Model, AgentError> {
93 let base = agent::Entity::find().filter(agent::Column::CollectionId.eq(collection.id));
94 let found = if let Ok(id) = Uuid::parse_str(agent_ref) {
95 base.filter(agent::Column::Id.eq(id))
96 .one(service.db().read())
97 .await?
98 } else if let Ok(number) = agent_ref.parse::<i32>() {
99 base.filter(agent::Column::AutoincrementId.eq(number))
100 .one(service.db().read())
101 .await?
102 } else {
103 None
104 };
105 found.ok_or(AgentError::NotFound)
106}
107
108pub async fn collection_context(
109 service: &AgentsService,
110 caller: i64,
111 space_name: &str,
112 collection_id: Uuid,
113) -> Result<(spaces::Model, agents::Model), AgentError> {
114 let space = space_context(service, caller, space_name).await?;
115 let collection = lookup_collection(service, &space, collection_id, caller).await?;
116 Ok((space, collection))
117}
118
119pub async fn agent_context(
120 service: &AgentsService,
121 caller: i64,
122 space_name: &str,
123 collection_id: Uuid,
124 agent_id: Uuid,
125) -> Result<(spaces::Model, agents::Model, agent::Model), AgentError> {
126 let (space, collection) =
127 collection_context(service, caller, space_name, collection_id).await?;
128 let item = lookup_agent(service, &collection, &agent_id.to_string()).await?;
129 Ok((space, collection, item))
130}
Deleted lib/agents/src/share/mod.rs
@@ -1,3 +0,0 @@
1pub mod authz;
2pub mod guard;
3pub mod lookup;
Deleted lib/agents/src/errors.rs
@@ -1,81 +0,0 @@
1use std::fmt;
2
3use sandbox_rpc::SandboxRpcError;
4
5#[derive(Debug)]
6pub enum AgentError {
7 BadRequest(String),
8 Unauthorized,
9 Forbidden,
10 NotFound,
11 Conflict(String),
12 RateLimited(String),
13 RuntimeUnavailable(String),
14 Internal(String),
15}
16
17impl fmt::Display for AgentError {
18 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
19 match self {
20 Self::BadRequest(message) => write!(f, "{message}"),
21 Self::Unauthorized => write!(f, "unauthorized"),
22 Self::Forbidden => write!(f, "forbidden"),
23 Self::NotFound => write!(f, "not found"),
24 Self::Conflict(message) => write!(f, "{message}"),
25 Self::RateLimited(message) => write!(f, "{message}"),
26 Self::RuntimeUnavailable(message) => write!(f, "{message}"),
27 Self::Internal(message) => write!(f, "{message}"),
28 }
29 }
30}
31
32impl std::error::Error for AgentError {}
33
34export::db_error_conversions!(AgentError);
35export::export_error_conversion!(AgentError);
36export::json_payload_conversion!(AgentError);
37export::domain_error_passthrough!(AgentError from space::errors::SpaceError);
38
39impl From<SandboxRpcError> for AgentError {
40 fn from(error: SandboxRpcError) -> Self {
41 match error {
42 SandboxRpcError::Unavailable(message) => Self::RuntimeUnavailable(message),
43 SandboxRpcError::Rejected(message) => Self::BadRequest(message),
44 SandboxRpcError::Config(message) => Self::RuntimeUnavailable(message),
45 SandboxRpcError::Internal(message) => Self::Internal(message),
46 }
47 }
48}
49
50export::api_error! {
51 log: "agents",
52 pub enum ApiError {
53 BadRequest(400, "request failed validation"),
54 Unauthorized(401, "authentication required"),
55 Forbidden(403, "operation not allowed"),
56 NotFound(404, "resource not found"),
57 Conflict(409, "resource conflict"),
58 TooManyRequests(429, "rate limit exceeded"),
59 ServiceUnavailable(503, "agent runtime unavailable"),
60 Internal(500, "internal server error"),
61 }
62}
63
64impl From<AgentError> for ApiError {
65 fn from(error: AgentError) -> Self {
66 match error {
67 AgentError::BadRequest(msg) => Self::bad_request(msg),
68 AgentError::Unauthorized => Self::unauthorized(),
69 AgentError::Forbidden => Self::forbidden(),
70 AgentError::NotFound => Self::not_found("agent resource not found"),
71 AgentError::Conflict(msg) => Self::conflict(msg),
72 AgentError::RateLimited(msg) => Self::rate_limited(msg),
73 AgentError::RuntimeUnavailable(message) => Self::build(
74 503,
75 export::rest::AppResponseData::err(503, message)
76 .with_error_code("runtime_unavailable"),
77 ),
78 AgentError::Internal(message) => Self::internal(message),
79 }
80 }
81}
Deleted lib/agents/src/lib.rs
@@ -1,6 +0,0 @@
1pub mod errors;
2pub mod handlers;
3pub mod openapi;
4pub mod routes;
5pub mod services;
6pub mod share;
Deleted lib/agents/src/openapi.rs
@@ -1,89 +0,0 @@
1use export::rest::AppResponseData;
2use utoipa::OpenApi;
3
4#[derive(OpenApi)]
5#[openapi(
6 paths(
7 crate::handlers::collections::create_agent_collection,
8 crate::handlers::collections::list_agent_collections,
9 crate::handlers::collections::get_agent_collection,
10 crate::handlers::collections::update_agent_collection,
11 crate::handlers::collections::delete_agent_collection,
12 crate::handlers::agents::create_agent,
13 crate::handlers::agents::list_agents,
14 crate::handlers::agents::get_agent,
15 crate::handlers::agents::update_agent,
16 crate::handlers::agents::delete_agent,
17 crate::handlers::builder::builder_chat,
18 crate::handlers::avatar::upload_agent_avatar,
19 crate::handlers::avatar::agent_avatar,
20 crate::handlers::chat::send_agent_message,
21 crate::handlers::chat::create_agent_stream_token,
22 crate::handlers::chat::list_agent_messages,
23 crate::handlers::chat::list_agent_tool_calls,
24 crate::handlers::chat::list_agent_runs,
25 crate::handlers::chat::cancel_agent_run,
26 crate::handlers::chat::list_agent_approvals,
27 crate::handlers::chat::decide_agent_approval,
28 crate::handlers::chat::list_agent_questions,
29 crate::handlers::chat::answer_agent_questions,
30 ),
31 components(schemas(
32 AppResponseData<export::rest::NoData>,
33 AppResponseData<crate::services::collections::AgentCollectionDto>,
34 AppResponseData<crate::services::collections::AgentCollectionPage>,
35 AppResponseData<crate::services::agents::AgentDto>,
36 AppResponseData<crate::services::agents::AgentPage>,
37 AppResponseData<Vec<crate::services::chat::AgentRunDto>>,
38 AppResponseData<Vec<crate::services::chat::AgentMessageDto>>,
39 AppResponseData<Vec<crate::services::chat::AgentToolCallDto>>,
40 AppResponseData<Vec<crate::services::chat::AgentApprovalDto>>,
41 AppResponseData<Vec<crate::services::chat::AgentQuestionDto>>,
42 AppResponseData<crate::services::chat::AgentStreamTokenDto>,
43 AppResponseData<crate::services::chat::AgentRunDto>,
44 AppResponseData<crate::services::chat::AgentApprovalDto>,
45 AppResponseData<crate::services::chat::AgentQuestionDto>,
46 export::rest::NoData,
47 space::services::spaces::UserDto,
48 space::services::spaces::SpaceNamePath,
49 crate::services::collections::AgentCollectionDto,
50 crate::services::collections::AgentCollectionPage,
51 crate::services::collections::CreateAgentCollectionCmd,
52 crate::services::collections::UpdateAgentCollectionCmd,
53 crate::services::collections::ListAgentCollectionQuery,
54 crate::services::collections::AgentCollectionPath,
55 crate::services::agents::AgentDto,
56 crate::services::agents::AgentPage,
57 crate::services::agents::CreateAgentCmd,
58 crate::services::agents::UpdateAgentCmd,
59 crate::services::agents::ListAgentQuery,
60 crate::services::agents::AgentPath,
61 crate::services::chat::AgentRunDto,
62 crate::services::chat::AgentMessageDto,
63 crate::services::chat::AgentToolCallDto,
64 crate::services::chat::AgentApprovalDto,
65 crate::services::chat::AgentQuestionDto,
66 crate::services::chat::AgentQuestionItemDto,
67 crate::services::chat::AgentQuestionOptionDto,
68 crate::services::chat::AgentQuestionAnswerDto,
69 crate::services::chat::AgentStreamTokenDto,
70 crate::services::builder::BuilderMessage,
71 crate::services::builder::BuilderChatCmd,
72 crate::services::builder::BuilderDraft,
73 crate::services::builder::BuilderChatResult,
74 AppResponseData<crate::services::builder::BuilderChatResult>,
75 crate::services::chat::SendAgentMessageCmd,
76 crate::services::chat::DecideAgentApprovalCmd,
77 crate::services::chat::AnswerAgentQuestionCmd,
78 crate::services::chat::AgentQuestionAnswerCmd,
79 crate::services::chat::ListAgentMessageQuery,
80 crate::services::chat::ListAgentToolCallQuery,
81 crate::services::chat::AgentRunPath,
82 crate::services::chat::AgentApprovalPath,
83 crate::services::chat::AgentQuestionPath,
84 )),
85 tags(
86 (name = "agents", description = "Agent set and agent management API")
87 )
88)]
89pub struct AgentsApiDoc;
Deleted lib/agents/src/routes.rs
@@ -1,87 +0,0 @@
1use axum::Router;
2use axum::routing::{get, post};
3
4use crate::handlers::agents::{create_agent, delete_agent, get_agent, list_agents, update_agent};
5use crate::handlers::avatar::{agent_avatar, upload_agent_avatar};
6use crate::handlers::builder::builder_chat;
7use crate::handlers::chat::{
8 answer_agent_questions, cancel_agent_run, create_agent_stream_token, decide_agent_approval,
9 list_agent_approvals, list_agent_messages, list_agent_questions, list_agent_runs,
10 list_agent_tool_calls, send_agent_message,
11};
12use crate::handlers::collections::{
13 create_agent_collection, delete_agent_collection, get_agent_collection, list_agent_collections,
14 update_agent_collection,
15};
16use crate::services::service::AgentsService;
17use crate::share::guard::agents_guard;
18
19pub fn router(service: AgentsService) -> Router<AgentsService> {
20 Router::new()
21 .route(
22 "/agents/{space_name}/collections",
23 post(create_agent_collection).get(list_agent_collections),
24 )
25 .route(
26 "/agents/{space_name}/collections/{collection_id}",
27 get(get_agent_collection)
28 .patch(update_agent_collection)
29 .delete(delete_agent_collection),
30 )
31 .route(
32 "/agents/{space_name}/collections/{collection_id}/agents",
33 post(create_agent).get(list_agents),
34 )
35 .route(
36 "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}",
37 get(get_agent).patch(update_agent).delete(delete_agent),
38 )
39 .route(
40 "/agents/{space_name}/collections/{collection_id}/builder",
41 post(builder_chat),
42 )
43 .route(
44 "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/avatar",
45 get(agent_avatar).put(upload_agent_avatar),
46 )
47 .route(
48 "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/stream-token",
49 post(create_agent_stream_token),
50 )
51 .route(
52 "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/messages",
53 post(send_agent_message).get(list_agent_messages),
54 )
55 .route(
56 "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/tool-calls",
57 get(list_agent_tool_calls),
58 )
59 .route(
60 "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/runs",
61 get(list_agent_runs),
62 )
63 .route(
64 "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/runs/{run_id}/cancel",
65 post(cancel_agent_run),
66 )
67 .route(
68 "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/approvals",
69 get(list_agent_approvals),
70 )
71 .route(
72 "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/approvals/{approval_id}/decide",
73 post(decide_agent_approval),
74 )
75 .route(
76 "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/questions",
77 get(list_agent_questions),
78 )
79 .route(
80 "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/questions/{question_id}/answer",
81 post(answer_agent_questions),
82 )
83 .route_layer(axum::middleware::from_fn_with_state(
84 service.clone(),
85 agents_guard,
86 ))
87}
Deleted lib/agents/Cargo.toml
@@ -1,24 +0,0 @@
1[package]
2name = "agents"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7agent = { workspace = true }
8async-trait = { workspace = true }
9axum = { workspace = true }
10config = { workspace = true }
11export = { workspace = true }
12git = { workspace = true }
13mcp-api = { workspace = true }
14models = { workspace = true }
15sandbox-rpc = { workspace = true }
16sea-orm = { workspace = true }
17serde = { workspace = true, features = ["derive"] }
18serde_json = { workspace = true }
19jwt = { workspace = true }
20space = { workspace = true }
21time = { workspace = true }
22tracing = { workspace = true }
23utoipa = { workspace = true }
24uuid = { workspace = true }
No newline at end of file
Modified lib/chat/src/share/lookup.rs View lib/chat/src/share/lookup.rs
@@ -121,7 +121,6 @@
121121 banned_at: None,
122122 ban_reason: None,
123123 ban_expires_at: None,
124 ban_allow_agent_join: false,
125124 ban_allow_speak: false,
126125 ban_allow_push_repo: false,
127126 ban_allow_comment_any: false,
Deleted lib/ci-protocol/src/ai.rs
@@ -1,155 +0,0 @@
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 #[serde(default)]
20 trace: Option<String>,
21 #[serde(default)]
22 request_id: Option<String>,
23 },
24 CommitReview {
25 job_id: Uuid,
26 repo_id: Uuid,
27 commit_sha: String,
28 #[serde(default)]
29 language: Option<String>,
30 #[serde(default)]
31 trace: Option<String>,
32 #[serde(default)]
33 request_id: Option<String>,
34 },
35 RepoSummary {
36 job_id: Uuid,
37 repo_id: Uuid,
38 head_sha: String,
39 base_sha: Option<String>,
40 limit: Option<u64>,
41 #[serde(default)]
42 language: Option<String>,
43 #[serde(default)]
44 trace: Option<String>,
45 #[serde(default)]
46 request_id: Option<String>,
47 },
48 ReleaseNotes {
49 job_id: Uuid,
50 repo_id: Uuid,
51 release_id: Uuid,
52 tag_name: String,
53 from_sha: Option<String>,
54 to_sha: String,
55 #[serde(default)]
56 language: Option<String>,
57 #[serde(default)]
58 trace: Option<String>,
59 #[serde(default)]
60 request_id: Option<String>,
61 },
62}
63
64impl AgentTask {
65 #[must_use]
66 pub fn job_id(&self) -> Uuid {
67 match self {
68 Self::PullReview { job_id, .. }
69 | Self::CommitReview { job_id, .. }
70 | Self::RepoSummary { job_id, .. }
71 | Self::ReleaseNotes { job_id, .. } => *job_id,
72 }
73 }
74
75 #[must_use]
76 pub fn repo_id(&self) -> Uuid {
77 match self {
78 Self::PullReview { repo_id, .. }
79 | Self::CommitReview { repo_id, .. }
80 | Self::RepoSummary { repo_id, .. }
81 | Self::ReleaseNotes { repo_id, .. } => *repo_id,
82 }
83 }
84
85 /// 稳定字符串标识,与 `ai_job.kind` 的取值一致。
86 #[must_use]
87 pub fn kind_str(&self) -> &'static str {
88 match self {
89 Self::PullReview { .. } => "pull_review",
90 Self::CommitReview { .. } => "commit_review",
91 Self::RepoSummary { .. } => "repo_summary",
92 Self::ReleaseNotes { .. } => "release_notes",
93 }
94 }
95
96 #[must_use]
97 pub fn target_id(&self) -> Option<Uuid> {
98 match self {
99 Self::PullReview {
100 pull_request_id, ..
101 } => Some(*pull_request_id),
102 Self::ReleaseNotes { release_id, .. } => Some(*release_id),
103 Self::CommitReview { .. } | Self::RepoSummary { .. } => None,
104 }
105 }
106
107 /// 该任务请求的输出语言(未指定时为 `None`,由消费侧取配置缺省)。
108 #[must_use]
109 pub fn language(&self) -> Option<&str> {
110 match self {
111 Self::PullReview { language, .. }
112 | Self::CommitReview { language, .. }
113 | Self::RepoSummary { language, .. }
114 | Self::ReleaseNotes { language, .. } => language.as_deref(),
115 }
116 }
117
118 #[must_use]
119 pub fn target_ref(&self) -> Option<String> {
120 match self {
121 Self::PullReview { head_sha, .. } => Some(head_sha.clone()),
122 Self::CommitReview { commit_sha, .. } => Some(commit_sha.clone()),
123 Self::RepoSummary { head_sha, .. } => Some(head_sha.clone()),
124 Self::ReleaseNotes { tag_name, .. } => Some(tag_name.clone()),
125 }
126 }
127
128 pub fn encode(&self) -> Result<Vec<u8>, serde_json::Error> {
129 serde_json::to_vec(self)
130 }
131
132 pub fn decode(payload: &[u8]) -> Result<Self, serde_json::Error> {
133 serde_json::from_slice(payload)
134 }
135
136 #[must_use]
137 pub fn trace(&self) -> Option<&str> {
138 match self {
139 Self::PullReview { trace, .. }
140 | Self::CommitReview { trace, .. }
141 | Self::RepoSummary { trace, .. }
142 | Self::ReleaseNotes { trace, .. } => trace.as_deref(),
143 }
144 }
145
146 #[must_use]
147 pub fn request_id(&self) -> Option<&str> {
148 match self {
149 Self::PullReview { request_id, .. }
150 | Self::CommitReview { request_id, .. }
151 | Self::RepoSummary { request_id, .. }
152 | Self::ReleaseNotes { request_id, .. } => request_id.as_deref(),
153 }
154 }
155}
Deleted lib/ci-protocol/src/ai_languages.rs
@@ -1,134 +0,0 @@
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}
Modified lib/ci-protocol/src/lib.rs View lib/ci-protocol/src/lib.rs
@@ -1,6 +1,4 @@
11pub mod actions;
2pub mod ai;
3pub mod ai_languages;
42pub mod dsl;
53pub mod events;
64pub mod runner;
Deleted lib/config/src/agents.rs
@@ -1,42 +0,0 @@
1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
4#[serde(default)]
5pub struct AgentsConfig {
6 pub max_pagination: u64,
7 pub default_pagination: u64,
8 pub max_collection_name_len: usize,
9 pub max_agent_name_len: usize,
10 pub max_instructions_len: usize,
11 /// Lifetime of the per-run space token attached to agent runs that enable
12 /// the platform MCP (seconds, clamped by mcp_api::space_token).
13 pub mcp_token_ttl_secs: u64,
14}
15
16impl Default for AgentsConfig {
17 fn default() -> Self {
18 Self {
19 max_pagination: 100,
20 default_pagination: 20,
21 max_collection_name_len: 100,
22 max_agent_name_len: 100,
23 max_instructions_len: 32_768,
24 mcp_token_ttl_secs: 86_400,
25 }
26 }
27}
28
29impl AgentsConfig {
30 pub fn validate(&self) -> Result<(), String> {
31 if self.max_pagination == 0 || self.default_pagination == 0 {
32 return Err("agents pagination limits must be greater than 0".to_string());
33 }
34 if self.max_collection_name_len == 0 || self.max_agent_name_len == 0 {
35 return Err("agents name length limits must be greater than 0".to_string());
36 }
37 if self.mcp_token_ttl_secs == 0 {
38 return Err("agents.mcp_token_ttl_secs must be greater than 0".to_string());
39 }
40 Ok(())
41 }
42}
Deleted lib/config/src/ai.rs
@@ -1,127 +0,0 @@
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 /// 思维链预算(reasoning tokens);0 表示不下发该参数,由端点自行决定。
25 pub thinking_budget: u32,
26 pub temperature: f32,
27 pub summary_commit_limit: u64,
28 pub bot_username: String,
29 pub bot_display_name: String,
30 /// 缺省输出语言(BCP-47,如 `zh-CN`、`en`、`ja`、`ko`、`fr`、`de`、`es`…)。
31 ///
32 /// 支持中、英、日、韩、法、德、西、葡、俄、阿等主流语言;填 `auto` 表示
33 /// 沿用代码注释的语言。每个任务还可携带自己的语言(默认取触发者的
34 /// `users.language`),因此这里只是兜底缺省值。
35 /// 代码标识符、命令行、路径保持原文,仅自然语言部分跟随语言设置。
36 pub output_language: String,
37}
38
39impl Default for AiConfig {
40 fn default() -> Self {
41 Self {
42 enabled: false,
43 base_url: String::new(),
44 api_key: String::new(),
45 model: String::new(),
46 review_enabled: true,
47 summary_enabled: true,
48 release_notes_enabled: true,
49 queue_topic: "gitdata:ai".to_string(),
50 dlq_topic: "gitdata:ai:dlq".to_string(),
51 max_attempts: 3,
52 request_timeout_secs: 120,
53 context_max_bytes: 512 * 1024,
54 max_findings: 20,
55 max_output_tokens: 4096,
56 thinking_budget: 0,
57 temperature: 0.2,
58 summary_commit_limit: 50,
59 bot_username: "ai-reviewer".to_string(),
60 bot_display_name: "AI Reviewer".to_string(),
61 output_language: "zh-CN".to_string(),
62 }
63 }
64}
65
66impl AiConfig {
67 /// 端点地址,规范化去除尾部斜杠。
68 pub fn endpoint(&self) -> String {
69 let base = self.base_url.trim().trim_end_matches('/');
70 if base.ends_with("/chat/completions") {
71 return base.to_string();
72 }
73 format!("{base}/chat/completions")
74 }
75
76 pub fn review_on(&self) -> bool {
77 self.enabled && self.review_enabled
78 }
79
80 pub fn summary_on(&self) -> bool {
81 self.enabled && self.summary_enabled
82 }
83
84 pub fn release_notes_on(&self) -> bool {
85 self.enabled && self.release_notes_enabled
86 }
87
88 pub fn validate(&self) -> Result<(), String> {
89 if !self.enabled {
90 return Ok(());
91 }
92 let base = self.base_url.trim();
93 if base.is_empty() {
94 return Err("ai.base_url must not be empty when ai is enabled".to_string());
95 }
96 if !(base.starts_with("http://") || base.starts_with("https://")) {
97 return Err("ai.base_url must be an http(s) URL".to_string());
98 }
99 if self.model.trim().is_empty() {
100 return Err("ai.model must not be empty when ai is enabled".to_string());
101 }
102 if self.queue_topic.trim().is_empty() || self.dlq_topic.trim().is_empty() {
103 return Err("ai queue topics must not be empty".to_string());
104 }
105 if !(self.temperature.is_finite() && (0.0..=2.0).contains(&self.temperature)) {
106 return Err("ai.temperature must be between 0 and 2".to_string());
107 }
108 if self.max_attempts <= 0
109 || self.request_timeout_secs == 0
110 || self.context_max_bytes == 0
111 || self.max_findings == 0
112 || self.max_output_tokens == 0
113 || self.summary_commit_limit == 0
114 {
115 return Err("ai limits must be greater than zero".to_string());
116 }
117 if self.bot_username.trim().is_empty() {
118 return Err("ai.bot_username must not be empty".to_string());
119 }
120 // 语言代码的规范化在消费侧(ci-protocol::ai_languages)完成;这里只要求非空,
121 // 以便部署时可以直接写 ja / ko / fr 等任意 BCP-47 代码。
122 if self.output_language.trim().is_empty() {
123 return Err("ai.output_language must not be empty".to_string());
124 }
125 Ok(())
126 }
127}
Deleted lib/config/src/aihost.rs
@@ -1,64 +0,0 @@
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}
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, aihost). Apps that already serve HTTP (apihost, githost, im)
12/// (synchost, mails). 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,7 +18,6 @@
1818 pub bind_addr: Option<String>,
1919 pub synchost: HealthServiceConfig,
2020 pub mails: HealthServiceConfig,
21 pub aihost: HealthServiceConfig,
2221}
2322
2423impl HealthConfig {
@@ -38,10 +37,6 @@
3837 self.mails.port.unwrap_or(3003)
3938 }
4039
41 pub fn aihost_port(&self) -> u16 {
42 self.aihost.port.unwrap_or(3006)
43 }
44
4540 pub fn is_valid(&self) -> Result<(), String> {
4641 if !self.enabled() {
4742 return Ok(());
@@ -55,9 +50,6 @@
5550 }
5651 if self.mails.port == Some(0) {
5752 return Err("health.mails.port must be greater than 0".to_string());
58 }
59 if self.aihost.port == Some(0) {
60 return Err("health.aihost.port must be greater than 0".to_string());
6153 }
6254 Ok(())
6355 }
Modified lib/config/src/lib.rs View lib/config/src/lib.rs
@@ -1,6 +1,3 @@
1pub mod agents;
2pub mod ai;
3pub mod aihost;
41pub mod apihost;
52pub mod auth;
63pub mod chat;
@@ -16,7 +13,6 @@
1613pub mod internal;
1714pub mod issues;
1815pub mod livekit;
19pub mod mcp;
2016pub mod oauth;
2117pub mod pow;
2218pub mod push;
@@ -24,7 +20,6 @@
2420pub mod redis;
2521pub mod runner;
2622pub mod s3;
27pub mod sandbox_rpc;
2823pub mod scoped;
2924pub mod search;
3025pub mod space;
@@ -37,9 +32,6 @@
3732
3833pub use error::ConfigError;
3934
40use crate::agents::AgentsConfig;
41use crate::ai::AiConfig;
42use crate::aihost::AiHostServerConfig;
4335use crate::apihost::ApihostConfig;
4436use crate::auth::AuthConfig;
4537use crate::chat::ChatConfig;
@@ -55,14 +47,12 @@
5547use crate::internal::InternalConfig;
5648use crate::issues::IssueConfig;
5749use crate::livekit::LiveKitConfig;
58use crate::mcp::McpConfig;
5950use crate::oauth::OAuthConfig;
6051use crate::pow::PowConfig;
6152use crate::push::PushConfig;
6253use crate::redis::RedisConfig;
6354use crate::runner::RunnerConfig;
6455use crate::s3::S3Config;
65use crate::sandbox_rpc::SandboxRpcConfig;
6656use crate::search::SearchConfig;
6757use crate::space::SpaceConfig;
6858use crate::user::UserConfig;
@@ -86,10 +76,7 @@
8676 pub quota: quota::QuotaConfig,
8777 pub space: SpaceConfig,
8878 pub issues: IssueConfig,
89 pub agents: AgentsConfig,
90 pub sandbox_rpc: SandboxRpcConfig,
9179 pub livekit: LiveKitConfig,
92 pub mcp: McpConfig,
9380 pub oauth: OAuthConfig,
9481 pub git: GitConfig,
9582 pub email: EmailConfig,
@@ -102,8 +89,6 @@
10289 pub ci: CiConfig,
10390 pub webhook: WebhookConfig,
10491 pub runner: RunnerConfig,
105 pub ai: AiConfig,
106 pub aihost: AiHostServerConfig,
10792 pub push: PushConfig,
10893}
10994
@@ -131,15 +116,8 @@
131116 self.internal.is_valid().map_err(ConfigError::Validation)?;
132117 self.ci.validate().map_err(ConfigError::Validation)?;
133118 self.webhook.is_valid().map_err(ConfigError::Validation)?;
134 self.ai.validate().map_err(ConfigError::Validation)?;
135 self.aihost.is_valid().map_err(ConfigError::Validation)?;
136119 self.livekit.is_valid().map_err(ConfigError::Validation)?;
137 self.mcp.is_valid().map_err(ConfigError::Validation)?;
138120 self.oauth.is_valid().map_err(ConfigError::Validation)?;
139 self.agents.validate().map_err(ConfigError::Validation)?;
140 self.sandbox_rpc
141 .is_valid()
142 .map_err(ConfigError::Validation)?;
143121 self.push.is_valid().map_err(ConfigError::Validation)?;
144122 Ok(())
145123 }
Deleted lib/config/src/mcp.rs
@@ -1,167 +0,0 @@
1use std::collections::HashSet;
2use std::net::IpAddr;
3
4use ipnet::IpNet;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8#[serde(default)]
9pub struct McpConfig {
10 pub enabled: Option<bool>,
11 pub bind_addr: Option<String>,
12 pub port: Option<u16>,
13 pub allowed_hosts: Vec<String>,
14 pub internal_cidrs: Vec<String>,
15 pub trusted_proxies: Vec<String>,
16 pub internal_keys: Vec<InternalKeyConfig>,
17 /// URL the sandbox-agent runtime uses to reach this MCP endpoint (e.g.
18 /// `http://127.0.0.1:3007/v1`). Empty disables the agent-chat MCP option.
19 pub runtime_url: Option<String>,
20}
21
22impl Default for McpConfig {
23 fn default() -> Self {
24 Self {
25 enabled: None,
26 bind_addr: None,
27 port: None,
28 allowed_hosts: Vec::new(),
29 internal_cidrs: vec!["127.0.0.1/32".to_string(), "::1/128".to_string()],
30 trusted_proxies: Vec::new(),
31 internal_keys: Vec::new(),
32 runtime_url: None,
33 }
34 }
35}
36
37#[derive(Debug, Clone, Default, Serialize, Deserialize)]
38#[serde(default)]
39pub struct InternalKeyConfig {
40 pub name: String,
41 pub token: Option<String>,
42 pub token_hash: Option<String>,
43 pub enabled: Option<bool>,
44}
45
46impl InternalKeyConfig {
47 pub fn enabled(&self) -> bool {
48 self.enabled.unwrap_or(true)
49 }
50}
51
52impl McpConfig {
53 pub fn enabled(&self) -> bool {
54 self.enabled.unwrap_or(true)
55 }
56
57 pub fn bind_addr(&self) -> &str {
58 self.bind_addr.as_deref().unwrap_or("0.0.0.0")
59 }
60
61 pub fn port(&self) -> u16 {
62 self.port.unwrap_or(3007)
63 }
64
65 /// Runtime-facing URL of this MCP endpoint; empty when the agent chat MCP
66 /// option must stay unavailable.
67 pub fn runtime_url(&self) -> &str {
68 self.runtime_url.as_deref().unwrap_or("").trim()
69 }
70
71 pub fn is_valid(&self) -> Result<(), String> {
72 if !self.enabled() {
73 return Ok(());
74 }
75 let bind = self.bind_addr();
76 if bind.parse::<IpAddr>().is_err() {
77 return Err(format!("mcp.bind_addr is not a valid IP: {bind:?}"));
78 }
79 if self.port == Some(0) {
80 return Err("mcp.port must be greater than 0".to_string());
81 }
82 if !self.runtime_url().is_empty() {
83 let url = url::Url::parse(self.runtime_url())
84 .map_err(|e| format!("mcp.runtime_url is not a valid URL: {e}"))?;
85 if url.scheme() != "http" && url.scheme() != "https" {
86 return Err("mcp.runtime_url must use http or https".to_string());
87 }
88 }
89 for host in &self.allowed_hosts {
90 if host.trim().is_empty() {
91 return Err("mcp.allowed_hosts must not contain empty entries".to_string());
92 }
93 }
94 for cidr in &self.internal_cidrs {
95 parse_net(cidr)
96 .map_err(|e| format!("mcp.internal_cidrs entry {cidr:?} is invalid: {e}"))?;
97 }
98 for cidr in &self.trusted_proxies {
99 parse_net(cidr)
100 .map_err(|e| format!("mcp.trusted_proxies entry {cidr:?} is invalid: {e}"))?;
101 }
102 let mut names = HashSet::new();
103 for key in &self.internal_keys {
104 let name = key.name.trim();
105 if name.is_empty() {
106 return Err("mcp.internal_keys[].name must not be empty".to_string());
107 }
108 if !names.insert(name.to_string()) {
109 return Err(format!("mcp.internal_keys name {name:?} is duplicated"));
110 }
111 match (&key.token, &key.token_hash) {
112 (Some(token), None) => {
113 if token.trim().len() < 16 {
114 return Err(format!(
115 "mcp.internal_keys[{name}].token must be at least 16 characters"
116 ));
117 }
118 }
119 (None, Some(hash)) => {
120 let hash = hash.trim();
121 if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
122 return Err(format!(
123 "mcp.internal_keys[{name}].token_hash must be 64 hex characters"
124 ));
125 }
126 }
127 (None, None) => {
128 return Err(format!(
129 "mcp.internal_keys[{name}] must set token or token_hash"
130 ));
131 }
132 (Some(_), Some(_)) => {
133 return Err(format!(
134 "mcp.internal_keys[{name}] must set only one of token or token_hash"
135 ));
136 }
137 }
138 }
139 Ok(())
140 }
141
142 pub fn parsed_internal_cidrs(&self) -> Vec<IpNet> {
143 self.internal_cidrs
144 .iter()
145 .filter_map(|cidr| parse_net(cidr).ok())
146 .collect()
147 }
148
149 pub fn parsed_trusted_proxies(&self) -> Vec<IpNet> {
150 self.trusted_proxies
151 .iter()
152 .filter_map(|cidr| parse_net(cidr).ok())
153 .collect()
154 }
155}
156
157fn parse_net(raw: &str) -> Result<IpNet, String> {
158 let raw = raw.trim();
159 if let Ok(net) = raw.parse::<IpNet>() {
160 return Ok(net);
161 }
162 if let Ok(ip) = raw.parse::<IpAddr>() {
163 let prefix = if ip.is_ipv4() { 32 } else { 128 };
164 return IpNet::new(ip, prefix).map_err(|e| e.to_string());
165 }
166 Err("expected an IP or CIDR".to_string())
167}
Deleted lib/config/src/sandbox_rpc.rs
@@ -1,72 +0,0 @@
1use serde::{Deserialize, Serialize};
2
3/// gRPC bridge to the standalone `sandbox-agent` runtime. The main workspace
4/// never links the runtime; it only reaches it through this endpoint.
5#[derive(Debug, Clone, Serialize, Deserialize)]
6#[serde(default)]
7pub struct SandboxRpcConfig {
8 pub enabled: bool,
9 pub endpoint: String,
10 /// HTTP base of the runtime's fanout service (SSE events/tokens). Empty
11 /// disables streaming; the web UI then falls back to polling.
12 pub fanout_endpoint: String,
13 pub trusted_key: String,
14 pub request_timeout_secs: u64,
15 /// Lifetime of the JWT minted per SSE connection for the fanout streams.
16 pub stream_token_ttl_secs: u64,
17}
18
19impl Default for SandboxRpcConfig {
20 fn default() -> Self {
21 Self {
22 enabled: false,
23 endpoint: "http://127.0.0.1:8091".to_string(),
24 fanout_endpoint: "http://127.0.0.1:8090".to_string(),
25 trusted_key: String::new(),
26 request_timeout_secs: 30,
27 stream_token_ttl_secs: 600,
28 }
29 }
30}
31
32impl SandboxRpcConfig {
33 pub fn is_valid(&self) -> Result<(), String> {
34 if !self.enabled {
35 return Ok(());
36 }
37 validate_endpoint("sandbox_rpc.endpoint", &self.endpoint, false)?;
38 if !self.fanout_endpoint.trim().is_empty() {
39 // The fanout base is browser-facing: an origin-relative prefix
40 // (e.g. `/sandbox` behind a reverse proxy) is the CORS-free default.
41 validate_endpoint("sandbox_rpc.fanout_endpoint", &self.fanout_endpoint, true)?;
42 }
43 if self.trusted_key.trim().is_empty() {
44 return Err("sandbox_rpc.trusted_key must not be empty when enabled".to_string());
45 }
46 if self.request_timeout_secs == 0 {
47 return Err("sandbox_rpc.request_timeout_secs must be greater than 0".to_string());
48 }
49 if self.stream_token_ttl_secs == 0 {
50 return Err("sandbox_rpc.stream_token_ttl_secs must be greater than 0".to_string());
51 }
52 Ok(())
53 }
54}
55
56fn validate_endpoint(field: &str, raw: &str, allow_relative: bool) -> Result<(), String> {
57 let endpoint = raw.trim();
58 if endpoint.is_empty() {
59 return Err(format!("{field} must not be empty"));
60 }
61 if allow_relative && endpoint.starts_with('/') {
62 if endpoint.starts_with("//") {
63 return Err(format!("{field} must not be protocol-relative"));
64 }
65 return Ok(());
66 }
67 let url = url::Url::parse(endpoint).map_err(|e| format!("{field} is not a valid URL: {e}"))?;
68 if url.scheme() != "http" && url.scheme() != "https" {
69 return Err(format!("{field} must use http or https"));
70 }
71 Ok(())
72}
Modified lib/config/src/scoped.rs View lib/config/src/scoped.rs
@@ -1,9 +1,6 @@
11use serde::Deserialize;
22use serde::de::DeserializeOwned;
33
4use crate::agents::AgentsConfig;
5use crate::ai::AiConfig;
6use crate::aihost::AiHostServerConfig;
74use crate::apihost::ApihostConfig as ApihostSection;
85use crate::auth::AuthConfig;
96use crate::chat::ChatConfig;
@@ -19,13 +16,11 @@
1916use crate::import::ImportConfig;
2017use crate::issues::IssueConfig;
2118use crate::livekit::LiveKitConfig;
22use crate::mcp::McpConfig;
2319use crate::pow::PowConfig;
2420use crate::push::PushConfig;
2521use crate::quota::QuotaConfig;
2622use crate::redis::RedisConfig;
2723use crate::s3::S3Config;
28use crate::sandbox_rpc::SandboxRpcConfig;
2924use crate::search::SearchConfig;
3025use crate::space::SpaceConfig;
3126use crate::user::UserConfig;
@@ -151,7 +146,6 @@
151146 pub git: GitConfig,
152147 pub githost: GithostSection,
153148 pub ci: CiConfig,
154 pub ai: AiConfig,
155149}
156150
157151impl GithostConfig {
@@ -166,7 +160,6 @@
166160 git: self.git,
167161 githost: self.githost,
168162 ci: self.ci,
169 ai: self.ai,
170163 ..Default::default()
171164 }
172165 }
@@ -178,8 +171,7 @@
178171 self.redis.validate()?;
179172 self.email.is_valid()?;
180173 self.email_queue.is_valid()?;
181 self.githost.is_valid()?;
182 self.ai.validate()
174 self.githost.is_valid()
183175 }
184176}
185177
@@ -199,16 +191,12 @@
199191 pub quota: QuotaConfig,
200192 pub space: SpaceConfig,
201193 pub issues: IssueConfig,
202 pub agents: AgentsConfig,
203 pub sandbox_rpc: SandboxRpcConfig,
204 pub mcp: McpConfig,
205194 pub git: GitConfig,
206195 pub email: EmailConfig,
207196 pub email_queue: EmailQueueConfig,
208197 pub apihost: ApihostSection,
209198 pub search: SearchConfig,
210199 pub import: ImportConfig,
211 pub ai: AiConfig,
212200 pub livekit: LiveKitConfig,
213201 pub push: PushConfig,
214202}
@@ -229,16 +217,12 @@
229217 quota: self.quota,
230218 space: self.space,
231219 issues: self.issues,
232 agents: self.agents,
233 sandbox_rpc: self.sandbox_rpc,
234 mcp: self.mcp,
235220 git: self.git,
236221 email: self.email,
237222 email_queue: self.email_queue,
238223 apihost: self.apihost,
239224 search: self.search,
240225 import: self.import,
241 ai: self.ai,
242226 livekit: self.livekit,
243227 push: self.push,
244228 ..Default::default()
@@ -254,16 +238,12 @@
254238 self.pow.validate()?;
255239 self.auth.validate()?;
256240 self.chat.is_valid()?;
257 self.mcp.is_valid()?;
258241 self.email.is_valid()?;
259242 self.email_queue.is_valid()?;
260243 self.search.validate()?;
261244 self.import.validate()?;
262245 self.apihost.is_valid()?;
263 self.ai.validate()?;
264246 self.livekit.is_valid()?;
265 self.agents.validate()?;
266 self.sandbox_rpc.is_valid()?;
267247 self.push.is_valid()
268248 }
269249}
@@ -308,102 +288,5 @@
308288 self.email.is_valid()?;
309289 self.email_queue.is_valid()?;
310290 self.im.is_valid()
311 }
312}
313
314#[derive(Clone, Debug, Default, Deserialize)]
315#[serde(default)]
316pub struct AiHostConfig {
317 pub database: DatabaseConfig,
318 pub redis: RedisConfig,
319 pub git: GitConfig,
320 pub email: EmailConfig,
321 pub email_queue: EmailQueueConfig,
322 pub s3: S3Config,
323 pub ai: AiConfig,
324 pub aihost: AiHostServerConfig,
325 pub health: HealthConfig,
326}
327
328impl AiHostConfig {
329 pub fn into_app_config(self) -> AppConfig {
330 AppConfig {
331 database: self.database,
332 redis: self.redis,
333 git: self.git,
334 email: self.email,
335 email_queue: self.email_queue,
336 s3: self.s3,
337 ai: self.ai,
338 aihost: self.aihost,
339 health: self.health,
340 ..Default::default()
341 }
342 }
343}
344
345impl ScopedConfig for AiHostConfig {
346 fn validate_scoped(&self) -> Result<(), String> {
347 self.database.validate()?;
348 self.redis.validate()?;
349 self.ai.validate()?;
350 self.aihost.is_valid()?;
351 self.health.is_valid()
352 }
353}
354
355#[derive(Clone, Debug, Default, Deserialize)]
356#[serde(default)]
357pub struct McpHostConfig {
358 pub database: DatabaseConfig,
359 pub redis: RedisConfig,
360 pub s3: S3Config,
361 pub email: EmailConfig,
362 pub email_queue: EmailQueueConfig,
363 pub push: PushConfig,
364 pub auth: AuthConfig,
365 pub user: UserConfig,
366 pub quota: QuotaConfig,
367 pub space: SpaceConfig,
368 pub issues: IssueConfig,
369 pub git: GitConfig,
370 pub web: WebConfig,
371 pub ai: AiConfig,
372 pub mcp: McpConfig,
373}
374
375impl McpHostConfig {
376 pub fn into_app_config(self) -> AppConfig {
377 AppConfig {
378 database: self.database,
379 redis: self.redis,
380 s3: self.s3,
381 email: self.email,
382 email_queue: self.email_queue,
383 push: self.push,
384 auth: self.auth,
385 user: self.user,
386 quota: self.quota,
387 space: self.space,
388 issues: self.issues,
389 git: self.git,
390 web: self.web,
391 ai: self.ai,
392 mcp: self.mcp,
393 ..Default::default()
394 }
395 }
396}
397
398impl ScopedConfig for McpHostConfig {
399 fn validate_scoped(&self) -> Result<(), String> {
400 self.database.validate()?;
401 self.redis.validate()?;
402 self.auth.validate()?;
403 self.email.is_valid()?;
404 self.email_queue.is_valid()?;
405 self.ai.validate()?;
406 self.mcp.is_valid()?;
407 self.push.is_valid()
408291 }
409292}
Deleted lib/export/src/authz.rs
@@ -1,42 +0,0 @@
1use std::collections::HashSet;
2use std::sync::{OnceLock, RwLock};
3
4
5
6static SYSTEM_UID: OnceLock<i64> = OnceLock::new();
7static SPACE_BOTS: OnceLock<RwLock<HashSet<i64>>> = OnceLock::new();
8
9/// Register the platform system actor (AI bot) that MCP internal keys act as.
10/// Once set, every authorization check treats this uid as the owner of every
11/// space and repository. The account is a bot with login prohibited, so it can
12/// only ever be reached through the internal MCP key path.
13pub fn set_system_uid(uid: i64) {
14 let _ = SYSTEM_UID.set(uid);
15}
16
17/// Register a per-space bot that just signed a scoped top-level credential.
18/// Like [`set_system_uid`], registered bots pass every owner-level check; the
19/// credential layer is responsible for binding them to a single space (MCP
20/// tool dispatch and the Git HTTP auth entry points both enforce the bind).
21/// Space bots have no password, no sessions and no access keys, so this uid
22/// can only be reached with the token that registered it.
23pub fn register_space_bot(uid: i64) {
24 let bots = SPACE_BOTS.get_or_init(|| RwLock::new(HashSet::new()));
25 if let Ok(mut bots) = bots.write() {
26 bots.insert(uid);
27 }
28}
29
30pub fn system_uid() -> Option<i64> {
31 SYSTEM_UID.get().copied()
32}
33
34pub fn is_system_uid(uid: i64) -> bool {
35 if SYSTEM_UID.get().is_some_and(|value| *value == uid) {
36 return true;
37 }
38 SPACE_BOTS
39 .get()
40 .and_then(|bots| bots.read().ok().map(|bots| bots.contains(&uid)))
41 .unwrap_or(false)
42}
Modified lib/export/src/lib.rs View lib/export/src/lib.rs
@@ -1,5 +1,4 @@
11mod api_error;
2pub mod authz;
32pub mod database;
43pub mod error;
54pub mod health;
@@ -14,7 +13,6 @@
1413pub mod rest;
1514pub mod serde_datetime;
1615pub mod serde_patch;
17pub mod space_token;
1816pub mod storage;
1917pub mod telemetry;
2018pub mod urls;
Modified lib/export/src/metrics.rs View lib/export/src/metrics.rs
@@ -61,28 +61,6 @@
6161 .build()
6262});
6363
64static AI_JOBS: LazyLock<Counter<u64>> = LazyLock::new(|| {
65 meter()
66 .u64_counter("ai.jobs")
67 .with_description("AI jobs processed by kind and outcome")
68 .build()
69});
70
71static AI_JOB_DURATION: LazyLock<Histogram<f64>> = LazyLock::new(|| {
72 meter()
73 .f64_histogram("ai.job.duration")
74 .with_unit("s")
75 .with_description("AI job wall-clock duration")
76 .build()
77});
78
79static AI_TOKENS: LazyLock<Counter<u64>> = LazyLock::new(|| {
80 meter()
81 .u64_counter("ai.tokens")
82 .with_description("Model tokens consumed by type")
83 .build()
84});
85
8664static EMAIL_SENT: LazyLock<Counter<u64>> = LazyLock::new(|| {
8765 meter()
8866 .u64_counter("email.sent")
@@ -172,38 +150,6 @@
172150
173151pub fn ci_webhook_delivery(outcome: &'static str) {
174152 CI_WEBHOOK_DELIVERIES.add(1, &[KeyValue::new("outcome", outcome)]);
175}
176
177pub fn ai_job(kind: &str, outcome: &'static str) {
178 AI_JOBS.add(
179 1,
180 &[
181 KeyValue::new("kind", kind.to_string()),
182 KeyValue::new("outcome", outcome),
183 ],
184 );
185}
186
187pub fn ai_job_duration(kind: &str, duration: Duration) {
188 AI_JOB_DURATION.record(
189 duration.as_secs_f64(),
190 &[KeyValue::new("kind", kind.to_string())],
191 );
192}
193
194pub fn ai_tokens(prompt: Option<i32>, completion: Option<i32>) {
195 if let Some(prompt) = prompt {
196 AI_TOKENS.add(
197 u64::try_from(prompt).unwrap_or(0),
198 &[KeyValue::new("type", "prompt")],
199 );
200 }
201 if let Some(completion) = completion {
202 AI_TOKENS.add(
203 u64::try_from(completion).unwrap_or(0),
204 &[KeyValue::new("type", "completion")],
205 );
206 }
207153}
208154
209155pub fn email_sent(outcome: &'static str) {
Modified lib/export/src/push.rs View lib/export/src/push.rs
@@ -30,7 +30,7 @@
3030 Reply,
3131 Dm,
3232 Reminder,
33 /// 工单、邀请、AI 评审、系统公告等:不受逐类开关控制。
33 /// 工单、邀请、系统公告等:不受逐类开关控制。
3434 Other,
3535}
3636
Deleted lib/export/src/space_token.rs
@@ -1,102 +0,0 @@
1use serde::{Deserialize, Serialize};
2use sha2::{Digest, Sha256};
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6use crate::error::ExportError;
7use crate::redis::{self, AppRedis};
8
9/// Space top-level tokens are opaque short-lived credentials bound to a single
10/// space. They are issued through `mcp_api::space_token` (a library-only
11/// service, never an HTTP route or MCP tool) and verified here so that both
12/// the MCP endpoint and the Git HTTP endpoint can accept the same token.
13pub const TOKEN_PREFIX: &str = "spt_";
14const KEY_PREFIX: &str = "mcp:space_token:";
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SpaceTokenRecord {
18 pub token_id: Uuid,
19 pub space_id: Uuid,
20 pub space_name: String,
21 pub bot_uid: i64,
22 pub secret_hash: String,
23 #[serde(default)]
24 pub issued_by: Option<String>,
25 pub created_at_unix: i64,
26 pub expires_at_unix: i64,
27}
28
29pub fn looks_like(token: &str) -> bool {
30 token.starts_with(TOKEN_PREFIX)
31}
32
33pub fn key(token_id: Uuid) -> String {
34 format!("{KEY_PREFIX}{token_id}")
35}
36
37pub fn parse(token: &str) -> Option<(Uuid, &str)> {
38 let rest = token.strip_prefix(TOKEN_PREFIX)?;
39 let (id, secret) = rest.split_once('.')?;
40 if secret.is_empty() {
41 return None;
42 }
43 let id = Uuid::parse_str(id).ok()?;
44 Some((id, secret))
45}
46
47pub fn hash_secret(secret: &str) -> String {
48 let digest = Sha256::digest(secret.as_bytes());
49 digest.iter().map(|byte| format!("{byte:02x}")).collect()
50}
51
52pub async fn store(
53 redis: &AppRedis,
54 record: &SpaceTokenRecord,
55 ttl_secs: u64,
56) -> Result<(), ExportError> {
57 let value = serde_json::to_string(record)
58 .map_err(|error| ExportError::InvalidConfig(format!("space token encode: {error}")))?;
59 redis::set_string(redis, &key(record.token_id), &value, ttl_secs.max(1)).await?;
60 Ok(())
61}
62
63pub async fn verify(
64 redis: &AppRedis,
65 token: &str,
66) -> Result<Option<SpaceTokenRecord>, ExportError> {
67 let Some((token_id, secret)) = parse(token) else {
68 return Ok(None);
69 };
70 let Some(raw) = redis::get_string(redis, &key(token_id)).await? else {
71 return Ok(None);
72 };
73 let Ok(record) = serde_json::from_str::<SpaceTokenRecord>(&raw) else {
74 return Ok(None);
75 };
76 if record.expires_at_unix <= OffsetDateTime::now_utc().unix_timestamp() {
77 return Ok(None);
78 }
79 let computed = hash_secret(secret);
80 if !constant_time_eq(computed.as_bytes(), record.secret_hash.as_bytes()) {
81 return Ok(None);
82 }
83 Ok(Some(record))
84}
85
86/// Deletes the token; returns whether it still existed.
87pub async fn revoke(redis: &AppRedis, token_id: Uuid) -> Result<bool, ExportError> {
88 let existed = redis::exists(redis, &key(token_id)).await?;
89 redis::delete(redis, &key(token_id)).await?;
90 Ok(existed)
91}
92
93fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
94 if a.len() != b.len() {
95 return false;
96 }
97 let mut diff: u8 = 0;
98 for (x, y) in a.iter().zip(b.iter()) {
99 diff |= x ^ y;
100 }
101 diff == 0
102}
Deleted lib/git/src/handler/ai_review/list_commit_ai_reviews.rs
@@ -1,37 +0,0 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use jwt::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}
Deleted lib/git/src/handler/ai_review/mod.rs
@@ -1,2 +0,0 @@
1pub mod list_commit_ai_reviews;
2pub mod trigger_ai_review;
Deleted lib/git/src/handler/ai_review/trigger_ai_review.rs
@@ -1,54 +0,0 @@
1use axum::Json;
2use axum::extract::{Path, State};
3use export::rest::AppResponseData;
4use jwt::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}
Deleted lib/git/src/handler/ai_summary/get_ai_summary.rs
@@ -1,37 +0,0 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use jwt::Session;
4
5use crate::errors::{ApiError, GitApiError};
6use crate::handler::resolve_reader;
7use crate::service::GitService;
8use crate::service::ai_summary::RepoAiSummaryStateDto;
9
10#[utoipa::path(
11 get,
12 path = "/repos/{space_name}/{repo_name}/ai-summary",
13 tag = "ai",
14 description = "读取当前默认分支的更新总结状态:已缓存的总结、仍未结束的生成作业,以及两者共同的 head。首次生成期间 data.summary 为 null 而 data.pending 非空。",
15 params(crate::handler::RepoPath),
16 responses(
17 (status = 200, description = "repo ai summary state", body = AppResponseData<RepoAiSummaryStateDto>)
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<RepoAiSummaryStateDto>, 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}
Deleted lib/git/src/handler/ai_summary/mod.rs
@@ -1,2 +0,0 @@
1pub mod get_ai_summary;
2pub mod trigger_ai_summary;
Deleted lib/git/src/handler/ai_summary/trigger_ai_summary.rs
@@ -1,41 +0,0 @@
1use axum::Json;
2use axum::extract::{Path, State};
3use export::rest::AppResponseData;
4use jwt::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}
Modified lib/git/src/handler/releases/mod.rs View lib/git/src/handler/releases/mod.rs
@@ -2,5 +2,4 @@
22pub mod delete_release;
33pub mod get_release;
44pub mod list_releases;
5pub mod redraft_release;
65pub mod update_release;
Deleted lib/git/src/handler/releases/redraft_release.rs
@@ -1,41 +0,0 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use jwt::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}
Deleted lib/git/src/handler/ai_languages.rs
@@ -1,64 +0,0 @@
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(),
49 english.trim_end_matches(')').to_string(),
50 ),
51 None => (display.clone(), display),
52 }
53 };
54 LanguageOptionDto {
55 code: code.to_string(),
56 native_name: native,
57 english_name: english,
58 auto,
59 default: code == configured,
60 }
61 })
62 .collect();
63 Ok(AppResponseData::ok(options))
64}
Modified lib/git/src/handler/mod.rs View lib/git/src/handler/mod.rs
@@ -1,6 +1,3 @@
1pub mod ai_languages;
2pub mod ai_review;
3pub mod ai_summary;
41pub mod analysis;
52pub mod archives;
63pub mod blame;
Deleted lib/git/src/service/ai_review.rs
@@ -1,215 +0,0 @@
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(row: models::ai::ai_review_result::Model, commit_sha: &str) -> Self {
35 let findings: Vec<models::ai::ai_review_result::AiFinding> =
36 serde_json::from_value(row.findings_json).unwrap_or_default();
37 Self {
38 id: row.id,
39 commit_sha: commit_sha.to_string(),
40 verdict: row.verdict,
41 summary: row.summary,
42 findings: findings
43 .into_iter()
44 .map(|finding| AiFindingDto {
45 path: finding.path,
46 line: finding.line,
47 side: finding.side,
48 severity: finding.severity,
49 body: finding.body,
50 })
51 .collect(),
52 truncated: row.truncated,
53 language: row.language,
54 created_at: row.created_at,
55 }
56 }
57}
58
59/// AI 评审使用的 commit status context。
60pub const AI_REVIEW_CONTEXT: &str = "ai-review";
61
62#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
63pub struct AiReviewJobDto {
64 pub job_id: Uuid,
65 pub status: String,
66 pub model: String,
67}
68
69/// One commit-status write for the `ai-review` context. Grouped so the four
70/// string-ish arguments cannot be passed in the wrong order.
71pub struct AiReviewStatus {
72 pub sha: String,
73 pub state: crate::service::commits::CommitStatusDtoState,
74 pub description: String,
75 pub target_url: Option<String>,
76}
77
78impl GitService {
79 /// 触发(或重跑)某个提交的 AI 评审。
80 ///
81 /// 入队前先写一条 `pending` 的 commit status,让 PR 检查页即时可见;
82 /// 评审完成后由 aihost 改写为 success/failure/error。
83 pub async fn trigger_commit_ai_review(
84 &self,
85 space_name: String,
86 repo_name: String,
87 revision: String,
88 creator: i64,
89 language: Option<String>,
90 ) -> Result<AiReviewJobDto, GitError> {
91 let cfg = &self.state.config.ai;
92 if !cfg.review_on() {
93 return Err(GitError::InvalidArgument(
94 "ai review is disabled by config".to_string(),
95 ));
96 }
97 let ctx = self.context(space_name.clone(), repo_name.clone()).await?;
98 let sha = self
99 .baby(&ctx)
100 .rev_parse(&revision)
101 .await
102 .map_err(crate::share::errors::map_baby)?
103 .to_string();
104 self.sync_commit(&ctx, &sha).await?;
105
106 let task = ci_protocol::ai::AgentTask::CommitReview {
107 job_id: Uuid::now_v7(),
108 repo_id: ctx.model.id,
109 commit_sha: sha.clone(),
110 language: match language.filter(|value| !value.trim().is_empty()) {
111 Some(explicit) => Some(ci_protocol::ai_languages::normalize(&explicit)),
112 None => {
113 crate::sync::ai_language::actor_language(self.db_read(), Some(creator)).await
114 }
115 },
116 trace: export::telemetry::traceparent(),
117 request_id: export::telemetry::request_id(),
118 };
119 let job_id =
120 crate::sync::ai_task::submit_ai_task(self.db_write(), &self.state.redis, cfg, &task)
121 .await
122 .map_err(|e| GitError::Internal(format!("submit ai review: {e}")))?;
123
124 self.set_ai_review_status(
125 space_name,
126 repo_name,
127 creator,
128 AiReviewStatus {
129 sha,
130 state: crate::service::commits::CommitStatusDtoState::Pending,
131 description: "AI 评审进行中".to_string(),
132 target_url: None,
133 },
134 )
135 .await?;
136
137 Ok(AiReviewJobDto {
138 job_id,
139 status: "pending".to_string(),
140 model: cfg.model.trim().to_string(),
141 })
142 }
143
144 /// 写入/更新 `ai-review` 这一条 commit status。
145 pub async fn set_ai_review_status(
146 &self,
147 space_name: String,
148 repo_name: String,
149 creator: i64,
150 status: AiReviewStatus,
151 ) -> Result<(), GitError> {
152 let AiReviewStatus {
153 sha,
154 state,
155 description,
156 target_url,
157 } = status;
158 self.set_commit_status(
159 space_name,
160 repo_name,
161 creator,
162 crate::service::commits::SetCommitStatusCmd {
163 sha,
164 state,
165 context: AI_REVIEW_CONTEXT.to_string(),
166 description: Some(description),
167 target_url,
168 },
169 )
170 .await?;
171 Ok(())
172 }
173
174 /// 该提交是否已有 AI 评审结果(用于前端展示就绪状态)。
175 pub async fn has_ai_review(&self, repo_id: Uuid, commit_sha: &str) -> bool {
176 use models::ai::ai_review_result;
177 use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
178 ai_review_result::Entity::find()
179 .filter(ai_review_result::Column::RepoId.eq(repo_id))
180 .filter(ai_review_result::Column::CommitSha.eq(commit_sha))
181 .one(self.db_read())
182 .await
183 .ok()
184 .flatten()
185 .is_some()
186 }
187
188 /// 列出某个提交的全部 AI 评审结果。
189 pub async fn list_commit_ai_reviews(
190 &self,
191 space_name: String,
192 repo_name: String,
193 revision: String,
194 ) -> Result<Vec<crate::service::ai_review::CommitAiReviewDto>, GitError> {
195 use models::ai::ai_review_result;
196 use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
197 let ctx = self.context(space_name, repo_name).await?;
198 let sha = self
199 .baby(&ctx)
200 .rev_parse(&revision)
201 .await
202 .map_err(crate::share::errors::map_baby)?
203 .to_string();
204 let rows = ai_review_result::Entity::find()
205 .filter(ai_review_result::Column::RepoId.eq(ctx.model.id))
206 .filter(ai_review_result::Column::CommitSha.eq(sha.clone()))
207 .order_by_desc(ai_review_result::Column::CreatedAt)
208 .all(self.db_read())
209 .await?;
210 Ok(rows
211 .into_iter()
212 .map(|row| crate::service::ai_review::CommitAiReviewDto::from_row(row, &sha))
213 .collect())
214 }
215}
Deleted lib/git/src/service/ai_summary.rs
@@ -1,244 +0,0 @@
1use crate::share::errors::GitError;
2use models::ai::ai_job::AiJobStatus;
3use models::ai::repo_ai_summary;
4use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
5use serde::{Deserialize, Serialize};
6use time::{Duration, OffsetDateTime};
7use utoipa::ToSchema;
8use uuid::Uuid;
9
10/// 一条作业还能算「在途」多久。
11///
12/// 队列没有租约:worker 崩溃、容器被重启、或在投递前进程退出,都会在
13/// `ai_job` 里留下一条永远停在 `pending`/`running` 的行。管理端的重投只覆盖
14/// `failed`,没人回收这些行,所以「有没有作业」不能只看状态,否则几天前的残骸
15/// 会一直在页面上显示「正在生成」,还会把新任务挡在幂等折叠之外。
16///
17/// 窗口由既有配置推出,不引入新旋钮:单次请求超时 × 尝试次数,再放宽 4 倍
18/// 容纳排队与重试。默认值(120s × 3 × 4)即 24 分钟——超过这个时间还没结束的
19/// 作业,对用户来说已经不是在途,而是失败。
20fn inflight_window(cfg: &config::ai::AiConfig) -> Duration {
21 let seconds = cfg
22 .request_timeout_secs
23 .max(60)
24 .saturating_mul(cfg.max_attempts.max(1) as u64)
25 .saturating_mul(4);
26 Duration::seconds(seconds.min(6 * 60 * 60) as i64)
27}
28
29/// 仓库 AI 总结视图。
30#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
31pub struct RepoAiSummaryDto {
32 pub id: Uuid,
33 pub repo_id: Uuid,
34 pub head_sha: String,
35 pub base_sha: Option<String>,
36 pub commit_count: i32,
37 pub content: String,
38 pub highlights: Vec<String>,
39 pub risks: Vec<String>,
40 pub model: String,
41 /// 生成该总结时使用的输出语言(BCP-47 或 `auto`)。
42 pub language: String,
43 pub created_at: OffsetDateTime,
44 pub updated_at: OffsetDateTime,
45 /// 该总结是否对应当前默认分支提示。
46 pub current: bool,
47}
48
49/// 一条仍未结束的总结作业。
50///
51/// 与结果分开建模:作业可能正为**另一个** head 生成(排队期间有人推送),
52/// 而结果行只能属于它自己的 head。把两者塞进同一个结构会让「有结果」与
53/// 「正在生成」互相掩盖,正是首次生成时页面什么都看不到的原因。
54#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
55pub struct RepoAiSummaryJobDto {
56 pub job_id: Uuid,
57 /// 该作业要总结的 head。
58 pub head_sha: String,
59 /// `pending`(已入队)或 `running`(执行中)。
60 pub status: AiJobStatus,
61 pub language: String,
62 pub created_at: OffsetDateTime,
63 pub started_at: Option<OffsetDateTime>,
64}
65
66/// 总结页签的完整状态:默认分支当前 tip、该 tip 上的结果、以及未结束的作业。
67///
68/// 三个字段一起返回,调用方才能区分「还没生成」与「正在生成」——只给结果时
69/// 两者都是 `null`,界面就只能显示空态。
70#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
71pub struct RepoAiSummaryStateDto {
72 /// 默认分支当前 tip,是结果与作业共同的坐标。
73 pub head_sha: String,
74 /// 该 head 上已缓存的总结;尚未生成时为 null。
75 pub summary: Option<RepoAiSummaryDto>,
76 /// 仍未结束的总结作业;没有则为 null。
77 pub pending: Option<RepoAiSummaryJobDto>,
78}
79
80#[derive(Debug, Clone, Deserialize, ToSchema)]
81pub struct TriggerSummaryCmd {
82 /// 输出语言(BCP-47 或 `auto`);缺省跟随触发者的语言偏好。
83 #[serde(default)]
84 pub language: Option<String>,
85 /// 区间的起点引用(分支/标签/SHA);缺省由服务端取默认分支。
86 #[serde(default)]
87 pub base: Option<String>,
88 /// 参与总结的提交上限;缺省使用 `[ai].summary_commit_limit`。
89 #[serde(default)]
90 pub limit: Option<u64>,
91}
92
93impl super::GitService {
94 /// 读取当前默认分支的 AI 总结状态:结果可能尚未生成,作业可能仍在路上。
95 pub async fn get_repo_ai_summary(
96 &self,
97 space_name: String,
98 repo_name: String,
99 ) -> Result<RepoAiSummaryStateDto, GitError> {
100 let ctx = self.context(space_name, repo_name).await?;
101 let head = self
102 .baby(&ctx)
103 .rev_parse(&format!("refs/heads/{}", ctx.model.default_branch))
104 .await
105 .map(|oid| oid.to_string())
106 .map_err(crate::share::errors::map_baby)?;
107 let row = repo_ai_summary::Entity::find()
108 .filter(repo_ai_summary::Column::RepoId.eq(ctx.model.id))
109 .filter(repo_ai_summary::Column::HeadSha.eq(head.clone()))
110 .one(self.db_read())
111 .await?;
112 let pending = self.pending_summary_job(ctx.model.id).await;
113 Ok(RepoAiSummaryStateDto {
114 summary: row.map(|row| Self::summary_dto(row, &head)),
115 pending,
116 head_sha: head,
117 })
118 }
119
120 /// 触发(或刷新)当前 head 的 AI 总结。
121 pub async fn trigger_repo_ai_summary(
122 &self,
123 space_name: String,
124 repo_name: String,
125 actor: Option<i64>,
126 cmd: TriggerSummaryCmd,
127 ) -> Result<Uuid, GitError> {
128 let cfg = &self.state.config.ai;
129 if !cfg.summary_on() {
130 return Err(GitError::InvalidArgument(
131 "ai summary is disabled by config".to_string(),
132 ));
133 }
134 let ctx = self.context(space_name, repo_name).await?;
135 let head = self
136 .baby(&ctx)
137 .rev_parse(&format!("refs/heads/{}", ctx.model.default_branch))
138 .await
139 .map(|oid| oid.to_string())
140 .map_err(crate::share::errors::map_baby)?;
141 let base_sha = match cmd.base.as_deref().filter(|value| !value.trim().is_empty()) {
142 Some(base) => Some(
143 self.baby(&ctx)
144 .rev_parse(base)
145 .await
146 .map_err(crate::share::errors::map_baby)?
147 .to_string(),
148 ),
149 None => None,
150 };
151 // 空态文案承诺「同一版本不会重复调用模型」:同一个 head 已有在途作业时
152 // 直接把它交回去,而不是再排一个。重复点击(或界面轮询触发)因此是幂等的。
153 if let Some(queued) = self.queued_summary_job(ctx.model.id, &head).await {
154 return Ok(queued);
155 }
156 let task = ci_protocol::ai::AgentTask::RepoSummary {
157 job_id: Uuid::now_v7(),
158 repo_id: ctx.model.id,
159 head_sha: head,
160 base_sha,
161 limit: cmd.limit,
162 language: match cmd
163 .language
164 .clone()
165 .filter(|value| !value.trim().is_empty())
166 {
167 Some(explicit) => Some(ci_protocol::ai_languages::normalize(&explicit)),
168 None => crate::sync::ai_language::actor_language(self.db_read(), actor).await,
169 },
170 trace: export::telemetry::traceparent(),
171 request_id: export::telemetry::request_id(),
172 };
173 crate::sync::ai_task::submit_ai_task(self.db_write(), &self.state.redis, cfg, &task)
174 .await
175 .map_err(|e| GitError::Internal(format!("submit ai summary: {e}")))
176 }
177
178 /// 该仓库仍未结束的总结作业,最新的优先。
179 ///
180 /// 按仓库而不是按 head 取:排队期间有人推送时,作业的目标已经不是当前
181 /// tip 了,但它依然是「正在生成」,界面必须能说出来——带上它的 head,
182 /// 用户才知道完成的是哪一版。
183 async fn pending_summary_job(&self, repo_id: Uuid) -> Option<RepoAiSummaryJobDto> {
184 use models::ai::ai_job::{self, AiJobKind, AiJobStatus};
185 let cutoff = OffsetDateTime::now_utc() - inflight_window(&self.state.config.ai);
186 ai_job::Entity::find()
187 .filter(ai_job::Column::RepoId.eq(repo_id))
188 .filter(ai_job::Column::Kind.eq(AiJobKind::RepoSummary))
189 .filter(ai_job::Column::Status.is_in([AiJobStatus::Pending, AiJobStatus::Running]))
190 .filter(ai_job::Column::CreatedAt.gte(cutoff))
191 .order_by_desc(ai_job::Column::CreatedAt)
192 .one(self.db_read())
193 .await
194 .ok()
195 .flatten()
196 .map(|row| RepoAiSummaryJobDto {
197 status: row.status,
198 head_sha: row.target_ref.unwrap_or_default(),
199 language: row.language,
200 created_at: row.created_at,
201 started_at: row.started_at,
202 job_id: row.id,
203 })
204 }
205
206 /// 指定 head 上仍未结束的总结作业 id,用于把重复触发折叠成一次。
207 async fn queued_summary_job(&self, repo_id: Uuid, head: &str) -> Option<Uuid> {
208 use models::ai::ai_job::{self, AiJobKind, AiJobStatus};
209 let cutoff = OffsetDateTime::now_utc() - inflight_window(&self.state.config.ai);
210 ai_job::Entity::find()
211 .filter(ai_job::Column::RepoId.eq(repo_id))
212 .filter(ai_job::Column::Kind.eq(AiJobKind::RepoSummary))
213 .filter(ai_job::Column::TargetRef.eq(head.to_string()))
214 .filter(ai_job::Column::Status.is_in([AiJobStatus::Pending, AiJobStatus::Running]))
215 .filter(ai_job::Column::CreatedAt.gte(cutoff))
216 .order_by_desc(ai_job::Column::CreatedAt)
217 .one(self.db_read())
218 .await
219 .ok()
220 .flatten()
221 .map(|row| row.id)
222 }
223
224 fn summary_dto(row: repo_ai_summary::Model, head: &str) -> RepoAiSummaryDto {
225 let highlights: Vec<String> =
226 serde_json::from_value(row.highlights_json).unwrap_or_default();
227 let risks: Vec<String> = serde_json::from_value(row.risks_json).unwrap_or_default();
228 RepoAiSummaryDto {
229 id: row.id,
230 repo_id: row.repo_id,
231 head_sha: row.head_sha.clone(),
232 base_sha: row.base_sha,
233 commit_count: row.commit_count,
234 content: row.content,
235 highlights,
236 risks,
237 model: row.model,
238 language: row.language,
239 created_at: row.created_at,
240 updated_at: row.updated_at,
241 current: row.head_sha == head,
242 }
243 }
244}
Modified lib/git/src/service/code_comments.rs View lib/git/src/service/code_comments.rs
@@ -1,6 +1,6 @@
11use crate::context::facade::GitContext;
22use export::pagination::Page;
3use models::ai::code_comment;
3use models::repo::code_comment;
44use sea_orm::{
55 ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter,
66 QueryOrder, QuerySelect,
Modified lib/git/src/service/mod.rs View lib/git/src/service/mod.rs
@@ -33,8 +33,6 @@
3333 }
3434}
3535
36pub mod ai_review;
37pub mod ai_summary;
3836pub mod analysis;
3937pub mod archives;
4038pub mod blame;
Modified lib/git/src/service/releases.rs View lib/git/src/service/releases.rs
@@ -260,49 +260,6 @@
260260 Ok(())
261261 }
262262
263 /// 重新为该 release 起草文案(仅草稿可起草)。
264 pub async fn redraft_release(
265 &self,
266 _ctx: &GitContext,
267 release: &repo_release::Model,
268 actor: Option<i64>,
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 trace: export::telemetry::traceparent(),
290 request_id: export::telemetry::request_id(),
291 };
292 crate::sync::ai_task::submit_ai_task(self.db_write(), &self.state.redis, cfg, &task)
293 .await
294 .map_err(|e| GitError::Internal(format!("submit release notes: {e}")))
295 }
296
297 /// 读取发布记录实体(供 handler 组合 AI 起草等操作)。
298 pub async fn release_model(
299 &self,
300 repo_id: Uuid,
301 tag: &str,
302 ) -> Result<repo_release::Model, GitError> {
303 self.find_release(repo_id, tag).await
304 }
305
306263 async fn find_release(
307264 &self,
308265 repo_id: Uuid,
Modified lib/git/src/service/repos.rs View lib/git/src/service/repos.rs
@@ -26,13 +26,12 @@
2626pub const DEFAULT_BRANCH: &str = "main";
2727
2828/// 仓库页签中可由设置重排序的中间页签(代码固定最前、设置固定最后)。
29pub const REORDERABLE_REPO_TABS: [&str; 6] = [
29pub const REORDERABLE_REPO_TABS: [&str; 5] = [
3030 "commits",
3131 "pulls",
3232 "branches",
3333 "pipelines",
3434 "releases",
35 "ai",
3635];
3736
3837/// 归一化页签顺序:只保留已知中间页签、去重、限制长度;空 = 默认顺序。
@@ -83,24 +82,12 @@
8382 pub object_format: String,
8483 pub http_clone_url: Option<String>,
8584 pub ssh_clone_url: Option<String>,
86 /// 仓库级 AI 自动化开关(仍需平台 `[ai]` 总开关为开启)。
87 pub ai_review_on_push: bool,
88 pub ai_summary_on_push: bool,
89 pub ai_review_on_pull: bool,
9085 /// 是否允许在本仓库创建合并请求(关闭后页签隐藏、接口拒绝)。
9186 pub allow_pull_requests: bool,
9287 /// 流水线页签是否公开展示(关闭后仅空间成员可见)。
9388 pub pipelines_public: bool,
94 /// AI 总结页签是否公开展示(关闭后仅空间成员可见)。
95 pub ai_summary_public: bool,
9689 /// 中间页签顺序;空数组 = 默认顺序。
9790 pub tab_order: Vec<String>,
98 /// 平台侧 AI 是否可用(总开关 + 模型端点已配置)。
99 ///
100 /// 随仓库详情一起下发,是因为设置页必须能区分「已关」与「开了也不生效」:
101 /// 仓库开关存得下、但平台没启用时,投递不会发生,界面要如实说明,否则用户
102 /// 会以为打开了却什么都没有。
103 pub ai_available: bool,
10491 pub created_at: OffsetDateTime,
10592 pub updated_at: OffsetDateTime,
10693 pub last_pushed_at: Option<OffsetDateTime>,
@@ -187,15 +174,9 @@
187174 object_format: m.object_format,
188175 http_clone_url: None,
189176 ssh_clone_url: None,
190 ai_review_on_push: m.ai_review_on_push,
191 ai_summary_on_push: m.ai_summary_on_push,
192 ai_review_on_pull: m.ai_review_on_pull,
193177 allow_pull_requests: m.allow_pull_requests,
194178 pipelines_public: m.pipelines_public,
195 ai_summary_public: m.ai_summary_public,
196179 tab_order: tab_order_from_json(m.tab_order.as_ref()),
197 // 平台状态由服务层补齐(`From` 拿不到配置)。
198 ai_available: false,
199180 created_at: m.created_at,
200181 updated_at: m.updated_at,
201182 last_pushed_at: m.last_pushed_at,
@@ -237,17 +218,9 @@
237218 #[serde(default)]
238219 pub default_branch: Option<String>,
239220 #[serde(default)]
240 pub ai_review_on_push: Option<bool>,
241 #[serde(default)]
242 pub ai_summary_on_push: Option<bool>,
243 #[serde(default)]
244 pub ai_review_on_pull: Option<bool>,
245 #[serde(default)]
246221 pub allow_pull_requests: Option<bool>,
247222 #[serde(default)]
248223 pub pipelines_public: Option<bool>,
249 #[serde(default)]
250 pub ai_summary_public: Option<bool>,
251224 /// 中间页签顺序;传空数组恢复默认顺序。
252225 #[serde(default)]
253226 pub tab_order: Option<Vec<String>>,
@@ -316,8 +289,7 @@
316289 })
317290 }
318291
319 /// 补齐 `RepoDto::from` 拿不到的两类派生字段:克隆地址(依赖空间名)与
320 /// 平台侧 AI 可用性(依赖全局配置)。
292 /// 补齐 `RepoDto::from` 拿不到的派生字段:克隆地址(依赖空间名)。
321293 ///
322294 /// 合并成一个入口,是因为仓库详情有四处出口(读取、更新、归档、取消归档),
323295 /// 分开填充必然出现某处漏掉、界面在保存后忽然少一块信息的情况。
@@ -325,11 +297,6 @@
325297 let repo_name = dto.name.clone();
326298 dto.http_clone_url = self.http_clone_url(space_name, &repo_name);
327299 dto.ssh_clone_url = self.ssh_clone_url(space_name, &repo_name);
328 let cfg = &self.state.config.ai;
329 // 设置页必须能区分「已关」与「开了也不生效」:开关存得下,但平台没启用
330 // 时不会投递任务,界面要如实说明。只下发这一个布尔量——模型名之类的部署
331 // 细节不属于仓库页面的信息面。
332 dto.ai_available = cfg.enabled && !cfg.model.trim().is_empty();
333300 }
334301
335302 pub async fn repo_dto(&self, m: repos::Model) -> Result<RepoDto, GitError> {
@@ -361,7 +328,7 @@
361328 cmd: CreateRepoCmd,
362329 ) -> Result<RepoDto, GitError> {
363330 let space = self.lookup_space(&space_name).await?;
364 if space.owner_id != caller && !export::authz::is_system_uid(caller) {
331 if space.owner_id != caller {
365332 return Err(GitError::Forbidden);
366333 }
367334 let name = validate_repo_name(&cmd.name)?;
@@ -459,14 +426,8 @@
459426 allow_squash_merge: Set(true),
460427 allow_merge_commits: Set(true),
461428 object_format: Set("sha1".to_string()),
462 // 新建仓库沿用平台既有行为:评审类自动触发照旧,自动总结默认
463 // 关闭,由仓库设置里显式开启。
464 ai_review_on_push: Set(true),
465 ai_summary_on_push: Set(false),
466 ai_review_on_pull: Set(true),
467429 allow_pull_requests: Set(true),
468430 pipelines_public: Set(true),
469 ai_summary_public: Set(true),
470431 tab_order: Set(None),
471432 };
472433 let repo = repo_row.insert(&txn).await?;
@@ -612,8 +573,7 @@
612573 };
613574 let rows = sel.all(self.db_read()).await?;
614575
615 let owner_view =
616 caller.is_some_and(|uid| space.owner_id == uid || export::authz::is_system_uid(uid));
576 let owner_view = caller.is_some_and(|uid| space.owner_id == uid);
617577 let mut accessible: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
618578 if let Some(uid) = caller
619579 && !owner_view
@@ -655,7 +615,7 @@
655615 ) -> Result<RepoDto, GitError> {
656616 let ctx = self.context(space_name, repo_name).await?;
657617 let space = self.repo_space(&ctx).await?;
658 if space.owner_id != caller && !export::authz::is_system_uid(caller) {
618 if space.owner_id != caller {
659619 return Err(GitError::Forbidden);
660620 }
661621 let new_branch = match &cmd.default_branch {
@@ -682,26 +642,14 @@
682642 }
683643 if let Some(branch) = &new_branch {
684644 am.default_branch = Set(branch.clone());
685 }
686 // 三个 AI 开关互相独立:只传其中一个时不动其余两个。
687 if let Some(value) = cmd.ai_review_on_push {
688 am.ai_review_on_push = Set(value);
689 }
690 if let Some(value) = cmd.ai_summary_on_push {
691 am.ai_summary_on_push = Set(value);
692 }
693 if let Some(value) = cmd.ai_review_on_pull {
694 am.ai_review_on_pull = Set(value);
695645 }
646 // 页签公开性与合并请求开关互相独立:只传其中一个时不动其余。
696647 if let Some(value) = cmd.allow_pull_requests {
697648 am.allow_pull_requests = Set(value);
698649 }
699650 if let Some(value) = cmd.pipelines_public {
700651 am.pipelines_public = Set(value);
701652 }
702 if let Some(value) = cmd.ai_summary_public {
703 am.ai_summary_public = Set(value);
704 }
705653 if let Some(order) = &cmd.tab_order {
706654 am.tab_order = Set(Some(serde_json::json!(normalize_repo_tab_order(order))));
707655 }
@@ -737,7 +685,7 @@
737685 ) -> Result<RepoDto, GitError> {
738686 let ctx = self.context(space_name, repo_name).await?;
739687 let space = self.repo_space(&ctx).await?;
740 if space.owner_id != caller && !export::authz::is_system_uid(caller) {
688 if space.owner_id != caller {
741689 return Err(GitError::Forbidden);
742690 }
743691 if ctx.model.is_archived == archived {
Modified lib/git/src/share/access.rs View lib/git/src/share/access.rs
@@ -81,15 +81,6 @@
8181 if let Some(token) = auth
8282 .token
8383 .as_deref()
84 .filter(|token| export::space_token::looks_like(token))
85 {
86 return verify_space_bot_token(svc, token, ctx)
87 .await?
88 .ok_or(GitError::Unauthorized);
89 }
90 if let Some(token) = auth
91 .token
92 .as_deref()
9384 .filter(|token| token.starts_with("ci_"))
9485 {
9586 return verify_ci_job_token(svc, token, ctx, true).await;
@@ -99,34 +90,6 @@
9990 Ok(uid)
10091}
10192
102/// Temporary space top-level tokens authenticate as the per-space bot and are
103/// valid only for repositories inside the space they were issued for. The
104/// token itself carries every owner-level right in that space, so no further
105/// repo ACL is consulted (mismatched tokens are rejected, not downgraded).
106async fn verify_space_bot_token(
107 svc: &GitService,
108 token: &str,
109 ctx: &GitContext,
110) -> Result<Option<i64>, GitError> {
111 if !export::space_token::looks_like(token) {
112 return Ok(None);
113 }
114 let record = export::space_token::verify(&svc.state.redis, token)
115 .await
116 .map_err(|error| GitError::Internal(format!("space token verification failed: {error}")))?;
117 let Some(record) = record else {
118 return Err(GitError::Unauthorized);
119 };
120 let space = find_space_for_repo(svc.db_read(), &ctx.model).await?;
121 if space.id != record.space_id {
122 return Err(GitError::Forbidden);
123 }
124 // Register so nested owner-level checks in this process also pass; the
125 // uid is only ever reachable through a token bound to one space.
126 export::authz::register_space_bot(record.bot_uid);
127 Ok(Some(record.bot_uid))
128}
129
13093async fn verify_ci_job_token(
13194 svc: &GitService,
13295 token: &str,
@@ -175,13 +138,6 @@
175138 if let Some(token) = auth
176139 .token
177140 .as_deref()
178 .filter(|token| export::space_token::looks_like(token))
179 {
180 return verify_space_bot_token(svc, token, ctx).await;
181 }
182 if let Some(token) = auth
183 .token
184 .as_deref()
185141 .filter(|token| token.starts_with("ci_"))
186142 {
187143 return verify_ci_job_token(svc, token, ctx, false).await.map(Some);
@@ -207,15 +163,6 @@
207163 auth: &BasicAuth,
208164 ctx: &GitContext,
209165) -> Result<i64, GitError> {
210 if let Some(token) = auth
211 .token
212 .as_deref()
213 .filter(|token| export::space_token::looks_like(token))
214 {
215 return verify_space_bot_token(svc, token, ctx)
216 .await?
217 .ok_or(GitError::Unauthorized);
218 }
219166 if auth
220167 .token
221168 .as_deref()
@@ -259,10 +206,6 @@
259206 want_write: bool,
260207 require_admin: bool,
261208) -> Result<(), GitError> {
262 if export::authz::is_system_uid(uid) {
263 return Ok(());
264 }
265
266209 let repo = &ctx.model;
267210 let db = svc.db_read();
268211
Modified lib/git/src/smart/dispatch.rs View lib/git/src/smart/dispatch.rs
@@ -130,13 +130,6 @@
130130 repo_id: ctx.model.id,
131131 actor_id,
132132 bare: bare.clone(),
133 ai_ctx: service
134 .ai_config()
135 .map(|cfg| crate::sync::post_receive::AiPushCtx {
136 db: service.git.state.database.clone(),
137 redis: service.git.state.redis.clone(),
138 config: cfg.clone(),
139 }),
140133 };
141134 let stream = stream.map(|chunk| chunk.map_err(|e| axum::Error::new(e.to_string())));
142135 let stream = body_stream_with_post_receive(stream, post);
@@ -190,7 +183,6 @@
190183 pub repo_id: Uuid,
191184 pub actor_id: Option<i64>,
192185 pub bare: std::path::PathBuf,
193 pub ai_ctx: Option<crate::sync::post_receive::AiPushCtx>,
194186}
195187
196188fn body_stream_with_post_receive(
@@ -208,14 +200,12 @@
208200 repo_id,
209201 actor_id,
210202 bare,
211 ai_ctx,
212203 } = post;
213204 if is_receive && let Some(queue) = queue {
214205 enqueue_post_receive_push_with_ci_logged(
215206 &queue,
216207 &settings,
217208 ci_topic.as_deref(),
218 ai_ctx,
219209 &crate::sync::PushTarget {
220210 space_id,
221211 repo_id,
Modified lib/git/src/smart/service.rs View lib/git/src/smart/service.rs
@@ -19,8 +19,6 @@
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>,
2422}
2523
2624impl SmartService {
@@ -31,17 +29,7 @@
3129 queue: None,
3230 sync_settings: SyncSettings::default(),
3331 ci_event_topic: None,
34 ai_config: None,
3532 }
36 }
37
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()
4533 }
4634
4735 pub fn with_ci_event_topic(mut self, topic: Option<String>) -> Self {
Modified lib/git/src/ssh/handler/channel.rs View lib/git/src/ssh/handler/channel.rs
@@ -178,15 +178,6 @@
178178 let queue = self.service.smart.queue.clone();
179179 let sync_settings = self.service.sync_settings.clone();
180180 let ci_topic = self.service.smart.ci_event_topic().map(|t| t.to_string());
181 let ai_ctx =
182 self.service
183 .smart
184 .ai_config()
185 .map(|cfg| crate::sync::post_receive::AiPushCtx {
186 db: self.service.smart.git.state.database.clone(),
187 redis: self.service.smart.git.state.redis.clone(),
188 config: cfg.clone(),
189 });
190181 let ssh_service = self.service.clone();
191182
192183 let db = self.service.smart.git.state.database.clone();
@@ -330,7 +321,6 @@
330321 queue,
331322 &sync_settings,
332323 ci_topic.as_deref(),
333 ai_ctx,
334324 &crate::sync::PushTarget {
335325 space_id,
336326 repo_id,
Modified lib/git/src/ssh/service.rs View lib/git/src/ssh/service.rs
@@ -37,9 +37,4 @@
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 }
4540}
Deleted lib/git/src/sync/ai_language.rs
@@ -1,23 +0,0 @@
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<i64>) -> 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(|| {
17 row.locale
18 .as_deref()
19 .filter(|value| !value.trim().is_empty())
20 })?;
21 let normalized = ci_protocol::ai_languages::normalize(raw);
22 (!normalized.is_empty()).then_some(normalized)
23}
Deleted lib/git/src/sync/ai_task.rs
@@ -1,519 +0,0 @@
1use config::ai::AiConfig;
2use export::{AppDatabase, AppRedis};
3use models::ai::ai_job;
4use models::ai::ai_job::{ActiveModel, AiJobStatus};
5use models::git::git_tags;
6use models::repo::{repo_release, repos};
7use sea_orm::PaginatorTrait;
8use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
9use time::OffsetDateTime;
10use uuid::Uuid;
11
12use crate::sync::error::{SyncError, SyncResult};
13use crate::sync::queue::SyncQueue;
14
15/// 一次推送要不要为各能力投递任务:平台开关与仓库开关的交集。
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17struct PushAiSwitches {
18 review: bool,
19 summary: bool,
20 release_notes: bool,
21}
22
23impl PushAiSwitches {
24 fn any(self) -> bool {
25 self.review || self.summary || self.release_notes
26 }
27}
28
29/// 三层准入的合并规则,单独成函数是为了能直接测:平台 `[ai]` 总开关 → 子能力
30/// 开关 → 仓库列。任何一层关掉都不投递,因此「仓库开了但平台没开」不会产生调用。
31fn push_ai_switches(cfg: &AiConfig, repo_review: bool, repo_summary: bool) -> PushAiSwitches {
32 PushAiSwitches {
33 review: cfg.review_on() && repo_review,
34 summary: cfg.summary_on() && repo_summary,
35 // release 文案没有仓库级开关:它跟着 tag 走,粒度由平台配置掌握。
36 release_notes: cfg.release_notes_on(),
37 }
38}
39
40pub async fn submit_ai_task(
41 db: &DatabaseConnection,
42 redis: &AppRedis,
43 cfg: &AiConfig,
44 task: &ci_protocol::ai::AgentTask,
45) -> SyncResult<Uuid> {
46 let job_id = task.job_id();
47 let now = OffsetDateTime::now_utc();
48 // 完整载荷随作业持久化:Redis 列表没有投递确认,管理端重试时必须
49 // 能够原样重投,而不是只把数据库状态改回 pending。
50 let payload = task
51 .encode()
52 .map_err(|e| SyncError::Internal(format!("encode ai task: {e}")))?;
53 let request = serde_json::to_value(task)
54 .map_err(|e| SyncError::Internal(format!("encode ai task: {e}")))?;
55 ActiveModel {
56 id: Set(job_id),
57 repo_id: Set(task.repo_id()),
58 kind: Set(kind_of(task)),
59 target_id: Set(task.target_id()),
60 status: Set(AiJobStatus::Pending),
61 attempts: Set(0),
62 model: Set(cfg.model.trim().to_string()),
63 target_ref: Set(task.target_ref()),
64 language: Set(task.language().unwrap_or_default().to_string()),
65 request_json: Set(Some(request)),
66 response_json: Set(None),
67 prompt_tokens: Set(None),
68 completion_tokens: Set(None),
69 error: Set(None),
70 created_at: Set(now),
71 started_at: Set(None),
72 finished_at: Set(None),
73 updated_at: Set(None),
74 }
75 .insert(db)
76 .await?;
77
78 let queue = SyncQueue::new(redis.clone());
79 if let Err(error) = queue.publish_raw(&cfg.queue_topic, None, &payload).await {
80 mark_submit_failure(db, job_id, &error.to_string()).await;
81 return Err(error);
82 }
83 tracing::debug!(job_id = %job_id, kind = task.kind_str(), "ai task submitted");
84 Ok(job_id)
85}
86
87pub async fn submit_ai_task_logged(
88 db: &DatabaseConnection,
89 redis: &AppRedis,
90 cfg: &AiConfig,
91 task: &ci_protocol::ai::AgentTask,
92) {
93 match submit_ai_task(db, redis, cfg, task).await {
94 Ok(job_id) => tracing::debug!(%job_id, kind = task.kind_str(), "ai task submitted"),
95 Err(error) => {
96 tracing::warn!(%error, kind = task.kind_str(), "ai task submission failed")
97 }
98 }
99}
100
101pub async fn trigger_push_ai_tasks(
102 db: &AppDatabase,
103 redis: &AppRedis,
104 cfg: &AiConfig,
105 repo_id: Uuid,
106 actor_id: Option<i64>,
107 refs: &[(String, String, String)],
108) {
109 let conn = db.write();
110 // 仓库级开关是这一层的准入条件:平台开着、仓库不想用,就不该产生调用。
111 // 读一次仓库行,两个开关都从它取,避免每条引用各查一次。
112 let (review_on_push, summary_on_push) = match repos::Entity::find_by_id(repo_id).one(conn).await
113 {
114 Ok(Some(row)) => (row.ai_review_on_push, row.ai_summary_on_push),
115 // 仓库行读不到(极少见:推送与同步竞态)时保持加列之前的行为:
116 // 评审照旧按平台开关走,新增的自动总结不触发。丢掉一次自动评审比
117 // 多发一次模型调用更难排查,所以这里偏向沿用旧行为。
118 Ok(None) | Err(_) => (true, false),
119 };
120 let switches = push_ai_switches(cfg, review_on_push, summary_on_push);
121 if !switches.any() {
122 return;
123 }
124 // 一次推送产生的所有任务共用触发者语言。
125 let language = crate::sync::ai_language::actor_language(conn, actor_id).await;
126 if switches.review {
127 for (name, _old, new) in refs {
128 if !name.starts_with("refs/heads/") || is_zero_oid(new) {
129 continue;
130 }
131 let task = ci_protocol::ai::AgentTask::CommitReview {
132 job_id: Uuid::now_v7(),
133 repo_id,
134 commit_sha: new.clone(),
135 language: language.clone(),
136 trace: export::telemetry::traceparent(),
137 request_id: export::telemetry::request_id(),
138 };
139 submit_ai_task_logged(conn, redis, cfg, &task).await;
140 }
141 }
142 if switches.summary {
143 for (name, _old, new) in refs {
144 if !name.starts_with("refs/heads/") || is_zero_oid(new) {
145 continue;
146 }
147 let task = ci_protocol::ai::AgentTask::RepoSummary {
148 job_id: Uuid::now_v7(),
149 repo_id,
150 head_sha: new.clone(),
151 base_sha: None,
152 limit: None,
153 language: language.clone(),
154 trace: export::telemetry::traceparent(),
155 request_id: export::telemetry::request_id(),
156 };
157 submit_ai_task_logged(conn, redis, cfg, &task).await;
158 }
159 }
160 if switches.release_notes {
161 for (name, _old, new) in refs {
162 let Some(tag) = name.strip_prefix("refs/tags/") else {
163 continue;
164 };
165 if is_zero_oid(new) {
166 continue;
167 }
168 match ensure_release_draft(
169 conn,
170 redis,
171 cfg,
172 repo_id,
173 actor_id,
174 language.clone(),
175 tag,
176 new,
177 )
178 .await
179 {
180 Ok(Some(job_id)) => {
181 tracing::debug!(%job_id, tag, "release draft queued");
182 }
183 Ok(None) => {}
184 Err(error) => tracing::warn!(%error, tag, "release draft creation failed"),
185 }
186 }
187 }
188}
189
190/// 若该 tag 尚无 release,则建草稿并入队文案任务;已存在则返回 `None`。
191#[allow(clippy::too_many_arguments)]
192pub async fn ensure_release_draft(
193 db: &DatabaseConnection,
194 redis: &AppRedis,
195 cfg: &AiConfig,
196 repo_id: Uuid,
197 actor_id: Option<i64>,
198 language: Option<String>,
199 tag_name: &str,
200 target_sha: &str,
201) -> SyncResult<Option<Uuid>> {
202 let lower_tag = tag_name.to_lowercase();
203 if repo_release::Entity::find()
204 .filter(repo_release::Column::RepoId.eq(repo_id))
205 .filter(repo_release::Column::LowerTagName.eq(lower_tag.clone()))
206 .one(db)
207 .await?
208 .is_some()
209 {
210 return Ok(None);
211 }
212 let publisher = match actor_id {
213 Some(uid) => uid,
214 None => repo_publisher_fallback(db, repo_id).await?,
215 };
216 let now = OffsetDateTime::now_utc();
217 let release_id = Uuid::now_v7();
218 let previous = previous_tag_target(db, repo_id, tag_name, target_sha).await;
219 let commit_count = count_commits_between(db, repo_id, previous.as_deref(), target_sha).await;
220 repo_release::ActiveModel {
221 id: Set(release_id),
222 repo_id: Set(repo_id),
223 // 草稿由系统创建,发布者取仓库所有者;用户发布时再改写为实际操作者。
224 publisher_id: Set(publisher),
225 publisher_name: Set(String::new()),
226 tag_name: Set(tag_name.to_string()),
227 lower_tag_name: Set(lower_tag),
228 target: Set(target_sha.to_string()),
229 title: Set(tag_name.to_string()),
230 content: Set(None),
231 sha: Set(target_sha.to_string()),
232 num_commits: Set(commit_count),
233 is_draft: Set(true),
234 is_prerelease: Set(tag_name.contains('-')),
235 is_tag: Set(true),
236 created_at: Set(now),
237 updated_at: Set(now),
238 }
239 .insert(db)
240 .await?;
241
242 let task = ci_protocol::ai::AgentTask::ReleaseNotes {
243 job_id: Uuid::now_v7(),
244 repo_id,
245 release_id,
246 tag_name: tag_name.to_string(),
247 from_sha: previous,
248 to_sha: target_sha.to_string(),
249 language: language.or(resolve_language_for(db, actor_id).await),
250 trace: export::telemetry::traceparent(),
251 request_id: export::telemetry::request_id(),
252 };
253 let job_id = submit_ai_task(db, redis, cfg, &task).await?;
254 Ok(Some(job_id))
255}
256
257/// 管理端手动重试:按持久化的载荷原样重投队列。
258///
259/// 队列是 Redis 列表且没有投递确认:仅把 `ai_job.status` 改回 `pending`
260/// 不会有任何消费者看到它,所以重试必须重新 `LPUSH` 原始载荷。载荷来自
261/// 提交时的持久化副本;历史记录没有副本时按作业字段重建,重建失败才判失败。
262pub async fn retry_ai_job(
263 db: &DatabaseConnection,
264 redis: &AppRedis,
265 cfg: &AiConfig,
266 row: &ai_job::Model,
267) -> SyncResult<()> {
268 // 优先使用提交时持久化的原始载荷;历史记录没有载荷时按作业字段重建,
269 // 否则管理端对旧失败任务的重试永远无法生效。
270 let task = match row.request_json.as_ref() {
271 Some(value) => serde_json::from_value::<ci_protocol::ai::AgentTask>(value.clone())
272 .map_err(|e| SyncError::Internal(format!("decode stored ai task: {e}")))?,
273 None => match reconstruct_task(db, row).await {
274 Some(task) => task,
275 None => {
276 let reason = "stored payload missing and task cannot be reconstructed".to_string();
277 mark_retry_failure(db, row.id, &reason).await;
278 return Err(SyncError::Internal(reason));
279 }
280 },
281 };
282 let payload = task
283 .encode()
284 .map_err(|e| SyncError::Internal(format!("encode ai task: {e}")))?;
285 // 先复位再投递:消费者只能看到投递之后的作业,避免 running 被改回 pending。
286 let now = OffsetDateTime::now_utc();
287 if let Some(current) = ai_job::Entity::find_by_id(row.id).one(db).await? {
288 let mut model: ActiveModel = current.into();
289 model.status = Set(AiJobStatus::Pending);
290 model.attempts = Set(0);
291 model.error = Set(None);
292 model.started_at = Set(None);
293 model.finished_at = Set(None);
294 model.updated_at = Set(Some(now));
295 model.update(db).await?;
296 }
297 let queue = SyncQueue::new(redis.clone());
298 if let Err(error) = queue.publish_raw(&cfg.queue_topic, None, &payload).await {
299 mark_retry_failure(db, row.id, &error.to_string()).await;
300 return Err(error);
301 }
302 tracing::info!(job_id = %row.id, kind = %row.kind.as_str(), "ai job requeued by admin");
303 Ok(())
304}
305
306/// 历史作业没有 `request_json` 时按已存字段重建队列载荷。
307///
308/// 能唯一确定任务身份的信息都在 `ai_job` 上(kind / repo / target / ref /
309/// language);release 的区间起点需要回查实体,缺失时按整个历史生成。
310async fn reconstruct_task(
311 db: &DatabaseConnection,
312 row: &ai_job::Model,
313) -> Option<ci_protocol::ai::AgentTask> {
314 use models::ai::ai_job::AiJobKind;
315 let language = if row.language.trim().is_empty() {
316 None
317 } else {
318 Some(row.language.clone())
319 };
320 let target_ref = row.target_ref.clone()?;
321 match row.kind {
322 AiJobKind::PullReview => Some(ci_protocol::ai::AgentTask::PullReview {
323 job_id: row.id,
324 repo_id: row.repo_id,
325 pull_request_id: row.target_id?,
326 head_sha: target_ref,
327 language,
328 trace: None,
329 request_id: None,
330 }),
331 AiJobKind::CommitReview => Some(ci_protocol::ai::AgentTask::CommitReview {
332 job_id: row.id,
333 repo_id: row.repo_id,
334 commit_sha: target_ref,
335 language,
336 trace: None,
337 request_id: None,
338 }),
339 AiJobKind::RepoSummary => Some(ci_protocol::ai::AgentTask::RepoSummary {
340 job_id: row.id,
341 repo_id: row.repo_id,
342 head_sha: target_ref,
343 base_sha: None,
344 limit: None,
345 language,
346 trace: None,
347 request_id: None,
348 }),
349 AiJobKind::ReleaseNotes => {
350 let release = repo_release::Entity::find_by_id(row.target_id?)
351 .one(db)
352 .await
353 .ok()??;
354 Some(ci_protocol::ai::AgentTask::ReleaseNotes {
355 job_id: row.id,
356 repo_id: row.repo_id,
357 release_id: release.id,
358 tag_name: release.tag_name,
359 from_sha: None,
360 to_sha: release.sha,
361 language,
362 trace: None,
363 request_id: None,
364 })
365 }
366 }
367}
368
369async fn mark_retry_failure(db: &DatabaseConnection, job_id: Uuid, error: &str) {
370 let now = OffsetDateTime::now_utc();
371 if let Ok(Some(row)) = ai_job::Entity::find_by_id(job_id).one(db).await {
372 let mut model: ActiveModel = row.into();
373 model.status = Set(AiJobStatus::Failed);
374 model.error = Set(Some(error.to_string()));
375 model.finished_at = Set(Some(now));
376 model.updated_at = Set(Some(now));
377 let _ = model.update(db).await;
378 }
379}
380
381/// 草稿发布者的兜底:无推送者(如系统同步)时取空间所有者。
382async fn repo_publisher_fallback(db: &DatabaseConnection, repo_id: Uuid) -> SyncResult<i64> {
383 let repo = repos::Entity::find_by_id(repo_id)
384 .one(db)
385 .await?
386 .ok_or_else(|| SyncError::Internal("repo not found for release".to_string()))?;
387 let space = crate::share::access::space_for_dir(db, repo.dir_id)
388 .await
389 .map_err(|e| SyncError::Internal(format!("space lookup: {e}")))?
390 .ok_or_else(|| SyncError::Internal("space not found for release".to_string()))?;
391 Ok(space.owner_id)
392}
393
394/// 入队时的语言解析:调用方已解析过就直接用,避免重复查用户表。
395async fn resolve_language_for(db: &DatabaseConnection, actor_id: Option<i64>) -> Option<String> {
396 crate::sync::ai_language::actor_language(db, actor_id).await
397}
398
399/// 新标签的 changelog 区间起点。
400///
401/// 用「该标签之前最近一个已登记的标签」作为起点。同一个推送里可能一次带上
402/// 多个标签,因此先按标签指向的提交在历史中的先后挑出真正的前驱,而不是
403/// 依赖 `created_at` 的写入顺序。
404async fn previous_tag_target(
405 db: &DatabaseConnection,
406 repo_id: Uuid,
407 tag_name: &str,
408 target_sha: &str,
409) -> Option<String> {
410 let rows = git_tags::Entity::find()
411 .filter(git_tags::Column::RepoId.eq(repo_id))
412 .filter(git_tags::Column::Name.ne(tag_name.to_string()))
413 .all(db)
414 .await
415 .ok()?;
416 // 排除指向同一个提交的别名标签,避免生成空区间。
417 rows.into_iter()
418 .find(|row| row.commit_sha != target_sha)
419 .map(|row| row.commit_sha)
420}
421
422async fn count_commits_between(
423 db: &DatabaseConnection,
424 repo_id: Uuid,
425 from: Option<&str>,
426 to: &str,
427) -> i32 {
428 use models::git::git_commits;
429 let mut select = git_commits::Entity::find().filter(git_commits::Column::RepoId.eq(repo_id));
430 if let Some(from) = from {
431 select = select.filter(git_commits::Column::Sha.ne(from.to_string()));
432 }
433 let _ = to;
434 select
435 .count(db)
436 .await
437 .map(|count| count as i32)
438 .unwrap_or(0)
439}
440
441fn is_zero_oid(oid: &str) -> bool {
442 oid.is_empty() || oid.chars().all(|c| c == '0')
443}
444
445async fn mark_submit_failure(db: &DatabaseConnection, job_id: Uuid, error: &str) {
446 use models::ai::ai_job;
447 use sea_orm::EntityTrait;
448 let now = OffsetDateTime::now_utc();
449 if let Ok(Some(row)) = ai_job::Entity::find_by_id(job_id).one(db).await {
450 let mut model: ai_job::ActiveModel = row.into();
451 model.status = Set(AiJobStatus::Failed);
452 model.error = Set(Some(error.to_string()));
453 model.finished_at = Set(Some(now));
454 model.updated_at = Set(Some(now));
455 let _ = model.update(db).await;
456 }
457}
458
459fn kind_of(task: &ci_protocol::ai::AgentTask) -> models::ai::ai_job::AiJobKind {
460 use ci_protocol::ai::AgentTask;
461 use models::ai::ai_job::AiJobKind;
462 match task {
463 AgentTask::PullReview { .. } => AiJobKind::PullReview,
464 AgentTask::CommitReview { .. } => AiJobKind::CommitReview,
465 AgentTask::RepoSummary { .. } => AiJobKind::RepoSummary,
466 AgentTask::ReleaseNotes { .. } => AiJobKind::ReleaseNotes,
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 fn cfg(enabled: bool, review: bool, summary: bool) -> AiConfig {
475 AiConfig {
476 enabled,
477 review_enabled: review,
478 summary_enabled: summary,
479 ..Default::default()
480 }
481 }
482
483 /// 仓库开关是「与」而不是「或」:仓库打开但平台没开时不能产生调用,否则
484 /// 关闭平台 AI 的部署仍会往外发请求。这也是设置页要提示「开了也不生效」的原因。
485 #[test]
486 fn platform_switch_gates_repo_switch() {
487 let off = push_ai_switches(&cfg(false, true, true), true, true);
488 assert!(!off.review && !off.summary, "平台关闭时仓库开关不得放行");
489
490 let on = push_ai_switches(&cfg(true, true, true), true, true);
491 assert!(on.review && on.summary);
492 }
493
494 /// 仓库关掉某项时,平台开着也不能投递——这是该功能的全部意义。
495 #[test]
496 fn repo_switch_gates_each_capability() {
497 let both_off = push_ai_switches(&cfg(true, true, true), false, false);
498 assert!(!both_off.review && !both_off.summary);
499
500 // 只开评审、不开自动总结:两个开关必须互不影响。
501 let review_only = push_ai_switches(&cfg(true, true, true), true, false);
502 assert!(review_only.review && !review_only.summary);
503
504 let summary_only = push_ai_switches(&cfg(true, true, true), false, true);
505 assert!(!summary_only.review && summary_only.summary);
506 }
507
508 /// 全关时不该继续往下走(`any()` 决定是否提前返回)。
509 #[test]
510 fn any_is_false_when_everything_is_off() {
511 let mut off = cfg(true, false, false);
512 off.release_notes_enabled = false;
513 assert!(!push_ai_switches(&off, false, false).any());
514 // release 文案没有仓库级开关,平台开着就仍应投递。
515 let mut with_release = cfg(true, false, false);
516 with_release.release_notes_enabled = true;
517 assert!(push_ai_switches(&with_release, false, false).any());
518 }
519}
Modified lib/git/src/sync/mod.rs View lib/git/src/sync/mod.rs
@@ -1,5 +1,3 @@
1pub mod ai_language;
2pub mod ai_task;
31pub mod branches;
42pub mod commits;
53pub mod error;
@@ -18,8 +16,6 @@
1816pub mod webhook;
1917pub mod worker;
2018
21pub use ai_language::actor_language;
22pub use ai_task::{retry_ai_job, submit_ai_task, submit_ai_task_logged};
2319pub use error::{SyncError, SyncResult};
2420pub use event::{
2521 EnqueueError as SyncEnqueueError, PostReceiveRefUpdate, PostReceiveTask, enqueue_post_receive,
@@ -27,8 +23,8 @@
2723};
2824pub use lock::{SyncLock, acquire_lock};
2925pub use post_receive::{
30 AiPushCtx, PushTarget, enqueue_post_receive_push, enqueue_post_receive_push_full,
31 enqueue_post_receive_push_with_ci_logged, prepare_post_receive,
26 PushTarget, enqueue_post_receive_push, enqueue_post_receive_push_with_ci_logged,
27 prepare_post_receive,
3228};
3329pub use queue::SyncQueue;
3430pub use sync::RepoSync;
Modified lib/git/src/sync/post_receive.rs View lib/git/src/sync/post_receive.rs
@@ -10,14 +10,6 @@
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
2113/// Who and where a push belongs to. Grouped into a struct because every
2214/// post-receive entry point threads the same four values, and three of them are
2315/// `Uuid`-shaped — positional arguments silently swap space and repo.
@@ -157,7 +149,7 @@
157149 settings: &SyncSettings,
158150 target: &PushTarget<'_>,
159151) -> Result<usize, EnqueueError> {
160 enqueue_push_impl(queue, settings, None, None, target).await
152 enqueue_push_impl(queue, settings, None, target).await
161153}
162154
163155/// Enqueues the sync task and (optionally) publishes a CI push event from the
@@ -168,25 +160,13 @@
168160 ci_topic: Option<&str>,
169161 target: &PushTarget<'_>,
170162) -> Result<usize, EnqueueError> {
171 enqueue_push_impl(queue, settings, ci_topic, None, target).await
172}
173
174/// 推送入口:同时镜像 CI 事件、投递 AI 任务并排入同步队列。
175pub async fn enqueue_post_receive_push_full(
176 queue: &SyncQueue,
177 settings: &SyncSettings,
178 ci_topic: Option<&str>,
179 ai_ctx: Option<AiPushCtx>,
180 target: &PushTarget<'_>,
181) -> Result<usize, EnqueueError> {
182 enqueue_push_impl(queue, settings, ci_topic, ai_ctx.as_ref(), target).await
163 enqueue_push_impl(queue, settings, ci_topic, target).await
183164}
184165
185166async fn enqueue_push_impl(
186167 queue: &SyncQueue,
187168 settings: &SyncSettings,
188169 ci_topic: Option<&str>,
189 ai_ctx: Option<&AiPushCtx>,
190170 target: &PushTarget<'_>,
191171) -> Result<usize, EnqueueError> {
192172 // Copied out so the borrow of `target` ends here; the fields are `Copy`.
@@ -196,17 +176,6 @@
196176 if refs.is_empty() {
197177 return Ok(0);
198178 }
199 // AI 触发只依赖本次引用更新,且必须容忍失败,因此先于 CI 事件执行。
200 if let Some(ai) = ai_ctx {
201 let flat: Vec<(String, String, String)> = refs
202 .iter()
203 .map(|r| (r.name.clone(), r.old_oid.clone(), r.new_oid.clone()))
204 .collect();
205 crate::sync::ai_task::trigger_push_ai_tasks(
206 &ai.db, &ai.redis, &ai.config, repo_id, actor_id, &flat,
207 )
208 .await;
209 }
210179 if let Some(topic) = ci_topic {
211180 let ci_refs: Vec<ci_protocol::events::RefUpdate> = refs
212181 .iter()
@@ -270,11 +239,10 @@
270239 queue: &SyncQueue,
271240 settings: &SyncSettings,
272241 ci_topic: Option<&str>,
273 ai_ctx: Option<AiPushCtx>,
274242 target: &PushTarget<'_>,
275243) {
276244 let (space_id, repo_id) = (target.space_id, target.repo_id);
277 match enqueue_post_receive_push_full(queue, settings, ci_topic, ai_ctx, target).await {
245 match enqueue_post_receive_push_with_ci(queue, settings, ci_topic, target).await {
278246 Ok(n) if n > 0 => tracing::debug!(
279247 count = n,
280248 space_id = %space_id,
Modified lib/git/src/openapi.rs View lib/git/src/openapi.rs
@@ -8,11 +8,6 @@
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,
1611 handler::code_comments::list_code_comments::list_code_comments,
1712 handler::code_comments::create_code_comment::create_code_comment,
1813 handler::code_comments::modify::update_code_comment,
@@ -24,7 +19,6 @@
2419 handler::releases::get_release::get_release,
2520 handler::releases::update_release::update_release,
2621 handler::releases::delete_release::delete_release,
27 handler::releases::redraft_release::redraft_release,
2822 handler::analysis::code_activity::code_activity,
2923 handler::analysis::contributors::contributors,
3024 handler::analysis::languages::languages,
@@ -152,11 +146,6 @@
152146 crate::service::protection::CreateTagPolicyCmd,
153147 crate::service::protection::UpdateTagPolicyCmd,
154148 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,
160149 crate::service::commits::CommitStatusDtoState,
161150 crate::service::commits::CommitsPageDto,
162151 crate::service::commits::SetCommitStatusCmd,
Modified lib/git/src/routes.rs View lib/git/src/routes.rs
@@ -1,8 +1,3 @@
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_review::trigger_ai_review::trigger_commit_ai_review;
4use crate::handler::ai_summary::get_ai_summary::get_ai_summary;
5use crate::handler::ai_summary::trigger_ai_summary::trigger_ai_summary;
61use crate::handler::analysis::code_activity::code_activity;
72use crate::handler::analysis::contributors::contributors;
83use crate::handler::analysis::languages::languages;
@@ -76,7 +71,6 @@
7671use crate::handler::releases::delete_release::delete_release;
7772use crate::handler::releases::get_release::get_release;
7873use crate::handler::releases::list_releases::list_releases;
79use crate::handler::releases::redraft_release::redraft_release;
8074use crate::handler::releases::update_release::update_release;
8175use crate::handler::remotes::add::upsert_remote;
8276use crate::handler::remotes::clone_import::clone_import as remote_clone_import;
@@ -178,20 +172,7 @@
178172 "/{space_name}/{repo_name}/commit-statuses/{sha}",
179173 get(list_commit_statuses),
180174 )
181 .route("/ai/languages", get(list_ai_languages))
182 .route(
183 "/{space_name}/{repo_name}/ai-review",
184 post(trigger_commit_ai_review),
185 )
186175 .route(
187 "/{space_name}/{repo_name}/commits/{revision}/ai-reviews",
188 get(list_commit_ai_reviews),
189 )
190 .route(
191 "/{space_name}/{repo_name}/ai-summary",
192 get(get_ai_summary).post(trigger_ai_summary),
193 )
194 .route(
195176 "/{space_name}/{repo_name}/code-comments",
196177 get(list_code_comments).post(create_code_comment),
197178 )
@@ -212,10 +193,6 @@
212193 get(get_release)
213194 .patch(update_release)
214195 .delete(delete_release),
215 )
216 .route(
217 "/{space_name}/{repo_name}/releases/{tag}/ai-draft",
218 post(redraft_release),
219196 )
220197 .route("/{space_name}/{repo_name}/tree/{revision}", get(get_tree))
221198 .route("/{space_name}/{repo_name}/path-log", get(path_log))
Modified lib/i18n/src/catalog.rs View lib/i18n/src/catalog.rs
@@ -328,52 +328,6 @@
328328 Reclaim,
329329}
330330
331/// AI review notification phrasing.
332///
333/// Unlike the rest of this crate these keep the full nine-language set that
334/// `agent::notifications` already shipped, because the AI output language is a
335/// per-task setting and the notification should follow it. Callers pass the
336/// raw `users.language`/task language; unknown values fall back to English.
337pub fn ai_review_title(language: &str, number: i32) -> String {
338 match language_tag(language).as_str() {
339 "zh" => format!("AI 评审完成 · #{number}"),
340 "ja" => format!("AI レビュー完了 · #{number}"),
341 "ko" => format!("AI 리뷰 완료 · #{number}"),
342 "fr" => format!("Revue IA terminée · #{number}"),
343 "de" => format!("KI-Review abgeschlossen · #{number}"),
344 "es" => format!("Revisión de IA completada · #{number}"),
345 "pt" => format!("Revisão de IA concluída · #{number}"),
346 "it" => format!("Revisione IA completata · #{number}"),
347 "ru" => format!("ИИ-ревью завершено · #{number}"),
348 _ => format!("AI review completed · #{number}"),
349 }
350}
351
352pub fn ai_review_body(language: &str, findings: usize, summary: &str) -> String {
353 let summary = summary.trim();
354 if findings == 0 {
355 return summary.to_string();
356 }
357 match language_tag(language).as_str() {
358 "zh" => format!("{findings} 条行级建议:{summary}"),
359 "ja" => format!("行コメント {findings} 件:{summary}"),
360 "ko" => format!("인라인 제안 {findings}건: {summary}"),
361 "fr" => format!("{findings} suggestion(s) en ligne : {summary}"),
362 "de" => format!("{findings} Inline-Hinweis(e): {summary}"),
363 "es" => format!("{findings} sugerencia(s) en línea: {summary}"),
364 "pt" => format!("{findings} sugestão(ões) em linha: {summary}"),
365 "it" => format!("{findings} suggerimento/i in linea: {summary}"),
366 "ru" => format!("{findings} замечаний к строкам: {summary}"),
367 _ => format!("{findings} inline suggestion(s): {summary}"),
368 }
369}
370
371/// Fold a BCP-47 tag onto its primary language subtag.
372fn language_tag(language: &str) -> String {
373 let normalized = ci_protocol::ai_languages::normalize(language);
374 normalized.split('-').next().unwrap_or("en").to_string()
375}
376
377331pub fn notification_reminder_title(locale: UiLocale) -> &'static str {
378332 match locale {
379333 UiLocale::ZhCn => "消息提醒",
Modified lib/i18n/src/lib.rs View lib/i18n/src/lib.rs
@@ -7,9 +7,8 @@
77//! `[email].default_locale`.
88//!
99//! Scope is deliberately narrow: two locales (`zh-CN`, `en`) covering the
10//! strings the platform actually emits, plus the nine-locale AI review phrasing
11//! preserved from `agent::notifications`. Anything written by a user or a model
12//! is never translated here.
10//! strings the platform actually emits. Anything written by a user is never
11//! translated here.
1312
1413pub mod catalog;
1514pub mod locale;
@@ -19,9 +18,9 @@
1918
2019/// Normalize a stored user preference to a locale this build can render.
2120///
22/// `users.language` doubles as the AI output-language preference, so values
23/// such as `ja` or `auto` are normal: they are not errors, they simply do not
24/// select a UI catalogue and therefore lose their turn in the chain.
21/// `users.language` is a free-form preference, so values such as `ja` or `auto`
22/// are normal: they are not errors, they simply do not select a UI catalogue and
23/// therefore lose their turn in the chain.
2524pub fn from_user_preference(value: Option<&str>) -> Option<UiLocale> {
2625 UiLocale::parse(value?)
2726}
Modified lib/kenauth/proto/common/v1/types.proto View lib/kenauth/proto/common/v1/types.proto
@@ -68,7 +68,7 @@
6868 TOKEN_AUDIENCE_REALTIME = 3; // realtime.gitdata.ai
6969 TOKEN_AUDIENCE_SFU = 4; // sfu.gitdata.ai(livekit)
7070 TOKEN_AUDIENCE_OBJECT = 5; // object.gitdata.ai
71 TOKEN_AUDIENCE_AGENT = 6; // agent.gitdata.ai
71 reserved 6;
7272 TOKEN_AUDIENCE_NOTIFY = 7; // notify.gitdata.ai
7373}
7474
Modified lib/kenauth/proto/v1/authorization.proto View lib/kenauth/proto/v1/authorization.proto
@@ -5,7 +5,7 @@
55import "common/v1/errors.proto";
66import "common/v1/types.proto";
77
8// 平台级授权。调用方:api(写接口入口)、object / notify / agent(动作前置检查)。
8// 平台级授权。调用方:api(写接口入口)、object / notify(动作前置检查)。
99//
1010// 边界:auth 只回答“这个用户在平台层面能不能做这类事”,
1111// 工会、仓库、房间内的细粒度授权属于各自服务的领域(api / git / chat),本树不定义。
@@ -33,7 +33,7 @@
3333 PLATFORM_CAPABILITY_MANAGE_CREDENTIALS = 3; // 管理本人会话、令牌、公钥、多因子
3434 PLATFORM_CAPABILITY_USE_GIT = 4; // 使用 git / pr 服务
3535 PLATFORM_CAPABILITY_USE_CHAT = 5; // 使用 chat / realtime / sfu
36 PLATFORM_CAPABILITY_USE_AGENT = 6; // 使用 agent 服务
36 reserved 6;
3737 PLATFORM_CAPABILITY_ADMIN_PANEL = 7; // 进入平台管理面
3838 PLATFORM_CAPABILITY_MANAGE_USERS = 8; // 增删改其他用户
3939 PLATFORM_CAPABILITY_MANAGE_PLATFORM = 9; // 平台配置、密钥轮换、配额
Modified lib/kenauth/proto/v1/events.proto View lib/kenauth/proto/v1/events.proto
@@ -9,7 +9,7 @@
99//
1010// 投递语义:at-least-once,消费者必须按 event_id 去重。事件在业务写入提交后发布;
1111// 发布失败不回滚业务,由本地 outbox 重试。事件不携带任何凭据明文(令牌、密钥、恢复码一律只出现摘要)。
12// 订阅方:notify(异常登录与安全提醒)、agent(用户生命周期)、api(审计与统计)、webhook(对外转发)。
12// 订阅方:notify(异常登录与安全提醒)、api(审计与统计)、webhook(对外转发)。
1313//
1414// 主题约定:auth.<kind>(如 auth.login_succeeded、auth.ssh_key_added),
1515// 队列组由订阅方各自声明;重试与死信由 crates/queue 的 ConsumerSpec 控制。
Modified lib/kenauth/proto/v1/identity.proto View lib/kenauth/proto/v1/identity.proto
@@ -6,7 +6,7 @@
66import "common/v1/types.proto";
77
88// 身份校验入口。调用方:api(用户接口前置)、git / ssh(HTTP 与 SSH 认证)、
9// chat / realtime / sfu(连接建立时)、issue / pr / agent / object / notify / webhook。
9// chat / realtime / sfu(连接建立时)、issue / pr / object / notify / webhook。
1010//
1111// 安全边界:
1212// * 凭据原文只在请求里出现,auth 立即摘要后丢弃,绝不回显、绝不落日志。
Modified lib/kenauth/proto/v1/organization.proto View lib/kenauth/proto/v1/organization.proto
@@ -281,7 +281,7 @@
281281 // 封禁附加信息。
282282 string ban_reason = 23;
283283 google.protobuf.Timestamp ban_expires_at = 24;
284 bool ban_allow_agent_join = 25;
284 reserved 25;
285285 bool ban_allow_speak = 26;
286286 bool ban_allow_push_repo = 27;
287287 bool ban_allow_comment_any = 28;
@@ -723,7 +723,7 @@
723723 optional string reason = 4;
724724 optional bool clear_expires_at = 5;
725725 google.protobuf.Timestamp expires_at = 6;
726 optional bool allow_agent_join = 7;
726 reserved 7;
727727 optional bool allow_speak = 8;
728728 optional bool allow_push_repo = 9;
729729 optional bool allow_comment_any = 10;
Modified lib/kenauth/proto/v1/token.proto View lib/kenauth/proto/v1/token.proto
@@ -6,7 +6,7 @@
66import "common/v1/types.proto";
77import "google/protobuf/timestamp.proto";
88
9// 令牌签发与内省。调用方:api(为 chat / realtime / sfu / object / agent / notify 请求令牌)、
9// 令牌签发与内省。调用方:api(为 chat / realtime / sfu / object / notify 请求令牌)、
1010// 各服务(连接建立或收到令牌后做内省)。
1111//
1212// 安全边界:
Modified lib/kenauth/src/client.rs View lib/kenauth/src/client.rs
@@ -694,7 +694,6 @@
694694 user_id: i64,
695695 reason: Option<Option<String>>,
696696 expires_at: Option<Option<prost_types::Timestamp>>,
697 allow_agent_join: Option<bool>,
698697 allow_speak: Option<bool>,
699698 allow_push_repo: Option<bool>,
700699 allow_comment_any: Option<bool>,
@@ -713,7 +712,6 @@
713712 reason: reason.map(|value| value.unwrap_or_default()),
714713 clear_expires_at,
715714 expires_at,
716 allow_agent_join,
717715 allow_speak,
718716 allow_push_repo,
719717 allow_comment_any,
Deleted lib/mcp-api/src/tools/args.rs
@@ -1,1084 +0,0 @@
1use serde::Deserialize;
2use uuid::Uuid;
3
4#[derive(Debug, Deserialize, schemars::JsonSchema)]
5pub struct SpaceNameArgs {
6 pub space: String,
7}
8
9#[derive(Debug, Deserialize, schemars::JsonSchema)]
10pub struct RepoRefArgs {
11 pub space: String,
12 pub repo: String,
13}
14
15#[derive(Debug, Deserialize, schemars::JsonSchema)]
16pub struct UserRefArgs {
17 #[serde(default)]
18 pub user_id: Option<i64>,
19 #[serde(default)]
20 pub username: Option<String>,
21}
22
23#[derive(Debug, Deserialize, schemars::JsonSchema)]
24pub struct UserIdsArgs {
25 pub ids: Vec<Uuid>,
26}
27
28#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
29pub struct SpaceListArgs {
30 #[serde(default)]
31 pub limit: Option<u64>,
32 #[serde(default)]
33 pub offset: Option<u64>,
34}
35
36#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
37pub struct SpaceExploreArgs {
38 #[serde(default)]
39 pub q: Option<String>,
40 #[serde(default)]
41 pub sort: Option<String>,
42 #[serde(default)]
43 pub limit: Option<u64>,
44 #[serde(default)]
45 pub offset: Option<u64>,
46}
47
48#[derive(Debug, Deserialize, schemars::JsonSchema)]
49pub struct SpaceCreateArgs {
50 pub name: String,
51 #[serde(default)]
52 pub description: Option<String>,
53 #[serde(default)]
54 pub is_private: bool,
55 #[serde(default)]
56 pub allow_join: Option<bool>,
57 #[serde(default)]
58 pub allow_invite: Option<bool>,
59 #[serde(default)]
60 pub welcome_message: Option<String>,
61 #[serde(default)]
62 pub allow_join_level: Option<String>,
63 #[serde(default)]
64 pub member_must_enable_2fa: bool,
65}
66
67#[derive(Debug, Deserialize, schemars::JsonSchema)]
68pub struct SpaceUpdateArgs {
69 pub space: String,
70 #[serde(default)]
71 pub name: Option<String>,
72 #[serde(default)]
73 pub description: Option<String>,
74 #[serde(default)]
75 pub is_private: Option<bool>,
76 #[serde(default)]
77 pub allow_join: Option<bool>,
78 #[serde(default)]
79 pub allow_invite: Option<bool>,
80 #[serde(default)]
81 pub welcome_message: Option<String>,
82 #[serde(default)]
83 pub allow_join_level: Option<String>,
84 #[serde(default)]
85 pub member_must_enable_2fa: Option<bool>,
86}
87
88#[derive(Debug, Deserialize, schemars::JsonSchema)]
89pub struct SpaceMemberListArgs {
90 pub space: String,
91 #[serde(default)]
92 pub limit: Option<u64>,
93 #[serde(default)]
94 pub offset: Option<u64>,
95}
96
97#[derive(Debug, Deserialize, schemars::JsonSchema)]
98pub struct SpaceMemberAddArgs {
99 pub space: String,
100 pub user_id: i64,
101 #[serde(default)]
102 pub alias_name: Option<String>,
103}
104
105#[derive(Debug, Deserialize, schemars::JsonSchema)]
106pub struct SpaceMemberUpdateArgs {
107 pub space: String,
108 pub user_id: i64,
109 #[serde(default)]
110 pub alias_name: Option<String>,
111 #[serde(default)]
112 pub muted: Option<bool>,
113}
114
115#[derive(Debug, Deserialize, schemars::JsonSchema)]
116pub struct SpaceMemberRefArgs {
117 pub space: String,
118 pub user_id: i64,
119}
120
121#[derive(Debug, Deserialize, schemars::JsonSchema)]
122pub struct SpaceGroupListArgs {
123 pub space: String,
124 #[serde(default)]
125 pub limit: Option<u64>,
126 #[serde(default)]
127 pub offset: Option<u64>,
128}
129
130#[derive(Debug, Deserialize, schemars::JsonSchema)]
131pub struct SpaceGroupCreateArgs {
132 pub space: String,
133 pub name: String,
134 #[serde(default)]
135 pub color: Option<String>,
136 #[serde(default)]
137 pub position: Option<i64>,
138 #[serde(default)]
139 pub permissions: Vec<String>,
140}
141
142#[derive(Debug, Deserialize, schemars::JsonSchema)]
143pub struct SpaceGroupUpdateArgs {
144 pub space: String,
145 pub group_id: Uuid,
146 #[serde(default)]
147 pub name: Option<String>,
148 #[serde(default)]
149 pub color: Option<String>,
150 #[serde(default)]
151 pub position: Option<i64>,
152 #[serde(default)]
153 pub permissions: Option<Vec<String>>,
154}
155
156#[derive(Debug, Deserialize, schemars::JsonSchema)]
157pub struct SpaceGroupRefArgs {
158 pub space: String,
159 pub group_id: Uuid,
160}
161
162#[derive(Debug, Deserialize, schemars::JsonSchema)]
163pub struct SpaceGroupAssignArgs {
164 pub space: String,
165 pub user_id: i64,
166 pub group_id: Uuid,
167}
168
169#[derive(Debug, Deserialize, schemars::JsonSchema)]
170pub struct SpaceDirTreeArgs {
171 pub space: String,
172 #[serde(default)]
173 pub parent_id: Option<Uuid>,
174}
175
176#[derive(Debug, Deserialize, schemars::JsonSchema)]
177pub struct SpaceDirCreateArgs {
178 pub space: String,
179 pub name: String,
180 #[serde(default)]
181 pub parent_id: Option<Uuid>,
182}
183
184#[derive(Debug, Deserialize, schemars::JsonSchema)]
185pub struct SpaceDirUpdateArgs {
186 pub space: String,
187 pub dir_id: Uuid,
188 pub name: String,
189}
190
191#[derive(Debug, Deserialize, schemars::JsonSchema)]
192pub struct SpaceDirMoveArgs {
193 pub space: String,
194 pub dir_id: Uuid,
195 #[serde(default)]
196 pub parent_id: Option<Uuid>,
197 #[serde(default)]
198 pub position: Option<i64>,
199}
200
201#[derive(Debug, Deserialize, schemars::JsonSchema)]
202pub struct SpaceInviteListArgs {
203 pub space: String,
204 #[serde(default)]
205 pub limit: Option<u64>,
206 #[serde(default)]
207 pub offset: Option<u64>,
208}
209
210#[derive(Debug, Deserialize, schemars::JsonSchema)]
211pub struct SpaceInviteCreateArgs {
212 pub space: String,
213 #[serde(default)]
214 pub user_id: Option<i64>,
215 #[serde(default)]
216 pub email: Option<String>,
217 #[serde(default)]
218 pub group_ids: Vec<Uuid>,
219 #[serde(default)]
220 pub expires_at: Option<String>,
221}
222
223#[derive(Debug, Deserialize, schemars::JsonSchema)]
224pub struct SpaceInviteRefArgs {
225 pub space: String,
226 pub invite_id: Uuid,
227}
228
229#[derive(Debug, Deserialize, schemars::JsonSchema)]
230pub struct SpaceJoinRequestListArgs {
231 pub space: String,
232 #[serde(default)]
233 pub status: Option<String>,
234 #[serde(default)]
235 pub limit: Option<u64>,
236 #[serde(default)]
237 pub offset: Option<u64>,
238}
239
240#[derive(Debug, Deserialize, schemars::JsonSchema)]
241pub struct SpaceJoinRequestCreateArgs {
242 pub space: String,
243 #[serde(default)]
244 pub note: Option<String>,
245}
246
247#[derive(Debug, Deserialize, schemars::JsonSchema)]
248pub struct SpaceJoinRequestRefArgs {
249 pub space: String,
250 pub request_id: Uuid,
251}
252
253#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
254pub struct RepoListArgs {
255 pub space: String,
256 #[serde(default)]
257 pub limit: Option<u64>,
258 #[serde(default)]
259 pub offset: Option<u64>,
260 #[serde(default)]
261 pub sort: Option<String>,
262}
263
264#[derive(Debug, Deserialize, schemars::JsonSchema)]
265pub struct RepoCreateArgs {
266 pub space: String,
267 pub name: String,
268 #[serde(default)]
269 pub description: Option<String>,
270 #[serde(default)]
271 pub is_private: bool,
272 #[serde(default)]
273 pub default_branch: Option<String>,
274 #[serde(default)]
275 pub parent_dir_id: Option<Uuid>,
276 #[serde(default)]
277 pub enable_lfs: bool,
278 #[serde(default)]
279 pub license_name: Option<String>,
280 #[serde(default)]
281 pub init_message: Option<String>,
282}
283
284#[derive(Debug, Deserialize, schemars::JsonSchema)]
285pub struct RepoUpdateArgs {
286 pub space: String,
287 pub repo: String,
288 #[serde(default)]
289 pub description: Option<String>,
290 #[serde(default)]
291 pub website: Option<String>,
292 #[serde(default)]
293 pub is_private: Option<bool>,
294 #[serde(default)]
295 pub enable_lfs: Option<bool>,
296 #[serde(default)]
297 pub default_branch: Option<String>,
298 #[serde(default)]
299 pub ai_review_on_push: Option<bool>,
300 #[serde(default)]
301 pub ai_summary_on_push: Option<bool>,
302 #[serde(default)]
303 pub ai_review_on_pull: Option<bool>,
304}
305
306#[derive(Debug, Deserialize, schemars::JsonSchema)]
307pub struct RepoArchiveArgs {
308 pub space: String,
309 pub repo: String,
310 pub archived: bool,
311}
312
313#[derive(Debug, Deserialize, schemars::JsonSchema)]
314pub struct RepoBranchGetArgs {
315 pub space: String,
316 pub repo: String,
317 pub branch: String,
318}
319
320#[derive(Debug, Deserialize, schemars::JsonSchema)]
321pub struct RepoBranchCreateArgs {
322 pub space: String,
323 pub repo: String,
324 pub name: String,
325 #[serde(default)]
326 pub start_point: Option<String>,
327}
328
329#[derive(Debug, Deserialize, schemars::JsonSchema)]
330pub struct RepoBranchRenameArgs {
331 pub space: String,
332 pub repo: String,
333 pub from: String,
334 pub to: String,
335}
336
337#[derive(Debug, Deserialize, schemars::JsonSchema)]
338pub struct RepoBranchDeleteArgs {
339 pub space: String,
340 pub repo: String,
341 pub name: String,
342 #[serde(default)]
343 pub force: bool,
344}
345
346#[derive(Debug, Deserialize, schemars::JsonSchema)]
347pub struct RepoBranchDefaultArgs {
348 pub space: String,
349 pub repo: String,
350 pub name: String,
351}
352
353#[derive(Debug, Deserialize, schemars::JsonSchema)]
354pub struct RepoTagGetArgs {
355 pub space: String,
356 pub repo: String,
357 pub tag: String,
358}
359
360#[derive(Debug, Deserialize, schemars::JsonSchema)]
361pub struct RepoTagCreateArgs {
362 pub space: String,
363 pub repo: String,
364 pub name: String,
365 pub target: String,
366 #[serde(default)]
367 pub message: Option<String>,
368 #[serde(default)]
369 pub annotated: bool,
370}
371
372#[derive(Debug, Deserialize, schemars::JsonSchema)]
373pub struct RepoTagDeleteArgs {
374 pub space: String,
375 pub repo: String,
376 pub name: String,
377}
378
379#[derive(Debug, Deserialize, schemars::JsonSchema)]
380pub struct RepoCommitListArgs {
381 pub space: String,
382 pub repo: String,
383 #[serde(default)]
384 pub revision: Option<String>,
385 #[serde(default)]
386 pub path: Option<String>,
387 #[serde(default)]
388 pub max_count: Option<u32>,
389 #[serde(default)]
390 pub skip: Option<u32>,
391 #[serde(default)]
392 pub first_parent: bool,
393}
394
395#[derive(Debug, Deserialize, schemars::JsonSchema)]
396pub struct RepoCommitGetArgs {
397 pub space: String,
398 pub repo: String,
399 pub revision: String,
400}
401
402#[derive(Debug, Deserialize, schemars::JsonSchema)]
403pub struct RepoTreeArgs {
404 pub space: String,
405 pub repo: String,
406 #[serde(default)]
407 pub revision: Option<String>,
408 #[serde(default)]
409 pub path: Option<String>,
410 #[serde(default)]
411 pub recurse: bool,
412}
413
414#[derive(Debug, Deserialize, schemars::JsonSchema)]
415pub struct RepoFileArgs {
416 pub space: String,
417 pub repo: String,
418 pub path: String,
419 #[serde(default)]
420 pub revision: Option<String>,
421}
422
423#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
424pub struct RepoReleaseListArgs {
425 pub space: String,
426 pub repo: String,
427 #[serde(default)]
428 pub draft: Option<bool>,
429 #[serde(default)]
430 pub limit: Option<u64>,
431 #[serde(default)]
432 pub offset: Option<u64>,
433}
434
435#[derive(Debug, Deserialize, schemars::JsonSchema)]
436pub struct RepoReleaseGetArgs {
437 pub space: String,
438 pub repo: String,
439 pub tag: String,
440}
441
442#[derive(Debug, Deserialize, schemars::JsonSchema)]
443pub struct RepoReleaseCreateArgs {
444 pub space: String,
445 pub repo: String,
446 pub tag_name: String,
447 #[serde(default)]
448 pub target: Option<String>,
449 #[serde(default)]
450 pub title: Option<String>,
451 #[serde(default)]
452 pub content: Option<String>,
453 #[serde(default)]
454 pub is_draft: Option<bool>,
455 #[serde(default)]
456 pub is_prerelease: Option<bool>,
457}
458
459#[derive(Debug, Deserialize, schemars::JsonSchema)]
460pub struct RepoReleaseUpdateArgs {
461 pub space: String,
462 pub repo: String,
463 pub tag: String,
464 #[serde(default)]
465 pub title: Option<String>,
466 #[serde(default)]
467 pub content: Option<String>,
468 #[serde(default)]
469 pub is_draft: Option<bool>,
470 #[serde(default)]
471 pub is_prerelease: Option<bool>,
472}
473
474#[derive(Debug, Deserialize, schemars::JsonSchema)]
475pub struct RepoReleaseDeleteArgs {
476 pub space: String,
477 pub repo: String,
478 pub tag: String,
479}
480
481#[derive(Debug, Deserialize, schemars::JsonSchema)]
482pub struct RepoTopicArgs {
483 pub space: String,
484 pub repo: String,
485}
486
487#[derive(Debug, Deserialize, schemars::JsonSchema)]
488pub struct RepoTopicChangeArgs {
489 pub space: String,
490 pub repo: String,
491 pub topic: String,
492}
493
494#[derive(Debug, Deserialize, schemars::JsonSchema)]
495pub struct RepoWatchArgs {
496 pub space: String,
497 pub repo: String,
498 pub mode: String,
499}
500
501#[derive(Debug, Deserialize, schemars::JsonSchema)]
502pub struct RepoStargazerListArgs {
503 pub space: String,
504 pub repo: String,
505 #[serde(default)]
506 pub limit: Option<u64>,
507 #[serde(default)]
508 pub offset: Option<u64>,
509}
510
511#[derive(Debug, Deserialize, schemars::JsonSchema)]
512pub struct RepoWatcherListArgs {
513 pub space: String,
514 pub repo: String,
515 #[serde(default)]
516 pub mode: Option<String>,
517 #[serde(default)]
518 pub limit: Option<u64>,
519 #[serde(default)]
520 pub offset: Option<u64>,
521}
522
523#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
524pub struct IssueBoardListArgs {
525 pub space: String,
526 #[serde(default)]
527 pub dir_id: Option<Uuid>,
528 #[serde(default)]
529 pub q: Option<String>,
530 #[serde(default)]
531 pub limit: Option<u64>,
532 #[serde(default)]
533 pub offset: Option<u64>,
534}
535
536#[derive(Debug, Deserialize, schemars::JsonSchema)]
537pub struct IssueBoardCreateArgs {
538 pub space: String,
539 pub name: String,
540 #[serde(default)]
541 pub parent_dir_id: Option<Uuid>,
542 /// Number prefix for the board's issues (`CAD` renders as `CAD-12`).
543 #[serde(default)]
544 pub prefix: Option<String>,
545 #[serde(default)]
546 pub icon: Option<String>,
547 #[serde(default)]
548 pub description: Option<String>,
549}
550
551#[derive(Debug, Deserialize, schemars::JsonSchema)]
552pub struct IssueBoardUpdateArgs {
553 pub space: String,
554 pub board_id: Uuid,
555 #[serde(default)]
556 pub name: Option<String>,
557 /// Number prefix; `""` clears it.
558 #[serde(default)]
559 pub prefix: Option<String>,
560 #[serde(default)]
561 pub icon: Option<String>,
562 #[serde(default)]
563 pub description: Option<String>,
564}
565
566#[derive(Debug, Deserialize, schemars::JsonSchema)]
567pub struct IssueBoardRefArgs {
568 pub space: String,
569 pub board_id: Uuid,
570}
571
572#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
573pub struct IssueListArgs {
574 pub space: String,
575 pub board_id: Uuid,
576 #[serde(default)]
577 pub state: Option<String>,
578 #[serde(default)]
579 pub author_id: Option<i64>,
580 #[serde(default)]
581 pub milestone_id: Option<Uuid>,
582 #[serde(default)]
583 pub label_id: Option<Uuid>,
584 #[serde(default)]
585 pub assignee_id: Option<i64>,
586 #[serde(default)]
587 pub priority: Option<String>,
588 #[serde(default)]
589 pub sort: Option<String>,
590 #[serde(default)]
591 pub q: Option<String>,
592 #[serde(default)]
593 pub limit: Option<u64>,
594 #[serde(default)]
595 pub offset: Option<u64>,
596}
597
598#[derive(Debug, Deserialize, schemars::JsonSchema)]
599pub struct IssueRefArgs {
600 pub space: String,
601 pub board_id: Uuid,
602 pub issue_id: String,
603}
604
605#[derive(Debug, Deserialize, schemars::JsonSchema)]
606pub struct IssueCreateArgs {
607 pub space: String,
608 pub board_id: Uuid,
609 pub title: String,
610 #[serde(default)]
611 pub description: Option<String>,
612 #[serde(default)]
613 pub content: Option<String>,
614 #[serde(default)]
615 pub milestone_id: Option<Uuid>,
616 #[serde(default)]
617 pub priority: Option<String>,
618 #[serde(default)]
619 pub due_at: Option<String>,
620 #[serde(default)]
621 pub assignee_ids: Vec<i64>,
622 #[serde(default)]
623 pub label_ids: Vec<Uuid>,
624}
625
626#[derive(Debug, Deserialize, schemars::JsonSchema)]
627pub struct IssueUpdateArgs {
628 pub space: String,
629 pub board_id: Uuid,
630 pub issue_id: String,
631 #[serde(default)]
632 pub title: Option<String>,
633 #[serde(default)]
634 pub description: Option<String>,
635 #[serde(default)]
636 pub content: Option<String>,
637 #[serde(default)]
638 pub milestone_id: Option<Uuid>,
639 #[serde(default)]
640 pub priority: Option<String>,
641 #[serde(default)]
642 pub due_at: Option<String>,
643 #[serde(default)]
644 pub assignee_ids: Option<Vec<i64>>,
645 #[serde(default)]
646 pub label_ids: Option<Vec<Uuid>>,
647}
648
649#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
650pub struct IssueCommentListArgs {
651 pub space: String,
652 pub board_id: Uuid,
653 pub issue_id: String,
654 #[serde(default)]
655 pub parent_id: Option<Uuid>,
656 #[serde(default)]
657 pub sort: Option<String>,
658 #[serde(default)]
659 pub limit: Option<u64>,
660 #[serde(default)]
661 pub offset: Option<u64>,
662}
663
664#[derive(Debug, Deserialize, schemars::JsonSchema)]
665pub struct IssueCommentCreateArgs {
666 pub space: String,
667 pub board_id: Uuid,
668 pub issue_id: String,
669 pub content: String,
670 #[serde(default)]
671 pub parent_id: Option<Uuid>,
672}
673
674#[derive(Debug, Deserialize, schemars::JsonSchema)]
675pub struct IssueCommentUpdateArgs {
676 pub space: String,
677 pub board_id: Uuid,
678 pub issue_id: String,
679 pub comment_id: Uuid,
680 pub content: String,
681}
682
683#[derive(Debug, Deserialize, schemars::JsonSchema)]
684pub struct IssueCommentRefArgs {
685 pub space: String,
686 pub board_id: Uuid,
687 pub issue_id: String,
688 pub comment_id: Uuid,
689}
690
691#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
692pub struct IssueLabelListArgs {
693 pub space: String,
694 pub board_id: Uuid,
695 #[serde(default)]
696 pub q: Option<String>,
697 #[serde(default)]
698 pub limit: Option<u64>,
699 #[serde(default)]
700 pub offset: Option<u64>,
701}
702
703#[derive(Debug, Deserialize, schemars::JsonSchema)]
704pub struct IssueLabelCreateArgs {
705 pub space: String,
706 pub board_id: Uuid,
707 pub name: String,
708 pub color: String,
709 #[serde(default)]
710 pub exclusive: Option<bool>,
711 #[serde(default)]
712 pub description: Option<String>,
713}
714
715#[derive(Debug, Deserialize, schemars::JsonSchema)]
716pub struct IssueLabelUpdateArgs {
717 pub space: String,
718 pub board_id: Uuid,
719 pub label_id: Uuid,
720 #[serde(default)]
721 pub name: Option<String>,
722 #[serde(default)]
723 pub color: Option<String>,
724 #[serde(default)]
725 pub exclusive: Option<bool>,
726 #[serde(default)]
727 pub description: Option<String>,
728 #[serde(default)]
729 pub is_archived: Option<bool>,
730}
731
732#[derive(Debug, Deserialize, schemars::JsonSchema)]
733pub struct IssueLabelRefArgs {
734 pub space: String,
735 pub board_id: Uuid,
736 pub label_id: Uuid,
737}
738
739#[derive(Debug, Deserialize, schemars::JsonSchema)]
740pub struct IssueLabelBindArgs {
741 pub space: String,
742 pub board_id: Uuid,
743 pub issue_id: String,
744 pub label_id: Uuid,
745}
746
747#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
748pub struct IssueMilestoneListArgs {
749 pub space: String,
750 pub board_id: Uuid,
751 #[serde(default)]
752 pub is_closed: Option<bool>,
753 #[serde(default)]
754 pub q: Option<String>,
755 #[serde(default)]
756 pub limit: Option<u64>,
757 #[serde(default)]
758 pub offset: Option<u64>,
759}
760
761#[derive(Debug, Deserialize, schemars::JsonSchema)]
762pub struct IssueMilestoneCreateArgs {
763 pub space: String,
764 pub board_id: Uuid,
765 pub name: String,
766 #[serde(default)]
767 pub description: Option<String>,
768 #[serde(default)]
769 pub due_at: Option<String>,
770}
771
772#[derive(Debug, Deserialize, schemars::JsonSchema)]
773pub struct IssueMilestoneUpdateArgs {
774 pub space: String,
775 pub board_id: Uuid,
776 pub milestone_id: Uuid,
777 #[serde(default)]
778 pub name: Option<String>,
779 #[serde(default)]
780 pub description: Option<String>,
781 #[serde(default)]
782 pub due_at: Option<String>,
783 #[serde(default)]
784 pub is_closed: Option<bool>,
785}
786
787#[derive(Debug, Deserialize, schemars::JsonSchema)]
788pub struct IssueMilestoneRefArgs {
789 pub space: String,
790 pub board_id: Uuid,
791 pub milestone_id: Uuid,
792}
793
794#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
795pub struct IssueMemberListArgs {
796 pub space: String,
797 pub board_id: Uuid,
798 pub issue_id: String,
799 #[serde(default)]
800 pub limit: Option<u64>,
801 #[serde(default)]
802 pub offset: Option<u64>,
803}
804
805#[derive(Debug, Deserialize, schemars::JsonSchema)]
806pub struct IssueMemberCreateArgs {
807 pub space: String,
808 pub board_id: Uuid,
809 pub issue_id: String,
810 pub user_id: i64,
811 pub kind: String,
812}
813
814#[derive(Debug, Deserialize, schemars::JsonSchema)]
815pub struct IssueMemberRefArgs {
816 pub space: String,
817 pub board_id: Uuid,
818 pub issue_id: String,
819 pub member_id: Uuid,
820}
821
822#[derive(Debug, Deserialize, schemars::JsonSchema)]
823pub struct IssuePinListArgs {
824 pub space: String,
825 pub board_id: Uuid,
826}
827
828#[derive(Debug, Deserialize, schemars::JsonSchema)]
829pub struct IssuePinCreateArgs {
830 pub space: String,
831 pub board_id: Uuid,
832 pub issue_id: String,
833 #[serde(default)]
834 pub pin_order: Option<i32>,
835}
836
837#[derive(Debug, Deserialize, schemars::JsonSchema)]
838pub struct IssuePinUpdateArgs {
839 pub space: String,
840 pub board_id: Uuid,
841 pub pin_id: Uuid,
842 pub pin_order: i32,
843}
844
845#[derive(Debug, Deserialize, schemars::JsonSchema)]
846pub struct IssuePinRefArgs {
847 pub space: String,
848 pub board_id: Uuid,
849 pub pin_id: Uuid,
850}
851
852#[derive(Debug, Deserialize, schemars::JsonSchema)]
853pub struct IssueDependencyListArgs {
854 pub space: String,
855 pub board_id: Uuid,
856 pub issue_id: String,
857}
858
859#[derive(Debug, Deserialize, schemars::JsonSchema)]
860pub struct IssueDependencyCreateArgs {
861 pub space: String,
862 pub board_id: Uuid,
863 pub issue_id: String,
864 pub dependency_id: Uuid,
865}
866
867#[derive(Debug, Deserialize, schemars::JsonSchema)]
868pub struct IssueDependencyRefArgs {
869 pub space: String,
870 pub board_id: Uuid,
871 pub issue_id: String,
872 pub dep_id: Uuid,
873}
874
875#[derive(Debug, Deserialize, schemars::JsonSchema)]
876pub struct PullListArgs {
877 pub space: String,
878 pub repo: String,
879 #[serde(default)]
880 pub state: Option<String>,
881 #[serde(default)]
882 pub author: Option<String>,
883 #[serde(default)]
884 pub base: Option<String>,
885 #[serde(default)]
886 pub head: Option<String>,
887 #[serde(default)]
888 pub draft: Option<bool>,
889 #[serde(default)]
890 pub search: Option<String>,
891 #[serde(default)]
892 pub sort: Option<String>,
893 #[serde(default)]
894 pub order: Option<String>,
895 #[serde(default)]
896 pub limit: Option<u64>,
897 #[serde(default)]
898 pub offset: Option<u64>,
899}
900
901#[derive(Debug, Deserialize, schemars::JsonSchema)]
902pub struct PullCreateArgs {
903 pub space: String,
904 pub repo: String,
905 pub title: String,
906 pub head_branch: String,
907 #[serde(default)]
908 pub body: Option<String>,
909 #[serde(default)]
910 pub base_branch: Option<String>,
911 #[serde(default)]
912 pub draft: Option<bool>,
913 #[serde(default)]
914 pub issue_id: Option<Uuid>,
915}
916
917#[derive(Debug, Deserialize, schemars::JsonSchema)]
918pub struct PullRefArgs {
919 pub space: String,
920 pub repo: String,
921 pub pull: String,
922}
923
924#[derive(Debug, Deserialize, schemars::JsonSchema)]
925pub struct PullUpdateArgs {
926 pub space: String,
927 pub repo: String,
928 pub pull: String,
929 #[serde(default)]
930 pub title: Option<String>,
931 #[serde(default)]
932 pub body: Option<String>,
933 #[serde(default)]
934 pub base_branch: Option<String>,
935 #[serde(default)]
936 pub draft: Option<bool>,
937 #[serde(default)]
938 pub auto_merge: Option<bool>,
939}
940
941#[derive(Debug, Deserialize, schemars::JsonSchema)]
942pub struct PullMergeArgs {
943 pub space: String,
944 pub repo: String,
945 pub pull: String,
946 #[serde(default)]
947 pub method: Option<String>,
948 #[serde(default)]
949 pub title: Option<String>,
950 #[serde(default)]
951 pub message: Option<String>,
952 #[serde(default)]
953 pub fast_forward_only: Option<bool>,
954 #[serde(default)]
955 pub expect_head_sha: Option<String>,
956 #[serde(default)]
957 pub delete_branch_after_merge: Option<bool>,
958}
959
960#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
961pub struct PullCommentListArgs {
962 pub space: String,
963 pub repo: String,
964 pub pull: String,
965 #[serde(default)]
966 pub limit: Option<u64>,
967 #[serde(default)]
968 pub offset: Option<u64>,
969}
970
971#[derive(Debug, Deserialize, schemars::JsonSchema)]
972pub struct PullCommentCreateArgs {
973 pub space: String,
974 pub repo: String,
975 pub pull: String,
976 pub body: String,
977 #[serde(default)]
978 pub path: Option<String>,
979 #[serde(default)]
980 pub line: Option<i32>,
981 #[serde(default)]
982 pub side: Option<String>,
983 #[serde(default)]
984 pub commit_sha: Option<String>,
985 #[serde(default)]
986 pub parent_comment_id: Option<Uuid>,
987}
988
989#[derive(Debug, Deserialize, schemars::JsonSchema)]
990pub struct PullCommentUpdateArgs {
991 pub space: String,
992 pub repo: String,
993 pub pull: String,
994 pub comment_id: Uuid,
995 pub body: String,
996}
997
998#[derive(Debug, Deserialize, schemars::JsonSchema)]
999pub struct PullCommentRefArgs {
1000 pub space: String,
1001 pub repo: String,
1002 pub pull: String,
1003 pub comment_id: Uuid,
1004}
1005
1006#[derive(Debug, Deserialize, schemars::JsonSchema)]
1007pub struct PullCommentResolveArgs {
1008 pub space: String,
1009 pub repo: String,
1010 pub pull: String,
1011 pub comment_id: Uuid,
1012 pub resolved: bool,
1013}
1014
1015#[derive(Debug, Deserialize, schemars::JsonSchema)]
1016pub struct PullReviewsArgs {
1017 pub space: String,
1018 pub repo: String,
1019 pub pull: String,
1020}
1021
1022#[derive(Debug, Deserialize, schemars::JsonSchema)]
1023pub struct PullReviewSubmitArgs {
1024 pub space: String,
1025 pub repo: String,
1026 pub pull: String,
1027 pub state: String,
1028 #[serde(default)]
1029 pub body: Option<String>,
1030}
1031
1032#[derive(Debug, Deserialize, schemars::JsonSchema)]
1033pub struct PullReviewDismissArgs {
1034 pub space: String,
1035 pub repo: String,
1036 pub pull: String,
1037 pub review_id: Uuid,
1038}
1039
1040#[derive(Debug, Deserialize, schemars::JsonSchema)]
1041pub struct PullReviewerRequestArgs {
1042 pub space: String,
1043 pub repo: String,
1044 pub pull: String,
1045 pub reviewers: Vec<String>,
1046 #[serde(default)]
1047 pub required: Option<bool>,
1048}
1049
1050#[derive(Debug, Deserialize, schemars::JsonSchema)]
1051pub struct PullReviewerCancelArgs {
1052 pub space: String,
1053 pub repo: String,
1054 pub pull: String,
1055 pub reviewer_id: i64,
1056}
1057
1058#[derive(Debug, Deserialize, schemars::JsonSchema)]
1059pub struct PullAssigneeArgs {
1060 pub space: String,
1061 pub repo: String,
1062 pub pull: String,
1063 pub user: String,
1064}
1065
1066#[derive(Debug, Deserialize, schemars::JsonSchema)]
1067pub struct PullLabelArgs {
1068 pub space: String,
1069 pub repo: String,
1070 pub pull: String,
1071 pub label: String,
1072}
1073
1074#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
1075pub struct PullInboxArgs {
1076 /// created | review-requested | reviewed
1077 pub scope: String,
1078 #[serde(default)]
1079 pub state: Option<String>,
1080 #[serde(default)]
1081 pub limit: Option<u64>,
1082 #[serde(default)]
1083 pub offset: Option<u64>,
1084}
Deleted lib/mcp-api/src/tools/helpers.rs
@@ -1,67 +0,0 @@
1use git::context::facade::GitContext;
2use rmcp::model::CallToolResult;
3use uuid::Uuid;
4
5use crate::error::{tool_error, tool_message};
6use crate::services::McpServices;
7
8pub(crate) fn page(
9 limit: Option<u64>,
10 offset: Option<u64>,
11 sort: Option<String>,
12) -> export::pagination::PageQuery {
13 export::pagination::PageQuery {
14 limit,
15 offset,
16 sort,
17 }
18}
19
20pub(crate) fn parse_time(raw: &str) -> Result<time::OffsetDateTime, CallToolResult> {
21 time::OffsetDateTime::parse(raw.trim(), &time::format_description::well_known::Rfc3339).map_err(
22 |_| {
23 tool_message(format!(
24 "时间格式无效(需要 RFC3339,如 2025-01-01T00:00:00Z): {raw}"
25 ))
26 },
27 )
28}
29
30pub(crate) fn parse_enum<T: serde::de::DeserializeOwned>(raw: &str) -> Result<T, CallToolResult> {
31 serde_json::from_value(serde_json::Value::String(raw.trim().to_string()))
32 .map_err(|_| tool_message(format!("无效的枚举值: {raw}")))
33}
34
35pub(crate) async fn repo_read(
36 services: &McpServices,
37 uid: i64,
38 space: &str,
39 repo: &str,
40) -> Result<GitContext, CallToolResult> {
41 let ctx = services
42 .git
43 .context(space.trim().to_string(), repo.trim().to_string())
44 .await
45 .map_err(tool_error)?;
46 git::share::access::authorize_repo_access(&services.git, uid, &ctx, false, false)
47 .await
48 .map_err(tool_error)?;
49 Ok(ctx)
50}
51
52pub(crate) async fn repo_write(
53 services: &McpServices,
54 uid: i64,
55 space: &str,
56 repo: &str,
57) -> Result<GitContext, CallToolResult> {
58 let ctx = services
59 .git
60 .context(space.trim().to_string(), repo.trim().to_string())
61 .await
62 .map_err(tool_error)?;
63 git::share::access::authorize_repo_access(&services.git, uid, &ctx, true, false)
64 .await
65 .map_err(tool_error)?;
66 Ok(ctx)
67}
Deleted lib/mcp-api/src/tools/issue.rs
@@ -1,1128 +0,0 @@
1use issues::services::boards::{CreateBoardCmd, ListBoardQuery, UpdateBoardCmd};
2use issues::services::comments::{CreateCommentCmd, ListCommentQuery, UpdateCommentCmd};
3use issues::services::dependencies::CreateDependencyCmd;
4use issues::services::issues::{
5 CreateIssueCmd, IssueState, ListIssueQuery, UpdateIssueCmd, priority_query_key,
6};
7use issues::services::labels::{CreateLabelCmd, ListLabelQuery, UpdateLabelCmd};
8use issues::services::members::{CreateIssueMemberCmd, ListIssueMemberQuery};
9use issues::services::milestones::{CreateMilestoneCmd, ListMilestoneQuery, UpdateMilestoneCmd};
10use issues::services::pins::{CreatePinCmd, UpdatePinCmd};
11use models::issue::issue_item::IssuePriority;
12use models::issue::issue_member::IssueMemberKind;
13use rmcp::model::CallToolResult;
14use uuid::Uuid;
15
16use crate::auth::Principal;
17use crate::error::{tool_done, tool_error, tool_ok, write_denied};
18use crate::services::McpServices;
19use crate::tools::args;
20use crate::tools::helpers::{parse_enum, parse_time};
21
22async fn resolve_issue(
23 services: &McpServices,
24 principal: &Principal,
25 space: &str,
26 board_id: Uuid,
27 reference: &str,
28) -> Result<Uuid, CallToolResult> {
29 services
30 .issues
31 .resolve_issue_ref(principal.uid, space.trim(), board_id, reference.trim())
32 .await
33 .map_err(tool_error)
34}
35
36pub(crate) async fn board_list(
37 services: &McpServices,
38 principal: &Principal,
39 args: args::IssueBoardListArgs,
40) -> CallToolResult {
41 let query = ListBoardQuery {
42 dir_id: args.dir_id,
43 q: args.q,
44 limit: args.limit,
45 offset: args.offset,
46 };
47 match services
48 .issues
49 .list_boards(principal.uid, args.space.trim(), query)
50 .await
51 {
52 Ok(result) => tool_ok(&result),
53 Err(error) => tool_error(error),
54 }
55}
56
57pub(crate) async fn board_get(
58 services: &McpServices,
59 principal: &Principal,
60 args: args::IssueBoardRefArgs,
61) -> CallToolResult {
62 match services
63 .issues
64 .get_board(principal.uid, args.space.trim(), args.board_id)
65 .await
66 {
67 Ok(result) => tool_ok(&result),
68 Err(error) => tool_error(error),
69 }
70}
71
72pub(crate) async fn board_create(
73 services: &McpServices,
74 principal: &Principal,
75 args: args::IssueBoardCreateArgs,
76) -> CallToolResult {
77 if !principal.can_write() {
78 return write_denied();
79 }
80 let cmd = CreateBoardCmd {
81 name: args.name,
82 parent_dir_id: args.parent_dir_id,
83 prefix: args.prefix,
84 icon: args.icon,
85 description: args.description,
86 };
87 match services
88 .issues
89 .create_board(principal.uid, args.space.trim(), cmd)
90 .await
91 {
92 Ok(result) => tool_ok(&result),
93 Err(error) => tool_error(error),
94 }
95}
96
97pub(crate) async fn board_update(
98 services: &McpServices,
99 principal: &Principal,
100 args: args::IssueBoardUpdateArgs,
101) -> CallToolResult {
102 if !principal.can_write() {
103 return write_denied();
104 }
105 let cmd = UpdateBoardCmd {
106 name: args.name,
107 prefix: args.prefix.map(Some),
108 icon: args.icon.map(Some),
109 description: args.description.map(Some),
110 };
111 match services
112 .issues
113 .update_board(principal.uid, args.space.trim(), args.board_id, cmd)
114 .await
115 {
116 Ok(result) => tool_ok(&result),
117 Err(error) => tool_error(error),
118 }
119}
120
121pub(crate) async fn board_delete(
122 services: &McpServices,
123 principal: &Principal,
124 args: args::IssueBoardRefArgs,
125) -> CallToolResult {
126 if !principal.can_write() {
127 return write_denied();
128 }
129 match services
130 .issues
131 .delete_board(principal.uid, args.space.trim(), args.board_id)
132 .await
133 {
134 Ok(()) => tool_done(),
135 Err(error) => tool_error(error),
136 }
137}
138
139pub(crate) async fn list(
140 services: &McpServices,
141 principal: &Principal,
142 args: args::IssueListArgs,
143) -> CallToolResult {
144 let state = match args.state.as_deref() {
145 Some(raw) => match parse_enum::<IssueState>(raw) {
146 Ok(state) => Some(state),
147 Err(error) => return error,
148 },
149 None => None,
150 };
151 let priority = match args.priority.as_deref() {
152 Some(raw) => match parse_enum::<IssuePriority>(raw) {
153 Ok(priority) => Some(priority),
154 Err(error) => return error,
155 },
156 None => None,
157 };
158 let query = ListIssueQuery {
159 state,
160 author_id: args.author_id,
161 milestone_id: args.milestone_id.map(|id| id.to_string()),
162 label_id: args.label_id.map(|id| id.to_string()),
163 assignee_id: args.assignee_id.map(|id| id.to_string()),
164 priority: priority.map(|value| priority_query_key(value).to_string()),
165 sort: args.sort,
166 q: args.q,
167 limit: args.limit,
168 offset: args.offset,
169 };
170 match services
171 .issues
172 .list_issues(principal.uid, args.space.trim(), args.board_id, query)
173 .await
174 {
175 Ok(result) => tool_ok(&result),
176 Err(error) => tool_error(error),
177 }
178}
179
180pub(crate) async fn get(
181 services: &McpServices,
182 principal: &Principal,
183 args: args::IssueRefArgs,
184) -> CallToolResult {
185 let issue_id = match resolve_issue(
186 services,
187 principal,
188 &args.space,
189 args.board_id,
190 &args.issue_id,
191 )
192 .await
193 {
194 Ok(id) => id,
195 Err(error) => return error,
196 };
197 match services
198 .issues
199 .get_issue(principal.uid, args.space.trim(), args.board_id, issue_id)
200 .await
201 {
202 Ok(result) => tool_ok(&result),
203 Err(error) => tool_error(error),
204 }
205}
206
207pub(crate) async fn create(
208 services: &McpServices,
209 principal: &Principal,
210 args: args::IssueCreateArgs,
211) -> CallToolResult {
212 if !principal.can_write() {
213 return write_denied();
214 }
215 let priority = match args.priority.as_deref() {
216 Some(raw) => match parse_enum::<IssuePriority>(raw) {
217 Ok(priority) => priority,
218 Err(error) => return error,
219 },
220 None => IssuePriority::None,
221 };
222 let due_at = match args.due_at.as_deref() {
223 Some(raw) => match parse_time(raw) {
224 Ok(time) => Some(time),
225 Err(error) => return error,
226 },
227 None => None,
228 };
229 let cmd = CreateIssueCmd {
230 title: args.title,
231 description: args.description,
232 content: args.content.unwrap_or_default(),
233 milestone_id: args.milestone_id,
234 priority,
235 due_at,
236 assignee_ids: args.assignee_ids,
237 label_ids: args.label_ids,
238 attachment_ids: Vec::new(),
239 };
240 match services
241 .issues
242 .create_issue(principal.uid, args.space.trim(), args.board_id, cmd)
243 .await
244 {
245 Ok(result) => tool_ok(&result),
246 Err(error) => tool_error(error),
247 }
248}
249
250pub(crate) async fn update(
251 services: &McpServices,
252 principal: &Principal,
253 args: args::IssueUpdateArgs,
254) -> CallToolResult {
255 if !principal.can_write() {
256 return write_denied();
257 }
258 let issue_id = match resolve_issue(
259 services,
260 principal,
261 &args.space,
262 args.board_id,
263 &args.issue_id,
264 )
265 .await
266 {
267 Ok(id) => id,
268 Err(error) => return error,
269 };
270 let priority = match args.priority.as_deref() {
271 Some(raw) => match parse_enum::<IssuePriority>(raw) {
272 Ok(priority) => Some(priority),
273 Err(error) => return error,
274 },
275 None => None,
276 };
277 let due_at = match args.due_at.as_deref() {
278 Some(raw) => match parse_time(raw) {
279 Ok(time) => Some(Some(time)),
280 Err(error) => return error,
281 },
282 None => None,
283 };
284 let cmd = UpdateIssueCmd {
285 title: args.title,
286 description: args.description.map(Some),
287 content: args.content,
288 milestone_id: args.milestone_id.map(Some),
289 priority,
290 due_at,
291 assignee_ids: args.assignee_ids,
292 label_ids: args.label_ids,
293 };
294 match services
295 .issues
296 .update_issue(
297 principal.uid,
298 args.space.trim(),
299 args.board_id,
300 issue_id,
301 cmd,
302 )
303 .await
304 {
305 Ok(result) => tool_ok(&result),
306 Err(error) => tool_error(error),
307 }
308}
309
310pub(crate) async fn delete(
311 services: &McpServices,
312 principal: &Principal,
313 args: args::IssueRefArgs,
314) -> CallToolResult {
315 if !principal.can_write() {
316 return write_denied();
317 }
318 let issue_id = match resolve_issue(
319 services,
320 principal,
321 &args.space,
322 args.board_id,
323 &args.issue_id,
324 )
325 .await
326 {
327 Ok(id) => id,
328 Err(error) => return error,
329 };
330 match services
331 .issues
332 .delete_issue(principal.uid, args.space.trim(), args.board_id, issue_id)
333 .await
334 {
335 Ok(()) => tool_done(),
336 Err(error) => tool_error(error),
337 }
338}
339
340pub(crate) async fn set_closed(
341 services: &McpServices,
342 principal: &Principal,
343 args: args::IssueRefArgs,
344 closed: bool,
345) -> CallToolResult {
346 if !principal.can_write() {
347 return write_denied();
348 }
349 let issue_id = match resolve_issue(
350 services,
351 principal,
352 &args.space,
353 args.board_id,
354 &args.issue_id,
355 )
356 .await
357 {
358 Ok(id) => id,
359 Err(error) => return error,
360 };
361 match services
362 .issues
363 .close_issue(
364 principal.uid,
365 args.space.trim(),
366 args.board_id,
367 issue_id,
368 closed,
369 )
370 .await
371 {
372 Ok(result) => tool_ok(&result),
373 Err(error) => tool_error(error),
374 }
375}
376
377pub(crate) async fn comment_list(
378 services: &McpServices,
379 principal: &Principal,
380 args: args::IssueCommentListArgs,
381) -> CallToolResult {
382 let issue_id = match resolve_issue(
383 services,
384 principal,
385 &args.space,
386 args.board_id,
387 &args.issue_id,
388 )
389 .await
390 {
391 Ok(id) => id,
392 Err(error) => return error,
393 };
394 let query = ListCommentQuery {
395 parent_id: args.parent_id,
396 limit: args.limit,
397 offset: args.offset,
398 sort: args.sort,
399 };
400 match services
401 .issues
402 .list_comments(
403 principal.uid,
404 args.space.trim(),
405 args.board_id,
406 issue_id,
407 query,
408 )
409 .await
410 {
411 Ok(result) => tool_ok(&result),
412 Err(error) => tool_error(error),
413 }
414}
415
416pub(crate) async fn comment_create(
417 services: &McpServices,
418 principal: &Principal,
419 args: args::IssueCommentCreateArgs,
420) -> CallToolResult {
421 if !principal.can_write() {
422 return write_denied();
423 }
424 let issue_id = match resolve_issue(
425 services,
426 principal,
427 &args.space,
428 args.board_id,
429 &args.issue_id,
430 )
431 .await
432 {
433 Ok(id) => id,
434 Err(error) => return error,
435 };
436 let cmd = CreateCommentCmd {
437 content: args.content,
438 parent_id: args.parent_id,
439 };
440 match services
441 .issues
442 .create_comment(
443 principal.uid,
444 args.space.trim(),
445 args.board_id,
446 issue_id,
447 cmd,
448 )
449 .await
450 {
451 Ok(result) => tool_ok(&result),
452 Err(error) => tool_error(error),
453 }
454}
455
456pub(crate) async fn comment_update(
457 services: &McpServices,
458 principal: &Principal,
459 args: args::IssueCommentUpdateArgs,
460) -> CallToolResult {
461 if !principal.can_write() {
462 return write_denied();
463 }
464 let issue_id = match resolve_issue(
465 services,
466 principal,
467 &args.space,
468 args.board_id,
469 &args.issue_id,
470 )
471 .await
472 {
473 Ok(id) => id,
474 Err(error) => return error,
475 };
476 let cmd = UpdateCommentCmd {
477 content: args.content,
478 };
479 match services
480 .issues
481 .update_comment(
482 principal.uid,
483 args.space.trim(),
484 args.board_id,
485 issue_id,
486 args.comment_id,
487 cmd,
488 )
489 .await
490 {
491 Ok(result) => tool_ok(&result),
492 Err(error) => tool_error(error),
493 }
494}
495
496pub(crate) async fn comment_delete(
497 services: &McpServices,
498 principal: &Principal,
499 args: args::IssueCommentRefArgs,
500) -> CallToolResult {
501 if !principal.can_write() {
502 return write_denied();
503 }
504 let issue_id = match resolve_issue(
505 services,
506 principal,
507 &args.space,
508 args.board_id,
509 &args.issue_id,
510 )
511 .await
512 {
513 Ok(id) => id,
514 Err(error) => return error,
515 };
516 match services
517 .issues
518 .delete_comment(
519 principal.uid,
520 args.space.trim(),
521 args.board_id,
522 issue_id,
523 args.comment_id,
524 )
525 .await
526 {
527 Ok(()) => tool_done(),
528 Err(error) => tool_error(error),
529 }
530}
531
532pub(crate) async fn label_list(
533 services: &McpServices,
534 principal: &Principal,
535 args: args::IssueLabelListArgs,
536) -> CallToolResult {
537 let query = ListLabelQuery {
538 q: args.q,
539 is_archived: Some(false),
540 limit: args.limit,
541 offset: args.offset,
542 };
543 match services
544 .issues
545 .list_labels(principal.uid, args.space.trim(), args.board_id, query)
546 .await
547 {
548 Ok(result) => tool_ok(&result),
549 Err(error) => tool_error(error),
550 }
551}
552
553pub(crate) async fn label_create(
554 services: &McpServices,
555 principal: &Principal,
556 args: args::IssueLabelCreateArgs,
557) -> CallToolResult {
558 if !principal.can_write() {
559 return write_denied();
560 }
561 let cmd = CreateLabelCmd {
562 name: args.name,
563 color: args.color,
564 exclusive: args.exclusive,
565 exclusive_order: None,
566 description: args.description,
567 };
568 match services
569 .issues
570 .create_label(principal.uid, args.space.trim(), args.board_id, cmd)
571 .await
572 {
573 Ok(result) => tool_ok(&result),
574 Err(error) => tool_error(error),
575 }
576}
577
578pub(crate) async fn label_update(
579 services: &McpServices,
580 principal: &Principal,
581 args: args::IssueLabelUpdateArgs,
582) -> CallToolResult {
583 if !principal.can_write() {
584 return write_denied();
585 }
586 let cmd = UpdateLabelCmd {
587 name: args.name,
588 color: args.color,
589 exclusive: args.exclusive,
590 exclusive_order: None,
591 description: args.description.map(Some),
592 is_archived: args.is_archived,
593 };
594 match services
595 .issues
596 .update_label(
597 principal.uid,
598 args.space.trim(),
599 args.board_id,
600 args.label_id,
601 cmd,
602 )
603 .await
604 {
605 Ok(result) => tool_ok(&result),
606 Err(error) => tool_error(error),
607 }
608}
609
610pub(crate) async fn label_delete(
611 services: &McpServices,
612 principal: &Principal,
613 args: args::IssueLabelRefArgs,
614) -> CallToolResult {
615 if !principal.can_write() {
616 return write_denied();
617 }
618 match services
619 .issues
620 .delete_label(
621 principal.uid,
622 args.space.trim(),
623 args.board_id,
624 args.label_id,
625 )
626 .await
627 {
628 Ok(()) => tool_done(),
629 Err(error) => tool_error(error),
630 }
631}
632
633pub(crate) async fn label_bind(
634 services: &McpServices,
635 principal: &Principal,
636 args: args::IssueLabelBindArgs,
637) -> CallToolResult {
638 if !principal.can_write() {
639 return write_denied();
640 }
641 let issue_id = match resolve_issue(
642 services,
643 principal,
644 &args.space,
645 args.board_id,
646 &args.issue_id,
647 )
648 .await
649 {
650 Ok(id) => id,
651 Err(error) => return error,
652 };
653 match services
654 .issues
655 .bind_label(
656 principal.uid,
657 args.space.trim(),
658 args.board_id,
659 issue_id,
660 args.label_id,
661 )
662 .await
663 {
664 Ok(()) => tool_done(),
665 Err(error) => tool_error(error),
666 }
667}
668
669pub(crate) async fn label_unbind(
670 services: &McpServices,
671 principal: &Principal,
672 args: args::IssueLabelBindArgs,
673) -> CallToolResult {
674 if !principal.can_write() {
675 return write_denied();
676 }
677 let issue_id = match resolve_issue(
678 services,
679 principal,
680 &args.space,
681 args.board_id,
682 &args.issue_id,
683 )
684 .await
685 {
686 Ok(id) => id,
687 Err(error) => return error,
688 };
689 match services
690 .issues
691 .unbind_label(
692 principal.uid,
693 args.space.trim(),
694 args.board_id,
695 issue_id,
696 args.label_id,
697 )
698 .await
699 {
700 Ok(()) => tool_done(),
701 Err(error) => tool_error(error),
702 }
703}
704
705pub(crate) async fn milestone_list(
706 services: &McpServices,
707 principal: &Principal,
708 args: args::IssueMilestoneListArgs,
709) -> CallToolResult {
710 let query = ListMilestoneQuery {
711 is_closed: args.is_closed,
712 q: args.q,
713 limit: args.limit,
714 offset: args.offset,
715 };
716 match services
717 .issues
718 .list_milestones(principal.uid, args.space.trim(), args.board_id, query)
719 .await
720 {
721 Ok(result) => tool_ok(&result),
722 Err(error) => tool_error(error),
723 }
724}
725
726pub(crate) async fn milestone_create(
727 services: &McpServices,
728 principal: &Principal,
729 args: args::IssueMilestoneCreateArgs,
730) -> CallToolResult {
731 if !principal.can_write() {
732 return write_denied();
733 }
734 let due_at = match args.due_at.as_deref() {
735 Some(raw) => match parse_time(raw) {
736 Ok(time) => Some(time),
737 Err(error) => return error,
738 },
739 None => None,
740 };
741 let cmd = CreateMilestoneCmd {
742 name: args.name,
743 description: args.description,
744 due_at,
745 };
746 match services
747 .issues
748 .create_milestone(principal.uid, args.space.trim(), args.board_id, cmd)
749 .await
750 {
751 Ok(result) => tool_ok(&result),
752 Err(error) => tool_error(error),
753 }
754}
755
756pub(crate) async fn milestone_update(
757 services: &McpServices,
758 principal: &Principal,
759 args: args::IssueMilestoneUpdateArgs,
760) -> CallToolResult {
761 if !principal.can_write() {
762 return write_denied();
763 }
764 let due_at = match args.due_at.as_deref() {
765 Some(raw) => match parse_time(raw) {
766 Ok(time) => Some(Some(time)),
767 Err(error) => return error,
768 },
769 None => None,
770 };
771 let cmd = UpdateMilestoneCmd {
772 name: args.name,
773 description: args.description.map(Some),
774 due_at,
775 is_closed: args.is_closed,
776 };
777 match services
778 .issues
779 .update_milestone(
780 principal.uid,
781 args.space.trim(),
782 args.board_id,
783 args.milestone_id,
784 cmd,
785 )
786 .await
787 {
788 Ok(result) => tool_ok(&result),
789 Err(error) => tool_error(error),
790 }
791}
792
793pub(crate) async fn milestone_delete(
794 services: &McpServices,
795 principal: &Principal,
796 args: args::IssueMilestoneRefArgs,
797) -> CallToolResult {
798 if !principal.can_write() {
799 return write_denied();
800 }
801 match services
802 .issues
803 .delete_milestone(
804 principal.uid,
805 args.space.trim(),
806 args.board_id,
807 args.milestone_id,
808 )
809 .await
810 {
811 Ok(()) => tool_done(),
812 Err(error) => tool_error(error),
813 }
814}
815
816pub(crate) async fn member_list(
817 services: &McpServices,
818 principal: &Principal,
819 args: args::IssueMemberListArgs,
820) -> CallToolResult {
821 let issue_id = match resolve_issue(
822 services,
823 principal,
824 &args.space,
825 args.board_id,
826 &args.issue_id,
827 )
828 .await
829 {
830 Ok(id) => id,
831 Err(error) => return error,
832 };
833 let query = ListIssueMemberQuery {
834 kind: None,
835 limit: args.limit,
836 offset: args.offset,
837 };
838 match services
839 .issues
840 .list_members(
841 principal.uid,
842 args.space.trim(),
843 args.board_id,
844 issue_id,
845 query,
846 )
847 .await
848 {
849 Ok(result) => tool_ok(&result),
850 Err(error) => tool_error(error),
851 }
852}
853
854pub(crate) async fn member_create(
855 services: &McpServices,
856 principal: &Principal,
857 args: args::IssueMemberCreateArgs,
858) -> CallToolResult {
859 if !principal.can_write() {
860 return write_denied();
861 }
862 let issue_id = match resolve_issue(
863 services,
864 principal,
865 &args.space,
866 args.board_id,
867 &args.issue_id,
868 )
869 .await
870 {
871 Ok(id) => id,
872 Err(error) => return error,
873 };
874 let kind = match parse_enum::<IssueMemberKind>(&args.kind) {
875 Ok(kind) => kind,
876 Err(error) => return error,
877 };
878 let cmd = CreateIssueMemberCmd {
879 user_id: args.user_id,
880 kind,
881 };
882 match services
883 .issues
884 .create_member(
885 principal.uid,
886 args.space.trim(),
887 args.board_id,
888 issue_id,
889 cmd,
890 )
891 .await
892 {
893 Ok(result) => tool_ok(&result),
894 Err(error) => tool_error(error),
895 }
896}
897
898pub(crate) async fn member_delete(
899 services: &McpServices,
900 principal: &Principal,
901 args: args::IssueMemberRefArgs,
902) -> CallToolResult {
903 if !principal.can_write() {
904 return write_denied();
905 }
906 let issue_id = match resolve_issue(
907 services,
908 principal,
909 &args.space,
910 args.board_id,
911 &args.issue_id,
912 )
913 .await
914 {
915 Ok(id) => id,
916 Err(error) => return error,
917 };
918 match services
919 .issues
920 .delete_member(
921 principal.uid,
922 args.space.trim(),
923 args.board_id,
924 issue_id,
925 args.member_id,
926 )
927 .await
928 {
929 Ok(()) => tool_done(),
930 Err(error) => tool_error(error),
931 }
932}
933
934pub(crate) async fn pin_list(
935 services: &McpServices,
936 principal: &Principal,
937 args: args::IssuePinListArgs,
938) -> CallToolResult {
939 match services
940 .issues
941 .list_pins(principal.uid, args.space.trim(), args.board_id)
942 .await
943 {
944 Ok(result) => tool_ok(&result),
945 Err(error) => tool_error(error),
946 }
947}
948
949pub(crate) async fn pin_create(
950 services: &McpServices,
951 principal: &Principal,
952 args: args::IssuePinCreateArgs,
953) -> CallToolResult {
954 if !principal.can_write() {
955 return write_denied();
956 }
957 let issue_id = match resolve_issue(
958 services,
959 principal,
960 &args.space,
961 args.board_id,
962 &args.issue_id,
963 )
964 .await
965 {
966 Ok(id) => id,
967 Err(error) => return error,
968 };
969 let cmd = CreatePinCmd {
970 issue_id,
971 pin_order: args.pin_order.unwrap_or(0),
972 };
973 match services
974 .issues
975 .create_pin(principal.uid, args.space.trim(), args.board_id, cmd)
976 .await
977 {
978 Ok(result) => tool_ok(&result),
979 Err(error) => tool_error(error),
980 }
981}
982
983pub(crate) async fn pin_update(
984 services: &McpServices,
985 principal: &Principal,
986 args: args::IssuePinUpdateArgs,
987) -> CallToolResult {
988 if !principal.can_write() {
989 return write_denied();
990 }
991 let cmd = UpdatePinCmd {
992 pin_order: args.pin_order,
993 };
994 match services
995 .issues
996 .update_pin(
997 principal.uid,
998 args.space.trim(),
999 args.board_id,
1000 args.pin_id,
1001 cmd,
1002 )
1003 .await
1004 {
1005 Ok(result) => tool_ok(&result),
1006 Err(error) => tool_error(error),
1007 }
1008}
1009
1010pub(crate) async fn pin_delete(
1011 services: &McpServices,
1012 principal: &Principal,
1013 args: args::IssuePinRefArgs,
1014) -> CallToolResult {
1015 if !principal.can_write() {
1016 return write_denied();
1017 }
1018 match services
1019 .issues
1020 .delete_pin(principal.uid, args.space.trim(), args.board_id, args.pin_id)
1021 .await
1022 {
1023 Ok(()) => tool_done(),
1024 Err(error) => tool_error(error),
1025 }
1026}
1027
1028pub(crate) async fn dependency_list(
1029 services: &McpServices,
1030 principal: &Principal,
1031 args: args::IssueDependencyListArgs,
1032) -> CallToolResult {
1033 let issue_id = match resolve_issue(
1034 services,
1035 principal,
1036 &args.space,
1037 args.board_id,
1038 &args.issue_id,
1039 )
1040 .await
1041 {
1042 Ok(id) => id,
1043 Err(error) => return error,
1044 };
1045 match services
1046 .issues
1047 .list_dependencies(principal.uid, args.space.trim(), args.board_id, issue_id)
1048 .await
1049 {
1050 Ok(result) => tool_ok(&result),
1051 Err(error) => tool_error(error),
1052 }
1053}
1054
1055pub(crate) async fn dependency_create(
1056 services: &McpServices,
1057 principal: &Principal,
1058 args: args::IssueDependencyCreateArgs,
1059) -> CallToolResult {
1060 if !principal.can_write() {
1061 return write_denied();
1062 }
1063 let issue_id = match resolve_issue(
1064 services,
1065 principal,
1066 &args.space,
1067 args.board_id,
1068 &args.issue_id,
1069 )
1070 .await
1071 {
1072 Ok(id) => id,
1073 Err(error) => return error,
1074 };
1075 let cmd = CreateDependencyCmd {
1076 dependency_id: args.dependency_id,
1077 };
1078 match services
1079 .issues
1080 .create_dependency(
1081 principal.uid,
1082 args.space.trim(),
1083 args.board_id,
1084 issue_id,
1085 cmd,
1086 )
1087 .await
1088 {
1089 Ok(result) => tool_ok(&result),
1090 Err(error) => tool_error(error),
1091 }
1092}
1093
1094pub(crate) async fn dependency_delete(
1095 services: &McpServices,
1096 principal: &Principal,
1097 args: args::IssueDependencyRefArgs,
1098) -> CallToolResult {
1099 if !principal.can_write() {
1100 return write_denied();
1101 }
1102 let issue_id = match resolve_issue(
1103 services,
1104 principal,
1105 &args.space,
1106 args.board_id,
1107 &args.issue_id,
1108 )
1109 .await
1110 {
1111 Ok(id) => id,
1112 Err(error) => return error,
1113 };
1114 match services
1115 .issues
1116 .delete_dependency(
1117 principal.uid,
1118 args.space.trim(),
1119 args.board_id,
1120 issue_id,
1121 args.dep_id,
1122 )
1123 .await
1124 {
1125 Ok(()) => tool_done(),
1126 Err(error) => tool_error(error),
1127 }
1128}
Deleted lib/mcp-api/src/tools/me.rs
@@ -1,144 +0,0 @@
1use rmcp::model::CallToolResult;
2use serde_json::json;
3
4use crate::auth::Principal;
5use crate::error::{tool_error, tool_ok};
6use crate::services::McpServices;
7use crate::tools::args;
8
9pub(crate) async fn get(services: &McpServices, principal: &Principal) -> CallToolResult {
10 let temporary = principal.temporary_key.as_ref().map(|temporary| {
11 json!({
12 "token": &temporary.token,
13 "token_id": temporary.token_id,
14 "space": principal.bound_space.as_ref().map(|(_, name)| name.as_str()),
15 "space_id": principal.bound_space.as_ref().map(|(id, _)| *id),
16 "expires_at": &temporary.expires_at,
17 "issued_by": &temporary.issued_by,
18 })
19 });
20 match services.auth.me(principal.uid).await {
21 Ok((me, profile)) => tool_ok(&json!({
22 "user": me,
23 "profile": profile,
24 "key": {
25 "scope": principal.scope,
26 "full_access": principal.full_access,
27 "source": match principal.source {
28 crate::auth::PrincipalSource::UserKey => "user_key",
29 crate::auth::PrincipalSource::InternalKey => "internal_key",
30 crate::auth::PrincipalSource::SpaceToken => "space_token",
31 },
32 "temporary": temporary,
33 }
34 })),
35 Err(error) => tool_error(error),
36 }
37}
38
39pub(crate) async fn space_permissions(
40 services: &McpServices,
41 principal: &Principal,
42 args: args::SpaceNameArgs,
43) -> CallToolResult {
44 let space = match space::share::lookup::lookup_space_by_name(&services.space, args.space.trim())
45 .await
46 {
47 Ok(space) => space,
48 Err(error) => return tool_error(error),
49 };
50 match space::share::authz::effective_permissions(
51 &services.state,
52 &space,
53 principal.uid,
54 )
55 .await
56 {
57 Ok(permissions) => tool_ok(&json!({
58 "space": space.lower_name,
59 "space_id": space.id,
60 "permissions": permissions.into_vec(),
61 })),
62 Err(error) => tool_error(error),
63 }
64}
65
66pub(crate) async fn repo_permissions(
67 services: &McpServices,
68 principal: &Principal,
69 args: args::RepoRefArgs,
70) -> CallToolResult {
71 let ctx = match services
72 .git
73 .context(args.space.trim().to_string(), args.repo.trim().to_string())
74 .await
75 {
76 Ok(ctx) => ctx,
77 Err(error) => return tool_error(error),
78 };
79 let read =
80 git::share::access::authorize_repo_access(&services.git, principal.uid, &ctx, false, false)
81 .await
82 .is_ok();
83 let write =
84 git::share::access::authorize_repo_access(&services.git, principal.uid, &ctx, true, false)
85 .await
86 .is_ok();
87 let admin =
88 git::share::access::authorize_repo_access(&services.git, principal.uid, &ctx, true, true)
89 .await
90 .is_ok();
91 tool_ok(&json!({
92 "space": args.space.trim(),
93 "repo": ctx.model.name,
94 "repo_id": ctx.model.id,
95 "is_private": ctx.model.is_private,
96 "read": read,
97 "write": write,
98 "admin": admin,
99 }))
100}
101
102pub(crate) async fn user_get(
103 services: &McpServices,
104 principal: &Principal,
105 args: args::UserRefArgs,
106) -> CallToolResult {
107 let result = if let Some(uid) = args.user_id {
108 services.auth.public_user(principal.uid, uid).await
109 } else if let Some(username) = args.username.as_deref() {
110 services
111 .auth
112 .public_user_by_name(principal.uid, username)
113 .await
114 } else {
115 return crate::error::tool_message("user_id 与 username 至少提供一个");
116 };
117 match result {
118 Ok(user) => tool_ok(&user),
119 Err(error) => tool_error(error),
120 }
121}
122
123pub(crate) async fn users_get(
124 services: &McpServices,
125 principal: &Principal,
126 args: args::UserIdsArgs,
127) -> CallToolResult {
128 match services.auth.public_users(principal.uid, args.ids).await {
129 Ok(users) => tool_ok(&users),
130 Err(error) => tool_error(error),
131 }
132}
133
134pub(crate) async fn my_spaces(services: &McpServices, principal: &Principal) -> CallToolResult {
135 let query = export::pagination::PageQuery {
136 limit: None,
137 offset: None,
138 sort: None,
139 };
140 match services.user.my_spaces(principal.uid, query).await {
141 Ok(spaces) => tool_ok(&spaces),
142 Err(error) => tool_error(error),
143 }
144}
Deleted lib/mcp-api/src/tools/mod.rs
@@ -1,1470 +0,0 @@
1pub(crate) mod args;
2pub(crate) mod helpers;
3pub(crate) mod issue;
4pub(crate) mod me;
5pub(crate) mod pull;
6pub(crate) mod repo;
7pub(crate) mod space;
8
9use rmcp::ErrorData;
10use rmcp::handler::server::wrapper::Parameters;
11use rmcp::model::CallToolResult;
12use rmcp::service::RequestContext;
13use rmcp::{RoleServer, tool, tool_router};
14
15use crate::McpServer;
16
17#[tool_router(vis = "pub(crate)")]
18impl McpServer {
19 #[tool(
20 description = "返回当前访问密钥对应的用户身份、密钥读写范围(scope);使用临时空间密钥时同时返回该临时密钥及其有效期。",
21 annotations(read_only_hint = true)
22 )]
23 async fn me_get(&self, ctx: RequestContext<RoleServer>) -> Result<CallToolResult, ErrorData> {
24 let principal = self.principal(&ctx)?;
25 Ok(me::get(&self.services, &principal).await)
26 }
27
28 #[tool(
29 description = "查询当前访问密钥的用户在指定 space 中的全部有效权限(view_space/read/message/manage_*)。",
30 annotations(read_only_hint = true)
31 )]
32 async fn me_space_permissions(
33 &self,
34 Parameters(args): Parameters<args::SpaceNameArgs>,
35 ctx: RequestContext<RoleServer>,
36 ) -> Result<CallToolResult, ErrorData> {
37 let principal = self.principal(&ctx)?;
38 Ok(me::space_permissions(&self.services, &principal, args).await)
39 }
40
41 #[tool(
42 description = "查询当前访问密钥的用户在指定仓库上的 read/write/admin 权限。",
43 annotations(read_only_hint = true)
44 )]
45 async fn me_repo_permissions(
46 &self,
47 Parameters(args): Parameters<args::RepoRefArgs>,
48 ctx: RequestContext<RoleServer>,
49 ) -> Result<CallToolResult, ErrorData> {
50 let principal = self.principal(&ctx)?;
51 Ok(me::repo_permissions(&self.services, &principal, args).await)
52 }
53
54 #[tool(
55 description = "返回当前访问密钥的用户拥有的 space 列表。",
56 annotations(read_only_hint = true)
57 )]
58 async fn me_spaces(
59 &self,
60 ctx: RequestContext<RoleServer>,
61 ) -> Result<CallToolResult, ErrorData> {
62 let principal = self.principal(&ctx)?;
63 Ok(me::my_spaces(&self.services, &principal).await)
64 }
65
66 #[tool(
67 description = "按用户名或 UID 查询平台用户的公开资料。",
68 annotations(read_only_hint = true)
69 )]
70 async fn user_get(
71 &self,
72 Parameters(args): Parameters<args::UserRefArgs>,
73 ctx: RequestContext<RoleServer>,
74 ) -> Result<CallToolResult, ErrorData> {
75 let principal = self.principal(&ctx)?;
76 Ok(me::user_get(&self.services, &principal, args).await)
77 }
78
79 #[tool(
80 description = "按一组 UID 批量查询平台用户公开资料(最多 100 个)。",
81 annotations(read_only_hint = true)
82 )]
83 async fn users_get(
84 &self,
85 Parameters(args): Parameters<args::UserIdsArgs>,
86 ctx: RequestContext<RoleServer>,
87 ) -> Result<CallToolResult, ErrorData> {
88 let principal = self.principal(&ctx)?;
89 Ok(me::users_get(&self.services, &principal, args).await)
90 }
91
92 #[tool(
93 description = "列出当前用户拥有的 space。",
94 annotations(read_only_hint = true)
95 )]
96 async fn space_list(
97 &self,
98 Parameters(args): Parameters<args::SpaceListArgs>,
99 ctx: RequestContext<RoleServer>,
100 ) -> Result<CallToolResult, ErrorData> {
101 let principal = self.principal(&ctx)?;
102 Ok(space::list(&self.services, &principal, args).await)
103 }
104
105 #[tool(
106 description = "浏览公开 space(未私有的可加入空间)。",
107 annotations(read_only_hint = true)
108 )]
109 async fn space_explore(
110 &self,
111 Parameters(args): Parameters<args::SpaceExploreArgs>,
112 ctx: RequestContext<RoleServer>,
113 ) -> Result<CallToolResult, ErrorData> {
114 let principal = self.principal(&ctx)?;
115 Ok(space::explore(&self.services, &principal, args).await)
116 }
117
118 #[tool(
119 description = "按名称获取 space 详情(私有时需要成员权限)。",
120 annotations(read_only_hint = true)
121 )]
122 async fn space_get(
123 &self,
124 Parameters(args): Parameters<args::SpaceNameArgs>,
125 ctx: RequestContext<RoleServer>,
126 ) -> Result<CallToolResult, ErrorData> {
127 let principal = self.principal(&ctx)?;
128 Ok(space::get(&self.services, &principal, args).await)
129 }
130
131 #[tool(description = "创建 space(当前用户成为所有者)。")]
132 async fn space_create(
133 &self,
134 Parameters(args): Parameters<args::SpaceCreateArgs>,
135 ctx: RequestContext<RoleServer>,
136 ) -> Result<CallToolResult, ErrorData> {
137 let principal = self.principal(&ctx)?;
138 Ok(space::create(&self.services, &principal, args).await)
139 }
140
141 #[tool(description = "更新 space 设置(仅所有者)。")]
142 async fn space_update(
143 &self,
144 Parameters(args): Parameters<args::SpaceUpdateArgs>,
145 ctx: RequestContext<RoleServer>,
146 ) -> Result<CallToolResult, ErrorData> {
147 let principal = self.principal(&ctx)?;
148 Ok(space::update(&self.services, &principal, args).await)
149 }
150
151 #[tool(
152 description = "停用(软删除)space(仅所有者)。",
153 annotations(destructive_hint = true)
154 )]
155 async fn space_delete(
156 &self,
157 Parameters(args): Parameters<args::SpaceNameArgs>,
158 ctx: RequestContext<RoleServer>,
159 ) -> Result<CallToolResult, ErrorData> {
160 let principal = self.principal(&ctx)?;
161 Ok(space::delete(&self.services, &principal, args).await)
162 }
163
164 #[tool(description = "列出 space 成员。", annotations(read_only_hint = true))]
165 async fn space_member_list(
166 &self,
167 Parameters(args): Parameters<args::SpaceMemberListArgs>,
168 ctx: RequestContext<RoleServer>,
169 ) -> Result<CallToolResult, ErrorData> {
170 let principal = self.principal(&ctx)?;
171 Ok(space::member_list(&self.services, &principal, args).await)
172 }
173
174 #[tool(description = "添加 space 成员(需要 manage_members 权限)。")]
175 async fn space_member_add(
176 &self,
177 Parameters(args): Parameters<args::SpaceMemberAddArgs>,
178 ctx: RequestContext<RoleServer>,
179 ) -> Result<CallToolResult, ErrorData> {
180 let principal = self.principal(&ctx)?;
181 Ok(space::member_add(&self.services, &principal, args).await)
182 }
183
184 #[tool(description = "更新 space 成员属性(需要 manage_members 权限)。")]
185 async fn space_member_update(
186 &self,
187 Parameters(args): Parameters<args::SpaceMemberUpdateArgs>,
188 ctx: RequestContext<RoleServer>,
189 ) -> Result<CallToolResult, ErrorData> {
190 let principal = self.principal(&ctx)?;
191 Ok(space::member_update(&self.services, &principal, args).await)
192 }
193
194 #[tool(
195 description = "移除 space 成员(需要 manage_members 权限)。",
196 annotations(destructive_hint = true)
197 )]
198 async fn space_member_remove(
199 &self,
200 Parameters(args): Parameters<args::SpaceMemberRefArgs>,
201 ctx: RequestContext<RoleServer>,
202 ) -> Result<CallToolResult, ErrorData> {
203 let principal = self.principal(&ctx)?;
204 Ok(space::member_remove(&self.services, &principal, args).await)
205 }
206
207 #[tool(description = "主动退出 space(所有者不可退出)。")]
208 async fn space_member_leave(
209 &self,
210 Parameters(args): Parameters<args::SpaceNameArgs>,
211 ctx: RequestContext<RoleServer>,
212 ) -> Result<CallToolResult, ErrorData> {
213 let principal = self.principal(&ctx)?;
214 Ok(space::member_leave(&self.services, &principal, args).await)
215 }
216
217 #[tool(
218 description = "列出 space 的角色组(需要 manage_roles 权限)。",
219 annotations(read_only_hint = true)
220 )]
221 async fn space_group_list(
222 &self,
223 Parameters(args): Parameters<args::SpaceGroupListArgs>,
224 ctx: RequestContext<RoleServer>,
225 ) -> Result<CallToolResult, ErrorData> {
226 let principal = self.principal(&ctx)?;
227 Ok(space::group_list(&self.services, &principal, args).await)
228 }
229
230 #[tool(description = "创建 space 角色组(需要 manage_roles 权限)。")]
231 async fn space_group_create(
232 &self,
233 Parameters(args): Parameters<args::SpaceGroupCreateArgs>,
234 ctx: RequestContext<RoleServer>,
235 ) -> Result<CallToolResult, ErrorData> {
236 let principal = self.principal(&ctx)?;
237 Ok(space::group_create(&self.services, &principal, args).await)
238 }
239
240 #[tool(description = "更新 space 角色组(需要 manage_roles 权限)。")]
241 async fn space_group_update(
242 &self,
243 Parameters(args): Parameters<args::SpaceGroupUpdateArgs>,
244 ctx: RequestContext<RoleServer>,
245 ) -> Result<CallToolResult, ErrorData> {
246 let principal = self.principal(&ctx)?;
247 Ok(space::group_update(&self.services, &principal, args).await)
248 }
249
250 #[tool(
251 description = "删除 space 角色组(需要 manage_roles 权限)。",
252 annotations(destructive_hint = true)
253 )]
254 async fn space_group_delete(
255 &self,
256 Parameters(args): Parameters<args::SpaceGroupRefArgs>,
257 ctx: RequestContext<RoleServer>,
258 ) -> Result<CallToolResult, ErrorData> {
259 let principal = self.principal(&ctx)?;
260 Ok(space::group_delete(&self.services, &principal, args).await)
261 }
262
263 #[tool(description = "把成员加入角色组(需要 manage_roles 权限)。")]
264 async fn space_group_assign(
265 &self,
266 Parameters(args): Parameters<args::SpaceGroupAssignArgs>,
267 ctx: RequestContext<RoleServer>,
268 ) -> Result<CallToolResult, ErrorData> {
269 let principal = self.principal(&ctx)?;
270 Ok(space::group_assign(&self.services, &principal, args).await)
271 }
272
273 #[tool(description = "把成员移出角色组(需要 manage_roles 权限)。")]
274 async fn space_group_unassign(
275 &self,
276 Parameters(args): Parameters<args::SpaceGroupAssignArgs>,
277 ctx: RequestContext<RoleServer>,
278 ) -> Result<CallToolResult, ErrorData> {
279 let principal = self.principal(&ctx)?;
280 Ok(space::group_unassign(&self.services, &principal, args).await)
281 }
282
283 #[tool(
284 description = "获取 space 的目录树(可见性受目录覆盖规则限制)。",
285 annotations(read_only_hint = true)
286 )]
287 async fn space_dir_tree(
288 &self,
289 Parameters(args): Parameters<args::SpaceDirTreeArgs>,
290 ctx: RequestContext<RoleServer>,
291 ) -> Result<CallToolResult, ErrorData> {
292 let principal = self.principal(&ctx)?;
293 Ok(space::dir_tree(&self.services, &principal, args).await)
294 }
295
296 #[tool(description = "创建目录节点(需要 manage_channels 权限)。")]
297 async fn space_dir_create(
298 &self,
299 Parameters(args): Parameters<args::SpaceDirCreateArgs>,
300 ctx: RequestContext<RoleServer>,
301 ) -> Result<CallToolResult, ErrorData> {
302 let principal = self.principal(&ctx)?;
303 Ok(space::dir_create(&self.services, &principal, args).await)
304 }
305
306 #[tool(description = "重命名目录节点(需要 manage_channels 权限)。")]
307 async fn space_dir_update(
308 &self,
309 Parameters(args): Parameters<args::SpaceDirUpdateArgs>,
310 ctx: RequestContext<RoleServer>,
311 ) -> Result<CallToolResult, ErrorData> {
312 let principal = self.principal(&ctx)?;
313 Ok(space::dir_update(&self.services, &principal, args).await)
314 }
315
316 #[tool(description = "移动目录节点(需要 manage_channels 权限)。")]
317 async fn space_dir_move(
318 &self,
319 Parameters(args): Parameters<args::SpaceDirMoveArgs>,
320 ctx: RequestContext<RoleServer>,
321 ) -> Result<CallToolResult, ErrorData> {
322 let principal = self.principal(&ctx)?;
323 Ok(space::dir_move(&self.services, &principal, args).await)
324 }
325
326 #[tool(
327 description = "列出 space 邀请(需要 manage_invites 权限)。",
328 annotations(read_only_hint = true)
329 )]
330 async fn space_invite_list(
331 &self,
332 Parameters(args): Parameters<args::SpaceInviteListArgs>,
333 ctx: RequestContext<RoleServer>,
334 ) -> Result<CallToolResult, ErrorData> {
335 let principal = self.principal(&ctx)?;
336 Ok(space::invite_list(&self.services, &principal, args).await)
337 }
338
339 #[tool(description = "创建 space 邀请(需要 manage_invites 权限)。")]
340 async fn space_invite_create(
341 &self,
342 Parameters(args): Parameters<args::SpaceInviteCreateArgs>,
343 ctx: RequestContext<RoleServer>,
344 ) -> Result<CallToolResult, ErrorData> {
345 let principal = self.principal(&ctx)?;
346 Ok(space::invite_create(&self.services, &principal, args).await)
347 }
348
349 #[tool(
350 description = "撤销 space 邀请(需要 manage_invites 权限)。",
351 annotations(destructive_hint = true)
352 )]
353 async fn space_invite_revoke(
354 &self,
355 Parameters(args): Parameters<args::SpaceInviteRefArgs>,
356 ctx: RequestContext<RoleServer>,
357 ) -> Result<CallToolResult, ErrorData> {
358 let principal = self.principal(&ctx)?;
359 Ok(space::invite_revoke(&self.services, &principal, args).await)
360 }
361
362 #[tool(
363 description = "列出 space 加入申请(需要 manage_members 权限)。",
364 annotations(read_only_hint = true)
365 )]
366 async fn space_join_request_list(
367 &self,
368 Parameters(args): Parameters<args::SpaceJoinRequestListArgs>,
369 ctx: RequestContext<RoleServer>,
370 ) -> Result<CallToolResult, ErrorData> {
371 let principal = self.principal(&ctx)?;
372 Ok(space::join_request_list(&self.services, &principal, args).await)
373 }
374
375 #[tool(description = "提交加入 space 的申请。")]
376 async fn space_join_request_create(
377 &self,
378 Parameters(args): Parameters<args::SpaceJoinRequestCreateArgs>,
379 ctx: RequestContext<RoleServer>,
380 ) -> Result<CallToolResult, ErrorData> {
381 let principal = self.principal(&ctx)?;
382 Ok(space::join_request_create(&self.services, &principal, args).await)
383 }
384
385 #[tool(description = "撤回自己的加入申请。")]
386 async fn space_join_request_withdraw(
387 &self,
388 Parameters(args): Parameters<args::SpaceJoinRequestRefArgs>,
389 ctx: RequestContext<RoleServer>,
390 ) -> Result<CallToolResult, ErrorData> {
391 let principal = self.principal(&ctx)?;
392 Ok(space::join_request_withdraw(&self.services, &principal, args).await)
393 }
394
395 #[tool(description = "通过加入申请(需要 manage_members 权限)。")]
396 async fn space_join_request_approve(
397 &self,
398 Parameters(args): Parameters<args::SpaceJoinRequestRefArgs>,
399 ctx: RequestContext<RoleServer>,
400 ) -> Result<CallToolResult, ErrorData> {
401 let principal = self.principal(&ctx)?;
402 Ok(space::join_request_approve(&self.services, &principal, args).await)
403 }
404
405 #[tool(description = "拒绝加入申请(需要 manage_members 权限)。")]
406 async fn space_join_request_reject(
407 &self,
408 Parameters(args): Parameters<args::SpaceJoinRequestRefArgs>,
409 ctx: RequestContext<RoleServer>,
410 ) -> Result<CallToolResult, ErrorData> {
411 let principal = self.principal(&ctx)?;
412 Ok(space::join_request_reject(&self.services, &principal, args).await)
413 }
414
415 #[tool(
416 description = "列出 space 下的仓库(私有仓库仅协作者可见)。",
417 annotations(read_only_hint = true)
418 )]
419 async fn repo_list(
420 &self,
421 Parameters(args): Parameters<args::RepoListArgs>,
422 ctx: RequestContext<RoleServer>,
423 ) -> Result<CallToolResult, ErrorData> {
424 let principal = self.principal(&ctx)?;
425 Ok(repo::list(&self.services, &principal, args).await)
426 }
427
428 #[tool(
429 description = "获取仓库详情(需要读权限)。",
430 annotations(read_only_hint = true)
431 )]
432 async fn repo_get(
433 &self,
434 Parameters(args): Parameters<args::RepoRefArgs>,
435 ctx: RequestContext<RoleServer>,
436 ) -> Result<CallToolResult, ErrorData> {
437 let principal = self.principal(&ctx)?;
438 Ok(repo::get(&self.services, &principal, args).await)
439 }
440
441 #[tool(description = "在 space 下创建仓库(需要 space 所有者权限)。")]
442 async fn repo_create(
443 &self,
444 Parameters(args): Parameters<args::RepoCreateArgs>,
445 ctx: RequestContext<RoleServer>,
446 ) -> Result<CallToolResult, ErrorData> {
447 let principal = self.principal(&ctx)?;
448 Ok(repo::create(&self.services, &principal, args).await)
449 }
450
451 #[tool(description = "更新仓库设置(需要 space 所有者权限)。")]
452 async fn repo_update(
453 &self,
454 Parameters(args): Parameters<args::RepoUpdateArgs>,
455 ctx: RequestContext<RoleServer>,
456 ) -> Result<CallToolResult, ErrorData> {
457 let principal = self.principal(&ctx)?;
458 Ok(repo::update(&self.services, &principal, args).await)
459 }
460
461 #[tool(description = "归档或取消归档仓库(需要 space 所有者权限)。")]
462 async fn repo_set_archived(
463 &self,
464 Parameters(args): Parameters<args::RepoArchiveArgs>,
465 ctx: RequestContext<RoleServer>,
466 ) -> Result<CallToolResult, ErrorData> {
467 let principal = self.principal(&ctx)?;
468 Ok(repo::set_archived(&self.services, &principal, args).await)
469 }
470
471 #[tool(description = "列出仓库分支。", annotations(read_only_hint = true))]
472 async fn repo_branch_list(
473 &self,
474 Parameters(args): Parameters<args::RepoRefArgs>,
475 ctx: RequestContext<RoleServer>,
476 ) -> Result<CallToolResult, ErrorData> {
477 let principal = self.principal(&ctx)?;
478 Ok(repo::branch_list(&self.services, &principal, args).await)
479 }
480
481 #[tool(description = "获取单个分支信息。", annotations(read_only_hint = true))]
482 async fn repo_branch_get(
483 &self,
484 Parameters(args): Parameters<args::RepoBranchGetArgs>,
485 ctx: RequestContext<RoleServer>,
486 ) -> Result<CallToolResult, ErrorData> {
487 let principal = self.principal(&ctx)?;
488 Ok(repo::branch_get(&self.services, &principal, args).await)
489 }
490
491 #[tool(description = "创建分支(需要仓库写权限)。")]
492 async fn repo_branch_create(
493 &self,
494 Parameters(args): Parameters<args::RepoBranchCreateArgs>,
495 ctx: RequestContext<RoleServer>,
496 ) -> Result<CallToolResult, ErrorData> {
497 let principal = self.principal(&ctx)?;
498 Ok(repo::branch_create(&self.services, &principal, args).await)
499 }
500
501 #[tool(description = "重命名分支(需要仓库写权限)。")]
502 async fn repo_branch_rename(
503 &self,
504 Parameters(args): Parameters<args::RepoBranchRenameArgs>,
505 ctx: RequestContext<RoleServer>,
506 ) -> Result<CallToolResult, ErrorData> {
507 let principal = self.principal(&ctx)?;
508 Ok(repo::branch_rename(&self.services, &principal, args).await)
509 }
510
511 #[tool(
512 description = "删除分支(需要仓库写权限)。",
513 annotations(destructive_hint = true)
514 )]
515 async fn repo_branch_delete(
516 &self,
517 Parameters(args): Parameters<args::RepoBranchDeleteArgs>,
518 ctx: RequestContext<RoleServer>,
519 ) -> Result<CallToolResult, ErrorData> {
520 let principal = self.principal(&ctx)?;
521 Ok(repo::branch_delete(&self.services, &principal, args).await)
522 }
523
524 #[tool(description = "设置默认分支(需要仓库写权限)。")]
525 async fn repo_branch_set_default(
526 &self,
527 Parameters(args): Parameters<args::RepoBranchDefaultArgs>,
528 ctx: RequestContext<RoleServer>,
529 ) -> Result<CallToolResult, ErrorData> {
530 let principal = self.principal(&ctx)?;
531 Ok(repo::branch_set_default(&self.services, &principal, args).await)
532 }
533
534 #[tool(description = "列出仓库标签。", annotations(read_only_hint = true))]
535 async fn repo_tag_list(
536 &self,
537 Parameters(args): Parameters<args::RepoRefArgs>,
538 ctx: RequestContext<RoleServer>,
539 ) -> Result<CallToolResult, ErrorData> {
540 let principal = self.principal(&ctx)?;
541 Ok(repo::tag_list(&self.services, &principal, args).await)
542 }
543
544 #[tool(description = "获取单个标签。", annotations(read_only_hint = true))]
545 async fn repo_tag_get(
546 &self,
547 Parameters(args): Parameters<args::RepoTagGetArgs>,
548 ctx: RequestContext<RoleServer>,
549 ) -> Result<CallToolResult, ErrorData> {
550 let principal = self.principal(&ctx)?;
551 Ok(repo::tag_get(&self.services, &principal, args).await)
552 }
553
554 #[tool(description = "创建标签(需要仓库写权限)。")]
555 async fn repo_tag_create(
556 &self,
557 Parameters(args): Parameters<args::RepoTagCreateArgs>,
558 ctx: RequestContext<RoleServer>,
559 ) -> Result<CallToolResult, ErrorData> {
560 let principal = self.principal(&ctx)?;
561 Ok(repo::tag_create(&self.services, &principal, args).await)
562 }
563
564 #[tool(
565 description = "删除标签(需要仓库写权限)。",
566 annotations(destructive_hint = true)
567 )]
568 async fn repo_tag_delete(
569 &self,
570 Parameters(args): Parameters<args::RepoTagDeleteArgs>,
571 ctx: RequestContext<RoleServer>,
572 ) -> Result<CallToolResult, ErrorData> {
573 let principal = self.principal(&ctx)?;
574 Ok(repo::tag_delete(&self.services, &principal, args).await)
575 }
576
577 #[tool(
578 description = "列出提交历史(可按路径/数量过滤)。",
579 annotations(read_only_hint = true)
580 )]
581 async fn repo_commit_list(
582 &self,
583 Parameters(args): Parameters<args::RepoCommitListArgs>,
584 ctx: RequestContext<RoleServer>,
585 ) -> Result<CallToolResult, ErrorData> {
586 let principal = self.principal(&ctx)?;
587 Ok(repo::commit_list(&self.services, &principal, args).await)
588 }
589
590 #[tool(description = "获取单个提交详情。", annotations(read_only_hint = true))]
591 async fn repo_commit_get(
592 &self,
593 Parameters(args): Parameters<args::RepoCommitGetArgs>,
594 ctx: RequestContext<RoleServer>,
595 ) -> Result<CallToolResult, ErrorData> {
596 let principal = self.principal(&ctx)?;
597 Ok(repo::commit_get(&self.services, &principal, args).await)
598 }
599
600 #[tool(
601 description = "浏览仓库目录树(recurse=true 递归列出全部条目)。",
602 annotations(read_only_hint = true)
603 )]
604 async fn repo_tree(
605 &self,
606 Parameters(args): Parameters<args::RepoTreeArgs>,
607 ctx: RequestContext<RoleServer>,
608 ) -> Result<CallToolResult, ErrorData> {
609 let principal = self.principal(&ctx)?;
610 Ok(repo::tree(&self.services, &principal, args).await)
611 }
612
613 #[tool(
614 description = "按路径读取仓库文件内容(文本返回 utf-8,二进制返回 base64)。",
615 annotations(read_only_hint = true)
616 )]
617 async fn repo_file_get(
618 &self,
619 Parameters(args): Parameters<args::RepoFileArgs>,
620 ctx: RequestContext<RoleServer>,
621 ) -> Result<CallToolResult, ErrorData> {
622 let principal = self.principal(&ctx)?;
623 Ok(repo::file_get(&self.services, &principal, args).await)
624 }
625
626 #[tool(description = "列出仓库 Release。", annotations(read_only_hint = true))]
627 async fn repo_release_list(
628 &self,
629 Parameters(args): Parameters<args::RepoReleaseListArgs>,
630 ctx: RequestContext<RoleServer>,
631 ) -> Result<CallToolResult, ErrorData> {
632 let principal = self.principal(&ctx)?;
633 Ok(repo::release_list(&self.services, &principal, args).await)
634 }
635
636 #[tool(
637 description = "按 tag 获取 Release。",
638 annotations(read_only_hint = true)
639 )]
640 async fn repo_release_get(
641 &self,
642 Parameters(args): Parameters<args::RepoReleaseGetArgs>,
643 ctx: RequestContext<RoleServer>,
644 ) -> Result<CallToolResult, ErrorData> {
645 let principal = self.principal(&ctx)?;
646 Ok(repo::release_get(&self.services, &principal, args).await)
647 }
648
649 #[tool(description = "创建 Release(需要仓库写权限)。")]
650 async fn repo_release_create(
651 &self,
652 Parameters(args): Parameters<args::RepoReleaseCreateArgs>,
653 ctx: RequestContext<RoleServer>,
654 ) -> Result<CallToolResult, ErrorData> {
655 let principal = self.principal(&ctx)?;
656 Ok(repo::release_create(&self.services, &principal, args).await)
657 }
658
659 #[tool(description = "更新 Release(发布者或仓库管理员)。")]
660 async fn repo_release_update(
661 &self,
662 Parameters(args): Parameters<args::RepoReleaseUpdateArgs>,
663 ctx: RequestContext<RoleServer>,
664 ) -> Result<CallToolResult, ErrorData> {
665 let principal = self.principal(&ctx)?;
666 Ok(repo::release_update(&self.services, &principal, args).await)
667 }
668
669 #[tool(
670 description = "删除 Release(发布者或仓库管理员)。",
671 annotations(destructive_hint = true)
672 )]
673 async fn repo_release_delete(
674 &self,
675 Parameters(args): Parameters<args::RepoReleaseDeleteArgs>,
676 ctx: RequestContext<RoleServer>,
677 ) -> Result<CallToolResult, ErrorData> {
678 let principal = self.principal(&ctx)?;
679 Ok(repo::release_delete(&self.services, &principal, args).await)
680 }
681
682 #[tool(description = "列出仓库 topics。", annotations(read_only_hint = true))]
683 async fn repo_topic_list(
684 &self,
685 Parameters(args): Parameters<args::RepoTopicArgs>,
686 ctx: RequestContext<RoleServer>,
687 ) -> Result<CallToolResult, ErrorData> {
688 let principal = self.principal(&ctx)?;
689 Ok(repo::topic_list(&self.services, &principal, args).await)
690 }
691
692 #[tool(description = "为仓库添加 topic(需要仓库写权限)。")]
693 async fn repo_topic_add(
694 &self,
695 Parameters(args): Parameters<args::RepoTopicChangeArgs>,
696 ctx: RequestContext<RoleServer>,
697 ) -> Result<CallToolResult, ErrorData> {
698 let principal = self.principal(&ctx)?;
699 Ok(repo::topic_add(&self.services, &principal, args).await)
700 }
701
702 #[tool(description = "移除仓库 topic(需要仓库写权限)。")]
703 async fn repo_topic_remove(
704 &self,
705 Parameters(args): Parameters<args::RepoTopicChangeArgs>,
706 ctx: RequestContext<RoleServer>,
707 ) -> Result<CallToolResult, ErrorData> {
708 let principal = self.principal(&ctx)?;
709 Ok(repo::topic_remove(&self.services, &principal, args).await)
710 }
711
712 #[tool(description = "收藏仓库。")]
713 async fn repo_star(
714 &self,
715 Parameters(args): Parameters<args::RepoRefArgs>,
716 ctx: RequestContext<RoleServer>,
717 ) -> Result<CallToolResult, ErrorData> {
718 let principal = self.principal(&ctx)?;
719 Ok(repo::star(&self.services, &principal, args).await)
720 }
721
722 #[tool(description = "取消收藏仓库。")]
723 async fn repo_unstar(
724 &self,
725 Parameters(args): Parameters<args::RepoRefArgs>,
726 ctx: RequestContext<RoleServer>,
727 ) -> Result<CallToolResult, ErrorData> {
728 let principal = self.principal(&ctx)?;
729 Ok(repo::unstar(&self.services, &principal, args).await)
730 }
731
732 #[tool(description = "设置仓库关注模式(unwatched/watching/ignore)。")]
733 async fn repo_watch(
734 &self,
735 Parameters(args): Parameters<args::RepoWatchArgs>,
736 ctx: RequestContext<RoleServer>,
737 ) -> Result<CallToolResult, ErrorData> {
738 let principal = self.principal(&ctx)?;
739 Ok(repo::watch(&self.services, &principal, args).await)
740 }
741
742 #[tool(description = "列出仓库收藏者。", annotations(read_only_hint = true))]
743 async fn repo_stargazers(
744 &self,
745 Parameters(args): Parameters<args::RepoStargazerListArgs>,
746 ctx: RequestContext<RoleServer>,
747 ) -> Result<CallToolResult, ErrorData> {
748 let principal = self.principal(&ctx)?;
749 Ok(repo::stargazers(&self.services, &principal, args).await)
750 }
751
752 #[tool(description = "列出仓库关注者。", annotations(read_only_hint = true))]
753 async fn repo_watchers(
754 &self,
755 Parameters(args): Parameters<args::RepoWatcherListArgs>,
756 ctx: RequestContext<RoleServer>,
757 ) -> Result<CallToolResult, ErrorData> {
758 let principal = self.principal(&ctx)?;
759 Ok(repo::watchers(&self.services, &principal, args).await)
760 }
761
762 #[tool(
763 description = "列出 space 内的 issue 看板。",
764 annotations(read_only_hint = true)
765 )]
766 async fn issue_board_list(
767 &self,
768 Parameters(args): Parameters<args::IssueBoardListArgs>,
769 ctx: RequestContext<RoleServer>,
770 ) -> Result<CallToolResult, ErrorData> {
771 let principal = self.principal(&ctx)?;
772 Ok(issue::board_list(&self.services, &principal, args).await)
773 }
774
775 #[tool(
776 description = "获取 issue 看板详情。",
777 annotations(read_only_hint = true)
778 )]
779 async fn issue_board_get(
780 &self,
781 Parameters(args): Parameters<args::IssueBoardRefArgs>,
782 ctx: RequestContext<RoleServer>,
783 ) -> Result<CallToolResult, ErrorData> {
784 let principal = self.principal(&ctx)?;
785 Ok(issue::board_get(&self.services, &principal, args).await)
786 }
787
788 #[tool(description = "创建 issue 看板(需要 manage_channels 权限)。")]
789 async fn issue_board_create(
790 &self,
791 Parameters(args): Parameters<args::IssueBoardCreateArgs>,
792 ctx: RequestContext<RoleServer>,
793 ) -> Result<CallToolResult, ErrorData> {
794 let principal = self.principal(&ctx)?;
795 Ok(issue::board_create(&self.services, &principal, args).await)
796 }
797
798 #[tool(description = "更新 issue 看板(需要管理权限)。")]
799 async fn issue_board_update(
800 &self,
801 Parameters(args): Parameters<args::IssueBoardUpdateArgs>,
802 ctx: RequestContext<RoleServer>,
803 ) -> Result<CallToolResult, ErrorData> {
804 let principal = self.principal(&ctx)?;
805 Ok(issue::board_update(&self.services, &principal, args).await)
806 }
807
808 #[tool(
809 description = "删除 issue 看板(需要管理权限)。",
810 annotations(destructive_hint = true)
811 )]
812 async fn issue_board_delete(
813 &self,
814 Parameters(args): Parameters<args::IssueBoardRefArgs>,
815 ctx: RequestContext<RoleServer>,
816 ) -> Result<CallToolResult, ErrorData> {
817 let principal = self.principal(&ctx)?;
818 Ok(issue::board_delete(&self.services, &principal, args).await)
819 }
820
821 #[tool(
822 description = "分页/过滤查询 issue。",
823 annotations(read_only_hint = true)
824 )]
825 async fn issue_list(
826 &self,
827 Parameters(args): Parameters<args::IssueListArgs>,
828 ctx: RequestContext<RoleServer>,
829 ) -> Result<CallToolResult, ErrorData> {
830 let principal = self.principal(&ctx)?;
831 Ok(issue::list(&self.services, &principal, args).await)
832 }
833
834 #[tool(
835 description = "获取 issue 详情(issue_id 支持 UUID 或看板内编号)。",
836 annotations(read_only_hint = true)
837 )]
838 async fn issue_get(
839 &self,
840 Parameters(args): Parameters<args::IssueRefArgs>,
841 ctx: RequestContext<RoleServer>,
842 ) -> Result<CallToolResult, ErrorData> {
843 let principal = self.principal(&ctx)?;
844 Ok(issue::get(&self.services, &principal, args).await)
845 }
846
847 #[tool(description = "创建 issue。")]
848 async fn issue_create(
849 &self,
850 Parameters(args): Parameters<args::IssueCreateArgs>,
851 ctx: RequestContext<RoleServer>,
852 ) -> Result<CallToolResult, ErrorData> {
853 let principal = self.principal(&ctx)?;
854 Ok(issue::create(&self.services, &principal, args).await)
855 }
856
857 #[tool(description = "更新 issue(作者或看板管理员)。")]
858 async fn issue_update(
859 &self,
860 Parameters(args): Parameters<args::IssueUpdateArgs>,
861 ctx: RequestContext<RoleServer>,
862 ) -> Result<CallToolResult, ErrorData> {
863 let principal = self.principal(&ctx)?;
864 Ok(issue::update(&self.services, &principal, args).await)
865 }
866
867 #[tool(
868 description = "删除 issue(作者或看板管理员)。",
869 annotations(destructive_hint = true)
870 )]
871 async fn issue_delete(
872 &self,
873 Parameters(args): Parameters<args::IssueRefArgs>,
874 ctx: RequestContext<RoleServer>,
875 ) -> Result<CallToolResult, ErrorData> {
876 let principal = self.principal(&ctx)?;
877 Ok(issue::delete(&self.services, &principal, args).await)
878 }
879
880 #[tool(description = "关闭 issue。")]
881 async fn issue_close(
882 &self,
883 Parameters(args): Parameters<args::IssueRefArgs>,
884 ctx: RequestContext<RoleServer>,
885 ) -> Result<CallToolResult, ErrorData> {
886 let principal = self.principal(&ctx)?;
887 Ok(issue::set_closed(&self.services, &principal, args, true).await)
888 }
889
890 #[tool(description = "重新打开 issue。")]
891 async fn issue_reopen(
892 &self,
893 Parameters(args): Parameters<args::IssueRefArgs>,
894 ctx: RequestContext<RoleServer>,
895 ) -> Result<CallToolResult, ErrorData> {
896 let principal = self.principal(&ctx)?;
897 Ok(issue::set_closed(&self.services, &principal, args, false).await)
898 }
899
900 #[tool(description = "列出 issue 评论。", annotations(read_only_hint = true))]
901 async fn issue_comment_list(
902 &self,
903 Parameters(args): Parameters<args::IssueCommentListArgs>,
904 ctx: RequestContext<RoleServer>,
905 ) -> Result<CallToolResult, ErrorData> {
906 let principal = self.principal(&ctx)?;
907 Ok(issue::comment_list(&self.services, &principal, args).await)
908 }
909
910 #[tool(description = "发表 issue 评论。")]
911 async fn issue_comment_create(
912 &self,
913 Parameters(args): Parameters<args::IssueCommentCreateArgs>,
914 ctx: RequestContext<RoleServer>,
915 ) -> Result<CallToolResult, ErrorData> {
916 let principal = self.principal(&ctx)?;
917 Ok(issue::comment_create(&self.services, &principal, args).await)
918 }
919
920 #[tool(description = "更新 issue 评论(作者或看板管理员)。")]
921 async fn issue_comment_update(
922 &self,
923 Parameters(args): Parameters<args::IssueCommentUpdateArgs>,
924 ctx: RequestContext<RoleServer>,
925 ) -> Result<CallToolResult, ErrorData> {
926 let principal = self.principal(&ctx)?;
927 Ok(issue::comment_update(&self.services, &principal, args).await)
928 }
929
930 #[tool(
931 description = "删除 issue 评论(作者或看板管理员)。",
932 annotations(destructive_hint = true)
933 )]
934 async fn issue_comment_delete(
935 &self,
936 Parameters(args): Parameters<args::IssueCommentRefArgs>,
937 ctx: RequestContext<RoleServer>,
938 ) -> Result<CallToolResult, ErrorData> {
939 let principal = self.principal(&ctx)?;
940 Ok(issue::comment_delete(&self.services, &principal, args).await)
941 }
942
943 #[tool(description = "列出看板标签。", annotations(read_only_hint = true))]
944 async fn issue_label_list(
945 &self,
946 Parameters(args): Parameters<args::IssueLabelListArgs>,
947 ctx: RequestContext<RoleServer>,
948 ) -> Result<CallToolResult, ErrorData> {
949 let principal = self.principal(&ctx)?;
950 Ok(issue::label_list(&self.services, &principal, args).await)
951 }
952
953 #[tool(description = "创建看板标签(需要看板管理权限)。")]
954 async fn issue_label_create(
955 &self,
956 Parameters(args): Parameters<args::IssueLabelCreateArgs>,
957 ctx: RequestContext<RoleServer>,
958 ) -> Result<CallToolResult, ErrorData> {
959 let principal = self.principal(&ctx)?;
960 Ok(issue::label_create(&self.services, &principal, args).await)
961 }
962
963 #[tool(description = "更新看板标签(需要看板管理权限)。")]
964 async fn issue_label_update(
965 &self,
966 Parameters(args): Parameters<args::IssueLabelUpdateArgs>,
967 ctx: RequestContext<RoleServer>,
968 ) -> Result<CallToolResult, ErrorData> {
969 let principal = self.principal(&ctx)?;
970 Ok(issue::label_update(&self.services, &principal, args).await)
971 }
972
973 #[tool(
974 description = "删除看板标签(需要看板管理权限)。",
975 annotations(destructive_hint = true)
976 )]
977 async fn issue_label_delete(
978 &self,
979 Parameters(args): Parameters<args::IssueLabelRefArgs>,
980 ctx: RequestContext<RoleServer>,
981 ) -> Result<CallToolResult, ErrorData> {
982 let principal = self.principal(&ctx)?;
983 Ok(issue::label_delete(&self.services, &principal, args).await)
984 }
985
986 #[tool(description = "给 issue 绑定标签。")]
987 async fn issue_label_bind(
988 &self,
989 Parameters(args): Parameters<args::IssueLabelBindArgs>,
990 ctx: RequestContext<RoleServer>,
991 ) -> Result<CallToolResult, ErrorData> {
992 let principal = self.principal(&ctx)?;
993 Ok(issue::label_bind(&self.services, &principal, args).await)
994 }
995
996 #[tool(description = "解绑 issue 标签。")]
997 async fn issue_label_unbind(
998 &self,
999 Parameters(args): Parameters<args::IssueLabelBindArgs>,
1000 ctx: RequestContext<RoleServer>,
1001 ) -> Result<CallToolResult, ErrorData> {
1002 let principal = self.principal(&ctx)?;
1003 Ok(issue::label_unbind(&self.services, &principal, args).await)
1004 }
1005
1006 #[tool(description = "列出看板里程碑。", annotations(read_only_hint = true))]
1007 async fn issue_milestone_list(
1008 &self,
1009 Parameters(args): Parameters<args::IssueMilestoneListArgs>,
1010 ctx: RequestContext<RoleServer>,
1011 ) -> Result<CallToolResult, ErrorData> {
1012 let principal = self.principal(&ctx)?;
1013 Ok(issue::milestone_list(&self.services, &principal, args).await)
1014 }
1015
1016 #[tool(description = "创建看板里程碑(需要看板管理权限)。")]
1017 async fn issue_milestone_create(
1018 &self,
1019 Parameters(args): Parameters<args::IssueMilestoneCreateArgs>,
1020 ctx: RequestContext<RoleServer>,
1021 ) -> Result<CallToolResult, ErrorData> {
1022 let principal = self.principal(&ctx)?;
1023 Ok(issue::milestone_create(&self.services, &principal, args).await)
1024 }
1025
1026 #[tool(description = "更新看板里程碑(需要看板管理权限)。")]
1027 async fn issue_milestone_update(
1028 &self,
1029 Parameters(args): Parameters<args::IssueMilestoneUpdateArgs>,
1030 ctx: RequestContext<RoleServer>,
1031 ) -> Result<CallToolResult, ErrorData> {
1032 let principal = self.principal(&ctx)?;
1033 Ok(issue::milestone_update(&self.services, &principal, args).await)
1034 }
1035
1036 #[tool(
1037 description = "删除看板里程碑(需要看板管理权限)。",
1038 annotations(destructive_hint = true)
1039 )]
1040 async fn issue_milestone_delete(
1041 &self,
1042 Parameters(args): Parameters<args::IssueMilestoneRefArgs>,
1043 ctx: RequestContext<RoleServer>,
1044 ) -> Result<CallToolResult, ErrorData> {
1045 let principal = self.principal(&ctx)?;
1046 Ok(issue::milestone_delete(&self.services, &principal, args).await)
1047 }
1048
1049 #[tool(
1050 description = "列出 issue 成员(参与者/关注者等)。",
1051 annotations(read_only_hint = true)
1052 )]
1053 async fn issue_member_list(
1054 &self,
1055 Parameters(args): Parameters<args::IssueMemberListArgs>,
1056 ctx: RequestContext<RoleServer>,
1057 ) -> Result<CallToolResult, ErrorData> {
1058 let principal = self.principal(&ctx)?;
1059 Ok(issue::member_list(&self.services, &principal, args).await)
1060 }
1061
1062 #[tool(description = "添加 issue 成员(需要看板管理权限)。")]
1063 async fn issue_member_create(
1064 &self,
1065 Parameters(args): Parameters<args::IssueMemberCreateArgs>,
1066 ctx: RequestContext<RoleServer>,
1067 ) -> Result<CallToolResult, ErrorData> {
1068 let principal = self.principal(&ctx)?;
1069 Ok(issue::member_create(&self.services, &principal, args).await)
1070 }
1071
1072 #[tool(
1073 description = "移除 issue 成员(需要看板管理权限)。",
1074 annotations(destructive_hint = true)
1075 )]
1076 async fn issue_member_delete(
1077 &self,
1078 Parameters(args): Parameters<args::IssueMemberRefArgs>,
1079 ctx: RequestContext<RoleServer>,
1080 ) -> Result<CallToolResult, ErrorData> {
1081 let principal = self.principal(&ctx)?;
1082 Ok(issue::member_delete(&self.services, &principal, args).await)
1083 }
1084
1085 #[tool(description = "列出看板置顶。", annotations(read_only_hint = true))]
1086 async fn issue_pin_list(
1087 &self,
1088 Parameters(args): Parameters<args::IssuePinListArgs>,
1089 ctx: RequestContext<RoleServer>,
1090 ) -> Result<CallToolResult, ErrorData> {
1091 let principal = self.principal(&ctx)?;
1092 Ok(issue::pin_list(&self.services, &principal, args).await)
1093 }
1094
1095 #[tool(description = "置顶 issue(需要看板管理权限)。")]
1096 async fn issue_pin_create(
1097 &self,
1098 Parameters(args): Parameters<args::IssuePinCreateArgs>,
1099 ctx: RequestContext<RoleServer>,
1100 ) -> Result<CallToolResult, ErrorData> {
1101 let principal = self.principal(&ctx)?;
1102 Ok(issue::pin_create(&self.services, &principal, args).await)
1103 }
1104
1105 #[tool(description = "调整置顶顺序(需要看板管理权限)。")]
1106 async fn issue_pin_update(
1107 &self,
1108 Parameters(args): Parameters<args::IssuePinUpdateArgs>,
1109 ctx: RequestContext<RoleServer>,
1110 ) -> Result<CallToolResult, ErrorData> {
1111 let principal = self.principal(&ctx)?;
1112 Ok(issue::pin_update(&self.services, &principal, args).await)
1113 }
1114
1115 #[tool(
1116 description = "取消置顶(需要看板管理权限)。",
1117 annotations(destructive_hint = true)
1118 )]
1119 async fn issue_pin_delete(
1120 &self,
1121 Parameters(args): Parameters<args::IssuePinRefArgs>,
1122 ctx: RequestContext<RoleServer>,
1123 ) -> Result<CallToolResult, ErrorData> {
1124 let principal = self.principal(&ctx)?;
1125 Ok(issue::pin_delete(&self.services, &principal, args).await)
1126 }
1127
1128 #[tool(description = "列出 issue 依赖。", annotations(read_only_hint = true))]
1129 async fn issue_dependency_list(
1130 &self,
1131 Parameters(args): Parameters<args::IssueDependencyListArgs>,
1132 ctx: RequestContext<RoleServer>,
1133 ) -> Result<CallToolResult, ErrorData> {
1134 let principal = self.principal(&ctx)?;
1135 Ok(issue::dependency_list(&self.services, &principal, args).await)
1136 }
1137
1138 #[tool(description = "添加 issue 依赖(需要看板管理权限)。")]
1139 async fn issue_dependency_create(
1140 &self,
1141 Parameters(args): Parameters<args::IssueDependencyCreateArgs>,
1142 ctx: RequestContext<RoleServer>,
1143 ) -> Result<CallToolResult, ErrorData> {
1144 let principal = self.principal(&ctx)?;
1145 Ok(issue::dependency_create(&self.services, &principal, args).await)
1146 }
1147
1148 #[tool(
1149 description = "删除 issue 依赖(需要看板管理权限)。",
1150 annotations(destructive_hint = true)
1151 )]
1152 async fn issue_dependency_delete(
1153 &self,
1154 Parameters(args): Parameters<args::IssueDependencyRefArgs>,
1155 ctx: RequestContext<RoleServer>,
1156 ) -> Result<CallToolResult, ErrorData> {
1157 let principal = self.principal(&ctx)?;
1158 Ok(issue::dependency_delete(&self.services, &principal, args).await)
1159 }
1160
1161 #[tool(
1162 description = "列出仓库的合并请求。",
1163 annotations(read_only_hint = true)
1164 )]
1165 async fn pull_list(
1166 &self,
1167 Parameters(args): Parameters<args::PullListArgs>,
1168 ctx: RequestContext<RoleServer>,
1169 ) -> Result<CallToolResult, ErrorData> {
1170 let principal = self.principal(&ctx)?;
1171 Ok(pull::list(&self.services, &principal, args).await)
1172 }
1173
1174 #[tool(
1175 description = "获取合并请求详情(pull 支持编号或 UUID)。",
1176 annotations(read_only_hint = true)
1177 )]
1178 async fn pull_get(
1179 &self,
1180 Parameters(args): Parameters<args::PullRefArgs>,
1181 ctx: RequestContext<RoleServer>,
1182 ) -> Result<CallToolResult, ErrorData> {
1183 let principal = self.principal(&ctx)?;
1184 Ok(pull::get(&self.services, &principal, args).await)
1185 }
1186
1187 #[tool(description = "创建合并请求(需要仓库写权限)。")]
1188 async fn pull_create(
1189 &self,
1190 Parameters(args): Parameters<args::PullCreateArgs>,
1191 ctx: RequestContext<RoleServer>,
1192 ) -> Result<CallToolResult, ErrorData> {
1193 let principal = self.principal(&ctx)?;
1194 Ok(pull::create(&self.services, &principal, args).await)
1195 }
1196
1197 #[tool(description = "更新合并请求(需要仓库写权限)。")]
1198 async fn pull_update(
1199 &self,
1200 Parameters(args): Parameters<args::PullUpdateArgs>,
1201 ctx: RequestContext<RoleServer>,
1202 ) -> Result<CallToolResult, ErrorData> {
1203 let principal = self.principal(&ctx)?;
1204 Ok(pull::update(&self.services, &principal, args).await)
1205 }
1206
1207 #[tool(description = "关闭合并请求(作者或仓库管理员)。")]
1208 async fn pull_close(
1209 &self,
1210 Parameters(args): Parameters<args::PullRefArgs>,
1211 ctx: RequestContext<RoleServer>,
1212 ) -> Result<CallToolResult, ErrorData> {
1213 let principal = self.principal(&ctx)?;
1214 Ok(pull::set_state(&self.services, &principal, args, true).await)
1215 }
1216
1217 #[tool(description = "重新打开合并请求(作者或仓库管理员)。")]
1218 async fn pull_reopen(
1219 &self,
1220 Parameters(args): Parameters<args::PullRefArgs>,
1221 ctx: RequestContext<RoleServer>,
1222 ) -> Result<CallToolResult, ErrorData> {
1223 let principal = self.principal(&ctx)?;
1224 Ok(pull::set_state(&self.services, &principal, args, false).await)
1225 }
1226
1227 #[tool(
1228 description = "删除合并请求(仅仓库管理员)。",
1229 annotations(destructive_hint = true)
1230 )]
1231 async fn pull_delete(
1232 &self,
1233 Parameters(args): Parameters<args::PullRefArgs>,
1234 ctx: RequestContext<RoleServer>,
1235 ) -> Result<CallToolResult, ErrorData> {
1236 let principal = self.principal(&ctx)?;
1237 Ok(pull::delete(&self.services, &principal, args).await)
1238 }
1239
1240 #[tool(
1241 description = "合并合并请求(受分支保护与审批门槛限制)。",
1242 annotations(destructive_hint = true)
1243 )]
1244 async fn pull_merge(
1245 &self,
1246 Parameters(args): Parameters<args::PullMergeArgs>,
1247 ctx: RequestContext<RoleServer>,
1248 ) -> Result<CallToolResult, ErrorData> {
1249 let principal = self.principal(&ctx)?;
1250 Ok(pull::merge(&self.services, &principal, args).await)
1251 }
1252
1253 #[tool(description = "列出合并请求评论。", annotations(read_only_hint = true))]
1254 async fn pull_comment_list(
1255 &self,
1256 Parameters(args): Parameters<args::PullCommentListArgs>,
1257 ctx: RequestContext<RoleServer>,
1258 ) -> Result<CallToolResult, ErrorData> {
1259 let principal = self.principal(&ctx)?;
1260 Ok(pull::comment_list(&self.services, &principal, args).await)
1261 }
1262
1263 #[tool(description = "发表合并请求评论。")]
1264 async fn pull_comment_create(
1265 &self,
1266 Parameters(args): Parameters<args::PullCommentCreateArgs>,
1267 ctx: RequestContext<RoleServer>,
1268 ) -> Result<CallToolResult, ErrorData> {
1269 let principal = self.principal(&ctx)?;
1270 Ok(pull::comment_create(&self.services, &principal, args).await)
1271 }
1272
1273 #[tool(description = "更新合并请求评论(作者或管理员)。")]
1274 async fn pull_comment_update(
1275 &self,
1276 Parameters(args): Parameters<args::PullCommentUpdateArgs>,
1277 ctx: RequestContext<RoleServer>,
1278 ) -> Result<CallToolResult, ErrorData> {
1279 let principal = self.principal(&ctx)?;
1280 Ok(pull::comment_update(&self.services, &principal, args).await)
1281 }
1282
1283 #[tool(
1284 description = "删除合并请求评论(作者或管理员)。",
1285 annotations(destructive_hint = true)
1286 )]
1287 async fn pull_comment_delete(
1288 &self,
1289 Parameters(args): Parameters<args::PullCommentRefArgs>,
1290 ctx: RequestContext<RoleServer>,
1291 ) -> Result<CallToolResult, ErrorData> {
1292 let principal = self.principal(&ctx)?;
1293 Ok(pull::comment_delete(&self.services, &principal, args).await)
1294 }
1295
1296 #[tool(description = "标记/取消标记评论已解决。")]
1297 async fn pull_comment_resolve(
1298 &self,
1299 Parameters(args): Parameters<args::PullCommentResolveArgs>,
1300 ctx: RequestContext<RoleServer>,
1301 ) -> Result<CallToolResult, ErrorData> {
1302 let principal = self.principal(&ctx)?;
1303 Ok(pull::comment_resolve(&self.services, &principal, args).await)
1304 }
1305
1306 #[tool(
1307 description = "列出合并请求的评审。",
1308 annotations(read_only_hint = true)
1309 )]
1310 async fn pull_review_list(
1311 &self,
1312 Parameters(args): Parameters<args::PullReviewsArgs>,
1313 ctx: RequestContext<RoleServer>,
1314 ) -> Result<CallToolResult, ErrorData> {
1315 let principal = self.principal(&ctx)?;
1316 Ok(pull::review_list(&self.services, &principal, args).await)
1317 }
1318
1319 #[tool(description = "提交评审(approve/request_changes/comment)。")]
1320 async fn pull_review_submit(
1321 &self,
1322 Parameters(args): Parameters<args::PullReviewSubmitArgs>,
1323 ctx: RequestContext<RoleServer>,
1324 ) -> Result<CallToolResult, ErrorData> {
1325 let principal = self.principal(&ctx)?;
1326 Ok(pull::review_submit(&self.services, &principal, args).await)
1327 }
1328
1329 #[tool(description = "驳回评审(需要管理员)。")]
1330 async fn pull_review_dismiss(
1331 &self,
1332 Parameters(args): Parameters<args::PullReviewDismissArgs>,
1333 ctx: RequestContext<RoleServer>,
1334 ) -> Result<CallToolResult, ErrorData> {
1335 let principal = self.principal(&ctx)?;
1336 Ok(pull::review_dismiss(&self.services, &principal, args).await)
1337 }
1338
1339 #[tool(description = "列出评审请求。", annotations(read_only_hint = true))]
1340 async fn pull_reviewer_list(
1341 &self,
1342 Parameters(args): Parameters<args::PullReviewsArgs>,
1343 ctx: RequestContext<RoleServer>,
1344 ) -> Result<CallToolResult, ErrorData> {
1345 let principal = self.principal(&ctx)?;
1346 Ok(pull::reviewer_list(&self.services, &principal, args).await)
1347 }
1348
1349 #[tool(description = "请求评审人(需要仓库写权限)。")]
1350 async fn pull_reviewer_request(
1351 &self,
1352 Parameters(args): Parameters<args::PullReviewerRequestArgs>,
1353 ctx: RequestContext<RoleServer>,
1354 ) -> Result<CallToolResult, ErrorData> {
1355 let principal = self.principal(&ctx)?;
1356 Ok(pull::reviewer_request(&self.services, &principal, args).await)
1357 }
1358
1359 #[tool(description = "取消评审请求(需要仓库写权限)。")]
1360 async fn pull_reviewer_cancel(
1361 &self,
1362 Parameters(args): Parameters<args::PullReviewerCancelArgs>,
1363 ctx: RequestContext<RoleServer>,
1364 ) -> Result<CallToolResult, ErrorData> {
1365 let principal = self.principal(&ctx)?;
1366 Ok(pull::reviewer_cancel(&self.services, &principal, args).await)
1367 }
1368
1369 #[tool(
1370 description = "列出合并请求指派人。",
1371 annotations(read_only_hint = true)
1372 )]
1373 async fn pull_assignee_list(
1374 &self,
1375 Parameters(args): Parameters<args::PullReviewsArgs>,
1376 ctx: RequestContext<RoleServer>,
1377 ) -> Result<CallToolResult, ErrorData> {
1378 let principal = self.principal(&ctx)?;
1379 Ok(pull::assignee_list(&self.services, &principal, args).await)
1380 }
1381
1382 #[tool(description = "指派合并请求负责人(需要仓库写权限)。")]
1383 async fn pull_assignee_add(
1384 &self,
1385 Parameters(args): Parameters<args::PullAssigneeArgs>,
1386 ctx: RequestContext<RoleServer>,
1387 ) -> Result<CallToolResult, ErrorData> {
1388 let principal = self.principal(&ctx)?;
1389 Ok(pull::assignee_add(&self.services, &principal, args).await)
1390 }
1391
1392 #[tool(description = "移除合并请求负责人(需要仓库写权限)。")]
1393 async fn pull_assignee_remove(
1394 &self,
1395 Parameters(args): Parameters<args::PullAssigneeArgs>,
1396 ctx: RequestContext<RoleServer>,
1397 ) -> Result<CallToolResult, ErrorData> {
1398 let principal = self.principal(&ctx)?;
1399 Ok(pull::assignee_remove(&self.services, &principal, args).await)
1400 }
1401
1402 #[tool(description = "列出合并请求标签。", annotations(read_only_hint = true))]
1403 async fn pull_label_list(
1404 &self,
1405 Parameters(args): Parameters<args::PullReviewsArgs>,
1406 ctx: RequestContext<RoleServer>,
1407 ) -> Result<CallToolResult, ErrorData> {
1408 let principal = self.principal(&ctx)?;
1409 Ok(pull::label_list(&self.services, &principal, args).await)
1410 }
1411
1412 #[tool(description = "给合并请求添加标签(需要仓库写权限)。")]
1413 async fn pull_label_attach(
1414 &self,
1415 Parameters(args): Parameters<args::PullLabelArgs>,
1416 ctx: RequestContext<RoleServer>,
1417 ) -> Result<CallToolResult, ErrorData> {
1418 let principal = self.principal(&ctx)?;
1419 Ok(pull::label_attach(&self.services, &principal, args).await)
1420 }
1421
1422 #[tool(description = "移除合并请求标签(需要仓库写权限)。")]
1423 async fn pull_label_detach(
1424 &self,
1425 Parameters(args): Parameters<args::PullLabelArgs>,
1426 ctx: RequestContext<RoleServer>,
1427 ) -> Result<CallToolResult, ErrorData> {
1428 let principal = self.principal(&ctx)?;
1429 Ok(pull::label_detach(&self.services, &principal, args).await)
1430 }
1431
1432 #[tool(
1433 description = "查看合并请求的 CI 检查状态。",
1434 annotations(read_only_hint = true)
1435 )]
1436 async fn pull_checks(
1437 &self,
1438 Parameters(args): Parameters<args::PullReviewsArgs>,
1439 ctx: RequestContext<RoleServer>,
1440 ) -> Result<CallToolResult, ErrorData> {
1441 let principal = self.principal(&ctx)?;
1442 Ok(pull::checks(&self.services, &principal, args).await)
1443 }
1444
1445 #[tool(
1446 description = "查看合并请求的合并门槛(分支保护/审批)。",
1447 annotations(read_only_hint = true)
1448 )]
1449 async fn pull_gate(
1450 &self,
1451 Parameters(args): Parameters<args::PullReviewsArgs>,
1452 ctx: RequestContext<RoleServer>,
1453 ) -> Result<CallToolResult, ErrorData> {
1454 let principal = self.principal(&ctx)?;
1455 Ok(pull::gate(&self.services, &principal, args).await)
1456 }
1457
1458 #[tool(
1459 description = "查看我的合并请求收件箱(created/review-requested/reviewed,scope 用 kebab-case)。",
1460 annotations(read_only_hint = true)
1461 )]
1462 async fn pull_inbox(
1463 &self,
1464 Parameters(args): Parameters<args::PullInboxArgs>,
1465 ctx: RequestContext<RoleServer>,
1466 ) -> Result<CallToolResult, ErrorData> {
1467 let principal = self.principal(&ctx)?;
1468 Ok(pull::inbox(&self.services, &principal, args).await)
1469 }
1470}
Deleted lib/mcp-api/src/tools/pull.rs
@@ -1,958 +0,0 @@
1use git::context::facade::GitContext;
2use pulls::services::comments::CreatePullCommentCmd;
3use pulls::services::inbox::{InboxQuery, InboxScope};
4use pulls::services::labels::AttachLabelCmd;
5use pulls::services::merge::MergePullCmd;
6use pulls::services::pulls::{CreatePullCmd, ListPullQuery, UpdatePullCmd};
7use pulls::services::reviewers::RequestReviewersCmd;
8use pulls::services::reviews::{InlineDraft, ReviewIntent, SubmitReviewCmd};
9use pulls::share::lookup::PullHandle;
10use rmcp::model::CallToolResult;
11use serde_json::json;
12
13use crate::auth::Principal;
14use crate::error::{tool_done, tool_error, tool_message, tool_ok, write_denied};
15use crate::services::McpServices;
16use crate::tools::args;
17use crate::tools::helpers::{page, parse_enum, repo_read, repo_write};
18
19async fn handle_for(
20 services: &McpServices,
21 principal: &Principal,
22 space: &str,
23 repo: &str,
24 pull_ref: &str,
25 write: bool,
26) -> Result<PullHandle, CallToolResult> {
27 let ctx: GitContext = if write {
28 repo_write(services, principal.uid, space, repo).await?
29 } else {
30 repo_read(services, principal.uid, space, repo).await?
31 };
32 let pull = pulls::share::lookup::find_pull(&services.pulls, ctx.model.id, pull_ref.trim())
33 .await
34 .map_err(tool_error)?;
35 let admin = pulls::share::lookup::is_admin(&services.pulls, principal.uid, &ctx).await;
36 Ok(PullHandle {
37 ctx,
38 pull,
39 caller: Some(principal.uid),
40 admin,
41 })
42}
43
44async fn detail(
45 services: &McpServices,
46 space: &str,
47 repo: &str,
48 handle: &PullHandle,
49) -> CallToolResult {
50 match services
51 .pulls
52 .pull_detail(
53 space.trim().to_string(),
54 repo.trim().to_string(),
55 &handle.ctx,
56 &handle.pull,
57 )
58 .await
59 {
60 Ok(dto) => tool_ok(&dto),
61 Err(error) => tool_error(error),
62 }
63}
64
65pub(crate) async fn list(
66 services: &McpServices,
67 principal: &Principal,
68 args: args::PullListArgs,
69) -> CallToolResult {
70 let ctx = match repo_read(services, principal.uid, &args.space, &args.repo).await {
71 Ok(ctx) => ctx,
72 Err(error) => return error,
73 };
74 let query = ListPullQuery {
75 state: args.state,
76 author: args.author,
77 base: args.base,
78 head: args.head,
79 draft: args.draft,
80 search: args.search,
81 sort: args.sort,
82 order: args.order,
83 limit: args.limit,
84 offset: args.offset,
85 };
86 match services.pulls.list_pulls(ctx.model.id, query).await {
87 Ok(result) => tool_ok(&result),
88 Err(error) => tool_error(error),
89 }
90}
91
92pub(crate) async fn create(
93 services: &McpServices,
94 principal: &Principal,
95 args: args::PullCreateArgs,
96) -> CallToolResult {
97 if !principal.can_write() {
98 return write_denied();
99 }
100 let ctx = match repo_write(services, principal.uid, &args.space, &args.repo).await {
101 Ok(ctx) => ctx,
102 Err(error) => return error,
103 };
104 let cmd = CreatePullCmd {
105 title: args.title,
106 body: args.body,
107 head_branch: args.head_branch,
108 base_branch: args.base_branch,
109 draft: args.draft,
110 issue_id: args.issue_id,
111 };
112 let space = args.space.trim().to_string();
113 let repo = args.repo.trim().to_string();
114 match services
115 .pulls
116 .create_pull(space.clone(), repo.clone(), &ctx, principal.uid, cmd)
117 .await
118 {
119 Ok(model) => {
120 let handle = PullHandle {
121 ctx,
122 pull: model,
123 caller: Some(principal.uid),
124 admin: false,
125 };
126 detail(services, &space, &repo, &handle).await
127 }
128 Err(error) => tool_error(error),
129 }
130}
131
132pub(crate) async fn get(
133 services: &McpServices,
134 principal: &Principal,
135 args: args::PullRefArgs,
136) -> CallToolResult {
137 let handle = match handle_for(
138 services,
139 principal,
140 &args.space,
141 &args.repo,
142 &args.pull,
143 false,
144 )
145 .await
146 {
147 Ok(handle) => handle,
148 Err(error) => return error,
149 };
150 detail(services, &args.space, &args.repo, &handle).await
151}
152
153pub(crate) async fn update(
154 services: &McpServices,
155 principal: &Principal,
156 args: args::PullUpdateArgs,
157) -> CallToolResult {
158 if !principal.can_write() {
159 return write_denied();
160 }
161 let handle = match handle_for(
162 services,
163 principal,
164 &args.space,
165 &args.repo,
166 &args.pull,
167 true,
168 )
169 .await
170 {
171 Ok(handle) => handle,
172 Err(error) => return error,
173 };
174 let cmd = UpdatePullCmd {
175 title: args.title,
176 body: args.body.map(Some),
177 base_branch: args.base_branch,
178 draft: args.draft,
179 auto_merge: args.auto_merge.map(Some),
180 issue_id: None,
181 };
182 let space = args.space.trim().to_string();
183 let repo = args.repo.trim().to_string();
184 match services
185 .pulls
186 .update_pull(space.clone(), repo.clone(), &handle, cmd)
187 .await
188 {
189 Ok(model) => {
190 let handle = PullHandle {
191 ctx: handle.ctx,
192 pull: model,
193 caller: Some(principal.uid),
194 admin: handle.admin,
195 };
196 detail(services, &space, &repo, &handle).await
197 }
198 Err(error) => tool_error(error),
199 }
200}
201
202pub(crate) async fn set_state(
203 services: &McpServices,
204 principal: &Principal,
205 args: args::PullRefArgs,
206 closed: bool,
207) -> CallToolResult {
208 if !principal.can_write() {
209 return write_denied();
210 }
211 let handle = match handle_for(
212 services,
213 principal,
214 &args.space,
215 &args.repo,
216 &args.pull,
217 true,
218 )
219 .await
220 {
221 Ok(handle) => handle,
222 Err(error) => return error,
223 };
224 match services
225 .pulls
226 .set_state(&handle.pull, principal.uid, closed)
227 .await
228 {
229 Ok(model) => {
230 let handle = PullHandle {
231 ctx: handle.ctx,
232 pull: model,
233 caller: Some(principal.uid),
234 admin: handle.admin,
235 };
236 detail(services, &args.space, &args.repo, &handle).await
237 }
238 Err(error) => tool_error(error),
239 }
240}
241
242pub(crate) async fn delete(
243 services: &McpServices,
244 principal: &Principal,
245 args: args::PullRefArgs,
246) -> CallToolResult {
247 if !principal.can_write() {
248 return write_denied();
249 }
250 let handle = match handle_for(
251 services,
252 principal,
253 &args.space,
254 &args.repo,
255 &args.pull,
256 true,
257 )
258 .await
259 {
260 Ok(handle) => handle,
261 Err(error) => return error,
262 };
263 if !handle.admin {
264 return tool_message("仅仓库管理员可以删除合并请求");
265 }
266 match services
267 .pulls
268 .delete_pull(&handle.pull, principal.uid)
269 .await
270 {
271 Ok(()) => tool_done(),
272 Err(error) => tool_error(error),
273 }
274}
275
276pub(crate) async fn merge(
277 services: &McpServices,
278 principal: &Principal,
279 args: args::PullMergeArgs,
280) -> CallToolResult {
281 if !principal.can_write() {
282 return write_denied();
283 }
284 let handle = match handle_for(
285 services,
286 principal,
287 &args.space,
288 &args.repo,
289 &args.pull,
290 true,
291 )
292 .await
293 {
294 Ok(handle) => handle,
295 Err(error) => return error,
296 };
297 let method = match args.method.as_deref() {
298 Some(raw) => match parse_enum::<models::pull_request::pull_requests::MergeMethod>(raw) {
299 Ok(method) => Some(method),
300 Err(error) => return error,
301 },
302 None => None,
303 };
304 let cmd = MergePullCmd {
305 method,
306 title: args.title,
307 message: args.message,
308 fast_forward_only: args.fast_forward_only,
309 expect_head_sha: args.expect_head_sha,
310 delete_branch_after_merge: args.delete_branch_after_merge,
311 };
312 let space = args.space.trim().to_string();
313 let repo = args.repo.trim().to_string();
314 match services
315 .pulls
316 .merge_pull(space.clone(), repo.clone(), &handle, cmd)
317 .await
318 {
319 Ok((model, result, summary)) => {
320 let handle = PullHandle {
321 ctx: handle.ctx,
322 pull: model,
323 caller: Some(principal.uid),
324 admin: handle.admin,
325 };
326 match services
327 .pulls
328 .pull_detail(space, repo, &handle.ctx, &handle.pull)
329 .await
330 {
331 Ok(dto) => tool_ok(&json!({ "pull": dto, "result": result, "summary": summary })),
332 Err(error) => tool_error(error),
333 }
334 }
335 Err(error) => tool_error(error),
336 }
337}
338
339pub(crate) async fn comment_list(
340 services: &McpServices,
341 principal: &Principal,
342 args: args::PullCommentListArgs,
343) -> CallToolResult {
344 let handle = match handle_for(
345 services,
346 principal,
347 &args.space,
348 &args.repo,
349 &args.pull,
350 false,
351 )
352 .await
353 {
354 Ok(handle) => handle,
355 Err(error) => return error,
356 };
357 match services
358 .pulls
359 .list_comments(handle.pull.id, page(args.limit, args.offset, None))
360 .await
361 {
362 Ok(result) => tool_ok(&result),
363 Err(error) => tool_error(error),
364 }
365}
366
367pub(crate) async fn comment_create(
368 services: &McpServices,
369 principal: &Principal,
370 args: args::PullCommentCreateArgs,
371) -> CallToolResult {
372 if !principal.can_write() {
373 return write_denied();
374 }
375 let handle = match handle_for(
376 services,
377 principal,
378 &args.space,
379 &args.repo,
380 &args.pull,
381 true,
382 )
383 .await
384 {
385 Ok(handle) => handle,
386 Err(error) => return error,
387 };
388 let cmd = CreatePullCommentCmd {
389 body: args.body,
390 path: args.path,
391 line: args.line,
392 side: args.side,
393 commit_sha: args.commit_sha,
394 review_id: None,
395 parent_comment_id: args.parent_comment_id,
396 };
397 match services
398 .pulls
399 .create_comment(&handle.ctx, &handle.pull, principal.uid, cmd)
400 .await
401 {
402 Ok(model) => tool_ok(&json!({ "id": model.id })),
403 Err(error) => tool_error(error),
404 }
405}
406
407pub(crate) async fn comment_update(
408 services: &McpServices,
409 principal: &Principal,
410 args: args::PullCommentUpdateArgs,
411) -> CallToolResult {
412 if !principal.can_write() {
413 return write_denied();
414 }
415 let handle = match handle_for(
416 services,
417 principal,
418 &args.space,
419 &args.repo,
420 &args.pull,
421 true,
422 )
423 .await
424 {
425 Ok(handle) => handle,
426 Err(error) => return error,
427 };
428 match services
429 .pulls
430 .update_comment(
431 &handle.pull,
432 args.comment_id,
433 principal.uid,
434 handle.admin,
435 args.body,
436 )
437 .await
438 {
439 Ok(model) => tool_ok(&json!({ "id": model.id })),
440 Err(error) => tool_error(error),
441 }
442}
443
444pub(crate) async fn comment_delete(
445 services: &McpServices,
446 principal: &Principal,
447 args: args::PullCommentRefArgs,
448) -> CallToolResult {
449 if !principal.can_write() {
450 return write_denied();
451 }
452 let handle = match handle_for(
453 services,
454 principal,
455 &args.space,
456 &args.repo,
457 &args.pull,
458 true,
459 )
460 .await
461 {
462 Ok(handle) => handle,
463 Err(error) => return error,
464 };
465 match services
466 .pulls
467 .delete_comment(&handle.pull, args.comment_id, principal.uid, handle.admin)
468 .await
469 {
470 Ok(()) => tool_done(),
471 Err(error) => tool_error(error),
472 }
473}
474
475pub(crate) async fn comment_resolve(
476 services: &McpServices,
477 principal: &Principal,
478 args: args::PullCommentResolveArgs,
479) -> CallToolResult {
480 if !principal.can_write() {
481 return write_denied();
482 }
483 let handle = match handle_for(
484 services,
485 principal,
486 &args.space,
487 &args.repo,
488 &args.pull,
489 true,
490 )
491 .await
492 {
493 Ok(handle) => handle,
494 Err(error) => return error,
495 };
496 match services
497 .pulls
498 .set_comment_resolved(&handle.pull, args.comment_id, principal.uid, args.resolved)
499 .await
500 {
501 Ok(model) => tool_ok(&json!({ "id": model.id, "resolved": args.resolved })),
502 Err(error) => tool_error(error),
503 }
504}
505
506pub(crate) async fn review_list(
507 services: &McpServices,
508 principal: &Principal,
509 args: args::PullReviewsArgs,
510) -> CallToolResult {
511 let handle = match handle_for(
512 services,
513 principal,
514 &args.space,
515 &args.repo,
516 &args.pull,
517 false,
518 )
519 .await
520 {
521 Ok(handle) => handle,
522 Err(error) => return error,
523 };
524 match services
525 .pulls
526 .list_reviews(handle.pull.id, Some(principal.uid))
527 .await
528 {
529 Ok(result) => tool_ok(&result),
530 Err(error) => tool_error(error),
531 }
532}
533
534pub(crate) async fn review_submit(
535 services: &McpServices,
536 principal: &Principal,
537 args: args::PullReviewSubmitArgs,
538) -> CallToolResult {
539 if !principal.can_write() {
540 return write_denied();
541 }
542 let state = match parse_enum::<ReviewIntent>(&args.state) {
543 Ok(state) => state,
544 Err(error) => return error,
545 };
546 let handle = match handle_for(
547 services,
548 principal,
549 &args.space,
550 &args.repo,
551 &args.pull,
552 true,
553 )
554 .await
555 {
556 Ok(handle) => handle,
557 Err(error) => return error,
558 };
559 let cmd = SubmitReviewCmd {
560 state,
561 body: args.body,
562 comments: Vec::<InlineDraft>::new(),
563 };
564 match services
565 .pulls
566 .submit_review(&handle.ctx, &handle.pull, principal.uid, handle.admin, cmd)
567 .await
568 {
569 Ok(model) => tool_ok(&json!({ "id": model.id })),
570 Err(error) => tool_error(error),
571 }
572}
573
574pub(crate) async fn review_dismiss(
575 services: &McpServices,
576 principal: &Principal,
577 args: args::PullReviewDismissArgs,
578) -> CallToolResult {
579 if !principal.can_write() {
580 return write_denied();
581 }
582 let handle = match handle_for(
583 services,
584 principal,
585 &args.space,
586 &args.repo,
587 &args.pull,
588 true,
589 )
590 .await
591 {
592 Ok(handle) => handle,
593 Err(error) => return error,
594 };
595 match services
596 .pulls
597 .dismiss_review(&handle.pull, args.review_id, principal.uid)
598 .await
599 {
600 Ok(model) => tool_ok(&json!({ "id": model.id })),
601 Err(error) => tool_error(error),
602 }
603}
604
605pub(crate) async fn reviewer_list(
606 services: &McpServices,
607 principal: &Principal,
608 args: args::PullReviewsArgs,
609) -> CallToolResult {
610 let handle = match handle_for(
611 services,
612 principal,
613 &args.space,
614 &args.repo,
615 &args.pull,
616 false,
617 )
618 .await
619 {
620 Ok(handle) => handle,
621 Err(error) => return error,
622 };
623 match services.pulls.list_reviewers(handle.pull.id).await {
624 Ok(result) => tool_ok(&result),
625 Err(error) => tool_error(error),
626 }
627}
628
629pub(crate) async fn reviewer_request(
630 services: &McpServices,
631 principal: &Principal,
632 args: args::PullReviewerRequestArgs,
633) -> CallToolResult {
634 if !principal.can_write() {
635 return write_denied();
636 }
637 let handle = match handle_for(
638 services,
639 principal,
640 &args.space,
641 &args.repo,
642 &args.pull,
643 true,
644 )
645 .await
646 {
647 Ok(handle) => handle,
648 Err(error) => return error,
649 };
650 let cmd = RequestReviewersCmd {
651 reviewers: args.reviewers,
652 required: args.required.unwrap_or(true),
653 };
654 match services
655 .pulls
656 .request_reviewers(&handle.pull, principal.uid, cmd)
657 .await
658 {
659 Ok(result) => tool_ok(&result),
660 Err(error) => tool_error(error),
661 }
662}
663
664pub(crate) async fn reviewer_cancel(
665 services: &McpServices,
666 principal: &Principal,
667 args: args::PullReviewerCancelArgs,
668) -> CallToolResult {
669 if !principal.can_write() {
670 return write_denied();
671 }
672 let handle = match handle_for(
673 services,
674 principal,
675 &args.space,
676 &args.repo,
677 &args.pull,
678 true,
679 )
680 .await
681 {
682 Ok(handle) => handle,
683 Err(error) => return error,
684 };
685 match services
686 .pulls
687 .cancel_reviewer_request(&handle.pull, principal.uid, args.reviewer_id)
688 .await
689 {
690 Ok(result) => tool_ok(&result),
691 Err(error) => tool_error(error),
692 }
693}
694
695pub(crate) async fn assignee_list(
696 services: &McpServices,
697 principal: &Principal,
698 args: args::PullReviewsArgs,
699) -> CallToolResult {
700 let handle = match handle_for(
701 services,
702 principal,
703 &args.space,
704 &args.repo,
705 &args.pull,
706 false,
707 )
708 .await
709 {
710 Ok(handle) => handle,
711 Err(error) => return error,
712 };
713 match services.pulls.list_assignees(handle.pull.id).await {
714 Ok(result) => tool_ok(&result),
715 Err(error) => tool_error(error),
716 }
717}
718
719pub(crate) async fn assignee_add(
720 services: &McpServices,
721 principal: &Principal,
722 args: args::PullAssigneeArgs,
723) -> CallToolResult {
724 if !principal.can_write() {
725 return write_denied();
726 }
727 let handle = match handle_for(
728 services,
729 principal,
730 &args.space,
731 &args.repo,
732 &args.pull,
733 true,
734 )
735 .await
736 {
737 Ok(handle) => handle,
738 Err(error) => return error,
739 };
740 match services
741 .pulls
742 .add_assignee(&handle.pull, principal.uid, &args.user)
743 .await
744 {
745 Ok(result) => tool_ok(&result),
746 Err(error) => tool_error(error),
747 }
748}
749
750pub(crate) async fn assignee_remove(
751 services: &McpServices,
752 principal: &Principal,
753 args: args::PullAssigneeArgs,
754) -> CallToolResult {
755 if !principal.can_write() {
756 return write_denied();
757 }
758 let handle = match handle_for(
759 services,
760 principal,
761 &args.space,
762 &args.repo,
763 &args.pull,
764 true,
765 )
766 .await
767 {
768 Ok(handle) => handle,
769 Err(error) => return error,
770 };
771 match services
772 .pulls
773 .remove_assignee(&handle.pull, principal.uid, &args.user)
774 .await
775 {
776 Ok(result) => tool_ok(&result),
777 Err(error) => tool_error(error),
778 }
779}
780
781pub(crate) async fn label_list(
782 services: &McpServices,
783 principal: &Principal,
784 args: args::PullReviewsArgs,
785) -> CallToolResult {
786 let handle = match handle_for(
787 services,
788 principal,
789 &args.space,
790 &args.repo,
791 &args.pull,
792 false,
793 )
794 .await
795 {
796 Ok(handle) => handle,
797 Err(error) => return error,
798 };
799 match services.pulls.list_labels(handle.pull.id).await {
800 Ok(result) => tool_ok(&result),
801 Err(error) => tool_error(error),
802 }
803}
804
805pub(crate) async fn label_attach(
806 services: &McpServices,
807 principal: &Principal,
808 args: args::PullLabelArgs,
809) -> CallToolResult {
810 if !principal.can_write() {
811 return write_denied();
812 }
813 let handle = match handle_for(
814 services,
815 principal,
816 &args.space,
817 &args.repo,
818 &args.pull,
819 true,
820 )
821 .await
822 {
823 Ok(handle) => handle,
824 Err(error) => return error,
825 };
826 let cmd = AttachLabelCmd { label: args.label };
827 match services
828 .pulls
829 .attach_label(&handle.pull, handle.ctx.model.dir_id, principal.uid, cmd)
830 .await
831 {
832 Ok(result) => tool_ok(&result),
833 Err(error) => tool_error(error),
834 }
835}
836
837pub(crate) async fn label_detach(
838 services: &McpServices,
839 principal: &Principal,
840 args: args::PullLabelArgs,
841) -> CallToolResult {
842 if !principal.can_write() {
843 return write_denied();
844 }
845 let handle = match handle_for(
846 services,
847 principal,
848 &args.space,
849 &args.repo,
850 &args.pull,
851 true,
852 )
853 .await
854 {
855 Ok(handle) => handle,
856 Err(error) => return error,
857 };
858 match services
859 .pulls
860 .detach_label(
861 &handle.pull,
862 handle.ctx.model.dir_id,
863 principal.uid,
864 &args.label,
865 )
866 .await
867 {
868 Ok(result) => tool_ok(&result),
869 Err(error) => tool_error(error),
870 }
871}
872
873pub(crate) async fn checks(
874 services: &McpServices,
875 principal: &Principal,
876 args: args::PullReviewsArgs,
877) -> CallToolResult {
878 let handle = match handle_for(
879 services,
880 principal,
881 &args.space,
882 &args.repo,
883 &args.pull,
884 false,
885 )
886 .await
887 {
888 Ok(handle) => handle,
889 Err(error) => return error,
890 };
891 match services
892 .pulls
893 .pull_checks(
894 args.space.trim().to_string(),
895 args.repo.trim().to_string(),
896 &handle.pull,
897 )
898 .await
899 {
900 Ok(result) => tool_ok(&result),
901 Err(error) => tool_error(error),
902 }
903}
904
905pub(crate) async fn gate(
906 services: &McpServices,
907 principal: &Principal,
908 args: args::PullReviewsArgs,
909) -> CallToolResult {
910 let handle = match handle_for(
911 services,
912 principal,
913 &args.space,
914 &args.repo,
915 &args.pull,
916 false,
917 )
918 .await
919 {
920 Ok(handle) => handle,
921 Err(error) => return error,
922 };
923 match services
924 .pulls
925 .pull_gate(
926 args.space.trim().to_string(),
927 args.repo.trim().to_string(),
928 &handle.ctx,
929 &handle.pull,
930 Some(principal.uid),
931 handle.admin,
932 )
933 .await
934 {
935 Ok(result) => tool_ok(&result),
936 Err(error) => tool_error(error),
937 }
938}
939
940pub(crate) async fn inbox(
941 services: &McpServices,
942 principal: &Principal,
943 args: args::PullInboxArgs,
944) -> CallToolResult {
945 let scope = match parse_enum::<InboxScope>(&args.scope) {
946 Ok(scope) => scope,
947 Err(error) => return error,
948 };
949 let query = InboxQuery {
950 state: args.state,
951 limit: args.limit,
952 offset: args.offset,
953 };
954 match services.pulls.list_inbox(principal.uid, scope, query).await {
955 Ok(result) => tool_ok(&result),
956 Err(error) => tool_error(error),
957 }
958}
Deleted lib/mcp-api/src/tools/repo.rs
@@ -1,837 +0,0 @@
1use std::path::PathBuf;
2
3use base64::Engine as _;
4use git::service::branches::CreateBranchCmd;
5use git::service::commits::ListCommitsCmd;
6use git::service::releases::{CreateReleaseCmd, ListReleasesQuery, UpdateReleaseCmd};
7use git::service::repos::{CreateRepoCmd, UpdateRepoCmd};
8use git::service::tags::CreateTagCmd;
9use git::service::watches::UpsertWatchCmd;
10use gitbaby::commit::types::TreeEntryMode;
11use models::common::watch_mode::WatchMode;
12use rmcp::model::CallToolResult;
13use serde_json::json;
14
15use crate::auth::Principal;
16use crate::error::{tool_done, tool_error, tool_ok, write_denied};
17use crate::services::McpServices;
18use crate::tools::args;
19use crate::tools::helpers::{page, parse_enum, repo_read, repo_write};
20
21pub(crate) async fn list(
22 services: &McpServices,
23 principal: &Principal,
24 args: args::RepoListArgs,
25) -> CallToolResult {
26 match services
27 .git
28 .list_repos(
29 args.space.trim().to_string(),
30 Some(principal.uid),
31 page(args.limit, args.offset, args.sort),
32 )
33 .await
34 {
35 Ok(result) => tool_ok(&result),
36 Err(error) => tool_error(error),
37 }
38}
39
40pub(crate) async fn get(
41 services: &McpServices,
42 principal: &Principal,
43 args: args::RepoRefArgs,
44) -> CallToolResult {
45 if let Err(error) = repo_read(services, principal.uid, &args.space, &args.repo).await {
46 return error;
47 }
48 match services
49 .git
50 .get_repo(args.space.trim().to_string(), args.repo.trim().to_string())
51 .await
52 {
53 Ok(result) => tool_ok(&result),
54 Err(error) => tool_error(error),
55 }
56}
57
58pub(crate) async fn create(
59 services: &McpServices,
60 principal: &Principal,
61 args: args::RepoCreateArgs,
62) -> CallToolResult {
63 if !principal.can_write() {
64 return write_denied();
65 }
66 let cmd = CreateRepoCmd {
67 name: args.name,
68 description: args.description,
69 is_private: args.is_private,
70 default_branch: args.default_branch,
71 parent_dir_id: args.parent_dir_id,
72 enable_lfs: args.enable_lfs,
73 license_name: args.license_name,
74 init_message: args.init_message,
75 };
76 match services
77 .git
78 .create_repo(args.space.trim().to_string(), principal.uid, cmd)
79 .await
80 {
81 Ok(result) => tool_ok(&result),
82 Err(error) => tool_error(error),
83 }
84}
85
86pub(crate) async fn update(
87 services: &McpServices,
88 principal: &Principal,
89 args: args::RepoUpdateArgs,
90) -> CallToolResult {
91 if !principal.can_write() {
92 return write_denied();
93 }
94 let cmd = UpdateRepoCmd {
95 description: args.description,
96 website: args.website,
97 is_private: args.is_private,
98 enable_lfs: args.enable_lfs,
99 default_branch: args.default_branch,
100 ai_review_on_push: args.ai_review_on_push,
101 ai_summary_on_push: args.ai_summary_on_push,
102 ai_review_on_pull: args.ai_review_on_pull,
103 allow_pull_requests: None,
104 pipelines_public: None,
105 ai_summary_public: None,
106 tab_order: None,
107 };
108 match services
109 .git
110 .update_repo(
111 args.space.trim().to_string(),
112 args.repo.trim().to_string(),
113 principal.uid,
114 cmd,
115 )
116 .await
117 {
118 Ok(result) => tool_ok(&result),
119 Err(error) => tool_error(error),
120 }
121}
122
123pub(crate) async fn set_archived(
124 services: &McpServices,
125 principal: &Principal,
126 args: args::RepoArchiveArgs,
127) -> CallToolResult {
128 if !principal.can_write() {
129 return write_denied();
130 }
131 match services
132 .git
133 .set_repo_archived(
134 args.space.trim().to_string(),
135 args.repo.trim().to_string(),
136 principal.uid,
137 args.archived,
138 )
139 .await
140 {
141 Ok(result) => tool_ok(&result),
142 Err(error) => tool_error(error),
143 }
144}
145
146pub(crate) async fn branch_list(
147 services: &McpServices,
148 principal: &Principal,
149 args: args::RepoRefArgs,
150) -> CallToolResult {
151 if let Err(error) = repo_read(services, principal.uid, &args.space, &args.repo).await {
152 return error;
153 }
154 match services
155 .git
156 .list_branches(args.space.trim().to_string(), args.repo.trim().to_string())
157 .await
158 {
159 Ok(result) => tool_ok(&result),
160 Err(error) => tool_error(error),
161 }
162}
163
164pub(crate) async fn branch_get(
165 services: &McpServices,
166 principal: &Principal,
167 args: args::RepoBranchGetArgs,
168) -> CallToolResult {
169 if let Err(error) = repo_read(services, principal.uid, &args.space, &args.repo).await {
170 return error;
171 }
172 match services
173 .git
174 .get_branch(
175 args.space.trim().to_string(),
176 args.repo.trim().to_string(),
177 args.branch,
178 )
179 .await
180 {
181 Ok(result) => tool_ok(&result),
182 Err(error) => tool_error(error),
183 }
184}
185
186pub(crate) async fn branch_create(
187 services: &McpServices,
188 principal: &Principal,
189 args: args::RepoBranchCreateArgs,
190) -> CallToolResult {
191 if !principal.can_write() {
192 return write_denied();
193 }
194 if let Err(error) = repo_write(services, principal.uid, &args.space, &args.repo).await {
195 return error;
196 }
197 let cmd = CreateBranchCmd {
198 name: args.name,
199 start_point: args.start_point,
200 };
201 match services
202 .git
203 .create_branch(
204 args.space.trim().to_string(),
205 args.repo.trim().to_string(),
206 cmd,
207 )
208 .await
209 {
210 Ok(result) => tool_ok(&result),
211 Err(error) => tool_error(error),
212 }
213}
214
215pub(crate) async fn branch_rename(
216 services: &McpServices,
217 principal: &Principal,
218 args: args::RepoBranchRenameArgs,
219) -> CallToolResult {
220 if !principal.can_write() {
221 return write_denied();
222 }
223 if let Err(error) = repo_write(services, principal.uid, &args.space, &args.repo).await {
224 return error;
225 }
226 match services
227 .git
228 .rename_branch(
229 args.space.trim().to_string(),
230 args.repo.trim().to_string(),
231 args.from,
232 args.to,
233 )
234 .await
235 {
236 Ok(()) => tool_done(),
237 Err(error) => tool_error(error),
238 }
239}
240
241pub(crate) async fn branch_delete(
242 services: &McpServices,
243 principal: &Principal,
244 args: args::RepoBranchDeleteArgs,
245) -> CallToolResult {
246 if !principal.can_write() {
247 return write_denied();
248 }
249 if let Err(error) = repo_write(services, principal.uid, &args.space, &args.repo).await {
250 return error;
251 }
252 match services
253 .git
254 .delete_branch(
255 args.space.trim().to_string(),
256 args.repo.trim().to_string(),
257 args.name,
258 args.force,
259 )
260 .await
261 {
262 Ok(()) => tool_done(),
263 Err(error) => tool_error(error),
264 }
265}
266
267pub(crate) async fn branch_set_default(
268 services: &McpServices,
269 principal: &Principal,
270 args: args::RepoBranchDefaultArgs,
271) -> CallToolResult {
272 if !principal.can_write() {
273 return write_denied();
274 }
275 if let Err(error) = repo_write(services, principal.uid, &args.space, &args.repo).await {
276 return error;
277 }
278 match services
279 .git
280 .set_default_branch(
281 args.space.trim().to_string(),
282 args.repo.trim().to_string(),
283 args.name,
284 )
285 .await
286 {
287 Ok(()) => tool_done(),
288 Err(error) => tool_error(error),
289 }
290}
291
292pub(crate) async fn tag_list(
293 services: &McpServices,
294 principal: &Principal,
295 args: args::RepoRefArgs,
296) -> CallToolResult {
297 if let Err(error) = repo_read(services, principal.uid, &args.space, &args.repo).await {
298 return error;
299 }
300 match services
301 .git
302 .list_tags(args.space.trim().to_string(), args.repo.trim().to_string())
303 .await
304 {
305 Ok(result) => tool_ok(&result),
306 Err(error) => tool_error(error),
307 }
308}
309
310pub(crate) async fn tag_get(
311 services: &McpServices,
312 principal: &Principal,
313 args: args::RepoTagGetArgs,
314) -> CallToolResult {
315 if let Err(error) = repo_read(services, principal.uid, &args.space, &args.repo).await {
316 return error;
317 }
318 match services
319 .git
320 .get_tag(
321 args.space.trim().to_string(),
322 args.repo.trim().to_string(),
323 args.tag,
324 )
325 .await
326 {
327 Ok(result) => tool_ok(&result),
328 Err(error) => tool_error(error),
329 }
330}
331
332pub(crate) async fn tag_create(
333 services: &McpServices,
334 principal: &Principal,
335 args: args::RepoTagCreateArgs,
336) -> CallToolResult {
337 if !principal.can_write() {
338 return write_denied();
339 }
340 if let Err(error) = repo_write(services, principal.uid, &args.space, &args.repo).await {
341 return error;
342 }
343 let cmd = CreateTagCmd {
344 name: args.name,
345 target: args.target,
346 message: args.message,
347 annotated: args.annotated,
348 };
349 match services
350 .git
351 .create_tag(
352 args.space.trim().to_string(),
353 args.repo.trim().to_string(),
354 cmd,
355 )
356 .await
357 {
358 Ok(result) => tool_ok(&result),
359 Err(error) => tool_error(error),
360 }
361}
362
363pub(crate) async fn tag_delete(
364 services: &McpServices,
365 principal: &Principal,
366 args: args::RepoTagDeleteArgs,
367) -> CallToolResult {
368 if !principal.can_write() {
369 return write_denied();
370 }
371 if let Err(error) = repo_write(services, principal.uid, &args.space, &args.repo).await {
372 return error;
373 }
374 match services
375 .git
376 .delete_tag(
377 args.space.trim().to_string(),
378 args.repo.trim().to_string(),
379 args.name,
380 )
381 .await
382 {
383 Ok(()) => tool_done(),
384 Err(error) => tool_error(error),
385 }
386}
387
388pub(crate) async fn commit_list(
389 services: &McpServices,
390 principal: &Principal,
391 args: args::RepoCommitListArgs,
392) -> CallToolResult {
393 if let Err(error) = repo_read(services, principal.uid, &args.space, &args.repo).await {
394 return error;
395 }
396 let cmd = ListCommitsCmd {
397 revision: args.revision.unwrap_or_else(|| "HEAD".to_string()),
398 path: args.path.map(PathBuf::from),
399 max_count: args.max_count,
400 skip: args.skip,
401 since: None,
402 until: None,
403 first_parent: args.first_parent,
404 };
405 match services
406 .git
407 .list_commits(
408 args.space.trim().to_string(),
409 args.repo.trim().to_string(),
410 cmd,
411 Some(principal.uid),
412 )
413 .await
414 {
415 Ok(result) => tool_ok(&result),
416 Err(error) => tool_error(error),
417 }
418}
419
420pub(crate) async fn commit_get(
421 services: &McpServices,
422 principal: &Principal,
423 args: args::RepoCommitGetArgs,
424) -> CallToolResult {
425 if let Err(error) = repo_read(services, principal.uid, &args.space, &args.repo).await {
426 return error;
427 }
428 match services
429 .git
430 .get_commit(
431 args.space.trim().to_string(),
432 args.repo.trim().to_string(),
433 args.revision,
434 )
435 .await
436 {
437 Ok(result) => tool_ok(&result),
438 Err(error) => tool_error(error),
439 }
440}
441
442pub(crate) async fn tree(
443 services: &McpServices,
444 principal: &Principal,
445 args: args::RepoTreeArgs,
446) -> CallToolResult {
447 if let Err(error) = repo_read(services, principal.uid, &args.space, &args.repo).await {
448 return error;
449 }
450 let revision = args.revision.unwrap_or_else(|| "HEAD".to_string());
451 let space = args.space.trim().to_string();
452 let repo = args.repo.trim().to_string();
453 let result = if args.recurse {
454 services
455 .git
456 .tree_walk(space, repo, revision, true, true)
457 .await
458 } else {
459 services
460 .git
461 .tree_read(
462 space,
463 repo,
464 revision,
465 PathBuf::from(args.path.unwrap_or_default()),
466 )
467 .await
468 };
469 match result {
470 Ok(entries) => tool_ok(&entries),
471 Err(error) => tool_error(error),
472 }
473}
474
475pub(crate) async fn file_get(
476 services: &McpServices,
477 principal: &Principal,
478 args: args::RepoFileArgs,
479) -> CallToolResult {
480 if let Err(error) = repo_read(services, principal.uid, &args.space, &args.repo).await {
481 return error;
482 }
483 let revision = args.revision.unwrap_or_else(|| "HEAD".to_string());
484 let space = args.space.trim().to_string();
485 let repo = args.repo.trim().to_string();
486 let entry = match services
487 .git
488 .tree_resolve_path(space.clone(), repo.clone(), revision, args.path.clone())
489 .await
490 {
491 Ok(entry) => entry,
492 Err(error) => return tool_error(error),
493 };
494 if entry.mode == TreeEntryMode::Tree {
495 return crate::error::tool_message("目标路径是目录,请使用 repo_tree 工具");
496 }
497 let blob = match services
498 .git
499 .blob_content(space, repo, entry.oid.to_string())
500 .await
501 {
502 Ok(blob) => blob,
503 Err(error) => return tool_error(error),
504 };
505 let text = String::from_utf8(blob.data.clone()).ok();
506 let content = match &text {
507 Some(text) => json!({ "encoding": "utf-8", "text": text }),
508 None => json!({
509 "encoding": "base64",
510 "base64": base64::engine::general_purpose::STANDARD.encode(&blob.data),
511 }),
512 };
513 tool_ok(&json!({
514 "path": args.path,
515 "oid": blob.oid,
516 "size": blob.size,
517 "is_binary": blob.is_binary,
518 "truncated": blob.truncated,
519 "content": content,
520 }))
521}
522
523pub(crate) async fn release_list(
524 services: &McpServices,
525 principal: &Principal,
526 args: args::RepoReleaseListArgs,
527) -> CallToolResult {
528 if let Err(error) = repo_read(services, principal.uid, &args.space, &args.repo).await {
529 return error;
530 }
531 let query = ListReleasesQuery {
532 limit: args.limit,
533 offset: args.offset,
534 draft: args.draft,
535 };
536 match services
537 .git
538 .list_releases(
539 args.space.trim().to_string(),
540 args.repo.trim().to_string(),
541 query,
542 )
543 .await
544 {
545 Ok(result) => tool_ok(&result),
546 Err(error) => tool_error(error),
547 }
548}
549
550pub(crate) async fn release_get(
551 services: &McpServices,
552 principal: &Principal,
553 args: args::RepoReleaseGetArgs,
554) -> CallToolResult {
555 if let Err(error) = repo_read(services, principal.uid, &args.space, &args.repo).await {
556 return error;
557 }
558 match services
559 .git
560 .get_release(
561 args.space.trim().to_string(),
562 args.repo.trim().to_string(),
563 args.tag,
564 )
565 .await
566 {
567 Ok(result) => tool_ok(&result),
568 Err(error) => tool_error(error),
569 }
570}
571
572pub(crate) async fn release_create(
573 services: &McpServices,
574 principal: &Principal,
575 args: args::RepoReleaseCreateArgs,
576) -> CallToolResult {
577 if !principal.can_write() {
578 return write_denied();
579 }
580 if let Err(error) = repo_write(services, principal.uid, &args.space, &args.repo).await {
581 return error;
582 }
583 let cmd = CreateReleaseCmd {
584 tag_name: args.tag_name,
585 target: args.target,
586 title: args.title,
587 content: args.content,
588 is_draft: args.is_draft,
589 is_prerelease: args.is_prerelease,
590 };
591 match services
592 .git
593 .create_release(
594 args.space.trim().to_string(),
595 args.repo.trim().to_string(),
596 principal.uid,
597 cmd,
598 )
599 .await
600 {
601 Ok(result) => tool_ok(&result),
602 Err(error) => tool_error(error),
603 }
604}
605
606pub(crate) async fn release_update(
607 services: &McpServices,
608 principal: &Principal,
609 args: args::RepoReleaseUpdateArgs,
610) -> CallToolResult {
611 if !principal.can_write() {
612 return write_denied();
613 }
614 let ctx = match repo_write(services, principal.uid, &args.space, &args.repo).await {
615 Ok(ctx) => ctx,
616 Err(error) => return error,
617 };
618 let admin =
619 git::share::access::authorize_repo_access(&services.git, principal.uid, &ctx, true, true)
620 .await
621 .is_ok();
622 let cmd = UpdateReleaseCmd {
623 title: args.title,
624 content: args.content,
625 is_draft: args.is_draft,
626 is_prerelease: args.is_prerelease,
627 };
628 match services
629 .git
630 .update_release(
631 args.space.trim().to_string(),
632 args.repo.trim().to_string(),
633 args.tag,
634 principal.uid,
635 admin,
636 cmd,
637 )
638 .await
639 {
640 Ok(result) => tool_ok(&result),
641 Err(error) => tool_error(error),
642 }
643}
644
645pub(crate) async fn release_delete(
646 services: &McpServices,
647 principal: &Principal,
648 args: args::RepoReleaseDeleteArgs,
649) -> CallToolResult {
650 if !principal.can_write() {
651 return write_denied();
652 }
653 let ctx = match repo_write(services, principal.uid, &args.space, &args.repo).await {
654 Ok(ctx) => ctx,
655 Err(error) => return error,
656 };
657 let admin =
658 git::share::access::authorize_repo_access(&services.git, principal.uid, &ctx, true, true)
659 .await
660 .is_ok();
661 match services
662 .git
663 .delete_release(
664 args.space.trim().to_string(),
665 args.repo.trim().to_string(),
666 args.tag,
667 principal.uid,
668 admin,
669 )
670 .await
671 {
672 Ok(()) => tool_done(),
673 Err(error) => tool_error(error),
674 }
675}
676
677pub(crate) async fn topic_list(
678 services: &McpServices,
679 principal: &Principal,
680 args: args::RepoTopicArgs,
681) -> CallToolResult {
682 let ctx = match repo_read(services, principal.uid, &args.space, &args.repo).await {
683 Ok(ctx) => ctx,
684 Err(error) => return error,
685 };
686 match services.git.list_repo_topics(&ctx).await {
687 Ok(result) => tool_ok(&result),
688 Err(error) => tool_error(error),
689 }
690}
691
692pub(crate) async fn topic_add(
693 services: &McpServices,
694 principal: &Principal,
695 args: args::RepoTopicChangeArgs,
696) -> CallToolResult {
697 if !principal.can_write() {
698 return write_denied();
699 }
700 let ctx = match repo_write(services, principal.uid, &args.space, &args.repo).await {
701 Ok(ctx) => ctx,
702 Err(error) => return error,
703 };
704 match services
705 .git
706 .add_topic_to_repo(&ctx, args.topic.trim())
707 .await
708 {
709 Ok(result) => tool_ok(&result),
710 Err(error) => tool_error(error),
711 }
712}
713
714pub(crate) async fn topic_remove(
715 services: &McpServices,
716 principal: &Principal,
717 args: args::RepoTopicChangeArgs,
718) -> CallToolResult {
719 if !principal.can_write() {
720 return write_denied();
721 }
722 let ctx = match repo_write(services, principal.uid, &args.space, &args.repo).await {
723 Ok(ctx) => ctx,
724 Err(error) => return error,
725 };
726 match services
727 .git
728 .remove_topic_from_repo(&ctx, args.topic.trim())
729 .await
730 {
731 Ok(()) => tool_done(),
732 Err(error) => tool_error(error),
733 }
734}
735
736pub(crate) async fn star(
737 services: &McpServices,
738 principal: &Principal,
739 args: args::RepoRefArgs,
740) -> CallToolResult {
741 if !principal.can_write() {
742 return write_denied();
743 }
744 let ctx = match repo_read(services, principal.uid, &args.space, &args.repo).await {
745 Ok(ctx) => ctx,
746 Err(error) => return error,
747 };
748 match services.git.star_repo(&ctx, principal.uid).await {
749 Ok(result) => tool_ok(&result),
750 Err(error) => tool_error(error),
751 }
752}
753
754pub(crate) async fn unstar(
755 services: &McpServices,
756 principal: &Principal,
757 args: args::RepoRefArgs,
758) -> CallToolResult {
759 if !principal.can_write() {
760 return write_denied();
761 }
762 let ctx = match repo_read(services, principal.uid, &args.space, &args.repo).await {
763 Ok(ctx) => ctx,
764 Err(error) => return error,
765 };
766 match services.git.unstar_repo(&ctx, principal.uid).await {
767 Ok(result) => tool_ok(&result),
768 Err(error) => tool_error(error),
769 }
770}
771
772pub(crate) async fn watch(
773 services: &McpServices,
774 principal: &Principal,
775 args: args::RepoWatchArgs,
776) -> CallToolResult {
777 if !principal.can_write() {
778 return write_denied();
779 }
780 let mode = match parse_enum::<WatchMode>(&args.mode) {
781 Ok(mode) => mode,
782 Err(error) => return error,
783 };
784 let ctx = match repo_read(services, principal.uid, &args.space, &args.repo).await {
785 Ok(ctx) => ctx,
786 Err(error) => return error,
787 };
788 match services
789 .git
790 .upsert_watch(&ctx, principal.uid, UpsertWatchCmd { mode })
791 .await
792 {
793 Ok(result) => tool_ok(&result),
794 Err(error) => tool_error(error),
795 }
796}
797
798pub(crate) async fn stargazers(
799 services: &McpServices,
800 principal: &Principal,
801 args: args::RepoStargazerListArgs,
802) -> CallToolResult {
803 let ctx = match repo_read(services, principal.uid, &args.space, &args.repo).await {
804 Ok(ctx) => ctx,
805 Err(error) => return error,
806 };
807 let limit = args.limit.unwrap_or(50).clamp(1, 100);
808 let offset = args.offset.unwrap_or(0);
809 match services.git.list_stargazers(&ctx, limit, offset).await {
810 Ok(result) => tool_ok(&result),
811 Err(error) => tool_error(error),
812 }
813}
814
815pub(crate) async fn watchers(
816 services: &McpServices,
817 principal: &Principal,
818 args: args::RepoWatcherListArgs,
819) -> CallToolResult {
820 let mode = match args.mode.as_deref() {
821 Some(raw) => match parse_enum::<WatchMode>(raw) {
822 Ok(mode) => Some(mode),
823 Err(error) => return error,
824 },
825 None => None,
826 };
827 let ctx = match repo_read(services, principal.uid, &args.space, &args.repo).await {
828 Ok(ctx) => ctx,
829 Err(error) => return error,
830 };
831 let limit = args.limit.unwrap_or(50).clamp(1, 100);
832 let offset = args.offset.unwrap_or(0);
833 match services.git.list_watchers(&ctx, mode, limit, offset).await {
834 Ok(result) => tool_ok(&result),
835 Err(error) => tool_error(error),
836 }
837}
Deleted lib/mcp-api/src/tools/space.rs

Diff too large to display.

Deleted lib/mcp-api/src/auth.rs
@@ -1,198 +0,0 @@
1use std::net::SocketAddr;
2
3use axum::Json;
4use axum::body::Body;
5use axum::extract::{ConnectInfo, State};
6use axum::http::{HeaderMap, HeaderValue, Request, StatusCode, header};
7use axum::middleware::Next;
8use axum::response::{IntoResponse, Response};
9use base64::Engine as _;
10use models::users::access_scope::AccessKeyScope;
11use rmcp::ErrorData;
12use rmcp::RoleServer;
13use rmcp::service::RequestContext;
14use serde_json::json;
15use uuid::Uuid;
16
17use crate::{McpRuntime, net};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum PrincipalSource {
21 UserKey,
22 InternalKey,
23 SpaceToken,
24}
25
26#[derive(Clone)]
27pub struct TemporaryKey {
28 pub token: String,
29 pub token_id: Uuid,
30 pub issued_by: Option<String>,
31 pub expires_at: time::OffsetDateTime,
32}
33
34impl std::fmt::Debug for TemporaryKey {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 f.debug_struct("TemporaryKey")
37 .field("token", &"<redacted>")
38 .field("token_id", &self.token_id)
39 .field("issued_by", &self.issued_by)
40 .field("expires_at", &self.expires_at)
41 .finish()
42 }
43}
44
45#[derive(Debug, Clone)]
46pub struct Principal {
47 pub uid: i64,
48 pub scope: AccessKeyScope,
49 pub full_access: bool,
50 pub source: PrincipalSource,
51 /// `Some((space_id, lower_name))` for temporary space top-level tokens:
52 /// every tool call must stay inside that single space.
53 pub bound_space: Option<(Uuid, String)>,
54 /// `Some` only for temporary space top-level tokens; carries the presented
55 /// token and its allocation details so `me_get` can hand it back.
56 pub temporary_key: Option<TemporaryKey>,
57}
58
59impl Principal {
60 pub fn can_write(&self) -> bool {
61 self.full_access || self.scope.allows(true)
62 }
63
64 pub(crate) fn from_ctx(ctx: &RequestContext<RoleServer>) -> Result<Self, ErrorData> {
65 let parts = ctx
66 .extensions
67 .get::<axum::http::request::Parts>()
68 .ok_or_else(|| ErrorData::internal_error("missing http request parts", None))?;
69 parts
70 .extensions
71 .get::<Principal>()
72 .cloned()
73 .ok_or_else(|| ErrorData::invalid_request("unauthenticated", None))
74 }
75}
76
77pub(crate) async fn auth_middleware(
78 State(runtime): State<McpRuntime>,
79 ConnectInfo(peer): ConnectInfo<SocketAddr>,
80 mut request: Request<Body>,
81 next: Next,
82) -> Response {
83 let Some(token) = extract_token(request.headers()) else {
84 return unauthorized("缺少访问密钥");
85 };
86 let peer = Some(peer);
87 let client_ip = net::resolve_client_ip(peer, request.headers(), runtime.trusted_proxies());
88
89 let principal = if let Some(name) = runtime.match_internal_key(&token) {
90 if !runtime.is_internal_ip(client_ip) {
91 tracing::warn!(key = %name, "internal mcp key rejected from a non-internal source");
92 return forbidden("内部访问密钥仅允许内网来源使用");
93 }
94 let Some(uid) = runtime.system_uid() else {
95 return unavailable("内部系统账号尚未就绪");
96 };
97 Principal {
98 uid,
99 scope: AccessKeyScope::Write,
100 full_access: true,
101 source: PrincipalSource::InternalKey,
102 bound_space: None,
103 temporary_key: None,
104 }
105 } else if export::space_token::looks_like(&token) {
106 match export::space_token::verify(&runtime.services().state.redis, &token).await {
107 Ok(Some(record)) => {
108 // The bot passes every owner-level authorization check; the
109 // space binding below is what keeps it scoped to one space.
110 export::authz::register_space_bot(record.bot_uid);
111 let expires_at = time::OffsetDateTime::from_unix_timestamp(record.expires_at_unix)
112 .unwrap_or_else(|_| time::OffsetDateTime::now_utc());
113 Principal {
114 uid: record.bot_uid,
115 scope: AccessKeyScope::Write,
116 full_access: true,
117 source: PrincipalSource::SpaceToken,
118 bound_space: Some((record.space_id, record.space_name)),
119 temporary_key: Some(TemporaryKey {
120 token: token.clone(),
121 token_id: record.token_id,
122 issued_by: record.issued_by,
123 expires_at,
124 }),
125 }
126 }
127 Ok(None) => return unauthorized("访问密钥无效或已过期"),
128 Err(error) => {
129 tracing::warn!(%error, "mcp space token verification failed");
130 return unavailable("鉴权服务暂不可用");
131 }
132 }
133 } else {
134 match runtime.services().auth.verify_access_token(&token).await {
135 Ok(Some(verified)) => Principal {
136 uid: verified.user_id,
137 scope: verified.scope,
138 full_access: false,
139 source: PrincipalSource::UserKey,
140 bound_space: None,
141 temporary_key: None,
142 },
143 Ok(None) => return unauthorized("访问密钥无效或已过期"),
144 Err(error) => {
145 tracing::warn!(%error, "mcp access key verification failed");
146 return unavailable("鉴权服务暂不可用");
147 }
148 }
149 };
150
151 request.extensions_mut().insert(principal);
152 next.run(request).await
153}
154
155fn extract_token(headers: &HeaderMap) -> Option<String> {
156 let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
157 if let Some(rest) = value
158 .strip_prefix("Bearer ")
159 .or_else(|| value.strip_prefix("bearer "))
160 {
161 let token = rest.trim();
162 return (!token.is_empty()).then(|| token.to_string());
163 }
164 if let Some(rest) = value.strip_prefix("Basic ") {
165 let decoded = base64::engine::general_purpose::STANDARD
166 .decode(rest.trim())
167 .ok()?;
168 let credentials = String::from_utf8(decoded).ok()?;
169 return credentials
170 .split_once(':')
171 .map(|(_, password)| password.to_string());
172 }
173 None
174}
175
176fn error_response(status: StatusCode, message: &str) -> Response {
177 let body = Json(json!({
178 "error": status.canonical_reason().unwrap_or("error").to_lowercase(),
179 "message": message,
180 }));
181 (status, body).into_response()
182}
183
184fn unauthorized(message: &str) -> Response {
185 let mut response = error_response(StatusCode::UNAUTHORIZED, message);
186 response
187 .headers_mut()
188 .insert(header::WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"));
189 response
190}
191
192fn forbidden(message: &str) -> Response {
193 error_response(StatusCode::FORBIDDEN, message)
194}
195
196fn unavailable(message: &str) -> Response {
197 error_response(StatusCode::SERVICE_UNAVAILABLE, message)
198}
Deleted lib/mcp-api/src/catalog.rs
@@ -1,38 +0,0 @@
1use serde::Serialize;
2
3use crate::McpServer;
4
5#[derive(Debug, Clone, Serialize)]
6pub struct McpToolInfo {
7 pub name: String,
8 pub group: String,
9 pub description: String,
10 pub read_only: bool,
11}
12
13pub fn tools() -> Vec<McpToolInfo> {
14 let router = McpServer::tool_router();
15 let mut tools: Vec<McpToolInfo> = router
16 .list_all()
17 .into_iter()
18 .map(|tool| {
19 let name = tool.name.to_string();
20 let group = name.split('_').next().unwrap_or("misc").to_string();
21 McpToolInfo {
22 group,
23 read_only: tool
24 .annotations
25 .as_ref()
26 .and_then(|annotations| annotations.read_only_hint)
27 .unwrap_or(false),
28 description: tool
29 .description
30 .map(|description| description.to_string())
31 .unwrap_or_default(),
32 name,
33 }
34 })
35 .collect();
36 tools.sort_by(|a, b| a.name.cmp(&b.name));
37 tools
38}
Deleted lib/mcp-api/src/error.rs
@@ -1,24 +0,0 @@
1use rmcp::model::{CallToolResult, ContentBlock};
2
3pub fn tool_ok<T: serde::Serialize>(value: &T) -> CallToolResult {
4 match serde_json::to_string_pretty(value) {
5 Ok(text) => CallToolResult::success(vec![ContentBlock::text(text)]),
6 Err(error) => tool_message(format!("序列化结果失败: {error}")),
7 }
8}
9
10pub fn tool_done() -> CallToolResult {
11 tool_ok(&serde_json::json!({ "ok": true }))
12}
13
14pub fn tool_message(message: impl Into<String>) -> CallToolResult {
15 CallToolResult::error(vec![ContentBlock::text(message.into())])
16}
17
18pub fn tool_error(error: impl std::fmt::Display) -> CallToolResult {
19 tool_message(error.to_string())
20}
21
22pub fn write_denied() -> CallToolResult {
23 tool_message("当前访问密钥只有只读权限,无法执行写操作")
24}
Deleted lib/mcp-api/src/lib.rs
@@ -1,154 +0,0 @@
1pub mod catalog;
2pub mod error;
3pub mod settings;
4pub mod space_token;
5
6mod auth;
7mod net;
8mod server;
9mod services;
10mod tools;
11
12pub use server::McpServer;
13pub use services::{McpServices, SharedServices};
14
15use std::net::IpAddr;
16use std::sync::Arc;
17
18use axum::Router;
19use axum::middleware;
20use export::AppState;
21use ipnet::IpNet;
22use rmcp::transport::StreamableHttpServerConfig;
23use rmcp::transport::StreamableHttpService;
24use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
25use sha2::{Digest, Sha256};
26use uuid::Uuid;
27
28struct InternalKey {
29 name: String,
30 hash: String,
31}
32
33pub(crate) struct RuntimeInner {
34 pub(crate) services: SharedServices,
35 internal_keys: Vec<InternalKey>,
36 internal_cidrs: Vec<IpNet>,
37 trusted_proxies: Vec<IpNet>,
38 system_uid: Option<i64>,
39}
40
41#[derive(Clone)]
42pub(crate) struct McpRuntime(Arc<RuntimeInner>);
43
44impl McpRuntime {
45 pub(crate) fn services(&self) -> &SharedServices {
46 &self.0.services
47 }
48
49 pub(crate) fn system_uid(&self) -> Option<i64> {
50 self.0.system_uid
51 }
52
53 pub(crate) fn trusted_proxies(&self) -> &[IpNet] {
54 &self.0.trusted_proxies
55 }
56
57 pub(crate) fn match_internal_key(&self, token: &str) -> Option<&str> {
58 let hash = sha256_hex(token.as_bytes());
59 self.0
60 .internal_keys
61 .iter()
62 .find(|key| constant_time_eq(hash.as_bytes(), key.hash.as_bytes()))
63 .map(|key| key.name.as_str())
64 }
65
66 pub(crate) fn is_internal_ip(&self, ip: Option<IpAddr>) -> bool {
67 ip.is_some_and(|ip| self.0.internal_cidrs.iter().any(|net| net.contains(&ip)))
68 }
69}
70
71pub async fn router(state: AppState) -> Router {
72 let config = state.config.mcp.clone();
73 if !config.enabled() {
74 return Router::new();
75 }
76
77 let system_uid = match agent::bot::resolve_bot_user(&state, &state.config.ai)
78 .await
79 {
80 Ok(uid) => {
81 export::authz::set_system_uid(uid);
82 Some(uid)
83 }
84 Err(error) => {
85 tracing::warn!(%error, "failed to resolve the platform bot user for mcp internal keys");
86 None
87 }
88 };
89
90 let internal_keys = config
91 .internal_keys
92 .iter()
93 .filter(|key| key.enabled())
94 .map(|key| InternalKey {
95 name: key.name.clone(),
96 hash: key
97 .token_hash
98 .as_ref()
99 .map(|hash| hash.trim().to_lowercase())
100 .unwrap_or_else(|| sha256_hex(key.token.as_deref().unwrap_or_default().as_bytes())),
101 })
102 .collect();
103
104 let runtime = McpRuntime(Arc::new(RuntimeInner {
105 services: Arc::new(McpServices::new(state)),
106 internal_keys,
107 internal_cidrs: config.parsed_internal_cidrs(),
108 trusted_proxies: config.parsed_trusted_proxies(),
109 system_uid,
110 }));
111
112 let mut http_config = StreamableHttpServerConfig::default()
113 .with_legacy_session_mode(false)
114 .with_json_response(true);
115 if !config.allowed_hosts.is_empty() {
116 http_config = http_config.with_allowed_hosts(config.allowed_hosts.clone());
117 }
118
119 let services = runtime.services().clone();
120 let mcp_service = StreamableHttpService::new(
121 move || Ok(McpServer::new(services.clone())),
122 LocalSessionManager::default().into(),
123 http_config,
124 );
125
126 tracing::info!(
127 internal_keys = runtime.0.internal_keys.len(),
128 internal_cidrs = runtime.0.internal_cidrs.len(),
129 "mcp endpoint ready"
130 );
131
132 Router::new()
133 .fallback_service(mcp_service)
134 .layer(middleware::from_fn_with_state(
135 runtime,
136 auth::auth_middleware,
137 ))
138}
139
140pub(crate) fn sha256_hex(data: &[u8]) -> String {
141 let digest = Sha256::digest(data);
142 digest.iter().map(|byte| format!("{byte:02x}")).collect()
143}
144
145fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
146 if a.len() != b.len() {
147 return false;
148 }
149 let mut diff: u8 = 0;
150 for (x, y) in a.iter().zip(b.iter()) {
151 diff |= x ^ y;
152 }
153 diff == 0
154}
Deleted lib/mcp-api/src/net.rs
@@ -1,40 +0,0 @@
1use std::net::{IpAddr, SocketAddr};
2
3use axum::http::HeaderMap;
4use ipnet::IpNet;
5
6pub fn resolve_client_ip(
7 peer: Option<SocketAddr>,
8 headers: &HeaderMap,
9 trusted_proxies: &[IpNet],
10) -> Option<IpAddr> {
11 match peer {
12 Some(addr) if trusted_proxies.iter().any(|net| net.contains(&addr.ip())) => {
13 forwarded_ip(headers)
14 }
15 Some(addr) => Some(addr.ip()),
16 None => None,
17 }
18}
19
20fn forwarded_ip(headers: &HeaderMap) -> Option<IpAddr> {
21 if let Some(value) = headers
22 .get("x-forwarded-for")
23 .and_then(|value| value.to_str().ok())
24 && let Some(first) = value
25 .split(',')
26 .map(str::trim)
27 .find(|item| !item.is_empty())
28 && let Ok(ip) = first.parse::<IpAddr>()
29 {
30 return Some(ip);
31 }
32 if let Some(value) = headers
33 .get("x-real-ip")
34 .and_then(|value| value.to_str().ok())
35 && let Ok(ip) = value.trim().parse::<IpAddr>()
36 {
37 return Some(ip);
38 }
39 None
40}
Deleted lib/mcp-api/src/server.rs
@@ -1,117 +0,0 @@
1use rmcp::ErrorData;
2use rmcp::ServerHandler;
3use rmcp::handler::server::router::tool::ToolRouter;
4use rmcp::handler::server::tool::ToolCallContext;
5use rmcp::model::{
6 CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ListToolsResult,
7 PaginatedRequestParams,
8};
9use rmcp::service::RequestContext;
10use rmcp::{RoleServer, tool_handler};
11
12use crate::auth::Principal;
13use crate::services::SharedServices;
14use crate::settings;
15
16#[derive(Clone)]
17pub struct McpServer {
18 pub(crate) services: SharedServices,
19 pub(crate) tool_router: ToolRouter<Self>,
20}
21
22impl McpServer {
23 pub fn new(services: SharedServices) -> Self {
24 Self {
25 services,
26 tool_router: Self::tool_router(),
27 }
28 }
29
30 pub(crate) fn principal(
31 &self,
32 ctx: &RequestContext<RoleServer>,
33 ) -> Result<Principal, ErrorData> {
34 Principal::from_ctx(ctx)
35 }
36}
37
38#[tool_handler(
39 name = "gitdata-mcp",
40 version = "1.0.0",
41 instructions = "GitData 平台的 MCP 工具集。调用结果遵循调用者访问密钥的权限:只读密钥只能调用只读工具,空间/仓库内的操作受成员角色与仓库 ACL 限制。"
42)]
43impl ServerHandler for McpServer {
44 async fn list_tools(
45 &self,
46 _request: Option<PaginatedRequestParams>,
47 context: RequestContext<RoleServer>,
48 ) -> Result<ListToolsResult, ErrorData> {
49 let principal = self.principal(&context)?;
50 let disabled = settings::read_disabled(&self.services.state).await;
51 let mut tools = self.tool_router.list_all();
52 tools.retain(|tool| !disabled.contains(tool.name.as_ref()));
53 if !principal.can_write() {
54 tools.retain(|tool| {
55 tool.annotations
56 .as_ref()
57 .and_then(|annotations| annotations.read_only_hint)
58 .unwrap_or(false)
59 });
60 }
61 Ok(ListToolsResult {
62 tools,
63 ..Default::default()
64 })
65 }
66
67 async fn call_tool(
68 &self,
69 request: CallToolRequestParams,
70 context: RequestContext<RoleServer>,
71 ) -> Result<CallToolResponse, ErrorData> {
72 let principal = self.principal(&context)?;
73 let name = request.name.to_string();
74 if let Some((_, bound_name)) = &principal.bound_space
75 && !bound_space_allows(&request, bound_name)
76 {
77 return Ok(CallToolResult::error(vec![ContentBlock::text(format!(
78 "该访问密钥仅允许操作 space {bound_name}"
79 ))])
80 .into());
81 }
82 let disabled = settings::read_disabled(&self.services.state).await;
83 if disabled.contains(&name) {
84 return Ok(CallToolResult::error(vec![ContentBlock::text(format!(
85 "工具 {name} 已被管理员停用"
86 ))])
87 .into());
88 }
89 let call = ToolCallContext::new(self, request, context);
90 self.tool_router.call(call).await
91 }
92}
93
94/// Temporary space tokens may only reach tools that address their single bound
95/// space. `me_get` is the one space-less tool they may use; everything else
96/// must carry a top-level `space`/`space_name` argument equal to the binding.
97fn bound_space_allows(request: &CallToolRequestParams, bound_name: &str) -> bool {
98 if request.name.as_ref() == "me_get" {
99 return true;
100 }
101 let Some(arguments) = request.arguments.as_ref() else {
102 return false;
103 };
104 let mut matched = false;
105 for key in ["space", "space_name"] {
106 if let Some(value) = arguments.get(key) {
107 let Some(value) = value.as_str() else {
108 return false;
109 };
110 if !value.trim().eq_ignore_ascii_case(bound_name) {
111 return false;
112 }
113 matched = true;
114 }
115 }
116 matched
117}
Deleted lib/mcp-api/src/services.rs
@@ -1,30 +0,0 @@
1use std::sync::Arc;
2
3use export::AppState;
4
5#[derive(Clone)]
6pub struct McpServices {
7 pub state: AppState,
8 pub auth: auth::services::AuthService,
9 pub user: user::services::service::UserService,
10 pub space: space::services::service::SpaceService,
11 pub git: git::service::GitService,
12 pub issues: issues::services::service::IssueService,
13 pub pulls: pulls::services::service::PullService,
14}
15
16impl McpServices {
17 pub fn new(state: AppState) -> Self {
18 Self {
19 auth: auth::services::AuthService::new(state.clone()),
20 user: user::services::service::UserService::new(state.clone()),
21 space: space::services::service::SpaceService::new(state.clone()),
22 git: git::service::GitService::new(state.clone()),
23 issues: issues::services::service::IssueService::new(state.clone()),
24 pulls: pulls::services::service::PullService::new(state.clone()),
25 state,
26 }
27 }
28}
29
30pub type SharedServices = Arc<McpServices>;
Deleted lib/mcp-api/src/settings.rs
@@ -1,102 +0,0 @@
1use std::collections::HashSet;
2use std::time::Duration;
3
4use cache::CacheClient;
5use export::AppState;
6use models::system::system_settings;
7use sea_orm::sea_query::OnConflict;
8use sea_orm::{DatabaseConnection, EntityTrait, Set};
9use serde::{Deserialize, Serialize};
10
11pub const DB_KEY: &str = "mcp.tools";
12pub const CACHE_KEY: &str = "system:setting:mcp.tools";
13const CACHE_TTL: Duration = Duration::from_secs(30);
14
15#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16pub struct McpToolSettings {
17 #[serde(default)]
18 pub disabled: Vec<String>,
19 #[serde(default)]
20 pub updated_by: Option<String>,
21 #[serde(default)]
22 pub updated_at: Option<String>,
23}
24
25pub async fn read(state: &AppState) -> McpToolSettings {
26 let cache = CacheClient::new(state.redis.clone());
27 if let Ok(Some(settings)) = cache.get::<McpToolSettings>(CACHE_KEY).await {
28 return settings;
29 }
30 match system_settings::Entity::find_by_id(DB_KEY.to_string())
31 .one(state.database.read())
32 .await
33 {
34 Ok(Some(row)) => match serde_json::from_str::<McpToolSettings>(&row.value) {
35 Ok(settings) => {
36 let _ = cache.set(CACHE_KEY, &settings, Some(CACHE_TTL)).await;
37 settings
38 }
39 Err(error) => {
40 tracing::warn!(%error, "invalid mcp tool settings payload; treating all tools as enabled");
41 McpToolSettings::default()
42 }
43 },
44 Ok(None) => McpToolSettings::default(),
45 Err(error) => {
46 tracing::warn!(%error, "failed to load mcp tool settings; treating all tools as enabled");
47 McpToolSettings::default()
48 }
49 }
50}
51
52pub async fn read_disabled(state: &AppState) -> HashSet<String> {
53 read(state).await.disabled.into_iter().collect()
54}
55
56pub async fn update(
57 state: &AppState,
58 disabled: Vec<String>,
59 updated_by: Option<String>,
60) -> Result<McpToolSettings, String> {
61 let mut disabled: Vec<String> = disabled
62 .into_iter()
63 .map(|name| name.trim().to_string())
64 .filter(|name| !name.is_empty())
65 .collect();
66 disabled.sort();
67 disabled.dedup();
68 let settings = McpToolSettings {
69 disabled,
70 updated_by,
71 updated_at: Some(time::OffsetDateTime::now_utc().to_string()),
72 };
73 let value = serde_json::to_string(&settings).map_err(|error| error.to_string())?;
74 save(state.database.write(), &value)
75 .await
76 .map_err(|error| error.to_string())?;
77 if let Err(error) = export::redis::delete(&state.redis, CACHE_KEY).await {
78 tracing::warn!(%error, "failed to invalidate mcp tool settings cache");
79 }
80 Ok(settings)
81}
82
83async fn save(db: &DatabaseConnection, value: &str) -> Result<(), sea_orm::DbErr> {
84 let now = time::OffsetDateTime::now_utc();
85 let model = system_settings::ActiveModel {
86 key: Set(DB_KEY.to_string()),
87 value: Set(value.to_string()),
88 updated_at: Set(now),
89 };
90 system_settings::Entity::insert(model)
91 .on_conflict(
92 OnConflict::column(system_settings::Column::Key)
93 .update_columns([
94 system_settings::Column::Value,
95 system_settings::Column::UpdatedAt,
96 ])
97 .to_owned(),
98 )
99 .exec_without_returning(db)
100 .await?;
101 Ok(())
102}
Deleted lib/mcp-api/src/space_token.rs
@@ -1,210 +0,0 @@
1use export::AppState;
2use export::space_token::SpaceTokenRecord;
3use models::space::spaces;
4use models::users::user::{self, ProfileVisibility};
5use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait};
6use serde::{Deserialize, Serialize};
7use space::errors::SpaceError;
8use space::services::members::{MemberActivationDefaults, activate_member_row};
9use space::services::service::SpaceService;
10use space::share::lookup::lookup_space_by_name;
11use time::OffsetDateTime;
12use uuid::Uuid;
13
14/// Library-only service for issuing temporary top-level tokens for a space.
15///
16/// Deliberately not an HTTP route and not an MCP tool: it is only reachable
17/// from Rust code that links this crate (`mcp_api::space_token::issue/revoke`).
18/// The token authenticates as a login-prohibited per-space bot that is treated
19/// as the platform system actor, but the MCP tool dispatch and the Git HTTP
20/// auth entry points both restrict it to the single space the token was issued
21/// for.
22pub const DEFAULT_TTL_SECS: u64 = 15 * 60;
23pub const MIN_TTL_SECS: u64 = 60;
24pub const MAX_TTL_SECS: u64 = 24 * 60 * 60;
25
26const BOT_USERNAME_SUFFIX: &str = "-bot";
27const MAX_USERNAME_LEN: usize = 64;
28
29#[derive(Debug, thiserror::Error)]
30pub enum SpaceTokenError {
31 #[error("space not found")]
32 SpaceNotFound,
33 #[error("username {0:?} is already taken by a non-bot account")]
34 BotNameConflict(String),
35 #[error(transparent)]
36 Space(#[from] SpaceError),
37 #[error(transparent)]
38 Database(#[from] sea_orm::DbErr),
39 #[error(transparent)]
40 Export(#[from] export::ExportError),
41}
42
43impl From<sea_orm::TransactionError<SpaceTokenError>> for SpaceTokenError {
44 fn from(error: sea_orm::TransactionError<SpaceTokenError>) -> Self {
45 match error {
46 sea_orm::TransactionError::Connection(db_err) => Self::Database(db_err),
47 sea_orm::TransactionError::Transaction(error) => error,
48 }
49 }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct IssueSpaceTokenCmd {
54 pub space: String,
55 #[serde(default)]
56 pub ttl_secs: Option<u64>,
57 #[serde(default)]
58 pub issued_by: Option<String>,
59}
60
61#[derive(Debug, Clone, Serialize)]
62pub struct IssuedSpaceToken {
63 pub token: String,
64 pub token_id: Uuid,
65 pub expires_at: OffsetDateTime,
66 pub space_id: Uuid,
67 pub space_name: String,
68 pub bot_uid: i64,
69 pub bot_username: String,
70}
71
72pub async fn issue(
73 state: &AppState,
74 cmd: IssueSpaceTokenCmd,
75) -> Result<IssuedSpaceToken, SpaceTokenError> {
76 let ttl = cmd
77 .ttl_secs
78 .unwrap_or(DEFAULT_TTL_SECS)
79 .clamp(MIN_TTL_SECS, MAX_TTL_SECS);
80 let service = SpaceService::new(state.clone());
81 let space = match lookup_space_by_name(&service, cmd.space.trim()).await {
82 Ok(space) => space,
83 Err(SpaceError::NotFound) => return Err(SpaceTokenError::SpaceNotFound),
84 Err(error) => return Err(SpaceTokenError::Space(error)),
85 };
86
87 let username = bot_username(&space);
88 let bot_uid = resolve_space_bot(state, &space, &username).await?;
89 ensure_space_membership(state, space.id, bot_uid).await?;
90
91 let token_id = Uuid::now_v7();
92 let secret = random_secret();
93 let token = format!("{}{token_id}.{secret}", export::space_token::TOKEN_PREFIX);
94 let now = OffsetDateTime::now_utc();
95 let expires_at = now + time::Duration::seconds(ttl as i64);
96 let record = SpaceTokenRecord {
97 token_id,
98 space_id: space.id,
99 space_name: space.lower_name.clone(),
100 bot_uid,
101 secret_hash: export::space_token::hash_secret(&secret),
102 issued_by: cmd.issued_by.clone(),
103 created_at_unix: now.unix_timestamp(),
104 expires_at_unix: expires_at.unix_timestamp(),
105 };
106 export::space_token::store(&state.redis, &record, ttl).await?;
107
108 tracing::info!(
109 space_id = %space.id,
110 space = %space.lower_name,
111 bot_uid = %bot_uid,
112 token_id = %token_id,
113 ttl_secs = ttl,
114 issued_by = cmd.issued_by.as_deref().unwrap_or("-"),
115 "issued a temporary space top-level token"
116 );
117
118 Ok(IssuedSpaceToken {
119 token,
120 token_id,
121 expires_at,
122 space_id: space.id,
123 space_name: space.lower_name,
124 bot_uid,
125 bot_username: username,
126 })
127}
128
129/// Deletes a token before its TTL elapses; returns whether it still existed.
130pub async fn revoke(state: &AppState, token_id: Uuid) -> Result<bool, SpaceTokenError> {
131 Ok(export::space_token::revoke(&state.redis, token_id).await?)
132}
133
134pub async fn verify(
135 state: &AppState,
136 token: &str,
137) -> Result<Option<SpaceTokenRecord>, SpaceTokenError> {
138 Ok(export::space_token::verify(&state.redis, token).await?)
139}
140
141fn bot_username(space: &spaces::Model) -> String {
142 let base = format!("{}{BOT_USERNAME_SUFFIX}", space.lower_name);
143 if base.len() <= MAX_USERNAME_LEN {
144 return base;
145 }
146 let hash = export::space_token::hash_secret(&space.lower_name);
147 let keep = MAX_USERNAME_LEN - 1 - 8 - BOT_USERNAME_SUFFIX.len();
148 let prefix: String = space.lower_name.chars().take(keep).collect();
149 format!("{prefix}-{}{BOT_USERNAME_SUFFIX}", &hash[..8])
150}
151
152fn random_secret() -> String {
153 let bytes: [u8; 32] = rand::random();
154 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
155}
156
157/// Look up the space bot by its deterministic username and create it only when
158/// it does not exist yet. The account is kept forever, so this is a plain
159/// resolve-or-create; a racing insert falls back to a re-read.
160async fn resolve_space_bot(
161 state: &AppState,
162 space: &spaces::Model,
163 username: &str,
164) -> Result<i64, SpaceTokenError> {
165 let display = format!("{} Bot", space.name);
166 match agent::bot::ensure_bot(state, username, &display, true).await {
167 Ok(uid) => Ok(uid),
168 Err(sea_orm::DbErr::Custom(message))
169 if message.to_lowercase().contains("already taken") =>
170 {
171 Err(SpaceTokenError::BotNameConflict(username.to_string()))
172 }
173 Err(error) => Err(SpaceTokenError::Database(error)),
174 }
175}
176
177/// Idempotent: inserts the membership row on first use, revives a left/removed
178/// row, and bumps `nums_members` exactly once. Skips the MANAGE_MEMBERS / 2FA
179/// checks `add_member` performs because the bot is a system-equivalent actor.
180async fn ensure_space_membership(
181 state: &AppState,
182 space_id: Uuid,
183 bot_uid: i64,
184) -> Result<(), SpaceTokenError> {
185 if let Some(client) = state.kenauth.as_ref() {
186 client
187 .ensure_bot_member(&space_id.to_string(), bot_uid, Vec::new(), Vec::new())
188 .await
189 .map_err(|error| SpaceTokenError::Export(export::ExportError::Connection(error.to_string())))?;
190 }
191 let now = OffsetDateTime::now_utc();
192 state
193 .database
194 .primary()
195 .transaction::<_, bool, SpaceTokenError>(move |txn| {
196 Box::pin(async move {
197 activate_member_row(
198 txn,
199 space_id,
200 bot_uid,
201 now,
202 MemberActivationDefaults::default(),
203 )
204 .await?;
205 Ok(true)
206 })
207 })
208 .await?;
209 Ok(())
210}
Deleted lib/mcp-api/Cargo.toml
@@ -1,33 +0,0 @@
1[package]
2name = "mcp-api"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7agent = { workspace = true }
8auth = { workspace = true }
9cache = { workspace = true }
10config = { workspace = true }
11export = { workspace = true }
12git = { workspace = true }
13gitbaby = { workspace = true }
14issues = { workspace = true }
15models = { workspace = true }
16pulls = { workspace = true }
17space = { workspace = true }
18user = { workspace = true }
19axum = { workspace = true }
20base64 = { workspace = true }
21ipnet = { workspace = true }
22rand = { workspace = true }
23rmcp = { workspace = true }
24sea-orm = { workspace = true }
25serde = { workspace = true, features = ["derive"] }
26serde_json = { workspace = true }
27sha2 = { workspace = true }
28thiserror = { workspace = true }
29time = { workspace = true }
30tokio = { workspace = true }
31tracing = { workspace = true }
32uuid = { workspace = true }
33schemars = { version = "1", features = ["derive", "uuid1"] }
Deleted lib/models/src/agent/agent_item.rs

Diff too large to display.

Deleted lib/models/src/agent/agents.rs

Diff too large to display.

Deleted lib/models/src/agent/mod.rs

Diff too large to display.

Deleted lib/models/src/ai/ai_job.rs

Diff too large to display.

Deleted lib/models/src/ai/ai_review_result.rs

Diff too large to display.

Deleted lib/models/src/ai/mod.rs

Diff too large to display.

Deleted lib/models/src/ai/repo_ai_summary.rs

Diff too large to display.

Renamed lib/models/src/ai/code_comment.rs → lib/models/src/repo/code_comment.rs View lib/models/src/repo/code_comment.rs
@@ -24,13 +24,13 @@
2424 pub created_at: OffsetDateTime,
2525 pub updated_at: Option<OffsetDateTime>,
2626 #[sea_orm(belongs_to, from = "repo_id", to = "id")]
27 pub repo: BelongsTo<super::super::repo::repos::Entity>,
27 pub repo: BelongsTo<crate::repo::repos::Entity>,
2828 #[sea_orm(belongs_to, relation_enum = "Author", from = "author_id", to = "uid")]
29 pub author: BelongsTo<super::super::users::user::Entity>,
29 pub author: BelongsTo<crate::users::user::Entity>,
3030 #[sea_orm(belongs_to, from = "resolved_by", to = "uid")]
31 pub resolver: BelongsTo<Option<super::super::users::user::Entity>>,
31 pub resolver: BelongsTo<Option<crate::users::user::Entity>>,
3232 #[sea_orm(belongs_to, relation_enum = "Parent", from = "parent_id", to = "id")]
33 pub parent: BelongsTo<Option<super::code_comment::Entity>>,
33 pub parent: BelongsTo<Option<crate::repo::code_comment::Entity>>,
3434}
3535
3636impl ActiveModelBehavior for ActiveModel {}
Modified lib/models/src/repo/mod.rs View lib/models/src/repo/mod.rs
@@ -1,5 +1,6 @@
11pub mod repo_branch_protection_policy;
22pub mod repo_branch_protection_whitelists;
3pub mod code_comment;
34pub mod repo_collaborations;
45pub mod repo_license;
56pub mod repo_relation_topic;
Modified lib/models/src/repo/repos.rs View lib/models/src/repo/repos.rs
@@ -48,35 +48,14 @@
4848 pub allow_merge_commits: bool,
4949 #[sea_orm(default_value = "sha1")]
5050 pub object_format: String,
51 /// 仓库级 AI 自动化开关。
52 ///
53 /// 平台配置(`[ai]`)决定「这套部署是否提供 AI 能力」,这三个列决定「这个
54 /// 仓库愿不愿意用」——同一个空间里,活跃仓库想要自动评审,归档脚本仓库不想
55 /// 每次推送都花一次模型调用。两者都开才生效,缺省关闭意味着升级到带此功能
56 /// 的版本不会突然开始产生模型调用与费用。
57 ///
58 /// 两个评审开关缺省为**开**:推送评审与开 PR 评审在加列之前就已由平台开关
59 /// 控制,默认关闭等于静默砍掉既有行为。自动总结是这次新增的能力,缺省关闭,
60 /// 避免升级后开始自动产生模型调用与费用。
61 #[sea_orm(default_value = "true")]
62 pub ai_review_on_push: bool,
63 /// 每次推送后为新的 head 自动生成仓库总结。
64 #[sea_orm(default_value = "false")]
65 pub ai_summary_on_push: bool,
66 /// 打开或更新 PR 时自动投递 AI 评审。
67 #[sea_orm(default_value = "true")]
68 pub ai_review_on_pull: bool,
6951 /// 是否允许在本仓库创建合并请求;关闭后合并请求页签对所有人隐藏,
70 /// 创建接口(含 MCP 工具)拒绝执行。
52 /// 创建接口拒绝执行。
7153 #[sea_orm(default_value = "true")]
7254 pub allow_pull_requests: bool,
7355 /// 流水线页签是否公开展示;关闭后仅空间成员可见。
7456 #[sea_orm(default_value = "true")]
7557 pub pipelines_public: bool,
76 /// AI 总结页签是否公开展示;关闭后仅空间成员可见。
77 #[sea_orm(default_value = "true")]
78 pub ai_summary_public: bool,
79 /// 中间页签顺序(提交/合并请求/分支/流水线/发布/AI 总结);缺省 = 站点默认顺序。
58 /// 中间页签顺序(提交/合并请求/分支/流水线/发布);缺省 = 站点默认顺序。
8059 #[sea_orm(column_type = "JsonBinary")]
8160 pub tab_order: Option<sea_orm::JsonValue>,
8261 #[sea_orm(belongs_to, from = "dir_id", to = "id")]
Modified lib/models/src/space/space_bans.rs View lib/models/src/space/space_bans.rs
@@ -15,7 +15,6 @@
1515 pub created_at: OffsetDateTime,
1616 pub updated_at: OffsetDateTime,
1717 pub expires_at: Option<OffsetDateTime>,
18 pub allow_agent_join: bool,
1918 pub allow_speak: bool,
2019 pub allow_push_repo: bool,
2120 pub allow_comment_any: bool,
Modified lib/models/src/space/space_members.rs View lib/models/src/space/space_members.rs
@@ -73,8 +73,6 @@
7373 pub ban_reason: Option<String>,
7474 pub ban_expires_at: Option<OffsetDateTime>,
7575 #[sea_orm(default_value = "false")]
76 pub ban_allow_agent_join: bool,
77 #[sea_orm(default_value = "false")]
7876 pub ban_allow_speak: bool,
7977 #[sea_orm(default_value = "false")]
8078 pub ban_allow_push_repo: bool,
Modified lib/models/src/lib.rs View lib/models/src/lib.rs
@@ -1,5 +1,3 @@
1pub mod agent;
2pub mod ai;
31pub mod chat;
42pub mod ci;
53pub mod common;
Deleted lib/pulls/src/handlers/ai/get_ai_review.rs

Diff too large to display.

Deleted lib/pulls/src/handlers/ai/list_ai_reviews.rs

Diff too large to display.

Deleted lib/pulls/src/handlers/ai/mod.rs

Diff too large to display.

Deleted lib/pulls/src/handlers/ai/trigger_ai_review.rs

Diff too large to display.

Deleted lib/pulls/src/handlers/pulls/ai_fill_pull.rs

Diff too large to display.

Modified lib/pulls/src/handlers/pulls/mod.rs View lib/pulls/src/handlers/pulls/mod.rs
@@ -1,4 +1,3 @@
1pub mod ai_fill_pull;
21pub mod close_pull;
32pub mod create_pull;
43pub mod delete_pull;
Modified lib/pulls/src/handlers/mod.rs View lib/pulls/src/handlers/mod.rs
@@ -1,4 +1,3 @@
1pub mod ai;
21pub mod assignees;
32pub mod checks;
43pub mod comments;
Deleted lib/pulls/src/services/ai.rs

Diff too large to display.

Deleted lib/pulls/src/services/ai_fill.rs

Diff too large to display.

Modified lib/pulls/src/services/events.rs View lib/pulls/src/services/events.rs
@@ -91,7 +91,6 @@
9191 )
9292 .await?;
9393 self.publish_ci_event(pull, actor, event_type).await;
94 self.publish_ai_event(pull, actor, event_type).await;
9594 Ok(())
9695 }
9796
@@ -104,7 +103,6 @@
104103 event_type: &str,
105104 ) {
106105 self.publish_ci_event(pull, actor, event_type).await;
107 self.publish_ai_event(pull, actor, event_type).await;
108106 }
109107
110108 /// Mirrors CI-relevant pull request activity onto the `gitdata:events`
@@ -159,53 +157,6 @@
159157 {
160158 tracing::warn!(%error, "ci pull request event publish failed");
161159 }
162 }
163
164 /// 打开/更新/重开 PR 时投递 AI 评审任务。
165 ///
166 /// 独立于 `ci.enabled`:只看 `[ai]` 总开关与评审子开关,且投递失败不影响
167 /// 事件写入与接口返回。
168 async fn publish_ai_event(
169 &self,
170 pull: &pull_requests::Model,
171 actor: Option<i64>,
172 event_type: &str,
173 ) {
174 let cfg = &self.config().ai;
175 if !cfg.review_on() {
176 return;
177 }
178 // 仓库级开关:平台提供能力,仓库决定用不用。读不到仓库行时沿用旧行为
179 // (自动评审继续),因为静默丢掉一次评审比多发一次更难排查。
180 let repo_allows = models::repo::repos::Entity::find_by_id(pull.repo_id)
181 .one(self.db_read())
182 .await
183 .ok()
184 .flatten()
185 .map(|row| row.ai_review_on_pull)
186 .unwrap_or(true);
187 if !repo_allows {
188 return;
189 }
190 let trigger = matches!(
191 event_type,
192 EVENT_CREATED | EVENT_HEAD_UPDATED | EVENT_REOPENED
193 );
194 if !trigger {
195 return;
196 }
197 let task = ci_protocol::ai::AgentTask::PullReview {
198 job_id: Uuid::now_v7(),
199 repo_id: pull.repo_id,
200 pull_request_id: pull.id,
201 head_sha: pull.head_sha.clone(),
202 // 评审语言跟随触发者(推送/开 PR 的人)。
203 language: git::sync::actor_language(self.db_read(), actor).await,
204 trace: export::telemetry::traceparent(),
205 request_id: export::telemetry::request_id(),
206 };
207 git::sync::ai_task::submit_ai_task_logged(self.db_write(), &self.state.redis, cfg, &task)
208 .await;
209160 }
210161
211162 async fn pull_space_id(&self, repo_id: Uuid) -> Option<Uuid> {
Modified lib/pulls/src/services/mod.rs View lib/pulls/src/services/mod.rs
@@ -1,5 +1,3 @@
1pub mod ai;
2pub mod ai_fill;
31pub mod approvals;
42pub mod assignees;
53pub mod changes;
Modified lib/pulls/src/openapi.rs View lib/pulls/src/openapi.rs
@@ -8,7 +8,6 @@
88 paths(
99 crate::handlers::pulls::list_pulls::list_pulls,
1010 crate::handlers::pulls::create_pull::create_pull,
11 crate::handlers::pulls::ai_fill_pull::ai_fill_pull,
1211 crate::handlers::pulls::get_pull::get_pull,
1312 crate::handlers::pulls::update_pull::update_pull,
1413 crate::handlers::pulls::delete_pull::delete_pull,
@@ -20,9 +19,6 @@
2019 crate::handlers::pulls::get_pull_patch::get_pull_patch,
2120 crate::handlers::pulls::refresh_pull_summary::refresh_pull_summary,
2221 crate::handlers::pulls::list_pull_events::list_pull_events,
23 crate::handlers::ai::trigger_ai_review::trigger_ai_review,
24 crate::handlers::ai::list_ai_reviews::list_ai_reviews,
25 crate::handlers::ai::get_ai_review::get_ai_review,
2622 crate::handlers::pulls::lock_pull::lock_pull,
2723 crate::handlers::pulls::lock_pull::unlock_pull,
2824 crate::handlers::pulls::update_branch_pull::update_branch_pull,
@@ -58,8 +54,6 @@
5854 components(schemas(
5955 AppResponseData<NoData>,
6056 AppResponseData<crate::services::pulls::PullDto>,
61 AppResponseData<crate::services::ai_fill::AiFillPullResult>,
62 crate::services::ai_fill::AiFillPullCmd,
6357 AppResponseData<crate::services::pulls::PullPage>,
6458 AppResponseData<crate::services::comments::CommentPage>,
6559 AppResponseData<crate::services::events::EventPage>,
@@ -93,10 +87,6 @@
9387 crate::services::changes::PullSummaryDto,
9488 crate::services::events::PullEventDto,
9589 crate::services::reviews::PullReviewDto,
96 crate::services::ai::AiReviewDto,
97 crate::services::ai::AiReviewJobDto,
98 crate::services::ai::AiFindingDto,
99 models::ai::ai_review_result::AiReviewVerdict,
10090 crate::services::reviews::ReviewIntent,
10191 crate::services::reviews::SubmitReviewCmd,
10292 crate::services::reviews::InlineDraft,
Modified lib/pulls/src/routes.rs View lib/pulls/src/routes.rs
@@ -1,9 +1,6 @@
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;
74use crate::handlers::assignees::add_pull_assignee::add_pull_assignee;
85use crate::handlers::assignees::list_pull_assignees::list_pull_assignees;
96use crate::handlers::assignees::remove_pull_assignee::remove_pull_assignee;
@@ -22,7 +19,6 @@
2219use crate::handlers::labels::detach_pull_label::detach_pull_label;
2320use crate::handlers::labels::list_label_options::list_label_options;
2421use crate::handlers::labels::list_pull_labels::list_pull_labels;
25use crate::handlers::pulls::ai_fill_pull::ai_fill_pull;
2622use crate::handlers::pulls::close_pull::close_pull;
2723use crate::handlers::pulls::create_pull::create_pull;
2824use crate::handlers::pulls::delete_pull::delete_pull;
@@ -57,7 +53,6 @@
5753pub fn router() -> Router<crate::services::service::PullService> {
5854 Router::new()
5955 .route(PULLS, get(list_pulls).post(create_pull))
60 .route(&format!("{PULLS}/ai-fill"), post(ai_fill_pull))
6156 .route(&format!("{PULLS}/label-options"), get(list_label_options))
6257 .route(ITEM, get(get_pull).patch(update_pull).delete(delete_pull))
6358 .route(&format!("{ITEM}/close"), post(close_pull))
@@ -68,12 +63,6 @@
6863 .route(&format!("{ITEM}/patch"), get(get_pull_patch))
6964 .route(&format!("{ITEM}/summary"), post(refresh_pull_summary))
7065 .route(&format!("{ITEM}/events"), get(list_pull_events))
71 .route(&format!("{ITEM}/ai-review"), post(trigger_ai_review))
72 .route(&format!("{ITEM}/ai-reviews"), get(list_ai_reviews))
73 .route(
74 &format!("{ITEM}/ai-reviews/{{review_id}}"),
75 get(get_ai_review),
76 )
7766 .route(&format!("{ITEM}/checks"), get(get_pull_checks))
7867 .route(&format!("{ITEM}/gate"), get(get_pull_gate))
7968 .route(&format!("{ITEM}/lock"), put(lock_pull).delete(unlock_pull))
Modified lib/pulls/Cargo.toml View lib/pulls/Cargo.toml
@@ -4,7 +4,6 @@
44edition.workspace = true
55
66[dependencies]
7agent = { workspace = true }
87config = { workspace = true }
98export = { workspace = true }
109git = { workspace = true }
Deleted lib/sandbox-rpc/proto/agent.proto

Diff too large to display.

Deleted lib/sandbox-rpc/src/lib.rs

Diff too large to display.

Deleted lib/sandbox-rpc/build.rs

Diff too large to display.

Deleted lib/sandbox-rpc/Cargo.toml

Diff too large to display.

Modified lib/space/src/services/bans.rs View lib/space/src/services/bans.rs
@@ -29,7 +29,6 @@
2929 pub created_at: OffsetDateTime,
3030 pub updated_at: OffsetDateTime,
3131 pub expires_at: Option<OffsetDateTime>,
32 pub allow_agent_join: bool,
3332 pub allow_speak: bool,
3433 pub allow_push_repo: bool,
3534 pub allow_comment_any: bool,
@@ -46,8 +45,6 @@
4645 )]
4746 pub expires_at: Option<OffsetDateTime>,
4847 #[serde(default)]
49 pub allow_agent_join: Option<bool>,
50 #[serde(default)]
5148 pub allow_speak: Option<bool>,
5249 #[serde(default)]
5350 pub allow_push_repo: Option<bool>,
@@ -65,8 +62,6 @@
6562 )]
6663 pub expires_at: Option<Option<OffsetDateTime>>,
6764 #[serde(default)]
68 pub allow_agent_join: Option<bool>,
69 #[serde(default)]
7065 pub allow_speak: Option<bool>,
7166 #[serde(default)]
7267 pub allow_push_repo: Option<bool>,
@@ -123,7 +118,6 @@
123118 )
124119 .await?;
125120 if cmd.expires_at.is_some()
126 || cmd.allow_agent_join.unwrap_or(false)
127121 || cmd.allow_speak.unwrap_or(false)
128122 || cmd.allow_push_repo.unwrap_or(false)
129123 || cmd.allow_comment_any.unwrap_or(false)
@@ -135,7 +129,6 @@
135129 cmd.user_id,
136130 None,
137131 cmd.expires_at.map(|value| Some(proto_timestamp(value))),
138 cmd.allow_agent_join,
139132 cmd.allow_speak,
140133 cmd.allow_push_repo,
141134 cmd.allow_comment_any,
@@ -156,7 +149,6 @@
156149 created_at: Set(now),
157150 updated_at: Set(now),
158151 expires_at: Set(cmd.expires_at),
159 allow_agent_join: Set(cmd.allow_agent_join.unwrap_or(false)),
160152 allow_speak: Set(cmd.allow_speak.unwrap_or(false)),
161153 allow_push_repo: Set(cmd.allow_push_repo.unwrap_or(false)),
162154 allow_comment_any: Set(cmd.allow_comment_any.unwrap_or(false)),
@@ -211,7 +203,6 @@
211203 created_at: inserted.created_at,
212204 updated_at: inserted.updated_at,
213205 expires_at: inserted.expires_at,
214 allow_agent_join: inserted.allow_agent_join,
215206 allow_speak: inserted.allow_speak,
216207 allow_push_repo: inserted.allow_push_repo,
217208 allow_comment_any: inserted.allow_comment_any,
@@ -244,7 +235,6 @@
244235 created_at: ban.created_at,
245236 updated_at: ban.updated_at,
246237 expires_at: ban.expires_at,
247 allow_agent_join: ban.allow_agent_join,
248238 allow_speak: ban.allow_speak,
249239 allow_push_repo: ban.allow_push_repo,
250240 allow_comment_any: ban.allow_comment_any,
@@ -279,7 +269,6 @@
279269 uid,
280270 cmd.reason.clone(),
281271 cmd.expires_at.map(|value| value.map(proto_timestamp)),
282 cmd.allow_agent_join,
283272 cmd.allow_speak,
284273 cmd.allow_push_repo,
285274 cmd.allow_comment_any,
@@ -294,9 +283,6 @@
294283 if let Some(expires_at) = cmd.expires_at {
295284 am.expires_at = Set(expires_at);
296285 }
297 if let Some(v) = cmd.allow_agent_join {
298 am.allow_agent_join = Set(v);
299 }
300286 if let Some(v) = cmd.allow_speak {
301287 am.allow_speak = Set(v);
302288 }
@@ -326,7 +312,6 @@
326312 created_at: updated.created_at,
327313 updated_at: updated.updated_at,
328314 expires_at: updated.expires_at,
329 allow_agent_join: updated.allow_agent_join,
330315 allow_speak: updated.allow_speak,
331316 allow_push_repo: updated.allow_push_repo,
332317 allow_comment_any: updated.allow_comment_any,
@@ -412,7 +397,6 @@
412397 created_at: b.created_at,
413398 updated_at: b.updated_at,
414399 expires_at: b.expires_at,
415 allow_agent_join: b.allow_agent_join,
416400 allow_speak: b.allow_speak,
417401 allow_push_repo: b.allow_push_repo,
418402 allow_comment_any: b.allow_comment_any,
Modified lib/space/src/services/spaces.rs View lib/space/src/services/spaces.rs
@@ -339,7 +339,7 @@
339339 cmd: UpdateSpaceCmd,
340340 ) -> Result<SpaceDto, SpaceError> {
341341 let space = lookup_space_by_name(self, space_name).await?;
342 if space.owner_id != caller && !export::authz::is_system_uid(caller) {
342 if space.owner_id != caller {
343343 return Err(SpaceError::Forbidden);
344344 }
345345
@@ -472,7 +472,7 @@
472472 #[tracing::instrument(name = "space.delete_space", skip_all, fields(caller = %caller, space = %space_name))]
473473 pub async fn delete_space(&self, caller: i64, space_name: &str) -> Result<(), SpaceError> {
474474 let space = lookup_space_by_name(self, space_name).await?;
475 if space.owner_id != caller && !export::authz::is_system_uid(caller) {
475 if space.owner_id != caller {
476476 return Err(SpaceError::Forbidden);
477477 }
478478
@@ -524,10 +524,8 @@
524524
525525 let mut select = spaces::Entity::find()
526526 .filter(spaces::Column::IsActive.eq(true))
527 .filter(spaces::Column::OwnerId.eq(caller))
527528 .order_by_desc(spaces::Column::CreatedAt);
528 if !export::authz::is_system_uid(caller) {
529 select = select.filter(spaces::Column::OwnerId.eq(caller));
530 }
531529
532530 let total = select.clone().count(self.db().read()).await? as i64;
533531 let rows = select
Modified lib/space/src/share/authz.rs View lib/space/src/share/authz.rs
@@ -69,9 +69,6 @@
6969 space_id: Uuid,
7070 user_id: i64,
7171) -> Result<(), SpaceError> {
72 if export::authz::is_system_uid(user_id) {
73 return Ok(());
74 }
7572 let now = OffsetDateTime::now_utc();
7673 if let Some(ban) = space_bans::Entity::find_by_id((space_id, user_id))
7774 .one(db)
@@ -103,7 +100,7 @@
103100 space: &spaces::Model,
104101 user_id: i64,
105102) -> Result<Permissions, SpaceError> {
106 if space.owner_id == user_id || export::authz::is_system_uid(user_id) {
103 if space.owner_id == user_id {
107104 return Ok(Permissions::owner());
108105 }
109106
Modified lib/space/src/share/dir_visibility.rs View lib/space/src/share/dir_visibility.rs
@@ -51,7 +51,7 @@
5151 dir_id: Uuid,
5252 caller: i64,
5353) -> Result<bool, SpaceError> {
54 if space.owner_id == caller || export::authz::is_system_uid(caller) {
54 if space.owner_id == caller {
5555 return Ok(true);
5656 }
5757 let target = dirs::Entity::find_by_id(dir_id).one(db).await?;
Modified lib/space/src/sync.rs View lib/space/src/sync.rs
@@ -160,7 +160,6 @@
160160 active.banned_at = Set(timestamp(&member.banned_at));
161161 active.ban_reason = Set(non_empty(&member.ban_reason));
162162 active.ban_expires_at = Set(timestamp(&member.ban_expires_at));
163 active.ban_allow_agent_join = Set(member.ban_allow_agent_join);
164163 active.ban_allow_speak = Set(member.ban_allow_speak);
165164 active.ban_allow_push_repo = Set(member.ban_allow_push_repo);
166165 active.ban_allow_comment_any = Set(member.ban_allow_comment_any);
@@ -194,7 +193,6 @@
194193 banned_at: Set(timestamp(&member.banned_at)),
195194 ban_reason: Set(non_empty(&member.ban_reason)),
196195 ban_expires_at: Set(timestamp(&member.ban_expires_at)),
197 ban_allow_agent_join: Set(member.ban_allow_agent_join),
198196 ban_allow_speak: Set(member.ban_allow_speak),
199197 ban_allow_push_repo: Set(member.ban_allow_push_repo),
200198 ban_allow_comment_any: Set(member.ban_allow_comment_any),
Modified lib/webapi/src/cleanup.rs View lib/webapi/src/cleanup.rs
@@ -1,10 +1,10 @@
11/**
22 * 空间「对象清理」:仅空间所有者可见的危险区。
33 *
4 * 空间对象分布在两棵树里 —— `dirs`(目录 / 仓库 / 问题集 / 智能体集)与
4 * 空间对象分布在两棵树里 —— `dirs`(目录 / 仓库 / 问题集)与
55 * `rooms`(文字、语音、舞台等频道)。这里把它们拍成一张扁平清单,并提供
66 * 「删除 / 归档 / 可见性 / 清除可见性覆盖」操作。删除复用各模块自己的清理
7 * 逻辑(问题集连同 issue 与目录节点一起删、智能体集连同会话一起删、频道软删),
7 * 逻辑(问题集连同 issue 与目录节点一起删、频道软删),
88 * 纯目录节点只有为空时才允许删除,避免留下孤儿对象。
99 */
1010use std::collections::HashMap;
@@ -13,7 +13,6 @@
1313use axum::routing::{delete, get, post};
1414use axum::{Json, Router};
1515use export::rest::{AppResponseData, NoData};
16use models::agent::agents as agent_collections;
1716use models::dir::{dir_overwrite_group, dir_overwrite_users, dirs};
1817use models::issue::issues as boards;
1918use models::repo::repos;
@@ -28,7 +27,6 @@
2827use utoipa::{OpenApi, ToSchema};
2928use uuid::Uuid;
3029
31use agents::services::service::AgentsService;
3230use git::service::GitService;
3331use issues::services::service::IssueService;
3432use space::errors::SpaceError;
@@ -50,7 +48,6 @@
5048pub struct CleanupState {
5149 pub space: SpaceService,
5250 pub issues: IssueService,
53 pub agents: AgentsService,
5451 pub git: GitService,
5552}
5653
@@ -58,7 +55,7 @@
5855pub struct CleanupObjectDto {
5956 /// `dirs.id`(目录/仓库/问题集/智能体集)或 `rooms.id`(频道)。
6057 pub id: Uuid,
61 /// `dir | object | repo | board | collection | text | voice | stage | category | news | guild_room`
58 /// `dir | object | repo | board | text | voice | stage | category | news | guild_room`
6259 pub kind: String,
6360 pub name: String,
6461 /// 父链拼出的可读路径,例如 `bots / 研究研究`。
@@ -82,16 +79,10 @@
8279 pub archived: bool,
8380}
8481
85pub fn router(
86 space: SpaceService,
87 issues: IssueService,
88 agents: AgentsService,
89 git: GitService,
90) -> Router {
82pub fn router(space: SpaceService, issues: IssueService, git: GitService) -> Router {
9183 let state = CleanupState {
9284 space,
9385 issues,
94 agents,
9586 git,
9687 };
9788 Router::new()
@@ -151,19 +142,6 @@
151142 }
152143}
153144
154fn map_agent(error: agents::errors::AgentError) -> ApiError {
155 use agents::errors::AgentError;
156 match error {
157 AgentError::BadRequest(message) | AgentError::Conflict(message) => {
158 ApiError::bad_request(message)
159 }
160 AgentError::Unauthorized => ApiError::unauthorized(),
161 AgentError::Forbidden => ApiError::forbidden(),
162 AgentError::NotFound => ApiError::not_found("resource not found"),
163 other => ApiError::internal(other.to_string()),
164 }
165}
166
167145fn db_internal(error: sea_orm::DbErr) -> ApiError {
168146 ApiError::internal(error.to_string())
169147}
@@ -253,7 +231,7 @@
253231 get,
254232 path = "/spaces/{space_name}/cleanup",
255233 tag = "cleanup",
256 description = "列出空间内全部目录与对象(目录/仓库/问题集/智能体集/频道),仅空间所有者可访问。",
234 description = "列出空间内全部目录与对象(目录/仓库/问题集/频道),仅空间所有者可访问。",
257235 params(("space_name" = String, Path, description = "space name")),
258236 responses(
259237 (status = 200, description = "cleanup objects", body = AppResponseData<Vec<CleanupObjectDto>>)
@@ -275,8 +253,8 @@
275253 .map_err(db_internal)?;
276254 let node_ids: Vec<Uuid> = nodes.iter().map(|node| node.id).collect();
277255
278 let (repo_rows, board_rows, collection_rows) = if node_ids.is_empty() {
279 (Vec::new(), Vec::new(), Vec::new())
256 let (repo_rows, board_rows) = if node_ids.is_empty() {
257 (Vec::new(), Vec::new())
280258 } else {
281259 (
282260 repos::Entity::find()
@@ -289,11 +267,6 @@
289267 .all(db.read())
290268 .await
291269 .map_err(db_internal)?,
292 agent_collections::Entity::find()
293 .filter(agent_collections::Column::DirId.is_in(node_ids.clone()))
294 .all(db.read())
295 .await
296 .map_err(db_internal)?,
297270 )
298271 };
299272
@@ -325,10 +298,6 @@
325298 repo_rows.iter().map(|row| (row.dir_id, row)).collect();
326299 let board_by_dir: HashMap<Uuid, &boards::Model> =
327300 board_rows.iter().map(|row| (row.dir_id, row)).collect();
328 let collection_by_dir: HashMap<Uuid, &agent_collections::Model> = collection_rows
329 .iter()
330 .map(|row| (row.dir_id, row))
331 .collect();
332301
333302 let mut overwrite_counts: HashMap<Uuid, i64> = HashMap::new();
334303 for row in group_ows.iter() {
@@ -371,8 +340,6 @@
371340 )
372341 } else if let Some(board) = board_by_dir.get(&node.id) {
373342 ("board", board.name.clone(), None, false)
374 } else if let Some(collection) = collection_by_dir.get(&node.id) {
375 ("collection", collection.name.clone(), None, false)
376343 } else if node.kind == dirs::DirKind::Object {
377344 ("object", node.name.clone(), None, node.is_archived)
378345 } else {
@@ -500,7 +467,7 @@
500467 delete,
501468 path = "/spaces/{space_name}/cleanup/{kind}/{object_id}",
502469 tag = "cleanup",
503 description = "删除对象:问题集/智能体集/频道调用各自模块的清理逻辑,纯目录节点需为空;仓库只能用归档。",
470 description = "删除对象:问题集/频道调用各自模块的清理逻辑,纯目录节点需为空;仓库只能用归档。",
504471 params(
505472 ("space_name" = String, Path, description = "space name"),
506473 ("kind" = String, Path, description = "object kind"),
@@ -536,19 +503,6 @@
536503 .await
537504 .map_err(map_issue)?;
538505 }
539 "collection" => {
540 let collection = agent_collections::Entity::find()
541 .filter(agent_collections::Column::DirId.eq(object_id))
542 .one(db.read())
543 .await
544 .map_err(db_internal)?
545 .ok_or_else(|| ApiError::not_found("collection not found"))?;
546 state
547 .agents
548 .delete_agent_collection(uid, &space_name, collection.id)
549 .await
550 .map_err(map_agent)?;
551 }
552506 "text" | "voice" | "stage" | "category" | "news" | "guild_room" | "thread" => {
553507 let room = rooms::Entity::find_by_id(object_id)
554508 .filter(rooms::Column::SpaceId.eq(space.id))
@@ -591,12 +545,6 @@
591545 .count(db.read())
592546 .await
593547 .map_err(db_internal)?
594 > 0
595 || agent_collections::Entity::find()
596 .filter(agent_collections::Column::DirId.eq(node.id))
597 .count(db.read())
598 .await
599 .map_err(db_internal)?
600548 > 0;
601549 if has_companion {
602550 return Err(ApiError::bad_request(
@@ -656,10 +604,7 @@
656604) -> Result<AppResponseData<NoData>, ApiError> {
657605 let uid = require_uid(&session)?;
658606 let space = require_owner(&state, &space_name, uid).await?;
659 if !matches!(
660 kind.as_str(),
661 "dir" | "object" | "repo" | "board" | "collection"
662 ) {
607 if !matches!(kind.as_str(), "dir" | "object" | "repo" | "board") {
663608 return Err(ApiError::bad_request(
664609 "visibility overwrites are stored on directory nodes only",
665610 ));
Modified lib/webapi/src/lib.rs View lib/webapi/src/lib.rs
@@ -77,7 +77,6 @@
7777 let cors = build_cors_layer(&state.config.web);
7878
7979 let auth = auth::services::AuthService::new(state.clone());
80 let agents = agents::services::service::AgentsService::new(state.clone());
8180 let chat = chat::services::service::ChatService::new(state.clone());
8281 let issues = issues::services::service::IssueService::new(state.clone());
8382 let space = space::services::service::SpaceService::new(state.clone());
@@ -104,14 +103,12 @@
104103 let revalidate_state = state.clone();
105104 let health_state = state;
106105
107 // 清理接口需要跨模块调用(space/issues/agents/git),这里的服务都还没被
106 // 清理接口需要跨模块调用(space/issues/git),这里的服务都还没被
108107 // 其他 router 的 with_state 消费掉,先构造再合并。
109 let cleanup_router =
110 cleanup::router(space.clone(), issues.clone(), agents.clone(), git.clone());
108 let cleanup_router = cleanup::router(space.clone(), issues.clone(), git.clone());
111109
112110 let public: Router = Router::new()
113111 .merge(auth::routes::router(auth.clone()).with_state(auth.clone()))
114 .merge(agents::routes::router(agents.clone()).with_state(agents))
115112 .merge(chat::routes::router(chat.clone(), space.clone()).with_state(chat))
116113 .merge(issues::routes::router(issues.clone()).with_state(issues))
117114 .merge(space::routes::router(space.clone()).with_state(space))
@@ -152,11 +149,11 @@
152149 let warm = verifier.clone();
153150 tokio::spawn(async move { warm.warm_up().await });
154151 router
155 .layer(axum::Extension(verifier))
156152 .layer(axum::middleware::from_fn_with_state(
157153 revalidate_state,
158154 auth::oauth::revalidate::revalidate,
159155 ))
156 .layer(axum::Extension(verifier))
160157 }
161158 None => router,
162159 };
Modified lib/webapi/src/openapi.rs View lib/webapi/src/openapi.rs
@@ -106,7 +106,6 @@
106106 }};
107107 }
108108 m!("auth", auth::openapi::AuthApiDoc::openapi());
109 m!("agents", agents::openapi::AgentsApiDoc::openapi());
110109 m!("chat", chat::openapi::ChatApiDoc::openapi());
111110 m!("issues", issues::openapi::IssuesApiDoc::openapi());
112111 m!("space", space::openapi::SpaceApiDoc::openapi());
Modified lib/webapi/Cargo.toml View lib/webapi/Cargo.toml
@@ -6,7 +6,6 @@
66[dependencies]
77config = { path = "../config" }
88auth = { workspace = true }
9agents = { workspace = true }
109chat = { workspace = true }
1110issues = { workspace = true }
1211pulls = { workspace = true }
Modified scripts/dev.ps1 View scripts/dev.ps1
@@ -21,12 +21,10 @@
2121 searchhost = @{ Exe = 'searchhost'; Port = 0; Health = $false }
2222 mails = @{ Exe = 'mails'; Port = 4084; Health = $true }
2323 webhook = @{ Exe = 'webhook'; Port = 3005; Health = $true }
24 aihost = @{ Exe = 'aihost'; Port = 3006; Health = $true }
25 mcp = @{ Exe = 'mcp'; Port = 3007; Health = $true }
2624 web = @{ Bun = 'web'; Port = 5173; Health = $false }
2725}
28$Order = @('migrate', 'apihost', 'githost', 'im', 'synchost', 'searchhost', 'mails', 'webhook', 'aihost', 'mcp', 'web')
29$Backends = @('migrate', 'apihost', 'githost', 'im', 'synchost', 'searchhost', 'mails', 'webhook', 'aihost', 'mcp')
26$Order = @('migrate', 'apihost', 'githost', 'im', 'synchost', 'searchhost', 'mails', 'webhook', 'web')
27$Backends = @('migrate', 'apihost', 'githost', 'im', 'synchost', 'searchhost', 'mails', 'webhook')
3028$Aliases = @{
3129 api = 'apihost'
3230 git = 'githost'
@@ -34,7 +32,6 @@
3432 sync = 'synchost'
3533 mail = 'mails'
3634 hook = 'webhook'
37 ai = 'aihost'
3835 ui = 'web'
3936}
4037
@@ -45,8 +42,6 @@
4542 synchost = 'http://127.0.0.1:4083/healthz'
4643 mails = 'http://127.0.0.1:4084/healthz'
4744 webhook = 'http://127.0.0.1:3005'
48 aihost = 'http://127.0.0.1:3006/healthz'
49 mcp = 'http://127.0.0.1:3007/healthz'
5045 web = 'http://127.0.0.1:5173'
5146}
5247
@@ -288,7 +283,7 @@
288283 continue
289284 }
290285 if (-not $Services.Contains($key)) {
291 throw "未知服务: $part;可用: $($Order -join ', ')(别名: api/git/chat/sync/mail/hook/ai/ui/backends/frontends)"
286 throw "未知服务: $part;可用: $($Order -join ', ')(别名: api/git/chat/sync/mail/hook/ui/backends/frontends)"
292287 }
293288 if (-not $names.Contains($key)) { $names.Add($key) }
294289 }
Modified scripts/dev.sh View scripts/dev.sh
@@ -12,8 +12,8 @@
1212DEV_HOST_KEY="$DEV_DIR/ssh_host_ed25519"
1313COMPOSE_FILE="$ROOT/docker-compose.dev.yml"
1414
15ORDER="apihost githost im synchost mails webhook aihost mcp web"
16BACKENDS="apihost githost im synchost mails webhook aihost mcp"
15ORDER="apihost githost im synchost mails webhook web"
16BACKENDS="apihost githost im synchost mails webhook"
1717INFRA="postgresql:5432 redis:6379 s3:9000 meilisearch:7700"
1818
1919if [ -t 1 ]; then
@@ -43,8 +43,6 @@
4343 synchost) echo 4083 ;;
4444 mails) echo 4084 ;;
4545 webhook) echo 3005 ;;
46 aihost) echo 3006 ;;
47 mcp) echo 3007 ;;
4846 web) echo 5173 ;;
4947 *) echo 0 ;;
5048 esac
@@ -65,7 +63,6 @@
6563 sync) echo synchost ;;
6664 mail) echo mails ;;
6765 hook) echo webhook ;;
68 ai) echo aihost ;;
6966 ui) echo web ;;
7067 *) echo "$1" ;;
7168 esac
@@ -79,8 +76,6 @@
7976 synchost) echo "http://127.0.0.1:4083/healthz" ;;
8077 mails) echo "http://127.0.0.1:4084/healthz" ;;
8178 webhook) echo "http://127.0.0.1:3005" ;;
82 aihost) echo "http://127.0.0.1:3006/healthz" ;;
83 mcp) echo "http://127.0.0.1:3007/healthz" ;;
8479 web) echo "http://127.0.0.1:5173" ;;
8580 esac
8681}
@@ -593,7 +588,7 @@
593588 if ! contains "$key" $ORDER; then
594589 fail "未知服务: $part"
595590 info "可用: $ORDER"
596 info "别名: api git chat sync mail hook ai ui / backends / frontends"
591 info "别名: api git chat sync mail hook ui / backends / frontends"
597592 return 1
598593 fi
599594 out="$out $key"
Modified web/public/manifest.webmanifest View web/public/manifest.webmanifest
@@ -1,7 +1,7 @@
11{
22 "name": "GitDataAI",
33 "short_name": "GitData",
4 "description": "Repositories, issues, pull requests, team communication and AI code review in one engineering workspace.",
4 "description": "Repositories, issues, pull requests and team communication in one engineering workspace.",
55 "start_url": "/app/@me",
66 "scope": "/",
77 "display": "standalone",
Modified web/src/app/routes/router.tsx View web/src/app/routes/router.tsx
@@ -38,7 +38,6 @@
3838 { path: ':spaceName' },
3939 { path: ':spaceName/chat/:roomId?' },
4040 { path: ':spaceName/board/:boardId?' },
41 { path: ':spaceName/agent/:collectionId?/:agentId?' },
4241 { path: ':spaceName/repo/:repoName?' },
4342 { path: ':spaceName/members' },
4443 ],
Modified web/src/app/styles/workspace.css View web/src/app/styles/workspace.css
@@ -334,7 +334,7 @@
334334 .workspace-shell *, .workspace-overlay * { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; transition-delay: 0s !important; scroll-behavior: auto !important; }
335335}
336336
337/* AI 生成的 Markdown(总结、评审结论、release 正文)。语义与 README 的
337/* 用户与流水线产出的 Markdown(描述、评论、release 正文)。语义与 README 的
338338 * markdown-body 一致,但作用域是应用内部,不依赖服务端片段样式。 */
339339.gitdata-markdown { font-size: 13px; line-height: 1.65; color: var(--shell-ink); word-break: break-word; }
340340.gitdata-markdown-compact { font-size: 12.5px; }
Deleted web/src/entities/pull-request/model/ai.ts

Diff too large to display.

Deleted web/src/entities/pull-request/ui/language-picker.tsx

Diff too large to display.

Modified web/src/entities/pull-request/index.ts View web/src/entities/pull-request/index.ts
@@ -5,5 +5,3 @@
55 */
66export * from './model/pull'
77export * from './model/api'
8export * from './model/ai'
9export { LanguagePicker, type LanguagePickerProps } from './ui/language-picker'
Deleted web/src/entities/repo/model/ai-summary.ts

Diff too large to display.

Modified web/src/entities/repo/model/releases.ts View web/src/entities/repo/model/releases.ts
@@ -1,4 +1,4 @@
1/** 发布记录领域:仓库 release 的读写与 AI 起草。 */
1/** 发布记录领域:仓库 release 的读写。 */
22import Apis from '@/shared/api'
33import { unwrapApi } from '@/shared/lib'
44import type { AppResponseData_ReleaseDto, CreateReleaseCmd, UpdateReleaseCmd } from '@/shared/api'
@@ -51,9 +51,3 @@
5151 unwrapApi<unknown>(
5252 Apis.releases.delete_release({ pathParams: { space_name: space, repo_name: repo, tag } }),
5353 )
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
@@ -175,15 +175,6 @@
175175 type BlameRange,
176176 type SubmoduleRow,
177177} from './model/blame'
178// ai summary (repo-scoped, cached per head)
179export {
180 fetchRepoAiSummary,
181 triggerRepoAiSummary,
182 type RepoAiSummary,
183 type RepoAiSummaryJob,
184 type RepoAiSummaryState,
185} from './model/ai-summary'
186
187178// releases
188179export {
189180 listReleases,
@@ -191,7 +182,6 @@
191182 createRelease,
192183 updateRelease,
193184 deleteRelease,
194 redraftRelease,
195185 type Release,
196186 type ReleaseListPage,
197187} from './model/releases'
Deleted web/src/features/pull-request/ui/ai-tab.tsx

Diff too large to display.

Modified web/src/features/pull-request/ui/files-tab.tsx View web/src/features/pull-request/ui/files-tab.tsx
@@ -56,7 +56,7 @@
5656
5757 const rows = useMemo(() => (files.data ?? []).map(file => changedFileToDiff(file)), [files.data])
5858
59 // 从 AI 发现/行内评论跳转过来时,滚动到目标行(两侧都试,命中即止)。
59 // 从行内评论跳转过来时,滚动到目标行(两侧都试,命中即止)。
6060 useEffect(() => {
6161 if (!focusLine || !focusPath) return
6262 const id = `diff-right-${encodeURIComponent(focusPath)}-${focusLine}`
Modified web/src/features/pull-request/ui/pull-detail.tsx View web/src/features/pull-request/ui/pull-detail.tsx
@@ -9,7 +9,6 @@
99 Pencil,
1010 RotateCcw,
1111 ShieldCheck,
12 Sparkles,
1312 Unlock,
1413 X,
1514} from 'lucide-react'
@@ -50,9 +49,8 @@
5049import { FilesTab } from './files-tab'
5150import { CommitsTab } from './commits-tab'
5251import { ChecksTab } from './checks-tab'
53import { AiTab } from './ai-tab'
5452
55export type PullSubTab = 'conversation' | 'files' | 'commits' | 'checks' | 'ai'
53export type PullSubTab = 'conversation' | 'files' | 'commits' | 'checks'
5654
5755export interface PullDetailProps {
5856 space: string
@@ -71,7 +69,6 @@
7169 { id: 'files', icon: FileCode2 },
7270 { id: 'commits', icon: GitMerge },
7371 { id: 'checks', icon: ListChecks },
74 { id: 'ai', icon: Sparkles },
7572]
7673
7774const SUB_TAB_KEYS: Record<PullSubTab, MessageKey> = {
@@ -79,7 +76,6 @@
7976 files: 'pull.tabs.files',
8077 commits: 'pull.tabs.commits',
8178 checks: 'pull.detail.tab.checks',
82 ai: 'pull.tabs.ai',
8379}
8480
8581/** PR 详情页:头部/操作 + 子页签 + 侧栏。 */
@@ -87,8 +83,8 @@
8783 const { t } = useT()
8884 const [tick, setTick] = useState(0)
8985 const [focusFile, setFocusFile] = useState('')
90 /** 从 AI 发现跳转过来时要高亮的行号(0 表示不高亮)。 */
91 const [focusLine, setFocusLine] = useState(0)
86 /** 从文件变更跳转过来时要高亮的行号(0 表示不高亮)。 */
87 const [focusLine] = useState(0)
9288 const [notice, setNotice] = useState<string | null>(null)
9389 const [error, setError] = useState<string | null>(null)
9490 const refObj: PullRef = { space, repo, pull: pullRef }
@@ -195,17 +191,6 @@
195191 ) : null}
196192 {ptab === 'commits' ? <CommitsTab refObj={refObj} onOpenCommit={onOpenCommit} /> : null}
197193 {ptab === 'checks' ? <ChecksTab refObj={refObj} headSha={pull.head.sha} /> : null}
198 {ptab === 'ai' ? (
199 <AiTab
200 refObj={refObj}
201 headSha={pull.head.sha}
202 onOpenFinding={(path, line) => {
203 setFocusFile(path)
204 setFocusLine(line)
205 onPtabChange('files')
206 }}
207 />
208 ) : null}
209194 </div>
210195
211196 <div className="w-full shrink-0 space-y-3 lg:w-[300px]">
Modified web/src/features/pull-request/ui/pull-form-modal.tsx View web/src/features/pull-request/ui/pull-form-modal.tsx
@@ -1,8 +1,8 @@
11import { useEffect, useState } from 'react'
2import { AlertCircle, CheckCircle2, Sparkles } from 'lucide-react'
2import { AlertCircle, CheckCircle2 } from 'lucide-react'
33import { Button } from '@/shared/ui'
44import { listBranches } from '@/entities/repo'
5import { aiFillPull, checkMergeStatus, createPull } from '@/entities/pull-request'
5import { checkMergeStatus, createPull } from '@/entities/pull-request'
66import type { MergeStatus, Pull } from '@/entities/pull-request'
77import { Modal, modalInputClass, modalLabelClass, modalTextareaClass } from '@/shared/ui'
88import { useT } from '@/shared/i18n'
@@ -25,7 +25,6 @@
2525 const [body, setBody] = useState('')
2626 const [draft, setDraft] = useState(false)
2727 const [busy, setBusy] = useState(false)
28 const [aiBusy, setAiBusy] = useState(false)
2928 const [error, setError] = useState<string | null>(null)
3029 const [probe, setProbe] = useState<{ key: string; value: MergeStatus | 'failed' } | null>(null)
3130
@@ -64,21 +63,6 @@
6463 clearTimeout(timer)
6564 }
6665 }, [space, repo, base, head, pairKey])
67
68 async function aiFill() {
69 if (!head || !base || head === base || aiBusy) return
70 setAiBusy(true)
71 setError(null)
72 try {
73 const draft = await aiFillPull(space, repo, { head_branch: head, base_branch: base })
74 setTitle(draft.title.slice(0, 200))
75 setBody(draft.body)
76 } catch (failure) {
77 setError(failure instanceof Error ? failure.message : t('pull.form.aiFillFailed'))
78 } finally {
79 setAiBusy(false)
80 }
81 }
8266
8367 async function submit() {
8468 if (!title.trim() || !head || !base) {
@@ -114,16 +98,8 @@
11498 onClose={onClose}
11599 footer={
116100 <>
117 <Button
118 variant="secondary"
119 disabled={busy || aiBusy || !head || !base || head === base}
120 onClick={() => void aiFill()}
121 >
122 <Sparkles className="h-3.5 w-3.5" aria-hidden />
123 {aiBusy ? t('pull.form.aiFilling') : t('pull.form.aiFill')}
124 </Button>
125 <Button variant="ghost" disabled={aiBusy} onClick={onClose}>{t('common.action.cancel')}</Button>
126 <Button disabled={busy || aiBusy} onClick={() => void submit()}>
101 <Button variant="ghost" onClick={onClose}>{t('common.action.cancel')}</Button>
102 <Button disabled={busy} onClick={() => void submit()}>
127103 {busy ? t('pull.form.creating') : draft ? t('pull.form.createDraft') : t('pull.form.create')}
128104 </Button>
129105 </>
Deleted web/src/features/repo-ai/ui/ai-summary-tab.tsx

Diff too large to display.

Deleted web/src/features/repo-ai/index.ts

Diff too large to display.

Modified web/src/features/repo-history/ui/commit-insights.tsx View web/src/features/repo-history/ui/commit-insights.tsx
@@ -1,6 +1,6 @@
11import { useState } from 'react'
2import { Sparkles, Trash2 } from 'lucide-react'
3import { Button, Markdown, modalTextareaClass } from '@/shared/ui'
2import { Trash2 } from 'lucide-react'
3import { Button, modalTextareaClass } from '@/shared/ui'
44import {
55 createCodeComment,
66 deleteCodeComment,
@@ -8,97 +8,7 @@
88 unresolveCodeComment,
99 type CodeComment,
1010} from '@/entities/repo'
11import { LanguagePicker, listCommitAiReviews, severityTone, triggerCommitAiReview, type CommitAiReview } from '@/entities/pull-request'
1211import { useT } from '@/shared/i18n'
13import { useAsync } from '@/shared/lib'
14
15export interface CommitInsightsProps {
16 space: string
17 repo: string
18 /** Commit being viewed; AI 评审同样锚定该提交。 */
19 sha: string
20 onChanged?: () => void
21}
22
23/** 提交页的 AI 评审面板。行级评论不在这里:它们由差异视图行内渲染。 */
24export function CommitInsights({ space, repo, sha, onChanged }: CommitInsightsProps) {
25 const { t } = useT()
26 const [tick, setTick] = useState(0)
27 const [busy, setBusy] = useState(false)
28 const [error, setError] = useState<string | null>(null)
29 /** '' = 跟随当前用户偏好。 */
30 const [language, setLanguage] = useState('')
31
32 const reviews = useAsync(() => listCommitAiReviews(space, repo, sha), [space, repo, sha, tick])
33 const aiRows: CommitAiReview[] = reviews.data ?? []
34
35 async function rerunAi() {
36 setBusy(true)
37 setError(null)
38 try {
39 await triggerCommitAiReview(space, repo, sha, language || undefined)
40 setTick(t => t + 1)
41 onChanged?.()
42 } catch (f) {
43 setError(f instanceof Error ? f.message : t('repo.ai.triggerReviewFailed'))
44 } finally {
45 setBusy(false)
46 }
47 }
48
49 return (
50 <div className="space-y-3">
51 {error ? <p className="text-[12.5px] text-[#b42318]" role="alert">{error}</p> : null}
52
53 {aiRows.length === 0 ? (
54 <section className="rounded-xl border border-shell-hairline bg-shell-surface px-4 py-3">
55 <div className="flex items-center gap-2">
56 <Sparkles className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden />
57 <span className="text-[13px] font-medium">{t('repo.ai.review')}</span>
58 <LanguagePicker value={language} onChange={setLanguage} disabled={busy} />
59 <Button className="ml-auto h-7 text-[12px]" disabled={busy} onClick={() => void rerunAi()}>
60 {t('repo.ai.rerunReview')}
61 </Button>
62 </div>
63 <p className="mt-2 text-[12.5px] text-shell-ink-muted">{t('repo.ai.noReview')}</p>
64 </section>
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-[12.5px]">
68 <Sparkles className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden />
69 <span className="font-medium">{t('repo.ai.review')}</span>
70 <span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
71 aiRows[0].verdict === 'changes_requested'
72 ? 'text-[#b42318] bg-[rgba(242,63,67,0.10)]'
73 : 'text-shell-ink-muted bg-shell-active'
74 }`}>
75 {aiRows[0].verdict === 'changes_requested' ? t('repo.ai.verdictChangesRequested') : t('repo.ai.verdictComment')}
76 </span>
77 {aiRows[0].language ? <span className="text-[11.5px] text-shell-ink-muted">{aiRows[0].language}</span> : null}
78 {aiRows[0].truncated ? <span className="text-[11.5px] text-[#8a6100]">{t('repo.ai.truncated')}</span> : null}
79 <LanguagePicker value={language} onChange={setLanguage} disabled={busy} />
80 <Button className="ml-auto h-7 text-[12px]" disabled={busy} onClick={() => void rerunAi()}>
81 {t('repo.ai.rerunReview')}
82 </Button>
83 </header>
84 <div className="space-y-2 px-4 py-3">
85 <Markdown>{aiRows[0].summary || t('repo.ai.noSummary')}</Markdown>
86 {aiRows[0].findings.map((finding, index) => (
87 <div key={`${finding.path}:${finding.line}:${index}`} className="rounded-lg border border-shell-hairline bg-shell-field px-3 py-2">
88 <div className="mb-1 flex flex-wrap items-center gap-2 text-[12px]">
89 <span className={`rounded px-1.5 py-0.5 font-medium ${severityTone(finding.severity)}`}>{finding.severity || 'minor'}</span>
90 <span className="font-mono">{finding.path}:{finding.line}</span>
91 {finding.side === 'left' ? <span className="text-shell-ink-muted">{t('repo.ai.oldLine')}</span> : null}
92 </div>
93 <Markdown compact>{finding.body}</Markdown>
94 </div>
95 ))}
96 </div>
97 </section>
98 )}
99 </div>
100 )
101}
10212
10313/** 行内评论卡片:钉在差异行下方,带作者、过期标记与解决/删除操作。 */
10414export function CodeLineCommentCard({
@@ -225,4 +135,4 @@
225135 </div>
226136 </div>
227137 )
228}
No newline at end of file
138}
Modified web/src/features/repo-history/ui/commit-view.tsx View web/src/features/repo-history/ui/commit-view.tsx
@@ -3,7 +3,7 @@
33import { t, useT } from '@/shared/i18n'
44import { Button, CopyChip, ErrorPanel, PanelNote } from '@/shared/ui'
55import { fmtRelative, fmtTime, repoKey, settingsKey, shortSha, useAsync } from '@/shared/lib'
6import { CodeLineCommentCard, CommitInsights, LineCommentComposer } from './commit-insights'
6import { CodeLineCommentCard, LineCommentComposer } from './commit-insights'
77import {
88 applyNumstat,
99 EMPTY_TREE,
@@ -215,13 +215,6 @@
215215 ))}
216216 </div>
217217 </div>
218
219 <CommitInsights
220 space={space}
221 repo={repo}
222 sha={data.commit.id}
223 onChanged={() => setCommentsVersion(v => v + 1)}
224 />
225218
226219 {files.length === 0 ? <PanelNote text={t('repo.commit.noFiles')} /> : null}
227220 <div className="space-y-3">
Modified web/src/features/repo-releases/ui/releases-tab.tsx View web/src/features/repo-releases/ui/releases-tab.tsx
@@ -1,5 +1,5 @@
11import { useMemo, useState } from 'react'
2import { Loader2, Pencil, Plus, Sparkles, Tag, Trash2 } from 'lucide-react'
2import { Loader2, Pencil, Plus, Tag, Trash2 } from 'lucide-react'
33import { Alert, Button, ErrorPanel, Markdown, Modal, PanelNote, modalInputClass, modalLabelClass, modalTextareaClass } from '@/shared/ui'
44import { useT } from '@/shared/i18n'
55import { fmtRelative, useAsync } from '@/shared/lib'
@@ -9,7 +9,6 @@
99 getRelease,
1010 listReleases,
1111 listTags,
12 redraftRelease,
1312 updateRelease,
1413 type Release,
1514} from '@/entities/repo'
@@ -24,7 +23,7 @@
2423 canWrite: boolean
2524}
2625
27/** 发布页签:草稿与已发布 release 的列表 / 详情 / AI 起草。 */
26/** 发布页签:草稿与已发布 release 的列表 / 详情。 */
2827export function ReleasesTab({ space, repo, selected, onSelect, canWrite }: ReleasesTabProps) {
2928 const { t } = useT()
3029 const [tick, setTick] = useState(0)
@@ -83,7 +82,6 @@
8382 release={detail.data}
8483 canWrite={canWrite}
8584 busy={busy}
86 onDraft={() => void run(() => redraftRelease(space, repo, selected), t('repo.release.redraftQueued'))}
8785 onSave={(title, content) =>
8886 void run(() => updateRelease(space, repo, selected, { title, content }), t('repo.release.savedDraft'))
8987 }
@@ -158,7 +156,6 @@
158156 release,
159157 canWrite,
160158 busy,
161 onDraft,
162159 onSave,
163160 onPublish,
164161 onTogglePrerelease,
@@ -167,7 +164,6 @@
167164 release: Release
168165 canWrite: boolean
169166 busy: boolean
170 onDraft: () => void
171167 onSave: (title: string, content: string) => void
172168 onPublish: () => void
173169 onTogglePrerelease: (next: boolean) => void
@@ -202,10 +198,6 @@
202198 </label>
203199 <div className="flex flex-wrap items-center gap-2">
204200 <Button className="h-8 text-[12.5px]" disabled={busy} onClick={() => onSave(title, content)}>{t('repo.release.saveDraft')}</Button>
205 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={onDraft}>
206 <Sparkles className="mr-1.5 h-3.5 w-3.5" aria-hidden />
207 {t('repo.release.redraft')}
208 </Button>
209201 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={onPublish}>{t('repo.release.publish')}</Button>
210202 <Button variant="ghost" className="h-8 text-[12px]" disabled={busy} onClick={() => onTogglePrerelease(!release.is_prerelease)}>
211203 {release.is_prerelease ? t('repo.release.unmarkPrerelease') : t('repo.release.markPrerelease')}
Modified web/src/features/repo-settings/model/sections.ts View web/src/features/repo-settings/model/sections.ts
@@ -19,7 +19,6 @@
1919 | 'locks'
2020 | 'protection'
2121 | 'ci'
22 | 'ai'
2322
2423export const REPO_SETTINGS_SECTIONS: ReadonlyArray<{ id: RepoSettingsSection; label: string }> = [
2524 { id: 'general', get label() { return t('repo.settings.nav.general') } },
@@ -32,5 +31,4 @@
3231 { id: 'locks', get label() { return t('repo.settings.nav.locks') } },
3332 { id: 'protection', get label() { return t('repo.settings.nav.protection') } },
3433 { id: 'ci', get label() { return t('repo.settings.nav.ci') } },
35 { id: 'ai', get label() { return t('repo.settings.nav.ai') } },
3634]
Deleted web/src/features/repo-settings/ui/sections/ai-settings.tsx

Diff too large to display.

Modified web/src/features/repo-settings/ui/sections/features-settings.tsx View web/src/features/repo-settings/ui/sections/features-settings.tsx
@@ -6,7 +6,7 @@
66import { Notice, SectionHead } from '../parts'
77
88/** 可由设置重排序的中间页签,顺序与站点默认一致。 */
9const REORDERABLE: string[] = ['commits', 'pulls', 'branches', 'pipelines', 'releases', 'ai']
9const REORDERABLE: string[] = ['commits', 'pulls', 'branches', 'pipelines', 'releases']
1010
1111const TAB_KEYS: Record<string, MessageKey> = {
1212 commits: 'shell.repo.tabs.commits',
@@ -14,7 +14,6 @@
1414 branches: 'shell.repo.tabs.branches',
1515 pipelines: 'shell.repo.tabs.pipelines',
1616 releases: 'shell.repo.tabs.releases',
17 ai: 'shell.repo.tabs.ai',
1817}
1918
2019/** 只保留已知中间页签、去重,缺失项按默认顺序补齐。 */
@@ -26,8 +25,7 @@
2625}
2726
2827/**
29 * 仓库页签配置:是否允许合并请求、流水线 / AI 总结是否公开展示,以及中间
30 * 页签的顺序。只有空间所有者能保存(服务端 PATCH repo 一律 owner-only)。
28 * 仓库页签配置:是否允许合并请求、流水线是否公开展示,以及中间页签的顺序。只有空间所有者能保存(服务端 PATCH repo 一律 owner-only)。
3129 */
3230export function FeaturesSettings({
3331 space,
@@ -44,7 +42,6 @@
4442 const [draft, setDraft] = useState({
4543 allow_pull_requests: repoDto.allow_pull_requests,
4644 pipelines_public: repoDto.pipelines_public,
47 ai_summary_public: repoDto.ai_summary_public,
4845 })
4946 const [order, setOrder] = useState<string[]>(() => normalize(repoDto.tab_order))
5047 const [busy, setBusy] = useState(false)
@@ -55,7 +52,6 @@
5552 const dirty =
5653 draft.allow_pull_requests !== repoDto.allow_pull_requests ||
5754 draft.pipelines_public !== repoDto.pipelines_public ||
58 draft.ai_summary_public !== repoDto.ai_summary_public ||
5955 order.join(',') !== savedOrder.join(',')
6056
6157 const move = (index: number, delta: -1 | 1) => {
@@ -93,11 +89,6 @@
9389 key: 'pipelines_public',
9490 label: t('repo.featuresSettings.pipelinesPublic'),
9591 hint: t('repo.featuresSettings.pipelinesPublicHint'),
96 },
97 {
98 key: 'ai_summary_public',
99 label: t('repo.featuresSettings.aiPublic'),
100 hint: t('repo.featuresSettings.aiPublicHint'),
10192 },
10293 ]
10394
Modified web/src/features/repo-settings/ui/settings-tab.tsx View web/src/features/repo-settings/ui/settings-tab.tsx
@@ -2,7 +2,6 @@
22import type { RepoDetail } from '@/entities/repo'
33import { REPO_SETTINGS_SECTIONS, type RepoSettingsSection } from '../model/sections'
44import { ArchivesSettings } from './sections/archives-settings'
5import { AiSettings } from './sections/ai-settings'
65import { CiSettings } from './sections/ci-settings'
76import { FeaturesSettings } from './sections/features-settings'
87import { GeneralSettings } from './sections/general-settings'
@@ -65,9 +64,6 @@
6564 {section === 'topics' ? <TopicsSettings space={space} repo={repo} /> : null}
6665 {section === 'protection' ? <ProtectionSettings space={space} repo={repo} /> : null}
6766 {section === 'ci' ? <CiSettings space={space} repo={repo} rev={rev} /> : null}
68 {section === 'ai' ? (
69 <AiSettings space={space} repo={repo} repoDto={repoDto} onChanged={onSaved} />
70 ) : null}
7167 {section === 'remotes' ? <RemotesSettings space={space} repo={repo} /> : null}
7268 {section === 'import' ? <ImportSettings space={space} repo={repo} /> : null}
7369 {section === 'archives' ? (
Modified web/src/features/space-management/model/api.ts View web/src/features/space-management/model/api.ts
@@ -17,7 +17,6 @@
1717 AppResponseData_UserPreferencesDto,
1818 AppResponseData_Vec_DirNodeDto,
1919 AppResponseData_Vec_MySpaceDto,
20 CreateAgentCollectionCmd,
2120 CreateBoardCmd,
2221 CreateDirCmd,
2322 CreateInviteCmd,
@@ -106,11 +105,6 @@
106105
107106export const createBoard = (spaceName: string, cmd: CreateBoardCmd) =>
108107 unwrapApi<unknown>(Apis.issues.create_board({ pathParams: { space_name: spaceName }, data: cmd }))
109
110export const createAgentCollection = (spaceName: string, cmd: CreateAgentCollectionCmd) =>
111 unwrapApi<unknown>(
112 Apis.agents.create_agent_collection({ pathParams: { space_name: spaceName }, data: cmd }),
113 )
114108
115109export const createRoom = (spaceName: string, cmd: CreateRoomCmd) =>
116110 unwrapApi<unknown>(
Modified web/src/features/space-management/ui/space-settings/bans-section.tsx View web/src/features/space-management/ui/space-settings/bans-section.tsx
@@ -29,7 +29,6 @@
2929 allow_speak: 'space.bans.allowSpeak',
3030 allow_push_repo: 'space.bans.allowPushRepo',
3131 allow_comment_any: 'space.bans.allowCommentAny',
32 allow_agent_join: 'space.bans.allowAgentJoin',
3332} as const
3433
3534const BAN_FLAGS = (Object.keys(BAN_FLAG_KEYS) as Array<keyof typeof BAN_FLAG_KEYS>).map(key => ({
@@ -138,7 +137,6 @@
138137 allow_speak: ban.allow_speak,
139138 allow_push_repo: ban.allow_push_repo,
140139 allow_comment_any: ban.allow_comment_any,
141 allow_agent_join: ban.allow_agent_join,
142140 }))
143141 const [reason, setReason] = useState(ban.reason ?? '')
144142 const { t } = useT()
Modified web/src/features/space-management/ui/space-settings/cleanup-section.tsx View web/src/features/space-management/ui/space-settings/cleanup-section.tsx
@@ -35,7 +35,6 @@
3535 object: 'space.cleanup.kind.object',
3636 repo: 'space.cleanup.kind.repo',
3737 board: 'space.cleanup.kind.board',
38 collection: 'space.cleanup.kind.collection',
3938 text: 'space.cleanup.kind.text',
4039 voice: 'space.cleanup.kind.voice',
4140 stage: 'space.cleanup.kind.stage',
@@ -48,9 +47,9 @@
4847/** 支持公开/私有的类型:仓库与频道。 */
4948const VISIBILITY_KINDS = new Set(['repo', 'text', 'voice', 'stage', 'category', 'news', 'guild_room', 'thread'])
5049/** 可以删除的类型(仓库除外,它只能归档)。 */
51const DELETE_KINDS = new Set(['board', 'collection', 'text', 'voice', 'stage', 'category', 'news', 'guild_room', 'thread', 'dir', 'object'])
50const DELETE_KINDS = new Set(['board', 'text', 'voice', 'stage', 'category', 'news', 'guild_room', 'thread', 'dir', 'object'])
5251/** 可见性覆盖存放在目录节点上。 */
53const OVERWRITE_KINDS = new Set(['dir', 'object', 'repo', 'board', 'collection'])
52const OVERWRITE_KINDS = new Set(['dir', 'object', 'repo', 'board'])
5453
5554export function CleanupSection({ spaceName, isOwner }: SpaceSectionProps) {
5655 const { t } = useT()
Modified web/src/features/space-management/ui/space-header.tsx View web/src/features/space-management/ui/space-header.tsx
@@ -1,7 +1,6 @@
11import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
22import { Link } from 'react-router-dom'
33import {
4 Bot,
54 ChevronDown,
65 Download,
76 FolderGit2,
@@ -15,7 +14,6 @@
1514} from 'lucide-react'
1615import type { LucideIcon } from 'lucide-react'
1716import {
18 createAgentCollection,
1917 createBoard,
2018 createInvite,
2119 createRepo,
@@ -38,7 +36,6 @@
3836 | 'repo'
3937 | 'import'
4038 | 'board'
41 | 'agent'
4239 | 'room'
4340 | 'dir'
4441 | 'privacy'
@@ -128,7 +125,6 @@
128125 dialog !== 'repo' &&
129126 dialog !== 'import' &&
130127 dialog !== 'board' &&
131 dialog !== 'agent' &&
132128 dialog !== 'room'
133129 )
134130 return
@@ -245,9 +241,6 @@
245241 <MenuItem icon={SquareKanban} label={t('space.menu.createBoard')} onClick={() => openDialog('board')} />
246242 ) : null}
247243 {canManageChannels ? (
248 <MenuItem icon={Bot} label={t('space.menu.createAgent')} onClick={() => openDialog('agent')} />
249 ) : null}
250 {canManageChannels ? (
251244 <MenuItem icon={Hash} label={t('space.menu.createRoom')} onClick={() => openDialog('room')} />
252245 ) : null}
253246 {canManageChannels ? (
@@ -423,47 +416,6 @@
423416 ]}
424417 onSubmit={async values => {
425418 await createBoard(space.name, {
426 name: String(values.name),
427 description: String(values.description).trim() || null,
428 icon: String(values.icon).trim() || null,
429 parent_dir_id: String(values.parent_dir_id) || null,
430 })
431 onListsChanged()
432 }}
433 onClose={closeDialog}
434 />
435 <FormModal
436 title={t('space.agent.create.title')}
437 description={t('space.agent.create.description')}
438 open={dialog === 'agent'}
439 confirmLabel={t('common.action.create')}
440 fields={[
441 { kind: 'text', key: 'name', label: t('space.agent.nameField'), placeholder: 'research-team' },
442 {
443 kind: 'textarea',
444 key: 'description',
445 label: t('space.agent.description'),
446 optional: true,
447 maxLength: 512,
448 },
449 {
450 kind: 'text',
451 key: 'icon',
452 label: t('space.agent.icon'),
453 placeholder: '🤖',
454 optional: true,
455 maxLength: 16,
456 },
457 {
458 kind: 'select',
459 key: 'parent_dir_id',
460 label: t('space.repo.dir'),
461 initialValue: '',
462 options: [{ value: '', label: t('space.dir.root') }, ...dirOptions],
463 },
464 ]}
465 onSubmit={async values => {
466 await createAgentCollection(space.name, {
467419 name: String(values.name),
468420 description: String(values.description).trim() || null,
469421 icon: String(values.icon).trim() || null,
Modified web/src/pages/landing/ui/landing-page.css View web/src/pages/landing/ui/landing-page.css
@@ -832,7 +832,6 @@
832832 grid-column: span 7;
833833}
834834
835.landing-feature-ai,
836835.landing-feature-board {
837836 grid-column: span 5;
838837}
@@ -860,7 +859,6 @@
860859}
861860
862861.landing-repo-visual,
863.landing-ai-visual,
864862.landing-board-visual,
865863.landing-team-visual {
866864 position: absolute;
@@ -908,61 +906,6 @@
908906 font-size: 18px;
909907}
910908
911.landing-ai-visual {
912 padding: 20px;
913}
914
915.landing-ai-visual > div:first-child {
916 display: grid;
917 grid-template-columns: 10px 1fr auto;
918 align-items: center;
919 gap: 8px;
920 color: #d0d3d5;
921 font-family: var(--font-mono);
922 font-size: 9px;
923}
924
925.landing-ai-pulse {
926 width: 7px;
927 height: 7px;
928 border-radius: 50%;
929 background: var(--landing-accent);
930 box-shadow: 0 0 0 5px rgb(255 132 0 / 10%);
931}
932
933.landing-ai-visual > div:first-child > span:last-child {
934 color: var(--landing-accent-soft);
935}
936
937.landing-ai-bar {
938 height: 4px;
939 margin: 14px 0 18px;
940 overflow: hidden;
941 border-radius: 99px;
942 background: #292e33;
943}
944
945.landing-ai-bar span {
946 display: block;
947 width: 84%;
948 height: 100%;
949 border-radius: inherit;
950 background: var(--landing-accent);
951}
952
953.landing-ai-visual p {
954 display: flex;
955 align-items: center;
956 gap: 8px;
957 margin: 9px 0 0;
958 color: #858c92;
959 font-size: 9px;
960}
961
962.landing-ai-visual p svg {
963 color: #69c487;
964}
965
966909.landing-board-visual {
967910 display: grid;
968911 grid-template-columns: repeat(3, 1fr);
@@ -1386,8 +1329,7 @@
13861329 animation: landing-rise 560ms 80ms cubic-bezier(0.22, 1, 0.36, 1) both;
13871330 }
13881331
1389 .landing-status-dot,
1390 .landing-ai-pulse {
1332 .landing-status-dot {
13911333 animation: landing-pulse 2.6s ease-in-out infinite;
13921334 }
13931335}
@@ -1432,7 +1374,6 @@
14321374
14331375 .landing-feature-repo,
14341376 .landing-feature-team,
1435 .landing-feature-ai,
14361377 .landing-feature-board {
14371378 grid-column: span 6;
14381379 }
@@ -1620,7 +1561,6 @@
16201561
16211562 .landing-feature-repo,
16221563 .landing-feature-team,
1623 .landing-feature-ai,
16241564 .landing-feature-board {
16251565 grid-column: span 1;
16261566 }
@@ -1631,7 +1571,6 @@
16311571 }
16321572
16331573 .landing-repo-visual,
1634 .landing-ai-visual,
16351574 .landing-board-visual,
16361575 .landing-team-visual {
16371576 right: 20px;
@@ -1691,7 +1630,6 @@
16911630 }
16921631
16931632 .landing-repo-visual,
1694 .landing-ai-visual,
16951633 .landing-board-visual,
16961634 .landing-team-visual {
16971635 right: 14px;
@@ -1747,8 +1685,7 @@
17471685 }
17481686
17491687 .landing-status-dot,
1750 .landing-demo-live span,
1751 .landing-ai-pulse {
1688 .landing-demo-live span {
17521689 border: 1px solid CanvasText;
17531690 }
17541691}
Modified web/src/pages/landing/ui/landing-page.tsx View web/src/pages/landing/ui/landing-page.tsx
@@ -3,7 +3,6 @@
33import {
44 ArrowRight,
55 ArrowUpRight,
6 Bot,
76 Boxes,
87 Check,
98 Code2,
@@ -13,7 +12,6 @@
1312 LayoutDashboard,
1413 MessageSquareMore,
1514 Mic2,
16 Sparkles,
1715} from 'lucide-react'
1816import { KernelMark } from '@/shared/brand'
1917import { localeLabel, setLocale, useT } from '@/shared/i18n'
@@ -28,7 +26,6 @@
2826
2927const VALUE_CARDS = [
3028 { icon: Boxes, title: 'landing.value.context.title', body: 'landing.value.context.body' },
31 { icon: Sparkles, title: 'landing.value.ai.title', body: 'landing.value.ai.body' },
3229 { icon: MessageSquareMore, title: 'landing.value.rhythm.title', body: 'landing.value.rhythm.body' },
3330] as const
3431
@@ -41,7 +38,6 @@
4138const FAQS = [
4239 ['landing.faq.one.question', 'landing.faq.one.answer'],
4340 ['landing.faq.two.question', 'landing.faq.two.answer'],
44 ['landing.faq.three.question', 'landing.faq.three.answer'],
4541 ['landing.faq.four.question', 'landing.faq.four.answer'],
4642 ['landing.faq.five.question', 'landing.faq.five.answer'],
4743 ['landing.faq.six.question', 'landing.faq.six.answer'],
@@ -165,19 +161,19 @@
165161 </div>
166162 <span className="landing-change-chip">+184 · −47</span>
167163 </div>
168 <div className="landing-file-row"><span className="landing-file-status">M</span><span>src/agent/context.rs</span><span>+72 −14</span></div>
164 <div className="landing-file-row"><span className="landing-file-status">M</span><span>src/search/context.rs</span><span>+72 −14</span></div>
169165 <div className="landing-file-row"><span className="landing-file-status is-new">A</span><span>src/search/rerank.rs</span><span>+96 −0</span></div>
170166 <div className="landing-file-row"><span className="landing-file-status">M</span><span>README.md</span><span>+16 −33</span></div>
171167 <div className="landing-diff">
172168 <div className="landing-diff-line is-muted"><i>41</i><code>pub async fn hydrate_context(</code></div>
173169 <div className="landing-diff-line is-removed"><i>42</i><code>- cache.get(&amp;key).await?</code></div>
174170 <div className="landing-diff-line is-added"><i>42</i><code>+ context.load(&amp;key, scope).await?</code></div>
175 <div className="landing-diff-line is-added"><i>43</i><code>+ tracing::info!(target: "agent", %scope);</code></div>
171 <div className="landing-diff-line is-added"><i>43</i><code>+ tracing::info!(target: "search", %scope);</code></div>
176172 </div>
177173 </div>
178174 <div className="landing-review-card">
179175 <div className="landing-review-head">
180 <span><Bot size={15} />{t('landing.demo.review')}</span>
176 <span><MessageSquareMore size={15} />{t('landing.demo.review')}</span>
181177 <span className="landing-review-score">02</span>
182178 </div>
183179 <strong>{t('landing.demo.reviewTitle')}</strong>
@@ -236,23 +232,9 @@
236232 <div className="landing-repo-visual" aria-hidden>
237233 <div className="landing-repo-visual-head"><span>atlas / main</span><span>8f31c2a</span></div>
238234 <div><GitBranch size={13} /><span>src</span><em>{t('landing.feature.repo.updated')}</em></div>
239 <div><Code2 size={13} /><span>agent/context.rs</span><em>4m</em></div>
235 <div><Code2 size={13} /><span>search/context.rs</span><em>4m</em></div>
240236 <div><Code2 size={13} /><span>search/rerank.rs</span><em>8m</em></div>
241237 <div><span className="landing-file-dot">·</span><span>README.md</span><em>12m</em></div>
242 </div>
243 </article>
244
245 <article className="landing-feature-card landing-feature-ai">
246 <div className="landing-feature-copy">
247 <span className="landing-feature-label"><Sparkles size={15} aria-hidden />{t('landing.feature.ai.label')}</span>
248 <h3>{t('landing.feature.ai.title')}</h3>
249 <p>{t('landing.feature.ai.body')}</p>
250 </div>
251 <div className="landing-ai-visual" aria-hidden>
252 <div><span className="landing-ai-pulse" /><b>{t('landing.feature.ai.scanning')}</b><span>84%</span></div>
253 <div className="landing-ai-bar"><span /></div>
254 <p><Check size={13} />{t('landing.feature.ai.signalOne')}</p>
255 <p><Check size={13} />{t('landing.feature.ai.signalTwo')}</p>
256238 </div>
257239 </article>
258240
Deleted web/src/pages/workspace/model/agent-api.ts

Diff too large to display.

Deleted web/src/pages/workspace/model/agent-artifacts.ts

Diff too large to display.

Modified web/src/pages/workspace/model/repo-location.ts View web/src/pages/workspace/model/repo-location.ts
@@ -86,12 +86,11 @@
8686 'branches',
8787 'pipelines',
8888 'releases',
89 'ai',
9089 'settings',
9190]
9291const SECTION_VALUES = REPO_SETTINGS_SECTIONS.map(section => section.id)
9392const DIFF_VALUES: readonly DiffStyle[] = ['unified', 'split']
94const PTAB_VALUES: readonly PullSubTab[] = ['conversation', 'files', 'commits', 'checks', 'ai']
93const PTAB_VALUES: readonly PullSubTab[] = ['conversation', 'files', 'commits', 'checks']
9594
9695/** Query keys owned by the repo page — the only ones this hook may rewrite. */
9796const OWNED_KEYS = [
@@ -125,7 +124,6 @@
125124 branches: ['tab', 'rev'],
126125 pipelines: ['tab', 'rev', 'run'],
127126 releases: ['tab', 'rev', 'release'],
128 ai: ['tab', 'rev'],
129127 settings: ['tab', 'rev', 'section'],
130128}
131129
Modified web/src/pages/workspace/model/route.ts View web/src/pages/workspace/model/route.ts
@@ -1,5 +1,5 @@
11/** Main-column variant, taken from the route's static section segment. */
2export type ShellMode = 'chat' | 'board' | 'agent' | 'repo' | 'members' | 'notify' | 'explore'
2export type ShellMode = 'chat' | 'board' | 'repo' | 'members' | 'notify' | 'explore'
33
44/** Repo page tabs, rendered by `RepoLayout`'s default tab strip. */
55export type RepoTabId =
@@ -10,7 +10,6 @@
1010 | 'branches'
1111 | 'pipelines'
1212 | 'releases'
13 | 'ai'
1413 | 'settings'
1514
1615/** Parsed route consumed by the workspace page's data loading. */
@@ -19,7 +18,5 @@
1918 spaceName: string
2019 roomId?: string
2120 boardId?: string
22 agentCollectionId?: string
23 agentId?: string
2421 repoName?: string
2522}
Modified web/src/pages/workspace/model/shell-api.ts View web/src/pages/workspace/model/shell-api.ts
@@ -9,7 +9,6 @@
99import { writeCache } from '@/shared/storage'
1010import { fetchSessionUser } from '@/features/auth'
1111import type {
12 AgentCollectionItem,
1312 BoardItem,
1413 DmItem,
1514 Me,
@@ -63,15 +62,6 @@
6362 pathParams: { space_name: spaceName },
6463 params: { limit: 50 },
6564 }) as unknown as PromiseLike<ApiEnvelope<{ items: BoardItem[] }>>,
66 ).then(p => p?.items ?? [])
67
68// `list_agent_collections` is typed against the generic `Page` schema; items are `AgentCollectionDto`.
69export const fetchSpaceAgentCollections = (spaceName: string) =>
70 unwrapApi<{ items: AgentCollectionItem[] }>(
71 Apis.agents.list_agent_collections({
72 pathParams: { space_name: spaceName },
73 params: { limit: 50 },
74 }) as unknown as PromiseLike<ApiEnvelope<{ items: AgentCollectionItem[] }>>,
7565 ).then(p => p?.items ?? [])
7666
7767/** First page of tag names for the repo toolbar chip. */
Modified web/src/pages/workspace/model/workspace-data.ts View web/src/pages/workspace/model/workspace-data.ts
@@ -3,7 +3,6 @@
33 dmsKey,
44 meKey,
55 repoKey,
6 spaceAgentCollectionsKey,
76 spaceBoardsKey,
87 spaceDirKey,
98 spaceReposKey,
@@ -18,7 +17,6 @@
1817 fetchDms,
1918 fetchMe,
2019 fetchMySpaces,
21 fetchSpaceAgentCollections,
2220 fetchSpaceBoards,
2321 fetchSpaceRepos,
2422 fetchSpaceRooms,
@@ -35,7 +33,7 @@
3533 * the network revalidation runs, and the fresh response replaces it live.
3634 */
3735export function useWorkspaceData(
38 { spaceName, roomId, boardId, agentCollectionId, repoName, mode }: ShellRoute,
36 { spaceName, roomId, boardId, repoName, mode }: ShellRoute,
3937 reloadKey = 0,
4038 listsReloadKey = 0,
4139 meReloadKey = 0,
@@ -66,11 +64,6 @@
6664 [spaceName, listsReloadKey],
6765 { cacheKey: scoped ? spaceBoardsKey(spaceName) : null },
6866 )
69 const agentCollections = useAsync(
70 () => (scoped ? fetchSpaceAgentCollections(spaceName) : Promise.resolve([])),
71 [spaceName, listsReloadKey],
72 { cacheKey: scoped ? spaceAgentCollectionsKey(spaceName) : null },
73 )
7467 const repoRef = mode === 'repo' && repoName ? `${spaceName}/${repoName}` : ''
7568 const tags = useAsync(
7669 () => (repoRef ? fetchTags(spaceName, repoName!) : Promise.resolve([])),
@@ -86,10 +79,10 @@
8679 if (meUserId) void setCacheOwner(meUserId)
8780 }, [meUserId])
8881
89 // The interface language follows the account: `users.language` (shared with
90 // the AI output preference) wins over `users.locale`, and either may fall
91 // through to the device choice when it names a locale this build does not
92 // ship. See `shared/i18n/locale` for the full precedence chain.
82 // The interface language follows the account: `users.language` wins over
83 // `users.locale`, and either may fall through to the device choice when it
84 // names a locale this build does not ship. See `shared/i18n/locale` for the
85 // full precedence chain.
9386 const meLanguage = me.data?.user.language
9487 const meLocale = me.data?.user.locale
9588 useEffect(() => {
@@ -104,14 +97,12 @@
10497 dms,
10598 repos,
10699 boards,
107 agentCollections,
108100 tags,
109101 repoRef,
110102 currentSpace: spaces.data?.find(({ space }) => space.name === spaceName)?.space,
111103 room: rooms.data?.find(r => r.id === roomId),
112104 dm: dms.data?.find(d => d.id === roomId),
113105 board: boards.data?.find(b => b.id === boardId),
114 agentCollection: agentCollections.data?.find(c => c.id === agentCollectionId),
115106 repo: repos.data?.find(r => r.name === repoName),
116107 }
117108}
Modified web/src/pages/workspace/model/workspace-route.ts View web/src/pages/workspace/model/workspace-route.ts
@@ -16,9 +16,7 @@
1616 const rawSection = segments[3] ?? ''
1717 // The id belongs to whichever section is in the URL; a section-less space
1818 // root (`/app/minimax`) has none, exactly like its optional route segment.
19 // The agent section adds a second, deeper id: the selected agent.
2019 const rawId = segments[4] || undefined
21 const rawDetail = segments[5] || undefined
2220 const decode = (value: string | undefined) =>
2321 value === undefined ? undefined : decodeURIComponent(value)
2422 // `/app/explore` is a static sibling of `/app/:spaceName`, so it has no
@@ -31,11 +29,9 @@
3129 ? 'notify'
3230 : section === 'board'
3331 ? 'board'
34 : section === 'agent'
35 ? 'agent'
36 : section === 'members'
37 ? 'members'
38 : section === 'repo'
32 : section === 'members'
33 ? 'members'
34 : section === 'repo'
3935 ? 'repo'
4036 : 'chat'
4137 // `/app/@me…` routes carry no :spaceName param; name the personal scope so
@@ -47,8 +43,6 @@
4743 spaceName: explore ? '' : decode(rawScope) ?? (personal ? '@me' : ''),
4844 roomId: section === 'chat' ? decode(rawId) : undefined,
4945 boardId: section === 'board' ? decode(rawId) : undefined,
50 agentCollectionId: section === 'agent' ? decode(rawId) : undefined,
51 agentId: section === 'agent' ? decode(rawDetail) : undefined,
5246 repoName: section === 'repo' ? decode(rawId) : undefined,
5347 }
5448}
Modified web/src/pages/workspace/model/workspace.ts View web/src/pages/workspace/model/workspace.ts
@@ -4,7 +4,6 @@
44 */
55import type { DmBrief, RoomBrief } from '@/entities/chat'
66import type {
7 AppResponseData_AgentCollectionDto,
87 AppResponseData_BoardDto,
98 AppResponseData_MeResp,
109 AppResponseData_InviteDto,
@@ -24,7 +23,6 @@
2423 NonNullable<AppResponseData_Page_RepoSummaryDto['data']>['items']
2524>[number]
2625export type BoardItem = NonNullable<AppResponseData_BoardDto['data']>
27export type AgentCollectionItem = NonNullable<AppResponseData_AgentCollectionDto['data']>
2826export type Me = NonNullable<AppResponseData_MeResp['data']>
2927export type SpaceDto = NonNullable<AppResponseData_SpaceDto['data']>
3028export type Invite = NonNullable<AppResponseData_InviteDto['data']>
Deleted web/src/pages/workspace/ui/agent/agent-builder-modal.tsx

Diff too large to display.

Deleted web/src/pages/workspace/ui/agent/agent-chat.tsx

Diff too large to display.

Deleted web/src/pages/workspace/ui/agent/agent-message.tsx

Diff too large to display.

Deleted web/src/pages/workspace/ui/agent/agent-share-modal.tsx

Diff too large to display.

Deleted web/src/pages/workspace/ui/agent/agent-view.tsx

Diff too large to display.

Deleted web/src/pages/workspace/ui/agent/artifact-kind.tsx

Diff too large to display.

Modified web/src/pages/workspace/ui/layout/repo-layout.tsx View web/src/pages/workspace/ui/layout/repo-layout.tsx
@@ -1,5 +1,5 @@
11import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2import { Activity, ChevronDown, Code, File, Folder, FolderGit2, GitBranch, GitCommitHorizontal, GitPullRequest, Info, MoreHorizontal, Settings, Sparkles, Tag, type LucideIcon } from 'lucide-react'
2import { Activity, ChevronDown, Code, File, Folder, FolderGit2, GitBranch, GitCommitHorizontal, GitPullRequest, Info, MoreHorizontal, Settings, Tag, type LucideIcon } from 'lucide-react'
33import { useT, type MessageKey } from '@/shared/i18n'
44import { AppShell, ShellTopBar, type AppShellProps } from '@/shared/ui'
55import type { RepoTabId } from '../../model/route'
@@ -55,8 +55,7 @@
5555export interface RepoTabOptions {
5656 allowPullRequests: boolean
5757 pipelinesVisible: boolean
58 aiVisible: boolean
59 /** 中间页签顺序(提交/合并请求/分支/流水线/发布/AI 总结);空 = 默认。 */
58 /** 中间页签顺序(提交/合并请求/分支/流水线/发布);空 = 默认。 */
6059 order: readonly string[]
6160}
6261
@@ -134,7 +133,6 @@
134133 ['branches', 'shell.repo.tabs.branches'],
135134 ['pipelines', 'shell.repo.tabs.pipelines'],
136135 ['releases', 'shell.repo.tabs.releases'],
137 ['ai', 'shell.repo.tabs.ai'],
138136 ['settings', 'shell.repo.tabs.settings'],
139137]
140138
@@ -144,7 +142,7 @@
144142const TAB_LABEL_KEYS = new Map<RepoTabId, MessageKey>(tabLabels)
145143
146144/** 可由仓库设置重排序的中间页签(代码固定最前、设置固定最后)。 */
147const REORDERABLE_TABS: RepoTabId[] = ['commits', 'pulls', 'branches', 'pipelines', 'releases', 'ai']
145const REORDERABLE_TABS: RepoTabId[] = ['commits', 'pulls', 'branches', 'pipelines', 'releases']
148146
149147/** 归一化仓库设置里的顺序:未知/重复项忽略,缺失项按默认顺序补齐。 */
150148function orderTabs(order: readonly string[] | undefined): RepoTabId[] {
@@ -162,9 +160,9 @@
162160const TAB_ITEM_CLASS =
163161 'relative flex h-full shrink-0 items-center gap-1.5 whitespace-nowrap px-3 text-[13.5px] font-medium transition-colors'
164162
165const tabItemClass = (isActive: boolean, id: RepoTabId) =>
163const tabItemClass = (isActive: boolean) =>
166164 `${TAB_ITEM_CLASS} ${
167 isActive ? 'text-[#ff8400]' : id === 'ai' ? 'text-[#7c3aed] hover:text-[#6d28d9]' : 'text-shell-ink-muted hover:text-shell-ink'
165 isActive ? 'text-[#ff8400]' : 'text-shell-ink-muted hover:text-shell-ink'
168166 }`
169167
170168/** 页签图标;`pull` 是合并请求详情的路由 id,与 `pulls` 用同一图标。 */
@@ -176,7 +174,6 @@
176174 branches: GitBranch,
177175 pipelines: Activity,
178176 releases: Tag,
179 ai: Sparkles,
180177 settings: Settings,
181178}
182179
@@ -199,9 +196,6 @@
199196 {count > 99 ? '99+' : count}
200197 </span>
201198 ) : null}
202 {id === 'ai' ? (
203 <span className="rounded bg-[#8b5cf6]/10 px-1 text-[10px] font-semibold leading-4 text-[#6d28d9]">BETA</span>
204 ) : null}
205199 </>
206200 )
207201}
@@ -241,8 +235,6 @@
241235 ? (tabOptions?.allowPullRequests ?? true)
242236 : id === 'pipelines'
243237 ? (tabOptions?.pipelinesVisible ?? true)
244 : id === 'ai'
245 ? (tabOptions?.aiVisible ?? true)
246238 : true,
247239 )
248240 const ids: RepoTabId[] = ['code', ...middle, ...(canAdmin ? (['settings'] as RepoTabId[]) : [])]
@@ -333,7 +325,7 @@
333325 {labels.map(([id, key]) => {
334326 const isActive = tabGroup(active) === id
335327 return (
336 <span key={id} className={tabItemClass(isActive, id)}>
328 <span key={id} className={tabItemClass(isActive)}>
337329 <TabContent id={id} labelKey={key} pullsCount={pullsCount} />
338330 {isActive ? <TabIndicator /> : null}
339331 </span>
@@ -369,7 +361,7 @@
369361 type="button"
370362 aria-current={isActive ? 'page' : undefined}
371363 onClick={() => onChange(id)}
372 className={tabItemClass(isActive, id)}
364 className={tabItemClass(isActive)}
373365 >
374366 <TabContent id={id} labelKey={key} pullsCount={pullsCount} />
375367 {isActive ? <TabIndicator /> : null}
Modified web/src/pages/workspace/ui/repo/repo-view.tsx View web/src/pages/workspace/ui/repo/repo-view.tsx
@@ -17,7 +17,6 @@
1717import { PullsTab, PullDetail } from '@/features/pull-request'
1818import { SettingsTab } from '@/features/repo-settings'
1919import { PipelinesView } from '@/features/repo-pipelines'
20import { AiSummaryTab } from '@/features/repo-ai'
2120import { ReleasesTab } from '@/features/repo-releases'
2221import { RefPicker } from './ref-picker'
2322import { RepoCloneMenu } from './clone-menu'
@@ -146,8 +145,7 @@
146145 if (!repo) return
147146 const prHidden = !repo.allow_pull_requests && (tab === 'pulls' || tab === 'pull')
148147 const pipelinesHidden = !(repo.pipelines_public || isSpaceMember || canManageRepo) && tab === 'pipelines'
149 const aiHidden = !(repo.ai_summary_public || isSpaceMember || canManageRepo) && tab === 'ai'
150 if (prHidden || pipelinesHidden || aiHidden) patch({ tab: 'code' }, { replace: true })
148 if (prHidden || pipelinesHidden) patch({ tab: 'code' }, { replace: true })
151149 }, [repo, tab, isSpaceMember, canManageRepo, patch])
152150
153151 if (repoState.loading && !repo) {
@@ -210,7 +208,6 @@
210208 tabOptions={{
211209 allowPullRequests: repo.allow_pull_requests,
212210 pipelinesVisible: repo.pipelines_public || isSpaceMember || canManageRepo,
213 aiVisible: repo.ai_summary_public || isSpaceMember || canManageRepo,
214211 order: repo.tab_order,
215212 }}
216213 aside={
@@ -337,7 +334,6 @@
337334 onSelectRun={runId => patch({ run: runId })}
338335 />
339336 ) : null}
340 {tab === 'ai' ? <AiSummaryTab space={spaceName} repo={repoName} rev={rev} /> : null}
341337 {tab === 'releases' ? (
342338 <ReleasesTab
343339 space={spaceName}
Modified web/src/pages/workspace/ui/asset-menu.tsx View web/src/pages/workspace/ui/asset-menu.tsx
@@ -1,16 +1,14 @@
11import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
22import { useNavigate } from 'react-router-dom'
3import { Bot, ChevronDown, FolderGit2, Hash, Search, SquareKanban } from 'lucide-react'
3import { ChevronDown, FolderGit2, Hash, Search, SquareKanban } from 'lucide-react'
44import { useT, type MessageKey } from '@/shared/i18n'
55import {
6 spaceAgentCollectionsKey,
76 spaceBoardsKey,
87 spaceReposKey,
98 spaceRoomsKey,
109 useAsync,
1110} from '@/shared/lib'
1211import {
13 fetchSpaceAgentCollections,
1412 fetchSpaceBoards,
1513 fetchSpaceRepos,
1614 fetchSpaceRooms,
@@ -18,7 +16,7 @@
1816import { useShellRoute } from '../model/workspace-route'
1917
2018/** 可被名字直达的空间资产种类(与侧边栏顶层行一一对应)。 */
21type AssetKind = 'repo' | 'board' | 'agent' | 'room'
19type AssetKind = 'repo' | 'board' | 'room'
2220
2321interface AssetOption {
2422 key: string
@@ -28,19 +26,17 @@
2826}
2927
3028/** 默认态分组顺序;搜索时同一批数据参与打分排序。 */
31const KINDS: AssetKind[] = ['repo', 'board', 'agent', 'room']
29const KINDS: AssetKind[] = ['repo', 'board', 'room']
3230
3331const KIND_ICONS: Record<AssetKind, ReactNode> = {
3432 repo: <FolderGit2 className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />,
3533 board: <SquareKanban className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />,
36 agent: <Bot className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />,
3734 room: <Hash className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />,
3835}
3936
4037const KIND_LABEL_KEYS: Record<AssetKind, MessageKey> = {
4138 repo: 'shell.nav.repo',
4239 board: 'shell.nav.board',
43 agent: 'agent.titleFallback',
4440 room: 'shell.sidebar.rooms',
4541}
4642
@@ -76,11 +72,6 @@
7672 [spaceName, scoped],
7773 { cacheKey: scoped ? spaceBoardsKey(spaceName) : null },
7874 )
79 const collections = useAsync(
80 () => (scoped ? fetchSpaceAgentCollections(spaceName) : Promise.resolve([])),
81 [spaceName, scoped],
82 { cacheKey: scoped ? spaceAgentCollectionsKey(spaceName) : null },
83 )
8475 const rooms = useAsync(
8576 () => (scoped ? fetchSpaceRooms(spaceName) : Promise.resolve([])),
8677 [spaceName, scoped],
@@ -114,14 +105,6 @@
114105 href: `/app/${spacePath}/board/${encodeURIComponent(board.id)}`,
115106 })
116107 }
117 for (const collection of collections.data ?? []) {
118 rows.push({
119 key: `agent:${collection.id}`,
120 kind: 'agent',
121 name: collection.name,
122 href: `/app/${spacePath}/agent/${encodeURIComponent(collection.id)}`,
123 })
124 }
125108 for (const room of rooms.data ?? []) {
126109 if (room.kind === 'Category' || room.kind === 'Thread' || room.hidden) continue
127110 rows.push({
@@ -132,7 +115,7 @@
132115 })
133116 }
134117 return rows
135 }, [scoped, spaceName, repos.data, boards.data, collections.data, rooms.data])
118 }, [scoped, spaceName, repos.data, boards.data, rooms.data])
136119
137120 const needle = query.trim().toLowerCase()
138121 const matched = useMemo(() => {
@@ -172,7 +155,7 @@
172155 () => rows.filter((row): row is Extract<Row, { type: 'item' }> => row.type === 'item'),
173156 [rows],
174157 )
175 const loading = repos.loading || boards.loading || collections.loading || rooms.loading
158 const loading = repos.loading || boards.loading || rooms.loading
176159 const activeIndex = items.length === 0 ? 0 : Math.min(active, items.length - 1)
177160
178161 useEffect(() => {
Modified web/src/pages/workspace/ui/sidebar-nav.tsx View web/src/pages/workspace/ui/sidebar-nav.tsx
@@ -2,13 +2,11 @@
22import { Link } from 'react-router-dom'
33import {
44 AtSign,
5 Bot,
65 ChevronRight,
76 File,
87 Folder,
98 FolderGit2,
109 Hash,
11 Loader2,
1210 SquareKanban,
1311 Star,
1412 Volume2,
@@ -17,14 +15,13 @@
1715import { useT } from '@/shared/i18n'
1816import { ApiFailure } from '@/shared/lib'
1917import { getVoiceSpaceStates, isVoiceRoom } from '@/entities/chat'
20import { fetchAgents, type AgentDto } from '../model/agent-api'
2118import { betweenPositions, STRIDE } from '../model/nav-order'
2219import {
2320 prefetchBoardView,
2421 prefetchRepoView,
2522 prefetchRoomView,
2623} from '../model/prefetch'
27import type { AgentCollectionItem, BoardItem, DirNode, DmItem, RepoItem, RoomBrief } from '../model/workspace'
24import type { BoardItem, DirNode, DmItem, RepoItem, RoomBrief } from '../model/workspace'
2825
2926export interface SidebarNavProps {
3027 spaceName: string
@@ -34,11 +31,8 @@
3431 dms: DmItem[]
3532 repos: RepoItem[]
3633 boards: BoardItem[]
37 agents: AgentCollectionItem[]
3834 activeRoomId?: string
3935 activeBoardId?: string
40 activeAgentCollectionId?: string
41 activeAgentId?: string
4236 activeRepoName?: string
4337 /** Both move endpoints require the space's `manage_channels` permission. */
4438 canOrganize?: boolean
@@ -47,7 +41,7 @@
4741}
4842
4943/** What a row is, after mapping the directory tree onto the space's assets. */
50type RowKind = 'folder' | 'repo' | 'board' | 'agent' | 'file' | 'room' | 'dm'
44type RowKind = 'folder' | 'repo' | 'board' | 'file' | 'room' | 'dm'
5145
5246/** One row of the view model, positioned inside its (effective) parent level. */
5347interface View {
@@ -93,11 +87,8 @@
9387 dms,
9488 repos,
9589 boards,
96 agents,
9790 activeRoomId,
9891 activeBoardId,
99 activeAgentCollectionId,
100 activeAgentId,
10192 activeRepoName,
10293 canOrganize = false,
10394 onChanged,
@@ -149,66 +140,6 @@
149140 }
150141 }, [spaceName])
151142
152 /* ------------------------------------------------- agent submenu ------- */
153
154 // 智能体集在导航里就地展开:成员按需拉取并缓存,URL 上的集自动展开。
155 const [openAgents, setOpenAgents] = useState<Set<string>>(() => new Set())
156 const [collectionAgents, setCollectionAgents] = useState<Record<string, AgentDto[]>>({})
157 const [loadingAgents, setLoadingAgents] = useState<Set<string>>(() => new Set())
158 const agentsCache = useRef<Record<string, AgentDto[]>>({})
159 // 同步的并发闸门(渲染用的是 loadingAgents 状态,ref 只在回调里读写)。
160 const loadingRef = useRef<Set<string>>(new Set())
161
162 const loadCollection = useCallback(
163 async (collectionId: string, force = false) => {
164 if (!force && agentsCache.current[collectionId]) {
165 setCollectionAgents(agentsCache.current)
166 return
167 }
168 if (loadingRef.current.has(collectionId)) return
169 loadingRef.current.add(collectionId)
170 setLoadingAgents(current => new Set([...current, collectionId]))
171 try {
172 const rows = await fetchAgents(spaceName, collectionId)
173 agentsCache.current = { ...agentsCache.current, [collectionId]: rows }
174 setCollectionAgents(agentsCache.current)
175 } catch {
176 // 加载失败不阻塞导航;下次展开或列表刷新时会重试。
177 } finally {
178 loadingRef.current.delete(collectionId)
179 setLoadingAgents(current => {
180 const next = new Set(current)
181 next.delete(collectionId)
182 return next
183 })
184 }
185 },
186 [spaceName],
187 )
188
189 const toggleAgents = useCallback(
190 (collectionId: string) => {
191 const opening = !openAgents.has(collectionId)
192 setOpenAgents(current => {
193 const next = new Set(current)
194 if (opening) next.add(collectionId)
195 else next.delete(collectionId)
196 return next
197 })
198 if (opening) void loadCollection(collectionId)
199 },
200 [openAgents, loadCollection],
201 )
202
203 // 集列表刷新(新建/删除智能体后)时重载已展开的集。
204 const openAgentsRef = useRef(openAgents)
205 useEffect(() => {
206 openAgentsRef.current = openAgents
207 }, [openAgents])
208 useEffect(() => {
209 for (const id of openAgentsRef.current) void loadCollection(id, true)
210 }, [agents, loadCollection])
211
212143 /* ------------------------------------------------------ view model ----- */
213144
214145 const flat: { node: DirNode; parent: string | null }[] = []
@@ -229,7 +160,6 @@
229160 const dirIds = new Set(flat.map(({ node }) => node.id))
230161 const repoByName = new Map(repos.map(repo => [repo.name.toLowerCase(), repo]))
231162 const boardByDir = new Map(boards.map(board => [board.dir_id, board]))
232 const agentByDir = new Map(agents.map(agent => [agent.dir_id, agent]))
233163 const visibleRooms = rooms.filter(
234164 room => room.kind !== 'Category' && room.kind !== 'Thread' && !room.hidden,
235165 )
@@ -243,7 +173,6 @@
243173
244174 const coveredRepoIds = new Set<string>()
245175 const coveredBoardIds = new Set<string>()
246 const coveredAgentIds = new Set<string>()
247176 for (const { node, parent } of flat) {
248177 const key = `d:${node.id}`
249178 const edit = edits[key]
@@ -253,7 +182,6 @@
253182 let href: string | null = null
254183 let unread = 0
255184 const board = boardByDir.get(node.id)
256 const agent = agentByDir.get(node.id)
257185 const repo = repoByName.get(node.name.toLowerCase())
258186 if (node.kind === 'Dir') kind = 'folder'
259187 else if (board) {
@@ -261,11 +189,6 @@
261189 href = `/app/${spacePath}/board/${encodeURIComponent(board.id)}`
262190 unread = board.open_issues_count
263191 coveredBoardIds.add(board.id)
264 } else if (agent) {
265 kind = 'agent'
266 href = `/app/${spacePath}/agent/${encodeURIComponent(agent.id)}`
267 unread = agent.agent_count
268 coveredAgentIds.add(agent.id)
269192 } else if (repo) {
270193 kind = 'repo'
271194 href = `/app/${spacePath}/repo/${encodeURIComponent(repo.name)}`
@@ -283,7 +206,7 @@
283206 unread,
284207 favorite: false,
285208 voice: false,
286 assetId: board?.id ?? agent?.id,
209 assetId: board?.id,
287210 })
288211 }
289212 for (const room of visibleRooms) {
@@ -355,25 +278,6 @@
355278 assetId: board.id,
356279 })),
357280 )
358 appendTail(
359 agents
360 .filter(agent => !coveredAgentIds.has(agent.id))
361 .map(agent => ({
362 key: `x:agent-${agent.id}`,
363 type: 'dir' as const,
364 id: '',
365 kind: 'agent' as RowKind,
366 label: agent.name,
367 href: `/app/${spacePath}/agent/${encodeURIComponent(agent.id)}`,
368 parentId: null,
369 position: 0,
370 unread: agent.agent_count,
371 favorite: false,
372 voice: false,
373 assetId: agent.id,
374 })),
375 )
376 // 私聊属于个人作用域:只在 `@me` 的导航里渲染,空间的导航不出现。
377281 if (spaceName === '@me') {
378282 appendTail(
379283 dms.map(dm => ({
@@ -542,11 +446,9 @@
542446 void prefetchRepoView(spaceName, view.label)
543447 } else if (view.kind === 'board' && view.assetId) {
544448 prefetchBoardView(spaceName, view.assetId)
545 } else if (view.kind === 'agent' && view.assetId) {
546 void loadCollection(view.assetId)
547449 }
548450 },
549 [spaceName, loadCollection],
451 [spaceName],
550452 )
551453
552454 /** Middle band of a folder = "into it"; elsewhere the half decides before/after. */
@@ -561,73 +463,12 @@
561463
562464 /* ---------------------------------------------------------- render ----- */
563465
564 /** One collection's agents, rendered as indented rows under its nav row. */
565 function renderAgentChildren(collectionId: string, depth: number): ReactNode[] {
566 const rows = collectionAgents[collectionId]
567 if (!rows) {
568 if (!loadingAgents.has(collectionId)) return []
569 return [
570 <p
571 key={`agent-loading-${collectionId}`}
572 className="flex h-7 items-center gap-1.5 text-[11.5px] text-shell-ink-muted"
573 style={{ paddingLeft: 8 + depth * 14 }}
574 >
575 <Loader2 className="h-3 w-3 animate-spin" aria-hidden />
576 {t('ui.loading')}
577 </p>,
578 ]
579 }
580 if (rows.length === 0) {
581 return [
582 <p
583 key={`agent-empty-${collectionId}`}
584 className="py-1 text-[11.5px] text-shell-ink-muted"
585 style={{ paddingLeft: 8 + depth * 14 }}
586 >
587 {t('agent.menu.empty')}
588 </p>,
589 ]
590 }
591 return rows.map(agent => {
592 const active = agent.id === activeAgentId
593 return (
594 <Link
595 key={agent.id}
596 to={`/app/${spacePath}/agent/${encodeURIComponent(collectionId)}/${encodeURIComponent(agent.id)}`}
597 title={agent.name}
598 aria-current={active ? 'page' : undefined}
599 className={`flex h-7 items-center gap-1.5 rounded-md pr-2 text-[13px] font-medium ${
600 active
601 ? 'bg-shell-active text-shell-ink'
602 : 'text-shell-ink/60 hover:bg-shell-active/60 hover:text-shell-ink'
603 }`}
604 style={{ paddingLeft: 8 + depth * 14 }}
605 >
606 {agent.avatar_url ? (
607 <img
608 src={agent.avatar_url}
609 alt=""
610 className="h-3.5 w-3.5 shrink-0 rounded-full object-cover"
611 loading="lazy"
612 />
613 ) : (
614 <Bot className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
615 )}
616 <span className="truncate">{agent.name}</span>
617 <span className="ml-auto shrink-0 text-[11px] text-shell-ink-muted">#{agent.number}</span>
618 </Link>
619 )
620 })
621 }
622
623466 function renderLevel(parentId: string | null, depth: number, seen: Set<string>): ReactNode[] {
624467 const rows: ReactNode[] = []
625468 for (const view of levels.get(parentId) ?? []) {
626469 if (view.kind === 'folder' && seen.has(view.id)) continue
627470 const isFolder = view.kind === 'folder'
628471 const isCollapsed = isFolder && collapsed.has(view.id)
629 const isAgent = view.kind === 'agent' && Boolean(view.assetId)
630 const agentOpen = isAgent && openAgents.has(view.assetId as string)
631472 rows.push(
632473 <Row
633474 key={view.key}
@@ -638,22 +479,17 @@
638479 (view.kind === 'room' && view.id === activeRoomId) ||
639480 (view.kind === 'dm' && Boolean(activeRoomId) && view.assetId === activeRoomId) ||
640481 (view.kind === 'board' && Boolean(activeBoardId) && view.assetId === activeBoardId) ||
641 (view.kind === 'agent' && Boolean(activeAgentCollectionId) && view.assetId === activeAgentCollectionId) ||
642482 (view.kind === 'repo' && view.label === activeRepoName)
643483 }
644484 dragging={dragKey === view.key}
645485 drop={target?.key === view.key ? target.mode : null}
646486 onToggle={() => setCollapsed(current => (current.has(view.id) ? without(current, view.id) : new Set([...current, view.id])))}
647 expandable={isAgent}
648 expanded={agentOpen}
649 onExpand={isAgent ? () => toggleAgents(view.assetId as string) : undefined}
650487 handlers={handlersFor(view)}
651488 onHover={hoverPrefetch}
652489 voiceCount={spaceName === '@me' ? undefined : voiceCounts[view.id]}
653490 />,
654491 )
655492 if (isFolder && !isCollapsed) rows.push(...renderLevel(view.id, depth + 1, new Set([...seen, view.id])))
656 if (agentOpen) rows.push(...renderAgentChildren(view.assetId as string, depth + 1))
657493 }
658494 return rows
659495 }
@@ -725,7 +561,6 @@
725561 dm: <AtSign className="h-4 w-4" />,
726562 repo: <FolderGit2 className="h-4 w-4" />,
727563 board: <SquareKanban className="h-4 w-4" />,
728 agent: <Bot className="h-4 w-4" />,
729564 file: <File className="h-4 w-4 shrink-0 text-shell-ink-muted" aria-hidden />,
730565}
731566
@@ -737,9 +572,6 @@
737572 dragging,
738573 drop,
739574 onToggle,
740 expandable,
741 expanded,
742 onExpand,
743575 handlers,
744576 onHover,
745577 voiceCount,
@@ -751,10 +583,6 @@
751583 dragging: boolean
752584 drop: DropMode | null
753585 onToggle: () => void
754 /** Agent collections carry a chevron that opens their agent submenu. */
755 expandable?: boolean
756 expanded?: boolean
757 onExpand?: () => void
758586 handlers: Record<string, unknown>
759587 /** Hover prefetch, delayed so a cursor sweeping the list fires nothing. */
760588 onHover: (view: View) => void
@@ -849,41 +677,6 @@
849677 </>
850678 )
851679
852 if (expandable && view.href) {
853 return (
854 <div
855 {...handlers}
856 data-kind="agent"
857 title={view.label}
858 className={`${shared} ${
859 active ? 'bg-shell-active text-shell-ink' : 'text-shell-ink/60 hover:bg-shell-active/60 hover:text-shell-ink'
860 }`}
861 style={indent}
862 >
863 <Link
864 {...hover}
865 to={view.href}
866 aria-current={active ? 'page' : undefined}
867 className="flex min-w-0 flex-1 items-center gap-1.5"
868 >
869 {body}
870 </Link>
871 {/* 展开图标放行尾,贴着计数徽标,不与频道图标争夺头部注意力 */}
872 <button
873 type="button"
874 aria-expanded={expanded}
875 aria-label={t('agent.menu.toggle')}
876 onClick={onExpand}
877 className="flex h-5 w-3.5 shrink-0 items-center justify-center rounded text-shell-ink-muted hover:bg-shell-surface"
878 >
879 <ChevronRight
880 className={`h-3.5 w-3.5 transition-transform ${expanded ? 'rotate-90' : ''}`}
881 aria-hidden
882 />
883 </button>
884 </div>
885 )
886 }
887680 if (!view.href) {
888681 return (
889682 <div {...handlers} className={`${shared} text-shell-ink/60`} style={indent}>
Modified web/src/pages/workspace/ui/workspace-shell.tsx View web/src/pages/workspace/ui/workspace-shell.tsx
@@ -2,7 +2,6 @@
22import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'
33import { Loader2 } from 'lucide-react'
44import { BoardView } from './board/board-view'
5import { AgentView } from './agent/agent-view'
65import { ChatLayout } from './layout/chat-layout'
76import { ChatView } from './chat/chat-view'
87import { VoiceCallBar } from '@/features/chat'
@@ -37,7 +36,7 @@
3736 // notification points here); the param is consumed on open so a later
3837 // manual open starts from the default section again.
3938 const [searchParams, setSearchParams] = useSearchParams()
40 const { mode, spaceName, roomId, boardId, agentCollectionId, agentId, repoName } = route
39 const { mode, spaceName, roomId, boardId, repoName } = route
4140 const exploring = mode === 'explore'
4241 const [creatingSpace, setCreatingSpace] = useState(false)
4342 const [spaceReload, setSpaceReload] = useState(0)
@@ -50,8 +49,8 @@
5049 const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined)
5150 const badgeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
5251 const {
53 me, spaces, rooms, dirs, dms, repos, boards, agentCollections,
54 room, dm, board, agentCollection,
52 me, spaces, rooms, dirs, dms, repos, boards,
53 room, dm, board,
5554 } = useWorkspaceData(route, spaceReload, listsReload, meReload)
5655 const requestedSection = searchParams.get('settings')
5756 const consumeSectionParam = useCallback(() => {
@@ -128,14 +127,12 @@
128127 ? t('shell.titleRepo', { repo: repoName, space: spaceName })
129128 : mode === 'board' && boardId
130129 ? t('shell.titleBoard', { board: board?.name || boardId, space: spaceName })
131 : mode === 'agent' && agentCollectionId
132 ? t('shell.titleAgent', { collection: agentCollection?.name || agentCollectionId, space: spaceName })
133130 : roomId
134131 ? t('shell.titleChat', { room: room?.name || dm?.name || roomId, space: spaceName })
135132 : spaceName && spaceName !== '@me'
136133 ? t('shell.titleSpace', { space: spaceName })
137134 : base
138 }, [t, mode, spaceName, repoName, boardId, board?.name, agentCollectionId, agentCollection?.name, roomId, room?.name, dm?.name])
135 }, [t, mode, spaceName, repoName, boardId, board?.name, roomId, room?.name, dm?.name])
139136
140137 if (me.error instanceof ApiFailure && me.error.code === 401) {
141138 return <Navigate to="/auth/login" replace />
@@ -209,11 +206,8 @@
209206 dms={dms.data ?? []}
210207 repos={repos.data ?? []}
211208 boards={boards.data ?? []}
212 agents={agentCollections.data ?? []}
213209 activeRoomId={roomId}
214210 activeBoardId={boardId}
215 activeAgentCollectionId={agentCollectionId}
216 activeAgentId={agentId}
217211 activeRepoName={repoName}
218212 canOrganize={canOrganize}
219213 onChanged={() => setListsReload(key => key + 1)}
@@ -309,28 +303,6 @@
309303 viewerId={me.data?.user.user_id}
310304 shell={shell}
311305 onCountsChanged={() => setListsReload(key => key + 1)}
312 />
313 {createSpaceModal}
314 {settingsModal}
315 {spaceSettingsModal}
316 <NotifyToaster />
317
318 <VoiceCallBar />
319 <GlobalSearchCommand scope={searchScope} />
320 </>
321 )
322 }
323
324 if (mode === 'agent') {
325 return (
326 <>
327 <AgentView
328 spaceName={spaceName}
329 collectionId={agentCollectionId ?? ''}
330 agentId={agentId}
331 collectionHint={agentCollection}
332 shell={shell}
333 onChanged={() => setListsReload(key => key + 1)}
334306 />
335307 {createSpaceModal}
336308 {settingsModal}
Modified web/src/shared/api/apiDefinitions.ts View web/src/shared/api/apiDefinitions.ts
@@ -100,51 +100,6 @@
100100 * **Do not edit the file manually.**
101101 */
102102export default {
103 'agents.list_agent_collections': ['GET', '/agents/{space_name}/collections'],
104 'agents.create_agent_collection': ['POST', '/agents/{space_name}/collections'],
105 'agents.get_agent_collection': ['GET', '/agents/{space_name}/collections/{collection_id}'],
106 'agents.delete_agent_collection': ['DELETE', '/agents/{space_name}/collections/{collection_id}'],
107 'agents.update_agent_collection': ['PATCH', '/agents/{space_name}/collections/{collection_id}'],
108 'agents.list_agents': ['GET', '/agents/{space_name}/collections/{collection_id}/agents'],
109 'agents.create_agent': ['POST', '/agents/{space_name}/collections/{collection_id}/agents'],
110 'agents.get_agent': ['GET', '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}'],
111 'agents.delete_agent': ['DELETE', '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}'],
112 'agents.update_agent': ['PATCH', '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}'],
113 'agents.list_agent_approvals': [
114 'GET',
115 '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/approvals'
116 ],
117 'agents.decide_agent_approval': [
118 'POST',
119 '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/approvals/{approval_id}/decide'
120 ],
121 'agents.agent_avatar': ['GET', '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/avatar'],
122 'agents.upload_agent_avatar': ['PUT', '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/avatar'],
123 'agents.list_agent_messages': ['GET', '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/messages'],
124 'agents.send_agent_message': ['POST', '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/messages'],
125 'agents.list_agent_questions': [
126 'GET',
127 '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/questions'
128 ],
129 'agents.answer_agent_questions': [
130 'POST',
131 '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/questions/{question_id}/answer'
132 ],
133 'agents.list_agent_runs': ['GET', '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/runs'],
134 'agents.cancel_agent_run': [
135 'POST',
136 '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/runs/{run_id}/cancel'
137 ],
138 'agents.create_agent_stream_token': [
139 'POST',
140 '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/stream-token'
141 ],
142 'agents.list_agent_tool_calls': [
143 'GET',
144 '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/tool-calls'
145 ],
146 'agents.builder_chat': ['POST', '/agents/{space_name}/collections/{collection_id}/builder'],
147 'ai.list_ai_languages': ['GET', '/ai/languages'],
148103 'auth.totp_disable': ['POST', '/auth/2fa/disable'],
149104 'auth.totp_enable': ['POST', '/auth/2fa/enable'],
150105 'auth.recovery_codes': ['GET', '/auth/2fa/recovery-codes'],
@@ -420,9 +375,6 @@
420375 'repos.create_repo': ['POST', '/repos/{space_name}'],
421376 'repos.get_repo': ['GET', '/repos/{space_name}/{repo_name}'],
422377 'repos.update_repo': ['PATCH', '/repos/{space_name}/{repo_name}'],
423 'ai.trigger_commit_ai_review': ['POST', '/repos/{space_name}/{repo_name}/ai-review'],
424 'ai.get_ai_summary': ['GET', '/repos/{space_name}/{repo_name}/ai-summary'],
425 'ai.trigger_ai_summary': ['POST', '/repos/{space_name}/{repo_name}/ai-summary'],
426378 'analysis.code_activity': ['GET', '/repos/{space_name}/{repo_name}/analysis/code-activity'],
427379 'analysis.contributors': ['GET', '/repos/{space_name}/{repo_name}/analysis/contributors'],
428380 'analysis.languages_read': ['GET', '/repos/{space_name}/{repo_name}/analysis/languages'],
@@ -472,7 +424,6 @@
472424 'commits.is_ancestor': ['GET', '/repos/{space_name}/{repo_name}/commits/ancestor'],
473425 'commits.commit_stats': ['GET', '/repos/{space_name}/{repo_name}/commits/stats'],
474426 'commits.get_commit': ['GET', '/repos/{space_name}/{repo_name}/commits/{revision}'],
475 'ai.list_commit_ai_reviews': ['GET', '/repos/{space_name}/{repo_name}/commits/{revision}/ai-reviews'],
476427 'conflicts.get_conflicts': ['GET', '/repos/{space_name}/{repo_name}/conflicts'],
477428 'conflicts.conflict_detail': ['GET', '/repos/{space_name}/{repo_name}/conflicts/detail'],
478429 'diffs.get_diff': ['GET', '/repos/{space_name}/{repo_name}/diff'],
@@ -508,14 +459,10 @@
508459 'pipelines.rerun': ['POST', '/repos/{space_name}/{repo_name}/pipelines/{run_id}/rerun'],
509460 'pulls.list_pulls': ['GET', '/repos/{space_name}/{repo_name}/pulls'],
510461 'pulls.create_pull': ['POST', '/repos/{space_name}/{repo_name}/pulls'],
511 'pulls.ai_fill_pull': ['POST', '/repos/{space_name}/{repo_name}/pulls/ai-fill'],
512462 'pulls.list_label_options': ['GET', '/repos/{space_name}/{repo_name}/pulls/label-options'],
513463 'pulls.get_pull': ['GET', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}'],
514464 'pulls.delete_pull': ['DELETE', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}'],
515465 'pulls.update_pull': ['PATCH', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}'],
516 'ai.trigger_ai_review': ['POST', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}/ai-review'],
517 'ai.list_ai_reviews': ['GET', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}/ai-reviews'],
518 'ai.get_ai_review': ['GET', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}/ai-reviews/{review_id}'],
519466 'pulls.list_pull_assignees': ['GET', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}/assignees'],
520467 'pulls.add_pull_assignee': ['POST', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}/assignees/{user_ref}'],
521468 'pulls.remove_pull_assignee': ['DELETE', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}/assignees/{user_ref}'],
@@ -564,7 +511,6 @@
564511 'releases.get_release': ['GET', '/repos/{space_name}/{repo_name}/releases/{tag}'],
565512 'releases.delete_release': ['DELETE', '/repos/{space_name}/{repo_name}/releases/{tag}'],
566513 'releases.update_release': ['PATCH', '/repos/{space_name}/{repo_name}/releases/{tag}'],
567 'releases.redraft_release': ['POST', '/repos/{space_name}/{repo_name}/releases/{tag}/ai-draft'],
568514 'remotes.list_remotes': ['GET', '/repos/{space_name}/{repo_name}/remotes'],
569515 'remotes.fetch': ['POST', '/repos/{space_name}/{repo_name}/remotes/fetch'],
570516 '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.

Deleted web/src/shared/i18n/messages/en/agent.ts

Diff too large to display.

Modified web/src/shared/i18n/messages/en/index.ts View web/src/shared/i18n/messages/en/index.ts
@@ -1,5 +1,4 @@
11import type { MessageKey } from '../zh-CN'
2import { agent } from './agent'
32import { auth } from './auth'
43import { chat } from './chat'
54import { chatComposer } from './chatComposer'
@@ -37,7 +36,6 @@
3736 ...repo,
3837 ...issues,
3938 ...issueEvents,
40 ...agent,
4139 ...space,
4240 ...misc,
4341 ...nav,
Modified web/src/shared/i18n/messages/en/issues.ts View web/src/shared/i18n/messages/en/issues.ts
@@ -46,12 +46,6 @@
4646 'issue.watch.watching': 'Watching',
4747 'issue.watch.unwatched': 'Default',
4848 'issue.watch.ignore': 'Ignoring',
49 'pull.ai.autoLanguage': 'Follow the content language',
50 'pull.ai.outputLanguage': 'Output language',
51 'pull.ai.followPreference': 'Follow my preference',
52 'pull.ai.defaultOption': ' (default)',
53 'pull.ai.verdict.changesRequested': 'Changes requested',
54 'pull.ai.verdict.commented': 'Comment only',
5549 'pull.new': 'New pull request',
5650 'pull.title': 'Title',
5751 'pull.description': 'Description',
@@ -63,7 +57,6 @@
6357 'pull.tabs.conversation': 'Conversation',
6458 'pull.tabs.files': 'Files changed',
6559 'pull.tabs.commits': 'Commits',
66 'pull.tabs.ai': 'AI review',
6760 'pull.state.open': 'Open',
6861 'pull.state.merged': 'Merged',
6962 'pull.state.closed': 'Closed',
@@ -460,21 +453,6 @@
460453 'pull.parts.metaOnly': 'Metadata-only change.',
461454 'pull.parts.addComment': 'Add a review comment on this line',
462455 'pull.parts.truncated': 'The diff is long; showing the first 1500 lines.',
463 'pull.ai.notice': 'Review job submitted ({model}{scope}); refresh once it finishes.',
464 'pull.ai.triggerFailed': 'Could not trigger the AI review',
465 'pull.ai.loading': 'Loading AI reviews…',
466 'pull.ai.errorAi': 'AI review',
467 'pull.ai.rerun': 'Review again',
468 'pull.ai.refresh': 'Refresh',
469 'pull.ai.currentVersion': 'Current revision',
470 'pull.ai.oldVersion': 'Based on an older revision {sha}',
471 'pull.ai.truncated': 'The diff was truncated',
472 'pull.ai.noSummary': '(no summary)',
473 'pull.ai.jobError': 'Job error: {error}',
474 'pull.ai.locate': 'Locate this line in the changed files',
475 'pull.ai.oldLine': 'old line',
476 'pull.ai.noFindings': 'Nothing needs changing.',
477 'pull.ai.empty': 'This pull request has no AI review yet. With [ai] enabled, opening or updating the PR reviews it automatically; you can also trigger one with the button above.',
478456 'pull.checks.pending': 'Queued',
479457 'pull.checks.running': 'Running',
480458 'pull.checks.success': 'Passed',
@@ -528,9 +506,7 @@
528506 'pull.form.titlePlaceholderFallback': 'Briefly describe this change',
529507 'pull.form.bodyPlaceholder': 'Explain the motivation, how you verified it, and the risks…',
530508 'pull.form.draftCheckbox': 'Create as draft (mark ready before requesting reviews)',
531 'pull.form.aiFill': 'AI fill',
532 'pull.form.aiFilling': 'Generating…',
533 'pull.form.aiFillFailed': 'AI fill failed, try again later', 'pull.form.descriptionField': 'Description (plain text, line breaks kept)',
509 'pull.form.descriptionField': 'Description (plain text, line breaks kept)',
534510 'issue.board.removeLabel': 'Remove label {name}',
535511 'issue.board.filterLabelPrefix': 'Priority',
536512 'issue.board.form.createDescription': 'Record the problem so collaboration has something to stand on.',
@@ -576,7 +552,6 @@
576552 'pull.event.autoMergeEnabled': 'enabled auto-merge',
577553 'pull.event.autoMergeDisabled': 'disabled auto-merge',
578554 'pull.badge.draft': 'Draft',
579 'pull.ai.matchesCurrent': 'Matches the current revision',
580555 'pull.files.summary_one': '{count} file changed',
581556 'pull.files.summary_other': '{count} files changed',
582557 'issue.board.pager_one': 'Page {page} / {pages} · {total} issue',
Modified web/src/shared/i18n/messages/en/landing.ts View web/src/shared/i18n/messages/en/landing.ts
@@ -1,6 +1,6 @@
11export const landing = {
2 'landing.meta.title': 'GitDataAI — One context for code, collaboration, and AI',
3 'landing.meta.description': 'GitDataAI brings repositories, issues, pull requests, team communication, and AI code review into one engineering workspace.',
2 'landing.meta.title': 'GitDataAI — One context for code, collaboration, and delivery',
3 'landing.meta.description': 'GitDataAI brings repositories, issues, pull requests, and team communication into one engineering workspace.',
44 'landing.skip': 'Skip to main content',
55 'landing.brand.aria': 'GitDataAI home',
66 'landing.nav.aria': 'Primary navigation',
@@ -10,8 +10,8 @@
1010 'landing.nav.faq': 'FAQ',
1111 'landing.nav.login': 'Sign in',
1212 'landing.nav.start': 'Start free',
13 'landing.hero.eyebrow': 'The software workspace for the AI era',
14 'landing.hero.titleLeadStart': 'An AI engineering workspace ',
13 'landing.hero.eyebrow': 'The software workspace for engineering teams',
14 'landing.hero.titleLeadStart': 'An engineering workspace ',
1515 'landing.hero.titleLeadEnd': 'built for continuous delivery.',
1616 'landing.hero.titleAccentStart': 'One shared context keeps ',
1717 'landing.hero.titleAccentEnd': 'code, work, reviews, and conversations connected',
@@ -19,7 +19,7 @@
1919 'landing.hero.primary': 'Start free',
2020 'landing.hero.secondary': 'Explore the product',
2121 'landing.hero.note': 'Container-ready deployment · Expand at your team’s pace',
22 'landing.demo.caption': 'A GitDataAI workspace showing code changes, AI review findings, and active collaborators together.',
22 'landing.demo.caption': 'A GitDataAI workspace showing code changes, review notes, and active collaborators together.',
2323 'landing.demo.status': 'Live sync',
2424 'landing.demo.repo': 'Repository',
2525 'landing.demo.branch': 'main',
@@ -29,7 +29,7 @@
2929 'landing.demo.team': 'Team',
3030 'landing.demo.pr': 'Pull Request #142',
3131 'landing.demo.prTitle': 'Unify the retrieval context pipeline',
32 'landing.demo.review': 'AI review',
32 'landing.demo.review': 'Review notes',
3333 'landing.demo.reviewTitle': '2 risks need your attention',
3434 'landing.demo.riskOne': 'The cache key is missing a space scope and may cross repositories.',
3535 'landing.demo.riskTwo': 'The rerank request needs a timeout and fallback path.',
@@ -39,8 +39,6 @@
3939 'landing.values.body': 'Reconnect the engineering context scattered across code hosting, task tracking, and team communication so everyone works from the same source of truth.',
4040 'landing.value.context.title': 'Context that stays connected',
4141 'landing.value.context.body': 'Code, issues, reviews, and discussions link together, so people stop reconstructing the story across disconnected systems.',
42 'landing.value.ai.title': 'AI inside the real workflow',
43 'landing.value.ai.body': 'From change summaries to review signals, AI works with the current repository and collaboration context instead of sitting in an isolated chat.',
4442 'landing.value.rhythm.title': 'A continuous team rhythm',
4543 'landing.value.rhythm.body': 'Boards, channels, threads, voice, and notifications organize around delivery goals so synchronous and asynchronous work flow together.',
4644 'landing.features.kicker': 'Product capabilities',
@@ -50,12 +48,6 @@
5048 'landing.feature.repo.title': 'Familiar Git, with the full engineering view',
5149 'landing.feature.repo.body': 'Browse branches, tags, file history, and diffs while managing code changes and repository collaboration in one interface.',
5250 'landing.feature.repo.updated': 'Updated now',
53 'landing.feature.ai.label': 'AI-native review',
54 'landing.feature.ai.title': 'Understand the change before choosing the action',
55 'landing.feature.ai.body': 'Summarize commits and pull requests, surface risks worth human attention, and spend review time where judgment matters most.',
56 'landing.feature.ai.scanning': 'Analyzing change context',
57 'landing.feature.ai.signalOne': 'Cross-file impact connected',
58 'landing.feature.ai.signalTwo': 'Review signals prioritized',
5951 'landing.feature.board.label': 'Issues and boards',
6052 'landing.feature.board.title': 'Turn the plan into visible progress',
6153 'landing.feature.board.body': 'Organize work with issues, labels, milestones, and boards while keeping every task connected to its code changes.',
@@ -82,13 +74,11 @@
8274 'landing.case.distributed.outcome': 'Run fewer sync meetings while complex work keeps moving',
8375 'landing.faq.kicker': 'Frequently asked questions',
8476 'landing.faq.title': 'What you may want to know before starting',
85 'landing.faq.body': 'Straight answers about product scope, migration, AI review, and deployment.',
77 'landing.faq.body': 'Straight answers about product scope, migration, and deployment.',
8678 'landing.faq.one.question': 'Is GitDataAI only a Git hosting platform?',
87 'landing.faq.one.answer': 'It goes beyond hosting. GitDataAI centers on Git repositories and connects issues, boards, pull requests, team communication, and AI review into a continuous engineering context.',
79 'landing.faq.one.answer': 'It goes beyond hosting. GitDataAI centers on Git repositories and connects issues, boards, pull requests, and team communication into a continuous engineering context.',
8880 'landing.faq.two.question': 'Can we import existing repositories?',
8981 'landing.faq.two.answer': 'Yes. The platform includes repository import capabilities, so teams can bring in existing projects and adopt issues, reviews, and collaboration features gradually.',
90 'landing.faq.three.question': 'Does AI code review replace human review?',
91 'landing.faq.three.answer': 'No. AI summarizes changes, connects context, and surfaces potential risks; your team still makes the final judgment. Think of it as a high-quality preparation layer for review.',
9282 'landing.faq.four.question': 'Can teams self-host GitDataAI?',
9383 'landing.faq.four.answer': 'Yes. GitDataAI supports containerized deployment and can be configured around your infrastructure and operating model. Enabled services and resource scale depend on the deployment environment.',
9484 'landing.faq.five.question': 'Can it replace separate task and chat tools?',
@@ -97,7 +87,7 @@
9787 'landing.faq.six.answer': 'Create an account to enter a workspace. Available features and quotas depend on the current deployment, or you can sign in to an existing account and continue working.',
9888 'landing.cta.kicker': 'Start with the next delivery',
9989 'landing.cta.title': 'Move good ideas toward done, faster',
100 'landing.cta.body': 'Put code, planning, communication, and AI on one work path so your team can spend its attention on the decisions that truly need it.',
90 'landing.cta.body': 'Put code, planning, and communication on one work path so your team can spend its attention on the decisions that truly need it.',
10191 'landing.cta.primary': 'Start free',
10292 'landing.cta.secondary': 'Already have an account? Sign in',
10393 'landing.footer.tagline': 'Build context around code and keep engineering teams shipping.',
Modified web/src/shared/i18n/messages/en/repo.ts View web/src/shared/i18n/messages/en/repo.ts
@@ -7,7 +7,6 @@
77 'repo.tabs.releases': 'Releases',
88 'repo.tabs.pipelines': 'Pipelines',
99 'repo.tabs.settings': 'Settings',
10 'repo.tabs.ai': 'AI',
1110 'repo.tabs.pulls': 'Pull requests',
1211 'repo.unknownPath': '(unknown path)',
1312 'repo.import.failed': 'The import request failed', 'repo.empty': 'This repository has no files',
@@ -102,15 +101,6 @@
102101 'repo.webhook.created': 'Webhook created.',
103102 'repo.webhook.deleted': 'Webhook deleted.',
104103 'repo.webhook.empty': 'No webhooks yet',
105 'repo.ai.summary': 'Repository summary',
106 'repo.ai.review': 'AI review',
107 'repo.ai.generate': 'Generate summary',
108 'repo.ai.generating': 'Generating summary…',
109 'repo.ai.generated': 'Summary generated.',
110 'repo.ai.failed': 'The AI task failed. Please try again later.',
111 'repo.ai.empty': 'No summary generated yet',
112 'repo.ai.language': 'Output language',
113 'repo.ai.languageAuto': 'Auto (follow the content language)',
114104 'repo.action.add': 'Add',
115105 'repo.action.browseCode': 'Browse code',
116106 'repo.action.confirmDelete': 'Confirm delete',
@@ -298,7 +288,6 @@
298288 'repo.release.loading': 'Loading releases…',
299289 'repo.release.what': 'releases',
300290 'repo.release.backToList': 'Back to list',
301 'repo.release.redraftQueued': 'AI drafting job submitted; refresh when it finishes.',
302291 'repo.release.savedDraft': 'Draft saved.',
303292 'repo.release.publishedNow': 'Published.',
304293 'repo.release.prereleaseUpdated': 'Pre-release flag updated.',
@@ -313,7 +302,6 @@
313302 'repo.release.system': 'System',
314303 'repo.release.body': 'Body',
315304 'repo.release.saveDraft': 'Save draft',
316 'repo.release.redraft': 'AI re-draft',
317305 'repo.release.publish': 'Publish',
318306 'repo.release.markPrerelease': 'Mark pre-release',
319307 'repo.release.unmarkPrerelease': 'Unmark pre-release',
@@ -367,38 +355,6 @@
367355 'repo.pipeline.approvalFailed': 'Approval action failed',
368356 'repo.pipeline.booleanTrue': 'Yes',
369357 'repo.pipeline.booleanFalse': 'No',
370 'repo.ai.triggerSummaryFailed': 'Failed to trigger the AI summary',
371 'repo.ai.queued': 'Summary job submitted; click “Load results” once it finishes.',
372 'repo.ai.loading': 'Loading the AI summary…',
373 'repo.ai.what': 'AI summary',
374 'repo.ai.regenerate': 'Regenerate',
375 'repo.ai.generateSummary': 'Generate summary',
376 'repo.ai.loadResults': 'Load results',
377 'repo.ai.branch': 'Branch {branch}',
378 'repo.ai.emptyNote': 'No AI summary for this branch yet. Summaries are generated from recent commits and cached by head, so the same revision never calls the model twice.',
379 'repo.ai.version': 'Version {version}',
380 'repo.ai.commitCount': '{count} commits',
381 'repo.ai.commitCount_one': '{count} commit',
382 'repo.ai.commitCount_other': '{count} commits',
383 'repo.ai.currentBranch': 'Matches the current branch',
384 'repo.ai.stale': 'Based on an older revision',
385 'repo.ai.pending': 'A job is running',
386 'repo.ai.pendingTitle': 'Generating the AI summary',
387 'repo.ai.pendingQueued': 'The job is queued and waiting for a worker.',
388 'repo.ai.pendingRunning': 'The model is reading the recent commits; the result appears here on its own once it finishes.',
389 'repo.ai.pendingOtherHead': 'The revision being generated is {version}, not the current branch tip; regenerate afterwards if you want this one.',
390 'repo.ai.pendingSince': 'Started {time}',
391 'repo.ai.autoRefresh': 'Checking again every {seconds}s until it finishes.',
392 'repo.ai.highlights': 'Highlights',
393 'repo.ai.risks': 'Risks',
394 'repo.ai.triggerReviewFailed': 'Failed to trigger the AI review',
395 'repo.ai.rerunReview': 'Re-review',
396 'repo.ai.noReview': 'This commit has no AI review yet.',
397 'repo.ai.verdictChangesRequested': 'Changes requested',
398 'repo.ai.verdictComment': 'Comment only',
399 'repo.ai.truncated': 'Content truncated',
400 'repo.ai.noSummary': '(no summary)',
401 'repo.ai.oldLine': 'old line',
402358 'repo.comment.unknownUser': 'Unknown user',
403359 'repo.comment.resolved': 'Resolved',
404360 'repo.comment.outdated': 'Outdated',
@@ -440,10 +396,8 @@
440396 'repo.featuresSettings.allowPrHint': 'When off, the Pull requests tab is hidden for everyone and creating pull requests is rejected.',
441397 'repo.featuresSettings.pipelinesPublic': 'Publicly show pipelines',
442398 'repo.featuresSettings.pipelinesPublicHint': 'When off, the Pipelines tab is visible to space members only.',
443 'repo.featuresSettings.aiPublic': 'Publicly show AI summary',
444 'repo.featuresSettings.aiPublicHint': 'When off, the AI summary tab is visible to space members only.',
445399 'repo.featuresSettings.orderTitle': 'Tab order',
446 'repo.featuresSettings.orderHint': 'Reorder Commits / Pull requests / Branches / Pipelines / Releases / AI summary. Code and Settings stay pinned.',
400 'repo.featuresSettings.orderHint': 'Reorder Commits / Pull requests / Branches / Pipelines / Releases. Code and Settings stay pinned.',
447401 'repo.featuresSettings.moveUp': 'Move {name} up',
448402 'repo.featuresSettings.moveDown': 'Move {name} down',
449403 'repo.featuresSettings.saved': 'Tab settings saved.',
@@ -455,16 +409,6 @@
455409 'repo.settings.nav.locks': 'LFS locks',
456410 'repo.settings.nav.protection': 'Protection rules',
457411 'repo.settings.nav.ci': 'CI/CD',
458 'repo.settings.nav.ai': 'AI',
459 'repo.aiSettings.sub': 'Turn this repository’s AI automation on or off per capability. These settings only take effect while the platform-wide AI switch is on.',
460 'repo.aiSettings.reviewOnPush': 'Review commits on push',
461 'repo.aiSettings.reviewOnPushHint': 'After a push to a branch, generate an AI review for the new commit; it shows up on the commit page and in PR checks.',
462 'repo.aiSettings.summaryOnPush': 'Summarise the latest state on push',
463 'repo.aiSettings.summaryOnPushHint': 'After a push to a branch, generate and cache a repository summary for that branch tip. The same revision never calls the model twice.',
464 'repo.aiSettings.reviewOnPull': 'Review new pull requests',
465 'repo.aiSettings.reviewOnPullHint': 'Opening, reopening or updating a pull request submits an AI review automatically; results land in the PR’s AI review tab.',
466 'repo.aiSettings.saved': 'AI settings saved.',
467 'repo.aiSettings.disabled': 'AI is not enabled platform-wide, or no model endpoint is configured. The switches below are saved but no jobs will be submitted.',
468412 'repo.settings.navLabel': 'Repository settings sections',
469413 'repo.settings.generalSub': 'Description, homepage, visibility, Git LFS and the default branch. Space owners only.',
470414 'repo.settings.field.description': 'Description',
Modified web/src/shared/i18n/messages/en/shell.ts View web/src/shared/i18n/messages/en/shell.ts
@@ -23,14 +23,13 @@
2323 'shell.account.settings': 'User settings',
2424 'shell.account.logout': 'Sign out',
2525 'shell.account.logoutFailed': 'Sign-out failed. Please try again.',
26 'shell.search.placeholder': 'Search repos, boards, agents, channels…',
26 'shell.search.placeholder': 'Search repos, boards, channels…',
2727 'shell.search.empty': 'No matches',
2828 'shell.search.hint': 'Type a keyword to search',
2929 'shell.title': 'GitDataAI',
3030 'shell.titleSpace': '{space} · GitDataAI',
3131 'shell.titleRepo': '{repo} · {space} · GitDataAI',
3232 'shell.titleBoard': '{board} · {space} · GitDataAI',
33 'shell.titleAgent': '{collection} · {space} · GitDataAI',
3433 'shell.titleChat': '{room} · {space} · GitDataAI',
3534 'shell.board.title': 'Issue boards',
3635 'shell.board.pick': 'Pick an issue board from the left.',
@@ -50,7 +49,6 @@
5049 'shell.repo.tabs.branches': 'Branches',
5150 'shell.repo.tabs.pipelines': 'Pipelines',
5251 'shell.repo.tabs.releases': 'Releases',
53 'shell.repo.tabs.ai': 'AI summary',
5452 'shell.repo.tabs.settings': 'Settings',
5553 'shell.repo.tabs.more': 'More',
5654 'shell.repo.loading': 'Loading repository…',
Modified web/src/shared/i18n/messages/en/space.ts View web/src/shared/i18n/messages/en/space.ts
@@ -150,7 +150,6 @@
150150 'space.menu.invite': 'Invite friends',
151151 'space.menu.createRepo': 'Create repository',
152152 'space.menu.createBoard': 'Create issue board',
153 'space.menu.createAgent': 'Create agent set',
154153 'space.menu.createRoom': 'Create channel',
155154 'space.menu.createDir': 'Create folder',
156155 'space.menu.settings': 'Space settings',
@@ -201,11 +200,6 @@
201200 'space.board.description': 'Description · Optional',
202201 'space.board.icon': 'Icon · Optional',
203202 'space.board.dir': 'Parent folder',
204 'space.agent.create.title': 'Create agent set',
205 'space.agent.create.description': 'An agent set is a directory object; every agent inside it owns a long-lived runtime session.',
206 'space.agent.nameField': 'Name',
207 'space.agent.description': 'Description · optional',
208 'space.agent.icon': 'Icon · optional',
209203 'space.room.title': 'Create channel',
210204 'space.room.name': 'Channel name',
211205 'space.room.nameHint': 'Lowercase letters, digits and hyphens',
@@ -341,7 +335,7 @@
341335 'space.settings.sections.cleanup': 'Object cleanup',
342336 'space.cleanup.ownerSub': 'Bulk-clean directories and objects in the space. Space owners only.',
343337 'space.cleanup.notOwner': 'Object cleanup is available to the space owner only.',
344 'space.cleanup.sub': 'Delete or adjust visibility quickly: repositories can toggle public/private and be archived, channels can toggle public/private or be deleted, boards and agent collections can be deleted, empty directories can be deleted, and nodes with visibility overwrites can be reset to the default.',
338 'space.cleanup.sub': 'Delete or adjust visibility quickly: repositories can toggle public/private and be archived, channels can toggle public/private or be deleted, boards can be deleted, empty directories can be deleted, and nodes with visibility overwrites can be reset to the default.',
345339 'space.cleanup.filterPlaceholder': 'Filter by name / path / type',
346340 'space.cleanup.empty': 'No matching objects.',
347341 'space.cleanup.children': '{count} child node(s)',
@@ -362,7 +356,6 @@
362356 'space.cleanup.kind.object': 'Object',
363357 'space.cleanup.kind.repo': 'Repository',
364358 'space.cleanup.kind.board': 'Board',
365 'space.cleanup.kind.collection': 'Agent collection',
366359 'space.cleanup.kind.text': 'Text channel',
367360 'space.cleanup.kind.voice': 'Voice channel',
368361 'space.cleanup.kind.stage': 'Stage channel',
@@ -541,7 +534,6 @@
541534 'space.bans.allowSpeak': 'Allow speaking in voice channels',
542535 'space.bans.allowPushRepo': 'Allow pushing to repositories',
543536 'space.bans.allowCommentAny': 'Allow commenting on any issue',
544 'space.bans.allowAgentJoin': 'Allow their agents to join',
545537 'space.channelPermissions.sub2': 'Hide a folder or channel from specific roles and members, or force it visible.',
546538 'space.channelPermissions.stance.deny': 'Hidden',
547539 'space.channelPermissions.stance.inherit': 'Inherit',
Deleted web/src/shared/i18n/messages/zh-CN/agent.ts

Diff too large to display.

Modified web/src/shared/i18n/messages/zh-CN/index.ts View web/src/shared/i18n/messages/zh-CN/index.ts
@@ -1,5 +1,4 @@
11import { auth } from './auth'
2import { agent } from './agent'
32import { chat } from './chat'
43import { chatComposer } from './chatComposer'
54import { common } from './common'
@@ -37,7 +36,6 @@
3736 ...repo,
3837 ...issues,
3938 ...issueEvents,
40 ...agent,
4139 ...space,
4240 ...misc,
4341 ...nav,
Modified web/src/shared/i18n/messages/zh-CN/issues.ts View web/src/shared/i18n/messages/zh-CN/issues.ts
@@ -46,12 +46,6 @@
4646 'issue.watch.watching': '关注',
4747 'issue.watch.unwatched': '默认',
4848 'issue.watch.ignore': '忽略',
49 'pull.ai.autoLanguage': '沿用内容语言',
50 'pull.ai.outputLanguage': '输出语言',
51 'pull.ai.followPreference': '跟随我的偏好',
52 'pull.ai.defaultOption': '(默认)',
53 'pull.ai.verdict.changesRequested': '请求修改',
54 'pull.ai.verdict.commented': '仅评论',
5549 'pull.new': '新建拉取请求',
5650 'pull.title': '标题',
5751 'pull.description': '描述',
@@ -63,7 +57,6 @@
6357 'pull.tabs.conversation': '讨论',
6458 'pull.tabs.files': '文件变更',
6559 'pull.tabs.commits': '提交',
66 'pull.tabs.ai': 'AI 评审',
6760 'pull.state.open': '待合并',
6861 'pull.state.merged': '已合并',
6962 'pull.state.closed': '已关闭',
@@ -460,21 +453,6 @@
460453 'pull.parts.metaOnly': '仅元数据变更。',
461454 'pull.parts.addComment': '在此行添加评审评论',
462455 'pull.parts.truncated': '差异过长,已截断显示前 1500 行。',
463 'pull.ai.notice': '已提交评审任务({model}{scope}),完成后刷新即可看到结果。',
464 'pull.ai.triggerFailed': '触发 AI 评审失败',
465 'pull.ai.loading': '加载 AI 评审…',
466 'pull.ai.errorAi': 'AI 评审',
467 'pull.ai.rerun': '重新评审',
468 'pull.ai.refresh': '刷新',
469 'pull.ai.currentVersion': '当前版本',
470 'pull.ai.oldVersion': '基于旧版本 {sha}',
471 'pull.ai.truncated': '变更内容被截断',
472 'pull.ai.noSummary': '(无总结)',
473 'pull.ai.jobError': '任务错误:{error}',
474 'pull.ai.locate': '在变更文件中定位该行',
475 'pull.ai.oldLine': '旧行',
476 'pull.ai.noFindings': '没有需要修改的问题。',
477 'pull.ai.empty': '该合并请求还没有 AI 评审结果。开启 [ai] 后打开或更新 PR 会自动评审,也可点击上方按钮手动触发。',
478456 'pull.checks.pending': '排队中',
479457 'pull.checks.running': '运行中',
480458 'pull.checks.success': '通过',
@@ -528,9 +506,7 @@
528506 'pull.form.titlePlaceholderFallback': '简短描述这次改动',
529507 'pull.form.bodyPlaceholder': '说明改动动机、验证方式与风险点…',
530508 'pull.form.draftCheckbox': '以草稿创建(标记为 Draft,ready 后再请求评审)',
531 'pull.form.aiFill': 'AI 填充',
532 'pull.form.aiFilling': '生成中…',
533 'pull.form.aiFillFailed': 'AI 填充失败,请稍后重试', 'pull.form.descriptionField': '描述(支持换行,纯文本渲染)',
509 'pull.form.descriptionField': '描述(支持换行,纯文本渲染)',
534510 'issue.board.removeLabel': '移除标签 {name}',
535511 'issue.board.filterLabelPrefix': '优先级',
536512 'issue.board.form.createDescription': '记录一个问题,让协作有据可循。',
@@ -576,7 +552,6 @@
576552 'pull.event.autoMergeEnabled': '开启了自动合并',
577553 'pull.event.autoMergeDisabled': '关闭了自动合并',
578554 'pull.badge.draft': '草稿',
579 'pull.ai.matchesCurrent': '对应当前版本',
580555 'pull.files.summary_one': '{count} 个文件改动',
581556 'pull.files.summary_other': '{count} 个文件改动',
582557 'issue.board.pager_one': '第 {page} / {pages} 页 · 共 {total} 个工单',
Modified web/src/shared/i18n/messages/zh-CN/landing.ts View web/src/shared/i18n/messages/zh-CN/landing.ts
@@ -1,6 +1,6 @@
11export const landing = {
2 'landing.meta.title': 'GitDataAI — 让代码、协作与 AI 共享同一上下文',
3 'landing.meta.description': 'GitDataAI 将代码仓库、Issue、Pull Request、团队沟通与 AI 代码审查放进同一个工程协作空间。',
2 'landing.meta.title': 'GitDataAI — 让代码、协作与交付共享同一上下文',
3 'landing.meta.description': 'GitDataAI 将代码仓库、Issue、Pull Request 与团队沟通放进同一个工程协作空间。',
44 'landing.skip': '跳到主要内容',
55 'landing.brand.aria': 'GitDataAI 首页',
66 'landing.nav.aria': '主要导航',
@@ -10,16 +10,16 @@
1010 'landing.nav.faq': '常见问题',
1111 'landing.nav.login': '登录',
1212 'landing.nav.start': '免费开始',
13 'landing.hero.eyebrow': '面向 AI 时代的软件协作空间',
13 'landing.hero.eyebrow': '面向工程团队的软件协作空间',
1414 'landing.hero.titleLeadStart': '为持续交付而生的 ',
15 'landing.hero.titleLeadEnd': 'AI 工程工作空间。',
15 'landing.hero.titleLeadEnd': '工程工作空间。',
1616 'landing.hero.titleAccentStart': '一份工程上下文,',
1717 'landing.hero.titleAccentEnd': '让代码、任务、审查与沟通始终连贯',
1818 'landing.hero.body': 'GitDataAI 把团队每天使用的工程工具连接起来,从第一次提交到最终交付,所有人始终基于同一份上下文协作。',
1919 'landing.hero.primary': '免费开始',
2020 'landing.hero.secondary': '查看产品能力',
2121 'landing.hero.note': '支持容器化部署 · 可按团队节奏逐步扩展',
22 'landing.demo.caption': 'GitDataAI 工作空间示意:代码变更、AI 审查与团队在线状态集中显示。',
22 'landing.demo.caption': 'GitDataAI 工作空间示意:代码变更、审查备注与团队在线状态集中显示。',
2323 'landing.demo.status': '实时同步',
2424 'landing.demo.repo': '仓库',
2525 'landing.demo.branch': 'main',
@@ -29,7 +29,7 @@
2929 'landing.demo.team': '团队',
3030 'landing.demo.pr': 'Pull Request #142',
3131 'landing.demo.prTitle': '统一检索上下文链路',
32 'landing.demo.review': 'AI 审查',
32 'landing.demo.review': '审查备注',
3333 'landing.demo.reviewTitle': '2 个风险需要确认',
3434 'landing.demo.riskOne': '缓存键缺少空间作用域,可能串联不同仓库。',
3535 'landing.demo.riskTwo': '重排请求需要设置超时与回退路径。',
@@ -39,8 +39,6 @@
3939 'landing.values.body': '把分散在代码托管、任务管理和即时沟通里的工程上下文重新连接,让团队围绕同一份事实协作。',
4040 'landing.value.context.title': '上下文不再断层',
4141 'landing.value.context.body': '代码、Issue、审查与讨论彼此关联,成员无需在多个系统之间反复还原事情的来龙去脉。',
42 'landing.value.ai.title': 'AI 进入真实工作流',
43 'landing.value.ai.body': '从变更摘要到代码审查线索,AI 基于当前仓库与协作上下文提供帮助,而不是停留在孤立对话里。',
4442 'landing.value.rhythm.title': '协作节奏保持连贯',
4543 'landing.value.rhythm.body': '看板、频道、线程、语音与通知围绕交付目标组织,让同步和异步协作自然衔接。',
4644 'landing.features.kicker': '产品能力',
@@ -50,12 +48,6 @@
5048 'landing.feature.repo.title': '熟悉的 Git,完整的工程视图',
5149 'landing.feature.repo.body': '浏览分支、标签、文件历史与差异,在同一界面管理代码变更和仓库协作。',
5250 'landing.feature.repo.updated': '刚刚更新',
53 'landing.feature.ai.label': 'AI 原生审查',
54 'landing.feature.ai.title': '先理解变化,再决定行动',
55 'landing.feature.ai.body': '自动提炼提交与 Pull Request 的重点,提示值得人工确认的风险,让审查时间花在真正重要的地方。',
56 'landing.feature.ai.scanning': '正在分析变更上下文',
57 'landing.feature.ai.signalOne': '跨文件影响已关联',
58 'landing.feature.ai.signalTwo': '审查线索已按优先级整理',
5951 'landing.feature.board.label': 'Issue 与看板',
6052 'landing.feature.board.title': '把计划变成看得见的进展',
6153 'landing.feature.board.body': '用 Issue、标签、里程碑和看板组织工作,并让任务与代码变更保持连接。',
@@ -82,13 +74,11 @@
8274 'landing.case.distributed.outcome': '少开同步会议,也能持续推进复杂协作',
8375 'landing.faq.kicker': '常见问题',
8476 'landing.faq.title': '开始之前,你可能想知道',
85 'landing.faq.body': '关于产品定位、迁移、AI 审查与部署方式的简明说明。',
77 'landing.faq.body': '关于产品定位、迁移与部署方式的简明说明。',
8678 'landing.faq.one.question': 'GitDataAI 只是一个 Git 托管平台吗?',
87 'landing.faq.one.answer': '不止于此。GitDataAI 以 Git 仓库为核心,同时连接 Issue、看板、Pull Request、团队沟通与 AI 审查,让工程协作拥有连续上下文。',
79 'landing.faq.one.answer': '不止于此。GitDataAI 以 Git 仓库为核心,同时连接 Issue、看板、Pull Request 与团队沟通,让工程协作拥有连续上下文。',
8880 'landing.faq.two.question': '可以导入已有仓库吗?',
8981 'landing.faq.two.answer': '可以。平台提供仓库导入能力,团队可以把现有项目迁入工作空间,再逐步启用 Issue、审查与协作功能。',
90 'landing.faq.three.question': 'AI 代码审查会取代人工审查吗?',
91 'landing.faq.three.answer': '不会。AI 用于总结变化、关联上下文并提示潜在风险,最终判断仍由团队完成;它更像审查前的高质量准备层。',
9282 'landing.faq.four.question': '支持团队自托管吗?',
9383 'landing.faq.four.answer': '支持容器化部署,可按团队的基础设施与运维方式配置。具体启用的服务和资源规模由部署环境决定。',
9484 'landing.faq.five.question': '它能替代独立的任务和聊天工具吗?',
@@ -97,7 +87,7 @@
9787 'landing.faq.six.answer': '注册账号后即可进入工作空间。可用功能与配额由当前部署配置决定,你也可以先登录已有账号继续工作。',
9888 'landing.cta.kicker': '从下一次交付开始',
9989 'landing.cta.title': '让好想法更快走到可交付',
100 'landing.cta.body': '把代码、计划、沟通与 AI 放在同一条工作链路里,让团队把精力留给真正需要判断的事情。',
90 'landing.cta.body': '把代码、计划与沟通放在同一条工作链路里,让团队把精力留给真正需要判断的事情。',
10191 'landing.cta.primary': '免费开始',
10292 'landing.cta.secondary': '已有账号?登录',
10393 'landing.footer.tagline': '围绕代码建立上下文,让工程团队持续交付。',
Modified web/src/shared/i18n/messages/zh-CN/repo.ts View web/src/shared/i18n/messages/zh-CN/repo.ts
@@ -7,7 +7,6 @@
77 'repo.tabs.releases': '发布',
88 'repo.tabs.pipelines': '流水线',
99 'repo.tabs.settings': '设置',
10 'repo.tabs.ai': 'AI',
1110 'repo.tabs.pulls': '拉取请求',
1211 'repo.unknownPath': '(未知路径)',
1312 'repo.import.failed': '导入请求失败', 'repo.empty': '仓库中没有文件',
@@ -102,15 +101,6 @@
102101 'repo.webhook.created': 'Webhook 已创建。',
103102 'repo.webhook.deleted': 'Webhook 已删除。',
104103 'repo.webhook.empty': '还没有 Webhook',
105 'repo.ai.summary': '仓库摘要',
106 'repo.ai.review': 'AI 评审',
107 'repo.ai.generate': '生成摘要',
108 'repo.ai.generating': '正在生成摘要…',
109 'repo.ai.generated': '摘要已生成。',
110 'repo.ai.failed': 'AI 任务失败,请稍后重试。',
111 'repo.ai.empty': '还没有生成摘要',
112 'repo.ai.language': '输出语言',
113 'repo.ai.languageAuto': '自动(跟随内容语言)',
114104 'repo.action.add': '添加',
115105 'repo.action.browseCode': '浏览代码',
116106 'repo.action.confirmDelete': '确认删除',
@@ -298,7 +288,6 @@
298288 'repo.release.loading': '加载发布记录…',
299289 'repo.release.what': '发布记录',
300290 'repo.release.backToList': '返回列表',
301 'repo.release.redraftQueued': '已提交 AI 起草任务;完成后刷新查看。',
302291 'repo.release.savedDraft': '已保存草稿。',
303292 'repo.release.publishedNow': '已发布。',
304293 'repo.release.prereleaseUpdated': '已更新预发布标记。',
@@ -313,7 +302,6 @@
313302 'repo.release.system': '系统',
314303 'repo.release.body': '正文',
315304 'repo.release.saveDraft': '保存草稿',
316 'repo.release.redraft': 'AI 重新起草',
317305 'repo.release.publish': '发布',
318306 'repo.release.markPrerelease': '标记预发布',
319307 'repo.release.unmarkPrerelease': '取消预发布',
@@ -367,38 +355,6 @@
367355 'repo.pipeline.approvalFailed': '审批操作失败',
368356 'repo.pipeline.booleanTrue': '是',
369357 'repo.pipeline.booleanFalse': '否',
370 'repo.ai.triggerSummaryFailed': '触发 AI 总结失败',
371 'repo.ai.queued': '已提交总结任务;完成后点击“载入结果”查看。',
372 'repo.ai.loading': '加载 AI 总结…',
373 'repo.ai.what': 'AI 总结',
374 'repo.ai.regenerate': '重新生成',
375 'repo.ai.generateSummary': '生成总结',
376 'repo.ai.loadResults': '载入结果',
377 'repo.ai.branch': '分支 {branch}',
378 'repo.ai.emptyNote': '该分支还没有生成 AI 总结。总结基于最近提交生成并按 head 缓存,同一版本不会重复调用模型。',
379 'repo.ai.version': '版本 {version}',
380 'repo.ai.commitCount': '{count} 个提交',
381 'repo.ai.commitCount_one': '{count} 个提交',
382 'repo.ai.commitCount_other': '{count} 个提交',
383 'repo.ai.currentBranch': '对应当前分支',
384 'repo.ai.stale': '基于旧版本',
385 'repo.ai.pending': '有任务进行中',
386 'repo.ai.pendingTitle': '正在生成 AI 总结',
387 'repo.ai.pendingQueued': '任务已排队,等待执行。',
388 'repo.ai.pendingRunning': '模型正在阅读最近的提交,完成后这里会自动显示结果。',
389 'repo.ai.pendingOtherHead': '正在生成的版本是 {version},与当前分支 tip 不同;完成后可按需重新生成。',
390 'repo.ai.pendingSince': '开始于 {time}',
391 'repo.ai.autoRefresh': '完成前每 {seconds} 秒自动检查一次。',
392 'repo.ai.highlights': '亮点',
393 'repo.ai.risks': '风险',
394 'repo.ai.triggerReviewFailed': '触发 AI 评审失败',
395 'repo.ai.rerunReview': '重新评审',
396 'repo.ai.noReview': '该提交还没有 AI 评审结果。',
397 'repo.ai.verdictChangesRequested': '请求修改',
398 'repo.ai.verdictComment': '仅评论',
399 'repo.ai.truncated': '内容被截断',
400 'repo.ai.noSummary': '(无总结)',
401 'repo.ai.oldLine': '旧行',
402358 'repo.comment.unknownUser': '未知用户',
403359 'repo.comment.resolved': '已解决',
404360 'repo.comment.outdated': '已过期',
@@ -440,10 +396,8 @@
440396 'repo.featuresSettings.allowPrHint': '关闭后「合并请求」页签对所有人隐藏,创建合并请求会被拒绝。',
441397 'repo.featuresSettings.pipelinesPublic': '公开展示流水线',
442398 'repo.featuresSettings.pipelinesPublicHint': '关闭后「流水线」页签仅空间成员可见。',
443 'repo.featuresSettings.aiPublic': '公开展示 AI 总结',
444 'repo.featuresSettings.aiPublicHint': '关闭后「AI 总结」页签仅空间成员可见。',
445399 'repo.featuresSettings.orderTitle': '页签顺序',
446 'repo.featuresSettings.orderHint': '调整「提交 / 合并请求 / 分支 / 流水线 / 发布 / AI 总结」的顺序;代码与设置固定不动。',
400 'repo.featuresSettings.orderHint': '调整「提交 / 合并请求 / 分支 / 流水线 / 发布」的顺序;代码与设置固定不动。',
447401 'repo.featuresSettings.moveUp': '上移 {name}',
448402 'repo.featuresSettings.moveDown': '下移 {name}',
449403 'repo.featuresSettings.saved': '页签设置已保存。',
@@ -455,16 +409,6 @@
455409 'repo.settings.nav.locks': 'LFS 锁',
456410 'repo.settings.nav.protection': '保护规则',
457411 'repo.settings.nav.ci': 'CI/CD',
458 'repo.settings.nav.ai': 'AI',
459 'repo.aiSettings.sub': '让这个仓库的 AI 自动化按需开关。平台级 AI 总开关关闭时,这里的设置不生效。',
460 'repo.aiSettings.reviewOnPush': '推送时自动评审提交',
461 'repo.aiSettings.reviewOnPushHint': '每次推送到分支后,为新的提交生成 AI 评审结果,可在提交页与 PR 检查中查看。',
462 'repo.aiSettings.summaryOnPush': '推送时自动总结最新状态',
463 'repo.aiSettings.summaryOnPushHint': '每次推送到分支后,为该分支最新状态生成仓库总结并缓存;同一版本不会重复调用模型。',
464 'repo.aiSettings.reviewOnPull': '新建 PR 时自动评审',
465 'repo.aiSettings.reviewOnPullHint': 'PR 创建、重开或推送新提交时自动投递 AI 评审,结果写入 PR 的 AI 评审页签。',
466 'repo.aiSettings.saved': 'AI 设置已保存。',
467 'repo.aiSettings.disabled': '平台未启用 AI,或未配置模型端点;下面的开关已保存,但不会有任务投递。',
468412 'repo.settings.navLabel': '仓库设置分类',
469413 'repo.settings.generalSub': '仓库描述、主页、可见性、Git LFS 与默认分支。仅空间所有者可修改。',
470414 'repo.settings.field.description': '描述',
Modified web/src/shared/i18n/messages/zh-CN/shell.ts View web/src/shared/i18n/messages/zh-CN/shell.ts
@@ -23,14 +23,13 @@
2323 'shell.account.settings': '用户设置',
2424 'shell.account.logout': '退出登录',
2525 'shell.account.logoutFailed': '退出登录失败,请重试。',
26 'shell.search.placeholder': '搜索仓库、问题集、智能体、频道…',
26 'shell.search.placeholder': '搜索仓库、问题集、频道…',
2727 'shell.search.empty': '没有匹配结果',
2828 'shell.search.hint': '输入关键词开始搜索',
2929 'shell.title': 'GitDataAI',
3030 'shell.titleSpace': '{space} · GitDataAI',
3131 'shell.titleRepo': '{repo} · {space} · GitDataAI',
3232 'shell.titleBoard': '{board} · {space} · GitDataAI',
33 'shell.titleAgent': '{collection} · {space} · GitDataAI',
3433 'shell.titleChat': '{room} · {space} · GitDataAI',
3534 'shell.board.title': '问题集',
3635 'shell.board.pick': '从左侧选择一个问题集。',
@@ -50,7 +49,6 @@
5049 'shell.repo.tabs.branches': '分支',
5150 'shell.repo.tabs.pipelines': '流水线',
5251 'shell.repo.tabs.releases': '发布',
53 'shell.repo.tabs.ai': 'AI 总结',
5452 'shell.repo.tabs.settings': '设置',
5553 'shell.repo.tabs.more': '更多',
5654 'shell.repo.loading': '正在加载仓库…',
Modified web/src/shared/i18n/messages/zh-CN/space.ts View web/src/shared/i18n/messages/zh-CN/space.ts
@@ -150,7 +150,6 @@
150150 'space.menu.invite': '邀请朋友',
151151 'space.menu.createRepo': '创建仓库',
152152 'space.menu.createBoard': '创建问题集',
153 'space.menu.createAgent': '创建智能体集',
154153 'space.menu.createRoom': '创建聊天室',
155154 'space.menu.createDir': '创建文件夹',
156155 'space.menu.settings': '社区设置',
@@ -201,11 +200,6 @@
201200 'space.board.description': '描述 · 可选',
202201 'space.board.icon': '图标 · 可选',
203202 'space.board.dir': '所在文件夹',
204 'space.agent.create.title': '创建智能体集',
205 'space.agent.create.description': '智能体集是空间目录中的一类对象,集内的每个智能体都拥有自己的运行时长期会话。',
206 'space.agent.nameField': '名称',
207 'space.agent.description': '描述 · 可选',
208 'space.agent.icon': '图标 · 可选',
209203 'space.room.title': '创建聊天室',
210204 'space.room.name': '频道名称',
211205 'space.room.nameHint': '小写字母、数字与连字符',
@@ -341,7 +335,7 @@
341335 'space.settings.sections.cleanup': '对象清理',
342336 'space.cleanup.ownerSub': '批量清理空间里的目录与对象,仅空间所有者可访问。',
343337 'space.cleanup.notOwner': '对象清理只对空间所有者开放。',
344 'space.cleanup.sub': '快速删除或调整可见性:仓库可切换公开/私有并归档,频道可切换公开/私有或删除,问题集 / 智能体集可直接删除,空目录可删除,带可见性覆盖的节点可一键恢复默认。',
338 'space.cleanup.sub': '快速删除或调整可见性:仓库可切换公开/私有并归档,频道可切换公开/私有或删除,问题集可直接删除,空目录可删除,带可见性覆盖的节点可一键恢复默认。',
345339 'space.cleanup.filterPlaceholder': '按名称 / 路径 / 类型过滤',
346340 'space.cleanup.empty': '没有匹配的对象。',
347341 'space.cleanup.children': '子节点 {count}',
@@ -362,7 +356,6 @@
362356 'space.cleanup.kind.object': '对象',
363357 'space.cleanup.kind.repo': '仓库',
364358 'space.cleanup.kind.board': '问题集',
365 'space.cleanup.kind.collection': '智能体集',
366359 'space.cleanup.kind.text': '文字频道',
367360 'space.cleanup.kind.voice': '语音频道',
368361 'space.cleanup.kind.stage': '舞台频道',
@@ -541,7 +534,6 @@
541534 'space.bans.allowSpeak': '允许在语音频道发言',
542535 'space.bans.allowPushRepo': '允许推送仓库',
543536 'space.bans.allowCommentAny': '允许评论任意问题',
544 'space.bans.allowAgentJoin': '允许其代理加入',
545537 'space.channelPermissions.sub2': '对单个文件夹或频道隐藏、或强制显示给特定角色与成员。',
546538 'space.channelPermissions.stance.deny': '隐藏',
547539 'space.channelPermissions.stance.inherit': '跟随',
Modified web/src/shared/lib/cache-keys.ts View web/src/shared/lib/cache-keys.ts
@@ -36,7 +36,6 @@
3636 */
3737export const spaceReposPickKey = (space: string) => `space:${space}:repos:pick`
3838export const spaceBoardsKey = (space: string) => `space:${space}:boards`
39export const spaceAgentCollectionsKey = (space: string) => `space:${space}:agent-collections`
4039export const spaceEmojisKey = (space: string) => `space:${space}:emojis`
4140
4241/** features/space-management rows (limit 200). */
Deleted web/src/shared/livekit/core/components/textStream.ts

Diff too large to display.

Modified web/src/shared/livekit/core/helper/index.ts View web/src/shared/livekit/core/helper/index.ts
@@ -13,4 +13,3 @@
1313export { setDifference } from './set-helper';
1414export { supportsScreenSharing } from './featureDetection';
1515export * from './transcriptions';
16export * from './participant-attributes';
Deleted web/src/shared/livekit/core/helper/participant-attributes.ts

Diff too large to display.

Modified web/src/shared/livekit/core/index.ts View web/src/shared/livekit/core/index.ts
@@ -31,6 +31,4 @@
3131
3232export * from './persistent-storage';
3333
34export * from './components/textStream';
35
3634export { log, setLogLevel, setLogExtension } from './logger';
Deleted web/src/shared/livekit/react/components/participant/animationSequences/connectingSequence.ts

Diff too large to display.

Deleted web/src/shared/livekit/react/components/participant/animationSequences/listeningSequence.ts

Diff too large to display.

Deleted web/src/shared/livekit/react/components/participant/animationSequences/thinkingSequence.ts

Diff too large to display.

Deleted web/src/shared/livekit/react/components/participant/animators/useBarAnimator.ts

Diff too large to display.

Deleted web/src/shared/livekit/react/components/participant/AudioVisualizer.tsx

Diff too large to display.

Deleted web/src/shared/livekit/react/components/participant/BarVisualizer.tsx

Diff too large to display.

Deleted web/src/shared/livekit/react/components/participant/ParticipantAudioTile.tsx

Diff too large to display.

Modified web/src/shared/livekit/react/components/index.ts View web/src/shared/livekit/react/components/index.ts
@@ -10,7 +10,6 @@
1010export * from './layout';
1111export * from './layout/LayoutContextProvider';
1212export * from './LiveKitRoom';
13export * from './participant/AudioVisualizer';
1413export * from './participant/ConnectionQualityIndicator';
1514export * from './participant/AudioTrack';
1615export * from './participant/VideoTrack';
@@ -22,8 +21,6 @@
2221export { Toast } from './Toast';
2322export * from './TrackLoop';
2423export * from './participant/ParticipantTile';
25export * from './participant/ParticipantAudioTile';
26export * from './participant/BarVisualizer';
2724export { ConnectionStateToast, type ConnectionStateToastProps } from './ConnectionStateToast';
2825export {
2926 type MessageFormatter,
@@ -31,4 +28,3 @@
3128 ChatEntry,
3229 formatChatMessageLinks,
3330} from './ChatEntry';
34export * from './SessionProvider';
Deleted web/src/shared/livekit/react/components/SessionProvider.tsx

Diff too large to display.

Modified web/src/shared/livekit/react/context/index.ts View web/src/shared/livekit/react/context/index.ts
@@ -16,7 +16,6 @@
1616} from './participant-context';
1717export {} from './pin-context';
1818export { RoomContext, useEnsureRoom, useMaybeRoomContext, useRoomContext } from './room-context';
19export { useEnsureSession, useMaybeSessionContext, useSessionContext } from './session-context';
2019export {
2120 TrackRefContext,
2221 useEnsureTrackRef,
Deleted web/src/shared/livekit/react/context/session-context.ts

Diff too large to display.

Modified web/src/shared/livekit/react/hooks/index.ts View web/src/shared/livekit/react/hooks/index.ts
@@ -53,29 +53,6 @@
5353export { type UseIsEncryptedOptions, useIsEncrypted } from './useIsEncrypted';
5454export * from './useTrackVolume';
5555export * from './useParticipantTracks';
56export * from './useTrackTranscription';
57export * from './useVoiceAssistant';
5856export * from './useParticipantAttributes';
5957export * from './useIsRecording';
60export * from './useTextStream';
61export * from './useAgentExpression';
62export * from './useTranscriptions';
63export * from './useSequentialRoomConnectDisconnect';
64export * from './useSession';
65export {
66 type AgentState,
67 AgentEvent,
68 type AgentCallbacks,
69 type UseAgentReturn,
70 useAgent,
71} from './useAgent';
7258export * from './useEvents';
73export * from './useSessionMessages';
74export {
75 type RpcHandler,
76 type RpcCallParams,
77 type UseRpcOptions,
78 type RpcPerformFn,
79 type UseRpcReturn,
80 useRpc,
81} from './useRpc';
Deleted web/src/shared/livekit/react/hooks/useAgent.ts

Diff too large to display.

Deleted web/src/shared/livekit/react/hooks/useAgentExpression.ts

Diff too large to display.

Deleted web/src/shared/livekit/react/hooks/useRpc.ts

Diff too large to display.

Deleted web/src/shared/livekit/react/hooks/useSequentialRoomConnectDisconnect.ts

Diff too large to display.

Deleted web/src/shared/livekit/react/hooks/useSession.ts

Diff too large to display.

Deleted web/src/shared/livekit/react/hooks/useSessionMessages.ts

Diff too large to display.

Deleted web/src/shared/livekit/react/hooks/useTextStream.ts

Diff too large to display.

Deleted web/src/shared/livekit/react/hooks/useTrackTranscription.ts

Diff too large to display.

Deleted web/src/shared/livekit/react/hooks/useTranscriptions.ts

Diff too large to display.

Deleted web/src/shared/livekit/react/hooks/useVoiceAssistant.ts

Diff too large to display.

Deleted web/src/shared/livekit/react/prefabs/AudioConference.tsx

Diff too large to display.

Modified web/src/shared/livekit/react/prefabs/index.ts View web/src/shared/livekit/react/prefabs/index.ts
@@ -3,9 +3,3 @@
33export { VideoConference, type VideoConferenceProps } from './VideoConference';
44export { ControlBar, type ControlBarProps, type ControlBarControls } from './ControlBar';
55export { MediaDeviceMenu, type MediaDeviceMenuProps } from './MediaDeviceMenu';
6export { AudioConference, type AudioConferenceProps } from './AudioConference';
7export {
8 VoiceAssistantControlBar,
9 type VoiceAssistantControlBarProps,
10 type VoiceAssistantControlBarControls,
11} from './VoiceAssistantControlBar';
Deleted web/src/shared/livekit/react/prefabs/VoiceAssistantControlBar.tsx

Diff too large to display.

Modified web/src/shared/livekit/react/index.ts View web/src/shared/livekit/react/index.ts
@@ -17,7 +17,6 @@
1717 ReceivedMessage,
1818 ReceivedChatMessage,
1919 ReceivedUserTranscriptionMessage,
20 ReceivedAgentTranscriptionMessage,
2120 MessageDecoder,
2221 MessageEncoder,
2322 LocalUserChoices,
@@ -28,5 +27,4 @@
2827 PinState,
2928 WidgetState,
3029 GridLayoutDefinition,
31 TextStreamData,
3230} from '@livekit/components-core';
Modified web/src/shared/ui/language-picker.tsx View web/src/shared/ui/language-picker.tsx
@@ -4,7 +4,7 @@
44 * Language selector used by the settings modal.
55 *
66 * `value` is the persisted `users.language`; an unrecognized tag (e.g. someone
7 * who set `ja` for AI output) is kept as a locked extra option so that saving
7 * who set `ja` for output) is kept as a locked extra option so that saving
88 * the form cannot silently erase it. Choosing a shipped locale writes the
99 * device choice immediately and returns it for the caller to persist.
1010 *
Modified web/src/shared/ui/markdown.tsx View web/src/shared/ui/markdown.tsx
@@ -6,7 +6,7 @@
66marked.use({ gfm: true, breaks: true })
77
88/**
9 * AI 生成的 Markdown 渲染器。
9 * Markdown 渲染器。
1010 *
1111 * 模型输出的是 Markdown(总结、评审结论、release 正文),必须按 Markdown
1212 * 渲染而不是当纯文本显示。模型输出属于不可信内容,因此渲染结果一律经
Modified web/index.html View web/index.html
@@ -10,7 +10,7 @@
1010 <meta name="theme-color" content="#090b0d" />
1111 <meta
1212 name="description"
13 content="GitDataAI brings repositories, issues, pull requests, team communication, and AI code review into one engineering workspace."
13 content="GitDataAI brings repositories, issues, pull requests, and team communication into one engineering workspace."
1414 />
1515 <meta name="app:region" content="EU-CENTRAL-1" />
1616 <meta name="app:min-age" content="13" />
Modified web/openapi.json View web/openapi.json

Diff too large to display.

Modified web/steiger.config.js View web/steiger.config.js
@@ -21,7 +21,6 @@
2121 'features/repo-history',
2222 'features/repo-pipelines',
2323 'features/repo-settings',
24 'features/repo-ai',
2524 'features/repo-releases',
2625 'features/search',
2726 'features/space-discovery',
Modified Cargo.toml View Cargo.toml
@@ -14,8 +14,6 @@
1414
1515
1616[workspace.dependencies]
17agent = { path = "lib/agent" }
18agents = { path = "lib/agents" }
1917auth = { path = "lib/auth" }
2018cache = { path = "lib/cache" }
2119chat = { path = "lib/chat" }
@@ -37,8 +35,6 @@
3735jwt = { path = "lib/jwt" }
3836import = { path = "lib/import" }
3937webapi = { path = "lib/webapi" }
40mcp-api = { path = "lib/mcp-api" }
41sandbox-rpc = { path = "lib/sandbox-rpc" }
4238pulls = { path = "lib/pulls" }
4339search = { path = "lib/search" }
4440social = { path = "lib/social" }
@@ -123,7 +119,6 @@
123119url = "2"
124120percent-encoding = "2"
125121encoding_rs = "0.8"
126rmcp = { version = "3.5.0", features = ["transport-streamable-http-server"] }
127122ipnet = "2"
128123tonic = { version = "0.14", default-features = false, features = ["channel", "codegen", "router", "server"] }
129124tonic-prost = "0.14"
Modified Dockerfile View Dockerfile
@@ -43,7 +43,7 @@
4343 /build/target/release/deps/web-* 2>/dev/null; \
4444 cargo build --release --workspace --bins && \
4545 mkdir -p /out && \
46 for b in apihost githost synchost searchhost mails migrate openapi im webhook runner aihost mcp; do \
46 for b in apihost githost synchost searchhost mails migrate openapi im webhook runner; do \
4747 cp "target/release/$b" /out/; \
4848 done
4949
@@ -79,7 +79,7 @@
7979WORKDIR /app
8080
8181COPY --chown=gitdata:gitdata config/default.toml config/default.toml
82COPY --from=builder /out/apihost /out/githost /out/synchost /out/searchhost /out/mails /out/migrate /out/openapi /out/im /out/webhook /out/runner /out/aihost /out/mcp /usr/local/bin/
82COPY --from=builder /out/apihost /out/githost /out/synchost /out/searchhost /out/mails /out/migrate /out/openapi /out/im /out/webhook /out/runner /usr/local/bin/
8383
8484COPY --from=builder /out/docker /usr/local/bin/docker
8585
Modified justfile View justfile
@@ -98,7 +98,6 @@
9898 @echo " githost http://127.0.0.1:9418 ssh://git@127.0.0.1:4022"
9999 @echo " im ws://127.0.0.1:4082"
100100 @echo " synchost http://127.0.0.1:4083/healthz"
101 @echo " mails http://127.0.0.1:4084/healthz"
102101 @echo " webhook http://127.0.0.1:3005"
103102 @echo " aihost http://127.0.0.1:3006/healthz"
104103 @echo " mcp http://127.0.0.1:3007/v1 (healthz: /healthz)"
Modified openapi.json View openapi.json

Diff too large to display.