diff --git a/.gitignore b/.gitignore index 151e78d..8fff206 100644 --- a/.gitignore +++ b/.gitignore @@ -245,9 +245,12 @@ bin/ logs/* !logs/.gitkeep +# Runtime configuration is initialized from example-configs on first start. +/configs/ + # docker-compose use docker-compose.yml in default, ignore this docker-compose.yaml # OS files .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db diff --git a/Dockerfile b/Dockerfile index ed00284..0864309 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,9 +33,9 @@ COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo # Copy the binary COPY --from=builder /build/feishu-github-tracker /app/feishu-github-tracker -# Keep immutable default configuration separate from the writable runtime directory. -COPY --from=builder /build/configs /app/default-configs -ENV DEFAULT_CONFIG_DIR=/app/default-configs +# Keep versioned examples separate from the writable runtime configuration. +COPY --from=builder /build/example-configs /app/example-configs +ENV DEFAULT_CONFIG_DIR=/app/example-configs # Set working directory WORKDIR /app diff --git a/Makefile b/Makefile index 5c81c8f..6a45c04 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ build: # Run the application locally (with -reload so panel edits apply live) run: - go run ./cmd/feishu-github-tracker -reload + CONFIG_DIR=./configs DEFAULT_CONFIG_DIR=./example-configs LOG_DIR=./logs go run ./cmd/feishu-github-tracker -reload # Run tests test: diff --git a/README.md b/README.md index 2afc604..aec1def 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,9 @@ 目前支持所有的 GitHub Webhook 事件 -- 详见 [configs/events.yaml](configs/events.yaml) +- 详见 [example-configs/events.yaml](example-configs/events.yaml) - 对应的处理方法以及文档详见 [internal/handler/](internal/handler/) -- 默认提供的消息模板详见 [configs/templates.jsonc](configs/templates.jsonc) +- 默认提供的消息模板详见 [example-configs/templates.jsonc](example-configs/templates.jsonc) - 也可以自定义模板,使用我们 `handler` 提供的的 `占位符变量` ([详见文档](internal/handler/README.md)) 以及 `template` 提供的 `模板引擎的语法` `过滤器` `条件块` 等功能 ([详见文档](internal/template/README.md)) 对发出消息的格式做相应的修改 ### Webhook 设置提醒 @@ -136,12 +136,13 @@ feishu-github-tracker/ │ └── template/ # 模板处理 ├── pkg/ │ └── logger/ # 日志模块 -├── configs/ # 配置文件目录 +├── example-configs/ # 受 Git 跟踪的默认配置与注释示例 │ ├── server.yaml │ ├── repos.yaml │ ├── events.yaml │ ├── feishu-bots.yaml │ └── templates.jsonc +├── configs/ # 运行时配置目录,首次启动生成且不受 Git 跟踪 ├── logs/ # 日志文件目录 ├── Dockerfile # Docker 镜像构建 ├── docker-compose.yml # Docker Compose 配置 @@ -151,6 +152,8 @@ feishu-github-tracker/ ## 配置说明 +仓库中的 [example-configs](example-configs) 是可随版本更新的默认配置;运行时请编辑 `configs/`。Docker Compose 首次启动会自动从示例目录复制缺失文件,已有文件绝不会被覆盖,因此更新代码不会再因本地配置修改而阻塞。 + ### server.yaml 服务器基础配置: @@ -248,7 +251,7 @@ event_sets: # 包含所有 GitHub 支持的事件... ``` -具体参考 [./configs/events.yaml](./configs/events.yaml) 中的详细内容 +具体参考 [./example-configs/events.yaml](./example-configs/events.yaml) 中的详细内容 ### repos.yaml @@ -345,6 +348,36 @@ templates: 3. **分支级别**:为 push/PR 指定分支规则 4. **动作级别**:为事件指定具体的 action(如 opened, closed) +### 多规则匹配 + +默认情况下,仓库事件只使用 `repos.yaml` 中第一条匹配的规则,适合使用精确规则覆盖通配符、将 `*` 作为兜底规则的配置方式。 + +若同一个仓库需要按事件发送到不同飞书机器人,可在 `server.yaml` 的 `server:` 下开启: + +```yaml +match_all_rules: true +``` + +开启后会按 `repos.yaml` 的顺序评估所有匹配规则:不订阅当前事件的规则会跳过,订阅该事件的规则会使用自己的 `notify_to` 发送通知。相同目标在同一次 webhook 内只会收到一条消息,发送仍按顺序执行。例如: + +```yaml +repos: + - pattern: "acme/widget" + events: + release: + notify_to: [release-bot] + - pattern: "acme/widget" + events: + all: + notify_to: [activity-bot] + - pattern: "acme/widget" + events: + reviewer: + notify_to: [review-bot] +``` + +在这个例子中,`issue_comment` 会通知 `activity-bot` 和 `review-bot`,而 `release` 会通知 `release-bot` 和 `activity-bot`。组织级 Webhook 保持原有行为。 + ### 模板选择 程序会根据事件的实际情况自动选择最合适的模板: @@ -420,7 +453,8 @@ make fmt ## 环境变量 -- `CONFIG_DIR` - 配置文件目录路径(默认:`./configs`) +- `CONFIG_DIR` - 运行时配置文件目录路径(Docker 默认:`/app/configs`) +- `DEFAULT_CONFIG_DIR` - 默认配置示例目录;启动时仅复制其中缺失的文件到 `CONFIG_DIR` - `LOG_DIR` - 日志文件目录路径(默认:`./logs`) - `TZ` - 时区设置(默认:`Asia/Shanghai`) diff --git a/docs/build-from-source.md b/docs/build-from-source.md index d9f3265..1e32f49 100644 --- a/docs/build-from-source.md +++ b/docs/build-from-source.md @@ -47,14 +47,14 @@ make docker-up # = docker-compose up -d # 编译 go build -o bin/feishu-github-tracker ./cmd/feishu-github-tracker -# 运行(-reload 启用配置热重载,推荐) -CONFIG_DIR=./configs LOG_DIR=./logs ./bin/feishu-github-tracker -reload +# 运行(首次会从 example-configs 初始化 ./configs;-reload 启用配置热重载) +CONFIG_DIR=./configs DEFAULT_CONFIG_DIR=./example-configs LOG_DIR=./logs ./bin/feishu-github-tracker -reload ``` 或直接 `go run`(等价于 `make run`): ```bash -go run ./cmd/feishu-github-tracker -reload +CONFIG_DIR=./configs DEFAULT_CONFIG_DIR=./example-configs LOG_DIR=./logs go run ./cmd/feishu-github-tracker -reload ``` `-reload` 会在每次收到 webhook 时重新加载 `./configs/`,修改配置后无需重启即生效。 @@ -71,7 +71,7 @@ docker compose up -d --build go build -o bin/feishu-github-tracker ./cmd/feishu-github-tracker ``` -配置文件位于 `./configs`(挂载卷),升级不会覆盖你已有的配置。 +`./configs` 是本地运行时配置,首次启动由 `./example-configs` 初始化,升级不会覆盖你已有的配置;更新后的示例可直接在 `example-configs/` 中对比。 --- diff --git a/docs/quickstart.md b/docs/quickstart.md index fdde202..ddb926f 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -53,13 +53,13 @@ http://localhost:4594/health ## 4. 修改配置 -编辑 `./configs/` 下的配置文件,参考 [../README.md](../README.md) 或 [../configs](../configs/) 下示例文件的注释。最常需要改的有: +编辑 `./configs/` 下的运行时配置,参考 [../README.md](../README.md) 或 [../example-configs](../example-configs/) 下示例文件的注释。最常需要改的有: -- [../configs/server.yaml](../configs/server.yaml):监听地址、端口、`secret`(测试可不设) -- [../configs/feishu-bots.yaml](../configs/feishu-bots.yaml):飞书机器人的 Webhook URL 与别名 +- `./configs/server.yaml`(示例:[server.yaml](../example-configs/server.yaml)):监听地址、端口、`secret`(测试可不设) +- `./configs/feishu-bots.yaml`(示例:[feishu-bots.yaml](../example-configs/feishu-bots.yaml)):飞书机器人的 Webhook URL 与别名 - 不清楚「飞书机器人 / Webhook URL」是什么?参考 [飞书文档](https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot),在群里建一个机器人并复制其 Webhook URL -- [../configs/repos.yaml](../configs/repos.yaml):要监听的 GitHub 仓库、事件及通知对象 -- [../configs/templates.jsonc](../configs/templates.jsonc):默认消息模板(可选:创建 `templates.<名称>.jsonc` 自定义模板) +- `./configs/repos.yaml`(示例:[repos.yaml](../example-configs/repos.yaml)):要监听的 GitHub 仓库、事件及通知对象 +- `./configs/templates.jsonc`(示例:[templates.jsonc](../example-configs/templates.jsonc)):默认消息模板(可选:创建 `templates.<名称>.jsonc` 自定义模板) 修改后保存,程序会在下一次收到 GitHub Webhook 时自动热重载。 @@ -69,7 +69,7 @@ http://localhost:4594/health - 面板地址:`http://localhost:4594/`(与 webhook 同端口;`/webhook`、`/health` 仍照常工作) - 默认账号:用户名 `admin` / 密码 `admin`(默认配置已带;老版本升级且未配置面板账号时,也自动用 `admin`/`admin`) - - 用户名:可在 [../configs/server.yaml](../configs/server.yaml) 的 `panel.username`、环境变量 `PANEL_USERNAME`,或面板「服务设置」页修改 + - 用户名:可在 `./configs/server.yaml` 的 `panel.username`、环境变量 `PANEL_USERNAME`,或面板「服务设置」页修改 - 密码(优先级从高到低): - 环境变量(推荐):`PANEL_PASSWORD=你的密码` - `panel.password`(明文):**存在则优先使用**;启动 / reload 时会自动转为 `password_hash`(覆盖原 hash)、删除该明文行并补回 `# password: "admin"` 注释 @@ -97,7 +97,7 @@ feishu_bots: - Payload URL:你的服务器地址,如 `http://your-domain-or-ip:4594/webhook` - Content type:选什么都支持 - Secret:填你在 `server.yaml` 配置的 `secret`(如果配了) -- 事件类型:可选 `Let me select individual events` 勾选需要的事件,并在 [../configs/repos.yaml](../configs/repos.yaml) 对应仓库里用 `all:` 等做更细控制;详见 [../configs/events.yaml](../configs/events.yaml) +- 事件类型:可选 `Let me select individual events` 勾选需要的事件,并在 `./configs/repos.yaml` 对应仓库里用 `all:` 等做更细控制;详见 [events.yaml 示例](../example-configs/events.yaml) - 点击 `Add webhook` - ✅ 配置无误的话,几秒后飞书群会收到一条「GitHub Webhook 添加成功」通知(GitHub 发送的 ping 事件),说明 Webhook 已生效 @@ -124,8 +124,22 @@ docker compose up -d # 用新镜像重建容器(本地配置保留) - 新版本引入的新默认配置项,也只在你对应文件缺失时才会自动补入 - 管理面板账号:若你从老版本升级且没配置过面板账号,默认登录 `admin` / `admin`(见 [§5](#5-web-管理面板可选)) +### 从旧版仓库迁移 + +旧版本曾将运行时配置直接纳入 Git 跟踪。首次拉取本次目录迁移前,请先备份并暂存本地配置,避免 Git 因配置文件修改拒绝更新: + +```bash +cp -a configs ../feishu-github-tracker-configs-backup +git stash push -m "backup runtime configs before config split" -- configs +git pull +mkdir -p configs +cp -a ../feishu-github-tracker-configs-backup/. configs/ +``` + +迁移后 `configs/` 已被忽略;以后更新时可直接比较 `example-configs/` 与本地 `configs/`,不会再产生配置文件的 Git 修改。 + > 从源码部署的更新方式见 [从源码构建 · 更新源码版本](build-from-source.md#更新源码版本)。 --- -更多配置与高级用法见 [../README.md](../README.md)、[../configs](../configs/) 示例注释,或 [../internal/handler](../internal/handler/)、[../internal/template](../internal/template/) 下的文档。 +更多配置与高级用法见 [../README.md](../README.md)、[../example-configs](../example-configs/) 示例注释,或 [../internal/handler](../internal/handler/)、[../internal/template](../internal/template/) 下的文档。 diff --git a/configs/events.yaml b/example-configs/events.yaml similarity index 100% rename from configs/events.yaml rename to example-configs/events.yaml diff --git a/configs/feishu-bots.yaml b/example-configs/feishu-bots.yaml similarity index 100% rename from configs/feishu-bots.yaml rename to example-configs/feishu-bots.yaml diff --git a/configs/repos.yaml b/example-configs/repos.yaml similarity index 95% rename from configs/repos.yaml rename to example-configs/repos.yaml index 7909e81..b8e377a 100644 --- a/configs/repos.yaml +++ b/example-configs/repos.yaml @@ -2,7 +2,7 @@ # repos.yaml # ----------------------------------------- # 用于定义组织/仓库与事件模板、通知对象的映射关系 -# 支持通配符,按顺序匹配 +# 支持通配符,默认按顺序取首条匹配;server.match_all_rules: true 时会依次处理所有匹配规则 # # 可选:每条匹配可单独配置一个 `secret`,用于校验该 GitHub Webhook 的签名。 # 留空则回退到 server.yaml 中的全局 `server.secret`。 diff --git a/configs/server.yaml b/example-configs/server.yaml similarity index 97% rename from configs/server.yaml rename to example-configs/server.yaml index 668e697..4436f71 100644 --- a/configs/server.yaml +++ b/example-configs/server.yaml @@ -9,6 +9,7 @@ server: port: 4594 # Webhook监听端口 # secret: "your_secret" # 全局 Webhook 密钥(fallback):用于验证 GitHub X-Hub-Signature;某条 repos 匹配单独配置了 secret 时,该规则优先使用自己的 secret,否则回退到这里 log_level: "info" # 可选: debug, info, warn, error + match_all_rules: false # 是否让同一 webhook 依次匹配所有仓库规则;false 时首条匹配规则生效 max_payload_size: 5MB # 限制单次Webhook body大小 timeout: 15 # 单次请求处理超时 (秒) diff --git a/configs/templates.cn.jsonc b/example-configs/templates.cn.jsonc similarity index 100% rename from configs/templates.cn.jsonc rename to example-configs/templates.cn.jsonc diff --git a/configs/templates.jsonc b/example-configs/templates.jsonc similarity index 100% rename from configs/templates.jsonc rename to example-configs/templates.jsonc diff --git a/internal/config/config.go b/internal/config/config.go index 07cf17f..9860430 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,6 +27,7 @@ type ServerConfig struct { Port int `yaml:"port"` Secret string `yaml:"secret"` LogLevel string `yaml:"log_level"` + MatchAllRules bool `yaml:"match_all_rules"` MaxPayloadSize string `yaml:"max_payload_size"` Timeout int `yaml:"timeout"` } `yaml:"server"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2b5e310..a607e26 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -17,6 +17,7 @@ server: port: 4594 secret: "test_secret" log_level: "debug" + match_all_rules: true max_payload_size: "5MB" timeout: 15 allowed_sources: @@ -114,6 +115,9 @@ feishu_bots: if cfg.Server.Server.Port != 4594 { t.Errorf("Expected port 4594, got %d", cfg.Server.Server.Port) } + if !cfg.Server.Server.MatchAllRules { + t.Error("Expected match_all_rules to be true") + } if len(cfg.Repos.Repos) != 1 { t.Errorf("Expected 1 repo, got %d", len(cfg.Repos.Repos)) @@ -171,9 +175,9 @@ feishu_bots: // This test is skipped by default as the real template files are very large and may have formatting issues func TestLoadRealTemplates(t *testing.T) { - // Use real templates from the project's configs directory. + // Use real templates from the project's versioned example-configs directory. // This test requires the files to exist in the repository root. - projectRoot := filepath.Join("..", "..", "configs") + projectRoot := filepath.Join("..", "..", "example-configs") // Ensure templates.jsonc exists templatesPath := filepath.Join(projectRoot, "templates.jsonc") diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 50bb1e7..4281b77 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -198,8 +198,10 @@ func (h *Handler) candidateSecrets(payload map[string]any) []string { add(h.config.Server.Server.Secret) if repo := h.extractRepoFullName(payload); repo != "" { - if rp, err := matcher.MatchRepo(repo, h.config.Repos.Repos); err == nil && rp != nil { - add(rp.Secret) + if rules, err := h.matchRepositoryRules(repo); err == nil { + for _, rule := range rules { + add(rule.Secret) + } } } else if org := h.extractOrgName(payload); org != "" { for _, rp := range h.config.Repos.Repos { @@ -211,6 +213,20 @@ func (h *Handler) candidateSecrets(payload map[string]any) []string { return out } +// matchRepositoryRules returns either the first matching rule (the historical +// default) or every matching rule when match_all_rules is enabled. +func (h *Handler) matchRepositoryRules(fullName string) ([]*config.RepoPattern, error) { + if h.config.Server.Server.MatchAllRules { + return matcher.MatchAllRepos(fullName, h.config.Repos.Repos) + } + + rule, err := matcher.MatchRepo(fullName, h.config.Repos.Repos) + if err != nil || rule == nil { + return nil, err + } + return []*config.RepoPattern{rule}, nil +} + // verifySignatureAny reports whether the X-Hub-Signature-256 header matches the // body for any of the provided secrets (HMAC-SHA256, constant-time compare). func (h *Handler) verifySignatureAny(signature string, body []byte, secrets []string) bool { @@ -235,6 +251,17 @@ func (h *Handler) processWebhook(eventType string, payload map[string]any) error // Extract organization name (for org-level webhooks) orgName := h.extractOrgName(payload) + if repoFullName != "" && h.config.Server.Server.MatchAllRules { + rules, err := matcher.MatchAllRepos(repoFullName, h.config.Repos.Repos) + if err != nil { + return fmt.Errorf("failed to match repository: %w", err) + } + if len(rules) == 0 { + logger.Debug("No matching repository pattern found for %s, skipping", repoFullName) + return nil + } + return h.processAllRepositoryRules(eventType, payload, rules) + } // Determine target bots based on repository or organization var repoPattern *config.RepoPattern @@ -313,51 +340,90 @@ func (h *Handler) processWebhook(eventType string, payload map[string]any) error } logger.Info("Event matched: %s, sending notification", eventType) + return h.sendNotification(eventType, payload, targetBots) +} - // Determine tags for template selection - tags := template.DetermineTags(eventType, payload) +// processAllRepositoryRules evaluates every matching rule in configuration +// order. A rule that does not subscribe to this event is skipped; failures in +// one eligible rule do not prevent later eligible rules from being attempted. +func (h *Handler) processAllRepositoryRules(eventType string, payload map[string]any, rules []*config.RepoPattern) error { + isPingEvent := eventType == "ping" + action := h.extractAction(payload) + ref := h.extractRef(payload) + seenTargets := make(map[string]struct{}) + var errs []string - // Prepare data for template filling (common for all templates) - data := h.prepareTemplateData(eventType, payload) + for _, rule := range rules { + logger.Debug("Matched repository pattern: %s", rule.Pattern) + if !isPingEvent { + expandedEvents := matcher.ExpandEvents(rule.Events, h.config.Events.EventSets, h.config.Events.Events) + if !matcher.MatchEvent(eventType, action, ref, payload, expandedEvents) { + logger.Debug("Event %s (action: %s, ref: %s) does not match rule %s, skipping", eventType, action, ref, rule.Pattern) + continue + } + } - // Group targets by template - targetsByTemplate := h.groupTargetsByTemplate(targetBots) + targets := uniqueUnseenTargets(rule.NotifyTo, seenTargets) + if len(targets) == 0 { + logger.Debug("Rule %s has no new notification targets, skipping", rule.Pattern) + continue + } - // Process each template group - var errs []string - for templateName, targets := range targetsByTemplate { - logger.Debug("Processing %d target(s) with template: %s", len(targets), templateName) + logger.Info("Event matched: %s (rule: %s), sending notification", eventType, rule.Pattern) + if err := h.sendNotification(eventType, payload, targets); err != nil { + logger.Error("Failed to send notifications for rule %s: %v", rule.Pattern, err) + errs = append(errs, fmt.Sprintf("rule %s: %v", rule.Pattern, err)) + } + } - // Get the appropriate template configuration - templatesConfig := h.config.GetTemplateConfig(templateName) + if len(errs) > 0 { + return fmt.Errorf("failed to process some matching rules: %s", strings.Join(errs, "; ")) + } + return nil +} + +// uniqueUnseenTargets prevents duplicate sends when overlapping matching rules +// contain the same configured target. +func uniqueUnseenTargets(targets []string, seen map[string]struct{}) []string { + var result []string + for _, target := range targets { + if _, ok := seen[target]; ok { + continue + } + seen[target] = struct{}{} + result = append(result, target) + } + return result +} - // Select template +func (h *Handler) sendNotification(eventType string, payload map[string]any, targets []string) error { + tags := template.DetermineTags(eventType, payload) + data := h.prepareTemplateData(eventType, payload) + targetsByTemplate := h.groupTargetsByTemplate(targets) + var errs []string + for templateName, templateTargets := range targetsByTemplate { + logger.Debug("Processing %d target(s) with template: %s", len(templateTargets), templateName) + templatesConfig := h.config.GetTemplateConfig(templateName) tmpl, err := template.SelectTemplate(eventType, tags, templatesConfig) if err != nil { logger.Error("Failed to select template for %s: %v", templateName, err) errs = append(errs, fmt.Sprintf("template %s: %v", templateName, err)) continue } - - // Fill template filledPayload, err := template.FillTemplate(tmpl, data) if err != nil { logger.Error("Failed to fill template for %s: %v", templateName, err) errs = append(errs, fmt.Sprintf("template %s: %v", templateName, err)) continue } - - // Send notifications to this group - if err := h.notifier.Send(targets, filledPayload); err != nil { + if err := h.notifier.Send(templateTargets, filledPayload); err != nil { logger.Error("Failed to send notifications for template %s: %v", templateName, err) errs = append(errs, fmt.Sprintf("template %s: %v", templateName, err)) } } - if len(errs) > 0 { return fmt.Errorf("failed to process some templates: %s", strings.Join(errs, "; ")) } - return nil } diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index 78e399d..3cbcbc4 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -1,10 +1,11 @@ package handler import ( - "testing" - "net/http" "net/http/httptest" + "os" + "testing" + "net/url" "strings" @@ -34,6 +35,61 @@ func TestPrepareTemplateData_IncludesNestedObjects(t *testing.T) { } } +func TestProcessWebhookMatchAllRules(t *testing.T) { + logger.Init("error", os.TempDir()) + received := make(map[string]int) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received[r.URL.Path]++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + newHandler := func(matchAllRules bool) *Handler { + cfg := &config.Config{ + Repos: config.ReposConfig{Repos: []config.RepoPattern{ + {Pattern: "org/repo", Events: map[string]any{"release": nil}, NotifyTo: []string{"sl"}}, + {Pattern: "org/repo", Events: map[string]any{"all": nil}, NotifyTo: []string{"action", "shared"}}, + {Pattern: "org/repo", Events: map[string]any{"reviewer": nil}, NotifyTo: []string{"reviewer", "shared"}}, + }}, + Events: config.EventsConfig{EventSets: map[string]map[string]any{ + "all": {"issue_comment": nil}, + "reviewer": {"issue_comment": nil}, + }}, + FeishuBots: config.FeishuBotsConfig{FeishuBots: []config.FeishuBot{ + {Alias: "sl", URL: server.URL + "/sl"}, + {Alias: "action", URL: server.URL + "/action"}, + {Alias: "reviewer", URL: server.URL + "/reviewer"}, + {Alias: "shared", URL: server.URL + "/shared"}, + }}, + Templates: map[string]config.TemplatesConfig{"default": { + Templates: map[string]config.EventTemplate{"issue_comment": { + Payloads: []config.PayloadTemplate{{Tags: []string{"default"}, Payload: map[string]any{"msg_type": "text"}}}, + }}, + }}, + } + cfg.Server.Server.MatchAllRules = matchAllRules + return New(cfg, notifier.New(cfg.FeishuBots)) + } + + payload := map[string]any{ + "action": "edited", + "repository": map[string]any{"full_name": "org/repo"}, + } + if err := newHandler(false).processWebhook("issue_comment", payload); err != nil { + t.Fatalf("default matching returned error: %v", err) + } + if len(received) != 0 { + t.Fatalf("default matching delivered %#v, want no messages", received) + } + + if err := newHandler(true).processWebhook("issue_comment", payload); err != nil { + t.Fatalf("match_all_rules returned error: %v", err) + } + if received["/action"] != 1 || received["/reviewer"] != 1 || received["/shared"] != 1 || received["/sl"] != 0 { + t.Fatalf("match_all_rules delivered %#v, want action, reviewer, and shared once each", received) + } +} + func TestServeHTTP_FormEncodedPayload(t *testing.T) { // Initialize logger for tests logger.Init("info", "/tmp") @@ -46,6 +102,7 @@ func TestServeHTTP_FormEncodedPayload(t *testing.T) { Port int `yaml:"port"` Secret string `yaml:"secret"` LogLevel string `yaml:"log_level"` + MatchAllRules bool `yaml:"match_all_rules"` MaxPayloadSize string `yaml:"max_payload_size"` Timeout int `yaml:"timeout"` }{Secret: ""}, @@ -113,6 +170,7 @@ func TestServeHTTP_FormEncodedMissingPayload(t *testing.T) { Port int `yaml:"port"` Secret string `yaml:"secret"` LogLevel string `yaml:"log_level"` + MatchAllRules bool `yaml:"match_all_rules"` MaxPayloadSize string `yaml:"max_payload_size"` Timeout int `yaml:"timeout"` }{Secret: ""}, diff --git a/internal/matcher/matcher.go b/internal/matcher/matcher.go index 0694ea3..26bca68 100644 --- a/internal/matcher/matcher.go +++ b/internal/matcher/matcher.go @@ -23,6 +23,23 @@ func MatchRepo(fullName string, repos []config.RepoPattern) (*config.RepoPattern return nil, nil } +// MatchAllRepos finds every matching repository pattern in configuration order. +// It is used only when the server explicitly enables multi-rule matching. +func MatchAllRepos(fullName string, repos []config.RepoPattern) ([]*config.RepoPattern, error) { + var matched []*config.RepoPattern + for i := range repos { + pattern := repos[i].Pattern + g, err := glob.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("invalid glob pattern %s: %w", pattern, err) + } + if g.Match(fullName) { + matched = append(matched, &repos[i]) + } + } + return matched, nil +} + // ExpandEvents expands event templates and merges them with custom events func ExpandEvents(repoEvents map[string]any, eventSets map[string]map[string]any, baseEvents map[string]any) map[string]any { result := make(map[string]any) diff --git a/internal/matcher/matcher_test.go b/internal/matcher/matcher_test.go index e33f1e7..80c96b6 100644 --- a/internal/matcher/matcher_test.go +++ b/internal/matcher/matcher_test.go @@ -39,6 +39,27 @@ func TestMatchRepo(t *testing.T) { } } +func TestMatchAllRepos(t *testing.T) { + repos := []config.RepoPattern{ + {Pattern: "org/specific-repo"}, + {Pattern: "org/*"}, + {Pattern: "*"}, + } + + matched, err := MatchAllRepos("org/specific-repo", repos) + if err != nil { + t.Fatalf("MatchAllRepos() error = %v", err) + } + if len(matched) != 3 { + t.Fatalf("MatchAllRepos() matched %d rules, want 3", len(matched)) + } + for i, rule := range matched { + if rule.Pattern != repos[i].Pattern { + t.Errorf("match %d = %q, want %q", i, rule.Pattern, repos[i].Pattern) + } + } +} + func TestMatchEvent(t *testing.T) { tests := []struct { name string diff --git a/internal/panel/app.go b/internal/panel/app.go index 8444d13..9e0df3d 100644 --- a/internal/panel/app.go +++ b/internal/panel/app.go @@ -126,6 +126,7 @@ type ServerForm struct { Port int Secret string LogLevel string + MatchAllRules bool MaxPayloadSize string Timeout int AllowedSources string // newline-joined diff --git a/internal/panel/handlers_settings.go b/internal/panel/handlers_settings.go index 4799a23..0ace4db 100644 --- a/internal/panel/handlers_settings.go +++ b/internal/panel/handlers_settings.go @@ -18,6 +18,7 @@ func (a *App) handleSettings(w http.ResponseWriter, r *http.Request) { Port: s.Port, Secret: s.Secret, LogLevel: s.LogLevel, + MatchAllRules: s.MatchAllRules, MaxPayloadSize: s.MaxPayloadSize, Timeout: s.Timeout, AllowedSources: strings.Join(cfg.Server.AllowedSources, "\n"), @@ -46,6 +47,7 @@ func (a *App) handleSettingsSave(w http.ResponseWriter, r *http.Request) { host := strings.TrimSpace(r.FormValue("host")) secret := strings.TrimSpace(r.FormValue("secret")) logLevel := strings.TrimSpace(r.FormValue("log_level")) + matchAllRules := r.FormValue("match_all_rules") == "on" maxPayload := strings.TrimSpace(r.FormValue("max_payload_size")) allowed := splitLines(r.FormValue("allowed_sources")) @@ -82,6 +84,7 @@ func (a *App) handleSettingsSave(w http.ResponseWriter, r *http.Request) { } mapSet(serverMap, "secret", secret) mapSet(serverMap, "log_level", logLevel) + mapSetPlain(serverMap, "match_all_rules", strconv.FormatBool(matchAllRules)) mapSet(serverMap, "max_payload_size", maxPayload) if timeout > 0 { mapSetPlain(serverMap, "timeout", strconv.Itoa(timeout)) diff --git a/internal/panel/templates/server_settings.html b/internal/panel/templates/server_settings.html index da5e454..6291d5f 100644 --- a/internal/panel/templates/server_settings.html +++ b/internal/panel/templates/server_settings.html @@ -44,6 +44,12 @@

服务设置 / Server Settings

+ +
默认只使用首条匹配规则。开启后会按配置顺序检查全部匹配规则,每条规则可独立筛选事件和通知目标;同一目标在一次 webhook 中只发送一次。 / By default only the first matching rule is used. When enabled, every matching rule is evaluated in order; each filters events and selects targets independently, while duplicate targets receive one notification per webhook.
+