[GitPulse #6] 커밋 컨벤션 자동 검사 & PR 리뷰 인터페이스
[GitPulse #6] 커밋 컨벤션 자동 검사 & PR 리뷰 인터페이스
✅ 오늘의 목표
- 커밋 메시지 컨벤션 자동 검사 기능 도입
- GitHub PR 리뷰를 위한 diff 기반 인터페이스 구현
- PR 상세 페이지 이동 시
state
기반 데이터 전달 구조 개선
🧩 문제 상황 / 배경
- 협업 중 커밋 메시지 컨벤션(feat/fix 등)을 도입했지만, 팀원마다 일관되지 않아 리뷰 품질과 이력 추적에 어려움이 발생했습니다. 이를 해결하기 위해 커밋 메시지에 대한 자동 검사 기능이 필요했습니다.
- PR 리뷰 기능의 초기 구현에서는 GitHub의 diff 정보를 단순히 string으로 출력했기 때문에, 줄 단위 인터랙션이나 댓글 작성이 불가능했고, 시각적으로도 불편했습니다.
- PR 상세 페이지로 이동할 때 필요한 정보를 URL 파싱에만 의존하다 보니, 유지보수에 취약하고 코드 가독성도 떨어졌습니다.
🛠️ 해결 과정 / 코드 정리
🔸 커밋 메시지 컨벤션 오류 검사 컴포넌트
✅ 검사 알고리즘 흐름
- 커밋 메시지를 정규화 (
normalizeMessage
) — 소문자로 통일, 공백 제거 merge
커밋은 예외로 처리 (검사 제외)- 정규화된 메시지가 정해진 접두어로 시작하지 않으면 오류 사유 추가
- 메시지 길이가 6자 미만이면 또 다른 오류 사유 추가
- 오류가 있는 커밋만 필터링하여 UI에 표시
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
const ConventionError = ({ commits }) => {
// 허용되는 접두어
const allowedPrefixes = [
"feat", "fix", "bug", "style", "refactor", "test", "chore", "docs",
];
// merge 커밋 여부 확인
const isMergeCommit = (message) => message.toLowerCase().startsWith("merge");
// 메시지를 소문자로 바꾸고, 공백 제거
const normalizeMessage = (msg) =>
msg.trim().toLowerCase().replace(/\s+/g, ""); // 여러 공백을 하나로 압축 후 제거
// 컨벤션 오류 판단
const isConventionError = (message) => {
const reasons = [];
if (isMergeCommit(message)) return { reasons: [] };
const normalized = normalizeMessage(message);
const hasPrefix = allowedPrefixes.some((prefix) =>
normalized.startsWith(prefix)
);
if (!hasPrefix)
reasons.push(
"커밋 메시지에 접두어(feat, fix 등)가 빠졌습니다. 컨벤션을 지켜주세요"
);
if (normalized.length < 6)
reasons.push("커밋 메시지가 너무 짧아요. 조금 더 구체적으로 적어주세요");
return { reasons };
};
// 전체 커밋 메시지 필터링
const commitMsgList = commits?.map((commit) => {
const { reasons } = isConventionError(commit.commit.message);
return {
date: commit.commit.author.date,
author: commit.commit.author.name,
message: commit.commit.message,
result: reasons,
};
});
const filteredList = commitMsgList?.filter(
(commit) => commit.result.length > 0
);
return (
<div className={css.conventionErrorCon}>
<div className={css.tableTitle}>
<h3>
커밋 메세지 <strong>컨벤션 오류</strong>
</h3>
<p>팀원 간 협업을 위해 커밋 메시지도 표준을 지켜야죠!</p>
</div>
<ConventionErrTable commitMsgList={filteredList} />
</div>
);
};
🔸 PR 상세 페이지로 이동 시 state
전달 방식 개선
기존 코드:
1
2
3
const handlePrComment = (id) => {
navigate(`/org/${orgId}/${orgs}/${id}`);
};
개선 코드:
1
2
3
4
5
6
7
const handlePrComment = (number, url) => {
navigate(`/org/${orgId}/${orgs}/${number}`, {
state: { url },
});
};
// ...
<button onClick={() => handlePrComment(PR.number, PR.url)} />
이동 후 페이지에서 URL 파싱:
1
2
3
4
5
6
const location = useLocation();
const { url } = location.state || {};
const parts = url?.split("/");
const orgs = parts[4];
const repo = parts[5];
const pullNumber = parts[7];
navigate
에state
를 함께 넘겨줌으로써, 불필요한 파싱 로직을 최소화하고 URL 데이터의 의존성을 줄임
❓state
를 사용하는 이유
- URL에 모든 정보를 포함시키는 방식은 URL 파싱에 의존하게 되고 유지보수에 불리
navigate(path, { state })
를 사용하면 필요한 데이터를 객체 형태로 명확하게 넘길 수 있어 가독성, 안정성, 재사용성 향상- 받은 컴포넌트에서는
useLocation()
으로state
값을 안전하게 추출 가능
🔸 PR 변경 파일 UI 구성 및 리뷰 인터랙션 처리
PR 상세 페이지에서는 GitHub API에서 제공하는 파일 변경 정보 (files
)를 받아, 다음과 같은 방식으로 UI에 보여주도록 구성
✅ usePRInfo
를 통해 파일 변경 정보 가져오기
1
2
const { data, isLoading, isError } = usePRInfo(orgs, repo, pullNumber);
const prFiles = data?.files;
응답 데이터 예시 (file.patch
포함):
1
2
3
4
5
6
7
8
{
"filename": "frontend/src/common/SideBar.jsx",
"status": "modified",
"additions": 67,
"deletions": 61,
"changes": 128,
"patch": "@@ -3,89 +3,95 @@ import css from ...\n- const navigate = useNavigate();\n+ ..."
}
✅ patch 데이터 기반 UI 렌더링 로직
- 변경된 파일의 patch 내용을 추가/삭제/변경 라인을 시각적으로 구분
- 각 줄에 리뷰가 있는 경우, 아이콘 및 리뷰 등록 기능 추가
① patch
문자열을 \n 기준으로 한 줄씩 쪼갠 다음 .map()
으로 하나씩 <div>에 담아서 그려준다.
file.patch
는 GitHub API에서 제공하는 “파일 변경 내용(diff)” 문자열- 줄바꿈 기준으로 분리하면, 코드 한 줄씩 변경 내용을 알 수 있음
1
2
3
4
5
6
7
file?.patch?.split("\n").map((line, lineIndex) => {
return (
<div key={lineIndex}>
{/* 한 줄의 코드 정보 */}
</div>
)
})
예를 들어 patch
가 이런 식이면:
1
2
3
4
@@ -3,89 +3,95 @@
-import { A }
+import { B }
const x = 1;
①을 거치면 각 줄을 <div>
로 감싸서 보여줌
<div>@@ -3,89 +3,95 @@</div>
<div>-import { A }</div>
<div>+import { B }</div>
<div> const x = 1</div>
이런 식으로 출력
② 각 줄의 종류를 판단해서 색을 입힌다
1
2
3
4
5
6
7
if (line.startsWith("+") && !line.startsWith("+++")) {
배경: 연한 초록색
} else if (line.startsWith("-") && !line.startsWith("---")) {
배경: 연한 빨간색
} else if (line.startsWith("@@")) {
배경: 회색 (메타정보)
}
줄 내용 | 색상 |
---|---|
+ import B | 연한 초록 |
- import A | 연한 빨강 |
@@ ... | 회색 |
기타 코드 | 기본색 |
③ 줄 번호와 💜 댓글 아이콘
1
2
{lineIndex + 1}
{commentsForLine.length > 0 && <span>💜</span>}
줄 번호는 1부터 시작해서 보여주고, 해당 줄에 리뷰 댓글이 달려 있으면 💜 아이콘을 같이 보여줌.
④ 줄 클릭 시 댓글 입력창 열기
1
2
3
4
5
6
7
8
onClick={() => {
if (isAddedLine) {
setExpandedLines((prev) => ({
...prev,
[key]: !prev[key],
}));
}
}}
+
로 시작하는 줄만 클릭 가능하게 해서 클릭하면 줄 아래쪽에 댓글 목록 + 댓글 입력창을 보여줌
전체 흐름 정리 그림
1
2
3
4
5
6
7
8
9
patch (string)
↓ split("\n")
[ line1, line2, ... ]
↓ map()
<pre>
<div>줄 1: 색상 입힘 + 줄번호 + 줄내용 + 댓글토글</div>
<div>줄 2: ...</div>
...
</pre>
코드 형식을 유지하기 위해 전체를
<pre>
로 감싸 텍스트 줄바꿈/들여쓰기를 유지한다.
전체 코드
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
<pre className={css.codeBlock}>
{file?.patch?.split("\n").map((line, lineIndex) => {
const key = `${file.filename}-${lineIndex}`;
// 줄 구분 색상 처리
const isAddedLine = line.startsWith("+") && !line.startsWith("+++");
const isRemovedLine = line.startsWith("-") && !line.startsWith("---");
const isMetaLine = line.startsWith("@@");
// 시각 스타일 설정
let bgColor = "";
let color = "";
if (isAddedLine) {
bgColor = "#e6ffed";
color = "#22863a";
} else if (isRemovedLine) {
bgColor = "#ffeef0";
color = "#cb2431";
} else if (isMetaLine) {
bgColor = "#f0f0f0";
color = "#6a737d";
}
const commentsForLine = getCommentsForLine(
file.filename,
lineIndex,
file.patch,
reviewComments
);
return (
<div
key={lineIndex}
className={css.codeLineWrapper}
style=
>
{/* 줄 번호 및 코드 */}
<div style=>
<div style=>
{lineIndex + 1}
{commentsForLine.length > 0 && <span className={css.isComment}>💜</span>}
</div>
<div
style=
onClick={() => {
if (isAddedLine) {
setExpandedLines((prev) => ({
...prev,
[key]: !prev[key],
}));
}
}}
>
{line}
</div>
</div>
{/* 줄 댓글 영역 (열렸을 때만 표시) */}
{expandedLines[key] && (
<div style=>
{commentsForLine.map((cmt, i) => (
<div key={i} className={css.commentBody}>
<div className={css.commentInfo}>
<div className={css.commentUser}>{cmt.user.login}</div>
<div>{cmt.created_at.split("T")[0]}</div>
</div>
<div className={css.lineBody}>{cmt.body}</div>
</div>
))}
{/* 댓글 작성 */}
<div className={css.lineComment}>
<LineCommentInput
value={commentTargets[key]}
onChange={(val) => handleCommentChange(key, val)}
/>
<button
onClick={async () => {
const body = commentTargets[key];
const position = getPositionInPatch(file.patch, lineIndex);
if (!commitId || position === null) {
alert("커밋 ID 또는 position이 유효하지 않습니다.");
return;
}
await postPRComment(
orgs,
repo,
pullNumber,
body,
commitId,
file.filename,
position
);
await refetchReviewComments();
handleCommentChange(key, undefined);
}}
>
리뷰 등록
</button>
</div>
</div>
)}
</div>
);
})}
</pre>
✅ 결과 및 느낀 점
- 커밋 메시지 컨벤션 오류를 시각적으로 보여주고 자동으로 감지할 수 있어, 리뷰 품질 및 팀 커뮤니케이션의 일관성이 높아졌습니다.
- patch 기반 코드 diff 인터페이스 구현을 통해 팀원들이 줄 단위로 코드를 리뷰하고 의견을 나누는 데 있어 사용성과 직관성이 크게 향상되었습니다.
- React Router의
navigate
에state
를 활용하면서, URL 파싱의 복잡도는 줄이고 구조적으로 더 안전한 데이터 전달이 가능해졌습니다.
🖼️ 작업 결과물
커밋 메시지 컨벤션 자동 검사
GitHub PR 리뷰를 위한 diff 기반 인터페이스
Reference
END
This post is licensed under CC BY 4.0 by the author.