gitea, notion webhook
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

399 lines
11 KiB

// 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)
// 커밋 줄: <url|shortsha>: 메시지 - 작성자명
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에서 의미를 갖는 문자를 이스케이프한다.
// 특히 '>'를 그대로 두면 <url|텍스트> 링크가 중간에서 끊긴다.
func escapeMrkdwn(s string) string {
return strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;").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
}