Skip to content
Clone
2f5d35e
241 lines 9.9 KB Rust
use crate::share::errors::GitError;
use models::ai::ai_job::AiJobStatus;
use models::ai::repo_ai_summary;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
use serde::{Deserialize, Serialize};
use time::{Duration, OffsetDateTime};
use utoipa::ToSchema;
use uuid::Uuid;
/// 一条作业还能算「在途」多久。
///
/// 队列没有租约:worker 崩溃、容器被重启、或在投递前进程退出,都会在
/// `ai_job` 里留下一条永远停在 `pending`/`running` 的行。管理端的重投只覆盖
/// `failed`,没人回收这些行,所以「有没有作业」不能只看状态,否则几天前的残骸
/// 会一直在页面上显示「正在生成」,还会把新任务挡在幂等折叠之外。
///
/// 窗口由既有配置推出,不引入新旋钮:单次请求超时 × 尝试次数,再放宽 4 倍
/// 容纳排队与重试。默认值(120s × 3 × 4)即 24 分钟——超过这个时间还没结束的
/// 作业,对用户来说已经不是在途,而是失败。
fn inflight_window(cfg: &config::ai::AiConfig) -> Duration {
let seconds = cfg
.request_timeout_secs
.max(60)
.saturating_mul(cfg.max_attempts.max(1) as u64)
.saturating_mul(4);
Duration::seconds(seconds.min(6 * 60 * 60) as i64)
}
/// 仓库 AI 总结视图。
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct RepoAiSummaryDto {
pub id: Uuid,
pub repo_id: Uuid,
pub head_sha: String,
pub base_sha: Option<String>,
pub commit_count: i32,
pub content: String,
pub highlights: Vec<String>,
pub risks: Vec<String>,
pub model: String,
/// 生成该总结时使用的输出语言(BCP-47 或 `auto`)。
pub language: String,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
/// 该总结是否对应当前默认分支提示。
pub current: bool,
}
/// 一条仍未结束的总结作业。
///
/// 与结果分开建模:作业可能正为**另一个** head 生成(排队期间有人推送),
/// 而结果行只能属于它自己的 head。把两者塞进同一个结构会让「有结果」与
/// 「正在生成」互相掩盖,正是首次生成时页面什么都看不到的原因。
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct RepoAiSummaryJobDto {
pub job_id: Uuid,
pub head_sha: String,
/// `pending`(已入队)或 `running`(执行中)。
pub status: AiJobStatus,
pub language: String,
pub created_at: OffsetDateTime,
pub started_at: Option<OffsetDateTime>,
}
/// 总结页签的完整状态:默认分支当前 tip、该 tip 上的结果、以及未结束的作业。
///
/// 三个字段一起返回,调用方才能区分「还没生成」与「正在生成」——只给结果时
/// 两者都是 `null`,界面就只能显示空态。
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct RepoAiSummaryStateDto {
/// 默认分支当前 tip,是结果与作业共同的坐标。
pub head_sha: String,
/// 该 head 上已缓存的总结;尚未生成时为 null。
pub summary: Option<RepoAiSummaryDto>,
/// 仍未结束的总结作业;没有则为 null。
pub pending: Option<RepoAiSummaryJobDto>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct TriggerSummaryCmd {
/// 输出语言(BCP-47 或 `auto`);缺省跟随触发者的语言偏好。
#[serde(default)]
pub language: Option<String>,
/// 区间的起点引用(分支/标签/SHA);缺省由服务端取默认分支。
#[serde(default)]
pub base: Option<String>,
/// 参与总结的提交上限;缺省使用 `[ai].summary_commit_limit`。
#[serde(default)]
pub limit: Option<u64>,
}
impl super::GitService {
/// 读取当前默认分支的 AI 总结状态:结果可能尚未生成,作业可能仍在路上。
pub async fn get_repo_ai_summary(
&self,
space_name: String,
repo_name: String,
) -> Result<RepoAiSummaryStateDto, GitError> {
let ctx = self.context(space_name, repo_name).await?;
let head = self
.baby(&ctx)
.rev_parse(&format!("refs/heads/{}", ctx.model.default_branch))
.await
.map(|oid| oid.to_string())
.map_err(crate::share::errors::map_baby)?;
let row = repo_ai_summary::Entity::find()
.filter(repo_ai_summary::Column::RepoId.eq(ctx.model.id))
.filter(repo_ai_summary::Column::HeadSha.eq(head.clone()))
.one(self.db_read())
.await?;
let pending = self.pending_summary_job(ctx.model.id).await;
Ok(RepoAiSummaryStateDto {
summary: row.map(|row| Self::summary_dto(row, &head)),
pending,
head_sha: head,
})
}
/// 触发(或刷新)当前 head 的 AI 总结。
pub async fn trigger_repo_ai_summary(
&self,
space_name: String,
repo_name: String,
actor: Option<Uuid>,
cmd: TriggerSummaryCmd,
) -> Result<Uuid, GitError> {
let cfg = &self.state.config.ai;
if !cfg.summary_on() {
return Err(GitError::InvalidArgument(
"ai summary is disabled by config".to_string(),
));
}
let ctx = self.context(space_name, repo_name).await?;
let head = self
.baby(&ctx)
.rev_parse(&format!("refs/heads/{}", ctx.model.default_branch))
.await
.map(|oid| oid.to_string())
.map_err(crate::share::errors::map_baby)?;
let base_sha = match cmd.base.as_deref().filter(|value| !value.trim().is_empty()) {
Some(base) => Some(
self.baby(&ctx)
.rev_parse(base)
.await
.map_err(crate::share::errors::map_baby)?
.to_string(),
),
None => None,
};
// 空态文案承诺「同一版本不会重复调用模型」:同一个 head 已有在途作业时
// 直接把它交回去,而不是再排一个。重复点击(或界面轮询触发)因此是幂等的。
if let Some(queued) = self.queued_summary_job(ctx.model.id, &head).await {
return Ok(queued);
}
let task = ci_protocol::ai::AgentTask::RepoSummary {
job_id: Uuid::now_v7(),
repo_id: ctx.model.id,
head_sha: head,
base_sha,
limit: cmd.limit,
language: match cmd
.language
.clone()
.filter(|value| !value.trim().is_empty())
{
Some(explicit) => Some(ci_protocol::ai_languages::normalize(&explicit)),
None => crate::sync::ai_language::actor_language(self.db_read(), actor).await,
},
};
crate::sync::ai_task::submit_ai_task(self.db_write(), &self.state.redis, cfg, &task)
.await
.map_err(|e| GitError::Internal(format!("submit ai summary: {e}")))
}
/// 该仓库仍未结束的总结作业,最新的优先。
///
/// 按仓库而不是按 head 取:排队期间有人推送时,作业的目标已经不是当前
/// tip 了,但它依然是「正在生成」,界面必须能说出来——带上它的 head,
/// 用户才知道完成的是哪一版。
async fn pending_summary_job(&self, repo_id: Uuid) -> Option<RepoAiSummaryJobDto> {
use models::ai::ai_job::{self, AiJobKind, AiJobStatus};
let cutoff = OffsetDateTime::now_utc() - inflight_window(&self.state.config.ai);
ai_job::Entity::find()
.filter(ai_job::Column::RepoId.eq(repo_id))
.filter(ai_job::Column::Kind.eq(AiJobKind::RepoSummary))
.filter(ai_job::Column::Status.is_in([AiJobStatus::Pending, AiJobStatus::Running]))
.filter(ai_job::Column::CreatedAt.gte(cutoff))
.order_by_desc(ai_job::Column::CreatedAt)
.one(self.db_read())
.await
.ok()
.flatten()
.map(|row| RepoAiSummaryJobDto {
status: row.status,
head_sha: row.target_ref.unwrap_or_default(),
language: row.language,
created_at: row.created_at,
started_at: row.started_at,
job_id: row.id,
})
}
/// 指定 head 上仍未结束的总结作业 id,用于把重复触发折叠成一次。
async fn queued_summary_job(&self, repo_id: Uuid, head: &str) -> Option<Uuid> {
use models::ai::ai_job::{self, AiJobKind, AiJobStatus};
let cutoff = OffsetDateTime::now_utc() - inflight_window(&self.state.config.ai);
ai_job::Entity::find()
.filter(ai_job::Column::RepoId.eq(repo_id))
.filter(ai_job::Column::Kind.eq(AiJobKind::RepoSummary))
.filter(ai_job::Column::TargetRef.eq(head.to_string()))
.filter(ai_job::Column::Status.is_in([AiJobStatus::Pending, AiJobStatus::Running]))
.filter(ai_job::Column::CreatedAt.gte(cutoff))
.order_by_desc(ai_job::Column::CreatedAt)
.one(self.db_read())
.await
.ok()
.flatten()
.map(|row| row.id)
}
fn summary_dto(row: repo_ai_summary::Model, head: &str) -> RepoAiSummaryDto {
let highlights: Vec<String> =
serde_json::from_value(row.highlights_json).unwrap_or_default();
let risks: Vec<String> = serde_json::from_value(row.risks_json).unwrap_or_default();
RepoAiSummaryDto {
id: row.id,
repo_id: row.repo_id,
head_sha: row.head_sha.clone(),
base_sha: row.base_sha,
commit_count: row.commit_count,
content: row.content,
highlights,
risks,
model: row.model,
language: row.language,
created_at: row.created_at,
updated_at: row.updated_at,
current: row.head_sha == head,
}
}
}