diff --git a/gss-api-app/src/main/java/com/devoops/controller/PingController.java b/gss-api-app/src/main/java/com/devoops/controller/PingController.java index d32cd591..972334d3 100644 --- a/gss-api-app/src/main/java/com/devoops/controller/PingController.java +++ b/gss-api-app/src/main/java/com/devoops/controller/PingController.java @@ -10,6 +10,6 @@ public class PingController { @GetMapping("/api/ping") public ResponseEntity ping() { - return new ResponseEntity<>("pong", HttpStatus.OK); + return new ResponseEntity<>("pong2", HttpStatus.OK); } } diff --git a/gss-client/gss-mcp-client/build.gradle b/gss-client/gss-mcp-client/build.gradle index 4870f734..033157a4 100644 --- a/gss-client/gss-mcp-client/build.gradle +++ b/gss-client/gss-mcp-client/build.gradle @@ -8,4 +8,5 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter:1.0.0-M6' + implementation 'org.springframework.ai:spring-ai-anthropic-spring-boot-starter:1.0.0-M6' } diff --git a/gss-client/gss-mcp-client/src/main/java/com/devoops/McpClientType.java b/gss-client/gss-mcp-client/src/main/java/com/devoops/McpClientType.java new file mode 100644 index 00000000..80bf90e1 --- /dev/null +++ b/gss-client/gss-mcp-client/src/main/java/com/devoops/McpClientType.java @@ -0,0 +1,12 @@ +package com.devoops; + +public enum McpClientType { + + OPEN_AI, + CLAUDE, + ; + + public boolean isOpenAi() { + return this == OPEN_AI; + } +} 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 f587f868..0602ab24 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,5 +1,6 @@ package com.devoops.client; +import com.devoops.McpClientType; import com.devoops.dto.request.AnalyzePrRequest; import com.devoops.dto.response.AnalyzePrResponse; @@ -7,4 +8,5 @@ public interface PrAnalysisClient { AnalyzePrResponse analyze(AnalyzePrRequest request); + McpClientType getMcpClientType(); } 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 index 148c9e4c..7a0db16f 100644 --- 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 @@ -14,6 +14,9 @@ public class PromptBuilder { @Value("${dev-oops.github-pr-analysis.system}") private String systemPrompt; + @Value("${dev-oops.github-pr-analysis.format-message}") + private String formatMessage; + public String buildUserPrompt(String title, String description, String diff) { return promptTemplate .replace("{title}", title) @@ -25,6 +28,10 @@ public String buildSystemPrompt() { return systemPrompt; } + public String buildSystemPromptWithResponseFormat(String jsonSchema) { + return systemPrompt + "\n\n" + formatMessage.formatted(jsonSchema); + } + 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/client/claude/ClaudePrAnalysisClient.java b/gss-client/gss-mcp-client/src/main/java/com/devoops/client/claude/ClaudePrAnalysisClient.java new file mode 100644 index 00000000..7e684205 --- /dev/null +++ b/gss-client/gss-mcp-client/src/main/java/com/devoops/client/claude/ClaudePrAnalysisClient.java @@ -0,0 +1,87 @@ +package com.devoops.client.claude; + +import com.devoops.McpClientType; +import com.devoops.client.PrAnalysisClient; +import com.devoops.client.PromptBuilder; +import com.devoops.dto.request.AnalyzePrRequest; +import com.devoops.dto.response.AnalyzePrResponse; +import com.devoops.dto.response.PrAnalysis; +import com.devoops.serdes.PrAnalysisMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.anthropic.AnthropicChatModel; +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; + +@Slf4j +public class ClaudePrAnalysisClient implements PrAnalysisClient { + + private static final McpClientType CLIENT_VENDOR = McpClientType.CLAUDE; + + private final ChatClient chatClient; + private final PromptBuilder promptBuilder; + private final PrAnalysisMapper prAnalysisMapper; + + public ClaudePrAnalysisClient( + AnthropicChatModel anthropicChatModel, + PromptBuilder promptBuilder, + PrAnalysisMapper prAnalysisMapper + ) { + this.chatClient = ChatClient.create(anthropicChatModel); + this.promptBuilder = promptBuilder; + this.prAnalysisMapper = prAnalysisMapper; + } + + @Override + public AnalyzePrResponse analyze(AnalyzePrRequest request) { + //option 설정 + ChatOptions anthropicChatOptions = anthropicChatOptions(request.model()); + + ChatResponse chatresponse = callChatResponse( + request.title(), + request.description(), + request.codeDifference(), + anthropicChatOptions + ); + Usage usage = chatresponse.getMetadata().getUsage(); + String analysisResult = chatresponse.getResult().getOutput().getText(); + PrAnalysis prAnalysis = prAnalysisMapper.mapToPrAnalysis(analysisResult); + return new AnalyzePrResponse(usage, prAnalysis); + } + + @Override + public McpClientType getMcpClientType() { + return CLIENT_VENDOR; + } + + private ChatOptions anthropicChatOptions(String model) { + return ChatOptions.builder() + .temperature(0.7) + .model(model) + .maxTokens(64_000) + .build(); + } + + private ChatResponse callChatResponse( + String title, + String description, + String codeDifference, + ChatOptions options + ) { + String userPrompt = promptBuilder.buildUserPrompt(title, description, codeDifference); + String systemPrompt = promptBuilder.buildSystemPromptWithResponseFormat(outputJsonSchema()); + return chatClient.prompt() + .options(options) + .system(systemPrompt) + .user(userPrompt) + .call() + .chatResponse(); + } + + private String outputJsonSchema() { + BeanOutputConverter outputConverter = new BeanOutputConverter<>(PrAnalysis.class); + return outputConverter.getJsonSchema(); + } +} 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/openai/OpenAiPrAnalysisClient.java similarity index 75% rename from gss-client/gss-mcp-client/src/main/java/com/devoops/client/PrAnalysisClientImpl.java rename to gss-client/gss-mcp-client/src/main/java/com/devoops/client/openai/OpenAiPrAnalysisClient.java index 0f18fba6..3c942bda 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/openai/OpenAiPrAnalysisClient.java @@ -1,30 +1,46 @@ -package com.devoops.client; +package com.devoops.client.openai; +import com.devoops.McpClientType; +import com.devoops.client.PrAnalysisClient; +import com.devoops.client.PromptBuilder; import com.devoops.dto.request.AnalyzePrRequest; import com.devoops.dto.response.AnalyzePrResponse; import com.devoops.dto.response.PrAnalysis; import com.devoops.serdes.PrAnalysisMapper; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; 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.OpenAiChatModel; import org.springframework.ai.openai.OpenAiChatOptions; import org.springframework.ai.openai.api.ResponseFormat; import org.springframework.ai.openai.api.ResponseFormat.Type; +import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Component; +@Primary @Component -@RequiredArgsConstructor @Slf4j -public class PrAnalysisClientImpl implements PrAnalysisClient { +public class OpenAiPrAnalysisClient implements PrAnalysisClient { + + private static final McpClientType MCP_CLIENT_VENDOR = McpClientType.OPEN_AI; private final ChatClient chatClient; private final PromptBuilder promptBuilder; private final PrAnalysisMapper prAnalysisMapper; + public OpenAiPrAnalysisClient( + OpenAiChatModel openAiChatModel, + PromptBuilder promptBuilder, + PrAnalysisMapper prAnalysisMapper + ) { + this.chatClient = ChatClient.create(openAiChatModel); + this.promptBuilder = promptBuilder; + this.prAnalysisMapper = prAnalysisMapper; + } + @Override public AnalyzePrResponse analyze(AnalyzePrRequest request) { //option 설정 @@ -45,6 +61,11 @@ public AnalyzePrResponse analyze(AnalyzePrRequest request) { return new AnalyzePrResponse(usage, prAnalysis); } + @Override + public McpClientType getMcpClientType() { + return MCP_CLIENT_VENDOR; + } + private OpenAiChatOptions.Builder openAiChatBuilder() { return OpenAiChatOptions.builder() .responseFormat(new ResponseFormat(Type.JSON_SCHEMA, outputJsonSchema())) 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 deleted file mode 100644 index 6fc1e023..00000000 --- a/gss-client/gss-mcp-client/src/main/java/com/devoops/config/AiConfig.java +++ /dev/null @@ -1,15 +0,0 @@ -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/config/ClientConfig.java b/gss-client/gss-mcp-client/src/main/java/com/devoops/config/ClientConfig.java new file mode 100644 index 00000000..276c899d --- /dev/null +++ b/gss-client/gss-mcp-client/src/main/java/com/devoops/config/ClientConfig.java @@ -0,0 +1,38 @@ +package com.devoops.config; + +import com.devoops.client.PrAnalysisClient; +import com.devoops.client.PromptBuilder; +import com.devoops.client.claude.ClaudePrAnalysisClient; +import com.devoops.client.openai.OpenAiPrAnalysisClient; +import com.devoops.serdes.PrAnalysisMapper; +import org.springframework.ai.anthropic.AnthropicChatModel; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.annotation.Order; + +@Profile("!test") +@Configuration +public class ClientConfig { + + @Bean + @Order(1) + public PrAnalysisClient openAiClient( + OpenAiChatModel openAiChatModel, + PromptBuilder promptBuilder, + PrAnalysisMapper prAnalysisMapper + ) { + return new OpenAiPrAnalysisClient(openAiChatModel, promptBuilder, prAnalysisMapper); + } + + @Bean + @Order(2) + public PrAnalysisClient claudeClient( + AnthropicChatModel anthropicChatModel, + PromptBuilder promptBuilder, + PrAnalysisMapper prAnalysisMapper + ) { + return new ClaudePrAnalysisClient(anthropicChatModel, promptBuilder, prAnalysisMapper); + } +} 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 index 19af8516..96903df3 100644 --- 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 @@ -12,7 +12,7 @@ @Component public class PrAnalysisMapper { - private static final int MAX_LOGGING_LENGTH = 255; + private static final int MAX_LOGGING_LENGTH = 500; private static final ObjectMapper MAPPER = new ObjectMapper() .findAndRegisterModules() 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 ca3018c1..5fac40da 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 @@ -26,6 +26,12 @@ dev-oops: diff를 Base64에서 디코딩한 후 분석하고 PR 요약과 질문을 만들어 주세요. + format-message: | + 응답은 반드시 다음 JSON 스키마에 맞춰 정확한 JSON 형식으로 제공해주세요('''json도 제외):: + + %s + + 응답에는 JSON만 포함하고, 다른 텍스트나 설명은 포함하지 마세요. --- spring: @@ -35,6 +41,8 @@ spring: ai: openai: api-key: testKey + anthropic: + api-key: testKey2 --- 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 75740fad..ce93fb52 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 @@ -40,6 +40,7 @@ public enum ErrorCode { REDIS_SUBSCRIBE_ERROR(500, "레디스 이벤트 수신 과정에서 문제가 생겼습니다"), GITHUB_CLIENT_ERROR(500, "깃허브 클라이언트 소통과정에 문제가 발생했습니다"), AI_RESPONSE_PARSING_ERROR(500, "AI로부터 온 질문 생성을 파싱하는 과정에 오류가 발생했습니다"), + AI_CREATE_QUESTION_ERROR(500, "AI 질문 생성과정에 오류가 발생했습니다") ; private final int statusCode; diff --git a/gss-domain/src/main/java/com/devoops/domain/entity/analysis/AiModel.java b/gss-domain/src/main/java/com/devoops/domain/entity/analysis/AiModel.java new file mode 100644 index 00000000..aabec212 --- /dev/null +++ b/gss-domain/src/main/java/com/devoops/domain/entity/analysis/AiModel.java @@ -0,0 +1,8 @@ +package com.devoops.domain.entity.analysis; + +public interface AiModel { + + double getCharge(int promptToken, int completionTokens); + + String getName(); +} diff --git a/gss-domain/src/main/java/com/devoops/domain/entity/analysis/ClaudeAiModel.java b/gss-domain/src/main/java/com/devoops/domain/entity/analysis/ClaudeAiModel.java new file mode 100644 index 00000000..d203ee25 --- /dev/null +++ b/gss-domain/src/main/java/com/devoops/domain/entity/analysis/ClaudeAiModel.java @@ -0,0 +1,23 @@ +package com.devoops.domain.entity.analysis; + +import com.devoops.util.CurrencyUtil; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ClaudeAiModel implements AiModel { + + CLAUDE_SONNET_4("claude-sonnet-4-20250514", 0.000003, 0.000015), + ; + + private final String name; + private final double inputTokenCharge; //달러 + private final double outputTokenCharge; //달러 + + 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/analysis/OpenAiModel.java b/gss-domain/src/main/java/com/devoops/domain/entity/analysis/OpenAiModel.java index b3164256..12b4cf45 100644 --- 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 @@ -8,7 +8,7 @@ @Getter @RequiredArgsConstructor -public enum OpenAiModel { +public enum OpenAiModel implements AiModel { GPT_5(0, 7500, "gpt-5", 0.00000125, 0.00001), GPT_5_MINI(7501, 12500, "gpt-5-mini", 0.00000025, 0.000002), @@ -23,12 +23,19 @@ public enum OpenAiModel { public static OpenAiModel getModelByUsage(BigDecimal currentUsageWon) { return Stream.of(values()) - .filter(model -> model.moneyUnderCriteria <= currentUsageWon.doubleValue() - && model.moneyUpperCriteria >= currentUsageWon.doubleValue()) + .filter(model -> model.isBetween( + currentUsageWon.doubleValue(), + model.moneyUnderCriteria, + model.moneyUpperCriteria) + ) .findAny() .orElse(GPT_5_NANO); } + private boolean isBetween(double currentUsage, double min, double max) { + return currentUsage >= min && currentUsage <= max; + } + public double getCharge(int promptToken, int completionTokens) { double inputCharge = CurrencyUtil.usdToKrw(inputTokenCharge * promptToken); double outputCharge = CurrencyUtil.usdToKrw(outputTokenCharge * completionTokens); 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 f198df6f..b4138e15 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 @@ -17,8 +17,6 @@ public interface GithubRepoDomainRepository { List findByUserId(long userId); - boolean existsByExternalIdAndUserId(long externalId, long userId); - Optional findByExternalIdAndUserId(long externalId, long userId); GithubRepository getByExternalIdAndUserId(long externalId, long userId); 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 22ea1045..bfeefc6f 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 @@ -41,12 +41,6 @@ public boolean existsByIdAndUserId(long id, long userId) { return repoJpaRepository.existsByIdAndUserId(id, userId); } - @Override - @Transactional(readOnly = true) - public boolean existsByExternalIdAndUserId(long externalId, long userId) { - return repoJpaRepository.existsByGithubRepositoryIdAndUserId(externalId, userId); - } - @Override @Transactional(readOnly = true) public GithubRepository findByIdAndUserId(long id, long userId) { diff --git a/gss-domain/src/main/java/com/devoops/jpa/repository/github/repo/GithubRepoJpaRepository.java b/gss-domain/src/main/java/com/devoops/jpa/repository/github/repo/GithubRepoJpaRepository.java index f7cc064b..c726ced5 100644 --- a/gss-domain/src/main/java/com/devoops/jpa/repository/github/repo/GithubRepoJpaRepository.java +++ b/gss-domain/src/main/java/com/devoops/jpa/repository/github/repo/GithubRepoJpaRepository.java @@ -15,7 +15,5 @@ public interface GithubRepoJpaRepository extends JpaRepository findByGithubRepositoryId(long externalId); - Optional findByGithubRepositoryIdAndUserId(long externalId, long userId); } diff --git a/gss-mcp-app/src/main/java/com/devoops/adaptor/AiModelSelector.java b/gss-mcp-app/src/main/java/com/devoops/adaptor/AiModelSelector.java new file mode 100644 index 00000000..873a2983 --- /dev/null +++ b/gss-mcp-app/src/main/java/com/devoops/adaptor/AiModelSelector.java @@ -0,0 +1,19 @@ +package com.devoops.adaptor; + +import com.devoops.McpClientType; +import com.devoops.domain.entity.analysis.AiModel; +import com.devoops.domain.entity.analysis.ClaudeAiModel; +import com.devoops.domain.entity.analysis.OpenAiModel; +import java.math.BigDecimal; +import org.springframework.stereotype.Component; + +@Component +public class AiModelSelector { + + public AiModel getAiModel(McpClientType clientType, BigDecimal aiCharge) { + if(clientType.isOpenAi()) { + return OpenAiModel.getModelByUsage(aiCharge); + } + return ClaudeAiModel.CLAUDE_SONNET_4; + } +} 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 a2a612ae..0ae0c1af 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 @@ -1,21 +1,40 @@ package com.devoops.adaptor; +import com.devoops.McpClientType; import com.devoops.client.PrAnalysisClient; +import com.devoops.domain.entity.analysis.AiModel; +import com.devoops.domain.entity.analysis.OpenAiModel; import com.devoops.dto.request.AdaptedAnalyzePrResponse; import com.devoops.dto.request.AnalyzePrRequest; import com.devoops.dto.response.AnalyzePrResponse; +import com.devoops.exception.custom.GssException; +import com.devoops.exception.errorcode.ErrorCode; +import java.math.BigDecimal; +import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class PrAnalysisAdapter { - private final PrAnalysisClient prAnalysisClient; + private final List prAnalysisClients; + private final AiModelSelector aiModelSelector; - 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); + public AdaptedAnalyzePrResponse analyze(String title, String description, String diff, BigDecimal aiCharge) { + for (PrAnalysisClient prAnalysisClient : prAnalysisClients) { + try { + McpClientType mcpClientType = prAnalysisClient.getMcpClientType(); + AiModel aiModel = aiModelSelector.getAiModel(mcpClientType, aiCharge); + AnalyzePrRequest analyzePrRequest = new AnalyzePrRequest(title, description, diff, aiModel.getName()); + AnalyzePrResponse analyzePrResponse = prAnalysisClient.analyze(analyzePrRequest); + return new AdaptedAnalyzePrResponse(analyzePrResponse); + } catch (Exception e) { + log.error("client type {} 질문 생성 중 오류 발생 : {}", prAnalysisClient.getMcpClientType(), e.getMessage());; + } + } + throw new GssException(ErrorCode.AI_CREATE_QUESTION_ERROR); } } 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 index e6e9aa57..c1673e62 100644 --- 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 @@ -32,7 +32,7 @@ public AdaptedAnalyzePrResponse analyzePullRequest(AppWebhookEventRequest reques request.title(), request.description(), diff, - aiModel.getName() + aiCharge.getCharge() ); double consumedCharge = aiModel.getCharge(result.promptTokens(), result.completionTokens()); diff --git a/gss-mcp-app/src/test/java/com/devoops/BaseMcpTest.java b/gss-mcp-app/src/test/java/com/devoops/BaseMcpTest.java index a1dc176d..b9447e10 100644 --- a/gss-mcp-app/src/test/java/com/devoops/BaseMcpTest.java +++ b/gss-mcp-app/src/test/java/com/devoops/BaseMcpTest.java @@ -1,20 +1,28 @@ package com.devoops; -import com.devoops.config.TestConfig; +import static org.mockito.ArgumentMatchers.any; + +import com.devoops.client.claude.ClaudePrAnalysisClient; +import com.devoops.client.openai.OpenAiPrAnalysisClient; +import com.devoops.dto.request.AnalyzePrRequest; +import com.devoops.fixture.PrAnalysisFixture; import com.devoops.generator.AnswerGenerator; import com.devoops.generator.AnswerRankingGenerator; import com.devoops.generator.GithubRepoGenerator; import com.devoops.generator.PullRequestGenerator; import com.devoops.generator.QuestionGenerator; import com.devoops.generator.UserGenerator; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; @ExtendWith(DataBaseCleaner.class) -@Import({TestConfig.class, RedisTestConfiguration.class}) +@Import({RedisTestConfiguration.class}) @ActiveProfiles("test") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) public abstract class BaseMcpTest { @@ -36,4 +44,25 @@ public abstract class BaseMcpTest { @Autowired protected AnswerRankingGenerator answerRankingGenerator; + + @MockitoBean + protected OpenAiPrAnalysisClient openAiPrAnalysisClient; + + @MockitoBean + protected ClaudePrAnalysisClient claudePrAnalysisClient; + + @BeforeEach + void setUp() { + Mockito.doReturn(PrAnalysisFixture.MOCK_RESPONSE) + .when(openAiPrAnalysisClient).analyze(any(AnalyzePrRequest.class)); + + Mockito.doReturn(McpClientType.OPEN_AI) + .when(openAiPrAnalysisClient).getMcpClientType(); + + Mockito.doReturn(PrAnalysisFixture.MOCK_RESPONSE) + .when(claudePrAnalysisClient).analyze(any(AnalyzePrRequest.class)); + + Mockito.doReturn(McpClientType.CLAUDE) + .when(claudePrAnalysisClient).getMcpClientType(); + } } diff --git a/gss-mcp-app/src/test/java/com/devoops/OpenAiPrAnalysisClientTest.java b/gss-mcp-app/src/test/java/com/devoops/OpenAiPrAnalysisClientTest.java new file mode 100644 index 00000000..4798d36e --- /dev/null +++ b/gss-mcp-app/src/test/java/com/devoops/OpenAiPrAnalysisClientTest.java @@ -0,0 +1,83 @@ +package com.devoops; + +import com.devoops.client.claude.ClaudePrAnalysisClient; +import com.devoops.client.openai.OpenAiPrAnalysisClient; +import com.devoops.dto.request.AnalyzePrRequest; +import com.devoops.dto.response.PrAnalysis; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +@Disabled +class OpenAiPrAnalysisClientTest { + + @Autowired + private OpenAiPrAnalysisClient openAiPrAnalysisClient; + + @Autowired + private ClaudePrAnalysisClient claudePrAnalysisClient; + + @Test + void analyzePr_shouldReturnSummaryAndQuestions_openai() { + // given + String title = "회원가입 시 이메일 중복 체크 로직 추가"; + String desc = "회원가입 시 이메일 중복 체크 로직 추가입니다."; + String diff = """ + diff --git a/UserService.java b/UserService.java + + public boolean isEmailTaken(String email) { + + return userRepository.existsByEmail(email); + + } + + + + public void register(User user) { + + if (isEmailTaken(user.getEmail())) { + + throw new DuplicateEmailException(); + + } + + userRepository.save(user); + + } + """; + + long startTime = System.currentTimeMillis(); + AnalyzePrRequest request = new AnalyzePrRequest(title, desc, diff, "gpt-5-nano"); + PrAnalysis result = openAiPrAnalysisClient.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)); + result.questions().forEach(q -> System.out.println("- " + q)); + } + + @Test + void analyzePr_shouldReturnSummaryAndQuestions_claude() { + // given + String title = "회원가입 시 이메일 중복 체크 로직 추가"; + String desc = "회원가입 시 이메일 중복 체크 로직 추가입니다."; + String diff = """ + diff --git a/UserService.java b/UserService.java + + public boolean isEmailTaken(String email) { + + return userRepository.existsByEmail(email); + + } + + + + public void register(User user) { + + if (isEmailTaken(user.getEmail())) { + + throw new DuplicateEmailException(); + + } + + userRepository.save(user); + + } + """; + + long startTime = System.currentTimeMillis(); + AnalyzePrRequest request = new AnalyzePrRequest(title, desc, diff, "claude-sonnet-4-20250514"); + PrAnalysis result = claudePrAnalysisClient.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)); + result.questions().forEach(q -> System.out.println("- " + q)); + } +} diff --git a/gss-mcp-app/src/test/java/com/devoops/PrAnalysisClientImplTest.java b/gss-mcp-app/src/test/java/com/devoops/PrAnalysisClientImplTest.java deleted file mode 100644 index ffb935dc..00000000 --- a/gss-mcp-app/src/test/java/com/devoops/PrAnalysisClientImplTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.devoops; - -import com.devoops.client.PrAnalysisClientImpl; -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; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -@Disabled -class PrAnalysisClientImplTest { - - @Autowired - PrAnalysisClientImpl prAnalysisClient; - - @Test - void analyzePr_shouldReturnSummaryAndQuestions() { - // given - String title = "회원가입 시 이메일 중복 체크 로직 추가"; - String desc = "회원가입 시 이메일 중복 체크 로직 추가입니다."; - String diff = """ - diff --git a/UserService.java b/UserService.java - + public boolean isEmailTaken(String email) { - + return userRepository.existsByEmail(email); - + } - + - + public void register(User user) { - + if (isEmailTaken(user.getEmail())) { - + throw new DuplicateEmailException(); - + } - + userRepository.save(user); - + } - """; - - 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)); - result.questions().forEach(q -> System.out.println("- " + q)); - } -} diff --git a/gss-mcp-app/src/test/java/com/devoops/adaptor/PrAnalysisAdapterTest.java b/gss-mcp-app/src/test/java/com/devoops/adaptor/PrAnalysisAdapterTest.java new file mode 100644 index 00000000..6b013667 --- /dev/null +++ b/gss-mcp-app/src/test/java/com/devoops/adaptor/PrAnalysisAdapterTest.java @@ -0,0 +1,69 @@ +package com.devoops.adaptor; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; + +import com.devoops.BaseMcpTest; +import com.devoops.dto.request.AnalyzePrRequest; +import com.devoops.exception.custom.GssException; +import com.devoops.exception.errorcode.ErrorCode; +import java.math.BigDecimal; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; + +class PrAnalysisAdapterTest extends BaseMcpTest { + + @Autowired + private PrAnalysisAdapter prAnalysisAdapter; + + @Nested + class SelectClient { + + @Test + void openAi에게_최초_호출한다() { + prAnalysisAdapter.analyze("title", "description", "diff", BigDecimal.ZERO); + + assertAll( + () -> Mockito.verify(openAiPrAnalysisClient, times(1)).analyze(any(AnalyzePrRequest.class)), + () -> Mockito.verify(claudePrAnalysisClient, never()).analyze(any(AnalyzePrRequest.class)) + ); + } + + @Test + void openAi가_실패하면_claude에게_질문_생성을_호출한다() { + Mockito.when(openAiPrAnalysisClient.analyze(any(AnalyzePrRequest.class))) + .thenThrow(GssException.class); + + prAnalysisAdapter.analyze("title", "description", "diff", BigDecimal.ZERO); + + assertAll( + () -> Mockito.verify(openAiPrAnalysisClient, times(1)).analyze(any(AnalyzePrRequest.class)), + () -> Mockito.verify(claudePrAnalysisClient, times(1)).analyze(any(AnalyzePrRequest.class)) + ); + } + + @Test + void 모든_클라이언트_호출이_실패하면_에러가_발생한다() { + Mockito.when(openAiPrAnalysisClient.analyze(any(AnalyzePrRequest.class))) + .thenThrow(GssException.class); + + Mockito.when(claudePrAnalysisClient.analyze(any(AnalyzePrRequest.class))) + .thenThrow(GssException.class); + + assertAll( + () -> assertThatThrownBy( + () -> prAnalysisAdapter.analyze("title", "description", "diff", BigDecimal.ZERO) + ) + .isInstanceOf(GssException.class) + .hasMessage(ErrorCode.AI_CREATE_QUESTION_ERROR.getMessage()), + () -> Mockito.verify(openAiPrAnalysisClient, times(1)).analyze(any(AnalyzePrRequest.class)), + () -> Mockito.verify(claudePrAnalysisClient, times(1)).analyze(any(AnalyzePrRequest.class)) + ); + } + } +} diff --git a/gss-mcp-app/src/test/java/com/devoops/config/TestConfig.java b/gss-mcp-app/src/test/java/com/devoops/config/TestConfig.java deleted file mode 100644 index 8fc17c5b..00000000 --- a/gss-mcp-app/src/test/java/com/devoops/config/TestConfig.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.devoops.config; - - -import com.devoops.client.PrAnalysisClient; -import com.devoops.fake.FakePrAnalysisClient; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Primary; -import org.springframework.context.annotation.Profile; - -@Profile(("test")) -@TestConfiguration -public class TestConfig { - - @Bean - @Primary - public PrAnalysisClient prAnalysisClient() { - return new FakePrAnalysisClient(); - } -} 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 deleted file mode 100644 index b6b04f6f..00000000 --- a/gss-mcp-app/src/test/java/com/devoops/fake/FakePrAnalysisClient.java +++ /dev/null @@ -1,53 +0,0 @@ -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; - -@Profile("test") -public class FakePrAnalysisClient implements PrAnalysisClient { - - public static PrAnalysis MOCK_RESPONSE = new PrAnalysis( - "이 PR은 사용자 인증 로직에 JWT 기반의 토큰 갱신 기능을 추가했습니다.", - List.of( - new PrAnalysis.SummaryDetails( - "JWT 리프레시 토큰 기능 추가", - "기존 로그인 로직에 리프레시 토큰 발급 및 재발급 기능을 추가하여, 사용자 인증 세션을 안전하게 유지할 수 있도록 개선했습니다." - ), - new PrAnalysis.SummaryDetails( - "보안 강화 및 예외 처리 보완", - "토큰 유효성 검사 과정에서 발생할 수 있는 다양한 예외를 핸들링하며, 불필요한 정보 노출을 막기 위한 응답 구조도 정비했습니다." - ) - ), - List.of( - new PrAnalysis.CategorizedQuestion( - "보안", - Arrays.asList( - "JWT 토큰을 사용할 때 고려해야 할 보안 취약점은 무엇인가요?", - "리프레시 토큰 저장 위치와 전달 방식에 따른 장단점은 무엇인가요?" - ) - ), - new PrAnalysis.CategorizedQuestion( - "유지보수성", - Arrays.asList( - "토큰 로직을 추상화하거나 모듈화할 때 고려해야 할 요소는 무엇인가요?", - "예외 처리를 공통화할 때 코드의 가독성과 유지보수성을 어떻게 확보할 수 있나요?" - ) - ) - ) - ); - - @Override - public AnalyzePrResponse analyze(AnalyzePrRequest request) { - return new AnalyzePrResponse( - 100, - 100, - 200, - MOCK_RESPONSE - ); - } -} diff --git a/gss-mcp-app/src/test/java/com/devoops/fixture/PrAnalysisFixture.java b/gss-mcp-app/src/test/java/com/devoops/fixture/PrAnalysisFixture.java new file mode 100644 index 00000000..68124144 --- /dev/null +++ b/gss-mcp-app/src/test/java/com/devoops/fixture/PrAnalysisFixture.java @@ -0,0 +1,41 @@ +package com.devoops.fixture; + +import com.devoops.dto.response.AnalyzePrResponse; +import com.devoops.dto.response.PrAnalysis; +import java.util.Arrays; +import java.util.List; + +public class PrAnalysisFixture { + + public static AnalyzePrResponse MOCK_RESPONSE = new AnalyzePrResponse( + 10, 10, 20, + new PrAnalysis( + "이 PR은 사용자 인증 로직에 JWT 기반의 토큰 갱신 기능을 추가했습니다.", + List.of( + new PrAnalysis.SummaryDetails( + "JWT 리프레시 토큰 기능 추가", + "기존 로그인 로직에 리프레시 토큰 발급 및 재발급 기능을 추가하여, 사용자 인증 세션을 안전하게 유지할 수 있도록 개선했습니다." + ), + new PrAnalysis.SummaryDetails( + "보안 강화 및 예외 처리 보완", + "토큰 유효성 검사 과정에서 발생할 수 있는 다양한 예외를 핸들링하며, 불필요한 정보 노출을 막기 위한 응답 구조도 정비했습니다." + ) + ), + List.of( + new PrAnalysis.CategorizedQuestion( + "보안", + Arrays.asList( + "JWT 토큰을 사용할 때 고려해야 할 보안 취약점은 무엇인가요?", + "리프레시 토큰 저장 위치와 전달 방식에 따른 장단점은 무엇인가요?" + ) + ), + new PrAnalysis.CategorizedQuestion( + "유지보수성", + Arrays.asList( + "토큰 로직을 추상화하거나 모듈화할 때 고려해야 할 요소는 무엇인가요?", + "예외 처리를 공통화할 때 코드의 가독성과 유지보수성을 어떻게 확보할 수 있나요?" + ) + ) + ) + )); +} diff --git a/gss-mcp-app/src/test/resources/application.yml b/gss-mcp-app/src/test/resources/application.yml index 1b1a6522..99993be2 100644 --- a/gss-mcp-app/src/test/resources/application.yml +++ b/gss-mcp-app/src/test/resources/application.yml @@ -30,6 +30,8 @@ spring: ai: openai: api-key: test + anthropic: + api-key: test dev-oops: mcp: @@ -60,3 +62,10 @@ dev-oops: --- END DIFF --- diff를 Base64에서 디코딩한 후 분석하고 PR 요약과 질문을 만들어 주세요. + + format-message: | + 응답은 반드시 다음 JSON 스키마에 맞춰 정확한 JSON 형식으로 제공해주세요('''json도 제외): + + %s + + 응답에는 JSON만 포함하고, 다른 텍스트나 설명은 포함하지 마세요.