Skip to content

Commit 279efb9

Browse files
authored
PR 코드 변경 사항 요약 기능 추가 및 리뷰 시스템 리팩터링
PR 코드 변경 사항 요약 기능 추가
2 parents 4078a10 + 59f5368 commit 279efb9

13 files changed

Lines changed: 629 additions & 186 deletions

README.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# code-review
2+
3+
Kotlin과 Spring Boot 기반으로 만든 자동 코드 리뷰 서비스로, PR에 포함된 변경 내용을 분석하여 LLM을 통해 리뷰 코멘트를 생성하고 GitHub에 자동으로 남긴다. 리뷰 생성 실패 시 재시도까지 관리하여 일관된 코드 리뷰 자동화를 제공한다.
4+
5+
---
6+
7+
## 프로젝트 개요
8+
9+
이 프로젝트는 GitHub Pull Request 이벤트(Webhook)를 받아 변경된 파일의 diff를 분석한 뒤, Google GenAI를 활용해 리뷰 코멘트를 생성하고 GitHub API를 통해 자동으로 리뷰를 남기는 시스템이다. 리뷰 생성 과정에서 발생할 수 있는 요청 한도 초과(429)·서버 오류(503) 같은 상황은 내부 재시도 큐에서 관리하여 안정적으로 처리한다.
10+
11+
---
12+
13+
## CodeReview 프로세스
14+
15+
1. **PR 발생 → Webhook 수신**
16+
GitHub Webhook이 PR 이벤트 정보를 서버로 전달한다.
17+
18+
2. **Diff 수집 및 분석**
19+
PR의 변경 파일을 조회하고 파일 단위로 diff snippet을 분리한다.
20+
21+
3. **리뷰 생성 요청(LLM 호출)**
22+
파일별 diff snippet을 기반으로 프롬프트를 구성해 Google GenAI 모델에 리뷰 생성을 요청한다.
23+
24+
4. **GitHub에 리뷰 코멘트 등록**
25+
생성된 리뷰 코멘트를 GitHub Pull Request에 자동으로 작성한다.
26+
27+
5. **오류 발생 시 재시도 큐로 이동**
28+
- 모델 응답 지연, 429/503 등 재시도가 필요한 오류는 재시도 큐에 적재
29+
- 일정 간격으로 스케줄러가 큐를 실행하여 재리뷰 시도
30+
- 최대 재시도 횟수 초과 시 실패 로그만 남기고 종료
31+
32+
이 구조를 통해 PR 하나의 리뷰가 파일 단위로 병렬 처리되고, 장애 상황에서도 안정적으로 완료될 수 있도록 설계되어 있다.
33+
34+
---
35+
36+
## 사용 기술 스택
37+
38+
- **언어 및 런타임**
39+
Kotlin 1.9, JVM 21
40+
41+
- **프레임워크**
42+
Spring Boot 3.5(Web + WebFlux), Kotlin Coroutine, Reactor
43+
44+
- **AI 모델**
45+
Google GenAI Java SDK(LLM 기반 리뷰 생성)
46+
47+
- **데이터 처리**
48+
Jackson Kotlin Module, Reactor Extensions
49+
50+
- **인증/보안**
51+
GitHub App 인증(JWT + Installation Token), JJWT 기반 서명 처리
52+
53+
- **CI/CD 및 배포**
54+
GitHub Actions(self-hosted runner), Docker Buildx, Docker Hub, EC2
55+
56+
---
57+
58+
## 배포 프로세스
59+
60+
1. **Build Job**
61+
- self-hosted Runner에서 Gradle 빌드
62+
- JAR 파일 생성 후 아티팩트 업로드
63+
64+
2. **Dockerize Job**
65+
- 다운로드한 JAR을 기반으로 Docker 이미지를 빌드
66+
- Buildx를 사용해 `linux/amd64``linux/arm64` 멀티 아키텍처 이미지 생성
67+
- Docker Hub로 push
68+
69+
3. **Deploy Job**
70+
- SSH로 EC2에 연결
71+
- 환경변수를 전달하고 `code-review-deploy.sh` 실행
72+
- 기존 컨테이너 중지 후 최신 이미지로 재배포
73+
- 자동 재시작 설정으로 운영 환경 안정성 확보
74+
75+
이 파이프라인을 통해 PR → LLM 리뷰 생성 → GitHub 코멘트 작성 → 장애 시 재시도 → 자동화된 Docker 배포까지 전 과정이 자동으로 이어진다.
76+
77+
## 사용 API Document
78+
79+
- [Google GenAI](https://github.com/google-gemini/cookbook)
80+
- [Github Webhook](http://docs.github.com/webhooks)
81+
- [Get Pull Request Diff](https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#get-a-pull-request)
82+
- [Pull Request Comment](https://docs.github.com/en/rest/pulls/comments?apiVersion=2022-11-28#create-a-review-comment-for-a-pull-request)

src/main/kotlin/com/project/codereview/batch/FailedTaskManager.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.project.codereview.batch
22

3-
import com.project.codereview.client.github.GithubDiffClient
43
import com.project.codereview.client.github.GithubDiffUtils
54
import com.project.codereview.core.dto.GithubPayload
65
import com.project.codereview.core.dto.GithubReviewDto
@@ -12,10 +11,10 @@ import java.util.concurrent.atomic.AtomicInteger
1211
class FailedTaskManager {
1312
data class OriginalTask(
1413
val payload: GithubPayload,
15-
val part: GithubDiffUtils.FileDiff
14+
val diff: GithubDiffUtils.DiffInfo
1615
) {
1716
fun toGithubReviewDto(review: String): GithubReviewDto {
18-
return GithubReviewDto(payload.pull_request, part, payload.installation.id, review)
17+
return GithubReviewDto(payload.pull_request, diff, payload.installation.id, review)
1918
}
2019
}
2120

Lines changed: 37 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,72 @@
11
package com.project.codereview.batch
22

3-
import com.project.codereview.client.github.GithubReviewClient
4-
import com.project.codereview.client.google.GoogleGeminiClient
3+
import com.project.codereview.core.service.ReviewCommand
4+
import com.project.codereview.core.service.ReviewExecutor
5+
import com.project.codereview.core.service.ReviewOutcome
56
import kotlinx.coroutines.CoroutineScope
67
import kotlinx.coroutines.Dispatchers
78
import kotlinx.coroutines.launch
89
import org.springframework.scheduling.annotation.Scheduled
910
import org.springframework.stereotype.Component
11+
import kotlin.math.min
12+
import kotlin.random.Random
1013

1114
@Component
1215
class FailedTaskRetryScheduler(
13-
private val googleGeminiClient: GoogleGeminiClient,
14-
private val githubReviewClient: GithubReviewClient,
16+
private val executor: ReviewExecutor,
1517
private val failedTaskManager: FailedTaskManager
1618
) {
1719
private val logger = org.slf4j.LoggerFactory.getLogger(FailedTaskRetryScheduler::class.java)
1820
private val maxRetry = 5
1921

20-
@Scheduled(fixedDelay = 120_000) // 2분마다 스케줄링
22+
@Scheduled(fixedDelay = 120_000)
2123
fun retryFailedTasks() {
2224
CoroutineScope(Dispatchers.IO).launch {
23-
val batchSize = 10
24-
val batch = failedTaskManager.pollBatch(batchSize)
25-
if (batch.isEmpty()) {
26-
return@launch
27-
}
25+
val batch = failedTaskManager.pollBatch(10)
26+
if (batch.isEmpty()) return@launch
2827

29-
logger.info(
30-
"[Retry Start] size = {}, queueSize = {}",
31-
batch.size,
32-
failedTaskManager.size()
33-
)
28+
logger.info("[Retry Start] size = {}, queueSize = {}", batch.size, failedTaskManager.size())
3429

3530
batch.forEach { task ->
3631
val original = task.task
37-
val filePath = original.part.filePath
3832
val retryCount = task.retryCount
39-
val promptLength = task.prompt.length
33+
val cmd = ReviewCommand(
34+
payload = original.payload,
35+
diff = original.diff,
36+
promptOverride = task.prompt // 재시도는 기존 프롬프트 유지
37+
)
4038

41-
// 재시도 횟수 초과 시 포기
4239
if (retryCount >= maxRetry) {
43-
logger.error("[Give Up] file={} after {} retries", filePath, retryCount)
40+
logger.error("[Give Up] file={} after {} retries", original.diff.path, retryCount)
4441
return@forEach
4542
}
4643

47-
runCatching {
48-
val review = googleGeminiClient.chat(filePath, task.prompt)
49-
50-
if (review != null) {
51-
githubReviewClient.addReviewComment(original.toGithubReviewDto(review))
52-
logger.info(
53-
"[Retry Success] file={}, retry={}, promptLength={}",
54-
filePath, retryCount, promptLength
55-
)
56-
} else {
57-
failedTaskManager.add(original, task.prompt, retryCount + 1)
58-
logger.warn(
59-
"[Retry Skipped] file={}, retry={}, promptLength={}",
60-
filePath, retryCount, promptLength
61-
)
44+
when (val outcome = executor.execute(cmd)) {
45+
is ReviewOutcome.Success -> {
46+
logger.info("[Retry Success] file={}, retry={}", original.diff.path, retryCount)
47+
}
48+
is ReviewOutcome.Retryable -> {
49+
val next = computeBackoffMillis(retryCount)
50+
failedTaskManager.add(original, outcome.promptUsed, retryCount + 1)
51+
logger.warn("[Retry Requeued] file={}, retry={}, next={}ms, reason={}",
52+
original.diff.path, retryCount + 1, next, outcome.reason)
53+
}
54+
is ReviewOutcome.NonRetryable -> {
55+
logger.error("[Retry Aborted] file={}, retry={}, reason={}",
56+
original.diff.path, retryCount, outcome.reason)
6257
}
63-
}.onFailure { e ->
64-
failedTaskManager.add(original, task.prompt, retryCount + 1)
65-
logger.error(
66-
"[Retry Failed] file={}, retry={}, promptLength={}",
67-
filePath, retryCount, promptLength, e
68-
)
6958
}
7059
}
7160

7261
logger.info("[Retry End] processed = {}", batch.size)
7362
}
7463
}
64+
65+
private fun computeBackoffMillis(retryCount: Int): Long {
66+
val base = 5_000L // 5초
67+
val maxCap = 10 * 60_000L // 10분
68+
val exp = base shl retryCount
69+
val jitter = Random.nextLong(0, base)
70+
return min(exp + jitter, maxCap)
71+
}
7572
}

0 commit comments

Comments
 (0)