mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-03-27 10:05:50 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63f419228e | ||
|
|
f84c0ab777 | ||
|
|
7792a78c00 | ||
|
|
7aec1e452a | ||
|
|
8f9910a3fd | ||
|
|
2392808b82 | ||
|
|
9567501369 | ||
|
|
9964614b5e | ||
|
|
e40daddf0d | ||
|
|
b10b8dd7d5 | ||
|
|
5aeff7585b |
10
.env.example
10
.env.example
@@ -1,8 +1,10 @@
|
||||
# 应用配置
|
||||
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
|
||||
# 必填,运行 openssl rand -hex 32 生成
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# 所有其他配置(Gitea连接、飞书通知、Webhook密钥、管理员密码、审查引擎、记忆系统等)
|
||||
# 均通过 Web 管理后台进行配置。
|
||||
# 启动服务后访问 http://localhost:3000 进行配置。
|
||||
# 启动服务后访问 http://localhost:5174 进行配置。
|
||||
|
||||
49
.github/workflows/release.yml
vendored
49
.github/workflows/release.yml
vendored
@@ -40,35 +40,66 @@ jobs:
|
||||
run: bun test
|
||||
|
||||
- name: Run semantic-release
|
||||
run: bunx semantic-release
|
||||
id: semantic
|
||||
uses: codfish/semantic-release-action@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
HUSKY: 0
|
||||
HUSKY_SKIP_HOOKS: 1
|
||||
|
||||
# Docker build and push
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: steps.semantic.outputs.new-release-published == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from package.json
|
||||
id: package-version
|
||||
- name: Derive Docker tags from semantic-release
|
||||
if: steps.semantic.outputs.new-release-published == 'true'
|
||||
id: docker-tags
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Detected version: $VERSION"
|
||||
VERSION="${{ steps.semantic.outputs.release-version }}"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "semantic-release did not provide release-version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$VERSION" == *"-"* ]]; then
|
||||
TAGS="ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${VERSION}"
|
||||
else
|
||||
MAJOR="${VERSION%%.*}"
|
||||
REST="${VERSION#*.}"
|
||||
MINOR="${REST%%.*}"
|
||||
|
||||
TAGS=$(printf '%s\n' \
|
||||
"ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:latest" \
|
||||
"ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${MAJOR}" \
|
||||
"ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${MAJOR}.${MINOR}" \
|
||||
"ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${VERSION}")
|
||||
fi
|
||||
|
||||
{
|
||||
echo "tags<<EOF"
|
||||
echo "$TAGS"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Release version: ${VERSION}"
|
||||
echo "Docker tags to push:"
|
||||
echo "$TAGS"
|
||||
|
||||
- name: Build and push Docker image
|
||||
if: steps.semantic.outputs.new-release-published == 'true'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:latest
|
||||
ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${{ steps.package-version.outputs.version }}
|
||||
tags: ${{ steps.docker-tags.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
27
CHANGELOG.md
27
CHANGELOG.md
@@ -1,3 +1,30 @@
|
||||
## [1.2.1](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.2.0...v1.2.1) (2026-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** source Docker tags from semantic-release version ([f84c0ab](https://github.com/jeffusion/gitea-ai-assistant/commit/f84c0ab7770b73032b331c82cf8f87f1e8b281ff))
|
||||
|
||||
# [1.2.0](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.1.1...v1.2.0) (2026-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **lint:** apply biome cleanup for notification modules ([7aec1e4](https://github.com/jeffusion/gitea-ai-assistant/commit/7aec1e452a04d3dbf935837e9b8e96107466c487))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** add dedicated notification management menu and test panel ([9964614](https://github.com/jeffusion/gitea-ai-assistant/commit/9964614b5ebb7972e2b35f3fc673f626372f6552))
|
||||
* **notification:** replace feishu-only flow with pluggable providers ([e40dadd](https://github.com/jeffusion/gitea-ai-assistant/commit/e40daddf0dd168c19251cdb84a3b6b136814f553))
|
||||
|
||||
## [1.1.1](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.1.0...v1.1.1) (2026-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **build:** guard husky prepare for production installs ([5aeff75](https://github.com/jeffusion/gitea-ai-assistant/commit/5aeff7585b465fa9479c538b67b99978d12455b1))
|
||||
|
||||
# [1.1.0](https://github.com/Jeffusion/gitea-ai-assistant/compare/v1.0.0...v1.1.0) (2026-03-24)
|
||||
|
||||
|
||||
|
||||
@@ -57,6 +57,6 @@ COPY --from=frontend-builder /app/frontend/dist ./public
|
||||
# Codex CLI binary (statically linked musl build)
|
||||
COPY --from=codex-downloader /usr/local/bin/codex /usr/local/bin/codex
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 5174
|
||||
|
||||
CMD ["bun", "run", "start"]
|
||||
|
||||
13
README.md
13
README.md
@@ -53,13 +53,16 @@ cd gitea-ai-assistant
|
||||
bun install
|
||||
```
|
||||
|
||||
> `bun install` at repository root now triggers frontend dependency installation automatically via `postinstall`.
|
||||
> If your environment skips lifecycle scripts, run `bun run bootstrap` once.
|
||||
|
||||
### Configuration
|
||||
|
||||
Create a `.env` file with only infrastructure-level settings:
|
||||
|
||||
```bash
|
||||
# Server port
|
||||
PORT=3000
|
||||
PORT=5174
|
||||
|
||||
# REQUIRED: encryption key for API key storage (generate with: openssl rand -hex 32)
|
||||
ENCRYPTION_KEY=
|
||||
@@ -68,7 +71,7 @@ ENCRYPTION_KEY=
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
```
|
||||
|
||||
> **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.
|
||||
> **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:5174`. On first boot, all settings are seeded with secure defaults automatically.
|
||||
|
||||
See [Configuration Reference](#configuration-reference) for all options.
|
||||
|
||||
@@ -83,14 +86,14 @@ bun run start # Production mode
|
||||
|
||||
**Option 1: Admin Dashboard (Recommended)**
|
||||
|
||||
1. Access `http://your-server:3000`
|
||||
1. Access `http://your-server:5174`
|
||||
2. Log in with the admin password (default: `password` — change it in the dashboard)
|
||||
3. Click "Enable" on repositories to auto-configure webhooks
|
||||
|
||||
**Option 2: Manual Configuration**
|
||||
|
||||
In Gitea repository settings, add a webhook:
|
||||
- **URL**: `http://your-server:3000/webhook/gitea`
|
||||
- **URL**: `http://your-server:5174/webhook/gitea`
|
||||
- **Content Type**: `application/json`
|
||||
- **Secret**: Same as the Webhook Secret configured in the dashboard
|
||||
- **Events**: "Pull Request" and "Status"
|
||||
@@ -187,7 +190,7 @@ Agent review execution model (current):
|
||||
|
||||
```bash
|
||||
docker build -t gitea-assistant .
|
||||
docker run -d -p 3000:3000 -v ./data:/app/data -e PORT=3000 gitea-assistant
|
||||
docker run -d -p 5174:5174 -v ./data:/app/data -e PORT=5174 gitea-assistant
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
|
||||
container_name: gitea-assistant
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "5174:5174"
|
||||
volumes:
|
||||
- assistant_data:/app/data
|
||||
env_file:
|
||||
@@ -19,7 +19,7 @@ services:
|
||||
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
|
||||
|
||||
@@ -53,13 +53,16 @@ cd gitea-ai-assistant
|
||||
bun install
|
||||
```
|
||||
|
||||
> 在仓库根目录执行 `bun install` 会通过 `postinstall` 自动安装 `frontend` 依赖。
|
||||
> 若你的环境跳过生命周期脚本,请额外执行一次 `bun run bootstrap`。
|
||||
|
||||
### 配置说明
|
||||
|
||||
创建 `.env` 文件,仅填写基础设施级别的配置:
|
||||
|
||||
```bash
|
||||
# 服务端口
|
||||
PORT=3000
|
||||
PORT=5174
|
||||
|
||||
# 必填:API Key 加密存储密钥(运行 openssl rand -hex 32 生成)
|
||||
ENCRYPTION_KEY=
|
||||
@@ -68,7 +71,7 @@ ENCRYPTION_KEY=
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
```
|
||||
|
||||
> **所有其他配置**(Gitea 连接、Webhook 密钥、管理员密码、审查引擎、飞书、记忆系统等)均通过 **Web 管理后台** 在 `http://your-server:3000` 进行配置。首次启动时,所有设置会自动以安全的默认值进行初始化。
|
||||
> **所有其他配置**(Gitea 连接、Webhook 密钥、管理员密码、审查引擎、飞书、记忆系统等)均通过 **Web 管理后台** 在 `http://your-server:5174` 进行配置。首次启动时,所有设置会自动以安全的默认值进行初始化。
|
||||
|
||||
完整配置项请参阅 [配置参考](#配置参考)。
|
||||
|
||||
@@ -83,14 +86,14 @@ bun run start # 生产模式
|
||||
|
||||
**方式一:管理后台(推荐)**
|
||||
|
||||
1. 在浏览器中访问 `http://your-server:3000`
|
||||
1. 在浏览器中访问 `http://your-server:5174`
|
||||
2. 使用管理员密码登录(默认:`password`,请在后台及时修改)
|
||||
3. 点击仓库对应的「启用」按钮自动配置 Webhook
|
||||
|
||||
**方式二:手动配置**
|
||||
|
||||
在 Gitea 仓库设置中添加 Webhook:
|
||||
- **URL**: `http://your-server:3000/webhook/gitea`
|
||||
- **URL**: `http://your-server:5174/webhook/gitea`
|
||||
- **内容类型**: `application/json`
|
||||
- **密钥**: 与管理后台中配置的 Webhook 密钥相同
|
||||
- **触发事件**: 「Pull Request」和「Status」
|
||||
@@ -187,7 +190,7 @@ LLM 提供商和模型通过**管理后台** Web 界面进行配置:
|
||||
|
||||
```bash
|
||||
docker build -t gitea-assistant .
|
||||
docker run -d -p 3000:3000 -v ./data:/app/data -e PORT=3000 gitea-assistant
|
||||
docker run -d -p 5174:5174 -v ./data:/app/data -e PORT=5174 gitea-assistant
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
617
docs/design/notification-service-refactoring.md
Normal file
617
docs/design/notification-service-refactoring.md
Normal file
@@ -0,0 +1,617 @@
|
||||
# 通知服务抽象化重构方案
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 背景
|
||||
当前项目中的通知功能仅支持飞书(Feishu/Lark)平台,代码高度耦合飞书特定的API实现。随着业务需求扩展,需要支持企业微信(WeCom)等其他通知渠道。
|
||||
|
||||
### 1.2 目标
|
||||
- 抽象通用通知服务接口,支持多平台扩展
|
||||
- 支持同时配置多个通知服务(如飞书+企业微信同时推送)
|
||||
- 统一通知调用入口,避免平台耦合与重复发送
|
||||
- 清晰的代码结构,便于后续添加新平台(如Slack、钉钉等)
|
||||
|
||||
### 1.3 非目标
|
||||
- 不修改通知的业务触发逻辑
|
||||
- 不改变现有的Gitea Webhook处理流程
|
||||
- 不引入外部通知服务SDK依赖(保持轻量)
|
||||
|
||||
---
|
||||
|
||||
## 2. 现有架构分析
|
||||
|
||||
### 2.1 重构前实现(已下线)
|
||||
```
|
||||
src/
|
||||
├── services/feishu.ts # 飞书服务实现(156行)
|
||||
├── controllers/review.ts # 通知调用点
|
||||
├── config/config-schema.ts # 配置定义
|
||||
└── config/config-manager.ts # 配置管理
|
||||
```
|
||||
|
||||
### 2.2 关键代码特征
|
||||
- **强耦合**:`review.ts` 直接调用 `feishuService.sendXXXNotification()`
|
||||
- **硬编码消息格式**:飞书特定的 `msg_type: 'text'` 结构
|
||||
- **签名逻辑**:HMAC-SHA256(timestamp+"\n"+secret)
|
||||
- **配置单一**:仅支持一组飞书配置
|
||||
|
||||
### 2.3 通知场景
|
||||
| 场景 | 方法名 | 触发条件 |
|
||||
|------|--------|----------|
|
||||
| 工单创建 | `sendIssueCreatedNotification` | Issue opened + 有指派人 |
|
||||
| 工单关闭 | `sendIssueClosedNotification` | Issue closed |
|
||||
| 工单指派 | `sendIssueAssignedNotification` | Issue assigned |
|
||||
| PR创建 | `sendPrCreatedNotification` | PR opened + 有审阅者 |
|
||||
| PR指派 | `sendPrReviewerAssignedNotification` | PR review_requested |
|
||||
|
||||
---
|
||||
|
||||
## 3. 目标架构设计
|
||||
|
||||
### 3.1 架构模式
|
||||
采用**策略模式(Strategy)** + **工厂模式(Factory)**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Notification Service Layer │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │
|
||||
│ │ INotification │ │ INotification │ │ INotification│ │
|
||||
│ │ Service │ │ Service │ │ Service │ │
|
||||
│ │ (Interface) │ │ (Interface) │ │ (Interface) │ │
|
||||
│ └────────┬────────┘ └────────┬────────┘ └──────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌─────┴─────┐ ┌────┴────┐ ┌────┴────┐ │
|
||||
│ │ Feishu │ │ WeCom │ │ Slack │ │
|
||||
│ │Service │ │Service │ │ Service │ │
|
||||
│ └───────────┘ └─────────┘ └─────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────┴─────────┐
|
||||
│ NotificationFactory│
|
||||
└─────────┬─────────┘
|
||||
│
|
||||
┌─────────┴─────────┐
|
||||
│ NotificationManager│ ← 统一入口,支持多服务
|
||||
└───────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 核心接口设计
|
||||
|
||||
#### 3.2.1 类型定义
|
||||
```typescript
|
||||
// types.ts
|
||||
export type NotificationProvider = 'feishu' | 'wecom' | 'slack' | 'dingtalk';
|
||||
|
||||
export interface NotificationContext {
|
||||
// PR相关
|
||||
prTitle?: string;
|
||||
prUrl?: string;
|
||||
prNumber?: number;
|
||||
|
||||
// Issue相关
|
||||
issueTitle?: string;
|
||||
issueUrl?: string;
|
||||
issueNumber?: number;
|
||||
|
||||
// 用户相关
|
||||
actor?: string;
|
||||
assignees?: string[];
|
||||
reviewers?: string[];
|
||||
creator?: string;
|
||||
|
||||
// 仓库相关
|
||||
repository?: string;
|
||||
owner?: string;
|
||||
|
||||
// 时间戳
|
||||
timestamp?: Date;
|
||||
}
|
||||
|
||||
export interface NotificationMessage {
|
||||
type: 'text' | 'markdown';
|
||||
title?: string;
|
||||
content: string;
|
||||
atUsers?: string[];
|
||||
url?: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2.2 服务接口
|
||||
```typescript
|
||||
// INotificationService
|
||||
export interface INotificationService {
|
||||
readonly provider: NotificationProvider;
|
||||
|
||||
isEnabled(): boolean;
|
||||
sendMessage(message: NotificationMessage): Promise<void>;
|
||||
|
||||
// 场景特定方法
|
||||
sendIssueCreatedNotification(context: NotificationContext): Promise<void>;
|
||||
sendIssueClosedNotification(context: NotificationContext): Promise<void>;
|
||||
sendIssueAssignedNotification(context: NotificationContext): Promise<void>;
|
||||
sendPrCreatedNotification(context: NotificationContext): Promise<void>;
|
||||
sendPrReviewerAssignedNotification(context: NotificationContext): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 平台差异对照
|
||||
|
||||
| 特性 | 飞书(Feishu) | 企业微信(WeCom) | Slack |
|
||||
|------|--------------|-----------------|-------|
|
||||
| **Webhook格式** | `open.feishu.cn/open-apis/bot/v2/hook/{key}` | `qyapi.weixin.qq.com/cgi-bin/webhook/send?key={key}` | `hooks.slack.com/services/...` |
|
||||
| **签名机制** | HMAC-SHA256(timestamp+"\n"+secret) | **无** | HMAC-SHA256(timestamp+":"+secret) |
|
||||
| **@用户方式** | `<at user_id="xxx">` 或文本追加 | `mentioned_list: ["userid"]` 或手机号 | `<@user_id>` |
|
||||
| **消息类型字段** | `msg_type` | `msgtype` | `type` |
|
||||
| **内容字段** | `content.text` | `text.content` | `text` |
|
||||
| **频率限制** | 100次/分钟 | 20条/分钟 | 1次/秒 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 详细实现方案
|
||||
|
||||
### 4.1 目录结构
|
||||
```
|
||||
src/
|
||||
├── services/
|
||||
│ ├── notification/
|
||||
│ │ ├── index.ts # 导出入口
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ ├── base-notification-service.ts # 抽象基类
|
||||
│ │ ├── notification-factory.ts # 工厂
|
||||
│ │ ├── notification-manager.ts # 管理器
|
||||
│ │ └── providers/
|
||||
│ │ ├── feishu-notification-service.ts
|
||||
│ │ └── wecom-notification-service.ts
|
||||
│ └── notification-manager.ts # 运行时通知管理器入口
|
||||
```
|
||||
|
||||
### 4.2 基类实现
|
||||
|
||||
```typescript
|
||||
// base-notification-service.ts
|
||||
export abstract class BaseNotificationService implements INotificationService {
|
||||
abstract readonly provider: NotificationProvider;
|
||||
|
||||
constructor(protected config: NotificationServiceConfig) {}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.config.enabled && !!this.config.webhookUrl;
|
||||
}
|
||||
|
||||
abstract sendMessage(message: NotificationMessage): Promise<void>;
|
||||
|
||||
// 通用模板方法
|
||||
async sendIssueCreatedNotification(context: NotificationContext): Promise<void> {
|
||||
const message = this.buildIssueCreatedMessage(context);
|
||||
await this.sendMessage(message);
|
||||
}
|
||||
|
||||
// 子类实现消息构建
|
||||
protected abstract buildIssueCreatedMessage(context: NotificationContext): NotificationMessage;
|
||||
// ... 其他方法类似
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 飞书实现要点
|
||||
|
||||
```typescript
|
||||
// feishu-notification-service.ts
|
||||
export class FeishuNotificationService extends BaseNotificationService {
|
||||
readonly provider = 'feishu' as const;
|
||||
|
||||
async sendMessage(message: NotificationMessage): Promise<void> {
|
||||
const payload: any = {
|
||||
msg_type: 'text',
|
||||
content: {
|
||||
text: message.content,
|
||||
},
|
||||
};
|
||||
|
||||
// 添加签名
|
||||
if (this.config.webhookSecret) {
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||
payload.timestamp = timestamp;
|
||||
payload.sign = this.generateSign(timestamp, this.config.webhookSecret);
|
||||
}
|
||||
|
||||
const response = await fetch(this.config.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Feishu notification failed: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
protected buildIssueCreatedMessage(context: NotificationContext): NotificationMessage {
|
||||
return {
|
||||
type: 'text',
|
||||
content: `📝 新工单已创建\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}`,
|
||||
atUsers: context.assignees,
|
||||
};
|
||||
}
|
||||
|
||||
private generateSign(timestamp: string, secret: string): string {
|
||||
const stringToSign = `${timestamp}\n${secret}`;
|
||||
const hmac = crypto.createHmac('sha256', stringToSign);
|
||||
return hmac.digest('base64');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 企业微信实现要点
|
||||
|
||||
```typescript
|
||||
// wecom-notification-service.ts
|
||||
export class WeComNotificationService extends BaseNotificationService {
|
||||
readonly provider = 'wecom' as const;
|
||||
|
||||
async sendMessage(message: NotificationMessage): Promise<void> {
|
||||
const payload: any = {
|
||||
msgtype: 'text',
|
||||
text: {
|
||||
content: message.content,
|
||||
},
|
||||
};
|
||||
|
||||
// 企业微信使用 mentioned_list
|
||||
if (message.atUsers?.length) {
|
||||
payload.text.mentioned_list = message.atUsers.map(u =>
|
||||
u.toLowerCase() === 'all' ? '@all' : u
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(this.config.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`WeCom notification failed: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
protected buildIssueCreatedMessage(context: NotificationContext): NotificationMessage {
|
||||
return {
|
||||
type: 'text',
|
||||
content: `📝 新工单已创建\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}`,
|
||||
atUsers: context.assignees,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 管理器实现
|
||||
|
||||
```typescript
|
||||
// notification-manager.ts
|
||||
export class NotificationManager {
|
||||
private services: INotificationService[] = [];
|
||||
|
||||
constructor(configs: NotificationServiceConfig[]) {
|
||||
this.services = configs
|
||||
.filter(c => c.enabled && c.webhookUrl)
|
||||
.map(c => NotificationFactory.createService(c));
|
||||
}
|
||||
|
||||
// 广播到所有服务
|
||||
async broadcast(
|
||||
operation: (service: INotificationService) => Promise<void>
|
||||
): Promise<void> {
|
||||
const results = await Promise.allSettled(
|
||||
this.services.map(async service => {
|
||||
try {
|
||||
await operation(service);
|
||||
} catch (error) {
|
||||
logger.error(`${service.provider} notification failed:`, error);
|
||||
throw error; // 重新抛出以便Promise.allSettled捕获
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 记录失败统计
|
||||
const failures = results.filter(r => r.status === 'rejected');
|
||||
if (failures.length > 0) {
|
||||
logger.warn(`${failures.length}/${this.services.length} notification services failed`);
|
||||
}
|
||||
}
|
||||
|
||||
// 便捷方法
|
||||
async notifyIssueCreated(context: NotificationContext): Promise<void> {
|
||||
await this.broadcast(s => s.sendIssueCreatedNotification(context));
|
||||
}
|
||||
|
||||
async notifyIssueClosed(context: NotificationContext): Promise<void> {
|
||||
await this.broadcast(s => s.sendIssueClosedNotification(context));
|
||||
}
|
||||
|
||||
async notifyIssueAssigned(context: NotificationContext): Promise<void> {
|
||||
await this.broadcast(s => s.sendIssueAssignedNotification(context));
|
||||
}
|
||||
|
||||
async notifyPrCreated(context: NotificationContext): Promise<void> {
|
||||
await this.broadcast(s => s.sendPrCreatedNotification(context));
|
||||
}
|
||||
|
||||
async notifyPrReviewerAssigned(context: NotificationContext): Promise<void> {
|
||||
await this.broadcast(s => s.sendPrReviewerAssignedNotification(context));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 配置改造
|
||||
|
||||
### 5.1 新增配置字段
|
||||
|
||||
```typescript
|
||||
// config-schema.ts
|
||||
export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
// ... 保留原有 ...
|
||||
|
||||
// 飞书配置(改造为可独立启用)
|
||||
{
|
||||
envKey: 'FEISHU_ENABLED',
|
||||
group: 'notification',
|
||||
label: '启用飞书通知',
|
||||
description: '是否启用飞书通知',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
envKey: 'FEISHU_WEBHOOK_URL',
|
||||
group: 'notification',
|
||||
label: '飞书 Webhook 地址',
|
||||
description: '飞书机器人 Webhook URL',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
},
|
||||
{
|
||||
envKey: 'FEISHU_WEBHOOK_SECRET',
|
||||
group: 'notification',
|
||||
label: '飞书 Webhook 密钥',
|
||||
description: '飞书 Webhook 签名密钥(可选)',
|
||||
type: 'string',
|
||||
sensitive: true,
|
||||
},
|
||||
|
||||
// 企业微信配置(新增)
|
||||
{
|
||||
envKey: 'WECOM_ENABLED',
|
||||
group: 'notification',
|
||||
label: '启用企业微信通知',
|
||||
description: '是否启用企业微信通知',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
envKey: 'WECOM_WEBHOOK_URL',
|
||||
group: 'notification',
|
||||
label: '企业微信 Webhook 地址',
|
||||
description: '企业微信机器人 Webhook URL',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### 5.2 配置组调整
|
||||
|
||||
```typescript
|
||||
// 将 'feishu' 组改为 'notification' 组
|
||||
export type ConfigGroup = 'gitea' | 'notification' | 'security' | 'review' | 'memory';
|
||||
|
||||
export const CONFIG_GROUPS: ConfigGroupMeta[] = [
|
||||
// ...
|
||||
{
|
||||
key: 'notification',
|
||||
label: '通知服务',
|
||||
description: '飞书、企业微信等通知渠道配置',
|
||||
icon: 'bell',
|
||||
},
|
||||
// ...
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 调用层迁移
|
||||
|
||||
### 6.1 review.ts 改造
|
||||
|
||||
```typescript
|
||||
import { getNotificationManager } from '../services/notification-manager';
|
||||
|
||||
// PR事件处理
|
||||
async function handlePullRequestEvent(c: Context, body: any): Promise<Response> {
|
||||
// ... 原有逻辑 ...
|
||||
|
||||
const context: NotificationContext = {
|
||||
prTitle: pullRequest.title,
|
||||
prUrl: pullRequest.html_url,
|
||||
prNumber: pullRequest.number,
|
||||
reviewers: reviewerUsernames,
|
||||
repository: repo.name,
|
||||
owner: repo.owner.login,
|
||||
actor: body.sender?.login,
|
||||
};
|
||||
|
||||
const notificationManager = getNotificationManager();
|
||||
|
||||
if (body.action === 'opened' && reviewerUsernames.length > 0) {
|
||||
await notificationManager.notifyPrCreated(context);
|
||||
}
|
||||
|
||||
if (body.action === 'review_requested' && body.requested_reviewer) {
|
||||
context.assignees = [body.requested_reviewer.full_name || body.requested_reviewer.login];
|
||||
await notificationManager.notifyPrReviewerAssigned(context);
|
||||
}
|
||||
|
||||
// ... 继续原有逻辑 ...
|
||||
}
|
||||
|
||||
// Issue事件处理
|
||||
async function handleIssueEvent(c: Context, body: any): Promise<Response> {
|
||||
// ...
|
||||
|
||||
const context: NotificationContext = {
|
||||
issueTitle: issue.title,
|
||||
issueUrl: issue.html_url,
|
||||
issueNumber: issue.number,
|
||||
creator: creatorUsername,
|
||||
assignees: assigneeUsernames,
|
||||
repository: repository.name,
|
||||
actor: body.sender?.login,
|
||||
};
|
||||
|
||||
if (action === 'opened' && assigneeUsernames.length > 0) {
|
||||
await notificationManager.notifyIssueCreated(context);
|
||||
} else if (action === 'closed') {
|
||||
await notificationManager.notifyIssueClosed(context);
|
||||
} else if (action === 'assigned') {
|
||||
await notificationManager.notifyIssueAssigned(context);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 落地决策(已执行)
|
||||
|
||||
### 7.1 旧飞书服务下线
|
||||
|
||||
- 已删除 `src/services/feishu.ts`,不再保留兼容层。
|
||||
- `src/controllers/review.ts` 中所有通知发送路径已统一到 `NotificationManager`。
|
||||
- 通过单一通知入口避免重复发送与配置路径分裂问题。
|
||||
|
||||
### 7.2 运行时配置生效策略
|
||||
|
||||
- 通知管理器按当前配置即时创建,不再使用长生命周期缓存。
|
||||
- 后台保存通知配置后,可立即在后续 webhook 事件生效。
|
||||
|
||||
### 7.3 落地检查清单
|
||||
|
||||
- [x] 飞书与企业微信通过统一通知抽象发送
|
||||
- [x] 旧飞书服务文件已下线
|
||||
- [x] 控制器通知链路已去重
|
||||
- [x] 前端新增独立“通知管理”菜单与页面
|
||||
|
||||
---
|
||||
|
||||
## 8. 实施计划
|
||||
|
||||
### 8.1 阶段划分
|
||||
|
||||
| 阶段 | 任务 | 文件 | 优先级 |
|
||||
|------|------|------|--------|
|
||||
| 1 | 核心抽象层 | `types.ts`, `base-notification-service.ts` | P0 |
|
||||
| 2 | 工厂与管理器 | `notification-factory.ts`, `notification-manager.ts` | P0 |
|
||||
| 3 | 飞书实现 | `providers/feishu-notification-service.ts` | P1 |
|
||||
| 4 | 企业微信实现 | `providers/wecom-notification-service.ts` | P1 |
|
||||
| 5 | 配置改造 | `config-schema.ts`, `config-manager.ts` | P1 |
|
||||
| 6 | 调用层迁移 | `review.ts` | P1 |
|
||||
| 7 | 前端通知管理页面 | `App.tsx`, `DashboardPage.tsx`, `NotificationConfigPage.tsx` | P1 |
|
||||
| 8 | 测试验证 | `config-manager.test.ts` 等 | P0 |
|
||||
|
||||
### 8.2 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| 签名算法变更 | 飞书通知失效 | 保持原有签名实现,单元测试覆盖 |
|
||||
| 配置迁移失败 | 服务无法启动 | 添加配置验证和默认值回退 |
|
||||
| 多服务并发失败 | 部分通知丢失 | Promise.allSettled 确保独立性 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试策略
|
||||
|
||||
### 9.1 单元测试
|
||||
|
||||
```typescript
|
||||
// __tests__/notification.test.ts
|
||||
describe('NotificationService', () => {
|
||||
describe('FeishuNotificationService', () => {
|
||||
it('should generate correct signature', () => {
|
||||
// 测试签名算法
|
||||
});
|
||||
|
||||
it('should format message correctly', () => {
|
||||
// 测试消息格式转换
|
||||
});
|
||||
});
|
||||
|
||||
describe('WeComNotificationService', () => {
|
||||
it('should use mentioned_list for @users', () => {
|
||||
// 测试@用户格式
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationManager', () => {
|
||||
it('should broadcast to all enabled services', async () => {
|
||||
// 测试广播逻辑
|
||||
});
|
||||
|
||||
it('should not fail if one service fails', async () => {
|
||||
// 测试容错
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 9.2 集成测试
|
||||
|
||||
- 配置真实飞书机器人测试消息发送
|
||||
- 配置企业微信机器人测试消息发送
|
||||
- 验证同时配置多个服务时的行为
|
||||
|
||||
---
|
||||
|
||||
## 10. 附录
|
||||
|
||||
### 10.1 飞书与企业微信API对比详情
|
||||
|
||||
#### 飞书消息格式
|
||||
```json
|
||||
{
|
||||
"msg_type": "text",
|
||||
"content": {
|
||||
"text": "Hello <at user_id=\"ou_xxx\">Tom</at>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 企业微信消息格式
|
||||
```json
|
||||
{
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": "Hello World",
|
||||
"mentioned_list": ["wangqing", "@all"],
|
||||
"mentioned_mobile_list": ["13800001111"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 扩展指南
|
||||
|
||||
添加新通知平台步骤:
|
||||
|
||||
1. 在 `types.ts` 添加新的 `NotificationProvider` 类型
|
||||
2. 在 `providers/` 创建新的服务类,继承 `BaseNotificationService`
|
||||
3. 在 `notification-factory.ts` 添加创建逻辑
|
||||
4. 在 `config-schema.ts` 添加配置字段
|
||||
5. 在 Admin Dashboard 添加UI配置项
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: 1.0
|
||||
**创建日期**: 2026-03-24
|
||||
**作者**: Sisyphus
|
||||
**状态**: 已实施(持续验证中)
|
||||
@@ -823,7 +823,7 @@ Day 6.5: 旧代码清理完毕,文档更新,Ready for review
|
||||
# .env.example(仅保留启动参数)
|
||||
|
||||
# 应用启动参数(不可通过 UI 设置)
|
||||
PORT=3000
|
||||
PORT=5174
|
||||
WEBHOOK_SECRET=your_webhook_secret
|
||||
DATABASE_PATH=./data/assistant.db # SQLite 文件路径
|
||||
|
||||
|
||||
@@ -11,6 +11,6 @@ RUN bun install --no-frozen-lockfile
|
||||
COPY src ./src
|
||||
COPY tsconfig.json .
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 5174
|
||||
|
||||
CMD ["bun", "run", "start"]
|
||||
|
||||
@@ -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}\"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -25,6 +25,7 @@ interface ConfigGroupCardProps {
|
||||
onFieldChange: (envKey: string, value: any) => void;
|
||||
onReset: (keys: string[]) => void;
|
||||
isResetting: boolean;
|
||||
headerActions?: React.ReactNode;
|
||||
/** Optional custom renderer for individual fields. Return `undefined` to use default ConfigFieldInput. */
|
||||
renderField?: (field: ConfigFieldDto, value: any, onChange: (val: any) => void) => React.ReactNode | undefined;
|
||||
}
|
||||
@@ -35,6 +36,7 @@ export function ConfigGroupCard({
|
||||
onFieldChange,
|
||||
onReset,
|
||||
isResetting,
|
||||
headerActions,
|
||||
renderField,
|
||||
}: ConfigGroupCardProps) {
|
||||
const hasOverride = group.fields.some((f) => f.source === 'db');
|
||||
@@ -69,17 +71,22 @@ export function ConfigGroupCard({
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{hasOverride && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={isResetting}
|
||||
className="theme-interactive-elevate border-danger/30 text-danger hover:bg-danger/10 hover:text-danger hover:border-danger/50 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
重置组配置
|
||||
</Button>
|
||||
{(headerActions || hasOverride) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{headerActions}
|
||||
{hasOverride && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={isResetting}
|
||||
className="theme-interactive-elevate border-danger/30 text-danger hover:bg-danger/10 hover:text-danger hover:border-danger/50 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
重置组配置
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="theme-card-content divide-y divide-border/50">
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Save, AlertCircle, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/** Groups shown on the system config page (excludes review & memory — moved to ReviewConfigPage). */
|
||||
const SYSTEM_GROUPS = new Set(['gitea', 'feishu', 'security']);
|
||||
const SYSTEM_GROUPS = new Set(['gitea', 'security']);
|
||||
|
||||
export function ConfigManager() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
379
frontend/src/components/NotificationConfigPage.tsx
Normal file
379
frontend/src/components/NotificationConfigPage.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchConfig,
|
||||
fetchNotificationTestHistory,
|
||||
updateConfig,
|
||||
resetConfig,
|
||||
testNotification,
|
||||
type NotificationTestProvider,
|
||||
} from '@/services/configService';
|
||||
import type {
|
||||
ConfigResponse,
|
||||
ConfigGroupDto,
|
||||
ConfigFieldDto,
|
||||
NotificationTestRecordDto,
|
||||
} from '@/services/configService';
|
||||
import { ConfigGroupCard } from './ConfigGroupCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Save, AlertCircle, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const NOTIFICATION_GROUPS = new Set(['notification']);
|
||||
|
||||
type ProviderCardMeta = {
|
||||
key: NotificationTestProvider;
|
||||
fieldPrefix: 'FEISHU_' | 'WECOM_';
|
||||
label: string;
|
||||
description: string;
|
||||
enableKey: 'FEISHU_ENABLED' | 'WECOM_ENABLED';
|
||||
webhookKey: 'FEISHU_WEBHOOK_URL' | 'WECOM_WEBHOOK_URL';
|
||||
};
|
||||
|
||||
const PROVIDER_CARDS: ProviderCardMeta[] = [
|
||||
{
|
||||
key: 'feishu',
|
||||
fieldPrefix: 'FEISHU_',
|
||||
label: '飞书通知',
|
||||
description: '配置飞书机器人 Webhook 与签名密钥。',
|
||||
enableKey: 'FEISHU_ENABLED',
|
||||
webhookKey: 'FEISHU_WEBHOOK_URL',
|
||||
},
|
||||
{
|
||||
key: 'wecom',
|
||||
fieldPrefix: 'WECOM_',
|
||||
label: '企业微信通知',
|
||||
description: '配置企业微信群机器人 Webhook。',
|
||||
enableKey: 'WECOM_ENABLED',
|
||||
webhookKey: 'WECOM_WEBHOOK_URL',
|
||||
},
|
||||
];
|
||||
|
||||
export function NotificationConfigPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [localConfig, setLocalConfig] = useState<Record<string, any>>({});
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery<ConfigResponse, Error>({
|
||||
queryKey: ['config'],
|
||||
queryFn: fetchConfig,
|
||||
});
|
||||
|
||||
const {
|
||||
data: testHistory,
|
||||
isLoading: isHistoryLoading,
|
||||
} = useQuery<NotificationTestRecordDto[], Error>({
|
||||
queryKey: ['notification-test-history'],
|
||||
queryFn: fetchNotificationTestHistory,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const initialState: Record<string, any> = {};
|
||||
data.groups
|
||||
.filter((g) => NOTIFICATION_GROUPS.has(g.key))
|
||||
.forEach((group) => {
|
||||
group.fields.forEach((field) => {
|
||||
if (field.sensitive && field.hasValue) {
|
||||
initialState[field.envKey] = '••••••••';
|
||||
} else if (field.type === 'boolean') {
|
||||
initialState[field.envKey] = field.value === 'true' || field.value === true;
|
||||
} else {
|
||||
initialState[field.envKey] = field.value ?? '';
|
||||
}
|
||||
});
|
||||
});
|
||||
setLocalConfig(initialState);
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (configData: Record<string, string>) => updateConfig(configData),
|
||||
onSuccess: () => {
|
||||
toast.success('通知配置已成功保存');
|
||||
queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||
setHasChanges(false);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`保存失败: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const feishuTestMutation = useMutation({
|
||||
mutationFn: () => testNotification('feishu'),
|
||||
onSuccess: () => {
|
||||
toast.success('飞书测试通知已发送');
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-test-history'] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`飞书测试发送失败: ${err.message}`);
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-test-history'] });
|
||||
},
|
||||
});
|
||||
|
||||
const wecomTestMutation = useMutation({
|
||||
mutationFn: () => testNotification('wecom'),
|
||||
onSuccess: () => {
|
||||
toast.success('企业微信测试通知已发送');
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-test-history'] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`企业微信测试发送失败: ${err.message}`);
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-test-history'] });
|
||||
},
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: (keys: string[]) => resetConfig(keys),
|
||||
onSuccess: () => {
|
||||
toast.success('通知配置已重置');
|
||||
queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`重置失败: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleFieldChange = (envKey: string, value: any) => {
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
[envKey]: value,
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const payload: Record<string, string> = {};
|
||||
|
||||
for (const [key, val] of Object.entries(localConfig)) {
|
||||
if (typeof val === 'boolean') {
|
||||
payload[key] = val ? 'true' : 'false';
|
||||
} else {
|
||||
payload[key] = val === undefined || val === null ? '' : String(val);
|
||||
}
|
||||
}
|
||||
|
||||
saveMutation.mutate(payload);
|
||||
};
|
||||
|
||||
const handleResetGroup = (keys: string[]) => {
|
||||
if (confirm('确定要重置这些通知配置到默认值吗?这将立即生效。')) {
|
||||
resetMutation.mutate(keys);
|
||||
}
|
||||
};
|
||||
|
||||
const notificationGroup = useMemo(
|
||||
() => data?.groups.find((g) => NOTIFICATION_GROUPS.has(g.key)),
|
||||
[data]
|
||||
);
|
||||
|
||||
const providerGroups = useMemo<ConfigGroupDto[]>(() => {
|
||||
if (!notificationGroup) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return PROVIDER_CARDS.map((provider) => {
|
||||
const fields = notificationGroup.fields.filter((field: ConfigFieldDto) =>
|
||||
field.envKey.startsWith(provider.fieldPrefix)
|
||||
);
|
||||
|
||||
return {
|
||||
...notificationGroup,
|
||||
key: `notification-${provider.key}`,
|
||||
label: provider.label,
|
||||
description: provider.description,
|
||||
fields,
|
||||
};
|
||||
}).filter((group) => group.fields.length > 0);
|
||||
}, [notificationGroup]);
|
||||
|
||||
const hasOverrides = useMemo(
|
||||
() =>
|
||||
providerGroups.some((g) =>
|
||||
g.fields.some((f) => f.source === 'db')
|
||||
),
|
||||
[providerGroups]
|
||||
);
|
||||
|
||||
const handleResetAll = () => {
|
||||
if (providerGroups.length === 0) return;
|
||||
const allOverrideKeys = providerGroups
|
||||
.flatMap((g) => g.fields)
|
||||
.filter((f) => f.source === 'db')
|
||||
.map((f) => f.envKey);
|
||||
|
||||
if (allOverrideKeys.length === 0) return;
|
||||
if (confirm('确定要重置所有通知配置到默认值吗?这将立即生效。')) {
|
||||
resetMutation.mutate(allOverrideKeys);
|
||||
}
|
||||
};
|
||||
|
||||
const canSendProviderTest = (provider: ProviderCardMeta): boolean => {
|
||||
const enabled = localConfig[provider.enableKey] === true;
|
||||
const webhook = localConfig[provider.webhookKey];
|
||||
return enabled && typeof webhook === 'string' && webhook.trim().length > 0;
|
||||
};
|
||||
|
||||
const getProviderMutation = (providerKey: NotificationTestProvider) => {
|
||||
return providerKey === 'feishu' ? feishuTestMutation : wecomTestMutation;
|
||||
};
|
||||
|
||||
const getProviderLabel = (provider: string): string => {
|
||||
if (provider === 'feishu') return '飞书';
|
||||
if (provider === 'wecom') return '企业微信';
|
||||
return provider;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<Skeleton className="h-10 w-48 bg-muted/60" />
|
||||
<Skeleton className="h-10 w-24 bg-muted/60" />
|
||||
</div>
|
||||
<Skeleton className="h-[280px] w-full rounded-xl bg-muted/60 border border-border/60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="theme-error-panel flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-danger" />
|
||||
<div className="font-medium tracking-wide">加载通知配置失败: {error.message}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="theme-page-frame">
|
||||
<div className="sticky top-0 z-10 theme-sticky-bar py-3 px-4 md:px-6 lg:px-8">
|
||||
<div className="theme-page-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleResetAll}
|
||||
disabled={!hasOverrides || resetMutation.isPending}
|
||||
className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
全部重置
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saveMutation.isPending}
|
||||
className="theme-interactive-elevate min-w-[130px] bg-primary text-primary-foreground font-bold hover:bg-primary/90 tech-glow transition-all"
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="size-4 animate-spin rounded-full border-2 border-primary-foreground/50 border-t-transparent" /> 保存中...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存配置
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="theme-page-content">
|
||||
{providerGroups.map((group) => {
|
||||
const provider = PROVIDER_CARDS.find((item) => group.key === `notification-${item.key}`);
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mutation = getProviderMutation(provider.key);
|
||||
const canTest = canSendProviderTest(provider);
|
||||
const canTestNow = canTest && !hasChanges && !saveMutation.isPending;
|
||||
const testTitle = hasChanges
|
||||
? '请先保存配置后再测试'
|
||||
: canTest
|
||||
? '发送测试通知'
|
||||
: '请先启用并配置Webhook地址';
|
||||
|
||||
return (
|
||||
<ConfigGroupCard
|
||||
key={group.key}
|
||||
group={group}
|
||||
localConfig={localConfig}
|
||||
onFieldChange={handleFieldChange}
|
||||
onReset={handleResetGroup}
|
||||
isResetting={resetMutation.isPending}
|
||||
headerActions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending || !canTestNow}
|
||||
className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors"
|
||||
title={testTitle}
|
||||
>
|
||||
{mutation.isPending ? '测试中...' : '测试发送'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="rounded-xl border border-border/60 bg-card p-4 md:p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold tracking-wide text-foreground">最近测试记录</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['notification-test-history'] })}
|
||||
className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors"
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isHistoryLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full bg-muted/60" />
|
||||
<Skeleton className="h-10 w-full bg-muted/60" />
|
||||
</div>
|
||||
) : (testHistory?.length ?? 0) === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||
暂无测试记录,点击上方“测试发送”按钮可生成记录。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{testHistory?.slice(0, 10).map((record) => (
|
||||
<div
|
||||
key={record.id}
|
||||
className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/20 p-3 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="border-border text-foreground">
|
||||
{getProviderLabel(record.provider)}
|
||||
</Badge>
|
||||
<Badge
|
||||
className={
|
||||
record.status === 'success'
|
||||
? 'bg-success/15 text-success border-success/30'
|
||||
: 'bg-danger/15 text-danger border-danger/30'
|
||||
}
|
||||
>
|
||||
{record.status === 'success' ? '成功' : '失败'}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">{record.message}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(record.timestamp).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -31,21 +31,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 +257,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,
|
||||
@@ -279,6 +316,14 @@ export async function installVisualApiMocks(page: Page) {
|
||||
return route.fulfill({ status: 204, body: '' });
|
||||
}
|
||||
|
||||
if (method === 'POST' && path.endsWith('/admin/api/config/notification/test')) {
|
||||
return json(route, { success: true, message: 'test sent' });
|
||||
}
|
||||
|
||||
if (method === 'GET' && path.endsWith('/admin/api/config/notification/test/history')) {
|
||||
return json(route, { data: notificationTestHistory });
|
||||
}
|
||||
|
||||
if (method === 'GET' && path.endsWith('/admin/api/llm/model-suggestions')) {
|
||||
return json(route, modelSuggestions);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ metadata:
|
||||
app.kubernetes.io/name: gitea-assistant
|
||||
app.kubernetes.io/part-of: gitea-assistant
|
||||
data:
|
||||
PORT: "3000"
|
||||
PORT: "5174"
|
||||
# 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 +38,7 @@ spec:
|
||||
image: ghcr.io/jeffusion/gitea-ai-assistant:latest
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3000
|
||||
containerPort: 5174
|
||||
protocol: TCP
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
@@ -92,6 +92,6 @@ spec:
|
||||
app.kubernetes.io/name: gitea-assistant
|
||||
ports:
|
||||
- name: http
|
||||
port: 3000
|
||||
port: 5174
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitea-assistant",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0-develop",
|
||||
"description": "Gitea功能增强助手,包含AI代码审核功能",
|
||||
"engines": {
|
||||
"bun": ">=1.2.5"
|
||||
@@ -38,6 +38,8 @@
|
||||
"main": "./dist/index.js",
|
||||
"typings": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"bootstrap": "bun install && (cd frontend && bun install)",
|
||||
"postinstall": "if [ -d frontend ]; then (cd frontend && bun install); fi",
|
||||
"dev": "concurrently -k -p [{name}] -n backend,frontend -c blue,green \"bun run dev:backend\" \"bun run dev:frontend\"",
|
||||
"dev:backend": "bun run --watch src/index.ts",
|
||||
"dev:frontend": "cd frontend && bun run dev",
|
||||
@@ -49,7 +51,7 @@
|
||||
"start:prod": "bun run dist/index.js",
|
||||
"lint": "biome check src/",
|
||||
"test": "bun test",
|
||||
"prepare": "husky"
|
||||
"prepare": "command -v husky >/dev/null 2>&1 && husky || true"
|
||||
},
|
||||
"keywords": [
|
||||
"code-review",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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(保证有baseSha),headCloneUrl作为额外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);
|
||||
|
||||
@@ -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();
|
||||
14
src/services/notification-manager.ts
Normal file
14
src/services/notification-manager.ts
Normal 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;
|
||||
}
|
||||
55
src/services/notification/base-notification-service.ts
Normal file
55
src/services/notification/base-notification-service.ts
Normal 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;
|
||||
}
|
||||
17
src/services/notification/index.ts
Normal file
17
src/services/notification/index.ts
Normal 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';
|
||||
20
src/services/notification/notification-factory.ts
Normal file
20
src/services/notification/notification-factory.ts
Normal 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));
|
||||
}
|
||||
105
src/services/notification/notification-manager.ts
Normal file
105
src/services/notification/notification-manager.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
47
src/services/notification/types.ts
Normal file
47
src/services/notification/types.ts
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user