Post

[GitPulse #6] 커밋 컨벤션 자동 검사 & PR 리뷰 인터페이스

[GitPulse #6] 커밋 컨벤션 자동 검사 & PR 리뷰 인터페이스

✅ 오늘의 목표

  • 커밋 메시지 컨벤션 자동 검사 기능 도입
  • GitHub PR 리뷰를 위한 diff 기반 인터페이스 구현
  • PR 상세 페이지 이동 시 state 기반 데이터 전달 구조 개선

🧩 문제 상황 / 배경

  • 협업 중 커밋 메시지 컨벤션(feat/fix 등)을 도입했지만, 팀원마다 일관되지 않아 리뷰 품질과 이력 추적에 어려움이 발생했습니다. 이를 해결하기 위해 커밋 메시지에 대한 자동 검사 기능이 필요했습니다.
  • PR 리뷰 기능의 초기 구현에서는 GitHub의 diff 정보를 단순히 string으로 출력했기 때문에, 줄 단위 인터랙션이나 댓글 작성이 불가능했고, 시각적으로도 불편했습니다.
  • PR 상세 페이지로 이동할 때 필요한 정보를 URL 파싱에만 의존하다 보니, 유지보수에 취약하고 코드 가독성도 떨어졌습니다.

🛠️ 해결 과정 / 코드 정리

🔸 커밋 메시지 컨벤션 오류 검사 컴포넌트

✅ 검사 알고리즘 흐름

  1. 커밋 메시지를 정규화 (normalizeMessage) — 소문자로 통일, 공백 제거
  2. merge 커밋은 예외로 처리 (검사 제외)
  3. 정규화된 메시지가 정해진 접두어로 시작하지 않으면 오류 사유 추가
  4. 메시지 길이가 6자 미만이면 또 다른 오류 사유 추가
  5. 오류가 있는 커밋만 필터링하여 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];

navigatestate를 함께 넘겨줌으로써, 불필요한 파싱 로직을 최소화하고 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의 navigatestate를 활용하면서, URL 파싱의 복잡도는 줄이고 구조적으로 더 안전한 데이터 전달이 가능해졌습니다.

🖼️ 작업 결과물

커밋 메시지 컨벤션 자동 검사

GitHub PR 리뷰를 위한 diff 기반 인터페이스


Reference


END

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