// Package gitea maps Gitea webhook events to the DMs that should be sent. package gitea import ( "encoding/json" "fmt" "log/slog" "strings" "git/palnet/slack-notifier/internal/notify" ) type user struct { Login string `json:"login"` Username string `json:"username"` Email string `json:"email"` Name string `json:"name"` // commit author/committer 표시 이름 } func (u *user) login() string { if u == nil { return "" } if u.Login != "" { return u.Login } return u.Username } func (u *user) email() string { if u == nil { return "" } return u.Email } type pullRequest struct { Number int `json:"number"` Title string `json:"title"` HTMLURL string `json:"html_url"` Merged bool `json:"merged"` User *user `json:"user"` Assignee *user `json:"assignee"` Assignees []user `json:"assignees"` } type issue struct { Number int `json:"number"` Title string `json:"title"` HTMLURL string `json:"html_url"` User *user `json:"user"` Assignee *user `json:"assignee"` Assignees []user `json:"assignees"` } type comment struct { Body string `json:"body"` HTMLURL string `json:"html_url"` } type review struct { Content string `json:"content"` Type string `json:"type"` } type commitInfo struct { ID string `json:"id"` Message string `json:"message"` URL string `json:"url"` Author *user `json:"author"` } type payload struct { Action string `json:"action"` Number int `json:"number"` Repository struct { FullName string `json:"full_name"` HTMLURL string `json:"html_url"` } `json:"repository"` Sender *user `json:"sender"` PullRequest *pullRequest `json:"pull_request"` Issue *issue `json:"issue"` 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 { if len(list) > 0 { return list } if single != nil { return []user{*single} } return nil } // BuildNotifications turns a Gitea webhook (event header + raw JSON) into DMs. func BuildNotifications(event string, body []byte) ([]notify.Notification, error) { var p payload if err := json.Unmarshal(body, &p); err != nil { return nil, err } repo := p.Repository.FullName sender := p.Sender.login() var notes []notify.Notification switch event { case "pull_request": notes = handlePullRequest(&p, repo, sender) case "issues": notes = handleIssues(&p, repo, sender) case "issue_comment": notes = handleIssueComment(&p, repo, sender) case "pull_request_review": notes = handlePullRequestReview(&p, repo, sender) default: slog.Info("gitea event not handled — ignored", "event", event) return nil, nil } 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/") n := len(p.Commits) plural := "" if n != 1 { plural = "s" } // 헤더: [repo:branch] N new commit(s) pushed by {actor} (Gitea 기본 형식과 유사) repoRef := fmt.Sprintf("%s:%s", repo, branch) if p.Repository.HTMLURL != "" { repoRef = fmt.Sprintf("<%s/src/branch/%s|%s:%s>", p.Repository.HTMLURL, branch, repo, branch) } head := fmt.Sprintf("[%s] %d new commit%s pushed by %s", repoRef, n, plural, actor) // 커밋 줄: : 메시지 - 작성자명 lines := make([]string, 0, n) for _, cm := range p.Commits { short := cm.ID if len(short) > 10 { short = short[:10] } msg := strings.SplitN(cm.Message, "\n", 2)[0] // 링크는 sha에만 — 메시지의 '>' 등이 링크를 중간에 끊지 않도록 평문은 이스케이프. line := fmt.Sprintf("<%s|%s>: %s", cm.URL, short, escapeMrkdwn(msg)) if cm.Author != nil && cm.Author.Name != "" { line += " - " + escapeMrkdwn(cm.Author.Name) } lines = append(lines, line) } body := head if p.Repository.HTMLURL != "" { body += "\n" + p.Repository.HTMLURL } if len(lines) > 0 { body += "\n" + strings.Join(lines, "\n") } text := fmt.Sprintf("[%s:%s] %d new commit%s pushed by %s", repo, branch, n, plural, actor) return pushNote(text, body), 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, escapeMrkdwn(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, escapeMrkdwn(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, escapeMrkdwn(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, escapeMrkdwn(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 } // escapeMrkdwn은 Slack mrkdwn에서 의미를 갖는 문자를 이스케이프한다. // 특히 '>'를 그대로 두면 링크가 중간에서 끊긴다. func escapeMrkdwn(s string) string { return strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace(s) } // 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), } } // pushNote는 헤더+커밋 목록을 한 섹션에 담는다(Gitea 기본 메시지와 유사한 단순 형식). func pushNote(text, body string) notify.Notification { return notify.Notification{ Text: text, Blocks: []notify.Block{ {"type": "section", "text": notify.Block{"type": "mrkdwn", "text": body}}, }, } } func handlePullRequest(p *payload, repo, sender string) []notify.Notification { pr := p.PullRequest if pr == nil { return nil } tag := fmt.Sprintf("%s PR #%d", repo, pr.Number) body := fmt.Sprintf("<%s|%s>", pr.HTMLURL, pr.Title) var notes []notify.Notification switch p.Action { case "assigned": for _, u := range assigneesOf(pr.Assignee, pr.Assignees) { notes = append(notes, note(u.email(), u.login(), fmt.Sprintf("[%s] 담당자로 지정되었습니다", tag), "📌 PR 담당자 지정 — "+tag, body, "by "+sender)) } case "review_requested": r := p.RequestedReviewer notes = append(notes, note(r.email(), r.login(), fmt.Sprintf("[%s] 리뷰 요청을 받았습니다", tag), "👀 리뷰 요청 — "+tag, body, "by "+sender)) case "opened", "reopened": for _, u := range assigneesOf(pr.Assignee, pr.Assignees) { notes = append(notes, note(u.email(), u.login(), fmt.Sprintf("[%s] 담당 PR이 %s 되었습니다", tag, p.Action), fmt.Sprintf("🔀 PR %s — %s", p.Action, tag), body, "by "+sender)) } case "closed": if pr.Merged { notes = append(notes, note(pr.User.email(), pr.User.login(), fmt.Sprintf("[%s] 작성한 PR이 머지되었습니다", tag), "✅ PR 머지됨 — "+tag, body, "by "+sender)) } } return notes } func handleIssues(p *payload, repo, sender string) []notify.Notification { iss := p.Issue if iss == nil || p.Action != "assigned" { return nil } tag := fmt.Sprintf("%s 이슈 #%d", repo, iss.Number) body := fmt.Sprintf("<%s|%s>", iss.HTMLURL, iss.Title) var notes []notify.Notification for _, u := range assigneesOf(iss.Assignee, iss.Assignees) { notes = append(notes, note(u.email(), u.login(), fmt.Sprintf("[%s] 담당자로 지정되었습니다", tag), "📌 이슈 담당자 지정 — "+tag, body, "by "+sender)) } return notes } func handleIssueComment(p *payload, repo, sender string) []notify.Notification { iss := p.Issue if iss == nil { return nil } tag := fmt.Sprintf("%s #%d", repo, iss.Number) url := iss.HTMLURL if p.Comment != nil && p.Comment.HTMLURL != "" { url = p.Comment.HTMLURL } snippet := "" if p.Comment != nil { snippet = strings.TrimSpace(p.Comment.Body) if len([]rune(snippet)) > 200 { snippet = string([]rune(snippet)[:200]) + "…" } } body := fmt.Sprintf("<%s|%s>\n> %s", url, iss.Title, snippet) // 담당자 + 작성자에게 알림. 단, 댓글 작성자 본인은 제외. recipients := assigneesOf(iss.Assignee, iss.Assignees) if iss.User != nil { recipients = append(recipients, *iss.User) } var notes []notify.Notification for _, u := range recipients { if u.email() == "" || u.login() == sender { continue } notes = append(notes, note(u.email(), u.login(), fmt.Sprintf("[%s] 새 댓글이 달렸습니다", tag), "💬 새 댓글 — "+tag, body, "by "+sender)) } return notes } func handlePullRequestReview(p *payload, repo, sender string) []notify.Notification { pr := p.PullRequest if pr == nil { return nil } tag := fmt.Sprintf("%s PR #%d", repo, pr.Number) state := "리뷰" if p.Review != nil && p.Review.Type != "" { state = strings.TrimPrefix(p.Review.Type, "pull_request_review_") } body := fmt.Sprintf("<%s|%s>", pr.HTMLURL, pr.Title) if p.Review != nil && strings.TrimSpace(p.Review.Content) != "" { c := strings.TrimSpace(p.Review.Content) if len([]rune(c)) > 200 { c = string([]rune(c)[:200]) } body += "\n> " + c } // 리뷰 결과는 PR 작성자에게 알림. return []notify.Notification{note(pr.User.email(), pr.User.login(), fmt.Sprintf("[%s] 리뷰가 등록되었습니다 (%s)", tag, state), "📝 리뷰 "+state+" — "+tag, body, "by "+sender)} } func note(email, login, text, title, body, context string) notify.Notification { return notify.Notification{ Email: email, Login: login, Text: text, Blocks: notify.SimpleBlocks(title, body, context), } } func dedupe(in []notify.Notification) []notify.Notification { seen := make(map[string]struct{}) out := make([]notify.Notification, 0, len(in)) for _, n := range in { if n.Email == "" && n.Login == "" { continue } key := n.Email + "|" + n.Login + "|" + n.Text if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, n) } return out }