diff --git a/.vscode/settings.json b/.vscode/settings.json index 729cf77..e96b09d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -36,7 +36,6 @@ "labeled", "ldflags", "lname", - "lumgr", "lurl", "marimo", "Math", diff --git a/Makefile b/Makefile index 70e868e..5c81c8f 100644 --- a/Makefile +++ b/Makefile @@ -4,9 +4,9 @@ build: go build -o bin/feishu-github-tracker ./cmd/feishu-github-tracker -# Run the application locally +# Run the application locally (with -reload so panel edits apply live) run: - go run ./cmd/feishu-github-tracker + go run ./cmd/feishu-github-tracker -reload # Run tests test: diff --git a/QUICKSTART.md b/QUICKSTART.md index fc59d25..385a06b 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -69,7 +69,32 @@ - [./configs/templates.jsonc](configs/templates.jsonc):默认消息模板(可选:创建/使用 `templates.<自定义名称,如「cn」>.jsonc` 自定义模板) - 修改后保存,程序会在下一次收到 GitHub Webhook 请求时自动热重载最新配置。 -3. 多模板配置(可选) +3. Web 管理面板(可选) + + 除了手改 YAML,本项目还内置一个 Web 管理面板,可在浏览器里增删改:仓库规则、飞书机器人、服务设置、事件配置、消息模板。 + + - 面板地址: + + ```bash + http://localhost:4594/ + ``` + + (与 webhook 服务同端口;`/webhook`、`/health` 仍照常工作。) + + - 设置管理员账号: + - 默认账号:用户名 `admin` / 密码 `admin`。默认配置文件已带 `panel.password: "admin"`;从老版本升级且未配置面板账号时,也会自动使用 `admin` / `admin`。 + - 用户名:默认 `admin`,可在 [./configs/server.yaml](configs/server.yaml) 的 `panel.username` 或环境变量 `PANEL_USERNAME` 中自定义;也可在面板「服务设置」页直接修改。 + - 密码(优先级从高到低): + - 环境变量(推荐):`PANEL_PASSWORD=你的密码` + - [./configs/server.yaml](configs/server.yaml) 的 `panel.password`(明文)。**若存在这一项则优先使用它**:启动 / reload 时会自动转为 `password_hash`(覆盖原 hash)、删除该明文行,并补回一行 `# password: "admin"` 注释。 + - [./configs/server.yaml](configs/server.yaml) 的 `panel.password_hash`(bcrypt,可用 `htpasswd -bnBC 10 "" 你的密码 | tr -d ':\n' | sed 's/^\$2y/\$2a/'` 生成) + - 在面板「服务设置」页修改用户名或密码:修改密码需先填写「当前密码」校验通过后才生效;新密码会生成 `password_hash`(同时补回 `# password: "admin"` 注释)。保存后下次登录即用新账号,无需重启。 + + - 面板内的修改保存后会自动 reload 生效(无需等待下一次 webhook,也无需重启);手动编辑 `./configs/` 下的文件则需以 `--reload` 启动或重启进程。端口 / 密钥的改动仍需重启进程。 + + - 注意:在「消息模板」页保存 `templates.*.jsonc` 会移除文件中的 `//` 注释并按字母重排键(功能不变)。 + +4. 多模板配置(可选) 如果需要为不同的飞书 bot 配置不同的消息模板(如中英文双语),可以在 `./configs/feishu-bots.yaml` 中指定模板: @@ -82,7 +107,7 @@ 也可以根据现有的修改并创建新的模版文件 `templates.<自定义名称>.jsonc`,然后在 `feishu-bots.yaml` 中引用。 -4. 添加 GitHub Webhook +5. 添加 GitHub Webhook - 进入你想监听的 GitHub 仓库,点击 `Settings` -> `Webhooks` -> `Add webhook` - 在 `Payload URL` 中填入你的服务器地址,例如 `http://your-domain-or-ip:4594/webhook` @@ -92,7 +117,7 @@ - 点击 `Add webhook` 保存 - **✅ 成功提示**:如果前面的步骤没有错误,几秒钟后你会在飞书群组中收到一条 "GitHub Webhook 添加成功" 的通知(这是 GitHub 发送的 ping 事件)。这表示 Webhook 已正确配置并能正常工作! -5. 简要调试 +6. 简要调试 - 若没有收到通知,请检查: - GitHub Webhook 配置(Payload URL、Secret、事件类型) diff --git a/README.md b/README.md index 450561c..d630adc 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 一个用于接收 GitHub Webhook 并转发到飞书机器人的中间件服务。支持灵活的配置、事件过滤和自定义消息模板。 -![logo](./assets/images/logo.png) +![logo](./assets/images/banner.png) ## 写在前面 @@ -17,6 +17,7 @@ 所以,我还是决定直接搓一个给大伙用了。我这边的主要目标是: - 简单易用:配置简单,Docker Compose 开箱即用,基于 GitHub 的 Webhook 实现 +- 可视化管理:内置 Web 管理面板,浏览器里即可增删改仓库规则、飞书机器人、事件、模板等,无需手改 YAML - 灵活可定制:支持多种事件过滤和自定义消息模板,只要替换现有的 `configs/templates.jsonc` 就可以满足大部分的模版定制需求。 - 高效稳定:使用 Go 语言编写,性能优越 - 安全可靠:支持签名验证,防止伪造请求 @@ -24,7 +25,7 @@ ### TODO -- [ ] 计划添加基于 `html` 模板的 `web` 管理页面(类似 [lumgr](https://github.com/hnrobert/lumgr)), 方便查看日志 / 修改配置 / 监控状态等(目前只能通过修改配置文件和查看日志来维护) +- [x] 已添加基于 `html/template` 的 `web` 管理面板(白色 + `#4EACF8`/`#071C37` 主题,中英双语),可在浏览器中查看日志 / 修改仓库规则、飞书机器人、服务设置、事件与消息模板。详见 [QUICKSTART.md](QUICKSTART.md#3-web-管理面板)。 - [ ] 计划添加更多的事件模板(目前已经包含了大部分常用事件的模板,后续会根据反馈继续完善) ## 支持的 GitHub 事件 diff --git a/assets/images/logo.png b/assets/images/banner.png similarity index 100% rename from assets/images/logo.png rename to assets/images/banner.png diff --git a/cmd/feishu-github-tracker/main.go b/cmd/feishu-github-tracker/main.go index f55c63a..1816741 100644 --- a/cmd/feishu-github-tracker/main.go +++ b/cmd/feishu-github-tracker/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/base64" "flag" "fmt" "io" @@ -17,6 +18,7 @@ import ( "github.com/hnrobert/feishu-github-tracker/internal/handler" "github.com/hnrobert/feishu-github-tracker/internal/logger" "github.com/hnrobert/feishu-github-tracker/internal/notifier" + "github.com/hnrobert/feishu-github-tracker/internal/panel" ) func main() { @@ -81,6 +83,22 @@ func main() { h.EnableHotReload(configDir) } + // Normalize the panel password once at startup: if server.yaml has a + // plaintext panel.password, convert it to password_hash and drop the + // plaintext line. Also run on each hot-reload so manual edits are converted. + if changed, err := panel.NormalizePanelPassword(configDir); err != nil { + logger.Warn("Panel password normalization failed: %v", err) + } else if changed { + logger.Info("Converted panel plaintext password to password_hash") + } + h.OnReload = func(dir string) { + if changed, err := panel.NormalizePanelPassword(dir); err != nil { + logger.Warn("Panel password normalization failed: %v", err) + } else if changed { + logger.Info("Converted panel plaintext password to password_hash") + } + } + // Setup HTTP server mux := http.NewServeMux() mux.Handle("/webhook", h) @@ -89,6 +107,22 @@ func main() { w.Write([]byte("OK")) }) + // Mount the web management panel at "/" (ServeMux longest-prefix matching + // keeps /webhook and /health routed to their handlers above). The panel + // resolves admin username/password from server.yaml + env on each login. + panelApp, err := panel.New(panel.Options{ + ConfigDir: configDir, + LogDir: logDir, + JWTSecret: resolvePanelSecret(cfg), + OnSave: h.Reload, // reload running config after any panel edit + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to initialize panel: %v\n", err) + os.Exit(1) + } + logger.Info("Management panel mounted at / (default login: admin / admin)") + mux.Handle("/", panelApp) + srv := NewServer(cfg, mux) // Start server in a goroutine @@ -209,3 +243,31 @@ func NewServer(cfg *config.Config, handler http.Handler) *http.Server { IdleTimeout: 60 * time.Second, } } + +// resolvePanelSecret derives the panel JWT signing secret. Precedence: +// PANEL_JWT_SECRET env > server.yaml panel.secret > nil (the panel then uses +// an ephemeral random secret, which logs everyone out on restart). +// +// Admin username/password are resolved by the panel itself on each login from +// the same sources, so they don't need to be passed at startup. +func resolvePanelSecret(cfg *config.Config) []byte { + secretText := os.Getenv("PANEL_JWT_SECRET") + if secretText == "" { + secretText = cfg.Server.Panel.Secret + } + if secretText == "" { + return nil + } + var secret []byte + if decoded, err := base64.RawURLEncoding.DecodeString(secretText); err == nil { + secret = decoded + } else { + secret = []byte(secretText) + } + if len(secret) < 16 { + pad := make([]byte, 16) + copy(pad, secret) + secret = pad + } + return secret +} diff --git a/configs/server.yaml b/configs/server.yaml index b60c68d..77ee024 100644 --- a/configs/server.yaml +++ b/configs/server.yaml @@ -17,3 +17,33 @@ allowed_sources: - "github.com" - "api.github.com" - "your-github-enterprise-domain.com" + +# ========================================= +# 管理面板 / Management Panel +# ----------------------------------------- +# 可选。配置管理员账号密码后,可在 http://:/ 访问 Web 管理面板。 +# Optional. Once an admin account is configured, the web panel is available at http://:/. +# +# 默认账号 / Default account: 用户名 / username = admin,密码 / password = admin +# · 老版本升级时若未配置面板账号,也会自动使用 admin / admin。 +# · Upgraders without a panel account also get admin / admin by default. +# +# 设置管理员密码的方式(任选其一)/ Set the admin password (any one): +# 1) 环境变量 / env PANEL_PASSWORD(明文,登录时自动 bcrypt 哈希)—— 推荐 / recommended +# 2) password:明文。若出现这一项,则【优先】使用它。/ plaintext; if present, this TAKES PRIORITY. +# 程序在启动 / reload 时会自动将其转换为 password_hash(覆盖原有 hash),然后删除该明文行。 +# On startup / reload it is auto-converted to password_hash (overwriting any existing hash), +# then the plaintext line is removed (and a `# password: "admin"` hint comment is added). +# 3) password_hash:bcrypt 哈希。可用 htpasswd 生成 / generate with htpasswd: +# htpasswd -bnBC 10 "" YOUR_PASSWORD | tr -d ':\n' | sed 's/^\$2y/\$2a/' +# +# 用户名 / Username: 默认 admin;可用 username 自定义,或环境变量 PANEL_USERNAME 覆盖。 +# defaults to "admin"; override via username or the PANEL_USERNAME env var. +panel: + enabled: true + username: "admin" # 登录用户名 / login username (default: admin) + password: "admin" # 默认明文密码;启动/reload 时会自动转为 password_hash 并删除此行 / default plaintext; auto-converted to password_hash on load + secret: "change-me-panel-jwt-secret" # JWT 签名密钥;留空则每次启动随机生成(会登出所有人)/ JWT secret; blank => ephemeral per restart + + + diff --git a/configs/templates.cn.jsonc b/configs/templates.cn.jsonc index 39bcf19..8c0b2fb 100644 --- a/configs/templates.cn.jsonc +++ b/configs/templates.cn.jsonc @@ -3528,7 +3528,7 @@ "tag": "div", "text": { "tag": "lark_md", - "content": "**作者:** {{sender_link_md | default(sender.login)}}\n**Issue:** #{{issue.number}} {{issue.title}}\n**评论:** {{comment.body}}\n" + "content": "**作者:** {{sender_link_md | default(sender.login)}}\n**Issue:** {{issue_link_md | default(issue.html_url | default(''))}}\n**评论:** {{comment.body}}\n" } }, { @@ -8582,9 +8582,6 @@ "content": "{{pr_body}}" } }, - { - "tag": "hr" - }, { "tag": "action", "actions": [ @@ -8694,9 +8691,6 @@ "content": "{{pr_body}}" } }, - { - "tag": "hr" - }, { "tag": "action", "actions": [ @@ -8774,7 +8768,7 @@ "header": { "title": { "tag": "plain_text", - "content": "� Pull Request 已更新" + "content": "🔄 Pull Request 已更新" }, "template": "blue" }, @@ -13980,4 +13974,4 @@ ] } } -} \ No newline at end of file +} diff --git a/configs/templates.jsonc b/configs/templates.jsonc index a92a943..912dc16 100644 --- a/configs/templates.jsonc +++ b/configs/templates.jsonc @@ -3528,7 +3528,7 @@ "tag": "div", "text": { "tag": "lark_md", - "content": "**Author:** {{sender_link_md | default(sender.login)}}\n**Issue:** #{{issue.number}} {{issue.title}}\n**Comment:** {{comment.body}}\n" + "content": "**Author:** {{sender_link_md | default(sender.login)}}\n**Issue:** {{issue_link_md | default(issue.html_url | default(''))}}\n**Comment:** {{comment.body}}\n" } }, { @@ -8625,9 +8625,6 @@ "content": "{{pr_body}}" } }, - { - "tag": "hr" - }, { "tag": "action", "actions": [ @@ -8737,9 +8734,6 @@ "content": "{{pr_body}}" } }, - { - "tag": "hr" - }, { "tag": "action", "actions": [ @@ -8817,7 +8811,7 @@ "header": { "title": { "tag": "plain_text", - "content": "� Pull Request Updated" + "content": "🔄 Pull Request Updated" }, "template": "blue" }, @@ -14023,4 +14017,4 @@ ] } } -} \ No newline at end of file +} diff --git a/go.mod b/go.mod index 506d23c..c32773e 100644 --- a/go.mod +++ b/go.mod @@ -4,5 +4,7 @@ go 1.21 require ( github.com/gobwas/glob v0.2.3 + github.com/golang-jwt/jwt/v5 v5.2.2 + golang.org/x/crypto v0.31.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 1cc6ca5..e0de224 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..b6c03f7 --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,69 @@ +package auth + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + // DefaultCookieName is the name of the browser cookie holding the session JWT. + DefaultCookieName = "fgt_panel_token" + DefaultIssuer = "feishu-github-tracker" +) + +// Claims describes the authenticated admin session. +type Claims struct { + Username string `json:"sub"` + Admin bool `json:"admin"` + jwt.RegisteredClaims +} + +// NewRandomSecretB64 returns n random bytes encoded with raw-url base64, suitable +// for use as an ephemeral JWT signing secret. +func NewRandomSecretB64(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// SignHS256 issues an HS256 JWT for the given admin subject, valid for ttl. +func SignHS256(secret []byte, username string, admin bool, ttl time.Duration) (string, error) { + now := time.Now() + claims := Claims{ + Username: username, + Admin: admin, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: DefaultIssuer, + Subject: username, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), + }, + } + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return tok.SignedString(secret) +} + +// ParseHS256 validates an HS256 JWT and returns its claims. Allows 30s of +// clock skew to tolerate minor drift between issuer and verifier. +func ParseHS256(secret []byte, tokenString string) (*Claims, error) { + parsed, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return secret, nil + }, jwt.WithLeeway(30*time.Second)) + if err != nil { + return nil, err + } + claims, ok := parsed.Claims.(*Claims) + if !ok || !parsed.Valid { + return nil, errors.New("invalid token") + } + return claims, nil +} diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..7c38ea3 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,27 @@ +package auth + +import ( + "errors" + + "golang.org/x/crypto/bcrypt" +) + +// HashPassword returns a bcrypt hash of the given plaintext password. +func HashPassword(plain string) (string, error) { + if plain == "" { + return "", errors.New("password must not be empty") + } + b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(b), nil +} + +// VerifyPassword reports whether plain matches the given bcrypt hash. +func VerifyPassword(hash, plain string) bool { + if hash == "" || plain == "" { + return false + } + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil +} diff --git a/internal/config/config.go b/internal/config/config.go index da88d9b..1e4f57b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,7 +30,18 @@ type ServerConfig struct { MaxPayloadSize string `yaml:"max_payload_size"` Timeout int `yaml:"timeout"` } `yaml:"server"` - AllowedSources []string `yaml:"allowed_sources"` + AllowedSources []string `yaml:"allowed_sources"` + Panel PanelConfig `yaml:"panel"` +} + +// PanelConfig represents the optional `panel:` block in server.yaml, used to +// configure the web management panel (admin username/password + JWT secret). +type PanelConfig struct { + Enabled bool `yaml:"enabled,omitempty"` + Username string `yaml:"username,omitempty"` // admin login username; defaults to "admin" + Password string `yaml:"password,omitempty"` // plaintext password (hashed at runtime); convenient but less secure + PasswordHash string `yaml:"password_hash,omitempty"` // bcrypt hash; preferred over Password + Secret string `yaml:"secret,omitempty"` // JWT signing secret; falls back to an ephemeral random secret } // ReposConfig represents repos.yaml @@ -58,7 +69,7 @@ type FeishuBotsConfig struct { type FeishuBot struct { Alias string `yaml:"alias"` URL string `yaml:"url"` - Template string `yaml:"template"` // Optional: template name (e.g., "cn"), defaults to "default" + Template string `yaml:"template,omitempty"` // Optional: template name (e.g., "cn"), defaults to "default" } // TemplatesConfig represents templates.jsonc (JSONC) diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 739487c..bca1d9e 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -24,6 +24,9 @@ type Handler struct { notifier *notifier.Notifier hotReload bool configDir string + // OnReload, if set, is invoked after a successful hot-reload of config (e.g. + // to run file-normalization side effects). It must not panic. + OnReload func(configDir string) } // New creates a new Handler @@ -43,38 +46,50 @@ func (h *Handler) EnableHotReload(configDir string) { logger.Info("Hot reload enabled for config directory: %s", configDir) } +// Reload re-reads the configuration from disk and swaps it into the handler +// (rebuilding the notifier and running the OnReload hook). It is called on each +// webhook when hot reload is enabled, and also by the management panel after a +// configuration edit so that changes take effect immediately without a restart. +func (h *Handler) Reload() { + if h.configDir == "" { + return + } + logger.Debug("Reloading configuration from %s", h.configDir) + cfg, err := config.Load(h.configDir) + if err != nil { + logger.Error("Failed to reload configuration: %v", err) + return + } + changed := false + if h.config != nil { + oldB, _ := json.Marshal(h.config) + newB, _ := json.Marshal(cfg) + if string(oldB) != string(newB) { + logger.Info("Configuration changes detected, applying new configuration") + changed = true + } + } else { + logger.Info("Configuration loaded") + changed = true + } + + h.config = cfg + h.notifier = notifier.New(cfg.FeishuBots) + + if h.OnReload != nil { + h.OnReload(h.configDir) + } + + if !changed { + logger.Debug("Configuration reloaded successfully (no changes detected)") + } +} + // ServeHTTP handles incoming webhook requests func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Hot reload configuration if enabled if h.hotReload && h.configDir != "" { - logger.Debug("Reloading configuration from %s", h.configDir) - cfg, err := config.Load(h.configDir) - if err != nil { - logger.Error("Failed to reload configuration: %v", err) - // Continue with old config instead of failing - } else { - // compare with previous config and log info if different - changed := false - if h.config != nil { - oldB, _ := json.Marshal(h.config) - newB, _ := json.Marshal(cfg) - if string(oldB) != string(newB) { - logger.Info("Configuration changes detected, applying new configuration") - changed = true - } - } else { - logger.Info("Configuration loaded") - changed = true - } - - h.config = cfg - // Update notifier with new config - h.notifier = notifier.New(cfg.FeishuBots) - - if !changed { - logger.Debug("Configuration reloaded successfully (no changes detected)") - } - } + h.Reload() } if r.Method != http.MethodPost { @@ -357,7 +372,8 @@ func (h *Handler) extractRef(payload map[string]any) string { func (h *Handler) prepareTemplateData(eventType string, payload map[string]any) map[string]any { data := make(map[string]any) - // populate common fields shared across event types + // populate common fields shared across event types (repo, sender, org, + // installation, action) prepareCommonData(data, payload) // delegate per-event handling into separate files for clarity diff --git a/internal/handler/handler_branch_protection_configuration.go b/internal/handler/handler_branch_protection_configuration.go index c889363..c57fc17 100644 --- a/internal/handler/handler_branch_protection_configuration.go +++ b/internal/handler/handler_branch_protection_configuration.go @@ -4,10 +4,6 @@ package handler func prepareBranchProtectionConfigurationData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Add the raw payload for templates that need more detail data["branch_protection_configuration"] = payload } diff --git a/internal/handler/handler_branch_protection_rule.go b/internal/handler/handler_branch_protection_rule.go index 2cf3fed..7d85206 100644 --- a/internal/handler/handler_branch_protection_rule.go +++ b/internal/handler/handler_branch_protection_rule.go @@ -4,10 +4,6 @@ package handler func prepareBranchProtectionRuleData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract rule information if rule, ok := payload["rule"].(map[string]any); ok { data["rule"] = rule diff --git a/internal/handler/handler_check_run.go b/internal/handler/handler_check_run.go index b57876c..50d2daa 100644 --- a/internal/handler/handler_check_run.go +++ b/internal/handler/handler_check_run.go @@ -2,9 +2,6 @@ package handler // prepareCheckRunData exposes check_run and action func prepareCheckRunData(data map[string]any, payload map[string]any) { - if action, ok := payload["action"].(string); ok { - data["action"] = action - } if cr, ok := payload["check_run"].(map[string]any); ok { data["check_run"] = cr diff --git a/internal/handler/handler_check_suite.go b/internal/handler/handler_check_suite.go index 25882bd..845e706 100644 --- a/internal/handler/handler_check_suite.go +++ b/internal/handler/handler_check_suite.go @@ -16,7 +16,4 @@ func prepareCheckSuiteData(data map[string]any, payload map[string]any) { } } } - if action, ok := payload["action"].(string); ok { - data["action"] = action - } } diff --git a/internal/handler/handler_code_scanning_alert.go b/internal/handler/handler_code_scanning_alert.go index 9cc083f..5539f0a 100644 --- a/internal/handler/handler_code_scanning_alert.go +++ b/internal/handler/handler_code_scanning_alert.go @@ -6,7 +6,4 @@ func prepareCodeScanningAlertData(data map[string]any, payload map[string]any) { if alert, ok := payload["alert"].(map[string]any); ok { data["code_scanning_alert"] = alert } - if action, ok := payload["action"].(string); ok { - data["action"] = action - } } diff --git a/internal/handler/handler_commit_comment.go b/internal/handler/handler_commit_comment.go index e1f5123..f38b9d7 100644 --- a/internal/handler/handler_commit_comment.go +++ b/internal/handler/handler_commit_comment.go @@ -4,9 +4,6 @@ package handler func prepareCommitCommentData(data map[string]any, payload map[string]any) { // populate common fields prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } if comment, ok := payload["comment"].(map[string]any); ok { data["comment"] = comment } diff --git a/internal/handler/handler_common.go b/internal/handler/handler_common.go index bbb066c..d7aef14 100644 --- a/internal/handler/handler_common.go +++ b/internal/handler/handler_common.go @@ -7,4 +7,10 @@ func prepareCommonData(data map[string]any, payload map[string]any) { prepareSenderData(data, payload) prepareOrgData(data, payload) prepareInstallationCommonData(data, payload) + + // action is present on most GitHub events; surface it once here so the + // per-event prepare* functions don't each repeat this copy. + if action, ok := payload["action"].(string); ok { + data["action"] = action + } } diff --git a/internal/handler/handler_custom_property.go b/internal/handler/handler_custom_property.go index 24d3350..6fef418 100644 --- a/internal/handler/handler_custom_property.go +++ b/internal/handler/handler_custom_property.go @@ -4,10 +4,6 @@ package handler func prepareCustomPropertyData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract custom property definition if definition, ok := payload["definition"].(map[string]any); ok { data["definition"] = definition diff --git a/internal/handler/handler_custom_property_values.go b/internal/handler/handler_custom_property_values.go index 07fd517..32285b6 100644 --- a/internal/handler/handler_custom_property_values.go +++ b/internal/handler/handler_custom_property_values.go @@ -4,10 +4,6 @@ package handler func prepareCustomPropertyValuesData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract property values if newValues, ok := payload["new_property_values"].([]any); ok { data["new_property_values"] = newValues diff --git a/internal/handler/handler_dependabot_alert.go b/internal/handler/handler_dependabot_alert.go index 8b917f3..ebcc5f6 100644 --- a/internal/handler/handler_dependabot_alert.go +++ b/internal/handler/handler_dependabot_alert.go @@ -6,7 +6,4 @@ func prepareDependabotAlertData(data map[string]any, payload map[string]any) { if alert, ok := payload["alert"].(map[string]any); ok { data["dependabot_alert"] = alert } - if action, ok := payload["action"].(string); ok { - data["action"] = action - } } diff --git a/internal/handler/handler_deploy_key.go b/internal/handler/handler_deploy_key.go index 00d4eb9..43c0bfe 100644 --- a/internal/handler/handler_deploy_key.go +++ b/internal/handler/handler_deploy_key.go @@ -3,9 +3,6 @@ package handler // prepareDeployKeyData exposes basic fields for deploy_key events func prepareDeployKeyData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } if key, ok := payload["key"].(map[string]any); ok { data["deploy_key"] = key } diff --git a/internal/handler/handler_deployment.go b/internal/handler/handler_deployment.go index 5e5db64..83d50f3 100644 --- a/internal/handler/handler_deployment.go +++ b/internal/handler/handler_deployment.go @@ -11,7 +11,4 @@ func prepareDeploymentData(data map[string]any, payload map[string]any) { data["deployment_url"] = url } } - if action, ok := payload["action"].(string); ok { - data["action"] = action - } } diff --git a/internal/handler/handler_deployment_protection_rule.go b/internal/handler/handler_deployment_protection_rule.go index 51573b6..3dfc419 100644 --- a/internal/handler/handler_deployment_protection_rule.go +++ b/internal/handler/handler_deployment_protection_rule.go @@ -4,10 +4,6 @@ package handler func prepareDeploymentProtectionRuleData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract environment and deployment info if environment, ok := payload["environment"].(string); ok { data["environment"] = environment diff --git a/internal/handler/handler_deployment_review.go b/internal/handler/handler_deployment_review.go index 1ec83d3..9043d32 100644 --- a/internal/handler/handler_deployment_review.go +++ b/internal/handler/handler_deployment_review.go @@ -4,10 +4,6 @@ package handler func prepareDeploymentReviewData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract approver info if approver, ok := payload["approver"].(map[string]any); ok { data["approver"] = approver diff --git a/internal/handler/handler_discussion.go b/internal/handler/handler_discussion.go index 1f38009..056c550 100644 --- a/internal/handler/handler_discussion.go +++ b/internal/handler/handler_discussion.go @@ -14,5 +14,4 @@ func prepareDiscussionData(data map[string]any, payload map[string]any) { } } } - data["action"] = payload["action"] } diff --git a/internal/handler/handler_github_app_authorization.go b/internal/handler/handler_github_app_authorization.go index 75a8b1e..985e395 100644 --- a/internal/handler/handler_github_app_authorization.go +++ b/internal/handler/handler_github_app_authorization.go @@ -4,9 +4,5 @@ package handler func prepareGitHubAppAuthorizationData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - data["github_app_authorization"] = payload } diff --git a/internal/handler/handler_installation.go b/internal/handler/handler_installation.go index ca758b3..1d72959 100644 --- a/internal/handler/handler_installation.go +++ b/internal/handler/handler_installation.go @@ -4,10 +4,6 @@ package handler func prepareInstallationData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract installation info if installation, ok := payload["installation"].(map[string]any); ok { data["installation"] = installation diff --git a/internal/handler/handler_installation_repositories.go b/internal/handler/handler_installation_repositories.go index c25ad41..ff4a679 100644 --- a/internal/handler/handler_installation_repositories.go +++ b/internal/handler/handler_installation_repositories.go @@ -4,10 +4,6 @@ package handler func prepareInstallationRepositoriesData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract added/removed repositories if added, ok := payload["repositories_added"].([]any); ok { data["repositories_added"] = added diff --git a/internal/handler/handler_installation_target.go b/internal/handler/handler_installation_target.go index 44918ea..867a05b 100644 --- a/internal/handler/handler_installation_target.go +++ b/internal/handler/handler_installation_target.go @@ -4,10 +4,6 @@ package handler func prepareInstallationTargetData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract account info if account, ok := payload["account"].(map[string]any); ok { data["account"] = account diff --git a/internal/handler/handler_issue_comment.go b/internal/handler/handler_issue_comment.go index 7e3187b..feb0ded 100644 --- a/internal/handler/handler_issue_comment.go +++ b/internal/handler/handler_issue_comment.go @@ -21,5 +21,12 @@ func prepareIssueCommentData(data map[string]any, payload map[string]any) { data["issue_title"] = issue["title"] data["issue_url"] = issue["html_url"] data["issue"] = issue + if url, ok := issue["html_url"].(string); ok { + if title, ok := issue["title"].(string); ok && title != "" { + data["issue_link_md"] = fmt.Sprintf("[#%v %s](%s)", issue["number"], title, url) + } else { + data["issue_link_md"] = url + } + } } } diff --git a/internal/handler/handler_issue_dependencies.go b/internal/handler/handler_issue_dependencies.go index 9375b09..bd52671 100644 --- a/internal/handler/handler_issue_dependencies.go +++ b/internal/handler/handler_issue_dependencies.go @@ -4,10 +4,6 @@ package handler func prepareIssueDependenciesData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract blocked issue info if blockedIssue, ok := payload["blocked_issue"].(map[string]any); ok { data["blocked_issue"] = blockedIssue diff --git a/internal/handler/handler_issues.go b/internal/handler/handler_issues.go index addd97e..6889a1e 100644 --- a/internal/handler/handler_issues.go +++ b/internal/handler/handler_issues.go @@ -154,5 +154,4 @@ func prepareIssuesData(data map[string]any, payload map[string]any) { } } - data["action"] = payload["action"] } diff --git a/internal/handler/handler_label.go b/internal/handler/handler_label.go index 7897d2e..2c58326 100644 --- a/internal/handler/handler_label.go +++ b/internal/handler/handler_label.go @@ -6,7 +6,4 @@ func prepareLabelData(data map[string]any, payload map[string]any) { if label, ok := payload["label"].(map[string]any); ok { data["label"] = label } - if action, ok := payload["action"].(string); ok { - data["action"] = action - } } diff --git a/internal/handler/handler_marketplace_purchase.go b/internal/handler/handler_marketplace_purchase.go index 5997f61..bb094d7 100644 --- a/internal/handler/handler_marketplace_purchase.go +++ b/internal/handler/handler_marketplace_purchase.go @@ -4,10 +4,6 @@ package handler func prepareMarketplacePurchaseData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract marketplace purchase info if purchase, ok := payload["marketplace_purchase"].(map[string]any); ok { data["marketplace_purchase"] = purchase diff --git a/internal/handler/handler_member.go b/internal/handler/handler_member.go index 409745c..2619d60 100644 --- a/internal/handler/handler_member.go +++ b/internal/handler/handler_member.go @@ -8,7 +8,4 @@ func prepareMemberData(data map[string]any, payload map[string]any) { data["member_login"] = login } } - if action, ok := payload["action"].(string); ok { - data["action"] = action - } } diff --git a/internal/handler/handler_membership.go b/internal/handler/handler_membership.go index cd0e050..142c0ec 100644 --- a/internal/handler/handler_membership.go +++ b/internal/handler/handler_membership.go @@ -5,7 +5,4 @@ func prepareMembershipData(data map[string]any, payload map[string]any) { if m, ok := payload["membership"].(map[string]any); ok { data["membership"] = m } - if action, ok := payload["action"].(string); ok { - data["action"] = action - } } diff --git a/internal/handler/handler_merge_group.go b/internal/handler/handler_merge_group.go index 020da1a..816aeef 100644 --- a/internal/handler/handler_merge_group.go +++ b/internal/handler/handler_merge_group.go @@ -4,10 +4,6 @@ package handler func prepareMergeGroupData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract merge group info if mergeGroup, ok := payload["merge_group"].(map[string]any); ok { data["merge_group"] = mergeGroup diff --git a/internal/handler/handler_meta.go b/internal/handler/handler_meta.go index 01bb7c8..fdb45c9 100644 --- a/internal/handler/handler_meta.go +++ b/internal/handler/handler_meta.go @@ -4,10 +4,6 @@ package handler func prepareMetaData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract hook info if hook, ok := payload["hook"].(map[string]any); ok { data["hook"] = hook diff --git a/internal/handler/handler_milestone.go b/internal/handler/handler_milestone.go index be8ee41..db84dc2 100644 --- a/internal/handler/handler_milestone.go +++ b/internal/handler/handler_milestone.go @@ -11,7 +11,4 @@ func prepareMilestoneData(data map[string]any, payload map[string]any) { data["milestone_description"] = desc } } - if action, ok := payload["action"].(string); ok { - data["action"] = action - } } diff --git a/internal/handler/handler_org_block.go b/internal/handler/handler_org_block.go index d2c3bc8..0f21422 100644 --- a/internal/handler/handler_org_block.go +++ b/internal/handler/handler_org_block.go @@ -4,10 +4,6 @@ package handler func prepareOrgBlockData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract blocked user info if blockedUser, ok := payload["blocked_user"].(map[string]any); ok { data["blocked_user"] = blockedUser diff --git a/internal/handler/handler_package.go b/internal/handler/handler_package.go index be68a51..649e951 100644 --- a/internal/handler/handler_package.go +++ b/internal/handler/handler_package.go @@ -211,5 +211,4 @@ func preparePackageData(data map[string]any, payload map[string]any) { } } } - data["action"] = payload["action"] } diff --git a/internal/handler/handler_personal_access_token_request.go b/internal/handler/handler_personal_access_token_request.go index e9efd19..933b082 100644 --- a/internal/handler/handler_personal_access_token_request.go +++ b/internal/handler/handler_personal_access_token_request.go @@ -4,10 +4,6 @@ package handler func preparePersonalAccessTokenRequestData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract token request info if tokenRequest, ok := payload["personal_access_token_request"].(map[string]any); ok { data["personal_access_token_request"] = tokenRequest diff --git a/internal/handler/handler_project.go b/internal/handler/handler_project.go index d66570e..38fbe83 100644 --- a/internal/handler/handler_project.go +++ b/internal/handler/handler_project.go @@ -11,7 +11,4 @@ func prepareProjectData(data map[string]any, payload map[string]any) { data["project_url"] = url } } - if action, ok := payload["action"].(string); ok { - data["action"] = action - } } diff --git a/internal/handler/handler_projects_v2.go b/internal/handler/handler_projects_v2.go index 860c282..7040ce5 100644 --- a/internal/handler/handler_projects_v2.go +++ b/internal/handler/handler_projects_v2.go @@ -4,10 +4,6 @@ package handler func prepareProjectsV2Data(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract project v2 info if project, ok := payload["projects_v2"].(map[string]any); ok { data["projects_v2"] = project diff --git a/internal/handler/handler_projects_v2_item.go b/internal/handler/handler_projects_v2_item.go index 52ceed2..55dd237 100644 --- a/internal/handler/handler_projects_v2_item.go +++ b/internal/handler/handler_projects_v2_item.go @@ -4,10 +4,6 @@ package handler func prepareProjectsV2ItemData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract project v2 item info if item, ok := payload["projects_v2_item"].(map[string]any); ok { data["projects_v2_item"] = item diff --git a/internal/handler/handler_projects_v2_status_update.go b/internal/handler/handler_projects_v2_status_update.go index 57a2d65..f2105eb 100644 --- a/internal/handler/handler_projects_v2_status_update.go +++ b/internal/handler/handler_projects_v2_status_update.go @@ -4,10 +4,6 @@ package handler func prepareProjectsV2StatusUpdateData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract status update info if statusUpdate, ok := payload["projects_v2_status_update"].(map[string]any); ok { data["projects_v2_status_update"] = statusUpdate diff --git a/internal/handler/handler_public.go b/internal/handler/handler_public.go index d570ef5..b475bd8 100644 --- a/internal/handler/handler_public.go +++ b/internal/handler/handler_public.go @@ -5,7 +5,4 @@ func preparePublicData(data map[string]any, payload map[string]any) { if repo, ok := payload["repository"].(map[string]any); ok { data["repository"] = repo } - if action, ok := payload["action"].(string); ok { - data["action"] = action - } } diff --git a/internal/handler/handler_pull_request.go b/internal/handler/handler_pull_request.go index 03dd87e..741513c 100644 --- a/internal/handler/handler_pull_request.go +++ b/internal/handler/handler_pull_request.go @@ -42,5 +42,4 @@ func preparePullRequestData(data map[string]any, payload map[string]any) { } } } - data["action"] = payload["action"] } diff --git a/internal/handler/handler_pull_request_review_thread.go b/internal/handler/handler_pull_request_review_thread.go index 85a1ca5..f18d545 100644 --- a/internal/handler/handler_pull_request_review_thread.go +++ b/internal/handler/handler_pull_request_review_thread.go @@ -4,10 +4,6 @@ package handler func preparePullRequestReviewThreadData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract pull request info if pr, ok := payload["pull_request"].(map[string]any); ok { data["pull_request"] = pr diff --git a/internal/handler/handler_registry_package.go b/internal/handler/handler_registry_package.go index 1422af9..425e64b 100644 --- a/internal/handler/handler_registry_package.go +++ b/internal/handler/handler_registry_package.go @@ -4,10 +4,6 @@ package handler func prepareRegistryPackageData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract registry package info (legacy GitHub Packages) if pkg, ok := payload["registry_package"].(map[string]any); ok { data["registry_package"] = pkg diff --git a/internal/handler/handler_release.go b/internal/handler/handler_release.go index 7baa2d8..f185f20 100644 --- a/internal/handler/handler_release.go +++ b/internal/handler/handler_release.go @@ -9,5 +9,4 @@ func prepareReleaseData(data map[string]any, payload map[string]any) { data["release_body"] = release["body"] data["release"] = release } - data["action"] = payload["action"] } diff --git a/internal/handler/handler_repository.go b/internal/handler/handler_repository.go index 298b5ac..cb5abdd 100644 --- a/internal/handler/handler_repository.go +++ b/internal/handler/handler_repository.go @@ -12,10 +12,4 @@ func prepareRepositoryData(data map[string]any, payload map[string]any) { } } - // Ensure templates can access the event action for repository events - // Many other prepare* functions set data["action"] = payload["action"]. - // repository events did not — that caused {{action}} to be empty in templates. - if a, ok := payload["action"]; ok { - data["action"] = a - } } diff --git a/internal/handler/handler_repository_advisory.go b/internal/handler/handler_repository_advisory.go index 0ce1d3d..179f1c8 100644 --- a/internal/handler/handler_repository_advisory.go +++ b/internal/handler/handler_repository_advisory.go @@ -4,10 +4,6 @@ package handler func prepareRepositoryAdvisoryData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract repository advisory info if advisory, ok := payload["repository_advisory"].(map[string]any); ok { data["repository_advisory"] = advisory diff --git a/internal/handler/handler_repository_import.go b/internal/handler/handler_repository_import.go index 6ec5136..2ad1dcc 100644 --- a/internal/handler/handler_repository_import.go +++ b/internal/handler/handler_repository_import.go @@ -3,9 +3,6 @@ package handler // prepareRepositoryImportData exposes basic fields for repository_import events func prepareRepositoryImportData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } if importData, ok := payload["import"].(map[string]any); ok { data["repository_import"] = importData } diff --git a/internal/handler/handler_repository_ruleset.go b/internal/handler/handler_repository_ruleset.go index 50bf33a..0920bcd 100644 --- a/internal/handler/handler_repository_ruleset.go +++ b/internal/handler/handler_repository_ruleset.go @@ -6,7 +6,4 @@ func prepareRepositoryRulesetData(data map[string]any, payload map[string]any) { if ruleset, ok := payload["ruleset"].(map[string]any); ok { data["repository_ruleset"] = ruleset } - if action, ok := payload["action"].(string); ok { - data["action"] = action - } } diff --git a/internal/handler/handler_repository_vulnerability_alert.go b/internal/handler/handler_repository_vulnerability_alert.go index 4c66129..8acfbe5 100644 --- a/internal/handler/handler_repository_vulnerability_alert.go +++ b/internal/handler/handler_repository_vulnerability_alert.go @@ -6,7 +6,4 @@ func prepareRepositoryVulnerabilityAlertData(data map[string]any, payload map[st if alert, ok := payload["alert"].(map[string]any); ok { data["repository_vulnerability_alert"] = alert } - if action, ok := payload["action"].(string); ok { - data["action"] = action - } } diff --git a/internal/handler/handler_secret_scanning_alert.go b/internal/handler/handler_secret_scanning_alert.go index fdf3bed..210dca0 100644 --- a/internal/handler/handler_secret_scanning_alert.go +++ b/internal/handler/handler_secret_scanning_alert.go @@ -6,7 +6,4 @@ func prepareSecretScanningAlertData(data map[string]any, payload map[string]any) if alert, ok := payload["alert"].(map[string]any); ok { data["secret_scanning_alert"] = alert } - if action, ok := payload["action"].(string); ok { - data["action"] = action - } } diff --git a/internal/handler/handler_secret_scanning_alert_location.go b/internal/handler/handler_secret_scanning_alert_location.go index e9f4610..45a00db 100644 --- a/internal/handler/handler_secret_scanning_alert_location.go +++ b/internal/handler/handler_secret_scanning_alert_location.go @@ -4,10 +4,6 @@ package handler func prepareSecretScanningAlertLocationData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract alert info if alert, ok := payload["alert"].(map[string]any); ok { data["alert"] = alert diff --git a/internal/handler/handler_secret_scanning_scan.go b/internal/handler/handler_secret_scanning_scan.go index 5324639..44c900f 100644 --- a/internal/handler/handler_secret_scanning_scan.go +++ b/internal/handler/handler_secret_scanning_scan.go @@ -4,10 +4,6 @@ package handler func prepareSecretScanningScanData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract scan info if scan, ok := payload["scan"].(map[string]any); ok { data["scan"] = scan diff --git a/internal/handler/handler_security_advisory.go b/internal/handler/handler_security_advisory.go index df5499a..c858901 100644 --- a/internal/handler/handler_security_advisory.go +++ b/internal/handler/handler_security_advisory.go @@ -8,7 +8,4 @@ func prepareSecurityAdvisoryData(data map[string]any, payload map[string]any) { data["security_advisory_id"] = ghsa } } - if action, ok := payload["action"].(string); ok { - data["action"] = action - } } diff --git a/internal/handler/handler_sponsorship.go b/internal/handler/handler_sponsorship.go index 4e290a8..9f555a9 100644 --- a/internal/handler/handler_sponsorship.go +++ b/internal/handler/handler_sponsorship.go @@ -4,10 +4,6 @@ package handler func prepareSponsorshipData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract sponsorship info if sponsorship, ok := payload["sponsorship"].(map[string]any); ok { data["sponsorship"] = sponsorship diff --git a/internal/handler/handler_star.go b/internal/handler/handler_star.go index 59c70a8..6fa25cf 100644 --- a/internal/handler/handler_star.go +++ b/internal/handler/handler_star.go @@ -2,7 +2,4 @@ package handler // prepareStarData handles star (watch) event payload func prepareStarData(data map[string]any, payload map[string]any) { - if action, ok := payload["action"].(string); ok { - data["action"] = action - } } diff --git a/internal/handler/handler_sub_issues.go b/internal/handler/handler_sub_issues.go index 3517609..bc68da0 100644 --- a/internal/handler/handler_sub_issues.go +++ b/internal/handler/handler_sub_issues.go @@ -4,10 +4,6 @@ package handler func prepareSubIssuesData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract parent issue info if parentIssue, ok := payload["parent_issue"].(map[string]any); ok { data["parent_issue"] = parentIssue diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index 794e62a..78e399d 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -188,6 +188,33 @@ func TestPrepareTemplateData_IssueLinks(t *testing.T) { } } +func TestPrepareTemplateData_ActionAndIssueCommentLink(t *testing.T) { + cfg := &config.Config{} + n := notifier.New(config.FeishuBotsConfig{}) + h := New(cfg, n) + + payload := map[string]any{ + "action": "created", + "issue": map[string]any{ + "number": 2, + "title": "Issue title", + "html_url": "https://github.com/org/repo/issues/2", + }, + } + + for _, eventType := range []string{"team_add", "security_and_analysis", "pull_request_review", "issue_comment"} { + data := h.prepareTemplateData(eventType, payload) + if got := data["action"]; got != "created" { + t.Errorf("%s action = %v, want created", eventType, got) + } + } + + data := h.prepareTemplateData("issue_comment", payload) + if got := data["issue_link_md"]; got != "[#2 Issue title](https://github.com/org/repo/issues/2)" { + t.Fatalf("issue_link_md = %v", got) + } +} + func TestPrepareTemplateData_PackageURL(t *testing.T) { cfg := &config.Config{} n := notifier.New(config.FeishuBotsConfig{}) diff --git a/internal/handler/handler_watch.go b/internal/handler/handler_watch.go index 4aef36f..cc50317 100644 --- a/internal/handler/handler_watch.go +++ b/internal/handler/handler_watch.go @@ -2,9 +2,6 @@ package handler // prepareWatchData handles watch events (star/watch changes) func prepareWatchData(data map[string]any, payload map[string]any) { - if action, ok := payload["action"].(string); ok { - data["action"] = action - } if repo, ok := payload["repository"].(map[string]any); ok { data["repository"] = repo } diff --git a/internal/handler/handler_workflow_job.go b/internal/handler/handler_workflow_job.go index 9bfcd43..70fc653 100644 --- a/internal/handler/handler_workflow_job.go +++ b/internal/handler/handler_workflow_job.go @@ -4,10 +4,6 @@ package handler func prepareWorkflowJobData(data map[string]any, payload map[string]any) { prepareCommonData(data, payload) - if action, ok := payload["action"].(string); ok { - data["action"] = action - } - // Extract workflow job info if job, ok := payload["workflow_job"].(map[string]any); ok { data["workflow_job"] = job diff --git a/internal/handler/handler_workflow_run.go b/internal/handler/handler_workflow_run.go index ad80254..c316786 100644 --- a/internal/handler/handler_workflow_run.go +++ b/internal/handler/handler_workflow_run.go @@ -70,7 +70,4 @@ func prepareWorkflowRunData(data map[string]any, payload map[string]any) { } } - if action, ok := payload["action"].(string); ok { - data["action"] = action - } } diff --git a/internal/panel/app.go b/internal/panel/app.go new file mode 100644 index 0000000..b78b53c --- /dev/null +++ b/internal/panel/app.go @@ -0,0 +1,389 @@ +// Package panel implements the web management UI for feishu-github-tracker. +// It is a server-rendered admin panel: net/http + html/template with an +// embedded layout, JWT-cookie auth, and CRUD over the tracker's YAML/JSONC +// configuration files. Edits are written to disk and trigger an immediate +// reload via the OnSave hook (a restart is only needed for port/secret changes). +package panel + +import ( + "crypto/subtle" + "embed" + "encoding/json" + "html/template" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/hnrobert/feishu-github-tracker/internal/auth" + "github.com/hnrobert/feishu-github-tracker/internal/config" +) + +//go:embed templates/*.html +var templatesFS embed.FS + +//go:embed static/logo.jpg +var logoJPEG []byte + +const sessionTTL = 24 * time.Hour + +// Options configures a panel App at construction time. +type Options struct { + ConfigDir string // directory holding server.yaml, repos.yaml, etc. + LogDir string // directory holding delivery logs (for dashboard tail) + JWTSecret []byte // JWT signing secret; if empty, an ephemeral random secret is used + // OnSave, if set, is invoked after the panel writes any config file, so the + // running process can reload and apply the change immediately. + OnSave func() +} + +// App holds panel state and serves HTTP. +type App struct { + secret []byte + cookieName string + cfgDir string + logDir string + onSave func() + pages map[string]*template.Template + handler http.Handler +} + +// ViewData is the single render context passed to every page template. +type ViewData struct { + // auth / nav + Authed bool + Username string + HideNav bool + Flash string + FlashKind string // "ok" | "err" | "" + CurrentPage string + + // dashboard + RepoCount int + BotCount int + EventSetCount int + TemplateFiles []string + ServerInfo ServerInfo + RecentLines []string + + // repos + Repos []RepoRow + EditRepo RepoRow + + // bots + Bots []BotRow + EditBot BotRow + Templates []string // known template names for the bot template selector + + // server settings + ServerForm ServerForm + + // events + EventSetsYAML string + EventsYAML string + + // templates + TemplateFilesList []TemplateFileRow + EditTemplate EditTemplateData +} + +// ServerInfo captures read-only server status shown on the dashboard. +type ServerInfo struct { + Host string + Port int + LogLevel string + MaxPayloadSize string + Timeout int + AllowedSources []string + PanelEnabled bool +} + +// RepoRow represents one repos.yaml entry, for both list display and editing. +type RepoRow struct { + Index int + Pattern string + NotifyTo []string // list display + NotifyToRaw string // newline-joined, for the edit form + Events map[string]any + EventsYAML string // raw YAML text, for the edit textarea + EventCount int +} + +// BotRow represents one feishu-bots.yaml entry. +type BotRow struct { + Index int + Alias string + URL string + Template string +} + +// ServerForm holds editable server.yaml fields. +type ServerForm struct { + Host string + Port int + Secret string + LogLevel string + MaxPayloadSize string + Timeout int + AllowedSources string // newline-joined + Username string // effective panel admin username (for the form) +} + +// TemplateFileRow represents one templates.*.jsonc file. +type TemplateFileRow struct { + Name string + Count int +} + +// EditTemplateData holds the per-event template editor state. +type EditTemplateData struct { + File string // template name (e.g. "default", "cn") + Events []string // available event keys + Event string // selected event key + PayloadsJSON string // editable JSON for the event's payloads array +} + +// New constructs a panel App from opts. +func New(opts Options) (*App, error) { + secret := opts.JWTSecret + if len(secret) == 0 { + s, err := auth.NewRandomSecretB64(32) + if err != nil { + return nil, err + } + secret = []byte(s) + } + + base := template.New("layout.html").Funcs(template.FuncMap{ + "eq": func(a, b string) bool { + a = strings.TrimSpace(a) + b = strings.TrimSpace(b) + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 + }, + "contains": func(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false + }, + "startsWith": func(s, p string) bool { + return strings.HasPrefix(strings.TrimSpace(s), strings.TrimSpace(p)) + }, + "trim": func(s string) string { return strings.TrimSpace(s) }, + "toJSON": func(v any) template.JS { + b, err := json.Marshal(v) + if err != nil { + return template.JS("null") + } + return template.JS(b) + }, + }) + + pages := map[string]*template.Template{} + for _, page := range []string{ + "login", + "dashboard", + "repos", + "repo_edit", + "bots", + "bot_edit", + "server_settings", + "events", + "templates_list", + "template_edit", + } { + t, err := base.Clone() + if err != nil { + return nil, err + } + if _, err := t.ParseFS(templatesFS, "templates/layout.html", "templates/"+page+".html"); err != nil { + return nil, err + } + pages[page] = t + } + + a := &App{ + secret: secret, + cookieName: auth.DefaultCookieName, + cfgDir: opts.ConfigDir, + logDir: opts.LogDir, + onSave: opts.OnSave, + pages: pages, + } + a.handler = a.withAuthContext(a.routes()) + return a, nil +} + +// notifySaved triggers the configured reload callback after a config write so +// changes take effect without a restart. +func (a *App) notifySaved() { + if a.onSave != nil { + a.onSave() + } +} + +// Enabled reports whether a login password can be resolved from env/config +// (or the built-in admin/admin default). +func (a *App) Enabled() bool { + _, h := resolveCredentials(a.cfgDir) + return len(h) > 0 +} + +// credentials resolves the current admin username + password hash fresh from +// server.yaml + environment (so password changes take effect on the next login +// without a restart). +func (a *App) credentials() (string, []byte) { + return resolveCredentials(a.cfgDir) +} + +// ServeHTTP routes the request through the panel's mux. +func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) { + a.handler.ServeHTTP(w, r) +} + +func (a *App) routes() http.Handler { + mux := http.NewServeMux() + + // Brand logo / favicon (served to everyone, no auth). + serveLogo := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/jpeg") + w.Header().Set("Cache-Control", "public, max-age=86400") + _, _ = w.Write(logoJPEG) + } + mux.HandleFunc("/static/logo.jpg", serveLogo) + mux.HandleFunc("/favicon.ico", serveLogo) + + mux.HandleFunc("/login", a.handleLogin) + mux.HandleFunc("/logout", a.handleLogout) + + mux.HandleFunc("/", a.requireAuth(a.handleDashboard)) + mux.HandleFunc("/repos", a.requireAuth(a.handleRepos)) + mux.HandleFunc("/repos/new", a.requireAuth(a.handleRepoNew)) + mux.HandleFunc("/repos/edit", a.requireAuth(a.handleRepoEdit)) + mux.HandleFunc("/repos/save", a.requireAuth(a.handleRepoSave)) + mux.HandleFunc("/repos/delete", a.requireAuth(a.handleRepoDelete)) + + mux.HandleFunc("/bots", a.requireAuth(a.handleBots)) + mux.HandleFunc("/bots/new", a.requireAuth(a.handleBotNew)) + mux.HandleFunc("/bots/edit", a.requireAuth(a.handleBotEdit)) + mux.HandleFunc("/bots/save", a.requireAuth(a.handleBotSave)) + mux.HandleFunc("/bots/delete", a.requireAuth(a.handleBotDelete)) + + mux.HandleFunc("/settings", a.requireAuth(a.handleSettings)) + mux.HandleFunc("/settings/save", a.requireAuth(a.handleSettingsSave)) + + mux.HandleFunc("/events", a.requireAuth(a.handleEvents)) + mux.HandleFunc("/events/save", a.requireAuth(a.handleEventsSave)) + + mux.HandleFunc("/templates", a.requireAuth(a.handleTemplatesList)) + mux.HandleFunc("/templates/edit", a.requireAuth(a.handleTemplateEdit)) + mux.HandleFunc("/templates/save", a.requireAuth(a.handleTemplateSave)) + + return mux +} + +// loadConfig re-reads the configuration from disk so the panel always reflects +// the current state (the same loader the hot-reload path uses). +func (a *App) loadConfig() (*config.Config, error) { + return config.Load(a.cfgDir) +} + +// renderPage executes the named page template against data. +func (a *App) renderPage(w http.ResponseWriter, page string, data ViewData) { + t, ok := a.pages[page] + if !ok { + http.Error(w, "unknown page: "+page, http.StatusInternalServerError) + return + } + data.CurrentPage = page + if err := t.ExecuteTemplate(w, "layout", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// baseData returns a ViewData pre-populated with auth + flash from the request. +func (a *App) baseData(r *http.Request) ViewData { + q := r.URL.Query() + return ViewData{ + Authed: true, + Username: usernameFrom(r), + Flash: q.Get("flash"), + FlashKind: q.Get("kind"), + } +} + +// redirectFlash redirects to dest with a flash message rendered on arrival. +func (a *App) redirectFlash(w http.ResponseWriter, r *http.Request, dest, flash, kind string) { + u := dest + if flash != "" { + sep := "?" + if strings.Contains(dest, "?") { + sep = "&" + } + u = dest + sep + "kind=" + kind + "&flash=" + urlQueryEscape(flash) + } + http.Redirect(w, r, u, http.StatusSeeOther) +} + +// urlQueryEscape is a minimal query escaper to avoid importing net/url in app.go. +func urlQueryEscape(s string) string { return strings.NewReplacer(" ", "%20", "&", "%26").Replace(s) } + +// knownTemplates returns the template names available in the current config +// (e.g. ["default", "cn"]), sorted, for the bot template selector. +func (a *App) knownTemplates(cfg *config.Config) []string { + names := make([]string, 0, len(cfg.Templates)) + for k := range cfg.Templates { + names = append(names, k) + } + return sortedStrings(names) +} + +func sortedStrings(in []string) []string { + out := append([]string(nil), in...) + // simple insertion sort to avoid pulling sort for a tiny slice + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j-1] > out[j]; j-- { + out[j-1], out[j] = out[j], out[j-1] + } + } + return out +} + +// readRecentLogLines tails up to n lines from the most recent .log file in +// logDir, keeping only delivery-relevant lines. It degrades to nil on any error. +func readRecentLogLines(logDir string, n int) []string { + entries, err := os.ReadDir(logDir) + if err != nil { + return nil + } + var newest os.DirEntry + for _, e := range entries { + if e.IsDir() { + continue + } + if newest == nil || e.Name() > newest.Name() { + newest = e + } + } + if newest == nil { + return nil + } + data, err := os.ReadFile(filepath.Join(logDir, newest.Name())) + if err != nil { + return nil + } + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + var kept []string + for _, l := range lines { + if strings.Contains(l, "Successfully sent") || strings.Contains(l, "Failed") || strings.Contains(l, "notification") { + kept = append(kept, l) + } + } + if len(kept) > n { + kept = kept[len(kept)-n:] + } + return kept +} diff --git a/internal/panel/configio.go b/internal/panel/configio.go new file mode 100644 index 0000000..3ec3656 --- /dev/null +++ b/internal/panel/configio.go @@ -0,0 +1,100 @@ +package panel + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sync" + + "gopkg.in/yaml.v3" +) + +// writeMu serializes all config writes so concurrent panel submissions cannot +// interleave or race on temp-file names. Reads are safe without the lock +// because every write is an atomic temp-file-then-rename. +var writeMu sync.Mutex + +// atomicWriteFile writes data to path atomically by writing a temp file in the +// same directory and renaming it over the target. Same-dir rename is atomic on +// POSIX and avoids cross-device EXDEV. +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create config directory: %w", err) + } + + tmp, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpName := tmp.Name() + cleanup := func() { _ = os.Remove(tmpName) } + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + cleanup() + return fmt.Errorf("write temp file: %w", err) + } + if err := tmp.Close(); err != nil { + cleanup() + return fmt.Errorf("close temp file: %w", err) + } + if err := os.Chmod(tmpName, perm); err != nil { + cleanup() + return fmt.Errorf("chmod temp file: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + cleanup() + return fmt.Errorf("rename temp file: %w", err) + } + return nil +} + +// SaveYAML marshals v to YAML and writes it to path atomically. +func SaveYAML(path string, v any) error { + writeMu.Lock() + defer writeMu.Unlock() + + out, err := yaml.Marshal(v) + if err != nil { + return fmt.Errorf("marshal yaml: %w", err) + } + return atomicWriteFile(path, out, 0o644) +} + +// SaveJSON marshals v to indented JSON and writes it to path atomically. +func SaveJSON(path string, v any) error { + writeMu.Lock() + defer writeMu.Unlock() + + out, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("marshal json: %w", err) + } + out = append(out, '\n') + return atomicWriteFile(path, out, 0o644) +} + +var ( + reBlockComment = regexp.MustCompile(`/\*[\s\S]*?\*/`) + reLineComment = regexp.MustCompile(`(?m)//.*$`) +) + +// stripComments removes // and /* */ comments from JSONC input so it can be +// parsed as plain JSON. Mirrors internal/config.stripJSONCComments. +func stripComments(s string) string { + s = reBlockComment.ReplaceAllString(s, "") + s = reLineComment.ReplaceAllString(s, "") + return s +} + +// loadJSONC reads a JSONC file, strips comments, and unmarshals into out. +func loadJSONC(path string, out any) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal([]byte(stripComments(string(data))), out) +} diff --git a/internal/panel/credentials.go b/internal/panel/credentials.go new file mode 100644 index 0000000..085a905 --- /dev/null +++ b/internal/panel/credentials.go @@ -0,0 +1,161 @@ +package panel + +import ( + "os" + "strings" + + "github.com/hnrobert/feishu-github-tracker/internal/auth" + "github.com/hnrobert/feishu-github-tracker/internal/config" + "gopkg.in/yaml.v3" +) + +// resolveCredentials returns the effective admin username and bcrypt password +// hash for login, resolved fresh from the current server.yaml + environment. +// +// Password precedence: +// 1. PANEL_PASSWORD env (plaintext, hashed here) +// 2. server.yaml panel.password (plaintext, hashed here) — takes priority over +// password_hash so an operator can rotate by setting a new plaintext value +// 3. server.yaml panel.password_hash (bcrypt) +// 4. fallback "admin" (so upgraders with no panel config can log in as admin/admin) +// +// Username precedence: PANEL_USERNAME env > panel.username > "admin". +func resolveCredentials(cfgDir string) (username string, passHash []byte) { + username = "admin" + if u := os.Getenv("PANEL_USERNAME"); u != "" { + username = u + } + + var pc config.PanelConfig + if sc, err := readServerPanel(cfgDir); err == nil { + pc = sc + } + if u := pc.Username; u != "" { + if os.Getenv("PANEL_USERNAME") == "" { + username = u + } + } + + switch { + case os.Getenv("PANEL_PASSWORD") != "": + if h, err := auth.HashPassword(os.Getenv("PANEL_PASSWORD")); err == nil { + passHash = []byte(h) + } + case pc.Password != "": + if h, err := auth.HashPassword(pc.Password); err == nil { + passHash = []byte(h) + } + case pc.PasswordHash != "": + passHash = []byte(pc.PasswordHash) + default: + // No password configured at all (e.g. upgraders): default to "admin". + if h, err := auth.HashPassword("admin"); err == nil { + passHash = []byte(h) + } + } + return username, passHash +} + +// readServerPanel parses just server.yaml and returns its panel block. +func readServerPanel(cfgDir string) (config.PanelConfig, error) { + data, err := os.ReadFile(cfgDir + "/server.yaml") + if err != nil { + return config.PanelConfig{}, err + } + var sc config.ServerConfig + if err := yaml.Unmarshal(data, &sc); err != nil { + return config.PanelConfig{}, err + } + return sc.Panel, nil +} + +// passwordCommentHint is the documented default-password hint comment placed +// next to password_hash after a plaintext password is converted. +const passwordCommentHint = `password: "admin"` + +// NormalizePanelPassword performs the plaintext→hash auto-conversion described +// in the panel config contract: +// +// - If server.yaml has an uncommented panel.password (plaintext), hash it, +// store it as panel.password_hash (overwriting any existing hash), remove +// the plaintext password line, and add a `# password: "admin"` comment so +// operators know they can rotate by setting a new plaintext password. +// +// It is a no-op (returns false) when no plaintext password is present. All +// other content/comments in server.yaml are preserved via yaml.Node round-trip. +func NormalizePanelPassword(cfgDir string) (changed bool, err error) { + root, err := loadServerRoot(cfgDir) + if err != nil { + return false, err + } + panel := mapGet(topMap(root), "panel") + if panel == nil || panel.Kind != yaml.MappingNode { + return false, nil + } + + pwNode := mapGet(panel, "password") + if pwNode == nil || strings.TrimSpace(pwNode.Value) == "" { + return false, nil + } + + hash, err := auth.HashPassword(pwNode.Value) + if err != nil { + return false, err + } + + mapDelete(panel, "password") + setPanelHashWithHint(panel, hash) + + if err := writeServerRoot(cfgDir, root); err != nil { + return false, err + } + return true, nil +} + +// setPanelHashWithHint sets password_hash to hash, attaches the +// `# password: "admin"` hint as a stable standalone comment on the password_hash +// KEY node (yaml.v3 renders a mapping pair's leading comment from the key node), +// and reorders the panel block canonically so the hint is not the trailing item. +func setPanelHashWithHint(panel *yaml.Node, hash string) { + mapSet(panel, "password_hash", hash) + for i := 0; i+1 < len(panel.Content); i += 2 { + if panel.Content[i].Value == "password_hash" { + panel.Content[i].LineComment = "" + panel.Content[i].FootComment = "" + panel.Content[i].HeadComment = passwordCommentHint + break + } + } + reorderPanel(panel) +} + +// SetPanelPasswordHash writes a new bcrypt hash as panel.password_hash, removes +// any plaintext panel.password, and ensures the `# password: "admin"` hint +// comment is present. Used by the panel's "change password" form. Other content +// and comments in server.yaml are preserved. +func SetPanelPasswordHash(cfgDir, hash string) error { + root, err := loadServerRoot(cfgDir) + if err != nil { + return err + } + panel := mapGet(topMap(root), "panel") + if panel == nil || panel.Kind != yaml.MappingNode { + // No panel block at all: nothing to do (login isn't file-configured). + return nil + } + mapDelete(panel, "password") + setPanelHashWithHint(panel, hash) + return writeServerRoot(cfgDir, root) +} + +// SetPanelUsername writes panel.username. Other content and comments in +// server.yaml are preserved. +func SetPanelUsername(cfgDir, username string) error { + root, err := loadServerRoot(cfgDir) + if err != nil { + return err + } + panel := ensureMap(root, "panel") + mapSet(panel, "username", username) + return writeServerRoot(cfgDir, root) +} diff --git a/internal/panel/handlers_auth.go b/internal/panel/handlers_auth.go new file mode 100644 index 0000000..4b2c143 --- /dev/null +++ b/internal/panel/handlers_auth.go @@ -0,0 +1,55 @@ +package panel + +import ( + "net/http" + + "github.com/hnrobert/feishu-github-tracker/internal/auth" +) + +// handleLogin: GET renders the login card; POST verifies the admin password and +// establishes a JWT session. +func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + a.handleLoginPost(w, r) + return + } + + q := r.URL.Query() + a.renderPage(w, "login", ViewData{ + HideNav: true, + CurrentPage: "login", + Flash: q.Get("flash"), + FlashKind: q.Get("kind"), + }) +} + +func (a *App) handleLoginPost(w http.ResponseWriter, r *http.Request) { + if !a.Enabled() { + a.redirectFlash(w, r, "/login", "面板未配置管理员密码 / panel login not configured", "err") + return + } + if err := r.ParseForm(); err != nil { + a.redirectFlash(w, r, "/login", "表单解析失败 / invalid form", "err") + return + } + wantUser, passHash := a.credentials() + username := r.FormValue("username") + password := r.FormValue("password") + if username != wantUser || !auth.VerifyPassword(string(passHash), password) { + a.redirectFlash(w, r, "/login", "用户名或密码错误 / invalid username or password", "err") + return + } + + tok, err := auth.SignHS256(a.secret, username, true, sessionTTL) + if err != nil { + http.Error(w, "failed to issue session", http.StatusInternalServerError) + return + } + a.issueCookie(w, tok) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) { + a.clearCookie(w) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} diff --git a/internal/panel/handlers_bots.go b/internal/panel/handlers_bots.go new file mode 100644 index 0000000..8761736 --- /dev/null +++ b/internal/panel/handlers_bots.go @@ -0,0 +1,111 @@ +package panel + +import ( + "net/http" + "strconv" + "strings" + + "github.com/hnrobert/feishu-github-tracker/internal/config" +) + +// handleBots lists all configured Feishu bots. +func (a *App) handleBots(w http.ResponseWriter, r *http.Request) { + data := a.baseData(r) + if cfg, err := a.loadConfig(); err == nil { + for i, b := range cfg.FeishuBots.FeishuBots { + data.Bots = append(data.Bots, BotRow{Index: i, Alias: b.Alias, URL: b.URL, Template: b.Template}) + } + data.Templates = a.knownTemplates(cfg) + } + a.renderPage(w, "bots", data) +} + +// handleBotNew renders a blank bot edit form. +func (a *App) handleBotNew(w http.ResponseWriter, r *http.Request) { + data := a.baseData(r) + data.EditBot = BotRow{Index: -1} + if cfg, err := a.loadConfig(); err == nil { + data.Templates = a.knownTemplates(cfg) + } + a.renderPage(w, "bot_edit", data) +} + +// handleBotEdit renders the edit form for an existing bot by index. +func (a *App) handleBotEdit(w http.ResponseWriter, r *http.Request) { + data := a.baseData(r) + idx, _ := strconv.Atoi(r.URL.Query().Get("index")) + cfg, err := a.loadConfig() + if err != nil { + a.redirectFlash(w, r, "/bots", "读取配置失败 / failed to load config", "err") + return + } + data.Templates = a.knownTemplates(cfg) + if idx < 0 || idx >= len(cfg.FeishuBots.FeishuBots) { + a.redirectFlash(w, r, "/bots", "机器人不存在 / bot not found", "err") + return + } + b := cfg.FeishuBots.FeishuBots[idx] + data.EditBot = BotRow{Index: idx, Alias: b.Alias, URL: b.URL, Template: b.Template} + a.renderPage(w, "bot_edit", data) +} + +// handleBotSave creates or updates a bot. +func (a *App) handleBotSave(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + a.redirectFlash(w, r, "/bots", "表单解析失败 / invalid form", "err") + return + } + idx, _ := strconv.Atoi(r.FormValue("index")) + alias := strings.TrimSpace(r.FormValue("alias")) + url := strings.TrimSpace(r.FormValue("url")) + tmpl := strings.TrimSpace(r.FormValue("template")) + if alias == "" || url == "" { + a.redirectFlash(w, r, "/bots", "alias 和 url 不能为空 / alias and url required", "err") + return + } + + cfg, err := a.loadConfig() + if err != nil { + a.redirectFlash(w, r, "/bots", "读取配置失败 / failed to load config", "err") + return + } + + bot := config.FeishuBot{Alias: alias, URL: url, Template: tmpl} + if idx >= 0 && idx < len(cfg.FeishuBots.FeishuBots) { + cfg.FeishuBots.FeishuBots[idx] = bot + } else { + cfg.FeishuBots.FeishuBots = append(cfg.FeishuBots.FeishuBots, bot) + } + + if err := SaveYAML(a.cfgDir+"/feishu-bots.yaml", cfg.FeishuBots); err != nil { + a.redirectFlash(w, r, "/bots", "保存失败: "+err.Error(), "err") + return + } + a.notifySaved() + a.redirectFlash(w, r, "/bots", "机器人已保存 / bot saved", "ok") +} + +// handleBotDelete removes a bot by index. +func (a *App) handleBotDelete(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + a.redirectFlash(w, r, "/bots", "表单解析失败 / invalid form", "err") + return + } + idx, _ := strconv.Atoi(r.FormValue("index")) + cfg, err := a.loadConfig() + if err != nil { + a.redirectFlash(w, r, "/bots", "读取配置失败 / failed to load config", "err") + return + } + if idx < 0 || idx >= len(cfg.FeishuBots.FeishuBots) { + a.redirectFlash(w, r, "/bots", "机器人不存在 / bot not found", "err") + return + } + cfg.FeishuBots.FeishuBots = append(cfg.FeishuBots.FeishuBots[:idx], cfg.FeishuBots.FeishuBots[idx+1:]...) + if err := SaveYAML(a.cfgDir+"/feishu-bots.yaml", cfg.FeishuBots); err != nil { + a.redirectFlash(w, r, "/bots", "保存失败: "+err.Error(), "err") + return + } + a.notifySaved() + a.redirectFlash(w, r, "/bots", "机器人已删除 / bot deleted", "ok") +} diff --git a/internal/panel/handlers_dashboard.go b/internal/panel/handlers_dashboard.go new file mode 100644 index 0000000..71ccbc4 --- /dev/null +++ b/internal/panel/handlers_dashboard.go @@ -0,0 +1,43 @@ +package panel + +import ( + "net/http" + + "github.com/hnrobert/feishu-github-tracker/internal/config" +) + +// handleDashboard renders the overview page with counts, server info and recent +// delivery-log activity. +func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) { + data := a.baseData(r) + + if cfg, err := a.loadConfig(); err == nil { + data.RepoCount = len(cfg.Repos.Repos) + data.BotCount = len(cfg.FeishuBots.FeishuBots) + data.EventSetCount = len(cfg.Events.EventSets) + for name := range cfg.Templates { + data.TemplateFiles = append(data.TemplateFiles, name) + } + data.TemplateFiles = sortedStrings(data.TemplateFiles) + data.ServerInfo = serverInfoFrom(cfg) + } + data.RecentLines = readRecentLogLines(a.logDir, 20) + + a.renderPage(w, "dashboard", data) +} + +func serverInfoFrom(cfg *config.Config) ServerInfo { + s := cfg.Server.Server + // Login is usable when a password/hash is configured in server.yaml (the + // PANEL_PASSWORD env override isn't visible here, so this reflects file state). + panelReady := cfg.Server.Panel.PasswordHash != "" || cfg.Server.Panel.Password != "" + return ServerInfo{ + Host: s.Host, + Port: s.Port, + LogLevel: s.LogLevel, + MaxPayloadSize: s.MaxPayloadSize, + Timeout: s.Timeout, + AllowedSources: cfg.Server.AllowedSources, + PanelEnabled: cfg.Server.Panel.Enabled && panelReady, + } +} diff --git a/internal/panel/handlers_events.go b/internal/panel/handlers_events.go new file mode 100644 index 0000000..03facbf --- /dev/null +++ b/internal/panel/handlers_events.go @@ -0,0 +1,42 @@ +package panel + +import ( + "net/http" + "os" + + "github.com/hnrobert/feishu-github-tracker/internal/config" + "gopkg.in/yaml.v3" +) + +// handleEvents shows the raw events.yaml in an editor. The whole file is +// edited as text so comments and ordering are preserved exactly. +func (a *App) handleEvents(w http.ResponseWriter, r *http.Request) { + data := a.baseData(r) + if b, err := os.ReadFile(a.cfgDir + "/events.yaml"); err == nil { + data.EventsYAML = string(b) + } + a.renderPage(w, "events", data) +} + +// handleEventsSave validates the edited events.yaml (by parsing it) and, if +// valid, writes the raw text back verbatim. +func (a *App) handleEventsSave(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + a.redirectFlash(w, r, "/events", "表单解析失败 / invalid form", "err") + return + } + text := r.FormValue("events_yaml") + + var check config.EventsConfig + if err := yaml.Unmarshal([]byte(text), &check); err != nil { + a.redirectFlash(w, r, "/events", "events.yaml 解析失败: "+err.Error(), "err") + return + } + + if err := os.WriteFile(a.cfgDir+"/events.yaml", []byte(text), 0o644); err != nil { + a.redirectFlash(w, r, "/events", "保存失败: "+err.Error(), "err") + return + } + a.notifySaved() + a.redirectFlash(w, r, "/events", "事件配置已保存 / events saved", "ok") +} diff --git a/internal/panel/handlers_repos.go b/internal/panel/handlers_repos.go new file mode 100644 index 0000000..45e42e1 --- /dev/null +++ b/internal/panel/handlers_repos.go @@ -0,0 +1,165 @@ +package panel + +import ( + "net/http" + "strconv" + "strings" + + "github.com/hnrobert/feishu-github-tracker/internal/config" + "gopkg.in/yaml.v3" +) + +// handleRepos lists all repo rules. +func (a *App) handleRepos(w http.ResponseWriter, r *http.Request) { + data := a.baseData(r) + if cfg, err := a.loadConfig(); err == nil { + for i, rp := range cfg.Repos.Repos { + data.Repos = append(data.Repos, repoListRow(i, rp)) + } + } + a.renderPage(w, "repos", data) +} + +// handleRepoNew renders a blank edit form for a new repo rule. +func (a *App) handleRepoNew(w http.ResponseWriter, r *http.Request) { + data := a.baseData(r) + data.EditRepo = RepoRow{Index: -1} + a.renderPage(w, "repo_edit", data) +} + +// handleRepoEdit renders the edit form for an existing repo rule by index. +func (a *App) handleRepoEdit(w http.ResponseWriter, r *http.Request) { + data := a.baseData(r) + idx, _ := strconv.Atoi(r.URL.Query().Get("index")) + cfg, err := a.loadConfig() + if err != nil { + a.redirectFlash(w, r, "/repos", "读取配置失败 / failed to load config", "err") + return + } + if idx < 0 || idx >= len(cfg.Repos.Repos) { + a.redirectFlash(w, r, "/repos", "仓库不存在 / repo not found", "err") + return + } + data.EditRepo = repoEditRow(idx, cfg.Repos.Repos[idx]) + a.renderPage(w, "repo_edit", data) +} + +// handleRepoSave creates or updates a repo rule. +func (a *App) handleRepoSave(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + a.redirectFlash(w, r, "/repos", "表单解析失败 / invalid form", "err") + return + } + idx, _ := strconv.Atoi(r.FormValue("index")) + pattern := strings.TrimSpace(r.FormValue("pattern")) + if pattern == "" { + a.redirectFlash(w, r, "/repos", "pattern 不能为空 / pattern must not be empty", "err") + return + } + + events, err := parseEventsYAML(r.FormValue("events")) + if err != nil { + a.redirectFlash(w, r, "/repos", "events YAML 解析失败: "+err.Error(), "err") + return + } + notifyTo := splitLines(r.FormValue("notify_to")) + + cfg, err := a.loadConfig() + if err != nil { + a.redirectFlash(w, r, "/repos", "读取配置失败 / failed to load config", "err") + return + } + + rp := config.RepoPattern{Pattern: pattern, Events: events, NotifyTo: notifyTo} + if idx >= 0 && idx < len(cfg.Repos.Repos) { + cfg.Repos.Repos[idx] = rp + } else { + cfg.Repos.Repos = append(cfg.Repos.Repos, rp) + } + + if err := SaveYAML(a.cfgDir+"/repos.yaml", cfg.Repos); err != nil { + a.redirectFlash(w, r, "/repos", "保存失败: "+err.Error(), "err") + return + } + a.notifySaved() + a.redirectFlash(w, r, "/repos", "仓库规则已保存 / repo rule saved", "ok") +} + +// handleRepoDelete removes a repo rule by index. +func (a *App) handleRepoDelete(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + a.redirectFlash(w, r, "/repos", "表单解析失败 / invalid form", "err") + return + } + idx, _ := strconv.Atoi(r.FormValue("index")) + cfg, err := a.loadConfig() + if err != nil { + a.redirectFlash(w, r, "/repos", "读取配置失败 / failed to load config", "err") + return + } + if idx < 0 || idx >= len(cfg.Repos.Repos) { + a.redirectFlash(w, r, "/repos", "仓库不存在 / repo not found", "err") + return + } + cfg.Repos.Repos = append(cfg.Repos.Repos[:idx], cfg.Repos.Repos[idx+1:]...) + if err := SaveYAML(a.cfgDir+"/repos.yaml", cfg.Repos); err != nil { + a.redirectFlash(w, r, "/repos", "保存失败: "+err.Error(), "err") + return + } + a.notifySaved() + a.redirectFlash(w, r, "/repos", "仓库规则已删除 / repo rule deleted", "ok") +} + +// repoListRow builds a RepoRow for list display. +func repoListRow(i int, rp config.RepoPattern) RepoRow { + return RepoRow{ + Index: i, + Pattern: rp.Pattern, + NotifyTo: rp.NotifyTo, + EventCount: len(rp.Events), + } +} + +// repoEditRow builds a RepoRow for the edit form (with raw textarea contents). +func repoEditRow(i int, rp config.RepoPattern) RepoRow { + row := RepoRow{ + Index: i, + Pattern: rp.Pattern, + NotifyTo: rp.NotifyTo, + NotifyToRaw: strings.Join(rp.NotifyTo, "\n"), + Events: rp.Events, + EventCount: len(rp.Events), + } + if len(rp.Events) > 0 { + if b, err := yaml.Marshal(rp.Events); err == nil { + row.EventsYAML = strings.TrimRight(string(b), "\n") + } + } + return row +} + +// parseEventsYAML parses the events textarea into a map[string]any. An empty +// textarea yields an empty (non-nil) map so the rule still subscribes to events. +func parseEventsYAML(text string) (map[string]any, error) { + text = strings.TrimSpace(text) + out := map[string]any{} + if text == "" { + return out, nil + } + if err := yaml.Unmarshal([]byte(text), &out); err != nil { + return nil, err + } + return out, nil +} + +// splitLines splits a textarea into trimmed, non-empty lines. +func splitLines(s string) []string { + var res []string + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + if line != "" { + res = append(res, line) + } + } + return res +} diff --git a/internal/panel/handlers_settings.go b/internal/panel/handlers_settings.go new file mode 100644 index 0000000..d7ae849 --- /dev/null +++ b/internal/panel/handlers_settings.go @@ -0,0 +1,124 @@ +package panel + +import ( + "net/http" + "strconv" + "strings" + + "github.com/hnrobert/feishu-github-tracker/internal/auth" +) + +// handleSettings renders the server.yaml editor. +func (a *App) handleSettings(w http.ResponseWriter, r *http.Request) { + data := a.baseData(r) + if cfg, err := a.loadConfig(); err == nil { + s := cfg.Server.Server + data.ServerForm = ServerForm{ + Host: s.Host, + Port: s.Port, + Secret: s.Secret, + LogLevel: s.LogLevel, + MaxPayloadSize: s.MaxPayloadSize, + Timeout: s.Timeout, + AllowedSources: strings.Join(cfg.Server.AllowedSources, "\n"), + } + } + // Show the effective admin username (env > config > "admin"). + if u, _ := resolveCredentials(a.cfgDir); u != "" { + data.ServerForm.Username = u + } + a.renderPage(w, "server_settings", data) +} + +// handleSettingsSave persists server.yaml edits via yaml.Node mutation so all +// existing comments (including the panel `# password: "admin"` hint) are +// preserved. It also handles panel username changes and password rotation +// (which requires the current password). After saving it triggers a reload so +// changes take effect immediately. Port/secret still require a restart. +func (a *App) handleSettingsSave(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + a.redirectFlash(w, r, "/settings", "表单解析失败 / invalid form", "err") + return + } + + port, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("port"))) + timeout, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("timeout"))) + host := strings.TrimSpace(r.FormValue("host")) + secret := strings.TrimSpace(r.FormValue("secret")) + logLevel := strings.TrimSpace(r.FormValue("log_level")) + maxPayload := strings.TrimSpace(r.FormValue("max_payload_size")) + allowed := splitLines(r.FormValue("allowed_sources")) + + newUsername := strings.TrimSpace(r.FormValue("panel_username")) + oldPassword := r.FormValue("panel_old_password") + newPassword := strings.TrimSpace(r.FormValue("panel_password")) + confirmPassword := strings.TrimSpace(r.FormValue("panel_password_confirm")) + + // Validate credential changes BEFORE writing anything. + currentUsername, currentHash := resolveCredentials(a.cfgDir) + usernameChanged := newUsername != "" && newUsername != currentUsername + passwordChanged := newPassword != "" + if passwordChanged { + if newPassword != confirmPassword { + a.redirectFlash(w, r, "/settings", "两次输入的新密码不一致 / new passwords do not match", "err") + return + } + if !auth.VerifyPassword(string(currentHash), oldPassword) { + a.redirectFlash(w, r, "/settings", "旧密码错误,密码未修改 / incorrect old password", "err") + return + } + } + + // 1. Persist server.* fields (+ panel.username) in one comment-preserving write. + root, err := loadServerRoot(a.cfgDir) + if err != nil { + a.redirectFlash(w, r, "/settings", "读取配置失败 / failed to load config", "err") + return + } + serverMap := ensureMap(root, "server") + mapSet(serverMap, "host", host) + if port > 0 { + mapSetPlain(serverMap, "port", strconv.Itoa(port)) + } + mapSet(serverMap, "secret", secret) + mapSet(serverMap, "log_level", logLevel) + mapSet(serverMap, "max_payload_size", maxPayload) + if timeout > 0 { + mapSetPlain(serverMap, "timeout", strconv.Itoa(timeout)) + } + setTopLevelSequence(root, "allowed_sources", allowed) + if usernameChanged { + mapSet(ensureMap(root, "panel"), "username", newUsername) + } + if err := writeServerRoot(a.cfgDir, root); err != nil { + a.redirectFlash(w, r, "/settings", "保存失败: "+err.Error(), "err") + return + } + + // 2. Persist password rotation (separate write: hashing + hint comment). + if passwordChanged { + hash, err := auth.HashPassword(newPassword) + if err != nil { + a.redirectFlash(w, r, "/settings", "密码哈希失败: "+err.Error(), "err") + return + } + if err := SetPanelPasswordHash(a.cfgDir, hash); err != nil { + a.redirectFlash(w, r, "/settings", "密码保存失败: "+err.Error(), "err") + return + } + } + + // 3. Reload so changes take effect immediately. + a.notifySaved() + + flash := "服务设置已保存(端口/密钥需重启生效)/ settings saved (port/secret need restart)" + switch { + case usernameChanged && passwordChanged: + flash = "服务设置、用户名与密码已保存 / settings, username and password saved" + case usernameChanged: + flash = "服务设置与用户名已保存 / settings and username saved" + case passwordChanged: + flash = "服务设置与密码已保存 / settings and password saved" + } + a.redirectFlash(w, r, "/settings", flash, "ok") +} diff --git a/internal/panel/handlers_templates.go b/internal/panel/handlers_templates.go new file mode 100644 index 0000000..1fac865 --- /dev/null +++ b/internal/panel/handlers_templates.go @@ -0,0 +1,147 @@ +package panel + +import ( + "encoding/json" + "net/http" + "path/filepath" + "sort" +) + +// templateFileName maps a logical template name to its on-disk file name. +// "default" -> templates.jsonc; anything else -> templates..jsonc. +func templateFileName(name string) string { + if name == "" || name == "default" { + return "templates.jsonc" + } + return "templates." + name + ".jsonc" +} + +func (a *App) templateFilePath(name string) string { + return filepath.Join(a.cfgDir, templateFileName(name)) +} + +// handleTemplatesList lists every templates.*.jsonc file with its event count. +func (a *App) handleTemplatesList(w http.ResponseWriter, r *http.Request) { + data := a.baseData(r) + if cfg, err := a.loadConfig(); err == nil { + for name, tc := range cfg.Templates { + data.TemplateFilesList = append(data.TemplateFilesList, TemplateFileRow{ + Name: name, + Count: len(tc.Templates), + }) + } + sort.Slice(data.TemplateFilesList, func(i, j int) bool { + return data.TemplateFilesList[i].Name < data.TemplateFilesList[j].Name + }) + } + a.renderPage(w, "templates_list", data) +} + +// handleTemplateEdit shows the payloads for one event in one template file as +// editable JSON. +func (a *App) handleTemplateEdit(w http.ResponseWriter, r *http.Request) { + file := r.URL.Query().Get("file") + event := r.URL.Query().Get("event") + path := a.templateFilePath(file) + + root := map[string]any{} + if err := loadJSONC(path, &root); err != nil { + a.redirectFlash(w, r, "/templates", "读取模板失败: "+err.Error(), "err") + return + } + + events := eventKeys(root) + ed := EditTemplateData{File: file, Events: events, Event: event} + if event != "" { + ed.PayloadsJSON = payloadsJSON(root, event) + } + data := a.baseData(r) + data.EditTemplate = ed + a.renderPage(w, "template_edit", data) +} + +// handleTemplateSave replaces one event's payloads in a templates file. +// +// NOTE: the file is re-marshalled as JSON, which strips // comments and +// alphabetically reorders keys. Functionality is preserved. +func (a *App) handleTemplateSave(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + a.redirectFlash(w, r, "/templates", "表单解析失败 / invalid form", "err") + return + } + file := r.FormValue("file") + event := r.FormValue("event") + text := r.FormValue("payloads_json") + if event == "" { + a.redirectFlash(w, r, "/templates", "未选择事件 / no event selected", "err") + return + } + + var payloads any + if err := json.Unmarshal([]byte(text), &payloads); err != nil { + a.redirectFlash(w, r, "/templates", "payloads JSON 解析失败: "+err.Error(), "err") + return + } + + path := a.templateFilePath(file) + root := map[string]any{} + if err := loadJSONC(path, &root); err != nil { + a.redirectFlash(w, r, "/templates", "读取模板失败: "+err.Error(), "err") + return + } + + templatesNode, _ := root["templates"].(map[string]any) + if templatesNode == nil { + templatesNode = map[string]any{} + root["templates"] = templatesNode + } + eventNode, _ := templatesNode[event].(map[string]any) + if eventNode == nil { + eventNode = map[string]any{} + } + eventNode["payloads"] = payloads + templatesNode[event] = eventNode + + if err := SaveJSON(path, root); err != nil { + a.redirectFlash(w, r, "/templates", "保存失败: "+err.Error(), "err") + return + } + a.notifySaved() + a.redirectFlash(w, r, "/templates", "模板已保存(注释与格式会被重排)/ template saved (comments/format reformatted)", "ok") +} + +// eventKeys returns the sorted event keys present in a parsed templates file. +func eventKeys(root map[string]any) []string { + templatesNode, ok := root["templates"].(map[string]any) + if !ok { + return nil + } + keys := make([]string, 0, len(templatesNode)) + for k := range templatesNode { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// payloadsJSON returns the indented JSON for one event's payloads array. +func payloadsJSON(root map[string]any, event string) string { + templatesNode, _ := root["templates"].(map[string]any) + if templatesNode == nil { + return "[]" + } + eventNode, _ := templatesNode[event].(map[string]any) + if eventNode == nil { + return "[]" + } + payloads, _ := eventNode["payloads"].([]any) + if payloads == nil { + // payloads may be missing (means "default payload"); expose empty array. + return "[]" + } + b, err := json.MarshalIndent(payloads, "", " ") + if err != nil { + return "[]" + } + return string(b) +} diff --git a/internal/panel/middleware.go b/internal/panel/middleware.go new file mode 100644 index 0000000..e55f674 --- /dev/null +++ b/internal/panel/middleware.go @@ -0,0 +1,91 @@ +package panel + +import ( + "context" + "net/http" + "strings" + + "github.com/hnrobert/feishu-github-tracker/internal/auth" +) + +type ctxKey string + +const ( + ctxUsername ctxKey = "username" +) + +// withAuthContext parses the session (cookie or Bearer token) and injects the +// username into the request context. Applied to the whole mux. +func (a *App) withAuthContext(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if username := a.readAuth(r); username != "" { + ctx := context.WithValue(r.Context(), ctxUsername, username) + r = r.WithContext(ctx) + } + next.ServeHTTP(w, r) + }) +} + +// readAuth returns the authenticated username from a cookie or Authorization +// header, or "" if absent/invalid. +func (a *App) readAuth(r *http.Request) string { + if c, err := r.Cookie(a.cookieName); err == nil && c.Value != "" { + if cl, err := auth.ParseHS256(a.secret, c.Value); err == nil { + return cl.Username + } + } + authz := r.Header.Get("Authorization") + if authz != "" { + parts := strings.SplitN(authz, " ", 2) + if len(parts) == 2 && strings.EqualFold(parts[0], "bearer") { + if cl, err := auth.ParseHS256(a.secret, strings.TrimSpace(parts[1])); err == nil { + return cl.Username + } + } + } + return "" +} + +func usernameFrom(r *http.Request) string { + if v := r.Context().Value(ctxUsername); v != nil { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// requireAuth redirects unauthenticated requests to /login. +func (a *App) requireAuth(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if usernameFrom(r) == "" { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + h(w, r) + } +} + +func (a *App) issueCookie(w http.ResponseWriter, token string) { + http.SetCookie(w, &http.Cookie{ + Name: a.cookieName, + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: false, + MaxAge: int(sessionTTL.Seconds()), + }) +} + +func (a *App) clearCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: a.cookieName, + Value: "", + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: false, + MaxAge: -1, + }) +} diff --git a/internal/panel/serveryaml.go b/internal/panel/serveryaml.go new file mode 100644 index 0000000..da0a003 --- /dev/null +++ b/internal/panel/serveryaml.go @@ -0,0 +1,190 @@ +package panel + +import ( + "bytes" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// topMap returns the top-level mapping node of a decoded YAML document. +func topMap(root *yaml.Node) *yaml.Node { + if root == nil { + return nil + } + if root.Kind == yaml.DocumentNode && len(root.Content) > 0 { + return root.Content[0] + } + if root.Kind == yaml.MappingNode { + return root + } + return nil +} + +// mapGet returns the value node for key in a mapping node, or nil. +func mapGet(m *yaml.Node, key string) *yaml.Node { + if m == nil || m.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + return m.Content[i+1] + } + } + return nil +} + +// mapSet sets a double-quoted scalar value for key in a mapping, adding the +// key/value pair if missing. Returns the value node. +func mapSet(m *yaml.Node, key, value string) *yaml.Node { + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + val := m.Content[i+1] + val.Kind = yaml.ScalarNode + val.Tag = "" // let the encoder infer str implicitly (avoid leaking !) + val.Value = value + val.Style = yaml.DoubleQuotedStyle + return val + } + } + m.Content = append(m.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "", Value: key}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "", Value: value, Style: yaml.DoubleQuotedStyle}, + ) + return m.Content[len(m.Content)-1] +} + +// mapDelete removes key (and its value) from a mapping node, if present. +func mapDelete(m *yaml.Node, key string) { + if m == nil || m.Kind != yaml.MappingNode { + return + } + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + m.Content = append(m.Content[:i], m.Content[i+2:]...) + return + } + } +} + +// mapSetPlain is like mapSet but emits a plain (unquoted) scalar, for numeric +// values such as port and timeout. +func mapSetPlain(m *yaml.Node, key, value string) *yaml.Node { + n := mapSet(m, key, value) + n.Style = 0 + return n +} + +// ensureMap returns the top-level mapping for key, creating it if absent. +func ensureMap(root *yaml.Node, key string) *yaml.Node { + tm := topMap(root) + if n := mapGet(tm, key); n != nil && n.Kind == yaml.MappingNode { + return n + } + tm.Content = append(tm.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: key}, + &yaml.Node{Kind: yaml.MappingNode}, + ) + return tm.Content[len(tm.Content)-1] +} + +// setTopLevelSequence sets a top-level key to a sequence of double-quoted string +// items, replacing any existing value (used for allowed_sources). +func setTopLevelSequence(root *yaml.Node, key string, values []string) { + tm := topMap(root) + var seq *yaml.Node + for i := 0; i+1 < len(tm.Content); i += 2 { + if tm.Content[i].Value == key { + seq = tm.Content[i+1] + break + } + } + if seq == nil { + tm.Content = append(tm.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: key}, + &yaml.Node{Kind: yaml.SequenceNode}, + ) + seq = tm.Content[len(tm.Content)-1] + } + seq.Kind = yaml.SequenceNode + seq.Tag = "" + seq.Content = nil + for _, v := range values { + seq.Content = append(seq.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: v, Style: yaml.DoubleQuotedStyle}) + } +} + +// reorderPanel reorders a panel mapping's key/value pairs into a canonical order +// (enabled, username, password, password_hash, secret). Keys not listed keep +// their original relative order, appended after. Comments attached to nodes +// travel with their nodes. This guarantees password_hash is followed by secret +// so a HeadComment on password_hash renders as a stable standalone line. +func reorderPanel(panel *yaml.Node) { + if panel == nil || panel.Kind != yaml.MappingNode { + return + } + order := []string{"enabled", "username", "password", "password_hash", "secret"} + keys := map[string]*yaml.Node{} // key value -> key node + vals := map[string]*yaml.Node{} // key value -> value node + var seen []string + for i := 0; i+1 < len(panel.Content); i += 2 { + k := panel.Content[i].Value + if _, dup := keys[k]; !dup { + seen = append(seen, k) + } + keys[k] = panel.Content[i] + vals[k] = panel.Content[i+1] + } + inOrder := map[string]bool{} + for _, o := range order { + inOrder[o] = true + } + var extras []string + for _, k := range seen { + if !inOrder[k] { + extras = append(extras, k) + } + } + + var content []*yaml.Node + for _, o := range order { + if _, ok := keys[o]; ok { + content = append(content, keys[o], vals[o]) + } + } + for _, e := range extras { + content = append(content, keys[e], vals[e]) + } + panel.Content = content +} + +// loadServerRoot decodes server.yaml into a yaml.Node tree (preserving comments). +func loadServerRoot(cfgDir string) (*yaml.Node, error) { + data, err := os.ReadFile(filepath.Join(cfgDir, "server.yaml")) + if err != nil { + return nil, err + } + var root yaml.Node + if err := yaml.Unmarshal(data, &root); err != nil { + return nil, err + } + return &root, nil +} + +// writeServerRoot encodes a node tree back to server.yaml atomically, using a +// 2-space indent to match the project's existing YAML style. +func writeServerRoot(cfgDir string, root *yaml.Node) error { + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(root); err != nil { + return err + } + if err := enc.Close(); err != nil { + return err + } + writeMu.Lock() + defer writeMu.Unlock() + return atomicWriteFile(filepath.Join(cfgDir, "server.yaml"), buf.Bytes(), 0o644) +} diff --git a/internal/panel/static/logo.jpg b/internal/panel/static/logo.jpg new file mode 100644 index 0000000..8b2aa52 Binary files /dev/null and b/internal/panel/static/logo.jpg differ diff --git a/internal/panel/templates/bot_edit.html b/internal/panel/templates/bot_edit.html new file mode 100644 index 0000000..98db61c --- /dev/null +++ b/internal/panel/templates/bot_edit.html @@ -0,0 +1,30 @@ +{{define "title"}}编辑机器人 · Feishu Bot{{end}} +{{define "content"}} +
+

{{if lt .EditBot.Index 0}}新建机器人 / New Bot{{else}}编辑机器人 / Edit Bot{{end}}

+
别名用于在仓库规则中引用此机器人。 / The alias is how repo rules reference this bot.
+
+ +
+ + + + + + + + + + + +
+ + 取消 / Cancel +
+
+{{end}} diff --git a/internal/panel/templates/bots.html b/internal/panel/templates/bots.html new file mode 100644 index 0000000..a1d5e87 --- /dev/null +++ b/internal/panel/templates/bots.html @@ -0,0 +1,47 @@ +{{define "title"}}飞书机器人 · Feishu Bots{{end}} +{{define "content"}} +
+
+

飞书机器人 / Feishu Bots

+
机器人别名与 Webhook URL,在仓库规则中以 alias 引用。 / Bot aliases + webhook URLs, referenced by alias in repo rules.
+
+ + 新建 / New +
+ +
+ {{if .Bots}} + + + + + + + + + + + + {{range .Bots}} + + + + + + + + {{end}} + +
#别名 / AliasWebhook URL模板 / Template
{{.Index}}{{.Alias}}{{.URL}}{{if .Template}}{{.Template}}{{else}}default{{end}} +
+ 编辑 / Edit +
+ + +
+
+
+ {{else}} +
暂无机器人,点击「新建」添加。 / No bots yet — click “New”.
+ {{end}} +
+{{end}} diff --git a/internal/panel/templates/dashboard.html b/internal/panel/templates/dashboard.html new file mode 100644 index 0000000..bf40c2c --- /dev/null +++ b/internal/panel/templates/dashboard.html @@ -0,0 +1,72 @@ +{{define "title"}}仪表盘 · Dashboard{{end}} +{{define "content"}} +
+

仪表盘 / Dashboard

+
配置概览与最近投递活动。 / Configuration overview and recent delivery activity.
+
+ +
+
+
{{.RepoCount}}
+
仓库规则 / Repo rules
+
+
+
{{.BotCount}}
+
飞书机器人 / Feishu bots
+
+
+
{{.EventSetCount}}
+
事件集合 / Event sets
+
+
+
{{len .TemplateFiles}}
+
模板文件 / Template files
+
+
+ +
+
+

服务信息 / Server

+
+
监听 / Listen
+
{{.ServerInfo.Host}}:{{.ServerInfo.Port}}
+
日志级别 / Log level
+
{{if .ServerInfo.LogLevel}}{{.ServerInfo.LogLevel}}{{else}}—{{end}}
+
超时 / Timeout
+
{{if .ServerInfo.Timeout}}{{.ServerInfo.Timeout}}s{{else}}—{{end}}
+
最大载荷 / Max payload
+
{{if .ServerInfo.MaxPayloadSize}}{{.ServerInfo.MaxPayloadSize}}{{else}}—{{end}}
+
面板 / Panel
+
{{if .ServerInfo.PanelEnabled}}已启用 / enabled{{else}}未启用 / disabled{{end}}
+
允许来源 / Allowed
+
+ {{if .ServerInfo.AllowedSources}}{{range .ServerInfo.AllowedSources}}{{.}}{{end}}{{else}}—{{end}} +
+
+
+ +
+

模板文件 / Template files

+ {{if .TemplateFiles}} +
+ {{range .TemplateFiles}}{{.}}{{end}} +
+ {{else}} +
无模板文件 / none
+ {{end}} +
在「消息模板 / Templates」页面编辑各事件卡片。 / Edit per-event cards under Templates.
+
+
+ +
+

最近投递 / Recent deliveries

+
来自日志文件尾部(只读)。 / Tailed from the log file (read-only).
+ {{if .RecentLines}} +
+ {{range .RecentLines}}
{{.}}
{{end}} +
+ {{else}} +
暂无投递记录 / no recent activity
+ {{end}} +
+{{end}} diff --git a/internal/panel/templates/events.html b/internal/panel/templates/events.html new file mode 100644 index 0000000..edb1e5a --- /dev/null +++ b/internal/panel/templates/events.html @@ -0,0 +1,16 @@ +{{define "title"}}事件 · Events{{end}} +{{define "content"}} +
+

事件 / Events

+
编辑 events.yaml 原文(event_setsevents)。保存时会校验 YAML 语法。 / Edit the raw events.yaml; YAML is validated on save.
+
+ +
+ + +
注释与格式将被完整保留。 / Comments and formatting are preserved verbatim.
+
+ +
+
+{{end}} diff --git a/internal/panel/templates/layout.html b/internal/panel/templates/layout.html new file mode 100644 index 0000000..260bd36 --- /dev/null +++ b/internal/panel/templates/layout.html @@ -0,0 +1,729 @@ +{{define "nav"}} + +{{end}} + +{{define "layout"}} + + + + + + + {{block "title" .}}Feishu GitHub Tracker{{end}} + + + + + + + + + + {{if .HideNav}} +
+
+
+ {{if .Flash}} +
{{.Flash}}
+ {{end}} + {{block "content" .}}{{end}} +
+
+
+ {{else}} + + +
+
+ + Feishu GitHub Tracker +
+ {{if .Authed}} +
+ +
+ {{end}} +
+ +
+ + +
+
+
+ {{if .Authed}} + {{.Username}} +
+ +
+ {{end}} +
+ + {{if .Flash}} +
{{.Flash}}
+ {{end}} + + {{block "content" .}}{{end}} +
+
+
+ + + +
+ Feishu GitHub Tracker · GitHub → 飞书 webhook 转发 / forwarder.
+ 面板内修改保存后会自动 reload;手动编辑配置文件则需以 -reload 启动或重启进程。 / Panel edits auto-reload on save; hand-edited files apply via -reload or restart. +
+ {{end}} + + + +{{end}} diff --git a/internal/panel/templates/login.html b/internal/panel/templates/login.html new file mode 100644 index 0000000..98c3005 --- /dev/null +++ b/internal/panel/templates/login.html @@ -0,0 +1,21 @@ +{{define "title"}}登录 · Login · Feishu GitHub Tracker{{end}} +{{define "content"}} +
+
+ +

登录 / Login

+
+
+ 输入管理员密码以进入管理面板。
Enter the admin password to access the management panel. +
+
+ + + + +
+ +
+
+
+{{end}} diff --git a/internal/panel/templates/repo_edit.html b/internal/panel/templates/repo_edit.html new file mode 100644 index 0000000..5ff65bc --- /dev/null +++ b/internal/panel/templates/repo_edit.html @@ -0,0 +1,35 @@ +{{define "title"}}编辑仓库规则 · Repo Rule{{end}} +{{define "content"}} +
+

{{if lt .EditRepo.Index 0}}新建仓库规则 / New Repo Rule{{else}}编辑仓库规则 / Edit Repo Rule{{end}}

+
事件以 YAML 书写;通知目标每行一个(alias 或完整 URL)。 / Events as YAML; one notify target per line (alias or full URL).
+
+ +
+ + + + + +
+
+ + +
+
+ + +
+
+ +
+ 事件可直接引用 events.yaml 中的事件(如 push:),或叠加 event_sets 中的模板名称。 + / Events may reference events.yaml entries (e.g. push:) or compose event_sets names. +
+ +
+ + 取消 / Cancel +
+
+{{end}} diff --git a/internal/panel/templates/repos.html b/internal/panel/templates/repos.html new file mode 100644 index 0000000..2fed551 --- /dev/null +++ b/internal/panel/templates/repos.html @@ -0,0 +1,51 @@ +{{define "title"}}仓库规则 · Repos{{end}} +{{define "content"}} +
+
+

仓库规则 / Repo Rules

+
仓库匹配模式 → 订阅事件 → 通知目标。按顺序匹配。 / Patterns → events → notify targets. Matched in order.
+
+ + 新建 / New +
+ +
+ {{if .Repos}} + + + + + + + + + + + + {{range .Repos}} + + + + + + + + {{end}} + +
#模式 / Pattern事件 / Events通知 / Notify to
{{.Index}}{{.Pattern}} + {{if gt .EventCount 0}}{{.EventCount}} 个 / events{{else}}0{{end}} + + {{if .NotifyTo}}{{range .NotifyTo}}{{.}}{{end}}{{else}}{{end}} + +
+ 编辑 / Edit +
+ + +
+
+
+ {{else}} +
暂无仓库规则,点击「新建」添加。 / No repo rules yet — click “New”.
+ {{end}} +
+{{end}} diff --git a/internal/panel/templates/server_settings.html b/internal/panel/templates/server_settings.html new file mode 100644 index 0000000..ab544a1 --- /dev/null +++ b/internal/panel/templates/server_settings.html @@ -0,0 +1,74 @@ +{{define "title"}}服务设置 · Server Settings{{end}} +{{define "content"}} +
+

服务设置 / Server Settings

+
编辑 server.yaml。端口与密钥的修改需重启进程后生效。 / Edit server.yaml. Port and secret changes need a restart.
+
+ +
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + + + +
+ +

面板账号 / Panel account

+ + + + + + + + + + + + +
修改密码需提供当前密码,并输入两次相同的新密码;新密码将生成 bcrypt 哈希写入 password_hash。 / Changing the password requires the current password and entering the new password twice; it is stored as a bcrypt password_hash.
+ +
+ +
+
+ ⚠️ 保存会重新格式化 server.yaml(移除注释)。端口 / 密钥 / 面板 secret 改动需重启进程;其它改动保存后会自动 reload 生效。 / Saving reformats server.yaml (comments removed). Port / secret / panel-secret changes require a restart; everything else is reloaded automatically on save. +
+
+{{end}} diff --git a/internal/panel/templates/template_edit.html b/internal/panel/templates/template_edit.html new file mode 100644 index 0000000..bbaad31 --- /dev/null +++ b/internal/panel/templates/template_edit.html @@ -0,0 +1,44 @@ +{{define "title"}}编辑模板 · Template{{end}} +{{define "content"}} +
+

编辑模板 / Edit Template

+
文件 {{if eq .EditTemplate.File ""}}default{{else}}{{.EditTemplate.File}}{{end}}{{if .EditTemplate.Event}} · 事件 {{.EditTemplate.Event}}{{end}}
+
+ + + +
+ + {{if .EditTemplate.Events}} +
+ {{range .EditTemplate.Events}} + {{.}} + {{end}} +
+ {{else}} +
该文件无事件 / no events in this file
+ {{end}} +
+ +{{if .EditTemplate.Event}} +
+ + + + + +
+ ⚠️ 保存会移除该文件的 // 注释并按字母重排键。功能不变。 / Saving strips // comments and reorders keys alphabetically. Functionality is unchanged. +
+ +
+ + 取消 / Cancel +
+
+{{end}} +{{end}} diff --git a/internal/panel/templates/templates_list.html b/internal/panel/templates/templates_list.html new file mode 100644 index 0000000..b1a3c44 --- /dev/null +++ b/internal/panel/templates/templates_list.html @@ -0,0 +1,23 @@ +{{define "title"}}消息模板 · Templates{{end}} +{{define "content"}} +
+

消息模板 / Templates

+
飞书卡片模板(templates.*.jsonc)。选择文件后按事件编辑其 payloads。 / Feishu card templates. Pick a file, then edit an event's payloads.
+
+ +{{if .TemplateFilesList}} +{{range .TemplateFilesList}} +
+
+
+

{{if eq .Name "default"}}templates.jsonc{{else}}templates.{{.Name}}.jsonc{{end}}

+
模板名 / name: {{.Name}} · {{.Count}} 个事件 / events
+
+ 浏览事件 / Browse events +
+
+{{end}} +{{else}} +
未发现模板文件 / no template files found
+{{end}} +{{end}} diff --git a/internal/template/template.go b/internal/template/template.go index ea08981..57e5090 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -4,23 +4,63 @@ import ( "encoding/json" "fmt" "regexp" + "slices" "strings" "github.com/hnrobert/feishu-github-tracker/internal/config" "github.com/hnrobert/feishu-github-tracker/internal/logger" ) -// SelectTemplate selects the appropriate template based on event type and tags +// SelectTemplate selects the appropriate template based on event type and tags. +// +// Matching prefers the most specific payload whose tags are ALL present in the +// event tags (i.e. the payload's tags are a subset of the event's tags). This +// prevents a partially-matching payload — for example an issue "bug" card whose +// tags are ["opened", "type:bug"] — from winning over the correct generic card +// for a normal issue, just because both share the "opened" tag. Among subset +// matches the payload with the most tags (most specific) wins; ties keep the +// first payload in file order. func SelectTemplate(eventType string, tags []string, templates config.TemplatesConfig) (map[string]any, error) { eventTemplate, exists := templates.Templates[eventType] if !exists { return nil, fmt.Errorf("no template found for event type: %s", eventType) } - // Find the best matching payload based on tags + eventSet := make(map[string]struct{}, len(tags)) + for _, t := range tags { + eventSet[t] = struct{}{} + } + + // 1) Best subset match: every payload tag must be present among the event + // tags; the payload with the most tags wins. + var best *config.PayloadTemplate + bestSpec := -1 + for i := range eventTemplate.Payloads { + payload := &eventTemplate.Payloads[i] + if !tagsAllIn(payload.Tags, eventSet) { + continue + } + if len(payload.Tags) > bestSpec { + bestSpec = len(payload.Tags) + best = payload + } + } + if best != nil { + return best.Payload, nil + } + + // 2) Fallback: a payload explicitly tagged "default". + for i := range eventTemplate.Payloads { + payload := &eventTemplate.Payloads[i] + if slices.Contains(payload.Tags, "default") { + return payload.Payload, nil + } + } + + // 3) Last resort: highest partial-overlap score (legacy behavior), so an + // event with no exact subset match still resolves to something. var selectedPayload *config.PayloadTemplate maxMatchScore := -1 - for i := range eventTemplate.Payloads { payload := &eventTemplate.Payloads[i] score := calculateMatchScore(tags, payload.Tags) @@ -37,7 +77,18 @@ func SelectTemplate(eventType string, tags []string, templates config.TemplatesC return selectedPayload.Payload, nil } -// calculateMatchScore calculates how well the tags match +// tagsAllIn reports whether every tag in payloadTags is present in eventSet. +func tagsAllIn(payloadTags []string, eventSet map[string]struct{}) bool { + for _, t := range payloadTags { + if _, ok := eventSet[t]; !ok { + return false + } + } + return true +} + +// calculateMatchScore counts how many of the template's tags appear among the +// event tags. Used only as a last-resort fallback in SelectTemplate. func calculateMatchScore(eventTags, templateTags []string) int { score := 0 for _, eventTag := range eventTags { diff --git a/internal/template/template_test.go b/internal/template/template_test.go index c7e9afd..0cc6d33 100644 --- a/internal/template/template_test.go +++ b/internal/template/template_test.go @@ -3,6 +3,8 @@ package template import ( "reflect" "testing" + + "github.com/hnrobert/feishu-github-tracker/internal/config" ) func TestFillTemplate_NestedPlaceholder(t *testing.T) { @@ -29,3 +31,85 @@ func TestFillTemplate_NestedPlaceholder(t *testing.T) { t.Fatalf("expected different map after fill") } } + +// issueTemplates builds a small issue template set mirroring the real +// templates.jsonc ordering (bug-specific payloads first, then generic ones). +func issueTemplates() config.TemplatesConfig { + mk := func(tags []string, title string) config.PayloadTemplate { + return config.PayloadTemplate{ + Tags: tags, + Payload: map[string]any{ + "msg_type": "text", + "content": map[string]any{"text": title}, + }, + } + } + return config.TemplatesConfig{ + Templates: map[string]config.EventTemplate{ + "issues": {Payloads: []config.PayloadTemplate{ + mk([]string{"opened", "type:bug"}, "BUG-OPENED"), + mk([]string{"opened", "type:feature"}, "FEATURE-OPENED"), + mk([]string{"opened"}, "ISSUE-OPENED"), + mk([]string{"closed", "type:bug"}, "BUG-CLOSED"), + mk([]string{"closed"}, "ISSUE-CLOSED"), + mk([]string{"type:unknown"}, "ISSUE-UNKNOWN"), + mk([]string{"default"}, "ISSUE-DEFAULT"), + }}, + }, + } +} + +func titleOf(t map[string]any) string { + if c, ok := t["content"].(map[string]any); ok { + if s, ok := c["text"].(string); ok { + return s + } + } + return "" +} + +func TestSelectTemplate_PlainIssueNotBug(t *testing.T) { + tt := issueTemplates() + // A normal opened issue with no bug/feature/task label. + got, err := SelectTemplate("issues", []string{"issues", "opened", "type:unknown"}, tt) + if err != nil { + t.Fatalf("SelectTemplate error: %v", err) + } + if titleOf(got) == "BUG-OPENED" { + t.Fatalf("plain issue must not use the bug card; got BUG-OPENED") + } +} + +func TestSelectTemplate_BugIssueUsesBugCard(t *testing.T) { + tt := issueTemplates() + got, err := SelectTemplate("issues", []string{"issues", "opened", "type:bug"}, tt) + if err != nil { + t.Fatalf("SelectTemplate error: %v", err) + } + if titleOf(got) != "BUG-OPENED" { + t.Fatalf("bug issue should use the bug card; got %q", titleOf(got)) + } +} + +func TestSelectTemplate_MostSpecificWins(t *testing.T) { + tt := issueTemplates() + // opened + type:bug: the 2-tag payload beats the 1-tag "opened" payload. + got, err := SelectTemplate("issues", []string{"issues", "opened", "type:bug"}, tt) + if err != nil { + t.Fatalf("SelectTemplate error: %v", err) + } + if titleOf(got) != "BUG-OPENED" { + t.Fatalf("most specific (opened+type:bug) should win; got %q", titleOf(got)) + } +} + +func TestSelectTemplate_ClosedBugBeatsGenericClosed(t *testing.T) { + tt := issueTemplates() + got, err := SelectTemplate("issues", []string{"issues", "closed", "type:bug"}, tt) + if err != nil { + t.Fatalf("SelectTemplate error: %v", err) + } + if titleOf(got) != "BUG-CLOSED" { + t.Fatalf("closed bug should use BUG-CLOSED; got %q", titleOf(got)) + } +}