21 Commits

Author SHA1 Message Date
semantic-release-bot
8d6d167b33 chore(release): 1.3.1 [skip ci]
## [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](b6e6ee0927))
* **logs:** gate repository list debug logs behind REPO_LIST_DEBUG_LOGS env flag ([3a97d67](3a97d673f6))
* **repo:** add structured diagnostics for repository list failures ([22b6032](22b603258a))
2026-03-26 15:52:17 +00:00
jeffusion
1e7c80ca9f docs: document LOG_LEVEL configuration and production defaults
Update all documentation to reflect new global LOG_LEVEL environment variable.

- Add LOG_LEVEL to configuration reference tables

- Update deployment guides with LOG_LEVEL=error examples

- Clarify dev (info) vs production (error) log level recommendations

- Add LOG_LEVEL to all .env examples and quick start guides

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
jeffusion
b92765ce7f chore(deploy): set production LOG_LEVEL to error
Configure production deployments to use LOG_LEVEL=error for minimal log volume.

- Add LOG_LEVEL=error to docker-compose.yml environment

- Add LOG_LEVEL: error to K8s ConfigMap

- Update .env.example with dev/prod LOG_LEVEL guidance

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
jeffusion
daae32ce07 chore(deps): add pino for structured logging
Add pino v10.3.1 and its dependencies to support global LOG_LEVEL logging.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
jeffusion
ab984ff415 refactor(logger): migrate to pino with global LOG_LEVEL control
Replace custom console-based logger with pino backend supporting LOG_LEVEL environment variable.

- Add pino dependency for structured JSON logging

- Implement LOG_LEVEL env var support (debug/info/warn/error, default: info)

- Remove REPO_LIST_DEBUG_LOGS special flag in favor of global LOG_LEVEL

- Preserve existing logger API compatibility (message, meta?)

- Add safe error serialization to prevent credential leakage

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
jeffusion
d49a16db6e test(db): add self-healing tests for missing repository prompt table
- Test runtime self-healing when repository_review_prompts table is dropped

- Test migration layer self-healing for inconsistent DB state

- Verify repository listing remains functional during schema recovery

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
jeffusion
3a97d673f6 fix(logs): gate repository list debug logs behind REPO_LIST_DEBUG_LOGS env flag
- Add REPO_LIST_DEBUG_LOGS environment variable to control debug output

- Gate debug logs in admin controller and gitea service

- Keep error/warn logs always enabled for production visibility

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
jeffusion
b6e6ee0927 fix(db): self-heal missing repository prompt schema
Recover inconsistent SQLite states where migration v3 is marked applied but repository_review_prompts objects are absent, preventing admin repository listing failures in docker deployments.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
jeffusion
22b603258a fix(repo): add structured diagnostics for repository list failures
Capture request/runtime context plus nested error metadata so docker-only repository-list issues can be diagnosed quickly.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
semantic-release-bot
1885004874 chore(release): 1.3.0 [skip ci]
# [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](d5deb75231))
2026-03-26 05:36:05 +00:00
jeffusion
d5deb75231 feat(repo): add project-level review prompt with UI redesign
- Add database migration and repository for project review prompts
- Add API endpoint for setting project-level prompts
- Integrate project prompts into Agent and Codex review flows
- Redesign repository management UI with dialog-based prompt editor
- Replace flat buttons with Switch for webhook toggle and dedicated prompt button
- Add Dialog and DropdownMenu UI components from Radix UI
- Add comprehensive tests for wiring and interactions
2026-03-26 13:35:05 +08:00
jeffusion
c313764b61 docs(readme): reorganize docs and screenshot gallery
Align project docs with current behavior using progressive disclosure and bilingual deep-dive guides. Add per-page admin screenshots with consistent page-* naming to make UI documentation clearer.
2026-03-24 16:04:57 +08:00
semantic-release-bot
63f419228e chore(release): 1.2.1 [skip ci]
## [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](f84c0ab777))
2026-03-24 07:29:39 +00:00
jeffusion
f84c0ab777 fix(ci): source Docker tags from semantic-release version
Avoid stale image tags from placeholder package.json and prevent prereleases from overwriting latest.
2026-03-24 15:06:48 +08:00
semantic-release-bot
7792a78c00 chore(release): 1.2.0 [skip ci]
# [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](7aec1e452a))

### Features

* **frontend:** add dedicated notification management menu and test panel ([9964614](9964614b5e))
* **notification:** replace feishu-only flow with pluggable providers ([e40dadd](e40daddf0d))
2026-03-24 05:40:56 +00:00
jeffusion
7aec1e452a fix(lint): apply biome cleanup for notification modules 2026-03-24 13:40:06 +08:00
jeffusion
8f9910a3fd refactor(notification): replace static factory class with function exports 2026-03-24 13:40:06 +08:00
jeffusion
2392808b82 chore(dev): bootstrap frontend dependencies from root install 2026-03-24 13:40:06 +08:00
jeffusion
9567501369 chore(deploy): standardize assistant default port to 5174 2026-03-24 13:40:06 +08:00
jeffusion
9964614b5e feat(frontend): add dedicated notification management menu and test panel 2026-03-24 13:40:06 +08:00
jeffusion
e40daddf0d feat(notification): replace feishu-only flow with pluggable providers 2026-03-24 13:40:06 +08:00
86 changed files with 4484 additions and 891 deletions

View File

@@ -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 进行配置。

View File

@@ -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

View File

@@ -1,3 +1,39 @@
## [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)

View File

@@ -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
View File

@@ -2,50 +2,48 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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.
![Gitea AI Assistant Dashboard - Repository Page](./docs/assets/page-repos.png)
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

View File

@@ -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=="],

View File

@@ -46,19 +46,11 @@ services:
- NODE_ENV=production
- GITEA_API_URL=http://gitea:3000/api/v1
- GITEA_ACCESS_TOKEN=${E2E_GITEA_TOKEN:-placeholder}
- PORT=3000
- PORT=5174
ports:
- "3334:3000"
- "3334:5174"
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

View File

@@ -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

21
docs/README.md Normal file
View File

@@ -0,0 +1,21 @@
# 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)
- [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)

View File

@@ -1,246 +1,25 @@
# Gitea AI Assistant
# 文档中心
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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)
- [通知服务重构设计](./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
└──────────────────┘
```
![Gitea AI Assistant 管理后台(仓库管理页)](./assets/page-repos.png)
### 审查引擎对比
## 语言切换
| 引擎 | 描述 | 适用场景 |
|------|------|----------|
| `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 Key64 位十六进制字符串)。运行 `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
View File

BIN
docs/assets/page-config.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

BIN
docs/assets/page-repos.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

78
docs/configuration.md Normal file
View 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.

View File

@@ -0,0 +1,78 @@
# 配置参考
## 配置模型
项目采用 DB-first 运行时配置模型:
- `.env` 仅用于基础设施级引导参数
- 运行时配置Gitea、Provider、密钥、审查策略、通知由管理后台维护并持久化到 SQLite
## 环境变量(最小集)
| 变量 | 必填 | 说明 | 默认值 |
|---|---|---|---|
| `ENCRYPTION_KEY` | 是 | API Key 加密主密钥AES-256-GCM64 位十六进制) | - |
| `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 SecretHMAC-SHA256 验签)
- Admin Password
- JWT Secret
## 3) LLM
- ProviderOpenAI 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
View 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
View 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"}}'
```

View 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
**状态**: 已实施(持续验证中)

View File

@@ -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
View 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.

View 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
View 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

View 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
View File

@@ -0,0 +1,23 @@
# Screenshot Gallery
All screenshots are captured from local development service.
## Repository management (`/repos`)
![Repository management](./assets/page-repos.png)
## System configuration (`/config`)
![System configuration](./assets/page-config.png)
## Notification management (`/notifications`)
![Notification management](./assets/page-notifications.png)
## Review configuration (`/review-config`)
![Review configuration](./assets/page-review-config.png)
## Language
- 中文: [screenshots.zh-CN.md](./screenshots.zh-CN.md)

23
docs/screenshots.zh-CN.md Normal file
View File

@@ -0,0 +1,23 @@
# 截图集
以下截图来自本地开发环境。
## 仓库管理(`/repos`
![仓库管理](./assets/page-repos.png)
## 系统配置(`/config`
![系统配置](./assets/page-config.png)
## 通知管理(`/notifications`
![通知管理](./assets/page-notifications.png)
## 审查配置(`/review-config`
![审查配置](./assets/page-review-config.png)
## 语言切换
- English: [screenshots.md](./screenshots.md)

View File

@@ -11,6 +11,6 @@ RUN bun install --no-frozen-lockfile
COPY src ./src
COPY tsconfig.json .
EXPOSE 3000
EXPOSE 5174
CMD ["bun", "run", "start"]

View File

@@ -162,7 +162,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}\"
}

View File

@@ -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" } }, ""],

View File

@@ -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",

View File

@@ -4,6 +4,7 @@ import { LoginPage } from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
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'
@@ -51,6 +52,7 @@ function AppContent() {
<Route index element={<Navigate to="/repos" replace />} />
<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>

View File

@@ -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">

View File

@@ -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();

View 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>
);
}

View 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>
</>
);
}

View File

@@ -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>

View File

@@ -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>
),
},
]

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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' }
);
});
});
});

View 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');
});
});
});

View 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,
}

View 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,
}

View File

@@ -1,7 +1,7 @@
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 } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
import { isColorPalette, useColorPalette } from '@/hooks/useColorPalette';
@@ -9,6 +9,7 @@ import { isColorPalette, useColorPalette } from '@/hooks/useColorPalette';
const navItems = [
{ 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 +32,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 +207,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>

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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',
},
],
},
{
@@ -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);
}

View File

@@ -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

View File

@@ -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",

View File

@@ -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();
});

View File

@@ -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;
@@ -46,13 +53,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;
@@ -137,9 +142,16 @@ class ConfigManager {
apiUrl: values.GITEA_API_URL ?? 'http://localhost:5174/api/v1',
accessToken: values.GITEA_ACCESS_TOKEN ?? 'test_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,
@@ -178,13 +190,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),

View File

@@ -7,7 +7,7 @@
// Types
// ---------------------------------------------------------------------------
export type ConfigGroup = 'gitea' | 'feishu' | 'security' | 'review' | 'memory';
export type ConfigGroup = 'gitea' | 'notification' | 'security' | 'review' | 'memory';
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',
},
{
@@ -110,24 +110,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',

View File

@@ -1,6 +1,7 @@
import { configManager } from './config-manager';
import type { NotificationServiceConfig } from '../services/notification/types.js';
import { configManager } from './config-manager.js';
type AppConfig = import('./config-manager').AppConfig;
type AppConfig = import('./config-manager.js').AppConfig;
const config = new Proxy({} as AppConfig, {
get(_target, prop) {
@@ -8,5 +9,29 @@ const config = new Proxy({} as AppConfig, {
},
});
export function getNotificationConfigs(): NotificationServiceConfig[] {
const current = configManager.getCurrent();
const configs: NotificationServiceConfig[] = [];
if (current.notification.feishu.enabled && current.notification.feishu.webhookUrl) {
configs.push({
provider: 'feishu',
enabled: true,
webhookUrl: current.notification.feishu.webhookUrl,
webhookSecret: current.notification.feishu.webhookSecret,
});
}
if (current.notification.wecom.enabled && current.notification.wecom.webhookUrl) {
configs.push({
provider: 'wecom',
enabled: true,
webhookUrl: current.notification.wecom.webhookUrl,
});
}
return configs;
}
export { configManager };
export default config;

View File

@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { Hono } from 'hono';
import { repositoryReviewPromptRepo } from '../../db/repositories/repository-review-prompt-repo';
import { giteaService } from '../../services/gitea';
import { adminController } from '../admin';
type RepoRecord = { full_name: string };
type HookRecord = { id: number; config: { url: string } };
function createTestApp(): Hono {
const app = new Hono();
app.route('/admin/api', adminController.protectedRoutes);
return app;
}
describe('admin repositories route', () => {
const originalListAllRepositories = giteaService.listAllRepositories;
const originalListWebhooks = giteaService.listWebhooks;
const originalListProjectPrompts = repositoryReviewPromptRepo.listProjectPrompts;
beforeEach(() => {
const repos: RepoRecord[] = [
{ full_name: 'team/inactive-alpha' },
{ full_name: 'team/active-beta' },
{ full_name: 'team/inactive-gamma' },
{ full_name: 'team/active-delta' },
];
giteaService.listAllRepositories = async () => ({
repos,
totalCount: repos.length,
});
giteaService.listWebhooks = async (_owner: string, repo: string) => {
if (repo.startsWith('active-')) {
return [{ id: 101, config: { url: 'http://localhost/webhook/gitea' } }] as HookRecord[];
}
return [] as HookRecord[];
};
repositoryReviewPromptRepo.listProjectPrompts = () => ({
'team/active-beta': 'focus security',
});
});
afterEach(() => {
giteaService.listAllRepositories = originalListAllRepositories;
giteaService.listWebhooks = originalListWebhooks;
repositoryReviewPromptRepo.listProjectPrompts = originalListProjectPrompts;
});
test('returns active webhook repositories first', async () => {
const app = createTestApp();
const response = await app.request('http://localhost/admin/api/repositories?page=1');
const payload = (await response.json()) as {
data: Array<{
name: string;
webhook_status: 'active' | 'inactive';
hook_id: number | null;
project_review_prompt: string | null;
}>;
totalCount: number;
page: number;
limit: number;
};
expect(response.status).toBe(200);
expect(payload.totalCount).toBe(4);
expect(payload.page).toBe(1);
expect(payload.limit).toBe(30);
expect(payload.data.map((repo) => repo.name)).toEqual([
'team/active-beta',
'team/active-delta',
'team/inactive-alpha',
'team/inactive-gamma',
]);
expect(payload.data.map((repo) => repo.webhook_status)).toEqual([
'active',
'active',
'inactive',
'inactive',
]);
expect(payload.data[0]?.project_review_prompt).toBe('focus security');
expect(payload.data[1]?.project_review_prompt).toBeNull();
});
});

View File

@@ -1,8 +1,10 @@
import { Hono } from 'hono';
import { sign } from 'hono/jwt';
import config from '../config';
import { repositoryReviewPromptRepo } from '../db/repositories/repository-review-prompt-repo';
import { reviewEngine } from '../review/engine';
import { giteaService } from '../services/gitea';
import { toErrorLogMeta } from '../utils/error-log';
import { logger } from '../utils/logger';
const publicRoutes = new Hono();
@@ -30,13 +32,55 @@ publicRoutes.post('/login', async (c) => {
// 获取仓库列表及 Webhook 状态
protectedRoutes.get('/repositories', async (c) => {
const page = Number.parseInt(c.req.query('page') || '1', 10);
const query = c.req.query('q');
const limit = 30; // 每页数量固定,或也可从查询参数获取
const requestContext = {
page,
limit,
query: query ?? null,
requestUrl: c.req.url,
method: c.req.method,
runtime: process.versions.bun ? `bun-${process.versions.bun}` : process.version,
nodeEnv: process.env.NODE_ENV ?? null,
databasePath: process.env.DATABASE_PATH || './data/assistant.db',
};
try {
const page = Number.parseInt(c.req.query('page') || '1', 10);
const query = c.req.query('q');
const limit = 30; // 每页数量固定,或也可从查询参数获取
logger.debug('开始获取仓库列表', requestContext);
const { repos, totalCount } = await giteaService.listAllRepositories(page, limit, query);
logger.debug('仓库搜索接口返回成功', {
...requestContext,
reposCount: repos.length,
totalCount,
sampleRepos: repos
.slice(0, 3)
.map((repo) => (typeof repo.full_name === 'string' ? repo.full_name : null)),
});
const webhookUrl = c.req.url.replace(/\/admin\/api\/repositories.*$/, '/webhook/gitea');
const fullNames = repos
.map((repo) => (typeof repo.full_name === 'string' ? repo.full_name : null))
.filter((name): name is string => name !== null);
logger.debug('准备批量读取项目级提示词', {
...requestContext,
fullNamesCount: fullNames.length,
fullNamesSample: fullNames.slice(0, 5),
});
let promptMap: Record<string, string>;
try {
promptMap = repositoryReviewPromptRepo.listProjectPrompts(fullNames);
} catch (error: unknown) {
logger.error('批量读取项目级提示词失败', {
...requestContext,
fullNamesCount: fullNames.length,
fullNamesSample: fullNames.slice(0, 5),
error: toErrorLogMeta(error),
});
throw error;
}
const reposWithStatus = await Promise.all(
repos.map(async (repo) => {
@@ -47,19 +91,54 @@ protectedRoutes.get('/repositories', async (c) => {
name: repo.full_name,
webhook_status: webhook ? 'active' : 'inactive',
hook_id: webhook ? webhook.id : null,
project_review_prompt: promptMap[repo.full_name] || null,
};
})
);
reposWithStatus.sort((a, b) => {
if (a.webhook_status === b.webhook_status) {
return 0;
}
return a.webhook_status === 'active' ? -1 : 1;
});
return c.json({
data: reposWithStatus,
totalCount,
page,
limit,
});
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('获取仓库列表失败:', {
...requestContext,
error: toErrorLogMeta(error),
});
return c.json({ message: 'Failed to fetch repositories', error: errorMessage }, 500);
}
});
protectedRoutes.put('/repositories/:owner/:repo/project-prompt', async (c) => {
const { owner, repo } = c.req.param();
try {
const body = (await c.req.json()) as { project_review_prompt?: unknown };
if (typeof body.project_review_prompt !== 'string') {
return c.json({ message: 'project_review_prompt must be a string' }, 400);
}
const normalizedPrompt = body.project_review_prompt.trim();
if (!normalizedPrompt) {
repositoryReviewPromptRepo.clearProjectPrompt(owner, repo);
return c.json({ success: true, project_review_prompt: null });
}
const updated = repositoryReviewPromptRepo.setProjectPrompt(owner, repo, normalizedPrompt);
return c.json({ success: true, project_review_prompt: updated.project_prompt });
} catch (error: any) {
logger.error('获取仓库列表失败:', error);
return c.json({ message: 'Failed to fetch repositories', error: error.message }, 500);
logger.error(`更新 ${owner}/${repo} 的项目级审查提示词失败:`, error);
return c.json({ message: 'Failed to update project review prompt', error: error.message }, 500);
}
});

View File

@@ -1,6 +1,8 @@
import { Hono } from 'hono';
import { configManager } from '../config/config-manager';
import { CONFIG_FIELDS, CONFIG_GROUPS, type ConfigFieldMeta } from '../config/config-schema';
import { getNotificationManager } from '../services/notification-manager';
import type { NotificationProvider } from '../services/notification/types';
import { logger } from '../utils/logger';
// ── Constants ────────────────────────────────────────────────────────────────
@@ -19,6 +21,36 @@ const INTEGER_FIELDS = new Set([
/** Fast lookup from envKey → field metadata. */
const FIELDS_MAP = new Map<string, ConfigFieldMeta>(CONFIG_FIELDS.map((f) => [f.envKey, f]));
const TESTABLE_PROVIDERS = new Set<NotificationProvider>(['feishu', 'wecom']);
const NOTIFICATION_TEST_HISTORY_LIMIT = 30;
type NotificationTestRecord = {
id: string;
provider: string;
status: 'success' | 'error';
message: string;
timestamp: string;
};
const notificationTestHistory: NotificationTestRecord[] = [];
function appendNotificationTestRecord(
provider: string,
status: 'success' | 'error',
message: string
): void {
notificationTestHistory.unshift({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
provider,
status,
message,
timestamp: new Date().toISOString(),
});
if (notificationTestHistory.length > NOTIFICATION_TEST_HISTORY_LIMIT) {
notificationTestHistory.splice(NOTIFICATION_TEST_HISTORY_LIMIT);
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -203,3 +235,56 @@ configRouter.post('/reset', async (c) => {
return c.json({ message: '保存配置失败', error: errMsg }, 500);
}
});
configRouter.post('/notification/test', async (c) => {
try {
let body: Record<string, unknown>;
try {
const parsed = await c.req.json();
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
appendNotificationTestRecord('unknown', 'error', '请求体必须是 JSON 对象');
return c.json({ message: '发送测试通知失败', error: '请求体必须是 JSON 对象' }, 400);
}
body = parsed;
} catch {
appendNotificationTestRecord('unknown', 'error', '请求体必须是 JSON 对象');
return c.json({ message: '发送测试通知失败', error: '请求体必须是 JSON 对象' }, 400);
}
const provider = typeof body.provider === 'string' ? body.provider : '';
if (!TESTABLE_PROVIDERS.has(provider as NotificationProvider)) {
appendNotificationTestRecord(
provider || 'unknown',
'error',
'provider 必须是 feishu 或 wecom'
);
return c.json({ message: '发送测试通知失败', error: 'provider 必须是 feishu 或 wecom' }, 400);
}
const notificationManager = getNotificationManager();
const providerName = provider as NotificationProvider;
if (!notificationManager.hasService(providerName)) {
appendNotificationTestRecord(providerName, 'error', `${providerName} 未启用或未配置`);
return c.json({ message: '发送测试通知失败', error: `${providerName} 未启用或未配置` }, 400);
}
await notificationManager.sendTestMessage(providerName);
appendNotificationTestRecord(providerName, 'success', `${providerName} 测试通知已发送`);
return c.json({
success: true,
message: `${providerName} 测试通知已发送`,
});
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : String(error);
appendNotificationTestRecord('unknown', 'error', errMsg);
logger.error('发送测试通知失败:', error);
return c.json({ message: '发送测试通知失败', error: errMsg }, 500);
}
});
configRouter.get('/notification/test/history', (c) => {
return c.json({ data: notificationTestHistory });
});

View File

@@ -6,8 +6,9 @@ import { codexEngine } from '../review/codex/codex-engine';
import { LocalRepoManager } from '../review/context/local-repo-manager';
import { SandboxExec } from '../review/context/sandbox-exec';
import { reviewEngine } from '../review/engine';
import { feishuService } from '../services/feishu';
import { PullRequestDetails, giteaService } from '../services/gitea';
import { getNotificationManager } from '../services/notification-manager';
import type { NotificationContext } from '../services/notification/types';
import { logger } from '../utils/logger';
// Gitea webhook事件类型
@@ -117,42 +118,47 @@ async function handlePullRequestEvent(c: Context, body: any): Promise<Response>
logger.info('收到PR事件', { owner, repo: repoName, prNumber, action: body.action });
// 处理PR审阅者通知仅在飞书启用时
if (feishuService.isEnabled()) {
try {
// 获取PR的审阅者列表
const reviewerUsernames = map(
pullRequest.requested_reviewers,
(reviewer) => reviewer.full_name || reviewer.login
);
// 处理PR审阅者通知支持多平台
try {
const reviewerUsernames = map(
pullRequest.requested_reviewers,
(reviewer) => reviewer.full_name || reviewer.login
);
// 记录审阅者信息
if (reviewerUsernames.length > 0) {
logger.info('PR有指定审阅者', {
prNumber,
reviewers: reviewerUsernames.join(','),
});
}
// 处理PR创建事件如果有审阅者则通知
if (body.action === 'opened' && reviewerUsernames.length > 0) {
await feishuService.sendPrCreatedNotification(prTitle, prUrl, reviewerUsernames);
}
// 处理审阅者指派事件
if (body.action === 'review_requested' && body.requested_reviewer) {
const newReviewerUsername =
body.requested_reviewer.full_name || body.requested_reviewer.login;
if (newReviewerUsername) {
await feishuService.sendPrReviewerAssignedNotification(prTitle, prUrl, [
newReviewerUsername,
]);
}
}
} catch (error) {
logger.error('处理PR审阅者通知失败:', error);
// 继续执行代码审查流程,不因通知失败而中断
if (reviewerUsernames.length > 0) {
logger.info('PR有指定审阅者', {
prNumber,
reviewers: reviewerUsernames.join(','),
});
}
const notificationManager = getNotificationManager();
const context: NotificationContext = {
prTitle,
prUrl,
prNumber,
reviewers: reviewerUsernames,
repository: repoName,
owner,
actor: body.sender?.login,
};
// 处理PR创建事件
if (body.action === 'opened' && reviewerUsernames.length > 0) {
await notificationManager.notifyPrCreated(context);
}
// 处理审阅者指派事件
if (body.action === 'review_requested' && body.requested_reviewer) {
const newReviewerUsername =
body.requested_reviewer.full_name || body.requested_reviewer.login;
if (newReviewerUsername) {
context.assignees = [newReviewerUsername];
await notificationManager.notifyPrReviewerAssigned(context);
}
}
} catch (error) {
logger.error('处理PR审阅者通知失败:', error);
}
// Fork PR策略始终clone base repo保证有baseShaheadCloneUrl作为额外remote保证有headSha
@@ -365,26 +371,28 @@ async function handleIssueEvent(c: Context, body: any): Promise<Response> {
assigneeUsernames: assigneeUsernames.join(','),
});
if (!feishuService.isEnabled()) {
return c.json({ status: 'ignored', message: '飞书通知未启用,工单事件已跳过' }, 200);
}
try {
// 处理工单创建事件
const notificationManager = getNotificationManager();
const context: NotificationContext = {
issueTitle,
issueUrl,
issueNumber: issue.number,
creator: creatorUsername,
assignees: assigneeUsernames,
repository: repository.name,
owner: repository.owner?.login,
actor: body.sender?.login,
};
if (action === 'opened' && assigneeUsernames.length > 0) {
await feishuService.sendIssueCreatedNotification(issueTitle, issueUrl, assigneeUsernames);
}
// 处理工单关闭事件
else if (action === 'closed' && creatorUsername) {
await feishuService.sendIssueClosedNotification(issueTitle, issueUrl, creatorUsername);
}
// 处理工单指派事件
else if (action === 'assigned' && assigneeUsernames.length > 0) {
await feishuService.sendIssueAssignedNotification(issueTitle, issueUrl, assigneeUsernames);
await notificationManager.notifyIssueCreated(context);
} else if (action === 'closed') {
await notificationManager.notifyIssueClosed(context);
} else if (action === 'assigned' && assigneeUsernames.length > 0) {
await notificationManager.notifyIssueAssigned(context);
}
} catch (error) {
logger.error('处理工单事件失败:', error);
return c.json({ error: '处理工单事件失败' }, 500);
logger.error('新通知系统处理工单事件失败:', error);
}
return c.json({ status: 'success', message: '工单事件处理完成' }, 200);

View File

@@ -0,0 +1,75 @@
import { Database } from 'bun:sqlite';
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { closeDatabase, getDatabase, initDatabase } from '../database';
function createInconsistentMigrationState(dbPath: string): void {
const db = new Database(dbPath);
db.exec('PRAGMA foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS _migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.query('INSERT INTO _migrations (version, name) VALUES (?, ?)').run(
1,
'init_llm_provider_schema'
);
db.query('INSERT INTO _migrations (version, name) VALUES (?, ?)').run(
2,
'remove_legacy_review_mode'
);
db.query('INSERT INTO _migrations (version, name) VALUES (?, ?)').run(
3,
'add_repository_review_prompts'
);
db.close();
}
describe('migration self-heal for repository review prompts', () => {
let dbPath: string;
const savedDbPath = process.env.DATABASE_PATH;
beforeEach(() => {
const tmpDir = join(tmpdir(), `db-migration-heal-${randomUUID()}`);
mkdirSync(tmpDir, { recursive: true });
dbPath = join(tmpDir, 'test.db');
process.env.DATABASE_PATH = dbPath;
createInconsistentMigrationState(dbPath);
});
afterEach(() => {
closeDatabase();
if (savedDbPath === undefined) {
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
} else {
process.env.DATABASE_PATH = savedDbPath;
}
if (existsSync(dbPath)) unlinkSync(dbPath);
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
});
test('rebuilds missing repository_review_prompts table even when migration 3 is marked applied', () => {
initDatabase();
const db = getDatabase();
const tableRow = db
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get('repository_review_prompts') as { name: string } | null;
expect(tableRow?.name).toBe('repository_review_prompts');
const migrationCountRow = db
.query('SELECT COUNT(*) AS count FROM _migrations WHERE version = ?')
.get(3) as { count: number } | null;
expect(migrationCountRow?.count).toBe(1);
});
});

View File

@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { closeDatabase, getDatabase, initDatabase } from '../database';
import { repositoryReviewPromptRepo } from '../repositories/repository-review-prompt-repo';
describe('repository-review-prompt-repo', () => {
let dbPath: string;
const savedDbPath = process.env.DATABASE_PATH;
beforeEach(() => {
const tmpDir = join(tmpdir(), `db-test-${randomUUID()}`);
mkdirSync(tmpDir, { recursive: true });
dbPath = join(tmpDir, 'test.db');
process.env.DATABASE_PATH = dbPath;
initDatabase();
});
afterEach(() => {
closeDatabase();
if (savedDbPath === undefined) {
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
} else {
process.env.DATABASE_PATH = savedDbPath;
}
if (existsSync(dbPath)) unlinkSync(dbPath);
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
});
test('sets and gets project prompt by owner/repo', () => {
repositoryReviewPromptRepo.setProjectPrompt('acme', 'assistant', 'focus on API correctness');
const prompt = repositoryReviewPromptRepo.getProjectPrompt('acme', 'assistant');
expect(prompt).toBe('focus on API correctness');
});
test('normalizes surrounding whitespace when setting prompt', () => {
repositoryReviewPromptRepo.setProjectPrompt('acme', 'assistant', ' use chinese output ');
const row = repositoryReviewPromptRepo.getByFullName('acme/assistant');
expect(row?.project_prompt).toBe('use chinese output');
});
test('clears prompt for repository', () => {
repositoryReviewPromptRepo.setProjectPrompt('acme', 'assistant', 'focus on security');
const deleted = repositoryReviewPromptRepo.clearProjectPrompt('acme', 'assistant');
expect(deleted).toBe(true);
expect(repositoryReviewPromptRepo.getProjectPrompt('acme', 'assistant')).toBeUndefined();
});
test('lists prompt map for repository names', () => {
repositoryReviewPromptRepo.setProjectPrompt('acme', 'a', 'prompt-a');
repositoryReviewPromptRepo.setProjectPrompt('acme', 'b', 'prompt-b');
const map = repositoryReviewPromptRepo.listProjectPrompts(['acme/a', 'acme/b', 'acme/c']);
expect(map).toEqual({
'acme/a': 'prompt-a',
'acme/b': 'prompt-b',
});
});
test('self-heals missing prompt table and keeps repository listing readable', () => {
const db = getDatabase();
db.exec('DROP TABLE repository_review_prompts');
const map = repositoryReviewPromptRepo.listProjectPrompts(['acme/a']);
expect(map).toEqual({});
repositoryReviewPromptRepo.setProjectPrompt('acme', 'a', 'prompt-a');
expect(repositoryReviewPromptRepo.getProjectPrompt('acme', 'a')).toBe('prompt-a');
});
test('self-heals missing prompt table for direct prompt write path', () => {
const db = getDatabase();
db.exec('DROP TABLE repository_review_prompts');
const row = repositoryReviewPromptRepo.setProjectPrompt(
'acme',
'direct-write',
'prompt-direct'
);
expect(row.project_prompt).toBe('prompt-direct');
expect(repositoryReviewPromptRepo.getProjectPrompt('acme', 'direct-write')).toBe(
'prompt-direct'
);
});
});

View File

@@ -11,6 +11,7 @@ import { dirname, resolve } from 'node:path';
import { migration001Init } from './migrations/001_init';
import { migration002RemoveLegacyReviewMode } from './migrations/002_remove_legacy_review_mode';
import { migration003RepositoryReviewPrompts } from './migrations/003_repository_review_prompts';
// ---------------------------------------------------------------------------
// Types
@@ -26,7 +27,13 @@ export interface Migration {
// Migration registry (ordered by version)
// ---------------------------------------------------------------------------
const MIGRATIONS: Migration[] = [migration001Init, migration002RemoveLegacyReviewMode];
const MIGRATIONS: Migration[] = [
migration001Init,
migration002RemoveLegacyReviewMode,
migration003RepositoryReviewPrompts,
];
const REPOSITORY_REVIEW_PROMPTS_TABLE = 'repository_review_prompts';
// ---------------------------------------------------------------------------
// Database singleton
@@ -67,11 +74,39 @@ export function initDatabase(): Database {
// Run migrations
runMigrations(db);
ensureRepositoryReviewPromptsSchema(db);
console.log(`📦 Database initialized at ${dbPath}`);
return db;
}
function doesTableExist(database: Database, tableName: string): boolean {
const row = database
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(tableName) as { name?: string } | null;
return row?.name === tableName;
}
export function ensureRepositoryReviewPromptsSchema(database: Database = getDatabase()): void {
if (doesTableExist(database, REPOSITORY_REVIEW_PROMPTS_TABLE)) {
return;
}
console.warn(
`⚠️ Detected inconsistent DB state: table '${REPOSITORY_REVIEW_PROMPTS_TABLE}' is missing. Rebuilding schema.`
);
database.transaction(() => {
migration003RepositoryReviewPrompts.up(database);
if (doesTableExist(database, '_migrations')) {
database
.query('INSERT OR IGNORE INTO _migrations (version, name) VALUES (?, ?)')
.run(migration003RepositoryReviewPrompts.version, migration003RepositoryReviewPrompts.name);
}
})();
}
/**
* Get the database instance. Throws if not initialized.
*/

View File

@@ -0,0 +1,21 @@
import type { Database } from 'bun:sqlite';
import type { Migration } from '../database';
export const migration003RepositoryReviewPrompts: Migration = {
version: 3,
name: 'add_repository_review_prompts',
up(db: Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS repository_review_prompts (
full_name TEXT PRIMARY KEY,
project_prompt TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec(
'CREATE INDEX IF NOT EXISTS idx_repository_review_prompts_updated_at ON repository_review_prompts(updated_at)'
);
},
};

View File

@@ -0,0 +1,188 @@
import { toErrorLogMeta } from '../../utils/error-log';
import { logger } from '../../utils/logger';
import { ensureRepositoryReviewPromptsSchema, getDatabase } from '../database';
export interface RepositoryReviewPromptRow {
full_name: string;
project_prompt: string;
updated_at: string;
}
function toFullName(owner: string, repo: string): string {
return `${owner}/${repo}`;
}
function isMissingPromptTableError(error: unknown): boolean {
return (
error instanceof Error && error.message.includes('no such table: repository_review_prompts')
);
}
function withPromptTableHeal<T>(operation: string, run: () => T): T {
try {
return run();
} catch (error: unknown) {
if (!isMissingPromptTableError(error)) {
throw error;
}
logger.warn('检测到 repository_review_prompts 表缺失,尝试自愈建表后重试', {
operation,
databasePath: process.env.DATABASE_PATH || './data/assistant.db',
error: toErrorLogMeta(error),
});
ensureRepositoryReviewPromptsSchema();
return run();
}
}
export const repositoryReviewPromptRepo = {
getByFullName(fullName: string): RepositoryReviewPromptRow | null {
return withPromptTableHeal('getByFullName', () => {
const db = getDatabase();
return (
(db
.query(
'SELECT full_name, project_prompt, updated_at FROM repository_review_prompts WHERE full_name = ?'
)
.get(fullName) as RepositoryReviewPromptRow | null) || null
);
});
},
getProjectPrompt(owner: string, repo: string): string | undefined {
const row = this.getByFullName(toFullName(owner, repo));
if (!row) return undefined;
const normalized = row.project_prompt.trim();
return normalized.length > 0 ? normalized : undefined;
},
upsertByFullName(fullName: string, projectPrompt: string): RepositoryReviewPromptRow {
const normalized = projectPrompt.trim();
if (!normalized) {
throw new Error('projectPrompt must be non-empty');
}
return withPromptTableHeal('upsertByFullName', () => {
const db = getDatabase();
db.query(
`INSERT INTO repository_review_prompts (full_name, project_prompt, updated_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(full_name) DO UPDATE SET
project_prompt = excluded.project_prompt,
updated_at = datetime('now')`
).run(fullName, normalized);
const row = this.getByFullName(fullName);
if (!row) {
throw new Error('Failed to load repository review prompt after upsert');
}
return row;
});
},
setProjectPrompt(owner: string, repo: string, projectPrompt: string): RepositoryReviewPromptRow {
return this.upsertByFullName(toFullName(owner, repo), projectPrompt);
},
deleteByFullName(fullName: string): boolean {
return withPromptTableHeal('deleteByFullName', () => {
const db = getDatabase();
const result = db
.query('DELETE FROM repository_review_prompts WHERE full_name = ?')
.run(fullName);
return result.changes > 0;
});
},
clearProjectPrompt(owner: string, repo: string): boolean {
return this.deleteByFullName(toFullName(owner, repo));
},
listProjectPrompts(fullNames: string[]): Record<string, string> {
if (fullNames.length === 0) {
return {};
}
const db = getDatabase();
const loadPromptMap = (): Record<string, string> => {
const placeholders = fullNames.map(() => '?').join(', ');
const rows = db
.query(
`SELECT full_name, project_prompt
FROM repository_review_prompts
WHERE full_name IN (${placeholders})`
)
.all(...fullNames) as Array<
Pick<RepositoryReviewPromptRow, 'full_name' | 'project_prompt'>
>;
const map: Record<string, string> = {};
for (const row of rows) {
const normalized = row.project_prompt.trim();
if (normalized) {
map[row.full_name] = normalized;
}
}
return map;
};
try {
return loadPromptMap();
} catch (error: unknown) {
if (isMissingPromptTableError(error)) {
logger.warn('检测到 repository_review_prompts 表缺失,尝试自愈建表后重试', {
fullNamesCount: fullNames.length,
fullNamesSample: fullNames.slice(0, 5),
databasePath: process.env.DATABASE_PATH || './data/assistant.db',
});
try {
ensureRepositoryReviewPromptsSchema(db);
return loadPromptMap();
} catch (healError: unknown) {
logger.error('自愈 repository_review_prompts 表后重试失败,降级返回空提示词映射', {
fullNamesCount: fullNames.length,
fullNamesSample: fullNames.slice(0, 5),
databasePath: process.env.DATABASE_PATH || './data/assistant.db',
originalError: toErrorLogMeta(error),
healError: toErrorLogMeta(healError),
});
return {};
}
}
let tableExists: boolean | null = null;
let latestMigrationVersion: number | null = null;
try {
const tableRow = db
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get('repository_review_prompts') as { name?: string } | null;
tableExists = tableRow?.name === 'repository_review_prompts';
const migrationRow = db
.query('SELECT version FROM _migrations ORDER BY version DESC LIMIT 1')
.get() as { version?: number } | null;
latestMigrationVersion = migrationRow?.version ?? null;
} catch (inspectError: unknown) {
logger.warn('查询项目级提示词失败后,诊断数据库状态时发生错误', {
inspectError: toErrorLogMeta(inspectError),
});
}
logger.error('批量查询项目级提示词失败', {
fullNamesCount: fullNames.length,
fullNamesSample: fullNames.slice(0, 5),
tableExists,
latestMigrationVersion,
databasePath: process.env.DATABASE_PATH || './data/assistant.db',
error: toErrorLogMeta(error),
});
throw error;
}
},
};

View File

@@ -0,0 +1,244 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import type { DiffExtractor } from '../context/diff-extractor';
import type { LocalRepoManager, LocalRepoPaths } from '../context/local-repo-manager';
import type { FileReviewStore } from '../store/file-review-store';
import type { Finding, ReviewContext, ReviewRun, ReviewTask } from '../types';
function makeRun(overrides: Partial<ReviewRun> = {}): ReviewRun {
return {
id: 'run-project-prompt',
idempotencyKey: 'owner/repo#8:base...head',
eventType: 'pull_request',
status: 'in_progress',
owner: 'owner',
repo: 'repo',
cloneUrl: 'https://example.com/repo.git',
prNumber: 8,
baseSha: 'base-sha',
headSha: 'head-sha',
attempts: 1,
maxAttempts: 3,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function createStoreMock() {
return {
markRunIgnored: mock(async () => undefined),
addStep: mock(async () => undefined),
getRunDetails: mock(async () => ({ comments: [], findings: [] })),
addFindings: mock(async () => undefined),
markFindingPublished: mock(async () => true),
addCommentRecord: mock(async () => undefined),
};
}
function createLocalRepoManagerMock() {
const repoPaths: LocalRepoPaths = {
mirrorPath: '/tmp/mirror',
workspacePath: '/tmp/workspace',
};
return {
manager: {
prepareWorkspace: mock(async () => repoPaths),
resolveReviewedRef: mock(async () => null),
saveReviewedRef: mock(async () => undefined),
cleanupWorkspace: mock(async () => undefined),
},
repoPaths,
};
}
function createDiffExtractorMock() {
const context: ReviewContext = {
workspacePath: '/tmp/workspace',
mirrorPath: '/tmp/mirror',
diff: 'diff --git a/src/app.ts b/src/app.ts\n+const x = 1;',
changedFiles: [
{
path: 'src/app.ts',
status: 'M',
additions: 3,
deletions: 1,
},
],
parsedDiff: [],
fileContents: {},
};
return {
context,
extractor: {
getSandbox: mock(() => ({
execute: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
})),
buildContext: mock(async () => context),
},
};
}
describe('project prompt wiring', () => {
beforeEach(() => {
mock.restore();
});
afterEach(() => {
mock.restore();
});
test('orchestrator forwards resolved project prompt to triage and specialist execution options', async () => {
const projectPrompt = `repo-policy-${'P'.repeat(360)}`;
mock.module('../project-review-prompt', () => ({
resolveProjectReviewPrompt: () => projectPrompt,
}));
const { ReviewOrchestrator } = await import('../orchestrator');
const store = createStoreMock();
const { manager } = createLocalRepoManagerMock();
const { extractor } = createDiffExtractorMock();
const orchestrator = new ReviewOrchestrator(
store as unknown as FileReviewStore,
manager as unknown as LocalRepoManager,
extractor as unknown as DiffExtractor
);
type TriageResultLike = {
complexity: 'trivial' | 'standard' | 'complex';
reviewSize: 'small' | 'medium' | 'large';
mode: 'skip' | 'light' | 'full';
tasks: ReviewTask[];
riskTags: string[];
rationale: string;
};
type ReviewFinding = Array<Omit<Finding, 'id' | 'runId' | 'published'>>;
type InternalOrchestrator = {
triageAgent: {
analyze: (
context: ReviewContext,
options?: { projectPrompt?: string }
) => Promise<TriageResultLike>;
};
agentMap: Record<
string,
{
reviewWithOptions: (
run: ReviewRun,
context: ReviewContext,
options: { projectPrompt?: string }
) => Promise<{ findings: ReviewFinding }>;
reviewWithReflection: (
run: ReviewRun,
context: ReviewContext,
maxRounds?: number,
options?: { projectPrompt?: string }
) => Promise<{ findings: ReviewFinding }>;
}
>;
judgeAgent: {
judge: (findings: ReviewFinding) => { summaryMarkdown: string; findings: ReviewFinding };
};
publishSummary: (run: ReviewRun, summary: string, gatedCount: number) => Promise<void>;
publishLineComments: (
run: ReviewRun,
comments: Array<{ path: string; line: number; comment: string }>
) => Promise<boolean>;
};
const internal = orchestrator as unknown as InternalOrchestrator;
const task: ReviewTask = {
domain: 'correctness',
paths: ['src/app.ts'],
riskTags: [],
mode: 'light',
tokenBudget: 1200,
maxIterations: 1,
allowTools: false,
allowReflection: false,
allowDebate: false,
};
const triageAnalyzeMock = mock(async () => ({
complexity: 'standard' as const,
reviewSize: 'small' as const,
mode: 'light' as const,
tasks: [task],
riskTags: [],
rationale: 'project prompt wiring test',
}));
const reviewWithOptionsMock = mock(async () => ({
findings: [] as ReviewFinding,
}));
const reviewWithReflectionMock = mock(async () => ({
findings: [] as ReviewFinding,
}));
internal.triageAgent = {
analyze: triageAnalyzeMock,
};
internal.agentMap = {
correctness: {
reviewWithOptions: reviewWithOptionsMock,
reviewWithReflection: reviewWithReflectionMock,
},
};
internal.judgeAgent = {
judge: mock(() => ({
summaryMarkdown: 'ok',
findings: [] as ReviewFinding,
})),
};
internal.publishSummary = mock(async () => undefined);
internal.publishLineComments = mock(async () => false);
const run = makeRun();
await orchestrator.execute(run);
expect(triageAnalyzeMock).toHaveBeenCalledWith(expect.anything(), { projectPrompt });
expect(reviewWithOptionsMock).toHaveBeenCalledWith(
run,
expect.anything(),
expect.objectContaining({ projectPrompt })
);
});
test('codex prompt builder includes resolved project-level prompt section', async () => {
const projectPrompt = `codex-policy-${'X'.repeat(320)}`;
mock.module('../project-review-prompt', () => ({
resolveProjectReviewPrompt: () => projectPrompt,
}));
const { CodexRunner } = await import('../codex/codex-runner');
const store = createStoreMock();
const { manager } = createLocalRepoManagerMock();
const runner = new CodexRunner(
store as unknown as FileReviewStore,
manager as unknown as LocalRepoManager
);
const internal = runner as unknown as {
buildReviewPrompt: (run: ReviewRun, lastReviewedHead?: string) => string;
};
const prompt = internal.buildReviewPrompt(makeRun(), undefined);
expect(prompt).toContain('## 项目级审查要求');
expect(prompt).toContain(projectPrompt);
});
});

View File

@@ -1,4 +1,5 @@
import { describe, expect, test } from 'bun:test';
import type { LLMGateway } from '../../llm/gateway';
import type { LLMChatResponse, ModelRole } from '../../llm/types';
import { TriageAgent } from '../agents/triage-agent';
import type { ChangedFile, FindingCategory, ReviewContext } from '../types';
@@ -204,6 +205,44 @@ describe('TriageAgent task-based routing', () => {
expect(result.rationale).toBe('跨文件业务逻辑调整');
});
test('LLM fallback: planner system message keeps full project prompt', async () => {
const longProjectPrompt = `repo-policy-${'P'.repeat(420)}`;
const { gateway, getCalls } = createMockGateway(async () =>
makeChatResponse(
JSON.stringify({
complexity: 'standard',
review_size: 'medium',
mode: 'light',
relevant_domains: ['correctness'],
risk_tags: ['maintainability-hotspot'],
rationale: '需要模型判断',
})
)
);
const agent = new TriageAgent(gateway as unknown as LLMGateway);
await agent.analyze(
makeContext({
changedFiles: [
makeChangedFile({ path: 'src/service/order.ts', additions: 20, deletions: 10 }),
makeChangedFile({ path: 'src/controller/order.ts', additions: 18, deletions: 12 }),
makeChangedFile({ path: 'src/repo/order.ts', additions: 15, deletions: 12 }),
makeChangedFile({ path: 'src/model/order.ts', additions: 14, deletions: 13 }),
],
}),
{ projectPrompt: longProjectPrompt }
);
const calls = getCalls();
expect(calls).toHaveLength(1);
const plannerMessages = calls[0].request.messages as Array<{ role: string; content: string }>;
const plannerSystemMessage = plannerMessages.find((message) => message.role === 'system');
expect(plannerSystemMessage?.content).toContain(longProjectPrompt);
});
test('LLM fallback: planner throws -> default full review with all domains', async () => {
const { gateway, getCalls } = createMockGateway(async () => {
throw new Error('planner unavailable');

View File

@@ -1,7 +1,7 @@
import config from '../../config';
import type { LLMGateway } from '../../llm/gateway';
import type { LLMMessage } from '../../llm/types';
import { withCoreGlobalPrompt } from '../../utils/global-prompt';
import { mergeReviewPrompts, withCoreGlobalPrompt } from '../../utils/global-prompt';
import { logger } from '../../utils/logger';
import { tokenCounter } from '../context/token-counter';
import { Finding, ReviewContext } from '../types';
@@ -25,7 +25,8 @@ export class CriticAgent {
async critique(
findings: Omit<Finding, 'id' | 'runId' | 'published'>[],
context: ReviewContext
context: ReviewContext,
projectPrompt?: string
): Promise<CritiqueResult> {
if (findings.length === 0) {
return {
@@ -75,7 +76,7 @@ ${tokenCounter.clip(context.diff, 1000)}
role: 'system',
content: withCoreGlobalPrompt(
'你是严格的代码审查质量评估专家以高标准评估findings的质量。',
config.review.globalPrompt
mergeReviewPrompts(config.review.globalPrompt, projectPrompt)
),
},
{ role: 'user', content: prompt },
@@ -132,7 +133,8 @@ ${tokenCounter.clip(context.diff, 1000)}
async evaluateSingleFinding(
finding: Omit<Finding, 'id' | 'runId' | 'published'>,
context: ReviewContext
context: ReviewContext,
projectPrompt?: string
): Promise<{
isValid: boolean;
confidence: number;
@@ -166,7 +168,10 @@ ${tokenCounter.clip(context.diff, 700)}
const messages: LLMMessage[] = [
{
role: 'system',
content: withCoreGlobalPrompt('你是代码审查质量评估专家。', config.review.globalPrompt),
content: withCoreGlobalPrompt(
'你是代码审查质量评估专家。',
mergeReviewPrompts(config.review.globalPrompt, projectPrompt)
),
},
{ role: 'user', content: prompt },
];

View File

@@ -1,7 +1,7 @@
import config from '../../config';
import type { LLMGateway } from '../../llm/gateway';
import type { LLMMessage } from '../../llm/types';
import { withCoreGlobalPrompt } from '../../utils/global-prompt';
import { mergeReviewPrompts, withCoreGlobalPrompt } from '../../utils/global-prompt';
import { logger } from '../../utils/logger';
import { Finding, FindingSeverity } from '../types';
import { SpecialistAgent } from './specialist-agent';
@@ -24,7 +24,8 @@ export class DebateOrchestrator {
async conductDebate(
finding: Omit<Finding, 'id' | 'runId' | 'published'>,
agents: SpecialistAgent[],
maxRounds = 2
maxRounds = 2,
projectPrompt?: string
): Promise<Omit<Finding, 'id' | 'runId' | 'published'>> {
if (agents.length < 2) {
logger.debug('Debate需要至少2个agents跳过');
@@ -41,7 +42,7 @@ export class DebateOrchestrator {
// 收集初始意见
for (const agent of agents) {
const opinion = await this.getAgentOpinion(agent, finding);
const opinion = await this.getAgentOpinion(agent, finding, projectPrompt);
opinions.set((agent as any).agentName, opinion);
}
@@ -55,7 +56,13 @@ export class DebateOrchestrator {
const agentName = (agent as any).agentName;
const otherOpinions = Array.from(opinions.entries()).filter(([name]) => name !== agentName);
const revisedOpinion = await this.reviseOpinion(agent, finding, otherOpinions, opinions);
const revisedOpinion = await this.reviseOpinion(
agent,
finding,
otherOpinions,
opinions,
projectPrompt
);
opinions.set(agentName, revisedOpinion);
}
@@ -75,7 +82,8 @@ export class DebateOrchestrator {
private async getAgentOpinion(
agent: SpecialistAgent,
finding: Omit<Finding, 'id' | 'runId' | 'published'>
finding: Omit<Finding, 'id' | 'runId' | 'published'>,
projectPrompt?: string
): Promise<AgentOpinion> {
const agentName = (agent as any).agentName;
const prompt = `你是${agentName}。评估以下代码问题的严重性、置信度和有效性。
@@ -107,7 +115,7 @@ export class DebateOrchestrator {
role: 'system',
content: withCoreGlobalPrompt(
`你是${agentName},从你的专业角度独立评估代码问题。`,
config.review.globalPrompt
mergeReviewPrompts(config.review.globalPrompt, projectPrompt)
),
},
{ role: 'user', content: prompt },
@@ -153,7 +161,8 @@ export class DebateOrchestrator {
agent: SpecialistAgent,
finding: Omit<Finding, 'id' | 'runId' | 'published'>,
otherOpinions: [string, AgentOpinion][],
opinions: Map<string, AgentOpinion>
opinions: Map<string, AgentOpinion>,
projectPrompt?: string
): Promise<AgentOpinion> {
const agentName = (agent as any).agentName;
const prompt = `你是${agentName}。重新评估以下问题,考虑其他专家的意见。
@@ -188,7 +197,7 @@ ${otherOpinions
role: 'system',
content: withCoreGlobalPrompt(
`你是${agentName},根据同行意见重新评估,但也要坚持你的专业判断。`,
config.review.globalPrompt
mergeReviewPrompts(config.review.globalPrompt, projectPrompt)
),
},
{ role: 'user', content: prompt },

View File

@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
import config from '../../config';
import type { LLMGateway } from '../../llm/gateway';
import type { LLMMessage } from '../../llm/types';
import { withGlobalPrompt } from '../../utils/global-prompt';
import { mergeReviewPrompts, withGlobalPrompt } from '../../utils/global-prompt';
import { logger } from '../../utils/logger';
import { tokenCounter } from '../context/token-counter';
import { LearningSystem } from '../learning/learning-system';
@@ -43,6 +43,7 @@ export class ReflexionAgent extends SpecialistAgent {
let bestFindings: Omit<Finding, 'id' | 'runId' | 'published'>[] = [];
let bestQualityScore = 0;
let currentFindings: Omit<Finding, 'id' | 'runId' | 'published'>[] = [];
const projectPrompt = options?.projectPrompt;
for (let round = 0; round < maxReflectionRounds; round++) {
logger.info(`${this.agentName} Reflection Round ${round + 1}/${maxReflectionRounds}`, {
@@ -53,7 +54,7 @@ export class ReflexionAgent extends SpecialistAgent {
const draft = await this.generateDraft(run, context, currentFindings, round, options);
// 自我批评
const critique = await this.criticAgent.critique(draft, context);
const critique = await this.criticAgent.critique(draft, context, projectPrompt);
logger.info(`${this.agentName} Critique结果`, {
runId: run.id,
@@ -82,7 +83,7 @@ export class ReflexionAgent extends SpecialistAgent {
// 如果还有改进空间继续优化refine后需要在下一轮重新评估
if (round < maxReflectionRounds - 1) {
currentFindings = await this.refine(draft, critique, context, run);
currentFindings = await this.refine(draft, critique, context, run, projectPrompt);
}
}
@@ -113,7 +114,8 @@ export class ReflexionAgent extends SpecialistAgent {
draft: Omit<Finding, 'id' | 'runId' | 'published'>[],
critique: CritiqueResult,
context: ReviewContext,
run: ReviewRun
run: ReviewRun,
projectPrompt?: string
): Promise<Omit<Finding, 'id' | 'runId' | 'published'>[]> {
const prompt = `你是${this.agentName}。根据以下批评意见,改进审查结果。
@@ -150,7 +152,7 @@ ${tokenCounter.clip(context.diff, 1000)}
role: 'system',
content: withGlobalPrompt(
`你是${this.agentName},根据批评反馈改进审查结果。`,
config.review.globalPrompt
mergeReviewPrompts(config.review.globalPrompt, projectPrompt)
),
},
{ role: 'user', content: prompt },

View File

@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
import config from '../../config';
import type { LLMGateway } from '../../llm/gateway';
import type { LLMMessage, LLMToolCall } from '../../llm/types';
import { withGlobalPrompt } from '../../utils/global-prompt';
import { mergeReviewPrompts, withGlobalPrompt } from '../../utils/global-prompt';
import { logger } from '../../utils/logger';
import { tokenCounter } from '../context/token-counter';
import type { LearningSystem } from '../learning/learning-system';
@@ -36,6 +36,7 @@ export interface SpecialistReviewOptions {
maxIterations?: number;
mode?: ReviewMode;
maxContextTokens?: number;
projectPrompt?: string;
}
function toCompactContext(context: ReviewContext, options?: CompactContextOptions): string {
@@ -185,7 +186,7 @@ ${toCompactContext(context, {
role: 'system',
content: withGlobalPrompt(
'你是严格的代码审查专家。返回结构化JSON不输出额外文字。confidence取值范围0到1。line必须是正整数且引用新增行。',
config.review.globalPrompt
mergeReviewPrompts(config.review.globalPrompt, options?.projectPrompt)
),
},
{ role: 'user', content: prompt },
@@ -271,7 +272,7 @@ ${this.toolRegistry!.getAll()
"need_more_investigation": false
}
每个 finding 对象的所有字段都是必填的。无问题时返回空数组 {"findings": [], "need_more_investigation": false}。`,
config.review.globalPrompt
mergeReviewPrompts(config.review.globalPrompt, options?.projectPrompt)
),
},
];

View File

@@ -12,7 +12,7 @@
import config from '../../config';
import type { LLMGateway } from '../../llm/gateway';
import type { LLMMessage } from '../../llm/types';
import { withCoreGlobalPrompt } from '../../utils/global-prompt';
import { mergeReviewPrompts, withCoreGlobalPrompt } from '../../utils/global-prompt';
import { logger } from '../../utils/logger';
import type {
ChangedFile,
@@ -41,6 +41,10 @@ export interface TriageResult {
rationale: string;
}
export interface TriageOptions {
projectPrompt?: string;
}
/** All valid finding categories. */
const ALL_DOMAINS: FindingCategory[] = [
'correctness',
@@ -334,7 +338,7 @@ export class TriageAgent {
* If the planner role is not configured or the call fails,
* falls back to a heuristic-based triage.
*/
async analyze(context: ReviewContext): Promise<TriageResult> {
async analyze(context: ReviewContext, options?: TriageOptions): Promise<TriageResult> {
// First try heuristic-based fast path (no LLM call needed for obvious cases)
const heuristicResult = this.heuristicTriage(context.changedFiles);
if (heuristicResult) {
@@ -349,7 +353,7 @@ export class TriageAgent {
// Fall back to LLM-based triage
try {
return await this.llmTriage(context);
return await this.llmTriage(context, options);
} catch (error) {
logger.warn('Triage: LLM 调用失败,回退到启发式全量派发', {
error: error instanceof Error ? error.message : String(error),
@@ -454,7 +458,7 @@ export class TriageAgent {
/**
* LLM-based triage using the 'planner' role.
*/
private async llmTriage(context: ReviewContext): Promise<TriageResult> {
private async llmTriage(context: ReviewContext, options?: TriageOptions): Promise<TriageResult> {
const policy = getReviewBudgetPolicy();
const riskTags = collectRiskTags(context.changedFiles);
const fileSummary = context.changedFiles
@@ -494,7 +498,7 @@ ${diffPreview}
role: 'system',
content: withCoreGlobalPrompt(
'你是代码变更分流专家,快速判断变更复杂度。返回结构化 JSON不输出额外文字。',
config.review.globalPrompt
mergeReviewPrompts(config.review.globalPrompt, options?.projectPrompt)
),
},
{ role: 'user', content: prompt },

View File

@@ -3,6 +3,7 @@ import path from 'node:path';
import config from '../../config';
import { logger } from '../../utils/logger';
import type { LocalRepoManager, LocalRepoPaths } from '../context/local-repo-manager';
import { resolveProjectReviewPrompt } from '../project-review-prompt';
import type { FileReviewStore } from '../store/file-review-store';
import type { ReviewRun } from '../types';
import { type ReviewRunContext, mcpToolExecutor } from './mcp-tools';
@@ -306,7 +307,7 @@ export class CodexRunner {
}
private resolveProjectPrompt(_run: ReviewRun): string | undefined {
return undefined;
return resolveProjectReviewPrompt(_run.owner, _run.repo);
}
/**

View File

@@ -12,6 +12,7 @@ import { LocalRepoManager, LocalRepoPaths } from './context/local-repo-manager';
import { LearningSystem } from './learning/learning-system';
import { VectorMemoryStore } from './memory/vector-store';
import { applyPublishPolicy } from './policy/publish-policy';
import { resolveProjectReviewPrompt } from './project-review-prompt';
import { FileReviewStore } from './store/file-review-store';
import { createCodeSearchTool } from './tools/code-search-tool';
import { createFileReadTool } from './tools/file-read-tool';
@@ -229,6 +230,8 @@ export class ReviewOrchestrator {
return;
}
const projectPrompt = resolveProjectReviewPrompt(run.owner, run.repo);
// ── Triage: 决定哪些 specialist 需要参与 ─────────────────────────
let triage: TriageResult | null = null;
const enableTriage = config.review.enableTriage ?? true;
@@ -242,7 +245,7 @@ export class ReviewOrchestrator {
startedAt: new Date(triageStart).toISOString(),
});
triage = await this.triageAgent.analyze(context);
triage = await this.triageAgent.analyze(context, { projectPrompt });
await this.store.addStep({
runId: run.id,
@@ -330,6 +333,7 @@ export class ReviewOrchestrator {
maxIterations: task.maxIterations,
mode: task.mode,
maxContextTokens: Math.max(1500, Math.floor(task.tokenBudget * 0.7)),
projectPrompt,
} as const;
const useReflection =
@@ -411,7 +415,9 @@ export class ReviewOrchestrator {
const uniqueDebateAgents = [...new Set(debateAgents)];
const debatedFinding = await this.debateOrchestrator.conductDebate(
finding,
uniqueDebateAgents
uniqueDebateAgents,
2,
projectPrompt
);
debatedFindings.push(debatedFinding);
}

View File

@@ -0,0 +1,19 @@
import { repositoryReviewPromptRepo } from '../db/repositories/repository-review-prompt-repo';
import { logger } from '../utils/logger';
export function resolveProjectReviewPrompt(owner: string, repo: string): string | undefined {
try {
return repositoryReviewPromptRepo.getProjectPrompt(owner, repo);
} catch (error) {
if (error instanceof Error && error.message.includes('Database not initialized')) {
return undefined;
}
logger.warn('读取项目级审查提示词失败,回退为仅全局提示词', {
owner,
repo,
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
}

View File

@@ -1,156 +0,0 @@
import * as crypto from 'node:crypto';
import config from '../config';
import { logger } from '../utils/logger';
export class FeishuService {
/**
* 判断飞书通知是否已启用
*/
isEnabled(): boolean {
return !!config.feishu.webhookUrl;
}
/**
* 生成飞书消息签名
* @param timestamp 时间戳
* @param secret 密钥
*/
private generateSign(timestamp: string, secret: string): string {
const stringToSign = `${timestamp}\n${secret}`;
const hmac = crypto.createHmac('sha256', stringToSign);
return hmac.digest('base64');
}
/**
* 发送飞书消息
* @param content 消息内容
* @param usernames 需要@的用户名列表
*/
async sendMessage(content: string, usernames: string[] = []): Promise<void> {
const webhookUrl = config.feishu.webhookUrl;
const webhookSecret = config.feishu.webhookSecret;
if (!webhookUrl) {
logger.debug('飞书通知已跳过: webhook URL未配置');
return;
}
try {
const timestamp = Math.floor(Date.now() / 1000).toString();
const message: any = {
msg_type: 'text',
content: {
text: content,
},
};
// 如果需要@用户添加at信息
if (usernames.length > 0) {
message.content.text += '\n';
usernames.forEach((username) => {
message.content.text += `@${username} `;
});
}
// 如果配置了密钥,添加签名
if (webhookSecret) {
message.timestamp = timestamp;
message.sign = this.generateSign(timestamp, webhookSecret);
}
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(message),
});
if (!response.ok) {
throw new Error(`发送飞书消息失败: ${response.statusText}`);
}
logger.info('飞书消息发送成功');
} catch (error) {
logger.error('发送飞书消息失败:', error);
throw error;
}
}
/**
* 发送工单创建通知
* @param issueTitle 工单标题
* @param issueUrl 工单链接
* @param assigneeUsernames 被指派人用户名列表
*/
async sendIssueCreatedNotification(
issueTitle: string,
issueUrl: string,
assigneeUsernames: string[]
): Promise<void> {
const content = `📝 新工单已创建\n标题: ${issueTitle}\n链接: ${issueUrl}`;
await this.sendMessage(content, assigneeUsernames);
}
/**
* 发送工单关闭通知
* @param issueTitle 工单标题
* @param issueUrl 工单链接
* @param creatorUsername 创建者用户名
*/
async sendIssueClosedNotification(
issueTitle: string,
issueUrl: string,
creatorUsername: string
): Promise<void> {
const content = `✅ 工单已关闭\n标题: ${issueTitle}\n链接: ${issueUrl}`;
await this.sendMessage(content, [creatorUsername]);
}
/**
* 发送工单指派通知
* @param issueTitle 工单标题
* @param issueUrl 工单链接
* @param assigneeUsernames 被指派人用户名列表
*/
async sendIssueAssignedNotification(
issueTitle: string,
issueUrl: string,
assigneeUsernames: string[]
): Promise<void> {
const content = `👤 工单已指派给你\n标题: ${issueTitle}\n链接: ${issueUrl}`;
await this.sendMessage(content, assigneeUsernames);
}
/**
* 发送PR创建通知给审阅者
* @param prTitle PR标题
* @param prUrl PR链接
* @param reviewerUsernames 审阅者用户名列表
*/
async sendPrCreatedNotification(
prTitle: string,
prUrl: string,
reviewerUsernames: string[]
): Promise<void> {
const content = `🔄 新PR等待你审阅\n标题: ${prTitle}\n链接: ${prUrl}`;
await this.sendMessage(content, reviewerUsernames);
}
/**
* 发送PR指派审阅者通知
* @param prTitle PR标题
* @param prUrl PR链接
* @param reviewerUsernames 审阅者用户名列表
*/
async sendPrReviewerAssignedNotification(
prTitle: string,
prUrl: string,
reviewerUsernames: string[]
): Promise<void> {
const content = `👀 你被指定为PR审阅者\n标题: ${prTitle}\n链接: ${prUrl}`;
await this.sendMessage(content, reviewerUsernames);
}
}
export const feishuService = new FeishuService();

View File

@@ -1,5 +1,6 @@
import axios from 'axios';
import config from '../config';
import { toErrorLogMeta } from '../utils/error-log';
import { logger } from '../utils/logger';
export interface LineComment {
@@ -32,6 +33,35 @@ giteaAdminClient.interceptors.request.use((req) => {
return req;
});
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function getResponseDataPreview(data: unknown): unknown {
if (typeof data === 'string') {
return data.length > 1000 ? `${data.slice(0, 1000)}...(truncated)` : data;
}
return data;
}
function getAxiosErrorMeta(error: unknown): Record<string, unknown> | null {
if (!axios.isAxiosError(error)) {
return null;
}
return {
code: error.code ?? null,
status: error.response?.status ?? null,
statusText: error.response?.statusText ?? null,
method: error.config?.method ?? null,
baseURL: error.config?.baseURL ?? null,
url: error.config?.url ?? null,
params: error.config?.params ?? null,
responseHeaders: error.response?.headers ?? null,
responseDataPreview: getResponseDataPreview(error.response?.data),
};
}
// Gitea服务接口定义
export interface GiteaService {
// 获取PR的文件差异
@@ -382,7 +412,20 @@ export const giteaService: GiteaService = {
limit = 30,
query?: string
): Promise<{ repos: any[]; totalCount: number }> {
const requestContext = {
page,
limit,
query: query ?? null,
apiUrl: config.gitea.apiUrl,
hasAdminToken: Boolean(config.admin.giteaAdminToken),
hasAccessToken: Boolean(config.gitea.accessToken),
runtime: process.versions.bun ? `bun-${process.versions.bun}` : process.version,
nodeEnv: process.env.NODE_ENV ?? null,
};
try {
logger.debug('开始请求 Gitea 仓库搜索接口', requestContext);
const response = await giteaAdminClient.get('/repos/search', {
params: {
page,
@@ -390,11 +433,51 @@ export const giteaService: GiteaService = {
q: query,
},
});
logger.debug('Gitea 仓库搜索接口返回成功', {
...requestContext,
status: response.status,
contentType: response.headers['content-type'] ?? null,
dataCount: Array.isArray(response.data?.data) ? response.data.data.length : null,
headerTotalCount: response.headers['x-total-count'] ?? null,
});
const totalCount = Number.parseInt(response.headers['x-total-count'] || '0', 10);
return { repos: response.data.data, totalCount };
} catch (error: any) {
logger.error('获取所有仓库列表失败:', error);
throw new Error(`获取所有仓库列表失败: ${error.message}`);
} catch (error: unknown) {
let rawResponseProbe: Record<string, unknown> | null = null;
try {
const probeResponse = await giteaAdminClient.get('/repos/search', {
params: {
page,
limit,
q: query,
},
responseType: 'text',
transformResponse: [(data) => data],
});
rawResponseProbe = {
status: probeResponse.status,
contentType: probeResponse.headers['content-type'] ?? null,
bodyLength: typeof probeResponse.data === 'string' ? probeResponse.data.length : null,
bodyPreview: getResponseDataPreview(probeResponse.data),
};
} catch (probeError: unknown) {
rawResponseProbe = {
probeError: toErrorLogMeta(probeError),
};
}
logger.error('获取所有仓库列表失败:', {
...requestContext,
error: toErrorLogMeta(error),
axiosError: getAxiosErrorMeta(error),
rawResponseProbe,
});
throw new Error(`获取所有仓库列表失败: ${getErrorMessage(error)}`);
}
},

View File

@@ -0,0 +1,14 @@
import { getNotificationConfigs } from '../config/index.js';
import {
type NotificationManager,
createNotificationManager,
} from './notification/notification-manager.js';
export function getNotificationManager(): NotificationManager {
const configs = getNotificationConfigs();
return createNotificationManager(configs);
}
export function resetNotificationManager(): void {
return;
}

View File

@@ -0,0 +1,55 @@
import type {
INotificationService,
NotificationContext,
NotificationMessage,
NotificationServiceConfig,
} from './types.js';
export abstract class BaseNotificationService implements INotificationService {
abstract readonly provider: import('./types.js').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);
}
async sendIssueClosedNotification(context: NotificationContext): Promise<void> {
const message = this.buildIssueClosedMessage(context);
await this.sendMessage(message);
}
async sendIssueAssignedNotification(context: NotificationContext): Promise<void> {
const message = this.buildIssueAssignedMessage(context);
await this.sendMessage(message);
}
async sendPrCreatedNotification(context: NotificationContext): Promise<void> {
const message = this.buildPrCreatedMessage(context);
await this.sendMessage(message);
}
async sendPrReviewerAssignedNotification(context: NotificationContext): Promise<void> {
const message = this.buildPrReviewerAssignedMessage(context);
await this.sendMessage(message);
}
protected abstract buildIssueCreatedMessage(context: NotificationContext): NotificationMessage;
protected abstract buildIssueClosedMessage(context: NotificationContext): NotificationMessage;
protected abstract buildIssueAssignedMessage(context: NotificationContext): NotificationMessage;
protected abstract buildPrCreatedMessage(context: NotificationContext): NotificationMessage;
protected abstract buildPrReviewerAssignedMessage(
context: NotificationContext
): NotificationMessage;
}

View File

@@ -0,0 +1,17 @@
export type {
NotificationProvider,
NotificationMessageType,
NotificationContext,
NotificationMessage,
NotificationServiceConfig,
INotificationService,
} from './types.js';
export { BaseNotificationService } from './base-notification-service.js';
export {
createNotificationService,
createNotificationServices,
} from './notification-factory.js';
export { NotificationManager, createNotificationManager } from './notification-manager.js';
export { FeishuNotificationService } from './providers/feishu-notification-service.js';
export { WeComNotificationService } from './providers/wecom-notification-service.js';

View File

@@ -0,0 +1,20 @@
import { FeishuNotificationService } from './providers/feishu-notification-service.js';
import { WeComNotificationService } from './providers/wecom-notification-service.js';
import type { INotificationService, NotificationServiceConfig } from './types.js';
export function createNotificationService(config: NotificationServiceConfig): INotificationService {
switch (config.provider) {
case 'feishu':
return new FeishuNotificationService(config);
case 'wecom':
return new WeComNotificationService(config);
default:
throw new Error(`Unknown notification provider: ${config.provider}`);
}
}
export function createNotificationServices(
configs: NotificationServiceConfig[]
): INotificationService[] {
return configs.filter((c) => c.enabled && c.webhookUrl).map((c) => createNotificationService(c));
}

View File

@@ -0,0 +1,105 @@
import { logger } from '../../utils/logger.js';
import { createNotificationServices } from './notification-factory.js';
import type {
INotificationService,
NotificationContext,
NotificationMessage,
NotificationProvider,
} from './types.js';
export class NotificationManager {
private services: INotificationService[] = [];
constructor(services: INotificationService[] = []) {
this.services = services;
}
addService(service: INotificationService): void {
this.services.push(service);
}
removeService(provider: NotificationProvider): void {
this.services = this.services.filter((s) => s.provider !== provider);
}
hasService(provider: NotificationProvider): boolean {
return this.services.some((s) => s.provider === provider && s.isEnabled());
}
getService(provider: NotificationProvider): INotificationService | undefined {
return this.services.find((s) => s.provider === provider);
}
getEnabledServices(): INotificationService[] {
return this.services.filter((s) => s.isEnabled());
}
private async broadcast(
operation: (service: INotificationService) => Promise<void>
): Promise<void> {
const enabledServices = this.getEnabledServices();
if (enabledServices.length === 0) {
logger.debug('No notification services enabled');
return;
}
const results = await Promise.allSettled(
enabledServices.map(async (service) => {
try {
await operation(service);
logger.debug(`${service.provider} notification sent successfully`);
} catch (error) {
logger.error(`${service.provider} notification failed:`, error);
throw error;
}
})
);
const failures = results.filter((r) => r.status === 'rejected');
if (failures.length > 0) {
logger.warn(`${failures.length}/${enabledServices.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));
}
async sendTestMessage(provider: NotificationProvider): Promise<void> {
const service = this.getService(provider);
if (!service || !service.isEnabled()) {
throw new Error(`${provider} notification service is not enabled`);
}
const message: NotificationMessage = {
type: 'text',
content: `🧪 通知测试消息\n服务: ${provider}\n时间: ${new Date().toISOString()}`,
};
await service.sendMessage(message);
}
}
export function createNotificationManager(
configs: import('./types.js').NotificationServiceConfig[]
): NotificationManager {
const services = createNotificationServices(configs);
return new NotificationManager(services);
}

View File

@@ -0,0 +1,121 @@
import * as crypto from 'node:crypto';
import { BaseNotificationService } from '../base-notification-service.js';
import type { NotificationContext, NotificationMessage } from '../types.js';
type FeishuApiResponse = {
code?: number;
msg?: string;
};
function parseFeishuResponse(raw: unknown): FeishuApiResponse {
if (typeof raw === 'object' && raw !== null) {
return raw as FeishuApiResponse;
}
return {};
}
export class FeishuNotificationService extends BaseNotificationService {
readonly provider = 'feishu' as const;
async sendMessage(message: NotificationMessage): Promise<void> {
if (!this.config.webhookUrl) {
throw new Error('Feishu webhook URL is not configured');
}
const payload: Record<string, unknown> = {
msg_type: 'text',
content: {
text: message.content,
},
};
if (this.config.webhookSecret) {
const timestamp = Math.floor(Date.now() / 1000).toString();
Object.assign(payload, {
timestamp,
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(`Failed to send Feishu message: ${response.status} ${response.statusText}`);
}
const result = parseFeishuResponse(await response.json());
if (result.code !== 0) {
throw new Error(`Feishu API error: ${result.msg || 'Unknown error'}`);
}
}
private generateSign(timestamp: string, secret: string): string {
const stringToSign = `${timestamp}\n${secret}`;
const hmac = crypto.createHmac('sha256', stringToSign);
return hmac.digest('base64');
}
protected buildIssueCreatedMessage(context: NotificationContext): NotificationMessage {
const atPart = context.assignees?.length
? `\n${context.assignees.map((u) => `@${u}`).join(' ')}`
: '';
return {
type: 'text',
content: `📝 新工单已创建\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}${atPart}`,
atUsers: context.assignees,
url: context.issueUrl,
};
}
protected buildIssueClosedMessage(context: NotificationContext): NotificationMessage {
const atPart = context.creator ? `\n@${context.creator}` : '';
return {
type: 'text',
content: `✅ 工单已关闭\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}${atPart}`,
atUsers: context.creator ? [context.creator] : undefined,
url: context.issueUrl,
};
}
protected buildIssueAssignedMessage(context: NotificationContext): NotificationMessage {
const atPart = context.assignees?.length
? `\n${context.assignees.map((u) => `@${u}`).join(' ')}`
: '';
return {
type: 'text',
content: `👤 工单已指派给你\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}${atPart}`,
atUsers: context.assignees,
url: context.issueUrl,
};
}
protected buildPrCreatedMessage(context: NotificationContext): NotificationMessage {
const atPart = context.reviewers?.length
? `\n${context.reviewers.map((u) => `@${u}`).join(' ')}`
: '';
return {
type: 'text',
content: `🔄 新PR等待你审阅\n标题: ${context.prTitle}\n链接: ${context.prUrl}${atPart}`,
atUsers: context.reviewers,
url: context.prUrl,
};
}
protected buildPrReviewerAssignedMessage(context: NotificationContext): NotificationMessage {
const atPart = context.assignees?.length
? `\n${context.assignees.map((u) => `@${u}`).join(' ')}`
: '';
return {
type: 'text',
content: `👀 你被指定为PR审阅者\n标题: ${context.prTitle}\n链接: ${context.prUrl}${atPart}`,
atUsers: context.assignees,
url: context.prUrl,
};
}
}

View File

@@ -0,0 +1,93 @@
import { BaseNotificationService } from '../base-notification-service.js';
import type { NotificationContext, NotificationMessage } from '../types.js';
type WeComApiResponse = {
errcode?: number;
errmsg?: string;
};
export class WeComNotificationService extends BaseNotificationService {
readonly provider = 'wecom' as const;
async sendMessage(message: NotificationMessage): Promise<void> {
if (!this.config.webhookUrl) {
throw new Error('WeCom webhook URL is not configured');
}
const payload: Record<string, unknown> = {
msgtype: 'text',
text: {
content: message.content,
},
};
if (message.atUsers?.length) {
const mentionedList = message.atUsers.map((u) => (u.toLowerCase() === 'all' ? '@all' : u));
Object.assign(payload.text as Record<string, unknown>, {
mentioned_list: mentionedList,
});
}
const response = await fetch(this.config.webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Failed to send WeCom message: ${response.status} ${response.statusText}`);
}
const result = (await response.json()) as WeComApiResponse;
if (result.errcode !== 0) {
throw new Error(`WeCom API error: ${result.errmsg || 'Unknown error'}`);
}
}
protected buildIssueCreatedMessage(context: NotificationContext): NotificationMessage {
return {
type: 'text',
content: `📝 新工单已创建\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}`,
atUsers: context.assignees,
url: context.issueUrl,
};
}
protected buildIssueClosedMessage(context: NotificationContext): NotificationMessage {
return {
type: 'text',
content: `✅ 工单已关闭\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}`,
atUsers: context.creator ? [context.creator] : undefined,
url: context.issueUrl,
};
}
protected buildIssueAssignedMessage(context: NotificationContext): NotificationMessage {
return {
type: 'text',
content: `👤 工单已指派给你\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}`,
atUsers: context.assignees,
url: context.issueUrl,
};
}
protected buildPrCreatedMessage(context: NotificationContext): NotificationMessage {
return {
type: 'text',
content: `🔄 新PR等待你审阅\n标题: ${context.prTitle}\n链接: ${context.prUrl}`,
atUsers: context.reviewers,
url: context.prUrl,
};
}
protected buildPrReviewerAssignedMessage(context: NotificationContext): NotificationMessage {
return {
type: 'text',
content: `👀 你被指定为PR审阅者\n标题: ${context.prTitle}\n链接: ${context.prUrl}`,
atUsers: context.assignees,
url: context.prUrl,
};
}
}

View File

@@ -0,0 +1,47 @@
export type NotificationProvider = 'feishu' | 'wecom' | 'slack' | 'dingtalk';
export type NotificationMessageType = 'text' | 'markdown';
export interface NotificationContext {
prTitle?: string;
prUrl?: string;
prNumber?: number;
issueTitle?: string;
issueUrl?: string;
issueNumber?: number;
actor?: string;
assignees?: string[];
reviewers?: string[];
creator?: string;
repository?: string;
owner?: string;
timestamp?: Date;
metadata?: Record<string, unknown>;
}
export interface NotificationMessage {
type: NotificationMessageType;
title?: string;
content: string;
atUsers?: string[];
url?: string;
}
export interface NotificationServiceConfig {
provider: NotificationProvider;
enabled: boolean;
webhookUrl: string;
webhookSecret?: string;
options?: Record<string, unknown>;
}
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>;
}

30
src/utils/error-log.ts Normal file
View File

@@ -0,0 +1,30 @@
type UnknownRecord = Record<string, unknown>;
function toUnknownRecord(value: unknown): UnknownRecord {
if (typeof value === 'object' && value !== null) {
return value as UnknownRecord;
}
return { value };
}
export function toErrorLogMeta(error: unknown): UnknownRecord {
if (error instanceof Error) {
const base: UnknownRecord = {
name: error.name,
message: error.message,
stack: error.stack,
};
const ownProps = Object.getOwnPropertyNames(error);
for (const prop of ownProps) {
if (prop in base) {
continue;
}
base[prop] = (error as unknown as UnknownRecord)[prop];
}
return base;
}
return toUnknownRecord(error);
}

View File

@@ -11,14 +11,32 @@ export function withGlobalPrompt(systemContent: string, globalPrompt: string | u
return `${systemContent}\n\n${globalPrompt}`;
}
export function mergeReviewPrompts(
globalPrompt: string | undefined,
projectPrompt: string | undefined
): string | undefined {
const normalized = [globalPrompt, projectPrompt]
.map((item) => item?.trim())
.filter((item): item is string => !!item && item.length > 0);
if (normalized.length === 0) {
return undefined;
}
return normalized.join('\n\n');
}
export function withCoreGlobalPrompt(
systemContent: string,
globalPrompt: string | undefined,
maxChars = 240
maxChars?: number
): string {
if (!globalPrompt || globalPrompt.trim() === '') {
return systemContent;
}
const compact = globalPrompt.trim().slice(0, maxChars);
const normalized = globalPrompt.trim();
const compact =
typeof maxChars === 'number' && maxChars > 0 ? normalized.slice(0, maxChars) : normalized;
return `${systemContent}\n\n${compact}`;
}

View File

@@ -1,58 +1,90 @@
// 简单的日志实用工具
import pino from 'pino';
/**
* 日志级别
*/
export enum LogLevel {
DEBUG = 'DEBUG',
INFO = 'INFO',
WARN = 'WARN',
ERROR = 'ERROR',
DEBUG = 'debug',
INFO = 'info',
WARN = 'warn',
ERROR = 'error',
}
/**
* 格式化时间
*/
function formatTime(): string {
return new Date().toISOString();
}
type LogMeta = Record<string, unknown>;
type ErrorWithCode = Error & { code?: unknown };
/**
* 格式化日志消息
*/
function formatMessage(level: LogLevel, message: string, meta?: any): string {
const timestamp = formatTime();
let formattedMessage = `[${timestamp}] [${level}] ${message}`;
if (meta) {
try {
formattedMessage += ` - ${JSON.stringify(meta)}`;
} catch (_error) {
formattedMessage += ` - ${meta}`;
}
function resolveLogLevel(rawLevel: string | undefined): LogLevel {
if (!rawLevel) {
return LogLevel.INFO;
}
return formattedMessage;
const normalized = rawLevel.toLowerCase();
if (normalized === LogLevel.DEBUG) return LogLevel.DEBUG;
if (normalized === LogLevel.INFO) return LogLevel.INFO;
if (normalized === LogLevel.WARN) return LogLevel.WARN;
if (normalized === LogLevel.ERROR) return LogLevel.ERROR;
return LogLevel.INFO;
}
function toLogMeta(meta: unknown): LogMeta | undefined {
if (meta === undefined) {
return undefined;
}
if (meta instanceof Error) {
const maybeCode = (meta as ErrorWithCode).code;
const code =
typeof maybeCode === 'string' || typeof maybeCode === 'number' ? maybeCode : undefined;
return {
error: {
name: meta.name,
message: meta.message,
stack: meta.stack,
...(code !== undefined ? { code } : {}),
},
};
}
if (typeof meta === 'object' && meta !== null) {
return meta as LogMeta;
}
return { meta };
}
const baseLogger = pino({
level: resolveLogLevel(process.env.LOG_LEVEL),
base: null,
timestamp: pino.stdTimeFunctions.isoTime,
formatters: {
level(label) {
return { level: label.toUpperCase() };
},
},
});
function writeLog(level: LogLevel, message: string, meta?: unknown): void {
const logMeta = toLogMeta(meta);
if (logMeta) {
baseLogger[level](logMeta, message);
return;
}
baseLogger[level](message);
}
/**
* 日志实用工具
*/
export const logger = {
debug(message: string, meta?: any) {
console.debug(formatMessage(LogLevel.DEBUG, message, meta));
debug(message: string, meta?: unknown): void {
writeLog(LogLevel.DEBUG, message, meta);
},
info(message: string, meta?: any) {
console.info(formatMessage(LogLevel.INFO, message, meta));
info(message: string, meta?: unknown): void {
writeLog(LogLevel.INFO, message, meta);
},
warn(message: string, meta?: any) {
console.warn(formatMessage(LogLevel.WARN, message, meta));
warn(message: string, meta?: unknown): void {
writeLog(LogLevel.WARN, message, meta);
},
error(message: string, meta?: any) {
console.error(formatMessage(LogLevel.ERROR, message, meta));
error(message: string, meta?: unknown): void {
writeLog(LogLevel.ERROR, message, meta);
},
};