diff --git a/gss-api-app/src/main/java/com/devoops/service/facade/RepositoryFacadeService.java b/gss-api-app/src/main/java/com/devoops/service/facade/RepositoryFacadeService.java index fa2f1381..85d46084 100644 --- a/gss-api-app/src/main/java/com/devoops/service/facade/RepositoryFacadeService.java +++ b/gss-api-app/src/main/java/com/devoops/service/facade/RepositoryFacadeService.java @@ -17,6 +17,7 @@ import com.devoops.service.github.WebHookService; import com.devoops.service.repository.RepositoryService; import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -46,6 +47,13 @@ public GithubRepository save(RepositorySaveRequest request, User user) { private GithubRepository saveRepository(GithubRepoUrl url, User user) { GithubRepoInfoResponse repositoryInfo = gitHubService.getRepositoryInfo(url, user.getGithubToken()); + long externalId = repositoryInfo.id(); + Optional alreadyRegisteredRepo = repositoryService.findByUserAndExternalId(user, externalId); + + if(alreadyRegisteredRepo.isPresent()) { + return reTrackingOrThrowException(user, alreadyRegisteredRepo.get()); + } + RepositoryCreateCommand createCommand = new RepositoryCreateCommand( user.getId(), repositoryInfo.name(), @@ -57,6 +65,13 @@ private GithubRepository saveRepository(GithubRepoUrl url, User user) { return repositoryService.save(createCommand); } + private GithubRepository reTrackingOrThrowException(User user, GithubRepository registeredRepo) { + if(registeredRepo.isTracking()) { + throw new GssException(ErrorCode.ALREADY_SAVED_REPOSITORY); + } + return repositoryService.reTracking(user, registeredRepo.getExternalId()); + } + public PullRequests findAllPullRequestsByRepository(User user, long repositoryId, int size, int page) { return repositoryService.getPullRequestsByRepository(user, repositoryId, size, page); } diff --git a/gss-api-app/src/test/java/com/devoops/BaseRepositoryTest.java b/gss-api-app/src/test/java/com/devoops/BaseRepositoryTest.java new file mode 100644 index 00000000..b6539cd3 --- /dev/null +++ b/gss-api-app/src/test/java/com/devoops/BaseRepositoryTest.java @@ -0,0 +1,25 @@ +package com.devoops; + +import com.devoops.domain.repository.analysis.AiChargeRepository; +import com.devoops.generator.AiChargeGenerator; +import com.devoops.jpa.repository.analysis.AiChargeJpaRepository; +import com.devoops.jpa.repository.analysis.AiChargeRepositoryImpl; +import com.devoops.jpa.repository.github.repo.GithubRepoJpaRepository; +import com.devoops.jpa.repository.github.pr.PullRequestJpaRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +@DataJpaTest +@Import({ + AiChargeRepository.class, + AiChargeRepositoryImpl.class, + AiChargeJpaRepository.class, + AiChargeGenerator.class, +}) +public abstract class BaseRepositoryTest { + + + @Autowired + protected AiChargeGenerator aiChargeGenerator; +} diff --git a/gss-api-app/src/test/java/com/devoops/BaseServiceTest.java b/gss-api-app/src/test/java/com/devoops/BaseServiceTest.java index e7236cb7..2e1df07d 100644 --- a/gss-api-app/src/test/java/com/devoops/BaseServiceTest.java +++ b/gss-api-app/src/test/java/com/devoops/BaseServiceTest.java @@ -1,5 +1,6 @@ package com.devoops; +import com.devoops.generator.AiChargeGenerator; import com.devoops.generator.AnswerGenerator; import com.devoops.generator.AnswerRankingGenerator; import com.devoops.generator.GithubRepoGenerator; @@ -39,5 +40,8 @@ public abstract class BaseServiceTest { @Autowired protected WebhookGenerator webhookGenerator; + + @Autowired + protected AiChargeGenerator aiChargeGenerator; } diff --git a/gss-api-app/src/test/java/com/devoops/repository/analysis/AiChargeRepositoryTest.java b/gss-api-app/src/test/java/com/devoops/repository/analysis/AiChargeRepositoryTest.java new file mode 100644 index 00000000..a5c41dd9 --- /dev/null +++ b/gss-api-app/src/test/java/com/devoops/repository/analysis/AiChargeRepositoryTest.java @@ -0,0 +1,57 @@ +package com.devoops.repository.analysis; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.devoops.BaseServiceTest; +import com.devoops.domain.entity.analysis.AiCharge; +import com.devoops.domain.repository.analysis.AiChargeRepository; +import java.time.LocalDate; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class AiChargeRepositoryTest extends BaseServiceTest { + + @Autowired + private AiChargeRepository chargeRepository; + + @Nested + class GetByMonth { + + @Test + void 월에_해당하는_요금을_가져온다() { + double charge = 1500.0; + LocalDate localDate = LocalDate.now(); + aiChargeGenerator.generate(localDate.getYear(), localDate.getMonthValue(), charge); + + AiCharge actual = chargeRepository.getByYearAndMonth(localDate.getYear(), localDate.getMonthValue()); + + assertThat(actual.getCharge().doubleValue()).isEqualTo(charge); + } + + @Test + void 가져올_요금이_없다면_초기화한다() { + LocalDate localDate = LocalDate.now(); + + AiCharge actual = chargeRepository.getByYearAndMonth(localDate.getYear(), localDate.getMonthValue()); + + assertThat(actual.getCharge().doubleValue()).isEqualTo(0.0); + } + } + + @Nested + class Update { + + @Test + void 요금을_업데이트_할_수_있다() { + double charge = 1500.0; + LocalDate localDate = LocalDate.now(); + aiChargeGenerator.generate(localDate.getYear(), localDate.getMonthValue(), charge); + + chargeRepository.addCharge(localDate.getYear(), localDate.getMonthValue(), charge); + + AiCharge updatedcharge = chargeRepository.getByYearAndMonth(localDate.getYear(), localDate.getMonthValue()); + assertThat(updatedcharge.getCharge().doubleValue()).isEqualTo(charge * 2); + } + } +} diff --git a/gss-api-app/src/test/java/com/devoops/service/facade/RepositoryFacadeServiceTest.java b/gss-api-app/src/test/java/com/devoops/service/facade/RepositoryFacadeServiceTest.java index 69175285..c99d69d0 100644 --- a/gss-api-app/src/test/java/com/devoops/service/facade/RepositoryFacadeServiceTest.java +++ b/gss-api-app/src/test/java/com/devoops/service/facade/RepositoryFacadeServiceTest.java @@ -81,6 +81,26 @@ class Save { .hasMessage(ErrorCode.ALREADY_SAVED_REPOSITORY.getMessage()); } + @Test + void 레포지토리를_재연결_할_수_있다() { + User user = userGenerator.generate("김건우"); + GithubRepository unTrackingRepo = repoGenerator.generate(user, "연결 끊긴 레포지토리", 123L, false); + RepositorySaveRequest request = new RepositorySaveRequest("https://github.com/octocat/Hello-World"); + mockingGithubClient(); + + GithubRepository reTrackingRepository = repositoryFacadeService.save(request, user); + + GithubRepository actual = githubRepoDomainRepository.findByIdAndUserId( + unTrackingRepo.getId(), + user.getId() + ); + + assertAll( + () -> Mockito.verify(gitHubClient, times(1)).createWebhook(any(), any(), any(), any()), + () -> assertThat(actual.isTracking()).isTrue() + ); + } + private void mockingGithubClient() { GithubRepoInfoResponse mockResponse = new GithubRepoInfoResponse(123, "testName", "testUrl", new OwnerResponse("김건우")); diff --git a/gss-api-app/src/test/java/com/devoops/service/repository/RepositoryServiceTest.java b/gss-api-app/src/test/java/com/devoops/service/repository/RepositoryServiceTest.java index 2d25b073..02c756e2 100644 --- a/gss-api-app/src/test/java/com/devoops/service/repository/RepositoryServiceTest.java +++ b/gss-api-app/src/test/java/com/devoops/service/repository/RepositoryServiceTest.java @@ -5,15 +5,12 @@ import static org.junit.jupiter.api.Assertions.assertAll; import com.devoops.BaseServiceTest; -import com.devoops.domain.entity.github.repo.GithubRepository; +import com.devoops.command.request.RepositoryCreateCommand; import com.devoops.domain.entity.github.pr.PullRequest; import com.devoops.domain.entity.github.pr.RecordStatus; +import com.devoops.domain.entity.github.repo.GithubRepository; import com.devoops.domain.entity.user.User; -import com.devoops.domain.repository.github.answer.AnswerDomainRepository; -import com.devoops.domain.repository.github.answer.AnswerRankingDomainRepository; import com.devoops.domain.repository.github.repo.GithubRepoDomainRepository; -import com.devoops.domain.repository.github.pr.PullRequestDomainRepository; -import com.devoops.domain.repository.github.question.QuestionDomainRepository; import com.devoops.exception.custom.GssException; import com.devoops.exception.errorcode.ErrorCode; import java.time.LocalDateTime; @@ -30,17 +27,28 @@ class RepositoryServiceTest extends BaseServiceTest { @Autowired private GithubRepoDomainRepository githubRepoDomainRepository; - @Autowired - private PullRequestDomainRepository pullRequestDomainRepository; - @Autowired - private QuestionDomainRepository questionDomainRepository; + @Nested + class Save { - @Autowired - private AnswerRankingDomainRepository answerRankingDomainRepository; + @Test + void 신규_레포지토리를_저장할_수_있다() { + User user = userGenerator.generate("김건우"); + RepositoryCreateCommand createCommand = new RepositoryCreateCommand(user.getId(), "새로운 레포", + "url", "건우", 0, 123L); - @Autowired - private AnswerDomainRepository answerDomainRepository; + GithubRepository savedRepository = repositoryService.save(createCommand); + + assertAll( + () -> assertThat(createCommand.url()).isEqualTo(savedRepository.getUrl()), + () -> assertThat(createCommand.userId()).isEqualTo(savedRepository.getUserId()), + () -> assertThat(createCommand.externalId()).isEqualTo(savedRepository.getExternalId()), + () -> assertThat(createCommand.prCount()).isEqualTo(savedRepository.getPrCount()), + () -> assertThat(createCommand.ownerName()).isEqualTo(savedRepository.getOwner()), + () -> assertThat(createCommand.repositoryName()).isEqualTo(savedRepository.getName()) + ); + } + } @Nested class getRepositoryPullRequestsByRepository { @@ -135,7 +143,8 @@ class getRepositoryPullRequests { .toList(); assertThat(pullRequestsId) - .containsExactly(nowPr.getId(), oneMinutesAgoPR.getId(), threeMinutesAgoPR.getId(), fiveMinutesAgoPR.getId()); + .containsExactly(nowPr.getId(), oneMinutesAgoPR.getId(), threeMinutesAgoPR.getId(), + fiveMinutesAgoPR.getId()); } @Test diff --git a/gss-client/gss-mcp-client/build.gradle b/gss-client/gss-mcp-client/build.gradle index 9acb3f3b..4870f734 100644 --- a/gss-client/gss-mcp-client/build.gradle +++ b/gss-client/gss-mcp-client/build.gradle @@ -3,6 +3,7 @@ plugins { } dependencies { + implementation project(":gss-common") implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' implementation 'org.springframework.boot:spring-boot-starter-webflux' diff --git a/gss-client/gss-mcp-client/src/main/java/com/devoops/client/PrAnalysisClient.java b/gss-client/gss-mcp-client/src/main/java/com/devoops/client/PrAnalysisClient.java index 51f8cfb0..f587f868 100644 --- a/gss-client/gss-mcp-client/src/main/java/com/devoops/client/PrAnalysisClient.java +++ b/gss-client/gss-mcp-client/src/main/java/com/devoops/client/PrAnalysisClient.java @@ -1,8 +1,10 @@ package com.devoops.client; +import com.devoops.dto.request.AnalyzePrRequest; import com.devoops.dto.response.AnalyzePrResponse; public interface PrAnalysisClient { - AnalyzePrResponse analyze(String title, String description, String diff); + AnalyzePrResponse analyze(AnalyzePrRequest request); + } diff --git a/gss-client/gss-mcp-client/src/main/java/com/devoops/client/PrAnalysisClientImpl.java b/gss-client/gss-mcp-client/src/main/java/com/devoops/client/PrAnalysisClientImpl.java index 401becd6..0f18fba6 100644 --- a/gss-client/gss-mcp-client/src/main/java/com/devoops/client/PrAnalysisClientImpl.java +++ b/gss-client/gss-mcp-client/src/main/java/com/devoops/client/PrAnalysisClientImpl.java @@ -1,12 +1,19 @@ package com.devoops.client; +import com.devoops.dto.request.AnalyzePrRequest; import com.devoops.dto.response.AnalyzePrResponse; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.devoops.dto.response.PrAnalysis; +import com.devoops.serdes.PrAnalysisMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.ai.chat.model.ChatModel; -import org.springframework.beans.factory.annotation.Value; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.ResponseFormat; +import org.springframework.ai.openai.api.ResponseFormat.Type; import org.springframework.stereotype.Component; @Component @@ -14,31 +21,52 @@ @Slf4j public class PrAnalysisClientImpl implements PrAnalysisClient { - @Value("${dev-oops.github-pr-analysis.prompt}") - private String promptTemplate; - - private final ChatModel chatModel; - private final ObjectMapper objectMapper; + private final ChatClient chatClient; + private final PromptBuilder promptBuilder; + private final PrAnalysisMapper prAnalysisMapper; @Override - public AnalyzePrResponse analyze(String title, String description, String diff) { - String prompt = buildPrompt(title, description, diff); -// log.info("prompt = {}", prompt); - String content = chatModel.call(prompt); - return parseResponse(content); + public AnalyzePrResponse analyze(AnalyzePrRequest request) { + //option 설정 + OpenAiChatOptions openAiChatOptions = openAiChatBuilder() + .model(request.model()) + .build(); + + ChatResponse chatresponse = callChatResponse( + request.title(), + request.description(), + request.codeDifference(), + openAiChatOptions + ); + + Usage usage = chatresponse.getMetadata().getUsage(); + String analysisResult = chatresponse.getResult().getOutput().getText(); + PrAnalysis prAnalysis = prAnalysisMapper.mapToPrAnalysis(analysisResult); + return new AnalyzePrResponse(usage, prAnalysis); + } + + private OpenAiChatOptions.Builder openAiChatBuilder() { + return OpenAiChatOptions.builder() + .responseFormat(new ResponseFormat(Type.JSON_SCHEMA, outputJsonSchema())) + .reasoningEffort("medium") + .temperature(1.0); + } - private String buildPrompt(String title, String description, String diff) { - return String.format(promptTemplate, title, description, diff); + private String outputJsonSchema() { + BeanOutputConverter outputConverter = new BeanOutputConverter<>(PrAnalysis.class); + return outputConverter.getJsonSchema(); } - private AnalyzePrResponse parseResponse(String content) { - try { - return objectMapper.readValue(content, new TypeReference<>() { - }); - } catch (Exception e) { - log.error("AI 응답 파싱 실패. 응답 내용: {}", content, e); - throw new IllegalArgumentException("AI 응답 파싱 중 오류 발생", e); - } + private ChatResponse callChatResponse(String title, String description, String codeDifference, + ChatOptions options) { + String userPrompt = promptBuilder.buildUserPrompt(title, description, codeDifference); + String systemPrompt = promptBuilder.buildSystemPrompt(); + return chatClient.prompt() + .options(options) + .system(systemPrompt) + .user(userPrompt) + .call() + .chatResponse(); } } diff --git a/gss-client/gss-mcp-client/src/main/java/com/devoops/client/PromptBuilder.java b/gss-client/gss-mcp-client/src/main/java/com/devoops/client/PromptBuilder.java new file mode 100644 index 00000000..148c9e4c --- /dev/null +++ b/gss-client/gss-mcp-client/src/main/java/com/devoops/client/PromptBuilder.java @@ -0,0 +1,31 @@ +package com.devoops.client; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class PromptBuilder { + + @Value("${dev-oops.github-pr-analysis.prompt}") + private String promptTemplate; + + @Value("${dev-oops.github-pr-analysis.system}") + private String systemPrompt; + + public String buildUserPrompt(String title, String description, String diff) { + return promptTemplate + .replace("{title}", title) + .replace("{description}", description) + .replace("{diff}", encodeDiff(diff)); + } + + public String buildSystemPrompt() { + return systemPrompt; + } + + private String encodeDiff(String diff) { + return Base64.getEncoder().encodeToString(diff.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/gss-client/gss-mcp-client/src/main/java/com/devoops/config/AiConfig.java b/gss-client/gss-mcp-client/src/main/java/com/devoops/config/AiConfig.java new file mode 100644 index 00000000..6fc1e023 --- /dev/null +++ b/gss-client/gss-mcp-client/src/main/java/com/devoops/config/AiConfig.java @@ -0,0 +1,15 @@ +package com.devoops.config; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AiConfig { + + @Bean + public ChatClient chatClient(OpenAiChatModel chatModel) { + return ChatClient.create(chatModel); + } +} diff --git a/gss-client/gss-mcp-client/src/main/java/com/devoops/dto/request/AnalyzePrRequest.java b/gss-client/gss-mcp-client/src/main/java/com/devoops/dto/request/AnalyzePrRequest.java new file mode 100644 index 00000000..4606262a --- /dev/null +++ b/gss-client/gss-mcp-client/src/main/java/com/devoops/dto/request/AnalyzePrRequest.java @@ -0,0 +1,10 @@ +package com.devoops.dto.request; + +public record AnalyzePrRequest( + String title, + String description, + String codeDifference, + String model +) { + +} diff --git a/gss-client/gss-mcp-client/src/main/java/com/devoops/dto/response/AnalyzePrResponse.java b/gss-client/gss-mcp-client/src/main/java/com/devoops/dto/response/AnalyzePrResponse.java index 4db519c0..33dba2b8 100644 --- a/gss-client/gss-mcp-client/src/main/java/com/devoops/dto/response/AnalyzePrResponse.java +++ b/gss-client/gss-mcp-client/src/main/java/com/devoops/dto/response/AnalyzePrResponse.java @@ -1,23 +1,22 @@ package com.devoops.dto.response; - -import java.util.List; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.ai.chat.metadata.Usage; public record AnalyzePrResponse( - String summary, - List summaryDetails, - List questions + int promptTokens, + int completionTokens, + int totalTokens, + PrAnalysis prAnalysis ) { - public record SummaryDetails( - String title, - String description - ) { - } - public record CategorizedQuestion( - String category, - List question - ) { + public AnalyzePrResponse(Usage usage, PrAnalysis prAnalysis) { + this( + usage.getPromptTokens(), + usage.getCompletionTokens(), + usage.getTotalTokens(), + prAnalysis + ); } } - diff --git a/gss-client/gss-mcp-client/src/main/java/com/devoops/dto/response/PrAnalysis.java b/gss-client/gss-mcp-client/src/main/java/com/devoops/dto/response/PrAnalysis.java new file mode 100644 index 00000000..284718c5 --- /dev/null +++ b/gss-client/gss-mcp-client/src/main/java/com/devoops/dto/response/PrAnalysis.java @@ -0,0 +1,24 @@ +package com.devoops.dto.response; + + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +public record PrAnalysis( + @JsonProperty(required = true) String summary, + @JsonProperty(required = true) List summaryDetails, + @JsonProperty(required = true) List questions +) { + public record SummaryDetails( + @JsonProperty(required = true) String title, + @JsonProperty(required = true) String description + ) { + } + + public record CategorizedQuestion( + @JsonProperty(required = true) String category, + @JsonProperty(required = true) List question + ) { + } +} + diff --git a/gss-client/gss-mcp-client/src/main/java/com/devoops/etc/OpenaiPropertiesLogger.java b/gss-client/gss-mcp-client/src/main/java/com/devoops/etc/OpenaiPropertiesLogger.java index f3b59b6a..43517f0a 100644 --- a/gss-client/gss-mcp-client/src/main/java/com/devoops/etc/OpenaiPropertiesLogger.java +++ b/gss-client/gss-mcp-client/src/main/java/com/devoops/etc/OpenaiPropertiesLogger.java @@ -12,13 +12,9 @@ public class OpenaiPropertiesLogger { @Value("${spring.ai.openai.api-key}") private String apiKey; - @Value("${spring.ai.openai.chat.options.model}") - private String model; - @PostConstruct public void logProperties() { log.info("🔑 OpenAI API Key: {}", maskKey(apiKey)); - log.info("🤖 OpenAI Model: {}", model); } private String maskKey(String key) { diff --git a/gss-client/gss-mcp-client/src/main/java/com/devoops/serdes/PrAnalysisMapper.java b/gss-client/gss-mcp-client/src/main/java/com/devoops/serdes/PrAnalysisMapper.java new file mode 100644 index 00000000..19af8516 --- /dev/null +++ b/gss-client/gss-mcp-client/src/main/java/com/devoops/serdes/PrAnalysisMapper.java @@ -0,0 +1,29 @@ +package com.devoops.serdes; + +import com.devoops.dto.response.PrAnalysis; +import com.devoops.exception.custom.GssException; +import com.devoops.exception.errorcode.ErrorCode; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class PrAnalysisMapper { + + private static final int MAX_LOGGING_LENGTH = 255; + + private static final ObjectMapper MAPPER = new ObjectMapper() + .findAndRegisterModules() + .configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + public PrAnalysis mapToPrAnalysis(String analysisResult) { + try { + return MAPPER.readValue(analysisResult, PrAnalysis.class); + } catch (JsonProcessingException e) { + log.error("PR 질문 생성 파싱 오류 : {}", analysisResult.substring(0, MAX_LOGGING_LENGTH)); + throw new GssException(ErrorCode.AI_RESPONSE_PARSING_ERROR); + } + } +} diff --git a/gss-client/gss-mcp-client/src/main/resources/application-mcp-client.yml b/gss-client/gss-mcp-client/src/main/resources/application-mcp-client.yml index cc9a5ba3..ca3018c1 100644 --- a/gss-client/gss-mcp-client/src/main/resources/application-mcp-client.yml +++ b/gss-client/gss-mcp-client/src/main/resources/application-mcp-client.yml @@ -1,51 +1,33 @@ dev-oops: github-pr-analysis: - prompt: | - 너는 10년차 시니어 개발자로서 아래 PR의 변경 내용을 분석하고, PR 제목, 내용, 변경내용에서 기술 면접에서 사용할 수 있는 질문을 생성해. - 나는 개발자 지망생으로서 나의 PR을 카테고리별 질문을 받음으로써 내 PR을 회고하고 싶어. - 너는 나의 PR내용 중에 사실확인용 같은 쉬운 질문들과 심화된 맞춤형 기술 질문들을 뽑아주면 돼 - - PR 제목: %s - PR 내용: %s + system: | + 당신은 10년차 시니어 개발자로서 아래 PR의 변경 내용을 분석하고, 기술 면접에서 사용할 수 있는 질문을 생성해. + + 조건: + - "summary"는 이 PR이 어떤 핵심 작업을 했는지 1문장으로 요약한 것 + - "summaryDetail"은 변경 내용을 항목별로 요약한 제목(title) + 설명(description) 쌍으로 구성해. + - "category"는 기술적인 관점에서 PR 코드 변경 내용을 반영하여 선택해 (예: 성능, 보안, 확장성, 유지보수성, 테스트 등) + - "question"은 각 category에 대해 기술 면접에서 사용할 수 있는 질문이어야 해. + - 각 질문들은 반드시 PR 코드 변경 내용("diff")을 인용해서 생성해. + - "diff"를 굉장히 자세하게 분석하고 몇몇 질문에는 코드를 반영해서 만들어줘 + - 질문 수는 카테고리마다 3개 이상 만들어. + - 질문은 꼭 존댓말로 "?"로 끝나는 의문문이어야 해. + prompt: | + 당신은 숙련된 코드 리뷰어입니다. + + PR 제목: {title} + PR 내용: {description} + PR 코드 변경 내용: --- BEGIN DIFF --- - %s + {diff} --- END DIFF --- - - 다음과 같은 형식으로 결과를 출력해주세요(JSON으로 파싱할 것이니 JSON 형식에 맞춰 답하고 '''json 등으로 감싸지마): - - { - "summary": "이 PR은 어떤 핵심 작업을 했는지 1문장으로 기술하세요.", - "summaryDetails": [ - { - "title": "1. 기능 추가 또는 수정 요약 제목(자유롭게)", - "description": "무엇을 왜, 어떻게 했는지 간단한 설명" - }, - { - "title": "2. 코드 변경 내역(자유롭게)", - "description": "구현 시 고려한 조건, 설정, 성능 또는 안정성과 관련된 요소" - } - ], - "questions": [ - { - "category": "카테고리 이름 (예: 성능, 확장성, 보안 등 자유롭게)", - "question": [ - "해당 카테고리에 적절한 자유로운 질문 1", - "해당 카테고리에 적절한 자유로운 질문 2" - ] - } - ] - } - - 조건: - - "summary"는 핵심 내용을 요약한 1문장으로 작성해. - - "detailedSummary"는 변경 내용을 항목별로 요약한 제목 + 설명 쌍으로 구성해. - - "category"는 기술적인 관점에서 자유롭게 선택해. (예: 성능, 보안, 확장성, 유지보수성, 테스트 등) - - "question"은 PR 코드 변경 내용을 반영해서 심화된 기술 질문으로 만들어. - - 카테고리마다 3개씩 질문을 만들어. + + diff를 Base64에서 디코딩한 후 분석하고 PR 요약과 질문을 만들어 주세요. --- + spring: config: activate: @@ -53,12 +35,9 @@ spring: ai: openai: api-key: testKey - chat: - options: - model: gpt-4o-mini - --- + spring: config: activate: diff --git a/gss-client/gss-mcp-client/src/test/java/com/devoops/client/ConcurrencyModelTest.java b/gss-client/gss-mcp-client/src/test/java/com/devoops/client/ConcurrencyModelTest.java new file mode 100644 index 00000000..9a9d8bef --- /dev/null +++ b/gss-client/gss-mcp-client/src/test/java/com/devoops/client/ConcurrencyModelTest.java @@ -0,0 +1,42 @@ +package com.devoops.client; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.ai.openai.OpenAiChatOptions; + +@Disabled +class ConcurrencyModelTest { + + private final OpenAiChatOptions.Builder sharedBuilder = OpenAiChatOptions.builder(); + + @Test + void 동시성_이슈에_따라_옵션이_섞이지_않아야_한다() throws ExecutionException, InterruptedException { + ExecutorService executor = Executors.newFixedThreadPool(10); + List> results = new ArrayList<>(); + + for (int i = 0; i < 10; i++) { + final int id = i; + results.add(executor.submit(() -> { + sharedBuilder.model(buildModel(id)); + return "id = " + id + " model = " + sharedBuilder.build().getModel(); + })); + } + + for (Future f : results) { + System.out.println(f.get()); + } + } + + private String buildModel(int id) { + if (id % 2 == 0) { + return "gpt-5-mini"; + } + return "gpt-5"; + } +} diff --git a/gss-common/src/main/java/com/devoops/exception/errorcode/ErrorCode.java b/gss-common/src/main/java/com/devoops/exception/errorcode/ErrorCode.java index 636702ea..75740fad 100644 --- a/gss-common/src/main/java/com/devoops/exception/errorcode/ErrorCode.java +++ b/gss-common/src/main/java/com/devoops/exception/errorcode/ErrorCode.java @@ -38,7 +38,8 @@ public enum ErrorCode { USER_NOT_FOUND(500, "찾는 회원이 존재하지 않습니다"), REDIS_PUBLISH_ERROR(500, "레디스 이벤트 발행 과정에서 문제가 생겼습니다"), REDIS_SUBSCRIBE_ERROR(500, "레디스 이벤트 수신 과정에서 문제가 생겼습니다"), - GITHUB_CLIENT_ERROR(500, "깃허브 클라이언트 소통과정에 문제가 발생했습니다") + GITHUB_CLIENT_ERROR(500, "깃허브 클라이언트 소통과정에 문제가 발생했습니다"), + AI_RESPONSE_PARSING_ERROR(500, "AI로부터 온 질문 생성을 파싱하는 과정에 오류가 발생했습니다"), ; private final int statusCode; diff --git a/gss-common/src/main/java/com/devoops/util/CurrencyUtil.java b/gss-common/src/main/java/com/devoops/util/CurrencyUtil.java new file mode 100644 index 00000000..923793a0 --- /dev/null +++ b/gss-common/src/main/java/com/devoops/util/CurrencyUtil.java @@ -0,0 +1,27 @@ +package com.devoops.util; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class CurrencyUtil { + + // 25-08-21 기준 + private static final BigDecimal CURRENCY_RATE = BigDecimal.valueOf(1397.84); + private static final int CONCURRENCY_ROUNDING_SCALE = 2; + + /** + * 달러를 원으로 변환 + * + * @param usd 달러 금액 + * @return KRW 금액 + */ + public static double usdToKrw(double usd) { + BigDecimal usdDecimal = BigDecimal.valueOf(usd); + return usdDecimal.multiply(CURRENCY_RATE) + .setScale(CONCURRENCY_ROUNDING_SCALE, RoundingMode.HALF_UP) + .doubleValue(); + } +} diff --git a/gss-domain/src/main/java/com/devoops/domain/entity/analysis/AiCharge.java b/gss-domain/src/main/java/com/devoops/domain/entity/analysis/AiCharge.java new file mode 100644 index 00000000..bd26018e --- /dev/null +++ b/gss-domain/src/main/java/com/devoops/domain/entity/analysis/AiCharge.java @@ -0,0 +1,14 @@ +package com.devoops.domain.entity.analysis; + +import java.math.BigDecimal; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class AiCharge { + + private final int year; + private final int month; + private final BigDecimal charge; +} diff --git a/gss-domain/src/main/java/com/devoops/domain/entity/analysis/OpenAiModel.java b/gss-domain/src/main/java/com/devoops/domain/entity/analysis/OpenAiModel.java new file mode 100644 index 00000000..b3164256 --- /dev/null +++ b/gss-domain/src/main/java/com/devoops/domain/entity/analysis/OpenAiModel.java @@ -0,0 +1,37 @@ +package com.devoops.domain.entity.analysis; + +import com.devoops.util.CurrencyUtil; +import java.math.BigDecimal; +import java.util.stream.Stream; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum OpenAiModel { + + GPT_5(0, 7500, "gpt-5", 0.00000125, 0.00001), + GPT_5_MINI(7501, 12500, "gpt-5-mini", 0.00000025, 0.000002), + GPT_5_NANO(12501, 15000, "gpt-5-nano", 0.00000005, 0.0000004), + ; + + private final int moneyUnderCriteria; //원 + private final int moneyUpperCriteria; //원 + private final String name; + private final double inputTokenCharge; //달러 + private final double outputTokenCharge; //달러 + + public static OpenAiModel getModelByUsage(BigDecimal currentUsageWon) { + return Stream.of(values()) + .filter(model -> model.moneyUnderCriteria <= currentUsageWon.doubleValue() + && model.moneyUpperCriteria >= currentUsageWon.doubleValue()) + .findAny() + .orElse(GPT_5_NANO); + } + + public double getCharge(int promptToken, int completionTokens) { + double inputCharge = CurrencyUtil.usdToKrw(inputTokenCharge * promptToken); + double outputCharge = CurrencyUtil.usdToKrw(outputTokenCharge * completionTokens); + return inputCharge + outputCharge; + } +} diff --git a/gss-domain/src/main/java/com/devoops/domain/entity/github/repo/GithubRepository.java b/gss-domain/src/main/java/com/devoops/domain/entity/github/repo/GithubRepository.java index 68ff9ae8..ba23e9a3 100644 --- a/gss-domain/src/main/java/com/devoops/domain/entity/github/repo/GithubRepository.java +++ b/gss-domain/src/main/java/com/devoops/domain/entity/github/repo/GithubRepository.java @@ -23,4 +23,8 @@ public GithubRepository(long userId, String name, String url, String owner, int public GithubRepository stopTracking() { return new GithubRepository(id, userId, name, url, owner, prCount, externalId, false); } + + public GithubRepository reTracking() { + return new GithubRepository(id, userId, name, url, owner, prCount, externalId, true); + } } diff --git a/gss-domain/src/main/java/com/devoops/domain/repository/analysis/AiChargeRepository.java b/gss-domain/src/main/java/com/devoops/domain/repository/analysis/AiChargeRepository.java new file mode 100644 index 00000000..e4133e8f --- /dev/null +++ b/gss-domain/src/main/java/com/devoops/domain/repository/analysis/AiChargeRepository.java @@ -0,0 +1,10 @@ +package com.devoops.domain.repository.analysis; + +import com.devoops.domain.entity.analysis.AiCharge; + +public interface AiChargeRepository { + + AiCharge getByYearAndMonth(int year, int month); + + void addCharge(int year, int month, double charge); +} diff --git a/gss-domain/src/main/java/com/devoops/domain/repository/github/repo/GithubRepoDomainRepository.java b/gss-domain/src/main/java/com/devoops/domain/repository/github/repo/GithubRepoDomainRepository.java index 25a60eaf..f198df6f 100644 --- a/gss-domain/src/main/java/com/devoops/domain/repository/github/repo/GithubRepoDomainRepository.java +++ b/gss-domain/src/main/java/com/devoops/domain/repository/github/repo/GithubRepoDomainRepository.java @@ -3,6 +3,7 @@ import com.devoops.domain.entity.github.repo.GithubRepository; import java.util.List; +import java.util.Optional; public interface GithubRepoDomainRepository { @@ -18,9 +19,9 @@ public interface GithubRepoDomainRepository { boolean existsByExternalIdAndUserId(long externalId, long userId); - GithubRepository findByExternalId(long externalId); + Optional findByExternalIdAndUserId(long externalId, long userId); - GithubRepository findByExternalIdAndUserId(long externalId, long userId); + GithubRepository getByExternalIdAndUserId(long externalId, long userId); void deleteById(long id); } diff --git a/gss-domain/src/main/java/com/devoops/jpa/entity/analysis/AiChargeEntity.java b/gss-domain/src/main/java/com/devoops/jpa/entity/analysis/AiChargeEntity.java new file mode 100644 index 00000000..8ff227e2 --- /dev/null +++ b/gss-domain/src/main/java/com/devoops/jpa/entity/analysis/AiChargeEntity.java @@ -0,0 +1,55 @@ +package com.devoops.jpa.entity.analysis; + +import com.devoops.domain.entity.analysis.AiCharge; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.math.BigDecimal; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "ai_charge", + uniqueConstraints = {@UniqueConstraint(name= "uk_year_month", columnNames = {"charge_year", "charge_month"})} +) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AiChargeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "charge_year") + private int year; + + @Column(name = "charge_month") + private int month; + + @Column(precision = 10, scale = 2) + private BigDecimal charge; + + public static AiChargeEntity from(AiCharge aiCharge) { + return new AiChargeEntity( + null, + aiCharge.getYear(), + aiCharge.getMonth(), + aiCharge.getCharge() + ); + } + + public AiCharge toDomainEntity() { + return new AiCharge( + year, + month, + charge + ); + } +} diff --git a/gss-domain/src/main/java/com/devoops/jpa/entity/github/repo/GithubRepositoryEntity.java b/gss-domain/src/main/java/com/devoops/jpa/entity/github/repo/GithubRepositoryEntity.java index cb19269d..685c5828 100644 --- a/gss-domain/src/main/java/com/devoops/jpa/entity/github/repo/GithubRepositoryEntity.java +++ b/gss-domain/src/main/java/com/devoops/jpa/entity/github/repo/GithubRepositoryEntity.java @@ -41,7 +41,6 @@ public class GithubRepositoryEntity extends BaseTimeEntity { private int pullRequestCount; - @Column(unique = true) private long githubRepositoryId; public static GithubRepositoryEntity from(GithubRepository githubRepository) { diff --git a/gss-domain/src/main/java/com/devoops/jpa/repository/analysis/AiChargeJpaRepository.java b/gss-domain/src/main/java/com/devoops/jpa/repository/analysis/AiChargeJpaRepository.java new file mode 100644 index 00000000..4473943f --- /dev/null +++ b/gss-domain/src/main/java/com/devoops/jpa/repository/analysis/AiChargeJpaRepository.java @@ -0,0 +1,27 @@ +package com.devoops.jpa.repository.analysis; + +import com.devoops.jpa.entity.analysis.AiChargeEntity; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface AiChargeJpaRepository extends JpaRepository { + + Optional findByYearAndMonth(int todayYear, int todayMonth); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update AiChargeEntity ai_charge + set ai_charge.charge = ai_charge.charge + :charge + where ai_charge.year = :year + and ai_charge.month = :month + and ai_charge.charge >= 0 + """) + void updateChargeById( + @Param("year") int year, + @Param("month") int month, + @Param("charge") double charge + ); +} diff --git a/gss-domain/src/main/java/com/devoops/jpa/repository/analysis/AiChargeRepositoryImpl.java b/gss-domain/src/main/java/com/devoops/jpa/repository/analysis/AiChargeRepositoryImpl.java new file mode 100644 index 00000000..1403f44a --- /dev/null +++ b/gss-domain/src/main/java/com/devoops/jpa/repository/analysis/AiChargeRepositoryImpl.java @@ -0,0 +1,31 @@ +package com.devoops.jpa.repository.analysis; + +import com.devoops.domain.entity.analysis.AiCharge; +import com.devoops.domain.repository.analysis.AiChargeRepository; +import com.devoops.jpa.entity.analysis.AiChargeEntity; +import java.math.BigDecimal; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +@RequiredArgsConstructor +public class AiChargeRepositoryImpl implements AiChargeRepository { + + private final AiChargeJpaRepository chargeJpaRepository; + + @Override + public AiCharge getByYearAndMonth(int year, int month) { + return chargeJpaRepository.findByYearAndMonth(year, month) + .orElseGet(() -> { + AiCharge initializeCharge = new AiCharge(year, month, BigDecimal.ZERO); + return chargeJpaRepository.save(AiChargeEntity.from(initializeCharge)); + }).toDomainEntity(); + } + + @Override + @Transactional + public void addCharge(int year, int month, double charge) { + chargeJpaRepository.updateChargeById(year, month, charge); + } +} diff --git a/gss-domain/src/main/java/com/devoops/jpa/repository/github/repo/GithubRepoDomainRepositoryImpl.java b/gss-domain/src/main/java/com/devoops/jpa/repository/github/repo/GithubRepoDomainRepositoryImpl.java index 99339572..22ea1045 100644 --- a/gss-domain/src/main/java/com/devoops/jpa/repository/github/repo/GithubRepoDomainRepositoryImpl.java +++ b/gss-domain/src/main/java/com/devoops/jpa/repository/github/repo/GithubRepoDomainRepositoryImpl.java @@ -5,6 +5,7 @@ import com.devoops.exception.custom.GssException; import com.devoops.exception.errorcode.ErrorCode; import com.devoops.jpa.entity.github.repo.GithubRepositoryEntity; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -65,21 +66,17 @@ public List findByUserId(long userId) { } @Override - @Transactional(readOnly = true) - public GithubRepository findByExternalId(long externalId) { - return repoJpaRepository.findByGithubRepositoryId(externalId) - .orElseThrow(() -> new GssException(ErrorCode.GITHUB_REPOSITORY_NOT_FOUND)) - .toDomainEntity(); - + public Optional findByExternalIdAndUserId(long externalId, long userId) { + return repoJpaRepository.findByGithubRepositoryIdAndUserId(externalId, userId) + .map(GithubRepositoryEntity::toDomainEntity); } @Override @Transactional(readOnly = true) - public GithubRepository findByExternalIdAndUserId(long externalId, long userId) { + public GithubRepository getByExternalIdAndUserId(long externalId, long userId) { return repoJpaRepository.findByGithubRepositoryIdAndUserId(externalId, userId) .orElseThrow(() -> new GssException(ErrorCode.GITHUB_REPOSITORY_NOT_FOUND)) .toDomainEntity(); - } @Override diff --git a/gss-domain/src/main/java/com/devoops/service/repository/RepositoryService.java b/gss-domain/src/main/java/com/devoops/service/repository/RepositoryService.java index 83a092d3..e1bc902d 100644 --- a/gss-domain/src/main/java/com/devoops/service/repository/RepositoryService.java +++ b/gss-domain/src/main/java/com/devoops/service/repository/RepositoryService.java @@ -1,14 +1,15 @@ package com.devoops.service.repository; import com.devoops.command.request.RepositoryCreateCommand; -import com.devoops.domain.entity.github.repo.GithubRepository; import com.devoops.domain.entity.github.pr.PullRequests; +import com.devoops.domain.entity.github.repo.GithubRepository; import com.devoops.domain.entity.user.User; -import com.devoops.domain.repository.github.repo.GithubRepoDomainRepository; import com.devoops.domain.repository.github.pr.PullRequestDomainRepository; +import com.devoops.domain.repository.github.repo.GithubRepoDomainRepository; import com.devoops.exception.custom.GssException; import com.devoops.exception.errorcode.ErrorCode; import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -23,13 +24,19 @@ public class RepositoryService { private final PullRequestDomainRepository pullRequestRepository; public GithubRepository save(RepositoryCreateCommand command) { - log.info("command: {}", command); - if (repoRepository.existsByExternalIdAndUserId(command.externalId(), command.userId())) { - throw new GssException(ErrorCode.ALREADY_SAVED_REPOSITORY); - } return repoRepository.save(command.toDomainEntity()); } + public Optional findByUserAndExternalId(User user, long repositoryId) { + return repoRepository.findByExternalIdAndUserId(repositoryId, user.getId()); + } + + public GithubRepository reTracking(User user, long repositoryId) { + GithubRepository repo = repoRepository.getByExternalIdAndUserId(repositoryId, user.getId()); + GithubRepository reTrackingRepo = repo.reTracking(); + return repoRepository.update(reTrackingRepo); + } + public PullRequests getPullRequests(User user, int size, int page) { return pullRequestRepository.findUserPullRequestsOrderByMergedAt(user.getId(), size, page); } diff --git a/gss-domain/src/test/java/com/devoops/domain/entity/analysis/OpenAiModelTest.java b/gss-domain/src/test/java/com/devoops/domain/entity/analysis/OpenAiModelTest.java new file mode 100644 index 00000000..bf2980fc --- /dev/null +++ b/gss-domain/src/test/java/com/devoops/domain/entity/analysis/OpenAiModelTest.java @@ -0,0 +1,48 @@ +package com.devoops.domain.entity.analysis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigDecimal; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class OpenAiModelTest { + + @Nested + class ModelSelection { + + @Test + void GPT5_범위에서_선택된다() { + BigDecimal lowerBound = BigDecimal.valueOf(OpenAiModel.GPT_5.getMoneyUnderCriteria()); + BigDecimal upperBound = BigDecimal.valueOf(OpenAiModel.GPT_5.getMoneyUpperCriteria()); + + assertAll( + () -> assertThat(OpenAiModel.getModelByUsage(lowerBound)).isEqualTo(OpenAiModel.GPT_5), + () -> assertThat(OpenAiModel.getModelByUsage(upperBound)).isEqualTo(OpenAiModel.GPT_5) + ); + } + + @Test + void GPT5_MINI_범위에서_선택된다() { + BigDecimal lowerBound = BigDecimal.valueOf(OpenAiModel.GPT_5_MINI.getMoneyUnderCriteria()); + BigDecimal upperBound = BigDecimal.valueOf(OpenAiModel.GPT_5_MINI.getMoneyUpperCriteria()); + + assertAll( + () -> assertThat(OpenAiModel.getModelByUsage(lowerBound)).isEqualTo(OpenAiModel.GPT_5_MINI), + () -> assertThat(OpenAiModel.getModelByUsage(upperBound)).isEqualTo(OpenAiModel.GPT_5_MINI) + ); + } + + @Test + void GPT5_NANO_범위에서_선택된다() { + BigDecimal lowerBound = BigDecimal.valueOf(OpenAiModel.GPT_5_NANO.getMoneyUnderCriteria()); + BigDecimal upperBound = BigDecimal.valueOf(OpenAiModel.GPT_5_NANO.getMoneyUpperCriteria()); + + assertAll( + () -> assertThat(OpenAiModel.getModelByUsage(lowerBound)).isEqualTo(OpenAiModel.GPT_5_NANO), + () -> assertThat(OpenAiModel.getModelByUsage(upperBound)).isEqualTo(OpenAiModel.GPT_5_NANO) + ); + } + } +} diff --git a/gss-domain/src/testFixtures/java/com/devoops/BaseRepositoryTest.java b/gss-domain/src/testFixtures/java/com/devoops/BaseRepositoryTest.java deleted file mode 100644 index 8dfa388d..00000000 --- a/gss-domain/src/testFixtures/java/com/devoops/BaseRepositoryTest.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.devoops; - -import com.devoops.jpa.repository.github.repo.GithubRepoJpaRepository; -import com.devoops.jpa.repository.github.pr.PullRequestJpaRepository; -import com.devoops.jpa.repository.user.UserJpaRepository; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.context.annotation.Import; - -@DataJpaTest -@Import({PullRequestJpaRepository.class, GithubRepoJpaRepository.class, UserJpaRepository.class}) -public abstract class BaseRepositoryTest { - -} diff --git a/gss-domain/src/testFixtures/java/com/devoops/generator/AiChargeGenerator.java b/gss-domain/src/testFixtures/java/com/devoops/generator/AiChargeGenerator.java new file mode 100644 index 00000000..aab4f4b9 --- /dev/null +++ b/gss-domain/src/testFixtures/java/com/devoops/generator/AiChargeGenerator.java @@ -0,0 +1,21 @@ +package com.devoops.generator; + +import com.devoops.domain.entity.analysis.AiCharge; +import com.devoops.jpa.entity.analysis.AiChargeEntity; +import com.devoops.jpa.repository.analysis.AiChargeJpaRepository; +import java.math.BigDecimal; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class AiChargeGenerator { + + @Autowired + private AiChargeJpaRepository repository; + + public AiCharge generate(int year, int month, double charge) { + AiCharge aiCharge = new AiCharge(year, month, BigDecimal.valueOf(charge)); + return repository.save(AiChargeEntity.from(aiCharge)) + .toDomainEntity(); + } +} diff --git a/gss-domain/src/testFixtures/java/com/devoops/generator/GithubRepoGenerator.java b/gss-domain/src/testFixtures/java/com/devoops/generator/GithubRepoGenerator.java index eb2fcc0e..5b9f7d33 100644 --- a/gss-domain/src/testFixtures/java/com/devoops/generator/GithubRepoGenerator.java +++ b/gss-domain/src/testFixtures/java/com/devoops/generator/GithubRepoGenerator.java @@ -14,6 +14,10 @@ public class GithubRepoGenerator { private GithubRepoDomainRepository githubRepoDomainRepository; public GithubRepository generate(User user, String repoName) { + return generate(user, repoName, ThreadLocalRandom.current().nextLong(), true); + } + + public GithubRepository generate(User user, String repoName, long externalId, boolean tracking) { GithubRepository repository = new GithubRepository( null, user.getId(), @@ -21,8 +25,8 @@ public GithubRepository generate(User user, String repoName) { "url", "owner", 0, - ThreadLocalRandom.current().nextLong(), - true + externalId, + tracking ); return githubRepoDomainRepository.save(repository); } diff --git a/gss-mcp-app/src/main/java/com/devoops/adaptor/PrAnalysisAdapter.java b/gss-mcp-app/src/main/java/com/devoops/adaptor/PrAnalysisAdapter.java index 4f40f0aa..a2a612ae 100644 --- a/gss-mcp-app/src/main/java/com/devoops/adaptor/PrAnalysisAdapter.java +++ b/gss-mcp-app/src/main/java/com/devoops/adaptor/PrAnalysisAdapter.java @@ -2,11 +2,8 @@ import com.devoops.client.PrAnalysisClient; import com.devoops.dto.request.AdaptedAnalyzePrResponse; +import com.devoops.dto.request.AnalyzePrRequest; import com.devoops.dto.response.AnalyzePrResponse; -import com.devoops.util.SummaryFormatter; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -16,19 +13,9 @@ public class PrAnalysisAdapter { private final PrAnalysisClient prAnalysisClient; - public AdaptedAnalyzePrResponse analyze(String title, String description, String diff) { - AnalyzePrResponse analyzePrResponse = prAnalysisClient.analyze(title, description, diff); - String detailSummary = resolveDetailSummary(analyzePrResponse.summaryDetails()); - return new AdaptedAnalyzePrResponse( - analyzePrResponse.summary(), - detailSummary, - analyzePrResponse.questions() - ); - } - - private String resolveDetailSummary(List summaryDetails) { - return summaryDetails.stream() - .map(sd -> Map.entry(sd.title(), sd.description())) - .collect(Collectors.collectingAndThen(Collectors.toList(), SummaryFormatter::formatWithNumbering)); + public AdaptedAnalyzePrResponse analyze(String title, String description, String diff, String model) { + AnalyzePrRequest analyzePrRequest = new AnalyzePrRequest(title, description, diff, model); + AnalyzePrResponse analyzePrResponse = prAnalysisClient.analyze(analyzePrRequest); + return new AdaptedAnalyzePrResponse(analyzePrResponse); } } diff --git a/gss-mcp-app/src/main/java/com/devoops/dto/request/AdaptedAnalyzePrResponse.java b/gss-mcp-app/src/main/java/com/devoops/dto/request/AdaptedAnalyzePrResponse.java index d2ba9621..8d4356e5 100644 --- a/gss-mcp-app/src/main/java/com/devoops/dto/request/AdaptedAnalyzePrResponse.java +++ b/gss-mcp-app/src/main/java/com/devoops/dto/request/AdaptedAnalyzePrResponse.java @@ -1,12 +1,36 @@ package com.devoops.dto.request; -import com.devoops.dto.response.AnalyzePrResponse.CategorizedQuestion; +import com.devoops.dto.response.AnalyzePrResponse; +import com.devoops.dto.response.PrAnalysis; +import com.devoops.dto.response.PrAnalysis.CategorizedQuestion; +import com.devoops.util.SummaryFormatter; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; public record AdaptedAnalyzePrResponse( + int promptTokens, + int completionTokens, + int totalTokens, String summary, String detailSummary, List questions ) { + public AdaptedAnalyzePrResponse(AnalyzePrResponse analyzePrResponse) { + this( + analyzePrResponse.promptTokens(), + analyzePrResponse.completionTokens(), + analyzePrResponse.totalTokens(), + analyzePrResponse.prAnalysis().summary(), + resolveDetailSummary(analyzePrResponse.prAnalysis().summaryDetails()), + analyzePrResponse.prAnalysis().questions() + ); + } + + private static String resolveDetailSummary(List summaryDetails) { + return summaryDetails.stream() + .map(sd -> Map.entry(sd.title(), sd.description())) + .collect(Collectors.collectingAndThen(Collectors.toList(), SummaryFormatter::formatWithNumbering)); + } } diff --git a/gss-mcp-app/src/main/java/com/devoops/event/QuestionCreateEvent.java b/gss-mcp-app/src/main/java/com/devoops/event/QuestionCreateEvent.java index b64c92f1..c79faff4 100644 --- a/gss-mcp-app/src/main/java/com/devoops/event/QuestionCreateEvent.java +++ b/gss-mcp-app/src/main/java/com/devoops/event/QuestionCreateEvent.java @@ -13,7 +13,6 @@ public class QuestionCreateEvent extends ApplicationEvent { private final PullRequest initializedPullRequest; private final GithubToken token; - public QuestionCreateEvent( Object source, AppWebhookEventRequest request, diff --git a/gss-mcp-app/src/main/java/com/devoops/event/QuestionEventListener.java b/gss-mcp-app/src/main/java/com/devoops/event/QuestionEventListener.java index 595375db..d87b1b41 100644 --- a/gss-mcp-app/src/main/java/com/devoops/event/QuestionEventListener.java +++ b/gss-mcp-app/src/main/java/com/devoops/event/QuestionEventListener.java @@ -1,14 +1,13 @@ package com.devoops.event; -import com.devoops.adaptor.GithubAdaptor; -import com.devoops.adaptor.PrAnalysisAdapter; import com.devoops.command.request.QuestionCreateCommand; -import com.devoops.domain.entity.github.token.GithubToken; import com.devoops.domain.entity.github.pr.PullRequest; import com.devoops.domain.entity.github.question.Question; +import com.devoops.domain.entity.github.token.GithubToken; import com.devoops.dto.AppWebhookEventRequest; import com.devoops.dto.request.AdaptedAnalyzePrResponse; -import com.devoops.dto.response.AnalyzePrResponse; +import com.devoops.dto.response.PrAnalysis; +import com.devoops.service.pranalysis.PrAnalysisService; import com.devoops.service.pullrequest.PullRequestService; import com.devoops.service.question.QuestionService; import java.util.List; @@ -21,10 +20,9 @@ @RequiredArgsConstructor public class QuestionEventListener { - private final GithubAdaptor githubAdaptor; - private final PrAnalysisAdapter prAnalysisAdapter; private final PullRequestService pullRequestService; private final QuestionService questionService; + private final PrAnalysisService prAnalysisService; @Async @EventListener(QuestionCreateEvent.class) @@ -33,9 +31,7 @@ public void createQuestion(QuestionCreateEvent questionCreateEvent) { GithubToken githubToken = questionCreateEvent.getToken(); PullRequest readyPullRequest = questionCreateEvent.getInitializedPullRequest(); - String diff = githubAdaptor.getCodeChangeHistory(request.diffUrl(), githubToken.getToken()); - AdaptedAnalyzePrResponse adaptedAnalyzePrResponse = prAnalysisAdapter.analyze(request.title(), request.description(), diff); - + AdaptedAnalyzePrResponse adaptedAnalyzePrResponse = prAnalysisService.analyzePullRequest(request, githubToken); PullRequest updatedPullRequest = pullRequestService.updateAnalyzeResult( readyPullRequest.getId(), @@ -51,7 +47,7 @@ public void createQuestion(QuestionCreateEvent questionCreateEvent) { } private List createQuestionListFromCategorizedQuestions( - List questions, + List questions, Long pulLRequestId ) { return questions.stream() diff --git a/gss-mcp-app/src/main/java/com/devoops/service/pranalysis/PrAnalysisService.java b/gss-mcp-app/src/main/java/com/devoops/service/pranalysis/PrAnalysisService.java new file mode 100644 index 00000000..e6e9aa57 --- /dev/null +++ b/gss-mcp-app/src/main/java/com/devoops/service/pranalysis/PrAnalysisService.java @@ -0,0 +1,42 @@ +package com.devoops.service.pranalysis; + +import com.devoops.adaptor.GithubAdaptor; +import com.devoops.adaptor.PrAnalysisAdapter; +import com.devoops.domain.entity.analysis.AiCharge; +import com.devoops.domain.entity.analysis.OpenAiModel; +import com.devoops.domain.entity.github.token.GithubToken; +import com.devoops.domain.repository.analysis.AiChargeRepository; +import com.devoops.dto.AppWebhookEventRequest; +import com.devoops.dto.request.AdaptedAnalyzePrResponse; +import java.time.LocalDate; +import java.time.ZoneId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PrAnalysisService { + + private final GithubAdaptor githubAdaptor; + private final PrAnalysisAdapter prAnalysisAdapter; + private final AiChargeRepository chargeRepository; + + public AdaptedAnalyzePrResponse analyzePullRequest(AppWebhookEventRequest request, GithubToken githubToken) { + String diff = githubAdaptor.getCodeChangeHistory(request.diffUrl(), githubToken.getToken()); + ZoneId seoulZoneId = ZoneId.of("Asia/Seoul"); + LocalDate today = LocalDate.now(seoulZoneId); + AiCharge aiCharge = chargeRepository.getByYearAndMonth(today.getYear(), today.getMonthValue()); + OpenAiModel aiModel = OpenAiModel.getModelByUsage(aiCharge.getCharge()); + + AdaptedAnalyzePrResponse result = prAnalysisAdapter.analyze( + request.title(), + request.description(), + diff, + aiModel.getName() + ); + + double consumedCharge = aiModel.getCharge(result.promptTokens(), result.completionTokens()); + chargeRepository.addCharge(today.getYear(), today.getMonthValue(), consumedCharge); + return result; + } +} diff --git a/gss-mcp-app/src/main/java/com/devoops/service/webhook/WebhookFacadeService.java b/gss-mcp-app/src/main/java/com/devoops/service/webhook/WebhookFacadeService.java index fe1dbe5e..5cdcef04 100644 --- a/gss-mcp-app/src/main/java/com/devoops/service/webhook/WebhookFacadeService.java +++ b/gss-mcp-app/src/main/java/com/devoops/service/webhook/WebhookFacadeService.java @@ -2,7 +2,6 @@ import com.devoops.command.request.PullRequestCreateCommand; import com.devoops.domain.entity.github.repo.GithubRepository; -import com.devoops.domain.entity.github.token.GithubToken; import com.devoops.domain.entity.github.pr.PullRequest; import com.devoops.domain.entity.user.User; import com.devoops.domain.repository.github.repo.GithubRepoDomainRepository; @@ -10,7 +9,6 @@ import com.devoops.event.QuestionCreateEvent; import com.devoops.service.pullrequest.PullRequestService; import com.devoops.service.user.UserService; -import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; @@ -34,7 +32,7 @@ public void createQuestionWithWebhookEvent(AppWebhookEventRequest request) { log.info("request : {}", request); User triggerUser = userService.findByProviderId(request.userId()); - GithubRepository githubRepository = githubRepoDomainRepository.findByExternalIdAndUserId(request.repositoryId(), triggerUser.getId()); + GithubRepository githubRepository = githubRepoDomainRepository.getByExternalIdAndUserId(request.repositoryId(), triggerUser.getId()); if(githubRepository.isTracking()) { PullRequest readyPullRequest = savePullRequest( @@ -51,7 +49,7 @@ private PullRequest savePullRequest( AppWebhookEventRequest request ) { // 레포 아이디를 기반으로 찾기 -> 풀리퀘 생성 -> prCount 올리기 - GithubRepository githubRepository = githubRepoDomainRepository.findByExternalIdAndUserId(request.repositoryId(), userId); + GithubRepository githubRepository = githubRepoDomainRepository.getByExternalIdAndUserId(request.repositoryId(), userId); PullRequestCreateCommand prCreateCommand = resolvePRCreateCommand(request, githubRepository.getId(), userId); return pullRequestService.save(prCreateCommand); } diff --git a/gss-mcp-app/src/main/resources/application-local.yml b/gss-mcp-app/src/main/resources/application-local.yml index 97bef2fa..63100599 100644 --- a/gss-mcp-app/src/main/resources/application-local.yml +++ b/gss-mcp-app/src/main/resources/application-local.yml @@ -2,13 +2,13 @@ spring: config: activate: on-profile: local - datasource: - url: jdbc:mysql://localhost:3306/gss?useSSL=false&allowPublicKeyRetrieval=true - username: root - password: - jpa: - hibernate: - ddl-auto: create-drop - properties: - hibernate: - dialect: org.hibernate.dialect.MySQL8Dialect +# datasource: +# url: jdbc:mysql://localhost:3306/gss?useSSL=false&allowPublicKeyRetrieval=true +# username: root +# password: +# jpa: +# hibernate: +# ddl-auto: create-drop +# properties: +# hibernate: +# dialect: org.hibernate.dialect.MySQL8Dialect diff --git a/gss-mcp-app/src/test/java/com/devoops/PrAnalysisClientImplTest.java b/gss-mcp-app/src/test/java/com/devoops/PrAnalysisClientImplTest.java index eaaa0a35..ffb935dc 100644 --- a/gss-mcp-app/src/test/java/com/devoops/PrAnalysisClientImplTest.java +++ b/gss-mcp-app/src/test/java/com/devoops/PrAnalysisClientImplTest.java @@ -1,7 +1,13 @@ package com.devoops; import com.devoops.client.PrAnalysisClientImpl; -import com.devoops.dto.response.AnalyzePrResponse; +import com.devoops.dto.request.AnalyzePrRequest; +import com.devoops.dto.response.PrAnalysis; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -33,7 +39,12 @@ void analyzePr_shouldReturnSummaryAndQuestions() { + } """; - AnalyzePrResponse result = prAnalysisClient.analyze(title, desc, diff); + long startTime = System.currentTimeMillis(); + AnalyzePrRequest request = new AnalyzePrRequest(title, desc, diff, "gpt-5-nano"); + PrAnalysis result = prAnalysisClient.analyze(request).prAnalysis(); + long endTime = System.currentTimeMillis(); + + System.out.println(endTime - startTime+ "ms"); System.out.println("📝 요약: " + result.summary()); result.summaryDetails().forEach(q -> System.out.println("- " + q)); diff --git a/gss-mcp-app/src/test/java/com/devoops/fake/FakePrAnalysisClient.java b/gss-mcp-app/src/test/java/com/devoops/fake/FakePrAnalysisClient.java index d164db7b..b6b04f6f 100644 --- a/gss-mcp-app/src/test/java/com/devoops/fake/FakePrAnalysisClient.java +++ b/gss-mcp-app/src/test/java/com/devoops/fake/FakePrAnalysisClient.java @@ -1,7 +1,9 @@ package com.devoops.fake; import com.devoops.client.PrAnalysisClient; +import com.devoops.dto.request.AnalyzePrRequest; import com.devoops.dto.response.AnalyzePrResponse; +import com.devoops.dto.response.PrAnalysis; import java.util.Arrays; import java.util.List; import org.springframework.context.annotation.Profile; @@ -9,27 +11,27 @@ @Profile("test") public class FakePrAnalysisClient implements PrAnalysisClient { - public static AnalyzePrResponse MOCK_RESPONSE = new AnalyzePrResponse( + public static PrAnalysis MOCK_RESPONSE = new PrAnalysis( "이 PR은 사용자 인증 로직에 JWT 기반의 토큰 갱신 기능을 추가했습니다.", List.of( - new AnalyzePrResponse.SummaryDetails( + new PrAnalysis.SummaryDetails( "JWT 리프레시 토큰 기능 추가", "기존 로그인 로직에 리프레시 토큰 발급 및 재발급 기능을 추가하여, 사용자 인증 세션을 안전하게 유지할 수 있도록 개선했습니다." ), - new AnalyzePrResponse.SummaryDetails( + new PrAnalysis.SummaryDetails( "보안 강화 및 예외 처리 보완", "토큰 유효성 검사 과정에서 발생할 수 있는 다양한 예외를 핸들링하며, 불필요한 정보 노출을 막기 위한 응답 구조도 정비했습니다." ) ), List.of( - new AnalyzePrResponse.CategorizedQuestion( + new PrAnalysis.CategorizedQuestion( "보안", Arrays.asList( "JWT 토큰을 사용할 때 고려해야 할 보안 취약점은 무엇인가요?", "리프레시 토큰 저장 위치와 전달 방식에 따른 장단점은 무엇인가요?" ) ), - new AnalyzePrResponse.CategorizedQuestion( + new PrAnalysis.CategorizedQuestion( "유지보수성", Arrays.asList( "토큰 로직을 추상화하거나 모듈화할 때 고려해야 할 요소는 무엇인가요?", @@ -40,7 +42,12 @@ public class FakePrAnalysisClient implements PrAnalysisClient { ); @Override - public AnalyzePrResponse analyze(String title, String description, String diff) { - return MOCK_RESPONSE; + public AnalyzePrResponse analyze(AnalyzePrRequest request) { + return new AnalyzePrResponse( + 100, + 100, + 200, + MOCK_RESPONSE + ); } } diff --git a/gss-mcp-app/src/test/resources/application.yml b/gss-mcp-app/src/test/resources/application.yml index 908e450f..1b1a6522 100644 --- a/gss-mcp-app/src/test/resources/application.yml +++ b/gss-mcp-app/src/test/resources/application.yml @@ -30,53 +30,33 @@ spring: ai: openai: api-key: test - chat.options.model: testModel dev-oops: mcp: webhook-url: https://test.dev-oops.kr/api/webhooks github-pr-analysis: - prompt: | - 당신은 10년차 시니어 개발자로서 아래 PR의 변경 내용을 분석하고, 기술 면접에서 사용할 수 있는 질문을 생성해. - - PR 제목: %s - PR 내용: %s + system: | + 당신은 10년차 시니어 개발자로서 아래 PR의 변경 내용을 분석하고, 기술 면접에서 사용할 수 있는 질문을 생성해. + + 조건: + - "summary"는 이 PR이 어떤 핵심 작업을 했는지 1문장으로 요약한 것 + - "summaryDetail"은 변경 내용을 항목별로 요약한 제목(title) + 설명(description) 쌍으로 구성해. + - "category"는 기술적인 관점에서 PR 코드 변경 내용을 반영하여 선택해 (예: 성능, 보안, 확장성, 유지보수성, 테스트 등) + - "question"은 각 category에 대해 기술 면접에서 사용할 수 있는 질문이어야 해. + - 각 질문들은 반드시 PR 코드 변경 내용("diff")을 인용해서 생성해. + - "diff"를 굉장히 자세하게 분석하고 몇몇 질문에는 코드를 반영해서 만들어줘 + - 질문 수는 카테고리마다 3개 이상 만들어. + - 질문은 꼭 존댓말로 "?"로 끝나는 의문문이어야 해. + prompt: | + 당신은 숙련된 코드 리뷰어입니다. + + PR 제목: {title} + PR 내용: {description} + PR 코드 변경 내용: --- BEGIN DIFF --- - %s + {diff} --- END DIFF --- - - 다음과 같은 형식으로 결과를 출력해주세요(JSON으로 파싱할 것이니 JSON 형식에 맞춰 답하라): - - { - "summary": "이 PR은 어떤 핵심 작업을 했는지 1문장으로 기술하세요.", - "summaryDetails": [ - { - "title": "1. 기능 추가 또는 수정 요약 제목(자유롭게)", - "description": "무엇을 왜, 어떻게 했는지 간단한 설명" - }, - { - "title": "2. 코드 변경 내역(자유롭게)", - "description": "구현 시 고려한 조건, 설정, 성능 또는 안정성과 관련된 요소" - } - ], - "questions": [ - { - "category": "카테고리 이름 (예: 성능, 확장성, 보안 등 자유롭게)", - "question": [ - "해당 카테고리에 적절한 자유로운 질문 1", - "해당 카테고리에 적절한 자유로운 질문 2" - ] - } - ] - } - - 조건: - - "summary"는 핵심 내용을 요약한 1문장으로 작성합니다. - - "detailedSummary"는 변경 내용을 항목별로 요약한 제목 + 설명 쌍으로 구성합니다. - - "category"는 기술적인 관점에서 자유롭게 선택하세요. (예: 성능, 보안, 확장성, 유지보수성, 테스트 등) - - "question"은 각 category에 대해 기술 면접에서 사용할 수 있는 질문이어야 하며, 자유롭게 작성해 주세요. - - 질문 수는 카테고리마다 1개 이상 작성해도 좋습니다. - - 특정 기술에 종속되지 않은 범용적인 질문을 생성해주세요. - + + diff를 Base64에서 디코딩한 후 분석하고 PR 요약과 질문을 만들어 주세요.