From f54a174f0c94fb730dd9ebe758424565173bc72a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dhji=28=EC=A7=80=EB=8C=80=ED=95=9C=29?= Date: Thu, 11 Jun 2026 15:27:53 +0900 Subject: [PATCH] feat: test --- .env.example | 9 ++- internal/config/config.go | 4 +- internal/gitea/gitea.go | 97 ++++++++++++++++++++++++ internal/logging/alert.go | 144 ++++++++++++++++++++++++++++++++++++ internal/logging/logging.go | 9 ++- internal/server/server.go | 2 +- internal/server/webhooks.go | 19 ++++- internal/slack/slack.go | 39 +++++----- main.go | 6 +- 9 files changed, 302 insertions(+), 27 deletions(-) create mode 100644 internal/logging/alert.go diff --git a/.env.example b/.env.example index e88b98a..a7fe2e8 100644 --- a/.env.example +++ b/.env.example @@ -2,9 +2,16 @@ # Bot User OAuth Token (xoxb-...). Scopes 필요: chat:write, users:read.email, im:write SLACK_BOT_TOKEN=xoxb-your-token -# 담당자 이메일을 Slack에서 못 찾았을 때 보낼 fallback 채널(선택). 비우면 그냥 skip. +# 기본 Slack 채널(채널 ID C... 또는 #채널명). 두 가지 용도: +# 1) 수신한 모든 이벤트(push 포함)를 이 채널에 요약 브로드캐스트 +# 2) 담당자 이메일을 Slack에서 못 찾았을 때 fallback +# 비우면 채널 출력/ fallback 모두 안 함(담당자 DM만). DEFAULT_SLACK_CHANNEL= +# 에러(slog.Error) 발생 시 알림을 보낼 모니터링 채널(선택, 예: monitor-dev). +# 채널 ID(C...) 권장. 봇을 해당 채널에 초대해야 함. 비우면 에러 알림 안 보냄(로그만). +MONITOR_SLACK_CHANNEL= + # --- Gitea --- # Gitea webhook 설정 시 입력한 Secret. HMAC-SHA256 서명 검증에 사용. GITEA_WEBHOOK_SECRET= diff --git a/internal/config/config.go b/internal/config/config.go index 23c7ad0..ecb3a0f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,7 +11,8 @@ import ( type Config struct { // Slack SlackBotToken string - DefaultSlackChannel string // fallback channel when a recipient email can't be resolved + DefaultSlackChannel string // 모든 이벤트 브로드캐스트 채널 + MonitorChannel string // 에러(slog.Error) 발생 시 알림을 보낼 채널 (예: monitor-dev) // Gitea GiteaWebhookSecret string @@ -58,6 +59,7 @@ func Load(envOverride string) Config { return Config{ SlackBotToken: os.Getenv("SLACK_BOT_TOKEN"), DefaultSlackChannel: os.Getenv("DEFAULT_SLACK_CHANNEL"), + MonitorChannel: os.Getenv("MONITOR_SLACK_CHANNEL"), GiteaWebhookSecret: os.Getenv("GITEA_WEBHOOK_SECRET"), NotionVerificationToken: os.Getenv("NOTION_VERIFICATION_TOKEN"), NotionAPIToken: os.Getenv("NOTION_API_TOKEN"), diff --git a/internal/gitea/gitea.go b/internal/gitea/gitea.go index 0349f2d..875ab45 100644 --- a/internal/gitea/gitea.go +++ b/internal/gitea/gitea.go @@ -62,6 +62,11 @@ type review struct { Type string `json:"type"` } +type commitInfo struct { + Message string `json:"message"` + URL string `json:"url"` +} + type payload struct { Action string `json:"action"` Number int `json:"number"` @@ -74,6 +79,11 @@ type payload struct { Comment *comment `json:"comment"` Review *review `json:"review"` RequestedReviewer *user `json:"requested_reviewer"` + + // push 이벤트용 + Ref string `json:"ref"` + Commits []commitInfo `json:"commits"` + Pusher *user `json:"pusher"` } func assigneesOf(single *user, list []user) []user { @@ -114,6 +124,93 @@ func BuildNotifications(event string, body []byte) ([]notify.Notification, error return dedupe(notes), nil } +// BuildChannelMessage builds a single summary message for the broadcast channel. +// Unlike BuildNotifications (DM, 담당자 한정), this covers ALL events — push 포함, +// 처리 규칙이 없는 이벤트도 일반 요약으로 항상 메시지를 만든다. +func BuildChannelMessage(event string, body []byte) (notify.Notification, bool) { + var p payload + if err := json.Unmarshal(body, &p); err != nil { + return notify.Notification{}, false + } + + repo := p.Repository.FullName + actor := p.Sender.login() + if actor == "" { + actor = p.Pusher.login() + } + ctx := "" + if actor != "" { + ctx = "by " + actor + } + + switch event { + case "push": + branch := strings.TrimPrefix(p.Ref, "refs/heads/") + title := fmt.Sprintf("📤 [%s] %s에 커밋 %d개 push", repo, branch, len(p.Commits)) + lines := make([]string, 0, len(p.Commits)) + for _, cm := range p.Commits { + msg := strings.SplitN(cm.Message, "\n", 2)[0] + lines = append(lines, fmt.Sprintf("• <%s|%s>", cm.URL, msg)) + } + bodyText := strings.Join(lines, "\n") + if bodyText == "" { + bodyText = "(커밋 정보 없음)" + } + return channelNote(title, bodyText, ctx), true + + case "pull_request": + if pr := p.PullRequest; pr != nil { + title := fmt.Sprintf("🔀 [%s] PR #%d %s", repo, pr.Number, withAction(p.Action)) + return channelNote(title, fmt.Sprintf("<%s|%s>", pr.HTMLURL, pr.Title), ctx), true + } + + case "issues": + if iss := p.Issue; iss != nil { + title := fmt.Sprintf("📋 [%s] 이슈 #%d %s", repo, iss.Number, withAction(p.Action)) + return channelNote(title, fmt.Sprintf("<%s|%s>", iss.HTMLURL, iss.Title), ctx), true + } + + case "issue_comment": + if iss := p.Issue; iss != nil { + url := iss.HTMLURL + if p.Comment != nil && p.Comment.HTMLURL != "" { + url = p.Comment.HTMLURL + } + title := fmt.Sprintf("💬 [%s] #%d 새 댓글", repo, iss.Number) + return channelNote(title, fmt.Sprintf("<%s|%s>", url, iss.Title), ctx), true + } + + case "pull_request_review": + if pr := p.PullRequest; pr != nil { + title := fmt.Sprintf("📝 [%s] PR #%d 리뷰", repo, pr.Number) + return channelNote(title, fmt.Sprintf("<%s|%s>", pr.HTMLURL, pr.Title), ctx), true + } + } + + // 그 외/예상 밖 이벤트도 빠짐없이 채널에 남긴다. + title := fmt.Sprintf("📣 [%s] %s 이벤트", repo, event) + detail := "이벤트가 수신되었습니다." + if p.Action != "" { + detail = "action: " + p.Action + } + return channelNote(title, detail, ctx), true +} + +// withAction은 액션이 있으면 "(action)" 형태로 덧붙인다. +func withAction(action string) string { + if action == "" { + return "" + } + return "(" + action + ")" +} + +func channelNote(title, body, context string) notify.Notification { + return notify.Notification{ + Text: title, + Blocks: notify.SimpleBlocks(title, body, context), + } +} + func handlePullRequest(p *payload, repo, sender string) []notify.Notification { pr := p.PullRequest if pr == nil { diff --git a/internal/logging/alert.go b/internal/logging/alert.go new file mode 100644 index 0000000..2a26bf1 --- /dev/null +++ b/internal/logging/alert.go @@ -0,0 +1,144 @@ +package logging + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + "time" + + "git/palnet/slack-notifier/internal/notify" +) + +// AlertPoster는 에러 알림을 Slack 채널로 보내는 최소 인터페이스. +// 실패해도 slog를 호출하지 않아야 한다(재귀 방지) — slack.Client.PostAlert가 이를 만족. +type AlertPoster interface { + PostAlert(ctx context.Context, channel, text string, blocks []notify.Block) error +} + +// slackAlertHandler는 다른 slog.Handler를 감싸, Error 이상 레벨의 로그를 +// 지정한 Slack 채널로도 함께 알린다(보기 좋은 Block Kit 형식). +type slackAlertHandler struct { + base slog.Handler + poster AlertPoster + channel string + attrs []slog.Attr // With로 누적된 속성(service/env 등) +} + +func newSlackAlertHandler(base slog.Handler, poster AlertPoster, channel string) *slackAlertHandler { + return &slackAlertHandler{base: base, poster: poster, channel: channel} +} + +func (h *slackAlertHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.base.Enabled(ctx, level) +} + +func (h *slackAlertHandler) Handle(ctx context.Context, rec slog.Record) error { + if rec.Level >= slog.LevelError { + attrs := make([]slog.Attr, 0, len(h.attrs)+rec.NumAttrs()) + attrs = append(attrs, h.attrs...) + rec.Attrs(func(a slog.Attr) bool { + attrs = append(attrs, a) + return true + }) + text, blocks := buildAlert(rec.Level, rec.Message, rec.Time, attrs) + // 비동기 — 로깅 경로를 막지 않게. 발송 실패는 stderr로만(다시 slog로 가면 무한루프). + go func() { + if err := h.poster.PostAlert(context.Background(), h.channel, text, blocks); err != nil { + fmt.Fprintf(os.Stderr, "alert post failed: %v\n", err) + } + }() + } + return h.base.Handle(ctx, rec) +} + +func (h *slackAlertHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + merged := make([]slog.Attr, 0, len(h.attrs)+len(attrs)) + merged = append(merged, h.attrs...) + merged = append(merged, attrs...) + return &slackAlertHandler{base: h.base.WithAttrs(attrs), poster: h.poster, channel: h.channel, attrs: merged} +} + +func (h *slackAlertHandler) WithGroup(name string) slog.Handler { + return &slackAlertHandler{base: h.base.WithGroup(name), poster: h.poster, channel: h.channel, attrs: h.attrs} +} + +// buildAlert는 로그 레코드를 보기 좋은 Slack 메시지로 만든다. +// 맨 앞에 프로젝트(service)와 환경(env)을 표시해 어느 프로젝트의 에러인지 한눈에 보이게 한다. +func buildAlert(level slog.Level, msg string, t time.Time, attrs []slog.Attr) (string, []notify.Block) { + // service/env는 머리말로 분리, 나머지는 상세 필드로. + service, env := "", "" + var fields []notify.Block + for _, a := range attrs { + switch a.Key { + case "service": + service = a.Value.String() + case "env": + env = a.Value.String() + default: + if len(fields) < 10 { // Slack section fields 최대 10개 + fields = append(fields, notify.Block{ + "type": "mrkdwn", + "text": "*" + a.Key + "*\n" + truncate(esc(a.Value.String()), 500), + }) + } + } + } + + project := service + if project == "" { + project = "app" + } + if env != "" { + project += " · " + env + } + + // 플레인 텍스트 알림(목록/푸시용)에도 프로젝트를 앞세운다. + text := fmt.Sprintf("🚨 [%s] %s", project, msg) + + blocks := []notify.Block{ + {"type": "header", "text": notify.Block{ + "type": "plain_text", "text": truncate("🚨 ["+project+"] 에러 발생", 150), "emoji": true, + }}, + {"type": "section", "text": notify.Block{ + "type": "mrkdwn", "text": "*" + truncate(esc(msg), 1500) + "*", + }}, + } + if len(fields) > 0 { + blocks = append(blocks, notify.Block{"type": "section", "fields": fields}) + } + ts := "" + if !t.IsZero() { + ts = t.Format("2006-01-02 15:04:05") + } + ctxLine := strings.TrimSpace(strings.Join(nonEmpty(level.String(), project, ts), " · ")) + blocks = append(blocks, notify.Block{ + "type": "context", + "elements": []notify.Block{{"type": "mrkdwn", "text": ctxLine}}, + }) + return text, blocks +} + +func nonEmpty(vals ...string) []string { + out := make([]string, 0, len(vals)) + for _, v := range vals { + if v != "" { + out = append(out, v) + } + } + return out +} + +func truncate(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) + "…" +} + +// esc는 Slack mrkdwn에서 의미를 갖는 문자만 최소 이스케이프한다. +func esc(s string) string { + return strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace(s) +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go index 2cd0557..ba700e4 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -17,7 +17,10 @@ import ( // Setup builds the slog logger from config and installs it as the default // (so package-level slog.Info/Warn/Error calls everywhere use this config). -func Setup(cfg config.Config) *slog.Logger { +// +// poster가 nil이 아니고 cfg.MonitorChannel이 설정돼 있으면, Error 이상 레벨의 로그를 +// 해당 Slack 채널로도 알린다. (poster는 보통 *slack.Client) +func Setup(cfg config.Config, poster AlertPoster) *slog.Logger { w := resolveWriter(cfg) opts := &slog.HandlerOptions{Level: parseLevel(cfg.LogLevel)} @@ -29,6 +32,10 @@ func Setup(cfg config.Config) *slog.Logger { h = slog.NewTextHandler(w, opts) } + if poster != nil && cfg.MonitorChannel != "" { + h = newSlackAlertHandler(h, poster, cfg.MonitorChannel) + } + logger := slog.New(h).With("service", "slack-notifier", "env", cfg.Env) slog.SetDefault(logger) return logger diff --git a/internal/server/server.go b/internal/server/server.go index 91caad7..59dfec5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -67,7 +67,7 @@ func (s *Server) deliver(ctx context.Context, notes []notify.Notification) int { sent := 0 for _, n := range notes { email := s.mappings.Resolve(n.Email, n.Login) - if s.slack.SendDM(ctx, email, n.Text, n.Blocks, s.cfg.DefaultSlackChannel) { + if s.slack.SendDM(ctx, email, n.Text, n.Blocks) { sent++ } } diff --git a/internal/server/webhooks.go b/internal/server/webhooks.go index 3328ce6..73c3bb0 100644 --- a/internal/server/webhooks.go +++ b/internal/server/webhooks.go @@ -12,10 +12,13 @@ import ( "git/palnet/slack-notifier/internal/security" ) +// handleGitea는 어떤 경우에도 Gitea에 에러를 응답하지 않는다(항상 200). +// 검증 실패·파싱 실패는 내부 로그로만 남기고, Gitea Recent Deliveries에는 성공으로 보이게 한다. func (s *Server) handleGitea(c *gin.Context) { raw, err := io.ReadAll(c.Request.Body) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "cannot read body"}) + slog.Warn("gitea: cannot read body", "err", err) + c.JSON(http.StatusOK, gin.H{"status": "cannot read body"}) return } @@ -28,14 +31,24 @@ func (s *Server) handleGitea(c *gin.Context) { if !security.VerifyHMACSHA256(s.cfg.GiteaWebhookSecret, raw, c.GetHeader("X-Gitea-Signature")) { slog.Warn("gitea signature verification failed") - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid signature"}) + c.JSON(http.StatusOK, gin.H{"status": "invalid signature, ignored"}) return } event := c.GetHeader("X-Gitea-Event") + + // 모든 이벤트(push 포함)를 기본 Slack 채널로 브로드캐스트. + if s.cfg.DefaultSlackChannel != "" { + if msg, ok := gitea.BuildChannelMessage(event, raw); ok { + s.slack.PostMessage(c.Request.Context(), s.cfg.DefaultSlackChannel, msg.Text, msg.Blocks) + } + } + + // 담당자가 정해지는 이벤트는 개인 DM도 함께 발송. notes, err := gitea.BuildNotifications(event, raw) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + slog.Warn("gitea: invalid payload", "err", err) + c.JSON(http.StatusOK, gin.H{"status": "invalid payload"}) return } diff --git a/internal/slack/slack.go b/internal/slack/slack.go index 6375b01..058941c 100644 --- a/internal/slack/slack.go +++ b/internal/slack/slack.go @@ -76,6 +76,20 @@ func (c *Client) LookupUserIDByEmail(ctx context.Context, email string) (string, } func (c *Client) PostMessage(ctx context.Context, channel, text string, blocks []notify.Block) bool { + if err := c.post(ctx, channel, text, blocks); err != nil { + slog.Error("slack postMessage failed", "channel", channel, "err", err) + return false + } + return true +} + +// PostAlert는 PostMessage와 같지만 실패해도 slog로 기록하지 않는다. +// 에러 알림 핸들러 전용 — slog.Error를 부르면 알림이 다시 트리거되어 무한루프가 된다. +func (c *Client) PostAlert(ctx context.Context, channel, text string, blocks []notify.Block) error { + return c.post(ctx, channel, text, blocks) +} + +func (c *Client) post(ctx context.Context, channel, text string, blocks []notify.Block) error { payload := map[string]any{"channel": channel} if text != "" { payload["text"] = text @@ -94,33 +108,22 @@ func (c *Client) PostMessage(ctx context.Context, channel, text string, blocks [ Error string `json:"error"` } if err := c.do(req, &out); err != nil { - slog.Error("slack postMessage failed", "channel", channel, "err", err) - return false + return err } if !out.OK { - slog.Error("slack postMessage failed", "channel", channel, "error", out.Error) - return false + return fmt.Errorf("slack api error: %s", out.Error) } - return true + return nil } -// SendDM resolves email -> Slack user and DMs them. Falls back to fallbackChannel -// if the email can't be resolved; if there's no fallback either, it is skipped. -func (c *Client) SendDM(ctx context.Context, email, text string, blocks []notify.Block, fallbackChannel string) bool { +// SendDM resolves email -> Slack user and DMs them. 담당자를 해석하지 못하면 +// 채널로 보내지 않고 로그만 남기고 skip한다(브로드캐스트는 별도 경로에서 처리). +func (c *Client) SendDM(ctx context.Context, email, text string, blocks []notify.Block) bool { if id, ok := c.LookupUserIDByEmail(ctx, email); ok { return c.PostMessage(ctx, id, text, blocks) } - if fallbackChannel != "" { - prefix := "(담당자 미지정) " - if email != "" { - prefix = fmt.Sprintf("(담당자 %s 매핑 실패) ", email) - } - slog.Info("email unresolved — routing to fallback channel", "email", email, "channel", fallbackChannel) - return c.PostMessage(ctx, fallbackChannel, prefix+text, blocks) - } - - slog.Info("no Slack recipient and no fallback channel — skipped", "email", email) + slog.Info("email unresolved — DM skipped (logged only)", "email", email) return false } diff --git a/main.go b/main.go index 0189d1f..196ee7c 100644 --- a/main.go +++ b/main.go @@ -20,7 +20,10 @@ func main() { flag.Parse() cfg := config.Load(*envFlag) - logging.Setup(cfg) + + // Slack 클라이언트를 먼저 만들어 로깅에 주입한다 — Error 로그를 모니터 채널로 알리기 위함. + slackClient := slack.New(cfg.SlackBotToken) + logging.Setup(cfg, slackClient) store, err := mapping.New(cfg.MappingFile) if err != nil { @@ -28,7 +31,6 @@ func main() { os.Exit(1) } - slackClient := slack.New(cfg.SlackBotToken) notionClient := notion.New(cfg.NotionAPIToken, cfg.NotionAssigneeProperty) srv := server.New(cfg, slackClient, store, notionClient)