Post

Svn에 커밋이 올라온다면 디스코드 알림이 오게 해보자

Svn에 커밋이 올라온다면 디스코드 알림이 오게 해보자

이 글은 제 개인적인 공부를 위해 작성한 글입니다.
틀린 내용이 있을 수 있고, 피드백은 환영합니다.


목적


SVN을 사용하다보니 팀 환경에서 커밋이 언제 발생했는지 실시간으로 알 수 없고, 어떤 작업자가 어떤 파일을 수정했는지 확인하기도 어려웠다.

그렇다보니 변경 사항이 공유되지 않아, 작업 중 충돌이 발생하거나 중복 작업이 생기는 문제가 발생하였다.

그래서 평소에 깃허브 커밋 디스코드 알림처럼 SVN도 동일하게 구현하면 좋을 듯해서 시도하였다.


구현


일단 나는 Visual SVN 서버를 AWS EC2 윈도우 인스턴스에 올려두고 사용했다.


  1. 커밋 정보 수집 스크립트 작성

우선 커밋 정보를 수집하는 코드는 go 언어로 작성하였고 아래와 같다.

물론 이번이 go 언어를 처음 사용해보는 터라 대부분의 코드 작성은 AI로 구성하였는데, go 언어를 사용해보니 설치/빌드 과정이 너무 편리해서 간단한 프로그램 만드는거면 자주 사용해야겠다

스크립트는 svnlook 명령어를 사용해서 변경내역/리비전 넘버/작성자 등의 커밋 정보를 수집한다.


혹시 에러가 발생하면 간단하게 로그도 찍게 두었다.

EC2에 로그 없이 exe 파일 넣었는데, 거기서 에러 뜨면 매우 귀찮기 때문에..

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"os/exec"
	"time"
)

const (
	webhookURL = "https://discord.com/api/webhooks/your-discord-webhooks-api"
	logFile    = "log.txt"
)

type DiscordMessage struct {
	Content string `json:"content"`
}

func logError(err error) {
	f, fileErr := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if fileErr != nil {
		fmt.Println("Log file error:", fileErr)
		return
	}
	defer f.Close()
	timestamp := time.Now().Format("2006-01-02 15:04:05")
	logMsg := fmt.Sprintf("[%s] %v\n", timestamp, err)
	f.WriteString(logMsg)
}

func runSvnlook(arg string, repoPath, revision string) (string, error) {
	cmd := exec.Command("svnlook", arg, "-r", revision, repoPath)
	var out bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = &out
	err := cmd.Run()
	if err != nil {
		return "", fmt.Errorf("svnlook %s error: %v - output: %s", arg, err, out.String())
	}
	return out.String(), nil
}

func sendDiscordMessage(repoPath, revision, author, changedFiles string) error {
	message := fmt.Sprintf(
		"SVN 커밋 발생!\nRepository: `%s`\nRevision: `%s`\nAuthor: `%s`\n\n변경된 파일:\n```\n%s```",
		repoPath, revision, author, changedFiles)

	payload := DiscordMessage{Content: message}
	jsonData, err := json.Marshal(payload)
	if err != nil {
		return err
	}

	resp, err := http.Post(webhookURL, "application/json", bytes.NewBuffer(jsonData))
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 400 {
		return fmt.Errorf("Discord webhook error: %s", resp.Status)
	}

	return nil
}

func main() {
	if len(os.Args) < 3 {
		logError(fmt.Errorf("Usage: discord_notify <REPO_PATH> <REVISION>"))
		return
	}

	repoPath := os.Args[1]
	revision := os.Args[2]

	author, err := runSvnlook("author", repoPath, revision)
	if err != nil {
		logError(err)
		author = "(작성자 정보 없음)"
	} else {
		author = string(bytes.TrimSpace([]byte(author)))
	}

	changedFiles, err := runSvnlook("changed", repoPath, revision)
	if err != nil {
		logError(err)
		changedFiles = "(변경된 파일 목록을 가져오는 데 실패했습니다)"
	}

	if err := sendDiscordMessage(repoPath, revision, author, changedFiles); err != nil {
		logError(err)
	}
}

뭐 go 언어 설치/빌드하는 법은 구글에 검색하면 쉽게 할 수 있고, svnlook 명령어는 svn 서버에서만 실행되기에 클라이언트에서 테스트해도 svnlook: E720002: Can't open file 'format': 라는 에러만 뜨고 안될 것이다. (내가 30분동안 그랬음)

만약 go 빌드가 안된다면? 빌드를 하고자 하는 스크립트가 있는 폴더에서 go mod init file_name을 하셨나요?


빌드된 exe 파일을 ec2 인스턴스 하드 디스크의 적당한 위치에 넣어주자.

아래 post-commit.bat 파일에 your_path\your_file_name.exe 이 그 적당한 위치와 파일 명이 된다.

그리고 RDP 클라이언트를 사용한다면 원격 데스크톱 연결(Remote Desktop Connection)으로 편하게 디스크 연동을 할 수 있더라. 아래에 참고 링크를 확인해보세요.


  1. post-commit.bat 작성

이 파일은 커밋 이후에 svn 서버에서 자동으로 실행되는 배치 파일이라고 한다.

생성 경로는 svn 서버에서 Repositories\project_name\hooks\ 내에 만들면 된다.

메모장으로 작성하고 확장자 변경을 잊지 말자!

1
2
3
@echo off

C:\your_path\your_file_name.exe %1 %2


결과


img

커밋이 올라올때마다 실시간으로 디스코드에 바로 알람이 떠서 너무 좋다..

다른 개발자가 에셋을 수정했을 때 바로바로 최신화 할 수 있고, 어떤 파일이 변경되었는지 디스코드에서 어느정도 확인도 가능하다.

걱정되는 점은, 디스코드에도 메세지 제한이 있을텐데 대량의 파일을 svn에 추가했을 때 제대로 작동할 지 모르겠다.



2025.07.14 : 메세지 제한 때문에 수정

일단 디스코드 메세지 제한 (2000자)를 넘기면 아예 알람이 안오더라!

그래서 500자마다 메세지를 짤라서 보내야지! 하고 수정했는데,

img

메세지가 너무 많이 날라와서 당황…

결국엔 안전하게 1900자를 넘기면 텍스트 파일을 첨부하는 걸로 다시 수정하였다.

img

1900자 미만이라면 텍스트로 수정 사항을 보여주고, 1900자를 넘는다면 텍스트 파일을 첨부하도록 했다.

아 근데 ec2 인스턴스가 한글이 안돼서 커밋 메세지가 한글이면 ???로 깨져서 보이더라. 느려터진 ec2에서 한글팩을 설치하기엔 너무 귀찮아서 팀원들끼리 영어로 커밋 메세지 작성하기로 합의해야겠다 ㅎㅎ

수정한 코드는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"os"
	"os/exec"
	"strings"
	"time"
)

const (
	webhookURL = "https://discord.com/api/webhooks/your-discord-webhooks-api"
	logFile    = "log.txt"
	maxLength  = 1900
)

type DiscordMessage struct {
	Content string `json:"content"`
}

func logError(err error) {
	f, fileErr := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if fileErr != nil {
		fmt.Println("Log file error:", fileErr)
		return
	}
	defer f.Close()
	timestamp := time.Now().Format("2006-01-02 15:04:05")
	logMsg := fmt.Sprintf("[%s] %v\n", timestamp, err)
	f.WriteString(logMsg)
}

func runSvnlook(arg string, repoPath, revision string) (string, error) {
	cmd := exec.Command("svnlook", arg, "-r", revision, repoPath)
	var out bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = &out
	err := cmd.Run()
	if err != nil {
		return "", fmt.Errorf("svnlook %s error: %v - output: %s", arg, err, out.String())
	}
	return out.String(), nil
}

func sendDiscordTextMessage(content string) error {
	payload := DiscordMessage{Content: content}
	jsonData, err := json.Marshal(payload)
	if err != nil {
		return err
	}

	resp, err := http.Post(webhookURL, "application/json", bytes.NewBuffer(jsonData))
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 400 {
		return fmt.Errorf("Discord webhook error: %s", resp.Status)
	}

	return nil
}

func sendDiscordMessageWithFile(content string, filePath string) error {
	file, err := os.Open(filePath)
	if err != nil {
		return err
	}
	defer file.Close()

	var b bytes.Buffer
	writer := multipart.NewWriter(&b)

	_ = writer.WriteField("content", content)

	part, err := writer.CreateFormFile("file", filePath)
	if err != nil {
		return err
	}
	_, err = io.Copy(part, file)
	if err != nil {
		return err
	}
	writer.Close()

	req, err := http.NewRequest("POST", webhookURL, &b)
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", writer.FormDataContentType())

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 400 {
		return fmt.Errorf("Discord file upload error: %s", resp.Status)
	}

	return nil
}

func main() {
	if len(os.Args) < 3 {
		logError(fmt.Errorf("Usage: discord_notify <REPO_PATH> <REVISION>"))
		return
	}

	repoPath := os.Args[1]
	revision := os.Args[2]

	author, err := runSvnlook("author", repoPath, revision)
	if err != nil {
		logError(err)
		author = "(Unknown author)"
	} else {
		author = strings.TrimSpace(author)
	}

	changedFiles, err := runSvnlook("changed", repoPath, revision)
	if err != nil {
		logError(err)
		changedFiles = "(Failed to retrieve changed files)"
	}

	logMessage, err := runSvnlook("log", repoPath, revision)
	if err != nil {
		logError(err)
		logMessage = "(Failed to retrieve commit message)"
	} else {
		logMessage = strings.TrimSpace(logMessage)
	}

	header := fmt.Sprintf(
		"----------------------\nSVN Commit!\nRepository: `%s`\nRevision: `%s`\nAuthor: `%s`\nMessage:\n```\n%s```",
		repoPath, revision, author, logMessage,
	)

	safeAuthor := strings.ReplaceAll(strings.ToLower(author), " ", "_")
	fileName := fmt.Sprintf("revision-%s-changes-by-%s.txt", revision, safeAuthor)

	if len(changedFiles) > maxLength {
		err := os.WriteFile(fileName, []byte(changedFiles), 0644)
		if err != nil {
			logError(fmt.Errorf("failed to write file: %v", err))
			return
		}
		err = sendDiscordMessageWithFile(header+"\n Changelog exceeded the discord message limit. See attachment.", fileName)
		if err != nil {
			logError(err)
		}
		_ = os.Remove(fileName)
	} else {
		msg := fmt.Sprintf("%s\n\nChanged Files:\n```\n%s```", header, changedFiles)
		if err := sendDiscordTextMessage(msg); err != nil {
			logError(err)
		}
	}
}


참고

This post is licensed under CC BY 4.0 by the author.