Skip to content
Clone

feat(ai): per-repository switches for push and PR automation

The platform [ai] block decides whether a deployment offers AI at all; an
archived script repository in an active space still paid for a model call
on every push. Three columns on repos now gate the submission alongside it:
ai_review_on_push, ai_summary_on_push (new: summarise the new tip after a
push) and ai_review_on_pull, checked in sync/ai_task (one repo read per
push, not per ref) and in the pull-request event hook.

Defaults keep the upgrade silent: both review switches default to true,
because they were already firing under the platform switch, while
auto-summary defaults to false so a rollout never starts billing model
calls nobody asked for. If the repo row cannot be read the old behaviour
is kept - silently losing a review is harder to debug than sending one
extra job.

The merge rule is a function of its own so it can be tested directly:
platform AND repo, per capability, with release notes having no repo
switch because it follows tags. A new AI section in the repo settings
panel exposes the three toggles and warns when the platform switch is off
or no endpoint is configured, since those settings then have no effect.

Adds the columns through the entity definitions, so the migrate hook
creates them; repo.aiSettings.* strings came in with the previous commit.
ZhenYi committed
parent 8963c28 commit 860bb09 Browse files

7 files changed

+314 −11

File tree 7
Modified lib/git/src/service/repos.rs View lib/git/src/service/repos.rs
@@ -51,6 +51,16 @@
5151 pub object_format: String,
5252 pub http_clone_url: Option<String>,
5353 pub ssh_clone_url: Option<String>,
54 /// 仓库级 AI 自动化开关(仍需平台 `[ai]` 总开关为开启)。
55 pub ai_review_on_push: bool,
56 pub ai_summary_on_push: bool,
57 pub ai_review_on_pull: bool,
58 /// 平台侧 AI 是否可用(总开关 + 模型端点已配置)。
59 ///
60 /// 随仓库详情一起下发,是因为设置页必须能区分「已关」与「开了也不生效」:
61 /// 仓库开关存得下、但平台没启用时,投递不会发生,界面要如实说明,否则用户
62 /// 会以为打开了却什么都没有。
63 pub ai_available: bool,
5464 pub created_at: OffsetDateTime,
5565 pub updated_at: OffsetDateTime,
5666 pub last_pushed_at: Option<OffsetDateTime>,
@@ -137,6 +147,11 @@
137147 object_format: m.object_format,
138148 http_clone_url: None,
139149 ssh_clone_url: None,
150 ai_review_on_push: m.ai_review_on_push,
151 ai_summary_on_push: m.ai_summary_on_push,
152 ai_review_on_pull: m.ai_review_on_pull,
153 // 平台状态由服务层补齐(`From` 拿不到配置)。
154 ai_available: false,
140155 created_at: m.created_at,
141156 updated_at: m.updated_at,
142157 last_pushed_at: m.last_pushed_at,
@@ -177,6 +192,12 @@
177192 pub enable_lfs: Option<bool>,
178193 #[serde(default)]
179194 pub default_branch: Option<String>,
195 #[serde(default)]
196 pub ai_review_on_push: Option<bool>,
197 #[serde(default)]
198 pub ai_summary_on_push: Option<bool>,
199 #[serde(default)]
200 pub ai_review_on_pull: Option<bool>,
180201}
181202
182203/// Strip a trailing `:<port>` from a URL authority (`localhost:3000` -> `localhost`).
@@ -242,10 +263,20 @@
242263 })
243264 }
244265
245 pub fn fill_clone_urls(&self, dto: &mut RepoDto, space_name: &str) {
266 /// 补齐 `RepoDto::from` 拿不到的两类派生字段:克隆地址(依赖空间名)与
267 /// 平台侧 AI 可用性(依赖全局配置)。
268 ///
269 /// 合并成一个入口,是因为仓库详情有四处出口(读取、更新、归档、取消归档),
270 /// 分开填充必然出现某处漏掉、界面在保存后忽然少一块信息的情况。
271 pub fn fill_derived(&self, dto: &mut RepoDto, space_name: &str) {
246272 let repo_name = dto.name.clone();
247273 dto.http_clone_url = self.http_clone_url(space_name, &repo_name);
248274 dto.ssh_clone_url = self.ssh_clone_url(space_name, &repo_name);
275 let cfg = &self.state.config.ai;
276 // 设置页必须能区分「已关」与「开了也不生效」:开关存得下,但平台没启用
277 // 时不会投递任务,界面要如实说明。只下发这一个布尔量——模型名之类的部署
278 // 细节不属于仓库页面的信息面。
279 dto.ai_available = cfg.enabled && !cfg.model.trim().is_empty();
249280 }
250281
251282 pub async fn repo_dto(&self, m: repos::Model) -> Result<RepoDto, GitError> {
@@ -253,7 +284,7 @@
253284 .await?
254285 .ok_or(GitError::NotFound)?;
255286 let mut dto = RepoDto::from(m);
256 self.fill_clone_urls(&mut dto, &space.name);
287 self.fill_derived(&mut dto, &space.name);
257288 Ok(dto)
258289 }
259290
@@ -375,6 +406,11 @@
375406 allow_squash_merge: Set(true),
376407 allow_merge_commits: Set(true),
377408 object_format: Set("sha1".to_string()),
409 // 新建仓库沿用平台既有行为:评审类自动触发照旧,自动总结默认
410 // 关闭,由仓库设置里显式开启。
411 ai_review_on_push: Set(true),
412 ai_summary_on_push: Set(false),
413 ai_review_on_pull: Set(true),
378414 };
379415 let repo = repo_row.insert(&txn).await?;
380416 txn.commit().await?;
@@ -454,7 +490,7 @@
454490 .await?
455491 .ok_or(GitError::NotFound)?;
456492 let mut dto = RepoDto::from(fresh);
457 self.fill_clone_urls(&mut dto, &space.name);
493 self.fill_derived(&mut dto, &space.name);
458494 Ok(dto)
459495 }
460496
@@ -589,6 +625,16 @@
589625 if let Some(branch) = &new_branch {
590626 am.default_branch = Set(branch.clone());
591627 }
628 // 三个 AI 开关互相独立:只传其中一个时不动其余两个。
629 if let Some(value) = cmd.ai_review_on_push {
630 am.ai_review_on_push = Set(value);
631 }
632 if let Some(value) = cmd.ai_summary_on_push {
633 am.ai_summary_on_push = Set(value);
634 }
635 if let Some(value) = cmd.ai_review_on_pull {
636 am.ai_review_on_pull = Set(value);
637 }
592638 am.updated_at = Set(now);
593639 let saved = am.update(self.db_write()).await?;
594640
@@ -608,7 +654,7 @@
608654 }
609655 }
610656 let mut dto = RepoDto::from(saved);
611 self.fill_clone_urls(&mut dto, &space.name);
657 self.fill_derived(&mut dto, &space.name);
612658 Ok(dto)
613659 }
614660
@@ -641,7 +687,7 @@
641687 };
642688 let saved = am.update(self.db_write()).await?;
643689 let mut dto = RepoDto::from(saved);
644 self.fill_clone_urls(&mut dto, &space.name);
690 self.fill_derived(&mut dto, &space.name);
645691 Ok(dto)
646692 }
647693
Modified lib/git/src/sync/ai_task.rs View lib/git/src/sync/ai_task.rs
@@ -12,6 +12,31 @@
1212use crate::sync::error::{SyncError, SyncResult};
1313use crate::sync::queue::SyncQueue;
1414
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
1540pub async fn submit_ai_task(
1641 db: &DatabaseConnection,
1742 redis: &AppRedis,
@@ -81,13 +106,24 @@
81106 actor_id: Option<Uuid>,
82107 refs: &[(String, String, String)],
83108) {
84 if !cfg.enabled {
109 let conn = db.write();
110 // 仓库级开关是这一层的准入条件:平台开着、仓库不想用,就不该产生调用。
111 // 读一次仓库行,两个开关都从它取,避免每条引用各查一次。
112 let (review_on_push, summary_on_push) =
113 match repos::Entity::find_by_id(repo_id).one(conn).await {
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() {
85122 return;
86123 }
87 let conn = db.write();
88124 // 一次推送产生的所有任务共用触发者语言。
89125 let language = crate::sync::ai_language::actor_language(conn, actor_id).await;
90 if cfg.review_on() {
126 if switches.review {
91127 for (name, _old, new) in refs {
92128 if !name.starts_with("refs/heads/") || is_zero_oid(new) {
93129 continue;
@@ -101,8 +137,24 @@
101137 submit_ai_task_logged(conn, redis, cfg, &task).await;
102138 }
103139 }
104 if cfg.release_notes_on() {
140 if switches.summary {
105141 for (name, _old, new) in refs {
142 if !name.starts_with("refs/heads/") || is_zero_oid(new) {
143 continue;
144 }
145 let task = ci_protocol::ai::AgentTask::RepoSummary {
146 job_id: Uuid::now_v7(),
147 repo_id,
148 head_sha: new.clone(),
149 base_sha: None,
150 limit: None,
151 language: language.clone(),
152 };
153 submit_ai_task_logged(conn, redis, cfg, &task).await;
154 }
155 }
156 if switches.release_notes {
157 for (name, _old, new) in refs {
106158 let Some(tag) = name.strip_prefix("refs/tags/") else {
107159 continue;
108160 };
@@ -400,3 +452,54 @@
400452 AgentTask::ReleaseNotes { .. } => AiJobKind::ReleaseNotes,
401453 }
402454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 fn cfg(enabled: bool, review: bool, summary: bool) -> AiConfig {
461 AiConfig {
462 enabled,
463 review_enabled: review,
464 summary_enabled: summary,
465 ..Default::default()
466 }
467 }
468
469 /// 仓库开关是「与」而不是「或」:仓库打开但平台没开时不能产生调用,否则
470 /// 关闭平台 AI 的部署仍会往外发请求。这也是设置页要提示「开了也不生效」的原因。
471 #[test]
472 fn platform_switch_gates_repo_switch() {
473 let off = push_ai_switches(&cfg(false, true, true), true, true);
474 assert!(!off.review && !off.summary, "平台关闭时仓库开关不得放行");
475
476 let on = push_ai_switches(&cfg(true, true, true), true, true);
477 assert!(on.review && on.summary);
478 }
479
480 /// 仓库关掉某项时,平台开着也不能投递——这是该功能的全部意义。
481 #[test]
482 fn repo_switch_gates_each_capability() {
483 let both_off = push_ai_switches(&cfg(true, true, true), false, false);
484 assert!(!both_off.review && !both_off.summary);
485
486 // 只开评审、不开自动总结:两个开关必须互不影响。
487 let review_only = push_ai_switches(&cfg(true, true, true), true, false);
488 assert!(review_only.review && !review_only.summary);
489
490 let summary_only = push_ai_switches(&cfg(true, true, true), false, true);
491 assert!(!summary_only.review && summary_only.summary);
492 }
493
494 /// 全关时不该继续往下走(`any()` 决定是否提前返回)。
495 #[test]
496 fn any_is_false_when_everything_is_off() {
497 let mut off = cfg(true, false, false);
498 off.release_notes_enabled = false;
499 assert!(!push_ai_switches(&off, false, false).any());
500 // release 文案没有仓库级开关,平台开着就仍应投递。
501 let mut with_release = cfg(true, false, false);
502 with_release.release_notes_enabled = true;
503 assert!(push_ai_switches(&with_release, false, false).any());
504 }
505}
Modified lib/models/src/repo/repos.rs View lib/models/src/repo/repos.rs
@@ -48,6 +48,24 @@
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,
5169 #[sea_orm(belongs_to, from = "dir_id", to = "id")]
5270 pub dir: BelongsTo<super::super::dir::dirs::Entity>,
5371 #[sea_orm(belongs_to, from = "license_name", to = "name")]
Modified lib/pulls/src/services/events.rs View lib/pulls/src/services/events.rs
@@ -173,6 +173,18 @@
173173 if !cfg.review_on() {
174174 return;
175175 }
176 // 仓库级开关:平台提供能力,仓库决定用不用。读不到仓库行时沿用旧行为
177 // (自动评审继续),因为静默丢掉一次评审比多发一次更难排查。
178 let repo_allows = models::repo::repos::Entity::find_by_id(pull.repo_id)
179 .one(self.db_read())
180 .await
181 .ok()
182 .flatten()
183 .map(|row| row.ai_review_on_pull)
184 .unwrap_or(true);
185 if !repo_allows {
186 return;
187 }
176188 let trigger = matches!(
177189 event_type,
178190 EVENT_CREATED | EVENT_HEAD_UPDATED | EVENT_REOPENED
Modified web/src/features/repo-settings/model/sections.ts View web/src/features/repo-settings/model/sections.ts
@@ -18,6 +18,7 @@
1818 | 'locks'
1919 | 'protection'
2020 | 'ci'
21 | 'ai'
2122
2223export const REPO_SETTINGS_SECTIONS: ReadonlyArray<{ id: RepoSettingsSection; label: string }> = [
2324 { id: 'general', get label() { return t('repo.settings.nav.general') } },
@@ -29,5 +30,5 @@
2930 { id: 'locks', get label() { return t('repo.settings.nav.locks') } },
3031 { id: 'protection', get label() { return t('repo.settings.nav.protection') } },
3132 { id: 'ci', get label() { return t('repo.settings.nav.ci') } },
33 { id: 'ai', get label() { return t('repo.settings.nav.ai') } },
3234]
33
Added web/src/features/repo-settings/ui/sections/ai-settings.tsx View web/src/features/repo-settings/ui/sections/ai-settings.tsx
@@ -0,0 +1,120 @@
1import { useState } from 'react'
2import { Alert, Button } from '@/shared/ui'
3import { useT } from '@/shared/i18n'
4import { updateRepo, type RepoDetail } from '@/entities/repo'
5import { Notice, SectionHead } from '../parts'
6
7/**
8 * AI automation switches, per repository.
9 *
10 * Two levels decide whether anything runs: the platform `[ai]` switch (is AI
11 * offered at all) and these three columns (does *this* repository want it).
12 *
13 * Only the *closed* platform case is surfaced. Announcing the healthy case is
14 * noise — it states the default every reader already assumes — and the model
15 * name is deployment detail, not something a repository page should hand out.
16 * When the platform switch is off the note is load-bearing: without it a saved
17 * switch that can never fire reads as a broken feature.
18 */
19export function AiSettings({
20 space,
21 repo,
22 repoDto,
23 onChanged,
24}: {
25 space: string
26 repo: string
27 repoDto: RepoDetail
28 onChanged: () => void
29}) {
30 const { t } = useT()
31 const [draft, setDraft] = useState({
32 ai_review_on_push: repoDto.ai_review_on_push,
33 ai_summary_on_push: repoDto.ai_summary_on_push,
34 ai_review_on_pull: repoDto.ai_review_on_pull,
35 })
36 const [busy, setBusy] = useState(false)
37 const [notice, setNotice] = useState<string | null>(null)
38 const [error, setError] = useState<string | null>(null)
39
40 const dirty =
41 draft.ai_review_on_push !== repoDto.ai_review_on_push ||
42 draft.ai_summary_on_push !== repoDto.ai_summary_on_push ||
43 draft.ai_review_on_pull !== repoDto.ai_review_on_pull
44
45 const save = async () => {
46 setBusy(true)
47 setNotice(null)
48 setError(null)
49 try {
50 await updateRepo(space, repo, draft)
51 setNotice(t('repo.aiSettings.saved'))
52 onChanged()
53 } catch (err) {
54 setError(err instanceof Error ? err.message : t('repo.error.saveFailed'))
55 } finally {
56 setBusy(false)
57 }
58 }
59
60 const rows: Array<{ key: keyof typeof draft; label: string; hint: string }> = [
61 {
62 key: 'ai_review_on_push',
63 label: t('repo.aiSettings.reviewOnPush'),
64 hint: t('repo.aiSettings.reviewOnPushHint'),
65 },
66 {
67 key: 'ai_summary_on_push',
68 label: t('repo.aiSettings.summaryOnPush'),
69 hint: t('repo.aiSettings.summaryOnPushHint'),
70 },
71 {
72 key: 'ai_review_on_pull',
73 label: t('repo.aiSettings.reviewOnPull'),
74 hint: t('repo.aiSettings.reviewOnPullHint'),
75 },
76 ]
77
78 return (
79 <div>
80 <SectionHead title={t('repo.settings.nav.ai')} sub={t('repo.aiSettings.sub')} />
81 <Notice notice={notice} error={error} />
82
83 {repoDto.ai_available ? null : (
84 <div className="mb-5">
85 <Alert tone="info">{t('repo.aiSettings.disabled')}</Alert>
86 </div>
87 )}
88
89 <ul className="mb-5 space-y-3">
90 {rows.map(row => (
91 <li
92 key={row.key}
93 className="flex flex-wrap items-start gap-3 rounded-xl border border-shell-hairline bg-shell-field px-4 py-3"
94 >
95 <label className="flex flex-1 cursor-pointer items-start gap-3">
96 <input
97 type="checkbox"
98 className="mt-0.5 h-4 w-4 shrink-0 accent-[var(--shell-action)]"
99 checked={draft[row.key]}
100 onChange={event =>
101 setDraft(prev => ({ ...prev, [row.key]: event.target.checked }))
102 }
103 />
104 <span className="min-w-0">
105 <span className="block text-[13.5px] font-medium">{row.label}</span>
106 <span className="mt-1 block text-[12px] leading-5 text-shell-ink-muted">
107 {row.hint}
108 </span>
109 </span>
110 </label>
111 </li>
112 ))}
113 </ul>
114
115 <Button disabled={busy || !dirty} onClick={() => void save()}>
116 {t('common.action.save')}
117 </Button>
118 </div>
119 )
120}
Modified web/src/features/repo-settings/ui/settings-tab.tsx View web/src/features/repo-settings/ui/settings-tab.tsx
@@ -2,6 +2,7 @@
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'
56import { CiSettings } from './sections/ci-settings'
67import { GeneralSettings } from './sections/general-settings'
78import { ImportSettings } from './sections/import-settings'
@@ -62,6 +63,9 @@
6263 {section === 'topics' ? <TopicsSettings space={space} repo={repo} /> : null}
6364 {section === 'protection' ? <ProtectionSettings space={space} repo={repo} /> : null}
6465 {section === 'ci' ? <CiSettings space={space} repo={repo} rev={rev} /> : null}
66 {section === 'ai' ? (
67 <AiSettings space={space} repo={repo} repoDto={repoDto} onChanged={onSaved} />
68 ) : null}
6569 {section === 'remotes' ? <RemotesSettings space={space} repo={repo} /> : null}
6670 {section === 'import' ? <ImportSettings space={space} repo={repo} /> : null}
6771 {section === 'archives' ? (
@@ -81,4 +85,3 @@
8185 </div>
8286 )
8387}
84