From a484c0a0099ee0f106f043d7495a45e9987b830c Mon Sep 17 00:00:00 2001 From: Nanaloveyuki Date: Fri, 24 Jul 2026 12:02:00 +0800 Subject: [PATCH] feat: support matching all repository rules --- README.md | 30 +++++ configs/repos.yaml | 2 +- configs/server.yaml | 1 + internal/config/config.go | 1 + internal/config/config_test.go | 4 + internal/handler/handler.go | 110 ++++++++++++++---- internal/handler/handler_test.go | 62 +++++++++- internal/matcher/matcher.go | 17 +++ internal/matcher/matcher_test.go | 21 ++++ internal/panel/app.go | 1 + internal/panel/handlers_settings.go | 3 + internal/panel/templates/server_settings.html | 6 + 12 files changed, 233 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 2afc604..1b5d221 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,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 保持原有行为。 + ### 模板选择 程序会根据事件的实际情况自动选择最合适的模板: diff --git a/configs/repos.yaml b/configs/repos.yaml index 7909e81..b8e377a 100644 --- a/configs/repos.yaml +++ b/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/configs/server.yaml index 668e697..4436f71 100644 --- a/configs/server.yaml +++ b/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/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..b8b1631 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)) 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.
+