From b2e1bacb6f936a7be443c02e41efa12091d9f029 Mon Sep 17 00:00:00 2001 From: Yujin1219 Date: Thu, 7 May 2026 14:46:43 +0900 Subject: [PATCH 1/5] =?UTF-8?q?refactor:=20merge=20=EC=BB=A4=EB=B0=8B?= =?UTF-8?q?=EC=9D=B4=20=EB=AA=A8=EB=91=90=20=EC=A0=9C=EC=99=B8=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../whylog/server/domain/git/service/GitCommandServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java b/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java index 4748bb8..52cf16b 100644 --- a/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java +++ b/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java @@ -168,7 +168,7 @@ private void syncCommits(GHRepository ghRepository, Repository repository, Local var shortInfo = ghCommit.getCommitShortInfo(); String message = shortInfo.getMessage(); - if (message.startsWith("Merge pull request")) return null; + if (ghCommit.getParentSHA1s().size() > 1) return null; String authorProfileImage = (ghCommit.getAuthor() != null) ? ghCommit.getAuthor().getAvatarUrl() : null; From bc97de00aede2497e1b3233f48a26d79a0dcf55b Mon Sep 17 00:00:00 2001 From: Yujin1219 Date: Thu, 7 May 2026 14:52:31 +0900 Subject: [PATCH 2/5] =?UTF-8?q?refactor:=20=EB=B6=84=EC=84=9D=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EC=8B=9C=20commitHash=EB=A5=BC=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=8B=9D=EB=B3=84=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../external/fast/dto/request/CommitAnalyzeRequest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/whylog/server/global/external/fast/dto/request/CommitAnalyzeRequest.java b/src/main/java/com/whylog/server/global/external/fast/dto/request/CommitAnalyzeRequest.java index 5fd5810..f4cf6cb 100644 --- a/src/main/java/com/whylog/server/global/external/fast/dto/request/CommitAnalyzeRequest.java +++ b/src/main/java/com/whylog/server/global/external/fast/dto/request/CommitAnalyzeRequest.java @@ -5,9 +5,9 @@ @Schema(description = "FastAPI 커밋 분석 요청") public record CommitAnalyzeRequest( - @Schema(description = "커밋 ID") + @Schema(description = "커밋 ID", nullable = true) Integer commitId, - @Schema(description = "Git 커밋 해시", nullable = true) + @Schema(description = "Git 커밋 해시") String commitHash, @Schema(description = "Spring 레포지토리 ID") Integer repositoryId, From 1badb3a933b0da2f6bed8fe214c845e0b8968e0d Mon Sep 17 00:00:00 2001 From: Yujin1219 Date: Thu, 7 May 2026 15:46:21 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EB=A0=88=ED=8F=AC=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=ED=9B=84=20=EC=BB=A4=EB=B0=8B=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20run=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EA=B2=B0=EA=B3=BC=20=EC=A0=80=EC=9E=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/git/entity/CommitAnalysis.java | 19 +- .../repository/CommitAnalysisRepository.java | 13 ++ .../git/service/GitCommandServiceImpl.java | 182 +++++++++++++++++- .../global/external/fast/FastApiInfo.java | 3 +- 4 files changed, 212 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/whylog/server/domain/git/repository/CommitAnalysisRepository.java diff --git a/src/main/java/com/whylog/server/domain/git/entity/CommitAnalysis.java b/src/main/java/com/whylog/server/domain/git/entity/CommitAnalysis.java index bb8af2b..ff83465 100644 --- a/src/main/java/com/whylog/server/domain/git/entity/CommitAnalysis.java +++ b/src/main/java/com/whylog/server/domain/git/entity/CommitAnalysis.java @@ -8,7 +8,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; @@ -25,7 +25,20 @@ public class CommitAnalysis extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "commit_id", nullable = false) + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "commit_id", nullable = false, unique = true) private Commit commit; + + @Column(name = "summary", columnDefinition = "TEXT") + private String summary; + + public static CommitAnalysis create(Commit commit) { + CommitAnalysis commitAnalysis = new CommitAnalysis(); + commitAnalysis.commit = commit; + return commitAnalysis; + } + + public void updateSummary(String summary) { + this.summary = summary; + } } diff --git a/src/main/java/com/whylog/server/domain/git/repository/CommitAnalysisRepository.java b/src/main/java/com/whylog/server/domain/git/repository/CommitAnalysisRepository.java new file mode 100644 index 0000000..2b25cba --- /dev/null +++ b/src/main/java/com/whylog/server/domain/git/repository/CommitAnalysisRepository.java @@ -0,0 +1,13 @@ +package com.whylog.server.domain.git.repository; + +import com.whylog.server.domain.git.entity.CommitAnalysis; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CommitAnalysisRepository extends JpaRepository { + + // 커밋 ID로 분석 결과를 조회 + Optional findByCommitId(Long commitId); +} diff --git a/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java b/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java index 52cf16b..8f4e6cf 100644 --- a/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java +++ b/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java @@ -1,15 +1,24 @@ package com.whylog.server.domain.git.service; +import com.fasterxml.jackson.databind.JsonNode; import com.whylog.server.domain.git.dto.GitRequest; import com.whylog.server.domain.git.dto.GitResponse; +import com.whylog.server.domain.git.entity.CommitAnalysis; import com.whylog.server.domain.git.entity.Commit; import com.whylog.server.domain.git.entity.Repository; import com.whylog.server.domain.git.exception.GitErrorCode; import com.whylog.server.domain.git.exception.GitTokenNotRegisteredException; import com.whylog.server.domain.git.exception.RepositoryNotFoundException; +import com.whylog.server.domain.git.repository.CommitAnalysisRepository; import com.whylog.server.domain.git.repository.CommitRepository; import com.whylog.server.domain.git.repository.RepositoryRepository; import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; +import com.whylog.server.global.external.fast.client.FastApiCommitClient; +import com.whylog.server.global.external.fast.dto.FastApiResponse; +import com.whylog.server.global.external.fast.dto.request.ChangedFile; +import com.whylog.server.global.external.fast.dto.request.CommitAnalyzeRequest; +import com.whylog.server.global.external.fast.exception.FastApiErrorCode; +import com.whylog.server.global.external.fast.exception.FastApiException; import com.whylog.server.global.util.github.GitHubUtil; import com.whylog.server.domain.team.entity.Team; import com.whylog.server.domain.team.service.TeamUseCase; @@ -24,11 +33,14 @@ import org.kohsuke.github.HttpException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.io.IOException; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.*; +import java.util.concurrent.CompletableFuture; @Service @RequiredArgsConstructor @@ -37,6 +49,8 @@ public class GitCommandServiceImpl implements GitCommandService { private final RepositoryRepository repositoryRepository; private final CommitRepository commitRepository; + private final CommitAnalysisRepository commitAnalysisRepository; + private final FastApiCommitClient fastApiCommitClient; private final TeamUseCase teamUseCase; private final MemberUseCase memberUseCase; @@ -197,13 +211,179 @@ private void syncCommits(GHRepository ghRepository, Repository repository, Local .toList(); if (!newCommits.isEmpty()) { - commitRepository.saveAll(newCommits); + List savedCommits = commitRepository.saveAllAndFlush(newCommits); + triggerCommitAnalyzeRunsAfterCommit(ghRepository, repository, savedCommits); log.info("새로운 커밋 {}개 동기화 완료: {}", newCommits.size(), ghRepository.getFullName()); } else { log.info("새로 동기화할 커밋이 없습니다: {}", ghRepository.getFullName()); } } + /** + * 커밋 저장 커밋 이후에 분석 run 생성을 비동기로 시작합니다. + */ + private void triggerCommitAnalyzeRunsAfterCommit(GHRepository ghRepository, Repository repository, List commits) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + triggerCommitAnalyzeRunsAsync(ghRepository, repository, commits); + } + }); + return; + } + + triggerCommitAnalyzeRunsAsync(ghRepository, repository, commits); + } + + /** + * 저장된 커밋들에 대해 FastAPI 분석 run 생성을 비동기로 순차 실행합니다. + */ + private void triggerCommitAnalyzeRunsAsync(GHRepository ghRepository, Repository repository, List commits) { + CompletableFuture.runAsync(() -> commits.forEach(commit -> createCommitAnalyzeRun(ghRepository, repository, commit))) + .exceptionally(ex -> { + log.warn("커밋 분석 run 생성 비동기 작업 실패: repositoryId={}", repository.getId(), ex); + return null; + }); + } + + /** + * 커밋별 변경 파일을 수집해 FastAPI 분석 run을 생성하고 완료 결과를 저장합니다. + */ + private void createCommitAnalyzeRun(GHRepository ghRepository, Repository repository, Commit commit) { + try { + GHCommit ghCommit = ghRepository.getCommit(commit.getHash()); + List changedFiles = ghCommit.listFiles().toList().stream() + .map(this::toChangedFile) + .toList(); + + CommitAnalyzeRequest request = new CommitAnalyzeRequest( + null, + commit.getHash(), + repository.getId().intValue(), + commit.getMessage(), + changedFiles + ); + + FastApiResponse createResponse = fastApiCommitClient.analyzeCommit(request); + JsonNode createResult = requireResult(createResponse); + String runId = readText(createResult, "run_id"); + if (runId == null || runId.isBlank()) { + throw new FastApiException(FastApiErrorCode.FAST_API_RESPONSE_EMPTY); + } + + JsonNode runResult = pollCommitAnalyzeRun(runId); + saveCommitAnalysis(commit, runResult); + } catch (Exception e) { + log.warn("커밋 분석 run 생성 실패: commitHash={}", commit.getHash(), e); + } + } + + /** + * FastAPI 분석 run 상태를 폴링해 summary가 준비된 최종 결과를 가져옵니다. + */ + private JsonNode pollCommitAnalyzeRun(String runId) { + for (int attempt = 1; attempt <= 120; attempt++) { + FastApiResponse response = fastApiCommitClient.getCommitAnalyzeRun(runId); + JsonNode runResult = response != null ? response.result() : null; + + if (runResult == null) { + sleep(3000L); + continue; + } + + String status = readText(runResult, "status"); + String phase = readText(runResult, "phase"); + String summary = readNestedText(runResult, "result", "summary"); + + if (isFailed(status, phase)) { + throw new FastApiException(FastApiErrorCode.FAST_API_REQUEST_FAILED); + } + + if (summary != null && !summary.isBlank()) { + return runResult; + } + + sleep(3000L); + } + + throw new FastApiException(FastApiErrorCode.FAST_API_REQUEST_FAILED); + } + + /** + * 완료된 분석 결과에서 summary를 upsert 저장합니다. + */ + private void saveCommitAnalysis(Commit commit, JsonNode runResult) { + String summary = readNestedText(runResult, "result", "summary"); + + if (summary == null || summary.isBlank()) { + throw new FastApiException(FastApiErrorCode.FAST_API_RESPONSE_EMPTY); + } + + CommitAnalysis commitAnalysis = commitAnalysisRepository.findByCommitId(commit.getId()) + .orElseGet(() -> CommitAnalysis.create(commit)); + commitAnalysis.updateSummary(summary); + commitAnalysisRepository.save(commitAnalysis); + + log.info("커밋 분석 저장 완료: commitHash={}", commit.getHash()); + } + + /** + * FastAPI run 상태가 실패인지 확인합니다. + */ + private boolean isFailed(String status, String phase) { + return "failed".equalsIgnoreCase(status) || "failed".equalsIgnoreCase(phase); + } + + /** + * JSON 필드의 문자열 값을 안전하게 읽습니다. + */ + private String readText(JsonNode node, String fieldName) { + JsonNode value = node != null ? node.get(fieldName) : null; + return value != null ? value.asText(null) : null; + } + + /** + * 중첩 JSON 필드의 문자열 값을 안전하게 읽습니다. + */ + private String readNestedText(JsonNode node, String parentFieldName, String childFieldName) { + JsonNode parent = node != null ? node.get(parentFieldName) : null; + JsonNode child = parent != null ? parent.get(childFieldName) : null; + return child != null ? child.asText(null) : null; + } + + /** + * FastAPI 응답의 result가 비어 있으면 예외를 던집니다. + */ + private T requireResult(FastApiResponse response) { + if (response == null || response.result() == null) { + throw new FastApiException(FastApiErrorCode.FAST_API_RESPONSE_EMPTY); + } + return response.result(); + } + + /** + * GitHub 커밋 파일 정보를 FastAPI 요청용 changed file로 변환합니다. + */ + private ChangedFile toChangedFile(GHCommit.File file) { + return new ChangedFile( + file.getFileName(), + file.getPatch() != null ? file.getPatch() : "" + ); + } + + /** + * 폴링 사이에 잠시 대기합니다. + */ + private void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new FastApiException(FastApiErrorCode.FAST_API_REQUEST_FAILED, e); + } + } + /** * GitHub Token 만료 시 처리합니다 (API 401 에러 감지) * token을 초기화하여 사용자가 재인증하도록 유도합니다. diff --git a/src/main/java/com/whylog/server/global/external/fast/FastApiInfo.java b/src/main/java/com/whylog/server/global/external/fast/FastApiInfo.java index 34bfb99..8189e5c 100644 --- a/src/main/java/com/whylog/server/global/external/fast/FastApiInfo.java +++ b/src/main/java/com/whylog/server/global/external/fast/FastApiInfo.java @@ -19,7 +19,8 @@ public enum FastApiInfo { MEETING_ANALYSIS_EXTRACT(HttpMethod.POST, "/api/meeting-analysis/extract", FastApiRequestBodyType.JSON), MEETING_ANALYSIS_EMBEDDINGS(HttpMethod.POST, "/api/meeting-analysis/embeddings", FastApiRequestBodyType.JSON), - COMMIT_ANALYZE(HttpMethod.POST, "/api/commit/analyze", FastApiRequestBodyType.JSON), + COMMIT_ANALYZE(HttpMethod.POST, "/api/commit/analyze/runs", FastApiRequestBodyType.JSON), + COMMIT_ANALYZE_RUN_STATUS(HttpMethod.GET, "/api/commit/analyze/runs/{run_id}", FastApiRequestBodyType.NONE), COMMIT_MATCH(HttpMethod.POST, "/api/commit/match", FastApiRequestBodyType.JSON); private final HttpMethod method; From 4b14a40c5697ae7a1923e0c063e574f682a043a7 Mon Sep 17 00:00:00 2001 From: Yujin1219 Date: Thu, 7 May 2026 15:47:21 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20FastAPI=20JSON=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=20=EC=A0=84=EC=86=A1=20=EB=B0=A9=EC=8B=9D=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/external/fast/client/FastApiClient.java | 13 ++++++++----- .../external/fast/client/FastApiCommitClient.java | 14 ++++++++++++++ .../fast/client/FastApiMeetingAnalysisClient.java | 5 +++++ .../external/fast/client/FastApiSystemClient.java | 5 +++++ .../fast/client/FastApiTranscribeClient.java | 5 +++++ 5 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/whylog/server/global/external/fast/client/FastApiClient.java b/src/main/java/com/whylog/server/global/external/fast/client/FastApiClient.java index ce8df47..7b5c1d8 100644 --- a/src/main/java/com/whylog/server/global/external/fast/client/FastApiClient.java +++ b/src/main/java/com/whylog/server/global/external/fast/client/FastApiClient.java @@ -13,6 +13,7 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.lang.Nullable; import org.springframework.util.LinkedMultiValueMap; @@ -30,8 +31,12 @@ public class FastApiClient { @Value("${fast.api.base-url}") private String baseUrl; - protected FastApiClient() { - this.restClient = RestClient.create(); + protected FastApiClient(RestClient.Builder restClientBuilder) { + // Spring Boot가 application.yaml(SNAKE_CASE 등)을 적용해 구성한 Builder를 그대로 사용해야 + // 요청 직렬화에서도 글로벌 Jackson 설정이 반영된다. + this.restClient = restClientBuilder + .requestFactory(new SimpleClientHttpRequestFactory()) + .build(); } protected T get(FastApiInfo fastApiInfo, @@ -78,10 +83,8 @@ private T execute(FastApiInfo fastApiInfo, .body(responseType); } - if (fastApiInfo.getFastApiRequestBodyType() == FastApiRequestBodyType.JSON) { - bodySpec.contentType(MediaType.APPLICATION_JSON); - } if (jsonBody != null) { + bodySpec.contentType(MediaType.APPLICATION_JSON); return bodySpec.body(jsonBody) .retrieve() .body(responseType); diff --git a/src/main/java/com/whylog/server/global/external/fast/client/FastApiCommitClient.java b/src/main/java/com/whylog/server/global/external/fast/client/FastApiCommitClient.java index 75473a0..26be42f 100644 --- a/src/main/java/com/whylog/server/global/external/fast/client/FastApiCommitClient.java +++ b/src/main/java/com/whylog/server/global/external/fast/client/FastApiCommitClient.java @@ -8,10 +8,15 @@ import com.whylog.server.global.external.fast.dto.request.CommitAnalyzeRequest; import org.springframework.core.ParameterizedTypeReference; import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; @Component public class FastApiCommitClient extends FastApiClient { + public FastApiCommitClient(RestClient.Builder restClientBuilder) { + super(restClientBuilder); + } + public FastApiResponse analyzeCommit(CommitAnalyzeRequest request) { return postJson( FastApiInfo.COMMIT_ANALYZE, @@ -21,6 +26,15 @@ public FastApiResponse analyzeCommit(CommitAnalyzeRequest request) { ); } + public FastApiResponse getCommitAnalyzeRun(String runId) { + return get( + FastApiInfo.COMMIT_ANALYZE_RUN_STATUS, + Map.of("run_id", runId), + new ParameterizedTypeReference<>() { + } + ); + } + public FastApiResponse matchApplicationCommits(Map request) { return postJson( FastApiInfo.COMMIT_MATCH, diff --git a/src/main/java/com/whylog/server/global/external/fast/client/FastApiMeetingAnalysisClient.java b/src/main/java/com/whylog/server/global/external/fast/client/FastApiMeetingAnalysisClient.java index 10dd5e2..10f0003 100644 --- a/src/main/java/com/whylog/server/global/external/fast/client/FastApiMeetingAnalysisClient.java +++ b/src/main/java/com/whylog/server/global/external/fast/client/FastApiMeetingAnalysisClient.java @@ -8,10 +8,15 @@ import com.whylog.server.global.external.fast.dto.request.MeetingAnalysisRequest; import org.springframework.core.ParameterizedTypeReference; import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; @Component public class FastApiMeetingAnalysisClient extends FastApiClient { + public FastApiMeetingAnalysisClient(RestClient.Builder restClientBuilder) { + super(restClientBuilder); + } + public FastApiResponse extractMeetingAnalysis(MeetingAnalysisRequest request) { return postJson( FastApiInfo.MEETING_ANALYSIS_EXTRACT, diff --git a/src/main/java/com/whylog/server/global/external/fast/client/FastApiSystemClient.java b/src/main/java/com/whylog/server/global/external/fast/client/FastApiSystemClient.java index 019de7d..780a67a 100644 --- a/src/main/java/com/whylog/server/global/external/fast/client/FastApiSystemClient.java +++ b/src/main/java/com/whylog/server/global/external/fast/client/FastApiSystemClient.java @@ -6,10 +6,15 @@ import com.whylog.server.global.external.fast.dto.FastApiResponse; import org.springframework.core.ParameterizedTypeReference; import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; @Component public class FastApiSystemClient extends FastApiClient { + public FastApiSystemClient(RestClient.Builder restClientBuilder) { + super(restClientBuilder); + } + public FastApiResponse> readRoot() { return get( FastApiInfo.READ_ROOT, diff --git a/src/main/java/com/whylog/server/global/external/fast/client/FastApiTranscribeClient.java b/src/main/java/com/whylog/server/global/external/fast/client/FastApiTranscribeClient.java index 931f0a2..5243d3c 100644 --- a/src/main/java/com/whylog/server/global/external/fast/client/FastApiTranscribeClient.java +++ b/src/main/java/com/whylog/server/global/external/fast/client/FastApiTranscribeClient.java @@ -11,10 +11,15 @@ import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.io.Resource; import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; @Component public class FastApiTranscribeClient extends FastApiClient { + public FastApiTranscribeClient(RestClient.Builder restClientBuilder) { + super(restClientBuilder); + } + public FastApiResponse transcribeAudio(Resource audio, String filename, String contentType, From 217047125806e69af013b34a80da8800f1859a0b Mon Sep 17 00:00:00 2001 From: Yujin1219 Date: Thu, 7 May 2026 18:33:55 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=EC=BB=A4=EB=B0=8B=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EB=8C=80=EC=83=81=20=ED=95=84=ED=84=B0=EB=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../git/service/GitCommandServiceImpl.java | 273 ++++++++++++++++-- 1 file changed, 256 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java b/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java index 8f4e6cf..88e0757 100644 --- a/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java +++ b/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java @@ -1,6 +1,8 @@ package com.whylog.server.domain.git.service; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.whylog.server.domain.git.dto.GitRequest; import com.whylog.server.domain.git.dto.GitResponse; import com.whylog.server.domain.git.entity.CommitAnalysis; @@ -28,6 +30,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.kohsuke.github.GHCommit; +import org.kohsuke.github.GHContent; import org.kohsuke.github.GHRepository; import org.kohsuke.github.GitHub; import org.kohsuke.github.HttpException; @@ -35,6 +38,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.client.RestClientResponseException; import java.io.IOException; import java.time.LocalDateTime; @@ -47,12 +51,17 @@ @Slf4j public class GitCommandServiceImpl implements GitCommandService { + private static final int MAX_COMMIT_ANALYZE_RETRY_ATTEMPTS = 3; + private static final long COMMIT_ANALYZE_FIRST_RETRY_INTERVAL_MILLIS = 30000L; + private static final long COMMIT_ANALYZE_SECOND_RETRY_INTERVAL_MILLIS = 60000L; + private final RepositoryRepository repositoryRepository; private final CommitRepository commitRepository; private final CommitAnalysisRepository commitAnalysisRepository; private final FastApiCommitClient fastApiCommitClient; private final TeamUseCase teamUseCase; private final MemberUseCase memberUseCase; + private final ObjectMapper objectMapper; /** * 사용자의 GitHub Access Token을 등록합니다. @@ -251,13 +260,20 @@ private void triggerCommitAnalyzeRunsAsync(GHRepository ghRepository, Repository * 커밋별 변경 파일을 수집해 FastAPI 분석 run을 생성하고 완료 결과를 저장합니다. */ private void createCommitAnalyzeRun(GHRepository ghRepository, Repository repository, Commit commit) { + CommitAnalyzeRequest request = null; try { GHCommit ghCommit = ghRepository.getCommit(commit.getHash()); List changedFiles = ghCommit.listFiles().toList().stream() - .map(this::toChangedFile) + .map(file -> toChangedFile(ghRepository, ghCommit, file)) + .filter(Objects::nonNull) .toList(); - CommitAnalyzeRequest request = new CommitAnalyzeRequest( + if (changedFiles.isEmpty()) { + log.info("분석 가능한 변경 파일이 없어 커밋 분석을 건너뜁니다: commitHash={}", commit.getHash()); + return; + } + + request = new CommitAnalyzeRequest( null, commit.getHash(), repository.getId().intValue(), @@ -265,16 +281,31 @@ private void createCommitAnalyzeRun(GHRepository ghRepository, Repository reposi changedFiles ); - FastApiResponse createResponse = fastApiCommitClient.analyzeCommit(request); - JsonNode createResult = requireResult(createResponse); - String runId = readText(createResult, "run_id"); - if (runId == null || runId.isBlank()) { - throw new FastApiException(FastApiErrorCode.FAST_API_RESPONSE_EMPTY); - } + for (int attempt = 1; attempt <= MAX_COMMIT_ANALYZE_RETRY_ATTEMPTS; attempt++) { + try { + FastApiResponse createResponse = fastApiCommitClient.analyzeCommit(request); + JsonNode createResult = requireResult(createResponse); + String runId = readText(createResult, "run_id"); + if (runId == null || runId.isBlank()) { + throw new FastApiException(FastApiErrorCode.FAST_API_RESPONSE_EMPTY); + } + + JsonNode runResult = pollCommitAnalyzeRun(runId); + saveCommitAnalysis(commit, runResult); + return; + } catch (FastApiException e) { + if (!isRetryableCommitAnalyzeFailure(e) || attempt == MAX_COMMIT_ANALYZE_RETRY_ATTEMPTS) { + throw e; + } - JsonNode runResult = pollCommitAnalyzeRun(runId); - saveCommitAnalysis(commit, runResult); + long retryIntervalMillis = resolveCommitAnalyzeRetryIntervalMillis(attempt); + log.warn("커밋 분석 재시도: commitHash={}, attempt={}/{}", + commit.getHash(), attempt + 1, MAX_COMMIT_ANALYZE_RETRY_ATTEMPTS); + sleep(retryIntervalMillis); + } + } } catch (Exception e) { + logCommitAnalyzeRequestOnClientError(commit, request, e); log.warn("커밋 분석 run 생성 실패: commitHash={}", commit.getHash(), e); } } @@ -294,10 +325,14 @@ private JsonNode pollCommitAnalyzeRun(String runId) { String status = readText(runResult, "status"); String phase = readText(runResult, "phase"); + String error = readText(runResult, "error"); String summary = readNestedText(runResult, "result", "summary"); if (isFailed(status, phase)) { - throw new FastApiException(FastApiErrorCode.FAST_API_REQUEST_FAILED); + throw new FastApiException( + FastApiErrorCode.FAST_API_REQUEST_FAILED, + new IllegalStateException(error != null ? error : "commit analyze run failed") + ); } if (summary != null && !summary.isBlank()) { @@ -307,7 +342,10 @@ private JsonNode pollCommitAnalyzeRun(String runId) { sleep(3000L); } - throw new FastApiException(FastApiErrorCode.FAST_API_REQUEST_FAILED); + throw new FastApiException( + FastApiErrorCode.FAST_API_REQUEST_FAILED, + new IllegalStateException("commit analyze run poll timeout") + ); } /** @@ -335,6 +373,61 @@ private boolean isFailed(String status, String phase) { return "failed".equalsIgnoreCase(status) || "failed".equalsIgnoreCase(phase); } + /** + * 커밋 분석 실패가 재시도 가능한 일시적 오류인지 확인합니다. + */ + private boolean isRetryableCommitAnalyzeFailure(FastApiException exception) { + Throwable cause = exception.getCause(); + while (cause != null) { + if (cause instanceof RestClientResponseException restClientResponseException) { + return restClientResponseException.getStatusCode().is5xxServerError(); + } + + String message = cause.getMessage(); + if (message != null) { + if (message.contains("Gemini 응답 시간이 초과되었습니다.") + || message.contains("504") + || message.contains("poll timeout")) { + return true; + } + } + + cause = cause.getCause(); + } + return false; + } + + /** + * 요청 본문 검증 실패가 발생한 경우 FastAPI로 전달한 요청 JSON을 함께 기록합니다. + */ + private void logCommitAnalyzeRequestOnClientError(Commit commit, CommitAnalyzeRequest request, Exception exception) { + Throwable cause = exception; + while (cause != null) { + if (cause instanceof RestClientResponseException restClientResponseException + && restClientResponseException.getStatusCode().is4xxClientError()) { + try { + log.warn("커밋 분석 요청 본문: commitHash={}, payload={}", + commit.getHash(), + objectMapper.writeValueAsString(request)); + } catch (JsonProcessingException jsonProcessingException) { + log.warn("커밋 분석 요청 본문 직렬화 실패: commitHash={}", commit.getHash(), jsonProcessingException); + } + return; + } + cause = cause.getCause(); + } + } + + /** + * 커밋 분석 재시도 순서에 맞는 대기 시간을 반환합니다. + */ + private long resolveCommitAnalyzeRetryIntervalMillis(int attempt) { + if (attempt == 1) { + return COMMIT_ANALYZE_FIRST_RETRY_INTERVAL_MILLIS; + } + return COMMIT_ANALYZE_SECOND_RETRY_INTERVAL_MILLIS; + } + /** * JSON 필드의 문자열 값을 안전하게 읽습니다. */ @@ -365,11 +458,27 @@ private T requireResult(FastApiResponse response) { /** * GitHub 커밋 파일 정보를 FastAPI 요청용 changed file로 변환합니다. */ - private ChangedFile toChangedFile(GHCommit.File file) { - return new ChangedFile( - file.getFileName(), - file.getPatch() != null ? file.getPatch() : "" - ); + private ChangedFile toChangedFile(GHRepository ghRepository, GHCommit ghCommit, GHCommit.File file) { + String patch = file.getPatch(); + if (patch != null && !patch.isBlank()) { + return new ChangedFile(file.getFileName(), patch); + } + + try { + if (isBinaryFile(file)) { + return null; + } + + String fallbackPatch = buildFallbackPatch(ghRepository, ghCommit, file); + if (fallbackPatch == null || fallbackPatch.isBlank()) { + return null; + } + + return new ChangedFile(file.getFileName(), fallbackPatch); + } catch (IOException e) { + log.warn("변경 파일 patch 생성 실패: fileName={}, message={}", file.getFileName(), e.getMessage()); + return null; + } } /** @@ -384,6 +493,136 @@ private void sleep(long millis) { } } + /** + * patch 가 없는 파일에 대해 before/after 내용을 다시 조회하여 fallback diff 를 생성합니다. + */ + private String buildFallbackPatch(GHRepository ghRepository, GHCommit ghCommit, GHCommit.File file) throws IOException { + String beforePath = file.getPreviousFilename() != null ? file.getPreviousFilename() : file.getFileName(); + String afterPath = file.getFileName(); + String status = file.getStatus(); + + String beforeContent = null; + String afterContent = null; + + if (!"added".equalsIgnoreCase(status)) { + beforeContent = readFileContentIfExists(ghRepository, beforePath, resolveParentSha(ghCommit)); + } + if (!"removed".equalsIgnoreCase(status)) { + afterContent = readFileContentIfExists(ghRepository, afterPath, resolveCurrentSha(ghCommit)); + } + + if (beforeContent == null && afterContent == null) { + return null; + } + + return buildUnifiedDiff(beforePath, afterPath, beforeContent, afterContent); + } + + /** + * 현재 파일이 바이너리 파일인지 추정합니다. + */ + private boolean isBinaryFile(GHCommit.File file) { + String fileName = file.getFileName().toLowerCase(); + return fileName.endsWith(".png") + || fileName.endsWith(".jpg") + || fileName.endsWith(".jpeg") + || fileName.endsWith(".gif") + || fileName.endsWith(".webp") + || fileName.endsWith(".svg") + || fileName.endsWith(".pdf") + || fileName.endsWith(".zip") + || fileName.endsWith(".jar") + || fileName.endsWith(".war") + || fileName.endsWith(".class") + || fileName.endsWith(".mp3") + || fileName.endsWith(".mp4") + || fileName.endsWith(".mov") + || fileName.endsWith(".avi") + || fileName.endsWith(".wav") + || fileName.endsWith(".ttf") + || fileName.endsWith(".otf") + || fileName.endsWith(".woff") + || fileName.endsWith(".woff2") + || fileName.endsWith(".ico"); + } + + /** + * 특정 ref 기준 파일 내용을 조회하고 없으면 null 을 반환합니다. + */ + private String readFileContentIfExists(GHRepository ghRepository, String path, String ref) throws IOException { + if (path == null || ref == null || ref.isBlank()) { + return null; + } + + try { + GHContent content = ghRepository.getFileContent(path, ref); + try (var inputStream = content.read()) { + byte[] bytes = inputStream.readAllBytes(); + if (containsNullByte(bytes)) { + return null; + } + return new String(bytes); + } + } catch (HttpException e) { + return null; + } + } + + /** + * fallback diff 생성을 위해 현재 커밋 sha 를 반환합니다. + */ + private String resolveCurrentSha(GHCommit ghCommit) { + return ghCommit.getSHA1(); + } + + /** + * fallback diff 생성을 위해 부모 커밋 sha 를 반환합니다. + */ + private String resolveParentSha(GHCommit ghCommit) { + List parentSha1s = ghCommit.getParentSHA1s(); + if (parentSha1s == null || parentSha1s.isEmpty()) { + return null; + } + return parentSha1s.get(0); + } + + /** + * before/after 전체 내용을 기반으로 단순 unified diff 문자열을 생성합니다. + */ + private String buildUnifiedDiff(String beforePath, String afterPath, String beforeContent, String afterContent) { + List beforeLines = beforeContent == null ? List.of() : Arrays.asList(beforeContent.split("\\R", -1)); + List afterLines = afterContent == null ? List.of() : Arrays.asList(afterContent.split("\\R", -1)); + + if (beforeLines.equals(afterLines)) { + return null; + } + + StringBuilder diff = new StringBuilder(); + diff.append("--- a/").append(beforePath).append("\n"); + diff.append("+++ b/").append(afterPath).append("\n"); + diff.append("@@ -1,").append(beforeLines.size()).append(" +1,").append(afterLines.size()).append(" @@\n"); + + for (String line : beforeLines) { + diff.append("-").append(line).append("\n"); + } + for (String line : afterLines) { + diff.append("+").append(line).append("\n"); + } + return diff.toString(); + } + + /** + * 파일 바이트 배열에 null byte 가 포함되어 있으면 바이너리로 간주합니다. + */ + private boolean containsNullByte(byte[] bytes) { + for (byte value : bytes) { + if (value == 0) { + return true; + } + } + return false; + } + /** * GitHub Token 만료 시 처리합니다 (API 401 에러 감지) * token을 초기화하여 사용자가 재인증하도록 유도합니다.