Browse Source

feat: test

main
지대한 2 weeks ago
parent
commit
f54a174f0c
  1. 9
      .env.example
  2. 4
      internal/config/config.go
  3. 97
      internal/gitea/gitea.go
  4. 144
      internal/logging/alert.go
  5. 9
      internal/logging/logging.go
  6. 2
      internal/server/server.go
  7. 19
      internal/server/webhooks.go
  8. 39
      internal/slack/slack.go
  9. 6
      main.go

9
.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=

4
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"),

97
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 {

144
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("&", "&amp;", "<", "&lt;", ">", "&gt;").Replace(s)
}

9
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

2
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++
}
}

19
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
}

39
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
}

6
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)

Loading…
Cancel
Save