Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 保持原有行为。

### 模板选择

程序会根据事件的实际情况自动选择最合适的模板:
Expand Down
2 changes: 1 addition & 1 deletion configs/repos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# repos.yaml
# -----------------------------------------
# 用于定义组织/仓库与事件模板、通知对象的映射关系
# 支持通配符,按顺序匹配
# 支持通配符,默认按顺序取首条匹配;server.match_all_rules: true 时会依次处理所有匹配规则
#
# 可选:每条匹配可单独配置一个 `secret`,用于校验该 GitHub Webhook 的签名。
# 留空则回退到 server.yaml 中的全局 `server.secret`。
Expand Down
1 change: 1 addition & 0 deletions configs/server.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 # 单次请求处理超时 (秒)

Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
4 changes: 4 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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))
Expand Down
110 changes: 88 additions & 22 deletions internal/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
62 changes: 60 additions & 2 deletions internal/handler/handler_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package handler

import (
"testing"

"net/http"
"net/http/httptest"
"os"
"testing"

"net/url"
"strings"

Expand Down Expand Up @@ -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")
Expand All @@ -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: ""},
Expand Down Expand Up @@ -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: ""},
Expand Down
17 changes: 17 additions & 0 deletions internal/matcher/matcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions internal/matcher/matcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/panel/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ type ServerForm struct {
Port int
Secret string
LogLevel string
MatchAllRules bool
MaxPayloadSize string
Timeout int
AllowedSources string // newline-joined
Expand Down
Loading
Loading