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
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("&", "&", "<", "<", ">", ">").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 |
|
}
|
|
|