mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-03-27 10:05:50 +00:00
Merge pull request #4 from jeffusion/agent-review-engine
Agent review engine
This commit is contained in:
@@ -7,3 +7,4 @@ frontend/node_modules/
|
||||
|
||||
# 忽略 kubernetes.yaml
|
||||
kubernetes.yaml
|
||||
config-overrides.json
|
||||
|
||||
32
.env.example
32
.env.example
@@ -19,3 +19,35 @@ PORT=3000
|
||||
# 在Linux/Mac终端: openssl rand -hex 32
|
||||
# 或者在Node.js中: require('crypto').randomBytes(32).toString('hex')
|
||||
WEBHOOK_SECRET=your_webhook_secret
|
||||
|
||||
# Agent审查配置(默认关闭,开启请设置为agent)
|
||||
REVIEW_ENGINE=legacy
|
||||
REVIEW_WORKDIR=/tmp/gitea-assistant
|
||||
REVIEW_MODEL_PLANNER=gpt-4o-mini
|
||||
REVIEW_MODEL_SPECIALIST=gpt-4o-mini
|
||||
REVIEW_MODEL_JUDGE=gpt-4o-mini
|
||||
REVIEW_MAX_PARALLEL_RUNS=2
|
||||
REVIEW_MAX_FILES_PER_RUN=200
|
||||
REVIEW_MAX_FILE_CONTENT_CHARS=40000
|
||||
REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE=0.8
|
||||
REVIEW_ENABLE_HUMAN_GATE=true
|
||||
REVIEW_ALLOWED_COMMANDS=git,rg,cat,sed,wc
|
||||
REVIEW_COMMAND_TIMEOUT_MS=10000
|
||||
|
||||
# 向量记忆和学习系统配置(可选,第二阶段功能)
|
||||
# Qdrant向量数据库URL(如果不配置则禁用记忆系统)
|
||||
QDRANT_URL=http://localhost:6333
|
||||
# 是否启用记忆系统(需要先配置QDRANT_URL)
|
||||
ENABLE_MEMORY=false
|
||||
# Few-shot学习示例数量(0-20)
|
||||
FEW_SHOT_EXAMPLES_COUNT=10
|
||||
|
||||
# Reflection和Debate配置(可选,第三阶段功能)
|
||||
# 是否启用Reflection自我批评机制(提升审查质量)
|
||||
ENABLE_REFLECTION=false
|
||||
# Reflection最大轮次(1-5)
|
||||
MAX_REFLECTION_ROUNDS=2
|
||||
# 是否启用Debate多代理辩论机制(提升高严重性问题准确性)
|
||||
ENABLE_DEBATE=false
|
||||
# Debate触发阈值(high=仅高严重性, medium=高和中等严重性)
|
||||
DEBATE_THRESHOLD=high
|
||||
|
||||
31
.github/workflows/ci.yml
vendored
Normal file
31
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
continue-on-error: true # Pre-existing lint violations — non-blocking until cleanup
|
||||
|
||||
- name: Type check
|
||||
run: bun run build
|
||||
|
||||
- name: Run tests
|
||||
run: bun test
|
||||
81
.github/workflows/release.yml
vendored
Normal file
81
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
continue-on-error: true
|
||||
|
||||
- name: Type check
|
||||
run: bun run build
|
||||
|
||||
- name: Run tests
|
||||
run: bun test
|
||||
|
||||
docker:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,3 +2,6 @@ node_modules/
|
||||
dist/
|
||||
.env
|
||||
kubernetes.yaml
|
||||
config-overrides.json
|
||||
.sisyphus/
|
||||
e2e/.env.e2e
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM oven/bun:1 as frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
|
||||
# 拷贝前端的 package.json 和 lockfile
|
||||
COPY frontend/package.json frontend/bun.lockb ./
|
||||
COPY frontend/package.json frontend/bun.lock* ./
|
||||
|
||||
# 安装前端依赖
|
||||
RUN bun install --frozen-lockfile
|
||||
@@ -22,7 +22,7 @@ FROM oven/bun:1 as backend-builder
|
||||
WORKDIR /app
|
||||
|
||||
# 拷贝后端的 package.json 和 lockfile
|
||||
COPY package.json bun.lockb ./
|
||||
COPY package.json bun.lock* ./
|
||||
|
||||
# 只安装生产环境依赖
|
||||
RUN bun install --frozen-lockfile --production
|
||||
|
||||
40
Makefile
40
Makefile
@@ -1,40 +0,0 @@
|
||||
NAME = gitea-assistant
|
||||
REGISTRY = docker-hosted.nexus.satfabric.com
|
||||
SHA1 = $(shell git rev-parse HEAD)
|
||||
REVISION = $(shell git rev-list --count HEAD)
|
||||
LABELS = --label SHA1=${SHA1} --label REVISION=${REVISION}
|
||||
VERSION = $(shell ./auto-ver.sh).${REVISION}
|
||||
|
||||
OS ?= linux
|
||||
ARCH ?= amd64
|
||||
|
||||
.PHONY: build
|
||||
build:
|
||||
npm run build
|
||||
|
||||
.PHONY: container.build
|
||||
container.build:
|
||||
make build
|
||||
docker buildx build --platform=${OS}/${ARCH} -t ${REGISTRY}/${NAME}:v${VERSION} ${LABELS} -f Dockerfile .
|
||||
|
||||
.PHONY: container.push
|
||||
container.push:
|
||||
make build
|
||||
docker buildx build --platform=${OS}/${ARCH} -t ${REGISTRY}/${NAME}:v${VERSION} ${LABELS} -f Dockerfile . --push
|
||||
|
||||
.PHONY: k8s.yaml
|
||||
k8s.yaml:
|
||||
cp ./kubernetes.yaml.template ./kubernetes.yaml
|
||||
sed -i.bak 's@<%= IMAGE_FROM %>@registry.kuiper.com/${NAME}:v${VERSION}@g' ./kubernetes.yaml
|
||||
sed -i.bak 's@<%= APP_NAME %>@${NAME}@g' ./kubernetes.yaml
|
||||
sed -i.bak 's@<%= VERSION %>@${VERSION}@g' ./kubernetes.yaml
|
||||
rm -f ./kubernetes.yaml.bak
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo 'Usage: make [target]'
|
||||
|
||||
@echo 'Available targets:'
|
||||
@printf " %-25s %s\n" "container.<build|push>" "本地构建(或构建加推送)容器镜像,若不执行TAG参数,则自动生成VERSION字段"
|
||||
@printf " %-25s %s\n" "k8s.yaml" "生成kubernetes.yaml文件"
|
||||
@printf " %-25s %s\n" "help" "显示此帮助信息"
|
||||
531
README.md
531
README.md
@@ -1,389 +1,180 @@
|
||||
# Gitea Assistant
|
||||
# Gitea AI Assistant
|
||||
|
||||
Gitea功能增强助手,基于Bun和TypeScript开发,提供AI驱动的代码审查等增强功能。本工具通过Webhook与Gitea集成,自动对Pull Request和提交进行代码审查,并提供智能化的代码质量分析。
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
## 功能特点
|
||||
AI-powered code review assistant for Gitea. Automatically reviews Pull Requests and commits using OpenAI, providing intelligent code quality analysis with both summary comments and line-level feedback.
|
||||
|
||||
- ✅ 自动对Gitea Pull Request进行代码审查
|
||||
- ✅ 自动对成功状态的单个提交进行代码审查
|
||||
- ✅ 使用OpenAI API进行代码分析
|
||||
- ✅ 提供总体代码审查评论
|
||||
- ✅ 支持代码行级别评论
|
||||
- ✅ 安全的Webhook验证
|
||||
- ✅ 飞书通知集成
|
||||
- ✅ 异步处理机制
|
||||
- ✅ 智能PR关联分析
|
||||
- ✅ 灵活的审查规则配置
|
||||
- ✅ **后台管理页面**: 提供UI界面,用于自动配置项目的Webhook。
|
||||
**[中文文档](./docs/README.zh-CN.md)**
|
||||
|
||||
## 架构设计
|
||||
## Features
|
||||
|
||||
### 核心组件
|
||||
- 🤖 **AI Code Review** - Automatic review of PRs and commits using OpenAI models
|
||||
- 📝 **Line-Level Comments** - Precise feedback on specific code changes
|
||||
- 🔄 **Dual Review Engines** - Legacy (simple) or Agent-based (multi-agent) review modes
|
||||
- 🔔 **Feishu Notifications** - Integrated notification system for PR events
|
||||
- 🎛️ **Admin Dashboard** - Web UI for managing repository webhooks and configuration
|
||||
- 🔐 **Secure Webhooks** - HMAC-SHA256 signature verification
|
||||
|
||||
1. **Webhook处理层**
|
||||
- 统一的Webhook端点处理
|
||||
- 事件类型自动识别
|
||||
- 请求签名验证
|
||||
- 异步处理机制
|
||||
## Architecture
|
||||
|
||||
2. **代码审查引擎**
|
||||
- 差异分析
|
||||
- 文件变更追踪
|
||||
- 智能PR关联
|
||||
- 审查结果格式化
|
||||
|
||||
3. **AI集成层**
|
||||
- OpenAI API集成
|
||||
- 可配置的提示模板
|
||||
- 结果解析和格式化
|
||||
- 错误处理和重试机制
|
||||
|
||||
4. **通知系统**
|
||||
- 飞书Webhook集成
|
||||
- 多类型通知支持
|
||||
- 通知模板配置
|
||||
- 失败重试机制
|
||||
|
||||
### 安全特性
|
||||
|
||||
- Webhook请求签名验证
|
||||
- 环境变量配置管理
|
||||
- 敏感信息保护
|
||||
- 开发环境安全控制
|
||||
- 防时序攻击保护
|
||||
|
||||
### 性能优化
|
||||
|
||||
- 异步处理机制
|
||||
- 批量处理优化
|
||||
- 缓存策略
|
||||
- 资源使用监控
|
||||
- 错误重试机制
|
||||
|
||||
### 扩展性
|
||||
|
||||
- 模块化设计
|
||||
- 插件化架构
|
||||
- 配置驱动
|
||||
- 自定义审查规则
|
||||
- 多通知渠道支持
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Bun
|
||||
- TypeScript
|
||||
- Hono (轻量级Web框架)
|
||||
- OpenAI API
|
||||
- Gitea API
|
||||
|
||||
## 安装
|
||||
|
||||
1. 克隆仓库
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd ai-review
|
||||
```
|
||||
|
||||
2. 安装依赖
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
3. 配置环境变量
|
||||
|
||||
复制.env.example文件为.env并填写必要配置:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
编辑.env文件,填写Gitea和OpenAI相关配置。
|
||||
|
||||
## 配置项
|
||||
|
||||
- `GITEA_API_URL`: Gitea API URL (例如: `http://your-gitea-instance.com/api/v1`)
|
||||
- `GITEA_ACCESS_TOKEN`: 用于代码审查的 Gitea 访问令牌 (需要仓库读权限和评论权限)。
|
||||
- `GITEA_ADMIN_TOKEN`: **(可选)** 用于后台管理的 Gitea 管理员令牌 (需要仓库读写及 Webhook 管理权限)。若不提供,则使用 `GITEA_ACCESS_TOKEN`。
|
||||
- `OPENAI_BASE_URL`: OpenAI 请求地址
|
||||
- `OPENAI_API_KEY`: OpenAI API密钥
|
||||
- `OPENAI_MODEL`:OpenAI 使用模型
|
||||
- `CUSTOM_SUMMARY_PROMPT`: 自定义总结审查提示 (可选)
|
||||
- `CUSTOM_LINE_COMMENT_PROMPT`: 自定义行评论提示 (可选)
|
||||
- `PORT`: 应用监听端口 (默认: 3000)
|
||||
- `WEBHOOK_SECRET`: Webhook秘钥,用于验证请求来源
|
||||
- `FEISHU_WEBHOOK_URL`: 飞书Webhook地址,用于发送通知
|
||||
- `FEISHU_WEBHOOK_SECRET`: 飞书Webhook秘यो (可选)
|
||||
- `ADMIN_PASSWORD`: 后台管理页面的登录密码 (默认: `password`)
|
||||
- `JWT_SECRET`: 用于签发后台登录Token的秘钥 (默认会使用一个安全字符串)
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 启动服务
|
||||
|
||||
```bash
|
||||
bun run dev # 开发模式
|
||||
# 或
|
||||
bun run start # 生产模式
|
||||
```
|
||||
|
||||
2. **(新)** 访问后台管理页面自动配置
|
||||
|
||||
启动服务后,直接在浏览器中访问 `http://your-server:3000`。您会看到一个登录页面。
|
||||
- **登录**: 使用您在环境变量中设置的 `ADMIN_PASSWORD` 进行登录。
|
||||
- **管理仓库**: 登录后,您将看到 Gitea 实例上的仓库列表。
|
||||
- **一键启用/停用**: 点击每行最右侧的按钮,即可为该仓库自动创建或删除 AI 代码审查所需的 Webhook。
|
||||
|
||||
> 强烈推荐使用此方法来代替手动配置,它更简单、更不容易出错。
|
||||
|
||||
3. 在Gitea仓库中**手动**配置Webhook
|
||||
|
||||
如果您不想使用后台管理功能,也可以继续手动配置。在Gitea仓库设置中添加Webhook:
|
||||
|
||||
**统一Webhook端点**:
|
||||
- URL: `http://your-server:3000/webhook/gitea`
|
||||
- 内容类型: `application/json`
|
||||
- 秘钥: 设置为与`WEBHOOK_SECRET`环境变量相同的值
|
||||
- 触发事件: 选择"Pull Request"和"Status"事件
|
||||
|
||||
> 注意: 系统使用统一的webhook端点处理所有事件类型,包括Pull Request和Commit Status事件。
|
||||
|
||||
### Webhook签名验证
|
||||
|
||||
为确保请求安全,系统使用Gitea的Webhook签名验证机制:
|
||||
|
||||
1. 设置环境变量`WEBHOOK_SECRET`为一个安全的随机字符串
|
||||
2. 在Gitea的Webhook配置中,使用相同的字符串作为"秘钥"
|
||||
3. 每次请求时,系统会验证请求头中的`X-Gitea-Signature`
|
||||
4. 如果签名验证失败,请求会被拒绝处理
|
||||
5. 在开发环境下(`NODE_ENV=development`),如果没有提供签名,系统会跳过验证
|
||||
|
||||
验证方法使用SHA-256哈希算法,在处理高负载的情况下这能防止恶意请求并保证请求来源的真实性。
|
||||
|
||||
## 功能说明
|
||||
|
||||
### PR代码审查
|
||||
|
||||
当PR被创建或更新时,系统会自动进行代码审查,提供总体评价和行级评论。
|
||||
|
||||
### PR通知功能
|
||||
|
||||
系统支持通过飞书发送PR相关的通知:
|
||||
|
||||
1. **PR创建通知**
|
||||
- 当PR被创建且有指定审阅者时
|
||||
- 通知内容包括PR标题和链接
|
||||
- 通知会发送给所有指定的审阅者
|
||||
|
||||
2. **PR审阅者指派通知**
|
||||
- 当有新的审阅者被指派到PR时
|
||||
- 通知内容包括PR标题和链接
|
||||
- 通知会发送给新指派的审阅者
|
||||
|
||||
### 单个提交审查
|
||||
|
||||
当提交状态变为"success"(如CI通过)时,系统会:
|
||||
|
||||
1. 对该提交进行代码审查
|
||||
2. 提供总体评价作为提交评论
|
||||
3. 尝试找到关联的PR,添加行级评论
|
||||
|
||||
这对于增量工作尤其有用,可以只对最新的变更进行审查,避免重复评审已审查过的代码。
|
||||
|
||||
## 代码审查规则
|
||||
|
||||
### 总体评价规则
|
||||
|
||||
系统会从以下几个方面对代码进行总体评价:
|
||||
|
||||
1. **代码质量**
|
||||
- 代码结构是否清晰
|
||||
- 命名是否规范
|
||||
- 代码是否易于维护
|
||||
- 是否有重复代码
|
||||
|
||||
2. **潜在问题**
|
||||
- 是否存在明显的逻辑错误
|
||||
- 是否有未处理的异常情况
|
||||
- 是否有边界条件未考虑
|
||||
|
||||
3. **性能考虑**
|
||||
- 是否存在性能瓶颈
|
||||
- 是否有不必要的计算或循环
|
||||
- 内存使用是否合理
|
||||
|
||||
4. **安全性**
|
||||
- 是否有潜在的安全漏洞
|
||||
- 敏感信息处理是否安全
|
||||
- 输入验证是否充分
|
||||
|
||||
5. **最佳实践**
|
||||
- 是否符合语言/框架的最佳实践
|
||||
- 是否遵循设计模式
|
||||
- 是否有适当的注释和文档
|
||||
|
||||
### 行级评论规则
|
||||
|
||||
系统只会在以下情况下对特定代码行提供评论:
|
||||
|
||||
1. **严重问题**
|
||||
- 明显的bug或逻辑错误
|
||||
- 可能导致系统崩溃的代码
|
||||
- 严重的安全漏洞
|
||||
|
||||
2. **性能问题**
|
||||
- 明显的性能瓶颈
|
||||
- 不必要的高复杂度操作
|
||||
- 资源使用不当
|
||||
|
||||
3. **数据一致性问题**
|
||||
- 可能导致数据不一致的操作
|
||||
- 并发访问问题
|
||||
- 事务处理不当
|
||||
|
||||
4. **代码规范问题**
|
||||
- 严重违反代码规范的情况
|
||||
- 可能导致维护困难的结构
|
||||
|
||||
> 注意:系统默认采用保守策略,不会对没有明显问题的代码行提供评论,以避免产生过多的噪音。
|
||||
|
||||
## 开发
|
||||
|
||||
- `bun run dev`: 开发模式运行
|
||||
- `bun run build`: 构建项目
|
||||
- `bun run start`: 生产模式运行
|
||||
- `bun run lint`: 运行代码风格检查
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
|
||||
## 自定义AI审查提示
|
||||
|
||||
默认情况下,AI代码审查工具配置为只对明显的bug和严重问题进行评论。你可以通过环境变量自定义AI使用的提示:
|
||||
|
||||
### 自定义总结提示
|
||||
|
||||
设置`CUSTOM_SUMMARY_PROMPT`环境变量来自定义代码审查总结。你可以在提示中使用以下变量,它们会在运行时被自动替换:
|
||||
|
||||
- `${context.diffContent}` - 代码差异内容
|
||||
- `${JSON.stringify(fileInfo, null, 2)}` - 变更文件的完整信息
|
||||
|
||||
### 自定义行评论提示
|
||||
|
||||
设置`CUSTOM_LINE_COMMENT_PROMPT`环境变量来自定义行级评论生成。你可以在提示中使用以下变量:
|
||||
|
||||
- `${file.path}` - 当前文件路径
|
||||
- `${fileContent}` - 文件的完整内容
|
||||
- ```${file.changes.map(c => `${c.lineNumber}: ${c.content} (${c.type === 'add' ? '新增' : '上下文'})`).join('\n')}``` - 变更行的上下文
|
||||
|
||||
请确保你的自定义提示返回正确的格式,特别是对于行评论,必须返回有效的JSON数组。
|
||||
|
||||
## 部署
|
||||
|
||||
### Docker部署
|
||||
|
||||
1. 构建Docker镜像
|
||||
|
||||
```bash
|
||||
docker build -t gitea-assistant .
|
||||
```
|
||||
|
||||
2. 运行容器
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name gitea-assistant \
|
||||
-p 3000:3000 \
|
||||
--env-file .env \
|
||||
gitea-assistant
|
||||
```
|
||||
|
||||
### Kubernetes部署
|
||||
|
||||
1. 使用提供的kubernetes.yaml模板
|
||||
|
||||
```bash
|
||||
cp kubernetes.yaml.template kubernetes.yaml
|
||||
```
|
||||
|
||||
2. 编辑kubernetes.yaml,更新环境变量和配置
|
||||
|
||||
3. 部署到Kubernetes集群
|
||||
|
||||
```bash
|
||||
kubectl apply -f kubernetes.yaml
|
||||
```
|
||||
|
||||
## 监控和日志
|
||||
|
||||
- 应用日志可以通过Docker或Kubernetes的标准日志收集机制获取
|
||||
- 建议配置日志聚合服务(如ELK、Grafana Loki等)进行日志管理
|
||||
- 关键操作(如代码审查、Webhook处理)都会记录详细日志
|
||||
- 错误和异常会被记录并包含堆栈跟踪信息
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **Webhook验证失败**
|
||||
- 检查`WEBHOOK_SECRET`环境变量是否与Gitea配置匹配
|
||||
- 确保请求头中的`X-Gitea-Signature`正确传递
|
||||
|
||||
2. **OpenAI API调用失败**
|
||||
- 验证`OPENAI_API_KEY`是否正确设置
|
||||
- 检查网络连接和API端点可访问性
|
||||
- 确认API配额是否充足
|
||||
|
||||
3. **Gitea API调用失败**
|
||||
- 验证`GITEA_ACCESS_TOKEN`是否有效
|
||||
- 检查Gitea实例是否可访问
|
||||
- 确认令牌权限是否足够
|
||||
|
||||
### 调试模式
|
||||
|
||||
在开发环境中,可以设置以下环境变量启用调试模式:
|
||||
|
||||
```bash
|
||||
DEBUG=true
|
||||
NODE_ENV=development
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Gitea Server │────▶│ Gitea Assistant │────▶│ OpenAI API │
|
||||
│ (Webhooks) │ │ (Hono + Bun) │ │ │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Admin Dashboard │
|
||||
│ (React SPA) │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
## 贡献指南
|
||||
### Review Engines
|
||||
|
||||
### 开发流程
|
||||
| Engine | Description | Use Case |
|
||||
|--------|-------------|----------|
|
||||
| `legacy` | Single-pass AI review with summary + line comments | Simple, fast reviews |
|
||||
| `agent` | Multi-agent orchestration with specialists, reflection, and debate | Deep, comprehensive analysis |
|
||||
|
||||
1. Fork项目并创建特性分支
|
||||
2. 提交变更并编写测试
|
||||
3. 确保代码通过lint检查
|
||||
4. 提交Pull Request
|
||||
## Quick Start
|
||||
|
||||
### 代码规范
|
||||
### Prerequisites
|
||||
|
||||
- 使用TypeScript编写代码
|
||||
- 遵循项目中的tslint配置
|
||||
- 编写清晰的注释和文档
|
||||
- 保持代码风格一致
|
||||
- [Bun](https://bun.sh/) >= 1.2.5
|
||||
- Gitea instance with API access
|
||||
- OpenAI API key
|
||||
|
||||
### 测试
|
||||
### Installation
|
||||
|
||||
- 编写单元测试覆盖新功能
|
||||
- 确保现有测试通过
|
||||
- 测试Webhook处理逻辑
|
||||
- 验证AI审查功能
|
||||
```bash
|
||||
git clone https://github.com/user/gitea-ai-assistant.git
|
||||
cd gitea-ai-assistant
|
||||
bun install
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
## 版本历史
|
||||
### Configuration
|
||||
|
||||
- v1.0.0: 初始版本发布
|
||||
- 基础代码审查功能
|
||||
- Webhook集成
|
||||
- OpenAI集成
|
||||
- 飞书通知支持
|
||||
Edit `.env` with your settings:
|
||||
|
||||
## 社区支持
|
||||
```bash
|
||||
# Gitea
|
||||
GITEA_API_URL=https://your-gitea-instance.com/api/v1
|
||||
GITEA_ACCESS_TOKEN=your_gitea_token
|
||||
|
||||
- 提交Issue报告问题
|
||||
- 参与讨论和功能建议
|
||||
- 贡献代码和文档
|
||||
- 分享使用经验
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=your_openai_key
|
||||
OPENAI_MODEL=gpt-4o-mini
|
||||
|
||||
# Security
|
||||
WEBHOOK_SECRET=your_webhook_secret # openssl rand -hex 32
|
||||
|
||||
# Admin Dashboard
|
||||
ADMIN_PASSWORD=your_admin_password
|
||||
```
|
||||
|
||||
See [Configuration Reference](#configuration-reference) for all options.
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
bun run dev # Development mode
|
||||
bun run start # Production mode
|
||||
```
|
||||
|
||||
### Setting Up Webhooks
|
||||
|
||||
**Option 1: Admin Dashboard (Recommended)**
|
||||
|
||||
1. Access `http://your-server:3000`
|
||||
2. Log in with `ADMIN_PASSWORD`
|
||||
3. Click "Enable" on repositories to auto-configure webhooks
|
||||
|
||||
**Option 2: Manual Configuration**
|
||||
|
||||
In Gitea repository settings, add a webhook:
|
||||
- **URL**: `http://your-server:3000/webhook/gitea`
|
||||
- **Content Type**: `application/json`
|
||||
- **Secret**: Same as `WEBHOOK_SECRET`
|
||||
- **Events**: "Pull Request" and "Status"
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### Core Settings
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `GITEA_API_URL` | Gitea API endpoint | Required |
|
||||
| `GITEA_ACCESS_TOKEN` | Token for code review (read + comment permissions) | Required |
|
||||
| `GITEA_ADMIN_TOKEN` | Token for webhook management (optional) | - |
|
||||
| `OPENAI_BASE_URL` | OpenAI API base URL | `https://api.openai.com/v1` |
|
||||
| `OPENAI_API_KEY` | OpenAI API key | Required |
|
||||
| `OPENAI_MODEL` | Model to use | `gpt-4o-mini` |
|
||||
| `PORT` | Server port | `3000` |
|
||||
| `WEBHOOK_SECRET` | Webhook signature secret | Required |
|
||||
|
||||
### Custom Prompts
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CUSTOM_SUMMARY_PROMPT` | Override the default summary review prompt |
|
||||
| `CUSTOM_LINE_COMMENT_PROMPT` | Override the default line comment prompt |
|
||||
|
||||
### Admin Dashboard
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `ADMIN_PASSWORD` | Dashboard login password | `password` |
|
||||
| `JWT_SECRET` | JWT signing secret | Auto-generated |
|
||||
|
||||
### Feishu Integration
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `FEISHU_WEBHOOK_URL` | Feishu bot webhook URL |
|
||||
| `FEISHU_WEBHOOK_SECRET` | Feishu webhook secret (optional) |
|
||||
|
||||
### Agent Review Engine
|
||||
|
||||
Enable with `REVIEW_ENGINE=agent` for advanced multi-agent reviews:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `REVIEW_ENGINE` | Engine mode (`legacy` or `agent`) | `legacy` |
|
||||
| `REVIEW_WORKDIR` | Working directory for repo clones | `/tmp/gitea-assistant` |
|
||||
| `REVIEW_MODEL_PLANNER` | Planner model | `gpt-4o-mini` |
|
||||
| `REVIEW_MODEL_SPECIALIST` | Specialist model | `gpt-4o-mini` |
|
||||
| `REVIEW_MODEL_JUDGE` | Judge model | `gpt-4o-mini` |
|
||||
| `REVIEW_MAX_PARALLEL_RUNS` | Max concurrent tasks | `2` |
|
||||
| `REVIEW_MAX_FILES_PER_RUN` | Max files per review | `200` |
|
||||
| `REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE` | Min confidence for auto-publish | `0.8` |
|
||||
| `REVIEW_ENABLE_HUMAN_GATE` | Enable human approval | `true` |
|
||||
|
||||
### Memory & Learning (Experimental)
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `QDRANT_URL` | Qdrant vector database URL | - |
|
||||
| `ENABLE_MEMORY` | Enable memory system | `false` |
|
||||
| `ENABLE_REFLECTION` | Enable self-critique | `false` |
|
||||
| `ENABLE_DEBATE` | Enable multi-agent debate | `false` |
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker build -t gitea-assistant .
|
||||
docker run -d -p 3000:3000 --env-file .env gitea-assistant
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
46
auto-ver.sh
46
auto-ver.sh
@@ -1,46 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# 生成基于日期的版本号函数
|
||||
generate_date_version() {
|
||||
date +"%Y.%-m.%-d"
|
||||
}
|
||||
|
||||
# 由于可能允许在detached状态下执行脚本,因此我们允许外部指定HEAD的REF
|
||||
HEAD_REF_NAME=$1
|
||||
if [ -z "$HEAD_REF_NAME" ]; then
|
||||
HEAD_REF_NAME=$(git rev-parse --abbrev-ref HEAD)
|
||||
fi
|
||||
if [ "$HEAD_REF_NAME" = "main" ]; then
|
||||
# 对于main分支,使用日期格式的版本号
|
||||
generate_date_version
|
||||
exit 0
|
||||
fi
|
||||
TAG_RECENT=$(git describe --tags --abbrev=0 --match "v*" 2>/dev/null)
|
||||
if [ -z "$TAG_RECENT" ]; then
|
||||
# 无法获取到符合semver格式的tag时,使用日期格式的版本号
|
||||
generate_date_version
|
||||
exit 0
|
||||
fi
|
||||
TAG_COMMIT=$(git log $TAG_RECENT --oneline -1 | awk '{print $1}')
|
||||
TAG_VERSION=$(echo $TAG_RECENT | tr -d "v")
|
||||
|
||||
sub_version_by_type()
|
||||
{
|
||||
type=$1
|
||||
field=3
|
||||
if [ "$type" = "major" ]; then
|
||||
field=1
|
||||
elif [ "$type" = "minor" ]; then
|
||||
field=2
|
||||
else
|
||||
field=3
|
||||
fi
|
||||
echo $TAG_VERSION | awk -F '.' -v field=$field '{print $field}'
|
||||
}
|
||||
|
||||
MAJOR=$(sub_version_by_type major)
|
||||
MINOR=$(sub_version_by_type minor)
|
||||
PATCH=$(sub_version_by_type patch)
|
||||
REVISION_TO_HEAD=$(git rev-list --count $TAG_COMMIT...HEAD)
|
||||
|
||||
echo "$MAJOR.$MINOR.$(($PATCH+$REVISION_TO_HEAD))"
|
||||
50
biome.json
Normal file
50
biome.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"noUnusedImports": "warn",
|
||||
"noUnusedVariables": "warn"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "off",
|
||||
"useImportType": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noExportsInTest": "off",
|
||||
"noImplicitAnyLet": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"semicolons": "always",
|
||||
"trailingCommas": "es5"
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"frontend",
|
||||
"*.json",
|
||||
"*.md"
|
||||
]
|
||||
}
|
||||
}
|
||||
133
bun.lock
133
bun.lock
@@ -5,29 +5,50 @@
|
||||
"name": "ai-review",
|
||||
"dependencies": {
|
||||
"@hono/zod-validator": "^0.4.3",
|
||||
"@qdrant/js-client-rest": "^1.16.2",
|
||||
"axios": "^1.8.3",
|
||||
"dotenv": "^16.4.7",
|
||||
"hono": "^4.7.4",
|
||||
"hono": "^4.11.9",
|
||||
"lodash-es": "^4.17.21",
|
||||
"openai": "^4.87.3",
|
||||
"zod": "^3.24.2",
|
||||
"zod": "^3.25.1",
|
||||
"zod-to-json-schema": "^3.25.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^22.13.10",
|
||||
"tslint": "^6.1.3",
|
||||
"concurrently": "^9.2.1",
|
||||
"typescript": "^5.8.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/code-frame@7.26.2", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ=="],
|
||||
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="],
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
|
||||
|
||||
"@hono/zod-validator": ["@hono/zod-validator@0.4.3", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-xIgMYXDyJ4Hj6ekm9T9Y27s080Nl9NXHcJkOvkXPhubOLj8hZkOL8pDnnXfvCf5xEE8Q4oMFenQUZZREUY2gqQ=="],
|
||||
|
||||
"@types/lodash": ["@types/lodash@4.17.16", "", {}, "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g=="],
|
||||
"@qdrant/js-client-rest": ["@qdrant/js-client-rest@1.16.2", "", { "dependencies": { "@qdrant/openapi-typescript-fetch": "1.2.6", "undici": "^6.0.0" }, "peerDependencies": { "typescript": ">=4.7" } }, "sha512-Zm4wEZURrZ24a+Hmm4l1QQYjiz975Ep3vF0yzWR7ICGcxittNz47YK2iBOk8kb8qseCu8pg7WmO1HOIsO8alvw=="],
|
||||
|
||||
"@qdrant/openapi-typescript-fetch": ["@qdrant/openapi-typescript-fetch@1.2.6", "", {}, "sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA=="],
|
||||
|
||||
"@types/lodash": ["@types/lodash@4.17.23", "", {}, "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA=="],
|
||||
|
||||
"@types/lodash-es": ["@types/lodash-es@4.17.12", "", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="],
|
||||
|
||||
@@ -39,42 +60,36 @@
|
||||
|
||||
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"axios": ["axios@1.8.3", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } }, "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
|
||||
|
||||
"builtin-modules": ["builtin-modules@1.1.1", "", {}, "sha512-wxXCdllwGhI2kCC0MnvTGYTMvnVZTvqgypkiTI8Pa5tcz2i6VqsqwYGgqwXji+4RgCzms6EajE4IxiUH6HH8nQ=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
"concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"diff": ["diff@4.0.2", "", {}, "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="],
|
||||
|
||||
"dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
@@ -83,9 +98,7 @@
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
||||
|
||||
@@ -97,19 +110,17 @@
|
||||
|
||||
"formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
|
||||
|
||||
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
@@ -117,21 +128,13 @@
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hono": ["hono@4.7.4", "", {}, "sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg=="],
|
||||
"hono": ["hono@4.11.9", "", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
|
||||
|
||||
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
|
||||
|
||||
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="],
|
||||
|
||||
"lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="],
|
||||
"lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
@@ -139,50 +142,38 @@
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||
|
||||
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"openai": ["openai@4.87.3", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-d2D54fzMuBYTxMW8wcNmhT1rYKcTfMJ8t+4KjH2KtvYenygITiGBgHoIrzHwnDQWW+C5oCA+ikIR2jgPCFqcKQ=="],
|
||||
|
||||
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
||||
|
||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
|
||||
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||
|
||||
"supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="],
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
|
||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
|
||||
"tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
|
||||
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
|
||||
|
||||
"tslint": ["tslint@6.1.3", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "builtin-modules": "^1.1.1", "chalk": "^2.3.0", "commander": "^2.12.1", "diff": "^4.0.1", "glob": "^7.1.1", "js-yaml": "^3.13.1", "minimatch": "^3.0.4", "mkdirp": "^0.5.3", "resolve": "^1.3.2", "semver": "^5.3.0", "tslib": "^1.13.0", "tsutils": "^2.29.0" }, "peerDependencies": { "typescript": ">=2.3.0-dev || >=2.4.0-dev || >=2.5.0-dev || >=2.6.0-dev || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >=3.0.0-dev || >= 3.1.0-dev || >= 3.2.0-dev || >= 4.0.0-dev" }, "bin": { "tslint": "bin/tslint" } }, "sha512-IbR4nkT96EQOvKE2PW/djGz8iGNeJ4rF2mBfiYaR/nvUWYKJhLwimoJKgjIFEIDibBtOevj7BqCRL4oHeWWUCg=="],
|
||||
|
||||
"tsutils": ["tsutils@2.29.0", "", { "dependencies": { "tslib": "^1.8.1" }, "peerDependencies": { "typescript": ">=2.1.0 || >=2.1.0-dev || >=2.2.0-dev || >=2.3.0-dev || >=2.4.0-dev || >=2.5.0-dev || >=2.6.0-dev || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >= 3.0.0-dev || >= 3.1.0-dev" } }, "sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA=="],
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="],
|
||||
|
||||
"undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
|
||||
|
||||
"undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
|
||||
@@ -191,12 +182,22 @@
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||
|
||||
"@types/node-fetch/@types/node": ["@types/node@18.19.80", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-kEWeMwMeIvxYkeg1gTc01awpwLbfMRZXdIhwRcakd/KlK53jmRC26LqcbIt7fnAQTu5GzlnWmzA3H6+l1u6xxQ=="],
|
||||
|
||||
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"openai/@types/node": ["@types/node@18.19.80", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-kEWeMwMeIvxYkeg1gTc01awpwLbfMRZXdIhwRcakd/KlK53jmRC26LqcbIt7fnAQTu5GzlnWmzA3H6+l1u6xxQ=="],
|
||||
|
||||
"@types/node-fetch/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
71
docker-compose.e2e.yml
Normal file
71
docker-compose.e2e.yml
Normal file
@@ -0,0 +1,71 @@
|
||||
version: '3.8'
|
||||
|
||||
# E2E 测试环境:Gitea + gitea-assistant
|
||||
# 用法:
|
||||
# docker compose -f docker-compose.e2e.yml up -d
|
||||
# # 等待服务启动后运行 seed 脚本:
|
||||
# ./e2e/seed.sh
|
||||
# # 运行 E2E 测试:
|
||||
# ./e2e/test.sh
|
||||
# # 清理:
|
||||
# docker compose -f docker-compose.e2e.yml down -v
|
||||
|
||||
services:
|
||||
gitea:
|
||||
image: gitea/gitea:1.22
|
||||
container_name: e2e-gitea
|
||||
environment:
|
||||
- GITEA__database__DB_TYPE=sqlite3
|
||||
- GITEA__server__ROOT_URL=http://gitea:3000
|
||||
- GITEA__server__HTTP_PORT=3000
|
||||
- GITEA__security__INSTALL_LOCK=true
|
||||
- GITEA__service__DISABLE_REGISTRATION=false
|
||||
- GITEA__service__REQUIRE_SIGNIN_VIEW=false
|
||||
- GITEA__webhook__ALLOWED_HOST_LIST=*
|
||||
- GITEA__webhook__SKIP_TLS_VERIFY=true
|
||||
ports:
|
||||
- "3333:3000"
|
||||
volumes:
|
||||
- gitea-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/v1/version"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
|
||||
assistant:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: e2e/Dockerfile.e2e
|
||||
container_name: e2e-assistant
|
||||
depends_on:
|
||||
gitea:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- GITEA_API_URL=http://gitea:3000/api/v1
|
||||
- GITEA_ACCESS_TOKEN=${E2E_GITEA_TOKEN:-placeholder}
|
||||
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-https://api.openai.com/v1}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-test_key}
|
||||
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o-mini}
|
||||
- FEISHU_WEBHOOK_URL=http://localhost:9999/noop
|
||||
- PORT=3000
|
||||
- WEBHOOK_SECRET=e2e-test-secret
|
||||
- REVIEW_ENGINE=agent
|
||||
- REVIEW_WORKDIR=/tmp/e2e-review
|
||||
- REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE=0.5
|
||||
- REVIEW_ENABLE_HUMAN_GATE=false
|
||||
- REVIEW_ALLOWED_COMMANDS=git,rg,cat,sed,wc
|
||||
- REVIEW_COMMAND_TIMEOUT_MS=30000
|
||||
ports:
|
||||
- "3334:3000"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 5s
|
||||
|
||||
volumes:
|
||||
gitea-data:
|
||||
@@ -10,6 +10,8 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./config-overrides.json:/app/config-overrides.json
|
||||
env_file:
|
||||
- .env
|
||||
healthcheck:
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
# Gitea AI Assistant 后台管理页面设计方案
|
||||
|
||||
## 1. 总体目标
|
||||
|
||||
构建一个简单、安全的后台管理界面,允许管理员浏览 Gitea 上的代码仓库,并为选定的仓库一键“开启”或“关闭” AI 代码审查的 Webhook。此功能旨在替代当前繁琐的手动配置过程,简化新项目的接入流程。
|
||||
|
||||
## 2. 技术选型
|
||||
|
||||
- **后端增强**: 继续使用 **Hono** 框架,在现有应用中开辟一组新的 API 路由。
|
||||
- **前端框架**: **React (Vite + TypeScript)**,用于快速构建现代化交互界面。
|
||||
- **UI 组件库**: **shadcn/ui** 配合 **Tailwind CSS**,以实现专业且美观的界面。
|
||||
- **状态管理**: **React Query (TanStack Query)**,用于高效管理服务器状态和数据缓存。
|
||||
- **认证机制**: **JSON Web Tokens (JWT)**,实现安全无状态的登录认证。
|
||||
|
||||
## 3. 架构设计
|
||||
|
||||
采用前后端分离的架构。
|
||||
|
||||
- **后端 (Backend)**:
|
||||
- 在 Hono 应用中新增路由组 `/admin/api`,提供 RESTful API。
|
||||
- 通过一个新的管理员级别 Gitea Token 与 Gitea API 交互,该 Token 需具备读写仓库和 Webhook 的权限。
|
||||
- 所有 `/admin/api` 路由都将受到 JWT 中间件的保护。
|
||||
|
||||
- **前端 (Frontend)**:
|
||||
- 在项目根目录创建新的 `frontend` 文件夹,存放所有前端代码。
|
||||
- 前端为单页面应用 (SPA),负责登录、展示仓库列表和提供 Webhook 管理操作。
|
||||
|
||||
- **部署 (Deployment)**:
|
||||
- 采用多阶段 `Dockerfile` 进行构建。
|
||||
- 第一阶段构建前端静态文件。
|
||||
- 第二阶段构建后端服务。
|
||||
- 最终镜像将前端静态文件集成到后端服务中,由 Hono 统一提供服务,实现单容器部署。
|
||||
- 相应更新 `kubernetes.yaml.template` 文件。
|
||||
|
||||
## 4. 核心功能模块设计
|
||||
|
||||
### A. 认证流程
|
||||
|
||||
1. **访问**: 用户访问管理页面,若无本地有效 JWT,则跳转至登录页。
|
||||
2. **登录**: 用户输入在环境变量中配置的 `ADMIN_PASSWORD`。
|
||||
3. **验证**: 前端调用 `POST /admin/api/login`,后端验证密码。
|
||||
4. **Token 生成**: 验证成功后,后端使用 `JWT_SECRET` 生成一个有时效性的 JWT 并返回给前端。
|
||||
5. **存储**: 前端将 JWT 存储在 `localStorage` 中。
|
||||
6. **请求**: 后续所有对 `/admin/api` 的请求均在 `Authorization` 请求头中携带 `Bearer <token>`。
|
||||
|
||||
### B. 后端 API 设计 (`/admin/api`)
|
||||
|
||||
- **`POST /admin/api/login`** (公开)
|
||||
- **功能**: 用户登录。
|
||||
- **请求体**: `{ password: "..." }`
|
||||
- **响应**: `{ token: "jwt_token" }` 或认证失败错误。
|
||||
|
||||
- **`GET /admin/api/repositories`** (需认证)
|
||||
- **功能**: 获取 Gitea 实例上管理员可见的所有仓库列表,并附带其 Webhook 状态。
|
||||
- **逻辑**: 调用 Gitea API 获取仓库列表,并对每个仓库检查是否存在由本应用创建的 Webhook。
|
||||
- **返回**: `[{ name: "owner/repo", webhook_status: "active" | "inactive" }]`
|
||||
|
||||
- **`POST /admin/api/repositories/{owner}/{repo}/webhook`** (需认证)
|
||||
- **功能**: 为指定仓库创建 AI Review 的 Webhook。
|
||||
- **逻辑**: 调用 Gitea API 创建 Webhook,目标 URL 指向本服务的 `/api/webhook`。
|
||||
- **返回**: `{ success: true }`
|
||||
|
||||
- **`DELETE /admin/api/repositories/{owner}/{repo}/webhook`** (需认证)
|
||||
- **功能**: 删除为 AI Review 创建的 Webhook。
|
||||
- **逻辑**: 调用 Gitea API 查找并删除与本服务相关的 Webhook。
|
||||
- **返回**: `{ success: true }`
|
||||
|
||||
### C. 前端页面设计
|
||||
|
||||
- **登录页**: 一个居中的表单,包含密码输入框和“登录”按钮。
|
||||
- **管理主页 (Dashboard)**:
|
||||
- 顶部标题和刷新按钮。
|
||||
- 仓库列表,支持按名称搜索/筛选。
|
||||
- 列表项包括:
|
||||
- 仓库名称 (`owner/repo`)。
|
||||
- Webhook 状态标识 (例如,彩色的图标和文字)。
|
||||
- 操作按钮 (例如,“启用”或“停用”)。
|
||||
|
||||
## 5. 环境变量配置
|
||||
|
||||
需要新增以下环境变量:
|
||||
|
||||
- `ADMIN_PASSWORD`: 后台管理页面的登录密码。
|
||||
- `JWT_SECRET`: 用于签发和验证 JWT 的密钥。
|
||||
- `GITEA_ADMIN_TOKEN`: 一个拥有 Gitea 管理权限的 Token,用于 API 调用。
|
||||
|
||||
## 6. 实施步骤规划
|
||||
|
||||
1. **项目初始化**: 创建 `frontend` 目录并初始化 Vite (React+TS) 项目,配置 UI 库。
|
||||
2. **后端开发**: 实现 `/admin/api` 路由组,包括登录、JWT 中间件和 Webhook 管理逻辑。
|
||||
3. **前端开发**: 创建登录页和管理主页,实现与后端 API 的交互。
|
||||
4. **容器化**: 更新 `Dockerfile` 为多阶段构建,并调整 `kubernetes.yaml.template`。
|
||||
5. **文档更新**: 在 `README.md` 中说明新功能的使用和配置方法。
|
||||
180
docs/README.zh-CN.md
Normal file
180
docs/README.zh-CN.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Gitea AI Assistant
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
基于 AI 的 Gitea 代码审查助手。自动审查 Pull Request 和提交,使用 OpenAI 提供智能代码质量分析,支持总体评论和行级反馈。
|
||||
|
||||
**[English Documentation](../README.md)**
|
||||
|
||||
## 功能特点
|
||||
|
||||
- 🤖 **AI 代码审查** - 使用 OpenAI 模型自动审查 PR 和提交
|
||||
- 📝 **行级评论** - 针对具体代码变更的精确反馈
|
||||
- 🔄 **双引擎模式** - Legacy(简单)或 Agent(多代理)审查模式
|
||||
- 🔔 **飞书通知** - PR 事件通知集成
|
||||
- 🎛️ **管理后台** - 用于管理仓库 Webhook 和配置的 Web 界面
|
||||
- 🔐 **安全验证** - HMAC-SHA256 签名验证
|
||||
|
||||
## 架构设计
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Gitea 服务器 │────▶│ Gitea Assistant │────▶│ OpenAI API │
|
||||
│ (Webhooks) │ │ (Hono + Bun) │ │ │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ 管理后台 │
|
||||
│ (React SPA) │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
### 审查引擎对比
|
||||
|
||||
| 引擎 | 描述 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| `legacy` | 单次 AI 审查,生成总结和行级评论 | 简单、快速的审查 |
|
||||
| `agent` | 多代理编排,支持专家、反思和辩论 | 深度、全面的分析 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- [Bun](https://bun.sh/) >= 1.2.5
|
||||
- 可访问的 Gitea 实例
|
||||
- OpenAI API 密钥
|
||||
|
||||
### 安装步骤
|
||||
|
||||
```bash
|
||||
git clone https://github.com/user/gitea-ai-assistant.git
|
||||
cd gitea-ai-assistant
|
||||
bun install
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### 配置说明
|
||||
|
||||
编辑 `.env` 文件:
|
||||
|
||||
```bash
|
||||
# Gitea
|
||||
GITEA_API_URL=https://your-gitea-instance.com/api/v1
|
||||
GITEA_ACCESS_TOKEN=your_gitea_token
|
||||
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=your_openai_key
|
||||
OPENAI_MODEL=gpt-4o-mini
|
||||
|
||||
# 安全
|
||||
WEBHOOK_SECRET=your_webhook_secret # openssl rand -hex 32
|
||||
|
||||
# 管理后台
|
||||
ADMIN_PASSWORD=your_admin_password
|
||||
```
|
||||
|
||||
完整配置项请参阅 [配置参考](#配置参考)。
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
bun run dev # 开发模式
|
||||
bun run start # 生产模式
|
||||
```
|
||||
|
||||
### 配置 Webhook
|
||||
|
||||
**方式一:管理后台(推荐)**
|
||||
|
||||
1. 在浏览器中访问 `http://your-server:3000`
|
||||
2. 使用 `ADMIN_PASSWORD` 登录
|
||||
3. 点击仓库对应的「启用」按钮自动配置 Webhook
|
||||
|
||||
**方式二:手动配置**
|
||||
|
||||
在 Gitea 仓库设置中添加 Webhook:
|
||||
- **URL**: `http://your-server:3000/webhook/gitea`
|
||||
- **内容类型**: `application/json`
|
||||
- **密钥**: 与 `WEBHOOK_SECRET` 相同
|
||||
- **触发事件**: 「Pull Request」和「Status」
|
||||
|
||||
## 配置参考
|
||||
|
||||
### 核心配置
|
||||
|
||||
| 变量 | 描述 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `GITEA_API_URL` | Gitea API 地址 | 必填 |
|
||||
| `GITEA_ACCESS_TOKEN` | 代码审查令牌(需要读取和评论权限) | 必填 |
|
||||
| `GITEA_ADMIN_TOKEN` | Webhook 管理令牌(可选) | - |
|
||||
| `OPENAI_BASE_URL` | OpenAI API 基础地址 | `https://api.openai.com/v1` |
|
||||
| `OPENAI_API_KEY` | OpenAI API 密钥 | 必填 |
|
||||
| `OPENAI_MODEL` | 使用的模型 | `gpt-4o-mini` |
|
||||
| `PORT` | 服务端口 | `3000` |
|
||||
| `WEBHOOK_SECRET` | Webhook 签名验证密钥 | 必填 |
|
||||
|
||||
### 自定义提示词
|
||||
|
||||
| 变量 | 描述 |
|
||||
|------|------|
|
||||
| `CUSTOM_SUMMARY_PROMPT` | 自定义总结审查提示词 |
|
||||
| `CUSTOM_LINE_COMMENT_PROMPT` | 自定义行级评论提示词 |
|
||||
|
||||
### 管理后台
|
||||
|
||||
| 变量 | 描述 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `ADMIN_PASSWORD` | 后台登录密码 | `password` |
|
||||
| `JWT_SECRET` | JWT 签名密钥 | 自动生成 |
|
||||
|
||||
### 飞书集成
|
||||
|
||||
| 变量 | 描述 |
|
||||
|------|------|
|
||||
| `FEISHU_WEBHOOK_URL` | 飞书机器人 Webhook 地址 |
|
||||
| `FEISHU_WEBHOOK_SECRET` | 飞书 Webhook 密钥(可选) |
|
||||
|
||||
### Agent 审查引擎
|
||||
|
||||
设置 `REVIEW_ENGINE=agent` 启用多代理审查:
|
||||
|
||||
| 变量 | 描述 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `REVIEW_ENGINE` | 引擎模式(`legacy` 或 `agent`) | `legacy` |
|
||||
| `REVIEW_WORKDIR` | 仓库克隆工作目录 | `/tmp/gitea-assistant` |
|
||||
| `REVIEW_MODEL_PLANNER` | 规划模型 | `gpt-4o-mini` |
|
||||
| `REVIEW_MODEL_SPECIALIST` | 专家模型 | `gpt-4o-mini` |
|
||||
| `REVIEW_MODEL_JUDGE` | 判断模型 | `gpt-4o-mini` |
|
||||
| `REVIEW_MAX_PARALLEL_RUNS` | 最大并发任务数 | `2` |
|
||||
| `REVIEW_MAX_FILES_PER_RUN` | 单次审查最大文件数 | `200` |
|
||||
| `REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE` | 自动发布最小置信度 | `0.8` |
|
||||
| `REVIEW_ENABLE_HUMAN_GATE` | 启用人工审批 | `true` |
|
||||
|
||||
### 记忆与学习(实验性)
|
||||
|
||||
| 变量 | 描述 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `QDRANT_URL` | Qdrant 向量数据库地址 | - |
|
||||
| `ENABLE_MEMORY` | 启用记忆系统 | `false` |
|
||||
| `ENABLE_REFLECTION` | 启用自我批评 | `false` |
|
||||
| `ENABLE_DEBATE` | 启用多代理辩论 | `false` |
|
||||
|
||||
## 部署指南
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker build -t gitea-assistant .
|
||||
docker run -d -p 3000:3000 --env-file .env gitea-assistant
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT 许可证
|
||||
16
e2e/Dockerfile.e2e
Normal file
16
e2e/Dockerfile.e2e
Normal file
@@ -0,0 +1,16 @@
|
||||
# E2E 测试用简化 Dockerfile(跳过 frontend 构建)
|
||||
FROM oven/bun:1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y git ripgrep curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json bun.lock* bun.lockb* ./
|
||||
RUN bun install --no-frozen-lockfile
|
||||
|
||||
COPY src ./src
|
||||
COPY tsconfig.json .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["bun", "run", "start"]
|
||||
175
e2e/seed.sh
Executable file
175
e2e/seed.sh
Executable file
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# E2E Seed Script
|
||||
# 初始化 Gitea 测试环境:创建用户、生成 Token、创建仓库、推送代码、配置 Webhook、创建 PR
|
||||
#
|
||||
# 前置条件: docker compose -f docker-compose.e2e.yml up -d && 等待 healthy
|
||||
# 产出: 写入 e2e/.env.e2e 供 test.sh 使用
|
||||
|
||||
GITEA_URL="http://localhost:3333"
|
||||
ASSISTANT_URL="http://localhost:3334"
|
||||
ADMIN_USER="e2e-admin"
|
||||
ADMIN_PASS="e2ePassword123!"
|
||||
ADMIN_EMAIL="admin@e2e-test.local"
|
||||
WEBHOOK_SECRET="e2e-test-secret"
|
||||
REPO_NAME="e2e-test-repo"
|
||||
|
||||
echo "=== [1/6] 等待 Gitea 就绪 ==="
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "${GITEA_URL}/api/v1/version" > /dev/null 2>&1; then
|
||||
echo "Gitea 已就绪"
|
||||
break
|
||||
fi
|
||||
echo " 等待中... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "=== [2/6] 创建管理员用户 ==="
|
||||
docker exec e2e-gitea gitea admin user create \
|
||||
--username "${ADMIN_USER}" \
|
||||
--password "${ADMIN_PASS}" \
|
||||
--email "${ADMIN_EMAIL}" \
|
||||
--admin \
|
||||
--must-change-password=false 2>/dev/null || echo " 用户已存在,跳过"
|
||||
|
||||
echo "=== [3/6] 生成 API Token ==="
|
||||
TOKEN_RESPONSE=$(curl -sf -X POST "${GITEA_URL}/api/v1/users/${ADMIN_USER}/tokens" \
|
||||
-u "${ADMIN_USER}:${ADMIN_PASS}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"name\": \"e2e-token-$(date +%s)\", \"scopes\": [\"all\"]}" 2>/dev/null || true)
|
||||
|
||||
if [ -z "${TOKEN_RESPONSE}" ]; then
|
||||
echo " Token 创建失败,尝试使用密码认证"
|
||||
GITEA_TOKEN=""
|
||||
else
|
||||
GITEA_TOKEN=$(echo "${TOKEN_RESPONSE}" | grep -o '"sha1":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
if [ -z "${GITEA_TOKEN}" ]; then
|
||||
GITEA_TOKEN=$(echo "${TOKEN_RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha1',''))" 2>/dev/null || true)
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "${GITEA_TOKEN}" ]; then
|
||||
echo " ERROR: 无法获取 Token"
|
||||
exit 1
|
||||
fi
|
||||
echo " Token: ${GITEA_TOKEN:0:8}..."
|
||||
|
||||
echo "=== [4/6] 创建测试仓库并推送代码 ==="
|
||||
curl -sf -X POST "${GITEA_URL}/api/v1/user/repos" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"name\": \"${REPO_NAME}\", \"auto_init\": true, \"default_branch\": \"main\"}" > /dev/null 2>&1 || echo " 仓库已存在,跳过"
|
||||
|
||||
CLONE_DIR=$(mktemp -d)
|
||||
trap "rm -rf ${CLONE_DIR}" EXIT
|
||||
|
||||
git clone "http://${ADMIN_USER}:${ADMIN_PASS}@localhost:3333/${ADMIN_USER}/${REPO_NAME}.git" "${CLONE_DIR}/repo" 2>/dev/null
|
||||
|
||||
pushd "${CLONE_DIR}/repo" > /dev/null
|
||||
git config user.email "e2e@test.local"
|
||||
git config user.name "E2E Bot"
|
||||
|
||||
mkdir -p src
|
||||
cat > src/auth.ts << 'TSEOF'
|
||||
export function authenticate(token: string): boolean {
|
||||
// 正确的认证实现
|
||||
if (!token || token.length < 10) {
|
||||
return false;
|
||||
}
|
||||
return verifyToken(token);
|
||||
}
|
||||
|
||||
function verifyToken(token: string): boolean {
|
||||
return token.startsWith('valid-');
|
||||
}
|
||||
TSEOF
|
||||
|
||||
git add -A
|
||||
git commit -m "initial: add auth module" --allow-empty 2>/dev/null || true
|
||||
git push origin main 2>/dev/null || true
|
||||
|
||||
git checkout -b feature/add-user-handler
|
||||
cat > src/user-handler.ts << 'TSEOF'
|
||||
import { authenticate } from './auth';
|
||||
|
||||
export function handleUserRequest(input: any) {
|
||||
// Bug: 没有对 input 做 null 检查
|
||||
const userId = input.userId;
|
||||
|
||||
// Bug: SQL 注入风险
|
||||
const query = `SELECT * FROM users WHERE id = '${userId}'`;
|
||||
|
||||
// Bug: 硬编码密钥
|
||||
const secret = "super-secret-api-key-12345";
|
||||
|
||||
// Bug: 不安全的 eval
|
||||
const config = eval(input.config);
|
||||
|
||||
return { query, config };
|
||||
}
|
||||
TSEOF
|
||||
|
||||
git add -A
|
||||
git commit -m "feat: add user handler"
|
||||
git push origin feature/add-user-handler 2>/dev/null
|
||||
popd > /dev/null
|
||||
|
||||
echo "=== [5/6] 配置 Webhook ==="
|
||||
curl -sf -X POST "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/hooks" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"type\": \"gitea\",
|
||||
\"active\": true,
|
||||
\"events\": [\"pull_request\"],
|
||||
\"config\": {
|
||||
\"url\": \"http://assistant:3000/webhook/gitea\",
|
||||
\"content_type\": \"json\",
|
||||
\"secret\": \"${WEBHOOK_SECRET}\"
|
||||
}
|
||||
}" > /dev/null 2>&1 || echo " Webhook 配置失败(可能已存在)"
|
||||
|
||||
echo "=== [6/6] 创建 Pull Request ==="
|
||||
PR_RESPONSE=$(curl -sf -X POST "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/pulls" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"title\": \"feat: add user handler\",
|
||||
\"body\": \"Add user request handler with authentication\",
|
||||
\"head\": \"feature/add-user-handler\",
|
||||
\"base\": \"main\"
|
||||
}" 2>/dev/null || true)
|
||||
|
||||
PR_NUMBER=$(echo "${PR_RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('number',''))" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "${PR_NUMBER}" ]; then
|
||||
echo " PR 创建失败或已存在,尝试查找现有 PR..."
|
||||
PR_NUMBER=$(curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/pulls?state=open" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" | \
|
||||
python3 -c "import sys,json; prs=json.load(sys.stdin); print(prs[0]['number'] if prs else '')" 2>/dev/null || echo "1")
|
||||
fi
|
||||
|
||||
echo " PR #${PR_NUMBER} 已创建"
|
||||
|
||||
cat > e2e/.env.e2e << EOF
|
||||
GITEA_URL=${GITEA_URL}
|
||||
ASSISTANT_URL=${ASSISTANT_URL}
|
||||
GITEA_TOKEN=${GITEA_TOKEN}
|
||||
ADMIN_USER=${ADMIN_USER}
|
||||
REPO_NAME=${REPO_NAME}
|
||||
PR_NUMBER=${PR_NUMBER}
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "=== Seed 完成 ==="
|
||||
echo " Gitea: ${GITEA_URL}"
|
||||
echo " Assistant: ${ASSISTANT_URL}"
|
||||
echo " Repo: ${ADMIN_USER}/${REPO_NAME}"
|
||||
echo " PR: #${PR_NUMBER}"
|
||||
echo " Token: ${GITEA_TOKEN:0:8}..."
|
||||
echo ""
|
||||
echo "下一步:"
|
||||
echo " 1. 更新 assistant 容器的 GITEA_ACCESS_TOKEN:"
|
||||
echo " E2E_GITEA_TOKEN=${GITEA_TOKEN} docker compose -f docker-compose.e2e.yml up -d assistant"
|
||||
echo " 2. 运行测试: ./e2e/test.sh"
|
||||
150
e2e/test.sh
Executable file
150
e2e/test.sh
Executable file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# E2E Test Script
|
||||
# 验证 AI 代码审查是否在 PR 上产生了评论
|
||||
#
|
||||
# 前置条件:
|
||||
# 1. docker compose -f docker-compose.e2e.yml up -d
|
||||
# 2. ./e2e/seed.sh
|
||||
# 3. E2E_GITEA_TOKEN=xxx docker compose -f docker-compose.e2e.yml up -d assistant
|
||||
|
||||
ENV_FILE="e2e/.env.e2e"
|
||||
if [ ! -f "${ENV_FILE}" ]; then
|
||||
echo "ERROR: ${ENV_FILE} 不存在,请先运行 ./e2e/seed.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "${ENV_FILE}"
|
||||
|
||||
MAX_WAIT=180 # 最多等待 3 分钟
|
||||
POLL_INTERVAL=5
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
echo "=== E2E 测试开始 ==="
|
||||
echo " Gitea: ${GITEA_URL}"
|
||||
echo " Repo: ${ADMIN_USER}/${REPO_NAME}"
|
||||
echo " PR: #${PR_NUMBER}"
|
||||
echo ""
|
||||
|
||||
# ─── 测试 1: Assistant 服务健康检查 ───
|
||||
echo "[TEST 1] Assistant 服务健康检查"
|
||||
if curl -sf "${ASSISTANT_URL}/" > /dev/null 2>&1; then
|
||||
echo " ✅ PASS: Assistant 服务正常"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: Assistant 服务不可达"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
# ─── 测试 2: Gitea API 可用 ───
|
||||
echo "[TEST 2] Gitea API 可用性"
|
||||
VERSION=$(curl -sf "${GITEA_URL}/api/v1/version" | python3 -c "import sys,json; print(json.load(sys.stdin).get('version','unknown'))" 2>/dev/null || echo "unknown")
|
||||
if [ "${VERSION}" != "unknown" ]; then
|
||||
echo " ✅ PASS: Gitea v${VERSION}"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: Gitea API 不可用"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
# ─── 测试 3: PR 存在 ───
|
||||
echo "[TEST 3] PR 存在性"
|
||||
PR_STATE=$(curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/pulls/${PR_NUMBER}" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))" 2>/dev/null || echo "")
|
||||
|
||||
if [ "${PR_STATE}" = "open" ]; then
|
||||
echo " ✅ PASS: PR #${PR_NUMBER} 状态为 open"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: PR #${PR_NUMBER} 状态异常 (${PR_STATE})"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
# ─── 测试 4: 等待 AI 审查评论出现 ───
|
||||
echo "[TEST 4] AI 审查评论(最多等待 ${MAX_WAIT}s)"
|
||||
COMMENT_FOUND=false
|
||||
WAITED=0
|
||||
|
||||
while [ ${WAITED} -lt ${MAX_WAIT} ]; do
|
||||
COMMENTS=$(curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/issues/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null || echo "[]")
|
||||
|
||||
AI_COMMENTS=$(echo "${COMMENTS}" | python3 -c "
|
||||
import sys, json
|
||||
comments = json.load(sys.stdin)
|
||||
ai = [c for c in comments if 'AI' in c.get('body', '') or 'Agent' in c.get('body', '')]
|
||||
print(len(ai))
|
||||
" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "${AI_COMMENTS}" -gt "0" ]; then
|
||||
COMMENT_FOUND=true
|
||||
echo " ✅ PASS: 发现 ${AI_COMMENTS} 条 AI 审查评论 (${WAITED}s)"
|
||||
PASS=$((PASS + 1))
|
||||
break
|
||||
fi
|
||||
|
||||
echo " ⏳ 等待中... (${WAITED}/${MAX_WAIT}s, 已有评论: $(echo "${COMMENTS}" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))' 2>/dev/null || echo 0))"
|
||||
sleep ${POLL_INTERVAL}
|
||||
WAITED=$((WAITED + POLL_INTERVAL))
|
||||
done
|
||||
|
||||
if [ "${COMMENT_FOUND}" = false ]; then
|
||||
echo " ❌ FAIL: ${MAX_WAIT}s 内未发现 AI 审查评论"
|
||||
FAIL=$((FAIL + 1))
|
||||
|
||||
echo " --- 调试信息 ---"
|
||||
echo " PR 所有评论:"
|
||||
curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/issues/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null | python3 -m json.tool 2>/dev/null || echo " (无法获取)"
|
||||
|
||||
echo " Assistant review runs:"
|
||||
curl -sf "${ASSISTANT_URL}/admin/api/review/runs" 2>/dev/null | python3 -m json.tool 2>/dev/null || echo " (无法获取)"
|
||||
fi
|
||||
|
||||
# ─── 测试 5: Review Run 状态检查 ───
|
||||
echo "[TEST 5] Review Run 状态"
|
||||
ADMIN_JWT=$(curl -sf -X POST "${ASSISTANT_URL}/admin/api/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"password":"password"}' | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || echo "")
|
||||
RUNS=$(curl -sf "${ASSISTANT_URL}/admin/api/review/runs" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" 2>/dev/null || echo "[]")
|
||||
RUN_COUNT=$(echo "${RUNS}" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d.get('data',d if isinstance(d,list) else [])))" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "${RUN_COUNT}" -gt "0" ]; then
|
||||
echo " ✅ PASS: 发现 ${RUN_COUNT} 个 review run(s)"
|
||||
PASS=$((PASS + 1))
|
||||
|
||||
echo "${RUNS}" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
runs = data.get('data', data if isinstance(data, list) else data.get('runs', []))
|
||||
for r in runs[:3]:
|
||||
print(f\" - {r.get('id','?')[:8]}... status={r.get('status','?')} attempts={r.get('attempts','?')}\")
|
||||
" 2>/dev/null || true
|
||||
else
|
||||
echo " ❌ FAIL: 无 review runs"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
# ─── 结果汇总 ───
|
||||
echo ""
|
||||
echo "=== E2E 测试结果 ==="
|
||||
TOTAL=$((PASS + FAIL))
|
||||
echo " 通过: ${PASS}/${TOTAL}"
|
||||
echo " 失败: ${FAIL}/${TOTAL}"
|
||||
|
||||
if [ ${FAIL} -gt 0 ]; then
|
||||
echo ""
|
||||
echo "⚠️ 部分测试失败。如果 AI 评论测试失败,请确保:"
|
||||
echo " 1. OPENAI_API_KEY 已正确配置"
|
||||
echo " 2. assistant 容器的 GITEA_ACCESS_TOKEN 已设置为 seed 生成的 token"
|
||||
echo " 3. Webhook 已正确触发(检查 Gitea webhook 日志)"
|
||||
exit 1
|
||||
else
|
||||
echo ""
|
||||
echo "🎉 所有 E2E 测试通过!"
|
||||
exit 0
|
||||
fi
|
||||
@@ -1,69 +0,0 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -5,7 +5,11 @@
|
||||
"name": "frontend",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tanstack/react-query": "^5.90.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.12.2",
|
||||
@@ -15,6 +19,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
},
|
||||
@@ -150,6 +155,14 @@
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.5", "", { "dependencies": { "@eslint/core": "^0.15.2", "levn": "^0.4.1" } }, "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="],
|
||||
|
||||
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.7", "", { "dependencies": { "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||
@@ -178,14 +191,70 @@
|
||||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
|
||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||
|
||||
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
||||
|
||||
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
|
||||
|
||||
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
||||
|
||||
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
|
||||
|
||||
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||
|
||||
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
|
||||
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||
|
||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
|
||||
|
||||
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
|
||||
|
||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
|
||||
|
||||
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
||||
|
||||
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
||||
|
||||
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
|
||||
|
||||
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
|
||||
|
||||
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||
|
||||
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
|
||||
|
||||
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
|
||||
|
||||
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.35", "", {}, "sha512-slYrCpoxJUqzFDDNlvrOYRazQUNRvWPjXA17dAOISY3rDMxX6k8K4cj2H+hEYMHF81HO3uNd5rHVigAWRM5dSg=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.0", "", { "os": "android", "cpu": "arm" }, "sha512-VxDYCDqOaR7NXzAtvRx7G1u54d2kEHopb28YH/pKzY6y0qmogP3gG7CSiWsq9WvDFxOQMpNEyjVAHZFXfH3o/A=="],
|
||||
@@ -298,6 +367,8 @@
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="],
|
||||
@@ -344,6 +415,8 @@
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
@@ -358,6 +431,8 @@
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.0", "", {}, "sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg=="],
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||
|
||||
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||
@@ -442,6 +517,8 @@
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
|
||||
@@ -626,6 +703,16 @@
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
|
||||
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-router": ["react-router@7.13.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA=="],
|
||||
|
||||
"react-router-dom": ["react-router-dom@7.13.1", "", { "dependencies": { "react-router": "7.13.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
|
||||
|
||||
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
@@ -644,6 +731,8 @@
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
@@ -688,6 +777,8 @@
|
||||
|
||||
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.3.8", "", {}, "sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
@@ -702,6 +793,10 @@
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||
|
||||
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"vite": ["vite@7.1.7", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA=="],
|
||||
@@ -724,6 +819,8 @@
|
||||
|
||||
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||
|
||||
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
@@ -758,6 +855,8 @@
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"@radix-ui/react-separator/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tanstack/react-query": "^5.90.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.12.2",
|
||||
@@ -21,6 +25,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
|
||||
@@ -1,26 +1,56 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import { RepositoryManager } from './components/RepositoryManager';
|
||||
import { ConfigManager } from './components/ConfigManager';
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
|
||||
function App() {
|
||||
function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div>正在加载...</div>
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="relative flex h-12 w-12 items-center justify-center">
|
||||
<div className="absolute h-full w-full rounded-full border-b-2 border-primary animate-spin"></div>
|
||||
<div className="absolute h-8 w-8 rounded-full border-t-2 border-primary/50 animate-spin opacity-50" style={{ animationDirection: 'reverse', animationDuration: '1.5s' }}></div>
|
||||
<div className="h-2 w-2 rounded-full bg-primary animate-pulse"></div>
|
||||
</div>
|
||||
<div className="text-sm font-mono tracking-widest text-primary/80 animate-pulse">INITIALIZING...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Page = isAuthenticated ? DashboardPage : LoginPage;
|
||||
if (!isAuthenticated) {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<Page />
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<AuthGuard>
|
||||
<DashboardPage />
|
||||
</AuthGuard>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/repos" replace />} />
|
||||
<Route path="repos" element={<RepositoryManager />} />
|
||||
<Route path="config" element={<ConfigManager />} />
|
||||
<Route path="*" element={<Navigate to="/repos" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<Toaster theme="dark" />
|
||||
</>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
140
frontend/src/components/ConfigFieldInput.tsx
Normal file
140
frontend/src/components/ConfigFieldInput.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
|
||||
import type { ConfigFieldDto } from '@/services/configService';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Lock } from 'lucide-react';
|
||||
|
||||
interface ConfigFieldInputProps {
|
||||
field: ConfigFieldDto;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
}
|
||||
|
||||
export function ConfigFieldInput({ field, value, onChange }: ConfigFieldInputProps) {
|
||||
const isReadonly = !!field.readonly;
|
||||
|
||||
const renderInput = () => {
|
||||
const baseInputClasses = "bg-zinc-900/50 border-white/10 focus-visible:ring-primary focus-visible:border-primary transition-all duration-200" + (isReadonly ? " opacity-50 cursor-not-allowed" : "");
|
||||
switch (field.type) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<Switch
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={onChange}
|
||||
disabled={isReadonly}
|
||||
className={`data-[state=checked]:bg-primary ${isReadonly ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
/>
|
||||
);
|
||||
case 'enum':
|
||||
return (
|
||||
<Select
|
||||
value={value !== undefined && value !== null ? String(value) : ''}
|
||||
onValueChange={onChange}
|
||||
disabled={isReadonly}
|
||||
>
|
||||
<SelectTrigger className={`w-full ${baseInputClasses}`}>
|
||||
<SelectValue placeholder="请选择..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-zinc-950 border-white/10">
|
||||
{field.enumValues?.map((val) => (
|
||||
<SelectItem key={val} value={val} className="focus:bg-zinc-900 focus:text-primary">
|
||||
{val}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
case 'text':
|
||||
return (
|
||||
<Textarea
|
||||
value={value !== undefined && value !== null ? String(value) : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.sensitive && field.hasValue ? '••••••••' : ''}
|
||||
className={`min-h-[100px] ${baseInputClasses}`}
|
||||
disabled={isReadonly}
|
||||
/>
|
||||
);
|
||||
case 'number':
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={value !== undefined && value !== null ? String(value) : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.sensitive && field.hasValue ? '••••••••' : ''}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
disabled={isReadonly}
|
||||
className={baseInputClasses}
|
||||
/>
|
||||
);
|
||||
case 'string':
|
||||
case 'url':
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
type={field.type === 'url' ? 'url' : 'text'}
|
||||
value={value !== undefined && value !== null ? String(value) : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.sensitive && field.hasValue ? '••••••••' : ''}
|
||||
disabled={isReadonly}
|
||||
className={baseInputClasses}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getSourceBadge = () => {
|
||||
switch (field.source) {
|
||||
case 'override':
|
||||
return <Badge className="ml-2 bg-primary/20 text-primary border-primary/30 tech-glow hover:bg-primary/30 transition-colors">覆盖值</Badge>;
|
||||
case 'env':
|
||||
return <Badge variant="secondary" className="ml-2 bg-amber-500/20 text-amber-500 border-amber-500/30 hover:bg-amber-500/30">环境变量</Badge>;
|
||||
case 'default':
|
||||
default:
|
||||
return <Badge variant="outline" className="ml-2 border-zinc-600 text-zinc-400">默认值</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col py-5 px-1 gap-3 hover:bg-zinc-900/30 transition-colors rounded-lg">
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
||||
<div className="flex flex-col space-y-1.5 flex-1">
|
||||
<div className="flex items-center">
|
||||
<Label className="text-base font-semibold text-zinc-100">{field.label || field.envKey}</Label>
|
||||
{getSourceBadge()}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-400 leading-relaxed">
|
||||
{field.description}
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<span className="font-mono text-xs px-2 py-0.5 rounded-md bg-primary/10 text-primary border border-primary/20 inline-flex items-center">
|
||||
$ {field.envKey}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 w-full max-w-xl flex flex-col gap-2">
|
||||
{renderInput()}
|
||||
|
||||
{isReadonly && (
|
||||
<div className="text-xs text-zinc-500 flex items-center gap-1.5 pt-1">
|
||||
<Lock className="w-3.5 h-3.5" />
|
||||
只读配置,请通过环境变量文件修改
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isReadonly && field.readonlyWarning && (
|
||||
<div className="text-xs text-amber-500 flex items-center gap-1.5 pt-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
|
||||
{field.readonlyWarning}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
frontend/src/components/ConfigGroupCard.tsx
Normal file
93
frontend/src/components/ConfigGroupCard.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
|
||||
import type { ConfigGroupDto } from '@/services/configService';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfigFieldInput } from './ConfigFieldInput';
|
||||
import {
|
||||
RotateCcw, Link, Bot, Bell, Settings, Shield, FileCheck, Brain,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
link: Link,
|
||||
bot: Bot,
|
||||
bell: Bell,
|
||||
settings: Settings,
|
||||
shield: Shield,
|
||||
'file-check': FileCheck,
|
||||
brain: Brain,
|
||||
};
|
||||
|
||||
interface ConfigGroupCardProps {
|
||||
group: ConfigGroupDto;
|
||||
localConfig: Record<string, any>;
|
||||
onFieldChange: (envKey: string, value: any) => void;
|
||||
onReset: (keys: string[]) => void;
|
||||
isResetting: boolean;
|
||||
}
|
||||
|
||||
export function ConfigGroupCard({
|
||||
group,
|
||||
localConfig,
|
||||
onFieldChange,
|
||||
onReset,
|
||||
isResetting,
|
||||
}: ConfigGroupCardProps) {
|
||||
const hasOverride = group.fields.some((f) => f.source === 'override');
|
||||
|
||||
const handleReset = () => {
|
||||
// Only reset fields that actually have overrides
|
||||
const keysToReset = group.fields
|
||||
.filter((f) => f.source === 'override')
|
||||
.map((f) => f.envKey);
|
||||
|
||||
if (keysToReset.length > 0) {
|
||||
onReset(keysToReset);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mb-8 glass-panel border-white/10 shadow-xl overflow-hidden group">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-4 space-y-0 border-b border-white/5 bg-zinc-950/30">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center border border-primary/20 tech-glow group-hover:bg-primary/20 transition-all duration-300">
|
||||
{(() => {
|
||||
const Icon = ICON_MAP[group.icon];
|
||||
return Icon ? <Icon className="h-5 w-5 text-primary" /> : <span className="text-primary">{group.icon}</span>;
|
||||
})()}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold text-zinc-100 tracking-tight">
|
||||
{group.label}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-zinc-400">
|
||||
{group.description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{hasOverride && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={isResetting}
|
||||
className="border-rose-500/30 text-rose-500 hover:bg-rose-500/10 hover:text-rose-400 hover:border-rose-500/50 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
重置组配置
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y divide-white/5 p-6 bg-zinc-950/20">
|
||||
{group.fields.map((field) => (
|
||||
<ConfigFieldInput
|
||||
key={field.envKey}
|
||||
field={field}
|
||||
value={localConfig[field.envKey]}
|
||||
onChange={(val) => onFieldChange(field.envKey, val)}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
181
frontend/src/components/ConfigManager.tsx
Normal file
181
frontend/src/components/ConfigManager.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { fetchConfig, updateConfig, resetConfig } from '@/services/configService';
|
||||
import type { ConfigResponse } from '@/services/configService';
|
||||
import { ConfigGroupCard } from './ConfigGroupCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Save, AlertCircle, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function ConfigManager() {
|
||||
const queryClient = useQueryClient();
|
||||
const [localConfig, setLocalConfig] = useState<Record<string, any>>({});
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery<ConfigResponse, Error>({
|
||||
queryKey: ['config'],
|
||||
queryFn: fetchConfig,
|
||||
});
|
||||
|
||||
// Initialize local config from fetched data
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const initialState: Record<string, any> = {};
|
||||
data.groups.forEach((group) => {
|
||||
group.fields.forEach((field) => {
|
||||
if (field.sensitive && field.hasValue) {
|
||||
initialState[field.envKey] = '••••••••';
|
||||
} else {
|
||||
// For boolean, keep as boolean. For others, string/number.
|
||||
// If value is undefined or null, use empty string.
|
||||
if (field.type === 'boolean') {
|
||||
initialState[field.envKey] = field.value === 'true' || field.value === true;
|
||||
} else {
|
||||
initialState[field.envKey] = field.value ?? '';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
setLocalConfig(initialState);
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (configData: Record<string, string>) => updateConfig(configData),
|
||||
onSuccess: () => {
|
||||
toast.success('配置已成功保存');
|
||||
queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||
setHasChanges(false);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`保存失败: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: (keys: string[]) => resetConfig(keys),
|
||||
onSuccess: () => {
|
||||
toast.success('配置已重置');
|
||||
queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`重置失败: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleFieldChange = (envKey: string, value: any) => {
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
[envKey]: value,
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const payload: Record<string, string> = {};
|
||||
|
||||
for (const [key, val] of Object.entries(localConfig)) {
|
||||
if (typeof val === 'boolean') {
|
||||
payload[key] = val ? 'true' : 'false';
|
||||
} else {
|
||||
payload[key] = val === undefined || val === null ? '' : String(val);
|
||||
}
|
||||
}
|
||||
|
||||
saveMutation.mutate(payload);
|
||||
};
|
||||
|
||||
const handleResetGroup = (keys: string[]) => {
|
||||
if (confirm('确定要重置这些配置到默认值吗?这将立即生效并重载关联设置。')) {
|
||||
resetMutation.mutate(keys);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetAll = () => {
|
||||
if (!data) return;
|
||||
const allOverrideKeys = data.groups
|
||||
.flatMap((g) => g.fields)
|
||||
.filter((f) => f.source === 'override')
|
||||
.map((f) => f.envKey);
|
||||
if (allOverrideKeys.length === 0) return;
|
||||
if (confirm('确定要重置所有配置到默认值吗?这将立即生效。')) {
|
||||
resetMutation.mutate(allOverrideKeys);
|
||||
}
|
||||
};
|
||||
|
||||
const hasOverrides = data?.groups.some((g) =>
|
||||
g.fields.some((f) => f.source === 'override')
|
||||
) ?? false;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<Skeleton className="h-10 w-48 bg-zinc-900/50" />
|
||||
<Skeleton className="h-10 w-24 bg-zinc-900/50" />
|
||||
</div>
|
||||
<Skeleton className="h-[300px] w-full rounded-xl bg-zinc-900/50 border border-white/5" />
|
||||
<Skeleton className="h-[300px] w-full rounded-xl bg-zinc-900/50 border border-white/5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="p-4 bg-rose-500/10 border border-rose-500/20 text-rose-500 rounded-lg flex items-center gap-3 glass-panel">
|
||||
<AlertCircle className="w-5 h-5 text-rose-500" />
|
||||
<div className="font-medium tracking-wide">加载配置失败: {error.message}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-12">
|
||||
{/* 固定在顶部的操作栏 */}
|
||||
<div className="sticky top-0 z-10 bg-zinc-950/80 backdrop-blur-xl border-b border-white/10 py-3 px-4 md:px-6 lg:px-8 shadow-2xl">
|
||||
<div className="flex items-center justify-end gap-3 max-w-5xl mx-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleResetAll}
|
||||
disabled={!hasOverrides || resetMutation.isPending}
|
||||
className="border-white/10 text-zinc-400 hover:text-zinc-100 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
全部重置
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saveMutation.isPending}
|
||||
className="min-w-[130px] bg-primary text-zinc-950 font-bold hover:bg-primary/90 tech-glow transition-all"
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="size-4 animate-spin rounded-full border-2 border-zinc-950 border-t-transparent" /> 保存中...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存配置
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-5xl mx-auto space-y-8 mt-6 px-4 md:px-6 lg:px-8">
|
||||
{data?.groups.map((group) => (
|
||||
<ConfigGroupCard
|
||||
key={group.key}
|
||||
group={group}
|
||||
localConfig={localConfig}
|
||||
onFieldChange={handleFieldChange}
|
||||
onReset={handleResetGroup}
|
||||
isResetting={resetMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,14 +32,14 @@ export function DataTable<TData, TValue>({
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<div className="rounded-xl border border-border/50 overflow-hidden glass-panel">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableHeader className="bg-zinc-900 text-zinc-400 uppercase tracking-wider font-mono text-xs">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
<TableHead key={header.id} className="font-mono text-zinc-400">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
@@ -58,6 +58,7 @@ export function DataTable<TData, TValue>({
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="hover:bg-zinc-900/50 transition-colors border-border/50"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
@@ -68,8 +69,13 @@ export function DataTable<TData, TValue>({
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
<TableCell colSpan={columns.length} className="h-48 text-center text-zinc-500 font-mono">
|
||||
<div className="flex flex-col items-center justify-center space-y-3">
|
||||
<div className="p-3 rounded-full bg-zinc-900 border border-white/5 text-zinc-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-database"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></svg>
|
||||
</div>
|
||||
<p>未找到匹配的仓库</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
@@ -13,21 +13,21 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
|
||||
function DataTableSkeleton() {
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<div className="rounded-xl border border-border/50 overflow-hidden glass-panel">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40%]"><Skeleton className="h-5 w-24" /></TableHead>
|
||||
<TableHead className="w-[30%]"><Skeleton className="h-5 w-24" /></TableHead>
|
||||
<TableHead className="w-[30%] text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableHead>
|
||||
<TableHeader className="bg-zinc-900 border-b border-border/50">
|
||||
<TableRow className="border-border/50">
|
||||
<TableHead className="w-[40%]"><Skeleton className="h-5 w-24 bg-zinc-800" /></TableHead>
|
||||
<TableHead className="w-[30%]"><Skeleton className="h-5 w-24 bg-zinc-800" /></TableHead>
|
||||
<TableHead className="w-[30%] text-right"><Skeleton className="h-5 w-16 ml-auto bg-zinc-800" /></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell><Skeleton className="h-5 w-3/4" /></TableCell>
|
||||
<TableCell><Skeleton className="h-6 w-20" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-8 w-16 ml-auto" /></TableCell>
|
||||
<TableRow key={i} className="border-border/50">
|
||||
<TableCell><Skeleton className="h-5 w-3/4 bg-zinc-800/80" /></TableCell>
|
||||
<TableCell><Skeleton className="h-6 w-20 bg-zinc-800/80 rounded-full" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-8 w-16 ml-auto bg-zinc-800/80" /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -61,16 +61,15 @@ export function RepositoryManager() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="font-semibold text-lg md:text-2xl">仓库 Webhook 管理</h1>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex justify-end mb-6">
|
||||
<div className="relative w-full md:w-auto">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-zinc-500" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="搜索仓库..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8 sm:w-[300px] md:w-[200px] lg:w-[300px]"
|
||||
className="pl-9 w-full sm:w-[300px] md:w-[250px] lg:w-[300px] bg-zinc-900/50 border-border/50 text-zinc-200 placeholder:text-zinc-600 focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:border-primary transition-all font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,20 +77,27 @@ export function RepositoryManager() {
|
||||
{isLoading ? (
|
||||
<DataTableSkeleton />
|
||||
) : isError ? (
|
||||
<div className="p-4">
|
||||
{/* The original Alert component was removed, so this will now just show the error message */}
|
||||
<p className="text-red-500">加载仓库列表失败: {error.message}</p>
|
||||
<div className="p-6 rounded-xl border border-rose-500/20 bg-rose-500/5 glass-panel">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-rose-500/10 rounded-lg text-rose-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-mono text-sm font-medium text-rose-500">System Error_</h3>
|
||||
<p className="font-mono text-xs text-rose-400/80">加载仓库列表失败: {error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DataTable columns={columns} data={repos} />
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between w-full mt-4 space-x-4">
|
||||
<div className="text-sm text-muted-foreground flex-shrink-0">
|
||||
第 {page} 页 / 共 {totalPages} 页 (共 {totalCount} 个仓库)
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between w-full mt-6 gap-4">
|
||||
<div className="font-mono text-xs text-zinc-400 flex-shrink-0 bg-zinc-900/50 px-3 py-1.5 rounded-md border border-white/5">
|
||||
第 <span className="text-zinc-200">{page}</span> 页 / 共 <span className="text-zinc-200">{totalPages}</span> 页 <span className="text-zinc-600 mx-1">|</span> 共 <span className="text-zinc-200">{totalCount}</span> 个仓库
|
||||
</div>
|
||||
<Pagination className="flex-shrink-0 justify-end w-auto">
|
||||
<PaginationContent>
|
||||
<Pagination className="flex-shrink-0 w-auto mx-0">
|
||||
<PaginationContent className="gap-2">
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
@@ -99,7 +105,7 @@ export function RepositoryManager() {
|
||||
e.preventDefault();
|
||||
setPage(p => Math.max(1, p - 1));
|
||||
}}
|
||||
className={page <= 1 ? "pointer-events-none opacity-50" : ""}
|
||||
className={`border border-border/50 hover:bg-zinc-800 hover:text-primary hover:border-primary/50 font-mono text-xs transition-colors ${page <= 1 ? "pointer-events-none opacity-30 bg-zinc-900/30" : "bg-zinc-900 text-zinc-400"}`}
|
||||
/>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
@@ -109,7 +115,7 @@ export function RepositoryManager() {
|
||||
e.preventDefault();
|
||||
setPage(p => Math.min(totalPages, p + 1));
|
||||
}}
|
||||
className={page >= totalPages ? "pointer-events-none opacity-50" : ""}
|
||||
className={`border border-border/50 hover:bg-zinc-800 hover:text-primary hover:border-primary/50 font-mono text-xs transition-colors ${page >= totalPages ? "pointer-events-none opacity-30 bg-zinc-900/30" : "bg-zinc-900 text-zinc-400"}`}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import type { Repository } from "@/services/repositoryService"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { WebhookToggleButton } from "@/components/WebhookToggleButton"
|
||||
|
||||
export const columns: ColumnDef<Repository>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "仓库名称",
|
||||
cell: ({ row }) => <div className="font-medium">{row.getValue("name")}</div>,
|
||||
cell: ({ row }) => <div className="font-medium text-zinc-100 text-sm">{row.getValue("name")}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: "webhook_status",
|
||||
@@ -18,15 +17,16 @@ export const columns: ColumnDef<Repository>[] = [
|
||||
const status = row.getValue("webhook_status") as Repository["webhook_status"]
|
||||
const isActive = status === 'active'
|
||||
return (
|
||||
<Badge variant={isActive ? "default" : "outline"}>
|
||||
<div className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold border ${isActive ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' : 'bg-transparent text-zinc-500 border-zinc-700'}`}>
|
||||
{isActive && <span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" style={{ boxShadow: '0 0 8px 1px rgba(52, 211, 153, 0.6)' }}></span>}
|
||||
{isActive ? '已启用' : '未启用'}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <div className="text-right">操作</div>,
|
||||
header: () => <div className="text-right text-zinc-400">操作</div>,
|
||||
cell: ({ row }) => {
|
||||
const repo = row.original
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -32,14 +33,26 @@ export function WebhookToggleButton({ repoName, status, hookId }: WebhookToggleB
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={status === 'active' ? 'destructive' : 'default'}
|
||||
variant={status === 'active' ? 'outline' : 'default'}
|
||||
size="sm"
|
||||
className={
|
||||
status === 'active'
|
||||
? "border-rose-500/50 bg-transparent text-rose-500 hover:bg-rose-500/10 hover:text-rose-400 transition-colors"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90 transition-all duration-300 hover:shadow-[0_0_15px_rgba(45,212,191,0.5)] tech-glow"
|
||||
}
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending
|
||||
? '处理中...'
|
||||
: status === 'active' ? '停用' : '启用'}
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
|
||||
<span className="font-mono text-xs">处理中...</span>
|
||||
</>
|
||||
) : status === 'active' ? (
|
||||
<span className="font-mono text-xs">停用</span>
|
||||
) : (
|
||||
<span className="font-mono text-xs">启用</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
173
frontend/src/components/ui/select.tsx
Normal file
173
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] dark:bg-input/30 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex h-9 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-muted -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
27
frontend/src/components/ui/separator.tsx
Normal file
27
frontend/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
29
frontend/src/components/ui/switch.tsx
Normal file
29
frontend/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
58
frontend/src/components/ui/tabs.tsx
Normal file
58
frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({ ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return <TabsPrimitive.Root data-slot="tabs" {...props} />
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
20
frontend/src/components/ui/textarea.tsx
Normal file
20
frontend/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex min-h-[80px] w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -1,51 +1,53 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--background: 240 10% 98%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--primary: 180 100% 35%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--accent: 180 100% 35%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 10% 3.9%;
|
||||
--ring: 180 100% 35%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--background: 240 10% 4%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card: 240 10% 6%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover: 240 10% 6%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--primary: 175 90% 45%;
|
||||
--primary-foreground: 240 10% 4%;
|
||||
--secondary: 240 5% 15%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--muted: 240 5% 15%;
|
||||
--muted-foreground: 240 5% 65%;
|
||||
--accent: 175 90% 45%;
|
||||
--accent-foreground: 240 10% 4%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--border: 240 5% 15%;
|
||||
--input: 240 5% 15%;
|
||||
--ring: 175 90% 45%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +56,32 @@
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground font-sans antialiased selection:bg-primary/30 selection:text-primary;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply tracking-tight;
|
||||
}
|
||||
code, pre, .font-mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.font-sans {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
.bg-grid-pattern {
|
||||
background-size: 40px 40px;
|
||||
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
|
||||
}
|
||||
.glass-panel {
|
||||
@apply bg-zinc-950/50 backdrop-blur-xl border border-white/10 shadow-2xl;
|
||||
}
|
||||
.tech-glow {
|
||||
box-shadow: 0 0 20px -5px hsl(var(--primary) / 0.5);
|
||||
}
|
||||
.tech-glow:hover {
|
||||
box-shadow: 0 0 30px -5px hsl(var(--primary) / 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,26 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import axios from 'axios'
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: (failureCount, error) => {
|
||||
// 4xx 客户端错误(如 401 未授权)不重试
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
const status = error.response.status;
|
||||
if (status >= 400 && status < 500) return false;
|
||||
}
|
||||
// 其他错误最多重试 2 次
|
||||
return failureCount < 2;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Force dark mode as requested
|
||||
document.documentElement.classList.add('dark');
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -1,53 +1,167 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LogOut, Bot, Settings } from 'lucide-react';
|
||||
import { RepositoryManager } from '@/components/RepositoryManager';
|
||||
import { LogOut, Bot, FolderGit2, Sliders, Menu, X, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
|
||||
|
||||
const navItems = [
|
||||
{ path: '/repos', label: '仓库管理', icon: FolderGit2 },
|
||||
{ path: '/config', label: '配置管理', icon: Sliders },
|
||||
] as const;
|
||||
|
||||
export default function DashboardPage() {
|
||||
const location = useLocation();
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('authToken');
|
||||
window.location.reload();
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
const currentTitle = navItems.find(item => location.pathname.startsWith(item.path))?.label || 'Dashboard';
|
||||
const isConfigPage = location.pathname.startsWith('/config');
|
||||
|
||||
return (
|
||||
<div className="grid min-h-screen w-full lg:grid-cols-[280px_1fr]">
|
||||
<div className="hidden border-r bg-gray-100/40 lg:block dark:bg-gray-800/40">
|
||||
<div className="flex h-full max-h-screen flex-col gap-2">
|
||||
<div className="flex h-[60px] items-center border-b px-6">
|
||||
<a href="/" className="flex items-center gap-2 font-semibold">
|
||||
<Bot className="h-6 w-6" />
|
||||
<span>Gitea AI Assistant</span>
|
||||
</a>
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background">
|
||||
{/* Mobile Overlay */}
|
||||
{isMobileMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden animate-in fade-in"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 flex flex-col border-r border-border bg-zinc-950 transition-all duration-300 ease-in-out lg:relative ${
|
||||
isSidebarCollapsed ? 'w-[72px]' : 'w-64'
|
||||
} ${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}`}
|
||||
>
|
||||
<div className="flex h-16 items-center justify-between px-4 border-b border-border/50 bg-zinc-950">
|
||||
<div className={`flex items-center gap-3 overflow-hidden transition-all duration-300 ${isSidebarCollapsed ? 'w-10 justify-center -ml-1' : 'w-full'}`}>
|
||||
<div className="flex shrink-0 h-9 w-9 items-center justify-center rounded-xl bg-primary/10 text-primary border border-primary/20 shadow-[0_0_15px_rgba(20,184,166,0.15)] ring-1 ring-primary/10">
|
||||
<Bot className="h-5 w-5" />
|
||||
</div>
|
||||
{!isSidebarCollapsed && (
|
||||
<span className="truncate font-bold tracking-tight text-zinc-100 whitespace-nowrap">
|
||||
Gitea AI Assistant
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto py-2">
|
||||
<nav className="grid items-start px-4 text-sm font-medium">
|
||||
<a
|
||||
href="#"
|
||||
className="flex items-center gap-3 rounded-lg bg-gray-100 px-3 py-2 text-gray-900 transition-all hover:text-gray-900 dark:bg-gray-800 dark:text-gray-50 dark:hover:text-gray-50"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Webhook 管理
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<header className="flex h-14 lg:h-[60px] items-center gap-4 border-b bg-gray-100/40 px-6 dark:bg-gray-800/40">
|
||||
<div className="w-full flex-1">
|
||||
{/* Can add search bar here in the future */}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full border w-8 h-8"
|
||||
onClick={handleLogout}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="lg:hidden shrink-0 h-8 w-8 text-zinc-400 hover:text-zinc-100"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span className="sr-only">Toggle user menu</span>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-2 overflow-y-auto p-3">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`group relative flex w-full items-center rounded-xl p-2.5 transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-zinc-400 hover:bg-zinc-900 hover:text-zinc-100'
|
||||
} ${isSidebarCollapsed ? 'justify-center' : 'justify-start gap-3'}`
|
||||
}
|
||||
title={isSidebarCollapsed ? item.label : undefined}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 h-1/2 w-1 -translate-y-1/2 rounded-r-full bg-primary shadow-[0_0_10px_rgba(20,184,166,0.5)]"></div>
|
||||
)}
|
||||
<Icon className={`h-5 w-5 shrink-0 transition-transform duration-300 ${isActive ? 'text-primary scale-110' : 'text-zinc-500 group-hover:text-zinc-300'}`} />
|
||||
{!isSidebarCollapsed && (
|
||||
<span className="font-medium tracking-wide text-sm">{item.label}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-border/50 p-3 bg-zinc-950">
|
||||
<button
|
||||
onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||
className={`hidden lg:flex w-full items-center rounded-xl p-2.5 text-zinc-500 transition-colors hover:bg-zinc-900 hover:text-zinc-300 ${
|
||||
isSidebarCollapsed ? 'justify-center' : 'justify-start gap-3'
|
||||
}`}
|
||||
>
|
||||
{isSidebarCollapsed ? (
|
||||
<PanelLeftOpen className="h-5 w-5" />
|
||||
) : (
|
||||
<>
|
||||
<PanelLeftClose className="h-5 w-5" />
|
||||
<span className="font-medium text-sm">收起侧边栏</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden relative">
|
||||
{/* Top Header */}
|
||||
<header className="flex h-16 shrink-0 items-center justify-between border-b border-border/50 bg-background/80 px-4 backdrop-blur-md z-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="lg:hidden text-zinc-400 hover:text-zinc-100 h-9 w-9 -ml-2"
|
||||
onClick={() => setIsMobileMenuOpen(true)}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-5 w-1.5 rounded-full bg-primary/80 hidden sm:block shadow-[0_0_8px_rgba(20,184,166,0.4)]"></div>
|
||||
<h1 className="text-lg font-semibold tracking-tight text-foreground">{currentTitle}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full border border-border/50 bg-zinc-900/50">
|
||||
<div className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-500 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-zinc-400 uppercase tracking-wider">System Online</span>
|
||||
</div>
|
||||
<div className="h-6 w-px bg-border/50 hidden sm:block"></div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full border border-border/50 bg-zinc-900 hover:bg-rose-500/10 hover:text-rose-400 hover:border-rose-500/20 transition-all h-9 w-9"
|
||||
onClick={handleLogout}
|
||||
title="登出"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span className="sr-only">登出</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-6">
|
||||
<RepositoryManager />
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 overflow-y-auto relative">
|
||||
<div className="absolute inset-0 bg-background/95 backdrop-blur-[1px] -z-10"></div>
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-[0.03] -z-10"></div>
|
||||
<div className={`mx-auto max-w-7xl animate-in fade-in slide-in-from-bottom-4 duration-500 ${isConfigPage ? '' : 'p-4 md:p-6 lg:p-8'}`}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,7 @@ import { useState } from 'react';
|
||||
import api from '@/lib/api';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Bot } from 'lucide-react';
|
||||
import { Bot, Terminal, ShieldCheck, ArrowRight, Activity } from 'lucide-react';
|
||||
|
||||
export function LoginPage() {
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -30,37 +29,87 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full lg:grid lg:min-h-screen lg:grid-cols-2">
|
||||
<div className="hidden bg-gray-900 lg:flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Bot className="h-24 w-24 text-gray-500 mx-auto mb-6" />
|
||||
<h1 className="text-4xl font-bold text-white">Gitea AI Assistant</h1>
|
||||
<p className="mt-4 text-gray-400">智能代码审查,自动化您的工作流</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-12 min-h-screen">
|
||||
<div className="mx-auto grid w-[350px] gap-6">
|
||||
<div className="grid gap-2 text-center">
|
||||
<h1 className="text-3xl font-bold">登录</h1>
|
||||
<p className="text-balance text-muted-foreground">
|
||||
请输入您的管理员密码以继续
|
||||
</p>
|
||||
<div className="relative flex min-h-screen w-full items-center justify-center overflow-hidden bg-zinc-950">
|
||||
{/* Background grid and gradient effects */}
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-10"></div>
|
||||
<div className="absolute top-[-20%] left-[-10%] h-[500px] w-[500px] rounded-full bg-primary/20 blur-[120px] pointer-events-none"></div>
|
||||
<div className="absolute bottom-[-20%] right-[-10%] h-[500px] w-[500px] rounded-full bg-primary/10 blur-[100px] pointer-events-none"></div>
|
||||
|
||||
<div className="z-10 w-full max-w-md px-4 sm:px-6 relative">
|
||||
<div className="glass-panel relative rounded-2xl p-8 sm:p-10 transition-all duration-500 hover:border-primary/20">
|
||||
{/* Decorative terminal dots */}
|
||||
<div className="absolute top-4 left-4 flex gap-2">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-rose-500/80 shadow-[0_0_5px_rgba(244,63,94,0.5)]"></div>
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-amber-500/80 shadow-[0_0_5px_rgba(245,158,11,0.5)]"></div>
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-emerald-500/80 shadow-[0_0_5px_rgba(16,185,129,0.5)]"></div>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !isLoading && handleLogin()}
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="mb-10 mt-6 flex flex-col items-center text-center">
|
||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-zinc-900 border border-primary/20 shadow-[0_0_20px_rgba(var(--primary),0.15)] ring-1 ring-primary/10 relative group">
|
||||
<div className="absolute inset-0 rounded-2xl bg-primary/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
<Bot className="h-8 w-8 text-primary relative z-10" />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button onClick={handleLogin} disabled={isLoading} className="w-full">
|
||||
{isLoading ? '验证中...' : '登录'}
|
||||
<h1 className="mb-2 text-2xl font-bold tracking-tight text-white sm:text-3xl">Gitea AI Assistant</h1>
|
||||
<div className="flex items-center gap-2 text-xs font-mono text-primary/70 bg-primary/5 px-3 py-1 rounded-full border border-primary/10">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
|
||||
</span>
|
||||
[SYSTEM] authentication_required
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="password" className="text-xs font-mono font-medium text-zinc-400 flex items-center gap-2">
|
||||
<span className="text-primary font-bold">></span> enter_admin_password
|
||||
</label>
|
||||
</div>
|
||||
<div className="relative group">
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !isLoading && handleLogin()}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
className="h-12 border-zinc-800 bg-zinc-900/50 font-mono text-zinc-100 placeholder:text-zinc-700 focus-visible:border-primary/50 focus-visible:ring-primary/20 transition-all duration-300"
|
||||
/>
|
||||
<Terminal className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-600 transition-colors group-focus-within:text-primary/70" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-3 text-sm text-rose-400 animate-in fade-in slide-in-from-top-1">
|
||||
<Activity className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p className="font-mono text-xs leading-relaxed">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
className="tech-glow group relative mt-4 h-12 w-full overflow-hidden bg-primary text-primary-foreground transition-all hover:bg-primary/90 disabled:opacity-70 disabled:pointer-events-none"
|
||||
>
|
||||
<div className="absolute inset-0 flex h-full w-full justify-center [transform:skew(-12deg)_translateX(-150%)] group-hover:duration-1000 group-hover:[transform:skew(-12deg)_translateX(150%)]">
|
||||
<div className="relative h-full w-12 bg-white/20"></div>
|
||||
</div>
|
||||
<span className="relative flex items-center gap-2 font-mono font-semibold tracking-wide">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground"></div>
|
||||
VERIFYING...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
AUTHORIZE
|
||||
<ArrowRight className="h-4 w-4 opacity-70 transition-transform duration-300 group-hover:translate-x-1 group-hover:opacity-100" />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
46
frontend/src/services/configService.ts
Normal file
46
frontend/src/services/configService.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import api from '@/lib/api';
|
||||
|
||||
export type ConfigSource = 'default' | 'env' | 'override';
|
||||
export type ConfigFieldType = 'string' | 'number' | 'boolean' | 'url' | 'text' | 'enum';
|
||||
|
||||
export interface ConfigFieldDto {
|
||||
envKey: string;
|
||||
label: string;
|
||||
description: string;
|
||||
type: ConfigFieldType;
|
||||
sensitive: boolean;
|
||||
readonly?: boolean;
|
||||
readonlyWarning?: string;
|
||||
enumValues?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
defaultValue?: string | number | boolean;
|
||||
value: string | number | boolean | undefined;
|
||||
hasValue: boolean;
|
||||
source: ConfigSource;
|
||||
}
|
||||
|
||||
export interface ConfigGroupDto {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
fields: ConfigFieldDto[];
|
||||
}
|
||||
|
||||
export interface ConfigResponse {
|
||||
groups: ConfigGroupDto[];
|
||||
}
|
||||
|
||||
export const fetchConfig = async (): Promise<ConfigResponse> => {
|
||||
const response = await api.get<ConfigResponse>('/config');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateConfig = async (configData: Record<string, string>): Promise<void> => {
|
||||
await api.put('/config', configData);
|
||||
};
|
||||
|
||||
export const resetConfig = async (keys: string[]): Promise<void> => {
|
||||
await api.post('/config/reset', { keys });
|
||||
};
|
||||
@@ -11,10 +11,11 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
// 将 /admin/api 的请求代理到后端服务
|
||||
'/admin/api': {
|
||||
target: 'http://localhost:5174', // 您的后端服务地址
|
||||
target: 'http://localhost:5174', // 后端服务地址
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# ConfigMap 用于存储非敏感配置
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: <%= APP_NAME %>-config
|
||||
data:
|
||||
GITEA_API_URL: "http://your-gitea-addr/api/v1"
|
||||
OPENAI_BASE_URL: "{{OPENAI_COMPATIBILITY_URL}}"
|
||||
OPENAI_MODEL: "gpt-4o-mini"
|
||||
PORT: "3000"
|
||||
FEISHU_WEBHOOK_URL: "{{FEISHU_WEBHOOK_URL}}"
|
||||
|
||||
---
|
||||
# Secret 用于存储敏感信息
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: <%= APP_NAME %>-secrets
|
||||
type: Opaque
|
||||
data:
|
||||
# base64 编码的敏感数据
|
||||
GITEA_ACCESS_TOKEN: "{{GITEA_ACCESS_TOKEN}}"
|
||||
OPENAI_API_KEY: "{{OPENAI_API_KEY}}"
|
||||
WEBHOOK_SECRET: "{{WEBHOOK_SECRET}}"
|
||||
FEISHU_WEBHOOK_SECRET: "{{FEISHU_WEBHOOK_SECRET}}"
|
||||
|
||||
---
|
||||
# Deployment 定义应用程序部署
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: <%= APP_NAME %>
|
||||
labels:
|
||||
app: <%= APP_NAME %>
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: <%= APP_NAME %>
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: <%= APP_NAME %>
|
||||
spec:
|
||||
containers:
|
||||
- name: <%= APP_NAME %>
|
||||
image: <%= IMAGE_FROM %>
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
name: http
|
||||
resources:
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
securityContext:
|
||||
runAsUser: 1001
|
||||
runAsGroup: 1001
|
||||
allowPrivilegeEscalation: false
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: <%= APP_NAME %>-config
|
||||
- secretRef:
|
||||
name: <%= APP_NAME %>-secrets
|
||||
|
||||
---
|
||||
# Service 暴露应用程序
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: <%= APP_NAME %>
|
||||
labels:
|
||||
app: <%= APP_NAME %>
|
||||
spec:
|
||||
selector:
|
||||
app: <%= APP_NAME %>
|
||||
ports:
|
||||
- port: 3000
|
||||
targetPort: 3000
|
||||
nodePort: 30300
|
||||
name: http
|
||||
type: NodePort
|
||||
16
package.json
16
package.json
@@ -7,17 +7,20 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/zod-validator": "^0.4.3",
|
||||
"@qdrant/js-client-rest": "^1.16.2",
|
||||
"axios": "^1.8.3",
|
||||
"dotenv": "^16.4.7",
|
||||
"hono": "^4.7.4",
|
||||
"hono": "^4.11.9",
|
||||
"lodash-es": "^4.17.21",
|
||||
"openai": "^4.87.3",
|
||||
"zod": "^3.24.2"
|
||||
"zod": "^3.25.1",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^22.13.10",
|
||||
"tslint": "^6.1.3",
|
||||
"concurrently": "^9.2.1",
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"files": [
|
||||
@@ -26,11 +29,14 @@
|
||||
"main": "./dist/index.js",
|
||||
"typings": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"dev": "concurrently -k -p [{name}] -n backend,frontend -c blue,green \"bun run dev:backend\" \"bun run dev:frontend\"",
|
||||
"dev:backend": "bun run --watch src/index.ts",
|
||||
"dev:frontend": "cd frontend && bun run dev",
|
||||
"build": "rm -rf dist && tsc",
|
||||
"start": "bun run src/index.ts",
|
||||
"start:prod": "bun run dist/index.js",
|
||||
"lint": "tslint -c tslint.json src/**/*.ts"
|
||||
"lint": "biome check src/",
|
||||
"test": "bun test"
|
||||
},
|
||||
"keywords": [
|
||||
"code-review",
|
||||
|
||||
211
src/config/__tests__/config-manager.test.ts
Normal file
211
src/config/__tests__/config-manager.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
// @ts-expect-error bun:test is provided by Bun at runtime
|
||||
declare module 'bun:test' {
|
||||
export const describe: any;
|
||||
export const test: any;
|
||||
export const it: any;
|
||||
export const expect: any;
|
||||
export const beforeEach: any;
|
||||
export const afterEach: any;
|
||||
export const beforeAll: any;
|
||||
export const afterAll: any;
|
||||
}
|
||||
|
||||
// @ts-expect-error bun:test is provided by Bun at runtime
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile, unlink } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { AppConfig } from '../config-manager';
|
||||
|
||||
// ── All env keys in the Zod schema ──────────────────────────────────────────
|
||||
const SCHEMA_KEYS = [
|
||||
'GITEA_API_URL',
|
||||
'GITEA_ACCESS_TOKEN',
|
||||
'GITEA_ADMIN_TOKEN',
|
||||
'OPENAI_BASE_URL',
|
||||
'OPENAI_API_KEY',
|
||||
'OPENAI_MODEL',
|
||||
'CUSTOM_SUMMARY_PROMPT',
|
||||
'CUSTOM_LINE_COMMENT_PROMPT',
|
||||
'FEISHU_WEBHOOK_URL',
|
||||
'FEISHU_WEBHOOK_SECRET',
|
||||
'PORT',
|
||||
'WEBHOOK_SECRET',
|
||||
'ADMIN_PASSWORD',
|
||||
'JWT_SECRET',
|
||||
'REVIEW_ENGINE',
|
||||
'REVIEW_WORKDIR',
|
||||
'REVIEW_MODEL_PLANNER',
|
||||
'REVIEW_MODEL_SPECIALIST',
|
||||
'REVIEW_MODEL_JUDGE',
|
||||
'REVIEW_MAX_PARALLEL_RUNS',
|
||||
'REVIEW_MAX_FILES_PER_RUN',
|
||||
'REVIEW_MAX_FILE_CONTENT_CHARS',
|
||||
'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE',
|
||||
'REVIEW_ENABLE_HUMAN_GATE',
|
||||
'REVIEW_ALLOWED_COMMANDS',
|
||||
'REVIEW_COMMAND_TIMEOUT_MS',
|
||||
'QDRANT_URL',
|
||||
'ENABLE_MEMORY',
|
||||
'FEW_SHOT_EXAMPLES_COUNT',
|
||||
'ENABLE_REFLECTION',
|
||||
'MAX_REFLECTION_ROUNDS',
|
||||
'ENABLE_DEBATE',
|
||||
'DEBATE_THRESHOLD',
|
||||
] as const;
|
||||
|
||||
const CONTROL_KEYS = ['CONFIG_OVERRIDES_PATH', 'NODE_ENV'] as const;
|
||||
const ALL_KEYS: readonly string[] = [...SCHEMA_KEYS, ...CONTROL_KEYS];
|
||||
|
||||
/**
|
||||
* Dynamically import a fresh config-manager module.
|
||||
* Appending a unique query string to the specifier forces Bun to bypass the
|
||||
* module cache, giving us a brand-new ConfigManager singleton each time.
|
||||
*/
|
||||
async function importFresh() {
|
||||
const mod = await import(`../config-manager.ts?t=${Date.now()}-${randomUUID()}`);
|
||||
return mod.configManager;
|
||||
}
|
||||
|
||||
describe('ConfigManager', () => {
|
||||
let tmpPath: string;
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
tmpPath = join(tmpdir(), `cfg-test-${randomUUID()}.json`);
|
||||
|
||||
// Snapshot every env key we might touch
|
||||
for (const key of ALL_KEYS) {
|
||||
savedEnv[key] = process.env[key];
|
||||
}
|
||||
|
||||
// Neutralise all schema keys ('' is treated as "absent" by getCurrent).
|
||||
// This also prevents dotenv from injecting values from a local .env file.
|
||||
for (const key of SCHEMA_KEYS) {
|
||||
process.env[key] = '';
|
||||
}
|
||||
|
||||
// Per-test temp overrides file
|
||||
process.env.CONFIG_OVERRIDES_PATH = tmpPath;
|
||||
|
||||
// FEISHU_WEBHOOK_URL has no Zod default → must be a valid URL for schema to pass.
|
||||
process.env.FEISHU_WEBHOOK_URL = 'https://hooks.example.com/test';
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const key of ALL_KEYS) {
|
||||
if (savedEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = savedEnv[key]!;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await unlink(tmpPath);
|
||||
} catch {
|
||||
/* ok if missing */
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 1. Layering: defaults < env < override ─────────────────────────
|
||||
|
||||
describe('layering: defaults < env < override', () => {
|
||||
test('Zod default used when env and override are absent', async () => {
|
||||
const cm = await importFresh();
|
||||
expect(cm.getCurrent().openai.model).toBe('gpt-4o-mini');
|
||||
});
|
||||
|
||||
test('env value overrides Zod default', async () => {
|
||||
process.env.OPENAI_MODEL = 'env-model';
|
||||
const cm = await importFresh();
|
||||
expect(cm.getCurrent().openai.model).toBe('env-model');
|
||||
});
|
||||
|
||||
test('override wins over env', async () => {
|
||||
process.env.OPENAI_MODEL = 'env-model';
|
||||
const cm = await importFresh();
|
||||
await cm.setOverrides({ OPENAI_MODEL: 'override-model' });
|
||||
expect(cm.getCurrent().openai.model).toBe('override-model');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. Empty string resets override ─────────────────────────────────
|
||||
|
||||
describe('empty string resets override', () => {
|
||||
test('setting override to "" removes it, value falls back to Zod default', async () => {
|
||||
const cm = await importFresh();
|
||||
await cm.setOverrides({ OPENAI_MODEL: 'temp-override' });
|
||||
expect(cm.getCurrent().openai.model).toBe('temp-override');
|
||||
|
||||
await cm.setOverrides({ OPENAI_MODEL: '' });
|
||||
|
||||
// OPENAI_MODEL is '' in env (neutralised) → falls to Zod default
|
||||
expect(cm.getCurrent().openai.model).toBe('gpt-4o-mini');
|
||||
expect(cm.getOverrides()).not.toHaveProperty('OPENAI_MODEL');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3. Persistence ─────────────────────────────────────────────────
|
||||
|
||||
describe('persistence', () => {
|
||||
test('setOverrides writes JSON file; new instance loads it', async () => {
|
||||
const cm1 = await importFresh();
|
||||
await cm1.setOverrides({ OPENAI_MODEL: 'persisted-model' });
|
||||
|
||||
// File structure check
|
||||
const raw = await readFile(tmpPath, 'utf-8');
|
||||
const data = JSON.parse(raw);
|
||||
expect(data.version).toBe(1);
|
||||
expect(typeof data.updatedAt).toBe('string');
|
||||
expect(data.overrides.OPENAI_MODEL).toBe('persisted-model');
|
||||
|
||||
// Fresh instance picks it up
|
||||
const cm2 = await importFresh();
|
||||
expect(cm2.getCurrent().openai.model).toBe('persisted-model');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 4. getSource() ─────────────────────────────────────────────────
|
||||
|
||||
describe('getSource()', () => {
|
||||
test('returns "default" when neither env nor override is set', async () => {
|
||||
// OPENAI_MODEL = '' (neutralised) → getSource sees '' → 'default'
|
||||
const cm = await importFresh();
|
||||
expect(cm.getSource('OPENAI_MODEL')).toBe('default');
|
||||
});
|
||||
|
||||
test('returns "env" when process.env has a non-empty value', async () => {
|
||||
process.env.OPENAI_MODEL = 'from-env';
|
||||
const cm = await importFresh();
|
||||
expect(cm.getSource('OPENAI_MODEL')).toBe('env');
|
||||
});
|
||||
|
||||
test('returns "override" when override is set', async () => {
|
||||
process.env.OPENAI_MODEL = 'from-env';
|
||||
const cm = await importFresh();
|
||||
await cm.setOverrides({ OPENAI_MODEL: 'from-override' });
|
||||
expect(cm.getSource('OPENAI_MODEL')).toBe('override');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 5. Dev fallback ─────────────────────────────────────────────────
|
||||
|
||||
describe('dev fallback', () => {
|
||||
test('FEISHU_WEBHOOK_URL missing + NODE_ENV=development → feishu.webhookUrl ""', async () => {
|
||||
process.env.FEISHU_WEBHOOK_URL = ''; // invalid → safeParse fails
|
||||
process.env.NODE_ENV = 'development';
|
||||
const cm = await importFresh();
|
||||
const cfg: AppConfig = cm.getCurrent();
|
||||
expect(cfg.feishu.webhookUrl).toBe('');
|
||||
});
|
||||
|
||||
test('FEISHU_WEBHOOK_URL missing + NODE_ENV unset → feishu.webhookUrl ""', async () => {
|
||||
process.env.FEISHU_WEBHOOK_URL = '';
|
||||
process.env.NODE_ENV = ''; // falsy → same branch as undefined
|
||||
const cm = await importFresh();
|
||||
const cfg: AppConfig = cm.getCurrent();
|
||||
expect(cfg.feishu.webhookUrl).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
402
src/config/config-manager.ts
Normal file
402
src/config/config-manager.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* Three-layer configuration manager.
|
||||
* Priority: Zod defaults → process.env → JSON overrides
|
||||
*
|
||||
* Override file format:
|
||||
* { version: 1, updatedAt: string, overrides: Record<string, string> }
|
||||
*
|
||||
* Bun-friendly IO: reads via readFile, writes atomically via temp+rename.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { config as dotenvConfig } from 'dotenv';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Load .env before any process.env access (must precede singleton construction)
|
||||
dotenvConfig();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Override file types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface OverridesFile {
|
||||
version: 1;
|
||||
updatedAt: string;
|
||||
overrides: Record<string, string>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zod schema (identical to src/config/index.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const defaultAllowedReviewCommands = ['git', 'rg', 'cat', 'sed', 'wc'];
|
||||
|
||||
const envSchema = z.object({
|
||||
// Gitea
|
||||
GITEA_API_URL: z.string().url().default('http://localhost:5174/api/v1'),
|
||||
GITEA_ACCESS_TOKEN: z.string().default('test_token'),
|
||||
GITEA_ADMIN_TOKEN: z.string().optional(),
|
||||
|
||||
// OpenAI
|
||||
OPENAI_BASE_URL: z.string().url().default('https://api.openai.com/v1'),
|
||||
OPENAI_API_KEY: z.string().default('test_openai_key'),
|
||||
OPENAI_MODEL: z.string().default('gpt-4o-mini'),
|
||||
CUSTOM_SUMMARY_PROMPT: z.string().optional(),
|
||||
CUSTOM_LINE_COMMENT_PROMPT: z.string().optional(),
|
||||
|
||||
// Feishu
|
||||
FEISHU_WEBHOOK_URL: z.string().url(),
|
||||
FEISHU_WEBHOOK_SECRET: z.string().optional(),
|
||||
|
||||
// App
|
||||
PORT: z.string().transform(Number).default('5174'),
|
||||
WEBHOOK_SECRET: z.string().default('test_webhook_secret'),
|
||||
|
||||
// Admin
|
||||
ADMIN_PASSWORD: z.string().default('password'),
|
||||
JWT_SECRET: z.string().default('a-secure-secret-for-jwt'),
|
||||
|
||||
// Review engine
|
||||
REVIEW_ENGINE: z.enum(['legacy', 'agent']).default('legacy'),
|
||||
REVIEW_WORKDIR: z.string().default('/tmp/gitea-assistant'),
|
||||
REVIEW_MODEL_PLANNER: z.string().default('gpt-4o-mini'),
|
||||
REVIEW_MODEL_SPECIALIST: z.string().default('gpt-4o-mini'),
|
||||
REVIEW_MODEL_JUDGE: z.string().default('gpt-4o-mini'),
|
||||
REVIEW_MAX_PARALLEL_RUNS: z.coerce.number().int().min(1).max(8).default(2),
|
||||
REVIEW_MAX_FILES_PER_RUN: z.coerce.number().int().min(1).max(1000).default(200),
|
||||
REVIEW_MAX_FILE_CONTENT_CHARS: z.coerce.number().int().min(1000).max(1_000_000).default(40_000),
|
||||
REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.8),
|
||||
REVIEW_ENABLE_HUMAN_GATE: z
|
||||
.enum(['true', 'false'])
|
||||
.default('true')
|
||||
.transform((value) => value === 'true'),
|
||||
REVIEW_ALLOWED_COMMANDS: z.string().default(defaultAllowedReviewCommands.join(',')),
|
||||
REVIEW_COMMAND_TIMEOUT_MS: z.coerce.number().int().min(1000).max(300000).default(10000),
|
||||
|
||||
// Memory & learning
|
||||
QDRANT_URL: z.preprocess(
|
||||
(val) => (typeof val === 'string' && val.trim() === '' ? undefined : val),
|
||||
z.string().url().optional()
|
||||
),
|
||||
ENABLE_MEMORY: z
|
||||
.enum(['true', 'false'])
|
||||
.default('false')
|
||||
.transform((value) => value === 'true'),
|
||||
FEW_SHOT_EXAMPLES_COUNT: z.coerce.number().int().min(0).max(20).default(10),
|
||||
|
||||
// Reflection & debate
|
||||
ENABLE_REFLECTION: z
|
||||
.enum(['true', 'false'])
|
||||
.default('false')
|
||||
.transform((value) => value === 'true'),
|
||||
MAX_REFLECTION_ROUNDS: z.coerce.number().int().min(1).max(5).default(2),
|
||||
ENABLE_DEBATE: z
|
||||
.enum(['true', 'false'])
|
||||
.default('false')
|
||||
.transform((value) => value === 'true'),
|
||||
DEBATE_THRESHOLD: z.enum(['high', 'medium']).default('high'),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config shape (matches default export of src/config/index.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AppConfig {
|
||||
gitea: {
|
||||
apiUrl: string;
|
||||
accessToken: string;
|
||||
};
|
||||
openai: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
customSummaryPrompt: string | undefined;
|
||||
customLineCommentPrompt: string | undefined;
|
||||
};
|
||||
feishu: {
|
||||
webhookUrl: string;
|
||||
webhookSecret: string | undefined;
|
||||
};
|
||||
app: {
|
||||
port: number;
|
||||
webhookSecret: string;
|
||||
};
|
||||
admin: {
|
||||
password: string;
|
||||
jwtSecret: string;
|
||||
giteaAdminToken: string | undefined;
|
||||
};
|
||||
review: {
|
||||
engine: string;
|
||||
workdir: string;
|
||||
modelPlanner: string;
|
||||
modelSpecialist: string;
|
||||
modelJudge: string;
|
||||
maxParallelRuns: number;
|
||||
maxFilesPerRun: number;
|
||||
maxFileContentChars: number;
|
||||
autoPublishMinConfidence: number;
|
||||
enableHumanGate: boolean;
|
||||
allowedCommands: string[];
|
||||
commandTimeoutMs: number;
|
||||
qdrantUrl: string | undefined;
|
||||
enableMemory: boolean;
|
||||
fewShotExamplesCount: number;
|
||||
enableReflection: boolean;
|
||||
maxReflectionRounds: number;
|
||||
enableDebate: boolean;
|
||||
debateThreshold: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dev fallback (matches src/config/index.ts behavior when validation fails)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEV_FALLBACK_CONFIG: AppConfig = {
|
||||
gitea: {
|
||||
apiUrl: 'http://localhost:5174/api/v1',
|
||||
accessToken: 'test_token',
|
||||
},
|
||||
openai: {
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
apiKey: 'test_openai_key',
|
||||
model: 'gpt-4o-mini',
|
||||
customSummaryPrompt: undefined,
|
||||
customLineCommentPrompt: undefined,
|
||||
},
|
||||
feishu: {
|
||||
webhookUrl: '',
|
||||
webhookSecret: '',
|
||||
},
|
||||
app: {
|
||||
port: 5174,
|
||||
webhookSecret: 'test_webhook_secret',
|
||||
},
|
||||
admin: {
|
||||
password: 'password',
|
||||
jwtSecret: 'a-secure-secret-for-jwt',
|
||||
giteaAdminToken: undefined,
|
||||
},
|
||||
review: {
|
||||
engine: 'legacy',
|
||||
workdir: '/tmp/gitea-assistant',
|
||||
modelPlanner: 'gpt-4o-mini',
|
||||
modelSpecialist: 'gpt-4o-mini',
|
||||
modelJudge: 'gpt-4o-mini',
|
||||
maxParallelRuns: 2,
|
||||
maxFilesPerRun: 200,
|
||||
maxFileContentChars: 40_000,
|
||||
autoPublishMinConfidence: 0.8,
|
||||
enableHumanGate: true,
|
||||
allowedCommands: ['git', 'rg', 'cat', 'sed', 'wc'],
|
||||
commandTimeoutMs: 10000,
|
||||
qdrantUrl: undefined,
|
||||
enableMemory: false,
|
||||
fewShotExamplesCount: 10,
|
||||
enableReflection: false,
|
||||
maxReflectionRounds: 2,
|
||||
enableDebate: false,
|
||||
debateThreshold: 'high',
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConfigManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ConfigManager {
|
||||
private readonly overridesPath: string;
|
||||
private overrides: Record<string, string> = {};
|
||||
|
||||
constructor() {
|
||||
this.overridesPath = resolve(process.env.CONFIG_OVERRIDES_PATH || './config-overrides.json');
|
||||
this.loadOverridesSync();
|
||||
}
|
||||
|
||||
/** Synchronously load overrides at construction time (file is tiny). */
|
||||
private loadOverridesSync(): void {
|
||||
try {
|
||||
const text = readFileSync(this.overridesPath, 'utf-8');
|
||||
const data: OverridesFile = JSON.parse(text);
|
||||
if (data && typeof data.overrides === 'object' && data.overrides !== null) {
|
||||
this.overrides = { ...data.overrides };
|
||||
}
|
||||
} catch {
|
||||
// File missing or invalid JSON — start with empty overrides
|
||||
}
|
||||
}
|
||||
|
||||
// ── Override file I/O ────────────────────────────────────────────────────
|
||||
|
||||
/** Load overrides from disk. If file is missing or malformed, treat as empty. */
|
||||
async loadOverrides(): Promise<void> {
|
||||
try {
|
||||
const text = await readFile(this.overridesPath, 'utf-8');
|
||||
const data: OverridesFile = JSON.parse(text);
|
||||
if (data && typeof data.overrides === 'object' && data.overrides !== null) {
|
||||
this.overrides = { ...data.overrides };
|
||||
} else {
|
||||
this.overrides = {};
|
||||
}
|
||||
} catch {
|
||||
// File missing or invalid JSON — start with empty overrides
|
||||
this.overrides = {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist current overrides to disk atomically (write temp → rename). */
|
||||
private async persistOverrides(): Promise<void> {
|
||||
const dir = dirname(this.overridesPath);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const payload: OverridesFile = {
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
overrides: { ...this.overrides },
|
||||
};
|
||||
|
||||
const tmpPath = `${this.overridesPath}.${randomUUID()}.tmp`;
|
||||
await writeFile(tmpPath, JSON.stringify(payload, null, 2), 'utf-8');
|
||||
await rename(tmpPath, this.overridesPath);
|
||||
}
|
||||
|
||||
// ── Core API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the fully resolved config object with the same shape as the
|
||||
* default export of `src/config/index.ts`.
|
||||
*
|
||||
* Layering: Zod defaults → process.env → overrides JSON
|
||||
*/
|
||||
getCurrent(): AppConfig {
|
||||
// Build a merged env-like record: process.env overlaid with overrides
|
||||
const merged: Record<string, string | undefined> = {};
|
||||
for (const key of Object.keys(envSchema.shape)) {
|
||||
const envVal = process.env[key];
|
||||
if (envVal !== undefined && envVal !== '') {
|
||||
merged[key] = envVal;
|
||||
}
|
||||
// Override wins if present and non-empty
|
||||
const ov = this.overrides[key];
|
||||
if (ov !== undefined && ov !== '') {
|
||||
merged[key] = ov;
|
||||
}
|
||||
}
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV;
|
||||
|
||||
const parseResult = envSchema.safeParse(merged);
|
||||
|
||||
if (!parseResult.success) {
|
||||
if (!isDev) {
|
||||
throw new Error('Configuration validation error');
|
||||
}
|
||||
return DEV_FALLBACK_CONFIG;
|
||||
}
|
||||
|
||||
const env = parseResult.data;
|
||||
|
||||
return {
|
||||
gitea: {
|
||||
apiUrl: env.GITEA_API_URL,
|
||||
accessToken: env.GITEA_ACCESS_TOKEN,
|
||||
},
|
||||
openai: {
|
||||
baseUrl: env.OPENAI_BASE_URL,
|
||||
apiKey: env.OPENAI_API_KEY,
|
||||
model: env.OPENAI_MODEL,
|
||||
customSummaryPrompt: env.CUSTOM_SUMMARY_PROMPT,
|
||||
customLineCommentPrompt: env.CUSTOM_LINE_COMMENT_PROMPT,
|
||||
},
|
||||
feishu: {
|
||||
webhookUrl: env.FEISHU_WEBHOOK_URL,
|
||||
webhookSecret: env.FEISHU_WEBHOOK_SECRET,
|
||||
},
|
||||
app: {
|
||||
port: env.PORT,
|
||||
webhookSecret: env.WEBHOOK_SECRET,
|
||||
},
|
||||
admin: {
|
||||
password: env.ADMIN_PASSWORD,
|
||||
jwtSecret: env.JWT_SECRET,
|
||||
giteaAdminToken: env.GITEA_ADMIN_TOKEN,
|
||||
},
|
||||
review: {
|
||||
engine: env.REVIEW_ENGINE,
|
||||
workdir: env.REVIEW_WORKDIR,
|
||||
modelPlanner: env.REVIEW_MODEL_PLANNER,
|
||||
modelSpecialist: env.REVIEW_MODEL_SPECIALIST,
|
||||
modelJudge: env.REVIEW_MODEL_JUDGE,
|
||||
maxParallelRuns: env.REVIEW_MAX_PARALLEL_RUNS,
|
||||
maxFilesPerRun: env.REVIEW_MAX_FILES_PER_RUN,
|
||||
maxFileContentChars: env.REVIEW_MAX_FILE_CONTENT_CHARS,
|
||||
autoPublishMinConfidence: env.REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE,
|
||||
enableHumanGate: env.REVIEW_ENABLE_HUMAN_GATE,
|
||||
allowedCommands: env.REVIEW_ALLOWED_COMMANDS.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
commandTimeoutMs: env.REVIEW_COMMAND_TIMEOUT_MS,
|
||||
qdrantUrl: env.QDRANT_URL,
|
||||
enableMemory: env.ENABLE_MEMORY,
|
||||
fewShotExamplesCount: env.FEW_SHOT_EXAMPLES_COUNT,
|
||||
enableReflection: env.ENABLE_REFLECTION,
|
||||
maxReflectionRounds: env.MAX_REFLECTION_ROUNDS,
|
||||
enableDebate: env.ENABLE_DEBATE,
|
||||
debateThreshold: env.DEBATE_THRESHOLD,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Return raw overrides record. */
|
||||
getOverrides(): Record<string, string> {
|
||||
return { ...this.overrides };
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge updates into overrides and persist.
|
||||
* If a value is empty string `''`, that key is deleted (reset to lower layer).
|
||||
*/
|
||||
async setOverrides(updates: Record<string, string>): Promise<void> {
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value === '') {
|
||||
delete this.overrides[key];
|
||||
} else {
|
||||
this.overrides[key] = value;
|
||||
}
|
||||
}
|
||||
await this.persistOverrides();
|
||||
}
|
||||
|
||||
/** Remove specified keys from overrides and persist. */
|
||||
async resetKeys(keys: string[]): Promise<void> {
|
||||
for (const key of keys) {
|
||||
delete this.overrides[key];
|
||||
}
|
||||
await this.persistOverrides();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine where the effective value for a given env key comes from.
|
||||
*/
|
||||
getSource(envKey: string): 'default' | 'env' | 'override' {
|
||||
const ov = this.overrides[envKey];
|
||||
if (ov !== undefined && ov !== '') {
|
||||
return 'override';
|
||||
}
|
||||
const envVal = process.env[envKey];
|
||||
if (envVal !== undefined && envVal !== '') {
|
||||
return 'env';
|
||||
}
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Singleton
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const configManager = new ConfigManager();
|
||||
423
src/config/config-schema.ts
Normal file
423
src/config/config-schema.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* 配置字段元数据定义
|
||||
* 纯静态元数据,不读取任何环境变量。供后端 API 和前端 GUI 渲染/编辑配置使用。
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ConfigGroup = 'gitea' | 'openai' | 'feishu' | 'app' | 'admin' | 'review' | 'memory';
|
||||
|
||||
export type ConfigFieldType = 'string' | 'number' | 'boolean' | 'url' | 'text' | 'enum';
|
||||
|
||||
export interface ConfigFieldMeta {
|
||||
envKey: string;
|
||||
group: ConfigGroup;
|
||||
label: string;
|
||||
description: string;
|
||||
type: ConfigFieldType;
|
||||
sensitive: boolean;
|
||||
readonly?: boolean;
|
||||
readonlyWarning?: string;
|
||||
enumValues?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
defaultValue?: string | number | boolean;
|
||||
}
|
||||
|
||||
export interface ConfigGroupMeta {
|
||||
key: ConfigGroup;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Groups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const CONFIG_GROUPS: ConfigGroupMeta[] = [
|
||||
{
|
||||
key: 'gitea',
|
||||
label: 'Gitea 连接',
|
||||
description: 'Gitea 实例地址与访问令牌',
|
||||
icon: 'link',
|
||||
},
|
||||
{
|
||||
key: 'openai',
|
||||
label: 'OpenAI / LLM',
|
||||
description: 'AI 模型接口与自定义提示词',
|
||||
icon: 'bot',
|
||||
},
|
||||
{
|
||||
key: 'feishu',
|
||||
label: '飞书通知',
|
||||
description: '飞书 Webhook 通知配置',
|
||||
icon: 'bell',
|
||||
},
|
||||
{
|
||||
key: 'app',
|
||||
label: '应用',
|
||||
description: '服务端口与 Webhook 安全',
|
||||
icon: 'settings',
|
||||
},
|
||||
{
|
||||
key: 'admin',
|
||||
label: '管理后台',
|
||||
description: '后台登录密码与 JWT 密钥',
|
||||
icon: 'shield',
|
||||
},
|
||||
{
|
||||
key: 'review',
|
||||
label: '审查引擎',
|
||||
description: 'Agent 审查模式、并发与沙箱设置',
|
||||
icon: 'file-check',
|
||||
},
|
||||
{
|
||||
key: 'memory',
|
||||
label: '记忆与学习',
|
||||
description: '向量记忆、反思与辩论系统',
|
||||
icon: 'brain',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
// ── Gitea ───────────────────────────────────────────────────────────────
|
||||
{
|
||||
envKey: 'GITEA_API_URL',
|
||||
group: 'gitea',
|
||||
label: 'Gitea API 地址',
|
||||
description: 'Gitea 实例的 API 根路径',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
defaultValue: 'http://localhost:5174/api/v1',
|
||||
},
|
||||
{
|
||||
envKey: 'GITEA_ACCESS_TOKEN',
|
||||
group: 'gitea',
|
||||
label: '访问令牌',
|
||||
description: '用于代码审查的 Gitea 访问令牌(需要仓库读权限和评论权限)',
|
||||
type: 'string',
|
||||
sensitive: true,
|
||||
defaultValue: 'test_token',
|
||||
},
|
||||
{
|
||||
envKey: 'GITEA_ADMIN_TOKEN',
|
||||
group: 'gitea',
|
||||
label: '管理员令牌',
|
||||
description: '用于后台管理的 Gitea 管理员令牌(可选,需要仓库读写及 Webhook 管理权限)',
|
||||
type: 'string',
|
||||
sensitive: true,
|
||||
},
|
||||
|
||||
// ── OpenAI ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
envKey: 'OPENAI_BASE_URL',
|
||||
group: 'openai',
|
||||
label: 'API 地址',
|
||||
description: 'OpenAI 兼容 API 的基础 URL',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
defaultValue: 'https://api.openai.com/v1',
|
||||
},
|
||||
{
|
||||
envKey: 'OPENAI_API_KEY',
|
||||
group: 'openai',
|
||||
label: 'API 密钥',
|
||||
description: 'OpenAI API 密钥',
|
||||
type: 'string',
|
||||
sensitive: true,
|
||||
defaultValue: 'test_openai_key',
|
||||
},
|
||||
{
|
||||
envKey: 'OPENAI_MODEL',
|
||||
group: 'openai',
|
||||
label: '模型',
|
||||
description: '默认使用的 OpenAI 模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
defaultValue: 'gpt-4o-mini',
|
||||
},
|
||||
{
|
||||
envKey: 'CUSTOM_SUMMARY_PROMPT',
|
||||
group: 'openai',
|
||||
label: '自定义总结提示词',
|
||||
description: '覆盖默认的代码审查总结提示词(留空使用内置提示词)',
|
||||
type: 'text',
|
||||
sensitive: false,
|
||||
},
|
||||
{
|
||||
envKey: 'CUSTOM_LINE_COMMENT_PROMPT',
|
||||
group: 'openai',
|
||||
label: '自定义行评论提示词',
|
||||
description: '覆盖默认的行级评论提示词(留空使用内置提示词)',
|
||||
type: 'text',
|
||||
sensitive: false,
|
||||
},
|
||||
|
||||
// ── 飞书 ────────────────────────────────────────────────────────────────
|
||||
{
|
||||
envKey: 'FEISHU_WEBHOOK_URL',
|
||||
group: 'feishu',
|
||||
label: 'Webhook 地址',
|
||||
description: '飞书机器人 Webhook URL',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
},
|
||||
{
|
||||
envKey: 'FEISHU_WEBHOOK_SECRET',
|
||||
group: 'feishu',
|
||||
label: 'Webhook 签名密钥',
|
||||
description: '飞书 Webhook 签名密钥(可选)',
|
||||
type: 'string',
|
||||
sensitive: true,
|
||||
},
|
||||
|
||||
// ── 应用 ────────────────────────────────────────────────────────────────
|
||||
{
|
||||
envKey: 'PORT',
|
||||
group: 'app',
|
||||
label: '监听端口',
|
||||
description: '服务监听的 HTTP 端口号,修改需通过 .env 配置并重启服务',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
readonly: true,
|
||||
defaultValue: 5174,
|
||||
},
|
||||
{
|
||||
envKey: 'WEBHOOK_SECRET',
|
||||
group: 'app',
|
||||
label: 'Webhook 密钥',
|
||||
description:
|
||||
'用于验证 Gitea Webhook 请求来源的 HMAC 密钥,修改需通过 .env 配置并同步更新 Gitea',
|
||||
type: 'string',
|
||||
sensitive: true,
|
||||
readonly: true,
|
||||
defaultValue: 'test_webhook_secret',
|
||||
},
|
||||
|
||||
// ── 管理后台 ────────────────────────────────────────────────────────────
|
||||
{
|
||||
envKey: 'ADMIN_PASSWORD',
|
||||
group: 'admin',
|
||||
label: '管理员密码',
|
||||
description: '后台管理界面的登录密码',
|
||||
type: 'string',
|
||||
sensitive: true,
|
||||
readonlyWarning: '修改后当前登录会话可能失效',
|
||||
defaultValue: 'password',
|
||||
},
|
||||
{
|
||||
envKey: 'JWT_SECRET',
|
||||
group: 'admin',
|
||||
label: 'JWT 密钥',
|
||||
description: '用于签发后台登录 Token 的密钥,修改需通过 .env 配置',
|
||||
type: 'string',
|
||||
sensitive: true,
|
||||
readonly: true,
|
||||
defaultValue: 'a-secure-secret-for-jwt',
|
||||
},
|
||||
|
||||
// ── 审查引擎 ────────────────────────────────────────────────────────────
|
||||
{
|
||||
envKey: 'REVIEW_ENGINE',
|
||||
group: 'review',
|
||||
label: '审查引擎',
|
||||
description: '代码审查模式:legacy(传统)或 agent(多代理编排)',
|
||||
type: 'enum',
|
||||
sensitive: false,
|
||||
enumValues: ['legacy', 'agent'],
|
||||
defaultValue: 'legacy',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_WORKDIR',
|
||||
group: 'review',
|
||||
label: '工作目录',
|
||||
description: 'Agent 模式下本地仓库 mirror/worktree 的工作目录',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
defaultValue: '/tmp/gitea-assistant',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_MODEL_PLANNER',
|
||||
group: 'review',
|
||||
label: '规划模型',
|
||||
description: 'Agent 模式下规划阶段使用的模型',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
defaultValue: 'gpt-4o-mini',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_MODEL_SPECIALIST',
|
||||
group: 'review',
|
||||
label: '专家模型',
|
||||
description: 'Agent 模式下专家子代理使用的模型',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
defaultValue: 'gpt-4o-mini',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_MODEL_JUDGE',
|
||||
group: 'review',
|
||||
label: '评审模型',
|
||||
description: 'Agent 模式下 Judge 聚合阶段使用的模型',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
defaultValue: 'gpt-4o-mini',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_MAX_PARALLEL_RUNS',
|
||||
group: 'review',
|
||||
label: '最大并发数',
|
||||
description: '单机同时执行的审查任务上限',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 1,
|
||||
max: 8,
|
||||
defaultValue: 2,
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_MAX_FILES_PER_RUN',
|
||||
group: 'review',
|
||||
label: '单次最大文件数',
|
||||
description: '单次审查最多处理的文件数量',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 1,
|
||||
max: 1000,
|
||||
defaultValue: 200,
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_MAX_FILE_CONTENT_CHARS',
|
||||
group: 'review',
|
||||
label: '单文件最大字符数',
|
||||
description: '单个文件上下文的最大字符数',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 1000,
|
||||
max: 1000000,
|
||||
defaultValue: 40000,
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE',
|
||||
group: 'review',
|
||||
label: '自动发布置信度',
|
||||
description: '自动发布评论所需的最小置信度(0~1)',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 0,
|
||||
max: 1,
|
||||
defaultValue: 0.8,
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_ENABLE_HUMAN_GATE',
|
||||
group: 'review',
|
||||
label: '人工审批',
|
||||
description: '是否启用人工审批队列(低置信度评论需人工确认后发布)',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_ALLOWED_COMMANDS',
|
||||
group: 'review',
|
||||
label: '允许命令',
|
||||
description: '本地审查沙箱中允许执行的命令白名单(逗号分隔)',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
defaultValue: 'git,rg,cat,sed,wc',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_COMMAND_TIMEOUT_MS',
|
||||
group: 'review',
|
||||
label: '命令超时(ms)',
|
||||
description: '单条本地命令的执行超时时间(毫秒)',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 1000,
|
||||
max: 300000,
|
||||
defaultValue: 10000,
|
||||
},
|
||||
|
||||
// ── 记忆与学习 ──────────────────────────────────────────────────────────
|
||||
{
|
||||
envKey: 'QDRANT_URL',
|
||||
group: 'memory',
|
||||
label: 'Qdrant 地址',
|
||||
description: 'Qdrant 向量数据库的连接 URL',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
},
|
||||
{
|
||||
envKey: 'ENABLE_MEMORY',
|
||||
group: 'memory',
|
||||
label: '启用记忆',
|
||||
description: '是否启用向量记忆系统(需配置 Qdrant)',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
envKey: 'FEW_SHOT_EXAMPLES_COUNT',
|
||||
group: 'memory',
|
||||
label: 'Few-shot 示例数',
|
||||
description: '检索的 few-shot 示例数量',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 0,
|
||||
max: 20,
|
||||
defaultValue: 10,
|
||||
},
|
||||
{
|
||||
envKey: 'ENABLE_REFLECTION',
|
||||
group: 'memory',
|
||||
label: '启用反思',
|
||||
description: '是否启用审查结果自我反思机制',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
envKey: 'MAX_REFLECTION_ROUNDS',
|
||||
group: 'memory',
|
||||
label: '最大反思轮数',
|
||||
description: '反思迭代的最大轮数',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 1,
|
||||
max: 5,
|
||||
defaultValue: 2,
|
||||
},
|
||||
{
|
||||
envKey: 'ENABLE_DEBATE',
|
||||
group: 'memory',
|
||||
label: '启用辩论',
|
||||
description: '是否启用多视角辩论机制',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
envKey: 'DEBATE_THRESHOLD',
|
||||
group: 'memory',
|
||||
label: '辩论阈值',
|
||||
description: '触发辩论的严重程度阈值',
|
||||
type: 'enum',
|
||||
sensitive: false,
|
||||
enumValues: ['high', 'medium'],
|
||||
defaultValue: 'high',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getFieldsByGroup(group: ConfigGroup): ConfigFieldMeta[] {
|
||||
return CONFIG_FIELDS.filter((f) => f.group === group);
|
||||
}
|
||||
@@ -1,77 +1,12 @@
|
||||
import { config } from 'dotenv';
|
||||
import { z } from 'zod';
|
||||
import { configManager } from './config-manager';
|
||||
|
||||
// 加载环境变量
|
||||
config();
|
||||
type AppConfig = import('./config-manager').AppConfig;
|
||||
|
||||
// 判断是否为开发环境
|
||||
const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV;
|
||||
|
||||
// 环境变量验证模式
|
||||
const envSchema = z.object({
|
||||
// Gitea配置
|
||||
GITEA_API_URL: z.string().url().default('http://localhost:5174/api/v1'),
|
||||
GITEA_ACCESS_TOKEN: z.string().default('test_token'),
|
||||
GITEA_ADMIN_TOKEN: z.string().optional(),
|
||||
|
||||
// OpenAI配置
|
||||
OPENAI_BASE_URL: z.string().url().default('https://api.openai.com/v1'),
|
||||
OPENAI_API_KEY: z.string().default('test_openai_key'),
|
||||
OPENAI_MODEL: z.string().default('gpt-4o-mini'),
|
||||
CUSTOM_SUMMARY_PROMPT: z.string().optional(),
|
||||
CUSTOM_LINE_COMMENT_PROMPT: z.string().optional(),
|
||||
|
||||
// 飞书配置
|
||||
FEISHU_WEBHOOK_URL: z.string().url(),
|
||||
FEISHU_WEBHOOK_SECRET: z.string().optional(),
|
||||
|
||||
// 应用配置
|
||||
PORT: z.string().transform(Number).default('5174'),
|
||||
WEBHOOK_SECRET: z.string().default('test_webhook_secret'),
|
||||
|
||||
// 管理后台配置
|
||||
ADMIN_PASSWORD: z.string().default('password'),
|
||||
JWT_SECRET: z.string().default('a-secure-secret-for-jwt'),
|
||||
const config = new Proxy({} as AppConfig, {
|
||||
get(_target, prop) {
|
||||
return configManager.getCurrent()[prop as keyof AppConfig];
|
||||
},
|
||||
});
|
||||
|
||||
// 处理验证结果
|
||||
const envParseResult = envSchema.safeParse(process.env);
|
||||
|
||||
if (!envParseResult.success) {
|
||||
console.error('❌ 环境变量验证失败:');
|
||||
console.error(envParseResult.error.format());
|
||||
|
||||
if (isDev) {
|
||||
console.warn('⚠️ 使用开发环境默认值');
|
||||
} else {
|
||||
throw new Error('环境变量配置错误');
|
||||
}
|
||||
}
|
||||
|
||||
// 导出配置
|
||||
export default {
|
||||
gitea: {
|
||||
apiUrl: envParseResult.success ? envParseResult.data.GITEA_API_URL : 'http://localhost:5174/api/v1',
|
||||
accessToken: envParseResult.success ? envParseResult.data.GITEA_ACCESS_TOKEN : 'test_token',
|
||||
},
|
||||
openai: {
|
||||
baseUrl: envParseResult.success ? envParseResult.data.OPENAI_BASE_URL : 'https://api.openai.com/v1',
|
||||
apiKey: envParseResult.success ? envParseResult.data.OPENAI_API_KEY : 'test_openai_key',
|
||||
model: envParseResult.success ? envParseResult.data.OPENAI_MODEL : 'gpt-4o-mini',
|
||||
customSummaryPrompt: envParseResult.success ? envParseResult.data.CUSTOM_SUMMARY_PROMPT : undefined,
|
||||
customLineCommentPrompt: envParseResult.success ? envParseResult.data.CUSTOM_LINE_COMMENT_PROMPT : undefined,
|
||||
},
|
||||
feishu: {
|
||||
webhookUrl: envParseResult.success ? envParseResult.data.FEISHU_WEBHOOK_URL : '',
|
||||
webhookSecret: envParseResult.success ? envParseResult.data.FEISHU_WEBHOOK_SECRET : '',
|
||||
},
|
||||
app: {
|
||||
port: envParseResult.success ? envParseResult.data.PORT : 5174,
|
||||
webhookSecret: envParseResult.success ? envParseResult.data.WEBHOOK_SECRET : 'test_webhook_secret',
|
||||
},
|
||||
admin: {
|
||||
password: envParseResult.success ? envParseResult.data.ADMIN_PASSWORD : 'password',
|
||||
jwtSecret: envParseResult.success ? envParseResult.data.JWT_SECRET : 'a-secure-secret-for-jwt',
|
||||
giteaAdminToken: envParseResult.success ? envParseResult.data.GITEA_ADMIN_TOKEN : undefined,
|
||||
},
|
||||
};
|
||||
export { configManager };
|
||||
export default config;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Hono } from 'hono';
|
||||
import { sign } from 'hono/jwt';
|
||||
import config from '@/config';
|
||||
import { giteaService } from '@/services/gitea';
|
||||
import { logger } from '@/utils/logger';
|
||||
import config from '../config';
|
||||
import { reviewEngine } from '../review/engine';
|
||||
import { giteaService } from '../services/gitea';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const publicRoutes = new Hono();
|
||||
const protectedRoutes = new Hono();
|
||||
@@ -25,13 +26,12 @@ publicRoutes.post('/login', async (c) => {
|
||||
return c.json({ message: 'Invalid credentials' }, 401);
|
||||
});
|
||||
|
||||
|
||||
// --- Protected Routes ---
|
||||
|
||||
// 获取仓库列表及 Webhook 状态
|
||||
protectedRoutes.get('/repositories', async (c) => {
|
||||
try {
|
||||
const page = parseInt(c.req.query('page') || '1', 10);
|
||||
const page = Number.parseInt(c.req.query('page') || '1', 10);
|
||||
const query = c.req.query('q');
|
||||
const limit = 30; // 每页数量固定,或也可从查询参数获取
|
||||
|
||||
@@ -42,7 +42,7 @@ protectedRoutes.get('/repositories', async (c) => {
|
||||
repos.map(async (repo) => {
|
||||
const [owner, repoName] = repo.full_name.split('/');
|
||||
const hooks = await giteaService.listWebhooks(owner, repoName);
|
||||
const webhook = hooks.find(h => h.config.url === webhookUrl);
|
||||
const webhook = hooks.find((h) => h.config.url === webhookUrl);
|
||||
return {
|
||||
name: repo.full_name,
|
||||
webhook_status: webhook ? 'active' : 'inactive',
|
||||
@@ -66,7 +66,7 @@ protectedRoutes.get('/repositories', async (c) => {
|
||||
// 创建 Webhook
|
||||
protectedRoutes.post('/repositories/:owner/:repo/webhook', async (c) => {
|
||||
const { owner, repo } = c.req.param();
|
||||
const webhookUrl = new URL(c.req.url).origin + '/webhook/gitea';
|
||||
const webhookUrl = `${new URL(c.req.url).origin}/webhook/gitea`;
|
||||
|
||||
try {
|
||||
await giteaService.createWebhook(owner, repo, webhookUrl);
|
||||
@@ -82,7 +82,7 @@ protectedRoutes.delete('/repositories/:owner/:repo/webhook/:hookId', async (c) =
|
||||
const { owner, repo, hookId } = c.req.param();
|
||||
|
||||
try {
|
||||
await giteaService.deleteWebhook(owner, repo, parseInt(hookId, 10));
|
||||
await giteaService.deleteWebhook(owner, repo, Number.parseInt(hookId, 10));
|
||||
return c.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error(`删除 ${owner}/${repo} 的 Webhook 失败:`, error);
|
||||
@@ -90,6 +90,33 @@ protectedRoutes.delete('/repositories/:owner/:repo/webhook/:hookId', async (c) =
|
||||
}
|
||||
});
|
||||
|
||||
// 查询审查任务
|
||||
protectedRoutes.get('/review/runs', async (c) => {
|
||||
try {
|
||||
const limit = Number.parseInt(c.req.query('limit') || '50', 10);
|
||||
const runs = await reviewEngine.listRuns(limit);
|
||||
return c.json({ data: runs });
|
||||
} catch (error: any) {
|
||||
logger.error('获取审查任务列表失败:', error);
|
||||
return c.json({ message: 'Failed to fetch review runs', error: error.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// 查询审查任务详情
|
||||
protectedRoutes.get('/review/runs/:runId', async (c) => {
|
||||
try {
|
||||
const { runId } = c.req.param();
|
||||
const result = await reviewEngine.getRunDetails(runId);
|
||||
if (!result) {
|
||||
return c.json({ message: 'Run not found' }, 404);
|
||||
}
|
||||
return c.json(result);
|
||||
} catch (error: any) {
|
||||
logger.error('获取审查任务详情失败:', error);
|
||||
return c.json({ message: 'Failed to fetch review run details', error: error.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export const adminController = {
|
||||
publicRoutes,
|
||||
protectedRoutes,
|
||||
|
||||
301
src/controllers/config.ts
Normal file
301
src/controllers/config.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { Hono } from 'hono';
|
||||
import { type AppConfig, configManager } from '../config/config-manager';
|
||||
import { CONFIG_FIELDS, CONFIG_GROUPS, type ConfigFieldMeta } from '../config/config-schema';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const MASKED_VALUE = '••••••••';
|
||||
|
||||
/** Number fields that must be integers (decimal not allowed). */
|
||||
const INTEGER_FIELDS = new Set([
|
||||
'PORT',
|
||||
'REVIEW_MAX_PARALLEL_RUNS',
|
||||
'REVIEW_MAX_FILES_PER_RUN',
|
||||
'REVIEW_MAX_FILE_CONTENT_CHARS',
|
||||
'REVIEW_COMMAND_TIMEOUT_MS',
|
||||
'FEW_SHOT_EXAMPLES_COUNT',
|
||||
'MAX_REFLECTION_ROUNDS',
|
||||
]);
|
||||
|
||||
/** Fast lookup from envKey → field metadata. */
|
||||
const FIELDS_MAP = new Map<string, ConfigFieldMeta>(CONFIG_FIELDS.map((f) => [f.envKey, f]));
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map an envKey to its effective value from the resolved AppConfig.
|
||||
* Explicit switch — no dynamic property access.
|
||||
*/
|
||||
function getEffectiveValue(
|
||||
envKey: string,
|
||||
current: AppConfig
|
||||
): string | number | boolean | undefined {
|
||||
switch (envKey) {
|
||||
// Gitea
|
||||
case 'GITEA_API_URL':
|
||||
return current.gitea.apiUrl;
|
||||
case 'GITEA_ACCESS_TOKEN':
|
||||
return current.gitea.accessToken;
|
||||
case 'GITEA_ADMIN_TOKEN':
|
||||
return current.admin.giteaAdminToken;
|
||||
// OpenAI
|
||||
case 'OPENAI_BASE_URL':
|
||||
return current.openai.baseUrl;
|
||||
case 'OPENAI_API_KEY':
|
||||
return current.openai.apiKey;
|
||||
case 'OPENAI_MODEL':
|
||||
return current.openai.model;
|
||||
case 'CUSTOM_SUMMARY_PROMPT':
|
||||
return current.openai.customSummaryPrompt;
|
||||
case 'CUSTOM_LINE_COMMENT_PROMPT':
|
||||
return current.openai.customLineCommentPrompt;
|
||||
// Feishu
|
||||
case 'FEISHU_WEBHOOK_URL':
|
||||
return current.feishu.webhookUrl;
|
||||
case 'FEISHU_WEBHOOK_SECRET':
|
||||
return current.feishu.webhookSecret;
|
||||
// App
|
||||
case 'PORT':
|
||||
return current.app.port;
|
||||
case 'WEBHOOK_SECRET':
|
||||
return current.app.webhookSecret;
|
||||
// Admin
|
||||
case 'ADMIN_PASSWORD':
|
||||
return current.admin.password;
|
||||
case 'JWT_SECRET':
|
||||
return current.admin.jwtSecret;
|
||||
// Review
|
||||
case 'REVIEW_ENGINE':
|
||||
return current.review.engine;
|
||||
case 'REVIEW_WORKDIR':
|
||||
return current.review.workdir;
|
||||
case 'REVIEW_MODEL_PLANNER':
|
||||
return current.review.modelPlanner;
|
||||
case 'REVIEW_MODEL_SPECIALIST':
|
||||
return current.review.modelSpecialist;
|
||||
case 'REVIEW_MODEL_JUDGE':
|
||||
return current.review.modelJudge;
|
||||
case 'REVIEW_MAX_PARALLEL_RUNS':
|
||||
return current.review.maxParallelRuns;
|
||||
case 'REVIEW_MAX_FILES_PER_RUN':
|
||||
return current.review.maxFilesPerRun;
|
||||
case 'REVIEW_MAX_FILE_CONTENT_CHARS':
|
||||
return current.review.maxFileContentChars;
|
||||
case 'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE':
|
||||
return current.review.autoPublishMinConfidence;
|
||||
case 'REVIEW_ENABLE_HUMAN_GATE':
|
||||
return current.review.enableHumanGate;
|
||||
case 'REVIEW_ALLOWED_COMMANDS':
|
||||
return current.review.allowedCommands.join(',');
|
||||
case 'REVIEW_COMMAND_TIMEOUT_MS':
|
||||
return current.review.commandTimeoutMs;
|
||||
// Memory
|
||||
case 'QDRANT_URL':
|
||||
return current.review.qdrantUrl;
|
||||
case 'ENABLE_MEMORY':
|
||||
return current.review.enableMemory;
|
||||
case 'FEW_SHOT_EXAMPLES_COUNT':
|
||||
return current.review.fewShotExamplesCount;
|
||||
case 'ENABLE_REFLECTION':
|
||||
return current.review.enableReflection;
|
||||
case 'MAX_REFLECTION_ROUNDS':
|
||||
return current.review.maxReflectionRounds;
|
||||
case 'ENABLE_DEBATE':
|
||||
return current.review.enableDebate;
|
||||
case 'DEBATE_THRESHOLD':
|
||||
return current.review.debateThreshold;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single field value against its metadata.
|
||||
* Returns an error message string, or `null` if valid.
|
||||
*/
|
||||
function validateField(field: ConfigFieldMeta, key: string, value: string): string | null {
|
||||
switch (field.type) {
|
||||
case 'url': {
|
||||
try {
|
||||
new URL(value);
|
||||
} catch {
|
||||
return `${field.label}(${key})必须是有效的 URL`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case 'enum': {
|
||||
if (field.enumValues && !field.enumValues.includes(value)) {
|
||||
return `${field.label}(${key})必须是以下值之一: ${field.enumValues.join(', ')}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case 'boolean': {
|
||||
if (value !== 'true' && value !== 'false') {
|
||||
return `${field.label}(${key})必须是布尔值`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case 'number': {
|
||||
const num = Number(value);
|
||||
if (Number.isNaN(num)) {
|
||||
return `${field.label}(${key})必须是有效的数字`;
|
||||
}
|
||||
if (INTEGER_FIELDS.has(key) && !Number.isInteger(num)) {
|
||||
return `${field.label}(${key})必须是整数`;
|
||||
}
|
||||
if (field.min !== undefined && num < field.min) {
|
||||
return `${field.label}(${key})不能小于 ${field.min}`;
|
||||
}
|
||||
if (field.max !== undefined && num > field.max) {
|
||||
return `${field.label}(${key})不能大于 ${field.max}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
// string, text — no special validation
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Router ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const configRouter = new Hono();
|
||||
|
||||
/**
|
||||
* GET / — Return all configuration groups, fields with metadata,
|
||||
* effective values, and source. Sensitive fields are masked.
|
||||
*/
|
||||
configRouter.get('/', (c) => {
|
||||
const current = configManager.getCurrent();
|
||||
|
||||
const groups = CONFIG_GROUPS.map((group) => {
|
||||
const groupFields = CONFIG_FIELDS.filter((f) => f.group === group.key);
|
||||
|
||||
const fields = groupFields.map((field) => {
|
||||
const rawValue = getEffectiveValue(field.envKey, current);
|
||||
const hasValue = rawValue !== undefined && rawValue !== '';
|
||||
const source = configManager.getSource(field.envKey);
|
||||
const value = field.sensitive && hasValue ? MASKED_VALUE : rawValue;
|
||||
|
||||
return {
|
||||
envKey: field.envKey,
|
||||
label: field.label,
|
||||
description: field.description,
|
||||
type: field.type,
|
||||
sensitive: field.sensitive,
|
||||
...(field.readonly && { readonly: true }),
|
||||
...(field.readonlyWarning !== undefined && { readonlyWarning: field.readonlyWarning }),
|
||||
...(field.enumValues !== undefined && { enumValues: field.enumValues }),
|
||||
...(field.min !== undefined && { min: field.min }),
|
||||
...(field.max !== undefined && { max: field.max }),
|
||||
...(field.defaultValue !== undefined && { defaultValue: field.defaultValue }),
|
||||
value,
|
||||
hasValue,
|
||||
source,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
key: group.key,
|
||||
label: group.label,
|
||||
description: group.description,
|
||||
icon: group.icon,
|
||||
fields,
|
||||
};
|
||||
});
|
||||
|
||||
return c.json({ groups });
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT / — Validate and persist override updates.
|
||||
* Masked sentinel ('••••••••') for sensitive fields is silently skipped.
|
||||
* Empty string '' causes the key to be reset (deleted from overrides).
|
||||
*/
|
||||
configRouter.put('/', async (c) => {
|
||||
try {
|
||||
const body = await c.req.json<Record<string, unknown>>();
|
||||
|
||||
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||
return c.json({ message: '保存配置失败', error: '请求体必须是 JSON 对象' }, 400);
|
||||
}
|
||||
|
||||
const updates: Record<string, string> = {};
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const [key, rawValue] of Object.entries(body)) {
|
||||
const field = FIELDS_MAP.get(key);
|
||||
|
||||
if (!field) {
|
||||
errors.push(`未知配置项: ${key}`);
|
||||
continue;
|
||||
}
|
||||
// Reject readonly fields
|
||||
if (field.readonly) {
|
||||
errors.push(`${field.label}(${key})为只读配置,无法通过 GUI 修改`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = String(rawValue ?? '');
|
||||
|
||||
// Skip masked sentinel for sensitive fields — do not overwrite with mask
|
||||
if (field.sensitive && value === MASKED_VALUE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Empty string → reset (ConfigManager deletes the key)
|
||||
if (value === '') {
|
||||
updates[key] = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldError = validateField(field, key, value);
|
||||
if (fieldError) {
|
||||
errors.push(fieldError);
|
||||
continue;
|
||||
}
|
||||
|
||||
updates[key] = value;
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return c.json({ message: '保存配置失败', error: errors.join('; ') }, 400);
|
||||
}
|
||||
|
||||
await configManager.setOverrides(updates);
|
||||
return c.json({ success: true, message: '配置已保存' });
|
||||
} catch (error: unknown) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error('保存配置失败:', error);
|
||||
return c.json({ message: '保存配置失败', error: errMsg }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /reset — Remove specified keys from overrides (revert to env / default).
|
||||
*/
|
||||
configRouter.post('/reset', async (c) => {
|
||||
try {
|
||||
const { keys } = await c.req.json<{ keys: unknown }>();
|
||||
|
||||
if (!Array.isArray(keys) || !keys.every((k): k is string => typeof k === 'string')) {
|
||||
return c.json({ message: '保存配置失败', error: 'keys 必须是字符串数组' }, 400);
|
||||
}
|
||||
|
||||
const unknownKeys = keys.filter((k) => !FIELDS_MAP.has(k));
|
||||
if (unknownKeys.length > 0) {
|
||||
return c.json(
|
||||
{ message: '保存配置失败', error: `未知配置项: ${unknownKeys.join(', ')}` },
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
await configManager.resetKeys(keys);
|
||||
return c.json({ success: true, message: '配置已重置' });
|
||||
} catch (error: unknown) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error('重置配置失败:', error);
|
||||
return c.json({ message: '保存配置失败', error: errMsg }, 500);
|
||||
}
|
||||
});
|
||||
295
src/controllers/feedback.ts
Normal file
295
src/controllers/feedback.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { Hono } from 'hono';
|
||||
import OpenAI from 'openai';
|
||||
import { z } from 'zod';
|
||||
import config from '../config';
|
||||
import { LearningSystem } from '../review/learning/learning-system';
|
||||
import { VectorMemoryStore } from '../review/memory/vector-store';
|
||||
import { FileReviewStore } from '../review/store/file-review-store';
|
||||
import { giteaService } from '../services/gitea';
|
||||
|
||||
const feedbackRouter = new Hono();
|
||||
|
||||
// 全局实例
|
||||
let memoryStore: VectorMemoryStore | null = null;
|
||||
let learningSystem: LearningSystem | null = null;
|
||||
let reviewStore: FileReviewStore | null = null;
|
||||
|
||||
// 初始化反馈系统(记忆系统可选)
|
||||
export function initializeFeedbackSystem(openaiClient: OpenAI, store: FileReviewStore): void {
|
||||
// 保存store实例以供handlers重用,避免多实例状态不同步
|
||||
reviewStore = store;
|
||||
|
||||
// 记忆系统为可选功能
|
||||
if (config.review.qdrantUrl && config.review.enableMemory) {
|
||||
memoryStore = new VectorMemoryStore(config.review.qdrantUrl, openaiClient);
|
||||
learningSystem = new LearningSystem(memoryStore, reviewStore);
|
||||
|
||||
memoryStore.initialize().catch((err) => {
|
||||
console.error('Failed to initialize memory store:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 提交人工反馈
|
||||
feedbackRouter.post(
|
||||
'/finding/:findingId',
|
||||
zValidator(
|
||||
'json',
|
||||
z.object({
|
||||
approved: z.boolean().describe('是否批准该finding'),
|
||||
reason: z.string().optional().describe('反馈原因'),
|
||||
reviewer: z.string().optional().describe('审查者'),
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { findingId } = c.req.param();
|
||||
const { approved, reason } = c.req.valid('json');
|
||||
|
||||
if (!reviewStore) {
|
||||
return c.json({ error: 'Feedback system not initialized' }, 503);
|
||||
}
|
||||
|
||||
// 重用已初始化的store实例,避免多实例状态不同步
|
||||
const finding = await reviewStore.getFinding(findingId);
|
||||
|
||||
if (!finding) {
|
||||
return c.json({ error: 'Finding not found' }, 404);
|
||||
}
|
||||
|
||||
// 获取run信息以获取owner和repo
|
||||
const runDetails = await reviewStore.getRunDetails(finding.runId);
|
||||
if (!runDetails) {
|
||||
return c.json({ error: 'Run not found' }, 404);
|
||||
}
|
||||
|
||||
const { owner, repo } = runDetails.run;
|
||||
|
||||
// 原子幂等性保护:先标记finding为published(原子check-and-set)
|
||||
// 只有第一个请求会得到true,后续并发/重试请求会得到false
|
||||
// 这解决了read-check-write竞态:两个并发请求不会都发布评论
|
||||
const wasUnpublished = await reviewStore.markFindingPublished(
|
||||
finding.runId,
|
||||
finding.fingerprint
|
||||
);
|
||||
|
||||
if (!wasUnpublished) {
|
||||
// finding已被标记为published,但需验证是否真的发布成功
|
||||
// 场景:并发请求A正在发布时请求B到达,或请求A发布失败回滚后请求B重试
|
||||
// 检查是否存在已发布的comment记录来确认真实状态
|
||||
// 关键:必须通过fingerprint匹配,而非仅path+line,以区分同一位置的不同findings
|
||||
const publishedComment = runDetails.comments.find(
|
||||
(c) => c.status === 'published' && c.fingerprint === finding.fingerprint
|
||||
);
|
||||
|
||||
if (publishedComment) {
|
||||
// 确认已成功发布到Gitea(存在published comment record),返回幂等成功
|
||||
return c.json({
|
||||
success: true,
|
||||
message: '该finding已处理过',
|
||||
alreadyProcessed: true,
|
||||
learningApplied: false,
|
||||
published: true,
|
||||
});
|
||||
}
|
||||
// published标记存在但无published comment记录
|
||||
// 可能原因:1) 并发请求正在发布中 2) 之前发布失败并回滚
|
||||
// 不能声称成功,返回错误让用户稍后重试
|
||||
return c.json(
|
||||
{
|
||||
error: 'Finding approval in progress or previously failed. Please retry in a moment.',
|
||||
inProgress: true,
|
||||
},
|
||||
409
|
||||
); // 409 Conflict
|
||||
}
|
||||
|
||||
// 以下代码只会被第一个请求执行(wasUnpublished=true)
|
||||
|
||||
let learningApplied = false;
|
||||
|
||||
// 如果记忆系统启用,尝试执行学习和向量存储(可选功能,失败不阻止审批流程)
|
||||
if (memoryStore && learningSystem) {
|
||||
try {
|
||||
await memoryStore.storeFeedback(findingId, approved, reason || '', owner, repo);
|
||||
|
||||
if (approved) {
|
||||
await learningSystem.learnFromApproval(finding, owner, repo);
|
||||
} else {
|
||||
await learningSystem.learnFromFalsePositive(
|
||||
finding,
|
||||
reason || '人工标记为误报',
|
||||
owner,
|
||||
repo
|
||||
);
|
||||
}
|
||||
|
||||
learningApplied = true;
|
||||
} catch (memoryError) {
|
||||
// 记忆系统故障不应阻止人工审批操作
|
||||
console.error('Memory system operation failed (non-fatal):', memoryError);
|
||||
learningApplied = false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 如果批准,发布到Gitea(人工审批通过的问题应该通知开发者)
|
||||
if (approved) {
|
||||
const comment = `## 🔍 AI代码审查问题(人工确认)
|
||||
|
||||
**${finding.title}**
|
||||
|
||||
严重程度: ${finding.severity}
|
||||
置信度: ${(finding.confidence * 100).toFixed(0)}%
|
||||
|
||||
${finding.detail}
|
||||
|
||||
${finding.evidence ? `**证据:**\n\`\`\`\n${finding.evidence}\n\`\`\`` : ''}
|
||||
|
||||
${finding.suggestion ? `**建议:**\n${finding.suggestion}` : ''}
|
||||
|
||||
---
|
||||
_此问题已通过人工审批确认_`;
|
||||
|
||||
// 关键:区分Gitea发布失败和本地store失败,避免重复发布
|
||||
// 1. 先发布到Gitea,失败则回滚published标记
|
||||
// 2. 再写本地record,失败不回滚(因为Gitea已成功,重试不应重复发布)
|
||||
try {
|
||||
if (runDetails.run.eventType === 'pull_request' && runDetails.run.prNumber) {
|
||||
await giteaService.addPullRequestComment(owner, repo, runDetails.run.prNumber, comment);
|
||||
} else if (runDetails.run.commitSha) {
|
||||
await giteaService.addCommitComment(owner, repo, runDetails.run.commitSha, comment);
|
||||
}
|
||||
} catch (giteaError) {
|
||||
// Gitea API失败:回滚published状态,允许用户重试发布
|
||||
await reviewStore.unmarkFindingPublished(finding.runId, finding.fingerprint);
|
||||
throw giteaError;
|
||||
}
|
||||
|
||||
// Gitea发布成功,写入本地record
|
||||
// 关键权衡:如果record写入失败,必须回滚published标记以保持可恢复性
|
||||
// 代价:立即重试可能导致重复Gitea评论(罕见边缘情况,优于永久卡死)
|
||||
try {
|
||||
await reviewStore.addCommentRecord({
|
||||
runId: finding.runId,
|
||||
status: 'published',
|
||||
body: comment,
|
||||
path: finding.path,
|
||||
line: finding.line,
|
||||
fingerprint: finding.fingerprint,
|
||||
});
|
||||
} catch (storeError) {
|
||||
// 本地store失败:回滚published标记,允许用户重试
|
||||
// 如果用户立即重试,可能导致重复Gitea评论(可接受的权衡以避免永久卡死)
|
||||
console.error(
|
||||
'Failed to persist comment record after successful Gitea publish, rolling back:',
|
||||
storeError
|
||||
);
|
||||
await reviewStore.unmarkFindingPublished(finding.runId, finding.fingerprint);
|
||||
throw new Error(
|
||||
'Comment published to Gitea but failed to save locally. State rolled back, you may retry. Note: immediate retry may create duplicate comments.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 拒绝(标记为误报):创建comment record以标记处理完成
|
||||
// 不发布到Gitea,但需要记录以使重试请求能识别已处理
|
||||
// 如果写入失败,回滚published标记以允许重试
|
||||
try {
|
||||
await reviewStore.addCommentRecord({
|
||||
runId: finding.runId,
|
||||
status: 'published',
|
||||
body: `REJECTED: ${finding.title} - ${reason || '人工标记为误报'}`,
|
||||
path: finding.path,
|
||||
line: finding.line,
|
||||
fingerprint: finding.fingerprint,
|
||||
});
|
||||
} catch (storeError) {
|
||||
// 拒绝record写入失败:回滚published标记,允许用户重试
|
||||
await reviewStore.unmarkFindingPublished(finding.runId, finding.fingerprint);
|
||||
throw storeError;
|
||||
}
|
||||
}
|
||||
|
||||
// finding已在开头原子标记为published,处理成功则保持published状态
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
message: approved ? '已标记为有效问题并发布到Gitea' : '已标记为误报',
|
||||
learningApplied,
|
||||
published: approved,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to process feedback:', error);
|
||||
return c.json(
|
||||
{
|
||||
error: 'Failed to process feedback',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 获取待审批的findings
|
||||
feedbackRouter.get('/pending', async (c) => {
|
||||
if (!reviewStore) {
|
||||
return c.json({ error: 'Feedback system not initialized' }, 503);
|
||||
}
|
||||
|
||||
const limit = Number(c.req.query('limit') || '50');
|
||||
|
||||
try {
|
||||
const pendingFindings = await reviewStore.getPendingFindings(limit);
|
||||
|
||||
return c.json({
|
||||
findings: pendingFindings,
|
||||
total: pendingFindings.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch pending findings:', error);
|
||||
return c.json(
|
||||
{
|
||||
error: 'Failed to fetch pending findings',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 获取finding详情
|
||||
feedbackRouter.get('/finding/:findingId', async (c) => {
|
||||
if (!reviewStore) {
|
||||
return c.json({ error: 'Feedback system not initialized' }, 503);
|
||||
}
|
||||
|
||||
const { findingId } = c.req.param();
|
||||
|
||||
try {
|
||||
const finding = await reviewStore.getFinding(findingId);
|
||||
|
||||
if (!finding) {
|
||||
return c.json({ error: 'Finding not found' }, 404);
|
||||
}
|
||||
|
||||
// 获取run详情以提供更多上下文
|
||||
const runDetails = await reviewStore.getRunDetails(finding.runId);
|
||||
|
||||
return c.json({
|
||||
finding,
|
||||
run: runDetails?.run,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch finding:', error);
|
||||
return c.json(
|
||||
{
|
||||
error: 'Failed to fetch finding',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export { feedbackRouter };
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import { Context } from 'hono';
|
||||
import { map } from 'lodash-es'
|
||||
import { giteaService, PullRequestFile, PullRequestDetails } from '../services/gitea';
|
||||
import { map } from 'lodash-es';
|
||||
import config from '../config';
|
||||
import { reviewEngine } from '../review/engine';
|
||||
import { aiReviewService } from '../services/ai-review';
|
||||
import { feishuService } from '../services/feishu';
|
||||
import config from '../config';
|
||||
import * as crypto from 'crypto';
|
||||
import { PullRequestDetails, PullRequestFile, giteaService } from '../services/gitea';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
// 判断是否为开发环境
|
||||
@@ -15,7 +16,7 @@ enum GiteaEventType {
|
||||
PullRequest = 'pull_request',
|
||||
Status = 'status',
|
||||
Issue = 'issues',
|
||||
Unknown = 'unknown'
|
||||
Unknown = 'unknown',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,10 +48,7 @@ function verifyWebhookSignature(body: string, signature: string): boolean {
|
||||
// Gitea的签名没有前缀,直接比较
|
||||
try {
|
||||
// 使用timingSafeEqual进行常量时间比较,防止时序攻击
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(calculatedSignature),
|
||||
Buffer.from(signature)
|
||||
);
|
||||
return crypto.timingSafeEqual(Buffer.from(calculatedSignature), Buffer.from(signature));
|
||||
} catch (error) {
|
||||
logger.error('签名验证失败', error);
|
||||
return false;
|
||||
@@ -78,6 +76,19 @@ function determineEventType(c: Context, body: any): GiteaEventType {
|
||||
return GiteaEventType.Unknown;
|
||||
}
|
||||
|
||||
function resolveCloneUrl(repo: any): string | null {
|
||||
if (repo?.clone_url && typeof repo.clone_url === 'string') {
|
||||
return repo.clone_url;
|
||||
}
|
||||
if (repo?.ssh_url && typeof repo.ssh_url === 'string') {
|
||||
return repo.ssh_url;
|
||||
}
|
||||
if (repo?.html_url && typeof repo.html_url === 'string') {
|
||||
return `${repo.html_url}.git`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理Pull Request事件
|
||||
*/
|
||||
@@ -94,10 +105,7 @@ async function handlePullRequestEvent(c: Context, body: any): Promise<Response>
|
||||
}
|
||||
|
||||
// 从事件中提取必要信息
|
||||
const {
|
||||
pull_request: pullRequest,
|
||||
repository: repo
|
||||
} = body;
|
||||
const { pull_request: pullRequest, repository: repo } = body;
|
||||
|
||||
if (!pullRequest || !repo) {
|
||||
return c.json({ error: '无效的Webhook数据' }, 400);
|
||||
@@ -109,18 +117,21 @@ async function handlePullRequestEvent(c: Context, body: any): Promise<Response>
|
||||
const prTitle = pullRequest.title;
|
||||
const prUrl = pullRequest.html_url;
|
||||
|
||||
logger.info(`收到PR事件`, { owner, repo: repoName, prNumber, action: body.action });
|
||||
logger.info('收到PR事件', { owner, repo: repoName, prNumber, action: body.action });
|
||||
|
||||
// 处理PR审阅者通知
|
||||
try {
|
||||
// 获取PR的审阅者列表
|
||||
const reviewerUsernames = map(pullRequest.requested_reviewers, reviewer => reviewer.full_name || reviewer.login);
|
||||
const reviewerUsernames = map(
|
||||
pullRequest.requested_reviewers,
|
||||
(reviewer) => reviewer.full_name || reviewer.login
|
||||
);
|
||||
|
||||
// 记录审阅者信息
|
||||
if (reviewerUsernames.length > 0) {
|
||||
logger.info(`PR有指定审阅者`, {
|
||||
logger.info('PR有指定审阅者', {
|
||||
prNumber,
|
||||
reviewers: reviewerUsernames.join(',')
|
||||
reviewers: reviewerUsernames.join(','),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -131,18 +142,60 @@ async function handlePullRequestEvent(c: Context, body: any): Promise<Response>
|
||||
|
||||
// 处理审阅者指派事件
|
||||
if (body.action === 'review_requested' && body.requested_reviewer) {
|
||||
const newReviewerUsername = body.requested_reviewer.full_name || body.requested_reviewer.login;
|
||||
const newReviewerUsername =
|
||||
body.requested_reviewer.full_name || body.requested_reviewer.login;
|
||||
if (newReviewerUsername) {
|
||||
await feishuService.sendPrReviewerAssignedNotification(prTitle, prUrl, [newReviewerUsername]);
|
||||
await feishuService.sendPrReviewerAssignedNotification(prTitle, prUrl, [
|
||||
newReviewerUsername,
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`处理PR审阅者通知失败:`, error);
|
||||
logger.error('处理PR审阅者通知失败:', error);
|
||||
// 继续执行代码审查流程,不因通知失败而中断
|
||||
}
|
||||
|
||||
// 开始异步审查流程
|
||||
reviewPullRequest(owner, repoName, prNumber).catch(error => {
|
||||
if (config.review.engine === 'agent') {
|
||||
// Fork PR策略:始终clone base repo(保证有baseSha),headCloneUrl作为额外remote(保证有headSha)
|
||||
const baseCloneUrl = resolveCloneUrl(repo);
|
||||
const headSha = pullRequest.head?.sha;
|
||||
const baseSha = pullRequest.base?.sha;
|
||||
if (!baseCloneUrl || !headSha || !baseSha) {
|
||||
return c.json({ error: '缺少Agent审查所需字段(clone_url/base sha/head sha)' }, 400);
|
||||
}
|
||||
|
||||
// 检测fork PR:head.repo存在且与base repo不同
|
||||
const headCloneUrl = pullRequest.head?.repo
|
||||
? resolveCloneUrl(pullRequest.head.repo)
|
||||
: undefined;
|
||||
const isForkPR = headCloneUrl && headCloneUrl !== baseCloneUrl;
|
||||
|
||||
// 包含baseSha以支持retarget场景:相同headSha但baseSha变化时需要重新审查
|
||||
const idempotencyKey = `${owner}/${repoName}#${prNumber}:${baseSha}...${headSha}`;
|
||||
const { run, reused } = await reviewEngine.enqueuePullRequest({
|
||||
eventType: 'pull_request',
|
||||
idempotencyKey,
|
||||
owner,
|
||||
repo: repoName,
|
||||
cloneUrl: baseCloneUrl,
|
||||
headCloneUrl: isForkPR ? headCloneUrl : undefined,
|
||||
prNumber,
|
||||
baseSha,
|
||||
headSha,
|
||||
});
|
||||
|
||||
return c.json(
|
||||
{
|
||||
status: reused ? 'deduplicated' : 'accepted',
|
||||
message: reused ? '审查任务已存在,已去重' : 'Agent代码审查任务已入队',
|
||||
runId: run.id,
|
||||
},
|
||||
202
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy模式:开始异步审查流程
|
||||
reviewPullRequest(owner, repoName, prNumber).catch((error) => {
|
||||
logger.error(`审查PR ${owner}/${repoName}#${prNumber} 失败:`, error);
|
||||
});
|
||||
|
||||
@@ -160,7 +213,7 @@ async function handleCommitStatusEvent(c: Context, body: any): Promise<Response>
|
||||
sha: body.sha,
|
||||
commit_id: body.commit?.id,
|
||||
context: body.context,
|
||||
repo: body.repository?.full_name
|
||||
repo: body.repository?.full_name,
|
||||
});
|
||||
|
||||
// 验证请求体中是否包含必要信息
|
||||
@@ -199,10 +252,10 @@ async function handleCommitStatusEvent(c: Context, body: any): Promise<Response>
|
||||
message: body.commit.message || '',
|
||||
added: body.commit.added || [],
|
||||
removed: body.commit.removed || [],
|
||||
modified: body.commit.modified || []
|
||||
modified: body.commit.modified || [],
|
||||
};
|
||||
|
||||
logger.info(`收到提交状态更新事件`, {
|
||||
logger.info('收到提交状态更新事件', {
|
||||
owner,
|
||||
repo: repoName,
|
||||
commitSha,
|
||||
@@ -210,17 +263,50 @@ async function handleCommitStatusEvent(c: Context, body: any): Promise<Response>
|
||||
relatedPR: relatedPR?.number || 'unknown',
|
||||
added: commitInfo.added.length,
|
||||
modified: commitInfo.modified.length,
|
||||
removed: commitInfo.removed.length
|
||||
removed: commitInfo.removed.length,
|
||||
});
|
||||
|
||||
// 如果没有文件变更信息,则忽略
|
||||
if (commitInfo.added.length === 0 && commitInfo.modified.length === 0 && commitInfo.removed.length === 0) {
|
||||
// Agent模式优先处理:从本地仓库派生diff,不依赖webhook文件列表
|
||||
if (config.review.engine === 'agent') {
|
||||
const cloneUrl = resolveCloneUrl(body.repository);
|
||||
if (!cloneUrl) {
|
||||
return c.json({ error: '缺少Agent审查所需字段(clone_url)' }, 400);
|
||||
}
|
||||
|
||||
const idempotencyKey = `${owner}/${repoName}@${commitSha}`;
|
||||
const { run, reused } = await reviewEngine.enqueueCommit({
|
||||
eventType: 'commit_status',
|
||||
idempotencyKey,
|
||||
owner,
|
||||
repo: repoName,
|
||||
cloneUrl,
|
||||
commitSha,
|
||||
commitMessage: commitInfo.message,
|
||||
relatedPrNumber: relatedPR?.number,
|
||||
});
|
||||
|
||||
return c.json(
|
||||
{
|
||||
status: reused ? 'deduplicated' : 'accepted',
|
||||
message: reused ? '审查任务已存在,已去重' : 'Agent提交审查任务已入队',
|
||||
runId: run.id,
|
||||
},
|
||||
202
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy模式:需要webhook文件列表
|
||||
if (
|
||||
commitInfo.added.length === 0 &&
|
||||
commitInfo.modified.length === 0 &&
|
||||
commitInfo.removed.length === 0
|
||||
) {
|
||||
logger.warn('提交没有文件变更信息,忽略审查', { commitSha });
|
||||
return c.json({ status: 'ignored', message: '提交没有文件变更信息' }, 200);
|
||||
}
|
||||
|
||||
// 开始异步审查流程,传入关联的PR信息
|
||||
reviewCommit(owner, repoName, commitSha, commitInfo, relatedPR).catch(error => {
|
||||
reviewCommit(owner, repoName, commitSha, commitInfo, relatedPR).catch((error) => {
|
||||
logger.error(`审查提交 ${owner}/${repoName}@${commitSha} 失败:`, error);
|
||||
});
|
||||
|
||||
@@ -241,14 +327,17 @@ async function handleIssueEvent(c: Context, body: any): Promise<Response> {
|
||||
const issueTitle = issue.title;
|
||||
const issueUrl = issue.html_url;
|
||||
const creatorUsername = issue.user.full_name || issue.user.login;
|
||||
const assigneeUsernames = map(issue.assignees, assignee => assignee.full_name || assignee.login);
|
||||
const assigneeUsernames = map(
|
||||
issue.assignees,
|
||||
(assignee) => assignee.full_name || assignee.login
|
||||
);
|
||||
|
||||
logger.info(`收到工单事件`, {
|
||||
logger.info('收到工单事件', {
|
||||
action,
|
||||
issueTitle,
|
||||
issueUrl,
|
||||
creatorUsername,
|
||||
assigneeUsernames: assigneeUsernames.join(',')
|
||||
assigneeUsernames: assigneeUsernames.join(','),
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -291,16 +380,16 @@ async function reviewPullRequest(owner: string, repo: string, prNumber: number):
|
||||
number: prNumber,
|
||||
title: '测试PR',
|
||||
head: {
|
||||
sha: 'abcd1234abcd1234abcd1234abcd1234abcd1234'
|
||||
sha: 'abcd1234abcd1234abcd1234abcd1234abcd1234',
|
||||
},
|
||||
base: {
|
||||
repo: {
|
||||
owner: {
|
||||
login: owner
|
||||
login: owner,
|
||||
},
|
||||
name: repo
|
||||
}
|
||||
}
|
||||
name: repo,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 测试用diff内容
|
||||
@@ -324,7 +413,7 @@ index 1234567..abcdefg 100644
|
||||
// 生产环境中从Gitea获取真实数据
|
||||
[prDetails, diffContent] = await Promise.all([
|
||||
giteaService.getPullRequestDetails(owner, repo, prNumber),
|
||||
giteaService.getPullRequestDiff(owner, repo, prNumber)
|
||||
giteaService.getPullRequestDiff(owner, repo, prNumber),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -341,21 +430,21 @@ index 1234567..abcdefg 100644
|
||||
);
|
||||
|
||||
logger.info('代码审查结果', {
|
||||
summary: reviewResult.summary.substring(0, 100) + '...',
|
||||
commentCount: reviewResult.lineComments.length
|
||||
summary: `${reviewResult.summary.substring(0, 100)}...`,
|
||||
commentCount: reviewResult.lineComments.length,
|
||||
});
|
||||
|
||||
// 添加总结评论
|
||||
if (isDev) {
|
||||
logger.info('开发环境: 模拟添加PR评论', {
|
||||
comment: reviewResult.summary
|
||||
comment: reviewResult.summary,
|
||||
});
|
||||
} else {
|
||||
logger.info('生产环境: 添加PR评论', {
|
||||
owner,
|
||||
repo,
|
||||
prNumber,
|
||||
comment: reviewResult.summary
|
||||
comment: reviewResult.summary,
|
||||
});
|
||||
await giteaService.addPullRequestComment(
|
||||
owner,
|
||||
@@ -370,7 +459,7 @@ index 1234567..abcdefg 100644
|
||||
if (isDev) {
|
||||
logger.info('开发环境: 模拟添加行评论', {
|
||||
commentCount: reviewResult.lineComments.length,
|
||||
comments: reviewResult.lineComments
|
||||
comments: reviewResult.lineComments,
|
||||
});
|
||||
} else {
|
||||
await giteaService.addLineComments(
|
||||
@@ -385,7 +474,7 @@ index 1234567..abcdefg 100644
|
||||
|
||||
logger.info(`完成PR ${owner}/${repo}#${prNumber} 的代码审查`);
|
||||
} catch (error) {
|
||||
logger.error(`审查PR失败:`, error);
|
||||
logger.error('审查PR失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -398,21 +487,22 @@ async function reviewCommit(
|
||||
repo: string,
|
||||
commitSha: string,
|
||||
commitInfo: {
|
||||
sha: string,
|
||||
message: string,
|
||||
added: string[],
|
||||
modified: string[],
|
||||
removed: string[]
|
||||
sha: string;
|
||||
message: string;
|
||||
added: string[];
|
||||
modified: string[];
|
||||
removed: string[];
|
||||
},
|
||||
relatedPR?: PullRequestDetails | null
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info(`开始审查提交 ${owner}/${repo}@${commitSha}`);
|
||||
logger.info('提交信息', {
|
||||
message: commitInfo.message.substring(0, 100) + (commitInfo.message.length > 100 ? '...' : ''),
|
||||
message:
|
||||
commitInfo.message.substring(0, 100) + (commitInfo.message.length > 100 ? '...' : ''),
|
||||
added: commitInfo.added.length,
|
||||
modified: commitInfo.modified.length,
|
||||
removed: commitInfo.removed.length
|
||||
removed: commitInfo.removed.length,
|
||||
});
|
||||
|
||||
// 如果是开发环境,打印更多信息但不执行实际审查
|
||||
@@ -423,47 +513,42 @@ async function reviewCommit(
|
||||
commitSha,
|
||||
added: commitInfo.added,
|
||||
modified: commitInfo.modified,
|
||||
removed: commitInfo.removed
|
||||
removed: commitInfo.removed,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建自定义文件列表,因为Gitea API不直接提供
|
||||
const webhookFiles: PullRequestFile[] = [
|
||||
...commitInfo.added.map(filename => ({
|
||||
...commitInfo.added.map((filename) => ({
|
||||
filename,
|
||||
status: 'added',
|
||||
additions: 0, // 不知道具体行数
|
||||
deletions: 0,
|
||||
changes: 0
|
||||
changes: 0,
|
||||
})),
|
||||
...commitInfo.modified.map(filename => ({
|
||||
...commitInfo.modified.map((filename) => ({
|
||||
filename,
|
||||
status: 'modified',
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
changes: 0
|
||||
changes: 0,
|
||||
})),
|
||||
...commitInfo.removed.map(filename => ({
|
||||
...commitInfo.removed.map((filename) => ({
|
||||
filename,
|
||||
status: 'removed',
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
changes: 0
|
||||
}))
|
||||
changes: 0,
|
||||
})),
|
||||
];
|
||||
|
||||
// 使用AI审查服务分析提交,并传入webhook提供的文件列表
|
||||
const reviewResult = await aiReviewService.reviewCommit(
|
||||
owner,
|
||||
repo,
|
||||
commitSha,
|
||||
webhookFiles
|
||||
);
|
||||
const reviewResult = await aiReviewService.reviewCommit(owner, repo, commitSha, webhookFiles);
|
||||
|
||||
logger.info('提交代码审查结果', {
|
||||
summary: reviewResult.summary.substring(0, 100) + '...',
|
||||
commentCount: reviewResult.lineComments.length
|
||||
summary: `${reviewResult.summary.substring(0, 100)}...`,
|
||||
commentCount: reviewResult.lineComments.length,
|
||||
});
|
||||
|
||||
// 添加总结评论到提交
|
||||
@@ -482,7 +567,7 @@ async function reviewCommit(
|
||||
// 尝试使用传入的PR信息,或者查找相关的PR
|
||||
try {
|
||||
// 如果已经有关联PR,直接使用
|
||||
if (relatedPR && relatedPR.number) {
|
||||
if (relatedPR?.number) {
|
||||
logger.info(`使用已知关联的PR #${relatedPR.number}`);
|
||||
|
||||
// 添加行级评论
|
||||
@@ -499,7 +584,7 @@ async function reviewCommit(
|
||||
// 否则尝试查找
|
||||
logger.info('尝试查找与提交关联的PR');
|
||||
const response = await giteaService.getRelatedPullRequest(owner, repo, commitSha);
|
||||
if (response && response.number) {
|
||||
if (response?.number) {
|
||||
logger.info(`找到与提交关联的PR #${response.number}`);
|
||||
|
||||
// 添加行级评论
|
||||
@@ -522,7 +607,7 @@ async function reviewCommit(
|
||||
|
||||
logger.info(`完成提交 ${owner}/${repo}@${commitSha} 的代码审查`);
|
||||
} catch (error) {
|
||||
logger.error(`审查提交失败:`, error);
|
||||
logger.error('审查提交失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
40
src/index.ts
40
src/index.ts
@@ -1,9 +1,13 @@
|
||||
import { Hono } from 'hono';
|
||||
import { jwt } from 'hono/jwt';
|
||||
import { serveStatic } from 'hono/bun';
|
||||
import { handleGiteaWebhook } from './controllers/review';
|
||||
import { adminController } from './controllers/admin';
|
||||
import { jwt } from 'hono/jwt';
|
||||
import OpenAI from 'openai';
|
||||
import config from './config';
|
||||
import { adminController } from './controllers/admin';
|
||||
import { configRouter } from './controllers/config';
|
||||
import { feedbackRouter, initializeFeedbackSystem } from './controllers/feedback';
|
||||
import { handleGiteaWebhook } from './controllers/review';
|
||||
import { reviewEngine } from './review/engine';
|
||||
|
||||
// 创建Hono应用实例
|
||||
const app = new Hono();
|
||||
@@ -21,12 +25,12 @@ app.get('/', (c) => {
|
||||
webhookSecurityEnabled: webhookSecretConfigured,
|
||||
configuration: {
|
||||
webhookEndpoints: {
|
||||
unified: '/webhook/gitea (支持Pull Request和Commit Status事件)'
|
||||
unified: '/webhook/gitea (支持Pull Request和Commit Status事件)',
|
||||
},
|
||||
signature: webhookSecretConfigured
|
||||
? '签名验证已启用 (使用X-Gitea-Signature头)'
|
||||
: '警告: 签名验证未配置,建议设置WEBHOOK_SECRET环境变量'
|
||||
}
|
||||
: '警告: 签名验证未配置,建议设置WEBHOOK_SECRET环境变量',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,11 +43,12 @@ app.route('/admin/api', adminController.publicRoutes);
|
||||
|
||||
// 受保护的路由
|
||||
const adminProtected = new Hono();
|
||||
adminProtected.use('/*', jwt({ secret: config.admin.jwtSecret }));
|
||||
adminProtected.use('/*', jwt({ secret: config.admin.jwtSecret, alg: 'HS256' }));
|
||||
adminProtected.route('/', adminController.protectedRoutes);
|
||||
adminProtected.route('/feedback', feedbackRouter);
|
||||
adminProtected.route('/config', configRouter);
|
||||
app.route('/admin/api', adminProtected);
|
||||
|
||||
|
||||
// --- 前端静态文件服务 ---
|
||||
|
||||
// 优先服务于 public 目录下的静态文件
|
||||
@@ -52,11 +57,28 @@ app.use('/*', serveStatic({ root: './public' }));
|
||||
// 对于所有未匹配到的GET请求,返回 index.html,以支持SPA路由
|
||||
app.get('*', serveStatic({ path: './public/index.html' }));
|
||||
|
||||
|
||||
// 启动服务器
|
||||
const port = config.app.port;
|
||||
console.log(`⚡️ 服务启动在 http://localhost:${port}`);
|
||||
|
||||
reviewEngine.start().catch((error) => {
|
||||
console.error('❌ 启动Agent Review Engine失败', error);
|
||||
});
|
||||
|
||||
// 初始化反馈系统(总是初始化,记忆系统可选)
|
||||
const openaiClient = new OpenAI({
|
||||
baseURL: config.openai.baseUrl,
|
||||
apiKey: config.openai.apiKey,
|
||||
});
|
||||
const reviewStore = reviewEngine.getStore();
|
||||
initializeFeedbackSystem(openaiClient, reviewStore);
|
||||
|
||||
if (config.review.enableMemory) {
|
||||
console.log('✅ 反馈系统已初始化(含向量记忆)');
|
||||
} else {
|
||||
console.log('✅ 反馈系统已初始化(不含向量记忆)');
|
||||
}
|
||||
|
||||
export default {
|
||||
port,
|
||||
fetch: app.fetch,
|
||||
|
||||
406
src/review/__tests__/file-review-store.test.ts
Normal file
406
src/review/__tests__/file-review-store.test.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { FileReviewStore } from '../store/file-review-store';
|
||||
import type { PullRequestReviewPayload } from '../types';
|
||||
|
||||
function makePRPayload(
|
||||
overrides: Partial<PullRequestReviewPayload> = {}
|
||||
): PullRequestReviewPayload {
|
||||
return {
|
||||
idempotencyKey: `idem-${Math.random().toString(36).slice(2, 8)}`,
|
||||
eventType: 'pull_request',
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
cloneUrl: 'https://example.com/repo.git',
|
||||
prNumber: 1,
|
||||
baseSha: 'aaa',
|
||||
headSha: 'bbb',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('FileReviewStore', () => {
|
||||
let tempDir: string;
|
||||
let store: FileReviewStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), 'store-test-'));
|
||||
store = new FileReviewStore(tempDir);
|
||||
await store.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── Init ───
|
||||
describe('init', () => {
|
||||
test('creates state file on first init', async () => {
|
||||
const stateFile = path.join(tempDir, 'state', 'review-store.json');
|
||||
const content = await readFile(stateFile, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
expect(data).toHaveProperty('runs');
|
||||
expect(data).toHaveProperty('steps');
|
||||
expect(data).toHaveProperty('findings');
|
||||
expect(data).toHaveProperty('comments');
|
||||
});
|
||||
|
||||
test('double init is safe', async () => {
|
||||
await store.init();
|
||||
await store.init();
|
||||
const runs = await store.listRuns();
|
||||
expect(runs).toEqual([]);
|
||||
});
|
||||
|
||||
test('re-reads existing data on init', async () => {
|
||||
const payload = makePRPayload();
|
||||
await store.createOrReuseRun(payload);
|
||||
|
||||
// New store instance reading same dir
|
||||
const store2 = new FileReviewStore(tempDir);
|
||||
await store2.init();
|
||||
const runs = await store2.listRuns();
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0].idempotencyKey).toBe(payload.idempotencyKey);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── createOrReuseRun ───
|
||||
describe('createOrReuseRun', () => {
|
||||
test('creates new run', async () => {
|
||||
const payload = makePRPayload();
|
||||
const { run, reused } = await store.createOrReuseRun(payload);
|
||||
expect(reused).toBe(false);
|
||||
expect(run.status).toBe('queued');
|
||||
expect(run.owner).toBe('test-owner');
|
||||
expect(run.prNumber).toBe(1);
|
||||
});
|
||||
|
||||
test('reuses existing non-failed run with same idempotencyKey', async () => {
|
||||
const payload = makePRPayload();
|
||||
const { run: first } = await store.createOrReuseRun(payload);
|
||||
const { run: second, reused } = await store.createOrReuseRun(payload);
|
||||
expect(reused).toBe(true);
|
||||
expect(second.id).toBe(first.id);
|
||||
});
|
||||
|
||||
test('does NOT reuse a failed run', async () => {
|
||||
const payload = makePRPayload({ maxAttempts: 1 });
|
||||
const { run: first } = await store.createOrReuseRun(payload);
|
||||
|
||||
// Acquire and fail
|
||||
await store.acquireNextQueuedRun();
|
||||
await store.markRunFailed(first.id, 'test error');
|
||||
|
||||
// Should create a new run, not reuse
|
||||
const { run: second, reused } = await store.createOrReuseRun(payload);
|
||||
expect(reused).toBe(false);
|
||||
expect(second.id).not.toBe(first.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Failed run cleanup ───
|
||||
describe('failed run cleanup', () => {
|
||||
test('cleans up oldest failed runs when MAX_FAILED_RUNS_PER_KEY exceeded', async () => {
|
||||
const key = 'cleanup-test-key';
|
||||
|
||||
// Create 4 failed runs for the same key
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const payload = makePRPayload({ idempotencyKey: key, maxAttempts: 1 });
|
||||
const { run } = await store.createOrReuseRun(payload);
|
||||
await store.acquireNextQueuedRun();
|
||||
await store.markRunFailed(run.id, `error-${i}`);
|
||||
}
|
||||
|
||||
// Creating 5th run should trigger cleanup
|
||||
const payload = makePRPayload({ idempotencyKey: key, maxAttempts: 1 });
|
||||
await store.createOrReuseRun(payload);
|
||||
|
||||
const runs = await store.listRuns(100);
|
||||
const runsForKey = runs.filter((r) => r.idempotencyKey === key);
|
||||
|
||||
// Should have at most MAX_FAILED_RUNS_PER_KEY (3) failed + 1 new queued = 4
|
||||
// But the cleanup runs before adding new, so we expect ≤4
|
||||
expect(runsForKey.length).toBeLessThanOrEqual(4);
|
||||
});
|
||||
|
||||
test('cleanup also removes associated steps, findings, comments', async () => {
|
||||
const key = 'cleanup-assoc-key';
|
||||
|
||||
// Create and fail runs, adding associated data
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const payload = makePRPayload({ idempotencyKey: key, maxAttempts: 1 });
|
||||
const { run } = await store.createOrReuseRun(payload);
|
||||
|
||||
// Add a step for this run
|
||||
await store.addStep({
|
||||
runId: run.id,
|
||||
stepName: `step-${i}`,
|
||||
status: 'started',
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Add findings
|
||||
await store.addFindings(run.id, [
|
||||
{
|
||||
id: `finding-${i}`,
|
||||
runId: run.id,
|
||||
fingerprint: `fp-${i}`,
|
||||
category: 'correctness',
|
||||
severity: 'high',
|
||||
confidence: 0.9,
|
||||
path: 'test.ts',
|
||||
line: i + 1,
|
||||
title: `Issue ${i}`,
|
||||
detail: 'Detail',
|
||||
evidence: 'Evidence',
|
||||
suggestion: 'Fix',
|
||||
published: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await store.acquireNextQueuedRun();
|
||||
await store.markRunFailed(run.id, `error-${i}`);
|
||||
}
|
||||
|
||||
// Trigger cleanup by creating 5th run
|
||||
const payload = makePRPayload({ idempotencyKey: key, maxAttempts: 1 });
|
||||
await store.createOrReuseRun(payload);
|
||||
|
||||
// Verify the data was actually persisted and can be read back
|
||||
const store2 = new FileReviewStore(tempDir);
|
||||
await store2.init();
|
||||
const allRuns = await store2.listRuns(100);
|
||||
const keyRuns = allRuns.filter((r) => r.idempotencyKey === key);
|
||||
expect(keyRuns.length).toBeLessThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── recoverInterruptedRuns ───
|
||||
describe('recoverInterruptedRuns', () => {
|
||||
test('resets in_progress runs to queued', async () => {
|
||||
const payload = makePRPayload();
|
||||
await store.createOrReuseRun(payload);
|
||||
await store.acquireNextQueuedRun(); // now in_progress
|
||||
|
||||
const recovered = await store.recoverInterruptedRuns();
|
||||
expect(recovered).toBe(1);
|
||||
|
||||
// Check it's queued again
|
||||
const runs = await store.listRuns();
|
||||
expect(runs[0].status).toBe('queued');
|
||||
});
|
||||
|
||||
test('no interrupted runs → returns 0', async () => {
|
||||
const payload = makePRPayload();
|
||||
await store.createOrReuseRun(payload); // queued, not in_progress
|
||||
const recovered = await store.recoverInterruptedRuns();
|
||||
expect(recovered).toBe(0);
|
||||
});
|
||||
|
||||
test('only recovers in_progress, not queued or succeeded', async () => {
|
||||
// Create 3 runs: one queued, one in_progress, one succeeded
|
||||
const p1 = makePRPayload();
|
||||
const p2 = makePRPayload();
|
||||
const p3 = makePRPayload();
|
||||
|
||||
await store.createOrReuseRun(p1); // queued
|
||||
const { run: r2 } = await store.createOrReuseRun(p2);
|
||||
await store.createOrReuseRun(p3);
|
||||
|
||||
// Acquire r2 (first queued gets picked) → in_progress
|
||||
await store.acquireNextQueuedRun();
|
||||
// Acquire r3 → in_progress (r1 still queued if r2 was picked, but let's be explicit)
|
||||
// Actually acquireNextQueuedRun picks first queued, so pick the remaining
|
||||
await store.acquireNextQueuedRun();
|
||||
// Succeed one of them
|
||||
await store.markRunSucceeded(r2.id);
|
||||
|
||||
const recovered = await store.recoverInterruptedRuns();
|
||||
// Only non-succeeded in_progress runs get recovered
|
||||
expect(recovered).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Atomic write ───
|
||||
describe('atomic write (persist)', () => {
|
||||
test('state file does not have .tmp extension after write', async () => {
|
||||
const payload = makePRPayload();
|
||||
await store.createOrReuseRun(payload);
|
||||
|
||||
const stateFile = path.join(tempDir, 'state', 'review-store.json');
|
||||
const content = await readFile(stateFile, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
expect(data.runs).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('concurrent writes are serialized (no data corruption)', async () => {
|
||||
// Fire multiple concurrent operations
|
||||
const promises = Array.from({ length: 10 }, (_, i) =>
|
||||
store.createOrReuseRun(makePRPayload({ idempotencyKey: `concurrent-${i}` }))
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Read back and verify all 10 runs exist
|
||||
const store2 = new FileReviewStore(tempDir);
|
||||
await store2.init();
|
||||
const runs = await store2.listRuns(20);
|
||||
expect(runs).toHaveLength(10);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── acquireNextQueuedRun ───
|
||||
describe('acquireNextQueuedRun', () => {
|
||||
test('returns null when no queued runs', async () => {
|
||||
const run = await store.acquireNextQueuedRun();
|
||||
expect(run).toBeNull();
|
||||
});
|
||||
|
||||
test('returns first queued run and sets to in_progress', async () => {
|
||||
const payload = makePRPayload();
|
||||
const { run: created } = await store.createOrReuseRun(payload);
|
||||
const acquired = await store.acquireNextQueuedRun();
|
||||
expect(acquired).not.toBeNull();
|
||||
expect(acquired!.id).toBe(created.id);
|
||||
expect(acquired!.status).toBe('in_progress');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── markRunFailed with retry ───
|
||||
describe('markRunFailed', () => {
|
||||
test('requeues if attempts < maxAttempts', async () => {
|
||||
const payload = makePRPayload({ maxAttempts: 3 });
|
||||
const { run } = await store.createOrReuseRun(payload);
|
||||
await store.acquireNextQueuedRun();
|
||||
|
||||
const { requeued, run: failedRun } = await store.markRunFailed(run.id, 'oops');
|
||||
expect(requeued).toBe(true);
|
||||
expect(failedRun!.status).toBe('queued');
|
||||
expect(failedRun!.attempts).toBe(1);
|
||||
});
|
||||
|
||||
test('permanently fails when attempts exhausted', async () => {
|
||||
const payload = makePRPayload({ maxAttempts: 1 });
|
||||
const { run } = await store.createOrReuseRun(payload);
|
||||
await store.acquireNextQueuedRun();
|
||||
|
||||
const { requeued, run: failedRun } = await store.markRunFailed(run.id, 'final');
|
||||
expect(requeued).toBe(false);
|
||||
expect(failedRun!.status).toBe('failed');
|
||||
});
|
||||
|
||||
test('non-existent runId returns null', async () => {
|
||||
const { requeued, run } = await store.markRunFailed('nonexistent-id', 'error');
|
||||
expect(requeued).toBe(false);
|
||||
expect(run).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Finding operations ───
|
||||
describe('findings', () => {
|
||||
test('addFindings replaces previous findings for same runId', async () => {
|
||||
const payload = makePRPayload();
|
||||
const { run } = await store.createOrReuseRun(payload);
|
||||
|
||||
await store.addFindings(run.id, [
|
||||
{
|
||||
id: 'f1',
|
||||
runId: run.id,
|
||||
fingerprint: 'fp1',
|
||||
category: 'correctness',
|
||||
severity: 'high',
|
||||
confidence: 0.9,
|
||||
path: 'a.ts',
|
||||
line: 1,
|
||||
title: 'Old',
|
||||
detail: 'd',
|
||||
evidence: 'e',
|
||||
suggestion: 's',
|
||||
published: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await store.addFindings(run.id, [
|
||||
{
|
||||
id: 'f2',
|
||||
runId: run.id,
|
||||
fingerprint: 'fp2',
|
||||
category: 'security',
|
||||
severity: 'medium',
|
||||
confidence: 0.8,
|
||||
path: 'b.ts',
|
||||
line: 2,
|
||||
title: 'New',
|
||||
detail: 'd',
|
||||
evidence: 'e',
|
||||
suggestion: 's',
|
||||
published: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const details = await store.getRunDetails(run.id);
|
||||
expect(details!.findings).toHaveLength(1);
|
||||
expect(details!.findings[0].title).toBe('New');
|
||||
});
|
||||
|
||||
test('markFindingPublished is idempotent', async () => {
|
||||
const payload = makePRPayload();
|
||||
const { run } = await store.createOrReuseRun(payload);
|
||||
await store.addFindings(run.id, [
|
||||
{
|
||||
id: 'f1',
|
||||
runId: run.id,
|
||||
fingerprint: 'fp1',
|
||||
category: 'correctness',
|
||||
severity: 'high',
|
||||
confidence: 0.9,
|
||||
path: 'a.ts',
|
||||
line: 1,
|
||||
title: 'Bug',
|
||||
detail: 'd',
|
||||
evidence: 'e',
|
||||
suggestion: 's',
|
||||
published: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const first = await store.markFindingPublished(run.id, 'fp1');
|
||||
expect(first).toBe(true); // was unpublished → now published
|
||||
|
||||
const second = await store.markFindingPublished(run.id, 'fp1');
|
||||
expect(second).toBe(false); // already published
|
||||
});
|
||||
});
|
||||
|
||||
// ─── listRuns ───
|
||||
describe('listRuns', () => {
|
||||
test('returns runs sorted by createdAt descending', async () => {
|
||||
const p1 = makePRPayload();
|
||||
const p2 = makePRPayload();
|
||||
const p3 = makePRPayload();
|
||||
|
||||
await store.createOrReuseRun(p1);
|
||||
// Ensure distinct timestamps for sorting
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
await store.createOrReuseRun(p2);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
await store.createOrReuseRun(p3);
|
||||
|
||||
const runs = await store.listRuns();
|
||||
expect(runs).toHaveLength(3);
|
||||
// Most recent first
|
||||
expect(runs[0].idempotencyKey).toBe(p3.idempotencyKey);
|
||||
});
|
||||
|
||||
test('respects limit parameter', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await store.createOrReuseRun(makePRPayload());
|
||||
}
|
||||
const runs = await store.listRuns(2);
|
||||
expect(runs).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
372
src/review/__tests__/integration.test.ts
Normal file
372
src/review/__tests__/integration.test.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { JudgeAgent } from '../agents/judge-agent';
|
||||
import { applyPublishPolicy } from '../policy/publish-policy';
|
||||
import { FileReviewStore } from '../store/file-review-store';
|
||||
import type { Finding, PullRequestReviewPayload } from '../types';
|
||||
|
||||
type PartialFinding = Omit<Finding, 'id' | 'runId' | 'published'>;
|
||||
|
||||
function makePRPayload(
|
||||
overrides: Partial<PullRequestReviewPayload> = {}
|
||||
): PullRequestReviewPayload {
|
||||
return {
|
||||
idempotencyKey: 'test/repo#1:aaa...bbb',
|
||||
eventType: 'pull_request',
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
cloneUrl: 'https://gitea.example.com/test-owner/test-repo.git',
|
||||
prNumber: 1,
|
||||
baseSha: 'aaa',
|
||||
headSha: 'bbb',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeAgentFindings(
|
||||
count: number,
|
||||
severity: 'high' | 'medium' | 'low' = 'high'
|
||||
): PartialFinding[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
fingerprint: `fp-${severity}-${i}`,
|
||||
category: 'correctness' as const,
|
||||
severity,
|
||||
confidence: severity === 'high' ? 0.95 : severity === 'medium' ? 0.85 : 0.7,
|
||||
path: `src/file${i}.ts`,
|
||||
line: 10 + i,
|
||||
title: `${severity} issue ${i}`,
|
||||
detail: `Detail for ${severity} issue ${i}`,
|
||||
evidence: `Evidence ${i}`,
|
||||
suggestion: `Fix suggestion ${i}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Integration tests: Store → JudgeAgent → PublishPolicy → Store pipeline
|
||||
*
|
||||
* These tests simulate the orchestrator's data flow without needing
|
||||
* live OpenAI or Gitea services. They verify that the pipeline from
|
||||
* enqueueing a run through judging findings to applying publish policy
|
||||
* works correctly end-to-end.
|
||||
*/
|
||||
describe('Integration: Store → Judge → Policy pipeline', () => {
|
||||
let tempDir: string;
|
||||
let store: FileReviewStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), 'integration-test-'));
|
||||
store = new FileReviewStore(tempDir);
|
||||
await store.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('full pipeline: enqueue → agent findings → judge dedup → policy → store findings → publish mark', async () => {
|
||||
const payload = makePRPayload();
|
||||
const { run, reused } = await store.createOrReuseRun(payload);
|
||||
expect(reused).toBe(false);
|
||||
expect(run.status).toBe('queued');
|
||||
|
||||
const acquired = await store.acquireNextQueuedRun();
|
||||
expect(acquired).not.toBeNull();
|
||||
expect(acquired!.status).toBe('in_progress');
|
||||
|
||||
await store.addStep({
|
||||
runId: run.id,
|
||||
stepName: 'run_specialists',
|
||||
status: 'started',
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const correctnessFindings = makeAgentFindings(2, 'high');
|
||||
const securityFindings = makeAgentFindings(1, 'medium');
|
||||
const lowFindings = makeAgentFindings(1, 'low');
|
||||
|
||||
const duplicateFinding: PartialFinding = {
|
||||
...correctnessFindings[0],
|
||||
confidence: 0.7,
|
||||
detail: 'Duplicate with lower confidence',
|
||||
};
|
||||
|
||||
const allAgentFindings = [
|
||||
...correctnessFindings,
|
||||
...securityFindings,
|
||||
...lowFindings,
|
||||
duplicateFinding,
|
||||
];
|
||||
|
||||
const judge = new JudgeAgent();
|
||||
const decision = judge.judge(allAgentFindings);
|
||||
|
||||
expect(decision.findings.length).toBe(4);
|
||||
const dedupedFp0 = decision.findings.find((f) => f.fingerprint === 'fp-high-0');
|
||||
expect(dedupedFp0!.confidence).toBe(0.95);
|
||||
|
||||
const policyResult = applyPublishPolicy(decision.findings, 0.8, false);
|
||||
|
||||
expect(policyResult.publishable.length).toBe(3);
|
||||
expect(policyResult.gated.length).toBe(0);
|
||||
expect(policyResult.dropped.length).toBe(1);
|
||||
expect(policyResult.dropped[0].severity).toBe('low');
|
||||
|
||||
const findingsToStore = [...policyResult.publishable, ...policyResult.gated];
|
||||
const persistedFindings: Finding[] = findingsToStore.map((f, i) => ({
|
||||
...f,
|
||||
id: `finding-${i}`,
|
||||
runId: run.id,
|
||||
published: false,
|
||||
}));
|
||||
await store.addFindings(run.id, persistedFindings);
|
||||
|
||||
for (const finding of policyResult.publishable) {
|
||||
const wasNew = await store.markFindingPublished(run.id, finding.fingerprint);
|
||||
expect(wasNew).toBe(true);
|
||||
}
|
||||
|
||||
for (const finding of policyResult.publishable) {
|
||||
const wasNew = await store.markFindingPublished(run.id, finding.fingerprint);
|
||||
expect(wasNew).toBe(false);
|
||||
}
|
||||
|
||||
await store.addCommentRecord({
|
||||
runId: run.id,
|
||||
status: 'published',
|
||||
body: `## AI Agent代码审查结果\n\n${decision.summaryMarkdown}`,
|
||||
});
|
||||
|
||||
for (const finding of policyResult.publishable) {
|
||||
await store.addCommentRecord({
|
||||
runId: run.id,
|
||||
status: 'published',
|
||||
path: finding.path,
|
||||
line: finding.line,
|
||||
body: `**[${finding.severity.toUpperCase()}]** ${finding.title}`,
|
||||
});
|
||||
}
|
||||
|
||||
await store.markRunSucceeded(run.id);
|
||||
|
||||
const details = await store.getRunDetails(run.id);
|
||||
expect(details).not.toBeNull();
|
||||
expect(details!.run.status).toBe('succeeded');
|
||||
expect(details!.findings.length).toBe(3);
|
||||
expect(details!.findings.every((f) => f.published)).toBe(true);
|
||||
expect(details!.comments.length).toBe(4);
|
||||
expect(details!.comments.filter((c) => !c.path).length).toBe(1);
|
||||
expect(details!.comments.filter((c) => c.path).length).toBe(3);
|
||||
});
|
||||
|
||||
test('pipeline with humanGate: low-confidence findings go to gated, not dropped', async () => {
|
||||
const payload = makePRPayload({ idempotencyKey: 'gate-test' });
|
||||
const { run } = await store.createOrReuseRun(payload);
|
||||
await store.acquireNextQueuedRun();
|
||||
|
||||
const findings: PartialFinding[] = [
|
||||
...makeAgentFindings(1, 'high'),
|
||||
{
|
||||
fingerprint: 'fp-low-conf',
|
||||
category: 'security',
|
||||
severity: 'high',
|
||||
confidence: 0.5,
|
||||
path: 'src/auth.ts',
|
||||
line: 20,
|
||||
title: 'Potential auth bypass',
|
||||
detail: 'Detail',
|
||||
evidence: 'Evidence',
|
||||
suggestion: 'Fix',
|
||||
},
|
||||
];
|
||||
|
||||
const judge = new JudgeAgent();
|
||||
const decision = judge.judge(findings);
|
||||
const policyResult = applyPublishPolicy(decision.findings, 0.8, true);
|
||||
|
||||
expect(policyResult.publishable.length).toBe(1);
|
||||
expect(policyResult.gated.length).toBe(1);
|
||||
expect(policyResult.dropped.length).toBe(0);
|
||||
expect(policyResult.gated[0].fingerprint).toBe('fp-low-conf');
|
||||
|
||||
const allToStore = [...policyResult.publishable, ...policyResult.gated];
|
||||
const persisted: Finding[] = allToStore.map((f, i) => ({
|
||||
...f,
|
||||
id: `f-${i}`,
|
||||
runId: run.id,
|
||||
published: false,
|
||||
}));
|
||||
await store.addFindings(run.id, persisted);
|
||||
|
||||
for (const f of policyResult.publishable) {
|
||||
await store.markFindingPublished(run.id, f.fingerprint);
|
||||
}
|
||||
|
||||
for (const f of policyResult.gated) {
|
||||
await store.addCommentRecord({
|
||||
runId: run.id,
|
||||
status: 'pending',
|
||||
path: f.path,
|
||||
line: f.line,
|
||||
body: `PENDING: ${f.title}`,
|
||||
fingerprint: f.fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
const details = await store.getRunDetails(run.id);
|
||||
const pendingComments = details!.comments.filter((c) => c.status === 'pending');
|
||||
expect(pendingComments.length).toBe(1);
|
||||
expect(pendingComments[0].fingerprint).toBe('fp-low-conf');
|
||||
|
||||
const unpublished = details!.findings.filter((f) => !f.published);
|
||||
expect(unpublished.length).toBe(1);
|
||||
expect(unpublished[0].fingerprint).toBe('fp-low-conf');
|
||||
});
|
||||
|
||||
test('idempotency: duplicate webhook enqueue returns same run', async () => {
|
||||
const payload = makePRPayload();
|
||||
|
||||
const { run: first, reused: r1 } = await store.createOrReuseRun(payload);
|
||||
expect(r1).toBe(false);
|
||||
|
||||
const { run: second, reused: r2 } = await store.createOrReuseRun(payload);
|
||||
expect(r2).toBe(true);
|
||||
expect(second.id).toBe(first.id);
|
||||
|
||||
const { run: third, reused: r3 } = await store.createOrReuseRun(payload);
|
||||
expect(r3).toBe(true);
|
||||
expect(third.id).toBe(first.id);
|
||||
});
|
||||
|
||||
test('retry flow: failed run creates new run on next enqueue, old steps/findings preserved', async () => {
|
||||
const payload = makePRPayload({ maxAttempts: 1 });
|
||||
const { run: firstRun } = await store.createOrReuseRun(payload);
|
||||
|
||||
await store.acquireNextQueuedRun();
|
||||
await store.addStep({
|
||||
runId: firstRun.id,
|
||||
stepName: 'prepare_workspace',
|
||||
status: 'failed',
|
||||
startedAt: new Date().toISOString(),
|
||||
error: 'git clone failed',
|
||||
});
|
||||
await store.markRunFailed(firstRun.id, 'git clone failed');
|
||||
|
||||
const firstDetails = await store.getRunDetails(firstRun.id);
|
||||
expect(firstDetails!.run.status).toBe('failed');
|
||||
expect(firstDetails!.steps.length).toBe(1);
|
||||
|
||||
const { run: retryRun, reused } = await store.createOrReuseRun(payload);
|
||||
expect(reused).toBe(false);
|
||||
expect(retryRun.id).not.toBe(firstRun.id);
|
||||
|
||||
const retryAcquired = await store.acquireNextQueuedRun();
|
||||
expect(retryAcquired!.id).toBe(retryRun.id);
|
||||
});
|
||||
|
||||
test('recovery after crash: in_progress runs are recovered to queued', async () => {
|
||||
const p1 = makePRPayload({ idempotencyKey: 'crash-1' });
|
||||
const p2 = makePRPayload({ idempotencyKey: 'crash-2' });
|
||||
|
||||
const { run: run1 } = await store.createOrReuseRun(p1);
|
||||
const { run: run2 } = await store.createOrReuseRun(p2);
|
||||
|
||||
await store.acquireNextQueuedRun();
|
||||
await store.acquireNextQueuedRun();
|
||||
|
||||
await store.markRunSucceeded(run1.id);
|
||||
|
||||
const store2 = new FileReviewStore(tempDir);
|
||||
await store2.init();
|
||||
const recovered = await store2.recoverInterruptedRuns();
|
||||
expect(recovered).toBe(1);
|
||||
|
||||
const next = await store2.acquireNextQueuedRun();
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.id).toBe(run2.id);
|
||||
});
|
||||
|
||||
test('concurrent enqueue: multiple payloads with different keys all get unique runs', async () => {
|
||||
const payloads = Array.from({ length: 5 }, (_, i) =>
|
||||
makePRPayload({ idempotencyKey: `concurrent-${i}`, prNumber: i + 1 })
|
||||
);
|
||||
|
||||
const results = await Promise.all(payloads.map((p) => store.createOrReuseRun(p)));
|
||||
|
||||
const ids = new Set(results.map((r) => r.run.id));
|
||||
expect(ids.size).toBe(5);
|
||||
expect(results.every((r) => !r.reused)).toBe(true);
|
||||
|
||||
const runs = await store.listRuns(10);
|
||||
expect(runs.length).toBe(5);
|
||||
});
|
||||
|
||||
test('end-to-end: no findings → summary only, no line comments', async () => {
|
||||
const payload = makePRPayload({ idempotencyKey: 'no-findings' });
|
||||
const { run } = await store.createOrReuseRun(payload);
|
||||
await store.acquireNextQueuedRun();
|
||||
|
||||
const judge = new JudgeAgent();
|
||||
const decision = judge.judge([]);
|
||||
|
||||
expect(decision.findings.length).toBe(0);
|
||||
expect(decision.summaryMarkdown).toContain('未发现');
|
||||
|
||||
const policyResult = applyPublishPolicy(decision.findings, 0.8, false);
|
||||
expect(policyResult.publishable.length).toBe(0);
|
||||
expect(policyResult.gated.length).toBe(0);
|
||||
expect(policyResult.dropped.length).toBe(0);
|
||||
|
||||
await store.addCommentRecord({
|
||||
runId: run.id,
|
||||
status: 'published',
|
||||
body: decision.summaryMarkdown,
|
||||
});
|
||||
|
||||
await store.markRunSucceeded(run.id);
|
||||
|
||||
const details = await store.getRunDetails(run.id);
|
||||
expect(details!.run.status).toBe('succeeded');
|
||||
expect(details!.findings.length).toBe(0);
|
||||
expect(details!.comments.length).toBe(1);
|
||||
expect(details!.comments[0].body).toContain('未发现');
|
||||
});
|
||||
|
||||
test('store persistence: data survives across store instances', async () => {
|
||||
const payload = makePRPayload();
|
||||
const { run } = await store.createOrReuseRun(payload);
|
||||
await store.acquireNextQueuedRun();
|
||||
|
||||
const findings: Finding[] = [
|
||||
{
|
||||
id: 'persist-f1',
|
||||
runId: run.id,
|
||||
fingerprint: 'persist-fp-1',
|
||||
category: 'security',
|
||||
severity: 'high',
|
||||
confidence: 0.95,
|
||||
path: 'src/auth.ts',
|
||||
line: 42,
|
||||
title: 'SQL injection',
|
||||
detail: 'Detail',
|
||||
evidence: 'Evidence',
|
||||
suggestion: 'Use parameterized queries',
|
||||
published: false,
|
||||
},
|
||||
];
|
||||
await store.addFindings(run.id, findings);
|
||||
await store.markFindingPublished(run.id, 'persist-fp-1');
|
||||
await store.markRunSucceeded(run.id);
|
||||
|
||||
const freshStore = new FileReviewStore(tempDir);
|
||||
await freshStore.init();
|
||||
|
||||
const details = await freshStore.getRunDetails(run.id);
|
||||
expect(details).not.toBeNull();
|
||||
expect(details!.run.status).toBe('succeeded');
|
||||
expect(details!.findings.length).toBe(1);
|
||||
expect(details!.findings[0].published).toBe(true);
|
||||
expect(details!.findings[0].fingerprint).toBe('persist-fp-1');
|
||||
});
|
||||
});
|
||||
137
src/review/__tests__/judge-agent.test.ts
Normal file
137
src/review/__tests__/judge-agent.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { JudgeAgent } from '../agents/judge-agent';
|
||||
import type { Finding } from '../types';
|
||||
|
||||
type TestFinding = Omit<Finding, 'id' | 'runId' | 'published'>;
|
||||
|
||||
function makeFinding(overrides: Partial<TestFinding> = {}): TestFinding {
|
||||
return {
|
||||
fingerprint: `fp-${Math.random().toString(36).slice(2, 8)}`,
|
||||
category: 'correctness',
|
||||
severity: 'medium',
|
||||
confidence: 0.8,
|
||||
path: 'src/foo.ts',
|
||||
line: 10,
|
||||
title: 'Test issue',
|
||||
detail: 'Detail',
|
||||
evidence: 'Evidence',
|
||||
suggestion: 'Fix it',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('JudgeAgent', () => {
|
||||
const judge = new JudgeAgent();
|
||||
|
||||
// ─── Empty input ───
|
||||
test('empty findings → summary says 未发现', () => {
|
||||
const result = judge.judge([]);
|
||||
expect(result.findings).toHaveLength(0);
|
||||
expect(result.summaryMarkdown).toContain('未发现');
|
||||
});
|
||||
|
||||
// ─── Deduplication by fingerprint ───
|
||||
test('duplicate fingerprints → keeps highest weighted', () => {
|
||||
const fp = 'same-fingerprint';
|
||||
const findings: TestFinding[] = [
|
||||
makeFinding({ fingerprint: fp, severity: 'low', confidence: 0.9 }), // weight: 1 * 0.9 = 0.9
|
||||
makeFinding({ fingerprint: fp, severity: 'high', confidence: 0.5 }), // weight: 3 * 0.5 = 1.5 ← winner
|
||||
makeFinding({ fingerprint: fp, severity: 'medium', confidence: 0.6 }), // weight: 2 * 0.6 = 1.2
|
||||
];
|
||||
const result = judge.judge(findings);
|
||||
expect(result.findings).toHaveLength(1);
|
||||
expect(result.findings[0].severity).toBe('high');
|
||||
expect(result.findings[0].confidence).toBe(0.5);
|
||||
});
|
||||
|
||||
test('same fingerprint same weight → first one wins (no override)', () => {
|
||||
const fp = 'dup-fp';
|
||||
const findings: TestFinding[] = [
|
||||
makeFinding({ fingerprint: fp, severity: 'high', confidence: 0.5, title: 'First' }),
|
||||
makeFinding({ fingerprint: fp, severity: 'high', confidence: 0.5, title: 'Second' }),
|
||||
];
|
||||
const result = judge.judge(findings);
|
||||
expect(result.findings).toHaveLength(1);
|
||||
// Same weight → second does NOT override (currentWeight > existingWeight is strict >)
|
||||
expect(result.findings[0].title).toBe('First');
|
||||
});
|
||||
|
||||
// ─── Sorting by severity × confidence ───
|
||||
test('findings sorted by weight descending', () => {
|
||||
const findings: TestFinding[] = [
|
||||
makeFinding({ fingerprint: 'a', severity: 'low', confidence: 0.9 }), // 1 * 0.9 = 0.9
|
||||
makeFinding({ fingerprint: 'b', severity: 'high', confidence: 0.8 }), // 3 * 0.8 = 2.4
|
||||
makeFinding({ fingerprint: 'c', severity: 'medium', confidence: 0.7 }), // 2 * 0.7 = 1.4
|
||||
];
|
||||
const result = judge.judge(findings);
|
||||
expect(result.findings).toHaveLength(3);
|
||||
expect(result.findings[0].fingerprint).toBe('b'); // weight 2.4
|
||||
expect(result.findings[1].fingerprint).toBe('c'); // weight 1.4
|
||||
expect(result.findings[2].fingerprint).toBe('a'); // weight 0.9
|
||||
});
|
||||
|
||||
// ─── Summary text ───
|
||||
test('summary counts by severity', () => {
|
||||
const findings: TestFinding[] = [
|
||||
makeFinding({ fingerprint: 'a', severity: 'high', confidence: 0.9 }),
|
||||
makeFinding({ fingerprint: 'b', severity: 'high', confidence: 0.85 }),
|
||||
makeFinding({ fingerprint: 'c', severity: 'medium', confidence: 0.8 }),
|
||||
makeFinding({ fingerprint: 'd', severity: 'low', confidence: 0.7 }),
|
||||
];
|
||||
const result = judge.judge(findings);
|
||||
expect(result.summaryMarkdown).toContain('4 个问题');
|
||||
expect(result.summaryMarkdown).toContain('high 2');
|
||||
expect(result.summaryMarkdown).toContain('medium 1');
|
||||
expect(result.summaryMarkdown).toContain('low 1');
|
||||
});
|
||||
|
||||
test('single finding → counts correctly', () => {
|
||||
const findings: TestFinding[] = [
|
||||
makeFinding({ fingerprint: 'x', severity: 'medium', confidence: 0.8 }),
|
||||
];
|
||||
const result = judge.judge(findings);
|
||||
expect(result.summaryMarkdown).toContain('1 个问题');
|
||||
expect(result.summaryMarkdown).toContain('high 0');
|
||||
expect(result.summaryMarkdown).toContain('medium 1');
|
||||
expect(result.summaryMarkdown).toContain('low 0');
|
||||
});
|
||||
|
||||
// ─── Dedup + sort combined ───
|
||||
test('dedup then sort: complex scenario', () => {
|
||||
const findings: TestFinding[] = [
|
||||
makeFinding({ fingerprint: 'x', severity: 'low', confidence: 0.3 }), // weight 0.3 — will be overridden
|
||||
makeFinding({ fingerprint: 'y', severity: 'high', confidence: 0.9 }), // weight 2.7 — unique
|
||||
makeFinding({ fingerprint: 'x', severity: 'medium', confidence: 0.8 }), // weight 1.6 — overrides x
|
||||
makeFinding({ fingerprint: 'z', severity: 'high', confidence: 0.5 }), // weight 1.5 — unique
|
||||
];
|
||||
const result = judge.judge(findings);
|
||||
expect(result.findings).toHaveLength(3); // x, y, z (deduped)
|
||||
// Sorted by weight: y(2.7) > x(1.6) > z(1.5)
|
||||
expect(result.findings[0].fingerprint).toBe('y');
|
||||
expect(result.findings[1].fingerprint).toBe('x');
|
||||
expect(result.findings[1].severity).toBe('medium'); // overridden version
|
||||
expect(result.findings[2].fingerprint).toBe('z');
|
||||
});
|
||||
|
||||
// ─── All same severity ───
|
||||
test('all high severity → sorted by confidence descending', () => {
|
||||
const findings: TestFinding[] = [
|
||||
makeFinding({ fingerprint: 'a', severity: 'high', confidence: 0.5 }),
|
||||
makeFinding({ fingerprint: 'b', severity: 'high', confidence: 0.9 }),
|
||||
makeFinding({ fingerprint: 'c', severity: 'high', confidence: 0.7 }),
|
||||
];
|
||||
const result = judge.judge(findings);
|
||||
expect(result.findings[0].fingerprint).toBe('b');
|
||||
expect(result.findings[1].fingerprint).toBe('c');
|
||||
expect(result.findings[2].fingerprint).toBe('a');
|
||||
});
|
||||
|
||||
// ─── Return type structure ───
|
||||
test('result has summaryMarkdown and findings', () => {
|
||||
const result = judge.judge([]);
|
||||
expect(result).toHaveProperty('summaryMarkdown');
|
||||
expect(result).toHaveProperty('findings');
|
||||
expect(typeof result.summaryMarkdown).toBe('string');
|
||||
expect(Array.isArray(result.findings)).toBe(true);
|
||||
});
|
||||
});
|
||||
197
src/review/__tests__/publish-policy.test.ts
Normal file
197
src/review/__tests__/publish-policy.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { applyPublishPolicy } from '../policy/publish-policy';
|
||||
import type { Finding } from '../types';
|
||||
|
||||
type TestFinding = Omit<Finding, 'id' | 'runId' | 'published'>;
|
||||
|
||||
function makeFinding(overrides: Partial<TestFinding> = {}): TestFinding {
|
||||
return {
|
||||
fingerprint: `fp-${Math.random().toString(36).slice(2, 8)}`,
|
||||
category: 'correctness',
|
||||
severity: 'medium',
|
||||
confidence: 0.9,
|
||||
path: 'src/foo.ts',
|
||||
line: 10,
|
||||
title: 'Test finding',
|
||||
detail: 'Detail',
|
||||
evidence: 'Evidence',
|
||||
suggestion: 'Fix it',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('applyPublishPolicy', () => {
|
||||
const MIN_CONFIDENCE = 0.8;
|
||||
|
||||
// ─── Empty input ───
|
||||
test('empty findings → all arrays empty', () => {
|
||||
const result = applyPublishPolicy([], MIN_CONFIDENCE, false);
|
||||
expect(result.publishable).toEqual([]);
|
||||
expect(result.gated).toEqual([]);
|
||||
expect(result.dropped).toEqual([]);
|
||||
});
|
||||
|
||||
// ─── High confidence + medium/high severity → publishable ───
|
||||
test('high severity + high confidence → publishable (humanGate off)', () => {
|
||||
const f = makeFinding({ severity: 'high', confidence: 0.95 });
|
||||
const result = applyPublishPolicy([f], MIN_CONFIDENCE, false);
|
||||
expect(result.publishable).toHaveLength(1);
|
||||
expect(result.gated).toHaveLength(0);
|
||||
expect(result.dropped).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('medium severity + high confidence → publishable (humanGate off)', () => {
|
||||
const f = makeFinding({ severity: 'medium', confidence: 0.85 });
|
||||
const result = applyPublishPolicy([f], MIN_CONFIDENCE, false);
|
||||
expect(result.publishable).toHaveLength(1);
|
||||
expect(result.gated).toHaveLength(0);
|
||||
expect(result.dropped).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('high severity + exactly at threshold → publishable', () => {
|
||||
const f = makeFinding({ severity: 'high', confidence: 0.8 });
|
||||
const result = applyPublishPolicy([f], MIN_CONFIDENCE, false);
|
||||
expect(result.publishable).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ─── Low severity → never publishable (even with high confidence) ───
|
||||
test('low severity + high confidence → dropped (humanGate off)', () => {
|
||||
const f = makeFinding({ severity: 'low', confidence: 0.95 });
|
||||
const result = applyPublishPolicy([f], MIN_CONFIDENCE, false);
|
||||
expect(result.publishable).toHaveLength(0);
|
||||
expect(result.gated).toHaveLength(0);
|
||||
expect(result.dropped).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('low severity + high confidence → gated (humanGate on)', () => {
|
||||
const f = makeFinding({ severity: 'low', confidence: 0.95 });
|
||||
const result = applyPublishPolicy([f], MIN_CONFIDENCE, true);
|
||||
expect(result.publishable).toHaveLength(0);
|
||||
expect(result.gated).toHaveLength(1);
|
||||
expect(result.dropped).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ─── Low confidence → not publishable ───
|
||||
test('high severity + low confidence → dropped (humanGate off)', () => {
|
||||
const f = makeFinding({ severity: 'high', confidence: 0.5 });
|
||||
const result = applyPublishPolicy([f], MIN_CONFIDENCE, false);
|
||||
expect(result.publishable).toHaveLength(0);
|
||||
expect(result.dropped).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('high severity + low confidence → gated (humanGate on)', () => {
|
||||
const f = makeFinding({ severity: 'high', confidence: 0.5 });
|
||||
const result = applyPublishPolicy([f], MIN_CONFIDENCE, true);
|
||||
expect(result.publishable).toHaveLength(0);
|
||||
expect(result.gated).toHaveLength(1);
|
||||
expect(result.dropped).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('medium severity + below threshold → dropped (humanGate off)', () => {
|
||||
const f = makeFinding({ severity: 'medium', confidence: 0.7 });
|
||||
const result = applyPublishPolicy([f], MIN_CONFIDENCE, false);
|
||||
expect(result.publishable).toHaveLength(0);
|
||||
expect(result.dropped).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('medium severity + below threshold → gated (humanGate on)', () => {
|
||||
const f = makeFinding({ severity: 'medium', confidence: 0.7 });
|
||||
const result = applyPublishPolicy([f], MIN_CONFIDENCE, true);
|
||||
expect(result.publishable).toHaveLength(0);
|
||||
expect(result.gated).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ─── Human gate ON: non-publishable → always gated, never dropped ───
|
||||
test('humanGate on: low confidence low severity → gated', () => {
|
||||
const f = makeFinding({ severity: 'low', confidence: 0.3 });
|
||||
const result = applyPublishPolicy([f], MIN_CONFIDENCE, true);
|
||||
expect(result.publishable).toHaveLength(0);
|
||||
expect(result.gated).toHaveLength(1);
|
||||
expect(result.dropped).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ─── Mixed findings ───
|
||||
test('mixed findings split correctly', () => {
|
||||
const findings: TestFinding[] = [
|
||||
makeFinding({ severity: 'high', confidence: 0.95 }), // → publishable
|
||||
makeFinding({ severity: 'medium', confidence: 0.85 }), // → publishable
|
||||
makeFinding({ severity: 'low', confidence: 0.9 }), // → dropped (low severity, humanGate off)
|
||||
makeFinding({ severity: 'high', confidence: 0.5 }), // → dropped (low confidence)
|
||||
makeFinding({ severity: 'medium', confidence: 0.6 }), // → dropped (low confidence)
|
||||
];
|
||||
const result = applyPublishPolicy(findings, MIN_CONFIDENCE, false);
|
||||
expect(result.publishable).toHaveLength(2);
|
||||
expect(result.gated).toHaveLength(0);
|
||||
expect(result.dropped).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('mixed findings with humanGate on', () => {
|
||||
const findings: TestFinding[] = [
|
||||
makeFinding({ severity: 'high', confidence: 0.95 }), // → publishable
|
||||
makeFinding({ severity: 'low', confidence: 0.9 }), // → gated
|
||||
makeFinding({ severity: 'high', confidence: 0.5 }), // → gated
|
||||
];
|
||||
const result = applyPublishPolicy(findings, MIN_CONFIDENCE, true);
|
||||
expect(result.publishable).toHaveLength(1);
|
||||
expect(result.gated).toHaveLength(2);
|
||||
expect(result.dropped).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ─── Boundary: confidence exactly at threshold ───
|
||||
test('confidence exactly at threshold + medium severity → publishable', () => {
|
||||
const f = makeFinding({ severity: 'medium', confidence: MIN_CONFIDENCE });
|
||||
const result = applyPublishPolicy([f], MIN_CONFIDENCE, false);
|
||||
expect(result.publishable).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('confidence just below threshold + medium severity → dropped', () => {
|
||||
const f = makeFinding({ severity: 'medium', confidence: MIN_CONFIDENCE - 0.01 });
|
||||
const result = applyPublishPolicy([f], MIN_CONFIDENCE, false);
|
||||
expect(result.dropped).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ─── All same fingerprint (policy doesn't dedup, that's judge's job) ───
|
||||
test('all findings same fingerprint → all processed independently', () => {
|
||||
const fp = 'shared-fingerprint';
|
||||
const findings: TestFinding[] = [
|
||||
makeFinding({ fingerprint: fp, severity: 'high', confidence: 0.9 }),
|
||||
makeFinding({ fingerprint: fp, severity: 'medium', confidence: 0.85 }),
|
||||
makeFinding({ fingerprint: fp, severity: 'low', confidence: 0.95 }),
|
||||
];
|
||||
const result = applyPublishPolicy(findings, MIN_CONFIDENCE, false);
|
||||
// Policy doesn't care about fingerprint - each finding evaluated independently
|
||||
expect(result.publishable).toHaveLength(2); // high+medium
|
||||
expect(result.dropped).toHaveLength(1); // low severity
|
||||
});
|
||||
|
||||
// ─── Different minConfidence thresholds ───
|
||||
test('very low threshold → more findings publishable', () => {
|
||||
const f = makeFinding({ severity: 'medium', confidence: 0.3 });
|
||||
const result = applyPublishPolicy([f], 0.1, false);
|
||||
expect(result.publishable).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('very high threshold → more findings dropped', () => {
|
||||
const f = makeFinding({ severity: 'high', confidence: 0.95 });
|
||||
const result = applyPublishPolicy([f], 0.99, false);
|
||||
expect(result.dropped).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ─── Return value structure ───
|
||||
test('returned findings preserve all original fields', () => {
|
||||
const f = makeFinding({
|
||||
severity: 'high',
|
||||
confidence: 0.95,
|
||||
path: 'src/important.ts',
|
||||
line: 42,
|
||||
title: 'Critical bug',
|
||||
detail: 'Detailed explanation',
|
||||
evidence: 'Code snippet',
|
||||
suggestion: 'Fix suggestion',
|
||||
category: 'security',
|
||||
fingerprint: 'unique-fp-123',
|
||||
});
|
||||
const result = applyPublishPolicy([f], MIN_CONFIDENCE, false);
|
||||
expect(result.publishable[0]).toEqual(f);
|
||||
});
|
||||
});
|
||||
162
src/review/__tests__/sandbox-exec.test.ts
Normal file
162
src/review/__tests__/sandbox-exec.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { SandboxExec } from '../context/sandbox-exec';
|
||||
|
||||
describe('SandboxExec', () => {
|
||||
// ─── Command whitelist ───
|
||||
describe('command whitelist', () => {
|
||||
test('allowed command executes successfully', async () => {
|
||||
const sandbox = new SandboxExec(['echo']);
|
||||
const result = await sandbox.run('echo', ['hello'], {
|
||||
cwd: '/tmp',
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('hello');
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test('disallowed command throws', async () => {
|
||||
const sandbox = new SandboxExec(['echo']);
|
||||
await expect(
|
||||
sandbox.run('rm', ['-rf', '/'], { cwd: '/tmp', timeoutMs: 5000 })
|
||||
).rejects.toThrow('命令未在白名单中: rm');
|
||||
});
|
||||
|
||||
test('empty whitelist blocks all commands', async () => {
|
||||
const sandbox = new SandboxExec([]);
|
||||
await expect(
|
||||
sandbox.run('echo', ['hello'], { cwd: '/tmp', timeoutMs: 5000 })
|
||||
).rejects.toThrow('命令未在白名单中: echo');
|
||||
});
|
||||
|
||||
test('multiple commands in whitelist', async () => {
|
||||
const sandbox = new SandboxExec(['echo', 'ls', 'cat']);
|
||||
const result = await sandbox.run('echo', ['test'], {
|
||||
cwd: '/tmp',
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('test');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error redaction (the token leak fix) ───
|
||||
describe('error redaction', () => {
|
||||
test('failed command error does NOT contain original error.message', async () => {
|
||||
const sandbox = new SandboxExec(['ls']);
|
||||
try {
|
||||
// ls a path that doesn't exist → stderr-based error
|
||||
await sandbox.run('ls', ['/nonexistent-path-that-does-not-exist-12345'], {
|
||||
cwd: '/tmp',
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
// If it doesn't throw, the path happened to exist, skip
|
||||
} catch (error: any) {
|
||||
// The error message should use stderr content or the redacted fallback
|
||||
// It should NOT include raw Node.js error.message which may contain tokens
|
||||
expect(error.message).toContain('命令执行失败');
|
||||
expect(error.message).toContain('ls');
|
||||
}
|
||||
});
|
||||
|
||||
test('error with no stderr uses redacted fallback message', async () => {
|
||||
const sandbox = new SandboxExec(['false']);
|
||||
try {
|
||||
// `false` exits with code 1, no stderr output
|
||||
await sandbox.run('false', [], {
|
||||
cwd: '/tmp',
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
} catch (error: any) {
|
||||
expect(error.message).toContain('命令执行失败');
|
||||
// Should use the redacted fallback, not error.message
|
||||
expect(error.message).toContain('(无 stderr,原始错误已脱敏)');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sensitive argument redaction ───
|
||||
describe('sensitive arg redaction in error messages', () => {
|
||||
test('URL with credentials is redacted in error', async () => {
|
||||
const sandbox = new SandboxExec(['git']);
|
||||
try {
|
||||
await sandbox.run(
|
||||
'git',
|
||||
['clone', 'https://user:secret-token@example.com/repo.git', '/nonexistent'],
|
||||
{ cwd: '/tmp', timeoutMs: 5000 }
|
||||
);
|
||||
} catch (error: any) {
|
||||
// The error message should have redacted credentials
|
||||
expect(error.message).not.toContain('secret-token');
|
||||
expect(error.message).toContain('***');
|
||||
}
|
||||
});
|
||||
|
||||
test('http.extraHeader Authorization token is redacted in error', async () => {
|
||||
const sandbox = new SandboxExec(['git']);
|
||||
try {
|
||||
await sandbox.run(
|
||||
'git',
|
||||
[
|
||||
'-c',
|
||||
'http.extraHeader=Authorization: token ghp_secrettoken123',
|
||||
'clone',
|
||||
'https://example.com/repo.git',
|
||||
'/nonexistent',
|
||||
],
|
||||
{ cwd: '/tmp', timeoutMs: 5000 }
|
||||
);
|
||||
} catch (error: any) {
|
||||
expect(error.message).not.toContain('ghp_secrettoken123');
|
||||
expect(error.message).toContain('***');
|
||||
}
|
||||
});
|
||||
|
||||
test('non-sensitive args are preserved in error', async () => {
|
||||
const sandbox = new SandboxExec(['ls']);
|
||||
try {
|
||||
await sandbox.run('ls', ['--color', '/nonexistent-12345'], {
|
||||
cwd: '/tmp',
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
} catch (error: any) {
|
||||
expect(error.message).toContain('--color');
|
||||
expect(error.message).toContain('/nonexistent-12345');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Duration tracking ───
|
||||
test('result includes durationMs', async () => {
|
||||
const sandbox = new SandboxExec(['echo']);
|
||||
const result = await sandbox.run('echo', ['hi'], {
|
||||
cwd: '/tmp',
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(typeof result.durationMs).toBe('number');
|
||||
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
// ─── Stderr capture ───
|
||||
test('stderr is captured on success', async () => {
|
||||
const sandbox = new SandboxExec(['ls']);
|
||||
const result = await sandbox.run('ls', ['/tmp'], {
|
||||
cwd: '/tmp',
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
// stderr should be a string (possibly empty)
|
||||
expect(typeof result.stderr).toBe('string');
|
||||
});
|
||||
|
||||
// ─── Environment isolation ───
|
||||
test('only PATH, HOME, LANG, LC_ALL are passed to child process', async () => {
|
||||
// Set a custom env var that should NOT be visible
|
||||
process.env.SUPER_SECRET_TOKEN = 'should-not-leak';
|
||||
const sandbox = new SandboxExec(['env']);
|
||||
const result = await sandbox.run('env', [], {
|
||||
cwd: '/tmp',
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(result.stdout).not.toContain('SUPER_SECRET_TOKEN');
|
||||
expect(result.stdout).not.toContain('should-not-leak');
|
||||
process.env.SUPER_SECRET_TOKEN = undefined;
|
||||
});
|
||||
});
|
||||
508
src/review/__tests__/specialist-agent-react.test.ts
Normal file
508
src/review/__tests__/specialist-agent-react.test.ts
Normal file
@@ -0,0 +1,508 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import { z } from 'zod';
|
||||
import { SpecialistAgent } from '../agents/specialist-agent';
|
||||
import { ToolRegistry } from '../tools/registry';
|
||||
import type { Tool } from '../tools/types';
|
||||
import type { FindingCategory, ReviewContext, ReviewRun } from '../types';
|
||||
|
||||
function makeRun(overrides: Partial<ReviewRun> = {}): ReviewRun {
|
||||
return {
|
||||
id: 'run-test-001',
|
||||
idempotencyKey: 'idem-test',
|
||||
eventType: 'pull_request',
|
||||
status: 'in_progress',
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
cloneUrl: 'https://example.com/repo.git',
|
||||
prNumber: 1,
|
||||
baseSha: 'aaa',
|
||||
headSha: 'bbb',
|
||||
attempts: 0,
|
||||
maxAttempts: 2,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(overrides: Partial<ReviewContext> = {}): ReviewContext {
|
||||
return {
|
||||
workspacePath: '/tmp/test-workspace',
|
||||
mirrorPath: '/tmp/test-mirror',
|
||||
diff: '--- a/src/foo.ts\n+++ b/src/foo.ts\n@@ -1,3 +1,4 @@\n+const x = null;\n export function foo() {}',
|
||||
changedFiles: [{ path: 'src/foo.ts', status: 'M', additions: 1, deletions: 0 }],
|
||||
parsedDiff: [
|
||||
{
|
||||
path: 'src/foo.ts',
|
||||
changes: [{ lineNumber: 1, content: 'const x = null;', type: 'add' }],
|
||||
},
|
||||
],
|
||||
fileContents: { 'src/foo.ts': 'const x = null;\nexport function foo() {}' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDummyTool(name = 'search_code'): Tool {
|
||||
return {
|
||||
name,
|
||||
description: 'Search code in the workspace',
|
||||
parameters: z.object({ query: z.string() }),
|
||||
execute: async () => ({ results: [] }),
|
||||
};
|
||||
}
|
||||
|
||||
type ChatCreateParams = {
|
||||
model: string;
|
||||
temperature: number;
|
||||
response_format?: { type: string };
|
||||
messages: any[];
|
||||
tools?: any[];
|
||||
tool_choice?: string;
|
||||
};
|
||||
|
||||
function createMockOpenAI(responses: Array<() => any>) {
|
||||
let callIndex = 0;
|
||||
const calls: ChatCreateParams[] = [];
|
||||
|
||||
return {
|
||||
client: {
|
||||
chat: {
|
||||
completions: {
|
||||
create: async (params: ChatCreateParams) => {
|
||||
calls.push(params);
|
||||
const responseFn = responses[callIndex] ?? responses[responses.length - 1];
|
||||
callIndex++;
|
||||
return responseFn();
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
getCalls: () => calls,
|
||||
};
|
||||
}
|
||||
|
||||
function toolCallResponse(toolCalls: Array<{ id: string; name: string; args: any }>) {
|
||||
return {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
tool_calls: toolCalls.map((tc) => ({
|
||||
id: tc.id,
|
||||
type: 'function',
|
||||
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
||||
})),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(data: any) {
|
||||
return {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: JSON.stringify(data),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function emptyResponse() {
|
||||
return { choices: [{ message: { role: 'assistant', content: null } }] };
|
||||
}
|
||||
|
||||
describe('SpecialistAgent ReAct loop', () => {
|
||||
const category: FindingCategory = 'correctness';
|
||||
|
||||
test('empty diff returns empty findings without calling OpenAI', async () => {
|
||||
const { client } = createMockOpenAI([]);
|
||||
const agent = new SpecialistAgent(client as any, 'gpt-4', category, 'TestAgent', 'bugs');
|
||||
const result = await agent.review(makeRun(), makeContext({ diff: ' ' }));
|
||||
expect(result.findings).toHaveLength(0);
|
||||
expect(result.agentName).toBe('TestAgent');
|
||||
});
|
||||
|
||||
test('no toolRegistry → uses legacy single-call mode', async () => {
|
||||
const finding = {
|
||||
severity: 'high',
|
||||
confidence: 0.9,
|
||||
path: 'src/foo.ts',
|
||||
line: 1,
|
||||
title: 'Null assignment',
|
||||
detail: 'x is null',
|
||||
evidence: 'const x = null',
|
||||
suggestion: 'Use undefined',
|
||||
};
|
||||
|
||||
const { client, getCalls } = createMockOpenAI([() => jsonResponse({ findings: [finding] })]);
|
||||
|
||||
const agent = new SpecialistAgent(client as any, 'gpt-4', category, 'TestAgent', 'bugs');
|
||||
const result = await agent.review(makeRun(), makeContext());
|
||||
|
||||
expect(result.findings).toHaveLength(1);
|
||||
expect(result.findings[0].severity).toBe('high');
|
||||
expect(result.findings[0].category).toBe('correctness');
|
||||
expect(result.findings[0].fingerprint).toBeTruthy();
|
||||
|
||||
const calls = getCalls();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].response_format).toEqual({ type: 'json_object' });
|
||||
});
|
||||
|
||||
test('ReAct: tool call → tool result → final JSON findings', async () => {
|
||||
const registry = new ToolRegistry();
|
||||
const executeFn = mock(async () => ({ results: ['some code match'] }));
|
||||
registry.register({ ...makeDummyTool(), execute: executeFn });
|
||||
|
||||
const finding = {
|
||||
severity: 'medium',
|
||||
confidence: 0.85,
|
||||
path: 'src/foo.ts',
|
||||
line: 1,
|
||||
title: 'Potential null',
|
||||
detail: 'Null assigned',
|
||||
evidence: 'const x = null',
|
||||
suggestion: 'Check usage',
|
||||
};
|
||||
|
||||
const { client, getCalls } = createMockOpenAI([
|
||||
() => toolCallResponse([{ id: 'call_1', name: 'search_code', args: { query: 'null' } }]),
|
||||
() => jsonResponse({ findings: [finding], need_more_investigation: false }),
|
||||
]);
|
||||
|
||||
const agent = new SpecialistAgent(
|
||||
client as any,
|
||||
'gpt-4',
|
||||
category,
|
||||
'TestAgent',
|
||||
'bugs',
|
||||
registry
|
||||
);
|
||||
const result = await agent.review(makeRun(), makeContext());
|
||||
|
||||
expect(executeFn).toHaveBeenCalledTimes(1);
|
||||
expect(result.findings).toHaveLength(1);
|
||||
expect(result.findings[0].category).toBe('correctness');
|
||||
|
||||
const calls = getCalls();
|
||||
expect(calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('ReAct: last iteration forces json_object + tool_choice=none', async () => {
|
||||
const registry = new ToolRegistry();
|
||||
registry.register(makeDummyTool());
|
||||
|
||||
const { client, getCalls } = createMockOpenAI([
|
||||
() => toolCallResponse([{ id: 'call_1', name: 'search_code', args: { query: 'x' } }]),
|
||||
() => toolCallResponse([{ id: 'call_2', name: 'search_code', args: { query: 'y' } }]),
|
||||
() => toolCallResponse([{ id: 'call_3', name: 'search_code', args: { query: 'z' } }]),
|
||||
() => toolCallResponse([{ id: 'call_4', name: 'search_code', args: { query: 'w' } }]),
|
||||
() => jsonResponse({ findings: [], need_more_investigation: false }),
|
||||
]);
|
||||
|
||||
const agent = new SpecialistAgent(
|
||||
client as any,
|
||||
'gpt-4',
|
||||
category,
|
||||
'TestAgent',
|
||||
'bugs',
|
||||
registry
|
||||
);
|
||||
await agent.review(makeRun(), makeContext());
|
||||
|
||||
const calls = getCalls();
|
||||
expect(calls).toHaveLength(5);
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
expect(calls[i].tool_choice).toBe('auto');
|
||||
expect(calls[i].response_format).toBeUndefined();
|
||||
}
|
||||
expect(calls[4].tool_choice).toBe('none');
|
||||
expect(calls[4].response_format).toEqual({ type: 'json_object' });
|
||||
});
|
||||
|
||||
test('ReAct: dead-loop prevention — need_more_investigation=true but no tool call injects user prompt', async () => {
|
||||
const registry = new ToolRegistry();
|
||||
registry.register(makeDummyTool());
|
||||
|
||||
const _callCount = 0;
|
||||
const { client, getCalls } = createMockOpenAI([
|
||||
() => jsonResponse({ findings: [], need_more_investigation: true }),
|
||||
() => jsonResponse({ findings: [], need_more_investigation: false }),
|
||||
]);
|
||||
|
||||
const agent = new SpecialistAgent(
|
||||
client as any,
|
||||
'gpt-4',
|
||||
category,
|
||||
'TestAgent',
|
||||
'bugs',
|
||||
registry
|
||||
);
|
||||
const _result = await agent.review(makeRun(), makeContext());
|
||||
|
||||
const calls = getCalls();
|
||||
expect(calls.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const secondCallMessages = calls[1].messages;
|
||||
const lastUserMsg = secondCallMessages.filter((m: any) => m.role === 'user').pop();
|
||||
expect(lastUserMsg.content).toContain('使用工具');
|
||||
});
|
||||
|
||||
test('ReAct: fingerprint dedup across iterations — later finding with same fp overwrites', async () => {
|
||||
const registry = new ToolRegistry();
|
||||
registry.register(makeDummyTool());
|
||||
|
||||
const findingV1 = {
|
||||
severity: 'low' as const,
|
||||
confidence: 0.6,
|
||||
path: 'src/foo.ts',
|
||||
line: 1,
|
||||
title: 'Null issue',
|
||||
detail: 'First version',
|
||||
evidence: 'const x = null',
|
||||
suggestion: 'Fix v1',
|
||||
fingerprint: 'shared-fp-123',
|
||||
};
|
||||
|
||||
const findingV2 = {
|
||||
...findingV1,
|
||||
severity: 'high' as const,
|
||||
confidence: 0.95,
|
||||
detail: 'Second version - more confident',
|
||||
suggestion: 'Fix v2',
|
||||
};
|
||||
|
||||
const { client } = createMockOpenAI([
|
||||
() => jsonResponse({ findings: [findingV1], need_more_investigation: true }),
|
||||
() => jsonResponse({ findings: [findingV2], need_more_investigation: false }),
|
||||
]);
|
||||
|
||||
const agent = new SpecialistAgent(
|
||||
client as any,
|
||||
'gpt-4',
|
||||
category,
|
||||
'TestAgent',
|
||||
'bugs',
|
||||
registry
|
||||
);
|
||||
const result = await agent.review(makeRun(), makeContext());
|
||||
|
||||
expect(result.findings).toHaveLength(1);
|
||||
expect(result.findings[0].severity).toBe('high');
|
||||
expect(result.findings[0].confidence).toBe(0.95);
|
||||
expect(result.findings[0].detail).toBe('Second version - more confident');
|
||||
});
|
||||
|
||||
test('ReAct: multiple unique fingerprints accumulate', async () => {
|
||||
const registry = new ToolRegistry();
|
||||
registry.register(makeDummyTool());
|
||||
|
||||
const finding1 = {
|
||||
severity: 'high' as const,
|
||||
confidence: 0.9,
|
||||
path: 'src/foo.ts',
|
||||
line: 1,
|
||||
title: 'Bug A',
|
||||
detail: 'Detail A',
|
||||
evidence: 'Evidence A',
|
||||
suggestion: 'Fix A',
|
||||
fingerprint: 'fp-aaa',
|
||||
};
|
||||
const finding2 = {
|
||||
severity: 'medium' as const,
|
||||
confidence: 0.8,
|
||||
path: 'src/bar.ts',
|
||||
line: 5,
|
||||
title: 'Bug B',
|
||||
detail: 'Detail B',
|
||||
evidence: 'Evidence B',
|
||||
suggestion: 'Fix B',
|
||||
fingerprint: 'fp-bbb',
|
||||
};
|
||||
|
||||
const { client } = createMockOpenAI([
|
||||
() => jsonResponse({ findings: [finding1], need_more_investigation: true }),
|
||||
() => jsonResponse({ findings: [finding2], need_more_investigation: false }),
|
||||
]);
|
||||
|
||||
const agent = new SpecialistAgent(
|
||||
client as any,
|
||||
'gpt-4',
|
||||
category,
|
||||
'TestAgent',
|
||||
'bugs',
|
||||
registry
|
||||
);
|
||||
const result = await agent.review(makeRun(), makeContext());
|
||||
|
||||
expect(result.findings).toHaveLength(2);
|
||||
const fps = result.findings.map((f) => f.fingerprint);
|
||||
expect(fps).toContain('fp-aaa');
|
||||
expect(fps).toContain('fp-bbb');
|
||||
});
|
||||
|
||||
test('ReAct: OpenAI error returns empty findings gracefully', async () => {
|
||||
const registry = new ToolRegistry();
|
||||
registry.register(makeDummyTool());
|
||||
|
||||
const { client } = createMockOpenAI([
|
||||
() => {
|
||||
throw new Error('API rate limited');
|
||||
},
|
||||
]);
|
||||
|
||||
const agent = new SpecialistAgent(
|
||||
client as any,
|
||||
'gpt-4',
|
||||
category,
|
||||
'TestAgent',
|
||||
'bugs',
|
||||
registry
|
||||
);
|
||||
const result = await agent.review(makeRun(), makeContext());
|
||||
|
||||
expect(result.findings).toHaveLength(0);
|
||||
expect(result.agentName).toBe('TestAgent');
|
||||
});
|
||||
|
||||
test('ReAct: unknown tool call returns error result to model', async () => {
|
||||
const registry = new ToolRegistry();
|
||||
registry.register(makeDummyTool('search_code'));
|
||||
|
||||
const { client, getCalls } = createMockOpenAI([
|
||||
() => toolCallResponse([{ id: 'call_1', name: 'nonexistent_tool', args: {} }]),
|
||||
() => jsonResponse({ findings: [], need_more_investigation: false }),
|
||||
]);
|
||||
|
||||
const agent = new SpecialistAgent(
|
||||
client as any,
|
||||
'gpt-4',
|
||||
category,
|
||||
'TestAgent',
|
||||
'bugs',
|
||||
registry
|
||||
);
|
||||
const _result = await agent.review(makeRun(), makeContext());
|
||||
|
||||
const calls = getCalls();
|
||||
expect(calls).toHaveLength(2);
|
||||
const toolResultMsg = calls[1].messages.find(
|
||||
(m: any) => m.role === 'tool' && m.tool_call_id === 'call_1'
|
||||
);
|
||||
expect(toolResultMsg).toBeTruthy();
|
||||
const parsed = JSON.parse(toolResultMsg.content);
|
||||
expect(parsed.error).toContain('未找到');
|
||||
});
|
||||
|
||||
test('ReAct: tool execution error is captured and returned to model', async () => {
|
||||
const registry = new ToolRegistry();
|
||||
registry.register({
|
||||
...makeDummyTool(),
|
||||
execute: async () => {
|
||||
throw new Error('Sandbox timeout');
|
||||
},
|
||||
});
|
||||
|
||||
const { client, getCalls } = createMockOpenAI([
|
||||
() => toolCallResponse([{ id: 'call_1', name: 'search_code', args: { query: 'x' } }]),
|
||||
() => jsonResponse({ findings: [], need_more_investigation: false }),
|
||||
]);
|
||||
|
||||
const agent = new SpecialistAgent(
|
||||
client as any,
|
||||
'gpt-4',
|
||||
category,
|
||||
'TestAgent',
|
||||
'bugs',
|
||||
registry
|
||||
);
|
||||
await agent.review(makeRun(), makeContext());
|
||||
|
||||
const calls = getCalls();
|
||||
const toolResultMsg = calls[1].messages.find(
|
||||
(m: any) => m.role === 'tool' && m.tool_call_id === 'call_1'
|
||||
);
|
||||
const parsed = JSON.parse(toolResultMsg.content);
|
||||
expect(parsed.error).toContain('Sandbox timeout');
|
||||
});
|
||||
|
||||
test('ReAct: empty choice content ends loop', async () => {
|
||||
const registry = new ToolRegistry();
|
||||
registry.register(makeDummyTool());
|
||||
|
||||
const { client } = createMockOpenAI([() => emptyResponse()]);
|
||||
|
||||
const agent = new SpecialistAgent(
|
||||
client as any,
|
||||
'gpt-4',
|
||||
category,
|
||||
'TestAgent',
|
||||
'bugs',
|
||||
registry
|
||||
);
|
||||
const result = await agent.review(makeRun(), makeContext());
|
||||
|
||||
expect(result.findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('ReAct: malformed JSON response ends loop gracefully', async () => {
|
||||
const registry = new ToolRegistry();
|
||||
registry.register(makeDummyTool());
|
||||
|
||||
const { client } = createMockOpenAI([
|
||||
() => ({ choices: [{ message: { role: 'assistant', content: 'not valid json {{{' } }] }),
|
||||
]);
|
||||
|
||||
const agent = new SpecialistAgent(
|
||||
client as any,
|
||||
'gpt-4',
|
||||
category,
|
||||
'TestAgent',
|
||||
'bugs',
|
||||
registry
|
||||
);
|
||||
const result = await agent.review(makeRun(), makeContext());
|
||||
|
||||
expect(result.findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('ReAct: auto-generates fingerprint when finding has none', async () => {
|
||||
const registry = new ToolRegistry();
|
||||
registry.register(makeDummyTool());
|
||||
|
||||
const finding = {
|
||||
severity: 'high' as const,
|
||||
confidence: 0.9,
|
||||
path: 'src/foo.ts',
|
||||
line: 1,
|
||||
title: 'Missing null check',
|
||||
detail: 'Detail',
|
||||
evidence: 'Evidence',
|
||||
suggestion: 'Add check',
|
||||
};
|
||||
|
||||
const { client } = createMockOpenAI([
|
||||
() => jsonResponse({ findings: [finding], need_more_investigation: false }),
|
||||
]);
|
||||
|
||||
const agent = new SpecialistAgent(
|
||||
client as any,
|
||||
'gpt-4',
|
||||
category,
|
||||
'TestAgent',
|
||||
'bugs',
|
||||
registry
|
||||
);
|
||||
const result = await agent.review(makeRun(), makeContext());
|
||||
|
||||
expect(result.findings).toHaveLength(1);
|
||||
expect(result.findings[0].fingerprint).toBeTruthy();
|
||||
expect(result.findings[0].fingerprint.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
23
src/review/agents/correctness-agent.ts
Normal file
23
src/review/agents/correctness-agent.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import OpenAI from 'openai';
|
||||
import type { LearningSystem } from '../learning/learning-system';
|
||||
import { ToolRegistry } from '../tools/registry';
|
||||
import { SpecialistAgent } from './specialist-agent';
|
||||
|
||||
export class CorrectnessAgent extends SpecialistAgent {
|
||||
constructor(
|
||||
openai: OpenAI,
|
||||
model: string,
|
||||
toolRegistry?: ToolRegistry,
|
||||
learningSystem?: LearningSystem
|
||||
) {
|
||||
super(
|
||||
openai,
|
||||
model,
|
||||
'correctness',
|
||||
'Correctness Agent',
|
||||
'业务逻辑正确性、边界条件、空值处理和明显bug',
|
||||
toolRegistry,
|
||||
learningSystem
|
||||
);
|
||||
}
|
||||
}
|
||||
198
src/review/agents/critic-agent.ts
Normal file
198
src/review/agents/critic-agent.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import OpenAI from 'openai';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { Finding, ReviewContext } from '../types';
|
||||
|
||||
export interface CritiqueResult {
|
||||
qualityScore: number; // 0-1
|
||||
issues: CritiqueIssue[];
|
||||
missedIssues: string[];
|
||||
overallAssessment: string;
|
||||
}
|
||||
|
||||
export interface CritiqueIssue {
|
||||
findingIndex: number;
|
||||
problem: string;
|
||||
suggestion: string;
|
||||
severity: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
export class CriticAgent {
|
||||
constructor(
|
||||
private openai: OpenAI,
|
||||
private model: string
|
||||
) {}
|
||||
|
||||
async critique(
|
||||
findings: Omit<Finding, 'id' | 'runId' | 'published'>[],
|
||||
context: ReviewContext
|
||||
): Promise<CritiqueResult> {
|
||||
if (findings.length === 0) {
|
||||
return {
|
||||
qualityScore: 1.0,
|
||||
issues: [],
|
||||
missedIssues: [],
|
||||
overallAssessment: '无findings需要评估',
|
||||
};
|
||||
}
|
||||
|
||||
const prompt = `你是严格的代码审查质量评估专家。评估以下审查结果的质量。
|
||||
|
||||
审查结果(${findings.length}个问题):
|
||||
${JSON.stringify(findings, null, 2)}
|
||||
|
||||
原始代码变更片段(供参考):
|
||||
${context.diff.slice(0, 3000)}
|
||||
|
||||
评估标准:
|
||||
1. **Evidence充分性**: 证据是否充分支持结论?是否引用了具体代码?
|
||||
2. **误报风险**: 是否可能是false positive?是否考虑了上下文?
|
||||
3. **Severity准确性**: 严重性评估是否合理?
|
||||
4. **Confidence合理性**: 置信度评分是否反映了证据强度?
|
||||
5. **Suggestion可行性**: 建议是否具体、可操作?
|
||||
6. **遗漏问题**: 是否遗漏了明显的问题?
|
||||
|
||||
返回JSON格式:
|
||||
{
|
||||
"quality_score": 0.0-1.0,
|
||||
"issues": [
|
||||
{
|
||||
"finding_index": 0,
|
||||
"problem": "证据不足,仅基于猜测",
|
||||
"suggestion": "需要引用具体代码行并说明为何存在问题",
|
||||
"severity": "high" | "medium" | "low"
|
||||
}
|
||||
],
|
||||
"missed_issues": [
|
||||
"可能遗漏的问题描述"
|
||||
],
|
||||
"overall_assessment": "总体评估说明"
|
||||
}`;
|
||||
|
||||
try {
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: this.model,
|
||||
temperature: 0.1, // 略高于0以允许批判性思考
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: '你是严格的代码审查质量评估专家,以高标准评估findings的质量。',
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message.content;
|
||||
if (!content) {
|
||||
throw new Error('Critic Agent返回空结果');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
const result: CritiqueResult = {
|
||||
// 使用 ?? 而非 ||,保留有效的0分(最差评价)
|
||||
qualityScore: parsed.quality_score ?? 0.5,
|
||||
issues: (parsed.issues || []).map((issue: any) => ({
|
||||
findingIndex: issue.finding_index || 0,
|
||||
problem: issue.problem || '',
|
||||
suggestion: issue.suggestion || '',
|
||||
severity: issue.severity || 'medium',
|
||||
})),
|
||||
missedIssues: parsed.missed_issues || [],
|
||||
overallAssessment: parsed.overall_assessment || '',
|
||||
};
|
||||
|
||||
logger.debug('Critic Agent评估完成', {
|
||||
findingsCount: findings.length,
|
||||
qualityScore: result.qualityScore,
|
||||
issuesFound: result.issues.length,
|
||||
missedIssues: result.missedIssues.length,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('Critic Agent执行失败', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
// 返回默认评估,避免阻塞流程
|
||||
return {
|
||||
qualityScore: 0.7,
|
||||
issues: [],
|
||||
missedIssues: [],
|
||||
overallAssessment: 'Critic Agent执行失败,使用默认评估',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async evaluateSingleFinding(
|
||||
finding: Omit<Finding, 'id' | 'runId' | 'published'>,
|
||||
context: ReviewContext
|
||||
): Promise<{
|
||||
isValid: boolean;
|
||||
confidence: number;
|
||||
issues: string[];
|
||||
}> {
|
||||
const prompt = `评估以下代码审查finding的有效性:
|
||||
|
||||
Finding:
|
||||
- Title: ${finding.title}
|
||||
- Detail: ${finding.detail}
|
||||
- Evidence: ${finding.evidence}
|
||||
- Severity: ${finding.severity}
|
||||
- Confidence: ${finding.confidence}
|
||||
|
||||
代码上下文:
|
||||
${context.diff.slice(0, 2000)}
|
||||
|
||||
判断:
|
||||
1. 这个finding是否有效(不是误报)?
|
||||
2. 置信度评估是否合理?
|
||||
3. 有哪些问题或改进建议?
|
||||
|
||||
返回JSON:
|
||||
{
|
||||
"is_valid": true/false,
|
||||
"confidence": 0.0-1.0,
|
||||
"issues": ["问题描述1", "问题描述2"]
|
||||
}`;
|
||||
|
||||
try {
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: this.model,
|
||||
temperature: 0,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: '你是代码审查质量评估专家。',
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message.content;
|
||||
if (!content) {
|
||||
throw new Error('评估失败');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
return {
|
||||
isValid: parsed.is_valid ?? true,
|
||||
confidence: parsed.confidence ?? finding.confidence,
|
||||
issues: parsed.issues || [],
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('单个finding评估失败', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
confidence: finding.confidence,
|
||||
issues: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
329
src/review/agents/debate-orchestrator.ts
Normal file
329
src/review/agents/debate-orchestrator.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import OpenAI from 'openai';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { Finding, FindingSeverity } from '../types';
|
||||
import { SpecialistAgent } from './specialist-agent';
|
||||
|
||||
interface AgentOpinion {
|
||||
agentName: string;
|
||||
confidence: number;
|
||||
severity: FindingSeverity;
|
||||
reasoning: string;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
export class DebateOrchestrator {
|
||||
private openai: OpenAI;
|
||||
private model: string;
|
||||
|
||||
constructor(openai: OpenAI, model: string) {
|
||||
this.openai = openai;
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
async conductDebate(
|
||||
finding: Omit<Finding, 'id' | 'runId' | 'published'>,
|
||||
agents: SpecialistAgent[],
|
||||
maxRounds = 2
|
||||
): Promise<Omit<Finding, 'id' | 'runId' | 'published'>> {
|
||||
if (agents.length < 2) {
|
||||
logger.debug('Debate需要至少2个agents,跳过');
|
||||
return finding;
|
||||
}
|
||||
|
||||
logger.info('启动Debate', {
|
||||
finding: finding.title,
|
||||
agentsCount: agents.length,
|
||||
maxRounds,
|
||||
});
|
||||
|
||||
const opinions = new Map<string, AgentOpinion>();
|
||||
|
||||
// 收集初始意见
|
||||
for (const agent of agents) {
|
||||
const opinion = await this.getAgentOpinion(agent, finding);
|
||||
opinions.set((agent as any).agentName, opinion);
|
||||
}
|
||||
|
||||
// 辩论轮次
|
||||
for (let round = 0; round < maxRounds; round++) {
|
||||
logger.debug(`Debate Round ${round + 1}/${maxRounds}`, {
|
||||
finding: finding.title,
|
||||
});
|
||||
|
||||
for (const agent of agents) {
|
||||
const agentName = (agent as any).agentName;
|
||||
const otherOpinions = Array.from(opinions.entries()).filter(([name]) => name !== agentName);
|
||||
|
||||
const revisedOpinion = await this.reviseOpinion(agent, finding, otherOpinions, opinions);
|
||||
|
||||
opinions.set(agentName, revisedOpinion);
|
||||
}
|
||||
|
||||
// 检查是否已达成共识
|
||||
if (this.hasConsensus(opinions)) {
|
||||
logger.info(`Debate在第${round + 1}轮达成共识`, {
|
||||
finding: finding.title,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 形成共识
|
||||
return this.formConsensus(finding, opinions);
|
||||
}
|
||||
|
||||
private async getAgentOpinion(
|
||||
agent: SpecialistAgent,
|
||||
finding: Omit<Finding, 'id' | 'runId' | 'published'>
|
||||
): Promise<AgentOpinion> {
|
||||
const agentName = (agent as any).agentName;
|
||||
const prompt = `你是${agentName}。评估以下代码问题的严重性、置信度和有效性。
|
||||
|
||||
问题:
|
||||
- Title: ${finding.title}
|
||||
- Detail: ${finding.detail}
|
||||
- Evidence: ${finding.evidence}
|
||||
- Current Severity: ${finding.severity}
|
||||
- Current Confidence: ${finding.confidence}
|
||||
|
||||
从你的专业角度判断:
|
||||
1. 这个问题是否真实存在(不是误报)?
|
||||
2. 严重性评估是否准确?
|
||||
3. 你的置信度是多少?
|
||||
4. 你的判断理由?
|
||||
|
||||
返回JSON:
|
||||
{
|
||||
"is_valid": true/false,
|
||||
"confidence": 0.0-1.0,
|
||||
"severity": "high" | "medium" | "low",
|
||||
"reasoning": "你的判断理由(详细说明)"
|
||||
}`;
|
||||
|
||||
try {
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: this.model,
|
||||
temperature: 0.2,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `你是${agentName},从你的专业角度独立评估代码问题。`,
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message.content;
|
||||
if (!content) {
|
||||
throw new Error('Agent opinion返回空');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
return {
|
||||
agentName,
|
||||
// 使用 ?? 而非 ||,保留有效的0置信度(完全不确定/强烈拒绝)
|
||||
confidence: parsed.confidence ?? 0.5,
|
||||
severity: parsed.severity || 'medium',
|
||||
reasoning: parsed.reasoning || '',
|
||||
isValid: parsed.is_valid ?? true,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`获取${agentName}意见失败`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
return {
|
||||
agentName,
|
||||
confidence: finding.confidence,
|
||||
severity: finding.severity,
|
||||
reasoning: '获取意见失败,使用默认值',
|
||||
isValid: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async reviseOpinion(
|
||||
agent: SpecialistAgent,
|
||||
finding: Omit<Finding, 'id' | 'runId' | 'published'>,
|
||||
otherOpinions: [string, AgentOpinion][],
|
||||
opinions: Map<string, AgentOpinion>
|
||||
): Promise<AgentOpinion> {
|
||||
const agentName = (agent as any).agentName;
|
||||
const prompt = `你是${agentName}。重新评估以下问题,考虑其他专家的意见。
|
||||
|
||||
问题:
|
||||
- Title: ${finding.title}
|
||||
- Evidence: ${finding.evidence}
|
||||
|
||||
其他专家意见:
|
||||
${otherOpinions
|
||||
.map(
|
||||
([name, op]) =>
|
||||
`- ${name}: ${op.isValid ? '有效' : '误报'}, ${op.severity} (置信度 ${op.confidence.toFixed(2)})\n 理由: ${
|
||||
op.reasoning
|
||||
}`
|
||||
)
|
||||
.join('\n')}
|
||||
|
||||
基于同行的意见,你是否改变观点?
|
||||
|
||||
返回JSON:
|
||||
{
|
||||
"is_valid": true/false,
|
||||
"confidence": 0.0-1.0,
|
||||
"severity": "high" | "medium" | "low",
|
||||
"reasoning": "修正后的理由或坚持原判断的原因"
|
||||
}`;
|
||||
|
||||
try {
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: this.model,
|
||||
temperature: 0.3, // 允许一定灵活性
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `你是${agentName},根据同行意见重新评估,但也要坚持你的专业判断。`,
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message.content;
|
||||
if (!content) {
|
||||
throw new Error('Revised opinion返回空');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
return {
|
||||
agentName,
|
||||
// 使用 ?? 而非 ||,保留有效的0置信度(完全不确定/强烈拒绝)
|
||||
confidence: parsed.confidence ?? 0.5,
|
||||
severity: parsed.severity || 'medium',
|
||||
reasoning: parsed.reasoning || '',
|
||||
isValid: parsed.is_valid ?? true,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${agentName}修订意见失败`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
// 返回当前意见(从opinions Map中获取)
|
||||
const currentOpinion = opinions.get(agentName);
|
||||
return (
|
||||
currentOpinion || {
|
||||
agentName,
|
||||
confidence: 0.5,
|
||||
severity: 'medium',
|
||||
reasoning: '修订失败',
|
||||
isValid: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private hasConsensus(opinions: Map<string, AgentOpinion>): boolean {
|
||||
const votes = Array.from(opinions.values());
|
||||
|
||||
if (votes.length === 0) return true;
|
||||
|
||||
// 检查有效性共识(至少80%同意)
|
||||
const validCount = votes.filter((v) => v.isValid).length;
|
||||
const validRatio = validCount / votes.length;
|
||||
|
||||
if (validRatio >= 0.8 || validRatio <= 0.2) {
|
||||
return true; // 大多数同意有效或无效
|
||||
}
|
||||
|
||||
// 检查严重性共识
|
||||
const severityCounts: Record<FindingSeverity, number> = {
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
};
|
||||
|
||||
votes.forEach((v) => {
|
||||
severityCounts[v.severity]++;
|
||||
});
|
||||
|
||||
const maxCount = Math.max(...Object.values(severityCounts));
|
||||
const consensusRatio = maxCount / votes.length;
|
||||
|
||||
return consensusRatio >= 0.7; // 70%同意同一严重性
|
||||
}
|
||||
|
||||
private formConsensus(
|
||||
finding: Omit<Finding, 'id' | 'runId' | 'published'>,
|
||||
opinions: Map<string, AgentOpinion>
|
||||
): Omit<Finding, 'id' | 'runId' | 'published'> {
|
||||
const votes = Array.from(opinions.values());
|
||||
|
||||
if (votes.length === 0) {
|
||||
return finding;
|
||||
}
|
||||
|
||||
// 判断有效性(投票)
|
||||
const validCount = votes.filter((v) => v.isValid).length;
|
||||
const isValid = validCount > votes.length / 2;
|
||||
|
||||
// 如果被判定为无效,降低置信度
|
||||
if (!isValid) {
|
||||
logger.info('Debate判定为无效finding', {
|
||||
finding: finding.title,
|
||||
validVotes: validCount,
|
||||
totalVotes: votes.length,
|
||||
});
|
||||
|
||||
return {
|
||||
...finding,
|
||||
confidence: Math.min(finding.confidence, 0.4),
|
||||
detail: `${finding.detail}\n\n**Debate结果**: 多数专家认为此问题可能是误报(${validCount}/${votes.length}认为有效)`,
|
||||
};
|
||||
}
|
||||
|
||||
// 计算平均置信度(仅计算认为有效的votes)
|
||||
const validVotes = votes.filter((v) => v.isValid);
|
||||
const avgConfidence = validVotes.reduce((sum, v) => sum + v.confidence, 0) / validVotes.length;
|
||||
|
||||
// 严重性投票(加权)
|
||||
const severityVotes: Record<FindingSeverity, number> = {
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
};
|
||||
|
||||
validVotes.forEach((vote) => {
|
||||
severityVotes[vote.severity] += vote.confidence;
|
||||
});
|
||||
|
||||
const agreedSeverity =
|
||||
(Object.entries(severityVotes).sort((a, b) => b[1] - a[1])[0][0] as FindingSeverity) ||
|
||||
finding.severity;
|
||||
|
||||
// 综合推理
|
||||
const synthesizedDetail = `${finding.detail}\n\n**专家Debate意见汇总:**\n${validVotes
|
||||
.map(
|
||||
(v) => `- ${v.agentName} (${v.severity}, 置信度${v.confidence.toFixed(2)}): ${v.reasoning}`
|
||||
)
|
||||
.join('\n')}`;
|
||||
|
||||
logger.info('Debate达成共识', {
|
||||
finding: finding.title,
|
||||
originalSeverity: finding.severity,
|
||||
agreedSeverity,
|
||||
originalConfidence: finding.confidence,
|
||||
avgConfidence,
|
||||
validVotes: validVotes.length,
|
||||
});
|
||||
|
||||
return {
|
||||
...finding,
|
||||
confidence: avgConfidence,
|
||||
severity: agreedSeverity,
|
||||
detail: synthesizedDetail,
|
||||
};
|
||||
}
|
||||
}
|
||||
54
src/review/agents/judge-agent.ts
Normal file
54
src/review/agents/judge-agent.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Finding, ReviewDecision } from '../types';
|
||||
|
||||
const severityWeight: Record<Finding['severity'], number> = {
|
||||
high: 3,
|
||||
medium: 2,
|
||||
low: 1,
|
||||
};
|
||||
|
||||
function summarizeFindings(findings: Omit<Finding, 'id' | 'runId' | 'published'>[]): string {
|
||||
if (findings.length === 0) {
|
||||
return '本次变更未发现需要立即处理的高置信问题。建议人工快速复核关键业务路径。';
|
||||
}
|
||||
|
||||
const total = findings.length;
|
||||
const high = findings.filter((item) => item.severity === 'high').length;
|
||||
const medium = findings.filter((item) => item.severity === 'medium').length;
|
||||
const low = findings.filter((item) => item.severity === 'low').length;
|
||||
|
||||
return [
|
||||
`本次 AI Agent 审查共识别 ${total} 个问题,其中 high ${high} 个、medium ${medium} 个、low ${low} 个。`,
|
||||
'以下评论按风险优先级自动发布,建议优先处理 high 与 medium 项。',
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
export class JudgeAgent {
|
||||
judge(results: Omit<Finding, 'id' | 'runId' | 'published'>[]): ReviewDecision {
|
||||
const bestByFingerprint = new Map<string, Omit<Finding, 'id' | 'runId' | 'published'>>();
|
||||
|
||||
for (const finding of results) {
|
||||
const existing = bestByFingerprint.get(finding.fingerprint);
|
||||
if (!existing) {
|
||||
bestByFingerprint.set(finding.fingerprint, finding);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingWeight = severityWeight[existing.severity] * existing.confidence;
|
||||
const currentWeight = severityWeight[finding.severity] * finding.confidence;
|
||||
if (currentWeight > existingWeight) {
|
||||
bestByFingerprint.set(finding.fingerprint, finding);
|
||||
}
|
||||
}
|
||||
|
||||
const findings = [...bestByFingerprint.values()].sort((a, b) => {
|
||||
const scoreA = severityWeight[a.severity] * a.confidence;
|
||||
const scoreB = severityWeight[b.severity] * b.confidence;
|
||||
return scoreB - scoreA;
|
||||
});
|
||||
|
||||
return {
|
||||
summaryMarkdown: summarizeFindings(findings),
|
||||
findings,
|
||||
};
|
||||
}
|
||||
}
|
||||
23
src/review/agents/maintainability-agent.ts
Normal file
23
src/review/agents/maintainability-agent.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import OpenAI from 'openai';
|
||||
import type { LearningSystem } from '../learning/learning-system';
|
||||
import { ToolRegistry } from '../tools/registry';
|
||||
import { SpecialistAgent } from './specialist-agent';
|
||||
|
||||
export class MaintainabilityAgent extends SpecialistAgent {
|
||||
constructor(
|
||||
openai: OpenAI,
|
||||
model: string,
|
||||
toolRegistry?: ToolRegistry,
|
||||
learningSystem?: LearningSystem
|
||||
) {
|
||||
super(
|
||||
openai,
|
||||
model,
|
||||
'maintainability',
|
||||
'Maintainability Agent',
|
||||
'可维护性、复杂度、接口破坏风险和可测试性不足',
|
||||
toolRegistry,
|
||||
learningSystem
|
||||
);
|
||||
}
|
||||
}
|
||||
183
src/review/agents/reflexion-agent.ts
Normal file
183
src/review/agents/reflexion-agent.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import OpenAI from 'openai';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { LearningSystem } from '../learning/learning-system';
|
||||
import { findingResponseSchema } from '../schema/finding-schema';
|
||||
import { ToolRegistry } from '../tools/registry';
|
||||
import { AgentResult, Finding, FindingCategory, ReviewContext, ReviewRun } from '../types';
|
||||
import { CriticAgent, CritiqueResult } from './critic-agent';
|
||||
import { SpecialistAgent } from './specialist-agent';
|
||||
|
||||
function buildFingerprint(category: string, path: string, line: number, title: string): string {
|
||||
return createHash('sha256')
|
||||
.update(`${category}:${path}:${line}:${title}`)
|
||||
.digest('hex')
|
||||
.slice(0, 24);
|
||||
}
|
||||
|
||||
export class ReflexionAgent extends SpecialistAgent {
|
||||
private criticAgent: CriticAgent;
|
||||
|
||||
constructor(
|
||||
openai: OpenAI,
|
||||
model: string,
|
||||
category: FindingCategory,
|
||||
agentName: string,
|
||||
focusPrompt: string,
|
||||
toolRegistry?: ToolRegistry,
|
||||
learningSystem?: LearningSystem
|
||||
) {
|
||||
super(openai, model, category, agentName, focusPrompt, toolRegistry, learningSystem);
|
||||
this.criticAgent = new CriticAgent(openai, model);
|
||||
}
|
||||
|
||||
async reviewWithReflection(
|
||||
run: ReviewRun,
|
||||
context: ReviewContext,
|
||||
maxReflectionRounds = 2
|
||||
): Promise<AgentResult> {
|
||||
let bestFindings: Omit<Finding, 'id' | 'runId' | 'published'>[] = [];
|
||||
let bestQualityScore = 0;
|
||||
let currentFindings: Omit<Finding, 'id' | 'runId' | 'published'>[] = [];
|
||||
|
||||
for (let round = 0; round < maxReflectionRounds; round++) {
|
||||
logger.info(`${this.agentName} Reflection Round ${round + 1}/${maxReflectionRounds}`, {
|
||||
runId: run.id,
|
||||
});
|
||||
|
||||
// 生成初步findings(首轮或基于上一轮refined结果)
|
||||
const draft = await this.generateDraft(run, context, currentFindings, round);
|
||||
|
||||
// 自我批评
|
||||
const critique = await this.criticAgent.critique(draft, context);
|
||||
|
||||
logger.info(`${this.agentName} Critique结果`, {
|
||||
runId: run.id,
|
||||
round: round + 1,
|
||||
qualityScore: critique.qualityScore,
|
||||
issuesFound: critique.issues.length,
|
||||
missedIssues: critique.missedIssues.length,
|
||||
});
|
||||
|
||||
// 如果质量已经很好,提前结束并保存最佳结果
|
||||
if (critique.qualityScore >= 0.9 && critique.issues.length === 0) {
|
||||
bestFindings = draft;
|
||||
bestQualityScore = critique.qualityScore;
|
||||
logger.info(`${this.agentName} 质量满足要求,提前结束Reflection`, {
|
||||
runId: run.id,
|
||||
finalScore: critique.qualityScore,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// 如果这轮质量更好,保存为最佳结果
|
||||
if (critique.qualityScore > bestQualityScore) {
|
||||
bestQualityScore = critique.qualityScore;
|
||||
bestFindings = draft;
|
||||
}
|
||||
|
||||
// 如果还有改进空间,继续优化(refine后需要在下一轮重新评估)
|
||||
if (round < maxReflectionRounds - 1) {
|
||||
currentFindings = await this.refine(draft, critique, context, run);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agentName: this.agentName,
|
||||
findings: bestFindings,
|
||||
};
|
||||
}
|
||||
|
||||
private async generateDraft(
|
||||
run: ReviewRun,
|
||||
context: ReviewContext,
|
||||
previousFindings: Omit<Finding, 'id' | 'runId' | 'published'>[],
|
||||
round: number
|
||||
): Promise<Omit<Finding, 'id' | 'runId' | 'published'>[]> {
|
||||
// 第一轮:使用父类的review方法
|
||||
if (round === 0) {
|
||||
const result = await super.review(run, context);
|
||||
return result.findings;
|
||||
}
|
||||
|
||||
// 后续轮次:在前一轮基础上改进(由refine方法生成)
|
||||
return previousFindings;
|
||||
}
|
||||
|
||||
private async refine(
|
||||
draft: Omit<Finding, 'id' | 'runId' | 'published'>[],
|
||||
critique: CritiqueResult,
|
||||
context: ReviewContext,
|
||||
run: ReviewRun
|
||||
): Promise<Omit<Finding, 'id' | 'runId' | 'published'>[]> {
|
||||
const prompt = `你是${this.agentName}。根据以下批评意见,改进审查结果。
|
||||
|
||||
原始findings(${draft.length}个):
|
||||
${JSON.stringify(draft, null, 2)}
|
||||
|
||||
Critic Agent的批评意见:
|
||||
质量评分: ${critique.qualityScore}
|
||||
发现的问题(${critique.issues.length}个):
|
||||
${critique.issues.map((issue) => `- Finding #${issue.findingIndex}: ${issue.problem}\n 建议: ${issue.suggestion}`).join('\n')}
|
||||
|
||||
可能遗漏的问题(${critique.missedIssues.length}个):
|
||||
${critique.missedIssues.map((missed) => `- ${missed}`).join('\n')}
|
||||
|
||||
总体评估: ${critique.overallAssessment}
|
||||
|
||||
代码上下文:
|
||||
${context.diff.slice(0, 3000)}
|
||||
|
||||
任务:
|
||||
1. 修正有问题的findings(根据批评意见)
|
||||
2. 补充遗漏的问题(如果确实存在)
|
||||
3. 移除误报
|
||||
4. 提升evidence的充分性和具体性
|
||||
|
||||
返回改进后的findings JSON数组,格式:
|
||||
{
|
||||
"findings": [...]
|
||||
}`;
|
||||
|
||||
try {
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: this.model,
|
||||
temperature: 0.1,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `你是${this.agentName},根据批评反馈改进审查结果。`,
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message.content;
|
||||
if (!content) {
|
||||
logger.warn(`${this.agentName} Refine返回空结果,使用原findings`);
|
||||
return draft;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
// 使用schema验证refined findings,防止畸形数据流入发布系统
|
||||
const validated = findingResponseSchema.parse({ findings: parsed.findings || draft });
|
||||
|
||||
// 标准化category和fingerprint
|
||||
return validated.findings.map((finding) => ({
|
||||
...finding,
|
||||
category: this.category,
|
||||
fingerprint:
|
||||
finding.fingerprint ||
|
||||
buildFingerprint(this.category, finding.path, finding.line, finding.title),
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error(`${this.agentName} Refine失败`, {
|
||||
runId: run.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return draft; // 失败时返回原findings
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/review/agents/reliability-agent.ts
Normal file
23
src/review/agents/reliability-agent.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import OpenAI from 'openai';
|
||||
import type { LearningSystem } from '../learning/learning-system';
|
||||
import { ToolRegistry } from '../tools/registry';
|
||||
import { SpecialistAgent } from './specialist-agent';
|
||||
|
||||
export class ReliabilityAgent extends SpecialistAgent {
|
||||
constructor(
|
||||
openai: OpenAI,
|
||||
model: string,
|
||||
toolRegistry?: ToolRegistry,
|
||||
learningSystem?: LearningSystem
|
||||
) {
|
||||
super(
|
||||
openai,
|
||||
model,
|
||||
'reliability',
|
||||
'Reliability Agent',
|
||||
'错误处理、重试策略、幂等性、并发一致性和资源释放',
|
||||
toolRegistry,
|
||||
learningSystem
|
||||
);
|
||||
}
|
||||
}
|
||||
23
src/review/agents/security-agent.ts
Normal file
23
src/review/agents/security-agent.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import OpenAI from 'openai';
|
||||
import type { LearningSystem } from '../learning/learning-system';
|
||||
import { ToolRegistry } from '../tools/registry';
|
||||
import { SpecialistAgent } from './specialist-agent';
|
||||
|
||||
export class SecurityAgent extends SpecialistAgent {
|
||||
constructor(
|
||||
openai: OpenAI,
|
||||
model: string,
|
||||
toolRegistry?: ToolRegistry,
|
||||
learningSystem?: LearningSystem
|
||||
) {
|
||||
super(
|
||||
openai,
|
||||
model,
|
||||
'security',
|
||||
'Security Agent',
|
||||
'注入漏洞、权限绕过、敏感信息泄露、反序列化和输入校验缺失',
|
||||
toolRegistry,
|
||||
learningSystem
|
||||
);
|
||||
}
|
||||
}
|
||||
364
src/review/agents/specialist-agent.ts
Normal file
364
src/review/agents/specialist-agent.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import OpenAI from 'openai';
|
||||
import { logger } from '../../utils/logger';
|
||||
import type { LearningSystem } from '../learning/learning-system';
|
||||
import { findingResponseSchema } from '../schema/finding-schema';
|
||||
import { ToolRegistry } from '../tools/registry';
|
||||
import type { ToolExecutionContext, ToolResult } from '../tools/types';
|
||||
import { AgentResult, Finding, FindingCategory, ReviewContext, ReviewRun } from '../types';
|
||||
|
||||
function buildFingerprint(category: string, path: string, line: number, title: string): string {
|
||||
return createHash('sha256')
|
||||
.update(`${category}:${path}:${line}:${title}`)
|
||||
.digest('hex')
|
||||
.slice(0, 24);
|
||||
}
|
||||
|
||||
function toCompactContext(context: ReviewContext): string {
|
||||
// 全局上下文大小限制:100k chars(约33k tokens),为系统prompt、few-shot、响应留空间
|
||||
const MAX_CONTEXT_CHARS = 100_000;
|
||||
|
||||
const files = context.changedFiles.map((file) => ({
|
||||
path: file.path,
|
||||
status: file.status,
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
}));
|
||||
|
||||
// 策略:逐步缩减直到满足限制
|
||||
// 1. changedFiles元数据(小且必需)
|
||||
// 2. parsedDiff(关键,逐步减少每个文件的changes数量)
|
||||
// 3. fileContents(最大,按需截断或移除部分文件)
|
||||
|
||||
let maxChangesPerFile = 200;
|
||||
let maxFileContentsEntries = Object.keys(context.fileContents).length;
|
||||
|
||||
// 尝试构建并测量大小
|
||||
const tryBuild = (changesLimit: number, contentEntriesLimit: number): string => {
|
||||
const snippets = context.parsedDiff.map((file) => ({
|
||||
path: file.path,
|
||||
changes: file.changes.slice(0, changesLimit),
|
||||
}));
|
||||
|
||||
const limitedContents: Record<string, string> = {};
|
||||
const contentKeys = Object.keys(context.fileContents);
|
||||
for (let i = 0; i < Math.min(contentEntriesLimit, contentKeys.length); i++) {
|
||||
const key = contentKeys[i];
|
||||
limitedContents[key] = context.fileContents[key];
|
||||
}
|
||||
|
||||
return JSON.stringify(
|
||||
{
|
||||
changedFiles: files,
|
||||
diffSnippets: snippets,
|
||||
fileContents: limitedContents,
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
};
|
||||
|
||||
let result = tryBuild(maxChangesPerFile, maxFileContentsEntries);
|
||||
|
||||
// 如果超过限制,逐步缩减
|
||||
while (
|
||||
result.length > MAX_CONTEXT_CHARS &&
|
||||
(maxChangesPerFile > 20 || maxFileContentsEntries > 0)
|
||||
) {
|
||||
if (maxChangesPerFile > 20) {
|
||||
maxChangesPerFile = Math.max(20, Math.floor(maxChangesPerFile * 0.7));
|
||||
} else if (maxFileContentsEntries > 0) {
|
||||
maxFileContentsEntries = Math.max(0, Math.floor(maxFileContentsEntries * 0.5));
|
||||
}
|
||||
|
||||
result = tryBuild(maxChangesPerFile, maxFileContentsEntries);
|
||||
}
|
||||
|
||||
// 如果仍然超限,强制截断(保留前N个字符)
|
||||
if (result.length > MAX_CONTEXT_CHARS) {
|
||||
logger.warn('Context size still exceeds limit after reduction, truncating', {
|
||||
originalSize: result.length,
|
||||
limit: MAX_CONTEXT_CHARS,
|
||||
});
|
||||
result = `${result.slice(0, MAX_CONTEXT_CHARS)}\n... [truncated]`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export class SpecialistAgent {
|
||||
constructor(
|
||||
protected readonly openai: OpenAI,
|
||||
protected readonly model: string,
|
||||
protected readonly category: FindingCategory,
|
||||
protected readonly agentName: string,
|
||||
protected readonly focusPrompt: string,
|
||||
protected readonly toolRegistry?: ToolRegistry,
|
||||
protected readonly learningSystem?: LearningSystem
|
||||
) {}
|
||||
|
||||
async review(run: ReviewRun, context: ReviewContext): Promise<AgentResult> {
|
||||
if (!context.diff.trim()) {
|
||||
return { agentName: this.agentName, findings: [] };
|
||||
}
|
||||
|
||||
// 如果没有工具注册表,使用传统单次调用模式
|
||||
if (!this.toolRegistry || this.toolRegistry.getAll().length === 0) {
|
||||
return this.reviewLegacy(run, context);
|
||||
}
|
||||
|
||||
// ReAct循环模式
|
||||
return this.reviewWithReAct(run, context);
|
||||
}
|
||||
|
||||
private async reviewLegacy(run: ReviewRun, context: ReviewContext): Promise<AgentResult> {
|
||||
const prompt = `你是${this.agentName},只关注${this.focusPrompt}。
|
||||
输出必须是JSON对象格式: {"findings": []}。
|
||||
仅报告有明确证据的问题;无问题时返回空数组。
|
||||
|
||||
审查上下文如下:
|
||||
${toCompactContext(context)}`;
|
||||
|
||||
try {
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: this.model,
|
||||
temperature: 0,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'你是严格的代码审查专家。返回结构化JSON,不输出额外文字。confidence取值范围0到1。line必须是正整数且引用新增行。',
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message.content;
|
||||
if (!content) {
|
||||
return { agentName: this.agentName, findings: [] };
|
||||
}
|
||||
|
||||
const parsed = findingResponseSchema.parse(JSON.parse(content));
|
||||
const findings = parsed.findings.map((item) => ({
|
||||
...item,
|
||||
category: this.category,
|
||||
fingerprint:
|
||||
item.fingerprint || buildFingerprint(this.category, item.path, item.line, item.title),
|
||||
}));
|
||||
|
||||
return {
|
||||
agentName: this.agentName,
|
||||
findings,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${this.agentName} 执行失败`, {
|
||||
runId: run.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return { agentName: this.agentName, findings: [] };
|
||||
}
|
||||
}
|
||||
|
||||
private async reviewWithReAct(run: ReviewRun, context: ReviewContext): Promise<AgentResult> {
|
||||
const maxIterations = 5;
|
||||
const findingsMap = new Map<string, Omit<Finding, 'id' | 'runId' | 'published'>>();
|
||||
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content: `你是${this.agentName},专注于${this.focusPrompt}。
|
||||
|
||||
你可以使用以下工具进行深入调查:
|
||||
${this.toolRegistry!.getAll()
|
||||
.map((t) => `- ${t.name}: ${t.description}`)
|
||||
.join('\n')}
|
||||
|
||||
工作流程:
|
||||
1. 分析给定的代码变更
|
||||
2. 如需更多信息,使用工具调查(如搜索相似代码、分析函数调用)
|
||||
3. 基于证据报告问题
|
||||
|
||||
当你需要使用工具时,直接调用工具即可。
|
||||
当你完成所有调查并准备输出最终结果时,以纯JSON格式返回:
|
||||
{"findings": [...], "need_more_investigation": false}
|
||||
confidence取值范围0到1。line必须是正整数且引用新增行。`,
|
||||
},
|
||||
];
|
||||
|
||||
// 添加Few-shot示例(如果学习系统可用)
|
||||
if (this.learningSystem) {
|
||||
try {
|
||||
const fewShotExamples = await this.learningSystem.generateFewShotExamples(
|
||||
this.category,
|
||||
run.owner,
|
||||
run.repo
|
||||
);
|
||||
if (fewShotExamples.length > 0) {
|
||||
messages.push(...fewShotExamples);
|
||||
logger.debug(`${this.agentName} 添加了 ${fewShotExamples.length} 条Few-shot示例`, {
|
||||
runId: run.id,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`${this.agentName} Few-shot示例生成失败`, {
|
||||
runId: run.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 添加当前审查任务
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: `审查以下代码变更:\n${toCompactContext(context)}`,
|
||||
});
|
||||
|
||||
try {
|
||||
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
||||
logger.info(`${this.agentName} ReAct迭代 ${iteration + 1}/${maxIterations}`, {
|
||||
runId: run.id,
|
||||
});
|
||||
|
||||
// 仅在最后一轮迭代强制 JSON 输出(无工具调用时解析结果)
|
||||
// 避免 response_format: json_object 与 tools 参数冲突导致工具不被调用
|
||||
const isLastIteration = iteration === maxIterations - 1;
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: this.model,
|
||||
temperature: 0,
|
||||
...(isLastIteration ? { response_format: { type: 'json_object' as const } } : {}),
|
||||
messages,
|
||||
tools: this.toolRegistry!.toOpenAIFunctions(),
|
||||
tool_choice: isLastIteration ? 'none' : 'auto',
|
||||
});
|
||||
|
||||
const choice = response.choices[0];
|
||||
if (!choice) break;
|
||||
|
||||
// 处理工具调用
|
||||
if (choice.message.tool_calls && choice.message.tool_calls.length > 0) {
|
||||
messages.push(choice.message as OpenAI.Chat.ChatCompletionMessageParam);
|
||||
|
||||
// 执行所有工具调用
|
||||
const toolResults = await this.executeTools(choice.message.tool_calls, {
|
||||
workspacePath: context.workspacePath,
|
||||
mirrorPath: context.mirrorPath,
|
||||
runId: run.id,
|
||||
});
|
||||
|
||||
// 添加工具结果到对话
|
||||
for (const toolResult of toolResults) {
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolResult.toolCallId,
|
||||
content: JSON.stringify(toolResult.result || { error: toolResult.error }),
|
||||
});
|
||||
}
|
||||
|
||||
continue; // 继续下一轮
|
||||
}
|
||||
|
||||
// 解析findings(模型选择返回内容而非调用工具)
|
||||
if (choice.message.content) {
|
||||
try {
|
||||
const parsed = JSON.parse(choice.message.content);
|
||||
|
||||
if (parsed.findings && parsed.findings.length > 0) {
|
||||
// 使用schema验证findings,防止畸形数据流入发布系统
|
||||
const validated = findingResponseSchema.parse({ findings: parsed.findings });
|
||||
for (const item of validated.findings) {
|
||||
const fp =
|
||||
item.fingerprint ||
|
||||
buildFingerprint(this.category, item.path, item.line, item.title);
|
||||
// 基于 fingerprint 去重:后续迭代产生的同一 finding 覆盖前一次
|
||||
findingsMap.set(fp, {
|
||||
...item,
|
||||
category: this.category,
|
||||
fingerprint: fp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否需要继续调查
|
||||
if (!parsed.need_more_investigation) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 模型要求继续调查但没有调用工具:注入 user 消息打破潜在的自我重复
|
||||
messages.push(choice.message as OpenAI.Chat.ChatCompletionMessageParam);
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content:
|
||||
'请使用工具进行更深入的调查。如果你已经获得了足够的信息,请将 need_more_investigation 设为 false 并输出最终结果。',
|
||||
});
|
||||
} catch (parseError) {
|
||||
logger.error(`${this.agentName} 解析响应失败`, {
|
||||
runId: run.id,
|
||||
error: parseError instanceof Error ? parseError.message : String(parseError),
|
||||
});
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// 没有内容,结束循环
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { agentName: this.agentName, findings: Array.from(findingsMap.values()) };
|
||||
} catch (error) {
|
||||
logger.error(`${this.agentName} ReAct执行失败`, {
|
||||
runId: run.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return { agentName: this.agentName, findings: [] };
|
||||
}
|
||||
}
|
||||
|
||||
private async executeTools(
|
||||
toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[],
|
||||
context: ToolExecutionContext
|
||||
): Promise<ToolResult[]> {
|
||||
const results: ToolResult[] = [];
|
||||
|
||||
for (const toolCall of toolCalls) {
|
||||
const tool = this.toolRegistry!.get(toolCall.function.name);
|
||||
|
||||
if (!tool) {
|
||||
results.push({
|
||||
toolCallId: toolCall.id,
|
||||
success: false,
|
||||
error: `工具 ${toolCall.function.name} 未找到`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const params = JSON.parse(toolCall.function.arguments);
|
||||
const result = await tool.execute(params, context);
|
||||
|
||||
logger.info(`工具调用成功: ${toolCall.function.name}`, {
|
||||
runId: context.runId,
|
||||
params,
|
||||
});
|
||||
|
||||
results.push({
|
||||
toolCallId: toolCall.id,
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`工具调用失败: ${toolCall.function.name}`, {
|
||||
runId: context.runId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
results.push({
|
||||
toolCallId: toolCall.id,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
340
src/review/context/diff-extractor.ts
Normal file
340
src/review/context/diff-extractor.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { lstat, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { ChangedFile, DiffFile, ReviewContext, ReviewRun } from '../types';
|
||||
import { LocalRepoManager } from './local-repo-manager';
|
||||
import { SandboxExec } from './sandbox-exec';
|
||||
|
||||
function toStatus(status: string): ChangedFile['status'] {
|
||||
const value = status.trim().charAt(0).toUpperCase();
|
||||
if (['A', 'M', 'D', 'R', 'C', 'T', 'U', 'X', 'B'].includes(value)) {
|
||||
return value as ChangedFile['status'];
|
||||
}
|
||||
return 'M';
|
||||
}
|
||||
|
||||
function safePath(basePath: string, relativePath: string): string {
|
||||
const resolved = path.resolve(basePath, relativePath);
|
||||
if (!resolved.startsWith(path.resolve(basePath))) {
|
||||
throw new Error(`非法文件路径: ${relativePath}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export class DiffExtractor {
|
||||
constructor(
|
||||
private readonly sandboxExec: SandboxExec,
|
||||
private readonly localRepoManager: LocalRepoManager,
|
||||
private readonly commandTimeoutMs: number,
|
||||
private readonly maxFilesPerRun: number,
|
||||
private readonly maxFileContentChars: number
|
||||
) {}
|
||||
|
||||
getSandbox(): SandboxExec {
|
||||
return this.sandboxExec;
|
||||
}
|
||||
|
||||
async buildContext(
|
||||
run: ReviewRun,
|
||||
mirrorPath: string,
|
||||
workspacePath: string
|
||||
): Promise<ReviewContext> {
|
||||
const targetSha = run.headSha || run.commitSha;
|
||||
if (!targetSha) {
|
||||
throw new Error('缺少 target sha,无法构建审查上下文');
|
||||
}
|
||||
|
||||
let baseSha = run.baseSha;
|
||||
if (!baseSha) {
|
||||
baseSha =
|
||||
(await this.localRepoManager.resolveCommitParent(workspacePath, targetSha)) || undefined;
|
||||
}
|
||||
|
||||
// Root commit场景:没有parent,使用git show获取完整diff
|
||||
const isRootCommit = !baseSha;
|
||||
const diff = isRootCommit
|
||||
? await this.getRootCommitDiff(workspacePath, targetSha)
|
||||
: await this.getDiff(workspacePath, run.eventType, baseSha!, targetSha);
|
||||
|
||||
const changedFiles = isRootCommit
|
||||
? await this.getRootCommitChangedFiles(workspacePath, targetSha)
|
||||
: await this.getChangedFiles(workspacePath, baseSha!, targetSha);
|
||||
|
||||
// 构建允许的文件路径集合,确保parsedDiff也受REVIEW_MAX_FILES_PER_RUN限制
|
||||
const allowedPaths = new Set(changedFiles.map((f) => f.path));
|
||||
const parsedDiff = this.parseDiff(diff, allowedPaths);
|
||||
|
||||
const fileContents = await this.readChangedFileContents(workspacePath, changedFiles);
|
||||
|
||||
return {
|
||||
workspacePath,
|
||||
mirrorPath,
|
||||
diff,
|
||||
changedFiles,
|
||||
parsedDiff,
|
||||
fileContents,
|
||||
};
|
||||
}
|
||||
|
||||
private async getRootCommitDiff(workspacePath: string, sha: string): Promise<string> {
|
||||
// Root commit:使用git show获取完整diff(相当于与空树的diff)
|
||||
const response = await this.sandboxExec.run('git', ['show', '--format=', '--unified=3', sha], {
|
||||
cwd: workspacePath,
|
||||
timeoutMs: this.commandTimeoutMs,
|
||||
});
|
||||
return response.stdout;
|
||||
}
|
||||
|
||||
private async getDiff(
|
||||
workspacePath: string,
|
||||
eventType: ReviewRun['eventType'],
|
||||
baseSha: string,
|
||||
targetSha: string
|
||||
): Promise<string> {
|
||||
if (eventType === 'pull_request') {
|
||||
const response = await this.sandboxExec.run(
|
||||
'git',
|
||||
['diff', '--unified=3', `${baseSha}...${targetSha}`],
|
||||
{
|
||||
cwd: workspacePath,
|
||||
timeoutMs: this.commandTimeoutMs,
|
||||
}
|
||||
);
|
||||
return response.stdout;
|
||||
}
|
||||
|
||||
const response = await this.sandboxExec.run(
|
||||
'git',
|
||||
['show', '--format=', '--unified=3', targetSha],
|
||||
{
|
||||
cwd: workspacePath,
|
||||
timeoutMs: this.commandTimeoutMs,
|
||||
}
|
||||
);
|
||||
return response.stdout;
|
||||
}
|
||||
|
||||
private async getRootCommitChangedFiles(
|
||||
workspacePath: string,
|
||||
sha: string
|
||||
): Promise<ChangedFile[]> {
|
||||
// Root commit:所有文件都是新增的(A状态)
|
||||
// --root flag是必需的,否则diff-tree对root commit返回空输出
|
||||
const statusResult = await this.sandboxExec.run(
|
||||
'git',
|
||||
['diff-tree', '--root', '--no-commit-id', '--name-status', '-r', sha],
|
||||
{
|
||||
cwd: workspacePath,
|
||||
timeoutMs: this.commandTimeoutMs,
|
||||
}
|
||||
);
|
||||
|
||||
const numStatResult = await this.sandboxExec.run(
|
||||
'git',
|
||||
['diff-tree', '--root', '--no-commit-id', '--numstat', '-r', sha],
|
||||
{
|
||||
cwd: workspacePath,
|
||||
timeoutMs: this.commandTimeoutMs,
|
||||
}
|
||||
);
|
||||
|
||||
const numMap = new Map<string, { additions: number; deletions: number }>();
|
||||
for (const line of numStatResult.stdout.split('\n')) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
const [addRaw = '0', delRaw = '0', filename] = line.split('\t');
|
||||
if (!filename) {
|
||||
continue;
|
||||
}
|
||||
const additions = Number.parseInt(addRaw, 10);
|
||||
const deletions = Number.parseInt(delRaw, 10);
|
||||
numMap.set(filename, {
|
||||
additions: Number.isFinite(additions) ? additions : 0,
|
||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||
});
|
||||
}
|
||||
|
||||
const changedFiles: ChangedFile[] = [];
|
||||
for (const line of statusResult.stdout.split('\n')) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
const [statusRaw = 'A', ...pathParts] = line.split('\t');
|
||||
const filePath = pathParts[pathParts.length - 1];
|
||||
if (!filePath) {
|
||||
continue;
|
||||
}
|
||||
const stats = numMap.get(filePath) || { additions: 0, deletions: 0 };
|
||||
changedFiles.push({
|
||||
path: filePath,
|
||||
status: toStatus(statusRaw),
|
||||
additions: stats.additions,
|
||||
deletions: stats.deletions,
|
||||
});
|
||||
if (changedFiles.length >= this.maxFilesPerRun) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return changedFiles;
|
||||
}
|
||||
|
||||
private async getChangedFiles(
|
||||
workspacePath: string,
|
||||
baseSha: string,
|
||||
targetSha: string
|
||||
): Promise<ChangedFile[]> {
|
||||
const statusResult = await this.sandboxExec.run(
|
||||
'git',
|
||||
['diff', '--name-status', `${baseSha}...${targetSha}`],
|
||||
{
|
||||
cwd: workspacePath,
|
||||
timeoutMs: this.commandTimeoutMs,
|
||||
}
|
||||
);
|
||||
|
||||
const numStatResult = await this.sandboxExec.run(
|
||||
'git',
|
||||
['diff', '--numstat', `${baseSha}...${targetSha}`],
|
||||
{
|
||||
cwd: workspacePath,
|
||||
timeoutMs: this.commandTimeoutMs,
|
||||
}
|
||||
);
|
||||
|
||||
const numMap = new Map<string, { additions: number; deletions: number }>();
|
||||
for (const line of numStatResult.stdout.split('\n')) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
const [addRaw = '0', delRaw = '0', filename] = line.split('\t');
|
||||
if (!filename) {
|
||||
continue;
|
||||
}
|
||||
const additions = Number.parseInt(addRaw, 10);
|
||||
const deletions = Number.parseInt(delRaw, 10);
|
||||
numMap.set(filename, {
|
||||
additions: Number.isFinite(additions) ? additions : 0,
|
||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||
});
|
||||
}
|
||||
|
||||
const changedFiles: ChangedFile[] = [];
|
||||
for (const line of statusResult.stdout.split('\n')) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
const [statusRaw = 'M', ...pathParts] = line.split('\t');
|
||||
const filePath = pathParts[pathParts.length - 1];
|
||||
if (!filePath) {
|
||||
continue;
|
||||
}
|
||||
const stats = numMap.get(filePath) || { additions: 0, deletions: 0 };
|
||||
changedFiles.push({
|
||||
path: filePath,
|
||||
status: toStatus(statusRaw),
|
||||
additions: stats.additions,
|
||||
deletions: stats.deletions,
|
||||
});
|
||||
if (changedFiles.length >= this.maxFilesPerRun) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return changedFiles;
|
||||
}
|
||||
|
||||
private async readChangedFileContents(
|
||||
workspacePath: string,
|
||||
changedFiles: ChangedFile[]
|
||||
): Promise<Record<string, string>> {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const file of changedFiles) {
|
||||
if (file.status === 'D') {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const filePath = safePath(workspacePath, file.path);
|
||||
|
||||
// 安全检查:拒绝符号链接以防止主机文件泄露
|
||||
const stats = await lstat(filePath);
|
||||
if (stats.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
result[file.path] = content.slice(0, this.maxFileContentChars);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
parseDiff(diffContent: string, allowedPaths?: Set<string>): DiffFile[] {
|
||||
const files: DiffFile[] = [];
|
||||
const lines = diffContent.split('\n');
|
||||
|
||||
let currentFile: DiffFile | null = null;
|
||||
let lineNumber = 0;
|
||||
let inHunk = false;
|
||||
let skipCurrentFile = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('diff --git')) {
|
||||
if (currentFile && !skipCurrentFile) {
|
||||
files.push(currentFile);
|
||||
}
|
||||
currentFile = { path: '', changes: [] };
|
||||
inHunk = false;
|
||||
skipCurrentFile = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentFile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('+++ b/')) {
|
||||
currentFile.path = line.substring(6);
|
||||
// 如果提供了allowedPaths,检查当前文件是否在允许列表中
|
||||
if (allowedPaths && !allowedPaths.has(currentFile.path)) {
|
||||
skipCurrentFile = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果跳过当前文件,忽略所有后续内容直到下一个文件
|
||||
if (skipCurrentFile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('@@')) {
|
||||
const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
if (match?.[1]) {
|
||||
lineNumber = Number.parseInt(match[1], 10) - 1;
|
||||
inHunk = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inHunk) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('+')) {
|
||||
lineNumber += 1;
|
||||
currentFile.changes.push({ lineNumber, content: line.slice(1), type: 'add' });
|
||||
} else if (line.startsWith(' ')) {
|
||||
lineNumber += 1;
|
||||
currentFile.changes.push({ lineNumber, content: line.slice(1), type: 'context' });
|
||||
}
|
||||
}
|
||||
|
||||
if (currentFile && !skipCurrentFile) {
|
||||
files.push(currentFile);
|
||||
}
|
||||
|
||||
return files.filter((file) => file.path && file.changes.length > 0);
|
||||
}
|
||||
}
|
||||
250
src/review/context/local-repo-manager.ts
Normal file
250
src/review/context/local-repo-manager.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { access, mkdir, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { SandboxExec } from './sandbox-exec';
|
||||
|
||||
export interface LocalRepoPaths {
|
||||
mirrorPath: string;
|
||||
workspacePath: string;
|
||||
}
|
||||
|
||||
function hashRepo(owner: string, repo: string): string {
|
||||
return createHash('sha256').update(`${owner}/${repo}`).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
export class LocalRepoManager {
|
||||
private mirrorLocks = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(
|
||||
private readonly workDir: string,
|
||||
private readonly sandboxExec: SandboxExec,
|
||||
private readonly commandTimeoutMs: number,
|
||||
private readonly giteaToken?: string
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 构建git命令的认证配置参数(非持久化)
|
||||
* 使用http.extraHeader避免将token存储在git config中
|
||||
*/
|
||||
private getAuthArgs(): string[] {
|
||||
if (!this.giteaToken) {
|
||||
return [];
|
||||
}
|
||||
// 使用Authorization header,不会持久化到.git/config
|
||||
return ['-c', `http.extraHeader=Authorization: token ${this.giteaToken}`];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取mirror仓库的互斥锁,防止并发修改同一mirror
|
||||
* 返回一个unlock函数,调用者必须在完成后调用
|
||||
*/
|
||||
private async acquireMirrorLock(mirrorPath: string): Promise<() => void> {
|
||||
// 获取前一个锁(如果有),用于排队等待
|
||||
const currentLock = this.mirrorLocks.get(mirrorPath) || Promise.resolve();
|
||||
|
||||
let releaseLock: () => void;
|
||||
const newLock = new Promise<void>((resolve) => {
|
||||
releaseLock = resolve;
|
||||
});
|
||||
|
||||
// 将新锁存入map(供后续调用者排队)
|
||||
// 修复:直接存储newLock而非chain,使unlock时的比较能够正确工作
|
||||
this.mirrorLocks.set(mirrorPath, newLock);
|
||||
|
||||
// 等待前一个锁完成
|
||||
await currentLock;
|
||||
|
||||
// 返回解锁函数
|
||||
return () => {
|
||||
releaseLock!();
|
||||
// 如果map中还是当前锁(没有新的等待者),清理以避免内存泄漏
|
||||
if (this.mirrorLocks.get(mirrorPath) === newLock) {
|
||||
this.mirrorLocks.delete(mirrorPath);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async prepareWorkspace(
|
||||
owner: string,
|
||||
repo: string,
|
||||
cloneUrl: string,
|
||||
targetSha: string,
|
||||
runId: string,
|
||||
headCloneUrl?: string
|
||||
): Promise<LocalRepoPaths> {
|
||||
const repoHash = hashRepo(owner, repo);
|
||||
const mirrorsRoot = path.join(this.workDir, 'repos');
|
||||
const workspacesRoot = path.join(this.workDir, 'workspaces');
|
||||
const mirrorPath = path.join(mirrorsRoot, `${repoHash}.git`);
|
||||
const workspacePath = path.join(workspacesRoot, runId);
|
||||
|
||||
await mkdir(mirrorsRoot, { recursive: true });
|
||||
await mkdir(workspacesRoot, { recursive: true });
|
||||
|
||||
// 获取mirror锁,防止并发修改同一mirror(remote set-url/fetch冲突)
|
||||
const unlock = await this.acquireMirrorLock(mirrorPath);
|
||||
|
||||
try {
|
||||
const authArgs = this.getAuthArgs();
|
||||
const mirrorExists = await this.pathExists(mirrorPath);
|
||||
|
||||
if (!mirrorExists) {
|
||||
logger.info('创建本地 mirror 仓库', { owner, repo, mirrorPath });
|
||||
await this.sandboxExec.run(
|
||||
'git',
|
||||
[...authArgs, 'clone', '--mirror', cloneUrl, mirrorPath],
|
||||
{
|
||||
cwd: this.workDir,
|
||||
timeoutMs: this.commandTimeoutMs,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// 更新remote URL(不含认证信息)
|
||||
await this.sandboxExec.run(
|
||||
'git',
|
||||
['--git-dir', mirrorPath, 'remote', 'set-url', 'origin', cloneUrl],
|
||||
{
|
||||
cwd: this.workDir,
|
||||
timeoutMs: this.commandTimeoutMs,
|
||||
}
|
||||
);
|
||||
// fetch使用认证参数
|
||||
await this.sandboxExec.run(
|
||||
'git',
|
||||
[...authArgs, '--git-dir', mirrorPath, 'fetch', '--prune', 'origin', '+refs/*:refs/*'],
|
||||
{
|
||||
cwd: this.workDir,
|
||||
timeoutMs: this.commandTimeoutMs,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Fork PR场景:添加head remote并fetch,确保head SHA可用
|
||||
if (headCloneUrl && headCloneUrl !== cloneUrl) {
|
||||
logger.info('Fork PR检测,添加head remote', { owner, repo, headCloneUrl });
|
||||
|
||||
// 检查head remote是否已存在,存在则更新URL
|
||||
const remoteListResult = await this.sandboxExec.run(
|
||||
'git',
|
||||
['--git-dir', mirrorPath, 'remote'],
|
||||
{
|
||||
cwd: this.workDir,
|
||||
timeoutMs: this.commandTimeoutMs,
|
||||
}
|
||||
);
|
||||
const hasHeadRemote = remoteListResult.stdout.includes('head');
|
||||
|
||||
if (hasHeadRemote) {
|
||||
await this.sandboxExec.run(
|
||||
'git',
|
||||
['--git-dir', mirrorPath, 'remote', 'set-url', 'head', headCloneUrl],
|
||||
{
|
||||
cwd: this.workDir,
|
||||
timeoutMs: this.commandTimeoutMs,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await this.sandboxExec.run(
|
||||
'git',
|
||||
['--git-dir', mirrorPath, 'remote', 'add', 'head', headCloneUrl],
|
||||
{
|
||||
cwd: this.workDir,
|
||||
timeoutMs: this.commandTimeoutMs,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch head remote
|
||||
await this.sandboxExec.run(
|
||||
'git',
|
||||
[
|
||||
...authArgs,
|
||||
'--git-dir',
|
||||
mirrorPath,
|
||||
'fetch',
|
||||
'head',
|
||||
'+refs/heads/*:refs/remotes/head/*',
|
||||
],
|
||||
{
|
||||
cwd: this.workDir,
|
||||
timeoutMs: this.commandTimeoutMs,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await rm(workspacePath, { recursive: true, force: true });
|
||||
|
||||
// 清理可能存在的stale worktree元数据(崩溃恢复时目录已删除但元数据仍注册)
|
||||
// prune会移除所有已删除但仍注册的worktree
|
||||
// 注意:prune/add也会修改mirror元数据,必须在锁保护下执行,防止并发冲突
|
||||
await this.sandboxExec.run('git', ['--git-dir', mirrorPath, 'worktree', 'prune'], {
|
||||
cwd: this.workDir,
|
||||
timeoutMs: this.commandTimeoutMs,
|
||||
});
|
||||
|
||||
await this.sandboxExec.run(
|
||||
'git',
|
||||
['--git-dir', mirrorPath, 'worktree', 'add', '--detach', workspacePath, targetSha],
|
||||
{
|
||||
cwd: this.workDir,
|
||||
timeoutMs: this.commandTimeoutMs,
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
// 确保锁总是被释放,在所有mirror-mutating操作(fetch/prune/add)完成后释放
|
||||
unlock();
|
||||
}
|
||||
|
||||
return {
|
||||
mirrorPath,
|
||||
workspacePath,
|
||||
};
|
||||
}
|
||||
|
||||
async cleanupWorkspace(paths: LocalRepoPaths): Promise<void> {
|
||||
// worktree remove也会修改mirror元数据,需要使用mirror锁防止与prepareWorkspace并发冲突
|
||||
const unlock = await this.acquireMirrorLock(paths.mirrorPath);
|
||||
|
||||
try {
|
||||
await this.sandboxExec.run(
|
||||
'git',
|
||||
['--git-dir', paths.mirrorPath, 'worktree', 'remove', '--force', paths.workspacePath],
|
||||
{
|
||||
cwd: this.workDir,
|
||||
timeoutMs: this.commandTimeoutMs,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn('移除 git worktree 失败,尝试直接清理目录', {
|
||||
workspacePath: paths.workspacePath,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
await rm(paths.workspacePath, { recursive: true, force: true });
|
||||
} finally {
|
||||
// 确保锁总是被释放
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
async resolveCommitParent(workspacePath: string, commitSha: string): Promise<string | null> {
|
||||
try {
|
||||
const result = await this.sandboxExec.run('git', ['rev-parse', `${commitSha}^`], {
|
||||
cwd: workspacePath,
|
||||
timeoutMs: this.commandTimeoutMs,
|
||||
});
|
||||
return result.stdout.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async pathExists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
98
src/review/context/sandbox-exec.ts
Normal file
98
src/review/context/sandbox-exec.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
|
||||
export interface SandboxCommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
durationMs: number;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
export interface SandboxRunOptions {
|
||||
cwd: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export class SandboxExec {
|
||||
private readonly allowedCommands: Set<string>;
|
||||
|
||||
constructor(allowedCommands: string[]) {
|
||||
this.allowedCommands = new Set(allowedCommands);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact敏感信息(如URLs中的token、git config中的认证header)以防止泄露到日志
|
||||
*/
|
||||
private redactSensitiveArgs(args: string[]): string[] {
|
||||
return args.map((arg) => {
|
||||
// Redact git config中的http.extraHeader认证token
|
||||
if (arg.includes('http.extraHeader=Authorization:')) {
|
||||
return arg.replace(/(Authorization:\s*token\s+)[^\s]+/i, '$1***');
|
||||
}
|
||||
|
||||
try {
|
||||
// 检测URL格式并redact认证信息
|
||||
const url = new URL(arg);
|
||||
if (url.username || url.password) {
|
||||
url.username = '***';
|
||||
url.password = '***';
|
||||
return url.toString();
|
||||
}
|
||||
} catch {
|
||||
// 不是URL,保持原样
|
||||
}
|
||||
return arg;
|
||||
});
|
||||
}
|
||||
|
||||
async run(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: SandboxRunOptions
|
||||
): Promise<SandboxCommandResult> {
|
||||
if (!this.allowedCommands.has(command)) {
|
||||
throw new Error(`命令未在白名单中: ${command}`);
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
|
||||
return new Promise<SandboxCommandResult>((resolve, reject) => {
|
||||
execFile(
|
||||
command,
|
||||
args,
|
||||
{
|
||||
cwd: options.cwd,
|
||||
timeout: options.timeoutMs,
|
||||
maxBuffer: 1024 * 1024 * 16,
|
||||
windowsHide: true,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HOME: process.env.HOME,
|
||||
LANG: process.env.LANG,
|
||||
LC_ALL: process.env.LC_ALL,
|
||||
},
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
if (error) {
|
||||
const code = typeof error.code === 'number' ? error.code : -1;
|
||||
// Redact敏感参数(如带token的URLs)以防止凭证泄露到日志和持久化错误
|
||||
const redactedArgs = this.redactSensitiveArgs(args);
|
||||
reject(
|
||||
new Error(
|
||||
`命令执行失败: ${command} ${redactedArgs.join(' ')}; code=${code}; stderr=${stderr || '(无 stderr,原始错误已脱敏)'}`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
stdout,
|
||||
stderr,
|
||||
durationMs,
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
155
src/review/engine.ts
Normal file
155
src/review/engine.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import config from '../config';
|
||||
import { logger } from '../utils/logger';
|
||||
import { DiffExtractor } from './context/diff-extractor';
|
||||
import { LocalRepoManager } from './context/local-repo-manager';
|
||||
import { SandboxExec } from './context/sandbox-exec';
|
||||
import { ReviewOrchestrator } from './orchestrator';
|
||||
import { FileReviewStore } from './store/file-review-store';
|
||||
import { CommitReviewPayload, PullRequestReviewPayload, ReviewRun } from './types';
|
||||
|
||||
class ReviewEngine {
|
||||
private readonly store = new FileReviewStore(config.review.workdir);
|
||||
private readonly sandboxExec = new SandboxExec(config.review.allowedCommands);
|
||||
private readonly localRepoManager = new LocalRepoManager(
|
||||
config.review.workdir,
|
||||
this.sandboxExec,
|
||||
config.review.commandTimeoutMs,
|
||||
config.gitea.accessToken
|
||||
);
|
||||
private readonly diffExtractor = new DiffExtractor(
|
||||
this.sandboxExec,
|
||||
this.localRepoManager,
|
||||
config.review.commandTimeoutMs,
|
||||
config.review.maxFilesPerRun,
|
||||
config.review.maxFileContentChars
|
||||
);
|
||||
private readonly orchestrator = new ReviewOrchestrator(
|
||||
this.store,
|
||||
this.localRepoManager,
|
||||
this.diffExtractor
|
||||
);
|
||||
|
||||
private started = false;
|
||||
private activeRunsCount = 0;
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private tickInProgress = false;
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.started || config.review.engine !== 'agent') {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.store.init();
|
||||
const recovered = await this.store.recoverInterruptedRuns();
|
||||
if (recovered > 0) {
|
||||
logger.warn('检测到未完成的审查任务,已重新入队', { recovered });
|
||||
}
|
||||
|
||||
this.timer = setInterval(() => {
|
||||
this.tick().catch((error) => {
|
||||
logger.error('Review Engine tick 失败', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
this.started = true;
|
||||
logger.info('Agent Review Engine 已启动');
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
async enqueuePullRequest(
|
||||
payload: PullRequestReviewPayload
|
||||
): Promise<{ run: ReviewRun; reused: boolean }> {
|
||||
await this.store.init();
|
||||
return this.store.createOrReuseRun(payload);
|
||||
}
|
||||
|
||||
async enqueueCommit(payload: CommitReviewPayload): Promise<{ run: ReviewRun; reused: boolean }> {
|
||||
await this.store.init();
|
||||
return this.store.createOrReuseRun(payload);
|
||||
}
|
||||
|
||||
async listRuns(limit = 50): Promise<ReviewRun[]> {
|
||||
return this.store.listRuns(limit);
|
||||
}
|
||||
|
||||
async getRunDetails(
|
||||
runId: string
|
||||
): Promise<Awaited<ReturnType<FileReviewStore['getRunDetails']>>> {
|
||||
return this.store.getRunDetails(runId);
|
||||
}
|
||||
|
||||
getStore(): FileReviewStore {
|
||||
return this.store;
|
||||
}
|
||||
|
||||
private async tick(): Promise<void> {
|
||||
// 防止重入:如果上一次tick还在执行,跳过本次调度
|
||||
if (this.tickInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tickInProgress = true;
|
||||
try {
|
||||
// 检查是否达到并行限制
|
||||
const maxParallel = config.review.maxParallelRuns;
|
||||
if (this.activeRunsCount >= maxParallel) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 尝试获取并启动新任务,直到达到并行上限
|
||||
while (this.activeRunsCount < maxParallel) {
|
||||
const run = await this.store.acquireNextQueuedRun();
|
||||
if (!run) {
|
||||
break; // 队列为空
|
||||
}
|
||||
|
||||
// 启动异步任务,不等待完成
|
||||
this.activeRunsCount++;
|
||||
this.processRun(run).finally(() => {
|
||||
this.activeRunsCount--;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this.tickInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async processRun(run: ReviewRun): Promise<void> {
|
||||
logger.info('开始处理 Agent 审查任务', {
|
||||
runId: run.id,
|
||||
owner: run.owner,
|
||||
repo: run.repo,
|
||||
eventType: run.eventType,
|
||||
activeRuns: this.activeRunsCount,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.orchestrator.execute(run);
|
||||
|
||||
// 检查run状态,防止将ignored状态覆盖为succeeded
|
||||
const runDetails = await this.store.getRunDetails(run.id);
|
||||
if (runDetails && runDetails.run.status !== 'ignored') {
|
||||
await this.store.markRunSucceeded(run.id);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const failed = await this.store.markRunFailed(run.id, message);
|
||||
if (!failed.requeued) {
|
||||
logger.error('审查任务失败并达到重试上限', { runId: run.id, error: message });
|
||||
} else {
|
||||
logger.warn('审查任务失败,已重新入队重试', { runId: run.id, error: message });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const reviewEngine = new ReviewEngine();
|
||||
210
src/review/learning/learning-system.ts
Normal file
210
src/review/learning/learning-system.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import OpenAI from 'openai';
|
||||
import config from '../../config';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { VectorMemoryStore } from '../memory/vector-store';
|
||||
import { FileReviewStore } from '../store/file-review-store';
|
||||
import { Finding, FindingCategory } from '../types';
|
||||
|
||||
export class LearningSystem {
|
||||
constructor(
|
||||
private memoryStore: VectorMemoryStore,
|
||||
private store: FileReviewStore
|
||||
) {}
|
||||
|
||||
async learnFromFalsePositive(
|
||||
finding: Finding,
|
||||
reason: string,
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<void> {
|
||||
// 存储误报模式到向量记忆
|
||||
await this.memoryStore.storeMemory({
|
||||
id: `fp-${finding.id}`,
|
||||
type: 'pattern',
|
||||
content: `False Positive: ${finding.title}\nReason: ${reason}\nEvidence: ${finding.evidence}\nCategory: ${finding.category}`,
|
||||
metadata: {
|
||||
category: finding.category,
|
||||
approved: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
owner,
|
||||
repo,
|
||||
project: `${owner}/${repo}`,
|
||||
},
|
||||
});
|
||||
|
||||
// 查找相似的未发布findings,降低置信度
|
||||
const similarFindings = await this.findSimilarPendingFindings(finding);
|
||||
|
||||
for (const similar of similarFindings) {
|
||||
if (!similar.published && similar.confidence > 0.5) {
|
||||
const newConfidence = Math.max(similar.confidence - 0.2, 0.3);
|
||||
await this.store.updateFindingConfidence(similar.id, newConfidence);
|
||||
|
||||
logger.info('从误报中学习,降低相似finding置信度', {
|
||||
findingId: similar.id,
|
||||
oldConfidence: similar.confidence,
|
||||
newConfidence,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('从误报中学习完成', {
|
||||
findingId: finding.id,
|
||||
category: finding.category,
|
||||
updatedSimilar: similarFindings.length,
|
||||
});
|
||||
}
|
||||
|
||||
async generateFewShotExamples(
|
||||
category: FindingCategory,
|
||||
owner?: string,
|
||||
repo?: string
|
||||
): Promise<OpenAI.Chat.ChatCompletionMessageParam[]> {
|
||||
const targetCount = config.review.fewShotExamplesCount;
|
||||
|
||||
// 提前检查:如果few-shot被禁用(targetCount=0),直接返回,避免无意义的向量查询
|
||||
if (targetCount === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 构建过滤条件
|
||||
const filter: any = {
|
||||
must: [{ key: 'category', match: { value: category } }],
|
||||
};
|
||||
|
||||
// 如果指定了项目,优先使用该项目的示例
|
||||
if (owner && repo) {
|
||||
filter.must.push({ key: 'project', match: { value: `${owner}/${repo}` } });
|
||||
}
|
||||
|
||||
// 使用category名称作为通用查询而非空字符串,避免无意义的embedding调用
|
||||
const categoryQuery = `${category} issues in code`;
|
||||
|
||||
// 获取已批准的正样本
|
||||
const approvedFilter = {
|
||||
must: [...filter.must, { key: 'approved', match: { value: true } }],
|
||||
};
|
||||
const approved = await this.memoryStore.searchSimilar(categoryQuery, 10, approvedFilter);
|
||||
|
||||
// 获取误报的负样本
|
||||
const rejectedFilter = {
|
||||
must: [...filter.must, { key: 'approved', match: { value: false } }],
|
||||
};
|
||||
const rejected = await this.memoryStore.searchSimilar(categoryQuery, 5, rejectedFilter);
|
||||
|
||||
// 如果项目内示例不足,补充全局示例
|
||||
if (approved.length < targetCount) {
|
||||
const globalApproved = await this.memoryStore.searchSimilar(categoryQuery, 10, {
|
||||
must: [
|
||||
{ key: 'category', match: { value: category } },
|
||||
{ key: 'approved', match: { value: true } },
|
||||
],
|
||||
});
|
||||
approved.push(
|
||||
...globalApproved.filter((a) => !approved.find((e) => e.entry.id === a.entry.id))
|
||||
);
|
||||
}
|
||||
|
||||
const examples: OpenAI.Chat.ChatCompletionMessageParam[] = [];
|
||||
|
||||
const negativeCount = Math.floor(targetCount * 0.4);
|
||||
|
||||
// 添加正样本示例
|
||||
for (const a of approved.slice(0, targetCount)) {
|
||||
examples.push({
|
||||
role: 'user',
|
||||
content: `审查这段代码变更,关注${category}相关问题:\n${a.entry.content}`,
|
||||
});
|
||||
examples.push({
|
||||
role: 'assistant',
|
||||
content: JSON.stringify({
|
||||
findings: [
|
||||
{
|
||||
title: a.entry.content.split('\n')[0].replace('False Positive: ', ''),
|
||||
category,
|
||||
severity: a.entry.metadata.severity || 'medium',
|
||||
valid: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// 添加负样本示例(误报)
|
||||
for (const r of rejected.slice(0, negativeCount)) {
|
||||
examples.push({
|
||||
role: 'user',
|
||||
content: `审查这段代码变更,关注${category}相关问题:\n${r.entry.content}`,
|
||||
});
|
||||
examples.push({
|
||||
role: 'assistant',
|
||||
content: JSON.stringify({
|
||||
findings: [],
|
||||
reason: '历史反馈表明这类情况不应报告为问题',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug('生成Few-shot示例', {
|
||||
category,
|
||||
positiveExamples: approved.length,
|
||||
negativeExamples: rejected.length,
|
||||
totalMessages: examples.length,
|
||||
});
|
||||
|
||||
return examples;
|
||||
}
|
||||
|
||||
private async findSimilarPendingFindings(_finding: Finding): Promise<Finding[]> {
|
||||
// 这里简化实现,实际应该查询数据库中相似的findings
|
||||
// 由于FileReviewStore没有这个方法,我们暂时返回空数组
|
||||
// 在实际部署时需要扩展FileReviewStore
|
||||
return [];
|
||||
}
|
||||
|
||||
async learnFromApproval(finding: Finding, _owner: string, _repo: string): Promise<void> {
|
||||
// 将已批准的finding存储为正样本
|
||||
await this.memoryStore.storeFinding(finding, true, _owner, _repo);
|
||||
|
||||
logger.info('从批准中学习完成', {
|
||||
findingId: finding.id,
|
||||
category: finding.category,
|
||||
severity: finding.severity,
|
||||
});
|
||||
}
|
||||
|
||||
async getConfidenceAdjustment(
|
||||
finding: Omit<Finding, 'id' | 'runId' | 'published'>,
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<number> {
|
||||
// 搜索相似的误报(优先同一项目)
|
||||
const query = `${finding.title}\n${finding.evidence}`;
|
||||
const similarFalsePositives = await this.memoryStore.searchSimilar(query, 3, {
|
||||
must: [
|
||||
{ key: 'type', match: { value: 'pattern' } },
|
||||
{ key: 'category', match: { value: finding.category } },
|
||||
{ key: 'project', match: { value: `${owner}/${repo}` } },
|
||||
],
|
||||
});
|
||||
|
||||
if (similarFalsePositives.length === 0) {
|
||||
return 0; // 无需调整
|
||||
}
|
||||
|
||||
// 根据相似度计算置信度惩罚
|
||||
const maxSimilarity = Math.max(...similarFalsePositives.map((fp) => fp.score));
|
||||
|
||||
if (maxSimilarity > 0.9) {
|
||||
return -0.3; // 高度相似的误报,大幅降低置信度
|
||||
}
|
||||
if (maxSimilarity > 0.8) {
|
||||
return -0.15; // 中度相似,适度降低
|
||||
}
|
||||
if (maxSimilarity > 0.7) {
|
||||
return -0.05; // 低度相似,略微降低
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
29
src/review/memory/types.ts
Normal file
29
src/review/memory/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface MemoryEntry {
|
||||
id: string;
|
||||
type: 'finding' | 'feedback' | 'pattern';
|
||||
content: string;
|
||||
embedding?: number[];
|
||||
metadata: {
|
||||
category?: string;
|
||||
severity?: string;
|
||||
approved?: boolean;
|
||||
timestamp: string;
|
||||
project?: string;
|
||||
owner?: string;
|
||||
repo?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MemorySearchResult {
|
||||
entry: MemoryEntry;
|
||||
score: number;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
export interface FeedbackRecord {
|
||||
findingId: string;
|
||||
approved: boolean;
|
||||
reason: string;
|
||||
timestamp: string;
|
||||
reviewer?: string;
|
||||
}
|
||||
207
src/review/memory/vector-store.ts
Normal file
207
src/review/memory/vector-store.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { QdrantClient } from '@qdrant/js-client-rest';
|
||||
import OpenAI from 'openai';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { Finding } from '../types';
|
||||
import { MemoryEntry, MemorySearchResult } from './types';
|
||||
|
||||
export class VectorMemoryStore {
|
||||
private client: QdrantClient;
|
||||
private openai: OpenAI;
|
||||
private collectionName = 'code_review_memory';
|
||||
private initialized = false;
|
||||
|
||||
constructor(qdrantUrl: string, openaiClient: OpenAI) {
|
||||
this.client = new QdrantClient({ url: qdrantUrl });
|
||||
this.openai = openaiClient;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const collections = await this.client.getCollections();
|
||||
const exists = collections.collections.some((c) => c.name === this.collectionName);
|
||||
|
||||
if (!exists) {
|
||||
await this.client.createCollection(this.collectionName, {
|
||||
vectors: {
|
||||
size: 1536, // text-embedding-3-small dimension
|
||||
distance: 'Cosine',
|
||||
},
|
||||
});
|
||||
logger.info('向量记忆集合已创建', { collection: this.collectionName });
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
logger.info('向量记忆系统已初始化');
|
||||
} catch (error) {
|
||||
logger.error('向量记忆系统初始化失败', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async storeMemory(entry: MemoryEntry): Promise<void> {
|
||||
await this.initialize();
|
||||
|
||||
const embedding = await this.getEmbedding(entry.content);
|
||||
|
||||
await this.client.upsert(this.collectionName, {
|
||||
points: [
|
||||
{
|
||||
id: entry.id,
|
||||
vector: embedding,
|
||||
payload: {
|
||||
type: entry.type,
|
||||
content: entry.content,
|
||||
...entry.metadata,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
logger.debug('记忆已存储', {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
category: entry.metadata.category,
|
||||
});
|
||||
}
|
||||
|
||||
async searchSimilar(query: string, limit = 5, filter?: any): Promise<MemorySearchResult[]> {
|
||||
await this.initialize();
|
||||
|
||||
const queryEmbedding = await this.getEmbedding(query);
|
||||
|
||||
const results = await this.client.search(this.collectionName, {
|
||||
vector: queryEmbedding,
|
||||
limit,
|
||||
filter,
|
||||
});
|
||||
|
||||
return results.map((r) => ({
|
||||
entry: {
|
||||
id: String(r.id),
|
||||
type: r.payload?.type as any,
|
||||
content: r.payload?.content as string,
|
||||
metadata: {
|
||||
category: r.payload?.category as string,
|
||||
severity: r.payload?.severity as string,
|
||||
approved: r.payload?.approved as boolean,
|
||||
timestamp: r.payload?.timestamp as string,
|
||||
project: r.payload?.project as string,
|
||||
owner: r.payload?.owner as string,
|
||||
repo: r.payload?.repo as string,
|
||||
},
|
||||
},
|
||||
score: r.score,
|
||||
distance: 1 - r.score,
|
||||
}));
|
||||
}
|
||||
|
||||
private async getEmbedding(text: string): Promise<number[]> {
|
||||
try {
|
||||
const response = await this.openai.embeddings.create({
|
||||
model: 'text-embedding-3-small',
|
||||
input: text.slice(0, 8000), // 限制长度防止超出token限制
|
||||
});
|
||||
|
||||
return response.data[0].embedding;
|
||||
} catch (error) {
|
||||
logger.error('生成embedding失败', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async storeFinding(
|
||||
finding: Finding,
|
||||
approved: boolean,
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<void> {
|
||||
const content = `${finding.title}\n${finding.detail}\nEvidence: ${finding.evidence}`;
|
||||
|
||||
// 使用repo-scoped ID防止不同仓库的findings相互覆盖
|
||||
const scopedId = `${owner}/${repo}:${finding.fingerprint}`;
|
||||
|
||||
await this.storeMemory({
|
||||
id: scopedId,
|
||||
type: 'finding',
|
||||
content,
|
||||
metadata: {
|
||||
category: finding.category,
|
||||
severity: finding.severity,
|
||||
approved,
|
||||
timestamp: new Date().toISOString(),
|
||||
owner,
|
||||
repo,
|
||||
project: `${owner}/${repo}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getHistoricalContext(
|
||||
currentFinding: Partial<Finding>,
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<string> {
|
||||
const query = `${currentFinding.title}\n${currentFinding.evidence || ''}`;
|
||||
|
||||
// 优先搜索同一项目的相似问题
|
||||
const projectSimilar = await this.searchSimilar(query, 2, {
|
||||
must: [
|
||||
{ key: 'approved', match: { value: true } },
|
||||
{ key: 'project', match: { value: `${owner}/${repo}` } },
|
||||
],
|
||||
});
|
||||
|
||||
// 如果项目内没有足够相似问题,搜索全局
|
||||
let similar = projectSimilar;
|
||||
if (similar.length < 2) {
|
||||
const globalSimilar = await this.searchSimilar(query, 3, {
|
||||
must: [{ key: 'approved', match: { value: true } }],
|
||||
});
|
||||
similar = [...projectSimilar, ...globalSimilar].slice(0, 3);
|
||||
}
|
||||
|
||||
if (similar.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `\n\n历史相似问题参考:\n${similar
|
||||
.map(
|
||||
(s, i) =>
|
||||
`${i + 1}. ${s.entry.content.split('\n')[0]} (相似度: ${(s.score * 100).toFixed(1)}%, 项目: ${
|
||||
s.entry.metadata.project || '未知'
|
||||
})`
|
||||
)
|
||||
.join('\n')}`;
|
||||
}
|
||||
|
||||
async storeFeedback(
|
||||
findingId: string,
|
||||
approved: boolean,
|
||||
reason: string,
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<void> {
|
||||
const content = `Feedback: ${approved ? 'Approved' : 'Rejected'}\nReason: ${reason}\nFinding ID: ${findingId}`;
|
||||
|
||||
await this.storeMemory({
|
||||
id: `feedback-${findingId}-${Date.now()}`,
|
||||
type: 'feedback',
|
||||
content,
|
||||
metadata: {
|
||||
approved,
|
||||
timestamp: new Date().toISOString(),
|
||||
owner,
|
||||
repo,
|
||||
project: `${owner}/${repo}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
559
src/review/orchestrator.ts
Normal file
559
src/review/orchestrator.ts
Normal file
@@ -0,0 +1,559 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import OpenAI from 'openai';
|
||||
import config from '../config';
|
||||
import { giteaService } from '../services/gitea';
|
||||
import { logger } from '../utils/logger';
|
||||
import { DebateOrchestrator } from './agents/debate-orchestrator';
|
||||
import { JudgeAgent } from './agents/judge-agent';
|
||||
import { ReflexionAgent } from './agents/reflexion-agent';
|
||||
import { DiffExtractor } from './context/diff-extractor';
|
||||
import { LocalRepoManager, LocalRepoPaths } from './context/local-repo-manager';
|
||||
import { LearningSystem } from './learning/learning-system';
|
||||
import { VectorMemoryStore } from './memory/vector-store';
|
||||
import { applyPublishPolicy } from './policy/publish-policy';
|
||||
import { FileReviewStore } from './store/file-review-store';
|
||||
import { createCodeSearchTool } from './tools/code-search-tool';
|
||||
import { createFileReadTool } from './tools/file-read-tool';
|
||||
import { createFunctionReferenceSearchTool } from './tools/function-reference-search-tool';
|
||||
import { ToolRegistry } from './tools/registry';
|
||||
import { Finding, ReviewRun } from './types';
|
||||
|
||||
interface LineCommentInput {
|
||||
path: string;
|
||||
line: number;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
function findingToLineComment(
|
||||
finding: Omit<Finding, 'id' | 'runId' | 'published'>
|
||||
): LineCommentInput {
|
||||
return {
|
||||
path: finding.path,
|
||||
line: finding.line,
|
||||
comment: `**[${finding.severity.toUpperCase()}][${finding.category}]** ${finding.title}\n\n${finding.detail}\n\n建议: ${finding.suggestion}`,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeGatedCount(gatedCount: number): string {
|
||||
if (gatedCount <= 0) {
|
||||
return '';
|
||||
}
|
||||
return `\n\n> ${gatedCount} 条低置信或低优先级问题已进入人工审批队列。`;
|
||||
}
|
||||
|
||||
export class ReviewOrchestrator {
|
||||
private readonly openai: OpenAI;
|
||||
private readonly toolRegistry: ToolRegistry;
|
||||
private readonly correctnessAgent: ReflexionAgent;
|
||||
private readonly securityAgent: ReflexionAgent;
|
||||
private readonly reliabilityAgent: ReflexionAgent;
|
||||
private readonly maintainabilityAgent: ReflexionAgent;
|
||||
private readonly judgeAgent: JudgeAgent;
|
||||
private readonly debateOrchestrator: DebateOrchestrator;
|
||||
private readonly memoryStore?: VectorMemoryStore;
|
||||
private readonly learningSystem?: LearningSystem;
|
||||
|
||||
constructor(
|
||||
private readonly store: FileReviewStore,
|
||||
private readonly localRepoManager: LocalRepoManager,
|
||||
private readonly diffExtractor: DiffExtractor
|
||||
) {
|
||||
this.openai = new OpenAI({
|
||||
baseURL: config.openai.baseUrl,
|
||||
apiKey: config.openai.apiKey,
|
||||
});
|
||||
|
||||
// 初始化工具注册表
|
||||
this.toolRegistry = new ToolRegistry();
|
||||
this.toolRegistry.register(createCodeSearchTool(this.diffExtractor.getSandbox()));
|
||||
this.toolRegistry.register(createFunctionReferenceSearchTool(this.diffExtractor.getSandbox()));
|
||||
this.toolRegistry.register(createFileReadTool());
|
||||
|
||||
logger.info('已注册工具(支持所有编程语言)', {
|
||||
tools: this.toolRegistry.getAll().map((t) => t.name),
|
||||
});
|
||||
|
||||
// 初始化记忆和学习系统(可选)
|
||||
if (config.review.qdrantUrl && config.review.enableMemory) {
|
||||
this.memoryStore = new VectorMemoryStore(config.review.qdrantUrl, this.openai);
|
||||
this.learningSystem = new LearningSystem(this.memoryStore, this.store);
|
||||
|
||||
this.memoryStore.initialize().catch((err) => {
|
||||
logger.warn('向量记忆系统初始化失败', { error: err.message });
|
||||
});
|
||||
|
||||
logger.info('向量记忆系统已启用', { qdrantUrl: config.review.qdrantUrl });
|
||||
}
|
||||
|
||||
// 创建Reflexion-wrapped agents并传递工具注册表和学习系统
|
||||
this.correctnessAgent = new ReflexionAgent(
|
||||
this.openai,
|
||||
config.review.modelSpecialist,
|
||||
'correctness',
|
||||
'Correctness Agent',
|
||||
'业务逻辑正确性、边界条件、空值处理和明显bug',
|
||||
this.toolRegistry,
|
||||
this.learningSystem
|
||||
);
|
||||
|
||||
this.securityAgent = new ReflexionAgent(
|
||||
this.openai,
|
||||
config.review.modelSpecialist,
|
||||
'security',
|
||||
'Security Agent',
|
||||
'注入漏洞、权限绕过、敏感信息泄露、反序列化和输入校验缺失',
|
||||
this.toolRegistry,
|
||||
this.learningSystem
|
||||
);
|
||||
|
||||
this.reliabilityAgent = new ReflexionAgent(
|
||||
this.openai,
|
||||
config.review.modelSpecialist,
|
||||
'reliability',
|
||||
'Reliability Agent',
|
||||
'错误处理、重试策略、幂等性、并发一致性和资源释放',
|
||||
this.toolRegistry,
|
||||
this.learningSystem
|
||||
);
|
||||
|
||||
this.maintainabilityAgent = new ReflexionAgent(
|
||||
this.openai,
|
||||
config.review.modelSpecialist,
|
||||
'maintainability',
|
||||
'Maintainability Agent',
|
||||
'可维护性、复杂度、接口破坏风险和可测试性不足',
|
||||
this.toolRegistry,
|
||||
this.learningSystem
|
||||
);
|
||||
|
||||
this.judgeAgent = new JudgeAgent();
|
||||
this.debateOrchestrator = new DebateOrchestrator(this.openai, config.review.modelSpecialist);
|
||||
}
|
||||
|
||||
async execute(run: ReviewRun): Promise<void> {
|
||||
const targetSha = run.headSha || run.commitSha;
|
||||
if (!targetSha) {
|
||||
await this.store.markRunIgnored(run.id, '缺少目标 sha');
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceStepStart = Date.now();
|
||||
await this.store.addStep({
|
||||
runId: run.id,
|
||||
stepName: 'prepare_workspace',
|
||||
status: 'started',
|
||||
startedAt: new Date(workspaceStepStart).toISOString(),
|
||||
});
|
||||
|
||||
let repoPaths: LocalRepoPaths | null = null;
|
||||
|
||||
try {
|
||||
repoPaths = await this.localRepoManager.prepareWorkspace(
|
||||
run.owner,
|
||||
run.repo,
|
||||
run.cloneUrl,
|
||||
targetSha,
|
||||
run.id,
|
||||
run.headCloneUrl
|
||||
);
|
||||
|
||||
await this.store.addStep({
|
||||
runId: run.id,
|
||||
stepName: 'prepare_workspace',
|
||||
status: 'succeeded',
|
||||
startedAt: new Date(workspaceStepStart).toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
latencyMs: Date.now() - workspaceStepStart,
|
||||
});
|
||||
|
||||
const contextStart = Date.now();
|
||||
await this.store.addStep({
|
||||
runId: run.id,
|
||||
stepName: 'build_context',
|
||||
status: 'started',
|
||||
startedAt: new Date(contextStart).toISOString(),
|
||||
});
|
||||
|
||||
const context = await this.diffExtractor.buildContext(
|
||||
run,
|
||||
repoPaths.mirrorPath,
|
||||
repoPaths.workspacePath
|
||||
);
|
||||
|
||||
await this.store.addStep({
|
||||
runId: run.id,
|
||||
stepName: 'build_context',
|
||||
status: 'succeeded',
|
||||
startedAt: new Date(contextStart).toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
latencyMs: Date.now() - contextStart,
|
||||
});
|
||||
|
||||
if (!context.diff.trim()) {
|
||||
await this.publishSummary(run, '本次变更无可审查差异内容,已跳过自动行级评论。', 0);
|
||||
await this.store.markRunIgnored(run.id, '无可审查差异');
|
||||
return;
|
||||
}
|
||||
|
||||
const agentStart = Date.now();
|
||||
await this.store.addStep({
|
||||
runId: run.id,
|
||||
stepName: 'run_specialists',
|
||||
status: 'started',
|
||||
startedAt: new Date(agentStart).toISOString(),
|
||||
});
|
||||
|
||||
// 使用Reflection模式运行specialists
|
||||
const enableReflection = config.review.enableReflection ?? false;
|
||||
const maxReflectionRounds = config.review.maxReflectionRounds ?? 2;
|
||||
|
||||
const agentResults = await Promise.all([
|
||||
enableReflection
|
||||
? this.correctnessAgent.reviewWithReflection(run, context, maxReflectionRounds)
|
||||
: this.correctnessAgent.review(run, context),
|
||||
enableReflection
|
||||
? this.securityAgent.reviewWithReflection(run, context, maxReflectionRounds)
|
||||
: this.securityAgent.review(run, context),
|
||||
enableReflection
|
||||
? this.reliabilityAgent.reviewWithReflection(run, context, maxReflectionRounds)
|
||||
: this.reliabilityAgent.review(run, context),
|
||||
enableReflection
|
||||
? this.maintainabilityAgent.reviewWithReflection(run, context, maxReflectionRounds)
|
||||
: this.maintainabilityAgent.review(run, context),
|
||||
]);
|
||||
|
||||
await this.store.addStep({
|
||||
runId: run.id,
|
||||
stepName: 'run_specialists',
|
||||
status: 'succeeded',
|
||||
startedAt: new Date(agentStart).toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
latencyMs: Date.now() - agentStart,
|
||||
});
|
||||
|
||||
let allFindings = agentResults.flatMap((result) => result.findings);
|
||||
|
||||
// 对高严重性findings启动Debate
|
||||
const enableDebate = config.review.enableDebate ?? false;
|
||||
const debateThreshold = config.review.debateThreshold ?? 'high';
|
||||
|
||||
if (enableDebate && allFindings.length > 0) {
|
||||
const debateStart = Date.now();
|
||||
await this.store.addStep({
|
||||
runId: run.id,
|
||||
stepName: 'debate_high_severity',
|
||||
status: 'started',
|
||||
startedAt: new Date(debateStart).toISOString(),
|
||||
});
|
||||
|
||||
const debatableFindings = allFindings.filter((f) => {
|
||||
if (debateThreshold === 'high') return f.severity === 'high';
|
||||
if (debateThreshold === 'medium') return f.severity === 'high' || f.severity === 'medium';
|
||||
return false;
|
||||
});
|
||||
|
||||
logger.info('启动Debate阶段', {
|
||||
runId: run.id,
|
||||
totalFindings: allFindings.length,
|
||||
debatableFindings: debatableFindings.length,
|
||||
threshold: debateThreshold,
|
||||
});
|
||||
|
||||
const debatedFindings: typeof allFindings = [];
|
||||
for (const finding of debatableFindings) {
|
||||
const debatedFinding = await this.debateOrchestrator.conductDebate(finding, [
|
||||
this.correctnessAgent,
|
||||
this.securityAgent,
|
||||
this.reliabilityAgent,
|
||||
this.maintainabilityAgent,
|
||||
]);
|
||||
debatedFindings.push(debatedFinding);
|
||||
}
|
||||
|
||||
// 替换原findings
|
||||
allFindings = [
|
||||
...debatedFindings,
|
||||
...allFindings.filter((f) => !debatableFindings.includes(f)),
|
||||
];
|
||||
|
||||
await this.store.addStep({
|
||||
runId: run.id,
|
||||
stepName: 'debate_high_severity',
|
||||
status: 'succeeded',
|
||||
startedAt: new Date(debateStart).toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
latencyMs: Date.now() - debateStart,
|
||||
});
|
||||
}
|
||||
|
||||
const decision = this.judgeAgent.judge(allFindings);
|
||||
const policyResult = applyPublishPolicy(
|
||||
decision.findings,
|
||||
config.review.autoPublishMinConfidence,
|
||||
config.review.enableHumanGate
|
||||
);
|
||||
|
||||
// 检查是否重试:检测summary或line comments是否已发布,避免重复发布
|
||||
// summary comment特征:status='published' 且 path字段为空
|
||||
// line comment特征:status='published' 且 path字段存在
|
||||
const runDetails = await this.store.getRunDetails(run.id);
|
||||
const summaryPublished =
|
||||
runDetails?.comments.some((comment) => comment.status === 'published' && !comment.path) ||
|
||||
false;
|
||||
const lineCommentsPublished =
|
||||
runDetails?.comments.some((comment) => comment.status === 'published' && comment.path) ||
|
||||
false;
|
||||
|
||||
if (lineCommentsPublished) {
|
||||
logger.info('检测到重试且line comments已发布,跳过line comments和findings标记', {
|
||||
runId: run.id,
|
||||
existingLineComments: runDetails?.comments.filter((c) => c.path).length,
|
||||
});
|
||||
// 重试场景:line comments已发布,跳过line comments发布步骤
|
||||
// 注意:不能return,需要继续执行summary和pending gate记录(即使summary已存在)
|
||||
}
|
||||
|
||||
// 只持久化publishable和gated的findings(human gate禁用时丢弃低质量findings)
|
||||
// 避免将不会发布也不会人工审批的findings加入pending队列
|
||||
const findingsToStore = [...policyResult.publishable, ...policyResult.gated];
|
||||
|
||||
// 创建fingerprint -> published状态的映射,用于在retry时恢复published状态
|
||||
// 防止addFindings覆盖时将已发布的findings重置为unpublished
|
||||
const existingPublishedStatus = new Map<string, boolean>();
|
||||
if (runDetails?.findings) {
|
||||
for (const f of runDetails.findings) {
|
||||
existingPublishedStatus.set(f.fingerprint, f.published);
|
||||
}
|
||||
}
|
||||
|
||||
const persistedFindings: Finding[] = findingsToStore.map((finding) => ({
|
||||
...finding,
|
||||
id: randomUUID(),
|
||||
runId: run.id,
|
||||
// 如果finding已经published(retry场景),保留published状态,否则设为false
|
||||
published: existingPublishedStatus.get(finding.fingerprint) || false,
|
||||
}));
|
||||
await this.store.addFindings(run.id, persistedFindings);
|
||||
|
||||
// 先发布line comments(可重试步骤),成功后再发布summary
|
||||
// 顺序重要:如果publishLineComments失败导致重试,不会重复发布summary
|
||||
if (!lineCommentsPublished) {
|
||||
// 首次执行:发布line comments并标记findings
|
||||
const lineComments = policyResult.publishable.map(findingToLineComment);
|
||||
const lineCommentsPublishedSuccessfully = await this.publishLineComments(run, lineComments);
|
||||
|
||||
// 只有实际发布了line comments才标记findings为published
|
||||
// 避免在无PR number等场景下findings消失但开发者没收到评论
|
||||
if (lineCommentsPublishedSuccessfully) {
|
||||
for (const finding of policyResult.publishable) {
|
||||
await this.store.markFindingPublished(run.id, finding.fingerprint);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Retry场景:line comments已发布,reconcile所有publishable findings的published状态
|
||||
// 防止crash/store write失败发生在markFindingPublished中间时,部分findings永远保持unpublished
|
||||
for (const finding of policyResult.publishable) {
|
||||
await this.store.markFindingPublished(run.id, finding.fingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
// Summary放在最后:line comments和markFindingPublished都成功后才发布
|
||||
// 如果前面步骤失败重试,不会产生重复summary
|
||||
if (!summaryPublished) {
|
||||
await this.publishSummary(run, decision.summaryMarkdown, policyResult.gated.length);
|
||||
} else {
|
||||
logger.info('Summary已发布,跳过重复发布', { runId: run.id });
|
||||
}
|
||||
|
||||
// 关键:即使summary已存在,仍需添加gated findings到pending队列
|
||||
// 防止crash发生在publishSummary之后、addCommentRecord之前时丢失待审批findings
|
||||
// 使用幂等性检查防止retry时重复添加
|
||||
const existingPendingComments =
|
||||
runDetails?.comments.filter((c) => c.status === 'pending') || [];
|
||||
|
||||
// 跟踪本次循环中已添加的location,防止同一run中多个findings在同一位置导致重复pending记录
|
||||
const addedLocations = new Set<string>();
|
||||
|
||||
for (const finding of policyResult.gated) {
|
||||
const locationKey = `${finding.path}:${finding.line}`;
|
||||
|
||||
// 检查是否已存在相同的pending记录(通过runId + path + line去重)
|
||||
// 需要同时检查:1) 之前run的记录 2) 本次循环已添加的记录
|
||||
const alreadyPending =
|
||||
existingPendingComments.some((c) => c.path === finding.path && c.line === finding.line) ||
|
||||
addedLocations.has(locationKey);
|
||||
|
||||
if (!alreadyPending) {
|
||||
await this.store.addCommentRecord({
|
||||
runId: run.id,
|
||||
status: 'pending',
|
||||
body: `PENDING: ${finding.title}`,
|
||||
path: finding.path,
|
||||
line: finding.line,
|
||||
fingerprint: finding.fingerprint,
|
||||
});
|
||||
addedLocations.add(locationKey);
|
||||
} else {
|
||||
logger.debug('跳过已存在的pending记录', {
|
||||
runId: run.id,
|
||||
path: finding.path,
|
||||
line: finding.line,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 将已发布的findings存储到向量记忆(自动标记为已批准)
|
||||
if (this.memoryStore && policyResult.publishable.length > 0) {
|
||||
for (const finding of policyResult.publishable) {
|
||||
const persistedFinding = persistedFindings.find(
|
||||
(f) => f.fingerprint === finding.fingerprint
|
||||
);
|
||||
if (persistedFinding) {
|
||||
try {
|
||||
await this.memoryStore.storeFinding(
|
||||
persistedFinding as Finding,
|
||||
true,
|
||||
run.owner,
|
||||
run.repo
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn('存储finding到向量记忆失败', {
|
||||
findingId: persistedFinding.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.debug('已发布findings已存储到向量记忆', {
|
||||
count: policyResult.publishable.length,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('Agent 审查流程完成', {
|
||||
runId: run.id,
|
||||
owner: run.owner,
|
||||
repo: run.repo,
|
||||
findings: decision.findings.length,
|
||||
published: policyResult.publishable.length,
|
||||
gated: policyResult.gated.length,
|
||||
dropped: policyResult.dropped.length,
|
||||
});
|
||||
} catch (error) {
|
||||
await this.store.addStep({
|
||||
runId: run.id,
|
||||
stepName: 'orchestrator',
|
||||
status: 'failed',
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
if (repoPaths) {
|
||||
await this.localRepoManager.cleanupWorkspace(repoPaths);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async publishSummary(run: ReviewRun, summary: string, gatedCount: number): Promise<void> {
|
||||
const body = `## AI Agent代码审查结果\n\n${summary}${summarizeGatedCount(gatedCount)}`;
|
||||
|
||||
if (run.eventType === 'pull_request' && run.prNumber) {
|
||||
await giteaService.addPullRequestComment(run.owner, run.repo, run.prNumber, body);
|
||||
|
||||
// 尝试写入本地record,失败不抛出(避免阻塞整个审查流程)
|
||||
// 如果失败,retry时会因缺少record重复发布summary(可接受的权衡)
|
||||
try {
|
||||
await this.store.addCommentRecord({
|
||||
runId: run.id,
|
||||
status: 'published',
|
||||
body,
|
||||
});
|
||||
} catch (storeError) {
|
||||
logger.error(
|
||||
'Failed to persist summary comment record (non-fatal, may cause duplicate on retry)',
|
||||
{
|
||||
runId: run.id,
|
||||
error: storeError instanceof Error ? storeError.message : String(storeError),
|
||||
}
|
||||
);
|
||||
// 不抛出,允许审查流程继续
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.commitSha) {
|
||||
await giteaService.addCommitComment(run.owner, run.repo, run.commitSha, body);
|
||||
|
||||
try {
|
||||
await this.store.addCommentRecord({
|
||||
runId: run.id,
|
||||
status: 'published',
|
||||
body,
|
||||
});
|
||||
} catch (storeError) {
|
||||
logger.error(
|
||||
'Failed to persist summary comment record (non-fatal, may cause duplicate on retry)',
|
||||
{
|
||||
runId: run.id,
|
||||
error: storeError instanceof Error ? storeError.message : String(storeError),
|
||||
}
|
||||
);
|
||||
// 不抛出,允许审查流程继续
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async publishLineComments(
|
||||
run: ReviewRun,
|
||||
comments: LineCommentInput[]
|
||||
): Promise<boolean> {
|
||||
if (comments.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const commitId = run.commitSha || run.headSha;
|
||||
if (!commitId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let prNumber = run.prNumber || run.relatedPrNumber;
|
||||
if (!prNumber) {
|
||||
const related = await giteaService.getRelatedPullRequest(run.owner, run.repo, commitId);
|
||||
prNumber = related?.number;
|
||||
}
|
||||
|
||||
if (!prNumber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await giteaService.addLineComments(run.owner, run.repo, prNumber, commitId, comments);
|
||||
|
||||
// 尝试为每个comment写入本地record,失败不抛出(避免阻塞整个审查流程)
|
||||
// 如果部分失败,retry时lineCommentsPublished可能为false/partial,导致重复发布(可接受的权衡)
|
||||
for (const comment of comments) {
|
||||
try {
|
||||
await this.store.addCommentRecord({
|
||||
runId: run.id,
|
||||
status: 'published',
|
||||
path: comment.path,
|
||||
line: comment.line,
|
||||
body: comment.comment,
|
||||
});
|
||||
} catch (storeError) {
|
||||
logger.error(
|
||||
'Failed to persist line comment record (non-fatal, may cause duplicate on retry)',
|
||||
{
|
||||
runId: run.id,
|
||||
path: comment.path,
|
||||
line: comment.line,
|
||||
error: storeError instanceof Error ? storeError.message : String(storeError),
|
||||
}
|
||||
);
|
||||
// 不抛出,继续处理下一条comment
|
||||
}
|
||||
}
|
||||
|
||||
return true; // 成功发布
|
||||
}
|
||||
}
|
||||
40
src/review/policy/publish-policy.ts
Normal file
40
src/review/policy/publish-policy.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Finding } from '../types';
|
||||
|
||||
export interface PublishPolicyResult {
|
||||
publishable: Omit<Finding, 'id' | 'runId' | 'published'>[];
|
||||
gated: Omit<Finding, 'id' | 'runId' | 'published'>[];
|
||||
dropped: Omit<Finding, 'id' | 'runId' | 'published'>[];
|
||||
}
|
||||
|
||||
export function applyPublishPolicy(
|
||||
findings: Omit<Finding, 'id' | 'runId' | 'published'>[],
|
||||
minConfidence: number,
|
||||
enableHumanGate: boolean
|
||||
): PublishPolicyResult {
|
||||
const publishable: Omit<Finding, 'id' | 'runId' | 'published'>[] = [];
|
||||
const gated: Omit<Finding, 'id' | 'runId' | 'published'>[] = [];
|
||||
const dropped: Omit<Finding, 'id' | 'runId' | 'published'>[] = [];
|
||||
|
||||
for (const finding of findings) {
|
||||
const meetsConfidence = finding.confidence >= minConfidence;
|
||||
const lowSeverity = finding.severity === 'low';
|
||||
|
||||
// 高置信度 + 中/高严重度 → 直接发布
|
||||
if (meetsConfidence && !lowSeverity) {
|
||||
publishable.push(finding);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 人工门禁开启时,所有未达标的 finding 进入待审批队列
|
||||
if (enableHumanGate) {
|
||||
gated.push(finding);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 人工门禁关闭时,明确记录被丢弃的 findings(低置信度或低严重度)
|
||||
// 低严重度但高置信度的 finding 也不自动发布,避免开发者产生噪音疲劳
|
||||
dropped.push(finding);
|
||||
}
|
||||
|
||||
return { publishable, gated, dropped };
|
||||
}
|
||||
20
src/review/schema/finding-schema.ts
Normal file
20
src/review/schema/finding-schema.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const findingItemSchema = z.object({
|
||||
category: z.enum(['correctness', 'security', 'reliability', 'maintainability']).optional(),
|
||||
severity: z.enum(['high', 'medium', 'low']),
|
||||
confidence: z.number().min(0).max(1),
|
||||
path: z.string().min(1),
|
||||
line: z.number().int().positive(),
|
||||
title: z.string().min(1),
|
||||
detail: z.string().min(1),
|
||||
evidence: z.string().min(1),
|
||||
suggestion: z.string().min(1),
|
||||
fingerprint: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const findingResponseSchema = z.object({
|
||||
findings: z.array(findingItemSchema).default([]),
|
||||
});
|
||||
|
||||
export type FindingResponse = z.infer<typeof findingResponseSchema>;
|
||||
418
src/review/store/file-review-store.ts
Normal file
418
src/review/store/file-review-store.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
CommitReviewPayload,
|
||||
Finding,
|
||||
PullRequestReviewPayload,
|
||||
ReviewCommentRecord,
|
||||
ReviewPayload,
|
||||
ReviewRun,
|
||||
ReviewRunStatus,
|
||||
ReviewStep,
|
||||
} from '../types';
|
||||
|
||||
interface ReviewStoreData {
|
||||
runs: ReviewRun[];
|
||||
steps: ReviewStep[];
|
||||
findings: Finding[];
|
||||
comments: ReviewCommentRecord[];
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
// 创建全新的空数据结构,避免共享引用
|
||||
function createEmptyData(): ReviewStoreData {
|
||||
return {
|
||||
runs: [],
|
||||
steps: [],
|
||||
findings: [],
|
||||
comments: [],
|
||||
};
|
||||
}
|
||||
|
||||
export class FileReviewStore {
|
||||
private readonly statePath: string;
|
||||
private data: ReviewStoreData = createEmptyData();
|
||||
private initialized = false;
|
||||
private writeChain: Promise<void> = Promise.resolve();
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(workDir: string) {
|
||||
this.statePath = path.join(workDir, 'state', 'review-store.json');
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
// 如果已初始化,直接返回
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果有正在进行的init,等待它完成(防止并发init导致数据竞争)
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
// 创建initPromise来序列化并发init调用
|
||||
this.initPromise = (async () => {
|
||||
try {
|
||||
await mkdir(path.dirname(this.statePath), { recursive: true });
|
||||
|
||||
try {
|
||||
const raw = await readFile(this.statePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as ReviewStoreData;
|
||||
this.data = {
|
||||
runs: parsed.runs ?? [],
|
||||
steps: parsed.steps ?? [],
|
||||
findings: parsed.findings ?? [],
|
||||
comments: parsed.comments ?? [],
|
||||
};
|
||||
} catch (error: any) {
|
||||
// 仅在文件不存在(初始化)时创建空数据
|
||||
// 读取/解析错误时抛出异常,避免擦除现有数据
|
||||
if (error.code === 'ENOENT') {
|
||||
this.data = createEmptyData();
|
||||
await this.persist();
|
||||
} else {
|
||||
throw new Error(`Store初始化失败 - 拒绝擦除数据: ${error.message || String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
} finally {
|
||||
// 无论成功或失败,清理initPromise以允许失败后重试
|
||||
this.initPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
async createOrReuseRun(payload: ReviewPayload): Promise<{ run: ReviewRun; reused: boolean }> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
const existing = this.data.runs.find(
|
||||
(run) => run.idempotencyKey === payload.idempotencyKey && run.status !== 'failed'
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
return { run: existing, reused: true };
|
||||
}
|
||||
|
||||
// 防止同一 idempotencyKey 的 failed runs 无限累积:
|
||||
// 如果已存在超过 MAX_FAILED_RUNS_PER_KEY 个失败记录,清理最早的记录
|
||||
const MAX_FAILED_RUNS_PER_KEY = 3;
|
||||
const failedRuns = this.data.runs.filter(
|
||||
(run) => run.idempotencyKey === payload.idempotencyKey && run.status === 'failed'
|
||||
);
|
||||
if (failedRuns.length >= MAX_FAILED_RUNS_PER_KEY) {
|
||||
// 按创建时间升序排列,移除最早的记录
|
||||
failedRuns.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
const toRemove = failedRuns.slice(0, failedRuns.length - MAX_FAILED_RUNS_PER_KEY + 1);
|
||||
const removeIds = new Set(toRemove.map((r) => r.id));
|
||||
this.data.runs = this.data.runs.filter((run) => !removeIds.has(run.id));
|
||||
// 同时清理关联的 steps、findings、comments
|
||||
this.data.steps = this.data.steps.filter((s) => !removeIds.has(s.runId));
|
||||
this.data.findings = this.data.findings.filter((f) => !removeIds.has(f.runId));
|
||||
this.data.comments = this.data.comments.filter((c) => !removeIds.has(c.runId));
|
||||
}
|
||||
|
||||
const timestamp = nowIso();
|
||||
const baseRun: ReviewRun = {
|
||||
id: randomUUID(),
|
||||
idempotencyKey: payload.idempotencyKey,
|
||||
eventType: payload.eventType,
|
||||
status: 'queued',
|
||||
owner: payload.owner,
|
||||
repo: payload.repo,
|
||||
cloneUrl: payload.cloneUrl,
|
||||
headCloneUrl: payload.headCloneUrl,
|
||||
attempts: 0,
|
||||
maxAttempts: payload.maxAttempts ?? 2,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
const run = this.populateRunDetails(baseRun, payload);
|
||||
this.data.runs.push(run);
|
||||
await this.persist();
|
||||
|
||||
return { run, reused: false };
|
||||
}
|
||||
|
||||
async recoverInterruptedRuns(): Promise<number> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
let recovered = 0;
|
||||
const timestamp = nowIso();
|
||||
for (const run of this.data.runs) {
|
||||
if (run.status === 'in_progress') {
|
||||
run.status = 'queued';
|
||||
run.updatedAt = timestamp;
|
||||
recovered += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (recovered > 0) {
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
return recovered;
|
||||
}
|
||||
|
||||
async acquireNextQueuedRun(): Promise<ReviewRun | null> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
const run = this.data.runs.find((item) => item.status === 'queued');
|
||||
if (!run) {
|
||||
return null;
|
||||
}
|
||||
|
||||
run.status = 'in_progress';
|
||||
run.startedAt = nowIso();
|
||||
run.updatedAt = run.startedAt;
|
||||
await this.persist();
|
||||
|
||||
return { ...run };
|
||||
}
|
||||
|
||||
async markRunSucceeded(runId: string): Promise<void> {
|
||||
await this.markRunFinished(runId, 'succeeded');
|
||||
}
|
||||
|
||||
async markRunIgnored(runId: string, reason: string): Promise<void> {
|
||||
await this.markRunFinished(runId, 'ignored', reason);
|
||||
}
|
||||
|
||||
async markRunFailed(
|
||||
runId: string,
|
||||
error: string
|
||||
): Promise<{ requeued: boolean; run: ReviewRun | null }> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
const run = this.data.runs.find((item) => item.id === runId);
|
||||
if (!run) {
|
||||
return { requeued: false, run: null };
|
||||
}
|
||||
|
||||
run.attempts += 1;
|
||||
run.error = error;
|
||||
run.updatedAt = nowIso();
|
||||
|
||||
const shouldRetry = run.attempts < run.maxAttempts;
|
||||
if (shouldRetry) {
|
||||
run.status = 'queued';
|
||||
run.startedAt = undefined;
|
||||
} else {
|
||||
run.status = 'failed';
|
||||
run.finishedAt = nowIso();
|
||||
}
|
||||
|
||||
await this.persist();
|
||||
return { requeued: shouldRetry, run: { ...run } };
|
||||
}
|
||||
|
||||
async addStep(step: Omit<ReviewStep, 'id'>): Promise<ReviewStep> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
const record: ReviewStep = {
|
||||
...step,
|
||||
id: randomUUID(),
|
||||
};
|
||||
|
||||
this.data.steps.push(record);
|
||||
await this.persist();
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
async addFindings(runId: string, findings: Finding[]): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
this.data.findings = this.data.findings.filter((item) => item.runId !== runId);
|
||||
this.data.findings.push(...findings);
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
async markFindingPublished(runId: string, fingerprint: string): Promise<boolean> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
let wasUnpublished = false;
|
||||
|
||||
for (const finding of this.data.findings) {
|
||||
if (finding.runId === runId && finding.fingerprint === fingerprint) {
|
||||
// 返回true仅当finding从unpublished变为published(原子check-and-set)
|
||||
// 用于实现幂等性:只有第一个调用者会得到true
|
||||
if (!finding.published) {
|
||||
wasUnpublished = true;
|
||||
finding.published = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.persist();
|
||||
return wasUnpublished;
|
||||
}
|
||||
|
||||
async unmarkFindingPublished(runId: string, fingerprint: string): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
for (const finding of this.data.findings) {
|
||||
if (finding.runId === runId && finding.fingerprint === fingerprint) {
|
||||
finding.published = false;
|
||||
}
|
||||
}
|
||||
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
async addCommentRecord(comment: Omit<ReviewCommentRecord, 'id' | 'createdAt'>): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
const record: ReviewCommentRecord = {
|
||||
...comment,
|
||||
id: randomUUID(),
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
|
||||
this.data.comments.push(record);
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
async listRuns(limit = 50): Promise<ReviewRun[]> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
const runs = [...this.data.runs].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
return runs.slice(0, limit);
|
||||
}
|
||||
|
||||
async getRunDetails(runId: string): Promise<{
|
||||
run: ReviewRun;
|
||||
steps: ReviewStep[];
|
||||
findings: Finding[];
|
||||
comments: ReviewCommentRecord[];
|
||||
} | null> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
const run = this.data.runs.find((item) => item.id === runId);
|
||||
if (!run) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
run: { ...run },
|
||||
steps: this.data.steps.filter((item) => item.runId === runId),
|
||||
findings: this.data.findings.filter((item) => item.runId === runId),
|
||||
comments: this.data.comments.filter((item) => item.runId === runId),
|
||||
};
|
||||
}
|
||||
|
||||
async getFinding(findingId: string): Promise<Finding | null> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
const finding = this.data.findings.find((item) => item.id === findingId);
|
||||
return finding ? { ...finding } : null;
|
||||
}
|
||||
|
||||
async updateFindingConfidence(findingId: string, newConfidence: number): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
const finding = this.data.findings.find((item) => item.id === findingId);
|
||||
if (finding) {
|
||||
finding.confidence = newConfidence;
|
||||
await this.persist();
|
||||
}
|
||||
}
|
||||
|
||||
async getPendingFindings(limit = 100): Promise<Finding[]> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
return this.data.findings
|
||||
.filter((finding) => !finding.published)
|
||||
.sort((a, b) => b.confidence - a.confidence)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
private populateRunDetails(baseRun: ReviewRun, payload: ReviewPayload): ReviewRun {
|
||||
if (payload.eventType === 'pull_request') {
|
||||
return this.populatePullRequestRun(baseRun, payload);
|
||||
}
|
||||
return this.populateCommitRun(baseRun, payload);
|
||||
}
|
||||
|
||||
private populatePullRequestRun(baseRun: ReviewRun, payload: PullRequestReviewPayload): ReviewRun {
|
||||
return {
|
||||
...baseRun,
|
||||
prNumber: payload.prNumber,
|
||||
baseSha: payload.baseSha,
|
||||
headSha: payload.headSha,
|
||||
commitSha: payload.headSha,
|
||||
};
|
||||
}
|
||||
|
||||
private populateCommitRun(baseRun: ReviewRun, payload: CommitReviewPayload): ReviewRun {
|
||||
return {
|
||||
...baseRun,
|
||||
commitSha: payload.commitSha,
|
||||
commitMessage: payload.commitMessage,
|
||||
relatedPrNumber: payload.relatedPrNumber,
|
||||
headSha: payload.commitSha,
|
||||
};
|
||||
}
|
||||
|
||||
private async markRunFinished(
|
||||
runId: string,
|
||||
status: ReviewRunStatus,
|
||||
error?: string
|
||||
): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
const run = this.data.runs.find((item) => item.id === runId);
|
||||
if (!run) {
|
||||
return;
|
||||
}
|
||||
|
||||
run.status = status;
|
||||
run.error = error;
|
||||
run.finishedAt = nowIso();
|
||||
run.updatedAt = run.finishedAt;
|
||||
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
private async ensureInitialized(): Promise<void> {
|
||||
if (!this.initialized) {
|
||||
await this.init();
|
||||
}
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
// 追踪当前write操作是否成功,失败时立即抛出给调用者(防止静默数据丢失)
|
||||
let currentWriteError: Error | null = null;
|
||||
|
||||
this.writeChain = this.writeChain.then(async () => {
|
||||
try {
|
||||
// 原子写入:先写临时文件,再 rename 覆盖目标文件
|
||||
// POSIX rename 是原子操作,即使进程在 rename 中间崩溃,文件也不会损坏
|
||||
const tempPath = `${this.statePath}.tmp`;
|
||||
await writeFile(tempPath, JSON.stringify(this.data, null, 2), 'utf-8');
|
||||
await rename(tempPath, this.statePath);
|
||||
currentWriteError = null; // 写入成功
|
||||
} catch (error) {
|
||||
// 捕获错误但不重新throw,保持chain为resolved状态(允许后续persist()重试)
|
||||
currentWriteError = error instanceof Error ? error : new Error(String(error));
|
||||
console.error('Store persist failed:', currentWriteError);
|
||||
}
|
||||
});
|
||||
|
||||
await this.writeChain;
|
||||
|
||||
// 检查当前write是否失败,如果失败则立即向调用者报告
|
||||
// 这确保触发persist()的操作(如enqueueing run)不会返回成功而实际未持久化
|
||||
if (currentWriteError) {
|
||||
throw currentWriteError;
|
||||
}
|
||||
}
|
||||
}
|
||||
75
src/review/tools/code-search-tool.ts
Normal file
75
src/review/tools/code-search-tool.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { z } from 'zod';
|
||||
import { SandboxExec } from '../context/sandbox-exec';
|
||||
import { Tool } from './types';
|
||||
|
||||
export function createCodeSearchTool(sandbox: SandboxExec): Tool {
|
||||
return {
|
||||
name: 'search_code',
|
||||
description: '在代码库中搜索匹配给定模式的代码,支持正则表达式。用于发现相似问题或影响范围。',
|
||||
parameters: z.object({
|
||||
pattern: z.string().describe('要搜索的正则表达式模式'),
|
||||
file_types: z.array(z.string()).optional().describe('限制搜索的文件类型,如["ts", "js"]'),
|
||||
max_results: z.number().default(20).describe('最大返回结果数'),
|
||||
}),
|
||||
execute: async (params, context) => {
|
||||
const { pattern, file_types, max_results } = params;
|
||||
|
||||
// 构建ripgrep参数:选项必须在--之前,--之后只能是pattern和路径等位置参数
|
||||
const args = ['--json', '--max-count', String(max_results || 20)];
|
||||
|
||||
if (file_types && file_types.length > 0) {
|
||||
args.push('--type-add', `custom:*.{${file_types.join(',')}}`);
|
||||
args.push('--type', 'custom');
|
||||
}
|
||||
|
||||
// 使用--分隔选项和pattern,防止pattern以-开头时被误解析为ripgrep选项
|
||||
args.push('--', pattern, context.workspacePath);
|
||||
|
||||
try {
|
||||
const result = await sandbox.run('rg', args, {
|
||||
cwd: context.workspacePath,
|
||||
timeoutMs: 10000,
|
||||
});
|
||||
|
||||
if (!result.stdout.trim()) {
|
||||
return { matches: [], message: '未找到匹配结果' };
|
||||
}
|
||||
|
||||
// 解析ripgrep JSON输出并过滤只保留match事件(排除begin/end/summary)
|
||||
const matches = result.stdout
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((event) => event && event.type === 'match')
|
||||
.slice(0, max_results || 20);
|
||||
|
||||
return {
|
||||
matches: matches.map((m: any) => ({
|
||||
path: m.data?.path?.text || '',
|
||||
line: m.data?.line_number || 0,
|
||||
content: m.data?.lines?.text || '',
|
||||
})),
|
||||
total: matches.length,
|
||||
};
|
||||
} catch (error) {
|
||||
// ripgrep返回exit code 1表示无匹配(正常情况),不应视为错误
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (errorMessage.includes('code=1')) {
|
||||
return { matches: [], message: '未找到匹配结果' };
|
||||
}
|
||||
|
||||
// 其他错误(超时、权限等)才是真正的失败
|
||||
return {
|
||||
error: errorMessage,
|
||||
matches: [],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
59
src/review/tools/file-read-tool.ts
Normal file
59
src/review/tools/file-read-tool.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { readFile, realpath } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { Tool } from './types';
|
||||
|
||||
export function createFileReadTool(): Tool {
|
||||
return {
|
||||
name: 'read_file',
|
||||
description: '读取指定文件的完整内容,用于深入分析代码逻辑。',
|
||||
parameters: z.object({
|
||||
file_path: z.string().describe('相对文件路径'),
|
||||
start_line: z.number().optional().describe('起始行号(可选)'),
|
||||
end_line: z.number().optional().describe('结束行号(可选)'),
|
||||
}),
|
||||
execute: async (params, context) => {
|
||||
const { file_path, start_line, end_line } = params;
|
||||
|
||||
// 安全性:规范化路径并验证是否在workspace内
|
||||
const normalizedPath = path.normalize(file_path).replace(/^(\.\.[\/\\])+/, '');
|
||||
const fullPath = path.resolve(context.workspacePath, normalizedPath);
|
||||
|
||||
try {
|
||||
// 使用realpath解析完整路径(跟随所有符号链接)
|
||||
const realPath = await realpath(fullPath);
|
||||
const workspaceRealPath = await realpath(context.workspacePath);
|
||||
|
||||
// 验证解析后的真实路径必须在workspace目录下
|
||||
if (!realPath.startsWith(workspaceRealPath + path.sep) && realPath !== workspaceRealPath) {
|
||||
return {
|
||||
error: `安全错误:路径 "${file_path}" 解析到workspace外部 (${realPath})`,
|
||||
path: file_path,
|
||||
};
|
||||
}
|
||||
|
||||
const content = await readFile(realPath, 'utf-8');
|
||||
|
||||
if (start_line !== undefined && end_line !== undefined) {
|
||||
const lines = content.split('\n');
|
||||
return {
|
||||
path: file_path,
|
||||
content: lines.slice(start_line - 1, end_line).join('\n'),
|
||||
lines: `${start_line}-${end_line}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
path: file_path,
|
||||
content,
|
||||
totalLines: content.split('\n').length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
path: file_path,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
180
src/review/tools/function-reference-search-tool.ts
Normal file
180
src/review/tools/function-reference-search-tool.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { z } from 'zod';
|
||||
import { SandboxExec } from '../context/sandbox-exec';
|
||||
import { Tool } from './types';
|
||||
|
||||
// 转义正则元字符,将identifier中的特殊字符转义为字面量
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function createFunctionReferenceSearchTool(sandbox: SandboxExec): Tool {
|
||||
return {
|
||||
name: 'search_function_references',
|
||||
description:
|
||||
'搜索指定函数、方法或类的所有引用和定义(支持所有编程语言)。用于理解代码影响范围和调用关系。',
|
||||
parameters: z.object({
|
||||
identifier: z.string().describe('要搜索的标识符(函数名、类名、方法名等)'),
|
||||
file_types: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('限制搜索的文件类型,如["ts", "go", "py", "java"]'),
|
||||
search_type: z
|
||||
.enum(['calls', 'definitions', 'all'])
|
||||
.default('all')
|
||||
.describe('搜索类型:calls=仅调用,definitions=仅定义,all=全部'),
|
||||
max_results: z.number().default(30).describe('最大返回结果数'),
|
||||
}),
|
||||
execute: async (params, context) => {
|
||||
const { identifier, file_types, search_type, max_results } = params;
|
||||
|
||||
// 转义identifier中的正则元字符,避免被解释为正则语法
|
||||
const escapedId = escapeRegex(identifier);
|
||||
|
||||
// 定义调用模式(适配多种语言)
|
||||
const callPatterns: string[] = [
|
||||
`${escapedId}\\s*\\(`, // 直接调用: functionName(
|
||||
`\\.${escapedId}\\s*\\(`, // 方法调用: obj.methodName(
|
||||
`::${escapedId}\\s*\\(`, // C++/Rust静态调用: Class::method(
|
||||
`${escapedId}\\s*<[^>]+>\\s*\\(`, // 泛型调用: functionName<T>( (修复:限制<>内容)
|
||||
];
|
||||
|
||||
// 定义声明模式(多语言)
|
||||
const definitionPatterns: string[] = [
|
||||
`func\\s+${escapedId}\\s*\\(`, // Go: func functionName(
|
||||
`fn\\s+${escapedId}\\s*\\(`, // Rust: fn functionName(
|
||||
`def\\s+${escapedId}\\s*\\(`, // Python: def functionName(
|
||||
`function\\s+${escapedId}\\s*\\(`, // JavaScript: function functionName(
|
||||
`${escapedId}\\s*:\\s*function`, // JS对象方法: methodName: function
|
||||
`${escapedId}\\s*=\\s*\\([^)]*\\)\\s*=>`, // Arrow function: const fn = () => (修复:限制参数)
|
||||
`class\\s+${escapedId}\\s*[{<]`, // 类定义: class ClassName {
|
||||
`interface\\s+${escapedId}\\s*[{<]`, // 接口: interface InterfaceName {
|
||||
`type\\s+${escapedId}\\s*=`, // 类型别名: type TypeName =
|
||||
`struct\\s+${escapedId}\\s*[{]`, // Go/Rust struct: struct StructName {
|
||||
`public\\s+[^(]*\\s+${escapedId}\\s*\\(`, // Java方法: public void methodName(
|
||||
`private\\s+[^(]*\\s+${escapedId}\\s*\\(`, // Java私有方法
|
||||
];
|
||||
|
||||
// 根据search_type选择模式
|
||||
interface SearchTask {
|
||||
patterns: string[];
|
||||
type: 'call' | 'definition';
|
||||
}
|
||||
|
||||
const tasks: SearchTask[] = [];
|
||||
if (search_type === 'calls' || search_type === 'all') {
|
||||
tasks.push({ patterns: callPatterns, type: 'call' });
|
||||
}
|
||||
if (search_type === 'definitions' || search_type === 'all') {
|
||||
tasks.push({ patterns: definitionPatterns, type: 'definition' });
|
||||
}
|
||||
|
||||
// 分别执行搜索任务
|
||||
const allReferences: Array<{
|
||||
path: string;
|
||||
line: number;
|
||||
content: string;
|
||||
type: 'call' | 'definition';
|
||||
}> = [];
|
||||
|
||||
for (const task of tasks) {
|
||||
const pattern = task.patterns.join('|');
|
||||
const args = [
|
||||
'--json',
|
||||
// 移除 --ignore-case,保持大小写敏感(大多数语言都是case-sensitive)
|
||||
'--max-count',
|
||||
String(max_results || 30),
|
||||
'-e',
|
||||
pattern,
|
||||
context.workspacePath,
|
||||
];
|
||||
|
||||
if (file_types && file_types.length > 0) {
|
||||
args.push('--type-add', `custom:*.{${file_types.join(',')}}`);
|
||||
args.push('--type', 'custom');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sandbox.run('rg', args, {
|
||||
cwd: context.workspacePath,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
|
||||
if (result.stdout.trim()) {
|
||||
// 解析ripgrep JSON输出
|
||||
const matches = result.stdout
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((event) => event && event.type === 'match');
|
||||
|
||||
// 转换为统一格式,使用task.type作为分类
|
||||
for (const m of matches) {
|
||||
allReferences.push({
|
||||
path: m.data?.path?.text || '',
|
||||
line: m.data?.line_number || 0,
|
||||
content: (m.data?.lines?.text || '').trim(),
|
||||
type: task.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// ripgrep返回exit code 1表示无匹配,这是正常的,继续处理
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (!errorMessage.includes('code=1')) {
|
||||
// 非"无匹配"的错误才需要记录
|
||||
console.warn(`Search ${task.type} failed:`, errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 去重(同一位置可能同时匹配调用和定义模式)
|
||||
const uniqueRefs = new Map<string, (typeof allReferences)[0]>();
|
||||
for (const ref of allReferences) {
|
||||
const key = `${ref.path}:${ref.line}`;
|
||||
if (!uniqueRefs.has(key)) {
|
||||
uniqueRefs.set(key, ref);
|
||||
} else {
|
||||
// 如果重复,优先保留definition类型
|
||||
const existing = uniqueRefs.get(key)!;
|
||||
if (ref.type === 'definition' && existing.type === 'call') {
|
||||
uniqueRefs.set(key, ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const references = Array.from(uniqueRefs.values()).slice(0, max_results || 30);
|
||||
|
||||
if (references.length === 0) {
|
||||
return {
|
||||
identifier,
|
||||
references: [],
|
||||
total: 0,
|
||||
message: `未找到 ${identifier} 的引用`,
|
||||
note: '这是基于正则模式的近似搜索,可能遗漏动态调用或同名符号',
|
||||
};
|
||||
}
|
||||
|
||||
// 统计
|
||||
const callCount = references.filter((r) => r.type === 'call').length;
|
||||
const defCount = references.filter((r) => r.type === 'definition').length;
|
||||
|
||||
return {
|
||||
identifier,
|
||||
references,
|
||||
total: references.length,
|
||||
statistics: {
|
||||
calls: callCount,
|
||||
definitions: defCount,
|
||||
},
|
||||
summary: `找到 ${defCount} 个定义,${callCount} 个调用`,
|
||||
note: '⚠️ 基于正则的近似搜索,可能包含字符串/注释中的匹配。建议查看实际代码确认。',
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
52
src/review/tools/registry.ts
Normal file
52
src/review/tools/registry.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { z } from 'zod';
|
||||
import zodToJsonSchema from 'zod-to-json-schema';
|
||||
import type { JsonSchema7Type } from 'zod-to-json-schema';
|
||||
import { Tool } from './types';
|
||||
|
||||
export class ToolRegistry {
|
||||
private tools = new Map<string, Tool>();
|
||||
|
||||
register(tool: Tool): void {
|
||||
this.tools.set(tool.name, tool);
|
||||
}
|
||||
|
||||
get(name: string): Tool | undefined {
|
||||
return this.tools.get(name);
|
||||
}
|
||||
|
||||
getAll(): Tool[] {
|
||||
return Array.from(this.tools.values());
|
||||
}
|
||||
|
||||
// 转换为OpenAI Function定义
|
||||
toOpenAIFunctions() {
|
||||
return this.getAll().map((tool) => ({
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: this.zodToJsonSchema(tool.parameters),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
private zodToJsonSchema(schema: z.ZodTypeAny): JsonSchema7Type {
|
||||
/**
|
||||
* 使用zod-to-json-schema库转换Zod schema为JSON Schema。
|
||||
*
|
||||
* 注意:该库v3.25.1使用了复杂的条件类型推断,会导致 TS2589
|
||||
* "Type instantiation is excessively deep" 错误。这是库的已知限制,
|
||||
* 见 https://github.com/StefanTerdell/zod-to-json-schema/issues
|
||||
*
|
||||
* 类型安全保证:
|
||||
* - 输入:z.ZodTypeAny 确保只接受Zod schema
|
||||
* - 输出:JsonSchema7Type 明确返回类型
|
||||
* - 运行时行为:库本身经过充分测试,转换逻辑正确
|
||||
*/
|
||||
// @ts-expect-error TS2589: zod-to-json-schema v3.25.1 的条件类型过于复杂
|
||||
return zodToJsonSchema(schema, {
|
||||
target: 'openApi3',
|
||||
$refStrategy: 'none',
|
||||
});
|
||||
}
|
||||
}
|
||||
27
src/review/tools/types.ts
Normal file
27
src/review/tools/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface Tool {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: z.ZodTypeAny;
|
||||
execute: (params: any, context: ToolExecutionContext) => Promise<any>;
|
||||
}
|
||||
|
||||
export interface ToolExecutionContext {
|
||||
workspacePath: string;
|
||||
mirrorPath: string;
|
||||
runId: string;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
toolName: string;
|
||||
parameters: any;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
toolCallId: string;
|
||||
success: boolean;
|
||||
result?: any;
|
||||
error?: string;
|
||||
}
|
||||
137
src/review/types.ts
Normal file
137
src/review/types.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
export type ReviewEngineMode = 'legacy' | 'agent';
|
||||
|
||||
export type ReviewEventType = 'pull_request' | 'commit_status';
|
||||
|
||||
export type ReviewRunStatus = 'queued' | 'in_progress' | 'succeeded' | 'failed' | 'ignored';
|
||||
|
||||
export type FindingSeverity = 'high' | 'medium' | 'low';
|
||||
|
||||
export type FindingCategory = 'correctness' | 'security' | 'reliability' | 'maintainability';
|
||||
|
||||
export interface ReviewRun {
|
||||
id: string;
|
||||
idempotencyKey: string;
|
||||
eventType: ReviewEventType;
|
||||
status: ReviewRunStatus;
|
||||
owner: string;
|
||||
repo: string;
|
||||
cloneUrl: string;
|
||||
headCloneUrl?: string; // Fork PR场景:head commit的源仓库URL,用于fetch head SHA
|
||||
prNumber?: number;
|
||||
relatedPrNumber?: number;
|
||||
baseSha?: string;
|
||||
headSha?: string;
|
||||
commitSha?: string;
|
||||
commitMessage?: string;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ReviewStep {
|
||||
id: string;
|
||||
runId: string;
|
||||
stepName: string;
|
||||
agentName?: string;
|
||||
status: 'started' | 'succeeded' | 'failed';
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
latencyMs?: number;
|
||||
inputRef?: string;
|
||||
outputRef?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface Finding {
|
||||
id: string;
|
||||
runId: string;
|
||||
fingerprint: string;
|
||||
category: FindingCategory;
|
||||
severity: FindingSeverity;
|
||||
confidence: number;
|
||||
path: string;
|
||||
line: number;
|
||||
title: string;
|
||||
detail: string;
|
||||
evidence: string;
|
||||
suggestion: string;
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
export interface ReviewCommentRecord {
|
||||
id: string;
|
||||
runId: string;
|
||||
path?: string;
|
||||
line?: number;
|
||||
body: string;
|
||||
giteaCommentId?: number;
|
||||
status: 'pending' | 'published' | 'failed';
|
||||
createdAt: string;
|
||||
fingerprint?: string; // Finding fingerprint for matching feedback to specific findings
|
||||
}
|
||||
|
||||
export interface ReviewPayloadBase {
|
||||
idempotencyKey: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
cloneUrl: string;
|
||||
headCloneUrl?: string; // Fork PR场景:head commit的源仓库URL
|
||||
maxAttempts?: number;
|
||||
}
|
||||
|
||||
export interface PullRequestReviewPayload extends ReviewPayloadBase {
|
||||
eventType: 'pull_request';
|
||||
prNumber: number;
|
||||
baseSha: string;
|
||||
headSha: string;
|
||||
}
|
||||
|
||||
export interface CommitReviewPayload extends ReviewPayloadBase {
|
||||
eventType: 'commit_status';
|
||||
commitSha: string;
|
||||
commitMessage?: string;
|
||||
relatedPrNumber?: number;
|
||||
}
|
||||
|
||||
export type ReviewPayload = PullRequestReviewPayload | CommitReviewPayload;
|
||||
|
||||
export interface ChangedFile {
|
||||
path: string;
|
||||
status: 'A' | 'M' | 'D' | 'R' | 'C' | 'T' | 'U' | 'X' | 'B';
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
export interface DiffLine {
|
||||
lineNumber: number;
|
||||
content: string;
|
||||
type: 'add' | 'context';
|
||||
}
|
||||
|
||||
export interface DiffFile {
|
||||
path: string;
|
||||
changes: DiffLine[];
|
||||
}
|
||||
|
||||
export interface ReviewContext {
|
||||
workspacePath: string;
|
||||
mirrorPath: string;
|
||||
diff: string;
|
||||
changedFiles: ChangedFile[];
|
||||
parsedDiff: DiffFile[];
|
||||
fileContents: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AgentResult {
|
||||
agentName: string;
|
||||
findings: Omit<Finding, 'id' | 'runId' | 'published'>[];
|
||||
}
|
||||
|
||||
export interface ReviewDecision {
|
||||
summaryMarkdown: string;
|
||||
findings: Omit<Finding, 'id' | 'runId' | 'published'>[];
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import OpenAI from 'openai';
|
||||
import config from '../config';
|
||||
import { logger } from '../utils/logger';
|
||||
import { giteaService, PullRequestFile } from './gitea';
|
||||
import { PullRequestFile, giteaService } from './gitea';
|
||||
|
||||
// 创建OpenAI客户端
|
||||
const openai = new OpenAI({
|
||||
@@ -92,7 +92,7 @@ export const aiReviewService = {
|
||||
logger.warn('提交差异为空,无法进行代码审查');
|
||||
return {
|
||||
summary: '提交差异为空,无法进行代码审查',
|
||||
lineComments: []
|
||||
lineComments: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ export const aiReviewService = {
|
||||
const context: ReviewContext = {
|
||||
changedFiles: files,
|
||||
fileContents,
|
||||
diffContent
|
||||
diffContent,
|
||||
};
|
||||
|
||||
// 使用上下文进行总体评价
|
||||
@@ -153,7 +153,7 @@ export const aiReviewService = {
|
||||
return {
|
||||
changedFiles,
|
||||
fileContents,
|
||||
diffContent
|
||||
diffContent,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error('获取审查上下文失败:', error);
|
||||
@@ -161,7 +161,7 @@ export const aiReviewService = {
|
||||
return {
|
||||
changedFiles: [],
|
||||
fileContents: {},
|
||||
diffContent
|
||||
diffContent,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -174,13 +174,13 @@ export const aiReviewService = {
|
||||
async generateSummary(context: ReviewContext): Promise<string> {
|
||||
try {
|
||||
// 准备上下文信息
|
||||
const fileInfo = context.changedFiles.map(file => {
|
||||
const fileInfo = context.changedFiles.map((file) => {
|
||||
return {
|
||||
path: file.filename,
|
||||
status: file.status,
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
content: context.fileContents[file.filename] || '无法获取文件内容'
|
||||
content: context.fileContents[file.filename] || '无法获取文件内容',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -205,9 +205,10 @@ export const aiReviewService = {
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: '你是一个专业的代码审查助手,擅长识别代码中的严重问题和bug。你会查看代码的完整上下文,而不是为了评论而评论。如无明显问题,应给予简短肯定。'
|
||||
content:
|
||||
'你是一个专业的代码审查助手,擅长识别代码中的严重问题和bug。你会查看代码的完整上下文,而不是为了评论而评论。如无明显问题,应给予简短肯定。',
|
||||
},
|
||||
{ role: 'user', content: summaryPrompt }
|
||||
{ role: 'user', content: summaryPrompt },
|
||||
],
|
||||
temperature: 0.1,
|
||||
});
|
||||
@@ -234,7 +235,7 @@ export const aiReviewService = {
|
||||
// 对每个文件的变更行进行审查
|
||||
for (const file of diffFiles) {
|
||||
// 只对添加的行进行评论
|
||||
const addedLines = file.changes.filter(change => change.type === 'add');
|
||||
const addedLines = file.changes.filter((change) => change.type === 'add');
|
||||
if (addedLines.length === 0) continue;
|
||||
|
||||
// 获取文件的完整内容作为上下文
|
||||
@@ -257,7 +258,7 @@ export const aiReviewService = {
|
||||
${fileContent}
|
||||
|
||||
变更部分上下文:
|
||||
${file.changes.map(c => `${c.lineNumber}: ${c.content} (${c.type === 'add' ? '新增' : '上下文'})`).join('\n')}
|
||||
${file.changes.map((c) => `${c.lineNumber}: ${c.content} (${c.type === 'add' ? '新增' : '上下文'})`).join('\n')}
|
||||
|
||||
请以JSON格式返回评论,格式如下:
|
||||
[
|
||||
@@ -276,9 +277,10 @@ export const aiReviewService = {
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: '你是一个谨慎的代码审查助手,只对有明显bug或严重问题的代码行提供评论。大多数情况下,如果代码没有严重问题,你应该返回空数组。请以JSON格式返回结果。'
|
||||
content:
|
||||
'你是一个谨慎的代码审查助手,只对有明显bug或严重问题的代码行提供评论。大多数情况下,如果代码没有严重问题,你应该返回空数组。请以JSON格式返回结果。',
|
||||
},
|
||||
{ role: 'user', content: filePrompt }
|
||||
{ role: 'user', content: filePrompt },
|
||||
],
|
||||
temperature: 0.1,
|
||||
response_format: { type: 'json_object' },
|
||||
@@ -290,7 +292,9 @@ export const aiReviewService = {
|
||||
try {
|
||||
// 解析JSON响应
|
||||
const responseObject = JSON.parse(content);
|
||||
const comments = Array.isArray(responseObject) ? responseObject : (responseObject.comments || []);
|
||||
const comments = Array.isArray(responseObject)
|
||||
? responseObject
|
||||
: responseObject.comments || [];
|
||||
|
||||
// 添加到结果中
|
||||
for (const comment of comments) {
|
||||
@@ -298,7 +302,7 @@ export const aiReviewService = {
|
||||
lineComments.push({
|
||||
path: file.path,
|
||||
line: comment.line,
|
||||
comment: comment.comment
|
||||
comment: comment.comment,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -321,17 +325,17 @@ export const aiReviewService = {
|
||||
*/
|
||||
parseDiff(diffContent: string): Array<{
|
||||
path: string;
|
||||
changes: Array<{ lineNumber: number; content: string; type: 'add' | 'context' }>
|
||||
changes: Array<{ lineNumber: number; content: string; type: 'add' | 'context' }>;
|
||||
}> {
|
||||
const files: Array<{
|
||||
path: string;
|
||||
changes: Array<{ lineNumber: number; content: string; type: 'add' | 'context' }>
|
||||
changes: Array<{ lineNumber: number; content: string; type: 'add' | 'context' }>;
|
||||
}> = [];
|
||||
|
||||
const diffLines = diffContent.split('\n');
|
||||
let currentFile: {
|
||||
path: string;
|
||||
changes: Array<{ lineNumber: number; content: string; type: 'add' | 'context' }>
|
||||
changes: Array<{ lineNumber: number; content: string; type: 'add' | 'context' }>;
|
||||
} | null = null;
|
||||
|
||||
let lineNumber = 0;
|
||||
@@ -355,8 +359,8 @@ export const aiReviewService = {
|
||||
// Hunk头,记录起始行号
|
||||
else if (line.startsWith('@@')) {
|
||||
const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
if (match && match[1]) {
|
||||
lineNumber = parseInt(match[1], 10) - 1; // 因为下面会+1
|
||||
if (match?.[1]) {
|
||||
lineNumber = Number.parseInt(match[1], 10) - 1; // 因为下面会+1
|
||||
inHunk = true;
|
||||
}
|
||||
}
|
||||
@@ -368,7 +372,7 @@ export const aiReviewService = {
|
||||
currentFile.changes.push({
|
||||
lineNumber,
|
||||
content: line.substring(1),
|
||||
type: 'add'
|
||||
type: 'add',
|
||||
});
|
||||
} else if (line.startsWith(' ')) {
|
||||
// 上下文行
|
||||
@@ -376,7 +380,7 @@ export const aiReviewService = {
|
||||
currentFile.changes.push({
|
||||
lineNumber,
|
||||
content: line.substring(1),
|
||||
type: 'context'
|
||||
type: 'context',
|
||||
});
|
||||
} else if (line.startsWith('-')) {
|
||||
// 删除的行,不增加行号
|
||||
@@ -394,5 +398,5 @@ export const aiReviewService = {
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { logger } from '../utils/logger';
|
||||
import * as crypto from 'node:crypto';
|
||||
import config from '../config';
|
||||
import * as crypto from 'crypto';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export class FeishuService {
|
||||
private webhookUrl: string;
|
||||
@@ -42,14 +42,14 @@ export class FeishuService {
|
||||
const message: any = {
|
||||
msg_type: 'text',
|
||||
content: {
|
||||
text: content
|
||||
}
|
||||
text: content,
|
||||
},
|
||||
};
|
||||
|
||||
// 如果需要@用户,添加at信息
|
||||
if (usernames.length > 0) {
|
||||
message.content.text += '\n';
|
||||
usernames.forEach(username => {
|
||||
usernames.forEach((username) => {
|
||||
message.content.text += `@${username} `;
|
||||
});
|
||||
}
|
||||
@@ -85,7 +85,11 @@ export class FeishuService {
|
||||
* @param issueUrl 工单链接
|
||||
* @param assigneeUsernames 被指派人用户名列表
|
||||
*/
|
||||
async sendIssueCreatedNotification(issueTitle: string, issueUrl: string, assigneeUsernames: string[]): Promise<void> {
|
||||
async sendIssueCreatedNotification(
|
||||
issueTitle: string,
|
||||
issueUrl: string,
|
||||
assigneeUsernames: string[]
|
||||
): Promise<void> {
|
||||
const content = `📝 新工单已创建\n标题: ${issueTitle}\n链接: ${issueUrl}`;
|
||||
await this.sendMessage(content, assigneeUsernames);
|
||||
}
|
||||
@@ -96,7 +100,11 @@ export class FeishuService {
|
||||
* @param issueUrl 工单链接
|
||||
* @param creatorUsername 创建者用户名
|
||||
*/
|
||||
async sendIssueClosedNotification(issueTitle: string, issueUrl: string, creatorUsername: string): Promise<void> {
|
||||
async sendIssueClosedNotification(
|
||||
issueTitle: string,
|
||||
issueUrl: string,
|
||||
creatorUsername: string
|
||||
): Promise<void> {
|
||||
const content = `✅ 工单已关闭\n标题: ${issueTitle}\n链接: ${issueUrl}`;
|
||||
await this.sendMessage(content, [creatorUsername]);
|
||||
}
|
||||
@@ -107,7 +115,11 @@ export class FeishuService {
|
||||
* @param issueUrl 工单链接
|
||||
* @param assigneeUsernames 被指派人用户名列表
|
||||
*/
|
||||
async sendIssueAssignedNotification(issueTitle: string, issueUrl: string, assigneeUsernames: string[]): Promise<void> {
|
||||
async sendIssueAssignedNotification(
|
||||
issueTitle: string,
|
||||
issueUrl: string,
|
||||
assigneeUsernames: string[]
|
||||
): Promise<void> {
|
||||
const content = `👤 工单已指派给你\n标题: ${issueTitle}\n链接: ${issueUrl}`;
|
||||
await this.sendMessage(content, assigneeUsernames);
|
||||
}
|
||||
@@ -118,7 +130,11 @@ export class FeishuService {
|
||||
* @param prUrl PR链接
|
||||
* @param reviewerUsernames 审阅者用户名列表
|
||||
*/
|
||||
async sendPrCreatedNotification(prTitle: string, prUrl: string, reviewerUsernames: string[]): Promise<void> {
|
||||
async sendPrCreatedNotification(
|
||||
prTitle: string,
|
||||
prUrl: string,
|
||||
reviewerUsernames: string[]
|
||||
): Promise<void> {
|
||||
const content = `🔄 新PR等待你审阅\n标题: ${prTitle}\n链接: ${prUrl}`;
|
||||
await this.sendMessage(content, reviewerUsernames);
|
||||
}
|
||||
@@ -129,7 +145,11 @@ export class FeishuService {
|
||||
* @param prUrl PR链接
|
||||
* @param reviewerUsernames 审阅者用户名列表
|
||||
*/
|
||||
async sendPrReviewerAssignedNotification(prTitle: string, prUrl: string, reviewerUsernames: string[]): Promise<void> {
|
||||
async sendPrReviewerAssignedNotification(
|
||||
prTitle: string,
|
||||
prUrl: string,
|
||||
reviewerUsernames: string[]
|
||||
): Promise<void> {
|
||||
const content = `👀 你被指定为PR审阅者\n标题: ${prTitle}\n链接: ${prUrl}`;
|
||||
await this.sendMessage(content, reviewerUsernames);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import axios from 'axios';
|
||||
import config from '../config';
|
||||
import { logger } from '../utils/logger';
|
||||
import { LineComment } from './ai-review';
|
||||
|
||||
// 打印将要使用的 Admin Token,用于调试
|
||||
logger.info(`Gitea Admin Token used: [${config.admin.giteaAdminToken}]`);
|
||||
logger.info(`Gitea Access Token (fallback): [${config.gitea.accessToken}]`);
|
||||
export interface LineComment {
|
||||
path: string;
|
||||
line: number;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
// 创建API客户端
|
||||
const giteaClient = axios.create({
|
||||
baseURL: config.gitea.apiUrl,
|
||||
headers: {
|
||||
'Authorization': `token ${config.gitea.accessToken}`,
|
||||
Authorization: `token ${config.gitea.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
@@ -20,7 +21,7 @@ const giteaClient = axios.create({
|
||||
const giteaAdminClient = axios.create({
|
||||
baseURL: config.gitea.apiUrl,
|
||||
headers: {
|
||||
'Authorization': `token ${config.admin.giteaAdminToken || config.gitea.accessToken}`,
|
||||
Authorization: `token ${config.admin.giteaAdminToken || config.gitea.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'curl/7.81.0', // 伪装成 curl
|
||||
},
|
||||
@@ -45,13 +46,22 @@ export interface GiteaService {
|
||||
getCommitFiles(owner: string, repo: string, commitSha: string): Promise<PullRequestFile[]>;
|
||||
|
||||
// 获取与提交关联的Pull Request
|
||||
getRelatedPullRequest(owner: string, repo: string, commitSha: string): Promise<PullRequestDetails | null>;
|
||||
getRelatedPullRequest(
|
||||
owner: string,
|
||||
repo: string,
|
||||
commitSha: string
|
||||
): Promise<PullRequestDetails | null>;
|
||||
|
||||
// 获取文件内容
|
||||
getFileContent(owner: string, repo: string, path: string, ref?: string): Promise<string>;
|
||||
|
||||
// 获取引用的相关文件
|
||||
getRelatedFiles(owner: string, repo: string, files: PullRequestFile[], commitSha: string): Promise<Record<string, string>>;
|
||||
getRelatedFiles(
|
||||
owner: string,
|
||||
repo: string,
|
||||
files: PullRequestFile[],
|
||||
commitSha: string
|
||||
): Promise<Record<string, string>>;
|
||||
|
||||
// 添加PR评论
|
||||
addPullRequestComment(owner: string, repo: string, prNumber: number, body: string): Promise<void>;
|
||||
@@ -69,7 +79,11 @@ export interface GiteaService {
|
||||
addCommitComment(owner: string, repo: string, commitSha: string, body: string): Promise<void>;
|
||||
|
||||
// 管理后台方法
|
||||
listAllRepositories(page: number, limit: number, query?: string): Promise<{ repos: any[], totalCount: number }>;
|
||||
listAllRepositories(
|
||||
page: number,
|
||||
limit: number,
|
||||
query?: string
|
||||
): Promise<{ repos: any[]; totalCount: number }>;
|
||||
listWebhooks(owner: string, repo: string): Promise<any[]>;
|
||||
createWebhook(owner: string, repo: string, webhookUrl: string): Promise<void>;
|
||||
deleteWebhook(owner: string, repo: string, hookId: number): Promise<void>;
|
||||
@@ -117,7 +131,11 @@ export const giteaService: GiteaService = {
|
||||
},
|
||||
|
||||
// 获取PR详情
|
||||
async getPullRequestDetails(owner: string, repo: string, prNumber: number): Promise<PullRequestDetails> {
|
||||
async getPullRequestDetails(
|
||||
owner: string,
|
||||
repo: string,
|
||||
prNumber: number
|
||||
): Promise<PullRequestDetails> {
|
||||
try {
|
||||
const response = await giteaClient.get(`/repos/${owner}/${repo}/pulls/${prNumber}`);
|
||||
return response.data;
|
||||
@@ -128,7 +146,11 @@ export const giteaService: GiteaService = {
|
||||
},
|
||||
|
||||
// 获取PR变更的文件列表
|
||||
async getPullRequestFiles(owner: string, repo: string, prNumber: number): Promise<PullRequestFile[]> {
|
||||
async getPullRequestFiles(
|
||||
owner: string,
|
||||
repo: string,
|
||||
prNumber: number
|
||||
): Promise<PullRequestFile[]> {
|
||||
try {
|
||||
const response = await giteaClient.get(`/repos/${owner}/${repo}/pulls/${prNumber}/files`);
|
||||
return response.data || [];
|
||||
@@ -152,7 +174,9 @@ export const giteaService: GiteaService = {
|
||||
}
|
||||
|
||||
// 使用官方API获取差异,使用diff格式
|
||||
const diffResponse = await giteaClient.get(`/repos/${owner}/${repo}/git/commits/${commitSha}.diff`);
|
||||
const diffResponse = await giteaClient.get(
|
||||
`/repos/${owner}/${repo}/git/commits/${commitSha}.diff`
|
||||
);
|
||||
return diffResponse.data || '';
|
||||
} catch (error: any) {
|
||||
logger.error('获取提交差异失败:', error);
|
||||
@@ -174,10 +198,9 @@ export const giteaService: GiteaService = {
|
||||
if (response.data.files) {
|
||||
// 如果API返回了文件列表,则使用它
|
||||
return response.data.files;
|
||||
} else {
|
||||
// 否则返回空数组,依赖控制器中webhook提供的文件列表
|
||||
return [];
|
||||
}
|
||||
// 否则返回空数组,依赖控制器中webhook提供的文件列表
|
||||
return [];
|
||||
} catch (error: any) {
|
||||
logger.error('获取提交文件列表失败:', error);
|
||||
throw new Error(`获取提交文件列表失败: ${error.message}`);
|
||||
@@ -185,7 +208,11 @@ export const giteaService: GiteaService = {
|
||||
},
|
||||
|
||||
// 获取与提交关联的Pull Request
|
||||
async getRelatedPullRequest(owner: string, repo: string, commitSha: string): Promise<PullRequestDetails | null> {
|
||||
async getRelatedPullRequest(
|
||||
owner: string,
|
||||
repo: string,
|
||||
commitSha: string
|
||||
): Promise<PullRequestDetails | null> {
|
||||
try {
|
||||
// 获取仓库中所有开放的PR
|
||||
const response = await giteaClient.get(`/repos/${owner}/${repo}/pulls?state=open`);
|
||||
@@ -197,7 +224,9 @@ export const giteaService: GiteaService = {
|
||||
const prDetails = await giteaService.getPullRequestDetails(owner, repo, pr.number);
|
||||
|
||||
// 检查PR的提交列表
|
||||
const commitsResponse = await giteaClient.get(`/repos/${owner}/${repo}/pulls/${pr.number}/commits`);
|
||||
const commitsResponse = await giteaClient.get(
|
||||
`/repos/${owner}/${repo}/pulls/${pr.number}/commits`
|
||||
);
|
||||
const commits = commitsResponse.data || [];
|
||||
|
||||
// 检查提交是否在PR中
|
||||
@@ -238,7 +267,12 @@ export const giteaService: GiteaService = {
|
||||
},
|
||||
|
||||
// 获取引用的相关文件
|
||||
async getRelatedFiles(owner: string, repo: string, files: PullRequestFile[], commitSha: string): Promise<Record<string, string>> {
|
||||
async getRelatedFiles(
|
||||
owner: string,
|
||||
repo: string,
|
||||
files: PullRequestFile[],
|
||||
commitSha: string
|
||||
): Promise<Record<string, string>> {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
// 对每个修改过的文件,获取其完整内容
|
||||
@@ -260,7 +294,12 @@ export const giteaService: GiteaService = {
|
||||
},
|
||||
|
||||
// 添加PR评论
|
||||
async addPullRequestComment(owner: string, repo: string, prNumber: number, body: string): Promise<void> {
|
||||
async addPullRequestComment(
|
||||
owner: string,
|
||||
repo: string,
|
||||
prNumber: number,
|
||||
body: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await giteaClient.post(`/repos/${owner}/${repo}/issues/${prNumber}/comments`, { body });
|
||||
} catch (error: any) {
|
||||
@@ -287,7 +326,7 @@ export const giteaService: GiteaService = {
|
||||
await giteaClient.post(`/repos/${owner}/${repo}/pulls/${prNumber}/reviews`, {
|
||||
event: 'COMMENT',
|
||||
commit_id: commitId,
|
||||
comments: comments.map(comment => ({
|
||||
comments: comments.map((comment) => ({
|
||||
path: comment.path,
|
||||
body: comment.comment,
|
||||
new_position: comment.line,
|
||||
@@ -307,7 +346,7 @@ export const giteaService: GiteaService = {
|
||||
commit_id: commitId,
|
||||
path: comment.path,
|
||||
line: comment.line,
|
||||
position: comment.line, // Gitea使用position参数表示行号
|
||||
position: comment.line, // Gitea使用position参数表示行号
|
||||
});
|
||||
}
|
||||
logger.info(`成功逐条添加 ${comments.length} 条评论`);
|
||||
@@ -319,7 +358,12 @@ export const giteaService: GiteaService = {
|
||||
},
|
||||
|
||||
// 添加提交评论
|
||||
async addCommitComment(owner: string, repo: string, commitSha: string, body: string): Promise<void> {
|
||||
async addCommitComment(
|
||||
owner: string,
|
||||
repo: string,
|
||||
commitSha: string,
|
||||
body: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await giteaClient.post(`/repos/${owner}/${repo}/git/commits/${commitSha}/comments`, { body });
|
||||
} catch (error: any) {
|
||||
@@ -329,7 +373,11 @@ export const giteaService: GiteaService = {
|
||||
},
|
||||
|
||||
// 获取所有仓库
|
||||
async listAllRepositories(page: number = 1, limit: number = 30, query?: string): Promise<{ repos: any[], totalCount: number }> {
|
||||
async listAllRepositories(
|
||||
page = 1,
|
||||
limit = 30,
|
||||
query?: string
|
||||
): Promise<{ repos: any[]; totalCount: number }> {
|
||||
try {
|
||||
const response = await giteaAdminClient.get('/repos/search', {
|
||||
params: {
|
||||
@@ -338,7 +386,7 @@ export const giteaService: GiteaService = {
|
||||
q: query,
|
||||
},
|
||||
});
|
||||
const totalCount = parseInt(response.headers['x-total-count'] || '0', 10);
|
||||
const totalCount = Number.parseInt(response.headers['x-total-count'] || '0', 10);
|
||||
return { repos: response.data.data, totalCount };
|
||||
} catch (error: any) {
|
||||
logger.error('获取所有仓库列表失败:', error);
|
||||
|
||||
@@ -28,7 +28,7 @@ function formatMessage(level: LogLevel, message: string, meta?: any): string {
|
||||
if (meta) {
|
||||
try {
|
||||
formattedMessage += ` - ${JSON.stringify(meta)}`;
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
formattedMessage += ` - ${meta}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"action": "opened",
|
||||
"pull_request": {
|
||||
"id": 1,
|
||||
"number": 1,
|
||||
"title": "测试PR",
|
||||
"head": {
|
||||
"sha": "abcd1234abcd1234abcd1234abcd1234abcd1234"
|
||||
},
|
||||
"base": {
|
||||
"repo": {
|
||||
"owner": {
|
||||
"login": "test-owner"
|
||||
},
|
||||
"name": "test-repo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"owner": {
|
||||
"login": "test-owner"
|
||||
},
|
||||
"name": "test-repo"
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,8 @@
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
"dist",
|
||||
"src/**/__tests__/**",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "tslint:latest"
|
||||
}
|
||||
10
typings/bun-test.d.ts
vendored
Normal file
10
typings/bun-test.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
declare module 'bun:test' {
|
||||
export const describe: any;
|
||||
export const test: any;
|
||||
export const it: any;
|
||||
export const expect: any;
|
||||
export const beforeEach: any;
|
||||
export const afterEach: any;
|
||||
export const beforeAll: any;
|
||||
export const afterAll: any;
|
||||
}
|
||||
Reference in New Issue
Block a user