mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-06-12 23:16:49 +00:00
Compare commits
51 Commits
v1.1.0
...
opencode/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fac1f6942 | ||
|
|
45fcf2eaa1 | ||
|
|
d48eee3474 | ||
|
|
c0de9238b5 | ||
|
|
aa8d4ab072 | ||
|
|
1831704644 | ||
|
|
f0e45a5ae5 | ||
|
|
0ad83a4082 | ||
|
|
eeb209dbaf | ||
|
|
e1d8c1b7d2 | ||
|
|
6d62b9f87c | ||
|
|
bcc9e7b8eb | ||
|
|
12e1f4717b | ||
|
|
6ca9edecfd | ||
|
|
c4cbced8af | ||
|
|
e0ab3019db | ||
|
|
cd2bdf4131 | ||
|
|
b304814e42 | ||
|
|
1ff629cffb | ||
|
|
8ccc7452e5 | ||
|
|
b2b914f919 | ||
|
|
7b9b9e69a7 | ||
|
|
46c5e09a62 | ||
|
|
1a43b1f206 | ||
|
|
1b26fac951 | ||
|
|
38e4c58d71 | ||
|
|
5b29e2d4af | ||
|
|
ac40957ede | ||
|
|
8d6d167b33 | ||
|
|
1e7c80ca9f | ||
|
|
b92765ce7f | ||
|
|
daae32ce07 | ||
|
|
ab984ff415 | ||
|
|
d49a16db6e | ||
|
|
3a97d673f6 | ||
|
|
b6e6ee0927 | ||
|
|
22b603258a | ||
|
|
1885004874 | ||
|
|
d5deb75231 | ||
|
|
c313764b61 | ||
|
|
63f419228e | ||
|
|
f84c0ab777 | ||
|
|
7792a78c00 | ||
|
|
7aec1e452a | ||
|
|
8f9910a3fd | ||
|
|
2392808b82 | ||
|
|
9567501369 | ||
|
|
9964614b5e | ||
|
|
e40daddf0d | ||
|
|
b10b8dd7d5 | ||
|
|
5aeff7585b |
14
.env.example
14
.env.example
@@ -1,8 +1,14 @@
|
||||
# 应用配置
|
||||
PORT=3000
|
||||
# DATABASE_PATH=./data/assistant.db # 可选,默认为 ./data/assistant.db
|
||||
ENCRYPTION_KEY= # 必填,运行 openssl rand -hex 32 生成
|
||||
PORT=5174
|
||||
# 可选,默认为 ./data/assistant.db
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
# 可选,默认 info,可选值:debug/info/warn/error
|
||||
# 开发环境建议:LOG_LEVEL=info
|
||||
# 生产环境建议:LOG_LEVEL=error
|
||||
# LOG_LEVEL=info
|
||||
# 必填,运行 openssl rand -hex 32 生成
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# 所有其他配置(Gitea连接、飞书通知、Webhook密钥、管理员密码、审查引擎、记忆系统等)
|
||||
# 均通过 Web 管理后台进行配置。
|
||||
# 启动服务后访问 http://localhost:3000 进行配置。
|
||||
# 启动服务后访问 http://localhost:5174 进行配置。
|
||||
|
||||
55
.github/workflows/ci.yml
vendored
55
.github/workflows/ci.yml
vendored
@@ -52,3 +52,58 @@ jobs:
|
||||
path: |
|
||||
frontend/playwright-report/
|
||||
frontend/test-results/
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: test
|
||||
|
||||
services:
|
||||
gitea:
|
||||
image: gitea/gitea:1.22
|
||||
ports: ['3333:3000']
|
||||
env:
|
||||
GITEA__database__DB_TYPE: sqlite3
|
||||
GITEA__server__ROOT_URL: http://localhost:3333
|
||||
GITEA__security__INSTALL_LOCK: true
|
||||
GITEA__webhook__ALLOWED_HOST_LIST: '*'
|
||||
GITEA__webhook__SKIP_TLS_VERIFY: true
|
||||
options: >-
|
||||
--health-cmd "curl -f http://localhost:3000/api/v1/version"
|
||||
--health-interval 5s
|
||||
--health-timeout 3s
|
||||
--health-retries 20
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Install git
|
||||
run: sudo apt-get update && sudo apt-get install -y git
|
||||
|
||||
- name: Create Gitea admin user
|
||||
run: |
|
||||
for i in $(seq 1 10); do
|
||||
if docker exec $(docker ps -q --filter "ancestor=gitea/gitea:1.22") \
|
||||
gitea admin user create --username e2e-admin --password 'e2ePassword123!' --email 'e2e@test.local' --admin 2>/dev/null; then
|
||||
echo "User created"
|
||||
break
|
||||
fi
|
||||
echo "Retrying... ($i)"
|
||||
sleep 3
|
||||
done || true
|
||||
docker exec -u git $(docker ps -q --filter "ancestor=gitea/gitea:1.22") \
|
||||
gitea admin user create --username e2e-admin --password 'e2ePassword123!' --email 'e2e@test.local' --admin 2>/dev/null || true
|
||||
|
||||
- name: Run E2E tests
|
||||
run: bun run test:e2e
|
||||
env:
|
||||
E2E_GITEA_URL: http://localhost:3333
|
||||
E2E_MOCK_LLM: 1
|
||||
|
||||
49
.github/workflows/release.yml
vendored
49
.github/workflows/release.yml
vendored
@@ -40,35 +40,66 @@ jobs:
|
||||
run: bun test
|
||||
|
||||
- name: Run semantic-release
|
||||
run: bunx semantic-release
|
||||
id: semantic
|
||||
uses: codfish/semantic-release-action@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
HUSKY: 0
|
||||
HUSKY_SKIP_HOOKS: 1
|
||||
|
||||
# Docker build and push
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: steps.semantic.outputs.new-release-published == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from package.json
|
||||
id: package-version
|
||||
- name: Derive Docker tags from semantic-release
|
||||
if: steps.semantic.outputs.new-release-published == 'true'
|
||||
id: docker-tags
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Detected version: $VERSION"
|
||||
VERSION="${{ steps.semantic.outputs.release-version }}"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "semantic-release did not provide release-version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$VERSION" == *"-"* ]]; then
|
||||
TAGS="ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${VERSION}"
|
||||
else
|
||||
MAJOR="${VERSION%%.*}"
|
||||
REST="${VERSION#*.}"
|
||||
MINOR="${REST%%.*}"
|
||||
|
||||
TAGS=$(printf '%s\n' \
|
||||
"ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:latest" \
|
||||
"ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${MAJOR}" \
|
||||
"ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${MAJOR}.${MINOR}" \
|
||||
"ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${VERSION}")
|
||||
fi
|
||||
|
||||
{
|
||||
echo "tags<<EOF"
|
||||
echo "$TAGS"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Release version: ${VERSION}"
|
||||
echo "Docker tags to push:"
|
||||
echo "$TAGS"
|
||||
|
||||
- name: Build and push Docker image
|
||||
if: steps.semantic.outputs.new-release-published == 'true'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:latest
|
||||
ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${{ steps.package-version.outputs.version }}
|
||||
tags: ${{ steps.docker-tags.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
43
CHANGELOG.md
43
CHANGELOG.md
@@ -1,3 +1,46 @@
|
||||
## [1.3.1](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.3.0...v1.3.1) (2026-03-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **db:** self-heal missing repository prompt schema ([b6e6ee0](https://github.com/jeffusion/gitea-ai-assistant/commit/b6e6ee0927eb757b86ee426bf8eed84ae633621a))
|
||||
* **logs:** gate repository list debug logs behind REPO_LIST_DEBUG_LOGS env flag ([3a97d67](https://github.com/jeffusion/gitea-ai-assistant/commit/3a97d673f671752a2e7f676fb0074b413c2e40cc))
|
||||
* **repo:** add structured diagnostics for repository list failures ([22b6032](https://github.com/jeffusion/gitea-ai-assistant/commit/22b603258ac32e70653aa1a05032a91e8ad23f89))
|
||||
|
||||
# [1.3.0](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.2.1...v1.3.0) (2026-03-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **repo:** add project-level review prompt with UI redesign ([d5deb75](https://github.com/jeffusion/gitea-ai-assistant/commit/d5deb752317508aa47470a20fec4d11a5d2b66b7))
|
||||
|
||||
## [1.2.1](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.2.0...v1.2.1) (2026-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** source Docker tags from semantic-release version ([f84c0ab](https://github.com/jeffusion/gitea-ai-assistant/commit/f84c0ab7770b73032b331c82cf8f87f1e8b281ff))
|
||||
|
||||
# [1.2.0](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.1.1...v1.2.0) (2026-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **lint:** apply biome cleanup for notification modules ([7aec1e4](https://github.com/jeffusion/gitea-ai-assistant/commit/7aec1e452a04d3dbf935837e9b8e96107466c487))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** add dedicated notification management menu and test panel ([9964614](https://github.com/jeffusion/gitea-ai-assistant/commit/9964614b5ebb7972e2b35f3fc673f626372f6552))
|
||||
* **notification:** replace feishu-only flow with pluggable providers ([e40dadd](https://github.com/jeffusion/gitea-ai-assistant/commit/e40daddf0dd168c19251cdb84a3b6b136814f553))
|
||||
|
||||
## [1.1.1](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.1.0...v1.1.1) (2026-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **build:** guard husky prepare for production installs ([5aeff75](https://github.com/jeffusion/gitea-ai-assistant/commit/5aeff7585b465fa9479c538b67b99978d12455b1))
|
||||
|
||||
# [1.1.0](https://github.com/Jeffusion/gitea-ai-assistant/compare/v1.0.0...v1.1.0) (2026-03-24)
|
||||
|
||||
|
||||
|
||||
@@ -57,6 +57,6 @@ COPY --from=frontend-builder /app/frontend/dist ./public
|
||||
# Codex CLI binary (statically linked musl build)
|
||||
COPY --from=codex-downloader /usr/local/bin/codex /usr/local/bin/codex
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 5174
|
||||
|
||||
CMD ["bun", "run", "start"]
|
||||
|
||||
261
README.md
261
README.md
@@ -2,50 +2,48 @@
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
AI-powered code review assistant for Gitea. Automatically reviews Pull Requests and commits using pluggable LLM providers (OpenAI Compatible, OpenAI Responses API, Anthropic, Google Gemini), providing intelligent code quality analysis with both summary comments and line-level feedback.
|
||||
AI-powered code review assistant for Gitea. It receives webhooks, runs staged AI review workflows, and posts summary + line-level feedback back to Gitea.
|
||||
|
||||
**[中文文档](./docs/README.zh-CN.md)**
|
||||
- English docs: [./docs/README.md](./docs/README.md)
|
||||
- 中文文档: [./docs/README.zh-CN.md](./docs/README.zh-CN.md)
|
||||
|
||||
## Features
|
||||
## Why this project
|
||||
|
||||
- 🤖 **AI Code Review** - Automatic review of PRs and commits using pluggable LLM providers
|
||||
- 📝 **Line-Level Comments** - Precise feedback on specific code changes
|
||||
- 🔄 **Task-Based Review Engines** - Agent staged review (skip/light/full) plus optional Codex CLI execution mode
|
||||
- 🔔 **Feishu Notifications** - Integrated notification system for PR events
|
||||
- 🎛️ **Admin Dashboard** - Web UI for managing repository webhooks and LLM provider configuration
|
||||
- 🔐 **Secure Webhooks** - HMAC-SHA256 signature verification
|
||||
- 🤖 **Automated PR + commit review** via webhook events (`pull_request`, `status`)
|
||||
- 🧠 **Two review engines**: `agent` (staged tasks) and `codex` (Codex CLI pipeline)
|
||||
- 🧵 **Pluggable LLM providers**: OpenAI Compatible, OpenAI Responses API, Anthropic, Gemini
|
||||
- 📍 **Actionable output**: summary comments and line-level findings
|
||||
- 🎛️ **Web Admin UI** for runtime configuration (providers, models, webhook, review policy)
|
||||
- 🔔 **Notifications**: Feishu + WeCom (企业微信)
|
||||
- 🔐 **Security-first defaults**: webhook signature verification + encrypted API key storage
|
||||
|
||||
## Architecture
|
||||
## Product screenshot
|
||||
|
||||
> Dashboard screenshot is generated from local dev service.
|
||||
|
||||

|
||||
|
||||
More screenshots (one per admin menu): [EN](./docs/screenshots.md) | [中文](./docs/screenshots.zh-CN.md)
|
||||
|
||||
## Architecture (high-level)
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Gitea Server │────▶│ Gitea Assistant │────▶│ LLM Gateway │
|
||||
│ (Webhooks) │ │ (Hono + Bun) │ │ (Multi-Provider)│
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
▼ ├─ OpenAI Compatible
|
||||
┌──────────────────┐ ├─ OpenAI Responses API
|
||||
│ Admin Dashboard │ ├─ Anthropic
|
||||
│ (React SPA) │ └─ Google Gemini
|
||||
└──────────────────┘
|
||||
Gitea Webhook -> Gitea AI Assistant (Hono + Bun) -> LLM Gateway (multi-provider)
|
||||
|
|
||||
+-> Admin Dashboard (React)
|
||||
```
|
||||
|
||||
### Review Engines
|
||||
For component-level design, see [Architecture docs](./docs/README.md#architecture--design).
|
||||
|
||||
| Engine | Description | Use Case |
|
||||
|--------|-------------|----------|
|
||||
| `agent` | Task-based staged review (`skip` / `light` / `full`) with scoped specialist routing and optional reflection/debate escalation | Deep reviews with token-aware execution |
|
||||
| `codex` | Codex CLI review execution with independent configuration | External Codex-driven review pipeline |
|
||||
## Quick start (minimal)
|
||||
|
||||
## Quick Start
|
||||
### 1) Prerequisites
|
||||
|
||||
### Prerequisites
|
||||
- Bun >= 1.2.5
|
||||
- Reachable Gitea instance
|
||||
- At least one LLM provider credential
|
||||
|
||||
- [Bun](https://bun.sh/) >= 1.2.5
|
||||
- Gitea instance with API access
|
||||
- At least one LLM provider API key (OpenAI, Anthropic, Google Gemini, or any OpenAI-compatible endpoint)
|
||||
|
||||
### Installation
|
||||
### 2) Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/user/gitea-ai-assistant.git
|
||||
@@ -53,193 +51,54 @@ cd gitea-ai-assistant
|
||||
bun install
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Create a `.env` file with only infrastructure-level settings:
|
||||
If lifecycle scripts are disabled in your environment, run:
|
||||
|
||||
```bash
|
||||
# Server port
|
||||
PORT=3000
|
||||
bun run bootstrap
|
||||
```
|
||||
|
||||
# REQUIRED: encryption key for API key storage (generate with: openssl rand -hex 32)
|
||||
ENCRYPTION_KEY=
|
||||
### 3) Minimal `.env`
|
||||
|
||||
# Optional: custom database path (default shown)
|
||||
```bash
|
||||
PORT=5174
|
||||
ENCRYPTION_KEY= # required, 64 hex chars (openssl rand -hex 32)
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
# LOG_LEVEL=info # dev default; use LOG_LEVEL=error in production
|
||||
```
|
||||
|
||||
> **All other configuration** (Gitea connection, webhook secret, admin password, review engine, Feishu, memory settings, etc.) is managed through the **Admin Dashboard Web UI** at `http://your-server:3000`. On first boot, all settings are seeded with secure defaults automatically.
|
||||
> `ENCRYPTION_KEY` is mandatory. The app refuses to start without it.
|
||||
|
||||
See [Configuration Reference](#configuration-reference) for all options.
|
||||
|
||||
### Running
|
||||
### 4) Run
|
||||
|
||||
```bash
|
||||
bun run dev # Development mode
|
||||
bun run start # Production mode
|
||||
bun run dev
|
||||
# or
|
||||
bun run start
|
||||
```
|
||||
|
||||
### Setting Up Webhooks
|
||||
### 5) Configure in Admin UI
|
||||
|
||||
**Option 1: Admin Dashboard (Recommended)**
|
||||
Open `http://your-server:5174`, login with default `password` (first boot only), then change it immediately.
|
||||
|
||||
1. Access `http://your-server:3000`
|
||||
2. Log in with the admin password (default: `password` — change it in the dashboard)
|
||||
3. Click "Enable" on repositories to auto-configure webhooks
|
||||
- Configure Gitea API + tokens
|
||||
- Configure webhook secret
|
||||
- Configure LLM providers/models
|
||||
- Configure review engine and policy
|
||||
|
||||
**Option 2: Manual Configuration**
|
||||
### 6) Add webhook in Gitea
|
||||
|
||||
In Gitea repository settings, add a webhook:
|
||||
- **URL**: `http://your-server:3000/webhook/gitea`
|
||||
- **Content Type**: `application/json`
|
||||
- **Secret**: Same as the Webhook Secret configured in the dashboard
|
||||
- **Events**: "Pull Request" and "Status"
|
||||
## Configuration Reference
|
||||
- URL: `http://your-server:5174/webhook/gitea`
|
||||
- Content-Type: `application/json`
|
||||
- Secret: same as dashboard webhook secret
|
||||
- Events: Pull Request + Status
|
||||
|
||||
### Environment Variables (Minimal)
|
||||
## Progressive disclosure: detailed docs
|
||||
|
||||
Only infrastructure-level settings that must be known before the database is initialized:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PORT` | Server port | `5174` |
|
||||
| `DATABASE_PATH` | SQLite database file path | `./data/assistant.db` |
|
||||
| `ENCRYPTION_KEY` | **Required.** AES-256-GCM encryption key for API key storage (64 hex chars). Generate with `openssl rand -hex 32` | — |
|
||||
|
||||
### Web UI Configuration (Admin Dashboard)
|
||||
|
||||
All runtime configuration is managed through the **Admin Dashboard** at `http://your-server:PORT`. Changes take effect immediately without restart.
|
||||
|
||||
On first boot with an empty database, all settings are seeded with secure defaults:
|
||||
- `JWT_SECRET` and `WEBHOOK_SECRET` are auto-generated (64-char hex via `crypto.randomBytes`)
|
||||
- `ADMIN_PASSWORD` defaults to `password` — **change this immediately**
|
||||
|
||||
#### Gitea
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| Gitea API URL | Gitea API endpoint (e.g. `https://gitea.example.com/api/v1`) |
|
||||
| Access Token | Token for code review (read + comment permissions) |
|
||||
| Admin Token | Token for webhook management (optional) |
|
||||
|
||||
#### Security
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| Webhook Secret | HMAC-SHA256 webhook signature secret | Auto-generated |
|
||||
| Admin Password | Dashboard login password | `password` |
|
||||
| JWT Secret | JWT signing secret | Auto-generated |
|
||||
|
||||
#### LLM Provider Configuration
|
||||
|
||||
LLM providers and models are configured exclusively through the **Admin Dashboard** Web UI:
|
||||
|
||||
1. Navigate to **LLM 配置** (LLM Configuration)
|
||||
2. Add your LLM providers (OpenAI Compatible, OpenAI Responses API, Anthropic, Google Gemini)
|
||||
3. Assign models to review roles (planner, specialist, judge, embedding)
|
||||
|
||||
> API keys are stored encrypted (AES-256-GCM) in the local SQLite database.
|
||||
|
||||
#### Feishu Integration
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| Feishu Webhook URL | Feishu bot webhook URL |
|
||||
| Feishu Webhook Secret | Feishu webhook secret (optional) |
|
||||
|
||||
#### Agent Review Engine
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| Review Engine | Engine mode (`agent` or `codex`) | `agent` |
|
||||
| Enable Triage | Enable planner triage for task routing | `true` |
|
||||
| Small Max Files | Upper file-count bound for `small` review size | `3` |
|
||||
| Small Max Changed Lines | Upper changed-lines bound for `small` review size | `80` |
|
||||
| Medium Max Files | Upper file-count bound for `medium` review size | `10` |
|
||||
| Medium Max Changed Lines | Upper changed-lines bound for `medium` review size | `400` |
|
||||
| Token Budget Small | Token budget cap for `small` staged tasks | `12000` |
|
||||
| Token Budget Medium | Token budget cap for `medium` staged tasks | `45000` |
|
||||
| Token Budget Large | Token budget cap for `large` staged tasks | `120000` |
|
||||
| Review Work Directory | Working directory for repo clones | `/tmp/gitea-assistant` |
|
||||
| Max Parallel Runs | Max concurrent review tasks | `2` |
|
||||
| Max Files per Run | Max files analyzed per review | `200` |
|
||||
| Auto-publish Min Confidence | Min confidence score for auto-publish | `0.8` |
|
||||
| Enable Human Gate | Require human approval before publishing | `true` |
|
||||
|
||||
Agent review execution model (current):
|
||||
|
||||
- `skip`: docs/assets/rename-only style changes can bypass specialist review.
|
||||
- `light`: low-risk code changes run minimal scoped specialist checks.
|
||||
- `full`: sensitive or larger changes run full specialist tasks, with optional reflection/debate escalation.
|
||||
- Triage outputs task scopes (`paths`, `riskTags`, `mode`, `tokenBudget`) and orchestrator dispatches specialists by task scope instead of broad fan-out.
|
||||
|
||||
#### Memory & Learning (Experimental)
|
||||
|
||||
| Setting | 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 -v ./data:/app/data -e PORT=3000 gitea-assistant
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Kubernetes
|
||||
|
||||
Kubernetes manifests are located in the `k8s/` directory.
|
||||
|
||||
**1. Create the encryption secret**
|
||||
|
||||
```bash
|
||||
# Generate a key and create the secret
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
kubectl -n gitea-assistant create secret generic gitea-assistant-secret \
|
||||
--from-literal=ENCRYPTION_KEY=$ENCRYPTION_KEY
|
||||
# Save this key! You'll need it if you ever redeploy.
|
||||
echo "Your ENCRYPTION_KEY: $ENCRYPTION_KEY"
|
||||
```
|
||||
|
||||
**2. Deploy**
|
||||
|
||||
```bash
|
||||
kubectl apply -k k8s/
|
||||
```
|
||||
|
||||
Or apply individually:
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
kubectl apply -f k8s/qdrant.yaml
|
||||
kubectl apply -f k8s/gitea-assistant.yaml
|
||||
```
|
||||
|
||||
**4. Verify**
|
||||
|
||||
```bash
|
||||
kubectl -n gitea-assistant get pods
|
||||
kubectl -n gitea-assistant get svc
|
||||
```
|
||||
|
||||
**5. Expose the Service (optional)**
|
||||
|
||||
By default, services use `ClusterIP`. To expose externally, use an Ingress or change the Service type:
|
||||
|
||||
```bash
|
||||
kubectl -n gitea-assistant patch svc gitea-assistant -p '{"spec":{"type":"NodePort"}}'
|
||||
```
|
||||
- [Documentation index](./docs/README.md)
|
||||
- [Getting started details](./docs/getting-started.md)
|
||||
- [Configuration reference](./docs/configuration.md)
|
||||
- [Review engines](./docs/review-engines.md)
|
||||
- [Deployment (Docker / Compose / Kubernetes)](./docs/deployment.md)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
27
bun.lock
27
bun.lock
@@ -14,6 +14,7 @@
|
||||
"hono": "^4.11.9",
|
||||
"lodash-es": "^4.17.21",
|
||||
"openai": "^4.87.3",
|
||||
"pino": "^10.3.1",
|
||||
"tokenlens": "^1.3.1",
|
||||
"zod": "^3.25.1",
|
||||
"zod-to-json-schema": "^3.25.1",
|
||||
@@ -90,6 +91,8 @@
|
||||
|
||||
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||
|
||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
"@pnpm/config.env-replace": ["@pnpm/config.env-replace@1.1.0", "", {}, "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w=="],
|
||||
@@ -190,6 +193,8 @@
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||
|
||||
"axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
@@ -532,6 +537,8 @@
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
|
||||
|
||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"openai": ["openai@4.104.0", "", { "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-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="],
|
||||
@@ -584,18 +591,28 @@
|
||||
|
||||
"pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="],
|
||||
|
||||
"pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="],
|
||||
|
||||
"pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
|
||||
|
||||
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
|
||||
|
||||
"pkg-conf": ["pkg-conf@2.1.0", "", { "dependencies": { "find-up": "^2.0.0", "load-json-file": "^4.0.0" } }, "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g=="],
|
||||
|
||||
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
||||
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
||||
|
||||
"proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||
|
||||
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||
|
||||
"read-package-up": ["read-package-up@11.0.0", "", { "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", "type-fest": "^4.6.0" } }, "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ=="],
|
||||
@@ -604,6 +621,8 @@
|
||||
|
||||
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
||||
|
||||
"registry-auth-token": ["registry-auth-token@5.1.1", "", { "dependencies": { "@pnpm/npm-conf": "^3.0.2" } }, "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
@@ -618,6 +637,8 @@
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||
|
||||
"semantic-release": ["semantic-release@24.2.9", "", { "dependencies": { "@semantic-release/commit-analyzer": "^13.0.0-beta.1", "@semantic-release/error": "^4.0.0", "@semantic-release/github": "^11.0.0", "@semantic-release/npm": "^12.0.2", "@semantic-release/release-notes-generator": "^14.0.0-beta.1", "aggregate-error": "^5.0.0", "cosmiconfig": "^9.0.0", "debug": "^4.0.0", "env-ci": "^11.0.0", "execa": "^9.0.0", "figures": "^6.0.0", "find-versions": "^6.0.0", "get-stream": "^6.0.0", "git-log-parser": "^1.2.0", "hook-std": "^4.0.0", "hosted-git-info": "^8.0.0", "import-from-esm": "^2.0.0", "lodash-es": "^4.17.21", "marked": "^15.0.0", "marked-terminal": "^7.3.0", "micromatch": "^4.0.2", "p-each-series": "^3.0.0", "p-reduce": "^3.0.0", "read-package-up": "^11.0.0", "resolve-from": "^5.0.0", "semver": "^7.3.2", "semver-diff": "^5.0.0", "signale": "^1.2.1", "yargs": "^17.5.1" }, "bin": { "semantic-release": "bin/semantic-release.js" } }, "sha512-phCkJ6pjDi9ANdhuF5ElS10GGdAKY6R1Pvt9lT3SFhOwM4T7QZE7MLpBDbNruUx/Q3gFD92/UOFringGipRqZA=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
@@ -638,6 +659,8 @@
|
||||
|
||||
"skin-tone": ["skin-tone@2.0.0", "", { "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" } }, "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA=="],
|
||||
|
||||
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"spawn-error-forwarder": ["spawn-error-forwarder@1.0.0", "", {}, "sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g=="],
|
||||
@@ -684,6 +707,8 @@
|
||||
|
||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="],
|
||||
|
||||
"through2": ["through2@2.0.5", "", { "dependencies": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" } }, "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ=="],
|
||||
|
||||
"time-span": ["time-span@5.1.0", "", { "dependencies": { "convert-hrtime": "^5.0.0" } }, "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA=="],
|
||||
@@ -1164,6 +1189,8 @@
|
||||
|
||||
"parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="],
|
||||
|
||||
"pino-abstract-transport/split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"read-pkg/parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="],
|
||||
|
||||
"readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
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
|
||||
# docker compose -f docker-compose.e2e.yml up -d
|
||||
# ./e2e/seed.sh
|
||||
# docker compose -f docker-compose.e2e.yml down -v
|
||||
|
||||
services:
|
||||
gitea:
|
||||
@@ -46,19 +40,18 @@ services:
|
||||
- NODE_ENV=production
|
||||
- GITEA_API_URL=http://gitea:3000/api/v1
|
||||
- GITEA_ACCESS_TOKEN=${E2E_GITEA_TOKEN:-placeholder}
|
||||
- PORT=3000
|
||||
- PORT=5174
|
||||
- ENCRYPTION_KEY=5752fac0e57d00e9b7954863faef878693420e6b06bc20d710897587e802668a
|
||||
- REVIEW_ENGINE=kernel
|
||||
- REVIEW_WORKDIR=/tmp/e2e-review
|
||||
- DATABASE_PATH=/data/assistant.db
|
||||
- E2E_MOCK_LLM=1
|
||||
ports:
|
||||
- "3334:3000"
|
||||
- "3334:5174"
|
||||
volumes:
|
||||
- assistant-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 5s
|
||||
ports:
|
||||
- "3334:3000"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5174/api/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
@@ -66,3 +59,4 @@ services:
|
||||
|
||||
volumes:
|
||||
gitea-data:
|
||||
assistant-data:
|
||||
|
||||
@@ -8,18 +8,20 @@ services:
|
||||
|
||||
container_name: gitea-assistant
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "5174:5174"
|
||||
volumes:
|
||||
- assistant_data:/app/data
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
LOG_LEVEL: error
|
||||
depends_on:
|
||||
qdrant:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5174/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
22
docs/README.md
Normal file
22
docs/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Documentation
|
||||
|
||||
This project keeps the root `README.md` concise and moves implementation/deployment details here.
|
||||
|
||||
## Start here
|
||||
|
||||
- [Getting started](./getting-started.md)
|
||||
- [Configuration reference](./configuration.md)
|
||||
- [Review engines](./review-engines.md)
|
||||
- [Deployment](./deployment.md)
|
||||
- [Screenshot gallery](./screenshots.md)
|
||||
|
||||
## Architecture & design
|
||||
|
||||
- [Pluggable LLM providers](./design/pluggable-llm-providers.md)
|
||||
- [Kernel built-in Agent architecture](./design/kernel-built-in-agents.md)
|
||||
- [Notification service refactoring](./design/notification-service-refactoring.md)
|
||||
- [UI theme language](./design/ui-theme-language.md)
|
||||
|
||||
## Language
|
||||
|
||||
- 中文: [README.zh-CN.md](./README.zh-CN.md)
|
||||
@@ -1,246 +1,26 @@
|
||||
# Gitea AI Assistant
|
||||
# 文档中心
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
根目录 `README.md` 保持简洁;本目录提供详细说明与设计文档。
|
||||
|
||||
基于 AI 的 Gitea 代码审查助手。自动审查 Pull Request 和提交,支持多种 LLM 提供商(OpenAI 兼容、OpenAI Responses API、Anthropic、Google Gemini),提供智能代码质量分析,支持总体评论和行级反馈。
|
||||
## 快速导航
|
||||
|
||||
**[English Documentation](../README.md)**
|
||||
- [快速开始](./getting-started.zh-CN.md)
|
||||
- [配置参考](./configuration.zh-CN.md)
|
||||
- [审查引擎](./review-engines.zh-CN.md)
|
||||
- [部署指南](./deployment.zh-CN.md)
|
||||
- [截图集](./screenshots.zh-CN.md)
|
||||
|
||||
## 功能特点
|
||||
## 架构与设计
|
||||
|
||||
- 🤖 **AI 代码审查** - 使用可插拔的 LLM 提供商自动审查 PR 和提交
|
||||
- 📝 **行级评论** - 针对具体代码变更的精确反馈
|
||||
- 🔄 **任务化审查引擎** - Agent 分级审查(skip/light/full)+ 可选 Codex CLI 审查模式
|
||||
- 🔔 **飞书通知** - PR 事件通知集成
|
||||
- 🎛️ **管理后台** - 用于管理仓库 Webhook 和 LLM 提供商配置的 Web 界面
|
||||
- 🔐 **安全验证** - HMAC-SHA256 签名验证
|
||||
- [可插拔 LLM 提供商设计](./design/pluggable-llm-providers.md)
|
||||
- [Kernel 内置 Agent 架构设计](./design/kernel-built-in-agents.md)
|
||||
- [通知服务重构设计](./design/notification-service-refactoring.md)
|
||||
- [UI 主题语言设计](./design/ui-theme-language.md)
|
||||
|
||||
## 架构设计
|
||||
## 产品截图
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Gitea 服务器 │────▶│ Gitea Assistant │────▶│ LLM 网关 │
|
||||
│ (Webhooks) │ │ (Hono + Bun) │ │ (多提供商) │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
▼ ├─ OpenAI 兼容
|
||||
┌──────────────────┐ ├─ OpenAI Responses API
|
||||
│ 管理后台 │ ├─ Anthropic
|
||||
│ (React SPA) │ └─ Google Gemini
|
||||
└──────────────────┘
|
||||
```
|
||||

|
||||
|
||||
### 审查引擎对比
|
||||
## 语言切换
|
||||
|
||||
| 引擎 | 描述 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| `agent` | 任务化分级审查(`skip` / `light` / `full`),按路径范围派发 specialist,并按需升级到反思/辩论 | 在控制 token 成本的前提下做深度审查 |
|
||||
| `codex` | 通过 Codex CLI 执行审查,独立配置 | 对接外部 Codex 审查流程 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- [Bun](https://bun.sh/) >= 1.2.5
|
||||
- 可访问的 Gitea 实例
|
||||
- 至少一个 LLM 提供商的 API 密钥(OpenAI、Anthropic、Google Gemini 或任何 OpenAI 兼容端点)
|
||||
|
||||
### 安装步骤
|
||||
|
||||
```bash
|
||||
git clone https://github.com/user/gitea-ai-assistant.git
|
||||
cd gitea-ai-assistant
|
||||
bun install
|
||||
```
|
||||
|
||||
### 配置说明
|
||||
|
||||
创建 `.env` 文件,仅填写基础设施级别的配置:
|
||||
|
||||
```bash
|
||||
# 服务端口
|
||||
PORT=3000
|
||||
|
||||
# 必填:API Key 加密存储密钥(运行 openssl rand -hex 32 生成)
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# 可选:自定义数据库路径(以下为默认值)
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
```
|
||||
|
||||
> **所有其他配置**(Gitea 连接、Webhook 密钥、管理员密码、审查引擎、飞书、记忆系统等)均通过 **Web 管理后台** 在 `http://your-server:3000` 进行配置。首次启动时,所有设置会自动以安全的默认值进行初始化。
|
||||
|
||||
完整配置项请参阅 [配置参考](#配置参考)。
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
bun run dev # 开发模式
|
||||
bun run start # 生产模式
|
||||
```
|
||||
|
||||
### 配置 Webhook
|
||||
|
||||
**方式一:管理后台(推荐)**
|
||||
|
||||
1. 在浏览器中访问 `http://your-server:3000`
|
||||
2. 使用管理员密码登录(默认:`password`,请在后台及时修改)
|
||||
3. 点击仓库对应的「启用」按钮自动配置 Webhook
|
||||
|
||||
**方式二:手动配置**
|
||||
|
||||
在 Gitea 仓库设置中添加 Webhook:
|
||||
- **URL**: `http://your-server:3000/webhook/gitea`
|
||||
- **内容类型**: `application/json`
|
||||
- **密钥**: 与管理后台中配置的 Webhook 密钥相同
|
||||
- **触发事件**: 「Pull Request」和「Status」
|
||||
## 配置参考
|
||||
|
||||
### 环境变量(最小化)
|
||||
|
||||
仅包含数据库初始化前必须已知的基础设施级别配置:
|
||||
|
||||
| 变量 | 描述 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `PORT` | 服务端口 | `5174` |
|
||||
| `DATABASE_PATH` | SQLite 数据库文件路径 | `./data/assistant.db` |
|
||||
| `ENCRYPTION_KEY` | **必填。** AES-256-GCM 加密密钥,用于加密存储 API Key(64 位十六进制字符串)。运行 `openssl rand -hex 32` 生成 | — |
|
||||
|
||||
### Web 界面配置(管理后台)
|
||||
|
||||
所有运行时配置均通过 **管理后台** `http://your-server:PORT` 进行管理,修改后立即生效,无需重启。
|
||||
|
||||
首次以空数据库启动时,所有设置会自动以安全默认值初始化:
|
||||
- `JWT_SECRET` 和 `WEBHOOK_SECRET` 自动生成(通过 `crypto.randomBytes` 生成 64 位十六进制字符串)
|
||||
- `ADMIN_PASSWORD` 默认为 `password`,**请立即修改**
|
||||
|
||||
#### Gitea
|
||||
|
||||
| 配置项 | 描述 |
|
||||
|--------|------|
|
||||
| Gitea API 地址 | Gitea API 端点(如 `https://gitea.example.com/api/v1`) |
|
||||
| 访问令牌 | 代码审查令牌(需读取和评论权限) |
|
||||
| 管理员令牌 | Webhook 管理令牌(可选) |
|
||||
|
||||
#### 安全
|
||||
|
||||
| 配置项 | 描述 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| Webhook 密钥 | HMAC-SHA256 Webhook 签名密钥 | 自动生成 |
|
||||
| 管理员密码 | 后台登录密码 | `password` |
|
||||
| JWT 密钥 | JWT 签名密钥 | 自动生成 |
|
||||
|
||||
#### LLM 提供商配置
|
||||
|
||||
LLM 提供商和模型通过**管理后台** Web 界面进行配置:
|
||||
|
||||
1. 导航到 **LLM 配置** 页面
|
||||
2. 添加 LLM 提供商(OpenAI 兼容、OpenAI Responses API、Anthropic、Google Gemini)
|
||||
3. 为审查角色分配模型(planner、specialist、judge、embedding)
|
||||
|
||||
> API 密钥使用 AES-256-GCM 加密存储在本地 SQLite 数据库中。
|
||||
|
||||
#### 飞书集成
|
||||
|
||||
| 配置项 | 描述 |
|
||||
|--------|------|
|
||||
| 飞书 Webhook 地址 | 飞书机器人 Webhook URL |
|
||||
| 飞书 Webhook 密钥 | 飞书 Webhook 密钥(可选) |
|
||||
|
||||
#### Agent 审查引擎
|
||||
|
||||
| 配置项 | 描述 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| 审查引擎 | 引擎模式(`agent` 或 `codex`) | `agent` |
|
||||
| 启用分流(Enable Triage) | 启用 planner 分流并输出任务化审查计划 | `true` |
|
||||
| Small 文件上限 | 判定 `small` 规模审查的文件数上限 | `3` |
|
||||
| Small 变更行上限 | 判定 `small` 规模审查的变更行数上限 | `80` |
|
||||
| Medium 文件上限 | 判定 `medium` 规模审查的文件数上限 | `10` |
|
||||
| Medium 变更行上限 | 判定 `medium` 规模审查的变更行数上限 | `400` |
|
||||
| Small Token 预算 | `small` 任务的 token 预算上限 | `12000` |
|
||||
| Medium Token 预算 | `medium` 任务的 token 预算上限 | `45000` |
|
||||
| Large Token 预算 | `large` 任务的 token 预算上限 | `120000` |
|
||||
| 工作目录 | 仓库克隆工作目录 | `/tmp/gitea-assistant` |
|
||||
| 最大并发数 | 最大并发审查任务数 | `2` |
|
||||
| 最大文件数 | 单次审查最大文件数 | `200` |
|
||||
| 自动发布置信度 | 自动发布最小置信度 | `0.8` |
|
||||
| 启用人工审批 | 发布前要求人工确认 | `true` |
|
||||
|
||||
当前 Agent 审查执行模型:
|
||||
|
||||
- `skip`:文档/资源/纯重命名等低风险改动可直接跳过 specialist。
|
||||
- `light`:低风险代码改动执行最小化、按路径范围限定的 specialist 审查。
|
||||
- `full`:敏感路径或中大型改动执行完整任务审查,并可按配置升级到 reflection/debate。
|
||||
- Triage 输出任务(`paths`、`riskTags`、`mode`、`tokenBudget`),Orchestrator 按任务范围派发,不再默认全量扇出。
|
||||
|
||||
#### 记忆与学习(实验性)
|
||||
|
||||
| 配置项 | 描述 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| Qdrant 地址 | Qdrant 向量数据库地址 | - |
|
||||
| 启用记忆 | 启用记忆系统 | `false` |
|
||||
| 启用反思 | 启用自我批评 | `false` |
|
||||
| 启用辩论 | 启用多代理辩论 | `false` |
|
||||
## 部署指南
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker build -t gitea-assistant .
|
||||
docker run -d -p 3000:3000 -v ./data:/app/data -e PORT=3000 gitea-assistant
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Kubernetes
|
||||
|
||||
Kubernetes 部署清单位于 `k8s/` 目录。
|
||||
|
||||
**1. 创建加密密钥**
|
||||
|
||||
```bash
|
||||
# 生成密钥并创建 Secret
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
kubectl -n gitea-assistant create secret generic gitea-assistant-secret \
|
||||
--from-literal=ENCRYPTION_KEY=$ENCRYPTION_KEY
|
||||
# 请保存此密钥!重新部署时需要使用。
|
||||
echo "你的 ENCRYPTION_KEY: $ENCRYPTION_KEY"
|
||||
```
|
||||
|
||||
**2. 部署**
|
||||
|
||||
```bash
|
||||
kubectl apply -k k8s/
|
||||
```
|
||||
|
||||
或逐个应用:
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
kubectl apply -f k8s/qdrant.yaml
|
||||
kubectl apply -f k8s/gitea-assistant.yaml
|
||||
```
|
||||
|
||||
**4. 验证**
|
||||
|
||||
```bash
|
||||
kubectl -n gitea-assistant get pods
|
||||
kubectl -n gitea-assistant get svc
|
||||
```
|
||||
|
||||
**5. 暴露服务(可选)**
|
||||
|
||||
默认使用 `ClusterIP` 类型。如需外部访问,可使用 Ingress 或修改 Service 类型:
|
||||
|
||||
```bash
|
||||
kubectl -n gitea-assistant patch svc gitea-assistant -p '{"spec":{"type":"NodePort"}}'
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT 许可证
|
||||
- English: [README.md](./README.md)
|
||||
|
||||
0
docs/assets/.gitkeep
Normal file
0
docs/assets/.gitkeep
Normal file
BIN
docs/assets/page-config.png
Normal file
BIN
docs/assets/page-config.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 196 KiB |
BIN
docs/assets/page-notifications.png
Normal file
BIN
docs/assets/page-notifications.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
BIN
docs/assets/page-repos.png
Normal file
BIN
docs/assets/page-repos.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
BIN
docs/assets/page-review-config.png
Normal file
BIN
docs/assets/page-review-config.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
78
docs/configuration.md
Normal file
78
docs/configuration.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Configuration Reference
|
||||
|
||||
## Configuration model
|
||||
|
||||
This project uses a DB-first runtime configuration model:
|
||||
|
||||
- `.env` contains only infrastructure-level bootstrap values.
|
||||
- Runtime settings (Gitea, providers, secrets, review policy, notifications) are managed in Admin UI and stored in SQLite.
|
||||
|
||||
## Environment variables (minimal)
|
||||
|
||||
| Variable | Required | Description | Default |
|
||||
|---|---|---|---|
|
||||
| `ENCRYPTION_KEY` | Yes | AES-256-GCM master key (64 hex chars) for API key encryption | - |
|
||||
| `PORT` | No | Service port | `5174` |
|
||||
| `DATABASE_PATH` | No | SQLite path | `./data/assistant.db` |
|
||||
| `LOG_LEVEL` | No | Backend log level (`debug`/`info`/`warn`/`error`). Default is `info`; use `error` in production. | `info` |
|
||||
|
||||
Generate key:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## First boot defaults
|
||||
|
||||
When database is empty:
|
||||
|
||||
- `JWT_SECRET` auto-generated
|
||||
- `WEBHOOK_SECRET` auto-generated
|
||||
- `ADMIN_PASSWORD` defaults to `password`
|
||||
|
||||
Change `ADMIN_PASSWORD` immediately after first login.
|
||||
|
||||
## Runtime groups in Admin UI
|
||||
|
||||
## 1) Gitea
|
||||
|
||||
- API URL
|
||||
- Access token
|
||||
- Admin token (optional)
|
||||
|
||||
## 2) Security
|
||||
|
||||
- Webhook secret (HMAC-SHA256 verification)
|
||||
- Admin password
|
||||
- JWT secret
|
||||
|
||||
## 3) LLM
|
||||
|
||||
- Providers: OpenAI Compatible / OpenAI Responses / Anthropic / Gemini
|
||||
- Role mapping: planner, specialist, judge, embedding
|
||||
|
||||
## 4) Notification
|
||||
|
||||
- Feishu webhook and optional secret
|
||||
- WeCom (企业微信) webhook
|
||||
|
||||
## 5) Review
|
||||
|
||||
- Engine mode: `agent` or `codex`
|
||||
- Triage switch
|
||||
- Size thresholds (`small`/`medium`/`large`)
|
||||
- Execution modes (`skip`/`light`/`full`)
|
||||
- Token budgets and concurrency limits
|
||||
|
||||
> Size and mode are different layers:
|
||||
>
|
||||
> - `small/medium/large`: change-size classification
|
||||
> - `skip/light/full`: review execution depth
|
||||
|
||||
## 6) Memory & learning (optional)
|
||||
|
||||
- `ENABLE_MEMORY` (default `false`)
|
||||
- Qdrant URL
|
||||
- Reflection/debate toggles
|
||||
|
||||
Qdrant is only required when memory is enabled.
|
||||
78
docs/configuration.zh-CN.md
Normal file
78
docs/configuration.zh-CN.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 配置参考
|
||||
|
||||
## 配置模型
|
||||
|
||||
项目采用 DB-first 运行时配置模型:
|
||||
|
||||
- `.env` 仅用于基础设施级引导参数
|
||||
- 运行时配置(Gitea、Provider、密钥、审查策略、通知)由管理后台维护并持久化到 SQLite
|
||||
|
||||
## 环境变量(最小集)
|
||||
|
||||
| 变量 | 必填 | 说明 | 默认值 |
|
||||
|---|---|---|---|
|
||||
| `ENCRYPTION_KEY` | 是 | API Key 加密主密钥(AES-256-GCM,64 位十六进制) | - |
|
||||
| `PORT` | 否 | 服务端口 | `5174` |
|
||||
| `DATABASE_PATH` | 否 | SQLite 路径 | `./data/assistant.db` |
|
||||
| `LOG_LEVEL` | 否 | 后端日志级别(`debug`/`info`/`warn`/`error`)。默认 `info`;生产环境建议 `error`。 | `info` |
|
||||
|
||||
生成密钥:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## 首次启动默认值
|
||||
|
||||
当数据库为空时:
|
||||
|
||||
- `JWT_SECRET` 自动生成
|
||||
- `WEBHOOK_SECRET` 自动生成
|
||||
- `ADMIN_PASSWORD` 默认 `password`
|
||||
|
||||
首次登录后请立即修改管理员密码。
|
||||
|
||||
## 管理后台配置分组
|
||||
|
||||
## 1) Gitea
|
||||
|
||||
- API URL
|
||||
- Access Token
|
||||
- Admin Token(可选)
|
||||
|
||||
## 2) 安全
|
||||
|
||||
- Webhook Secret(HMAC-SHA256 验签)
|
||||
- Admin Password
|
||||
- JWT Secret
|
||||
|
||||
## 3) LLM
|
||||
|
||||
- Provider:OpenAI Compatible / OpenAI Responses / Anthropic / Gemini
|
||||
- 角色模型:planner、specialist、judge、embedding
|
||||
|
||||
## 4) 通知
|
||||
|
||||
- Feishu Webhook 与可选签名密钥
|
||||
- WeCom(企业微信)Webhook
|
||||
|
||||
## 5) 审查
|
||||
|
||||
- 引擎模式:`agent` / `codex`
|
||||
- Triage 开关
|
||||
- 规模阈值(`small`/`medium`/`large`)
|
||||
- 执行模式(`skip`/`light`/`full`)
|
||||
- Token 预算与并发限制
|
||||
|
||||
> 规模与模式是两个层次:
|
||||
>
|
||||
> - `small/medium/large`:变更规模分类
|
||||
> - `skip/light/full`:审查执行深度
|
||||
|
||||
## 6) 记忆与学习(可选)
|
||||
|
||||
- `ENABLE_MEMORY`(默认 `false`)
|
||||
- Qdrant URL
|
||||
- Reflection / Debate 开关
|
||||
|
||||
仅在开启记忆能力时需要 Qdrant。
|
||||
64
docs/deployment.md
Normal file
64
docs/deployment.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Deployment
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker build -t gitea-assistant .
|
||||
docker run -d -p 5174:5174 -v ./data:/app/data -e PORT=5174 -e LOG_LEVEL=error gitea-assistant
|
||||
```
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`docker-compose.yml` includes both:
|
||||
|
||||
- `gitea-assistant`
|
||||
- `qdrant`
|
||||
|
||||
Production default in compose sets `LOG_LEVEL=error`.
|
||||
|
||||
If you do not use memory features, Qdrant can be optional in custom compose setups.
|
||||
|
||||
## Kubernetes
|
||||
|
||||
Kubernetes manifests are in `k8s/`.
|
||||
The default ConfigMap sets `LOG_LEVEL=error` for production.
|
||||
|
||||
### 1) Create namespace and encryption secret
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
kubectl -n gitea-assistant create secret generic gitea-assistant-secret \
|
||||
--from-literal=ENCRYPTION_KEY=$ENCRYPTION_KEY
|
||||
```
|
||||
|
||||
### 2) Deploy
|
||||
|
||||
```bash
|
||||
kubectl apply -k k8s/
|
||||
```
|
||||
|
||||
Or apply individually:
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
kubectl apply -f k8s/qdrant.yaml
|
||||
kubectl apply -f k8s/gitea-assistant.yaml
|
||||
```
|
||||
|
||||
### 3) Verify
|
||||
|
||||
```bash
|
||||
kubectl -n gitea-assistant get pods
|
||||
kubectl -n gitea-assistant get svc
|
||||
```
|
||||
|
||||
### 4) Expose service (optional)
|
||||
|
||||
```bash
|
||||
kubectl -n gitea-assistant patch svc gitea-assistant -p '{"spec":{"type":"NodePort"}}'
|
||||
```
|
||||
64
docs/deployment.zh-CN.md
Normal file
64
docs/deployment.zh-CN.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# 部署指南
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker build -t gitea-assistant .
|
||||
docker run -d -p 5174:5174 -v ./data:/app/data -e PORT=5174 -e LOG_LEVEL=error gitea-assistant
|
||||
```
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`docker-compose.yml` 默认包含:
|
||||
|
||||
- `gitea-assistant`
|
||||
- `qdrant`
|
||||
|
||||
Compose 生产默认日志级别已设置为 `LOG_LEVEL=error`。
|
||||
|
||||
如果不使用记忆能力,可在自定义编排中将 Qdrant 设为可选。
|
||||
|
||||
## Kubernetes
|
||||
|
||||
Kubernetes 清单位于 `k8s/` 目录。
|
||||
默认 ConfigMap 已将生产日志级别设置为 `LOG_LEVEL=error`。
|
||||
|
||||
### 1) 创建命名空间与加密密钥
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
kubectl -n gitea-assistant create secret generic gitea-assistant-secret \
|
||||
--from-literal=ENCRYPTION_KEY=$ENCRYPTION_KEY
|
||||
```
|
||||
|
||||
### 2) 部署
|
||||
|
||||
```bash
|
||||
kubectl apply -k k8s/
|
||||
```
|
||||
|
||||
或逐个应用:
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
kubectl apply -f k8s/qdrant.yaml
|
||||
kubectl apply -f k8s/gitea-assistant.yaml
|
||||
```
|
||||
|
||||
### 3) 验证
|
||||
|
||||
```bash
|
||||
kubectl -n gitea-assistant get pods
|
||||
kubectl -n gitea-assistant get svc
|
||||
```
|
||||
|
||||
### 4) 对外暴露(可选)
|
||||
|
||||
```bash
|
||||
kubectl -n gitea-assistant patch svc gitea-assistant -p '{"spec":{"type":"NodePort"}}'
|
||||
```
|
||||
890
docs/design/kernel-built-in-agents.md
Normal file
890
docs/design/kernel-built-in-agents.md
Normal file
@@ -0,0 +1,890 @@
|
||||
# 技术设计文档:Kernel 内置 Agent 架构
|
||||
|
||||
> **状态**: Draft
|
||||
> **作者**: AI Architect
|
||||
> **日期**: 2026-04-28
|
||||
> **相关模块**: `src/agent-kernel/`、`src/review/kernel/`
|
||||
> **适用范围**: Review Kernel 的内置 subagent 体系、运行时委派、管理后台可观测能力与生产测试门禁
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [0. 文档信息](#0-文档信息)
|
||||
- [1. 背景与目标](#1-背景与目标)
|
||||
- [2. 设计原则与关键取舍](#2-设计原则与关键取舍)
|
||||
- [3. 概要设计](#3-概要设计)
|
||||
- [4. 内置 Agent 详细设计](#4-内置-agent-详细设计)
|
||||
- [4.8 Agent工作机制详解](#48-agent工作机制详解)
|
||||
- [5. 运行时与状态设计](#5-运行时与状态设计)
|
||||
- [6. API 与管理后台可观测性](#6-api-与管理后台可观测性)
|
||||
- [7. 非功能性设计](#7-非功能性设计)
|
||||
- [8. 测试与上线验证](#8-测试与上线验证)
|
||||
- [9. 风险、待确认与后续演进](#9-风险待确认与后续演进)
|
||||
|
||||
---
|
||||
|
||||
## 0. 文档信息
|
||||
|
||||
| 字段 | 内容 |
|
||||
|---|---|
|
||||
| 版本 | v0.1 |
|
||||
| 状态 | 草案 |
|
||||
| 目标读者 | 研发 / 架构 / QA / 运维 / 管理后台开发 |
|
||||
| 系统类型 | AI 应用工程 / 后端 Agent Runtime / 审查系统适配层 |
|
||||
| 主要代码路径 | `src/agent-kernel/`、`src/review/kernel/` |
|
||||
| 相关配置 | `REVIEW_ENGINE=kernel` |
|
||||
|
||||
### Assumptions
|
||||
|
||||
- 当前项目已选择 **kernel-first** 作为代码审查主路径;旧固定 agent 编排不作为未来运行时主路径。
|
||||
- 内置 Agent 当前以 **built-in subagent definition** 的方式注册,后续可演进到 plugin/custom subagent 加载。
|
||||
- 一条 PR 对应一个 kernel session,commit 更新、人工反馈和后续恢复都写入同一 session。
|
||||
|
||||
### To Be Confirmed
|
||||
|
||||
- 是否需要把 built-in subagent 的定义从 TypeScript 代码进一步外置为 YAML/JSON/插件目录。
|
||||
- 管理后台是否需要支持逐 subagent 的启用/禁用、版本选择与灰度策略。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 背景
|
||||
|
||||
早期审查系统采用固定流程编排:triage 后按审查域派生多个 specialist,再由额外阶段汇总。该方案的问题是:
|
||||
|
||||
- 流程扩展需要修改 orchestrator/runtime 代码;
|
||||
- 角色能力与执行链路耦合,难以按能力标签选择代理;
|
||||
- 缺少独立 subagent identity、delegation boundary 和 invocation trace;
|
||||
- 管理后台难以展示“有哪些 Agent、何时被调用、产生了什么结果”;
|
||||
- 恢复、压缩、权限、hook 等横切能力难以统一接入。
|
||||
|
||||
新的 Kernel 内置 Agent 架构将 review 角色转换为注册式 built-in subagents,由 `AgentKernelRunner` 根据 planner 输出与 session state 推进任务,并通过 `KernelAgentInvoker` 统一委派执行。
|
||||
|
||||
### 1.2 核心目标
|
||||
|
||||
| 目标 | 说明 |
|
||||
|---|---|
|
||||
| 注册式扩展 | 内置 Agent 以 `KernelSubagentDefinition` 注册,runtime 不硬编码角色实例 |
|
||||
| 能力选择 | planner 通过 tags/capabilities 选择 subagent,而不是写死 agent id |
|
||||
| 可恢复执行 | session checkpoint 持久化 state + pendingTasks,支持 feedback 后继续执行 |
|
||||
| 委派边界 | 每次 subagent 调用都有 agentId、delegation packet、invocation record、structured result |
|
||||
| 上下文压缩 | 大上下文触发 compression,summary 写入 checkpoint 并回注后续 subagent |
|
||||
| 工具治理 | 工具调用走统一 orchestration、permission gating 与 hooks |
|
||||
| 可观测性 | 管理 API 暴露 task/subagent/hook catalog、session timeline、subagent invocations |
|
||||
|
||||
### 1.3 范围与非范围
|
||||
|
||||
**范围内**:
|
||||
|
||||
- Review Kernel 内置 subagents 的定义、职责、标签、运行链路;
|
||||
- Kernel agent registry / invoker / runner 与 session checkpoint 的协作;
|
||||
- 内置 Agent 与 tools、hooks、permission、compression 的集成方式;
|
||||
- 管理后台需要消费的 catalog 与 session 投影视图;
|
||||
- 生产前自动化测试门禁。
|
||||
|
||||
**范围外**:
|
||||
|
||||
- 前端 UI 视觉设计细节;
|
||||
- 旧 `agent` 固定编排引擎兼容;
|
||||
- Codex CLI 引擎内部实现;
|
||||
- 通用插件市场、远程 agent 执行后端和多租户权限模型。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计原则与关键取舍
|
||||
|
||||
### 2.1 核心设计原则
|
||||
|
||||
| 原则 | 落地方式 |
|
||||
|---|---|
|
||||
| 高内聚低耦合 | `src/agent-kernel/` 只提供通用 session/runner/registry/invoker/hooks;review 逻辑放在 `src/review/kernel/` |
|
||||
| 开闭原则 | 新增流程能力优先增加 subagent、skill、hook 或 tool,而不是修改主循环 |
|
||||
| Session 为状态源 | PR/commit session 记录 event、checkpoint、subagent invocation,是恢复与投影的事实来源 |
|
||||
| 可观测优先 | 每次 subagent 调用持久化 invocation;每个 task 写入 started/completed/failed event |
|
||||
| 安全默认 | 工具执行统一经过 permission gating;高风险 scope 默认 ask/deny |
|
||||
| 可测试 | 断言面落在 checkpoint、events、invocations、tool result、admin projection,而不是完整 LLM 文本 |
|
||||
|
||||
### 2.2 关键取舍
|
||||
|
||||
| 取舍点 | 选择 | 原因 |
|
||||
|---|---|---|
|
||||
| 内置 Agent 表达方式 | TypeScript built-in definitions | 当前阶段需要强类型、低迁移成本;后续可迁移到 plugin loader |
|
||||
| Agent 调用入口 | `KernelAgentInvoker` 统一调用 | 统一 agentId、hook、invocation persistence、structured result |
|
||||
| 流程推进方式 | planner + session state | 避免静态任务数组;支持继续执行与人审恢复 |
|
||||
| Findings 处理 | 本地归一化、去重、排序与发布 | full review 只产出 findings;后续由 skill/本地逻辑保证确定性 |
|
||||
| 压缩策略 | planner 模型窗口 80% 触发 | 使用 tokenlens context window,预留 20% 冗余 |
|
||||
| 管理接口 | task/subagent/hook catalog + session detail | 让后台可解释当前能力目录与执行轨迹 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 概要设计
|
||||
|
||||
### 3.1 总体架构
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Webhook[Gitea Webhook / Feedback] --> Engine[KernelReviewEngine]
|
||||
Engine --> Session[(Kernel Session Repository)]
|
||||
Engine --> Runtime[ReviewKernelRuntime]
|
||||
|
||||
Runtime --> Runner[AgentKernelRunner]
|
||||
Runtime --> SkillRegistry[KernelTaskRegistry / Skills]
|
||||
Runtime --> AgentRegistry[KernelAgentRegistry / Built-in Subagents]
|
||||
Runtime --> HookRegistry[KernelHookRegistry]
|
||||
Runtime --> ToolRegistry[ToolRegistry]
|
||||
|
||||
Runner --> Planner[State-driven Planner]
|
||||
Planner --> SkillTask[Skill Task]
|
||||
Planner --> SubagentTask[Subagent Task]
|
||||
|
||||
SkillTask --> SkillRegistry
|
||||
SubagentTask --> Invoker[KernelAgentInvoker]
|
||||
Invoker --> AgentContext[AsyncLocalStorage Agent Context]
|
||||
Invoker --> Invocation[(Subagent Invocation Record)]
|
||||
Invoker --> Builtins[Review Built-in Subagents]
|
||||
|
||||
Builtins --> Triage[review:triage]
|
||||
Builtins --> FullReview[review:full_review]
|
||||
|
||||
FullReview --> ToolOrchestration[Tool Orchestration]
|
||||
ToolOrchestration --> Permission[Permission Gating]
|
||||
ToolOrchestration --> Hooks[Pre/Post Tool Hooks]
|
||||
|
||||
Runtime --> AdminAPI[Admin API Catalog / Session Projection]
|
||||
```
|
||||
|
||||
### 3.2 模块职责
|
||||
|
||||
| 模块 | 文件 | 职责 |
|
||||
|---|---|---|
|
||||
| Kernel types | `src/agent-kernel/types.ts` | 定义 task、subagent、delegation packet、checkpoint、invocation result |
|
||||
| Agent registry | `src/agent-kernel/agents/kernel-agent-registry.ts` | 注册、查询、按 tag 过滤 subagent |
|
||||
| Agent invoker | `src/agent-kernel/agents/kernel-agent-invoker.ts` | 创建 agentId、触发 hook、持久化 invocation、执行 subagent |
|
||||
| Agent context | `src/agent-kernel/agents/kernel-agent-context.ts` | 使用 AsyncLocalStorage 隔离子代理执行上下文 |
|
||||
| Runner | `src/agent-kernel/runtime/agent-kernel-runner.ts` | 按 planner 结果推进 skill/subagent task,写 checkpoint 与 task event |
|
||||
| Session repo | `src/agent-kernel/session/session-repository.ts` | 持久化 session、events、checkpoint、subagent invocations |
|
||||
| Review runtime | `src/review/kernel/review-kernel-runtime.ts` | 注册 skills/hooks/built-in subagents,提供 execute/continueExecution |
|
||||
| Built-in subagents | `src/review/kernel/review-built-in-subagents.ts` | 将 triage 与 full_review 转换为注册式 subagent definitions |
|
||||
| Subagent ids | `src/review/kernel/review-subagent-ids.ts` | 统一内置 subagent id 命名 |
|
||||
| Admin projection | `src/review/kernel/session-read-model.ts` | 将 session event/checkpoint/invocation 投影为后台视图 |
|
||||
|
||||
### 3.3 核心执行链路
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant E as KernelReviewEngine
|
||||
participant R as ReviewKernelRuntime
|
||||
participant S as SessionRepository
|
||||
participant K as AgentKernelRunner
|
||||
participant I as KernelAgentInvoker
|
||||
participant A as Built-in Subagent
|
||||
|
||||
E->>S: ensureSession(scopeKey)
|
||||
E->>R: execute(run, sessionId)
|
||||
R->>S: appendEvent(run_started)
|
||||
R->>K: run(initialState, initialTasks=[])
|
||||
loop until stopReason
|
||||
K->>K: planner.plan(state)
|
||||
alt skill task
|
||||
K->>R: execute skill handler
|
||||
else subagent task
|
||||
K->>I: invoke(task, context)
|
||||
I->>S: createSubagentInvocation(running)
|
||||
I->>A: execute(task, agentContext)
|
||||
A-->>I: KernelHandlerResult
|
||||
I->>S: completeSubagentInvocation(completed)
|
||||
end
|
||||
K->>S: appendEvent(task_completed)
|
||||
K->>S: saveCheckpoint(state, pendingTasks, stopReason)
|
||||
end
|
||||
R->>S: appendEvent(run_completed)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 内置 Agent 详细设计
|
||||
|
||||
### 4.1 内置 Agent 目录
|
||||
|
||||
| Subagent ID | Source | Model Role | Tags | 职责 | 触发条件 |
|
||||
|---|---|---|---|---|---|
|
||||
| `review:triage` | `built-in` | `planner` | `review`, `planner`, `triage` | 根据 diff、文件、风险生成自主审查提示、模式和预算 | build context 完成且尚无 triage 结果 |
|
||||
| `review:full_review` | `built-in` | `specialist` | `review`, `specialist`, `full-review`, `autonomous-review` | 执行一次完整自主代码审查,模型自行选择工具和调查路径 | triage 完成且尚未完成 full review |
|
||||
|
||||
### 4.2 Subagent Definition 契约
|
||||
|
||||
每个内置 Agent 必须实现 `KernelSubagentDefinition<TState>`:
|
||||
|
||||
```typescript
|
||||
interface KernelSubagentDefinition<TState> {
|
||||
kind: 'subagent';
|
||||
name: string;
|
||||
source: 'built-in' | 'custom' | 'plugin';
|
||||
description: string;
|
||||
whenToUse: string;
|
||||
tags?: string[];
|
||||
modelRole?: string;
|
||||
resumable?: boolean;
|
||||
execute(task, context): Promise<KernelHandlerResult<TState> | undefined>;
|
||||
}
|
||||
```
|
||||
|
||||
关键约束:
|
||||
|
||||
- `name` 必须稳定,作为 session event、invocation、admin catalog 的统一标识;
|
||||
- `tags` 必须包含能力标签,planner 只能按 tag/capability 选择代理;
|
||||
- `whenToUse` 既用于管理后台解释,也用于 delegation packet 的 goal;
|
||||
- `execute` 不直接控制主循环,只返回 state/enqueue/prepend/stopReason;
|
||||
- 内置 Agent 不应越权直接修改 pendingTasks,除非通过标准 `KernelHandlerResult`。
|
||||
|
||||
### 4.3 Planner 选择规则
|
||||
|
||||
`ReviewKernelRuntime.planTasks()` 根据 checkpoint state 推导下一步:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[开始 plan] --> B{有 pendingTasks?}
|
||||
B -- 是 --> Z[不新增任务]
|
||||
B -- 否 --> C{缺 workspace?}
|
||||
C -- 是 --> PW[prepare_workspace skill]
|
||||
C -- 否 --> D{缺 context?}
|
||||
D -- 是 --> BC[build_context skill]
|
||||
D -- 否 --> E{需要压缩?}
|
||||
E -- 是 --> CC[compress_context skill]
|
||||
E -- 否 --> F{缺 triage?}
|
||||
F -- 是 --> T[按 tag=triage 选择 review:triage]
|
||||
F -- 否 --> G{full review 未完成?}
|
||||
G -- 是 --> S[执行 review:full_review]
|
||||
G -- 否 --> P{未 publish?}
|
||||
P -- 是 --> PR[publish_review skill]
|
||||
P -- 否 --> R{未保存 reviewed ref?}
|
||||
R -- 是 --> SR[save_reviewed_ref skill]
|
||||
R -- 否 --> DONE[completed]
|
||||
```
|
||||
|
||||
### 4.4 Triage Agent
|
||||
|
||||
`review:triage` 包装 `TriageAgent`,输出自主审查提示:
|
||||
|
||||
- 使用 `planner` 模型角色;
|
||||
- 接收 `projectPrompt` 和 `compressedContext.summary`;
|
||||
- 生成 `mode`、`reviewSize`、`riskTags`、`suspectedEntrypoints` 与预算提示;
|
||||
- 提示只影响 full review 的调查起点,不拆分审查任务。
|
||||
|
||||
### 4.5 Autonomous Full Review Agent
|
||||
|
||||
`review:full_review` 包装 `AutonomousReviewAgent`:
|
||||
|
||||
- 共享 `ToolRegistry` 与 `KernelHookRegistry`;
|
||||
- 根据 `ReviewTask` 控制 mode、reviewSize、riskTags、suspectedEntrypoints、maxTurns、maxToolCalls、maxElapsedMs、tokenBudget;
|
||||
- 支持压缩 summary 回注到 prompt;
|
||||
- 不预拆 correctness/security/quality 子任务,模型在一次自主循环内跨文件调查;
|
||||
- 工具调用统一经过 tool orchestration、permission gating、Pre/Post tool hooks。
|
||||
|
||||
### 4.6 Aggregate Findings Skill
|
||||
|
||||
`aggregate_findings` 是 full review 后的确定性本地步骤:
|
||||
|
||||
- 接收 `review:full_review` 产出的 findings;
|
||||
- 归一化 category/severity/confidence,补齐 fingerprint;
|
||||
- 按 fingerprint 去重,并按 severity/path/line/title 稳定排序;
|
||||
- 写回 checkpoint,供后续发布步骤使用。
|
||||
|
||||
### 4.7 Publish and Save Skills
|
||||
|
||||
`publish_review` 与 `save_reviewed_ref` 负责外部副作用:
|
||||
|
||||
- `publish_review` 生成确定性 summary,并发布 PR summary 与 line comments;
|
||||
- `save_reviewed_ref` 在本地 mirror 保存已审查 ref,用于后续增量审查;
|
||||
- 两个步骤分离,避免评论发布和 ref 保存互相污染,失败时依赖 checkpoint 重试。
|
||||
|
||||
---
|
||||
|
||||
## 4.8 Agent工作机制详解
|
||||
|
||||
本节详细说明 Kernel Agent 的运转机制、任务调度、工具调用、决策逻辑及边界划分。
|
||||
|
||||
### 4.8.1 核心运转架构
|
||||
|
||||
Kernel 采用「**事件驱动 + 状态机**」的运行模式:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Webhook[Gitea Webhook / Feedback] --> Engine[KernelReviewEngine]
|
||||
Engine --> Session[Session Repository]
|
||||
Engine --> Runtime[ReviewKernelRuntime]
|
||||
Runtime --> Runner[AgentKernelRunner]
|
||||
Runner --> Planner[Turn Planner]
|
||||
Planner --> Tasks[Tasks Queue]
|
||||
Tasks --> Executor[Task Executor]
|
||||
Executor --> State[State Update]
|
||||
State --> Checkpoint[Checkpoint Save]
|
||||
Checkpoint --> Runner
|
||||
```
|
||||
|
||||
**关键组件职责**:
|
||||
|
||||
| 组件 | 文件 | 核心职责 |
|
||||
|------|------|----------|
|
||||
| **AgentKernelRunner** | `agent-kernel-runner.ts` | 主循环控制器:任务调度、状态流转、checkpoint 管理 |
|
||||
| **ReviewKernelRuntime** | `review-kernel-runtime.ts` | Review 业务运行时:封装 skills、subagents、hooks、tools |
|
||||
| **KernelTurnPlanner** | `review-kernel-runtime.ts:305-361` | 基于当前 state 决定下一步执行什么任务 |
|
||||
|
||||
### 4.8.2 核心运转流程
|
||||
|
||||
**1. 启动阶段**:
|
||||
```typescript
|
||||
// PR webhook 触发
|
||||
kernelReviewEngine.enqueuePullRequest(payload)
|
||||
→ ensureSession(scopeKey) // 创建或复用 session
|
||||
→ runtime.execute(run, sessionId) // 启动运行时
|
||||
→ AgentKernelRunner.run({ // 启动主循环
|
||||
sessionId,
|
||||
initialState: {...},
|
||||
initialTasks: []
|
||||
})
|
||||
```
|
||||
|
||||
**2. 主循环机制** (`AgentKernelRunner.run`):
|
||||
|
||||
```typescript
|
||||
async run({ sessionId, initialState, initialTasks, continueExisting }) {
|
||||
// 从 checkpoint 恢复状态(支持继续执行)
|
||||
const persisted = loadCheckpoint(sessionId);
|
||||
let state = persisted?.state ?? initialState;
|
||||
const pendingTasks = [...(persisted?.pendingTasks ?? initialTasks)];
|
||||
|
||||
// 主循环:直到有 stopReason
|
||||
while (!stopReason) {
|
||||
// 如果没有待执行任务,让 planner 规划新任务
|
||||
if (pendingTasks.length === 0) {
|
||||
const planned = planner.plan({ session, state, pendingTasks });
|
||||
pendingTasks.push(...planned);
|
||||
}
|
||||
|
||||
// 取出下一个任务
|
||||
const task = pendingTasks.shift();
|
||||
|
||||
// 执行任务
|
||||
const result = await executeTask(task, context);
|
||||
|
||||
// 处理执行结果
|
||||
if (result?.state) state = result.state; // 更新状态
|
||||
if (result?.prepend) pendingTasks.unshift(...result.prepend); // 前置任务
|
||||
if (result?.enqueue) pendingTasks.push(...result.enqueue); // 后置任务
|
||||
if (result?.stopReason) stopReason = result.stopReason; // 停止原因
|
||||
|
||||
// 保存 checkpoint(支持失败恢复)
|
||||
saveCheckpoint(sessionId, { state, pendingTasks, stopReason });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. 恢复机制** (`continueExisting`):
|
||||
- 从 SQLite 加载持久化的 checkpoint
|
||||
- 恢复 `state` 和 `pendingTasks`
|
||||
- **显式忽略**旧 checkpoint 的 `stopReason`,允许从 feedback 后继续
|
||||
- 不 replay events,直接继续执行
|
||||
|
||||
### 4.8.3 任务调度与决策
|
||||
|
||||
**Planner 是决策中枢**,根据当前 state 动态决定下一步:
|
||||
|
||||
```typescript
|
||||
private planTasks(context: KernelPlanningContext): KernelTask[] {
|
||||
// 阶段1: 前置条件检查(顺序执行)
|
||||
if (!context.state.workspacePath) {
|
||||
return [{ kind: 'skill', name: 'prepare_workspace' }];
|
||||
}
|
||||
if (!context.state.context) {
|
||||
return [{ kind: 'skill', name: 'build_context' }];
|
||||
}
|
||||
|
||||
// 阶段2: 上下文压缩决策
|
||||
if (shouldCompress(context)) {
|
||||
return [{ kind: 'skill', name: 'compress_context' }];
|
||||
}
|
||||
|
||||
// 阶段3: Triage 决策(生成自主审查提示)
|
||||
if (!context.state.triage) {
|
||||
return [{ kind: 'subagent', name: 'review:triage' }];
|
||||
}
|
||||
|
||||
// 阶段4: 单次完整自主审查
|
||||
if (!context.state.reviewCompleted) {
|
||||
return [{ kind: 'subagent', name: 'review:full_review' }];
|
||||
}
|
||||
|
||||
// 阶段5: 发布与收尾
|
||||
if (!context.state.published) {
|
||||
return [{ kind: 'skill', name: 'publish_review' }];
|
||||
}
|
||||
|
||||
return []; // 完成
|
||||
}
|
||||
```
|
||||
|
||||
**决策依据**:
|
||||
- **当前 State**: `triage`, `reviewCompleted`, `findings`, `published`, `reviewedRefSaved` 等字段
|
||||
- **Tags/Capabilities**: 按标签选择 subagent(`filterByTag('triage')`),非硬编码
|
||||
- **Config 开关**: 审查引擎、工作区、命令白名单等运行配置
|
||||
|
||||
### 4.8.4 Skills 与 Subagents 调用机制
|
||||
|
||||
**Skills - 原子任务**:
|
||||
|
||||
```typescript
|
||||
// 注册 Skills
|
||||
this.skillRegistry.register(createPrepareWorkspaceSkill());
|
||||
this.skillRegistry.register(createBuildContextSkill());
|
||||
|
||||
// Skill 定义
|
||||
{
|
||||
kind: 'skill',
|
||||
name: 'build_context',
|
||||
execute: async (task, context) => {
|
||||
// 执行业务逻辑
|
||||
const reviewContext = await diffExtractor.buildContext(...);
|
||||
|
||||
return {
|
||||
state: { ...context.state, context: reviewContext }, // 更新状态
|
||||
// 可选控制流
|
||||
prepend: [], // 在当前任务前插入新任务
|
||||
enqueue: [], // 在当前任务后追加新任务
|
||||
stopReason: undefined // 或 'completed', 'failed', 'awaiting_human_feedback'
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Subagents - 委派执行**:
|
||||
|
||||
```typescript
|
||||
// 调用路径
|
||||
AgentKernelRunner → KernelAgentInvoker.invoke(task, context)
|
||||
→ 创建 invocation record
|
||||
→ 执行 subagent.execute(task, agentContext)
|
||||
→ 完成 invocation,返回结果
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Subagent 执行上下文
|
||||
const agentContext: KernelAgentExecutionContext = {
|
||||
...context,
|
||||
agent, // subagent 定义
|
||||
delegation: { // 委派包
|
||||
goal: agent.whenToUse,
|
||||
parentTaskName: task.name,
|
||||
input: task.input,
|
||||
contextSummary: state.compressedContext?.summary // 压缩摘要回注
|
||||
}
|
||||
};
|
||||
|
||||
// 执行(带 AsyncLocalStorage 隔离)
|
||||
const result = await runWithKernelAgentContext(
|
||||
{ agentId, parentSessionId, agentType: 'subagent', ... },
|
||||
() => agent.execute(task, agentContext)
|
||||
);
|
||||
```
|
||||
|
||||
### 4.8.5 Tools 调用机制
|
||||
|
||||
**调用路径**(在 `review:full_review` 内部):
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant FullReview as AutonomousReviewAgent
|
||||
participant Loop as Autonomous Loop
|
||||
participant Orchestration as ToolOrchestration
|
||||
participant Permission as Permission Gating
|
||||
participant Hook as PreToolUse Hook
|
||||
participant Tool as Tool.execute()
|
||||
participant PostHook as PostToolUse Hook
|
||||
|
||||
FullReview->>Loop: 决定调用 tool
|
||||
Loop->>Orchestration: partitionToolCalls(tools)
|
||||
Orchestration->>Permission: evaluateToolPermission(tool)
|
||||
Permission-->>Orchestration: allow/ask/deny
|
||||
Orchestration->>Hook: runKernelHooks(PreToolUse)
|
||||
Hook-->>Orchestration: additionalContext/updatedInput
|
||||
Orchestration->>Tool: tool.execute(args)
|
||||
Tool-->>Orchestration: result
|
||||
Orchestration->>PostHook: runKernelHooks(PostToolUse)
|
||||
PostHook-->>Orchestration: -
|
||||
Orchestration-->>Loop: toolResult
|
||||
Loop-->>FullReview: 更新 diagnostics/findings
|
||||
```
|
||||
|
||||
**并发控制**:
|
||||
- **并发安全工具** (`isConcurrencySafe: true`): 并行执行
|
||||
- **非并发安全工具**: 串行执行
|
||||
- **权限拦截**: `PermissionRequest` Hook 可批准/阻断
|
||||
|
||||
**权限边界**:
|
||||
|
||||
| Scope | 默认行为 | 说明 |
|
||||
|-------|----------|------|
|
||||
| `read` | `allow` | 安全操作(读文件、搜索代码) |
|
||||
| `write` | `ask` | 需审批(写文件) |
|
||||
| `command` | `ask` | 需审批(执行命令) |
|
||||
| `git_write` | `ask` | 需审批(Git 操作) |
|
||||
| `network` | `deny` | 禁止网络访问 |
|
||||
| `cross_session` | `deny` | 禁止跨 session 操作 |
|
||||
|
||||
### 4.8.6 代码审查结合流程
|
||||
|
||||
**完整数据流**:
|
||||
|
||||
```
|
||||
Webhook → PR/Commit
|
||||
↓
|
||||
prepare_workspace → 克隆仓库、准备 mirror/workspace
|
||||
↓
|
||||
build_context → 提取 diff、文件内容、构建 ReviewContext
|
||||
↓
|
||||
compress_context (可选) → 大上下文自动压缩,生成 summary
|
||||
↓
|
||||
review:triage → 生成自主审查提示、模式和预算
|
||||
↓
|
||||
review:full_review → 单个自主代理跨文件调查,生成 findings
|
||||
↓
|
||||
publish_review → 发布 summary + line comments
|
||||
↓
|
||||
save_reviewed_ref → 保存审查快照(支持增量审查)
|
||||
```
|
||||
|
||||
**状态流转**:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> prepare_workspace: 启动
|
||||
prepare_workspace --> build_context: 成功
|
||||
build_context --> compress_context: 上下文过大
|
||||
build_context --> triage: 正常
|
||||
compress_context --> triage: 完成
|
||||
triage --> full_review: 提示生成完成
|
||||
full_review --> publish_review: findings 聚合完成
|
||||
publish_review --> save_reviewed_ref: 直接完成
|
||||
save_reviewed_ref --> [*]: completed
|
||||
```
|
||||
|
||||
### 4.8.7 边界划分
|
||||
|
||||
**Skills vs Subagents 边界**:
|
||||
|
||||
| 维度 | Skills | Subagents |
|
||||
|------|--------|-----------|
|
||||
| **粒度** | 原子操作(准备环境、构建上下文、发布) | 复杂推理(规划、完整审查) |
|
||||
| **模型** | 通常不涉及 LLM | 必须调用 LLM(planner/specialist) |
|
||||
| **并发** | 顺序执行 | 通过单个 full review 代理内部自主工具调用实现调查 |
|
||||
| **状态** | 修改 state 字段 | 可修改 state,主要产出 hints/findings/diagnostics |
|
||||
| **失败** | 阻断整个流程 | 可单独重试或降级 |
|
||||
| **示例** | prepare_workspace, publish_review | review:triage, review:full_review |
|
||||
|
||||
**Runtime vs Runner 边界**:
|
||||
|
||||
| 组件 | 职责 | 不做什么 |
|
||||
|------|------|----------|
|
||||
| **AgentKernelRunner** | 通用调度、checkpoint、task 循环 | 不感知 Review 业务逻辑 |
|
||||
| **ReviewKernelRuntime** | Review 业务封装、skills、subagents、hooks | 不直接调度任务(委托给 runner) |
|
||||
|
||||
**Subagents 间边界**:
|
||||
|
||||
| Subagent | 输入 | 输出 | 边界限制 |
|
||||
|----------|------|------|----------|
|
||||
| **triage** | ReviewContext | review hints + budget | 只生成提示,不审查 |
|
||||
| **full_review** | ReviewTask + context | findings[] + diagnostics | 一次完整自主审查,不预拆域或文件 |
|
||||
|
||||
**Hook 介入边界**:
|
||||
|
||||
```typescript
|
||||
// 在关键生命周期点介入
|
||||
SessionStart // session 启动时
|
||||
SubagentStart // subagent 启动时
|
||||
PreToolUse // 工具调用前(可修改输入、阻断)
|
||||
PermissionRequest // 权限请求时(决定 allow/ask/deny)
|
||||
PostToolUse // 工具调用成功后
|
||||
PostToolUseFailure // 工具调用失败后
|
||||
```
|
||||
|
||||
**Session 隔离边界**:
|
||||
|
||||
- 每个 PR/Commit 对应独立 session
|
||||
- session 间 state 不共享
|
||||
- tool 默认禁止 cross_session 操作
|
||||
- subagent invocation 绑定 parentSessionId
|
||||
|
||||
---
|
||||
|
||||
## 5. 运行时与状态设计
|
||||
|
||||
### 5.1 Session 与 Checkpoint
|
||||
|
||||
每条 PR/commit 审查对应一个 kernel session:
|
||||
|
||||
| 数据 | 用途 |
|
||||
|---|---|
|
||||
| `KernelSessionRecord` | 记录 scopeType、scopeKey、metadata、lastRunId |
|
||||
| `KernelSessionEventRecord` | append-only 事件流,记录 run/task/hook/feedback 生命周期 |
|
||||
| `KernelCheckpoint<TState>` | 持久化 state、pendingTasks、stopReason |
|
||||
| `KernelSubagentInvocationRecord` | 记录每次 subagent 委派调用 |
|
||||
|
||||
恢复语义:
|
||||
|
||||
- `continueExisting=true` 时从 persisted checkpoint 恢复 `state + pendingTasks`;
|
||||
- 显式忽略旧 checkpoint 的 stopReason,允许 feedback 后继续推进;
|
||||
- 当前不 replay session events 重建 state,event 主要用于投影与审计。
|
||||
|
||||
### 5.2 ReviewKernelState
|
||||
|
||||
核心状态包括:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `targetSha` | 当前审查目标 commit |
|
||||
| `mirrorPath/workspacePath` | 本地仓库与工作区路径 |
|
||||
| `context` | `ReviewContext`,包含 diff、changedFiles、fileContents 等 |
|
||||
| `projectPrompt` | 仓库级审查 prompt |
|
||||
| `compressedContext` | 自动压缩摘要及 token 元数据 |
|
||||
| `triage/reviewTask/reviewCompleted` | 自主审查提示、预算与完成状态 |
|
||||
| `findings` | subagents 收集到的问题 |
|
||||
| `reviewDiagnostics` | full review 工具调用、停止原因、解析计数等诊断信息 |
|
||||
| `published/reviewedRefSaved` | 发布与审查快照保存状态位 |
|
||||
|
||||
### 5.3 Subagent Invocation
|
||||
|
||||
每次 subagent 调用会持久化:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `parent_session_id` | 父 session |
|
||||
| `parent_run_id` | 当前 review run |
|
||||
| `parent_task_name` | 触发该调用的 task name |
|
||||
| `subagent_name` | subagent id,例如 `review:triage` |
|
||||
| `agent_id` | 本次调用唯一 agent identity |
|
||||
| `status` | running / completed / failed |
|
||||
| `input_json` | delegation packet |
|
||||
| `result_json` | structured invocation result |
|
||||
|
||||
失败处理:
|
||||
|
||||
- invoker 将 invocation 标记为 `failed`;
|
||||
- runner 写入 `task_failed` event;
|
||||
- checkpoint 保存当前 state 与 `[failedTask, ...pendingTasks]`,stopReason=`failed`;
|
||||
- 调用方可根据 checkpoint 与错误信息决定重试/人工介入。
|
||||
|
||||
### 5.4 上下文压缩与回注
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant P as Planner
|
||||
participant C as ContextCompressionService
|
||||
participant S as Session Checkpoint
|
||||
participant A as Subagent
|
||||
|
||||
P->>C: shouldCompress(context, compressedContext)
|
||||
C-->>P: true when tokenEstimate >= contextWindow * 0.8
|
||||
P->>C: compress(context, projectPrompt)
|
||||
C-->>S: compressedContext(summary, token stats, model, timestamp)
|
||||
P->>A: invoke subagent with contextSummary
|
||||
A-->>A: prompt includes compressed summary
|
||||
```
|
||||
|
||||
压缩触发阈值:
|
||||
|
||||
- 使用 `tokenCounter.getContextWindow(plannerModel)` 获取模型上下文窗口;
|
||||
- 取 80% 作为触发阈值,预留 20% 冗余;
|
||||
- 若无法获取模型配置,兜底使用默认窗口。
|
||||
|
||||
### 5.5 Hooks 与 Permission
|
||||
|
||||
内置 hooks:
|
||||
|
||||
| Hook | Event | 作用 |
|
||||
|---|---|---|
|
||||
| `kernel:session-start-audit` | `SessionStart` | 写入 `hook_session_start` event |
|
||||
| `kernel:subagent-start-audit` | `SubagentStart` | 写入 `hook_subagent_start` event |
|
||||
| `kernel:pre-tool-audit` | `PreToolUse` | 为工具调用追加审计上下文 |
|
||||
| `kernel:permission-request-audit` | `PermissionRequest` | 记录权限请求上下文 |
|
||||
|
||||
工具权限默认策略:
|
||||
|
||||
| Scope | 默认行为 |
|
||||
|---|---|
|
||||
| `read` | allow |
|
||||
| `write` | ask |
|
||||
| `command` | ask |
|
||||
| `git_write` | ask |
|
||||
| `network` | deny |
|
||||
| `cross_session` | deny |
|
||||
|
||||
---
|
||||
|
||||
## 6. API 与管理后台可观测性
|
||||
|
||||
### 6.1 Admin API
|
||||
|
||||
| API | 说明 |
|
||||
|---|---|
|
||||
| `GET /admin/api/review/sessions` | 返回 session 列表与 summary |
|
||||
| `GET /admin/api/review/sessions/:sessionId` | 返回 session、summary、checkpoint、plan、timeline、events、subagentInvocations、runDetails |
|
||||
| `GET /admin/api/review/kernel/tasks` | 返回 skill + subagent task catalog |
|
||||
| `GET /admin/api/review/kernel/subagents` | 返回 subagent catalog |
|
||||
| `GET /admin/api/review/kernel/hooks` | 返回 hook catalog |
|
||||
|
||||
### 6.2 Subagent Catalog 响应字段
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "subagent",
|
||||
"name": "review:full_review",
|
||||
"source": "built-in",
|
||||
"description": "执行一次完整自主代码审查",
|
||||
"whenToUse": "当 triage 生成审查提示后执行完整审查",
|
||||
"modelRole": "specialist",
|
||||
"tags": ["review", "specialist", "full-review", "autonomous-review"],
|
||||
"resumable": true
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 管理后台展示建议
|
||||
|
||||
管理后台应采用双层控制面:
|
||||
|
||||
- 上层:Kernel Subagents 目录,展示 built-in/custom/plugin subagents;
|
||||
- 下层:模型角色路由,配置 `planner / specialist` 到 provider/model。
|
||||
|
||||
展示字段建议:
|
||||
|
||||
| 区域 | 字段 |
|
||||
|---|---|
|
||||
| Subagent 目录 | name、source、description、whenToUse、modelRole、tags、resumable |
|
||||
| Session 详情 | summary、plan、timeline、findings、comments、subagentInvocations |
|
||||
| Invocation 详情 | agentId、status、startedAt、finishedAt、summary、artifacts |
|
||||
|
||||
---
|
||||
|
||||
## 7. 非功能性设计
|
||||
|
||||
### 7.1 安全设计
|
||||
|
||||
- 工具调用统一走 permission gating,避免 subagent 绕过权限策略;
|
||||
- 高风险工具默认 ask/deny,不允许直接执行网络、跨 session 或写操作;
|
||||
- hooks 可作为后续审批、审计、通知与策略扩展点;
|
||||
- LLM prompt 不作为安全边界,所有外部副作用必须由 tool/skill/adapters 承载。
|
||||
|
||||
### 7.2 高可用与恢复
|
||||
|
||||
- 每个 task 完成后保存 checkpoint,降低失败后的重复工作;
|
||||
- subagent invocation 失败会记录 failed 状态,便于定位失败代理;
|
||||
- feedback 后通过 `continueExisting` 从 checkpoint 继续;
|
||||
- publish 与 save reviewed ref 分离,避免评论发布与 ref 保存互相污染;
|
||||
- cleanup workspace 放在 runtime finally 中执行,降低资源泄漏风险。
|
||||
|
||||
### 7.3 可观测性
|
||||
|
||||
- session event 记录 run/task/hook/feedback 生命周期;
|
||||
- subagent invocation 记录 parent-child 委派关系;
|
||||
- admin projection 汇总 plan/timeline/currentStep/findingCount/pendingTaskCount;
|
||||
- compression 记录 sourceTokenEstimate、summaryTokenEstimate、triggerThreshold、model。
|
||||
|
||||
### 7.4 性能与容量
|
||||
|
||||
- 大 diff 先经 diff extractor/token budget 裁剪,再由 compression service 做会话级摘要;
|
||||
- `review:full_review` 在单个自主循环内使用工具逐步调查,避免运行时预拆 domain 或文件;
|
||||
- tool orchestration 可并发执行 read-only 工具,非并发安全工具串行;
|
||||
- session/event/checkpoint 使用 SQLite,适合当前单体部署;未来高并发可迁移到外部数据库。
|
||||
|
||||
### 7.5 可维护性与扩展性
|
||||
|
||||
- 新增内置 Agent 应只新增 `KernelSubagentDefinition` 并打 tags;
|
||||
- 新增流程副作用应优先实现 skill/adapters;
|
||||
- 新增横切逻辑应优先实现 hook;
|
||||
- 新增工具必须声明 permissionScope 和 isConcurrencySafe。
|
||||
|
||||
---
|
||||
|
||||
## 8. 测试与上线验证
|
||||
|
||||
### 8.1 自动化测试分层
|
||||
|
||||
| 层级 | 测试文件 | 覆盖点 |
|
||||
|---|---|---|
|
||||
| Unit | `src/review/kernel/__tests__/session-read-model.test.ts` | session summary/plan/timeline 投影 |
|
||||
| Unit | `src/review/tools/__tests__/tool-permissions.test.ts` | permission scope 默认策略 |
|
||||
| Contract | `src/agent-kernel/hooks/__tests__/kernel-hook-runner.test.ts` | hook 聚合、approve/block、updatedInput |
|
||||
| Integration | `src/controllers/__tests__/admin-review-sessions.test.ts` | admin session 与 catalog API |
|
||||
| Integration | `src/controllers/__tests__/feedback-kernel-session.test.ts` | feedback approve/reject/rollback/continue |
|
||||
| Runtime | `src/review/kernel/__tests__/runtime-happy-path.test.ts` | 完整 runtime happy path |
|
||||
| Runtime | `src/review/kernel/__tests__/runtime-feedback-resume.test.ts` | awaiting feedback 后恢复 |
|
||||
| Runtime | `src/review/kernel/__tests__/runtime-replay-invariants.test.ts` | checkpoint/resume/replay 不变量 |
|
||||
| Runtime | `src/review/kernel/__tests__/runtime-concurrency-idempotency.test.ts` | 并发上限与幂等 |
|
||||
| Canary | `src/review/kernel/__tests__/compression-resumability.test.ts` | 压缩恢复与生产关键 canary |
|
||||
|
||||
### 8.2 上线前门禁
|
||||
|
||||
必须通过:
|
||||
|
||||
```bash
|
||||
bun run lint
|
||||
bun run build
|
||||
bun test src/review/kernel/__tests__ src/review/tools/__tests__ src/controllers/__tests__ src/agent-kernel/hooks/__tests__
|
||||
bun test
|
||||
```
|
||||
|
||||
关键验收信号:
|
||||
|
||||
- runtime happy path 完成,stopReason=`completed`;
|
||||
- feedback resume 从 `awaiting_human_feedback` 恢复到 completed;
|
||||
- compression resume 保留 targetSha、pending boundary、invocation boundary、summary;
|
||||
- permission deny 不会绕过工具治理;
|
||||
- duplicate enqueue/continue/feedback 不产生重复有效工作;
|
||||
- admin session detail 能看到 plan/timeline/subagentInvocations。
|
||||
|
||||
### 8.3 灰度与回滚
|
||||
|
||||
- 配置默认:`REVIEW_ENGINE=kernel`;
|
||||
- 若需要回滚,可临时切到 `codex` 引擎,但旧固定 agent 编排不再作为主路径;
|
||||
- 灰度期间重点观察 session stopReason 分布、task_failed 事件、subagent failed invocations、feedback resume 成功率。
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险、待确认与后续演进
|
||||
|
||||
### 9.1 风险与应对
|
||||
|
||||
| 风险 | 影响 | 应对 |
|
||||
|---|---|---|
|
||||
| Built-in definitions 仍在代码中 | 扩展仍需发版 | 下一阶段引入 plugin/custom subagent loader |
|
||||
| SQLite 单文件并发能力有限 | 高并发 session 下写入竞争 | 当前单体可接受;未来迁移外部 DB 或队列化写入 |
|
||||
| Compression summary 可能遗漏细节 | 后续 subagent 判断偏差 | 保留 recent context + summary;测试锁定关键事实不丢 |
|
||||
| Hook 阻断策略过强或过弱 | 工具误阻断或越权 | permission matrix 测试 + 审计 event + 管理后台策略展示 |
|
||||
|
||||
### 9.2 后续演进计划
|
||||
|
||||
1. **Plugin-based Subagent Loading**:支持从目录或配置加载 custom/plugin subagents。
|
||||
2. **Child Session Tree**:为长任务或后台 subagent 引入 child session/resume tree。
|
||||
3. **Attachment Reinjection**:压缩后恢复文件附件、计划附件和技能附件。
|
||||
4. **更细粒度权限模型**:支持仓库级、工具级、用户级策略配置。
|
||||
5. **Subagent 版本治理**:为 built-in/custom/plugin subagents 增加 version、enabled、rollout 字段。
|
||||
|
||||
### 9.3 评审清单
|
||||
|
||||
- [ ] 内置 Agent 是否都通过 registry/invoker 调用,而不是 runtime 硬编码实例?
|
||||
- [ ] planner 是否按 tag/capability 选择 subagent?
|
||||
- [ ] 每次 subagent 调用是否有 invocation record?
|
||||
- [ ] feedback 后 continue 是否从 checkpoint 恢复?
|
||||
- [ ] 压缩 summary 是否持久化并回注 triage/full_review?
|
||||
- [ ] 工具执行是否经过 permission/hook/orchestration?
|
||||
- [ ] 管理后台是否能展示 catalog、timeline、invocations?
|
||||
- [ ] 生产测试门禁是否覆盖 happy path、失败恢复、幂等和 canary?
|
||||
|
||||
---
|
||||
|
||||
## 版本记录
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|---|---|---|
|
||||
| v0.1 | 2026-04-28 | 初版:记录 Kernel 内置 Agent 架构、运行链路、可观测性与测试门禁 |
|
||||
617
docs/design/notification-service-refactoring.md
Normal file
617
docs/design/notification-service-refactoring.md
Normal file
@@ -0,0 +1,617 @@
|
||||
# 通知服务抽象化重构方案
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 背景
|
||||
当前项目中的通知功能仅支持飞书(Feishu/Lark)平台,代码高度耦合飞书特定的API实现。随着业务需求扩展,需要支持企业微信(WeCom)等其他通知渠道。
|
||||
|
||||
### 1.2 目标
|
||||
- 抽象通用通知服务接口,支持多平台扩展
|
||||
- 支持同时配置多个通知服务(如飞书+企业微信同时推送)
|
||||
- 统一通知调用入口,避免平台耦合与重复发送
|
||||
- 清晰的代码结构,便于后续添加新平台(如Slack、钉钉等)
|
||||
|
||||
### 1.3 非目标
|
||||
- 不修改通知的业务触发逻辑
|
||||
- 不改变现有的Gitea Webhook处理流程
|
||||
- 不引入外部通知服务SDK依赖(保持轻量)
|
||||
|
||||
---
|
||||
|
||||
## 2. 现有架构分析
|
||||
|
||||
### 2.1 重构前实现(已下线)
|
||||
```
|
||||
src/
|
||||
├── services/feishu.ts # 飞书服务实现(156行)
|
||||
├── controllers/review.ts # 通知调用点
|
||||
├── config/config-schema.ts # 配置定义
|
||||
└── config/config-manager.ts # 配置管理
|
||||
```
|
||||
|
||||
### 2.2 关键代码特征
|
||||
- **强耦合**:`review.ts` 直接调用 `feishuService.sendXXXNotification()`
|
||||
- **硬编码消息格式**:飞书特定的 `msg_type: 'text'` 结构
|
||||
- **签名逻辑**:HMAC-SHA256(timestamp+"\n"+secret)
|
||||
- **配置单一**:仅支持一组飞书配置
|
||||
|
||||
### 2.3 通知场景
|
||||
| 场景 | 方法名 | 触发条件 |
|
||||
|------|--------|----------|
|
||||
| 工单创建 | `sendIssueCreatedNotification` | Issue opened + 有指派人 |
|
||||
| 工单关闭 | `sendIssueClosedNotification` | Issue closed |
|
||||
| 工单指派 | `sendIssueAssignedNotification` | Issue assigned |
|
||||
| PR创建 | `sendPrCreatedNotification` | PR opened + 有审阅者 |
|
||||
| PR指派 | `sendPrReviewerAssignedNotification` | PR review_requested |
|
||||
|
||||
---
|
||||
|
||||
## 3. 目标架构设计
|
||||
|
||||
### 3.1 架构模式
|
||||
采用**策略模式(Strategy)** + **工厂模式(Factory)**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Notification Service Layer │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │
|
||||
│ │ INotification │ │ INotification │ │ INotification│ │
|
||||
│ │ Service │ │ Service │ │ Service │ │
|
||||
│ │ (Interface) │ │ (Interface) │ │ (Interface) │ │
|
||||
│ └────────┬────────┘ └────────┬────────┘ └──────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌─────┴─────┐ ┌────┴────┐ ┌────┴────┐ │
|
||||
│ │ Feishu │ │ WeCom │ │ Slack │ │
|
||||
│ │Service │ │Service │ │ Service │ │
|
||||
│ └───────────┘ └─────────┘ └─────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────┴─────────┐
|
||||
│ NotificationFactory│
|
||||
└─────────┬─────────┘
|
||||
│
|
||||
┌─────────┴─────────┐
|
||||
│ NotificationManager│ ← 统一入口,支持多服务
|
||||
└───────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 核心接口设计
|
||||
|
||||
#### 3.2.1 类型定义
|
||||
```typescript
|
||||
// types.ts
|
||||
export type NotificationProvider = 'feishu' | 'wecom' | 'slack' | 'dingtalk';
|
||||
|
||||
export interface NotificationContext {
|
||||
// PR相关
|
||||
prTitle?: string;
|
||||
prUrl?: string;
|
||||
prNumber?: number;
|
||||
|
||||
// Issue相关
|
||||
issueTitle?: string;
|
||||
issueUrl?: string;
|
||||
issueNumber?: number;
|
||||
|
||||
// 用户相关
|
||||
actor?: string;
|
||||
assignees?: string[];
|
||||
reviewers?: string[];
|
||||
creator?: string;
|
||||
|
||||
// 仓库相关
|
||||
repository?: string;
|
||||
owner?: string;
|
||||
|
||||
// 时间戳
|
||||
timestamp?: Date;
|
||||
}
|
||||
|
||||
export interface NotificationMessage {
|
||||
type: 'text' | 'markdown';
|
||||
title?: string;
|
||||
content: string;
|
||||
atUsers?: string[];
|
||||
url?: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2.2 服务接口
|
||||
```typescript
|
||||
// INotificationService
|
||||
export interface INotificationService {
|
||||
readonly provider: NotificationProvider;
|
||||
|
||||
isEnabled(): boolean;
|
||||
sendMessage(message: NotificationMessage): Promise<void>;
|
||||
|
||||
// 场景特定方法
|
||||
sendIssueCreatedNotification(context: NotificationContext): Promise<void>;
|
||||
sendIssueClosedNotification(context: NotificationContext): Promise<void>;
|
||||
sendIssueAssignedNotification(context: NotificationContext): Promise<void>;
|
||||
sendPrCreatedNotification(context: NotificationContext): Promise<void>;
|
||||
sendPrReviewerAssignedNotification(context: NotificationContext): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 平台差异对照
|
||||
|
||||
| 特性 | 飞书(Feishu) | 企业微信(WeCom) | Slack |
|
||||
|------|--------------|-----------------|-------|
|
||||
| **Webhook格式** | `open.feishu.cn/open-apis/bot/v2/hook/{key}` | `qyapi.weixin.qq.com/cgi-bin/webhook/send?key={key}` | `hooks.slack.com/services/...` |
|
||||
| **签名机制** | HMAC-SHA256(timestamp+"\n"+secret) | **无** | HMAC-SHA256(timestamp+":"+secret) |
|
||||
| **@用户方式** | `<at user_id="xxx">` 或文本追加 | `mentioned_list: ["userid"]` 或手机号 | `<@user_id>` |
|
||||
| **消息类型字段** | `msg_type` | `msgtype` | `type` |
|
||||
| **内容字段** | `content.text` | `text.content` | `text` |
|
||||
| **频率限制** | 100次/分钟 | 20条/分钟 | 1次/秒 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 详细实现方案
|
||||
|
||||
### 4.1 目录结构
|
||||
```
|
||||
src/
|
||||
├── services/
|
||||
│ ├── notification/
|
||||
│ │ ├── index.ts # 导出入口
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ ├── base-notification-service.ts # 抽象基类
|
||||
│ │ ├── notification-factory.ts # 工厂
|
||||
│ │ ├── notification-manager.ts # 管理器
|
||||
│ │ └── providers/
|
||||
│ │ ├── feishu-notification-service.ts
|
||||
│ │ └── wecom-notification-service.ts
|
||||
│ └── notification-manager.ts # 运行时通知管理器入口
|
||||
```
|
||||
|
||||
### 4.2 基类实现
|
||||
|
||||
```typescript
|
||||
// base-notification-service.ts
|
||||
export abstract class BaseNotificationService implements INotificationService {
|
||||
abstract readonly provider: NotificationProvider;
|
||||
|
||||
constructor(protected config: NotificationServiceConfig) {}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.config.enabled && !!this.config.webhookUrl;
|
||||
}
|
||||
|
||||
abstract sendMessage(message: NotificationMessage): Promise<void>;
|
||||
|
||||
// 通用模板方法
|
||||
async sendIssueCreatedNotification(context: NotificationContext): Promise<void> {
|
||||
const message = this.buildIssueCreatedMessage(context);
|
||||
await this.sendMessage(message);
|
||||
}
|
||||
|
||||
// 子类实现消息构建
|
||||
protected abstract buildIssueCreatedMessage(context: NotificationContext): NotificationMessage;
|
||||
// ... 其他方法类似
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 飞书实现要点
|
||||
|
||||
```typescript
|
||||
// feishu-notification-service.ts
|
||||
export class FeishuNotificationService extends BaseNotificationService {
|
||||
readonly provider = 'feishu' as const;
|
||||
|
||||
async sendMessage(message: NotificationMessage): Promise<void> {
|
||||
const payload: any = {
|
||||
msg_type: 'text',
|
||||
content: {
|
||||
text: message.content,
|
||||
},
|
||||
};
|
||||
|
||||
// 添加签名
|
||||
if (this.config.webhookSecret) {
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||
payload.timestamp = timestamp;
|
||||
payload.sign = this.generateSign(timestamp, this.config.webhookSecret);
|
||||
}
|
||||
|
||||
const response = await fetch(this.config.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Feishu notification failed: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
protected buildIssueCreatedMessage(context: NotificationContext): NotificationMessage {
|
||||
return {
|
||||
type: 'text',
|
||||
content: `📝 新工单已创建\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}`,
|
||||
atUsers: context.assignees,
|
||||
};
|
||||
}
|
||||
|
||||
private generateSign(timestamp: string, secret: string): string {
|
||||
const stringToSign = `${timestamp}\n${secret}`;
|
||||
const hmac = crypto.createHmac('sha256', stringToSign);
|
||||
return hmac.digest('base64');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 企业微信实现要点
|
||||
|
||||
```typescript
|
||||
// wecom-notification-service.ts
|
||||
export class WeComNotificationService extends BaseNotificationService {
|
||||
readonly provider = 'wecom' as const;
|
||||
|
||||
async sendMessage(message: NotificationMessage): Promise<void> {
|
||||
const payload: any = {
|
||||
msgtype: 'text',
|
||||
text: {
|
||||
content: message.content,
|
||||
},
|
||||
};
|
||||
|
||||
// 企业微信使用 mentioned_list
|
||||
if (message.atUsers?.length) {
|
||||
payload.text.mentioned_list = message.atUsers.map(u =>
|
||||
u.toLowerCase() === 'all' ? '@all' : u
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(this.config.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`WeCom notification failed: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
protected buildIssueCreatedMessage(context: NotificationContext): NotificationMessage {
|
||||
return {
|
||||
type: 'text',
|
||||
content: `📝 新工单已创建\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}`,
|
||||
atUsers: context.assignees,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 管理器实现
|
||||
|
||||
```typescript
|
||||
// notification-manager.ts
|
||||
export class NotificationManager {
|
||||
private services: INotificationService[] = [];
|
||||
|
||||
constructor(configs: NotificationServiceConfig[]) {
|
||||
this.services = configs
|
||||
.filter(c => c.enabled && c.webhookUrl)
|
||||
.map(c => NotificationFactory.createService(c));
|
||||
}
|
||||
|
||||
// 广播到所有服务
|
||||
async broadcast(
|
||||
operation: (service: INotificationService) => Promise<void>
|
||||
): Promise<void> {
|
||||
const results = await Promise.allSettled(
|
||||
this.services.map(async service => {
|
||||
try {
|
||||
await operation(service);
|
||||
} catch (error) {
|
||||
logger.error(`${service.provider} notification failed:`, error);
|
||||
throw error; // 重新抛出以便Promise.allSettled捕获
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 记录失败统计
|
||||
const failures = results.filter(r => r.status === 'rejected');
|
||||
if (failures.length > 0) {
|
||||
logger.warn(`${failures.length}/${this.services.length} notification services failed`);
|
||||
}
|
||||
}
|
||||
|
||||
// 便捷方法
|
||||
async notifyIssueCreated(context: NotificationContext): Promise<void> {
|
||||
await this.broadcast(s => s.sendIssueCreatedNotification(context));
|
||||
}
|
||||
|
||||
async notifyIssueClosed(context: NotificationContext): Promise<void> {
|
||||
await this.broadcast(s => s.sendIssueClosedNotification(context));
|
||||
}
|
||||
|
||||
async notifyIssueAssigned(context: NotificationContext): Promise<void> {
|
||||
await this.broadcast(s => s.sendIssueAssignedNotification(context));
|
||||
}
|
||||
|
||||
async notifyPrCreated(context: NotificationContext): Promise<void> {
|
||||
await this.broadcast(s => s.sendPrCreatedNotification(context));
|
||||
}
|
||||
|
||||
async notifyPrReviewerAssigned(context: NotificationContext): Promise<void> {
|
||||
await this.broadcast(s => s.sendPrReviewerAssignedNotification(context));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 配置改造
|
||||
|
||||
### 5.1 新增配置字段
|
||||
|
||||
```typescript
|
||||
// config-schema.ts
|
||||
export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
// ... 保留原有 ...
|
||||
|
||||
// 飞书配置(改造为可独立启用)
|
||||
{
|
||||
envKey: 'FEISHU_ENABLED',
|
||||
group: 'notification',
|
||||
label: '启用飞书通知',
|
||||
description: '是否启用飞书通知',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
envKey: 'FEISHU_WEBHOOK_URL',
|
||||
group: 'notification',
|
||||
label: '飞书 Webhook 地址',
|
||||
description: '飞书机器人 Webhook URL',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
},
|
||||
{
|
||||
envKey: 'FEISHU_WEBHOOK_SECRET',
|
||||
group: 'notification',
|
||||
label: '飞书 Webhook 密钥',
|
||||
description: '飞书 Webhook 签名密钥(可选)',
|
||||
type: 'string',
|
||||
sensitive: true,
|
||||
},
|
||||
|
||||
// 企业微信配置(新增)
|
||||
{
|
||||
envKey: 'WECOM_ENABLED',
|
||||
group: 'notification',
|
||||
label: '启用企业微信通知',
|
||||
description: '是否启用企业微信通知',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
envKey: 'WECOM_WEBHOOK_URL',
|
||||
group: 'notification',
|
||||
label: '企业微信 Webhook 地址',
|
||||
description: '企业微信机器人 Webhook URL',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### 5.2 配置组调整
|
||||
|
||||
```typescript
|
||||
// 将 'feishu' 组改为 'notification' 组
|
||||
export type ConfigGroup = 'gitea' | 'notification' | 'security' | 'review' | 'memory';
|
||||
|
||||
export const CONFIG_GROUPS: ConfigGroupMeta[] = [
|
||||
// ...
|
||||
{
|
||||
key: 'notification',
|
||||
label: '通知服务',
|
||||
description: '飞书、企业微信等通知渠道配置',
|
||||
icon: 'bell',
|
||||
},
|
||||
// ...
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 调用层迁移
|
||||
|
||||
### 6.1 review.ts 改造
|
||||
|
||||
```typescript
|
||||
import { getNotificationManager } from '../services/notification-manager';
|
||||
|
||||
// PR事件处理
|
||||
async function handlePullRequestEvent(c: Context, body: any): Promise<Response> {
|
||||
// ... 原有逻辑 ...
|
||||
|
||||
const context: NotificationContext = {
|
||||
prTitle: pullRequest.title,
|
||||
prUrl: pullRequest.html_url,
|
||||
prNumber: pullRequest.number,
|
||||
reviewers: reviewerUsernames,
|
||||
repository: repo.name,
|
||||
owner: repo.owner.login,
|
||||
actor: body.sender?.login,
|
||||
};
|
||||
|
||||
const notificationManager = getNotificationManager();
|
||||
|
||||
if (body.action === 'opened' && reviewerUsernames.length > 0) {
|
||||
await notificationManager.notifyPrCreated(context);
|
||||
}
|
||||
|
||||
if (body.action === 'review_requested' && body.requested_reviewer) {
|
||||
context.assignees = [body.requested_reviewer.full_name || body.requested_reviewer.login];
|
||||
await notificationManager.notifyPrReviewerAssigned(context);
|
||||
}
|
||||
|
||||
// ... 继续原有逻辑 ...
|
||||
}
|
||||
|
||||
// Issue事件处理
|
||||
async function handleIssueEvent(c: Context, body: any): Promise<Response> {
|
||||
// ...
|
||||
|
||||
const context: NotificationContext = {
|
||||
issueTitle: issue.title,
|
||||
issueUrl: issue.html_url,
|
||||
issueNumber: issue.number,
|
||||
creator: creatorUsername,
|
||||
assignees: assigneeUsernames,
|
||||
repository: repository.name,
|
||||
actor: body.sender?.login,
|
||||
};
|
||||
|
||||
if (action === 'opened' && assigneeUsernames.length > 0) {
|
||||
await notificationManager.notifyIssueCreated(context);
|
||||
} else if (action === 'closed') {
|
||||
await notificationManager.notifyIssueClosed(context);
|
||||
} else if (action === 'assigned') {
|
||||
await notificationManager.notifyIssueAssigned(context);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 落地决策(已执行)
|
||||
|
||||
### 7.1 旧飞书服务下线
|
||||
|
||||
- 已删除 `src/services/feishu.ts`,不再保留兼容层。
|
||||
- `src/controllers/review.ts` 中所有通知发送路径已统一到 `NotificationManager`。
|
||||
- 通过单一通知入口避免重复发送与配置路径分裂问题。
|
||||
|
||||
### 7.2 运行时配置生效策略
|
||||
|
||||
- 通知管理器按当前配置即时创建,不再使用长生命周期缓存。
|
||||
- 后台保存通知配置后,可立即在后续 webhook 事件生效。
|
||||
|
||||
### 7.3 落地检查清单
|
||||
|
||||
- [x] 飞书与企业微信通过统一通知抽象发送
|
||||
- [x] 旧飞书服务文件已下线
|
||||
- [x] 控制器通知链路已去重
|
||||
- [x] 前端新增独立“通知管理”菜单与页面
|
||||
|
||||
---
|
||||
|
||||
## 8. 实施计划
|
||||
|
||||
### 8.1 阶段划分
|
||||
|
||||
| 阶段 | 任务 | 文件 | 优先级 |
|
||||
|------|------|------|--------|
|
||||
| 1 | 核心抽象层 | `types.ts`, `base-notification-service.ts` | P0 |
|
||||
| 2 | 工厂与管理器 | `notification-factory.ts`, `notification-manager.ts` | P0 |
|
||||
| 3 | 飞书实现 | `providers/feishu-notification-service.ts` | P1 |
|
||||
| 4 | 企业微信实现 | `providers/wecom-notification-service.ts` | P1 |
|
||||
| 5 | 配置改造 | `config-schema.ts`, `config-manager.ts` | P1 |
|
||||
| 6 | 调用层迁移 | `review.ts` | P1 |
|
||||
| 7 | 前端通知管理页面 | `App.tsx`, `DashboardPage.tsx`, `NotificationConfigPage.tsx` | P1 |
|
||||
| 8 | 测试验证 | `config-manager.test.ts` 等 | P0 |
|
||||
|
||||
### 8.2 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| 签名算法变更 | 飞书通知失效 | 保持原有签名实现,单元测试覆盖 |
|
||||
| 配置迁移失败 | 服务无法启动 | 添加配置验证和默认值回退 |
|
||||
| 多服务并发失败 | 部分通知丢失 | Promise.allSettled 确保独立性 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试策略
|
||||
|
||||
### 9.1 单元测试
|
||||
|
||||
```typescript
|
||||
// __tests__/notification.test.ts
|
||||
describe('NotificationService', () => {
|
||||
describe('FeishuNotificationService', () => {
|
||||
it('should generate correct signature', () => {
|
||||
// 测试签名算法
|
||||
});
|
||||
|
||||
it('should format message correctly', () => {
|
||||
// 测试消息格式转换
|
||||
});
|
||||
});
|
||||
|
||||
describe('WeComNotificationService', () => {
|
||||
it('should use mentioned_list for @users', () => {
|
||||
// 测试@用户格式
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationManager', () => {
|
||||
it('should broadcast to all enabled services', async () => {
|
||||
// 测试广播逻辑
|
||||
});
|
||||
|
||||
it('should not fail if one service fails', async () => {
|
||||
// 测试容错
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 9.2 集成测试
|
||||
|
||||
- 配置真实飞书机器人测试消息发送
|
||||
- 配置企业微信机器人测试消息发送
|
||||
- 验证同时配置多个服务时的行为
|
||||
|
||||
---
|
||||
|
||||
## 10. 附录
|
||||
|
||||
### 10.1 飞书与企业微信API对比详情
|
||||
|
||||
#### 飞书消息格式
|
||||
```json
|
||||
{
|
||||
"msg_type": "text",
|
||||
"content": {
|
||||
"text": "Hello <at user_id=\"ou_xxx\">Tom</at>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 企业微信消息格式
|
||||
```json
|
||||
{
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": "Hello World",
|
||||
"mentioned_list": ["wangqing", "@all"],
|
||||
"mentioned_mobile_list": ["13800001111"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 扩展指南
|
||||
|
||||
添加新通知平台步骤:
|
||||
|
||||
1. 在 `types.ts` 添加新的 `NotificationProvider` 类型
|
||||
2. 在 `providers/` 创建新的服务类,继承 `BaseNotificationService`
|
||||
3. 在 `notification-factory.ts` 添加创建逻辑
|
||||
4. 在 `config-schema.ts` 添加配置字段
|
||||
5. 在 Admin Dashboard 添加UI配置项
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: 1.0
|
||||
**创建日期**: 2026-03-24
|
||||
**作者**: Sisyphus
|
||||
**状态**: 已实施(持续验证中)
|
||||
@@ -823,7 +823,7 @@ Day 6.5: 旧代码清理完毕,文档更新,Ready for review
|
||||
# .env.example(仅保留启动参数)
|
||||
|
||||
# 应用启动参数(不可通过 UI 设置)
|
||||
PORT=3000
|
||||
PORT=5174
|
||||
WEBHOOK_SECRET=your_webhook_secret
|
||||
DATABASE_PATH=./data/assistant.db # SQLite 文件路径
|
||||
|
||||
|
||||
69
docs/getting-started.md
Normal file
69
docs/getting-started.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Getting Started
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Bun >= 1.2.5
|
||||
- A reachable Gitea instance
|
||||
- At least one LLM provider credential
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/user/gitea-ai-assistant.git
|
||||
cd gitea-ai-assistant
|
||||
bun install
|
||||
```
|
||||
|
||||
`bun install` at repository root installs frontend dependencies via `postinstall`.
|
||||
|
||||
If lifecycle scripts are disabled:
|
||||
|
||||
```bash
|
||||
bun run bootstrap
|
||||
```
|
||||
|
||||
## Minimal environment
|
||||
|
||||
Create `.env`:
|
||||
|
||||
```bash
|
||||
PORT=5174
|
||||
ENCRYPTION_KEY= # required, generate with: openssl rand -hex 32
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
# LOG_LEVEL=info # local dev default; use LOG_LEVEL=error in production
|
||||
```
|
||||
|
||||
> `ENCRYPTION_KEY` is required. Application startup fails when it is missing.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
# or
|
||||
bun run start
|
||||
```
|
||||
|
||||
## First login
|
||||
|
||||
- Open `http://your-server:5174`
|
||||
- Default admin password is `password` on first boot
|
||||
- Change admin password immediately after login
|
||||
|
||||
## Webhook setup
|
||||
|
||||
### Option A: Admin UI (recommended)
|
||||
|
||||
In repository list, click enable to auto-provision webhook.
|
||||
|
||||
### Option B: Manual
|
||||
|
||||
In Gitea repository settings:
|
||||
|
||||
- URL: `http://your-server:5174/webhook/gitea`
|
||||
- Content Type: `application/json`
|
||||
- Secret: same value as dashboard webhook secret
|
||||
- Events: Pull Request + Status
|
||||
|
||||
## Health endpoint
|
||||
|
||||
Use `/api/health` to check service status.
|
||||
69
docs/getting-started.zh-CN.md
Normal file
69
docs/getting-started.zh-CN.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# 快速开始
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Bun >= 1.2.5
|
||||
- 可访问的 Gitea 实例
|
||||
- 至少一个 LLM 提供商凭证
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
git clone https://github.com/user/gitea-ai-assistant.git
|
||||
cd gitea-ai-assistant
|
||||
bun install
|
||||
```
|
||||
|
||||
在仓库根目录执行 `bun install` 会通过 `postinstall` 自动安装 `frontend` 依赖。
|
||||
|
||||
如果你的环境禁用了生命周期脚本:
|
||||
|
||||
```bash
|
||||
bun run bootstrap
|
||||
```
|
||||
|
||||
## 最小环境变量
|
||||
|
||||
创建 `.env` 文件:
|
||||
|
||||
```bash
|
||||
PORT=5174
|
||||
ENCRYPTION_KEY= # 必填,使用 openssl rand -hex 32 生成
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
# LOG_LEVEL=info # 本地开发默认;生产环境请使用 LOG_LEVEL=error
|
||||
```
|
||||
|
||||
> `ENCRYPTION_KEY` 为必填项,缺失时服务会拒绝启动。
|
||||
|
||||
## 启动服务
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
# 或
|
||||
bun run start
|
||||
```
|
||||
|
||||
## 首次登录
|
||||
|
||||
- 访问 `http://your-server:5174`
|
||||
- 首次启动默认管理员密码为 `password`
|
||||
- 登录后请立即修改管理员密码
|
||||
|
||||
## Webhook 配置
|
||||
|
||||
### 方式 A:管理后台(推荐)
|
||||
|
||||
在仓库列表点击启用按钮,由系统自动配置 webhook。
|
||||
|
||||
### 方式 B:手动配置
|
||||
|
||||
在 Gitea 仓库设置中配置:
|
||||
|
||||
- URL:`http://your-server:5174/webhook/gitea`
|
||||
- Content Type:`application/json`
|
||||
- Secret:与管理后台中的 Webhook Secret 保持一致
|
||||
- 事件:Pull Request + Status
|
||||
|
||||
## 健康检查
|
||||
|
||||
可通过 `/api/health` 查看服务状态。
|
||||
46
docs/review-engines.md
Normal file
46
docs/review-engines.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Review Engines
|
||||
|
||||
## Overview
|
||||
|
||||
The system supports two engines:
|
||||
|
||||
- `agent`: native staged review pipeline
|
||||
- `codex`: Codex CLI-backed review pipeline
|
||||
|
||||
Engine is selected by `REVIEW_ENGINE` runtime configuration.
|
||||
|
||||
## Agent engine
|
||||
|
||||
Agent engine classifies changes and dispatches specialist tasks.
|
||||
|
||||
### Review modes
|
||||
|
||||
- `skip`: low-risk changes may bypass specialist review
|
||||
- `light`: minimal specialist checks for low-risk code changes
|
||||
- `full`: full specialist review for risky or larger changes
|
||||
|
||||
### Size policy
|
||||
|
||||
`small`/`medium`/`large` thresholds are used by triage to choose mode and token budgets.
|
||||
|
||||
## Codex engine
|
||||
|
||||
Codex engine runs review through Codex CLI with independent runtime settings:
|
||||
|
||||
- `CODEX_API_URL`
|
||||
- `CODEX_API_KEY`
|
||||
- `CODEX_MODEL`
|
||||
- `CODEX_TIMEOUT_MS`
|
||||
- `CODEX_REVIEW_PROMPT`
|
||||
|
||||
## Event support
|
||||
|
||||
Both engines process:
|
||||
|
||||
- Pull request webhook events
|
||||
- Commit status webhook events
|
||||
|
||||
## Output
|
||||
|
||||
- PR/commit summary comment
|
||||
- Line-level findings with confidence and severity
|
||||
46
docs/review-engines.zh-CN.md
Normal file
46
docs/review-engines.zh-CN.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# 审查引擎
|
||||
|
||||
## 概览
|
||||
|
||||
系统支持两种审查引擎:
|
||||
|
||||
- `agent`:原生任务化分级审查
|
||||
- `codex`:基于 Codex CLI 的审查流水线
|
||||
|
||||
通过运行时配置 `REVIEW_ENGINE` 选择引擎。
|
||||
|
||||
## Agent 引擎
|
||||
|
||||
Agent 引擎会先做变更分流,再按领域派发 specialist 任务。
|
||||
|
||||
### 审查模式
|
||||
|
||||
- `skip`:低风险改动可跳过 specialist
|
||||
- `light`:对低风险代码执行最小化专项检查
|
||||
- `full`:对高风险或大规模改动执行完整审查
|
||||
|
||||
### 规模策略
|
||||
|
||||
`small` / `medium` / `large` 阈值用于 triage 阶段决策模式与 token 预算。
|
||||
|
||||
## Codex 引擎
|
||||
|
||||
Codex 引擎通过 Codex CLI 执行审查,支持独立配置:
|
||||
|
||||
- `CODEX_API_URL`
|
||||
- `CODEX_API_KEY`
|
||||
- `CODEX_MODEL`
|
||||
- `CODEX_TIMEOUT_MS`
|
||||
- `CODEX_REVIEW_PROMPT`
|
||||
|
||||
## 事件支持
|
||||
|
||||
两种引擎都支持:
|
||||
|
||||
- Pull Request webhook 事件
|
||||
- Commit Status webhook 事件
|
||||
|
||||
## 输出
|
||||
|
||||
- PR/提交总结评论
|
||||
- 行级问题(含置信度与严重性)
|
||||
23
docs/screenshots.md
Normal file
23
docs/screenshots.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Screenshot Gallery
|
||||
|
||||
All screenshots are captured from local development service.
|
||||
|
||||
## Repository management (`/repos`)
|
||||
|
||||

|
||||
|
||||
## System configuration (`/config`)
|
||||
|
||||

|
||||
|
||||
## Notification management (`/notifications`)
|
||||
|
||||

|
||||
|
||||
## Review configuration (`/review-config`)
|
||||
|
||||

|
||||
|
||||
## Language
|
||||
|
||||
- 中文: [screenshots.zh-CN.md](./screenshots.zh-CN.md)
|
||||
23
docs/screenshots.zh-CN.md
Normal file
23
docs/screenshots.zh-CN.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# 截图集
|
||||
|
||||
以下截图来自本地开发环境。
|
||||
|
||||
## 仓库管理(`/repos`)
|
||||
|
||||

|
||||
|
||||
## 系统配置(`/config`)
|
||||
|
||||

|
||||
|
||||
## 通知管理(`/notifications`)
|
||||
|
||||

|
||||
|
||||
## 审查配置(`/review-config`)
|
||||
|
||||

|
||||
|
||||
## 语言切换
|
||||
|
||||
- English: [screenshots.md](./screenshots.md)
|
||||
@@ -11,6 +11,8 @@ RUN bun install --no-frozen-lockfile
|
||||
COPY src ./src
|
||||
COPY tsconfig.json .
|
||||
|
||||
EXPOSE 3000
|
||||
COPY frontend/dist ./public
|
||||
|
||||
EXPOSE 5174
|
||||
|
||||
CMD ["bun", "run", "start"]
|
||||
|
||||
169
e2e/__tests__/e2e-review.test.ts
Normal file
169
e2e/__tests__/e2e-review.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
E2ETestHarness,
|
||||
type Finding,
|
||||
type Scenario,
|
||||
type SessionDetail,
|
||||
} from './e2e-test-harness';
|
||||
|
||||
function assertFindingsMatchScenario(findings: Finding[], scenario: Scenario): void {
|
||||
expect(findings.length).toBeGreaterThanOrEqual(scenario.minFindings);
|
||||
|
||||
if (scenario.maxFindings !== undefined) {
|
||||
expect(findings.length).toBeLessThanOrEqual(scenario.maxFindings);
|
||||
}
|
||||
|
||||
const highSeverityCount = findings.filter((finding) => finding.severity === 'high').length;
|
||||
expect(highSeverityCount).toBeGreaterThanOrEqual(scenario.minHighSeverity);
|
||||
|
||||
const fingerprints = findings
|
||||
.map((finding) => finding.fingerprint)
|
||||
.filter((value): value is string => Boolean(value));
|
||||
expect(new Set(fingerprints).size).toBe(fingerprints.length);
|
||||
}
|
||||
|
||||
function expectPipelineStepsCompleted(detail: SessionDetail): void {
|
||||
const statusesByKey = new Map(detail.plan.map((step) => [step.key, step.status]));
|
||||
expect(statusesByKey.get('prepare_workspace')).toBe('completed');
|
||||
expect(statusesByKey.get('build_context')).toBe('completed');
|
||||
expect(statusesByKey.get('review:triage')).toBe('completed');
|
||||
expect(statusesByKey.get('review:full_review')).toBe('completed');
|
||||
expect(statusesByKey.get('aggregate_findings')).toBe('completed');
|
||||
expect(statusesByKey.get('publish_review')).toBe('completed');
|
||||
expect(statusesByKey.get('save_reviewed_ref')).toBe('completed');
|
||||
}
|
||||
|
||||
function expectAutonomousFullReviewPipeline(detail: SessionDetail): void {
|
||||
const fullReviewInvocations = detail.subagentInvocations.filter(
|
||||
(invocation) => invocation.subagentName === 'review:full_review'
|
||||
);
|
||||
expect(fullReviewInvocations).toHaveLength(1);
|
||||
expect(fullReviewInvocations[0].status).toBe('completed');
|
||||
expect(detail.checkpoint?.state?.reviewCompleted).toBe(true);
|
||||
expect(detail.checkpoint?.state?.published).toBe(true);
|
||||
expect(detail.checkpoint?.state?.reviewedRefSaved).toBe(true);
|
||||
expect(detail.checkpoint?.state?.reviewDiagnostics?.toolCallNames).toEqual([
|
||||
'search_code',
|
||||
'read_file',
|
||||
'read_file',
|
||||
]);
|
||||
expect(detail.checkpoint?.state?.reviewDiagnostics?.stopReason).toBe('modelFinalized');
|
||||
|
||||
const findings = detail.checkpoint?.state?.findings ?? [];
|
||||
expect(findings.length).toBeGreaterThan(0);
|
||||
expect(findings[0].detail).toContain('auth/user model');
|
||||
expect(findings[0].evidence).toContain('src/auth.ts');
|
||||
|
||||
const publishedComments = detail.runDetails?.comments?.filter(
|
||||
(comment) => comment.status === 'published'
|
||||
);
|
||||
expect(publishedComments?.length).toBeGreaterThan(0);
|
||||
expect(publishedComments?.some((comment) => !comment.path)).toBe(true);
|
||||
expect(publishedComments?.some((comment) => comment.path === 'src/user-handler.ts')).toBe(true);
|
||||
}
|
||||
|
||||
describe('E2E Review Flow', () => {
|
||||
const harness = new E2ETestHarness();
|
||||
|
||||
beforeAll(async () => {
|
||||
await harness.start();
|
||||
await harness.seedGitea();
|
||||
}, 90_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.stop();
|
||||
});
|
||||
|
||||
test('核心链路验证: webhook → clone → triage → full_review → aggregate → publish → save ref → Gitea has comments', async () => {
|
||||
const { owner, repo, prNumber } = await harness.seedPR('simple-bug-pr');
|
||||
|
||||
const webhookResponse = await harness.triggerWebhook(owner, repo, prNumber);
|
||||
expect(webhookResponse.status).toBe('accepted');
|
||||
|
||||
const result = await harness.waitForReview(owner, repo, prNumber, 120);
|
||||
expect(result.completed).toBe(true);
|
||||
expect(result.sessionState).toBe('completed');
|
||||
expectPipelineStepsCompleted(result.detail);
|
||||
expect(result.detail.checkpoint?.state?.published).toBe(true);
|
||||
expectAutonomousFullReviewPipeline(result.detail);
|
||||
|
||||
const comments = await harness.getGiteaComments(owner, repo, prNumber);
|
||||
expect(comments.length).toBeGreaterThan(0);
|
||||
}, 150_000);
|
||||
|
||||
test('状态正确性: session status transitions and checkpoint consistency', async () => {
|
||||
const { owner, repo, prNumber } = await harness.seedPR('security-pr');
|
||||
|
||||
await harness.triggerWebhook(owner, repo, prNumber);
|
||||
const snapshot = await harness.waitForSessionSnapshot(owner, repo, prNumber, 30);
|
||||
expect(['queued', 'planning', 'executing', 'completed']).toContain(
|
||||
snapshot.detail.summary.status
|
||||
);
|
||||
|
||||
const result = await harness.waitForReview(owner, repo, prNumber, 120);
|
||||
expect(['queued', 'planning', 'executing', 'completed']).toContain(result.observedStates[0]);
|
||||
expect(result.sessionState).toBe('completed');
|
||||
expect(result.detail.checkpoint?.stopReason).toBe('completed');
|
||||
expect(result.detail.checkpoint?.pendingTasks ?? []).toHaveLength(0);
|
||||
expect(result.detail.summary.findingCount).toBe(harness.extractFindings(result.detail).length);
|
||||
}, 150_000);
|
||||
|
||||
test('Findings 质量: fixtures trigger expected triage modes, autonomous full review, and finding counts', async () => {
|
||||
const fixtureNames = ['simple-bug-pr', 'minimal-change-pr'];
|
||||
|
||||
for (const fixtureName of fixtureNames) {
|
||||
const { owner, repo, prNumber, scenario } = await harness.seedPR(fixtureName);
|
||||
await harness.triggerWebhook(owner, repo, prNumber);
|
||||
const result = await harness.waitForReview(owner, repo, prNumber, 120);
|
||||
expect(result.sessionState).toBe('completed');
|
||||
|
||||
const triageMode = harness.extractTriageMode(result.detail);
|
||||
if (triageMode !== undefined) {
|
||||
expect(triageMode).toBe(scenario.expectedTriageMode);
|
||||
}
|
||||
|
||||
expectPipelineStepsCompleted(result.detail);
|
||||
expect(result.detail.subagentInvocations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ subagentName: 'review:full_review', status: 'completed' }),
|
||||
])
|
||||
);
|
||||
|
||||
assertFindingsMatchScenario(harness.extractFindings(result.detail), scenario);
|
||||
}
|
||||
}, 360_000);
|
||||
|
||||
test('幂等性: duplicate webhook does not create duplicate comments', async () => {
|
||||
const { owner, repo, prNumber } = await harness.seedPR('duplicate-webhook-pr');
|
||||
|
||||
await harness.triggerWebhook(owner, repo, prNumber);
|
||||
const firstResult = await harness.waitForReview(owner, repo, prNumber, 120);
|
||||
expect(firstResult.sessionState).toBe('completed');
|
||||
const firstComments = await harness.getGiteaComments(owner, repo, prNumber);
|
||||
expect(firstComments.length).toBeGreaterThan(0);
|
||||
|
||||
const duplicateWebhookResponse = await harness.triggerWebhook(owner, repo, prNumber);
|
||||
expect(['accepted', 'deduplicated']).toContain(duplicateWebhookResponse.status);
|
||||
const secondResult = await harness.waitForReview(owner, repo, prNumber, 60);
|
||||
expect(secondResult.sessionId).toBe(firstResult.sessionId);
|
||||
const secondComments = await harness.getGiteaComments(owner, repo, prNumber);
|
||||
|
||||
expect(secondComments.length).toBe(firstComments.length);
|
||||
expect(new Set(secondComments.map((comment) => comment.body)).size).toBe(
|
||||
new Set(firstComments.map((comment) => comment.body)).size
|
||||
);
|
||||
}, 180_000);
|
||||
|
||||
test('错误恢复: clone failure marks session failed, not stuck', async () => {
|
||||
const { owner, repo, prNumber } = await harness.seedPR('clean-refactor-pr');
|
||||
|
||||
await harness.triggerWebhook(owner, repo, prNumber, {
|
||||
repositoryPatch: {
|
||||
clone_url: `http://invalid-host-99999.local/${owner}/${repo}-missing.git`,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await harness.waitForReview(owner, repo, prNumber, 120);
|
||||
expect(['completed', 'failed']).toContain(result.sessionState);
|
||||
}, 150_000);
|
||||
});
|
||||
748
e2e/__tests__/e2e-test-harness.ts
Normal file
748
e2e/__tests__/e2e-test-harness.ts
Normal file
@@ -0,0 +1,748 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
||||
const WEBHOOK_SECRET = 'e2e-test-webhook-secret';
|
||||
const TERMINAL_STATES = new Set(['completed', 'failed', 'ignored', 'cancelled', 'error']);
|
||||
|
||||
type JsonPrimitive = string | number | boolean | null;
|
||||
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
interface GiteaUser {
|
||||
login: string;
|
||||
full_name?: string;
|
||||
}
|
||||
|
||||
interface GiteaRepo {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
clone_url: string;
|
||||
html_url: string;
|
||||
ssh_url?: string;
|
||||
owner: GiteaUser;
|
||||
}
|
||||
|
||||
interface GiteaPullRequest {
|
||||
id: number;
|
||||
number: number;
|
||||
title: string;
|
||||
html_url: string;
|
||||
head: {
|
||||
ref: string;
|
||||
sha: string;
|
||||
repo?: GiteaRepo;
|
||||
};
|
||||
base: {
|
||||
ref: string;
|
||||
sha: string;
|
||||
repo?: GiteaRepo;
|
||||
};
|
||||
requested_reviewers?: GiteaUser[];
|
||||
user?: GiteaUser;
|
||||
}
|
||||
|
||||
interface Scenario {
|
||||
name: string;
|
||||
description: string;
|
||||
expectedTriageMode: string;
|
||||
expectedDomains: string[];
|
||||
minFindings: number;
|
||||
maxFindings?: number;
|
||||
minHighSeverity: number;
|
||||
testIdempotency?: boolean;
|
||||
}
|
||||
|
||||
interface AdminLoginResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface SessionSummary {
|
||||
sessionId: string;
|
||||
owner?: string;
|
||||
repo?: string;
|
||||
prNumber?: number;
|
||||
status: string;
|
||||
findingCount: number;
|
||||
}
|
||||
|
||||
interface SessionListEntry {
|
||||
session: {
|
||||
id: string;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
};
|
||||
summary: SessionSummary;
|
||||
}
|
||||
|
||||
interface SessionListResponse {
|
||||
data: SessionListEntry[];
|
||||
}
|
||||
|
||||
interface Finding {
|
||||
severity?: string;
|
||||
confidence?: number;
|
||||
path?: string;
|
||||
line?: number;
|
||||
title?: string;
|
||||
detail?: string;
|
||||
evidence?: string;
|
||||
category?: string;
|
||||
domain?: string;
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
interface SessionDetail {
|
||||
session: {
|
||||
id: string;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
};
|
||||
summary: SessionSummary;
|
||||
checkpoint: {
|
||||
stopReason?: string;
|
||||
pendingTasks?: Array<{ name: string }>;
|
||||
state?: {
|
||||
targetSha?: string;
|
||||
triage?: {
|
||||
mode?: string;
|
||||
domains?: string[];
|
||||
};
|
||||
triageMode?: string;
|
||||
findings?: Finding[];
|
||||
published?: boolean;
|
||||
reviewedRefSaved?: boolean;
|
||||
reviewCompleted?: boolean;
|
||||
reviewedRef?: string;
|
||||
reviewDiagnostics?: {
|
||||
toolCallNames?: string[];
|
||||
toolCallCount?: number;
|
||||
parsedFindingCount?: number;
|
||||
stopReason?: string;
|
||||
};
|
||||
};
|
||||
} | null;
|
||||
plan: Array<{ key: string; status: string; label: string }>;
|
||||
events: Array<{ eventType: string; payload: Record<string, JsonValue> }>;
|
||||
runDetails: {
|
||||
findings?: Finding[];
|
||||
comments?: Array<{
|
||||
status?: string;
|
||||
path?: string;
|
||||
line?: number;
|
||||
body?: string;
|
||||
fingerprint?: string;
|
||||
}>;
|
||||
} | null;
|
||||
subagentInvocations: Array<{
|
||||
subagentName: string;
|
||||
status: string;
|
||||
result?: Record<string, JsonValue>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface GiteaTokenResponse {
|
||||
sha1?: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
interface CommentLike {
|
||||
id: number;
|
||||
body: string;
|
||||
path?: string;
|
||||
line?: number;
|
||||
}
|
||||
|
||||
interface SeedResult {
|
||||
owner: string;
|
||||
repo: string;
|
||||
prNumber: number;
|
||||
scenario: Scenario;
|
||||
}
|
||||
|
||||
interface ReviewWaitResult {
|
||||
completed: boolean;
|
||||
sessionState: string;
|
||||
sessionId: string;
|
||||
detail: SessionDetail;
|
||||
observedStates: string[];
|
||||
}
|
||||
|
||||
interface TriggerWebhookOptions {
|
||||
repositoryPatch?: Partial<GiteaRepo>;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
export class E2ETestHarness {
|
||||
readonly giteaUrl = (process.env.E2E_GITEA_URL ?? 'http://localhost:3333').replace(/\/$/, '');
|
||||
readonly adminUser = process.env.E2E_GITEA_ADMIN_USER ?? 'e2e-admin';
|
||||
readonly adminPass = process.env.E2E_GITEA_ADMIN_PASS ?? 'e2ePassword123!';
|
||||
|
||||
private assistantProcess?: Bun.Subprocess<'pipe', 'pipe', 'pipe'>;
|
||||
private assistantPort = 43100 + Math.floor(Math.random() * 1000);
|
||||
private tempDir = mkdtempSync(path.join(tmpdir(), 'e2e-assistant-'));
|
||||
private databasePath = path.join(this.tempDir, 'assistant.db');
|
||||
private reviewWorkDir = path.join(this.tempDir, 'review-workdir');
|
||||
private adminJwt?: string;
|
||||
private giteaToken?: string;
|
||||
private repoCounter = 0;
|
||||
|
||||
get assistantUrl(): string {
|
||||
return `http://127.0.0.1:${this.assistantPort}`;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.startAssistant();
|
||||
this.adminJwt = await this.getAdminJWT();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopAssistant();
|
||||
}
|
||||
|
||||
async startAssistant(): Promise<void> {
|
||||
if (this.assistantProcess) return;
|
||||
|
||||
this.assistantProcess = Bun.spawn(['bun', 'run', 'src/index.ts'], {
|
||||
cwd: path.resolve(import.meta.dir, '../..'),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
E2E_MOCK_LLM: '1',
|
||||
ENCRYPTION_KEY,
|
||||
DATABASE_PATH: this.databasePath,
|
||||
REVIEW_ENGINE: 'kernel',
|
||||
PORT: String(this.assistantPort),
|
||||
LOG_LEVEL: process.env.LOG_LEVEL ?? 'error',
|
||||
},
|
||||
});
|
||||
|
||||
this.drainProcessOutput(this.assistantProcess.stdout, 'assistant stdout');
|
||||
this.drainProcessOutput(this.assistantProcess.stderr, 'assistant stderr');
|
||||
await this.waitForAssistantHealth();
|
||||
}
|
||||
|
||||
stopAssistant(): void {
|
||||
if (this.assistantProcess) {
|
||||
this.assistantProcess.kill();
|
||||
this.assistantProcess = undefined;
|
||||
}
|
||||
|
||||
if (existsSync(this.tempDir)) {
|
||||
rmSync(this.tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async seedGitea(): Promise<void> {
|
||||
await this.waitForGitea();
|
||||
await this.ensureAdminUser();
|
||||
this.giteaToken = await this.createToken();
|
||||
await this.configureAssistant();
|
||||
}
|
||||
|
||||
async seedPR(scenarioName: string): Promise<SeedResult> {
|
||||
if (!this.giteaToken) {
|
||||
await this.seedGitea();
|
||||
}
|
||||
|
||||
const scenario = await this.readScenario(scenarioName);
|
||||
const owner = this.adminUser;
|
||||
const repo = `e2e-${scenarioName.replace(/[^a-z0-9-]/gi, '-')}-${Date.now()}-${this.repoCounter++}`;
|
||||
const baseBranch = 'main';
|
||||
const featureBranch = `feature/${scenarioName}-${this.repoCounter}`;
|
||||
|
||||
await this.createRepo(repo);
|
||||
await this.pushBranchWithFiles(
|
||||
owner,
|
||||
repo,
|
||||
baseBranch,
|
||||
await this.readFixtureFiles(scenarioName, 'base'),
|
||||
`test: seed ${scenario.name} base`
|
||||
);
|
||||
await this.pushBranchWithFiles(
|
||||
owner,
|
||||
repo,
|
||||
featureBranch,
|
||||
await this.readFixtureFiles(scenarioName, 'branch'),
|
||||
`feat: ${scenario.description}`
|
||||
);
|
||||
const pr = await this.createPullRequest(
|
||||
owner,
|
||||
repo,
|
||||
scenario.description,
|
||||
featureBranch,
|
||||
baseBranch
|
||||
);
|
||||
await this.createWebhook(owner, repo);
|
||||
|
||||
return { owner, repo, prNumber: pr.number, scenario };
|
||||
}
|
||||
|
||||
async triggerWebhook(
|
||||
owner: string,
|
||||
repo: string,
|
||||
prNumber: number,
|
||||
options: TriggerWebhookOptions = {}
|
||||
): Promise<{ status: string; runId?: string }> {
|
||||
const repository = await this.giteaFetch<GiteaRepo>(`/repos/${owner}/${repo}`);
|
||||
const pullRequest = await this.giteaFetch<GiteaPullRequest>(
|
||||
`/repos/${owner}/${repo}/pulls/${prNumber}`
|
||||
);
|
||||
const normalizedRepository = this.normalizeRepoUrls({
|
||||
...repository,
|
||||
...options.repositoryPatch,
|
||||
owner: repository.owner,
|
||||
});
|
||||
const payload = {
|
||||
action: options.action ?? 'opened',
|
||||
number: prNumber,
|
||||
pull_request: {
|
||||
...pullRequest,
|
||||
head: {
|
||||
...pullRequest.head,
|
||||
repo: pullRequest.head.repo ? this.normalizeRepoUrls(pullRequest.head.repo) : undefined,
|
||||
},
|
||||
base: {
|
||||
...pullRequest.base,
|
||||
repo: pullRequest.base.repo ? this.normalizeRepoUrls(pullRequest.base.repo) : undefined,
|
||||
},
|
||||
requested_reviewers: pullRequest.requested_reviewers ?? [],
|
||||
},
|
||||
repository: normalizedRepository,
|
||||
sender: repository.owner,
|
||||
};
|
||||
const body = JSON.stringify(payload);
|
||||
const signature = createHmac('sha256', WEBHOOK_SECRET).update(body).digest('hex');
|
||||
return this.fetchJson<{ status: string; runId?: string }>(
|
||||
`${this.assistantUrl}/webhook/gitea`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Gitea-Event': 'pull_request',
|
||||
'X-Gitea-Signature': signature,
|
||||
},
|
||||
body,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async waitForReview(
|
||||
owner: string,
|
||||
repo: string,
|
||||
prNumber: number,
|
||||
timeoutSeconds = 120
|
||||
): Promise<ReviewWaitResult> {
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
const observedStates: string[] = [];
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const entry = await this.findSession(owner, repo, prNumber);
|
||||
if (entry) {
|
||||
const status = entry.summary.status;
|
||||
if (observedStates.at(-1) !== status) observedStates.push(status);
|
||||
const detail = await this.getSessionDetail(entry.summary.sessionId);
|
||||
const detailStatus = detail.summary.status;
|
||||
if (observedStates.at(-1) !== detailStatus) observedStates.push(detailStatus);
|
||||
|
||||
if (TERMINAL_STATES.has(detailStatus)) {
|
||||
return {
|
||||
completed: detailStatus === 'completed',
|
||||
sessionState: detailStatus,
|
||||
sessionId: entry.summary.sessionId,
|
||||
detail,
|
||||
observedStates,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await this.sleep(2000);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out waiting for review ${owner}/${repo}#${prNumber}; observed states: ${observedStates.join(' -> ') || 'none'}`
|
||||
);
|
||||
}
|
||||
|
||||
async waitForSessionSnapshot(
|
||||
owner: string,
|
||||
repo: string,
|
||||
prNumber: number,
|
||||
timeoutSeconds = 30
|
||||
): Promise<{ entry: SessionListEntry; detail: SessionDetail }> {
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const entry = await this.findSession(owner, repo, prNumber);
|
||||
if (entry) {
|
||||
return { entry, detail: await this.getSessionDetail(entry.summary.sessionId) };
|
||||
}
|
||||
await this.sleep(500);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for session snapshot ${owner}/${repo}#${prNumber}`);
|
||||
}
|
||||
|
||||
async getAdminJWT(): Promise<string> {
|
||||
const response = await this.fetchJson<AdminLoginResponse>(
|
||||
`${this.assistantUrl}/admin/api/login`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: 'password' }),
|
||||
}
|
||||
);
|
||||
return response.token;
|
||||
}
|
||||
|
||||
async getSessionDetail(sessionId: string): Promise<SessionDetail> {
|
||||
return this.adminFetch<SessionDetail>(
|
||||
`/admin/api/review/sessions/${encodeURIComponent(sessionId)}`
|
||||
);
|
||||
}
|
||||
|
||||
async getGiteaComments(owner: string, repo: string, prNumber: number): Promise<CommentLike[]> {
|
||||
const issueComments = await this.giteaFetch<CommentLike[]>(
|
||||
`/repos/${owner}/${repo}/issues/${prNumber}/comments`
|
||||
);
|
||||
|
||||
const reviews = await this.giteaFetch<{ id: number }[]>(
|
||||
`/repos/${owner}/${repo}/pulls/${prNumber}/reviews`
|
||||
);
|
||||
const reviewCommentLists = await Promise.all(
|
||||
reviews.map((r) =>
|
||||
this.giteaFetch<CommentLike[]>(
|
||||
`/repos/${owner}/${repo}/pulls/${prNumber}/reviews/${r.id}/comments`
|
||||
).catch(() => [] as CommentLike[])
|
||||
)
|
||||
);
|
||||
const reviewComments = reviewCommentLists.flat();
|
||||
|
||||
return [...issueComments, ...reviewComments];
|
||||
}
|
||||
|
||||
extractFindings(detail: SessionDetail): Finding[] {
|
||||
return detail.checkpoint?.state?.findings ?? detail.runDetails?.findings ?? [];
|
||||
}
|
||||
|
||||
extractTriageMode(detail: SessionDetail): string | undefined {
|
||||
return detail.checkpoint?.state?.triage?.mode ?? detail.checkpoint?.state?.triageMode;
|
||||
}
|
||||
|
||||
extractDomains(detail: SessionDetail): string[] {
|
||||
const triageDomains = detail.checkpoint?.state?.triage?.domains;
|
||||
return triageDomains ?? [];
|
||||
}
|
||||
|
||||
private async configureAssistant(): Promise<void> {
|
||||
await this.putConfig({
|
||||
GITEA_API_URL: `${this.giteaUrl}/api/v1`,
|
||||
GITEA_ACCESS_TOKEN: this.requireToken(),
|
||||
GITEA_ADMIN_TOKEN: this.requireToken(),
|
||||
WEBHOOK_SECRET,
|
||||
REVIEW_ENGINE: 'kernel',
|
||||
REVIEW_WORKDIR: this.reviewWorkDir,
|
||||
REVIEW_COMMAND_TIMEOUT_MS: '30000',
|
||||
REVIEW_ALLOWED_COMMANDS: 'git,rg,cat,sed,wc',
|
||||
});
|
||||
}
|
||||
|
||||
private async putConfig(values: Record<string, string>): Promise<void> {
|
||||
const token = this.adminJwt ?? (await this.getAdminJWT());
|
||||
const response = await fetch(`${this.assistantUrl}/admin/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to configure assistant: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async findSession(
|
||||
owner: string,
|
||||
repo: string,
|
||||
prNumber: number
|
||||
): Promise<SessionListEntry | undefined> {
|
||||
const payload = await this.adminFetch<SessionListResponse>(
|
||||
'/admin/api/review/sessions?limit=100'
|
||||
);
|
||||
return payload.data.find((entry) => {
|
||||
const metadata = entry.session.metadata ?? {};
|
||||
const metadataOwner = typeof metadata.owner === 'string' ? metadata.owner : undefined;
|
||||
const metadataRepo = typeof metadata.repo === 'string' ? metadata.repo : undefined;
|
||||
const metadataPr =
|
||||
typeof metadata.prNumber === 'number' ? metadata.prNumber : Number(metadata.prNumber);
|
||||
return (
|
||||
(entry.summary.owner ?? metadataOwner) === owner &&
|
||||
(entry.summary.repo ?? metadataRepo) === repo &&
|
||||
(entry.summary.prNumber ?? metadataPr) === prNumber
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async adminFetch<T>(apiPath: string): Promise<T> {
|
||||
const token = this.adminJwt ?? (await this.getAdminJWT());
|
||||
return this.fetchJson<T>(`${this.assistantUrl}${apiPath}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
private async waitForAssistantHealth(): Promise<void> {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`${this.assistantUrl}/api/health`);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
await this.sleep(2000);
|
||||
}
|
||||
}
|
||||
throw new Error(`Assistant did not become healthy at ${this.assistantUrl}`);
|
||||
}
|
||||
|
||||
private async waitForGitea(): Promise<void> {
|
||||
const deadline = Date.now() + 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`${this.giteaUrl}/api/v1/version`);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
await this.sleep(2000);
|
||||
}
|
||||
await this.sleep(2000);
|
||||
}
|
||||
throw new Error(`Gitea did not become available at ${this.giteaUrl}`);
|
||||
}
|
||||
|
||||
private async ensureAdminUser(): Promise<void> {
|
||||
const loginCheck = await fetch(`${this.giteaUrl}/api/v1/user`, {
|
||||
headers: { Authorization: `Basic ${btoa(`${this.adminUser}:${this.adminPass}`)}` },
|
||||
});
|
||||
if (loginCheck.ok) return;
|
||||
|
||||
const body = JSON.stringify({
|
||||
username: this.adminUser,
|
||||
password: this.adminPass,
|
||||
email: `${this.adminUser}@e2e-test.local`,
|
||||
must_change_password: false,
|
||||
login_name: this.adminUser,
|
||||
admin_permission: true,
|
||||
});
|
||||
|
||||
for (const [user, pass] of [
|
||||
[this.adminUser, this.adminPass],
|
||||
['root', 'root'],
|
||||
] as const) {
|
||||
const response = await fetch(`${this.giteaUrl}/api/v1/admin/users`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Basic ${btoa(`${user}:${pass}`)}`,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (response.ok || response.status === 422 || response.status === 409) return;
|
||||
}
|
||||
|
||||
const retryLogin = await fetch(`${this.giteaUrl}/api/v1/user`, {
|
||||
headers: { Authorization: `Basic ${btoa(`${this.adminUser}:${this.adminPass}`)}` },
|
||||
});
|
||||
if (!retryLogin.ok) {
|
||||
throw new Error(
|
||||
`Unable to create or authenticate Gitea admin user: ${retryLogin.status} ${await retryLogin.text()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async createToken(): Promise<string> {
|
||||
const response = await fetch(
|
||||
`${this.giteaUrl}/api/v1/users/${encodeURIComponent(this.adminUser)}/tokens`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Basic ${btoa(`${this.adminUser}:${this.adminPass}`)}`,
|
||||
},
|
||||
body: JSON.stringify({ name: `e2e-token-${Date.now()}`, scopes: ['all'] }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create Gitea token: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as GiteaTokenResponse;
|
||||
const token = payload.sha1 ?? payload.token;
|
||||
if (!token) throw new Error('Gitea token response did not include sha1/token');
|
||||
return token;
|
||||
}
|
||||
|
||||
private async createRepo(name: string): Promise<GiteaRepo> {
|
||||
return this.giteaFetch<GiteaRepo>('/user/repos', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, auto_init: true, default_branch: 'main' }),
|
||||
});
|
||||
}
|
||||
|
||||
private async createPullRequest(
|
||||
owner: string,
|
||||
repo: string,
|
||||
description: string,
|
||||
head: string,
|
||||
base: string
|
||||
): Promise<GiteaPullRequest> {
|
||||
return this.giteaFetch<GiteaPullRequest>(`/repos/${owner}/${repo}/pulls`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: `E2E: ${description}`,
|
||||
body: `E2E test PR: ${description}`,
|
||||
head,
|
||||
base,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
private async createWebhook(owner: string, repo: string): Promise<void> {
|
||||
await this.giteaFetch<JsonValue>(`/repos/${owner}/${repo}/hooks`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: 'gitea',
|
||||
active: true,
|
||||
events: ['pull_request'],
|
||||
config: {
|
||||
url: `${this.assistantUrl}/webhook/gitea`,
|
||||
content_type: 'json',
|
||||
secret: WEBHOOK_SECRET,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
private async giteaFetch<T>(apiPath: string, init: RequestInit = {}): Promise<T> {
|
||||
return this.fetchJson<T>(`${this.giteaUrl}/api/v1${apiPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `token ${this.requireToken()}`,
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchJson<T>(url: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for ${url}: ${await response.text()}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
private async readScenario(scenarioName: string): Promise<Scenario> {
|
||||
const scenarioPath = path.join(this.fixturesDir(), scenarioName, 'scenario.json');
|
||||
return JSON.parse(await readFile(scenarioPath, 'utf-8')) as Scenario;
|
||||
}
|
||||
|
||||
private async readFixtureFiles(
|
||||
scenarioName: string,
|
||||
fixturePart: 'base' | 'branch'
|
||||
): Promise<Record<string, string>> {
|
||||
const dir = path.join(this.fixturesDir(), scenarioName, fixturePart);
|
||||
const files: Record<string, string> = {};
|
||||
const glob = new Bun.Glob('**/*');
|
||||
|
||||
for await (const file of glob.scan({ cwd: dir, onlyFiles: true })) {
|
||||
files[file] = await readFile(path.join(dir, file), 'utf-8');
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private async pushBranchWithFiles(
|
||||
owner: string,
|
||||
repo: string,
|
||||
branchName: string,
|
||||
files: Record<string, string>,
|
||||
commitMessage: string
|
||||
): Promise<void> {
|
||||
const tmpDir = mkdtempSync(
|
||||
path.join(tmpdir(), `e2e-push-${branchName.replace(/[^a-z0-9-]/gi, '-')}-`)
|
||||
);
|
||||
const cloneUrl = `${this.giteaUrl.replace('http://', `http://${this.adminUser}:${this.adminPass}@`)}/${owner}/${repo}.git`;
|
||||
|
||||
try {
|
||||
await this.exec(['git', 'clone', cloneUrl, tmpDir]);
|
||||
await this.exec(['git', 'checkout', '-B', branchName], tmpDir);
|
||||
|
||||
for (const [filePath, content] of Object.entries(files)) {
|
||||
const destination = path.join(tmpDir, filePath);
|
||||
mkdirSync(path.dirname(destination), { recursive: true });
|
||||
await Bun.write(destination, content);
|
||||
}
|
||||
|
||||
await this.exec(['git', 'config', 'user.email', 'e2e@test.local'], tmpDir);
|
||||
await this.exec(['git', 'config', 'user.name', 'E2E Bot'], tmpDir);
|
||||
await this.exec(['git', 'add', '-A'], tmpDir);
|
||||
await this.exec(['git', 'commit', '-m', commitMessage, '--allow-empty'], tmpDir);
|
||||
await this.exec(['git', 'push', 'origin', branchName, '--force'], tmpDir);
|
||||
} finally {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
private async exec(args: string[], cwd?: string): Promise<void> {
|
||||
const proc = Bun.spawn(args, { cwd, stdout: 'pipe', stderr: 'pipe' });
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Command failed (${args.join(' ')}):\n${stdout}\n${stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
private fixturesDir(): string {
|
||||
return path.resolve(import.meta.dir, '../fixtures');
|
||||
}
|
||||
|
||||
private normalizeRepoUrls(repo: GiteaRepo): GiteaRepo {
|
||||
return {
|
||||
...repo,
|
||||
clone_url: this.normalizeGiteaUrl(repo.clone_url),
|
||||
html_url: this.normalizeGiteaUrl(repo.html_url),
|
||||
ssh_url: repo.ssh_url ? this.normalizeGiteaUrl(repo.ssh_url) : repo.ssh_url,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeGiteaUrl(value: string): string {
|
||||
return value.replace('http://gitea:3000', this.giteaUrl);
|
||||
}
|
||||
|
||||
private requireToken(): string {
|
||||
if (!this.giteaToken) throw new Error('Gitea token is not initialized');
|
||||
return this.giteaToken;
|
||||
}
|
||||
|
||||
private drainProcessOutput(stream: ReadableStream<Uint8Array>, label: string): void {
|
||||
void new Response(stream).text().then((output) => {
|
||||
if (output.trim().length > 0 && process.env.E2E_DEBUG === '1') {
|
||||
console.log(`[${label}] ${output}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
export type { Finding, ReviewWaitResult, Scenario, SeedResult, SessionDetail };
|
||||
21
e2e/fixtures/clean-refactor-pr/base/src/service.ts
Normal file
21
e2e/fixtures/clean-refactor-pr/base/src/service.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
interface Order {
|
||||
id: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface Invoice {
|
||||
id: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function summarizeOrder(order: Order): string {
|
||||
const rounded = Math.round(order.total * 100) / 100;
|
||||
const formatted = rounded.toFixed(2);
|
||||
return `Order ${order.id}: $${formatted}`;
|
||||
}
|
||||
|
||||
export function summarizeInvoice(invoice: Invoice): string {
|
||||
const rounded = Math.round(invoice.total * 100) / 100;
|
||||
const formatted = rounded.toFixed(2);
|
||||
return `Invoice ${invoice.id}: $${formatted}`;
|
||||
}
|
||||
22
e2e/fixtures/clean-refactor-pr/branch/src/service.ts
Normal file
22
e2e/fixtures/clean-refactor-pr/branch/src/service.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
interface Order {
|
||||
id: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface Invoice {
|
||||
id: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
function formatCurrency(total: number): string {
|
||||
const rounded = Math.round(total * 100) / 100;
|
||||
return rounded.toFixed(2);
|
||||
}
|
||||
|
||||
export function summarizeOrder(order: Order): string {
|
||||
return `Order ${order.id}: $${formatCurrency(order.total)}`;
|
||||
}
|
||||
|
||||
export function summarizeInvoice(invoice: Invoice): string {
|
||||
return `Invoice ${invoice.id}: $${formatCurrency(invoice.total)}`;
|
||||
}
|
||||
9
e2e/fixtures/clean-refactor-pr/scenario.json
Normal file
9
e2e/fixtures/clean-refactor-pr/scenario.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "clean-refactor-pr",
|
||||
"description": "正确的重构",
|
||||
"expectedTriageMode": "light",
|
||||
"expectedDomains": ["correctness"],
|
||||
"minFindings": 0,
|
||||
"maxFindings": 1,
|
||||
"minHighSeverity": 0
|
||||
}
|
||||
7
e2e/fixtures/docs-only-pr/base/src/app.ts
Normal file
7
e2e/fixtures/docs-only-pr/base/src/app.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function startApp(): string {
|
||||
return 'sunny-cactus app started';
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
console.log(startApp());
|
||||
}
|
||||
7
e2e/fixtures/docs-only-pr/branch/README.md
Normal file
7
e2e/fixtures/docs-only-pr/branch/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Sunny Cactus Demo
|
||||
|
||||
This fixture updates documentation only. It explains how to start the sample app and does not change runtime behavior.
|
||||
|
||||
## Usage
|
||||
|
||||
Run the application entrypoint and verify that it prints a startup message.
|
||||
8
e2e/fixtures/docs-only-pr/scenario.json
Normal file
8
e2e/fixtures/docs-only-pr/scenario.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "docs-only-pr",
|
||||
"description": "纯文档变更",
|
||||
"expectedTriageMode": "skip",
|
||||
"expectedDomains": [],
|
||||
"minFindings": 0,
|
||||
"minHighSeverity": 0
|
||||
}
|
||||
22
e2e/fixtures/duplicate-webhook-pr/base/src/auth.ts
Normal file
22
e2e/fixtures/duplicate-webhook-pr/base/src/auth.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'user' | 'admin';
|
||||
}
|
||||
|
||||
const users = new Map<string, User>([
|
||||
['token-user', { id: 'u1', name: 'Alice', role: 'user' }],
|
||||
['token-admin', { id: 'u2', name: 'Bob', role: 'admin' }],
|
||||
]);
|
||||
|
||||
export function authenticate(token: string): User | null {
|
||||
if (!token.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return users.get(token) ?? null;
|
||||
}
|
||||
|
||||
export function requireAdmin(user: User | null): boolean {
|
||||
return user?.role === 'admin';
|
||||
}
|
||||
20
e2e/fixtures/duplicate-webhook-pr/branch/src/user-handler.ts
Normal file
20
e2e/fixtures/duplicate-webhook-pr/branch/src/user-handler.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
interface UserRecord {
|
||||
id: string;
|
||||
email: string;
|
||||
profile?: {
|
||||
displayName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Database {
|
||||
query<T = unknown>(sql: string): Promise<T[]>;
|
||||
}
|
||||
|
||||
export async function getUserDisplayName(user: UserRecord | null): Promise<string> {
|
||||
return user.profile!.displayName!.toUpperCase();
|
||||
}
|
||||
|
||||
export async function findUserByEmail(db: Database, email: string): Promise<UserRecord | null> {
|
||||
const rows = await db.query<UserRecord>(`SELECT * FROM users WHERE email = '${email}'`);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
9
e2e/fixtures/duplicate-webhook-pr/scenario.json
Normal file
9
e2e/fixtures/duplicate-webhook-pr/scenario.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "duplicate-webhook-pr",
|
||||
"description": "重复webhook幂等性测试",
|
||||
"expectedTriageMode": "light",
|
||||
"expectedDomains": ["correctness"],
|
||||
"minFindings": 1,
|
||||
"minHighSeverity": 0,
|
||||
"testIdempotency": true
|
||||
}
|
||||
15
e2e/fixtures/minimal-change-pr/base/src/utils.ts
Normal file
15
e2e/fixtures/minimal-change-pr/base/src/utils.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export function normalizeScore(score: number): number {
|
||||
if (score < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (score > 100) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return Math.floor(score);
|
||||
}
|
||||
|
||||
export function formatUserName(firstName: string, lastName: string): string {
|
||||
return `${firstName} ${lastName}`.trim();
|
||||
}
|
||||
15
e2e/fixtures/minimal-change-pr/branch/src/utils.ts
Normal file
15
e2e/fixtures/minimal-change-pr/branch/src/utils.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export function normalizeScore(score: number): number {
|
||||
if (score <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (score >= 100) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return Math.floor(score);
|
||||
}
|
||||
|
||||
export function formatUserName(firstName: string, lastName: string): string {
|
||||
return `${firstName} ${lastName}`.trim();
|
||||
}
|
||||
9
e2e/fixtures/minimal-change-pr/scenario.json
Normal file
9
e2e/fixtures/minimal-change-pr/scenario.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "minimal-change-pr",
|
||||
"description": "单文件微量变更",
|
||||
"expectedTriageMode": "light",
|
||||
"expectedDomains": ["correctness"],
|
||||
"minFindings": 0,
|
||||
"maxFindings": 3,
|
||||
"minHighSeverity": 0
|
||||
}
|
||||
12
e2e/fixtures/security-pr/base/src/auth.ts
Normal file
12
e2e/fixtures/security-pr/base/src/auth.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface TokenPayload {
|
||||
sub: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export function verifyToken(token: string, expectedToken: string): boolean {
|
||||
return token.length > 0 && token === expectedToken;
|
||||
}
|
||||
|
||||
export function isExpired(payload: TokenPayload, now = Date.now()): boolean {
|
||||
return payload.exp * 1000 <= now;
|
||||
}
|
||||
14
e2e/fixtures/security-pr/branch/src/auth.ts
Normal file
14
e2e/fixtures/security-pr/branch/src/auth.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface TokenPayload {
|
||||
sub: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
const FALLBACK_ADMIN_TOKEN = 'admin-super-secret-token';
|
||||
|
||||
export function verifyToken(token: string, expectedToken: string): boolean {
|
||||
return token.length > 0 && (token === expectedToken || token === FALLBACK_ADMIN_TOKEN);
|
||||
}
|
||||
|
||||
export function isExpired(payload: TokenPayload, now = Date.now()): boolean {
|
||||
return payload.exp * 1000 <= now;
|
||||
}
|
||||
8
e2e/fixtures/security-pr/scenario.json
Normal file
8
e2e/fixtures/security-pr/scenario.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "security-pr",
|
||||
"description": "安全相关变更",
|
||||
"expectedTriageMode": "light",
|
||||
"expectedDomains": ["correctness"],
|
||||
"minFindings": 1,
|
||||
"minHighSeverity": 0
|
||||
}
|
||||
22
e2e/fixtures/simple-bug-pr/base/src/auth.ts
Normal file
22
e2e/fixtures/simple-bug-pr/base/src/auth.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'user' | 'admin';
|
||||
}
|
||||
|
||||
const users = new Map<string, User>([
|
||||
['token-user', { id: 'u1', name: 'Alice', role: 'user' }],
|
||||
['token-admin', { id: 'u2', name: 'Bob', role: 'admin' }],
|
||||
]);
|
||||
|
||||
export function authenticate(token: string): User | null {
|
||||
if (!token.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return users.get(token) ?? null;
|
||||
}
|
||||
|
||||
export function requireAdmin(user: User | null): boolean {
|
||||
return user?.role === 'admin';
|
||||
}
|
||||
22
e2e/fixtures/simple-bug-pr/branch/src/auth.ts
Normal file
22
e2e/fixtures/simple-bug-pr/branch/src/auth.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'user' | 'admin';
|
||||
}
|
||||
|
||||
const users = new Map<string, User>([
|
||||
['token-user', { id: 'u1', name: 'Alice', role: 'user' }],
|
||||
['token-admin', { id: 'u2', name: 'Bob', role: 'admin' }],
|
||||
]);
|
||||
|
||||
export function authenticate(token: string): User | null {
|
||||
if (!token.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return users.get(token) ?? null;
|
||||
}
|
||||
|
||||
export function requireAdmin(user: User | null): boolean {
|
||||
return user?.role === 'admin';
|
||||
}
|
||||
39
e2e/fixtures/simple-bug-pr/branch/src/user-handler.ts
Normal file
39
e2e/fixtures/simple-bug-pr/branch/src/user-handler.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { User } from './auth';
|
||||
|
||||
interface UserRecord {
|
||||
id: string;
|
||||
email: string;
|
||||
profile?: {
|
||||
displayName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Database {
|
||||
query<T = unknown>(sql: string): Promise<T[]>;
|
||||
}
|
||||
|
||||
export async function getUserDisplayName(user: UserRecord | null): Promise<string> {
|
||||
return user.profile!.displayName!.toUpperCase();
|
||||
}
|
||||
|
||||
export async function findUserByEmail(db: Database, email: string): Promise<UserRecord | null> {
|
||||
const rows = await db.query<UserRecord>(`SELECT * FROM users WHERE email = '${email}'`);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export function validateUserRole(user: User | null, requiredRole: string): boolean {
|
||||
const hardcodedSecret = 'sk-abc123secretkey456';
|
||||
if (hardcodedSecret) {
|
||||
return user?.role === requiredRole;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function deleteUser(users: Map<string, User>, userId: string): Map<string, User> {
|
||||
const user = users.get(userId);
|
||||
if (user!.role === 'admin') {
|
||||
throw new Error('Cannot delete admin user');
|
||||
}
|
||||
users.delete(userId);
|
||||
return users;
|
||||
}
|
||||
8
e2e/fixtures/simple-bug-pr/scenario.json
Normal file
8
e2e/fixtures/simple-bug-pr/scenario.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "simple-bug-pr",
|
||||
"description": "包含空指针、SQL注入、硬编码密钥的PR",
|
||||
"expectedTriageMode": "light",
|
||||
"expectedDomains": ["correctness"],
|
||||
"minFindings": 2,
|
||||
"minHighSeverity": 1
|
||||
}
|
||||
104
e2e/llm-mock.test.ts
Normal file
104
e2e/llm-mock.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { createMockChatForRole, isE2EMockActive } from './llm-mock';
|
||||
|
||||
describe('LLM Mock', () => {
|
||||
test('specialist role returns preset findings', async () => {
|
||||
const mock = createMockChatForRole();
|
||||
const response = await mock('specialist', {
|
||||
messages: [
|
||||
{ role: 'system', content: 'You are a code reviewer' },
|
||||
{ role: 'user', content: 'Review this code' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.finishReason).toBe('stop');
|
||||
expect(response.toolCalls).toEqual([]);
|
||||
const parsed = JSON.parse(response.content!);
|
||||
expect(parsed.findings).toBeDefined();
|
||||
expect(parsed.findings.length).toBeGreaterThanOrEqual(1);
|
||||
expect(parsed.findings[0].severity).toBe('high');
|
||||
expect(parsed.findings[0].path).toBe('src/user-handler.ts');
|
||||
});
|
||||
|
||||
test('specialist role simulates autonomous search and cross-file reads when tools are available', async () => {
|
||||
const mock = createMockChatForRole();
|
||||
const tools = [
|
||||
{
|
||||
name: 'search_code',
|
||||
description: 'search',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
{ name: 'read_file', description: 'read', parameters: { type: 'object', properties: {} } },
|
||||
];
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: 'You are a code reviewer' },
|
||||
{ role: 'user' as const, content: 'Review this code' },
|
||||
];
|
||||
|
||||
const turn1 = await mock('specialist', { messages, tools });
|
||||
expect(turn1.finishReason).toBe('tool_calls');
|
||||
expect(turn1.toolCalls.map((toolCall) => toolCall.name)).toEqual(['search_code']);
|
||||
|
||||
const turn2 = await mock('specialist', {
|
||||
messages: [
|
||||
...messages,
|
||||
{ role: 'assistant', content: '', toolCalls: turn1.toolCalls },
|
||||
{ role: 'tool', toolCallId: 'e2e_search_user_handler', content: '{"matches":[]}' },
|
||||
],
|
||||
tools,
|
||||
});
|
||||
expect(turn2.toolCalls.map((toolCall) => toolCall.name)).toEqual(['read_file']);
|
||||
expect(JSON.parse(turn2.toolCalls[0].arguments)).toEqual({ file_path: 'src/user-handler.ts' });
|
||||
|
||||
const turn3 = await mock('specialist', {
|
||||
messages: [
|
||||
...messages,
|
||||
{ role: 'tool', toolCallId: 'e2e_search_user_handler', content: '{"matches":[]}' },
|
||||
{ role: 'tool', toolCallId: 'e2e_read_caller', content: '{"path":"src/user-handler.ts"}' },
|
||||
],
|
||||
tools,
|
||||
});
|
||||
expect(turn3.toolCalls.map((toolCall) => toolCall.name)).toEqual(['read_file']);
|
||||
expect(JSON.parse(turn3.toolCalls[0].arguments)).toEqual({ file_path: 'src/auth.ts' });
|
||||
|
||||
const turn4 = await mock('specialist', {
|
||||
messages: [
|
||||
...messages,
|
||||
{ role: 'tool', toolCallId: 'e2e_search_user_handler', content: '{"matches":[]}' },
|
||||
{ role: 'tool', toolCallId: 'e2e_read_caller', content: '{"path":"src/user-handler.ts"}' },
|
||||
{ role: 'tool', toolCallId: 'e2e_read_callee', content: '{"path":"src/auth.ts"}' },
|
||||
],
|
||||
tools,
|
||||
});
|
||||
expect(turn4.finishReason).toBe('stop');
|
||||
expect(turn4.toolCalls).toEqual([]);
|
||||
const parsed = JSON.parse(turn4.content!);
|
||||
expect(parsed.findings[0].detail).toContain('auth/user model');
|
||||
expect(parsed.findings[0].evidence).toContain('src/auth.ts');
|
||||
});
|
||||
|
||||
test('planner role returns preset summary', async () => {
|
||||
const mock = createMockChatForRole();
|
||||
const response = await mock('planner', {
|
||||
messages: [{ role: 'user', content: 'Summarize this diff' }],
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(response.content!);
|
||||
expect(parsed.summary).toBeDefined();
|
||||
expect(parsed.keyConcerns).toBeDefined();
|
||||
});
|
||||
|
||||
test('isE2EMockActive returns true when E2E_MOCK_LLM=1', () => {
|
||||
const orig = process.env.E2E_MOCK_LLM;
|
||||
process.env.E2E_MOCK_LLM = '1';
|
||||
expect(isE2EMockActive()).toBe(true);
|
||||
process.env.E2E_MOCK_LLM = orig;
|
||||
});
|
||||
|
||||
test('isE2EMockActive returns false when E2E_MOCK_LLM is not set', () => {
|
||||
const orig = process.env.E2E_MOCK_LLM;
|
||||
process.env.E2E_MOCK_LLM = undefined;
|
||||
expect(isE2EMockActive()).toBe(false);
|
||||
if (orig !== undefined) process.env.E2E_MOCK_LLM = orig;
|
||||
});
|
||||
});
|
||||
1
e2e/llm-mock.ts
Normal file
1
e2e/llm-mock.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createMockChatForRole, isE2EMockActive } from '../src/llm/e2e-mock';
|
||||
57
e2e/seed.sh
57
e2e/seed.sh
@@ -26,12 +26,12 @@ for i in $(seq 1 30); do
|
||||
done
|
||||
|
||||
echo "=== [2/6] 创建管理员用户 ==="
|
||||
docker exec e2e-gitea gitea admin user create \
|
||||
docker exec -u git e2e-gitea gitea admin user create \
|
||||
--username "${ADMIN_USER}" \
|
||||
--password "${ADMIN_PASS}" \
|
||||
--email "${ADMIN_EMAIL}" \
|
||||
--admin \
|
||||
--must-change-password=false 2>/dev/null || echo " 用户已存在,跳过"
|
||||
--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" \
|
||||
@@ -120,37 +120,43 @@ ADMIN_DEFAULT_PASS="password"
|
||||
|
||||
# Wait for assistant to be healthy
|
||||
for i in $(seq 1 20); do
|
||||
if curl -sf "${ASSISTANT_URL}/" > /dev/null 2>&1; then
|
||||
echo " Assistant 已就绪"
|
||||
if curl -sf "${ASSISTANT_URL}/api/health" > /dev/null 2>&1; then
|
||||
echo " Assistant 已就绪"
|
||||
break
|
||||
fi
|
||||
echo " 等待 Assistant... ($i/20)"
|
||||
echo " 等待 Assistant... ($i/20)"
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# Login to get JWT
|
||||
LOGIN_RESP=$(curl -sf -X POST "${ASSISTANT_URL}/admin/login" \
|
||||
# Login to get JWT (正确路径: /admin/api/login)
|
||||
LOGIN_RESP=$(curl -sf -X POST "${ASSISTANT_URL}/admin/api/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"password\": \"${ADMIN_DEFAULT_PASS}\"}" 2>/dev/null || true)
|
||||
ADMIN_JWT=$(echo "${LOGIN_RESP}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -z "${ADMIN_JWT}" ]; then
|
||||
echo " WARNING: 无法获取管理员 JWT,跳过 assistant 配置"
|
||||
echo " WARNING: 无法获取管理员 JWT,跳过 assistant 配置"
|
||||
else
|
||||
echo " JWT 获取成功,配置 assistant 设置..."
|
||||
curl -sf -X PUT "${ASSISTANT_URL}/admin/config" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" \
|
||||
-d "{
|
||||
\"WEBHOOK_SECRET\": \"${WEBHOOK_SECRET}\",
|
||||
\"GITEA_API_URL\": \"http://gitea:3000/api/v1\",
|
||||
\"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\"
|
||||
}" > /dev/null 2>&1 && echo " Assistant 配置完成" || echo " WARNING: assistant 配置失败"
|
||||
echo " JWT 获取成功,配置 assistant 设置..."
|
||||
|
||||
# 逐项配置(避免 JSON 格式化问题)
|
||||
set_assistant_config() {
|
||||
local key="$1" value="$2"
|
||||
curl -sf -X PUT "${ASSISTANT_URL}/admin/api/config" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" \
|
||||
-d "{\"${key}\": \"${value}\"}" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
set_assistant_config "WEBHOOK_SECRET" "${WEBHOOK_SECRET}"
|
||||
set_assistant_config "GITEA_API_URL" "http://gitea:3000/api/v1"
|
||||
set_assistant_config "GITEA_ACCESS_TOKEN" "${GITEA_TOKEN}"
|
||||
set_assistant_config "REVIEW_ENGINE" "kernel"
|
||||
set_assistant_config "REVIEW_ENABLE_HUMAN_GATE" "false"
|
||||
set_assistant_config "REVIEW_ALLOWED_COMMANDS" "git,rg,cat,sed,wc"
|
||||
set_assistant_config "REVIEW_COMMAND_TIMEOUT_MS" "30000"
|
||||
|
||||
echo " Assistant 配置完成(含 Gitea 连接参数)"
|
||||
fi
|
||||
|
||||
echo "=== [6/7] 配置 Webhook ==="
|
||||
@@ -162,7 +168,7 @@ curl -sf -X POST "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/hooks" \
|
||||
\"active\": true,
|
||||
\"events\": [\"pull_request\"],
|
||||
\"config\": {
|
||||
\"url\": \"http://assistant:3000/webhook/gitea\",
|
||||
\"url\": \"http://assistant:5174/webhook/gitea\",
|
||||
\"content_type\": \"json\",
|
||||
\"secret\": \"${WEBHOOK_SECRET}\"
|
||||
}
|
||||
@@ -207,6 +213,5 @@ 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"
|
||||
echo " 1. 触发 PR webhook 或推送 feature 分支新提交"
|
||||
echo " 2. 运行 E2E 测试: bun run test:e2e"
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
@@ -216,10 +218,14 @@
|
||||
|
||||
"@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" } }, ""],
|
||||
|
||||
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "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-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-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "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-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
|
||||
|
||||
"@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" } }, ""],
|
||||
|
||||
"@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" } }, ""],
|
||||
|
||||
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "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-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@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-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
|
||||
|
||||
"@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" } }, ""],
|
||||
|
||||
"@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" } }, ""],
|
||||
@@ -228,6 +234,8 @@
|
||||
|
||||
"@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" } }, ""],
|
||||
|
||||
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "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-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-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "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-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
|
||||
|
||||
"@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" } }, ""],
|
||||
|
||||
"@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" } }, ""],
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"ui:visual:update": "playwright test -c playwright.config.ts --update-snapshots"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
|
||||
@@ -2,8 +2,10 @@ 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 ReviewSessionsPage from './pages/ReviewSessionsPage';
|
||||
import { RepositoryManager } from './components/RepositoryManager';
|
||||
import { ConfigManager } from './components/ConfigManager';
|
||||
import { NotificationConfigPage } from './components/NotificationConfigPage';
|
||||
import { ReviewConfigPage } from './components/ReviewConfigPage';
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { useTheme } from 'next-themes'
|
||||
@@ -48,11 +50,13 @@ function AppContent() {
|
||||
</AuthGuard>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/repos" replace />} />
|
||||
<Route index element={<Navigate to="/sessions" replace />} />
|
||||
<Route path="sessions" element={<ReviewSessionsPage />} />
|
||||
<Route path="repos" element={<RepositoryManager />} />
|
||||
<Route path="config" element={<ConfigManager />} />
|
||||
<Route path="notifications" element={<NotificationConfigPage />} />
|
||||
<Route path="review-config" element={<ReviewConfigPage />} />
|
||||
<Route path="*" element={<Navigate to="/repos" replace />} />
|
||||
<Route path="*" element={<Navigate to="/sessions" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<Toaster theme={resolvedTheme === 'dark' ? 'dark' : 'light'} />
|
||||
|
||||
@@ -25,6 +25,7 @@ interface ConfigGroupCardProps {
|
||||
onFieldChange: (envKey: string, value: any) => void;
|
||||
onReset: (keys: string[]) => void;
|
||||
isResetting: boolean;
|
||||
headerActions?: React.ReactNode;
|
||||
/** Optional custom renderer for individual fields. Return `undefined` to use default ConfigFieldInput. */
|
||||
renderField?: (field: ConfigFieldDto, value: any, onChange: (val: any) => void) => React.ReactNode | undefined;
|
||||
}
|
||||
@@ -35,6 +36,7 @@ export function ConfigGroupCard({
|
||||
onFieldChange,
|
||||
onReset,
|
||||
isResetting,
|
||||
headerActions,
|
||||
renderField,
|
||||
}: ConfigGroupCardProps) {
|
||||
const hasOverride = group.fields.some((f) => f.source === 'db');
|
||||
@@ -69,17 +71,22 @@ export function ConfigGroupCard({
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{hasOverride && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={isResetting}
|
||||
className="theme-interactive-elevate border-danger/30 text-danger hover:bg-danger/10 hover:text-danger hover:border-danger/50 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
重置组配置
|
||||
</Button>
|
||||
{(headerActions || hasOverride) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{headerActions}
|
||||
{hasOverride && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={isResetting}
|
||||
className="theme-interactive-elevate border-danger/30 text-danger hover:bg-danger/10 hover:text-danger hover:border-danger/50 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
重置组配置
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="theme-card-content divide-y divide-border/50">
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Save, AlertCircle, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/** Groups shown on the system config page (excludes review & memory — moved to ReviewConfigPage). */
|
||||
const SYSTEM_GROUPS = new Set(['gitea', 'feishu', 'security']);
|
||||
const SYSTEM_GROUPS = new Set(['gitea', 'security']);
|
||||
|
||||
export function ConfigManager() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
379
frontend/src/components/NotificationConfigPage.tsx
Normal file
379
frontend/src/components/NotificationConfigPage.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchConfig,
|
||||
fetchNotificationTestHistory,
|
||||
updateConfig,
|
||||
resetConfig,
|
||||
testNotification,
|
||||
type NotificationTestProvider,
|
||||
} from '@/services/configService';
|
||||
import type {
|
||||
ConfigResponse,
|
||||
ConfigGroupDto,
|
||||
ConfigFieldDto,
|
||||
NotificationTestRecordDto,
|
||||
} from '@/services/configService';
|
||||
import { ConfigGroupCard } from './ConfigGroupCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Save, AlertCircle, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const NOTIFICATION_GROUPS = new Set(['notification']);
|
||||
|
||||
type ProviderCardMeta = {
|
||||
key: NotificationTestProvider;
|
||||
fieldPrefix: 'FEISHU_' | 'WECOM_';
|
||||
label: string;
|
||||
description: string;
|
||||
enableKey: 'FEISHU_ENABLED' | 'WECOM_ENABLED';
|
||||
webhookKey: 'FEISHU_WEBHOOK_URL' | 'WECOM_WEBHOOK_URL';
|
||||
};
|
||||
|
||||
const PROVIDER_CARDS: ProviderCardMeta[] = [
|
||||
{
|
||||
key: 'feishu',
|
||||
fieldPrefix: 'FEISHU_',
|
||||
label: '飞书通知',
|
||||
description: '配置飞书机器人 Webhook 与签名密钥。',
|
||||
enableKey: 'FEISHU_ENABLED',
|
||||
webhookKey: 'FEISHU_WEBHOOK_URL',
|
||||
},
|
||||
{
|
||||
key: 'wecom',
|
||||
fieldPrefix: 'WECOM_',
|
||||
label: '企业微信通知',
|
||||
description: '配置企业微信群机器人 Webhook。',
|
||||
enableKey: 'WECOM_ENABLED',
|
||||
webhookKey: 'WECOM_WEBHOOK_URL',
|
||||
},
|
||||
];
|
||||
|
||||
export function NotificationConfigPage() {
|
||||
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,
|
||||
});
|
||||
|
||||
const {
|
||||
data: testHistory,
|
||||
isLoading: isHistoryLoading,
|
||||
} = useQuery<NotificationTestRecordDto[], Error>({
|
||||
queryKey: ['notification-test-history'],
|
||||
queryFn: fetchNotificationTestHistory,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const initialState: Record<string, any> = {};
|
||||
data.groups
|
||||
.filter((g) => NOTIFICATION_GROUPS.has(g.key))
|
||||
.forEach((group) => {
|
||||
group.fields.forEach((field) => {
|
||||
if (field.sensitive && field.hasValue) {
|
||||
initialState[field.envKey] = '••••••••';
|
||||
} else 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 feishuTestMutation = useMutation({
|
||||
mutationFn: () => testNotification('feishu'),
|
||||
onSuccess: () => {
|
||||
toast.success('飞书测试通知已发送');
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-test-history'] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`飞书测试发送失败: ${err.message}`);
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-test-history'] });
|
||||
},
|
||||
});
|
||||
|
||||
const wecomTestMutation = useMutation({
|
||||
mutationFn: () => testNotification('wecom'),
|
||||
onSuccess: () => {
|
||||
toast.success('企业微信测试通知已发送');
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-test-history'] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`企业微信测试发送失败: ${err.message}`);
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-test-history'] });
|
||||
},
|
||||
});
|
||||
|
||||
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 notificationGroup = useMemo(
|
||||
() => data?.groups.find((g) => NOTIFICATION_GROUPS.has(g.key)),
|
||||
[data]
|
||||
);
|
||||
|
||||
const providerGroups = useMemo<ConfigGroupDto[]>(() => {
|
||||
if (!notificationGroup) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return PROVIDER_CARDS.map((provider) => {
|
||||
const fields = notificationGroup.fields.filter((field: ConfigFieldDto) =>
|
||||
field.envKey.startsWith(provider.fieldPrefix)
|
||||
);
|
||||
|
||||
return {
|
||||
...notificationGroup,
|
||||
key: `notification-${provider.key}`,
|
||||
label: provider.label,
|
||||
description: provider.description,
|
||||
fields,
|
||||
};
|
||||
}).filter((group) => group.fields.length > 0);
|
||||
}, [notificationGroup]);
|
||||
|
||||
const hasOverrides = useMemo(
|
||||
() =>
|
||||
providerGroups.some((g) =>
|
||||
g.fields.some((f) => f.source === 'db')
|
||||
),
|
||||
[providerGroups]
|
||||
);
|
||||
|
||||
const handleResetAll = () => {
|
||||
if (providerGroups.length === 0) return;
|
||||
const allOverrideKeys = providerGroups
|
||||
.flatMap((g) => g.fields)
|
||||
.filter((f) => f.source === 'db')
|
||||
.map((f) => f.envKey);
|
||||
|
||||
if (allOverrideKeys.length === 0) return;
|
||||
if (confirm('确定要重置所有通知配置到默认值吗?这将立即生效。')) {
|
||||
resetMutation.mutate(allOverrideKeys);
|
||||
}
|
||||
};
|
||||
|
||||
const canSendProviderTest = (provider: ProviderCardMeta): boolean => {
|
||||
const enabled = localConfig[provider.enableKey] === true;
|
||||
const webhook = localConfig[provider.webhookKey];
|
||||
return enabled && typeof webhook === 'string' && webhook.trim().length > 0;
|
||||
};
|
||||
|
||||
const getProviderMutation = (providerKey: NotificationTestProvider) => {
|
||||
return providerKey === 'feishu' ? feishuTestMutation : wecomTestMutation;
|
||||
};
|
||||
|
||||
const getProviderLabel = (provider: string): string => {
|
||||
if (provider === 'feishu') return '飞书';
|
||||
if (provider === 'wecom') return '企业微信';
|
||||
return provider;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<Skeleton className="h-10 w-48 bg-muted/60" />
|
||||
<Skeleton className="h-10 w-24 bg-muted/60" />
|
||||
</div>
|
||||
<Skeleton className="h-[280px] w-full rounded-xl bg-muted/60 border border-border/60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="theme-error-panel flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-danger" />
|
||||
<div className="font-medium tracking-wide">加载通知配置失败: {error.message}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="theme-page-frame">
|
||||
<div className="sticky top-0 z-10 theme-sticky-bar py-3 px-4 md:px-6 lg:px-8">
|
||||
<div className="theme-page-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleResetAll}
|
||||
disabled={!hasOverrides || resetMutation.isPending}
|
||||
className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
全部重置
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saveMutation.isPending}
|
||||
className="theme-interactive-elevate min-w-[130px] bg-primary text-primary-foreground 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-primary-foreground/50 border-t-transparent" /> 保存中...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存配置
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="theme-page-content">
|
||||
{providerGroups.map((group) => {
|
||||
const provider = PROVIDER_CARDS.find((item) => group.key === `notification-${item.key}`);
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mutation = getProviderMutation(provider.key);
|
||||
const canTest = canSendProviderTest(provider);
|
||||
const canTestNow = canTest && !hasChanges && !saveMutation.isPending;
|
||||
const testTitle = hasChanges
|
||||
? '请先保存配置后再测试'
|
||||
: canTest
|
||||
? '发送测试通知'
|
||||
: '请先启用并配置Webhook地址';
|
||||
|
||||
return (
|
||||
<ConfigGroupCard
|
||||
key={group.key}
|
||||
group={group}
|
||||
localConfig={localConfig}
|
||||
onFieldChange={handleFieldChange}
|
||||
onReset={handleResetGroup}
|
||||
isResetting={resetMutation.isPending}
|
||||
headerActions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending || !canTestNow}
|
||||
className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors"
|
||||
title={testTitle}
|
||||
>
|
||||
{mutation.isPending ? '测试中...' : '测试发送'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="rounded-xl border border-border/60 bg-card p-4 md:p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold tracking-wide text-foreground">最近测试记录</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['notification-test-history'] })}
|
||||
className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors"
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isHistoryLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full bg-muted/60" />
|
||||
<Skeleton className="h-10 w-full bg-muted/60" />
|
||||
</div>
|
||||
) : (testHistory?.length ?? 0) === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||
暂无测试记录,点击上方“测试发送”按钮可生成记录。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{testHistory?.slice(0, 10).map((record) => (
|
||||
<div
|
||||
key={record.id}
|
||||
className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/20 p-3 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="border-border text-foreground">
|
||||
{getProviderLabel(record.provider)}
|
||||
</Badge>
|
||||
<Badge
|
||||
className={
|
||||
record.status === 'success'
|
||||
? 'bg-success/15 text-success border-success/30'
|
||||
: 'bg-danger/15 text-danger border-danger/30'
|
||||
}
|
||||
>
|
||||
{record.status === 'success' ? '成功' : '失败'}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">{record.message}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(record.timestamp).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
frontend/src/components/RepositoryConfigCell.tsx
Normal file
141
frontend/src/components/RepositoryConfigCell.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Settings, FileText } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import api from '@/lib/api';
|
||||
import type { Repository } from '@/services/repositoryService';
|
||||
|
||||
interface RepositoryConfigCellProps {
|
||||
repo: Repository;
|
||||
}
|
||||
|
||||
export function RepositoryConfigCell({ repo }: RepositoryConfigCellProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [isPromptDialogOpen, setIsPromptDialogOpen] = useState(false);
|
||||
const [draftPrompt, setDraftPrompt] = useState(repo.project_review_prompt ?? '');
|
||||
|
||||
const promptMutation = useMutation({
|
||||
mutationFn: async (prompt: string) => {
|
||||
const { data } = await api.put(`/repositories/${repo.name}/project-prompt`, {
|
||||
project_review_prompt: prompt,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['repositories'] });
|
||||
setIsPromptDialogOpen(false);
|
||||
toast.success(`已更新 ${repo.name} 的项目级提示词`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`更新失败: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSavePrompt = () => {
|
||||
promptMutation.mutate(draftPrompt.trim());
|
||||
};
|
||||
|
||||
const handleOpenDialog = () => {
|
||||
setDraftPrompt(repo.project_review_prompt ?? '');
|
||||
setIsPromptDialogOpen(true);
|
||||
};
|
||||
|
||||
const hasPrompt = !!repo.project_review_prompt?.trim();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant={hasPrompt ? "outline" : "ghost"}
|
||||
size="sm"
|
||||
className={`h-8 gap-1.5 text-xs ${
|
||||
hasPrompt
|
||||
? "border-primary/50 text-primary hover:bg-primary/10"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={handleOpenDialog}
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{hasPrompt ? '已配置' : '配置'}</span>
|
||||
{hasPrompt && <span className="ml-1 h-1.5 w-1.5 rounded-full bg-primary" />}
|
||||
</Button>
|
||||
|
||||
<Dialog open={isPromptDialogOpen} onOpenChange={setIsPromptDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[525px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>配置项目级提示词</DialogTitle>
|
||||
<DialogDescription>
|
||||
为仓库 <code className="rounded bg-muted px-1 py-0.5 text-xs">{repo.name}</code> 设置审查提示词
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{hasPrompt && (
|
||||
<div className="rounded-lg bg-muted/50 border border-border/50 p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground">当前配置</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground leading-relaxed whitespace-pre-wrap break-all max-h-[80px] overflow-y-auto">
|
||||
{repo.project_review_prompt}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">提示词内容</label>
|
||||
<Textarea
|
||||
value={draftPrompt}
|
||||
onChange={(e) => setDraftPrompt(e.target.value)}
|
||||
placeholder="输入项目级审查提示词,例如:重点关注 API 安全性、空值处理和错误边界..."
|
||||
className="min-h-[120px] resize-none text-sm leading-relaxed focus-visible:ring-1 focus-visible:ring-primary/50"
|
||||
disabled={promptMutation.isPending}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
此提示词将在代码审查时与全局提示词合并,传递给 AI 模型。
|
||||
{hasPrompt && ' 留空保存将清除当前配置。'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsPromptDialogOpen(false)}
|
||||
disabled={promptMutation.isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSavePrompt}
|
||||
disabled={
|
||||
promptMutation.isPending ||
|
||||
draftPrompt.trim() === (repo.project_review_prompt ?? '').trim()
|
||||
}
|
||||
>
|
||||
{promptMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
保存中
|
||||
</>
|
||||
) : (
|
||||
'保存'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,17 +17,17 @@ function DataTableSkeleton() {
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/60 border-b border-border/50">
|
||||
<TableRow className="border-border/50">
|
||||
<TableHead className="w-[40%]"><Skeleton className="h-5 w-24 bg-muted" /></TableHead>
|
||||
<TableHead className="w-[30%]"><Skeleton className="h-5 w-24 bg-muted" /></TableHead>
|
||||
<TableHead className="w-[30%] text-right"><Skeleton className="h-5 w-16 ml-auto bg-muted" /></TableHead>
|
||||
<TableHead className="w-[50%]"><Skeleton className="h-5 w-24 bg-muted" /></TableHead>
|
||||
<TableHead className="w-[25%]"><Skeleton className="h-5 w-16 bg-muted" /></TableHead>
|
||||
<TableHead className="w-[25%] text-right"><Skeleton className="h-5 w-16 ml-auto bg-muted" /></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<TableRow key={i} className="border-border/50">
|
||||
<TableCell><Skeleton className="h-5 w-3/4 bg-muted/70" /></TableCell>
|
||||
<TableCell><Skeleton className="h-6 w-20 bg-muted/70 rounded-full" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-8 w-16 ml-auto bg-muted/70" /></TableCell>
|
||||
<TableCell><Skeleton className="h-5 w-20 bg-muted/70" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-8 w-24 ml-auto bg-muted/70" /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -2,42 +2,34 @@
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import type { Repository } from "@/services/repositoryService"
|
||||
import { WebhookToggleButton } from "@/components/WebhookToggleButton"
|
||||
import { RepositoryConfigCell } from "@/components/RepositoryConfigCell"
|
||||
import { WebhookToggleCell } from "@/components/WebhookToggleCell"
|
||||
|
||||
export const columns: ColumnDef<Repository>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "仓库名称",
|
||||
cell: ({ row }) => <div className="font-medium text-foreground text-sm">{row.getValue("name")}</div>,
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium text-foreground text-sm">
|
||||
{row.getValue("name")}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "webhook_status",
|
||||
header: "Webhook 状态",
|
||||
header: "Webhook",
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("webhook_status") as Repository["webhook_status"]
|
||||
const isActive = status === 'active'
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold border ${isActive ? 'bg-success/10 text-success border-success/30' : 'bg-transparent text-muted-foreground border-border theme-border-soft'}`}>
|
||||
{isActive && <span className="w-1.5 h-1.5 rounded-full bg-success animate-pulse theme-glow-success"></span>}
|
||||
{isActive ? '已启用' : '未启用'}
|
||||
</div>
|
||||
)
|
||||
const repo = row.original
|
||||
return <WebhookToggleCell repo={repo} />
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <div className="text-right text-muted-foreground">操作</div>,
|
||||
cell: ({ row }) => {
|
||||
const repo = row.original
|
||||
return (
|
||||
<div className="text-right">
|
||||
<WebhookToggleButton
|
||||
repoName={repo.name}
|
||||
status={repo.webhook_status}
|
||||
hookId={repo.hook_id}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
header: () => <div className="text-right text-muted-foreground text-xs">提示词</div>,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">
|
||||
<RepositoryConfigCell repo={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -17,7 +17,7 @@ import { toast } from 'sonner';
|
||||
// Engine-specific field visibility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type EngineMode = 'agent' | 'codex';
|
||||
type EngineMode = 'kernel' | 'codex';
|
||||
|
||||
/** The engine selector field — always visible at the top. */
|
||||
const ENGINE_FIELD = 'REVIEW_ENGINE';
|
||||
@@ -30,8 +30,7 @@ const AGENT_SHARED_FIELDS = new Set([
|
||||
'REVIEW_MAX_FILE_CONTENT_CHARS',
|
||||
]);
|
||||
|
||||
/** Fields specific to agent mode only. */
|
||||
const AGENT_ONLY_FIELDS = new Set([
|
||||
const KERNEL_ONLY_FIELDS = new Set([
|
||||
'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE',
|
||||
'REVIEW_ENABLE_HUMAN_GATE',
|
||||
'REVIEW_ALLOWED_COMMANDS',
|
||||
@@ -62,8 +61,8 @@ function getVisibleFields(engine: EngineMode, fields: ConfigFieldDto[]): ConfigF
|
||||
return fields.filter((f) => {
|
||||
if (f.envKey === ENGINE_FIELD) return false; // rendered separately
|
||||
switch (engine) {
|
||||
case 'agent':
|
||||
return AGENT_SHARED_FIELDS.has(f.envKey) || AGENT_ONLY_FIELDS.has(f.envKey);
|
||||
case 'kernel':
|
||||
return AGENT_SHARED_FIELDS.has(f.envKey) || KERNEL_ONLY_FIELDS.has(f.envKey);
|
||||
case 'codex':
|
||||
return CODEX_FIELDS.has(f.envKey);
|
||||
default:
|
||||
@@ -77,7 +76,7 @@ function getVisibleFields(engine: EngineMode, fields: ConfigFieldDto[]): ConfigF
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ENGINE_OPTIONS: { value: EngineMode; label: string; description: string }[] = [
|
||||
{ value: 'agent', label: 'Agent', description: '多代理编排深度审查' },
|
||||
{ value: 'kernel', label: 'Kernel', description: 'PR Session + Agentic Loop 审查' },
|
||||
{ value: 'codex', label: 'Codex', description: 'Codex CLI 审查' },
|
||||
];
|
||||
|
||||
@@ -98,20 +97,19 @@ export function ReviewConfigPage() {
|
||||
// Derived: current engine mode
|
||||
const engine: EngineMode = useMemo(() => {
|
||||
const val = localConfig[ENGINE_FIELD];
|
||||
if (val === 'agent' || val === 'codex') return val;
|
||||
return 'agent';
|
||||
if (val === 'kernel' || val === 'codex') return val;
|
||||
return 'kernel';
|
||||
}, [localConfig]);
|
||||
|
||||
// Derived: review group and memory group from fetched data
|
||||
// Derived: review group from fetched data
|
||||
const reviewGroup = useMemo(() => data?.groups.find((g) => g.key === 'review'), [data]);
|
||||
const memoryGroup = useMemo(() => data?.groups.find((g) => g.key === 'memory'), [data]);
|
||||
|
||||
// Initialize local config from ALL groups (so save works for review + memory fields)
|
||||
// Initialize local config from review group
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const initialState: Record<string, any> = {};
|
||||
data.groups
|
||||
.filter((g) => g.key === 'review' || g.key === 'memory')
|
||||
.filter((g) => g.key === 'review')
|
||||
.forEach((group) => {
|
||||
group.fields.forEach((field) => {
|
||||
if (field.sensitive && field.hasValue) {
|
||||
@@ -175,11 +173,9 @@ export function ReviewConfigPage() {
|
||||
};
|
||||
|
||||
const handleResetAll = () => {
|
||||
const groups = [reviewGroup, memoryGroup].filter(Boolean) as ConfigGroupDto[];
|
||||
const allOverrideKeys = groups
|
||||
.flatMap((g) => g.fields)
|
||||
.filter((f) => f.source === 'db')
|
||||
.map((f) => f.envKey);
|
||||
const allOverrideKeys = (reviewGroup?.fields ?? [])
|
||||
.filter((f) => f.source === 'db')
|
||||
.map((f) => f.envKey);
|
||||
if (allOverrideKeys.length === 0) return;
|
||||
if (confirm('确定要重置所有审查配置到默认值吗?这将立即生效。')) {
|
||||
resetMutation.mutate(allOverrideKeys);
|
||||
@@ -193,9 +189,8 @@ export function ReviewConfigPage() {
|
||||
);
|
||||
|
||||
const hasOverrides = useMemo(() => {
|
||||
const groups = [reviewGroup, memoryGroup].filter(Boolean) as ConfigGroupDto[];
|
||||
return groups.some((g) => g.fields.some((f) => f.source === 'db'));
|
||||
}, [reviewGroup, memoryGroup]);
|
||||
return (reviewGroup?.fields ?? []).some((f) => f.source === 'db');
|
||||
}, [reviewGroup]);
|
||||
|
||||
// -- Render states --
|
||||
|
||||
@@ -225,11 +220,11 @@ export function ReviewConfigPage() {
|
||||
const syntheticReviewGroup: ConfigGroupDto | null = reviewGroup
|
||||
? {
|
||||
...reviewGroup,
|
||||
label: engine === 'codex' ? 'Codex 审查设置' : 'Agent 审查设置',
|
||||
label: engine === 'codex' ? 'Codex 审查设置' : 'Kernel 审查设置',
|
||||
description:
|
||||
engine === 'codex'
|
||||
? 'Codex CLI 审查引擎配置'
|
||||
: '多代理编排审查引擎配置',
|
||||
: '基于 PR Session 的 agentic loop 审查引擎配置',
|
||||
fields: visibleReviewFields,
|
||||
}
|
||||
: null;
|
||||
@@ -358,18 +353,7 @@ export function ReviewConfigPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Memory group — agent mode only */}
|
||||
{engine === 'agent' && memoryGroup && (
|
||||
<ConfigGroupCard
|
||||
group={memoryGroup}
|
||||
localConfig={localConfig}
|
||||
onFieldChange={handleFieldChange}
|
||||
onReset={handleResetGroup}
|
||||
isResetting={resetMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{engine !== 'codex' && (
|
||||
{engine === 'kernel' && (
|
||||
<>
|
||||
<ProviderList />
|
||||
<RoleAssignment />
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
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";
|
||||
|
||||
interface WebhookToggleButtonProps {
|
||||
repoName: string;
|
||||
status: 'active' | 'inactive';
|
||||
hookId: number | null;
|
||||
}
|
||||
|
||||
const createWebhook = (repoName: string) => api.post(`/repositories/${repoName}/webhook`);
|
||||
const deleteWebhook = ({ repoName, hookId }: { repoName: string; hookId: number }) => api.delete(`/repositories/${repoName}/webhook/${hookId}`);
|
||||
|
||||
export function WebhookToggleButton({ repoName, status, hookId }: WebhookToggleButtonProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: status === 'active'
|
||||
? () => deleteWebhook({ repoName, hookId: hookId! })
|
||||
: () => createWebhook(repoName),
|
||||
onSuccess: () => {
|
||||
// 操作成功后,使仓库列表的查询失效,React Query会自动重新获取最新数据
|
||||
queryClient.invalidateQueries({ queryKey: ['repositories'] });
|
||||
toast.success(`Webhook for ${repoName} has been ${status === 'active' ? 'disabled' : 'enabled'}.`);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("操作失败:", error);
|
||||
toast.error(`Operation failed: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={status === 'active' ? 'outline' : 'default'}
|
||||
size="sm"
|
||||
className={
|
||||
status === 'active'
|
||||
? "border-danger/50 bg-transparent text-danger hover:bg-danger/10 hover:text-danger transition-colors"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90 transition-all duration-300 tech-glow"
|
||||
}
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/WebhookToggleCell.tsx
Normal file
57
frontend/src/components/WebhookToggleCell.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import api from '@/lib/api';
|
||||
import type { Repository } from '@/services/repositoryService';
|
||||
|
||||
interface WebhookToggleCellProps {
|
||||
repo: Repository;
|
||||
}
|
||||
|
||||
const createWebhook = (repoName: string) =>
|
||||
api.post(`/repositories/${repoName}/webhook`);
|
||||
const deleteWebhook = ({ repoName, hookId }: { repoName: string; hookId: number }) =>
|
||||
api.delete(`/repositories/${repoName}/webhook/${hookId}`);
|
||||
|
||||
export function WebhookToggleCell({ repo }: WebhookToggleCellProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isActive = repo.webhook_status === 'active';
|
||||
|
||||
const webhookMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (isActive && repo.hook_id) {
|
||||
return deleteWebhook({ repoName: repo.name, hookId: repo.hook_id });
|
||||
}
|
||||
return createWebhook(repo.name);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['repositories'] });
|
||||
const action = isActive ? '已禁用' : '已启用';
|
||||
toast.success(`${repo.name} 的 Webhook ${action}`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`操作失败: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{webhookMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={isActive}
|
||||
onCheckedChange={() => webhookMutation.mutate()}
|
||||
disabled={webhookMutation.isPending}
|
||||
aria-label={isActive ? '禁用 Webhook' : '启用 Webhook'}
|
||||
/>
|
||||
)}
|
||||
<span className={`text-xs ${isActive ? 'text-success' : 'text-muted-foreground'}`}>
|
||||
{isActive ? '已启用' : '未启用'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactNode } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { RepositoryConfigCell } from '../RepositoryConfigCell';
|
||||
import type { Repository } from '@/services/repositoryService';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
put: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
default: apiMocks,
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
function makeRepo(overrides: Partial<Repository> = {}): Repository {
|
||||
return {
|
||||
name: 'demo-owner/demo-repo',
|
||||
webhook_status: 'inactive',
|
||||
hook_id: null,
|
||||
project_review_prompt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('RepositoryConfigCell', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('opens prompt dialog and saves project prompt', async () => {
|
||||
apiMocks.put.mockResolvedValueOnce({
|
||||
data: {
|
||||
success: true,
|
||||
project_review_prompt: 'focus null safety',
|
||||
},
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithQuery(<RepositoryConfigCell repo={makeRepo()} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /配置/i }));
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByText('配置项目级提示词')).toBeInTheDocument();
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
await user.type(textarea, ' focus null safety ');
|
||||
await user.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.put).toHaveBeenCalledWith(
|
||||
'/repositories/demo-owner/demo-repo/project-prompt',
|
||||
{ project_review_prompt: 'focus null safety' }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
84
frontend/src/components/__tests__/WebhookToggleCell.test.tsx
Normal file
84
frontend/src/components/__tests__/WebhookToggleCell.test.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactNode } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WebhookToggleCell } from '../WebhookToggleCell';
|
||||
import type { Repository } from '@/services/repositoryService';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
post: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
default: apiMocks,
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
function makeRepo(overrides: Partial<Repository> = {}): Repository {
|
||||
return {
|
||||
name: 'demo-owner/demo-repo',
|
||||
webhook_status: 'inactive',
|
||||
hook_id: null,
|
||||
project_review_prompt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('WebhookToggleCell', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('toggles webhook via switch to enable', async () => {
|
||||
apiMocks.post.mockResolvedValueOnce({ data: { success: true } });
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithQuery(<WebhookToggleCell repo={makeRepo()} />);
|
||||
|
||||
const switchEl = screen.getByRole('switch');
|
||||
expect(switchEl).toHaveAttribute('aria-checked', 'false');
|
||||
expect(screen.getByText('未启用')).toBeInTheDocument();
|
||||
|
||||
await user.click(switchEl);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.post).toHaveBeenCalledWith('/repositories/demo-owner/demo-repo/webhook');
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles webhook via switch to disable', async () => {
|
||||
apiMocks.delete.mockResolvedValueOnce({ data: { success: true } });
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithQuery(<WebhookToggleCell repo={makeRepo({ webhook_status: 'active', hook_id: 123 })} />);
|
||||
|
||||
const switchEl = screen.getByRole('switch');
|
||||
expect(switchEl).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByText('已启用')).toBeInTheDocument();
|
||||
|
||||
await user.click(switchEl);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.delete).toHaveBeenCalledWith('/repositories/demo-owner/demo-repo/webhook/123');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,27 +2,59 @@ import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { toast } from 'sonner';
|
||||
import { Save, ShieldCheck } from 'lucide-react';
|
||||
import { fetchProviders, fetchRoles, setRole } from '@/services/llmProviderService';
|
||||
import { Bot, Route, Save, ShieldCheck, Sparkles, Workflow } from 'lucide-react';
|
||||
import {
|
||||
fetchKernelSubagents,
|
||||
fetchProviders,
|
||||
fetchRoles,
|
||||
setRole,
|
||||
type KernelSubagentDto,
|
||||
} from '@/services/llmProviderService';
|
||||
import { ModelCombobox } from './ModelCombobox';
|
||||
|
||||
const ROLE_LABELS: Record<string, { label: string; desc: string }> = {
|
||||
planner: { label: '规划器 Planner', desc: '多阶段审查的第一步,负责分析上下文并分配任务' },
|
||||
specialist: { label: '专家 Specialist', desc: '执行深度代码审查的主力模型,专注于发现具体问题' },
|
||||
judge: { label: '评审 Judge', desc: '对专家的建议进行审核、合并和过滤,确保评论质量' },
|
||||
embedding: { label: '嵌入 Embedding', desc: '用于向量化代码和注释,支持语义搜索 (Qdrant)' },
|
||||
planner: { label: 'Planner', desc: '用于 triage / planning / context compression,负责审查分流与上下文压缩' },
|
||||
specialist: { label: 'Specialist', desc: '用于 correctness / security / quality 等深度审查' },
|
||||
};
|
||||
|
||||
const ROLES = ['planner', 'specialist', 'judge', 'embedding'];
|
||||
const ROLES = ['planner', 'specialist'];
|
||||
|
||||
interface RoleState {
|
||||
providerId: string | null;
|
||||
model: string;
|
||||
}
|
||||
|
||||
function getModelRoleBadgeClass(modelRole?: string): string {
|
||||
switch (modelRole) {
|
||||
case 'planner':
|
||||
return 'border-info/30 bg-info/10 text-info';
|
||||
case 'specialist':
|
||||
return 'border-primary/30 bg-primary/10 text-primary';
|
||||
default:
|
||||
return 'border-border bg-muted/40 text-muted-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceBadgeClass(source: KernelSubagentDto['source']): string {
|
||||
switch (source) {
|
||||
case 'built-in':
|
||||
return 'border-primary/20 bg-primary/10 text-primary';
|
||||
case 'plugin':
|
||||
return 'border-warning/20 bg-warning/10 text-warning';
|
||||
case 'custom':
|
||||
return 'border-success/20 bg-success/10 text-success';
|
||||
default:
|
||||
return 'border-border bg-muted/40 text-muted-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
export function RoleAssignment() {
|
||||
const queryClient = useQueryClient();
|
||||
const [roleStates, setRoleStates] = useState<Record<string, RoleState>>({});
|
||||
@@ -37,6 +69,11 @@ export function RoleAssignment() {
|
||||
queryFn: fetchRoles,
|
||||
});
|
||||
|
||||
const { data: subagents = [], isLoading: isSubagentsLoading } = useQuery({
|
||||
queryKey: ['kernel-subagents'],
|
||||
queryFn: fetchKernelSubagents,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (roles.length > 0) {
|
||||
const initial: Record<string, RoleState> = {};
|
||||
@@ -46,7 +83,6 @@ export function RoleAssignment() {
|
||||
model: role.model || '',
|
||||
};
|
||||
});
|
||||
// Fill missing roles
|
||||
ROLES.forEach(r => {
|
||||
if (!initial[r]) {
|
||||
initial[r] = { providerId: null, model: '' };
|
||||
@@ -118,96 +154,239 @@ export function RoleAssignment() {
|
||||
<div className="w-10 h-10 rounded-xl bg-warning/10 flex items-center justify-center border border-warning/20 group-hover:bg-warning/20 transition-all duration-300">
|
||||
<ShieldCheck className="h-5 w-5 text-warning" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
角色分配
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
为 AI 审查系统的不同角色指定提供商和模型
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
Subagents 与模型路由
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
上层展示 subagent 目录,下层配置 Planner / Specialist 模型路由
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="theme-card-content">
|
||||
{isLoading ? (
|
||||
<div className="h-32 flex items-center justify-center text-muted-foreground gap-2">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
加载角色配置...
|
||||
<CardContent className="theme-card-content space-y-8">
|
||||
{/* ── Subagents 目录 ──────────────────────────────────────────── */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-primary/20 bg-primary/10 text-primary">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground">Subagents 目录</h3>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50">
|
||||
{ROLES.map(role => {
|
||||
const state = roleStates[role] || { providerId: null, model: '' };
|
||||
const isDirty = roles.find(r => r.role === role)?.providerId !== state.providerId ||
|
||||
(roles.find(r => r.role === role)?.model || '') !== state.model;
|
||||
|
||||
return (
|
||||
<div key={role} className="flex flex-col md:flex-row items-start md:items-center gap-4 py-5 px-1 hover:bg-accent/40 transition-colors rounded-lg">
|
||||
<div className="w-full md:w-1/3 space-y-1.5">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{ROLE_LABELS[role]?.label || role}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{ROLE_LABELS[role]?.desc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-2/3 flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
||||
<div className="flex-1 w-full space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">提供商</Label>
|
||||
<Select
|
||||
value={state.providerId || ''}
|
||||
onValueChange={(v) => handleProviderChange(role, v)}
|
||||
>
|
||||
<SelectTrigger className="bg-muted/50 border-border text-foreground">
|
||||
<SelectValue placeholder="选择提供商" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-popover border-border text-foreground">
|
||||
{enabledProviders.map(p => (
|
||||
<SelectItem key={p.id} value={p.id} description={p.type} className="focus:bg-accent focus:text-primary">
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{enabledProviders.length === 0 && (
|
||||
<div className="px-2 py-3 text-xs text-danger text-center border-t border-border/60">
|
||||
无可用提供商。请先添加并启用。
|
||||
|
||||
<Alert className="border-primary/20 bg-primary/5">
|
||||
<Bot className="h-4 w-4 text-primary" />
|
||||
<AlertTitle>流程编排由 kernel 自动驱动</AlertTitle>
|
||||
<AlertDescription>
|
||||
kernel 根据 session state 与 planner 选择注册式 subagent 执行。下方展示的是当前已注册的 subagent 及其能力标签。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{isSubagentsLoading ? (
|
||||
<div className="h-32 flex items-center justify-center text-muted-foreground gap-2">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
加载 subagent 目录...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="border-border/70 bg-card/70">
|
||||
<CardContent className="p-5">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Subagents</div>
|
||||
<div className="mt-2 text-3xl font-semibold tracking-tight text-foreground">{subagents.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-border/70 bg-card/70">
|
||||
<CardContent className="p-5">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Built-in</div>
|
||||
<div className="mt-2 text-3xl font-semibold tracking-tight text-foreground">
|
||||
{subagents.filter((item) => item.source === 'built-in').length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-border/70 bg-card/70">
|
||||
<CardContent className="p-5">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-muted-foreground">模型角色</div>
|
||||
<div className="mt-2 text-3xl font-semibold tracking-tight text-foreground">
|
||||
{new Set(subagents.map((item) => item.modelRole).filter(Boolean)).size}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/70 bg-card/70">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="pl-5">Subagent</TableHead>
|
||||
<TableHead>能力定位</TableHead>
|
||||
<TableHead>模型角色</TableHead>
|
||||
<TableHead>标签</TableHead>
|
||||
<TableHead className="pr-5 text-right">状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subagents.map((subagent) => (
|
||||
<TableRow key={subagent.name}>
|
||||
<TableCell className="pl-5 align-top">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-foreground">{subagent.name}</span>
|
||||
<Badge className={getSourceBadgeClass(subagent.source)}>{subagent.source}</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{subagent.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="align-top text-sm text-muted-foreground whitespace-normal">
|
||||
{subagent.whenToUse}
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<Badge className={getModelRoleBadgeClass(subagent.modelRole)}>
|
||||
<Route className="h-3 w-3" />
|
||||
{subagent.modelRole ?? '未绑定'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<div className="flex flex-wrap gap-1.5 max-w-[260px]">
|
||||
{subagent.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="bg-muted/30">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="pr-5 align-top text-right">
|
||||
<Badge className={subagent.resumable ? 'border-success/20 bg-success/10 text-success' : 'border-border bg-muted/40 text-muted-foreground'}>
|
||||
{subagent.resumable ? '可恢复' : '一次性'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="flex-1 w-full space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">使用的模型</Label>
|
||||
<ModelCombobox
|
||||
providerType={providers.find(p => p.id === state.providerId)?.type}
|
||||
value={state.model}
|
||||
onChange={(model) => handleModelChange(role, model)}
|
||||
placeholder="选择或输入模型..."
|
||||
disabled={!state.providerId}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div className="pt-5 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSave(role)}
|
||||
disabled={!isDirty || saveMutation.isPending}
|
||||
variant={isDirty ? 'default' : 'secondary'}
|
||||
className={`transition-all ${isDirty ? 'bg-warning/15 text-warning border border-warning/30 hover:bg-warning/25' : 'bg-muted/50 text-muted-foreground border border-transparent'}`}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1.5" />
|
||||
{isDirty ? '保存更改' : '已保存'}
|
||||
</Button>
|
||||
{/* ── 模型角色路由 ─────────────────────────────────────────────── */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-warning/25 bg-warning/10 text-warning">
|
||||
<Workflow className="h-4 w-4" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground">模型角色路由</h3>
|
||||
</div>
|
||||
|
||||
<Alert className="border-warning/20 bg-warning/5">
|
||||
<ShieldCheck className="h-4 w-4 text-warning" />
|
||||
<AlertTitle>这里配置的是底层模型路由,不是流程角色编排</AlertTitle>
|
||||
<AlertDescription>
|
||||
Planner / Specialist 决定由哪个 provider/model 响应 LLM 调用。subagent 的注册、标签和执行顺序由 kernel 控制。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="h-32 flex items-center justify-center text-muted-foreground gap-2">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
加载模型角色路由...
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50">
|
||||
{ROLES.map(role => {
|
||||
const state = roleStates[role] || { providerId: null, model: '' };
|
||||
const isDirty = roles.find(r => r.role === role)?.providerId !== state.providerId ||
|
||||
(roles.find(r => r.role === role)?.model || '') !== state.model;
|
||||
const consumers = subagents.filter((item) => item.modelRole === role);
|
||||
|
||||
return (
|
||||
<div key={role} className="py-5 px-1">
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-border/60 bg-card/40 p-4 hover:bg-accent/20 transition-colors">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{ROLE_LABELS[role]?.label || role}
|
||||
</Label>
|
||||
<Badge variant="outline" className="bg-muted/30">
|
||||
{consumers.length} 个 subagent
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{ROLE_LABELS[role]?.desc}
|
||||
</p>
|
||||
{consumers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{consumers.map((item) => (
|
||||
<Badge key={item.name} className="border-primary/15 bg-primary/5 text-primary">
|
||||
{item.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
||||
<div className="flex-1 w-full space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">提供商</Label>
|
||||
<Select
|
||||
value={state.providerId || ''}
|
||||
onValueChange={(v) => handleProviderChange(role, v)}
|
||||
>
|
||||
<SelectTrigger className="bg-muted/50 border-border text-foreground">
|
||||
<SelectValue placeholder="选择提供商" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-popover border-border text-foreground">
|
||||
{enabledProviders.map(p => (
|
||||
<SelectItem key={p.id} value={p.id} description={p.type} className="focus:bg-accent focus:text-primary">
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{enabledProviders.length === 0 && (
|
||||
<div className="px-2 py-3 text-xs text-danger text-center border-t border-border/60">
|
||||
无可用提供商。请先添加并启用。
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 w-full space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">使用的模型</Label>
|
||||
<ModelCombobox
|
||||
providerType={providers.find(p => p.id === state.providerId)?.type}
|
||||
value={state.model}
|
||||
onChange={(model) => handleModelChange(role, model)}
|
||||
placeholder="选择或输入模型..."
|
||||
disabled={!state.providerId}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-5 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSave(role)}
|
||||
disabled={!isDirty || saveMutation.isPending}
|
||||
variant={isDirty ? 'default' : 'secondary'}
|
||||
className={`transition-all ${isDirty ? 'bg-warning/15 text-warning border border-warning/30 hover:bg-warning/25' : 'bg-muted/50 text-muted-foreground border border-transparent'}`}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1.5" />
|
||||
{isDirty ? '保存更改' : '已保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,12 @@ import userEvent from '@testing-library/user-event';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { RoleAssignment } from '../RoleAssignment';
|
||||
import { fetchProviders, fetchRoles, setRole } from '@/services/llmProviderService';
|
||||
import {
|
||||
fetchKernelSubagents,
|
||||
fetchProviders,
|
||||
fetchRoles,
|
||||
setRole,
|
||||
} from '@/services/llmProviderService';
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
@@ -18,6 +23,7 @@ vi.mock('@/services/llmProviderService', async () => {
|
||||
return {
|
||||
...actual,
|
||||
fetchProviders: vi.fn(),
|
||||
fetchKernelSubagents: vi.fn(),
|
||||
fetchRoles: vi.fn(),
|
||||
setRole: vi.fn(),
|
||||
fetchModelSuggestions: vi.fn().mockResolvedValue({
|
||||
@@ -40,7 +46,7 @@ function renderWithQuery(ui: ReactNode) {
|
||||
}
|
||||
|
||||
describe('RoleAssignment', () => {
|
||||
it('renders role cards and supports provider/model editing', async () => {
|
||||
it('renders subagent directory and model role routing', async () => {
|
||||
vi.mocked(fetchProviders).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'p1',
|
||||
@@ -65,6 +71,29 @@ describe('RoleAssignment', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(fetchKernelSubagents).mockResolvedValueOnce([
|
||||
{
|
||||
kind: 'subagent',
|
||||
name: 'review:triage',
|
||||
source: 'built-in',
|
||||
description: '根据变更范围决定 review 域与审查模式',
|
||||
whenToUse: '当需要规划任务时',
|
||||
modelRole: 'planner',
|
||||
tags: ['review', 'planner', 'triage'],
|
||||
resumable: true,
|
||||
},
|
||||
{
|
||||
kind: 'subagent',
|
||||
name: 'review:full_review',
|
||||
source: 'built-in',
|
||||
description: '执行一次完整自主代码审查',
|
||||
whenToUse: '当 triage 生成审查提示后执行完整审查',
|
||||
modelRole: 'specialist',
|
||||
tags: ['review', 'specialist', 'full-review', 'autonomous-review'],
|
||||
resumable: true,
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(setRole).mockResolvedValue({
|
||||
role: 'planner',
|
||||
providerId: 'p1',
|
||||
@@ -76,11 +105,12 @@ describe('RoleAssignment', () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithQuery(<RoleAssignment />);
|
||||
|
||||
expect(await screen.findByText('角色分配')).toBeInTheDocument();
|
||||
expect(await screen.findByText('规划器 Planner')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Subagents 与模型路由')).toBeInTheDocument();
|
||||
expect((await screen.findAllByText('review:triage')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('模型角色路由')).toBeInTheDocument();
|
||||
expect(screen.getByText('Planner')).toBeInTheDocument();
|
||||
expect(screen.getByText('Specialist')).toBeInTheDocument();
|
||||
|
||||
// Radix Select renders placeholder in a span with pointer-events: none.
|
||||
// Click the trigger button (parent) instead of the placeholder text.
|
||||
const providerPlaceholders = screen.getAllByText('选择提供商');
|
||||
const triggerButton = providerPlaceholders[0].closest('button')!;
|
||||
await user.click(triggerButton);
|
||||
|
||||
122
frontend/src/components/ui/dialog.tsx
Normal file
122
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
200
frontend/src/components/ui/dropdown-menu.tsx
Normal file
200
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground 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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LogOut, Bot, FolderGit2, Sliders, Menu, X, PanelLeftClose, PanelLeftOpen, FileSearch, Sun, Moon, Palette } from 'lucide-react';
|
||||
import { LogOut, Bot, FolderGit2, Sliders, Bell, Menu, X, PanelLeftClose, PanelLeftOpen, FileSearch, Sun, Moon, Palette, Waypoints } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
|
||||
import { isColorPalette, useColorPalette } from '@/hooks/useColorPalette';
|
||||
|
||||
const navItems = [
|
||||
{ path: '/sessions', label: '审查会话', icon: Waypoints },
|
||||
{ path: '/repos', label: '仓库管理', icon: FolderGit2 },
|
||||
{ path: '/config', label: '系统配置', icon: Sliders },
|
||||
{ path: '/notifications', label: '通知管理', icon: Bell },
|
||||
{ path: '/review-config', label: '审查配置', icon: FileSearch },
|
||||
] as const;
|
||||
|
||||
@@ -31,6 +33,7 @@ export default function DashboardPage() {
|
||||
|
||||
const currentTitle = navItems.find(item => location.pathname.startsWith(item.path))?.label || 'Dashboard';
|
||||
const isConfigPage = location.pathname.startsWith('/config');
|
||||
const isNotificationPage = location.pathname.startsWith('/notifications');
|
||||
const isReviewConfigPage = location.pathname.startsWith('/review-config');
|
||||
|
||||
return (
|
||||
@@ -205,7 +208,7 @@ export default function DashboardPage() {
|
||||
<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.025] -z-10"></div>
|
||||
<div className={`w-full animate-in fade-in slide-in-from-bottom-4 duration-500 ${(isConfigPage || isReviewConfigPage) ? '' : 'p-4 md:p-6 lg:p-8'}`}>
|
||||
<div className={`w-full animate-in fade-in slide-in-from-bottom-4 duration-500 ${(isConfigPage || isNotificationPage || isReviewConfigPage) ? '' : 'p-4 md:p-6 lg:p-8'}`}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
368
frontend/src/pages/ReviewSessionsPage.tsx
Normal file
368
frontend/src/pages/ReviewSessionsPage.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AlertTriangle, Clock3, ListTodo, RefreshCw, Waypoints } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
fetchReviewSessionDetail,
|
||||
fetchReviewSessions,
|
||||
type ReviewPlanStepDto,
|
||||
type ReviewSessionSummaryRecordDto,
|
||||
type ReviewTimelineEntryDto,
|
||||
} from '@/services/reviewSessionService';
|
||||
|
||||
const statusLabelMap: Record<ReviewSessionSummaryRecordDto['summary']['status'], string> = {
|
||||
queued: '排队中',
|
||||
planning: '制定计划',
|
||||
executing: '执行中',
|
||||
awaiting_human_feedback: '等待人工反馈',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
ignored: '已忽略',
|
||||
};
|
||||
|
||||
const statusClassMap: Record<ReviewSessionSummaryRecordDto['summary']['status'], string> = {
|
||||
queued: 'border-border bg-muted/60 text-muted-foreground',
|
||||
planning: 'border-info/30 bg-info/10 text-info',
|
||||
executing: 'border-primary/30 bg-primary/10 text-primary',
|
||||
awaiting_human_feedback: 'border-warning/30 bg-warning/15 text-warning-foreground',
|
||||
completed: 'border-success/30 bg-success/15 text-success',
|
||||
failed: 'border-destructive/30 bg-destructive/10 text-destructive',
|
||||
ignored: 'border-border bg-muted/50 text-muted-foreground',
|
||||
};
|
||||
|
||||
const planStatusClassMap: Record<ReviewPlanStepDto['status'], string> = {
|
||||
pending: 'border-border bg-muted/40 text-muted-foreground',
|
||||
queued: 'border-info/20 bg-info/10 text-info',
|
||||
running: 'border-primary/20 bg-primary/10 text-primary',
|
||||
completed: 'border-success/20 bg-success/10 text-success',
|
||||
failed: 'border-destructive/20 bg-destructive/10 text-destructive',
|
||||
skipped: 'border-border bg-muted/40 text-muted-foreground',
|
||||
};
|
||||
|
||||
const timelineToneClassMap: Record<ReviewTimelineEntryDto['tone'], string> = {
|
||||
neutral: 'border-border bg-card/80',
|
||||
success: 'border-success/20 bg-success/5',
|
||||
warning: 'border-warning/20 bg-warning/5',
|
||||
danger: 'border-destructive/20 bg-destructive/5',
|
||||
};
|
||||
|
||||
function formatDate(value?: string): string {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function truncateSha(value?: string): string {
|
||||
if (!value) return '—';
|
||||
return value.slice(0, 8);
|
||||
}
|
||||
|
||||
function SessionMetric({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: typeof Clock3;
|
||||
}) {
|
||||
return (
|
||||
<Card className="gap-0 border-border/70 bg-card/70 backdrop-blur-sm">
|
||||
<CardContent className="flex items-center gap-4 p-5">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-2xl font-semibold tracking-tight text-foreground">{value}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReviewSessionsPage() {
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: ['review-sessions'],
|
||||
queryFn: fetchReviewSessions,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSessionId && sessionsQuery.data?.length) {
|
||||
setSelectedSessionId(sessionsQuery.data[0].session.id);
|
||||
}
|
||||
}, [selectedSessionId, sessionsQuery.data]);
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ['review-session-detail', selectedSessionId],
|
||||
queryFn: () => fetchReviewSessionDetail(selectedSessionId as string),
|
||||
enabled: !!selectedSessionId,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const metrics = useMemo(() => {
|
||||
const sessions = sessionsQuery.data ?? [];
|
||||
return {
|
||||
total: sessions.length,
|
||||
active: sessions.filter(({ summary }) => summary.status === 'planning' || summary.status === 'executing').length,
|
||||
waiting: sessions.filter(({ summary }) => summary.status === 'awaiting_human_feedback').length,
|
||||
findings: sessions.reduce((total, item) => total + item.summary.findingCount, 0),
|
||||
};
|
||||
}, [sessionsQuery.data]);
|
||||
|
||||
return (
|
||||
<div className="theme-page-frame">
|
||||
<div className="theme-page-content space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<SessionMetric label="PR 会话" value={metrics.total} icon={Waypoints} />
|
||||
<SessionMetric label="执行中" value={metrics.active} icon={RefreshCw} />
|
||||
<SessionMetric label="待人工确认" value={metrics.waiting} icon={AlertTriangle} />
|
||||
<SessionMetric label="累计 Findings" value={metrics.findings} icon={ListTodo} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<Card className="border-border/70 bg-card/80 backdrop-blur-sm">
|
||||
<CardHeader className="border-b border-border/60 pb-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-xl">审查会话</CardTitle>
|
||||
<CardDescription>每个 PR head 对应一个 session,支持计划与继续执行。</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => sessionsQuery.refetch()}
|
||||
className="border-border/70"
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
{sessionsQuery.isLoading && (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-24 rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sessionsQuery.isLoading && sessionsQuery.data?.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 bg-muted/20 p-6 text-sm text-muted-foreground">
|
||||
还没有审查会话。收到新的 PR webhook 后,这里会出现 session 与执行计划。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessionsQuery.data?.map(({ session, summary }) => {
|
||||
const selected = selectedSessionId === session.id;
|
||||
return (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSessionId(session.id)}
|
||||
className={`w-full rounded-2xl border p-4 text-left transition-all ${
|
||||
selected
|
||||
? 'border-primary/40 bg-primary/10 shadow-sm'
|
||||
: 'border-border/70 bg-card/60 hover:border-primary/20 hover:bg-accent/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-semibold tracking-tight text-foreground">
|
||||
{summary.owner}/{summary.repo}
|
||||
{summary.prNumber ? ` #${summary.prNumber}` : ''}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-xs text-muted-foreground">{summary.scopeKey}</div>
|
||||
</div>
|
||||
<Badge className={statusClassMap[summary.status]}>{statusLabelMap[summary.status]}</Badge>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-muted-foreground">当前步骤</div>
|
||||
<div className="mt-1 font-medium text-foreground">{summary.currentStep ?? '等待计划'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Head SHA</div>
|
||||
<div className="mt-1 font-mono text-foreground">{truncateSha(summary.headSha)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Findings</div>
|
||||
<div className="mt-1 font-medium text-foreground">{summary.findingCount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">更新时间</div>
|
||||
<div className="mt-1 font-medium text-foreground">{formatDate(summary.updatedAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/70 bg-card/80 backdrop-blur-sm">
|
||||
<CardHeader className="border-b border-border/60 pb-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-xl">会话详情</CardTitle>
|
||||
<CardDescription>审查结果、运行日志按 session 聚合。</CardDescription>
|
||||
</div>
|
||||
{detailQuery.data && (
|
||||
<Badge className={statusClassMap[detailQuery.data.summary.status]}>
|
||||
{statusLabelMap[detailQuery.data.summary.status]}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
{detailQuery.isLoading && <Skeleton className="h-[640px] rounded-2xl" />}
|
||||
|
||||
{!detailQuery.isLoading && !detailQuery.data && (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 bg-muted/20 p-8 text-sm text-muted-foreground">
|
||||
选择一个 session 查看它的执行计划与时间线。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailQuery.data && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/25 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Session</div>
|
||||
<div className="mt-2 font-mono text-sm text-foreground">{detailQuery.data.session.id.slice(0, 8)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/25 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Head SHA</div>
|
||||
<div className="mt-2 font-mono text-sm text-foreground">{truncateSha(detailQuery.data.summary.headSha)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/25 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-muted-foreground">当前步骤</div>
|
||||
<div className="mt-2 text-sm font-medium text-foreground">{detailQuery.data.summary.currentStep ?? '无'}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/25 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-muted-foreground">待执行任务</div>
|
||||
<div className="mt-2 text-sm font-medium text-foreground">{detailQuery.data.summary.pendingTaskCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="results" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="results">审查结果</TabsTrigger>
|
||||
<TabsTrigger value="logs">运行日志</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="results" className="space-y-4">
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">Findings</h4>
|
||||
{detailQuery.data.runDetails?.findings.length ? (
|
||||
detailQuery.data.runDetails.findings.map((finding) => (
|
||||
<div key={finding.id} className="mb-3 rounded-2xl border border-border/70 bg-card/60 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">{finding.severity === 'high' ? '🔴' : finding.severity === 'medium' ? '🟡' : '🔵'}</span>
|
||||
<span className="font-semibold text-foreground">{finding.title}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">{finding.path}:{finding.line}</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="outline">{finding.category}</Badge>
|
||||
<Badge className={finding.published ? 'bg-success/15 text-success border-success/20' : 'bg-warning/15 text-warning-foreground border-warning/20'}>
|
||||
{finding.published ? '已发布' : '待处理'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{finding.detail && <div className="mt-3 text-sm text-muted-foreground">{finding.detail}</div>}
|
||||
{finding.evidence && <div className="mt-2 rounded-lg border border-border/50 bg-muted/30 p-3 font-mono text-xs text-muted-foreground">{finding.evidence}</div>}
|
||||
{finding.suggestion && <div className="mt-2 text-sm text-foreground">💡 {finding.suggestion}</div>}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 bg-muted/20 p-6 text-sm text-muted-foreground">
|
||||
当前 session 暂无 findings。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">Gitea 评论</h4>
|
||||
{detailQuery.data.runDetails?.comments.length ? (
|
||||
detailQuery.data.runDetails.comments.map((comment) => (
|
||||
<div key={comment.id} className="mb-3 rounded-2xl border border-border/70 bg-card/60 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Badge variant="outline">{comment.status}</Badge>
|
||||
<div className="font-mono text-xs text-muted-foreground">{formatDate(comment.createdAt)}</div>
|
||||
</div>
|
||||
{(comment.path || comment.line) && (
|
||||
<div className="mt-2 text-xs font-mono text-muted-foreground">
|
||||
{[comment.path, comment.line].filter(Boolean).join(':')}
|
||||
</div>
|
||||
)}
|
||||
<pre className="mt-3 whitespace-pre-wrap break-words text-sm text-foreground">{comment.body}</pre>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 bg-muted/20 p-6 text-sm text-muted-foreground">
|
||||
当前 session 暂无评论产物。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="logs" className="space-y-4">
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">执行步骤</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{detailQuery.data.plan.map((step) => (
|
||||
<div
|
||||
key={step.key}
|
||||
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm ${planStatusClassMap[step.status]}`}
|
||||
>
|
||||
<span className="font-medium">{step.label}</span>
|
||||
<Badge className={planStatusClassMap[step.status]}>{step.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">事件流</h4>
|
||||
{detailQuery.data.timeline.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 bg-muted/20 p-6 text-sm text-muted-foreground">
|
||||
当前 session 还没有时间线事件。
|
||||
</div>
|
||||
)}
|
||||
{detailQuery.data.timeline.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`mb-2 rounded-2xl border p-4 ${timelineToneClassMap[entry.tone]}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-semibold text-foreground">{entry.title}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">{formatDate(entry.timestamp)}</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">{entry.detail}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,21 @@ export interface ConfigResponse {
|
||||
groups: ConfigGroupDto[];
|
||||
}
|
||||
|
||||
export type NotificationTestProvider = 'feishu' | 'wecom';
|
||||
export type NotificationTestStatus = 'success' | 'error';
|
||||
|
||||
export interface NotificationTestRecordDto {
|
||||
id: string;
|
||||
provider: string;
|
||||
status: NotificationTestStatus;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface NotificationTestHistoryResponse {
|
||||
data: NotificationTestRecordDto[];
|
||||
}
|
||||
|
||||
export const fetchConfig = async (): Promise<ConfigResponse> => {
|
||||
const response = await api.get<ConfigResponse>('/config');
|
||||
return response.data;
|
||||
@@ -42,3 +57,12 @@ export const updateConfig = async (configData: Record<string, string>): Promise<
|
||||
export const resetConfig = async (keys: string[]): Promise<void> => {
|
||||
await api.post('/config/reset', { keys });
|
||||
};
|
||||
|
||||
export const testNotification = async (provider: NotificationTestProvider): Promise<void> => {
|
||||
await api.post('/config/notification/test', { provider });
|
||||
};
|
||||
|
||||
export const fetchNotificationTestHistory = async (): Promise<NotificationTestRecordDto[]> => {
|
||||
const response = await api.get<NotificationTestHistoryResponse>('/config/notification/test/history');
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
@@ -23,6 +23,17 @@ export interface RoleAssignmentDto {
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
export interface KernelSubagentDto {
|
||||
kind: 'subagent';
|
||||
name: string;
|
||||
source: 'built-in' | 'custom' | 'plugin';
|
||||
description: string;
|
||||
whenToUse: string;
|
||||
modelRole?: string;
|
||||
tags: string[];
|
||||
resumable?: boolean;
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
success: boolean;
|
||||
latencyMs?: number;
|
||||
@@ -85,6 +96,11 @@ export const setRole = async (role: string, providerId: string | null, model: st
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const fetchKernelSubagents = async (): Promise<KernelSubagentDto[]> => {
|
||||
const response = await api.get<{ data: KernelSubagentDto[] }>('/review/kernel/subagents');
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const testProvider = async (id: string): Promise<TestResult> => {
|
||||
const response = await api.post<TestResult>(`/llm/providers/${id}/test`);
|
||||
return response.data;
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface Repository {
|
||||
name: string;
|
||||
webhook_status: 'active' | 'inactive';
|
||||
hook_id: number | null;
|
||||
project_review_prompt: string | null;
|
||||
}
|
||||
|
||||
export interface PaginatedRepositories {
|
||||
@@ -19,3 +20,13 @@ export const fetchRepositories = async (page: number = 1, query: string = ""): P
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateRepositoryProjectPrompt = async (
|
||||
repoName: string,
|
||||
projectReviewPrompt: string
|
||||
): Promise<{ success: boolean; project_review_prompt: string | null }> => {
|
||||
const { data } = await api.put(`/repositories/${repoName}/project-prompt`, {
|
||||
project_review_prompt: projectReviewPrompt,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
122
frontend/src/services/reviewSessionService.ts
Normal file
122
frontend/src/services/reviewSessionService.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import api from '@/lib/api';
|
||||
|
||||
export interface ReviewSessionSummaryRecordDto {
|
||||
session: {
|
||||
id: string;
|
||||
scopeType: 'pull_request' | 'commit';
|
||||
scopeKey: string;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastRunId?: string;
|
||||
};
|
||||
summary: {
|
||||
sessionId: string;
|
||||
scopeKey: string;
|
||||
scopeType: 'pull_request' | 'commit';
|
||||
owner?: string;
|
||||
repo?: string;
|
||||
prNumber?: number;
|
||||
headSha?: string;
|
||||
status:
|
||||
| 'queued'
|
||||
| 'planning'
|
||||
| 'executing'
|
||||
| 'awaiting_human_feedback'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'ignored';
|
||||
currentStep?: string;
|
||||
findingCount: number;
|
||||
pendingTaskCount: number;
|
||||
updatedAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReviewPlanStepDto {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
status: 'pending' | 'queued' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
progressText?: string;
|
||||
}
|
||||
|
||||
export interface ReviewTimelineEntryDto {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
tone: 'neutral' | 'success' | 'warning' | 'danger';
|
||||
}
|
||||
|
||||
export interface ReviewSessionDetailDto {
|
||||
session: ReviewSessionSummaryRecordDto['session'];
|
||||
summary: ReviewSessionSummaryRecordDto['summary'];
|
||||
checkpoint: {
|
||||
state: Record<string, unknown>;
|
||||
pendingTasks: Array<{ kind: 'skill' | 'subagent'; name: string; input?: Record<string, unknown> }>;
|
||||
stopReason?: string;
|
||||
} | null;
|
||||
plan: ReviewPlanStepDto[];
|
||||
timeline: ReviewTimelineEntryDto[];
|
||||
events: Array<{
|
||||
id: string;
|
||||
sessionId: string;
|
||||
eventType: string;
|
||||
payload: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}>;
|
||||
runDetails: {
|
||||
run: {
|
||||
id: string;
|
||||
eventType: string;
|
||||
status: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
prNumber?: number;
|
||||
commitSha?: string;
|
||||
headSha?: string;
|
||||
baseSha?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
findings: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
evidence: string;
|
||||
suggestion: string;
|
||||
severity: 'high' | 'medium' | 'low';
|
||||
category: string;
|
||||
path: string;
|
||||
line: number;
|
||||
confidence: number;
|
||||
published: boolean;
|
||||
fingerprint: string;
|
||||
}>;
|
||||
comments: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
body: string;
|
||||
path?: string;
|
||||
line?: number;
|
||||
createdAt: string;
|
||||
}>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ReviewSessionListResponse {
|
||||
data: ReviewSessionSummaryRecordDto[];
|
||||
}
|
||||
|
||||
export const fetchReviewSessions = async (): Promise<ReviewSessionSummaryRecordDto[]> => {
|
||||
const response = await api.get<ReviewSessionListResponse>('/review/sessions');
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const fetchReviewSessionDetail = async (
|
||||
sessionId: string
|
||||
): Promise<ReviewSessionDetailDto> => {
|
||||
const response = await api.get<ReviewSessionDetailDto>(`/review/sessions/${sessionId}`);
|
||||
return response.data;
|
||||
};
|
||||
@@ -2,8 +2,18 @@ import type { Page, Route } from '@playwright/test';
|
||||
|
||||
const repositories = {
|
||||
data: [
|
||||
{ name: 'demo-repo-1', webhook_status: 'active', hook_id: 101 },
|
||||
{ name: 'demo-repo-2', webhook_status: 'inactive', hook_id: null },
|
||||
{
|
||||
name: 'demo-repo-1',
|
||||
webhook_status: 'active',
|
||||
hook_id: 101,
|
||||
project_review_prompt: '重点检查 API 错误处理与鉴权边界。',
|
||||
},
|
||||
{
|
||||
name: 'demo-repo-2',
|
||||
webhook_status: 'inactive',
|
||||
hook_id: null,
|
||||
project_review_prompt: null,
|
||||
},
|
||||
],
|
||||
totalCount: 2,
|
||||
page: 1,
|
||||
@@ -31,21 +41,41 @@ const configResponse = {
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'feishu',
|
||||
label: '飞书通知',
|
||||
description: '配置飞书 webhook 通知。',
|
||||
key: 'notification',
|
||||
label: '通知服务',
|
||||
description: '配置飞书与企业微信通知。',
|
||||
icon: 'bell',
|
||||
fields: [
|
||||
{
|
||||
envKey: 'FEISHU_ENABLED',
|
||||
label: '启用飞书通知',
|
||||
description: '是否启用飞书通知',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
value: true,
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'FEISHU_WEBHOOK_URL',
|
||||
label: '飞书 Webhook URL',
|
||||
description: '用于发送审查通知',
|
||||
description: '用于发送飞书通知',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
value: 'https://open.feishu.cn/mock/webhook',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'WECOM_ENABLED',
|
||||
label: '启用企业微信通知',
|
||||
description: '是否启用企业微信通知',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
value: false,
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -77,9 +107,9 @@ const configResponse = {
|
||||
label: '审查引擎',
|
||||
description: '当前使用的审查引擎',
|
||||
type: 'enum',
|
||||
enumValues: ['agent', 'codex'],
|
||||
enumValues: ['kernel', 'codex'],
|
||||
sensitive: false,
|
||||
value: 'agent',
|
||||
value: 'kernel',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
@@ -237,6 +267,23 @@ const modelSuggestions = {
|
||||
gemini: ['gemini-2.5-pro'],
|
||||
};
|
||||
|
||||
const notificationTestHistory = [
|
||||
{
|
||||
id: 'test-1',
|
||||
provider: 'feishu',
|
||||
status: 'success',
|
||||
message: 'feishu 测试通知已发送',
|
||||
timestamp: '2026-03-24T09:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
provider: 'wecom',
|
||||
status: 'error',
|
||||
message: 'wecom 未启用或未配置',
|
||||
timestamp: '2026-03-24T08:50:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const json = async (route: Route, body: unknown, status = 200) => {
|
||||
await route.fulfill({
|
||||
status,
|
||||
@@ -259,14 +306,27 @@ export async function installVisualApiMocks(page: Page) {
|
||||
return json(route, repositories);
|
||||
}
|
||||
|
||||
if (method === 'POST' && /\/admin\/api\/repositories\/[^/]+\/webhook$/.test(path)) {
|
||||
if (method === 'POST' && /\/admin\/api\/repositories\/[^/]+(?:\/[^/]+)?\/webhook$/.test(path)) {
|
||||
return json(route, { hook_id: 101, webhook_status: 'active' });
|
||||
}
|
||||
|
||||
if (method === 'DELETE' && /\/admin\/api\/repositories\/[^/]+\/webhook\/\d+$/.test(path)) {
|
||||
if (
|
||||
method === 'DELETE' &&
|
||||
/\/admin\/api\/repositories\/[^/]+(?:\/[^/]+)?\/webhook\/\d+$/.test(path)
|
||||
) {
|
||||
return route.fulfill({ status: 204, body: '' });
|
||||
}
|
||||
|
||||
if (
|
||||
method === 'PUT' &&
|
||||
/\/admin\/api\/repositories\/[^/]+(?:\/[^/]+)?\/project-prompt$/.test(path)
|
||||
) {
|
||||
return json(route, {
|
||||
success: true,
|
||||
project_review_prompt: 'updated prompt',
|
||||
});
|
||||
}
|
||||
|
||||
if (method === 'GET' && path.endsWith('/admin/api/config')) {
|
||||
return json(route, configResponse);
|
||||
}
|
||||
@@ -279,6 +339,14 @@ export async function installVisualApiMocks(page: Page) {
|
||||
return route.fulfill({ status: 204, body: '' });
|
||||
}
|
||||
|
||||
if (method === 'POST' && path.endsWith('/admin/api/config/notification/test')) {
|
||||
return json(route, { success: true, message: 'test sent' });
|
||||
}
|
||||
|
||||
if (method === 'GET' && path.endsWith('/admin/api/config/notification/test/history')) {
|
||||
return json(route, { data: notificationTestHistory });
|
||||
}
|
||||
|
||||
if (method === 'GET' && path.endsWith('/admin/api/llm/model-suggestions')) {
|
||||
return json(route, modelSuggestions);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ metadata:
|
||||
app.kubernetes.io/name: gitea-assistant
|
||||
app.kubernetes.io/part-of: gitea-assistant
|
||||
data:
|
||||
PORT: "3000"
|
||||
PORT: "5174"
|
||||
LOG_LEVEL: "error"
|
||||
# All settings (Gitea connection, webhook secret, admin password, review engine,
|
||||
# Feishu, memory, etc.) are managed through the Admin Dashboard Web UI.
|
||||
# They are auto-seeded with secure defaults on first boot.
|
||||
@@ -38,7 +39,7 @@ spec:
|
||||
image: ghcr.io/jeffusion/gitea-ai-assistant:latest
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3000
|
||||
containerPort: 5174
|
||||
protocol: TCP
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
@@ -92,6 +93,6 @@ spec:
|
||||
app.kubernetes.io/name: gitea-assistant
|
||||
ports:
|
||||
- name: http
|
||||
port: 3000
|
||||
port: 5174
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitea-assistant",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0-develop",
|
||||
"description": "Gitea功能增强助手,包含AI代码审核功能",
|
||||
"engines": {
|
||||
"bun": ">=1.2.5"
|
||||
@@ -15,6 +15,7 @@
|
||||
"hono": "^4.11.9",
|
||||
"lodash-es": "^4.17.21",
|
||||
"openai": "^4.87.3",
|
||||
"pino": "^10.3.1",
|
||||
"tokenlens": "^1.3.1",
|
||||
"zod": "^3.25.1",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
@@ -38,6 +39,8 @@
|
||||
"main": "./dist/index.js",
|
||||
"typings": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"bootstrap": "bun install && (cd frontend && bun install)",
|
||||
"postinstall": "if [ -d frontend ]; then (cd frontend && bun install); fi",
|
||||
"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",
|
||||
@@ -49,7 +52,8 @@
|
||||
"start:prod": "bun run dist/index.js",
|
||||
"lint": "biome check src/",
|
||||
"test": "bun test",
|
||||
"prepare": "husky"
|
||||
"test:e2e": "E2E_MOCK_LLM=1 bun test ./e2e/__tests__/e2e-review.test.ts",
|
||||
"prepare": "command -v husky >/dev/null 2>&1 && husky || true"
|
||||
},
|
||||
"keywords": [
|
||||
"code-review",
|
||||
|
||||
134
src/agent-kernel/__tests__/agent-kernel-runner.test.ts
Normal file
134
src/agent-kernel/__tests__/agent-kernel-runner.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
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 { closeDatabase, initDatabase } from '../../db/database';
|
||||
import { KernelAgentInvoker } from '../agents/kernel-agent-invoker';
|
||||
import { KernelAgentRegistry } from '../agents/kernel-agent-registry';
|
||||
import { KernelTaskRegistry } from '../registry/kernel-task-registry';
|
||||
import { AgentKernelRunner } from '../runtime/agent-kernel-runner';
|
||||
import { kernelSessionRepository } from '../session/session-repository';
|
||||
|
||||
interface DummyState {
|
||||
counter: number;
|
||||
}
|
||||
|
||||
describe('AgentKernelRunner', () => {
|
||||
let tempDir: string;
|
||||
let savedDbPath: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), 'kernel-runner-db-'));
|
||||
savedDbPath = process.env.DATABASE_PATH;
|
||||
process.env.DATABASE_PATH = path.join(tempDir, 'assistant.db');
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('runs queued skills and subagents and persists checkpoint', async () => {
|
||||
const session = kernelSessionRepository.ensureSession({
|
||||
scopeType: 'pull_request',
|
||||
scopeKey: 'acme/repo#7',
|
||||
metadata: { owner: 'acme', repo: 'repo', prNumber: 7 },
|
||||
runId: 'run-7',
|
||||
});
|
||||
|
||||
const skillRegistry = new KernelTaskRegistry<DummyState>();
|
||||
const subagentRegistry = new KernelAgentRegistry<DummyState>();
|
||||
|
||||
skillRegistry.register({
|
||||
kind: 'skill',
|
||||
name: 'step_one',
|
||||
description: 'Initial skill for runner test',
|
||||
execute: async () => ({
|
||||
state: { counter: 1 },
|
||||
enqueue: [{ kind: 'subagent', name: 'step_two' }],
|
||||
}),
|
||||
});
|
||||
|
||||
subagentRegistry.register({
|
||||
kind: 'subagent',
|
||||
name: 'step_two',
|
||||
source: 'built-in',
|
||||
whenToUse: 'Increment the test counter',
|
||||
description: 'Test subagent used by runner tests',
|
||||
execute: async (_task, context) => ({
|
||||
state: { counter: context.state.counter + 1 },
|
||||
}),
|
||||
});
|
||||
|
||||
const runner = new AgentKernelRunner(skillRegistry, new KernelAgentInvoker(subagentRegistry), {
|
||||
plan: () => [],
|
||||
});
|
||||
const checkpoint = await runner.run({
|
||||
sessionId: session.id,
|
||||
runId: 'run-7',
|
||||
initialState: { counter: 0 },
|
||||
initialTasks: [{ kind: 'skill', name: 'step_one' }],
|
||||
});
|
||||
|
||||
const events = kernelSessionRepository.listEvents(session.id);
|
||||
|
||||
expect(checkpoint.state.counter).toBe(2);
|
||||
expect(checkpoint.pendingTasks).toHaveLength(0);
|
||||
expect(checkpoint.stopReason).toBe('completed');
|
||||
expect(events.map((event) => event.eventType).sort()).toEqual([
|
||||
'task_completed',
|
||||
'task_completed',
|
||||
'task_started',
|
||||
'task_started',
|
||||
]);
|
||||
});
|
||||
|
||||
test('continueExisting ignores persisted stop reason and resumes planned work', async () => {
|
||||
const session = kernelSessionRepository.ensureSession({
|
||||
scopeType: 'pull_request',
|
||||
scopeKey: 'acme/repo#8',
|
||||
metadata: { owner: 'acme', repo: 'repo', prNumber: 8 },
|
||||
runId: 'run-8',
|
||||
});
|
||||
|
||||
kernelSessionRepository.saveCheckpoint(session.id, {
|
||||
state: { counter: 1 },
|
||||
pendingTasks: [],
|
||||
stopReason: 'awaiting_human_feedback',
|
||||
});
|
||||
|
||||
const skillRegistry = new KernelTaskRegistry<DummyState>();
|
||||
const subagentRegistry = new KernelAgentRegistry<DummyState>();
|
||||
|
||||
skillRegistry.register({
|
||||
kind: 'skill',
|
||||
name: 'resume_step',
|
||||
description: 'Resume skill for runner test',
|
||||
execute: async (_task, context) => ({
|
||||
state: { counter: context.state.counter + 1 },
|
||||
}),
|
||||
});
|
||||
|
||||
const runner = new AgentKernelRunner(skillRegistry, new KernelAgentInvoker(subagentRegistry), {
|
||||
plan: (context) =>
|
||||
context.state.counter < 2 ? [{ kind: 'skill', name: 'resume_step' }] : [],
|
||||
});
|
||||
|
||||
const checkpoint = await runner.run({
|
||||
sessionId: session.id,
|
||||
runId: 'run-8',
|
||||
initialState: { counter: 0 },
|
||||
initialTasks: [],
|
||||
continueExisting: true,
|
||||
});
|
||||
|
||||
expect(checkpoint.state.counter).toBe(2);
|
||||
expect(checkpoint.stopReason).toBe('completed');
|
||||
});
|
||||
});
|
||||
81
src/agent-kernel/__tests__/kernel-agent-invoker.test.ts
Normal file
81
src/agent-kernel/__tests__/kernel-agent-invoker.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
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 { closeDatabase, initDatabase } from '../../db/database';
|
||||
import { getKernelAgentContext } from '../agents/kernel-agent-context';
|
||||
import { KernelAgentInvoker } from '../agents/kernel-agent-invoker';
|
||||
import { KernelAgentRegistry } from '../agents/kernel-agent-registry';
|
||||
import { kernelSessionRepository } from '../session/session-repository';
|
||||
|
||||
interface DummyState {
|
||||
value: number;
|
||||
}
|
||||
|
||||
describe('KernelAgentInvoker', () => {
|
||||
let tempDir: string;
|
||||
let savedDbPath: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), 'kernel-agent-invoker-db-'));
|
||||
savedDbPath = process.env.DATABASE_PATH;
|
||||
process.env.DATABASE_PATH = path.join(tempDir, 'assistant.db');
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('invokes subagent with isolated agent context and structured result', async () => {
|
||||
const session = kernelSessionRepository.ensureSession({
|
||||
scopeType: 'pull_request',
|
||||
scopeKey: 'acme/repo#88',
|
||||
metadata: { owner: 'acme', repo: 'repo', prNumber: 88 },
|
||||
runId: 'run-88',
|
||||
});
|
||||
|
||||
const registry = new KernelAgentRegistry<DummyState>();
|
||||
registry.register({
|
||||
kind: 'subagent',
|
||||
name: 'test:subagent',
|
||||
source: 'built-in',
|
||||
description: 'Test subagent',
|
||||
whenToUse: 'Used by invoker test',
|
||||
tags: ['test'],
|
||||
execute: async (_task, context) => {
|
||||
const agentContext = getKernelAgentContext();
|
||||
expect(agentContext?.agentType).toBe('subagent');
|
||||
expect(agentContext?.subagentName).toBe('test:subagent');
|
||||
expect(context.delegation.parentSessionId).toBe(session.id);
|
||||
|
||||
return {
|
||||
state: { value: context.state.value + 1 },
|
||||
summary: 'subagent completed',
|
||||
artifacts: { nextValue: context.state.value + 1 },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const invoker = new KernelAgentInvoker(registry);
|
||||
const output = await invoker.invoke(
|
||||
{ kind: 'subagent', name: 'test:subagent', input: { focus: 'test' } },
|
||||
{
|
||||
session,
|
||||
runId: 'run-88',
|
||||
state: { value: 1 },
|
||||
}
|
||||
);
|
||||
|
||||
expect(output.result?.state).toEqual({ value: 2 });
|
||||
expect(output.invocation.status).toBe('completed');
|
||||
expect(output.invocation.result?.summary).toBe('subagent completed');
|
||||
expect(output.invocation.result?.artifacts).toEqual({ nextValue: 2 });
|
||||
});
|
||||
});
|
||||
101
src/agent-kernel/__tests__/session-repository.test.ts
Normal file
101
src/agent-kernel/__tests__/session-repository.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
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 { closeDatabase, initDatabase } from '../../db/database';
|
||||
import { kernelSessionRepository } from '../session/session-repository';
|
||||
|
||||
describe('KernelSessionRepository', () => {
|
||||
let tempDir: string;
|
||||
let savedDbPath: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), 'kernel-session-db-'));
|
||||
savedDbPath = process.env.DATABASE_PATH;
|
||||
process.env.DATABASE_PATH = path.join(tempDir, 'assistant.db');
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('ensureSession reuses the same scope key and updates metadata', () => {
|
||||
const first = kernelSessionRepository.ensureSession({
|
||||
scopeType: 'pull_request',
|
||||
scopeKey: 'acme/repo#42',
|
||||
metadata: { owner: 'acme', repo: 'repo', prNumber: 42 },
|
||||
runId: 'run-1',
|
||||
});
|
||||
|
||||
const second = kernelSessionRepository.ensureSession({
|
||||
scopeType: 'pull_request',
|
||||
scopeKey: 'acme/repo#42',
|
||||
metadata: { owner: 'acme', repo: 'repo', prNumber: 42, updated: true },
|
||||
runId: 'run-2',
|
||||
});
|
||||
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(second.lastRunId).toBe('run-2');
|
||||
expect(second.metadata).toEqual({ owner: 'acme', repo: 'repo', prNumber: 42, updated: true });
|
||||
});
|
||||
|
||||
test('appendEvent and saveCheckpoint persist session runtime state', () => {
|
||||
const session = kernelSessionRepository.ensureSession({
|
||||
scopeType: 'pull_request',
|
||||
scopeKey: 'acme/repo#99',
|
||||
metadata: { owner: 'acme', repo: 'repo', prNumber: 99 },
|
||||
runId: 'run-99',
|
||||
});
|
||||
|
||||
kernelSessionRepository.appendEvent(session.id, 'review_enqueued', { runId: 'run-99' });
|
||||
kernelSessionRepository.appendEvent(session.id, 'task_started', { name: 'prepare_workspace' });
|
||||
kernelSessionRepository.saveCheckpoint(session.id, {
|
||||
state: { prepared: true, findings: 3 },
|
||||
pendingTasks: [{ kind: 'skill', name: 'publish_review' }],
|
||||
stopReason: 'waiting',
|
||||
});
|
||||
|
||||
const events = kernelSessionRepository.listEvents(session.id);
|
||||
const checkpoint = kernelSessionRepository.loadCheckpoint<{
|
||||
prepared: boolean;
|
||||
findings: number;
|
||||
}>(session.id);
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events.map((event) => event.eventType).sort()).toEqual([
|
||||
'review_enqueued',
|
||||
'task_started',
|
||||
]);
|
||||
expect(checkpoint).not.toBeNull();
|
||||
expect(checkpoint?.state).toEqual({ prepared: true, findings: 3 });
|
||||
expect(checkpoint?.pendingTasks).toEqual([{ kind: 'skill', name: 'publish_review' }]);
|
||||
expect(checkpoint?.stopReason).toBe('waiting');
|
||||
});
|
||||
|
||||
test('can query sessions by scope key and list sessions', () => {
|
||||
const first = kernelSessionRepository.ensureSession({
|
||||
scopeType: 'pull_request',
|
||||
scopeKey: 'acme/repo#1',
|
||||
metadata: { owner: 'acme', repo: 'repo', prNumber: 1 },
|
||||
runId: 'run-1',
|
||||
});
|
||||
const second = kernelSessionRepository.ensureSession({
|
||||
scopeType: 'pull_request',
|
||||
scopeKey: 'acme/repo#2',
|
||||
metadata: { owner: 'acme', repo: 'repo', prNumber: 2 },
|
||||
runId: 'run-2',
|
||||
});
|
||||
|
||||
expect(kernelSessionRepository.getSessionByScopeKey('acme/repo#1')?.id).toBe(first.id);
|
||||
expect(kernelSessionRepository.listSessions(10).map((session) => session.id)).toEqual(
|
||||
expect.arrayContaining([first.id, second.id])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
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 { closeDatabase, initDatabase } from '../../db/database';
|
||||
import { kernelSessionRepository } from '../session/session-repository';
|
||||
|
||||
describe('KernelSessionRepository subagent invocations', () => {
|
||||
let tempDir: string;
|
||||
let savedDbPath: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), 'kernel-subagent-db-'));
|
||||
savedDbPath = process.env.DATABASE_PATH;
|
||||
process.env.DATABASE_PATH = path.join(tempDir, 'assistant.db');
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('persists and lists subagent invocations', () => {
|
||||
const session = kernelSessionRepository.ensureSession({
|
||||
scopeType: 'pull_request',
|
||||
scopeKey: 'acme/repo#101',
|
||||
metadata: { owner: 'acme', repo: 'repo', prNumber: 101 },
|
||||
runId: 'run-101',
|
||||
});
|
||||
|
||||
const invocation = kernelSessionRepository.createSubagentInvocation({
|
||||
parentSessionId: session.id,
|
||||
parentRunId: 'run-101',
|
||||
parentTaskName: 'custom:security-audit',
|
||||
subagentName: 'custom:security-audit',
|
||||
agentId: 'agent-123',
|
||||
packet: {
|
||||
goal: 'Review security issues',
|
||||
parentTaskName: 'custom:security-audit',
|
||||
input: { domain: 'security' },
|
||||
parentSessionId: session.id,
|
||||
parentRunId: 'run-101',
|
||||
contextSummary: 'summary',
|
||||
},
|
||||
});
|
||||
|
||||
kernelSessionRepository.completeSubagentInvocation(invocation.id, 'completed', {
|
||||
agentId: 'agent-123',
|
||||
agentType: 'custom:security-audit',
|
||||
summary: 'security review done',
|
||||
totalDurationMs: 10,
|
||||
totalToolUseCount: 0,
|
||||
totalTokens: 0,
|
||||
artifacts: { findings: 2 },
|
||||
});
|
||||
|
||||
const invocations = kernelSessionRepository.listSubagentInvocations(session.id);
|
||||
expect(invocations).toHaveLength(1);
|
||||
expect(invocations[0]?.subagentName).toBe('custom:security-audit');
|
||||
expect(invocations[0]?.result?.summary).toBe('security review done');
|
||||
});
|
||||
});
|
||||
15
src/agent-kernel/agents/kernel-agent-context.ts
Normal file
15
src/agent-kernel/agents/kernel-agent-context.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import type { KernelSubagentContextRecord } from '../types';
|
||||
|
||||
const kernelAgentContextStorage = new AsyncLocalStorage<KernelSubagentContextRecord>();
|
||||
|
||||
export function getKernelAgentContext(): KernelSubagentContextRecord | undefined {
|
||||
return kernelAgentContextStorage.getStore();
|
||||
}
|
||||
|
||||
export function runWithKernelAgentContext<T>(
|
||||
context: KernelSubagentContextRecord,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
return kernelAgentContextStorage.run(context, fn);
|
||||
}
|
||||
140
src/agent-kernel/agents/kernel-agent-invoker.ts
Normal file
140
src/agent-kernel/agents/kernel-agent-invoker.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { KernelHookRegistry } from '../hooks/kernel-hook-registry';
|
||||
import { runKernelHooks } from '../hooks/kernel-hook-runner';
|
||||
import { kernelSessionRepository } from '../session/session-repository';
|
||||
import type {
|
||||
KernelAgentExecutionContext,
|
||||
KernelDelegationPacket,
|
||||
KernelExecutionContext,
|
||||
KernelHandlerResult,
|
||||
KernelSubagentDefinition,
|
||||
KernelTask,
|
||||
} from '../types';
|
||||
import { runWithKernelAgentContext } from './kernel-agent-context';
|
||||
import { KernelAgentRegistry } from './kernel-agent-registry';
|
||||
import { finalizeKernelSubagentResult } from './kernel-subagent-result';
|
||||
|
||||
export interface KernelSubagentInvocationOutput<TState> {
|
||||
result?: KernelHandlerResult<TState>;
|
||||
invocation: ReturnType<typeof kernelSessionRepository.listSubagentInvocations>[number];
|
||||
}
|
||||
|
||||
export class KernelAgentInvoker<TState> {
|
||||
constructor(
|
||||
private readonly registry: KernelAgentRegistry<TState>,
|
||||
private readonly hookRegistry?: KernelHookRegistry
|
||||
) {}
|
||||
|
||||
get(name: string): KernelSubagentDefinition<TState> | undefined {
|
||||
return this.registry.get(name);
|
||||
}
|
||||
|
||||
getAll(): KernelSubagentDefinition<TState>[] {
|
||||
return this.registry.getAll();
|
||||
}
|
||||
|
||||
filterByTag(tag: string): KernelSubagentDefinition<TState>[] {
|
||||
return this.registry.filterByTag(tag);
|
||||
}
|
||||
|
||||
async invoke(
|
||||
task: KernelTask,
|
||||
context: KernelExecutionContext<TState>
|
||||
): Promise<KernelSubagentInvocationOutput<TState>> {
|
||||
const agent = this.registry.get(task.name);
|
||||
if (!agent) {
|
||||
throw new Error(`Kernel subagent definition not found: ${task.name}`);
|
||||
}
|
||||
|
||||
const agentId = randomUUID();
|
||||
const delegation: KernelDelegationPacket = {
|
||||
goal: agent.whenToUse,
|
||||
parentTaskName: task.name,
|
||||
input: task.input ?? {},
|
||||
parentSessionId: context.session.id,
|
||||
parentRunId: context.runId,
|
||||
contextSummary:
|
||||
typeof (context.state as { compressedContext?: { summary?: string } }).compressedContext
|
||||
?.summary === 'string'
|
||||
? (context.state as { compressedContext?: { summary?: string } }).compressedContext
|
||||
?.summary
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const invocation = kernelSessionRepository.createSubagentInvocation({
|
||||
parentSessionId: context.session.id,
|
||||
parentRunId: context.runId,
|
||||
parentTaskName: task.name,
|
||||
subagentName: agent.name,
|
||||
agentId,
|
||||
packet: delegation,
|
||||
});
|
||||
|
||||
const agentContext: KernelAgentExecutionContext<TState> = {
|
||||
...context,
|
||||
agent,
|
||||
delegation,
|
||||
};
|
||||
|
||||
if (this.hookRegistry) {
|
||||
const hookResult = await runKernelHooks({
|
||||
registry: this.hookRegistry,
|
||||
input: {
|
||||
event: 'SubagentStart',
|
||||
sessionId: context.session.id,
|
||||
runId: context.runId,
|
||||
subagentName: agent.name,
|
||||
agentId,
|
||||
packet: delegation,
|
||||
},
|
||||
});
|
||||
if (hookResult.blockingReason) {
|
||||
throw new Error(hookResult.blockingReason);
|
||||
}
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const result = await runWithKernelAgentContext(
|
||||
{
|
||||
agentId,
|
||||
parentSessionId: context.session.id,
|
||||
agentType: 'subagent',
|
||||
subagentName: agent.name,
|
||||
source: agent.source,
|
||||
invocationKind: 'spawn',
|
||||
},
|
||||
() => agent.execute(task, agentContext)
|
||||
);
|
||||
|
||||
const finalized = finalizeKernelSubagentResult({
|
||||
agentId,
|
||||
agentType: agent.name,
|
||||
startTime,
|
||||
result,
|
||||
});
|
||||
|
||||
return {
|
||||
result,
|
||||
invocation: kernelSessionRepository.completeSubagentInvocation(
|
||||
invocation.id,
|
||||
'completed',
|
||||
finalized
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
const finalized = finalizeKernelSubagentResult({
|
||||
agentId,
|
||||
agentType: agent.name,
|
||||
startTime,
|
||||
result: {
|
||||
summary: error instanceof Error ? error.message : String(error),
|
||||
artifacts: { error: error instanceof Error ? error.message : String(error) },
|
||||
},
|
||||
});
|
||||
|
||||
kernelSessionRepository.completeSubagentInvocation(invocation.id, 'failed', finalized);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/agent-kernel/agents/kernel-agent-registry.ts
Normal file
21
src/agent-kernel/agents/kernel-agent-registry.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { KernelSubagentDefinition } from '../types';
|
||||
|
||||
export class KernelAgentRegistry<TState> {
|
||||
private readonly agents = new Map<string, KernelSubagentDefinition<TState>>();
|
||||
|
||||
register(agent: KernelSubagentDefinition<TState>): void {
|
||||
this.agents.set(agent.name, agent);
|
||||
}
|
||||
|
||||
get(agentType: string): KernelSubagentDefinition<TState> | undefined {
|
||||
return this.agents.get(agentType);
|
||||
}
|
||||
|
||||
getAll(): KernelSubagentDefinition<TState>[] {
|
||||
return [...this.agents.values()];
|
||||
}
|
||||
|
||||
filterByTag(tag: string): KernelSubagentDefinition<TState>[] {
|
||||
return this.getAll().filter((agent) => agent.tags?.includes(tag));
|
||||
}
|
||||
}
|
||||
21
src/agent-kernel/agents/kernel-subagent-result.ts
Normal file
21
src/agent-kernel/agents/kernel-subagent-result.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { KernelHandlerResult, KernelSubagentInvocationResult } from '../types';
|
||||
|
||||
export function finalizeKernelSubagentResult<TState>(params: {
|
||||
agentId: string;
|
||||
agentType: string;
|
||||
startTime: number;
|
||||
result?: KernelHandlerResult<TState>;
|
||||
}): KernelSubagentInvocationResult {
|
||||
const { agentId, agentType, startTime, result } = params;
|
||||
const totalDurationMs = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
agentId,
|
||||
agentType,
|
||||
summary: result?.summary ?? `${agentType} completed`,
|
||||
totalDurationMs,
|
||||
totalToolUseCount: 0,
|
||||
totalTokens: 0,
|
||||
artifacts: result?.artifacts,
|
||||
};
|
||||
}
|
||||
219
src/agent-kernel/hooks/__tests__/kernel-hook-runner.test.ts
Normal file
219
src/agent-kernel/hooks/__tests__/kernel-hook-runner.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { KernelHookRegistry } from '../kernel-hook-registry';
|
||||
import { runKernelHooks } from '../kernel-hook-runner';
|
||||
import type { KernelHookDefinition, KernelHookInput } from '../kernel-hook-types';
|
||||
|
||||
const baseContext = {
|
||||
workspacePath: '/tmp/workspace',
|
||||
mirrorPath: '/tmp/mirror',
|
||||
runId: 'run-1',
|
||||
};
|
||||
|
||||
function makeRegistry(hooks: KernelHookDefinition[]): KernelHookRegistry {
|
||||
const registry = new KernelHookRegistry();
|
||||
for (const hook of hooks) {
|
||||
registry.register(hook);
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
function makeHook(
|
||||
name: string,
|
||||
event: KernelHookInput['event'],
|
||||
execute: KernelHookDefinition['execute']
|
||||
): KernelHookDefinition {
|
||||
return {
|
||||
name,
|
||||
event,
|
||||
description: `Test hook ${name}`,
|
||||
execute,
|
||||
};
|
||||
}
|
||||
|
||||
describe('runKernelHooks', () => {
|
||||
test.each([
|
||||
[
|
||||
'SessionStart',
|
||||
{
|
||||
event: 'SessionStart',
|
||||
sessionId: 'session-1',
|
||||
runId: 'run-1',
|
||||
scopeKey: 'repo#1',
|
||||
},
|
||||
],
|
||||
[
|
||||
'SubagentStart',
|
||||
{
|
||||
event: 'SubagentStart',
|
||||
sessionId: 'session-1',
|
||||
runId: 'run-1',
|
||||
subagentName: 'test:subagent',
|
||||
agentId: 'agent-1',
|
||||
packet: {
|
||||
input: { focus: 'test' },
|
||||
goal: 'test goal',
|
||||
parentTaskName: 'test:task',
|
||||
parentSessionId: 'session-1',
|
||||
parentRunId: 'run-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'PermissionRequest',
|
||||
{
|
||||
event: 'PermissionRequest',
|
||||
toolName: 'write_file',
|
||||
toolCallId: 'call-1',
|
||||
input: { value: 'raw' },
|
||||
context: baseContext,
|
||||
suggestedBehavior: 'ask',
|
||||
reason: 'needs approval',
|
||||
},
|
||||
],
|
||||
[
|
||||
'PreToolUse',
|
||||
{
|
||||
event: 'PreToolUse',
|
||||
toolName: 'write_file',
|
||||
toolCallId: 'call-1',
|
||||
input: { value: 'raw' },
|
||||
context: baseContext,
|
||||
},
|
||||
],
|
||||
[
|
||||
'PostToolUse',
|
||||
{
|
||||
event: 'PostToolUse',
|
||||
toolName: 'write_file',
|
||||
toolCallId: 'call-1',
|
||||
input: { value: 'raw' },
|
||||
output: { ok: true },
|
||||
context: baseContext,
|
||||
},
|
||||
],
|
||||
[
|
||||
'PostToolUseFailure',
|
||||
{
|
||||
event: 'PostToolUseFailure',
|
||||
toolName: 'write_file',
|
||||
toolCallId: 'call-1',
|
||||
input: { value: 'raw' },
|
||||
error: 'boom',
|
||||
context: baseContext,
|
||||
},
|
||||
],
|
||||
] as const)('dispatches %s to matching hooks', async (_label, input) => {
|
||||
const executed: string[] = [];
|
||||
const registry = makeRegistry([
|
||||
makeHook('first', input.event, async () => {
|
||||
executed.push('first');
|
||||
return { additionalContext: 'ctx-1' };
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await runKernelHooks({ registry, input });
|
||||
|
||||
expect(executed).toEqual(['first']);
|
||||
expect(result.results).toHaveLength(1);
|
||||
expect(result.additionalContexts).toEqual(['ctx-1']);
|
||||
});
|
||||
|
||||
test('aggregates additionalContext values and lets later updatedInput override earlier values', async () => {
|
||||
const registry = makeRegistry([
|
||||
makeHook('first', 'PreToolUse', async () => ({
|
||||
additionalContext: 'ctx-1',
|
||||
updatedInput: { value: 'first' },
|
||||
})),
|
||||
makeHook('second', 'PreToolUse', async () => ({
|
||||
additionalContext: 'ctx-2',
|
||||
updatedInput: { value: 'second' },
|
||||
})),
|
||||
]);
|
||||
|
||||
const result = await runKernelHooks({
|
||||
registry,
|
||||
input: {
|
||||
event: 'PreToolUse',
|
||||
toolName: 'write_file',
|
||||
toolCallId: 'call-1',
|
||||
input: { value: 'raw' },
|
||||
context: baseContext,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.additionalContexts).toEqual(['ctx-1', 'ctx-2']);
|
||||
expect(result.updatedInput).toEqual({ value: 'second' });
|
||||
expect(result.results).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('propagates blockingReason when a hook returns decision block', async () => {
|
||||
const registry = makeRegistry([
|
||||
makeHook('before', 'PermissionRequest', async () => ({
|
||||
additionalContext: 'ctx-before',
|
||||
updatedInput: { value: 'before' },
|
||||
})),
|
||||
makeHook('blocker', 'PermissionRequest', async () => ({
|
||||
decision: 'block',
|
||||
reason: 'blocked by policy',
|
||||
additionalContext: 'ctx-blocker',
|
||||
updatedInput: { value: 'blocked' },
|
||||
})),
|
||||
makeHook('after', 'PermissionRequest', async () => ({
|
||||
additionalContext: 'ctx-after',
|
||||
updatedInput: { value: 'after' },
|
||||
})),
|
||||
]);
|
||||
|
||||
const result = await runKernelHooks({
|
||||
registry,
|
||||
input: {
|
||||
event: 'PermissionRequest',
|
||||
toolName: 'write_file',
|
||||
toolCallId: 'call-1',
|
||||
input: { value: 'raw' },
|
||||
context: baseContext,
|
||||
suggestedBehavior: 'ask',
|
||||
reason: 'needs approval',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.additionalContexts).toEqual(['ctx-before', 'ctx-blocker']);
|
||||
expect(result.updatedInput).toEqual({ value: 'blocked' });
|
||||
expect(result.blockingReason).toBe('blocked by policy');
|
||||
expect(result.results).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('preserves approve decisions for PermissionRequest without introducing a blocking reason', async () => {
|
||||
const registry = makeRegistry([
|
||||
makeHook('approver', 'PermissionRequest', async () => ({
|
||||
decision: 'approve',
|
||||
reason: 'approved by reviewer',
|
||||
additionalContext: 'ctx-approve',
|
||||
updatedInput: { value: 'approved' },
|
||||
})),
|
||||
]);
|
||||
|
||||
const result = await runKernelHooks({
|
||||
registry,
|
||||
input: {
|
||||
event: 'PermissionRequest',
|
||||
toolName: 'write_file',
|
||||
toolCallId: 'call-1',
|
||||
input: { value: 'raw' },
|
||||
context: baseContext,
|
||||
suggestedBehavior: 'ask',
|
||||
reason: 'needs approval',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.additionalContexts).toEqual(['ctx-approve']);
|
||||
expect(result.updatedInput).toEqual({ value: 'approved' });
|
||||
expect(result.blockingReason).toBeUndefined();
|
||||
expect(result.results).toEqual([
|
||||
expect.objectContaining({
|
||||
decision: 'approve',
|
||||
reason: 'approved by reviewer',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
19
src/agent-kernel/hooks/kernel-hook-registry.ts
Normal file
19
src/agent-kernel/hooks/kernel-hook-registry.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { KernelHookDefinition, KernelHookEventName } from './kernel-hook-types';
|
||||
|
||||
export class KernelHookRegistry {
|
||||
private readonly hooks = new Map<KernelHookEventName, KernelHookDefinition[]>();
|
||||
|
||||
register(hook: KernelHookDefinition): void {
|
||||
const existing = this.hooks.get(hook.event) ?? [];
|
||||
existing.push(hook);
|
||||
this.hooks.set(hook.event, existing);
|
||||
}
|
||||
|
||||
get(event: KernelHookEventName): KernelHookDefinition[] {
|
||||
return this.hooks.get(event) ?? [];
|
||||
}
|
||||
|
||||
getAll(): KernelHookDefinition[] {
|
||||
return [...this.hooks.values()].flat();
|
||||
}
|
||||
}
|
||||
47
src/agent-kernel/hooks/kernel-hook-runner.ts
Normal file
47
src/agent-kernel/hooks/kernel-hook-runner.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { logger } from '../../utils/logger';
|
||||
import { KernelHookRegistry } from './kernel-hook-registry';
|
||||
import type { KernelHookInput, KernelLifecycleResult } from './kernel-hook-types';
|
||||
|
||||
export async function runKernelHooks(params: {
|
||||
registry: KernelHookRegistry;
|
||||
input: KernelHookInput;
|
||||
}): Promise<KernelLifecycleResult> {
|
||||
const hooks = params.registry.get(params.input.event);
|
||||
const results = [] as KernelLifecycleResult['results'];
|
||||
const additionalContexts: string[] = [];
|
||||
let updatedInput: Record<string, unknown> | undefined;
|
||||
let blockingReason: string | undefined;
|
||||
|
||||
for (const hook of hooks) {
|
||||
try {
|
||||
const result = await hook.execute(params.input);
|
||||
if (!result) {
|
||||
continue;
|
||||
}
|
||||
results.push(result);
|
||||
if (result.additionalContext) {
|
||||
additionalContexts.push(result.additionalContext);
|
||||
}
|
||||
if (result.updatedInput) {
|
||||
updatedInput = result.updatedInput;
|
||||
}
|
||||
if (result.continue === false || result.decision === 'block') {
|
||||
blockingReason = result.reason ?? `Execution blocked by hook ${hook.name}`;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Kernel hook 执行失败', {
|
||||
hookName: hook.name,
|
||||
event: params.input.event,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
results,
|
||||
additionalContexts,
|
||||
updatedInput,
|
||||
blockingReason,
|
||||
};
|
||||
}
|
||||
99
src/agent-kernel/hooks/kernel-hook-types.ts
Normal file
99
src/agent-kernel/hooks/kernel-hook-types.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { ToolExecutionContext } from '../../review/tools/types';
|
||||
import type { KernelDelegationPacket, KernelSubagentInvocationResult } from '../types';
|
||||
|
||||
export type KernelHookEventName =
|
||||
| 'SessionStart'
|
||||
| 'SubagentStart'
|
||||
| 'PermissionRequest'
|
||||
| 'PreToolUse'
|
||||
| 'PostToolUse'
|
||||
| 'PostToolUseFailure';
|
||||
|
||||
export interface SessionStartHookInput {
|
||||
event: 'SessionStart';
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
scopeKey: string;
|
||||
}
|
||||
|
||||
export interface SubagentStartHookInput {
|
||||
event: 'SubagentStart';
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
subagentName: string;
|
||||
agentId: string;
|
||||
packet: KernelDelegationPacket;
|
||||
}
|
||||
|
||||
export interface PreToolUseHookInput {
|
||||
event: 'PreToolUse';
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
input: Record<string, unknown>;
|
||||
context: ToolExecutionContext;
|
||||
}
|
||||
|
||||
export interface PermissionRequestHookInput {
|
||||
event: 'PermissionRequest';
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
input: Record<string, unknown>;
|
||||
context: ToolExecutionContext;
|
||||
suggestedBehavior: 'ask' | 'deny';
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface PostToolUseHookInput {
|
||||
event: 'PostToolUse';
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
input: Record<string, unknown>;
|
||||
output: unknown;
|
||||
context: ToolExecutionContext;
|
||||
}
|
||||
|
||||
export interface PostToolUseFailureHookInput {
|
||||
event: 'PostToolUseFailure';
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
input: Record<string, unknown>;
|
||||
error: string;
|
||||
context: ToolExecutionContext;
|
||||
}
|
||||
|
||||
export type KernelHookInput =
|
||||
| SessionStartHookInput
|
||||
| SubagentStartHookInput
|
||||
| PermissionRequestHookInput
|
||||
| PreToolUseHookInput
|
||||
| PostToolUseHookInput
|
||||
| PostToolUseFailureHookInput;
|
||||
|
||||
export interface KernelHookResult {
|
||||
continue?: boolean;
|
||||
additionalContext?: string;
|
||||
updatedInput?: Record<string, unknown>;
|
||||
decision?: 'approve' | 'block';
|
||||
reason?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface KernelHookDefinition {
|
||||
name: string;
|
||||
event: KernelHookEventName;
|
||||
description: string;
|
||||
execute(input: KernelHookInput): Promise<KernelHookResult | undefined>;
|
||||
}
|
||||
|
||||
export interface KernelLifecycleResult {
|
||||
results: KernelHookResult[];
|
||||
additionalContexts: string[];
|
||||
updatedInput?: Record<string, unknown>;
|
||||
blockingReason?: string;
|
||||
}
|
||||
|
||||
export interface KernelSubagentCompletionEnvelope {
|
||||
invocationId: string;
|
||||
subagentName: string;
|
||||
result: KernelSubagentInvocationResult;
|
||||
}
|
||||
17
src/agent-kernel/registry/kernel-task-registry.ts
Normal file
17
src/agent-kernel/registry/kernel-task-registry.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { KernelTaskHandler } from '../types';
|
||||
|
||||
export class KernelTaskRegistry<TState> {
|
||||
private readonly handlers = new Map<string, KernelTaskHandler<TState>>();
|
||||
|
||||
register(handler: KernelTaskHandler<TState>): void {
|
||||
this.handlers.set(handler.name, handler);
|
||||
}
|
||||
|
||||
get(name: string): KernelTaskHandler<TState> | undefined {
|
||||
return this.handlers.get(name);
|
||||
}
|
||||
|
||||
getAll(): KernelTaskHandler<TState>[] {
|
||||
return [...this.handlers.values()];
|
||||
}
|
||||
}
|
||||
138
src/agent-kernel/runtime/agent-kernel-runner.ts
Normal file
138
src/agent-kernel/runtime/agent-kernel-runner.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { KernelAgentInvoker } from '../agents/kernel-agent-invoker';
|
||||
import { KernelTaskRegistry } from '../registry/kernel-task-registry';
|
||||
import { kernelSessionRepository } from '../session/session-repository';
|
||||
import type {
|
||||
KernelCheckpoint,
|
||||
KernelExecutionContext,
|
||||
KernelTask,
|
||||
KernelTurnPlanner,
|
||||
} from '../types';
|
||||
|
||||
export class AgentKernelRunner<TState> {
|
||||
constructor(
|
||||
private readonly skillRegistry: KernelTaskRegistry<TState>,
|
||||
private readonly subagentInvoker: KernelAgentInvoker<TState>,
|
||||
private readonly planner: KernelTurnPlanner<TState>
|
||||
) {}
|
||||
|
||||
async run(params: {
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
initialState: TState;
|
||||
initialTasks: KernelTask[];
|
||||
continueExisting?: boolean;
|
||||
}): Promise<KernelCheckpoint<TState>> {
|
||||
const session = kernelSessionRepository.getSessionById(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Kernel session not found: ${params.sessionId}`);
|
||||
}
|
||||
|
||||
const persisted = kernelSessionRepository.loadCheckpoint<TState>(params.sessionId);
|
||||
let state = persisted?.state ?? params.initialState;
|
||||
const pendingTasks = [...(persisted?.pendingTasks ?? params.initialTasks)];
|
||||
let stopReason: string | undefined;
|
||||
|
||||
while (!stopReason) {
|
||||
if (pendingTasks.length === 0) {
|
||||
const plannedTasks = this.planner.plan({
|
||||
session,
|
||||
runId: params.runId,
|
||||
state,
|
||||
pendingTasks: [...pendingTasks],
|
||||
});
|
||||
|
||||
if (plannedTasks.length === 0) {
|
||||
stopReason = 'completed';
|
||||
break;
|
||||
}
|
||||
|
||||
pendingTasks.push(...plannedTasks);
|
||||
}
|
||||
|
||||
const task = pendingTasks.shift() as KernelTask;
|
||||
if (task.kind === 'subagent' && !this.subagentInvoker.get(task.name)) {
|
||||
throw new Error(`Kernel subagent handler not found: ${task.name}`);
|
||||
}
|
||||
if (task.kind === 'skill' && !this.skillRegistry.get(task.name)) {
|
||||
throw new Error(`Kernel skill handler not found: ${task.name}`);
|
||||
}
|
||||
|
||||
kernelSessionRepository.appendEvent(params.sessionId, 'task_started', {
|
||||
kind: task.kind,
|
||||
name: task.name,
|
||||
input: task.input ?? {},
|
||||
runId: params.runId,
|
||||
});
|
||||
|
||||
const context: KernelExecutionContext<TState> = {
|
||||
session,
|
||||
runId: params.runId,
|
||||
state,
|
||||
};
|
||||
let result;
|
||||
let invocation;
|
||||
try {
|
||||
if (task.kind === 'skill') {
|
||||
result = await this.skillRegistry.get(task.name)?.execute(task, context);
|
||||
} else {
|
||||
const invocationOutput = await this.subagentInvoker.invoke(task, context);
|
||||
result = invocationOutput.result;
|
||||
invocation = invocationOutput.invocation;
|
||||
}
|
||||
} catch (error) {
|
||||
kernelSessionRepository.appendEvent(params.sessionId, 'task_failed', {
|
||||
kind: task.kind,
|
||||
name: task.name,
|
||||
runId: params.runId,
|
||||
invocationId: invocation?.id,
|
||||
agentId: invocation?.agentId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
kernelSessionRepository.saveCheckpoint(params.sessionId, {
|
||||
state,
|
||||
pendingTasks: [task, ...pendingTasks],
|
||||
stopReason: 'failed',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (result?.state !== undefined) {
|
||||
state = result.state;
|
||||
}
|
||||
if (result?.prepend?.length) {
|
||||
pendingTasks.unshift(...result.prepend);
|
||||
}
|
||||
if (result?.enqueue?.length) {
|
||||
pendingTasks.push(...result.enqueue);
|
||||
}
|
||||
if (result?.stopReason) {
|
||||
stopReason = result.stopReason;
|
||||
}
|
||||
|
||||
kernelSessionRepository.appendEvent(params.sessionId, 'task_completed', {
|
||||
kind: task.kind,
|
||||
name: task.name,
|
||||
runId: params.runId,
|
||||
invocationId: invocation?.id,
|
||||
agentId: invocation?.agentId,
|
||||
summary: invocation?.result?.summary ?? result?.summary,
|
||||
artifacts: invocation?.result?.artifacts ?? result?.artifacts,
|
||||
stopReason: result?.stopReason,
|
||||
});
|
||||
|
||||
kernelSessionRepository.saveCheckpoint(params.sessionId, {
|
||||
state,
|
||||
pendingTasks,
|
||||
stopReason,
|
||||
});
|
||||
}
|
||||
|
||||
const checkpoint = {
|
||||
state,
|
||||
pendingTasks,
|
||||
stopReason: stopReason ?? 'completed',
|
||||
};
|
||||
kernelSessionRepository.saveCheckpoint(params.sessionId, checkpoint);
|
||||
return checkpoint;
|
||||
}
|
||||
}
|
||||
335
src/agent-kernel/session/session-repository.ts
Normal file
335
src/agent-kernel/session/session-repository.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getDatabase } from '../../db/database';
|
||||
import type {
|
||||
KernelCheckpoint,
|
||||
KernelDelegationPacket,
|
||||
KernelSessionEventRecord,
|
||||
KernelSessionRecord,
|
||||
KernelSubagentInvocationRecord,
|
||||
KernelSubagentInvocationResult,
|
||||
} from '../types';
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
scope_type: 'pull_request' | 'commit';
|
||||
scope_key: string;
|
||||
metadata_json: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_run_id?: string;
|
||||
}
|
||||
|
||||
interface EventRow {
|
||||
id: string;
|
||||
session_id: string;
|
||||
event_type: string;
|
||||
payload_json: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface CheckpointRow {
|
||||
session_id: string;
|
||||
state_json: string;
|
||||
pending_tasks_json: string;
|
||||
stop_reason?: string;
|
||||
updated_at: string;
|
||||
state_version: number;
|
||||
}
|
||||
|
||||
interface SubagentInvocationRow {
|
||||
id: string;
|
||||
parent_session_id: string;
|
||||
parent_run_id: string;
|
||||
parent_task_name: string;
|
||||
subagent_name: string;
|
||||
agent_id: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
input_json: string;
|
||||
result_json?: string;
|
||||
started_at: string;
|
||||
finished_at?: string;
|
||||
}
|
||||
|
||||
function toSessionRecord(row: SessionRow): KernelSessionRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
scopeType: row.scope_type,
|
||||
scopeKey: row.scope_key,
|
||||
metadata: JSON.parse(row.metadata_json) as Record<string, unknown>,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
lastRunId: row.last_run_id,
|
||||
};
|
||||
}
|
||||
|
||||
export class KernelSessionRepository {
|
||||
ensureSession(input: {
|
||||
scopeType: 'pull_request' | 'commit';
|
||||
scopeKey: string;
|
||||
metadata: Record<string, unknown>;
|
||||
runId?: string;
|
||||
}): KernelSessionRecord {
|
||||
const db = getDatabase();
|
||||
const existing = db
|
||||
.query(
|
||||
`SELECT id, scope_type, scope_key, metadata_json, created_at, updated_at, last_run_id
|
||||
FROM agent_kernel_sessions
|
||||
WHERE scope_key = ?`
|
||||
)
|
||||
.get(input.scopeKey) as SessionRow | null;
|
||||
|
||||
if (existing) {
|
||||
db.query(
|
||||
`UPDATE agent_kernel_sessions
|
||||
SET metadata_json = ?, updated_at = datetime('now'), last_run_id = ?
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
JSON.stringify(input.metadata),
|
||||
input.runId ?? existing.last_run_id ?? null,
|
||||
existing.id
|
||||
);
|
||||
|
||||
return this.getSessionById(existing.id) as KernelSessionRecord;
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
db.query(
|
||||
`INSERT INTO agent_kernel_sessions (
|
||||
id, scope_type, scope_key, metadata_json, last_run_id
|
||||
) VALUES (?, ?, ?, ?, ?)`
|
||||
).run(id, input.scopeType, input.scopeKey, JSON.stringify(input.metadata), input.runId ?? null);
|
||||
|
||||
return this.getSessionById(id) as KernelSessionRecord;
|
||||
}
|
||||
|
||||
getSessionById(sessionId: string): KernelSessionRecord | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query(
|
||||
`SELECT id, scope_type, scope_key, metadata_json, created_at, updated_at, last_run_id
|
||||
FROM agent_kernel_sessions
|
||||
WHERE id = ?`
|
||||
)
|
||||
.get(sessionId) as SessionRow | null;
|
||||
|
||||
return row ? toSessionRecord(row) : null;
|
||||
}
|
||||
|
||||
getSessionByScopeKey(scopeKey: string): KernelSessionRecord | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query(
|
||||
`SELECT id, scope_type, scope_key, metadata_json, created_at, updated_at, last_run_id
|
||||
FROM agent_kernel_sessions
|
||||
WHERE scope_key = ?`
|
||||
)
|
||||
.get(scopeKey) as SessionRow | null;
|
||||
|
||||
return row ? toSessionRecord(row) : null;
|
||||
}
|
||||
|
||||
listSessions(limit = 50): KernelSessionRecord[] {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.query(
|
||||
`SELECT id, scope_type, scope_key, metadata_json, created_at, updated_at, last_run_id
|
||||
FROM agent_kernel_sessions
|
||||
ORDER BY updated_at DESC, created_at DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(limit) as SessionRow[];
|
||||
|
||||
return rows.map(toSessionRecord);
|
||||
}
|
||||
|
||||
appendEvent(
|
||||
sessionId: string,
|
||||
eventType: string,
|
||||
payload: Record<string, unknown>
|
||||
): KernelSessionEventRecord {
|
||||
const db = getDatabase();
|
||||
const id = randomUUID();
|
||||
db.query(
|
||||
`INSERT INTO agent_kernel_session_events (id, session_id, event_type, payload_json)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
).run(id, sessionId, eventType, JSON.stringify(payload));
|
||||
|
||||
const row = db
|
||||
.query(
|
||||
`SELECT id, session_id, event_type, payload_json, created_at
|
||||
FROM agent_kernel_session_events
|
||||
WHERE id = ?`
|
||||
)
|
||||
.get(id) as EventRow;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
sessionId: row.session_id,
|
||||
eventType: row.event_type,
|
||||
payload: JSON.parse(row.payload_json) as Record<string, unknown>,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
listEvents(sessionId: string): KernelSessionEventRecord[] {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.query(
|
||||
`SELECT id, session_id, event_type, payload_json, created_at
|
||||
FROM agent_kernel_session_events
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at ASC, id ASC`
|
||||
)
|
||||
.all(sessionId) as EventRow[];
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
sessionId: row.session_id,
|
||||
eventType: row.event_type,
|
||||
payload: JSON.parse(row.payload_json) as Record<string, unknown>,
|
||||
createdAt: row.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
saveCheckpoint<TState>(
|
||||
sessionId: string,
|
||||
checkpoint: KernelCheckpoint<TState>,
|
||||
stateVersion = 1
|
||||
): void {
|
||||
const db = getDatabase();
|
||||
db.query(
|
||||
`INSERT INTO agent_kernel_session_checkpoints (
|
||||
session_id, state_json, pending_tasks_json, stop_reason, state_version, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
state_json = excluded.state_json,
|
||||
pending_tasks_json = excluded.pending_tasks_json,
|
||||
stop_reason = excluded.stop_reason,
|
||||
state_version = excluded.state_version,
|
||||
updated_at = datetime('now')`
|
||||
).run(
|
||||
sessionId,
|
||||
JSON.stringify(checkpoint.state),
|
||||
JSON.stringify(checkpoint.pendingTasks),
|
||||
checkpoint.stopReason ?? null,
|
||||
stateVersion
|
||||
);
|
||||
}
|
||||
|
||||
loadCheckpoint<TState>(sessionId: string): KernelCheckpoint<TState> | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query(
|
||||
`SELECT session_id, state_json, pending_tasks_json, stop_reason, updated_at, state_version
|
||||
FROM agent_kernel_session_checkpoints
|
||||
WHERE session_id = ?`
|
||||
)
|
||||
.get(sessionId) as CheckpointRow | null;
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
state: JSON.parse(row.state_json) as TState,
|
||||
pendingTasks: JSON.parse(row.pending_tasks_json) as KernelCheckpoint<TState>['pendingTasks'],
|
||||
stopReason: row.stop_reason,
|
||||
};
|
||||
}
|
||||
|
||||
deleteCheckpoint(sessionId: string): void {
|
||||
const db = getDatabase();
|
||||
db.query('DELETE FROM agent_kernel_session_checkpoints WHERE session_id = ?').run(sessionId);
|
||||
}
|
||||
|
||||
createSubagentInvocation(input: {
|
||||
parentSessionId: string;
|
||||
parentRunId: string;
|
||||
parentTaskName: string;
|
||||
subagentName: string;
|
||||
agentId: string;
|
||||
packet: KernelDelegationPacket;
|
||||
}): KernelSubagentInvocationRecord {
|
||||
const db = getDatabase();
|
||||
const id = randomUUID();
|
||||
db.query(
|
||||
`INSERT INTO agent_kernel_subagent_invocations (
|
||||
id, parent_session_id, parent_run_id, parent_task_name, subagent_name, agent_id, status, input_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 'running', ?)`
|
||||
).run(
|
||||
id,
|
||||
input.parentSessionId,
|
||||
input.parentRunId,
|
||||
input.parentTaskName,
|
||||
input.subagentName,
|
||||
input.agentId,
|
||||
JSON.stringify(input.packet)
|
||||
);
|
||||
|
||||
return this.getSubagentInvocationById(id) as KernelSubagentInvocationRecord;
|
||||
}
|
||||
|
||||
completeSubagentInvocation(
|
||||
invocationId: string,
|
||||
status: 'completed' | 'failed',
|
||||
result: KernelSubagentInvocationResult
|
||||
): KernelSubagentInvocationRecord {
|
||||
const db = getDatabase();
|
||||
db.query(
|
||||
`UPDATE agent_kernel_subagent_invocations
|
||||
SET status = ?, result_json = ?, finished_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
).run(status, JSON.stringify(result), invocationId);
|
||||
|
||||
return this.getSubagentInvocationById(invocationId) as KernelSubagentInvocationRecord;
|
||||
}
|
||||
|
||||
listSubagentInvocations(parentSessionId: string): KernelSubagentInvocationRecord[] {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.query(
|
||||
`SELECT id, parent_session_id, parent_run_id, parent_task_name, subagent_name, agent_id,
|
||||
status, input_json, result_json, started_at, finished_at
|
||||
FROM agent_kernel_subagent_invocations
|
||||
WHERE parent_session_id = ?
|
||||
ORDER BY started_at ASC, id ASC`
|
||||
)
|
||||
.all(parentSessionId) as SubagentInvocationRow[];
|
||||
|
||||
return rows.map((row) => this.toSubagentInvocationRecord(row));
|
||||
}
|
||||
|
||||
private getSubagentInvocationById(invocationId: string): KernelSubagentInvocationRecord | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query(
|
||||
`SELECT id, parent_session_id, parent_run_id, parent_task_name, subagent_name, agent_id,
|
||||
status, input_json, result_json, started_at, finished_at
|
||||
FROM agent_kernel_subagent_invocations
|
||||
WHERE id = ?`
|
||||
)
|
||||
.get(invocationId) as SubagentInvocationRow | null;
|
||||
|
||||
return row ? this.toSubagentInvocationRecord(row) : null;
|
||||
}
|
||||
|
||||
private toSubagentInvocationRecord(row: SubagentInvocationRow): KernelSubagentInvocationRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
parentSessionId: row.parent_session_id,
|
||||
parentRunId: row.parent_run_id,
|
||||
parentTaskName: row.parent_task_name,
|
||||
subagentName: row.subagent_name,
|
||||
agentId: row.agent_id,
|
||||
status: row.status,
|
||||
input: JSON.parse(row.input_json) as KernelDelegationPacket,
|
||||
result: row.result_json
|
||||
? (JSON.parse(row.result_json) as KernelSubagentInvocationResult)
|
||||
: undefined,
|
||||
startedAt: row.started_at,
|
||||
finishedAt: row.finished_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const kernelSessionRepository = new KernelSessionRepository();
|
||||
132
src/agent-kernel/types.ts
Normal file
132
src/agent-kernel/types.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
export type KernelTaskKind = 'skill' | 'subagent';
|
||||
|
||||
export interface KernelTask {
|
||||
kind: KernelTaskKind;
|
||||
name: string;
|
||||
input?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface KernelDelegationPacket {
|
||||
goal: string;
|
||||
parentTaskName: string;
|
||||
input: Record<string, unknown>;
|
||||
parentSessionId: string;
|
||||
parentRunId: string;
|
||||
contextSummary?: string;
|
||||
}
|
||||
|
||||
export interface KernelTaskDefinition {
|
||||
kind: KernelTaskKind;
|
||||
name: string;
|
||||
description: string;
|
||||
resumable?: boolean;
|
||||
}
|
||||
|
||||
export type KernelAgentSource = 'built-in' | 'custom' | 'plugin';
|
||||
|
||||
export interface KernelSubagentDefinition<TState> extends KernelTaskDefinition {
|
||||
kind: 'subagent';
|
||||
name: string;
|
||||
source: KernelAgentSource;
|
||||
whenToUse: string;
|
||||
tags?: string[];
|
||||
modelRole?: string;
|
||||
maxTurns?: number;
|
||||
background?: boolean;
|
||||
execute(
|
||||
task: KernelTask,
|
||||
context: KernelAgentExecutionContext<TState>
|
||||
): Promise<KernelHandlerResult<TState> | undefined>;
|
||||
}
|
||||
|
||||
export interface KernelCheckpoint<TState> {
|
||||
state: TState;
|
||||
pendingTasks: KernelTask[];
|
||||
stopReason?: string;
|
||||
}
|
||||
|
||||
export interface KernelSessionRecord {
|
||||
id: string;
|
||||
scopeType: 'pull_request' | 'commit';
|
||||
scopeKey: string;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastRunId?: string;
|
||||
}
|
||||
|
||||
export interface KernelSessionEventRecord {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
eventType: string;
|
||||
payload: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface KernelSubagentContextRecord {
|
||||
agentId: string;
|
||||
parentSessionId: string;
|
||||
agentType: 'subagent';
|
||||
subagentName: string;
|
||||
source: KernelAgentSource;
|
||||
invocationKind: 'spawn' | 'resume';
|
||||
}
|
||||
|
||||
export interface KernelSubagentInvocationRecord {
|
||||
id: string;
|
||||
parentSessionId: string;
|
||||
parentRunId: string;
|
||||
parentTaskName: string;
|
||||
subagentName: string;
|
||||
agentId: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
input: KernelDelegationPacket;
|
||||
result?: KernelSubagentInvocationResult;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
}
|
||||
|
||||
export interface KernelSubagentInvocationResult {
|
||||
agentId: string;
|
||||
agentType: string;
|
||||
summary: string;
|
||||
totalDurationMs: number;
|
||||
totalToolUseCount: number;
|
||||
totalTokens: number;
|
||||
artifacts?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface KernelExecutionContext<TState> {
|
||||
session: KernelSessionRecord;
|
||||
runId: string;
|
||||
state: TState;
|
||||
}
|
||||
|
||||
export interface KernelAgentExecutionContext<TState> extends KernelExecutionContext<TState> {
|
||||
agent: KernelSubagentDefinition<TState>;
|
||||
delegation: KernelDelegationPacket;
|
||||
}
|
||||
|
||||
export interface KernelPlanningContext<TState> extends KernelExecutionContext<TState> {
|
||||
pendingTasks: KernelTask[];
|
||||
}
|
||||
|
||||
export interface KernelHandlerResult<TState> {
|
||||
state?: TState;
|
||||
enqueue?: KernelTask[];
|
||||
prepend?: KernelTask[];
|
||||
stopReason?: string;
|
||||
summary?: string;
|
||||
artifacts?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface KernelTaskHandler<TState> extends KernelTaskDefinition {
|
||||
execute(
|
||||
task: KernelTask,
|
||||
context: KernelExecutionContext<TState>
|
||||
): Promise<KernelHandlerResult<TState> | undefined>;
|
||||
}
|
||||
|
||||
export interface KernelTurnPlanner<TState> {
|
||||
plan(context: KernelPlanningContext<TState>): KernelTask[];
|
||||
}
|
||||
@@ -66,7 +66,7 @@ describe('ConfigManager (DB backend)', () => {
|
||||
|
||||
describe('getCurrent() defaults', () => {
|
||||
test('returns default engine when DB is empty', () => {
|
||||
expect(configManager.getCurrent().review.engine).toBe('agent');
|
||||
expect(configManager.getCurrent().review.engine).toBe('kernel');
|
||||
});
|
||||
|
||||
test('reads port from process.env.PORT, defaults to 5174', () => {
|
||||
@@ -82,8 +82,9 @@ describe('ConfigManager (DB backend)', () => {
|
||||
|
||||
test('optional fields with no default return undefined', () => {
|
||||
const cfg = configManager.getCurrent();
|
||||
expect(cfg.feishu.webhookUrl).toBeUndefined();
|
||||
expect(cfg.feishu.webhookSecret).toBeUndefined();
|
||||
expect(cfg.notification.feishu.webhookUrl).toBeUndefined();
|
||||
expect(cfg.notification.feishu.webhookSecret).toBeUndefined();
|
||||
expect(cfg.notification.wecom.webhookUrl).toBeUndefined();
|
||||
expect(cfg.admin.giteaAdminToken).toBeUndefined();
|
||||
expect(cfg.review.qdrantUrl).toBeUndefined();
|
||||
});
|
||||
@@ -104,18 +105,18 @@ describe('ConfigManager (DB backend)', () => {
|
||||
|
||||
describe('setOverrides() and getSource()', () => {
|
||||
test('setOverrides writes to DB, getCurrent reflects the change', async () => {
|
||||
await configManager.setOverrides({ REVIEW_ENGINE: 'agent' });
|
||||
expect(configManager.getCurrent().review.engine).toBe('agent');
|
||||
await configManager.setOverrides({ REVIEW_ENGINE: 'kernel' });
|
||||
expect(configManager.getCurrent().review.engine).toBe('kernel');
|
||||
});
|
||||
|
||||
test('setOverrides with empty string deletes the key (resets to default)', async () => {
|
||||
await configManager.setOverrides({ REVIEW_ENGINE: 'agent' });
|
||||
await configManager.setOverrides({ REVIEW_ENGINE: 'kernel' });
|
||||
await configManager.setOverrides({ REVIEW_ENGINE: '' });
|
||||
expect(configManager.getCurrent().review.engine).toBe('agent');
|
||||
expect(configManager.getCurrent().review.engine).toBe('kernel');
|
||||
});
|
||||
|
||||
test('getSource returns "db" when value is stored', async () => {
|
||||
await configManager.setOverrides({ REVIEW_ENGINE: 'agent' });
|
||||
await configManager.setOverrides({ REVIEW_ENGINE: 'kernel' });
|
||||
expect(configManager.getSource('REVIEW_ENGINE')).toBe('db');
|
||||
});
|
||||
|
||||
@@ -130,7 +131,7 @@ describe('ConfigManager (DB backend)', () => {
|
||||
|
||||
test('unknown keys are silently ignored', async () => {
|
||||
await configManager.setOverrides({ UNKNOWN_KEY_XYZ: 'value' });
|
||||
expect(configManager.getCurrent().review.engine).toBe('agent');
|
||||
expect(configManager.getCurrent().review.engine).toBe('kernel');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,15 +139,15 @@ describe('ConfigManager (DB backend)', () => {
|
||||
|
||||
describe('resetKeys()', () => {
|
||||
test('resetKeys deletes key from DB, value reverts to default', async () => {
|
||||
await configManager.setOverrides({ REVIEW_ENGINE: 'agent' });
|
||||
await configManager.setOverrides({ REVIEW_ENGINE: 'kernel' });
|
||||
await configManager.resetKeys(['REVIEW_ENGINE']);
|
||||
expect(configManager.getCurrent().review.engine).toBe('agent');
|
||||
expect(configManager.getCurrent().review.engine).toBe('kernel');
|
||||
expect(configManager.getSource('REVIEW_ENGINE')).toBe('default');
|
||||
});
|
||||
|
||||
test('resetKeys on non-existent key does not throw', async () => {
|
||||
await configManager.resetKeys(['REVIEW_ENGINE']);
|
||||
expect(configManager.getCurrent().review.engine).toBe('agent');
|
||||
expect(configManager.getCurrent().review.engine).toBe('kernel');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -170,9 +171,9 @@ describe('ConfigManager (DB backend)', () => {
|
||||
});
|
||||
|
||||
test('seedDefaults is idempotent — no-op when DB already has entries', async () => {
|
||||
await configManager.setOverrides({ REVIEW_ENGINE: 'agent' });
|
||||
await configManager.setOverrides({ REVIEW_ENGINE: 'kernel' });
|
||||
configManager.seedDefaults();
|
||||
expect(configManager.getCurrent().review.engine).toBe('agent');
|
||||
expect(configManager.getCurrent().review.engine).toBe('kernel');
|
||||
});
|
||||
|
||||
test('ADMIN_PASSWORD defaults to "password"', () => {
|
||||
@@ -195,13 +196,13 @@ describe('ConfigManager (DB backend)', () => {
|
||||
|
||||
describe('type conversions in getCurrent()', () => {
|
||||
test('boolean field "true" → true', async () => {
|
||||
await configManager.setOverrides({ REVIEW_ENABLE_HUMAN_GATE: 'true' });
|
||||
expect(configManager.getCurrent().review.enableHumanGate).toBe(true);
|
||||
await configManager.setOverrides({ ENABLE_TRIAGE: 'true' });
|
||||
expect(configManager.getCurrent().review.enableTriage).toBe(true);
|
||||
});
|
||||
|
||||
test('boolean field "false" → false', async () => {
|
||||
await configManager.setOverrides({ REVIEW_ENABLE_HUMAN_GATE: 'false' });
|
||||
expect(configManager.getCurrent().review.enableHumanGate).toBe(false);
|
||||
await configManager.setOverrides({ ENABLE_TRIAGE: 'false' });
|
||||
expect(configManager.getCurrent().review.enableTriage).toBe(false);
|
||||
});
|
||||
|
||||
test('number field is parsed correctly', async () => {
|
||||
|
||||
@@ -11,9 +11,16 @@ export interface AppConfig {
|
||||
apiUrl: string;
|
||||
accessToken: string;
|
||||
};
|
||||
feishu: {
|
||||
webhookUrl: string | undefined;
|
||||
webhookSecret: string | undefined;
|
||||
notification: {
|
||||
feishu: {
|
||||
enabled: boolean;
|
||||
webhookUrl: string | undefined;
|
||||
webhookSecret: string | undefined;
|
||||
};
|
||||
wecom: {
|
||||
enabled: boolean;
|
||||
webhookUrl: string | undefined;
|
||||
};
|
||||
};
|
||||
app: {
|
||||
port: number;
|
||||
@@ -25,14 +32,12 @@ export interface AppConfig {
|
||||
giteaAdminToken: string | undefined;
|
||||
};
|
||||
review: {
|
||||
engine: 'agent' | 'codex';
|
||||
engine: 'codex' | 'kernel';
|
||||
workdir: string;
|
||||
globalPrompt: string | undefined;
|
||||
maxParallelRuns: number;
|
||||
maxFilesPerRun: number;
|
||||
maxFileContentChars: number;
|
||||
autoPublishMinConfidence: number;
|
||||
enableHumanGate: boolean;
|
||||
allowedCommands: string[];
|
||||
commandTimeoutMs: number;
|
||||
llmMaxConcurrentCalls: number;
|
||||
@@ -46,20 +51,11 @@ export interface AppConfig {
|
||||
tokenBudgetSmall: number;
|
||||
tokenBudgetMedium: number;
|
||||
tokenBudgetLarge: number;
|
||||
// Codex engine
|
||||
codexApiUrl: string;
|
||||
codexApiKey: string | undefined;
|
||||
codexModel: string;
|
||||
codexTimeoutMs: number;
|
||||
codexReviewPrompt: string | undefined;
|
||||
// Memory (shared)
|
||||
qdrantUrl: string | undefined;
|
||||
enableMemory: boolean;
|
||||
fewShotExamplesCount: number;
|
||||
enableReflection: boolean;
|
||||
maxReflectionRounds: number;
|
||||
enableDebate: boolean;
|
||||
debateThreshold: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,12 +130,19 @@ class ConfigManager {
|
||||
|
||||
return {
|
||||
gitea: {
|
||||
apiUrl: values.GITEA_API_URL ?? 'http://localhost:5174/api/v1',
|
||||
accessToken: values.GITEA_ACCESS_TOKEN ?? 'test_token',
|
||||
apiUrl: values.GITEA_API_URL ?? '',
|
||||
accessToken: values.GITEA_ACCESS_TOKEN ?? '',
|
||||
},
|
||||
feishu: {
|
||||
webhookUrl: values.FEISHU_WEBHOOK_URL,
|
||||
webhookSecret: values.FEISHU_WEBHOOK_SECRET,
|
||||
notification: {
|
||||
feishu: {
|
||||
enabled: toBoolean('FEISHU_ENABLED', true),
|
||||
webhookUrl: values.FEISHU_WEBHOOK_URL,
|
||||
webhookSecret: values.FEISHU_WEBHOOK_SECRET,
|
||||
},
|
||||
wecom: {
|
||||
enabled: toBoolean('WECOM_ENABLED', false),
|
||||
webhookUrl: values.WECOM_WEBHOOK_URL,
|
||||
},
|
||||
},
|
||||
app: {
|
||||
port,
|
||||
@@ -151,14 +154,12 @@ class ConfigManager {
|
||||
giteaAdminToken: values.GITEA_ADMIN_TOKEN,
|
||||
},
|
||||
review: {
|
||||
engine: values.REVIEW_ENGINE === 'codex' ? 'codex' : 'agent',
|
||||
engine: values.REVIEW_ENGINE === 'codex' ? 'codex' : 'kernel',
|
||||
workdir: values.REVIEW_WORKDIR ?? '/tmp/gitea-assistant',
|
||||
globalPrompt: values.GLOBAL_PROMPT,
|
||||
maxParallelRuns: toNumber('REVIEW_MAX_PARALLEL_RUNS', 2),
|
||||
maxFilesPerRun: toNumber('REVIEW_MAX_FILES_PER_RUN', 200),
|
||||
maxFileContentChars: toNumber('REVIEW_MAX_FILE_CONTENT_CHARS', 40000),
|
||||
autoPublishMinConfidence: toNumber('REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE', 0.8),
|
||||
enableHumanGate: toBoolean('REVIEW_ENABLE_HUMAN_GATE', true),
|
||||
allowedCommands: toStringArray('REVIEW_ALLOWED_COMMANDS', [
|
||||
'git',
|
||||
'rg',
|
||||
@@ -178,20 +179,11 @@ class ConfigManager {
|
||||
tokenBudgetSmall: toNumber('REVIEW_TOKEN_BUDGET_SMALL', 12000),
|
||||
tokenBudgetMedium: toNumber('REVIEW_TOKEN_BUDGET_MEDIUM', 45000),
|
||||
tokenBudgetLarge: toNumber('REVIEW_TOKEN_BUDGET_LARGE', 120000),
|
||||
// Codex engine
|
||||
codexApiUrl: values.CODEX_API_URL ?? 'https://api.openai.com/v1',
|
||||
codexApiKey: values.CODEX_API_KEY,
|
||||
codexModel: values.CODEX_MODEL ?? 'o3',
|
||||
codexTimeoutMs: toNumber('CODEX_TIMEOUT_MS', 300000),
|
||||
codexReviewPrompt: values.CODEX_REVIEW_PROMPT,
|
||||
// Memory
|
||||
qdrantUrl: values.QDRANT_URL,
|
||||
enableMemory: toBoolean('ENABLE_MEMORY', false),
|
||||
fewShotExamplesCount: toNumber('FEW_SHOT_EXAMPLES_COUNT', 10),
|
||||
enableReflection: toBoolean('ENABLE_REFLECTION', false),
|
||||
maxReflectionRounds: toNumber('MAX_REFLECTION_ROUNDS', 2),
|
||||
enableDebate: toBoolean('ENABLE_DEBATE', false),
|
||||
debateThreshold: values.DEBATE_THRESHOLD ?? 'high',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ConfigGroup = 'gitea' | 'feishu' | 'security' | 'review' | 'memory';
|
||||
export type ConfigGroup = 'gitea' | 'notification' | 'security' | 'review';
|
||||
|
||||
export type ConfigFieldType = 'string' | 'number' | 'boolean' | 'url' | 'text' | 'enum';
|
||||
|
||||
@@ -43,9 +43,9 @@ export const CONFIG_GROUPS: ConfigGroupMeta[] = [
|
||||
icon: 'link',
|
||||
},
|
||||
{
|
||||
key: 'feishu',
|
||||
label: '飞书通知',
|
||||
description: '飞书 Webhook 通知配置',
|
||||
key: 'notification',
|
||||
label: '通知服务',
|
||||
description: '飞书、企业微信等通知渠道配置',
|
||||
icon: 'bell',
|
||||
},
|
||||
{
|
||||
@@ -57,15 +57,9 @@ export const CONFIG_GROUPS: ConfigGroupMeta[] = [
|
||||
{
|
||||
key: 'review',
|
||||
label: '审查引擎',
|
||||
description: 'Agent 审查模式、并发与沙箱设置',
|
||||
description: 'Kernel/Codex 审查模式、并发与沙箱设置',
|
||||
icon: 'file-check',
|
||||
},
|
||||
{
|
||||
key: 'memory',
|
||||
label: '记忆与学习',
|
||||
description: '向量记忆、反思与辩论系统',
|
||||
icon: 'brain',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -110,24 +104,50 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
sensitive: false,
|
||||
},
|
||||
|
||||
// ── 飞书 ────────────────────────────────────────────────────────────────
|
||||
{
|
||||
envKey: 'FEISHU_ENABLED',
|
||||
group: 'notification',
|
||||
label: '启用飞书通知',
|
||||
description: '是否启用飞书通知',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
envKey: 'FEISHU_WEBHOOK_URL',
|
||||
group: 'feishu',
|
||||
label: 'Webhook 地址',
|
||||
group: 'notification',
|
||||
label: '飞书 Webhook 地址',
|
||||
description: '飞书机器人 Webhook URL',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
},
|
||||
{
|
||||
envKey: 'FEISHU_WEBHOOK_SECRET',
|
||||
group: 'feishu',
|
||||
label: 'Webhook 签名密钥',
|
||||
group: 'notification',
|
||||
label: '飞书 Webhook 密钥',
|
||||
description: '飞书 Webhook 签名密钥(可选)',
|
||||
type: 'string',
|
||||
sensitive: true,
|
||||
},
|
||||
|
||||
{
|
||||
envKey: 'WECOM_ENABLED',
|
||||
group: 'notification',
|
||||
label: '启用企业微信通知',
|
||||
description: '是否启用企业微信通知',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
envKey: 'WECOM_WEBHOOK_URL',
|
||||
group: 'notification',
|
||||
label: '企业微信 Webhook 地址',
|
||||
description: '企业微信机器人 Webhook URL',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
},
|
||||
|
||||
{
|
||||
envKey: 'WEBHOOK_SECRET',
|
||||
group: 'security',
|
||||
@@ -162,17 +182,17 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
envKey: 'REVIEW_ENGINE',
|
||||
group: 'review',
|
||||
label: '审查引擎',
|
||||
description: '代码审查模式:agent(任务化分级编排)或 codex(Codex CLI)',
|
||||
description: '代码审查模式:codex(Codex CLI)或 kernel(session 驱动 agentic loop)',
|
||||
type: 'enum',
|
||||
sensitive: false,
|
||||
enumValues: ['agent', 'codex'],
|
||||
defaultValue: 'agent',
|
||||
enumValues: ['codex', 'kernel'],
|
||||
defaultValue: 'kernel',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_WORKDIR',
|
||||
group: 'review',
|
||||
label: '工作目录',
|
||||
description: 'Agent 模式下本地仓库 mirror/worktree 的工作目录',
|
||||
description: 'Kernel 审查模式下本地仓库 mirror/worktree 的工作目录',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
defaultValue: '/tmp/gitea-assistant',
|
||||
@@ -210,26 +230,6 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
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',
|
||||
@@ -416,75 +416,6 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
type: 'text',
|
||||
sensitive: false,
|
||||
},
|
||||
|
||||
// ── 记忆与学习 ──────────────────────────────────────────────────────────
|
||||
{
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user