Skip to content

Commit 891cb8e

Browse files
authored
서비스의 역할 분리 및 객체 생명주기 리팩터링
서비스의 역할 분리
2 parents 5c9b178 + 17d7a0c commit 891cb8e

7 files changed

Lines changed: 155 additions & 145 deletions

File tree

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

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

33
import com.project.codereview.client.github.GithubDiffClient
4+
import com.project.codereview.client.github.GithubDiffUtils
45
import com.project.codereview.core.dto.GithubPayload
56
import com.project.codereview.core.dto.GithubReviewDto
67
import org.springframework.stereotype.Component
@@ -11,7 +12,7 @@ import java.util.concurrent.atomic.AtomicInteger
1112
class FailedTaskManager {
1213
data class OriginalTask(
1314
val payload: GithubPayload,
14-
val part: GithubDiffClient.FileDiff
15+
val part: GithubDiffUtils.FileDiff
1516
) {
1617
fun toGithubReviewDto(review: String): GithubReviewDto {
1718
return GithubReviewDto(payload.pull_request, part, review)
Lines changed: 6 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
package com.project.codereview.client.github
22

3-
import com.project.codereview.core.controller.CodeReviewController
4-
import org.slf4j.LoggerFactory
53
import org.springframework.beans.factory.annotation.Value
64
import org.springframework.stereotype.Service
75
import org.springframework.web.reactive.function.client.WebClient
@@ -10,90 +8,19 @@ import org.springframework.web.reactive.function.client.WebClient
108
class GithubDiffClient(
119
@param:Value("\${app.github.api.token}") private val token: String,
1210
) {
13-
data class FileDiff(
14-
val filePath: String,
15-
val content: String,
16-
val line: Int
17-
)
18-
private val logger = LoggerFactory.getLogger(GithubDiffClient::class.java)
19-
20-
private val headerRegex = Regex("""a/(.+?) b/(.+)""")
21-
private val hunkHeaderRegex = Regex("""\@\@ [^+]+\+(\d+),?(\d+)? \@\@""")
22-
private val deletedFileRegex = Regex("""\+\+\+ /dev/null""")
23-
2411
private val client = WebClient.builder()
2512
.baseUrl("https://api.github.com")
2613
.defaultHeader("Accept", "application/vnd.github.v3.diff")
2714
.build()
2815

29-
private fun findLastChangedLine(diffContent: String): Int {
30-
// 삭제된 파일이면 무조건 0 리턴
31-
if (deletedFileRegex.containsMatchIn(diffContent)) {
32-
return 0
33-
}
34-
35-
var maxLastLine = 0
36-
var currentLineNumber = 0
37-
38-
for (line in diffContent.lines()) {
39-
val match = hunkHeaderRegex.find(line)
40-
if (match != null) {
41-
currentLineNumber = match.groupValues[1].toInt()
42-
continue
43-
}
44-
45-
when {
46-
line.startsWith("+") && !line.startsWith("+++") -> {
47-
if (currentLineNumber > maxLastLine) {
48-
maxLastLine = currentLineNumber
49-
}
50-
currentLineNumber++
51-
}
52-
!line.startsWith("-") -> {
53-
currentLineNumber++
54-
}
55-
}
56-
}
57-
58-
return maxLastLine
59-
}
60-
61-
private fun splitDiffByFile(diff: String): List<FileDiff> =
62-
diff.split("diff --git")
63-
.drop(1)
64-
.mapNotNull { chunk ->
65-
val lines = chunk.trim().lines()
66-
val header = lines.firstOrNull() ?: return@mapNotNull null
67-
68-
val match = headerRegex.find(header)
69-
val filePath = match?.groupValues?.getOrNull(1)?.trim()
70-
if (filePath.isNullOrBlank()) {
71-
logger.warn("잘못된 diff 헤더 감지됨 = {}", header)
72-
return@mapNotNull null
73-
}
74-
75-
val content = "diff --git $chunk".trim()
76-
val lastChangedLine = findLastChangedLine(chunk)
77-
78-
// 삭제된 파일(line=0)은 코멘트 불가능하므로 필터링
79-
if (lastChangedLine == 0) {
80-
logger.info("삭제된 파일 감지, 코멘트 대상 제외: {}", filePath)
81-
return@mapNotNull null
82-
}
83-
84-
FileDiff(filePath, content, lastChangedLine)
85-
}
86-
8716
fun getPrDiff(
8817
owner: String,
8918
repo: String,
9019
prNumber: String
91-
): List<FileDiff> = splitDiffByFile(
92-
client.get()
93-
.uri("/repos/$owner/$repo/pulls/$prNumber")
94-
.header("Authorization", "Bearer $token")
95-
.retrieve()
96-
.bodyToMono(String::class.java)
97-
.block()!!
98-
)
20+
): String = client.get()
21+
.uri("/repos/$owner/$repo/pulls/$prNumber")
22+
.header("Authorization", "Bearer $token")
23+
.retrieve()
24+
.bodyToMono(String::class.java)
25+
.block()!!
9926
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package com.project.codereview.client.github
2+
3+
import org.slf4j.LoggerFactory
4+
5+
object GithubDiffUtils {
6+
data class FileDiff(
7+
val filePath: String,
8+
val content: String,
9+
val line: Int
10+
)
11+
12+
private val logger = LoggerFactory.getLogger(GithubDiffUtils::class.java)
13+
14+
private val headerRegex = Regex("""a/(.+?) b/(.+)""")
15+
private val hunkHeaderRegex = Regex("""\@\@ [^+]+\+(\d+),?(\d+)? \@\@""")
16+
private val deletedFileRegex = Regex("""\+\+\+ /dev/null""")
17+
18+
private fun findLastChangedLine(diffContent: String): Int {
19+
// 삭제된 파일이면 무조건 0 리턴
20+
if (deletedFileRegex.containsMatchIn(diffContent)) {
21+
return 0
22+
}
23+
24+
var maxLastLine = 0
25+
var currentLineNumber = 0
26+
27+
for (line in diffContent.lines()) {
28+
val match = hunkHeaderRegex.find(line)
29+
if (match != null) {
30+
currentLineNumber = match.groupValues[1].toInt()
31+
continue
32+
}
33+
34+
when {
35+
line.startsWith("+") && !line.startsWith("+++") -> {
36+
if (currentLineNumber > maxLastLine) {
37+
maxLastLine = currentLineNumber
38+
}
39+
currentLineNumber++
40+
}
41+
42+
!line.startsWith("-") -> {
43+
currentLineNumber++
44+
}
45+
}
46+
}
47+
48+
return maxLastLine
49+
}
50+
51+
fun splitDiffByFile(diff: String): List<FileDiff> = diff.split("diff --git")
52+
.drop(1)
53+
.mapNotNull { chunk ->
54+
val lines = chunk.trim().lines()
55+
val header = lines.firstOrNull() ?: return@mapNotNull null
56+
57+
val match = headerRegex.find(header)
58+
val filePath = match?.groupValues?.getOrNull(1)?.trim()
59+
if (filePath.isNullOrBlank()) {
60+
logger.warn("잘못된 diff 헤더 감지됨 = {}", header)
61+
return@mapNotNull null
62+
}
63+
64+
val content = "diff --git $chunk".trim()
65+
val lastChangedLine = findLastChangedLine(chunk)
66+
67+
// 삭제된 파일(line=0)은 코멘트 불가능하므로 필터링
68+
if (lastChangedLine == 0) {
69+
logger.info("삭제된 파일 감지, 코멘트 대상 제외: {}", filePath)
70+
return@mapNotNull null
71+
}
72+
73+
FileDiff(filePath, content, lastChangedLine)
74+
}
75+
}

src/main/kotlin/com/project/codereview/core/dto/GithubReviewDto.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
package com.project.codereview.core.dto
22

33
import com.project.codereview.client.github.GithubDiffClient
4+
import com.project.codereview.client.github.GithubDiffUtils
45
import com.project.codereview.client.github.GithubReviewClient
56

67
data class GithubReviewDto(
78
val payload: PullRequestPayload,
8-
val diff: GithubDiffClient.FileDiff,
9+
val diff: GithubDiffUtils.FileDiff,
910
val review: String
1011
) {
1112
fun toReviewCommentRequest() = GithubReviewClient.ReviewCommentRequest(
Lines changed: 6 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
11
package com.project.codereview.core.service
22

3-
import com.project.codereview.batch.FailedTaskManager
4-
import com.project.codereview.client.github.GithubDiffClient
5-
import com.project.codereview.client.github.GithubReviewClient
6-
import com.project.codereview.client.google.GoogleGeminiClient
73
import com.project.codereview.core.dto.GithubPayload
8-
import com.project.codereview.core.dto.GithubReviewDto
9-
import com.project.codereview.core.dto.PullRequestPayload
104
import kotlinx.coroutines.Dispatchers
115
import kotlinx.coroutines.async
126
import kotlinx.coroutines.awaitAll
@@ -15,82 +9,30 @@ import kotlinx.coroutines.sync.Semaphore
159
import kotlinx.coroutines.sync.withPermit
1610
import org.slf4j.LoggerFactory
1711
import org.springframework.stereotype.Service
18-
import java.util.concurrent.PriorityBlockingQueue
1912

2013
@Service
2114
class CodeReviewService(
22-
private val githubDiffClient: GithubDiffClient,
23-
private val googleGeminiClient: GoogleGeminiClient,
24-
private val githubReviewClient: GithubReviewClient,
25-
private val failedTaskManager: FailedTaskManager
15+
private val preparer: DiffTaskPreparer,
16+
private val worker: ReviewWorker
2617
) {
2718
private val logger = LoggerFactory.getLogger(CodeReviewService::class.java)
2819

29-
data class ReviewTask(
30-
val payload: PullRequestPayload,
31-
val part: GithubDiffClient.FileDiff,
32-
val priority: Int
33-
) : Comparable<ReviewTask> {
34-
override fun compareTo(other: ReviewTask): Int {
35-
// priority 낮은 숫자가 높은 우선순위
36-
return this.priority.compareTo(other.priority)
37-
}
38-
}
39-
4020
suspend fun review(payload: GithubPayload) = coroutineScope {
41-
val parts = githubDiffClient.getPrDiff(
42-
payload.pull_request.owner,
43-
payload.pull_request.repo,
44-
payload.pull_request.prNumber
45-
)
46-
47-
val queue = PriorityBlockingQueue<ReviewTask>()
48-
49-
// 우선순위 부여 — 여기서는 짧은 diff 기준
50-
parts.forEach { part ->
51-
val priority = part.content.length
52-
queue.put(ReviewTask(payload.pull_request, part, priority))
53-
}
54-
55-
// 동시 실행 수 계산
56-
val availableCores = Runtime.getRuntime().availableProcessors()
57-
val maxConcurrency = (availableCores * 2).coerceAtLeast(5)
21+
val queue = preparer.prepareTasks(payload.pull_request)
22+
val maxConcurrency = (Runtime.getRuntime().availableProcessors() * 2).coerceAtLeast(5)
5823
val semaphore = Semaphore(maxConcurrency)
5924

60-
logger.info("[Review Task Start] total={}, maxConcurrency={}", queue.size, maxConcurrency)
25+
logger.info("[Review Start] total={}, concurrency={}", queue.size, maxConcurrency)
6126

62-
// Worker 코루틴 실행
6327
val workers = (1..maxConcurrency).map {
6428
async(Dispatchers.IO) {
6529
while (true) {
6630
val task = queue.poll() ?: break
67-
68-
val part = task.part
69-
val filePath = part.filePath
70-
71-
semaphore.withPermit {
72-
val prompt = "```diff\n${part.content}\n```"
73-
74-
runCatching {
75-
val review = googleGeminiClient.chat(filePath, prompt)
76-
77-
if(review != null) {
78-
githubReviewClient.addReviewComment(
79-
GithubReviewDto(task.payload, part, review)
80-
)
81-
82-
logger.info("[Review Complete] file={}", filePath)
83-
}
84-
}.onFailure {
85-
logger.error("[Review Failed] add to retry queue")
86-
failedTaskManager.add(FailedTaskManager.OriginalTask(payload, part), prompt)
87-
}
88-
}
31+
semaphore.withPermit { worker.process(payload, task) }
8932
}
9033
}
9134
}
9235

9336
workers.awaitAll()
94-
logger.info("[Review Task Dispatched] total={}", parts.size)
9537
}
9638
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.project.codereview.core.service
2+
3+
import com.project.codereview.client.github.GithubDiffClient
4+
import com.project.codereview.client.github.GithubDiffUtils
5+
import com.project.codereview.core.dto.PullRequestPayload
6+
import org.springframework.stereotype.Service
7+
import java.util.concurrent.PriorityBlockingQueue
8+
9+
@Service
10+
class DiffTaskPreparer(
11+
private val githubDiffClient: GithubDiffClient
12+
) {
13+
data class ReviewTask(
14+
val payload: PullRequestPayload,
15+
val part: GithubDiffUtils.FileDiff,
16+
val priority: Int
17+
) : Comparable<ReviewTask> {
18+
override fun compareTo(
19+
other: ReviewTask
20+
): Int = this.priority.compareTo(other.priority)
21+
}
22+
23+
fun prepareTasks(payload: PullRequestPayload): PriorityBlockingQueue<ReviewTask> {
24+
val queue = PriorityBlockingQueue<ReviewTask>()
25+
26+
val diff = githubDiffClient.getPrDiff(payload.owner, payload.repo, payload.prNumber)
27+
val parts = GithubDiffUtils.splitDiffByFile(diff)
28+
parts.forEach { part ->
29+
val priority = part.content.length
30+
queue.put(ReviewTask(payload, part, priority))
31+
}
32+
33+
return queue
34+
}
35+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.project.codereview.core.service
2+
3+
import com.project.codereview.batch.FailedTaskManager
4+
import com.project.codereview.client.github.GithubReviewClient
5+
import com.project.codereview.client.google.GoogleGeminiClient
6+
import com.project.codereview.core.dto.GithubPayload
7+
import com.project.codereview.core.dto.GithubReviewDto
8+
import org.springframework.stereotype.Service
9+
10+
@Service
11+
class ReviewWorker(
12+
private val googleGeminiClient: GoogleGeminiClient,
13+
private val githubReviewClient: GithubReviewClient,
14+
private val failedTaskManager: FailedTaskManager
15+
) {
16+
suspend fun process(payload: GithubPayload, task: DiffTaskPreparer.ReviewTask) {
17+
val prompt = "```diff\n${task.part.content}\n```"
18+
val filePath = task.part.filePath
19+
20+
runCatching {
21+
val review = googleGeminiClient.chat(filePath, prompt)
22+
if (review != null) {
23+
githubReviewClient.addReviewComment(GithubReviewDto(task.payload, task.part, review))
24+
}
25+
}.onFailure {
26+
failedTaskManager.add(FailedTaskManager.OriginalTask(payload, task.part), prompt)
27+
}
28+
}
29+
}

0 commit comments

Comments
 (0)